@spaceflow/review 0.80.0 → 0.82.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/src/review-llm.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  PullRequestCommit,
3
- ChangedFile,
4
3
  type LLMMode,
5
4
  LlmProxyService,
6
5
  logStreamEvent,
@@ -23,6 +22,7 @@ import { dirname, extname } from "path";
23
22
  import micromatch from "micromatch";
24
23
  import type { FileReviewPrompt, ReviewPrompt, LLMReviewOptions } from "./types/review-llm";
25
24
  import { buildLinesWithNumbers, buildCommitsSection, extractCodeBlocks } from "./utils/review-llm";
25
+ import { ChangedFileCollection } from "./changed-file-collection";
26
26
  import { extractCodeBlockTypes, extractGlobsFromIncludes } from "./review-includes-filter";
27
27
  import {
28
28
  REVIEW_SCHEMA,
@@ -89,7 +89,7 @@ export class ReviewLlmProcessor {
89
89
 
90
90
  async buildReviewPrompt(
91
91
  specs: ReviewSpec[],
92
- changedFiles: ChangedFile[],
92
+ changedFiles: ChangedFileCollection,
93
93
  fileContents: FileContentsMap,
94
94
  commits: PullRequestCommit[],
95
95
  existingResult?: ReviewResult | null,
@@ -99,25 +99,23 @@ export class ReviewLlmProcessor {
99
99
  ): Promise<ReviewPrompt> {
100
100
  const round = (existingResult?.round ?? 0) + 1;
101
101
  const { staticIssues, skippedFiles } = applyStaticRules(
102
- changedFiles,
102
+ changedFiles.toArray(),
103
103
  fileContents,
104
104
  systemRules,
105
105
  round,
106
106
  verbose,
107
107
  );
108
108
 
109
- const fileDataList = changedFiles
110
- .filter((f) => f.status !== "deleted" && f.filename)
111
- .map((file) => {
112
- const filename = file.filename!;
113
- if (skippedFiles.has(filename)) return null;
114
- const contentLines = fileContents.get(filename);
115
- if (!contentLines) {
116
- return { filename, file, contentLines: null, commitsSection: "- 无相关 commits" };
117
- }
118
- const commitsSection = buildCommitsSection(contentLines, commits);
119
- return { filename, file, contentLines, commitsSection };
120
- });
109
+ const fileDataList = changedFiles.nonDeletedFiles().map((file) => {
110
+ const filename = file.filename!;
111
+ if (skippedFiles.has(filename)) return null;
112
+ const contentLines = fileContents.get(filename);
113
+ if (!contentLines) {
114
+ return { filename, file, contentLines: null, commitsSection: "- 无相关 commits" };
115
+ }
116
+ const commitsSection = buildCommitsSection(contentLines, commits);
117
+ return { filename, file, contentLines, commitsSection };
118
+ });
121
119
 
122
120
  const filePrompts: (FileReviewPrompt | null)[] = await Promise.all(
123
121
  fileDataList
@@ -438,14 +436,14 @@ export class ReviewLlmProcessor {
438
436
  */
439
437
  async generatePrDescription(
440
438
  commits: PullRequestCommit[],
441
- changedFiles: ChangedFile[],
439
+ changedFiles: ChangedFileCollection,
442
440
  llmMode: LLMMode,
443
441
  fileContents?: FileContentsMap,
444
442
  verbose?: VerboseLevel,
445
443
  ): Promise<{ title: string; description: string }> {
446
444
  const { userPrompt } = buildPrDescriptionPrompt({
447
445
  commits,
448
- changedFiles,
446
+ changedFiles: changedFiles.toArray(),
449
447
  fileContents,
450
448
  });
451
449
 
@@ -479,9 +477,9 @@ export class ReviewLlmProcessor {
479
477
  */
480
478
  async generatePrTitle(
481
479
  commits: PullRequestCommit[],
482
- changedFiles: ChangedFile[],
480
+ changedFiles: ChangedFileCollection,
483
481
  ): Promise<string> {
484
- const { userPrompt } = buildPrTitlePrompt({ commits, changedFiles });
482
+ const { userPrompt } = buildPrTitlePrompt({ commits, changedFiles: changedFiles.toArray() });
485
483
 
486
484
  try {
487
485
  const stream = this.llmProxyService.chatStream([{ role: "user", content: userPrompt }], {
@@ -514,7 +512,7 @@ export class ReviewLlmProcessor {
514
512
  */
515
513
  async buildBasicDescription(
516
514
  commits: PullRequestCommit[],
517
- changedFiles: ChangedFile[],
515
+ changedFiles: ChangedFileCollection,
518
516
  ): Promise<{ title: string; description: string }> {
519
517
  const parts: string[] = [];
520
518
  // 使用 LLM 生成标题
@@ -529,9 +527,7 @@ export class ReviewLlmProcessor {
529
527
  }
530
528
  }
531
529
  if (changedFiles.length > 0) {
532
- const added = changedFiles.filter((f) => f.status === "added").length;
533
- const modified = changedFiles.filter((f) => f.status === "modified").length;
534
- const deleted = changedFiles.filter((f) => f.status === "deleted").length;
530
+ const { added, modified, deleted } = changedFiles.countByStatus();
535
531
  const stats: string[] = [];
536
532
  if (added > 0) stats.push(`新增 ${added}`);
537
533
  if (modified > 0) stats.push(`修改 ${modified}`);
@@ -348,13 +348,12 @@ export class MarkdownFormatter implements ReviewReportFormatter, ReviewReportPar
348
348
  formatStats(stats: ReviewStats, prNumber?: number): string {
349
349
  const title = prNumber ? `PR #${prNumber} Review 状态统计` : "Review 状态统计";
350
350
  const lines = [`## 📊 ${title}\n`, `| 指标 | 数量 |`, `|------|------|`];
351
- lines.push(`| 总问题数 | ${stats.total} |`);
352
- lines.push(`| 🟢 已修复 | ${stats.fixed} |`);
353
- lines.push(`| ⚪ 已解决 | ${stats.resolved} |`);
354
- lines.push(`| ❌ 无效 | ${stats.invalid} |`);
355
- lines.push(`| ⚠️ 待处理 | ${stats.pending} |`);
356
- lines.push(`| 修复率 | ${stats.fixRate}% |`);
357
- lines.push(`| 解决率 | ${stats.resolveRate}% |`);
351
+ lines.push(
352
+ `| 有效问题 | ${stats.validTotal} (🟢已验收 ${stats.fixed}, ⚪已解决 ${stats.resolved}, ⚠️待处理 ${stats.pending}) |`,
353
+ );
354
+ lines.push(`| ❌ 无效问题 | ${stats.invalid} |`);
355
+ lines.push(`| 验收率 | ${stats.fixRate}% (${stats.fixed}/${stats.validTotal}) |`);
356
+ lines.push(`| 解决率 | ${stats.resolveRate}% (${stats.resolved}/${stats.validTotal}) |`);
358
357
  return lines.join("\n");
359
358
  }
360
359
  }
@@ -391,7 +391,7 @@ describe("ReviewResultModel", () => {
391
391
  const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
392
392
  const issues = [{ file: "a.ts", line: "1" }] as any[];
393
393
  const model = ReviewResultModel.create(createPr(gitProvider), createResult({ issues }), deps);
394
- await model.invalidateChangedFiles(undefined, 1);
394
+ await model.invalidateChangedFiles(undefined, undefined, 1);
395
395
  expect(issues[0].valid).toBeUndefined();
396
396
  expect(consoleSpy).toHaveBeenCalledWith(" ⚠️ 无法获取 PR head SHA,跳过变更文件检查");
397
397
  consoleSpy.mockRestore();
@@ -402,7 +402,7 @@ describe("ReviewResultModel", () => {
402
402
  gitProvider.getCommitDiff.mockResolvedValue("");
403
403
  const issues = [{ file: "a.ts", line: "1" }] as any[];
404
404
  const model = ReviewResultModel.create(createPr(gitProvider), createResult({ issues }), deps);
405
- await model.invalidateChangedFiles("abc123", 1);
405
+ await model.invalidateChangedFiles("abc123", undefined, 1);
406
406
  expect(issues[0].valid).toBeUndefined();
407
407
  consoleSpy.mockRestore();
408
408
  });
@@ -417,7 +417,7 @@ describe("ReviewResultModel", () => {
417
417
  { file: "unchanged.ts", line: "2", ruleId: "R2" },
418
418
  ] as any[];
419
419
  const model = ReviewResultModel.create(createPr(gitProvider), createResult({ issues }), deps);
420
- await model.invalidateChangedFiles("abc123", 1);
420
+ await model.invalidateChangedFiles("abc123", undefined, 1);
421
421
  expect(model.issues[0].valid).toBe("false");
422
422
  expect(model.issues[1].valid).toBeUndefined();
423
423
  consoleSpy.mockRestore();
@@ -445,10 +445,41 @@ describe("ReviewResultModel", () => {
445
445
  gitProvider.getCommitDiff.mockRejectedValue(new Error("diff fail"));
446
446
  const issues = [{ file: "a.ts", line: "1" }] as any[];
447
447
  const model = ReviewResultModel.create(createPr(gitProvider), createResult({ issues }), deps);
448
- await model.invalidateChangedFiles("abc123", 1);
448
+ await model.invalidateChangedFiles("abc123", undefined, 1);
449
449
  expect(consoleSpy).toHaveBeenCalled();
450
450
  consoleSpy.mockRestore();
451
451
  });
452
+
453
+ it("should preserve resolved issue when current code differs from issue.code", async () => {
454
+ gitProvider.getCommitDiff.mockResolvedValue(
455
+ "diff --git a/changed.ts b/changed.ts\n--- a/changed.ts\n+++ b/changed.ts\n@@ -1,1 +1,2 @@\n line1\n+new",
456
+ );
457
+ const issues = [
458
+ { file: "changed.ts", line: "1", ruleId: "R1", resolved: "2024-01-01", code: "old code" },
459
+ ] as any[];
460
+ const model = ReviewResultModel.create(createPr(gitProvider), createResult({ issues }), deps);
461
+ const fileContents: Map<string, [string, string][]> = new Map([
462
+ ["changed.ts", [["abc1234", "new code"]]],
463
+ ]);
464
+ await model.invalidateChangedFiles("abc123", fileContents);
465
+ expect(model.issues[0].valid).toBeUndefined();
466
+ expect(model.issues[0].resolved).toBe("2024-01-01");
467
+ });
468
+
469
+ it("should invalidate resolved issue when current code matches issue.code", async () => {
470
+ gitProvider.getCommitDiff.mockResolvedValue(
471
+ "diff --git a/changed.ts b/changed.ts\n--- a/changed.ts\n+++ b/changed.ts\n@@ -1,1 +1,2 @@\n old code\n+new",
472
+ );
473
+ const issues = [
474
+ { file: "changed.ts", line: "1", ruleId: "R1", resolved: "2024-01-01", code: "old code" },
475
+ ] as any[];
476
+ const model = ReviewResultModel.create(createPr(gitProvider), createResult({ issues }), deps);
477
+ const fileContents: Map<string, [string, string][]> = new Map([
478
+ ["changed.ts", [["abc1234", "old code"]]],
479
+ ]);
480
+ await model.invalidateChangedFiles("abc123", fileContents);
481
+ expect(model.issues[0].valid).toBe("false");
482
+ });
452
483
  });
453
484
 
454
485
  // ─── deleteOldReviews ─────────────────────────────────
@@ -9,7 +9,13 @@ import {
9
9
  import type { IConfigReader } from "@spaceflow/core";
10
10
  import { PullRequestModel } from "./pull-request-model";
11
11
  import { type ReviewConfig } from "./review.config";
12
- import { ReviewSpecService, ReviewIssue, ReviewResult, ReviewStats } from "./review-spec";
12
+ import {
13
+ ReviewSpecService,
14
+ ReviewIssue,
15
+ ReviewResult,
16
+ ReviewStats,
17
+ FileContentsMap,
18
+ } from "./review-spec";
13
19
  import { ReviewReportService, type ReportFormat } from "./review-report";
14
20
  import { extname } from "path";
15
21
  import {
@@ -143,11 +149,16 @@ export class ReviewResultModel {
143
149
  * - 合并历史 issues(this.issues)+ newIssues
144
150
  * - 复制 newResult 的元信息(title/description/deletionImpact 等)
145
151
  *
146
- * 调用方应在调用前完成对历史 issues 的预处理(syncResolved、invalidateChangedFiles、verifyFixes、去重等)。
152
+ * 调用方应在调用前完成对历史 issues 的预处理(syncResolved、invalidateChangedFiles、verifyFixes 等)。
147
153
  */
148
154
  nextRound(newResult: ReviewResult): ReviewResultModel {
149
155
  const nextRoundNum = this._result.round + 1;
150
- const taggedNewIssues = newResult.issues.map((issue) => ({ ...issue, round: nextRoundNum }));
156
+
157
+ // 去重:过滤掉已存在于历史 issues 中的新问题(含 valid:false 的都参与去重)
158
+ const existingKeys = new Set(this._result.issues.map((i) => generateIssueKey(i)));
159
+ const dedupedNewIssues = newResult.issues.filter((i) => !existingKeys.has(generateIssueKey(i)));
160
+
161
+ const taggedNewIssues = dedupedNewIssues.map((issue) => ({ ...issue, round: nextRoundNum }));
151
162
  const mergedResult: ReviewResult = {
152
163
  ...newResult,
153
164
  round: nextRoundNum,
@@ -415,9 +426,14 @@ export class ReviewResultModel {
415
426
 
416
427
  /**
417
428
  * 将有变更文件的历史 issue 标记为无效。
418
- * 简化策略:如果文件在最新 commit 中有变更,则将该文件的所有历史问题标记为无效。
429
+ * 策略:如果文件在最新 commit 中有变更,则将该文件的历史问题标记为无效,但以下情况保留:
430
+ * - issue 已被用户手动 resolved 且当前代码行内容与 issue.code 不同(说明用户 resolve 后代码已变,应保留其 resolve 状态)
419
431
  */
420
- async invalidateChangedFiles(headSha: string | undefined, verbose?: VerboseLevel): Promise<void> {
432
+ async invalidateChangedFiles(
433
+ headSha: string | undefined,
434
+ fileContents?: FileContentsMap,
435
+ verbose?: VerboseLevel,
436
+ ): Promise<void> {
421
437
  if (!headSha) {
422
438
  if (shouldLog(verbose, 1)) {
423
439
  console.log(` ⚠️ 无法获取 PR head SHA,跳过变更文件检查`);
@@ -449,14 +465,43 @@ export class ReviewResultModel {
449
465
 
450
466
  // 将变更文件的历史 issue 标记为无效
451
467
  let invalidatedCount = 0;
468
+ let preservedCount = 0;
452
469
  this._result.issues = this._result.issues.map((issue) => {
453
- // 如果 issue 已修复、已解决或已无效,不需要处理
454
- if (issue.fixed || issue.resolved || issue.valid === "false") {
470
+ // 如果 issue 已修复或已无效,不需要处理
471
+ if (issue.fixed || issue.valid === "false") {
455
472
  return issue;
456
473
  }
457
474
 
458
- // 如果 issue 所在文件有变更,标记为无效
475
+ // 如果 issue 所在文件有变更
459
476
  if (changedFileSet.has(issue.file)) {
477
+ // 已 resolved 的 issue:检查当前代码行是否与 issue.code 不同
478
+ // 不同说明用户 resolve 后代码确实变了,保留其 resolve 状态
479
+ if (issue.resolved && issue.code && fileContents) {
480
+ const contentLines = fileContents.get(issue.file);
481
+ if (contentLines) {
482
+ const lineNums = issue.line
483
+ .split("-")
484
+ .map(Number)
485
+ .filter((n) => !isNaN(n));
486
+ const startLine = lineNums[0];
487
+ const endLine = lineNums[lineNums.length - 1];
488
+ const currentCode = contentLines
489
+ .slice(startLine - 1, endLine)
490
+ .map(([, line]) => line)
491
+ .join("\n")
492
+ .trim();
493
+ if (currentCode !== issue.code) {
494
+ preservedCount++;
495
+ if (shouldLog(verbose, 1)) {
496
+ console.log(
497
+ ` ✅ Issue ${issue.file}:${issue.line} 已 resolved 且代码已变更,保留`,
498
+ );
499
+ }
500
+ return issue;
501
+ }
502
+ }
503
+ }
504
+
460
505
  invalidatedCount++;
461
506
  if (shouldLog(verbose, 1)) {
462
507
  console.log(` 🗑️ Issue ${issue.file}:${issue.line} 所在文件有变更,标记为无效`);
@@ -467,8 +512,11 @@ export class ReviewResultModel {
467
512
  return issue;
468
513
  });
469
514
 
470
- if (invalidatedCount > 0 && shouldLog(verbose, 1)) {
471
- console.log(` 📊 共标记 ${invalidatedCount} 个历史问题为无效(文件有变更)`);
515
+ if ((invalidatedCount > 0 || preservedCount > 0) && shouldLog(verbose, 1)) {
516
+ const parts: string[] = [];
517
+ if (invalidatedCount > 0) parts.push(`标记 ${invalidatedCount} 个无效`);
518
+ if (preservedCount > 0) parts.push(`保留 ${preservedCount} 个已 resolved`);
519
+ console.log(` 📊 Issue 处理: ${parts.join(",")}`);
472
520
  }
473
521
  } catch (error) {
474
522
  if (shouldLog(verbose, 1)) {