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.

Potentially problematic release.


This version of gitlab-ai-review might be problematic. Click here for more details.

@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Diff 解析工具 - 解析 Git Diff 格式
3
+ * 按块拆分:连续的删除+新增组合为一个变更块
3
4
  */
4
5
 
5
6
  /**
@@ -106,9 +107,23 @@ export function calculateNewLineNumber(hunk, changeIndex) {
106
107
  }
107
108
 
108
109
  /**
109
- * 提取有意义的变更(忽略纯空白变更)
110
+ * 判断内容是否有意义(非空白、非纯符号)
111
+ */
112
+ function isContentMeaningful(content) {
113
+ if (!content) return false;
114
+ const trimmed = content.trim();
115
+ return trimmed.length > 0 && !/^[{}()\[\];,]*$/.test(trimmed);
116
+ }
117
+
118
+ /**
119
+ * 提取有意义的变更(按块拆分)
120
+ * 规则:
121
+ * 1. 连续的删除行 + 紧随其后的连续新增行 = 一个"修改块"
122
+ * 2. 只有删除行(后面是上下文或结束)= 一个"删除块"
123
+ * 3. 只有新增行(前面是上下文或开始)= 一个"新增块"
124
+ *
110
125
  * @param {Array} hunks - hunks 数组
111
- * @returns {Array} 有意义的变更数组
126
+ * @returns {Array} 有意义的变更块数组
112
127
  */
113
128
  export function extractMeaningfulChanges(hunks) {
114
129
  const meaningfulChanges = [];
@@ -117,57 +132,142 @@ export function extractMeaningfulChanges(hunks) {
117
132
  let currentNewLine = hunk.newStart;
118
133
  let currentOldLine = hunk.oldStart;
119
134
 
120
- hunk.changes.forEach((change, index) => {
121
- if (change.type === 'addition') {
122
- const content = change.content.trim();
135
+ // 暂存当前正在收集的删除行和新增行
136
+ let pendingDeletions = [];
137
+ let pendingAdditions = [];
138
+ let deletionStartLine = null;
139
+ let additionStartLine = null;
140
+ let contextBefore = [];
141
+
142
+ // 完成当前变更块并添加到结果
143
+ const flushBlock = (contextAfter = []) => {
144
+ // 检查是否有有意义的内容
145
+ const hasMeaningfulDeletions = pendingDeletions.some(d => isContentMeaningful(d.content));
146
+ const hasMeaningfulAdditions = pendingAdditions.some(a => isContentMeaningful(a.content));
147
+
148
+ if (!hasMeaningfulDeletions && !hasMeaningfulAdditions) {
149
+ // 没有有意义的内容,跳过
150
+ pendingDeletions = [];
151
+ pendingAdditions = [];
152
+ deletionStartLine = null;
153
+ additionStartLine = null;
154
+ return;
155
+ }
156
+
157
+ // 创建变更块
158
+ const block = {
159
+ // 类型:同时有删除和新增是修改,只有删除是删除,只有新增是新增
160
+ type: (pendingDeletions.length > 0 && pendingAdditions.length > 0) ? 'modification'
161
+ : (pendingDeletions.length > 0) ? 'deletion'
162
+ : 'addition',
163
+
164
+ // 删除部分
165
+ deletions: pendingDeletions.length > 0 ? {
166
+ startLine: deletionStartLine,
167
+ endLine: deletionStartLine + pendingDeletions.length - 1,
168
+ lines: pendingDeletions.map(d => d.content || ''),
169
+ } : null,
170
+
171
+ // 新增部分
172
+ additions: pendingAdditions.length > 0 ? {
173
+ startLine: additionStartLine,
174
+ endLine: additionStartLine + pendingAdditions.length - 1,
175
+ lines: pendingAdditions.map(a => a.content || ''),
176
+ } : null,
177
+
178
+ // hunk 信息
179
+ hunk: hunk.header,
180
+ hunkRange: {
181
+ oldStart: hunk.oldStart,
182
+ oldEnd: hunk.oldStart + hunk.oldLines - 1,
183
+ newStart: hunk.newStart,
184
+ newEnd: hunk.newStart + hunk.newLines - 1,
185
+ },
123
186
 
124
- // 过滤空行和仅包含符号的行
125
- if (content.length > 0 && !/^[{}()\[\];,]*$/.test(content)) {
126
- meaningfulChanges.push({
127
- type: 'addition',
128
- content: change.content,
129
- lineNumber: currentNewLine,
130
- hunk: hunk.header,
131
- hunkRange: { // 添加 hunk 的范围信息
132
- oldStart: hunk.oldStart,
133
- oldEnd: hunk.oldStart + hunk.oldLines - 1,
134
- newStart: hunk.newStart,
135
- newEnd: hunk.newStart + hunk.newLines - 1,
136
- },
137
- context: {
138
- before: hunk.changes.slice(Math.max(0, index - 2), index).map(c => c.content),
139
- after: hunk.changes.slice(index + 1, index + 3).map(c => c.content),
140
- },
141
- });
187
+ // 上下文
188
+ context: {
189
+ before: [...contextBefore],
190
+ after: contextAfter,
191
+ },
192
+ };
193
+
194
+ meaningfulChanges.push(block);
195
+
196
+ // 重置
197
+ pendingDeletions = [];
198
+ pendingAdditions = [];
199
+ deletionStartLine = null;
200
+ additionStartLine = null;
201
+ };
202
+
203
+ hunk.changes.forEach((change, index) => {
204
+ if (change.type === 'deletion') {
205
+ // 如果已经在收集新增行,说明删除和新增不连续,需要先 flush
206
+ if (pendingAdditions.length > 0) {
207
+ // 获取当前位置后面的上下文
208
+ const afterContext = hunk.changes.slice(index, index + 2)
209
+ .filter(c => c.type === 'context')
210
+ .map(c => c.content || '');
211
+ flushBlock(afterContext);
212
+ contextBefore = [];
142
213
  }
143
- currentNewLine++;
144
- } else if (change.type === 'deletion') {
145
- const content = change.content.trim();
146
214
 
147
- if (content.length > 0 && !/^[{}()\[\];,]*$/.test(content)) {
148
- meaningfulChanges.push({
149
- type: 'deletion',
150
- content: change.content,
151
- lineNumber: currentOldLine, // 使用旧文件的行号
152
- hunk: hunk.header,
153
- hunkRange: { // 添加 hunk 的范围信息
154
- oldStart: hunk.oldStart,
155
- oldEnd: hunk.oldStart + hunk.oldLines - 1,
156
- newStart: hunk.newStart,
157
- newEnd: hunk.newStart + hunk.newLines - 1,
158
- },
159
- context: {
160
- before: hunk.changes.slice(Math.max(0, index - 2), index).map(c => c.content),
161
- after: hunk.changes.slice(index + 1, index + 3).map(c => c.content),
162
- },
163
- });
215
+ // 记录删除起始行
216
+ if (deletionStartLine === null) {
217
+ deletionStartLine = currentOldLine;
218
+ // 收集之前的上下文
219
+ contextBefore = hunk.changes.slice(Math.max(0, index - 2), index)
220
+ .filter(c => c.type === 'context')
221
+ .map(c => c.content || '');
164
222
  }
223
+
224
+ pendingDeletions.push({
225
+ lineNumber: currentOldLine,
226
+ content: change.content,
227
+ });
165
228
  currentOldLine++;
229
+
230
+ } else if (change.type === 'addition') {
231
+ // 记录新增起始行
232
+ if (additionStartLine === null) {
233
+ additionStartLine = currentNewLine;
234
+ // 如果没有待处理的删除,收集之前的上下文
235
+ if (pendingDeletions.length === 0) {
236
+ contextBefore = hunk.changes.slice(Math.max(0, index - 2), index)
237
+ .filter(c => c.type === 'context')
238
+ .map(c => c.content || '');
239
+ }
240
+ }
241
+
242
+ pendingAdditions.push({
243
+ lineNumber: currentNewLine,
244
+ content: change.content,
245
+ });
246
+ currentNewLine++;
247
+
166
248
  } else if (change.type === 'context') {
249
+ // 遇到上下文行,完成当前块
250
+ if (pendingDeletions.length > 0 || pendingAdditions.length > 0) {
251
+ const afterContext = hunk.changes.slice(index, index + 2)
252
+ .filter(c => c.type === 'context')
253
+ .map(c => c.content || '');
254
+ flushBlock(afterContext);
255
+ }
256
+
257
+ // 更新上下文
258
+ contextBefore = hunk.changes.slice(Math.max(0, index - 1), index + 1)
259
+ .filter(c => c.type === 'context')
260
+ .map(c => c.content || '');
261
+
167
262
  currentNewLine++;
168
263
  currentOldLine++;
169
264
  }
170
265
  });
266
+
267
+ // 处理最后一个块
268
+ if (pendingDeletions.length > 0 || pendingAdditions.length > 0) {
269
+ flushBlock([]);
270
+ }
171
271
  });
172
272
 
173
273
  return meaningfulChanges;
@@ -209,4 +309,3 @@ export default {
209
309
  extractMeaningfulChanges,
210
310
  generateDiffSummary,
211
311
  };
212
-
@@ -0,0 +1,329 @@
1
+ /**
2
+ * 文档加载器 - 加载并解析 documents 文件夹下的 PRD.docx 和 TD.docx
3
+ * 为 AI Review 提供产品和技术背景上下文
4
+ */
5
+
6
+ import mammoth from 'mammoth';
7
+
8
+ /**
9
+ * 文档类型枚举
10
+ */
11
+ export const DocumentType = {
12
+ PRD: 'PRD', // Product Requirement Document 产品需求文档
13
+ TD: 'TD', // Technical Document 技术文档
14
+ };
15
+
16
+ /**
17
+ * 从 base64 编码的 docx 内容中提取文本
18
+ * @param {string} base64Content - base64 编码的文件内容
19
+ * @returns {Promise<string>} 提取的文本内容
20
+ */
21
+ async function extractTextFromDocxBase64(base64Content) {
22
+ try {
23
+ // 将 base64 转换为 Buffer
24
+ const buffer = Buffer.from(base64Content, 'base64');
25
+
26
+ // 使用 mammoth 解析
27
+ const result = await mammoth.extractRawText({ buffer });
28
+
29
+ // 清洗文本:移除多余空行,保留结构
30
+ const lines = result.value.split('\n');
31
+ const cleanedLines = [];
32
+ let lastWasEmpty = false;
33
+
34
+ for (const line of lines) {
35
+ const trimmed = line.trim();
36
+
37
+ if (trimmed.length === 0) {
38
+ if (!lastWasEmpty) {
39
+ cleanedLines.push('');
40
+ lastWasEmpty = true;
41
+ }
42
+ } else {
43
+ cleanedLines.push(trimmed);
44
+ lastWasEmpty = false;
45
+ }
46
+ }
47
+
48
+ return cleanedLines.join('\n');
49
+ } catch (error) {
50
+ throw new Error(`解析 docx 文件失败: ${error.message}`);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * 使用 AI 总结文档内容(流式输出)
56
+ * @param {Object} aiClient - AI 客户端实例
57
+ * @param {string} content - 文档原始内容
58
+ * @param {string} docType - 文档类型 (PRD 或 TD)
59
+ * @returns {Promise<string>} AI 生成的总结
60
+ */
61
+ async function summarizeDocument(aiClient, content, docType) {
62
+ const docTypeNames = {
63
+ [DocumentType.PRD]: '产品需求文档',
64
+ [DocumentType.TD]: '技术设计文档',
65
+ };
66
+
67
+ const prompts = {
68
+ [DocumentType.PRD]: `请仔细阅读以下产品需求文档,提取并总结关键信息。
69
+
70
+ 重点关注:
71
+ 1. 产品背景和目标
72
+ 2. 核心功能和特性
73
+ 3. 用户场景和需求
74
+ 4. 业务价值和优先级
75
+ 5. 产品边界和限制
76
+
77
+ 请用简洁的语言总结(300-500字),保留关键细节,便于后续代码审查时参考。
78
+
79
+ 产品需求文档内容:
80
+ ---
81
+ ${content}
82
+ ---
83
+
84
+ 请提供结构化的总结:`,
85
+
86
+ [DocumentType.TD]: `请仔细阅读以下技术设计文档,提取并总结关键信息。
87
+
88
+ 重点关注:
89
+ 1. 技术架构和设计方案
90
+ 2. 核心模块和组件
91
+ 3. 技术选型和依赖
92
+ 4. 数据结构和接口设计
93
+ 5. 性能和安全考虑
94
+ 6. 技术难点和风险
95
+
96
+ 请用简洁的语言总结(300-500字),保留技术关键点,便于后续代码审查时参考。
97
+
98
+ 技术设计文档内容:
99
+ ---
100
+ ${content}
101
+ ---
102
+
103
+ 请提供结构化的总结:`,
104
+ };
105
+
106
+ try {
107
+ const messages = [
108
+ {
109
+ role: 'system',
110
+ content: `你是一个专业的文档分析助手,擅长提取和总结${docTypeNames[docType]}的关键信息。请提供清晰、结构化的总结。`,
111
+ },
112
+ {
113
+ role: 'user',
114
+ content: prompts[docType],
115
+ },
116
+ ];
117
+
118
+ console.log(`🤖 正在使用 AI 总结${docTypeNames[docType]}...`);
119
+ console.log('📥 AI 实时响应:');
120
+ console.log('-'.repeat(60));
121
+
122
+ // 🎯 使用流式调用 AI(参考 config.js 的实现)
123
+ const response = await aiClient.sendMessageStream(messages, (chunk) => {
124
+ if (chunk.content) {
125
+ process.stdout.write(chunk.content);
126
+ }
127
+ });
128
+
129
+ console.log('\n' + '-'.repeat(60));
130
+ const summary = response.content;
131
+
132
+ if (!summary || summary.trim().length === 0) {
133
+ throw new Error('AI 返回了空的总结');
134
+ }
135
+
136
+ console.log(`✅ ${docTypeNames[docType]}总结生成成功 (${summary.length} 字符)\n`);
137
+
138
+ return summary.trim();
139
+ } catch (error) {
140
+ console.error(`❌ AI 总结失败: ${error.message}`);
141
+ // 返回原始内容的前 1000 字符作为降级方案
142
+ return `${docTypeNames[docType]}内容摘要(AI 总结失败,显示原始内容前 1000 字):\n\n${content.substring(0, 1000)}${content.length > 1000 ? '...' : ''}`;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * 加载项目文档
148
+ * @param {Object} options - 选项
149
+ * @param {Object} options.gitlabClient - GitLab 客户端实例
150
+ * @param {string} options.projectId - 项目 ID
151
+ * @param {string} options.ref - 分支名(默认 'main')
152
+ * @param {Object} options.aiClient - AI 客户端实例(可选,用于生成总结)
153
+ * @param {string} options.documentsPath - documents 文件夹路径(默认 'documents')
154
+ * @returns {Promise<Object>} 文档内容和总结
155
+ */
156
+ export async function loadProjectDocuments(options = {}) {
157
+ const {
158
+ gitlabClient,
159
+ projectId,
160
+ ref = 'main',
161
+ aiClient = null,
162
+ documentsPath = 'documents',
163
+ } = options;
164
+
165
+ if (!gitlabClient || !projectId) {
166
+ throw new Error('gitlabClient 和 projectId 是必需的参数');
167
+ }
168
+
169
+ const result = {
170
+ prd: null,
171
+ td: null,
172
+ context: '', // 用于审查时携带的上下文
173
+ };
174
+
175
+ console.log(`\n📚 开始加载项目文档 (路径: ${documentsPath}/)...`);
176
+
177
+ // 文档配置
178
+ const docs = [
179
+ { type: DocumentType.PRD, filename: 'PRD.docx', name: '产品需求文档' },
180
+ { type: DocumentType.TD, filename: 'TD.docx', name: '技术设计文档' },
181
+ ];
182
+
183
+ let loadedCount = 0;
184
+ const contextParts = [];
185
+
186
+ for (const doc of docs) {
187
+ const filePath = `${documentsPath}/${doc.filename}`;
188
+
189
+ try {
190
+ // 从 GitLab 获取文件(参考 gitlab-client.js 的 getProjectFile 方法)
191
+ console.log(`📄 尝试加载 ${doc.name} (${filePath})...`);
192
+
193
+ // getProjectFile 返回的是文本内容,但对于二进制文件我们需要 base64
194
+ // 使用 GitLab API 的 raw 端点不适合二进制文件,需要使用带 encoding 的接口
195
+ const encodedPath = encodeURIComponent(filePath);
196
+ const response = await fetch(
197
+ `${gitlabClient.apiUrl}/projects/${encodeURIComponent(projectId)}/repository/files/${encodedPath}?ref=${ref}`,
198
+ {
199
+ headers: {
200
+ 'PRIVATE-TOKEN': gitlabClient.token,
201
+ },
202
+ }
203
+ );
204
+
205
+ if (!response.ok) {
206
+ if (response.status === 404) {
207
+ console.log(`⏭️ ${doc.name}不存在 (${filePath}),跳过`);
208
+ continue;
209
+ }
210
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
211
+ }
212
+
213
+ const fileData = await response.json();
214
+ const base64Content = fileData.content;
215
+
216
+ if (!base64Content) {
217
+ console.log(`⏭️ ${doc.name}内容为空,跳过`);
218
+ continue;
219
+ }
220
+
221
+ // 解析 docx 文件
222
+ console.log(`🔄 正在解析 ${doc.name}...`);
223
+ const content = await extractTextFromDocxBase64(base64Content);
224
+
225
+ console.log(`✅ ${doc.name}加载成功 (${content.length} 字符)`);
226
+
227
+ // 使用 AI 生成总结(如果提供了 aiClient)
228
+ let summary = null;
229
+ if (aiClient) {
230
+ try {
231
+ summary = await summarizeDocument(aiClient, content, doc.type);
232
+ } catch (error) {
233
+ console.error(`⚠️ ${doc.name}总结生成失败: ${error.message}`);
234
+ }
235
+ }
236
+
237
+ // 保存结果
238
+ if (doc.type === DocumentType.PRD) {
239
+ result.prd = {
240
+ content,
241
+ summary,
242
+ filename: doc.filename,
243
+ path: filePath,
244
+ };
245
+ } else if (doc.type === DocumentType.TD) {
246
+ result.td = {
247
+ content,
248
+ summary,
249
+ filename: doc.filename,
250
+ path: filePath,
251
+ };
252
+ }
253
+
254
+ // 构建上下文
255
+ if (summary) {
256
+ contextParts.push(`## ${doc.name}\n\n${summary}`);
257
+ } else {
258
+ // 如果没有总结,使用原始内容的前 800 字符
259
+ const excerpt = content.substring(0, 800);
260
+ contextParts.push(`## ${doc.name}(摘录)\n\n${excerpt}${content.length > 800 ? '\n\n...(内容过长,已截断)' : ''}`);
261
+ }
262
+
263
+ loadedCount++;
264
+ } catch (error) {
265
+ if (error.message && error.message.includes('404')) {
266
+ console.log(`⏭️ ${doc.name}不存在 (${filePath}),跳过`);
267
+ } else {
268
+ console.error(`❌ 加载 ${doc.name}时出错: ${error.message}`);
269
+ }
270
+ }
271
+ }
272
+
273
+ // 生成最终上下文
274
+ if (loadedCount > 0) {
275
+ result.context = `# 📚 项目背景文档
276
+
277
+ 以下是项目的背景文档信息,在进行代码审查时请参考这些文档,确保代码实现符合产品需求和技术设计。
278
+
279
+ ${contextParts.join('\n\n---\n\n')}
280
+
281
+ ---
282
+
283
+ 请在审查代码时:
284
+ 1. 确保实现符合产品需求文档中的功能描述
285
+ 2. 遵循技术设计文档中的架构和设计原则
286
+ 3. 检查是否有遗漏或超出文档范围的实现
287
+ 4. 评估代码质量是否满足项目标准
288
+
289
+ `;
290
+ console.log(`\n✅ 文档加载完成!共加载 ${loadedCount} 个文档`);
291
+ } else {
292
+ console.log(`\n⏭️ 未找到任何项目文档,将在没有文档上下文的情况下进行审查`);
293
+ }
294
+
295
+ return result;
296
+ }
297
+
298
+ /**
299
+ * 格式化文档上下文用于显示
300
+ * @param {Object} documents - 文档对象
301
+ * @returns {string} 格式化的文档信息
302
+ */
303
+ export function formatDocumentsInfo(documents) {
304
+ const info = [];
305
+
306
+ if (documents.prd) {
307
+ info.push(`📄 产品需求文档 (PRD.docx)`);
308
+ info.push(` - 路径: ${documents.prd.path}`);
309
+ info.push(` - 大小: ${documents.prd.content.length} 字符`);
310
+ if (documents.prd.summary) {
311
+ info.push(` - 已生成 AI 总结: ${documents.prd.summary.length} 字符`);
312
+ }
313
+ }
314
+
315
+ if (documents.td) {
316
+ info.push(`📄 技术设计文档 (TD.docx)`);
317
+ info.push(` - 路径: ${documents.td.path}`);
318
+ info.push(` - 大小: ${documents.td.content.length} 字符`);
319
+ if (documents.td.summary) {
320
+ info.push(` - 已生成 AI 总结: ${documents.td.summary.length} 字符`);
321
+ }
322
+ }
323
+
324
+ if (info.length === 0) {
325
+ return '未找到项目文档';
326
+ }
327
+
328
+ return info.join('\n');
329
+ }