前言
这篇文章,我将向大家介绍如何使用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可是中国科技的一颗冉冉升起的新星。
注册之后,我们接下来就要发动人民币的力量了,先进行充值。
充值完成之后,我们需要创建一个应用程序,填入你的应用程序的名称就可以了:
比如下图就是我创建的一个应用了,创建好之后你要妥善保存这个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的风格。
{
"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目录就可以开始搞了。
以下是我最终开发好的目录结构:
代码编写
我们大致需要几个模块,一个模块用来处理用户的配置,我们必然不能把那些配置全部写死在项目里面,到时候改起来也会比较难受。
这个项目里面需要配置的内容是非常多的,比如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来描述测试用例,我们的代码就可以跑起来了,如下图,直接点击就可以运行。
注意,我上面的测试用例写的比较潦草,如果你是一个专业的技术团队,请慎重对待。
经过以上步骤,我们核心的内容就已经开发完成了。
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.json
的bin
字段:
{
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
是我自己实现的一个解析配置的规则,大家可以根据自己的需求配置即可。
好了,经过这个步骤之后,命令行就可以调用了。
脚本也已经在执行了。
万事俱备,只欠东风了,我们只需要把它在集成到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阶段。
开始进行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🥰