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