├── .husky ├── .gitignore ├── pre-commit └── commit-msg ├── .eslintignore ├── examples ├── mr-note.png ├── MR-notice.png ├── code-comment.png ├── secret_token.png ├── with-merged.png ├── gitlab-project.png ├── with-assignee.png ├── with-code-comment.png ├── with-codereviewer.png ├── with-mr-complete.png ├── Gitlab-robot-process.png └── with-codereview-update.png ├── nodemon.json ├── src ├── schedule │ ├── index.ts │ └── gitlab │ │ ├── index.ts │ │ └── queryMergeRequests.ts ├── api │ ├── index.ts │ └── gitlabHook │ │ ├── event │ │ ├── index.ts │ │ ├── pipeline │ │ │ └── index.ts │ │ ├── comment │ │ │ └── index.ts │ │ └── mergeRequest │ │ │ └── index.ts │ │ └── index.ts ├── appConfig.ts ├── index.ts ├── config │ ├── gitlabEvent.ts │ ├── projectKeys.ts │ └── gitlabApi.ts ├── typings │ └── pinyin.d.ts └── utils │ ├── wechatUtil.ts │ ├── markdown.ts │ ├── requireAll.ts │ └── index.ts ├── .prettierrc ├── pm2.json ├── .commitlintrc.js ├── .eslintrc ├── scripts └── build.js ├── LICENSE ├── .gitignore ├── package.json ├── README.md └── tsconfig.json /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | out 2 | node_modules -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn commitlint --edit $1 -------------------------------------------------------------------------------- /examples/mr-note.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huih99/wx-gitlab-robot/HEAD/examples/mr-note.png -------------------------------------------------------------------------------- /examples/MR-notice.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huih99/wx-gitlab-robot/HEAD/examples/MR-notice.png -------------------------------------------------------------------------------- /examples/code-comment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huih99/wx-gitlab-robot/HEAD/examples/code-comment.png -------------------------------------------------------------------------------- /examples/secret_token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huih99/wx-gitlab-robot/HEAD/examples/secret_token.png -------------------------------------------------------------------------------- /examples/with-merged.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huih99/wx-gitlab-robot/HEAD/examples/with-merged.png -------------------------------------------------------------------------------- /examples/gitlab-project.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huih99/wx-gitlab-robot/HEAD/examples/gitlab-project.png -------------------------------------------------------------------------------- /examples/with-assignee.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huih99/wx-gitlab-robot/HEAD/examples/with-assignee.png -------------------------------------------------------------------------------- /examples/with-code-comment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huih99/wx-gitlab-robot/HEAD/examples/with-code-comment.png -------------------------------------------------------------------------------- /examples/with-codereviewer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huih99/wx-gitlab-robot/HEAD/examples/with-codereviewer.png -------------------------------------------------------------------------------- /examples/with-mr-complete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huih99/wx-gitlab-robot/HEAD/examples/with-mr-complete.png -------------------------------------------------------------------------------- /examples/Gitlab-robot-process.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huih99/wx-gitlab-robot/HEAD/examples/Gitlab-robot-process.png -------------------------------------------------------------------------------- /examples/with-codereview-update.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/huih99/wx-gitlab-robot/HEAD/examples/with-codereview-update.png -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts,json", 4 | "ignore": ["src/**/*.spec.ts","src/**/*.d.ts"], 5 | "exec": "npx ts-node-transpile-only ./src/index.ts" 6 | } -------------------------------------------------------------------------------- /src/schedule/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: TanHui 3 | * @Date: 2021-08-25 12:27:03 4 | * @LastEditors: TanHui 5 | * @LastEditTime: 2021-08-25 12:27:03 6 | * @Description: 7 | */ 8 | import "./gitlab"; 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "arrowParens": "avoid", 4 | "endOfLine": "lf", 5 | "bracketSpacing": true, 6 | "printWidth": 80, 7 | "useTabs": false, 8 | "trailingComma": "none", 9 | "semi": true 10 | } -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: TanHui 3 | * @Date: 2021-04-08 13:36:11 4 | * @LastEditors: TanHui 5 | * @LastEditTime: 2021-07-27 13:37:51 6 | * @Description: 7 | */ 8 | import e from "express"; 9 | import gitlabHook from "./gitlabHook"; 10 | 11 | export default function apiRegister(app: e.Express): void { 12 | app.post("/gitlab_hook", gitlabHook); 13 | } 14 | -------------------------------------------------------------------------------- /src/appConfig.ts: -------------------------------------------------------------------------------- 1 | // gitlab的api地址,一般为 公司使用的gitlab地址/api/v4, 形如http://www.gitlab.com/api/v4, 私服与公服皆可 2 | export const GITLAB_API_V4 = ""; 3 | // 访问gitlab API所需要的access_token: https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#personal-access-tokens 4 | export const GITLAB_ACCESS_TOKEN = ""; 5 | // gitlab事件通过webhook对此项目URL发起请求时请求头中携带有 `x-gitlab-token`,通过判定该请求头中是否包含GITLAB_EVENT_TOKEN,从而决定是否处理该次请求 6 | export const GITLAB_EVENT_TOKEN = "gitlab-hook"; 7 | -------------------------------------------------------------------------------- /pm2.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "name": "webhook-server", 5 | "script": "index.js", 6 | "cwd": "./", 7 | "watch": true, 8 | "ignore_watch": [ 9 | "logs", 10 | "package.json", 11 | "yarn.lock" 12 | ], 13 | "env": { 14 | "server_port": 16080 15 | }, 16 | "error_file": "./logs/err.log", 17 | "merge_logs": true, 18 | "log_date_format": "YYYY-MM-DD HH:mm Z" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: TanHui 3 | * @Date: 2021-04-08 13:23:55 4 | * @LastEditors: TanHui 5 | * @LastEditTime: 2021-08-25 14:31:51 6 | * @Description: 7 | */ 8 | 9 | import express from "express"; 10 | import apiRegister from "./api"; 11 | import "./schedule"; 12 | 13 | const app = express(); 14 | app.use(express.json()); 15 | apiRegister(app); 16 | app.get("/", function (req, res) { 17 | res.end("hello"); 18 | }); 19 | app.listen(process.env.server_port || 8080); 20 | -------------------------------------------------------------------------------- /src/config/gitlabEvent.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: TanHui 3 | * @Date: 2021-04-08 13:39:34 4 | * @LastEditors: TanHui 5 | * @LastEditTime: 2021-07-27 13:13:06 6 | * @Description: 7 | */ 8 | 9 | export const EVENT_TYPE = { 10 | MERGE_REQUEST: "merge_request", 11 | COMMENT: "note", 12 | PIPELINE: "pipeline" 13 | }; 14 | 15 | export const EVENT_ACTION = { 16 | [EVENT_TYPE.MERGE_REQUEST]: { 17 | open: "open", 18 | reopen: "reopen", 19 | update: "update", 20 | merge: "merge" 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /src/config/projectKeys.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: TanHui 3 | * @Date: 2021-04-09 17:42:56 4 | * @LastEditors: TanHui 5 | * @LastEditTime: 2021-07-26 15:53:37 6 | * @Description: 各项目微信机器人key 7 | */ 8 | export interface ProjectKeys { 9 | [k: string]: { 10 | id: number; 11 | key: string; 12 | }; 13 | } 14 | const projectKeys: ProjectKeys = { 15 | // 项目配置 16 | "project name": { 17 | id: 0, // project id, 在仓库的web页面中可以看到 18 | key: "robot key" // 企业微信机器人key 19 | }, 20 | }; 21 | export default projectKeys; 22 | -------------------------------------------------------------------------------- /src/typings/pinyin.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: TanHui 3 | * @Date: 2021-07-26 15:59:32 4 | * @LastEditors: TanHui 5 | * @LastEditTime: 2021-07-27 17:16:02 6 | * @Description: 7 | */ 8 | 9 | /* eslint-disable no-unused-vars */ 10 | declare function pinyin( 11 | pinyin: string, 12 | options: { [k: string]: any } 13 | ): string[][]; 14 | // eslint-disable-next-line no-redeclare 15 | declare namespace pinyin { 16 | export const STYLE_NORMAL: number; 17 | } 18 | 19 | declare module "pinyin" { 20 | export = pinyin; 21 | } 22 | -------------------------------------------------------------------------------- /.commitlintrc.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: TanHui 3 | * @Date: 2021-04-08 15:47:09 4 | * @LastEditors: TanHui 5 | * @LastEditTime: 2021-07-28 13:46:32 6 | * @Description: 7 | */ 8 | module.exports = { 9 | extends: ["@commitlint/config-conventional"], 10 | rules: { 11 | "type-enum": [ 12 | 2, 13 | "always", 14 | [ 15 | "build", 16 | "chore", 17 | "ci", 18 | "docs", 19 | "feat", 20 | "fix", 21 | "perf", 22 | "refactor", 23 | "revert", 24 | "style", 25 | "test", 26 | "version" 27 | ] 28 | ] 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /src/api/gitlabHook/event/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: TanHui 3 | * @Date: 2021-04-09 11:30:31 4 | * @LastEditors: TanHui 5 | * @LastEditTime: 2021-07-27 13:32:00 6 | * @Description: 7 | */ 8 | import mergeRequestHook, { MergeRequestEvent } from "./mergeRequest"; 9 | import commentHook, { NoteEvent } from "./comment"; 10 | import pipelineHook, { PipelineEvent } from "./pipeline"; 11 | import { EVENT_TYPE } from "../../../config/gitlabEvent"; 12 | 13 | export type EventData = MergeRequestEvent | NoteEvent | PipelineEvent; 14 | export default { 15 | [EVENT_TYPE.MERGE_REQUEST]: mergeRequestHook, 16 | [EVENT_TYPE.COMMENT]: commentHook, 17 | [EVENT_TYPE.PIPELINE]: pipelineHook 18 | }; 19 | -------------------------------------------------------------------------------- /src/utils/wechatUtil.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: TanHui 3 | * @Date: 2021-04-12 13:31:23 4 | * @LastEditors: TanHui 5 | * @LastEditTime: 2021-08-24 13:18:09 6 | * @Description: 企业微信机器人消息发送 7 | */ 8 | import axios, { AxiosResponse } from "axios"; 9 | const prefixUrl = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send"; 10 | 11 | export function sendMdMessage( 12 | key: string, 13 | content: string, 14 | mentionList = [], 15 | mentionMobiles = [] 16 | ): Promise { 17 | const api = prefixUrl + "?key=" + key; 18 | 19 | return axios.post(api, { 20 | msgtype: "markdown", 21 | markdown: { 22 | content, 23 | mentioned_list: mentionList, 24 | mentioned_mobile_list: mentionMobiles 25 | } 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /src/schedule/gitlab/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: TanHui 3 | * @Date: 2021-08-24 13:20:19 4 | * @LastEditors: TanHui 5 | * @LastEditTime: 2021-08-25 14:28:47 6 | * @Description: 定时任务管理,采用统一的约定方式,即每个定时任务需要导出一个函数,且该函数上必须包含一个rule属性 7 | */ 8 | import schedule from "node-schedule"; 9 | import path from "path"; 10 | import requireAll from "../../utils/requireAll"; 11 | 12 | interface Scheduler { 13 | (fireDate: Date): any; 14 | rule: any; 15 | } 16 | 17 | // 引入当前目录下除自身外的所有文件 18 | requireAll(__dirname, [path.basename(__filename)], modules => { 19 | scheduleJobs(modules); 20 | }); 21 | 22 | function scheduleJobs(schedulers: Scheduler[]) { 23 | schedulers.forEach(scheduler => { 24 | schedule.scheduleJob(scheduler.rule, scheduler); 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": [ 4 | "@typescript-eslint", 5 | "prettier" 6 | ], 7 | "parserOptions": { 8 | "ecmaVersion": 11, 9 | "sourceType": "module" 10 | }, 11 | "extends": [ 12 | "eslint:recommended", 13 | "plugin:@typescript-eslint/recommended" 14 | ], 15 | "rules": { 16 | "prettier/prettier": "error", 17 | "no-var": "error", 18 | // 注释前空格 19 | "spaced-comment": [ 20 | "error", 21 | "always" 22 | ], 23 | // 禁止修改 const 声明的变量 24 | "no-const-assign": 2, 25 | "no-unused-vars": "off", 26 | "@typescript-eslint/no-unused-vars": "error" 27 | }, 28 | "env": { 29 | "es6": true, 30 | "node": true 31 | } 32 | } -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: TanHui 3 | * @Date: 2021-04-13 10:15:29 4 | * @LastEditors: TanHui 5 | * @LastEditTime: 2021-07-27 17:20:09 6 | * @Description:项目打包,目前采取TS编译加复制关键文件的方式,不采用打包工具打包 7 | */ 8 | 9 | /* eslint-disable @typescript-eslint/no-var-requires */ 10 | const { spawn } = require("child_process"); 11 | const copyfiles = require("copyfiles"); 12 | 13 | const packageFiles = ["package.json", "yarn.lock", "pm2.json"]; 14 | const destination = ["out"]; 15 | 16 | const promiseCp = function (files, destination, options = {}) { 17 | return new Promise((resolve, reject) => { 18 | copyfiles(files.concat(destination), options, error => { 19 | if (error) { 20 | reject(error); 21 | } else { 22 | resolve(); 23 | } 24 | }); 25 | }); 26 | }; 27 | 28 | async function build() { 29 | await promiseCp(packageFiles, destination); 30 | // typescript 编译 31 | const ts = spawn("npx tsc", { shell: true, stdio: "inherit" }); 32 | ts.on("close", code => { 33 | if (code === 0) { 34 | console.log("✅ 代码打包成功"); 35 | } 36 | }); 37 | } 38 | build(); 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 huih99 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/utils/markdown.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: TanHui 3 | * @Date: 2021-04-09 10:08:11 4 | * @LastEditors: TanHui 5 | * @LastEditTime: 2021-08-24 15:54:47 6 | * @Description: 企业微信 md 语法 7 | */ 8 | 9 | class Markdown { 10 | private val: string; 11 | private beforeValue: string; 12 | constructor(str: string) { 13 | this.val = str || ""; 14 | this.beforeValue = ""; 15 | } 16 | join(val: string) { 17 | this.val += val; 18 | return this; 19 | } 20 | newLine() { 21 | this.val = `${this.val}\n`; 22 | return this; 23 | } 24 | quote() { 25 | this.val = `>${this.val}`; 26 | return this; 27 | } 28 | bold() { 29 | this.val = `**${this.val}**`; 30 | return this; 31 | } 32 | info() { 33 | this.val = `${this.val}`; 34 | return this; 35 | } 36 | warning() { 37 | this.val = `${this.val}`; 38 | return this; 39 | } 40 | comment() { 41 | this.val = `${this.val}`; 42 | return this; 43 | } 44 | link(href: string) { 45 | this.val = `[${this.val}](${href})`; 46 | return this; 47 | } 48 | mark() { 49 | this.val = `<@${this.val}>`; 50 | return this; 51 | } 52 | continue(str: string) { 53 | this.beforeValue += this.val; 54 | this.val = str; 55 | return this; 56 | } 57 | toString() { 58 | return this.beforeValue + this.val; 59 | } 60 | toJSON() { 61 | return this.beforeValue + this.val; 62 | } 63 | } 64 | 65 | function md(str = ""): Markdown { 66 | if (str && typeof str !== "string") { 67 | throw new Error("expected arg not string"); 68 | } 69 | return new Markdown(str); 70 | } 71 | 72 | export default md; 73 | -------------------------------------------------------------------------------- /src/utils/requireAll.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: TanHui 3 | * @Date: 2021-08-25 13:30:41 4 | * @LastEditors: TanHui 5 | * @LastEditTime: 2021-08-25 14:14:24 6 | * @Description: 引入目录下所有模块 7 | */ 8 | 9 | import * as fs from "fs"; 10 | import * as path from "path"; 11 | 12 | function requireAll(dir: string): void; 13 | function requireAll(dir: string, except: string[] | RegExp): void; 14 | function requireAll(dir: string, callback: (modules: any[]) => void): void; 15 | function requireAll( 16 | dir: string, 17 | except: string[] | RegExp, 18 | callback: (modules: any[]) => void 19 | ): void; 20 | function requireAll( 21 | dir: string, 22 | except?: string[] | RegExp | ((modules: any[]) => void), 23 | callback?: (modules: any[]) => void 24 | ): void { 25 | if (typeof except === "function") { 26 | callback = except; 27 | except = void 0; 28 | } 29 | fs.stat(dir, (err, stats) => { 30 | if (err) { 31 | return; 32 | } 33 | if (!stats.isDirectory()) { 34 | return; 35 | } 36 | fs.readdir(dir, (err, files) => { 37 | if (err) { 38 | return; 39 | } 40 | const modules: any[] = []; 41 | files.forEach(file => { 42 | if (except) { 43 | if (Array.isArray(except) && except.includes(file)) { 44 | return; 45 | } 46 | if (except instanceof RegExp && except.test(file)) { 47 | return; 48 | } 49 | } 50 | const modulePath = path.resolve(dir, file); 51 | // eslint-disable-next-line @typescript-eslint/no-var-requires 52 | const module = require(modulePath); 53 | modules.push(module.default || module); 54 | }); 55 | callback && callback(modules); 56 | }); 57 | }); 58 | } 59 | 60 | export default requireAll; 61 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: TanHui 3 | * @Date: 2021-04-09 13:51:47 4 | * @LastEditors: TanHui 5 | * @LastEditTime: 2021-08-24 14:42:30 6 | * @Description: 公共方法库 7 | */ 8 | 9 | import pinyin from "pinyin"; 10 | import { queryUserInfoByUsername } from "../config/gitlabApi"; 11 | 12 | /** 13 | * 将中文名转为userid 14 | * 名称在3个字以下时,使用名称全拼,否则使用姓全拼加名首字母 15 | * @param {string} chineseName 中文名 16 | * @return {string} userid 17 | */ 18 | export const usernameToUserid = function (chineseName: string): string { 19 | if (!chineseName) { 20 | return ""; 21 | } 22 | const splitNames = chineseName.split("-"); 23 | const truthName = splitNames.pop(); 24 | const pyNames = pinyin(truthName!, { style: pinyin.STYLE_NORMAL }); 25 | // 如果名字最后是数字,则该数字为编号 26 | let nameCode = ""; 27 | let userid = ""; 28 | if (/\d+/.test(pyNames[pyNames.length - 1][0])) { 29 | nameCode = pyNames.pop()![0]; 30 | } 31 | if (pyNames.length < 3) { 32 | userid = pyNames.reduce((acc, val) => { 33 | return acc + val[0]; 34 | }, ""); 35 | } else { 36 | pyNames.forEach((v, i) => { 37 | if (i === 0) { 38 | userid += v[0]; 39 | } else { 40 | const firstLetter = v[0].substr(0, 1); 41 | const secondLetter = v[0].substr(1, 1); 42 | 43 | userid += firstLetter; 44 | // 说明是翘舌音字,则取其声母部分作为首字母,例如z, zh 45 | if (secondLetter === "h") { 46 | userid += secondLetter; 47 | } 48 | } 49 | }); 50 | } 51 | return userid + nameCode; 52 | }; 53 | 54 | /** 55 | * 获取 MR描述中@ 的对象 56 | * @param {string} description mr 描述 57 | */ 58 | export const getCrList = (description = ""): string[] => { 59 | const atReg = /@[^\s]+/g; 60 | return (description.match(atReg) || []).map(assignee => assignee.slice(1)); 61 | }; 62 | 63 | /** 64 | * 将用户的username转换为企业微信中userid 65 | * @param crList Mr描述中@ 的用户 66 | * @returns 67 | */ 68 | export const crNameToUserid = async (crList: string[]): Promise => { 69 | const promises = crList.map(username => { 70 | return queryUserInfoByUsername(username); 71 | }); 72 | const users = await Promise.all(promises); 73 | return users.map(user => usernameToUserid(user[0].name)); 74 | }; 75 | -------------------------------------------------------------------------------- /src/api/gitlabHook/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: TanHui 3 | * @Date: 2021-04-08 13:37:56 4 | * @LastEditors: TanHui 5 | * @LastEditTime: 2021-07-27 13:37:15 6 | * @Description: 7 | */ 8 | import {GITLAB_EVENT_TOKEN} from '../../appConfig' 9 | import { EVENT_TYPE } from "../../config/gitlabEvent"; 10 | import { sendMdMessage } from "../../utils/wechatUtil"; 11 | import projectKeys from "../../config/projectKeys"; 12 | 13 | import events, { EventData } from "./event"; 14 | import { Request, Response } from "express"; 15 | 16 | function supportEventType(eventType: string) { 17 | return (Object.keys(EVENT_TYPE) as (keyof typeof EVENT_TYPE)[]).some( 18 | key => EVENT_TYPE[key] === eventType 19 | ); 20 | } 21 | 22 | function getWechatKey(project: { name: string; id: number }) { 23 | let wechatKey; 24 | Object.keys(projectKeys).some(key => { 25 | const target = projectKeys[key]; 26 | if (project.name === key && project.id === target.id) { 27 | wechatKey = target.key; 28 | return true; 29 | } 30 | }); 31 | return wechatKey; 32 | } 33 | 34 | export default async function (req: Request, res: Response): Promise { 35 | const sendError = (msg: string) => { 36 | res.setHeader("Content-Type", "text/plain;charset=utf-8"); 37 | res.status(500).end(msg); 38 | }; 39 | const gitlabToken = req.headers["x-gitlab-token"] || ""; 40 | if (gitlabToken.indexOf(GITLAB_EVENT_TOKEN) < 0) { 41 | return sendError("token invalid"); 42 | } 43 | const data = req.body as EventData; 44 | if (!data) { 45 | return sendError("response is empty"); 46 | } 47 | const eventType = data.object_kind; 48 | if (!supportEventType(eventType)) { 49 | return sendError("not support the event type: " + eventType); 50 | } 51 | 52 | const wechatKey = getWechatKey(data.project); 53 | if (!wechatKey) { 54 | return sendError("未获取到有效的wechat key"); 55 | } 56 | 57 | const handler = events[eventType]; 58 | try { 59 | const result = await handler(data as any); 60 | if (result.markdown) { 61 | sendMdMessage(wechatKey, result.markdown); 62 | } 63 | res.setHeader("Content-Type", "text/plain;charset=utf-8"); 64 | res.status(200).end(result.description); 65 | } catch (e) { 66 | const msg = e instanceof Error ? e.message : (e as string); 67 | sendError(msg); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env.local 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | out 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and not Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | 105 | # TernJS port file 106 | .tern-port 107 | 108 | # Stores VSCode versions used for testing VSCode extensions 109 | .vscode-test 110 | 111 | # yarn v2 112 | .yarn/cache 113 | .yarn/unplugged 114 | .yarn/build-state.yml 115 | .yarn/install-state.gz 116 | .pnp.* 117 | 118 | .DS_Store -------------------------------------------------------------------------------- /src/api/gitlabHook/event/pipeline/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: TanHui 3 | * @Date: 2021-06-16 10:40:08 4 | * @LastEditors: TanHui 5 | * @LastEditTime: 2021-07-27 11:31:40 6 | * @Description: pipeline 事件支持 7 | */ 8 | import { usernameToUserid } from "../../../../utils"; 9 | import md from "../../../../utils/markdown"; 10 | 11 | export interface PipelineEvent { 12 | object_kind: "pipeline"; 13 | object_attributes: { 14 | id: number; 15 | ref: string; 16 | tag: boolean; 17 | sha: string; 18 | before_sha: string; 19 | source: string; 20 | status: string; 21 | stages: string[]; 22 | created_at: string; 23 | finished_at: string; 24 | duration: number; 25 | detailed_status: string; 26 | }; 27 | user: { 28 | id: number; 29 | name: string; 30 | username: string; 31 | avatar_url: string; 32 | email: string; 33 | }; 34 | project: { 35 | id: number; 36 | name: string; 37 | description: string; 38 | web_url: string; 39 | namespace: string; 40 | path_with_namespace: string; 41 | default_branch: string; 42 | }; 43 | commit: { 44 | id: number; 45 | message: string; 46 | title: string; 47 | timestamp: string; 48 | url: string; 49 | author: { 50 | name: string; 51 | email: string; 52 | }; 53 | }; 54 | } 55 | 56 | function pipelineHook( 57 | data: PipelineEvent 58 | ): { description: string; markdown?: string } { 59 | const { 60 | project, 61 | object_attributes, 62 | user, 63 | commit = {} as PipelineEvent["commit"] 64 | } = data; 65 | // pipeline运行成功 66 | const { id, ref, detailed_status } = object_attributes || {}; 67 | if (detailed_status === "passed") { 68 | const userId = usernameToUserid(user.name); 69 | const pipelineUrl = `${project.web_url}/pipelines/${id}`; 70 | 71 | const markdown = 72 | "" + 73 | md(userId) 74 | .mark() 75 | .newLine() 76 | .join("pipeline运行成功") 77 | .newLine() 78 | .join(md(` 项目:${project.name}`).quote().toString()) 79 | .newLine() 80 | .join(md(` 分支:${ref}`).quote().toString()) 81 | .newLine() 82 | .join( 83 | md(" 最新提交:") 84 | .join(md(`${commit.title}`).info().toString()) 85 | .quote() 86 | .toString() 87 | ) 88 | .newLine() 89 | .join(md("查看").link(pipelineUrl).quote().toString()); 90 | return { markdown, description: "已成功发起通知" }; 91 | } 92 | return { description: "正在执行或者失败的pipeline不发起通知" }; 93 | } 94 | 95 | export default pipelineHook; 96 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webhook-server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "nodemon", 8 | "build": "rimraf out && node scripts/build", 9 | "test": "echo \"Error: no test specified\" && exit 1", 10 | "prepare": "husky install" 11 | }, 12 | "keywords": [ 13 | "webhook", 14 | "gitlab", 15 | "企业微信机器人" 16 | ], 17 | "author": "TanHui", 18 | "license": "MIT", 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/huih99/wx-gitlab-robot.git" 22 | }, 23 | "devDependencies": { 24 | "@commitlint/cli": "^12.1.1", 25 | "@commitlint/config-conventional": "^12.1.1", 26 | "@types/express": "^4.17.13", 27 | "@types/node": "^16.4.3", 28 | "@types/node-schedule": "^1.3.2", 29 | "@typescript-eslint/eslint-plugin": "^4.28.4", 30 | "@typescript-eslint/parser": "^4.28.4", 31 | "copyfiles": "^2.4.1", 32 | "cross-env": "^7.0.3", 33 | "cz-conventional-changelog": "^3.3.0", 34 | "eslint": "^7.23.0", 35 | "eslint-plugin-prettier": "^3.3.1", 36 | "husky": "^6.0.0", 37 | "lint-staged": "^10.5.4", 38 | "nodemon": "^2.0.12", 39 | "prettier": "^2.2.1", 40 | "rimraf": "^3.0.2", 41 | "ts-node": "^10.1.0", 42 | "typescript": "^4.3.5" 43 | }, 44 | "dependencies": { 45 | "axios": "^0.21.1", 46 | "express": "^4.17.1", 47 | "node-schedule": "^2.0.0", 48 | "pinyin": "^2.10.2" 49 | }, 50 | "lint-staged": { 51 | "src/**/*.js": [ 52 | "eslint --fix" 53 | ], 54 | "src/**/*.ts": [ 55 | "eslint --fix" 56 | ] 57 | }, 58 | "config": { 59 | "commitizen": { 60 | "path": "./node_modules/cz-conventional-changelog", 61 | "types": { 62 | "feat": { 63 | "description": "A new feature" 64 | }, 65 | "fix": { 66 | "description": "A bug fix" 67 | }, 68 | "docs": { 69 | "description": "Documentation only changes" 70 | }, 71 | "style": { 72 | "description": "Changes that do not to affect the meaning code(white-space, formatting,, missing semi-colons, etc)" 73 | }, 74 | "refactor": { 75 | "description": "A code change that neither fixes a bug or adds a feature" 76 | }, 77 | "perf": { 78 | "description": "A code change that improves performance" 79 | }, 80 | "chore": { 81 | "description": "Changes to the build process or auxiliary tools and libraries such as documentation generation" 82 | }, 83 | "version": { 84 | "description": "Bump version", 85 | "title": "NEW VERSION" 86 | } 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/config/gitlabApi.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: TanHui 3 | * @Date: 2021-04-09 09:56:18 4 | * @LastEditors: TanHui 5 | * @LastEditTime: 2021-08-24 14:08:46 6 | * @Description: 7 | */ 8 | import axios, { AxiosRequestConfig } from "axios"; 9 | import { GITLAB_ACCESS_TOKEN, GITLAB_API_V4 } from "../appConfig"; 10 | 11 | const request = axios.create({ 12 | baseURL: GITLAB_API_V4, 13 | timeout: 30000, 14 | headers: { "Private-Token": GITLAB_ACCESS_TOKEN } 15 | }); 16 | 17 | request.interceptors.response.use(response => { 18 | if (response.status === 200) { 19 | return response.data; 20 | } 21 | throw new Error(response.status + ": 不正确的状态码"); 22 | }); 23 | 24 | // 解决使用拦截器后,无法正确识别axios请求的返回类型问题 25 | interface Ajax { 26 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 27 | get(url: string, config?: AxiosRequestConfig): Promise; 28 | } 29 | 30 | const ajax: Ajax = function (url: string, config?: AxiosRequestConfig) { 31 | return request(url, config); 32 | }; 33 | 34 | ajax.get = function (url, config) { 35 | return request.get(url, config); 36 | }; 37 | 38 | export interface UserInfo { 39 | id: number; 40 | name: string; 41 | username: string; 42 | state: string; 43 | avatar_url: string; 44 | web_url: string; 45 | created_at: string; 46 | bio: string; 47 | location: string; 48 | public_email: string; 49 | skype: string; 50 | linkedin: string; 51 | twitter: string; 52 | website_url: string; 53 | organization: string; 54 | job_title: string; 55 | work_information: string | null; 56 | } 57 | 58 | interface Comment { 59 | body: string; 60 | author: { username: string; id: number }; 61 | } 62 | 63 | interface MergeRequest { 64 | id: number; 65 | title: string; 66 | created_at: string; 67 | updated_at: string; 68 | description: string; 69 | state: string; 70 | web_url: string; 71 | author: { 72 | name: string; 73 | username: string; 74 | }; 75 | assignee: { 76 | id: string; 77 | name: string; 78 | username: string; 79 | }; 80 | } 81 | 82 | /** 83 | * 查询用户信息 84 | * @param {number} id 用户 ID 85 | */ 86 | export const queryUserInfo = (id: number): Promise => { 87 | return ajax.get(`/users/${id}`); 88 | }; 89 | 90 | /** 91 | * 查询用户信息 92 | * @param {string} username 用户名 93 | * @returns 94 | */ 95 | export const queryUserInfoByUsername = ( 96 | username: string 97 | ): Promise => { 98 | return ajax.get(`/users?username=${username}`); 99 | }; 100 | /** 101 | * 查询 mr 的 comment 102 | * @param {number} projectId 项目 ID 103 | * @param {number} mrIid merge_request_iid 104 | */ 105 | export const queryMrComments = ( 106 | projectId: number, 107 | mrIid: number 108 | ): Promise => { 109 | return ajax.get( 110 | `/projects/${projectId}/merge_requests/${mrIid}/notes` 111 | ); 112 | }; 113 | 114 | /** 115 | * 获取项目下打开的MergeRequest 116 | * @param projectId 项目id 117 | * @returns {MergeRequest[]} 118 | */ 119 | export const queryOpenedMergeRequests = ( 120 | projectId: number 121 | ): Promise => { 122 | return ajax.get( 123 | `/projects/${projectId}/merge_requests?state=opened` 124 | ); 125 | }; 126 | -------------------------------------------------------------------------------- /src/schedule/gitlab/queryMergeRequests.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: TanHui 3 | * @Date: 2021-08-24 13:18:49 4 | * @LastEditors: TanHui 5 | * @LastEditTime: 2021-08-25 13:29:07 6 | * @Description: 7 | */ 8 | import { sendMdMessage } from "../../utils/wechatUtil"; 9 | import { usernameToUserid, getCrList, crNameToUserid } from "../../utils"; 10 | import md from "../../utils/markdown"; 11 | import { queryOpenedMergeRequests } from "../../config/gitlabApi"; 12 | import projectKeys from "../../config/projectKeys"; 13 | 14 | type PromiseType = T extends Promise ? P : any; 15 | 16 | interface TodoMergeRequest { 17 | robotKey: string; 18 | title: string; 19 | author: string; 20 | crList: string[]; 21 | web_url: string; 22 | } 23 | /** 24 | * 判断是否当天创建的Mr 25 | * @param createdTime 创建时间 26 | */ 27 | function isTimeout(createdTime: string): boolean { 28 | const curDate = new Date(); 29 | const createdDate = new Date(createdTime); 30 | const curDateNum = Number( 31 | "" + curDate.getFullYear() + curDate.getMonth() + curDate.getDate() 32 | ); 33 | const createdDateNum = Number( 34 | "" + 35 | createdDate.getFullYear() + 36 | createdDate.getMonth() + 37 | createdDate.getDate() 38 | ); 39 | return curDateNum > createdDateNum; 40 | } 41 | 42 | function mentionTodo(todoMrs: TodoMergeRequest[]) { 43 | // 先按企业微信机器人key分组 44 | const mrsGroups: { [k: string]: TodoMergeRequest[] } = {}; 45 | const robotKeys: string[] = []; 46 | todoMrs.forEach(item => { 47 | if (!mrsGroups[item.robotKey]) { 48 | robotKeys.push(item.robotKey); 49 | mrsGroups[item.robotKey] = []; 50 | } 51 | mrsGroups[item.robotKey].push(item); 52 | }); 53 | 54 | robotKeys.forEach(robotKey => { 55 | const markdown = md("待办MR事项提醒").newLine(); 56 | mrsGroups[robotKey].forEach((mr, idx, self) => { 57 | markdown 58 | .continue(`${idx + 1}、`) 59 | .quote() 60 | .continue(mr.title) 61 | .link(mr.web_url) 62 | .join(" "); 63 | mr.crList.map(item => { 64 | markdown.continue(item).mark(); 65 | }); 66 | markdown.newLine(); 67 | if (idx < self.length - 1) { 68 | markdown.continue("").quote().newLine(); 69 | } 70 | }); 71 | sendMdMessage(robotKey, markdown.toString()); 72 | }); 73 | } 74 | 75 | async function queryTodoMergeRequests(): Promise { 76 | const promises: Promise<{ 77 | robotKey: string; 78 | mrs: PromiseType>; 79 | }>[] = []; 80 | 81 | Object.keys(projectKeys).forEach(key => { 82 | promises.push( 83 | new Promise((resolve, reject) => { 84 | queryOpenedMergeRequests(projectKeys[key].id) 85 | .then(mrs => { 86 | resolve({ robotKey: projectKeys[key].key, mrs }); 87 | }) 88 | .catch(reject); 89 | }) 90 | ); 91 | }); 92 | 93 | const openedProjectMrs = await Promise.all(promises); 94 | const promises1: Promise[] = []; 95 | openedProjectMrs.forEach(item => { 96 | item.mrs.forEach(async mr => { 97 | if (isTimeout(mr.created_at)) { 98 | promises1.push( 99 | new Promise((resolve, reject) => { 100 | const crList = getCrList(mr.description); 101 | crNameToUserid(crList) 102 | .then(crIds => { 103 | resolve({ 104 | robotKey: item.robotKey, 105 | crList: crIds, 106 | author: usernameToUserid(mr.author.name), 107 | web_url: mr.web_url, 108 | title: mr.title 109 | }); 110 | }) 111 | .catch(reject); 112 | }) 113 | ); 114 | } 115 | }); 116 | }); 117 | 118 | const todoMrs: TodoMergeRequest[] = await Promise.all(promises1); 119 | mentionTodo(todoMrs); 120 | } 121 | /* 122 | cron style rule: 123 | * * * * * * 124 | ┬ ┬ ┬ ┬ ┬ ┬ 125 | │ │ │ │ │ │ 126 | │ │ │ │ │ └ day of week (0 - 7) (0 or 7 is Sun) 127 | │ │ │ │ └───── month (1 - 12) 128 | │ │ │ └────────── day of month (1 - 31) 129 | │ │ └─────────────── hour (0 - 23) 130 | │ └──────────────────── minute (0 - 59) 131 | └───────────────────────── second (0 - 59, OPTIONAL) 132 | */ 133 | 134 | // 每天的10点0分0秒执行任务 135 | queryTodoMergeRequests.rule = "0 0 10 * * *"; 136 | 137 | export default queryTodoMergeRequests; 138 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 8 | 9 | # wx-gitlab-robot 10 | 11 | [![OSCS Status](https://www.oscs1024.com/platform/badge/huih99/wx-gitlab-robot.svg?size=small)](https://www.oscs1024.com/project/huih99/wx-gitlab-robot?ref=badge_small) 12 | 13 | 让gitlab事件直接在企业微信中发起提醒,替代传统的邮件提醒,达到更好的消息送达率。 14 | 15 | ![gitlab-robot-process](examples/Gitlab-robot-process.png) 16 | 17 | 最开始做这个项目的目的是为了建立一套`Merge Requrest` 加 `Code Review`消息通知机制,整个的Merge Request机制如下: 18 | 19 | 1. 用户发起`Merge Request`,同时在`Merge Request`中的**description**去@对应用户,被@的用户会做为`Code Reviewer`来为你的代码进行`Code Review`,会在企业微信群中对被@的用户发起通知,提醒前去进行`Code Review`。通知的消息为在MR中`title`项填写的内容。 20 | 2. 当`Code Reviewer`进行`Code Review`后认为没有代码质量问题,则在`Merge Request`的评论中回复 **1** 或者 **done**,当所有`Code Reviewer`都评论过后,则认为代码质量审核通过。 21 | 3. 当`Code Review`通过后,随即发起`Merge Request`提醒合并通知,通知人为该仓库的 `author` 以及在发起`Merge Request`时 `assignee`指定的用户 22 | 4. `Merge Request`合并完成后,发起该`Merge Request`已被合并的通知,并通知发起人。 23 | 24 | ![MR消息提醒](examples/MR-notice.png) 25 | 26 | 27 | 目前支持以下gitlab事件的webhook 28 | 29 | ## gitlab webhook 30 | 31 | ### 配置gitlab webhook 32 | 33 | 接口路径: **/gitlab_hook**, 即项目部署的地址加上此路径,即为gitlab webhook Url. 34 | 35 | Secret Token: **gitlab-webhook**, 给项目中添加webhook时,需要填写的选项,增强接口安全性, 默认为`gitlab-webhook`, 可在`src/appConfig.ts`中修改 36 | 37 | ![Secret Token](examples/secret_token.png) 38 | 39 | ### merge_request 40 | 41 | 提供对gitlab webhook中**merge_request**事件支持,目前支持以下类型提醒: 42 | 43 | - 新开`Merge Request`时,提醒仓库作者和`assignee`指定的用户 44 | 45 | 当发起MR时没有在description中指定Code Reivew人员时,通知如下 46 | 47 | ![发起MR](examples/with-assignee.png) 48 | 49 | 当指定了Code Review人员时, 通知如下 50 | 51 | ![发起MR通知](examples/with-codereviewer.png) 52 | 53 | - `Merge Request`发生更新时(除Title和Label更新)提醒 54 | 55 | ![更新MR](examples/with-codereview-update.png) 56 | 57 | - `Merge Request`被合并时通知仓库作者和`assignee`指定的用户 58 | 59 | ![MR合并完成](examples/with-merged.png) 60 | 61 | ### note 62 | 63 | 提供对`Merge Request`中发生评论时的事件提醒功能,目前支持以下类型提醒 64 | 65 | - 当`Merge Request`中发生评论时 66 | 67 | ![代码评论事件](examples/with-code-comment.png) 68 | 69 | - 当`Merge Request`中对代码进行评论时 70 | 71 | 当在Merge Request中评论1或者done且评论人为Code Review人员时,则认为代码审查通过 72 | 73 | ![CR完成时](examples/with-mr-complete.png) 74 | 75 | ### pipeline 76 | 77 | 支持pipeline事件的提醒。当pipeline运行成功后提醒。 78 | 79 | ### 定时任务 80 | 81 | 制订了一个定时任务,用于非周末的每一天查询所有项目是否还有未处理的`Merge Request`,将向对应项目的机器人发送一条待办事项的通知 82 | 83 | ## gitlab api 84 | 85 | ### api url 86 | 87 | 本项目中使用到了gitlab的 [REST API](https://docs.gitlab.com/ee/api/),请根据您实际使用的gitlab 服务确定api地址。 88 | 配置路径: `src/appConfig.ts` 89 | 90 | ### access_token 91 | 92 | 本项目中使用到了gitlab api, 访问gitlab api需要使用[access_token](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html), 93 | token 配置在 `src/appConfig.ts`中,请在此替换您的token。 94 | 95 | ## 企业微信机器人 96 | 97 | 上述**gitlab webhook**提醒功能依赖于企业微信机器人,由机器人负责发送消息到企业微信群,每个机器人具有唯一的`key`,请保管好群机器人`key`值,防止泄漏 98 | 99 | [官方配置说明](https://work.weixin.qq.com/api/doc/90000/90136/91770?st=90C13105071F4D7927E5A268695641600FC9DD01795F92F5F62FA2A0DDD9C19755F98AD4C0497A68AC1D16650327A55793C300E704AB48ECA20AEF2DA0DA6B91506E059F23D0DA0E353AE3273E41F17B785B60E4D2B98F848D69F4C9D0FB6404D9E0C9580212E3A2888501AF4B94FFA52A16CF370B7420884D8F33AD9CB08CE168E72157B5FA834A2BF607B397757D5A7523098814E3F0E51DE2F25E1A828FA64F821F7B31954898737A0598E029D09D02747621D5338AB2EB6B6B90D8FCF857&vid=1688851055484810&cst=E044957B8A4BC7889EC7DEF5E4A175843D84DD7D35D0FEA2882E76C8030223CD1706148B503A6803A14356CA27219AF5&deviceid=ad7d135c-a593-4a65-b78b-ed9d8a108a66&version=3.1.8.3108&platform=win) 100 | 101 | ### 机器人key的配置 102 | 103 | 在代码中`src/config/projectKeys`文件中,维护了一份群机器人key与gitlab项目信息的映射关系,参考以下配置进行修改 104 | 105 | ```js 106 | module.exports =module.exports = { 107 | // gitlab projectName 108 | "project name": { 109 | id: "", // gitlab project id 110 | key: "" // 企业微信机器人key 111 | } 112 | ``` 113 | ![gitlab-project](./examples/gitlab-project.png) 114 | 115 | ### 如何在发送消息时@企业微信用户 116 | 117 | 由于通过接口发送消息时,无法直接通过@用户名去@对应用户,想要@用户必须使用用户id。 用户ID根据每个公司的设置规则而有所不同,需要找到公司里管理企业微信后台的管理员索要用户ID设置规则。 118 | 119 | 在代码中会对gitlab事件相关的用户进行查询,以得到其中文名,然后根据用户ID规则进行一个转换以得到真实用户ID,这样才能正确的@用户。 120 | 121 | 相关代码在`src/utils/index.ts`中的 `usernameToUserid`方法,请根据不同的ID规则修改 122 | 123 | ## 关于代码评论与MR评论 124 | 125 | 这两个评论事件在 GitLab 中都是 Note 事件,通过区分事件中的属性从而判断是代码评论还是MR事件本身的评论 126 | 127 | 这是 MR 评论 128 | 129 | ![MR 评论](examples/mr-note.png) 130 | 131 | 这是代码评论 132 | 133 | ![代码评论](examples/code-comment.png) 134 | 135 | 之所以选择使用MR评论作为 **Code Review**完成的标志,因为我的项目中使用的是gitlab 12版本,本来最开始是选用gitlab 的 approval 作为 Merge Request 的代码审批规则的,但是需要13.2版本以后才开始支持,所以只得放弃。由于采用评论作为Code Review通过与否的判断,所以需要团队间达成一个行为约定。 136 | 137 | ## 如何打包和部署 138 | 139 | ### 首次部署 140 | 141 | 1. 准备一台具有node环境的服务器 142 | 2. 在本项目中使用`yarn build` 或 `npm run build`命令打包得到 out 文件夹。 143 | 3. 将 out 文件夹下的所有文件拷贝到服务器指定的目录,然后切换到目录下,使用 `yarn` 或 `npm i`安装依赖 144 | 4. 在目录下执行命令 `pm2 start pm2.json`即可成功启动服务(默认监听端口为16080,可在pm2.json中修改) 145 | 146 | ### 版本更新 147 | 148 | 1. 进入部署服务的服务器,切换到项目所在的目录 149 | 2. 将本次打包的代码拷贝进来,覆盖老版本代码 150 | 3. 执行命令`pm2 restart webhook-server`。(webhook-server是pm2启动的项目的名字,可在pm2.json中修改) -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "src/**/*" 4 | ], 5 | "exclude": [ 6 | "node_modules" 7 | ], 8 | "compilerOptions": { 9 | /* Basic Options */ 10 | "target": "es2015", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ 11 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 12 | "lib": [ 13 | "ESNext" 14 | ], /* Specify library files to be included in the compilation. */ 15 | // "allowJs": true, /* Allow javascript files to be compiled. */ 16 | // "checkJs": true, /* Report errors in .js files. */ 17 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 18 | "declaration": false, /* Generates corresponding '.d.ts' file. */ 19 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 20 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 21 | // "outFile": "./", /* Concatenate and emit output to single file. */ 22 | "outDir": "out", /* Redirect output structure to the directory. */ 23 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 24 | // "composite": true, /* Enable project compilation */ 25 | "removeComments": true, /* Do not emit comments to output. */ 26 | // "noEmit": true, /* Do not emit outputs. */ 27 | "importHelpers": true, /* Import emit helpers from 'tslib'. */ 28 | "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 29 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 30 | /* Strict Type-Checking Options */ 31 | "strict": true, /* Enable all strict type-checking options. */ 32 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 33 | "strictNullChecks": true, /* Enable strict null checks. */ 34 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 35 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 36 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 37 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 38 | /* Additional Checks */ 39 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 40 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 41 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 42 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 43 | /* Module Resolution Options */ 44 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | "paths": { 47 | "tslib": ["node_modules/tslib/tslib.d.ts"], 48 | }, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 49 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 50 | // "typeRoots": [], /* List of folders to include type definitions from. */ 51 | // "types": [], /* Type declaration files to be included in compilation. */ 52 | "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 53 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 54 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 55 | /* Source Map Options */ 56 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | /* Experimental Options */ 61 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 62 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 63 | /* Advanced Options */ 64 | // "declarationDir": "lib" /* Output directory for generated declaration files. */ 65 | } 66 | } -------------------------------------------------------------------------------- /src/api/gitlabHook/event/comment/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: TanHui 3 | * @Date: 2021-04-09 11:30:46 4 | * @LastEditors: TanHui 5 | * @LastEditTime: 2021-07-27 13:30:34 6 | * @Description: 7 | */ 8 | /** 9 | * 处理 comment 事件 10 | */ 11 | 12 | import md from "../../../../utils/markdown"; 13 | 14 | import { queryUserInfo, queryMrComments } from "../../../../config/gitlabApi"; 15 | 16 | import { getCrList, usernameToUserid } from "../../../../utils"; 17 | 18 | type BranchInfo = { 19 | name: string; 20 | description: string; 21 | web_url: string; 22 | git_ssh_url: string; 23 | git_http_url: string; 24 | namespace: string; 25 | visibility_level: number; 26 | path_with_namespace: string; 27 | default_branch: string; 28 | homepage: string; 29 | url: string; 30 | ssh_url: string; 31 | http_url: string; 32 | }; 33 | 34 | export interface NoteEvent { 35 | object_kind: "note"; 36 | user: { 37 | id: number; 38 | name: string; 39 | username: string; 40 | avatar_url: string; 41 | email: string; 42 | }; 43 | project: { 44 | id: number; 45 | name: string; 46 | description: string; 47 | web_url: string; 48 | namespace: string; 49 | path_with_namespace: string; 50 | default_branch: string; 51 | homepage: string; 52 | }; 53 | object_attributes: { 54 | id: number; 55 | note: string; 56 | noteable_type: string; 57 | author_id: number; 58 | created_at: string; 59 | updated_at: string; 60 | position: number; 61 | project_id: number; 62 | commit_id: string; 63 | noteable_id: number; 64 | system: boolean; 65 | url: string; 66 | }; 67 | merge_request: { 68 | id: number; 69 | target_branch: string; 70 | source_branch: string; 71 | source_project_id: number; 72 | author_id: number; 73 | assignee_id: number; 74 | title: string; 75 | created_at: string; 76 | updated_at: string; 77 | milestone_id: number; 78 | state: string; 79 | merge_status: string; 80 | target_project_id: number; 81 | iid: number; 82 | description: string; 83 | position: number; 84 | source: BranchInfo; 85 | target: BranchInfo; 86 | url: string; 87 | last_commit: { 88 | id: string; 89 | message: string; 90 | timestamp: string; 91 | url: string; 92 | author: { 93 | name: string; 94 | email: string; 95 | }; 96 | }; 97 | assignee: { 98 | name: string; 99 | username: string; 100 | avatar_url: string; 101 | }; 102 | }; 103 | } 104 | 105 | export default async function ( 106 | data: NoteEvent 107 | ): Promise<{ description: string; markdown?: string }> { 108 | const { 109 | object_attributes: objectAttributes = {} as NoteEvent["object_attributes"], // MR 基础信息 110 | user, // 本次事件发起人 111 | project, // MR 项目信息 112 | merge_request = {} as NoteEvent["merge_request"] // mr 信息 113 | } = data; 114 | const { position, note, url, noteable_type } = objectAttributes; 115 | // 暂时只支持 MergeRequest 相关 116 | if (noteable_type !== "MergeRequest") 117 | return { 118 | description: `本次 type 为 ${noteable_type} 暂时只支持 MergeRequest 相关 comment` 119 | }; 120 | // 是否代码评论 121 | // 存在 position 字段即为代码评论 122 | const isCommentCode = !!position; 123 | const { 124 | author_id, 125 | assignee_id, 126 | description, 127 | iid, 128 | url: mrUrl 129 | } = merge_request; 130 | const { name, id: projectId } = project; 131 | const codeReviewer = getCrList(description); 132 | // 不存在 codeReviewer, 则不进行通知 133 | if (!codeReviewer.length) { 134 | return { 135 | description: `本次评论「${note}」,但不存在 code reviewer 暂时不进行通知` 136 | }; 137 | } 138 | // MR的发起人信息 139 | const authorInfo = await queryUserInfo(author_id); 140 | const author = usernameToUserid(authorInfo.name); 141 | 142 | // 本此NOTE事件发起人 143 | const curUser = usernameToUserid(user.name); 144 | 145 | if (isCommentCode) { 146 | const markdown = 147 | md(author).mark().newLine() + 148 | "您的 merge request 有新的评论:" + 149 | md(note).warning().newLine() + 150 | md(`项目:${name}`).quote().newLine() + 151 | md("评论人:").join(md(curUser).mark().toString()).quote().newLine() + 152 | md(" 查看").link(url); 153 | return { 154 | description: "已发起MergeRequest新评论通知", 155 | markdown 156 | }; 157 | } 158 | // 只需要 包含 done 或者 1 的 comment 159 | const reg = /^(done|1)$/; 160 | if (!reg.test(note)) 161 | return { 162 | description: `本次评论「${note}」,但不是 code review 暂时不进行通知` 163 | }; 164 | // 普通评论视为赞同 165 | const commentList = await queryMrComments(projectId, iid); 166 | 167 | // 过滤 comment 168 | const codeReviewerMap = commentList 169 | .filter(item => reg.test(item.body.trim())) 170 | .reduce((userCommentMap, comment) => { 171 | const { 172 | author: { username } 173 | } = comment; 174 | userCommentMap[username] = true; 175 | return userCommentMap; 176 | }, {} as { [k: string]: boolean }); 177 | 178 | // 如果还有 code reviewer 未赞同,则不进行通知 179 | if (!codeReviewer.every(reviewer => codeReviewerMap[reviewer])) { 180 | return { 181 | description: `本次评论「${note}」,但是还未达到人数要求,不需要通知` 182 | }; 183 | } 184 | let assignee; 185 | if (assignee_id) { 186 | const assigneeInfo = await queryUserInfo(assignee_id); 187 | assignee = usernameToUserid(assigneeInfo.name); 188 | } 189 | const mentionUsers = assignee ? [...new Set([author, assignee])] : [author]; 190 | const markdown = 191 | mentionUsers.map(user => md(user).mark()).join(" ") + 192 | md().newLine() + 193 | "该 Merge Request 已经通过 Code Review: " + 194 | md(merge_request.title).info().newLine() + 195 | md(` 项目:${name}`).quote().newLine() + 196 | md(" 查看").link(mrUrl).quote(); 197 | return { 198 | description: "已发起Merge Request通过 code review 的通知", 199 | markdown 200 | }; 201 | } 202 | -------------------------------------------------------------------------------- /src/api/gitlabHook/event/mergeRequest/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: TanHui 3 | * @Date: 2021-04-09 11:30:41 4 | * @LastEditors: TanHui 5 | * @LastEditTime: 2021-07-27 13:31:15 6 | * @Description: 7 | */ 8 | import { EVENT_ACTION, EVENT_TYPE } from "../../../../config/gitlabEvent"; 9 | import { 10 | queryUserInfo, 11 | queryUserInfoByUsername 12 | } from "../../../../config/gitlabApi"; 13 | import { getCrList, usernameToUserid } from "../../../../utils"; 14 | import md from "../../../../utils/markdown"; 15 | 16 | interface Label { 17 | id: number; 18 | title: string; 19 | color: string; 20 | project_id: number; 21 | created_at: string; 22 | updated_at: string; 23 | template: boolean; 24 | description: string; 25 | type: string; 26 | group_id: number; 27 | } 28 | 29 | export interface MergeRequestEvent { 30 | object_kind: "merge_request"; 31 | user: { 32 | id: number; 33 | name: string; 34 | username: string; 35 | avatar_url: string; 36 | email: string; 37 | }; 38 | project: { 39 | id: number; 40 | name: string; 41 | description: string; 42 | web_url: string; 43 | namespace: string; 44 | path_with_namespace: string; 45 | default_branch: string; 46 | homepage: string; 47 | url: string; 48 | ssh_url: string; 49 | http_url: string; 50 | }; 51 | repository: { 52 | name: string; 53 | url: string; 54 | description: string; 55 | homepage: string; 56 | }; 57 | object_attributes: { 58 | id: number; 59 | target_branch: string; 60 | source_branch: string; 61 | source_project_id: number; 62 | author_id: number; 63 | assignee_id: number; 64 | title: string; 65 | created_at: string; 66 | updated_at: string; 67 | milestone_id: null; 68 | state: string; 69 | merge_status: string; 70 | target_project_id: number; 71 | iid: number; 72 | description: string; 73 | source: { 74 | name: string; 75 | description: string; 76 | path_with_namespace: string; 77 | default_branch: "master"; 78 | }; 79 | target: { 80 | name: string; 81 | description: string; 82 | path_with_namespace: string; 83 | default_branch: string; 84 | }; 85 | last_commit: { 86 | id: string; 87 | title: string; 88 | message: string; 89 | timestamp: string; 90 | url: string; 91 | author: { 92 | name: string; 93 | email: string; 94 | }; 95 | }; 96 | url: string; 97 | action: string; 98 | assignee: { 99 | name: string; 100 | username: string; 101 | avatar_url: string; 102 | }; 103 | }; 104 | changes: { 105 | updated_at: { 106 | previous: string; 107 | current: string; 108 | }; 109 | labels: { 110 | previous: Label[]; 111 | current: Label[]; 112 | }; 113 | }; 114 | } 115 | async function crNameToUserid(crList: string[]) { 116 | const promises = crList.map(username => { 117 | return queryUserInfoByUsername(username); 118 | }); 119 | const users = await Promise.all(promises); 120 | return users.map(user => usernameToUserid(user[0].name)); 121 | } 122 | 123 | function mentionCodeReview( 124 | crList: string[], 125 | title: string, 126 | projectName: string, 127 | user: MergeRequestEvent["user"], 128 | url: string 129 | ) { 130 | const userId = usernameToUserid(user.name); 131 | const content = 132 | crList.map(item => md(item).mark()).join(" ") + 133 | md().newLine() + 134 | "您有新的 Code Review 订单:" + 135 | md(title).info().newLine() + 136 | md(` 项目:${projectName}`).quote().newLine() + 137 | md(` 发起人: ${md(userId).mark()}`) 138 | .quote() 139 | .newLine() + 140 | md(" 开始 Code Review").link(url).quote(); 141 | 142 | return content; 143 | } 144 | 145 | async function mentionCodeReviewUpdate( 146 | crList: string[], 147 | title: string, 148 | projectName: string, 149 | author_id: number, 150 | url: string, 151 | last_commit: MergeRequestEvent["object_attributes"]["last_commit"] 152 | ) { 153 | const user = await queryUserInfo(author_id); 154 | const { title: commitTitle, url: commitUrl } = last_commit || {}; 155 | const content = 156 | crList.map(item => md(item).mark()).join(" ") + 157 | md().newLine() + 158 | "您的 Code Review 订单有更新:" + 159 | md(title).info().newLine() + 160 | md(" 最新 commit: " + md(commitTitle).link(commitUrl)) 161 | .quote() 162 | .newLine() + 163 | md(` 项目: ${projectName}`).quote().newLine() + 164 | md(` 发起人:${md(usernameToUserid(user.name)).mark()}`) 165 | .quote() 166 | .newLine() + 167 | md(` 开始 Code Review`).link(url).quote(); 168 | return content; 169 | } 170 | 171 | /** 172 | * 发生MR变化时的提醒 173 | * @param {string} title 本次消息的标题 174 | * @param {object} project 项目信息 175 | * @param {string} description 本次提醒的描述信息 176 | * @param {string} url 对应消息可访达的url地址 177 | * @param {object} author 作者信息 178 | * @param {string[]} mentionList 需要提醒的用户 179 | * @param {number[]} mentionIdList 需要提醒的用户的gitlabId 180 | * @returns markdown 181 | */ 182 | async function mentionAssignee( 183 | title: string, 184 | project: MergeRequestEvent["project"], 185 | description: string, 186 | url: string, 187 | author: { type: string; name?: string; id: number }, 188 | mentionList: string[], 189 | mentionIdList: number[] 190 | ) { 191 | if (!author.name) { 192 | const authorDetails = await queryUserInfo(author.id); 193 | author.name = authorDetails.name; 194 | } 195 | const mentionUsers = mentionList ? [...mentionList] : []; 196 | mentionIdList = [...new Set(mentionIdList)]; 197 | for (const id of mentionIdList) { 198 | if (id) { 199 | const userInfo = await queryUserInfo(id); 200 | mentionUsers.push(usernameToUserid(userInfo.name)); 201 | } 202 | } 203 | 204 | const content = 205 | "" + 206 | md(mentionUsers.map(user => md(user).mark()).join(" ")) 207 | .newLine() 208 | .join(`${title}: `) + 209 | md(description).info().newLine() + 210 | md(` 项目:${project.name}`).quote().newLine() + 211 | md(` ${author.type}:${author.name}`).newLine() + 212 | md("查看").link(url).quote(); 213 | return content; 214 | } 215 | 216 | async function mergeRequestHook( 217 | data: MergeRequestEvent 218 | ): Promise<{ markdown?: string; description: string }> { 219 | const { 220 | project = {} as MergeRequestEvent["project"], 221 | object_attributes = {} as MergeRequestEvent["object_attributes"], 222 | user, 223 | changes = {} as MergeRequestEvent["changes"] // 信息变更 224 | } = data; 225 | const { 226 | description, 227 | title, 228 | url, 229 | action, 230 | state, 231 | last_commit = {} as MergeRequestEvent["object_attributes"]["last_commit"], 232 | assignee_id, 233 | author_id 234 | } = object_attributes; 235 | // 获取 code review 人员列表 236 | const crList = [...new Set([...getCrList(description)])]; 237 | const MERGE_REQUEST_ACTIONS = EVENT_ACTION[EVENT_TYPE.MERGE_REQUEST]; 238 | 239 | if (state === "closed") { 240 | return { description: "该Merge Request已被关闭,忽略此条信息" }; 241 | } 242 | // eslint-disable-next-line no-prototype-builtins 243 | if (!MERGE_REQUEST_ACTIONS.hasOwnProperty(action)) { 244 | throw new Error(`无法识别的action类型:${action}`); 245 | } 246 | 247 | if ( 248 | action === MERGE_REQUEST_ACTIONS.open || 249 | action === MERGE_REQUEST_ACTIONS.reopen 250 | ) { 251 | // 如果没有@ cr,则直接提醒合并 252 | if (!crList.length) { 253 | const markdown = await mentionAssignee( 254 | "您有一个新的Merge Request待处理", 255 | project, 256 | title, 257 | url, 258 | { type: "发起人", id: author_id }, 259 | [], 260 | [assignee_id] 261 | ); 262 | return { 263 | markdown, 264 | description: "没有 Code Reviewer,不进行CodeReview通知" 265 | }; 266 | } 267 | const crUseridList = await crNameToUserid(crList); 268 | const markdown = mentionCodeReview( 269 | crUseridList, 270 | title, 271 | project.name, 272 | user, 273 | url 274 | ); 275 | return { markdown, description: "已发起Code Review通知" }; 276 | } 277 | // 该MR已被合并 278 | if (action === MERGE_REQUEST_ACTIONS.merge) { 279 | const markdown = await mentionAssignee( 280 | "您有一个Merge Request被成功合并", 281 | project, 282 | title, 283 | url, 284 | { type: "操作者", name: user.name, id: user.id }, 285 | [], 286 | [author_id] 287 | ); 288 | return { markdown, description: "已发起MR merged通知" }; 289 | } 290 | 291 | if (action === MERGE_REQUEST_ACTIONS.update) { 292 | // 更新 label, title 则不进行提示 293 | const { labels } = changes; 294 | if (labels) { 295 | return { description: `更新 label 或 title` }; 296 | } 297 | let markdown; 298 | if (crList.length > 0) { 299 | const crUseridList = await crNameToUserid(crList); 300 | markdown = await mentionCodeReviewUpdate( 301 | crUseridList, 302 | title, 303 | project.name, 304 | author_id, 305 | url, 306 | last_commit 307 | ); 308 | } else { 309 | markdown = await mentionAssignee( 310 | "您有一个待处理的Merge Request有更新", 311 | project, 312 | title, 313 | url, 314 | { type: "发起人", name: user.name, id: user.id }, 315 | [], 316 | [assignee_id] 317 | ); 318 | } 319 | return { markdown, description: "已发起MR Update 通知" }; 320 | } 321 | return { description: `action:${action},暂无对应处理程序` }; 322 | } 323 | 324 | export default mergeRequestHook; 325 | --------------------------------------------------------------------------------