index.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. const { Gitlab } = require('@gitbeaker/rest');
  2. const axios = require('axios');
  3. const yaml = require('yaml');
  4. const fs = require('fs');
  5. const minimatch = require('minimatch');
  6. require('dotenv').config();
  7. // 阿里百炼 https://bailian.console.aliyun.com/
  8. const BAILIAN_API_KEY = process.env.BAILIAN_API_KEY;
  9. const BAILIAN_API_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions';
  10. // 阿波罗AI https://api.ablai.top/personal
  11. const ABLAI_API_KEY = process.env.ABLAI_API_KEY;
  12. const ABLAI_API_URL = 'https://api.ablai.top/v1/chat/completions';
  13. const GITLAB_TOKEN = process.env.GITLAB_TOKEN;
  14. const GITLAB_URL = process.env.CI_SERVER_URL || 'http://git.dcloud.io';
  15. const api = new Gitlab({
  16. token: GITLAB_TOKEN,
  17. host: GITLAB_URL
  18. });
  19. // AI 服务商配置
  20. const AI_PROVIDERS = {
  21. bailian: {
  22. name: '阿里百炼',
  23. apiKey: BAILIAN_API_KEY,
  24. apiUrl: BAILIAN_API_URL,
  25. envKey: 'BAILIAN_API_KEY'
  26. },
  27. ablai: {
  28. name: '阿波罗',
  29. apiKey: ABLAI_API_KEY,
  30. apiUrl: ABLAI_API_URL,
  31. envKey: 'ABLAI_API_KEY'
  32. }
  33. };
  34. // 检查提交是否已经被评审过
  35. async function isCommitReviewed(projectId, commitId) {
  36. try {
  37. const discussions = await api.CommitDiscussions.all(projectId, commitId);
  38. return discussions.some(discussion =>
  39. discussion.notes.some(note =>
  40. note.body.includes('🤖 AI 代码评审结果')
  41. )
  42. );
  43. } catch (error) {
  44. console.error(`检查提交 ${commitId} 评审状态时出错:`, error);
  45. return false;
  46. }
  47. }
  48. // 加载项目配置
  49. function loadProjectConfig() {
  50. try {
  51. // 在 GitLab CI 环境中,工作目录是 /builds/username/project-name/
  52. const configPath = `${process.env.CI_PROJECT_DIR}/code-review/configs/code-review.yaml`;
  53. const configContent = fs.readFileSync(configPath, 'utf8');
  54. const config = yaml.parse(configContent);
  55. if (!config || !config.project) {
  56. throw new Error('配置文件格式错误');
  57. }
  58. return {
  59. reviewGuidelines: config.project.reviewGuidelines || '',
  60. ignoreFiles: config.ignore || [],
  61. aiModel: config.project.aiModel || "qwen-turbo-2025-04-28",
  62. provider: config.project.provider || 'ablai',
  63. maxTokens: config.project.maxTokens || 5000
  64. };
  65. } catch (error) {
  66. console.error('Error loading config:', error);
  67. return null;
  68. }
  69. }
  70. // 生成 AI 评审提示词
  71. function generateReviewPrompt(projectConfig, changes, commitInfo = null) {
  72. const { reviewGuidelines } = projectConfig;
  73. // 格式化变更信息
  74. const formattedChanges = changes.map(change => {
  75. return `
  76. #### 文件路径:${change.file}
  77. ##### 变更内容:
  78. ${change.diff}
  79. ${change.content ? `##### 文件完整内容:
  80. ${change.content}` : ''}
  81. `;
  82. }).join('\n');
  83. // 添加 commit 信息
  84. const commitInfoText = commitInfo ? `${commitInfo.message}` : '';
  85. return `
  86. ${reviewGuidelines}
  87. ### 提交日志 (Commit Message):
  88. ${commitInfoText}
  89. ### 代码变更及上下文:
  90. ${formattedChanges}
  91. `;
  92. }
  93. // 添加重试函数
  94. async function retryWithDelay(fn, maxRetries = 5, delay = 3000) {
  95. let lastError;
  96. for (let i = 0; i < maxRetries; i++) {
  97. try {
  98. return await fn();
  99. } catch (error) {
  100. lastError = error;
  101. if (error.response && error.response.status >= 500) {
  102. console.log(`API 请求失败 (状态码: ${error.response.status}),${i + 1}/${maxRetries} 次重试...`);
  103. if (i < maxRetries - 1) {
  104. await new Promise(resolve => setTimeout(resolve, delay));
  105. continue;
  106. }
  107. }
  108. throw error;
  109. }
  110. }
  111. throw lastError;
  112. }
  113. // 调用 AI API 进行评审
  114. async function getAIReview(prompt, projectConfig) {
  115. try {
  116. console.log('调用 AI API...');
  117. console.log(prompt);
  118. const model = projectConfig.aiModel || "qwen-turbo-2025-04-28";
  119. const provider = projectConfig.provider || 'ablai';
  120. console.log('provider', provider);
  121. // 获取服务商配置
  122. const providerConfig = AI_PROVIDERS[provider];
  123. if (!providerConfig) {
  124. throw new Error(`不支持的服务商: ${provider}`);
  125. }
  126. if (!providerConfig.apiKey) {
  127. throw new Error(`${providerConfig.name} API Key (${providerConfig.envKey}) 未设置`);
  128. }
  129. // 创建 axios 实例
  130. const axiosInstance = axios.create({
  131. proxy: false,
  132. timeout: 600000 // 设置超时时间为 10 分钟
  133. });
  134. // 使用重试机制发送请求
  135. const response = await retryWithDelay(async () => {
  136. return await axiosInstance.post(providerConfig.apiUrl, {
  137. model: model,
  138. messages: [{ role: "user", content: prompt }],
  139. temperature: 0.7,
  140. max_tokens: projectConfig.maxTokens || 5000
  141. }, {
  142. headers: {
  143. 'Content-Type': 'application/json',
  144. 'Authorization': `Bearer ${providerConfig.apiKey}`
  145. }
  146. });
  147. });
  148. return response.data.choices[0].message.content;
  149. } catch (error) {
  150. console.error('Error calling AI API:', error);
  151. if (error.code === 'ECONNABORTED') {
  152. console.error('API 请求超时,请检查网络连接或增加超时时间');
  153. }
  154. throw error;
  155. }
  156. }
  157. // 获取代码变更内容
  158. async function getChanges(projectId, sourceType, sourceId) {
  159. try {
  160. let changes;
  161. if (sourceType === 'merge_request') {
  162. console.log(`获取合并请求 ${sourceId} 的代码变更...`);
  163. changes = await api.MergeRequests.allDiffs(projectId, sourceId, {
  164. accessRawDiffs: true
  165. });
  166. console.log(`成功获取合并请求 ${sourceId} 的代码变更,共 ${changes.length} 个文件`);
  167. } else if (sourceType === 'push') {
  168. console.log(`获取提交 ${sourceId} 的代码变更...`);
  169. // 获取单个 commit 的变更
  170. const diff = await api.Commits.showDiff(projectId, sourceId);
  171. changes = diff.map(change => ({
  172. new_path: change.new_path,
  173. old_path: change.old_path,
  174. diff: change.diff
  175. }));
  176. console.log(`成功获取提交 ${sourceId} 的代码变更,共 ${changes.length} 个文件`);
  177. } else {
  178. console.error(`不支持的类型: ${sourceType}`);
  179. throw new Error(`不支持的类型: ${sourceType}`);
  180. }
  181. const projectConfig = loadProjectConfig();
  182. const ignorePatterns = projectConfig.ignoreFiles || [];
  183. // 获取变更文件的完整内容
  184. const changesWithContent = await Promise.all(changes
  185. .filter(change => {
  186. // 检查文件是否在忽略列表中
  187. return !ignorePatterns.some(pattern => {
  188. // 使用 minimatch 进行 glob 模式匹配
  189. const shouldIgnore =
  190. (change.new_path && minimatch(change.new_path, pattern)) ||
  191. (change.old_path && minimatch(change.old_path, pattern));
  192. if (shouldIgnore) {
  193. console.log(`忽略文件: ${change.new_path || change.old_path} (匹配模式: ${pattern})`);
  194. }
  195. return shouldIgnore;
  196. });
  197. })
  198. .map(async change => {
  199. const filePath = change.new_path || change.old_path;
  200. try {
  201. console.log(`正在获取文件 ${filePath} 的完整内容...`);
  202. // 获取文件的完整内容
  203. const fileContent = await api.RepositoryFiles.show(projectId, filePath, sourceId);
  204. // 对 base64 编码的内容进行解码
  205. const decodedContent = Buffer.from(fileContent.content, 'base64').toString('utf-8');
  206. console.log(`成功获取文件 ${filePath} 的完整内容`);
  207. return {
  208. file: filePath,
  209. diff: change.diff,
  210. content: decodedContent
  211. };
  212. } catch (error) {
  213. console.error(`无法获取文件 ${filePath} 的完整内容:`, error);
  214. return {
  215. file: filePath,
  216. diff: change.diff
  217. };
  218. }
  219. }));
  220. console.log(`成功处理所有文件变更,共 ${changesWithContent.length} 个文件`);
  221. return changesWithContent;
  222. } catch (error) {
  223. console.error('获取代码变更失败:', error);
  224. throw error;
  225. }
  226. }
  227. // 添加评审评论
  228. async function addReviewComment(projectId, sourceType, sourceId, review) {
  229. try {
  230. console.log(`添加评审评论 - 项目ID: ${projectId}, 来源类型: ${sourceType}, 来源ID: ${sourceId}`);
  231. if (!projectId) {
  232. throw new Error('项目ID不能为空');
  233. }
  234. if (!sourceId) {
  235. throw new Error('来源ID不能为空');
  236. }
  237. if (!review) {
  238. throw new Error('评审内容不能为空');
  239. }
  240. const note = `🤖 AI 代码评审结果:\n\n${review}`;
  241. if (sourceType === 'merge_request') {
  242. console.log('正在为合并请求添加评论...');
  243. await api.MergeRequestNotes.create(projectId, sourceId, note);
  244. console.log('合并请求评论添加成功');
  245. } else if (sourceType === 'push') {
  246. console.log('正在为提交添加评论...');
  247. await api.CommitDiscussions.create(projectId, sourceId, note);
  248. console.log('提交评论添加成功');
  249. } else {
  250. throw new Error(`不支持的来源类型: ${sourceType}`);
  251. }
  252. } catch (error) {
  253. console.error('添加评审评论失败:', {
  254. error: error.message,
  255. projectId,
  256. sourceType,
  257. sourceId,
  258. reviewLength: review?.length
  259. });
  260. if (error.cause?.description) {
  261. console.error('错误详情:', error.cause.description);
  262. }
  263. throw error;
  264. }
  265. }
  266. // 主处理函数
  267. async function processReview(projectId, sourceType, sourceId) {
  268. try {
  269. const projectConfig = loadProjectConfig();
  270. if (!projectConfig) {
  271. console.error('Project configuration not found');
  272. process.exit(1);
  273. }
  274. if (sourceType === 'push') {
  275. console.log(process.env.CI_COMMIT_BEFORE_SHA);
  276. console.log(process.env.CI_COMMIT_SHA);
  277. console.log(process.env.CI_COMMIT_BRANCH);
  278. // 获取本次 push 的所有 commit
  279. let commits;
  280. if (process.env.CI_COMMIT_BEFORE_SHA && process.env.CI_COMMIT_SHA) {
  281. commits = await api.Repositories.compare(projectId, process.env.CI_COMMIT_BEFORE_SHA, process.env.CI_COMMIT_SHA);
  282. commits = commits.commits || [];
  283. console.log('获取本次提交的信息:', commits);
  284. } else {
  285. commits = await api.Commits.all(projectId, {
  286. ref_name: process.env.CI_COMMIT_BRANCH,
  287. per_page: 1
  288. });
  289. console.log('获取首次提交的信息:', commits);
  290. }
  291. // 过滤掉合并分支的提交
  292. commits = commits.filter(commit => !commit.message.startsWith('Merge branch'));
  293. console.log(`获取到 ${commits.length} 个提交需要评审(已过滤合并分支的提交)`);
  294. // 对每个 commit 进行评审
  295. for (const commit of commits) {
  296. console.log(`开始评审提交: ${commit.id}`);
  297. console.log(`提交信息: ${commit.message}`);
  298. // 检查提交是否已经被评审过
  299. const isReviewed = await isCommitReviewed(projectId, commit.id);
  300. if (isReviewed) {
  301. console.log(`提交 ${commit.id} 已经评审过,跳过评审`);
  302. continue;
  303. }
  304. // 获取该 commit 的变更
  305. const changes = await getChanges(projectId, sourceType, commit.id);
  306. if (changes.length === 0) {
  307. console.log(`提交 ${commit.id} 没有代码变更,跳过评审`);
  308. continue;
  309. }
  310. console.log(`提交 ${commit.id} 包含 ${changes.length} 个文件变更`);
  311. // 生成评审提示词
  312. const prompt = generateReviewPrompt(projectConfig, changes, {
  313. author_name: commit.author_name,
  314. created_at: commit.created_at,
  315. message: commit.message,
  316. ref_name: process.env.CI_COMMIT_BRANCH
  317. });
  318. // 获取 AI 评审结果
  319. const review = await getAIReview(prompt, projectConfig);
  320. // 添加评审评论到 commit
  321. await addReviewComment(projectId, sourceType, commit.id, review);
  322. console.log(`提交 ${commit.id} 评审完成`);
  323. }
  324. } else if (sourceType === 'merge_request') {
  325. const changes = await getChanges(projectId, sourceType, sourceId);
  326. if (changes.length === 0) {
  327. console.log('No changes to review');
  328. return;
  329. }
  330. // 获取合并请求信息
  331. const mrInfo = await api.MergeRequests.show(projectId, sourceId);
  332. const prompt = generateReviewPrompt(projectConfig, changes, {
  333. author_name: mrInfo.author.name,
  334. created_at: mrInfo.created_at,
  335. message: mrInfo.description,
  336. ref_name: mrInfo.source_branch
  337. });
  338. const review = await getAIReview(prompt, projectConfig);
  339. await addReviewComment(projectId, sourceType, sourceId, review);
  340. }
  341. console.log('Review completed successfully');
  342. } catch (error) {
  343. console.error('Error processing review:', error);
  344. if (error.cause?.description?.includes('401 Unauthorized')) {
  345. console.error('GitLab API authentication failed. Please check your GITLAB_TOKEN.');
  346. }
  347. process.exit(1);
  348. }
  349. }
  350. // 导出需要测试的函数
  351. module.exports = {
  352. loadProjectConfig,
  353. generateReviewPrompt,
  354. getAIReview,
  355. getChanges,
  356. addReviewComment,
  357. processReview
  358. };
  359. // 只在直接运行 index.js 时执行
  360. if (require.main === module) {
  361. const projectId = process.env.CI_PROJECT_ID;
  362. const sourceType = process.env.CI_PIPELINE_SOURCE === 'merge_request_event' ? 'merge_request' : 'push';
  363. const sourceId = sourceType === 'merge_request' ? process.env.CI_MERGE_REQUEST_IID : process.env.CI_COMMIT_SHA;
  364. if (!GITLAB_TOKEN) {
  365. console.error('GITLAB_TOKEN is not set');
  366. process.exit(1);
  367. }
  368. if (!projectId) {
  369. console.error('CI_PROJECT_ID is not set');
  370. process.exit(1);
  371. }
  372. if (!sourceId) {
  373. console.error('Source ID is not set');
  374. process.exit(1);
  375. }
  376. processReview(projectId, sourceType, sourceId);
  377. }