@spaceflow/review 0.48.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,35 @@
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
+
18
+ ## [0.48.0](https://github.com/Lydanne/spaceflow/compare/@spaceflow/review@0.47.0...@spaceflow/review@0.48.0) (2026-02-27)
19
+
20
+ ### 新特性
21
+
22
+ * **mcp:** 添加 MCP 资源支持,包括扩展资源和内置配置/扩展列表资源 ([ab19889](https://github.com/Lydanne/spaceflow/commit/ab198890a13c998c17987734da3875834f747a70))
23
+
24
+ ### 其他修改
25
+
26
+ * **cli:** released version 0.38.0 [no ci] ([b8c1a54](https://github.com/Lydanne/spaceflow/commit/b8c1a546876b1ad74d5da755c9ecafea9a99798d))
27
+ * **core:** released version 0.16.0 [no ci] ([4486a32](https://github.com/Lydanne/spaceflow/commit/4486a320fccea858e400b79c5b4f18ed4a6f58ea))
28
+ * **publish:** released version 0.40.0 [no ci] ([8fe14c7](https://github.com/Lydanne/spaceflow/commit/8fe14c7faffa2784b91710d3f129911534bf64d2))
29
+ * **review-summary:** released version 0.17.0 [no ci] ([e00f17d](https://github.com/Lydanne/spaceflow/commit/e00f17dc2ade751ff4de7b45b4d9671b25271f7c))
30
+ * **scripts:** released version 0.17.0 [no ci] ([8946ea6](https://github.com/Lydanne/spaceflow/commit/8946ea68e1ea372ae9d1c20cef098e1ef59bdf25))
31
+ * **shell:** released version 0.17.0 [no ci] ([fb4e833](https://github.com/Lydanne/spaceflow/commit/fb4e833b1a469bf2446b25656a3b439584a4639a))
32
+
3
33
  ## [0.47.0](https://github.com/Lydanne/spaceflow/compare/@spaceflow/review@0.46.0...@spaceflow/review@0.47.0) (2026-02-27)
4
34
 
5
35
  ### 新特性
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) || "";
@@ -2427,8 +2428,14 @@ ${fileChanges || "无"}`;
2427
2428
  await this.syncResolvedComments(owner, repo, prNumber, result);
2428
2429
  // 获取评论的 reactions,同步 valid 状态(👎 标记为无效)
2429
2430
  await this.syncReactionsToIssues(owner, repo, prNumber, result, verbose);
2430
- // 查找已有的 AI 评论(Issue Comment
2431
- const existingComment = await this.findExistingAiComment(owner, repo, prNumber);
2431
+ // 查找已有的 AI 评论(Issue Comment),可能存在多个重复评论
2432
+ if (shouldLog(verbose, 2)) {
2433
+ console.log(`[postOrUpdateReviewComment] owner=${owner}, repo=${repo}, prNumber=${prNumber}`);
2434
+ }
2435
+ const existingComments = await this.findExistingAiComments(owner, repo, prNumber, verbose);
2436
+ if (shouldLog(verbose, 2)) {
2437
+ console.log(`[postOrUpdateReviewComment] found ${existingComments.length} existing AI comments`);
2438
+ }
2432
2439
  // 调试:检查 issues 是否有 author
2433
2440
  if (shouldLog(verbose, 3)) {
2434
2441
  for (const issue of result.issues.slice(0, 3)){
@@ -2445,9 +2452,19 @@ ${fileChanges || "无"}`;
2445
2452
  const commitId = pr.head?.sha;
2446
2453
  // 1. 发布或更新主评论(使用 Issue Comment API,支持删除和更新)
2447
2454
  try {
2448
- if (existingComment?.id) {
2449
- await this.gitProvider.updateIssueComment(owner, repo, existingComment.id, reviewBody);
2455
+ if (existingComments.length > 0) {
2456
+ // 更新第一个 AI 评论
2457
+ await this.gitProvider.updateIssueComment(owner, repo, existingComments[0].id, reviewBody);
2450
2458
  console.log(`✅ 已更新 AI Review 评论`);
2459
+ // 删除多余的重复 AI 评论
2460
+ for (const duplicate of existingComments.slice(1)){
2461
+ try {
2462
+ await this.gitProvider.deleteIssueComment(owner, repo, duplicate.id);
2463
+ console.log(`🗑️ 已删除重复的 AI Review 评论 (id: ${duplicate.id})`);
2464
+ } catch {
2465
+ console.warn(`⚠️ 删除重复评论失败 (id: ${duplicate.id})`);
2466
+ }
2467
+ }
2451
2468
  } else {
2452
2469
  await this.gitProvider.createIssueComment(owner, repo, prNumber, {
2453
2470
  body: reviewBody
@@ -2529,34 +2546,65 @@ ${fileChanges || "无"}`;
2529
2546
  }
2530
2547
  }
2531
2548
  /**
2532
- * 查找已有的 AI 评论(Issue Comment)
2533
- */ async findExistingAiComment(owner, repo, prNumber) {
2549
+ * 查找已有的所有 AI 评论(Issue Comment)
2550
+ * 返回所有包含 REVIEW_COMMENT_MARKER 的评论,用于更新第一个并清理重复项
2551
+ */ async findExistingAiComments(owner, repo, prNumber, verbose) {
2534
2552
  try {
2535
2553
  const comments = await this.gitProvider.listIssueComments(owner, repo, prNumber);
2536
- const aiComment = comments.find((c)=>c.body?.includes(REVIEW_COMMENT_MARKER));
2537
- return aiComment?.id ? {
2538
- id: aiComment.id
2539
- } : null;
2540
- } catch {
2541
- return null;
2554
+ if (shouldLog(verbose, 2)) {
2555
+ console.log(`[findExistingAiComments] listIssueComments returned ${Array.isArray(comments) ? comments.length : typeof comments} comments`);
2556
+ if (Array.isArray(comments)) {
2557
+ for (const c of comments.slice(0, 5)){
2558
+ console.log(`[findExistingAiComments] comment id=${c.id}, body starts with: ${c.body?.slice(0, 80) ?? "(no body)"}`);
2559
+ }
2560
+ }
2561
+ }
2562
+ return comments.filter((c)=>c.body?.includes(REVIEW_COMMENT_MARKER) && c.id).map((c)=>({
2563
+ id: c.id
2564
+ }));
2565
+ } catch (error) {
2566
+ console.warn("[findExistingAiComments] error:", error);
2567
+ return [];
2542
2568
  }
2543
2569
  }
2544
2570
  /**
2545
2571
  * 从 PR 的所有 resolved review threads 中同步 fixed 状态到 result.issues
2546
- * 直接通过 GraphQL 查询所有 resolved threads path+line,匹配 issues
2572
+ * 优先通过评论 body 中的 issue key 精确匹配,回退到 path+line 匹配
2547
2573
  */ async syncResolvedComments(owner, repo, prNumber, result) {
2548
2574
  try {
2549
2575
  const resolvedThreads = await this.gitProvider.listResolvedThreads(owner, repo, prNumber);
2550
2576
  if (resolvedThreads.length === 0) {
2551
2577
  return;
2552
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
+ }
2553
2584
  const now = new Date().toISOString();
2554
2585
  for (const thread of resolvedThreads){
2555
2586
  if (!thread.path) continue;
2556
- 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
+ }
2557
2599
  if (matchedIssue && !matchedIssue.fixed) {
2558
2600
  matchedIssue.fixed = now;
2559
- 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})` : ""));
2560
2608
  }
2561
2609
  }
2562
2610
  } catch (error) {
@@ -2686,11 +2734,28 @@ ${fileChanges || "无"}`;
2686
2734
  }
2687
2735
  }
2688
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
+ /**
2689
2745
  * 同步评论回复到对应的 issues
2690
2746
  * review 评论回复是通过同一个 review 下的后续评论实现的
2747
+ *
2748
+ * 通过 AI 评论 body 中嵌入的 issue key(`<!-- issue-key: file:line:ruleId -->`)精确匹配 issue:
2749
+ * - 含 issue key 的评论是 AI 自身评论,过滤掉不作为回复
2750
+ * - 不含 issue key 的评论是用户真实回复,归到其前面最近的 AI 评论对应的 issue
2691
2751
  */ async syncRepliesToIssues(_owner, _repo, _prNumber, reviewComments, result) {
2692
2752
  try {
2693
- // 按文件路径和行号分组评论,第一条是原始评论,后续是回复
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
+ // 按文件路径和行号分组评论
2694
2759
  const commentsByLocation = new Map();
2695
2760
  for (const comment of reviewComments){
2696
2761
  if (!comment.path || !comment.position) continue;
@@ -2699,7 +2764,7 @@ ${fileChanges || "无"}`;
2699
2764
  comments.push(comment);
2700
2765
  commentsByLocation.set(key, comments);
2701
2766
  }
2702
- // 遍历每个位置的评论,将非第一条评论作为回复
2767
+ // 遍历每个位置的评论
2703
2768
  for (const [, comments] of commentsByLocation){
2704
2769
  if (comments.length <= 1) continue;
2705
2770
  // 按创建时间排序
@@ -2708,20 +2773,35 @@ ${fileChanges || "无"}`;
2708
2773
  const timeB = b.created_at ? new Date(b.created_at).getTime() : 0;
2709
2774
  return timeA - timeB;
2710
2775
  });
2711
- const firstComment = comments[0];
2712
- // 找到对应的 issue
2713
- const matchedIssue = result.issues.find((issue)=>issue.file === firstComment.path && this.lineMatchesPosition(issue.line, firstComment.position));
2714
- if (!matchedIssue) continue;
2715
- // 后续评论作为回复
2716
- 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({
2717
2797
  user: {
2718
- id: c.user?.id?.toString(),
2719
- login: c.user?.login || "unknown"
2798
+ id: comment.user?.id?.toString(),
2799
+ login: comment.user?.login || "unknown"
2720
2800
  },
2721
- body: c.body || "",
2722
- createdAt: c.created_at || ""
2723
- }));
2724
- matchedIssue.replies = replies;
2801
+ body: comment.body || "",
2802
+ createdAt: comment.created_at || ""
2803
+ });
2804
+ }
2725
2805
  }
2726
2806
  } catch (error) {
2727
2807
  console.warn("⚠️ 同步评论回复失败:", error);
@@ -2789,6 +2869,7 @@ ${fileChanges || "无"}`;
2789
2869
  lines.push(`- **Commit**: ${issue.commit}`);
2790
2870
  }
2791
2871
  lines.push(`- **开发人员**: ${issue.author ? "@" + issue.author.login : "未知"}`);
2872
+ lines.push(`<!-- issue-key: ${this.generateIssueKey(issue)} -->`);
2792
2873
  if (issue.suggestion) {
2793
2874
  const ext = extname(issue.file).slice(1) || "";
2794
2875
  const cleanSuggestion = issue.suggestion.replace(/```/g, "//").trim();
@@ -5212,7 +5293,7 @@ const extension = defineExtension({
5212
5293
  deletionAnalysisMode: options?.deletionAnalysisMode,
5213
5294
  deletionOnly: !!options?.deletionOnly,
5214
5295
  outputFormat: options?.outputFormat,
5215
- generateDescription: !!options?.generateDescription,
5296
+ generateDescription: options?.generateDescription ? true : undefined,
5216
5297
  showAll: !!options?.showAll,
5217
5298
  flush: isFlush,
5218
5299
  eventAction: options?.eventAction
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaceflow/review",
3
- "version": "0.48.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.16.0"
31
+ "@spaceflow/core": "0.18.0"
32
32
  },
33
33
  "spaceflow": {
34
34
  "type": "flow",
package/src/index.ts CHANGED
@@ -98,7 +98,7 @@ export const extension = defineExtension({
98
98
  deletionAnalysisMode: options?.deletionAnalysisMode as LLMMode,
99
99
  deletionOnly: !!options?.deletionOnly,
100
100
  outputFormat: options?.outputFormat as ReportFormat,
101
- generateDescription: !!options?.generateDescription,
101
+ generateDescription: options?.generateDescription ? true : undefined,
102
102
  showAll: !!options?.showAll,
103
103
  flush: isFlush,
104
104
  eventAction: options?.eventAction as string,
@@ -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);
@@ -1880,8 +1880,16 @@ ${fileChanges || "无"}`;
1880
1880
  // 获取评论的 reactions,同步 valid 状态(👎 标记为无效)
1881
1881
  await this.syncReactionsToIssues(owner, repo, prNumber, result, verbose);
1882
1882
 
1883
- // 查找已有的 AI 评论(Issue Comment
1884
- const existingComment = await this.findExistingAiComment(owner, repo, prNumber);
1883
+ // 查找已有的 AI 评论(Issue Comment),可能存在多个重复评论
1884
+ if (shouldLog(verbose, 2)) {
1885
+ console.log(`[postOrUpdateReviewComment] owner=${owner}, repo=${repo}, prNumber=${prNumber}`);
1886
+ }
1887
+ const existingComments = await this.findExistingAiComments(owner, repo, prNumber, verbose);
1888
+ if (shouldLog(verbose, 2)) {
1889
+ console.log(
1890
+ `[postOrUpdateReviewComment] found ${existingComments.length} existing AI comments`,
1891
+ );
1892
+ }
1885
1893
 
1886
1894
  // 调试:检查 issues 是否有 author
1887
1895
  if (shouldLog(verbose, 3)) {
@@ -1904,9 +1912,19 @@ ${fileChanges || "无"}`;
1904
1912
 
1905
1913
  // 1. 发布或更新主评论(使用 Issue Comment API,支持删除和更新)
1906
1914
  try {
1907
- if (existingComment?.id) {
1908
- await this.gitProvider.updateIssueComment(owner, repo, existingComment.id, reviewBody);
1915
+ if (existingComments.length > 0) {
1916
+ // 更新第一个 AI 评论
1917
+ await this.gitProvider.updateIssueComment(owner, repo, existingComments[0].id, reviewBody);
1909
1918
  console.log(`✅ 已更新 AI Review 评论`);
1919
+ // 删除多余的重复 AI 评论
1920
+ for (const duplicate of existingComments.slice(1)) {
1921
+ try {
1922
+ await this.gitProvider.deleteIssueComment(owner, repo, duplicate.id);
1923
+ console.log(`🗑️ 已删除重复的 AI Review 评论 (id: ${duplicate.id})`);
1924
+ } catch {
1925
+ console.warn(`⚠️ 删除重复评论失败 (id: ${duplicate.id})`);
1926
+ }
1927
+ }
1910
1928
  } else {
1911
1929
  await this.gitProvider.createIssueComment(owner, repo, prNumber, { body: reviewBody });
1912
1930
  console.log(`✅ 已发布 AI Review 评论`);
@@ -1994,25 +2012,41 @@ ${fileChanges || "无"}`;
1994
2012
  }
1995
2013
 
1996
2014
  /**
1997
- * 查找已有的 AI 评论(Issue Comment)
2015
+ * 查找已有的所有 AI 评论(Issue Comment)
2016
+ * 返回所有包含 REVIEW_COMMENT_MARKER 的评论,用于更新第一个并清理重复项
1998
2017
  */
1999
- protected async findExistingAiComment(
2018
+ protected async findExistingAiComments(
2000
2019
  owner: string,
2001
2020
  repo: string,
2002
2021
  prNumber: number,
2003
- ): Promise<{ id: number } | null> {
2022
+ verbose?: VerboseLevel,
2023
+ ): Promise<{ id: number }[]> {
2004
2024
  try {
2005
2025
  const comments = await this.gitProvider.listIssueComments(owner, repo, prNumber);
2006
- const aiComment = comments.find((c) => c.body?.includes(REVIEW_COMMENT_MARKER));
2007
- return aiComment?.id ? { id: aiComment.id } : null;
2008
- } catch {
2009
- return null;
2026
+ if (shouldLog(verbose, 2)) {
2027
+ console.log(
2028
+ `[findExistingAiComments] listIssueComments returned ${Array.isArray(comments) ? comments.length : typeof comments} comments`,
2029
+ );
2030
+ if (Array.isArray(comments)) {
2031
+ for (const c of comments.slice(0, 5)) {
2032
+ console.log(
2033
+ `[findExistingAiComments] comment id=${c.id}, body starts with: ${c.body?.slice(0, 80) ?? "(no body)"}`,
2034
+ );
2035
+ }
2036
+ }
2037
+ }
2038
+ return comments
2039
+ .filter((c) => c.body?.includes(REVIEW_COMMENT_MARKER) && c.id)
2040
+ .map((c) => ({ id: c.id! }));
2041
+ } catch (error) {
2042
+ console.warn("[findExistingAiComments] error:", error);
2043
+ return [];
2010
2044
  }
2011
2045
  }
2012
2046
 
2013
2047
  /**
2014
2048
  * 从 PR 的所有 resolved review threads 中同步 fixed 状态到 result.issues
2015
- * 直接通过 GraphQL 查询所有 resolved threads path+line,匹配 issues
2049
+ * 优先通过评论 body 中的 issue key 精确匹配,回退到 path+line 匹配
2016
2050
  */
2017
2051
  protected async syncResolvedComments(
2018
2052
  owner: string,
@@ -2025,16 +2059,41 @@ ${fileChanges || "无"}`;
2025
2059
  if (resolvedThreads.length === 0) {
2026
2060
  return;
2027
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
+ }
2028
2067
  const now = new Date().toISOString();
2029
2068
  for (const thread of resolvedThreads) {
2030
2069
  if (!thread.path) continue;
2031
- const matchedIssue = result.issues.find(
2032
- (issue) =>
2033
- issue.file === thread.path && this.lineMatchesPosition(issue.line, thread.line),
2034
- );
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
+ }
2035
2085
  if (matchedIssue && !matchedIssue.fixed) {
2036
2086
  matchedIssue.fixed = now;
2037
- 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
+ );
2038
2097
  }
2039
2098
  }
2040
2099
  } catch (error) {
@@ -2200,9 +2259,23 @@ ${fileChanges || "无"}`;
2200
2259
  }
2201
2260
  }
2202
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
+
2203
2272
  /**
2204
2273
  * 同步评论回复到对应的 issues
2205
2274
  * review 评论回复是通过同一个 review 下的后续评论实现的
2275
+ *
2276
+ * 通过 AI 评论 body 中嵌入的 issue key(`<!-- issue-key: file:line:ruleId -->`)精确匹配 issue:
2277
+ * - 含 issue key 的评论是 AI 自身评论,过滤掉不作为回复
2278
+ * - 不含 issue key 的评论是用户真实回复,归到其前面最近的 AI 评论对应的 issue
2206
2279
  */
2207
2280
  protected async syncRepliesToIssues(
2208
2281
  _owner: string,
@@ -2219,7 +2292,12 @@ ${fileChanges || "无"}`;
2219
2292
  result: ReviewResult,
2220
2293
  ): Promise<void> {
2221
2294
  try {
2222
- // 按文件路径和行号分组评论,第一条是原始评论,后续是回复
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
+ // 按文件路径和行号分组评论
2223
2301
  const commentsByLocation = new Map<string, typeof reviewComments>();
2224
2302
  for (const comment of reviewComments) {
2225
2303
  if (!comment.path || !comment.position) continue;
@@ -2228,7 +2306,7 @@ ${fileChanges || "无"}`;
2228
2306
  comments.push(comment);
2229
2307
  commentsByLocation.set(key, comments);
2230
2308
  }
2231
- // 遍历每个位置的评论,将非第一条评论作为回复
2309
+ // 遍历每个位置的评论
2232
2310
  for (const [, comments] of commentsByLocation) {
2233
2311
  if (comments.length <= 1) continue;
2234
2312
  // 按创建时间排序
@@ -2237,24 +2315,40 @@ ${fileChanges || "无"}`;
2237
2315
  const timeB = b.created_at ? new Date(b.created_at).getTime() : 0;
2238
2316
  return timeA - timeB;
2239
2317
  });
2240
- const firstComment = comments[0];
2241
- // 找到对应的 issue
2242
- const matchedIssue = result.issues.find(
2243
- (issue) =>
2244
- issue.file === firstComment.path &&
2245
- this.lineMatchesPosition(issue.line, firstComment.position),
2246
- );
2247
- if (!matchedIssue) continue;
2248
- // 后续评论作为回复
2249
- const replies = comments.slice(1).map((c) => ({
2250
- user: {
2251
- id: c.user?.id?.toString(),
2252
- login: c.user?.login || "unknown",
2253
- },
2254
- body: c.body || "",
2255
- createdAt: c.created_at || "",
2256
- }));
2257
- 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
+ }
2258
2352
  }
2259
2353
  } catch (error) {
2260
2354
  console.warn("⚠️ 同步评论回复失败:", error);
@@ -2334,6 +2428,7 @@ ${fileChanges || "无"}`;
2334
2428
  lines.push(`- **Commit**: ${issue.commit}`);
2335
2429
  }
2336
2430
  lines.push(`- **开发人员**: ${issue.author ? "@" + issue.author.login : "未知"}`);
2431
+ lines.push(`<!-- issue-key: ${this.generateIssueKey(issue)} -->`);
2337
2432
  if (issue.suggestion) {
2338
2433
  const ext = extname(issue.file).slice(1) || "";
2339
2434
  const cleanSuggestion = issue.suggestion.replace(/```/g, "//").trim();