@spaceflow/review 0.81.0 → 0.82.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/dist/index.js +798 -717
- package/package.json +2 -2
- package/src/README.md +0 -1
- package/src/changed-file-collection.ts +87 -0
- package/src/mcp/index.ts +5 -1
- package/src/prompt/issue-verify.ts +8 -3
- package/src/review-context.spec.ts +214 -0
- package/src/review-issue-filter.spec.ts +742 -0
- package/src/review-issue-filter.ts +20 -279
- package/src/review-llm.spec.ts +287 -0
- package/src/review-llm.ts +19 -23
- package/src/review-report/formatters/markdown.formatter.ts +6 -7
- package/src/review-result-model.spec.ts +35 -4
- package/src/review-result-model.ts +58 -10
- package/src/review-source-resolver.ts +636 -0
- package/src/review-spec/review-spec.service.spec.ts +5 -4
- package/src/review-spec/review-spec.service.ts +5 -15
- package/src/review.service.spec.ts +142 -1154
- package/src/review.service.ts +177 -534
- package/src/types/changed-file-collection.ts +5 -0
- package/src/types/review-source-resolver.ts +55 -0
|
@@ -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,12 +641,12 @@ 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
|
});
|
|
@@ -1218,9 +654,8 @@ describe("ReviewService", () => {
|
|
|
1218
654
|
|
|
1219
655
|
describe("ReviewService.execute - flush mode", () => {
|
|
1220
656
|
it("should route to executeCollectOnly when flush is true", async () => {
|
|
1221
|
-
const
|
|
1222
|
-
|
|
1223
|
-
mockReviewReportService.formatStatsTerminal = vi.fn().mockReturnValue("stats");
|
|
657
|
+
const flushResult = { issues: [{ file: "a.ts", line: "1", ruleId: "R1" }], summary: [] };
|
|
658
|
+
service._reviewReportService.formatStatsTerminal = vi.fn().mockReturnValue("stats") as any;
|
|
1224
659
|
gitProvider.listPullReviews.mockResolvedValue([] as any);
|
|
1225
660
|
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
1226
661
|
gitProvider.getPullRequestCommits.mockResolvedValue([] as any);
|
|
@@ -1228,8 +663,8 @@ describe("ReviewService", () => {
|
|
|
1228
663
|
vi.spyOn(ReviewResultModel, "loadFromPr").mockResolvedValue(
|
|
1229
664
|
ReviewResultModel.create(
|
|
1230
665
|
new PullRequestModel(gitProvider as any, "o", "r", 1),
|
|
1231
|
-
|
|
1232
|
-
|
|
666
|
+
flushResult as any,
|
|
667
|
+
service._resultModelDeps,
|
|
1233
668
|
),
|
|
1234
669
|
);
|
|
1235
670
|
const context = {
|
|
@@ -1250,14 +685,11 @@ describe("ReviewService", () => {
|
|
|
1250
685
|
describe("ReviewService.executeDeletionOnly", () => {
|
|
1251
686
|
it("should throw when no llmMode", async () => {
|
|
1252
687
|
const context = { owner: "o", repo: "r", prNumber: 1, ci: false, dryRun: false };
|
|
1253
|
-
await expect(
|
|
1254
|
-
"必须指定 LLM 类型",
|
|
1255
|
-
);
|
|
688
|
+
await expect(service.executeDeletionOnly(context)).rejects.toThrow("必须指定 LLM 类型");
|
|
1256
689
|
});
|
|
1257
690
|
|
|
1258
691
|
it("should execute deletion analysis with PR", async () => {
|
|
1259
|
-
|
|
1260
|
-
mockReviewReportService.formatMarkdown.mockReturnValue("report");
|
|
692
|
+
vi.mocked(service._reviewReportService.formatMarkdown).mockReturnValue("report");
|
|
1261
693
|
mockDeletionImpactService.analyzeDeletionImpact.mockResolvedValue({
|
|
1262
694
|
issues: [],
|
|
1263
695
|
summary: "ok",
|
|
@@ -1273,9 +705,8 @@ describe("ReviewService", () => {
|
|
|
1273
705
|
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
1274
706
|
gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "abc" } } as any);
|
|
1275
707
|
gitProvider.createIssueComment.mockResolvedValue({} as any);
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
const context = {
|
|
708
|
+
vi.mocked(service._config.getPluginConfig).mockReturnValue({});
|
|
709
|
+
const context: Partial<ReviewContext> = {
|
|
1279
710
|
owner: "o",
|
|
1280
711
|
repo: "r",
|
|
1281
712
|
prNumber: 1,
|
|
@@ -1285,14 +716,13 @@ describe("ReviewService", () => {
|
|
|
1285
716
|
deletionAnalysisMode: "openai",
|
|
1286
717
|
verbose: 1,
|
|
1287
718
|
};
|
|
1288
|
-
const result = await
|
|
719
|
+
const result = await service.executeDeletionOnly(context);
|
|
1289
720
|
expect(result.success).toBe(true);
|
|
1290
721
|
expect(result.deletionImpact).toBeDefined();
|
|
1291
722
|
});
|
|
1292
723
|
|
|
1293
724
|
it("should post comment in CI mode for deletionOnly", async () => {
|
|
1294
|
-
|
|
1295
|
-
mockReviewReportService.formatMarkdown.mockReturnValue("report");
|
|
725
|
+
vi.mocked(service._reviewReportService.formatMarkdown).mockReturnValue("report");
|
|
1296
726
|
mockDeletionImpactService.analyzeDeletionImpact.mockResolvedValue({
|
|
1297
727
|
issues: [],
|
|
1298
728
|
summary: "ok",
|
|
@@ -1308,9 +738,8 @@ describe("ReviewService", () => {
|
|
|
1308
738
|
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
1309
739
|
gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "abc" } } as any);
|
|
1310
740
|
gitProvider.createIssueComment.mockResolvedValue({} as any);
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
const context = {
|
|
741
|
+
vi.mocked(service._config.getPluginConfig).mockReturnValue({});
|
|
742
|
+
const context: Partial<ReviewContext> = {
|
|
1314
743
|
owner: "o",
|
|
1315
744
|
repo: "r",
|
|
1316
745
|
prNumber: 1,
|
|
@@ -1320,7 +749,7 @@ describe("ReviewService", () => {
|
|
|
1320
749
|
deletionAnalysisMode: "openai",
|
|
1321
750
|
verbose: 1,
|
|
1322
751
|
};
|
|
1323
|
-
const result = await
|
|
752
|
+
const result = await service.executeDeletionOnly(context);
|
|
1324
753
|
expect(result.success).toBe(true);
|
|
1325
754
|
expect(gitProvider.createIssueComment).toHaveBeenCalled();
|
|
1326
755
|
});
|
|
@@ -1352,7 +781,7 @@ describe("ReviewService", () => {
|
|
|
1352
781
|
|
|
1353
782
|
it("should auto-detect prNumber from event in CI mode", async () => {
|
|
1354
783
|
configService.get.mockReturnValue({ repository: "owner/repo", refName: "main" });
|
|
1355
|
-
vi.spyOn(
|
|
784
|
+
vi.spyOn(service._contextBuilder, "getPrNumberFromEvent").mockResolvedValue(42);
|
|
1356
785
|
gitProvider.getPullRequest.mockResolvedValue({ title: "feat: test" } as any);
|
|
1357
786
|
const options = { dryRun: false, ci: true, verbose: 1 };
|
|
1358
787
|
const context = await service.getContextFromEnv(options as any);
|
|
@@ -1433,8 +862,7 @@ describe("ReviewService", () => {
|
|
|
1433
862
|
|
|
1434
863
|
it("should merge references from options and config", async () => {
|
|
1435
864
|
configService.get.mockReturnValue({ repository: "owner/repo", refName: "main" });
|
|
1436
|
-
|
|
1437
|
-
configReader.getPluginConfig.mockReturnValue({ references: ["config-ref"] });
|
|
865
|
+
vi.mocked(service._config.getPluginConfig).mockReturnValue({ references: ["config-ref"] });
|
|
1438
866
|
const options = { dryRun: false, ci: false, references: ["opt-ref"] };
|
|
1439
867
|
const context = await service.getContextFromEnv(options as any);
|
|
1440
868
|
expect(context.specSources).toContain("opt-ref");
|
|
@@ -1453,17 +881,7 @@ describe("ReviewService", () => {
|
|
|
1453
881
|
describe("ReviewService.ensureClaudeCli", () => {
|
|
1454
882
|
it("should not throw when claude is installed", async () => {
|
|
1455
883
|
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);
|
|
884
|
+
await expect(service.ensureClaudeCli()).resolves.toBeUndefined();
|
|
1467
885
|
});
|
|
1468
886
|
});
|
|
1469
887
|
|
|
@@ -1479,11 +897,11 @@ describe("ReviewService", () => {
|
|
|
1479
897
|
localMode: false,
|
|
1480
898
|
};
|
|
1481
899
|
|
|
1482
|
-
const result = await
|
|
900
|
+
const result = await service.resolveSourceData(context);
|
|
1483
901
|
|
|
1484
902
|
expect(result.isDirectFileMode).toBe(true);
|
|
1485
903
|
expect(result.isLocalMode).toBe(true);
|
|
1486
|
-
expect(result.changedFiles).toEqual([
|
|
904
|
+
expect(result.changedFiles.toArray()).toEqual([
|
|
1487
905
|
{ filename: "miniprogram/utils/asyncSharedUtilsLoader.js", status: "modified" },
|
|
1488
906
|
]);
|
|
1489
907
|
expect(mockGitSdkService.getUncommittedFiles).not.toHaveBeenCalled();
|
|
@@ -1502,138 +920,24 @@ describe("ReviewService", () => {
|
|
|
1502
920
|
localMode: false,
|
|
1503
921
|
};
|
|
1504
922
|
|
|
1505
|
-
const result = await
|
|
923
|
+
const result = await service.resolveSourceData(context);
|
|
1506
924
|
|
|
1507
925
|
expect(result.isDirectFileMode).toBe(true);
|
|
1508
|
-
expect(result.changedFiles).toEqual([
|
|
926
|
+
expect(result.changedFiles.toArray()).toEqual([
|
|
1509
927
|
{ filename: "miniprogram/utils/asyncSharedUtilsLoader.js", status: "modified" },
|
|
1510
928
|
]);
|
|
1511
929
|
});
|
|
1512
930
|
});
|
|
1513
931
|
|
|
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
932
|
describe("ReviewService.execute - CI with existingResult", () => {
|
|
1630
933
|
beforeEach(() => {
|
|
1631
|
-
vi.spyOn(service
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
934
|
+
vi.spyOn(service._llmProcessor, "runLLMReview").mockResolvedValue(
|
|
935
|
+
mockResult({
|
|
936
|
+
issues: [mockIssue({ file: "test.ts", line: "5", ruleId: "R1", reason: "new issue" })],
|
|
937
|
+
summary: [mockSummary({ file: "test.ts", summary: "ok" })],
|
|
938
|
+
}),
|
|
939
|
+
);
|
|
940
|
+
vi.spyOn(service._sourceResolver, "getFileContents").mockResolvedValue(new Map());
|
|
1637
941
|
});
|
|
1638
942
|
|
|
1639
943
|
it("should merge existing issues with new issues in CI mode", async () => {
|
|
@@ -1649,11 +953,11 @@ describe("ReviewService", () => {
|
|
|
1649
953
|
summary: [],
|
|
1650
954
|
round: 1,
|
|
1651
955
|
} as any,
|
|
1652
|
-
|
|
956
|
+
service._resultModelDeps,
|
|
1653
957
|
),
|
|
1654
958
|
);
|
|
1655
|
-
const configReader =
|
|
1656
|
-
configReader.getPluginConfig.mockReturnValue({});
|
|
959
|
+
const configReader = service._config;
|
|
960
|
+
vi.mocked(configReader.getPluginConfig).mockReturnValue({});
|
|
1657
961
|
gitProvider.getPullRequest.mockResolvedValue({ title: "PR", head: { sha: "abc" } } as any);
|
|
1658
962
|
gitProvider.getPullRequestCommits.mockResolvedValue([
|
|
1659
963
|
{ sha: "abc123", commit: { message: "fix" }, author: { id: 1, login: "dev" } },
|
|
@@ -1671,7 +975,7 @@ describe("ReviewService", () => {
|
|
|
1671
975
|
specSources: ["/spec"],
|
|
1672
976
|
dryRun: false,
|
|
1673
977
|
ci: true,
|
|
1674
|
-
llmMode: "openai",
|
|
978
|
+
llmMode: "openai" as const,
|
|
1675
979
|
verifyFixes: false,
|
|
1676
980
|
verbose: 1,
|
|
1677
981
|
};
|
|
@@ -1681,7 +985,7 @@ describe("ReviewService", () => {
|
|
|
1681
985
|
});
|
|
1682
986
|
|
|
1683
987
|
it("should verify fixes when verifyFixes is true", async () => {
|
|
1684
|
-
vi.
|
|
988
|
+
vi.mocked(ReviewResultModel.loadFromPr).mockResolvedValue(
|
|
1685
989
|
ReviewResultModel.create(
|
|
1686
990
|
new PullRequestModel(gitProvider as any, "o", "r", 1),
|
|
1687
991
|
{
|
|
@@ -1691,11 +995,11 @@ describe("ReviewService", () => {
|
|
|
1691
995
|
summary: [],
|
|
1692
996
|
round: 1,
|
|
1693
997
|
} as any,
|
|
1694
|
-
|
|
998
|
+
service._resultModelDeps,
|
|
1695
999
|
),
|
|
1696
1000
|
);
|
|
1697
|
-
const configReader =
|
|
1698
|
-
configReader.getPluginConfig.mockReturnValue({});
|
|
1001
|
+
const configReader = service._config;
|
|
1002
|
+
vi.mocked(configReader.getPluginConfig).mockReturnValue({});
|
|
1699
1003
|
gitProvider.getPullRequest.mockResolvedValue({ title: "PR", head: { sha: "abc" } } as any);
|
|
1700
1004
|
gitProvider.getPullRequestCommits.mockResolvedValue([
|
|
1701
1005
|
{ sha: "abc123", commit: { message: "fix" }, author: { id: 1, login: "dev" } },
|
|
@@ -1725,12 +1029,8 @@ describe("ReviewService", () => {
|
|
|
1725
1029
|
|
|
1726
1030
|
describe("ReviewService.execute - filterCommits branch", () => {
|
|
1727
1031
|
beforeEach(() => {
|
|
1728
|
-
vi.spyOn(service
|
|
1729
|
-
|
|
1730
|
-
issues: [],
|
|
1731
|
-
summary: [],
|
|
1732
|
-
});
|
|
1733
|
-
vi.spyOn(service as any, "getFileContents").mockResolvedValue(new Map());
|
|
1032
|
+
vi.spyOn(service._llmProcessor, "runLLMReview").mockResolvedValue(mockResult());
|
|
1033
|
+
vi.spyOn(service._sourceResolver, "getFileContents").mockResolvedValue(new Map());
|
|
1734
1034
|
vi.spyOn(ReviewResultModel, "loadFromPr").mockResolvedValue(null as any);
|
|
1735
1035
|
});
|
|
1736
1036
|
|
|
@@ -1828,27 +1128,9 @@ describe("ReviewService", () => {
|
|
|
1828
1128
|
});
|
|
1829
1129
|
});
|
|
1830
1130
|
|
|
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
1131
|
describe("ReviewService.executeDeletionOnly - baseRef/headRef mode", () => {
|
|
1849
1132
|
it("should execute with baseRef/headRef instead of PR", async () => {
|
|
1850
|
-
|
|
1851
|
-
mockReviewReportService.formatMarkdown.mockReturnValue("report");
|
|
1133
|
+
vi.mocked(service._reviewReportService.formatMarkdown).mockReturnValue("report");
|
|
1852
1134
|
mockDeletionImpactService.analyzeDeletionImpact.mockResolvedValue({
|
|
1853
1135
|
issues: [],
|
|
1854
1136
|
summary: "ok",
|
|
@@ -1859,7 +1141,7 @@ describe("ReviewService", () => {
|
|
|
1859
1141
|
mockGitSdkService.getCommitsBetweenRefs.mockResolvedValue([
|
|
1860
1142
|
{ sha: "abc", commit: { message: "fix" } },
|
|
1861
1143
|
]);
|
|
1862
|
-
const context = {
|
|
1144
|
+
const context: Partial<ReviewContext> = {
|
|
1863
1145
|
owner: "o",
|
|
1864
1146
|
repo: "r",
|
|
1865
1147
|
baseRef: "main",
|
|
@@ -1869,13 +1151,12 @@ describe("ReviewService", () => {
|
|
|
1869
1151
|
llmMode: "openai",
|
|
1870
1152
|
deletionAnalysisMode: "openai",
|
|
1871
1153
|
};
|
|
1872
|
-
const result = await
|
|
1154
|
+
const result = await service.executeDeletionOnly(context);
|
|
1873
1155
|
expect(result.success).toBe(true);
|
|
1874
1156
|
});
|
|
1875
1157
|
|
|
1876
1158
|
it("should filter files by includes in deletionOnly", async () => {
|
|
1877
|
-
|
|
1878
|
-
mockReviewReportService.formatMarkdown.mockReturnValue("report");
|
|
1159
|
+
vi.mocked(service._reviewReportService.formatMarkdown).mockReturnValue("report");
|
|
1879
1160
|
mockDeletionImpactService.analyzeDeletionImpact.mockResolvedValue({
|
|
1880
1161
|
issues: [],
|
|
1881
1162
|
summary: "ok",
|
|
@@ -1887,7 +1168,7 @@ describe("ReviewService", () => {
|
|
|
1887
1168
|
{ filename: "a.ts", status: "modified" },
|
|
1888
1169
|
{ filename: "b.md", status: "modified" },
|
|
1889
1170
|
] as any);
|
|
1890
|
-
const context = {
|
|
1171
|
+
const context: Partial<ReviewContext> = {
|
|
1891
1172
|
owner: "o",
|
|
1892
1173
|
repo: "r",
|
|
1893
1174
|
prNumber: 1,
|
|
@@ -1897,7 +1178,7 @@ describe("ReviewService", () => {
|
|
|
1897
1178
|
deletionAnalysisMode: "openai",
|
|
1898
1179
|
includes: ["*.ts"],
|
|
1899
1180
|
};
|
|
1900
|
-
const result = await
|
|
1181
|
+
const result = await service.executeDeletionOnly(context);
|
|
1901
1182
|
expect(result.success).toBe(true);
|
|
1902
1183
|
});
|
|
1903
1184
|
});
|
|
@@ -1913,19 +1194,20 @@ describe("ReviewService", () => {
|
|
|
1913
1194
|
|
|
1914
1195
|
describe("ReviewService.executeCollectOnly - CI post comment", () => {
|
|
1915
1196
|
it("should post comment in CI mode", async () => {
|
|
1916
|
-
const
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1197
|
+
const ciResult = { issues: [{ file: "a.ts", line: "1", ruleId: "R1" }], summary: [] };
|
|
1198
|
+
vi.mocked(service._reviewReportService.parseMarkdown).mockReturnValue({
|
|
1199
|
+
result: ciResult,
|
|
1200
|
+
} as any);
|
|
1201
|
+
service._reviewReportService.formatStatsTerminal = vi.fn().mockReturnValue("stats") as any;
|
|
1202
|
+
vi.mocked(service._reviewReportService.formatMarkdown).mockReturnValue("report");
|
|
1921
1203
|
gitProvider.listIssueComments.mockResolvedValue([
|
|
1922
1204
|
{ id: 10, body: "<!-- spaceflow-review --> content" },
|
|
1923
1205
|
] as any);
|
|
1924
1206
|
vi.spyOn(ReviewResultModel, "loadFromPr").mockResolvedValue(
|
|
1925
1207
|
ReviewResultModel.create(
|
|
1926
1208
|
new PullRequestModel(gitProvider as any, "o", "r", 1),
|
|
1927
|
-
|
|
1928
|
-
|
|
1209
|
+
ciResult as any,
|
|
1210
|
+
service._resultModelDeps,
|
|
1929
1211
|
),
|
|
1930
1212
|
);
|
|
1931
1213
|
gitProvider.listPullReviews.mockResolvedValue([] as any);
|
|
@@ -1933,321 +1215,27 @@ describe("ReviewService", () => {
|
|
|
1933
1215
|
gitProvider.getPullRequestCommits.mockResolvedValue([] as any);
|
|
1934
1216
|
gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "abc" } } as any);
|
|
1935
1217
|
gitProvider.updateIssueComment.mockResolvedValue({} as any);
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1218
|
+
vi.mocked(service._config.getPluginConfig).mockReturnValue({});
|
|
1219
|
+
const context: Partial<ReviewContext> = {
|
|
1220
|
+
owner: "o",
|
|
1221
|
+
repo: "r",
|
|
1222
|
+
prNumber: 1,
|
|
1223
|
+
ci: true,
|
|
1224
|
+
dryRun: false,
|
|
1225
|
+
verbose: 1,
|
|
1226
|
+
};
|
|
1227
|
+
const result = await service.executeCollectOnly(context);
|
|
1940
1228
|
expect(result.issues).toHaveLength(1);
|
|
1941
1229
|
expect(gitProvider.updateIssueComment).toHaveBeenCalled();
|
|
1942
1230
|
});
|
|
1943
1231
|
});
|
|
1944
1232
|
|
|
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
1233
|
describe("ReviewService.ensureClaudeCli", () => {
|
|
2246
1234
|
it("should do nothing when claude is already installed", async () => {
|
|
2247
1235
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
2248
1236
|
// execSync is already mocked globally
|
|
2249
1237
|
|
|
2250
|
-
await
|
|
1238
|
+
await service.ensureClaudeCli();
|
|
2251
1239
|
expect(consoleSpy).not.toHaveBeenCalledWith("🔧 Claude CLI 未安装,正在安装...");
|
|
2252
1240
|
consoleSpy.mockRestore();
|
|
2253
1241
|
});
|
|
@@ -2262,7 +1250,7 @@ describe("ReviewService", () => {
|
|
|
2262
1250
|
})
|
|
2263
1251
|
.mockImplementationOnce(() => Buffer.from(""));
|
|
2264
1252
|
|
|
2265
|
-
await
|
|
1253
|
+
await service.ensureClaudeCli();
|
|
2266
1254
|
expect(consoleSpy).toHaveBeenCalledWith("🔧 Claude CLI 未安装,正在安装...");
|
|
2267
1255
|
expect(consoleSpy).toHaveBeenCalledWith("✅ Claude CLI 安装完成");
|
|
2268
1256
|
consoleSpy.mockRestore();
|
|
@@ -2278,7 +1266,7 @@ describe("ReviewService", () => {
|
|
|
2278
1266
|
throw new Error("install failed");
|
|
2279
1267
|
});
|
|
2280
1268
|
|
|
2281
|
-
await expect(
|
|
1269
|
+
await expect(service.ensureClaudeCli()).rejects.toThrow(
|
|
2282
1270
|
"Claude CLI 安装失败: install failed",
|
|
2283
1271
|
);
|
|
2284
1272
|
});
|