これは ゆめみ Advent Calendar 2018 の2日目の投稿です。
コミュニケーションツールとして Slack、コードを GitHub 上で開発している場合、開発の状況や GitHub 上のアクションに応じていい感じに Slack へ通知を流したい、と思うことがありませんでしょうか。私はあります。
一応、GitHub 公式のインテグレーション もあるのですが、これに物足りない場合は自前で仕組みを構築することになります。今回は試しに、GitHub の Pull request または Issue で、 @ でメンションされたら Slack に通知する という仕組みを構築してみたので、その構築の手順を紹介をしたいと思います。
動作イメージ
全体の構成
独自のコードをどこかに自前でホスティングする必要がありますが、それは AWS Lmabda に置きます。GitHub の Webhooks で GitHub 上のイベントを HTTP(S) で API Gataway -> Lambda へ送り、イベントを Lambda で処理後、Slack API を利用して Slack へ通知を送る構成です。
図にすると次のとおりです。
環境の構築
Slack
まず Slack API を利用できるようにします。https://api.slack.com/ にアクセスしてアプリケーションを作成します。
下図のように6種のアプリケーションのタイプから1つを選択する必要があります。今回は単純に Slack への投稿の API だけ叩ければいいので、「Permissions」を選択し、
「Scopes(アプリに付与する権限のようなもの)」では次のものを1つだけ選択します。
後は [Install App to Workspace] ボタンを押下して、Slack のワークスペースに本アプリケーションをインストールします。画面のどこかに「OAuth Access Token」が表示されるので、この文字列を控えておきます。
AWS
API Gateway
特別なことはなく POST メソッドのバックエンドを Lambda にするだけですが、Lambda 側でのリクエスト/レスポンスの扱いが楽になるので「Lambda プロキシ統合の使用」にチェックをつけておきます。
Lambda
ランタイム(実行言語)は同じことが実現できれば何でもいいのですが、今回は Node.js 8.10
を選択して関数を作成します。
念の為 Slack API 用のトークンは環境変数で渡すようにします。実行ロールは、とくに AWS のコンポーネントにアクセスするわけではないのでデフォルトで用意されているものを指定します。あとは念の為、タイムアウトの秒数を 15
秒ぐらいへ増やしておきます。
関数のコードについては後の項で説明します。
GitHub
今回は Organization 内の全リポジトリを対象にしたいので、Organization の設定画面から Webhooks を設定します。
「Payload URL」は先に作成した API Gateway の URL です。イベントは JSON 形式で送るようにします。また 上図では見切れてますが、トリガーするイベントは Pull requests
Issues
Issue comments
Pull request review comments
の4つを選択します。(この4つで、ユーザのテキスト入力イベントを全て拾える。具体的には Pull request/Issue の説明文とそれらへのコメント、プルリクエストのレビュー時のコメントです。)
Lambda 関数のコード
今回、実現する機能をおさらいすると、ユーザのテキスト入力に @ のメンションが含まれている場合は Slack に通知する、というものです。どのメンションを(どのユーザ/チームに対するメンションを)対象にするかは予め定義しておく仕様とし、またメンション毎に通知先の Slack チャンネルを設定できるようにします。
具体的には、GitHub のユーザ/チームの ID と、通知先の Slack チャンネルの対応表を次のような JSON ファイルで管理します。(対応表はいくらでも追加可)
{
"@hkusu": "@h_kusu",
"@yumemi/hoge-team": "hoge_channel"
}
この例では、GitHub ユーザ hkusu
が GitHub 上でメンションされたら slack の @h_kusu
チャンネルへ通知(つまりダイレクトメッセージ)し、yumemi/hoge-team
チームがメンションされたらそのチームの hoge_channel
チャンネルへ通知します。
コードの方は全体をそのまま掲載しました。処理の内容はコードのコメントを見てください。(ちなみにサードパーティのライブラリを利用するとコードのデプロイが面倒なので、標準で利用できる https
モジュール以外は利用していません。)
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