@spaceflow/review 0.76.0 → 0.77.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/index.js +2654 -1872
- package/package.json +2 -2
- package/src/deletion-impact.service.ts +4 -2
- package/src/index.ts +34 -2
- package/src/locales/en/review.json +2 -1
- package/src/locales/zh-cn/review.json +2 -1
- package/src/pull-request-model.ts +236 -0
- package/src/review-context.ts +409 -0
- package/src/review-includes-filter.spec.ts +248 -0
- package/src/review-includes-filter.ts +144 -0
- package/src/review-issue-filter.ts +523 -0
- package/src/review-llm.ts +634 -0
- package/src/review-pr-comment-utils.ts +186 -0
- package/src/review-result-model.spec.ts +657 -0
- package/src/review-result-model.ts +1024 -0
- package/src/review-spec/types.ts +2 -0
- package/src/review.config.ts +9 -0
- package/src/review.service.spec.ts +93 -1626
- package/src/review.service.ts +531 -2765
- package/src/types/review-llm.ts +19 -0
- package/src/utils/review-llm.ts +32 -0
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import {
|
|
2
|
+
GitProviderService,
|
|
3
|
+
PullRequestCommit,
|
|
4
|
+
type CiConfig,
|
|
5
|
+
type LLMMode,
|
|
6
|
+
type VerboseLevel,
|
|
7
|
+
shouldLog,
|
|
8
|
+
normalizeVerbose,
|
|
9
|
+
GitSdkService,
|
|
10
|
+
} from "@spaceflow/core";
|
|
11
|
+
import type { IConfigReader, LocalReviewMode } from "@spaceflow/core";
|
|
12
|
+
import { type AnalyzeDeletionsMode, type ReviewConfig } from "./review.config";
|
|
13
|
+
import { ReviewOptions } from "./review.config";
|
|
14
|
+
import { parseTitleOptions } from "./parse-title-options";
|
|
15
|
+
import { type ReviewIssue, type UserInfo } from "./review-spec";
|
|
16
|
+
import { readFile } from "fs/promises";
|
|
17
|
+
import { join } from "path";
|
|
18
|
+
import { isAbsolute, relative } from "path";
|
|
19
|
+
import { homedir } from "os";
|
|
20
|
+
import type { ReportFormat } from "./review-report";
|
|
21
|
+
|
|
22
|
+
export interface ReviewContext extends ReviewOptions {
|
|
23
|
+
owner: string;
|
|
24
|
+
repo: string;
|
|
25
|
+
prNumber?: number;
|
|
26
|
+
baseRef?: string;
|
|
27
|
+
headRef?: string;
|
|
28
|
+
specSources: string[];
|
|
29
|
+
verbose?: VerboseLevel;
|
|
30
|
+
includes?: string[];
|
|
31
|
+
files?: string[];
|
|
32
|
+
commits?: string[];
|
|
33
|
+
concurrency?: number;
|
|
34
|
+
timeout?: number;
|
|
35
|
+
retries?: number;
|
|
36
|
+
retryDelay?: number;
|
|
37
|
+
/** 仅执行删除代码分析,跳过常规代码审查 */
|
|
38
|
+
deletionOnly?: boolean;
|
|
39
|
+
/** 删除代码分析模式:openai 使用标准模式,claude-agent 使用 Agent 模式 */
|
|
40
|
+
deletionAnalysisMode?: LLMMode;
|
|
41
|
+
/** 输出格式:markdown, terminal, json。不指定则智能选择 */
|
|
42
|
+
outputFormat?: ReportFormat;
|
|
43
|
+
/** 是否使用 AI 生成 PR 功能描述 */
|
|
44
|
+
generateDescription?: boolean;
|
|
45
|
+
/** 显示所有问题,不过滤非变更行的问题 */
|
|
46
|
+
showAll?: boolean;
|
|
47
|
+
/** PR 事件类型(opened, synchronize, closed 等) */
|
|
48
|
+
eventAction?: string;
|
|
49
|
+
/**
|
|
50
|
+
* 本地代码审查模式(已解析)
|
|
51
|
+
* - 'uncommitted': 审查所有未提交的代码(暂存区 + 工作区)
|
|
52
|
+
* - 'staged': 仅审查暂存区的代码
|
|
53
|
+
* - false: 禁用本地模式
|
|
54
|
+
*/
|
|
55
|
+
localMode?: LocalReviewMode;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class ReviewContextBuilder {
|
|
59
|
+
constructor(
|
|
60
|
+
protected readonly gitProvider: GitProviderService,
|
|
61
|
+
protected readonly config: IConfigReader,
|
|
62
|
+
protected readonly gitSdk: GitSdkService,
|
|
63
|
+
) {}
|
|
64
|
+
|
|
65
|
+
async getContextFromEnv(options: ReviewOptions): Promise<ReviewContext> {
|
|
66
|
+
const reviewConf = this.config.getPluginConfig<ReviewConfig>("review");
|
|
67
|
+
const ciConf = this.config.get<CiConfig>("ci");
|
|
68
|
+
const repository = ciConf?.repository;
|
|
69
|
+
|
|
70
|
+
if (options.ci) {
|
|
71
|
+
this.gitProvider.validateConfig();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let repoPath = repository;
|
|
75
|
+
if (!repoPath) {
|
|
76
|
+
// 非 CI 模式下,从 git remote 获取仓库信息
|
|
77
|
+
const remoteUrl = this.gitSdk.getRemoteUrl();
|
|
78
|
+
if (remoteUrl) {
|
|
79
|
+
const parsed = this.gitSdk.parseRepositoryFromRemoteUrl(remoteUrl);
|
|
80
|
+
if (parsed) {
|
|
81
|
+
repoPath = `${parsed.owner}/${parsed.repo}`;
|
|
82
|
+
if (shouldLog(options.verbose, 1)) {
|
|
83
|
+
console.log(`📦 从 git remote 获取仓库: ${repoPath}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!repoPath) {
|
|
90
|
+
throw new Error("缺少配置 ci.repository");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const parts = repoPath.split("/");
|
|
94
|
+
if (parts.length < 2) {
|
|
95
|
+
throw new Error("ci.repository 格式不正确");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const owner = parts[0];
|
|
99
|
+
const repo = parts[1];
|
|
100
|
+
|
|
101
|
+
let prNumber = options.prNumber;
|
|
102
|
+
|
|
103
|
+
if (!prNumber && options.ci) {
|
|
104
|
+
prNumber = await this.getPrNumberFromEvent();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 从 PR 标题解析命令参数(命令行参数优先,标题参数作为补充)
|
|
108
|
+
let titleOptions: ReturnType<typeof parseTitleOptions> = {};
|
|
109
|
+
if (prNumber && options.ci) {
|
|
110
|
+
try {
|
|
111
|
+
const pr = await this.gitProvider.getPullRequest(owner, repo, prNumber);
|
|
112
|
+
if (pr?.title) {
|
|
113
|
+
titleOptions = parseTitleOptions(pr.title);
|
|
114
|
+
if (Object.keys(titleOptions).length > 0 && shouldLog(options.verbose, 1)) {
|
|
115
|
+
console.log(`📋 从 PR 标题解析到参数:`, titleOptions);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (shouldLog(options.verbose, 1)) {
|
|
120
|
+
console.warn(`⚠️ 获取 PR 标题失败:`, error);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const specSources = [
|
|
126
|
+
join(homedir(), ".spaceflow", "deps"),
|
|
127
|
+
join(process.cwd(), ".spaceflow", "deps"),
|
|
128
|
+
];
|
|
129
|
+
if (options.references?.length) {
|
|
130
|
+
specSources.push(...options.references);
|
|
131
|
+
}
|
|
132
|
+
if (reviewConf.references?.length) {
|
|
133
|
+
specSources.push(...reviewConf.references);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 解析本地模式:非 CI、非 PR、无 base/head 时默认启用 uncommitted 模式
|
|
137
|
+
const localMode = this.resolveLocalMode(options, {
|
|
138
|
+
ci: options.ci,
|
|
139
|
+
hasPrNumber: !!prNumber,
|
|
140
|
+
hasBaseHead: !!(options.base || options.head),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// 当没有 PR 且没有指定 base/head 且不是本地模式时,自动获取默认值
|
|
144
|
+
let baseRef = options.base;
|
|
145
|
+
let headRef = options.head;
|
|
146
|
+
if (!prNumber && !baseRef && !headRef && !localMode) {
|
|
147
|
+
headRef = this.gitSdk.getCurrentBranch() ?? "HEAD";
|
|
148
|
+
baseRef = this.gitSdk.getDefaultBranch();
|
|
149
|
+
if (shouldLog(options.verbose, 1)) {
|
|
150
|
+
console.log(`📌 自动检测分支: base=${baseRef}, head=${headRef}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 合并参数优先级:命令行 > PR 标题 > 配置文件 > 默认值
|
|
155
|
+
return {
|
|
156
|
+
owner,
|
|
157
|
+
repo,
|
|
158
|
+
prNumber,
|
|
159
|
+
baseRef,
|
|
160
|
+
headRef,
|
|
161
|
+
specSources,
|
|
162
|
+
dryRun: options.dryRun || titleOptions.dryRun || false,
|
|
163
|
+
ci: options.ci ?? false,
|
|
164
|
+
verbose: normalizeVerbose(options.verbose ?? titleOptions.verbose),
|
|
165
|
+
includes: options.includes ?? titleOptions.includes ?? reviewConf.includes,
|
|
166
|
+
llmMode: options.llmMode ?? titleOptions.llmMode ?? reviewConf.llmMode,
|
|
167
|
+
files: this.normalizeFilePaths(options.files),
|
|
168
|
+
commits: options.commits,
|
|
169
|
+
verifyFixes:
|
|
170
|
+
options.verifyFixes ?? titleOptions.verifyFixes ?? reviewConf.verifyFixes ?? true,
|
|
171
|
+
verifyConcurrency: options.verifyConcurrency ?? reviewConf.verifyFixesConcurrency ?? 10,
|
|
172
|
+
analyzeDeletions: this.resolveAnalyzeDeletions(
|
|
173
|
+
options.analyzeDeletions ??
|
|
174
|
+
options.deletionOnly ??
|
|
175
|
+
titleOptions.analyzeDeletions ??
|
|
176
|
+
titleOptions.deletionOnly ??
|
|
177
|
+
reviewConf.analyzeDeletions ??
|
|
178
|
+
false,
|
|
179
|
+
{ ci: options.ci, hasPrNumber: !!prNumber },
|
|
180
|
+
),
|
|
181
|
+
deletionOnly: options.deletionOnly || titleOptions.deletionOnly || false,
|
|
182
|
+
deletionAnalysisMode:
|
|
183
|
+
options.deletionAnalysisMode ??
|
|
184
|
+
titleOptions.deletionAnalysisMode ??
|
|
185
|
+
reviewConf.deletionAnalysisMode ??
|
|
186
|
+
"openai",
|
|
187
|
+
concurrency: options.concurrency ?? reviewConf.concurrency ?? 5,
|
|
188
|
+
timeout: options.timeout ?? reviewConf.timeout,
|
|
189
|
+
retries: options.retries ?? reviewConf.retries ?? 0,
|
|
190
|
+
retryDelay: options.retryDelay ?? reviewConf.retryDelay ?? 1000,
|
|
191
|
+
generateDescription: options.generateDescription ?? reviewConf.generateDescription ?? false,
|
|
192
|
+
showAll: options.showAll ?? false,
|
|
193
|
+
flush: options.flush ?? false,
|
|
194
|
+
eventAction: options.eventAction,
|
|
195
|
+
localMode,
|
|
196
|
+
skipDuplicateWorkflow:
|
|
197
|
+
options.skipDuplicateWorkflow ?? reviewConf.skipDuplicateWorkflow ?? false,
|
|
198
|
+
autoApprove: options.autoApprove ?? reviewConf.autoApprove ?? false,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 解析本地代码审查模式
|
|
204
|
+
* - 显式指定 --local [mode] 时使用指定值
|
|
205
|
+
* - 显式指定 --no-local 时禁用
|
|
206
|
+
* - 非 CI、非 PR、无 base/head 时默认启用 uncommitted 模式
|
|
207
|
+
*/
|
|
208
|
+
resolveLocalMode(
|
|
209
|
+
options: ReviewOptions,
|
|
210
|
+
env: { ci: boolean; hasPrNumber: boolean; hasBaseHead: boolean },
|
|
211
|
+
): "uncommitted" | "staged" | false {
|
|
212
|
+
// 显式指定了 --no-local
|
|
213
|
+
if (options.local === false) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
// 显式指定了 --local [mode]
|
|
217
|
+
if (options.local === "staged" || options.local === "uncommitted") {
|
|
218
|
+
return options.local;
|
|
219
|
+
}
|
|
220
|
+
// CI 或 PR 模式下不启用本地模式
|
|
221
|
+
if (env.ci || env.hasPrNumber) {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
// 指定了 base/head 时不启用本地模式
|
|
225
|
+
if (env.hasBaseHead) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
// 默认启用 uncommitted 模式
|
|
229
|
+
return "uncommitted";
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* 将文件路径规范化为相对于仓库根目录的路径
|
|
234
|
+
* 支持绝对路径和相对路径输入
|
|
235
|
+
*/
|
|
236
|
+
normalizeFilePaths(files?: string[]): string[] | undefined {
|
|
237
|
+
if (!files || files.length === 0) return files;
|
|
238
|
+
|
|
239
|
+
const cwd = process.cwd();
|
|
240
|
+
return files.map((file) => {
|
|
241
|
+
if (isAbsolute(file)) {
|
|
242
|
+
// 绝对路径转换为相对路径
|
|
243
|
+
return relative(cwd, file);
|
|
244
|
+
}
|
|
245
|
+
return file;
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 根据 AnalyzeDeletionsMode 和当前环境解析是否启用删除代码分析
|
|
251
|
+
* @param mode 配置的模式值
|
|
252
|
+
* @param env 当前环境信息
|
|
253
|
+
* @returns 是否启用删除代码分析
|
|
254
|
+
*/
|
|
255
|
+
resolveAnalyzeDeletions(
|
|
256
|
+
mode: AnalyzeDeletionsMode,
|
|
257
|
+
env: { ci: boolean; hasPrNumber: boolean },
|
|
258
|
+
): boolean {
|
|
259
|
+
if (typeof mode === "boolean") {
|
|
260
|
+
return mode;
|
|
261
|
+
}
|
|
262
|
+
switch (mode) {
|
|
263
|
+
case "ci":
|
|
264
|
+
return env.ci;
|
|
265
|
+
case "pr":
|
|
266
|
+
return env.hasPrNumber;
|
|
267
|
+
case "terminal":
|
|
268
|
+
return !env.ci;
|
|
269
|
+
default:
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 从 CI 事件文件中解析 PR 编号
|
|
276
|
+
* 在 CI 环境中,GitHub/Gitea Actions 会将事件信息写入 GITHUB_EVENT_PATH / GITEA_EVENT_PATH 指向的文件
|
|
277
|
+
* @returns PR 编号,如果无法解析则返回 undefined
|
|
278
|
+
*/
|
|
279
|
+
async getPrNumberFromEvent(): Promise<number | undefined> {
|
|
280
|
+
const eventPath = process.env.GITHUB_EVENT_PATH || process.env.GITEA_EVENT_PATH;
|
|
281
|
+
if (!eventPath) {
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
const eventContent = await readFile(eventPath, "utf-8");
|
|
287
|
+
const event = JSON.parse(eventContent);
|
|
288
|
+
// 支持多种事件类型:
|
|
289
|
+
// - pull_request 事件: event.pull_request.number 或 event.number
|
|
290
|
+
// - issue_comment 事件: event.issue.number
|
|
291
|
+
return event.pull_request?.number || event.issue?.number || event.number;
|
|
292
|
+
} catch {
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 根据 commit 填充 issue 的 author 信息
|
|
299
|
+
* 如果没有找到对应的 author,使用最后一次提交的人作为默认值
|
|
300
|
+
*/
|
|
301
|
+
async fillIssueAuthors(
|
|
302
|
+
issues: ReviewIssue[],
|
|
303
|
+
commits: PullRequestCommit[],
|
|
304
|
+
_owner: string,
|
|
305
|
+
_repo: string,
|
|
306
|
+
verbose?: VerboseLevel,
|
|
307
|
+
): Promise<ReviewIssue[]> {
|
|
308
|
+
if (shouldLog(verbose, 2)) {
|
|
309
|
+
console.log(`[fillIssueAuthors] issues=${issues.length}, commits=${commits.length}`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// 收集需要查找的 Git 作者信息(email 或 name)
|
|
313
|
+
const gitAuthorsToSearch = new Set<string>();
|
|
314
|
+
for (const commit of commits) {
|
|
315
|
+
const platformUser = commit.author || commit.committer;
|
|
316
|
+
if (!platformUser?.login) {
|
|
317
|
+
const gitAuthor = commit.commit?.author;
|
|
318
|
+
if (gitAuthor?.email) gitAuthorsToSearch.add(gitAuthor.email);
|
|
319
|
+
if (gitAuthor?.name) gitAuthorsToSearch.add(gitAuthor.name);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// 通过 Git Provider API 查找用户,建立 email/name -> UserInfo 的映射
|
|
324
|
+
const gitAuthorToUserMap = new Map<string, UserInfo>();
|
|
325
|
+
for (const query of gitAuthorsToSearch) {
|
|
326
|
+
try {
|
|
327
|
+
const users = await this.gitProvider.searchUsers(query, 1);
|
|
328
|
+
if (users.length > 0 && users[0].login) {
|
|
329
|
+
const user: UserInfo = { id: String(users[0].id), login: users[0].login };
|
|
330
|
+
gitAuthorToUserMap.set(query, user);
|
|
331
|
+
if (shouldLog(verbose, 2)) {
|
|
332
|
+
console.log(`[fillIssueAuthors] found user: ${query} -> ${user.login}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
} catch {
|
|
336
|
+
// 忽略搜索失败
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 构建 commit hash 到 author 的映射
|
|
341
|
+
const commitAuthorMap = new Map<string, UserInfo>();
|
|
342
|
+
for (const commit of commits) {
|
|
343
|
+
const platformUser = commit.author || commit.committer;
|
|
344
|
+
const gitAuthor = commit.commit?.author;
|
|
345
|
+
if (shouldLog(verbose, 2)) {
|
|
346
|
+
console.log(
|
|
347
|
+
`[fillIssueAuthors] commit: sha=${commit.sha?.slice(0, 7)}, platformUser=${platformUser?.login}, gitAuthor=${gitAuthor?.name}`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
if (commit.sha) {
|
|
351
|
+
const shortHash = commit.sha.slice(0, 7);
|
|
352
|
+
if (platformUser?.login) {
|
|
353
|
+
commitAuthorMap.set(shortHash, {
|
|
354
|
+
id: String(platformUser.id),
|
|
355
|
+
login: platformUser.login,
|
|
356
|
+
});
|
|
357
|
+
} else if (gitAuthor) {
|
|
358
|
+
const foundUser =
|
|
359
|
+
(gitAuthor.email && gitAuthorToUserMap.get(gitAuthor.email)) ||
|
|
360
|
+
(gitAuthor.name && gitAuthorToUserMap.get(gitAuthor.name));
|
|
361
|
+
if (foundUser) {
|
|
362
|
+
commitAuthorMap.set(shortHash, foundUser);
|
|
363
|
+
} else if (gitAuthor.name) {
|
|
364
|
+
commitAuthorMap.set(shortHash, { id: "0", login: gitAuthor.name });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (shouldLog(verbose, 2)) {
|
|
370
|
+
console.log(`[fillIssueAuthors] commitAuthorMap size: ${commitAuthorMap.size}`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// 获取最后一次提交的 author 作为默认值
|
|
374
|
+
const lastCommit = commits[commits.length - 1];
|
|
375
|
+
const lastPlatformUser = lastCommit?.author || lastCommit?.committer;
|
|
376
|
+
const lastGitAuthor = lastCommit?.commit?.author;
|
|
377
|
+
let defaultAuthor: UserInfo | undefined;
|
|
378
|
+
if (lastPlatformUser?.login) {
|
|
379
|
+
defaultAuthor = { id: String(lastPlatformUser.id), login: lastPlatformUser.login };
|
|
380
|
+
} else if (lastGitAuthor) {
|
|
381
|
+
const foundUser =
|
|
382
|
+
(lastGitAuthor.email && gitAuthorToUserMap.get(lastGitAuthor.email)) ||
|
|
383
|
+
(lastGitAuthor.name && gitAuthorToUserMap.get(lastGitAuthor.name));
|
|
384
|
+
defaultAuthor =
|
|
385
|
+
foundUser || (lastGitAuthor.name ? { id: "0", login: lastGitAuthor.name } : undefined);
|
|
386
|
+
}
|
|
387
|
+
if (shouldLog(verbose, 2)) {
|
|
388
|
+
console.log(`[fillIssueAuthors] defaultAuthor: ${JSON.stringify(defaultAuthor)}`);
|
|
389
|
+
}
|
|
390
|
+
// 为每个 issue 填充 author
|
|
391
|
+
return issues.map((issue) => {
|
|
392
|
+
if (issue.author) {
|
|
393
|
+
if (shouldLog(verbose, 2)) {
|
|
394
|
+
console.log(`[fillIssueAuthors] issue already has author: ${issue.author.login}`);
|
|
395
|
+
}
|
|
396
|
+
return issue;
|
|
397
|
+
}
|
|
398
|
+
const shortHash = issue.commit?.slice(0, 7);
|
|
399
|
+
const author =
|
|
400
|
+
shortHash && !shortHash.includes("---") ? commitAuthorMap.get(shortHash) : undefined;
|
|
401
|
+
if (shouldLog(verbose, 2)) {
|
|
402
|
+
console.log(
|
|
403
|
+
`[fillIssueAuthors] issue: file=${issue.file}, commit=${issue.commit}, shortHash=${shortHash}, foundAuthor=${author?.login}, finalAuthor=${(author || defaultAuthor)?.login}`,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
return { ...issue, author: author || defaultAuthor };
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
parseIncludePattern,
|
|
4
|
+
filterFilesByIncludes,
|
|
5
|
+
extractGlobsFromIncludes,
|
|
6
|
+
} from "./review-includes-filter";
|
|
7
|
+
|
|
8
|
+
describe("review-includes-filter", () => {
|
|
9
|
+
describe("parseIncludePattern", () => {
|
|
10
|
+
it("无分隔符时返回原始 glob,status 为 undefined", () => {
|
|
11
|
+
expect(parseIncludePattern("*/**/*.ts")).toEqual({ status: undefined, glob: "*/**/*.ts" });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("! 开头的排除模式直接返回,不解析前缀", () => {
|
|
15
|
+
expect(parseIncludePattern("!*/**/*.spec.ts")).toEqual({
|
|
16
|
+
status: undefined,
|
|
17
|
+
glob: "!*/**/*.spec.ts",
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("added| 前缀解析为 status=added", () => {
|
|
22
|
+
expect(parseIncludePattern("added|*/**/*.ts")).toEqual({
|
|
23
|
+
status: "added",
|
|
24
|
+
glob: "*/**/*.ts",
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("modified| 前缀解析为 status=modified", () => {
|
|
29
|
+
expect(parseIncludePattern("modified|*/**/*.ts")).toEqual({
|
|
30
|
+
status: "modified",
|
|
31
|
+
glob: "*/**/*.ts",
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("deleted| 前缀解析为 status=deleted", () => {
|
|
36
|
+
expect(parseIncludePattern("deleted|*/**/*.ts")).toEqual({
|
|
37
|
+
status: "deleted",
|
|
38
|
+
glob: "*/**/*.ts",
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("glob 部分可以带 ! 前缀(status 内排除语法)", () => {
|
|
43
|
+
expect(parseIncludePattern("added|!*/**/*.spec.ts")).toEqual({
|
|
44
|
+
status: "added",
|
|
45
|
+
glob: "!*/**/*.spec.ts",
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("无法识别的前缀当作普通 glob 处理(容错)", () => {
|
|
50
|
+
expect(parseIncludePattern("unknown|*/**/*.ts")).toEqual({
|
|
51
|
+
status: undefined,
|
|
52
|
+
glob: "unknown|*/**/*.ts",
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("extglob 中含 | 不被误识别为前缀分隔符", () => {
|
|
57
|
+
expect(parseIncludePattern("+(*.ts|*.js)")).toEqual({
|
|
58
|
+
status: undefined,
|
|
59
|
+
glob: "+(*.ts|*.js)",
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("前缀大小写不敏感", () => {
|
|
64
|
+
expect(parseIncludePattern("Added|*/**/*.ts")).toEqual({
|
|
65
|
+
status: "added",
|
|
66
|
+
glob: "*/**/*.ts",
|
|
67
|
+
});
|
|
68
|
+
expect(parseIncludePattern("MODIFIED|*/**/*.ts")).toEqual({
|
|
69
|
+
status: "modified",
|
|
70
|
+
glob: "*/**/*.ts",
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("平台别名:created 映射为 added", () => {
|
|
75
|
+
expect(parseIncludePattern("created|*/**/*.ts")).toEqual({
|
|
76
|
+
status: "added",
|
|
77
|
+
glob: "*/**/*.ts",
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("平台别名:removed 映射为 deleted", () => {
|
|
82
|
+
expect(parseIncludePattern("removed|*/**/*.ts")).toEqual({
|
|
83
|
+
status: "deleted",
|
|
84
|
+
glob: "*/**/*.ts",
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("平台别名:renamed 映射为 modified", () => {
|
|
89
|
+
expect(parseIncludePattern("renamed|*/**/*.ts")).toEqual({
|
|
90
|
+
status: "modified",
|
|
91
|
+
glob: "*/**/*.ts",
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("filterFilesByIncludes", () => {
|
|
97
|
+
const files = [
|
|
98
|
+
{ filename: "src/foo.ts", status: "added" },
|
|
99
|
+
{ filename: "src/foo.spec.ts", status: "added" },
|
|
100
|
+
{ filename: "src/bar.ts", status: "modified" },
|
|
101
|
+
{ filename: "src/bar.spec.ts", status: "modified" },
|
|
102
|
+
{ filename: "src/old.ts", status: "removed" },
|
|
103
|
+
{ filename: "src/old.spec.ts", status: "removed" },
|
|
104
|
+
];
|
|
105
|
+
const glob = "**/*.ts";
|
|
106
|
+
const specGlob = "**/*.spec.ts";
|
|
107
|
+
|
|
108
|
+
it("includes 为空时返回全部文件", () => {
|
|
109
|
+
expect(filterFilesByIncludes(files, [])).toEqual(files);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("无前缀 glob 不限 status,匹配所有 .ts 文件", () => {
|
|
113
|
+
const result = filterFilesByIncludes(files, [glob]);
|
|
114
|
+
expect(result.map((f) => f.filename)).toEqual([
|
|
115
|
+
"src/foo.ts",
|
|
116
|
+
"src/foo.spec.ts",
|
|
117
|
+
"src/bar.ts",
|
|
118
|
+
"src/bar.spec.ts",
|
|
119
|
+
"src/old.ts",
|
|
120
|
+
"src/old.spec.ts",
|
|
121
|
+
]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("排除模式过滤掉 spec 文件", () => {
|
|
125
|
+
const result = filterFilesByIncludes(files, [glob, `!${specGlob}`]);
|
|
126
|
+
expect(result.map((f) => f.filename)).toEqual(["src/foo.ts", "src/bar.ts", "src/old.ts"]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("排除模式优先于所有正向匹配", () => {
|
|
130
|
+
const result = filterFilesByIncludes(files, [`added|${glob}`, `!${specGlob}`]);
|
|
131
|
+
expect(result.map((f) => f.filename)).toEqual(["src/foo.ts"]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("added| 前缀只匹配 added 状态文件", () => {
|
|
135
|
+
const result = filterFilesByIncludes(files, [`added|${glob}`]);
|
|
136
|
+
expect(result.map((f) => f.filename)).toEqual(["src/foo.ts", "src/foo.spec.ts"]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("modified| 前缀只匹配 modified 状态文件", () => {
|
|
140
|
+
const result = filterFilesByIncludes(files, [`modified|${glob}`]);
|
|
141
|
+
expect(result.map((f) => f.filename)).toEqual(["src/bar.ts", "src/bar.spec.ts"]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("deleted| 前缀匹配 removed 状态文件(平台别名)", () => {
|
|
145
|
+
const result = filterFilesByIncludes(files, [`deleted|${glob}`]);
|
|
146
|
+
expect(result.map((f) => f.filename)).toEqual(["src/old.ts", "src/old.spec.ts"]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("多个 status 前缀之间是 OR 关系", () => {
|
|
150
|
+
const result = filterFilesByIncludes(files, [`added|${glob}`, `modified|${glob}`]);
|
|
151
|
+
expect(result.map((f) => f.filename)).toEqual([
|
|
152
|
+
"src/foo.ts",
|
|
153
|
+
"src/foo.spec.ts",
|
|
154
|
+
"src/bar.ts",
|
|
155
|
+
"src/bar.spec.ts",
|
|
156
|
+
]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("status 前缀内排除语法 added|!**/*.spec.ts", () => {
|
|
160
|
+
const result = filterFilesByIncludes(files, [`added|${glob}`, `added|!${specGlob}`]);
|
|
161
|
+
expect(result.map((f) => f.filename)).toEqual(["src/foo.ts"]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("status 内排除只影响对应 status,不影响其他 status 的匹配", () => {
|
|
165
|
+
const result = filterFilesByIncludes(files, [
|
|
166
|
+
`added|${glob}`,
|
|
167
|
+
`added|!${specGlob}`,
|
|
168
|
+
`modified|${glob}`,
|
|
169
|
+
]);
|
|
170
|
+
expect(result.map((f) => f.filename)).toEqual([
|
|
171
|
+
"src/foo.ts",
|
|
172
|
+
"src/bar.ts",
|
|
173
|
+
"src/bar.spec.ts",
|
|
174
|
+
]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("无前缀 glob 与 status 前缀混用时任一命中即保留", () => {
|
|
178
|
+
const result = filterFilesByIncludes(files, [glob, "added|**/*.vue"]);
|
|
179
|
+
expect(result.map((f) => f.filename)).toEqual([
|
|
180
|
+
"src/foo.ts",
|
|
181
|
+
"src/foo.spec.ts",
|
|
182
|
+
"src/bar.ts",
|
|
183
|
+
"src/bar.spec.ts",
|
|
184
|
+
"src/old.ts",
|
|
185
|
+
"src/old.spec.ts",
|
|
186
|
+
]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("status 未知的文件 fallback 为 modified", () => {
|
|
190
|
+
const unknownFiles = [{ filename: "src/foo.ts", status: undefined }];
|
|
191
|
+
const result = filterFilesByIncludes(unknownFiles, [`modified|${glob}`]);
|
|
192
|
+
expect(result.map((f) => f.filename)).toEqual(["src/foo.ts"]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("status 未知的文件不被 added| 匹配", () => {
|
|
196
|
+
const unknownFiles = [{ filename: "src/foo.ts", status: undefined }];
|
|
197
|
+
const result = filterFilesByIncludes(unknownFiles, [`added|${glob}`]);
|
|
198
|
+
expect(result).toEqual([]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("filename 为空的文件被过滤掉", () => {
|
|
202
|
+
const withEmpty = [
|
|
203
|
+
{ filename: "", status: "added" },
|
|
204
|
+
{ filename: "src/foo.ts", status: "added" },
|
|
205
|
+
];
|
|
206
|
+
const result = filterFilesByIncludes(withEmpty, [glob]);
|
|
207
|
+
expect(result.map((f) => f.filename)).toEqual(["src/foo.ts"]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("GitHub 平台 status=removed 被 deleted| 前缀匹配", () => {
|
|
211
|
+
const ghFiles = [{ filename: "src/old.ts", status: "removed" }];
|
|
212
|
+
expect(filterFilesByIncludes(ghFiles, [`deleted|${glob}`])).toHaveLength(1);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("GitLab 平台 status=deleted 被 deleted| 前缀匹配", () => {
|
|
216
|
+
const glFiles = [{ filename: "src/old.ts", status: "deleted" }];
|
|
217
|
+
expect(filterFilesByIncludes(glFiles, [`deleted|${glob}`])).toHaveLength(1);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("extractGlobsFromIncludes", () => {
|
|
222
|
+
it("无前缀的 glob 原样返回", () => {
|
|
223
|
+
expect(extractGlobsFromIncludes(["**/*.ts", "!**/*.spec.ts"])).toEqual([
|
|
224
|
+
"**/*.ts",
|
|
225
|
+
"!**/*.spec.ts",
|
|
226
|
+
]);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("有 status 前缀的 pattern 去掉前缀只返回 glob 部分", () => {
|
|
230
|
+
expect(extractGlobsFromIncludes(["added|**/*.ts", "modified|**/*.vue"])).toEqual([
|
|
231
|
+
"**/*.ts",
|
|
232
|
+
"**/*.vue",
|
|
233
|
+
]);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("混合模式只提取 glob 部分", () => {
|
|
237
|
+
expect(extractGlobsFromIncludes(["**/*.ts", "added|**/*.vue", "!**/*.spec.ts"])).toEqual([
|
|
238
|
+
"**/*.ts",
|
|
239
|
+
"**/*.vue",
|
|
240
|
+
"!**/*.spec.ts",
|
|
241
|
+
]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("空数组返回空数组", () => {
|
|
245
|
+
expect(extractGlobsFromIncludes([])).toEqual([]);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
});
|