const { Gitlab } = require('@gitbeaker/rest');
const axios = require('axios');
const yaml = require('yaml');
const fs = require('fs');
const minimatch = require('minimatch');
require('dotenv').config();
const BAILIAN_API_KEY = process.env.BAILIAN_API_KEY;
const BAILIAN_API_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions';
const ABLAI_API_KEY = process.env.ABLAI_API_KEY;
const ABLAI_API_URL = 'https://api.ablai.top/v1/chat/completions';
const GITLAB_TOKEN = process.env.GITLAB_TOKEN;
const GITLAB_URL = process.env.CI_SERVER_URL || 'http://git.dcloud.io';
const api = new Gitlab({
token: GITLAB_TOKEN,
host: GITLAB_URL
});
const AI_PROVIDERS = {
bailian: {
name: '阿里百炼',
apiKey: BAILIAN_API_KEY,
apiUrl: BAILIAN_API_URL,
envKey: 'BAILIAN_API_KEY'
},
ablai: {
name: '阿波罗',
apiKey: ABLAI_API_KEY,
apiUrl: ABLAI_API_URL,
envKey: 'ABLAI_API_KEY'
}
};
async function isCommitReviewed(projectId, commitId) {
try {
const discussions = await api.CommitDiscussions.all(projectId, commitId);
return discussions.some(discussion =>
discussion.notes.some(note =>
note.body.includes('🤖 AI 代码评审结果')
)
);
} catch (error) {
console.error(`检查提交 ${commitId} 评审状态时出错:`, error);
return false;
}
}
function loadProjectConfig() {
try {
const configPath = `${process.env.CI_PROJECT_DIR}/code-review/configs/code-review.yaml`;
const configContent = fs.readFileSync(configPath, 'utf8');
const config = yaml.parse(configContent);
if (!config || !config.project) {
throw new Error('配置文件格式错误');
}
return {
reviewGuidelines: config.project.reviewGuidelines || '',
ignoreFiles: config.ignore || [],
aiModel: config.project.aiModel || "qwen-turbo-2025-04-28",
provider: config.project.provider || 'ablai',
maxTokens: config.project.maxTokens || 5000
};
} catch (error) {
console.error('Error loading config:', error);
return null;
}
}
function generateReviewPrompt(projectConfig, changes, commitInfo = null) {
const { reviewGuidelines } = projectConfig;
const formattedChanges = changes.map(change => {
return `
#### 文件路径:${change.file}
##### 变更内容:
${change.diff}
${change.content ? `##### 文件完整内容:
${change.content}` : ''}
`;
}).join('\n');
const commitInfoText = commitInfo ? `${commitInfo.message}` : '';
return `
${reviewGuidelines}
### 提交日志 (Commit Message):
${commitInfoText}
### 代码变更及上下文:
${formattedChanges}
`;
}
async function retryWithDelay(fn, maxRetries = 5, delay = 3000) {
let lastError;
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (error.response && error.response.status >= 500) {
console.log(`API 请求失败 (状态码: ${error.response.status}),${i + 1}/${maxRetries} 次重试...`);
if (i < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
}
throw error;
}
}
throw lastError;
}
async function getAIReview(prompt, projectConfig) {
try {
console.log('调用 AI API...');
console.log(prompt);
const model = projectConfig.aiModel || "qwen-turbo-2025-04-28";
const provider = projectConfig.provider || 'ablai';
console.log('provider', provider);
const providerConfig = AI_PROVIDERS[provider];
if (!providerConfig) {
throw new Error(`不支持的服务商: ${provider}`);
}
if (!providerConfig.apiKey) {
throw new Error(`${providerConfig.name} API Key (${providerConfig.envKey}) 未设置`);
}
const axiosInstance = axios.create({
proxy: false,
timeout: 600000
});
const response = await retryWithDelay(async () => {
return await axiosInstance.post(providerConfig.apiUrl, {
model: model,
messages: [{ role: "user", content: prompt }],
temperature: 0.7,
max_tokens: projectConfig.maxTokens || 5000
}, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${providerConfig.apiKey}`
}
});
});
return response.data.choices[0].message.content;
} catch (error) {
console.error('Error calling AI API:', error);
if (error.code === 'ECONNABORTED') {
console.error('API 请求超时,请检查网络连接或增加超时时间');
}
throw error;
}
}
async function getChanges(projectId, sourceType, sourceId) {
try {
let changes;
if (sourceType === 'merge_request') {
console.log(`获取合并请求 ${sourceId} 的代码变更...`);
changes = await api.MergeRequests.allDiffs(projectId, sourceId, {
accessRawDiffs: true
});
console.log(`成功获取合并请求 ${sourceId} 的代码变更,共 ${changes.length} 个文件`);
} else if (sourceType === 'push') {
console.log(`获取提交 ${sourceId} 的代码变更...`);
const diff = await api.Commits.showDiff(projectId, sourceId);
changes = diff.map(change => ({
new_path: change.new_path,
old_path: change.old_path,
diff: change.diff
}));
console.log(`成功获取提交 ${sourceId} 的代码变更,共 ${changes.length} 个文件`);
} else {
console.error(`不支持的类型: ${sourceType}`);
throw new Error(`不支持的类型: ${sourceType}`);
}
const projectConfig = loadProjectConfig();
const ignorePatterns = projectConfig.ignoreFiles || [];
const changesWithContent = await Promise.all(changes
.filter(change => {
return !ignorePatterns.some(pattern => {
const shouldIgnore =
(change.new_path && minimatch(change.new_path, pattern)) ||
(change.old_path && minimatch(change.old_path, pattern));
if (shouldIgnore) {
console.log(`忽略文件: ${change.new_path || change.old_path} (匹配模式: ${pattern})`);
}
return shouldIgnore;
});
})
.map(async change => {
const filePath = change.new_path || change.old_path;
try {
console.log(`正在获取文件 ${filePath} 的完整内容...`);
const fileContent = await api.RepositoryFiles.show(projectId, filePath, sourceId);
const decodedContent = Buffer.from(fileContent.content, 'base64').toString('utf-8');
console.log(`成功获取文件 ${filePath} 的完整内容`);
return {
file: filePath,
diff: change.diff,
content: decodedContent
};
} catch (error) {
console.error(`无法获取文件 ${filePath} 的完整内容:`, error);
return {
file: filePath,
diff: change.diff
};
}
}));
console.log(`成功处理所有文件变更,共 ${changesWithContent.length} 个文件`);
return changesWithContent;
} catch (error) {
console.error('获取代码变更失败:', error);
throw error;
}
}
async function addReviewComment(projectId, sourceType, sourceId, review) {
try {
console.log(`添加评审评论 - 项目ID: ${projectId}, 来源类型: ${sourceType}, 来源ID: ${sourceId}`);
if (!projectId) {
throw new Error('项目ID不能为空');
}
if (!sourceId) {
throw new Error('来源ID不能为空');
}
if (!review) {
throw new Error('评审内容不能为空');
}
const note = `🤖 AI 代码评审结果:\n\n${review}`;
if (sourceType === 'merge_request') {
console.log('正在为合并请求添加评论...');
await api.MergeRequestNotes.create(projectId, sourceId, note);
console.log('合并请求评论添加成功');
} else if (sourceType === 'push') {
console.log('正在为提交添加评论...');
await api.CommitDiscussions.create(projectId, sourceId, note);
console.log('提交评论添加成功');
} else {
throw new Error(`不支持的来源类型: ${sourceType}`);
}
} catch (error) {
console.error('添加评审评论失败:', {
error: error.message,
projectId,
sourceType,
sourceId,
reviewLength: review?.length
});
if (error.cause?.description) {
console.error('错误详情:', error.cause.description);
}
throw error;
}
}
async function processReview(projectId, sourceType, sourceId) {
try {
const projectConfig = loadProjectConfig();
if (!projectConfig) {
console.error('Project configuration not found');
process.exit(1);
}
if (sourceType === 'push') {
console.log(process.env.CI_COMMIT_BEFORE_SHA);
console.log(process.env.CI_COMMIT_SHA);
console.log(process.env.CI_COMMIT_BRANCH);
let commits;
if (process.env.CI_COMMIT_BEFORE_SHA && process.env.CI_COMMIT_SHA) {
commits = await api.Repositories.compare(projectId, process.env.CI_COMMIT_BEFORE_SHA, process.env.CI_COMMIT_SHA);
commits = commits.commits || [];
console.log('获取本次提交的信息:', commits);
} else {
commits = await api.Commits.all(projectId, {
ref_name: process.env.CI_COMMIT_BRANCH,
per_page: 1
});
console.log('获取首次提交的信息:', commits);
}
commits = commits.filter(commit => !commit.message.startsWith('Merge branch'));
console.log(`获取到 ${commits.length} 个提交需要评审(已过滤合并分支的提交)`);
for (const commit of commits) {
console.log(`开始评审提交: ${commit.id}`);
console.log(`提交信息: ${commit.message}`);
const isReviewed = await isCommitReviewed(projectId, commit.id);
if (isReviewed) {
console.log(`提交 ${commit.id} 已经评审过,跳过评审`);
continue;
}
const changes = await getChanges(projectId, sourceType, commit.id);
if (changes.length === 0) {
console.log(`提交 ${commit.id} 没有代码变更,跳过评审`);
continue;
}
console.log(`提交 ${commit.id} 包含 ${changes.length} 个文件变更`);
const prompt = generateReviewPrompt(projectConfig, changes, {
author_name: commit.author_name,
created_at: commit.created_at,
message: commit.message,
ref_name: process.env.CI_COMMIT_BRANCH
});
const review = await getAIReview(prompt, projectConfig);
await addReviewComment(projectId, sourceType, commit.id, review);
console.log(`提交 ${commit.id} 评审完成`);
}
} else if (sourceType === 'merge_request') {
const changes = await getChanges(projectId, sourceType, sourceId);
if (changes.length === 0) {
console.log('No changes to review');
return;
}
const mrInfo = await api.MergeRequests.show(projectId, sourceId);
const prompt = generateReviewPrompt(projectConfig, changes, {
author_name: mrInfo.author.name,
created_at: mrInfo.created_at,
message: mrInfo.description,
ref_name: mrInfo.source_branch
});
const review = await getAIReview(prompt, projectConfig);
await addReviewComment(projectId, sourceType, sourceId, review);
}
console.log('Review completed successfully');
} catch (error) {
console.error('Error processing review:', error);
if (error.cause?.description?.includes('401 Unauthorized')) {
console.error('GitLab API authentication failed. Please check your GITLAB_TOKEN.');
}
process.exit(1);
}
}
module.exports = {
loadProjectConfig,
generateReviewPrompt,
getAIReview,
getChanges,
addReviewComment,
processReview
};
if (require.main === module) {
const projectId = process.env.CI_PROJECT_ID;
const sourceType = process.env.CI_PIPELINE_SOURCE === 'merge_request_event' ? 'merge_request' : 'push';
const sourceId = sourceType === 'merge_request' ? process.env.CI_MERGE_REQUEST_IID : process.env.CI_COMMIT_SHA;
if (!GITLAB_TOKEN) {
console.error('GITLAB_TOKEN is not set');
process.exit(1);
}
if (!projectId) {
console.error('CI_PROJECT_ID is not set');
process.exit(1);
}
if (!sourceId) {
console.error('Source ID is not set');
process.exit(1);
}
processReview(projectId, sourceType, sourceId);
}