@spaceflow/review 0.80.0 → 0.82.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 +27 -0
- package/dist/index.js +1048 -762
- package/package.json +3 -3
- package/src/README.md +0 -1
- package/src/changed-file-collection.ts +87 -0
- package/src/mcp/index.ts +5 -1
- package/src/prompt/issue-verify.ts +8 -3
- package/src/review-context.spec.ts +214 -0
- package/src/review-context.ts +4 -2
- package/src/review-issue-filter.spec.ts +742 -0
- package/src/review-issue-filter.ts +21 -280
- package/src/review-llm.spec.ts +287 -0
- package/src/review-llm.ts +19 -23
- package/src/review-report/formatters/markdown.formatter.ts +6 -7
- package/src/review-result-model.spec.ts +35 -4
- package/src/review-result-model.ts +58 -10
- package/src/review-source-resolver.ts +636 -0
- package/src/review-spec/review-spec.service.spec.ts +94 -12
- package/src/review-spec/review-spec.service.ts +289 -59
- package/src/review.service.spec.ts +142 -1154
- package/src/review.service.ts +177 -534
- package/src/types/changed-file-collection.ts +5 -0
- package/src/types/review-source-resolver.ts +55 -0
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
import {
|
|
2
|
+
GitProviderService,
|
|
3
|
+
PullRequestCommit,
|
|
4
|
+
ChangedFile,
|
|
5
|
+
type VerboseLevel,
|
|
6
|
+
shouldLog,
|
|
7
|
+
GitSdkService,
|
|
8
|
+
parseChangedLinesFromPatch,
|
|
9
|
+
} from "@spaceflow/core";
|
|
10
|
+
import micromatch from "micromatch";
|
|
11
|
+
import type { ReviewContext } from "./review-context";
|
|
12
|
+
import { ReviewIssueFilter } from "./review-issue-filter";
|
|
13
|
+
import { filterFilesByIncludes, extractGlobsFromIncludes } from "./review-includes-filter";
|
|
14
|
+
import { PullRequestModel } from "./pull-request-model";
|
|
15
|
+
import { ReviewResult, FileContentsMap, FileContentLine } from "./review-spec";
|
|
16
|
+
import { REVIEW_COMMENT_MARKER, REVIEW_LINE_COMMENTS_MARKER } from "./utils/review-pr-comment";
|
|
17
|
+
import type {
|
|
18
|
+
SourceData,
|
|
19
|
+
LocalFilesResult,
|
|
20
|
+
PrDataResult,
|
|
21
|
+
CommitsAndFiles,
|
|
22
|
+
} from "./types/review-source-resolver";
|
|
23
|
+
import { ChangedFileCollection } from "./changed-file-collection";
|
|
24
|
+
|
|
25
|
+
export type {
|
|
26
|
+
SourceData,
|
|
27
|
+
LocalFilesResult,
|
|
28
|
+
PrDataResult,
|
|
29
|
+
CommitsAndFiles,
|
|
30
|
+
} from "./types/review-source-resolver";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 审查源数据解析器:根据审查模式(本地/PR/分支比较)获取 commits、changedFiles 等输入数据,
|
|
34
|
+
* 并应用前置过滤管道(merge commit、files、commits、includes)。
|
|
35
|
+
*
|
|
36
|
+
* 从 ReviewService 中提取,职责单一化:只负责"获取和过滤源数据",不涉及 LLM 审查、报告生成等。
|
|
37
|
+
*/
|
|
38
|
+
export class ReviewSourceResolver {
|
|
39
|
+
constructor(
|
|
40
|
+
private readonly gitProvider: GitProviderService,
|
|
41
|
+
private readonly gitSdk: GitSdkService,
|
|
42
|
+
private readonly issueFilter: ReviewIssueFilter,
|
|
43
|
+
) {}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 解析输入数据:根据模式(本地/PR/分支比较)获取 commits、changedFiles 等。
|
|
47
|
+
* 包含前置过滤(merge commit、files、commits、includes)。
|
|
48
|
+
* 如果需要提前返回(如同分支、重复 workflow),通过 earlyReturn 字段传递。
|
|
49
|
+
*
|
|
50
|
+
* 数据获取流程:
|
|
51
|
+
* 1. 本地模式 → resolveLocalFiles(暂存区/未提交变更,无变更时回退分支比较)
|
|
52
|
+
* 2. 直接文件模式(-f)→ 构造 changedFiles
|
|
53
|
+
* 3. PR 模式 → resolvePrData(含重复 workflow 检查)
|
|
54
|
+
* 4. 分支比较模式 → resolveBranchCompareData
|
|
55
|
+
*
|
|
56
|
+
* 前置过滤管道(applyPreFilters):
|
|
57
|
+
* 0. merge commit 过滤
|
|
58
|
+
* 1. --files 过滤
|
|
59
|
+
* 2. --commits 过滤
|
|
60
|
+
* 3. --includes 过滤(支持 status| 前缀语法)
|
|
61
|
+
*/
|
|
62
|
+
async resolve(context: ReviewContext): Promise<SourceData> {
|
|
63
|
+
const { prNumber, verbose, files, localMode } = context;
|
|
64
|
+
|
|
65
|
+
const isDirectFileMode = !!(files && files.length > 0 && !prNumber);
|
|
66
|
+
let isLocalMode = !!localMode;
|
|
67
|
+
let effectiveBaseRef = context.baseRef;
|
|
68
|
+
let effectiveHeadRef = context.headRef;
|
|
69
|
+
|
|
70
|
+
let prModel: PullRequestModel | undefined;
|
|
71
|
+
let commits: PullRequestCommit[] = [];
|
|
72
|
+
let changedFiles: ChangedFile[] = [];
|
|
73
|
+
|
|
74
|
+
// ── 阶段 1:按模式获取 commits + changedFiles ──────────
|
|
75
|
+
|
|
76
|
+
if (isLocalMode) {
|
|
77
|
+
const local = this.resolveLocalFiles(localMode as "uncommitted" | "staged", verbose);
|
|
78
|
+
if (local.earlyReturn)
|
|
79
|
+
return {
|
|
80
|
+
...local.earlyReturn,
|
|
81
|
+
changedFiles: ChangedFileCollection.from(local.earlyReturn.changedFiles),
|
|
82
|
+
isDirectFileMode: false,
|
|
83
|
+
fileContents: new Map(),
|
|
84
|
+
};
|
|
85
|
+
isLocalMode = local.isLocalMode;
|
|
86
|
+
changedFiles = local.changedFiles;
|
|
87
|
+
effectiveBaseRef = local.effectiveBaseRef ?? effectiveBaseRef;
|
|
88
|
+
effectiveHeadRef = local.effectiveHeadRef ?? effectiveHeadRef;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (isDirectFileMode) {
|
|
92
|
+
// 直接文件审查模式(-f):绕过 diff,直接按指定文件构造审查输入
|
|
93
|
+
if (shouldLog(verbose, 1)) {
|
|
94
|
+
console.log(`📥 直接审查指定文件模式 (${files!.length} 个文件)`);
|
|
95
|
+
}
|
|
96
|
+
changedFiles = files!.map((f) => ({ filename: f, status: "modified" as const }));
|
|
97
|
+
isLocalMode = true;
|
|
98
|
+
} else if (prNumber) {
|
|
99
|
+
const prData = await this.resolvePrData(context);
|
|
100
|
+
if (prData.earlyReturn) {
|
|
101
|
+
return {
|
|
102
|
+
...prData,
|
|
103
|
+
changedFiles: ChangedFileCollection.from(prData.changedFiles),
|
|
104
|
+
headSha: prData.headSha!,
|
|
105
|
+
isLocalMode,
|
|
106
|
+
isDirectFileMode,
|
|
107
|
+
fileContents: new Map(),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
prModel = prData.prModel;
|
|
111
|
+
commits = prData.commits;
|
|
112
|
+
changedFiles = prData.changedFiles;
|
|
113
|
+
} else if (effectiveBaseRef && effectiveHeadRef) {
|
|
114
|
+
if (changedFiles.length === 0) {
|
|
115
|
+
const branchData = await this.resolveBranchCompareData(
|
|
116
|
+
context,
|
|
117
|
+
effectiveBaseRef,
|
|
118
|
+
effectiveHeadRef,
|
|
119
|
+
);
|
|
120
|
+
commits = branchData.commits;
|
|
121
|
+
changedFiles = branchData.changedFiles;
|
|
122
|
+
}
|
|
123
|
+
} else if (!isLocalMode) {
|
|
124
|
+
if (shouldLog(verbose, 1)) {
|
|
125
|
+
console.log(`❌ 错误: 缺少 prNumber 或 baseRef/headRef`, {
|
|
126
|
+
prNumber,
|
|
127
|
+
baseRef: context.baseRef,
|
|
128
|
+
headRef: context.headRef,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
throw new Error("必须指定 PR 编号或者 base/head 分支");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── 阶段 2:前置过滤管道 ─────────────────────────────
|
|
135
|
+
|
|
136
|
+
({ commits, changedFiles } = await this.applyPreFilters(
|
|
137
|
+
context,
|
|
138
|
+
commits,
|
|
139
|
+
changedFiles,
|
|
140
|
+
isDirectFileMode,
|
|
141
|
+
));
|
|
142
|
+
|
|
143
|
+
const headSha = prModel ? await prModel.getHeadSha() : context.headRef || "HEAD";
|
|
144
|
+
const collectedFiles = ChangedFileCollection.from(changedFiles);
|
|
145
|
+
const fileContents = await this.getFileContents(
|
|
146
|
+
context.owner,
|
|
147
|
+
context.repo,
|
|
148
|
+
collectedFiles.toArray(),
|
|
149
|
+
commits,
|
|
150
|
+
headSha,
|
|
151
|
+
context.prNumber,
|
|
152
|
+
isLocalMode,
|
|
153
|
+
context.verbose,
|
|
154
|
+
);
|
|
155
|
+
return {
|
|
156
|
+
prModel,
|
|
157
|
+
commits,
|
|
158
|
+
changedFiles: collectedFiles,
|
|
159
|
+
headSha,
|
|
160
|
+
isLocalMode,
|
|
161
|
+
isDirectFileMode,
|
|
162
|
+
fileContents,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── 数据获取子方法 ──────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 本地模式:获取暂存区或未提交的变更文件。
|
|
170
|
+
* 如果本地无变更,自动回退到分支比较模式并检测 base/head 分支。
|
|
171
|
+
* 同分支时通过 earlyReturn 提前终止。
|
|
172
|
+
*/
|
|
173
|
+
private resolveLocalFiles(
|
|
174
|
+
localMode: "uncommitted" | "staged",
|
|
175
|
+
verbose?: VerboseLevel,
|
|
176
|
+
): LocalFilesResult {
|
|
177
|
+
if (shouldLog(verbose, 1)) {
|
|
178
|
+
console.log(`📥 本地模式: 获取${localMode === "staged" ? "暂存区" : "未提交"}的代码变更`);
|
|
179
|
+
}
|
|
180
|
+
const localFiles =
|
|
181
|
+
localMode === "staged" ? this.gitSdk.getStagedFiles() : this.gitSdk.getUncommittedFiles();
|
|
182
|
+
|
|
183
|
+
if (localFiles.length === 0) {
|
|
184
|
+
// 本地无变更,回退到分支比较模式
|
|
185
|
+
if (shouldLog(verbose, 1)) {
|
|
186
|
+
console.log(
|
|
187
|
+
`ℹ️ 没有${localMode === "staged" ? "暂存区" : "未提交"}的代码变更,回退到分支比较模式`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
const effectiveHeadRef = this.gitSdk.getCurrentBranch() ?? "HEAD";
|
|
191
|
+
const effectiveBaseRef = this.gitSdk.getDefaultBranch();
|
|
192
|
+
if (shouldLog(verbose, 1)) {
|
|
193
|
+
console.log(`📌 自动检测分支: base=${effectiveBaseRef}, head=${effectiveHeadRef}`);
|
|
194
|
+
}
|
|
195
|
+
// 同分支无法比较,提前返回
|
|
196
|
+
if (effectiveBaseRef === effectiveHeadRef) {
|
|
197
|
+
console.log(`ℹ️ 当前分支 ${effectiveHeadRef} 与默认分支相同,没有可审查的代码变更`);
|
|
198
|
+
return {
|
|
199
|
+
changedFiles: [],
|
|
200
|
+
isLocalMode: false,
|
|
201
|
+
earlyReturn: {
|
|
202
|
+
commits: [],
|
|
203
|
+
changedFiles: [],
|
|
204
|
+
headSha: "HEAD",
|
|
205
|
+
isLocalMode: false,
|
|
206
|
+
earlyReturn: { success: true, description: "", issues: [], summary: [], round: 1 },
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return { changedFiles: [], isLocalMode: false, effectiveBaseRef, effectiveHeadRef };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 一次性获取所有 diff,避免每个文件调用一次 git 命令
|
|
214
|
+
const localDiffs =
|
|
215
|
+
localMode === "staged" ? this.gitSdk.getStagedDiff() : this.gitSdk.getUncommittedDiff();
|
|
216
|
+
const diffMap = new Map(localDiffs.map((d) => [d.filename, d.patch]));
|
|
217
|
+
|
|
218
|
+
const changedFiles: ChangedFile[] = localFiles.map((f) => ({
|
|
219
|
+
filename: f.filename,
|
|
220
|
+
status: f.status as ChangedFile["status"],
|
|
221
|
+
patch: diffMap.get(f.filename),
|
|
222
|
+
}));
|
|
223
|
+
|
|
224
|
+
if (shouldLog(verbose, 1)) {
|
|
225
|
+
console.log(` Changed files: ${changedFiles.length}`);
|
|
226
|
+
}
|
|
227
|
+
return { changedFiles, isLocalMode: true };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* PR 模式:获取 PR 信息、commits、changedFiles。
|
|
232
|
+
* 同时检查是否有同名 review workflow 正在运行(防止重复审查)。
|
|
233
|
+
*/
|
|
234
|
+
private async resolvePrData(context: ReviewContext): Promise<PrDataResult> {
|
|
235
|
+
const { owner, repo, prNumber, verbose, ci, duplicateWorkflowResolved } = context;
|
|
236
|
+
|
|
237
|
+
if (shouldLog(verbose, 1)) {
|
|
238
|
+
console.log(`📥 获取 PR #${prNumber} 信息 (owner: ${owner}, repo: ${repo})`);
|
|
239
|
+
}
|
|
240
|
+
const prModel = new PullRequestModel(this.gitProvider, owner, repo, prNumber!);
|
|
241
|
+
const prInfo = await prModel.getInfo();
|
|
242
|
+
const commits = await prModel.getCommits();
|
|
243
|
+
const changedFiles = await prModel.getFiles();
|
|
244
|
+
if (shouldLog(verbose, 1)) {
|
|
245
|
+
console.log(` PR: ${prInfo?.title}`);
|
|
246
|
+
console.log(` Commits: ${commits.length}`);
|
|
247
|
+
console.log(` Changed files: ${changedFiles.length}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 检查是否有其他同名 review workflow 正在运行中
|
|
251
|
+
if (duplicateWorkflowResolved !== "off" && ci && prInfo?.head?.sha) {
|
|
252
|
+
const duplicateResult = await this.checkDuplicateWorkflow(
|
|
253
|
+
prModel,
|
|
254
|
+
prInfo.head.sha,
|
|
255
|
+
duplicateWorkflowResolved,
|
|
256
|
+
verbose,
|
|
257
|
+
);
|
|
258
|
+
if (duplicateResult) {
|
|
259
|
+
return {
|
|
260
|
+
prModel,
|
|
261
|
+
commits,
|
|
262
|
+
changedFiles,
|
|
263
|
+
headSha: prInfo.head.sha,
|
|
264
|
+
earlyReturn: duplicateResult,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return { prModel, commits, changedFiles };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* 分支比较模式:获取 base...head 之间的 changedFiles 和 commits。
|
|
274
|
+
*/
|
|
275
|
+
private async resolveBranchCompareData(
|
|
276
|
+
context: ReviewContext,
|
|
277
|
+
baseRef: string,
|
|
278
|
+
headRef: string,
|
|
279
|
+
): Promise<CommitsAndFiles> {
|
|
280
|
+
const { owner, repo, verbose } = context;
|
|
281
|
+
|
|
282
|
+
if (shouldLog(verbose, 1)) {
|
|
283
|
+
console.log(`📥 获取 ${baseRef}...${headRef} 的差异 (owner: ${owner}, repo: ${repo})`);
|
|
284
|
+
}
|
|
285
|
+
const changedFiles = await this.issueFilter.getChangedFilesBetweenRefs(
|
|
286
|
+
owner,
|
|
287
|
+
repo,
|
|
288
|
+
baseRef,
|
|
289
|
+
headRef,
|
|
290
|
+
);
|
|
291
|
+
const commits = await this.issueFilter.getCommitsBetweenRefs(baseRef, headRef);
|
|
292
|
+
if (shouldLog(verbose, 1)) {
|
|
293
|
+
console.log(` Changed files: ${changedFiles.length}`);
|
|
294
|
+
console.log(` Commits: ${commits.length}`);
|
|
295
|
+
}
|
|
296
|
+
return { commits, changedFiles };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ─── 前置过滤 ──────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* 前置过滤管道:对 commits 和 changedFiles 依次执行过滤。
|
|
303
|
+
*
|
|
304
|
+
* 过滤顺序:
|
|
305
|
+
* 0. merge commit — 排除以 "Merge " 开头的 commit
|
|
306
|
+
* 1. --files — 仅保留用户指定的文件
|
|
307
|
+
* 2. --commits — 仅保留用户指定的 commit 及其涉及的文件
|
|
308
|
+
* 3. --includes — glob 模式过滤文件和 commits(支持 status| 前缀语法)
|
|
309
|
+
*/
|
|
310
|
+
private async applyPreFilters(
|
|
311
|
+
context: ReviewContext,
|
|
312
|
+
commits: PullRequestCommit[],
|
|
313
|
+
rawChangedFiles: ChangedFile[],
|
|
314
|
+
isDirectFileMode: boolean,
|
|
315
|
+
): Promise<CommitsAndFiles> {
|
|
316
|
+
const { owner, repo, prNumber, verbose, includes, files, commits: filterCommits } = context;
|
|
317
|
+
let changedFiles = ChangedFileCollection.from(rawChangedFiles);
|
|
318
|
+
|
|
319
|
+
// 0. 过滤掉 merge commit
|
|
320
|
+
{
|
|
321
|
+
const before = commits.length;
|
|
322
|
+
commits = commits.filter((c) => {
|
|
323
|
+
const message = c.commit?.message || "";
|
|
324
|
+
return !message.startsWith("Merge ");
|
|
325
|
+
});
|
|
326
|
+
if (before !== commits.length && shouldLog(verbose, 1)) {
|
|
327
|
+
console.log(` 跳过 Merge Commits: ${before} -> ${commits.length} 个`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// 1. 按指定的 files 过滤
|
|
332
|
+
if (files && files.length > 0) {
|
|
333
|
+
const before = changedFiles.length;
|
|
334
|
+
changedFiles = changedFiles.filterByFilenames(files);
|
|
335
|
+
if (shouldLog(verbose, 1)) {
|
|
336
|
+
console.log(` Files 过滤文件: ${before} -> ${changedFiles.length} 个文件`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 2. 按指定的 commits 过滤(同时过滤文件:仅保留属于匹配 commits 的文件)
|
|
341
|
+
if (filterCommits && filterCommits.length > 0) {
|
|
342
|
+
const beforeCommits = commits.length;
|
|
343
|
+
commits = commits.filter((c) => filterCommits.some((fc) => fc && c.sha?.startsWith(fc)));
|
|
344
|
+
if (shouldLog(verbose, 1)) {
|
|
345
|
+
console.log(` Commits 过滤: ${beforeCommits} -> ${commits.length} 个`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const beforeFiles = changedFiles.length;
|
|
349
|
+
const commitFilenames = new Set<string>();
|
|
350
|
+
for (const commit of commits) {
|
|
351
|
+
if (!commit.sha) continue;
|
|
352
|
+
const commitFiles = await this.issueFilter.getFilesForCommit(
|
|
353
|
+
owner,
|
|
354
|
+
repo,
|
|
355
|
+
commit.sha,
|
|
356
|
+
prNumber,
|
|
357
|
+
);
|
|
358
|
+
commitFiles.forEach((f) => commitFilenames.add(f));
|
|
359
|
+
}
|
|
360
|
+
changedFiles = changedFiles.filterByCommitFiles(commitFilenames);
|
|
361
|
+
if (shouldLog(verbose, 1)) {
|
|
362
|
+
console.log(` 按 Commits 过滤文件: ${beforeFiles} -> ${changedFiles.length} 个文件`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// 3. 使用 includes 过滤文件和 commits(支持 added|/modified|/deleted| 前缀语法)
|
|
367
|
+
if (isDirectFileMode && includes && includes.length > 0) {
|
|
368
|
+
if (shouldLog(verbose, 1)) {
|
|
369
|
+
console.log(`ℹ️ 直接文件模式下忽略 includes 过滤`);
|
|
370
|
+
}
|
|
371
|
+
} else if (includes && includes.length > 0) {
|
|
372
|
+
const beforeFiles = changedFiles.length;
|
|
373
|
+
if (shouldLog(verbose, 2)) {
|
|
374
|
+
console.log(
|
|
375
|
+
`[resolveSourceData] filterFilesByIncludes: before=${JSON.stringify(changedFiles.map((f) => ({ filename: f.filename, status: f.status })))}, includes=${JSON.stringify(includes)}`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
changedFiles = ChangedFileCollection.from(
|
|
379
|
+
filterFilesByIncludes(changedFiles.toArray(), includes),
|
|
380
|
+
);
|
|
381
|
+
if (shouldLog(verbose, 1)) {
|
|
382
|
+
console.log(` Includes 过滤文件: ${beforeFiles} -> ${changedFiles.length} 个文件`);
|
|
383
|
+
}
|
|
384
|
+
if (shouldLog(verbose, 2)) {
|
|
385
|
+
console.log(
|
|
386
|
+
`[resolveSourceData] filterFilesByIncludes: after=${JSON.stringify(changedFiles.map((f) => f.filename))}`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// 按 includes glob 过滤 commits:仅保留涉及匹配文件的 commits
|
|
391
|
+
const globs = extractGlobsFromIncludes(includes);
|
|
392
|
+
const beforeCommits = commits.length;
|
|
393
|
+
const filteredCommits: PullRequestCommit[] = [];
|
|
394
|
+
for (const commit of commits) {
|
|
395
|
+
if (!commit.sha) continue;
|
|
396
|
+
const commitFiles = await this.issueFilter.getFilesForCommit(
|
|
397
|
+
owner,
|
|
398
|
+
repo,
|
|
399
|
+
commit.sha,
|
|
400
|
+
prNumber,
|
|
401
|
+
);
|
|
402
|
+
if (micromatch.some(commitFiles, globs)) {
|
|
403
|
+
filteredCommits.push(commit);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
commits = filteredCommits;
|
|
407
|
+
if (shouldLog(verbose, 1)) {
|
|
408
|
+
console.log(` Includes 过滤 Commits: ${beforeCommits} -> ${commits.length} 个`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return { commits, changedFiles: changedFiles.toArray() };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ─── 文件内容 ─────────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* 获取文件内容并构建行号到 commit hash 的映射
|
|
419
|
+
* 返回 Map<filename, Array<[commitHash, lineCode]>>
|
|
420
|
+
*/
|
|
421
|
+
async getFileContents(
|
|
422
|
+
owner: string,
|
|
423
|
+
repo: string,
|
|
424
|
+
changedFiles: ChangedFile[],
|
|
425
|
+
commits: PullRequestCommit[],
|
|
426
|
+
ref: string,
|
|
427
|
+
prNumber?: number,
|
|
428
|
+
isLocalMode?: boolean,
|
|
429
|
+
verbose?: VerboseLevel,
|
|
430
|
+
): Promise<FileContentsMap> {
|
|
431
|
+
const contents: FileContentsMap = new Map();
|
|
432
|
+
const latestCommitHash = commits[commits.length - 1]?.sha?.slice(0, 7) || "+local+";
|
|
433
|
+
|
|
434
|
+
if (shouldLog(verbose, 1)) {
|
|
435
|
+
console.log(`📊 正在构建行号到变更的映射...`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
for (const file of changedFiles) {
|
|
439
|
+
if (file.filename && file.status !== "deleted") {
|
|
440
|
+
try {
|
|
441
|
+
let rawContent: string;
|
|
442
|
+
if (isLocalMode) {
|
|
443
|
+
rawContent = this.gitSdk.getWorkingFileContent(file.filename);
|
|
444
|
+
} else if (prNumber) {
|
|
445
|
+
rawContent = await this.gitProvider.getFileContent(owner, repo, file.filename, ref);
|
|
446
|
+
} else {
|
|
447
|
+
rawContent = await this.gitSdk.getFileContent(ref, file.filename);
|
|
448
|
+
}
|
|
449
|
+
const lines = rawContent.split("\n");
|
|
450
|
+
|
|
451
|
+
let changedLines = parseChangedLinesFromPatch(file.patch);
|
|
452
|
+
|
|
453
|
+
const isNewFile =
|
|
454
|
+
file.status === "added" ||
|
|
455
|
+
file.status === "A" ||
|
|
456
|
+
(file.additions && file.additions > 0 && file.deletions === 0 && !file.patch);
|
|
457
|
+
if (changedLines.size === 0 && isNewFile) {
|
|
458
|
+
changedLines = new Set(lines.map((_, i) => i + 1));
|
|
459
|
+
if (shouldLog(verbose, 2)) {
|
|
460
|
+
console.log(
|
|
461
|
+
` ℹ️ ${file.filename}: 新增文件无 patch,将所有 ${lines.length} 行标记为变更`,
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
let blameMap: Map<number, string> | undefined;
|
|
467
|
+
if (!isLocalMode) {
|
|
468
|
+
try {
|
|
469
|
+
blameMap = await this.gitSdk.getFileBlame(ref, file.filename);
|
|
470
|
+
} catch {
|
|
471
|
+
// blame 失败时回退到 latestCommitHash
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (shouldLog(verbose, 3)) {
|
|
476
|
+
console.log(` 📄 ${file.filename}: ${lines.length} 行, ${changedLines.size} 行变更`);
|
|
477
|
+
console.log(
|
|
478
|
+
` blame: ${blameMap ? `${blameMap.size} 行` : `不可用,回退到 ${latestCommitHash}`}`,
|
|
479
|
+
);
|
|
480
|
+
if (changedLines.size > 0 && changedLines.size <= 20) {
|
|
481
|
+
console.log(
|
|
482
|
+
` 变更行号: ${Array.from(changedLines)
|
|
483
|
+
.sort((a, b) => a - b)
|
|
484
|
+
.join(", ")}`,
|
|
485
|
+
);
|
|
486
|
+
} else if (changedLines.size > 20) {
|
|
487
|
+
console.log(` 变更行号: (共 ${changedLines.size} 行,省略详情)`);
|
|
488
|
+
}
|
|
489
|
+
if (!file.patch) {
|
|
490
|
+
console.log(
|
|
491
|
+
` ⚠️ 该文件没有 patch 信息 (status=${file.status}, additions=${file.additions}, deletions=${file.deletions})`,
|
|
492
|
+
);
|
|
493
|
+
} else {
|
|
494
|
+
console.log(
|
|
495
|
+
` patch 前 200 字符: ${file.patch.slice(0, 200).replace(/\n/g, "\\n")}`,
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const contentLines: FileContentLine[] = lines.map((line, index) => {
|
|
501
|
+
const lineNum = index + 1;
|
|
502
|
+
if (!changedLines.has(lineNum)) {
|
|
503
|
+
return ["-------", line];
|
|
504
|
+
}
|
|
505
|
+
const hash = blameMap?.get(lineNum) ?? latestCommitHash;
|
|
506
|
+
return [hash, line];
|
|
507
|
+
});
|
|
508
|
+
contents.set(file.filename, contentLines);
|
|
509
|
+
} catch (error) {
|
|
510
|
+
console.warn(`警告: 无法获取文件内容: ${file.filename}`, error);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (shouldLog(verbose, 1)) {
|
|
516
|
+
console.log(`📊 映射构建完成,共 ${contents.size} 个文件`);
|
|
517
|
+
}
|
|
518
|
+
return contents;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ─── 重复 workflow 检查 ──────────────────────────────────
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* 检查是否有其他同名 review workflow 正在运行中。
|
|
525
|
+
* 根据 duplicateWorkflowResolved 配置决定是跳过还是删除旧评论。
|
|
526
|
+
*/
|
|
527
|
+
private async checkDuplicateWorkflow(
|
|
528
|
+
prModel: PullRequestModel,
|
|
529
|
+
headSha: string,
|
|
530
|
+
mode: "skip" | "delete",
|
|
531
|
+
verbose?: VerboseLevel,
|
|
532
|
+
): Promise<ReviewResult | null> {
|
|
533
|
+
const ref = process.env.GITHUB_REF || process.env.GITEA_REF || "";
|
|
534
|
+
const prMatch = ref.match(/refs\/pull\/(\d+)/);
|
|
535
|
+
const currentPrNumber = prMatch ? parseInt(prMatch[1], 10) : prModel.number;
|
|
536
|
+
|
|
537
|
+
try {
|
|
538
|
+
const runningWorkflows = await prModel.listWorkflowRuns({
|
|
539
|
+
status: "in_progress",
|
|
540
|
+
});
|
|
541
|
+
const currentWorkflowName = process.env.GITHUB_WORKFLOW || process.env.GITEA_WORKFLOW;
|
|
542
|
+
const currentRunId = process.env.GITHUB_RUN_ID || process.env.GITEA_RUN_ID;
|
|
543
|
+
const duplicateReviewRuns = runningWorkflows.filter(
|
|
544
|
+
(w) =>
|
|
545
|
+
w.sha === headSha &&
|
|
546
|
+
w.name === currentWorkflowName &&
|
|
547
|
+
(!currentRunId || String(w.id) !== currentRunId),
|
|
548
|
+
);
|
|
549
|
+
if (duplicateReviewRuns.length > 0) {
|
|
550
|
+
if (mode === "delete") {
|
|
551
|
+
// 删除模式:清理旧的 AI Review 评论和 PR Review
|
|
552
|
+
if (shouldLog(verbose, 1)) {
|
|
553
|
+
console.log(
|
|
554
|
+
`🗑️ 检测到 ${duplicateReviewRuns.length} 个同名 workflow,清理旧的 AI Review 评论...`,
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
await this.cleanupDuplicateAiReviews(prModel, verbose);
|
|
558
|
+
// 清理后继续执行当前审查
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// 跳过模式(默认)
|
|
563
|
+
if (shouldLog(verbose, 1)) {
|
|
564
|
+
console.log(
|
|
565
|
+
`⏭️ 跳过审查: 当前 PR #${currentPrNumber} 有 ${duplicateReviewRuns.length} 个同名 workflow 正在运行中`,
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
return {
|
|
569
|
+
success: true,
|
|
570
|
+
description: `跳过审查: PR #${currentPrNumber} 有 ${duplicateReviewRuns.length} 个同名 workflow 正在运行中,等待完成后重新审查`,
|
|
571
|
+
issues: [],
|
|
572
|
+
summary: [],
|
|
573
|
+
round: 1,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
} catch (error) {
|
|
577
|
+
if (shouldLog(verbose, 1)) {
|
|
578
|
+
console.warn(
|
|
579
|
+
`⚠️ 无法检查重复 workflow(可能缺少 repo owner 权限),跳过此检查:`,
|
|
580
|
+
error instanceof Error ? error.message : error,
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* 清理重复的 AI Review 评论(Issue Comments 和 PR Reviews)
|
|
589
|
+
*/
|
|
590
|
+
private async cleanupDuplicateAiReviews(
|
|
591
|
+
prModel: PullRequestModel,
|
|
592
|
+
verbose?: VerboseLevel,
|
|
593
|
+
): Promise<void> {
|
|
594
|
+
try {
|
|
595
|
+
// 删除 Issue Comments(主评论)
|
|
596
|
+
const comments = await prModel.getComments();
|
|
597
|
+
const aiComments = comments.filter((c) => c.body?.includes(REVIEW_COMMENT_MARKER));
|
|
598
|
+
let deletedComments = 0;
|
|
599
|
+
for (const comment of aiComments) {
|
|
600
|
+
if (comment.id) {
|
|
601
|
+
try {
|
|
602
|
+
await prModel.deleteComment(comment.id);
|
|
603
|
+
deletedComments++;
|
|
604
|
+
} catch {
|
|
605
|
+
// 忽略删除失败
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (deletedComments > 0 && shouldLog(verbose, 1)) {
|
|
610
|
+
console.log(` 已删除 ${deletedComments} 个重复的 AI Review 主评论`);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// 删除 PR Reviews(行级评论)
|
|
614
|
+
const reviews = await prModel.getReviews();
|
|
615
|
+
const aiReviews = reviews.filter((r) => r.body?.includes(REVIEW_LINE_COMMENTS_MARKER));
|
|
616
|
+
let deletedReviews = 0;
|
|
617
|
+
for (const review of aiReviews) {
|
|
618
|
+
if (review.id) {
|
|
619
|
+
try {
|
|
620
|
+
await prModel.deleteReview(review.id);
|
|
621
|
+
deletedReviews++;
|
|
622
|
+
} catch {
|
|
623
|
+
// 已提交的 review 无法删除,忽略
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (deletedReviews > 0 && shouldLog(verbose, 1)) {
|
|
628
|
+
console.log(` 已删除 ${deletedReviews} 个重复的 AI Review PR Review`);
|
|
629
|
+
}
|
|
630
|
+
} catch (error) {
|
|
631
|
+
if (shouldLog(verbose, 1)) {
|
|
632
|
+
console.warn(`⚠️ 清理旧评论失败:`, error instanceof Error ? error.message : error);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|