@spaceflow/review 0.56.0 → 0.57.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaceflow/review",
3
- "version": "0.56.0",
3
+ "version": "0.57.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.19.0"
31
+ "@spaceflow/core": "0.20.0"
32
32
  },
33
33
  "spaceflow": {
34
34
  "type": "flow",
package/src/index.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import "./locales";
2
+ export * from "./review-spec";
3
+ export * from "./review-report";
2
4
  import { defineExtension, t } from "@spaceflow/core";
3
5
  import type { GitProviderService, LlmProxyService, GitSdkService, LLMMode } from "@spaceflow/core";
4
6
  import { parseVerbose } from "@spaceflow/core";
@@ -130,30 +130,32 @@ export class MarkdownFormatter implements ReviewReportFormatter, ReviewReportPar
130
130
  return "没有需要审查的文件";
131
131
  }
132
132
 
133
- const issuesByFile = new Map<string, { resolved: number; unresolved: number }>();
133
+ const issuesByFile = new Map<string, { resolved: number; errors: number; warns: number }>();
134
134
  for (const issue of issues) {
135
135
  if (issue.valid === "false") continue;
136
- const stats = issuesByFile.get(issue.file) || { resolved: 0, unresolved: 0 };
136
+ const stats = issuesByFile.get(issue.file) || { resolved: 0, errors: 0, warns: 0 };
137
137
  if (issue.fixed) {
138
138
  stats.resolved++;
139
+ } else if (issue.severity === "error") {
140
+ stats.errors++;
139
141
  } else {
140
- stats.unresolved++;
142
+ stats.warns++;
141
143
  }
142
144
  issuesByFile.set(issue.file, stats);
143
145
  }
144
146
 
145
147
  const lines: string[] = [];
146
- lines.push("| 文件 | 🟢 | 🔴 | 总结 |");
147
- lines.push("|------|----|----|------|");
148
+ lines.push("| 文件 | 🟢 | 🔴 | 🟡 | 总结 |");
149
+ lines.push("|------|----|----|----|----|");
148
150
 
149
151
  for (const fileSummary of summaries) {
150
- const stats = issuesByFile.get(fileSummary.file) || { resolved: 0, unresolved: 0 };
152
+ const stats = issuesByFile.get(fileSummary.file) || { resolved: 0, errors: 0, warns: 0 };
151
153
  const summaryText = fileSummary.summary
152
154
  .split("\n")
153
155
  .filter((line) => line.trim())
154
156
  .join("<br>");
155
157
  lines.push(
156
- `| \`${fileSummary.file}\` | ${stats.resolved} | ${stats.unresolved} | ${summaryText} |`,
158
+ `| \`${fileSummary.file}\` | ${stats.resolved} | ${stats.errors} | ${stats.warns} | ${summaryText} |`,
157
159
  );
158
160
  }
159
161
 
@@ -292,6 +294,7 @@ export class MarkdownFormatter implements ReviewReportFormatter, ReviewReportPar
292
294
  lines.push(`| ❌ 无效 | ${stats.invalid} |`);
293
295
  lines.push(`| ⚠️ 待处理 | ${stats.pending} |`);
294
296
  lines.push(`| 修复率 | ${stats.fixRate}% |`);
297
+ lines.push(`| 解决率 | ${stats.resolveRate}% |`);
295
298
  return lines.join("\n");
296
299
  }
297
300
  }
@@ -28,24 +28,26 @@ export class TerminalFormatter implements ReviewReportFormatter {
28
28
  return "没有需要审查的文件";
29
29
  }
30
30
 
31
- const issuesByFile = new Map<string, { resolved: number; unresolved: number }>();
31
+ const issuesByFile = new Map<string, { resolved: number; errors: number; warns: number }>();
32
32
  for (const issue of issues) {
33
- const stats = issuesByFile.get(issue.file) || { resolved: 0, unresolved: 0 };
33
+ const stats = issuesByFile.get(issue.file) || { resolved: 0, errors: 0, warns: 0 };
34
34
  if (issue.fixed) {
35
35
  stats.resolved++;
36
+ } else if (issue.severity === "error") {
37
+ stats.errors++;
36
38
  } else {
37
- stats.unresolved++;
39
+ stats.warns++;
38
40
  }
39
41
  issuesByFile.set(issue.file, stats);
40
42
  }
41
43
 
42
44
  const lines: string[] = [];
43
45
  for (const fileSummary of summaries) {
44
- const stats = issuesByFile.get(fileSummary.file) || { resolved: 0, unresolved: 0 };
46
+ const stats = issuesByFile.get(fileSummary.file) || { resolved: 0, errors: 0, warns: 0 };
45
47
  const resolvedText = stats.resolved > 0 ? `${GREEN}✅ ${stats.resolved} 已解决${RESET}` : "";
46
- const unresolvedText =
47
- stats.unresolved > 0 ? `${YELLOW} ${stats.unresolved} 未解决${RESET}` : "";
48
- const statsText = [resolvedText, unresolvedText].filter(Boolean).join(" / ");
48
+ const errorText = stats.errors > 0 ? `${RED}🔴 ${stats.errors} error${RESET}` : "";
49
+ const warnText = stats.warns > 0 ? `${YELLOW}🟡 ${stats.warns} warn${RESET}` : "";
50
+ const statsText = [resolvedText, errorText, warnText].filter(Boolean).join(" / ");
49
51
 
50
52
  if (statsText) {
51
53
  lines.push(`${BOLD}${fileSummary.file}${RESET} (${statsText}): ${fileSummary.summary}`);
@@ -126,6 +128,7 @@ export class TerminalFormatter implements ReviewReportFormatter {
126
128
  lines.push(` ${RED}❌ 无效: ${stats.invalid}${RESET}`);
127
129
  lines.push(` ${YELLOW}⚠️ 待处理: ${stats.pending}${RESET}`);
128
130
  lines.push(` 修复率: ${stats.fixRate}%`);
131
+ lines.push(` 解决率: ${stats.resolveRate}%`);
129
132
  return lines.join("\n");
130
133
  }
131
134
  }
@@ -125,8 +125,10 @@ export interface ReviewStats {
125
125
  invalid: number;
126
126
  /** 待处理数 */
127
127
  pending: number;
128
- /** 修复率 (0-100) */
128
+ /** 修复率 (0-100),仅计算代码修复:fixed / total */
129
129
  fixRate: number;
130
+ /** 解决率 (0-100),计算修复+解决:(fixed + resolved) / total */
131
+ resolveRate: number;
130
132
  }
131
133
 
132
134
  export interface ReviewResult {
@@ -409,30 +409,7 @@ export class ReviewService {
409
409
  return this.executeCollectOnly(context);
410
410
  }
411
411
 
412
- if (shouldLog(verbose, 1)) {
413
- console.log(`📂 解析规则来源: ${specSources.length} 个`);
414
- }
415
- const specDirs = await this.reviewSpecService.resolveSpecSources(specSources);
416
- if (shouldLog(verbose, 2)) {
417
- console.log(` 解析到 ${specDirs.length} 个规则目录`, specDirs);
418
- }
419
-
420
- let specs: ReviewSpec[] = [];
421
- for (const specDir of specDirs) {
422
- const dirSpecs = await this.reviewSpecService.loadReviewSpecs(specDir);
423
- specs.push(...dirSpecs);
424
- }
425
- if (shouldLog(verbose, 1)) {
426
- console.log(` 找到 ${specs.length} 个规则文件`);
427
- }
428
-
429
- // 去重规则:后加载的覆盖先加载的
430
- const beforeDedup = specs.reduce((sum, s) => sum + s.rules.length, 0);
431
- specs = this.reviewSpecService.deduplicateSpecs(specs);
432
- const afterDedup = specs.reduce((sum, s) => sum + s.rules.length, 0);
433
- if (beforeDedup !== afterDedup && shouldLog(verbose, 1)) {
434
- console.log(` 去重规则: ${beforeDedup} -> ${afterDedup} 条`);
435
- }
412
+ const specs = await this.loadSpecs(specSources, verbose);
436
413
 
437
414
  let pr: PullRequest | undefined;
438
415
  let commits: PullRequestCommit[] = [];
@@ -719,19 +696,12 @@ export class ReviewService {
719
696
 
720
697
  // 验证历史问题是否已修复
721
698
  if (context.verifyFixes) {
722
- const unfixedExistingIssues = existingIssues.filter(
723
- (i) => i.valid !== "false" && !i.fixed,
699
+ existingIssues = await this.verifyAndUpdateIssues(
700
+ context,
701
+ existingIssues,
702
+ commits,
703
+ { specs, fileContents },
724
704
  );
725
- if (unfixedExistingIssues.length > 0 && llmMode) {
726
- existingIssues = await this.issueVerifyService.verifyIssueFixes(
727
- existingIssues,
728
- fileContents,
729
- specs,
730
- llmMode,
731
- verbose,
732
- context.verifyConcurrency,
733
- );
734
- }
735
705
  } else {
736
706
  if (shouldLog(verbose, 1)) {
737
707
  console.log(` ⏭️ 跳过历史问题验证 (verifyFixes=false)`);
@@ -873,14 +843,25 @@ export class ReviewService {
873
843
  // 4. 同步评论 reactions(👍/👎)
874
844
  await this.syncReactionsToIssues(owner, repo, prNumber, existingResult, verbose);
875
845
 
876
- // 5. 统计问题状态并设置到 result
846
+ // 5. LLM 验证历史问题是否已修复
847
+ try {
848
+ existingResult.issues = await this.verifyAndUpdateIssues(
849
+ context,
850
+ existingResult.issues,
851
+ commits,
852
+ );
853
+ } catch (error) {
854
+ console.warn("⚠️ LLM 验证修复状态失败,跳过:", error);
855
+ }
856
+
857
+ // 6. 统计问题状态并设置到 result
877
858
  const stats = this.calculateIssueStats(existingResult.issues);
878
859
  existingResult.stats = stats;
879
860
 
880
- // 6. 输出统计信息
861
+ // 7. 输出统计信息
881
862
  console.log(this.reviewReportService.formatStatsTerminal(stats, prNumber));
882
863
 
883
- // 7. 更新 PR 评论(如果不是 dry-run)
864
+ // 8. 更新 PR 评论(如果不是 dry-run)
884
865
  if (ci && !dryRun) {
885
866
  if (shouldLog(verbose, 1)) {
886
867
  console.log(`💬 更新 PR 评论...`);
@@ -894,6 +875,106 @@ export class ReviewService {
894
875
  return existingResult;
895
876
  }
896
877
 
878
+ /**
879
+ * 加载并去重审查规则
880
+ */
881
+ protected async loadSpecs(specSources: string[], verbose?: VerboseLevel): Promise<ReviewSpec[]> {
882
+ if (shouldLog(verbose, 1)) {
883
+ console.log(`📂 解析规则来源: ${specSources.length} 个`);
884
+ }
885
+ const specDirs = await this.reviewSpecService.resolveSpecSources(specSources);
886
+ if (shouldLog(verbose, 2)) {
887
+ console.log(` 解析到 ${specDirs.length} 个规则目录`, specDirs);
888
+ }
889
+
890
+ let specs: ReviewSpec[] = [];
891
+ for (const specDir of specDirs) {
892
+ const dirSpecs = await this.reviewSpecService.loadReviewSpecs(specDir);
893
+ specs.push(...dirSpecs);
894
+ }
895
+ if (shouldLog(verbose, 1)) {
896
+ console.log(` 找到 ${specs.length} 个规则文件`);
897
+ }
898
+
899
+ const beforeDedup = specs.reduce((sum, s) => sum + s.rules.length, 0);
900
+ specs = this.reviewSpecService.deduplicateSpecs(specs);
901
+ const afterDedup = specs.reduce((sum, s) => sum + s.rules.length, 0);
902
+ if (beforeDedup !== afterDedup && shouldLog(verbose, 1)) {
903
+ console.log(` 去重规则: ${beforeDedup} -> ${afterDedup} 条`);
904
+ }
905
+
906
+ return specs;
907
+ }
908
+
909
+ /**
910
+ * LLM 验证历史问题是否已修复
911
+ * 如果传入 preloaded(specs/fileContents),直接使用;否则从 PR 获取
912
+ */
913
+ protected async verifyAndUpdateIssues(
914
+ context: ReviewContext,
915
+ issues: ReviewIssue[],
916
+ commits: PullRequestCommit[],
917
+ preloaded?: { specs: ReviewSpec[]; fileContents: FileContentsMap },
918
+ ): Promise<ReviewIssue[]> {
919
+ const { owner, repo, prNumber, llmMode, specSources, verbose } = context;
920
+ const unfixedIssues = issues.filter(
921
+ (i) => i.valid !== "false" && !i.fixed,
922
+ );
923
+
924
+ if (unfixedIssues.length === 0) {
925
+ return issues;
926
+ }
927
+
928
+ if (!llmMode) {
929
+ if (shouldLog(verbose, 1)) {
930
+ console.log(` ⏭️ 跳过 LLM 验证(缺少 llmMode)`);
931
+ }
932
+ return issues;
933
+ }
934
+
935
+ if (!preloaded && (!specSources?.length || !prNumber)) {
936
+ if (shouldLog(verbose, 1)) {
937
+ console.log(` ⏭️ 跳过 LLM 验证(缺少 specSources 或 prNumber)`);
938
+ }
939
+ return issues;
940
+ }
941
+
942
+ if (shouldLog(verbose, 1)) {
943
+ console.log(`\n🔍 开始 LLM 验证 ${unfixedIssues.length} 个未修复问题...`);
944
+ }
945
+
946
+ let specs: ReviewSpec[];
947
+ let fileContents: FileContentsMap;
948
+
949
+ if (preloaded) {
950
+ specs = preloaded.specs;
951
+ fileContents = preloaded.fileContents;
952
+ } else {
953
+ const pr = await this.gitProvider.getPullRequest(owner, repo, prNumber!);
954
+ const changedFiles = await this.gitProvider.getPullRequestFiles(owner, repo, prNumber!);
955
+ const headSha = pr?.head?.sha || "HEAD";
956
+ specs = await this.loadSpecs(specSources, verbose);
957
+ fileContents = await this.getFileContents(
958
+ owner,
959
+ repo,
960
+ changedFiles,
961
+ commits,
962
+ headSha,
963
+ prNumber!,
964
+ verbose,
965
+ );
966
+ }
967
+
968
+ return this.issueVerifyService.verifyIssueFixes(
969
+ issues,
970
+ fileContents,
971
+ specs,
972
+ llmMode,
973
+ verbose,
974
+ context.verifyConcurrency,
975
+ );
976
+ }
977
+
897
978
  /**
898
979
  * 计算问题状态统计
899
980
  */
@@ -904,7 +985,8 @@ export class ReviewService {
904
985
  const invalid = issues.filter((i) => i.valid === "false").length;
905
986
  const pending = total - fixed - resolved - invalid;
906
987
  const fixRate = total > 0 ? Math.round((fixed / total) * 100 * 10) / 10 : 0;
907
- return { total, fixed, resolved, invalid, pending, fixRate };
988
+ const resolveRate = total > 0 ? Math.round(((fixed + resolved) / total) * 100 * 10) / 10 : 0;
989
+ return { total, fixed, resolved, invalid, pending, fixRate, resolveRate };
908
990
  }
909
991
 
910
992
  /**
@@ -1949,37 +2031,52 @@ ${fileChanges || "无"}`;
1949
2031
  .map((issue) => this.issueToReviewComment(issue))
1950
2032
  .filter((comment): comment is CreatePullReviewComment => comment !== null);
1951
2033
  }
1952
- if (comments.length > 0) {
2034
+ if (reviewConf.lineComments) {
1953
2035
  const reviewBody = this.buildLineReviewBody(lineIssues, result.round, result.issues);
1954
- try {
1955
- await this.gitProvider.createPullReview(owner, repo, prNumber, {
1956
- event: REVIEW_STATE.COMMENT,
1957
- body: reviewBody,
1958
- comments,
1959
- commit_id: commitId,
1960
- });
1961
- console.log(`✅ 已发布 ${comments.length} 条行级评论`);
1962
- } catch {
1963
- // 批量失败时逐条发布,跳过无法定位的评论
1964
- console.warn("⚠️ 批量发布行级评论失败,尝试逐条发布...");
1965
- let successCount = 0;
1966
- for (const comment of comments) {
1967
- try {
1968
- await this.gitProvider.createPullReview(owner, repo, prNumber, {
1969
- event: REVIEW_STATE.COMMENT,
1970
- body: successCount === 0 ? reviewBody : undefined,
1971
- comments: [comment],
1972
- commit_id: commitId,
1973
- });
1974
- successCount++;
1975
- } catch {
1976
- console.warn(`⚠️ 跳过无法定位的评论: ${comment.path}:${comment.new_position}`);
2036
+ if (comments.length > 0) {
2037
+ try {
2038
+ await this.gitProvider.createPullReview(owner, repo, prNumber, {
2039
+ event: REVIEW_STATE.COMMENT,
2040
+ body: reviewBody,
2041
+ comments,
2042
+ commit_id: commitId,
2043
+ });
2044
+ console.log(`✅ 已发布 ${comments.length} 条行级评论`);
2045
+ } catch {
2046
+ // 批量失败时逐条发布,跳过无法定位的评论
2047
+ console.warn("⚠️ 批量发布行级评论失败,尝试逐条发布...");
2048
+ let successCount = 0;
2049
+ for (const comment of comments) {
2050
+ try {
2051
+ await this.gitProvider.createPullReview(owner, repo, prNumber, {
2052
+ event: REVIEW_STATE.COMMENT,
2053
+ body: successCount === 0 ? reviewBody : undefined,
2054
+ comments: [comment],
2055
+ commit_id: commitId,
2056
+ });
2057
+ successCount++;
2058
+ } catch {
2059
+ console.warn(`⚠️ 跳过无法定位的评论: ${comment.path}:${comment.new_position}`);
2060
+ }
2061
+ }
2062
+ if (successCount > 0) {
2063
+ console.log(`✅ 逐条发布成功 ${successCount}/${comments.length} 条行级评论`);
2064
+ } else {
2065
+ console.warn("⚠️ 所有行级评论均无法定位,已跳过");
1977
2066
  }
1978
2067
  }
1979
- if (successCount > 0) {
1980
- console.log(`✅ 逐条发布成功 ${successCount}/${comments.length} 条行级评论`);
1981
- } else {
1982
- console.warn("⚠️ 所有行级评论均无法定位,已跳过");
2068
+ } else {
2069
+ // 本轮无新问题,仍发布 Round 状态(含上轮回顾)
2070
+ try {
2071
+ await this.gitProvider.createPullReview(owner, repo, prNumber, {
2072
+ event: REVIEW_STATE.COMMENT,
2073
+ body: reviewBody,
2074
+ comments: [],
2075
+ commit_id: commitId,
2076
+ });
2077
+ console.log(`✅ 已发布 Round ${result.round} 审查状态(无新问题)`);
2078
+ } catch (error) {
2079
+ console.warn("⚠️ 发布审查状态失败:", error);
1983
2080
  }
1984
2081
  }
1985
2082
  }
@@ -2419,8 +2516,12 @@ ${fileChanges || "无"}`;
2419
2516
  if (warnCount > 0) badges.push(`🟡 ${warnCount}`);
2420
2517
 
2421
2518
  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(" ") : ""}`);
2519
+ parts.push(`### 🚀 Spaceflow Review · Round ${round}`);
2520
+ if (issues.length === 0) {
2521
+ parts.push(`> ✅ 未发现新问题`);
2522
+ } else {
2523
+ parts.push(`> **${issues.length}** 个新问题 · **${fileCount}** 个文件${badges.length > 0 ? " · " + badges.join(" ") : ""}`);
2524
+ }
2424
2525
 
2425
2526
  // 上轮回顾
2426
2527
  if (round > 1) {