@spaceflow/review 0.75.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.
@@ -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(service as any, "getExistingReviewResult").mockResolvedValue(null);
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.getExistingReviewResult", () => {
1494
- it("should return null when no AI review exists", async () => {
1495
- gitProvider.listIssueComments.mockResolvedValue([{ body: "normal comment" }] as any);
1496
- const result = await (service as any).getExistingReviewResult("o", "r", 1);
1497
- expect(result).toBeNull();
1498
- });
1499
-
1500
- it("should return parsed result when AI review exists", async () => {
1501
- const mockResult = { issues: [], summary: [] };
1502
- const mockReviewReportService = (service as any).reviewReportService;
1503
- mockReviewReportService.parseMarkdown.mockReturnValue({ result: mockResult });
1504
- gitProvider.listIssueComments.mockResolvedValue([
1505
- { body: "<!-- spaceflow-review --> review content" },
1506
- ] as any);
1507
- const result = await (service as any).getExistingReviewResult("o", "r", 1);
1508
- expect(result).toEqual(mockResult);
1509
- });
1510
-
1511
- it("should return null on error", async () => {
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
- it("should show no issues message when issues array is empty", () => {
2434
- const issues = [];
2435
- const allIssues = [];
2436
- const result = (service as any).buildLineReviewBody(issues, 1, allIssues);
2437
- expect(result).toContain("✅ 未发现新问题");
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(service as any, "getExistingReviewResult").mockResolvedValue({
2778
- issues: [{ file: "old.ts", line: "1", ruleId: "R2", reason: "old issue", valid: "true" }],
2779
- summary: [],
2780
- round: 1,
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(service as any, "getExistingReviewResult").mockResolvedValue({
2812
- issues: [{ file: "old.ts", line: "1", ruleId: "R2", reason: "old", valid: "true" }],
2813
- summary: [],
2814
- round: 1,
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(service as any, "getExistingReviewResult").mockResolvedValue(null);
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.buildFallbackDescription", () => {
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).buildFallbackDescription(commits, changedFiles);
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).buildFallbackDescription([], []);
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.buildLineCommitMap", () => {
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[] = [];