GitHub のメンションを Slack へ通知する

これは [ゆめみ Advent Calendar 2018](https://qiita.com/advent-calendar/2018/yumemi) の２日目の投稿です。

コミュニケーションツールとして Slack、コードを GitHub 上で開発している場合、開発の状況や GitHub 上のアクションに応じていい感じに Slack へ通知を流したい、と思うことがありませんでしょうか。私はあります。

一応、[GitHub 公式のインテグレーション](https://github.com/marketplace/slack-github) もあるのですが、これに物足りない場合は自前で仕組みを構築することになります。今回は試しに、**GitHub の Pull request または Issue で、 @ でメンションされたら Slack に通知する** という仕組みを構築してみたので、その構築の手順を紹介をしたいと思います。

# 動作イメージ

<img width="400" alt="スクリーンショット 2018-11-30 16.21.40.png" src="https://qiita-image-store.s3.amazonaws.com/0/35733/2ac823ca-9edd-8148-9c45-f4c5fa9bf089.png">
<img width="400" alt="スクリーンショット_2018-11-30_16_25_54.png" src="https://qiita-image-store.s3.amazonaws.com/0/35733/d5fa60b5-b8ff-4a58-2544-b637d8956860.png">

# 全体の構成

独自のコードをどこかに自前でホスティングする必要がありますが、それは AWS Lmabda に置きます。GitHub の Webhooks で GitHub 上のイベントを HTTP(S) で API Gataway -> Lambda へ送り、イベントを Lambda で処理後、Slack API を利用して Slack へ通知を送る構成です。

図にすると次のとおりです。

<img width="600" alt="スクリーンショット 2018-11-30 13.41.36.png" src="https://qiita-image-store.s3.amazonaws.com/0/35733/a659ae55-ec5a-a161-6739-22a7511012d0.png">

# 環境の構築

## Slack

まず Slack API を利用できるようにします。https://api.slack.com/ にアクセスしてアプリケーションを作成します。

<img width="400" alt="スクリーンショット 2018-11-30 1.20.45.png" src="https://qiita-image-store.s3.amazonaws.com/0/35733/0900fa21-006c-e2a7-f971-8743a04cd3ae.png">

下図のように６種のアプリケーションのタイプから１つを選択する必要があります。今回は単純に Slack への投稿の API だけ叩ければいいので、「Permissions」を選択し、

<img width="400" alt="スクリーンショット_2018-11-30_1_21_24.png" src="https://qiita-image-store.s3.amazonaws.com/0/35733/167d17e9-2b62-034b-1fd3-8c8c950022eb.png">

「Scopes（アプリに付与する権限のようなもの）」では次のものを１つだけ選択します。

<img width="400" alt="スクリーンショット_2018-11-30_1_32_02.png" src="https://qiita-image-store.s3.amazonaws.com/0/35733/40a26a9a-075e-2700-63e9-46d8fe226854.png">

後は [Install App to Workspace] ボタンを押下して、Slack のワークスペースに本アプリケーションをインストールします。画面のどこかに「OAuth Access Token」が表示されるので、この文字列を控えておきます。

## AWS

### API Gateway

特別なことはなく POST メソッドのバックエンドを Lambda にするだけですが、Lambda 側でのリクエスト/レスポンスの扱いが楽になるので「Lambda プロキシ統合の使用」にチェックをつけておきます。

<img width="400" alt="スクリーンショット 2018-11-30 0.04.05.png" src="https://qiita-image-store.s3.amazonaws.com/0/35733/930b4182-957a-3196-3c0f-8c3e9c71f00f.png">

### Lambda

ランタイム（実行言語）は同じことが実現できれば何でもいいのですが、今回は `Node.js 8.10` を選択して関数を作成します。

<img width="600" alt="スクリーンショット_2018-11-30_0_22_29-2.png" src="https://qiita-image-store.s3.amazonaws.com/0/35733/ac6b5f71-b323-2a82-beee-b280a3d4f79a.png">

念の為 Slack API 用のトークンは環境変数で渡すようにします。実行ロールは、とくに AWS のコンポーネントにアクセスするわけではないのでデフォルトで用意されているものを指定します。あとは念の為、タイムアウトの秒数を `15` 秒ぐらいへ増やしておきます。

関数のコードについては後の項で説明します。

## GitHub

今回は Organization 内の全リポジトリを対象にしたいので、Organization の設定画面から Webhooks を設定します。

<img width="600" alt="スクリーンショット_2018-11-30_0_35_45.png" src="https://qiita-image-store.s3.amazonaws.com/0/35733/c4de3330-efab-159f-4fab-f58d020e3f29.png">

「Payload URL」は先に作成した API Gateway の URL です。イベントは JSON 形式で送るようにします。また 上図では見切れてますが、トリガーするイベントは `Pull requests` `Issues` `Issue comments` `Pull request review comments` の４つを選択します。（この４つで、ユーザのテキスト入力イベントを全て拾える。具体的には Pull request/Issue の説明文とそれらへのコメント、プルリクエストのレビュー時のコメントです。）

# Lambda 関数のコード

今回、実現する機能をおさらいすると、ユーザのテキスト入力に @ のメンションが含まれている場合は Slack に通知する、というものです。どのメンションを（どのユーザ/チームに対するメンションを）対象にするかは予め定義しておく仕様とし、またメンション毎に通知先の Slack チャンネルを設定できるようにします。

具体的には、GitHub のユーザ/チームの ID と、通知先の Slack チャンネルの対応表を次のような JSON ファイルで管理します。（対応表はいくらでも追加可）

```json:accounts.json
{
  "@hkusu": "@h_kusu",
  "@yumemi/hoge-team": "hoge_channel"
}
```

この例では、GitHub ユーザ `hkusu` が GitHub 上でメンションされたら slack の `@h_kusu` チャンネルへ通知（つまりダイレクトメッセージ）し、`yumemi/hoge-team` チームがメンションされたらそのチームの `hoge_channel` チャンネルへ通知します。

コードの方は全体をそのまま掲載しました。処理の内容はコードのコメントを見てください。（ちなみにサードパーティのライブラリを利用するとコードのデプロイが面倒なので、標準で利用できる `https` モジュール以外は利用していません。）

```javascript:index.js
var https = require('https');
const TOKEN = process.env['TOKEN']; // Lambda の管理コンソールで設定した環境変数
const accounts = require('./accounts.json'); // GitHub の ID と通知先 Slack チャンネルの対照表

exports.handler = async (event) => {
  // GitHub のイベントの種別をヘッダから取得
  // (pull_request or issues or issue_comment or pull_request_review_comment)
  const gitHubEvent = event.headers['X-GitHub-Event'];
  // GitHub から送られた JSON の内容
  const body = JSON.parse(event.body);

  let targetText;
  let linkUrl;

  // Pull request の説明欄の場合
  if (gitHubEvent === 'pull_request' && (body.action === 'opened' || body.action === 'edited')) {
    targetText = body.pull_request.body;
    linkUrl = body.pull_request.html_url;
  }
  // Issue の説明欄の場合
  else if (gitHubEvent === 'issues' && (body.action === 'opened' || body.action === 'edited'))  {
    targetText = body.issue.body;
    linkUrl = body.issue.html_url;
  }
  // Pull request または Issue のコメントの場合
  else if (gitHubEvent === 'issue_comment' && (body.action === 'created' || body.action === 'edited'))  {
    targetText = body.comment.body;
    linkUrl = body.comment.html_url;
  }
  // Pull request のレビューのコメントの場合
  else if (gitHubEvent === 'pull_request_review_comment' && (body.action === 'created' || body.action === 'edited'))  {
    targetText = body.comment.body;
    linkUrl = body.comment.html_url;
  }

  if (!targetText) {
    return {
      statusCode: 200,
      body: 'Process has been passed through.',
    }; 
  }

  // テキストに含まれる GitHub ID のリストを作成
  const gitHubIds = targetText.match(/@[A-Za-z0-9-/]+/g);
  // 0件の場合は終了
  if (!gitHubIds) {
    return {
      statusCode: 200,
      body: 'Process has been passed through.',
    };
  }

  // GitHub ID のリストを元に、通知先 Slack のチャンネルのリストを作成
  const slackChannels = gitHubIds
    .filter((element, index, array) => {
      return element in accounts;
    })
    .map((element, index, array) => {
      return accounts[element];
    })
  // 0件の場合は終了
  if (!slackChannels.length) {
    return {
      statusCode: 200,
      body: 'Process has been passed through.',
    };
  }

  // Slack へメッセージを並列で POST
  const results = await Promise.all(slackChannels.map(channel => {
    return post(channel, targetText + '\n' + linkUrl);
  }))

  // 結果にエラーが含まれる場合
  for (let r of results) {
    if (r instanceof Error) {
      return {
        statusCode: 500,
        body: 'Some were not posted.',
      };
    }
  }

  return {
    statusCode: 200,
    body: `All posts to slack.`,
  };
};

function post(channel, message) {
  return new Promise((resolve, reject) => {
    const data = {
      channel: channel,
      text: message,
    }
    const options = {
      host: 'slack.com',
      port: '443',
      path: '/api/chat.postMessage',
      method: 'POST',
      headers: {
        'Authorization': 'Bearer ' + TOKEN,
        'Content-Type': 'application/json',
      },
    };
    const req = https.request(options, res => {
      res.setEncoding('utf8');
      res.on('data', (chunk) => {
        const result = JSON.parse(chunk);
        if (result.ok) {
          resolve(chunk);
        } else {
          resolve(new Error());
        }
      });
    });
    req.on('error', e => {
      // Lambda を正常に終了する為には全ての並列リクエストが完了するのを
      // 待ってからエラー処理する必要があるのでここでは reject しない
      resolve(new Error());
    });
    req.write(JSON.stringify(data))
    req.end();
  });
}
```

余談ですが、複数のメンションがある場合を想定して slack への HTTP POST を並列で実行しているのですが、`Promise.all()` と async/await で簡潔に書けました。便利ですね。

# おわりに

まだまだ改善点はありそうですが、この記事が GitHub と Slack の連携のカスタマイズの例として参考になれば幸いです。

# 2018.12.28 追記

改良したコードをこちらに置きました。
⇒ https://github.com/hkusu/github-mention-to-slack-lambda