@spaceflow/review 0.54.0 → 0.56.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 +23 -0
- package/dist/index.js +92 -47
- package/package.json +1 -1
- package/src/locales/en/review.json +3 -1
- package/src/locales/zh-cn/review.json +3 -1
- package/src/mcp/index.ts +54 -0
- package/src/review-report/formatters/markdown.formatter.ts +5 -0
- package/src/review-report/formatters/terminal.formatter.ts +1 -0
- package/src/review-spec/types.ts +7 -3
- package/src/review.service.spec.ts +32 -16
- package/src/review.service.ts +49 -51
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.55.0](https://github.com/Lydanne/spaceflow/compare/@spaceflow/review@0.54.0...@spaceflow/review@0.55.0) (2026-03-02)
|
|
4
|
+
|
|
5
|
+
### 新特性
|
|
6
|
+
|
|
7
|
+
* **review:** 保留历史行级评论,为每轮 Review 生成独立评论并添加上轮回顾 ([de431a0](https://github.com/Lydanne/spaceflow/commit/de431a09b4e3b5e1ada9ee5f1ee65786d22b6ff9))
|
|
8
|
+
* **review:** 支持用户手动 resolve 评论并在报告中区分 AI 修复与手动解决 ([c968b65](https://github.com/Lydanne/spaceflow/commit/c968b65c850bc68de3f4409aa3b5294e5a0311ff))
|
|
9
|
+
|
|
10
|
+
### 修复BUG
|
|
11
|
+
|
|
12
|
+
* **review:** 修复率计算仅统计 AI 修复的问题,排除手动解决的问题 ([12b3415](https://github.com/Lydanne/spaceflow/commit/12b3415749c9d8523e8b23365fbb39fc7657ff1d))
|
|
13
|
+
|
|
14
|
+
## [0.54.0](https://github.com/Lydanne/spaceflow/compare/@spaceflow/review@0.53.0...@spaceflow/review@0.54.0) (2026-03-02)
|
|
15
|
+
|
|
16
|
+
### 新特性
|
|
17
|
+
|
|
18
|
+
* **review:** 为行级评论 Review 添加统计信息摘要 ([58d5b37](https://github.com/Lydanne/spaceflow/commit/58d5b37ba54daa24bd2f8396318fedc87f388c74))
|
|
19
|
+
|
|
20
|
+
### 其他修改
|
|
21
|
+
|
|
22
|
+
* **review-summary:** released version 0.21.0 [no ci] ([11379c4](https://github.com/Lydanne/spaceflow/commit/11379c478859a12dd0340a78b1578487d9a24b31))
|
|
23
|
+
* **scripts:** released version 0.21.0 [no ci] ([1f0a213](https://github.com/Lydanne/spaceflow/commit/1f0a2139d155807451dc968de8213bafe2e4edb8))
|
|
24
|
+
* **shell:** released version 0.21.0 [no ci] ([b619af7](https://github.com/Lydanne/spaceflow/commit/b619af741e16053868a2eedd41f56d50134954d8))
|
|
25
|
+
|
|
3
26
|
## [0.53.0](https://github.com/Lydanne/spaceflow/compare/@spaceflow/review@0.52.0...@spaceflow/review@0.53.0) (2026-03-02)
|
|
4
27
|
|
|
5
28
|
### 修复BUG
|
package/dist/index.js
CHANGED
|
@@ -143,9 +143,9 @@ __webpack_require__.d(__webpack_exports__, {
|
|
|
143
143
|
;// CONCATENATED MODULE: external "@spaceflow/core"
|
|
144
144
|
|
|
145
145
|
;// CONCATENATED MODULE: ./src/locales/zh-cn/review.json
|
|
146
|
-
var review_namespaceObject = JSON.parse('{"description":"代码审查命令,使用 LLM 对 PR 代码进行自动审查","options.dryRun":"仅打印将要执行的操作,不实际提交评论","options.prNumber":"PR 编号,如果不指定则从环境变量获取","options.base":"基准分支/tag,用于比较差异","options.head":"目标分支/tag,用于比较差异","options.includes":"要审查的文件 glob 模式,如 *.ts *.js(可多次指定)","options.llmMode":"使用的 LLM 模式: claude-code, openai, gemini","options.files":"仅审查指定的文件(空格分隔)","options.commits":"仅审查指定的 commits(空格分隔)","options.verifyFixes":"是否验证历史问题是否已修复(默认从配置文件读取)","options.noVerifyFixes":"禁用历史问题验证","options.verifyConcurrency":"验证历史问题的并发数(默认 10)","options.analyzeDeletions":"分析删除代码可能带来的影响 (true, false, ci, pr, terminal)","options.deletionAnalysisMode":"删除代码分析模式: openai (标准模式) 或 claude-code (Agent 模式,可使用工具)","options.deletionOnly":"仅执行删除代码分析,跳过常规代码审查","options.outputFormat":"输出格式: markdown, terminal, json。不指定则智能选择(PR 用 markdown,终端用 terminal)","options.generateDescription":"使用 AI 生成 PR 功能描述","options.showAll":"显示所有发现的问题,不过滤非变更行的问题","options.flush":"仅刷新状态(同步 reactions、resolved conversations、replies),不执行 LLM 审查","options.eventAction":"PR 事件类型(opened, synchronize, closed 等),closed 时仅收集统计不进行 AI 审查","extensionDescription":"代码审查命令,使用 LLM 对 PR 代码进行自动审查","mcp.serverDescription":"代码审查规则查询服务","mcp.listRules":"获取当前项目的所有代码审查规则,返回规则列表包含 ID、标题、描述、适用的文件扩展名等信息","mcp.getRulesForFile":"获取某个文件应该使用的代码审查规则,根据文件扩展名和 includes 配置过滤","mcp.getRuleDetail":"获取某个规则的完整详情,包括描述、示例代码等","mcp.ruleNotFound":"规则 {{ruleId}} 不存在","mcp.dto.cwd":"项目根目录路径,默认为当前工作目录","mcp.dto.filePath":"文件路径,可以是相对路径或绝对路径","mcp.dto.includeExamples":"是否包含规则示例代码,默认 false","mcp.dto.ruleId":"规则 ID,如 JsTs.Naming.FileName"}')
|
|
146
|
+
var review_namespaceObject = JSON.parse('{"description":"代码审查命令,使用 LLM 对 PR 代码进行自动审查","options.dryRun":"仅打印将要执行的操作,不实际提交评论","options.prNumber":"PR 编号,如果不指定则从环境变量获取","options.base":"基准分支/tag,用于比较差异","options.head":"目标分支/tag,用于比较差异","options.includes":"要审查的文件 glob 模式,如 *.ts *.js(可多次指定)","options.llmMode":"使用的 LLM 模式: claude-code, openai, gemini","options.files":"仅审查指定的文件(空格分隔)","options.commits":"仅审查指定的 commits(空格分隔)","options.verifyFixes":"是否验证历史问题是否已修复(默认从配置文件读取)","options.noVerifyFixes":"禁用历史问题验证","options.verifyConcurrency":"验证历史问题的并发数(默认 10)","options.analyzeDeletions":"分析删除代码可能带来的影响 (true, false, ci, pr, terminal)","options.deletionAnalysisMode":"删除代码分析模式: openai (标准模式) 或 claude-code (Agent 模式,可使用工具)","options.deletionOnly":"仅执行删除代码分析,跳过常规代码审查","options.outputFormat":"输出格式: markdown, terminal, json。不指定则智能选择(PR 用 markdown,终端用 terminal)","options.generateDescription":"使用 AI 生成 PR 功能描述","options.showAll":"显示所有发现的问题,不过滤非变更行的问题","options.flush":"仅刷新状态(同步 reactions、resolved conversations、replies),不执行 LLM 审查","options.eventAction":"PR 事件类型(opened, synchronize, closed 等),closed 时仅收集统计不进行 AI 审查","extensionDescription":"代码审查命令,使用 LLM 对 PR 代码进行自动审查","mcp.serverDescription":"代码审查规则查询服务","mcp.listRules":"获取当前项目的所有代码审查规则,返回规则列表包含 ID、标题、描述、适用的文件扩展名等信息","mcp.getRulesForFile":"获取某个文件应该使用的代码审查规则,根据文件扩展名和 includes 配置过滤","mcp.getRuleDetail":"获取某个规则的完整详情,包括描述、示例代码等","mcp.ruleNotFound":"规则 {{ruleId}} 不存在","mcp.dto.cwd":"项目根目录路径,默认为当前工作目录","mcp.dto.filePath":"文件路径,可以是相对路径或绝对路径","mcp.dto.includeExamples":"是否包含规则示例代码,默认 false","mcp.dto.ruleId":"规则 ID,如 JsTs.Naming.FileName","mcp.getRulesFromDir":"从指定目录加载代码审查规则。读取目录下所有 .md 文件,解析为规则并按规则 ID 去重(后加载的覆盖先加载的)","mcp.dto.dirPath":"包含规则 .md 文件的目录路径,可以是相对路径或绝对路径"}')
|
|
147
147
|
;// CONCATENATED MODULE: ./src/locales/en/review.json
|
|
148
|
-
var en_review_namespaceObject = JSON.parse('{"description":"Code review command using LLM for automated PR review","options.dryRun":"Only print actions without posting comments","options.prNumber":"PR number, auto-detected from env if not specified","options.base":"Base branch/tag for diff comparison","options.head":"Head branch/tag for diff comparison","options.includes":"File glob patterns to review, e.g. *.ts *.js (can be specified multiple times)","options.llmMode":"LLM mode: claude-code, openai, gemini","options.files":"Only review specified files (space-separated)","options.commits":"Only review specified commits (space-separated)","options.verifyFixes":"Verify if historical issues are fixed (default from config)","options.noVerifyFixes":"Disable historical issue verification","options.verifyConcurrency":"Concurrency for verifying historical issues (default 10)","options.analyzeDeletions":"Analyze impact of deleted code (true, false, ci, pr, terminal)","options.deletionAnalysisMode":"Deletion analysis mode: openai (standard) or claude-code (Agent mode with tools)","options.deletionOnly":"Only run deletion analysis, skip regular code review","options.outputFormat":"Output format: markdown, terminal, json. Auto-selected if not specified (markdown for PR, terminal for CLI)","options.generateDescription":"Generate PR description using AI","options.showAll":"Show all issues found, including those on unchanged lines","options.flush":"Only sync status (reactions, resolved conversations, replies), skip LLM review","options.eventAction":"PR event type (opened, synchronize, closed, etc.), closed only collects stats without AI review","extensionDescription":"Code review command using LLM for automated PR review","mcp.serverDescription":"Code review rules query service","mcp.listRules":"List all code review rules for the current project, returning rule list with ID, title, description, applicable file extensions, etc.","mcp.getRulesForFile":"Get applicable code review rules for a specific file, filtered by file extension and includes configuration","mcp.getRuleDetail":"Get full details of a specific rule, including description, example code, etc.","mcp.ruleNotFound":"Rule {{ruleId}} not found","mcp.dto.cwd":"Project root directory path, defaults to current working directory","mcp.dto.filePath":"File path, can be relative or absolute","mcp.dto.includeExamples":"Whether to include rule example code, defaults to false","mcp.dto.ruleId":"Rule ID, e.g. JsTs.Naming.FileName"}')
|
|
148
|
+
var en_review_namespaceObject = JSON.parse('{"description":"Code review command using LLM for automated PR review","options.dryRun":"Only print actions without posting comments","options.prNumber":"PR number, auto-detected from env if not specified","options.base":"Base branch/tag for diff comparison","options.head":"Head branch/tag for diff comparison","options.includes":"File glob patterns to review, e.g. *.ts *.js (can be specified multiple times)","options.llmMode":"LLM mode: claude-code, openai, gemini","options.files":"Only review specified files (space-separated)","options.commits":"Only review specified commits (space-separated)","options.verifyFixes":"Verify if historical issues are fixed (default from config)","options.noVerifyFixes":"Disable historical issue verification","options.verifyConcurrency":"Concurrency for verifying historical issues (default 10)","options.analyzeDeletions":"Analyze impact of deleted code (true, false, ci, pr, terminal)","options.deletionAnalysisMode":"Deletion analysis mode: openai (standard) or claude-code (Agent mode with tools)","options.deletionOnly":"Only run deletion analysis, skip regular code review","options.outputFormat":"Output format: markdown, terminal, json. Auto-selected if not specified (markdown for PR, terminal for CLI)","options.generateDescription":"Generate PR description using AI","options.showAll":"Show all issues found, including those on unchanged lines","options.flush":"Only sync status (reactions, resolved conversations, replies), skip LLM review","options.eventAction":"PR event type (opened, synchronize, closed, etc.), closed only collects stats without AI review","extensionDescription":"Code review command using LLM for automated PR review","mcp.serverDescription":"Code review rules query service","mcp.listRules":"List all code review rules for the current project, returning rule list with ID, title, description, applicable file extensions, etc.","mcp.getRulesForFile":"Get applicable code review rules for a specific file, filtered by file extension and includes configuration","mcp.getRuleDetail":"Get full details of a specific rule, including description, example code, etc.","mcp.ruleNotFound":"Rule {{ruleId}} not found","mcp.dto.cwd":"Project root directory path, defaults to current working directory","mcp.dto.filePath":"File path, can be relative or absolute","mcp.dto.includeExamples":"Whether to include rule example code, defaults to false","mcp.dto.ruleId":"Rule ID, e.g. JsTs.Naming.FileName","mcp.getRulesFromDir":"Load code review rules from a specific directory. Reads all .md files in the directory, parses them into rules, and deduplicates by rule ID (later rules override earlier ones)","mcp.dto.dirPath":"Directory path containing rule .md files, can be relative or absolute"}')
|
|
149
149
|
;// CONCATENATED MODULE: ./src/locales/index.ts
|
|
150
150
|
|
|
151
151
|
|
|
@@ -363,6 +363,10 @@ class MarkdownFormatter {
|
|
|
363
363
|
const fixedByStr = issue.fixedBy?.login ? ` (by @${issue.fixedBy.login})` : "";
|
|
364
364
|
lines.push(`- **修复时间**: ${formatDateToUTC8(issue.fixed)}${fixedByStr}`);
|
|
365
365
|
}
|
|
366
|
+
if (issue.resolved) {
|
|
367
|
+
const resolvedByStr = issue.resolvedBy?.login ? ` (by @${issue.resolvedBy.login})` : "";
|
|
368
|
+
lines.push(`- **解决时间**: ${formatDateToUTC8(issue.resolved)}${resolvedByStr}`);
|
|
369
|
+
}
|
|
366
370
|
if (issue.suggestion) {
|
|
367
371
|
const ext = extname(issue.file).slice(1) || "";
|
|
368
372
|
const cleanSuggestion = issue.suggestion.replace(/```/g, "//").trim();
|
|
@@ -568,6 +572,7 @@ class MarkdownFormatter {
|
|
|
568
572
|
];
|
|
569
573
|
lines.push(`| 总问题数 | ${stats.total} |`);
|
|
570
574
|
lines.push(`| ✅ 已修复 | ${stats.fixed} |`);
|
|
575
|
+
lines.push(`| 🟢 已解决 | ${stats.resolved} |`);
|
|
571
576
|
lines.push(`| ❌ 无效 | ${stats.invalid} |`);
|
|
572
577
|
lines.push(`| ⚠️ 待处理 | ${stats.pending} |`);
|
|
573
578
|
lines.push(`| 修复率 | ${stats.fixRate}% |`);
|
|
@@ -687,6 +692,7 @@ class TerminalFormatter {
|
|
|
687
692
|
];
|
|
688
693
|
lines.push(` 总问题数: ${stats.total}`);
|
|
689
694
|
lines.push(` ${GREEN}✅ 已修复: ${stats.fixed}${RESET}`);
|
|
695
|
+
lines.push(` ${GREEN}🟢 已解决: ${stats.resolved}${RESET}`);
|
|
690
696
|
lines.push(` ${RED}❌ 无效: ${stats.invalid}${RESET}`);
|
|
691
697
|
lines.push(` ${YELLOW}⚠️ 待处理: ${stats.pending}${RESET}`);
|
|
692
698
|
lines.push(` 修复率: ${stats.fixRate}%`);
|
|
@@ -1611,12 +1617,14 @@ class ReviewService {
|
|
|
1611
1617
|
*/ calculateIssueStats(issues) {
|
|
1612
1618
|
const total = issues.length;
|
|
1613
1619
|
const fixed = issues.filter((i)=>i.fixed).length;
|
|
1620
|
+
const resolved = issues.filter((i)=>i.resolved && !i.fixed).length;
|
|
1614
1621
|
const invalid = issues.filter((i)=>i.valid === "false").length;
|
|
1615
|
-
const pending = total - fixed - invalid;
|
|
1622
|
+
const pending = total - fixed - resolved - invalid;
|
|
1616
1623
|
const fixRate = total > 0 ? Math.round(fixed / total * 100 * 10) / 10 : 0;
|
|
1617
1624
|
return {
|
|
1618
1625
|
total,
|
|
1619
1626
|
fixed,
|
|
1627
|
+
resolved,
|
|
1620
1628
|
invalid,
|
|
1621
1629
|
pending,
|
|
1622
1630
|
fixRate
|
|
@@ -2474,45 +2482,15 @@ ${fileChanges || "无"}`;
|
|
|
2474
2482
|
} catch (error) {
|
|
2475
2483
|
console.warn("⚠️ 发布/更新 AI Review 评论失败:", error);
|
|
2476
2484
|
}
|
|
2477
|
-
// 2.
|
|
2478
|
-
try {
|
|
2479
|
-
const reviews = await this.gitProvider.listPullReviews(owner, repo, prNumber);
|
|
2480
|
-
const oldLineReviews = reviews.filter((r)=>r.body?.includes(REVIEW_LINE_COMMENTS_MARKER));
|
|
2481
|
-
for (const review of oldLineReviews){
|
|
2482
|
-
if (review.id) {
|
|
2483
|
-
const reviewComments = await this.gitProvider.listPullReviewComments(owner, repo, prNumber, review.id);
|
|
2484
|
-
for (const comment of reviewComments){
|
|
2485
|
-
if (comment.id) {
|
|
2486
|
-
try {
|
|
2487
|
-
await this.gitProvider.deletePullReviewComment(owner, repo, comment.id);
|
|
2488
|
-
} catch {
|
|
2489
|
-
// 删除失败忽略
|
|
2490
|
-
}
|
|
2491
|
-
}
|
|
2492
|
-
}
|
|
2493
|
-
// 评论删除后尝试删除 review 本身
|
|
2494
|
-
try {
|
|
2495
|
-
await this.gitProvider.deletePullReview(owner, repo, prNumber, review.id);
|
|
2496
|
-
} catch {
|
|
2497
|
-
// 已提交的 review 无法删除,忽略
|
|
2498
|
-
}
|
|
2499
|
-
}
|
|
2500
|
-
}
|
|
2501
|
-
if (oldLineReviews.length > 0) {
|
|
2502
|
-
console.log(`🗑️ 已清理 ${oldLineReviews.length} 个旧的行级评论 review`);
|
|
2503
|
-
}
|
|
2504
|
-
} catch (error) {
|
|
2505
|
-
console.warn("⚠️ 清理旧行级评论失败:", error);
|
|
2506
|
-
}
|
|
2507
|
-
// 3. 发布新的行级评论(使用 PR Review API)
|
|
2485
|
+
// 2. 发布本轮新发现的行级评论(使用 PR Review API,不删除旧的 review,保留历史)
|
|
2508
2486
|
let lineIssues = [];
|
|
2509
2487
|
let comments = [];
|
|
2510
2488
|
if (reviewConf.lineComments) {
|
|
2511
|
-
lineIssues = result.issues.filter((issue)
|
|
2489
|
+
lineIssues = result.issues.filter((issue)=>issue.round === result.round && !issue.fixed && !issue.resolved && issue.valid !== "false");
|
|
2512
2490
|
comments = lineIssues.map((issue)=>this.issueToReviewComment(issue)).filter((comment)=>comment !== null);
|
|
2513
2491
|
}
|
|
2514
2492
|
if (comments.length > 0) {
|
|
2515
|
-
const reviewBody = this.buildLineReviewBody(lineIssues);
|
|
2493
|
+
const reviewBody = this.buildLineReviewBody(lineIssues, result.round, result.issues);
|
|
2516
2494
|
try {
|
|
2517
2495
|
await this.gitProvider.createPullReview(owner, repo, prNumber, {
|
|
2518
2496
|
event: REVIEW_STATE.COMMENT,
|
|
@@ -2571,7 +2549,8 @@ ${fileChanges || "无"}`;
|
|
|
2571
2549
|
}
|
|
2572
2550
|
}
|
|
2573
2551
|
/**
|
|
2574
|
-
* 从 PR 的所有 resolved review threads 中同步
|
|
2552
|
+
* 从 PR 的所有 resolved review threads 中同步 resolved 状态到 result.issues
|
|
2553
|
+
* 用户手动点击 resolve 的记录写入 resolved/resolvedBy 字段(区别于 AI 验证的 fixed/fixedBy)
|
|
2575
2554
|
* 优先通过评论 body 中的 issue key 精确匹配,回退到 path+line 匹配
|
|
2576
2555
|
*/ async syncResolvedComments(owner, repo, prNumber, result) {
|
|
2577
2556
|
try {
|
|
@@ -2599,10 +2578,10 @@ ${fileChanges || "无"}`;
|
|
|
2599
2578
|
if (!matchedIssue) {
|
|
2600
2579
|
matchedIssue = result.issues.find((issue)=>issue.file === thread.path && this.lineMatchesPosition(issue.line, thread.line));
|
|
2601
2580
|
}
|
|
2602
|
-
if (matchedIssue && !matchedIssue.
|
|
2603
|
-
matchedIssue.
|
|
2581
|
+
if (matchedIssue && !matchedIssue.resolved) {
|
|
2582
|
+
matchedIssue.resolved = now;
|
|
2604
2583
|
if (thread.resolvedBy) {
|
|
2605
|
-
matchedIssue.
|
|
2584
|
+
matchedIssue.resolvedBy = {
|
|
2606
2585
|
id: thread.resolvedBy.id?.toString(),
|
|
2607
2586
|
login: thread.resolvedBy.login
|
|
2608
2587
|
};
|
|
@@ -2872,19 +2851,38 @@ ${fileChanges || "无"}`;
|
|
|
2872
2851
|
}
|
|
2873
2852
|
}
|
|
2874
2853
|
/**
|
|
2875
|
-
* 构建行级评论 Review 的 body(marker +
|
|
2876
|
-
*/ buildLineReviewBody(issues) {
|
|
2854
|
+
* 构建行级评论 Review 的 body(marker + 本轮统计 + 上轮回顾)
|
|
2855
|
+
*/ buildLineReviewBody(issues, round, allIssues) {
|
|
2877
2856
|
const errorCount = issues.filter((i)=>i.severity === "error").length;
|
|
2878
2857
|
const warnCount = issues.filter((i)=>i.severity === "warn").length;
|
|
2879
2858
|
const fileCount = new Set(issues.map((i)=>i.file)).size;
|
|
2859
|
+
const badges = [];
|
|
2860
|
+
if (errorCount > 0) badges.push(`🔴 ${errorCount}`);
|
|
2861
|
+
if (warnCount > 0) badges.push(`🟡 ${warnCount}`);
|
|
2880
2862
|
const parts = [
|
|
2881
2863
|
REVIEW_LINE_COMMENTS_MARKER
|
|
2882
2864
|
];
|
|
2883
|
-
parts.push(
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
if (
|
|
2887
|
-
|
|
2865
|
+
parts.push(`### � Spaceflow Review · Round ${round}`);
|
|
2866
|
+
parts.push(`> **${issues.length}** 个新问题 · **${fileCount}** 个文件${badges.length > 0 ? " · " + badges.join(" ") : ""}`);
|
|
2867
|
+
// 上轮回顾
|
|
2868
|
+
if (round > 1) {
|
|
2869
|
+
const prevIssues = allIssues.filter((i)=>i.round === round - 1);
|
|
2870
|
+
if (prevIssues.length > 0) {
|
|
2871
|
+
const prevFixed = prevIssues.filter((i)=>i.fixed).length;
|
|
2872
|
+
const prevResolved = prevIssues.filter((i)=>i.resolved && !i.fixed).length;
|
|
2873
|
+
const prevInvalid = prevIssues.filter((i)=>i.valid === "false").length;
|
|
2874
|
+
const prevPending = prevIssues.length - prevFixed - prevResolved - prevInvalid;
|
|
2875
|
+
parts.push("");
|
|
2876
|
+
parts.push(`<details><summary>📊 Round ${round - 1} 回顾 (${prevIssues.length} 个问题)</summary>\n`);
|
|
2877
|
+
parts.push(`| 状态 | 数量 |`);
|
|
2878
|
+
parts.push(`|------|------|`);
|
|
2879
|
+
if (prevFixed > 0) parts.push(`| ✅ 已修复 | ${prevFixed} |`);
|
|
2880
|
+
if (prevResolved > 0) parts.push(`| 🟢 已解决 | ${prevResolved} |`);
|
|
2881
|
+
if (prevInvalid > 0) parts.push(`| ❌ 无效 | ${prevInvalid} |`);
|
|
2882
|
+
if (prevPending > 0) parts.push(`| ⚠️ 待处理 | ${prevPending} |`);
|
|
2883
|
+
parts.push(`\n</details>`);
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2888
2886
|
return parts.join("\n");
|
|
2889
2887
|
}
|
|
2890
2888
|
/**
|
|
@@ -5040,6 +5038,10 @@ const getRulesForFileInputSchema = z.object({
|
|
|
5040
5038
|
const getRuleDetailInputSchema = z.object({
|
|
5041
5039
|
ruleId: z.string().describe(t("review:mcp.dto.ruleId"))
|
|
5042
5040
|
});
|
|
5041
|
+
const getRulesFromDirInputSchema = z.object({
|
|
5042
|
+
dirPath: z.string().describe(t("review:mcp.dto.dirPath")),
|
|
5043
|
+
includeExamples: z.boolean().optional().describe(t("review:mcp.dto.includeExamples"))
|
|
5044
|
+
});
|
|
5043
5045
|
/**
|
|
5044
5046
|
* 获取 GitProviderService(可选)
|
|
5045
5047
|
*/ function getGitProvider(ctx) {
|
|
@@ -5192,6 +5194,49 @@ const tools = [
|
|
|
5192
5194
|
}))
|
|
5193
5195
|
};
|
|
5194
5196
|
}
|
|
5197
|
+
},
|
|
5198
|
+
{
|
|
5199
|
+
name: "get_rules_from_dir",
|
|
5200
|
+
description: t("review:mcp.getRulesFromDir"),
|
|
5201
|
+
inputSchema: getRulesFromDirInputSchema,
|
|
5202
|
+
handler: async (input, ctx)=>{
|
|
5203
|
+
const { dirPath, includeExamples } = input;
|
|
5204
|
+
const workDir = ctx.cwd;
|
|
5205
|
+
const resolvedDir = dirPath.startsWith("/") ? dirPath : join(workDir, dirPath);
|
|
5206
|
+
if (!existsSync(resolvedDir)) {
|
|
5207
|
+
return {
|
|
5208
|
+
error: `Directory not found: ${resolvedDir}`
|
|
5209
|
+
};
|
|
5210
|
+
}
|
|
5211
|
+
const gitProvider = getGitProvider(ctx);
|
|
5212
|
+
const specService = new ReviewSpecService(gitProvider);
|
|
5213
|
+
const specs = await specService.loadReviewSpecs(resolvedDir);
|
|
5214
|
+
const dedupedSpecs = specService.deduplicateSpecs(specs);
|
|
5215
|
+
const rules = dedupedSpecs.flatMap((spec)=>spec.rules.map((rule)=>({
|
|
5216
|
+
id: rule.id,
|
|
5217
|
+
title: rule.title,
|
|
5218
|
+
description: includeExamples ? rule.description : rule.description.slice(0, 200) + (rule.description.length > 200 ? "..." : ""),
|
|
5219
|
+
severity: rule.severity || spec.severity,
|
|
5220
|
+
extensions: spec.extensions,
|
|
5221
|
+
specFile: spec.filename,
|
|
5222
|
+
includes: spec.includes,
|
|
5223
|
+
...includeExamples && rule.examples.length > 0 ? {
|
|
5224
|
+
examples: rule.examples.map((ex)=>({
|
|
5225
|
+
type: ex.type,
|
|
5226
|
+
lang: ex.lang,
|
|
5227
|
+
code: ex.code
|
|
5228
|
+
}))
|
|
5229
|
+
} : {
|
|
5230
|
+
hasExamples: rule.examples.length > 0
|
|
5231
|
+
}
|
|
5232
|
+
})));
|
|
5233
|
+
return {
|
|
5234
|
+
dir: resolvedDir,
|
|
5235
|
+
specFiles: dedupedSpecs.length,
|
|
5236
|
+
total: rules.length,
|
|
5237
|
+
rules
|
|
5238
|
+
};
|
|
5239
|
+
}
|
|
5195
5240
|
}
|
|
5196
5241
|
];
|
|
5197
5242
|
|
package/package.json
CHANGED
|
@@ -28,5 +28,7 @@
|
|
|
28
28
|
"mcp.dto.cwd": "Project root directory path, defaults to current working directory",
|
|
29
29
|
"mcp.dto.filePath": "File path, can be relative or absolute",
|
|
30
30
|
"mcp.dto.includeExamples": "Whether to include rule example code, defaults to false",
|
|
31
|
-
"mcp.dto.ruleId": "Rule ID, e.g. JsTs.Naming.FileName"
|
|
31
|
+
"mcp.dto.ruleId": "Rule ID, e.g. JsTs.Naming.FileName",
|
|
32
|
+
"mcp.getRulesFromDir": "Load code review rules from a specific directory. Reads all .md files in the directory, parses them into rules, and deduplicates by rule ID (later rules override earlier ones)",
|
|
33
|
+
"mcp.dto.dirPath": "Directory path containing rule .md files, can be relative or absolute"
|
|
32
34
|
}
|
|
@@ -28,5 +28,7 @@
|
|
|
28
28
|
"mcp.dto.cwd": "项目根目录路径,默认为当前工作目录",
|
|
29
29
|
"mcp.dto.filePath": "文件路径,可以是相对路径或绝对路径",
|
|
30
30
|
"mcp.dto.includeExamples": "是否包含规则示例代码,默认 false",
|
|
31
|
-
"mcp.dto.ruleId": "规则 ID,如 JsTs.Naming.FileName"
|
|
31
|
+
"mcp.dto.ruleId": "规则 ID,如 JsTs.Naming.FileName",
|
|
32
|
+
"mcp.getRulesFromDir": "从指定目录加载代码审查规则。读取目录下所有 .md 文件,解析为规则并按规则 ID 去重(后加载的覆盖先加载的)",
|
|
33
|
+
"mcp.dto.dirPath": "包含规则 .md 文件的目录路径,可以是相对路径或绝对路径"
|
|
32
34
|
}
|
package/src/mcp/index.ts
CHANGED
|
@@ -16,6 +16,11 @@ export const getRuleDetailInputSchema = z.object({
|
|
|
16
16
|
ruleId: z.string().describe(t("review:mcp.dto.ruleId")),
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
+
export const getRulesFromDirInputSchema = z.object({
|
|
20
|
+
dirPath: z.string().describe(t("review:mcp.dto.dirPath")),
|
|
21
|
+
includeExamples: z.boolean().optional().describe(t("review:mcp.dto.includeExamples")),
|
|
22
|
+
});
|
|
23
|
+
|
|
19
24
|
/**
|
|
20
25
|
* 获取 GitProviderService(可选)
|
|
21
26
|
*/
|
|
@@ -165,4 +170,53 @@ export const tools = [
|
|
|
165
170
|
};
|
|
166
171
|
},
|
|
167
172
|
},
|
|
173
|
+
{
|
|
174
|
+
name: "get_rules_from_dir",
|
|
175
|
+
description: t("review:mcp.getRulesFromDir"),
|
|
176
|
+
inputSchema: getRulesFromDirInputSchema,
|
|
177
|
+
handler: async (input, ctx) => {
|
|
178
|
+
const { dirPath, includeExamples } = input as z.infer<typeof getRulesFromDirInputSchema>;
|
|
179
|
+
const workDir = ctx.cwd;
|
|
180
|
+
const resolvedDir = dirPath.startsWith("/") ? dirPath : join(workDir, dirPath);
|
|
181
|
+
|
|
182
|
+
if (!existsSync(resolvedDir)) {
|
|
183
|
+
return { error: `Directory not found: ${resolvedDir}` };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const gitProvider = getGitProvider(ctx);
|
|
187
|
+
const specService = new ReviewSpecService(gitProvider);
|
|
188
|
+
const specs = await specService.loadReviewSpecs(resolvedDir);
|
|
189
|
+
const dedupedSpecs = specService.deduplicateSpecs(specs);
|
|
190
|
+
|
|
191
|
+
const rules = dedupedSpecs.flatMap((spec) =>
|
|
192
|
+
spec.rules.map((rule) => ({
|
|
193
|
+
id: rule.id,
|
|
194
|
+
title: rule.title,
|
|
195
|
+
description: includeExamples
|
|
196
|
+
? rule.description
|
|
197
|
+
: rule.description.slice(0, 200) + (rule.description.length > 200 ? "..." : ""),
|
|
198
|
+
severity: rule.severity || spec.severity,
|
|
199
|
+
extensions: spec.extensions,
|
|
200
|
+
specFile: spec.filename,
|
|
201
|
+
includes: spec.includes,
|
|
202
|
+
...(includeExamples && rule.examples.length > 0
|
|
203
|
+
? {
|
|
204
|
+
examples: rule.examples.map((ex) => ({
|
|
205
|
+
type: ex.type,
|
|
206
|
+
lang: ex.lang,
|
|
207
|
+
code: ex.code,
|
|
208
|
+
})),
|
|
209
|
+
}
|
|
210
|
+
: { hasExamples: rule.examples.length > 0 }),
|
|
211
|
+
})),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
dir: resolvedDir,
|
|
216
|
+
specFiles: dedupedSpecs.length,
|
|
217
|
+
total: rules.length,
|
|
218
|
+
rules,
|
|
219
|
+
};
|
|
220
|
+
},
|
|
221
|
+
},
|
|
168
222
|
];
|
|
@@ -53,6 +53,10 @@ export class MarkdownFormatter implements ReviewReportFormatter, ReviewReportPar
|
|
|
53
53
|
const fixedByStr = issue.fixedBy?.login ? ` (by @${issue.fixedBy.login})` : "";
|
|
54
54
|
lines.push(`- **修复时间**: ${formatDateToUTC8(issue.fixed)}${fixedByStr}`);
|
|
55
55
|
}
|
|
56
|
+
if (issue.resolved) {
|
|
57
|
+
const resolvedByStr = issue.resolvedBy?.login ? ` (by @${issue.resolvedBy.login})` : "";
|
|
58
|
+
lines.push(`- **解决时间**: ${formatDateToUTC8(issue.resolved)}${resolvedByStr}`);
|
|
59
|
+
}
|
|
56
60
|
if (issue.suggestion) {
|
|
57
61
|
const ext = extname(issue.file).slice(1) || "";
|
|
58
62
|
const cleanSuggestion = issue.suggestion.replace(/```/g, "//").trim();
|
|
@@ -284,6 +288,7 @@ export class MarkdownFormatter implements ReviewReportFormatter, ReviewReportPar
|
|
|
284
288
|
const lines = [`## 📊 ${title}\n`, `| 指标 | 数量 |`, `|------|------|`];
|
|
285
289
|
lines.push(`| 总问题数 | ${stats.total} |`);
|
|
286
290
|
lines.push(`| ✅ 已修复 | ${stats.fixed} |`);
|
|
291
|
+
lines.push(`| 🟢 已解决 | ${stats.resolved} |`);
|
|
287
292
|
lines.push(`| ❌ 无效 | ${stats.invalid} |`);
|
|
288
293
|
lines.push(`| ⚠️ 待处理 | ${stats.pending} |`);
|
|
289
294
|
lines.push(`| 修复率 | ${stats.fixRate}% |`);
|
|
@@ -122,6 +122,7 @@ export class TerminalFormatter implements ReviewReportFormatter {
|
|
|
122
122
|
const lines = [`\n${BOLD}${CYAN}📊 ${title}:${RESET}`];
|
|
123
123
|
lines.push(` 总问题数: ${stats.total}`);
|
|
124
124
|
lines.push(` ${GREEN}✅ 已修复: ${stats.fixed}${RESET}`);
|
|
125
|
+
lines.push(` ${GREEN}🟢 已解决: ${stats.resolved}${RESET}`);
|
|
125
126
|
lines.push(` ${RED}❌ 无效: ${stats.invalid}${RESET}`);
|
|
126
127
|
lines.push(` ${YELLOW}⚠️ 待处理: ${stats.pending}${RESET}`);
|
|
127
128
|
lines.push(` 修复率: ${stats.fixRate}%`);
|
package/src/review-spec/types.ts
CHANGED
|
@@ -73,8 +73,10 @@ export interface ReviewIssue {
|
|
|
73
73
|
specFile: string;
|
|
74
74
|
reason: string;
|
|
75
75
|
date?: string; // 发现问题的时间
|
|
76
|
-
fixed?: string; //
|
|
77
|
-
fixedBy?: UserInfo; //
|
|
76
|
+
fixed?: string; // AI 验证修复时间
|
|
77
|
+
fixedBy?: UserInfo; // AI 验证修复者
|
|
78
|
+
resolved?: string; // 用户手动点击 resolve 的时间
|
|
79
|
+
resolvedBy?: UserInfo; // 手动 resolve 的操作者
|
|
78
80
|
valid?: string; // 问题是否有效
|
|
79
81
|
suggestion?: string;
|
|
80
82
|
commit?: string;
|
|
@@ -115,8 +117,10 @@ export interface DeletionImpactResult {
|
|
|
115
117
|
export interface ReviewStats {
|
|
116
118
|
/** 总问题数 */
|
|
117
119
|
total: number;
|
|
118
|
-
/**
|
|
120
|
+
/** AI 验证已修复数 */
|
|
119
121
|
fixed: number;
|
|
122
|
+
/** 用户手动 resolve 数 */
|
|
123
|
+
resolved: number;
|
|
120
124
|
/** 无效问题数 */
|
|
121
125
|
invalid: number;
|
|
122
126
|
/** 待处理数 */
|
|
@@ -1226,17 +1226,32 @@ describe("ReviewService", () => {
|
|
|
1226
1226
|
describe("ReviewService.calculateIssueStats", () => {
|
|
1227
1227
|
it("should calculate stats for empty array", () => {
|
|
1228
1228
|
const stats = (service as any).calculateIssueStats([]);
|
|
1229
|
-
expect(stats).toEqual({
|
|
1229
|
+
expect(stats).toEqual({
|
|
1230
|
+
total: 0,
|
|
1231
|
+
fixed: 0,
|
|
1232
|
+
resolved: 0,
|
|
1233
|
+
invalid: 0,
|
|
1234
|
+
pending: 0,
|
|
1235
|
+
fixRate: 0,
|
|
1236
|
+
});
|
|
1230
1237
|
});
|
|
1231
1238
|
|
|
1232
1239
|
it("should calculate stats correctly", () => {
|
|
1233
|
-
const issues = [
|
|
1240
|
+
const issues = [
|
|
1241
|
+
{ fixed: "2024-01-01" },
|
|
1242
|
+
{ fixed: "2024-01-02" },
|
|
1243
|
+
{ resolved: "2024-01-03" },
|
|
1244
|
+
{ valid: "false" },
|
|
1245
|
+
{},
|
|
1246
|
+
{},
|
|
1247
|
+
];
|
|
1234
1248
|
const stats = (service as any).calculateIssueStats(issues);
|
|
1235
|
-
expect(stats.total).toBe(
|
|
1249
|
+
expect(stats.total).toBe(6);
|
|
1236
1250
|
expect(stats.fixed).toBe(2);
|
|
1251
|
+
expect(stats.resolved).toBe(1);
|
|
1237
1252
|
expect(stats.invalid).toBe(1);
|
|
1238
1253
|
expect(stats.pending).toBe(2);
|
|
1239
|
-
expect(stats.fixRate).toBe(
|
|
1254
|
+
expect(stats.fixRate).toBe(33.3);
|
|
1240
1255
|
});
|
|
1241
1256
|
});
|
|
1242
1257
|
|
|
@@ -1807,6 +1822,7 @@ describe("ReviewService", () => {
|
|
|
1807
1822
|
specFile: "s.md",
|
|
1808
1823
|
reason: "r",
|
|
1809
1824
|
severity: "error",
|
|
1825
|
+
round: 1,
|
|
1810
1826
|
},
|
|
1811
1827
|
],
|
|
1812
1828
|
summary: [],
|
|
@@ -1820,18 +1836,18 @@ describe("ReviewService", () => {
|
|
|
1820
1836
|
});
|
|
1821
1837
|
|
|
1822
1838
|
describe("ReviewService.syncResolvedComments", () => {
|
|
1823
|
-
it("should mark matched issues as
|
|
1839
|
+
it("should mark matched issues as resolved via path:line fallback", async () => {
|
|
1824
1840
|
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
1825
1841
|
gitProvider.listResolvedThreads.mockResolvedValue([
|
|
1826
1842
|
{ path: "test.ts", line: 10, resolvedBy: { login: "user1" } },
|
|
1827
1843
|
] as any);
|
|
1828
1844
|
const result = { issues: [{ file: "test.ts", line: "10", ruleId: "Rule1" }] };
|
|
1829
1845
|
await (service as any).syncResolvedComments("o", "r", 1, result);
|
|
1830
|
-
expect((result.issues[0] as any).
|
|
1831
|
-
expect((result.issues[0] as any).
|
|
1846
|
+
expect((result.issues[0] as any).resolved).toBeDefined();
|
|
1847
|
+
expect((result.issues[0] as any).resolvedBy).toEqual({ id: undefined, login: "user1" });
|
|
1832
1848
|
});
|
|
1833
1849
|
|
|
1834
|
-
it("should mark matched issues as
|
|
1850
|
+
it("should mark matched issues as resolved via issue key in body", async () => {
|
|
1835
1851
|
mockReviewSpecService.parseLineRange = vi.fn().mockReturnValue([10]);
|
|
1836
1852
|
gitProvider.listResolvedThreads.mockResolvedValue([
|
|
1837
1853
|
{
|
|
@@ -1845,8 +1861,8 @@ describe("ReviewService", () => {
|
|
|
1845
1861
|
issues: [{ file: "test.ts", line: "10", ruleId: "RuleA" }],
|
|
1846
1862
|
};
|
|
1847
1863
|
await (service as any).syncResolvedComments("o", "r", 1, result);
|
|
1848
|
-
expect((result.issues[0] as any).
|
|
1849
|
-
expect((result.issues[0] as any).
|
|
1864
|
+
expect((result.issues[0] as any).resolved).toBeDefined();
|
|
1865
|
+
expect((result.issues[0] as any).resolvedBy).toEqual({ id: undefined, login: "user1" });
|
|
1850
1866
|
});
|
|
1851
1867
|
|
|
1852
1868
|
it("should match correct issue by issue key when multiple issues at same position", async () => {
|
|
@@ -1866,17 +1882,17 @@ describe("ReviewService", () => {
|
|
|
1866
1882
|
],
|
|
1867
1883
|
};
|
|
1868
1884
|
await (service as any).syncResolvedComments("o", "r", 1, result);
|
|
1869
|
-
expect(result.issues[0].
|
|
1870
|
-
expect(result.issues[0].
|
|
1871
|
-
expect(result.issues[1].
|
|
1872
|
-
expect(result.issues[1].
|
|
1885
|
+
expect(result.issues[0].resolved).toBeUndefined(); // RuleA 未解决
|
|
1886
|
+
expect(result.issues[0].resolvedBy).toBeUndefined();
|
|
1887
|
+
expect(result.issues[1].resolved).toBeDefined(); // RuleB 已解决
|
|
1888
|
+
expect(result.issues[1].resolvedBy).toEqual({ id: undefined, login: "user1" });
|
|
1873
1889
|
});
|
|
1874
1890
|
|
|
1875
1891
|
it("should skip when no resolved threads", async () => {
|
|
1876
1892
|
gitProvider.listResolvedThreads.mockResolvedValue([] as any);
|
|
1877
1893
|
const result = { issues: [{ file: "test.ts", line: "10", ruleId: "Rule1" }] };
|
|
1878
1894
|
await (service as any).syncResolvedComments("o", "r", 1, result);
|
|
1879
|
-
expect((result.issues[0] as any).
|
|
1895
|
+
expect((result.issues[0] as any).resolved).toBeUndefined();
|
|
1880
1896
|
});
|
|
1881
1897
|
|
|
1882
1898
|
it("should skip threads without path", async () => {
|
|
@@ -1885,7 +1901,7 @@ describe("ReviewService", () => {
|
|
|
1885
1901
|
] as any);
|
|
1886
1902
|
const result = { issues: [{ file: "test.ts", line: "10", ruleId: "Rule1" }] };
|
|
1887
1903
|
await (service as any).syncResolvedComments("o", "r", 1, result);
|
|
1888
|
-
expect((result.issues[0] as any).
|
|
1904
|
+
expect((result.issues[0] as any).resolved).toBeUndefined();
|
|
1889
1905
|
});
|
|
1890
1906
|
|
|
1891
1907
|
it("should handle error gracefully", async () => {
|
package/src/review.service.ts
CHANGED
|
@@ -900,10 +900,11 @@ export class ReviewService {
|
|
|
900
900
|
protected calculateIssueStats(issues: ReviewIssue[]): ReviewStats {
|
|
901
901
|
const total = issues.length;
|
|
902
902
|
const fixed = issues.filter((i) => i.fixed).length;
|
|
903
|
+
const resolved = issues.filter((i) => i.resolved && !i.fixed).length;
|
|
903
904
|
const invalid = issues.filter((i) => i.valid === "false").length;
|
|
904
|
-
const pending = total - fixed - invalid;
|
|
905
|
+
const pending = total - fixed - resolved - invalid;
|
|
905
906
|
const fixRate = total > 0 ? Math.round((fixed / total) * 100 * 10) / 10 : 0;
|
|
906
|
-
return { total, fixed, invalid, pending, fixRate };
|
|
907
|
+
return { total, fixed, resolved, invalid, pending, fixRate };
|
|
907
908
|
}
|
|
908
909
|
|
|
909
910
|
/**
|
|
@@ -1933,52 +1934,23 @@ ${fileChanges || "无"}`;
|
|
|
1933
1934
|
console.warn("⚠️ 发布/更新 AI Review 评论失败:", error);
|
|
1934
1935
|
}
|
|
1935
1936
|
|
|
1936
|
-
// 2.
|
|
1937
|
-
try {
|
|
1938
|
-
const reviews = await this.gitProvider.listPullReviews(owner, repo, prNumber);
|
|
1939
|
-
const oldLineReviews = reviews.filter((r) => r.body?.includes(REVIEW_LINE_COMMENTS_MARKER));
|
|
1940
|
-
for (const review of oldLineReviews) {
|
|
1941
|
-
if (review.id) {
|
|
1942
|
-
const reviewComments = await this.gitProvider.listPullReviewComments(
|
|
1943
|
-
owner,
|
|
1944
|
-
repo,
|
|
1945
|
-
prNumber,
|
|
1946
|
-
review.id,
|
|
1947
|
-
);
|
|
1948
|
-
for (const comment of reviewComments) {
|
|
1949
|
-
if (comment.id) {
|
|
1950
|
-
try {
|
|
1951
|
-
await this.gitProvider.deletePullReviewComment(owner, repo, comment.id);
|
|
1952
|
-
} catch {
|
|
1953
|
-
// 删除失败忽略
|
|
1954
|
-
}
|
|
1955
|
-
}
|
|
1956
|
-
}
|
|
1957
|
-
// 评论删除后尝试删除 review 本身
|
|
1958
|
-
try {
|
|
1959
|
-
await this.gitProvider.deletePullReview(owner, repo, prNumber, review.id);
|
|
1960
|
-
} catch {
|
|
1961
|
-
// 已提交的 review 无法删除,忽略
|
|
1962
|
-
}
|
|
1963
|
-
}
|
|
1964
|
-
}
|
|
1965
|
-
if (oldLineReviews.length > 0) {
|
|
1966
|
-
console.log(`🗑️ 已清理 ${oldLineReviews.length} 个旧的行级评论 review`);
|
|
1967
|
-
}
|
|
1968
|
-
} catch (error) {
|
|
1969
|
-
console.warn("⚠️ 清理旧行级评论失败:", error);
|
|
1970
|
-
}
|
|
1971
|
-
// 3. 发布新的行级评论(使用 PR Review API)
|
|
1937
|
+
// 2. 发布本轮新发现的行级评论(使用 PR Review API,不删除旧的 review,保留历史)
|
|
1972
1938
|
let lineIssues: ReviewIssue[] = [];
|
|
1973
1939
|
let comments: CreatePullReviewComment[] = [];
|
|
1974
1940
|
if (reviewConf.lineComments) {
|
|
1975
|
-
lineIssues = result.issues.filter(
|
|
1941
|
+
lineIssues = result.issues.filter(
|
|
1942
|
+
(issue) =>
|
|
1943
|
+
issue.round === result.round &&
|
|
1944
|
+
!issue.fixed &&
|
|
1945
|
+
!issue.resolved &&
|
|
1946
|
+
issue.valid !== "false",
|
|
1947
|
+
);
|
|
1976
1948
|
comments = lineIssues
|
|
1977
1949
|
.map((issue) => this.issueToReviewComment(issue))
|
|
1978
1950
|
.filter((comment): comment is CreatePullReviewComment => comment !== null);
|
|
1979
1951
|
}
|
|
1980
1952
|
if (comments.length > 0) {
|
|
1981
|
-
const reviewBody = this.buildLineReviewBody(lineIssues);
|
|
1953
|
+
const reviewBody = this.buildLineReviewBody(lineIssues, result.round, result.issues);
|
|
1982
1954
|
try {
|
|
1983
1955
|
await this.gitProvider.createPullReview(owner, repo, prNumber, {
|
|
1984
1956
|
event: REVIEW_STATE.COMMENT,
|
|
@@ -2047,7 +2019,8 @@ ${fileChanges || "无"}`;
|
|
|
2047
2019
|
}
|
|
2048
2020
|
|
|
2049
2021
|
/**
|
|
2050
|
-
* 从 PR 的所有 resolved review threads 中同步
|
|
2022
|
+
* 从 PR 的所有 resolved review threads 中同步 resolved 状态到 result.issues
|
|
2023
|
+
* 用户手动点击 resolve 的记录写入 resolved/resolvedBy 字段(区别于 AI 验证的 fixed/fixedBy)
|
|
2051
2024
|
* 优先通过评论 body 中的 issue key 精确匹配,回退到 path+line 匹配
|
|
2052
2025
|
*/
|
|
2053
2026
|
protected async syncResolvedComments(
|
|
@@ -2084,10 +2057,10 @@ ${fileChanges || "无"}`;
|
|
|
2084
2057
|
issue.file === thread.path && this.lineMatchesPosition(issue.line, thread.line),
|
|
2085
2058
|
);
|
|
2086
2059
|
}
|
|
2087
|
-
if (matchedIssue && !matchedIssue.
|
|
2088
|
-
matchedIssue.
|
|
2060
|
+
if (matchedIssue && !matchedIssue.resolved) {
|
|
2061
|
+
matchedIssue.resolved = now;
|
|
2089
2062
|
if (thread.resolvedBy) {
|
|
2090
|
-
matchedIssue.
|
|
2063
|
+
matchedIssue.resolvedBy = {
|
|
2091
2064
|
id: thread.resolvedBy.id?.toString(),
|
|
2092
2065
|
login: thread.resolvedBy.login,
|
|
2093
2066
|
};
|
|
@@ -2430,19 +2403,44 @@ ${fileChanges || "无"}`;
|
|
|
2430
2403
|
}
|
|
2431
2404
|
|
|
2432
2405
|
/**
|
|
2433
|
-
* 构建行级评论 Review 的 body(marker +
|
|
2406
|
+
* 构建行级评论 Review 的 body(marker + 本轮统计 + 上轮回顾)
|
|
2434
2407
|
*/
|
|
2435
|
-
protected buildLineReviewBody(
|
|
2408
|
+
protected buildLineReviewBody(
|
|
2409
|
+
issues: ReviewIssue[],
|
|
2410
|
+
round: number,
|
|
2411
|
+
allIssues: ReviewIssue[],
|
|
2412
|
+
): string {
|
|
2436
2413
|
const errorCount = issues.filter((i) => i.severity === "error").length;
|
|
2437
2414
|
const warnCount = issues.filter((i) => i.severity === "warn").length;
|
|
2438
2415
|
const fileCount = new Set(issues.map((i) => i.file)).size;
|
|
2439
2416
|
|
|
2440
|
-
const parts: string[] = [REVIEW_LINE_COMMENTS_MARKER];
|
|
2441
|
-
parts.push(`**Spaceflow Review** — ${issues.length} 个问题,涉及 ${fileCount} 个文件`);
|
|
2442
2417
|
const badges: string[] = [];
|
|
2443
|
-
if (errorCount > 0) badges.push(`🔴 ${errorCount}
|
|
2444
|
-
if (warnCount > 0) badges.push(`🟡 ${warnCount}
|
|
2445
|
-
|
|
2418
|
+
if (errorCount > 0) badges.push(`🔴 ${errorCount}`);
|
|
2419
|
+
if (warnCount > 0) badges.push(`🟡 ${warnCount}`);
|
|
2420
|
+
|
|
2421
|
+
const parts: string[] = [REVIEW_LINE_COMMENTS_MARKER];
|
|
2422
|
+
parts.push(`### � Spaceflow Review · Round ${round}`);
|
|
2423
|
+
parts.push(`> **${issues.length}** 个新问题 · **${fileCount}** 个文件${badges.length > 0 ? " · " + badges.join(" ") : ""}`);
|
|
2424
|
+
|
|
2425
|
+
// 上轮回顾
|
|
2426
|
+
if (round > 1) {
|
|
2427
|
+
const prevIssues = allIssues.filter((i) => i.round === round - 1);
|
|
2428
|
+
if (prevIssues.length > 0) {
|
|
2429
|
+
const prevFixed = prevIssues.filter((i) => i.fixed).length;
|
|
2430
|
+
const prevResolved = prevIssues.filter((i) => i.resolved && !i.fixed).length;
|
|
2431
|
+
const prevInvalid = prevIssues.filter((i) => i.valid === "false").length;
|
|
2432
|
+
const prevPending = prevIssues.length - prevFixed - prevResolved - prevInvalid;
|
|
2433
|
+
parts.push("");
|
|
2434
|
+
parts.push(`<details><summary>📊 Round ${round - 1} 回顾 (${prevIssues.length} 个问题)</summary>\n`);
|
|
2435
|
+
parts.push(`| 状态 | 数量 |`);
|
|
2436
|
+
parts.push(`|------|------|`);
|
|
2437
|
+
if (prevFixed > 0) parts.push(`| ✅ 已修复 | ${prevFixed} |`);
|
|
2438
|
+
if (prevResolved > 0) parts.push(`| 🟢 已解决 | ${prevResolved} |`);
|
|
2439
|
+
if (prevInvalid > 0) parts.push(`| ❌ 无效 | ${prevInvalid} |`);
|
|
2440
|
+
if (prevPending > 0) parts.push(`| ⚠️ 待处理 | ${prevPending} |`);
|
|
2441
|
+
parts.push(`\n</details>`);
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2446
2444
|
|
|
2447
2445
|
return parts.join("\n");
|
|
2448
2446
|
}
|