Cloud Functions for FirebaseでサーバレスでGitHubのメンションをSlackに通知する


## やりたいこと
GitHubのアカウント名とSlackのアカウント名が異なるケースがよくある。この場合、Slack標準のGitHub連携だとメンションが飛ばない。GitHubのコメントでメンションしたらよしなにSlackで通知させたい。

つまりは、こういうことがしたい。

![goal.png](https://qiita-image-store.s3.amazonaws.com/0/30233/0011167c-ce05-1030-b16a-cc1f970ad995.png "goal.png")



## 背景
今までこの機能は社内Hubotが役割を務めていたが、メンションだけのために1台インスタンスを立てておくのはもったいないし、ちょくちょくアプリケーションが死んでて誰かが再起動させたりと小さいながらも運用が発生していた。Hubotの設定をしてくれた方が退職して、インテグレーションが外れたのを機に、なるべく人依存な運用を減らすことを目的として、サーバレスで同機能を実現することにした。

## 前提知識
- GitHubは「Issuesを立てる」、「コメントをする」といったイベント毎にWebhookを叩ける
- SlackのIncoming WebHooksは叩かれた際のパラメータに基づいて、任意のチャンネルに任意のコメントを投稿できる

## 実行環境
node - v8.9.4
firebase-functions - 0.8.1

## 実現方法

[Cloud Functions for Firebase](https://firebase.google.com/docs/functions/?hl=ja)を使って実現する。具体的には、GitHubのイベント毎にCloud Functions for Firebaseで発行したWebhookのURLを叩き、Cloud Functions for Firebase内のfunctionでGitHubのアカウント名とSlackのアカウント名の変換を行い、SlackのIncoming WebHooksを叩く。

つまりは、こういうこと。

![system-flow.png](https://qiita-image-store.s3.amazonaws.com/0/30233/ec3d833a-3a15-00c2-06bf-1fac3c8f36fd.png "system-flow.png")



さっそく進めていく。

### Slackの設定を行う
Incoming WebHooksから、Webhook URLを発行する。

```
https://hooks.slack.com/services/XXXXXX/XXXXXX/XXXXXX
```

### Firebaseのプロジョクトの作成
Firebaseのコンソールから、

- プロジェクト名: `github2slack`
- プロジェクトID: `YOUR_PROJECT_ID`
- 国/地域: `日本`

で作成する。

### 公式サンプルをクローンする

Firebase公式のサンプル「Post GitHub commits to Slack channel」を元に開発を進める。このサンプルはGithubのコミットをSlackに通知する機能が実装されている。

[functions-samples/github-to-slack at master · firebase/functions-samples](https://github.com/firebase/functions-samples/tree/master/github-to-slack)

### Firebase CLIのインストール

```console:console
npm install -g firebase-tools
```

### Firebase Projectと紐付ける
先ほどクローンしたリポジトリ内で、

```console:console
firebase use --add
```

と打って、作成したプロジェクトのIDを選択する。
下記ファイルが生成される。

```json:.firebaserc
{
  "projects": {
    "default": "YOUR_PROJECT_ID"
  }
}
```

### 開発環境を整える
node_modulesファイルがGit管理されてしまうので、.gitignoreファイルを追加する。

```.gitignore
functions/node_modules
firebase-debug.log
```

依存パッケージをインストールする。

```console:console
cd functions; npm install; cd -
```

### GitHubの設定を行う
通知を飛ばしたいGitHubリポジトリにて、 `Settings > Webhooks > Add webhook` から下記情報をセットアップする。

- Payload URL: `https://us-central1-YOUR_PROJECT_ID.cloudfunctions.net/githubWebhook`
- Content type: `application/json`
- Secret: `YOUR_SECRET`

サンプルだと、Pushイベントしか受け取らなくて良いが、今回はPull Requestのコメントなどでもメンション検知をしたいので、下記イベントをフックする設定にする。

- [x] Issue Comment
- [x] Issues
- [x] Pull request
- [x] Pull request review
- [x] Pull request review comment
- [x] Push (# サンプルを試し終わったらチェックを外しておいた方が良いかも)

### 環境変数を設定する

Slackの設定にて、設定したWebhook URLとGitHubの設定にて、設定したSecretを環境変数として設定する。

```console:console
firebase functions:config:set slack.webhook_url="https://hooks.slack.com/services/XXXXXX/XXXXXX/XXXXXX" github.secret="YOUR_SECRET"
```

### デプロイしてみる

```console:console
firebase deploy
```

デプロイが成功し、Pushした時にSlackにメッセージが流れることを確認する。

### 今回作るもの
仕様は下記の通り。

- GitHubのアカウント名とSlackのアカウント名のマッピング情報、および、リポジトリ名とSlackのチャンネル名のマッピング情報は `config.json` ファイルで保持する
- GitHubの Pull request, (Issue|Pull request) comment でメンションするとSlackの任意のチャネルに通知が飛ぶ
- Assignees にユーザを指定すると、Slackの任意のチャネルに通知が飛ぶ

GitHubのアカウント名とSlackのアカウント名のマッピング情報はFirebase Realtime Databaseなどで持つ案もあったが、そのDBを管理する人が必要になってくるので、運用負荷を下げるという目的で `config.json` をGit管理し、全員でメンテする方法を取った。GitHub上で雑に編集して、masterブランチにコミットする運用を取っている。


Commentの内容やPull requestの内容に関しては[GitHub公式のSlackアプリケーション](https://slack.com/apps/A0F7YS2SX-github)で十分機能として足りているので、今回はGitHub公式のSlackアプリケーションを併用する前提で、メンション通知機能のみにフォーカスした。

### 今回作ったもの

最終成果物はこちらから。

[kikunantoka/github2slack](https://github.com/kikunantoka/github2slack)

まずは、GitHubのアカウント名とSlackのアカウント名のマッピング情報を管理するためのjsonファイルを作る。必要に応じて、サンプルを参考にアカウントを追加する。このファイルでリポジトリ名とチャンネルのマッピング情報も管理する。


```json:functions/config.json
{
  "account_map": {
    "@github_name": "@slack_name",
    "@kikunantoka": "@kick"
  },
  "channel_map": {
    "repository_name": "#channel_name",
    "nakamy": "#notification_test"
  }
}
```

また、 `functions/index.js` を下記の通りに修正する。


```js:functions/index.js
'use strict';

const config  = require('./config.json');
const functions = require('firebase-functions');
const rp = require('request-promise');
const crypto = require('crypto');
const secureCompare = require('secure-compare');

exports.githubWebhook = functions.https.onRequest((req, res) => {
  const signature = req.headers['x-hub-signature'];
  const cipher = 'sha1';
  const hmac = crypto.createHmac(cipher, functions.config().github.secret)
    .update(JSON.stringify(req.body, null, 0))
    .digest('hex');
  const expectedSignature = `${cipher}=${hmac}`;
  let mentions = '';

  if (!secureCompare(signature, expectedSignature)) {
    console.error('x-hub-signature', signature, 'did not match', expectedSignature);
    return res.status(403).send('Your x-hub-signature\'s bad and you should feel bad!');
  }
  
  switch (req.headers["x-github-event"]) {
    case 'issue_comment':
    case 'pull_request_review_comment':
      mentions += extractMentionNamesFromBody(req.body.comment.body);
      break;
    case 'pull_request_review':
      mentions += extractMentionNamesFromBody(req.body.review.body);
      break;
    case 'pull_request':
      if (req.body.pull_request.state === 'open') {
        mentions += extractMentionNamesFromBody(req.body.pull_request.body);
        mentions += extractMentionNamesFromAssignees(req.body.pull_request.assignees);
      }
      break;
    case 'issues':
      if (req.body.issue.state === 'open') {
        mentions += extractMentionNamesFromBody(req.body.issue.body);
        mentions += extractMentionNamesFromAssignees(req.body.issue.assignees);
      }
      break;
  }

  if (!mentions) return res.end();
  const channel = config.channel_map[req.body.repository.name] || "#notification_test";
  return postToSlack(mentions, channel).then(() => {
    res.end();
  }).catch(error => {
    console.error(error);
    res.status(500).send('Something went wrong while posting the message to Slack.');
  });
});

function postToSlack(mentions, channel) {
  return rp({
    method: 'POST',
    uri: functions.config().slack.webhook_url,
    body: {
      text: mentions,
      link_names: 1,
      channel: channel
    },
    json: true
  });
}

function extractMentionNamesFromBody(body) {
  let result = '';
  let mentions = body.match(/@[a-zA-Z0-9_\-]+/g);
  if (mentions) {
    mentions.forEach(function(mention) {
      result += convertMentionName(mention) + "\n";
    });
  }
  return result;
};

function extractMentionNamesFromAssignees(assignees) {
  if (!assignees || assignees.length == 0) return '';
  let result = '';
  assignees.forEach(function(assignee) {
    result += convertMentionName("@" + assignee.login) + "\n";
  })
  return result;
};

function convertMentionName(name) {
  return config.account_map[name] || name;
}
```

GitHubのAPIはイベント毎に内容の取り出し方が異なるので、[GitHubのAPIドキュメント](https://developer.github.com/v3/)に従って、イベント毎にインタフェースを合わせ、投稿内容を取得し、投稿内容からメンションを抽出し、抽出したメンションを `functions/config.json` のマッピングに従って変換して、SlackのWebhookにPostする、というのが大雑把な処理の流れになる。

他のGitHubのイベントに関しても、 `req.headers["x-github-event"]` 内にイベント名が入っているので、caseを増やせば拡張できる。

SlackのAPIにPostする際に重要になるのは、 `link_names: 1` このオプションである。このオプションを付けないとただの文字列として認識され、Slack上で通知が飛ばない。また、channelに何も指定しないと、Incoming Webhooksで設定したチャンネルに投稿されるようになっている。今回はイベント元のリポジトリによって、投稿するチャンネルを振り分けれるようにした。その他、投稿内容をカスタマイズしたい場合は、[SlackのAPIドキュメント](https://api.slack.com/incoming-webhooks)を参考にして、拡張を行う。

### 再度デプロイしてみる

```console:console
firebase deploy
```

Issue内のコメントでメンションを飛ばして、Slack内の指定したチャネルで通知が飛ぶことを確認する。

### GitHubにPushする
ここまでの状態で、GitHubにPushしておく。

### デプロイを自動化する
このままでは、新しい人が入社したり、新しいリポジトリを作った情報を追加するために、 `functions/config.json` を編集した際に、デプロイする人が必要になってしまう。

人依存を減らすことが目的だったので、デプロイ作業も自動化するために、[CircleCI](https://circleci.com/)を使う。

```yml:circle.yml
machine:
  node:
    version: 8.9.4

dependencies:
  pre:
    - npm install -g firebase-tools
    - cd functions && npm install

test:
  override:
    - "true"

deployment:
  production:
    branch: master
    commands:
      - firebase deploy --token "$FIREBASE_TOKEN"
```

masterブランチにコミットされたら、デプロイが走るように書いてある。Firebaseの認証情報はGoogle認証を使えないので、トークンを発行して、それを環境変数から読み出す。Firebaseのトークンの発行方法は下記の通り。

```console:console
firebase login:ci
```

発行されたトークンをCircle CI内の環境変数に設定する。設定方法は、Circle CI内のこのソースコードをPushしたリポジトリと連携を行い、設定画面に行き、Environment Variablesを開いて、Add Variableのボタンを押し、下記情報を保存する。

- Name: `FIREBASE_TOKEN`
- Value: `YOUR_ISSUED_FIREBASE_TOKEN`

サンプルからそのまま進めていて、CI上でのnpm installが失敗する場合は、 `package.json` に `"private": true` を追加する。

```diff:package.json
{
  "name": "github-to-slack-functions",
  "description": "Firebase Functions that posts new GitHub commits to a Slack channel.",
+ "private": true,
  "dependencies": {
    "firebase-functions": "^0.8.1",
    "request": "^2.80.0",
    "request-promise": "^4.1.1",
    "secure-compare": "^3.0.1"
  },
  "devDependencies": {
    "firebase-tools": "^3.17.4"
  }
}
```

masterブランチに何かしらのコミットをしたのをきっかけに、デプロイが走り、最新の状態が反映されていれば完成となる。


これで、新しい人が入社した場合も、誰かが `functions/config.json` ファイルを編集して、masterにコミットして、数分待てば、メンションが飛ぶ環境となった🎉

## 最終的にこんな環境になった

![final-system-flow.png](https://qiita-image-store.s3.amazonaws.com/0/30233/28cd2866-117a-79f9-e8b8-8d1c590a384c.png "final-system-flow.png")


## Q&A

### Q. 料金はかかるの？
A. 2018年2月20日現在、200万リクエストまでは無料枠の範囲なので、これを超える使い方をしない限りは無料で利用できる。

[Firebase - pricing](https://firebase.google.com/pricing/?hl=ja)

<img width="964" alt="firebase-pricing.png" src="https://qiita-image-store.s3.amazonaws.com/0/30233/47788011-8f98-775a-ba0f-55445395e910.png">

## 参考
- [SlackのGitHub連携をやめGitHub->AmazonSNS->Lambda->Slackで連携する - Qiita](https://qiita.com/ooharabucyou/items/2a3dca643f6b7783d665)
- [Firebaseでslack力を上げる // Speaker Deck](https://speakerdeck.com/tebasakyu/firebaseteslackli-woshang-keru)