@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
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
import {
|
|
2
|
+
GitProviderService,
|
|
3
|
+
PullRequestCommit,
|
|
4
|
+
ChangedFile,
|
|
5
|
+
type VerboseLevel,
|
|
6
|
+
shouldLog,
|
|
7
|
+
GitSdkService,
|
|
8
|
+
parseChangedLinesFromPatch,
|
|
9
|
+
parseDiffText,
|
|
10
|
+
parseHunksFromPatch,
|
|
11
|
+
calculateNewLineNumber,
|
|
12
|
+
} from "@spaceflow/core";
|
|
13
|
+
import type { IConfigReader } from "@spaceflow/core";
|
|
14
|
+
import { PullRequestModel } from "./pull-request-model";
|
|
15
|
+
import {
|
|
16
|
+
ReviewSpecService,
|
|
17
|
+
ReviewSpec,
|
|
18
|
+
ReviewIssue,
|
|
19
|
+
FileContentsMap,
|
|
20
|
+
FileContentLine,
|
|
21
|
+
} from "./review-spec";
|
|
22
|
+
import { IssueVerifyService } from "./issue-verify.service";
|
|
23
|
+
import { generateIssueKey } from "./utils/review-pr-comment";
|
|
24
|
+
import type { ReviewContext } from "./review-context";
|
|
25
|
+
|
|
26
|
+
export class ReviewIssueFilter {
|
|
27
|
+
constructor(
|
|
28
|
+
protected readonly gitProvider: GitProviderService,
|
|
29
|
+
protected readonly config: IConfigReader,
|
|
30
|
+
protected readonly reviewSpecService: ReviewSpecService,
|
|
31
|
+
protected readonly issueVerifyService: IssueVerifyService,
|
|
32
|
+
protected readonly gitSdk: GitSdkService,
|
|
33
|
+
) {}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 加载并去重审查规则
|
|
37
|
+
*/
|
|
38
|
+
async loadSpecs(specSources: string[], verbose?: VerboseLevel): Promise<ReviewSpec[]> {
|
|
39
|
+
if (shouldLog(verbose, 1)) {
|
|
40
|
+
console.log(`📂 解析规则来源: ${specSources.length} 个`);
|
|
41
|
+
}
|
|
42
|
+
const specDirs = await this.reviewSpecService.resolveSpecSources(specSources);
|
|
43
|
+
if (shouldLog(verbose, 2)) {
|
|
44
|
+
console.log(` 解析到 ${specDirs.length} 个规则目录`, specDirs);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let specs: ReviewSpec[] = [];
|
|
48
|
+
for (const specDir of specDirs) {
|
|
49
|
+
const dirSpecs = await this.reviewSpecService.loadReviewSpecs(specDir);
|
|
50
|
+
specs.push(...dirSpecs);
|
|
51
|
+
}
|
|
52
|
+
if (shouldLog(verbose, 1)) {
|
|
53
|
+
console.log(` 找到 ${specs.length} 个规则文件`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const beforeDedup = specs.reduce((sum, s) => sum + s.rules.length, 0);
|
|
57
|
+
specs = this.reviewSpecService.deduplicateSpecs(specs);
|
|
58
|
+
const afterDedup = specs.reduce((sum, s) => sum + s.rules.length, 0);
|
|
59
|
+
if (beforeDedup !== afterDedup && shouldLog(verbose, 1)) {
|
|
60
|
+
console.log(` 去重规则: ${beforeDedup} -> ${afterDedup} 条`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return specs;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* LLM 验证历史问题是否已修复
|
|
68
|
+
* 如果传入 preloaded(specs/fileContents),直接使用;否则从 PR 获取
|
|
69
|
+
*/
|
|
70
|
+
async verifyAndUpdateIssues(
|
|
71
|
+
context: ReviewContext,
|
|
72
|
+
issues: ReviewIssue[],
|
|
73
|
+
commits: PullRequestCommit[],
|
|
74
|
+
preloaded?: { specs: ReviewSpec[]; fileContents: FileContentsMap },
|
|
75
|
+
pr?: PullRequestModel,
|
|
76
|
+
): Promise<ReviewIssue[]> {
|
|
77
|
+
const { llmMode, specSources, verbose } = context;
|
|
78
|
+
const unfixedIssues = issues.filter((i) => i.valid !== "false" && !i.fixed);
|
|
79
|
+
|
|
80
|
+
if (unfixedIssues.length === 0) {
|
|
81
|
+
return issues;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!llmMode) {
|
|
85
|
+
if (shouldLog(verbose, 1)) {
|
|
86
|
+
console.log(` ⏭️ 跳过 LLM 验证(缺少 llmMode)`);
|
|
87
|
+
}
|
|
88
|
+
return issues;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!preloaded && (!specSources?.length || !pr)) {
|
|
92
|
+
if (shouldLog(verbose, 1)) {
|
|
93
|
+
console.log(` ⏭️ 跳过 LLM 验证(缺少 specSources 或 pr)`);
|
|
94
|
+
}
|
|
95
|
+
return issues;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (shouldLog(verbose, 1)) {
|
|
99
|
+
console.log(`\n🔍 开始 LLM 验证 ${unfixedIssues.length} 个未修复问题...`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let specs: ReviewSpec[];
|
|
103
|
+
let fileContents: FileContentsMap;
|
|
104
|
+
|
|
105
|
+
if (preloaded) {
|
|
106
|
+
specs = preloaded.specs;
|
|
107
|
+
fileContents = preloaded.fileContents;
|
|
108
|
+
} else {
|
|
109
|
+
const changedFiles = await pr!.getFiles();
|
|
110
|
+
const headSha = await pr!.getHeadSha();
|
|
111
|
+
specs = await this.loadSpecs(specSources, verbose);
|
|
112
|
+
fileContents = await this.getFileContents(
|
|
113
|
+
pr!.owner,
|
|
114
|
+
pr!.repo,
|
|
115
|
+
changedFiles,
|
|
116
|
+
commits,
|
|
117
|
+
headSha,
|
|
118
|
+
pr!.number,
|
|
119
|
+
verbose,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return await this.issueVerifyService.verifyIssueFixes(
|
|
124
|
+
issues,
|
|
125
|
+
fileContents,
|
|
126
|
+
specs,
|
|
127
|
+
llmMode,
|
|
128
|
+
verbose,
|
|
129
|
+
context.verifyConcurrency,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async getChangedFilesBetweenRefs(
|
|
134
|
+
_owner: string,
|
|
135
|
+
_repo: string,
|
|
136
|
+
baseRef: string,
|
|
137
|
+
headRef: string,
|
|
138
|
+
): Promise<ChangedFile[]> {
|
|
139
|
+
// 使用 getDiffBetweenRefs 获取包含 patch 的文件列表
|
|
140
|
+
// 这样可以正确解析变更行号,用于过滤非变更行的问题
|
|
141
|
+
const diffFiles = await this.gitSdk.getDiffBetweenRefs(baseRef, headRef);
|
|
142
|
+
const statusFiles = await this.gitSdk.getChangedFilesBetweenRefs(baseRef, headRef);
|
|
143
|
+
|
|
144
|
+
// 合并 status 和 patch 信息
|
|
145
|
+
const statusMap = new Map(statusFiles.map((f) => [f.filename, f.status]));
|
|
146
|
+
return diffFiles.map((f) => ({
|
|
147
|
+
filename: f.filename,
|
|
148
|
+
status: statusMap.get(f.filename) || "modified",
|
|
149
|
+
patch: f.patch,
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async getCommitsBetweenRefs(baseRef: string, headRef: string): Promise<PullRequestCommit[]> {
|
|
154
|
+
const gitCommits = await this.gitSdk.getCommitsBetweenRefs(baseRef, headRef);
|
|
155
|
+
return gitCommits.map((c) => ({
|
|
156
|
+
sha: c.sha,
|
|
157
|
+
commit: {
|
|
158
|
+
message: c.message,
|
|
159
|
+
author: c.author,
|
|
160
|
+
},
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async getFilesForCommit(
|
|
165
|
+
owner: string,
|
|
166
|
+
repo: string,
|
|
167
|
+
sha: string,
|
|
168
|
+
prNumber?: number,
|
|
169
|
+
): Promise<string[]> {
|
|
170
|
+
if (prNumber) {
|
|
171
|
+
const commit = await this.gitProvider.getCommit(owner, repo, sha);
|
|
172
|
+
return commit.files?.map((f) => f.filename || "").filter(Boolean) || [];
|
|
173
|
+
} else {
|
|
174
|
+
return this.gitSdk.getFilesForCommit(sha);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 获取文件内容并构建行号到 commit hash 的映射
|
|
180
|
+
* 返回 Map<filename, Array<[commitHash, lineCode]>>
|
|
181
|
+
*/
|
|
182
|
+
async getFileContents(
|
|
183
|
+
owner: string,
|
|
184
|
+
repo: string,
|
|
185
|
+
changedFiles: ChangedFile[],
|
|
186
|
+
commits: PullRequestCommit[],
|
|
187
|
+
ref: string,
|
|
188
|
+
prNumber?: number,
|
|
189
|
+
verbose?: VerboseLevel,
|
|
190
|
+
isLocalMode?: boolean,
|
|
191
|
+
): Promise<FileContentsMap> {
|
|
192
|
+
const contents: FileContentsMap = new Map();
|
|
193
|
+
const latestCommitHash = commits[commits.length - 1]?.sha?.slice(0, 7) || "+local+";
|
|
194
|
+
|
|
195
|
+
// 优先使用 changedFiles 中的 patch 字段(来自 PR 的整体 diff base...head)
|
|
196
|
+
// 这样行号是相对于最终文件的,而不是每个 commit 的父 commit
|
|
197
|
+
// buildLineCommitMap 遍历每个 commit 的 diff,行号可能与最终文件不一致
|
|
198
|
+
if (shouldLog(verbose, 1)) {
|
|
199
|
+
console.log(`📊 正在构建行号到变更的映射...`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const file of changedFiles) {
|
|
203
|
+
if (file.filename && file.status !== "deleted") {
|
|
204
|
+
try {
|
|
205
|
+
let rawContent: string;
|
|
206
|
+
if (isLocalMode) {
|
|
207
|
+
// 本地模式:读取工作区文件的当前内容
|
|
208
|
+
rawContent = this.gitSdk.getWorkingFileContent(file.filename);
|
|
209
|
+
} else if (prNumber) {
|
|
210
|
+
rawContent = await this.gitProvider.getFileContent(owner, repo, file.filename, ref);
|
|
211
|
+
} else {
|
|
212
|
+
rawContent = await this.gitSdk.getFileContent(ref, file.filename);
|
|
213
|
+
}
|
|
214
|
+
const lines = rawContent.split("\n");
|
|
215
|
+
|
|
216
|
+
// 优先使用 file.patch(PR 整体 diff),这是相对于最终文件的行号
|
|
217
|
+
let changedLines = parseChangedLinesFromPatch(file.patch);
|
|
218
|
+
|
|
219
|
+
// 如果 changedLines 为空,需要判断是否应该将所有行标记为变更
|
|
220
|
+
// 情况1: 文件是新增的(status 为 added/A)
|
|
221
|
+
// 情况2: patch 为空但文件有 additions(部分 Git Provider API 可能不返回完整 patch)
|
|
222
|
+
const isNewFile =
|
|
223
|
+
file.status === "added" ||
|
|
224
|
+
file.status === "A" ||
|
|
225
|
+
(file.additions && file.additions > 0 && file.deletions === 0 && !file.patch);
|
|
226
|
+
if (changedLines.size === 0 && isNewFile) {
|
|
227
|
+
changedLines = new Set(lines.map((_, i) => i + 1));
|
|
228
|
+
if (shouldLog(verbose, 2)) {
|
|
229
|
+
console.log(
|
|
230
|
+
` ℹ️ ${file.filename}: 新增文件无 patch,将所有 ${lines.length} 行标记为变更`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (shouldLog(verbose, 3)) {
|
|
236
|
+
console.log(` 📄 ${file.filename}: ${lines.length} 行, ${changedLines.size} 行变更`);
|
|
237
|
+
console.log(` latestCommitHash: ${latestCommitHash}`);
|
|
238
|
+
if (changedLines.size > 0 && changedLines.size <= 20) {
|
|
239
|
+
console.log(
|
|
240
|
+
` 变更行号: ${Array.from(changedLines)
|
|
241
|
+
.sort((a, b) => a - b)
|
|
242
|
+
.join(", ")}`,
|
|
243
|
+
);
|
|
244
|
+
} else if (changedLines.size > 20) {
|
|
245
|
+
console.log(` 变更行号: (共 ${changedLines.size} 行,省略详情)`);
|
|
246
|
+
}
|
|
247
|
+
if (!file.patch) {
|
|
248
|
+
console.log(
|
|
249
|
+
` ⚠️ 该文件没有 patch 信息 (status=${file.status}, additions=${file.additions}, deletions=${file.deletions})`,
|
|
250
|
+
);
|
|
251
|
+
} else {
|
|
252
|
+
console.log(
|
|
253
|
+
` patch 前 200 字符: ${file.patch.slice(0, 200).replace(/\n/g, "\\n")}`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const contentLines: FileContentLine[] = lines.map((line, index) => {
|
|
259
|
+
const lineNum = index + 1;
|
|
260
|
+
// 如果该行在 PR 的整体 diff 中被标记为变更,则使用最新 commit hash
|
|
261
|
+
const hash = changedLines.has(lineNum) ? latestCommitHash : "-------";
|
|
262
|
+
return [hash, line];
|
|
263
|
+
});
|
|
264
|
+
contents.set(file.filename, contentLines);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
console.warn(`警告: 无法获取文件内容: ${file.filename}`, error);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (shouldLog(verbose, 1)) {
|
|
272
|
+
console.log(`📊 映射构建完成,共 ${contents.size} 个文件`);
|
|
273
|
+
}
|
|
274
|
+
return contents;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async fillIssueCode(
|
|
278
|
+
issues: ReviewIssue[],
|
|
279
|
+
fileContents: FileContentsMap,
|
|
280
|
+
): Promise<ReviewIssue[]> {
|
|
281
|
+
return issues.map((issue) => {
|
|
282
|
+
const contentLines = fileContents.get(issue.file);
|
|
283
|
+
if (!contentLines) {
|
|
284
|
+
return issue;
|
|
285
|
+
}
|
|
286
|
+
const lineNums = this.reviewSpecService.parseLineRange(issue.line);
|
|
287
|
+
if (lineNums.length === 0) {
|
|
288
|
+
return issue;
|
|
289
|
+
}
|
|
290
|
+
const startLine = lineNums[0];
|
|
291
|
+
const endLine = lineNums[lineNums.length - 1];
|
|
292
|
+
if (startLine < 1 || startLine > contentLines.length) {
|
|
293
|
+
return issue;
|
|
294
|
+
}
|
|
295
|
+
const codeLines = contentLines
|
|
296
|
+
.slice(startLine - 1, Math.min(endLine, contentLines.length))
|
|
297
|
+
.map(([, line]) => line);
|
|
298
|
+
const code = codeLines.join("\n").trim();
|
|
299
|
+
return { ...issue, code };
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* 根据代码变更更新历史 issue 的行号
|
|
305
|
+
* 当代码发生变化时,之前发现的 issue 行号可能已经不准确
|
|
306
|
+
* 此方法通过分析 diff 来计算新的行号
|
|
307
|
+
*/
|
|
308
|
+
updateIssueLineNumbers(
|
|
309
|
+
issues: ReviewIssue[],
|
|
310
|
+
filePatchMap: Map<string, string>,
|
|
311
|
+
verbose?: VerboseLevel,
|
|
312
|
+
): ReviewIssue[] {
|
|
313
|
+
let updatedCount = 0;
|
|
314
|
+
let invalidatedCount = 0;
|
|
315
|
+
const updatedIssues = issues.map((issue) => {
|
|
316
|
+
// 如果 issue 已修复、已解决或无效,不需要更新行号
|
|
317
|
+
if (issue.fixed || issue.resolved || issue.valid === "false") {
|
|
318
|
+
return issue;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const patch = filePatchMap.get(issue.file);
|
|
322
|
+
if (!patch) {
|
|
323
|
+
// 文件没有变更,行号不变
|
|
324
|
+
return issue;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const lines = this.reviewSpecService.parseLineRange(issue.line);
|
|
328
|
+
if (lines.length === 0) {
|
|
329
|
+
return issue;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const startLine = lines[0];
|
|
333
|
+
const endLine = lines[lines.length - 1];
|
|
334
|
+
const hunks = parseHunksFromPatch(patch);
|
|
335
|
+
|
|
336
|
+
// 计算新的起始行号
|
|
337
|
+
const newStartLine = calculateNewLineNumber(startLine, hunks);
|
|
338
|
+
if (newStartLine === null) {
|
|
339
|
+
// 起始行被删除,直接标记为无效问题
|
|
340
|
+
invalidatedCount++;
|
|
341
|
+
if (shouldLog(verbose, 1)) {
|
|
342
|
+
console.log(`📍 Issue ${issue.file}:${issue.line} 对应的代码已被删除,标记为无效`);
|
|
343
|
+
}
|
|
344
|
+
return { ...issue, valid: "false", originalLine: issue.originalLine ?? issue.line };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// 如果是范围行号,计算新的结束行号
|
|
348
|
+
let newLine: string;
|
|
349
|
+
if (startLine === endLine) {
|
|
350
|
+
newLine = String(newStartLine);
|
|
351
|
+
} else {
|
|
352
|
+
const newEndLine = calculateNewLineNumber(endLine, hunks);
|
|
353
|
+
if (newEndLine === null || newEndLine === newStartLine) {
|
|
354
|
+
// 结束行被删除或范围缩小为单行,使用起始行
|
|
355
|
+
newLine = String(newStartLine);
|
|
356
|
+
} else {
|
|
357
|
+
newLine = `${newStartLine}-${newEndLine}`;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// 如果行号发生变化,更新 issue
|
|
362
|
+
if (newLine !== issue.line) {
|
|
363
|
+
updatedCount++;
|
|
364
|
+
if (shouldLog(verbose, 1)) {
|
|
365
|
+
console.log(`📍 Issue 行号更新: ${issue.file}:${issue.line} -> ${issue.file}:${newLine}`);
|
|
366
|
+
}
|
|
367
|
+
return { ...issue, line: newLine, originalLine: issue.originalLine ?? issue.line };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return issue;
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
if ((updatedCount > 0 || invalidatedCount > 0) && shouldLog(verbose, 1)) {
|
|
374
|
+
const parts: string[] = [];
|
|
375
|
+
if (updatedCount > 0) parts.push(`更新 ${updatedCount} 个行号`);
|
|
376
|
+
if (invalidatedCount > 0) parts.push(`标记 ${invalidatedCount} 个无效`);
|
|
377
|
+
console.log(`📊 Issue 行号处理: ${parts.join(",")}`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return updatedIssues;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* 过滤掉不属于本次 PR commits 的问题(排除 merge commit 引入的代码)
|
|
385
|
+
* 根据 fileContents 中问题行的实际 commit hash 进行验证,而不是依赖 LLM 填写的 commit
|
|
386
|
+
*/
|
|
387
|
+
filterIssuesByValidCommits(
|
|
388
|
+
issues: ReviewIssue[],
|
|
389
|
+
commits: PullRequestCommit[],
|
|
390
|
+
fileContents: FileContentsMap,
|
|
391
|
+
verbose?: VerboseLevel,
|
|
392
|
+
): ReviewIssue[] {
|
|
393
|
+
const validCommitHashes = new Set(commits.map((c) => c.sha?.slice(0, 7)).filter(Boolean));
|
|
394
|
+
|
|
395
|
+
if (shouldLog(verbose, 3)) {
|
|
396
|
+
console.log(` 🔍 有效 commit hashes: ${Array.from(validCommitHashes).join(", ")}`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const beforeCount = issues.length;
|
|
400
|
+
const filtered = issues.filter((issue) => {
|
|
401
|
+
const contentLines = fileContents.get(issue.file);
|
|
402
|
+
if (!contentLines) {
|
|
403
|
+
// 文件不在 fileContents 中,保留 issue
|
|
404
|
+
if (shouldLog(verbose, 3)) {
|
|
405
|
+
console.log(` ✅ Issue ${issue.file}:${issue.line} - 文件不在 fileContents 中,保留`);
|
|
406
|
+
}
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const lineNums = this.reviewSpecService.parseLineRange(issue.line);
|
|
411
|
+
if (lineNums.length === 0) {
|
|
412
|
+
if (shouldLog(verbose, 3)) {
|
|
413
|
+
console.log(` ✅ Issue ${issue.file}:${issue.line} - 无法解析行号,保留`);
|
|
414
|
+
}
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// 检查问题行范围内是否有任意一行属于本次 PR 的有效 commits
|
|
419
|
+
for (const lineNum of lineNums) {
|
|
420
|
+
const lineData = contentLines[lineNum - 1];
|
|
421
|
+
if (lineData) {
|
|
422
|
+
const [actualHash] = lineData;
|
|
423
|
+
if (actualHash !== "-------" && validCommitHashes.has(actualHash)) {
|
|
424
|
+
if (shouldLog(verbose, 3)) {
|
|
425
|
+
console.log(
|
|
426
|
+
` ✅ Issue ${issue.file}:${issue.line} - 行 ${lineNum} hash=${actualHash} 匹配,保留`,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// 问题行都不属于本次 PR 的有效 commits
|
|
435
|
+
if (shouldLog(verbose, 2)) {
|
|
436
|
+
console.log(` Issue ${issue.file}:${issue.line} 不在本次 PR 变更行范围内,跳过`);
|
|
437
|
+
}
|
|
438
|
+
if (shouldLog(verbose, 3)) {
|
|
439
|
+
const hashes = lineNums.map((ln) => {
|
|
440
|
+
const ld = contentLines[ln - 1];
|
|
441
|
+
return ld ? `${ln}:${ld[0]}` : `${ln}:N/A`;
|
|
442
|
+
});
|
|
443
|
+
console.log(` ❌ Issue ${issue.file}:${issue.line} - 行号 hash: ${hashes.join(", ")}`);
|
|
444
|
+
}
|
|
445
|
+
return false;
|
|
446
|
+
});
|
|
447
|
+
if (beforeCount !== filtered.length && shouldLog(verbose, 1)) {
|
|
448
|
+
console.log(` 过滤非本次 PR commits 问题后: ${beforeCount} -> ${filtered.length} 个问题`);
|
|
449
|
+
}
|
|
450
|
+
return filtered;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
filterDuplicateIssues(
|
|
454
|
+
newIssues: ReviewIssue[],
|
|
455
|
+
existingIssues: ReviewIssue[],
|
|
456
|
+
): { filteredIssues: ReviewIssue[]; skippedCount: number } {
|
|
457
|
+
// 所有历史问题(无论 valid 状态)都阻止新问题重复添加
|
|
458
|
+
// valid='false' 的问题已被评审人标记为无效,不应再次报告
|
|
459
|
+
// valid='true' 的问题已存在,无需重复
|
|
460
|
+
// fixed 的问题已解决,无需重复
|
|
461
|
+
const existingKeys = new Set(existingIssues.map((issue) => this.generateIssueKey(issue)));
|
|
462
|
+
const filteredIssues = newIssues.filter(
|
|
463
|
+
(issue) => !existingKeys.has(this.generateIssueKey(issue)),
|
|
464
|
+
);
|
|
465
|
+
const skippedCount = newIssues.length - filteredIssues.length;
|
|
466
|
+
return { filteredIssues, skippedCount };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
generateIssueKey(issue: ReviewIssue): string {
|
|
470
|
+
return generateIssueKey(issue);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* 构建文件行号到 commit hash 的映射
|
|
475
|
+
* 遍历每个 commit,获取其修改的文件和行号
|
|
476
|
+
* 优先使用 API,失败时回退到 git 命令
|
|
477
|
+
*/
|
|
478
|
+
async buildLineCommitMap(
|
|
479
|
+
owner: string,
|
|
480
|
+
repo: string,
|
|
481
|
+
commits: PullRequestCommit[],
|
|
482
|
+
verbose?: VerboseLevel,
|
|
483
|
+
): Promise<Map<string, Map<number, string>>> {
|
|
484
|
+
// Map<filename, Map<lineNumber, commitHash>>
|
|
485
|
+
const fileLineMap = new Map<string, Map<number, string>>();
|
|
486
|
+
|
|
487
|
+
// 按时间顺序遍历 commits(早的在前),后面的 commit 会覆盖前面的
|
|
488
|
+
for (const commit of commits) {
|
|
489
|
+
if (!commit.sha) continue;
|
|
490
|
+
|
|
491
|
+
const shortHash = commit.sha.slice(0, 7);
|
|
492
|
+
let files: Array<{ filename: string; patch: string }> = [];
|
|
493
|
+
|
|
494
|
+
// 优先使用 getCommitDiff API 获取 diff 文本
|
|
495
|
+
try {
|
|
496
|
+
const diffText = await this.gitProvider.getCommitDiff(owner, repo, commit.sha);
|
|
497
|
+
files = parseDiffText(diffText);
|
|
498
|
+
} catch {
|
|
499
|
+
// API 失败,回退到 git 命令
|
|
500
|
+
files = this.gitSdk.getCommitDiff(commit.sha);
|
|
501
|
+
}
|
|
502
|
+
if (shouldLog(verbose, 2)) console.log(` commit ${shortHash}: ${files.length} 个文件变更`);
|
|
503
|
+
|
|
504
|
+
for (const file of files) {
|
|
505
|
+
// 解析这个 commit 修改的行号
|
|
506
|
+
const changedLines = parseChangedLinesFromPatch(file.patch);
|
|
507
|
+
|
|
508
|
+
// 获取或创建文件的行号映射
|
|
509
|
+
if (!fileLineMap.has(file.filename)) {
|
|
510
|
+
fileLineMap.set(file.filename, new Map());
|
|
511
|
+
}
|
|
512
|
+
const lineMap = fileLineMap.get(file.filename)!;
|
|
513
|
+
|
|
514
|
+
// 记录每行对应的 commit hash
|
|
515
|
+
for (const lineNum of changedLines) {
|
|
516
|
+
lineMap.set(lineNum, shortHash);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return fileLineMap;
|
|
522
|
+
}
|
|
523
|
+
}
|