gitlab-ai-review 2.5.3 → 3.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 +46 -0
- package/cli.js +72 -59
- package/index.js +158 -48
- package/lib/diff-parser.js +0 -31
- package/lib/gitlab-client.js +69 -0
- package/lib/impact-analyzer.js +455 -0
- package/lib/prompt-tools.js +219 -64
- package/package.json +17 -4
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 影响分析器 - 分析代码变更对其他文件的影响
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 从文件内容中提取导入/导出信息
|
|
7
|
+
* @param {string} content - 文件内容
|
|
8
|
+
* @param {string} fileName - 文件名
|
|
9
|
+
* @returns {Object} 导入导出信息
|
|
10
|
+
*/
|
|
11
|
+
export function extractImportsExports(content, fileName) {
|
|
12
|
+
const imports = [];
|
|
13
|
+
const exports = [];
|
|
14
|
+
|
|
15
|
+
// JavaScript/TypeScript 导入
|
|
16
|
+
const importRegex = /import\s+(?:{([^}]+)}|(\w+)|\*\s+as\s+(\w+))\s+from\s+['"]([^'"]+)['"]/g;
|
|
17
|
+
let match;
|
|
18
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
19
|
+
const namedImports = match[1] ? match[1].split(',').map(s => s.trim()) : [];
|
|
20
|
+
const defaultImport = match[2];
|
|
21
|
+
const namespaceImport = match[3];
|
|
22
|
+
const from = match[4];
|
|
23
|
+
|
|
24
|
+
imports.push({
|
|
25
|
+
type: match[1] ? 'named' : match[2] ? 'default' : 'namespace',
|
|
26
|
+
names: namedImports,
|
|
27
|
+
default: defaultImport,
|
|
28
|
+
namespace: namespaceImport,
|
|
29
|
+
from: from,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 导出
|
|
34
|
+
const exportRegex = /export\s+(?:default\s+)?(?:class|function|const|let|var|interface|type|enum)\s+(\w+)/g;
|
|
35
|
+
while ((match = exportRegex.exec(content)) !== null) {
|
|
36
|
+
exports.push({
|
|
37
|
+
name: match[1],
|
|
38
|
+
line: content.substring(0, match.index).split('\n').length,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Vue 文件特殊处理
|
|
43
|
+
if (fileName.endsWith('.vue')) {
|
|
44
|
+
// 提取组件名
|
|
45
|
+
const componentNameMatch = content.match(/export\s+default\s+{[\s\S]*?name:\s*['"](\w+)['"]/);
|
|
46
|
+
if (componentNameMatch) {
|
|
47
|
+
exports.push({
|
|
48
|
+
name: componentNameMatch[1],
|
|
49
|
+
type: 'vue-component',
|
|
50
|
+
line: content.substring(0, componentNameMatch.index).split('\n').length,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { imports, exports };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 从 diff 中提取变更的函数/类/组件名称
|
|
60
|
+
* @param {string} diff - diff 内容
|
|
61
|
+
* @param {string} fileName - 文件名
|
|
62
|
+
* @returns {Object} 变更的符号列表 { added: [], deleted: [], modified: [] }
|
|
63
|
+
*/
|
|
64
|
+
export function extractChangedSymbols(diff, fileName) {
|
|
65
|
+
if (!diff) return { added: [], deleted: [], modified: [] };
|
|
66
|
+
|
|
67
|
+
const addedSymbols = [];
|
|
68
|
+
const deletedSymbols = [];
|
|
69
|
+
const lines = diff.split('\n');
|
|
70
|
+
|
|
71
|
+
// 匹配函数、类、常量定义
|
|
72
|
+
const definitionPatterns = [
|
|
73
|
+
/(?:export\s+)?(?:function|const|let|var)\s+(\w+)/, // 函数/变量
|
|
74
|
+
/(?:export\s+)?class\s+(\w+)/, // 类
|
|
75
|
+
/(?:export\s+)?interface\s+(\w+)/, // 接口
|
|
76
|
+
/(?:export\s+)?type\s+(\w+)/, // 类型
|
|
77
|
+
/(?:export\s+)?enum\s+(\w+)/, // 枚举
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
lines.forEach((line, index) => {
|
|
81
|
+
const isAddition = line.startsWith('+') && !line.startsWith('+++');
|
|
82
|
+
const isDeletion = line.startsWith('-') && !line.startsWith('---');
|
|
83
|
+
|
|
84
|
+
if (isAddition || isDeletion) {
|
|
85
|
+
const cleanLine = line.substring(1); // 移除 +/- 前缀
|
|
86
|
+
|
|
87
|
+
// 检查定义
|
|
88
|
+
definitionPatterns.forEach(pattern => {
|
|
89
|
+
const match = cleanLine.match(pattern);
|
|
90
|
+
if (match) {
|
|
91
|
+
const symbol = {
|
|
92
|
+
name: match[1],
|
|
93
|
+
type: 'definition',
|
|
94
|
+
line: index + 1,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
if (isAddition) {
|
|
98
|
+
addedSymbols.push(symbol);
|
|
99
|
+
} else {
|
|
100
|
+
deletedSymbols.push(symbol);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// 匹配函数调用
|
|
106
|
+
if (isAddition) {
|
|
107
|
+
const callPattern = /(\w+)\s*\(/g;
|
|
108
|
+
let callMatch;
|
|
109
|
+
while ((callMatch = callPattern.exec(cleanLine)) !== null) {
|
|
110
|
+
addedSymbols.push({
|
|
111
|
+
name: callMatch[1],
|
|
112
|
+
type: 'usage',
|
|
113
|
+
line: index + 1,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// 找出被修改的符号(既被删除又被添加)
|
|
121
|
+
const modifiedSymbols = [];
|
|
122
|
+
const deletedNames = new Set(deletedSymbols.map(s => s.name));
|
|
123
|
+
const addedNames = new Set(addedSymbols.map(s => s.name));
|
|
124
|
+
|
|
125
|
+
deletedNames.forEach(name => {
|
|
126
|
+
if (addedNames.has(name)) {
|
|
127
|
+
modifiedSymbols.push({ name, type: 'modified' });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
added: addedSymbols,
|
|
133
|
+
deleted: deletedSymbols,
|
|
134
|
+
modified: modifiedSymbols,
|
|
135
|
+
all: [...addedSymbols, ...deletedSymbols], // 所有变更的符号
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 检查文件内部是否使用了指定的符号
|
|
141
|
+
* @param {string} content - 文件内容
|
|
142
|
+
* @param {Array} symbols - 要检查的符号列表(通常是被删除的符号)
|
|
143
|
+
* @returns {Array} 文件内部使用情况
|
|
144
|
+
*/
|
|
145
|
+
export function checkInternalUsage(content, symbols) {
|
|
146
|
+
if (!content || !symbols || symbols.length === 0) {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const usages = [];
|
|
151
|
+
const lines = content.split('\n');
|
|
152
|
+
|
|
153
|
+
symbols.forEach(symbol => {
|
|
154
|
+
const symbolName = symbol.name || symbol;
|
|
155
|
+
const regex = new RegExp(`\\b${symbolName}\\b`, 'g');
|
|
156
|
+
|
|
157
|
+
lines.forEach((line, index) => {
|
|
158
|
+
// 跳过定义行(避免匹配定义本身)
|
|
159
|
+
if (line.match(/(?:export\s+)?(?:function|const|let|var|class)\s+/) &&
|
|
160
|
+
line.includes(symbolName)) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (regex.test(line)) {
|
|
165
|
+
usages.push({
|
|
166
|
+
symbol: symbolName,
|
|
167
|
+
lineNumber: index + 1,
|
|
168
|
+
line: line.trim(),
|
|
169
|
+
context: {
|
|
170
|
+
before: lines.slice(Math.max(0, index - 2), index).map(l => l.trim()),
|
|
171
|
+
after: lines.slice(index + 1, index + 3).map(l => l.trim()),
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return usages;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 搜索项目中使用了指定符号的文件
|
|
183
|
+
* @param {Object} gitlabClient - GitLab 客户端
|
|
184
|
+
* @param {string} projectId - 项目 ID
|
|
185
|
+
* @param {string} ref - 分支名
|
|
186
|
+
* @param {Array} symbols - 要搜索的符号列表
|
|
187
|
+
* @returns {Promise<Array>} 使用了这些符号的文件列表
|
|
188
|
+
*/
|
|
189
|
+
export async function searchSymbolUsage(gitlabClient, projectId, ref, symbols) {
|
|
190
|
+
if (!symbols || symbols.length === 0) {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const affectedFiles = new Map();
|
|
195
|
+
|
|
196
|
+
// 对每个符号进行搜索
|
|
197
|
+
for (const symbol of symbols) {
|
|
198
|
+
try {
|
|
199
|
+
const symbolName = symbol.name || symbol;
|
|
200
|
+
// 使用 GitLab Search API 搜索代码
|
|
201
|
+
const searchResults = await gitlabClient.searchInProject(
|
|
202
|
+
projectId,
|
|
203
|
+
symbolName,
|
|
204
|
+
ref
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
if (searchResults && searchResults.length > 0) {
|
|
208
|
+
searchResults.forEach(result => {
|
|
209
|
+
const key = result.path || result.filename;
|
|
210
|
+
if (!affectedFiles.has(key)) {
|
|
211
|
+
affectedFiles.set(key, {
|
|
212
|
+
path: key,
|
|
213
|
+
symbols: [],
|
|
214
|
+
lines: [],
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const fileInfo = affectedFiles.get(key);
|
|
219
|
+
fileInfo.symbols.push(symbolName);
|
|
220
|
+
if (result.startline) {
|
|
221
|
+
fileInfo.lines.push(result.startline);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
} catch (error) {
|
|
226
|
+
console.warn(`搜索符号 ${symbol.name || symbol} 失败:`, error.message);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return Array.from(affectedFiles.values());
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 提取文件中特定符号周围的代码片段
|
|
235
|
+
* @param {string} content - 文件内容
|
|
236
|
+
* @param {string} symbolName - 符号名称
|
|
237
|
+
* @param {number} contextLines - 上下文行数
|
|
238
|
+
* @returns {Array} 代码片段数组
|
|
239
|
+
*/
|
|
240
|
+
export function extractCodeSnippets(content, symbolName, contextLines = 5) {
|
|
241
|
+
const lines = content.split('\n');
|
|
242
|
+
const snippets = [];
|
|
243
|
+
|
|
244
|
+
lines.forEach((line, index) => {
|
|
245
|
+
if (line.includes(symbolName)) {
|
|
246
|
+
const start = Math.max(0, index - contextLines);
|
|
247
|
+
const end = Math.min(lines.length, index + contextLines + 1);
|
|
248
|
+
|
|
249
|
+
snippets.push({
|
|
250
|
+
lineNumber: index + 1,
|
|
251
|
+
snippet: lines.slice(start, end).join('\n'),
|
|
252
|
+
startLine: start + 1,
|
|
253
|
+
endLine: end,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
return snippets;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* 获取变更文件的函数/类签名
|
|
263
|
+
* @param {string} content - 文件内容
|
|
264
|
+
* @param {Array} changedSymbols - 变更的符号列表
|
|
265
|
+
* @returns {Array} 函数签名列表
|
|
266
|
+
*/
|
|
267
|
+
export function extractSignatures(content, changedSymbols) {
|
|
268
|
+
const signatures = [];
|
|
269
|
+
const lines = content.split('\n');
|
|
270
|
+
|
|
271
|
+
changedSymbols.forEach(symbol => {
|
|
272
|
+
// 找到定义该符号的行
|
|
273
|
+
const definitionLineIndex = lines.findIndex(line =>
|
|
274
|
+
line.includes(`function ${symbol.name}`) ||
|
|
275
|
+
line.includes(`const ${symbol.name}`) ||
|
|
276
|
+
line.includes(`class ${symbol.name}`) ||
|
|
277
|
+
line.includes(`interface ${symbol.name}`)
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
if (definitionLineIndex !== -1) {
|
|
281
|
+
// 提取函数签名(可能跨多行)
|
|
282
|
+
let signature = lines[definitionLineIndex];
|
|
283
|
+
let lineIndex = definitionLineIndex;
|
|
284
|
+
|
|
285
|
+
// 如果没有找到完整的签名(例如没有 { 或 =>),继续往下找
|
|
286
|
+
while (lineIndex < lines.length - 1 &&
|
|
287
|
+
!signature.includes('{') &&
|
|
288
|
+
!signature.includes('=>') &&
|
|
289
|
+
!signature.includes(';')) {
|
|
290
|
+
lineIndex++;
|
|
291
|
+
signature += '\n' + lines[lineIndex];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
signatures.push({
|
|
295
|
+
name: symbol.name,
|
|
296
|
+
signature: signature.trim(),
|
|
297
|
+
lineNumber: definitionLineIndex + 1,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
return signatures;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* 分析代码变更的完整影响
|
|
307
|
+
* @param {Object} options - 配置选项
|
|
308
|
+
* @param {Object} options.gitlabClient - GitLab 客户端
|
|
309
|
+
* @param {string} options.projectId - 项目 ID
|
|
310
|
+
* @param {string} options.ref - 分支名
|
|
311
|
+
* @param {Object} options.change - 代码变更对象
|
|
312
|
+
* @param {number} options.maxAffectedFiles - 最多分析的受影响文件数量
|
|
313
|
+
* @returns {Promise<Object>} 影响分析结果
|
|
314
|
+
*/
|
|
315
|
+
export async function analyzeImpact(options) {
|
|
316
|
+
const {
|
|
317
|
+
gitlabClient,
|
|
318
|
+
projectId,
|
|
319
|
+
ref,
|
|
320
|
+
change,
|
|
321
|
+
maxAffectedFiles = 10,
|
|
322
|
+
} = options;
|
|
323
|
+
|
|
324
|
+
const fileName = change.new_path || change.old_path;
|
|
325
|
+
const diff = change.diff;
|
|
326
|
+
|
|
327
|
+
console.log(`\n🔍 分析文件 ${fileName} 的影响...`);
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
// 1. 提取变更的符号(包括新增、删除、修改)
|
|
331
|
+
const changedSymbols = extractChangedSymbols(diff, fileName);
|
|
332
|
+
const totalSymbols = changedSymbols.all.length;
|
|
333
|
+
|
|
334
|
+
console.log(` 发现变更符号: 新增 ${changedSymbols.added.length}, 删除 ${changedSymbols.deleted.length}, 修改 ${changedSymbols.modified.length}`);
|
|
335
|
+
|
|
336
|
+
if (totalSymbols === 0) {
|
|
337
|
+
return {
|
|
338
|
+
fileName,
|
|
339
|
+
changedSymbols,
|
|
340
|
+
fileContent: null,
|
|
341
|
+
internalUsage: [],
|
|
342
|
+
affectedFiles: [],
|
|
343
|
+
signatures: [],
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// 2. 获取变更文件的完整内容
|
|
348
|
+
let fileContent = null;
|
|
349
|
+
let signatures = [];
|
|
350
|
+
let internalUsage = [];
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
fileContent = await gitlabClient.getProjectFile(projectId, fileName, ref);
|
|
354
|
+
if (fileContent) {
|
|
355
|
+
// 提取函数签名(针对新增和修改的符号)
|
|
356
|
+
const symbolsForSignature = [...changedSymbols.added, ...changedSymbols.modified];
|
|
357
|
+
if (symbolsForSignature.length > 0) {
|
|
358
|
+
signatures = extractSignatures(fileContent, symbolsForSignature);
|
|
359
|
+
console.log(` 提取了 ${signatures.length} 个函数签名`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// 检查文件内部是否使用了被删除的符号
|
|
363
|
+
if (changedSymbols.deleted.length > 0) {
|
|
364
|
+
internalUsage = checkInternalUsage(fileContent, changedSymbols.deleted);
|
|
365
|
+
if (internalUsage.length > 0) {
|
|
366
|
+
console.log(` ⚠️ 文件内部有 ${internalUsage.length} 处使用了被删除的符号`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
} catch (error) {
|
|
371
|
+
console.warn(` 无法获取文件内容:`, error.message);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// 3. 搜索项目中使用了这些符号的其他文件
|
|
375
|
+
// 重点关注:被删除的符号、被修改的符号
|
|
376
|
+
const symbolsToSearch = [
|
|
377
|
+
...changedSymbols.deleted.filter(s => s.type === 'definition'),
|
|
378
|
+
...changedSymbols.modified,
|
|
379
|
+
...changedSymbols.added.filter(s => s.type === 'definition'),
|
|
380
|
+
];
|
|
381
|
+
|
|
382
|
+
const affectedFiles = await searchSymbolUsage(
|
|
383
|
+
gitlabClient,
|
|
384
|
+
projectId,
|
|
385
|
+
ref,
|
|
386
|
+
symbolsToSearch
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
// 过滤掉当前文件本身
|
|
390
|
+
const externalAffectedFiles = affectedFiles.filter(f => f.path !== fileName);
|
|
391
|
+
|
|
392
|
+
console.log(` 找到 ${externalAffectedFiles.length} 个其他文件可能受影响`);
|
|
393
|
+
|
|
394
|
+
// 4. 限制数量并获取代码片段
|
|
395
|
+
const limitedFiles = externalAffectedFiles.slice(0, maxAffectedFiles);
|
|
396
|
+
const filesWithSnippets = await Promise.all(
|
|
397
|
+
limitedFiles.map(async (file) => {
|
|
398
|
+
try {
|
|
399
|
+
const content = await gitlabClient.getProjectFile(projectId, file.path, ref);
|
|
400
|
+
|
|
401
|
+
if (content) {
|
|
402
|
+
const snippets = [];
|
|
403
|
+
file.symbols.forEach(symbol => {
|
|
404
|
+
const symbolSnippets = extractCodeSnippets(content, symbol, 3);
|
|
405
|
+
snippets.push(...symbolSnippets);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
...file,
|
|
410
|
+
snippets: snippets.slice(0, 3), // 每个文件最多 3 个片段
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return file;
|
|
415
|
+
} catch (error) {
|
|
416
|
+
console.warn(` 无法获取文件 ${file.path} 的内容:`, error.message);
|
|
417
|
+
return file;
|
|
418
|
+
}
|
|
419
|
+
})
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
fileName,
|
|
424
|
+
changedSymbols,
|
|
425
|
+
fileContent, // 完整文件内容
|
|
426
|
+
internalUsage, // 文件内部使用情况
|
|
427
|
+
signatures,
|
|
428
|
+
affectedFiles: filesWithSnippets,
|
|
429
|
+
totalAffectedFiles: externalAffectedFiles.length,
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
} catch (error) {
|
|
433
|
+
console.error(` 影响分析失败:`, error.message);
|
|
434
|
+
return {
|
|
435
|
+
fileName,
|
|
436
|
+
error: error.message,
|
|
437
|
+
changedSymbols: { added: [], deleted: [], modified: [], all: [] },
|
|
438
|
+
fileContent: null,
|
|
439
|
+
internalUsage: [],
|
|
440
|
+
affectedFiles: [],
|
|
441
|
+
signatures: [],
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export default {
|
|
447
|
+
extractImportsExports,
|
|
448
|
+
extractChangedSymbols,
|
|
449
|
+
checkInternalUsage,
|
|
450
|
+
searchSymbolUsage,
|
|
451
|
+
extractCodeSnippets,
|
|
452
|
+
extractSignatures,
|
|
453
|
+
analyzeImpact,
|
|
454
|
+
};
|
|
455
|
+
|