@spaceflow/review 0.49.0 → 0.50.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,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.49.0](https://github.com/Lydanne/spaceflow/compare/@spaceflow/review@0.48.0...@spaceflow/review@0.49.0) (2026-02-27)
4
+
5
+ ### 修复BUG
6
+
7
+ * **review:** 修复重复 AI 评论问题,改进评论查找和清理逻辑 ([5ec3757](https://github.com/Lydanne/spaceflow/commit/5ec3757533f618aa6210ccebecabf411b2dae9a4))
8
+ * **review:** 修改 generateDescription 选项处理逻辑,仅在明确指定时设置为 true ([48e710a](https://github.com/Lydanne/spaceflow/commit/48e710ade62e0aeaf2effa3db58dbcb2b2a0983e))
9
+
10
+ ### 其他修改
11
+
12
+ * **core:** released version 0.17.0 [no ci] ([4e8f807](https://github.com/Lydanne/spaceflow/commit/4e8f8074fa9d174995e97c9466c379ba81227f9f))
13
+ * **publish:** released version 0.41.0 [no ci] ([e96a488](https://github.com/Lydanne/spaceflow/commit/e96a48824bbb305142b78afa989e3473eec0c1c2))
14
+ * **review-summary:** released version 0.18.0 [no ci] ([164fb64](https://github.com/Lydanne/spaceflow/commit/164fb64c511d93466585cb5d6df7cd6be0922c8c))
15
+ * **scripts:** released version 0.18.0 [no ci] ([289be06](https://github.com/Lydanne/spaceflow/commit/289be06674264d98ab9e1da908d088fff4e1cf7e))
16
+ * **shell:** released version 0.18.0 [no ci] ([88bf217](https://github.com/Lydanne/spaceflow/commit/88bf2178e3361516871c887fda75f7a0086ed55f))
17
+
3
18
  ## [0.48.0](https://github.com/Lydanne/spaceflow/compare/@spaceflow/review@0.47.0...@spaceflow/review@0.48.0) (2026-02-27)
4
19
 
5
20
  ### 新特性
package/dist/index.js CHANGED
@@ -360,7 +360,8 @@ class MarkdownFormatter {
360
360
  lines.push(`- **发现时间**: ${formatDateToUTC8(issue.date)}`);
361
361
  }
362
362
  if (issue.fixed) {
363
- lines.push(`- **修复时间**: ${formatDateToUTC8(issue.fixed)}`);
363
+ const fixedByStr = issue.fixedBy?.login ? ` (by @${issue.fixedBy.login})` : "";
364
+ lines.push(`- **修复时间**: ${formatDateToUTC8(issue.fixed)}${fixedByStr}`);
364
365
  }
365
366
  if (issue.suggestion) {
366
367
  const ext = extname(issue.file).slice(1) || "";
@@ -2568,20 +2569,42 @@ ${fileChanges || "无"}`;
2568
2569
  }
2569
2570
  /**
2570
2571
  * 从 PR 的所有 resolved review threads 中同步 fixed 状态到 result.issues
2571
- * 直接通过 GraphQL 查询所有 resolved threads path+line,匹配 issues
2572
+ * 优先通过评论 body 中的 issue key 精确匹配,回退到 path+line 匹配
2572
2573
  */ async syncResolvedComments(owner, repo, prNumber, result) {
2573
2574
  try {
2574
2575
  const resolvedThreads = await this.gitProvider.listResolvedThreads(owner, repo, prNumber);
2575
2576
  if (resolvedThreads.length === 0) {
2576
2577
  return;
2577
2578
  }
2579
+ // 构建 issue key → issue 的映射,用于精确匹配
2580
+ const issueByKey = new Map();
2581
+ for (const issue of result.issues){
2582
+ issueByKey.set(this.generateIssueKey(issue), issue);
2583
+ }
2578
2584
  const now = new Date().toISOString();
2579
2585
  for (const thread of resolvedThreads){
2580
2586
  if (!thread.path) continue;
2581
- const matchedIssue = result.issues.find((issue)=>issue.file === thread.path && this.lineMatchesPosition(issue.line, thread.line));
2587
+ // 优先通过 issue key 精确匹配
2588
+ let matchedIssue;
2589
+ if (thread.body) {
2590
+ const issueKey = this.extractIssueKeyFromBody(thread.body);
2591
+ if (issueKey) {
2592
+ matchedIssue = issueByKey.get(issueKey);
2593
+ }
2594
+ }
2595
+ // 回退:path:line 匹配
2596
+ if (!matchedIssue) {
2597
+ matchedIssue = result.issues.find((issue)=>issue.file === thread.path && this.lineMatchesPosition(issue.line, thread.line));
2598
+ }
2582
2599
  if (matchedIssue && !matchedIssue.fixed) {
2583
2600
  matchedIssue.fixed = now;
2584
- console.log(`🟢 问题已标记为已解决: ${matchedIssue.file}:${matchedIssue.line}`);
2601
+ if (thread.resolvedBy) {
2602
+ matchedIssue.fixedBy = {
2603
+ id: thread.resolvedBy.id?.toString(),
2604
+ login: thread.resolvedBy.login
2605
+ };
2606
+ }
2607
+ console.log(`🟢 问题已标记为已解决: ${matchedIssue.file}:${matchedIssue.line}` + (thread.resolvedBy?.login ? ` (by @${thread.resolvedBy.login})` : ""));
2585
2608
  }
2586
2609
  }
2587
2610
  } catch (error) {
@@ -2711,11 +2734,28 @@ ${fileChanges || "无"}`;
2711
2734
  }
2712
2735
  }
2713
2736
  /**
2737
+ * 从评论 body 中提取 issue key(AI 行级评论末尾的 HTML 注释标记)
2738
+ * 格式:`<!-- issue-key: file:line:ruleId -->`
2739
+ * 返回 null 表示非 AI 评论(即用户真实回复)
2740
+ */ extractIssueKeyFromBody(body) {
2741
+ const match = body.match(/<!-- issue-key: (.+?) -->/);
2742
+ return match ? match[1] : null;
2743
+ }
2744
+ /**
2714
2745
  * 同步评论回复到对应的 issues
2715
2746
  * review 评论回复是通过同一个 review 下的后续评论实现的
2747
+ *
2748
+ * 通过 AI 评论 body 中嵌入的 issue key(`<!-- issue-key: file:line:ruleId -->`)精确匹配 issue:
2749
+ * - 含 issue key 的评论是 AI 自身评论,过滤掉不作为回复
2750
+ * - 不含 issue key 的评论是用户真实回复,归到其前面最近的 AI 评论对应的 issue
2716
2751
  */ async syncRepliesToIssues(_owner, _repo, _prNumber, reviewComments, result) {
2717
2752
  try {
2718
- // 按文件路径和行号分组评论,第一条是原始评论,后续是回复
2753
+ // 构建 issue key → issue 的映射,用于快速查找
2754
+ const issueByKey = new Map();
2755
+ for (const issue of result.issues){
2756
+ issueByKey.set(this.generateIssueKey(issue), issue);
2757
+ }
2758
+ // 按文件路径和行号分组评论
2719
2759
  const commentsByLocation = new Map();
2720
2760
  for (const comment of reviewComments){
2721
2761
  if (!comment.path || !comment.position) continue;
@@ -2724,7 +2764,7 @@ ${fileChanges || "无"}`;
2724
2764
  comments.push(comment);
2725
2765
  commentsByLocation.set(key, comments);
2726
2766
  }
2727
- // 遍历每个位置的评论,将非第一条评论作为回复
2767
+ // 遍历每个位置的评论
2728
2768
  for (const [, comments] of commentsByLocation){
2729
2769
  if (comments.length <= 1) continue;
2730
2770
  // 按创建时间排序
@@ -2733,20 +2773,35 @@ ${fileChanges || "无"}`;
2733
2773
  const timeB = b.created_at ? new Date(b.created_at).getTime() : 0;
2734
2774
  return timeA - timeB;
2735
2775
  });
2736
- const firstComment = comments[0];
2737
- // 找到对应的 issue
2738
- const matchedIssue = result.issues.find((issue)=>issue.file === firstComment.path && this.lineMatchesPosition(issue.line, firstComment.position));
2739
- if (!matchedIssue) continue;
2740
- // 后续评论作为回复
2741
- const replies = comments.slice(1).map((c)=>({
2776
+ // 遍历评论,用 issue key 精确匹配
2777
+ let lastIssueKey = null;
2778
+ for (const comment of comments){
2779
+ const issueKey = this.extractIssueKeyFromBody(comment.body || "");
2780
+ if (issueKey) {
2781
+ // AI 自身评论,记录 issue key 但不作为回复
2782
+ lastIssueKey = issueKey;
2783
+ continue;
2784
+ }
2785
+ // 用户真实回复,通过前面最近的 AI 评论的 issue key 精确匹配
2786
+ let matchedIssue = lastIssueKey ? issueByKey.get(lastIssueKey) ?? null : null;
2787
+ // 回退:如果 issue key 匹配失败,使用 path:position 匹配
2788
+ if (!matchedIssue) {
2789
+ matchedIssue = result.issues.find((issue)=>issue.file === comment.path && this.lineMatchesPosition(issue.line, comment.position)) ?? null;
2790
+ }
2791
+ if (!matchedIssue) continue;
2792
+ // 追加回复(而非覆盖,同一 issue 可能有多条用户回复)
2793
+ if (!matchedIssue.replies) {
2794
+ matchedIssue.replies = [];
2795
+ }
2796
+ matchedIssue.replies.push({
2742
2797
  user: {
2743
- id: c.user?.id?.toString(),
2744
- login: c.user?.login || "unknown"
2798
+ id: comment.user?.id?.toString(),
2799
+ login: comment.user?.login || "unknown"
2745
2800
  },
2746
- body: c.body || "",
2747
- createdAt: c.created_at || ""
2748
- }));
2749
- matchedIssue.replies = replies;
2801
+ body: comment.body || "",
2802
+ createdAt: comment.created_at || ""
2803
+ });
2804
+ }
2750
2805
  }
2751
2806
  } catch (error) {
2752
2807
  console.warn("⚠️ 同步评论回复失败:", error);
@@ -2814,6 +2869,7 @@ ${fileChanges || "无"}`;
2814
2869
  lines.push(`- **Commit**: ${issue.commit}`);
2815
2870
  }
2816
2871
  lines.push(`- **开发人员**: ${issue.author ? "@" + issue.author.login : "未知"}`);
2872
+ lines.push(`<!-- issue-key: ${this.generateIssueKey(issue)} -->`);
2817
2873
  if (issue.suggestion) {
2818
2874
  const ext = extname(issue.file).slice(1) || "";
2819
2875
  const cleanSuggestion = issue.suggestion.replace(/```/g, "//").trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaceflow/review",
3
- "version": "0.49.0",
3
+ "version": "0.50.0",
4
4
  "description": "Spaceflow 代码审查插件,使用 LLM 对 PR 代码进行自动审查",
5
5
  "license": "MIT",
6
6
  "author": "Lydanne",
@@ -28,7 +28,7 @@
28
28
  "@spaceflow/cli": "0.38.0"
29
29
  },
30
30
  "peerDependencies": {
31
- "@spaceflow/core": "0.17.0"
31
+ "@spaceflow/core": "0.18.0"
32
32
  },
33
33
  "spaceflow": {
34
34
  "type": "flow",
@@ -50,7 +50,8 @@ export class MarkdownFormatter implements ReviewReportFormatter, ReviewReportPar
50
50
  lines.push(`- **发现时间**: ${formatDateToUTC8(issue.date)}`);
51
51
  }
52
52
  if (issue.fixed) {
53
- lines.push(`- **修复时间**: ${formatDateToUTC8(issue.fixed)}`);
53
+ const fixedByStr = issue.fixedBy?.login ? ` (by @${issue.fixedBy.login})` : "";
54
+ lines.push(`- **修复时间**: ${formatDateToUTC8(issue.fixed)}${fixedByStr}`);
54
55
  }
55
56
  if (issue.suggestion) {
56
57
  const ext = extname(issue.file).slice(1) || "";
@@ -74,6 +74,7 @@ export interface ReviewIssue {
74
74
  reason: string;
75
75
  date?: string; // 发现问题的时间
76
76
  fixed?: string; // 修复时间
77
+ fixedBy?: UserInfo; // 解决者
77
78
  valid?: string; // 问题是否有效
78
79
  suggestion?: string;
79
80
  commit?: string;
@@ -1751,7 +1751,7 @@ describe("ReviewService", () => {
1751
1751
 
1752
1752
  describe("ReviewService.postOrUpdateReviewComment", () => {
1753
1753
  it("should post review comment", async () => {
1754
- const configReader = (service as any).configReader;
1754
+ const configReader = (service as any).config;
1755
1755
  configReader.getPluginConfig.mockReturnValue({});
1756
1756
  gitProvider.listIssueComments.mockResolvedValue([] as any);
1757
1757
  gitProvider.listPullReviewComments.mockResolvedValue([] as any);
@@ -1763,7 +1763,7 @@ describe("ReviewService", () => {
1763
1763
  });
1764
1764
 
1765
1765
  it("should update PR title when autoUpdatePrTitle enabled", async () => {
1766
- const configReader = (service as any).configReader;
1766
+ const configReader = (service as any).config;
1767
1767
  configReader.getPluginConfig.mockReturnValue({ autoUpdatePrTitle: true });
1768
1768
  gitProvider.listIssueComments.mockResolvedValue([] as any);
1769
1769
  gitProvider.listPullReviewComments.mockResolvedValue([] as any);
@@ -1776,7 +1776,7 @@ describe("ReviewService", () => {
1776
1776
  });
1777
1777
 
1778
1778
  it("should handle createIssueComment error gracefully", async () => {
1779
- const configReader = (service as any).configReader;
1779
+ const configReader = (service as any).config;
1780
1780
  configReader.getPluginConfig.mockReturnValue({});
1781
1781
  gitProvider.listIssueComments.mockResolvedValue([] as any);
1782
1782
  gitProvider.listPullReviewComments.mockResolvedValue([] as any);
@@ -1790,7 +1790,7 @@ describe("ReviewService", () => {
1790
1790
  });
1791
1791
 
1792
1792
  it("should include line comments when configured", async () => {
1793
- const configReader = (service as any).configReader;
1793
+ const configReader = (service as any).config;
1794
1794
  configReader.getPluginConfig.mockReturnValue({ lineComments: true });
1795
1795
  mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
1796
1796
  gitProvider.listIssueComments.mockResolvedValue([] as any);
@@ -1820,19 +1820,61 @@ describe("ReviewService", () => {
1820
1820
  });
1821
1821
 
1822
1822
  describe("ReviewService.syncResolvedComments", () => {
1823
- it("should mark matched issues as fixed", async () => {
1823
+ it("should mark matched issues as fixed via path:line fallback", async () => {
1824
1824
  mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
1825
1825
  gitProvider.listResolvedThreads.mockResolvedValue([
1826
1826
  { path: "test.ts", line: 10, resolvedBy: { login: "user1" } },
1827
1827
  ] as any);
1828
- const result = { issues: [{ file: "test.ts", line: "10" }] };
1828
+ const result = { issues: [{ file: "test.ts", line: "10", ruleId: "Rule1" }] };
1829
1829
  await (service as any).syncResolvedComments("o", "r", 1, result);
1830
1830
  expect((result.issues[0] as any).fixed).toBeDefined();
1831
+ expect((result.issues[0] as any).fixedBy).toEqual({ id: undefined, login: "user1" });
1832
+ });
1833
+
1834
+ it("should mark matched issues as fixed via issue key in body", async () => {
1835
+ mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
1836
+ gitProvider.listResolvedThreads.mockResolvedValue([
1837
+ {
1838
+ path: "test.ts",
1839
+ line: 10,
1840
+ resolvedBy: { login: "user1" },
1841
+ body: `🟡 **问题**\n<!-- issue-key: test.ts:10:RuleA -->`,
1842
+ },
1843
+ ] as any);
1844
+ const result = {
1845
+ issues: [{ file: "test.ts", line: "10", ruleId: "RuleA" }],
1846
+ };
1847
+ await (service as any).syncResolvedComments("o", "r", 1, result);
1848
+ expect((result.issues[0] as any).fixed).toBeDefined();
1849
+ expect((result.issues[0] as any).fixedBy).toEqual({ id: undefined, login: "user1" });
1850
+ });
1851
+
1852
+ it("should match correct issue by issue key when multiple issues at same position", async () => {
1853
+ mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
1854
+ gitProvider.listResolvedThreads.mockResolvedValue([
1855
+ {
1856
+ path: "test.ts",
1857
+ line: 10,
1858
+ resolvedBy: { login: "user1" },
1859
+ body: `🟡 **问题B**\n<!-- issue-key: test.ts:10:RuleB -->`,
1860
+ },
1861
+ ] as any);
1862
+ const result = {
1863
+ issues: [
1864
+ { file: "test.ts", line: "10", ruleId: "RuleA" } as any,
1865
+ { file: "test.ts", line: "10", ruleId: "RuleB" } as any,
1866
+ ],
1867
+ };
1868
+ await (service as any).syncResolvedComments("o", "r", 1, result);
1869
+ expect(result.issues[0].fixed).toBeUndefined(); // RuleA 未解决
1870
+ expect(result.issues[0].fixedBy).toBeUndefined();
1871
+ expect(result.issues[1].fixed).toBeDefined(); // RuleB 已解决
1872
+ expect(result.issues[1].fixedBy).toEqual({ id: undefined, login: "user1" });
1831
1873
  });
1832
1874
 
1833
1875
  it("should skip when no resolved threads", async () => {
1834
1876
  gitProvider.listResolvedThreads.mockResolvedValue([] as any);
1835
- const result = { issues: [{ file: "test.ts", line: "10" }] };
1877
+ const result = { issues: [{ file: "test.ts", line: "10", ruleId: "Rule1" }] };
1836
1878
  await (service as any).syncResolvedComments("o", "r", 1, result);
1837
1879
  expect((result.issues[0] as any).fixed).toBeUndefined();
1838
1880
  });
@@ -1841,7 +1883,7 @@ describe("ReviewService", () => {
1841
1883
  gitProvider.listResolvedThreads.mockResolvedValue([
1842
1884
  { path: undefined, line: 10, resolvedBy: { login: "user1" } },
1843
1885
  ] as any);
1844
- const result = { issues: [{ file: "test.ts", line: "10" }] };
1886
+ const result = { issues: [{ file: "test.ts", line: "10", ruleId: "Rule1" }] };
1845
1887
  await (service as any).syncResolvedComments("o", "r", 1, result);
1846
1888
  expect((result.issues[0] as any).fixed).toBeUndefined();
1847
1889
  });
@@ -1982,7 +2024,7 @@ describe("ReviewService", () => {
1982
2024
  gitProvider.listPullReviewComments.mockResolvedValue([] as any);
1983
2025
  gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "abc" } } as any);
1984
2026
  gitProvider.createIssueComment.mockResolvedValue({} as any);
1985
- const configReader = (service as any).configReader;
2027
+ const configReader = (service as any).config;
1986
2028
  configReader.getPluginConfig.mockReturnValue({});
1987
2029
  const context = {
1988
2030
  owner: "o",
@@ -2017,7 +2059,7 @@ describe("ReviewService", () => {
2017
2059
  gitProvider.listPullReviewComments.mockResolvedValue([] as any);
2018
2060
  gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "abc" } } as any);
2019
2061
  gitProvider.createIssueComment.mockResolvedValue({} as any);
2020
- const configReader = (service as any).configReader;
2062
+ const configReader = (service as any).config;
2021
2063
  configReader.getPluginConfig.mockReturnValue({});
2022
2064
  const context = {
2023
2065
  owner: "o",
@@ -2128,7 +2170,7 @@ describe("ReviewService", () => {
2128
2170
 
2129
2171
  it("should merge references from options and config", async () => {
2130
2172
  configService.get.mockReturnValue({ repository: "owner/repo", refName: "main" });
2131
- const configReader = (service as any).configReader;
2173
+ const configReader = (service as any).config;
2132
2174
  configReader.getPluginConfig.mockReturnValue({ references: ["config-ref"] });
2133
2175
  const options = { dryRun: false, ci: false, references: ["opt-ref"] };
2134
2176
  const context = await service.getContextFromEnv(options as any);
@@ -2445,15 +2487,27 @@ describe("ReviewService", () => {
2445
2487
  });
2446
2488
  });
2447
2489
 
2490
+ describe("ReviewService.extractIssueKeyFromBody", () => {
2491
+ it("should extract issue key from AI comment body", () => {
2492
+ const body = `🟡 **问题描述**\n- **规则**: \`Rule1\`\n<!-- issue-key: test.ts:10:Rule1 -->`;
2493
+ expect((service as any).extractIssueKeyFromBody(body)).toBe("test.ts:10:Rule1");
2494
+ });
2495
+
2496
+ it("should return null for user reply without issue key marker", () => {
2497
+ expect((service as any).extractIssueKeyFromBody("这个问题已经修复了")).toBeNull();
2498
+ expect((service as any).extractIssueKeyFromBody("")).toBeNull();
2499
+ });
2500
+ });
2501
+
2448
2502
  describe("ReviewService.syncRepliesToIssues", () => {
2449
- it("should sync replies to matched issues", async () => {
2503
+ it("should sync user replies to matched issues and filter out AI comments", async () => {
2450
2504
  mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
2451
2505
  const reviewComments = [
2452
2506
  {
2453
2507
  id: 1,
2454
2508
  path: "test.ts",
2455
2509
  position: 10,
2456
- body: "original",
2510
+ body: `🟡 **问题描述**\n<!-- issue-key: test.ts:10:JsTs.Base.Rule1 -->`,
2457
2511
  user: { id: 1, login: "bot" },
2458
2512
  created_at: "2024-01-01",
2459
2513
  },
@@ -2461,15 +2515,58 @@ describe("ReviewService", () => {
2461
2515
  id: 2,
2462
2516
  path: "test.ts",
2463
2517
  position: 10,
2464
- body: "reply",
2518
+ body: "reply from user",
2465
2519
  user: { id: 2, login: "dev" },
2466
2520
  created_at: "2024-01-02",
2467
2521
  },
2468
2522
  ];
2469
- const result = { issues: [{ file: "test.ts", line: "10", replies: [] }] };
2523
+ const result = {
2524
+ issues: [{ file: "test.ts", line: "10", ruleId: "JsTs.Base.Rule1", replies: [] }],
2525
+ };
2470
2526
  await (service as any).syncRepliesToIssues("o", "r", 1, reviewComments, result);
2471
2527
  expect(result.issues[0].replies).toHaveLength(1);
2472
- expect(result.issues[0].replies[0].body).toBe("reply");
2528
+ expect(result.issues[0].replies[0].body).toBe("reply from user");
2529
+ expect(result.issues[0].replies[0].user.login).toBe("dev");
2530
+ });
2531
+
2532
+ it("should match user reply to correct issue by issue key when multiple issues at same position", async () => {
2533
+ mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
2534
+ const reviewComments = [
2535
+ {
2536
+ id: 1,
2537
+ path: "test.ts",
2538
+ position: 10,
2539
+ body: `🔴 **问题A**\n<!-- issue-key: test.ts:10:JsTs.Base.RuleA -->`,
2540
+ user: { id: 1, login: "bot" },
2541
+ created_at: "2024-01-01T01:00:00Z",
2542
+ },
2543
+ {
2544
+ id: 2,
2545
+ path: "test.ts",
2546
+ position: 10,
2547
+ body: `🟡 **问题B**\n<!-- issue-key: test.ts:10:JsTs.Base.RuleB -->`,
2548
+ user: { id: 1, login: "bot" },
2549
+ created_at: "2024-01-01T02:00:00Z",
2550
+ },
2551
+ {
2552
+ id: 3,
2553
+ path: "test.ts",
2554
+ position: 10,
2555
+ body: "针对问题B的回复",
2556
+ user: { id: 2, login: "dev" },
2557
+ created_at: "2024-01-01T03:00:00Z",
2558
+ },
2559
+ ];
2560
+ const result = {
2561
+ issues: [
2562
+ { file: "test.ts", line: "10", ruleId: "JsTs.Base.RuleA" } as any,
2563
+ { file: "test.ts", line: "10", ruleId: "JsTs.Base.RuleB" } as any,
2564
+ ],
2565
+ };
2566
+ await (service as any).syncRepliesToIssues("o", "r", 1, reviewComments, result);
2567
+ expect(result.issues[0].replies).toBeUndefined(); // RuleA 无回复
2568
+ expect(result.issues[1].replies).toHaveLength(1);
2569
+ expect(result.issues[1].replies[0].body).toBe("针对问题B的回复");
2473
2570
  });
2474
2571
 
2475
2572
  it("should skip comments without path or position", async () => {
@@ -2493,6 +2590,34 @@ describe("ReviewService", () => {
2493
2590
  expect(consoleSpy).toHaveBeenCalled();
2494
2591
  consoleSpy.mockRestore();
2495
2592
  });
2593
+
2594
+ it("should fallback to path:position match when no issue key is available", async () => {
2595
+ mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
2596
+ const reviewComments = [
2597
+ {
2598
+ id: 1,
2599
+ path: "test.ts",
2600
+ position: 10,
2601
+ body: "some comment without issue key",
2602
+ user: { id: 1, login: "user1" },
2603
+ created_at: "2024-01-01",
2604
+ },
2605
+ {
2606
+ id: 2,
2607
+ path: "test.ts",
2608
+ position: 10,
2609
+ body: "user reply",
2610
+ user: { id: 2, login: "user2" },
2611
+ created_at: "2024-01-02",
2612
+ },
2613
+ ];
2614
+ const result = {
2615
+ issues: [{ file: "test.ts", line: "10", ruleId: "SomeRule" } as any],
2616
+ };
2617
+ await (service as any).syncRepliesToIssues("o", "r", 1, reviewComments, result);
2618
+ // 两条都不含 issue key,都会通过 fallback path:position 匹配
2619
+ expect(result.issues[0].replies).toHaveLength(2);
2620
+ });
2496
2621
  });
2497
2622
 
2498
2623
  describe("ReviewService.execute - CI with existingResult", () => {
@@ -2512,7 +2637,7 @@ describe("ReviewService", () => {
2512
2637
  summary: [],
2513
2638
  round: 1,
2514
2639
  });
2515
- const configReader = (service as any).configReader;
2640
+ const configReader = (service as any).config;
2516
2641
  configReader.getPluginConfig.mockReturnValue({});
2517
2642
  gitProvider.getPullRequest.mockResolvedValue({ title: "PR", head: { sha: "abc" } } as any);
2518
2643
  gitProvider.getPullRequestCommits.mockResolvedValue([
@@ -2546,7 +2671,7 @@ describe("ReviewService", () => {
2546
2671
  summary: [],
2547
2672
  round: 1,
2548
2673
  });
2549
- const configReader = (service as any).configReader;
2674
+ const configReader = (service as any).config;
2550
2675
  configReader.getPluginConfig.mockReturnValue({});
2551
2676
  gitProvider.getPullRequest.mockResolvedValue({ title: "PR", head: { sha: "abc" } } as any);
2552
2677
  gitProvider.getPullRequestCommits.mockResolvedValue([
@@ -2779,7 +2904,7 @@ describe("ReviewService", () => {
2779
2904
  gitProvider.getPullRequestCommits.mockResolvedValue([] as any);
2780
2905
  gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "abc" } } as any);
2781
2906
  gitProvider.updateIssueComment.mockResolvedValue({} as any);
2782
- const configReader = (service as any).configReader;
2907
+ const configReader = (service as any).config;
2783
2908
  configReader.getPluginConfig.mockReturnValue({});
2784
2909
  const context = { owner: "o", repo: "r", prNumber: 1, ci: true, dryRun: false, verbose: 1 };
2785
2910
  const result = await (service as any).executeCollectOnly(context);
@@ -2046,7 +2046,7 @@ ${fileChanges || "无"}`;
2046
2046
 
2047
2047
  /**
2048
2048
  * 从 PR 的所有 resolved review threads 中同步 fixed 状态到 result.issues
2049
- * 直接通过 GraphQL 查询所有 resolved threads path+line,匹配 issues
2049
+ * 优先通过评论 body 中的 issue key 精确匹配,回退到 path+line 匹配
2050
2050
  */
2051
2051
  protected async syncResolvedComments(
2052
2052
  owner: string,
@@ -2059,16 +2059,41 @@ ${fileChanges || "无"}`;
2059
2059
  if (resolvedThreads.length === 0) {
2060
2060
  return;
2061
2061
  }
2062
+ // 构建 issue key → issue 的映射,用于精确匹配
2063
+ const issueByKey = new Map<string, ReviewResult["issues"][0]>();
2064
+ for (const issue of result.issues) {
2065
+ issueByKey.set(this.generateIssueKey(issue), issue);
2066
+ }
2062
2067
  const now = new Date().toISOString();
2063
2068
  for (const thread of resolvedThreads) {
2064
2069
  if (!thread.path) continue;
2065
- const matchedIssue = result.issues.find(
2066
- (issue) =>
2067
- issue.file === thread.path && this.lineMatchesPosition(issue.line, thread.line),
2068
- );
2070
+ // 优先通过 issue key 精确匹配
2071
+ let matchedIssue: ReviewResult["issues"][0] | undefined;
2072
+ if (thread.body) {
2073
+ const issueKey = this.extractIssueKeyFromBody(thread.body);
2074
+ if (issueKey) {
2075
+ matchedIssue = issueByKey.get(issueKey);
2076
+ }
2077
+ }
2078
+ // 回退:path:line 匹配
2079
+ if (!matchedIssue) {
2080
+ matchedIssue = result.issues.find(
2081
+ (issue) =>
2082
+ issue.file === thread.path && this.lineMatchesPosition(issue.line, thread.line),
2083
+ );
2084
+ }
2069
2085
  if (matchedIssue && !matchedIssue.fixed) {
2070
2086
  matchedIssue.fixed = now;
2071
- console.log(`🟢 问题已标记为已解决: ${matchedIssue.file}:${matchedIssue.line}`);
2087
+ if (thread.resolvedBy) {
2088
+ matchedIssue.fixedBy = {
2089
+ id: thread.resolvedBy.id?.toString(),
2090
+ login: thread.resolvedBy.login,
2091
+ };
2092
+ }
2093
+ console.log(
2094
+ `🟢 问题已标记为已解决: ${matchedIssue.file}:${matchedIssue.line}` +
2095
+ (thread.resolvedBy?.login ? ` (by @${thread.resolvedBy.login})` : ""),
2096
+ );
2072
2097
  }
2073
2098
  }
2074
2099
  } catch (error) {
@@ -2234,9 +2259,23 @@ ${fileChanges || "无"}`;
2234
2259
  }
2235
2260
  }
2236
2261
 
2262
+ /**
2263
+ * 从评论 body 中提取 issue key(AI 行级评论末尾的 HTML 注释标记)
2264
+ * 格式:`<!-- issue-key: file:line:ruleId -->`
2265
+ * 返回 null 表示非 AI 评论(即用户真实回复)
2266
+ */
2267
+ protected extractIssueKeyFromBody(body: string): string | null {
2268
+ const match = body.match(/<!-- issue-key: (.+?) -->/);
2269
+ return match ? match[1] : null;
2270
+ }
2271
+
2237
2272
  /**
2238
2273
  * 同步评论回复到对应的 issues
2239
2274
  * review 评论回复是通过同一个 review 下的后续评论实现的
2275
+ *
2276
+ * 通过 AI 评论 body 中嵌入的 issue key(`<!-- issue-key: file:line:ruleId -->`)精确匹配 issue:
2277
+ * - 含 issue key 的评论是 AI 自身评论,过滤掉不作为回复
2278
+ * - 不含 issue key 的评论是用户真实回复,归到其前面最近的 AI 评论对应的 issue
2240
2279
  */
2241
2280
  protected async syncRepliesToIssues(
2242
2281
  _owner: string,
@@ -2253,7 +2292,12 @@ ${fileChanges || "无"}`;
2253
2292
  result: ReviewResult,
2254
2293
  ): Promise<void> {
2255
2294
  try {
2256
- // 按文件路径和行号分组评论,第一条是原始评论,后续是回复
2295
+ // 构建 issue key → issue 的映射,用于快速查找
2296
+ const issueByKey = new Map<string, ReviewResult["issues"][0]>();
2297
+ for (const issue of result.issues) {
2298
+ issueByKey.set(this.generateIssueKey(issue), issue);
2299
+ }
2300
+ // 按文件路径和行号分组评论
2257
2301
  const commentsByLocation = new Map<string, typeof reviewComments>();
2258
2302
  for (const comment of reviewComments) {
2259
2303
  if (!comment.path || !comment.position) continue;
@@ -2262,7 +2306,7 @@ ${fileChanges || "无"}`;
2262
2306
  comments.push(comment);
2263
2307
  commentsByLocation.set(key, comments);
2264
2308
  }
2265
- // 遍历每个位置的评论,将非第一条评论作为回复
2309
+ // 遍历每个位置的评论
2266
2310
  for (const [, comments] of commentsByLocation) {
2267
2311
  if (comments.length <= 1) continue;
2268
2312
  // 按创建时间排序
@@ -2271,24 +2315,40 @@ ${fileChanges || "无"}`;
2271
2315
  const timeB = b.created_at ? new Date(b.created_at).getTime() : 0;
2272
2316
  return timeA - timeB;
2273
2317
  });
2274
- const firstComment = comments[0];
2275
- // 找到对应的 issue
2276
- const matchedIssue = result.issues.find(
2277
- (issue) =>
2278
- issue.file === firstComment.path &&
2279
- this.lineMatchesPosition(issue.line, firstComment.position),
2280
- );
2281
- if (!matchedIssue) continue;
2282
- // 后续评论作为回复
2283
- const replies = comments.slice(1).map((c) => ({
2284
- user: {
2285
- id: c.user?.id?.toString(),
2286
- login: c.user?.login || "unknown",
2287
- },
2288
- body: c.body || "",
2289
- createdAt: c.created_at || "",
2290
- }));
2291
- matchedIssue.replies = replies;
2318
+ // 遍历评论,用 issue key 精确匹配
2319
+ let lastIssueKey: string | null = null;
2320
+ for (const comment of comments) {
2321
+ const issueKey = this.extractIssueKeyFromBody(comment.body || "");
2322
+ if (issueKey) {
2323
+ // AI 自身评论,记录 issue key 但不作为回复
2324
+ lastIssueKey = issueKey;
2325
+ continue;
2326
+ }
2327
+ // 用户真实回复,通过前面最近的 AI 评论的 issue key 精确匹配
2328
+ let matchedIssue = lastIssueKey ? (issueByKey.get(lastIssueKey) ?? null) : null;
2329
+ // 回退:如果 issue key 匹配失败,使用 path:position 匹配
2330
+ if (!matchedIssue) {
2331
+ matchedIssue =
2332
+ result.issues.find(
2333
+ (issue) =>
2334
+ issue.file === comment.path &&
2335
+ this.lineMatchesPosition(issue.line, comment.position),
2336
+ ) ?? null;
2337
+ }
2338
+ if (!matchedIssue) continue;
2339
+ // 追加回复(而非覆盖,同一 issue 可能有多条用户回复)
2340
+ if (!matchedIssue.replies) {
2341
+ matchedIssue.replies = [];
2342
+ }
2343
+ matchedIssue.replies.push({
2344
+ user: {
2345
+ id: comment.user?.id?.toString(),
2346
+ login: comment.user?.login || "unknown",
2347
+ },
2348
+ body: comment.body || "",
2349
+ createdAt: comment.created_at || "",
2350
+ });
2351
+ }
2292
2352
  }
2293
2353
  } catch (error) {
2294
2354
  console.warn("⚠️ 同步评论回复失败:", error);
@@ -2368,6 +2428,7 @@ ${fileChanges || "无"}`;
2368
2428
  lines.push(`- **Commit**: ${issue.commit}`);
2369
2429
  }
2370
2430
  lines.push(`- **开发人员**: ${issue.author ? "@" + issue.author.login : "未知"}`);
2431
+ lines.push(`<!-- issue-key: ${this.generateIssueKey(issue)} -->`);
2371
2432
  if (issue.suggestion) {
2372
2433
  const ext = extname(issue.file).slice(1) || "";
2373
2434
  const cleanSuggestion = issue.suggestion.replace(/```/g, "//").trim();