gitlab-ai-review 4.2.3 → 6.3.9

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.
@@ -92,11 +92,16 @@ export class GitLabClient {
92
92
  old_path: lineInfo.oldPath || lineInfo.filePath,
93
93
  };
94
94
 
95
- // 设置行号(只能设置其中一个,或者两个都设置表示修改块)
95
+ // 设置行号
96
+ // GitLab API 要求:
97
+ // - 纯新增行:只设置 new_line
98
+ // - 纯删除行:只设置 old_line
99
+ // - 修改块:设置 new_line(评论到新代码上)
100
+ // 注意:同时设置 old_line 和 new_line 可能导致定位问题,优先使用 new_line
96
101
  if (lineInfo.newLine !== undefined) {
97
102
  position.new_line = lineInfo.newLine;
98
- }
99
- if (lineInfo.oldLine !== undefined) {
103
+ } else if (lineInfo.oldLine !== undefined) {
104
+ // 只有在没有 newLine 时才使用 oldLine(纯删除情况)
100
105
  position.old_line = lineInfo.oldLine;
101
106
  }
102
107
 
@@ -203,14 +208,590 @@ export class GitLabClient {
203
208
  * @param {string} projectId - 项目 ID
204
209
  * @param {string} ref - 分支名
205
210
  * @param {string} path - 路径(可选)
211
+ * @param {boolean} recursive - 是否递归获取(默认 false)
206
212
  * @returns {Promise<Array>} 文件树
207
213
  */
208
- async getRepositoryTree(projectId, ref, path = '') {
214
+ async getRepositoryTree(projectId, ref, path = '', recursive = false) {
209
215
  try {
210
- const endpoint = `/projects/${encodeURIComponent(projectId)}/repository/tree?ref=${ref}&path=${encodeURIComponent(path)}&recursive=false`;
211
- return await this.request(endpoint);
216
+ // 获取所有页面的数据
217
+ let allItems = [];
218
+ let page = 1;
219
+ const perPage = 100;
220
+
221
+ while (true) {
222
+ const endpoint = `/projects/${encodeURIComponent(projectId)}/repository/tree?ref=${encodeURIComponent(ref)}&path=${encodeURIComponent(path)}&recursive=${recursive}&per_page=${perPage}&page=${page}`;
223
+
224
+ const response = await fetch(`${this.apiUrl}${endpoint}`, {
225
+ headers: {
226
+ 'PRIVATE-TOKEN': this.token,
227
+ 'Content-Type': 'application/json',
228
+ },
229
+ });
230
+
231
+ if (!response.ok) {
232
+ console.warn(`❌ 获取仓库树失败: ${response.status} ${response.statusText}`);
233
+ return allItems;
234
+ }
235
+
236
+ const items = await response.json();
237
+
238
+ if (!Array.isArray(items) || items.length === 0) {
239
+ break;
240
+ }
241
+
242
+ allItems = allItems.concat(items);
243
+
244
+ if (items.length < perPage) {
245
+ break;
246
+ }
247
+
248
+ page++;
249
+
250
+ // 安全限制:最多获取 10 页
251
+ if (page > 10) {
252
+ break;
253
+ }
254
+ }
255
+
256
+ return allItems;
257
+ } catch (error) {
258
+ console.warn(`❌ 获取仓库树失败:`, error.message);
259
+ return [];
260
+ }
261
+ }
262
+
263
+ /**
264
+ * 获取整个项目的源代码文件
265
+ * @param {string} projectId - 项目 ID
266
+ * @param {string} ref - 分支名
267
+ * @param {Object} options - 选项
268
+ * @param {Array} options.extensions - 要获取的文件扩展名(默认常见源代码文件)
269
+ * @param {Array} options.excludePaths - 排除的路径模式
270
+ * @param {number} options.maxFiles - 最大文件数量(默认 50)
271
+ * @param {number} options.maxFileSize - 最大单文件行数(默认 300)
272
+ * @returns {Promise<Array>} 文件内容数组 [{ path, content }]
273
+ */
274
+ async getProjectSourceFiles(projectId, ref, options = {}) {
275
+ const {
276
+ extensions = ['.js', '.ts', '.tsx', '.jsx', '.vue', '.py', '.go', '.java', '.rb', '.php', '.cs', '.cpp', '.c', '.h'],
277
+ excludePaths = ['node_modules', 'dist', 'build', '.git', 'vendor', '__pycache__', '.next', 'coverage'],
278
+ } = options;
279
+
280
+ console.log(`📂 获取项目源代码文件 (分支: ${ref})...`);
281
+
282
+ // 递归获取所有文件
283
+ const tree = await this.getRepositoryTree(projectId, ref, '', true);
284
+
285
+ // 过滤源代码文件
286
+ const sourceFiles = tree.filter(item => {
287
+ if (item.type !== 'blob') return false;
288
+
289
+ // 检查扩展名
290
+ const hasValidExtension = extensions.some(ext => item.path.endsWith(ext));
291
+ if (!hasValidExtension) return false;
292
+
293
+ // 检查排除路径
294
+ const isExcluded = excludePaths.some(excludePath => item.path.includes(excludePath));
295
+ if (isExcluded) return false;
296
+
297
+ return true;
298
+ });
299
+
300
+ console.log(` - 找到 ${sourceFiles.length} 个源代码文件`);
301
+
302
+ // 获取所有文件内容(不限制数量和大小)
303
+ const filesContent = [];
304
+
305
+ for (const file of sourceFiles) {
306
+ try {
307
+ const content = await this.getProjectFile(projectId, file.path, ref);
308
+ if (content) {
309
+ const lines = content.split('\n');
310
+ filesContent.push({
311
+ path: file.path,
312
+ content: content,
313
+ totalLines: lines.length,
314
+ });
315
+ }
316
+ } catch (error) {
317
+ // 静默处理获取失败的文件
318
+ }
319
+ }
320
+
321
+ console.log(` ✓ 成功获取 ${filesContent.length} 个文件的内容`);
322
+
323
+ return filesContent;
324
+ }
325
+
326
+ /**
327
+ * 🎯 获取整个项目的所有代码文件(用于生成 reviewguard.md)
328
+ * @param {string} projectId - 项目 ID
329
+ * @param {string} ref - 分支名(默认 main)
330
+ * @param {Object} options - 选项
331
+ * @param {number} options.maxTotalChars - 最大总字符数(默认 500000,约 500K)
332
+ * @param {number} options.maxSingleFileChars - 单个文件最大字符数(默认 50000)
333
+ * @returns {Promise<Object>} { files: Array, techStack: Object, stats: Object }
334
+ */
335
+ async getFullProjectForAnalysis(projectId, ref = 'main', options = {}) {
336
+ const {
337
+ fullMode = true, // 🎯 默认使用完整模式
338
+ maxTotalChars = fullMode ? 500000 : 25000, // 完整模式:500K,轻量模式:25K
339
+ maxConfigFiles = fullMode ? 50 : 8, // 完整模式:50个,轻量模式:8个
340
+ maxSampleFiles = fullMode ? 100 : 5, // 完整模式:100个,轻量模式:5个
341
+ maxFileSize = fullMode ? 50000 : 8000, // 单文件上限:50K vs 8K
342
+ } = options;
343
+
344
+ const modeLabel = fullMode ? '📦 完整模式' : '🚀 轻量模式';
345
+
346
+ console.log('\n' + '='.repeat(60));
347
+ console.log(`📊 正在通过 GitLab API 分析项目(${fullMode ? '完整模式' : '轻量模式'})...`);
348
+ console.log('='.repeat(60));
349
+ console.log(` - 项目 ID: ${projectId}`);
350
+ console.log(` - 分支: ${ref}`);
351
+ console.log(` - 模式: ${modeLabel}`);
352
+ console.log(` - 最大总字符数: ${(maxTotalChars / 1000).toFixed(0)}K`);
353
+
354
+ // 排除的路径(目录)
355
+ const excludePaths = [
356
+ 'node_modules', 'dist', 'build', '.git', 'vendor',
357
+ '__pycache__', '.next', '.nuxt', 'coverage', 'out',
358
+ '.venv', 'venv', 'target', 'bin', 'obj',
359
+ // 🎯 排除静态资源目录
360
+ 'assets', 'images', 'img', 'icons', 'fonts',
361
+ 'public', 'static',
362
+ // 排除测试和配置目录
363
+ 'test', 'tests', '__tests__', 'spec', 'e2e',
364
+ '.github', '.vscode', '.idea', '.husky',
365
+ ];
366
+
367
+ // 🎯 排除的文件扩展名(非代码文件)
368
+ const excludeExtensions = [
369
+ // 图片
370
+ '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.webp', '.bmp',
371
+ // 字体
372
+ '.woff', '.woff2', '.ttf', '.eot', '.otf',
373
+ // 媒体
374
+ '.mp3', '.mp4', '.wav', '.avi', '.mov', '.webm',
375
+ // 其他二进制
376
+ '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
377
+ '.zip', '.tar', '.gz', '.rar', '.7z',
378
+ '.exe', '.dll', '.so', '.dylib',
379
+ // 锁文件
380
+ '.lock', '-lock.json', '-lock.yaml',
381
+ ];
382
+
383
+ // 🎯 关键配置文件(优先获取)
384
+ const keyConfigFiles = [
385
+ 'package.json', 'tsconfig.json',
386
+ 'vite.config.ts', 'vite.config.js',
387
+ 'webpack.config.js', 'tailwind.config.js',
388
+ '.eslintrc.js', '.eslintrc.json', 'eslint.config.js',
389
+ 'requirements.txt', 'pyproject.toml',
390
+ 'go.mod', 'Cargo.toml', 'pom.xml',
391
+ ];
392
+
393
+ // 代码文件扩展名(只读取这些类型的代码文件)
394
+ const codeExtensions = ['.ts', '.tsx', '.js', '.jsx', '.vue', '.py', '.go', '.java', '.rs', '.rb', '.php', '.cs', '.cpp', '.c', '.h'];
395
+
396
+ // 1. 获取项目文件树
397
+ console.log('\n📂 获取项目文件树...');
398
+ const tree = await this.getRepositoryTree(projectId, ref, '', true);
399
+ console.log(` 找到 ${tree.length} 个文件/目录`);
400
+
401
+ // 2. 过滤并分类文件
402
+ const allFiles = tree.filter(item => {
403
+ if (item.type !== 'blob') return false;
404
+ // 排除指定目录
405
+ if (excludePaths.some(ex => item.path.includes(`/${ex}/`) || item.path.startsWith(`${ex}/`))) {
406
+ return false;
407
+ }
408
+ // 排除指定扩展名
409
+ if (excludeExtensions.some(ext => item.path.toLowerCase().endsWith(ext))) {
410
+ return false;
411
+ }
412
+ return true;
413
+ });
414
+
415
+ // 分类:配置文件 vs 代码文件
416
+ const configFiles = [];
417
+ const sourceFiles = [];
418
+
419
+ for (const file of allFiles) {
420
+ const fileName = file.path.split('/').pop();
421
+ const isConfig = keyConfigFiles.some(cf => fileName === cf || fileName.startsWith(cf.split('.')[0]));
422
+ const isCode = codeExtensions.some(ext => file.path.endsWith(ext));
423
+
424
+ if (isConfig) configFiles.push(file);
425
+ else if (isCode) sourceFiles.push(file);
426
+ }
427
+
428
+ console.log(` 配置文件: ${configFiles.length} 个`);
429
+ console.log(` 代码文件: ${sourceFiles.length} 个`);
430
+
431
+ // 3. 构建目录结构树
432
+ const directoryTree = this.buildDirectoryTree(allFiles.map(f => f.path));
433
+
434
+ // 4. 获取关键文件内容
435
+ console.log(`\n📖 读取关键文件(${fullMode ? '完整模式' : '轻量模式'})...`);
436
+ const files = [];
437
+ let totalChars = 0;
438
+
439
+ // 4.1 优先获取配置文件(完整内容)
440
+ for (const file of configFiles.slice(0, maxConfigFiles)) {
441
+ if (totalChars >= maxTotalChars) break;
442
+
443
+ try {
444
+ const content = await this.getProjectFile(projectId, file.path, ref);
445
+ if (content && content.length < maxFileSize) {
446
+ files.push({ path: file.path, content, size: content.length, type: 'config' });
447
+ totalChars += content.length;
448
+ console.log(` ✅ ${file.path} (${(content.length / 1000).toFixed(1)}K)`);
449
+ }
450
+ } catch (e) { /* skip */ }
451
+ }
452
+
453
+ // 4.2 获取代码文件
454
+ if (fullMode) {
455
+ // 🎯 完整模式:获取所有代码文件的完整内容
456
+ console.log('\n📝 读取所有代码文件(完整内容)...');
457
+ for (const file of sourceFiles.slice(0, maxSampleFiles)) {
458
+ if (totalChars >= maxTotalChars) {
459
+ console.log(` ⚠️ 已达到字符上限 ${(maxTotalChars / 1000).toFixed(0)}K,停止读取更多文件`);
460
+ break;
461
+ }
462
+
463
+ try {
464
+ const content = await this.getProjectFile(projectId, file.path, ref);
465
+ if (content && content.length < maxFileSize) {
466
+ files.push({ path: file.path, content, size: content.length, type: 'source' });
467
+ totalChars += content.length;
468
+ console.log(` ✅ ${file.path} (${(content.length / 1000).toFixed(1)}K)`);
469
+ } else if (content) {
470
+ // 文件太大,截取前面部分
471
+ const truncated = content.slice(0, maxFileSize);
472
+ files.push({ path: file.path, content: truncated + '\n// ... 文件过大,已截断 ...', size: truncated.length, type: 'source', truncated: true });
473
+ totalChars += truncated.length;
474
+ console.log(` 📝 ${file.path} (截断: ${(truncated.length / 1000).toFixed(1)}K / ${(content.length / 1000).toFixed(1)}K)`);
475
+ }
476
+ } catch (e) { /* skip */ }
477
+ }
478
+ } else {
479
+ // 🚀 轻量模式:只获取入口文件的摘要
480
+ const entryPatterns = ['index.ts', 'index.js', 'main.ts', 'main.js', 'App.tsx', 'App.vue', 'app.py', 'main.go'];
481
+ const entryFiles = sourceFiles.filter(f => entryPatterns.some(p => f.path.endsWith(p)));
482
+ const sampleFiles = [...entryFiles, ...sourceFiles.slice(0, 3)].slice(0, maxSampleFiles);
483
+
484
+ for (const file of sampleFiles) {
485
+ if (totalChars >= maxTotalChars) break;
486
+
487
+ try {
488
+ const content = await this.getProjectFile(projectId, file.path, ref);
489
+ if (content) {
490
+ // 只取简短摘要
491
+ const summary = this.extractFileSummary(content, file.path, 25);
492
+ if (totalChars + summary.length <= maxTotalChars) {
493
+ files.push({ path: file.path, content: summary, size: summary.length, originalSize: content.length, type: 'sample' });
494
+ totalChars += summary.length;
495
+ console.log(` 📝 ${file.path} (摘要: ${(summary.length / 1000).toFixed(1)}K)`);
496
+ }
497
+ }
498
+ } catch (e) { /* skip */ }
499
+ }
500
+ }
501
+
502
+ // 5. 检测技术栈
503
+ const techStack = this.detectTechStackFromFiles(files);
504
+
505
+ // 6. 统计信息
506
+ const stats = {
507
+ totalFiles: files.length,
508
+ totalChars: totalChars,
509
+ configFilesCount: configFiles.length,
510
+ sourceFilesCount: sourceFiles.length,
511
+ mode: fullMode ? 'full' : 'lite',
512
+ };
513
+
514
+ console.log('\n' + '='.repeat(60));
515
+ console.log(`✅ 项目分析完成(${fullMode ? '完整模式' : '轻量模式'})`);
516
+ console.log(` - 获取文件数: ${stats.totalFiles}`);
517
+ console.log(` - 总字符数: ${(stats.totalChars / 1000).toFixed(1)}K`);
518
+ console.log(` - 项目配置文件: ${stats.configFilesCount} 个`);
519
+ console.log(` - 项目代码文件: ${stats.sourceFilesCount} 个`);
520
+ console.log('='.repeat(60) + '\n');
521
+
522
+ return {
523
+ files,
524
+ techStack,
525
+ stats,
526
+ directoryTree, // 📁 目录结构树
527
+ fileList: allFiles.map(f => f.path), // 📋 完整文件列表
528
+ };
529
+ }
530
+
531
+ /**
532
+ * 构建目录结构树(文本格式)
533
+ * @param {Array} filePaths - 文件路径数组
534
+ * @returns {string} 目录树文本
535
+ */
536
+ buildDirectoryTree(filePaths) {
537
+ // 按目录分组统计
538
+ const dirStats = {};
539
+ for (const path of filePaths) {
540
+ const parts = path.split('/');
541
+ if (parts.length > 1) {
542
+ const dir = parts.slice(0, -1).join('/');
543
+ dirStats[dir] = (dirStats[dir] || 0) + 1;
544
+ } else {
545
+ dirStats['.'] = (dirStats['.'] || 0) + 1;
546
+ }
547
+ }
548
+
549
+ // 构建简化的目录树
550
+ const lines = ['📦 项目结构'];
551
+ const topDirs = Object.entries(dirStats)
552
+ .sort((a, b) => b[1] - a[1])
553
+ .slice(0, 20); // 只显示前 20 个目录
554
+
555
+ for (const [dir, count] of topDirs) {
556
+ const depth = dir.split('/').length;
557
+ const indent = ' '.repeat(Math.min(depth, 3));
558
+ lines.push(`${indent}📁 ${dir}/ (${count} 个文件)`);
559
+ }
560
+
561
+ if (Object.keys(dirStats).length > 20) {
562
+ lines.push(` ... 还有 ${Object.keys(dirStats).length - 20} 个目录`);
563
+ }
564
+
565
+ lines.push(`\n📊 总计: ${filePaths.length} 个文件`);
566
+ return lines.join('\n');
567
+ }
568
+
569
+ /**
570
+ * 提取文件摘要(用于大文件)
571
+ * 提取:imports、exports、函数/类签名、关键注释
572
+ * @param {string} content - 文件内容
573
+ * @param {string} filePath - 文件路径
574
+ * @param {number} maxLines - 最大保留行数
575
+ * @returns {string} 文件摘要
576
+ */
577
+ extractFileSummary(content, filePath, maxLines = 100) {
578
+ const lines = content.split('\n');
579
+ const ext = filePath.split('.').pop()?.toLowerCase() || '';
580
+ const fileName = filePath.split('/').pop() || filePath;
581
+
582
+ // 配置文件:保留完整内容(通常很重要)
583
+ const configFiles = ['package.json', 'tsconfig.json', 'vite.config', 'webpack.config', '.eslintrc', '.prettierrc'];
584
+ if (configFiles.some(cf => fileName.includes(cf))) {
585
+ // 配置文件只截断,不做摘要
586
+ if (lines.length <= maxLines * 2) {
587
+ return content;
588
+ }
589
+ return lines.slice(0, maxLines * 2).join('\n') + `\n// ... 省略 ${lines.length - maxLines * 2} 行 ...`;
590
+ }
591
+
592
+ const summary = [];
593
+ summary.push(`// 📄 ${filePath}`);
594
+ summary.push(`// 原文件共 ${lines.length} 行,以下为摘要`);
595
+ summary.push('');
596
+
597
+ // 提取 imports
598
+ const imports = lines.filter(l => {
599
+ const trimmed = l.trim();
600
+ return trimmed.startsWith('import ') ||
601
+ trimmed.startsWith('from ') ||
602
+ trimmed.match(/^(const|let|var)\s+.*=\s*require\(/);
603
+ });
604
+ if (imports.length > 0) {
605
+ summary.push('// === Imports ===');
606
+ summary.push(...imports.slice(0, 15));
607
+ if (imports.length > 15) {
608
+ summary.push(`// ... 还有 ${imports.length - 15} 个 imports`);
609
+ }
610
+ summary.push('');
611
+ }
612
+
613
+ // 提取 exports 和函数/类/接口定义
614
+ const definitions = [];
615
+ for (let i = 0; i < lines.length; i++) {
616
+ const line = lines[i];
617
+ const trimmed = line.trim();
618
+
619
+ // export 语句
620
+ if (trimmed.startsWith('export ')) {
621
+ // 如果是多行定义,尝试获取完整签名
622
+ if (trimmed.match(/export\s+(default\s+)?(function|class|interface|type|const|let|enum)/)) {
623
+ let def = line;
624
+ // 获取到第一个 { 或 = 后的几行
625
+ let j = i + 1;
626
+ while (j < lines.length && j < i + 5 && !lines[j - 1].includes('{') && !lines[j - 1].includes(';')) {
627
+ def += '\n' + lines[j];
628
+ j++;
629
+ }
630
+ definitions.push(def.split('{')[0].trim() + (def.includes('{') ? ' { ... }' : ''));
631
+ } else {
632
+ definitions.push(trimmed);
633
+ }
634
+ }
635
+ // 非 export 的函数/类定义
636
+ else if (trimmed.match(/^(async\s+)?function\s+\w+/) ||
637
+ trimmed.match(/^class\s+\w+/) ||
638
+ trimmed.match(/^interface\s+\w+/) ||
639
+ trimmed.match(/^type\s+\w+\s*=/)) {
640
+ definitions.push(trimmed.split('{')[0].trim() + (trimmed.includes('{') ? ' { ... }' : ''));
641
+ }
642
+ // React 组件(箭头函数)
643
+ else if (trimmed.match(/^(export\s+)?(const|let)\s+\w+\s*[=:]\s*(React\.FC|FC|\(\s*\w*\s*\)|<)/)) {
644
+ definitions.push(trimmed.split('=>')[0].trim() + ' => ...');
645
+ }
646
+ }
647
+
648
+ if (definitions.length > 0) {
649
+ summary.push('// === Exports & Definitions ===');
650
+ summary.push(...definitions.slice(0, 25));
651
+ if (definitions.length > 25) {
652
+ summary.push(`// ... 还有 ${definitions.length - 25} 个定义`);
653
+ }
654
+ summary.push('');
655
+ }
656
+
657
+ // 如果摘要太短,添加一些代码片段
658
+ if (summary.length < 20 && lines.length > 20) {
659
+ summary.push('// === 代码片段 ===');
660
+ summary.push(...lines.slice(0, Math.min(30, maxLines)).filter(l => l.trim()));
661
+ summary.push('// ... 省略剩余代码 ...');
662
+ }
663
+
664
+ return summary.join('\n');
665
+ }
666
+
667
+ /**
668
+ * 从文件内容中检测技术栈
669
+ * @param {Array} files - 文件数组
670
+ * @returns {Object} 技术栈信息
671
+ */
672
+ detectTechStackFromFiles(files) {
673
+ const techStack = {
674
+ languages: new Set(),
675
+ frameworks: new Set(),
676
+ tools: new Set(),
677
+ packageManager: null,
678
+ };
679
+
680
+ // 查找 package.json
681
+ const packageJson = files.find(f => f.path === 'package.json' || f.path.endsWith('/package.json'));
682
+ if (packageJson) {
683
+ try {
684
+ const pkg = JSON.parse(packageJson.content);
685
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
686
+
687
+ // 检测语言
688
+ if (files.some(f => f.path.endsWith('.ts') || f.path.endsWith('.tsx'))) {
689
+ techStack.languages.add('TypeScript');
690
+ }
691
+ if (files.some(f => f.path.endsWith('.js') || f.path.endsWith('.jsx'))) {
692
+ techStack.languages.add('JavaScript');
693
+ }
694
+
695
+ // 检测框架
696
+ if (deps.react) techStack.frameworks.add('React');
697
+ if (deps.vue) techStack.frameworks.add('Vue');
698
+ if (deps.next) techStack.frameworks.add('Next.js');
699
+ if (deps.nuxt) techStack.frameworks.add('Nuxt.js');
700
+ if (deps.svelte) techStack.frameworks.add('Svelte');
701
+ if (deps['@angular/core']) techStack.frameworks.add('Angular');
702
+ if (deps['@radix-ui/react-dialog']) techStack.frameworks.add('Radix UI');
703
+ if (deps['@headlessui/react']) techStack.frameworks.add('Headless UI');
704
+
705
+ // 检测工具
706
+ if (deps.vite) techStack.tools.add('Vite');
707
+ if (deps.webpack) techStack.tools.add('Webpack');
708
+ if (deps.tailwindcss) techStack.tools.add('Tailwind CSS');
709
+ if (deps.eslint) techStack.tools.add('ESLint');
710
+ if (deps.prettier) techStack.tools.add('Prettier');
711
+ } catch (e) {
712
+ // 忽略解析错误
713
+ }
714
+ }
715
+
716
+ // 检测其他语言
717
+ if (files.some(f => f.path.endsWith('.py'))) techStack.languages.add('Python');
718
+ if (files.some(f => f.path.endsWith('.go'))) techStack.languages.add('Go');
719
+ if (files.some(f => f.path.endsWith('.java'))) techStack.languages.add('Java');
720
+ if (files.some(f => f.path.endsWith('.vue'))) techStack.languages.add('Vue');
721
+
722
+ return {
723
+ languages: Array.from(techStack.languages),
724
+ frameworks: Array.from(techStack.frameworks),
725
+ tools: Array.from(techStack.tools),
726
+ };
727
+ }
728
+
729
+ /**
730
+ * 从 Package Registry 读取文件
731
+ * @param {string} projectId - 项目 ID
732
+ * @param {string} packageName - 包名(如 'ai-review-data')
733
+ * @param {string} packageVersion - 版本(如 'latest')
734
+ * @param {string} fileName - 文件名(如 'review-history.json')
735
+ * @returns {Promise<Object|null>} 文件内容(JSON)或 null
736
+ */
737
+ async getPackageFile(projectId, packageName, packageVersion, fileName) {
738
+ try {
739
+ const url = `${this.apiUrl}/projects/${encodeURIComponent(projectId)}/packages/generic/${packageName}/${packageVersion}/${fileName}`;
740
+
741
+ const response = await fetch(url, {
742
+ headers: {
743
+ 'PRIVATE-TOKEN': this.token,
744
+ },
745
+ });
746
+
747
+ if (!response.ok) {
748
+ if (response.status === 404) {
749
+ console.log(`📦 Package Registry 中未找到 ${fileName},将创建新文件`);
750
+ return null;
751
+ }
752
+ console.warn(`⚠️ 获取 Package Registry 文件失败: ${response.status}`);
753
+ return null;
754
+ }
755
+
756
+ const content = await response.text();
757
+ return JSON.parse(content);
758
+ } catch (error) {
759
+ console.warn(`⚠️ 读取 Package Registry 失败: ${error.message}`);
760
+ return null;
761
+ }
762
+ }
763
+
764
+ /**
765
+ * 获取 MR 的单条评论内容
766
+ * @param {string} projectId - 项目 ID
767
+ * @param {number} mergeRequestIid - MR IID
768
+ * @param {number} noteId - 评论 ID
769
+ * @returns {Promise<Object|null>} 评论内容或 null
770
+ */
771
+ async getMergeRequestNote(projectId, mergeRequestIid, noteId) {
772
+ try {
773
+ return await this.request(
774
+ `/projects/${encodeURIComponent(projectId)}/merge_requests/${mergeRequestIid}/notes/${noteId}`
775
+ );
776
+ } catch (error) {
777
+ console.warn(`⚠️ 获取评论 ${noteId} 失败: ${error.message}`);
778
+ return null;
779
+ }
780
+ }
781
+
782
+ /**
783
+ * 获取 MR 的所有评论
784
+ * @param {string} projectId - 项目 ID
785
+ * @param {number} mergeRequestIid - MR IID
786
+ * @returns {Promise<Array>} 评论列表
787
+ */
788
+ async getMergeRequestNotes(projectId, mergeRequestIid) {
789
+ try {
790
+ return await this.request(
791
+ `/projects/${encodeURIComponent(projectId)}/merge_requests/${mergeRequestIid}/notes?per_page=100`
792
+ );
212
793
  } catch (error) {
213
- console.warn(`获取仓库树失败:`, error.message);
794
+ console.warn(`⚠️ 获取 MR 评论失败: ${error.message}`);
214
795
  return [];
215
796
  }
216
797
  }