@spaceflow/review 0.77.0 → 0.79.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.
@@ -127,6 +127,10 @@ describe("ReviewService", () => {
127
127
  parseDiffText: vi.fn().mockReturnValue([]),
128
128
  getRemoteUrl: vi.fn().mockReturnValue(null),
129
129
  parseRepositoryFromRemoteUrl: vi.fn().mockReturnValue(null),
130
+ getUncommittedFiles: vi.fn().mockReturnValue([]),
131
+ getStagedFiles: vi.fn().mockReturnValue([]),
132
+ getUncommittedDiff: vi.fn().mockReturnValue([]),
133
+ getStagedDiff: vi.fn().mockReturnValue([]),
130
134
  getChangedFilesBetweenRefs: vi.fn().mockResolvedValue([]),
131
135
  getCommitsBetweenRefs: vi.fn().mockResolvedValue([]),
132
136
  getDiffBetweenRefs: vi.fn().mockResolvedValue([]),
@@ -419,6 +423,26 @@ describe("ReviewService", () => {
419
423
  expect(result.success).toBe(true);
420
424
  });
421
425
 
426
+ it("should ignore whenModifiedCode in direct file mode", async () => {
427
+ const context: ReviewContext = {
428
+ owner: "owner",
429
+ repo: "repo",
430
+ files: ["src/app.ts"],
431
+ specSources: ["/spec/dir"],
432
+ dryRun: true,
433
+ ci: false,
434
+ llmMode: "openai",
435
+ whenModifiedCode: ["function", "class"],
436
+ };
437
+ const buildReviewPromptSpy = vi.spyOn(service as any, "buildReviewPrompt");
438
+
439
+ const result = await service.execute(context);
440
+
441
+ expect(result.success).toBe(true);
442
+ expect(buildReviewPromptSpy).toHaveBeenCalled();
443
+ expect(buildReviewPromptSpy.mock.calls[0][5]).toBeUndefined();
444
+ });
445
+
422
446
  it("should filter files by includes pattern", async () => {
423
447
  const context: ReviewContext = {
424
448
  owner: "owner",
@@ -1134,13 +1158,23 @@ describe("ReviewService", () => {
1134
1158
  expect(result[0].author.login).toBe("GitUser");
1135
1159
  });
1136
1160
 
1161
+ it("should mark invalid when existing author but ------- commit hash", async () => {
1162
+ const issues = [
1163
+ { file: "test.ts", line: "1", commit: "-------", author: { id: "1", login: "dev1" } },
1164
+ ];
1165
+ const result = await (service as any).fillIssueAuthors(issues, [], "o", "r");
1166
+ expect(result[0].commit).toBeUndefined();
1167
+ expect(result[0].valid).toBe("false");
1168
+ });
1169
+
1137
1170
  it("should handle issues with ------- commit hash", async () => {
1138
1171
  const issues = [{ file: "test.ts", line: "1", commit: "-------" }];
1139
1172
  const commits = [
1140
1173
  { sha: "abc1234567890", author: { id: 1, login: "dev1" }, commit: { author: {} } },
1141
1174
  ];
1142
1175
  const result = await (service as any).fillIssueAuthors(issues, commits, "o", "r");
1143
- expect(result[0].author.login).toBe("dev1");
1176
+ expect(result[0].commit).toBeUndefined();
1177
+ expect(result[0].valid).toBe("false");
1144
1178
  });
1145
1179
  });
1146
1180
 
@@ -1353,14 +1387,28 @@ describe("ReviewService", () => {
1353
1387
 
1354
1388
  it("should normalize absolute file paths", async () => {
1355
1389
  configService.get.mockReturnValue({ repository: "owner/repo", refName: "main" });
1390
+ const absPath = `${process.cwd()}/src/file.ts`;
1356
1391
  const options = {
1357
1392
  dryRun: false,
1358
1393
  ci: false,
1359
- files: ["/absolute/path/to/file.ts", "relative.ts"],
1394
+ files: [absPath, "./relative.ts"],
1360
1395
  };
1361
1396
  const context = await service.getContextFromEnv(options as any);
1362
1397
  expect(context.files).toBeDefined();
1363
- expect(context.files![1]).toBe("relative.ts");
1398
+ expect(context.files).toEqual(["src/file.ts", "relative.ts"]);
1399
+ });
1400
+
1401
+ it("should force direct file mode when files are specified", async () => {
1402
+ configService.get.mockReturnValue({ repository: "owner/repo", refName: "main" });
1403
+ const options = {
1404
+ dryRun: false,
1405
+ ci: false,
1406
+ local: "uncommitted" as const,
1407
+ files: ["./miniprogram/utils/asyncSharedUtilsLoader.js"],
1408
+ };
1409
+ const context = await service.getContextFromEnv(options as any);
1410
+ expect(context.localMode).toBe(false);
1411
+ expect(context.files).toEqual(["miniprogram/utils/asyncSharedUtilsLoader.js"]);
1364
1412
  });
1365
1413
 
1366
1414
  it("should auto-detect base/head with verbose logging", async () => {
@@ -1419,6 +1467,30 @@ describe("ReviewService", () => {
1419
1467
  });
1420
1468
  });
1421
1469
 
1470
+ describe("ReviewService.resolveSourceData - direct file mode", () => {
1471
+ it("should bypass local uncommitted scanning when files are specified", async () => {
1472
+ const context: ReviewContext = {
1473
+ owner: "o",
1474
+ repo: "r",
1475
+ dryRun: true,
1476
+ ci: false,
1477
+ specSources: ["/spec"],
1478
+ files: ["miniprogram/utils/asyncSharedUtilsLoader.js"],
1479
+ localMode: false,
1480
+ };
1481
+
1482
+ const result = await (service as any).resolveSourceData(context);
1483
+
1484
+ expect(result.isDirectFileMode).toBe(true);
1485
+ expect(result.isLocalMode).toBe(true);
1486
+ expect(result.changedFiles).toEqual([
1487
+ { filename: "miniprogram/utils/asyncSharedUtilsLoader.js", status: "modified" },
1488
+ ]);
1489
+ expect(mockGitSdkService.getUncommittedFiles).not.toHaveBeenCalled();
1490
+ expect(mockGitSdkService.getStagedFiles).not.toHaveBeenCalled();
1491
+ });
1492
+ });
1493
+
1422
1494
  describe("ReviewService.getFilesForCommit", () => {
1423
1495
  it("should return files from git sdk", async () => {
1424
1496
  mockGitSdkService.getFilesForCommit.mockResolvedValue([
@@ -23,6 +23,7 @@ import { filterFilesByIncludes, extractGlobsFromIncludes } from "./review-includ
23
23
  import { ReviewLlmProcessor } from "./review-llm";
24
24
  import { PullRequestModel } from "./pull-request-model";
25
25
  import { ReviewResultModel, type ReviewResultModelDeps } from "./review-result-model";
26
+ import { REVIEW_COMMENT_MARKER, REVIEW_LINE_COMMENTS_MARKER } from "./utils/review-pr-comment";
26
27
 
27
28
  export type { ReviewContext } from "./review-context";
28
29
  export type { FileReviewPrompt, ReviewPrompt, LLMReviewOptions } from "./review-llm";
@@ -96,10 +97,22 @@ export class ReviewService {
96
97
  if (source.earlyReturn) return source.earlyReturn;
97
98
 
98
99
  const { prModel, commits, changedFiles, headSha, isDirectFileMode } = source;
100
+ const effectiveWhenModifiedCode = isDirectFileMode ? undefined : context.whenModifiedCode;
101
+ if (isDirectFileMode && context.whenModifiedCode?.length && shouldLog(verbose, 1)) {
102
+ console.log(`ℹ️ 直接文件模式下忽略 whenModifiedCode 过滤`);
103
+ }
99
104
 
100
105
  // 2. 规则匹配
101
106
  const specs = await this.issueFilter.loadSpecs(specSources, verbose);
102
107
  const applicableSpecs = this.reviewSpecService.filterApplicableSpecs(specs, changedFiles);
108
+ if (shouldLog(verbose, 2)) {
109
+ console.log(
110
+ `[execute] loadSpecs: loaded ${specs.length} specs from sources: ${JSON.stringify(specSources)}`,
111
+ );
112
+ console.log(
113
+ `[execute] filterApplicableSpecs: ${applicableSpecs.length} applicable out of ${specs.length}, changedFiles=${JSON.stringify(changedFiles.map((f) => f.filename))}`,
114
+ );
115
+ }
103
116
  if (shouldLog(verbose, 1)) {
104
117
  console.log(` 适用的规则文件: ${applicableSpecs.length}`);
105
118
  }
@@ -138,6 +151,9 @@ export class ReviewService {
138
151
  fileContents,
139
152
  commits,
140
153
  existingResultModel?.result ?? null,
154
+ effectiveWhenModifiedCode,
155
+ verbose,
156
+ context.systemRules,
141
157
  );
142
158
  const result = await this.runLLMReview(llmMode, reviewPrompt, {
143
159
  verbose,
@@ -166,6 +182,14 @@ export class ReviewService {
166
182
  isDirectFileMode,
167
183
  context,
168
184
  });
185
+
186
+ // 静态规则产生的系统问题直接合并,不经过过滤管道
187
+ if (reviewPrompt.staticIssues?.length) {
188
+ result.issues = [...reviewPrompt.staticIssues, ...result.issues];
189
+ if (shouldLog(verbose, 1)) {
190
+ console.log(`⚙️ 追加 ${reviewPrompt.staticIssues.length} 个静态规则系统问题`);
191
+ }
192
+ }
169
193
  if (shouldLog(verbose, 1)) {
170
194
  console.log(`📝 最终发现 ${result.issues.length} 个问题`);
171
195
  }
@@ -211,10 +235,10 @@ export class ReviewService {
211
235
  files,
212
236
  commits: filterCommits,
213
237
  localMode,
214
- skipDuplicateWorkflow,
238
+ duplicateWorkflowResolved,
215
239
  } = context;
216
240
 
217
- const isDirectFileMode = !!(files && files.length > 0 && baseRef === headRef);
241
+ const isDirectFileMode = !!(files && files.length > 0 && !prNumber);
218
242
  let isLocalMode = !!localMode;
219
243
  let effectiveBaseRef = baseRef;
220
244
  let effectiveHeadRef = headRef;
@@ -274,8 +298,16 @@ export class ReviewService {
274
298
  }
275
299
  }
276
300
 
301
+ // 直接文件审查模式(-f):绕过 diff,直接按指定文件构造审查输入
302
+ if (isDirectFileMode) {
303
+ if (shouldLog(verbose, 1)) {
304
+ console.log(`📥 直接审查指定文件模式 (${files!.length} 个文件)`);
305
+ }
306
+ changedFiles = files!.map((f) => ({ filename: f, status: "modified" as const }));
307
+ isLocalMode = true;
308
+ }
277
309
  // PR 模式、分支比较模式、或本地模式回退后的分支比较
278
- if (prNumber) {
310
+ else if (prNumber) {
279
311
  if (shouldLog(verbose, 1)) {
280
312
  console.log(`📥 获取 PR #${prNumber} 信息 (owner: ${owner}, repo: ${repo})`);
281
313
  }
@@ -290,9 +322,14 @@ export class ReviewService {
290
322
  }
291
323
 
292
324
  // 检查是否有其他同名 review workflow 正在运行中
293
- if (skipDuplicateWorkflow && ci && prInfo?.head?.sha) {
294
- const skipResult = await this.checkDuplicateWorkflow(prModel, prInfo.head.sha, verbose);
295
- if (skipResult) {
325
+ if (duplicateWorkflowResolved !== "off" && ci && prInfo?.head?.sha) {
326
+ const duplicateResult = await this.checkDuplicateWorkflow(
327
+ prModel,
328
+ prInfo.head.sha,
329
+ duplicateWorkflowResolved,
330
+ verbose,
331
+ );
332
+ if (duplicateResult) {
296
333
  return {
297
334
  prModel,
298
335
  commits,
@@ -300,17 +337,12 @@ export class ReviewService {
300
337
  headSha: prInfo.head.sha,
301
338
  isLocalMode,
302
339
  isDirectFileMode,
303
- earlyReturn: skipResult,
340
+ earlyReturn: duplicateResult,
304
341
  };
305
342
  }
306
343
  }
307
344
  } else if (effectiveBaseRef && effectiveHeadRef) {
308
- if (files && files.length > 0 && effectiveBaseRef === effectiveHeadRef) {
309
- if (shouldLog(verbose, 1)) {
310
- console.log(`📥 直接审查指定文件模式 (${files.length} 个文件)`);
311
- }
312
- changedFiles = files.map((f) => ({ filename: f, status: "modified" as const }));
313
- } else if (changedFiles.length === 0) {
345
+ if (changedFiles.length === 0) {
314
346
  if (shouldLog(verbose, 1)) {
315
347
  console.log(
316
348
  `📥 获取 ${effectiveBaseRef}...${effectiveHeadRef} 的差异 (owner: ${owner}, repo: ${repo})`,
@@ -382,10 +414,20 @@ export class ReviewService {
382
414
  // 3. 使用 includes 过滤文件和 commits(支持 added|/modified|/deleted| 前缀语法)
383
415
  if (includes && includes.length > 0) {
384
416
  const beforeFiles = changedFiles.length;
417
+ if (shouldLog(verbose, 2)) {
418
+ console.log(
419
+ `[resolveSourceData] filterFilesByIncludes: before=${JSON.stringify(changedFiles.map((f) => ({ filename: f.filename, status: f.status })))}, includes=${JSON.stringify(includes)}`,
420
+ );
421
+ }
385
422
  changedFiles = filterFilesByIncludes(changedFiles, includes);
386
423
  if (shouldLog(verbose, 1)) {
387
424
  console.log(` Includes 过滤文件: ${beforeFiles} -> ${changedFiles.length} 个文件`);
388
425
  }
426
+ if (shouldLog(verbose, 2)) {
427
+ console.log(
428
+ `[resolveSourceData] filterFilesByIncludes: after=${JSON.stringify(changedFiles.map((f) => f.filename))}`,
429
+ );
430
+ }
389
431
 
390
432
  const globs = extractGlobsFromIncludes(includes);
391
433
  const beforeCommits = commits.length;
@@ -843,10 +885,12 @@ export class ReviewService {
843
885
 
844
886
  /**
845
887
  * 检查是否有其他同名 review workflow 正在运行中
888
+ * 根据 duplicateWorkflowResolved 配置决定是跳过还是删除旧评论
846
889
  */
847
890
  private async checkDuplicateWorkflow(
848
891
  prModel: PullRequestModel,
849
892
  headSha: string,
893
+ mode: "skip" | "delete",
850
894
  verbose?: VerboseLevel,
851
895
  ): Promise<ReviewResult | null> {
852
896
  const ref = process.env.GITHUB_REF || process.env.GITEA_REF || "";
@@ -866,6 +910,19 @@ export class ReviewService {
866
910
  (!currentRunId || String(w.id) !== currentRunId),
867
911
  );
868
912
  if (duplicateReviewRuns.length > 0) {
913
+ if (mode === "delete") {
914
+ // 删除模式:清理旧的 AI Review 评论和 PR Review
915
+ if (shouldLog(verbose, 1)) {
916
+ console.log(
917
+ `🗑️ 检测到 ${duplicateReviewRuns.length} 个同名 workflow,清理旧的 AI Review 评论...`,
918
+ );
919
+ }
920
+ await this.cleanupDuplicateAiReviews(prModel, verbose);
921
+ // 清理后继续执行当前审查
922
+ return null;
923
+ }
924
+
925
+ // 跳过模式(默认)
869
926
  if (shouldLog(verbose, 1)) {
870
927
  console.log(
871
928
  `⏭️ 跳过审查: 当前 PR #${currentPrNumber} 有 ${duplicateReviewRuns.length} 个同名 workflow 正在运行中`,
@@ -890,6 +947,56 @@ export class ReviewService {
890
947
  return null;
891
948
  }
892
949
 
950
+ /**
951
+ * 清理重复的 AI Review 评论(Issue Comments 和 PR Reviews)
952
+ */
953
+ private async cleanupDuplicateAiReviews(
954
+ prModel: PullRequestModel,
955
+ verbose?: VerboseLevel,
956
+ ): Promise<void> {
957
+ try {
958
+ // 删除 Issue Comments(主评论)
959
+ const comments = await prModel.getComments();
960
+ const aiComments = comments.filter((c) => c.body?.includes(REVIEW_COMMENT_MARKER));
961
+ let deletedComments = 0;
962
+ for (const comment of aiComments) {
963
+ if (comment.id) {
964
+ try {
965
+ await prModel.deleteComment(comment.id);
966
+ deletedComments++;
967
+ } catch {
968
+ // 忽略删除失败
969
+ }
970
+ }
971
+ }
972
+ if (deletedComments > 0 && shouldLog(verbose, 1)) {
973
+ console.log(` 已删除 ${deletedComments} 个重复的 AI Review 主评论`);
974
+ }
975
+
976
+ // 删除 PR Reviews(行级评论)
977
+ const reviews = await prModel.getReviews();
978
+ const aiReviews = reviews.filter((r) => r.body?.includes(REVIEW_LINE_COMMENTS_MARKER));
979
+ let deletedReviews = 0;
980
+ for (const review of aiReviews) {
981
+ if (review.id) {
982
+ try {
983
+ await prModel.deleteReview(review.id);
984
+ deletedReviews++;
985
+ } catch {
986
+ // 已提交的 review 无法删除,忽略
987
+ }
988
+ }
989
+ }
990
+ if (deletedReviews > 0 && shouldLog(verbose, 1)) {
991
+ console.log(` 已删除 ${deletedReviews} 个重复的 AI Review PR Review`);
992
+ }
993
+ } catch (error) {
994
+ if (shouldLog(verbose, 1)) {
995
+ console.warn(`⚠️ 清理旧评论失败:`, error instanceof Error ? error.message : error);
996
+ }
997
+ }
998
+ }
999
+
893
1000
  // --- Delegation methods for backward compatibility with tests ---
894
1001
 
895
1002
  protected async fillIssueAuthors(...args: Parameters<ReviewContextBuilder["fillIssueAuthors"]>) {
@@ -0,0 +1,48 @@
1
+ import type { ChangedFile, VerboseLevel } from "@spaceflow/core";
2
+ import type { ReviewIssue, FileContentsMap } from "../review-spec";
3
+ import type { SystemRules } from "../review.config";
4
+ import { checkMaxLinesPerFile } from "./max-lines-per-file";
5
+
6
+ export {
7
+ RULE_ID as SYSTEM_RULE_MAX_LINES,
8
+ SPEC_FILE as SYSTEM_SPEC_FILE,
9
+ } from "./max-lines-per-file";
10
+
11
+ export interface ApplyStaticRulesResult {
12
+ staticIssues: ReviewIssue[];
13
+ /** 被静态规则排除的文件名集合,不应再进入 LLM 审查 */
14
+ skippedFiles: Set<string>;
15
+ }
16
+
17
+ /**
18
+ * 对变更文件执行所有已启用的静态规则检查。
19
+ * 返回系统问题列表和需要跳过 LLM 审查的文件集合。
20
+ */
21
+ export function applyStaticRules(
22
+ changedFiles: ChangedFile[],
23
+ fileContents: FileContentsMap,
24
+ staticRules: SystemRules | undefined,
25
+ round: number,
26
+ verbose?: VerboseLevel,
27
+ ): ApplyStaticRulesResult {
28
+ const staticIssues: ReviewIssue[] = [];
29
+ const skippedFiles = new Set<string>();
30
+
31
+ if (!staticRules) {
32
+ return { staticIssues, skippedFiles };
33
+ }
34
+
35
+ if (staticRules.maxLinesPerFile) {
36
+ const result = checkMaxLinesPerFile(
37
+ changedFiles,
38
+ fileContents,
39
+ staticRules.maxLinesPerFile,
40
+ round,
41
+ verbose,
42
+ );
43
+ staticIssues.push(...result.staticIssues);
44
+ result.skippedFiles.forEach((f) => skippedFiles.add(f));
45
+ }
46
+
47
+ return { staticIssues, skippedFiles };
48
+ }
@@ -0,0 +1,57 @@
1
+ import type { ChangedFile, VerboseLevel } from "@spaceflow/core";
2
+ import { shouldLog } from "@spaceflow/core";
3
+ import type { ReviewIssue, FileContentsMap } from "../review-spec";
4
+ import type { Severity } from "../review.config";
5
+
6
+ export const RULE_ID = "system:max-lines-per-file";
7
+ export const SPEC_FILE = "__system__";
8
+
9
+ export interface MaxLinesPerFileResult {
10
+ staticIssues: ReviewIssue[];
11
+ /** 超限文件名集合,需从 LLM 审查中排除 */
12
+ skippedFiles: Set<string>;
13
+ }
14
+
15
+ export function checkMaxLinesPerFile(
16
+ changedFiles: ChangedFile[],
17
+ fileContents: FileContentsMap,
18
+ rule: [number, Severity],
19
+ round: number,
20
+ verbose?: VerboseLevel,
21
+ ): MaxLinesPerFileResult {
22
+ const [maxLine, severity] = rule;
23
+ const staticIssues: ReviewIssue[] = [];
24
+ const skippedFiles = new Set<string>();
25
+
26
+ if (maxLine <= 0) {
27
+ return { staticIssues, skippedFiles };
28
+ }
29
+
30
+ for (const file of changedFiles) {
31
+ if (file.status === "deleted" || !file.filename) continue;
32
+ const filename = file.filename;
33
+ const contentLines = fileContents.get(filename);
34
+ if (!contentLines || contentLines.length <= maxLine) continue;
35
+
36
+ if (shouldLog(verbose, 1)) {
37
+ console.log(
38
+ `⚠️ [system-rules/maxLinesPerFile] ${filename}: ${contentLines.length} 行超过限制 ${maxLine} 行,跳过 LLM 审查`,
39
+ );
40
+ }
41
+
42
+ skippedFiles.add(filename);
43
+ staticIssues.push({
44
+ file: filename,
45
+ line: "1",
46
+ code: "",
47
+ ruleId: RULE_ID,
48
+ specFile: SPEC_FILE,
49
+ reason: `文件共 ${contentLines.length} 行,超过静态规则限制 ${maxLine} 行,已跳过 LLM 审查。请考虑拆分文件或调大 staticRules.maxLinesPerFile 配置。`,
50
+ severity,
51
+ round,
52
+ date: new Date().toISOString(),
53
+ });
54
+ }
55
+
56
+ return { staticIssues, skippedFiles };
57
+ }
@@ -8,6 +8,8 @@ export interface FileReviewPrompt {
8
8
 
9
9
  export interface ReviewPrompt {
10
10
  filePrompts: FileReviewPrompt[];
11
+ /** 静态规则检查产生的系统问题,不经过 LLM 过滤管道,直接写入结果 */
12
+ staticIssues?: import("../review-spec").ReviewIssue[];
11
13
  }
12
14
 
13
15
  export interface LLMReviewOptions {