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

前言

这篇文章,我将向大家介绍如何使用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即可,返回内容需要以这样的格式返回:

ts 复制代码
{ 
    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可是中国科技的一颗冉冉升起的新星。

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

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

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

GitLab 配置

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

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

以下就是我创建的Token:

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

项目配置

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

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

json 复制代码
{
  "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的配置如下:

ts 复制代码
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目录就可以开始搞了。

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

代码编写

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

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

配置模块

ts 复制代码
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的能力,返回结果。

ts 复制代码
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的操作统一封装,便于管理。

ts 复制代码
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的类型定义文件比较多,我单独向大家展示:

ts 复制代码
/**
 * 定义 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的评论即可。

ts 复制代码
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设计就如下:

ts 复制代码
export { setGlobalConfig } from "./config";
export { startCodeReview } from "./core";

飞书IM通知模块(可选)

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

ts 复制代码
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来描述测试用例,我们的代码就可以跑起来了,如下图,直接点击就可以运行。

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

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

CLI集成

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

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

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

ts 复制代码
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字段:

json 复制代码
{
    name: '@xxx/cli',
    "bin": {
        "my-tool": "./lib/cli.js"
    }
}

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

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

ts 复制代码
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是我自己实现的一个解析配置的规则,大家可以根据自己的需求配置即可。

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

脚本也已经在执行了。

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

CI/CD环境集成

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

yaml 复制代码
# 增加一个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阶段。 开始进行CR前后,飞书机器人都会把消息同步到相关消息群。 CR过程中,LLM生成的意见会相应评论到对应的MR详情的变更文件对应的位置,开发者即可根据意见进行修改即可。

总结与优化

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

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

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

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

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

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

相关推荐
xiaofeichaichai4 小时前
Webpack
前端·webpack·node.js
Thecozzy4 小时前
线上 Bug 排查与修复实录
架构
鹏大师运维4 小时前
为什么信创电脑装软件总提示“软件包架构不匹配”?
linux·运维·架构·国产化·麒麟·deb·统信uos
问心无愧05134 小时前
ctf show web入门111
android·前端·笔记
唐某人丶5 小时前
模型越来越强,我们还需要 Agent 工程吗?—— 从价值重估到 Harness 实践
前端·agent·ai编程
智码看视界5 小时前
现代Web开发基础:全栈工程师的起航点
前端·后端·c5全栈
JS菌5 小时前
手写一个 AI Agent 全栈项目:从沙箱执行到子智能体的完整实现
前端·人工智能·后端
excel6 小时前
HLS TS 文件损坏的元凶:Git 提交与拉取
前端
Aphasia3117 小时前
https连接传输流程
前端·面试