@spaceflow/review 0.76.0 → 0.77.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 +12 -0
- package/dist/index.js +2654 -1872
- package/package.json +2 -2
- package/src/deletion-impact.service.ts +4 -2
- package/src/index.ts +34 -2
- package/src/locales/en/review.json +2 -1
- package/src/locales/zh-cn/review.json +2 -1
- package/src/pull-request-model.ts +236 -0
- package/src/review-context.ts +409 -0
- package/src/review-includes-filter.spec.ts +248 -0
- package/src/review-includes-filter.ts +144 -0
- package/src/review-issue-filter.ts +523 -0
- package/src/review-llm.ts +634 -0
- package/src/review-pr-comment-utils.ts +186 -0
- package/src/review-result-model.spec.ts +657 -0
- package/src/review-result-model.ts +1024 -0
- package/src/review-spec/types.ts +2 -0
- package/src/review.config.ts +9 -0
- package/src/review.service.spec.ts +93 -1626
- package/src/review.service.ts +531 -2765
- package/src/types/review-llm.ts +19 -0
- package/src/utils/review-llm.ts +32 -0
- package/tsconfig.json +1 -1
|
@@ -3,6 +3,8 @@ import { parseChangedLinesFromPatch } from "@spaceflow/core";
|
|
|
3
3
|
import { readFile } from "fs/promises";
|
|
4
4
|
import { ReviewService, ReviewContext, ReviewPrompt } from "./review.service";
|
|
5
5
|
import type { ReviewOptions } from "./review.config";
|
|
6
|
+
import { PullRequestModel } from "./pull-request-model";
|
|
7
|
+
import { ReviewResultModel } from "./review-result-model";
|
|
6
8
|
|
|
7
9
|
vi.mock("c12");
|
|
8
10
|
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
|
@@ -215,9 +217,8 @@ describe("ReviewService", () => {
|
|
|
215
217
|
issues: [],
|
|
216
218
|
summary: [],
|
|
217
219
|
});
|
|
218
|
-
vi.spyOn(service as any, "buildLineCommitMap").mockResolvedValue(new Map());
|
|
219
220
|
vi.spyOn(service as any, "getFileContents").mockResolvedValue(new Map());
|
|
220
|
-
vi.spyOn(
|
|
221
|
+
vi.spyOn(ReviewResultModel, "loadFromPr").mockResolvedValue(null as any);
|
|
221
222
|
});
|
|
222
223
|
|
|
223
224
|
it("should execute review for PR successfully", async () => {
|
|
@@ -563,7 +564,7 @@ describe("ReviewService", () => {
|
|
|
563
564
|
describe("ReviewService.runLLMReview", () => {
|
|
564
565
|
it("should call callLLM when llmMode is claude", async () => {
|
|
565
566
|
const callLLMSpy = vi
|
|
566
|
-
.spyOn(service as any, "callLLM")
|
|
567
|
+
.spyOn((service as any).llmProcessor, "callLLM")
|
|
567
568
|
.mockResolvedValue({ issues: [], summary: [] });
|
|
568
569
|
|
|
569
570
|
const mockPrompt: ReviewPrompt = {
|
|
@@ -577,7 +578,7 @@ describe("ReviewService", () => {
|
|
|
577
578
|
|
|
578
579
|
it("should call callLLM when llmMode is openai", async () => {
|
|
579
580
|
const callLLMSpy = vi
|
|
580
|
-
.spyOn(service as any, "callLLM")
|
|
581
|
+
.spyOn((service as any).llmProcessor, "callLLM")
|
|
581
582
|
.mockResolvedValue({ issues: [], summary: [] });
|
|
582
583
|
|
|
583
584
|
const mockPrompt: ReviewPrompt = {
|
|
@@ -602,7 +603,7 @@ describe("ReviewService", () => {
|
|
|
602
603
|
suggestion: "fix",
|
|
603
604
|
} as any,
|
|
604
605
|
];
|
|
605
|
-
const normalized = (service as any).normalizeIssues(issues);
|
|
606
|
+
const normalized = (service as any).llmProcessor.normalizeIssues(issues);
|
|
606
607
|
expect(normalized).toHaveLength(2);
|
|
607
608
|
expect(normalized[0].line).toBe("10");
|
|
608
609
|
expect(normalized[1].line).toBe("12");
|
|
@@ -621,269 +622,6 @@ describe("ReviewService", () => {
|
|
|
621
622
|
});
|
|
622
623
|
});
|
|
623
624
|
|
|
624
|
-
describe("ReviewService.updateIssueLineNumbers", () => {
|
|
625
|
-
beforeEach(() => {
|
|
626
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockImplementation((lineStr: string) => {
|
|
627
|
-
const lines: number[] = [];
|
|
628
|
-
const rangeMatch = lineStr.match(/^(\d+)-(\d+)$/);
|
|
629
|
-
if (rangeMatch) {
|
|
630
|
-
const start = parseInt(rangeMatch[1], 10);
|
|
631
|
-
const end = parseInt(rangeMatch[2], 10);
|
|
632
|
-
for (let i = start; i <= end; i++) {
|
|
633
|
-
lines.push(i);
|
|
634
|
-
}
|
|
635
|
-
} else {
|
|
636
|
-
const line = parseInt(lineStr, 10);
|
|
637
|
-
if (!isNaN(line)) {
|
|
638
|
-
lines.push(line);
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
return lines;
|
|
642
|
-
});
|
|
643
|
-
});
|
|
644
|
-
|
|
645
|
-
it("should update issue line numbers when code is inserted before", () => {
|
|
646
|
-
// 在第1行插入2行,原第5行变成第7行
|
|
647
|
-
const issues = [
|
|
648
|
-
{
|
|
649
|
-
file: "test.ts",
|
|
650
|
-
line: "5",
|
|
651
|
-
ruleId: "R1",
|
|
652
|
-
specFile: "s1.md",
|
|
653
|
-
reason: "test issue",
|
|
654
|
-
severity: "error",
|
|
655
|
-
code: "",
|
|
656
|
-
round: 1,
|
|
657
|
-
} as any,
|
|
658
|
-
];
|
|
659
|
-
const filePatchMap = new Map<string, string>([
|
|
660
|
-
[
|
|
661
|
-
"test.ts",
|
|
662
|
-
`@@ -1,3 +1,5 @@
|
|
663
|
-
line1
|
|
664
|
-
+new line 1
|
|
665
|
-
+new line 2
|
|
666
|
-
line2
|
|
667
|
-
line3`,
|
|
668
|
-
],
|
|
669
|
-
]);
|
|
670
|
-
|
|
671
|
-
const result = (service as any).updateIssueLineNumbers(issues, filePatchMap);
|
|
672
|
-
|
|
673
|
-
expect(result[0].line).toBe("7");
|
|
674
|
-
expect(result[0].originalLine).toBe("5");
|
|
675
|
-
});
|
|
676
|
-
|
|
677
|
-
it("should update issue line numbers when code is deleted before", () => {
|
|
678
|
-
// 删除第1-2行,原第5行变成第3行
|
|
679
|
-
const issues = [
|
|
680
|
-
{
|
|
681
|
-
file: "test.ts",
|
|
682
|
-
line: "5",
|
|
683
|
-
ruleId: "R1",
|
|
684
|
-
specFile: "s1.md",
|
|
685
|
-
reason: "test issue",
|
|
686
|
-
severity: "error",
|
|
687
|
-
code: "",
|
|
688
|
-
round: 1,
|
|
689
|
-
} as any,
|
|
690
|
-
];
|
|
691
|
-
const filePatchMap = new Map<string, string>([
|
|
692
|
-
[
|
|
693
|
-
"test.ts",
|
|
694
|
-
`@@ -1,4 +1,2 @@
|
|
695
|
-
-line1
|
|
696
|
-
-line2
|
|
697
|
-
line3
|
|
698
|
-
line4`,
|
|
699
|
-
],
|
|
700
|
-
]);
|
|
701
|
-
|
|
702
|
-
const result = (service as any).updateIssueLineNumbers(issues, filePatchMap);
|
|
703
|
-
|
|
704
|
-
expect(result[0].line).toBe("3");
|
|
705
|
-
expect(result[0].originalLine).toBe("5");
|
|
706
|
-
});
|
|
707
|
-
|
|
708
|
-
it("should mark issue as invalid when the line is deleted", () => {
|
|
709
|
-
// 删除第5行
|
|
710
|
-
const issues = [
|
|
711
|
-
{
|
|
712
|
-
file: "test.ts",
|
|
713
|
-
line: "5",
|
|
714
|
-
ruleId: "R1",
|
|
715
|
-
specFile: "s1.md",
|
|
716
|
-
reason: "test issue",
|
|
717
|
-
severity: "error",
|
|
718
|
-
code: "",
|
|
719
|
-
round: 1,
|
|
720
|
-
} as any,
|
|
721
|
-
];
|
|
722
|
-
const filePatchMap = new Map<string, string>([
|
|
723
|
-
[
|
|
724
|
-
"test.ts",
|
|
725
|
-
`@@ -5,1 +5,0 @@
|
|
726
|
-
-deleted line`,
|
|
727
|
-
],
|
|
728
|
-
]);
|
|
729
|
-
|
|
730
|
-
const result = (service as any).updateIssueLineNumbers(issues, filePatchMap);
|
|
731
|
-
|
|
732
|
-
expect(result[0].valid).toBe("false");
|
|
733
|
-
expect(result[0].originalLine).toBe("5");
|
|
734
|
-
});
|
|
735
|
-
|
|
736
|
-
it("should not update issue when file has no changes", () => {
|
|
737
|
-
const issues = [
|
|
738
|
-
{
|
|
739
|
-
file: "test.ts",
|
|
740
|
-
line: "5",
|
|
741
|
-
ruleId: "R1",
|
|
742
|
-
specFile: "s1.md",
|
|
743
|
-
reason: "test issue",
|
|
744
|
-
severity: "error",
|
|
745
|
-
code: "",
|
|
746
|
-
round: 1,
|
|
747
|
-
} as any,
|
|
748
|
-
];
|
|
749
|
-
const filePatchMap = new Map<string, string>([
|
|
750
|
-
[
|
|
751
|
-
"other.ts",
|
|
752
|
-
`@@ -1,1 +1,2 @@
|
|
753
|
-
line1
|
|
754
|
-
+new line`,
|
|
755
|
-
],
|
|
756
|
-
]);
|
|
757
|
-
|
|
758
|
-
const result = (service as any).updateIssueLineNumbers(issues, filePatchMap);
|
|
759
|
-
|
|
760
|
-
expect(result[0].line).toBe("5");
|
|
761
|
-
expect(result[0].originalLine).toBeUndefined();
|
|
762
|
-
});
|
|
763
|
-
|
|
764
|
-
it("should not update already fixed issues", () => {
|
|
765
|
-
const issues = [
|
|
766
|
-
{
|
|
767
|
-
file: "test.ts",
|
|
768
|
-
line: "5",
|
|
769
|
-
ruleId: "R1",
|
|
770
|
-
specFile: "s1.md",
|
|
771
|
-
reason: "test issue",
|
|
772
|
-
severity: "error",
|
|
773
|
-
code: "",
|
|
774
|
-
round: 1,
|
|
775
|
-
fixed: "2024-01-01T00:00:00Z",
|
|
776
|
-
} as any,
|
|
777
|
-
];
|
|
778
|
-
const filePatchMap = new Map<string, string>([
|
|
779
|
-
[
|
|
780
|
-
"test.ts",
|
|
781
|
-
`@@ -1,1 +1,3 @@
|
|
782
|
-
line1
|
|
783
|
-
+new line 1
|
|
784
|
-
+new line 2`,
|
|
785
|
-
],
|
|
786
|
-
]);
|
|
787
|
-
|
|
788
|
-
const result = (service as any).updateIssueLineNumbers(issues, filePatchMap);
|
|
789
|
-
|
|
790
|
-
expect(result[0].line).toBe("5");
|
|
791
|
-
expect(result[0].originalLine).toBeUndefined();
|
|
792
|
-
});
|
|
793
|
-
|
|
794
|
-
it("should not update invalid issues", () => {
|
|
795
|
-
const issues = [
|
|
796
|
-
{
|
|
797
|
-
file: "test.ts",
|
|
798
|
-
line: "5",
|
|
799
|
-
ruleId: "R1",
|
|
800
|
-
specFile: "s1.md",
|
|
801
|
-
reason: "test issue",
|
|
802
|
-
severity: "error",
|
|
803
|
-
code: "",
|
|
804
|
-
round: 1,
|
|
805
|
-
valid: "false",
|
|
806
|
-
} as any,
|
|
807
|
-
];
|
|
808
|
-
const filePatchMap = new Map<string, string>([
|
|
809
|
-
[
|
|
810
|
-
"test.ts",
|
|
811
|
-
`@@ -1,1 +1,3 @@
|
|
812
|
-
line1
|
|
813
|
-
+new line 1
|
|
814
|
-
+new line 2`,
|
|
815
|
-
],
|
|
816
|
-
]);
|
|
817
|
-
|
|
818
|
-
const result = (service as any).updateIssueLineNumbers(issues, filePatchMap);
|
|
819
|
-
|
|
820
|
-
expect(result[0].line).toBe("5");
|
|
821
|
-
expect(result[0].originalLine).toBeUndefined();
|
|
822
|
-
});
|
|
823
|
-
|
|
824
|
-
it("should handle range line numbers", () => {
|
|
825
|
-
// 在第1行插入2行,原第5-7行变成第7-9行
|
|
826
|
-
const issues = [
|
|
827
|
-
{
|
|
828
|
-
file: "test.ts",
|
|
829
|
-
line: "5-7",
|
|
830
|
-
ruleId: "R1",
|
|
831
|
-
specFile: "s1.md",
|
|
832
|
-
reason: "test issue",
|
|
833
|
-
severity: "error",
|
|
834
|
-
code: "",
|
|
835
|
-
round: 1,
|
|
836
|
-
} as any,
|
|
837
|
-
];
|
|
838
|
-
const filePatchMap = new Map<string, string>([
|
|
839
|
-
[
|
|
840
|
-
"test.ts",
|
|
841
|
-
`@@ -1,3 +1,5 @@
|
|
842
|
-
line1
|
|
843
|
-
+new line 1
|
|
844
|
-
+new line 2
|
|
845
|
-
line2
|
|
846
|
-
line3`,
|
|
847
|
-
],
|
|
848
|
-
]);
|
|
849
|
-
|
|
850
|
-
const result = (service as any).updateIssueLineNumbers(issues, filePatchMap);
|
|
851
|
-
|
|
852
|
-
expect(result[0].line).toBe("7-9");
|
|
853
|
-
expect(result[0].originalLine).toBe("5-7");
|
|
854
|
-
});
|
|
855
|
-
|
|
856
|
-
it("should preserve originalLine if already set", () => {
|
|
857
|
-
const issues = [
|
|
858
|
-
{
|
|
859
|
-
file: "test.ts",
|
|
860
|
-
line: "7",
|
|
861
|
-
originalLine: "3",
|
|
862
|
-
ruleId: "R1",
|
|
863
|
-
specFile: "s1.md",
|
|
864
|
-
reason: "test issue",
|
|
865
|
-
severity: "error",
|
|
866
|
-
code: "",
|
|
867
|
-
round: 1,
|
|
868
|
-
} as any,
|
|
869
|
-
];
|
|
870
|
-
const filePatchMap = new Map<string, string>([
|
|
871
|
-
[
|
|
872
|
-
"test.ts",
|
|
873
|
-
`@@ -1,1 +1,3 @@
|
|
874
|
-
line1
|
|
875
|
-
+new line 1
|
|
876
|
-
+new line 2`,
|
|
877
|
-
],
|
|
878
|
-
]);
|
|
879
|
-
|
|
880
|
-
const result = (service as any).updateIssueLineNumbers(issues, filePatchMap);
|
|
881
|
-
|
|
882
|
-
expect(result[0].line).toBe("9");
|
|
883
|
-
expect(result[0].originalLine).toBe("3");
|
|
884
|
-
});
|
|
885
|
-
});
|
|
886
|
-
|
|
887
625
|
describe("ReviewService.getFileContents", () => {
|
|
888
626
|
beforeEach(() => {
|
|
889
627
|
mockReviewSpecService.parseLineRange = vi.fn().mockImplementation((lineStr: string) => {
|
|
@@ -1237,206 +975,6 @@ describe("ReviewService", () => {
|
|
|
1237
975
|
});
|
|
1238
976
|
});
|
|
1239
977
|
|
|
1240
|
-
describe("ReviewService.calculateIssueStats", () => {
|
|
1241
|
-
it("should calculate stats for empty array", () => {
|
|
1242
|
-
const stats = (service as any).calculateIssueStats([]);
|
|
1243
|
-
expect(stats).toEqual({
|
|
1244
|
-
total: 0,
|
|
1245
|
-
fixed: 0,
|
|
1246
|
-
resolved: 0,
|
|
1247
|
-
invalid: 0,
|
|
1248
|
-
pending: 0,
|
|
1249
|
-
fixRate: 0,
|
|
1250
|
-
resolveRate: 0,
|
|
1251
|
-
});
|
|
1252
|
-
});
|
|
1253
|
-
|
|
1254
|
-
it("should calculate stats correctly", () => {
|
|
1255
|
-
const issues = [
|
|
1256
|
-
{ fixed: "2024-01-01" },
|
|
1257
|
-
{ fixed: "2024-01-02" },
|
|
1258
|
-
{ resolved: "2024-01-03" },
|
|
1259
|
-
{ valid: "false" },
|
|
1260
|
-
{},
|
|
1261
|
-
{},
|
|
1262
|
-
];
|
|
1263
|
-
const stats = (service as any).calculateIssueStats(issues);
|
|
1264
|
-
expect(stats.total).toBe(6);
|
|
1265
|
-
expect(stats.fixed).toBe(2);
|
|
1266
|
-
expect(stats.resolved).toBe(1);
|
|
1267
|
-
expect(stats.invalid).toBe(1);
|
|
1268
|
-
expect(stats.pending).toBe(2);
|
|
1269
|
-
expect(stats.fixRate).toBe(33.3);
|
|
1270
|
-
});
|
|
1271
|
-
});
|
|
1272
|
-
|
|
1273
|
-
describe("ReviewService.filterSpecsForFile", () => {
|
|
1274
|
-
it("should return empty for files without extension", () => {
|
|
1275
|
-
const specs = [{ extensions: ["ts"], includes: [], rules: [] }];
|
|
1276
|
-
expect((service as any).filterSpecsForFile(specs, "Makefile")).toEqual([]);
|
|
1277
|
-
});
|
|
1278
|
-
|
|
1279
|
-
it("should filter by extension", () => {
|
|
1280
|
-
const specs = [
|
|
1281
|
-
{ extensions: ["ts"], includes: [], rules: [{ id: "R1" }] },
|
|
1282
|
-
{ extensions: ["py"], includes: [], rules: [{ id: "R2" }] },
|
|
1283
|
-
];
|
|
1284
|
-
const result = (service as any).filterSpecsForFile(specs, "src/app.ts");
|
|
1285
|
-
expect(result).toHaveLength(1);
|
|
1286
|
-
expect(result[0].rules[0].id).toBe("R1");
|
|
1287
|
-
});
|
|
1288
|
-
|
|
1289
|
-
it("should filter by includes pattern when present", () => {
|
|
1290
|
-
const specs = [{ extensions: ["ts"], includes: ["**/*.spec.ts"], rules: [{ id: "R1" }] }];
|
|
1291
|
-
expect((service as any).filterSpecsForFile(specs, "src/app.spec.ts")).toHaveLength(1);
|
|
1292
|
-
expect((service as any).filterSpecsForFile(specs, "src/app.ts")).toHaveLength(0);
|
|
1293
|
-
});
|
|
1294
|
-
});
|
|
1295
|
-
|
|
1296
|
-
describe("ReviewService.buildSystemPrompt", () => {
|
|
1297
|
-
it("should include specs section in prompt", () => {
|
|
1298
|
-
const result = (service as any).buildSystemPrompt("## 规则内容");
|
|
1299
|
-
expect(result).toContain("## 规则内容");
|
|
1300
|
-
expect(result).toContain("代码审查专家");
|
|
1301
|
-
});
|
|
1302
|
-
});
|
|
1303
|
-
|
|
1304
|
-
describe("ReviewService.formatReviewComment", () => {
|
|
1305
|
-
it("should use markdown format in CI with PR", () => {
|
|
1306
|
-
const result = { issues: [], summary: [] };
|
|
1307
|
-
(service as any).formatReviewComment(result, { ci: true, prNumber: 1 });
|
|
1308
|
-
expect((service as any).reviewReportService.formatMarkdown).toHaveBeenCalled();
|
|
1309
|
-
});
|
|
1310
|
-
|
|
1311
|
-
it("should use terminal format by default", () => {
|
|
1312
|
-
const result = { issues: [], summary: [] };
|
|
1313
|
-
(service as any).formatReviewComment(result, {});
|
|
1314
|
-
expect((service as any).reviewReportService.format).toHaveBeenCalledWith(result, "terminal");
|
|
1315
|
-
});
|
|
1316
|
-
|
|
1317
|
-
it("should use specified outputFormat", () => {
|
|
1318
|
-
const result = { issues: [], summary: [] };
|
|
1319
|
-
(service as any).formatReviewComment(result, { outputFormat: "markdown" });
|
|
1320
|
-
expect((service as any).reviewReportService.formatMarkdown).toHaveBeenCalled();
|
|
1321
|
-
});
|
|
1322
|
-
});
|
|
1323
|
-
|
|
1324
|
-
describe("ReviewService.lineMatchesPosition", () => {
|
|
1325
|
-
it("should return false when no position", () => {
|
|
1326
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
1327
|
-
expect((service as any).lineMatchesPosition("10", undefined)).toBe(false);
|
|
1328
|
-
});
|
|
1329
|
-
|
|
1330
|
-
it("should return true when position is within range", () => {
|
|
1331
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10, 11, 12]);
|
|
1332
|
-
expect((service as any).lineMatchesPosition("10-12", 11)).toBe(true);
|
|
1333
|
-
});
|
|
1334
|
-
|
|
1335
|
-
it("should return false when position is outside range", () => {
|
|
1336
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10, 11, 12]);
|
|
1337
|
-
expect((service as any).lineMatchesPosition("10-12", 15)).toBe(false);
|
|
1338
|
-
});
|
|
1339
|
-
|
|
1340
|
-
it("should return false for empty line range", () => {
|
|
1341
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([]);
|
|
1342
|
-
expect((service as any).lineMatchesPosition("", 10)).toBe(false);
|
|
1343
|
-
});
|
|
1344
|
-
});
|
|
1345
|
-
|
|
1346
|
-
describe("ReviewService.issueToReviewComment", () => {
|
|
1347
|
-
it("should return null for invalid line", () => {
|
|
1348
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([]);
|
|
1349
|
-
const issue = { file: "test.ts", line: "abc", ruleId: "R1", specFile: "s1.md", reason: "r" };
|
|
1350
|
-
expect((service as any).issueToReviewComment(issue)).toBeNull();
|
|
1351
|
-
});
|
|
1352
|
-
|
|
1353
|
-
it("should convert issue to review comment", () => {
|
|
1354
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
1355
|
-
const issue = {
|
|
1356
|
-
file: "test.ts",
|
|
1357
|
-
line: "10",
|
|
1358
|
-
ruleId: "R1",
|
|
1359
|
-
specFile: "s1.md",
|
|
1360
|
-
reason: "问题描述",
|
|
1361
|
-
severity: "error",
|
|
1362
|
-
author: { login: "dev1" },
|
|
1363
|
-
suggestion: "fix code",
|
|
1364
|
-
};
|
|
1365
|
-
const result = (service as any).issueToReviewComment(issue);
|
|
1366
|
-
expect(result).not.toBeNull();
|
|
1367
|
-
expect(result.path).toBe("test.ts");
|
|
1368
|
-
expect(result.new_position).toBe(10);
|
|
1369
|
-
expect(result.body).toContain("🔴");
|
|
1370
|
-
expect(result.body).toContain("@dev1");
|
|
1371
|
-
expect(result.body).toContain("fix code");
|
|
1372
|
-
});
|
|
1373
|
-
|
|
1374
|
-
it("should handle warn severity", () => {
|
|
1375
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([5]);
|
|
1376
|
-
const issue = {
|
|
1377
|
-
file: "test.ts",
|
|
1378
|
-
line: "5",
|
|
1379
|
-
ruleId: "R1",
|
|
1380
|
-
specFile: "s1.md",
|
|
1381
|
-
reason: "r",
|
|
1382
|
-
severity: "warn",
|
|
1383
|
-
};
|
|
1384
|
-
const result = (service as any).issueToReviewComment(issue);
|
|
1385
|
-
expect(result.body).toContain("🟡");
|
|
1386
|
-
});
|
|
1387
|
-
|
|
1388
|
-
it("should handle issue without author", () => {
|
|
1389
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([5]);
|
|
1390
|
-
const issue = {
|
|
1391
|
-
file: "test.ts",
|
|
1392
|
-
line: "5",
|
|
1393
|
-
ruleId: "R1",
|
|
1394
|
-
specFile: "s1.md",
|
|
1395
|
-
reason: "r",
|
|
1396
|
-
severity: "info",
|
|
1397
|
-
};
|
|
1398
|
-
const result = (service as any).issueToReviewComment(issue);
|
|
1399
|
-
expect(result.body).toContain("未知");
|
|
1400
|
-
expect(result.body).toContain("⚪");
|
|
1401
|
-
});
|
|
1402
|
-
|
|
1403
|
-
it("should include commit info when present", () => {
|
|
1404
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([5]);
|
|
1405
|
-
const issue = {
|
|
1406
|
-
file: "test.ts",
|
|
1407
|
-
line: "5",
|
|
1408
|
-
ruleId: "R1",
|
|
1409
|
-
specFile: "s1.md",
|
|
1410
|
-
reason: "r",
|
|
1411
|
-
commit: "abc1234",
|
|
1412
|
-
};
|
|
1413
|
-
const result = (service as any).issueToReviewComment(issue);
|
|
1414
|
-
expect(result.body).toContain("abc1234");
|
|
1415
|
-
});
|
|
1416
|
-
});
|
|
1417
|
-
|
|
1418
|
-
describe("ReviewService.generateIssueKey", () => {
|
|
1419
|
-
it("should generate key from file, line, and ruleId", () => {
|
|
1420
|
-
const issue = { file: "test.ts", line: "10", ruleId: "R1" };
|
|
1421
|
-
expect((service as any).generateIssueKey(issue)).toBe("test.ts:10:R1");
|
|
1422
|
-
});
|
|
1423
|
-
});
|
|
1424
|
-
|
|
1425
|
-
describe("ReviewService.parseExistingReviewResult", () => {
|
|
1426
|
-
it("should return null when parseMarkdown returns null", () => {
|
|
1427
|
-
const mockReviewReportService = (service as any).reviewReportService;
|
|
1428
|
-
mockReviewReportService.parseMarkdown.mockReturnValue(null);
|
|
1429
|
-
expect((service as any).parseExistingReviewResult("body")).toBeNull();
|
|
1430
|
-
});
|
|
1431
|
-
|
|
1432
|
-
it("should return result from parsed markdown", () => {
|
|
1433
|
-
const mockReviewReportService = (service as any).reviewReportService;
|
|
1434
|
-
const mockResult = { issues: [{ id: 1 }], summary: [] };
|
|
1435
|
-
mockReviewReportService.parseMarkdown.mockReturnValue({ result: mockResult });
|
|
1436
|
-
expect((service as any).parseExistingReviewResult("body")).toEqual(mockResult);
|
|
1437
|
-
});
|
|
1438
|
-
});
|
|
1439
|
-
|
|
1440
978
|
describe("ReviewService.filterDuplicateIssues", () => {
|
|
1441
979
|
it("should filter issues that exist in valid existing issues", () => {
|
|
1442
980
|
const newIssues = [
|
|
@@ -1459,22 +997,6 @@ describe("ReviewService", () => {
|
|
|
1459
997
|
});
|
|
1460
998
|
});
|
|
1461
999
|
|
|
1462
|
-
describe("ReviewService.getFallbackTitle", () => {
|
|
1463
|
-
it("should return first commit message", () => {
|
|
1464
|
-
const commits = [{ commit: { message: "feat: add feature\n\ndetails" } }];
|
|
1465
|
-
expect((service as any).getFallbackTitle(commits)).toBe("feat: add feature");
|
|
1466
|
-
});
|
|
1467
|
-
|
|
1468
|
-
it("should return default when no commits", () => {
|
|
1469
|
-
expect((service as any).getFallbackTitle([])).toBe("PR 更新");
|
|
1470
|
-
});
|
|
1471
|
-
|
|
1472
|
-
it("should truncate long titles", () => {
|
|
1473
|
-
const commits = [{ commit: { message: "a".repeat(100) } }];
|
|
1474
|
-
expect((service as any).getFallbackTitle(commits).length).toBeLessThanOrEqual(50);
|
|
1475
|
-
});
|
|
1476
|
-
});
|
|
1477
|
-
|
|
1478
1000
|
describe("ReviewService.normalizeFilePaths", () => {
|
|
1479
1001
|
it("should return undefined for empty array", () => {
|
|
1480
1002
|
expect((service as any).normalizeFilePaths([])).toEqual([]);
|
|
@@ -1490,151 +1012,27 @@ describe("ReviewService", () => {
|
|
|
1490
1012
|
});
|
|
1491
1013
|
});
|
|
1492
1014
|
|
|
1493
|
-
describe("ReviewService.
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
gitProvider.listIssueComments.mockRejectedValue(new Error("API error"));
|
|
1513
|
-
const result = await (service as any).getExistingReviewResult("o", "r", 1);
|
|
1514
|
-
expect(result).toBeNull();
|
|
1515
|
-
});
|
|
1516
|
-
});
|
|
1517
|
-
|
|
1518
|
-
describe("ReviewService.deleteExistingAiReviews", () => {
|
|
1519
|
-
it("should delete AI reviews via review API", async () => {
|
|
1520
|
-
gitProvider.listPullReviews.mockResolvedValue([
|
|
1521
|
-
{ id: 1, body: "<!-- spaceflow-review --> old review" },
|
|
1522
|
-
{ id: 2, body: "normal review" },
|
|
1523
|
-
] as any);
|
|
1524
|
-
gitProvider.listIssueComments.mockResolvedValue([] as any);
|
|
1525
|
-
gitProvider.deletePullReview.mockResolvedValue(undefined as any);
|
|
1526
|
-
await (service as any).deleteExistingAiReviews("o", "r", 1);
|
|
1527
|
-
expect(gitProvider.deletePullReview).toHaveBeenCalledWith("o", "r", 1, 1);
|
|
1528
|
-
expect(gitProvider.deletePullReview).toHaveBeenCalledTimes(1);
|
|
1529
|
-
});
|
|
1530
|
-
|
|
1531
|
-
it("should delete AI reviews via issue comment API", async () => {
|
|
1532
|
-
gitProvider.listPullReviews.mockResolvedValue([] as any);
|
|
1533
|
-
gitProvider.listIssueComments.mockResolvedValue([
|
|
1534
|
-
{ id: 10, body: "<!-- spaceflow-review --> old comment" },
|
|
1535
|
-
{ id: 11, body: "normal comment" },
|
|
1536
|
-
] as any);
|
|
1537
|
-
gitProvider.deleteIssueComment.mockResolvedValue(undefined as any);
|
|
1538
|
-
await (service as any).deleteExistingAiReviews("o", "r", 1);
|
|
1539
|
-
expect(gitProvider.deleteIssueComment).toHaveBeenCalledWith("o", "r", 10);
|
|
1540
|
-
expect(gitProvider.deleteIssueComment).toHaveBeenCalledTimes(1);
|
|
1541
|
-
});
|
|
1542
|
-
|
|
1543
|
-
it("should handle review API error gracefully", async () => {
|
|
1544
|
-
gitProvider.listPullReviews.mockRejectedValue(new Error("fail"));
|
|
1545
|
-
gitProvider.listIssueComments.mockResolvedValue([] as any);
|
|
1546
|
-
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
1547
|
-
await (service as any).deleteExistingAiReviews("o", "r", 1);
|
|
1548
|
-
expect(consoleSpy).toHaveBeenCalled();
|
|
1549
|
-
consoleSpy.mockRestore();
|
|
1550
|
-
});
|
|
1551
|
-
|
|
1552
|
-
it("should handle issue comment API error gracefully", async () => {
|
|
1553
|
-
gitProvider.listPullReviews.mockResolvedValue([] as any);
|
|
1554
|
-
gitProvider.listIssueComments.mockRejectedValue(new Error("fail"));
|
|
1555
|
-
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
1556
|
-
await (service as any).deleteExistingAiReviews("o", "r", 1);
|
|
1557
|
-
expect(consoleSpy).toHaveBeenCalled();
|
|
1558
|
-
consoleSpy.mockRestore();
|
|
1559
|
-
});
|
|
1560
|
-
|
|
1561
|
-
it("should log error when deleting comment fails", async () => {
|
|
1562
|
-
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
1563
|
-
gitProvider.listPullReviews.mockResolvedValue([] as any);
|
|
1564
|
-
gitProvider.listIssueComments.mockResolvedValue([
|
|
1565
|
-
{ id: 10, body: "<!-- spaceflow-review --> old comment" },
|
|
1566
|
-
] as any);
|
|
1567
|
-
gitProvider.deleteIssueComment.mockRejectedValue(new Error("delete failed"));
|
|
1568
|
-
|
|
1569
|
-
await (service as any).deleteExistingAiReviews("o", "r", 1);
|
|
1570
|
-
expect(consoleSpy).toHaveBeenCalledWith("⚠️ 删除评论 10 失败:", expect.any(Error));
|
|
1571
|
-
consoleSpy.mockRestore();
|
|
1572
|
-
});
|
|
1573
|
-
});
|
|
1574
|
-
|
|
1575
|
-
describe("ReviewService.invalidateIssuesForChangedFiles", () => {
|
|
1576
|
-
it("should return issues unchanged when no headSha", async () => {
|
|
1577
|
-
const issues = [{ file: "a.ts", line: "1" }];
|
|
1578
|
-
const result = await (service as any).invalidateIssuesForChangedFiles(
|
|
1579
|
-
issues,
|
|
1580
|
-
undefined,
|
|
1581
|
-
"o",
|
|
1582
|
-
"r",
|
|
1583
|
-
);
|
|
1584
|
-
expect(result).toBe(issues);
|
|
1585
|
-
});
|
|
1586
|
-
|
|
1587
|
-
it("should invalidate issues for changed files", async () => {
|
|
1588
|
-
gitProvider.getCommitDiff = vi
|
|
1589
|
-
.fn()
|
|
1590
|
-
.mockResolvedValue(
|
|
1591
|
-
"diff --git a/changed.ts b/changed.ts\n--- a/changed.ts\n+++ b/changed.ts\n@@ -1,1 +1,2 @@\n line1\n+new",
|
|
1592
|
-
) as any;
|
|
1593
|
-
const issues = [
|
|
1594
|
-
{ file: "changed.ts", line: "1", ruleId: "R1" },
|
|
1595
|
-
{ file: "unchanged.ts", line: "2", ruleId: "R2" },
|
|
1596
|
-
{ file: "changed.ts", line: "3", ruleId: "R3", fixed: "2024-01-01" },
|
|
1597
|
-
];
|
|
1598
|
-
const result = await (service as any).invalidateIssuesForChangedFiles(
|
|
1599
|
-
issues,
|
|
1600
|
-
"abc123",
|
|
1601
|
-
"o",
|
|
1602
|
-
"r",
|
|
1603
|
-
);
|
|
1604
|
-
expect(result).toHaveLength(3);
|
|
1605
|
-
expect(result[0].valid).toBe("false");
|
|
1606
|
-
expect(result[1].valid).toBeUndefined();
|
|
1607
|
-
expect(result[2].fixed).toBe("2024-01-01");
|
|
1608
|
-
});
|
|
1609
|
-
|
|
1610
|
-
it("should return issues unchanged when no diff files", async () => {
|
|
1611
|
-
gitProvider.getCommitDiff = vi.fn().mockResolvedValue("") as any;
|
|
1612
|
-
const issues = [{ file: "a.ts", line: "1" }];
|
|
1613
|
-
const result = await (service as any).invalidateIssuesForChangedFiles(
|
|
1614
|
-
issues,
|
|
1615
|
-
"abc123",
|
|
1616
|
-
"o",
|
|
1617
|
-
"r",
|
|
1618
|
-
);
|
|
1619
|
-
expect(result).toBe(issues);
|
|
1620
|
-
});
|
|
1621
|
-
|
|
1622
|
-
it("should handle API error gracefully", async () => {
|
|
1623
|
-
gitProvider.getCommitDiff = vi.fn().mockRejectedValue(new Error("fail")) as any;
|
|
1624
|
-
const issues = [{ file: "a.ts", line: "1" }];
|
|
1625
|
-
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
1626
|
-
const result = await (service as any).invalidateIssuesForChangedFiles(
|
|
1627
|
-
issues,
|
|
1628
|
-
"abc123",
|
|
1629
|
-
"o",
|
|
1630
|
-
"r",
|
|
1631
|
-
);
|
|
1632
|
-
expect(result).toBe(issues);
|
|
1633
|
-
consoleSpy.mockRestore();
|
|
1015
|
+
describe("ReviewService.fillIssueCode", () => {
|
|
1016
|
+
beforeEach(() => {
|
|
1017
|
+
mockReviewSpecService.parseLineRange = vi.fn().mockImplementation((lineStr: string) => {
|
|
1018
|
+
const lines: number[] = [];
|
|
1019
|
+
const rangeMatch = lineStr.match(/^(\d+)-(\d+)$/);
|
|
1020
|
+
if (rangeMatch) {
|
|
1021
|
+
const start = parseInt(rangeMatch[1], 10);
|
|
1022
|
+
const end = parseInt(rangeMatch[2], 10);
|
|
1023
|
+
for (let i = start; i <= end; i++) {
|
|
1024
|
+
lines.push(i);
|
|
1025
|
+
}
|
|
1026
|
+
} else {
|
|
1027
|
+
const line = parseInt(lineStr, 10);
|
|
1028
|
+
if (!isNaN(line)) {
|
|
1029
|
+
lines.push(line);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
return lines;
|
|
1033
|
+
});
|
|
1634
1034
|
});
|
|
1635
|
-
});
|
|
1636
1035
|
|
|
1637
|
-
describe("ReviewService.fillIssueCode", () => {
|
|
1638
1036
|
it("should fill code from file contents", async () => {
|
|
1639
1037
|
const issues = [{ file: "test.ts", line: "2" }];
|
|
1640
1038
|
const fileContents = new Map([
|
|
@@ -1746,239 +1144,6 @@ describe("ReviewService", () => {
|
|
|
1746
1144
|
});
|
|
1747
1145
|
});
|
|
1748
1146
|
|
|
1749
|
-
describe("ReviewService.reviewSingleFile", () => {
|
|
1750
|
-
it("should return issues from LLM stream", async () => {
|
|
1751
|
-
const llmProxy = (service as any).llmProxyService;
|
|
1752
|
-
const mockStream = (async function* () {
|
|
1753
|
-
yield {
|
|
1754
|
-
type: "result",
|
|
1755
|
-
response: {
|
|
1756
|
-
structuredOutput: {
|
|
1757
|
-
issues: [{ file: "test.ts", line: "1", ruleId: "R1", reason: "bad" }],
|
|
1758
|
-
summary: "found issues",
|
|
1759
|
-
},
|
|
1760
|
-
},
|
|
1761
|
-
};
|
|
1762
|
-
})();
|
|
1763
|
-
llmProxy.chatStream.mockReturnValue(mockStream);
|
|
1764
|
-
const filePrompt = { filename: "test.ts", systemPrompt: "sys", userPrompt: "user" };
|
|
1765
|
-
const result = await (service as any).reviewSingleFile("openai", filePrompt, 2);
|
|
1766
|
-
expect(result.issues).toHaveLength(1);
|
|
1767
|
-
expect(result.summary.file).toBe("test.ts");
|
|
1768
|
-
});
|
|
1769
|
-
|
|
1770
|
-
it("should throw on error event", async () => {
|
|
1771
|
-
const llmProxy = (service as any).llmProxyService;
|
|
1772
|
-
const mockStream = (async function* () {
|
|
1773
|
-
yield { type: "error", message: "LLM failed" };
|
|
1774
|
-
})();
|
|
1775
|
-
llmProxy.chatStream.mockReturnValue(mockStream);
|
|
1776
|
-
const filePrompt = { filename: "test.ts", systemPrompt: "sys", userPrompt: "user" };
|
|
1777
|
-
await expect((service as any).reviewSingleFile("openai", filePrompt)).rejects.toThrow(
|
|
1778
|
-
"LLM failed",
|
|
1779
|
-
);
|
|
1780
|
-
});
|
|
1781
|
-
|
|
1782
|
-
it("should return empty issues when no structured output", async () => {
|
|
1783
|
-
const llmProxy = (service as any).llmProxyService;
|
|
1784
|
-
const mockStream = (async function* () {
|
|
1785
|
-
yield { type: "result", response: {} };
|
|
1786
|
-
})();
|
|
1787
|
-
llmProxy.chatStream.mockReturnValue(mockStream);
|
|
1788
|
-
const filePrompt = { filename: "test.ts", systemPrompt: "sys", userPrompt: "user" };
|
|
1789
|
-
const result = await (service as any).reviewSingleFile("openai", filePrompt);
|
|
1790
|
-
expect(result.issues).toHaveLength(0);
|
|
1791
|
-
expect(result.summary.summary).toBe("");
|
|
1792
|
-
});
|
|
1793
|
-
});
|
|
1794
|
-
|
|
1795
|
-
describe("ReviewService.postOrUpdateReviewComment", () => {
|
|
1796
|
-
it("should post review comment", async () => {
|
|
1797
|
-
const configReader = (service as any).config;
|
|
1798
|
-
configReader.getPluginConfig.mockReturnValue({});
|
|
1799
|
-
gitProvider.listIssueComments.mockResolvedValue([] as any);
|
|
1800
|
-
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
1801
|
-
gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "abc123" } } as any);
|
|
1802
|
-
gitProvider.createIssueComment.mockResolvedValue({} as any);
|
|
1803
|
-
const result = { issues: [], summary: [], round: 1 };
|
|
1804
|
-
await (service as any).postOrUpdateReviewComment("o", "r", 1, result);
|
|
1805
|
-
expect(gitProvider.createIssueComment).toHaveBeenCalled();
|
|
1806
|
-
});
|
|
1807
|
-
|
|
1808
|
-
it("should update PR title when autoUpdatePrTitle enabled", async () => {
|
|
1809
|
-
const configReader = (service as any).config;
|
|
1810
|
-
configReader.getPluginConfig.mockReturnValue({ autoUpdatePrTitle: true });
|
|
1811
|
-
gitProvider.listIssueComments.mockResolvedValue([] as any);
|
|
1812
|
-
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
1813
|
-
gitProvider.editPullRequest.mockResolvedValue({} as any);
|
|
1814
|
-
gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "abc123" } } as any);
|
|
1815
|
-
gitProvider.createIssueComment.mockResolvedValue({} as any);
|
|
1816
|
-
const result = { issues: [], summary: [], round: 1, title: "New Title" };
|
|
1817
|
-
await (service as any).postOrUpdateReviewComment("o", "r", 1, result);
|
|
1818
|
-
expect(gitProvider.editPullRequest).toHaveBeenCalledWith("o", "r", 1, { title: "New Title" });
|
|
1819
|
-
});
|
|
1820
|
-
|
|
1821
|
-
it("should handle createIssueComment error gracefully", async () => {
|
|
1822
|
-
const configReader = (service as any).config;
|
|
1823
|
-
configReader.getPluginConfig.mockReturnValue({});
|
|
1824
|
-
gitProvider.listIssueComments.mockResolvedValue([] as any);
|
|
1825
|
-
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
1826
|
-
gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "abc123" } } as any);
|
|
1827
|
-
gitProvider.createIssueComment.mockRejectedValue(new Error("fail") as any);
|
|
1828
|
-
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
1829
|
-
const result = { issues: [], summary: [], round: 1 };
|
|
1830
|
-
await (service as any).postOrUpdateReviewComment("o", "r", 1, result);
|
|
1831
|
-
expect(consoleSpy).toHaveBeenCalled();
|
|
1832
|
-
consoleSpy.mockRestore();
|
|
1833
|
-
});
|
|
1834
|
-
|
|
1835
|
-
it("should include line comments when configured", async () => {
|
|
1836
|
-
const configReader = (service as any).config;
|
|
1837
|
-
configReader.getPluginConfig.mockReturnValue({ lineComments: true });
|
|
1838
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
1839
|
-
gitProvider.listIssueComments.mockResolvedValue([] as any);
|
|
1840
|
-
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
1841
|
-
gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "abc123" } } as any);
|
|
1842
|
-
gitProvider.createIssueComment.mockResolvedValue({} as any);
|
|
1843
|
-
gitProvider.createPullReview.mockResolvedValue({} as any);
|
|
1844
|
-
const result = {
|
|
1845
|
-
issues: [
|
|
1846
|
-
{
|
|
1847
|
-
file: "test.ts",
|
|
1848
|
-
line: "10",
|
|
1849
|
-
ruleId: "R1",
|
|
1850
|
-
specFile: "s.md",
|
|
1851
|
-
reason: "r",
|
|
1852
|
-
severity: "error",
|
|
1853
|
-
round: 1,
|
|
1854
|
-
},
|
|
1855
|
-
],
|
|
1856
|
-
summary: [],
|
|
1857
|
-
round: 1,
|
|
1858
|
-
};
|
|
1859
|
-
await (service as any).postOrUpdateReviewComment("o", "r", 1, result);
|
|
1860
|
-
expect(gitProvider.createPullReview.mock.calls.length).toBeGreaterThan(0);
|
|
1861
|
-
const callArgs = gitProvider.createPullReview.mock.calls[0];
|
|
1862
|
-
expect(callArgs[3].comments.length).toBeGreaterThan(0);
|
|
1863
|
-
});
|
|
1864
|
-
});
|
|
1865
|
-
|
|
1866
|
-
describe("ReviewService.syncResolvedComments", () => {
|
|
1867
|
-
it("should mark matched issues as resolved via path:line fallback", async () => {
|
|
1868
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
1869
|
-
gitProvider.listResolvedThreads.mockResolvedValue([
|
|
1870
|
-
{ path: "test.ts", line: 10, resolvedBy: { login: "user1" } },
|
|
1871
|
-
] as any);
|
|
1872
|
-
const result = { issues: [{ file: "test.ts", line: "10", ruleId: "Rule1" }] };
|
|
1873
|
-
await (service as any).syncResolvedComments("o", "r", 1, result);
|
|
1874
|
-
expect((result.issues[0] as any).resolved).toBeDefined();
|
|
1875
|
-
expect((result.issues[0] as any).resolvedBy).toEqual({ id: undefined, login: "user1" });
|
|
1876
|
-
});
|
|
1877
|
-
|
|
1878
|
-
it("should mark matched issues as resolved via issue key in body", async () => {
|
|
1879
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
1880
|
-
gitProvider.listResolvedThreads.mockResolvedValue([
|
|
1881
|
-
{
|
|
1882
|
-
path: "test.ts",
|
|
1883
|
-
line: 10,
|
|
1884
|
-
resolvedBy: { login: "user1" },
|
|
1885
|
-
body: `🟡 **问题**\n<!-- issue-key: test.ts:10:RuleA -->`,
|
|
1886
|
-
},
|
|
1887
|
-
] as any);
|
|
1888
|
-
const result = {
|
|
1889
|
-
issues: [{ file: "test.ts", line: "10", ruleId: "RuleA" }],
|
|
1890
|
-
};
|
|
1891
|
-
await (service as any).syncResolvedComments("o", "r", 1, result);
|
|
1892
|
-
expect((result.issues[0] as any).resolved).toBeDefined();
|
|
1893
|
-
expect((result.issues[0] as any).resolvedBy).toEqual({ id: undefined, login: "user1" });
|
|
1894
|
-
});
|
|
1895
|
-
|
|
1896
|
-
it("should match correct issue by issue key when multiple issues at same position", async () => {
|
|
1897
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
1898
|
-
gitProvider.listResolvedThreads.mockResolvedValue([
|
|
1899
|
-
{
|
|
1900
|
-
path: "test.ts",
|
|
1901
|
-
line: 10,
|
|
1902
|
-
resolvedBy: { login: "user1" },
|
|
1903
|
-
body: `🟡 **问题B**\n<!-- issue-key: test.ts:10:RuleB -->`,
|
|
1904
|
-
},
|
|
1905
|
-
] as any);
|
|
1906
|
-
const result = {
|
|
1907
|
-
issues: [
|
|
1908
|
-
{ file: "test.ts", line: "10", ruleId: "RuleA" } as any,
|
|
1909
|
-
{ file: "test.ts", line: "10", ruleId: "RuleB" } as any,
|
|
1910
|
-
],
|
|
1911
|
-
};
|
|
1912
|
-
await (service as any).syncResolvedComments("o", "r", 1, result);
|
|
1913
|
-
expect(result.issues[0].resolved).toBeUndefined(); // RuleA 未解决
|
|
1914
|
-
expect(result.issues[0].resolvedBy).toBeUndefined();
|
|
1915
|
-
expect(result.issues[1].resolved).toBeDefined(); // RuleB 已解决
|
|
1916
|
-
expect(result.issues[1].resolvedBy).toEqual({ id: undefined, login: "user1" });
|
|
1917
|
-
});
|
|
1918
|
-
|
|
1919
|
-
it("should skip when no resolved threads", async () => {
|
|
1920
|
-
gitProvider.listResolvedThreads.mockResolvedValue([] as any);
|
|
1921
|
-
const result = { issues: [{ file: "test.ts", line: "10", ruleId: "Rule1" }] };
|
|
1922
|
-
await (service as any).syncResolvedComments("o", "r", 1, result);
|
|
1923
|
-
expect((result.issues[0] as any).resolved).toBeUndefined();
|
|
1924
|
-
});
|
|
1925
|
-
|
|
1926
|
-
it("should skip threads without path", async () => {
|
|
1927
|
-
gitProvider.listResolvedThreads.mockResolvedValue([
|
|
1928
|
-
{ path: undefined, line: 10, resolvedBy: { login: "user1" } },
|
|
1929
|
-
] as any);
|
|
1930
|
-
const result = { issues: [{ file: "test.ts", line: "10", ruleId: "Rule1" }] };
|
|
1931
|
-
await (service as any).syncResolvedComments("o", "r", 1, result);
|
|
1932
|
-
expect((result.issues[0] as any).resolved).toBeUndefined();
|
|
1933
|
-
});
|
|
1934
|
-
|
|
1935
|
-
it("should handle error gracefully", async () => {
|
|
1936
|
-
gitProvider.listResolvedThreads.mockRejectedValue(new Error("fail"));
|
|
1937
|
-
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
1938
|
-
const result = { issues: [] };
|
|
1939
|
-
await (service as any).syncResolvedComments("o", "r", 1, result);
|
|
1940
|
-
expect(consoleSpy).toHaveBeenCalled();
|
|
1941
|
-
consoleSpy.mockRestore();
|
|
1942
|
-
});
|
|
1943
|
-
});
|
|
1944
|
-
|
|
1945
|
-
describe("ReviewService.callLLM", () => {
|
|
1946
|
-
it("should aggregate results from multiple files", async () => {
|
|
1947
|
-
const llmProxy = (service as any).llmProxyService;
|
|
1948
|
-
const mockStream = (async function* () {
|
|
1949
|
-
yield {
|
|
1950
|
-
type: "result",
|
|
1951
|
-
response: {
|
|
1952
|
-
structuredOutput: {
|
|
1953
|
-
issues: [{ file: "a.ts", line: "1", ruleId: "R1", reason: "bad" }],
|
|
1954
|
-
summary: "ok",
|
|
1955
|
-
},
|
|
1956
|
-
},
|
|
1957
|
-
};
|
|
1958
|
-
})();
|
|
1959
|
-
llmProxy.chatStream.mockReturnValue(mockStream);
|
|
1960
|
-
const reviewPrompt = {
|
|
1961
|
-
filePrompts: [{ filename: "a.ts", systemPrompt: "sys", userPrompt: "user" }],
|
|
1962
|
-
};
|
|
1963
|
-
const result = await (service as any).callLLM("openai", reviewPrompt);
|
|
1964
|
-
expect(result.issues).toHaveLength(1);
|
|
1965
|
-
expect(result.summary).toHaveLength(1);
|
|
1966
|
-
});
|
|
1967
|
-
|
|
1968
|
-
it("should handle failed file review", async () => {
|
|
1969
|
-
const llmProxy = (service as any).llmProxyService;
|
|
1970
|
-
const mockStream = (async function* () {
|
|
1971
|
-
yield { type: "error", message: "LLM failed" };
|
|
1972
|
-
})();
|
|
1973
|
-
llmProxy.chatStream.mockReturnValue(mockStream);
|
|
1974
|
-
const reviewPrompt = {
|
|
1975
|
-
filePrompts: [{ filename: "a.ts", systemPrompt: "sys", userPrompt: "user" }],
|
|
1976
|
-
};
|
|
1977
|
-
const result = await (service as any).callLLM("openai", reviewPrompt);
|
|
1978
|
-
expect(result.summary[0].summary).toContain("审查失败");
|
|
1979
|
-
});
|
|
1980
|
-
});
|
|
1981
|
-
|
|
1982
1147
|
describe("ReviewService.executeCollectOnly", () => {
|
|
1983
1148
|
it("should return empty result when no existing review", async () => {
|
|
1984
1149
|
gitProvider.listPullReviews.mockResolvedValue([] as any);
|
|
@@ -1998,15 +1163,18 @@ describe("ReviewService", () => {
|
|
|
1998
1163
|
it("should collect and return existing review result", async () => {
|
|
1999
1164
|
const mockResult = { issues: [{ file: "a.ts", line: "1", ruleId: "R1" }], summary: [] };
|
|
2000
1165
|
const mockReviewReportService = (service as any).reviewReportService;
|
|
2001
|
-
mockReviewReportService.parseMarkdown.mockReturnValue({ result: mockResult });
|
|
2002
1166
|
mockReviewReportService.formatStatsTerminal = vi.fn().mockReturnValue("stats");
|
|
2003
|
-
gitProvider.listIssueComments.mockResolvedValue([
|
|
2004
|
-
{ id: 10, body: "<!-- spaceflow-review --> content" },
|
|
2005
|
-
] as any);
|
|
2006
1167
|
gitProvider.listPullReviews.mockResolvedValue([] as any);
|
|
2007
1168
|
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
2008
1169
|
gitProvider.getPullRequestCommits.mockResolvedValue([] as any);
|
|
2009
1170
|
gitProvider.getPullRequest.mockResolvedValue({} as any);
|
|
1171
|
+
vi.spyOn(ReviewResultModel, "loadFromPr").mockResolvedValue(
|
|
1172
|
+
ReviewResultModel.create(
|
|
1173
|
+
new PullRequestModel(gitProvider as any, "o", "r", 1),
|
|
1174
|
+
mockResult as any,
|
|
1175
|
+
(service as any).resultModelDeps,
|
|
1176
|
+
),
|
|
1177
|
+
);
|
|
2010
1178
|
const context = { owner: "o", repo: "r", prNumber: 1, ci: false, dryRun: false };
|
|
2011
1179
|
const result = await (service as any).executeCollectOnly(context);
|
|
2012
1180
|
expect(result.issues).toHaveLength(1);
|
|
@@ -2018,15 +1186,18 @@ describe("ReviewService", () => {
|
|
|
2018
1186
|
it("should route to executeCollectOnly when flush is true", async () => {
|
|
2019
1187
|
const mockResult = { issues: [{ file: "a.ts", line: "1", ruleId: "R1" }], summary: [] };
|
|
2020
1188
|
const mockReviewReportService = (service as any).reviewReportService;
|
|
2021
|
-
mockReviewReportService.parseMarkdown.mockReturnValue({ result: mockResult });
|
|
2022
1189
|
mockReviewReportService.formatStatsTerminal = vi.fn().mockReturnValue("stats");
|
|
2023
|
-
gitProvider.listIssueComments.mockResolvedValue([
|
|
2024
|
-
{ id: 10, body: "<!-- spaceflow-review --> content" },
|
|
2025
|
-
] as any);
|
|
2026
1190
|
gitProvider.listPullReviews.mockResolvedValue([] as any);
|
|
2027
1191
|
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
2028
1192
|
gitProvider.getPullRequestCommits.mockResolvedValue([] as any);
|
|
2029
1193
|
gitProvider.getPullRequest.mockResolvedValue({} as any);
|
|
1194
|
+
vi.spyOn(ReviewResultModel, "loadFromPr").mockResolvedValue(
|
|
1195
|
+
ReviewResultModel.create(
|
|
1196
|
+
new PullRequestModel(gitProvider as any, "o", "r", 1),
|
|
1197
|
+
mockResult as any,
|
|
1198
|
+
(service as any).resultModelDeps,
|
|
1199
|
+
),
|
|
1200
|
+
);
|
|
2030
1201
|
const context = {
|
|
2031
1202
|
owner: "o",
|
|
2032
1203
|
repo: "r",
|
|
@@ -2147,7 +1318,7 @@ describe("ReviewService", () => {
|
|
|
2147
1318
|
|
|
2148
1319
|
it("should auto-detect prNumber from event in CI mode", async () => {
|
|
2149
1320
|
configService.get.mockReturnValue({ repository: "owner/repo", refName: "main" });
|
|
2150
|
-
vi.spyOn(service as any, "getPrNumberFromEvent").mockResolvedValue(42);
|
|
1321
|
+
vi.spyOn((service as any).contextBuilder, "getPrNumberFromEvent").mockResolvedValue(42);
|
|
2151
1322
|
gitProvider.getPullRequest.mockResolvedValue({ title: "feat: test" } as any);
|
|
2152
1323
|
const options = { dryRun: false, ci: true, verbose: 1 };
|
|
2153
1324
|
const context = await service.getContextFromEnv(options as any);
|
|
@@ -2196,7 +1367,7 @@ describe("ReviewService", () => {
|
|
|
2196
1367
|
configService.get.mockReturnValue({ repository: "owner/repo", refName: "main" });
|
|
2197
1368
|
mockGitSdkService.getCurrentBranch.mockReturnValue("feature");
|
|
2198
1369
|
mockGitSdkService.getDefaultBranch.mockReturnValue("main");
|
|
2199
|
-
const options = { dryRun: false, ci: false, verbose: 1 };
|
|
1370
|
+
const options = { dryRun: false, ci: false, verbose: 1, local: false };
|
|
2200
1371
|
const context = await service.getContextFromEnv(options as any);
|
|
2201
1372
|
expect(context.headRef).toBe("feature");
|
|
2202
1373
|
expect(context.baseRef).toBe("main");
|
|
@@ -2206,7 +1377,7 @@ describe("ReviewService", () => {
|
|
|
2206
1377
|
configService.get.mockReturnValue({ repository: "owner/repo", refName: "main" });
|
|
2207
1378
|
mockGitSdkService.getCurrentBranch.mockReturnValue("feature");
|
|
2208
1379
|
mockGitSdkService.getDefaultBranch.mockReturnValue("main");
|
|
2209
|
-
const options = { dryRun: false, ci: false };
|
|
1380
|
+
const options = { dryRun: false, ci: false, local: false };
|
|
2210
1381
|
const context = await service.getContextFromEnv(options as any);
|
|
2211
1382
|
expect(context.headRef).toBe("feature");
|
|
2212
1383
|
expect(context.baseRef).toBe("main");
|
|
@@ -2238,203 +1409,23 @@ describe("ReviewService", () => {
|
|
|
2238
1409
|
});
|
|
2239
1410
|
});
|
|
2240
1411
|
|
|
2241
|
-
describe("ReviewService.getFileDirectoryInfo", () => {
|
|
2242
|
-
it("should return root directory marker for root files", async () => {
|
|
2243
|
-
const result = await (service as any).getFileDirectoryInfo("file.ts");
|
|
2244
|
-
expect(result).toBe("(根目录)");
|
|
2245
|
-
});
|
|
2246
|
-
});
|
|
2247
|
-
|
|
2248
1412
|
describe("ReviewService.getCommitsBetweenRefs", () => {
|
|
2249
1413
|
it("should return commits from git sdk", async () => {
|
|
2250
1414
|
mockGitSdkService.getCommitsBetweenRefs.mockResolvedValue([
|
|
2251
1415
|
{ sha: "abc", commit: { message: "fix" } },
|
|
2252
|
-
]);
|
|
2253
|
-
const result = await (service as any).getCommitsBetweenRefs("main", "feature");
|
|
2254
|
-
expect(result).toHaveLength(1);
|
|
2255
|
-
});
|
|
2256
|
-
});
|
|
2257
|
-
|
|
2258
|
-
describe("ReviewService.getFilesForCommit", () => {
|
|
2259
|
-
it("should return files from git sdk", async () => {
|
|
2260
|
-
mockGitSdkService.getFilesForCommit.mockResolvedValue([
|
|
2261
|
-
{ filename: "a.ts", status: "modified" },
|
|
2262
|
-
]);
|
|
2263
|
-
const result = await (service as any).getFilesForCommit("abc123");
|
|
2264
|
-
expect(result).toHaveLength(1);
|
|
2265
|
-
});
|
|
2266
|
-
});
|
|
2267
|
-
|
|
2268
|
-
describe("ReviewService.syncReactionsToIssues", () => {
|
|
2269
|
-
it("should skip when no AI review found", async () => {
|
|
2270
|
-
gitProvider.listPullReviews.mockResolvedValue([{ body: "normal" }] as any);
|
|
2271
|
-
const result = { issues: [] };
|
|
2272
|
-
await (service as any).syncReactionsToIssues("o", "r", 1, result);
|
|
2273
|
-
expect(result.issues).toEqual([]);
|
|
2274
|
-
});
|
|
2275
|
-
|
|
2276
|
-
it("should handle error gracefully", async () => {
|
|
2277
|
-
gitProvider.listPullReviews.mockRejectedValue(new Error("fail"));
|
|
2278
|
-
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
2279
|
-
const result = { issues: [] };
|
|
2280
|
-
await (service as any).syncReactionsToIssues("o", "r", 1, result);
|
|
2281
|
-
expect(consoleSpy).toHaveBeenCalled();
|
|
2282
|
-
consoleSpy.mockRestore();
|
|
2283
|
-
});
|
|
2284
|
-
it("should mark issue as invalid on thumbs down from reviewer", async () => {
|
|
2285
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
2286
|
-
gitProvider.listPullReviews.mockResolvedValue([
|
|
2287
|
-
{ id: 1, body: "<!-- spaceflow-review-lines --> content", user: { login: "bot" } },
|
|
2288
|
-
{ id: 2, body: "LGTM", user: { login: "reviewer1" } },
|
|
2289
|
-
] as any);
|
|
2290
|
-
gitProvider.getPullRequest.mockResolvedValue({
|
|
2291
|
-
head: { sha: "abc" },
|
|
2292
|
-
requested_reviewers: [],
|
|
2293
|
-
requested_reviewers_teams: [],
|
|
2294
|
-
} as any);
|
|
2295
|
-
gitProvider.listPullReviewComments.mockResolvedValue([
|
|
2296
|
-
{ id: 100, path: "test.ts", position: 10 },
|
|
2297
|
-
] as any);
|
|
2298
|
-
gitProvider.getPullReviewCommentReactions.mockResolvedValue([
|
|
2299
|
-
{ content: "-1", user: { login: "reviewer1" } },
|
|
2300
|
-
] as any);
|
|
2301
|
-
const result = { issues: [{ file: "test.ts", line: "10", valid: "true" }] };
|
|
2302
|
-
await (service as any).syncReactionsToIssues("o", "r", 1, result);
|
|
2303
|
-
expect(result.issues[0].valid).toBe("false");
|
|
2304
|
-
});
|
|
2305
|
-
|
|
2306
|
-
it("should add requested_reviewers to reviewers set", async () => {
|
|
2307
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
2308
|
-
gitProvider.listPullReviews.mockResolvedValue([
|
|
2309
|
-
{ id: 1, body: "<!-- spaceflow-review-lines --> content", user: { login: "bot" } },
|
|
2310
|
-
] as any);
|
|
2311
|
-
gitProvider.getPullRequest.mockResolvedValue({
|
|
2312
|
-
requested_reviewers: [{ login: "req-reviewer" }],
|
|
2313
|
-
requested_reviewers_teams: [],
|
|
2314
|
-
} as any);
|
|
2315
|
-
gitProvider.listPullReviewComments.mockResolvedValue([
|
|
2316
|
-
{ id: 100, path: "test.ts", position: 10 },
|
|
2317
|
-
] as any);
|
|
2318
|
-
gitProvider.getPullReviewCommentReactions.mockResolvedValue([
|
|
2319
|
-
{ content: "-1", user: { login: "req-reviewer" } },
|
|
2320
|
-
] as any);
|
|
2321
|
-
const result = { issues: [{ file: "test.ts", line: "10", valid: "true" }] };
|
|
2322
|
-
await (service as any).syncReactionsToIssues("o", "r", 1, result);
|
|
2323
|
-
expect(result.issues[0].valid).toBe("false");
|
|
2324
|
-
});
|
|
2325
|
-
|
|
2326
|
-
it("should skip comments without id", async () => {
|
|
2327
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
2328
|
-
gitProvider.listPullReviews.mockResolvedValue([
|
|
2329
|
-
{ id: 1, body: "<!-- spaceflow-review-lines --> content" },
|
|
2330
|
-
] as any);
|
|
2331
|
-
gitProvider.getPullRequest.mockResolvedValue({
|
|
2332
|
-
requested_reviewers: [],
|
|
2333
|
-
requested_reviewers_teams: [],
|
|
2334
|
-
} as any);
|
|
2335
|
-
gitProvider.listPullReviewComments.mockResolvedValue([
|
|
2336
|
-
{ path: "test.ts", position: 10 },
|
|
2337
|
-
] as any);
|
|
2338
|
-
const result = { issues: [{ file: "test.ts", line: "10", reactions: [] }] };
|
|
2339
|
-
await (service as any).syncReactionsToIssues("o", "r", 1, result);
|
|
2340
|
-
expect(result.issues[0].reactions).toHaveLength(0);
|
|
2341
|
-
});
|
|
2342
|
-
|
|
2343
|
-
it("should skip when reactions are empty", async () => {
|
|
2344
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
2345
|
-
gitProvider.listPullReviews.mockResolvedValue([
|
|
2346
|
-
{ id: 1, body: "<!-- spaceflow-review-lines --> content" },
|
|
2347
|
-
] as any);
|
|
2348
|
-
gitProvider.getPullRequest.mockResolvedValue({
|
|
2349
|
-
requested_reviewers: [],
|
|
2350
|
-
requested_reviewers_teams: [],
|
|
2351
|
-
} as any);
|
|
2352
|
-
gitProvider.listPullReviewComments.mockResolvedValue([
|
|
2353
|
-
{ id: 100, path: "test.ts", position: 10 },
|
|
2354
|
-
] as any);
|
|
2355
|
-
gitProvider.getPullReviewCommentReactions.mockResolvedValue([] as any);
|
|
2356
|
-
const result = { issues: [{ file: "test.ts", line: "10", reactions: [] }] };
|
|
2357
|
-
await (service as any).syncReactionsToIssues("o", "r", 1, result);
|
|
2358
|
-
expect(result.issues[0].reactions).toHaveLength(0);
|
|
2359
|
-
});
|
|
2360
|
-
|
|
2361
|
-
it("should store multiple reaction types", async () => {
|
|
2362
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
2363
|
-
gitProvider.listPullReviews.mockResolvedValue([
|
|
2364
|
-
{ id: 1, body: "<!-- spaceflow-review-lines --> content" },
|
|
2365
|
-
] as any);
|
|
2366
|
-
gitProvider.getPullRequest.mockResolvedValue({
|
|
2367
|
-
requested_reviewers: [],
|
|
2368
|
-
requested_reviewers_teams: [],
|
|
2369
|
-
} as any);
|
|
2370
|
-
gitProvider.listPullReviewComments.mockResolvedValue([
|
|
2371
|
-
{ id: 100, path: "test.ts", position: 10 },
|
|
2372
|
-
] as any);
|
|
2373
|
-
gitProvider.getPullReviewCommentReactions.mockResolvedValue([
|
|
2374
|
-
{ content: "+1", user: { login: "user1" } },
|
|
2375
|
-
{ content: "+1", user: { login: "user2" } },
|
|
2376
|
-
{ content: "heart", user: { login: "user1" } },
|
|
2377
|
-
] as any);
|
|
2378
|
-
const result = { issues: [{ file: "test.ts", line: "10", reactions: [] }] };
|
|
2379
|
-
await (service as any).syncReactionsToIssues("o", "r", 1, result);
|
|
2380
|
-
expect(result.issues[0].reactions).toHaveLength(2);
|
|
2381
|
-
});
|
|
2382
|
-
|
|
2383
|
-
it("should not mark as invalid when thumbs down from non-reviewer", async () => {
|
|
2384
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
2385
|
-
gitProvider.listPullReviews.mockResolvedValue([
|
|
2386
|
-
{ id: 1, body: "<!-- spaceflow-review-lines --> content" },
|
|
2387
|
-
] as any);
|
|
2388
|
-
gitProvider.getPullRequest.mockResolvedValue({
|
|
2389
|
-
requested_reviewers: [],
|
|
2390
|
-
requested_reviewers_teams: [],
|
|
2391
|
-
} as any);
|
|
2392
|
-
gitProvider.listPullReviewComments.mockResolvedValue([
|
|
2393
|
-
{ id: 100, path: "test.ts", position: 10 },
|
|
2394
|
-
] as any);
|
|
2395
|
-
gitProvider.getPullReviewCommentReactions.mockResolvedValue([
|
|
2396
|
-
{ content: "-1", user: { login: "random-user" } },
|
|
2397
|
-
] as any);
|
|
2398
|
-
const result = { issues: [{ file: "test.ts", line: "10", valid: "true", reactions: [] }] };
|
|
2399
|
-
expect(result.issues[0].valid).toBe("true");
|
|
2400
|
-
});
|
|
2401
|
-
});
|
|
2402
|
-
|
|
2403
|
-
describe("ReviewService.buildLineReviewBody", () => {
|
|
2404
|
-
it("should include previous round summary when round > 1", () => {
|
|
2405
|
-
const issues = [
|
|
2406
|
-
{ round: 2, fixed: "2024-01-01", resolved: undefined, valid: undefined },
|
|
2407
|
-
{ round: 2, resolved: "2024-01-02", fixed: undefined, valid: undefined },
|
|
2408
|
-
{ round: 2, valid: "false", fixed: undefined, resolved: undefined },
|
|
2409
|
-
{ round: 2, fixed: undefined, resolved: undefined, valid: undefined },
|
|
2410
|
-
];
|
|
2411
|
-
const allIssues = [
|
|
2412
|
-
...issues,
|
|
2413
|
-
{ round: 1, fixed: "2024-01-01" },
|
|
2414
|
-
{ round: 1, resolved: "2024-01-02" },
|
|
2415
|
-
{ round: 1, valid: "false" },
|
|
2416
|
-
{ round: 1 },
|
|
2417
|
-
];
|
|
2418
|
-
const result = (service as any).buildLineReviewBody(issues, 2, allIssues);
|
|
2419
|
-
expect(result).toContain("Round 1 回顾");
|
|
2420
|
-
expect(result).toContain("🟢 已修复 | 1");
|
|
2421
|
-
expect(result).toContain("⚪ 已解决 | 1");
|
|
2422
|
-
expect(result).toContain("❌ 无效 | 1");
|
|
2423
|
-
expect(result).toContain("⚠️ 待处理 | 1");
|
|
2424
|
-
});
|
|
2425
|
-
|
|
2426
|
-
it("should not include previous round summary when round <= 1", () => {
|
|
2427
|
-
const issues = [{ round: 1 }];
|
|
2428
|
-
const allIssues = [{ round: 1 }];
|
|
2429
|
-
const result = (service as any).buildLineReviewBody(issues, 1, allIssues);
|
|
2430
|
-
expect(result).not.toContain("Round 1 回顾");
|
|
1416
|
+
]);
|
|
1417
|
+
const result = await (service as any).getCommitsBetweenRefs("main", "feature");
|
|
1418
|
+
expect(result).toHaveLength(1);
|
|
2431
1419
|
});
|
|
1420
|
+
});
|
|
2432
1421
|
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
1422
|
+
describe("ReviewService.getFilesForCommit", () => {
|
|
1423
|
+
it("should return files from git sdk", async () => {
|
|
1424
|
+
mockGitSdkService.getFilesForCommit.mockResolvedValue([
|
|
1425
|
+
{ filename: "a.ts", status: "modified" },
|
|
1426
|
+
]);
|
|
1427
|
+
const result = await (service as any).getFilesForCommit("abc123");
|
|
1428
|
+
expect(result).toHaveLength(1);
|
|
2438
1429
|
});
|
|
2439
1430
|
});
|
|
2440
1431
|
|
|
@@ -2543,225 +1534,6 @@ describe("ReviewService", () => {
|
|
|
2543
1534
|
});
|
|
2544
1535
|
});
|
|
2545
1536
|
|
|
2546
|
-
describe("ReviewService.generatePrTitle", () => {
|
|
2547
|
-
it("should generate title from LLM", async () => {
|
|
2548
|
-
const llmProxy = (service as any).llmProxyService;
|
|
2549
|
-
const mockStream = (async function* () {
|
|
2550
|
-
yield { type: "text", content: "Feat: 新功能" };
|
|
2551
|
-
})();
|
|
2552
|
-
llmProxy.chatStream.mockReturnValue(mockStream);
|
|
2553
|
-
const commits = [{ sha: "abc", commit: { message: "feat: add" } }];
|
|
2554
|
-
const changedFiles = [{ filename: "a.ts", status: "modified" }];
|
|
2555
|
-
const result = await (service as any).generatePrTitle(commits, changedFiles);
|
|
2556
|
-
expect(result).toBe("Feat: 新功能");
|
|
2557
|
-
});
|
|
2558
|
-
|
|
2559
|
-
it("should fallback on error", async () => {
|
|
2560
|
-
const llmProxy = (service as any).llmProxyService;
|
|
2561
|
-
const mockStream = (async function* () {
|
|
2562
|
-
yield { type: "error", message: "fail" };
|
|
2563
|
-
})();
|
|
2564
|
-
llmProxy.chatStream.mockReturnValue(mockStream);
|
|
2565
|
-
const commits = [{ sha: "abc", commit: { message: "feat: add feature" } }];
|
|
2566
|
-
const result = await (service as any).generatePrTitle(commits, []);
|
|
2567
|
-
expect(result).toBe("feat: add feature");
|
|
2568
|
-
});
|
|
2569
|
-
});
|
|
2570
|
-
|
|
2571
|
-
describe("ReviewService.extractIssueKeyFromBody", () => {
|
|
2572
|
-
it("should extract issue key from AI comment body", () => {
|
|
2573
|
-
const body = `🟡 **问题描述**\n- **规则**: \`Rule1\`\n<!-- issue-key: test.ts:10:Rule1 -->`;
|
|
2574
|
-
expect((service as any).extractIssueKeyFromBody(body)).toBe("test.ts:10:Rule1");
|
|
2575
|
-
});
|
|
2576
|
-
|
|
2577
|
-
it("should return null for user reply without issue key marker", () => {
|
|
2578
|
-
expect((service as any).extractIssueKeyFromBody("这个问题已经修复了")).toBeNull();
|
|
2579
|
-
expect((service as any).extractIssueKeyFromBody("")).toBeNull();
|
|
2580
|
-
});
|
|
2581
|
-
});
|
|
2582
|
-
|
|
2583
|
-
describe("ReviewService.isAiGeneratedComment", () => {
|
|
2584
|
-
it("should detect comment with issue-key marker", () => {
|
|
2585
|
-
const body = `🟡 **问题**\n<!-- issue-key: test.ts:10:Rule1 -->`;
|
|
2586
|
-
expect((service as any).isAiGeneratedComment(body)).toBe(true);
|
|
2587
|
-
});
|
|
2588
|
-
|
|
2589
|
-
it("should detect comment with structured AI format (规则 + 文件)", () => {
|
|
2590
|
-
const body = ` **魔法字符串问题**\n- **文件**: \`test.ts:64-98\`\n- **规则**: \`JsTs.Base.NoMagicStringsAndNumbers\` (来自 \`js&ts.base.md\`)`;
|
|
2591
|
-
expect((service as any).isAiGeneratedComment(body)).toBe(true);
|
|
2592
|
-
});
|
|
2593
|
-
|
|
2594
|
-
it("should return false for normal user reply", () => {
|
|
2595
|
-
expect((service as any).isAiGeneratedComment("这个问题已经修复了")).toBe(false);
|
|
2596
|
-
expect((service as any).isAiGeneratedComment("LGTM")).toBe(false);
|
|
2597
|
-
expect((service as any).isAiGeneratedComment("")).toBe(false);
|
|
2598
|
-
});
|
|
2599
|
-
|
|
2600
|
-
it("should return false for partial match (only 规则 or only 文件)", () => {
|
|
2601
|
-
expect((service as any).isAiGeneratedComment("- **规则**: something")).toBe(false);
|
|
2602
|
-
expect((service as any).isAiGeneratedComment("- **文件**: something")).toBe(false);
|
|
2603
|
-
});
|
|
2604
|
-
});
|
|
2605
|
-
|
|
2606
|
-
describe("ReviewService.syncRepliesToIssues", () => {
|
|
2607
|
-
it("should sync user replies to matched issues and filter out AI comments", async () => {
|
|
2608
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
2609
|
-
const reviewComments = [
|
|
2610
|
-
{
|
|
2611
|
-
id: 1,
|
|
2612
|
-
path: "test.ts",
|
|
2613
|
-
position: 10,
|
|
2614
|
-
body: `🟡 **问题描述**\n<!-- issue-key: test.ts:10:JsTs.Base.Rule1 -->`,
|
|
2615
|
-
user: { id: 1, login: "bot" },
|
|
2616
|
-
created_at: "2024-01-01",
|
|
2617
|
-
},
|
|
2618
|
-
{
|
|
2619
|
-
id: 2,
|
|
2620
|
-
path: "test.ts",
|
|
2621
|
-
position: 10,
|
|
2622
|
-
body: "reply from user",
|
|
2623
|
-
user: { id: 2, login: "dev" },
|
|
2624
|
-
created_at: "2024-01-02",
|
|
2625
|
-
},
|
|
2626
|
-
];
|
|
2627
|
-
const result = {
|
|
2628
|
-
issues: [{ file: "test.ts", line: "10", ruleId: "JsTs.Base.Rule1", replies: [] }],
|
|
2629
|
-
};
|
|
2630
|
-
await (service as any).syncRepliesToIssues("o", "r", 1, reviewComments, result);
|
|
2631
|
-
expect(result.issues[0].replies).toHaveLength(1);
|
|
2632
|
-
expect(result.issues[0].replies[0].body).toBe("reply from user");
|
|
2633
|
-
expect(result.issues[0].replies[0].user.login).toBe("dev");
|
|
2634
|
-
});
|
|
2635
|
-
|
|
2636
|
-
it("should match user reply to correct issue by issue key when multiple issues at same position", async () => {
|
|
2637
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
2638
|
-
const reviewComments = [
|
|
2639
|
-
{
|
|
2640
|
-
id: 1,
|
|
2641
|
-
path: "test.ts",
|
|
2642
|
-
position: 10,
|
|
2643
|
-
body: `🔴 **问题A**\n<!-- issue-key: test.ts:10:JsTs.Base.RuleA -->`,
|
|
2644
|
-
user: { id: 1, login: "bot" },
|
|
2645
|
-
created_at: "2024-01-01T01:00:00Z",
|
|
2646
|
-
},
|
|
2647
|
-
{
|
|
2648
|
-
id: 2,
|
|
2649
|
-
path: "test.ts",
|
|
2650
|
-
position: 10,
|
|
2651
|
-
body: `🟡 **问题B**\n<!-- issue-key: test.ts:10:JsTs.Base.RuleB -->`,
|
|
2652
|
-
user: { id: 1, login: "bot" },
|
|
2653
|
-
created_at: "2024-01-01T02:00:00Z",
|
|
2654
|
-
},
|
|
2655
|
-
{
|
|
2656
|
-
id: 3,
|
|
2657
|
-
path: "test.ts",
|
|
2658
|
-
position: 10,
|
|
2659
|
-
body: "针对问题B的回复",
|
|
2660
|
-
user: { id: 2, login: "dev" },
|
|
2661
|
-
created_at: "2024-01-01T03:00:00Z",
|
|
2662
|
-
},
|
|
2663
|
-
];
|
|
2664
|
-
const result = {
|
|
2665
|
-
issues: [
|
|
2666
|
-
{ file: "test.ts", line: "10", ruleId: "JsTs.Base.RuleA" } as any,
|
|
2667
|
-
{ file: "test.ts", line: "10", ruleId: "JsTs.Base.RuleB" } as any,
|
|
2668
|
-
],
|
|
2669
|
-
};
|
|
2670
|
-
await (service as any).syncRepliesToIssues("o", "r", 1, reviewComments, result);
|
|
2671
|
-
expect(result.issues[0].replies).toBeUndefined(); // RuleA 无回复
|
|
2672
|
-
expect(result.issues[1].replies).toHaveLength(1);
|
|
2673
|
-
expect(result.issues[1].replies[0].body).toBe("针对问题B的回复");
|
|
2674
|
-
});
|
|
2675
|
-
|
|
2676
|
-
it("should skip comments without path or position", async () => {
|
|
2677
|
-
const reviewComments = [{ id: 1, body: "no path" }];
|
|
2678
|
-
const result = { issues: [] };
|
|
2679
|
-
await (service as any).syncRepliesToIssues("o", "r", 1, reviewComments, result);
|
|
2680
|
-
expect(result.issues).toEqual([]);
|
|
2681
|
-
});
|
|
2682
|
-
|
|
2683
|
-
it("should handle error gracefully", async () => {
|
|
2684
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockImplementation(() => {
|
|
2685
|
-
throw new Error("fail");
|
|
2686
|
-
});
|
|
2687
|
-
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
2688
|
-
const reviewComments = [
|
|
2689
|
-
{ id: 1, path: "test.ts", position: 10, body: "a", created_at: "2024-01-01" },
|
|
2690
|
-
{ id: 2, path: "test.ts", position: 10, body: "b", created_at: "2024-01-02" },
|
|
2691
|
-
];
|
|
2692
|
-
const result = { issues: [{ file: "test.ts", line: "10", replies: [] }] };
|
|
2693
|
-
await (service as any).syncRepliesToIssues("o", "r", 1, reviewComments, result);
|
|
2694
|
-
expect(consoleSpy).toHaveBeenCalled();
|
|
2695
|
-
consoleSpy.mockRestore();
|
|
2696
|
-
});
|
|
2697
|
-
|
|
2698
|
-
it("should fallback to path:position match when no issue key is available", async () => {
|
|
2699
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
2700
|
-
const reviewComments = [
|
|
2701
|
-
{
|
|
2702
|
-
id: 1,
|
|
2703
|
-
path: "test.ts",
|
|
2704
|
-
position: 10,
|
|
2705
|
-
body: "some comment without issue key",
|
|
2706
|
-
user: { id: 1, login: "user1" },
|
|
2707
|
-
created_at: "2024-01-01",
|
|
2708
|
-
},
|
|
2709
|
-
{
|
|
2710
|
-
id: 2,
|
|
2711
|
-
path: "test.ts",
|
|
2712
|
-
position: 10,
|
|
2713
|
-
body: "user reply",
|
|
2714
|
-
user: { id: 2, login: "user2" },
|
|
2715
|
-
created_at: "2024-01-02",
|
|
2716
|
-
},
|
|
2717
|
-
];
|
|
2718
|
-
const result = {
|
|
2719
|
-
issues: [{ file: "test.ts", line: "10", ruleId: "SomeRule" } as any],
|
|
2720
|
-
};
|
|
2721
|
-
await (service as any).syncRepliesToIssues("o", "r", 1, reviewComments, result);
|
|
2722
|
-
// 两条都不含 issue key,都会通过 fallback path:position 匹配
|
|
2723
|
-
expect(result.issues[0].replies).toHaveLength(2);
|
|
2724
|
-
});
|
|
2725
|
-
|
|
2726
|
-
it("should filter out bot comments with AI structured format but without issue-key", async () => {
|
|
2727
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
2728
|
-
const reviewComments = [
|
|
2729
|
-
{
|
|
2730
|
-
id: 1,
|
|
2731
|
-
path: "test.ts",
|
|
2732
|
-
position: 10,
|
|
2733
|
-
body: `🟡 **问题描述**\n<!-- issue-key: test.ts:10:JsTs.Base.ComplexFunc -->`,
|
|
2734
|
-
user: { id: 1, login: "bot" },
|
|
2735
|
-
created_at: "2024-01-01T01:00:00Z",
|
|
2736
|
-
},
|
|
2737
|
-
{
|
|
2738
|
-
id: 2,
|
|
2739
|
-
path: "test.ts",
|
|
2740
|
-
position: 10,
|
|
2741
|
-
body: ` **魔法字符串问题**\n- **文件**: \`test.ts:64-98\`\n- **规则**: \`JsTs.Base.NoMagicStringsAndNumbers\` (来自 \`js&ts.base.md\`)\n- **Commit**: 3390baa\n- **建议**:\n\`\`\`ts\nconst UNKNOWN = '未知';\n\`\`\``,
|
|
2742
|
-
user: { id: 12, login: "GiteaActions" },
|
|
2743
|
-
created_at: "2024-01-01T02:00:00Z",
|
|
2744
|
-
},
|
|
2745
|
-
{
|
|
2746
|
-
id: 3,
|
|
2747
|
-
path: "test.ts",
|
|
2748
|
-
position: 10,
|
|
2749
|
-
body: "已修复,谢谢",
|
|
2750
|
-
user: { id: 5, login: "dev" },
|
|
2751
|
-
created_at: "2024-01-01T03:00:00Z",
|
|
2752
|
-
},
|
|
2753
|
-
];
|
|
2754
|
-
const result = {
|
|
2755
|
-
issues: [{ file: "test.ts", line: "10", ruleId: "JsTs.Base.ComplexFunc" } as any],
|
|
2756
|
-
};
|
|
2757
|
-
await (service as any).syncRepliesToIssues("o", "r", 1, reviewComments, result);
|
|
2758
|
-
// bot 的结构化评论应被过滤,只保留用户的真实回复
|
|
2759
|
-
expect(result.issues[0].replies).toHaveLength(1);
|
|
2760
|
-
expect(result.issues[0].replies[0].body).toBe("已修复,谢谢");
|
|
2761
|
-
expect(result.issues[0].replies[0].user.login).toBe("dev");
|
|
2762
|
-
});
|
|
2763
|
-
});
|
|
2764
|
-
|
|
2765
1537
|
describe("ReviewService.execute - CI with existingResult", () => {
|
|
2766
1538
|
beforeEach(() => {
|
|
2767
1539
|
vi.spyOn(service as any, "runLLMReview").mockResolvedValue({
|
|
@@ -2769,16 +1541,25 @@ describe("ReviewService", () => {
|
|
|
2769
1541
|
issues: [{ file: "test.ts", line: "5", ruleId: "R1", reason: "new issue" }],
|
|
2770
1542
|
summary: [{ file: "test.ts", summary: "ok" }],
|
|
2771
1543
|
});
|
|
2772
|
-
vi.spyOn(service as any, "buildLineCommitMap").mockResolvedValue(new Map());
|
|
2773
1544
|
vi.spyOn(service as any, "getFileContents").mockResolvedValue(new Map());
|
|
2774
1545
|
});
|
|
2775
1546
|
|
|
2776
1547
|
it("should merge existing issues with new issues in CI mode", async () => {
|
|
2777
|
-
vi.spyOn(
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
1548
|
+
vi.spyOn(ReviewResultModel, "loadFromPr").mockResolvedValue(
|
|
1549
|
+
ReviewResultModel.create(
|
|
1550
|
+
new PullRequestModel(gitProvider as any, "o", "r", 1),
|
|
1551
|
+
{
|
|
1552
|
+
success: true,
|
|
1553
|
+
description: "",
|
|
1554
|
+
issues: [
|
|
1555
|
+
{ file: "old.ts", line: "1", ruleId: "R2", reason: "old issue", valid: "true" },
|
|
1556
|
+
],
|
|
1557
|
+
summary: [],
|
|
1558
|
+
round: 1,
|
|
1559
|
+
} as any,
|
|
1560
|
+
(service as any).resultModelDeps,
|
|
1561
|
+
),
|
|
1562
|
+
);
|
|
2782
1563
|
const configReader = (service as any).config;
|
|
2783
1564
|
configReader.getPluginConfig.mockReturnValue({});
|
|
2784
1565
|
gitProvider.getPullRequest.mockResolvedValue({ title: "PR", head: { sha: "abc" } } as any);
|
|
@@ -2808,11 +1589,19 @@ describe("ReviewService", () => {
|
|
|
2808
1589
|
});
|
|
2809
1590
|
|
|
2810
1591
|
it("should verify fixes when verifyFixes is true", async () => {
|
|
2811
|
-
vi.spyOn(
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
1592
|
+
vi.spyOn(ReviewResultModel, "loadFromPr").mockResolvedValue(
|
|
1593
|
+
ReviewResultModel.create(
|
|
1594
|
+
new PullRequestModel(gitProvider as any, "o", "r", 1),
|
|
1595
|
+
{
|
|
1596
|
+
success: true,
|
|
1597
|
+
description: "",
|
|
1598
|
+
issues: [{ file: "old.ts", line: "1", ruleId: "R2", reason: "old", valid: "true" }],
|
|
1599
|
+
summary: [],
|
|
1600
|
+
round: 1,
|
|
1601
|
+
} as any,
|
|
1602
|
+
(service as any).resultModelDeps,
|
|
1603
|
+
),
|
|
1604
|
+
);
|
|
2816
1605
|
const configReader = (service as any).config;
|
|
2817
1606
|
configReader.getPluginConfig.mockReturnValue({});
|
|
2818
1607
|
gitProvider.getPullRequest.mockResolvedValue({ title: "PR", head: { sha: "abc" } } as any);
|
|
@@ -2849,9 +1638,8 @@ describe("ReviewService", () => {
|
|
|
2849
1638
|
issues: [],
|
|
2850
1639
|
summary: [],
|
|
2851
1640
|
});
|
|
2852
|
-
vi.spyOn(service as any, "buildLineCommitMap").mockResolvedValue(new Map());
|
|
2853
1641
|
vi.spyOn(service as any, "getFileContents").mockResolvedValue(new Map());
|
|
2854
|
-
vi.spyOn(
|
|
1642
|
+
vi.spyOn(ReviewResultModel, "loadFromPr").mockResolvedValue(null as any);
|
|
2855
1643
|
});
|
|
2856
1644
|
|
|
2857
1645
|
it("should filter by specified commits", async () => {
|
|
@@ -3041,6 +1829,13 @@ describe("ReviewService", () => {
|
|
|
3041
1829
|
gitProvider.listIssueComments.mockResolvedValue([
|
|
3042
1830
|
{ id: 10, body: "<!-- spaceflow-review --> content" },
|
|
3043
1831
|
] as any);
|
|
1832
|
+
vi.spyOn(ReviewResultModel, "loadFromPr").mockResolvedValue(
|
|
1833
|
+
ReviewResultModel.create(
|
|
1834
|
+
new PullRequestModel(gitProvider as any, "o", "r", 1),
|
|
1835
|
+
mockResult as any,
|
|
1836
|
+
(service as any).resultModelDeps,
|
|
1837
|
+
),
|
|
1838
|
+
);
|
|
3044
1839
|
gitProvider.listPullReviews.mockResolvedValue([] as any);
|
|
3045
1840
|
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
3046
1841
|
gitProvider.getPullRequestCommits.mockResolvedValue([] as any);
|
|
@@ -3162,7 +1957,7 @@ describe("ReviewService", () => {
|
|
|
3162
1957
|
});
|
|
3163
1958
|
});
|
|
3164
1959
|
|
|
3165
|
-
describe("ReviewService.
|
|
1960
|
+
describe("ReviewService.buildBasicDescription", () => {
|
|
3166
1961
|
it("should build description from commits and files", async () => {
|
|
3167
1962
|
const llmProxy = (service as any).llmProxyService;
|
|
3168
1963
|
const mockStream = (async function* () {
|
|
@@ -3175,7 +1970,7 @@ describe("ReviewService", () => {
|
|
|
3175
1970
|
{ filename: "b.ts", status: "modified" },
|
|
3176
1971
|
{ filename: "c.ts", status: "deleted" },
|
|
3177
1972
|
];
|
|
3178
|
-
const result = await (service as any).
|
|
1973
|
+
const result = await (service as any).buildBasicDescription(commits, changedFiles);
|
|
3179
1974
|
expect(result.description).toContain("提交记录");
|
|
3180
1975
|
expect(result.description).toContain("文件变更");
|
|
3181
1976
|
expect(result.description).toContain("新增 1");
|
|
@@ -3189,47 +1984,11 @@ describe("ReviewService", () => {
|
|
|
3189
1984
|
yield { type: "text", content: "Feat: empty" };
|
|
3190
1985
|
})();
|
|
3191
1986
|
llmProxy.chatStream.mockReturnValue(mockStream);
|
|
3192
|
-
const result = await (service as any).
|
|
1987
|
+
const result = await (service as any).buildBasicDescription([], []);
|
|
3193
1988
|
expect(result.title).toBeDefined();
|
|
3194
1989
|
});
|
|
3195
1990
|
});
|
|
3196
1991
|
|
|
3197
|
-
describe("ReviewService.normalizeIssues - comma separated", () => {
|
|
3198
|
-
it("should split comma separated lines into multiple issues", () => {
|
|
3199
|
-
const issues = [
|
|
3200
|
-
{ file: "test.ts", line: "10, 20", ruleId: "R1", reason: "bad", suggestion: "fix it" },
|
|
3201
|
-
];
|
|
3202
|
-
const result = (service as any).normalizeIssues(issues);
|
|
3203
|
-
expect(result).toHaveLength(2);
|
|
3204
|
-
expect(result[0].line).toBe("10");
|
|
3205
|
-
expect(result[0].suggestion).toBe("fix it");
|
|
3206
|
-
expect(result[1].line).toBe("20");
|
|
3207
|
-
expect(result[1].suggestion).toContain("参考");
|
|
3208
|
-
});
|
|
3209
|
-
});
|
|
3210
|
-
|
|
3211
|
-
describe("ReviewService.formatReviewComment - terminal format", () => {
|
|
3212
|
-
it("should use terminal format when not CI", () => {
|
|
3213
|
-
const mockReviewReportService = (service as any).reviewReportService;
|
|
3214
|
-
mockReviewReportService.format.mockReturnValue("terminal output");
|
|
3215
|
-
const result = (service as any).formatReviewComment(
|
|
3216
|
-
{ issues: [], summary: [] },
|
|
3217
|
-
{ ci: false },
|
|
3218
|
-
);
|
|
3219
|
-
expect(result).toBe("terminal output");
|
|
3220
|
-
});
|
|
3221
|
-
|
|
3222
|
-
it("should use specified outputFormat", () => {
|
|
3223
|
-
const mockReviewReportService = (service as any).reviewReportService;
|
|
3224
|
-
mockReviewReportService.format.mockReturnValue("terminal output");
|
|
3225
|
-
const result = (service as any).formatReviewComment(
|
|
3226
|
-
{ issues: [], summary: [] },
|
|
3227
|
-
{ outputFormat: "terminal" },
|
|
3228
|
-
);
|
|
3229
|
-
expect(result).toBe("terminal output");
|
|
3230
|
-
});
|
|
3231
|
-
});
|
|
3232
|
-
|
|
3233
1992
|
describe("ReviewService.getFilesForCommit - no PR", () => {
|
|
3234
1993
|
it("should use git sdk when no prNumber", async () => {
|
|
3235
1994
|
mockGitSdkService.getFilesForCommit.mockResolvedValue(["a.ts", "b.ts"]);
|
|
@@ -3250,299 +2009,7 @@ describe("ReviewService", () => {
|
|
|
3250
2009
|
});
|
|
3251
2010
|
});
|
|
3252
2011
|
|
|
3253
|
-
describe("ReviewService.
|
|
3254
|
-
it("should build line commit map from commits", async () => {
|
|
3255
|
-
gitProvider.getCommitDiff = vi
|
|
3256
|
-
.fn()
|
|
3257
|
-
.mockResolvedValue(
|
|
3258
|
-
"diff --git a/file.ts b/file.ts\n--- a/file.ts\n+++ b/file.ts\n@@ -1,2 +1,3 @@\n line1\n+new line\n line2",
|
|
3259
|
-
) as any;
|
|
3260
|
-
const commits = [{ sha: "abc1234567890" }];
|
|
3261
|
-
const result = await (service as any).buildLineCommitMap("o", "r", commits);
|
|
3262
|
-
expect(result.has("file.ts")).toBe(true);
|
|
3263
|
-
expect(result.get("file.ts").get(2)).toBe("abc1234");
|
|
3264
|
-
});
|
|
3265
|
-
|
|
3266
|
-
it("should skip commits without sha", async () => {
|
|
3267
|
-
const commits = [{ sha: undefined }];
|
|
3268
|
-
const result = await (service as any).buildLineCommitMap("o", "r", commits);
|
|
3269
|
-
expect(result.size).toBe(0);
|
|
3270
|
-
});
|
|
3271
|
-
|
|
3272
|
-
it("should fallback to git sdk on API error", async () => {
|
|
3273
|
-
gitProvider.getCommitDiff = vi.fn().mockRejectedValue(new Error("fail")) as any;
|
|
3274
|
-
mockGitSdkService.getCommitDiff = vi.fn().mockReturnValue([]);
|
|
3275
|
-
const commits = [{ sha: "abc1234567890" }];
|
|
3276
|
-
const result = await (service as any).buildLineCommitMap("o", "r", commits);
|
|
3277
|
-
expect(mockGitSdkService.getCommitDiff).toHaveBeenCalledWith("abc1234567890");
|
|
3278
|
-
expect(result.size).toBe(0);
|
|
3279
|
-
});
|
|
3280
|
-
});
|
|
3281
|
-
|
|
3282
|
-
describe("ReviewService.invalidateIssuesForChangedFiles", () => {
|
|
3283
|
-
it("should return issues unchanged when no headSha", async () => {
|
|
3284
|
-
const issues = [{ file: "test.ts" }];
|
|
3285
|
-
const result = await (service as any).invalidateIssuesForChangedFiles(
|
|
3286
|
-
issues,
|
|
3287
|
-
undefined,
|
|
3288
|
-
"o",
|
|
3289
|
-
"r",
|
|
3290
|
-
);
|
|
3291
|
-
expect(result).toBe(issues);
|
|
3292
|
-
});
|
|
3293
|
-
|
|
3294
|
-
it("should log warning when no headSha", async () => {
|
|
3295
|
-
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
3296
|
-
const issues = [{ file: "test.ts" }];
|
|
3297
|
-
await (service as any).invalidateIssuesForChangedFiles(issues, undefined, "o", "r", 1);
|
|
3298
|
-
expect(consoleSpy).toHaveBeenCalledWith(" ⚠️ 无法获取 PR head SHA,跳过变更文件检查");
|
|
3299
|
-
consoleSpy.mockRestore();
|
|
3300
|
-
});
|
|
3301
|
-
|
|
3302
|
-
it("should invalidate issues for changed files", async () => {
|
|
3303
|
-
gitProvider.getCommitDiff = vi
|
|
3304
|
-
.fn()
|
|
3305
|
-
.mockResolvedValue(
|
|
3306
|
-
"diff --git a/changed.ts b/changed.ts\n--- a/changed.ts\n+++ b/changed.ts\n@@ -1,1 +1,2 @@\n line1\n+new",
|
|
3307
|
-
) as any;
|
|
3308
|
-
const issues = [
|
|
3309
|
-
{ file: "changed.ts", line: "1", ruleId: "R1" },
|
|
3310
|
-
{ file: "unchanged.ts", line: "2", ruleId: "R2" },
|
|
3311
|
-
{ file: "changed.ts", line: "3", ruleId: "R3", fixed: "2024-01-01" },
|
|
3312
|
-
];
|
|
3313
|
-
const result = await (service as any).invalidateIssuesForChangedFiles(
|
|
3314
|
-
issues,
|
|
3315
|
-
"abc123",
|
|
3316
|
-
"o",
|
|
3317
|
-
"r",
|
|
3318
|
-
1,
|
|
3319
|
-
);
|
|
3320
|
-
expect(result).toHaveLength(3);
|
|
3321
|
-
expect(result[0].valid).toBe("false");
|
|
3322
|
-
expect(result[1].valid).toBeUndefined();
|
|
3323
|
-
expect(result[2].fixed).toBe("2024-01-01");
|
|
3324
|
-
});
|
|
3325
|
-
|
|
3326
|
-
it("should log when files are invalidated", async () => {
|
|
3327
|
-
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
3328
|
-
gitProvider.getCommitDiff = vi
|
|
3329
|
-
.fn()
|
|
3330
|
-
.mockResolvedValue(
|
|
3331
|
-
"diff --git a/changed.ts b/changed.ts\n--- a/changed.ts\n+++ b/changed.ts\n@@ -1,1 +1,2 @@\n line1\n+new",
|
|
3332
|
-
) as any;
|
|
3333
|
-
const issues = [{ file: "changed.ts", line: "1", ruleId: "R1" }];
|
|
3334
|
-
await (service as any).invalidateIssuesForChangedFiles(issues, "abc123", "o", "r", 1);
|
|
3335
|
-
expect(consoleSpy).toHaveBeenCalledWith(
|
|
3336
|
-
" 🗑️ Issue changed.ts:1 所在文件有变更,标记为无效",
|
|
3337
|
-
);
|
|
3338
|
-
expect(consoleSpy).toHaveBeenCalledWith(" 📊 共标记 1 个历史问题为无效(文件有变更)");
|
|
3339
|
-
consoleSpy.mockRestore();
|
|
3340
|
-
});
|
|
3341
|
-
|
|
3342
|
-
it("should return issues unchanged when no diff files", async () => {
|
|
3343
|
-
gitProvider.getCommitDiff = vi.fn().mockResolvedValue("") as any;
|
|
3344
|
-
const issues = [{ file: "test.ts", line: "1" }];
|
|
3345
|
-
const result = await (service as any).invalidateIssuesForChangedFiles(
|
|
3346
|
-
issues,
|
|
3347
|
-
"abc123",
|
|
3348
|
-
"o",
|
|
3349
|
-
"r",
|
|
3350
|
-
1,
|
|
3351
|
-
);
|
|
3352
|
-
expect(result).toBe(issues);
|
|
3353
|
-
});
|
|
3354
|
-
|
|
3355
|
-
it("should log when no diff files", async () => {
|
|
3356
|
-
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
3357
|
-
gitProvider.getCommitDiff = vi.fn().mockResolvedValue("") as any;
|
|
3358
|
-
const issues = [{ file: "test.ts", line: "1" }];
|
|
3359
|
-
await (service as any).invalidateIssuesForChangedFiles(issues, "abc123", "o", "r", 1);
|
|
3360
|
-
expect(consoleSpy).toHaveBeenCalledWith(" ⏭️ 最新 commit 无文件变更");
|
|
3361
|
-
consoleSpy.mockRestore();
|
|
3362
|
-
});
|
|
3363
|
-
|
|
3364
|
-
it("should handle API error gracefully", async () => {
|
|
3365
|
-
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
3366
|
-
gitProvider.getCommitDiff = vi.fn().mockRejectedValue(new Error("fail")) as any;
|
|
3367
|
-
const issues = [{ file: "test.ts", line: "1" }];
|
|
3368
|
-
const result = await (service as any).invalidateIssuesForChangedFiles(
|
|
3369
|
-
issues,
|
|
3370
|
-
"abc123",
|
|
3371
|
-
"o",
|
|
3372
|
-
"r",
|
|
3373
|
-
1,
|
|
3374
|
-
);
|
|
3375
|
-
expect(result).toBe(issues);
|
|
3376
|
-
expect(consoleSpy).toHaveBeenCalledWith(" ⚠️ 获取最新 commit 变更文件失败: Error: fail");
|
|
3377
|
-
consoleSpy.mockRestore();
|
|
3378
|
-
});
|
|
3379
|
-
});
|
|
3380
|
-
|
|
3381
|
-
describe("ReviewService.updateIssueLineNumbers", () => {
|
|
3382
|
-
beforeEach(() => {
|
|
3383
|
-
mockReviewSpecService.parseLineRange = vi.fn().mockImplementation((lineStr: string) => {
|
|
3384
|
-
const lines: number[] = [];
|
|
3385
|
-
const rangeMatch = lineStr.match(/^(\d+)-(\d+)$/);
|
|
3386
|
-
if (rangeMatch) {
|
|
3387
|
-
const start = parseInt(rangeMatch[1], 10);
|
|
3388
|
-
const end = parseInt(rangeMatch[2], 10);
|
|
3389
|
-
for (let i = start; i <= end; i++) {
|
|
3390
|
-
lines.push(i);
|
|
3391
|
-
}
|
|
3392
|
-
} else {
|
|
3393
|
-
const line = parseInt(lineStr, 10);
|
|
3394
|
-
if (!isNaN(line)) {
|
|
3395
|
-
lines.push(line);
|
|
3396
|
-
}
|
|
3397
|
-
}
|
|
3398
|
-
return lines;
|
|
3399
|
-
});
|
|
3400
|
-
});
|
|
3401
|
-
|
|
3402
|
-
it("should return issues unchanged when no patch for file", () => {
|
|
3403
|
-
const issues = [{ file: "test.ts", line: "5", ruleId: "R1" }];
|
|
3404
|
-
const filePatchMap = new Map([["other.ts", "@@ -1,1 +1,2 @@\n-old1\n+new1\n+new2"]]);
|
|
3405
|
-
const result = (service as any).updateIssueLineNumbers(issues, filePatchMap);
|
|
3406
|
-
expect(result).toEqual(issues);
|
|
3407
|
-
});
|
|
3408
|
-
|
|
3409
|
-
it("should skip issues that are already fixed/resolved/invalid", () => {
|
|
3410
|
-
const issues = [
|
|
3411
|
-
{ file: "test.ts", line: "5", ruleId: "R1", fixed: "2024-01-01" },
|
|
3412
|
-
{ file: "test.ts", line: "6", ruleId: "R2", resolved: "2024-01-02" },
|
|
3413
|
-
{ file: "test.ts", line: "7", ruleId: "R3", valid: "false" },
|
|
3414
|
-
];
|
|
3415
|
-
const filePatchMap = new Map([["test.ts", "@@ -1,1 +1,2 @@\n-old1\n+new1\n+new2"]]);
|
|
3416
|
-
const result = (service as any).updateIssueLineNumbers(issues, filePatchMap);
|
|
3417
|
-
expect(result).toEqual(issues);
|
|
3418
|
-
});
|
|
3419
|
-
|
|
3420
|
-
it("should mark issue as invalid when line is deleted", () => {
|
|
3421
|
-
const filePatchMap = new Map([["test.ts", "@@ -1,1 +1,0 @@\n-old1"]]);
|
|
3422
|
-
const issues = [{ file: "test.ts", line: "1", ruleId: "R1" }];
|
|
3423
|
-
const result = (service as any).updateIssueLineNumbers(issues, filePatchMap);
|
|
3424
|
-
expect(result[0].valid).toBe("false");
|
|
3425
|
-
expect(result[0].originalLine).toBe("1");
|
|
3426
|
-
});
|
|
3427
|
-
|
|
3428
|
-
it("should log when line is deleted and marked invalid", () => {
|
|
3429
|
-
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
3430
|
-
const filePatchMap = new Map([["test.ts", "@@ -1,1 +1,0 @@\n-old1"]]);
|
|
3431
|
-
const issues = [{ file: "test.ts", line: "1", ruleId: "R1" }];
|
|
3432
|
-
(service as any).updateIssueLineNumbers(issues, filePatchMap, 1);
|
|
3433
|
-
expect(consoleSpy).toHaveBeenCalledWith("📍 Issue test.ts:1 对应的代码已被删除,标记为无效");
|
|
3434
|
-
consoleSpy.mockRestore();
|
|
3435
|
-
});
|
|
3436
|
-
|
|
3437
|
-
it("should log when line range is collapsed to single line", () => {
|
|
3438
|
-
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
3439
|
-
const filePatchMap = new Map([["test.ts", "@@ -1,2 +1,1 @@\n-old1\n-old2\n+new1"]]);
|
|
3440
|
-
const issues = [{ file: "test.ts", line: "1-2", ruleId: "R1" }];
|
|
3441
|
-
(service as any).updateIssueLineNumbers(issues, filePatchMap, 1);
|
|
3442
|
-
expect(consoleSpy).toHaveBeenCalledWith("📍 Issue 行号更新: test.ts:1-2 -> test.ts:1");
|
|
3443
|
-
consoleSpy.mockRestore();
|
|
3444
|
-
});
|
|
3445
|
-
});
|
|
3446
|
-
|
|
3447
|
-
describe("ReviewService.findExistingAiComments", () => {
|
|
3448
|
-
it("should log comments when verbose level >= 2", async () => {
|
|
3449
|
-
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
3450
|
-
const mockComments = [
|
|
3451
|
-
{ id: 1, body: "test comment 1" },
|
|
3452
|
-
{ id: 2, body: "test comment 2<!-- spaceflow-review -->" },
|
|
3453
|
-
] as any;
|
|
3454
|
-
gitProvider.listIssueComments.mockResolvedValue(mockComments);
|
|
3455
|
-
|
|
3456
|
-
await (service as any).findExistingAiComments("o", "r", 1, 2);
|
|
3457
|
-
expect(consoleSpy).toHaveBeenCalledWith(
|
|
3458
|
-
"[findExistingAiComments] listIssueComments returned 2 comments",
|
|
3459
|
-
);
|
|
3460
|
-
expect(consoleSpy).toHaveBeenCalledWith(
|
|
3461
|
-
"[findExistingAiComments] comment id=1, body starts with: test comment 1",
|
|
3462
|
-
);
|
|
3463
|
-
consoleSpy.mockRestore();
|
|
3464
|
-
});
|
|
3465
|
-
|
|
3466
|
-
it("should log error when API fails", async () => {
|
|
3467
|
-
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
3468
|
-
gitProvider.listIssueComments.mockRejectedValue(new Error("API error"));
|
|
3469
|
-
|
|
3470
|
-
const result = await (service as any).findExistingAiComments("o", "r", 1);
|
|
3471
|
-
expect(result).toEqual([]);
|
|
3472
|
-
expect(consoleSpy).toHaveBeenCalledWith("[findExistingAiComments] error:", expect.any(Error));
|
|
3473
|
-
consoleSpy.mockRestore();
|
|
3474
|
-
});
|
|
3475
|
-
});
|
|
3476
|
-
|
|
3477
|
-
describe("ReviewService.syncReactionsToIssues", () => {
|
|
3478
|
-
it("should log when no AI review found", async () => {
|
|
3479
|
-
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
3480
|
-
gitProvider.listPullReviews.mockResolvedValue([] as any);
|
|
3481
|
-
|
|
3482
|
-
await (service as any).syncReactionsToIssues("o", "r", 1, { issues: [] }, 2);
|
|
3483
|
-
expect(consoleSpy).toHaveBeenCalledWith("[syncReactionsToIssues] No AI review found");
|
|
3484
|
-
consoleSpy.mockRestore();
|
|
3485
|
-
});
|
|
3486
|
-
|
|
3487
|
-
it("should log reviewers from reviews", async () => {
|
|
3488
|
-
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
3489
|
-
const mockReviews = [
|
|
3490
|
-
{ user: { login: "user1" }, body: "normal review" },
|
|
3491
|
-
{ user: { login: "bot" }, body: "<!-- spaceflow-review-lines --> AI review", id: 123 },
|
|
3492
|
-
] as any;
|
|
3493
|
-
gitProvider.listPullReviews.mockResolvedValue(mockReviews);
|
|
3494
|
-
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
3495
|
-
|
|
3496
|
-
await (service as any).syncReactionsToIssues("o", "r", 1, { issues: [] }, 2);
|
|
3497
|
-
expect(consoleSpy).toHaveBeenCalledWith(
|
|
3498
|
-
"[syncReactionsToIssues] reviewers from reviews: user1",
|
|
3499
|
-
);
|
|
3500
|
-
consoleSpy.mockRestore();
|
|
3501
|
-
});
|
|
3502
|
-
|
|
3503
|
-
it("should log requested reviewers and teams", async () => {
|
|
3504
|
-
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
3505
|
-
const mockReviews = [
|
|
3506
|
-
{ user: { login: "bot" }, body: "<!-- spaceflow-review-lines --> AI review", id: 123 },
|
|
3507
|
-
] as any;
|
|
3508
|
-
const mockPr = {
|
|
3509
|
-
requested_reviewers: [{ login: "reviewer1" }],
|
|
3510
|
-
requested_reviewers_teams: [{ name: "team1", id: 123 }],
|
|
3511
|
-
} as any;
|
|
3512
|
-
gitProvider.listPullReviews.mockResolvedValue(mockReviews);
|
|
3513
|
-
gitProvider.getPullRequest.mockResolvedValue(mockPr);
|
|
3514
|
-
gitProvider.getTeamMembers.mockResolvedValue([{ login: "teamuser1" }]);
|
|
3515
|
-
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
3516
|
-
|
|
3517
|
-
await (service as any).syncReactionsToIssues("o", "r", 1, { issues: [] }, 2);
|
|
3518
|
-
expect(consoleSpy).toHaveBeenCalledWith(
|
|
3519
|
-
"[syncReactionsToIssues] requested_reviewers: reviewer1",
|
|
3520
|
-
);
|
|
3521
|
-
expect(consoleSpy).toHaveBeenCalledWith(
|
|
3522
|
-
'[syncReactionsToIssues] requested_reviewers_teams: [{"name":"team1","id":123}]',
|
|
3523
|
-
);
|
|
3524
|
-
expect(consoleSpy).toHaveBeenCalledWith(
|
|
3525
|
-
"[syncReactionsToIssues] team team1(123) members: teamuser1",
|
|
3526
|
-
);
|
|
3527
|
-
consoleSpy.mockRestore();
|
|
3528
|
-
});
|
|
3529
|
-
|
|
3530
|
-
it("should log final reviewers", async () => {
|
|
3531
|
-
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
3532
|
-
const mockReviews = [
|
|
3533
|
-
{ user: { login: "bot" }, body: "<!-- spaceflow-review-lines --> AI review", id: 123 },
|
|
3534
|
-
] as any;
|
|
3535
|
-
gitProvider.listPullReviews.mockResolvedValue(mockReviews);
|
|
3536
|
-
gitProvider.getPullRequest.mockRejectedValue(new Error("PR not found"));
|
|
3537
|
-
gitProvider.listPullReviewComments.mockResolvedValue([] as any);
|
|
3538
|
-
|
|
3539
|
-
await (service as any).syncReactionsToIssues("o", "r", 1, { issues: [] }, 2);
|
|
3540
|
-
expect(consoleSpy).toHaveBeenCalledWith("[syncReactionsToIssues] final reviewers: ");
|
|
3541
|
-
consoleSpy.mockRestore();
|
|
3542
|
-
});
|
|
3543
|
-
});
|
|
3544
|
-
|
|
3545
|
-
describe("ReviewService.deleteExistingAiReviews", () => {
|
|
2012
|
+
describe("ReviewService.filterIssuesByValidCommits", () => {
|
|
3546
2013
|
beforeEach(() => {
|
|
3547
2014
|
mockReviewSpecService.parseLineRange = vi.fn().mockImplementation((lineStr: string) => {
|
|
3548
2015
|
const lines: number[] = [];
|