job51-gitlab-cr-node-jt-1 2.3.6 → 2.3.8

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 (3) hide show
  1. package/index.js +716 -669
  2. package/package.json +1 -1
  3. package/patch.js +81 -0
package/index.js CHANGED
@@ -1,669 +1,716 @@
1
- #!/usr/bin/env node
2
-
3
- const { spawn } = require('child_process');
4
- const path = require('path');
5
- const fs = require('fs');
6
- const { GitLabAPIClient, debugLog, extractReportContent } = require('./utils');
7
-
8
- class GitLabCodeReviewer {
9
- constructor(gitlabToken, gitlabUrl = null) {
10
- debugLog(`GitLab客户端初始化: ${gitlabUrl}`);
11
- this.gitlabClient = new GitLabAPIClient(gitlabToken, gitlabUrl);
12
- }
13
-
14
- /**
15
- * 获取合并请求的diff信息
16
- * @param {number} projectId GitLab项目ID
17
- * @param {number} mergeRequestIid 合并请求IID
18
- * @returns {Promise<Array>} diff块数组
19
- */
20
- async getMergeRequestDiffs(projectId, mergeRequestIid) {
21
- debugLog(`开始获取项目 ${projectId} 合并请求 ${mergeRequestIid} 的diff信息`);
22
- try {
23
- let allDiffs = [];
24
- let page = 1;
25
- const perPage = 20;
26
-
27
- do {
28
- const url = `/projects/${projectId}/merge_requests/${mergeRequestIid}/diffs?per_page=${perPage}&page=${page}`;
29
- const data = await this.gitlabClient.callGitLabAPI(url);
30
-
31
- if (data && data.length > 0) {
32
- allDiffs = allDiffs.concat(data);
33
- debugLog(`成功获取到第${page}页,${data.length}个diff块`);
34
- page++;
35
- } else {
36
- break; // 没有更多数据
37
- }
38
- } while (true);
39
-
40
- debugLog(`总共获取到 ${allDiffs.length} 个diff块`);
41
- return allDiffs;
42
- } catch (error) {
43
- console.error('获取diff信息失败:', error.message);
44
- throw error;
45
- }
46
- }
47
-
48
-
49
-
50
- /**
51
- * 使用Claude对单个diff块进行代码审核
52
- * @param {Object} diff 单个diff对象
53
- * @returns {Promise<string>} 审核结果
54
- */
55
- async reviewDiffWithClaude(diff) {
56
- debugLog(`开始审核文件: ${diff.new_path || diff.old_path}, diff长度: ${diff.diff.length}`);
57
-
58
- const prompt = `使用技能进行代码审查,变更内容:${diff.diff}`;
59
-
60
- debugLog(`开始调用本地AI命令审核文件, prompt:${prompt}`)
61
- // 最多重试3次,直到结果包含"🤖 AI 代码审查结果"或达到最大重试次数
62
- let attempts = 0;
63
- const maxAttempts = 5;
64
- let claudeResult = null;
65
-
66
- while (attempts < maxAttempts) {
67
- attempts++;
68
- try {
69
- debugLog(`调用本地AI命令审核文件 (尝试 ${attempts}/${maxAttempts})`);
70
-
71
- // 直接将prompt内容传递给Claude命令
72
- claudeResult = await runClaudeCommand(prompt);
73
-
74
- debugLog(`本地AI命令审核完成,AI审核结果: ${claudeResult}`);
75
-
76
- // 检查结果是否包含"🤖 AI 代码审查结果",如果包含则返回结果
77
- if (claudeResult && claudeResult.includes('🤖 AI 代码审查结果')) {
78
- debugLog(`AI审核成功,包含"🤖 AI 代码审查结果" (尝试 ${attempts})`);
79
-
80
-
81
- // 提取REPORT标签内容并返回
82
- // 提取 REPORT 标签和 LINE_INFO 标签内容并返回对象
83
- return extractReportContent(claudeResult);
84
- } else {
85
- debugLog(`AI审核结果不包含"🤖 AI 代码审查结果" (尝试 ${attempts}),将重试...`);
86
- if (attempts >= maxAttempts) {
87
- debugLog(`已达到最大重试次数 ${maxAttempts},返回最后一次结果`);
88
-
89
- // 验证报告有效性:检查 LINE_INFO 是否为空且报告内容是否过短
90
- const lineInfoEmpty = claudeResult.includes('<LINE_INFO>[]</LINE_INFO>');
91
- const reportTooShort = claudeResult.length < 100;
92
-
93
- // 如果 LINE_INFO 为空且报告内容很短,说明无实质问题
94
- if (lineInfoEmpty && reportTooShort) {
95
- debugLog(`报告无实质问题,修正为标准无问题格式`);
96
- claudeResult = '<REPORT>\n## 🤖 AI 代码审查结果\n\n</REPORT>\n\n<LINE_INFO>\n[]\n</LINE_INFO>';
97
- }
98
-
99
- return extractReportContent(claudeResult);
100
- }
101
- }
102
- } catch (error) {
103
- console.error(`AI审核失败 (尝试 ${attempts}/${maxAttempts}):`, error.message);
104
- if (attempts >= maxAttempts) {
105
- return `审核失败: ${error.message}`;
106
- }
107
- // 等待一段时间后重试
108
- await new Promise(resolve => setTimeout(resolve, 1000 * attempts)); // 递增等待时间
109
- }
110
- }
111
- }
112
-
113
- /**
114
- * 对合并请求的所有diff进行审核(逐个处理并立即发布评论)
115
- * @param {number} projectId GitLab项目ID
116
- * @param {number} mergeRequestIid 合并请求IID
117
- * @returns {Promise<Array>} 审核结果数组
118
- */
119
- async reviewMergeRequest(projectId, mergeRequestIid, maxConcurrency = 3) {
120
- debugLog(`开始审核项目 ${projectId} 的合并请求 ${mergeRequestIid}`);
121
-
122
- // 获取diff信息
123
- const diffs = await this.getMergeRequestDiffs(projectId, mergeRequestIid);
124
- debugLog(`获取到 ${diffs.length} 个diff块`);
125
-
126
- // 对每个diff进一步按变更块拆分并审核
127
- debugLog('开始处理所有diff块的变更块拆分');
128
-
129
- // 创建处理单个块的函数
130
- const processBlock = async (diffObject, blockIndex) => {
131
- // 创建临时文件存储diff内容,文件地址选择当前文件夹下,避免权限问题
132
- const fileName = `temp-diff-block-${Date.now()}-${blockIndex}.diff`;
133
- const tmpFileName = path.join(process.cwd(), fileName);
134
-
135
- try {
136
- // 构造包含元数据的 diff 内容
137
- const diffContentWithMetadata = `=== File Information ===
138
- New Path: ${diffObject.new_path || 'N/A'}
139
- Old Path: ${diffObject.old_path || 'N/A'}
140
- Block Index: ${blockIndex}
141
- === Diff Content ===
142
- ${diffObject.diff}`;
143
-
144
- // diff内容写入临时文件
145
- fs.writeFileSync(tmpFileName, diffContentWithMetadata);
146
-
147
- // 审核当前块(传入临时的文件而不是直接的diff内容)
148
- const review_result = await this.reviewDiffWithClaudeUsingFile(tmpFileName);
149
- const blockObj = { ...diffObject, review_result, temp_file_path: tmpFileName };
150
-
151
- // 检查审查结果中是否包含严重问题,只有包含严重问题才发布评论
152
- if (blockObj.review_result && blockObj.review_result.reportContent && blockObj.review_result.reportContent.includes('🔴 严重问题')) {
153
- // 立即发布评论
154
- await this.postSingleCommentToGitLab(projectId, mergeRequestIid, {
155
- diff_info: blockObj,
156
- block_index: blockObj.block_index,
157
- review_result: blockObj.review_result,
158
- });
159
- } else {
160
- debugLog(`该块不包含严重问题,跳过评论发布: ${blockObj.new_path || blockObj.old_path}#${blockObj.block_index}`);
161
- }
162
-
163
- return {
164
- diff_info: blockObj,
165
- block_index: blockObj.block_index,
166
- review_result: blockObj.review_result,
167
- temp_file_path: tmpFileName,
168
- };
169
- } catch (error) {
170
- throw error;
171
- } finally {
172
- try {
173
- if (fs.existsSync(tmpFileName)) {
174
- fs.unlinkSync(tmpFileName);
175
- }
176
- } catch (cleanupError) {
177
- console.error('清理临时文件失败:', cleanupError.message);
178
- }
179
- }
180
- };
181
-
182
- // 收集所有需要处理的块
183
- const allBlocks = [];
184
- for (const diff of diffs) {
185
- const diffObjects = this.getDiffBlocks(diff);
186
- for (let i = 0; i < diffObjects.length; i++) {
187
- // 更新块索引
188
- diffObjects[i].block_index = i;
189
- allBlocks.push({ diffObject: diffObjects[i], blockIndex: i });
190
- }
191
- }
192
-
193
- // 使用线程池控制并发数量
194
- const results = await this.processWithThreadPool(allBlocks, processBlock, maxConcurrency);
195
-
196
- debugLog(`总共处理了 ${results.length} 个diff block块`);
197
-
198
- debugLog('所有diff块审核并发布评论完成');
199
-
200
- return results;
201
- }
202
-
203
- /**
204
- * 使用Claude对单个diff文件进行代码审核
205
- * @param {string} filePath 临时文件路径
206
- * @returns {Promise<string>} 审核结果
207
- */
208
- async reviewDiffWithClaudeUsingFile(filePath) {
209
- debugLog(`开始审核文件: ${filePath}`);
210
-
211
- const prompt = `请调用 simple-code-review 技能审核代码变更。
212
- 文件路径:${filePath}
213
-
214
- **重要审查规则**:
215
- 1. **只审查当前 diff 块内的新增代码**(+ 开头的行)
216
- 2. **只报告当前 diff 块内能直接发现的问题**,不要追踪方法调用链去其他地方报告问题
217
- 3. **同一问题只报告一次**:如 import 行、声明行、调用行都有问题,只在真正会出错的调用行报告一次
218
- 4. **禁止报告的位置**:import 语句、类定义、方法签名、依赖注入声明行
219
-
220
- **输出要求**:
221
- 1. 严格按照 .claude/skills/simple-code-review/SKILL.md 中定义的模板格式输出
222
- 2. **每个模块不是必须的**:没有对应问题时,完全省略该模块(不输出标题)
223
- 3. 必须以 <REPORT> 开始,以 </REPORT> 结束
224
- 4. **必须输出 '<LINE_INFO>' 标签**,包含所有问题的行号信息(无问题时输出空数组 [])
225
- 5. 不要输出任何额外的解释、问候或总结文本`;
226
- //打印
227
- debugLog(`Claude命令: ${prompt}`);
228
- // 最多重试5次,直到结果包含"🤖 AI 代码审查结果"或达到最大重试次数
229
- let attempts = 0;
230
- const maxAttempts = 5;
231
- let claudeResult = null;
232
-
233
- while (attempts < maxAttempts) {
234
- attempts++;
235
- try {
236
- debugLog(`调用本地AI命令审核文件 (尝试 ${attempts}/${maxAttempts})`);
237
-
238
- // 直接将prompt内容(包含文件路径)传递给Claude命令
239
- claudeResult = await runClaudeCommand(prompt);
240
- //若结果为空,则记录日志
241
- if (!claudeResult) {
242
- debugLog(`本地AI命令审核结果为空`);
243
- }
244
- debugLog(`本地AI命令审核完成,审核结果为:${claudeResult}`);
245
- // 检查结果是否包含"🤖 AI 代码审查结果",如果包含则返回结果
246
- if (claudeResult && claudeResult.includes('🤖 AI 代码审查结果')) {
247
- debugLog(`AI审核成功,包含"🤖 AI 代码审查结果" (尝试 ${attempts})`);
248
-
249
- // 提取REPORT标签内容并返回
250
- // 提取 REPORT 标签和 LINE_INFO 标签内容并返回对象
251
- return extractReportContent(claudeResult);
252
- } else {
253
- debugLog(`AI审核结果不包含"🤖 AI 代码审查结果" (尝试 ${attempts}),将重试...`);
254
- if (attempts >= maxAttempts) {
255
- debugLog(`已达到最大重试次数 ${maxAttempts},返回最后一次结果`);
256
-
257
- // 验证报告有效性:检查 LINE_INFO 是否为空且报告内容是否过短
258
- const lineInfoEmpty = claudeResult.includes('<LINE_INFO>[]</LINE_INFO>');
259
- const reportTooShort = claudeResult.length < 100;
260
-
261
- // 如果 LINE_INFO 为空且报告内容很短,说明无实质问题
262
- if (lineInfoEmpty && reportTooShort) {
263
- debugLog(`报告无实质问题,修正为标准无问题格式`);
264
- claudeResult = '<REPORT>\n## 🤖 AI 代码审查结果\n\n</REPORT>\n\n<LINE_INFO>\n[]\n</LINE_INFO>';
265
- }
266
-
267
- return extractReportContent(claudeResult);
268
- }
269
- }
270
- } catch (error) {
271
- console.error(`AI审核失败 (尝试 ${attempts}/${maxAttempts}):`, error.message);
272
- if (attempts >= maxAttempts) {
273
- return `审核失败: ${error.message}`;
274
- }
275
- // 等待一段时间后重试
276
- await new Promise(resolve => setTimeout(resolve, 1000 * attempts)); // 递增等待时间
277
- }
278
- }
279
- }
280
-
281
- /**
282
- * 使用线程池控制并发执行
283
- * @param {Array} tasks 任务数组
284
- * @param {Function} processor 任务处理器
285
- * @param {number} maxConcurrency 最大并发数
286
- * @returns {Promise<Array>} 处理结果数组
287
- */
288
- async processWithThreadPool(tasks, processor, maxConcurrency = 3) {
289
- debugLog(`开始使用线程池处理 ${tasks.length} 个任务,最大并发数: ${maxConcurrency}`);
290
-
291
- const results = [];
292
- const executing = [];
293
-
294
- for (let i = 0; i < tasks.length; i++) {
295
- const task = tasks[i];
296
-
297
- // 创建一个异步任务
298
- const promise = processor(task.diffObject, task.blockIndex)
299
- .then(result => {
300
- results.push(result);
301
- // 从执行队列中移除已完成的任务
302
- const index = executing.indexOf(promise);
303
- if (index !== -1) {
304
- executing.splice(index, 1);
305
- }
306
- debugLog(`----------任务完成: ${i + 1}/${tasks.length} (${((i + 1) / tasks.length * 100).toFixed(1)}%)----------`);
307
- return result;
308
- });
309
-
310
- executing.push(promise);
311
-
312
- debugLog(`----------开始处理任务: ${i + 1}/${tasks.length} (${((i + 1) / tasks.length * 100).toFixed(1)}%)----------`);
313
-
314
- // 如果达到最大并发数,等待至少一个任务完成
315
- if (executing.length >= maxConcurrency) {
316
- await Promise.race(executing);
317
- }
318
- }
319
-
320
- // 等待所有剩余任务完成
321
- await Promise.all(executing);
322
-
323
- debugLog(`线程池处理完成,共处理 ${results.length} 个任务`);
324
-
325
- return results;
326
- }
327
-
328
- /**
329
- * 按照变更块分割数组并提取行号信息
330
- * @param {Object} diffObj 包含diff内容和文件信息的对象
331
- * @returns {Array} 包含diff块内容和行号信息的对象数组
332
- */
333
- getDiffBlocks(diffObj) {
334
- const regex = /(?=@@\s-\d+(?:,\d+)?\s\+\d+(?:,\d+)?\s@@)/g;
335
- const diffBlocks = diffObj.diff.split(regex);
336
- // 过滤掉空块并提取行号信息
337
- return diffBlocks
338
- .filter(block => block.trim() !== '')
339
- .map(block => {
340
- // 解析diff头信息 @@ -old_start,old_count +new_start,new_count @@
341
- const headerRegex = /@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
342
- const headerMatch = block.match(headerRegex);
343
-
344
- // 取block第一行是否是加号或者减号开头还是没有开头,block需要用.split(/\r?\n/)
345
- const firstLine = block.split(/\r?\n/)[1];
346
- // 取出第一行的第一个字符
347
- const firstLineFirstChar = firstLine.charAt(0);
348
-
349
- let old_start = 0;
350
- let old_count = 0;
351
- let new_start = 0;
352
- let new_count = 0;
353
-
354
- if (headerMatch) {
355
- old_start = parseInt(headerMatch[1], 10);
356
- old_count = headerMatch[2] ? parseInt(headerMatch[2], 10) : 1;
357
- new_start = parseInt(headerMatch[3], 10);
358
- new_count = headerMatch[4] ? parseInt(headerMatch[4], 10) : 1;
359
- }
360
-
361
- return {
362
- diff: block,
363
- new_path: diffObj.new_path,
364
- old_path: diffObj.old_path,
365
- a_mode: diffObj.a_mode,
366
- b_mode: diffObj.b_mode,
367
- new_file: diffObj.new_file,
368
- renamed_file: diffObj.renamed_file,
369
- deleted_file: diffObj.deleted_file,
370
- generated_file: diffObj.generated_file,
371
- block_index: null, // 会在后续分配
372
- line_info: {
373
- old_start,
374
- old_count,
375
- new_start,
376
- new_count,
377
- firstLineFirstChar
378
- }
379
- };
380
- });
381
- }
382
-
383
- /**
384
- * 从 LINE_INFO 标签字符串中解析行号信息
385
- * @param {string} lineInfoTag LINE_INFO 标签字符串,如 "<LINE_INFO>[...]</LINE_INFO>"
386
- * @returns {Object|null} 行号信息对象 {new_path, new_line, old_path, old_line} 或 null
387
- */
388
- parseLineInfoFromReviewResult(lineInfoTag) {
389
- if (!lineInfoTag) return null;
390
-
391
- try {
392
- // 从标签中提取 JSON 内容:<LINE_INFO>[{...}]</LINE_INFO>
393
- const jsonContent = lineInfoTag.replace(/<LINE_INFO>\s*/g, '').replace(/\s*<\/LINE_INFO>/g, '').trim();
394
-
395
- debugLog(`解析 LINE_INFO 原始内容:${lineInfoTag}`);
396
- debugLog(`解析 LINE_INFO JSON 内容:${jsonContent}`);
397
-
398
- // 如果是空数组,返回 null 使用后备方案
399
- if (jsonContent === '[]') {
400
- debugLog('LINE_INFO 为空数组,使用 diff 块起始行号作为后备方案');
401
- return null;
402
- }
403
-
404
- const lineInfoArray = JSON.parse(jsonContent);
405
- debugLog(`解析 LINE_INFO JSON 成功:${JSON.stringify(lineInfoArray)}`);
406
-
407
- if (Array.isArray(lineInfoArray) && lineInfoArray.length > 0) {
408
- debugLog(`从 LINE_INFO 中解析出行号信息:${JSON.stringify(lineInfoArray)}`);
409
- // 返回第一个问题的行号信息
410
- const lineInfo = lineInfoArray[0];
411
- // GitLab API 只需要 new_path 和 new_line
412
- return {
413
- new_path: lineInfo.new_path,
414
- new_line: lineInfo.new_line
415
- };
416
- }
417
- } catch (error) {
418
- debugLog(`解析 LINE_INFO JSON 失败:${error.message}`);
419
- }
420
-
421
- debugLog('无法从 LINE_INFO 中解析行号,将使用 diff 块起始行号作为后备方案');
422
- return null;
423
- }
424
-
425
- /**
426
- * 获取合并请求的最新版本信息
427
- * @param {number} projectId GitLab项目ID
428
- * @param {number} mergeRequestIid 合并请求IID
429
- * @returns {Promise<Object>} 版本信息
430
- */
431
- async getMergeRequestVersions(projectId, mergeRequestIid) {
432
- try {
433
- const data = await this.gitlabClient.callGitLabAPI(
434
- `/projects/${projectId}/merge_requests/${mergeRequestIid}/versions`
435
- );
436
- // 返回最新版本信息
437
- return data[0] || null;
438
- } catch (error) {
439
- console.error('获取MR版本信息失败:', error.message);
440
- throw error;
441
- }
442
- }
443
-
444
- /**
445
- * 发布单个评论到GitLab MR
446
- * @param {number} projectId GitLab项目ID
447
- * @param {number} mergeRequestIid 合并请求IID
448
- * @param {Object} result 单个审核结果
449
- */
450
- async postSingleCommentToGitLab(projectId, mergeRequestIid, result) {
451
- try {
452
- const { diff_info, review_result, block_index } = result;
453
- // review_result 现在是对象:{ reportContent, lineInfo }
454
- const reviewContent = review_result?.reportContent || '';
455
- const lineInfoTag = review_result?.lineInfo || null;
456
- const file_path = diff_info.new_path || diff_info.old_path;
457
- const file_path_with_line = `${file_path}#L${block_index}`;
458
-
459
- // 获取MR版本信息
460
- const versionInfo = await this.getMergeRequestVersions(projectId, mergeRequestIid);
461
- if (!versionInfo) {
462
- console.error('无法获取MR版本信息');
463
- return;
464
- }
465
-
466
- const baseSha = versionInfo.base_commit_sha;
467
- const headSha = versionInfo.head_commit_sha;
468
- const startSha = versionInfo.start_commit_sha;
469
-
470
- debugLog(`获取到版本信息 - base: ${baseSha}, head: ${headSha}, start: ${startSha}`);
471
-
472
- // 直接使用diff_info中的line_info
473
- const lineInfo = diff_info.line_info;
474
-
475
- if (!lineInfo && !lineInfoTag) {
476
- // 如果没有行号信息,创建一般讨论
477
- await this.createGeneralDiscussion(projectId, mergeRequestIid, file_path_with_line, reviewContent);
478
- debugLog(`评论已发布到文件 ${file_path_with_line} (无法解析行号)`);
479
- return;
480
- }
481
-
482
- // 如果 lineInfo 里的 firstLineFirstChar 为 +,则 targetLine 为 new_path 的 new_start 行;为 -,则 targetLine 为 old_path 的 old_start 行
483
- // GitLab API 要求:diff 评论只能传递 new_line 或 old_line 中的一个,不能同时传递
484
- let targetLine = lineInfo.firstLineFirstChar === '+'
485
- ? { new_line: lineInfo.new_start, new_path: diff_info.new_path }
486
- : lineInfo.firstLineFirstChar === '-'
487
- ? { old_line: lineInfo.old_start, old_path: diff_info.old_path }
488
- : { new_line: lineInfo.new_start, new_path: diff_info.new_path };
489
-
490
- // 尝试从审查结果中解析更精确的行号信息(从 LINE_INFO 标签)
491
- const parsedLineInfo = lineInfoTag ? this.parseLineInfoFromReviewResult(lineInfoTag) : null;
492
- if (parsedLineInfo) {
493
- // 验证解析的行号是否在当前 diff 块范围内
494
- const newStart = lineInfo.new_start;
495
- const newCount = lineInfo.new_count || 0;
496
- const newEnd = newStart + newCount - 1;
497
- debugLog(`diff 块信息:new_start=${newStart}, new_count=${newCount}, 范围=[${newStart}, ${newEnd}]`);
498
- debugLog(`解析的行号:${parsedLineInfo.new_line}`);
499
- if (parsedLineInfo.new_line >= newStart && parsedLineInfo.new_line <= newEnd) {
500
- debugLog(`从 LINE_INFO 中解析出行号成功,使用解析后的行号覆盖 diff 块起始行号`);
501
- targetLine = parsedLineInfo;
502
- } else {
503
- debugLog(`解析的行号 ${parsedLineInfo.new_line} 不在 diff 块范围 [${newStart}, ${newEnd}] 内,使用 diff 块起始行号`);
504
- }
505
- }
506
-
507
- //打印targetLine
508
- debugLog(`targetLine: ${JSON.stringify(targetLine)}`);
509
-
510
- if (targetLine) {
511
- // 创建diff评论
512
- const payload = {
513
- body: reviewContent,
514
- position: {
515
- position_type: 'text',
516
- base_sha: baseSha,
517
- head_sha: headSha,
518
- start_sha: startSha,
519
- ...targetLine
520
- }
521
- };
522
-
523
- try {
524
- await this.createDiffDiscussion(projectId, mergeRequestIid, payload);
525
- debugLog(`评论已发布到文件 ${file_path_with_line} 的相关变更区域`);
526
- } catch (error) {
527
- // 打印详细的错误响应信息
528
- debugLog(`GitLab API 错误详情:${JSON.stringify(error.response?.data || error.message)}`);
529
- console.error(`发布评论到文件 ${file_path_with_line} 的变更区域失败,改用一般讨论:`, error.message);
530
- await this.createGeneralDiscussion(projectId, mergeRequestIid, file_path_with_line, reviewContent);
531
- debugLog(`评论已发布到文件 ${file_path_with_line} (作为一般讨论)`);
532
- }
533
- } else {
534
- // 如果没有找到任何行号,创建一般讨论
535
- await this.createGeneralDiscussion(projectId, mergeRequestIid, file_path_with_line, reviewContent);
536
- debugLog(`评论已发布到文件 ${file_path_with_line} (无特定行号)`);
537
- }
538
- } catch (error) {
539
- console.error('发布单个评论到GitLab失败:', error.message);
540
- throw error;
541
- }
542
- }
543
-
544
- /**
545
- * 发布评论到GitLab MR
546
- * @param {number} projectId GitLab项目ID
547
- * @param {number} mergeRequestIid 合并请求IID
548
- * @param {Array} reviewResults 审核结果数组
549
- */
550
- async postCommentsToGitLab(projectId, mergeRequestIid, reviewResults) {
551
- // 保持原有的批量发布方法,以防需要兼容旧的调用
552
- for (const result of reviewResults) {
553
- await this.postSingleCommentToGitLab(projectId, mergeRequestIid, result);
554
- }
555
- }
556
-
557
- /**
558
- * 创建一般讨论
559
- * @param {number} projectId GitLab项目ID
560
- * @param {number} mergeRequestIid 合并请求IID
561
- * @param {string} file_path 文件路径
562
- * @param {string} review_result 审核结果
563
- */
564
- async createGeneralDiscussion(projectId, mergeRequestIid, file_path, review_result) {
565
- const discussionData = {
566
- body: `**代码审核评论 - 文件: ${file_path}**\n\n${review_result}`
567
- };
568
-
569
- await this.gitlabClient.callGitLabAPI(
570
- `/projects/${projectId}/merge_requests/${mergeRequestIid}/discussions`,
571
- { method: 'POST', data: discussionData }
572
- );
573
- }
574
-
575
- /**
576
- * 创建diff讨论
577
- * @param {number} projectId GitLab项目ID
578
- * @param {number} mergeRequestIid 合并请求IID
579
- * @param {Object} payload 讨论数据
580
- */
581
- async createDiffDiscussion(projectId, mergeRequestIid, payload) {
582
- await this.gitlabClient.callGitLabAPI(
583
- `/projects/${projectId}/merge_requests/${mergeRequestIid}/discussions`,
584
- { method: 'POST', data: payload }
585
- );
586
- }
587
- }
588
-
589
- // 主函数
590
- async function main() {
591
- debugLog('开始加载环境变量');
592
- // 从环境变量获取配置
593
- const gitlabUrl = process.env.CI_API_V4_URL;
594
- const gitlabToken = process.env.GITLAB_ACCESS_TOKEN;
595
- const projectId = process.env.CI_PROJECT_ID;
596
- const mergeRequestIid = process.env.CI_MERGE_REQUEST_IID;
597
- // 获取最大并发数配置,默认为3
598
- const maxConcurrency = parseInt(process.env.GITLAB_CR_CONCURRENCY || 3);
599
-
600
- debugLog(`环境变量加载完成:`);
601
- debugLog(` GITLAB_API_V4_URL: ${gitlabUrl}`);
602
- debugLog(` GITLAB_TOKEN存在: ${!!gitlabToken}`);
603
- debugLog(` GITLAB_PROJECT_ID: ${projectId}`);
604
- debugLog(` GITLAB_MERGE_REQUEST_IID: ${mergeRequestIid}`);
605
- debugLog(` 设置最大并发数: ${maxConcurrency}`);
606
-
607
- if (!gitlabToken || !projectId || !mergeRequestIid) {
608
- console.error('缺少必要的环境变量配置');
609
- console.error('请设置: CI_API_V4_URL, GITLAB_ACCESS_TOKEN, CI_PROJECT_ID, CI_MERGE_REQUEST_IID');
610
- process.exit(1);
611
- }
612
-
613
- const reviewer = new GitLabCodeReviewer(gitlabToken, gitlabUrl);
614
-
615
- try {
616
- // 审核合并请求 - 现在会在每个块审核后立即发布评论
617
- debugLog('开始审核合并请求并发布评论...');
618
- await reviewer.reviewMergeRequest(projectId, mergeRequestIid, maxConcurrency);
619
- debugLog('所有评论已成功发布到GitLab MR');
620
- console.log('代码审核完成!');
621
- } catch (error) {
622
- console.error('审核过程出错:', error);
623
- process.exit(1);
624
- }
625
- }
626
-
627
- // 辅助函数:执行本地Claude命令
628
- function runClaudeCommand(promptContent) {
629
- return new Promise((resolve, reject) => {
630
-
631
- // 使用当前工作目录,确保 Claude 能访问临时文件和项目代码
632
- const projectDir = process.cwd();
633
- debugLog("AI review命令开始时间, 工作目录:" + projectDir);
634
-
635
- // 使用spawn替代exec,更安全地处理命令执行并实现环境隔离
636
- const claudeProcess = spawn('claude', ['--tools', 'default', '-p', '--', promptContent], {
637
- cwd: projectDir, // 使用项目目录,让 Claude 能访问项目代码作为上下文
638
- env: process.env,
639
- stdio: ['ignore', 'pipe', 'pipe']
640
- });
641
-
642
- let stdout = '';
643
- let stderr = '';
644
-
645
- claudeProcess.stdout.on('data', (data) => {
646
- stdout += data.toString();
647
- });
648
-
649
- claudeProcess.stderr.on('data', (data) => {
650
- stderr += data.toString();
651
- });
652
-
653
- claudeProcess.on('close', (code) => {
654
- debugLog("AI review命令结束时间")
655
- resolve(stdout.trim());
656
- });
657
-
658
- claudeProcess.on('error', (error) => {
659
- reject(new Error(`AI命令执行出错: ${error.message}, 错误输出: ${stderr || '无错误输出'}`));
660
- });
661
- });
662
- }
663
-
664
- // 如果直接运行此文件,则执行主函数
665
- if (require.main === module) {
666
- main();
667
- }
668
-
669
- module.exports = GitLabCodeReviewer;
1
+ #!/usr/bin/env node
2
+
3
+ const { spawn } = require('child_process');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const { GitLabAPIClient, debugLog, extractReportContent } = require('./utils');
7
+
8
+ class GitLabCodeReviewer {
9
+ constructor(gitlabToken, gitlabUrl = null) {
10
+ debugLog(`GitLab客户端初始化: ${gitlabUrl}`);
11
+ this.gitlabClient = new GitLabAPIClient(gitlabToken, gitlabUrl);
12
+ }
13
+
14
+ /**
15
+ * 获取合并请求的diff信息
16
+ * @param {number} projectId GitLab项目ID
17
+ * @param {number} mergeRequestIid 合并请求IID
18
+ * @returns {Promise<Array>} diff块数组
19
+ */
20
+ async getMergeRequestDiffs(projectId, mergeRequestIid) {
21
+ debugLog(`开始获取项目 ${projectId} 合并请求 ${mergeRequestIid} 的diff信息`);
22
+ try {
23
+ let allDiffs = [];
24
+ let page = 1;
25
+ const perPage = 20;
26
+
27
+ do {
28
+ const url = `/projects/${projectId}/merge_requests/${mergeRequestIid}/diffs?per_page=${perPage}&page=${page}`;
29
+ const data = await this.gitlabClient.callGitLabAPI(url);
30
+
31
+ if (data && data.length > 0) {
32
+ allDiffs = allDiffs.concat(data);
33
+ debugLog(`成功获取到第${page}页,${data.length}个diff块`);
34
+ page++;
35
+ } else {
36
+ break; // 没有更多数据
37
+ }
38
+ } while (true);
39
+
40
+ debugLog(`总共获取到 ${allDiffs.length} 个diff块`);
41
+ return allDiffs;
42
+ } catch (error) {
43
+ console.error('获取diff信息失败:', error.message);
44
+ throw error;
45
+ }
46
+ }
47
+
48
+
49
+
50
+ /**
51
+ * 使用Claude对单个diff块进行代码审核
52
+ * @param {Object} diff 单个diff对象
53
+ * @returns {Promise<string>} 审核结果
54
+ */
55
+ async reviewDiffWithClaude(diff) {
56
+ debugLog(`开始审核文件: ${diff.new_path || diff.old_path}, diff长度: ${diff.diff.length}`);
57
+
58
+ const prompt = `使用技能进行代码审查,变更内容:${diff.diff}`;
59
+
60
+ debugLog(`开始调用本地AI命令审核文件, prompt:${prompt}`)
61
+ // 最多重试3次,直到结果包含"🤖 AI 代码审查结果"或达到最大重试次数
62
+ let attempts = 0;
63
+ const maxAttempts = 5;
64
+ let claudeResult = null;
65
+
66
+ while (attempts < maxAttempts) {
67
+ attempts++;
68
+ try {
69
+ debugLog(`调用本地AI命令审核文件 (尝试 ${attempts}/${maxAttempts})`);
70
+
71
+ // 直接将prompt内容传递给Claude命令
72
+ claudeResult = await runClaudeCommand(prompt);
73
+
74
+ debugLog(`本地 AI 命令审核完成,AI 审核结果长度:${claudeResult?.length || 0}`);
75
+
76
+ // 打印报告内容前 500 字符(避免过长)
77
+ const reportPreview = claudeResult?.length > 500 ? claudeResult.substring(0, 500) + '...' : claudeResult;
78
+ debugLog(`AI 审核报告内容预览:${reportPreview}`);
79
+
80
+ // 使用正则提取 LINE_INFO 内容(支持换行)
81
+ const lineInfoMatch = claudeResult?.match(/<LINE_INFO>\s*\[([^\]]*)\]\s*<\/LINE_INFO>/);
82
+ const hasLineInfoTag = lineInfoMatch !== null && lineInfoMatch !== undefined;
83
+
84
+ // 提取 LINE_INFO 数组内容,去除所有空白字符(换行、空格等)后判断
85
+ const lineInfoContent = hasLineInfoTag ? lineInfoMatch[1].replace(/\s/g, '') : '';
86
+ const hasNonEmptyLineInfo = hasLineInfoTag && lineInfoContent !== '';
87
+
88
+ debugLog(`LINE_INFO 检查结果:hasLineInfoTag=${hasLineInfoTag}, hasNonEmptyLineInfo=${hasNonEmptyLineInfo}, lineInfoContent=[${lineInfoContent}]`);
89
+
90
+ // LINE_INFO 为空或不存在 → 说明无问题,直接返回标准空格式
91
+ if (!hasLineInfoTag || !hasNonEmptyLineInfo) {
92
+ debugLog(`【决策】LINE_INFO 为空或不存在,说明无问题,直接返回标准空格式`);
93
+ return { reportContent: '<REPORT>\n## 🤖 AI 代码审查结果\n\n</REPORT>', lineInfo: '[]' };
94
+ }
95
+
96
+ // LINE_INFO 有行号 继续检查报告内容
97
+ // 先检查是否有严重问题(放宽匹配,只检查"严重问题"关键词)
98
+ const hasSeriousProblem = claudeResult && claudeResult.includes('严重问题');
99
+
100
+ debugLog(`严重问题检查:hasSeriousProblem=${hasSeriousProblem}`);
101
+
102
+ // 无严重问题 直接返回标准空格式
103
+ if (!hasSeriousProblem) {
104
+ debugLog(`【决策】报告无严重问题,返回标准空格式`);
105
+ return { reportContent: claudeResult, lineInfo: lineInfoContent };
106
+ }
107
+
108
+ // 有严重问题 检查标题是否符合要求
109
+ const hasValidTitle = claudeResult && claudeResult.includes('🤖 AI 代码审查结果');
110
+
111
+ debugLog(`标题检查:hasValidTitle=${hasValidTitle}`);
112
+
113
+ if (hasValidTitle) {
114
+ debugLog(`【决策】报告包含严重问题且标题正确,接受结果 (尝试 ${attempts})`);
115
+ return extractReportContent(claudeResult);
116
+ }
117
+
118
+ // 有严重问题但标题不符合 → 重试
119
+ debugLog(`【决策】报告包含严重问题但标题不符合要求 (尝试 ${attempts}),将重试...`);
120
+ if (attempts >= maxAttempts) {
121
+ debugLog(`【决策】已达到最大重试次数 ${maxAttempts},返回最后一次结果`);
122
+ return extractReportContent(claudeResult);
123
+ }
124
+ } catch (error) {
125
+ console.error(`AI审核失败 (尝试 ${attempts}/${maxAttempts}):`, error.message);
126
+ if (attempts >= maxAttempts) {
127
+ return `审核失败: ${error.message}`;
128
+ }
129
+ // 等待一段时间后重试
130
+ await new Promise(resolve => setTimeout(resolve, 1000 * attempts)); // 递增等待时间
131
+ }
132
+ }
133
+ }
134
+
135
+ /**
136
+ * 对合并请求的所有diff进行审核(逐个处理并立即发布评论)
137
+ * @param {number} projectId GitLab项目ID
138
+ * @param {number} mergeRequestIid 合并请求IID
139
+ * @returns {Promise<Array>} 审核结果数组
140
+ */
141
+ async reviewMergeRequest(projectId, mergeRequestIid, maxConcurrency = 3) {
142
+ debugLog(`开始审核项目 ${projectId} 的合并请求 ${mergeRequestIid}`);
143
+
144
+ // 获取diff信息
145
+ const diffs = await this.getMergeRequestDiffs(projectId, mergeRequestIid);
146
+ debugLog(`获取到 ${diffs.length} 个diff块`);
147
+
148
+ // 对每个diff进一步按变更块拆分并审核
149
+ debugLog('开始处理所有diff块的变更块拆分');
150
+
151
+ // 创建处理单个块的函数
152
+ const processBlock = async (diffObject, blockIndex) => {
153
+ // 创建临时文件存储diff内容,文件地址选择当前文件夹下,避免权限问题
154
+ const fileName = `temp-diff-block-${Date.now()}-${blockIndex}.diff`;
155
+ const tmpFileName = path.join(process.cwd(), fileName);
156
+
157
+ try {
158
+ // 构造包含元数据的 diff 内容
159
+ const diffContentWithMetadata = `=== File Information ===
160
+ New Path: ${diffObject.new_path || 'N/A'}
161
+ Old Path: ${diffObject.old_path || 'N/A'}
162
+ Block Index: ${blockIndex}
163
+ === Diff Content ===
164
+ ${diffObject.diff}`;
165
+
166
+ // 将diff内容写入临时文件
167
+ fs.writeFileSync(tmpFileName, diffContentWithMetadata);
168
+
169
+ // 审核当前块(传入临时的文件而不是直接的diff内容)
170
+ const review_result = await this.reviewDiffWithClaudeUsingFile(tmpFileName);
171
+ const blockObj = { ...diffObject, review_result, temp_file_path: tmpFileName };
172
+
173
+ // 检查审查结果中是否包含严重问题,只有包含严重问题才发布评论
174
+ if (blockObj.review_result && blockObj.review_result.reportContent && blockObj.review_result.reportContent.includes('严重问题')) {
175
+ // 立即发布评论
176
+ await this.postSingleCommentToGitLab(projectId, mergeRequestIid, {
177
+ diff_info: blockObj,
178
+ block_index: blockObj.block_index,
179
+ review_result: blockObj.review_result,
180
+ });
181
+ } else {
182
+ debugLog(`该块不包含严重问题,跳过评论发布: ${blockObj.new_path || blockObj.old_path}#${blockObj.block_index}`);
183
+ }
184
+
185
+ return {
186
+ diff_info: blockObj,
187
+ block_index: blockObj.block_index,
188
+ review_result: blockObj.review_result,
189
+ temp_file_path: tmpFileName,
190
+ };
191
+ } catch (error) {
192
+ throw error;
193
+ } finally {
194
+ try {
195
+ if (fs.existsSync(tmpFileName)) {
196
+ fs.unlinkSync(tmpFileName);
197
+ }
198
+ } catch (cleanupError) {
199
+ console.error('清理临时文件失败:', cleanupError.message);
200
+ }
201
+ }
202
+ };
203
+
204
+ // 收集所有需要处理的块
205
+ const allBlocks = [];
206
+ for (const diff of diffs) {
207
+ const diffObjects = this.getDiffBlocks(diff);
208
+ for (let i = 0; i < diffObjects.length; i++) {
209
+ // 更新块索引
210
+ diffObjects[i].block_index = i;
211
+ allBlocks.push({ diffObject: diffObjects[i], blockIndex: i });
212
+ }
213
+ }
214
+
215
+ // 使用线程池控制并发数量
216
+ const results = await this.processWithThreadPool(allBlocks, processBlock, maxConcurrency);
217
+
218
+ debugLog(`总共处理了 ${results.length} 个diff block块`);
219
+
220
+ debugLog('所有diff块审核并发布评论完成');
221
+
222
+ return results;
223
+ }
224
+
225
+ /**
226
+ * 使用Claude对单个diff文件进行代码审核
227
+ * @param {string} filePath 临时文件路径
228
+ * @returns {Promise<string>} 审核结果
229
+ */
230
+ async reviewDiffWithClaudeUsingFile(filePath) {
231
+ debugLog(`开始审核文件: ${filePath}`);
232
+
233
+ const prompt = `请调用 simple-code-review 技能审核代码变更。
234
+ 文件路径:${filePath}
235
+
236
+ **重要审查规则**:
237
+ 1. **只审查当前 diff 块内的新增代码**(+ 开头的行)
238
+ 2. **只报告当前 diff 块内能直接发现的问题**,不要追踪方法调用链去其他地方报告问题
239
+ 3. **同一问题只报告一次**:如 import 行、声明行、调用行都有问题,只在真正会出错的调用行报告一次
240
+ 4. **禁止报告的位置**:import 语句、类定义、方法签名、依赖注入声明行
241
+
242
+ **输出要求**:
243
+ 1. 严格按照 .claude/skills/simple-code-review/SKILL.md 中定义的模板格式输出
244
+ 2. **每个模块不是必须的**:没有对应问题时,完全省略该模块(不输出标题)
245
+ 3. 必须以 <REPORT> 开始,以 </REPORT> 结束
246
+ 4. **必须输出 '<LINE_INFO>' 标签**,包含所有问题的行号信息(无问题时输出空数组 [])
247
+ 5. 不要输出任何额外的解释、问候或总结文本`;
248
+ //打印
249
+ debugLog(`Claude命令: ${prompt}`);
250
+ // 最多重试5次,直到结果包含"🤖 AI 代码审查结果"或达到最大重试次数
251
+ let attempts = 0;
252
+ const maxAttempts = 5;
253
+ let claudeResult = null;
254
+
255
+ while (attempts < maxAttempts) {
256
+ attempts++;
257
+ try {
258
+ debugLog(`调用本地AI命令审核文件 (尝试 ${attempts}/${maxAttempts})`);
259
+
260
+ // 直接将prompt内容(包含文件路径)传递给Claude命令
261
+ claudeResult = await runClaudeCommand(prompt);
262
+ //若结果为空,则记录日志
263
+ if (!claudeResult) {
264
+ debugLog(`本地AI命令审核结果为空`);
265
+ return { reportContent: claudeResult || '<REPORT>\n## 🤖 AI 代码审查结果\n\n</REPORT>', lineInfo: '[]' };
266
+ }
267
+ debugLog(`本地 AI 命令审核完成,审核结果长度:${claudeResult?.length || 0}`);
268
+
269
+ // 打印报告内容前 500 字符(避免过长)
270
+ const reportPreview = claudeResult?.length > 500 ? claudeResult.substring(0, 500) + '...' : claudeResult;
271
+ debugLog(`AI 审核报告内容预览:${reportPreview}`);
272
+
273
+ // 使用正则提取 LINE_INFO 内容(支持换行)
274
+ const lineInfoMatch = claudeResult?.match(/<LINE_INFO>\s*\[([^\]]*)\]\s*<\/LINE_INFO>/);
275
+ const hasLineInfoTag = lineInfoMatch !== null && lineInfoMatch !== undefined;
276
+
277
+ // 提取 LINE_INFO 数组内容,去除所有空白字符(换行、空格等)后判断
278
+ const lineInfoContent = hasLineInfoTag ? lineInfoMatch[1].replace(/\s/g, '') : '';
279
+ const hasNonEmptyLineInfo = hasLineInfoTag && lineInfoContent !== '';
280
+
281
+ debugLog(`LINE_INFO 检查结果:hasLineInfoTag=${hasLineInfoTag}, hasNonEmptyLineInfo=${hasNonEmptyLineInfo}, lineInfoContent=[${lineInfoContent}]`);
282
+
283
+ // LINE_INFO 为空或不存在 说明无问题,直接返回标准空格式
284
+ if (!hasLineInfoTag || !hasNonEmptyLineInfo) {
285
+ debugLog(`【决策】LINE_INFO 为空或不存在,说明无问题,直接返回标准空格式`);
286
+ return { reportContent: '<REPORT>\n## 🤖 AI 代码审查结果\n\n</REPORT>', lineInfo: '[]' };
287
+ }
288
+
289
+ // LINE_INFO 有行号 → 继续检查报告内容
290
+ // 先检查是否有严重问题(放宽匹配,只检查"严重问题"关键词)
291
+ const hasSeriousProblem = claudeResult && claudeResult.includes('严重问题');
292
+
293
+ debugLog(`严重问题检查:hasSeriousProblem=${hasSeriousProblem}`);
294
+
295
+ // 无严重问题 直接返回标准空格式
296
+ if (!hasSeriousProblem) {
297
+ debugLog(`【决策】报告无严重问题,返回标准空格式`);
298
+ return { reportContent: claudeResult, lineInfo: lineInfoContent };
299
+ }
300
+
301
+ // 有严重问题 → 检查标题是否符合要求
302
+ const hasValidTitle = claudeResult && claudeResult.includes('🤖 AI 代码审查结果');
303
+
304
+ debugLog(`标题检查:hasValidTitle=${hasValidTitle}`);
305
+
306
+ if (hasValidTitle) {
307
+ debugLog(`【决策】报告包含严重问题且标题正确,接受结果 (尝试 ${attempts})`);
308
+ return extractReportContent(claudeResult);
309
+ }
310
+
311
+ // 有严重问题但标题不符合 → 重试
312
+ debugLog(`【决策】报告包含严重问题但标题不符合要求 (尝试 ${attempts}),将重试...`);
313
+ if (attempts >= maxAttempts) {
314
+ debugLog(`【决策】已达到最大重试次数 ${maxAttempts},返回最后一次结果`);
315
+ return extractReportContent(claudeResult);
316
+ }
317
+ } catch (error) {
318
+ console.error(`AI审核失败 (尝试 ${attempts}/${maxAttempts}):`, error.message);
319
+ if (attempts >= maxAttempts) {
320
+ return `审核失败: ${error.message}`;
321
+ }
322
+ // 等待一段时间后重试
323
+ await new Promise(resolve => setTimeout(resolve, 1000 * attempts)); // 递增等待时间
324
+ }
325
+ }
326
+ }
327
+
328
+ /**
329
+ * 使用线程池控制并发执行
330
+ * @param {Array} tasks 任务数组
331
+ * @param {Function} processor 任务处理器
332
+ * @param {number} maxConcurrency 最大并发数
333
+ * @returns {Promise<Array>} 处理结果数组
334
+ */
335
+ async processWithThreadPool(tasks, processor, maxConcurrency = 3) {
336
+ debugLog(`开始使用线程池处理 ${tasks.length} 个任务,最大并发数: ${maxConcurrency}`);
337
+
338
+ const results = [];
339
+ const executing = [];
340
+
341
+ for (let i = 0; i < tasks.length; i++) {
342
+ const task = tasks[i];
343
+
344
+ // 创建一个异步任务
345
+ const promise = processor(task.diffObject, task.blockIndex)
346
+ .then(result => {
347
+ results.push(result);
348
+ // 从执行队列中移除已完成的任务
349
+ const index = executing.indexOf(promise);
350
+ if (index !== -1) {
351
+ executing.splice(index, 1);
352
+ }
353
+ debugLog(`----------任务完成: ${i + 1}/${tasks.length} (${((i + 1) / tasks.length * 100).toFixed(1)}%)----------`);
354
+ return result;
355
+ });
356
+
357
+ executing.push(promise);
358
+
359
+ debugLog(`----------开始处理任务: ${i + 1}/${tasks.length} (${((i + 1) / tasks.length * 100).toFixed(1)}%)----------`);
360
+
361
+ // 如果达到最大并发数,等待至少一个任务完成
362
+ if (executing.length >= maxConcurrency) {
363
+ await Promise.race(executing);
364
+ }
365
+ }
366
+
367
+ // 等待所有剩余任务完成
368
+ await Promise.all(executing);
369
+
370
+ debugLog(`线程池处理完成,共处理 ${results.length} 个任务`);
371
+
372
+ return results;
373
+ }
374
+
375
+ /**
376
+ * 按照变更块分割数组并提取行号信息
377
+ * @param {Object} diffObj 包含diff内容和文件信息的对象
378
+ * @returns {Array} 包含diff块内容和行号信息的对象数组
379
+ */
380
+ getDiffBlocks(diffObj) {
381
+ const regex = /(?=@@\s-\d+(?:,\d+)?\s\+\d+(?:,\d+)?\s@@)/g;
382
+ const diffBlocks = diffObj.diff.split(regex);
383
+ // 过滤掉空块并提取行号信息
384
+ return diffBlocks
385
+ .filter(block => block.trim() !== '')
386
+ .map(block => {
387
+ // 解析diff头信息 @@ -old_start,old_count +new_start,new_count @@
388
+ const headerRegex = /@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
389
+ const headerMatch = block.match(headerRegex);
390
+
391
+ // 取block第一行是否是加号或者减号开头还是没有开头,block需要用.split(/\r?\n/)
392
+ const firstLine = block.split(/\r?\n/)[1];
393
+ // 取出第一行的第一个字符
394
+ const firstLineFirstChar = firstLine.charAt(0);
395
+
396
+ let old_start = 0;
397
+ let old_count = 0;
398
+ let new_start = 0;
399
+ let new_count = 0;
400
+
401
+ if (headerMatch) {
402
+ old_start = parseInt(headerMatch[1], 10);
403
+ old_count = headerMatch[2] ? parseInt(headerMatch[2], 10) : 1;
404
+ new_start = parseInt(headerMatch[3], 10);
405
+ new_count = headerMatch[4] ? parseInt(headerMatch[4], 10) : 1;
406
+ }
407
+
408
+ return {
409
+ diff: block,
410
+ new_path: diffObj.new_path,
411
+ old_path: diffObj.old_path,
412
+ a_mode: diffObj.a_mode,
413
+ b_mode: diffObj.b_mode,
414
+ new_file: diffObj.new_file,
415
+ renamed_file: diffObj.renamed_file,
416
+ deleted_file: diffObj.deleted_file,
417
+ generated_file: diffObj.generated_file,
418
+ block_index: null, // 会在后续分配
419
+ line_info: {
420
+ old_start,
421
+ old_count,
422
+ new_start,
423
+ new_count,
424
+ firstLineFirstChar
425
+ }
426
+ };
427
+ });
428
+ }
429
+
430
+ /**
431
+ * LINE_INFO 标签字符串中解析行号信息
432
+ * @param {string} lineInfoTag LINE_INFO 标签字符串,如 "<LINE_INFO>[...]</LINE_INFO>"
433
+ * @returns {Object|null} 行号信息对象 {new_path, new_line, old_path, old_line} 或 null
434
+ */
435
+ parseLineInfoFromReviewResult(lineInfoTag) {
436
+ if (!lineInfoTag) return null;
437
+
438
+ try {
439
+ // 从标签中提取 JSON 内容:<LINE_INFO>[{...}]</LINE_INFO>
440
+ const jsonContent = lineInfoTag.replace(/<LINE_INFO>\s*/g, '').replace(/\s*<\/LINE_INFO>/g, '').trim();
441
+
442
+ debugLog(`解析 LINE_INFO 原始内容:${lineInfoTag}`);
443
+ debugLog(`解析 LINE_INFO JSON 内容:${jsonContent}`);
444
+
445
+ // 如果是空数组,返回 null 使用后备方案
446
+ if (jsonContent === '[]') {
447
+ debugLog('LINE_INFO 为空数组,使用 diff 块起始行号作为后备方案');
448
+ return null;
449
+ }
450
+
451
+ const lineInfoArray = JSON.parse(jsonContent);
452
+ debugLog(`解析 LINE_INFO JSON 成功:${JSON.stringify(lineInfoArray)}`);
453
+
454
+ if (Array.isArray(lineInfoArray) && lineInfoArray.length > 0) {
455
+ debugLog(`从 LINE_INFO 中解析出行号信息:${JSON.stringify(lineInfoArray)}`);
456
+ // 返回第一个问题的行号信息
457
+ const lineInfo = lineInfoArray[0];
458
+ // GitLab API 只需要 new_path 和 new_line
459
+ return {
460
+ new_path: lineInfo.new_path,
461
+ new_line: lineInfo.new_line
462
+ };
463
+ }
464
+ } catch (error) {
465
+ debugLog(`解析 LINE_INFO JSON 失败:${error.message}`);
466
+ }
467
+
468
+ debugLog('无法从 LINE_INFO 中解析行号,将使用 diff 块起始行号作为后备方案');
469
+ return null;
470
+ }
471
+
472
+ /**
473
+ * 获取合并请求的最新版本信息
474
+ * @param {number} projectId GitLab项目ID
475
+ * @param {number} mergeRequestIid 合并请求IID
476
+ * @returns {Promise<Object>} 版本信息
477
+ */
478
+ async getMergeRequestVersions(projectId, mergeRequestIid) {
479
+ try {
480
+ const data = await this.gitlabClient.callGitLabAPI(
481
+ `/projects/${projectId}/merge_requests/${mergeRequestIid}/versions`
482
+ );
483
+ // 返回最新版本信息
484
+ return data[0] || null;
485
+ } catch (error) {
486
+ console.error('获取MR版本信息失败:', error.message);
487
+ throw error;
488
+ }
489
+ }
490
+
491
+ /**
492
+ * 发布单个评论到GitLab MR
493
+ * @param {number} projectId GitLab项目ID
494
+ * @param {number} mergeRequestIid 合并请求IID
495
+ * @param {Object} result 单个审核结果
496
+ */
497
+ async postSingleCommentToGitLab(projectId, mergeRequestIid, result) {
498
+ try {
499
+ const { diff_info, review_result, block_index } = result;
500
+ // review_result 现在是对象:{ reportContent, lineInfo }
501
+ const reviewContent = review_result?.reportContent || '';
502
+ const lineInfoTag = review_result?.lineInfo || null;
503
+ const file_path = diff_info.new_path || diff_info.old_path;
504
+ const file_path_with_line = `${file_path}#L${block_index}`;
505
+
506
+ // 获取MR版本信息
507
+ const versionInfo = await this.getMergeRequestVersions(projectId, mergeRequestIid);
508
+ if (!versionInfo) {
509
+ console.error('无法获取MR版本信息');
510
+ return;
511
+ }
512
+
513
+ const baseSha = versionInfo.base_commit_sha;
514
+ const headSha = versionInfo.head_commit_sha;
515
+ const startSha = versionInfo.start_commit_sha;
516
+
517
+ debugLog(`获取到版本信息 - base: ${baseSha}, head: ${headSha}, start: ${startSha}`);
518
+
519
+ // 直接使用diff_info中的line_info
520
+ const lineInfo = diff_info.line_info;
521
+
522
+ if (!lineInfo && !lineInfoTag) {
523
+ // 如果没有行号信息,创建一般讨论
524
+ await this.createGeneralDiscussion(projectId, mergeRequestIid, file_path_with_line, reviewContent);
525
+ debugLog(`评论已发布到文件 ${file_path_with_line} (无法解析行号)`);
526
+ return;
527
+ }
528
+
529
+ // 如果 lineInfo 里的 firstLineFirstChar 为 +,则 targetLine 为 new_path 的 new_start 行;为 -,则 targetLine 为 old_path 的 old_start 行
530
+ // GitLab API 要求:diff 评论只能传递 new_line 或 old_line 中的一个,不能同时传递
531
+ let targetLine = lineInfo.firstLineFirstChar === '+'
532
+ ? { new_line: lineInfo.new_start, new_path: diff_info.new_path }
533
+ : lineInfo.firstLineFirstChar === '-'
534
+ ? { old_line: lineInfo.old_start, old_path: diff_info.old_path }
535
+ : { new_line: lineInfo.new_start, new_path: diff_info.new_path };
536
+
537
+ // 尝试从审查结果中解析更精确的行号信息(从 LINE_INFO 标签)
538
+ const parsedLineInfo = lineInfoTag ? this.parseLineInfoFromReviewResult(lineInfoTag) : null;
539
+ if (parsedLineInfo) {
540
+ // 验证解析的行号是否在当前 diff 块范围内
541
+ const newStart = lineInfo.new_start;
542
+ const newCount = lineInfo.new_count || 0;
543
+ const newEnd = newStart + newCount - 1;
544
+ debugLog(`diff 块信息:new_start=${newStart}, new_count=${newCount}, 范围=[${newStart}, ${newEnd}]`);
545
+ debugLog(`解析的行号:${parsedLineInfo.new_line}`);
546
+ if (parsedLineInfo.new_line >= newStart && parsedLineInfo.new_line <= newEnd) {
547
+ debugLog(`从 LINE_INFO 中解析出行号成功,使用解析后的行号覆盖 diff 块起始行号`);
548
+ targetLine = parsedLineInfo;
549
+ } else {
550
+ debugLog(`解析的行号 ${parsedLineInfo.new_line} 不在 diff 块范围 [${newStart}, ${newEnd}] 内,使用 diff 块起始行号`);
551
+ }
552
+ }
553
+
554
+ //打印targetLine
555
+ debugLog(`targetLine: ${JSON.stringify(targetLine)}`);
556
+
557
+ if (targetLine) {
558
+ // 创建diff评论
559
+ const payload = {
560
+ body: reviewContent,
561
+ position: {
562
+ position_type: 'text',
563
+ base_sha: baseSha,
564
+ head_sha: headSha,
565
+ start_sha: startSha,
566
+ ...targetLine
567
+ }
568
+ };
569
+
570
+ try {
571
+ await this.createDiffDiscussion(projectId, mergeRequestIid, payload);
572
+ debugLog(`评论已发布到文件 ${file_path_with_line} 的相关变更区域`);
573
+ } catch (error) {
574
+ // 打印详细的错误响应信息
575
+ debugLog(`GitLab API 错误详情:${JSON.stringify(error.response?.data || error.message)}`);
576
+ console.error(`发布评论到文件 ${file_path_with_line} 的变更区域失败,改用一般讨论:`, error.message);
577
+ await this.createGeneralDiscussion(projectId, mergeRequestIid, file_path_with_line, reviewContent);
578
+ debugLog(`评论已发布到文件 ${file_path_with_line} (作为一般讨论)`);
579
+ }
580
+ } else {
581
+ // 如果没有找到任何行号,创建一般讨论
582
+ await this.createGeneralDiscussion(projectId, mergeRequestIid, file_path_with_line, reviewContent);
583
+ debugLog(`评论已发布到文件 ${file_path_with_line} (无特定行号)`);
584
+ }
585
+ } catch (error) {
586
+ console.error('发布单个评论到GitLab失败:', error.message);
587
+ throw error;
588
+ }
589
+ }
590
+
591
+ /**
592
+ * 发布评论到GitLab MR
593
+ * @param {number} projectId GitLab项目ID
594
+ * @param {number} mergeRequestIid 合并请求IID
595
+ * @param {Array} reviewResults 审核结果数组
596
+ */
597
+ async postCommentsToGitLab(projectId, mergeRequestIid, reviewResults) {
598
+ // 保持原有的批量发布方法,以防需要兼容旧的调用
599
+ for (const result of reviewResults) {
600
+ await this.postSingleCommentToGitLab(projectId, mergeRequestIid, result);
601
+ }
602
+ }
603
+
604
+ /**
605
+ * 创建一般讨论
606
+ * @param {number} projectId GitLab项目ID
607
+ * @param {number} mergeRequestIid 合并请求IID
608
+ * @param {string} file_path 文件路径
609
+ * @param {string} review_result 审核结果
610
+ */
611
+ async createGeneralDiscussion(projectId, mergeRequestIid, file_path, review_result) {
612
+ const discussionData = {
613
+ body: `**代码审核评论 - 文件: ${file_path}**\n\n${review_result}`
614
+ };
615
+
616
+ await this.gitlabClient.callGitLabAPI(
617
+ `/projects/${projectId}/merge_requests/${mergeRequestIid}/discussions`,
618
+ { method: 'POST', data: discussionData }
619
+ );
620
+ }
621
+
622
+ /**
623
+ * 创建diff讨论
624
+ * @param {number} projectId GitLab项目ID
625
+ * @param {number} mergeRequestIid 合并请求IID
626
+ * @param {Object} payload 讨论数据
627
+ */
628
+ async createDiffDiscussion(projectId, mergeRequestIid, payload) {
629
+ await this.gitlabClient.callGitLabAPI(
630
+ `/projects/${projectId}/merge_requests/${mergeRequestIid}/discussions`,
631
+ { method: 'POST', data: payload }
632
+ );
633
+ }
634
+ }
635
+
636
+ // 主函数
637
+ async function main() {
638
+ debugLog('开始加载环境变量');
639
+ // 从环境变量获取配置
640
+ const gitlabUrl = process.env.CI_API_V4_URL;
641
+ const gitlabToken = process.env.GITLAB_ACCESS_TOKEN;
642
+ const projectId = process.env.CI_PROJECT_ID;
643
+ const mergeRequestIid = process.env.CI_MERGE_REQUEST_IID;
644
+ // 获取最大并发数配置,默认为3
645
+ const maxConcurrency = parseInt(process.env.GITLAB_CR_CONCURRENCY || 3);
646
+
647
+ debugLog(`环境变量加载完成:`);
648
+ debugLog(` GITLAB_API_V4_URL: ${gitlabUrl}`);
649
+ debugLog(` GITLAB_TOKEN存在: ${!!gitlabToken}`);
650
+ debugLog(` GITLAB_PROJECT_ID: ${projectId}`);
651
+ debugLog(` GITLAB_MERGE_REQUEST_IID: ${mergeRequestIid}`);
652
+ debugLog(` 设置最大并发数: ${maxConcurrency}`);
653
+
654
+ if (!gitlabToken || !projectId || !mergeRequestIid) {
655
+ console.error('缺少必要的环境变量配置');
656
+ console.error('请设置: CI_API_V4_URL, GITLAB_ACCESS_TOKEN, CI_PROJECT_ID, CI_MERGE_REQUEST_IID');
657
+ process.exit(1);
658
+ }
659
+
660
+ const reviewer = new GitLabCodeReviewer(gitlabToken, gitlabUrl);
661
+
662
+ try {
663
+ // 审核合并请求 - 现在会在每个块审核后立即发布评论
664
+ debugLog('开始审核合并请求并发布评论...');
665
+ await reviewer.reviewMergeRequest(projectId, mergeRequestIid, maxConcurrency);
666
+ debugLog('所有评论已成功发布到GitLab MR');
667
+ console.log('代码审核完成!');
668
+ } catch (error) {
669
+ console.error('审核过程出错:', error);
670
+ process.exit(1);
671
+ }
672
+ }
673
+
674
+ // 辅助函数:执行本地Claude命令
675
+ function runClaudeCommand(promptContent) {
676
+ return new Promise((resolve, reject) => {
677
+
678
+ // 使用当前工作目录,确保 Claude 能访问临时文件和项目代码
679
+ const projectDir = process.cwd();
680
+ debugLog("AI review命令开始时间, 工作目录:" + projectDir);
681
+
682
+ // 使用spawn替代exec,更安全地处理命令执行并实现环境隔离
683
+ const claudeProcess = spawn('claude', ['--tools', 'default', '-p', '--', promptContent], {
684
+ cwd: projectDir, // 使用项目目录,让 Claude 能访问项目代码作为上下文
685
+ env: process.env,
686
+ stdio: ['ignore', 'pipe', 'pipe']
687
+ });
688
+
689
+ let stdout = '';
690
+ let stderr = '';
691
+
692
+ claudeProcess.stdout.on('data', (data) => {
693
+ stdout += data.toString();
694
+ });
695
+
696
+ claudeProcess.stderr.on('data', (data) => {
697
+ stderr += data.toString();
698
+ });
699
+
700
+ claudeProcess.on('close', (code) => {
701
+ debugLog("AI review命令结束时间")
702
+ resolve(stdout.trim());
703
+ });
704
+
705
+ claudeProcess.on('error', (error) => {
706
+ reject(new Error(`AI命令执行出错: ${error.message}, 错误输出: ${stderr || '无错误输出'}`));
707
+ });
708
+ });
709
+ }
710
+
711
+ // 如果直接运行此文件,则执行主函数
712
+ if (require.main === module) {
713
+ main();
714
+ }
715
+
716
+ module.exports = GitLabCodeReviewer;