我是这样使用AI提高前端基础建设工具效率的

59 阅读11分钟

前言

这篇文章,我将向大家介绍如何使用AI来提高前端基础建设的工具效率。

本文将会向大家展示我是如何用Deepseek进行CodeReview(后文将统一简称CR),并且接入到GitLab的CI/CD自动化触发CR的实践过程。

在我们团队,CR一直是一个老大难问题,因为没有人愿意花费自己的时间进行这个过程,属于费力不讨好,但是在维护别人写的代码的时候,又会觉得某些同事的代码写的很烂,难以维护。

这就跟程序员喜欢文档,但是又不喜欢写文档是一样的问题,这个问题在我们的技术团队是一个暂时无法解决的问题,因为我们正常的开发任务本来就安排的比较紧凑。

我作为前端团队基础设施建设的主要负责人,于是我就想到引入AI来解决这个矛盾,AI虽然不能100%的完美的完成CR,但是只要能够帮我们完成30-40%的有效的CR,这个事儿就会变得非常有意义,对于整体的效率提升是立竿见影的。

于是我开始探索如何在前端基础设施中接入AI,在Github上查阅了一些开源的CR库,发现其实现原理也挺简单的,大概就是得到diff内容,发起对LLM的调用,拿到返回内容输出。

技术方案规划

首先,大模型的话(后文统一简称LLM),就选Deepseek,因为Deepseek的API调用在目前已有的LLM中算是相当便宜的了。

Deepseek文档:api-docs.deepseek.com/zh-cn/quick…

接着,我们一起梳理一下整个工具的实现思路:

首先,我们在发起MergeRequest(后文将简称MR)的时候拿到MRID,根据这个ID请求到整个MR的详细内容,这个过程,我们需要使用GitLab的 API调用。

用户事先得配置需要处理的ProjectID,当我们拿到一个MR的文件变更之后,我们需要搜集到这个MR下面的所有代码变更,需要剔除掉一些二进制文件(这个规则我们可以外置,可以将来由用户自定义配置),然后,我们针对每个文件发起对LLM的调用,我们需要要求LLM按照一定的规范返回内容给我们,并且要求它只需要给需要改进的内容,如果没有任何需要改进的内容的话,直接返回一个空json即可,返回内容需要以这样的格式返回:

{ 
    lineNumber: number;
    comment: string;
 }

然后,我们根据LLM返回的结构,决定是否发起对GitLab的评论API调用。

在调用LLM的时候是发送diff好呢还是发送整个源代码文件比较好呢?我经过思考,我觉得发送diff比较好,因为发送diff首先是比较节省Token,还有可以提高CR的效率,也能避免一些问题或者纠纷,比如你是维护别人的代码,但是CR跑出来却是一堆前任留下来的问题,你会觉得比较冤,这些信息显然是多余的,所以最终选择了发送diff内容(我查阅的几个Github开源库也是选择的发送diff这个方案)

以上是整体的处理流程,但是我们还需要把这个能力接入到GitLab的CI/CD自动化流水线中,否则对于业务开发的同学使用起来也是比较难受的。

好了,基本上整体的思路就是这样的,为了使得我们的这个工具有一定的扩展性,我们这个能力不仅支持JS API调用,还需要支持CLI调用,所以在开发的时候,我们先正常写基础能力,最后使用CLI包裹一下这个JS API即可。

为了能够及时的同步CR进度,还可以考虑接入相应的IM机器人支持,具体就取决于个人所在的团队了。

技术选择的话,采用Jest+TypeScript进行开发,这样在调试的时候会比较舒服,不用编译TS也能执行,并且还能保留一些可回溯的测试用例,专业且高效。

基本上整体的思路就是这样了,接着我们就开始一步一步的去落地了。

LLM申请

相信大家Deepseek账号早就注册了吧,毕竟今年的Deepseek可是中国科技的一颗冉冉升起的新星。

注册之后,我们接下来就要发动人民币的力量了,先进行充值。

image.png 充值完成之后,我们需要创建一个应用程序,填入你的应用程序的名称就可以了: image.png 比如下图就是我创建的一个应用了,创建好之后你要妥善保存这个Token,它不会出现第二次了image.png 然后,我们就可以拿着这个Token,按照Deepseek的文档开始进行调用了。

Deepseek的API需要一些参数,每个参数Deepseek的文档都有解释,大家查阅相应的文档即可。 image.png 这样就是调用成功了。

GitLab 配置

为了支持GitLab的API调用,我们也需要申请一个Token,大家打开自己的GitLab地址,申请Token。

image.png

image.png

然后输入Token的名称,把这些全部都勾上,点击创建即可。 image.png

以下就是我创建的Token: image.png

同样和Deepseek是一样的,这个Token创建好之后,也要妥善保存,要不然就找不到了。

项目配置

对于这种不用跟浏览器打交道的程序,我们采用TDD(Test-Drive-Development)的这种方式,虽然我们不会真正的去编写case,但是调试代码的时候,可是非常舒服的。

项目的技术选项就用TypeScript+Jest,所以我们首先得把Jest配置好,让它能够运行ESM风格的TS,建议大家在编写Node程序的时候都使用ESM的风格

{
  "name": "@xxx/ai-code-review",
  "version": "1.4.2",
  "description": "",
  "type": "module",
  "module": "lib/index.js",
  "main": "lib/index.cjs",
  "types": "lib/index.d.ts",
  "scripts": {
    "dev": "tsup --watch",
    "build": "tsup"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@jest/globals": "^30.0.2",
    "diff": "^8.0.2",
    "fs-extra": "^10.0.0",
    "parse-diff": "^0.11.1"
  },
  "devDependencies": {
    "@babel/preset-typescript": "^7.27.1",
    "@types/fs-extra": "^11.0.4",
    "@types/glob": "^8.1.0",
    "@types/jest": "^30.0.0",
    "axios": "^1.7.9",
    "babel-jest": "^30.0.2",
    "jest": "^30.0.2",
    "ts-jest": "^29.4.0",
    "tsup": "^8.5.0"
  },
  "jest": {
    "moduleFileExtensions": [
      "js",
      "json",
      "ts"
    ],
    "rootDir": "test",
    "testRegex": ".*\\.spec\\.(t|j)s$",
    "transform": {
      "^.+\\.(t|j)s$": "ts-jest"
    },
    "collectCoverageFrom": [
      "**/*.(t|j)s"
    ],
    "coverageDirectory": "../coverage",
    "testEnvironment": "node"
  }
}

构建工具的话,我们就选择tsup就可以了,要比TSC好用一些,性能也更好。

tsup的配置如下:

import { defineConfig } from "tsup";

export default defineConfig({
  entry: ["src/index.ts"],
  format: ["cjs", "esm"],
  outDir: 'lib',
  dts: true,
  clean: true,
  sourcemap: true,
});

好了,基本上就OK了,在项目建立src目录和test目录就可以开始搞了。

以下是我最终开发好的目录结构: image.png

代码编写

我们大致需要几个模块,一个模块用来处理用户的配置,我们必然不能把那些配置全部写死在项目里面,到时候改起来也会比较难受。

这个项目里面需要配置的内容是非常多的,比如Deepseek的Token,GitLab的Host和Token,还有一些过滤CR的规则等,因为我们团队使用飞书,为了能够更及时的消息提醒,我还接入了飞书机器人。

配置模块

interface GlobalConfig {
  /**
   * 机器人通知相关配置
   */
  robot?: {
    /**
     * 秘钥
     */
    secret: string;
    /**
     * 通知的回调地址
     */
    webhook: string;
  };
  /**
   * deepseek相关配置
   */
  deepseek: {
    /**
     * deepseek的API KEY
     */
    apiKey: string;
    /**
     * CodeReview的提示设定
     */
    prompt?: string;
  };
  /**
   * gitlab相关配置
   */
  gitlab: {
    /**
     * GitLAB的host地址
     */
    hostname: string;
    /**
     * GitLab可访问的Token
     */
    token: string;
    /**
     * 需要处理的project
     */
    projectId: string;
    /**
     * 目标校验分支,默认为master
     */
    targetCheckBranch?: string;
    /**
     * 跳过CR的关键字
     */
    skipCRKeywords?: string[];
    /**
     * 单一文件最大行数
     */
    singleFileMaxRows: number;
  };
  /**
   * 高级配置
   */
  advance?: {
    /**
     * 自定义需要跳过CR的规则
     * @param filePath
     * @returns
     */
    isValid?: (filePath: string) => boolean;
  };
}


const globalConfig: GlobalConfig = {
  deepseek: {
    apiKey: "",
  },
  robot: {
    secret: "",
    webhook: "",
  },
  gitlab: {
    hostname: "",
    token: "",
    projectId: "",
    singleFileMaxRows: 50,
  },
};

let hasSetGlobalConfig = false;


/**
 * 设置全局配置
 * @param config
 */
export function setGlobalConfig(config: GlobalConfig) {
  hasSetGlobalConfig = true;
  Object.assign(globalConfig, config);
  if (!config.advance) {
    config.advance = {};
  }
  // 生成默认的自定义验证规则
  if (!config.advance.isValid) {
    config.advance.isValid = () => true;
  }
  // 生成默认的prompt
  if (!config.deepseek.prompt) {
    config.deepseek.prompt = `您是专业的AI代码审查专家,分析git diff -U0格式的代码更改。您的主要关注点应该放在新添加和修改的代码部分,而忽略删除的部分。
        请严格按照以下维度进行评审:
        - 代码可能存在潜在的bug
        - 代码冗余、逻辑差及坏味道
        - 提高代码的可读性
        不考虑代码的格式风格,不引入不相关的视角,不对正面内容给予评价,仅需要对明确有问题的部分给出建议,您给出的评审结果以JSON格式提供响应:{"reviews": [{"ln": <line_number>, "comment": "<review comment>"}],若没有任何可改进的部分,则返回空list,您的返回建议内容应该为中文,若多个问题重复出现,请用“同上”替代`;
  }
  // 处理默认分支和默认跳过CR检查的关键字
  if (!globalConfig.gitlab.skipCRKeywords) {
    globalConfig.gitlab.skipCRKeywords = ["[SKIP CR]", "[CR SKIP]"];
  }
  if (!globalConfig.gitlab.targetCheckBranch) {
    globalConfig.gitlab.targetCheckBranch = "master";
  }
}

/**
 * 获取全局配置
 * @returns
 */
export function getGlobalConfig() {
  if (!hasSetGlobalConfig) {
    logger.error("您尚未进行全局配置,请先进行全局配置再操作!");
    process.exit(1);
  }
  return globalConfig;
}

LLM调用模块

以下代码没向大家贴引入的代码,所以并不能直接使用,大家可以借鉴我的实现。这个模块主要提供调用LLM的能力,返回结果。

interface LLMMessage {
  /** 消息角色 */
  role: "system" | "user" | "assistant";
  /** 消息内容 */
  content: string;
}

interface LLMRequestParams {
  /** 指定使用的模型名称 */
  model: "deepseek-chat" | "deepseek-coder";

  /** 对话消息数组 */
  messages: LLMMessage[];

  /** 是否使用流式响应(可选) */
  stream?: boolean;

  /** 生成的最大 token 数量(可选) */
  max_tokens?: number;

  /** 温度系数(0-2),控制随机性(可选) */
  temperature?: number;

  /** 核心采样率(0-1),与 temperature 二选一(可选) */
  top_p?: number;

  /** 频率惩罚系数(-2.0 到 2.0)(可选) */
  frequency_penalty?: number;

  /** 存在惩罚系数(-2.0 到 2.0)(可选) */
  presence_penalty?: number;

  /** 停止生成标记(最多4个序列)(可选) */
  stop?: string | string[];
}

/**
 * 调用大模型,获取AI的改进建议
 * @param prompt 需要提供审阅的内容
 * @returns
 */
export function requestAIPromptResponse(prompt: string): Promise<DeepSeekResponse> {
  const config = getGlobalConfig();
  const url = `https://api.deepseek.com/chat/completions`;
  const headers = {
    "Content-Type": "application/json",
    Authorization: `Bearer ${config.deepseek.apiKey}`,
  };

  /**
   - 其它潜在的安全隐患及可改进的建议
   */
  const data: LLMRequestParams = {
    model: "deepseek-chat",
    temperature: 0,
    messages: [
      {
        role: "system",
        content: config.deepseek.prompt!,
      },
      {
        role: "user",
        content: prompt,
      },
    ],
    stream: false,
  };

  return axios({
    url,
    method: "POST",
    data,
    headers,
  })
    .then((resp) => {
      return resp.data;
    })
    .catch((err: AxiosError) => {
      if (err.status === 401) {
        logger.error("LLM未授权");
      }
      if (err.status === 402) {
        logger.error("LLM余额不足,请及时充值!");
        // 直接中断后续的请求了
        process.exit(1);
      }
      return {};
    }) as Promise<DeepSeekResponse>;
}

GitLab模块

我使用axios来发起Node端的Http请求,大家用node-fetch也可以,根据个人喜好选择。这个模块主要是将对GitLab的操作统一封装,便于管理。

interface RequestConfig {
  url: string;
  method?: "post" | "get";
  data?: Record<PropertyKey, unknown>;
  headers?: Record<PropertyKey, unknown>;
}

/**
 * 封装对GitLab的操作
 */
export class GitLabIntegration {
  private host: string = "";

  private token: string = "";

  private baseUrl: string = "/api/v4";

  private projectId: string = "";

  /**
   * 文件内容缓存
   */
  private fileDetailCache: Map<string, Map<string, string>> = new Map();

  constructor() {
    const config = getGlobalConfig();

    this.host = config.gitlab.hostname;
    this.token = config.gitlab.token;
    this.projectId = config.gitlab.projectId;
  }

  /**
   * 使用axios发起请求
   * @param requestConfig
   */
  private sendRequest(requestConfig: RequestConfig): Promise<unknown> {
    const url = `${this.host}${this.baseUrl}${requestConfig.url}`;
    const { headers = {}, data = {} } = requestConfig;
    const { Authorization, ...restHeaders } = headers;
    const mergedHeaders = {
      ...restHeaders,
      Authorization: `Bearer ${this.token}`,
    };
    const method = requestConfig.method || "get";
    const targetUrl = new URL(url);
    // node端的axios似乎有点儿bug?
    if (method === "get") {
      const searchParams = new URLSearchParams(data as any);
      for (const [prop, value] of searchParams) {
        targetUrl.searchParams.set(prop, value);
      }
    }
    return axios({
      url: targetUrl.toString(),
      method,
      headers: mergedHeaders,
      data: method === "get" ? undefined : data,
    }).then((resp) => {
      return resp.data;
    });
  }

  /**
   * 获取指定目标分支的最新哈希码
   * @param targetBranch
   */
  getTargetBranchSha(targetBranch: string): Promise<GitLabBranch> {
    targetBranch = encodeURIComponent(targetBranch);
    return this.sendRequest({
      url: `/projects/${this.projectId}/repository/branches/${targetBranch}`,
    }) as Promise<GitLabBranch>;
  }

  /**
   * 根据MR ID 获取,本次MergeRequest的changes
   * @param mrId
   */
  getMergeRequestDetailById(mrId: string): Promise<MergeRequest> {
    return this.sendRequest({
      url: `/projects/${this.projectId}/merge_requests/${mrId}/changes`,
    }).catch((err: AxiosError) => {
      logger.error(err.message);
      return {};
    }) as Promise<MergeRequest>;
  }

  /**
   * 获取指定文件的提交修改
   * @param filePath
   * @param sha
   * @returns
   */
  async getFileCommitList(filePath: string, sha: string): Promise<FileHistoryItemInfo[]> {
    return this.sendRequest({
      url: `/projects/${this.projectId}/repository/commits`,
      method: "get",
      data: {
        path: filePath,
        ref_name: sha,
      },
    }) as Promise<FileHistoryItemInfo[]>;
  }

  /**
   * 获取某一个仓库里面的指定文件内容
   * @param filePath 文件路径
   * @param sha 哈希码
   * @returns
   */
  async getFileDetail(filePath: string, sha: string): Promise<string> {
    let fileCache: Map<string, string>;
    // 一级缓存不存在
    if (!this.fileDetailCache.get(sha)) {
      fileCache = new Map();
      this.fileDetailCache.set(sha, fileCache);
    } else {
      fileCache = this.fileDetailCache.get(sha)!;
    }
    // 从缓存中获取,能获取的到的话,提前返回
    if (fileCache.get(filePath)) {
      return fileCache.get(filePath)!;
    }
    const resp = (await this.sendRequest({
      url: `/projects/${this.projectId}/repository/files/${encodeURIComponent(filePath)}`,
      data: {
        ref: sha,
      },
    })) as FileDetailInfo;
    const parsedContent = Buffer.from(resp.content, "base64").toString("utf-8");
    // 设置缓存
    fileCache.set(filePath, parsedContent);
    return parsedContent;
  }

  /**
   * 发起一个MergeRequest级别的评论
   * @param info MergeRequest详情
   * @param comment 评论内容
   * @returns
   */
  async requestCommentForMergeRequest(info: MergeRequest, comment: string) {
    return this.sendRequest({
      url: `/projects/${this.projectId}/merge_requests/${info.iid}/discussions`,
      method: "post",
      headers: {
        "Content-Type": "application/json",
      },
      data: {
        body: comment,
      },
    });
  }

  /**
   * 发起一个行级评论,针对单个文件
   * @param info MergeRequest详情
   * @param changeFile 发起评论的目标文件
   * @param line 发起评论所在的行
   * @param comment 评论信息
   * @returns
   */
  async requestLineLevelCommentForFile(
    info: MergeRequest,
    changeFile: ChangeFileInfo,
    comment: string,
    newNine: number,
    oldLine?: number
  ) {
    return this.sendRequest({
      url: `/projects/${this.projectId}/merge_requests/${info.iid}/discussions`,
      method: "post",
      headers: {
        "Content-Type": "application/json",
      },
      data: {
        body: comment,
        position: {
          base_sha: info.diff_refs.base_sha,
          start_sha: info.diff_refs.start_sha,
          head_sha: info.diff_refs.head_sha,
          position_type: "text",
          new_path: changeFile.new_path,
          old_path: changeFile.old_path,
          new_line: newNine,
          old_line: oldLine,
        },
      },
    }).catch((err: AxiosError) => {
      logger.error(`对${changeFile.new_path}发起评论失败,详细信息:` + err.message);
      return {};
    });
  }
}

因为GitLab的类型定义文件比较多,我单独向大家展示:

/**
 * 定义 GitLab 合并请求(Merge Request)及相关实体的类型
 * 根据提供的 JSON 结构生成
 */
export interface MergeRequest {
  id: number;
  iid: number;
  project_id: number;
  title: string;
  description: string;
  state: string;
  created_at: string;
  updated_at: string;
  merged_by: User;
  merge_user: User;
  merged_at: string;
  closed_by: User | null;
  closed_at: string | null;
  target_branch: string;
  source_branch: string;
  user_notes_count: number;
  upvotes: number;
  downvotes: number;
  author: User;
  assignees: Assignee[];
  assignee: Assignee | null;
  reviewers: any[]; // 根据数据为空数组
  source_project_id: number;
  target_project_id: number;
  labels: any[]; // 根据数据为空数组
  draft: boolean;
  work_in_progress: boolean;
  milestone: any | null; // 根据数据为null
  merge_when_pipeline_succeeds: boolean;
  merge_status: string;
  detailed_merge_status: string;
  sha: string;
  merge_commit_sha: string;
  squash_commit_sha: string | null;
  discussion_locked: any | null; // 根据数据为null
  should_remove_source_branch: boolean;
  force_remove_source_branch: boolean;
  reference: string;
  references: References;
  web_url: string;
  time_stats: TimeStats;
  squash: boolean;
  squash_on_merge: boolean;
  task_completion_status: TaskCompletionStatus;
  has_conflicts: boolean;
  blocking_discussions_resolved: boolean;
  subscribed: boolean;
  changes_count: string;
  latest_build_started_at: any | null; // 根据数据为null
  latest_build_finished_at: any | null; // 根据数据为null
  first_deployed_to_production_at: any | null; // 根据数据为null
  pipeline: any | null; // 根据数据为null
  head_pipeline: any | null; // 根据数据为null
  diff_refs: DiffRefs;
  merge_error: any | null; // 根据数据为null
  user: MergeUser;
  changes: ChangeFileInfo[];
  overflow: boolean;
}

export interface User {
  id: number;
  username: string;
  name: string;
  state: string;
  avatar_url: string;
  web_url: string;
}

export interface Assignee {
  // 根据数据为空数组,结构应与User类似
  // 实际使用时可能需要更具体的定义
}

export interface References {
  short: string;
  relative: string;
  full: string;
}

export interface TimeStats {
  time_estimate: number;
  total_time_spent: number;
  human_time_estimate: any | null; // 根据数据为null
  human_total_time_spent: any | null; // 根据数据为null
}

export interface TaskCompletionStatus {
  count: number;
  completed_count: number;
}

export interface DiffRefs {
  base_sha: string;
  head_sha: string;
  start_sha: string;
}

export interface MergeUser {
  can_merge: boolean;
}

export interface ChangeFileInfo {
  diff: string;
  new_path: string;
  old_path: string;
  a_mode: string;
  b_mode: string;
  new_file: boolean;
  renamed_file: boolean;
  deleted_file: boolean;
}

export interface FileDetailInfo {
  file_name: string;
  file_path: string;
  size: number;
  encoding: string;
  content: string;
  content_sha256: string;
  ref: string;
  commit_id: string;
}

export interface FileHistoryItemInfo {
  id: string;
  short_id: string;
  created_at: string; // ISO 8601 格式日期字符串
  parent_ids: string[];
  title: string;
  message: string;
  author_name: string;
  author_email: string;
  authored_date: string; // ISO 8601 格式日期字符串
  committer_name: string;
  committer_email: string;
  committed_date: string; // ISO 8601 格式日期字符串
  trailers: Record<string, unknown>; // 键值对对象
  web_url: string;
}

/**
 * GitLab 分支信息类型定义
 */
export interface GitLabBranch {
  /** 分支名称 */
  name: string;

  /** 分支的最新提交信息 */
  commit: GitLabCommit;

  /** 是否已合并 */
  merged: boolean;

  /** 是否是保护分支 */
  protected: boolean;

  /** 开发者是否有推送权限 */
  developers_can_push: boolean;

  /** 开发者是否有合并权限 */
  developers_can_merge: boolean;

  /** 当前用户是否有推送权限 */
  can_push: boolean;

  /** 是否是默认分支 */
  default: boolean;

  /** 分支的 Web URL */
  web_url: string;
}

/**
 * GitLab 提交信息类型定义
 */
export interface GitLabCommit {
  /** 完整的提交 SHA */
  id: string;

  /** 简短的提交 SHA */
  short_id: string;

  /** 提交创建时间 (ISO 8601 格式) */
  created_at: string;

  /** 父提交的 SHA 数组 */
  parent_ids: string[];

  /** 提交标题 (第一行消息) */
  title: string;

  /** 完整的提交消息 */
  message: string;

  /** 作者姓名 */
  author_name: string;

  /** 作者邮箱 */
  author_email: string;

  /** 作者提交时间 (ISO 8601 格式) */
  authored_date: string;

  /** 提交者姓名 */
  committer_name: string;

  /** 提交者邮箱 */
  committer_email: string;

  /** 提交时间 (ISO 8601 格式) */
  committed_date: string;

  /** 提交的 trailers 信息 (如 Git trailer) */
  trailers: Record<string, string>;

  /** 提交的 Web URL */
  web_url: string;
}

核心模块

这个模块的内容其实反而还比较简单了,我们只需要做一件事儿,通过MRID,调用GitLab的API获取到changes,然后调用LLM,最后再根据结果调起GitLab的评论即可。

interface AICodeReviewAdvice {
  reviews: Array<{
    ln: number;
    comment: string;
  }>;
}

/**
 * 根据一个mergeRequest,使用AI发起CodeReview
 * @param mergeRequestId
 */
export async function startCodeReview(mergeRequestId: string) {
  const config = getGlobalConfig();
  const gitlabCtx = new GitLabIntegration();
  const robot = new IMRobotPushMessage();
  const mergeRequestDetail = await gitlabCtx.getMergeRequestDetailById(mergeRequestId);
  const targetCheckBranch = config.gitlab.targetCheckBranch || "master";
  if (
    config.gitlab.skipCRKeywords!.some((keywords) => {
      return mergeRequestDetail.title.indexOf(keywords) >= 0;
    })
  ) {
    logger.success("用户手动指定跳过CR检查!");
    process.exit(0);
  }
  if (mergeRequestDetail.target_branch !== targetCheckBranch) {
    logger.success("非目标分支,跳过校验!");
    process.exit(0);
  }
  const changes = mergeRequestDetail.changes;
  // 过滤掉不支持的文件,并且还要过滤掉删除的文件
  const shouldCommitCRChanges = changes.filter((current) => {
    // 包含用户自定义的配置
    return isSupportExt(current.new_path) && config.advance!.isValid!(current.new_path) && !current.deleted_file;
  });
  // NOTE: 发送IM消息,统计多少个文件变更,其中需要进行CodeReview的文件是多少个,若有配置的话
  if (robot.isConfiguration) {
    await robot.sendCodeReviewStartMsg(mergeRequestDetail);
  }
  const targetBranch = await gitlabCtx.getTargetBranchSha(mergeRequestDetail.target_branch);
  const targetSha = targetBranch.commit.id;
  let commentCount = 0;

  /**
   * 根据AI返回的建议,对MergeRequest提交建议
   * @param mergeRequest MergeRequestDetail
   * @param change 单个文件的变更
   * @param aiResponse AI的返回内容
   * @returns
   */
  async function setCodeReviewAdvice(mergeRequest: MergeRequest, change: ChangeFileInfo, aiResponse: DeepSeekResponse) {
    const choices = aiResponse.choices || [];
    for (const choice of choices) {
      const adviceJson = choice.message?.content || "";
      const formatJson = adviceJson
        .replace(/^```json/, "")
        .replace(/```/, "")
        .replace(/\\n/g, "");
      const responseAdvices = JSON.parse(formatJson) as AICodeReviewAdvice;
      const reviews = responseAdvices?.reviews || [];
      if (reviews.length === 0) {
        logger.success(`当前文件${change.new_path}没有修改建议`);
        return;
      }
      for (const adviceRow of reviews) {
        commentCount++;
        await gitlabCtx.requestLineLevelCommentForFile(mergeRequest, change, adviceRow.comment, adviceRow.ln);
      }
    }
  }

  for (const change of shouldCommitCRChanges) {
    // 对于其它在非预期的配置中的二进制文件,也不用进行校验
    if (/^Binary file/.test(change.diff)) {
      continue;
    }
    const sendDiff = `\`\`\`diff
    ${change.diff}
    \`\`\``;
    const codeReviewResponse = await requestAIPromptResponse(sendDiff);
    await setCodeReviewAdvice(mergeRequestDetail, change, codeReviewResponse);
  }
  // NOTE: CodeReview完成,发送飞书消息
  if (robot.isConfiguration) {
    await robot.sendCodeReviewTerminateMsg(mergeRequestDetail, commentCount);
  }
}

导出JS API

这个时候,我们要想清楚需要对外导出什么内容,因为用户知道的东西多了,反而是负担,对于用户来说,他需要把基本配置传递给我们,因此,我们需要导出设置配置的方法,另外,最核心的发起CR的方法肯定是少不了的,其它内容基本上就是我们项目内部自己实现的细节了,不必向外界导出。

于是,整体JS API设计就如下:

export { setGlobalConfig } from "./config";
export { startCodeReview } from "./core";

飞书IM通知模块(可选)

我们团队使用飞书,为了能够更好的通知,所以我额外接入了飞书机器人,飞书机器人是可选的,若用户没有配置的话,就不要发送信息。

export class IMRobotPushMessage {
  private secret: string = "";

  private webhook: string = "";

  /**
   * 是否已经正确配置IM机器人
   */
  public get isConfiguration() {
    return this.secret && this.webhook;
  }

  constructor() {
    const config = getGlobalConfig();
    if (config.robot) {
      this.secret = config.robot?.secret;
      this.webhook = config.robot.webhook;
    }
  }

  /**
   * 获取IM的秘钥的配置
   * @returns
   */
  private getAuthConfig() {
    const timestamp = Math.floor(Date.now() / 1000);
    const str = Buffer.from(`${timestamp}\n${this.secret}`, "utf8");
    const sign = crypto.createHmac("SHA256", str);
    sign.update(Buffer.alloc(0));
    return { timestamp, sign: sign.digest("base64") };
  }

  /**
   * 向IM发送消息
   * @param msg
   * @returns
   */
  sendMessage(msg?: null | Record<PropertyKey, unknown>) {
    if (!msg) {
      return;
    }
    if (!this.secret || !this.webhook) {
      console.error("尚未完成IM配置,无法发送消息");
      return;
    }
    const authInfo = this.getAuthConfig();
    const sendBody = {
      ...authInfo,
      ...msg,
    };
    return axios.post(this.webhook, sendBody);
  }

  /**
   * 生成IM的卡片信息
   * @param eventChannel 事件
   * @param content 事件内容
   * @param info MergeRequest详情
   * @returns
   */
  private buildCardInfo(eventChannel: string, content: string, info: MergeRequest, theme: string = "violet") {
    const { title, author, web_url } = info;
    return {
      msg_type: "interactive",
      card: {
        header: {
          template: theme,
          title: {
            content: eventChannel,
            tag: "plain_text",
          },
        },
        elements: [
          {
            fields: [
              {
                is_short: false,
                text: {
                  content,
                  tag: "lark_md",
                },
              },
            ],
            tag: "div",
          },
          {
            fields: [
              {
                is_short: true,
                text: {
                  content: `**📚提交:**\n${title}`,
                  tag: "lark_md",
                },
              },
              {
                is_short: true,
                text: {
                  content: `**👤作者:**\n${author.name}`,
                  tag: "lark_md",
                },
              },
            ],
            tag: "div",
          },
          {
            tag: "hr",
          },
          {
            actions: [
              {
                tag: "button",
                text: {
                  content: "查看MergeRequest详情",
                  tag: "plain_text",
                },
                type: "primary",
                url: web_url,
              },
            ],
            tag: "action",
          },
        ],
      },
    };
  }

  private buildCodeReviewStartMsg(info: MergeRequest) {
    const { changes_count, changes } = info;
    const supportChanges = changes.filter((change) => {
      return isSupportExt(change.new_path);
    });
    // 没有需要进行CodeReview的文件的话,就不再需要通知了
    if (supportChanges.length === 0) {
      return null;
    }
    return this.buildCardInfo(
      "AI-CodeReview开始阶段提醒",
      `文件变更信息: 本次MR共有文件${changes_count}个变更,其中需要使用AI进行CodeReview的文件为${supportChanges.length}个,CodeReview的过程可能会花费一些时间,请您耐心等待...`,
      info
    );
  }

  /**
   * 根据MergeRequest向IM发送CodeReview开始提醒
   * @param info MergeRequest详情
   * @returns
   */
  sendCodeReviewStartMsg(info: MergeRequest) {
    const msg = this.buildCodeReviewStartMsg(info);
    return this.sendMessage(msg);
  }

  private buildCodeReviewTerminateMsg(info: MergeRequest, commentCount: number) {
    const { changes } = info;
    const supportChanges = changes.filter((change) => {
      return isSupportExt(change.new_path);
    });

    return this.buildCardInfo(
      "AI-CodeReview结束阶段提醒",
      `系统已成功为您完成${supportChanges.length}个文件的CodeReview,系统已将${
        commentCount > 0 ? commentCount + "个" : ""
      }详细信息评论到对应的代码片段,请针对给予的意见进行代码修改(若有)`,
      info,
      "green"
    );
  }

  /**
   * 根据MergeRequest向IM发送CodeReview结束提醒
   * @param info
   */
  sendCodeReviewTerminateMsg(info: MergeRequest, commentCount: number) {
    const msg = this.buildCodeReviewTerminateMsg(info, commentCount);
    return this.sendMessage(msg);
  }
}

现在,我们就可以使用Jest来运行测试用例了:

在test目录下面建立一个core.spec.ts,使用Jest的API来描述测试用例,我们的代码就可以跑起来了,如下图,直接点击就可以运行。 image.png

注意,我上面的测试用例写的比较潦草,如果你是一个专业的技术团队,请慎重对待。

经过以上步骤,我们核心的内容就已经开发完成了。

CLI集成

上述阶段,我们只完成了JS API的开发,为了使得我们能够把这个能力集成到CI/CD环境,我们需要提供命令行的能力。

对于这个命令的话,我们只需要要求用户传入一个MRID即可,其它内容都事先通过配置文件传入。

我采用的是cac这个包,当然您也可以有别的选择,比如command.js

import { cac } from "cac";
import { VERSION } from "./constants";
import { CodeReviewCommand } from "./commands/code-review";
const cli = cac("my-tool");

cli
  .command("cr", "start code review by ai")
  .option("--mrId <mrId>", "MergeRequestId")
  .action(async ({ mrId }: { mrId: string }) => {
    const crCmd = new CodeReviewCommand();
    await crCmd.run(mrId);
  });
  

cli.help();
cli.version(VERSION);

cli.parse();  

对于一个CLI程序的话,需要指定一下package.jsonbin字段:

{
    name: '@xxx/cli',
    "bin": {
        "my-tool": "./lib/cli.js"
    }
}

因为我的构建结果的主入口就是lib/cli.js,所以我是这样配置的,大家可以根据自己的需求酌情处理。

最后就是是编写CodeReviewCommand的实现了。

import { setGlobalConfig, startCodeReview } from "@xxx/ai-code-review";
import { cliConfigureManager } from "../config/cli-config";

export class CodeReviewCommand {
  async run(mergeRequestId: string) {
    // 事先载入项目的配置
    cliConfigureManager.loadConfig();
    if (!cliConfigureManager.cliConfig.codeReview) {
      logger.error("请正确配置CodeReview所需配置!");
      return;
    }
    const { deepseek, gitlab, robot, advance } = cliConfigureManager.cliConfig.codeReview;
    // 传入项目的配置
    setGlobalConfig({
      deepseek: {
        apiKey: deepseek.apiKey,
      },
      gitlab: {
        hostname: gitlab.hostname,
        token: gitlab.token,
        projectId: gitlab.projectId,
        targetCheckBranch: gitlab.targetCheckBranch,
        singleFileMaxRows: gitlab.singleFileMaxRows,
      },
      robot: robot
        ? {
            webhook: robot.webhook,
            secret: robot.secret,
          }
        : undefined,
      advance: {
        isValid: advance?.isValid,
      },
    });
    await startCodeReview(mergeRequestId);
  }
}

上述的cliConfigureManager是我自己实现的一个解析配置的规则,大家可以根据自己的需求配置即可。

好了,经过这个步骤之后,命令行就可以调用了。

image.png 脚本也已经在执行了。 image.png

万事俱备,只欠东风了,我们只需要把它在集成到CI/CD环境成为自动化流水线的一个可选步骤即可。

CI/CD环境集成

对于这个步骤,肯定很多前端同学都不太熟悉,其实我也是不熟悉,如果自己的公司有运维的话,可以让运维同事帮帮忙,如果没有的话,就求助一下人工智能吧,应该还是可以给你一些可用的方案的。

# 增加一个stage
stages:
- code-review
# 其它stage并没有向大家展示

default:
  before_script:
  - eval $(ssh-agent -s)
  - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
  

# 新增的code-review Stage
code-review:
  stage: code-review
  # 这个image我写的也是假的,大家可以根据自己的需求处理
  image: xxx
  script:
    - npm config set registry https://registry.npm.taobao.org/
    - npm install -g pnpm@9.5.0
    - pnpmi i @xxx/cli -g    
    # 执行代码审查命令,使用GitLab预定义变量CI_MERGE_REQUEST_IID获取MR ID
    - npm run cr -- --mrId=${CI_MERGE_REQUEST_IID}
  rules:
    # 仅在合并请求到master分支时触发
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master"'
      when: always
  tags:
    - devops-runner-v15.11.1
# 其它配置    

好了,目前就完成了CI/CD的环境集成了。

效果展示

当且仅当发起MergeRequest时,且目标分支是Master时,才开启CR阶段。 image.png 开始进行CR前后,飞书机器人都会把消息同步到相关消息群。 image.png CR过程中,LLM生成的意见会相应评论到对应的MR详情的变更文件对应的位置,开发者即可根据意见进行修改即可。 image.png

总结与优化

以上就向大家展示了一个基于Deepseek实现的自动化AI CodeReview工具,大家可以通过配置prompt可以改善LLM返回的内容的准确度,经过我们团队的实测,它能够帮助我们提出约 30% 的有效的CR建议,我对这个效率已经非常满意了,哈哈哈。

由于我们是前端项目,所以CSS在CR过程中并不重要,因此我在发起大模型调用的时候,是移除了相关CSS的变化的,并且因为CSS的Token占用还特别多,所以移除掉CSS的话,每次LLM的调用就特别省钱了。

按照我们目前的团队使用情况,这个价格还是相当划算了。 image.png

另外,我在实现的时候是直接把LLM的API地址硬编码了,如果大家有切换别的LLM的需求,可以通过策略模式编写不同LLM的实现类,然后供程序调用,这样就可以支持LLM的切换了。

大家在阅读完之后如果对于本文有什么问题,可以联系我。

由于笔者水平有限,写作过程中难免出现错误,若有纰漏,请各位读者指正,你们的意见将会帮助我更好的进步。本文乃笔者原创,若转载,请联系作者本人,邮箱404189928@qq.com🥰