@spaceflow/review 0.76.0 → 0.78.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 +47 -0
- package/dist/index.js +3830 -2469
- package/package.json +2 -2
- package/src/deletion-impact.service.ts +17 -130
- package/src/index.ts +34 -2
- package/src/issue-verify.service.ts +18 -82
- package/src/locales/en/review.json +2 -1
- package/src/locales/zh-cn/review.json +2 -1
- package/src/mcp/index.ts +4 -1
- package/src/prompt/code-review.ts +95 -0
- package/src/prompt/deletion-impact.ts +105 -0
- package/src/prompt/index.ts +37 -0
- package/src/prompt/issue-verify.ts +86 -0
- package/src/prompt/pr-description.ts +149 -0
- package/src/prompt/schemas.ts +106 -0
- package/src/prompt/types.ts +53 -0
- package/src/pull-request-model.ts +236 -0
- package/src/review-context.ts +433 -0
- package/src/review-includes-filter.spec.ts +284 -0
- package/src/review-includes-filter.ts +196 -0
- package/src/review-issue-filter.ts +523 -0
- package/src/review-llm.ts +543 -0
- package/src/review-result-model.spec.ts +657 -0
- package/src/review-result-model.ts +1046 -0
- package/src/review-spec/review-spec.service.ts +26 -5
- package/src/review-spec/types.ts +2 -0
- package/src/review.config.ts +40 -5
- package/src/review.service.spec.ts +102 -1625
- package/src/review.service.ts +608 -2742
- package/src/system-rules/index.ts +48 -0
- package/src/system-rules/max-lines-per-file.ts +57 -0
- package/src/types/review-llm.ts +21 -0
- package/src/utils/review-llm.spec.ts +277 -0
- package/src/utils/review-llm.ts +177 -0
- package/src/utils/review-pr-comment.spec.ts +340 -0
- package/src/utils/review-pr-comment.ts +186 -0
- package/tsconfig.json +1 -1
package/src/review.service.ts
CHANGED
|
@@ -1,152 +1,38 @@
|
|
|
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
|
-
|
|
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";
|
|
26
|
+
import { REVIEW_COMMENT_MARKER, REVIEW_LINE_COMMENTS_MARKER } from "./utils/review-pr-comment";
|
|
102
27
|
|
|
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
|
-
};
|
|
28
|
+
export type { ReviewContext } from "./review-context";
|
|
29
|
+
export type { FileReviewPrompt, ReviewPrompt, LLMReviewOptions } from "./review-llm";
|
|
147
30
|
|
|
148
31
|
export class ReviewService {
|
|
149
|
-
protected readonly
|
|
32
|
+
protected readonly contextBuilder: ReviewContextBuilder;
|
|
33
|
+
protected readonly issueFilter: ReviewIssueFilter;
|
|
34
|
+
protected readonly llmProcessor: ReviewLlmProcessor;
|
|
35
|
+
protected readonly resultModelDeps: ReviewResultModelDeps;
|
|
150
36
|
|
|
151
37
|
constructor(
|
|
152
38
|
protected readonly gitProvider: GitProviderService,
|
|
@@ -158,319 +44,202 @@ export class ReviewService {
|
|
|
158
44
|
protected readonly deletionImpactService: DeletionImpactService,
|
|
159
45
|
protected readonly gitSdk: GitSdkService,
|
|
160
46
|
) {
|
|
161
|
-
this.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
47
|
+
this.contextBuilder = new ReviewContextBuilder(gitProvider, config, gitSdk);
|
|
48
|
+
this.issueFilter = new ReviewIssueFilter(
|
|
49
|
+
gitProvider,
|
|
50
|
+
config,
|
|
51
|
+
reviewSpecService,
|
|
52
|
+
issueVerifyService,
|
|
53
|
+
gitSdk,
|
|
54
|
+
);
|
|
55
|
+
this.llmProcessor = new ReviewLlmProcessor(llmProxyService, reviewSpecService);
|
|
56
|
+
this.resultModelDeps = {
|
|
57
|
+
gitProvider,
|
|
58
|
+
config,
|
|
59
|
+
reviewSpecService,
|
|
60
|
+
reviewReportService,
|
|
61
|
+
};
|
|
176
62
|
}
|
|
177
63
|
|
|
178
64
|
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
|
-
}
|
|
65
|
+
return this.contextBuilder.getContextFromEnv(options);
|
|
66
|
+
}
|
|
201
67
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
68
|
+
/**
|
|
69
|
+
* 执行代码审查的主方法
|
|
70
|
+
* 该方法负责协调整个审查流程,包括:
|
|
71
|
+
* 1. 加载审查规范(specs)
|
|
72
|
+
* 2. 获取 PR/分支的变更文件和提交记录
|
|
73
|
+
* 3. 调用 LLM 进行代码审查
|
|
74
|
+
* 4. 处理历史 issue(更新行号、验证修复状态)
|
|
75
|
+
* 5. 生成并发布审查报告
|
|
76
|
+
*
|
|
77
|
+
* @param context 审查上下文,包含 owner、repo、prNumber 等信息
|
|
78
|
+
* @returns 审查结果,包含发现的问题列表和统计信息
|
|
79
|
+
*/
|
|
80
|
+
async execute(context: ReviewContext): Promise<ReviewResult> {
|
|
81
|
+
const { specSources, verbose, llmMode, deletionOnly } = context;
|
|
205
82
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
83
|
+
if (shouldLog(verbose, 1)) {
|
|
84
|
+
console.log(`🔍 Review 启动`);
|
|
85
|
+
console.log(` DRY-RUN mode: ${context.dryRun ? "enabled" : "disabled"}`);
|
|
86
|
+
console.log(` CI mode: ${context.ci ? "enabled" : "disabled"}`);
|
|
87
|
+
if (context.localMode) console.log(` Local mode: ${context.localMode}`);
|
|
88
|
+
console.log(` Verbose: ${verbose}`);
|
|
209
89
|
}
|
|
210
90
|
|
|
211
|
-
|
|
212
|
-
|
|
91
|
+
// 早期分流
|
|
92
|
+
if (deletionOnly) return this.executeDeletionOnly(context);
|
|
93
|
+
if (context.eventAction === "closed" || context.flush) return this.executeCollectOnly(context);
|
|
213
94
|
|
|
214
|
-
|
|
95
|
+
// 1. 解析输入数据(本地/PR/分支模式 + 前置过滤)
|
|
96
|
+
const source = await this.resolveSourceData(context);
|
|
97
|
+
if (source.earlyReturn) return source.earlyReturn;
|
|
215
98
|
|
|
216
|
-
|
|
217
|
-
prNumber = await this.getPrNumberFromEvent();
|
|
218
|
-
}
|
|
99
|
+
const { prModel, commits, changedFiles, headSha, isDirectFileMode } = source;
|
|
219
100
|
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
231
|
-
} catch (error) {
|
|
232
|
-
if (shouldLog(options.verbose, 1)) {
|
|
233
|
-
console.warn(`⚠️ 获取 PR 标题失败:`, error);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
101
|
+
// 2. 规则匹配
|
|
102
|
+
const specs = await this.issueFilter.loadSpecs(specSources, verbose);
|
|
103
|
+
const applicableSpecs = this.reviewSpecService.filterApplicableSpecs(specs, changedFiles);
|
|
104
|
+
if (shouldLog(verbose, 2)) {
|
|
105
|
+
console.log(
|
|
106
|
+
`[execute] loadSpecs: loaded ${specs.length} specs from sources: ${JSON.stringify(specSources)}`,
|
|
107
|
+
);
|
|
108
|
+
console.log(
|
|
109
|
+
`[execute] filterApplicableSpecs: ${applicableSpecs.length} applicable out of ${specs.length}, changedFiles=${JSON.stringify(changedFiles.map((f) => f.filename))}`,
|
|
110
|
+
);
|
|
236
111
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
join(homedir(), ".spaceflow", "deps"),
|
|
240
|
-
join(process.cwd(), ".spaceflow", "deps"),
|
|
241
|
-
];
|
|
242
|
-
if (options.references?.length) {
|
|
243
|
-
specSources.push(...options.references);
|
|
112
|
+
if (shouldLog(verbose, 1)) {
|
|
113
|
+
console.log(` 适用的规则文件: ${applicableSpecs.length}`);
|
|
244
114
|
}
|
|
245
|
-
if (
|
|
246
|
-
|
|
115
|
+
if (applicableSpecs.length === 0 || changedFiles.length === 0) {
|
|
116
|
+
return this.handleNoApplicableSpecs(context, applicableSpecs, changedFiles, commits);
|
|
247
117
|
}
|
|
248
118
|
|
|
249
|
-
//
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
119
|
+
// 3. 获取文件内容 + LLM 审查
|
|
120
|
+
const fileContents = await this.getFileContents(
|
|
121
|
+
context.owner,
|
|
122
|
+
context.repo,
|
|
123
|
+
changedFiles,
|
|
124
|
+
commits,
|
|
125
|
+
headSha,
|
|
126
|
+
context.prNumber,
|
|
127
|
+
verbose,
|
|
128
|
+
source.isLocalMode,
|
|
129
|
+
);
|
|
130
|
+
if (!llmMode) throw new Error("必须指定 LLM 类型");
|
|
255
131
|
|
|
256
|
-
//
|
|
257
|
-
let
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
if (shouldLog(options.verbose, 1)) {
|
|
263
|
-
console.log(`📌 自动检测分支: base=${baseRef}, head=${headRef}`);
|
|
132
|
+
// 获取上一次的审查结果(用于提示词优化和轮次推进)
|
|
133
|
+
let existingResultModel: ReviewResultModel | null = null;
|
|
134
|
+
if (context.ci && prModel) {
|
|
135
|
+
existingResultModel = await ReviewResultModel.loadFromPr(prModel, this.resultModelDeps);
|
|
136
|
+
if (existingResultModel && shouldLog(verbose, 1)) {
|
|
137
|
+
console.log(`📋 获取到上一次审查结果,包含 ${existingResultModel.issues.length} 个问题`);
|
|
264
138
|
}
|
|
265
139
|
}
|
|
140
|
+
if (shouldLog(verbose, 1)) {
|
|
141
|
+
console.log(`🔄 当前审查轮次: ${(existingResultModel?.round ?? 0) + 1}`);
|
|
142
|
+
}
|
|
266
143
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
}
|
|
144
|
+
const reviewPrompt = await this.buildReviewPrompt(
|
|
145
|
+
specs,
|
|
146
|
+
changedFiles,
|
|
147
|
+
fileContents,
|
|
148
|
+
commits,
|
|
149
|
+
existingResultModel?.result ?? null,
|
|
150
|
+
context.whenModifiedCode,
|
|
151
|
+
verbose,
|
|
152
|
+
context.systemRules,
|
|
153
|
+
);
|
|
154
|
+
const result = await this.runLLMReview(llmMode, reviewPrompt, {
|
|
155
|
+
verbose,
|
|
156
|
+
concurrency: context.concurrency,
|
|
157
|
+
timeout: context.timeout,
|
|
158
|
+
retries: context.retries,
|
|
159
|
+
retryDelay: context.retryDelay,
|
|
160
|
+
});
|
|
314
161
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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;
|
|
162
|
+
// 填充 PR 功能描述和标题
|
|
163
|
+
const prInfo = context.generateDescription
|
|
164
|
+
? await this.generatePrDescription(commits, changedFiles, llmMode, fileContents, verbose)
|
|
165
|
+
: await this.buildBasicDescription(commits, changedFiles);
|
|
166
|
+
result.title = prInfo.title;
|
|
167
|
+
result.description = prInfo.description;
|
|
168
|
+
if (shouldLog(verbose, 1)) {
|
|
169
|
+
console.log(`📝 LLM 审查完成,发现 ${result.issues.length} 个问题`);
|
|
340
170
|
}
|
|
341
|
-
// 默认启用 uncommitted 模式
|
|
342
|
-
return "uncommitted";
|
|
343
|
-
}
|
|
344
171
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
return files.map((file) => {
|
|
354
|
-
if (isAbsolute(file)) {
|
|
355
|
-
// 绝对路径转换为相对路径
|
|
356
|
-
return relative(cwd, file);
|
|
357
|
-
}
|
|
358
|
-
return file;
|
|
172
|
+
// 4. 过滤新 issues
|
|
173
|
+
result.issues = await this.fillIssueCode(result.issues, fileContents);
|
|
174
|
+
result.issues = this.filterNewIssues(result.issues, specs, applicableSpecs, {
|
|
175
|
+
commits,
|
|
176
|
+
fileContents,
|
|
177
|
+
changedFiles,
|
|
178
|
+
isDirectFileMode,
|
|
179
|
+
context,
|
|
359
180
|
});
|
|
360
|
-
}
|
|
361
181
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
protected resolveAnalyzeDeletions(
|
|
369
|
-
mode: AnalyzeDeletionsMode,
|
|
370
|
-
env: { ci: boolean; hasPrNumber: boolean },
|
|
371
|
-
): boolean {
|
|
372
|
-
if (typeof mode === "boolean") {
|
|
373
|
-
return mode;
|
|
182
|
+
// 静态规则产生的系统问题直接合并,不经过过滤管道
|
|
183
|
+
if (reviewPrompt.staticIssues?.length) {
|
|
184
|
+
result.issues = [...reviewPrompt.staticIssues, ...result.issues];
|
|
185
|
+
if (shouldLog(verbose, 1)) {
|
|
186
|
+
console.log(`⚙️ 追加 ${reviewPrompt.staticIssues.length} 个静态规则系统问题`);
|
|
187
|
+
}
|
|
374
188
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
return env.ci;
|
|
378
|
-
case "pr":
|
|
379
|
-
return env.hasPrNumber;
|
|
380
|
-
case "terminal":
|
|
381
|
-
return !env.ci;
|
|
382
|
-
default:
|
|
383
|
-
return false;
|
|
189
|
+
if (shouldLog(verbose, 1)) {
|
|
190
|
+
console.log(`📝 最终发现 ${result.issues.length} 个问题`);
|
|
384
191
|
}
|
|
385
|
-
}
|
|
386
192
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
if (!eventPath) {
|
|
395
|
-
return undefined;
|
|
396
|
-
}
|
|
193
|
+
// 5. 构建最终的 ReviewResultModel
|
|
194
|
+
const finalModel = await this.buildFinalModel(
|
|
195
|
+
context,
|
|
196
|
+
result,
|
|
197
|
+
{ prModel, commits, headSha, specs, fileContents },
|
|
198
|
+
existingResultModel,
|
|
199
|
+
);
|
|
397
200
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
// 支持多种事件类型:
|
|
402
|
-
// - pull_request 事件: event.pull_request.number 或 event.number
|
|
403
|
-
// - issue_comment 事件: event.issue.number
|
|
404
|
-
return event.pull_request?.number || event.issue?.number || event.number;
|
|
405
|
-
} catch {
|
|
406
|
-
return undefined;
|
|
407
|
-
}
|
|
201
|
+
// 6. 保存 + 输出
|
|
202
|
+
await this.saveAndOutput(context, finalModel, commits);
|
|
203
|
+
return finalModel.result;
|
|
408
204
|
}
|
|
409
205
|
|
|
206
|
+
// ─── 提取的子方法 ──────────────────────────────────────
|
|
207
|
+
|
|
410
208
|
/**
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
* 2. 获取 PR/分支的变更文件和提交记录
|
|
415
|
-
* 3. 调用 LLM 进行代码审查
|
|
416
|
-
* 4. 处理历史 issue(更新行号、验证修复状态)
|
|
417
|
-
* 5. 生成并发布审查报告
|
|
418
|
-
*
|
|
419
|
-
* @param context 审查上下文,包含 owner、repo、prNumber 等信息
|
|
420
|
-
* @returns 审查结果,包含发现的问题列表和统计信息
|
|
209
|
+
* 解析输入数据:根据模式(本地/PR/分支比较)获取 commits、changedFiles 等。
|
|
210
|
+
* 包含前置过滤(merge commit、files、commits、includes)。
|
|
211
|
+
* 如果需要提前返回(如同分支、重复 workflow),通过 earlyReturn 字段传递。
|
|
421
212
|
*/
|
|
422
|
-
async
|
|
213
|
+
protected async resolveSourceData(context: ReviewContext): Promise<{
|
|
214
|
+
prModel?: PullRequestModel;
|
|
215
|
+
commits: PullRequestCommit[];
|
|
216
|
+
changedFiles: ChangedFile[];
|
|
217
|
+
headSha: string;
|
|
218
|
+
isLocalMode: boolean;
|
|
219
|
+
isDirectFileMode: boolean;
|
|
220
|
+
earlyReturn?: ReviewResult;
|
|
221
|
+
}> {
|
|
423
222
|
const {
|
|
424
223
|
owner,
|
|
425
224
|
repo,
|
|
426
225
|
prNumber,
|
|
427
226
|
baseRef,
|
|
428
227
|
headRef,
|
|
429
|
-
specSources,
|
|
430
|
-
dryRun,
|
|
431
|
-
ci,
|
|
432
228
|
verbose,
|
|
229
|
+
ci,
|
|
433
230
|
includes,
|
|
434
|
-
llmMode,
|
|
435
231
|
files,
|
|
436
232
|
commits: filterCommits,
|
|
437
|
-
deletionOnly,
|
|
438
233
|
localMode,
|
|
439
|
-
|
|
440
|
-
autoApprove,
|
|
234
|
+
duplicateWorkflowResolved,
|
|
441
235
|
} = context;
|
|
442
236
|
|
|
443
|
-
|
|
444
|
-
const isDirectFileMode = files && files.length > 0 && baseRef === headRef;
|
|
445
|
-
// 本地模式:审查未提交的代码(可能回退到分支比较)
|
|
237
|
+
const isDirectFileMode = !!(files && files.length > 0 && baseRef === headRef);
|
|
446
238
|
let isLocalMode = !!localMode;
|
|
447
|
-
// 用于回退时动态计算的 base/head
|
|
448
239
|
let effectiveBaseRef = baseRef;
|
|
449
240
|
let effectiveHeadRef = headRef;
|
|
450
241
|
|
|
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;
|
|
242
|
+
let prModel: PullRequestModel | undefined;
|
|
474
243
|
let commits: PullRequestCommit[] = [];
|
|
475
244
|
let changedFiles: ChangedFile[] = [];
|
|
476
245
|
|
|
@@ -499,11 +268,12 @@ export class ReviewService {
|
|
|
499
268
|
if (effectiveBaseRef === effectiveHeadRef) {
|
|
500
269
|
console.log(`ℹ️ 当前分支 ${effectiveHeadRef} 与默认分支相同,没有可审查的代码变更`);
|
|
501
270
|
return {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
271
|
+
commits: [],
|
|
272
|
+
changedFiles: [],
|
|
273
|
+
headSha: "HEAD",
|
|
274
|
+
isLocalMode: false,
|
|
275
|
+
isDirectFileMode: false,
|
|
276
|
+
earlyReturn: { success: true, description: "", issues: [], summary: [], round: 1 },
|
|
507
277
|
};
|
|
508
278
|
}
|
|
509
279
|
} else {
|
|
@@ -529,75 +299,43 @@ export class ReviewService {
|
|
|
529
299
|
if (shouldLog(verbose, 1)) {
|
|
530
300
|
console.log(`📥 获取 PR #${prNumber} 信息 (owner: ${owner}, repo: ${repo})`);
|
|
531
301
|
}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
302
|
+
prModel = new PullRequestModel(this.gitProvider, owner, repo, prNumber);
|
|
303
|
+
const prInfo = await prModel.getInfo();
|
|
304
|
+
commits = await prModel.getCommits();
|
|
305
|
+
changedFiles = await prModel.getFiles();
|
|
535
306
|
if (shouldLog(verbose, 1)) {
|
|
536
|
-
console.log(` PR: ${
|
|
307
|
+
console.log(` PR: ${prInfo?.title}`);
|
|
537
308
|
console.log(` Commits: ${commits.length}`);
|
|
538
309
|
console.log(` Changed files: ${changedFiles.length}`);
|
|
539
310
|
}
|
|
540
311
|
|
|
541
|
-
// 检查是否有其他同名 review workflow
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
}
|
|
312
|
+
// 检查是否有其他同名 review workflow 正在运行中
|
|
313
|
+
if (duplicateWorkflowResolved !== "off" && ci && prInfo?.head?.sha) {
|
|
314
|
+
const duplicateResult = await this.checkDuplicateWorkflow(
|
|
315
|
+
prModel,
|
|
316
|
+
prInfo.head.sha,
|
|
317
|
+
duplicateWorkflowResolved,
|
|
318
|
+
verbose,
|
|
319
|
+
);
|
|
320
|
+
if (duplicateResult) {
|
|
321
|
+
return {
|
|
322
|
+
prModel,
|
|
323
|
+
commits,
|
|
324
|
+
changedFiles,
|
|
325
|
+
headSha: prInfo.head.sha,
|
|
326
|
+
isLocalMode,
|
|
327
|
+
isDirectFileMode,
|
|
328
|
+
earlyReturn: duplicateResult,
|
|
329
|
+
};
|
|
590
330
|
}
|
|
591
331
|
}
|
|
592
332
|
} else if (effectiveBaseRef && effectiveHeadRef) {
|
|
593
|
-
// 如果指定了 -f 文件且 base=head(无差异模式),直接审查指定文件
|
|
594
333
|
if (files && files.length > 0 && effectiveBaseRef === effectiveHeadRef) {
|
|
595
334
|
if (shouldLog(verbose, 1)) {
|
|
596
335
|
console.log(`📥 直接审查指定文件模式 (${files.length} 个文件)`);
|
|
597
336
|
}
|
|
598
337
|
changedFiles = files.map((f) => ({ filename: f, status: "modified" as const }));
|
|
599
338
|
} else if (changedFiles.length === 0) {
|
|
600
|
-
// 仅当 changedFiles 为空时才获取(避免与回退逻辑重复)
|
|
601
339
|
if (shouldLog(verbose, 1)) {
|
|
602
340
|
console.log(
|
|
603
341
|
`📥 获取 ${effectiveBaseRef}...${effectiveHeadRef} 的差异 (owner: ${owner}, repo: ${repo})`,
|
|
@@ -616,44 +354,44 @@ export class ReviewService {
|
|
|
616
354
|
}
|
|
617
355
|
}
|
|
618
356
|
} else if (!isLocalMode) {
|
|
619
|
-
// 非本地模式且无有效的 base/head
|
|
620
357
|
if (shouldLog(verbose, 1)) {
|
|
621
358
|
console.log(`❌ 错误: 缺少 prNumber 或 baseRef/headRef`, { prNumber, baseRef, headRef });
|
|
622
359
|
}
|
|
623
360
|
throw new Error("必须指定 PR 编号或者 base/head 分支");
|
|
624
361
|
}
|
|
625
362
|
|
|
626
|
-
//
|
|
363
|
+
// ── 前置过滤 ──────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
// 0. 过滤掉 merge commit
|
|
627
366
|
{
|
|
628
|
-
const
|
|
367
|
+
const before = commits.length;
|
|
629
368
|
commits = commits.filter((c) => {
|
|
630
369
|
const message = c.commit?.message || "";
|
|
631
370
|
return !message.startsWith("Merge ");
|
|
632
371
|
});
|
|
633
|
-
if (
|
|
634
|
-
console.log(` 跳过 Merge Commits: ${
|
|
372
|
+
if (before !== commits.length && shouldLog(verbose, 1)) {
|
|
373
|
+
console.log(` 跳过 Merge Commits: ${before} -> ${commits.length} 个`);
|
|
635
374
|
}
|
|
636
375
|
}
|
|
637
376
|
|
|
638
377
|
// 1. 按指定的 files 过滤
|
|
639
378
|
if (files && files.length > 0) {
|
|
640
|
-
const
|
|
379
|
+
const before = changedFiles.length;
|
|
641
380
|
changedFiles = changedFiles.filter((f) => files.includes(f.filename || ""));
|
|
642
381
|
if (shouldLog(verbose, 1)) {
|
|
643
|
-
console.log(` Files 过滤文件: ${
|
|
382
|
+
console.log(` Files 过滤文件: ${before} -> ${changedFiles.length} 个文件`);
|
|
644
383
|
}
|
|
645
384
|
}
|
|
646
385
|
|
|
647
386
|
// 2. 按指定的 commits 过滤
|
|
648
387
|
if (filterCommits && filterCommits.length > 0) {
|
|
649
|
-
const
|
|
388
|
+
const beforeCommits = commits.length;
|
|
650
389
|
commits = commits.filter((c) => filterCommits.some((fc) => fc && c.sha?.startsWith(fc)));
|
|
651
390
|
if (shouldLog(verbose, 1)) {
|
|
652
|
-
console.log(` Commits 过滤: ${
|
|
391
|
+
console.log(` Commits 过滤: ${beforeCommits} -> ${commits.length} 个`);
|
|
653
392
|
}
|
|
654
393
|
|
|
655
|
-
|
|
656
|
-
const beforeFilesCount = changedFiles.length;
|
|
394
|
+
const beforeFiles = changedFiles.length;
|
|
657
395
|
const commitFilenames = new Set<string>();
|
|
658
396
|
for (const commit of commits) {
|
|
659
397
|
if (!commit.sha) continue;
|
|
@@ -662,169 +400,80 @@ export class ReviewService {
|
|
|
662
400
|
}
|
|
663
401
|
changedFiles = changedFiles.filter((f) => commitFilenames.has(f.filename || ""));
|
|
664
402
|
if (shouldLog(verbose, 1)) {
|
|
665
|
-
console.log(` 按 Commits 过滤文件: ${
|
|
403
|
+
console.log(` 按 Commits 过滤文件: ${beforeFiles} -> ${changedFiles.length} 个文件`);
|
|
666
404
|
}
|
|
667
405
|
}
|
|
668
406
|
|
|
669
|
-
// 3. 使用 includes 过滤文件和 commits
|
|
407
|
+
// 3. 使用 includes 过滤文件和 commits(支持 added|/modified|/deleted| 前缀语法)
|
|
670
408
|
if (includes && includes.length > 0) {
|
|
671
|
-
const
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
409
|
+
const beforeFiles = changedFiles.length;
|
|
410
|
+
if (shouldLog(verbose, 2)) {
|
|
411
|
+
console.log(
|
|
412
|
+
`[resolveSourceData] filterFilesByIncludes: before=${JSON.stringify(changedFiles.map((f) => ({ filename: f.filename, status: f.status })))}, includes=${JSON.stringify(includes)}`,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
changedFiles = filterFilesByIncludes(changedFiles, includes);
|
|
675
416
|
if (shouldLog(verbose, 1)) {
|
|
676
|
-
console.log(` Includes 过滤文件: ${
|
|
417
|
+
console.log(` Includes 过滤文件: ${beforeFiles} -> ${changedFiles.length} 个文件`);
|
|
418
|
+
}
|
|
419
|
+
if (shouldLog(verbose, 2)) {
|
|
420
|
+
console.log(
|
|
421
|
+
`[resolveSourceData] filterFilesByIncludes: after=${JSON.stringify(changedFiles.map((f) => f.filename))}`,
|
|
422
|
+
);
|
|
677
423
|
}
|
|
678
424
|
|
|
679
|
-
const
|
|
425
|
+
const globs = extractGlobsFromIncludes(includes);
|
|
426
|
+
const beforeCommits = commits.length;
|
|
680
427
|
const filteredCommits: PullRequestCommit[] = [];
|
|
681
428
|
for (const commit of commits) {
|
|
682
429
|
if (!commit.sha) continue;
|
|
683
430
|
const commitFiles = await this.getFilesForCommit(owner, repo, commit.sha, prNumber);
|
|
684
|
-
if (micromatch.some(commitFiles,
|
|
431
|
+
if (micromatch.some(commitFiles, globs)) {
|
|
685
432
|
filteredCommits.push(commit);
|
|
686
433
|
}
|
|
687
434
|
}
|
|
688
435
|
commits = filteredCommits;
|
|
689
436
|
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} 个问题`);
|
|
437
|
+
console.log(` Includes 过滤 Commits: ${beforeCommits} -> ${commits.length} 个`);
|
|
769
438
|
}
|
|
770
439
|
}
|
|
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
440
|
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
441
|
+
const headSha = prModel ? await prModel.getHeadSha() : headRef || "HEAD";
|
|
442
|
+
return { prModel, commits, changedFiles, headSha, isLocalMode, isDirectFileMode };
|
|
443
|
+
}
|
|
804
444
|
|
|
805
|
-
|
|
445
|
+
/**
|
|
446
|
+
* LLM 审查后的 issue 过滤管道:
|
|
447
|
+
* includes → 规则存在性 → overrides → 变更行过滤 → 格式化
|
|
448
|
+
*/
|
|
449
|
+
protected filterNewIssues(
|
|
450
|
+
issues: ReviewResult["issues"],
|
|
451
|
+
specs: any[],
|
|
452
|
+
applicableSpecs: any[],
|
|
453
|
+
opts: {
|
|
454
|
+
commits: PullRequestCommit[];
|
|
455
|
+
fileContents: any;
|
|
456
|
+
changedFiles: ChangedFile[];
|
|
457
|
+
isDirectFileMode: boolean;
|
|
458
|
+
context: ReviewContext;
|
|
459
|
+
},
|
|
460
|
+
): ReviewResult["issues"] {
|
|
461
|
+
const { commits, fileContents, changedFiles, isDirectFileMode, context } = opts;
|
|
462
|
+
const { verbose } = context;
|
|
806
463
|
|
|
807
|
-
|
|
808
|
-
let filteredIssues = this.reviewSpecService.filterIssuesByIncludes(
|
|
809
|
-
result.issues,
|
|
810
|
-
applicableSpecs,
|
|
811
|
-
);
|
|
464
|
+
let filtered = this.reviewSpecService.filterIssuesByIncludes(issues, applicableSpecs);
|
|
812
465
|
if (shouldLog(verbose, 1)) {
|
|
813
|
-
console.log(` 应用 includes 过滤后: ${
|
|
466
|
+
console.log(` 应用 includes 过滤后: ${filtered.length} 个问题`);
|
|
814
467
|
}
|
|
815
468
|
|
|
816
|
-
|
|
469
|
+
filtered = this.reviewSpecService.filterIssuesByRuleExistence(filtered, specs);
|
|
817
470
|
if (shouldLog(verbose, 1)) {
|
|
818
|
-
console.log(` 应用规则存在性过滤后: ${
|
|
471
|
+
console.log(` 应用规则存在性过滤后: ${filtered.length} 个问题`);
|
|
819
472
|
}
|
|
820
473
|
|
|
821
|
-
|
|
822
|
-
filteredIssues,
|
|
823
|
-
applicableSpecs,
|
|
824
|
-
verbose,
|
|
825
|
-
);
|
|
474
|
+
filtered = this.reviewSpecService.filterIssuesByOverrides(filtered, applicableSpecs, verbose);
|
|
826
475
|
|
|
827
|
-
//
|
|
476
|
+
// 变更行过滤
|
|
828
477
|
if (shouldLog(verbose, 3)) {
|
|
829
478
|
console.log(` 🔍 变更行过滤条件检查:`);
|
|
830
479
|
console.log(
|
|
@@ -833,16 +482,11 @@ export class ReviewService {
|
|
|
833
482
|
}
|
|
834
483
|
if (!context.showAll && !isDirectFileMode && commits.length > 0) {
|
|
835
484
|
if (shouldLog(verbose, 2)) {
|
|
836
|
-
console.log(` 🔍 开始变更行过滤,当前 ${
|
|
485
|
+
console.log(` 🔍 开始变更行过滤,当前 ${filtered.length} 个问题`);
|
|
837
486
|
}
|
|
838
|
-
|
|
839
|
-
filteredIssues,
|
|
840
|
-
commits,
|
|
841
|
-
fileContents,
|
|
842
|
-
verbose,
|
|
843
|
-
);
|
|
487
|
+
filtered = this.filterIssuesByValidCommits(filtered, commits, fileContents, verbose);
|
|
844
488
|
if (shouldLog(verbose, 2)) {
|
|
845
|
-
console.log(` 🔍 变更行过滤完成,剩余 ${
|
|
489
|
+
console.log(` 🔍 变更行过滤完成,剩余 ${filtered.length} 个问题`);
|
|
846
490
|
}
|
|
847
491
|
} else if (shouldLog(verbose, 1)) {
|
|
848
492
|
console.log(
|
|
@@ -850,100 +494,123 @@ export class ReviewService {
|
|
|
850
494
|
);
|
|
851
495
|
}
|
|
852
496
|
|
|
853
|
-
|
|
854
|
-
specs,
|
|
855
|
-
changedFiles,
|
|
856
|
-
});
|
|
497
|
+
filtered = this.reviewSpecService.formatIssues(filtered, { specs, changedFiles });
|
|
857
498
|
if (shouldLog(verbose, 1)) {
|
|
858
|
-
console.log(` 应用格式化后: ${
|
|
499
|
+
console.log(` 应用格式化后: ${filtered.length} 个问题`);
|
|
859
500
|
}
|
|
860
501
|
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
console.log(`📝 最终发现 ${result.issues.length} 个问题`);
|
|
864
|
-
}
|
|
502
|
+
return filtered;
|
|
503
|
+
}
|
|
865
504
|
|
|
866
|
-
|
|
867
|
-
|
|
505
|
+
/**
|
|
506
|
+
* 构建最终的 ReviewResultModel:处理历史 issue 合并或首次创建
|
|
507
|
+
*/
|
|
508
|
+
protected async buildFinalModel(
|
|
509
|
+
context: ReviewContext,
|
|
510
|
+
result: ReviewResult,
|
|
511
|
+
source: {
|
|
512
|
+
prModel?: PullRequestModel;
|
|
513
|
+
commits: PullRequestCommit[];
|
|
514
|
+
headSha: string;
|
|
515
|
+
specs: any[];
|
|
516
|
+
fileContents: any;
|
|
517
|
+
},
|
|
518
|
+
existingResultModel: ReviewResultModel | null,
|
|
519
|
+
): Promise<ReviewResultModel> {
|
|
520
|
+
const { prModel, commits, headSha, specs, fileContents } = source;
|
|
521
|
+
const { verbose, ci } = context;
|
|
868
522
|
|
|
869
|
-
if (ci &&
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
console.log(`📋 已有评论中存在 ${existingIssues.length} 个问题`);
|
|
874
|
-
}
|
|
523
|
+
if (ci && prModel && existingResultModel && existingResultModel.issues.length > 0) {
|
|
524
|
+
if (shouldLog(verbose, 1)) {
|
|
525
|
+
console.log(`📋 已有评论中存在 ${existingResultModel.issues.length} 个问题`);
|
|
526
|
+
}
|
|
875
527
|
|
|
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
|
-
}
|
|
528
|
+
// 预处理历史 issues:同步 resolved 状态
|
|
529
|
+
await existingResultModel.syncResolved();
|
|
894
530
|
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
console.log(` ⏭️ 跳过历史问题验证 (verifyFixes=false)`);
|
|
904
|
-
}
|
|
905
|
-
}
|
|
531
|
+
// 如果文件有变更,将该文件的历史问题标记为无效
|
|
532
|
+
const reviewConf = this.config.getPluginConfig<ReviewConfig>("review");
|
|
533
|
+
if (
|
|
534
|
+
reviewConf.invalidateChangedFiles !== "off" &&
|
|
535
|
+
reviewConf.invalidateChangedFiles !== "keep"
|
|
536
|
+
) {
|
|
537
|
+
await existingResultModel.invalidateChangedFiles(headSha, verbose);
|
|
538
|
+
}
|
|
906
539
|
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
540
|
+
// 验证历史问题是否已修复
|
|
541
|
+
if (context.verifyFixes) {
|
|
542
|
+
existingResultModel.issues = await this.issueFilter.verifyAndUpdateIssues(
|
|
543
|
+
context,
|
|
544
|
+
existingResultModel.issues,
|
|
545
|
+
commits,
|
|
546
|
+
{ specs, fileContents },
|
|
547
|
+
prModel,
|
|
910
548
|
);
|
|
911
|
-
|
|
912
|
-
|
|
549
|
+
} else {
|
|
550
|
+
if (shouldLog(verbose, 1)) {
|
|
551
|
+
console.log(` ⏭️ 跳过历史问题验证 (verifyFixes=false)`);
|
|
913
552
|
}
|
|
914
|
-
result.issues = newIssues;
|
|
915
|
-
allIssues = [...existingIssues, ...newIssues];
|
|
916
553
|
}
|
|
917
|
-
}
|
|
918
554
|
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
555
|
+
// 去重:与所有历史 issues 去重
|
|
556
|
+
const { filteredIssues: newIssues, skippedCount } = this.filterDuplicateIssues(
|
|
557
|
+
result.issues,
|
|
558
|
+
existingResultModel.issues,
|
|
559
|
+
);
|
|
560
|
+
if (skippedCount > 0 && shouldLog(verbose, 1)) {
|
|
561
|
+
console.log(` 跳过 ${skippedCount} 个重复问题,新增 ${newIssues.length} 个问题`);
|
|
562
|
+
}
|
|
563
|
+
result.issues = newIssues;
|
|
564
|
+
result.headSha = headSha;
|
|
565
|
+
|
|
566
|
+
// 自动 round 递增 + issues 合并
|
|
567
|
+
return existingResultModel.nextRound(result);
|
|
922
568
|
}
|
|
923
569
|
|
|
924
|
-
//
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
570
|
+
// 首次审查或无历史结果
|
|
571
|
+
result.round = 1;
|
|
572
|
+
result.headSha = headSha;
|
|
573
|
+
result.issues = result.issues.map((issue) => ({ ...issue, round: 1 }));
|
|
574
|
+
return prModel
|
|
575
|
+
? ReviewResultModel.create(prModel, result, this.resultModelDeps)
|
|
576
|
+
: ReviewResultModel.createLocal(result, this.resultModelDeps);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* 统一的保存 + 输出逻辑
|
|
581
|
+
*/
|
|
582
|
+
protected async saveAndOutput(
|
|
583
|
+
context: ReviewContext,
|
|
584
|
+
finalModel: ReviewResultModel,
|
|
585
|
+
commits: PullRequestCommit[],
|
|
586
|
+
): Promise<void> {
|
|
587
|
+
const {
|
|
588
|
+
owner,
|
|
589
|
+
repo,
|
|
590
|
+
prNumber,
|
|
591
|
+
baseRef,
|
|
592
|
+
headRef,
|
|
593
|
+
verbose,
|
|
594
|
+
ci,
|
|
595
|
+
dryRun,
|
|
596
|
+
llmMode,
|
|
597
|
+
includes,
|
|
598
|
+
autoApprove,
|
|
599
|
+
} = context;
|
|
600
|
+
const prModel = finalModel.pr.number > 0 ? finalModel.pr : undefined;
|
|
929
601
|
|
|
930
|
-
|
|
602
|
+
// 填充 author 信息
|
|
603
|
+
if (commits.length > 0) {
|
|
604
|
+
finalModel.issues = await this.fillIssueAuthors(
|
|
605
|
+
finalModel.issues,
|
|
606
|
+
commits,
|
|
931
607
|
owner,
|
|
932
608
|
repo,
|
|
933
|
-
prNumber,
|
|
934
|
-
{
|
|
935
|
-
...result,
|
|
936
|
-
issues: allIssues,
|
|
937
|
-
},
|
|
938
609
|
verbose,
|
|
939
|
-
autoApprove,
|
|
940
610
|
);
|
|
941
|
-
if (shouldLog(verbose, 1)) {
|
|
942
|
-
console.log(`✅ 评论已提交`);
|
|
943
|
-
}
|
|
944
611
|
}
|
|
945
612
|
|
|
946
|
-
//
|
|
613
|
+
// 删除代码影响分析(在 save 之前完成,避免多次 save 产生重复的 Round 评论)
|
|
947
614
|
if (context.analyzeDeletions && llmMode) {
|
|
948
615
|
const deletionImpact = await this.deletionImpactService.analyzeDeletionImpact(
|
|
949
616
|
{
|
|
@@ -958,43 +625,31 @@ export class ReviewService {
|
|
|
958
625
|
llmMode,
|
|
959
626
|
verbose,
|
|
960
627
|
);
|
|
961
|
-
|
|
628
|
+
finalModel.update({ deletionImpact });
|
|
629
|
+
}
|
|
962
630
|
|
|
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
|
-
}
|
|
631
|
+
// 统一提交报告(只调用一次 save,避免重复创建 PR Review)
|
|
632
|
+
if (prModel && !dryRun) {
|
|
633
|
+
if (shouldLog(verbose, 1)) {
|
|
634
|
+
console.log(`💬 提交 PR 评论...`);
|
|
635
|
+
}
|
|
636
|
+
await finalModel.save({ verbose, autoApprove, skipSync: true });
|
|
637
|
+
if (shouldLog(verbose, 1)) {
|
|
638
|
+
console.log(`✅ 评论已提交`);
|
|
981
639
|
}
|
|
982
640
|
}
|
|
983
641
|
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
642
|
+
// 终端输出
|
|
643
|
+
const reviewComment = finalModel.formatComment({
|
|
644
|
+
prNumber,
|
|
645
|
+
outputFormat: context.outputFormat,
|
|
646
|
+
ci,
|
|
647
|
+
});
|
|
990
648
|
console.log(MarkdownFormatter.clearReviewData(reviewComment, "<hidden>"));
|
|
991
|
-
|
|
992
|
-
return result;
|
|
993
649
|
}
|
|
994
650
|
|
|
995
651
|
/**
|
|
996
652
|
* 仅收集 review 状态模式(用于 PR 关闭或 --flush 指令)
|
|
997
|
-
* 从现有的 AI review 评论中读取问题状态,同步已解决/无效状态,输出统计信息
|
|
998
653
|
*/
|
|
999
654
|
protected async executeCollectOnly(context: ReviewContext): Promise<ReviewResult> {
|
|
1000
655
|
const { owner, repo, prNumber, verbose, ci, dryRun, autoApprove } = context;
|
|
@@ -1007,9 +662,11 @@ export class ReviewService {
|
|
|
1007
662
|
throw new Error("collectOnly 模式必须指定 PR 编号");
|
|
1008
663
|
}
|
|
1009
664
|
|
|
665
|
+
const prModel = new PullRequestModel(this.gitProvider, owner, repo, prNumber);
|
|
666
|
+
|
|
1010
667
|
// 1. 从现有的 AI review 评论中读取问题
|
|
1011
|
-
const
|
|
1012
|
-
if (!
|
|
668
|
+
const resultModel = await ReviewResultModel.loadFromPr(prModel, this.resultModelDeps);
|
|
669
|
+
if (!resultModel) {
|
|
1013
670
|
console.log(`ℹ️ PR #${prNumber} 没有找到 AI review 评论`);
|
|
1014
671
|
return {
|
|
1015
672
|
success: true,
|
|
@@ -1021,13 +678,13 @@ export class ReviewService {
|
|
|
1021
678
|
}
|
|
1022
679
|
|
|
1023
680
|
if (shouldLog(verbose, 1)) {
|
|
1024
|
-
console.log(`📋 找到 ${
|
|
681
|
+
console.log(`📋 找到 ${resultModel.issues.length} 个历史问题`);
|
|
1025
682
|
}
|
|
1026
683
|
|
|
1027
684
|
// 2. 获取 commits 并填充 author 信息
|
|
1028
|
-
const commits = await
|
|
1029
|
-
|
|
1030
|
-
|
|
685
|
+
const commits = await prModel.getCommits();
|
|
686
|
+
resultModel.issues = await this.fillIssueAuthors(
|
|
687
|
+
resultModel.issues,
|
|
1031
688
|
commits,
|
|
1032
689
|
owner,
|
|
1033
690
|
repo,
|
|
@@ -1035,25 +692,26 @@ export class ReviewService {
|
|
|
1035
692
|
);
|
|
1036
693
|
|
|
1037
694
|
// 3. 同步已解决的评论状态
|
|
1038
|
-
await
|
|
695
|
+
await resultModel.syncResolved();
|
|
1039
696
|
|
|
1040
697
|
// 4. 同步评论 reactions(👍/👎/☹️)
|
|
1041
|
-
await
|
|
698
|
+
await resultModel.syncReactions(verbose);
|
|
1042
699
|
|
|
1043
700
|
// 5. LLM 验证历史问题是否已修复
|
|
1044
701
|
try {
|
|
1045
|
-
|
|
702
|
+
resultModel.issues = await this.issueFilter.verifyAndUpdateIssues(
|
|
1046
703
|
context,
|
|
1047
|
-
|
|
704
|
+
resultModel.issues,
|
|
1048
705
|
commits,
|
|
706
|
+
undefined,
|
|
707
|
+
prModel,
|
|
1049
708
|
);
|
|
1050
709
|
} catch (error) {
|
|
1051
710
|
console.warn("⚠️ LLM 验证修复状态失败,跳过:", error);
|
|
1052
711
|
}
|
|
1053
712
|
|
|
1054
713
|
// 6. 统计问题状态并设置到 result
|
|
1055
|
-
const stats =
|
|
1056
|
-
existingResult.stats = stats;
|
|
714
|
+
const stats = resultModel.updateStats();
|
|
1057
715
|
|
|
1058
716
|
// 7. 输出统计信息
|
|
1059
717
|
console.log(this.reviewReportService.formatStatsTerminal(stats, prNumber));
|
|
@@ -1063,134 +721,13 @@ export class ReviewService {
|
|
|
1063
721
|
if (shouldLog(verbose, 1)) {
|
|
1064
722
|
console.log(`💬 更新 PR 评论...`);
|
|
1065
723
|
}
|
|
1066
|
-
await
|
|
1067
|
-
owner,
|
|
1068
|
-
repo,
|
|
1069
|
-
prNumber,
|
|
1070
|
-
existingResult,
|
|
1071
|
-
verbose,
|
|
1072
|
-
autoApprove,
|
|
1073
|
-
);
|
|
724
|
+
await resultModel.save({ verbose, autoApprove });
|
|
1074
725
|
if (shouldLog(verbose, 1)) {
|
|
1075
726
|
console.log(`✅ 评论已更新`);
|
|
1076
727
|
}
|
|
1077
728
|
}
|
|
1078
729
|
|
|
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 };
|
|
730
|
+
return resultModel.result;
|
|
1194
731
|
}
|
|
1195
732
|
|
|
1196
733
|
/**
|
|
@@ -1223,1944 +760,324 @@ export class ReviewService {
|
|
|
1223
760
|
);
|
|
1224
761
|
|
|
1225
762
|
// 获取 commits 和 changedFiles 用于生成描述
|
|
763
|
+
let prModel: PullRequestModel | undefined;
|
|
1226
764
|
let commits: PullRequestCommit[] = [];
|
|
1227
765
|
let changedFiles: ChangedFile[] = [];
|
|
1228
766
|
if (prNumber) {
|
|
1229
|
-
|
|
1230
|
-
|
|
767
|
+
prModel = new PullRequestModel(this.gitProvider, owner, repo, prNumber);
|
|
768
|
+
commits = await prModel.getCommits();
|
|
769
|
+
changedFiles = await prModel.getFiles();
|
|
1231
770
|
} else if (baseRef && headRef) {
|
|
1232
771
|
changedFiles = await this.getChangedFilesBetweenRefs(owner, repo, baseRef, headRef);
|
|
1233
772
|
commits = await this.getCommitsBetweenRefs(baseRef, headRef);
|
|
1234
773
|
}
|
|
1235
774
|
|
|
1236
|
-
// 使用 includes
|
|
775
|
+
// 使用 includes 过滤文件(支持 added|/modified|/deleted| 前缀语法)
|
|
1237
776
|
if (context.includes && context.includes.length > 0) {
|
|
1238
|
-
|
|
1239
|
-
const matchedFilenames = micromatch(filenames, context.includes);
|
|
1240
|
-
changedFiles = changedFiles.filter((file) => matchedFilenames.includes(file.filename || ""));
|
|
777
|
+
changedFiles = filterFilesByIncludes(changedFiles, context.includes);
|
|
1241
778
|
}
|
|
1242
779
|
|
|
1243
|
-
const
|
|
780
|
+
const prDesc = context.generateDescription
|
|
1244
781
|
? await this.generatePrDescription(commits, changedFiles, llmMode, undefined, verbose)
|
|
1245
|
-
: await this.
|
|
782
|
+
: await this.buildBasicDescription(commits, changedFiles);
|
|
1246
783
|
const result: ReviewResult = {
|
|
1247
784
|
success: true,
|
|
1248
|
-
title:
|
|
1249
|
-
description:
|
|
785
|
+
title: prDesc.title,
|
|
786
|
+
description: prDesc.description,
|
|
1250
787
|
issues: [],
|
|
1251
788
|
summary: [],
|
|
1252
789
|
deletionImpact,
|
|
1253
790
|
round: 1,
|
|
1254
791
|
};
|
|
1255
792
|
|
|
1256
|
-
const
|
|
793
|
+
const resultModel = prModel
|
|
794
|
+
? ReviewResultModel.create(prModel, result, this.resultModelDeps)
|
|
795
|
+
: ReviewResultModel.createLocal(result, this.resultModelDeps);
|
|
796
|
+
const reviewComment = resultModel.formatComment({
|
|
1257
797
|
prNumber,
|
|
1258
798
|
outputFormat: context.outputFormat,
|
|
1259
799
|
ci,
|
|
1260
800
|
});
|
|
1261
801
|
|
|
1262
|
-
if (ci &&
|
|
802
|
+
if (ci && prModel && !dryRun) {
|
|
1263
803
|
if (shouldLog(verbose, 1)) {
|
|
1264
804
|
console.log(`💬 提交 PR 评论...`);
|
|
1265
805
|
}
|
|
1266
|
-
await
|
|
806
|
+
await resultModel.save({ verbose, autoApprove });
|
|
1267
807
|
if (shouldLog(verbose, 1)) {
|
|
1268
808
|
console.log(`✅ 评论已提交`);
|
|
1269
809
|
}
|
|
1270
810
|
}
|
|
1271
811
|
|
|
1272
|
-
//
|
|
1273
|
-
|
|
812
|
+
// 终端输出
|
|
1274
813
|
console.log(MarkdownFormatter.clearReviewData(reviewComment, "<hidden>"));
|
|
1275
814
|
|
|
1276
815
|
return result;
|
|
1277
816
|
}
|
|
1278
817
|
|
|
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
818
|
/**
|
|
1328
|
-
*
|
|
1329
|
-
* 返回 Map<filename, Array<[commitHash, lineCode]>>
|
|
819
|
+
* 处理无适用规则或无变更文件的情况
|
|
1330
820
|
*/
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
821
|
+
private async handleNoApplicableSpecs(
|
|
822
|
+
context: ReviewContext,
|
|
823
|
+
applicableSpecs: any[],
|
|
1334
824
|
changedFiles: ChangedFile[],
|
|
1335
825
|
commits: PullRequestCommit[],
|
|
1336
|
-
|
|
1337
|
-
prNumber
|
|
1338
|
-
|
|
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,行号可能与最终文件不一致
|
|
826
|
+
): Promise<ReviewResult> {
|
|
827
|
+
const { ci, prNumber, verbose, dryRun, llmMode, autoApprove } = context;
|
|
828
|
+
|
|
1347
829
|
if (shouldLog(verbose, 1)) {
|
|
1348
|
-
console.log(
|
|
830
|
+
console.log("✅ 没有需要审查的文件或规则");
|
|
1349
831
|
}
|
|
1350
832
|
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
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
|
-
}
|
|
833
|
+
// 获取上一次的审查结果以计算正确的轮次
|
|
834
|
+
let existingResultModel: ReviewResultModel | null = null;
|
|
835
|
+
let prModel: PullRequestModel | undefined;
|
|
836
|
+
if (ci && prNumber) {
|
|
837
|
+
prModel = new PullRequestModel(this.gitProvider, context.owner, context.repo, prNumber);
|
|
838
|
+
existingResultModel = await ReviewResultModel.loadFromPr(prModel, this.resultModelDeps);
|
|
839
|
+
}
|
|
840
|
+
const currentRound = (existingResultModel?.round ?? 0) + 1;
|
|
1383
841
|
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
}
|
|
842
|
+
// 即使没有适用的规则,也为每个变更文件生成摘要
|
|
843
|
+
const summary: FileSummary[] = changedFiles
|
|
844
|
+
.filter((f) => f.filename && f.status !== "deleted")
|
|
845
|
+
.map((f) => ({
|
|
846
|
+
file: f.filename!,
|
|
847
|
+
resolved: 0,
|
|
848
|
+
unresolved: 0,
|
|
849
|
+
summary: applicableSpecs.length === 0 ? "无适用的审查规则" : "已跳过",
|
|
850
|
+
}));
|
|
851
|
+
const prDesc =
|
|
852
|
+
context.generateDescription && llmMode
|
|
853
|
+
? await this.generatePrDescription(commits, changedFiles, llmMode, undefined, verbose)
|
|
854
|
+
: await this.buildBasicDescription(commits, changedFiles);
|
|
855
|
+
const result: ReviewResult = {
|
|
856
|
+
success: true,
|
|
857
|
+
title: prDesc.title,
|
|
858
|
+
description: prDesc.description,
|
|
859
|
+
issues: [],
|
|
860
|
+
summary,
|
|
861
|
+
round: currentRound,
|
|
862
|
+
};
|
|
1406
863
|
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
}
|
|
864
|
+
// CI 模式下也需要发送 review 评论
|
|
865
|
+
if (ci && prModel && !dryRun) {
|
|
866
|
+
if (shouldLog(verbose, 1)) {
|
|
867
|
+
console.log(`💬 提交 PR 评论...`);
|
|
868
|
+
}
|
|
869
|
+
const resultModel = ReviewResultModel.create(prModel, result, this.resultModelDeps);
|
|
870
|
+
await resultModel.save({ verbose, autoApprove });
|
|
871
|
+
if (shouldLog(verbose, 1)) {
|
|
872
|
+
console.log(`✅ 评论已提交`);
|
|
1417
873
|
}
|
|
1418
874
|
}
|
|
1419
875
|
|
|
1420
|
-
|
|
1421
|
-
console.log(`📊 映射构建完成,共 ${contents.size} 个文件`);
|
|
1422
|
-
}
|
|
1423
|
-
return contents;
|
|
876
|
+
return result;
|
|
1424
877
|
}
|
|
1425
878
|
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
879
|
+
/**
|
|
880
|
+
* 检查是否有其他同名 review workflow 正在运行中
|
|
881
|
+
* 根据 duplicateWorkflowResolved 配置决定是跳过还是删除旧评论
|
|
882
|
+
*/
|
|
883
|
+
private async checkDuplicateWorkflow(
|
|
884
|
+
prModel: PullRequestModel,
|
|
885
|
+
headSha: string,
|
|
886
|
+
mode: "skip" | "delete",
|
|
887
|
+
verbose?: VerboseLevel,
|
|
888
|
+
): Promise<ReviewResult | null> {
|
|
889
|
+
const ref = process.env.GITHUB_REF || process.env.GITEA_REF || "";
|
|
890
|
+
const prMatch = ref.match(/refs\/pull\/(\d+)/);
|
|
891
|
+
const currentPrNumber = prMatch ? parseInt(prMatch[1], 10) : prModel.number;
|
|
1432
892
|
|
|
1433
893
|
try {
|
|
1434
|
-
const
|
|
1435
|
-
|
|
1436
|
-
|
|
894
|
+
const runningWorkflows = await prModel.listWorkflowRuns({
|
|
895
|
+
status: "in_progress",
|
|
896
|
+
});
|
|
897
|
+
const currentWorkflowName = process.env.GITHUB_WORKFLOW || process.env.GITEA_WORKFLOW;
|
|
898
|
+
const currentRunId = process.env.GITHUB_RUN_ID || process.env.GITEA_RUN_ID;
|
|
899
|
+
const duplicateReviewRuns = runningWorkflows.filter(
|
|
900
|
+
(w) =>
|
|
901
|
+
w.sha === headSha &&
|
|
902
|
+
w.name === currentWorkflowName &&
|
|
903
|
+
(!currentRunId || String(w.id) !== currentRunId),
|
|
904
|
+
);
|
|
905
|
+
if (duplicateReviewRuns.length > 0) {
|
|
906
|
+
if (mode === "delete") {
|
|
907
|
+
// 删除模式:清理旧的 AI Review 评论和 PR Review
|
|
908
|
+
if (shouldLog(verbose, 1)) {
|
|
909
|
+
console.log(
|
|
910
|
+
`🗑️ 检测到 ${duplicateReviewRuns.length} 个同名 workflow,清理旧的 AI Review 评论...`,
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
await this.cleanupDuplicateAiReviews(prModel, verbose);
|
|
914
|
+
// 清理后继续执行当前审查
|
|
915
|
+
return null;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// 跳过模式(默认)
|
|
919
|
+
if (shouldLog(verbose, 1)) {
|
|
920
|
+
console.log(
|
|
921
|
+
`⏭️ 跳过审查: 当前 PR #${currentPrNumber} 有 ${duplicateReviewRuns.length} 个同名 workflow 正在运行中`,
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
return {
|
|
925
|
+
success: true,
|
|
926
|
+
description: `跳过审查: PR #${currentPrNumber} 有 ${duplicateReviewRuns.length} 个同名 workflow 正在运行中,等待完成后重新审查`,
|
|
927
|
+
issues: [],
|
|
928
|
+
summary: [],
|
|
929
|
+
round: 1,
|
|
930
|
+
};
|
|
1437
931
|
}
|
|
1438
|
-
return {
|
|
1439
|
-
success: true,
|
|
1440
|
-
description: "", // 由 execute 方法填充
|
|
1441
|
-
issues: result.issues || [],
|
|
1442
|
-
summary: result.summary || [],
|
|
1443
|
-
round: 1, // 由 execute 方法根据 existingResult 更新
|
|
1444
|
-
};
|
|
1445
932
|
} catch (error) {
|
|
1446
|
-
if (
|
|
1447
|
-
console.
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
} else {
|
|
1452
|
-
console.error("LLM 调用失败:", error);
|
|
933
|
+
if (shouldLog(verbose, 1)) {
|
|
934
|
+
console.warn(
|
|
935
|
+
`⚠️ 无法检查重复 workflow(可能缺少 repo owner 权限),跳过此检查:`,
|
|
936
|
+
error instanceof Error ? error.message : error,
|
|
937
|
+
);
|
|
1453
938
|
}
|
|
1454
|
-
return {
|
|
1455
|
-
success: false,
|
|
1456
|
-
description: "",
|
|
1457
|
-
issues: [],
|
|
1458
|
-
summary: [],
|
|
1459
|
-
round: 1,
|
|
1460
|
-
};
|
|
1461
939
|
}
|
|
940
|
+
return null;
|
|
1462
941
|
}
|
|
1463
942
|
|
|
1464
943
|
/**
|
|
1465
|
-
*
|
|
1466
|
-
* - 如果 spec 有 includes 配置,只有当文件名匹配 includes 模式时才包含该 spec
|
|
1467
|
-
* - 如果 spec 没有 includes 配置,则按扩展名匹配
|
|
944
|
+
* 清理重复的 AI Review 评论(Issue Comments 和 PR Reviews)
|
|
1468
945
|
*/
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
//
|
|
1475
|
-
|
|
1476
|
-
|
|
946
|
+
private async cleanupDuplicateAiReviews(
|
|
947
|
+
prModel: PullRequestModel,
|
|
948
|
+
verbose?: VerboseLevel,
|
|
949
|
+
): Promise<void> {
|
|
950
|
+
try {
|
|
951
|
+
// 删除 Issue Comments(主评论)
|
|
952
|
+
const comments = await prModel.getComments();
|
|
953
|
+
const aiComments = comments.filter((c) => c.body?.includes(REVIEW_COMMENT_MARKER));
|
|
954
|
+
let deletedComments = 0;
|
|
955
|
+
for (const comment of aiComments) {
|
|
956
|
+
if (comment.id) {
|
|
957
|
+
try {
|
|
958
|
+
await prModel.deleteComment(comment.id);
|
|
959
|
+
deletedComments++;
|
|
960
|
+
} catch {
|
|
961
|
+
// 忽略删除失败
|
|
962
|
+
}
|
|
963
|
+
}
|
|
1477
964
|
}
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
if (spec.includes.length > 0) {
|
|
1481
|
-
return micromatch.isMatch(filename, spec.includes, { matchBase: true });
|
|
965
|
+
if (deletedComments > 0 && shouldLog(verbose, 1)) {
|
|
966
|
+
console.log(` 已删除 ${deletedComments} 个重复的 AI Review 主评论`);
|
|
1482
967
|
}
|
|
1483
968
|
|
|
1484
|
-
//
|
|
1485
|
-
|
|
1486
|
-
|
|
969
|
+
// 删除 PR Reviews(行级评论)
|
|
970
|
+
const reviews = await prModel.getReviews();
|
|
971
|
+
const aiReviews = reviews.filter((r) => r.body?.includes(REVIEW_LINE_COMMENTS_MARKER));
|
|
972
|
+
let deletedReviews = 0;
|
|
973
|
+
for (const review of aiReviews) {
|
|
974
|
+
if (review.id) {
|
|
975
|
+
try {
|
|
976
|
+
await prModel.deleteReview(review.id);
|
|
977
|
+
deletedReviews++;
|
|
978
|
+
} catch {
|
|
979
|
+
// 已提交的 review 无法删除,忽略
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
if (deletedReviews > 0 && shouldLog(verbose, 1)) {
|
|
984
|
+
console.log(` 已删除 ${deletedReviews} 个重复的 AI Review PR Review`);
|
|
985
|
+
}
|
|
986
|
+
} catch (error) {
|
|
987
|
+
if (shouldLog(verbose, 1)) {
|
|
988
|
+
console.warn(`⚠️ 清理旧评论失败:`, error instanceof Error ? error.message : error);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
1487
991
|
}
|
|
1488
992
|
|
|
1489
|
-
|
|
1490
|
-
* 构建 systemPrompt
|
|
1491
|
-
*/
|
|
1492
|
-
protected buildSystemPrompt(specsSection: string): string {
|
|
1493
|
-
return `你是一个专业的代码审查专家,负责根据团队的编码规范对代码进行严格审查。
|
|
993
|
+
// --- Delegation methods for backward compatibility with tests ---
|
|
1494
994
|
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
995
|
+
protected async fillIssueAuthors(...args: Parameters<ReviewContextBuilder["fillIssueAuthors"]>) {
|
|
996
|
+
return this.contextBuilder.fillIssueAuthors(...args);
|
|
997
|
+
}
|
|
1498
998
|
|
|
1499
|
-
|
|
999
|
+
protected async getFileContents(...args: Parameters<ReviewIssueFilter["getFileContents"]>) {
|
|
1000
|
+
return this.issueFilter.getFileContents(...args);
|
|
1001
|
+
}
|
|
1500
1002
|
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
4. **提供可行建议**:对于每个问题,提供具体的修改建议代码
|
|
1003
|
+
protected async getFilesForCommit(...args: Parameters<ReviewIssueFilter["getFilesForCommit"]>) {
|
|
1004
|
+
return this.issueFilter.getFilesForCommit(...args);
|
|
1005
|
+
}
|
|
1505
1006
|
|
|
1506
|
-
|
|
1007
|
+
protected async getChangedFilesBetweenRefs(
|
|
1008
|
+
...args: Parameters<ReviewIssueFilter["getChangedFilesBetweenRefs"]>
|
|
1009
|
+
) {
|
|
1010
|
+
return this.issueFilter.getChangedFilesBetweenRefs(...args);
|
|
1011
|
+
}
|
|
1507
1012
|
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1013
|
+
protected async getCommitsBetweenRefs(
|
|
1014
|
+
...args: Parameters<ReviewIssueFilter["getCommitsBetweenRefs"]>
|
|
1015
|
+
) {
|
|
1016
|
+
return this.issueFilter.getCommitsBetweenRefs(...args);
|
|
1017
|
+
}
|
|
1512
1018
|
|
|
1513
|
-
|
|
1019
|
+
protected filterIssuesByValidCommits(
|
|
1020
|
+
...args: Parameters<ReviewIssueFilter["filterIssuesByValidCommits"]>
|
|
1021
|
+
) {
|
|
1022
|
+
return this.issueFilter.filterIssuesByValidCommits(...args);
|
|
1023
|
+
}
|
|
1514
1024
|
|
|
1515
|
-
|
|
1516
|
-
|
|
1025
|
+
protected filterDuplicateIssues(...args: Parameters<ReviewIssueFilter["filterDuplicateIssues"]>) {
|
|
1026
|
+
return this.issueFilter.filterDuplicateIssues(...args);
|
|
1517
1027
|
}
|
|
1518
1028
|
|
|
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
|
-
});
|
|
1029
|
+
protected async fillIssueCode(...args: Parameters<ReviewIssueFilter["fillIssueCode"]>) {
|
|
1030
|
+
return this.issueFilter.fillIssueCode(...args);
|
|
1031
|
+
}
|
|
1565
1032
|
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1033
|
+
protected async runLLMReview(...args: Parameters<ReviewLlmProcessor["runLLMReview"]>) {
|
|
1034
|
+
return this.llmProcessor.runLLMReview(...args);
|
|
1035
|
+
}
|
|
1569
1036
|
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1037
|
+
protected async buildReviewPrompt(...args: Parameters<ReviewLlmProcessor["buildReviewPrompt"]>) {
|
|
1038
|
+
return this.llmProcessor.buildReviewPrompt(...args);
|
|
1039
|
+
}
|
|
1573
1040
|
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
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
|
-
}
|
|
1596
|
-
|
|
1597
|
-
const userPrompt = `## ${filename} (${file.status})
|
|
1598
|
-
|
|
1599
|
-
### 文件内容
|
|
1600
|
-
|
|
1601
|
-
\`\`\`
|
|
1602
|
-
${linesWithNumbers}
|
|
1603
|
-
\`\`\`
|
|
1604
|
-
|
|
1605
|
-
### 该文件的相关 Commits
|
|
1606
|
-
|
|
1607
|
-
${commitsSection}
|
|
1608
|
-
|
|
1609
|
-
### 该文件所在的目录树
|
|
1610
|
-
|
|
1611
|
-
${fileDirectoryInfo}
|
|
1612
|
-
|
|
1613
|
-
### 上一次审查结果
|
|
1614
|
-
|
|
1615
|
-
${previousReviewSection}`;
|
|
1616
|
-
|
|
1617
|
-
// 根据文件过滤 specs,只注入与当前文件匹配的规则
|
|
1618
|
-
const fileSpecs = this.filterSpecsForFile(specs, filename);
|
|
1619
|
-
const specsSection = this.reviewSpecService.buildSpecsSection(fileSpecs);
|
|
1620
|
-
const systemPrompt = this.buildSystemPrompt(specsSection);
|
|
1621
|
-
|
|
1622
|
-
return { filename, systemPrompt, userPrompt };
|
|
1623
|
-
}),
|
|
1624
|
-
);
|
|
1625
|
-
|
|
1626
|
-
return { filePrompts };
|
|
1627
|
-
}
|
|
1628
|
-
|
|
1629
|
-
protected async fillIssueCode(
|
|
1630
|
-
issues: ReviewIssue[],
|
|
1631
|
-
fileContents: FileContentsMap,
|
|
1632
|
-
): Promise<ReviewIssue[]> {
|
|
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
|
-
});
|
|
1650
|
-
}
|
|
1651
|
-
|
|
1652
|
-
/**
|
|
1653
|
-
* 根据 commit 填充 issue 的 author 信息
|
|
1654
|
-
* 如果没有找到对应的 author,使用最后一次提交的人作为默认值
|
|
1655
|
-
*/
|
|
1656
|
-
protected async fillIssueAuthors(
|
|
1657
|
-
issues: ReviewIssue[],
|
|
1658
|
-
commits: PullRequestCommit[],
|
|
1659
|
-
_owner: string,
|
|
1660
|
-
_repo: string,
|
|
1661
|
-
verbose?: VerboseLevel,
|
|
1662
|
-
): Promise<ReviewIssue[]> {
|
|
1663
|
-
if (shouldLog(verbose, 2)) {
|
|
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);
|
|
1675
|
-
}
|
|
1676
|
-
}
|
|
1677
|
-
|
|
1678
|
-
// 通过 Git Provider API 查找用户,建立 email/name -> UserInfo 的映射
|
|
1679
|
-
const gitAuthorToUserMap = new Map<string, UserInfo>();
|
|
1680
|
-
for (const query of gitAuthorsToSearch) {
|
|
1681
|
-
try {
|
|
1682
|
-
const users = await this.gitProvider.searchUsers(query, 1);
|
|
1683
|
-
if (users.length > 0 && users[0].login) {
|
|
1684
|
-
const user: UserInfo = { id: String(users[0].id), login: users[0].login };
|
|
1685
|
-
gitAuthorToUserMap.set(query, user);
|
|
1686
|
-
if (shouldLog(verbose, 2)) {
|
|
1687
|
-
console.log(`[fillIssueAuthors] found user: ${query} -> ${user.login}`);
|
|
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}`,
|
|
1704
|
-
);
|
|
1705
|
-
}
|
|
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
|
-
}
|
|
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
|
-
};
|
|
1041
|
+
protected async generatePrDescription(
|
|
1042
|
+
...args: Parameters<ReviewLlmProcessor["generatePrDescription"]>
|
|
1043
|
+
) {
|
|
1044
|
+
return this.llmProcessor.generatePrDescription(...args);
|
|
2879
1045
|
}
|
|
2880
1046
|
|
|
2881
|
-
protected
|
|
2882
|
-
|
|
1047
|
+
protected async buildBasicDescription(
|
|
1048
|
+
...args: Parameters<ReviewLlmProcessor["buildBasicDescription"]>
|
|
1049
|
+
) {
|
|
1050
|
+
return this.llmProcessor.buildBasicDescription(...args);
|
|
2883
1051
|
}
|
|
2884
1052
|
|
|
2885
|
-
protected
|
|
2886
|
-
|
|
2887
|
-
if (!parsed) {
|
|
2888
|
-
return null;
|
|
2889
|
-
}
|
|
2890
|
-
return parsed.result;
|
|
1053
|
+
protected normalizeFilePaths(...args: Parameters<ReviewContextBuilder["normalizeFilePaths"]>) {
|
|
1054
|
+
return this.contextBuilder.normalizeFilePaths(...args);
|
|
2891
1055
|
}
|
|
2892
1056
|
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
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
|
-
}
|
|
1057
|
+
protected resolveAnalyzeDeletions(
|
|
1058
|
+
...args: Parameters<ReviewContextBuilder["resolveAnalyzeDeletions"]>
|
|
1059
|
+
) {
|
|
1060
|
+
return this.contextBuilder.resolveAnalyzeDeletions(...args);
|
|
2970
1061
|
}
|
|
2971
1062
|
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
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;
|
|
1063
|
+
protected async getPrNumberFromEvent(
|
|
1064
|
+
...args: Parameters<ReviewContextBuilder["getPrNumberFromEvent"]>
|
|
1065
|
+
) {
|
|
1066
|
+
return this.contextBuilder.getPrNumberFromEvent(...args);
|
|
3054
1067
|
}
|
|
3055
1068
|
|
|
3056
1069
|
/**
|
|
3057
|
-
*
|
|
3058
|
-
* 根据 fileContents 中问题行的实际 commit hash 进行验证,而不是依赖 LLM 填写的 commit
|
|
1070
|
+
* 确保 Claude CLI 已安装
|
|
3059
1071
|
*/
|
|
3060
|
-
protected
|
|
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> {
|
|
1072
|
+
protected async ensureClaudeCli(ci?: boolean): Promise<void> {
|
|
3161
1073
|
try {
|
|
3162
1074
|
execSync("claude --version", { stdio: "ignore" });
|
|
3163
1075
|
} catch {
|
|
1076
|
+
if (ci) {
|
|
1077
|
+
throw new Error(
|
|
1078
|
+
"Claude CLI 未安装。CI 环境请在 workflow 中预装: npm install -g @anthropic-ai/claude-code",
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
3164
1081
|
console.log("🔧 Claude CLI 未安装,正在安装...");
|
|
3165
1082
|
try {
|
|
3166
1083
|
execSync("npm install -g @anthropic-ai/claude-code", {
|
|
@@ -3174,55 +1091,4 @@ ${fileChanges || "无"}`;
|
|
|
3174
1091
|
}
|
|
3175
1092
|
}
|
|
3176
1093
|
}
|
|
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
|
-
}
|
|
3228
1094
|
}
|