GitLab merge request 结合钉钉群消息机器人的全自动 Code Review 实践(含源码)——下

2,133 阅读8分钟
本文是 Code Review 系列的第三篇,前两篇可点击下方传送门查看

这个系列计划写3篇(本文是第3篇)

---------------------------------------我是长长的分割线-----------------------------------

GitLab 上的所有操作,之所以能够把消息推送到对应钉钉群,本质上是借助于 Webhooks 和钉钉群消息机器人的能力,其流程如下图所示

  1. GitLab 上发生操作,比如 Create Merge Request,会触发 GitLab 自身对应事件的“回调”(自己歪歪的名字);
  2. 在该回调里,发现对应的事件配置了 Webhooks 相关服务,则会将当前事件的所有信息通过 POST 方式发送给对应的服务,也就是 Webhooks 里配置的 URL所对应的服务,上图里就是钉钉群”GitLab机器人“服务(自己歪歪的名字);
  3. 钉钉群”GitLab机器人“服务接受到 GitLab 的请求之后,对该请求进行处理,其逻辑是根据不同的“GitLab 事件类型” 推送不同的消息到对应的钉钉群(每个钉钉群机器人的服务URL都是不一样的);

上述流程中,给钉钉群推送什么样格式的消息是由钉钉群"GitLab机器人"服务来做的,那么如果要自定义推送到钉钉群的消息内容,只需要替换掉钉钉群”GitLab机器人“的默认服务即可,流程图如下所示

区别有两处

  1. GitLab 的消息推送到自建 Nodejs 服务,由该服务自定义业务逻辑,比如将 GitLab 消息体中的@格式转换为钉钉群消息的@格式;
  2. 自建Nodejs服务将处理后的数据推送给钉钉群”自定义机器人“服务,这里之所以要用钉钉群”自定义机器人“服务替换钉钉群”GitLab机器人“服务,就是为了实现推送到钉钉群消息的完全自定义;

接下来,就跟着笔者来一步一步搭建一个完整的”自动化“ code review服务吧。

第1步:创建钉钉群自定义机器人

首先,我们创建一个钉钉群自定义机器人,该机器人会对外暴露一个 webhook(本质就是一个 URL 提供服务),通过该 webhook,可以往钉钉群推送任何自定义消息,具体操作路径是:钉钉群设置->群助手->添加机器人->选择自定义机器人->下一步……直至最终创建完成->拷贝webhook url。不熟悉该步骤的同学可参考上一篇有类似步骤或者官方文档,和上一篇中添加机器人唯一不同如下图所示,添加机器人时选择自定义机器人即可。

这里需要额外关注的是,钉钉群自定义机器人接受消息的格式,官方文档有详细描述,笔者采用了 markdown 格式,如下示例

{
     "msgtype": "markdown",
     "markdown": {
         "title":"杭州天气",
         "text": "#### 杭州天气 @150XXXXXXXX \n > 9度,西北风1级,空气良89,相对温度73%\n > ![screenshot](https://img.alicdn.com/tfs/TB1NwmBEL9TBuNjy1zbXXXpepXa-2400-1218.png)\n > ###### 10点20分发布 [天气](https://www.dingtalk.com) \n"
     },
      "at": {
          "atMobiles": [
              "150XXXXXXXX"
          ],
          "atUserIds": [
              "user123"
          ],
          "isAtAll": false
      }
 }

对应字段的含义如下

  • msgtype 表明数据格式,这里选择 markdown;
  • title 是通信的标题,可自定义;
  • text 是推送到钉钉群的消息主体;
  • at 对象里说明要@的用户信息,有两种@方式:1. atMobiles 通过手机号识别用户;2. atUserIds 通过钉钉的用户ID识别用户。

至此钉钉群自定义机器人就创建好了,想要测试也很简单,任意发送一个 POST 请求,请求 URL 就是创建的自定义机器人的 webhook URL,参数直接拷贝如上示例数据即可。

第2步:搭建 Nodejs 服务

Nodejs 服务作为 GitLab 和 钉钉群之间的桥梁,负责接受 GitLab 的请求,对数据进行二次加工,再推送到钉钉群,其核心逻辑也很简单

  1. 对外暴露一个服务接口,接受 GitLab 的 POST 请求;
  2. 处理完 GitLab 的请求之后,发起一个 POST 请求,将数据推送给钉钉群;

接下来就分步解释一下 Nodejs 服务的核心代码实现(完整代码已在 GitHub 上开源,如果觉得有帮助记得 star 哦

2.1 入口文件

该文件包含内容如下

  1. 暴露一个接口,名称为 /code-review ;
  2. 根据 GitLab 的事件类型,将请求分发到对应的服务,比如 merge request 由其对应的服务来处理;

源码

import Koa from "koa";
import bodyParser from 'koa-bodyparser';
import Router from 'koa-router';
import fs from 'fs';
import actionComment from './actions/comment';
import actionMergeRequest from './actions/merge_request';
import actionPush from './actions/push';
import actionTagPush from './actions/tag_push';
import actionIssue from './actions/issue';
import actionBuild from './actions/build';
import actionPipeline from './actions/pipeline';
import actionWiki from './actions/wiki';

interface ICtx {
  request: any;
  body?: any;
}

const app = new Koa();
const router = new Router();
// DingTalk group code review url
const defaultDingtalkUrl = 'https://oapi.dingtalk.com/robot/send?access_token=ae02abd824a4c628b97dd95a2ce3f2a67303cccfe38b6c3dd2aee2c6efb8c169';

router.post('/code-review', async (ctx: ICtx) => {
  const { dingTalkUrl } = ctx.request.query || {};
  const gitlabDataFromWebHook = { dingTalkUrl: dingTalkUrl || defaultDingtalkUrl, ...ctx.request.body, };
  fs.writeFile('log.txt', JSON.stringify(gitlabDataFromWebHook), () => {
    // 1. 简单的日志记录
  });
  const {
    object_kind, // 2. GitLab 的事件类型
  } = gitlabDataFromWebHook || {};
  
  // 3. 根据不同事件类型,分发给不同的服务
  // 不同的服务本质上都是类似的,对 GitLab 数据进行深加工,然后发送特定格式的数据到钉钉群
  switch (object_kind) {
    case 'merge_request':
      // 4. 接下来就以 merge request 事件为例来分享一下处理逻辑
      actionMergeRequest(gitlabDataFromWebHook);
      break;
    case 'note':
      actionComment(gitlabDataFromWebHook);
      break;
    case 'push':
      actionPush(gitlabDataFromWebHook);
      break;
    case 'tag_push':
      actionTagPush(gitlabDataFromWebHook);
      break;
    case 'issue':
      actionIssue(gitlabDataFromWebHook);
      break;
    case 'build':
      actionBuild(gitlabDataFromWebHook);
      break;
    case 'pipeline':
      actionPipeline(gitlabDataFromWebHook);
      break;
    case 'wiki_page':
      actionWiki(gitlabDataFromWebHook);
      break;
    default:
      // nothing
  }
  ctx.body = "hello code review";
});

app
  .use(bodyParser())
  .use(router.routes())
  .use(router.allowedMethods());

app.listen(50001);
  • 使用 koa 和 koa-router 启动一个 Nodejs 服务,对 koa 不熟悉的同学可前往其官网查看相关文档,也是很简单的;
  • 依赖 Nodejs 内置模块 fs 进行日志写入,这个也是 Nodejs 的基础模块,对 Nodejs 不熟悉的同学可前往其官网查案相关文档;

2.2 特定 GitLab 事件处理逻辑

源码

import request from '../utils/request'; // 1. request 是对 axios 的封装
import userList from '../config/user_list'; // 2. user_list 是一个对象,键为 GitLab 开发同学的用户名,值为其在钉钉群的手机号

export default function mergeRequest(gitlabDataFromWebHook: any) {
  const {
    dingTalkUrl,
    object_kind,
    user: {
      name: applyerName,
      username: applyerUsername,
    },
    assignee: { 
      name: assigneeName,
      username: assignUsername,
    },
    object_attributes: { 
      action,
      url: gitActionUrl,
      title,
      description,
      source_branch,
      target_branch,
      state: actionState,
    },
    project: {
      name: projectName,
    },
  } = gitlabDataFromWebHook;

  // 3. 如果该 merge request 没有选择 assignee, 则@该 merge request 提交人,通知她/他要选择 assignee(就是负责 review 的同学)  
  // 4. 只有当 merge request 是opened状态才@对应同学,其他状态不处理
  // 5. 这里 @ 的实现其实就是把对应的 GitLab 用户转换为钉钉群用户,底层依赖 user_list 文件
  // user_list 文件是一个以 GitLab 用户名为键,以钉钉群用户手机号为值的对象
  // 那转换就简单了,其实就是用户信息的简单替换
  let assigneeStr = '';
  if (actionState === 'opened') {
    assigneeStr = `@${userList[assigneeName] || userList[assignUsername]}`;
    // if no assignee then @ the applyer him/her self
    if (!userList[assigneeName] && !userList[assignUsername]) {
      assigneeStr = `@${userList[applyerName] || userList[applyerUsername]} 要找谁帮你merge代码呢?记得选择Assignee哦`
    }
  }
  if (actionState === 'merged') {
    const {
      object_attributes: {
        // last_commit is exist this case
        last_commit: {
          author: {
            email: applyerEmail,
          },
        },
      },
    } = gitlabDataFromWebHook
    const lastCommitUsername = applyerEmail.replace('@mistong.com', '');
    if (lastCommitUsername !== applyerName) {
      // only @ user if this actions is not dispatched by him/her self
      assigneeStr = `@${userList[lastCommitUsername] || '佚名'}, you can go on now`;
    }
  }

  // 6. 将所有数据拼装为钉钉群机器人要求的格式
  const text = `${applyerName} ${action} the ${object_kind} from ${source_branch} to ${target_branch} ${assigneeStr} \n `
    + `> [${title}](${gitActionUrl}) \n `
    + `> ###### [${description}](${gitActionUrl}) \n `
    + ` > ###### Status: ${actionState} \n `
    + `> Repository: ${projectName} \n `
    + `> ###### [${gitActionUrl}](${gitActionUrl})`;
  
  // 7. 向钉钉群发送消息
  request(dingTalkUrl, text);
}

user_list 代码简单示意如下

const userList: any = {  zhangsan: '135xxxxxxxx',};
export default userList;

request 代码示意如下

import axios from 'axios';
import userList from '../config/user_list';
import dingTalkCustomPrefix from '../config/dingtalk_custom_prefix';

export default function request(dingTalkUrl: string, text: string) {
  axios.post(
    dingTalkUrl,
    {
      "msgtype": "markdown",
      "at": {
        // TODO: this could be optimized with real @ user
        "atMobiles": Object.values(userList), // @ need to config 2 place, this is first place
        "atUserIds": [],
        "isAtAll": false
      },
      "markdown": {
        "title": dingTalkCustomPrefix,
        // // @ need to config 2 place, this is second place
        "text": `#### ${dingTalkCustomPrefix}: ${text}`,
      },
    }
  ).then((res: any) => {
    console.log(res.data);
  });
}

关于@钉钉群用户,需要设置2个地方(本文以用户手机号为媒介来实现)

  • atMobiles 字段要将需要@的用户手机号传进去

  • text 正文里也必须有 @目标用户手机号 的文本

假设我们要 @ 一个手机号为 19999999999 的用户,必须的数据格式如下所示

{
  "msgtype": "markdown",
   "at": {
        "atMobiles": [19999999999]
    },
    "markdown": {        
        "text": `@19999999999 同学你好,我要圈你了`,      
    },
}

当然也可以使用群用户的userId来实现@功能,这个找公司的管理员要到权限即可,笔者采用了更简单的方式,直接采集群用户的手机号。

需要注意的点

  1. Nodejs 服务必须部署在一个公网可以访问的服务器上,这样 GitLab 才能访问到该服务,笔者是部署在阿里云 ECS 上的;
  2. 另外,关于 GitLab 各种不同事件推送的数据格式,可参见其官网有详细说明,笔者的 Nodejs 服务也对各种不同的 GitLab 推送数据格式进行了本地 mock, 方便本地开发调试,具体路径为 src/events_mock_data ;

其余部分的代码比较简单,这里就不展开了,对 Typescript 不了解的同学可点击传送门,Typescript 是 JavaScript 的超集,新增了类型定义,入门也是很容易的。

基于 Nodejs 的服务部署好了之后,接下来,只需完成最后一步即可。

第3步:配置 GitLab 的 webhooks

具体配置的完整流程可参见上一篇,这里只示意一下核心部分。假设部署的 Nodejs 完整服务地址是 http://12.84.77.36:50001/code-review ,将该 url 添加到 GitLab 项目的 webhooks 即可,如下示意

至此,一个完整的流程就完成了,在 GitLab 上创建一个 merge request,对应的钉钉群就会收到消息了,@群用户的能力也有了,剩下的,就是根据自己的团队需求,自定义@逻辑即可。

完整流程图如下