@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,543 @@
1
+ import {
2
+ PullRequestCommit,
3
+ ChangedFile,
4
+ type LLMMode,
5
+ LlmProxyService,
6
+ logStreamEvent,
7
+ createStreamLoggerState,
8
+ shouldLog,
9
+ type VerboseLevel,
10
+ LlmJsonPut,
11
+ parallel,
12
+ } from "@spaceflow/core";
13
+ import {
14
+ ReviewSpecService,
15
+ ReviewSpec,
16
+ ReviewIssue,
17
+ ReviewResult,
18
+ FileSummary,
19
+ FileContentsMap,
20
+ } from "./review-spec";
21
+ import { readdir } from "fs/promises";
22
+ import { dirname, extname } from "path";
23
+ import micromatch from "micromatch";
24
+ import type { FileReviewPrompt, ReviewPrompt, LLMReviewOptions } from "./types/review-llm";
25
+ import { buildLinesWithNumbers, buildCommitsSection, extractCodeBlocks } from "./utils/review-llm";
26
+ import { extractCodeBlockTypes, extractGlobsFromIncludes } from "./review-includes-filter";
27
+ import {
28
+ REVIEW_SCHEMA,
29
+ buildFileReviewPrompt,
30
+ buildPrDescriptionPrompt,
31
+ buildPrTitlePrompt,
32
+ } from "./prompt";
33
+ import type { SystemRules } from "./review.config";
34
+ import { applyStaticRules } from "./system-rules";
35
+
36
+ export type { FileReviewPrompt, ReviewPrompt, LLMReviewOptions } from "./types/review-llm";
37
+
38
+ export class ReviewLlmProcessor {
39
+ readonly llmJsonPut: LlmJsonPut<ReviewResult>;
40
+
41
+ constructor(
42
+ protected readonly llmProxyService: LlmProxyService,
43
+ protected readonly reviewSpecService: ReviewSpecService,
44
+ ) {
45
+ this.llmJsonPut = new LlmJsonPut(REVIEW_SCHEMA, {
46
+ llmRequest: async (prompt) => {
47
+ const response = await this.llmProxyService.chat(
48
+ [
49
+ { role: "system", content: prompt.systemPrompt },
50
+ { role: "user", content: prompt.userPrompt },
51
+ ],
52
+ { adapter: "openai" },
53
+ );
54
+ if (!response.content) {
55
+ throw new Error("LLM 返回了空内容");
56
+ }
57
+ return response.content;
58
+ },
59
+ });
60
+ }
61
+
62
+ /**
63
+ * 根据文件过滤 specs,只返回与该文件匹配的规则
64
+ * - 如果 spec 有 includes 配置,只有当文件名匹配 includes 模式时才包含该 spec
65
+ * - 如果 spec 没有 includes 配置,则按扩展名匹配
66
+ */
67
+ filterSpecsForFile(specs: ReviewSpec[], filename: string): ReviewSpec[] {
68
+ const ext = extname(filename).slice(1).toLowerCase();
69
+ if (!ext) return [];
70
+
71
+ return specs.filter((spec) => {
72
+ // 先检查扩展名是否匹配
73
+ if (!spec.extensions.includes(ext)) {
74
+ return false;
75
+ }
76
+
77
+ // 如果有 includes 配置,检查文件名是否匹配 includes 模式
78
+ // 需先提取纯 glob(去掉 added|/modified| 前缀,过滤 code-* 空串),避免 micromatch 报错
79
+ if (spec.includes.length > 0) {
80
+ const globs = extractGlobsFromIncludes(spec.includes);
81
+ if (globs.length === 0) return true;
82
+ return micromatch.isMatch(filename, globs, { matchBase: true });
83
+ }
84
+
85
+ // 没有 includes 配置,扩展名匹配即可
86
+ return true;
87
+ });
88
+ }
89
+
90
+ async buildReviewPrompt(
91
+ specs: ReviewSpec[],
92
+ changedFiles: ChangedFile[],
93
+ fileContents: FileContentsMap,
94
+ commits: PullRequestCommit[],
95
+ existingResult?: ReviewResult | null,
96
+ whenModifiedCode?: string[],
97
+ verbose?: VerboseLevel,
98
+ systemRules?: SystemRules,
99
+ ): Promise<ReviewPrompt> {
100
+ const round = (existingResult?.round ?? 0) + 1;
101
+ const { staticIssues, skippedFiles } = applyStaticRules(
102
+ changedFiles,
103
+ fileContents,
104
+ systemRules,
105
+ round,
106
+ verbose,
107
+ );
108
+
109
+ const fileDataList = changedFiles
110
+ .filter((f) => f.status !== "deleted" && f.filename)
111
+ .map((file) => {
112
+ const filename = file.filename!;
113
+ if (skippedFiles.has(filename)) return null;
114
+ const contentLines = fileContents.get(filename);
115
+ if (!contentLines) {
116
+ return { filename, file, contentLines: null, commitsSection: "- 无相关 commits" };
117
+ }
118
+ const commitsSection = buildCommitsSection(contentLines, commits);
119
+ return { filename, file, contentLines, commitsSection };
120
+ });
121
+
122
+ const filePrompts: (FileReviewPrompt | null)[] = await Promise.all(
123
+ fileDataList
124
+ .filter((item): item is NonNullable<typeof item> => item !== null)
125
+ .map(async ({ filename, file, contentLines, commitsSection }) => {
126
+ const fileDirectoryInfo = await this.getFileDirectoryInfo(filename);
127
+
128
+ // 根据文件过滤 specs,只注入与当前文件匹配的规则
129
+ const fileSpecs = this.filterSpecsForFile(specs, filename);
130
+
131
+ // 从全局 whenModifiedCode 配置中解析代码结构过滤类型
132
+ const codeBlockTypes = whenModifiedCode ? extractCodeBlockTypes(whenModifiedCode) : [];
133
+
134
+ // 构建带行号的内容:有 code-* 过滤时只输出匹配的代码块范围
135
+ let linesWithNumbers: string;
136
+ if (!contentLines) {
137
+ linesWithNumbers = "(无法获取内容)";
138
+ } else if (codeBlockTypes.length > 0) {
139
+ const visibleRanges = extractCodeBlocks(contentLines, codeBlockTypes);
140
+ // 如果配置了 whenModifiedCode 但没有匹配的代码块,跳过这个文件
141
+ if (visibleRanges.length === 0) {
142
+ if (shouldLog(verbose, 2)) {
143
+ console.log(
144
+ `[buildReviewPrompt] ${filename}: 没有匹配的 ${codeBlockTypes.join(", ")} 代码块,跳过审查`,
145
+ );
146
+ }
147
+ return null;
148
+ }
149
+ linesWithNumbers = buildLinesWithNumbers(contentLines, visibleRanges);
150
+ } else {
151
+ linesWithNumbers = buildLinesWithNumbers(contentLines);
152
+ }
153
+
154
+ // 获取该文件上一次的审查结果
155
+ const existingFileSummary = existingResult?.summary?.find((s) => s.file === filename);
156
+ const existingFileIssues =
157
+ existingResult?.issues?.filter((i) => i.file === filename) ?? [];
158
+
159
+ let previousReviewSection = "";
160
+ if (existingFileSummary || existingFileIssues.length > 0) {
161
+ const parts: string[] = [];
162
+ if (existingFileSummary?.summary) {
163
+ parts.push(`**总结**:\n`);
164
+ parts.push(`${existingFileSummary.summary}\n`);
165
+ }
166
+ if (existingFileIssues.length > 0) {
167
+ parts.push(`**已发现的问题** (${existingFileIssues.length} 个):\n`);
168
+ for (const issue of existingFileIssues) {
169
+ const status = issue.fixed
170
+ ? "✅ 已修复"
171
+ : issue.valid === "false"
172
+ ? "❌ 无效"
173
+ : "⚠️ 待处理";
174
+ parts.push(
175
+ `- [${status}] 行 ${issue.line}: ${issue.reason} (规则: ${issue.ruleId})`,
176
+ );
177
+ }
178
+ parts.push("");
179
+ // parts.push("请注意:不要重复报告上述已发现的问题,除非代码有新的变更导致问题复现。\n");
180
+ }
181
+ previousReviewSection = parts.join("\n");
182
+ }
183
+
184
+ const specsSection = this.reviewSpecService.buildSpecsSection(fileSpecs);
185
+ const { systemPrompt, userPrompt } = buildFileReviewPrompt({
186
+ filename,
187
+ status: file.status,
188
+ linesWithNumbers,
189
+ commitsSection,
190
+ fileDirectoryInfo,
191
+ previousReviewSection,
192
+ specsSection,
193
+ });
194
+
195
+ return { filename, systemPrompt, userPrompt };
196
+ }),
197
+ );
198
+
199
+ // 过滤掉 null 值(跳过的文件)
200
+ const validFilePrompts = filePrompts.filter((fp): fp is FileReviewPrompt => fp !== null);
201
+ return {
202
+ filePrompts: validFilePrompts,
203
+ staticIssues: staticIssues.length > 0 ? staticIssues : undefined,
204
+ };
205
+ }
206
+
207
+ async runLLMReview(
208
+ llmMode: LLMMode,
209
+ reviewPrompt: ReviewPrompt,
210
+ options: LLMReviewOptions = {},
211
+ ): Promise<ReviewResult> {
212
+ console.log(`🤖 调用 ${llmMode} 进行代码审查...`);
213
+
214
+ try {
215
+ const result = await this.callLLM(llmMode, reviewPrompt, options);
216
+ if (!result) {
217
+ throw new Error("AI 未返回有效结果");
218
+ }
219
+ return {
220
+ success: true,
221
+ description: "", // 由 execute 方法填充
222
+ issues: result.issues || [],
223
+ summary: result.summary || [],
224
+ round: 1, // 由 execute 方法根据 existingResult 更新
225
+ };
226
+ } catch (error) {
227
+ if (error instanceof Error) {
228
+ console.error("LLM 调用失败:", error.message);
229
+ if (error.stack) {
230
+ console.error("堆栈信息:\n" + error.stack);
231
+ }
232
+ } else {
233
+ console.error("LLM 调用失败:", error);
234
+ }
235
+ return {
236
+ success: false,
237
+ description: "",
238
+ issues: [],
239
+ summary: [],
240
+ round: 1,
241
+ };
242
+ }
243
+ }
244
+
245
+ async getFileDirectoryInfo(filename: string): Promise<string> {
246
+ const dir = dirname(filename);
247
+ const currentFileName = filename.split("/").pop();
248
+
249
+ if (dir === "." || dir === "") {
250
+ return "(根目录)";
251
+ }
252
+
253
+ try {
254
+ const entries = await readdir(dir, { withFileTypes: true });
255
+
256
+ const sortedEntries = entries.sort((a, b) => {
257
+ if (a.isDirectory() && !b.isDirectory()) return -1;
258
+ if (!a.isDirectory() && b.isDirectory()) return 1;
259
+ return a.name.localeCompare(b.name);
260
+ });
261
+
262
+ const lines: string[] = [`📁 ${dir}/`];
263
+
264
+ for (let i = 0; i < sortedEntries.length; i++) {
265
+ const entry = sortedEntries[i];
266
+ const isLast = i === sortedEntries.length - 1;
267
+ const isCurrent = entry.name === currentFileName;
268
+ const branch = isLast ? "└── " : "├── ";
269
+ const icon = entry.isDirectory() ? "📂" : "📄";
270
+ const marker = isCurrent ? " ← 当前文件" : "";
271
+
272
+ lines.push(`${branch}${icon} ${entry.name}${marker}`);
273
+ }
274
+
275
+ return lines.join("\n");
276
+ } catch {
277
+ return `📁 ${dir}/`;
278
+ }
279
+ }
280
+
281
+ async callLLM(
282
+ llmMode: LLMMode,
283
+ reviewPrompt: ReviewPrompt,
284
+ options: LLMReviewOptions = {},
285
+ ): Promise<{ issues: ReviewIssue[]; summary: FileSummary[] } | null> {
286
+ const { verbose, concurrency = 5, timeout, retries = 0, retryDelay = 1000 } = options;
287
+ const fileCount = reviewPrompt.filePrompts.length;
288
+ console.log(
289
+ `📂 开始并行审查 ${fileCount} 个文件 (并发: ${concurrency}, 重试: ${retries}, 超时: ${timeout ?? "无"}ms)`,
290
+ );
291
+
292
+ const executor = parallel({
293
+ concurrency,
294
+ timeout,
295
+ retries,
296
+ retryDelay,
297
+ stopOnError: retries > 0,
298
+ onTaskStart: (taskId) => {
299
+ console.log(`🚀 开始审查: ${taskId}`);
300
+ },
301
+ onTaskComplete: (taskId, success) => {
302
+ console.log(`${success ? "✅" : "❌"} 完成审查: ${taskId}`);
303
+ },
304
+ onRetry: (taskId, attempt, error) => {
305
+ console.log(`🔄 重试 ${taskId} (第 ${attempt} 次): ${error.message}`);
306
+ },
307
+ });
308
+
309
+ const results = await executor.map(
310
+ reviewPrompt.filePrompts,
311
+ (filePrompt) => this.reviewSingleFile(llmMode, filePrompt, verbose),
312
+ (filePrompt) => filePrompt.filename,
313
+ );
314
+
315
+ const allIssues: ReviewIssue[] = [];
316
+ const fileSummaries: FileSummary[] = [];
317
+
318
+ for (const result of results) {
319
+ if (result.success && result.result) {
320
+ allIssues.push(...result.result.issues);
321
+ fileSummaries.push(result.result.summary);
322
+ } else {
323
+ fileSummaries.push({
324
+ file: result.id,
325
+ resolved: 0,
326
+ unresolved: 0,
327
+ summary: `❌ 审查失败: ${result.error?.message ?? "未知错误"}`,
328
+ });
329
+ }
330
+ }
331
+
332
+ const successCount = results.filter((r) => r.success).length;
333
+ console.log(`🔍 审查完成: ${successCount}/${fileCount} 个文件成功`);
334
+
335
+ return {
336
+ issues: this.normalizeIssues(allIssues),
337
+ summary: fileSummaries,
338
+ };
339
+ }
340
+
341
+ async reviewSingleFile(
342
+ llmMode: LLMMode,
343
+ filePrompt: FileReviewPrompt,
344
+ verbose?: VerboseLevel,
345
+ ): Promise<{ issues: ReviewIssue[]; summary: FileSummary }> {
346
+ if (shouldLog(verbose, 3)) {
347
+ console.log(
348
+ `\nsystemPrompt:\n----------------\n${filePrompt.systemPrompt}\n----------------`,
349
+ );
350
+ console.log(`\nuserPrompt:\n----------------\n${filePrompt.userPrompt}\n----------------`);
351
+ }
352
+
353
+ const stream = this.llmProxyService.chatStream(
354
+ [
355
+ { role: "system", content: filePrompt.systemPrompt },
356
+ { role: "user", content: filePrompt.userPrompt },
357
+ ],
358
+ {
359
+ adapter: llmMode,
360
+ jsonSchema: this.llmJsonPut,
361
+ verbose,
362
+ allowedTools: [
363
+ "Read",
364
+ "Glob",
365
+ "Grep",
366
+ "WebSearch",
367
+ "TodoWrite",
368
+ "TodoRead",
369
+ "Task",
370
+ "Skill",
371
+ ],
372
+ },
373
+ );
374
+
375
+ const streamLoggerState = createStreamLoggerState();
376
+ let fileResult: { issues?: ReviewIssue[]; summary?: string } | undefined;
377
+
378
+ for await (const event of stream) {
379
+ if (shouldLog(verbose, 2)) {
380
+ logStreamEvent(event, streamLoggerState);
381
+ }
382
+
383
+ if (event.type === "result") {
384
+ fileResult = event.response.structuredOutput as
385
+ | { issues?: ReviewIssue[]; summary?: string }
386
+ | undefined;
387
+ } else if (event.type === "error") {
388
+ throw new Error(event.message);
389
+ }
390
+ }
391
+
392
+ // 在获取到问题时立即记录发现时间
393
+ const now = new Date().toISOString();
394
+ const issues = (fileResult?.issues ?? []).map((issue) => ({
395
+ ...issue,
396
+ date: issue.date ?? now,
397
+ }));
398
+
399
+ return {
400
+ issues,
401
+ summary: {
402
+ file: filePrompt.filename,
403
+ resolved: 0,
404
+ unresolved: 0,
405
+ summary: fileResult?.summary ?? "",
406
+ },
407
+ };
408
+ }
409
+
410
+ /**
411
+ * 规范化 issues,拆分包含逗号的行号为多个独立 issue,并添加发现时间
412
+ * 例如 "114, 122" 会被拆分成两个 issue,分别是 "114" 和 "122"
413
+ */
414
+ normalizeIssues(issues: ReviewIssue[]): ReviewIssue[] {
415
+ const now = new Date().toISOString();
416
+ return issues.flatMap((issue) => {
417
+ // 确保 line 是字符串(LLM 可能返回数字)
418
+ const lineStr = String(issue.line ?? "");
419
+ const baseIssue = { ...issue, line: lineStr, date: issue.date ?? now };
420
+
421
+ if (!lineStr.includes(",")) {
422
+ return baseIssue;
423
+ }
424
+
425
+ const lines = lineStr.split(",");
426
+
427
+ return lines.map((linePart, index) => ({
428
+ ...baseIssue,
429
+ line: linePart.trim(),
430
+ suggestion: index === 0 ? issue.suggestion : `参考 ${issue.file}:${lines[0]}`,
431
+ }));
432
+ });
433
+ }
434
+
435
+ /**
436
+ * 使用 AI 根据 commits、变更文件和代码内容总结 PR 实现的功能
437
+ * @returns 包含 title 和 description 的对象
438
+ */
439
+ async generatePrDescription(
440
+ commits: PullRequestCommit[],
441
+ changedFiles: ChangedFile[],
442
+ llmMode: LLMMode,
443
+ fileContents?: FileContentsMap,
444
+ verbose?: VerboseLevel,
445
+ ): Promise<{ title: string; description: string }> {
446
+ const { userPrompt } = buildPrDescriptionPrompt({
447
+ commits,
448
+ changedFiles,
449
+ fileContents,
450
+ });
451
+
452
+ try {
453
+ const stream = this.llmProxyService.chatStream([{ role: "user", content: userPrompt }], {
454
+ adapter: llmMode,
455
+ });
456
+ let content = "";
457
+ for await (const event of stream) {
458
+ if (event.type === "text") {
459
+ content += event.content;
460
+ } else if (event.type === "error") {
461
+ throw new Error(event.message);
462
+ }
463
+ }
464
+ // 解析标题和描述:第一行是标题,其余是描述
465
+ const lines = content.trim().split("\n");
466
+ const title = lines[0]?.replace(/^#+\s*/, "").trim() || "PR 更新";
467
+ const description = lines.slice(1).join("\n").trim();
468
+ return { title, description };
469
+ } catch (error) {
470
+ if (shouldLog(verbose, 1)) {
471
+ console.warn("⚠️ AI 总结 PR 功能失败,使用默认描述:", error);
472
+ }
473
+ return this.buildBasicDescription(commits, changedFiles);
474
+ }
475
+ }
476
+
477
+ /**
478
+ * 使用 LLM 生成 PR 标题
479
+ */
480
+ async generatePrTitle(
481
+ commits: PullRequestCommit[],
482
+ changedFiles: ChangedFile[],
483
+ ): Promise<string> {
484
+ const { userPrompt } = buildPrTitlePrompt({ commits, changedFiles });
485
+
486
+ try {
487
+ const stream = this.llmProxyService.chatStream([{ role: "user", content: userPrompt }], {
488
+ adapter: "openai",
489
+ });
490
+ let title = "";
491
+ for await (const event of stream) {
492
+ if (event.type === "text") {
493
+ title += event.content;
494
+ } else if (event.type === "error") {
495
+ throw new Error(event.message);
496
+ }
497
+ }
498
+ return title.trim().slice(0, 50) || this.getFallbackTitle(commits);
499
+ } catch {
500
+ return this.getFallbackTitle(commits);
501
+ }
502
+ }
503
+
504
+ /**
505
+ * 获取降级标题(从第一个 commit 消息)
506
+ */
507
+ getFallbackTitle(commits: PullRequestCommit[]): string {
508
+ const firstCommitMsg = commits[0]?.commit?.message?.split("\n")[0] || "PR 更新";
509
+ return firstCommitMsg.slice(0, 50);
510
+ }
511
+
512
+ /**
513
+ * 构建基础描述(不做完整的 AI 功能总结,但仍用 LLM 辅助生成标题)
514
+ */
515
+ async buildBasicDescription(
516
+ commits: PullRequestCommit[],
517
+ changedFiles: ChangedFile[],
518
+ ): Promise<{ title: string; description: string }> {
519
+ const parts: string[] = [];
520
+ // 使用 LLM 生成标题
521
+ const title = await this.generatePrTitle(commits, changedFiles);
522
+ if (commits.length > 0) {
523
+ const messages = commits
524
+ .slice(0, 5)
525
+ .map((c) => `- ${c.commit?.message?.split("\n")[0]}`)
526
+ .filter(Boolean);
527
+ if (messages.length > 0) {
528
+ parts.push(`**提交记录**: ${messages.join("; ")}`);
529
+ }
530
+ }
531
+ if (changedFiles.length > 0) {
532
+ const added = changedFiles.filter((f) => f.status === "added").length;
533
+ const modified = changedFiles.filter((f) => f.status === "modified").length;
534
+ const deleted = changedFiles.filter((f) => f.status === "deleted").length;
535
+ const stats: string[] = [];
536
+ if (added > 0) stats.push(`新增 ${added}`);
537
+ if (modified > 0) stats.push(`修改 ${modified}`);
538
+ if (deleted > 0) stats.push(`删除 ${deleted}`);
539
+ parts.push(`**文件变更**: ${changedFiles.length} 个文件 (${stats.join(", ")})`);
540
+ }
541
+ return { title, description: parts.join("\n") };
542
+ }
543
+ }