Backlog通知をサーバレス構成でslackに飛ばす

  • 13
    いいね
  • 2
    コメント

概要

最近、業務上の色んな通知がメールからSlackに集約されてきている中で、バックログ通知がまだメールのままだったので、Slackに集約してしまおう!と思い立ってみました。
そして、せっかくならAPI Gateway+Lambdaを使用したサーバレス構成でやってみよう!と考え、以下のような流れで通知が行えるようにしてみました。

2017-05-04_145514.png

対象のバックログプロジェクトのWebhookにAPI GatewayのURLを登録すると、課題の登録・更新のタイミングで、指定したSlackチャンネルに通知を行います。

つくってみる

実際に行った内容を順番にまとめていきます。
(試行錯誤しながらつくっていったので、手順が前後しているところがあるかもしれないです。また、コード書くの久々なので変なとこがあったらすいません。。)

slack側の準備

まずはじめに、slackで通知するbotを用意します。
slackのApp Directoryにて「incomming webhooks」を検索し、作成。
設定内容は以下の通り。

Post to Channel: #generalなどの消えないチャンネルを設定
Customize Name: 「backlog」などbot名を設定
Customize Icon: バックログからの通知だ!とわかるアイコン画像を設定

この時、Webhook URL( https://hooks.slack.com/services/XXXX/XXXX/XXXX )が発行されるので、メモっておきます。

AWS上の準備

Lambda用のIAM Roleの作成

IAM Roleを作成します。
こちらの記事を参考に、lambda-backlog-slackというRoleを作成しました。

ロール名: lambda-backlog-slack
アタッチするポリシー: AWSLambdaExecute

スクリプトの作成

Backlog Webhookのjsonデータを整形し、SlackへPOSTするスクリプトを作成します。Node.jsで作ってみます。
ローカル環境で「lambda」フォルダを作成し、必要なモジュールを準備します。
lambdaフォルダ内で以下を実行すると、node_modulesフォルダ配下にモジュールが置かれます。

$ npm install slack-node

そしてlambdaフォルダ直下にindex.jsを作成します。
このjsでは、通知先のチャンネル名をパラメータで受け取り、jsonを整形し、Slackに投稿します。

※設置環境によって変わる値はLambdaの環境変数に外出しします。
※通知する内容が長い場合は後ろをカットしています。
※一部の通知イベント(削除系,ファイル系,プロジェクトに関するイベントなど)には対応していません。必要に応じてswitch文を追加してください。

index.js
'use strict';

//requires
const Slack = require('slack-node');
const slack = new Slack();

// settings (lambda環境変数)
var BOTNAME = process.env['BOTNAME'];
var BASEURL = process.env['BASEURL'];
var WEBHOOKURI = process.env['WEBHOOKURI'];

slack.setWebhook(WEBHOOKURI);

// Format chat message
function makeChatMessage(body) {
  var msgObj = new Object();
  var label = "";
  var bl_key = "";
  var bl_summary = "";
  var bl_comment = "";
  var bl_url = "";

  switch (body.type) {
    case 1:
      label = "追加";
      bl_key = "["+body.project.projectKey+"-"+body.content.key_id+"]";
      bl_summary = "「" + body.content.summary + "」";
      bl_url = BASEURL+"view/"+body.project.projectKey+"-"+body.content.key_id;
      bl_comment = body.content.description;
      break;
    case 2:
      label = "更新";
      bl_key = "["+body.project.projectKey+"-"+body.content.key_id+"]";
      bl_summary = "「" + body.content.summary + "」";
      bl_url = BASEURL+"view/"+body.project.projectKey+"-"+body.content.key_id;
      bl_comment = body.content.description;
      break;
    case 3:
      label = "コメント";
      bl_key = "["+body.project.projectKey+"-"+body.content.key_id+"]";
      bl_summary = "「" + body.content.summary + "」";
      bl_url = BASEURL+"view/"+body.project.projectKey+"-"+body.content.key_id+"#comment-"+body.content.comment.id;
      bl_comment = body.content.comment.content;
      break;
    case 14:
      label = "課題まとめて更新";
      bl_key = "";
      bl_summary = "";
      bl_url = BASEURL+"projects/"+body.project.projectKey;
      bl_comment = body.createdUser.name+"さんが課題をまとめて操作しました。";
      break;
    case 5:
      label = "Wiki追加";
      bl_key = "";
      bl_summary = "「"+body.content.name+"」";
      bl_url = BASEURL+"alias/wiki/"+body.content.id;
      bl_comment = body.createdUser.name+"さんがWikiページを追加しました。";
      break;
    case 6:
      label = "Wiki更新";
      bl_key = "";
      bl_summary = "「"+body.content.name+"」";
      bl_url = BASEURL+"alias/wiki/"+body.content.id;
      bl_comment = body.createdUser.name+"さんがWikiページを更新しました。";
      break;
    case 11:
      label = "SVNコミット";
      bl_key = "[r"+body.content.rev+"]";
      bl_summary = "";
      bl_url = BASEURL+"rev/"+body.project.projectKey+"/"+body.content.rev;
      bl_comment = body.content.comment;
      break;
    case 12:
      label = "Gitプッシュ";
      var git_rev = body.content.revisions[0].rev;
      git_rev = git_rev.substr(0,10);
      bl_key = "["+git_rev+"]";
      bl_summary = "";
      bl_url = BASEURL+"git/"+body.project.projectKey+"/"+body.content.repository.name+"/"+body.content.revision_type+"/"+body.content.revisions[0].rev;
      bl_comment = body.content.revisions[0].comment;
      break;
    case 18:
      label = "プルリクエスト追加";
      bl_key = "( 担当:"+body.content.assignee.name+" )";
      bl_summary = "「"+body.content.summary+"」";
      bl_url = BASEURL+"git/"+body.project.projectKey+"/"+body.content.repository.name+"/pullRequests/"+body.content.number;
      bl_comment = body.content.description;
      break;
    case 19:
      label = "プルリクエスト更新";
      bl_key = "( 担当:"+body.content.assignee.name+" )";
      bl_summary = "「"+body.content.summary+"」";
      bl_url = BASEURL+"git/"+body.project.projectKey+"/"+body.content.repository.name+"/pullRequests/"+body.content.number;
      bl_comment = body.content.description;
      break;
    case 20:
      label = "プルリクエストコメント";
      bl_key = "( 担当:"+body.content.assignee.name+" )";
      bl_summary = "";
      bl_url = BASEURL+"git/"+body.project.projectKey+"/"+body.content.repository.name+"/pullRequests/"+body.content.number+"#comment-"+body.content.comment.id;
      bl_comment = body.content.comment.content;
      break;

    default:
      return;
  }
  console.log(label);

  if(label){
    msgObj['message'] = bl_key+" "
    + label
    + bl_summary
    + " by " + body.createdUser.name
    + "\n "+bl_url;
    // 長いコメントは後ろカット
    if(bl_comment.length > 200){
      bl_comment = bl_comment.substr(0,200)+"...";
    }
    msgObj['comment'] = bl_comment;
  }
  return msgObj;
}

// POST Slack
function postSlack(channel,message,comment){
  // 引用コメント部分整形
  var attachments_opts = "";
  if(comment){
    attachments_opts = {
      "color": "#42ce9f",
      "fields": [
        {
          "value":comment, 
          "short": false
        }
      ]
    };
  }

  return new Promise(function(resolve,reject) {
    slack.webhook({
      channel: channel,
      username: BOTNAME,
      text: message,
      attachments: [ attachments_opts ]
    }, function(err, response) {
      if (err) {
        console.log(response);
        reject(new Error('Error'));
        return;
      } else {
        resolve(response);
      }
    });
  });

}

// Main Handler
exports.handler = function (event, context, callback) {
  var room;
  var body;
  var slackObj = new Object();

  console.log('event: ' + JSON.stringify(event, null, 4));

  if(event.room && event.requestParameters){
    // 通知先チャンネル取得
    console.log('room='+event.room);
    room = event.room;
    // json整形・メッセージ作成
    body = event.requestParameters;
    slackObj = makeChatMessage(body);

    // Slack投稿
    if(slackObj){
      postSlack(room, slackObj['message'], slackObj['comment']);
    }
  }else{
    console.log('対象チャンネルが指定されていないか、データが取得できません。');
  }

  callback(null, 'Done.');

};

Lambda関数の作成

ここまで用意出来たら、AWS上でLamda関数を作っていきます。
AWSサービス画面にてLambdaを探し、アクセスします。リージョンは東京(ap-northeast-1)で。
※AWSの言語設定を日本語にしてるので、ラベル名などは日本語表記で記載しますね。。

「Lambda関数の作成」に入り、「設計図の選択」で「ブランク関数」を選択します。
「トリガーの設定」では何も設定せず、次の「関数の設定」に進みます。
「関数の設定」では以下のように設定します。

名前: backlog2slack
説明: backlog to slack
ランタイム: Node.js 6.10

「Lambda 関数のコード」にて「.zipファイルをアップロード」を選びます。
ローカル環境にて、index.jsとnode_modulesフォルダを選択した状態でzip化し、アップロードします。
(※zip化する際の階層に注意してください)

続いて環境変数を3つ、設定していきます。

WEBHOOKURI: https://hooks.slack.com/services/... ※slack側の準備時に発行されたWebhook URL
BASEURL: https://xxx.backlog.jp/ ※xxx部分は自身の環境に合わせて変更
BOTNAME: backlog ※slack発言時の名前

※本サンプルは単一のバックログスペース(環境変数BASEURLに設定したスペース)にのみ対応します。複数のスペースに対応したい場合は拡張してみてください。

「Lambda 関数ハンドラおよびロール」は以下のように設定。

ハンドラ: index.handler
ロール: 既存のロールを選択→lambda-backlog-slack

それ以外の設定はデフォルトのままにし、関数を作成します。

API Gatewayの設定

続いてAPI Gatewayの設定を進めます。
API GatewayでBacklogのWebhookに通知先として登録するURLを作成し、実行時に先程作ったLambda関数が叩かれるように設定します。

AWSサービス画面にてAPI Gatewayを探し、アクセスします。こちらもリージョンは東京(ap-northeast-1)で。
APIを以下の内容で新規作成します。

API名: Backlog2SlackAPI
説明: Backlog to SlackAPI

次に、リソースとメソッドの作成を進めていきます。

リソースの作成

「/」配下に「/backlog2slack」を作成します。
アクションから「リソースの作成」を選択。

リソース名: backlog2slack ※リソースパスに自動で同じものが入力されます。

更にその下に「/{room}」を作成します。

リソース名: {room} ※{なんちゃら}で、パラメータ扱いとなります。
リソースパス: {room} ※自動で入力された値を書き換えます。

メソッドの作成

「/{room}」配下にPOSTメソッドを作成します。
アクションから「メソッドの作成」を選択。
「/{room}」の配下にプルダウンがでますので、POSTを選択。

そのままPOSTメソッドのセットアップに進みます。

統合タイプ: Lambda 関数
Lambda リージョン: ap-northeast-1
Lambda 関数: backlog2slack

保存時に「Lambda 関数に権限を追加する」ダイアログが出ますので、OKを押します。

最終的なリソースのツリーは以下のようになります。

2017-05-04_133949.png

パラメータ設定

slack発言チャンネル用パラメータ「room」の設定(確認)を行います。
リソースの、 /backlog2slack/{room} - POST の「メソッドリクエスト」にて、リクエストパスにroomが追加されていることを確認します。

マッピングテンプレート設定

リソースの、 /backlog2slack/{room} - POST の「統合リクエスト」にて、「本文マッピングテンプレート」の設定を行います。
「本文マッピングテンプレート」の「リクエスト本文のパススルー」で「テンプレートが定義されていない場合 (推奨)」を選択しつつ、マッピングテンプレート「application/json」を追加します。

内容はこちらの記事にあるコードに加え、以下を追加しました。

"room": "$input.params('room')"

APIのデプロイ

APIをデプロイして、URLの形で叩けるようにします。
リソースのアクションから「APIのデプロイ」を選択。

デプロイされるステージ: [新しいステージ]
ステージ名: xxx ※ひとまず、バックログのスペース名にしました。

デプロイされた後、API Gateway左メニューの「ステージ」をクリック、デプロイされたツリーを開いていきます。
最下層のPOSTメソッドを開くと、画面右側の「URL の呼び出し」で、呼び出しURLが表示されるのでメモっておきます。

https://XXXX.execute-api.ap-northeast-1.amazonaws.com/ステージ名/backlog2slack/{room}

Backlog側の準備

最後にバックログのWebhook URL設定を行います。
なお、バックログのWebhook設定には、対象のバックログプロジェクトの管理権限が必要です。

対象のバックログプロジェクトに入り、「プロジェクト設定」>Webhookと辿っていき、以下内容でWebhookを追加します。

2017-05-04_141219.png

Webhook名: Backlog2Lambda ※お好みの内容で
説明: Backlog2Lambda ※お好みの内容で
WebHook URL: https://(略)/ステージ名/backlog2slack/{Slackチャンネル名}
通知するイベント: すべて ※Lambda側で通知するイベントを制御してるので、ここではすべてに。

Webhook URLですが、randomチャンネルに通知したい場合は以下のように設定します。
https://XXXX.execute-api.ap-northeast-1.amazonaws.com/ステージ名/backlog2slack/random

動かしてみる

では、実際に動かしてみます。
バックログのWebhook URLを設定した画面の下の方で、実行テストが行なえるので試してみます。

2017-05-04_141248.png

こんな感じで通知がやってきます。

2017-05-04_141458.png

また、「送信履歴」タブでは実際に送信されたjson内容が確認できます。
バックログのリファレンスに全パターンのjson内容の説明がないので、作成時、実行テスト→送信履歴で内容確認を繰り返してましたw

2017-05-04_141733.png

また、AWSのCloudWatch画面ではLambdaの実行ログが確認できます。
CloudWatch画面の「ログ」から「/aws/lambda/backlog2slack」のストリームを参照してみてください。index.jsの中でconsole.logとして出力している内容がログの中で確認できます。

おわりに

導入してみて気づいたのですが、バックログ通知=slackの発言になるので、その発言に対してリアクションしたり、スレッドを起こしてコメントできるのが地味に便利だなと思いました。
例えば、「○○さん、これ見てもらえる?」といった分担決めをslackのスレッド機能でササッと済ませるなど。

こうやって、便利に使えるものはどんどん取り入れていきたいですね~。