@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 +15 -0
- package/dist/index.js +74 -18
- package/package.json +2 -2
- package/src/review-report/formatters/markdown.formatter.ts +2 -1
- package/src/review-spec/types.ts +1 -0
- package/src/review.service.spec.ts +144 -19
- package/src/review.service.ts +87 -26
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
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:
|
|
2744
|
-
login:
|
|
2798
|
+
id: comment.user?.id?.toString(),
|
|
2799
|
+
login: comment.user?.login || "unknown"
|
|
2745
2800
|
},
|
|
2746
|
-
body:
|
|
2747
|
-
createdAt:
|
|
2748
|
-
})
|
|
2749
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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) || "";
|
package/src/review-spec/types.ts
CHANGED
|
@@ -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).
|
|
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).
|
|
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).
|
|
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).
|
|
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).
|
|
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).
|
|
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).
|
|
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:
|
|
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 = {
|
|
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).
|
|
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).
|
|
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).
|
|
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);
|
package/src/review.service.ts
CHANGED
|
@@ -2046,7 +2046,7 @@ ${fileChanges || "无"}`;
|
|
|
2046
2046
|
|
|
2047
2047
|
/**
|
|
2048
2048
|
* 从 PR 的所有 resolved review threads 中同步 fixed 状态到 result.issues
|
|
2049
|
-
*
|
|
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
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2275
|
-
|
|
2276
|
-
const
|
|
2277
|
-
(
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
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();
|