@spaceflow/review 0.76.0 → 0.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,144 @@
1
+ import micromatch from "micromatch";
2
+
3
+ /**
4
+ * includes 模式中的变更类型前缀
5
+ *
6
+ * 语法:`<status>|<glob>`,例如:
7
+ * - `added|*\/**\/*.ts` → 仅匹配新增文件
8
+ * - `modified|*\/**\/*.ts` → 仅匹配修改文件
9
+ * - `deleted|*\/**\/*.ts` → 仅匹配删除文件
10
+ * - `*\/**\/*.ts` → 不限变更类型(原有行为)
11
+ */
12
+ export type IncludeStatusPrefix = "added" | "modified" | "deleted";
13
+
14
+ /** status 值到前缀的映射(兼容 GitHub/GitLab/Gitea 各平台) */
15
+ const STATUS_ALIAS: Record<string, IncludeStatusPrefix> = {
16
+ added: "added",
17
+ created: "added",
18
+ renamed: "modified",
19
+ modified: "modified",
20
+ changed: "modified",
21
+ removed: "deleted",
22
+ deleted: "deleted",
23
+ };
24
+
25
+ export interface ParsedIncludePattern {
26
+ /** 变更类型前缀,undefined 表示不限类型 */
27
+ status: IncludeStatusPrefix | undefined;
28
+ /** 去掉前缀后的 glob 模式 */
29
+ glob: string;
30
+ }
31
+
32
+ /**
33
+ * 解析单条 include 模式,拆分 status 前缀和 glob。
34
+ *
35
+ * 只有当 `|` 前面的部分是已知 status 关键字时才视为前缀,否则当作普通 glob 处理(容错),
36
+ * 这样可以避免误解析 extglob 语法中含 `|` 的模式(如 `+(*.ts|*.js)`)。
37
+ * 排除模式(以 `!` 开头)始终作为普通 glob 处理。
38
+ */
39
+ export function parseIncludePattern(pattern: string): ParsedIncludePattern {
40
+ if (pattern.startsWith("!")) {
41
+ return { status: undefined, glob: pattern };
42
+ }
43
+ const separatorIndex = pattern.indexOf("|");
44
+ if (separatorIndex === -1) {
45
+ return { status: undefined, glob: pattern };
46
+ }
47
+ const prefix = pattern.slice(0, separatorIndex).trim().toLowerCase();
48
+ const glob = pattern.slice(separatorIndex + 1).trim();
49
+ const status = STATUS_ALIAS[prefix] as IncludeStatusPrefix | undefined;
50
+ if (!status) {
51
+ // 前缀无法识别(如 extglob 中的 `|`),当作普通 glob 处理
52
+ return { status: undefined, glob: pattern };
53
+ }
54
+ return { status, glob };
55
+ }
56
+
57
+ export interface FileWithStatus {
58
+ filename?: string;
59
+ status?: string;
60
+ }
61
+
62
+ /**
63
+ * 根据 includes 模式列表过滤文件,支持 `status|glob` 前缀语法。
64
+ *
65
+ * 算法:
66
+ * 1. 将 includes 拆分为:排除模式(`!`)、无前缀正向 glob、有 status 前缀 glob
67
+ * 2. 每个文件先检查是否命中任意正向条件(无前缀 glob 或匹配 status 的前缀 glob)
68
+ * 3. 最后用排除模式做全局过滤(排除模式始终优先)
69
+ *
70
+ * @param files 待过滤的文件列表
71
+ * @param includes include 模式列表,支持 `added|*.ts`、`modified|*.ts`、`deleted|*.ts` 前缀
72
+ * @returns 匹配的文件列表
73
+ */
74
+ export function filterFilesByIncludes<T extends FileWithStatus>(
75
+ files: T[],
76
+ includes: string[],
77
+ ): T[] {
78
+ if (!includes || includes.length === 0) return files;
79
+
80
+ const parsed = includes.map(parseIncludePattern);
81
+
82
+ // 排除模式(以 ! 开头),用于最终全局过滤
83
+ const negativeGlobs = parsed
84
+ .filter((p) => p.status === undefined && p.glob.startsWith("!"))
85
+ .map((p) => p.glob.slice(1)); // 去掉 ! 前缀,用 micromatch.not 处理
86
+ // 无前缀的正向 globs
87
+ const plainGlobs = parsed
88
+ .filter((p) => p.status === undefined && !p.glob.startsWith("!"))
89
+ .map((p) => p.glob);
90
+ // 有 status 前缀的 patterns
91
+ const statusPatterns = parsed.filter((p) => p.status !== undefined);
92
+
93
+ return files.filter((file) => {
94
+ const filename = file.filename ?? "";
95
+ if (!filename) return false;
96
+
97
+ // 最终排除:命中排除模式的文件直接过滤掉
98
+ if (
99
+ negativeGlobs.length > 0 &&
100
+ micromatch.isMatch(filename, negativeGlobs, { matchBase: true })
101
+ ) {
102
+ return false;
103
+ }
104
+
105
+ // 正向匹配:无前缀 glob
106
+ if (plainGlobs.length > 0 && micromatch.isMatch(filename, plainGlobs, { matchBase: true })) {
107
+ return true;
108
+ }
109
+
110
+ // 正向匹配:有 status 前缀的 glob,按文件实际 status 过滤
111
+ // glob 可以带 ! 前缀表示在该 status 范围内排除,如 added|!**/*.spec.ts
112
+ if (statusPatterns.length > 0) {
113
+ const fileStatus = STATUS_ALIAS[file.status?.toLowerCase() ?? ""] ?? "modified";
114
+ // 按 status 分组,每组内正向 glob + 排除 glob 合并后批量匹配
115
+ const matchingStatusGlobs = statusPatterns
116
+ .filter(({ status }) => status === fileStatus)
117
+ .map(({ glob }) => glob);
118
+ if (matchingStatusGlobs.length > 0) {
119
+ // 有正向 glob 才有意义,纯排除 glob 组合 micromatch 会视为全匹配再排除
120
+ const positiveGlobs = matchingStatusGlobs.filter((g) => !g.startsWith("!"));
121
+ const negativeStatusGlobs = matchingStatusGlobs
122
+ .filter((g) => g.startsWith("!"))
123
+ .map((g) => g.slice(1));
124
+ if (positiveGlobs.length > 0) {
125
+ const matchesPositive = micromatch.isMatch(filename, positiveGlobs, { matchBase: true });
126
+ const matchesNegative =
127
+ negativeStatusGlobs.length > 0 &&
128
+ micromatch.isMatch(filename, negativeStatusGlobs, { matchBase: true });
129
+ if (matchesPositive && !matchesNegative) return true;
130
+ }
131
+ }
132
+ }
133
+
134
+ return false;
135
+ });
136
+ }
137
+
138
+ /**
139
+ * 从 includes 模式列表中提取纯 glob(用于 commit 过滤,commit 没有 status 概念)。
140
+ * 带 status 前缀的模式会去掉前缀,仅保留 glob 部分。
141
+ */
142
+ export function extractGlobsFromIncludes(includes: string[]): string[] {
143
+ return includes.map((p) => parseIncludePattern(p).glob);
144
+ }
@@ -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 "./review-pr-comment-utils";
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
+ }