@spaceflow/review 0.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +533 -0
- package/README.md +124 -0
- package/dist/551.js +9 -0
- package/dist/index.js +5704 -0
- package/package.json +50 -0
- package/src/README.md +364 -0
- package/src/__mocks__/@anthropic-ai/claude-agent-sdk.js +3 -0
- package/src/__mocks__/json-stringify-pretty-compact.ts +4 -0
- package/src/deletion-impact.service.spec.ts +974 -0
- package/src/deletion-impact.service.ts +879 -0
- package/src/dto/mcp.dto.ts +42 -0
- package/src/index.ts +32 -0
- package/src/issue-verify.service.spec.ts +460 -0
- package/src/issue-verify.service.ts +309 -0
- package/src/locales/en/review.json +31 -0
- package/src/locales/index.ts +11 -0
- package/src/locales/zh-cn/review.json +31 -0
- package/src/parse-title-options.spec.ts +251 -0
- package/src/parse-title-options.ts +185 -0
- package/src/review-report/formatters/deletion-impact.formatter.ts +144 -0
- package/src/review-report/formatters/index.ts +4 -0
- package/src/review-report/formatters/json.formatter.ts +8 -0
- package/src/review-report/formatters/markdown.formatter.ts +291 -0
- package/src/review-report/formatters/terminal.formatter.ts +130 -0
- package/src/review-report/index.ts +4 -0
- package/src/review-report/review-report.module.ts +8 -0
- package/src/review-report/review-report.service.ts +58 -0
- package/src/review-report/types.ts +26 -0
- package/src/review-spec/index.ts +3 -0
- package/src/review-spec/review-spec.module.ts +10 -0
- package/src/review-spec/review-spec.service.spec.ts +1543 -0
- package/src/review-spec/review-spec.service.ts +902 -0
- package/src/review-spec/types.ts +143 -0
- package/src/review.command.ts +244 -0
- package/src/review.config.ts +58 -0
- package/src/review.mcp.ts +184 -0
- package/src/review.module.ts +52 -0
- package/src/review.service.spec.ts +3007 -0
- package/src/review.service.ts +2603 -0
- package/tsconfig.json +8 -0
- package/vitest.config.ts +34 -0
|
@@ -0,0 +1,2603 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Injectable,
|
|
3
|
+
ConfigService,
|
|
4
|
+
ConfigReaderService,
|
|
5
|
+
GitProviderService,
|
|
6
|
+
PullRequest,
|
|
7
|
+
PullRequestCommit,
|
|
8
|
+
ChangedFile,
|
|
9
|
+
CreatePullReviewComment,
|
|
10
|
+
CiConfig,
|
|
11
|
+
type LLMMode,
|
|
12
|
+
LlmProxyService,
|
|
13
|
+
logStreamEvent,
|
|
14
|
+
createStreamLoggerState,
|
|
15
|
+
type VerboseLevel,
|
|
16
|
+
shouldLog,
|
|
17
|
+
normalizeVerbose,
|
|
18
|
+
type LlmJsonPutSchema,
|
|
19
|
+
LlmJsonPut,
|
|
20
|
+
parallel,
|
|
21
|
+
GitSdkService,
|
|
22
|
+
parseChangedLinesFromPatch,
|
|
23
|
+
parseDiffText,
|
|
24
|
+
parseHunksFromPatch,
|
|
25
|
+
calculateNewLineNumber,
|
|
26
|
+
} from "@spaceflow/core";
|
|
27
|
+
import { type AnalyzeDeletionsMode, type ReviewConfig } from "./review.config";
|
|
28
|
+
import {
|
|
29
|
+
ReviewSpecService,
|
|
30
|
+
ReviewSpec,
|
|
31
|
+
ReviewIssue,
|
|
32
|
+
ReviewResult,
|
|
33
|
+
ReviewStats,
|
|
34
|
+
FileSummary,
|
|
35
|
+
FileContentsMap,
|
|
36
|
+
FileContentLine,
|
|
37
|
+
type UserInfo,
|
|
38
|
+
} from "./review-spec";
|
|
39
|
+
import { MarkdownFormatter, ReviewReportService, type ReportFormat } from "./review-report";
|
|
40
|
+
import { execSync } from "child_process";
|
|
41
|
+
import { readFile, readdir } from "fs/promises";
|
|
42
|
+
import { join, dirname, extname, relative, isAbsolute } from "path";
|
|
43
|
+
import micromatch from "micromatch";
|
|
44
|
+
import { ReviewOptions } from "./review.command";
|
|
45
|
+
import { IssueVerifyService } from "./issue-verify.service";
|
|
46
|
+
import { DeletionImpactService } from "./deletion-impact.service";
|
|
47
|
+
import { parseTitleOptions } from "./parse-title-options";
|
|
48
|
+
import { homedir } from "os";
|
|
49
|
+
|
|
50
|
+
export interface ReviewContext extends ReviewOptions {
|
|
51
|
+
owner: string;
|
|
52
|
+
repo: string;
|
|
53
|
+
prNumber?: number;
|
|
54
|
+
baseRef?: string;
|
|
55
|
+
headRef?: string;
|
|
56
|
+
specSources: string[];
|
|
57
|
+
verbose?: VerboseLevel;
|
|
58
|
+
includes?: string[];
|
|
59
|
+
files?: string[];
|
|
60
|
+
commits?: string[];
|
|
61
|
+
concurrency?: number;
|
|
62
|
+
timeout?: number;
|
|
63
|
+
retries?: number;
|
|
64
|
+
retryDelay?: number;
|
|
65
|
+
/** 仅执行删除代码分析,跳过常规代码审查 */
|
|
66
|
+
deletionOnly?: boolean;
|
|
67
|
+
/** 删除代码分析模式:openai 使用标准模式,claude-agent 使用 Agent 模式 */
|
|
68
|
+
deletionAnalysisMode?: LLMMode;
|
|
69
|
+
/** 输出格式:markdown, terminal, json。不指定则智能选择 */
|
|
70
|
+
outputFormat?: ReportFormat;
|
|
71
|
+
/** 是否使用 AI 生成 PR 功能描述 */
|
|
72
|
+
generateDescription?: boolean;
|
|
73
|
+
/** 显示所有问题,不过滤非变更行的问题 */
|
|
74
|
+
showAll?: boolean;
|
|
75
|
+
/** PR 事件类型(opened, synchronize, closed 等) */
|
|
76
|
+
eventAction?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface FileReviewPrompt {
|
|
80
|
+
filename: string;
|
|
81
|
+
systemPrompt: string;
|
|
82
|
+
userPrompt: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ReviewPrompt {
|
|
86
|
+
filePrompts: FileReviewPrompt[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface LLMReviewOptions {
|
|
90
|
+
verbose?: VerboseLevel;
|
|
91
|
+
concurrency?: number;
|
|
92
|
+
timeout?: number;
|
|
93
|
+
retries?: number;
|
|
94
|
+
retryDelay?: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const REVIEW_COMMENT_MARKER = "<!-- spaceflow-review -->";
|
|
98
|
+
|
|
99
|
+
const REVIEW_SCHEMA: LlmJsonPutSchema = {
|
|
100
|
+
type: "object",
|
|
101
|
+
properties: {
|
|
102
|
+
issues: {
|
|
103
|
+
type: "array",
|
|
104
|
+
items: {
|
|
105
|
+
type: "object",
|
|
106
|
+
properties: {
|
|
107
|
+
file: { type: "string", description: "发生问题的文件路径" },
|
|
108
|
+
line: {
|
|
109
|
+
type: "string",
|
|
110
|
+
description:
|
|
111
|
+
"问题所在的行号,只支持单行或多行 (如 123 或 123-125),不允许使用 `,` 分隔多个行号",
|
|
112
|
+
},
|
|
113
|
+
ruleId: { type: "string", description: "违反的规则 ID(如 JsTs.FileName.UpperCamel)" },
|
|
114
|
+
specFile: {
|
|
115
|
+
type: "string",
|
|
116
|
+
description: "规则来源的规范文件名(如 js&ts.file-name.md)",
|
|
117
|
+
},
|
|
118
|
+
reason: { type: "string", description: "问题的简要概括" },
|
|
119
|
+
suggestion: {
|
|
120
|
+
type: "string",
|
|
121
|
+
description:
|
|
122
|
+
"修改后的完整代码片段。要求以代码为主体,并在代码中使用详细的中文注释解释逻辑改进点。不要包含 Markdown 反引号。",
|
|
123
|
+
},
|
|
124
|
+
commit: { type: "string", description: "相关的 7 位 commit SHA" },
|
|
125
|
+
severity: {
|
|
126
|
+
type: "string",
|
|
127
|
+
description: "问题严重程度,根据规则文档中的 severity 标记确定",
|
|
128
|
+
enum: ["error", "warn"],
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
required: ["file", "line", "ruleId", "specFile", "reason"],
|
|
132
|
+
additionalProperties: false,
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
summary: { type: "string", description: "本次代码审查的整体总结" },
|
|
136
|
+
},
|
|
137
|
+
required: ["issues", "summary"],
|
|
138
|
+
additionalProperties: false,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
@Injectable()
|
|
142
|
+
export class ReviewService {
|
|
143
|
+
protected readonly llmJsonPut: LlmJsonPut<ReviewResult>;
|
|
144
|
+
|
|
145
|
+
constructor(
|
|
146
|
+
protected readonly gitProvider: GitProviderService,
|
|
147
|
+
protected readonly configService: ConfigService,
|
|
148
|
+
protected readonly configReader: ConfigReaderService,
|
|
149
|
+
protected readonly reviewSpecService: ReviewSpecService,
|
|
150
|
+
protected readonly llmProxyService: LlmProxyService,
|
|
151
|
+
protected readonly reviewReportService: ReviewReportService,
|
|
152
|
+
protected readonly issueVerifyService: IssueVerifyService,
|
|
153
|
+
protected readonly deletionImpactService: DeletionImpactService,
|
|
154
|
+
protected readonly gitSdk: GitSdkService,
|
|
155
|
+
) {
|
|
156
|
+
this.llmJsonPut = new LlmJsonPut(REVIEW_SCHEMA, {
|
|
157
|
+
llmRequest: async (prompt) => {
|
|
158
|
+
const response = await this.llmProxyService.chat(
|
|
159
|
+
[
|
|
160
|
+
{ role: "system", content: prompt.systemPrompt },
|
|
161
|
+
{ role: "user", content: prompt.userPrompt },
|
|
162
|
+
],
|
|
163
|
+
{ adapter: "openai" },
|
|
164
|
+
);
|
|
165
|
+
if (!response.content) {
|
|
166
|
+
throw new Error("LLM 返回了空内容");
|
|
167
|
+
}
|
|
168
|
+
return response.content;
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async getContextFromEnv(options: ReviewOptions): Promise<ReviewContext> {
|
|
174
|
+
const reviewConf = this.configReader.getPluginConfig<ReviewConfig>("review");
|
|
175
|
+
const ciConf = this.configService.get<CiConfig>("ci");
|
|
176
|
+
const repository = ciConf?.repository;
|
|
177
|
+
|
|
178
|
+
if (options.ci) {
|
|
179
|
+
this.gitProvider.validateConfig();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let repoPath = repository;
|
|
183
|
+
if (!repoPath) {
|
|
184
|
+
// 非 CI 模式下,从 git remote 获取仓库信息
|
|
185
|
+
const remoteUrl = this.gitSdk.getRemoteUrl();
|
|
186
|
+
if (remoteUrl) {
|
|
187
|
+
const parsed = this.gitSdk.parseRepositoryFromRemoteUrl(remoteUrl);
|
|
188
|
+
if (parsed) {
|
|
189
|
+
repoPath = `${parsed.owner}/${parsed.repo}`;
|
|
190
|
+
if (shouldLog(options.verbose, 1)) {
|
|
191
|
+
console.log(`📦 从 git remote 获取仓库: ${repoPath}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!repoPath) {
|
|
198
|
+
throw new Error("缺少配置 ci.repository");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const parts = repoPath.split("/");
|
|
202
|
+
if (parts.length < 2) {
|
|
203
|
+
throw new Error("ci.repository 格式不正确");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const owner = parts[0];
|
|
207
|
+
const repo = parts[1];
|
|
208
|
+
|
|
209
|
+
let prNumber = options.prNumber;
|
|
210
|
+
|
|
211
|
+
if (!prNumber && options.ci) {
|
|
212
|
+
prNumber = await this.getPrNumberFromEvent();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 从 PR 标题解析命令参数(命令行参数优先,标题参数作为补充)
|
|
216
|
+
let titleOptions: ReturnType<typeof parseTitleOptions> = {};
|
|
217
|
+
if (prNumber && options.ci) {
|
|
218
|
+
try {
|
|
219
|
+
const pr = await this.gitProvider.getPullRequest(owner, repo, prNumber);
|
|
220
|
+
if (pr?.title) {
|
|
221
|
+
titleOptions = parseTitleOptions(pr.title);
|
|
222
|
+
if (Object.keys(titleOptions).length > 0 && shouldLog(options.verbose, 1)) {
|
|
223
|
+
console.log(`📋 从 PR 标题解析到参数:`, titleOptions);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if (shouldLog(options.verbose, 1)) {
|
|
228
|
+
console.warn(`⚠️ 获取 PR 标题失败:`, error);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const specSources = [
|
|
234
|
+
join(homedir(), ".spaceflow", "deps"),
|
|
235
|
+
join(process.cwd(), ".spaceflow", "deps"),
|
|
236
|
+
];
|
|
237
|
+
if (options.references?.length) {
|
|
238
|
+
specSources.push(...options.references);
|
|
239
|
+
}
|
|
240
|
+
if (reviewConf.references?.length) {
|
|
241
|
+
specSources.push(...reviewConf.references);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 当没有 PR 且没有指定 base/head 时,自动获取默认值
|
|
245
|
+
let baseRef = options.base;
|
|
246
|
+
let headRef = options.head;
|
|
247
|
+
if (!prNumber && !baseRef && !headRef) {
|
|
248
|
+
headRef = this.gitSdk.getCurrentBranch() ?? "HEAD";
|
|
249
|
+
baseRef = this.gitSdk.getDefaultBranch();
|
|
250
|
+
if (shouldLog(options.verbose, 1)) {
|
|
251
|
+
console.log(`📌 自动检测分支: base=${baseRef}, head=${headRef}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 合并参数优先级:命令行 > PR 标题 > 配置文件 > 默认值
|
|
256
|
+
return {
|
|
257
|
+
owner,
|
|
258
|
+
repo,
|
|
259
|
+
prNumber,
|
|
260
|
+
baseRef,
|
|
261
|
+
headRef,
|
|
262
|
+
specSources,
|
|
263
|
+
dryRun: options.dryRun || titleOptions.dryRun || false,
|
|
264
|
+
ci: options.ci ?? false,
|
|
265
|
+
verbose: normalizeVerbose(options.verbose ?? titleOptions.verbose),
|
|
266
|
+
includes: options.includes ?? titleOptions.includes ?? reviewConf.includes,
|
|
267
|
+
llmMode: options.llmMode ?? titleOptions.llmMode ?? reviewConf.llmMode,
|
|
268
|
+
files: this.normalizeFilePaths(options.files),
|
|
269
|
+
commits: options.commits,
|
|
270
|
+
verifyFixes:
|
|
271
|
+
options.verifyFixes ?? titleOptions.verifyFixes ?? reviewConf.verifyFixes ?? true,
|
|
272
|
+
verifyConcurrency: options.verifyConcurrency ?? reviewConf.verifyFixesConcurrency ?? 10,
|
|
273
|
+
analyzeDeletions: this.resolveAnalyzeDeletions(
|
|
274
|
+
options.analyzeDeletions ??
|
|
275
|
+
options.deletionOnly ??
|
|
276
|
+
titleOptions.analyzeDeletions ??
|
|
277
|
+
titleOptions.deletionOnly ??
|
|
278
|
+
reviewConf.analyzeDeletions ??
|
|
279
|
+
false,
|
|
280
|
+
{ ci: options.ci, hasPrNumber: !!prNumber },
|
|
281
|
+
),
|
|
282
|
+
deletionOnly: options.deletionOnly || titleOptions.deletionOnly || false,
|
|
283
|
+
deletionAnalysisMode:
|
|
284
|
+
options.deletionAnalysisMode ??
|
|
285
|
+
titleOptions.deletionAnalysisMode ??
|
|
286
|
+
reviewConf.deletionAnalysisMode ??
|
|
287
|
+
"openai",
|
|
288
|
+
concurrency: options.concurrency ?? reviewConf.concurrency ?? 5,
|
|
289
|
+
timeout: options.timeout ?? reviewConf.timeout,
|
|
290
|
+
retries: options.retries ?? reviewConf.retries ?? 0,
|
|
291
|
+
retryDelay: options.retryDelay ?? reviewConf.retryDelay ?? 1000,
|
|
292
|
+
generateDescription: options.generateDescription ?? reviewConf.generateDescription ?? false,
|
|
293
|
+
showAll: options.showAll ?? false,
|
|
294
|
+
eventAction: options.eventAction,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 将文件路径规范化为相对于仓库根目录的路径
|
|
300
|
+
* 支持绝对路径和相对路径输入
|
|
301
|
+
*/
|
|
302
|
+
protected normalizeFilePaths(files?: string[]): string[] | undefined {
|
|
303
|
+
if (!files || files.length === 0) return files;
|
|
304
|
+
|
|
305
|
+
const cwd = process.cwd();
|
|
306
|
+
return files.map((file) => {
|
|
307
|
+
if (isAbsolute(file)) {
|
|
308
|
+
// 绝对路径转换为相对路径
|
|
309
|
+
return relative(cwd, file);
|
|
310
|
+
}
|
|
311
|
+
return file;
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* 根据 AnalyzeDeletionsMode 和当前环境解析是否启用删除代码分析
|
|
317
|
+
* @param mode 配置的模式值
|
|
318
|
+
* @param env 当前环境信息
|
|
319
|
+
* @returns 是否启用删除代码分析
|
|
320
|
+
*/
|
|
321
|
+
protected resolveAnalyzeDeletions(
|
|
322
|
+
mode: AnalyzeDeletionsMode,
|
|
323
|
+
env: { ci: boolean; hasPrNumber: boolean },
|
|
324
|
+
): boolean {
|
|
325
|
+
if (typeof mode === "boolean") {
|
|
326
|
+
return mode;
|
|
327
|
+
}
|
|
328
|
+
switch (mode) {
|
|
329
|
+
case "ci":
|
|
330
|
+
return env.ci;
|
|
331
|
+
case "pr":
|
|
332
|
+
return env.hasPrNumber;
|
|
333
|
+
case "terminal":
|
|
334
|
+
return !env.ci;
|
|
335
|
+
default:
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* 从 GitHub Actions 事件文件中解析 PR 编号
|
|
342
|
+
* 在 CI 环境中,GitHub Actions 会将事件信息写入 GITHUB_EVENT_PATH 指向的文件
|
|
343
|
+
* @returns PR 编号,如果无法解析则返回 undefined
|
|
344
|
+
*/
|
|
345
|
+
protected async getPrNumberFromEvent(): Promise<number | undefined> {
|
|
346
|
+
const eventPath = process.env.GITHUB_EVENT_PATH;
|
|
347
|
+
if (!eventPath) {
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
const eventContent = await readFile(eventPath, "utf-8");
|
|
353
|
+
const event = JSON.parse(eventContent);
|
|
354
|
+
// 支持多种事件类型:
|
|
355
|
+
// - pull_request 事件: event.pull_request.number 或 event.number
|
|
356
|
+
// - issue_comment 事件: event.issue.number
|
|
357
|
+
return event.pull_request?.number || event.issue?.number || event.number;
|
|
358
|
+
} catch {
|
|
359
|
+
return undefined;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* 执行代码审查的主方法
|
|
365
|
+
* 该方法负责协调整个审查流程,包括:
|
|
366
|
+
* 1. 加载审查规范(specs)
|
|
367
|
+
* 2. 获取 PR/分支的变更文件和提交记录
|
|
368
|
+
* 3. 调用 LLM 进行代码审查
|
|
369
|
+
* 4. 处理历史 issue(更新行号、验证修复状态)
|
|
370
|
+
* 5. 生成并发布审查报告
|
|
371
|
+
*
|
|
372
|
+
* @param context 审查上下文,包含 owner、repo、prNumber 等信息
|
|
373
|
+
* @returns 审查结果,包含发现的问题列表和统计信息
|
|
374
|
+
*/
|
|
375
|
+
async execute(context: ReviewContext): Promise<ReviewResult> {
|
|
376
|
+
const {
|
|
377
|
+
owner,
|
|
378
|
+
repo,
|
|
379
|
+
prNumber,
|
|
380
|
+
baseRef,
|
|
381
|
+
headRef,
|
|
382
|
+
specSources,
|
|
383
|
+
dryRun,
|
|
384
|
+
ci,
|
|
385
|
+
verbose,
|
|
386
|
+
includes,
|
|
387
|
+
llmMode,
|
|
388
|
+
files,
|
|
389
|
+
commits: filterCommits,
|
|
390
|
+
deletionOnly,
|
|
391
|
+
} = context;
|
|
392
|
+
|
|
393
|
+
// 直接审查文件模式:指定了 -f 文件且 base=head
|
|
394
|
+
const isDirectFileMode = files && files.length > 0 && baseRef === headRef;
|
|
395
|
+
|
|
396
|
+
if (shouldLog(verbose, 1)) {
|
|
397
|
+
console.log(`🔍 Review 启动`);
|
|
398
|
+
console.log(` DRY-RUN mode: ${dryRun ? "enabled" : "disabled"}`);
|
|
399
|
+
console.log(` CI mode: ${ci ? "enabled" : "disabled"}`);
|
|
400
|
+
console.log(` Verbose: ${verbose}`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// 如果是 deletionOnly 模式,直接执行删除代码分析
|
|
404
|
+
if (deletionOnly) {
|
|
405
|
+
return this.executeDeletionOnly(context);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// 如果是 closed 事件,仅收集 review 状态
|
|
409
|
+
if (context.eventAction === "closed") {
|
|
410
|
+
return this.executeCollectOnly(context);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (shouldLog(verbose, 1)) {
|
|
414
|
+
console.log(`📂 解析规则来源: ${specSources.length} 个`);
|
|
415
|
+
}
|
|
416
|
+
const specDirs = await this.reviewSpecService.resolveSpecSources(specSources);
|
|
417
|
+
if (shouldLog(verbose, 2)) {
|
|
418
|
+
console.log(` 解析到 ${specDirs.length} 个规则目录`, specDirs);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
let specs: ReviewSpec[] = [];
|
|
422
|
+
for (const specDir of specDirs) {
|
|
423
|
+
const dirSpecs = await this.reviewSpecService.loadReviewSpecs(specDir);
|
|
424
|
+
specs.push(...dirSpecs);
|
|
425
|
+
}
|
|
426
|
+
if (shouldLog(verbose, 1)) {
|
|
427
|
+
console.log(` 找到 ${specs.length} 个规则文件`);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// 去重规则:后加载的覆盖先加载的
|
|
431
|
+
const beforeDedup = specs.reduce((sum, s) => sum + s.rules.length, 0);
|
|
432
|
+
specs = this.reviewSpecService.deduplicateSpecs(specs);
|
|
433
|
+
const afterDedup = specs.reduce((sum, s) => sum + s.rules.length, 0);
|
|
434
|
+
if (beforeDedup !== afterDedup && shouldLog(verbose, 1)) {
|
|
435
|
+
console.log(` 去重规则: ${beforeDedup} -> ${afterDedup} 条`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
let pr: PullRequest | undefined;
|
|
439
|
+
let commits: PullRequestCommit[] = [];
|
|
440
|
+
let changedFiles: ChangedFile[] = [];
|
|
441
|
+
|
|
442
|
+
if (prNumber) {
|
|
443
|
+
if (shouldLog(verbose, 1)) {
|
|
444
|
+
console.log(`📥 获取 PR #${prNumber} 信息 (owner: ${owner}, repo: ${repo})`);
|
|
445
|
+
}
|
|
446
|
+
pr = await this.gitProvider.getPullRequest(owner, repo, prNumber);
|
|
447
|
+
commits = await this.gitProvider.getPullRequestCommits(owner, repo, prNumber);
|
|
448
|
+
changedFiles = await this.gitProvider.getPullRequestFiles(owner, repo, prNumber);
|
|
449
|
+
if (shouldLog(verbose, 1)) {
|
|
450
|
+
console.log(` PR: ${pr?.title}`);
|
|
451
|
+
console.log(` Commits: ${commits.length}`);
|
|
452
|
+
console.log(` Changed files: ${changedFiles.length}`);
|
|
453
|
+
}
|
|
454
|
+
} else if (baseRef && headRef) {
|
|
455
|
+
// 如果指定了 -f 文件且 base=head(无差异模式),直接审查指定文件
|
|
456
|
+
if (files && files.length > 0 && baseRef === headRef) {
|
|
457
|
+
if (shouldLog(verbose, 1)) {
|
|
458
|
+
console.log(`📥 直接审查指定文件模式 (${files.length} 个文件)`);
|
|
459
|
+
}
|
|
460
|
+
changedFiles = files.map((f) => ({ filename: f, status: "modified" as const }));
|
|
461
|
+
} else {
|
|
462
|
+
if (shouldLog(verbose, 1)) {
|
|
463
|
+
console.log(`📥 获取 ${baseRef}...${headRef} 的差异 (owner: ${owner}, repo: ${repo})`);
|
|
464
|
+
}
|
|
465
|
+
changedFiles = await this.getChangedFilesBetweenRefs(owner, repo, baseRef, headRef);
|
|
466
|
+
commits = await this.getCommitsBetweenRefs(baseRef, headRef);
|
|
467
|
+
if (shouldLog(verbose, 1)) {
|
|
468
|
+
console.log(` Changed files: ${changedFiles.length}`);
|
|
469
|
+
console.log(` Commits: ${commits.length}`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
} else {
|
|
473
|
+
if (shouldLog(verbose, 1)) {
|
|
474
|
+
console.log(`❌ 错误: 缺少 prNumber 或 baseRef/headRef`, { prNumber, baseRef, headRef });
|
|
475
|
+
}
|
|
476
|
+
throw new Error("必须指定 PR 编号或者 base/head 分支");
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// 0. 过滤掉 merge commit(消息以 "Merge branch" 开头的 commit)
|
|
480
|
+
const beforeMergeFilterCount = commits.length;
|
|
481
|
+
commits = commits.filter((c) => {
|
|
482
|
+
const message = c.commit?.message || "";
|
|
483
|
+
return !message.startsWith("Merge branch ");
|
|
484
|
+
});
|
|
485
|
+
if (beforeMergeFilterCount !== commits.length && shouldLog(verbose, 1)) {
|
|
486
|
+
console.log(` 跳过 Merge Commits: ${beforeMergeFilterCount} -> ${commits.length} 个`);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// 1. 按指定的 files 过滤
|
|
490
|
+
if (files && files.length > 0) {
|
|
491
|
+
const beforeFilesCount = changedFiles.length;
|
|
492
|
+
changedFiles = changedFiles.filter((f) => files.includes(f.filename || ""));
|
|
493
|
+
if (shouldLog(verbose, 1)) {
|
|
494
|
+
console.log(` Files 过滤文件: ${beforeFilesCount} -> ${changedFiles.length} 个文件`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// 2. 按指定的 commits 过滤
|
|
499
|
+
if (filterCommits && filterCommits.length > 0) {
|
|
500
|
+
const beforeCommitsCount = commits.length;
|
|
501
|
+
commits = commits.filter((c) => filterCommits.some((fc) => fc && c.sha?.startsWith(fc)));
|
|
502
|
+
if (shouldLog(verbose, 1)) {
|
|
503
|
+
console.log(` Commits 过滤: ${beforeCommitsCount} -> ${commits.length} 个`);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// 同时也过滤变更文件,仅保留属于这些 commit 的文件
|
|
507
|
+
const beforeFilesCount = changedFiles.length;
|
|
508
|
+
const commitFilenames = new Set<string>();
|
|
509
|
+
for (const commit of commits) {
|
|
510
|
+
if (!commit.sha) continue;
|
|
511
|
+
const commitFiles = await this.getFilesForCommit(owner, repo, commit.sha, prNumber);
|
|
512
|
+
commitFiles.forEach((f) => commitFilenames.add(f));
|
|
513
|
+
}
|
|
514
|
+
changedFiles = changedFiles.filter((f) => commitFilenames.has(f.filename || ""));
|
|
515
|
+
if (shouldLog(verbose, 1)) {
|
|
516
|
+
console.log(` 按 Commits 过滤文件: ${beforeFilesCount} -> ${changedFiles.length} 个文件`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// 3. 使用 includes 过滤文件和 commits
|
|
521
|
+
if (includes && includes.length > 0) {
|
|
522
|
+
const beforeFilesCount = changedFiles.length;
|
|
523
|
+
const filenames = changedFiles.map((file) => file.filename || "");
|
|
524
|
+
const matchedFilenames = micromatch(filenames, includes);
|
|
525
|
+
changedFiles = changedFiles.filter((file) => matchedFilenames.includes(file.filename || ""));
|
|
526
|
+
if (shouldLog(verbose, 1)) {
|
|
527
|
+
console.log(` Includes 过滤文件: ${beforeFilesCount} -> ${changedFiles.length} 个文件`);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const beforeCommitsCount = commits.length;
|
|
531
|
+
const filteredCommits: PullRequestCommit[] = [];
|
|
532
|
+
for (const commit of commits) {
|
|
533
|
+
if (!commit.sha) continue;
|
|
534
|
+
const commitFiles = await this.getFilesForCommit(owner, repo, commit.sha, prNumber);
|
|
535
|
+
if (micromatch.some(commitFiles, includes)) {
|
|
536
|
+
filteredCommits.push(commit);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
commits = filteredCommits;
|
|
540
|
+
if (shouldLog(verbose, 1)) {
|
|
541
|
+
console.log(` Includes 过滤 Commits: ${beforeCommitsCount} -> ${commits.length} 个`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// 只按扩展名过滤规则,includes 和 override 在 LLM 审查后处理
|
|
546
|
+
const applicableSpecs = this.reviewSpecService.filterApplicableSpecs(specs, changedFiles);
|
|
547
|
+
if (shouldLog(verbose, 1)) {
|
|
548
|
+
console.log(` 适用的规则文件: ${applicableSpecs.length}`);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (applicableSpecs.length === 0 || changedFiles.length === 0) {
|
|
552
|
+
if (shouldLog(verbose, 1)) {
|
|
553
|
+
console.log("✅ 没有需要审查的文件或规则");
|
|
554
|
+
}
|
|
555
|
+
// 即使没有适用的规则,也为每个变更文件生成摘要
|
|
556
|
+
const summary: FileSummary[] = changedFiles
|
|
557
|
+
.filter((f) => f.filename && f.status !== "deleted")
|
|
558
|
+
.map((f) => ({
|
|
559
|
+
file: f.filename!,
|
|
560
|
+
resolved: 0,
|
|
561
|
+
unresolved: 0,
|
|
562
|
+
summary: applicableSpecs.length === 0 ? "无适用的审查规则" : "已跳过",
|
|
563
|
+
}));
|
|
564
|
+
const prInfo =
|
|
565
|
+
context.generateDescription && llmMode
|
|
566
|
+
? await this.generatePrDescription(commits, changedFiles, llmMode, undefined, verbose)
|
|
567
|
+
: await this.buildFallbackDescription(commits, changedFiles);
|
|
568
|
+
return {
|
|
569
|
+
success: true,
|
|
570
|
+
title: prInfo.title,
|
|
571
|
+
description: prInfo.description,
|
|
572
|
+
issues: [],
|
|
573
|
+
summary,
|
|
574
|
+
round: 1,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const headSha = pr?.head?.sha || headRef || "HEAD";
|
|
579
|
+
const fileContents = await this.getFileContents(
|
|
580
|
+
owner,
|
|
581
|
+
repo,
|
|
582
|
+
changedFiles,
|
|
583
|
+
commits,
|
|
584
|
+
headSha,
|
|
585
|
+
prNumber,
|
|
586
|
+
verbose,
|
|
587
|
+
);
|
|
588
|
+
if (!llmMode) {
|
|
589
|
+
throw new Error("必须指定 LLM 类型");
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// 获取上一次的审查结果(用于提示词优化)
|
|
593
|
+
let existingResult: ReviewResult | null = null;
|
|
594
|
+
if (ci && prNumber) {
|
|
595
|
+
existingResult = await this.getExistingReviewResult(owner, repo, prNumber);
|
|
596
|
+
if (existingResult && shouldLog(verbose, 1)) {
|
|
597
|
+
console.log(`📋 获取到上一次审查结果,包含 ${existingResult.issues.length} 个问题`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// 计算当前轮次:基于已有结果的轮次 + 1
|
|
601
|
+
const currentRound = (existingResult?.round ?? 0) + 1;
|
|
602
|
+
if (shouldLog(verbose, 1)) {
|
|
603
|
+
console.log(`🔄 当前审查轮次: ${currentRound}`);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const reviewPrompt = await this.buildReviewPrompt(
|
|
607
|
+
specs,
|
|
608
|
+
changedFiles,
|
|
609
|
+
fileContents,
|
|
610
|
+
commits,
|
|
611
|
+
existingResult,
|
|
612
|
+
);
|
|
613
|
+
const result = await this.runLLMReview(llmMode, reviewPrompt, {
|
|
614
|
+
verbose,
|
|
615
|
+
concurrency: context.concurrency,
|
|
616
|
+
timeout: context.timeout,
|
|
617
|
+
retries: context.retries,
|
|
618
|
+
retryDelay: context.retryDelay,
|
|
619
|
+
});
|
|
620
|
+
// 填充 PR 功能描述和标题
|
|
621
|
+
const prInfo = context.generateDescription
|
|
622
|
+
? await this.generatePrDescription(commits, changedFiles, llmMode, fileContents, verbose)
|
|
623
|
+
: await this.buildFallbackDescription(commits, changedFiles);
|
|
624
|
+
result.title = prInfo.title;
|
|
625
|
+
result.description = prInfo.description;
|
|
626
|
+
// 更新 round 并为新 issues 赋值 round
|
|
627
|
+
result.round = currentRound;
|
|
628
|
+
result.issues = result.issues.map((issue) => ({ ...issue, round: currentRound }));
|
|
629
|
+
|
|
630
|
+
if (shouldLog(verbose, 1)) {
|
|
631
|
+
console.log(`📝 LLM 审查完成,发现 ${result.issues.length} 个问题`);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
result.issues = await this.fillIssueCode(result.issues, fileContents);
|
|
635
|
+
|
|
636
|
+
// 在 LLM 审查后应用 includes 和 override 过滤
|
|
637
|
+
let filteredIssues = this.reviewSpecService.filterIssuesByIncludes(
|
|
638
|
+
result.issues,
|
|
639
|
+
applicableSpecs,
|
|
640
|
+
);
|
|
641
|
+
if (shouldLog(verbose, 1)) {
|
|
642
|
+
console.log(` 应用 includes 过滤后: ${filteredIssues.length} 个问题`);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
filteredIssues = this.reviewSpecService.filterIssuesByRuleExistence(filteredIssues, specs);
|
|
646
|
+
if (shouldLog(verbose, 1)) {
|
|
647
|
+
console.log(` 应用规则存在性过滤后: ${filteredIssues.length} 个问题`);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
filteredIssues = this.reviewSpecService.filterIssuesByOverrides(
|
|
651
|
+
filteredIssues,
|
|
652
|
+
applicableSpecs,
|
|
653
|
+
verbose,
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
// 过滤掉不属于本次 PR commits 的问题(排除 merge commit 引入的代码)
|
|
657
|
+
if (shouldLog(verbose, 3)) {
|
|
658
|
+
console.log(` 🔍 变更行过滤条件检查:`);
|
|
659
|
+
console.log(
|
|
660
|
+
` showAll=${context.showAll}, isDirectFileMode=${isDirectFileMode}, commits.length=${commits.length}`,
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
if (!context.showAll && !isDirectFileMode && commits.length > 0) {
|
|
664
|
+
if (shouldLog(verbose, 2)) {
|
|
665
|
+
console.log(` 🔍 开始变更行过滤,当前 ${filteredIssues.length} 个问题`);
|
|
666
|
+
}
|
|
667
|
+
filteredIssues = this.filterIssuesByValidCommits(
|
|
668
|
+
filteredIssues,
|
|
669
|
+
commits,
|
|
670
|
+
fileContents,
|
|
671
|
+
verbose,
|
|
672
|
+
);
|
|
673
|
+
if (shouldLog(verbose, 2)) {
|
|
674
|
+
console.log(` 🔍 变更行过滤完成,剩余 ${filteredIssues.length} 个问题`);
|
|
675
|
+
}
|
|
676
|
+
} else if (shouldLog(verbose, 1)) {
|
|
677
|
+
console.log(
|
|
678
|
+
` 跳过变更行过滤 (${context.showAll ? "showAll=true" : isDirectFileMode ? "直接审查文件模式" : "commits.length=0"})`,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
filteredIssues = this.reviewSpecService.formatIssues(filteredIssues, {
|
|
683
|
+
specs,
|
|
684
|
+
changedFiles,
|
|
685
|
+
});
|
|
686
|
+
if (shouldLog(verbose, 1)) {
|
|
687
|
+
console.log(` 应用格式化后: ${filteredIssues.length} 个问题`);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
result.issues = filteredIssues;
|
|
691
|
+
if (shouldLog(verbose, 1)) {
|
|
692
|
+
console.log(`📝 最终发现 ${result.issues.length} 个问题`);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
let existingIssues: ReviewIssue[] = [];
|
|
696
|
+
let allIssues = result.issues;
|
|
697
|
+
|
|
698
|
+
if (ci && prNumber && existingResult) {
|
|
699
|
+
existingIssues = existingResult.issues ?? [];
|
|
700
|
+
if (existingIssues.length > 0) {
|
|
701
|
+
if (shouldLog(verbose, 1)) {
|
|
702
|
+
console.log(`📋 已有评论中存在 ${existingIssues.length} 个问题`);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// 如果文件有变更,将该文件的历史问题标记为无效
|
|
706
|
+
// 简化策略:避免复杂的行号更新逻辑
|
|
707
|
+
const reviewConf = this.configReader.getPluginConfig<ReviewConfig>("review");
|
|
708
|
+
if (
|
|
709
|
+
reviewConf.invalidateChangedFiles !== "off" &&
|
|
710
|
+
reviewConf.invalidateChangedFiles !== "keep"
|
|
711
|
+
) {
|
|
712
|
+
existingIssues = await this.invalidateIssuesForChangedFiles(
|
|
713
|
+
existingIssues,
|
|
714
|
+
pr?.head?.sha,
|
|
715
|
+
owner,
|
|
716
|
+
repo,
|
|
717
|
+
verbose,
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// 验证历史问题是否已修复
|
|
722
|
+
if (context.verifyFixes) {
|
|
723
|
+
const unfixedExistingIssues = existingIssues.filter(
|
|
724
|
+
(i) => i.valid !== "false" && !i.fixed,
|
|
725
|
+
);
|
|
726
|
+
if (unfixedExistingIssues.length > 0 && llmMode) {
|
|
727
|
+
existingIssues = await this.issueVerifyService.verifyIssueFixes(
|
|
728
|
+
existingIssues,
|
|
729
|
+
fileContents,
|
|
730
|
+
specs,
|
|
731
|
+
llmMode,
|
|
732
|
+
verbose,
|
|
733
|
+
context.verifyConcurrency,
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
} else {
|
|
737
|
+
if (shouldLog(verbose, 1)) {
|
|
738
|
+
console.log(` ⏭️ 跳过历史问题验证 (verifyFixes=false)`);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const { filteredIssues: newIssues, skippedCount } = this.filterDuplicateIssues(
|
|
743
|
+
result.issues,
|
|
744
|
+
existingIssues,
|
|
745
|
+
);
|
|
746
|
+
if (skippedCount > 0 && shouldLog(verbose, 1)) {
|
|
747
|
+
console.log(` 跳过 ${skippedCount} 个重复问题,新增 ${newIssues.length} 个问题`);
|
|
748
|
+
}
|
|
749
|
+
result.issues = newIssues;
|
|
750
|
+
allIssues = [...existingIssues, ...newIssues];
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// 统一填充所有问题的 author 信息(仅在有 commits 时)
|
|
755
|
+
if (commits.length > 0) {
|
|
756
|
+
allIssues = await this.fillIssueAuthors(allIssues, commits, owner, repo, verbose);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// 第一次提交报告:审查问题完成
|
|
760
|
+
if (prNumber && !dryRun) {
|
|
761
|
+
if (shouldLog(verbose, 1)) {
|
|
762
|
+
console.log(`💬 提交 PR 评论 (代码审查完成)...`);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
await this.postOrUpdateReviewComment(
|
|
766
|
+
owner,
|
|
767
|
+
repo,
|
|
768
|
+
prNumber,
|
|
769
|
+
{
|
|
770
|
+
...result,
|
|
771
|
+
issues: allIssues,
|
|
772
|
+
},
|
|
773
|
+
verbose,
|
|
774
|
+
);
|
|
775
|
+
if (shouldLog(verbose, 1)) {
|
|
776
|
+
console.log(`✅ 评论已提交`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// 如果启用了删除代码影响分析
|
|
781
|
+
if (context.analyzeDeletions && llmMode) {
|
|
782
|
+
const deletionImpact = await this.deletionImpactService.analyzeDeletionImpact(
|
|
783
|
+
{
|
|
784
|
+
owner,
|
|
785
|
+
repo,
|
|
786
|
+
prNumber,
|
|
787
|
+
baseRef,
|
|
788
|
+
headRef,
|
|
789
|
+
analysisMode: context.deletionAnalysisMode,
|
|
790
|
+
includes,
|
|
791
|
+
},
|
|
792
|
+
llmMode,
|
|
793
|
+
verbose,
|
|
794
|
+
);
|
|
795
|
+
result.deletionImpact = deletionImpact;
|
|
796
|
+
|
|
797
|
+
// 第二次更新报告:删除代码分析完成
|
|
798
|
+
if (prNumber && !dryRun) {
|
|
799
|
+
if (shouldLog(verbose, 1)) {
|
|
800
|
+
console.log(`💬 更新 PR 评论 (删除代码分析完成)...`);
|
|
801
|
+
}
|
|
802
|
+
await this.postOrUpdateReviewComment(
|
|
803
|
+
owner,
|
|
804
|
+
repo,
|
|
805
|
+
prNumber,
|
|
806
|
+
{
|
|
807
|
+
...result,
|
|
808
|
+
issues: allIssues,
|
|
809
|
+
},
|
|
810
|
+
verbose,
|
|
811
|
+
);
|
|
812
|
+
if (shouldLog(verbose, 1)) {
|
|
813
|
+
console.log(`✅ 评论已更新`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const reviewComment = this.formatReviewComment(
|
|
819
|
+
{ ...result, issues: allIssues },
|
|
820
|
+
{ prNumber, outputFormat: context.outputFormat, ci },
|
|
821
|
+
);
|
|
822
|
+
|
|
823
|
+
// 终端输出(根据 outputFormat 或智能选择)
|
|
824
|
+
console.log(MarkdownFormatter.clearReviewData(reviewComment, "<hidden>"));
|
|
825
|
+
|
|
826
|
+
return result;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* 仅收集 review 状态模式(用于 PR 关闭时)
|
|
831
|
+
* 从现有的 AI review 评论中读取问题状态,同步已解决/无效状态,输出统计信息
|
|
832
|
+
*/
|
|
833
|
+
protected async executeCollectOnly(context: ReviewContext): Promise<ReviewResult> {
|
|
834
|
+
const { owner, repo, prNumber, verbose, ci, dryRun } = context;
|
|
835
|
+
|
|
836
|
+
if (shouldLog(verbose, 1)) {
|
|
837
|
+
console.log(`📊 仅收集 review 状态模式`);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (!prNumber) {
|
|
841
|
+
throw new Error("collectOnly 模式必须指定 PR 编号");
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// 1. 从现有的 AI review 评论中读取问题
|
|
845
|
+
const existingResult = await this.getExistingReviewResult(owner, repo, prNumber);
|
|
846
|
+
if (!existingResult) {
|
|
847
|
+
console.log(`ℹ️ PR #${prNumber} 没有找到 AI review 评论`);
|
|
848
|
+
return {
|
|
849
|
+
success: true,
|
|
850
|
+
description: "",
|
|
851
|
+
issues: [],
|
|
852
|
+
summary: [],
|
|
853
|
+
round: 0,
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
if (shouldLog(verbose, 1)) {
|
|
858
|
+
console.log(`📋 找到 ${existingResult.issues.length} 个历史问题`);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// 2. 获取 commits 并填充 author 信息
|
|
862
|
+
const commits = await this.gitProvider.getPullRequestCommits(owner, repo, prNumber);
|
|
863
|
+
existingResult.issues = await this.fillIssueAuthors(
|
|
864
|
+
existingResult.issues,
|
|
865
|
+
commits,
|
|
866
|
+
owner,
|
|
867
|
+
repo,
|
|
868
|
+
verbose,
|
|
869
|
+
);
|
|
870
|
+
|
|
871
|
+
// 3. 同步已解决的评论状态
|
|
872
|
+
await this.syncResolvedComments(owner, repo, prNumber, existingResult);
|
|
873
|
+
|
|
874
|
+
// 4. 同步评论 reactions(👍/👎)
|
|
875
|
+
await this.syncReactionsToIssues(owner, repo, prNumber, existingResult, verbose);
|
|
876
|
+
|
|
877
|
+
// 5. 统计问题状态并设置到 result
|
|
878
|
+
const stats = this.calculateIssueStats(existingResult.issues);
|
|
879
|
+
existingResult.stats = stats;
|
|
880
|
+
|
|
881
|
+
// 6. 输出统计信息
|
|
882
|
+
console.log(this.reviewReportService.formatStatsTerminal(stats, prNumber));
|
|
883
|
+
|
|
884
|
+
// 7. 更新 PR 评论(如果不是 dry-run)
|
|
885
|
+
if (ci && !dryRun) {
|
|
886
|
+
if (shouldLog(verbose, 1)) {
|
|
887
|
+
console.log(`💬 更新 PR 评论...`);
|
|
888
|
+
}
|
|
889
|
+
await this.postOrUpdateReviewComment(owner, repo, prNumber, existingResult, verbose);
|
|
890
|
+
if (shouldLog(verbose, 1)) {
|
|
891
|
+
console.log(`✅ 评论已更新`);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
return existingResult;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* 计算问题状态统计
|
|
900
|
+
*/
|
|
901
|
+
protected calculateIssueStats(issues: ReviewIssue[]): ReviewStats {
|
|
902
|
+
const total = issues.length;
|
|
903
|
+
const fixed = issues.filter((i) => i.fixed).length;
|
|
904
|
+
const invalid = issues.filter((i) => i.valid === "false").length;
|
|
905
|
+
const pending = total - fixed - invalid;
|
|
906
|
+
const fixRate = total > 0 ? Math.round((fixed / total) * 100 * 10) / 10 : 0;
|
|
907
|
+
return { total, fixed, invalid, pending, fixRate };
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* 仅执行删除代码分析模式
|
|
912
|
+
*/
|
|
913
|
+
protected async executeDeletionOnly(context: ReviewContext): Promise<ReviewResult> {
|
|
914
|
+
const { owner, repo, prNumber, baseRef, headRef, dryRun, ci, verbose, llmMode } = context;
|
|
915
|
+
|
|
916
|
+
if (shouldLog(verbose, 1)) {
|
|
917
|
+
console.log(`🗑️ 仅执行删除代码分析模式`);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
if (!llmMode) {
|
|
921
|
+
throw new Error("必须指定 LLM 类型");
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const deletionImpact = await this.deletionImpactService.analyzeDeletionImpact(
|
|
925
|
+
{
|
|
926
|
+
owner,
|
|
927
|
+
repo,
|
|
928
|
+
prNumber,
|
|
929
|
+
baseRef,
|
|
930
|
+
headRef,
|
|
931
|
+
analysisMode: context.deletionAnalysisMode,
|
|
932
|
+
includes: context.includes,
|
|
933
|
+
},
|
|
934
|
+
llmMode,
|
|
935
|
+
verbose,
|
|
936
|
+
);
|
|
937
|
+
|
|
938
|
+
// 获取 commits 和 changedFiles 用于生成描述
|
|
939
|
+
let commits: PullRequestCommit[] = [];
|
|
940
|
+
let changedFiles: ChangedFile[] = [];
|
|
941
|
+
if (prNumber) {
|
|
942
|
+
commits = await this.gitProvider.getPullRequestCommits(owner, repo, prNumber);
|
|
943
|
+
changedFiles = await this.gitProvider.getPullRequestFiles(owner, repo, prNumber);
|
|
944
|
+
} else if (baseRef && headRef) {
|
|
945
|
+
changedFiles = await this.getChangedFilesBetweenRefs(owner, repo, baseRef, headRef);
|
|
946
|
+
commits = await this.getCommitsBetweenRefs(baseRef, headRef);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// 使用 includes 过滤文件
|
|
950
|
+
if (context.includes && context.includes.length > 0) {
|
|
951
|
+
const filenames = changedFiles.map((file) => file.filename || "");
|
|
952
|
+
const matchedFilenames = micromatch(filenames, context.includes);
|
|
953
|
+
changedFiles = changedFiles.filter((file) => matchedFilenames.includes(file.filename || ""));
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const prInfo = context.generateDescription
|
|
957
|
+
? await this.generatePrDescription(commits, changedFiles, llmMode, undefined, verbose)
|
|
958
|
+
: await this.buildFallbackDescription(commits, changedFiles);
|
|
959
|
+
const result: ReviewResult = {
|
|
960
|
+
success: true,
|
|
961
|
+
title: prInfo.title,
|
|
962
|
+
description: prInfo.description,
|
|
963
|
+
issues: [],
|
|
964
|
+
summary: [],
|
|
965
|
+
deletionImpact,
|
|
966
|
+
round: 1,
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
const reviewComment = this.formatReviewComment(result, {
|
|
970
|
+
prNumber,
|
|
971
|
+
outputFormat: context.outputFormat,
|
|
972
|
+
ci,
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
if (ci && prNumber && !dryRun) {
|
|
976
|
+
if (shouldLog(verbose, 1)) {
|
|
977
|
+
console.log(`💬 提交 PR 评论...`);
|
|
978
|
+
}
|
|
979
|
+
await this.postOrUpdateReviewComment(owner, repo, prNumber, result, verbose);
|
|
980
|
+
if (shouldLog(verbose, 1)) {
|
|
981
|
+
console.log(`✅ 评论已提交`);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// 终端输出(根据 outputFormat 或智能选择)
|
|
986
|
+
|
|
987
|
+
console.log(MarkdownFormatter.clearReviewData(reviewComment, "<hidden>"));
|
|
988
|
+
|
|
989
|
+
return result;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
protected async getChangedFilesBetweenRefs(
|
|
993
|
+
_owner: string,
|
|
994
|
+
_repo: string,
|
|
995
|
+
baseRef: string,
|
|
996
|
+
headRef: string,
|
|
997
|
+
): Promise<ChangedFile[]> {
|
|
998
|
+
// 使用 getDiffBetweenRefs 获取包含 patch 的文件列表
|
|
999
|
+
// 这样可以正确解析变更行号,用于过滤非变更行的问题
|
|
1000
|
+
const diffFiles = await this.gitSdk.getDiffBetweenRefs(baseRef, headRef);
|
|
1001
|
+
const statusFiles = await this.gitSdk.getChangedFilesBetweenRefs(baseRef, headRef);
|
|
1002
|
+
|
|
1003
|
+
// 合并 status 和 patch 信息
|
|
1004
|
+
const statusMap = new Map(statusFiles.map((f) => [f.filename, f.status]));
|
|
1005
|
+
return diffFiles.map((f) => ({
|
|
1006
|
+
filename: f.filename,
|
|
1007
|
+
status: statusMap.get(f.filename) || "modified",
|
|
1008
|
+
patch: f.patch,
|
|
1009
|
+
}));
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
protected async getCommitsBetweenRefs(
|
|
1013
|
+
baseRef: string,
|
|
1014
|
+
headRef: string,
|
|
1015
|
+
): Promise<PullRequestCommit[]> {
|
|
1016
|
+
const gitCommits = await this.gitSdk.getCommitsBetweenRefs(baseRef, headRef);
|
|
1017
|
+
return gitCommits.map((c) => ({
|
|
1018
|
+
sha: c.sha,
|
|
1019
|
+
commit: {
|
|
1020
|
+
message: c.message,
|
|
1021
|
+
author: c.author,
|
|
1022
|
+
},
|
|
1023
|
+
}));
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
protected async getFilesForCommit(
|
|
1027
|
+
owner: string,
|
|
1028
|
+
repo: string,
|
|
1029
|
+
sha: string,
|
|
1030
|
+
prNumber?: number,
|
|
1031
|
+
): Promise<string[]> {
|
|
1032
|
+
if (prNumber) {
|
|
1033
|
+
const commit = await this.gitProvider.getCommit(owner, repo, sha);
|
|
1034
|
+
return commit.files?.map((f) => f.filename || "").filter(Boolean) || [];
|
|
1035
|
+
} else {
|
|
1036
|
+
return this.gitSdk.getFilesForCommit(sha);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* 获取文件内容并构建行号到 commit hash 的映射
|
|
1042
|
+
* 返回 Map<filename, Array<[commitHash, lineCode]>>
|
|
1043
|
+
*/
|
|
1044
|
+
protected async getFileContents(
|
|
1045
|
+
owner: string,
|
|
1046
|
+
repo: string,
|
|
1047
|
+
changedFiles: ChangedFile[],
|
|
1048
|
+
commits: PullRequestCommit[],
|
|
1049
|
+
ref: string,
|
|
1050
|
+
prNumber?: number,
|
|
1051
|
+
verbose?: VerboseLevel,
|
|
1052
|
+
): Promise<FileContentsMap> {
|
|
1053
|
+
const contents: FileContentsMap = new Map();
|
|
1054
|
+
const latestCommitHash = commits[commits.length - 1]?.sha?.slice(0, 7) || "-------";
|
|
1055
|
+
|
|
1056
|
+
// 优先使用 changedFiles 中的 patch 字段(来自 PR 的整体 diff base...head)
|
|
1057
|
+
// 这样行号是相对于最终文件的,而不是每个 commit 的父 commit
|
|
1058
|
+
// buildLineCommitMap 遍历每个 commit 的 diff,行号可能与最终文件不一致
|
|
1059
|
+
if (shouldLog(verbose, 1)) {
|
|
1060
|
+
console.log(`📊 正在构建行号到变更的映射...`);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
for (const file of changedFiles) {
|
|
1064
|
+
if (file.filename && file.status !== "deleted") {
|
|
1065
|
+
try {
|
|
1066
|
+
let rawContent: string;
|
|
1067
|
+
if (prNumber) {
|
|
1068
|
+
rawContent = await this.gitProvider.getFileContent(owner, repo, file.filename, ref);
|
|
1069
|
+
} else {
|
|
1070
|
+
rawContent = await this.gitSdk.getFileContent(ref, file.filename);
|
|
1071
|
+
}
|
|
1072
|
+
const lines = rawContent.split("\n");
|
|
1073
|
+
|
|
1074
|
+
// 优先使用 file.patch(PR 整体 diff),这是相对于最终文件的行号
|
|
1075
|
+
let changedLines = parseChangedLinesFromPatch(file.patch);
|
|
1076
|
+
|
|
1077
|
+
// 如果 changedLines 为空,需要判断是否应该将所有行标记为变更
|
|
1078
|
+
// 情况1: 文件是新增的(status 为 added/A)
|
|
1079
|
+
// 情况2: patch 为空但文件有 additions(部分 Git Provider API 可能不返回完整 patch)
|
|
1080
|
+
const isNewFile =
|
|
1081
|
+
file.status === "added" ||
|
|
1082
|
+
file.status === "A" ||
|
|
1083
|
+
(file.additions && file.additions > 0 && file.deletions === 0 && !file.patch);
|
|
1084
|
+
if (changedLines.size === 0 && isNewFile) {
|
|
1085
|
+
changedLines = new Set(lines.map((_, i) => i + 1));
|
|
1086
|
+
if (shouldLog(verbose, 2)) {
|
|
1087
|
+
console.log(
|
|
1088
|
+
` ℹ️ ${file.filename}: 新增文件无 patch,将所有 ${lines.length} 行标记为变更`,
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
if (shouldLog(verbose, 3)) {
|
|
1094
|
+
console.log(` 📄 ${file.filename}: ${lines.length} 行, ${changedLines.size} 行变更`);
|
|
1095
|
+
console.log(` latestCommitHash: ${latestCommitHash}`);
|
|
1096
|
+
if (changedLines.size > 0 && changedLines.size <= 20) {
|
|
1097
|
+
console.log(
|
|
1098
|
+
` 变更行号: ${Array.from(changedLines)
|
|
1099
|
+
.sort((a, b) => a - b)
|
|
1100
|
+
.join(", ")}`,
|
|
1101
|
+
);
|
|
1102
|
+
} else if (changedLines.size > 20) {
|
|
1103
|
+
console.log(` 变更行号: (共 ${changedLines.size} 行,省略详情)`);
|
|
1104
|
+
}
|
|
1105
|
+
if (!file.patch) {
|
|
1106
|
+
console.log(
|
|
1107
|
+
` ⚠️ 该文件没有 patch 信息 (status=${file.status}, additions=${file.additions}, deletions=${file.deletions})`,
|
|
1108
|
+
);
|
|
1109
|
+
} else {
|
|
1110
|
+
console.log(
|
|
1111
|
+
` patch 前 200 字符: ${file.patch.slice(0, 200).replace(/\n/g, "\\n")}`,
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const contentLines: FileContentLine[] = lines.map((line, index) => {
|
|
1117
|
+
const lineNum = index + 1;
|
|
1118
|
+
// 如果该行在 PR 的整体 diff 中被标记为变更,则使用最新 commit hash
|
|
1119
|
+
const hash = changedLines.has(lineNum) ? latestCommitHash : "-------";
|
|
1120
|
+
return [hash, line];
|
|
1121
|
+
});
|
|
1122
|
+
contents.set(file.filename, contentLines);
|
|
1123
|
+
} catch {
|
|
1124
|
+
console.warn(`警告: 无法获取文件内容: ${file.filename}`);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
if (shouldLog(verbose, 1)) {
|
|
1130
|
+
console.log(`📊 映射构建完成,共 ${contents.size} 个文件`);
|
|
1131
|
+
}
|
|
1132
|
+
return contents;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
protected async runLLMReview(
|
|
1136
|
+
llmMode: LLMMode,
|
|
1137
|
+
reviewPrompt: ReviewPrompt,
|
|
1138
|
+
options: LLMReviewOptions = {},
|
|
1139
|
+
): Promise<ReviewResult> {
|
|
1140
|
+
console.log(`🤖 调用 ${llmMode} 进行代码审查...`);
|
|
1141
|
+
|
|
1142
|
+
try {
|
|
1143
|
+
const result = await this.callLLM(llmMode, reviewPrompt, options);
|
|
1144
|
+
if (!result) {
|
|
1145
|
+
throw new Error("AI 未返回有效结果");
|
|
1146
|
+
}
|
|
1147
|
+
return {
|
|
1148
|
+
success: true,
|
|
1149
|
+
description: "", // 由 execute 方法填充
|
|
1150
|
+
issues: result.issues || [],
|
|
1151
|
+
summary: result.summary || [],
|
|
1152
|
+
round: 1, // 由 execute 方法根据 existingResult 更新
|
|
1153
|
+
};
|
|
1154
|
+
} catch (error) {
|
|
1155
|
+
if (error instanceof Error) {
|
|
1156
|
+
console.error("LLM 调用失败:", error.message);
|
|
1157
|
+
if (error.stack) {
|
|
1158
|
+
console.error("堆栈信息:\n" + error.stack);
|
|
1159
|
+
}
|
|
1160
|
+
} else {
|
|
1161
|
+
console.error("LLM 调用失败:", error);
|
|
1162
|
+
}
|
|
1163
|
+
return {
|
|
1164
|
+
success: false,
|
|
1165
|
+
description: "",
|
|
1166
|
+
issues: [],
|
|
1167
|
+
summary: [],
|
|
1168
|
+
round: 1,
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* 根据文件过滤 specs,只返回与该文件匹配的规则
|
|
1175
|
+
* - 如果 spec 有 includes 配置,只有当文件名匹配 includes 模式时才包含该 spec
|
|
1176
|
+
* - 如果 spec 没有 includes 配置,则按扩展名匹配
|
|
1177
|
+
*/
|
|
1178
|
+
protected filterSpecsForFile(specs: ReviewSpec[], filename: string): ReviewSpec[] {
|
|
1179
|
+
const ext = extname(filename).slice(1).toLowerCase();
|
|
1180
|
+
if (!ext) return [];
|
|
1181
|
+
|
|
1182
|
+
return specs.filter((spec) => {
|
|
1183
|
+
// 先检查扩展名是否匹配
|
|
1184
|
+
if (!spec.extensions.includes(ext)) {
|
|
1185
|
+
return false;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// 如果有 includes 配置,检查文件名是否匹配 includes 模式
|
|
1189
|
+
if (spec.includes.length > 0) {
|
|
1190
|
+
return micromatch.isMatch(filename, spec.includes, { matchBase: true });
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// 没有 includes 配置,扩展名匹配即可
|
|
1194
|
+
return true;
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* 构建 systemPrompt
|
|
1200
|
+
*/
|
|
1201
|
+
protected buildSystemPrompt(specsSection: string): string {
|
|
1202
|
+
return `你是一个专业的代码审查专家,负责根据团队的编码规范对代码进行严格审查。
|
|
1203
|
+
|
|
1204
|
+
## 审查规范
|
|
1205
|
+
|
|
1206
|
+
${specsSection}
|
|
1207
|
+
|
|
1208
|
+
## 审查要求
|
|
1209
|
+
|
|
1210
|
+
1. **严格遵循规范**:只按照上述审查规范进行审查,不要添加规范之外的要求
|
|
1211
|
+
2. **精准定位问题**:每个问题必须指明具体的行号,行号从文件内容中的 "行号|" 格式获取
|
|
1212
|
+
3. **避免重复报告**:如果提示词中包含"上一次审查结果",请不要重复报告已存在的问题
|
|
1213
|
+
4. **提供可行建议**:对于每个问题,提供具体的修改建议代码
|
|
1214
|
+
|
|
1215
|
+
## 注意事项
|
|
1216
|
+
|
|
1217
|
+
- 变更文件内容已在上下文中提供,无需调用读取工具
|
|
1218
|
+
- 你可以读取项目中的其他文件以了解上下文
|
|
1219
|
+
- 不要调用编辑工具修改文件,你的职责是审查而非修改
|
|
1220
|
+
- 文件内容格式为 "CommitHash 行号| 代码",输出的 line 字段应对应原始行号
|
|
1221
|
+
|
|
1222
|
+
## 输出要求
|
|
1223
|
+
|
|
1224
|
+
- 发现问题时:在 issues 数组中列出所有问题,每个问题包含 file、line、ruleId、specFile、reason、suggestion、severity
|
|
1225
|
+
- 无论是否发现问题:都必须在 summary 中提供该文件的审查总结,简要说明审查结果`;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
protected async buildReviewPrompt(
|
|
1229
|
+
specs: ReviewSpec[],
|
|
1230
|
+
changedFiles: ChangedFile[],
|
|
1231
|
+
fileContents: FileContentsMap,
|
|
1232
|
+
commits: PullRequestCommit[],
|
|
1233
|
+
existingResult?: ReviewResult | null,
|
|
1234
|
+
): Promise<ReviewPrompt> {
|
|
1235
|
+
const fileDataList = changedFiles
|
|
1236
|
+
.filter((f) => f.status !== "deleted" && f.filename)
|
|
1237
|
+
.map((file) => {
|
|
1238
|
+
const filename = file.filename!;
|
|
1239
|
+
const contentLines = fileContents.get(filename);
|
|
1240
|
+
if (!contentLines) {
|
|
1241
|
+
return {
|
|
1242
|
+
filename,
|
|
1243
|
+
file,
|
|
1244
|
+
linesWithNumbers: "(无法获取内容)",
|
|
1245
|
+
commitsSection: "- 无相关 commits",
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
const padWidth = String(contentLines.length).length;
|
|
1249
|
+
const linesWithNumbers = contentLines
|
|
1250
|
+
.map(([hash, line], index) => {
|
|
1251
|
+
const lineNum = index + 1;
|
|
1252
|
+
return `${hash} ${String(lineNum).padStart(padWidth)}| ${line}`;
|
|
1253
|
+
})
|
|
1254
|
+
.join("\n");
|
|
1255
|
+
// 从 contentLines 中收集该文件相关的 commit hashes
|
|
1256
|
+
const fileCommitHashes = new Set<string>();
|
|
1257
|
+
for (const [hash] of contentLines) {
|
|
1258
|
+
if (hash !== "-------") {
|
|
1259
|
+
fileCommitHashes.add(hash);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
const relatedCommits = commits.filter((c) => {
|
|
1263
|
+
const shortHash = c.sha?.slice(0, 7) || "";
|
|
1264
|
+
return fileCommitHashes.has(shortHash);
|
|
1265
|
+
});
|
|
1266
|
+
const commitsSection =
|
|
1267
|
+
relatedCommits.length > 0
|
|
1268
|
+
? relatedCommits
|
|
1269
|
+
.map((c) => `- \`${c.sha?.slice(0, 7)}\` ${c.commit?.message?.split("\n")[0]}`)
|
|
1270
|
+
.join("\n")
|
|
1271
|
+
: "- 无相关 commits";
|
|
1272
|
+
return { filename, file, linesWithNumbers, commitsSection };
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
const filePrompts: FileReviewPrompt[] = await Promise.all(
|
|
1276
|
+
fileDataList.map(async ({ filename, file, linesWithNumbers, commitsSection }) => {
|
|
1277
|
+
const fileDirectoryInfo = await this.getFileDirectoryInfo(filename);
|
|
1278
|
+
|
|
1279
|
+
// 获取该文件上一次的审查结果
|
|
1280
|
+
const existingFileSummary = existingResult?.summary?.find((s) => s.file === filename);
|
|
1281
|
+
const existingFileIssues = existingResult?.issues?.filter((i) => i.file === filename) ?? [];
|
|
1282
|
+
|
|
1283
|
+
let previousReviewSection = "";
|
|
1284
|
+
if (existingFileSummary || existingFileIssues.length > 0) {
|
|
1285
|
+
const parts: string[] = [];
|
|
1286
|
+
if (existingFileSummary?.summary) {
|
|
1287
|
+
parts.push(`**总结**:\n`);
|
|
1288
|
+
parts.push(`${existingFileSummary.summary}\n`);
|
|
1289
|
+
}
|
|
1290
|
+
if (existingFileIssues.length > 0) {
|
|
1291
|
+
parts.push(`**已发现的问题** (${existingFileIssues.length} 个):\n`);
|
|
1292
|
+
for (const issue of existingFileIssues) {
|
|
1293
|
+
const status = issue.fixed
|
|
1294
|
+
? "✅ 已修复"
|
|
1295
|
+
: issue.valid === "false"
|
|
1296
|
+
? "❌ 无效"
|
|
1297
|
+
: "⚠️ 待处理";
|
|
1298
|
+
parts.push(`- [${status}] 行 ${issue.line}: ${issue.reason} (规则: ${issue.ruleId})`);
|
|
1299
|
+
}
|
|
1300
|
+
parts.push("");
|
|
1301
|
+
// parts.push("请注意:不要重复报告上述已发现的问题,除非代码有新的变更导致问题复现。\n");
|
|
1302
|
+
}
|
|
1303
|
+
previousReviewSection = parts.join("\n");
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
const userPrompt = `## ${filename} (${file.status})
|
|
1307
|
+
|
|
1308
|
+
### 文件内容
|
|
1309
|
+
|
|
1310
|
+
\`\`\`
|
|
1311
|
+
${linesWithNumbers}
|
|
1312
|
+
\`\`\`
|
|
1313
|
+
|
|
1314
|
+
### 该文件的相关 Commits
|
|
1315
|
+
|
|
1316
|
+
${commitsSection}
|
|
1317
|
+
|
|
1318
|
+
### 该文件所在的目录树
|
|
1319
|
+
|
|
1320
|
+
${fileDirectoryInfo}
|
|
1321
|
+
|
|
1322
|
+
### 上一次审查结果
|
|
1323
|
+
|
|
1324
|
+
${previousReviewSection}`;
|
|
1325
|
+
|
|
1326
|
+
// 根据文件过滤 specs,只注入与当前文件匹配的规则
|
|
1327
|
+
const fileSpecs = this.filterSpecsForFile(specs, filename);
|
|
1328
|
+
const specsSection = this.reviewSpecService.buildSpecsSection(fileSpecs);
|
|
1329
|
+
const systemPrompt = this.buildSystemPrompt(specsSection);
|
|
1330
|
+
|
|
1331
|
+
return { filename, systemPrompt, userPrompt };
|
|
1332
|
+
}),
|
|
1333
|
+
);
|
|
1334
|
+
|
|
1335
|
+
return { filePrompts };
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
protected async fillIssueCode(
|
|
1339
|
+
issues: ReviewIssue[],
|
|
1340
|
+
fileContents: FileContentsMap,
|
|
1341
|
+
): Promise<ReviewIssue[]> {
|
|
1342
|
+
return issues.map((issue) => {
|
|
1343
|
+
const contentLines = fileContents.get(issue.file);
|
|
1344
|
+
if (!contentLines) {
|
|
1345
|
+
return issue;
|
|
1346
|
+
}
|
|
1347
|
+
const lineRange = issue.line.split("-").map((n) => parseInt(n, 10));
|
|
1348
|
+
const startLine = lineRange[0];
|
|
1349
|
+
const endLine = lineRange.length > 1 ? lineRange[1] : startLine;
|
|
1350
|
+
if (isNaN(startLine) || startLine < 1 || startLine > contentLines.length) {
|
|
1351
|
+
return issue;
|
|
1352
|
+
}
|
|
1353
|
+
const codeLines = contentLines
|
|
1354
|
+
.slice(startLine - 1, Math.min(endLine, contentLines.length))
|
|
1355
|
+
.map(([, line]) => line);
|
|
1356
|
+
const code = codeLines.join("\n").trim();
|
|
1357
|
+
return { ...issue, code };
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
/**
|
|
1362
|
+
* 根据 commit 填充 issue 的 author 信息
|
|
1363
|
+
* 如果没有找到对应的 author,使用最后一次提交的人作为默认值
|
|
1364
|
+
*/
|
|
1365
|
+
protected async fillIssueAuthors(
|
|
1366
|
+
issues: ReviewIssue[],
|
|
1367
|
+
commits: PullRequestCommit[],
|
|
1368
|
+
_owner: string,
|
|
1369
|
+
_repo: string,
|
|
1370
|
+
verbose?: VerboseLevel,
|
|
1371
|
+
): Promise<ReviewIssue[]> {
|
|
1372
|
+
if (shouldLog(verbose, 2)) {
|
|
1373
|
+
console.log(`[fillIssueAuthors] issues=${issues.length}, commits=${commits.length}`);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// 收集需要查找的 Git 作者信息(email 或 name)
|
|
1377
|
+
const gitAuthorsToSearch = new Set<string>();
|
|
1378
|
+
for (const commit of commits) {
|
|
1379
|
+
const platformUser = commit.author || commit.committer;
|
|
1380
|
+
if (!platformUser?.login) {
|
|
1381
|
+
const gitAuthor = commit.commit?.author;
|
|
1382
|
+
if (gitAuthor?.email) gitAuthorsToSearch.add(gitAuthor.email);
|
|
1383
|
+
if (gitAuthor?.name) gitAuthorsToSearch.add(gitAuthor.name);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
// 通过 Git Provider API 查找用户,建立 email/name -> UserInfo 的映射
|
|
1388
|
+
const gitAuthorToUserMap = new Map<string, UserInfo>();
|
|
1389
|
+
for (const query of gitAuthorsToSearch) {
|
|
1390
|
+
try {
|
|
1391
|
+
const users = await this.gitProvider.searchUsers(query, 1);
|
|
1392
|
+
if (users.length > 0 && users[0].login) {
|
|
1393
|
+
const user: UserInfo = { id: String(users[0].id), login: users[0].login };
|
|
1394
|
+
gitAuthorToUserMap.set(query, user);
|
|
1395
|
+
if (shouldLog(verbose, 2)) {
|
|
1396
|
+
console.log(`[fillIssueAuthors] found user: ${query} -> ${user.login}`);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
} catch {
|
|
1400
|
+
// 忽略搜索失败
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// 构建 commit hash 到 author 的映射
|
|
1405
|
+
const commitAuthorMap = new Map<string, UserInfo>();
|
|
1406
|
+
for (const commit of commits) {
|
|
1407
|
+
// API 返回的 author/committer 可能为 null(未关联平台用户)
|
|
1408
|
+
const platformUser = commit.author || commit.committer;
|
|
1409
|
+
const gitAuthor = commit.commit?.author;
|
|
1410
|
+
if (shouldLog(verbose, 2)) {
|
|
1411
|
+
console.log(
|
|
1412
|
+
`[fillIssueAuthors] commit: sha=${commit.sha?.slice(0, 7)}, platformUser=${platformUser?.login}, gitAuthor=${gitAuthor?.name}`,
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
if (commit.sha) {
|
|
1416
|
+
const shortHash = commit.sha.slice(0, 7);
|
|
1417
|
+
if (platformUser?.login) {
|
|
1418
|
+
commitAuthorMap.set(shortHash, {
|
|
1419
|
+
id: String(platformUser.id),
|
|
1420
|
+
login: platformUser.login,
|
|
1421
|
+
});
|
|
1422
|
+
} else if (gitAuthor) {
|
|
1423
|
+
// 尝试从平台用户映射中查找
|
|
1424
|
+
const foundUser =
|
|
1425
|
+
(gitAuthor.email && gitAuthorToUserMap.get(gitAuthor.email)) ||
|
|
1426
|
+
(gitAuthor.name && gitAuthorToUserMap.get(gitAuthor.name));
|
|
1427
|
+
if (foundUser) {
|
|
1428
|
+
commitAuthorMap.set(shortHash, foundUser);
|
|
1429
|
+
} else if (gitAuthor.name) {
|
|
1430
|
+
// 使用 Git 原始作者信息(name 作为 login)
|
|
1431
|
+
commitAuthorMap.set(shortHash, { id: "0", login: gitAuthor.name });
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
if (shouldLog(verbose, 2)) {
|
|
1437
|
+
console.log(`[fillIssueAuthors] commitAuthorMap size: ${commitAuthorMap.size}`);
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// 获取最后一次提交的 author 作为默认值
|
|
1441
|
+
const lastCommit = commits[commits.length - 1];
|
|
1442
|
+
const lastPlatformUser = lastCommit?.author || lastCommit?.committer;
|
|
1443
|
+
const lastGitAuthor = lastCommit?.commit?.author;
|
|
1444
|
+
let defaultAuthor: UserInfo | undefined;
|
|
1445
|
+
if (lastPlatformUser?.login) {
|
|
1446
|
+
defaultAuthor = { id: String(lastPlatformUser.id), login: lastPlatformUser.login };
|
|
1447
|
+
} else if (lastGitAuthor) {
|
|
1448
|
+
// 尝试从平台用户映射中查找
|
|
1449
|
+
const foundUser =
|
|
1450
|
+
(lastGitAuthor.email && gitAuthorToUserMap.get(lastGitAuthor.email)) ||
|
|
1451
|
+
(lastGitAuthor.name && gitAuthorToUserMap.get(lastGitAuthor.name));
|
|
1452
|
+
defaultAuthor =
|
|
1453
|
+
foundUser || (lastGitAuthor.name ? { id: "0", login: lastGitAuthor.name } : undefined);
|
|
1454
|
+
}
|
|
1455
|
+
if (shouldLog(verbose, 2)) {
|
|
1456
|
+
console.log(`[fillIssueAuthors] defaultAuthor: ${JSON.stringify(defaultAuthor)}`);
|
|
1457
|
+
}
|
|
1458
|
+
// 为每个 issue 填充 author
|
|
1459
|
+
return issues.map((issue) => {
|
|
1460
|
+
// 如果 issue 已有 author,保留原值
|
|
1461
|
+
if (issue.author) {
|
|
1462
|
+
if (shouldLog(verbose, 2)) {
|
|
1463
|
+
console.log(`[fillIssueAuthors] issue already has author: ${issue.author.login}`);
|
|
1464
|
+
}
|
|
1465
|
+
return issue;
|
|
1466
|
+
}
|
|
1467
|
+
// issue.commit 可能是 7 位短 hash
|
|
1468
|
+
const shortHash = issue.commit?.slice(0, 7);
|
|
1469
|
+
const author =
|
|
1470
|
+
shortHash && !shortHash.includes("---") ? commitAuthorMap.get(shortHash) : undefined;
|
|
1471
|
+
if (shouldLog(verbose, 2)) {
|
|
1472
|
+
console.log(
|
|
1473
|
+
`[fillIssueAuthors] issue: file=${issue.file}, commit=${issue.commit}, shortHash=${shortHash}, foundAuthor=${author?.login}, finalAuthor=${(author || defaultAuthor)?.login}`,
|
|
1474
|
+
);
|
|
1475
|
+
}
|
|
1476
|
+
// 优先使用 commit 对应的 author,否则使用默认 author
|
|
1477
|
+
return { ...issue, author: author || defaultAuthor };
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
protected async getFileDirectoryInfo(filename: string): Promise<string> {
|
|
1482
|
+
const dir = dirname(filename);
|
|
1483
|
+
const currentFileName = filename.split("/").pop();
|
|
1484
|
+
|
|
1485
|
+
if (dir === "." || dir === "") {
|
|
1486
|
+
return "(根目录)";
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
try {
|
|
1490
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
1491
|
+
|
|
1492
|
+
const sortedEntries = entries.sort((a, b) => {
|
|
1493
|
+
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
1494
|
+
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
1495
|
+
return a.name.localeCompare(b.name);
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
const lines: string[] = [`📁 ${dir}/`];
|
|
1499
|
+
|
|
1500
|
+
for (let i = 0; i < sortedEntries.length; i++) {
|
|
1501
|
+
const entry = sortedEntries[i];
|
|
1502
|
+
const isLast = i === sortedEntries.length - 1;
|
|
1503
|
+
const isCurrent = entry.name === currentFileName;
|
|
1504
|
+
const branch = isLast ? "└── " : "├── ";
|
|
1505
|
+
const icon = entry.isDirectory() ? "📂" : "📄";
|
|
1506
|
+
const marker = isCurrent ? " ← 当前文件" : "";
|
|
1507
|
+
|
|
1508
|
+
lines.push(`${branch}${icon} ${entry.name}${marker}`);
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
return lines.join("\n");
|
|
1512
|
+
} catch {
|
|
1513
|
+
return `📁 ${dir}/`;
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
protected async callLLM(
|
|
1518
|
+
llmMode: LLMMode,
|
|
1519
|
+
reviewPrompt: ReviewPrompt,
|
|
1520
|
+
options: LLMReviewOptions = {},
|
|
1521
|
+
): Promise<{ issues: ReviewIssue[]; summary: FileSummary[] } | null> {
|
|
1522
|
+
const { verbose, concurrency = 5, timeout, retries = 0, retryDelay = 1000 } = options;
|
|
1523
|
+
const fileCount = reviewPrompt.filePrompts.length;
|
|
1524
|
+
console.log(
|
|
1525
|
+
`📂 开始并行审查 ${fileCount} 个文件 (并发: ${concurrency}, 重试: ${retries}, 超时: ${timeout ?? "无"}ms)`,
|
|
1526
|
+
);
|
|
1527
|
+
|
|
1528
|
+
const executor = parallel({
|
|
1529
|
+
concurrency,
|
|
1530
|
+
timeout,
|
|
1531
|
+
retries,
|
|
1532
|
+
retryDelay,
|
|
1533
|
+
onTaskStart: (taskId) => {
|
|
1534
|
+
console.log(`🚀 开始审查: ${taskId}`);
|
|
1535
|
+
},
|
|
1536
|
+
onTaskComplete: (taskId, success) => {
|
|
1537
|
+
console.log(`${success ? "✅" : "❌"} 完成审查: ${taskId}`);
|
|
1538
|
+
},
|
|
1539
|
+
onRetry: (taskId, attempt, error) => {
|
|
1540
|
+
console.log(`🔄 重试 ${taskId} (第 ${attempt} 次): ${error.message}`);
|
|
1541
|
+
},
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
const results = await executor.map(
|
|
1545
|
+
reviewPrompt.filePrompts,
|
|
1546
|
+
(filePrompt) => this.reviewSingleFile(llmMode, filePrompt, verbose),
|
|
1547
|
+
(filePrompt) => filePrompt.filename,
|
|
1548
|
+
);
|
|
1549
|
+
|
|
1550
|
+
const allIssues: ReviewIssue[] = [];
|
|
1551
|
+
const fileSummaries: FileSummary[] = [];
|
|
1552
|
+
|
|
1553
|
+
for (const result of results) {
|
|
1554
|
+
if (result.success && result.result) {
|
|
1555
|
+
allIssues.push(...result.result.issues);
|
|
1556
|
+
fileSummaries.push(result.result.summary);
|
|
1557
|
+
} else {
|
|
1558
|
+
fileSummaries.push({
|
|
1559
|
+
file: result.id,
|
|
1560
|
+
resolved: 0,
|
|
1561
|
+
unresolved: 0,
|
|
1562
|
+
summary: `❌ 审查失败: ${result.error?.message ?? "未知错误"}`,
|
|
1563
|
+
});
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
const successCount = results.filter((r) => r.success).length;
|
|
1568
|
+
console.log(`🔍 审查完成: ${successCount}/${fileCount} 个文件成功`);
|
|
1569
|
+
|
|
1570
|
+
return {
|
|
1571
|
+
issues: this.normalizeIssues(allIssues),
|
|
1572
|
+
summary: fileSummaries,
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
protected async reviewSingleFile(
|
|
1577
|
+
llmMode: LLMMode,
|
|
1578
|
+
filePrompt: FileReviewPrompt,
|
|
1579
|
+
verbose?: VerboseLevel,
|
|
1580
|
+
): Promise<{ issues: ReviewIssue[]; summary: FileSummary }> {
|
|
1581
|
+
if (shouldLog(verbose, 3)) {
|
|
1582
|
+
console.log(
|
|
1583
|
+
`\nsystemPrompt:\n----------------\n${filePrompt.systemPrompt}\n----------------`,
|
|
1584
|
+
);
|
|
1585
|
+
console.log(`\nuserPrompt:\n----------------\n${filePrompt.userPrompt}\n----------------`);
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
const stream = this.llmProxyService.chatStream(
|
|
1589
|
+
[
|
|
1590
|
+
{ role: "system", content: filePrompt.systemPrompt },
|
|
1591
|
+
{ role: "user", content: filePrompt.userPrompt },
|
|
1592
|
+
],
|
|
1593
|
+
{
|
|
1594
|
+
adapter: llmMode,
|
|
1595
|
+
jsonSchema: this.llmJsonPut,
|
|
1596
|
+
verbose,
|
|
1597
|
+
allowedTools: [
|
|
1598
|
+
"Read",
|
|
1599
|
+
"Glob",
|
|
1600
|
+
"Grep",
|
|
1601
|
+
"WebSearch",
|
|
1602
|
+
"TodoWrite",
|
|
1603
|
+
"TodoRead",
|
|
1604
|
+
"Task",
|
|
1605
|
+
"Skill",
|
|
1606
|
+
],
|
|
1607
|
+
},
|
|
1608
|
+
);
|
|
1609
|
+
|
|
1610
|
+
const streamLoggerState = createStreamLoggerState();
|
|
1611
|
+
let fileResult: { issues?: ReviewIssue[]; summary?: string } | undefined;
|
|
1612
|
+
|
|
1613
|
+
for await (const event of stream) {
|
|
1614
|
+
if (shouldLog(verbose, 2)) {
|
|
1615
|
+
logStreamEvent(event, streamLoggerState);
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
if (event.type === "result") {
|
|
1619
|
+
fileResult = event.response.structuredOutput as
|
|
1620
|
+
| { issues?: ReviewIssue[]; summary?: string }
|
|
1621
|
+
| undefined;
|
|
1622
|
+
} else if (event.type === "error") {
|
|
1623
|
+
throw new Error(event.message);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
// 在获取到问题时立即记录发现时间
|
|
1628
|
+
const now = new Date().toISOString();
|
|
1629
|
+
const issues = (fileResult?.issues ?? []).map((issue) => ({
|
|
1630
|
+
...issue,
|
|
1631
|
+
date: issue.date ?? now,
|
|
1632
|
+
}));
|
|
1633
|
+
|
|
1634
|
+
return {
|
|
1635
|
+
issues,
|
|
1636
|
+
summary: {
|
|
1637
|
+
file: filePrompt.filename,
|
|
1638
|
+
resolved: 0,
|
|
1639
|
+
unresolved: 0,
|
|
1640
|
+
summary: fileResult?.summary ?? "",
|
|
1641
|
+
},
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
/**
|
|
1646
|
+
* 规范化 issues,拆分包含逗号的行号为多个独立 issue,并添加发现时间
|
|
1647
|
+
* 例如 "114, 122" 会被拆分成两个 issue,分别是 "114" 和 "122"
|
|
1648
|
+
*/
|
|
1649
|
+
protected normalizeIssues(issues: ReviewIssue[]): ReviewIssue[] {
|
|
1650
|
+
const now = new Date().toISOString();
|
|
1651
|
+
return issues.flatMap((issue) => {
|
|
1652
|
+
// 确保 line 是字符串(LLM 可能返回数字)
|
|
1653
|
+
const lineStr = String(issue.line ?? "");
|
|
1654
|
+
const baseIssue = { ...issue, line: lineStr, date: issue.date ?? now };
|
|
1655
|
+
|
|
1656
|
+
if (!lineStr.includes(",")) {
|
|
1657
|
+
return baseIssue;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
const lines = lineStr.split(",");
|
|
1661
|
+
|
|
1662
|
+
return lines.map((linePart, index) => ({
|
|
1663
|
+
...baseIssue,
|
|
1664
|
+
line: linePart.trim(),
|
|
1665
|
+
suggestion: index === 0 ? issue.suggestion : `参考 ${issue.file}:${lines[0]}`,
|
|
1666
|
+
}));
|
|
1667
|
+
});
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
/**
|
|
1671
|
+
* 使用 AI 根据 commits、变更文件和代码内容总结 PR 实现的功能
|
|
1672
|
+
* @returns 包含 title 和 description 的对象
|
|
1673
|
+
*/
|
|
1674
|
+
protected async generatePrDescription(
|
|
1675
|
+
commits: PullRequestCommit[],
|
|
1676
|
+
changedFiles: ChangedFile[],
|
|
1677
|
+
llmMode: LLMMode,
|
|
1678
|
+
fileContents?: FileContentsMap,
|
|
1679
|
+
verbose?: VerboseLevel,
|
|
1680
|
+
): Promise<{ title: string; description: string }> {
|
|
1681
|
+
const commitMessages = commits
|
|
1682
|
+
.map((c) => `- ${c.sha?.slice(0, 7)}: ${c.commit?.message?.split("\n")[0]}`)
|
|
1683
|
+
.join("\n");
|
|
1684
|
+
const fileChanges = changedFiles
|
|
1685
|
+
.slice(0, 30)
|
|
1686
|
+
.map((f) => `- ${f.filename} (${f.status})`)
|
|
1687
|
+
.join("\n");
|
|
1688
|
+
// 构建代码变更内容(只包含变更行,限制总长度)
|
|
1689
|
+
let codeChangesSection = "";
|
|
1690
|
+
if (fileContents && fileContents.size > 0) {
|
|
1691
|
+
const codeSnippets: string[] = [];
|
|
1692
|
+
let totalLength = 0;
|
|
1693
|
+
const maxTotalLength = 8000; // 限制代码总长度
|
|
1694
|
+
for (const [filename, lines] of fileContents) {
|
|
1695
|
+
if (totalLength >= maxTotalLength) break;
|
|
1696
|
+
// 只提取有变更的行(commitHash 不是 "-------")
|
|
1697
|
+
const changedLines = lines
|
|
1698
|
+
.map(([hash, code], idx) => (hash !== "-------" ? `${idx + 1}: ${code}` : null))
|
|
1699
|
+
.filter(Boolean);
|
|
1700
|
+
if (changedLines.length > 0) {
|
|
1701
|
+
const snippet = `### ${filename}\n\`\`\`\n${changedLines.slice(0, 50).join("\n")}\n\`\`\``;
|
|
1702
|
+
if (totalLength + snippet.length <= maxTotalLength) {
|
|
1703
|
+
codeSnippets.push(snippet);
|
|
1704
|
+
totalLength += snippet.length;
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
if (codeSnippets.length > 0) {
|
|
1709
|
+
codeChangesSection = `\n\n## 代码变更内容\n${codeSnippets.join("\n\n")}`;
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
const prompt = `请根据以下 PR 的 commit 记录、文件变更和代码内容,用简洁的中文总结这个 PR 实现了什么功能。
|
|
1713
|
+
要求:
|
|
1714
|
+
1. 第一行输出 PR 标题,格式必须是: Feat xxx 或 Fix xxx 或 Refactor xxx(根据变更类型选择,整体不超过 50 个字符)
|
|
1715
|
+
2. 空一行后输出详细描述
|
|
1716
|
+
3. 描述应该简明扼要,突出核心功能点
|
|
1717
|
+
4. 使用 Markdown 格式
|
|
1718
|
+
5. 不要逐条列出 commit,而是归纳总结
|
|
1719
|
+
6. 重点分析代码变更的实际功能
|
|
1720
|
+
|
|
1721
|
+
## Commit 记录 (${commits.length} 个)
|
|
1722
|
+
${commitMessages || "无"}
|
|
1723
|
+
|
|
1724
|
+
## 文件变更 (${changedFiles.length} 个文件)
|
|
1725
|
+
${fileChanges || "无"}
|
|
1726
|
+
${changedFiles.length > 30 ? `\n... 等 ${changedFiles.length - 30} 个文件` : ""}${codeChangesSection}`;
|
|
1727
|
+
try {
|
|
1728
|
+
const stream = this.llmProxyService.chatStream([{ role: "user", content: prompt }], {
|
|
1729
|
+
adapter: llmMode,
|
|
1730
|
+
});
|
|
1731
|
+
let content = "";
|
|
1732
|
+
for await (const event of stream) {
|
|
1733
|
+
if (event.type === "text") {
|
|
1734
|
+
content += event.content;
|
|
1735
|
+
} else if (event.type === "error") {
|
|
1736
|
+
throw new Error(event.message);
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
// 解析标题和描述:第一行是标题,其余是描述
|
|
1740
|
+
const lines = content.trim().split("\n");
|
|
1741
|
+
const title = lines[0]?.replace(/^#+\s*/, "").trim() || "PR 更新";
|
|
1742
|
+
const description = lines.slice(1).join("\n").trim();
|
|
1743
|
+
return { title, description };
|
|
1744
|
+
} catch (error) {
|
|
1745
|
+
if (shouldLog(verbose, 1)) {
|
|
1746
|
+
console.warn("⚠️ AI 总结 PR 功能失败,使用默认描述:", error);
|
|
1747
|
+
}
|
|
1748
|
+
return this.buildFallbackDescription(commits, changedFiles);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
/**
|
|
1753
|
+
* 使用 LLM 生成 PR 标题
|
|
1754
|
+
*/
|
|
1755
|
+
protected async generatePrTitle(
|
|
1756
|
+
commits: PullRequestCommit[],
|
|
1757
|
+
changedFiles: ChangedFile[],
|
|
1758
|
+
): Promise<string> {
|
|
1759
|
+
const commitMessages = commits
|
|
1760
|
+
.slice(0, 10)
|
|
1761
|
+
.map((c) => c.commit?.message?.split("\n")[0])
|
|
1762
|
+
.filter(Boolean)
|
|
1763
|
+
.join("\n");
|
|
1764
|
+
const fileChanges = changedFiles
|
|
1765
|
+
.slice(0, 20)
|
|
1766
|
+
.map((f) => `${f.filename} (${f.status})`)
|
|
1767
|
+
.join("\n");
|
|
1768
|
+
const prompt = `请根据以下 commit 记录和文件变更,生成一个简短的 PR 标题。
|
|
1769
|
+
要求:
|
|
1770
|
+
1. 格式必须是: Feat: xxx 或 Fix: xxx 或 Refactor: xxx
|
|
1771
|
+
2. 根据变更内容选择合适的前缀(新功能用 Feat,修复用 Fix,重构用 Refactor)
|
|
1772
|
+
3. xxx 部分用简短的中文描述(整体不超过 50 个字符)
|
|
1773
|
+
4. 只输出标题,不要加任何解释
|
|
1774
|
+
|
|
1775
|
+
Commit 记录:
|
|
1776
|
+
${commitMessages || "无"}
|
|
1777
|
+
|
|
1778
|
+
文件变更:
|
|
1779
|
+
${fileChanges || "无"}`;
|
|
1780
|
+
try {
|
|
1781
|
+
const stream = this.llmProxyService.chatStream([{ role: "user", content: prompt }], {
|
|
1782
|
+
adapter: "openai",
|
|
1783
|
+
});
|
|
1784
|
+
let title = "";
|
|
1785
|
+
for await (const event of stream) {
|
|
1786
|
+
if (event.type === "text") {
|
|
1787
|
+
title += event.content;
|
|
1788
|
+
} else if (event.type === "error") {
|
|
1789
|
+
throw new Error(event.message);
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
return title.trim().slice(0, 50) || this.getFallbackTitle(commits);
|
|
1793
|
+
} catch {
|
|
1794
|
+
return this.getFallbackTitle(commits);
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
/**
|
|
1799
|
+
* 获取降级标题(从第一个 commit 消息)
|
|
1800
|
+
*/
|
|
1801
|
+
protected getFallbackTitle(commits: PullRequestCommit[]): string {
|
|
1802
|
+
const firstCommitMsg = commits[0]?.commit?.message?.split("\n")[0] || "PR 更新";
|
|
1803
|
+
return firstCommitMsg.slice(0, 50);
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
/**
|
|
1807
|
+
* 构建降级描述(当 AI 总结失败时使用)
|
|
1808
|
+
*/
|
|
1809
|
+
protected async buildFallbackDescription(
|
|
1810
|
+
commits: PullRequestCommit[],
|
|
1811
|
+
changedFiles: ChangedFile[],
|
|
1812
|
+
): Promise<{ title: string; description: string }> {
|
|
1813
|
+
const parts: string[] = [];
|
|
1814
|
+
// 使用 LLM 生成标题
|
|
1815
|
+
const title = await this.generatePrTitle(commits, changedFiles);
|
|
1816
|
+
if (commits.length > 0) {
|
|
1817
|
+
const messages = commits
|
|
1818
|
+
.slice(0, 5)
|
|
1819
|
+
.map((c) => `- ${c.commit?.message?.split("\n")[0]}`)
|
|
1820
|
+
.filter(Boolean);
|
|
1821
|
+
if (messages.length > 0) {
|
|
1822
|
+
parts.push(`**提交记录**: ${messages.join("; ")}`);
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
if (changedFiles.length > 0) {
|
|
1826
|
+
const added = changedFiles.filter((f) => f.status === "added").length;
|
|
1827
|
+
const modified = changedFiles.filter((f) => f.status === "modified").length;
|
|
1828
|
+
const deleted = changedFiles.filter((f) => f.status === "deleted").length;
|
|
1829
|
+
const stats: string[] = [];
|
|
1830
|
+
if (added > 0) stats.push(`新增 ${added}`);
|
|
1831
|
+
if (modified > 0) stats.push(`修改 ${modified}`);
|
|
1832
|
+
if (deleted > 0) stats.push(`删除 ${deleted}`);
|
|
1833
|
+
parts.push(`**文件变更**: ${changedFiles.length} 个文件 (${stats.join(", ")})`);
|
|
1834
|
+
}
|
|
1835
|
+
return { title, description: parts.join("\n") };
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
protected formatReviewComment(
|
|
1839
|
+
result: ReviewResult,
|
|
1840
|
+
options: { prNumber?: number; outputFormat?: ReportFormat; ci?: boolean } = {},
|
|
1841
|
+
): string {
|
|
1842
|
+
const { prNumber, outputFormat, ci } = options;
|
|
1843
|
+
// 智能选择格式:如果未指定,PR 模式用 markdown,终端用 terminal
|
|
1844
|
+
const format: ReportFormat = outputFormat || (ci && prNumber ? "markdown" : "terminal");
|
|
1845
|
+
|
|
1846
|
+
if (format === "markdown") {
|
|
1847
|
+
return this.reviewReportService.formatMarkdown(result, {
|
|
1848
|
+
prNumber,
|
|
1849
|
+
includeReanalysisCheckbox: true,
|
|
1850
|
+
includeJsonData: true,
|
|
1851
|
+
reviewCommentMarker: REVIEW_COMMENT_MARKER,
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
return this.reviewReportService.format(result, format);
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
protected async postOrUpdateReviewComment(
|
|
1859
|
+
owner: string,
|
|
1860
|
+
repo: string,
|
|
1861
|
+
prNumber: number,
|
|
1862
|
+
result: ReviewResult,
|
|
1863
|
+
verbose?: VerboseLevel,
|
|
1864
|
+
): Promise<void> {
|
|
1865
|
+
// 获取配置
|
|
1866
|
+
const reviewConf = this.configReader.getPluginConfig<ReviewConfig>("review");
|
|
1867
|
+
|
|
1868
|
+
// 如果配置启用且有 AI 生成的标题,只在第一轮审查时更新 PR 标题
|
|
1869
|
+
if (reviewConf.autoUpdatePrTitle && result.title && result.round === 1) {
|
|
1870
|
+
try {
|
|
1871
|
+
await this.gitProvider.editPullRequest(owner, repo, prNumber, { title: result.title });
|
|
1872
|
+
console.log(`📝 已更新 PR 标题: ${result.title}`);
|
|
1873
|
+
} catch (error) {
|
|
1874
|
+
console.warn("⚠️ 更新 PR 标题失败:", error);
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
// 获取已解决的评论,同步 fixed 状态(在删除旧 review 之前)
|
|
1879
|
+
await this.syncResolvedComments(owner, repo, prNumber, result);
|
|
1880
|
+
|
|
1881
|
+
// 获取评论的 reactions,同步 valid 状态(👎 标记为无效)
|
|
1882
|
+
await this.syncReactionsToIssues(owner, repo, prNumber, result, verbose);
|
|
1883
|
+
|
|
1884
|
+
// 删除已有的 AI review(避免重复评论)
|
|
1885
|
+
await this.deleteExistingAiReviews(owner, repo, prNumber);
|
|
1886
|
+
|
|
1887
|
+
// 调试:检查 issues 是否有 author
|
|
1888
|
+
if (shouldLog(verbose, 3)) {
|
|
1889
|
+
for (const issue of result.issues.slice(0, 3)) {
|
|
1890
|
+
console.log(
|
|
1891
|
+
`[postOrUpdateReviewComment] issue: file=${issue.file}, commit=${issue.commit}, author=${issue.author?.login}`,
|
|
1892
|
+
);
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
const reviewBody = this.formatReviewComment(result, {
|
|
1897
|
+
prNumber,
|
|
1898
|
+
outputFormat: "markdown",
|
|
1899
|
+
ci: true,
|
|
1900
|
+
});
|
|
1901
|
+
|
|
1902
|
+
// 获取 PR 信息以获取 head commit SHA
|
|
1903
|
+
const pr = await this.gitProvider.getPullRequest(owner, repo, prNumber);
|
|
1904
|
+
const commitId = pr.head?.sha;
|
|
1905
|
+
|
|
1906
|
+
// 构建行级评论(根据配置决定是否启用)
|
|
1907
|
+
let comments: CreatePullReviewComment[] = [];
|
|
1908
|
+
if (reviewConf.lineComments) {
|
|
1909
|
+
comments = result.issues
|
|
1910
|
+
.filter((issue) => !issue.fixed && issue.valid !== "false")
|
|
1911
|
+
.map((issue) => this.issueToReviewComment(issue))
|
|
1912
|
+
.filter((comment): comment is CreatePullReviewComment => comment !== null);
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
try {
|
|
1916
|
+
// 使用 PR Review 发布主评论 + 行级评论(合并为一个消息块)
|
|
1917
|
+
await this.gitProvider.createPullReview(owner, repo, prNumber, {
|
|
1918
|
+
event: "COMMENT",
|
|
1919
|
+
body: reviewBody,
|
|
1920
|
+
comments,
|
|
1921
|
+
commit_id: commitId,
|
|
1922
|
+
});
|
|
1923
|
+
const lineMsg = comments.length > 0 ? `,包含 ${comments.length} 条行级评论` : "";
|
|
1924
|
+
console.log(`✅ 已发布 AI Review${lineMsg}`);
|
|
1925
|
+
} catch (error) {
|
|
1926
|
+
console.warn("⚠️ 发布 AI Review 失败:", error);
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
/**
|
|
1931
|
+
* 从旧的 AI review 中获取已解决的评论,同步 fixed 状态到 result.issues
|
|
1932
|
+
*/
|
|
1933
|
+
protected async syncResolvedComments(
|
|
1934
|
+
owner: string,
|
|
1935
|
+
repo: string,
|
|
1936
|
+
prNumber: number,
|
|
1937
|
+
result: ReviewResult,
|
|
1938
|
+
): Promise<void> {
|
|
1939
|
+
try {
|
|
1940
|
+
const reviews = await this.gitProvider.listPullReviews(owner, repo, prNumber);
|
|
1941
|
+
const aiReview = reviews.find((r) => r.body?.includes(REVIEW_COMMENT_MARKER));
|
|
1942
|
+
if (!aiReview?.id) {
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
// 获取该 review 的所有行级评论
|
|
1946
|
+
const reviewComments = await this.gitProvider.listPullReviewComments(
|
|
1947
|
+
owner,
|
|
1948
|
+
repo,
|
|
1949
|
+
prNumber,
|
|
1950
|
+
aiReview.id,
|
|
1951
|
+
);
|
|
1952
|
+
// 找出已解决的评论(resolver 不为 null)
|
|
1953
|
+
const resolvedComments = reviewComments.filter(
|
|
1954
|
+
(c) => c.resolver !== null && c.resolver !== undefined,
|
|
1955
|
+
);
|
|
1956
|
+
if (resolvedComments.length === 0) {
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1959
|
+
// 根据文件路径和行号匹配 issues,标记为已解决
|
|
1960
|
+
const now = new Date().toISOString();
|
|
1961
|
+
for (const comment of resolvedComments) {
|
|
1962
|
+
const matchedIssue = result.issues.find(
|
|
1963
|
+
(issue) =>
|
|
1964
|
+
issue.file === comment.path && this.lineMatchesPosition(issue.line, comment.position),
|
|
1965
|
+
);
|
|
1966
|
+
if (matchedIssue && !matchedIssue.fixed) {
|
|
1967
|
+
matchedIssue.fixed = now;
|
|
1968
|
+
console.log(`🟢 问题已标记为已解决: ${matchedIssue.file}:${matchedIssue.line}`);
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
} catch (error) {
|
|
1972
|
+
console.warn("⚠️ 同步已解决评论失败:", error);
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
/**
|
|
1977
|
+
* 检查 issue 的行号是否匹配评论的 position
|
|
1978
|
+
*/
|
|
1979
|
+
protected lineMatchesPosition(issueLine: string, position?: number): boolean {
|
|
1980
|
+
if (!position) return false;
|
|
1981
|
+
const lines = this.reviewSpecService.parseLineRange(issueLine);
|
|
1982
|
+
if (lines.length === 0) return false;
|
|
1983
|
+
const startLine = lines[0];
|
|
1984
|
+
const endLine = lines[lines.length - 1];
|
|
1985
|
+
return position >= startLine && position <= endLine;
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
/**
|
|
1989
|
+
* 从旧的 AI review 评论中获取 reactions 和回复,同步到 result.issues
|
|
1990
|
+
* - 存储所有 reactions 到 issue.reactions 字段
|
|
1991
|
+
* - 存储评论回复到 issue.replies 字段
|
|
1992
|
+
* - 如果评论有 👎 (-1) reaction,将对应的问题标记为无效
|
|
1993
|
+
*/
|
|
1994
|
+
protected async syncReactionsToIssues(
|
|
1995
|
+
owner: string,
|
|
1996
|
+
repo: string,
|
|
1997
|
+
prNumber: number,
|
|
1998
|
+
result: ReviewResult,
|
|
1999
|
+
verbose?: VerboseLevel,
|
|
2000
|
+
): Promise<void> {
|
|
2001
|
+
try {
|
|
2002
|
+
const reviews = await this.gitProvider.listPullReviews(owner, repo, prNumber);
|
|
2003
|
+
const aiReview = reviews.find((r) => r.body?.includes(REVIEW_COMMENT_MARKER));
|
|
2004
|
+
if (!aiReview?.id) {
|
|
2005
|
+
if (shouldLog(verbose, 2)) {
|
|
2006
|
+
console.log(`[syncReactionsToIssues] No AI review found`);
|
|
2007
|
+
}
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
// 收集所有评审人
|
|
2012
|
+
const reviewers = new Set<string>();
|
|
2013
|
+
|
|
2014
|
+
// 1. 从已提交的 review 中获取评审人(排除 AI bot)
|
|
2015
|
+
for (const review of reviews) {
|
|
2016
|
+
if (review.user?.login && !review.body?.includes(REVIEW_COMMENT_MARKER)) {
|
|
2017
|
+
reviewers.add(review.user.login);
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
if (shouldLog(verbose, 2)) {
|
|
2021
|
+
console.log(
|
|
2022
|
+
`[syncReactionsToIssues] reviewers from reviews: ${Array.from(reviewers).join(", ")}`,
|
|
2023
|
+
);
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
// 2. 从 PR 指定的评审人中获取(包括团队成员)
|
|
2027
|
+
try {
|
|
2028
|
+
const pr = await this.gitProvider.getPullRequest(owner, repo, prNumber);
|
|
2029
|
+
// 添加指定的个人评审人
|
|
2030
|
+
for (const reviewer of pr.requested_reviewers || []) {
|
|
2031
|
+
if (reviewer.login) {
|
|
2032
|
+
reviewers.add(reviewer.login);
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
if (shouldLog(verbose, 2)) {
|
|
2036
|
+
console.log(
|
|
2037
|
+
`[syncReactionsToIssues] requested_reviewers: ${(pr.requested_reviewers || []).map((r) => r.login).join(", ")}`,
|
|
2038
|
+
);
|
|
2039
|
+
console.log(
|
|
2040
|
+
`[syncReactionsToIssues] requested_reviewers_teams: ${JSON.stringify(pr.requested_reviewers_teams || [])}`,
|
|
2041
|
+
);
|
|
2042
|
+
}
|
|
2043
|
+
// 添加指定的团队成员(需要通过 API 获取团队成员列表)
|
|
2044
|
+
for (const team of pr.requested_reviewers_teams || []) {
|
|
2045
|
+
if (team.id) {
|
|
2046
|
+
try {
|
|
2047
|
+
const members = await this.gitProvider.getTeamMembers(team.id);
|
|
2048
|
+
if (shouldLog(verbose, 2)) {
|
|
2049
|
+
console.log(
|
|
2050
|
+
`[syncReactionsToIssues] team ${team.name}(${team.id}) members: ${members.map((m) => m.login).join(", ")}`,
|
|
2051
|
+
);
|
|
2052
|
+
}
|
|
2053
|
+
for (const member of members) {
|
|
2054
|
+
if (member.login) {
|
|
2055
|
+
reviewers.add(member.login);
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
} catch (e) {
|
|
2059
|
+
if (shouldLog(verbose, 2)) {
|
|
2060
|
+
console.log(`[syncReactionsToIssues] failed to get team ${team.id} members: ${e}`);
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
} catch {
|
|
2066
|
+
// 获取 PR 信息失败,继续使用已有的评审人列表
|
|
2067
|
+
}
|
|
2068
|
+
if (shouldLog(verbose, 2)) {
|
|
2069
|
+
console.log(`[syncReactionsToIssues] final reviewers: ${Array.from(reviewers).join(", ")}`);
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
// 获取该 review 的所有行级评论
|
|
2073
|
+
const reviewComments = await this.gitProvider.listPullReviewComments(
|
|
2074
|
+
owner,
|
|
2075
|
+
repo,
|
|
2076
|
+
prNumber,
|
|
2077
|
+
aiReview.id,
|
|
2078
|
+
);
|
|
2079
|
+
// 构建评论 ID 到 issue 的映射,用于后续匹配回复
|
|
2080
|
+
const commentIdToIssue = new Map<number, (typeof result.issues)[0]>();
|
|
2081
|
+
// 遍历每个评论,获取其 reactions
|
|
2082
|
+
for (const comment of reviewComments) {
|
|
2083
|
+
if (!comment.id) continue;
|
|
2084
|
+
// 找到对应的 issue
|
|
2085
|
+
const matchedIssue = result.issues.find(
|
|
2086
|
+
(issue) =>
|
|
2087
|
+
issue.file === comment.path && this.lineMatchesPosition(issue.line, comment.position),
|
|
2088
|
+
);
|
|
2089
|
+
if (matchedIssue) {
|
|
2090
|
+
commentIdToIssue.set(comment.id, matchedIssue);
|
|
2091
|
+
}
|
|
2092
|
+
try {
|
|
2093
|
+
const reactions = await this.gitProvider.getIssueCommentReactions(
|
|
2094
|
+
owner,
|
|
2095
|
+
repo,
|
|
2096
|
+
comment.id,
|
|
2097
|
+
);
|
|
2098
|
+
if (reactions.length === 0 || !matchedIssue) continue;
|
|
2099
|
+
// 按 content 分组,收集每种 reaction 的用户列表
|
|
2100
|
+
const reactionMap = new Map<string, string[]>();
|
|
2101
|
+
for (const r of reactions) {
|
|
2102
|
+
if (!r.content) continue;
|
|
2103
|
+
const users = reactionMap.get(r.content) || [];
|
|
2104
|
+
if (r.user?.login) {
|
|
2105
|
+
users.push(r.user.login);
|
|
2106
|
+
}
|
|
2107
|
+
reactionMap.set(r.content, users);
|
|
2108
|
+
}
|
|
2109
|
+
// 存储到 issue.reactions
|
|
2110
|
+
matchedIssue.reactions = Array.from(reactionMap.entries()).map(([content, users]) => ({
|
|
2111
|
+
content,
|
|
2112
|
+
users,
|
|
2113
|
+
}));
|
|
2114
|
+
// 检查是否有评审人的 👎 (-1) reaction,标记为无效
|
|
2115
|
+
const thumbsDownUsers = reactionMap.get("-1") || [];
|
|
2116
|
+
const reviewerThumbsDown = thumbsDownUsers.filter((u) => reviewers.has(u));
|
|
2117
|
+
if (reviewerThumbsDown.length > 0 && matchedIssue.valid !== "false") {
|
|
2118
|
+
matchedIssue.valid = "false";
|
|
2119
|
+
console.log(
|
|
2120
|
+
`👎 问题已标记为无效: ${matchedIssue.file}:${matchedIssue.line} (by 评审人: ${reviewerThumbsDown.join(", ")})`,
|
|
2121
|
+
);
|
|
2122
|
+
}
|
|
2123
|
+
} catch {
|
|
2124
|
+
// 单个评论获取 reactions 失败,继续处理其他评论
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
// 获取 PR 上的所有 Issue Comments(包含对 review 评论的回复)
|
|
2128
|
+
await this.syncRepliesToIssues(owner, repo, prNumber, reviewComments, result);
|
|
2129
|
+
} catch (error) {
|
|
2130
|
+
console.warn("⚠️ 同步评论 reactions 失败:", error);
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
/**
|
|
2135
|
+
* 同步评论回复到对应的 issues
|
|
2136
|
+
* review 评论回复是通过同一个 review 下的后续评论实现的
|
|
2137
|
+
*/
|
|
2138
|
+
protected async syncRepliesToIssues(
|
|
2139
|
+
_owner: string,
|
|
2140
|
+
_repo: string,
|
|
2141
|
+
_prNumber: number,
|
|
2142
|
+
reviewComments: {
|
|
2143
|
+
id?: number;
|
|
2144
|
+
path?: string;
|
|
2145
|
+
position?: number;
|
|
2146
|
+
body?: string;
|
|
2147
|
+
user?: { id?: number; login?: string };
|
|
2148
|
+
created_at?: string;
|
|
2149
|
+
}[],
|
|
2150
|
+
result: ReviewResult,
|
|
2151
|
+
): Promise<void> {
|
|
2152
|
+
try {
|
|
2153
|
+
// 按文件路径和行号分组评论,第一条是原始评论,后续是回复
|
|
2154
|
+
const commentsByLocation = new Map<string, typeof reviewComments>();
|
|
2155
|
+
for (const comment of reviewComments) {
|
|
2156
|
+
if (!comment.path || !comment.position) continue;
|
|
2157
|
+
const key = `${comment.path}:${comment.position}`;
|
|
2158
|
+
const comments = commentsByLocation.get(key) || [];
|
|
2159
|
+
comments.push(comment);
|
|
2160
|
+
commentsByLocation.set(key, comments);
|
|
2161
|
+
}
|
|
2162
|
+
// 遍历每个位置的评论,将非第一条评论作为回复
|
|
2163
|
+
for (const [, comments] of commentsByLocation) {
|
|
2164
|
+
if (comments.length <= 1) continue;
|
|
2165
|
+
// 按创建时间排序
|
|
2166
|
+
comments.sort((a, b) => {
|
|
2167
|
+
const timeA = a.created_at ? new Date(a.created_at).getTime() : 0;
|
|
2168
|
+
const timeB = b.created_at ? new Date(b.created_at).getTime() : 0;
|
|
2169
|
+
return timeA - timeB;
|
|
2170
|
+
});
|
|
2171
|
+
const firstComment = comments[0];
|
|
2172
|
+
// 找到对应的 issue
|
|
2173
|
+
const matchedIssue = result.issues.find(
|
|
2174
|
+
(issue) =>
|
|
2175
|
+
issue.file === firstComment.path &&
|
|
2176
|
+
this.lineMatchesPosition(issue.line, firstComment.position),
|
|
2177
|
+
);
|
|
2178
|
+
if (!matchedIssue) continue;
|
|
2179
|
+
// 后续评论作为回复
|
|
2180
|
+
const replies = comments.slice(1).map((c) => ({
|
|
2181
|
+
user: {
|
|
2182
|
+
id: c.user?.id?.toString(),
|
|
2183
|
+
login: c.user?.login || "unknown",
|
|
2184
|
+
},
|
|
2185
|
+
body: c.body || "",
|
|
2186
|
+
createdAt: c.created_at || "",
|
|
2187
|
+
}));
|
|
2188
|
+
matchedIssue.replies = replies;
|
|
2189
|
+
}
|
|
2190
|
+
} catch (error) {
|
|
2191
|
+
console.warn("⚠️ 同步评论回复失败:", error);
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
/**
|
|
2196
|
+
* 删除已有的 AI review(通过 marker 识别)
|
|
2197
|
+
*/
|
|
2198
|
+
protected async deleteExistingAiReviews(
|
|
2199
|
+
owner: string,
|
|
2200
|
+
repo: string,
|
|
2201
|
+
prNumber: number,
|
|
2202
|
+
): Promise<void> {
|
|
2203
|
+
try {
|
|
2204
|
+
const reviews = await this.gitProvider.listPullReviews(owner, repo, prNumber);
|
|
2205
|
+
const aiReviews = reviews.filter((r) => r.body?.includes(REVIEW_COMMENT_MARKER));
|
|
2206
|
+
for (const review of aiReviews) {
|
|
2207
|
+
if (review.id) {
|
|
2208
|
+
await this.gitProvider.deletePullReview(owner, repo, prNumber, review.id);
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
if (aiReviews.length > 0) {
|
|
2212
|
+
console.log(`🗑️ 已删除 ${aiReviews.length} 个旧的 AI review`);
|
|
2213
|
+
}
|
|
2214
|
+
} catch (error) {
|
|
2215
|
+
console.warn("⚠️ 删除旧 AI review 失败:", error);
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
/**
|
|
2220
|
+
* 将单个 ReviewIssue 转换为 CreatePullReviewComment
|
|
2221
|
+
*/
|
|
2222
|
+
protected issueToReviewComment(issue: ReviewIssue): CreatePullReviewComment | null {
|
|
2223
|
+
const lineNums = this.reviewSpecService.parseLineRange(issue.line);
|
|
2224
|
+
if (lineNums.length === 0) {
|
|
2225
|
+
return null;
|
|
2226
|
+
}
|
|
2227
|
+
const lineNum = lineNums[0];
|
|
2228
|
+
// 构建评论内容,参照 markdown.formatter.ts 的格式
|
|
2229
|
+
const severityEmoji =
|
|
2230
|
+
issue.severity === "error" ? "🔴" : issue.severity === "warn" ? "🟡" : "⚪";
|
|
2231
|
+
const lines: string[] = [];
|
|
2232
|
+
lines.push(`${severityEmoji} **${issue.reason}**`);
|
|
2233
|
+
lines.push(`- **文件**: \`${issue.file}:${issue.line}\``);
|
|
2234
|
+
lines.push(`- **规则**: \`${issue.ruleId}\` (来自 \`${issue.specFile}\`)`);
|
|
2235
|
+
if (issue.commit) {
|
|
2236
|
+
lines.push(`- **Commit**: ${issue.commit}`);
|
|
2237
|
+
}
|
|
2238
|
+
lines.push(`- **开发人员**: ${issue.author ? "@" + issue.author.login : "未知"}`);
|
|
2239
|
+
if (issue.suggestion) {
|
|
2240
|
+
const ext = extname(issue.file).slice(1) || "";
|
|
2241
|
+
const cleanSuggestion = issue.suggestion.replace(/```/g, "//").trim();
|
|
2242
|
+
lines.push(`- **建议**:`);
|
|
2243
|
+
lines.push(`\`\`\`${ext}`);
|
|
2244
|
+
lines.push(cleanSuggestion);
|
|
2245
|
+
lines.push("```");
|
|
2246
|
+
}
|
|
2247
|
+
return {
|
|
2248
|
+
path: issue.file,
|
|
2249
|
+
body: lines.join("\n"),
|
|
2250
|
+
new_position: lineNum,
|
|
2251
|
+
old_position: 0,
|
|
2252
|
+
};
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
protected generateIssueKey(issue: ReviewIssue): string {
|
|
2256
|
+
return `${issue.file}:${issue.line}:${issue.ruleId}`;
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
protected parseExistingReviewResult(commentBody: string): ReviewResult | null {
|
|
2260
|
+
const parsed = this.reviewReportService.parseMarkdown(commentBody);
|
|
2261
|
+
if (!parsed) {
|
|
2262
|
+
return null;
|
|
2263
|
+
}
|
|
2264
|
+
return parsed.result;
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
/**
|
|
2268
|
+
* 将有变更文件的历史 issue 标记为无效
|
|
2269
|
+
* 简化策略:如果文件在最新 commit 中有变更,则将该文件的所有历史问题标记为无效
|
|
2270
|
+
* @param issues 历史 issue 列表
|
|
2271
|
+
* @param headSha 当前 PR head 的 SHA
|
|
2272
|
+
* @param owner 仓库所有者
|
|
2273
|
+
* @param repo 仓库名
|
|
2274
|
+
* @param verbose 日志级别
|
|
2275
|
+
* @returns 更新后的 issue 列表
|
|
2276
|
+
*/
|
|
2277
|
+
protected async invalidateIssuesForChangedFiles(
|
|
2278
|
+
issues: ReviewIssue[],
|
|
2279
|
+
headSha: string | undefined,
|
|
2280
|
+
owner: string,
|
|
2281
|
+
repo: string,
|
|
2282
|
+
verbose?: VerboseLevel,
|
|
2283
|
+
): Promise<ReviewIssue[]> {
|
|
2284
|
+
if (!headSha) {
|
|
2285
|
+
if (shouldLog(verbose, 1)) {
|
|
2286
|
+
console.log(` ⚠️ 无法获取 PR head SHA,跳过变更文件检查`);
|
|
2287
|
+
}
|
|
2288
|
+
return issues;
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
if (shouldLog(verbose, 1)) {
|
|
2292
|
+
console.log(` 📊 获取最新 commit 变更文件: ${headSha.slice(0, 7)}`);
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
try {
|
|
2296
|
+
// 使用 Git Provider API 获取最新一次 commit 的 diff
|
|
2297
|
+
const diffText = await this.gitProvider.getCommitDiff(owner, repo, headSha);
|
|
2298
|
+
const diffFiles = parseDiffText(diffText);
|
|
2299
|
+
|
|
2300
|
+
if (diffFiles.length === 0) {
|
|
2301
|
+
if (shouldLog(verbose, 1)) {
|
|
2302
|
+
console.log(` ⏭️ 最新 commit 无文件变更`);
|
|
2303
|
+
}
|
|
2304
|
+
return issues;
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
// 构建变更文件集合
|
|
2308
|
+
const changedFileSet = new Set(diffFiles.map((f) => f.filename));
|
|
2309
|
+
if (shouldLog(verbose, 2)) {
|
|
2310
|
+
console.log(` [invalidateIssues] 变更文件: ${[...changedFileSet].join(", ")}`);
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
// 将变更文件的历史 issue 标记为无效
|
|
2314
|
+
let invalidatedCount = 0;
|
|
2315
|
+
const updatedIssues = issues.map((issue) => {
|
|
2316
|
+
// 如果 issue 已修复或已无效,不需要处理
|
|
2317
|
+
if (issue.fixed || issue.valid === "false") {
|
|
2318
|
+
return issue;
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
// 如果 issue 所在文件有变更,标记为无效
|
|
2322
|
+
if (changedFileSet.has(issue.file)) {
|
|
2323
|
+
invalidatedCount++;
|
|
2324
|
+
if (shouldLog(verbose, 1)) {
|
|
2325
|
+
console.log(` 🗑️ Issue ${issue.file}:${issue.line} 所在文件有变更,标记为无效`);
|
|
2326
|
+
}
|
|
2327
|
+
return { ...issue, valid: "false", originalLine: issue.originalLine ?? issue.line };
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
return issue;
|
|
2331
|
+
});
|
|
2332
|
+
|
|
2333
|
+
if (invalidatedCount > 0 && shouldLog(verbose, 1)) {
|
|
2334
|
+
console.log(` 📊 共标记 ${invalidatedCount} 个历史问题为无效(文件有变更)`);
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
return updatedIssues;
|
|
2338
|
+
} catch (error) {
|
|
2339
|
+
if (shouldLog(verbose, 1)) {
|
|
2340
|
+
console.log(` ⚠️ 获取最新 commit 变更文件失败: ${error}`);
|
|
2341
|
+
}
|
|
2342
|
+
return issues;
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
/**
|
|
2347
|
+
* 根据代码变更更新历史 issue 的行号
|
|
2348
|
+
* 当代码发生变化时,之前发现的 issue 行号可能已经不准确
|
|
2349
|
+
* 此方法通过分析 diff 来计算新的行号
|
|
2350
|
+
* @param issues 历史 issue 列表
|
|
2351
|
+
* @param filePatchMap 文件名到 patch 的映射
|
|
2352
|
+
* @param verbose 日志级别
|
|
2353
|
+
* @returns 更新后的 issue 列表
|
|
2354
|
+
*/
|
|
2355
|
+
protected updateIssueLineNumbers(
|
|
2356
|
+
issues: ReviewIssue[],
|
|
2357
|
+
filePatchMap: Map<string, string>,
|
|
2358
|
+
verbose?: VerboseLevel,
|
|
2359
|
+
): ReviewIssue[] {
|
|
2360
|
+
let updatedCount = 0;
|
|
2361
|
+
let invalidatedCount = 0;
|
|
2362
|
+
const updatedIssues = issues.map((issue) => {
|
|
2363
|
+
// 如果 issue 已修复或无效,不需要更新行号
|
|
2364
|
+
if (issue.fixed || issue.valid === "false") {
|
|
2365
|
+
return issue;
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
const patch = filePatchMap.get(issue.file);
|
|
2369
|
+
if (!patch) {
|
|
2370
|
+
// 文件没有变更,行号不变
|
|
2371
|
+
return issue;
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
const lines = this.reviewSpecService.parseLineRange(issue.line);
|
|
2375
|
+
if (lines.length === 0) {
|
|
2376
|
+
return issue;
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
const startLine = lines[0];
|
|
2380
|
+
const endLine = lines[lines.length - 1];
|
|
2381
|
+
const hunks = parseHunksFromPatch(patch);
|
|
2382
|
+
|
|
2383
|
+
// 计算新的起始行号
|
|
2384
|
+
const newStartLine = calculateNewLineNumber(startLine, hunks);
|
|
2385
|
+
if (newStartLine === null) {
|
|
2386
|
+
// 起始行被删除,直接标记为无效问题
|
|
2387
|
+
invalidatedCount++;
|
|
2388
|
+
if (shouldLog(verbose, 1)) {
|
|
2389
|
+
console.log(`📍 Issue ${issue.file}:${issue.line} 对应的代码已被删除,标记为无效`);
|
|
2390
|
+
}
|
|
2391
|
+
return { ...issue, valid: "false", originalLine: issue.originalLine ?? issue.line };
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
// 如果是范围行号,计算新的结束行号
|
|
2395
|
+
let newLine: string;
|
|
2396
|
+
if (startLine === endLine) {
|
|
2397
|
+
newLine = String(newStartLine);
|
|
2398
|
+
} else {
|
|
2399
|
+
const newEndLine = calculateNewLineNumber(endLine, hunks);
|
|
2400
|
+
if (newEndLine === null || newEndLine === newStartLine) {
|
|
2401
|
+
// 结束行被删除或范围缩小为单行,使用起始行
|
|
2402
|
+
newLine = String(newStartLine);
|
|
2403
|
+
} else {
|
|
2404
|
+
newLine = `${newStartLine}-${newEndLine}`;
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
// 如果行号发生变化,更新 issue
|
|
2409
|
+
if (newLine !== issue.line) {
|
|
2410
|
+
updatedCount++;
|
|
2411
|
+
if (shouldLog(verbose, 1)) {
|
|
2412
|
+
console.log(`📍 Issue 行号更新: ${issue.file}:${issue.line} -> ${issue.file}:${newLine}`);
|
|
2413
|
+
}
|
|
2414
|
+
return { ...issue, line: newLine, originalLine: issue.originalLine ?? issue.line };
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
return issue;
|
|
2418
|
+
});
|
|
2419
|
+
|
|
2420
|
+
if ((updatedCount > 0 || invalidatedCount > 0) && shouldLog(verbose, 1)) {
|
|
2421
|
+
const parts: string[] = [];
|
|
2422
|
+
if (updatedCount > 0) parts.push(`更新 ${updatedCount} 个行号`);
|
|
2423
|
+
if (invalidatedCount > 0) parts.push(`标记 ${invalidatedCount} 个无效`);
|
|
2424
|
+
console.log(`📊 Issue 行号处理: ${parts.join(",")}`);
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
return updatedIssues;
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
/**
|
|
2431
|
+
* 过滤掉不属于本次 PR commits 的问题(排除 merge commit 引入的代码)
|
|
2432
|
+
* 根据 fileContents 中问题行的实际 commit hash 进行验证,而不是依赖 LLM 填写的 commit
|
|
2433
|
+
*/
|
|
2434
|
+
protected filterIssuesByValidCommits(
|
|
2435
|
+
issues: ReviewIssue[],
|
|
2436
|
+
commits: PullRequestCommit[],
|
|
2437
|
+
fileContents: FileContentsMap,
|
|
2438
|
+
verbose?: VerboseLevel,
|
|
2439
|
+
): ReviewIssue[] {
|
|
2440
|
+
const validCommitHashes = new Set(commits.map((c) => c.sha?.slice(0, 7)).filter(Boolean));
|
|
2441
|
+
|
|
2442
|
+
if (shouldLog(verbose, 3)) {
|
|
2443
|
+
console.log(` 🔍 有效 commit hashes: ${Array.from(validCommitHashes).join(", ")}`);
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
const beforeCount = issues.length;
|
|
2447
|
+
const filtered = issues.filter((issue) => {
|
|
2448
|
+
const contentLines = fileContents.get(issue.file);
|
|
2449
|
+
if (!contentLines) {
|
|
2450
|
+
// 文件不在 fileContents 中,保留 issue
|
|
2451
|
+
if (shouldLog(verbose, 3)) {
|
|
2452
|
+
console.log(` ✅ Issue ${issue.file}:${issue.line} - 文件不在 fileContents 中,保留`);
|
|
2453
|
+
}
|
|
2454
|
+
return true;
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
const lineNums = this.reviewSpecService.parseLineRange(issue.line);
|
|
2458
|
+
if (lineNums.length === 0) {
|
|
2459
|
+
if (shouldLog(verbose, 3)) {
|
|
2460
|
+
console.log(` ✅ Issue ${issue.file}:${issue.line} - 无法解析行号,保留`);
|
|
2461
|
+
}
|
|
2462
|
+
return true;
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
// 检查问题行范围内是否有任意一行属于本次 PR 的有效 commits
|
|
2466
|
+
for (const lineNum of lineNums) {
|
|
2467
|
+
const lineData = contentLines[lineNum - 1];
|
|
2468
|
+
if (lineData) {
|
|
2469
|
+
const [actualHash] = lineData;
|
|
2470
|
+
if (actualHash !== "-------" && validCommitHashes.has(actualHash)) {
|
|
2471
|
+
if (shouldLog(verbose, 3)) {
|
|
2472
|
+
console.log(
|
|
2473
|
+
` ✅ Issue ${issue.file}:${issue.line} - 行 ${lineNum} hash=${actualHash} 匹配,保留`,
|
|
2474
|
+
);
|
|
2475
|
+
}
|
|
2476
|
+
return true;
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
// 问题行都不属于本次 PR 的有效 commits
|
|
2482
|
+
if (shouldLog(verbose, 2)) {
|
|
2483
|
+
console.log(` Issue ${issue.file}:${issue.line} 不在本次 PR 变更行范围内,跳过`);
|
|
2484
|
+
}
|
|
2485
|
+
if (shouldLog(verbose, 3)) {
|
|
2486
|
+
const hashes = lineNums.map((ln) => {
|
|
2487
|
+
const ld = contentLines[ln - 1];
|
|
2488
|
+
return ld ? `${ln}:${ld[0]}` : `${ln}:N/A`;
|
|
2489
|
+
});
|
|
2490
|
+
console.log(` ❌ Issue ${issue.file}:${issue.line} - 行号 hash: ${hashes.join(", ")}`);
|
|
2491
|
+
}
|
|
2492
|
+
return false;
|
|
2493
|
+
});
|
|
2494
|
+
if (beforeCount !== filtered.length && shouldLog(verbose, 1)) {
|
|
2495
|
+
console.log(` 过滤非本次 PR commits 问题后: ${beforeCount} -> ${filtered.length} 个问题`);
|
|
2496
|
+
}
|
|
2497
|
+
return filtered;
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
protected filterDuplicateIssues(
|
|
2501
|
+
newIssues: ReviewIssue[],
|
|
2502
|
+
existingIssues: ReviewIssue[],
|
|
2503
|
+
): { filteredIssues: ReviewIssue[]; skippedCount: number } {
|
|
2504
|
+
// 只有 valid === 'true' 的历史问题才阻止新问题,其他情况允许覆盖
|
|
2505
|
+
const existingKeys = new Set(
|
|
2506
|
+
existingIssues
|
|
2507
|
+
.filter((issue) => issue.valid === "true")
|
|
2508
|
+
.map((issue) => this.generateIssueKey(issue)),
|
|
2509
|
+
);
|
|
2510
|
+
const filteredIssues = newIssues.filter(
|
|
2511
|
+
(issue) => !existingKeys.has(this.generateIssueKey(issue)),
|
|
2512
|
+
);
|
|
2513
|
+
const skippedCount = newIssues.length - filteredIssues.length;
|
|
2514
|
+
return { filteredIssues, skippedCount };
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
protected async getExistingReviewResult(
|
|
2518
|
+
owner: string,
|
|
2519
|
+
repo: string,
|
|
2520
|
+
prNumber: number,
|
|
2521
|
+
): Promise<ReviewResult | null> {
|
|
2522
|
+
try {
|
|
2523
|
+
// 从 PR Review 获取已有的审查结果
|
|
2524
|
+
const reviews = await this.gitProvider.listPullReviews(owner, repo, prNumber);
|
|
2525
|
+
const existingReview = reviews.find((r) => r.body?.includes(REVIEW_COMMENT_MARKER));
|
|
2526
|
+
if (existingReview?.body) {
|
|
2527
|
+
return this.parseExistingReviewResult(existingReview.body);
|
|
2528
|
+
}
|
|
2529
|
+
} catch (error) {
|
|
2530
|
+
console.warn("⚠️ 获取已有评论失败:", error);
|
|
2531
|
+
}
|
|
2532
|
+
return null;
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
protected async ensureClaudeCli(): Promise<void> {
|
|
2536
|
+
try {
|
|
2537
|
+
execSync("claude --version", { stdio: "ignore" });
|
|
2538
|
+
} catch {
|
|
2539
|
+
console.log("🔧 Claude CLI 未安装,正在安装...");
|
|
2540
|
+
try {
|
|
2541
|
+
execSync("npm install -g @anthropic-ai/claude-code", {
|
|
2542
|
+
stdio: "inherit",
|
|
2543
|
+
});
|
|
2544
|
+
console.log("✅ Claude CLI 安装完成");
|
|
2545
|
+
} catch (installError) {
|
|
2546
|
+
throw new Error(
|
|
2547
|
+
`Claude CLI 安装失败: ${installError instanceof Error ? installError.message : String(installError)}`,
|
|
2548
|
+
);
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
/**
|
|
2554
|
+
* 构建文件行号到 commit hash 的映射
|
|
2555
|
+
* 遍历每个 commit,获取其修改的文件和行号
|
|
2556
|
+
* 优先使用 API,失败时回退到 git 命令
|
|
2557
|
+
*/
|
|
2558
|
+
protected async buildLineCommitMap(
|
|
2559
|
+
owner: string,
|
|
2560
|
+
repo: string,
|
|
2561
|
+
commits: PullRequestCommit[],
|
|
2562
|
+
verbose?: VerboseLevel,
|
|
2563
|
+
): Promise<Map<string, Map<number, string>>> {
|
|
2564
|
+
// Map<filename, Map<lineNumber, commitHash>>
|
|
2565
|
+
const fileLineMap = new Map<string, Map<number, string>>();
|
|
2566
|
+
|
|
2567
|
+
// 按时间顺序遍历 commits(早的在前),后面的 commit 会覆盖前面的
|
|
2568
|
+
for (const commit of commits) {
|
|
2569
|
+
if (!commit.sha) continue;
|
|
2570
|
+
|
|
2571
|
+
const shortHash = commit.sha.slice(0, 7);
|
|
2572
|
+
let files: Array<{ filename: string; patch: string }> = [];
|
|
2573
|
+
|
|
2574
|
+
// 优先使用 getCommitDiff API 获取 diff 文本
|
|
2575
|
+
try {
|
|
2576
|
+
const diffText = await this.gitProvider.getCommitDiff(owner, repo, commit.sha);
|
|
2577
|
+
files = parseDiffText(diffText);
|
|
2578
|
+
} catch {
|
|
2579
|
+
// API 失败,回退到 git 命令
|
|
2580
|
+
files = this.gitSdk.getCommitDiff(commit.sha);
|
|
2581
|
+
}
|
|
2582
|
+
if (shouldLog(verbose, 2)) console.log(` commit ${shortHash}: ${files.length} 个文件变更`);
|
|
2583
|
+
|
|
2584
|
+
for (const file of files) {
|
|
2585
|
+
// 解析这个 commit 修改的行号
|
|
2586
|
+
const changedLines = parseChangedLinesFromPatch(file.patch);
|
|
2587
|
+
|
|
2588
|
+
// 获取或创建文件的行号映射
|
|
2589
|
+
if (!fileLineMap.has(file.filename)) {
|
|
2590
|
+
fileLineMap.set(file.filename, new Map());
|
|
2591
|
+
}
|
|
2592
|
+
const lineMap = fileLineMap.get(file.filename)!;
|
|
2593
|
+
|
|
2594
|
+
// 记录每行对应的 commit hash
|
|
2595
|
+
for (const lineNum of changedLines) {
|
|
2596
|
+
lineMap.set(lineNum, shortHash);
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
return fileLineMap;
|
|
2602
|
+
}
|
|
2603
|
+
}
|