hong-review-cli 1.0.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/README.md +74 -0
- package/bin/hong-review.js +89 -0
- package/index.js +166 -0
- package/package.json +30 -0
- package/src/commands/actions.js +122 -0
- package/src/commands/config.js +49 -0
- package/src/commands/list.js +65 -0
- package/src/commands/login.js +69 -0
- package/src/commands/mr.js +270 -0
- package/src/core/agent.js +141 -0
- package/src/core/ai.js +108 -0
- package/src/core/cache.js +221 -0
- package/src/core/gitlab.js +164 -0
- package/src/utils/hooks.js +60 -0
- package/src/utils/logger.js +52 -0
- package/src/utils/storage.js +67 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
const inquirer = require('inquirer');
|
|
2
|
+
const chalk = require('chalk');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const storage = require('../utils/storage');
|
|
6
|
+
const logger = require('../utils/logger');
|
|
7
|
+
const hooks = require('../utils/hooks');
|
|
8
|
+
const GitLabClient = require('../core/gitlab');
|
|
9
|
+
const AIClient = require('../core/ai');
|
|
10
|
+
const {
|
|
11
|
+
AGENT_SYSTEM_PROMPT,
|
|
12
|
+
parseAgentResponse,
|
|
13
|
+
formatFileList,
|
|
14
|
+
formatFileContents,
|
|
15
|
+
createUserMessage,
|
|
16
|
+
createAssistantMessage
|
|
17
|
+
} = require('../core/agent');
|
|
18
|
+
|
|
19
|
+
module.exports = async function(mrId, options) {
|
|
20
|
+
const config = storage.getAll();
|
|
21
|
+
if (!config.gitlabToken || !config.aiKey) {
|
|
22
|
+
logger.error('缺少必要的配置,请先运行 `hong-review login` 初始化配置。');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 这里简化处理:因为我们需要 projectId 和 mrIid,
|
|
27
|
+
// 在真实场景中,项目 ID 通常从本地 git remote 推断或配置中读取。
|
|
28
|
+
// 为了 demo 的通用性,我们直接要求用户确认或配置 projectId。
|
|
29
|
+
let projectId = config.defaultProjectId;
|
|
30
|
+
if (!projectId) {
|
|
31
|
+
const answer = await inquirer.prompt([{
|
|
32
|
+
type: 'input',
|
|
33
|
+
name: 'pid',
|
|
34
|
+
message: '请输入当前 Git 仓库对应的 GitLab Project ID:'
|
|
35
|
+
}]);
|
|
36
|
+
projectId = answer.pid;
|
|
37
|
+
storage.set('defaultProjectId', projectId);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const mrIid = parseInt(mrId);
|
|
41
|
+
if (isNaN(mrIid)) {
|
|
42
|
+
logger.error('请输入有效的 Merge Request ID (纯数字)。');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
logger.info(`开始准备审查 Project [${projectId}] 的 MR !${mrIid}`);
|
|
47
|
+
|
|
48
|
+
// 触发 Hook
|
|
49
|
+
await hooks.emit('onReviewStart', { projectId, mrId: mrIid });
|
|
50
|
+
|
|
51
|
+
const gitlab = new GitLabClient({ host: config.gitlabUrl, token: config.gitlabToken });
|
|
52
|
+
const ai = new AIClient({ apiKey: config.aiKey, baseURL: config.aiBaseUrl, model: config.aiModel });
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
logger.startSpinner('fetching', '正在从 GitLab 拉取合并请求详情与代码变更...');
|
|
56
|
+
const detail = await gitlab.getMergeRequestDetail(projectId, mrIid);
|
|
57
|
+
const changes = await gitlab.getMergeRequestChanges(projectId, mrIid);
|
|
58
|
+
logger.stopSpinner('fetching', true, `成功拉取 MR [${detail.title}] 分析所需的 ${changes.length} 个文件变更。`);
|
|
59
|
+
|
|
60
|
+
if (changes.length === 0) {
|
|
61
|
+
logger.warn('该 MR 没有包含任何文件变更。');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
logger.startSpinner('ai-thinking', `🤖 AI (${config.aiModel}) 正在深度分析中,请稍候...`);
|
|
66
|
+
|
|
67
|
+
let reviewResult = null;
|
|
68
|
+
let round = 1;
|
|
69
|
+
const fileOverviews = GitLabClient.getFileOverview(changes);
|
|
70
|
+
const initialMessage = formatFileList(fileOverviews);
|
|
71
|
+
const conversationHistory = [{ role: 'user', content: initialMessage }];
|
|
72
|
+
|
|
73
|
+
// 开始 Agent 轮询
|
|
74
|
+
while (true) {
|
|
75
|
+
logger.startSpinner('ai-thinking', `🤖 AI 思考中 (第 ${round} 轮)...`);
|
|
76
|
+
const aiResponse = await ai.chatWithJsonMode(AGENT_SYSTEM_PROMPT, conversationHistory);
|
|
77
|
+
const action = parseAgentResponse(aiResponse);
|
|
78
|
+
|
|
79
|
+
if (!action) {
|
|
80
|
+
throw new Error('AI 返回的数据无法解析为期望的 JSON 格式。');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
conversationHistory.push({ role: 'assistant', content: aiResponse });
|
|
84
|
+
|
|
85
|
+
if (action.action === 'generate_report') {
|
|
86
|
+
reviewResult = action.result;
|
|
87
|
+
break;
|
|
88
|
+
} else if (action.action === 'request_files') {
|
|
89
|
+
logger.stopSpinner('ai-thinking', true, `AI 请求查看额外文件: ${action.files.join(', ')}`);
|
|
90
|
+
|
|
91
|
+
const contents = await fetchFileContents(gitlab, projectId, detail.source_branch, changes, action.files, action.contentTypes);
|
|
92
|
+
const fileContentMessage = formatFileContents(contents);
|
|
93
|
+
conversationHistory.push({ role: 'user', content: fileContentMessage });
|
|
94
|
+
round++;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
logger.stopSpinner('ai-thinking', true, 'AI 审查完成!');
|
|
99
|
+
|
|
100
|
+
// 打印报告
|
|
101
|
+
printReport(mrIid, detail, reviewResult);
|
|
102
|
+
|
|
103
|
+
// 处理自动保存报告参数 -R
|
|
104
|
+
if (options.report) {
|
|
105
|
+
const reportStr = buildMarkdownReport(mrIid, detail, reviewResult);
|
|
106
|
+
const reportName = `review_report_mr_${mrIid}.md`;
|
|
107
|
+
fs.writeFileSync(path.join(process.cwd(), reportName), reportStr, 'utf-8');
|
|
108
|
+
logger.success(`审查报告已保存至当前目录: ${reportName}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 判断后续状态
|
|
112
|
+
const isSuccess = reviewResult.riskLevel !== 'high';
|
|
113
|
+
if (isSuccess) {
|
|
114
|
+
await hooks.emit('onReviewSuccess', { projectId, mrId: mrIid, result: reviewResult });
|
|
115
|
+
} else {
|
|
116
|
+
await hooks.emit('onReviewFailed', { projectId, mrId: mrIid, result: reviewResult });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 处理自动评论参数 -c
|
|
120
|
+
if (options.comment) {
|
|
121
|
+
logger.startSpinner('comment', '正在向 GitLab 发送审查总结评论...');
|
|
122
|
+
const mdBody = buildMarkdownReport(mrIid, detail, reviewResult);
|
|
123
|
+
await gitlab.addMergeRequestNote(projectId, mrIid, { body: mdBody });
|
|
124
|
+
logger.stopSpinner('comment', true, '评论提交成功。');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 处理自动合并参数 -m
|
|
128
|
+
if (options.merge && isSuccess && reviewResult.issues.filter(i => i.severity === 'error').length === 0) {
|
|
129
|
+
logger.startSpinner('merge', '达到自动合并标准,尝试执行自动合并...');
|
|
130
|
+
try {
|
|
131
|
+
await gitlab.merge(projectId, mrIid);
|
|
132
|
+
logger.stopSpinner('merge', true, '合并执行成功!');
|
|
133
|
+
await hooks.emit('onMerged', { projectId, mrId: mrIid });
|
|
134
|
+
} catch (err) {
|
|
135
|
+
logger.stopSpinner('merge', false, `合并失败: ${err.message}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!options.merge) {
|
|
140
|
+
console.log('\n');
|
|
141
|
+
const postReviewAnswer = await inquirer.prompt([{
|
|
142
|
+
type: 'list',
|
|
143
|
+
name: 'nextAction',
|
|
144
|
+
message: '代码审查完成,请选择后续操作:',
|
|
145
|
+
choices: [
|
|
146
|
+
{ name: '✅ 合并该 MR (Merge)', value: 'merge' },
|
|
147
|
+
{ name: '❌ 暂不合并,退出 (Quit)', value: 'quit' },
|
|
148
|
+
{ name: '↩️ 返回列表 (Return)', value: 'return' }
|
|
149
|
+
]
|
|
150
|
+
}]);
|
|
151
|
+
|
|
152
|
+
if (postReviewAnswer.nextAction === 'merge') {
|
|
153
|
+
logger.startSpinner('merge', '尝试执行合并...');
|
|
154
|
+
try {
|
|
155
|
+
await gitlab.merge(projectId, mrIid);
|
|
156
|
+
logger.stopSpinner('merge', true, '合并执行成功!');
|
|
157
|
+
await hooks.emit('onMerged', { projectId, mrId: mrIid });
|
|
158
|
+
} catch (err) {
|
|
159
|
+
logger.stopSpinner('merge', false, `合并失败: ${err.message}`);
|
|
160
|
+
}
|
|
161
|
+
} else if (postReviewAnswer.nextAction === 'return') {
|
|
162
|
+
const listCmd = require('./list');
|
|
163
|
+
return listCmd();
|
|
164
|
+
} else {
|
|
165
|
+
logger.info('操作已完成,退出审查。');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
} catch (e) {
|
|
170
|
+
logger.stopSpinner('fetching', false);
|
|
171
|
+
logger.stopSpinner('ai-thinking', false);
|
|
172
|
+
logger.error(`审查过程中发生异常: ${e.message}`);
|
|
173
|
+
await hooks.emit('onReviewFailed', { projectId, mrId: mrIid, error: e.message });
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
async function fetchFileContents(gitlabClient, projectId, ref, changes, requestedFiles, contentTypes) {
|
|
178
|
+
const contents = [];
|
|
179
|
+
for (let i = 0; i < requestedFiles.length; i++) {
|
|
180
|
+
const filePath = requestedFiles[i];
|
|
181
|
+
const contentType = contentTypes[i] || 'diff';
|
|
182
|
+
try {
|
|
183
|
+
if (contentType === 'full') {
|
|
184
|
+
const content = await gitlabClient.getFileContent(projectId, filePath, ref);
|
|
185
|
+
contents.push({ path: filePath, contentType: 'full', content });
|
|
186
|
+
} else {
|
|
187
|
+
const fileChange = changes.find(f => f.new_path === filePath);
|
|
188
|
+
contents.push({
|
|
189
|
+
path: filePath,
|
|
190
|
+
contentType: 'diff',
|
|
191
|
+
content: fileChange ? fileChange.diff : '(文件未找到)'
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
} catch (err) {
|
|
195
|
+
contents.push({ path: filePath, contentType, content: `(获取文件内容失败: ${err.message})` });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return contents;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function printReport(mrIid, detail, result) {
|
|
202
|
+
console.log('\n' + chalk.bold.inverse(` ====== 📋 审查报告:MR !${mrIid} (${detail.title}) ====== `) + '\n');
|
|
203
|
+
console.log(chalk.bold('📈 整体概述:'));
|
|
204
|
+
console.log(chalk.gray(result.summary) + '\n');
|
|
205
|
+
|
|
206
|
+
let errorCount = 0;
|
|
207
|
+
let warningCount = 0;
|
|
208
|
+
|
|
209
|
+
console.log(chalk.bold('⚠️ 发现的问题:'));
|
|
210
|
+
if (!result.issues || result.issues.length === 0) {
|
|
211
|
+
console.log(chalk.green(' 没有任何问题,代码很棒!\n'));
|
|
212
|
+
} else {
|
|
213
|
+
result.issues.forEach(issue => {
|
|
214
|
+
let prefix = '';
|
|
215
|
+
let colorMsg = issue.description;
|
|
216
|
+
if (issue.severity === 'error') {
|
|
217
|
+
prefix = chalk.bgRed.white(' ERROR ');
|
|
218
|
+
colorMsg = chalk.red(issue.description);
|
|
219
|
+
errorCount++;
|
|
220
|
+
} else if (issue.severity === 'warning') {
|
|
221
|
+
prefix = chalk.bgYellow.black(' WARN ');
|
|
222
|
+
colorMsg = chalk.yellow(issue.description);
|
|
223
|
+
warningCount++;
|
|
224
|
+
} else {
|
|
225
|
+
prefix = chalk.bgBlue.white(' INFO ');
|
|
226
|
+
colorMsg = chalk.blue(issue.description);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
console.log(`${prefix} ${chalk.underline(issue.file)}${issue.line ? ':'+issue.line : ''} - ${chalk.bold(issue.title)}`);
|
|
230
|
+
console.log(` 💡 ${colorMsg}\n`);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (result.suggestions && result.suggestions.length > 0) {
|
|
235
|
+
console.log(chalk.bold('💡 改进建议:'));
|
|
236
|
+
result.suggestions.forEach(s => console.log(` - ${s}`));
|
|
237
|
+
console.log();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
console.log('--------------------------------------------------');
|
|
241
|
+
const sumText = `风险等级: ${result.riskLevel.toUpperCase()} | 严重错误: ${chalk.red(errorCount)} | 优化建议: ${chalk.yellow(warningCount)}`;
|
|
242
|
+
console.log(sumText);
|
|
243
|
+
console.log('--------------------------------------------------\n');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function buildMarkdownReport(mrIid, detail, result) {
|
|
247
|
+
let md = `# AI 代码审查报告 (MR !${mrIid})\n\n`;
|
|
248
|
+
md += `**标题**: ${detail.title}\n`;
|
|
249
|
+
md += `**风险评级**: ${result.riskLevel.toUpperCase()}\n\n`;
|
|
250
|
+
md += `## 整体概述\n${result.summary}\n\n`;
|
|
251
|
+
|
|
252
|
+
if (result.issues && result.issues.length > 0) {
|
|
253
|
+
md += `## 发现的问题\n\n`;
|
|
254
|
+
result.issues.forEach(issue => {
|
|
255
|
+
const icon = issue.severity === 'error' ? '🔴' : (issue.severity === 'warning' ? '🟡' : '🔵');
|
|
256
|
+
md += `### ${icon} ${issue.title} (${issue.file}${issue.line ? `:${issue.line}` : ''})\n`;
|
|
257
|
+
md += `${issue.description}\n\n`;
|
|
258
|
+
});
|
|
259
|
+
} else {
|
|
260
|
+
md += `## 发现的问题\n未发现明显问题。\n\n`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (result.suggestions && result.suggestions.length > 0) {
|
|
264
|
+
md += `## 改进建议\n`;
|
|
265
|
+
result.suggestions.forEach(s => md += `- ${s}\n`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
md += `\n> *本报告由 \`hong-review\` CLI 工具自动生成。*`;
|
|
269
|
+
return md;
|
|
270
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
const AGENT_SYSTEM_PROMPT = `你是一个资深的代码审查专家。你的任务是分析合并请求(MR)中的代码变更,找出潜在问题并给出审查建议。
|
|
2
|
+
|
|
3
|
+
## 工作流程
|
|
4
|
+
|
|
5
|
+
1. 首先,你会收到一个待审查的文件列表(包含文件路径、变更类型、变更行数)
|
|
6
|
+
2. 你需要根据文件列表,决定需要查看哪些文件的详细内容
|
|
7
|
+
3. 你可以请求查看文件的 diff(差异)或 full(完整内容)
|
|
8
|
+
4. 获取足够信息后,生成最终的审查报告
|
|
9
|
+
|
|
10
|
+
## 输出格式
|
|
11
|
+
|
|
12
|
+
你必须始终以 JSON 格式输出,有两种可能的动作:
|
|
13
|
+
|
|
14
|
+
### 动作1: 请求查看文件
|
|
15
|
+
当你需要更多信息时,输出:
|
|
16
|
+
\`\`\`json
|
|
17
|
+
{
|
|
18
|
+
"action": "request_files",
|
|
19
|
+
"files": ["文件路径1", "文件路径2"],
|
|
20
|
+
"contentTypes": ["diff", "full"],
|
|
21
|
+
"reasoning": "说明为什么需要查看这些文件"
|
|
22
|
+
}
|
|
23
|
+
\`\`\`
|
|
24
|
+
|
|
25
|
+
### 动作2: 生成审查报告
|
|
26
|
+
当你已获取足够信息,可以做出判断时,输出:
|
|
27
|
+
\`\`\`json
|
|
28
|
+
{
|
|
29
|
+
"action": "generate_report",
|
|
30
|
+
"result": {
|
|
31
|
+
"riskLevel": "low|medium|high",
|
|
32
|
+
"summary": "本次变更的整体总结",
|
|
33
|
+
"issues": [
|
|
34
|
+
{
|
|
35
|
+
"severity": "info|warning|error",
|
|
36
|
+
"file": "文件路径",
|
|
37
|
+
"line": 12,
|
|
38
|
+
"title": "问题标题",
|
|
39
|
+
"description": "问题描述"
|
|
40
|
+
}
|
|
41
|
+
],
|
|
42
|
+
"suggestions": ["改进建议1", "改进建议2"]
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
\`\`\`
|
|
46
|
+
|
|
47
|
+
## 注意事项
|
|
48
|
+
|
|
49
|
+
- 优先查看变更行数多的文件、核心业务文件、涉及安全的文件
|
|
50
|
+
- 一次不要请求太多文件(建议 3-5 个),分批请求更高效
|
|
51
|
+
- 请用中文输出 reasoning 和审查结果内容`;
|
|
52
|
+
|
|
53
|
+
function generateId() {
|
|
54
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function formatFileList(files) {
|
|
58
|
+
const statusMap = {
|
|
59
|
+
added: '新增',
|
|
60
|
+
modified: '修改',
|
|
61
|
+
deleted: '删除',
|
|
62
|
+
renamed: '重命名'
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const lines = files.map(f => {
|
|
66
|
+
const status = statusMap[f.status] || f.status;
|
|
67
|
+
const changes = f.additions + f.deletions;
|
|
68
|
+
return `- [${status}] ${f.path} (${changes} 行变更)`;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return `## 待审查的代码变更
|
|
72
|
+
|
|
73
|
+
以下文件发生了变更,请告诉我你需要查看哪些文件的详细内容:
|
|
74
|
+
|
|
75
|
+
${lines.join('\n')}
|
|
76
|
+
|
|
77
|
+
请告诉我你需要查看哪些文件的详细内容来做出准确的代码审查判断。`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function formatFileContents(contents) {
|
|
81
|
+
const parts = contents.map(c => {
|
|
82
|
+
const typeLabel = c.contentType === 'diff' ? 'Diff 内容' : '完整文件内容';
|
|
83
|
+
return `### 📄 ${c.path} (${typeLabel})\n\n\`\`\`\n${c.content}\n\`\`\``;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return `已提供 ${contents.length} 个文件的变更内容:\n\n${parts.join('\n\n')}\n\n请继续分析,如果需要更多信息请继续请求,如果信息足够请生成审查报告。`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseAgentResponse(content) {
|
|
90
|
+
try {
|
|
91
|
+
const jsonMatch = content.match(/```json\s*([\s\S]*?)\s*```/);
|
|
92
|
+
const jsonStr = jsonMatch ? jsonMatch[1] : content;
|
|
93
|
+
const cleanedJson = jsonStr.trim();
|
|
94
|
+
const parsed = JSON.parse(cleanedJson);
|
|
95
|
+
|
|
96
|
+
if (parsed.action === 'request_files' || parsed.action === 'generate_report') {
|
|
97
|
+
return parsed;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function createUserMessage(content, label, files) {
|
|
106
|
+
return {
|
|
107
|
+
id: generateId(),
|
|
108
|
+
role: 'user',
|
|
109
|
+
content,
|
|
110
|
+
timestamp: new Date(),
|
|
111
|
+
label,
|
|
112
|
+
files
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function createAssistantMessage(content, parsedAction) {
|
|
117
|
+
let label = 'JSON';
|
|
118
|
+
if (parsedAction) {
|
|
119
|
+
if (parsedAction.action === 'request_files') label = '请求更多上下文';
|
|
120
|
+
else if (parsedAction.action === 'generate_report') label = '生成报告';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
id: generateId(),
|
|
125
|
+
role: 'assistant',
|
|
126
|
+
content,
|
|
127
|
+
timestamp: new Date(),
|
|
128
|
+
parsedAction,
|
|
129
|
+
label
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = {
|
|
134
|
+
AGENT_SYSTEM_PROMPT,
|
|
135
|
+
generateId,
|
|
136
|
+
formatFileList,
|
|
137
|
+
formatFileContents,
|
|
138
|
+
parseAgentResponse,
|
|
139
|
+
createUserMessage,
|
|
140
|
+
createAssistantMessage
|
|
141
|
+
};
|
package/src/core/ai.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
const axios = require('axios');
|
|
2
|
+
|
|
3
|
+
class AIClient {
|
|
4
|
+
constructor(config) {
|
|
5
|
+
this.apiKey = config.apiKey;
|
|
6
|
+
this.baseURL = config.baseURL || 'https://api.openai.com/v1';
|
|
7
|
+
this.model = config.model || 'gpt-4o';
|
|
8
|
+
this.requestTimeout = 180000;
|
|
9
|
+
|
|
10
|
+
this.client = axios.create({
|
|
11
|
+
baseURL: this.baseURL,
|
|
12
|
+
headers: {
|
|
13
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
14
|
+
'Content-Type': 'application/json'
|
|
15
|
+
},
|
|
16
|
+
timeout: this.requestTimeout
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
setTimeout(timeout) {
|
|
21
|
+
this.client.defaults.timeout = timeout;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async chatRequest(messages, jsonMode = false) {
|
|
25
|
+
const requestBody = {
|
|
26
|
+
model: this.model,
|
|
27
|
+
messages
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
if (jsonMode) {
|
|
31
|
+
requestBody.response_format = { type: 'json_object' };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const response = await this.client.post('/chat/completions', requestBody);
|
|
36
|
+
return response.data.choices[0]?.message?.content || '';
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error.response) {
|
|
39
|
+
throw new Error(`AI API error: ${error.response.status} - ${JSON.stringify(error.response.data)}`);
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`AI API error: ${error.message}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async analyzeMergeRequest(files) {
|
|
46
|
+
try {
|
|
47
|
+
const fileChanges = files.map(f => {
|
|
48
|
+
const truncatedDiff = f.diff.length > 1500
|
|
49
|
+
? f.diff.slice(0, 1500) + '\n... (内容已截断)'
|
|
50
|
+
: f.diff;
|
|
51
|
+
return `\n【文件: ${f.path}】\n${truncatedDiff}`;
|
|
52
|
+
}).join('\n---\n');
|
|
53
|
+
|
|
54
|
+
const maxLength = 15000;
|
|
55
|
+
const content = fileChanges.length > maxLength
|
|
56
|
+
? fileChanges.slice(0, maxLength) + '\n\n... (更多文件变更已省略)'
|
|
57
|
+
: fileChanges;
|
|
58
|
+
|
|
59
|
+
const prompt = `你是一个资深的代码审查专家。请分析以下合并请求(MR)中所有文件的代码变更。
|
|
60
|
+
## 代码变更内容:
|
|
61
|
+
${content}
|
|
62
|
+
|
|
63
|
+
## 请按以下格式输出分析报告:
|
|
64
|
+
### 📋 整体概述
|
|
65
|
+
### 📁 主要改动点
|
|
66
|
+
### ⚠️ 需要关注
|
|
67
|
+
### ✅ 审查建议
|
|
68
|
+
请用中文、通俗易懂的语言回答。`;
|
|
69
|
+
|
|
70
|
+
return await this.chatRequest([{ role: 'user', content: prompt }]);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
console.error('Failed to analyze MR:', error);
|
|
73
|
+
return 'AI分析服务暂时不可用,请检查API Key配置';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async chatWithJsonMode(systemPrompt, messages) {
|
|
78
|
+
const allMessages = [
|
|
79
|
+
{ role: 'system', content: systemPrompt },
|
|
80
|
+
...messages
|
|
81
|
+
];
|
|
82
|
+
return await this.chatRequest(allMessages, true);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async testConnection() {
|
|
86
|
+
const startTime = Date.now();
|
|
87
|
+
try {
|
|
88
|
+
const response = await this.chatRequest([
|
|
89
|
+
{ role: 'user', content: '请回复"OK"两个字母,不要说其他任何内容。' }
|
|
90
|
+
]);
|
|
91
|
+
const responseTime = Date.now() - startTime;
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
success: true,
|
|
95
|
+
message: `连接成功!响应很快。${response.includes('OK')?'':'响应:'+response}`,
|
|
96
|
+
responseTime
|
|
97
|
+
};
|
|
98
|
+
} catch (error) {
|
|
99
|
+
return {
|
|
100
|
+
success: false,
|
|
101
|
+
message: error.message,
|
|
102
|
+
responseTime: Date.now() - startTime
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = AIClient;
|