@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.
- package/CHANGELOG.md +50 -0
- package/dist/index.js +813 -718
- 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 +794 -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 +654 -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 +186 -1154
- package/src/review.service.ts +185 -535
- package/src/types/changed-file-collection.ts +5 -0
- package/src/types/review-source-resolver.ts +55 -0
|
@@ -1,10 +1,32 @@
|
|
|
1
|
-
import { vi
|
|
1
|
+
import { vi } from "vitest";
|
|
2
2
|
import { parseChangedLinesFromPatch } from "@spaceflow/core";
|
|
3
|
-
import {
|
|
4
|
-
import { ReviewService, ReviewContext, ReviewPrompt } from "./review.service";
|
|
3
|
+
import { ReviewService, ReviewContext } from "./review.service";
|
|
5
4
|
import type { ReviewOptions } from "./review.config";
|
|
6
5
|
import { PullRequestModel } from "./pull-request-model";
|
|
7
6
|
import { ReviewResultModel } from "./review-result-model";
|
|
7
|
+
import type { ReviewResult, ReviewIssue, FileSummary } from "./review-spec/types";
|
|
8
|
+
|
|
9
|
+
function mockResult(overrides: Partial<ReviewResult> = {}): ReviewResult {
|
|
10
|
+
return { success: true, description: "", issues: [], summary: [], round: 1, ...overrides };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function mockIssue(overrides: Partial<ReviewIssue> = {}): ReviewIssue {
|
|
14
|
+
return {
|
|
15
|
+
file: "",
|
|
16
|
+
line: "1",
|
|
17
|
+
code: "",
|
|
18
|
+
ruleId: "",
|
|
19
|
+
specFile: "",
|
|
20
|
+
reason: "",
|
|
21
|
+
severity: "error",
|
|
22
|
+
round: 1,
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function mockSummary(overrides: Partial<FileSummary> = {}): FileSummary {
|
|
28
|
+
return { file: "", resolved: 0, unresolved: 0, summary: "", ...overrides };
|
|
29
|
+
}
|
|
8
30
|
|
|
9
31
|
vi.mock("c12");
|
|
10
32
|
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
|
@@ -43,8 +65,49 @@ vi.mock("openai", () => {
|
|
|
43
65
|
};
|
|
44
66
|
});
|
|
45
67
|
|
|
68
|
+
class TestReviewService extends ReviewService {
|
|
69
|
+
// 暴露 protected 成员用于测试
|
|
70
|
+
get _contextBuilder() {
|
|
71
|
+
return this.contextBuilder;
|
|
72
|
+
}
|
|
73
|
+
get _issueFilter() {
|
|
74
|
+
return this.issueFilter;
|
|
75
|
+
}
|
|
76
|
+
get _llmProcessor() {
|
|
77
|
+
return this.llmProcessor;
|
|
78
|
+
}
|
|
79
|
+
get _resultModelDeps() {
|
|
80
|
+
return this.resultModelDeps;
|
|
81
|
+
}
|
|
82
|
+
get _config() {
|
|
83
|
+
return this.config;
|
|
84
|
+
}
|
|
85
|
+
get _reviewReportService() {
|
|
86
|
+
return this.reviewReportService;
|
|
87
|
+
}
|
|
88
|
+
get _llmProxyService() {
|
|
89
|
+
return this.llmProxyService;
|
|
90
|
+
}
|
|
91
|
+
get _sourceResolver() {
|
|
92
|
+
return this.sourceResolver;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
executeCollectOnly(context: Partial<ReviewContext>) {
|
|
96
|
+
return super.executeCollectOnly(context as ReviewContext);
|
|
97
|
+
}
|
|
98
|
+
executeDeletionOnly(context: Partial<ReviewContext>) {
|
|
99
|
+
return super.executeDeletionOnly(context as ReviewContext);
|
|
100
|
+
}
|
|
101
|
+
ensureClaudeCli(ci?: boolean) {
|
|
102
|
+
return super.ensureClaudeCli(ci);
|
|
103
|
+
}
|
|
104
|
+
resolveSourceData(context: Partial<ReviewContext>) {
|
|
105
|
+
return super.resolveSourceData(context as ReviewContext);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
46
109
|
describe("ReviewService", () => {
|
|
47
|
-
let service:
|
|
110
|
+
let service: TestReviewService;
|
|
48
111
|
let gitProvider: any;
|
|
49
112
|
let configService: any;
|
|
50
113
|
let mockReviewSpecService: any;
|
|
@@ -148,7 +211,7 @@ describe("ReviewService", () => {
|
|
|
148
211
|
getAvailableAdapters: vi.fn().mockReturnValue(["claude-code", "openai"]),
|
|
149
212
|
};
|
|
150
213
|
|
|
151
|
-
service = new
|
|
214
|
+
service = new TestReviewService(
|
|
152
215
|
gitProvider as any,
|
|
153
216
|
configService as any,
|
|
154
217
|
mockReviewSpecService as any,
|
|
@@ -216,12 +279,8 @@ describe("ReviewService", () => {
|
|
|
216
279
|
|
|
217
280
|
describe("ReviewService.execute", () => {
|
|
218
281
|
beforeEach(() => {
|
|
219
|
-
vi.spyOn(service
|
|
220
|
-
|
|
221
|
-
issues: [],
|
|
222
|
-
summary: [],
|
|
223
|
-
});
|
|
224
|
-
vi.spyOn(service as any, "getFileContents").mockResolvedValue(new Map());
|
|
282
|
+
vi.spyOn(service._llmProcessor, "runLLMReview").mockResolvedValue(mockResult());
|
|
283
|
+
vi.spyOn(service._sourceResolver, "getFileContents").mockResolvedValue(new Map());
|
|
225
284
|
vi.spyOn(ReviewResultModel, "loadFromPr").mockResolvedValue(null as any);
|
|
226
285
|
});
|
|
227
286
|
|
|
@@ -434,7 +493,7 @@ describe("ReviewService", () => {
|
|
|
434
493
|
llmMode: "openai",
|
|
435
494
|
whenModifiedCode: ["function", "class"],
|
|
436
495
|
};
|
|
437
|
-
const buildReviewPromptSpy = vi.spyOn(service
|
|
496
|
+
const buildReviewPromptSpy = vi.spyOn(service._llmProcessor, "buildReviewPrompt");
|
|
438
497
|
|
|
439
498
|
const result = await service.execute(context);
|
|
440
499
|
|
|
@@ -543,97 +602,7 @@ describe("ReviewService", () => {
|
|
|
543
602
|
});
|
|
544
603
|
});
|
|
545
604
|
|
|
546
|
-
describe("ReviewService.getPrNumberFromEvent", () => {
|
|
547
|
-
const originalEnv = process.env;
|
|
548
|
-
|
|
549
|
-
beforeEach(() => {
|
|
550
|
-
process.env = { ...originalEnv };
|
|
551
|
-
});
|
|
552
|
-
|
|
553
|
-
afterEach(() => {
|
|
554
|
-
process.env = originalEnv;
|
|
555
|
-
});
|
|
556
|
-
|
|
557
|
-
it("should return undefined if GITHUB_EVENT_PATH and GITEA_EVENT_PATH are not set", async () => {
|
|
558
|
-
delete process.env.GITHUB_EVENT_PATH;
|
|
559
|
-
delete process.env.GITEA_EVENT_PATH;
|
|
560
|
-
const prNumber = await (service as any).getPrNumberFromEvent();
|
|
561
|
-
expect(prNumber).toBeUndefined();
|
|
562
|
-
});
|
|
563
|
-
|
|
564
|
-
it("should parse prNumber from GITHUB_EVENT_PATH", async () => {
|
|
565
|
-
const mockEventPath = "/tmp/event.json";
|
|
566
|
-
process.env.GITHUB_EVENT_PATH = mockEventPath;
|
|
567
|
-
const mockEventContent = JSON.stringify({ pull_request: { number: 456 } });
|
|
568
|
-
|
|
569
|
-
(readFile as Mock).mockResolvedValue(mockEventContent);
|
|
570
|
-
|
|
571
|
-
const prNumber = await (service as any).getPrNumberFromEvent();
|
|
572
|
-
expect(prNumber).toBe(456);
|
|
573
|
-
});
|
|
574
|
-
|
|
575
|
-
it("should parse prNumber from GITEA_EVENT_PATH when GITHUB_EVENT_PATH is not set", async () => {
|
|
576
|
-
delete process.env.GITHUB_EVENT_PATH;
|
|
577
|
-
const mockEventPath = "/tmp/gitea-event.json";
|
|
578
|
-
process.env.GITEA_EVENT_PATH = mockEventPath;
|
|
579
|
-
const mockEventContent = JSON.stringify({ pull_request: { number: 789 } });
|
|
580
|
-
|
|
581
|
-
(readFile as Mock).mockResolvedValue(mockEventContent);
|
|
582
|
-
|
|
583
|
-
const prNumber = await (service as any).getPrNumberFromEvent();
|
|
584
|
-
expect(prNumber).toBe(789);
|
|
585
|
-
});
|
|
586
|
-
});
|
|
587
|
-
|
|
588
|
-
describe("ReviewService.runLLMReview", () => {
|
|
589
|
-
it("should call callLLM when llmMode is claude", async () => {
|
|
590
|
-
const callLLMSpy = vi
|
|
591
|
-
.spyOn((service as any).llmProcessor, "callLLM")
|
|
592
|
-
.mockResolvedValue({ issues: [], summary: [] });
|
|
593
|
-
|
|
594
|
-
const mockPrompt: ReviewPrompt = {
|
|
595
|
-
filePrompts: [{ filename: "test.ts", systemPrompt: "system", userPrompt: "user" }],
|
|
596
|
-
};
|
|
597
|
-
|
|
598
|
-
await (service as any).runLLMReview("claude-code", mockPrompt);
|
|
599
|
-
|
|
600
|
-
expect(callLLMSpy).toHaveBeenCalledWith("claude-code", mockPrompt, {});
|
|
601
|
-
});
|
|
602
|
-
|
|
603
|
-
it("should call callLLM when llmMode is openai", async () => {
|
|
604
|
-
const callLLMSpy = vi
|
|
605
|
-
.spyOn((service as any).llmProcessor, "callLLM")
|
|
606
|
-
.mockResolvedValue({ issues: [], summary: [] });
|
|
607
|
-
|
|
608
|
-
const mockPrompt: ReviewPrompt = {
|
|
609
|
-
filePrompts: [{ filename: "test.ts", systemPrompt: "system", userPrompt: "user" }],
|
|
610
|
-
};
|
|
611
|
-
|
|
612
|
-
await (service as any).runLLMReview("openai", mockPrompt);
|
|
613
|
-
|
|
614
|
-
expect(callLLMSpy).toHaveBeenCalledWith("openai", mockPrompt, {});
|
|
615
|
-
});
|
|
616
|
-
});
|
|
617
|
-
|
|
618
605
|
describe("ReviewService Logic", () => {
|
|
619
|
-
it("normalizeIssues should split comma separated lines", () => {
|
|
620
|
-
const issues = [
|
|
621
|
-
{
|
|
622
|
-
file: "test.ts",
|
|
623
|
-
line: "10, 12",
|
|
624
|
-
ruleId: "R1",
|
|
625
|
-
specFile: "s1.md",
|
|
626
|
-
reason: "r1",
|
|
627
|
-
suggestion: "fix",
|
|
628
|
-
} as any,
|
|
629
|
-
];
|
|
630
|
-
const normalized = (service as any).llmProcessor.normalizeIssues(issues);
|
|
631
|
-
expect(normalized).toHaveLength(2);
|
|
632
|
-
expect(normalized[0].line).toBe("10");
|
|
633
|
-
expect(normalized[1].line).toBe("12");
|
|
634
|
-
expect(normalized[1].suggestion).toContain("参考 test.ts:10");
|
|
635
|
-
});
|
|
636
|
-
|
|
637
606
|
it("parseChangedLinesFromPatch should correctly parse additions", () => {
|
|
638
607
|
const patch = `@@ -1,3 +1,4 @@
|
|
639
608
|
line1
|
|
@@ -646,558 +615,25 @@ describe("ReviewService", () => {
|
|
|
646
615
|
});
|
|
647
616
|
});
|
|
648
617
|
|
|
649
|
-
describe("ReviewService.getFileContents", () => {
|
|
650
|
-
beforeEach(() => {
|
|
651
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockImplementation((lineStr: string) => {
|
|
652
|
-
const lines: number[] = [];
|
|
653
|
-
const rangeMatch = lineStr.match(/^(\d+)-(\d+)$/);
|
|
654
|
-
if (rangeMatch) {
|
|
655
|
-
const start = parseInt(rangeMatch[1], 10);
|
|
656
|
-
const end = parseInt(rangeMatch[2], 10);
|
|
657
|
-
for (let i = start; i <= end; i++) {
|
|
658
|
-
lines.push(i);
|
|
659
|
-
}
|
|
660
|
-
} else {
|
|
661
|
-
const line = parseInt(lineStr, 10);
|
|
662
|
-
if (!isNaN(line)) {
|
|
663
|
-
lines.push(line);
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
return lines;
|
|
667
|
-
});
|
|
668
|
-
});
|
|
669
|
-
|
|
670
|
-
it("should mark changed lines with commit hash from PR patch", async () => {
|
|
671
|
-
const changedFiles = [
|
|
672
|
-
{
|
|
673
|
-
filename: "test.ts",
|
|
674
|
-
status: "modified",
|
|
675
|
-
patch: `@@ -1,3 +1,5 @@
|
|
676
|
-
line1
|
|
677
|
-
+new line 1
|
|
678
|
-
+new line 2
|
|
679
|
-
line2
|
|
680
|
-
line3`,
|
|
681
|
-
},
|
|
682
|
-
];
|
|
683
|
-
const commits = [{ sha: "abc1234567890" }];
|
|
684
|
-
|
|
685
|
-
gitProvider.getFileContent.mockResolvedValue("line1\nnew line 1\nnew line 2\nline2\nline3");
|
|
686
|
-
|
|
687
|
-
const result = await (service as any).getFileContents(
|
|
688
|
-
"owner",
|
|
689
|
-
"repo",
|
|
690
|
-
changedFiles,
|
|
691
|
-
commits,
|
|
692
|
-
"abc1234",
|
|
693
|
-
123,
|
|
694
|
-
);
|
|
695
|
-
|
|
696
|
-
expect(result.size).toBe(1);
|
|
697
|
-
const fileContent = result.get("test.ts");
|
|
698
|
-
expect(fileContent).toBeDefined();
|
|
699
|
-
// 第 1 行未变更
|
|
700
|
-
expect(fileContent[0][0]).toBe("-------");
|
|
701
|
-
// 第 2、3 行是新增的
|
|
702
|
-
expect(fileContent[1][0]).toBe("abc1234");
|
|
703
|
-
expect(fileContent[2][0]).toBe("abc1234");
|
|
704
|
-
// 第 4、5 行未变更
|
|
705
|
-
expect(fileContent[3][0]).toBe("-------");
|
|
706
|
-
expect(fileContent[4][0]).toBe("-------");
|
|
707
|
-
});
|
|
708
|
-
|
|
709
|
-
it("should handle files without patch (all lines unmarked)", async () => {
|
|
710
|
-
const changedFiles = [
|
|
711
|
-
{
|
|
712
|
-
filename: "test.ts",
|
|
713
|
-
status: "modified",
|
|
714
|
-
// 没有 patch 字段
|
|
715
|
-
},
|
|
716
|
-
];
|
|
717
|
-
const commits = [{ sha: "abc1234567890" }];
|
|
718
|
-
|
|
719
|
-
gitProvider.getFileContent.mockResolvedValue("line1\nline2\nline3");
|
|
720
|
-
|
|
721
|
-
const result = await (service as any).getFileContents(
|
|
722
|
-
"owner",
|
|
723
|
-
"repo",
|
|
724
|
-
changedFiles,
|
|
725
|
-
commits,
|
|
726
|
-
"abc1234",
|
|
727
|
-
123,
|
|
728
|
-
);
|
|
729
|
-
|
|
730
|
-
const fileContent = result.get("test.ts");
|
|
731
|
-
expect(fileContent).toBeDefined();
|
|
732
|
-
// 所有行都未标记变更
|
|
733
|
-
expect(fileContent[0][0]).toBe("-------");
|
|
734
|
-
expect(fileContent[1][0]).toBe("-------");
|
|
735
|
-
expect(fileContent[2][0]).toBe("-------");
|
|
736
|
-
});
|
|
737
|
-
|
|
738
|
-
it("should mark all lines as changed for added files without patch", async () => {
|
|
739
|
-
const changedFiles = [
|
|
740
|
-
{
|
|
741
|
-
filename: "new-file.ts",
|
|
742
|
-
status: "added",
|
|
743
|
-
additions: 3,
|
|
744
|
-
deletions: 0,
|
|
745
|
-
// 没有 patch 字段(Gitea API 可能不返回新增文件的完整 patch)
|
|
746
|
-
},
|
|
747
|
-
];
|
|
748
|
-
const commits = [{ sha: "abc1234567890" }];
|
|
749
|
-
|
|
750
|
-
gitProvider.getFileContent.mockResolvedValue("line1\nline2\nline3");
|
|
751
|
-
|
|
752
|
-
const result = await (service as any).getFileContents(
|
|
753
|
-
"owner",
|
|
754
|
-
"repo",
|
|
755
|
-
changedFiles,
|
|
756
|
-
commits,
|
|
757
|
-
"abc1234",
|
|
758
|
-
123,
|
|
759
|
-
);
|
|
760
|
-
|
|
761
|
-
const fileContent = result.get("new-file.ts");
|
|
762
|
-
expect(fileContent).toBeDefined();
|
|
763
|
-
// 新增文件的所有行都应该标记为变更
|
|
764
|
-
expect(fileContent[0][0]).toBe("abc1234");
|
|
765
|
-
expect(fileContent[1][0]).toBe("abc1234");
|
|
766
|
-
expect(fileContent[2][0]).toBe("abc1234");
|
|
767
|
-
});
|
|
768
|
-
|
|
769
|
-
it("should skip deleted files", async () => {
|
|
770
|
-
const changedFiles = [
|
|
771
|
-
{
|
|
772
|
-
filename: "deleted.ts",
|
|
773
|
-
status: "deleted",
|
|
774
|
-
},
|
|
775
|
-
];
|
|
776
|
-
const commits = [{ sha: "abc1234567890" }];
|
|
777
|
-
|
|
778
|
-
const result = await (service as any).getFileContents(
|
|
779
|
-
"owner",
|
|
780
|
-
"repo",
|
|
781
|
-
changedFiles,
|
|
782
|
-
commits,
|
|
783
|
-
"abc1234",
|
|
784
|
-
123,
|
|
785
|
-
);
|
|
786
|
-
|
|
787
|
-
expect(result.size).toBe(0);
|
|
788
|
-
});
|
|
789
|
-
});
|
|
790
|
-
|
|
791
|
-
describe("ReviewService.getChangedFilesBetweenRefs", () => {
|
|
792
|
-
it("should return files with patch from getDiffBetweenRefs", async () => {
|
|
793
|
-
const diffFiles = [
|
|
794
|
-
{
|
|
795
|
-
filename: "test.ts",
|
|
796
|
-
patch: "@@ -1,3 +1,5 @@\n line1\n+new line",
|
|
797
|
-
},
|
|
798
|
-
];
|
|
799
|
-
const statusFiles = [
|
|
800
|
-
{
|
|
801
|
-
filename: "test.ts",
|
|
802
|
-
status: "modified",
|
|
803
|
-
},
|
|
804
|
-
];
|
|
805
|
-
|
|
806
|
-
mockGitSdkService.getDiffBetweenRefs.mockResolvedValue(diffFiles);
|
|
807
|
-
mockGitSdkService.getChangedFilesBetweenRefs.mockResolvedValue(statusFiles);
|
|
808
|
-
|
|
809
|
-
const result = await (service as any).getChangedFilesBetweenRefs(
|
|
810
|
-
"owner",
|
|
811
|
-
"repo",
|
|
812
|
-
"main",
|
|
813
|
-
"feature",
|
|
814
|
-
);
|
|
815
|
-
|
|
816
|
-
expect(result).toHaveLength(1);
|
|
817
|
-
expect(result[0].filename).toBe("test.ts");
|
|
818
|
-
expect(result[0].status).toBe("modified");
|
|
819
|
-
expect(result[0].patch).toBe("@@ -1,3 +1,5 @@\n line1\n+new line");
|
|
820
|
-
});
|
|
821
|
-
|
|
822
|
-
it("should use default status when file not in statusFiles", async () => {
|
|
823
|
-
const diffFiles = [
|
|
824
|
-
{
|
|
825
|
-
filename: "new.ts",
|
|
826
|
-
patch: "@@ -0,0 +1,3 @@\n+line1",
|
|
827
|
-
},
|
|
828
|
-
];
|
|
829
|
-
const statusFiles: any[] = [];
|
|
830
|
-
|
|
831
|
-
mockGitSdkService.getDiffBetweenRefs.mockResolvedValue(diffFiles);
|
|
832
|
-
mockGitSdkService.getChangedFilesBetweenRefs.mockResolvedValue(statusFiles);
|
|
833
|
-
|
|
834
|
-
const result = await (service as any).getChangedFilesBetweenRefs(
|
|
835
|
-
"owner",
|
|
836
|
-
"repo",
|
|
837
|
-
"main",
|
|
838
|
-
"feature",
|
|
839
|
-
);
|
|
840
|
-
|
|
841
|
-
expect(result).toHaveLength(1);
|
|
842
|
-
expect(result[0].filename).toBe("new.ts");
|
|
843
|
-
expect(result[0].status).toBe("modified");
|
|
844
|
-
});
|
|
845
|
-
});
|
|
846
|
-
|
|
847
|
-
describe("ReviewService.filterIssuesByValidCommits", () => {
|
|
848
|
-
beforeEach(() => {
|
|
849
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockImplementation((lineStr: string) => {
|
|
850
|
-
const lines: number[] = [];
|
|
851
|
-
const rangeMatch = lineStr.match(/^(\d+)-(\d+)$/);
|
|
852
|
-
if (rangeMatch) {
|
|
853
|
-
const start = parseInt(rangeMatch[1], 10);
|
|
854
|
-
const end = parseInt(rangeMatch[2], 10);
|
|
855
|
-
for (let i = start; i <= end; i++) {
|
|
856
|
-
lines.push(i);
|
|
857
|
-
}
|
|
858
|
-
} else {
|
|
859
|
-
const line = parseInt(lineStr, 10);
|
|
860
|
-
if (!isNaN(line)) {
|
|
861
|
-
lines.push(line);
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
return lines;
|
|
865
|
-
});
|
|
866
|
-
});
|
|
867
|
-
|
|
868
|
-
it("should filter out issues on non-changed lines", () => {
|
|
869
|
-
const issues = [
|
|
870
|
-
{
|
|
871
|
-
file: "test.ts",
|
|
872
|
-
line: "1",
|
|
873
|
-
ruleId: "R1",
|
|
874
|
-
specFile: "s1.md",
|
|
875
|
-
reason: "issue on unchanged line",
|
|
876
|
-
},
|
|
877
|
-
{
|
|
878
|
-
file: "test.ts",
|
|
879
|
-
line: "2",
|
|
880
|
-
ruleId: "R2",
|
|
881
|
-
specFile: "s1.md",
|
|
882
|
-
reason: "issue on changed line",
|
|
883
|
-
},
|
|
884
|
-
];
|
|
885
|
-
const commits = [{ sha: "abc1234567890" }];
|
|
886
|
-
const fileContents = new Map([
|
|
887
|
-
[
|
|
888
|
-
"test.ts",
|
|
889
|
-
[
|
|
890
|
-
["-------", "line1"],
|
|
891
|
-
["abc1234", "new line"],
|
|
892
|
-
["-------", "line2"],
|
|
893
|
-
],
|
|
894
|
-
],
|
|
895
|
-
]);
|
|
896
|
-
|
|
897
|
-
const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents);
|
|
898
|
-
|
|
899
|
-
expect(result).toHaveLength(1);
|
|
900
|
-
expect(result[0].line).toBe("2");
|
|
901
|
-
});
|
|
902
|
-
|
|
903
|
-
it("should keep issues when file not in fileContents", () => {
|
|
904
|
-
const issues = [
|
|
905
|
-
{ file: "unknown.ts", line: "1", ruleId: "R1", specFile: "s1.md", reason: "issue" },
|
|
906
|
-
];
|
|
907
|
-
const commits = [{ sha: "abc1234567890" }];
|
|
908
|
-
const fileContents = new Map();
|
|
909
|
-
|
|
910
|
-
const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents);
|
|
911
|
-
|
|
912
|
-
expect(result).toHaveLength(1);
|
|
913
|
-
});
|
|
914
|
-
|
|
915
|
-
it("should keep issues with range if any line is changed", () => {
|
|
916
|
-
const issues = [
|
|
917
|
-
{ file: "test.ts", line: "1-3", ruleId: "R1", specFile: "s1.md", reason: "range issue" },
|
|
918
|
-
];
|
|
919
|
-
const commits = [{ sha: "abc1234567890" }];
|
|
920
|
-
const fileContents = new Map([
|
|
921
|
-
[
|
|
922
|
-
"test.ts",
|
|
923
|
-
[
|
|
924
|
-
["-------", "line1"],
|
|
925
|
-
["abc1234", "new line"],
|
|
926
|
-
["-------", "line3"],
|
|
927
|
-
],
|
|
928
|
-
],
|
|
929
|
-
]);
|
|
930
|
-
|
|
931
|
-
const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents);
|
|
932
|
-
|
|
933
|
-
expect(result).toHaveLength(1);
|
|
934
|
-
});
|
|
935
|
-
|
|
936
|
-
it("should filter out issues with range if no line is changed", () => {
|
|
937
|
-
const issues = [
|
|
938
|
-
{ file: "test.ts", line: "1-3", ruleId: "R1", specFile: "s1.md", reason: "range issue" },
|
|
939
|
-
];
|
|
940
|
-
const commits = [{ sha: "abc1234567890" }];
|
|
941
|
-
const fileContents = new Map([
|
|
942
|
-
[
|
|
943
|
-
"test.ts",
|
|
944
|
-
[
|
|
945
|
-
["-------", "line1"],
|
|
946
|
-
["-------", "line2"],
|
|
947
|
-
["-------", "line3"],
|
|
948
|
-
],
|
|
949
|
-
],
|
|
950
|
-
]);
|
|
951
|
-
|
|
952
|
-
const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents);
|
|
953
|
-
|
|
954
|
-
expect(result).toHaveLength(0);
|
|
955
|
-
});
|
|
956
|
-
});
|
|
957
|
-
|
|
958
|
-
describe("ReviewService.resolveAnalyzeDeletions", () => {
|
|
959
|
-
it("should return boolean directly", () => {
|
|
960
|
-
expect(
|
|
961
|
-
(service as any).resolveAnalyzeDeletions(true, { ci: false, hasPrNumber: false }),
|
|
962
|
-
).toBe(true);
|
|
963
|
-
expect((service as any).resolveAnalyzeDeletions(false, { ci: true, hasPrNumber: true })).toBe(
|
|
964
|
-
false,
|
|
965
|
-
);
|
|
966
|
-
});
|
|
967
|
-
|
|
968
|
-
it("should resolve 'ci' mode", () => {
|
|
969
|
-
expect((service as any).resolveAnalyzeDeletions("ci", { ci: true, hasPrNumber: false })).toBe(
|
|
970
|
-
true,
|
|
971
|
-
);
|
|
972
|
-
expect(
|
|
973
|
-
(service as any).resolveAnalyzeDeletions("ci", { ci: false, hasPrNumber: false }),
|
|
974
|
-
).toBe(false);
|
|
975
|
-
});
|
|
976
|
-
|
|
977
|
-
it("should resolve 'pr' mode", () => {
|
|
978
|
-
expect((service as any).resolveAnalyzeDeletions("pr", { ci: false, hasPrNumber: true })).toBe(
|
|
979
|
-
true,
|
|
980
|
-
);
|
|
981
|
-
expect(
|
|
982
|
-
(service as any).resolveAnalyzeDeletions("pr", { ci: false, hasPrNumber: false }),
|
|
983
|
-
).toBe(false);
|
|
984
|
-
});
|
|
985
|
-
|
|
986
|
-
it("should resolve 'terminal' mode", () => {
|
|
987
|
-
expect(
|
|
988
|
-
(service as any).resolveAnalyzeDeletions("terminal", { ci: false, hasPrNumber: false }),
|
|
989
|
-
).toBe(true);
|
|
990
|
-
expect(
|
|
991
|
-
(service as any).resolveAnalyzeDeletions("terminal", { ci: true, hasPrNumber: false }),
|
|
992
|
-
).toBe(false);
|
|
993
|
-
});
|
|
994
|
-
|
|
995
|
-
it("should return false for unknown mode", () => {
|
|
996
|
-
expect(
|
|
997
|
-
(service as any).resolveAnalyzeDeletions("unknown", { ci: false, hasPrNumber: false }),
|
|
998
|
-
).toBe(false);
|
|
999
|
-
});
|
|
1000
|
-
});
|
|
1001
|
-
|
|
1002
|
-
describe("ReviewService.filterDuplicateIssues", () => {
|
|
1003
|
-
it("should filter issues that exist in valid existing issues", () => {
|
|
1004
|
-
const newIssues = [
|
|
1005
|
-
{ file: "a.ts", line: "1", ruleId: "R1" },
|
|
1006
|
-
{ file: "b.ts", line: "2", ruleId: "R2" },
|
|
1007
|
-
];
|
|
1008
|
-
const existingIssues = [{ file: "a.ts", line: "1", ruleId: "R1", valid: "true" }];
|
|
1009
|
-
const result = (service as any).filterDuplicateIssues(newIssues, existingIssues);
|
|
1010
|
-
expect(result.filteredIssues).toHaveLength(1);
|
|
1011
|
-
expect(result.filteredIssues[0].file).toBe("b.ts");
|
|
1012
|
-
expect(result.skippedCount).toBe(1);
|
|
1013
|
-
});
|
|
1014
|
-
|
|
1015
|
-
it("should also filter invalid existing issues to prevent repeated reporting", () => {
|
|
1016
|
-
const newIssues = [{ file: "a.ts", line: "1", ruleId: "R1" }];
|
|
1017
|
-
const existingIssues = [{ file: "a.ts", line: "1", ruleId: "R1", valid: "false" }];
|
|
1018
|
-
const result = (service as any).filterDuplicateIssues(newIssues, existingIssues);
|
|
1019
|
-
expect(result.filteredIssues).toHaveLength(0);
|
|
1020
|
-
expect(result.skippedCount).toBe(1);
|
|
1021
|
-
});
|
|
1022
|
-
});
|
|
1023
|
-
|
|
1024
|
-
describe("ReviewService.normalizeFilePaths", () => {
|
|
1025
|
-
it("should return undefined for empty array", () => {
|
|
1026
|
-
expect((service as any).normalizeFilePaths([])).toEqual([]);
|
|
1027
|
-
});
|
|
1028
|
-
|
|
1029
|
-
it("should return undefined for undefined input", () => {
|
|
1030
|
-
expect((service as any).normalizeFilePaths(undefined)).toBeUndefined();
|
|
1031
|
-
});
|
|
1032
|
-
|
|
1033
|
-
it("should keep relative paths as-is", () => {
|
|
1034
|
-
const result = (service as any).normalizeFilePaths(["src/app.ts", "lib/util.ts"]);
|
|
1035
|
-
expect(result).toEqual(["src/app.ts", "lib/util.ts"]);
|
|
1036
|
-
});
|
|
1037
|
-
});
|
|
1038
|
-
|
|
1039
|
-
describe("ReviewService.fillIssueCode", () => {
|
|
1040
|
-
beforeEach(() => {
|
|
1041
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockImplementation((lineStr: string) => {
|
|
1042
|
-
const lines: number[] = [];
|
|
1043
|
-
const rangeMatch = lineStr.match(/^(\d+)-(\d+)$/);
|
|
1044
|
-
if (rangeMatch) {
|
|
1045
|
-
const start = parseInt(rangeMatch[1], 10);
|
|
1046
|
-
const end = parseInt(rangeMatch[2], 10);
|
|
1047
|
-
for (let i = start; i <= end; i++) {
|
|
1048
|
-
lines.push(i);
|
|
1049
|
-
}
|
|
1050
|
-
} else {
|
|
1051
|
-
const line = parseInt(lineStr, 10);
|
|
1052
|
-
if (!isNaN(line)) {
|
|
1053
|
-
lines.push(line);
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
return lines;
|
|
1057
|
-
});
|
|
1058
|
-
});
|
|
1059
|
-
|
|
1060
|
-
it("should fill code from file contents", async () => {
|
|
1061
|
-
const issues = [{ file: "test.ts", line: "2" }];
|
|
1062
|
-
const fileContents = new Map([
|
|
1063
|
-
[
|
|
1064
|
-
"test.ts",
|
|
1065
|
-
[
|
|
1066
|
-
["-------", "line1"],
|
|
1067
|
-
["abc1234", "line2"],
|
|
1068
|
-
["-------", "line3"],
|
|
1069
|
-
],
|
|
1070
|
-
],
|
|
1071
|
-
]);
|
|
1072
|
-
const result = await (service as any).fillIssueCode(issues, fileContents);
|
|
1073
|
-
expect(result[0].code).toBe("line2");
|
|
1074
|
-
});
|
|
1075
|
-
|
|
1076
|
-
it("should handle range lines", async () => {
|
|
1077
|
-
const issues = [{ file: "test.ts", line: "1-2" }];
|
|
1078
|
-
const fileContents = new Map([
|
|
1079
|
-
[
|
|
1080
|
-
"test.ts",
|
|
1081
|
-
[
|
|
1082
|
-
["-------", "line1"],
|
|
1083
|
-
["abc1234", "line2"],
|
|
1084
|
-
["-------", "line3"],
|
|
1085
|
-
],
|
|
1086
|
-
],
|
|
1087
|
-
]);
|
|
1088
|
-
const result = await (service as any).fillIssueCode(issues, fileContents);
|
|
1089
|
-
expect(result[0].code).toBe("line1\nline2");
|
|
1090
|
-
});
|
|
1091
|
-
|
|
1092
|
-
it("should return issue unchanged if file not found", async () => {
|
|
1093
|
-
const issues = [{ file: "missing.ts", line: "1" }];
|
|
1094
|
-
const result = await (service as any).fillIssueCode(issues, new Map());
|
|
1095
|
-
expect(result[0].code).toBeUndefined();
|
|
1096
|
-
});
|
|
1097
|
-
|
|
1098
|
-
it("should return issue unchanged if line out of range", async () => {
|
|
1099
|
-
const issues = [{ file: "test.ts", line: "999" }];
|
|
1100
|
-
const fileContents = new Map([["test.ts", [["-------", "line1"]]]]);
|
|
1101
|
-
const result = await (service as any).fillIssueCode(issues, fileContents);
|
|
1102
|
-
expect(result[0].code).toBeUndefined();
|
|
1103
|
-
});
|
|
1104
|
-
|
|
1105
|
-
it("should return issue unchanged if line is NaN", async () => {
|
|
1106
|
-
const issues = [{ file: "test.ts", line: "abc" }];
|
|
1107
|
-
const fileContents = new Map([["test.ts", [["-------", "line1"]]]]);
|
|
1108
|
-
const result = await (service as any).fillIssueCode(issues, fileContents);
|
|
1109
|
-
expect(result[0].code).toBeUndefined();
|
|
1110
|
-
});
|
|
1111
|
-
});
|
|
1112
|
-
|
|
1113
|
-
describe("ReviewService.fillIssueAuthors", () => {
|
|
1114
|
-
it("should fill author from commit with platform user", async () => {
|
|
1115
|
-
const issues = [{ file: "test.ts", line: "1", commit: "abc1234" }];
|
|
1116
|
-
const commits = [
|
|
1117
|
-
{
|
|
1118
|
-
sha: "abc1234567890",
|
|
1119
|
-
author: { id: 1, login: "dev1" },
|
|
1120
|
-
commit: { author: { name: "Dev", email: "dev@test.com" } },
|
|
1121
|
-
},
|
|
1122
|
-
];
|
|
1123
|
-
const result = await (service as any).fillIssueAuthors(issues, commits, "o", "r");
|
|
1124
|
-
expect(result[0].author.login).toBe("dev1");
|
|
1125
|
-
});
|
|
1126
|
-
|
|
1127
|
-
it("should use default author when commit not matched", async () => {
|
|
1128
|
-
const issues = [{ file: "test.ts", line: "1", commit: "zzz9999" }];
|
|
1129
|
-
const commits = [
|
|
1130
|
-
{
|
|
1131
|
-
sha: "abc1234567890",
|
|
1132
|
-
author: { id: 1, login: "dev1" },
|
|
1133
|
-
commit: { author: { name: "Dev", email: "dev@test.com" } },
|
|
1134
|
-
},
|
|
1135
|
-
];
|
|
1136
|
-
const result = await (service as any).fillIssueAuthors(issues, commits, "o", "r");
|
|
1137
|
-
expect(result[0].author.login).toBe("dev1");
|
|
1138
|
-
});
|
|
1139
|
-
|
|
1140
|
-
it("should keep existing author", async () => {
|
|
1141
|
-
const issues = [{ file: "test.ts", line: "1", author: { id: "99", login: "existing" } }];
|
|
1142
|
-
const commits = [{ sha: "abc1234567890", author: { id: 1, login: "dev1" } }];
|
|
1143
|
-
const result = await (service as any).fillIssueAuthors(issues, commits, "o", "r");
|
|
1144
|
-
expect(result[0].author.login).toBe("existing");
|
|
1145
|
-
});
|
|
1146
|
-
|
|
1147
|
-
it("should use git author name when no platform user", async () => {
|
|
1148
|
-
const issues = [{ file: "test.ts", line: "1", commit: "abc1234" }];
|
|
1149
|
-
const commits = [
|
|
1150
|
-
{
|
|
1151
|
-
sha: "abc1234567890",
|
|
1152
|
-
author: null,
|
|
1153
|
-
committer: null,
|
|
1154
|
-
commit: { author: { name: "GitUser", email: "git@test.com" } },
|
|
1155
|
-
},
|
|
1156
|
-
];
|
|
1157
|
-
const result = await (service as any).fillIssueAuthors(issues, commits, "o", "r");
|
|
1158
|
-
expect(result[0].author.login).toBe("GitUser");
|
|
1159
|
-
});
|
|
1160
|
-
|
|
1161
|
-
it("should mark invalid when existing author but ------- commit hash", async () => {
|
|
1162
|
-
const issues = [
|
|
1163
|
-
{ file: "test.ts", line: "1", commit: "-------", author: { id: "1", login: "dev1" } },
|
|
1164
|
-
];
|
|
1165
|
-
const result = await (service as any).fillIssueAuthors(issues, [], "o", "r");
|
|
1166
|
-
expect(result[0].commit).toBeUndefined();
|
|
1167
|
-
expect(result[0].valid).toBe("false");
|
|
1168
|
-
});
|
|
1169
|
-
|
|
1170
|
-
it("should handle issues with ------- commit hash", async () => {
|
|
1171
|
-
const issues = [{ file: "test.ts", line: "1", commit: "-------" }];
|
|
1172
|
-
const commits = [
|
|
1173
|
-
{ sha: "abc1234567890", author: { id: 1, login: "dev1" }, commit: { author: {} } },
|
|
1174
|
-
];
|
|
1175
|
-
const result = await (service as any).fillIssueAuthors(issues, commits, "o", "r");
|
|
1176
|
-
expect(result[0].commit).toBeUndefined();
|
|
1177
|
-
expect(result[0].valid).toBe("false");
|
|
1178
|
-
});
|
|
1179
|
-
});
|
|
1180
|
-
|
|
1181
618
|
describe("ReviewService.executeCollectOnly", () => {
|
|
1182
619
|
it("should return empty result when no existing review", async () => {
|
|
1183
620
|
gitProvider.listPullReviews.mockResolvedValue([] as any);
|
|
1184
621
|
const context = { owner: "o", repo: "r", prNumber: 1, ci: false, dryRun: false };
|
|
1185
|
-
const result = await
|
|
622
|
+
const result = await service.executeCollectOnly(context);
|
|
1186
623
|
expect(result.success).toBe(true);
|
|
1187
624
|
expect(result.issues).toEqual([]);
|
|
1188
625
|
});
|
|
1189
626
|
|
|
1190
627
|
it("should throw when no prNumber", async () => {
|
|
1191
628
|
const context = { owner: "o", repo: "r", ci: false, dryRun: false };
|
|
1192
|
-
await expect(
|
|
629
|
+
await expect(service.executeCollectOnly(context)).rejects.toThrow(
|
|
1193
630
|
"collectOnly 模式必须指定 PR 编号",
|
|
1194
631
|
);
|
|
1195
632
|
});
|
|
1196
633
|
|
|
1197
634
|
it("should collect and return existing review result", async () => {
|
|
1198
|
-
const
|
|
1199
|
-
|
|
1200
|
-
mockReviewReportService.formatStatsTerminal = vi.fn().mockReturnValue("stats");
|
|
635
|
+
const existingResult = { issues: [{ file: "a.ts", line: "1", ruleId: "R1" }], summary: [] };
|
|
636
|
+
service._reviewReportService.formatStatsTerminal = vi.fn().mockReturnValue("stats") as any;
|
|
1201
637
|
gitProvider.listPullReviews.mockResolvedValue([] as any);
|
|
1202
638
|
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
1203
639
|
gitProvider.getPullRequestCommits.mockResolvedValue([] as any);
|
|
@@ -1205,22 +641,65 @@ describe("ReviewService", () => {
|
|
|
1205
641
|
vi.spyOn(ReviewResultModel, "loadFromPr").mockResolvedValue(
|
|
1206
642
|
ReviewResultModel.create(
|
|
1207
643
|
new PullRequestModel(gitProvider as any, "o", "r", 1),
|
|
1208
|
-
|
|
1209
|
-
|
|
644
|
+
existingResult as any,
|
|
645
|
+
service._resultModelDeps,
|
|
1210
646
|
),
|
|
1211
647
|
);
|
|
1212
648
|
const context = { owner: "o", repo: "r", prNumber: 1, ci: false, dryRun: false };
|
|
1213
|
-
const result = await
|
|
649
|
+
const result = await service.executeCollectOnly(context);
|
|
1214
650
|
expect(result.issues).toHaveLength(1);
|
|
1215
651
|
expect(result.stats).toBeDefined();
|
|
1216
652
|
});
|
|
653
|
+
|
|
654
|
+
it("should filter merge commits before getFileContents when verifyFixes enabled", async () => {
|
|
655
|
+
const existingResult = { issues: [{ file: "a.ts", line: "1", ruleId: "R1" }], summary: [] };
|
|
656
|
+
service._reviewReportService.formatStatsTerminal = vi.fn().mockReturnValue("stats") as any;
|
|
657
|
+
gitProvider.listPullReviews.mockResolvedValue([] as any);
|
|
658
|
+
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
659
|
+
gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "head1234" } } as any);
|
|
660
|
+
gitProvider.getPullRequestFiles.mockResolvedValue([
|
|
661
|
+
{ filename: "a.ts", status: "modified" },
|
|
662
|
+
] as any);
|
|
663
|
+
gitProvider.getPullRequestCommits.mockResolvedValue([
|
|
664
|
+
{ sha: "merge1111", commit: { message: "Merge branch 'main' into feature" } },
|
|
665
|
+
{ sha: "feat22222", commit: { message: "feat: add logic" } },
|
|
666
|
+
] as any);
|
|
667
|
+
|
|
668
|
+
vi.spyOn(ReviewResultModel, "loadFromPr").mockResolvedValue(
|
|
669
|
+
ReviewResultModel.create(
|
|
670
|
+
new PullRequestModel(gitProvider as any, "o", "r", 1),
|
|
671
|
+
existingResult as any,
|
|
672
|
+
service._resultModelDeps,
|
|
673
|
+
),
|
|
674
|
+
);
|
|
675
|
+
const getFileContentsSpy = vi
|
|
676
|
+
.spyOn(service._sourceResolver, "getFileContents")
|
|
677
|
+
.mockResolvedValue(new Map() as any);
|
|
678
|
+
|
|
679
|
+
const context = {
|
|
680
|
+
owner: "o",
|
|
681
|
+
repo: "r",
|
|
682
|
+
prNumber: 1,
|
|
683
|
+
ci: false,
|
|
684
|
+
dryRun: false,
|
|
685
|
+
verifyFixes: true,
|
|
686
|
+
specSources: ["/spec/dir"],
|
|
687
|
+
showAll: false,
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
await service.executeCollectOnly(context);
|
|
691
|
+
|
|
692
|
+
expect(getFileContentsSpy).toHaveBeenCalled();
|
|
693
|
+
const passedCommits = getFileContentsSpy.mock.calls[0][3] as any[];
|
|
694
|
+
expect(passedCommits).toHaveLength(1);
|
|
695
|
+
expect(passedCommits[0].sha).toBe("feat22222");
|
|
696
|
+
});
|
|
1217
697
|
});
|
|
1218
698
|
|
|
1219
699
|
describe("ReviewService.execute - flush mode", () => {
|
|
1220
700
|
it("should route to executeCollectOnly when flush is true", async () => {
|
|
1221
|
-
const
|
|
1222
|
-
|
|
1223
|
-
mockReviewReportService.formatStatsTerminal = vi.fn().mockReturnValue("stats");
|
|
701
|
+
const flushResult = { issues: [{ file: "a.ts", line: "1", ruleId: "R1" }], summary: [] };
|
|
702
|
+
service._reviewReportService.formatStatsTerminal = vi.fn().mockReturnValue("stats") as any;
|
|
1224
703
|
gitProvider.listPullReviews.mockResolvedValue([] as any);
|
|
1225
704
|
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
1226
705
|
gitProvider.getPullRequestCommits.mockResolvedValue([] as any);
|
|
@@ -1228,8 +707,8 @@ describe("ReviewService", () => {
|
|
|
1228
707
|
vi.spyOn(ReviewResultModel, "loadFromPr").mockResolvedValue(
|
|
1229
708
|
ReviewResultModel.create(
|
|
1230
709
|
new PullRequestModel(gitProvider as any, "o", "r", 1),
|
|
1231
|
-
|
|
1232
|
-
|
|
710
|
+
flushResult as any,
|
|
711
|
+
service._resultModelDeps,
|
|
1233
712
|
),
|
|
1234
713
|
);
|
|
1235
714
|
const context = {
|
|
@@ -1250,14 +729,11 @@ describe("ReviewService", () => {
|
|
|
1250
729
|
describe("ReviewService.executeDeletionOnly", () => {
|
|
1251
730
|
it("should throw when no llmMode", async () => {
|
|
1252
731
|
const context = { owner: "o", repo: "r", prNumber: 1, ci: false, dryRun: false };
|
|
1253
|
-
await expect(
|
|
1254
|
-
"必须指定 LLM 类型",
|
|
1255
|
-
);
|
|
732
|
+
await expect(service.executeDeletionOnly(context)).rejects.toThrow("必须指定 LLM 类型");
|
|
1256
733
|
});
|
|
1257
734
|
|
|
1258
735
|
it("should execute deletion analysis with PR", async () => {
|
|
1259
|
-
|
|
1260
|
-
mockReviewReportService.formatMarkdown.mockReturnValue("report");
|
|
736
|
+
vi.mocked(service._reviewReportService.formatMarkdown).mockReturnValue("report");
|
|
1261
737
|
mockDeletionImpactService.analyzeDeletionImpact.mockResolvedValue({
|
|
1262
738
|
issues: [],
|
|
1263
739
|
summary: "ok",
|
|
@@ -1273,9 +749,8 @@ describe("ReviewService", () => {
|
|
|
1273
749
|
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
1274
750
|
gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "abc" } } as any);
|
|
1275
751
|
gitProvider.createIssueComment.mockResolvedValue({} as any);
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
const context = {
|
|
752
|
+
vi.mocked(service._config.getPluginConfig).mockReturnValue({});
|
|
753
|
+
const context: Partial<ReviewContext> = {
|
|
1279
754
|
owner: "o",
|
|
1280
755
|
repo: "r",
|
|
1281
756
|
prNumber: 1,
|
|
@@ -1285,14 +760,13 @@ describe("ReviewService", () => {
|
|
|
1285
760
|
deletionAnalysisMode: "openai",
|
|
1286
761
|
verbose: 1,
|
|
1287
762
|
};
|
|
1288
|
-
const result = await
|
|
763
|
+
const result = await service.executeDeletionOnly(context);
|
|
1289
764
|
expect(result.success).toBe(true);
|
|
1290
765
|
expect(result.deletionImpact).toBeDefined();
|
|
1291
766
|
});
|
|
1292
767
|
|
|
1293
768
|
it("should post comment in CI mode for deletionOnly", async () => {
|
|
1294
|
-
|
|
1295
|
-
mockReviewReportService.formatMarkdown.mockReturnValue("report");
|
|
769
|
+
vi.mocked(service._reviewReportService.formatMarkdown).mockReturnValue("report");
|
|
1296
770
|
mockDeletionImpactService.analyzeDeletionImpact.mockResolvedValue({
|
|
1297
771
|
issues: [],
|
|
1298
772
|
summary: "ok",
|
|
@@ -1308,9 +782,8 @@ describe("ReviewService", () => {
|
|
|
1308
782
|
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
1309
783
|
gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "abc" } } as any);
|
|
1310
784
|
gitProvider.createIssueComment.mockResolvedValue({} as any);
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
const context = {
|
|
785
|
+
vi.mocked(service._config.getPluginConfig).mockReturnValue({});
|
|
786
|
+
const context: Partial<ReviewContext> = {
|
|
1314
787
|
owner: "o",
|
|
1315
788
|
repo: "r",
|
|
1316
789
|
prNumber: 1,
|
|
@@ -1320,7 +793,7 @@ describe("ReviewService", () => {
|
|
|
1320
793
|
deletionAnalysisMode: "openai",
|
|
1321
794
|
verbose: 1,
|
|
1322
795
|
};
|
|
1323
|
-
const result = await
|
|
796
|
+
const result = await service.executeDeletionOnly(context);
|
|
1324
797
|
expect(result.success).toBe(true);
|
|
1325
798
|
expect(gitProvider.createIssueComment).toHaveBeenCalled();
|
|
1326
799
|
});
|
|
@@ -1352,7 +825,7 @@ describe("ReviewService", () => {
|
|
|
1352
825
|
|
|
1353
826
|
it("should auto-detect prNumber from event in CI mode", async () => {
|
|
1354
827
|
configService.get.mockReturnValue({ repository: "owner/repo", refName: "main" });
|
|
1355
|
-
vi.spyOn(
|
|
828
|
+
vi.spyOn(service._contextBuilder, "getPrNumberFromEvent").mockResolvedValue(42);
|
|
1356
829
|
gitProvider.getPullRequest.mockResolvedValue({ title: "feat: test" } as any);
|
|
1357
830
|
const options = { dryRun: false, ci: true, verbose: 1 };
|
|
1358
831
|
const context = await service.getContextFromEnv(options as any);
|
|
@@ -1433,8 +906,7 @@ describe("ReviewService", () => {
|
|
|
1433
906
|
|
|
1434
907
|
it("should merge references from options and config", async () => {
|
|
1435
908
|
configService.get.mockReturnValue({ repository: "owner/repo", refName: "main" });
|
|
1436
|
-
|
|
1437
|
-
configReader.getPluginConfig.mockReturnValue({ references: ["config-ref"] });
|
|
909
|
+
vi.mocked(service._config.getPluginConfig).mockReturnValue({ references: ["config-ref"] });
|
|
1438
910
|
const options = { dryRun: false, ci: false, references: ["opt-ref"] };
|
|
1439
911
|
const context = await service.getContextFromEnv(options as any);
|
|
1440
912
|
expect(context.specSources).toContain("opt-ref");
|
|
@@ -1453,17 +925,7 @@ describe("ReviewService", () => {
|
|
|
1453
925
|
describe("ReviewService.ensureClaudeCli", () => {
|
|
1454
926
|
it("should not throw when claude is installed", async () => {
|
|
1455
927
|
vi.spyOn(require("child_process"), "execSync").mockImplementation(() => Buffer.from("1.0.0"));
|
|
1456
|
-
await expect(
|
|
1457
|
-
});
|
|
1458
|
-
});
|
|
1459
|
-
|
|
1460
|
-
describe("ReviewService.getCommitsBetweenRefs", () => {
|
|
1461
|
-
it("should return commits from git sdk", async () => {
|
|
1462
|
-
mockGitSdkService.getCommitsBetweenRefs.mockResolvedValue([
|
|
1463
|
-
{ sha: "abc", commit: { message: "fix" } },
|
|
1464
|
-
]);
|
|
1465
|
-
const result = await (service as any).getCommitsBetweenRefs("main", "feature");
|
|
1466
|
-
expect(result).toHaveLength(1);
|
|
928
|
+
await expect(service.ensureClaudeCli()).resolves.toBeUndefined();
|
|
1467
929
|
});
|
|
1468
930
|
});
|
|
1469
931
|
|
|
@@ -1479,11 +941,11 @@ describe("ReviewService", () => {
|
|
|
1479
941
|
localMode: false,
|
|
1480
942
|
};
|
|
1481
943
|
|
|
1482
|
-
const result = await
|
|
944
|
+
const result = await service.resolveSourceData(context);
|
|
1483
945
|
|
|
1484
946
|
expect(result.isDirectFileMode).toBe(true);
|
|
1485
947
|
expect(result.isLocalMode).toBe(true);
|
|
1486
|
-
expect(result.changedFiles).toEqual([
|
|
948
|
+
expect(result.changedFiles.toArray()).toEqual([
|
|
1487
949
|
{ filename: "miniprogram/utils/asyncSharedUtilsLoader.js", status: "modified" },
|
|
1488
950
|
]);
|
|
1489
951
|
expect(mockGitSdkService.getUncommittedFiles).not.toHaveBeenCalled();
|
|
@@ -1502,138 +964,24 @@ describe("ReviewService", () => {
|
|
|
1502
964
|
localMode: false,
|
|
1503
965
|
};
|
|
1504
966
|
|
|
1505
|
-
const result = await
|
|
967
|
+
const result = await service.resolveSourceData(context);
|
|
1506
968
|
|
|
1507
969
|
expect(result.isDirectFileMode).toBe(true);
|
|
1508
|
-
expect(result.changedFiles).toEqual([
|
|
970
|
+
expect(result.changedFiles.toArray()).toEqual([
|
|
1509
971
|
{ filename: "miniprogram/utils/asyncSharedUtilsLoader.js", status: "modified" },
|
|
1510
972
|
]);
|
|
1511
973
|
});
|
|
1512
974
|
});
|
|
1513
975
|
|
|
1514
|
-
describe("ReviewService.getFilesForCommit", () => {
|
|
1515
|
-
it("should return files from git sdk", async () => {
|
|
1516
|
-
mockGitSdkService.getFilesForCommit.mockResolvedValue([
|
|
1517
|
-
{ filename: "a.ts", status: "modified" },
|
|
1518
|
-
]);
|
|
1519
|
-
const result = await (service as any).getFilesForCommit("abc123");
|
|
1520
|
-
expect(result).toHaveLength(1);
|
|
1521
|
-
});
|
|
1522
|
-
});
|
|
1523
|
-
|
|
1524
|
-
describe("ReviewService.buildReviewPrompt", () => {
|
|
1525
|
-
it("should build prompts for changed files", async () => {
|
|
1526
|
-
const specs = [{ extensions: ["ts"], includes: [], rules: [{ id: "R1" }] }];
|
|
1527
|
-
const changedFiles = [{ filename: "test.ts", status: "modified" }];
|
|
1528
|
-
const fileContents = new Map([
|
|
1529
|
-
[
|
|
1530
|
-
"test.ts",
|
|
1531
|
-
[
|
|
1532
|
-
["abc1234", "const x = 1;"],
|
|
1533
|
-
["-------", "const y = 2;"],
|
|
1534
|
-
],
|
|
1535
|
-
],
|
|
1536
|
-
]);
|
|
1537
|
-
const commits = [{ sha: "abc1234567890", commit: { message: "fix" } }];
|
|
1538
|
-
const result = await (service as any).buildReviewPrompt(
|
|
1539
|
-
specs,
|
|
1540
|
-
changedFiles,
|
|
1541
|
-
fileContents,
|
|
1542
|
-
commits,
|
|
1543
|
-
);
|
|
1544
|
-
expect(result.filePrompts).toHaveLength(1);
|
|
1545
|
-
expect(result.filePrompts[0].filename).toBe("test.ts");
|
|
1546
|
-
expect(result.filePrompts[0].userPrompt).toContain("test.ts");
|
|
1547
|
-
expect(result.filePrompts[0].systemPrompt).toContain("代码审查专家");
|
|
1548
|
-
});
|
|
1549
|
-
|
|
1550
|
-
it("should skip deleted files", async () => {
|
|
1551
|
-
const specs = [{ extensions: ["ts"], includes: [], rules: [] }];
|
|
1552
|
-
const changedFiles = [{ filename: "deleted.ts", status: "deleted" }];
|
|
1553
|
-
const result = await (service as any).buildReviewPrompt(specs, changedFiles, new Map(), []);
|
|
1554
|
-
expect(result.filePrompts).toHaveLength(0);
|
|
1555
|
-
});
|
|
1556
|
-
|
|
1557
|
-
it("should handle missing file contents", async () => {
|
|
1558
|
-
const specs = [{ extensions: ["ts"], includes: [], rules: [] }];
|
|
1559
|
-
const changedFiles = [{ filename: "test.ts", status: "modified" }];
|
|
1560
|
-
const result = await (service as any).buildReviewPrompt(specs, changedFiles, new Map(), []);
|
|
1561
|
-
expect(result.filePrompts).toHaveLength(1);
|
|
1562
|
-
expect(result.filePrompts[0].userPrompt).toContain("无法获取内容");
|
|
1563
|
-
});
|
|
1564
|
-
|
|
1565
|
-
it("should include existing result in prompt", async () => {
|
|
1566
|
-
const specs = [{ extensions: ["ts"], includes: [], rules: [] }];
|
|
1567
|
-
const changedFiles = [{ filename: "test.ts", status: "modified" }];
|
|
1568
|
-
const fileContents = new Map([["test.ts", [["-------", "code"]]]]);
|
|
1569
|
-
const existingResult = {
|
|
1570
|
-
issues: [{ file: "test.ts", line: "1", ruleId: "R1", reason: "bad code" }],
|
|
1571
|
-
summary: [{ file: "test.ts", summary: "has issues" }],
|
|
1572
|
-
};
|
|
1573
|
-
const result = await (service as any).buildReviewPrompt(
|
|
1574
|
-
specs,
|
|
1575
|
-
changedFiles,
|
|
1576
|
-
fileContents,
|
|
1577
|
-
[],
|
|
1578
|
-
existingResult,
|
|
1579
|
-
);
|
|
1580
|
-
expect(result.filePrompts[0].userPrompt).toContain("bad code");
|
|
1581
|
-
});
|
|
1582
|
-
});
|
|
1583
|
-
|
|
1584
|
-
describe("ReviewService.generatePrDescription", () => {
|
|
1585
|
-
it("should generate description from LLM", async () => {
|
|
1586
|
-
const llmProxy = (service as any).llmProxyService;
|
|
1587
|
-
const mockStream = (async function* () {
|
|
1588
|
-
yield { type: "text", content: "# Feat: 新功能\n\n详细描述" };
|
|
1589
|
-
})();
|
|
1590
|
-
llmProxy.chatStream.mockReturnValue(mockStream);
|
|
1591
|
-
const commits = [{ sha: "abc123", commit: { message: "feat: add" } }];
|
|
1592
|
-
const changedFiles = [{ filename: "a.ts", status: "modified" }];
|
|
1593
|
-
const result = await (service as any).generatePrDescription(commits, changedFiles, "openai");
|
|
1594
|
-
expect(result.title).toBe("Feat: 新功能");
|
|
1595
|
-
expect(result.description).toContain("详细描述");
|
|
1596
|
-
});
|
|
1597
|
-
|
|
1598
|
-
it("should fallback on LLM error", async () => {
|
|
1599
|
-
const llmProxy = (service as any).llmProxyService;
|
|
1600
|
-
const mockStream = (async function* () {
|
|
1601
|
-
yield { type: "error", message: "fail" };
|
|
1602
|
-
})();
|
|
1603
|
-
llmProxy.chatStream.mockReturnValue(mockStream);
|
|
1604
|
-
const commits = [{ sha: "abc123", commit: { message: "feat: add" } }];
|
|
1605
|
-
const changedFiles = [{ filename: "a.ts", status: "modified" }];
|
|
1606
|
-
const result = await (service as any).generatePrDescription(commits, changedFiles, "openai");
|
|
1607
|
-
expect(result.title).toBeDefined();
|
|
1608
|
-
});
|
|
1609
|
-
|
|
1610
|
-
it("should include code changes section when fileContents provided", async () => {
|
|
1611
|
-
const llmProxy = (service as any).llmProxyService;
|
|
1612
|
-
const mockStream = (async function* () {
|
|
1613
|
-
yield { type: "text", content: "Feat: test\n\ndesc" };
|
|
1614
|
-
})();
|
|
1615
|
-
llmProxy.chatStream.mockReturnValue(mockStream);
|
|
1616
|
-
const commits = [{ sha: "abc123", commit: { message: "feat" } }];
|
|
1617
|
-
const changedFiles = [{ filename: "a.ts", status: "modified" }];
|
|
1618
|
-
const fileContents = new Map([["a.ts", [["abc1234", "new code"]]]]);
|
|
1619
|
-
const result = await (service as any).generatePrDescription(
|
|
1620
|
-
commits,
|
|
1621
|
-
changedFiles,
|
|
1622
|
-
"openai",
|
|
1623
|
-
fileContents,
|
|
1624
|
-
);
|
|
1625
|
-
expect(result.title).toBeDefined();
|
|
1626
|
-
});
|
|
1627
|
-
});
|
|
1628
|
-
|
|
1629
976
|
describe("ReviewService.execute - CI with existingResult", () => {
|
|
1630
977
|
beforeEach(() => {
|
|
1631
|
-
vi.spyOn(service
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
978
|
+
vi.spyOn(service._llmProcessor, "runLLMReview").mockResolvedValue(
|
|
979
|
+
mockResult({
|
|
980
|
+
issues: [mockIssue({ file: "test.ts", line: "5", ruleId: "R1", reason: "new issue" })],
|
|
981
|
+
summary: [mockSummary({ file: "test.ts", summary: "ok" })],
|
|
982
|
+
}),
|
|
983
|
+
);
|
|
984
|
+
vi.spyOn(service._sourceResolver, "getFileContents").mockResolvedValue(new Map());
|
|
1637
985
|
});
|
|
1638
986
|
|
|
1639
987
|
it("should merge existing issues with new issues in CI mode", async () => {
|
|
@@ -1649,11 +997,11 @@ describe("ReviewService", () => {
|
|
|
1649
997
|
summary: [],
|
|
1650
998
|
round: 1,
|
|
1651
999
|
} as any,
|
|
1652
|
-
|
|
1000
|
+
service._resultModelDeps,
|
|
1653
1001
|
),
|
|
1654
1002
|
);
|
|
1655
|
-
const configReader =
|
|
1656
|
-
configReader.getPluginConfig.mockReturnValue({});
|
|
1003
|
+
const configReader = service._config;
|
|
1004
|
+
vi.mocked(configReader.getPluginConfig).mockReturnValue({});
|
|
1657
1005
|
gitProvider.getPullRequest.mockResolvedValue({ title: "PR", head: { sha: "abc" } } as any);
|
|
1658
1006
|
gitProvider.getPullRequestCommits.mockResolvedValue([
|
|
1659
1007
|
{ sha: "abc123", commit: { message: "fix" }, author: { id: 1, login: "dev" } },
|
|
@@ -1671,7 +1019,7 @@ describe("ReviewService", () => {
|
|
|
1671
1019
|
specSources: ["/spec"],
|
|
1672
1020
|
dryRun: false,
|
|
1673
1021
|
ci: true,
|
|
1674
|
-
llmMode: "openai",
|
|
1022
|
+
llmMode: "openai" as const,
|
|
1675
1023
|
verifyFixes: false,
|
|
1676
1024
|
verbose: 1,
|
|
1677
1025
|
};
|
|
@@ -1681,7 +1029,7 @@ describe("ReviewService", () => {
|
|
|
1681
1029
|
});
|
|
1682
1030
|
|
|
1683
1031
|
it("should verify fixes when verifyFixes is true", async () => {
|
|
1684
|
-
vi.
|
|
1032
|
+
vi.mocked(ReviewResultModel.loadFromPr).mockResolvedValue(
|
|
1685
1033
|
ReviewResultModel.create(
|
|
1686
1034
|
new PullRequestModel(gitProvider as any, "o", "r", 1),
|
|
1687
1035
|
{
|
|
@@ -1691,11 +1039,11 @@ describe("ReviewService", () => {
|
|
|
1691
1039
|
summary: [],
|
|
1692
1040
|
round: 1,
|
|
1693
1041
|
} as any,
|
|
1694
|
-
|
|
1042
|
+
service._resultModelDeps,
|
|
1695
1043
|
),
|
|
1696
1044
|
);
|
|
1697
|
-
const configReader =
|
|
1698
|
-
configReader.getPluginConfig.mockReturnValue({});
|
|
1045
|
+
const configReader = service._config;
|
|
1046
|
+
vi.mocked(configReader.getPluginConfig).mockReturnValue({});
|
|
1699
1047
|
gitProvider.getPullRequest.mockResolvedValue({ title: "PR", head: { sha: "abc" } } as any);
|
|
1700
1048
|
gitProvider.getPullRequestCommits.mockResolvedValue([
|
|
1701
1049
|
{ sha: "abc123", commit: { message: "fix" }, author: { id: 1, login: "dev" } },
|
|
@@ -1725,12 +1073,8 @@ describe("ReviewService", () => {
|
|
|
1725
1073
|
|
|
1726
1074
|
describe("ReviewService.execute - filterCommits branch", () => {
|
|
1727
1075
|
beforeEach(() => {
|
|
1728
|
-
vi.spyOn(service
|
|
1729
|
-
|
|
1730
|
-
issues: [],
|
|
1731
|
-
summary: [],
|
|
1732
|
-
});
|
|
1733
|
-
vi.spyOn(service as any, "getFileContents").mockResolvedValue(new Map());
|
|
1076
|
+
vi.spyOn(service._llmProcessor, "runLLMReview").mockResolvedValue(mockResult());
|
|
1077
|
+
vi.spyOn(service._sourceResolver, "getFileContents").mockResolvedValue(new Map());
|
|
1734
1078
|
vi.spyOn(ReviewResultModel, "loadFromPr").mockResolvedValue(null as any);
|
|
1735
1079
|
});
|
|
1736
1080
|
|
|
@@ -1828,27 +1172,9 @@ describe("ReviewService", () => {
|
|
|
1828
1172
|
});
|
|
1829
1173
|
});
|
|
1830
1174
|
|
|
1831
|
-
describe("ReviewService.fillIssueAuthors - searchUsers success", () => {
|
|
1832
|
-
it("should use searchUsers result for git-only authors", async () => {
|
|
1833
|
-
gitProvider.searchUsers.mockResolvedValue([{ id: 42, login: "found-user" }] as any);
|
|
1834
|
-
const issues = [{ file: "test.ts", line: "1", commit: "abc1234" }];
|
|
1835
|
-
const commits = [
|
|
1836
|
-
{
|
|
1837
|
-
sha: "abc1234567890",
|
|
1838
|
-
author: null,
|
|
1839
|
-
committer: null,
|
|
1840
|
-
commit: { author: { name: "GitUser", email: "git@test.com" } },
|
|
1841
|
-
},
|
|
1842
|
-
];
|
|
1843
|
-
const result = await (service as any).fillIssueAuthors(issues, commits, "o", "r");
|
|
1844
|
-
expect(result[0].author.login).toBe("found-user");
|
|
1845
|
-
});
|
|
1846
|
-
});
|
|
1847
|
-
|
|
1848
1175
|
describe("ReviewService.executeDeletionOnly - baseRef/headRef mode", () => {
|
|
1849
1176
|
it("should execute with baseRef/headRef instead of PR", async () => {
|
|
1850
|
-
|
|
1851
|
-
mockReviewReportService.formatMarkdown.mockReturnValue("report");
|
|
1177
|
+
vi.mocked(service._reviewReportService.formatMarkdown).mockReturnValue("report");
|
|
1852
1178
|
mockDeletionImpactService.analyzeDeletionImpact.mockResolvedValue({
|
|
1853
1179
|
issues: [],
|
|
1854
1180
|
summary: "ok",
|
|
@@ -1859,7 +1185,7 @@ describe("ReviewService", () => {
|
|
|
1859
1185
|
mockGitSdkService.getCommitsBetweenRefs.mockResolvedValue([
|
|
1860
1186
|
{ sha: "abc", commit: { message: "fix" } },
|
|
1861
1187
|
]);
|
|
1862
|
-
const context = {
|
|
1188
|
+
const context: Partial<ReviewContext> = {
|
|
1863
1189
|
owner: "o",
|
|
1864
1190
|
repo: "r",
|
|
1865
1191
|
baseRef: "main",
|
|
@@ -1869,13 +1195,12 @@ describe("ReviewService", () => {
|
|
|
1869
1195
|
llmMode: "openai",
|
|
1870
1196
|
deletionAnalysisMode: "openai",
|
|
1871
1197
|
};
|
|
1872
|
-
const result = await
|
|
1198
|
+
const result = await service.executeDeletionOnly(context);
|
|
1873
1199
|
expect(result.success).toBe(true);
|
|
1874
1200
|
});
|
|
1875
1201
|
|
|
1876
1202
|
it("should filter files by includes in deletionOnly", async () => {
|
|
1877
|
-
|
|
1878
|
-
mockReviewReportService.formatMarkdown.mockReturnValue("report");
|
|
1203
|
+
vi.mocked(service._reviewReportService.formatMarkdown).mockReturnValue("report");
|
|
1879
1204
|
mockDeletionImpactService.analyzeDeletionImpact.mockResolvedValue({
|
|
1880
1205
|
issues: [],
|
|
1881
1206
|
summary: "ok",
|
|
@@ -1887,7 +1212,7 @@ describe("ReviewService", () => {
|
|
|
1887
1212
|
{ filename: "a.ts", status: "modified" },
|
|
1888
1213
|
{ filename: "b.md", status: "modified" },
|
|
1889
1214
|
] as any);
|
|
1890
|
-
const context = {
|
|
1215
|
+
const context: Partial<ReviewContext> = {
|
|
1891
1216
|
owner: "o",
|
|
1892
1217
|
repo: "r",
|
|
1893
1218
|
prNumber: 1,
|
|
@@ -1897,7 +1222,7 @@ describe("ReviewService", () => {
|
|
|
1897
1222
|
deletionAnalysisMode: "openai",
|
|
1898
1223
|
includes: ["*.ts"],
|
|
1899
1224
|
};
|
|
1900
|
-
const result = await
|
|
1225
|
+
const result = await service.executeDeletionOnly(context);
|
|
1901
1226
|
expect(result.success).toBe(true);
|
|
1902
1227
|
});
|
|
1903
1228
|
});
|
|
@@ -1913,19 +1238,20 @@ describe("ReviewService", () => {
|
|
|
1913
1238
|
|
|
1914
1239
|
describe("ReviewService.executeCollectOnly - CI post comment", () => {
|
|
1915
1240
|
it("should post comment in CI mode", async () => {
|
|
1916
|
-
const
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1241
|
+
const ciResult = { issues: [{ file: "a.ts", line: "1", ruleId: "R1" }], summary: [] };
|
|
1242
|
+
vi.mocked(service._reviewReportService.parseMarkdown).mockReturnValue({
|
|
1243
|
+
result: ciResult,
|
|
1244
|
+
} as any);
|
|
1245
|
+
service._reviewReportService.formatStatsTerminal = vi.fn().mockReturnValue("stats") as any;
|
|
1246
|
+
vi.mocked(service._reviewReportService.formatMarkdown).mockReturnValue("report");
|
|
1921
1247
|
gitProvider.listIssueComments.mockResolvedValue([
|
|
1922
1248
|
{ id: 10, body: "<!-- spaceflow-review --> content" },
|
|
1923
1249
|
] as any);
|
|
1924
1250
|
vi.spyOn(ReviewResultModel, "loadFromPr").mockResolvedValue(
|
|
1925
1251
|
ReviewResultModel.create(
|
|
1926
1252
|
new PullRequestModel(gitProvider as any, "o", "r", 1),
|
|
1927
|
-
|
|
1928
|
-
|
|
1253
|
+
ciResult as any,
|
|
1254
|
+
service._resultModelDeps,
|
|
1929
1255
|
),
|
|
1930
1256
|
);
|
|
1931
1257
|
gitProvider.listPullReviews.mockResolvedValue([] as any);
|
|
@@ -1933,321 +1259,27 @@ describe("ReviewService", () => {
|
|
|
1933
1259
|
gitProvider.getPullRequestCommits.mockResolvedValue([] as any);
|
|
1934
1260
|
gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "abc" } } as any);
|
|
1935
1261
|
gitProvider.updateIssueComment.mockResolvedValue({} as any);
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1262
|
+
vi.mocked(service._config.getPluginConfig).mockReturnValue({});
|
|
1263
|
+
const context: Partial<ReviewContext> = {
|
|
1264
|
+
owner: "o",
|
|
1265
|
+
repo: "r",
|
|
1266
|
+
prNumber: 1,
|
|
1267
|
+
ci: true,
|
|
1268
|
+
dryRun: false,
|
|
1269
|
+
verbose: 1,
|
|
1270
|
+
};
|
|
1271
|
+
const result = await service.executeCollectOnly(context);
|
|
1940
1272
|
expect(result.issues).toHaveLength(1);
|
|
1941
1273
|
expect(gitProvider.updateIssueComment).toHaveBeenCalled();
|
|
1942
1274
|
});
|
|
1943
1275
|
});
|
|
1944
1276
|
|
|
1945
|
-
describe("ReviewService.getFileContents", () => {
|
|
1946
|
-
it("should get file contents with PR mode", async () => {
|
|
1947
|
-
gitProvider.getFileContent.mockResolvedValue("line1\nline2\nline3" as any);
|
|
1948
|
-
const changedFiles = [
|
|
1949
|
-
{
|
|
1950
|
-
filename: "test.ts",
|
|
1951
|
-
status: "modified",
|
|
1952
|
-
patch: "@@ -1,2 +1,3 @@\n line1\n+line2\n line3",
|
|
1953
|
-
},
|
|
1954
|
-
];
|
|
1955
|
-
const commits = [{ sha: "abc1234567890" }];
|
|
1956
|
-
const result = await (service as any).getFileContents(
|
|
1957
|
-
"o",
|
|
1958
|
-
"r",
|
|
1959
|
-
changedFiles,
|
|
1960
|
-
commits,
|
|
1961
|
-
"abc",
|
|
1962
|
-
1,
|
|
1963
|
-
);
|
|
1964
|
-
expect(result.has("test.ts")).toBe(true);
|
|
1965
|
-
expect(result.get("test.ts")).toHaveLength(3);
|
|
1966
|
-
});
|
|
1967
|
-
|
|
1968
|
-
it("should get file contents with git sdk mode (no PR)", async () => {
|
|
1969
|
-
mockGitSdkService.getFileContent.mockResolvedValue("line1\nline2");
|
|
1970
|
-
const changedFiles = [
|
|
1971
|
-
{ filename: "test.ts", status: "modified", patch: "@@ -1,1 +1,2 @@\n line1\n+line2" },
|
|
1972
|
-
];
|
|
1973
|
-
const commits = [{ sha: "abc1234567890" }];
|
|
1974
|
-
const result = await (service as any).getFileContents(
|
|
1975
|
-
"o",
|
|
1976
|
-
"r",
|
|
1977
|
-
changedFiles,
|
|
1978
|
-
commits,
|
|
1979
|
-
"HEAD",
|
|
1980
|
-
);
|
|
1981
|
-
expect(result.has("test.ts")).toBe(true);
|
|
1982
|
-
});
|
|
1983
|
-
|
|
1984
|
-
it("should skip deleted files", async () => {
|
|
1985
|
-
const changedFiles = [{ filename: "deleted.ts", status: "deleted" }];
|
|
1986
|
-
const result = await (service as any).getFileContents("o", "r", changedFiles, [], "HEAD", 1);
|
|
1987
|
-
expect(result.size).toBe(0);
|
|
1988
|
-
});
|
|
1989
|
-
|
|
1990
|
-
it("should handle file content fetch error", async () => {
|
|
1991
|
-
gitProvider.getFileContent.mockRejectedValue(new Error("not found") as any);
|
|
1992
|
-
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
1993
|
-
const changedFiles = [{ filename: "missing.ts", status: "modified" }];
|
|
1994
|
-
const result = await (service as any).getFileContents("o", "r", changedFiles, [], "HEAD", 1);
|
|
1995
|
-
expect(result.size).toBe(0);
|
|
1996
|
-
expect(consoleSpy).toHaveBeenCalled();
|
|
1997
|
-
consoleSpy.mockRestore();
|
|
1998
|
-
});
|
|
1999
|
-
|
|
2000
|
-
it("should get file contents with verbose=3 logging", async () => {
|
|
2001
|
-
gitProvider.getFileContent.mockResolvedValue("line1\nline2" as any);
|
|
2002
|
-
const changedFiles = [
|
|
2003
|
-
{ filename: "test.ts", status: "modified", patch: "@@ -1,1 +1,2 @@\n line1\n+line2" },
|
|
2004
|
-
];
|
|
2005
|
-
const commits = [{ sha: "abc1234567890" }];
|
|
2006
|
-
const result = await (service as any).getFileContents(
|
|
2007
|
-
"o",
|
|
2008
|
-
"r",
|
|
2009
|
-
changedFiles,
|
|
2010
|
-
commits,
|
|
2011
|
-
"abc",
|
|
2012
|
-
1,
|
|
2013
|
-
3,
|
|
2014
|
-
);
|
|
2015
|
-
expect(result.has("test.ts")).toBe(true);
|
|
2016
|
-
});
|
|
2017
|
-
|
|
2018
|
-
it("should mark all lines as changed for new files without patch", async () => {
|
|
2019
|
-
gitProvider.getFileContent.mockResolvedValue("line1\nline2" as any);
|
|
2020
|
-
const changedFiles = [{ filename: "new.ts", status: "added", additions: 2, deletions: 0 }];
|
|
2021
|
-
const commits = [{ sha: "abc1234567890" }];
|
|
2022
|
-
const result = await (service as any).getFileContents(
|
|
2023
|
-
"o",
|
|
2024
|
-
"r",
|
|
2025
|
-
changedFiles,
|
|
2026
|
-
commits,
|
|
2027
|
-
"abc",
|
|
2028
|
-
1,
|
|
2029
|
-
);
|
|
2030
|
-
expect(result.has("new.ts")).toBe(true);
|
|
2031
|
-
const lines = result.get("new.ts");
|
|
2032
|
-
expect(lines[0][0]).toBe("abc1234");
|
|
2033
|
-
expect(lines[1][0]).toBe("abc1234");
|
|
2034
|
-
});
|
|
2035
|
-
});
|
|
2036
|
-
|
|
2037
|
-
describe("ReviewService.getChangedFilesBetweenRefs", () => {
|
|
2038
|
-
it("should merge diff and status info", async () => {
|
|
2039
|
-
mockGitSdkService.getDiffBetweenRefs.mockResolvedValue([
|
|
2040
|
-
{ filename: "a.ts", patch: "diff content" },
|
|
2041
|
-
]);
|
|
2042
|
-
mockGitSdkService.getChangedFilesBetweenRefs.mockResolvedValue([
|
|
2043
|
-
{ filename: "a.ts", status: "added" },
|
|
2044
|
-
]);
|
|
2045
|
-
const result = await (service as any).getChangedFilesBetweenRefs("o", "r", "main", "feature");
|
|
2046
|
-
expect(result).toHaveLength(1);
|
|
2047
|
-
expect(result[0].status).toBe("added");
|
|
2048
|
-
expect(result[0].patch).toBe("diff content");
|
|
2049
|
-
});
|
|
2050
|
-
});
|
|
2051
|
-
|
|
2052
|
-
describe("ReviewService.buildBasicDescription", () => {
|
|
2053
|
-
it("should build description from commits and files", async () => {
|
|
2054
|
-
const llmProxy = (service as any).llmProxyService;
|
|
2055
|
-
const mockStream = (async function* () {
|
|
2056
|
-
yield { type: "text", content: "Feat: test" };
|
|
2057
|
-
})();
|
|
2058
|
-
llmProxy.chatStream.mockReturnValue(mockStream);
|
|
2059
|
-
const commits = [{ sha: "abc", commit: { message: "feat: add feature" } }];
|
|
2060
|
-
const changedFiles = [
|
|
2061
|
-
{ filename: "a.ts", status: "added" },
|
|
2062
|
-
{ filename: "b.ts", status: "modified" },
|
|
2063
|
-
{ filename: "c.ts", status: "deleted" },
|
|
2064
|
-
];
|
|
2065
|
-
const result = await (service as any).buildBasicDescription(commits, changedFiles);
|
|
2066
|
-
expect(result.description).toContain("提交记录");
|
|
2067
|
-
expect(result.description).toContain("文件变更");
|
|
2068
|
-
expect(result.description).toContain("新增 1");
|
|
2069
|
-
expect(result.description).toContain("修改 1");
|
|
2070
|
-
expect(result.description).toContain("删除 1");
|
|
2071
|
-
});
|
|
2072
|
-
|
|
2073
|
-
it("should handle empty commits", async () => {
|
|
2074
|
-
const llmProxy = (service as any).llmProxyService;
|
|
2075
|
-
const mockStream = (async function* () {
|
|
2076
|
-
yield { type: "text", content: "Feat: empty" };
|
|
2077
|
-
})();
|
|
2078
|
-
llmProxy.chatStream.mockReturnValue(mockStream);
|
|
2079
|
-
const result = await (service as any).buildBasicDescription([], []);
|
|
2080
|
-
expect(result.title).toBeDefined();
|
|
2081
|
-
});
|
|
2082
|
-
});
|
|
2083
|
-
|
|
2084
|
-
describe("ReviewService.getFilesForCommit - no PR", () => {
|
|
2085
|
-
it("should use git sdk when no prNumber", async () => {
|
|
2086
|
-
mockGitSdkService.getFilesForCommit.mockResolvedValue(["a.ts", "b.ts"]);
|
|
2087
|
-
const result = await (service as any).getFilesForCommit("o", "r", "abc123");
|
|
2088
|
-
expect(result).toEqual(["a.ts", "b.ts"]);
|
|
2089
|
-
});
|
|
2090
|
-
|
|
2091
|
-
it("should use git provider when prNumber provided", async () => {
|
|
2092
|
-
gitProvider.getCommit.mockResolvedValue({ files: [{ filename: "a.ts" }] } as any);
|
|
2093
|
-
const result = await (service as any).getFilesForCommit("o", "r", "abc123", 1);
|
|
2094
|
-
expect(result).toEqual(["a.ts"]);
|
|
2095
|
-
});
|
|
2096
|
-
|
|
2097
|
-
it("should handle null files from getCommit", async () => {
|
|
2098
|
-
gitProvider.getCommit.mockResolvedValue({ files: null } as any);
|
|
2099
|
-
const result = await (service as any).getFilesForCommit("o", "r", "abc123", 1);
|
|
2100
|
-
expect(result).toEqual([]);
|
|
2101
|
-
});
|
|
2102
|
-
});
|
|
2103
|
-
|
|
2104
|
-
describe("ReviewService.filterIssuesByValidCommits", () => {
|
|
2105
|
-
beforeEach(() => {
|
|
2106
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockImplementation((lineStr: string) => {
|
|
2107
|
-
const lines: number[] = [];
|
|
2108
|
-
const rangeMatch = lineStr.match(/^(\d+)-(\d+)$/);
|
|
2109
|
-
if (rangeMatch) {
|
|
2110
|
-
const start = parseInt(rangeMatch[1], 10);
|
|
2111
|
-
const end = parseInt(rangeMatch[2], 10);
|
|
2112
|
-
for (let i = start; i <= end; i++) {
|
|
2113
|
-
lines.push(i);
|
|
2114
|
-
}
|
|
2115
|
-
} else {
|
|
2116
|
-
const line = parseInt(lineStr, 10);
|
|
2117
|
-
if (!isNaN(line)) {
|
|
2118
|
-
lines.push(line);
|
|
2119
|
-
}
|
|
2120
|
-
}
|
|
2121
|
-
return lines;
|
|
2122
|
-
});
|
|
2123
|
-
});
|
|
2124
|
-
|
|
2125
|
-
it("should filter issues by valid commit hashes", () => {
|
|
2126
|
-
const commits = [{ sha: "abc1234567890" }];
|
|
2127
|
-
const fileContents = new Map([
|
|
2128
|
-
[
|
|
2129
|
-
"test.ts",
|
|
2130
|
-
[
|
|
2131
|
-
["-------", "line1"],
|
|
2132
|
-
["abc1234", "line2"],
|
|
2133
|
-
["-------", "line3"],
|
|
2134
|
-
],
|
|
2135
|
-
],
|
|
2136
|
-
]);
|
|
2137
|
-
const issues = [
|
|
2138
|
-
{ file: "test.ts", line: "2", ruleId: "R1" }, // 应该保留,hash匹配
|
|
2139
|
-
{ file: "test.ts", line: "1", ruleId: "R2" }, // 应该过滤,hash不匹配
|
|
2140
|
-
{ file: "test.ts", line: "3", ruleId: "R3" }, // 应该过滤,hash不匹配
|
|
2141
|
-
];
|
|
2142
|
-
const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents, 2);
|
|
2143
|
-
expect(result).toHaveLength(1);
|
|
2144
|
-
expect(result[0].ruleId).toBe("R1");
|
|
2145
|
-
});
|
|
2146
|
-
|
|
2147
|
-
it("should log filtering summary", () => {
|
|
2148
|
-
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
2149
|
-
const commits = [{ sha: "abc1234567890" }];
|
|
2150
|
-
const fileContents = new Map([
|
|
2151
|
-
[
|
|
2152
|
-
"test.ts",
|
|
2153
|
-
[
|
|
2154
|
-
["-------", "line1"],
|
|
2155
|
-
["abc1234", "line2"],
|
|
2156
|
-
],
|
|
2157
|
-
],
|
|
2158
|
-
]);
|
|
2159
|
-
const issues = [
|
|
2160
|
-
{ file: "test.ts", line: "1", ruleId: "R1" },
|
|
2161
|
-
{ file: "test.ts", line: "2", ruleId: "R2" },
|
|
2162
|
-
];
|
|
2163
|
-
(service as any).filterIssuesByValidCommits(issues, commits, fileContents, 1);
|
|
2164
|
-
expect(consoleSpy).toHaveBeenCalledWith(" 过滤非本次 PR commits 问题后: 2 -> 1 个问题");
|
|
2165
|
-
consoleSpy.mockRestore();
|
|
2166
|
-
});
|
|
2167
|
-
|
|
2168
|
-
it("should keep issues when file not in fileContents", () => {
|
|
2169
|
-
const commits = [{ sha: "abc1234567890" }];
|
|
2170
|
-
const fileContents = new Map();
|
|
2171
|
-
const issues = [{ file: "missing.ts", line: "1", ruleId: "R1" }];
|
|
2172
|
-
const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents);
|
|
2173
|
-
expect(result).toEqual(issues);
|
|
2174
|
-
});
|
|
2175
|
-
|
|
2176
|
-
it("should keep issues when line range cannot be parsed", () => {
|
|
2177
|
-
const commits = [{ sha: "abc1234567890" }];
|
|
2178
|
-
const fileContents = new Map([["test.ts", [["-------", "line1"]]]]);
|
|
2179
|
-
const issues = [{ file: "test.ts", line: "abc", ruleId: "R1" }];
|
|
2180
|
-
const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents);
|
|
2181
|
-
expect(result).toEqual(issues);
|
|
2182
|
-
});
|
|
2183
|
-
|
|
2184
|
-
it("should handle range line numbers", () => {
|
|
2185
|
-
const commits = [{ sha: "abc1234567890" }];
|
|
2186
|
-
const fileContents = new Map([
|
|
2187
|
-
[
|
|
2188
|
-
"test.ts",
|
|
2189
|
-
[
|
|
2190
|
-
["-------", "line1"],
|
|
2191
|
-
["abc1234", "line2"],
|
|
2192
|
-
["-------", "line3"],
|
|
2193
|
-
],
|
|
2194
|
-
],
|
|
2195
|
-
]);
|
|
2196
|
-
const issues = [{ file: "test.ts", line: "1-3", ruleId: "R1" }];
|
|
2197
|
-
const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents);
|
|
2198
|
-
expect(result).toHaveLength(1); // 只要范围内有一行匹配就保留
|
|
2199
|
-
});
|
|
2200
|
-
|
|
2201
|
-
it("should log when file not in fileContents at verbose level 3", () => {
|
|
2202
|
-
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
2203
|
-
const commits = [{ sha: "abc1234567890" }];
|
|
2204
|
-
const fileContents = new Map();
|
|
2205
|
-
const issues = [{ file: "missing.ts", line: "1", ruleId: "R1" }];
|
|
2206
|
-
(service as any).filterIssuesByValidCommits(issues, commits, fileContents, 3);
|
|
2207
|
-
expect(consoleSpy).toHaveBeenCalledWith(
|
|
2208
|
-
" ✅ Issue missing.ts:1 - 文件不在 fileContents 中,保留",
|
|
2209
|
-
);
|
|
2210
|
-
consoleSpy.mockRestore();
|
|
2211
|
-
});
|
|
2212
|
-
|
|
2213
|
-
it("should log when line range cannot be parsed at verbose level 3", () => {
|
|
2214
|
-
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
2215
|
-
const commits = [{ sha: "abc1234567890" }];
|
|
2216
|
-
const fileContents = new Map([["test.ts", [["-------", "line1"]]]]);
|
|
2217
|
-
const issues = [{ file: "test.ts", line: "abc", ruleId: "R1" }];
|
|
2218
|
-
(service as any).filterIssuesByValidCommits(issues, commits, fileContents, 3);
|
|
2219
|
-
expect(consoleSpy).toHaveBeenCalledWith(" ✅ Issue test.ts:abc - 无法解析行号,保留");
|
|
2220
|
-
consoleSpy.mockRestore();
|
|
2221
|
-
});
|
|
2222
|
-
|
|
2223
|
-
it("should log detailed hash matching at verbose level 3", () => {
|
|
2224
|
-
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
2225
|
-
const commits = [{ sha: "abc1234567890" }];
|
|
2226
|
-
const fileContents = new Map([
|
|
2227
|
-
[
|
|
2228
|
-
"test.ts",
|
|
2229
|
-
[
|
|
2230
|
-
["-------", "line1"],
|
|
2231
|
-
["abc1234", "line2"],
|
|
2232
|
-
],
|
|
2233
|
-
],
|
|
2234
|
-
]);
|
|
2235
|
-
const issues = [{ file: "test.ts", line: "2", ruleId: "R1" }];
|
|
2236
|
-
(service as any).filterIssuesByValidCommits(issues, commits, fileContents, 3);
|
|
2237
|
-
expect(consoleSpy).toHaveBeenCalledWith(" 🔍 有效 commit hashes: abc1234");
|
|
2238
|
-
expect(consoleSpy).toHaveBeenCalledWith(
|
|
2239
|
-
" ✅ Issue test.ts:2 - 行 2 hash=abc1234 匹配,保留",
|
|
2240
|
-
);
|
|
2241
|
-
consoleSpy.mockRestore();
|
|
2242
|
-
});
|
|
2243
|
-
});
|
|
2244
|
-
|
|
2245
1277
|
describe("ReviewService.ensureClaudeCli", () => {
|
|
2246
1278
|
it("should do nothing when claude is already installed", async () => {
|
|
2247
1279
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
2248
1280
|
// execSync is already mocked globally
|
|
2249
1281
|
|
|
2250
|
-
await
|
|
1282
|
+
await service.ensureClaudeCli();
|
|
2251
1283
|
expect(consoleSpy).not.toHaveBeenCalledWith("🔧 Claude CLI 未安装,正在安装...");
|
|
2252
1284
|
consoleSpy.mockRestore();
|
|
2253
1285
|
});
|
|
@@ -2262,7 +1294,7 @@ describe("ReviewService", () => {
|
|
|
2262
1294
|
})
|
|
2263
1295
|
.mockImplementationOnce(() => Buffer.from(""));
|
|
2264
1296
|
|
|
2265
|
-
await
|
|
1297
|
+
await service.ensureClaudeCli();
|
|
2266
1298
|
expect(consoleSpy).toHaveBeenCalledWith("🔧 Claude CLI 未安装,正在安装...");
|
|
2267
1299
|
expect(consoleSpy).toHaveBeenCalledWith("✅ Claude CLI 安装完成");
|
|
2268
1300
|
consoleSpy.mockRestore();
|
|
@@ -2278,7 +1310,7 @@ describe("ReviewService", () => {
|
|
|
2278
1310
|
throw new Error("install failed");
|
|
2279
1311
|
});
|
|
2280
1312
|
|
|
2281
|
-
await expect(
|
|
1313
|
+
await expect(service.ensureClaudeCli()).rejects.toThrow(
|
|
2282
1314
|
"Claude CLI 安装失败: install failed",
|
|
2283
1315
|
);
|
|
2284
1316
|
});
|