@spaceflow/review 0.75.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 +24 -0
- package/dist/index.js +2657 -1873
- 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 +537 -2769
- package/src/types/review-llm.ts +19 -0
- package/src/utils/review-llm.ts +32 -0
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PullRequestCommit,
|
|
3
|
+
ChangedFile,
|
|
4
|
+
type LLMMode,
|
|
5
|
+
LlmProxyService,
|
|
6
|
+
logStreamEvent,
|
|
7
|
+
createStreamLoggerState,
|
|
8
|
+
shouldLog,
|
|
9
|
+
type VerboseLevel,
|
|
10
|
+
type LlmJsonPutSchema,
|
|
11
|
+
LlmJsonPut,
|
|
12
|
+
parallel,
|
|
13
|
+
} from "@spaceflow/core";
|
|
14
|
+
import {
|
|
15
|
+
ReviewSpecService,
|
|
16
|
+
ReviewSpec,
|
|
17
|
+
ReviewIssue,
|
|
18
|
+
ReviewResult,
|
|
19
|
+
FileSummary,
|
|
20
|
+
FileContentsMap,
|
|
21
|
+
} from "./review-spec";
|
|
22
|
+
import { readdir } from "fs/promises";
|
|
23
|
+
import { dirname, extname } from "path";
|
|
24
|
+
import micromatch from "micromatch";
|
|
25
|
+
import type { FileReviewPrompt, ReviewPrompt, LLMReviewOptions } from "./types/review-llm";
|
|
26
|
+
import { buildLinesWithNumbers, buildCommitsSection } from "./utils/review-llm";
|
|
27
|
+
|
|
28
|
+
export type { FileReviewPrompt, ReviewPrompt, LLMReviewOptions } from "./types/review-llm";
|
|
29
|
+
|
|
30
|
+
const REVIEW_SCHEMA: LlmJsonPutSchema = {
|
|
31
|
+
type: "object",
|
|
32
|
+
properties: {
|
|
33
|
+
issues: {
|
|
34
|
+
type: "array",
|
|
35
|
+
items: {
|
|
36
|
+
type: "object",
|
|
37
|
+
properties: {
|
|
38
|
+
file: { type: "string", description: "发生问题的文件路径" },
|
|
39
|
+
line: {
|
|
40
|
+
type: "string",
|
|
41
|
+
description:
|
|
42
|
+
"问题所在的行号,只支持单行或多行 (如 123 或 123-125),不允许使用 `,` 分隔多个行号",
|
|
43
|
+
},
|
|
44
|
+
ruleId: { type: "string", description: "违反的规则 ID(如 JsTs.FileName.UpperCamel)" },
|
|
45
|
+
specFile: {
|
|
46
|
+
type: "string",
|
|
47
|
+
description: "规则来源的规范文件名(如 js&ts.file-name.md)",
|
|
48
|
+
},
|
|
49
|
+
reason: { type: "string", description: "问题的简要概括" },
|
|
50
|
+
suggestion: {
|
|
51
|
+
type: "string",
|
|
52
|
+
description:
|
|
53
|
+
"修改后的完整代码片段。要求以代码为主体,并在代码中使用详细的中文注释解释逻辑改进点。不要包含 Markdown 反引号。",
|
|
54
|
+
},
|
|
55
|
+
commit: { type: "string", description: "相关的 7 位 commit SHA" },
|
|
56
|
+
severity: {
|
|
57
|
+
type: "string",
|
|
58
|
+
description: "问题严重程度,根据规则文档中的 severity 标记确定",
|
|
59
|
+
enum: ["error", "warn"],
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
required: ["file", "line", "ruleId", "specFile", "reason"],
|
|
63
|
+
additionalProperties: false,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
summary: { type: "string", description: "本次代码审查的整体总结" },
|
|
67
|
+
},
|
|
68
|
+
required: ["issues", "summary"],
|
|
69
|
+
additionalProperties: false,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export class ReviewLlmProcessor {
|
|
73
|
+
readonly llmJsonPut: LlmJsonPut<ReviewResult>;
|
|
74
|
+
|
|
75
|
+
constructor(
|
|
76
|
+
protected readonly llmProxyService: LlmProxyService,
|
|
77
|
+
protected readonly reviewSpecService: ReviewSpecService,
|
|
78
|
+
) {
|
|
79
|
+
this.llmJsonPut = new LlmJsonPut(REVIEW_SCHEMA, {
|
|
80
|
+
llmRequest: async (prompt) => {
|
|
81
|
+
const response = await this.llmProxyService.chat(
|
|
82
|
+
[
|
|
83
|
+
{ role: "system", content: prompt.systemPrompt },
|
|
84
|
+
{ role: "user", content: prompt.userPrompt },
|
|
85
|
+
],
|
|
86
|
+
{ adapter: "openai" },
|
|
87
|
+
);
|
|
88
|
+
if (!response.content) {
|
|
89
|
+
throw new Error("LLM 返回了空内容");
|
|
90
|
+
}
|
|
91
|
+
return response.content;
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 根据文件过滤 specs,只返回与该文件匹配的规则
|
|
98
|
+
* - 如果 spec 有 includes 配置,只有当文件名匹配 includes 模式时才包含该 spec
|
|
99
|
+
* - 如果 spec 没有 includes 配置,则按扩展名匹配
|
|
100
|
+
*/
|
|
101
|
+
filterSpecsForFile(specs: ReviewSpec[], filename: string): ReviewSpec[] {
|
|
102
|
+
const ext = extname(filename).slice(1).toLowerCase();
|
|
103
|
+
if (!ext) return [];
|
|
104
|
+
|
|
105
|
+
return specs.filter((spec) => {
|
|
106
|
+
// 先检查扩展名是否匹配
|
|
107
|
+
if (!spec.extensions.includes(ext)) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 如果有 includes 配置,检查文件名是否匹配 includes 模式
|
|
112
|
+
if (spec.includes.length > 0) {
|
|
113
|
+
return micromatch.isMatch(filename, spec.includes, { matchBase: true });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 没有 includes 配置,扩展名匹配即可
|
|
117
|
+
return true;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 构建 systemPrompt
|
|
123
|
+
*/
|
|
124
|
+
buildSystemPrompt(specsSection: string): string {
|
|
125
|
+
return `你是一个专业的代码审查专家,负责根据团队的编码规范对代码进行严格审查。
|
|
126
|
+
|
|
127
|
+
## 审查规范
|
|
128
|
+
|
|
129
|
+
${specsSection}
|
|
130
|
+
|
|
131
|
+
## 审查要求
|
|
132
|
+
|
|
133
|
+
1. **严格遵循规范**:只按照上述审查规范进行审查,不要添加规范之外的要求
|
|
134
|
+
2. **精准定位问题**:每个问题必须指明具体的行号,行号从文件内容中的 "行号|" 格式获取
|
|
135
|
+
3. **避免重复报告**:如果提示词中包含"上一次审查结果",请不要重复报告已存在的问题
|
|
136
|
+
4. **提供可行建议**:对于每个问题,提供具体的修改建议代码
|
|
137
|
+
|
|
138
|
+
## 注意事项
|
|
139
|
+
|
|
140
|
+
- 变更文件内容已在上下文中提供,无需调用读取工具
|
|
141
|
+
- 你可以读取项目中的其他文件以了解上下文
|
|
142
|
+
- 不要调用编辑工具修改文件,你的职责是审查而非修改
|
|
143
|
+
- 文件内容格式为 "CommitHash 行号| 代码",输出的 line 字段应对应原始行号
|
|
144
|
+
|
|
145
|
+
## 输出要求
|
|
146
|
+
|
|
147
|
+
- 发现问题时:在 issues 数组中列出所有问题,每个问题包含 file、line、ruleId、specFile、reason、suggestion、severity
|
|
148
|
+
- 无论是否发现问题:都必须在 summary 中提供该文件的审查总结,简要说明审查结果`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async buildReviewPrompt(
|
|
152
|
+
specs: ReviewSpec[],
|
|
153
|
+
changedFiles: ChangedFile[],
|
|
154
|
+
fileContents: FileContentsMap,
|
|
155
|
+
commits: PullRequestCommit[],
|
|
156
|
+
existingResult?: ReviewResult | null,
|
|
157
|
+
): Promise<ReviewPrompt> {
|
|
158
|
+
const fileDataList = changedFiles
|
|
159
|
+
.filter((f) => f.status !== "deleted" && f.filename)
|
|
160
|
+
.map((file) => {
|
|
161
|
+
const filename = file.filename!;
|
|
162
|
+
const contentLines = fileContents.get(filename);
|
|
163
|
+
if (!contentLines) {
|
|
164
|
+
return {
|
|
165
|
+
filename,
|
|
166
|
+
file,
|
|
167
|
+
linesWithNumbers: "(无法获取内容)",
|
|
168
|
+
commitsSection: "- 无相关 commits",
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const linesWithNumbers = buildLinesWithNumbers(contentLines);
|
|
172
|
+
const commitsSection = buildCommitsSection(contentLines, commits);
|
|
173
|
+
return { filename, file, linesWithNumbers, commitsSection };
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const filePrompts: FileReviewPrompt[] = await Promise.all(
|
|
177
|
+
fileDataList.map(async ({ filename, file, linesWithNumbers, commitsSection }) => {
|
|
178
|
+
const fileDirectoryInfo = await this.getFileDirectoryInfo(filename);
|
|
179
|
+
|
|
180
|
+
// 获取该文件上一次的审查结果
|
|
181
|
+
const existingFileSummary = existingResult?.summary?.find((s) => s.file === filename);
|
|
182
|
+
const existingFileIssues = existingResult?.issues?.filter((i) => i.file === filename) ?? [];
|
|
183
|
+
|
|
184
|
+
let previousReviewSection = "";
|
|
185
|
+
if (existingFileSummary || existingFileIssues.length > 0) {
|
|
186
|
+
const parts: string[] = [];
|
|
187
|
+
if (existingFileSummary?.summary) {
|
|
188
|
+
parts.push(`**总结**:\n`);
|
|
189
|
+
parts.push(`${existingFileSummary.summary}\n`);
|
|
190
|
+
}
|
|
191
|
+
if (existingFileIssues.length > 0) {
|
|
192
|
+
parts.push(`**已发现的问题** (${existingFileIssues.length} 个):\n`);
|
|
193
|
+
for (const issue of existingFileIssues) {
|
|
194
|
+
const status = issue.fixed
|
|
195
|
+
? "✅ 已修复"
|
|
196
|
+
: issue.valid === "false"
|
|
197
|
+
? "❌ 无效"
|
|
198
|
+
: "⚠️ 待处理";
|
|
199
|
+
parts.push(`- [${status}] 行 ${issue.line}: ${issue.reason} (规则: ${issue.ruleId})`);
|
|
200
|
+
}
|
|
201
|
+
parts.push("");
|
|
202
|
+
// parts.push("请注意:不要重复报告上述已发现的问题,除非代码有新的变更导致问题复现。\n");
|
|
203
|
+
}
|
|
204
|
+
previousReviewSection = parts.join("\n");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const userPrompt = `## ${filename} (${file.status})
|
|
208
|
+
|
|
209
|
+
### 文件内容
|
|
210
|
+
|
|
211
|
+
\`\`\`
|
|
212
|
+
${linesWithNumbers}
|
|
213
|
+
\`\`\`
|
|
214
|
+
|
|
215
|
+
### 该文件的相关 Commits
|
|
216
|
+
|
|
217
|
+
${commitsSection}
|
|
218
|
+
|
|
219
|
+
### 该文件所在的目录树
|
|
220
|
+
|
|
221
|
+
${fileDirectoryInfo}
|
|
222
|
+
|
|
223
|
+
### 上一次审查结果
|
|
224
|
+
|
|
225
|
+
${previousReviewSection}`;
|
|
226
|
+
|
|
227
|
+
// 根据文件过滤 specs,只注入与当前文件匹配的规则
|
|
228
|
+
const fileSpecs = this.filterSpecsForFile(specs, filename);
|
|
229
|
+
const specsSection = this.reviewSpecService.buildSpecsSection(fileSpecs);
|
|
230
|
+
const systemPrompt = this.buildSystemPrompt(specsSection);
|
|
231
|
+
|
|
232
|
+
return { filename, systemPrompt, userPrompt };
|
|
233
|
+
}),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
return { filePrompts };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async runLLMReview(
|
|
240
|
+
llmMode: LLMMode,
|
|
241
|
+
reviewPrompt: ReviewPrompt,
|
|
242
|
+
options: LLMReviewOptions = {},
|
|
243
|
+
): Promise<ReviewResult> {
|
|
244
|
+
console.log(`🤖 调用 ${llmMode} 进行代码审查...`);
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
const result = await this.callLLM(llmMode, reviewPrompt, options);
|
|
248
|
+
if (!result) {
|
|
249
|
+
throw new Error("AI 未返回有效结果");
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
success: true,
|
|
253
|
+
description: "", // 由 execute 方法填充
|
|
254
|
+
issues: result.issues || [],
|
|
255
|
+
summary: result.summary || [],
|
|
256
|
+
round: 1, // 由 execute 方法根据 existingResult 更新
|
|
257
|
+
};
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (error instanceof Error) {
|
|
260
|
+
console.error("LLM 调用失败:", error.message);
|
|
261
|
+
if (error.stack) {
|
|
262
|
+
console.error("堆栈信息:\n" + error.stack);
|
|
263
|
+
}
|
|
264
|
+
} else {
|
|
265
|
+
console.error("LLM 调用失败:", error);
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
success: false,
|
|
269
|
+
description: "",
|
|
270
|
+
issues: [],
|
|
271
|
+
summary: [],
|
|
272
|
+
round: 1,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async getFileDirectoryInfo(filename: string): Promise<string> {
|
|
278
|
+
const dir = dirname(filename);
|
|
279
|
+
const currentFileName = filename.split("/").pop();
|
|
280
|
+
|
|
281
|
+
if (dir === "." || dir === "") {
|
|
282
|
+
return "(根目录)";
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
287
|
+
|
|
288
|
+
const sortedEntries = entries.sort((a, b) => {
|
|
289
|
+
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
290
|
+
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
291
|
+
return a.name.localeCompare(b.name);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const lines: string[] = [`📁 ${dir}/`];
|
|
295
|
+
|
|
296
|
+
for (let i = 0; i < sortedEntries.length; i++) {
|
|
297
|
+
const entry = sortedEntries[i];
|
|
298
|
+
const isLast = i === sortedEntries.length - 1;
|
|
299
|
+
const isCurrent = entry.name === currentFileName;
|
|
300
|
+
const branch = isLast ? "└── " : "├── ";
|
|
301
|
+
const icon = entry.isDirectory() ? "📂" : "📄";
|
|
302
|
+
const marker = isCurrent ? " ← 当前文件" : "";
|
|
303
|
+
|
|
304
|
+
lines.push(`${branch}${icon} ${entry.name}${marker}`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return lines.join("\n");
|
|
308
|
+
} catch {
|
|
309
|
+
return `📁 ${dir}/`;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async callLLM(
|
|
314
|
+
llmMode: LLMMode,
|
|
315
|
+
reviewPrompt: ReviewPrompt,
|
|
316
|
+
options: LLMReviewOptions = {},
|
|
317
|
+
): Promise<{ issues: ReviewIssue[]; summary: FileSummary[] } | null> {
|
|
318
|
+
const { verbose, concurrency = 5, timeout, retries = 0, retryDelay = 1000 } = options;
|
|
319
|
+
const fileCount = reviewPrompt.filePrompts.length;
|
|
320
|
+
console.log(
|
|
321
|
+
`📂 开始并行审查 ${fileCount} 个文件 (并发: ${concurrency}, 重试: ${retries}, 超时: ${timeout ?? "无"}ms)`,
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
const executor = parallel({
|
|
325
|
+
concurrency,
|
|
326
|
+
timeout,
|
|
327
|
+
retries,
|
|
328
|
+
retryDelay,
|
|
329
|
+
stopOnError: retries > 0,
|
|
330
|
+
onTaskStart: (taskId) => {
|
|
331
|
+
console.log(`🚀 开始审查: ${taskId}`);
|
|
332
|
+
},
|
|
333
|
+
onTaskComplete: (taskId, success) => {
|
|
334
|
+
console.log(`${success ? "✅" : "❌"} 完成审查: ${taskId}`);
|
|
335
|
+
},
|
|
336
|
+
onRetry: (taskId, attempt, error) => {
|
|
337
|
+
console.log(`🔄 重试 ${taskId} (第 ${attempt} 次): ${error.message}`);
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const results = await executor.map(
|
|
342
|
+
reviewPrompt.filePrompts,
|
|
343
|
+
(filePrompt) => this.reviewSingleFile(llmMode, filePrompt, verbose),
|
|
344
|
+
(filePrompt) => filePrompt.filename,
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
const allIssues: ReviewIssue[] = [];
|
|
348
|
+
const fileSummaries: FileSummary[] = [];
|
|
349
|
+
|
|
350
|
+
for (const result of results) {
|
|
351
|
+
if (result.success && result.result) {
|
|
352
|
+
allIssues.push(...result.result.issues);
|
|
353
|
+
fileSummaries.push(result.result.summary);
|
|
354
|
+
} else {
|
|
355
|
+
fileSummaries.push({
|
|
356
|
+
file: result.id,
|
|
357
|
+
resolved: 0,
|
|
358
|
+
unresolved: 0,
|
|
359
|
+
summary: `❌ 审查失败: ${result.error?.message ?? "未知错误"}`,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const successCount = results.filter((r) => r.success).length;
|
|
365
|
+
console.log(`🔍 审查完成: ${successCount}/${fileCount} 个文件成功`);
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
issues: this.normalizeIssues(allIssues),
|
|
369
|
+
summary: fileSummaries,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async reviewSingleFile(
|
|
374
|
+
llmMode: LLMMode,
|
|
375
|
+
filePrompt: FileReviewPrompt,
|
|
376
|
+
verbose?: VerboseLevel,
|
|
377
|
+
): Promise<{ issues: ReviewIssue[]; summary: FileSummary }> {
|
|
378
|
+
if (shouldLog(verbose, 3)) {
|
|
379
|
+
console.log(
|
|
380
|
+
`\nsystemPrompt:\n----------------\n${filePrompt.systemPrompt}\n----------------`,
|
|
381
|
+
);
|
|
382
|
+
console.log(`\nuserPrompt:\n----------------\n${filePrompt.userPrompt}\n----------------`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const stream = this.llmProxyService.chatStream(
|
|
386
|
+
[
|
|
387
|
+
{ role: "system", content: filePrompt.systemPrompt },
|
|
388
|
+
{ role: "user", content: filePrompt.userPrompt },
|
|
389
|
+
],
|
|
390
|
+
{
|
|
391
|
+
adapter: llmMode,
|
|
392
|
+
jsonSchema: this.llmJsonPut,
|
|
393
|
+
verbose,
|
|
394
|
+
allowedTools: [
|
|
395
|
+
"Read",
|
|
396
|
+
"Glob",
|
|
397
|
+
"Grep",
|
|
398
|
+
"WebSearch",
|
|
399
|
+
"TodoWrite",
|
|
400
|
+
"TodoRead",
|
|
401
|
+
"Task",
|
|
402
|
+
"Skill",
|
|
403
|
+
],
|
|
404
|
+
},
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
const streamLoggerState = createStreamLoggerState();
|
|
408
|
+
let fileResult: { issues?: ReviewIssue[]; summary?: string } | undefined;
|
|
409
|
+
|
|
410
|
+
for await (const event of stream) {
|
|
411
|
+
if (shouldLog(verbose, 2)) {
|
|
412
|
+
logStreamEvent(event, streamLoggerState);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (event.type === "result") {
|
|
416
|
+
fileResult = event.response.structuredOutput as
|
|
417
|
+
| { issues?: ReviewIssue[]; summary?: string }
|
|
418
|
+
| undefined;
|
|
419
|
+
} else if (event.type === "error") {
|
|
420
|
+
throw new Error(event.message);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// 在获取到问题时立即记录发现时间
|
|
425
|
+
const now = new Date().toISOString();
|
|
426
|
+
const issues = (fileResult?.issues ?? []).map((issue) => ({
|
|
427
|
+
...issue,
|
|
428
|
+
date: issue.date ?? now,
|
|
429
|
+
}));
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
issues,
|
|
433
|
+
summary: {
|
|
434
|
+
file: filePrompt.filename,
|
|
435
|
+
resolved: 0,
|
|
436
|
+
unresolved: 0,
|
|
437
|
+
summary: fileResult?.summary ?? "",
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* 规范化 issues,拆分包含逗号的行号为多个独立 issue,并添加发现时间
|
|
444
|
+
* 例如 "114, 122" 会被拆分成两个 issue,分别是 "114" 和 "122"
|
|
445
|
+
*/
|
|
446
|
+
normalizeIssues(issues: ReviewIssue[]): ReviewIssue[] {
|
|
447
|
+
const now = new Date().toISOString();
|
|
448
|
+
return issues.flatMap((issue) => {
|
|
449
|
+
// 确保 line 是字符串(LLM 可能返回数字)
|
|
450
|
+
const lineStr = String(issue.line ?? "");
|
|
451
|
+
const baseIssue = { ...issue, line: lineStr, date: issue.date ?? now };
|
|
452
|
+
|
|
453
|
+
if (!lineStr.includes(",")) {
|
|
454
|
+
return baseIssue;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const lines = lineStr.split(",");
|
|
458
|
+
|
|
459
|
+
return lines.map((linePart, index) => ({
|
|
460
|
+
...baseIssue,
|
|
461
|
+
line: linePart.trim(),
|
|
462
|
+
suggestion: index === 0 ? issue.suggestion : `参考 ${issue.file}:${lines[0]}`,
|
|
463
|
+
}));
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* 使用 AI 根据 commits、变更文件和代码内容总结 PR 实现的功能
|
|
469
|
+
* @returns 包含 title 和 description 的对象
|
|
470
|
+
*/
|
|
471
|
+
async generatePrDescription(
|
|
472
|
+
commits: PullRequestCommit[],
|
|
473
|
+
changedFiles: ChangedFile[],
|
|
474
|
+
llmMode: LLMMode,
|
|
475
|
+
fileContents?: FileContentsMap,
|
|
476
|
+
verbose?: VerboseLevel,
|
|
477
|
+
): Promise<{ title: string; description: string }> {
|
|
478
|
+
const commitMessages = commits
|
|
479
|
+
.map((c) => `- ${c.sha?.slice(0, 7)}: ${c.commit?.message?.split("\n")[0]}`)
|
|
480
|
+
.join("\n");
|
|
481
|
+
const fileChanges = changedFiles
|
|
482
|
+
.slice(0, 30)
|
|
483
|
+
.map((f) => `- ${f.filename} (${f.status})`)
|
|
484
|
+
.join("\n");
|
|
485
|
+
// 构建代码变更内容(只包含变更行,限制总长度)
|
|
486
|
+
let codeChangesSection = "";
|
|
487
|
+
if (fileContents && fileContents.size > 0) {
|
|
488
|
+
const codeSnippets: string[] = [];
|
|
489
|
+
let totalLength = 0;
|
|
490
|
+
const maxTotalLength = 8000; // 限制代码总长度
|
|
491
|
+
for (const [filename, lines] of fileContents) {
|
|
492
|
+
if (totalLength >= maxTotalLength) break;
|
|
493
|
+
// 只提取有变更的行(commitHash 不是 "-------")
|
|
494
|
+
const changedLines = lines
|
|
495
|
+
.map(([hash, code], idx) => (hash !== "-------" ? `${idx + 1}: ${code}` : null))
|
|
496
|
+
.filter(Boolean);
|
|
497
|
+
if (changedLines.length > 0) {
|
|
498
|
+
const snippet = `### ${filename}\n\`\`\`\n${changedLines.slice(0, 50).join("\n")}\n\`\`\``;
|
|
499
|
+
if (totalLength + snippet.length <= maxTotalLength) {
|
|
500
|
+
codeSnippets.push(snippet);
|
|
501
|
+
totalLength += snippet.length;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (codeSnippets.length > 0) {
|
|
506
|
+
codeChangesSection = `\n\n## 代码变更内容\n${codeSnippets.join("\n\n")}`;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const prompt = `请根据以下 PR 的 commit 记录、文件变更和代码内容,用简洁的中文总结这个 PR 实现了什么功能。
|
|
510
|
+
要求:
|
|
511
|
+
1. 第一行输出 PR 标题,格式必须是: Feat xxx 或 Fix xxx 或 Refactor xxx(根据变更类型选择,整体不超过 50 个字符)
|
|
512
|
+
2. 空一行后输出详细描述
|
|
513
|
+
3. 描述应该简明扼要,突出核心功能点
|
|
514
|
+
4. 使用 Markdown 格式
|
|
515
|
+
5. 不要逐条列出 commit,而是归纳总结
|
|
516
|
+
6. 重点分析代码变更的实际功能
|
|
517
|
+
|
|
518
|
+
## Commit 记录 (${commits.length} 个)
|
|
519
|
+
${commitMessages || "无"}
|
|
520
|
+
|
|
521
|
+
## 文件变更 (${changedFiles.length} 个文件)
|
|
522
|
+
${fileChanges || "无"}
|
|
523
|
+
${changedFiles.length > 30 ? `\n... 等 ${changedFiles.length - 30} 个文件` : ""}${codeChangesSection}`;
|
|
524
|
+
try {
|
|
525
|
+
const stream = this.llmProxyService.chatStream([{ role: "user", content: prompt }], {
|
|
526
|
+
adapter: llmMode,
|
|
527
|
+
});
|
|
528
|
+
let content = "";
|
|
529
|
+
for await (const event of stream) {
|
|
530
|
+
if (event.type === "text") {
|
|
531
|
+
content += event.content;
|
|
532
|
+
} else if (event.type === "error") {
|
|
533
|
+
throw new Error(event.message);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
// 解析标题和描述:第一行是标题,其余是描述
|
|
537
|
+
const lines = content.trim().split("\n");
|
|
538
|
+
const title = lines[0]?.replace(/^#+\s*/, "").trim() || "PR 更新";
|
|
539
|
+
const description = lines.slice(1).join("\n").trim();
|
|
540
|
+
return { title, description };
|
|
541
|
+
} catch (error) {
|
|
542
|
+
if (shouldLog(verbose, 1)) {
|
|
543
|
+
console.warn("⚠️ AI 总结 PR 功能失败,使用默认描述:", error);
|
|
544
|
+
}
|
|
545
|
+
return this.buildBasicDescription(commits, changedFiles);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* 使用 LLM 生成 PR 标题
|
|
551
|
+
*/
|
|
552
|
+
async generatePrTitle(
|
|
553
|
+
commits: PullRequestCommit[],
|
|
554
|
+
changedFiles: ChangedFile[],
|
|
555
|
+
): Promise<string> {
|
|
556
|
+
const commitMessages = commits
|
|
557
|
+
.slice(0, 10)
|
|
558
|
+
.map((c) => c.commit?.message?.split("\n")[0])
|
|
559
|
+
.filter(Boolean)
|
|
560
|
+
.join("\n");
|
|
561
|
+
const fileChanges = changedFiles
|
|
562
|
+
.slice(0, 20)
|
|
563
|
+
.map((f) => `${f.filename} (${f.status})`)
|
|
564
|
+
.join("\n");
|
|
565
|
+
const prompt = `请根据以下 commit 记录和文件变更,生成一个简短的 PR 标题。
|
|
566
|
+
要求:
|
|
567
|
+
1. 格式必须是: Feat: xxx 或 Fix: xxx 或 Refactor: xxx
|
|
568
|
+
2. 根据变更内容选择合适的前缀(新功能用 Feat,修复用 Fix,重构用 Refactor)
|
|
569
|
+
3. xxx 部分用简短的中文描述(整体不超过 50 个字符)
|
|
570
|
+
4. 只输出标题,不要加任何解释
|
|
571
|
+
|
|
572
|
+
Commit 记录:
|
|
573
|
+
${commitMessages || "无"}
|
|
574
|
+
|
|
575
|
+
文件变更:
|
|
576
|
+
${fileChanges || "无"}`;
|
|
577
|
+
try {
|
|
578
|
+
const stream = this.llmProxyService.chatStream([{ role: "user", content: prompt }], {
|
|
579
|
+
adapter: "openai",
|
|
580
|
+
});
|
|
581
|
+
let title = "";
|
|
582
|
+
for await (const event of stream) {
|
|
583
|
+
if (event.type === "text") {
|
|
584
|
+
title += event.content;
|
|
585
|
+
} else if (event.type === "error") {
|
|
586
|
+
throw new Error(event.message);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return title.trim().slice(0, 50) || this.getFallbackTitle(commits);
|
|
590
|
+
} catch {
|
|
591
|
+
return this.getFallbackTitle(commits);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* 获取降级标题(从第一个 commit 消息)
|
|
597
|
+
*/
|
|
598
|
+
getFallbackTitle(commits: PullRequestCommit[]): string {
|
|
599
|
+
const firstCommitMsg = commits[0]?.commit?.message?.split("\n")[0] || "PR 更新";
|
|
600
|
+
return firstCommitMsg.slice(0, 50);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* 构建基础描述(不做完整的 AI 功能总结,但仍用 LLM 辅助生成标题)
|
|
605
|
+
*/
|
|
606
|
+
async buildBasicDescription(
|
|
607
|
+
commits: PullRequestCommit[],
|
|
608
|
+
changedFiles: ChangedFile[],
|
|
609
|
+
): Promise<{ title: string; description: string }> {
|
|
610
|
+
const parts: string[] = [];
|
|
611
|
+
// 使用 LLM 生成标题
|
|
612
|
+
const title = await this.generatePrTitle(commits, changedFiles);
|
|
613
|
+
if (commits.length > 0) {
|
|
614
|
+
const messages = commits
|
|
615
|
+
.slice(0, 5)
|
|
616
|
+
.map((c) => `- ${c.commit?.message?.split("\n")[0]}`)
|
|
617
|
+
.filter(Boolean);
|
|
618
|
+
if (messages.length > 0) {
|
|
619
|
+
parts.push(`**提交记录**: ${messages.join("; ")}`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
if (changedFiles.length > 0) {
|
|
623
|
+
const added = changedFiles.filter((f) => f.status === "added").length;
|
|
624
|
+
const modified = changedFiles.filter((f) => f.status === "modified").length;
|
|
625
|
+
const deleted = changedFiles.filter((f) => f.status === "deleted").length;
|
|
626
|
+
const stats: string[] = [];
|
|
627
|
+
if (added > 0) stats.push(`新增 ${added}`);
|
|
628
|
+
if (modified > 0) stats.push(`修改 ${modified}`);
|
|
629
|
+
if (deleted > 0) stats.push(`删除 ${deleted}`);
|
|
630
|
+
parts.push(`**文件变更**: ${changedFiles.length} 个文件 (${stats.join(", ")})`);
|
|
631
|
+
}
|
|
632
|
+
return { title, description: parts.join("\n") };
|
|
633
|
+
}
|
|
634
|
+
}
|