@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,1046 @@
1
+ import {
2
+ GitProviderService,
3
+ CreatePullReviewComment,
4
+ REVIEW_STATE,
5
+ type VerboseLevel,
6
+ shouldLog,
7
+ parseDiffText,
8
+ } from "@spaceflow/core";
9
+ import type { IConfigReader } from "@spaceflow/core";
10
+ import { PullRequestModel } from "./pull-request-model";
11
+ import { type ReviewConfig } from "./review.config";
12
+ import { ReviewSpecService, ReviewIssue, ReviewResult, ReviewStats } from "./review-spec";
13
+ import { ReviewReportService, type ReportFormat } from "./review-report";
14
+ import { extname } from "path";
15
+ import {
16
+ extractIssueKeyFromBody,
17
+ generateIssueKey,
18
+ syncRepliesToIssues,
19
+ calculateIssueStats,
20
+ REVIEW_COMMENT_MARKER,
21
+ REVIEW_LINE_COMMENTS_MARKER,
22
+ } from "./utils/review-pr-comment";
23
+
24
+ export interface ReviewResultSaveOptions {
25
+ verbose?: VerboseLevel;
26
+ autoApprove?: boolean;
27
+ skipSync?: boolean;
28
+ }
29
+
30
+ export interface ReviewResultModelDeps {
31
+ gitProvider: GitProviderService;
32
+ config: IConfigReader;
33
+ reviewSpecService: ReviewSpecService;
34
+ reviewReportService: ReviewReportService;
35
+ }
36
+
37
+ /**
38
+ * ReviewResult 的活跃对象模型,封装 PR ↔ ReviewResult 的双向映射。
39
+ *
40
+ * - 持有 PullRequestModel 引用和 ReviewResult 数据
41
+ * - 提供从 PR 读取/同步状态的方法
42
+ * - 提供将结果写回 PR 的方法
43
+ * - 提供格式化输出的方法
44
+ */
45
+ export class ReviewResultModel {
46
+ constructor(
47
+ readonly pr: PullRequestModel,
48
+ private _result: ReviewResult,
49
+ private readonly deps: ReviewResultModelDeps,
50
+ ) {}
51
+
52
+ // ─── 工厂方法 ───────────────────────────────────────────
53
+
54
+ /**
55
+ * 从 PR 的已有 AI 评论中加载 ReviewResult。
56
+ * 如果没有找到 AI 评论,返回 null。
57
+ */
58
+ static async loadFromPr(
59
+ pr: PullRequestModel,
60
+ deps: ReviewResultModelDeps,
61
+ ): Promise<ReviewResultModel | null> {
62
+ try {
63
+ const comments = await pr.getComments();
64
+ const existingComment = comments.findLast((c) => c.body?.includes(REVIEW_COMMENT_MARKER));
65
+ if (existingComment?.body) {
66
+ const parsed = deps.reviewReportService.parseMarkdown(existingComment.body);
67
+ if (parsed?.result) {
68
+ return new ReviewResultModel(pr, parsed.result, deps);
69
+ }
70
+ }
71
+ } catch (error) {
72
+ console.warn("⚠️ 获取已有评论失败:", error);
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /**
78
+ * 用已有的 ReviewResult 数据创建模型(例如 LLM 审查结果)。
79
+ */
80
+ static create(
81
+ pr: PullRequestModel,
82
+ result: ReviewResult,
83
+ deps: ReviewResultModelDeps,
84
+ ): ReviewResultModel {
85
+ return new ReviewResultModel(pr, result, deps);
86
+ }
87
+
88
+ /**
89
+ * 非 PR 模式:用假的 PullRequestModel 创建模型(仅用于格式化输出等纯数据操作)。
90
+ * PR I/O 方法(save/syncResolved 等)不可用。
91
+ */
92
+ static createLocal(result: ReviewResult, deps: ReviewResultModelDeps): ReviewResultModel {
93
+ const stubPr = new PullRequestModel(deps.gitProvider, "", "", 0);
94
+ return new ReviewResultModel(stubPr, result, deps);
95
+ }
96
+
97
+ /**
98
+ * 创建一个空的 ReviewResult 模型。
99
+ */
100
+ static empty(pr: PullRequestModel, deps: ReviewResultModelDeps): ReviewResultModel {
101
+ return new ReviewResultModel(
102
+ pr,
103
+ { success: true, description: "", issues: [], summary: [], round: 0 },
104
+ deps,
105
+ );
106
+ }
107
+
108
+ // ─── 读取器 ─────────────────────────────────────────────
109
+
110
+ get result(): ReviewResult {
111
+ return this._result;
112
+ }
113
+
114
+ get issues(): ReviewIssue[] {
115
+ return this._result.issues;
116
+ }
117
+
118
+ set issues(value: ReviewIssue[]) {
119
+ this._result.issues = value;
120
+ }
121
+
122
+ get round(): number {
123
+ return this._result.round;
124
+ }
125
+
126
+ get stats(): ReviewStats {
127
+ return calculateIssueStats(this._result.issues);
128
+ }
129
+
130
+ // ─── 轮次推进 ─────────────────────────────────────────────
131
+
132
+ /**
133
+ * 构建 Round 标题字符串,供 buildLineReviewBody 和 cleanupDuplicateRoundReviews 共用。
134
+ */
135
+ static buildRoundTitle(round: number): string {
136
+ return `### 🚀 Spaceflow Review · Round ${round}`;
137
+ }
138
+
139
+ /**
140
+ * 基于当前模型创建下一轮审查模型。
141
+ * - 自动递增 round
142
+ * - 为 newIssues 打上 round 标签
143
+ * - 合并历史 issues(this.issues)+ newIssues
144
+ * - 复制 newResult 的元信息(title/description/deletionImpact 等)
145
+ *
146
+ * 调用方应在调用前完成对历史 issues 的预处理(syncResolved、invalidateChangedFiles、verifyFixes、去重等)。
147
+ */
148
+ nextRound(newResult: ReviewResult): ReviewResultModel {
149
+ const nextRoundNum = this._result.round + 1;
150
+ const taggedNewIssues = newResult.issues.map((issue) => ({ ...issue, round: nextRoundNum }));
151
+ const mergedResult: ReviewResult = {
152
+ ...newResult,
153
+ round: nextRoundNum,
154
+ issues: [...this._result.issues, ...taggedNewIssues],
155
+ };
156
+ return new ReviewResultModel(this.pr, mergedResult, this.deps);
157
+ }
158
+
159
+ // ─── 数据操作 ───────────────────────────────────────────
160
+
161
+ /**
162
+ * 替换整个 result 对象
163
+ */
164
+ setResult(result: ReviewResult): void {
165
+ this._result = result;
166
+ }
167
+
168
+ /**
169
+ * 更新 result 的部分字段
170
+ */
171
+ update(partial: Partial<ReviewResult>): void {
172
+ Object.assign(this._result, partial);
173
+ }
174
+
175
+ /**
176
+ * 计算问题统计并设置到 result.stats
177
+ */
178
+ updateStats(): ReviewStats {
179
+ const stats = this.stats;
180
+ this._result.stats = stats;
181
+ return stats;
182
+ }
183
+
184
+ // ─── 同步(读 PR → 修改 result)─────────────────────────
185
+
186
+ /**
187
+ * 从 PR 的所有 resolved review threads 中同步 resolved 状态到 result.issues。
188
+ * 用户手动点击 resolve 的记录写入 resolved/resolvedBy 字段(区别于 AI 验证的 fixed/fixedBy)。
189
+ * 优先通过评论 body 中的 issue key 精确匹配,回退到 path+line 匹配。
190
+ */
191
+ async syncResolved(): Promise<void> {
192
+ try {
193
+ const resolvedThreads = await this.pr.getResolvedThreads();
194
+ if (resolvedThreads.length === 0) {
195
+ return;
196
+ }
197
+ // 构建 issue key → issue 的映射,用于精确匹配
198
+ const issueByKey = new Map<string, ReviewIssue>();
199
+ for (const issue of this._result.issues) {
200
+ issueByKey.set(generateIssueKey(issue), issue);
201
+ }
202
+ const now = new Date().toISOString();
203
+ for (const thread of resolvedThreads) {
204
+ if (!thread.path) continue;
205
+ // 优先通过 issue key 精确匹配
206
+ let matchedIssue: ReviewIssue | undefined;
207
+ if (thread.body) {
208
+ const issueKey = extractIssueKeyFromBody(thread.body);
209
+ if (issueKey) {
210
+ matchedIssue = issueByKey.get(issueKey);
211
+ }
212
+ }
213
+ // 回退:path:line 匹配
214
+ if (!matchedIssue) {
215
+ matchedIssue = this._result.issues.find(
216
+ (issue) =>
217
+ issue.file === thread.path && this.lineMatchesPosition(issue.line, thread.line),
218
+ );
219
+ }
220
+ if (matchedIssue && !matchedIssue.resolved) {
221
+ matchedIssue.resolved = now;
222
+ if (thread.resolvedBy) {
223
+ matchedIssue.resolvedBy = {
224
+ id: thread.resolvedBy.id?.toString(),
225
+ login: thread.resolvedBy.login,
226
+ };
227
+ }
228
+ console.log(
229
+ `🟢 问题已标记为已解决: ${matchedIssue.file}:${matchedIssue.line}` +
230
+ (thread.resolvedBy?.login ? ` (by @${thread.resolvedBy.login})` : ""),
231
+ );
232
+ }
233
+ }
234
+ } catch (error) {
235
+ console.warn("⚠️ 同步已解决评论失败:", error);
236
+ }
237
+ }
238
+
239
+ /**
240
+ * 从旧的 AI review 评论中获取 reactions 和回复,同步到 result.issues。
241
+ * - 存储所有 reactions 到 issue.reactions 字段
242
+ * - 存储评论回复到 issue.replies 字段
243
+ * - 如果评论有 ☹️ (confused) reaction,将对应的问题标记为无效
244
+ * - 如果评论有 👎 (-1) reaction,将对应的问题标记为未解决
245
+ */
246
+ async syncReactions(verbose?: VerboseLevel): Promise<void> {
247
+ try {
248
+ const reviews = await this.pr.getReviews();
249
+ const aiReview = reviews.find((r) => r.body?.includes(REVIEW_LINE_COMMENTS_MARKER));
250
+ if (!aiReview?.id) {
251
+ if (shouldLog(verbose, 2)) {
252
+ console.log(`[syncReactionsToIssues] No AI review found`);
253
+ }
254
+ return;
255
+ }
256
+
257
+ // 收集所有评审人
258
+ const reviewers = new Set<string>();
259
+
260
+ // 1. 从已提交的 review 中获取评审人(排除 AI bot)
261
+ for (const review of reviews) {
262
+ if (review.user?.login && !review.body?.includes(REVIEW_LINE_COMMENTS_MARKER)) {
263
+ reviewers.add(review.user.login);
264
+ }
265
+ }
266
+ if (shouldLog(verbose, 2)) {
267
+ console.log(
268
+ `[syncReactionsToIssues] reviewers from reviews: ${Array.from(reviewers).join(", ")}`,
269
+ );
270
+ }
271
+
272
+ // 2. 从 PR 指定的评审人中获取(包括团队成员)
273
+ try {
274
+ const prInfo = await this.pr.getInfo();
275
+ // 添加指定的个人评审人
276
+ for (const reviewer of prInfo.requested_reviewers || []) {
277
+ if (reviewer.login) {
278
+ reviewers.add(reviewer.login);
279
+ }
280
+ }
281
+ if (shouldLog(verbose, 2)) {
282
+ console.log(
283
+ `[syncReactionsToIssues] requested_reviewers: ${(prInfo.requested_reviewers || []).map((r) => r.login).join(", ")}`,
284
+ );
285
+ console.log(
286
+ `[syncReactionsToIssues] requested_reviewers_teams: ${JSON.stringify(prInfo.requested_reviewers_teams || [])}`,
287
+ );
288
+ }
289
+ // 添加指定的团队成员(需要通过 API 获取团队成员列表)
290
+ for (const team of prInfo.requested_reviewers_teams || []) {
291
+ if (team.id) {
292
+ try {
293
+ const members = await this.deps.gitProvider.getTeamMembers(team.id);
294
+ if (shouldLog(verbose, 2)) {
295
+ console.log(
296
+ `[syncReactionsToIssues] team ${team.name}(${team.id}) members: ${members.map((m) => m.login).join(", ")}`,
297
+ );
298
+ }
299
+ for (const member of members) {
300
+ if (member.login) {
301
+ reviewers.add(member.login);
302
+ }
303
+ }
304
+ } catch (e) {
305
+ if (shouldLog(verbose, 2)) {
306
+ console.log(`[syncReactionsToIssues] failed to get team ${team.id} members: ${e}`);
307
+ }
308
+ }
309
+ }
310
+ }
311
+ } catch (prError) {
312
+ // 获取 PR 信息失败,继续使用已有的评审人列表
313
+ if (shouldLog(verbose, 2)) {
314
+ console.warn("[syncReactionsToIssues] 获取 PR 信息失败:", prError);
315
+ }
316
+ }
317
+ if (shouldLog(verbose, 2)) {
318
+ console.log(`[syncReactionsToIssues] final reviewers: ${Array.from(reviewers).join(", ")}`);
319
+ }
320
+
321
+ // 获取该 review 的所有行级评论
322
+ const reviewComments = await this.pr.getReviewComments(aiReview.id);
323
+ // 构建评论 ID 到 issue 的映射,用于后续匹配回复
324
+ const commentIdToIssue = new Map<number, ReviewIssue>();
325
+ // 遍历每个评论,获取其 reactions
326
+ for (const comment of reviewComments) {
327
+ if (!comment.id) continue;
328
+ // 找到对应的 issue:优先通过 issue-key 精确匹配,回退到 path+line 匹配
329
+ let matchedIssue: ReviewIssue | undefined;
330
+ if (comment.body) {
331
+ const issueKey = extractIssueKeyFromBody(comment.body);
332
+ if (issueKey) {
333
+ matchedIssue = this._result.issues.find(
334
+ (issue) => generateIssueKey(issue) === issueKey,
335
+ );
336
+ if (shouldLog(verbose, 3)) {
337
+ console.log(
338
+ `[syncReactionsToIssues] comment ${comment.id}: issue-key=${issueKey}, matched=${matchedIssue ? "yes" : "no"}`,
339
+ );
340
+ }
341
+ }
342
+ }
343
+ // 如果 issue-key 匹配失败,使用 path+position 回退匹配
344
+ if (!matchedIssue) {
345
+ matchedIssue = this._result.issues.find(
346
+ (issue) =>
347
+ issue.file === comment.path && this.lineMatchesPosition(issue.line, comment.position),
348
+ );
349
+ if (shouldLog(verbose, 3)) {
350
+ console.log(
351
+ `[syncReactionsToIssues] comment ${comment.id}: fallback matching path=${comment.path}, position=${comment.position}, matched=${matchedIssue ? "yes" : "no"}`,
352
+ );
353
+ }
354
+ }
355
+ if (matchedIssue) {
356
+ commentIdToIssue.set(comment.id, matchedIssue);
357
+ }
358
+ try {
359
+ const reactions = await this.pr.getReviewCommentReactions(comment.id);
360
+ if (reactions.length === 0 || !matchedIssue) continue;
361
+ // 按 content 分组,收集每种 reaction 的用户列表
362
+ const reactionMap = new Map<string, string[]>();
363
+ for (const r of reactions) {
364
+ if (!r.content) continue;
365
+ const users = reactionMap.get(r.content) || [];
366
+ if (r.user?.login) {
367
+ users.push(r.user.login);
368
+ }
369
+ reactionMap.set(r.content, users);
370
+ }
371
+ // 存储到 issue.reactions
372
+ matchedIssue.reactions = Array.from(reactionMap.entries()).map(([content, users]) => ({
373
+ content,
374
+ users,
375
+ }));
376
+ // 检查是否有评审人的 ☹️ (confused) reaction,标记为无效
377
+ const confusedUsers = reactionMap.get("confused") || [];
378
+ const reviewerConfused = confusedUsers.filter((u) => reviewers.has(u));
379
+ if (reviewerConfused.length > 0 && matchedIssue.valid !== "false") {
380
+ matchedIssue.valid = "false";
381
+ console.log(
382
+ `☹️ 问题已标记为无效: ${matchedIssue.file}:${matchedIssue.line} (by 评审人: ${reviewerConfused.join(", ")})`,
383
+ );
384
+ }
385
+ // 检查是否有评审人的 👎 (-1) reaction,标记为未解决
386
+ const thumbsDownUsers = reactionMap.get("-1") || [];
387
+ const reviewerThumbsDown = thumbsDownUsers.filter((u) => reviewers.has(u));
388
+ if (reviewerThumbsDown.length > 0 && (matchedIssue.resolved || matchedIssue.fixed)) {
389
+ matchedIssue.resolved = undefined;
390
+ matchedIssue.resolvedBy = undefined;
391
+ matchedIssue.fixed = undefined;
392
+ matchedIssue.fixedBy = undefined;
393
+ console.log(
394
+ `👎 问题已标记为未解决: ${matchedIssue.file}:${matchedIssue.line} (by 评审人: ${reviewerThumbsDown.join(", ")})`,
395
+ );
396
+ }
397
+ } catch (reactionError) {
398
+ // 单个评论获取 reactions 失败,继续处理其他评论
399
+ if (shouldLog(verbose, 2)) {
400
+ console.warn(
401
+ `[syncReactionsToIssues] 获取评论 ${comment.id} reactions 失败:`,
402
+ reactionError,
403
+ );
404
+ }
405
+ }
406
+ }
407
+ // 获取 PR 上的所有 Issue Comments(包含对 review 评论的回复)
408
+ await syncRepliesToIssues(reviewComments, this._result, (line, pos) =>
409
+ this.lineMatchesPosition(line, pos),
410
+ );
411
+ } catch (error) {
412
+ console.warn("⚠️ 同步评论 reactions 失败:", error);
413
+ }
414
+ }
415
+
416
+ /**
417
+ * 将有变更文件的历史 issue 标记为无效。
418
+ * 简化策略:如果文件在最新 commit 中有变更,则将该文件的所有历史问题标记为无效。
419
+ */
420
+ async invalidateChangedFiles(headSha: string | undefined, verbose?: VerboseLevel): Promise<void> {
421
+ if (!headSha) {
422
+ if (shouldLog(verbose, 1)) {
423
+ console.log(` ⚠️ 无法获取 PR head SHA,跳过变更文件检查`);
424
+ }
425
+ return;
426
+ }
427
+
428
+ if (shouldLog(verbose, 1)) {
429
+ console.log(` 📊 获取最新 commit 变更文件: ${headSha.slice(0, 7)}`);
430
+ }
431
+
432
+ try {
433
+ // 使用 Git Provider API 获取最新一次 commit 的 diff
434
+ const diffText = await this.pr.getCommitDiff(headSha);
435
+ const diffFiles = parseDiffText(diffText);
436
+
437
+ if (diffFiles.length === 0) {
438
+ if (shouldLog(verbose, 1)) {
439
+ console.log(` ⏭️ 最新 commit 无文件变更`);
440
+ }
441
+ return;
442
+ }
443
+
444
+ // 构建变更文件集合
445
+ const changedFileSet = new Set(diffFiles.map((f) => f.filename));
446
+ if (shouldLog(verbose, 2)) {
447
+ console.log(` [invalidateIssues] 变更文件: ${[...changedFileSet].join(", ")}`);
448
+ }
449
+
450
+ // 将变更文件的历史 issue 标记为无效
451
+ let invalidatedCount = 0;
452
+ this._result.issues = this._result.issues.map((issue) => {
453
+ // 如果 issue 已修复、已解决或已无效,不需要处理
454
+ if (issue.fixed || issue.resolved || issue.valid === "false") {
455
+ return issue;
456
+ }
457
+
458
+ // 如果 issue 所在文件有变更,标记为无效
459
+ if (changedFileSet.has(issue.file)) {
460
+ invalidatedCount++;
461
+ if (shouldLog(verbose, 1)) {
462
+ console.log(` 🗑️ Issue ${issue.file}:${issue.line} 所在文件有变更,标记为无效`);
463
+ }
464
+ return { ...issue, valid: "false", originalLine: issue.originalLine ?? issue.line };
465
+ }
466
+
467
+ return issue;
468
+ });
469
+
470
+ if (invalidatedCount > 0 && shouldLog(verbose, 1)) {
471
+ console.log(` 📊 共标记 ${invalidatedCount} 个历史问题为无效(文件有变更)`);
472
+ }
473
+ } catch (error) {
474
+ if (shouldLog(verbose, 1)) {
475
+ console.warn(` ⚠️ 获取最新 commit 变更文件失败: ${error}`);
476
+ }
477
+ }
478
+ }
479
+
480
+ // ─── 写(result → PR)──────────────────────────────────
481
+
482
+ /**
483
+ * 将 ReviewResult 写回 PR(发布/更新主评论 + 行级评论 + 自动批准)。
484
+ */
485
+ async save(options?: ReviewResultSaveOptions): Promise<void> {
486
+ const { verbose, autoApprove, skipSync } = options ?? {};
487
+ // 获取配置
488
+ const reviewConf = this.deps.config.getPluginConfig<ReviewConfig>("review");
489
+
490
+ // 如果配置启用且有 AI 生成的标题,只在第一轮审查时更新 PR 标题
491
+ if (reviewConf.autoUpdatePrTitle && this._result.title && this._result.round === 1) {
492
+ try {
493
+ await this.pr.edit({ title: this._result.title });
494
+ console.log(`📝 已更新 PR 标题: ${this._result.title}`);
495
+ } catch (error) {
496
+ console.warn("⚠️ 更新 PR 标题失败:", error);
497
+ }
498
+ }
499
+
500
+ // 获取已解决的评论,同步 resolve 状态(在更新 review 之前)
501
+ // 如果调用方已经同步过,skipSync=true 跳过冗余的 API 调用
502
+ if (!skipSync) {
503
+ await this.syncResolved();
504
+ await this.syncReactions(verbose);
505
+ }
506
+
507
+ // 并发合并:写入前重新读取 PR 最新评论,检查是否有并发 workflow 先写入了同 round 数据
508
+ // 场景:用户快速提交 commit A 和 B → 两个 review workflow 并发运行 → 都基于旧数据计算出 Round N
509
+ // 先完成的已写入 Round N,后完成的需要合并而非覆盖
510
+ await this.mergeWithLatest(verbose);
511
+
512
+ // 查找已有的 AI 评论(Issue Comment),可能存在多个重复评论
513
+ if (shouldLog(verbose, 2)) {
514
+ console.log(
515
+ `[postOrUpdateReviewComment] owner=${this.pr.owner}, repo=${this.pr.repo}, prNumber=${this.pr.number}`,
516
+ );
517
+ }
518
+ const existingComments = await this.findExistingAiComments(verbose);
519
+ if (shouldLog(verbose, 2)) {
520
+ console.log(
521
+ `[postOrUpdateReviewComment] found ${existingComments.length} existing AI comments`,
522
+ );
523
+ }
524
+
525
+ // 调试:检查 issues 是否有 author
526
+ if (shouldLog(verbose, 3)) {
527
+ for (const issue of this._result.issues.slice(0, 3)) {
528
+ console.log(
529
+ `[postOrUpdateReviewComment] issue: file=${issue.file}, commit=${issue.commit}, author=${issue.author?.login}`,
530
+ );
531
+ }
532
+ }
533
+
534
+ const reviewBody = this.formatComment({
535
+ prNumber: this.pr.number,
536
+ outputFormat: "markdown",
537
+ ci: true,
538
+ });
539
+
540
+ // 获取 PR 信息以获取 head commit SHA
541
+ const commitId = await this.pr.getHeadSha();
542
+
543
+ // 1. 发布或更新主评论(使用 Issue Comment API,支持删除和更新)
544
+ try {
545
+ if (existingComments.length > 0) {
546
+ // 更新第一个 AI 评论
547
+ await this.pr.updateComment(existingComments[0].id, reviewBody);
548
+ console.log(`✅ 已更新 AI Review 评论`);
549
+ // 删除多余的重复 AI 评论
550
+ for (const duplicate of existingComments.slice(1)) {
551
+ try {
552
+ await this.pr.deleteComment(duplicate.id);
553
+ console.log(`🗑️ 已删除重复的 AI Review 评论 (id: ${duplicate.id})`);
554
+ } catch {
555
+ console.warn(`⚠️ 删除重复评论失败 (id: ${duplicate.id})`);
556
+ }
557
+ }
558
+ } else {
559
+ await this.pr.createComment({ body: reviewBody });
560
+ console.log(`✅ 已发布 AI Review 评论`);
561
+ }
562
+ } catch (error) {
563
+ console.warn("⚠️ 发布/更新 AI Review 评论失败:", error);
564
+ }
565
+
566
+ // 2. 发布本轮新发现的行级评论(使用 PR Review API)
567
+ // 保留旧轮次的 review 历史,但清理同轮次的旧 AI review(重复触发场景)
568
+ // 如果启用 autoApprove 且所有问题已解决,使用 APPROVE event 合并发布
569
+ await this.cleanupDuplicateRoundReviews(this._result.round, verbose);
570
+
571
+ let lineIssues: ReviewIssue[] = [];
572
+ let comments: CreatePullReviewComment[] = [];
573
+ if (reviewConf.lineComments) {
574
+ lineIssues = this._result.issues.filter(
575
+ (issue) =>
576
+ issue.round === this._result.round &&
577
+ !issue.fixed &&
578
+ !issue.resolved &&
579
+ issue.valid !== "false",
580
+ );
581
+ comments = lineIssues
582
+ .map((issue) => this.issueToReviewComment(issue))
583
+ .filter((comment): comment is CreatePullReviewComment => comment !== null);
584
+ }
585
+
586
+ // 计算是否需要自动批准
587
+ // 条件:启用 autoApprove 且没有待处理问题(包括从未发现问题的情况)
588
+ const stats = this.stats;
589
+ const shouldAutoApprove = autoApprove && stats.pending === 0;
590
+
591
+ // 获取 PR 作者用户名,用于自动批准时 @ 通知
592
+ const prAuthorLogin = shouldAutoApprove ? (await this.pr.getInfo()).user?.login : undefined;
593
+
594
+ if (reviewConf.lineComments) {
595
+ const lineReviewBody = this.buildLineReviewBody(
596
+ lineIssues,
597
+ this._result.round,
598
+ this._result.issues,
599
+ );
600
+
601
+ // 如果需要自动批准,追加批准信息到 body
602
+ const finalReviewBody = shouldAutoApprove
603
+ ? lineReviewBody + `\n\n---\n\n` + this.buildAutoApproveBody(stats, prAuthorLogin)
604
+ : lineReviewBody;
605
+
606
+ const reviewEvent = shouldAutoApprove ? REVIEW_STATE.APPROVE : REVIEW_STATE.COMMENT;
607
+
608
+ if (comments.length > 0) {
609
+ try {
610
+ await this.pr.createReview({
611
+ event: reviewEvent,
612
+ body: finalReviewBody,
613
+ comments,
614
+ commit_id: commitId,
615
+ });
616
+ if (shouldAutoApprove) {
617
+ console.log(`✅ 已自动批准 PR #${this.pr.number}(所有问题已解决)`);
618
+ } else {
619
+ console.log(`✅ 已发布 ${comments.length} 条行级评论`);
620
+ }
621
+ } catch (batchError) {
622
+ // 批量失败时逐条发布,跳过无法定位的评论
623
+ console.warn("⚠️ 批量发布行级评论失败,尝试逐条发布...", batchError);
624
+ let successCount = 0;
625
+ for (const comment of comments) {
626
+ try {
627
+ // 逐条发布时只用 COMMENT event,避免重复 APPROVE
628
+ await this.pr.createReview({
629
+ event: REVIEW_STATE.COMMENT,
630
+ body: successCount === 0 ? lineReviewBody : undefined,
631
+ comments: [comment],
632
+ commit_id: commitId,
633
+ });
634
+ successCount++;
635
+ } catch (singleError) {
636
+ console.warn(
637
+ `⚠️ 跳过无法定位的评论: ${comment.path}:${comment.new_position}`,
638
+ singleError,
639
+ );
640
+ }
641
+ }
642
+ if (successCount > 0) {
643
+ console.log(`✅ 逐条发布成功 ${successCount}/${comments.length} 条行级评论`);
644
+ // 如果需要自动批准,单独发一个 APPROVE review
645
+ if (shouldAutoApprove) {
646
+ try {
647
+ await this.pr.createReview({
648
+ event: REVIEW_STATE.APPROVE,
649
+ body: this.buildAutoApproveBody(stats, prAuthorLogin),
650
+ commit_id: commitId,
651
+ });
652
+ console.log(`✅ 已自动批准 PR #${this.pr.number}(所有问题已解决)`);
653
+ } catch (error) {
654
+ console.warn("⚠️ 自动批准失败:", error);
655
+ }
656
+ }
657
+ } else {
658
+ console.warn("⚠️ 所有行级评论均无法定位,已跳过");
659
+ }
660
+ }
661
+ } else {
662
+ // 本轮无新问题,仍发布 Round 状态(含上轮回顾)
663
+ try {
664
+ await this.pr.createReview({
665
+ event: reviewEvent,
666
+ body: finalReviewBody,
667
+ comments: [],
668
+ commit_id: commitId,
669
+ });
670
+ if (shouldAutoApprove) {
671
+ console.log(
672
+ `✅ 已自动批准 PR #${this.pr.number}(Round ${this._result.round},所有问题已解决)`,
673
+ );
674
+ } else {
675
+ console.log(`✅ 已发布 Round ${this._result.round} 审查状态(无新问题)`);
676
+ }
677
+ } catch (error) {
678
+ console.warn("⚠️ 发布审查状态失败:", error);
679
+ }
680
+ }
681
+ } else if (shouldAutoApprove) {
682
+ // 未启用 lineComments 但需要自动批准
683
+ try {
684
+ await this.pr.createReview({
685
+ event: REVIEW_STATE.APPROVE,
686
+ body: this.buildAutoApproveBody(stats, prAuthorLogin),
687
+ commit_id: commitId,
688
+ });
689
+ console.log(`✅ 已自动批准 PR #${this.pr.number}(所有问题已解决)`);
690
+ } catch (error) {
691
+ console.warn("⚠️ 自动批准失败:", error);
692
+ }
693
+ }
694
+ }
695
+
696
+ /**
697
+ * 构建自动批准消息 body,包含 @username mention
698
+ */
699
+ private buildAutoApproveBody(stats: ReviewStats, prAuthorLogin?: string): string {
700
+ const mention = prAuthorLogin ? ` @${prAuthorLogin}` : "";
701
+ const reason =
702
+ stats.validTotal > 0
703
+ ? `所有问题都已解决 (${stats.fixed} 已修复, ${stats.resolved} 已解决),`
704
+ : "代码审查通过,未发现问题,";
705
+ return `✅ **自动批准合并**\n\n${reason}自动批准此 PR。${mention}`;
706
+ }
707
+
708
+ /**
709
+ * 删除已有的 AI review(通过 marker 识别)。
710
+ * - 删除行级评论的 PR Review(带 REVIEW_LINE_COMMENTS_MARKER)
711
+ * - 删除主评论的 Issue Comment(带 REVIEW_COMMENT_MARKER)
712
+ */
713
+ async deleteOldReviews(): Promise<void> {
714
+ let deletedCount = 0;
715
+ // 删除行级评论的 PR Review
716
+ try {
717
+ const reviews = await this.pr.getReviews();
718
+ const aiReviews = reviews.filter(
719
+ (r) =>
720
+ r.body?.includes(REVIEW_LINE_COMMENTS_MARKER) || r.body?.includes(REVIEW_COMMENT_MARKER),
721
+ );
722
+ for (const review of aiReviews) {
723
+ if (review.id) {
724
+ try {
725
+ await this.pr.deleteReview(review.id);
726
+ deletedCount++;
727
+ } catch {
728
+ // 已提交的 review 无法删除,忽略
729
+ }
730
+ }
731
+ }
732
+ } catch (error) {
733
+ console.warn("⚠️ 列出 PR reviews 失败:", error);
734
+ }
735
+ // 删除主评论的 Issue Comment
736
+ try {
737
+ const comments = await this.pr.getComments();
738
+ const aiComments = comments.filter((c) => c.body?.includes(REVIEW_COMMENT_MARKER));
739
+ for (const comment of aiComments) {
740
+ if (comment.id) {
741
+ try {
742
+ await this.pr.deleteComment(comment.id);
743
+ deletedCount++;
744
+ } catch (error) {
745
+ console.warn(`⚠️ 删除评论 ${comment.id} 失败:`, error);
746
+ }
747
+ }
748
+ }
749
+ } catch (error) {
750
+ console.warn("⚠️ 列出 issue comments 失败:", error);
751
+ }
752
+ if (deletedCount > 0) {
753
+ console.log(`🗑️ 已删除 ${deletedCount} 个旧的 AI review`);
754
+ }
755
+ }
756
+
757
+ // ─── 格式化 ─────────────────────────────────────────────
758
+
759
+ /**
760
+ * 将 ReviewResult 格式化为评论文本。
761
+ */
762
+ formatComment(
763
+ options: { prNumber?: number; outputFormat?: ReportFormat; ci?: boolean } = {},
764
+ ): string {
765
+ const { prNumber, outputFormat, ci } = options;
766
+ // 智能选择格式:如果未指定,PR 模式用 markdown,终端用 terminal
767
+ const format: ReportFormat = outputFormat || (ci && prNumber ? "markdown" : "terminal");
768
+
769
+ if (format === "markdown") {
770
+ return this.deps.reviewReportService.formatMarkdown(this._result, {
771
+ prNumber,
772
+ includeReanalysisCheckbox: true,
773
+ includeJsonData: true,
774
+ reviewCommentMarker: REVIEW_COMMENT_MARKER,
775
+ });
776
+ }
777
+
778
+ return this.deps.reviewReportService.format(this._result, format);
779
+ }
780
+
781
+ /**
782
+ * 从评论 body 解析出 ReviewResult(用于测试兼容)。
783
+ */
784
+ parseFromComment(commentBody: string): ReviewResult | null {
785
+ const parsed = this.deps.reviewReportService.parseMarkdown(commentBody);
786
+ return parsed?.result ?? null;
787
+ }
788
+
789
+ // ─── 内部方法 ───────────────────────────────────────────
790
+
791
+ /**
792
+ * 写入前并发合并:重新从 PR 读取最新评论,检查是否有并发 workflow 先写入了同 round 数据。
793
+ * 如果有,将当前结果的同 round issues 与已有的合并去重,保证 PR 上只有一份 Round N 数据。
794
+ *
795
+ * 场景:用户快速提交 commit A、B → 两个 workflow 并发 → 都基于旧 Round N-1 算出 Round N
796
+ * → 先完成的写入 Round N → 后完成的需要读取最新、合并后替换。
797
+ *
798
+ * ⚠️ 限制:这是 best-effort 合并,存在 TOCTOU 竞态。如果两个 workflow 几乎同时到达
799
+ * mergeWithLatest(),它们都会读到相同的旧状态并各自写入,导致后写者覆盖先写者的主评论、
800
+ * 以及产生两个 PR Review。cleanupDuplicateRoundReviews 可以缓解重复 review 问题,
801
+ * 但主评论仍以最后写入者为准。在没有服务端锁或乐观并发控制(如 ETag/If-Match)的前提下,
802
+ * 这是当前能做到的最佳方案。
803
+ */
804
+ private async mergeWithLatest(verbose?: VerboseLevel): Promise<void> {
805
+ try {
806
+ // invalidate 缓存,确保读到最新数据
807
+ this.pr.invalidate("comments");
808
+ const latestModel = await ReviewResultModel.loadFromPr(this.pr, this.deps);
809
+ if (!latestModel) return;
810
+
811
+ const myRound = this._result.round;
812
+ const latestRound = latestModel.round;
813
+
814
+ // 场景 1: latestRound < myRound — 没有并发写入,无需合并
815
+ if (latestRound < myRound) return;
816
+
817
+ // 场景 2: latestRound === myRound — 并发 workflow 先写入了同 round
818
+ // 将 latest 的同 round issues 与当前的合并去重
819
+ if (latestRound === myRound) {
820
+ const latestRoundIssues = latestModel.issues.filter((i) => i.round === myRound);
821
+ if (latestRoundIssues.length === 0) return;
822
+
823
+ const myRoundIssues = this._result.issues.filter((i) => i.round === myRound);
824
+ const myHistoryIssues = this._result.issues.filter((i) => i.round !== myRound);
825
+
826
+ // 以 latest 的同 round issues 为基础,追加当前独有的 issues(去重)
827
+ const existingKeys = new Set(latestRoundIssues.map((i) => generateIssueKey(i)));
828
+ const uniqueNewIssues = myRoundIssues.filter((i) => !existingKeys.has(generateIssueKey(i)));
829
+
830
+ const mergedRoundIssues = [...latestRoundIssues, ...uniqueNewIssues];
831
+ this._result = {
832
+ ...this._result,
833
+ issues: [...myHistoryIssues, ...mergedRoundIssues],
834
+ // 合并 latest 的 deletionImpact(并发的那个 workflow 可能已完成删除分析)
835
+ deletionImpact: this._result.deletionImpact ?? latestModel.result.deletionImpact,
836
+ };
837
+
838
+ if (shouldLog(verbose, 1)) {
839
+ console.log(
840
+ `🔀 检测到并发 Round ${myRound},合并 ${latestRoundIssues.length} 个已有 + ${uniqueNewIssues.length} 个新增问题`,
841
+ );
842
+ }
843
+ return;
844
+ }
845
+
846
+ // 场景 3: latestRound > myRound — 并发 workflow 已推进到更高轮次
847
+ // 当前结果作为 latestRound 的补充合并进去
848
+ // 注意:有意使用 latestModel 的元信息(title/description/headSha 等)覆盖当前结果,
849
+ // 因为 latestModel 基于更新的 commit 生成,其元信息更准确。
850
+ if (latestRound > myRound) {
851
+ const myRoundIssues = this._result.issues.filter((i) => i.round === myRound);
852
+ // 将当前 round 的 issues 标记为 latestRound
853
+ const retaggedIssues = myRoundIssues.map((i) => ({ ...i, round: latestRound }));
854
+
855
+ // 与 latest 的同 round issues 去重
856
+ const latestKeys = new Set(latestModel.issues.map((i) => generateIssueKey(i)));
857
+ const uniqueIssues = retaggedIssues.filter((i) => !latestKeys.has(generateIssueKey(i)));
858
+
859
+ this._result = {
860
+ ...latestModel.result,
861
+ issues: [...latestModel.issues, ...uniqueIssues],
862
+ // 保留当前的 deletionImpact 等元信息(如果有)
863
+ deletionImpact: this._result.deletionImpact ?? latestModel.result.deletionImpact,
864
+ };
865
+
866
+ if (shouldLog(verbose, 1)) {
867
+ console.log(
868
+ `🔀 检测到并发更高轮次 Round ${latestRound}(当前 Round ${myRound}),合并 ${uniqueIssues.length} 个新增问题`,
869
+ );
870
+ }
871
+ }
872
+ } catch (error) {
873
+ // 合并失败不阻塞写入,使用当前数据继续
874
+ if (shouldLog(verbose, 2)) {
875
+ console.warn("⚠️ 并发合并检查失败:", error);
876
+ }
877
+ }
878
+ }
879
+
880
+ /**
881
+ * 清理同轮次的旧 AI review(PR Review),避免重复触发时产生重复的 Round 评论。
882
+ * 保留旧轮次的 review 历史(如 Round 1 的评论在 Round 2 时保留)。
883
+ * 只删除包含 REVIEW_LINE_COMMENTS_MARKER 且 Round 号匹配当前轮次的 review。
884
+ */
885
+ private async cleanupDuplicateRoundReviews(
886
+ currentRound: number,
887
+ verbose?: VerboseLevel,
888
+ ): Promise<void> {
889
+ try {
890
+ const reviews = await this.pr.getReviews();
891
+ const roundPattern = ReviewResultModel.buildRoundTitle(currentRound);
892
+ const duplicateReviews = reviews.filter(
893
+ (r) =>
894
+ r.body?.includes(REVIEW_LINE_COMMENTS_MARKER) && r.body?.includes(roundPattern) && r.id,
895
+ );
896
+ for (const review of duplicateReviews) {
897
+ try {
898
+ await this.pr.deleteReview(review.id!);
899
+ if (shouldLog(verbose, 1)) {
900
+ console.log(`🗑️ 已删除同轮次的旧 AI review (id: ${review.id}, Round ${currentRound})`);
901
+ }
902
+ } catch {
903
+ // 已提交的 review 无法删除,忽略
904
+ }
905
+ }
906
+ // invalidate reviews 缓存以便后续操作获取最新状态
907
+ if (duplicateReviews.length > 0) {
908
+ this.pr.invalidate("reviews");
909
+ }
910
+ } catch (error) {
911
+ if (shouldLog(verbose, 2)) {
912
+ console.warn("⚠️ 清理同轮次旧 review 失败:", error);
913
+ }
914
+ }
915
+ }
916
+
917
+ /**
918
+ * 查找已有的所有 AI 评论(Issue Comment)。
919
+ * 返回所有包含 REVIEW_COMMENT_MARKER 的评论,用于更新第一个并清理重复项。
920
+ */
921
+ async findExistingAiComments(verbose?: VerboseLevel): Promise<{ id: number }[]> {
922
+ try {
923
+ const comments = await this.pr.getComments();
924
+ if (shouldLog(verbose, 2)) {
925
+ console.log(
926
+ `[findExistingAiComments] listIssueComments returned ${Array.isArray(comments) ? comments.length : typeof comments} comments`,
927
+ );
928
+ if (Array.isArray(comments)) {
929
+ for (const c of comments.slice(0, 5)) {
930
+ console.log(
931
+ `[findExistingAiComments] comment id=${c.id}, body starts with: ${c.body?.slice(0, 80) ?? "(no body)"}`,
932
+ );
933
+ }
934
+ }
935
+ }
936
+ return comments
937
+ .filter((c) => c.body?.includes(REVIEW_COMMENT_MARKER) && c.id)
938
+ .map((c) => ({ id: c.id! }));
939
+ } catch (error) {
940
+ console.warn("[findExistingAiComments] error:", error);
941
+ return [];
942
+ }
943
+ }
944
+
945
+ /**
946
+ * 检查 issue 的行号是否匹配评论的 position。
947
+ */
948
+ lineMatchesPosition(issueLine: string, position?: number): boolean {
949
+ if (!position) return false;
950
+ const lines = this.deps.reviewSpecService.parseLineRange(issueLine);
951
+ if (lines.length === 0) return false;
952
+ const startLine = lines[0];
953
+ const endLine = lines[lines.length - 1];
954
+ return position >= startLine && position <= endLine;
955
+ }
956
+
957
+ /**
958
+ * 将单个 ReviewIssue 转换为 CreatePullReviewComment。
959
+ */
960
+ issueToReviewComment(issue: ReviewIssue): CreatePullReviewComment | null {
961
+ const lineNums = this.deps.reviewSpecService.parseLineRange(issue.line);
962
+ if (lineNums.length === 0) {
963
+ return null;
964
+ }
965
+ const lineNum = lineNums[0];
966
+ // 构建评论内容,参照 markdown.formatter.ts 的格式
967
+ const severityEmoji =
968
+ issue.severity === "error" ? "🔴" : issue.severity === "warn" ? "🟡" : "⚪";
969
+ const lines: string[] = [];
970
+ lines.push(`${severityEmoji} **${issue.reason}**`);
971
+ lines.push(`- **文件**: \`${issue.file}:${issue.line}\``);
972
+ lines.push(`- **规则**: \`${issue.ruleId}\` (来自 \`${issue.specFile}\`)`);
973
+ if (issue.commit) {
974
+ lines.push(`- **Commit**: ${issue.commit}`);
975
+ }
976
+ lines.push(`- **开发人员**: ${issue.author ? "@" + issue.author.login : "未知"}`);
977
+ lines.push(`<!-- issue-key: ${generateIssueKey(issue)} -->`);
978
+ if (issue.suggestion) {
979
+ const ext = extname(issue.file).slice(1) || "";
980
+ const cleanSuggestion = issue.suggestion.replace(/```/g, "//").trim();
981
+ lines.push(`- **建议**:`);
982
+ lines.push(`\`\`\`${ext}`);
983
+ lines.push(cleanSuggestion);
984
+ lines.push("```");
985
+ }
986
+ return {
987
+ path: issue.file,
988
+ body: lines.join("\n"),
989
+ new_position: lineNum,
990
+ old_position: 0,
991
+ };
992
+ }
993
+
994
+ /**
995
+ * 构建行级评论 Review 的 body(marker + 本轮统计 + 上轮回顾)。
996
+ */
997
+ buildLineReviewBody(issues: ReviewIssue[], round: number, allIssues: ReviewIssue[]): string {
998
+ // 只统计待处理的问题(未修复且未解决)
999
+ const pendingIssues = issues.filter((i) => !i.fixed && !i.resolved && i.valid !== "false");
1000
+ const pendingErrors = pendingIssues.filter((i) => i.severity === "error").length;
1001
+ const pendingWarns = pendingIssues.filter((i) => i.severity === "warn").length;
1002
+ const fileCount = new Set(issues.map((i) => i.file)).size;
1003
+
1004
+ const totalPending = pendingErrors + pendingWarns;
1005
+ const badges: string[] = [];
1006
+ if (totalPending > 0) badges.push(`⚠️ ${totalPending}`);
1007
+ if (pendingErrors > 0) badges.push(`🔴 ${pendingErrors}`);
1008
+ if (pendingWarns > 0) badges.push(`🟡 ${pendingWarns}`);
1009
+
1010
+ const parts: string[] = [REVIEW_LINE_COMMENTS_MARKER];
1011
+ parts.push(ReviewResultModel.buildRoundTitle(round));
1012
+ if (issues.length === 0) {
1013
+ parts.push(`> ✅ 未发现新问题`);
1014
+ } else {
1015
+ parts.push(
1016
+ `> **${issues.length}** 个新问题 · **${fileCount}** 个文件${badges.length > 0 ? " · " + badges.join(" ") : ""}`,
1017
+ );
1018
+ }
1019
+
1020
+ // 上轮回顾
1021
+ if (round > 1) {
1022
+ const prevIssues = allIssues.filter((i) => i.round === round - 1);
1023
+ if (prevIssues.length > 0) {
1024
+ const prevFixed = prevIssues.filter((i) => i.fixed).length;
1025
+ const prevResolved = prevIssues.filter((i) => i.resolved && !i.fixed).length;
1026
+ const prevInvalid = prevIssues.filter(
1027
+ (i) => i.valid === "false" && !i.fixed && !i.resolved,
1028
+ ).length;
1029
+ const prevPending = prevIssues.length - prevFixed - prevResolved - prevInvalid;
1030
+ parts.push("");
1031
+ parts.push(
1032
+ `<details><summary>📊 Round ${round - 1} 回顾 (${prevIssues.length} 个问题)</summary>\n`,
1033
+ );
1034
+ parts.push(`| 状态 | 数量 |`);
1035
+ parts.push(`|------|------|`);
1036
+ if (prevFixed > 0) parts.push(`| 🟢 已修复 | ${prevFixed} |`);
1037
+ if (prevResolved > 0) parts.push(`| ⚪ 已解决 | ${prevResolved} |`);
1038
+ if (prevInvalid > 0) parts.push(`| ❌ 无效 | ${prevInvalid} |`);
1039
+ if (prevPending > 0) parts.push(`| ⚠️ 待处理 | ${prevPending} |`);
1040
+ parts.push(`\n</details>`);
1041
+ }
1042
+ }
1043
+
1044
+ return parts.join("\n");
1045
+ }
1046
+ }