@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/index.js +3830 -2469
  3. package/package.json +2 -2
  4. package/src/deletion-impact.service.ts +17 -130
  5. package/src/index.ts +34 -2
  6. package/src/issue-verify.service.ts +18 -82
  7. package/src/locales/en/review.json +2 -1
  8. package/src/locales/zh-cn/review.json +2 -1
  9. package/src/mcp/index.ts +4 -1
  10. package/src/prompt/code-review.ts +95 -0
  11. package/src/prompt/deletion-impact.ts +105 -0
  12. package/src/prompt/index.ts +37 -0
  13. package/src/prompt/issue-verify.ts +86 -0
  14. package/src/prompt/pr-description.ts +149 -0
  15. package/src/prompt/schemas.ts +106 -0
  16. package/src/prompt/types.ts +53 -0
  17. package/src/pull-request-model.ts +236 -0
  18. package/src/review-context.ts +433 -0
  19. package/src/review-includes-filter.spec.ts +284 -0
  20. package/src/review-includes-filter.ts +196 -0
  21. package/src/review-issue-filter.ts +523 -0
  22. package/src/review-llm.ts +543 -0
  23. package/src/review-result-model.spec.ts +657 -0
  24. package/src/review-result-model.ts +1046 -0
  25. package/src/review-spec/review-spec.service.ts +26 -5
  26. package/src/review-spec/types.ts +2 -0
  27. package/src/review.config.ts +40 -5
  28. package/src/review.service.spec.ts +102 -1625
  29. package/src/review.service.ts +608 -2742
  30. package/src/system-rules/index.ts +48 -0
  31. package/src/system-rules/max-lines-per-file.ts +57 -0
  32. package/src/types/review-llm.ts +21 -0
  33. package/src/utils/review-llm.spec.ts +277 -0
  34. package/src/utils/review-llm.ts +177 -0
  35. package/src/utils/review-pr-comment.spec.ts +340 -0
  36. package/src/utils/review-pr-comment.ts +186 -0
  37. package/tsconfig.json +1 -1
@@ -0,0 +1,236 @@
1
+ import {
2
+ GitProviderService,
3
+ PullRequest,
4
+ PullRequestCommit,
5
+ ChangedFile,
6
+ CommitInfo,
7
+ IssueComment,
8
+ CreateIssueCommentOption,
9
+ CreatePullReviewOption,
10
+ PullReview,
11
+ PullReviewComment,
12
+ EditPullRequestOption,
13
+ Reaction,
14
+ ResolvedThread,
15
+ } from "@spaceflow/core";
16
+
17
+ /**
18
+ * PR 数据模型映射层
19
+ * 封装所有 PR 相关的读写操作,消除 owner/repo/prNumber 三元组的散传
20
+ * 读操作带懒加载缓存,写操作直通 gitProvider
21
+ */
22
+ export class PullRequestModel {
23
+ private _info: PullRequest | null = null;
24
+ private _commits: PullRequestCommit[] | null = null;
25
+ private _files: ChangedFile[] | null = null;
26
+ private _diff: string | null = null;
27
+ private _comments: IssueComment[] | null = null;
28
+ private _reviews: PullReview[] | null = null;
29
+
30
+ constructor(
31
+ private readonly gitProvider: GitProviderService,
32
+ readonly owner: string,
33
+ readonly repo: string,
34
+ readonly number: number,
35
+ ) {}
36
+
37
+ // ============ 懒加载数据(带缓存) ============
38
+
39
+ /** 获取 PR 基本信息(懒加载 + 缓存) */
40
+ async getInfo(): Promise<PullRequest> {
41
+ if (!this._info) {
42
+ this._info = await this.gitProvider.getPullRequest(this.owner, this.repo, this.number);
43
+ }
44
+ return this._info;
45
+ }
46
+
47
+ /** 获取 head commit SHA */
48
+ async getHeadSha(): Promise<string> {
49
+ const info = await this.getInfo();
50
+ return info.head?.sha || "HEAD";
51
+ }
52
+
53
+ /** 获取 PR 的所有 commits(懒加载 + 缓存) */
54
+ async getCommits(): Promise<PullRequestCommit[]> {
55
+ if (!this._commits) {
56
+ this._commits = await this.gitProvider.getPullRequestCommits(
57
+ this.owner,
58
+ this.repo,
59
+ this.number,
60
+ );
61
+ }
62
+ return [...this._commits];
63
+ }
64
+
65
+ /** 获取 PR 的变更文件列表(懒加载 + 缓存) */
66
+ async getFiles(): Promise<ChangedFile[]> {
67
+ if (!this._files) {
68
+ this._files = await this.gitProvider.getPullRequestFiles(this.owner, this.repo, this.number);
69
+ }
70
+ return [...this._files];
71
+ }
72
+
73
+ /** 获取 PR 的 diff 文本(懒加载 + 缓存) */
74
+ async getDiff(): Promise<string> {
75
+ if (!this._diff) {
76
+ this._diff = await this.gitProvider.getPullRequestDiff(this.owner, this.repo, this.number);
77
+ }
78
+ return this._diff;
79
+ }
80
+
81
+ // ============ PR 编辑 ============
82
+
83
+ /** 编辑 PR(标题、描述等),自动 invalidate info 缓存 */
84
+ async edit(options: EditPullRequestOption): Promise<PullRequest> {
85
+ const result = await this.gitProvider.editPullRequest(
86
+ this.owner,
87
+ this.repo,
88
+ this.number,
89
+ options,
90
+ );
91
+ this._info = result;
92
+ return result;
93
+ }
94
+
95
+ // ============ Issue Comments(PR 主评论) ============
96
+
97
+ /** 列出 PR 的所有 Issue Comments(懒加载 + 缓存) */
98
+ async getComments(): Promise<IssueComment[]> {
99
+ if (!this._comments) {
100
+ this._comments = await this.gitProvider.listIssueComments(this.owner, this.repo, this.number);
101
+ }
102
+ return [...this._comments];
103
+ }
104
+
105
+ /** 创建 Issue Comment */
106
+ async createComment(options: CreateIssueCommentOption): Promise<IssueComment> {
107
+ const result = await this.gitProvider.createIssueComment(
108
+ this.owner,
109
+ this.repo,
110
+ this.number,
111
+ options,
112
+ );
113
+ this._comments = null; // invalidate
114
+ return result;
115
+ }
116
+
117
+ /** 更新 Issue Comment */
118
+ async updateComment(commentId: number, body: string): Promise<IssueComment> {
119
+ const result = await this.gitProvider.updateIssueComment(
120
+ this.owner,
121
+ this.repo,
122
+ commentId,
123
+ body,
124
+ );
125
+ this._comments = null; // invalidate
126
+ return result;
127
+ }
128
+
129
+ /** 删除 Issue Comment */
130
+ async deleteComment(commentId: number): Promise<void> {
131
+ await this.gitProvider.deleteIssueComment(this.owner, this.repo, commentId);
132
+ this._comments = null; // invalidate
133
+ }
134
+
135
+ // ============ PR Reviews(行级评论) ============
136
+
137
+ /** 列出 PR 的所有 Reviews(懒加载 + 缓存) */
138
+ async getReviews(): Promise<PullReview[]> {
139
+ if (!this._reviews) {
140
+ this._reviews = await this.gitProvider.listPullReviews(this.owner, this.repo, this.number);
141
+ }
142
+ return [...this._reviews];
143
+ }
144
+
145
+ /** 创建 PR Review */
146
+ async createReview(options: CreatePullReviewOption): Promise<PullReview> {
147
+ const result = await this.gitProvider.createPullReview(
148
+ this.owner,
149
+ this.repo,
150
+ this.number,
151
+ options,
152
+ );
153
+ this._reviews = null; // invalidate
154
+ return result;
155
+ }
156
+
157
+ /** 删除 PR Review */
158
+ async deleteReview(reviewId: number): Promise<void> {
159
+ await this.gitProvider.deletePullReview(this.owner, this.repo, this.number, reviewId);
160
+ this._reviews = null; // invalidate
161
+ }
162
+
163
+ /** 获取 PR Review 的行级评论列表 */
164
+ async getReviewComments(reviewId: number): Promise<PullReviewComment[]> {
165
+ return this.gitProvider.listPullReviewComments(this.owner, this.repo, this.number, reviewId);
166
+ }
167
+
168
+ /** 获取已解决的 review threads */
169
+ async getResolvedThreads(): Promise<ResolvedThread[]> {
170
+ return this.gitProvider.listResolvedThreads(this.owner, this.repo, this.number);
171
+ }
172
+
173
+ // ============ Reaction 操作 ============
174
+
175
+ /** 获取 PR Review 行级评论的 reactions */
176
+ async getReviewCommentReactions(commentId: number): Promise<Reaction[]> {
177
+ return this.gitProvider.getPullReviewCommentReactions(this.owner, this.repo, commentId);
178
+ }
179
+
180
+ // ============ 仓库级操作(需要 owner/repo 但不需要 prNumber) ============
181
+
182
+ /** 获取文件内容 */
183
+ async getFileContent(filepath: string, ref?: string): Promise<string> {
184
+ return this.gitProvider.getFileContent(this.owner, this.repo, filepath, ref);
185
+ }
186
+
187
+ /** 获取单个 commit 信息 */
188
+ async getCommit(sha: string): Promise<CommitInfo> {
189
+ return this.gitProvider.getCommit(this.owner, this.repo, sha);
190
+ }
191
+
192
+ /** 获取单个 commit 的 diff */
193
+ async getCommitDiff(sha: string): Promise<string> {
194
+ return this.gitProvider.getCommitDiff(this.owner, this.repo, sha);
195
+ }
196
+
197
+ /** 列出 workflow runs */
198
+ async listWorkflowRuns(options?: { status?: string; sha?: string }) {
199
+ return this.gitProvider.listWorkflowRuns(this.owner, this.repo, options);
200
+ }
201
+
202
+ // ============ 缓存控制 ============
203
+
204
+ /** 清除指定或所有缓存 */
205
+ invalidate(key?: "info" | "commits" | "files" | "diff" | "comments" | "reviews"): void {
206
+ if (key) {
207
+ switch (key) {
208
+ case "info":
209
+ this._info = null;
210
+ break;
211
+ case "commits":
212
+ this._commits = null;
213
+ break;
214
+ case "files":
215
+ this._files = null;
216
+ break;
217
+ case "diff":
218
+ this._diff = null;
219
+ break;
220
+ case "comments":
221
+ this._comments = null;
222
+ break;
223
+ case "reviews":
224
+ this._reviews = null;
225
+ break;
226
+ }
227
+ } else {
228
+ this._info = null;
229
+ this._commits = null;
230
+ this._files = null;
231
+ this._diff = null;
232
+ this._comments = null;
233
+ this._reviews = null;
234
+ }
235
+ }
236
+ }
@@ -0,0 +1,433 @@
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
+ whenModifiedCode?: string[];
32
+ files?: string[];
33
+ commits?: string[];
34
+ concurrency?: number;
35
+ timeout?: number;
36
+ retries?: number;
37
+ retryDelay?: number;
38
+ /** 仅执行删除代码分析,跳过常规代码审查 */
39
+ deletionOnly?: boolean;
40
+ /** 删除代码分析模式:openai 使用标准模式,claude-agent 使用 Agent 模式 */
41
+ deletionAnalysisMode?: LLMMode;
42
+ /** 输出格式:markdown, terminal, json。不指定则智能选择 */
43
+ outputFormat?: ReportFormat;
44
+ /** 是否使用 AI 生成 PR 功能描述 */
45
+ generateDescription?: boolean;
46
+ /** 显示所有问题,不过滤非变更行的问题 */
47
+ showAll?: boolean;
48
+ /** PR 事件类型(opened, synchronize, closed 等) */
49
+ eventAction?: string;
50
+ /**
51
+ * 本地代码审查模式(已解析)
52
+ * - 'uncommitted': 审查所有未提交的代码(暂存区 + 工作区)
53
+ * - 'staged': 仅审查暂存区的代码
54
+ * - false: 禁用本地模式
55
+ */
56
+ localMode?: LocalReviewMode;
57
+ }
58
+
59
+ export class ReviewContextBuilder {
60
+ constructor(
61
+ protected readonly gitProvider: GitProviderService,
62
+ protected readonly config: IConfigReader,
63
+ protected readonly gitSdk: GitSdkService,
64
+ ) {}
65
+
66
+ async getContextFromEnv(options: ReviewOptions): Promise<ReviewContext> {
67
+ const reviewConf = this.config.getPluginConfig<ReviewConfig>("review");
68
+ if (shouldLog(options.verbose, 2)) {
69
+ console.log(`[getContextFromEnv] reviewConf: ${JSON.stringify(reviewConf)}`);
70
+ }
71
+ const ciConf = this.config.get<CiConfig>("ci");
72
+ const repository = ciConf?.repository;
73
+
74
+ if (options.ci) {
75
+ this.gitProvider.validateConfig();
76
+ }
77
+
78
+ let repoPath = repository;
79
+ if (!repoPath) {
80
+ // 非 CI 模式下,从 git remote 获取仓库信息
81
+ const remoteUrl = this.gitSdk.getRemoteUrl();
82
+ if (remoteUrl) {
83
+ const parsed = this.gitSdk.parseRepositoryFromRemoteUrl(remoteUrl);
84
+ if (parsed) {
85
+ repoPath = `${parsed.owner}/${parsed.repo}`;
86
+ if (shouldLog(options.verbose, 1)) {
87
+ console.log(`📦 从 git remote 获取仓库: ${repoPath}`);
88
+ }
89
+ }
90
+ }
91
+ }
92
+
93
+ if (!repoPath) {
94
+ throw new Error("缺少配置 ci.repository");
95
+ }
96
+
97
+ const parts = repoPath.split("/");
98
+ if (parts.length < 2) {
99
+ throw new Error("ci.repository 格式不正确");
100
+ }
101
+
102
+ const owner = parts[0];
103
+ const repo = parts[1];
104
+
105
+ let prNumber = options.prNumber;
106
+
107
+ if (!prNumber && options.ci) {
108
+ prNumber = await this.getPrNumberFromEvent();
109
+ }
110
+
111
+ // 从 PR 标题解析命令参数(命令行参数优先,标题参数作为补充)
112
+ let titleOptions: ReturnType<typeof parseTitleOptions> = {};
113
+ if (prNumber && options.ci) {
114
+ try {
115
+ const pr = await this.gitProvider.getPullRequest(owner, repo, prNumber);
116
+ if (pr?.title) {
117
+ titleOptions = parseTitleOptions(pr.title);
118
+ if (Object.keys(titleOptions).length > 0 && shouldLog(options.verbose, 1)) {
119
+ console.log(`📋 从 PR 标题解析到参数:`, titleOptions);
120
+ }
121
+ }
122
+ } catch (error) {
123
+ if (shouldLog(options.verbose, 1)) {
124
+ console.warn(`⚠️ 获取 PR 标题失败:`, error);
125
+ }
126
+ }
127
+ }
128
+
129
+ const specSources = [
130
+ join(homedir(), ".spaceflow", "deps"),
131
+ join(process.cwd(), ".spaceflow", "deps"),
132
+ ];
133
+ if (options.references?.length) {
134
+ specSources.push(...options.references);
135
+ }
136
+ if (reviewConf.references?.length) {
137
+ specSources.push(...reviewConf.references);
138
+ }
139
+
140
+ // 解析本地模式:非 CI、非 PR、无 base/head 时默认启用 uncommitted 模式
141
+ const localMode = this.resolveLocalMode(options, {
142
+ ci: options.ci,
143
+ hasPrNumber: !!prNumber,
144
+ hasBaseHead: !!(options.base || options.head),
145
+ });
146
+
147
+ // 当没有 PR 且没有指定 base/head 且不是本地模式时,自动获取默认值
148
+ let baseRef = options.base;
149
+ let headRef = options.head;
150
+ if (!prNumber && !baseRef && !headRef && !localMode) {
151
+ headRef = this.gitSdk.getCurrentBranch() ?? "HEAD";
152
+ baseRef = this.gitSdk.getDefaultBranch();
153
+ if (shouldLog(options.verbose, 1)) {
154
+ console.log(`📌 自动检测分支: base=${baseRef}, head=${headRef}`);
155
+ }
156
+ }
157
+
158
+ // 合并参数优先级:命令行 > PR 标题 > 配置文件 > 默认值
159
+ const ctxIncludes = options.includes ?? titleOptions.includes ?? reviewConf.includes;
160
+ if (shouldLog(options.verbose, 2)) {
161
+ console.log(
162
+ `[getContextFromEnv] includes: commandLine=${JSON.stringify(options.includes)}, title=${JSON.stringify(titleOptions.includes)}, config=${JSON.stringify(reviewConf.includes)}, final=${JSON.stringify(ctxIncludes)}`,
163
+ );
164
+ }
165
+ return {
166
+ owner,
167
+ repo,
168
+ prNumber,
169
+ baseRef,
170
+ headRef,
171
+ specSources,
172
+ dryRun: options.dryRun || titleOptions.dryRun || false,
173
+ ci: options.ci ?? false,
174
+ verbose: normalizeVerbose(options.verbose ?? titleOptions.verbose),
175
+ includes: ctxIncludes,
176
+ whenModifiedCode: options.whenModifiedCode ?? reviewConf.whenModifiedCode,
177
+ llmMode: options.llmMode ?? titleOptions.llmMode ?? reviewConf.llmMode,
178
+ files: this.normalizeFilePaths(options.files),
179
+ commits: options.commits,
180
+ verifyFixes:
181
+ options.verifyFixes ?? titleOptions.verifyFixes ?? reviewConf.verifyFixes ?? true,
182
+ verifyConcurrency: options.verifyConcurrency ?? reviewConf.verifyFixesConcurrency ?? 10,
183
+ analyzeDeletions: this.resolveAnalyzeDeletions(
184
+ options.analyzeDeletions ??
185
+ options.deletionOnly ??
186
+ titleOptions.analyzeDeletions ??
187
+ titleOptions.deletionOnly ??
188
+ reviewConf.analyzeDeletions ??
189
+ false,
190
+ { ci: options.ci, hasPrNumber: !!prNumber },
191
+ ),
192
+ deletionOnly: options.deletionOnly || titleOptions.deletionOnly || false,
193
+ deletionAnalysisMode:
194
+ options.deletionAnalysisMode ??
195
+ titleOptions.deletionAnalysisMode ??
196
+ reviewConf.deletionAnalysisMode ??
197
+ "openai",
198
+ concurrency: options.concurrency ?? reviewConf.concurrency ?? 5,
199
+ timeout: options.timeout ?? reviewConf.timeout,
200
+ retries: options.retries ?? reviewConf.retries ?? 0,
201
+ retryDelay: options.retryDelay ?? reviewConf.retryDelay ?? 1000,
202
+ generateDescription: options.generateDescription ?? reviewConf.generateDescription ?? false,
203
+ showAll: options.showAll ?? false,
204
+ flush: options.flush ?? false,
205
+ eventAction: options.eventAction,
206
+ localMode,
207
+ duplicateWorkflowResolved:
208
+ options.duplicateWorkflowResolved ?? reviewConf.duplicateWorkflowResolved ?? "delete",
209
+ autoApprove: options.autoApprove ?? reviewConf.autoApprove ?? false,
210
+ systemRules: options.systemRules ?? reviewConf.systemRules,
211
+ };
212
+ }
213
+
214
+ /**
215
+ * 解析本地代码审查模式
216
+ * - 显式指定 --local [mode] 时使用指定值
217
+ * - 显式指定 --no-local 时禁用
218
+ * - 非 CI、非 PR、无 base/head 时默认启用 uncommitted 模式
219
+ */
220
+ resolveLocalMode(
221
+ options: ReviewOptions,
222
+ env: { ci: boolean; hasPrNumber: boolean; hasBaseHead: boolean },
223
+ ): "uncommitted" | "staged" | false {
224
+ // 显式指定了 --no-local
225
+ if (options.local === false) {
226
+ return false;
227
+ }
228
+ // 显式指定了 --local [mode]
229
+ if (options.local === "staged" || options.local === "uncommitted") {
230
+ return options.local;
231
+ }
232
+ // CI 或 PR 模式下不启用本地模式
233
+ if (env.ci || env.hasPrNumber) {
234
+ return false;
235
+ }
236
+ // 指定了 base/head 时不启用本地模式
237
+ if (env.hasBaseHead) {
238
+ return false;
239
+ }
240
+ // 默认启用 uncommitted 模式
241
+ return "uncommitted";
242
+ }
243
+
244
+ /**
245
+ * 将文件路径规范化为相对于仓库根目录的路径
246
+ * 支持绝对路径和相对路径输入
247
+ */
248
+ normalizeFilePaths(files?: string[]): string[] | undefined {
249
+ if (!files || files.length === 0) return files;
250
+
251
+ const cwd = process.cwd();
252
+ return files.map((file) => {
253
+ if (isAbsolute(file)) {
254
+ // 绝对路径转换为相对路径
255
+ return relative(cwd, file);
256
+ }
257
+ return file;
258
+ });
259
+ }
260
+
261
+ /**
262
+ * 根据 AnalyzeDeletionsMode 和当前环境解析是否启用删除代码分析
263
+ * @param mode 配置的模式值
264
+ * @param env 当前环境信息
265
+ * @returns 是否启用删除代码分析
266
+ */
267
+ resolveAnalyzeDeletions(
268
+ mode: AnalyzeDeletionsMode,
269
+ env: { ci: boolean; hasPrNumber: boolean },
270
+ ): boolean {
271
+ if (typeof mode === "boolean") {
272
+ return mode;
273
+ }
274
+ switch (mode) {
275
+ case "ci":
276
+ return env.ci;
277
+ case "pr":
278
+ return env.hasPrNumber;
279
+ case "terminal":
280
+ return !env.ci;
281
+ default:
282
+ return false;
283
+ }
284
+ }
285
+
286
+ /**
287
+ * 从 CI 事件文件中解析 PR 编号
288
+ * 在 CI 环境中,GitHub/Gitea Actions 会将事件信息写入 GITHUB_EVENT_PATH / GITEA_EVENT_PATH 指向的文件
289
+ * @returns PR 编号,如果无法解析则返回 undefined
290
+ */
291
+ async getPrNumberFromEvent(): Promise<number | undefined> {
292
+ const eventPath = process.env.GITHUB_EVENT_PATH || process.env.GITEA_EVENT_PATH;
293
+ if (!eventPath) {
294
+ return undefined;
295
+ }
296
+
297
+ try {
298
+ const eventContent = await readFile(eventPath, "utf-8");
299
+ const event = JSON.parse(eventContent);
300
+ // 支持多种事件类型:
301
+ // - pull_request 事件: event.pull_request.number 或 event.number
302
+ // - issue_comment 事件: event.issue.number
303
+ return event.pull_request?.number || event.issue?.number || event.number;
304
+ } catch {
305
+ return undefined;
306
+ }
307
+ }
308
+
309
+ /**
310
+ * 根据 commit 填充 issue 的 author 信息
311
+ * 如果没有找到对应的 author,使用最后一次提交的人作为默认值
312
+ */
313
+ async fillIssueAuthors(
314
+ issues: ReviewIssue[],
315
+ commits: PullRequestCommit[],
316
+ _owner: string,
317
+ _repo: string,
318
+ verbose?: VerboseLevel,
319
+ ): Promise<ReviewIssue[]> {
320
+ if (shouldLog(verbose, 2)) {
321
+ console.log(`[fillIssueAuthors] issues=${issues.length}, commits=${commits.length}`);
322
+ }
323
+
324
+ // 收集需要查找的 Git 作者信息(email 或 name)
325
+ const gitAuthorsToSearch = new Set<string>();
326
+ for (const commit of commits) {
327
+ const platformUser = commit.author || commit.committer;
328
+ if (!platformUser?.login) {
329
+ const gitAuthor = commit.commit?.author;
330
+ if (gitAuthor?.email) gitAuthorsToSearch.add(gitAuthor.email);
331
+ if (gitAuthor?.name) gitAuthorsToSearch.add(gitAuthor.name);
332
+ }
333
+ }
334
+
335
+ // 通过 Git Provider API 查找用户,建立 email/name -> UserInfo 的映射
336
+ const gitAuthorToUserMap = new Map<string, UserInfo>();
337
+ for (const query of gitAuthorsToSearch) {
338
+ try {
339
+ const users = await this.gitProvider.searchUsers(query, 1);
340
+ if (users.length > 0 && users[0].login) {
341
+ const user: UserInfo = { id: String(users[0].id), login: users[0].login };
342
+ gitAuthorToUserMap.set(query, user);
343
+ if (shouldLog(verbose, 2)) {
344
+ console.log(`[fillIssueAuthors] found user: ${query} -> ${user.login}`);
345
+ }
346
+ }
347
+ } catch {
348
+ // 忽略搜索失败
349
+ }
350
+ }
351
+
352
+ // 构建 commit hash 到 author 的映射
353
+ const commitAuthorMap = new Map<string, UserInfo>();
354
+ for (const commit of commits) {
355
+ const platformUser = commit.author || commit.committer;
356
+ const gitAuthor = commit.commit?.author;
357
+ if (shouldLog(verbose, 2)) {
358
+ console.log(
359
+ `[fillIssueAuthors] commit: sha=${commit.sha?.slice(0, 7)}, platformUser=${platformUser?.login}, gitAuthor=${gitAuthor?.name}`,
360
+ );
361
+ }
362
+ if (commit.sha) {
363
+ const shortHash = commit.sha.slice(0, 7);
364
+ if (platformUser?.login) {
365
+ commitAuthorMap.set(shortHash, {
366
+ id: String(platformUser.id),
367
+ login: platformUser.login,
368
+ });
369
+ } else if (gitAuthor) {
370
+ const foundUser =
371
+ (gitAuthor.email && gitAuthorToUserMap.get(gitAuthor.email)) ||
372
+ (gitAuthor.name && gitAuthorToUserMap.get(gitAuthor.name));
373
+ if (foundUser) {
374
+ commitAuthorMap.set(shortHash, foundUser);
375
+ } else if (gitAuthor.name) {
376
+ commitAuthorMap.set(shortHash, { id: "0", login: gitAuthor.name });
377
+ }
378
+ }
379
+ }
380
+ }
381
+ if (shouldLog(verbose, 2)) {
382
+ console.log(`[fillIssueAuthors] commitAuthorMap size: ${commitAuthorMap.size}`);
383
+ }
384
+
385
+ // 获取最后一次提交的 author 作为默认值
386
+ const lastCommit = commits[commits.length - 1];
387
+ const lastPlatformUser = lastCommit?.author || lastCommit?.committer;
388
+ const lastGitAuthor = lastCommit?.commit?.author;
389
+ let defaultAuthor: UserInfo | undefined;
390
+ if (lastPlatformUser?.login) {
391
+ defaultAuthor = { id: String(lastPlatformUser.id), login: lastPlatformUser.login };
392
+ } else if (lastGitAuthor) {
393
+ const foundUser =
394
+ (lastGitAuthor.email && gitAuthorToUserMap.get(lastGitAuthor.email)) ||
395
+ (lastGitAuthor.name && gitAuthorToUserMap.get(lastGitAuthor.name));
396
+ defaultAuthor =
397
+ foundUser || (lastGitAuthor.name ? { id: "0", login: lastGitAuthor.name } : undefined);
398
+ }
399
+ if (shouldLog(verbose, 2)) {
400
+ console.log(`[fillIssueAuthors] defaultAuthor: ${JSON.stringify(defaultAuthor)}`);
401
+ }
402
+ // 为每个 issue 填充 author
403
+ return issues.map((issue) => {
404
+ if (issue.author) {
405
+ const shortHash = issue.commit?.slice(0, 7);
406
+ if (shortHash?.includes("---")) {
407
+ return { ...issue, commit: undefined, valid: "false" };
408
+ }
409
+ if (shouldLog(verbose, 2)) {
410
+ console.log(`[fillIssueAuthors] issue already has author: ${issue.author.login}`);
411
+ }
412
+ return issue;
413
+ }
414
+ const shortHash = issue.commit?.slice(0, 7);
415
+ const isValidHash = Boolean(shortHash && !shortHash.includes("---"));
416
+ if (!isValidHash) {
417
+ if (shouldLog(verbose, 2)) {
418
+ console.log(
419
+ `[fillIssueAuthors] issue: file=${issue.file}, commit=${issue.commit} is invalid hash, marking as invalid`,
420
+ );
421
+ }
422
+ return { ...issue, commit: undefined, valid: "false" };
423
+ }
424
+ const author = commitAuthorMap.get(shortHash!);
425
+ if (shouldLog(verbose, 2)) {
426
+ console.log(
427
+ `[fillIssueAuthors] issue: file=${issue.file}, commit=${issue.commit}, shortHash=${shortHash}, foundAuthor=${author?.login}, finalAuthor=${(author || defaultAuthor)?.login}`,
428
+ );
429
+ }
430
+ return { ...issue, author: author || defaultAuthor };
431
+ });
432
+ }
433
+ }