@zjex/git-workflow 0.4.7 → 0.5.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 +6 -0
- package/README.md +44 -6
- package/dist/index.js +614 -11
- package/docs/.vitepress/cache/deps/_metadata.json +10 -10
- package/docs/.vitepress/config.ts +2 -0
- package/docs/commands/index.md +4 -0
- package/docs/commands/review.md +142 -0
- package/docs/guide/ai-review.md +159 -0
- package/docs/guide/index.md +2 -0
- package/docs/index.md +26 -3
- package/package.json +1 -1
- package/src/commands/review.ts +759 -0
- package/src/index.ts +29 -1
- package/tests/review.test.ts +1058 -0
- package/docs/.vitepress/cache/deps_temp_44e2fb0f/chunk-2CLQ7TTZ.js +0 -9719
- package/docs/.vitepress/cache/deps_temp_44e2fb0f/chunk-2CLQ7TTZ.js.map +0 -7
- package/docs/.vitepress/cache/deps_temp_44e2fb0f/chunk-LE5NDSFD.js +0 -12824
- package/docs/.vitepress/cache/deps_temp_44e2fb0f/chunk-LE5NDSFD.js.map +0 -7
- package/docs/.vitepress/cache/deps_temp_44e2fb0f/package.json +0 -3
- package/docs/.vitepress/cache/deps_temp_44e2fb0f/vitepress___@vue_devtools-api.js +0 -4505
- package/docs/.vitepress/cache/deps_temp_44e2fb0f/vitepress___@vue_devtools-api.js.map +0 -7
- package/docs/.vitepress/cache/deps_temp_44e2fb0f/vitepress___@vueuse_core.js +0 -583
- package/docs/.vitepress/cache/deps_temp_44e2fb0f/vitepress___@vueuse_core.js.map +0 -7
- package/docs/.vitepress/cache/deps_temp_44e2fb0f/vitepress___@vueuse_integrations_useFocusTrap.js +0 -1352
- package/docs/.vitepress/cache/deps_temp_44e2fb0f/vitepress___@vueuse_integrations_useFocusTrap.js.map +0 -7
- package/docs/.vitepress/cache/deps_temp_44e2fb0f/vitepress___mark__js_src_vanilla__js.js +0 -1665
- package/docs/.vitepress/cache/deps_temp_44e2fb0f/vitepress___mark__js_src_vanilla__js.js.map +0 -7
- package/docs/.vitepress/cache/deps_temp_44e2fb0f/vitepress___minisearch.js +0 -1813
- package/docs/.vitepress/cache/deps_temp_44e2fb0f/vitepress___minisearch.js.map +0 -7
- package/docs/.vitepress/cache/deps_temp_44e2fb0f/vue.js +0 -347
- package/docs/.vitepress/cache/deps_temp_44e2fb0f/vue.js.map +0 -7
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI 代码审查命令
|
|
3
|
+
*
|
|
4
|
+
* 功能:
|
|
5
|
+
* 1. 审查当前暂存的更改
|
|
6
|
+
* 2. 审查指定的 commit(s)
|
|
7
|
+
* 3. 交互式选择要审查的 commits
|
|
8
|
+
* 4. 生成详细的 markdown 审查报告
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { select, checkbox } from "@inquirer/prompts";
|
|
12
|
+
import ora from "ora";
|
|
13
|
+
import { writeFileSync, existsSync, mkdirSync } from "fs";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
import { execOutput, colors, theme, divider } from "../utils.js";
|
|
16
|
+
import { loadConfig, type GwConfig } from "../config.js";
|
|
17
|
+
|
|
18
|
+
// ========== 类型定义 ==========
|
|
19
|
+
|
|
20
|
+
interface CommitInfo {
|
|
21
|
+
hash: string;
|
|
22
|
+
shortHash: string;
|
|
23
|
+
subject: string;
|
|
24
|
+
author: string;
|
|
25
|
+
date: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ReviewOptions {
|
|
29
|
+
last?: number;
|
|
30
|
+
output?: string;
|
|
31
|
+
staged?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface DiffFile {
|
|
35
|
+
oldPath: string;
|
|
36
|
+
newPath: string;
|
|
37
|
+
status: string; // A: added, M: modified, D: deleted, R: renamed
|
|
38
|
+
diff: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ========== AI Provider 配置 ==========
|
|
42
|
+
|
|
43
|
+
interface AIProvider {
|
|
44
|
+
name: string;
|
|
45
|
+
endpoint: string;
|
|
46
|
+
defaultModel: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const AI_PROVIDERS: Record<string, AIProvider> = {
|
|
50
|
+
github: {
|
|
51
|
+
name: "GitHub Models",
|
|
52
|
+
endpoint: "https://models.github.ai/inference/chat/completions",
|
|
53
|
+
defaultModel: "gpt-4o",
|
|
54
|
+
},
|
|
55
|
+
openai: {
|
|
56
|
+
name: "OpenAI",
|
|
57
|
+
endpoint: "https://api.openai.com/v1/chat/completions",
|
|
58
|
+
defaultModel: "gpt-4o",
|
|
59
|
+
},
|
|
60
|
+
claude: {
|
|
61
|
+
name: "Claude",
|
|
62
|
+
endpoint: "https://api.anthropic.com/v1/messages",
|
|
63
|
+
defaultModel: "claude-3-5-sonnet-20241022",
|
|
64
|
+
},
|
|
65
|
+
ollama: {
|
|
66
|
+
name: "Ollama",
|
|
67
|
+
endpoint: "http://localhost:11434/api/generate",
|
|
68
|
+
defaultModel: "qwen2.5-coder:14b",
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// ========== 辅助函数 ==========
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 获取最近的 commits 列表
|
|
76
|
+
*/
|
|
77
|
+
function getRecentCommits(limit: number = 20): CommitInfo[] {
|
|
78
|
+
try {
|
|
79
|
+
const output = execOutput(
|
|
80
|
+
`git log -${limit} --pretty=format:"%H|%h|%s|%an|%ad" --date=short`
|
|
81
|
+
);
|
|
82
|
+
if (!output) return [];
|
|
83
|
+
|
|
84
|
+
return output
|
|
85
|
+
.split("\n")
|
|
86
|
+
.filter(Boolean)
|
|
87
|
+
.map((line) => {
|
|
88
|
+
const [hash, shortHash, subject, author, date] = line.split("|");
|
|
89
|
+
return { hash, shortHash, subject, author, date };
|
|
90
|
+
});
|
|
91
|
+
} catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 获取暂存区的 diff
|
|
98
|
+
*/
|
|
99
|
+
function getStagedDiff(): string {
|
|
100
|
+
try {
|
|
101
|
+
const diff = execOutput("git diff --cached");
|
|
102
|
+
if (diff) return diff;
|
|
103
|
+
// 如果没有暂存的更改,获取工作区更改
|
|
104
|
+
return execOutput("git diff") || "";
|
|
105
|
+
} catch {
|
|
106
|
+
return "";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 获取指定 commit 的 diff
|
|
112
|
+
*/
|
|
113
|
+
function getCommitDiff(hash: string): string {
|
|
114
|
+
try {
|
|
115
|
+
return execOutput(`git show ${hash} --format="" --patch`) || "";
|
|
116
|
+
} catch {
|
|
117
|
+
return "";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 获取多个 commits 的合并 diff
|
|
123
|
+
*/
|
|
124
|
+
function getMultipleCommitsDiff(hashes: string[]): string {
|
|
125
|
+
if (hashes.length === 0) return "";
|
|
126
|
+
if (hashes.length === 1) return getCommitDiff(hashes[0]);
|
|
127
|
+
|
|
128
|
+
// 获取范围 diff
|
|
129
|
+
const oldest = hashes[hashes.length - 1];
|
|
130
|
+
const newest = hashes[0];
|
|
131
|
+
try {
|
|
132
|
+
return execOutput(`git diff ${oldest}^..${newest}`) || "";
|
|
133
|
+
} catch {
|
|
134
|
+
// 如果失败,合并各个 commit 的 diff
|
|
135
|
+
return hashes.map((h) => getCommitDiff(h)).join("\n\n");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 解析 diff 内容,提取文件信息
|
|
141
|
+
*/
|
|
142
|
+
function parseDiff(diff: string): DiffFile[] {
|
|
143
|
+
const files: DiffFile[] = [];
|
|
144
|
+
const fileDiffs = diff.split(/^diff --git /m).filter(Boolean);
|
|
145
|
+
|
|
146
|
+
for (const fileDiff of fileDiffs) {
|
|
147
|
+
const lines = fileDiff.split("\n");
|
|
148
|
+
const headerMatch = lines[0]?.match(/a\/(.+) b\/(.+)/);
|
|
149
|
+
if (!headerMatch) continue;
|
|
150
|
+
|
|
151
|
+
const oldPath = headerMatch[1];
|
|
152
|
+
const newPath = headerMatch[2];
|
|
153
|
+
|
|
154
|
+
// 判断文件状态
|
|
155
|
+
let status = "M";
|
|
156
|
+
if (fileDiff.includes("new file mode")) status = "A";
|
|
157
|
+
else if (fileDiff.includes("deleted file mode")) status = "D";
|
|
158
|
+
else if (fileDiff.includes("rename from")) status = "R";
|
|
159
|
+
|
|
160
|
+
files.push({
|
|
161
|
+
oldPath,
|
|
162
|
+
newPath,
|
|
163
|
+
status,
|
|
164
|
+
diff: "diff --git " + fileDiff,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return files;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 获取 diff 统计信息
|
|
173
|
+
*/
|
|
174
|
+
function getDiffStats(diff: string): { additions: number; deletions: number; files: number } {
|
|
175
|
+
const lines = diff.split("\n");
|
|
176
|
+
let additions = 0;
|
|
177
|
+
let deletions = 0;
|
|
178
|
+
|
|
179
|
+
for (const line of lines) {
|
|
180
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
181
|
+
additions++;
|
|
182
|
+
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
|
183
|
+
deletions++;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const files = parseDiff(diff).length;
|
|
188
|
+
return { additions, deletions, files };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ========== 提示词构建 ==========
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 构建代码审查的系统提示词
|
|
195
|
+
*/
|
|
196
|
+
function buildSystemPrompt(language: string): string {
|
|
197
|
+
const isZh = language === "zh-CN";
|
|
198
|
+
|
|
199
|
+
if (isZh) {
|
|
200
|
+
return `你是一个资深的代码审查专家,拥有丰富的软件开发经验。你的任务是审查 Git 提交中的代码变更,提供专业、有价值、有建设性的审查意见。
|
|
201
|
+
|
|
202
|
+
## 审查原则
|
|
203
|
+
|
|
204
|
+
1. **重点关注变更代码**:只审查 diff 中带 \`+\` 或 \`-\` 的代码行,这些是实际的变更内容
|
|
205
|
+
2. **提供具体建议**:不要泛泛而谈,要针对具体代码行给出改进建议
|
|
206
|
+
3. **区分问题严重程度**:使用 🔴 严重、🟡 警告、🔵 建议 三个级别
|
|
207
|
+
4. **代码示例**:在建议修改时,尽可能提供修改后的代码示例
|
|
208
|
+
5. **正面反馈**:对于写得好的代码,也要给予肯定
|
|
209
|
+
|
|
210
|
+
## 审查维度
|
|
211
|
+
|
|
212
|
+
1. **代码质量**:可读性、可维护性、代码风格
|
|
213
|
+
2. **潜在 Bug**:空指针、边界条件、异常处理
|
|
214
|
+
3. **安全问题**:SQL 注入、XSS、敏感信息泄露
|
|
215
|
+
4. **性能问题**:不必要的循环、内存泄漏、重复计算
|
|
216
|
+
5. **最佳实践**:设计模式、SOLID 原则、DRY 原则
|
|
217
|
+
|
|
218
|
+
## Diff 格式说明
|
|
219
|
+
|
|
220
|
+
- 以 \`+\` 开头的行是新增的代码
|
|
221
|
+
- 以 \`-\` 开头的行是删除的代码
|
|
222
|
+
- \`@@\` 行表示代码位置信息,格式为 \`@@ -旧文件起始行,行数 +新文件起始行,行数 @@\`
|
|
223
|
+
- 没有 \`+\` 或 \`-\` 前缀的行是上下文代码,用于帮助理解变更
|
|
224
|
+
|
|
225
|
+
## 输出格式
|
|
226
|
+
|
|
227
|
+
请使用 Markdown 格式输出审查报告,包含以下部分:
|
|
228
|
+
|
|
229
|
+
1. **概述**:简要总结本次变更的内容和整体评价
|
|
230
|
+
2. **问题列表**:按严重程度列出发现的问题
|
|
231
|
+
3. **改进建议**:提供具体的代码改进建议
|
|
232
|
+
4. **亮点**:指出代码中写得好的地方(如果有)
|
|
233
|
+
|
|
234
|
+
注意:
|
|
235
|
+
- 每个问题都要指明文件路径和行号
|
|
236
|
+
- 提供修改建议时要给出代码示例
|
|
237
|
+
- 如果代码没有明显问题,也要说明审查结论`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return `You are a senior code review expert with extensive software development experience. Your task is to review code changes in Git commits and provide professional, valuable, and constructive review feedback.
|
|
241
|
+
|
|
242
|
+
## Review Principles
|
|
243
|
+
|
|
244
|
+
1. **Focus on Changed Code**: Only review lines with \`+\` or \`-\` prefixes in the diff - these are the actual changes
|
|
245
|
+
2. **Provide Specific Suggestions**: Don't be vague, give improvement suggestions for specific code lines
|
|
246
|
+
3. **Categorize Issue Severity**: Use 🔴 Critical, 🟡 Warning, 🔵 Suggestion levels
|
|
247
|
+
4. **Code Examples**: When suggesting changes, provide modified code examples whenever possible
|
|
248
|
+
5. **Positive Feedback**: Also acknowledge well-written code
|
|
249
|
+
|
|
250
|
+
## Review Dimensions
|
|
251
|
+
|
|
252
|
+
1. **Code Quality**: Readability, maintainability, code style
|
|
253
|
+
2. **Potential Bugs**: Null pointers, boundary conditions, exception handling
|
|
254
|
+
3. **Security Issues**: SQL injection, XSS, sensitive data exposure
|
|
255
|
+
4. **Performance Issues**: Unnecessary loops, memory leaks, redundant calculations
|
|
256
|
+
5. **Best Practices**: Design patterns, SOLID principles, DRY principle
|
|
257
|
+
|
|
258
|
+
## Diff Format Explanation
|
|
259
|
+
|
|
260
|
+
- Lines starting with \`+\` are added code
|
|
261
|
+
- Lines starting with \`-\` are deleted code
|
|
262
|
+
- \`@@\` lines indicate code location, format: \`@@ -old_start,count +new_start,count @@\`
|
|
263
|
+
- Lines without \`+\` or \`-\` prefix are context code to help understand changes
|
|
264
|
+
|
|
265
|
+
## Output Format
|
|
266
|
+
|
|
267
|
+
Please output the review report in Markdown format, including:
|
|
268
|
+
|
|
269
|
+
1. **Overview**: Brief summary of changes and overall assessment
|
|
270
|
+
2. **Issues**: List issues by severity
|
|
271
|
+
3. **Suggestions**: Provide specific code improvement suggestions
|
|
272
|
+
4. **Highlights**: Point out well-written code (if any)
|
|
273
|
+
|
|
274
|
+
Note:
|
|
275
|
+
- Each issue should specify file path and line number
|
|
276
|
+
- Provide code examples when suggesting modifications
|
|
277
|
+
- If no obvious issues, state the review conclusion`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* 构建用户提示词(包含 diff 内容)
|
|
282
|
+
*/
|
|
283
|
+
function buildUserPrompt(
|
|
284
|
+
diff: string,
|
|
285
|
+
commits: CommitInfo[],
|
|
286
|
+
language: string
|
|
287
|
+
): string {
|
|
288
|
+
const isZh = language === "zh-CN";
|
|
289
|
+
const stats = getDiffStats(diff);
|
|
290
|
+
const files = parseDiff(diff);
|
|
291
|
+
|
|
292
|
+
let prompt = "";
|
|
293
|
+
|
|
294
|
+
// 添加变更概览
|
|
295
|
+
if (isZh) {
|
|
296
|
+
prompt += `## 变更概览\n\n`;
|
|
297
|
+
prompt += `- 涉及文件: ${stats.files} 个\n`;
|
|
298
|
+
prompt += `- 新增行数: +${stats.additions}\n`;
|
|
299
|
+
prompt += `- 删除行数: -${stats.deletions}\n\n`;
|
|
300
|
+
|
|
301
|
+
if (commits.length > 0) {
|
|
302
|
+
prompt += `## 相关提交\n\n`;
|
|
303
|
+
for (const commit of commits) {
|
|
304
|
+
prompt += `- \`${commit.shortHash}\` ${commit.subject} (${commit.author}, ${commit.date})\n`;
|
|
305
|
+
}
|
|
306
|
+
prompt += `\n`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
prompt += `## 变更文件列表\n\n`;
|
|
310
|
+
for (const file of files) {
|
|
311
|
+
const statusIcon =
|
|
312
|
+
file.status === "A" ? "🆕" : file.status === "D" ? "🗑️" : file.status === "R" ? "📝" : "✏️";
|
|
313
|
+
prompt += `- ${statusIcon} \`${file.newPath}\`\n`;
|
|
314
|
+
}
|
|
315
|
+
prompt += `\n`;
|
|
316
|
+
|
|
317
|
+
prompt += `## Diff 内容\n\n请仔细审查以下代码变更:\n\n`;
|
|
318
|
+
} else {
|
|
319
|
+
prompt += `## Change Overview\n\n`;
|
|
320
|
+
prompt += `- Files changed: ${stats.files}\n`;
|
|
321
|
+
prompt += `- Lines added: +${stats.additions}\n`;
|
|
322
|
+
prompt += `- Lines deleted: -${stats.deletions}\n\n`;
|
|
323
|
+
|
|
324
|
+
if (commits.length > 0) {
|
|
325
|
+
prompt += `## Related Commits\n\n`;
|
|
326
|
+
for (const commit of commits) {
|
|
327
|
+
prompt += `- \`${commit.shortHash}\` ${commit.subject} (${commit.author}, ${commit.date})\n`;
|
|
328
|
+
}
|
|
329
|
+
prompt += `\n`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
prompt += `## Changed Files\n\n`;
|
|
333
|
+
for (const file of files) {
|
|
334
|
+
const statusIcon =
|
|
335
|
+
file.status === "A" ? "🆕" : file.status === "D" ? "🗑️" : file.status === "R" ? "📝" : "✏️";
|
|
336
|
+
prompt += `- ${statusIcon} \`${file.newPath}\`\n`;
|
|
337
|
+
}
|
|
338
|
+
prompt += `\n`;
|
|
339
|
+
|
|
340
|
+
prompt += `## Diff Content\n\nPlease carefully review the following code changes:\n\n`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 添加 diff 内容,使用特定格式便于 AI 理解
|
|
344
|
+
for (const file of files) {
|
|
345
|
+
prompt += `### ${file.newPath}\n\n`;
|
|
346
|
+
prompt += "```diff\n";
|
|
347
|
+
prompt += file.diff;
|
|
348
|
+
prompt += "\n```\n\n";
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return prompt;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ========== AI API 调用 ==========
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* 调用 GitHub Models API
|
|
358
|
+
*/
|
|
359
|
+
async function callGitHubAPI(
|
|
360
|
+
systemPrompt: string,
|
|
361
|
+
userPrompt: string,
|
|
362
|
+
apiKey: string,
|
|
363
|
+
model: string
|
|
364
|
+
): Promise<string> {
|
|
365
|
+
const response = await fetch(AI_PROVIDERS.github.endpoint, {
|
|
366
|
+
method: "POST",
|
|
367
|
+
headers: {
|
|
368
|
+
Authorization: `Bearer ${apiKey}`,
|
|
369
|
+
"Content-Type": "application/json",
|
|
370
|
+
},
|
|
371
|
+
body: JSON.stringify({
|
|
372
|
+
model,
|
|
373
|
+
messages: [
|
|
374
|
+
{ role: "system", content: systemPrompt },
|
|
375
|
+
{ role: "user", content: userPrompt },
|
|
376
|
+
],
|
|
377
|
+
max_tokens: 4000,
|
|
378
|
+
temperature: 0.3,
|
|
379
|
+
}),
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
if (!response.ok) {
|
|
383
|
+
const error = await response.text();
|
|
384
|
+
throw new Error(`GitHub Models API 错误: ${response.status} ${error}`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const data = await response.json();
|
|
388
|
+
return data.choices[0]?.message?.content?.trim() || "";
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* 调用 OpenAI API
|
|
393
|
+
*/
|
|
394
|
+
async function callOpenAIAPI(
|
|
395
|
+
systemPrompt: string,
|
|
396
|
+
userPrompt: string,
|
|
397
|
+
apiKey: string,
|
|
398
|
+
model: string
|
|
399
|
+
): Promise<string> {
|
|
400
|
+
const response = await fetch(AI_PROVIDERS.openai.endpoint, {
|
|
401
|
+
method: "POST",
|
|
402
|
+
headers: {
|
|
403
|
+
Authorization: `Bearer ${apiKey}`,
|
|
404
|
+
"Content-Type": "application/json",
|
|
405
|
+
},
|
|
406
|
+
body: JSON.stringify({
|
|
407
|
+
model,
|
|
408
|
+
messages: [
|
|
409
|
+
{ role: "system", content: systemPrompt },
|
|
410
|
+
{ role: "user", content: userPrompt },
|
|
411
|
+
],
|
|
412
|
+
max_tokens: 4000,
|
|
413
|
+
temperature: 0.3,
|
|
414
|
+
}),
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
if (!response.ok) {
|
|
418
|
+
const error = await response.text();
|
|
419
|
+
throw new Error(`OpenAI API 错误: ${response.status} ${error}`);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const data = await response.json();
|
|
423
|
+
return data.choices[0]?.message?.content?.trim() || "";
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* 调用 Claude API
|
|
428
|
+
*/
|
|
429
|
+
async function callClaudeAPI(
|
|
430
|
+
systemPrompt: string,
|
|
431
|
+
userPrompt: string,
|
|
432
|
+
apiKey: string,
|
|
433
|
+
model: string
|
|
434
|
+
): Promise<string> {
|
|
435
|
+
const response = await fetch(AI_PROVIDERS.claude.endpoint, {
|
|
436
|
+
method: "POST",
|
|
437
|
+
headers: {
|
|
438
|
+
"x-api-key": apiKey,
|
|
439
|
+
"anthropic-version": "2023-06-01",
|
|
440
|
+
"Content-Type": "application/json",
|
|
441
|
+
},
|
|
442
|
+
body: JSON.stringify({
|
|
443
|
+
model,
|
|
444
|
+
system: systemPrompt,
|
|
445
|
+
messages: [{ role: "user", content: userPrompt }],
|
|
446
|
+
max_tokens: 4000,
|
|
447
|
+
temperature: 0.3,
|
|
448
|
+
}),
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
if (!response.ok) {
|
|
452
|
+
const error = await response.text();
|
|
453
|
+
throw new Error(`Claude API 错误: ${response.status} ${error}`);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const data = await response.json();
|
|
457
|
+
return data.content[0]?.text?.trim() || "";
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* 调用 Ollama API
|
|
462
|
+
*/
|
|
463
|
+
async function callOllamaAPI(
|
|
464
|
+
systemPrompt: string,
|
|
465
|
+
userPrompt: string,
|
|
466
|
+
model: string
|
|
467
|
+
): Promise<string> {
|
|
468
|
+
try {
|
|
469
|
+
const response = await fetch(AI_PROVIDERS.ollama.endpoint, {
|
|
470
|
+
method: "POST",
|
|
471
|
+
headers: { "Content-Type": "application/json" },
|
|
472
|
+
body: JSON.stringify({
|
|
473
|
+
model,
|
|
474
|
+
prompt: `${systemPrompt}\n\n${userPrompt}`,
|
|
475
|
+
stream: false,
|
|
476
|
+
options: {
|
|
477
|
+
num_predict: 4000,
|
|
478
|
+
temperature: 0.3,
|
|
479
|
+
},
|
|
480
|
+
}),
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
if (!response.ok) {
|
|
484
|
+
throw new Error(`Ollama 未运行或模型未安装`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const data = await response.json();
|
|
488
|
+
return data.response?.trim() || "";
|
|
489
|
+
} catch (error) {
|
|
490
|
+
throw new Error(
|
|
491
|
+
`Ollama 连接失败。请确保:\n1. 已安装 Ollama (https://ollama.com)\n2. 运行 'ollama serve'\n3. 下载模型 'ollama pull ${model}'`
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* 调用 AI 进行代码审查
|
|
498
|
+
*/
|
|
499
|
+
async function callAIReview(
|
|
500
|
+
diff: string,
|
|
501
|
+
commits: CommitInfo[],
|
|
502
|
+
config: GwConfig
|
|
503
|
+
): Promise<string> {
|
|
504
|
+
const aiConfig = config.aiCommit || {};
|
|
505
|
+
const provider = aiConfig.provider || "github";
|
|
506
|
+
const language = aiConfig.language || "zh-CN";
|
|
507
|
+
const apiKey = aiConfig.apiKey || "";
|
|
508
|
+
|
|
509
|
+
const providerInfo = AI_PROVIDERS[provider];
|
|
510
|
+
if (!providerInfo) {
|
|
511
|
+
throw new Error(`不支持的 AI 提供商: ${provider}`);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Review 使用更强大的模型
|
|
515
|
+
const model = aiConfig.model || providerInfo.defaultModel;
|
|
516
|
+
|
|
517
|
+
if (provider !== "ollama" && !apiKey) {
|
|
518
|
+
throw new Error(
|
|
519
|
+
`${providerInfo.name} 需要 API key。请运行 'gw init' 配置,或在 .gwrc.json 中设置 aiCommit.apiKey`
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const systemPrompt = buildSystemPrompt(language);
|
|
524
|
+
const userPrompt = buildUserPrompt(diff, commits, language);
|
|
525
|
+
|
|
526
|
+
// 限制 diff 长度
|
|
527
|
+
const maxLength = 30000;
|
|
528
|
+
const truncatedUserPrompt =
|
|
529
|
+
userPrompt.length > maxLength
|
|
530
|
+
? userPrompt.slice(0, maxLength) + "\n\n[... diff 内容过长,已截断 ...]"
|
|
531
|
+
: userPrompt;
|
|
532
|
+
|
|
533
|
+
switch (provider) {
|
|
534
|
+
case "github":
|
|
535
|
+
return callGitHubAPI(systemPrompt, truncatedUserPrompt, apiKey, model);
|
|
536
|
+
case "openai":
|
|
537
|
+
return callOpenAIAPI(systemPrompt, truncatedUserPrompt, apiKey, model);
|
|
538
|
+
case "claude":
|
|
539
|
+
return callClaudeAPI(systemPrompt, truncatedUserPrompt, apiKey, model);
|
|
540
|
+
case "ollama":
|
|
541
|
+
return callOllamaAPI(systemPrompt, truncatedUserPrompt, model);
|
|
542
|
+
default:
|
|
543
|
+
throw new Error(`不支持的 AI 提供商: ${provider}`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ========== 报告生成 ==========
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* 生成审查报告的 markdown 文件
|
|
551
|
+
*/
|
|
552
|
+
function generateReportFile(
|
|
553
|
+
reviewContent: string,
|
|
554
|
+
commits: CommitInfo[],
|
|
555
|
+
stats: { additions: number; deletions: number; files: number },
|
|
556
|
+
outputPath?: string
|
|
557
|
+
): string {
|
|
558
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
559
|
+
const commitInfo =
|
|
560
|
+
commits.length > 0
|
|
561
|
+
? commits.map((c) => c.shortHash).join("-")
|
|
562
|
+
: "staged";
|
|
563
|
+
|
|
564
|
+
// 确保 .gw-reviews 目录存在
|
|
565
|
+
const reviewDir = ".gw-reviews";
|
|
566
|
+
if (!existsSync(reviewDir)) {
|
|
567
|
+
mkdirSync(reviewDir, { recursive: true });
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const filename = outputPath || join(reviewDir, `review-${commitInfo}-${timestamp}.md`);
|
|
571
|
+
|
|
572
|
+
// 构建完整的报告
|
|
573
|
+
let report = `# 🔍 代码审查报告\n\n`;
|
|
574
|
+
report += `> 生成时间: ${new Date().toLocaleString("zh-CN")}\n\n`;
|
|
575
|
+
|
|
576
|
+
// 添加变更统计
|
|
577
|
+
report += `## 📊 变更统计\n\n`;
|
|
578
|
+
report += `| 指标 | 数值 |\n`;
|
|
579
|
+
report += `|------|------|\n`;
|
|
580
|
+
report += `| 文件数 | ${stats.files} |\n`;
|
|
581
|
+
report += `| 新增行 | +${stats.additions} |\n`;
|
|
582
|
+
report += `| 删除行 | -${stats.deletions} |\n\n`;
|
|
583
|
+
|
|
584
|
+
// 添加 commit 信息
|
|
585
|
+
if (commits.length > 0) {
|
|
586
|
+
report += `## 📝 审查的提交\n\n`;
|
|
587
|
+
for (const commit of commits) {
|
|
588
|
+
report += `- \`${commit.shortHash}\` ${commit.subject} - ${commit.author} (${commit.date})\n`;
|
|
589
|
+
}
|
|
590
|
+
report += `\n`;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// 添加 AI 审查内容
|
|
594
|
+
report += `## 🤖 AI 审查结果\n\n`;
|
|
595
|
+
report += reviewContent;
|
|
596
|
+
report += `\n\n---\n\n`;
|
|
597
|
+
report += `*本报告由 [git-workflow](https://github.com/iamzjt-front-end/git-workflow) 的 AI Review 功能生成*\n`;
|
|
598
|
+
|
|
599
|
+
// 写入文件
|
|
600
|
+
writeFileSync(filename, report, "utf-8");
|
|
601
|
+
|
|
602
|
+
return filename;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ========== 主函数 ==========
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* 代码审查主函数
|
|
609
|
+
*/
|
|
610
|
+
export async function review(
|
|
611
|
+
hashes?: string[],
|
|
612
|
+
options: ReviewOptions = {}
|
|
613
|
+
): Promise<void> {
|
|
614
|
+
const config = loadConfig();
|
|
615
|
+
|
|
616
|
+
// 检查 AI 配置
|
|
617
|
+
const aiConfig = config.aiCommit;
|
|
618
|
+
if (!aiConfig?.apiKey && aiConfig?.provider !== "ollama") {
|
|
619
|
+
console.log(colors.red("❌ 未配置 AI API Key"));
|
|
620
|
+
console.log("");
|
|
621
|
+
console.log(colors.dim(" 请先运行以下命令配置 AI:"));
|
|
622
|
+
console.log(colors.cyan(" gw init"));
|
|
623
|
+
console.log("");
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
let diff = "";
|
|
628
|
+
let commits: CommitInfo[] = [];
|
|
629
|
+
|
|
630
|
+
// 确定要审查的内容
|
|
631
|
+
if (hashes && hashes.length > 0) {
|
|
632
|
+
// 指定了 commit hash
|
|
633
|
+
commits = hashes.map((hash) => {
|
|
634
|
+
const info = execOutput(
|
|
635
|
+
`git log -1 --pretty=format:"%H|%h|%s|%an|%ad" --date=short ${hash}`
|
|
636
|
+
);
|
|
637
|
+
if (!info) {
|
|
638
|
+
console.log(colors.red(`❌ 找不到 commit: ${hash}`));
|
|
639
|
+
process.exit(1);
|
|
640
|
+
}
|
|
641
|
+
const [fullHash, shortHash, subject, author, date] = info.split("|");
|
|
642
|
+
return { hash: fullHash, shortHash, subject, author, date };
|
|
643
|
+
});
|
|
644
|
+
diff = getMultipleCommitsDiff(hashes);
|
|
645
|
+
} else if (options.last) {
|
|
646
|
+
// 审查最近 N 个 commits
|
|
647
|
+
commits = getRecentCommits(options.last);
|
|
648
|
+
diff = getMultipleCommitsDiff(commits.map((c) => c.hash));
|
|
649
|
+
} else if (options.staged) {
|
|
650
|
+
// 审查暂存区
|
|
651
|
+
diff = getStagedDiff();
|
|
652
|
+
} else {
|
|
653
|
+
// 交互式选择
|
|
654
|
+
const recentCommits = getRecentCommits(20);
|
|
655
|
+
const stagedDiff = getStagedDiff();
|
|
656
|
+
|
|
657
|
+
const choices: any[] = [];
|
|
658
|
+
|
|
659
|
+
if (stagedDiff) {
|
|
660
|
+
choices.push({
|
|
661
|
+
name: `📦 暂存区的更改 (staged changes)`,
|
|
662
|
+
value: "staged",
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
choices.push(
|
|
667
|
+
...recentCommits.map((c) => ({
|
|
668
|
+
name: `${colors.yellow(c.shortHash)} ${c.subject} ${colors.dim(`- ${c.author} (${c.date})`)}`,
|
|
669
|
+
value: c.hash,
|
|
670
|
+
}))
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
if (choices.length === 0) {
|
|
674
|
+
console.log(colors.yellow("⚠️ 没有可审查的内容"));
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
divider();
|
|
679
|
+
|
|
680
|
+
const selected = await checkbox({
|
|
681
|
+
message: "选择要审查的内容 (空格选择,回车确认):",
|
|
682
|
+
choices,
|
|
683
|
+
pageSize: choices.length, // 显示所有选项,不滚动
|
|
684
|
+
loop: false, // 到达边界时不循环
|
|
685
|
+
theme,
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
if (selected.length === 0) {
|
|
689
|
+
console.log(colors.yellow("⚠️ 未选择任何内容"));
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (selected.includes("staged")) {
|
|
694
|
+
diff = stagedDiff;
|
|
695
|
+
} else {
|
|
696
|
+
commits = recentCommits.filter((c) => selected.includes(c.hash));
|
|
697
|
+
diff = getMultipleCommitsDiff(selected as string[]);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (!diff) {
|
|
702
|
+
console.log(colors.yellow("⚠️ 没有检测到代码变更"));
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const stats = getDiffStats(diff);
|
|
707
|
+
|
|
708
|
+
divider();
|
|
709
|
+
console.log(colors.cyan("📊 变更统计:"));
|
|
710
|
+
console.log(colors.dim(` 文件: ${stats.files} 个`));
|
|
711
|
+
console.log(colors.dim(` 新增: +${stats.additions} 行`));
|
|
712
|
+
console.log(colors.dim(` 删除: -${stats.deletions} 行`));
|
|
713
|
+
divider();
|
|
714
|
+
|
|
715
|
+
// 调用 AI 进行审查
|
|
716
|
+
const spinner = ora("🤖 AI 正在审查代码...").start();
|
|
717
|
+
|
|
718
|
+
try {
|
|
719
|
+
const reviewContent = await callAIReview(diff, commits, config);
|
|
720
|
+
spinner.succeed("AI 审查完成");
|
|
721
|
+
|
|
722
|
+
// 生成报告文件
|
|
723
|
+
const reportPath = generateReportFile(
|
|
724
|
+
reviewContent,
|
|
725
|
+
commits,
|
|
726
|
+
stats,
|
|
727
|
+
options.output
|
|
728
|
+
);
|
|
729
|
+
|
|
730
|
+
console.log("");
|
|
731
|
+
console.log(colors.green(`✅ 审查报告已生成: ${colors.cyan(reportPath)}`));
|
|
732
|
+
console.log("");
|
|
733
|
+
|
|
734
|
+
// 询问是否打开报告
|
|
735
|
+
const shouldOpen = await select({
|
|
736
|
+
message: "是否打开审查报告?",
|
|
737
|
+
choices: [
|
|
738
|
+
{ name: "是,在编辑器中打开", value: true },
|
|
739
|
+
{ name: "否,稍后查看", value: false },
|
|
740
|
+
],
|
|
741
|
+
theme,
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
if (shouldOpen) {
|
|
745
|
+
// 尝试用默认编辑器打开
|
|
746
|
+
try {
|
|
747
|
+
const { exec } = await import("child_process");
|
|
748
|
+
exec(`open "${reportPath}"`);
|
|
749
|
+
} catch {
|
|
750
|
+
console.log(colors.dim(` 请手动打开: ${reportPath}`));
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
} catch (error) {
|
|
754
|
+
spinner.fail("AI 审查失败");
|
|
755
|
+
console.log("");
|
|
756
|
+
console.log(colors.red(`❌ ${(error as Error).message}`));
|
|
757
|
+
console.log("");
|
|
758
|
+
}
|
|
759
|
+
}
|