@spaceflow/review 0.63.0 → 0.64.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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.63.0](https://github.com/Lydanne/spaceflow/compare/@spaceflow/review@0.62.0...@spaceflow/review@0.63.0) (2026-03-03)
4
+
5
+ ### 代码重构
6
+
7
+ * **review:** 为文件总结标题添加 💡 图标,增强视觉识别度 ([69cecf0](https://github.com/Lydanne/spaceflow/commit/69cecf0a6deadf1935db060800f8f110ae4b9889))
8
+
9
+ ### 其他修改
10
+
11
+ * **review-summary:** released version 0.30.0 [no ci] ([3902c7b](https://github.com/Lydanne/spaceflow/commit/3902c7be16ceb6ab7ff2abe52e434634de12a664))
12
+
3
13
  ## [0.62.0](https://github.com/Lydanne/spaceflow/compare/@spaceflow/review@0.61.0...@spaceflow/review@0.62.0) (2026-03-03)
4
14
 
5
15
  ### 代码重构
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaceflow/review",
3
- "version": "0.63.0",
3
+ "version": "0.64.0",
4
4
  "description": "Spaceflow 代码审查插件,使用 LLM 对 PR 代码进行自动审查",
5
5
  "license": "MIT",
6
6
  "author": "Lydanne",
@@ -75,6 +75,7 @@ describe("ReviewService", () => {
75
75
  updateIssueComment: vi.fn().mockResolvedValue({}),
76
76
  deleteIssueComment: vi.fn().mockResolvedValue(undefined),
77
77
  updatePullReview: vi.fn().mockResolvedValue({}),
78
+ getTeamMembers: vi.fn().mockResolvedValue([]),
78
79
  };
79
80
 
80
81
  configService = {
@@ -1246,6 +1247,7 @@ describe("ReviewService", () => {
1246
1247
  invalid: 0,
1247
1248
  pending: 0,
1248
1249
  fixRate: 0,
1250
+ resolveRate: 0,
1249
1251
  });
1250
1252
  });
1251
1253
 
@@ -1555,6 +1557,19 @@ describe("ReviewService", () => {
1555
1557
  expect(consoleSpy).toHaveBeenCalled();
1556
1558
  consoleSpy.mockRestore();
1557
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
+ });
1558
1573
  });
1559
1574
 
1560
1575
  describe("ReviewService.invalidateIssuesForChangedFiles", () => {
@@ -2381,11 +2396,48 @@ describe("ReviewService", () => {
2381
2396
  { content: "-1", user: { login: "random-user" } },
2382
2397
  ] as any);
2383
2398
  const result = { issues: [{ file: "test.ts", line: "10", valid: "true", reactions: [] }] };
2384
- await (service as any).syncReactionsToIssues("o", "r", 1, result);
2385
2399
  expect(result.issues[0].valid).toBe("true");
2386
2400
  });
2387
2401
  });
2388
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 回顾");
2431
+ });
2432
+
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("✅ 未发现新问题");
2438
+ });
2439
+ });
2440
+
2389
2441
  describe("ReviewService.buildReviewPrompt", () => {
2390
2442
  it("should build prompts for changed files", async () => {
2391
2443
  const specs = [{ extensions: ["ts"], includes: [], rules: [{ id: "R1" }] }];
@@ -3226,4 +3278,450 @@ describe("ReviewService", () => {
3226
3278
  expect(result.size).toBe(0);
3227
3279
  });
3228
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", () => {
3546
+ beforeEach(() => {
3547
+ mockReviewSpecService.parseLineRange = vi.fn().mockImplementation((lineStr: string) => {
3548
+ const lines: number[] = [];
3549
+ const rangeMatch = lineStr.match(/^(\d+)-(\d+)$/);
3550
+ if (rangeMatch) {
3551
+ const start = parseInt(rangeMatch[1], 10);
3552
+ const end = parseInt(rangeMatch[2], 10);
3553
+ for (let i = start; i <= end; i++) {
3554
+ lines.push(i);
3555
+ }
3556
+ } else {
3557
+ const line = parseInt(lineStr, 10);
3558
+ if (!isNaN(line)) {
3559
+ lines.push(line);
3560
+ }
3561
+ }
3562
+ return lines;
3563
+ });
3564
+ });
3565
+
3566
+ it("should filter issues by valid commit hashes", () => {
3567
+ const commits = [{ sha: "abc1234567890" }];
3568
+ const fileContents = new Map([
3569
+ [
3570
+ "test.ts",
3571
+ [
3572
+ ["-------", "line1"],
3573
+ ["abc1234", "line2"],
3574
+ ["-------", "line3"],
3575
+ ],
3576
+ ],
3577
+ ]);
3578
+ const issues = [
3579
+ { file: "test.ts", line: "2", ruleId: "R1" }, // 应该保留,hash匹配
3580
+ { file: "test.ts", line: "1", ruleId: "R2" }, // 应该过滤,hash不匹配
3581
+ { file: "test.ts", line: "3", ruleId: "R3" }, // 应该过滤,hash不匹配
3582
+ ];
3583
+ const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents, 2);
3584
+ expect(result).toHaveLength(1);
3585
+ expect(result[0].ruleId).toBe("R1");
3586
+ });
3587
+
3588
+ it("should log filtering summary", () => {
3589
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
3590
+ const commits = [{ sha: "abc1234567890" }];
3591
+ const fileContents = new Map([
3592
+ [
3593
+ "test.ts",
3594
+ [
3595
+ ["-------", "line1"],
3596
+ ["abc1234", "line2"],
3597
+ ],
3598
+ ],
3599
+ ]);
3600
+ const issues = [
3601
+ { file: "test.ts", line: "1", ruleId: "R1" },
3602
+ { file: "test.ts", line: "2", ruleId: "R2" },
3603
+ ];
3604
+ (service as any).filterIssuesByValidCommits(issues, commits, fileContents, 1);
3605
+ expect(consoleSpy).toHaveBeenCalledWith(" 过滤非本次 PR commits 问题后: 2 -> 1 个问题");
3606
+ consoleSpy.mockRestore();
3607
+ });
3608
+
3609
+ it("should keep issues when file not in fileContents", () => {
3610
+ const commits = [{ sha: "abc1234567890" }];
3611
+ const fileContents = new Map();
3612
+ const issues = [{ file: "missing.ts", line: "1", ruleId: "R1" }];
3613
+ const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents);
3614
+ expect(result).toEqual(issues);
3615
+ });
3616
+
3617
+ it("should keep issues when line range cannot be parsed", () => {
3618
+ const commits = [{ sha: "abc1234567890" }];
3619
+ const fileContents = new Map([["test.ts", [["-------", "line1"]]]]);
3620
+ const issues = [{ file: "test.ts", line: "abc", ruleId: "R1" }];
3621
+ const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents);
3622
+ expect(result).toEqual(issues);
3623
+ });
3624
+
3625
+ it("should handle range line numbers", () => {
3626
+ const commits = [{ sha: "abc1234567890" }];
3627
+ const fileContents = new Map([
3628
+ [
3629
+ "test.ts",
3630
+ [
3631
+ ["-------", "line1"],
3632
+ ["abc1234", "line2"],
3633
+ ["-------", "line3"],
3634
+ ],
3635
+ ],
3636
+ ]);
3637
+ const issues = [{ file: "test.ts", line: "1-3", ruleId: "R1" }];
3638
+ const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents);
3639
+ expect(result).toHaveLength(1); // 只要范围内有一行匹配就保留
3640
+ });
3641
+
3642
+ it("should log when file not in fileContents at verbose level 3", () => {
3643
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
3644
+ const commits = [{ sha: "abc1234567890" }];
3645
+ const fileContents = new Map();
3646
+ const issues = [{ file: "missing.ts", line: "1", ruleId: "R1" }];
3647
+ (service as any).filterIssuesByValidCommits(issues, commits, fileContents, 3);
3648
+ expect(consoleSpy).toHaveBeenCalledWith(
3649
+ " ✅ Issue missing.ts:1 - 文件不在 fileContents 中,保留",
3650
+ );
3651
+ consoleSpy.mockRestore();
3652
+ });
3653
+
3654
+ it("should log when line range cannot be parsed at verbose level 3", () => {
3655
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
3656
+ const commits = [{ sha: "abc1234567890" }];
3657
+ const fileContents = new Map([["test.ts", [["-------", "line1"]]]]);
3658
+ const issues = [{ file: "test.ts", line: "abc", ruleId: "R1" }];
3659
+ (service as any).filterIssuesByValidCommits(issues, commits, fileContents, 3);
3660
+ expect(consoleSpy).toHaveBeenCalledWith(" ✅ Issue test.ts:abc - 无法解析行号,保留");
3661
+ consoleSpy.mockRestore();
3662
+ });
3663
+
3664
+ it("should log detailed hash matching at verbose level 3", () => {
3665
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
3666
+ const commits = [{ sha: "abc1234567890" }];
3667
+ const fileContents = new Map([
3668
+ [
3669
+ "test.ts",
3670
+ [
3671
+ ["-------", "line1"],
3672
+ ["abc1234", "line2"],
3673
+ ],
3674
+ ],
3675
+ ]);
3676
+ const issues = [{ file: "test.ts", line: "2", ruleId: "R1" }];
3677
+ (service as any).filterIssuesByValidCommits(issues, commits, fileContents, 3);
3678
+ expect(consoleSpy).toHaveBeenCalledWith(" 🔍 有效 commit hashes: abc1234");
3679
+ expect(consoleSpy).toHaveBeenCalledWith(
3680
+ " ✅ Issue test.ts:2 - 行 2 hash=abc1234 匹配,保留",
3681
+ );
3682
+ consoleSpy.mockRestore();
3683
+ });
3684
+ });
3685
+
3686
+ describe("ReviewService.ensureClaudeCli", () => {
3687
+ it("should do nothing when claude is already installed", async () => {
3688
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
3689
+ // execSync is already mocked globally
3690
+
3691
+ await (service as any).ensureClaudeCli();
3692
+ expect(consoleSpy).not.toHaveBeenCalledWith("🔧 Claude CLI 未安装,正在安装...");
3693
+ consoleSpy.mockRestore();
3694
+ });
3695
+
3696
+ it("should install claude when not found", async () => {
3697
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
3698
+ // Mock execSync to throw then succeed
3699
+ const execSyncMock = vi.mocked(await import("child_process"));
3700
+ execSyncMock.execSync
3701
+ .mockImplementationOnce(() => {
3702
+ throw new Error("command not found");
3703
+ })
3704
+ .mockImplementationOnce(() => Buffer.from(""));
3705
+
3706
+ await (service as any).ensureClaudeCli();
3707
+ expect(consoleSpy).toHaveBeenCalledWith("🔧 Claude CLI 未安装,正在安装...");
3708
+ expect(consoleSpy).toHaveBeenCalledWith("✅ Claude CLI 安装完成");
3709
+ consoleSpy.mockRestore();
3710
+ });
3711
+
3712
+ it("should throw error when installation fails", async () => {
3713
+ const execSyncMock = vi.mocked(await import("child_process"));
3714
+ execSyncMock.execSync
3715
+ .mockImplementationOnce(() => {
3716
+ throw new Error("command not found");
3717
+ })
3718
+ .mockImplementationOnce(() => {
3719
+ throw new Error("install failed");
3720
+ });
3721
+
3722
+ await expect((service as any).ensureClaudeCli()).rejects.toThrow(
3723
+ "Claude CLI 安装失败: install failed",
3724
+ );
3725
+ });
3726
+ });
3229
3727
  });