@spaceflow/review 0.81.0 → 0.83.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.
@@ -1,7 +1,6 @@
1
1
  import {
2
2
  GitProviderService,
3
3
  PullRequestCommit,
4
- ChangedFile,
5
4
  type LLMMode,
6
5
  LlmProxyService,
7
6
  type VerboseLevel,
@@ -10,29 +9,37 @@ import {
10
9
  } from "@spaceflow/core";
11
10
  import type { IConfigReader } from "@spaceflow/core";
12
11
  import { type ReviewConfig } from "./review.config";
13
- import { ReviewSpecService, ReviewResult, FileSummary } from "./review-spec";
12
+ import {
13
+ ReviewSpecService,
14
+ ReviewResult,
15
+ FileSummary,
16
+ ReviewSpec,
17
+ FileContentsMap,
18
+ } from "./review-spec";
19
+ import { ChangedFileCollection } from "./changed-file-collection";
14
20
  import { MarkdownFormatter, ReviewReportService } from "./review-report";
15
- import micromatch from "micromatch";
16
21
  import { ReviewOptions } from "./review.config";
17
22
  import { IssueVerifyService } from "./issue-verify.service";
18
23
  import { DeletionImpactService } from "./deletion-impact.service";
19
24
  import { execSync } from "child_process";
20
25
  import { ReviewContextBuilder, type ReviewContext } from "./review-context";
21
26
  import { ReviewIssueFilter } from "./review-issue-filter";
22
- import { filterFilesByIncludes, extractGlobsFromIncludes } from "./review-includes-filter";
27
+ import { filterFilesByIncludes } from "./review-includes-filter";
23
28
  import { ReviewLlmProcessor } from "./review-llm";
24
29
  import { PullRequestModel } from "./pull-request-model";
25
30
  import { ReviewResultModel, type ReviewResultModelDeps } from "./review-result-model";
26
- import { REVIEW_COMMENT_MARKER, REVIEW_LINE_COMMENTS_MARKER } from "./utils/review-pr-comment";
31
+ import { ReviewSourceResolver, type SourceData } from "./review-source-resolver";
27
32
 
28
33
  export type { ReviewContext } from "./review-context";
29
34
  export type { FileReviewPrompt, ReviewPrompt, LLMReviewOptions } from "./review-llm";
35
+ export type { SourceData } from "./review-source-resolver";
30
36
 
31
37
  export class ReviewService {
32
38
  protected readonly contextBuilder: ReviewContextBuilder;
33
39
  protected readonly issueFilter: ReviewIssueFilter;
34
40
  protected readonly llmProcessor: ReviewLlmProcessor;
35
41
  protected readonly resultModelDeps: ReviewResultModelDeps;
42
+ protected readonly sourceResolver: ReviewSourceResolver;
36
43
 
37
44
  constructor(
38
45
  protected readonly gitProvider: GitProviderService,
@@ -53,6 +60,7 @@ export class ReviewService {
53
60
  gitSdk,
54
61
  );
55
62
  this.llmProcessor = new ReviewLlmProcessor(llmProxyService, reviewSpecService);
63
+ this.sourceResolver = new ReviewSourceResolver(gitProvider, gitSdk, this.issueFilter);
56
64
  this.resultModelDeps = {
57
65
  gitProvider,
58
66
  config,
@@ -96,47 +104,42 @@ export class ReviewService {
96
104
  const source = await this.resolveSourceData(context);
97
105
  if (source.earlyReturn) return source.earlyReturn;
98
106
 
99
- const { prModel, commits, changedFiles, headSha, isDirectFileMode } = source;
100
- const effectiveWhenModifiedCode = isDirectFileMode ? undefined : context.whenModifiedCode;
101
- if (isDirectFileMode && context.whenModifiedCode?.length && shouldLog(verbose, 1)) {
107
+ const effectiveWhenModifiedCode = source.isDirectFileMode
108
+ ? undefined
109
+ : context.whenModifiedCode;
110
+ if (source.isDirectFileMode && context.whenModifiedCode?.length && shouldLog(verbose, 1)) {
102
111
  console.log(`ℹ️ 直接文件模式下忽略 whenModifiedCode 过滤`);
103
112
  }
104
113
 
105
114
  // 2. 规则匹配
106
- const specs = await this.issueFilter.loadSpecs(specSources, verbose);
107
- const applicableSpecs = this.reviewSpecService.filterApplicableSpecs(specs, changedFiles);
115
+ const allSpecs = await this.issueFilter.loadSpecs(specSources, verbose);
116
+ const specs = this.reviewSpecService.filterApplicableSpecs(allSpecs, source.changedFiles);
108
117
  if (shouldLog(verbose, 2)) {
109
118
  console.log(
110
119
  `[execute] loadSpecs: loaded ${specs.length} specs from sources: ${JSON.stringify(specSources)}`,
111
120
  );
112
121
  console.log(
113
- `[execute] filterApplicableSpecs: ${applicableSpecs.length} applicable out of ${specs.length}, changedFiles=${JSON.stringify(changedFiles.map((f) => f.filename))}`,
122
+ `[execute] filterApplicableSpecs: ${specs.length} applicable out of ${allSpecs.length}, changedFiles=${JSON.stringify(source.changedFiles.filenames())}`,
114
123
  );
115
124
  }
116
125
  if (shouldLog(verbose, 1)) {
117
- console.log(` 适用的规则文件: ${applicableSpecs.length}`);
126
+ console.log(` 适用的规则文件: ${specs.length}`);
118
127
  }
119
- if (applicableSpecs.length === 0 || changedFiles.length === 0) {
120
- return this.handleNoApplicableSpecs(context, applicableSpecs, changedFiles, commits);
128
+ if (specs.length === 0 || source.changedFiles.length === 0) {
129
+ return this.handleNoApplicableSpecs(context, specs, source.changedFiles, source.commits);
121
130
  }
122
131
 
123
- // 3. 获取文件内容 + LLM 审查
124
- const fileContents = await this.getFileContents(
125
- context.owner,
126
- context.repo,
127
- changedFiles,
128
- commits,
129
- headSha,
130
- context.prNumber,
131
- verbose,
132
- source.isLocalMode,
133
- );
132
+ // 3. LLM 审查
133
+ const { fileContents } = source;
134
134
  if (!llmMode) throw new Error("必须指定 LLM 类型");
135
135
 
136
136
  // 获取上一次的审查结果(用于提示词优化和轮次推进)
137
137
  let existingResultModel: ReviewResultModel | null = null;
138
- if (context.ci && prModel) {
139
- existingResultModel = await ReviewResultModel.loadFromPr(prModel, this.resultModelDeps);
138
+ if (context.ci && source.prModel) {
139
+ existingResultModel = await ReviewResultModel.loadFromPr(
140
+ source.prModel,
141
+ this.resultModelDeps,
142
+ );
140
143
  if (existingResultModel && shouldLog(verbose, 1)) {
141
144
  console.log(`📋 获取到上一次审查结果,包含 ${existingResultModel.issues.length} 个问题`);
142
145
  }
@@ -145,17 +148,67 @@ export class ReviewService {
145
148
  console.log(`🔄 当前审查轮次: ${(existingResultModel?.round ?? 0) + 1}`);
146
149
  }
147
150
 
148
- const reviewPrompt = await this.buildReviewPrompt(
151
+ const reviewPrompt = await this.llmProcessor.buildReviewPrompt(
149
152
  specs,
150
- changedFiles,
153
+ source.changedFiles,
151
154
  fileContents,
152
- commits,
155
+ source.commits,
153
156
  existingResultModel?.result ?? null,
154
157
  effectiveWhenModifiedCode,
155
158
  verbose,
156
159
  context.systemRules,
157
160
  );
158
- const result = await this.runLLMReview(llmMode, reviewPrompt, {
161
+ // 4. 运行 LLM 审查 + 过滤新 issues
162
+ const result = await this.buildReviewResult(context, reviewPrompt, llmMode, {
163
+ specs,
164
+ fileContents,
165
+ changedFiles: source.changedFiles,
166
+ commits: source.commits,
167
+ isDirectFileMode: source.isDirectFileMode,
168
+ });
169
+
170
+ // 5. 构建最终的 ReviewResultModel
171
+ const finalModel = await this.buildFinalModel(
172
+ context,
173
+ result,
174
+ {
175
+ prModel: source.prModel,
176
+ commits: source.commits,
177
+ headSha: source.headSha,
178
+ specs,
179
+ fileContents,
180
+ },
181
+ existingResultModel,
182
+ );
183
+
184
+ // 6. 保存 + 输出
185
+ await this.saveAndOutput(context, finalModel, source.commits);
186
+ return finalModel.result;
187
+ }
188
+
189
+ /**
190
+ * 运行 LLM 审查并构建过滤后的 ReviewResult:
191
+ * - 调用 LLM 生成问题列表
192
+ * - 填充 PR 标题/描述
193
+ * - 过滤新 issues(去重、commit 范围等)
194
+ * - 合并静态规则问题
195
+ */
196
+ protected async buildReviewResult(
197
+ context: ReviewContext,
198
+ reviewPrompt: Awaited<ReturnType<typeof this.llmProcessor.buildReviewPrompt>>,
199
+ llmMode: LLMMode,
200
+ source: {
201
+ specs: ReviewSpec[];
202
+ fileContents: FileContentsMap;
203
+ changedFiles: ChangedFileCollection;
204
+ commits: PullRequestCommit[];
205
+ isDirectFileMode: boolean;
206
+ },
207
+ ): Promise<ReviewResult> {
208
+ const { verbose } = context;
209
+ const { specs, fileContents, changedFiles, commits, isDirectFileMode } = source;
210
+
211
+ const result = await this.llmProcessor.runLLMReview(llmMode, reviewPrompt, {
159
212
  verbose,
160
213
  concurrency: context.concurrency,
161
214
  timeout: context.timeout,
@@ -165,17 +218,22 @@ export class ReviewService {
165
218
 
166
219
  // 填充 PR 功能描述和标题
167
220
  const prInfo = context.generateDescription
168
- ? await this.generatePrDescription(commits, changedFiles, llmMode, fileContents, verbose)
169
- : await this.buildBasicDescription(commits, changedFiles);
221
+ ? await this.llmProcessor.generatePrDescription(
222
+ commits,
223
+ changedFiles,
224
+ llmMode,
225
+ fileContents,
226
+ verbose,
227
+ )
228
+ : await this.llmProcessor.buildBasicDescription(commits, changedFiles);
170
229
  result.title = prInfo.title;
171
230
  result.description = prInfo.description;
172
231
  if (shouldLog(verbose, 1)) {
173
232
  console.log(`📝 LLM 审查完成,发现 ${result.issues.length} 个问题`);
174
233
  }
175
234
 
176
- // 4. 过滤新 issues
177
- result.issues = await this.fillIssueCode(result.issues, fileContents);
178
- result.issues = this.filterNewIssues(result.issues, specs, applicableSpecs, {
235
+ result.issues = await this.issueFilter.fillIssueCode(result.issues, fileContents);
236
+ result.issues = this.filterNewIssues(result.issues, specs, {
179
237
  commits,
180
238
  fileContents,
181
239
  changedFiles,
@@ -194,263 +252,15 @@ export class ReviewService {
194
252
  console.log(`📝 最终发现 ${result.issues.length} 个问题`);
195
253
  }
196
254
 
197
- // 5. 构建最终的 ReviewResultModel
198
- const finalModel = await this.buildFinalModel(
199
- context,
200
- result,
201
- { prModel, commits, headSha, specs, fileContents },
202
- existingResultModel,
203
- );
204
-
205
- // 6. 保存 + 输出
206
- await this.saveAndOutput(context, finalModel, commits);
207
- return finalModel.result;
255
+ return result;
208
256
  }
209
257
 
210
- // ─── 提取的子方法 ──────────────────────────────────────
211
-
212
258
  /**
213
- * 解析输入数据:根据模式(本地/PR/分支比较)获取 commits、changedFiles 等。
214
- * 包含前置过滤(merge commit、files、commits、includes)。
215
- * 如果需要提前返回(如同分支、重复 workflow),通过 earlyReturn 字段传递。
259
+ * 解析输入数据:委托给 ReviewSourceResolver。
260
+ * @see ReviewSourceResolver#resolve
216
261
  */
217
- protected async resolveSourceData(context: ReviewContext): Promise<{
218
- prModel?: PullRequestModel;
219
- commits: PullRequestCommit[];
220
- changedFiles: ChangedFile[];
221
- headSha: string;
222
- isLocalMode: boolean;
223
- isDirectFileMode: boolean;
224
- earlyReturn?: ReviewResult;
225
- }> {
226
- const {
227
- owner,
228
- repo,
229
- prNumber,
230
- baseRef,
231
- headRef,
232
- verbose,
233
- ci,
234
- includes,
235
- files,
236
- commits: filterCommits,
237
- localMode,
238
- duplicateWorkflowResolved,
239
- } = context;
240
-
241
- const isDirectFileMode = !!(files && files.length > 0 && !prNumber);
242
- let isLocalMode = !!localMode;
243
- let effectiveBaseRef = baseRef;
244
- let effectiveHeadRef = headRef;
245
-
246
- let prModel: PullRequestModel | undefined;
247
- let commits: PullRequestCommit[] = [];
248
- let changedFiles: ChangedFile[] = [];
249
-
250
- if (isLocalMode) {
251
- // 本地模式:从 git 获取未提交/暂存区的变更
252
- if (shouldLog(verbose, 1)) {
253
- console.log(`📥 本地模式: 获取${localMode === "staged" ? "暂存区" : "未提交"}的代码变更`);
254
- }
255
- const localFiles =
256
- localMode === "staged" ? this.gitSdk.getStagedFiles() : this.gitSdk.getUncommittedFiles();
257
-
258
- if (localFiles.length === 0) {
259
- // 本地无变更,回退到分支比较模式
260
- if (shouldLog(verbose, 1)) {
261
- console.log(
262
- `ℹ️ 没有${localMode === "staged" ? "暂存区" : "未提交"}的代码变更,回退到分支比较模式`,
263
- );
264
- }
265
- isLocalMode = false;
266
- effectiveHeadRef = this.gitSdk.getCurrentBranch() ?? "HEAD";
267
- effectiveBaseRef = this.gitSdk.getDefaultBranch();
268
- if (shouldLog(verbose, 1)) {
269
- console.log(`📌 自动检测分支: base=${effectiveBaseRef}, head=${effectiveHeadRef}`);
270
- }
271
- // 同分支无法比较,提前返回
272
- if (effectiveBaseRef === effectiveHeadRef) {
273
- console.log(`ℹ️ 当前分支 ${effectiveHeadRef} 与默认分支相同,没有可审查的代码变更`);
274
- return {
275
- commits: [],
276
- changedFiles: [],
277
- headSha: "HEAD",
278
- isLocalMode: false,
279
- isDirectFileMode: false,
280
- earlyReturn: { success: true, description: "", issues: [], summary: [], round: 1 },
281
- };
282
- }
283
- } else {
284
- // 一次性获取所有 diff,避免每个文件调用一次 git 命令
285
- const localDiffs =
286
- localMode === "staged" ? this.gitSdk.getStagedDiff() : this.gitSdk.getUncommittedDiff();
287
- const diffMap = new Map(localDiffs.map((d) => [d.filename, d.patch]));
288
-
289
- changedFiles = localFiles.map((f) => ({
290
- filename: f.filename,
291
- status: f.status as ChangedFile["status"],
292
- patch: diffMap.get(f.filename),
293
- }));
294
-
295
- if (shouldLog(verbose, 1)) {
296
- console.log(` Changed files: ${changedFiles.length}`);
297
- }
298
- }
299
- }
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
- }
309
- // PR 模式、分支比较模式、或本地模式回退后的分支比较
310
- else if (prNumber) {
311
- if (shouldLog(verbose, 1)) {
312
- console.log(`📥 获取 PR #${prNumber} 信息 (owner: ${owner}, repo: ${repo})`);
313
- }
314
- prModel = new PullRequestModel(this.gitProvider, owner, repo, prNumber);
315
- const prInfo = await prModel.getInfo();
316
- commits = await prModel.getCommits();
317
- changedFiles = await prModel.getFiles();
318
- if (shouldLog(verbose, 1)) {
319
- console.log(` PR: ${prInfo?.title}`);
320
- console.log(` Commits: ${commits.length}`);
321
- console.log(` Changed files: ${changedFiles.length}`);
322
- }
323
-
324
- // 检查是否有其他同名 review workflow 正在运行中
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) {
333
- return {
334
- prModel,
335
- commits,
336
- changedFiles,
337
- headSha: prInfo.head.sha,
338
- isLocalMode,
339
- isDirectFileMode,
340
- earlyReturn: duplicateResult,
341
- };
342
- }
343
- }
344
- } else if (effectiveBaseRef && effectiveHeadRef) {
345
- if (changedFiles.length === 0) {
346
- if (shouldLog(verbose, 1)) {
347
- console.log(
348
- `📥 获取 ${effectiveBaseRef}...${effectiveHeadRef} 的差异 (owner: ${owner}, repo: ${repo})`,
349
- );
350
- }
351
- changedFiles = await this.getChangedFilesBetweenRefs(
352
- owner,
353
- repo,
354
- effectiveBaseRef,
355
- effectiveHeadRef,
356
- );
357
- commits = await this.getCommitsBetweenRefs(effectiveBaseRef, effectiveHeadRef);
358
- if (shouldLog(verbose, 1)) {
359
- console.log(` Changed files: ${changedFiles.length}`);
360
- console.log(` Commits: ${commits.length}`);
361
- }
362
- }
363
- } else if (!isLocalMode) {
364
- if (shouldLog(verbose, 1)) {
365
- console.log(`❌ 错误: 缺少 prNumber 或 baseRef/headRef`, { prNumber, baseRef, headRef });
366
- }
367
- throw new Error("必须指定 PR 编号或者 base/head 分支");
368
- }
369
-
370
- // ── 前置过滤 ──────────────────────────────────────────
371
-
372
- // 0. 过滤掉 merge commit
373
- {
374
- const before = commits.length;
375
- commits = commits.filter((c) => {
376
- const message = c.commit?.message || "";
377
- return !message.startsWith("Merge ");
378
- });
379
- if (before !== commits.length && shouldLog(verbose, 1)) {
380
- console.log(` 跳过 Merge Commits: ${before} -> ${commits.length} 个`);
381
- }
382
- }
383
-
384
- // 1. 按指定的 files 过滤
385
- if (files && files.length > 0) {
386
- const before = changedFiles.length;
387
- changedFiles = changedFiles.filter((f) => files.includes(f.filename || ""));
388
- if (shouldLog(verbose, 1)) {
389
- console.log(` Files 过滤文件: ${before} -> ${changedFiles.length} 个文件`);
390
- }
391
- }
392
-
393
- // 2. 按指定的 commits 过滤
394
- if (filterCommits && filterCommits.length > 0) {
395
- const beforeCommits = commits.length;
396
- commits = commits.filter((c) => filterCommits.some((fc) => fc && c.sha?.startsWith(fc)));
397
- if (shouldLog(verbose, 1)) {
398
- console.log(` Commits 过滤: ${beforeCommits} -> ${commits.length} 个`);
399
- }
400
-
401
- const beforeFiles = changedFiles.length;
402
- const commitFilenames = new Set<string>();
403
- for (const commit of commits) {
404
- if (!commit.sha) continue;
405
- const commitFiles = await this.getFilesForCommit(owner, repo, commit.sha, prNumber);
406
- commitFiles.forEach((f) => commitFilenames.add(f));
407
- }
408
- changedFiles = changedFiles.filter((f) => commitFilenames.has(f.filename || ""));
409
- if (shouldLog(verbose, 1)) {
410
- console.log(` 按 Commits 过滤文件: ${beforeFiles} -> ${changedFiles.length} 个文件`);
411
- }
412
- }
413
-
414
- // 3. 使用 includes 过滤文件和 commits(支持 added|/modified|/deleted| 前缀语法)
415
- if (isDirectFileMode && includes && includes.length > 0) {
416
- if (shouldLog(verbose, 1)) {
417
- console.log(`ℹ️ 直接文件模式下忽略 includes 过滤`);
418
- }
419
- } else if (includes && includes.length > 0) {
420
- const beforeFiles = changedFiles.length;
421
- if (shouldLog(verbose, 2)) {
422
- console.log(
423
- `[resolveSourceData] filterFilesByIncludes: before=${JSON.stringify(changedFiles.map((f) => ({ filename: f.filename, status: f.status })))}, includes=${JSON.stringify(includes)}`,
424
- );
425
- }
426
- changedFiles = filterFilesByIncludes(changedFiles, includes);
427
- if (shouldLog(verbose, 1)) {
428
- console.log(` Includes 过滤文件: ${beforeFiles} -> ${changedFiles.length} 个文件`);
429
- }
430
- if (shouldLog(verbose, 2)) {
431
- console.log(
432
- `[resolveSourceData] filterFilesByIncludes: after=${JSON.stringify(changedFiles.map((f) => f.filename))}`,
433
- );
434
- }
435
-
436
- const globs = extractGlobsFromIncludes(includes);
437
- const beforeCommits = commits.length;
438
- const filteredCommits: PullRequestCommit[] = [];
439
- for (const commit of commits) {
440
- if (!commit.sha) continue;
441
- const commitFiles = await this.getFilesForCommit(owner, repo, commit.sha, prNumber);
442
- if (micromatch.some(commitFiles, globs)) {
443
- filteredCommits.push(commit);
444
- }
445
- }
446
- commits = filteredCommits;
447
- if (shouldLog(verbose, 1)) {
448
- console.log(` Includes 过滤 Commits: ${beforeCommits} -> ${commits.length} 个`);
449
- }
450
- }
451
-
452
- const headSha = prModel ? await prModel.getHeadSha() : headRef || "HEAD";
453
- return { prModel, commits, changedFiles, headSha, isLocalMode, isDirectFileMode };
262
+ protected async resolveSourceData(context: ReviewContext): Promise<SourceData> {
263
+ return this.sourceResolver.resolve(context);
454
264
  }
455
265
 
456
266
  /**
@@ -460,11 +270,10 @@ export class ReviewService {
460
270
  protected filterNewIssues(
461
271
  issues: ReviewResult["issues"],
462
272
  specs: any[],
463
- applicableSpecs: any[],
464
273
  opts: {
465
274
  commits: PullRequestCommit[];
466
275
  fileContents: any;
467
- changedFiles: ChangedFile[];
276
+ changedFiles: ChangedFileCollection;
468
277
  isDirectFileMode: boolean;
469
278
  context: ReviewContext;
470
279
  },
@@ -472,7 +281,7 @@ export class ReviewService {
472
281
  const { commits, fileContents, changedFiles, isDirectFileMode, context } = opts;
473
282
  const { verbose } = context;
474
283
 
475
- let filtered = this.reviewSpecService.filterIssuesByIncludes(issues, applicableSpecs);
284
+ let filtered = this.reviewSpecService.filterIssuesByIncludes(issues, specs);
476
285
  if (shouldLog(verbose, 1)) {
477
286
  console.log(` 应用 includes 过滤后: ${filtered.length} 个问题`);
478
287
  }
@@ -482,7 +291,7 @@ export class ReviewService {
482
291
  console.log(` 应用规则存在性过滤后: ${filtered.length} 个问题`);
483
292
  }
484
293
 
485
- filtered = this.reviewSpecService.filterIssuesByOverrides(filtered, applicableSpecs, verbose);
294
+ filtered = this.reviewSpecService.filterIssuesByOverrides(filtered, specs, verbose);
486
295
 
487
296
  // 变更行过滤
488
297
  if (shouldLog(verbose, 3)) {
@@ -491,21 +300,27 @@ export class ReviewService {
491
300
  ` showAll=${context.showAll}, isDirectFileMode=${isDirectFileMode}, commits.length=${commits.length}`,
492
301
  );
493
302
  }
494
- if (!context.showAll && !isDirectFileMode && commits.length > 0) {
303
+ if (!context.showAll && !isDirectFileMode) {
495
304
  if (shouldLog(verbose, 2)) {
496
305
  console.log(` 🔍 开始变更行过滤,当前 ${filtered.length} 个问题`);
497
306
  }
498
- filtered = this.filterIssuesByValidCommits(filtered, commits, fileContents, verbose);
307
+ filtered = this.issueFilter.filterIssuesByValidCommits(
308
+ filtered,
309
+ commits,
310
+ fileContents,
311
+ verbose,
312
+ );
499
313
  if (shouldLog(verbose, 2)) {
500
314
  console.log(` 🔍 变更行过滤完成,剩余 ${filtered.length} 个问题`);
501
315
  }
502
316
  } else if (shouldLog(verbose, 1)) {
503
- console.log(
504
- ` 跳过变更行过滤 (${context.showAll ? "showAll=true" : isDirectFileMode ? "直接审查文件模式" : "commits.length=0"})`,
505
- );
317
+ console.log(` 跳过变更行过滤 (${context.showAll ? "showAll=true" : "直接审查文件模式"})`);
506
318
  }
507
319
 
508
- filtered = this.reviewSpecService.formatIssues(filtered, { specs, changedFiles });
320
+ filtered = this.reviewSpecService.formatIssues(filtered, {
321
+ specs,
322
+ changedFiles: changedFiles.toArray(),
323
+ });
509
324
  if (shouldLog(verbose, 1)) {
510
325
  console.log(` 应用格式化后: ${filtered.length} 个问题`);
511
326
  }
@@ -530,6 +345,7 @@ export class ReviewService {
530
345
  ): Promise<ReviewResultModel> {
531
346
  const { prModel, commits, headSha, specs, fileContents } = source;
532
347
  const { verbose, ci } = context;
348
+ result.headSha = headSha;
533
349
 
534
350
  if (ci && prModel && existingResultModel && existingResultModel.issues.length > 0) {
535
351
  if (shouldLog(verbose, 1)) {
@@ -545,7 +361,7 @@ export class ReviewService {
545
361
  reviewConf.invalidateChangedFiles !== "off" &&
546
362
  reviewConf.invalidateChangedFiles !== "keep"
547
363
  ) {
548
- await existingResultModel.invalidateChangedFiles(headSha, verbose);
364
+ await existingResultModel.invalidateChangedFiles(headSha, fileContents, verbose);
549
365
  }
550
366
 
551
367
  // 验证历史问题是否已修复
@@ -555,7 +371,6 @@ export class ReviewService {
555
371
  existingResultModel.issues,
556
372
  commits,
557
373
  { specs, fileContents },
558
- prModel,
559
374
  );
560
375
  } else {
561
376
  if (shouldLog(verbose, 1)) {
@@ -563,24 +378,12 @@ export class ReviewService {
563
378
  }
564
379
  }
565
380
 
566
- // 去重:与所有历史 issues 去重
567
- const { filteredIssues: newIssues, skippedCount } = this.filterDuplicateIssues(
568
- result.issues,
569
- existingResultModel.issues,
570
- );
571
- if (skippedCount > 0 && shouldLog(verbose, 1)) {
572
- console.log(` 跳过 ${skippedCount} 个重复问题,新增 ${newIssues.length} 个问题`);
573
- }
574
- result.issues = newIssues;
575
- result.headSha = headSha;
576
-
577
- // 自动 round 递增 + issues 合并
381
+ // 自动 round 递增 + 去重 + issues 合并
578
382
  return existingResultModel.nextRound(result);
579
383
  }
580
384
 
581
385
  // 首次审查或无历史结果
582
386
  result.round = 1;
583
- result.headSha = headSha;
584
387
  result.issues = result.issues.map((issue) => ({ ...issue, round: 1 }));
585
388
  return prModel
586
389
  ? ReviewResultModel.create(prModel, result, this.resultModelDeps)
@@ -612,7 +415,7 @@ export class ReviewService {
612
415
 
613
416
  // 填充 author 信息
614
417
  if (commits.length > 0) {
615
- finalModel.issues = await this.fillIssueAuthors(
418
+ finalModel.issues = await this.contextBuilder.fillIssueAuthors(
616
419
  finalModel.issues,
617
420
  commits,
618
421
  owner,
@@ -693,8 +496,14 @@ export class ReviewService {
693
496
  }
694
497
 
695
498
  // 2. 获取 commits 并填充 author 信息
696
- const commits = await prModel.getCommits();
697
- resultModel.issues = await this.fillIssueAuthors(
499
+ const allCommits = await prModel.getCommits();
500
+ const commits = context.showAll
501
+ ? allCommits
502
+ : allCommits.filter((c) => !/^merge\b/i.test(c.commit?.message || ""));
503
+ if (allCommits.length !== commits.length && shouldLog(verbose, 1)) {
504
+ console.log(` 跳过 Merge Commits: ${allCommits.length} -> ${commits.length} 个`);
505
+ }
506
+ resultModel.issues = await this.contextBuilder.fillIssueAuthors(
698
507
  resultModel.issues,
699
508
  commits,
700
509
  owner,
@@ -709,16 +518,33 @@ export class ReviewService {
709
518
  await resultModel.syncReactions(verbose);
710
519
 
711
520
  // 5. LLM 验证历史问题是否已修复
712
- try {
713
- resultModel.issues = await this.issueFilter.verifyAndUpdateIssues(
714
- context,
715
- resultModel.issues,
716
- commits,
717
- undefined,
718
- prModel,
719
- );
720
- } catch (error) {
721
- console.warn("⚠️ LLM 验证修复状态失败,跳过:", error);
521
+ if (context.verifyFixes && context.specSources?.length) {
522
+ try {
523
+ const changedFiles = await prModel.getFiles();
524
+ const headSha = await prModel.getHeadSha();
525
+ const verifySpecs = await this.issueFilter.loadSpecs(context.specSources, verbose);
526
+ const verifyFileContents = await this.sourceResolver.getFileContents(
527
+ owner,
528
+ repo,
529
+ changedFiles,
530
+ commits,
531
+ headSha,
532
+ prNumber,
533
+ false,
534
+ context.showAll,
535
+ verbose,
536
+ );
537
+ resultModel.issues = await this.issueFilter.verifyAndUpdateIssues(
538
+ context,
539
+ resultModel.issues,
540
+ commits,
541
+ { specs: verifySpecs, fileContents: verifyFileContents },
542
+ );
543
+ } catch (error) {
544
+ console.warn("⚠️ LLM 验证修复状态失败,跳过:", error);
545
+ }
546
+ } else if (!context.verifyFixes && shouldLog(verbose, 1)) {
547
+ console.log(` ⏭️ 跳过历史问题验证 (verifyFixes=false)`);
722
548
  }
723
549
 
724
550
  // 6. 统计问题状态并设置到 result
@@ -773,24 +599,34 @@ export class ReviewService {
773
599
  // 获取 commits 和 changedFiles 用于生成描述
774
600
  let prModel: PullRequestModel | undefined;
775
601
  let commits: PullRequestCommit[] = [];
776
- let changedFiles: ChangedFile[] = [];
602
+ let changedFiles: ChangedFileCollection = ChangedFileCollection.empty();
777
603
  if (prNumber) {
778
604
  prModel = new PullRequestModel(this.gitProvider, owner, repo, prNumber);
779
605
  commits = await prModel.getCommits();
780
- changedFiles = await prModel.getFiles();
606
+ changedFiles = ChangedFileCollection.from(await prModel.getFiles());
781
607
  } else if (baseRef && headRef) {
782
- changedFiles = await this.getChangedFilesBetweenRefs(owner, repo, baseRef, headRef);
783
- commits = await this.getCommitsBetweenRefs(baseRef, headRef);
608
+ changedFiles = ChangedFileCollection.from(
609
+ await this.issueFilter.getChangedFilesBetweenRefs(owner, repo, baseRef, headRef),
610
+ );
611
+ commits = await this.issueFilter.getCommitsBetweenRefs(baseRef, headRef);
784
612
  }
785
613
 
786
614
  // 使用 includes 过滤文件(支持 added|/modified|/deleted| 前缀语法)
787
615
  if (context.includes && context.includes.length > 0) {
788
- changedFiles = filterFilesByIncludes(changedFiles, context.includes);
616
+ changedFiles = ChangedFileCollection.from(
617
+ filterFilesByIncludes(changedFiles.toArray(), context.includes),
618
+ );
789
619
  }
790
620
 
791
621
  const prDesc = context.generateDescription
792
- ? await this.generatePrDescription(commits, changedFiles, llmMode, undefined, verbose)
793
- : await this.buildBasicDescription(commits, changedFiles);
622
+ ? await this.llmProcessor.generatePrDescription(
623
+ commits,
624
+ changedFiles,
625
+ llmMode,
626
+ undefined,
627
+ verbose,
628
+ )
629
+ : await this.llmProcessor.buildBasicDescription(commits, changedFiles);
794
630
  const result: ReviewResult = {
795
631
  success: true,
796
632
  title: prDesc.title,
@@ -832,7 +668,7 @@ export class ReviewService {
832
668
  private async handleNoApplicableSpecs(
833
669
  context: ReviewContext,
834
670
  applicableSpecs: any[],
835
- changedFiles: ChangedFile[],
671
+ changedFiles: ChangedFileCollection,
836
672
  commits: PullRequestCommit[],
837
673
  ): Promise<ReviewResult> {
838
674
  const { ci, prNumber, verbose, dryRun, llmMode, autoApprove } = context;
@@ -851,18 +687,22 @@ export class ReviewService {
851
687
  const currentRound = (existingResultModel?.round ?? 0) + 1;
852
688
 
853
689
  // 即使没有适用的规则,也为每个变更文件生成摘要
854
- const summary: FileSummary[] = changedFiles
855
- .filter((f) => f.filename && f.status !== "deleted")
856
- .map((f) => ({
857
- file: f.filename!,
858
- resolved: 0,
859
- unresolved: 0,
860
- summary: applicableSpecs.length === 0 ? "无适用的审查规则" : "已跳过",
861
- }));
690
+ const summary: FileSummary[] = changedFiles.nonDeletedFiles().map((f) => ({
691
+ file: f.filename!,
692
+ resolved: 0,
693
+ unresolved: 0,
694
+ summary: applicableSpecs.length === 0 ? "无适用的审查规则" : "已跳过",
695
+ }));
862
696
  const prDesc =
863
697
  context.generateDescription && llmMode
864
- ? await this.generatePrDescription(commits, changedFiles, llmMode, undefined, verbose)
865
- : await this.buildBasicDescription(commits, changedFiles);
698
+ ? await this.llmProcessor.generatePrDescription(
699
+ commits,
700
+ changedFiles,
701
+ llmMode,
702
+ undefined,
703
+ verbose,
704
+ )
705
+ : await this.llmProcessor.buildBasicDescription(commits, changedFiles);
866
706
  const result: ReviewResult = {
867
707
  success: true,
868
708
  title: prDesc.title,
@@ -887,196 +727,6 @@ export class ReviewService {
887
727
  return result;
888
728
  }
889
729
 
890
- /**
891
- * 检查是否有其他同名 review workflow 正在运行中
892
- * 根据 duplicateWorkflowResolved 配置决定是跳过还是删除旧评论
893
- */
894
- private async checkDuplicateWorkflow(
895
- prModel: PullRequestModel,
896
- headSha: string,
897
- mode: "skip" | "delete",
898
- verbose?: VerboseLevel,
899
- ): Promise<ReviewResult | null> {
900
- const ref = process.env.GITHUB_REF || process.env.GITEA_REF || "";
901
- const prMatch = ref.match(/refs\/pull\/(\d+)/);
902
- const currentPrNumber = prMatch ? parseInt(prMatch[1], 10) : prModel.number;
903
-
904
- try {
905
- const runningWorkflows = await prModel.listWorkflowRuns({
906
- status: "in_progress",
907
- });
908
- const currentWorkflowName = process.env.GITHUB_WORKFLOW || process.env.GITEA_WORKFLOW;
909
- const currentRunId = process.env.GITHUB_RUN_ID || process.env.GITEA_RUN_ID;
910
- const duplicateReviewRuns = runningWorkflows.filter(
911
- (w) =>
912
- w.sha === headSha &&
913
- w.name === currentWorkflowName &&
914
- (!currentRunId || String(w.id) !== currentRunId),
915
- );
916
- if (duplicateReviewRuns.length > 0) {
917
- if (mode === "delete") {
918
- // 删除模式:清理旧的 AI Review 评论和 PR Review
919
- if (shouldLog(verbose, 1)) {
920
- console.log(
921
- `🗑️ 检测到 ${duplicateReviewRuns.length} 个同名 workflow,清理旧的 AI Review 评论...`,
922
- );
923
- }
924
- await this.cleanupDuplicateAiReviews(prModel, verbose);
925
- // 清理后继续执行当前审查
926
- return null;
927
- }
928
-
929
- // 跳过模式(默认)
930
- if (shouldLog(verbose, 1)) {
931
- console.log(
932
- `⏭️ 跳过审查: 当前 PR #${currentPrNumber} 有 ${duplicateReviewRuns.length} 个同名 workflow 正在运行中`,
933
- );
934
- }
935
- return {
936
- success: true,
937
- description: `跳过审查: PR #${currentPrNumber} 有 ${duplicateReviewRuns.length} 个同名 workflow 正在运行中,等待完成后重新审查`,
938
- issues: [],
939
- summary: [],
940
- round: 1,
941
- };
942
- }
943
- } catch (error) {
944
- if (shouldLog(verbose, 1)) {
945
- console.warn(
946
- `⚠️ 无法检查重复 workflow(可能缺少 repo owner 权限),跳过此检查:`,
947
- error instanceof Error ? error.message : error,
948
- );
949
- }
950
- }
951
- return null;
952
- }
953
-
954
- /**
955
- * 清理重复的 AI Review 评论(Issue Comments 和 PR Reviews)
956
- */
957
- private async cleanupDuplicateAiReviews(
958
- prModel: PullRequestModel,
959
- verbose?: VerboseLevel,
960
- ): Promise<void> {
961
- try {
962
- // 删除 Issue Comments(主评论)
963
- const comments = await prModel.getComments();
964
- const aiComments = comments.filter((c) => c.body?.includes(REVIEW_COMMENT_MARKER));
965
- let deletedComments = 0;
966
- for (const comment of aiComments) {
967
- if (comment.id) {
968
- try {
969
- await prModel.deleteComment(comment.id);
970
- deletedComments++;
971
- } catch {
972
- // 忽略删除失败
973
- }
974
- }
975
- }
976
- if (deletedComments > 0 && shouldLog(verbose, 1)) {
977
- console.log(` 已删除 ${deletedComments} 个重复的 AI Review 主评论`);
978
- }
979
-
980
- // 删除 PR Reviews(行级评论)
981
- const reviews = await prModel.getReviews();
982
- const aiReviews = reviews.filter((r) => r.body?.includes(REVIEW_LINE_COMMENTS_MARKER));
983
- let deletedReviews = 0;
984
- for (const review of aiReviews) {
985
- if (review.id) {
986
- try {
987
- await prModel.deleteReview(review.id);
988
- deletedReviews++;
989
- } catch {
990
- // 已提交的 review 无法删除,忽略
991
- }
992
- }
993
- }
994
- if (deletedReviews > 0 && shouldLog(verbose, 1)) {
995
- console.log(` 已删除 ${deletedReviews} 个重复的 AI Review PR Review`);
996
- }
997
- } catch (error) {
998
- if (shouldLog(verbose, 1)) {
999
- console.warn(`⚠️ 清理旧评论失败:`, error instanceof Error ? error.message : error);
1000
- }
1001
- }
1002
- }
1003
-
1004
- // --- Delegation methods for backward compatibility with tests ---
1005
-
1006
- protected async fillIssueAuthors(...args: Parameters<ReviewContextBuilder["fillIssueAuthors"]>) {
1007
- return this.contextBuilder.fillIssueAuthors(...args);
1008
- }
1009
-
1010
- protected async getFileContents(...args: Parameters<ReviewIssueFilter["getFileContents"]>) {
1011
- return this.issueFilter.getFileContents(...args);
1012
- }
1013
-
1014
- protected async getFilesForCommit(...args: Parameters<ReviewIssueFilter["getFilesForCommit"]>) {
1015
- return this.issueFilter.getFilesForCommit(...args);
1016
- }
1017
-
1018
- protected async getChangedFilesBetweenRefs(
1019
- ...args: Parameters<ReviewIssueFilter["getChangedFilesBetweenRefs"]>
1020
- ) {
1021
- return this.issueFilter.getChangedFilesBetweenRefs(...args);
1022
- }
1023
-
1024
- protected async getCommitsBetweenRefs(
1025
- ...args: Parameters<ReviewIssueFilter["getCommitsBetweenRefs"]>
1026
- ) {
1027
- return this.issueFilter.getCommitsBetweenRefs(...args);
1028
- }
1029
-
1030
- protected filterIssuesByValidCommits(
1031
- ...args: Parameters<ReviewIssueFilter["filterIssuesByValidCommits"]>
1032
- ) {
1033
- return this.issueFilter.filterIssuesByValidCommits(...args);
1034
- }
1035
-
1036
- protected filterDuplicateIssues(...args: Parameters<ReviewIssueFilter["filterDuplicateIssues"]>) {
1037
- return this.issueFilter.filterDuplicateIssues(...args);
1038
- }
1039
-
1040
- protected async fillIssueCode(...args: Parameters<ReviewIssueFilter["fillIssueCode"]>) {
1041
- return this.issueFilter.fillIssueCode(...args);
1042
- }
1043
-
1044
- protected async runLLMReview(...args: Parameters<ReviewLlmProcessor["runLLMReview"]>) {
1045
- return this.llmProcessor.runLLMReview(...args);
1046
- }
1047
-
1048
- protected async buildReviewPrompt(...args: Parameters<ReviewLlmProcessor["buildReviewPrompt"]>) {
1049
- return this.llmProcessor.buildReviewPrompt(...args);
1050
- }
1051
-
1052
- protected async generatePrDescription(
1053
- ...args: Parameters<ReviewLlmProcessor["generatePrDescription"]>
1054
- ) {
1055
- return this.llmProcessor.generatePrDescription(...args);
1056
- }
1057
-
1058
- protected async buildBasicDescription(
1059
- ...args: Parameters<ReviewLlmProcessor["buildBasicDescription"]>
1060
- ) {
1061
- return this.llmProcessor.buildBasicDescription(...args);
1062
- }
1063
-
1064
- protected normalizeFilePaths(...args: Parameters<ReviewContextBuilder["normalizeFilePaths"]>) {
1065
- return this.contextBuilder.normalizeFilePaths(...args);
1066
- }
1067
-
1068
- protected resolveAnalyzeDeletions(
1069
- ...args: Parameters<ReviewContextBuilder["resolveAnalyzeDeletions"]>
1070
- ) {
1071
- return this.contextBuilder.resolveAnalyzeDeletions(...args);
1072
- }
1073
-
1074
- protected async getPrNumberFromEvent(
1075
- ...args: Parameters<ReviewContextBuilder["getPrNumberFromEvent"]>
1076
- ) {
1077
- return this.contextBuilder.getPrNumberFromEvent(...args);
1078
- }
1079
-
1080
730
  /**
1081
731
  * 确保 Claude CLI 已安装
1082
732
  */