Slack と GAS と GitHub を使って部内の問題・情報管理を円滑にしたい話

IGGG ソフトウェア基盤部のひげです。

特定の GitHub リポジトリの任意の Issue を任意の Slack のチャンネルに通知を飛ばすための Bot (これは公式のインテグレーションではできないはず) を GAS で作った話です。

いきさつ

IGGG では予てより Slack というチャットサービスを利用して日々 雑談 ディスカッションをしております。
しかし、フリープランの Slack では ログが一万件しか残らない!
割と重要な事案が しょーもない雑談 活発な技術的な議論によって、気づいたら流れてしまう…。

皆を説得して有償プランにしようとか、ログの残るサービスに移ろうとか、何度かいろんな案で話し合ったのですが…いろいろな理由で難しい。
そこで、IGGG の 外圧担当 CTO こと 擬音 より GitHub の Issue で管理しよう との提案が、今年の4月中旬ぐらいにされた。

いい感じ

しっかりと問題提起・議論が残り、とてもいい感じ。

問題

ただ、いくつか問題があった。

  1. IGGG の GitHub 組織アカウントに所属しないと議論が見れない
    • 誰しもが GitHub アカウントを持ってるわけでは…
      • もっておこうよ
    • Slack の GitHub Integration で特定のチャンネル(#management)に飛ばす
      • 飛ばしてるけど、決定事項(Issueの結論)だけ #general に飛ばしたい
      • 全部 #general に飛ばしたらうるさい
  2. 特定のチャンネルで見たい特定の Issue がある
    • インフラチャンネルで IPアドレスが欲しい という Issue が見たいとか
    • いちいち #management に移るのめんどい

要するに 1) #general に Issue の Open と Close だけをコメント付きで通知したいのと、 2) 任意の Issue のコメントを任意のチャンネルに通知したい。

ということで、これらの問題を解決するために Slack の Bot を作った。

1. #general に Issue の Open と Close だけをコメント付きで通知する

GitHub インテグレーションでも、Issue の Open, Close だけを飛ばすという設定はある。

GitHub インテグレーションにて

だがしかし、これでは

てすと

という感じの Issue に対し(てててすと というコメントは Close 時に書いたコメント)

てーすと

と感じに来る(そりゃそう)。
ここで、最後のコメントも通知してほしいのだ(Issueの結論を書いて)。

Manager 1号

ということで Bot を作った。
ソースコードはこちら。

以下の3つのGASライブラリを用いている。

  • SlackApp : M3W5Ut3Q39AaIwLquryEPMwV62A3znfOO
  • GitHubAPI : 1F4yn329GjHKdcXu9nm0uBZHFo40NGRUF8dfZCTHM1KjXpOXYr2BzIIcJ
  • Underscore : M3i7wmUA_5n0NSEaa6NnNqOBao7QLBR4j

また、SlackBot の APIトークン と GitHub の APIトークン を利用している。

GitHub の Personal Token を利用しているので、念のため IGGG の GAS では無く、個人の GAS 上で作った。

実装

動作は Issue の Open と Close にフックして動作させ、POSTデータを解析し、適切なメッセージ作成して、Slack に投げている。

function doPost(e) {
var jsonString = e.postData.getDataAsString();
var jsonData = JSON.parse(jsonString);
postMessage(jsonData, 'general');
}

function postMessage(data, channelName) {
var prop = PropertiesService.getScriptProperties().getProperties();
const repo = prop.GITHUB_OWNER + '/' + prop.GITHUB_REPO;

/* 念のためのフィルタリング */
if (data['repository']['full_name'] != repo && data['comment'] == undefined && data['issue'] != undefined) {
throw new Error("invalid repository.");
}
/* 余計なアクション(editedとか)は破棄 */
if (data['action'] != 'opened'
&& data['action'] != 'closed'
&& data['action'] != 'reopened') {
throw new Error("undefined action: " + data['action']);
}

 /* メッセージを作成 */
var number = data['issue']['number'];
var message = makeMessage(data['action'], data['issue'], repo, prop);

/* Slackに投げる */
var slackApp = SlackApp.create(prop.SLACK_API_TOKEN);
const BOT_NAME = 'manager';
const BOT_ICON = 'http://drive.google.com/uc?export=view&id=' + prop.ICON_ID;
var option = { username : BOT_NAME, icon_url : BOT_ICON, link_names : 1 };
var _ = Underscore.load();
slackApp.postMessage(channelName, '', _.extend(option, message));
}

メッセージは Open, Close, ReOpen の場合に分けて作成している。
Close の場合だけ直近のコメントを GitHub API を使って取ってきている(getIssueResentCommentBody)。

function makeMessage(action, issue, repo, prop) {
var user = '<' + issue['user']['html_url'] + '|' + issue['user']['login'] + '>';
/* アクションごとに分岐 */
var actionText = '';
var text = '';
switch(action) {
case 'opened':
actionText = 'created';
text = issue['body'];
break;
case 'closed':
actionText = 'closed';
text = getIssueResentCommentBody(issue['number'], prop);
break;
case 'reopened':
actionText = 're-opened';
text = issue['body'];
break;
}
var pretext = '[' + repo + '] Issue ' + actionText + ' by ' + user;

/* いい感じなメッセージにするために */
return {
'attachments': JSON.stringify([{
'pretext': pretext,
'title': '#' + issue['number'] + ': ' + issue['title'],
'title_link': issue['html_url'],
'color': prop.COLOR,
'text': text,
'footer': '詳細は #management かリポジトリで'
}])
};
}

function getIssueResentCommentBody(number, prop) {
var github = GitHubAPI.create(prop.GITHUB_OWNER, prop.GITHUB_REPO, prop.GITHUB_API_TOKEN);
var comment = github.get("/issues/" + number + "/comments");
return comment[comment.length-1]['body'];
}

attachmentsを使って、頑張っていい感じのメッセージにしている。
これを模索するために、テストとして無駄にメッセージを投げてしまった(ごめんね)。

ちなみに、webhook せずにテストするときは

function test() {
var prop = PropertiesService.getScriptProperties().getProperties();
var data = {
'action': 'closed',
'repository': {
'full_name': prop.GITHUB_OWNER + '/' + prop.GITHUB_REPO
},
'issue': {
'number': 15,
'title': 'このリポジトリは必要か',
'user': {
'login': 'matsubara0507',
'html_url': 'https://github.com/matsubara0507'
},
'html_url': 'https://github.com/' + prop.GITHUB_OWNER + '/' + prop.GITHUB_REPO + '/issues/15',
'body': 'なんか知らぬ間に決まってる感じもある\n自分で見に行けってのもあるけどサ'
}
};
/* テスト!!*/
postMessage(data, 'bot-test');
}

こんな感じの関数を作って実行する。

あとは、このスクリプトを、公開 -> ウェブアプリケーションとして導入 を押して webhook 用の URL を発行し、これを リポジトリの Webhook に設定するだけ。

Issueだけチェックする

実行

いい感じ

2. 任意の Issue のコメントを任意のチャンネルに通知する

GitHub インテグレーションは特定のリポジトリと特定のチェンネルをつなぐ。
よって、特定のリポジトリの特定の Issue と特定の特定のチャンネル繋ぐことはできない。

Manager 2号

どのチャンネルにどの Issue のを通知するかの設定も Slack からしたいよね。
そのため、設定する側と、コメントにフックして通知する側の2つに分けて書くことにする。
それらのソースコードは、これ(設定) と これ(通知)。

ライブラリは 1号のと同じ。

チャンネルと Issue の対応表は(めちゃ簡単な)スプレッドシートで残しておくことにした。

雑な表

設定側の実装

Slack の Outgoing Webhook Integration にフックしてスプレッドシートに対応関係を書き込むことにする。

@manager <cmd>: <issue-num> というフォーマットでメッセージ送られてくると想定している。
コマンド (<cmd>)には、チャンネルと Issue の対応関係をセットする set-issue と、対応関係をアンセットする unset-issue がある。
また set-issue では、指定された Issue の番号 (<issue-num>) が本当に存在するかや、既にセット済みかを確認している(existRow)。

function doPost(e) {
var prop = PropertiesService.getScriptProperties().getProperties();

/* Spread Sheet の読み取り*/
/* 割愛 */

/* Slack の準備*/
/* 割愛 */

/* メッセージによって分岐 */
var message = e.parameter.text.split(' ');
var channelName = e.parameter.channel_name;

if (message[0] != ('@' + BOT_NAME)) {
throw new Error('invalid bot name.');
}

var _ = Underscore.load();
var subcmd = message[1];
var text = '';
switch(subcmd) {
case 'set-issue:':
var number = message[2];
var issue = getIssue(number, prop);
if (issue == 'error') {
text = 'issuer #' + number + ' is not exist.';
break;
}
if (existRow(table, channelName, number)) {
text = 'issue <' + issue['html_url'] + '| #' + number + '> has already been set for #' + channelName;
} else {
text = 'OK! set issue.';
var repo = prop.GITHUB_OWNER + '/' + prop.GITHUB_REPO;
option = _.extend(option, makeMessage(issue, repo, prop));
sheet.getRange(rowNum + 1, 1).setValue(channelName);
sheet.getRange(rowNum + 1, 2).setValue(number);
}
break;
case 'unset-issue:':
var number = message[2];
text = 'not set yet: ' + channelName + ' - ' + number;
for(var i = 0; i < table.length; i++) {
if (table[i][0] == channelName && table[i][1] == number) {
sheet.getRange(i + 1, 1).setValue('');
sheet.getRange(i + 1, 2).setValue('');
text = 'OK! unset issue.';
}
}
break;
default:
text = 'undefined cmd: ' + subcmd;
break;
}
slackApp.postMessage(channelName, text, option);
}

function existRow(table, channelName, number) {
for (var i = 0; i < table.length; i++) {
if (table[i][1] == number && table[i][0] == channelName)
return true;
}
return false;
}

指定された Issue の番号が本当に存在するかを確認するために、GitHubAPI をたたいて、リポジトリ内の Issue を全て取得し、愚直に線形探索している。
見つかれば、その Issue をそのまま返し、無い場合は "error" という文字列を返している。

function getIssue(number, prop) {
var github = GitHubAPI.create(prop.GITHUB_OWNER, prop.GITHUB_REPO, prop.GITHUB_API_TOKEN);
var issues = github.get('/issues?state=all');
for (var i = 0; i < issues.length; i++) {
if (issues[i]['number'] == number)
return issues[i];
}
return 'error';
}

こいつも attachments でいい感じのメッセージにしている。

function makeMessage(issue, repo, prop) {
var user = '<' + issue['user']['html_url'] + '|' + issue['user']['login'] + '>';
var pretext = '[' + repo + '] Issue created by ' + user;
var title = '#' + issue['number'] + ' ' + issue['title'];
var title_link = issue['html_url'];
return {
'attachments': JSON.stringify([{
'pretext': pretext,
'title': title,
'title_link': title_link,
'color': prop.COLOR,
'text': issue['body']
}])
};
}

設定側の実行

いい感じ

通知側の実装

GitHub リポジトリに Issue のコメントだけに Webhook されるようにする。
そしたら POST データを解析し、スプレッドシートの中から2列目が等しい行の1列目だけ取ってきて(高階関数最高)、POST データからいい感じのメッセージ生成して、Slack Bot に通知している。

function doPost(e) {
var jsonString = e.postData.getDataAsString();
var jsonData = JSON.parse(jsonString);
postMessage(jsonData);
}

function postMessage(data) {
var prop = PropertiesService.getScriptProperties().getProperties();
const repo = prop.GITHUB_OWNER + '/' + prop.GITHUB_REPO;

/* いい感じにフィルタリング */
if (data['repository']['full_name'] != repo && data['comment'] != undefined && data['issue'] != undefined) {
throw new Error('invalid repository.');
}

/* Spread Sheet の読み取り*/
/* 割愛 */

 /* 表からフックされた Issue の番号を線形探索 */
var number = data['issue']['number'];
var channels = table.filter(function(row){
return row[1] == number;
}).map(function(row){ return row[0] });

/* いい感じのメッセージを作成 */
var message = makeMessage(data['action'], data['comment'], data['issue'], repo, prop);

/* Slack の準備*/
/* 割愛 */

var _ = Underscore.load();
channels.forEach(function(channelName){
option = _.extend(option, message);
slackApp.postMessage(channelName, '', option);
})
}

メッセージはコメントの作成・編集・削除ごとに異なる。
ただ、編集時には編集前のコメントしか手に入らないので、GitHub API をたたいて編集後のコメントを取りに行ってる(getIssueCommentBody)。

function makeMessage(action, comment, issue, repo, prop) {
var user = '<' + comment['user']['html_url'] + '|' + comment['user']['login'] + '>';
var issueTitle = '<' + comment['html_url'] + '|' + '#' + issue['number'] + ': ' + issue['title'] + '>';

var actionText = '';
var text = '';
switch(action) {
case 'created':
actionText = 'New';
text = comment['body'];
break;
case 'edited':
actionText = 'Edit';
text = getIssueCommentBody(comment['id'], prop);
break;
case 'deleted':
actionText = 'Delet';
text = comment['body'];
break;
}

var pretext = '[' + repo + '] ' + actionText + ' comment by ' + user + ' on isuue ' + issueTitle;
return {
'attachments': JSON.stringify([{
'pretext': pretext,
'color': prop.COLOR,
'text': text
}])
};
}

function getIssueCommentBody(id, prop) {
var github = GitHubAPI.create(prop.GITHUB_OWNER, prop.GITHUB_REPO, prop.GITHUB_API_TOKEN);
var comment = github.get('/issues/comments/' + id);
return comment['body'];
}

通知側の実行

いい感じ

おしまい

これで部内の問題・情報管理がさらに円滑になるはず!(願望)