@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 +30 -0
- package/dist/index.js +112 -31
- package/package.json +2 -2
- package/src/index.ts +1 -1
- 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 +132 -37
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
|
-
|
|
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
|
-
|
|
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 (
|
|
2449
|
-
|
|
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
|
-
*
|
|
2533
|
-
|
|
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
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
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:
|
|
2719
|
-
login:
|
|
2798
|
+
id: comment.user?.id?.toString(),
|
|
2799
|
+
login: comment.user?.login || "unknown"
|
|
2720
2800
|
},
|
|
2721
|
-
body:
|
|
2722
|
-
createdAt:
|
|
2723
|
-
})
|
|
2724
|
-
|
|
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:
|
|
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.
|
|
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",
|
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:
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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 (
|
|
1908
|
-
|
|
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
|
-
*
|
|
2015
|
+
* 查找已有的所有 AI 评论(Issue Comment)
|
|
2016
|
+
* 返回所有包含 REVIEW_COMMENT_MARKER 的评论,用于更新第一个并清理重复项
|
|
1998
2017
|
*/
|
|
1999
|
-
protected async
|
|
2018
|
+
protected async findExistingAiComments(
|
|
2000
2019
|
owner: string,
|
|
2001
2020
|
repo: string,
|
|
2002
2021
|
prNumber: number,
|
|
2003
|
-
|
|
2022
|
+
verbose?: VerboseLevel,
|
|
2023
|
+
): Promise<{ id: number }[]> {
|
|
2004
2024
|
try {
|
|
2005
2025
|
const comments = await this.gitProvider.listIssueComments(owner, repo, prNumber);
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2241
|
-
|
|
2242
|
-
const
|
|
2243
|
-
(
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
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();
|