PLAY DEVELOPERS BLOG

HuluやTVerなどの日本最大級の動画配信を支える株式会社PLAYが運営するテックブログです。

HuluやTVerなどの日本最大級の動画配信を支える株式会社PLAYが運営するテックブログです。

GoogleAppsScript を使った GitLab→Slack の通知

こんにちは、OTT事業部の三田です。

私の事業部ではソースコードの管理にGitLabを利用しています。
GitLabを利用する中で、マージリクエストを用いたコードレビューを行っていますが、自分がメインでレビューを行っていた際にレビュー依頼が出ていることに気づかなかったり、都度Slackでレビュー依頼を出したりする小さな手間があったので、そこを自動化したお話です。

全体構成

全体の構成としては以下のイメージとなります。

GitLab標準でSlackに通知する機能はありますが、メンションをつけたりすることができなかったので、Google Apps Script(以下、GAS)を間に挟むことで自由度を上げています。
GASを選んだ理由としては、導入の敷居が低く、今回利用している範囲内であれば無料で扱えるところになります。
(AWS Lambdaでも同じことが無料でできそうですけどね!)

1. GAS

プログラム作成

GitLabからの通知の受口となるプログラムを作っていきます。

GASの画面にアクセスして、「新しいプロジェクト」からプロジェクトを作成後、コーディングしていきます。

たくさん通知が来ても困るので、マージリクエストとタグ付けのみを通知していきます。
GitLabから受け取れるイベントや、パラメータについては以下の公式ドキュメントを参考にしてください。

docs.gitlab.com

const COLOR_MERGE_OPENED   = '#156984';
const COLOR_MERGE_REOPENED = '#156984';
const COLOR_MERGE_CLOSED   = '#e3793e';
const COLOR_MERGE_MERGED   = '#9abe86';
const COLOR_MERGE_TEST     = '#505050';
const COLOR_TAG_CREATE     = '#f2c94c';
const COLOR_TAG_DELETE     = '#eb4730';

/** Slackのユーザー名とユーザーIDのマッピングテーブル */
const SLACK_ID_LIST = [
  { name: '[Gitアカウント]', name_kan: '[GitLabアカウント名]', id: '[SlackアカウントID]' },
];

function doPost(e) {
  console.log(e.postData.getDataAsString());
  
  try {
    addLog(e.postData.contents);
    const json        = JSON.parse(e.postData.contents);
    const project     = json.project;
    
    if (project && project.web_url.match(/* GitLabのドメイン名 */)) {
      switch (json.object_kind) {
        case 'tag_push':
          var message = tagEvent(json);
          break;
        case 'merge_request':
          var message = mergeEvent(json);
          break;
        default:
          throw new Error("エラーが発生しました。");
          break;
      }
    } else {
      return;
    }
    if (Object.keys(message).length) {
      _slackPost(message);
    }
  } catch(ex) {
    _slackPost(ex);
    addLog(ex);
  }
}

function tagEvent(json) {
  const user         = json.user_name;
  const tag_name     = json.ref.split('/')[2];
  const project      = json.project;
  const tag_url      = project.web_url + '/tags/' + tag_name;
  const project_name = project.path_with_namespace.split('/')[0];
  
  var message = getMessageFormat(json.object_kind);
  
  if (json.checkout_sha) {
    message.pretext = Utilities.formatString(message.pretext, user + 'さんがタグを切りました。');
    message.text    = Utilities.formatString(message.text, project.web_url, project_name, 'Create', tag_url, tag_name, json.checkout_sha);
    message.color   = COLOR_TAG_CREATE;
  } else {
    message.pretext = Utilities.formatString(message.pretext, user + 'さんがタグを削除しました。');
    message.text    = Utilities.formatString(message.text, project.web_url, project_name, 'Delete', tag_url, tag_name, json.before);
    message.color   = COLOR_TAG_DELETE;
  }
  addLog(message);
  return message;
}

function mergeEvent(json) {
  console.log(json);
  const user              = json.user.name;
  const title             = json.object_attributes.title;
  const title_link        = json.object_attributes.url;
  const action            = json.object_attributes.action;
  const target_branch     = json.object_attributes.target_branch;
  const source_branch     = json.object_attributes.source_branch;
  const description       = json.object_attributes.description;
  const responsible       = ('assignees' in json && 'username' in json.assignees[0]) ? conversion(json.assignees[0].username, json.assignees[0].name) : '';
  const project           = json.project;
  const project_name      = project.path_with_namespace;
  const reviewer          = '!here'; // GitLabのバージョンが上がってレビュアー情報が取得できなくなりました。。

  var message  = getMessageFormat(json.object_kind);
  message.text = Utilities.formatString(message.text, project.web_url, project_name, title_link, title, description, source_branch, target_branch);

  switch (action) {
    case 'open':
      message.pretext = Utilities.formatString(message.pretext, reviewer, user + 'さんからマージリクエストがありました。');
      message.color   = COLOR_MERGE_OPENED;
      break;
    case 'reopen':
      message.pretext = Utilities.formatString(message.pretext, reviewer, user + 'さんからマージリクエストが再オープンされました。');
      message.color   = COLOR_MERGE_REOPENED;
      break;
    case 'merge':
      message.pretext = Utilities.formatString(message.pretext, responsible, 'マージリクエストがマージされました。');
      message.color   = COLOR_MERGE_MERGED;
      break;
    case 'close':
      message.pretext = Utilities.formatString(message.pretext, responsible, 'マージリクエストがクローズされました。');
      message.color   = COLOR_MERGE_CLOSED;
      break;
    case 'approved':
    case 'unapproved':
    case 'approval':
    case 'unapproval':
    case 'update':
      message = new Object;
      break;
    default:
      message.pretext = Utilities.formatString(message.pretext, reviewer, 'マージリクエスト通知テスト。');
      message.color   = COLOR_MERGE_TEST;
      break;
  }

  return message;
}

function getMessageFormat(object_kind) {
  if ('tag_push' == object_kind) {
    var values = [];
    var pretext = '';

    pretext = '<!here>';
    pretext = pretext + '\n%s\n';
    return {
      'pretext' : pretext,
      'text'    : 'プロジェクト: <%s|%s>\n%s <%s|%s>\n%s',
      'color'   : ''
    };
  } else if ('merge_request' == object_kind) {
    return {
      'pretext' : '<%s>\n%s\n',
      'text'    : 'プロジェクト: <%s|%s>\n<%s|%s>\n%s\nFrom %s -> To %s',
      'color'   : ''
    };
  } else {
    return {};
  }
}

/**
 * Slackへの投稿用メソッド
 * Slack の Incoming Webhook URL をスクリプトプロパティに設定
 * @param message 投稿内容
 */
function _slackPost(message) {
  var properties = PropertiesService.getScriptProperties();
  var url     = properties.getProperty("slackWebhookUrl");
  var payload = {
    'attachments': [
      {
        'fallback'  : 'Notice from GitLab.',
        'color'     : message.color,
        'pretext'   : message.pretext,
        'text'      : message.text,
      }
    ]
  };
  var params = {
    'method' : 'post',
    'Content-type': 'application/json',
    'payload' : JSON.stringify(payload)
  };
  var response = UrlFetchApp.fetch(url, params);
  addLog(response);
}

/**
 * ログ出力用メソッド
 * Google Spreadsheet の spreadsheetId および sheetName をスクリプトプロパティに設定
 * @param text ログ内容
 */
function addLog(text) {
  var properties = PropertiesService.getScriptProperties();
  var spreadsheetId = properties.getProperty("spreadsheetId");
  var sheetName = properties.getProperty("sheetName");
  var spreadsheet = SpreadsheetApp.openById(spreadsheetId);
  var sheet = spreadsheet.getSheetByName(sheetName);
  sheet.appendRow([new Date()/*タイムスタンプ*/,text]);
  return text;
}

/**
 * Slack Incoming Webhookの仕様変更のため、ID変換が必要
 * https://api.slack.com/changelog/2017-09-the-one-about-usernames
 * @param name
 */
function conversion(name, kan) {
  addLog('findIndex: ' + SLACK_ID_LIST.findIndex(s => s.name === name));
  var slackId, isError = false;
  try {
    if (0 < SLACK_ID_LIST.findIndex(s => s.name === name)) {
      slackId = SLACK_ID_LIST.find(s => s.name === name).id;
    } else {
      slackId = SLACK_ID_LIST.find(s => s.name_kan === kan).id;
    }
  } catch (e) {
    addLog('ERROR: ' + e.message);
    isError = true;
  } 
  return isError ? '!here' : '@' + slackId;
}

以下は各関数毎の説明になります。

doPost

GASでHTTP POSTを受ける際にはこの命名で関数を作成します。
この関数から後続の処理を呼び出していきます。
不正に利用されるのを避けるためにリクエストパラメータからGitLabプロジェクトのURLを拾ってチェックしています。
このあたりはトークンも投げられるので、好きにカスタマイズしたら良いです。

tagEvent

タグ付けされた際に呼び出される関数になります。
リリースの時に「タグ切ってなかった」とかそういう凡ミスをなくすために通知します。

mergeEvent

マージリクエストの際に呼び出される関数になります。
以前はマージリクエストの際にレビュアーに設定したユーザの情報も取得できたのですが、現在は取得ができないため@hereで通知するようにしています。
私のチームでは、以下の流れでマージまで行っています。

  1. コーディング後、マージリクエスト作成(オープン)
  2. レビュアーは指摘事項がなければ、承認を行いマージする
  3. レビュアーは指摘事項があれば、マージリクエストをクローズする
  4. レビューイは指摘事項を確認し、修正後にマージリクエストを再オープンする

そのため、通知する内容は4つに分類しています。

  • マージリクエストのオープン
  • マージリクエストのクローズ
  • マージリクエストのマージ
  • マージリクエストの再オープン

getMessageFormat

Slack通知用のメッセージフォーマットを設定しています。
Slack通知から対象のマージリクエストの情報がわかるようにすることと、リンクできるように設定しています。

_slackPost

Slack通知、特に書くことないですね。

addLog

Google Spreadsheetにログを出力しています。

conversion

Slack通知時にメンションをつけたいため、Authorに設定されているメールアドレスのユーザ名から取得し、SlackアカウントIDに変換します。
(なんとかメンションつけるために苦肉の策です…。)
たまにメールアドレスがおかしい人がいるので、その場合にはGitLabのアカウント名でヒットさせるようにもしています。
それでもヒットしない輩な人独自設定の人の場合には、とにかく@hereで誰かしら気づくようにします。。

プロジェクト設定

GASではプロジェクト毎にプロパティの設定ができるので、プロジェクト毎に異なるような設定値はこちらに設定していきます。

デプロイ

コーディングが終わって、プロジェクトの設定も完了したらデプロイを行います。

デプロイの種類は「ウェブアプリ」を選択します。

GitLabからのWebhookで実行しますので、アクセスできるユーザを「全員」に設定します。
お好みで素敵な説明文を入れてください。

デプロイが完了するとアプリケーションのURLが発行されるので、こちらをGitLabに設定していきます。
以上でGASでの作業は完了です。

なお、更新時に発行されたURLを変更したくない場合には、「デプロイを管理」から該当のバージョンを編集する必要があります。
「新しいデプロイ」とした場合には、発行されるURLが変わってしまいますので、注意ください。

2. GitLab

プロジェクト設定

通知したいGitLabプロジェクトの設定から、webhookを設定していきます。

今回はタグ付けとマージリクエストのみを対象とするため、「Tag push events」と「Merge request events」を設定します。

通知テスト

設定が完了したら、「Test」からマージリクエストイベントのテスト通知をしてみます。
Slackチャンネルに通知がきたらOKです!

さいごに

これでマージリクエストの作成、レビュー結果の通知をSlack上で受け取れるようになりました。
Slack上に通知が行われるようになり、レビュー開始までの速度の上昇や、修正があった場合のアクションが取りやすくなると思います。
(以下の通知では、今回作成の通知以外にもGitLabデフォルトの通知も行っています)

レビュー依頼の手間が少しだけ軽減され、対応漏れが少なくなり、プロジェクトの進行もスムーズに!
良いことづくめですね。

それでは良いレビューライフを!