gitlab-ai-review 2.5.4 → 3.0.1

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/index.js CHANGED
@@ -8,6 +8,7 @@ import { GitLabClient } from './lib/gitlab-client.js';
8
8
  import { AIClient } from './lib/ai-client.js';
9
9
  import * as PromptTools from './lib/prompt-tools.js';
10
10
  import * as DiffParser from './lib/diff-parser.js';
11
+ import * as ImpactAnalyzer from './lib/impact-analyzer.js';
11
12
 
12
13
  /**
13
14
  * GitLab AI Review SDK 主类
@@ -15,7 +16,7 @@ import * as DiffParser from './lib/diff-parser.js';
15
16
  export class GitLabAIReview {
16
17
  constructor(options = {}) {
17
18
  this.name = 'GitLab AI Review SDK';
18
- this.version = '2.5.4';
19
+ this.version = '2.3.0';
19
20
 
20
21
  // 如果传入了配置,使用手动配置;否则使用自动检测
21
22
  if (options.token || options.gitlab) {
@@ -166,35 +167,14 @@ export class GitLabAIReview {
166
167
  continue;
167
168
  }
168
169
 
169
- const additionsCount = meaningfulChanges.filter(c => c.type === 'addition').length;
170
- const deletionsCount = meaningfulChanges.filter(c => c.type === 'deletion').length;
171
- console.log(` 发现 ${meaningfulChanges.length} 个有意义的变更(新增 ${additionsCount} 行,删除 ${deletionsCount} 行)`);
170
+ console.log(` 发现 ${meaningfulChanges.length} 个有意义的变更`);
172
171
 
173
172
  // 调用 AI 一次性审查整个文件的所有变更(按文件批量)
174
173
  const fileReview = await this.reviewFileChanges(change, meaningfulChanges);
175
174
 
176
- // 创建有效新增行号的集合(用于验证,只包含新增的行)
177
- const validAdditionLineNumbers = new Set(
178
- meaningfulChanges
179
- .filter(c => c.type === 'addition') // 只包含新增的行
180
- .map(c => c.lineNumber)
181
- );
182
-
183
- // 根据 AI 返回的结果,只对有问题的新增行添加评论
175
+ // 根据 AI 返回的结果,只对有问题的行添加评论
184
176
  for (const review of fileReview.reviews) {
185
177
  if (review.hasIssue) {
186
- // 验证行号是否是新增行
187
- if (!validAdditionLineNumbers.has(review.lineNumber)) {
188
- console.log(` ⚠ 第 ${review.lineNumber} 行:不是新增行或不在变更范围内,跳过评论`);
189
- results.push({
190
- status: 'skipped',
191
- fileName,
192
- lineNumber: review.lineNumber,
193
- reason: '不是新增行或行号不在 diff 变更范围内',
194
- });
195
- continue;
196
- }
197
-
198
178
  try {
199
179
  const commentResult = await this.gitlabClient.createLineComment(
200
180
  this.config.project.projectId,
@@ -259,11 +239,8 @@ export class GitLabAIReview {
259
239
  const projectPrompt = this.config.ai?.guardConfig?.content || '';
260
240
  const fileName = change.new_path || change.old_path;
261
241
 
262
- // 按 hunk 分组变更
263
- const hunkGroups = DiffParser.groupChangesByHunk(meaningfulChanges);
264
-
265
- // 构建整个文件的批量审查消息(按 hunk 组织)
266
- const messages = PromptTools.buildFileReviewMessages(fileName, meaningfulChanges, hunkGroups, projectPrompt);
242
+ // 构建整个文件的批量审查消息
243
+ const messages = PromptTools.buildFileReviewMessages(fileName, meaningfulChanges, projectPrompt);
267
244
 
268
245
  // 调用 AI(一次调用审查整个文件)
269
246
  const response = await aiClient.sendMessage(messages);
@@ -279,9 +256,6 @@ export class GitLabAIReview {
279
256
  jsonStr = jsonStr.slice(3, -3).trim();
280
257
  }
281
258
 
282
- // 清理可能的格式错误(如多余的括号)
283
- jsonStr = this.cleanJsonString(jsonStr);
284
-
285
259
  const result = JSON.parse(jsonStr);
286
260
  return result;
287
261
  } catch (error) {
@@ -292,23 +266,159 @@ export class GitLabAIReview {
292
266
  }
293
267
 
294
268
  /**
295
- * 清理 JSON 字符串中的常见格式错误
296
- * @param {string} jsonStr - 待清理的 JSON 字符串
297
- * @returns {string} 清理后的 JSON 字符串
269
+ * AI 审查 MR 的所有变更(包含影响分析)
270
+ * @param {Object} options - 选项
271
+ * @param {number} options.maxFiles - 最大审查文件数量(默认不限制)
272
+ * @param {number} options.maxAffectedFiles - 每个文件最多分析的受影响文件数量(默认 10)
273
+ * @param {boolean} options.enableImpactAnalysis - 是否启用影响分析(默认 true)
274
+ * @returns {Promise<Array>} 评论结果数组
298
275
  */
299
- cleanJsonString(jsonStr) {
300
- // 修复常见的 JSON 格式错误
276
+ async reviewWithImpactAnalysis(options = {}) {
277
+ const {
278
+ maxFiles = Infinity,
279
+ maxAffectedFiles = 10,
280
+ enableImpactAnalysis = true
281
+ } = options;
301
282
 
302
- // 1. 移除末尾多余的 ]}(如 ]}]} 改为 ]})
303
- jsonStr = jsonStr.replace(/\]\}\]\}$/g, ']}');
304
-
305
- // 2. 移除末尾多余的 ](如 }]} 改为 })
306
- jsonStr = jsonStr.replace(/\}\]\}$/g, '}');
307
-
308
- // 3. 移除其他常见的多余闭合符号
309
- jsonStr = jsonStr.replace(/\]\]$/g, ']');
310
-
311
- return jsonStr.trim();
283
+ const changes = await this.getMergeRequestChanges();
284
+ const mrInfo = await this.getMergeRequest();
285
+ const ref = mrInfo.target_branch || 'main';
286
+ const results = [];
287
+
288
+ const filesToReview = maxFiles === Infinity ? changes.length : Math.min(maxFiles, changes.length);
289
+ console.log(`共 ${changes.length} 个文件需要审查${maxFiles === Infinity ? '(不限制数量)' : `(最多审查 ${maxFiles} 个)`}`);
290
+ console.log(`影响分析: ${enableImpactAnalysis ? '已启用' : '已禁用'}`);
291
+
292
+ for (const change of changes.slice(0, filesToReview)) {
293
+ const fileName = change.new_path || change.old_path;
294
+
295
+ try {
296
+ console.log(`\n审查文件: ${fileName}`);
297
+
298
+ // 解析 diff,提取有意义的变更
299
+ const hunks = DiffParser.parseDiff(change.diff);
300
+ const meaningfulChanges = DiffParser.extractMeaningfulChanges(hunks);
301
+
302
+ if (meaningfulChanges.length === 0) {
303
+ console.log(` 跳过:没有有意义的变更`);
304
+ continue;
305
+ }
306
+
307
+ console.log(` 发现 ${meaningfulChanges.length} 个有意义的变更`);
308
+
309
+ // 影响分析
310
+ let impactAnalysis = null;
311
+ if (enableImpactAnalysis) {
312
+ impactAnalysis = await ImpactAnalyzer.analyzeImpact({
313
+ gitlabClient: this.gitlabClient,
314
+ projectId: this.config.project.projectId,
315
+ ref: ref,
316
+ change: change,
317
+ maxAffectedFiles: maxAffectedFiles,
318
+ });
319
+ }
320
+
321
+ // 调用 AI 审查(包含影响分析)
322
+ const fileReview = await this.reviewFileChangesWithImpact(
323
+ change,
324
+ meaningfulChanges,
325
+ impactAnalysis
326
+ );
327
+
328
+ // 根据 AI 返回的结果,只对有问题的行添加评论
329
+ for (const review of fileReview.reviews) {
330
+ if (review.hasIssue) {
331
+ try {
332
+ const commentResult = await this.gitlabClient.createLineComment(
333
+ this.config.project.projectId,
334
+ this.config.project.mergeRequestIid,
335
+ `🤖 **AI 代码审查**\n\n${review.comment}`,
336
+ {
337
+ filePath: fileName,
338
+ oldPath: change.old_path,
339
+ newLine: review.lineNumber,
340
+ }
341
+ );
342
+
343
+ results.push({
344
+ status: 'success',
345
+ fileName,
346
+ lineNumber: review.lineNumber,
347
+ comment: review.comment,
348
+ commentResult,
349
+ });
350
+
351
+ console.log(` ✓ 第 ${review.lineNumber} 行:已添加评论`);
352
+ } catch (error) {
353
+ results.push({
354
+ status: 'error',
355
+ fileName,
356
+ lineNumber: review.lineNumber,
357
+ error: error.message,
358
+ });
359
+ console.log(` ✗ 第 ${review.lineNumber} 行:评论失败 - ${error.message}`);
360
+ }
361
+ } else {
362
+ console.log(` ✓ 第 ${review.lineNumber} 行:代码质量良好`);
363
+ }
364
+ }
365
+
366
+ // 如果没有问题
367
+ if (fileReview.reviews.length === 0 || fileReview.reviews.every(r => !r.hasIssue)) {
368
+ console.log(` ✓ 所有代码质量良好,无需评论`);
369
+ }
370
+
371
+ } catch (error) {
372
+ results.push({
373
+ status: 'error',
374
+ fileName,
375
+ error: error.message,
376
+ });
377
+ console.log(` ✗ 文件审查失败: ${error.message}`);
378
+ }
379
+ }
380
+
381
+ return results;
382
+ }
383
+
384
+ /**
385
+ * 审查单个文件的所有变更(包含影响分析)
386
+ * @param {Object} change - 代码变更对象
387
+ * @param {Array} meaningfulChanges - 有意义的变更数组
388
+ * @param {Object} impactAnalysis - 影响分析结果
389
+ * @returns {Promise<Object>} 审查结果 { reviews: [{lineNumber, hasIssue, comment}] }
390
+ */
391
+ async reviewFileChangesWithImpact(change, meaningfulChanges, impactAnalysis) {
392
+ const aiClient = this.getAIClient();
393
+ const projectPrompt = this.config.ai?.guardConfig?.content || '';
394
+ const fileName = change.new_path || change.old_path;
395
+
396
+ // 构建包含影响分析的批量审查消息
397
+ const messages = impactAnalysis
398
+ ? PromptTools.buildFileReviewWithImpactMessages(fileName, meaningfulChanges, impactAnalysis, projectPrompt)
399
+ : PromptTools.buildFileReviewMessages(fileName, meaningfulChanges, projectPrompt);
400
+
401
+ // 调用 AI(一次调用审查整个文件)
402
+ const response = await aiClient.sendMessage(messages);
403
+
404
+ // 解析 AI 返回的 JSON
405
+ try {
406
+ // 提取 JSON(可能被包裹在 ```json ``` 中)
407
+ let jsonStr = response.content.trim();
408
+ const jsonMatch = jsonStr.match(/```json\s*([\s\S]*?)\s*```/);
409
+ if (jsonMatch) {
410
+ jsonStr = jsonMatch[1];
411
+ } else if (jsonStr.startsWith('```') && jsonStr.endsWith('```')) {
412
+ jsonStr = jsonStr.slice(3, -3).trim();
413
+ }
414
+
415
+ const result = JSON.parse(jsonStr);
416
+ return result;
417
+ } catch (error) {
418
+ console.error('解析 AI 返回的 JSON 失败:', error.message);
419
+ console.error('AI 原始返回:', response.content);
420
+ return { reviews: [] };
421
+ }
312
422
  }
313
423
 
314
424
  /**
@@ -323,7 +433,7 @@ export class GitLabAIReview {
323
433
  export { getConfig, validateConfig } from './lib/config.js';
324
434
  export { GitLabClient } from './lib/gitlab-client.js';
325
435
  export { AIClient } from './lib/ai-client.js';
326
- export { PromptTools, DiffParser };
436
+ export { PromptTools, DiffParser, ImpactAnalyzer };
327
437
 
328
438
  // 默认导出
329
439
  export default GitLabAIReview;
@@ -187,42 +187,11 @@ export function generateDiffSummary(diff) {
187
187
  };
188
188
  }
189
189
 
190
- /**
191
- * 将有意义的变更按 hunk 分组
192
- * @param {Array} meaningfulChanges - 有意义的变更数组
193
- * @returns {Array} 按 hunk 分组的变更数组
194
- */
195
- export function groupChangesByHunk(meaningfulChanges) {
196
- const hunkMap = new Map();
197
-
198
- meaningfulChanges.forEach(change => {
199
- const hunkKey = change.hunk; // 使用 hunk header 作为 key
200
-
201
- if (!hunkMap.has(hunkKey)) {
202
- hunkMap.set(hunkKey, {
203
- header: hunkKey,
204
- additions: [],
205
- deletions: [],
206
- });
207
- }
208
-
209
- const hunkGroup = hunkMap.get(hunkKey);
210
- if (change.type === 'addition') {
211
- hunkGroup.additions.push(change);
212
- } else if (change.type === 'deletion') {
213
- hunkGroup.deletions.push(change);
214
- }
215
- });
216
-
217
- return Array.from(hunkMap.values());
218
- }
219
-
220
190
  export default {
221
191
  parseHunkHeader,
222
192
  parseDiff,
223
193
  calculateNewLineNumber,
224
194
  extractMeaningfulChanges,
225
195
  generateDiffSummary,
226
- groupChangesByHunk,
227
196
  };
228
197
 
@@ -145,5 +145,74 @@ export class GitLabClient {
145
145
 
146
146
  return results;
147
147
  }
148
+
149
+ /**
150
+ * 获取项目中的文件内容
151
+ * @param {string} projectId - 项目 ID
152
+ * @param {string} filePath - 文件路径
153
+ * @param {string} ref - 分支名或提交 SHA
154
+ * @returns {Promise<string|null>} 文件内容
155
+ */
156
+ async getProjectFile(projectId, filePath, ref) {
157
+ try {
158
+ const response = await fetch(
159
+ `${this.apiUrl}/projects/${encodeURIComponent(projectId)}/repository/files/${encodeURIComponent(filePath)}/raw?ref=${ref}`,
160
+ {
161
+ headers: {
162
+ 'PRIVATE-TOKEN': this.token,
163
+ },
164
+ }
165
+ );
166
+
167
+ if (!response.ok) {
168
+ if (response.status === 404) {
169
+ return null;
170
+ }
171
+ throw new Error(`获取文件失败: ${response.status}`);
172
+ }
173
+
174
+ return await response.text();
175
+ } catch (error) {
176
+ console.warn(`获取文件 ${filePath} 失败:`, error.message);
177
+ return null;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * 在项目中搜索代码
183
+ * @param {string} projectId - 项目 ID
184
+ * @param {string} search - 搜索关键词
185
+ * @param {string} ref - 分支名
186
+ * @returns {Promise<Array>} 搜索结果
187
+ */
188
+ async searchInProject(projectId, search, ref) {
189
+ try {
190
+ const response = await this.request(
191
+ `/projects/${encodeURIComponent(projectId)}/search?scope=blobs&search=${encodeURIComponent(search)}&ref=${ref}`
192
+ );
193
+
194
+ return response || [];
195
+ } catch (error) {
196
+ console.warn(`搜索 "${search}" 失败:`, error.message);
197
+ return [];
198
+ }
199
+ }
200
+
201
+ /**
202
+ * 获取仓库树结构
203
+ * @param {string} projectId - 项目 ID
204
+ * @param {string} ref - 分支名
205
+ * @param {string} path - 路径(可选)
206
+ * @returns {Promise<Array>} 文件树
207
+ */
208
+ async getRepositoryTree(projectId, ref, path = '') {
209
+ try {
210
+ const endpoint = `/projects/${encodeURIComponent(projectId)}/repository/tree?ref=${ref}&path=${encodeURIComponent(path)}&recursive=false`;
211
+ return await this.request(endpoint);
212
+ } catch (error) {
213
+ console.warn(`获取仓库树失败:`, error.message);
214
+ return [];
215
+ }
216
+ }
148
217
  }
149
218