@spaceflow/review 0.75.0 → 0.77.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,152 +1,37 @@
1
1
  import {
2
2
  GitProviderService,
3
- PullRequest,
4
3
  PullRequestCommit,
5
4
  ChangedFile,
6
- CreatePullReviewComment,
7
- REVIEW_STATE,
8
- type CiConfig,
9
5
  type LLMMode,
10
6
  LlmProxyService,
11
- logStreamEvent,
12
- createStreamLoggerState,
13
7
  type VerboseLevel,
14
8
  shouldLog,
15
- normalizeVerbose,
16
- type LlmJsonPutSchema,
17
- LlmJsonPut,
18
- parallel,
19
9
  GitSdkService,
20
- parseChangedLinesFromPatch,
21
- parseDiffText,
22
- parseHunksFromPatch,
23
- calculateNewLineNumber,
24
10
  } from "@spaceflow/core";
25
- import type { IConfigReader, LocalReviewMode } from "@spaceflow/core";
26
- import { type AnalyzeDeletionsMode, type ReviewConfig } from "./review.config";
27
- import {
28
- ReviewSpecService,
29
- ReviewSpec,
30
- ReviewIssue,
31
- ReviewResult,
32
- ReviewStats,
33
- FileSummary,
34
- FileContentsMap,
35
- FileContentLine,
36
- type UserInfo,
37
- } from "./review-spec";
38
- import { MarkdownFormatter, ReviewReportService, type ReportFormat } from "./review-report";
39
- import { execSync } from "child_process";
40
- import { readFile, readdir } from "fs/promises";
41
- import { join, dirname, extname, relative, isAbsolute } from "path";
11
+ import type { IConfigReader } from "@spaceflow/core";
12
+ import { type ReviewConfig } from "./review.config";
13
+ import { ReviewSpecService, ReviewResult, FileSummary } from "./review-spec";
14
+ import { MarkdownFormatter, ReviewReportService } from "./review-report";
42
15
  import micromatch from "micromatch";
43
16
  import { ReviewOptions } from "./review.config";
44
17
  import { IssueVerifyService } from "./issue-verify.service";
45
18
  import { DeletionImpactService } from "./deletion-impact.service";
46
- import { parseTitleOptions } from "./parse-title-options";
47
- import { homedir } from "os";
48
-
49
- export interface ReviewContext extends ReviewOptions {
50
- owner: string;
51
- repo: string;
52
- prNumber?: number;
53
- baseRef?: string;
54
- headRef?: string;
55
- specSources: string[];
56
- verbose?: VerboseLevel;
57
- includes?: string[];
58
- files?: string[];
59
- commits?: string[];
60
- concurrency?: number;
61
- timeout?: number;
62
- retries?: number;
63
- retryDelay?: number;
64
- /** 仅执行删除代码分析,跳过常规代码审查 */
65
- deletionOnly?: boolean;
66
- /** 删除代码分析模式:openai 使用标准模式,claude-agent 使用 Agent 模式 */
67
- deletionAnalysisMode?: LLMMode;
68
- /** 输出格式:markdown, terminal, json。不指定则智能选择 */
69
- outputFormat?: ReportFormat;
70
- /** 是否使用 AI 生成 PR 功能描述 */
71
- generateDescription?: boolean;
72
- /** 显示所有问题,不过滤非变更行的问题 */
73
- showAll?: boolean;
74
- /** PR 事件类型(opened, synchronize, closed 等) */
75
- eventAction?: string;
76
- /**
77
- * 本地代码审查模式(已解析)
78
- * - 'uncommitted': 审查所有未提交的代码(暂存区 + 工作区)
79
- * - 'staged': 仅审查暂存区的代码
80
- * - false: 禁用本地模式
81
- */
82
- localMode?: LocalReviewMode;
83
- }
84
-
85
- export interface FileReviewPrompt {
86
- filename: string;
87
- systemPrompt: string;
88
- userPrompt: string;
89
- }
90
-
91
- export interface ReviewPrompt {
92
- filePrompts: FileReviewPrompt[];
93
- }
94
-
95
- export interface LLMReviewOptions {
96
- verbose?: VerboseLevel;
97
- concurrency?: number;
98
- timeout?: number;
99
- retries?: number;
100
- retryDelay?: number;
101
- }
19
+ import { execSync } from "child_process";
20
+ import { ReviewContextBuilder, type ReviewContext } from "./review-context";
21
+ import { ReviewIssueFilter } from "./review-issue-filter";
22
+ import { filterFilesByIncludes, extractGlobsFromIncludes } from "./review-includes-filter";
23
+ import { ReviewLlmProcessor } from "./review-llm";
24
+ import { PullRequestModel } from "./pull-request-model";
25
+ import { ReviewResultModel, type ReviewResultModelDeps } from "./review-result-model";
102
26
 
103
- const REVIEW_COMMENT_MARKER = "<!-- spaceflow-review -->";
104
- const REVIEW_LINE_COMMENTS_MARKER = "<!-- spaceflow-review-lines -->";
105
-
106
- const REVIEW_SCHEMA: LlmJsonPutSchema = {
107
- type: "object",
108
- properties: {
109
- issues: {
110
- type: "array",
111
- items: {
112
- type: "object",
113
- properties: {
114
- file: { type: "string", description: "发生问题的文件路径" },
115
- line: {
116
- type: "string",
117
- description:
118
- "问题所在的行号,只支持单行或多行 (如 123 或 123-125),不允许使用 `,` 分隔多个行号",
119
- },
120
- ruleId: { type: "string", description: "违反的规则 ID(如 JsTs.FileName.UpperCamel)" },
121
- specFile: {
122
- type: "string",
123
- description: "规则来源的规范文件名(如 js&ts.file-name.md)",
124
- },
125
- reason: { type: "string", description: "问题的简要概括" },
126
- suggestion: {
127
- type: "string",
128
- description:
129
- "修改后的完整代码片段。要求以代码为主体,并在代码中使用详细的中文注释解释逻辑改进点。不要包含 Markdown 反引号。",
130
- },
131
- commit: { type: "string", description: "相关的 7 位 commit SHA" },
132
- severity: {
133
- type: "string",
134
- description: "问题严重程度,根据规则文档中的 severity 标记确定",
135
- enum: ["error", "warn"],
136
- },
137
- },
138
- required: ["file", "line", "ruleId", "specFile", "reason"],
139
- additionalProperties: false,
140
- },
141
- },
142
- summary: { type: "string", description: "本次代码审查的整体总结" },
143
- },
144
- required: ["issues", "summary"],
145
- additionalProperties: false,
146
- };
27
+ export type { ReviewContext } from "./review-context";
28
+ export type { FileReviewPrompt, ReviewPrompt, LLMReviewOptions } from "./review-llm";
147
29
 
148
30
  export class ReviewService {
149
- protected readonly llmJsonPut: LlmJsonPut<ReviewResult>;
31
+ protected readonly contextBuilder: ReviewContextBuilder;
32
+ protected readonly issueFilter: ReviewIssueFilter;
33
+ protected readonly llmProcessor: ReviewLlmProcessor;
34
+ protected readonly resultModelDeps: ReviewResultModelDeps;
150
35
 
151
36
  constructor(
152
37
  protected readonly gitProvider: GitProviderService,
@@ -158,319 +43,183 @@ export class ReviewService {
158
43
  protected readonly deletionImpactService: DeletionImpactService,
159
44
  protected readonly gitSdk: GitSdkService,
160
45
  ) {
161
- this.llmJsonPut = new LlmJsonPut(REVIEW_SCHEMA, {
162
- llmRequest: async (prompt) => {
163
- const response = await this.llmProxyService.chat(
164
- [
165
- { role: "system", content: prompt.systemPrompt },
166
- { role: "user", content: prompt.userPrompt },
167
- ],
168
- { adapter: "openai" },
169
- );
170
- if (!response.content) {
171
- throw new Error("LLM 返回了空内容");
172
- }
173
- return response.content;
174
- },
175
- });
46
+ this.contextBuilder = new ReviewContextBuilder(gitProvider, config, gitSdk);
47
+ this.issueFilter = new ReviewIssueFilter(
48
+ gitProvider,
49
+ config,
50
+ reviewSpecService,
51
+ issueVerifyService,
52
+ gitSdk,
53
+ );
54
+ this.llmProcessor = new ReviewLlmProcessor(llmProxyService, reviewSpecService);
55
+ this.resultModelDeps = {
56
+ gitProvider,
57
+ config,
58
+ reviewSpecService,
59
+ reviewReportService,
60
+ };
176
61
  }
177
62
 
178
63
  async getContextFromEnv(options: ReviewOptions): Promise<ReviewContext> {
179
- const reviewConf = this.config.getPluginConfig<ReviewConfig>("review");
180
- const ciConf = this.config.get<CiConfig>("ci");
181
- const repository = ciConf?.repository;
182
-
183
- if (options.ci) {
184
- this.gitProvider.validateConfig();
185
- }
186
-
187
- let repoPath = repository;
188
- if (!repoPath) {
189
- // 非 CI 模式下,从 git remote 获取仓库信息
190
- const remoteUrl = this.gitSdk.getRemoteUrl();
191
- if (remoteUrl) {
192
- const parsed = this.gitSdk.parseRepositoryFromRemoteUrl(remoteUrl);
193
- if (parsed) {
194
- repoPath = `${parsed.owner}/${parsed.repo}`;
195
- if (shouldLog(options.verbose, 1)) {
196
- console.log(`📦 从 git remote 获取仓库: ${repoPath}`);
197
- }
198
- }
199
- }
200
- }
64
+ return this.contextBuilder.getContextFromEnv(options);
65
+ }
201
66
 
202
- if (!repoPath) {
203
- throw new Error("缺少配置 ci.repository");
204
- }
67
+ /**
68
+ * 执行代码审查的主方法
69
+ * 该方法负责协调整个审查流程,包括:
70
+ * 1. 加载审查规范(specs)
71
+ * 2. 获取 PR/分支的变更文件和提交记录
72
+ * 3. 调用 LLM 进行代码审查
73
+ * 4. 处理历史 issue(更新行号、验证修复状态)
74
+ * 5. 生成并发布审查报告
75
+ *
76
+ * @param context 审查上下文,包含 owner、repo、prNumber 等信息
77
+ * @returns 审查结果,包含发现的问题列表和统计信息
78
+ */
79
+ async execute(context: ReviewContext): Promise<ReviewResult> {
80
+ const { specSources, verbose, llmMode, deletionOnly } = context;
205
81
 
206
- const parts = repoPath.split("/");
207
- if (parts.length < 2) {
208
- throw new Error("ci.repository 格式不正确");
82
+ if (shouldLog(verbose, 1)) {
83
+ console.log(`🔍 Review 启动`);
84
+ console.log(` DRY-RUN mode: ${context.dryRun ? "enabled" : "disabled"}`);
85
+ console.log(` CI mode: ${context.ci ? "enabled" : "disabled"}`);
86
+ if (context.localMode) console.log(` Local mode: ${context.localMode}`);
87
+ console.log(` Verbose: ${verbose}`);
209
88
  }
210
89
 
211
- const owner = parts[0];
212
- const repo = parts[1];
213
-
214
- let prNumber = options.prNumber;
90
+ // 早期分流
91
+ if (deletionOnly) return this.executeDeletionOnly(context);
92
+ if (context.eventAction === "closed" || context.flush) return this.executeCollectOnly(context);
215
93
 
216
- if (!prNumber && options.ci) {
217
- prNumber = await this.getPrNumberFromEvent();
218
- }
94
+ // 1. 解析输入数据(本地/PR/分支模式 + 前置过滤)
95
+ const source = await this.resolveSourceData(context);
96
+ if (source.earlyReturn) return source.earlyReturn;
219
97
 
220
- // PR 标题解析命令参数(命令行参数优先,标题参数作为补充)
221
- let titleOptions: ReturnType<typeof parseTitleOptions> = {};
222
- if (prNumber && options.ci) {
223
- try {
224
- const pr = await this.gitProvider.getPullRequest(owner, repo, prNumber);
225
- if (pr?.title) {
226
- titleOptions = parseTitleOptions(pr.title);
227
- if (Object.keys(titleOptions).length > 0 && shouldLog(options.verbose, 1)) {
228
- console.log(`📋 从 PR 标题解析到参数:`, titleOptions);
229
- }
230
- }
231
- } catch (error) {
232
- if (shouldLog(options.verbose, 1)) {
233
- console.warn(`⚠️ 获取 PR 标题失败:`, error);
234
- }
235
- }
236
- }
98
+ const { prModel, commits, changedFiles, headSha, isDirectFileMode } = source;
237
99
 
238
- const specSources = [
239
- join(homedir(), ".spaceflow", "deps"),
240
- join(process.cwd(), ".spaceflow", "deps"),
241
- ];
242
- if (options.references?.length) {
243
- specSources.push(...options.references);
100
+ // 2. 规则匹配
101
+ const specs = await this.issueFilter.loadSpecs(specSources, verbose);
102
+ const applicableSpecs = this.reviewSpecService.filterApplicableSpecs(specs, changedFiles);
103
+ if (shouldLog(verbose, 1)) {
104
+ console.log(` 适用的规则文件: ${applicableSpecs.length}`);
244
105
  }
245
- if (reviewConf.references?.length) {
246
- specSources.push(...reviewConf.references);
106
+ if (applicableSpecs.length === 0 || changedFiles.length === 0) {
107
+ return this.handleNoApplicableSpecs(context, applicableSpecs, changedFiles, commits);
247
108
  }
248
109
 
249
- // 解析本地模式:非 CI、非 PR、无 base/head 时默认启用 uncommitted 模式
250
- const localMode = this.resolveLocalMode(options, {
251
- ci: options.ci,
252
- hasPrNumber: !!prNumber,
253
- hasBaseHead: !!(options.base || options.head),
254
- });
110
+ // 3. 获取文件内容 + LLM 审查
111
+ const fileContents = await this.getFileContents(
112
+ context.owner,
113
+ context.repo,
114
+ changedFiles,
115
+ commits,
116
+ headSha,
117
+ context.prNumber,
118
+ verbose,
119
+ source.isLocalMode,
120
+ );
121
+ if (!llmMode) throw new Error("必须指定 LLM 类型");
255
122
 
256
- // 当没有 PR 且没有指定 base/head 且不是本地模式时,自动获取默认值
257
- let baseRef = options.base;
258
- let headRef = options.head;
259
- if (!prNumber && !baseRef && !headRef && !localMode) {
260
- headRef = this.gitSdk.getCurrentBranch() ?? "HEAD";
261
- baseRef = this.gitSdk.getDefaultBranch();
262
- if (shouldLog(options.verbose, 1)) {
263
- console.log(`📌 自动检测分支: base=${baseRef}, head=${headRef}`);
123
+ // 获取上一次的审查结果(用于提示词优化和轮次推进)
124
+ let existingResultModel: ReviewResultModel | null = null;
125
+ if (context.ci && prModel) {
126
+ existingResultModel = await ReviewResultModel.loadFromPr(prModel, this.resultModelDeps);
127
+ if (existingResultModel && shouldLog(verbose, 1)) {
128
+ console.log(`📋 获取到上一次审查结果,包含 ${existingResultModel.issues.length} 个问题`);
264
129
  }
265
130
  }
266
-
267
- // 合并参数优先级:命令行 > PR 标题 > 配置文件 > 默认值
268
- return {
269
- owner,
270
- repo,
271
- prNumber,
272
- baseRef,
273
- headRef,
274
- specSources,
275
- dryRun: options.dryRun || titleOptions.dryRun || false,
276
- ci: options.ci ?? false,
277
- verbose: normalizeVerbose(options.verbose ?? titleOptions.verbose),
278
- includes: options.includes ?? titleOptions.includes ?? reviewConf.includes,
279
- llmMode: options.llmMode ?? titleOptions.llmMode ?? reviewConf.llmMode,
280
- files: this.normalizeFilePaths(options.files),
281
- commits: options.commits,
282
- verifyFixes:
283
- options.verifyFixes ?? titleOptions.verifyFixes ?? reviewConf.verifyFixes ?? true,
284
- verifyConcurrency: options.verifyConcurrency ?? reviewConf.verifyFixesConcurrency ?? 10,
285
- analyzeDeletions: this.resolveAnalyzeDeletions(
286
- options.analyzeDeletions ??
287
- options.deletionOnly ??
288
- titleOptions.analyzeDeletions ??
289
- titleOptions.deletionOnly ??
290
- reviewConf.analyzeDeletions ??
291
- false,
292
- { ci: options.ci, hasPrNumber: !!prNumber },
293
- ),
294
- deletionOnly: options.deletionOnly || titleOptions.deletionOnly || false,
295
- deletionAnalysisMode:
296
- options.deletionAnalysisMode ??
297
- titleOptions.deletionAnalysisMode ??
298
- reviewConf.deletionAnalysisMode ??
299
- "openai",
300
- concurrency: options.concurrency ?? reviewConf.concurrency ?? 5,
301
- timeout: options.timeout ?? reviewConf.timeout,
302
- retries: options.retries ?? reviewConf.retries ?? 0,
303
- retryDelay: options.retryDelay ?? reviewConf.retryDelay ?? 1000,
304
- generateDescription: options.generateDescription ?? reviewConf.generateDescription ?? false,
305
- showAll: options.showAll ?? false,
306
- flush: options.flush ?? false,
307
- eventAction: options.eventAction,
308
- localMode,
309
- skipDuplicateWorkflow:
310
- options.skipDuplicateWorkflow ?? reviewConf.skipDuplicateWorkflow ?? false,
311
- autoApprove: options.autoApprove ?? reviewConf.autoApprove ?? false,
312
- };
313
- }
314
-
315
- /**
316
- * 解析本地代码审查模式
317
- * - 显式指定 --local [mode] 时使用指定值
318
- * - 显式指定 --no-local 时禁用
319
- * - 非 CI、非 PR、无 base/head 时默认启用 uncommitted 模式
320
- */
321
- protected resolveLocalMode(
322
- options: ReviewOptions,
323
- env: { ci: boolean; hasPrNumber: boolean; hasBaseHead: boolean },
324
- ): "uncommitted" | "staged" | false {
325
- // 显式指定了 --no-local
326
- if (options.local === false) {
327
- return false;
328
- }
329
- // 显式指定了 --local [mode]
330
- if (options.local === "staged" || options.local === "uncommitted") {
331
- return options.local;
332
- }
333
- // CI 或 PR 模式下不启用本地模式
334
- if (env.ci || env.hasPrNumber) {
335
- return false;
336
- }
337
- // 指定了 base/head 时不启用本地模式
338
- if (env.hasBaseHead) {
339
- return false;
131
+ if (shouldLog(verbose, 1)) {
132
+ console.log(`🔄 当前审查轮次: ${(existingResultModel?.round ?? 0) + 1}`);
340
133
  }
341
- // 默认启用 uncommitted 模式
342
- return "uncommitted";
343
- }
344
134
 
345
- /**
346
- * 将文件路径规范化为相对于仓库根目录的路径
347
- * 支持绝对路径和相对路径输入
348
- */
349
- protected normalizeFilePaths(files?: string[]): string[] | undefined {
350
- if (!files || files.length === 0) return files;
351
-
352
- const cwd = process.cwd();
353
- return files.map((file) => {
354
- if (isAbsolute(file)) {
355
- // 绝对路径转换为相对路径
356
- return relative(cwd, file);
357
- }
358
- return file;
135
+ const reviewPrompt = await this.buildReviewPrompt(
136
+ specs,
137
+ changedFiles,
138
+ fileContents,
139
+ commits,
140
+ existingResultModel?.result ?? null,
141
+ );
142
+ const result = await this.runLLMReview(llmMode, reviewPrompt, {
143
+ verbose,
144
+ concurrency: context.concurrency,
145
+ timeout: context.timeout,
146
+ retries: context.retries,
147
+ retryDelay: context.retryDelay,
359
148
  });
360
- }
361
149
 
362
- /**
363
- * 根据 AnalyzeDeletionsMode 和当前环境解析是否启用删除代码分析
364
- * @param mode 配置的模式值
365
- * @param env 当前环境信息
366
- * @returns 是否启用删除代码分析
367
- */
368
- protected resolveAnalyzeDeletions(
369
- mode: AnalyzeDeletionsMode,
370
- env: { ci: boolean; hasPrNumber: boolean },
371
- ): boolean {
372
- if (typeof mode === "boolean") {
373
- return mode;
374
- }
375
- switch (mode) {
376
- case "ci":
377
- return env.ci;
378
- case "pr":
379
- return env.hasPrNumber;
380
- case "terminal":
381
- return !env.ci;
382
- default:
383
- return false;
150
+ // 填充 PR 功能描述和标题
151
+ const prInfo = context.generateDescription
152
+ ? await this.generatePrDescription(commits, changedFiles, llmMode, fileContents, verbose)
153
+ : await this.buildBasicDescription(commits, changedFiles);
154
+ result.title = prInfo.title;
155
+ result.description = prInfo.description;
156
+ if (shouldLog(verbose, 1)) {
157
+ console.log(`📝 LLM 审查完成,发现 ${result.issues.length} 个问题`);
384
158
  }
385
- }
386
159
 
387
- /**
388
- * CI 事件文件中解析 PR 编号
389
- * CI 环境中,GitHub/Gitea Actions 会将事件信息写入 GITHUB_EVENT_PATH / GITEA_EVENT_PATH 指向的文件
390
- * @returns PR 编号,如果无法解析则返回 undefined
391
- */
392
- protected async getPrNumberFromEvent(): Promise<number | undefined> {
393
- const eventPath = process.env.GITHUB_EVENT_PATH || process.env.GITEA_EVENT_PATH;
394
- if (!eventPath) {
395
- return undefined;
160
+ // 4. 过滤新 issues
161
+ result.issues = await this.fillIssueCode(result.issues, fileContents);
162
+ result.issues = this.filterNewIssues(result.issues, specs, applicableSpecs, {
163
+ commits,
164
+ fileContents,
165
+ changedFiles,
166
+ isDirectFileMode,
167
+ context,
168
+ });
169
+ if (shouldLog(verbose, 1)) {
170
+ console.log(`📝 最终发现 ${result.issues.length} 个问题`);
396
171
  }
397
172
 
398
- try {
399
- const eventContent = await readFile(eventPath, "utf-8");
400
- const event = JSON.parse(eventContent);
401
- // 支持多种事件类型:
402
- // - pull_request 事件: event.pull_request.number event.number
403
- // - issue_comment 事件: event.issue.number
404
- return event.pull_request?.number || event.issue?.number || event.number;
405
- } catch {
406
- return undefined;
407
- }
173
+ // 5. 构建最终的 ReviewResultModel
174
+ const finalModel = await this.buildFinalModel(
175
+ context,
176
+ result,
177
+ { prModel, commits, headSha, specs, fileContents },
178
+ existingResultModel,
179
+ );
180
+
181
+ // 6. 保存 + 输出
182
+ await this.saveAndOutput(context, finalModel, commits);
183
+ return finalModel.result;
408
184
  }
409
185
 
186
+ // ─── 提取的子方法 ──────────────────────────────────────
187
+
410
188
  /**
411
- * 执行代码审查的主方法
412
- * 该方法负责协调整个审查流程,包括:
413
- * 1. 加载审查规范(specs)
414
- * 2. 获取 PR/分支的变更文件和提交记录
415
- * 3. 调用 LLM 进行代码审查
416
- * 4. 处理历史 issue(更新行号、验证修复状态)
417
- * 5. 生成并发布审查报告
418
- *
419
- * @param context 审查上下文,包含 owner、repo、prNumber 等信息
420
- * @returns 审查结果,包含发现的问题列表和统计信息
189
+ * 解析输入数据:根据模式(本地/PR/分支比较)获取 commits、changedFiles 等。
190
+ * 包含前置过滤(merge commit、files、commits、includes)。
191
+ * 如果需要提前返回(如同分支、重复 workflow),通过 earlyReturn 字段传递。
421
192
  */
422
- async execute(context: ReviewContext): Promise<ReviewResult> {
193
+ protected async resolveSourceData(context: ReviewContext): Promise<{
194
+ prModel?: PullRequestModel;
195
+ commits: PullRequestCommit[];
196
+ changedFiles: ChangedFile[];
197
+ headSha: string;
198
+ isLocalMode: boolean;
199
+ isDirectFileMode: boolean;
200
+ earlyReturn?: ReviewResult;
201
+ }> {
423
202
  const {
424
203
  owner,
425
204
  repo,
426
205
  prNumber,
427
206
  baseRef,
428
207
  headRef,
429
- specSources,
430
- dryRun,
431
- ci,
432
208
  verbose,
209
+ ci,
433
210
  includes,
434
- llmMode,
435
211
  files,
436
212
  commits: filterCommits,
437
- deletionOnly,
438
213
  localMode,
439
214
  skipDuplicateWorkflow,
440
- autoApprove,
441
215
  } = context;
442
216
 
443
- // 直接审查文件模式:指定了 -f 文件且 base=head
444
- const isDirectFileMode = files && files.length > 0 && baseRef === headRef;
445
- // 本地模式:审查未提交的代码(可能回退到分支比较)
217
+ const isDirectFileMode = !!(files && files.length > 0 && baseRef === headRef);
446
218
  let isLocalMode = !!localMode;
447
- // 用于回退时动态计算的 base/head
448
219
  let effectiveBaseRef = baseRef;
449
220
  let effectiveHeadRef = headRef;
450
221
 
451
- if (shouldLog(verbose, 1)) {
452
- console.log(`🔍 Review 启动`);
453
- console.log(` DRY-RUN mode: ${dryRun ? "enabled" : "disabled"}`);
454
- console.log(` CI mode: ${ci ? "enabled" : "disabled"}`);
455
- if (isLocalMode) {
456
- console.log(` Local mode: ${localMode}`);
457
- }
458
- console.log(` Verbose: ${verbose}`);
459
- }
460
-
461
- // 如果是 deletionOnly 模式,直接执行删除代码分析
462
- if (deletionOnly) {
463
- return this.executeDeletionOnly(context);
464
- }
465
-
466
- // 如果是 closed 事件或 flush 模式,仅收集 review 状态
467
- if (context.eventAction === "closed" || context.flush) {
468
- return this.executeCollectOnly(context);
469
- }
470
-
471
- const specs = await this.loadSpecs(specSources, verbose);
472
-
473
- let pr: PullRequest | undefined;
222
+ let prModel: PullRequestModel | undefined;
474
223
  let commits: PullRequestCommit[] = [];
475
224
  let changedFiles: ChangedFile[] = [];
476
225
 
@@ -499,11 +248,12 @@ export class ReviewService {
499
248
  if (effectiveBaseRef === effectiveHeadRef) {
500
249
  console.log(`ℹ️ 当前分支 ${effectiveHeadRef} 与默认分支相同,没有可审查的代码变更`);
501
250
  return {
502
- success: true,
503
- description: "",
504
- issues: [],
505
- summary: [],
506
- round: 1,
251
+ commits: [],
252
+ changedFiles: [],
253
+ headSha: "HEAD",
254
+ isLocalMode: false,
255
+ isDirectFileMode: false,
256
+ earlyReturn: { success: true, description: "", issues: [], summary: [], round: 1 },
507
257
  };
508
258
  }
509
259
  } else {
@@ -529,75 +279,38 @@ export class ReviewService {
529
279
  if (shouldLog(verbose, 1)) {
530
280
  console.log(`📥 获取 PR #${prNumber} 信息 (owner: ${owner}, repo: ${repo})`);
531
281
  }
532
- pr = await this.gitProvider.getPullRequest(owner, repo, prNumber);
533
- commits = await this.gitProvider.getPullRequestCommits(owner, repo, prNumber);
534
- changedFiles = await this.gitProvider.getPullRequestFiles(owner, repo, prNumber);
282
+ prModel = new PullRequestModel(this.gitProvider, owner, repo, prNumber);
283
+ const prInfo = await prModel.getInfo();
284
+ commits = await prModel.getCommits();
285
+ changedFiles = await prModel.getFiles();
535
286
  if (shouldLog(verbose, 1)) {
536
- console.log(` PR: ${pr?.title}`);
287
+ console.log(` PR: ${prInfo?.title}`);
537
288
  console.log(` Commits: ${commits.length}`);
538
289
  console.log(` Changed files: ${changedFiles.length}`);
539
290
  }
540
291
 
541
- // 检查是否有其他同名 review workflow 正在运行中(防止同一 PR 重复审查)
542
- // 需要显式启用 skipDuplicateWorkflow 配置
543
- // 注意:Gitea Actions API 要求用户是 repo owner,否则会返回 403
544
- if (skipDuplicateWorkflow && ci && pr?.head?.sha) {
545
- const headSha = pr.head.sha;
546
- // 获取当前 PR 编号(从 CI 环境变量)
547
- // GitHub: GITHUB_REF = refs/pull/123/merge
548
- // Gitea: GITEA_REF = refs/pull/123/head
549
- const ref = process.env.GITHUB_REF || process.env.GITEA_REF || "";
550
- const prMatch = ref.match(/refs\/pull\/(\d+)/);
551
- const currentPrNumber = prMatch ? parseInt(prMatch[1], 10) : prNumber;
552
-
553
- try {
554
- const runningWorkflows = await this.gitProvider.listWorkflowRuns(owner, repo, {
555
- status: "in_progress",
556
- });
557
- // 获取当前 workflow 名称和 run ID
558
- const currentWorkflowName = process.env.GITHUB_WORKFLOW || process.env.GITEA_WORKFLOW;
559
- const currentRunId = process.env.GITHUB_RUN_ID || process.env.GITEA_RUN_ID;
560
- // 只检查同 PR 同名的其他 workflow run(排除当前 run)
561
- const duplicateReviewRuns = runningWorkflows.filter(
562
- (w) =>
563
- w.sha === headSha &&
564
- w.name === currentWorkflowName &&
565
- (!currentRunId || String(w.id) !== currentRunId),
566
- );
567
- if (duplicateReviewRuns.length > 0) {
568
- if (shouldLog(verbose, 1)) {
569
- console.log(
570
- `⏭️ 跳过审查: 当前 PR #${currentPrNumber} 有 ${duplicateReviewRuns.length} 个同名 workflow 正在运行中`,
571
- );
572
- }
573
- return {
574
- success: true,
575
- description: `跳过审查: PR #${currentPrNumber} 有 ${duplicateReviewRuns.length} 个同名 workflow 正在运行中,等待完成后重新审查`,
576
- issues: [],
577
- summary: [],
578
- round: 1,
579
- };
580
- }
581
- } catch (error) {
582
- // Gitea Actions API 可能返回 403(需要 repo owner 权限)
583
- // 捕获错误后跳过重复检查,继续执行审查
584
- if (shouldLog(verbose, 1)) {
585
- console.warn(
586
- `⚠️ 无法检查重复 workflow(可能缺少 repo owner 权限),跳过此检查:`,
587
- error instanceof Error ? error.message : error,
588
- );
589
- }
292
+ // 检查是否有其他同名 review workflow 正在运行中
293
+ if (skipDuplicateWorkflow && ci && prInfo?.head?.sha) {
294
+ const skipResult = await this.checkDuplicateWorkflow(prModel, prInfo.head.sha, verbose);
295
+ if (skipResult) {
296
+ return {
297
+ prModel,
298
+ commits,
299
+ changedFiles,
300
+ headSha: prInfo.head.sha,
301
+ isLocalMode,
302
+ isDirectFileMode,
303
+ earlyReturn: skipResult,
304
+ };
590
305
  }
591
306
  }
592
307
  } else if (effectiveBaseRef && effectiveHeadRef) {
593
- // 如果指定了 -f 文件且 base=head(无差异模式),直接审查指定文件
594
308
  if (files && files.length > 0 && effectiveBaseRef === effectiveHeadRef) {
595
309
  if (shouldLog(verbose, 1)) {
596
310
  console.log(`📥 直接审查指定文件模式 (${files.length} 个文件)`);
597
311
  }
598
312
  changedFiles = files.map((f) => ({ filename: f, status: "modified" as const }));
599
313
  } else if (changedFiles.length === 0) {
600
- // 仅当 changedFiles 为空时才获取(避免与回退逻辑重复)
601
314
  if (shouldLog(verbose, 1)) {
602
315
  console.log(
603
316
  `📥 获取 ${effectiveBaseRef}...${effectiveHeadRef} 的差异 (owner: ${owner}, repo: ${repo})`,
@@ -616,42 +329,44 @@ export class ReviewService {
616
329
  }
617
330
  }
618
331
  } else if (!isLocalMode) {
619
- // 非本地模式且无有效的 base/head
620
332
  if (shouldLog(verbose, 1)) {
621
333
  console.log(`❌ 错误: 缺少 prNumber 或 baseRef/headRef`, { prNumber, baseRef, headRef });
622
334
  }
623
335
  throw new Error("必须指定 PR 编号或者 base/head 分支");
624
336
  }
625
337
 
626
- // 0. 过滤掉 merge commit(消息以 "Merge branch" 开头的 commit)
627
- const beforeMergeFilterCount = commits.length;
628
- commits = commits.filter((c) => {
629
- const message = c.commit?.message || "";
630
- return !message.startsWith("Merge branch ");
631
- });
632
- if (beforeMergeFilterCount !== commits.length && shouldLog(verbose, 1)) {
633
- console.log(` 跳过 Merge Commits: ${beforeMergeFilterCount} -> ${commits.length} 个`);
338
+ // ── 前置过滤 ──────────────────────────────────────────
339
+
340
+ // 0. 过滤掉 merge commit
341
+ {
342
+ const before = commits.length;
343
+ commits = commits.filter((c) => {
344
+ const message = c.commit?.message || "";
345
+ return !message.startsWith("Merge ");
346
+ });
347
+ if (before !== commits.length && shouldLog(verbose, 1)) {
348
+ console.log(` 跳过 Merge Commits: ${before} -> ${commits.length} 个`);
349
+ }
634
350
  }
635
351
 
636
352
  // 1. 按指定的 files 过滤
637
353
  if (files && files.length > 0) {
638
- const beforeFilesCount = changedFiles.length;
354
+ const before = changedFiles.length;
639
355
  changedFiles = changedFiles.filter((f) => files.includes(f.filename || ""));
640
356
  if (shouldLog(verbose, 1)) {
641
- console.log(` Files 过滤文件: ${beforeFilesCount} -> ${changedFiles.length} 个文件`);
357
+ console.log(` Files 过滤文件: ${before} -> ${changedFiles.length} 个文件`);
642
358
  }
643
359
  }
644
360
 
645
361
  // 2. 按指定的 commits 过滤
646
362
  if (filterCommits && filterCommits.length > 0) {
647
- const beforeCommitsCount = commits.length;
363
+ const beforeCommits = commits.length;
648
364
  commits = commits.filter((c) => filterCommits.some((fc) => fc && c.sha?.startsWith(fc)));
649
365
  if (shouldLog(verbose, 1)) {
650
- console.log(` Commits 过滤: ${beforeCommitsCount} -> ${commits.length} 个`);
366
+ console.log(` Commits 过滤: ${beforeCommits} -> ${commits.length} 个`);
651
367
  }
652
368
 
653
- // 同时也过滤变更文件,仅保留属于这些 commit 的文件
654
- const beforeFilesCount = changedFiles.length;
369
+ const beforeFiles = changedFiles.length;
655
370
  const commitFilenames = new Set<string>();
656
371
  for (const commit of commits) {
657
372
  if (!commit.sha) continue;
@@ -660,169 +375,70 @@ export class ReviewService {
660
375
  }
661
376
  changedFiles = changedFiles.filter((f) => commitFilenames.has(f.filename || ""));
662
377
  if (shouldLog(verbose, 1)) {
663
- console.log(` 按 Commits 过滤文件: ${beforeFilesCount} -> ${changedFiles.length} 个文件`);
378
+ console.log(` 按 Commits 过滤文件: ${beforeFiles} -> ${changedFiles.length} 个文件`);
664
379
  }
665
380
  }
666
381
 
667
- // 3. 使用 includes 过滤文件和 commits
382
+ // 3. 使用 includes 过滤文件和 commits(支持 added|/modified|/deleted| 前缀语法)
668
383
  if (includes && includes.length > 0) {
669
- const beforeFilesCount = changedFiles.length;
670
- const filenames = changedFiles.map((file) => file.filename || "");
671
- const matchedFilenames = micromatch(filenames, includes);
672
- changedFiles = changedFiles.filter((file) => matchedFilenames.includes(file.filename || ""));
384
+ const beforeFiles = changedFiles.length;
385
+ changedFiles = filterFilesByIncludes(changedFiles, includes);
673
386
  if (shouldLog(verbose, 1)) {
674
- console.log(` Includes 过滤文件: ${beforeFilesCount} -> ${changedFiles.length} 个文件`);
387
+ console.log(` Includes 过滤文件: ${beforeFiles} -> ${changedFiles.length} 个文件`);
675
388
  }
676
389
 
677
- const beforeCommitsCount = commits.length;
390
+ const globs = extractGlobsFromIncludes(includes);
391
+ const beforeCommits = commits.length;
678
392
  const filteredCommits: PullRequestCommit[] = [];
679
393
  for (const commit of commits) {
680
394
  if (!commit.sha) continue;
681
395
  const commitFiles = await this.getFilesForCommit(owner, repo, commit.sha, prNumber);
682
- if (micromatch.some(commitFiles, includes)) {
396
+ if (micromatch.some(commitFiles, globs)) {
683
397
  filteredCommits.push(commit);
684
398
  }
685
399
  }
686
400
  commits = filteredCommits;
687
401
  if (shouldLog(verbose, 1)) {
688
- console.log(` Includes 过滤 Commits: ${beforeCommitsCount} -> ${commits.length} 个`);
689
- }
690
- }
691
-
692
- // 只按扩展名过滤规则,includes 和 override 在 LLM 审查后处理
693
- const applicableSpecs = this.reviewSpecService.filterApplicableSpecs(specs, changedFiles);
694
- if (shouldLog(verbose, 1)) {
695
- console.log(` 适用的规则文件: ${applicableSpecs.length}`);
696
- }
697
-
698
- if (applicableSpecs.length === 0 || changedFiles.length === 0) {
699
- if (shouldLog(verbose, 1)) {
700
- console.log("✅ 没有需要审查的文件或规则");
701
- }
702
-
703
- // 获取上一次的审查结果以计算正确的轮次
704
- let existingResult: ReviewResult | null = null;
705
- if (ci && prNumber) {
706
- existingResult = await this.getExistingReviewResult(owner, repo, prNumber);
707
- }
708
- const currentRound = (existingResult?.round ?? 0) + 1;
709
-
710
- // 即使没有适用的规则,也为每个变更文件生成摘要
711
- const summary: FileSummary[] = changedFiles
712
- .filter((f) => f.filename && f.status !== "deleted")
713
- .map((f) => ({
714
- file: f.filename!,
715
- resolved: 0,
716
- unresolved: 0,
717
- summary: applicableSpecs.length === 0 ? "无适用的审查规则" : "已跳过",
718
- }));
719
- const prInfo =
720
- context.generateDescription && llmMode
721
- ? await this.generatePrDescription(commits, changedFiles, llmMode, undefined, verbose)
722
- : await this.buildFallbackDescription(commits, changedFiles);
723
- const result: ReviewResult = {
724
- success: true,
725
- title: prInfo.title,
726
- description: prInfo.description,
727
- issues: [],
728
- summary,
729
- round: currentRound,
730
- };
731
-
732
- // CI 模式下也需要发送 review 评论
733
- if (ci && prNumber && !dryRun) {
734
- if (shouldLog(verbose, 1)) {
735
- console.log(`💬 提交 PR 评论...`);
736
- }
737
- await this.postOrUpdateReviewComment(owner, repo, prNumber, result, verbose, autoApprove);
738
- if (shouldLog(verbose, 1)) {
739
- console.log(`✅ 评论已提交`);
740
- }
741
- }
742
-
743
- return result;
744
- }
745
-
746
- const headSha = pr?.head?.sha || headRef || "HEAD";
747
- const fileContents = await this.getFileContents(
748
- owner,
749
- repo,
750
- changedFiles,
751
- commits,
752
- headSha,
753
- prNumber,
754
- verbose,
755
- isLocalMode,
756
- );
757
- if (!llmMode) {
758
- throw new Error("必须指定 LLM 类型");
759
- }
760
-
761
- // 获取上一次的审查结果(用于提示词优化)
762
- let existingResult: ReviewResult | null = null;
763
- if (ci && prNumber) {
764
- existingResult = await this.getExistingReviewResult(owner, repo, prNumber);
765
- if (existingResult && shouldLog(verbose, 1)) {
766
- console.log(`📋 获取到上一次审查结果,包含 ${existingResult.issues.length} 个问题`);
402
+ console.log(` Includes 过滤 Commits: ${beforeCommits} -> ${commits.length} 个`);
767
403
  }
768
404
  }
769
- // 计算当前轮次:基于已有结果的轮次 + 1
770
- const currentRound = (existingResult?.round ?? 0) + 1;
771
- if (shouldLog(verbose, 1)) {
772
- console.log(`🔄 当前审查轮次: ${currentRound}`);
773
- }
774
-
775
- const reviewPrompt = await this.buildReviewPrompt(
776
- specs,
777
- changedFiles,
778
- fileContents,
779
- commits,
780
- existingResult,
781
- );
782
- const result = await this.runLLMReview(llmMode, reviewPrompt, {
783
- verbose,
784
- concurrency: context.concurrency,
785
- timeout: context.timeout,
786
- retries: context.retries,
787
- retryDelay: context.retryDelay,
788
- });
789
- // 填充 PR 功能描述和标题
790
- const prInfo = context.generateDescription
791
- ? await this.generatePrDescription(commits, changedFiles, llmMode, fileContents, verbose)
792
- : await this.buildFallbackDescription(commits, changedFiles);
793
- result.title = prInfo.title;
794
- result.description = prInfo.description;
795
- // 更新 round 并为新 issues 赋值 round
796
- result.round = currentRound;
797
- result.issues = result.issues.map((issue) => ({ ...issue, round: currentRound }));
798
405
 
799
- if (shouldLog(verbose, 1)) {
800
- console.log(`📝 LLM 审查完成,发现 ${result.issues.length} 个问题`);
801
- }
406
+ const headSha = prModel ? await prModel.getHeadSha() : headRef || "HEAD";
407
+ return { prModel, commits, changedFiles, headSha, isLocalMode, isDirectFileMode };
408
+ }
802
409
 
803
- result.issues = await this.fillIssueCode(result.issues, fileContents);
410
+ /**
411
+ * LLM 审查后的 issue 过滤管道:
412
+ * includes → 规则存在性 → overrides → 变更行过滤 → 格式化
413
+ */
414
+ protected filterNewIssues(
415
+ issues: ReviewResult["issues"],
416
+ specs: any[],
417
+ applicableSpecs: any[],
418
+ opts: {
419
+ commits: PullRequestCommit[];
420
+ fileContents: any;
421
+ changedFiles: ChangedFile[];
422
+ isDirectFileMode: boolean;
423
+ context: ReviewContext;
424
+ },
425
+ ): ReviewResult["issues"] {
426
+ const { commits, fileContents, changedFiles, isDirectFileMode, context } = opts;
427
+ const { verbose } = context;
804
428
 
805
- // LLM 审查后应用 includes 和 override 过滤
806
- let filteredIssues = this.reviewSpecService.filterIssuesByIncludes(
807
- result.issues,
808
- applicableSpecs,
809
- );
429
+ let filtered = this.reviewSpecService.filterIssuesByIncludes(issues, applicableSpecs);
810
430
  if (shouldLog(verbose, 1)) {
811
- console.log(` 应用 includes 过滤后: ${filteredIssues.length} 个问题`);
431
+ console.log(` 应用 includes 过滤后: ${filtered.length} 个问题`);
812
432
  }
813
433
 
814
- filteredIssues = this.reviewSpecService.filterIssuesByRuleExistence(filteredIssues, specs);
434
+ filtered = this.reviewSpecService.filterIssuesByRuleExistence(filtered, specs);
815
435
  if (shouldLog(verbose, 1)) {
816
- console.log(` 应用规则存在性过滤后: ${filteredIssues.length} 个问题`);
436
+ console.log(` 应用规则存在性过滤后: ${filtered.length} 个问题`);
817
437
  }
818
438
 
819
- filteredIssues = this.reviewSpecService.filterIssuesByOverrides(
820
- filteredIssues,
821
- applicableSpecs,
822
- verbose,
823
- );
439
+ filtered = this.reviewSpecService.filterIssuesByOverrides(filtered, applicableSpecs, verbose);
824
440
 
825
- // 过滤掉不属于本次 PR commits 的问题(排除 merge commit 引入的代码)
441
+ // 变更行过滤
826
442
  if (shouldLog(verbose, 3)) {
827
443
  console.log(` 🔍 变更行过滤条件检查:`);
828
444
  console.log(
@@ -831,16 +447,11 @@ export class ReviewService {
831
447
  }
832
448
  if (!context.showAll && !isDirectFileMode && commits.length > 0) {
833
449
  if (shouldLog(verbose, 2)) {
834
- console.log(` 🔍 开始变更行过滤,当前 ${filteredIssues.length} 个问题`);
450
+ console.log(` 🔍 开始变更行过滤,当前 ${filtered.length} 个问题`);
835
451
  }
836
- filteredIssues = this.filterIssuesByValidCommits(
837
- filteredIssues,
838
- commits,
839
- fileContents,
840
- verbose,
841
- );
452
+ filtered = this.filterIssuesByValidCommits(filtered, commits, fileContents, verbose);
842
453
  if (shouldLog(verbose, 2)) {
843
- console.log(` 🔍 变更行过滤完成,剩余 ${filteredIssues.length} 个问题`);
454
+ console.log(` 🔍 变更行过滤完成,剩余 ${filtered.length} 个问题`);
844
455
  }
845
456
  } else if (shouldLog(verbose, 1)) {
846
457
  console.log(
@@ -848,100 +459,123 @@ export class ReviewService {
848
459
  );
849
460
  }
850
461
 
851
- filteredIssues = this.reviewSpecService.formatIssues(filteredIssues, {
852
- specs,
853
- changedFiles,
854
- });
462
+ filtered = this.reviewSpecService.formatIssues(filtered, { specs, changedFiles });
855
463
  if (shouldLog(verbose, 1)) {
856
- console.log(` 应用格式化后: ${filteredIssues.length} 个问题`);
464
+ console.log(` 应用格式化后: ${filtered.length} 个问题`);
857
465
  }
858
466
 
859
- result.issues = filteredIssues;
860
- if (shouldLog(verbose, 1)) {
861
- console.log(`📝 最终发现 ${result.issues.length} 个问题`);
862
- }
467
+ return filtered;
468
+ }
863
469
 
864
- let existingIssues: ReviewIssue[] = [];
865
- let allIssues = result.issues;
470
+ /**
471
+ * 构建最终的 ReviewResultModel:处理历史 issue 合并或首次创建
472
+ */
473
+ protected async buildFinalModel(
474
+ context: ReviewContext,
475
+ result: ReviewResult,
476
+ source: {
477
+ prModel?: PullRequestModel;
478
+ commits: PullRequestCommit[];
479
+ headSha: string;
480
+ specs: any[];
481
+ fileContents: any;
482
+ },
483
+ existingResultModel: ReviewResultModel | null,
484
+ ): Promise<ReviewResultModel> {
485
+ const { prModel, commits, headSha, specs, fileContents } = source;
486
+ const { verbose, ci } = context;
866
487
 
867
- if (ci && prNumber && existingResult) {
868
- existingIssues = existingResult.issues ?? [];
869
- if (existingIssues.length > 0) {
870
- if (shouldLog(verbose, 1)) {
871
- console.log(`📋 已有评论中存在 ${existingIssues.length} 个问题`);
872
- }
488
+ if (ci && prModel && existingResultModel && existingResultModel.issues.length > 0) {
489
+ if (shouldLog(verbose, 1)) {
490
+ console.log(`📋 已有评论中存在 ${existingResultModel.issues.length} 个问题`);
491
+ }
873
492
 
874
- // 先同步最新的 resolved 状态,确保后续 invalidate/verify 能正确跳过已解决的问题
875
- await this.syncResolvedComments(owner, repo, prNumber, existingResult);
876
-
877
- // 如果文件有变更,将该文件的历史问题标记为无效
878
- // 简化策略:避免复杂的行号更新逻辑
879
- const reviewConf = this.config.getPluginConfig<ReviewConfig>("review");
880
- if (
881
- reviewConf.invalidateChangedFiles !== "off" &&
882
- reviewConf.invalidateChangedFiles !== "keep"
883
- ) {
884
- existingIssues = await this.invalidateIssuesForChangedFiles(
885
- existingIssues,
886
- pr?.head?.sha,
887
- owner,
888
- repo,
889
- verbose,
890
- );
891
- }
493
+ // 预处理历史 issues:同步 resolved 状态
494
+ await existingResultModel.syncResolved();
892
495
 
893
- // 验证历史问题是否已修复
894
- if (context.verifyFixes) {
895
- existingIssues = await this.verifyAndUpdateIssues(context, existingIssues, commits, {
896
- specs,
897
- fileContents,
898
- });
899
- } else {
900
- if (shouldLog(verbose, 1)) {
901
- console.log(` ⏭️ 跳过历史问题验证 (verifyFixes=false)`);
902
- }
903
- }
496
+ // 如果文件有变更,将该文件的历史问题标记为无效
497
+ const reviewConf = this.config.getPluginConfig<ReviewConfig>("review");
498
+ if (
499
+ reviewConf.invalidateChangedFiles !== "off" &&
500
+ reviewConf.invalidateChangedFiles !== "keep"
501
+ ) {
502
+ await existingResultModel.invalidateChangedFiles(headSha, verbose);
503
+ }
904
504
 
905
- const { filteredIssues: newIssues, skippedCount } = this.filterDuplicateIssues(
906
- result.issues,
907
- existingIssues,
505
+ // 验证历史问题是否已修复
506
+ if (context.verifyFixes) {
507
+ existingResultModel.issues = await this.issueFilter.verifyAndUpdateIssues(
508
+ context,
509
+ existingResultModel.issues,
510
+ commits,
511
+ { specs, fileContents },
512
+ prModel,
908
513
  );
909
- if (skippedCount > 0 && shouldLog(verbose, 1)) {
910
- console.log(` 跳过 ${skippedCount} 个重复问题,新增 ${newIssues.length} 个问题`);
514
+ } else {
515
+ if (shouldLog(verbose, 1)) {
516
+ console.log(` ⏭️ 跳过历史问题验证 (verifyFixes=false)`);
911
517
  }
912
- result.issues = newIssues;
913
- allIssues = [...existingIssues, ...newIssues];
914
518
  }
915
- }
916
519
 
917
- // 统一填充所有问题的 author 信息(仅在有 commits 时)
918
- if (commits.length > 0) {
919
- allIssues = await this.fillIssueAuthors(allIssues, commits, owner, repo, verbose);
520
+ // 去重:与所有历史 issues 去重
521
+ const { filteredIssues: newIssues, skippedCount } = this.filterDuplicateIssues(
522
+ result.issues,
523
+ existingResultModel.issues,
524
+ );
525
+ if (skippedCount > 0 && shouldLog(verbose, 1)) {
526
+ console.log(` 跳过 ${skippedCount} 个重复问题,新增 ${newIssues.length} 个问题`);
527
+ }
528
+ result.issues = newIssues;
529
+ result.headSha = headSha;
530
+
531
+ // 自动 round 递增 + issues 合并
532
+ return existingResultModel.nextRound(result);
920
533
  }
921
534
 
922
- // 第一次提交报告:审查问题完成
923
- if (prNumber && !dryRun) {
924
- if (shouldLog(verbose, 1)) {
925
- console.log(`💬 提交 PR 评论 (代码审查完成)...`);
926
- }
535
+ // 首次审查或无历史结果
536
+ result.round = 1;
537
+ result.headSha = headSha;
538
+ result.issues = result.issues.map((issue) => ({ ...issue, round: 1 }));
539
+ return prModel
540
+ ? ReviewResultModel.create(prModel, result, this.resultModelDeps)
541
+ : ReviewResultModel.createLocal(result, this.resultModelDeps);
542
+ }
543
+
544
+ /**
545
+ * 统一的保存 + 输出逻辑
546
+ */
547
+ protected async saveAndOutput(
548
+ context: ReviewContext,
549
+ finalModel: ReviewResultModel,
550
+ commits: PullRequestCommit[],
551
+ ): Promise<void> {
552
+ const {
553
+ owner,
554
+ repo,
555
+ prNumber,
556
+ baseRef,
557
+ headRef,
558
+ verbose,
559
+ ci,
560
+ dryRun,
561
+ llmMode,
562
+ includes,
563
+ autoApprove,
564
+ } = context;
565
+ const prModel = finalModel.pr.number > 0 ? finalModel.pr : undefined;
927
566
 
928
- await this.postOrUpdateReviewComment(
567
+ // 填充 author 信息
568
+ if (commits.length > 0) {
569
+ finalModel.issues = await this.fillIssueAuthors(
570
+ finalModel.issues,
571
+ commits,
929
572
  owner,
930
573
  repo,
931
- prNumber,
932
- {
933
- ...result,
934
- issues: allIssues,
935
- },
936
574
  verbose,
937
- autoApprove,
938
575
  );
939
- if (shouldLog(verbose, 1)) {
940
- console.log(`✅ 评论已提交`);
941
- }
942
576
  }
943
577
 
944
- // 如果启用了删除代码影响分析
578
+ // 删除代码影响分析(在 save 之前完成,避免多次 save 产生重复的 Round 评论)
945
579
  if (context.analyzeDeletions && llmMode) {
946
580
  const deletionImpact = await this.deletionImpactService.analyzeDeletionImpact(
947
581
  {
@@ -956,43 +590,31 @@ export class ReviewService {
956
590
  llmMode,
957
591
  verbose,
958
592
  );
959
- result.deletionImpact = deletionImpact;
593
+ finalModel.update({ deletionImpact });
594
+ }
960
595
 
961
- // 第二次更新报告:删除代码分析完成
962
- if (prNumber && !dryRun) {
963
- if (shouldLog(verbose, 1)) {
964
- console.log(`💬 更新 PR 评论 (删除代码分析完成)...`);
965
- }
966
- await this.postOrUpdateReviewComment(
967
- owner,
968
- repo,
969
- prNumber,
970
- {
971
- ...result,
972
- issues: allIssues,
973
- },
974
- verbose,
975
- );
976
- if (shouldLog(verbose, 1)) {
977
- console.log(`✅ 评论已更新`);
978
- }
596
+ // 统一提交报告(只调用一次 save,避免重复创建 PR Review)
597
+ if (prModel && !dryRun) {
598
+ if (shouldLog(verbose, 1)) {
599
+ console.log(`💬 提交 PR 评论...`);
600
+ }
601
+ await finalModel.save({ verbose, autoApprove, skipSync: true });
602
+ if (shouldLog(verbose, 1)) {
603
+ console.log(`✅ 评论已提交`);
979
604
  }
980
605
  }
981
606
 
982
- const reviewComment = this.formatReviewComment(
983
- { ...result, issues: allIssues },
984
- { prNumber, outputFormat: context.outputFormat, ci },
985
- );
986
-
987
- // 终端输出(根据 outputFormat 或智能选择)
607
+ // 终端输出
608
+ const reviewComment = finalModel.formatComment({
609
+ prNumber,
610
+ outputFormat: context.outputFormat,
611
+ ci,
612
+ });
988
613
  console.log(MarkdownFormatter.clearReviewData(reviewComment, "<hidden>"));
989
-
990
- return result;
991
614
  }
992
615
 
993
616
  /**
994
617
  * 仅收集 review 状态模式(用于 PR 关闭或 --flush 指令)
995
- * 从现有的 AI review 评论中读取问题状态,同步已解决/无效状态,输出统计信息
996
618
  */
997
619
  protected async executeCollectOnly(context: ReviewContext): Promise<ReviewResult> {
998
620
  const { owner, repo, prNumber, verbose, ci, dryRun, autoApprove } = context;
@@ -1005,9 +627,11 @@ export class ReviewService {
1005
627
  throw new Error("collectOnly 模式必须指定 PR 编号");
1006
628
  }
1007
629
 
630
+ const prModel = new PullRequestModel(this.gitProvider, owner, repo, prNumber);
631
+
1008
632
  // 1. 从现有的 AI review 评论中读取问题
1009
- const existingResult = await this.getExistingReviewResult(owner, repo, prNumber);
1010
- if (!existingResult) {
633
+ const resultModel = await ReviewResultModel.loadFromPr(prModel, this.resultModelDeps);
634
+ if (!resultModel) {
1011
635
  console.log(`ℹ️ PR #${prNumber} 没有找到 AI review 评论`);
1012
636
  return {
1013
637
  success: true,
@@ -1019,13 +643,13 @@ export class ReviewService {
1019
643
  }
1020
644
 
1021
645
  if (shouldLog(verbose, 1)) {
1022
- console.log(`📋 找到 ${existingResult.issues.length} 个历史问题`);
646
+ console.log(`📋 找到 ${resultModel.issues.length} 个历史问题`);
1023
647
  }
1024
648
 
1025
649
  // 2. 获取 commits 并填充 author 信息
1026
- const commits = await this.gitProvider.getPullRequestCommits(owner, repo, prNumber);
1027
- existingResult.issues = await this.fillIssueAuthors(
1028
- existingResult.issues,
650
+ const commits = await prModel.getCommits();
651
+ resultModel.issues = await this.fillIssueAuthors(
652
+ resultModel.issues,
1029
653
  commits,
1030
654
  owner,
1031
655
  repo,
@@ -1033,25 +657,26 @@ export class ReviewService {
1033
657
  );
1034
658
 
1035
659
  // 3. 同步已解决的评论状态
1036
- await this.syncResolvedComments(owner, repo, prNumber, existingResult);
660
+ await resultModel.syncResolved();
1037
661
 
1038
662
  // 4. 同步评论 reactions(👍/👎/☹️)
1039
- await this.syncReactionsToIssues(owner, repo, prNumber, existingResult, verbose);
663
+ await resultModel.syncReactions(verbose);
1040
664
 
1041
665
  // 5. LLM 验证历史问题是否已修复
1042
666
  try {
1043
- existingResult.issues = await this.verifyAndUpdateIssues(
667
+ resultModel.issues = await this.issueFilter.verifyAndUpdateIssues(
1044
668
  context,
1045
- existingResult.issues,
669
+ resultModel.issues,
1046
670
  commits,
671
+ undefined,
672
+ prModel,
1047
673
  );
1048
674
  } catch (error) {
1049
675
  console.warn("⚠️ LLM 验证修复状态失败,跳过:", error);
1050
676
  }
1051
677
 
1052
678
  // 6. 统计问题状态并设置到 result
1053
- const stats = this.calculateIssueStats(existingResult.issues);
1054
- existingResult.stats = stats;
679
+ const stats = resultModel.updateStats();
1055
680
 
1056
681
  // 7. 输出统计信息
1057
682
  console.log(this.reviewReportService.formatStatsTerminal(stats, prNumber));
@@ -1061,134 +686,13 @@ export class ReviewService {
1061
686
  if (shouldLog(verbose, 1)) {
1062
687
  console.log(`💬 更新 PR 评论...`);
1063
688
  }
1064
- await this.postOrUpdateReviewComment(
1065
- owner,
1066
- repo,
1067
- prNumber,
1068
- existingResult,
1069
- verbose,
1070
- autoApprove,
1071
- );
689
+ await resultModel.save({ verbose, autoApprove });
1072
690
  if (shouldLog(verbose, 1)) {
1073
691
  console.log(`✅ 评论已更新`);
1074
692
  }
1075
693
  }
1076
694
 
1077
- return existingResult;
1078
- }
1079
-
1080
- /**
1081
- * 加载并去重审查规则
1082
- */
1083
- protected async loadSpecs(specSources: string[], verbose?: VerboseLevel): Promise<ReviewSpec[]> {
1084
- if (shouldLog(verbose, 1)) {
1085
- console.log(`📂 解析规则来源: ${specSources.length} 个`);
1086
- }
1087
- const specDirs = await this.reviewSpecService.resolveSpecSources(specSources);
1088
- if (shouldLog(verbose, 2)) {
1089
- console.log(` 解析到 ${specDirs.length} 个规则目录`, specDirs);
1090
- }
1091
-
1092
- let specs: ReviewSpec[] = [];
1093
- for (const specDir of specDirs) {
1094
- const dirSpecs = await this.reviewSpecService.loadReviewSpecs(specDir);
1095
- specs.push(...dirSpecs);
1096
- }
1097
- if (shouldLog(verbose, 1)) {
1098
- console.log(` 找到 ${specs.length} 个规则文件`);
1099
- }
1100
-
1101
- const beforeDedup = specs.reduce((sum, s) => sum + s.rules.length, 0);
1102
- specs = this.reviewSpecService.deduplicateSpecs(specs);
1103
- const afterDedup = specs.reduce((sum, s) => sum + s.rules.length, 0);
1104
- if (beforeDedup !== afterDedup && shouldLog(verbose, 1)) {
1105
- console.log(` 去重规则: ${beforeDedup} -> ${afterDedup} 条`);
1106
- }
1107
-
1108
- return specs;
1109
- }
1110
-
1111
- /**
1112
- * LLM 验证历史问题是否已修复
1113
- * 如果传入 preloaded(specs/fileContents),直接使用;否则从 PR 获取
1114
- */
1115
- protected async verifyAndUpdateIssues(
1116
- context: ReviewContext,
1117
- issues: ReviewIssue[],
1118
- commits: PullRequestCommit[],
1119
- preloaded?: { specs: ReviewSpec[]; fileContents: FileContentsMap },
1120
- ): Promise<ReviewIssue[]> {
1121
- const { owner, repo, prNumber, llmMode, specSources, verbose } = context;
1122
- const unfixedIssues = issues.filter((i) => i.valid !== "false" && !i.fixed);
1123
-
1124
- if (unfixedIssues.length === 0) {
1125
- return issues;
1126
- }
1127
-
1128
- if (!llmMode) {
1129
- if (shouldLog(verbose, 1)) {
1130
- console.log(` ⏭️ 跳过 LLM 验证(缺少 llmMode)`);
1131
- }
1132
- return issues;
1133
- }
1134
-
1135
- if (!preloaded && (!specSources?.length || !prNumber)) {
1136
- if (shouldLog(verbose, 1)) {
1137
- console.log(` ⏭️ 跳过 LLM 验证(缺少 specSources 或 prNumber)`);
1138
- }
1139
- return issues;
1140
- }
1141
-
1142
- if (shouldLog(verbose, 1)) {
1143
- console.log(`\n🔍 开始 LLM 验证 ${unfixedIssues.length} 个未修复问题...`);
1144
- }
1145
-
1146
- let specs: ReviewSpec[];
1147
- let fileContents: FileContentsMap;
1148
-
1149
- if (preloaded) {
1150
- specs = preloaded.specs;
1151
- fileContents = preloaded.fileContents;
1152
- } else {
1153
- const pr = await this.gitProvider.getPullRequest(owner, repo, prNumber!);
1154
- const changedFiles = await this.gitProvider.getPullRequestFiles(owner, repo, prNumber!);
1155
- const headSha = pr?.head?.sha || "HEAD";
1156
- specs = await this.loadSpecs(specSources, verbose);
1157
- fileContents = await this.getFileContents(
1158
- owner,
1159
- repo,
1160
- changedFiles,
1161
- commits,
1162
- headSha,
1163
- prNumber!,
1164
- verbose,
1165
- );
1166
- }
1167
-
1168
- return await this.issueVerifyService.verifyIssueFixes(
1169
- issues,
1170
- fileContents,
1171
- specs,
1172
- llmMode,
1173
- verbose,
1174
- context.verifyConcurrency,
1175
- );
1176
- }
1177
-
1178
- /**
1179
- * 计算问题状态统计
1180
- */
1181
- protected calculateIssueStats(issues: ReviewIssue[]): ReviewStats {
1182
- const total = issues.length;
1183
- const validIssue = issues.filter((i) => i.valid !== "false");
1184
- const validTotal = validIssue.length;
1185
- const fixed = validIssue.filter((i) => i.fixed).length;
1186
- const resolved = validIssue.filter((i) => i.resolved).length;
1187
- const invalid = total - validTotal;
1188
- const pending = validTotal - fixed - resolved;
1189
- const fixRate = validTotal > 0 ? Math.round((fixed / validTotal) * 100 * 10) / 10 : 0;
1190
- const resolveRate = validTotal > 0 ? Math.round((resolved / validTotal) * 100 * 10) / 10 : 0;
1191
- return { total, validTotal, fixed, resolved, invalid, pending, fixRate, resolveRate };
695
+ return resultModel.result;
1192
696
  }
1193
697
 
1194
698
  /**
@@ -1221,2006 +725,270 @@ export class ReviewService {
1221
725
  );
1222
726
 
1223
727
  // 获取 commits 和 changedFiles 用于生成描述
728
+ let prModel: PullRequestModel | undefined;
1224
729
  let commits: PullRequestCommit[] = [];
1225
730
  let changedFiles: ChangedFile[] = [];
1226
731
  if (prNumber) {
1227
- commits = await this.gitProvider.getPullRequestCommits(owner, repo, prNumber);
1228
- changedFiles = await this.gitProvider.getPullRequestFiles(owner, repo, prNumber);
732
+ prModel = new PullRequestModel(this.gitProvider, owner, repo, prNumber);
733
+ commits = await prModel.getCommits();
734
+ changedFiles = await prModel.getFiles();
1229
735
  } else if (baseRef && headRef) {
1230
736
  changedFiles = await this.getChangedFilesBetweenRefs(owner, repo, baseRef, headRef);
1231
737
  commits = await this.getCommitsBetweenRefs(baseRef, headRef);
1232
738
  }
1233
739
 
1234
- // 使用 includes 过滤文件
740
+ // 使用 includes 过滤文件(支持 added|/modified|/deleted| 前缀语法)
1235
741
  if (context.includes && context.includes.length > 0) {
1236
- const filenames = changedFiles.map((file) => file.filename || "");
1237
- const matchedFilenames = micromatch(filenames, context.includes);
1238
- changedFiles = changedFiles.filter((file) => matchedFilenames.includes(file.filename || ""));
742
+ changedFiles = filterFilesByIncludes(changedFiles, context.includes);
1239
743
  }
1240
744
 
1241
- const prInfo = context.generateDescription
745
+ const prDesc = context.generateDescription
1242
746
  ? await this.generatePrDescription(commits, changedFiles, llmMode, undefined, verbose)
1243
- : await this.buildFallbackDescription(commits, changedFiles);
747
+ : await this.buildBasicDescription(commits, changedFiles);
1244
748
  const result: ReviewResult = {
1245
749
  success: true,
1246
- title: prInfo.title,
1247
- description: prInfo.description,
750
+ title: prDesc.title,
751
+ description: prDesc.description,
1248
752
  issues: [],
1249
753
  summary: [],
1250
754
  deletionImpact,
1251
755
  round: 1,
1252
756
  };
1253
757
 
1254
- const reviewComment = this.formatReviewComment(result, {
758
+ const resultModel = prModel
759
+ ? ReviewResultModel.create(prModel, result, this.resultModelDeps)
760
+ : ReviewResultModel.createLocal(result, this.resultModelDeps);
761
+ const reviewComment = resultModel.formatComment({
1255
762
  prNumber,
1256
763
  outputFormat: context.outputFormat,
1257
764
  ci,
1258
765
  });
1259
766
 
1260
- if (ci && prNumber && !dryRun) {
767
+ if (ci && prModel && !dryRun) {
1261
768
  if (shouldLog(verbose, 1)) {
1262
769
  console.log(`💬 提交 PR 评论...`);
1263
770
  }
1264
- await this.postOrUpdateReviewComment(owner, repo, prNumber, result, verbose, autoApprove);
771
+ await resultModel.save({ verbose, autoApprove });
1265
772
  if (shouldLog(verbose, 1)) {
1266
773
  console.log(`✅ 评论已提交`);
1267
774
  }
1268
775
  }
1269
776
 
1270
- // 终端输出(根据 outputFormat 或智能选择)
1271
-
777
+ // 终端输出
1272
778
  console.log(MarkdownFormatter.clearReviewData(reviewComment, "<hidden>"));
1273
779
 
1274
780
  return result;
1275
781
  }
1276
782
 
1277
- protected async getChangedFilesBetweenRefs(
1278
- _owner: string,
1279
- _repo: string,
1280
- baseRef: string,
1281
- headRef: string,
1282
- ): Promise<ChangedFile[]> {
1283
- // 使用 getDiffBetweenRefs 获取包含 patch 的文件列表
1284
- // 这样可以正确解析变更行号,用于过滤非变更行的问题
1285
- const diffFiles = await this.gitSdk.getDiffBetweenRefs(baseRef, headRef);
1286
- const statusFiles = await this.gitSdk.getChangedFilesBetweenRefs(baseRef, headRef);
1287
-
1288
- // 合并 status 和 patch 信息
1289
- const statusMap = new Map(statusFiles.map((f) => [f.filename, f.status]));
1290
- return diffFiles.map((f) => ({
1291
- filename: f.filename,
1292
- status: statusMap.get(f.filename) || "modified",
1293
- patch: f.patch,
1294
- }));
1295
- }
1296
-
1297
- protected async getCommitsBetweenRefs(
1298
- baseRef: string,
1299
- headRef: string,
1300
- ): Promise<PullRequestCommit[]> {
1301
- const gitCommits = await this.gitSdk.getCommitsBetweenRefs(baseRef, headRef);
1302
- return gitCommits.map((c) => ({
1303
- sha: c.sha,
1304
- commit: {
1305
- message: c.message,
1306
- author: c.author,
1307
- },
1308
- }));
1309
- }
1310
-
1311
- protected async getFilesForCommit(
1312
- owner: string,
1313
- repo: string,
1314
- sha: string,
1315
- prNumber?: number,
1316
- ): Promise<string[]> {
1317
- if (prNumber) {
1318
- const commit = await this.gitProvider.getCommit(owner, repo, sha);
1319
- return commit.files?.map((f) => f.filename || "").filter(Boolean) || [];
1320
- } else {
1321
- return this.gitSdk.getFilesForCommit(sha);
1322
- }
1323
- }
1324
-
1325
783
  /**
1326
- * 获取文件内容并构建行号到 commit hash 的映射
1327
- * 返回 Map<filename, Array<[commitHash, lineCode]>>
784
+ * 处理无适用规则或无变更文件的情况
1328
785
  */
1329
- protected async getFileContents(
1330
- owner: string,
1331
- repo: string,
786
+ private async handleNoApplicableSpecs(
787
+ context: ReviewContext,
788
+ applicableSpecs: any[],
1332
789
  changedFiles: ChangedFile[],
1333
790
  commits: PullRequestCommit[],
1334
- ref: string,
1335
- prNumber?: number,
1336
- verbose?: VerboseLevel,
1337
- isLocalMode?: boolean,
1338
- ): Promise<FileContentsMap> {
1339
- const contents: FileContentsMap = new Map();
1340
- const latestCommitHash = commits[commits.length - 1]?.sha?.slice(0, 7) || "+local+";
1341
-
1342
- // 优先使用 changedFiles 中的 patch 字段(来自 PR 的整体 diff base...head)
1343
- // 这样行号是相对于最终文件的,而不是每个 commit 的父 commit
1344
- // buildLineCommitMap 遍历每个 commit 的 diff,行号可能与最终文件不一致
1345
- if (shouldLog(verbose, 1)) {
1346
- console.log(`📊 正在构建行号到变更的映射...`);
1347
- }
791
+ ): Promise<ReviewResult> {
792
+ const { ci, prNumber, verbose, dryRun, llmMode, autoApprove } = context;
1348
793
 
1349
- for (const file of changedFiles) {
1350
- if (file.filename && file.status !== "deleted") {
1351
- try {
1352
- let rawContent: string;
1353
- if (isLocalMode) {
1354
- // 本地模式:读取工作区文件的当前内容
1355
- rawContent = this.gitSdk.getWorkingFileContent(file.filename);
1356
- } else if (prNumber) {
1357
- rawContent = await this.gitProvider.getFileContent(owner, repo, file.filename, ref);
1358
- } else {
1359
- rawContent = await this.gitSdk.getFileContent(ref, file.filename);
1360
- }
1361
- const lines = rawContent.split("\n");
1362
-
1363
- // 优先使用 file.patch(PR 整体 diff),这是相对于最终文件的行号
1364
- let changedLines = parseChangedLinesFromPatch(file.patch);
1365
-
1366
- // 如果 changedLines 为空,需要判断是否应该将所有行标记为变更
1367
- // 情况1: 文件是新增的(status 为 added/A)
1368
- // 情况2: patch 为空但文件有 additions(部分 Git Provider API 可能不返回完整 patch)
1369
- const isNewFile =
1370
- file.status === "added" ||
1371
- file.status === "A" ||
1372
- (file.additions && file.additions > 0 && file.deletions === 0 && !file.patch);
1373
- if (changedLines.size === 0 && isNewFile) {
1374
- changedLines = new Set(lines.map((_, i) => i + 1));
1375
- if (shouldLog(verbose, 2)) {
1376
- console.log(
1377
- ` ℹ️ ${file.filename}: 新增文件无 patch,将所有 ${lines.length} 行标记为变更`,
1378
- );
1379
- }
1380
- }
1381
-
1382
- if (shouldLog(verbose, 3)) {
1383
- console.log(` 📄 ${file.filename}: ${lines.length} 行, ${changedLines.size} 行变更`);
1384
- console.log(` latestCommitHash: ${latestCommitHash}`);
1385
- if (changedLines.size > 0 && changedLines.size <= 20) {
1386
- console.log(
1387
- ` 变更行号: ${Array.from(changedLines)
1388
- .sort((a, b) => a - b)
1389
- .join(", ")}`,
1390
- );
1391
- } else if (changedLines.size > 20) {
1392
- console.log(` 变更行号: (共 ${changedLines.size} 行,省略详情)`);
1393
- }
1394
- if (!file.patch) {
1395
- console.log(
1396
- ` ⚠️ 该文件没有 patch 信息 (status=${file.status}, additions=${file.additions}, deletions=${file.deletions})`,
1397
- );
1398
- } else {
1399
- console.log(
1400
- ` patch 前 200 字符: ${file.patch.slice(0, 200).replace(/\n/g, "\\n")}`,
1401
- );
1402
- }
1403
- }
1404
-
1405
- const contentLines: FileContentLine[] = lines.map((line, index) => {
1406
- const lineNum = index + 1;
1407
- // 如果该行在 PR 的整体 diff 中被标记为变更,则使用最新 commit hash
1408
- const hash = changedLines.has(lineNum) ? latestCommitHash : "-------";
1409
- return [hash, line];
1410
- });
1411
- contents.set(file.filename, contentLines);
1412
- } catch {
1413
- console.warn(`警告: 无法获取文件内容: ${file.filename}`);
1414
- }
1415
- }
794
+ if (shouldLog(verbose, 1)) {
795
+ console.log("✅ 没有需要审查的文件或规则");
1416
796
  }
1417
797
 
1418
- if (shouldLog(verbose, 1)) {
1419
- console.log(`📊 映射构建完成,共 ${contents.size} 个文件`);
798
+ // 获取上一次的审查结果以计算正确的轮次
799
+ let existingResultModel: ReviewResultModel | null = null;
800
+ let prModel: PullRequestModel | undefined;
801
+ if (ci && prNumber) {
802
+ prModel = new PullRequestModel(this.gitProvider, context.owner, context.repo, prNumber);
803
+ existingResultModel = await ReviewResultModel.loadFromPr(prModel, this.resultModelDeps);
1420
804
  }
1421
- return contents;
1422
- }
805
+ const currentRound = (existingResultModel?.round ?? 0) + 1;
1423
806
 
1424
- protected async runLLMReview(
1425
- llmMode: LLMMode,
1426
- reviewPrompt: ReviewPrompt,
1427
- options: LLMReviewOptions = {},
1428
- ): Promise<ReviewResult> {
1429
- console.log(`🤖 调用 ${llmMode} 进行代码审查...`);
807
+ // 即使没有适用的规则,也为每个变更文件生成摘要
808
+ const summary: FileSummary[] = changedFiles
809
+ .filter((f) => f.filename && f.status !== "deleted")
810
+ .map((f) => ({
811
+ file: f.filename!,
812
+ resolved: 0,
813
+ unresolved: 0,
814
+ summary: applicableSpecs.length === 0 ? "无适用的审查规则" : "已跳过",
815
+ }));
816
+ const prDesc =
817
+ context.generateDescription && llmMode
818
+ ? await this.generatePrDescription(commits, changedFiles, llmMode, undefined, verbose)
819
+ : await this.buildBasicDescription(commits, changedFiles);
820
+ const result: ReviewResult = {
821
+ success: true,
822
+ title: prDesc.title,
823
+ description: prDesc.description,
824
+ issues: [],
825
+ summary,
826
+ round: currentRound,
827
+ };
1430
828
 
1431
- try {
1432
- const result = await this.callLLM(llmMode, reviewPrompt, options);
1433
- if (!result) {
1434
- throw new Error("AI 未返回有效结果");
829
+ // CI 模式下也需要发送 review 评论
830
+ if (ci && prModel && !dryRun) {
831
+ if (shouldLog(verbose, 1)) {
832
+ console.log(`💬 提交 PR 评论...`);
1435
833
  }
1436
- return {
1437
- success: true,
1438
- description: "", // 由 execute 方法填充
1439
- issues: result.issues || [],
1440
- summary: result.summary || [],
1441
- round: 1, // 由 execute 方法根据 existingResult 更新
1442
- };
1443
- } catch (error) {
1444
- if (error instanceof Error) {
1445
- console.error("LLM 调用失败:", error.message);
1446
- if (error.stack) {
1447
- console.error("堆栈信息:\n" + error.stack);
1448
- }
1449
- } else {
1450
- console.error("LLM 调用失败:", error);
834
+ const resultModel = ReviewResultModel.create(prModel, result, this.resultModelDeps);
835
+ await resultModel.save({ verbose, autoApprove });
836
+ if (shouldLog(verbose, 1)) {
837
+ console.log(`✅ 评论已提交`);
1451
838
  }
1452
- return {
1453
- success: false,
1454
- description: "",
1455
- issues: [],
1456
- summary: [],
1457
- round: 1,
1458
- };
1459
839
  }
840
+
841
+ return result;
1460
842
  }
1461
843
 
1462
844
  /**
1463
- * 根据文件过滤 specs,只返回与该文件匹配的规则
1464
- * - 如果 spec 有 includes 配置,只有当文件名匹配 includes 模式时才包含该 spec
1465
- * - 如果 spec 没有 includes 配置,则按扩展名匹配
845
+ * 检查是否有其他同名 review workflow 正在运行中
1466
846
  */
1467
- protected filterSpecsForFile(specs: ReviewSpec[], filename: string): ReviewSpec[] {
1468
- const ext = extname(filename).slice(1).toLowerCase();
1469
- if (!ext) return [];
1470
-
1471
- return specs.filter((spec) => {
1472
- // 先检查扩展名是否匹配
1473
- if (!spec.extensions.includes(ext)) {
1474
- return false;
1475
- }
847
+ private async checkDuplicateWorkflow(
848
+ prModel: PullRequestModel,
849
+ headSha: string,
850
+ verbose?: VerboseLevel,
851
+ ): Promise<ReviewResult | null> {
852
+ const ref = process.env.GITHUB_REF || process.env.GITEA_REF || "";
853
+ const prMatch = ref.match(/refs\/pull\/(\d+)/);
854
+ const currentPrNumber = prMatch ? parseInt(prMatch[1], 10) : prModel.number;
1476
855
 
1477
- // 如果有 includes 配置,检查文件名是否匹配 includes 模式
1478
- if (spec.includes.length > 0) {
1479
- return micromatch.isMatch(filename, spec.includes, { matchBase: true });
856
+ try {
857
+ const runningWorkflows = await prModel.listWorkflowRuns({
858
+ status: "in_progress",
859
+ });
860
+ const currentWorkflowName = process.env.GITHUB_WORKFLOW || process.env.GITEA_WORKFLOW;
861
+ const currentRunId = process.env.GITHUB_RUN_ID || process.env.GITEA_RUN_ID;
862
+ const duplicateReviewRuns = runningWorkflows.filter(
863
+ (w) =>
864
+ w.sha === headSha &&
865
+ w.name === currentWorkflowName &&
866
+ (!currentRunId || String(w.id) !== currentRunId),
867
+ );
868
+ if (duplicateReviewRuns.length > 0) {
869
+ if (shouldLog(verbose, 1)) {
870
+ console.log(
871
+ `⏭️ 跳过审查: 当前 PR #${currentPrNumber} 有 ${duplicateReviewRuns.length} 个同名 workflow 正在运行中`,
872
+ );
873
+ }
874
+ return {
875
+ success: true,
876
+ description: `跳过审查: PR #${currentPrNumber} 有 ${duplicateReviewRuns.length} 个同名 workflow 正在运行中,等待完成后重新审查`,
877
+ issues: [],
878
+ summary: [],
879
+ round: 1,
880
+ };
1480
881
  }
1481
-
1482
- // 没有 includes 配置,扩展名匹配即可
1483
- return true;
1484
- });
882
+ } catch (error) {
883
+ if (shouldLog(verbose, 1)) {
884
+ console.warn(
885
+ `⚠️ 无法检查重复 workflow(可能缺少 repo owner 权限),跳过此检查:`,
886
+ error instanceof Error ? error.message : error,
887
+ );
888
+ }
889
+ }
890
+ return null;
1485
891
  }
1486
892
 
1487
- /**
1488
- * 构建 systemPrompt
1489
- */
1490
- protected buildSystemPrompt(specsSection: string): string {
1491
- return `你是一个专业的代码审查专家,负责根据团队的编码规范对代码进行严格审查。
1492
-
1493
- ## 审查规范
893
+ // --- Delegation methods for backward compatibility with tests ---
1494
894
 
1495
- ${specsSection}
1496
-
1497
- ## 审查要求
1498
-
1499
- 1. **严格遵循规范**:只按照上述审查规范进行审查,不要添加规范之外的要求
1500
- 2. **精准定位问题**:每个问题必须指明具体的行号,行号从文件内容中的 "行号|" 格式获取
1501
- 3. **避免重复报告**:如果提示词中包含"上一次审查结果",请不要重复报告已存在的问题
1502
- 4. **提供可行建议**:对于每个问题,提供具体的修改建议代码
1503
-
1504
- ## 注意事项
1505
-
1506
- - 变更文件内容已在上下文中提供,无需调用读取工具
1507
- - 你可以读取项目中的其他文件以了解上下文
1508
- - 不要调用编辑工具修改文件,你的职责是审查而非修改
1509
- - 文件内容格式为 "CommitHash 行号| 代码",输出的 line 字段应对应原始行号
1510
-
1511
- ## 输出要求
1512
-
1513
- - 发现问题时:在 issues 数组中列出所有问题,每个问题包含 file、line、ruleId、specFile、reason、suggestion、severity
1514
- - 无论是否发现问题:都必须在 summary 中提供该文件的审查总结,简要说明审查结果`;
895
+ protected async fillIssueAuthors(...args: Parameters<ReviewContextBuilder["fillIssueAuthors"]>) {
896
+ return this.contextBuilder.fillIssueAuthors(...args);
1515
897
  }
1516
898
 
1517
- protected async buildReviewPrompt(
1518
- specs: ReviewSpec[],
1519
- changedFiles: ChangedFile[],
1520
- fileContents: FileContentsMap,
1521
- commits: PullRequestCommit[],
1522
- existingResult?: ReviewResult | null,
1523
- ): Promise<ReviewPrompt> {
1524
- const fileDataList = changedFiles
1525
- .filter((f) => f.status !== "deleted" && f.filename)
1526
- .map((file) => {
1527
- const filename = file.filename!;
1528
- const contentLines = fileContents.get(filename);
1529
- if (!contentLines) {
1530
- return {
1531
- filename,
1532
- file,
1533
- linesWithNumbers: "(无法获取内容)",
1534
- commitsSection: "- 无相关 commits",
1535
- };
1536
- }
1537
- const padWidth = String(contentLines.length).length;
1538
- const linesWithNumbers = contentLines
1539
- .map(([hash, line], index) => {
1540
- const lineNum = index + 1;
1541
- return `${hash} ${String(lineNum).padStart(padWidth)}| ${line}`;
1542
- })
1543
- .join("\n");
1544
- // 从 contentLines 中收集该文件相关的 commit hashes
1545
- const fileCommitHashes = new Set<string>();
1546
- for (const [hash] of contentLines) {
1547
- if (hash !== "-------") {
1548
- fileCommitHashes.add(hash);
1549
- }
1550
- }
1551
- const relatedCommits = commits.filter((c) => {
1552
- const shortHash = c.sha?.slice(0, 7) || "";
1553
- return fileCommitHashes.has(shortHash);
1554
- });
1555
- const commitsSection =
1556
- relatedCommits.length > 0
1557
- ? relatedCommits
1558
- .map((c) => `- \`${c.sha?.slice(0, 7)}\` ${c.commit?.message?.split("\n")[0]}`)
1559
- .join("\n")
1560
- : "- 无相关 commits";
1561
- return { filename, file, linesWithNumbers, commitsSection };
1562
- });
1563
-
1564
- const filePrompts: FileReviewPrompt[] = await Promise.all(
1565
- fileDataList.map(async ({ filename, file, linesWithNumbers, commitsSection }) => {
1566
- const fileDirectoryInfo = await this.getFileDirectoryInfo(filename);
1567
-
1568
- // 获取该文件上一次的审查结果
1569
- const existingFileSummary = existingResult?.summary?.find((s) => s.file === filename);
1570
- const existingFileIssues = existingResult?.issues?.filter((i) => i.file === filename) ?? [];
1571
-
1572
- let previousReviewSection = "";
1573
- if (existingFileSummary || existingFileIssues.length > 0) {
1574
- const parts: string[] = [];
1575
- if (existingFileSummary?.summary) {
1576
- parts.push(`**总结**:\n`);
1577
- parts.push(`${existingFileSummary.summary}\n`);
1578
- }
1579
- if (existingFileIssues.length > 0) {
1580
- parts.push(`**已发现的问题** (${existingFileIssues.length} 个):\n`);
1581
- for (const issue of existingFileIssues) {
1582
- const status = issue.fixed
1583
- ? "✅ 已修复"
1584
- : issue.valid === "false"
1585
- ? "❌ 无效"
1586
- : "⚠️ 待处理";
1587
- parts.push(`- [${status}] 行 ${issue.line}: ${issue.reason} (规则: ${issue.ruleId})`);
1588
- }
1589
- parts.push("");
1590
- // parts.push("请注意:不要重复报告上述已发现的问题,除非代码有新的变更导致问题复现。\n");
1591
- }
1592
- previousReviewSection = parts.join("\n");
1593
- }
899
+ protected async getFileContents(...args: Parameters<ReviewIssueFilter["getFileContents"]>) {
900
+ return this.issueFilter.getFileContents(...args);
901
+ }
1594
902
 
1595
- const userPrompt = `## ${filename} (${file.status})
903
+ protected async getFilesForCommit(...args: Parameters<ReviewIssueFilter["getFilesForCommit"]>) {
904
+ return this.issueFilter.getFilesForCommit(...args);
905
+ }
1596
906
 
1597
- ### 文件内容
907
+ protected async getChangedFilesBetweenRefs(
908
+ ...args: Parameters<ReviewIssueFilter["getChangedFilesBetweenRefs"]>
909
+ ) {
910
+ return this.issueFilter.getChangedFilesBetweenRefs(...args);
911
+ }
1598
912
 
1599
- \`\`\`
1600
- ${linesWithNumbers}
1601
- \`\`\`
913
+ protected async getCommitsBetweenRefs(
914
+ ...args: Parameters<ReviewIssueFilter["getCommitsBetweenRefs"]>
915
+ ) {
916
+ return this.issueFilter.getCommitsBetweenRefs(...args);
917
+ }
1602
918
 
1603
- ### 该文件的相关 Commits
919
+ protected filterIssuesByValidCommits(
920
+ ...args: Parameters<ReviewIssueFilter["filterIssuesByValidCommits"]>
921
+ ) {
922
+ return this.issueFilter.filterIssuesByValidCommits(...args);
923
+ }
1604
924
 
1605
- ${commitsSection}
925
+ protected filterDuplicateIssues(...args: Parameters<ReviewIssueFilter["filterDuplicateIssues"]>) {
926
+ return this.issueFilter.filterDuplicateIssues(...args);
927
+ }
1606
928
 
1607
- ### 该文件所在的目录树
929
+ protected async fillIssueCode(...args: Parameters<ReviewIssueFilter["fillIssueCode"]>) {
930
+ return this.issueFilter.fillIssueCode(...args);
931
+ }
1608
932
 
1609
- ${fileDirectoryInfo}
933
+ protected async runLLMReview(...args: Parameters<ReviewLlmProcessor["runLLMReview"]>) {
934
+ return this.llmProcessor.runLLMReview(...args);
935
+ }
1610
936
 
1611
- ### 上一次审查结果
937
+ protected async buildReviewPrompt(...args: Parameters<ReviewLlmProcessor["buildReviewPrompt"]>) {
938
+ return this.llmProcessor.buildReviewPrompt(...args);
939
+ }
1612
940
 
1613
- ${previousReviewSection}`;
941
+ protected async generatePrDescription(
942
+ ...args: Parameters<ReviewLlmProcessor["generatePrDescription"]>
943
+ ) {
944
+ return this.llmProcessor.generatePrDescription(...args);
945
+ }
1614
946
 
1615
- // 根据文件过滤 specs,只注入与当前文件匹配的规则
1616
- const fileSpecs = this.filterSpecsForFile(specs, filename);
1617
- const specsSection = this.reviewSpecService.buildSpecsSection(fileSpecs);
1618
- const systemPrompt = this.buildSystemPrompt(specsSection);
947
+ protected async buildBasicDescription(
948
+ ...args: Parameters<ReviewLlmProcessor["buildBasicDescription"]>
949
+ ) {
950
+ return this.llmProcessor.buildBasicDescription(...args);
951
+ }
1619
952
 
1620
- return { filename, systemPrompt, userPrompt };
1621
- }),
1622
- );
953
+ protected normalizeFilePaths(...args: Parameters<ReviewContextBuilder["normalizeFilePaths"]>) {
954
+ return this.contextBuilder.normalizeFilePaths(...args);
955
+ }
1623
956
 
1624
- return { filePrompts };
957
+ protected resolveAnalyzeDeletions(
958
+ ...args: Parameters<ReviewContextBuilder["resolveAnalyzeDeletions"]>
959
+ ) {
960
+ return this.contextBuilder.resolveAnalyzeDeletions(...args);
1625
961
  }
1626
962
 
1627
- protected async fillIssueCode(
1628
- issues: ReviewIssue[],
1629
- fileContents: FileContentsMap,
1630
- ): Promise<ReviewIssue[]> {
1631
- return issues.map((issue) => {
1632
- const contentLines = fileContents.get(issue.file);
1633
- if (!contentLines) {
1634
- return issue;
1635
- }
1636
- const lineRange = issue.line.split("-").map((n) => parseInt(n, 10));
1637
- const startLine = lineRange[0];
1638
- const endLine = lineRange.length > 1 ? lineRange[1] : startLine;
1639
- if (isNaN(startLine) || startLine < 1 || startLine > contentLines.length) {
1640
- return issue;
1641
- }
1642
- const codeLines = contentLines
1643
- .slice(startLine - 1, Math.min(endLine, contentLines.length))
1644
- .map(([, line]) => line);
1645
- const code = codeLines.join("\n").trim();
1646
- return { ...issue, code };
1647
- });
963
+ protected async getPrNumberFromEvent(
964
+ ...args: Parameters<ReviewContextBuilder["getPrNumberFromEvent"]>
965
+ ) {
966
+ return this.contextBuilder.getPrNumberFromEvent(...args);
1648
967
  }
1649
968
 
1650
969
  /**
1651
- * 根据 commit 填充 issue 的 author 信息
1652
- * 如果没有找到对应的 author,使用最后一次提交的人作为默认值
970
+ * 确保 Claude CLI 已安装
1653
971
  */
1654
- protected async fillIssueAuthors(
1655
- issues: ReviewIssue[],
1656
- commits: PullRequestCommit[],
1657
- _owner: string,
1658
- _repo: string,
1659
- verbose?: VerboseLevel,
1660
- ): Promise<ReviewIssue[]> {
1661
- if (shouldLog(verbose, 2)) {
1662
- console.log(`[fillIssueAuthors] issues=${issues.length}, commits=${commits.length}`);
1663
- }
1664
-
1665
- // 收集需要查找的 Git 作者信息(email 或 name)
1666
- const gitAuthorsToSearch = new Set<string>();
1667
- for (const commit of commits) {
1668
- const platformUser = commit.author || commit.committer;
1669
- if (!platformUser?.login) {
1670
- const gitAuthor = commit.commit?.author;
1671
- if (gitAuthor?.email) gitAuthorsToSearch.add(gitAuthor.email);
1672
- if (gitAuthor?.name) gitAuthorsToSearch.add(gitAuthor.name);
972
+ protected async ensureClaudeCli(ci?: boolean): Promise<void> {
973
+ try {
974
+ execSync("claude --version", { stdio: "ignore" });
975
+ } catch {
976
+ if (ci) {
977
+ throw new Error(
978
+ "Claude CLI 未安装。CI 环境请在 workflow 中预装: npm install -g @anthropic-ai/claude-code",
979
+ );
1673
980
  }
1674
- }
1675
-
1676
- // 通过 Git Provider API 查找用户,建立 email/name -> UserInfo 的映射
1677
- const gitAuthorToUserMap = new Map<string, UserInfo>();
1678
- for (const query of gitAuthorsToSearch) {
981
+ console.log("🔧 Claude CLI 未安装,正在安装...");
1679
982
  try {
1680
- const users = await this.gitProvider.searchUsers(query, 1);
1681
- if (users.length > 0 && users[0].login) {
1682
- const user: UserInfo = { id: String(users[0].id), login: users[0].login };
1683
- gitAuthorToUserMap.set(query, user);
1684
- if (shouldLog(verbose, 2)) {
1685
- console.log(`[fillIssueAuthors] found user: ${query} -> ${user.login}`);
1686
- }
1687
- }
1688
- } catch {
1689
- // 忽略搜索失败
1690
- }
1691
- }
1692
-
1693
- // 构建 commit hash 到 author 的映射
1694
- const commitAuthorMap = new Map<string, UserInfo>();
1695
- for (const commit of commits) {
1696
- // API 返回的 author/committer 可能为 null(未关联平台用户)
1697
- const platformUser = commit.author || commit.committer;
1698
- const gitAuthor = commit.commit?.author;
1699
- if (shouldLog(verbose, 2)) {
1700
- console.log(
1701
- `[fillIssueAuthors] commit: sha=${commit.sha?.slice(0, 7)}, platformUser=${platformUser?.login}, gitAuthor=${gitAuthor?.name}`,
983
+ execSync("npm install -g @anthropic-ai/claude-code", {
984
+ stdio: "inherit",
985
+ });
986
+ console.log("✅ Claude CLI 安装完成");
987
+ } catch (installError) {
988
+ throw new Error(
989
+ `Claude CLI 安装失败: ${installError instanceof Error ? installError.message : String(installError)}`,
1702
990
  );
1703
991
  }
1704
- if (commit.sha) {
1705
- const shortHash = commit.sha.slice(0, 7);
1706
- if (platformUser?.login) {
1707
- commitAuthorMap.set(shortHash, {
1708
- id: String(platformUser.id),
1709
- login: platformUser.login,
1710
- });
1711
- } else if (gitAuthor) {
1712
- // 尝试从平台用户映射中查找
1713
- const foundUser =
1714
- (gitAuthor.email && gitAuthorToUserMap.get(gitAuthor.email)) ||
1715
- (gitAuthor.name && gitAuthorToUserMap.get(gitAuthor.name));
1716
- if (foundUser) {
1717
- commitAuthorMap.set(shortHash, foundUser);
1718
- } else if (gitAuthor.name) {
1719
- // 使用 Git 原始作者信息(name 作为 login)
1720
- commitAuthorMap.set(shortHash, { id: "0", login: gitAuthor.name });
1721
- }
1722
- }
1723
- }
1724
992
  }
1725
- if (shouldLog(verbose, 2)) {
1726
- console.log(`[fillIssueAuthors] commitAuthorMap size: ${commitAuthorMap.size}`);
1727
- }
1728
-
1729
- // 获取最后一次提交的 author 作为默认值
1730
- const lastCommit = commits[commits.length - 1];
1731
- const lastPlatformUser = lastCommit?.author || lastCommit?.committer;
1732
- const lastGitAuthor = lastCommit?.commit?.author;
1733
- let defaultAuthor: UserInfo | undefined;
1734
- if (lastPlatformUser?.login) {
1735
- defaultAuthor = { id: String(lastPlatformUser.id), login: lastPlatformUser.login };
1736
- } else if (lastGitAuthor) {
1737
- // 尝试从平台用户映射中查找
1738
- const foundUser =
1739
- (lastGitAuthor.email && gitAuthorToUserMap.get(lastGitAuthor.email)) ||
1740
- (lastGitAuthor.name && gitAuthorToUserMap.get(lastGitAuthor.name));
1741
- defaultAuthor =
1742
- foundUser || (lastGitAuthor.name ? { id: "0", login: lastGitAuthor.name } : undefined);
1743
- }
1744
- if (shouldLog(verbose, 2)) {
1745
- console.log(`[fillIssueAuthors] defaultAuthor: ${JSON.stringify(defaultAuthor)}`);
1746
- }
1747
- // 为每个 issue 填充 author
1748
- return issues.map((issue) => {
1749
- // 如果 issue 已有 author,保留原值
1750
- if (issue.author) {
1751
- if (shouldLog(verbose, 2)) {
1752
- console.log(`[fillIssueAuthors] issue already has author: ${issue.author.login}`);
1753
- }
1754
- return issue;
1755
- }
1756
- // issue.commit 可能是 7 位短 hash
1757
- const shortHash = issue.commit?.slice(0, 7);
1758
- const author =
1759
- shortHash && !shortHash.includes("---") ? commitAuthorMap.get(shortHash) : undefined;
1760
- if (shouldLog(verbose, 2)) {
1761
- console.log(
1762
- `[fillIssueAuthors] issue: file=${issue.file}, commit=${issue.commit}, shortHash=${shortHash}, foundAuthor=${author?.login}, finalAuthor=${(author || defaultAuthor)?.login}`,
1763
- );
1764
- }
1765
- // 优先使用 commit 对应的 author,否则使用默认 author
1766
- return { ...issue, author: author || defaultAuthor };
1767
- });
1768
- }
1769
-
1770
- protected async getFileDirectoryInfo(filename: string): Promise<string> {
1771
- const dir = dirname(filename);
1772
- const currentFileName = filename.split("/").pop();
1773
-
1774
- if (dir === "." || dir === "") {
1775
- return "(根目录)";
1776
- }
1777
-
1778
- try {
1779
- const entries = await readdir(dir, { withFileTypes: true });
1780
-
1781
- const sortedEntries = entries.sort((a, b) => {
1782
- if (a.isDirectory() && !b.isDirectory()) return -1;
1783
- if (!a.isDirectory() && b.isDirectory()) return 1;
1784
- return a.name.localeCompare(b.name);
1785
- });
1786
-
1787
- const lines: string[] = [`📁 ${dir}/`];
1788
-
1789
- for (let i = 0; i < sortedEntries.length; i++) {
1790
- const entry = sortedEntries[i];
1791
- const isLast = i === sortedEntries.length - 1;
1792
- const isCurrent = entry.name === currentFileName;
1793
- const branch = isLast ? "└── " : "├── ";
1794
- const icon = entry.isDirectory() ? "📂" : "📄";
1795
- const marker = isCurrent ? " ← 当前文件" : "";
1796
-
1797
- lines.push(`${branch}${icon} ${entry.name}${marker}`);
1798
- }
1799
-
1800
- return lines.join("\n");
1801
- } catch {
1802
- return `📁 ${dir}/`;
1803
- }
1804
- }
1805
-
1806
- protected async callLLM(
1807
- llmMode: LLMMode,
1808
- reviewPrompt: ReviewPrompt,
1809
- options: LLMReviewOptions = {},
1810
- ): Promise<{ issues: ReviewIssue[]; summary: FileSummary[] } | null> {
1811
- const { verbose, concurrency = 5, timeout, retries = 0, retryDelay = 1000 } = options;
1812
- const fileCount = reviewPrompt.filePrompts.length;
1813
- console.log(
1814
- `📂 开始并行审查 ${fileCount} 个文件 (并发: ${concurrency}, 重试: ${retries}, 超时: ${timeout ?? "无"}ms)`,
1815
- );
1816
-
1817
- const executor = parallel({
1818
- concurrency,
1819
- timeout,
1820
- retries,
1821
- retryDelay,
1822
- onTaskStart: (taskId) => {
1823
- console.log(`🚀 开始审查: ${taskId}`);
1824
- },
1825
- onTaskComplete: (taskId, success) => {
1826
- console.log(`${success ? "✅" : "❌"} 完成审查: ${taskId}`);
1827
- },
1828
- onRetry: (taskId, attempt, error) => {
1829
- console.log(`🔄 重试 ${taskId} (第 ${attempt} 次): ${error.message}`);
1830
- },
1831
- });
1832
-
1833
- const results = await executor.map(
1834
- reviewPrompt.filePrompts,
1835
- (filePrompt) => this.reviewSingleFile(llmMode, filePrompt, verbose),
1836
- (filePrompt) => filePrompt.filename,
1837
- );
1838
-
1839
- const allIssues: ReviewIssue[] = [];
1840
- const fileSummaries: FileSummary[] = [];
1841
-
1842
- for (const result of results) {
1843
- if (result.success && result.result) {
1844
- allIssues.push(...result.result.issues);
1845
- fileSummaries.push(result.result.summary);
1846
- } else {
1847
- fileSummaries.push({
1848
- file: result.id,
1849
- resolved: 0,
1850
- unresolved: 0,
1851
- summary: `❌ 审查失败: ${result.error?.message ?? "未知错误"}`,
1852
- });
1853
- }
1854
- }
1855
-
1856
- const successCount = results.filter((r) => r.success).length;
1857
- console.log(`🔍 审查完成: ${successCount}/${fileCount} 个文件成功`);
1858
-
1859
- return {
1860
- issues: this.normalizeIssues(allIssues),
1861
- summary: fileSummaries,
1862
- };
1863
- }
1864
-
1865
- protected async reviewSingleFile(
1866
- llmMode: LLMMode,
1867
- filePrompt: FileReviewPrompt,
1868
- verbose?: VerboseLevel,
1869
- ): Promise<{ issues: ReviewIssue[]; summary: FileSummary }> {
1870
- if (shouldLog(verbose, 3)) {
1871
- console.log(
1872
- `\nsystemPrompt:\n----------------\n${filePrompt.systemPrompt}\n----------------`,
1873
- );
1874
- console.log(`\nuserPrompt:\n----------------\n${filePrompt.userPrompt}\n----------------`);
1875
- }
1876
-
1877
- const stream = this.llmProxyService.chatStream(
1878
- [
1879
- { role: "system", content: filePrompt.systemPrompt },
1880
- { role: "user", content: filePrompt.userPrompt },
1881
- ],
1882
- {
1883
- adapter: llmMode,
1884
- jsonSchema: this.llmJsonPut,
1885
- verbose,
1886
- allowedTools: [
1887
- "Read",
1888
- "Glob",
1889
- "Grep",
1890
- "WebSearch",
1891
- "TodoWrite",
1892
- "TodoRead",
1893
- "Task",
1894
- "Skill",
1895
- ],
1896
- },
1897
- );
1898
-
1899
- const streamLoggerState = createStreamLoggerState();
1900
- let fileResult: { issues?: ReviewIssue[]; summary?: string } | undefined;
1901
-
1902
- for await (const event of stream) {
1903
- if (shouldLog(verbose, 2)) {
1904
- logStreamEvent(event, streamLoggerState);
1905
- }
1906
-
1907
- if (event.type === "result") {
1908
- fileResult = event.response.structuredOutput as
1909
- | { issues?: ReviewIssue[]; summary?: string }
1910
- | undefined;
1911
- } else if (event.type === "error") {
1912
- throw new Error(event.message);
1913
- }
1914
- }
1915
-
1916
- // 在获取到问题时立即记录发现时间
1917
- const now = new Date().toISOString();
1918
- const issues = (fileResult?.issues ?? []).map((issue) => ({
1919
- ...issue,
1920
- date: issue.date ?? now,
1921
- }));
1922
-
1923
- return {
1924
- issues,
1925
- summary: {
1926
- file: filePrompt.filename,
1927
- resolved: 0,
1928
- unresolved: 0,
1929
- summary: fileResult?.summary ?? "",
1930
- },
1931
- };
1932
- }
1933
-
1934
- /**
1935
- * 规范化 issues,拆分包含逗号的行号为多个独立 issue,并添加发现时间
1936
- * 例如 "114, 122" 会被拆分成两个 issue,分别是 "114" 和 "122"
1937
- */
1938
- protected normalizeIssues(issues: ReviewIssue[]): ReviewIssue[] {
1939
- const now = new Date().toISOString();
1940
- return issues.flatMap((issue) => {
1941
- // 确保 line 是字符串(LLM 可能返回数字)
1942
- const lineStr = String(issue.line ?? "");
1943
- const baseIssue = { ...issue, line: lineStr, date: issue.date ?? now };
1944
-
1945
- if (!lineStr.includes(",")) {
1946
- return baseIssue;
1947
- }
1948
-
1949
- const lines = lineStr.split(",");
1950
-
1951
- return lines.map((linePart, index) => ({
1952
- ...baseIssue,
1953
- line: linePart.trim(),
1954
- suggestion: index === 0 ? issue.suggestion : `参考 ${issue.file}:${lines[0]}`,
1955
- }));
1956
- });
1957
- }
1958
-
1959
- /**
1960
- * 使用 AI 根据 commits、变更文件和代码内容总结 PR 实现的功能
1961
- * @returns 包含 title 和 description 的对象
1962
- */
1963
- protected async generatePrDescription(
1964
- commits: PullRequestCommit[],
1965
- changedFiles: ChangedFile[],
1966
- llmMode: LLMMode,
1967
- fileContents?: FileContentsMap,
1968
- verbose?: VerboseLevel,
1969
- ): Promise<{ title: string; description: string }> {
1970
- const commitMessages = commits
1971
- .map((c) => `- ${c.sha?.slice(0, 7)}: ${c.commit?.message?.split("\n")[0]}`)
1972
- .join("\n");
1973
- const fileChanges = changedFiles
1974
- .slice(0, 30)
1975
- .map((f) => `- ${f.filename} (${f.status})`)
1976
- .join("\n");
1977
- // 构建代码变更内容(只包含变更行,限制总长度)
1978
- let codeChangesSection = "";
1979
- if (fileContents && fileContents.size > 0) {
1980
- const codeSnippets: string[] = [];
1981
- let totalLength = 0;
1982
- const maxTotalLength = 8000; // 限制代码总长度
1983
- for (const [filename, lines] of fileContents) {
1984
- if (totalLength >= maxTotalLength) break;
1985
- // 只提取有变更的行(commitHash 不是 "-------")
1986
- const changedLines = lines
1987
- .map(([hash, code], idx) => (hash !== "-------" ? `${idx + 1}: ${code}` : null))
1988
- .filter(Boolean);
1989
- if (changedLines.length > 0) {
1990
- const snippet = `### ${filename}\n\`\`\`\n${changedLines.slice(0, 50).join("\n")}\n\`\`\``;
1991
- if (totalLength + snippet.length <= maxTotalLength) {
1992
- codeSnippets.push(snippet);
1993
- totalLength += snippet.length;
1994
- }
1995
- }
1996
- }
1997
- if (codeSnippets.length > 0) {
1998
- codeChangesSection = `\n\n## 代码变更内容\n${codeSnippets.join("\n\n")}`;
1999
- }
2000
- }
2001
- const prompt = `请根据以下 PR 的 commit 记录、文件变更和代码内容,用简洁的中文总结这个 PR 实现了什么功能。
2002
- 要求:
2003
- 1. 第一行输出 PR 标题,格式必须是: Feat xxx 或 Fix xxx 或 Refactor xxx(根据变更类型选择,整体不超过 50 个字符)
2004
- 2. 空一行后输出详细描述
2005
- 3. 描述应该简明扼要,突出核心功能点
2006
- 4. 使用 Markdown 格式
2007
- 5. 不要逐条列出 commit,而是归纳总结
2008
- 6. 重点分析代码变更的实际功能
2009
-
2010
- ## Commit 记录 (${commits.length} 个)
2011
- ${commitMessages || "无"}
2012
-
2013
- ## 文件变更 (${changedFiles.length} 个文件)
2014
- ${fileChanges || "无"}
2015
- ${changedFiles.length > 30 ? `\n... 等 ${changedFiles.length - 30} 个文件` : ""}${codeChangesSection}`;
2016
- try {
2017
- const stream = this.llmProxyService.chatStream([{ role: "user", content: prompt }], {
2018
- adapter: llmMode,
2019
- });
2020
- let content = "";
2021
- for await (const event of stream) {
2022
- if (event.type === "text") {
2023
- content += event.content;
2024
- } else if (event.type === "error") {
2025
- throw new Error(event.message);
2026
- }
2027
- }
2028
- // 解析标题和描述:第一行是标题,其余是描述
2029
- const lines = content.trim().split("\n");
2030
- const title = lines[0]?.replace(/^#+\s*/, "").trim() || "PR 更新";
2031
- const description = lines.slice(1).join("\n").trim();
2032
- return { title, description };
2033
- } catch (error) {
2034
- if (shouldLog(verbose, 1)) {
2035
- console.warn("⚠️ AI 总结 PR 功能失败,使用默认描述:", error);
2036
- }
2037
- return this.buildFallbackDescription(commits, changedFiles);
2038
- }
2039
- }
2040
-
2041
- /**
2042
- * 使用 LLM 生成 PR 标题
2043
- */
2044
- protected async generatePrTitle(
2045
- commits: PullRequestCommit[],
2046
- changedFiles: ChangedFile[],
2047
- ): Promise<string> {
2048
- const commitMessages = commits
2049
- .slice(0, 10)
2050
- .map((c) => c.commit?.message?.split("\n")[0])
2051
- .filter(Boolean)
2052
- .join("\n");
2053
- const fileChanges = changedFiles
2054
- .slice(0, 20)
2055
- .map((f) => `${f.filename} (${f.status})`)
2056
- .join("\n");
2057
- const prompt = `请根据以下 commit 记录和文件变更,生成一个简短的 PR 标题。
2058
- 要求:
2059
- 1. 格式必须是: Feat: xxx 或 Fix: xxx 或 Refactor: xxx
2060
- 2. 根据变更内容选择合适的前缀(新功能用 Feat,修复用 Fix,重构用 Refactor)
2061
- 3. xxx 部分用简短的中文描述(整体不超过 50 个字符)
2062
- 4. 只输出标题,不要加任何解释
2063
-
2064
- Commit 记录:
2065
- ${commitMessages || "无"}
2066
-
2067
- 文件变更:
2068
- ${fileChanges || "无"}`;
2069
- try {
2070
- const stream = this.llmProxyService.chatStream([{ role: "user", content: prompt }], {
2071
- adapter: "openai",
2072
- });
2073
- let title = "";
2074
- for await (const event of stream) {
2075
- if (event.type === "text") {
2076
- title += event.content;
2077
- } else if (event.type === "error") {
2078
- throw new Error(event.message);
2079
- }
2080
- }
2081
- return title.trim().slice(0, 50) || this.getFallbackTitle(commits);
2082
- } catch {
2083
- return this.getFallbackTitle(commits);
2084
- }
2085
- }
2086
-
2087
- /**
2088
- * 获取降级标题(从第一个 commit 消息)
2089
- */
2090
- protected getFallbackTitle(commits: PullRequestCommit[]): string {
2091
- const firstCommitMsg = commits[0]?.commit?.message?.split("\n")[0] || "PR 更新";
2092
- return firstCommitMsg.slice(0, 50);
2093
- }
2094
-
2095
- /**
2096
- * 构建降级描述(当 AI 总结失败时使用)
2097
- */
2098
- protected async buildFallbackDescription(
2099
- commits: PullRequestCommit[],
2100
- changedFiles: ChangedFile[],
2101
- ): Promise<{ title: string; description: string }> {
2102
- const parts: string[] = [];
2103
- // 使用 LLM 生成标题
2104
- const title = await this.generatePrTitle(commits, changedFiles);
2105
- if (commits.length > 0) {
2106
- const messages = commits
2107
- .slice(0, 5)
2108
- .map((c) => `- ${c.commit?.message?.split("\n")[0]}`)
2109
- .filter(Boolean);
2110
- if (messages.length > 0) {
2111
- parts.push(`**提交记录**: ${messages.join("; ")}`);
2112
- }
2113
- }
2114
- if (changedFiles.length > 0) {
2115
- const added = changedFiles.filter((f) => f.status === "added").length;
2116
- const modified = changedFiles.filter((f) => f.status === "modified").length;
2117
- const deleted = changedFiles.filter((f) => f.status === "deleted").length;
2118
- const stats: string[] = [];
2119
- if (added > 0) stats.push(`新增 ${added}`);
2120
- if (modified > 0) stats.push(`修改 ${modified}`);
2121
- if (deleted > 0) stats.push(`删除 ${deleted}`);
2122
- parts.push(`**文件变更**: ${changedFiles.length} 个文件 (${stats.join(", ")})`);
2123
- }
2124
- return { title, description: parts.join("\n") };
2125
- }
2126
-
2127
- protected formatReviewComment(
2128
- result: ReviewResult,
2129
- options: { prNumber?: number; outputFormat?: ReportFormat; ci?: boolean } = {},
2130
- ): string {
2131
- const { prNumber, outputFormat, ci } = options;
2132
- // 智能选择格式:如果未指定,PR 模式用 markdown,终端用 terminal
2133
- const format: ReportFormat = outputFormat || (ci && prNumber ? "markdown" : "terminal");
2134
-
2135
- if (format === "markdown") {
2136
- return this.reviewReportService.formatMarkdown(result, {
2137
- prNumber,
2138
- includeReanalysisCheckbox: true,
2139
- includeJsonData: true,
2140
- reviewCommentMarker: REVIEW_COMMENT_MARKER,
2141
- });
2142
- }
2143
-
2144
- return this.reviewReportService.format(result, format);
2145
- }
2146
-
2147
- protected async postOrUpdateReviewComment(
2148
- owner: string,
2149
- repo: string,
2150
- prNumber: number,
2151
- result: ReviewResult,
2152
- verbose?: VerboseLevel,
2153
- autoApprove?: boolean,
2154
- ): Promise<void> {
2155
- // 获取配置
2156
- const reviewConf = this.config.getPluginConfig<ReviewConfig>("review");
2157
-
2158
- // 如果配置启用且有 AI 生成的标题,只在第一轮审查时更新 PR 标题
2159
- if (reviewConf.autoUpdatePrTitle && result.title && result.round === 1) {
2160
- try {
2161
- await this.gitProvider.editPullRequest(owner, repo, prNumber, { title: result.title });
2162
- console.log(`📝 已更新 PR 标题: ${result.title}`);
2163
- } catch (error) {
2164
- console.warn("⚠️ 更新 PR 标题失败:", error);
2165
- }
2166
- }
2167
-
2168
- // 获取已解决的评论,同步 resolve 状态(在更新 review 之前)
2169
- await this.syncResolvedComments(owner, repo, prNumber, result);
2170
-
2171
- // 获取评论的 reactions,同步状态(☹️ 标记无效,👎 标记未解决)
2172
- await this.syncReactionsToIssues(owner, repo, prNumber, result, verbose);
2173
-
2174
- // 查找已有的 AI 评论(Issue Comment),可能存在多个重复评论
2175
- if (shouldLog(verbose, 2)) {
2176
- console.log(`[postOrUpdateReviewComment] owner=${owner}, repo=${repo}, prNumber=${prNumber}`);
2177
- }
2178
- const existingComments = await this.findExistingAiComments(owner, repo, prNumber, verbose);
2179
- if (shouldLog(verbose, 2)) {
2180
- console.log(
2181
- `[postOrUpdateReviewComment] found ${existingComments.length} existing AI comments`,
2182
- );
2183
- }
2184
-
2185
- // 调试:检查 issues 是否有 author
2186
- if (shouldLog(verbose, 3)) {
2187
- for (const issue of result.issues.slice(0, 3)) {
2188
- console.log(
2189
- `[postOrUpdateReviewComment] issue: file=${issue.file}, commit=${issue.commit}, author=${issue.author?.login}`,
2190
- );
2191
- }
2192
- }
2193
-
2194
- const reviewBody = this.formatReviewComment(result, {
2195
- prNumber,
2196
- outputFormat: "markdown",
2197
- ci: true,
2198
- });
2199
-
2200
- // 获取 PR 信息以获取 head commit SHA
2201
- const pr = await this.gitProvider.getPullRequest(owner, repo, prNumber);
2202
- const commitId = pr.head?.sha;
2203
-
2204
- // 1. 发布或更新主评论(使用 Issue Comment API,支持删除和更新)
2205
- try {
2206
- if (existingComments.length > 0) {
2207
- // 更新第一个 AI 评论
2208
- await this.gitProvider.updateIssueComment(owner, repo, existingComments[0].id, reviewBody);
2209
- console.log(`✅ 已更新 AI Review 评论`);
2210
- // 删除多余的重复 AI 评论
2211
- for (const duplicate of existingComments.slice(1)) {
2212
- try {
2213
- await this.gitProvider.deleteIssueComment(owner, repo, duplicate.id);
2214
- console.log(`🗑️ 已删除重复的 AI Review 评论 (id: ${duplicate.id})`);
2215
- } catch {
2216
- console.warn(`⚠️ 删除重复评论失败 (id: ${duplicate.id})`);
2217
- }
2218
- }
2219
- } else {
2220
- await this.gitProvider.createIssueComment(owner, repo, prNumber, { body: reviewBody });
2221
- console.log(`✅ 已发布 AI Review 评论`);
2222
- }
2223
- } catch (error) {
2224
- console.warn("⚠️ 发布/更新 AI Review 评论失败:", error);
2225
- }
2226
-
2227
- // 2. 发布本轮新发现的行级评论(使用 PR Review API,不删除旧的 review,保留历史)
2228
- // 如果启用 autoApprove 且所有问题已解决,使用 APPROVE event 合并发布
2229
- let lineIssues: ReviewIssue[] = [];
2230
- let comments: CreatePullReviewComment[] = [];
2231
- if (reviewConf.lineComments) {
2232
- lineIssues = result.issues.filter(
2233
- (issue) =>
2234
- issue.round === result.round &&
2235
- !issue.fixed &&
2236
- !issue.resolved &&
2237
- issue.valid !== "false",
2238
- );
2239
- comments = lineIssues
2240
- .map((issue) => this.issueToReviewComment(issue))
2241
- .filter((comment): comment is CreatePullReviewComment => comment !== null);
2242
- }
2243
-
2244
- // 计算是否需要自动批准
2245
- // 条件:启用 autoApprove 且没有待处理问题(包括从未发现问题的情况)
2246
- const stats = this.calculateIssueStats(result.issues);
2247
- const shouldAutoApprove = autoApprove && stats.pending === 0;
2248
-
2249
- if (reviewConf.lineComments) {
2250
- const lineReviewBody = this.buildLineReviewBody(lineIssues, result.round, result.issues);
2251
-
2252
- // 如果需要自动批准,追加批准信息到 body
2253
- const finalReviewBody = shouldAutoApprove
2254
- ? lineReviewBody +
2255
- `\n\n---\n\n✅ **自动批准合并**\n\n${
2256
- stats.validTotal > 0
2257
- ? `所有问题都已解决 (${stats.fixed} 已修复, ${stats.resolved} 已解决),`
2258
- : "代码审查通过,未发现问题,"
2259
- }自动批准此 PR。`
2260
- : lineReviewBody;
2261
-
2262
- const reviewEvent = shouldAutoApprove ? REVIEW_STATE.APPROVE : REVIEW_STATE.COMMENT;
2263
-
2264
- if (comments.length > 0) {
2265
- try {
2266
- await this.gitProvider.createPullReview(owner, repo, prNumber, {
2267
- event: reviewEvent,
2268
- body: finalReviewBody,
2269
- comments,
2270
- commit_id: commitId,
2271
- });
2272
- if (shouldAutoApprove) {
2273
- console.log(`✅ 已自动批准 PR #${prNumber}(所有问题已解决)`);
2274
- } else {
2275
- console.log(`✅ 已发布 ${comments.length} 条行级评论`);
2276
- }
2277
- } catch {
2278
- // 批量失败时逐条发布,跳过无法定位的评论
2279
- console.warn("⚠️ 批量发布行级评论失败,尝试逐条发布...");
2280
- let successCount = 0;
2281
- for (const comment of comments) {
2282
- try {
2283
- // 逐条发布时只用 COMMENT event,避免重复 APPROVE
2284
- await this.gitProvider.createPullReview(owner, repo, prNumber, {
2285
- event: REVIEW_STATE.COMMENT,
2286
- body: successCount === 0 ? reviewBody : undefined,
2287
- comments: [comment],
2288
- commit_id: commitId,
2289
- });
2290
- successCount++;
2291
- } catch {
2292
- console.warn(`⚠️ 跳过无法定位的评论: ${comment.path}:${comment.new_position}`);
2293
- }
2294
- }
2295
- if (successCount > 0) {
2296
- console.log(`✅ 逐条发布成功 ${successCount}/${comments.length} 条行级评论`);
2297
- // 如果需要自动批准,单独发一个 APPROVE review
2298
- if (shouldAutoApprove) {
2299
- try {
2300
- await this.gitProvider.createPullReview(owner, repo, prNumber, {
2301
- event: REVIEW_STATE.APPROVE,
2302
- body: `✅ **自动批准合并**\n\n${
2303
- stats.validTotal > 0
2304
- ? `所有问题都已解决 (${stats.fixed} 已修复, ${stats.resolved} 已解决),`
2305
- : "代码审查通过,未发现问题,"
2306
- }自动批准此 PR。`,
2307
- commit_id: commitId,
2308
- });
2309
- console.log(`✅ 已自动批准 PR #${prNumber}(所有问题已解决)`);
2310
- } catch (error) {
2311
- console.warn("⚠️ 自动批准失败:", error);
2312
- }
2313
- }
2314
- } else {
2315
- console.warn("⚠️ 所有行级评论均无法定位,已跳过");
2316
- }
2317
- }
2318
- } else {
2319
- // 本轮无新问题,仍发布 Round 状态(含上轮回顾)
2320
- try {
2321
- await this.gitProvider.createPullReview(owner, repo, prNumber, {
2322
- event: reviewEvent,
2323
- body: finalReviewBody,
2324
- comments: [],
2325
- commit_id: commitId,
2326
- });
2327
- if (shouldAutoApprove) {
2328
- console.log(`✅ 已自动批准 PR #${prNumber}(Round ${result.round},所有问题已解决)`);
2329
- } else {
2330
- console.log(`✅ 已发布 Round ${result.round} 审查状态(无新问题)`);
2331
- }
2332
- } catch (error) {
2333
- console.warn("⚠️ 发布审查状态失败:", error);
2334
- }
2335
- }
2336
- } else if (shouldAutoApprove) {
2337
- // 未启用 lineComments 但需要自动批准
2338
- try {
2339
- await this.gitProvider.createPullReview(owner, repo, prNumber, {
2340
- event: REVIEW_STATE.APPROVE,
2341
- body: `✅ **自动批准合并**\n\n${
2342
- stats.validTotal > 0
2343
- ? `所有问题都已解决 (${stats.fixed} 已修复, ${stats.resolved} 已解决),`
2344
- : "代码审查通过,未发现问题,"
2345
- }自动批准此 PR。`,
2346
- commit_id: commitId,
2347
- });
2348
- console.log(`✅ 已自动批准 PR #${prNumber}(所有问题已解决)`);
2349
- } catch (error) {
2350
- console.warn("⚠️ 自动批准失败:", error);
2351
- }
2352
- }
2353
- }
2354
-
2355
- /**
2356
- * 查找已有的所有 AI 评论(Issue Comment)
2357
- * 返回所有包含 REVIEW_COMMENT_MARKER 的评论,用于更新第一个并清理重复项
2358
- */
2359
- protected async findExistingAiComments(
2360
- owner: string,
2361
- repo: string,
2362
- prNumber: number,
2363
- verbose?: VerboseLevel,
2364
- ): Promise<{ id: number }[]> {
2365
- try {
2366
- const comments = await this.gitProvider.listIssueComments(owner, repo, prNumber);
2367
- if (shouldLog(verbose, 2)) {
2368
- console.log(
2369
- `[findExistingAiComments] listIssueComments returned ${Array.isArray(comments) ? comments.length : typeof comments} comments`,
2370
- );
2371
- if (Array.isArray(comments)) {
2372
- for (const c of comments.slice(0, 5)) {
2373
- console.log(
2374
- `[findExistingAiComments] comment id=${c.id}, body starts with: ${c.body?.slice(0, 80) ?? "(no body)"}`,
2375
- );
2376
- }
2377
- }
2378
- }
2379
- return comments
2380
- .filter((c) => c.body?.includes(REVIEW_COMMENT_MARKER) && c.id)
2381
- .map((c) => ({ id: c.id! }));
2382
- } catch (error) {
2383
- console.warn("[findExistingAiComments] error:", error);
2384
- return [];
2385
- }
2386
- }
2387
-
2388
- /**
2389
- * 从 PR 的所有 resolved review threads 中同步 resolved 状态到 result.issues
2390
- * 用户手动点击 resolve 的记录写入 resolved/resolvedBy 字段(区别于 AI 验证的 fixed/fixedBy)
2391
- * 优先通过评论 body 中的 issue key 精确匹配,回退到 path+line 匹配
2392
- */
2393
- protected async syncResolvedComments(
2394
- owner: string,
2395
- repo: string,
2396
- prNumber: number,
2397
- result: ReviewResult,
2398
- ): Promise<void> {
2399
- try {
2400
- const resolvedThreads = await this.gitProvider.listResolvedThreads(owner, repo, prNumber);
2401
- if (resolvedThreads.length === 0) {
2402
- return;
2403
- }
2404
- // 构建 issue key → issue 的映射,用于精确匹配
2405
- const issueByKey = new Map<string, ReviewResult["issues"][0]>();
2406
- for (const issue of result.issues) {
2407
- issueByKey.set(this.generateIssueKey(issue), issue);
2408
- }
2409
- const now = new Date().toISOString();
2410
- for (const thread of resolvedThreads) {
2411
- if (!thread.path) continue;
2412
- // 优先通过 issue key 精确匹配
2413
- let matchedIssue: ReviewResult["issues"][0] | undefined;
2414
- if (thread.body) {
2415
- const issueKey = this.extractIssueKeyFromBody(thread.body);
2416
- if (issueKey) {
2417
- matchedIssue = issueByKey.get(issueKey);
2418
- }
2419
- }
2420
- // 回退:path:line 匹配
2421
- if (!matchedIssue) {
2422
- matchedIssue = result.issues.find(
2423
- (issue) =>
2424
- issue.file === thread.path && this.lineMatchesPosition(issue.line, thread.line),
2425
- );
2426
- }
2427
- if (matchedIssue && !matchedIssue.resolved) {
2428
- matchedIssue.resolved = now;
2429
- if (thread.resolvedBy) {
2430
- matchedIssue.resolvedBy = {
2431
- id: thread.resolvedBy.id?.toString(),
2432
- login: thread.resolvedBy.login,
2433
- };
2434
- }
2435
- console.log(
2436
- `🟢 问题已标记为已解决: ${matchedIssue.file}:${matchedIssue.line}` +
2437
- (thread.resolvedBy?.login ? ` (by @${thread.resolvedBy.login})` : ""),
2438
- );
2439
- }
2440
- }
2441
- } catch (error) {
2442
- console.warn("⚠️ 同步已解决评论失败:", error);
2443
- }
2444
- }
2445
-
2446
- /**
2447
- * 检查 issue 的行号是否匹配评论的 position
2448
- */
2449
- protected lineMatchesPosition(issueLine: string, position?: number): boolean {
2450
- if (!position) return false;
2451
- const lines = this.reviewSpecService.parseLineRange(issueLine);
2452
- if (lines.length === 0) return false;
2453
- const startLine = lines[0];
2454
- const endLine = lines[lines.length - 1];
2455
- return position >= startLine && position <= endLine;
2456
- }
2457
-
2458
- /**
2459
- * 从旧的 AI review 评论中获取 reactions 和回复,同步到 result.issues
2460
- * - 存储所有 reactions 到 issue.reactions 字段
2461
- * - 存储评论回复到 issue.replies 字段
2462
- * - 如果评论有 ☹️ (confused) reaction,将对应的问题标记为无效
2463
- * - 如果评论有 👎 (-1) reaction,将对应的问题标记为未解决
2464
- */
2465
- protected async syncReactionsToIssues(
2466
- owner: string,
2467
- repo: string,
2468
- prNumber: number,
2469
- result: ReviewResult,
2470
- verbose?: VerboseLevel,
2471
- ): Promise<void> {
2472
- try {
2473
- const reviews = await this.gitProvider.listPullReviews(owner, repo, prNumber);
2474
- const aiReview = reviews.find((r) => r.body?.includes(REVIEW_LINE_COMMENTS_MARKER));
2475
- if (!aiReview?.id) {
2476
- if (shouldLog(verbose, 2)) {
2477
- console.log(`[syncReactionsToIssues] No AI review found`);
2478
- }
2479
- return;
2480
- }
2481
-
2482
- // 收集所有评审人
2483
- const reviewers = new Set<string>();
2484
-
2485
- // 1. 从已提交的 review 中获取评审人(排除 AI bot)
2486
- for (const review of reviews) {
2487
- if (review.user?.login && !review.body?.includes(REVIEW_LINE_COMMENTS_MARKER)) {
2488
- reviewers.add(review.user.login);
2489
- }
2490
- }
2491
- if (shouldLog(verbose, 2)) {
2492
- console.log(
2493
- `[syncReactionsToIssues] reviewers from reviews: ${Array.from(reviewers).join(", ")}`,
2494
- );
2495
- }
2496
-
2497
- // 2. 从 PR 指定的评审人中获取(包括团队成员)
2498
- try {
2499
- const pr = await this.gitProvider.getPullRequest(owner, repo, prNumber);
2500
- // 添加指定的个人评审人
2501
- for (const reviewer of pr.requested_reviewers || []) {
2502
- if (reviewer.login) {
2503
- reviewers.add(reviewer.login);
2504
- }
2505
- }
2506
- if (shouldLog(verbose, 2)) {
2507
- console.log(
2508
- `[syncReactionsToIssues] requested_reviewers: ${(pr.requested_reviewers || []).map((r) => r.login).join(", ")}`,
2509
- );
2510
- console.log(
2511
- `[syncReactionsToIssues] requested_reviewers_teams: ${JSON.stringify(pr.requested_reviewers_teams || [])}`,
2512
- );
2513
- }
2514
- // 添加指定的团队成员(需要通过 API 获取团队成员列表)
2515
- for (const team of pr.requested_reviewers_teams || []) {
2516
- if (team.id) {
2517
- try {
2518
- const members = await this.gitProvider.getTeamMembers(team.id);
2519
- if (shouldLog(verbose, 2)) {
2520
- console.log(
2521
- `[syncReactionsToIssues] team ${team.name}(${team.id}) members: ${members.map((m) => m.login).join(", ")}`,
2522
- );
2523
- }
2524
- for (const member of members) {
2525
- if (member.login) {
2526
- reviewers.add(member.login);
2527
- }
2528
- }
2529
- } catch (e) {
2530
- if (shouldLog(verbose, 2)) {
2531
- console.log(`[syncReactionsToIssues] failed to get team ${team.id} members: ${e}`);
2532
- }
2533
- }
2534
- }
2535
- }
2536
- } catch {
2537
- // 获取 PR 信息失败,继续使用已有的评审人列表
2538
- }
2539
- if (shouldLog(verbose, 2)) {
2540
- console.log(`[syncReactionsToIssues] final reviewers: ${Array.from(reviewers).join(", ")}`);
2541
- }
2542
-
2543
- // 获取该 review 的所有行级评论
2544
- const reviewComments = await this.gitProvider.listPullReviewComments(
2545
- owner,
2546
- repo,
2547
- prNumber,
2548
- aiReview.id,
2549
- );
2550
- // 构建评论 ID 到 issue 的映射,用于后续匹配回复
2551
- const commentIdToIssue = new Map<number, (typeof result.issues)[0]>();
2552
- // 遍历每个评论,获取其 reactions
2553
- for (const comment of reviewComments) {
2554
- if (!comment.id) continue;
2555
- // 找到对应的 issue
2556
- const matchedIssue = result.issues.find(
2557
- (issue) =>
2558
- issue.file === comment.path && this.lineMatchesPosition(issue.line, comment.position),
2559
- );
2560
- if (matchedIssue) {
2561
- commentIdToIssue.set(comment.id, matchedIssue);
2562
- }
2563
- try {
2564
- const reactions = await this.gitProvider.getPullReviewCommentReactions(
2565
- owner,
2566
- repo,
2567
- comment.id,
2568
- );
2569
- if (reactions.length === 0 || !matchedIssue) continue;
2570
- // 按 content 分组,收集每种 reaction 的用户列表
2571
- const reactionMap = new Map<string, string[]>();
2572
- for (const r of reactions) {
2573
- if (!r.content) continue;
2574
- const users = reactionMap.get(r.content) || [];
2575
- if (r.user?.login) {
2576
- users.push(r.user.login);
2577
- }
2578
- reactionMap.set(r.content, users);
2579
- }
2580
- // 存储到 issue.reactions
2581
- matchedIssue.reactions = Array.from(reactionMap.entries()).map(([content, users]) => ({
2582
- content,
2583
- users,
2584
- }));
2585
- // 检查是否有评审人的 ☹️ (confused) reaction,标记为无效
2586
- const confusedUsers = reactionMap.get("confused") || [];
2587
- const reviewerConfused = confusedUsers.filter((u) => reviewers.has(u));
2588
- if (reviewerConfused.length > 0 && matchedIssue.valid !== "false") {
2589
- matchedIssue.valid = "false";
2590
- console.log(
2591
- `☹️ 问题已标记为无效: ${matchedIssue.file}:${matchedIssue.line} (by 评审人: ${reviewerConfused.join(", ")})`,
2592
- );
2593
- }
2594
- // 检查是否有评审人的 👎 (-1) reaction,标记为未解决
2595
- const thumbsDownUsers = reactionMap.get("-1") || [];
2596
- const reviewerThumbsDown = thumbsDownUsers.filter((u) => reviewers.has(u));
2597
- if (reviewerThumbsDown.length > 0 && (matchedIssue.resolved || matchedIssue.fixed)) {
2598
- matchedIssue.resolved = undefined;
2599
- matchedIssue.resolvedBy = undefined;
2600
- matchedIssue.fixed = undefined;
2601
- matchedIssue.fixedBy = undefined;
2602
- console.log(
2603
- `👎 问题已标记为未解决: ${matchedIssue.file}:${matchedIssue.line} (by 评审人: ${reviewerThumbsDown.join(", ")})`,
2604
- );
2605
- }
2606
- } catch {
2607
- // 单个评论获取 reactions 失败,继续处理其他评论
2608
- }
2609
- }
2610
- // 获取 PR 上的所有 Issue Comments(包含对 review 评论的回复)
2611
- await this.syncRepliesToIssues(owner, repo, prNumber, reviewComments, result);
2612
- } catch (error) {
2613
- console.warn("⚠️ 同步评论 reactions 失败:", error);
2614
- }
2615
- }
2616
-
2617
- /**
2618
- * 从评论 body 中提取 issue key(AI 行级评论末尾的 HTML 注释标记)
2619
- * 格式:`<!-- issue-key: file:line:ruleId -->`
2620
- * 返回 null 表示非 AI 评论(即用户真实回复)
2621
- */
2622
- protected extractIssueKeyFromBody(body: string): string | null {
2623
- const match = body.match(/<!-- issue-key: (.+?) -->/);
2624
- return match ? match[1] : null;
2625
- }
2626
-
2627
- /**
2628
- * 判断评论是否为 AI 生成的评论(非用户真实回复)
2629
- * 除 issue-key 标记外,还通过结构化格式特征识别
2630
- */
2631
- protected isAiGeneratedComment(body: string): boolean {
2632
- if (!body) return false;
2633
- // 含 issue-key 标记
2634
- if (body.includes("<!-- issue-key:")) return true;
2635
- // 含 AI 评论的结构化格式特征(同时包含「规则」和「文件」字段)
2636
- if (body.includes("- **规则**:") && body.includes("- **文件**:")) return true;
2637
- return false;
2638
- }
2639
-
2640
- /**
2641
- * 同步评论回复到对应的 issues
2642
- * review 评论回复是通过同一个 review 下的后续评论实现的
2643
- *
2644
- * 通过 AI 评论 body 中嵌入的 issue key(`<!-- issue-key: file:line:ruleId -->`)精确匹配 issue:
2645
- * - 含 issue key 的评论是 AI 自身评论,过滤掉不作为回复
2646
- * - 不含 issue key 但匹配 AI 格式特征的评论也视为 AI 评论,过滤掉
2647
- * - 其余评论是用户真实回复,归到其前面最近的 AI 评论对应的 issue
2648
- */
2649
- protected async syncRepliesToIssues(
2650
- _owner: string,
2651
- _repo: string,
2652
- _prNumber: number,
2653
- reviewComments: {
2654
- id?: number;
2655
- path?: string;
2656
- position?: number;
2657
- body?: string;
2658
- user?: { id?: number; login?: string };
2659
- created_at?: string;
2660
- }[],
2661
- result: ReviewResult,
2662
- ): Promise<void> {
2663
- try {
2664
- // 构建 issue key → issue 的映射,用于快速查找
2665
- const issueByKey = new Map<string, ReviewResult["issues"][0]>();
2666
- for (const issue of result.issues) {
2667
- issueByKey.set(this.generateIssueKey(issue), issue);
2668
- }
2669
- // 按文件路径和行号分组评论
2670
- const commentsByLocation = new Map<string, typeof reviewComments>();
2671
- for (const comment of reviewComments) {
2672
- if (!comment.path || !comment.position) continue;
2673
- const key = `${comment.path}:${comment.position}`;
2674
- const comments = commentsByLocation.get(key) || [];
2675
- comments.push(comment);
2676
- commentsByLocation.set(key, comments);
2677
- }
2678
- // 遍历每个位置的评论
2679
- for (const [, comments] of commentsByLocation) {
2680
- if (comments.length <= 1) continue;
2681
- // 按创建时间排序
2682
- comments.sort((a, b) => {
2683
- const timeA = a.created_at ? new Date(a.created_at).getTime() : 0;
2684
- const timeB = b.created_at ? new Date(b.created_at).getTime() : 0;
2685
- return timeA - timeB;
2686
- });
2687
- // 遍历评论,用 issue key 精确匹配
2688
- let lastIssueKey: string | null = null;
2689
- for (const comment of comments) {
2690
- const commentBody = comment.body || "";
2691
- const issueKey = this.extractIssueKeyFromBody(commentBody);
2692
- if (issueKey) {
2693
- // AI 自身评论(含 issue-key),记录 issue key 但不作为回复
2694
- lastIssueKey = issueKey;
2695
- continue;
2696
- }
2697
- // 跳过不含 issue-key 但匹配 AI 格式特征的评论(如其他轮次的 bot 评论)
2698
- if (this.isAiGeneratedComment(commentBody)) {
2699
- continue;
2700
- }
2701
- // 用户真实回复,通过前面最近的 AI 评论的 issue key 精确匹配
2702
- let matchedIssue = lastIssueKey ? (issueByKey.get(lastIssueKey) ?? null) : null;
2703
- // 回退:如果 issue key 匹配失败,使用 path:position 匹配
2704
- if (!matchedIssue) {
2705
- matchedIssue =
2706
- result.issues.find(
2707
- (issue) =>
2708
- issue.file === comment.path &&
2709
- this.lineMatchesPosition(issue.line, comment.position),
2710
- ) ?? null;
2711
- }
2712
- if (!matchedIssue) continue;
2713
- // 追加回复(而非覆盖,同一 issue 可能有多条用户回复)
2714
- if (!matchedIssue.replies) {
2715
- matchedIssue.replies = [];
2716
- }
2717
- matchedIssue.replies.push({
2718
- user: {
2719
- id: comment.user?.id?.toString(),
2720
- login: comment.user?.login || "unknown",
2721
- },
2722
- body: comment.body || "",
2723
- createdAt: comment.created_at || "",
2724
- });
2725
- }
2726
- }
2727
- } catch (error) {
2728
- console.warn("⚠️ 同步评论回复失败:", error);
2729
- }
2730
- }
2731
-
2732
- /**
2733
- * 删除已有的 AI review(通过 marker 识别)
2734
- * - 删除行级评论的 PR Review(带 REVIEW_LINE_COMMENTS_MARKER)
2735
- * - 删除主评论的 Issue Comment(带 REVIEW_COMMENT_MARKER)
2736
- */
2737
- protected async deleteExistingAiReviews(
2738
- owner: string,
2739
- repo: string,
2740
- prNumber: number,
2741
- ): Promise<void> {
2742
- let deletedCount = 0;
2743
- // 删除行级评论的 PR Review
2744
- try {
2745
- const reviews = await this.gitProvider.listPullReviews(owner, repo, prNumber);
2746
- const aiReviews = reviews.filter(
2747
- (r) =>
2748
- r.body?.includes(REVIEW_LINE_COMMENTS_MARKER) || r.body?.includes(REVIEW_COMMENT_MARKER),
2749
- );
2750
- for (const review of aiReviews) {
2751
- if (review.id) {
2752
- try {
2753
- await this.gitProvider.deletePullReview(owner, repo, prNumber, review.id);
2754
- deletedCount++;
2755
- } catch {
2756
- // 已提交的 review 无法删除,忽略
2757
- }
2758
- }
2759
- }
2760
- } catch (error) {
2761
- console.warn("⚠️ 列出 PR reviews 失败:", error);
2762
- }
2763
- // 删除主评论的 Issue Comment
2764
- try {
2765
- const comments = await this.gitProvider.listIssueComments(owner, repo, prNumber);
2766
- const aiComments = comments.filter((c) => c.body?.includes(REVIEW_COMMENT_MARKER));
2767
- for (const comment of aiComments) {
2768
- if (comment.id) {
2769
- try {
2770
- await this.gitProvider.deleteIssueComment(owner, repo, comment.id);
2771
- deletedCount++;
2772
- } catch (error) {
2773
- console.warn(`⚠️ 删除评论 ${comment.id} 失败:`, error);
2774
- }
2775
- }
2776
- }
2777
- } catch (error) {
2778
- console.warn("⚠️ 列出 issue comments 失败:", error);
2779
- }
2780
- if (deletedCount > 0) {
2781
- console.log(`🗑️ 已删除 ${deletedCount} 个旧的 AI review`);
2782
- }
2783
- }
2784
-
2785
- /**
2786
- * 构建行级评论 Review 的 body(marker + 本轮统计 + 上轮回顾)
2787
- */
2788
- protected buildLineReviewBody(
2789
- issues: ReviewIssue[],
2790
- round: number,
2791
- allIssues: ReviewIssue[],
2792
- ): string {
2793
- // 只统计待处理的问题(未修复且未解决)
2794
- const pendingIssues = issues.filter((i) => !i.fixed && !i.resolved && i.valid !== "false");
2795
- const pendingErrors = pendingIssues.filter((i) => i.severity === "error").length;
2796
- const pendingWarns = pendingIssues.filter((i) => i.severity === "warn").length;
2797
- const fileCount = new Set(issues.map((i) => i.file)).size;
2798
-
2799
- const totalPending = pendingErrors + pendingWarns;
2800
- const badges: string[] = [];
2801
- if (totalPending > 0) badges.push(`⚠️ ${totalPending}`);
2802
- if (pendingErrors > 0) badges.push(`🔴 ${pendingErrors}`);
2803
- if (pendingWarns > 0) badges.push(`🟡 ${pendingWarns}`);
2804
-
2805
- const parts: string[] = [REVIEW_LINE_COMMENTS_MARKER];
2806
- parts.push(`### 🚀 Spaceflow Review · Round ${round}`);
2807
- if (issues.length === 0) {
2808
- parts.push(`> ✅ 未发现新问题`);
2809
- } else {
2810
- parts.push(
2811
- `> **${issues.length}** 个新问题 · **${fileCount}** 个文件${badges.length > 0 ? " · " + badges.join(" ") : ""}`,
2812
- );
2813
- }
2814
-
2815
- // 上轮回顾
2816
- if (round > 1) {
2817
- const prevIssues = allIssues.filter((i) => i.round === round - 1);
2818
- if (prevIssues.length > 0) {
2819
- const prevFixed = prevIssues.filter((i) => i.fixed).length;
2820
- const prevResolved = prevIssues.filter((i) => i.resolved && !i.fixed).length;
2821
- const prevInvalid = prevIssues.filter(
2822
- (i) => i.valid === "false" && !i.fixed && !i.resolved,
2823
- ).length;
2824
- const prevPending = prevIssues.length - prevFixed - prevResolved - prevInvalid;
2825
- parts.push("");
2826
- parts.push(
2827
- `<details><summary>📊 Round ${round - 1} 回顾 (${prevIssues.length} 个问题)</summary>\n`,
2828
- );
2829
- parts.push(`| 状态 | 数量 |`);
2830
- parts.push(`|------|------|`);
2831
- if (prevFixed > 0) parts.push(`| 🟢 已修复 | ${prevFixed} |`);
2832
- if (prevResolved > 0) parts.push(`| ⚪ 已解决 | ${prevResolved} |`);
2833
- if (prevInvalid > 0) parts.push(`| ❌ 无效 | ${prevInvalid} |`);
2834
- if (prevPending > 0) parts.push(`| ⚠️ 待处理 | ${prevPending} |`);
2835
- parts.push(`\n</details>`);
2836
- }
2837
- }
2838
-
2839
- return parts.join("\n");
2840
- }
2841
-
2842
- /**
2843
- * 将单个 ReviewIssue 转换为 CreatePullReviewComment
2844
- */
2845
- protected issueToReviewComment(issue: ReviewIssue): CreatePullReviewComment | null {
2846
- const lineNums = this.reviewSpecService.parseLineRange(issue.line);
2847
- if (lineNums.length === 0) {
2848
- return null;
2849
- }
2850
- const lineNum = lineNums[0];
2851
- // 构建评论内容,参照 markdown.formatter.ts 的格式
2852
- const severityEmoji =
2853
- issue.severity === "error" ? "🔴" : issue.severity === "warn" ? "🟡" : "⚪";
2854
- const lines: string[] = [];
2855
- lines.push(`${severityEmoji} **${issue.reason}**`);
2856
- lines.push(`- **文件**: \`${issue.file}:${issue.line}\``);
2857
- lines.push(`- **规则**: \`${issue.ruleId}\` (来自 \`${issue.specFile}\`)`);
2858
- if (issue.commit) {
2859
- lines.push(`- **Commit**: ${issue.commit}`);
2860
- }
2861
- lines.push(`- **开发人员**: ${issue.author ? "@" + issue.author.login : "未知"}`);
2862
- lines.push(`<!-- issue-key: ${this.generateIssueKey(issue)} -->`);
2863
- if (issue.suggestion) {
2864
- const ext = extname(issue.file).slice(1) || "";
2865
- const cleanSuggestion = issue.suggestion.replace(/```/g, "//").trim();
2866
- lines.push(`- **建议**:`);
2867
- lines.push(`\`\`\`${ext}`);
2868
- lines.push(cleanSuggestion);
2869
- lines.push("```");
2870
- }
2871
- return {
2872
- path: issue.file,
2873
- body: lines.join("\n"),
2874
- new_position: lineNum,
2875
- old_position: 0,
2876
- };
2877
- }
2878
-
2879
- protected generateIssueKey(issue: ReviewIssue): string {
2880
- return `${issue.file}:${issue.line}:${issue.ruleId}`;
2881
- }
2882
-
2883
- protected parseExistingReviewResult(commentBody: string): ReviewResult | null {
2884
- const parsed = this.reviewReportService.parseMarkdown(commentBody);
2885
- if (!parsed) {
2886
- return null;
2887
- }
2888
- return parsed.result;
2889
- }
2890
-
2891
- /**
2892
- * 将有变更文件的历史 issue 标记为无效
2893
- * 简化策略:如果文件在最新 commit 中有变更,则将该文件的所有历史问题标记为无效
2894
- * @param issues 历史 issue 列表
2895
- * @param headSha 当前 PR head 的 SHA
2896
- * @param owner 仓库所有者
2897
- * @param repo 仓库名
2898
- * @param verbose 日志级别
2899
- * @returns 更新后的 issue 列表
2900
- */
2901
- protected async invalidateIssuesForChangedFiles(
2902
- issues: ReviewIssue[],
2903
- headSha: string | undefined,
2904
- owner: string,
2905
- repo: string,
2906
- verbose?: VerboseLevel,
2907
- ): Promise<ReviewIssue[]> {
2908
- if (!headSha) {
2909
- if (shouldLog(verbose, 1)) {
2910
- console.log(` ⚠️ 无法获取 PR head SHA,跳过变更文件检查`);
2911
- }
2912
- return issues;
2913
- }
2914
-
2915
- if (shouldLog(verbose, 1)) {
2916
- console.log(` 📊 获取最新 commit 变更文件: ${headSha.slice(0, 7)}`);
2917
- }
2918
-
2919
- try {
2920
- // 使用 Git Provider API 获取最新一次 commit 的 diff
2921
- const diffText = await this.gitProvider.getCommitDiff(owner, repo, headSha);
2922
- const diffFiles = parseDiffText(diffText);
2923
-
2924
- if (diffFiles.length === 0) {
2925
- if (shouldLog(verbose, 1)) {
2926
- console.log(` ⏭️ 最新 commit 无文件变更`);
2927
- }
2928
- return issues;
2929
- }
2930
-
2931
- // 构建变更文件集合
2932
- const changedFileSet = new Set(diffFiles.map((f) => f.filename));
2933
- if (shouldLog(verbose, 2)) {
2934
- console.log(` [invalidateIssues] 变更文件: ${[...changedFileSet].join(", ")}`);
2935
- }
2936
-
2937
- // 将变更文件的历史 issue 标记为无效
2938
- let invalidatedCount = 0;
2939
- const updatedIssues = issues.map((issue) => {
2940
- // 如果 issue 已修复、已解决或已无效,不需要处理
2941
- if (issue.fixed || issue.resolved || issue.valid === "false") {
2942
- return issue;
2943
- }
2944
-
2945
- // 如果 issue 所在文件有变更,标记为无效
2946
- if (changedFileSet.has(issue.file)) {
2947
- invalidatedCount++;
2948
- if (shouldLog(verbose, 1)) {
2949
- console.log(` 🗑️ Issue ${issue.file}:${issue.line} 所在文件有变更,标记为无效`);
2950
- }
2951
- return { ...issue, valid: "false", originalLine: issue.originalLine ?? issue.line };
2952
- }
2953
-
2954
- return issue;
2955
- });
2956
-
2957
- if (invalidatedCount > 0 && shouldLog(verbose, 1)) {
2958
- console.log(` 📊 共标记 ${invalidatedCount} 个历史问题为无效(文件有变更)`);
2959
- }
2960
-
2961
- return updatedIssues;
2962
- } catch (error) {
2963
- if (shouldLog(verbose, 1)) {
2964
- console.log(` ⚠️ 获取最新 commit 变更文件失败: ${error}`);
2965
- }
2966
- return issues;
2967
- }
2968
- }
2969
-
2970
- /**
2971
- * 根据代码变更更新历史 issue 的行号
2972
- * 当代码发生变化时,之前发现的 issue 行号可能已经不准确
2973
- * 此方法通过分析 diff 来计算新的行号
2974
- * @param issues 历史 issue 列表
2975
- * @param filePatchMap 文件名到 patch 的映射
2976
- * @param verbose 日志级别
2977
- * @returns 更新后的 issue 列表
2978
- */
2979
- protected updateIssueLineNumbers(
2980
- issues: ReviewIssue[],
2981
- filePatchMap: Map<string, string>,
2982
- verbose?: VerboseLevel,
2983
- ): ReviewIssue[] {
2984
- let updatedCount = 0;
2985
- let invalidatedCount = 0;
2986
- const updatedIssues = issues.map((issue) => {
2987
- // 如果 issue 已修复、已解决或无效,不需要更新行号
2988
- if (issue.fixed || issue.resolved || issue.valid === "false") {
2989
- return issue;
2990
- }
2991
-
2992
- const patch = filePatchMap.get(issue.file);
2993
- if (!patch) {
2994
- // 文件没有变更,行号不变
2995
- return issue;
2996
- }
2997
-
2998
- const lines = this.reviewSpecService.parseLineRange(issue.line);
2999
- if (lines.length === 0) {
3000
- return issue;
3001
- }
3002
-
3003
- const startLine = lines[0];
3004
- const endLine = lines[lines.length - 1];
3005
- const hunks = parseHunksFromPatch(patch);
3006
-
3007
- // 计算新的起始行号
3008
- const newStartLine = calculateNewLineNumber(startLine, hunks);
3009
- if (newStartLine === null) {
3010
- // 起始行被删除,直接标记为无效问题
3011
- invalidatedCount++;
3012
- if (shouldLog(verbose, 1)) {
3013
- console.log(`📍 Issue ${issue.file}:${issue.line} 对应的代码已被删除,标记为无效`);
3014
- }
3015
- return { ...issue, valid: "false", originalLine: issue.originalLine ?? issue.line };
3016
- }
3017
-
3018
- // 如果是范围行号,计算新的结束行号
3019
- let newLine: string;
3020
- if (startLine === endLine) {
3021
- newLine = String(newStartLine);
3022
- } else {
3023
- const newEndLine = calculateNewLineNumber(endLine, hunks);
3024
- if (newEndLine === null || newEndLine === newStartLine) {
3025
- // 结束行被删除或范围缩小为单行,使用起始行
3026
- newLine = String(newStartLine);
3027
- } else {
3028
- newLine = `${newStartLine}-${newEndLine}`;
3029
- }
3030
- }
3031
-
3032
- // 如果行号发生变化,更新 issue
3033
- if (newLine !== issue.line) {
3034
- updatedCount++;
3035
- if (shouldLog(verbose, 1)) {
3036
- console.log(`📍 Issue 行号更新: ${issue.file}:${issue.line} -> ${issue.file}:${newLine}`);
3037
- }
3038
- return { ...issue, line: newLine, originalLine: issue.originalLine ?? issue.line };
3039
- }
3040
-
3041
- return issue;
3042
- });
3043
-
3044
- if ((updatedCount > 0 || invalidatedCount > 0) && shouldLog(verbose, 1)) {
3045
- const parts: string[] = [];
3046
- if (updatedCount > 0) parts.push(`更新 ${updatedCount} 个行号`);
3047
- if (invalidatedCount > 0) parts.push(`标记 ${invalidatedCount} 个无效`);
3048
- console.log(`📊 Issue 行号处理: ${parts.join(",")}`);
3049
- }
3050
-
3051
- return updatedIssues;
3052
- }
3053
-
3054
- /**
3055
- * 过滤掉不属于本次 PR commits 的问题(排除 merge commit 引入的代码)
3056
- * 根据 fileContents 中问题行的实际 commit hash 进行验证,而不是依赖 LLM 填写的 commit
3057
- */
3058
- protected filterIssuesByValidCommits(
3059
- issues: ReviewIssue[],
3060
- commits: PullRequestCommit[],
3061
- fileContents: FileContentsMap,
3062
- verbose?: VerboseLevel,
3063
- ): ReviewIssue[] {
3064
- const validCommitHashes = new Set(commits.map((c) => c.sha?.slice(0, 7)).filter(Boolean));
3065
-
3066
- if (shouldLog(verbose, 3)) {
3067
- console.log(` 🔍 有效 commit hashes: ${Array.from(validCommitHashes).join(", ")}`);
3068
- }
3069
-
3070
- const beforeCount = issues.length;
3071
- const filtered = issues.filter((issue) => {
3072
- const contentLines = fileContents.get(issue.file);
3073
- if (!contentLines) {
3074
- // 文件不在 fileContents 中,保留 issue
3075
- if (shouldLog(verbose, 3)) {
3076
- console.log(` ✅ Issue ${issue.file}:${issue.line} - 文件不在 fileContents 中,保留`);
3077
- }
3078
- return true;
3079
- }
3080
-
3081
- const lineNums = this.reviewSpecService.parseLineRange(issue.line);
3082
- if (lineNums.length === 0) {
3083
- if (shouldLog(verbose, 3)) {
3084
- console.log(` ✅ Issue ${issue.file}:${issue.line} - 无法解析行号,保留`);
3085
- }
3086
- return true;
3087
- }
3088
-
3089
- // 检查问题行范围内是否有任意一行属于本次 PR 的有效 commits
3090
- for (const lineNum of lineNums) {
3091
- const lineData = contentLines[lineNum - 1];
3092
- if (lineData) {
3093
- const [actualHash] = lineData;
3094
- if (actualHash !== "-------" && validCommitHashes.has(actualHash)) {
3095
- if (shouldLog(verbose, 3)) {
3096
- console.log(
3097
- ` ✅ Issue ${issue.file}:${issue.line} - 行 ${lineNum} hash=${actualHash} 匹配,保留`,
3098
- );
3099
- }
3100
- return true;
3101
- }
3102
- }
3103
- }
3104
-
3105
- // 问题行都不属于本次 PR 的有效 commits
3106
- if (shouldLog(verbose, 2)) {
3107
- console.log(` Issue ${issue.file}:${issue.line} 不在本次 PR 变更行范围内,跳过`);
3108
- }
3109
- if (shouldLog(verbose, 3)) {
3110
- const hashes = lineNums.map((ln) => {
3111
- const ld = contentLines[ln - 1];
3112
- return ld ? `${ln}:${ld[0]}` : `${ln}:N/A`;
3113
- });
3114
- console.log(` ❌ Issue ${issue.file}:${issue.line} - 行号 hash: ${hashes.join(", ")}`);
3115
- }
3116
- return false;
3117
- });
3118
- if (beforeCount !== filtered.length && shouldLog(verbose, 1)) {
3119
- console.log(` 过滤非本次 PR commits 问题后: ${beforeCount} -> ${filtered.length} 个问题`);
3120
- }
3121
- return filtered;
3122
- }
3123
-
3124
- protected filterDuplicateIssues(
3125
- newIssues: ReviewIssue[],
3126
- existingIssues: ReviewIssue[],
3127
- ): { filteredIssues: ReviewIssue[]; skippedCount: number } {
3128
- // 所有历史问题(无论 valid 状态)都阻止新问题重复添加
3129
- // valid='false' 的问题已被评审人标记为无效,不应再次报告
3130
- // valid='true' 的问题已存在,无需重复
3131
- // fixed 的问题已解决,无需重复
3132
- const existingKeys = new Set(existingIssues.map((issue) => this.generateIssueKey(issue)));
3133
- const filteredIssues = newIssues.filter(
3134
- (issue) => !existingKeys.has(this.generateIssueKey(issue)),
3135
- );
3136
- const skippedCount = newIssues.length - filteredIssues.length;
3137
- return { filteredIssues, skippedCount };
3138
- }
3139
-
3140
- protected async getExistingReviewResult(
3141
- owner: string,
3142
- repo: string,
3143
- prNumber: number,
3144
- ): Promise<ReviewResult | null> {
3145
- try {
3146
- // 从 Issue Comment 获取已有的审查结果
3147
- const comments = await this.gitProvider.listIssueComments(owner, repo, prNumber);
3148
- const existingComment = comments.find((c) => c.body?.includes(REVIEW_COMMENT_MARKER));
3149
- if (existingComment?.body) {
3150
- return this.parseExistingReviewResult(existingComment.body);
3151
- }
3152
- } catch (error) {
3153
- console.warn("⚠️ 获取已有评论失败:", error);
3154
- }
3155
- return null;
3156
- }
3157
-
3158
- protected async ensureClaudeCli(): Promise<void> {
3159
- try {
3160
- execSync("claude --version", { stdio: "ignore" });
3161
- } catch {
3162
- console.log("🔧 Claude CLI 未安装,正在安装...");
3163
- try {
3164
- execSync("npm install -g @anthropic-ai/claude-code", {
3165
- stdio: "inherit",
3166
- });
3167
- console.log("✅ Claude CLI 安装完成");
3168
- } catch (installError) {
3169
- throw new Error(
3170
- `Claude CLI 安装失败: ${installError instanceof Error ? installError.message : String(installError)}`,
3171
- );
3172
- }
3173
- }
3174
- }
3175
-
3176
- /**
3177
- * 构建文件行号到 commit hash 的映射
3178
- * 遍历每个 commit,获取其修改的文件和行号
3179
- * 优先使用 API,失败时回退到 git 命令
3180
- */
3181
- protected async buildLineCommitMap(
3182
- owner: string,
3183
- repo: string,
3184
- commits: PullRequestCommit[],
3185
- verbose?: VerboseLevel,
3186
- ): Promise<Map<string, Map<number, string>>> {
3187
- // Map<filename, Map<lineNumber, commitHash>>
3188
- const fileLineMap = new Map<string, Map<number, string>>();
3189
-
3190
- // 按时间顺序遍历 commits(早的在前),后面的 commit 会覆盖前面的
3191
- for (const commit of commits) {
3192
- if (!commit.sha) continue;
3193
-
3194
- const shortHash = commit.sha.slice(0, 7);
3195
- let files: Array<{ filename: string; patch: string }> = [];
3196
-
3197
- // 优先使用 getCommitDiff API 获取 diff 文本
3198
- try {
3199
- const diffText = await this.gitProvider.getCommitDiff(owner, repo, commit.sha);
3200
- files = parseDiffText(diffText);
3201
- } catch {
3202
- // API 失败,回退到 git 命令
3203
- files = this.gitSdk.getCommitDiff(commit.sha);
3204
- }
3205
- if (shouldLog(verbose, 2)) console.log(` commit ${shortHash}: ${files.length} 个文件变更`);
3206
-
3207
- for (const file of files) {
3208
- // 解析这个 commit 修改的行号
3209
- const changedLines = parseChangedLinesFromPatch(file.patch);
3210
-
3211
- // 获取或创建文件的行号映射
3212
- if (!fileLineMap.has(file.filename)) {
3213
- fileLineMap.set(file.filename, new Map());
3214
- }
3215
- const lineMap = fileLineMap.get(file.filename)!;
3216
-
3217
- // 记录每行对应的 commit hash
3218
- for (const lineNum of changedLines) {
3219
- lineMap.set(lineNum, shortHash);
3220
- }
3221
- }
3222
- }
3223
-
3224
- return fileLineMap;
3225
993
  }
3226
994
  }