Backlogの通知をLambdaで受けてSlackに通知してみた

Backlogの通知をLambdaで自由に色々いじってSlackに飛ばします。Lambdaに飛ぶのでピタゴラスイッチし放題です。
2021.06.17

業務ではBacklogでプロジェクト管理を行っています。 一方、メンバーとのコミュニケーションはもっぱらSlackを使っているので、 情報はできるだけSlackに集約させたいなぁと思っています。 そう思った時、さすがはBacklog、当然のようにSlack連携が用意されています。

もちろんベーシックな通知はこれでばっちりです。 しかしBacklogで色々管理をしたり、徹底的にラクをしていくためにも、 私にはゴリゴリにカスタマイズ可能な通知が必要だな、と思ってしまいました。

ということで、Backlogでのイベント発生を起点としてピタゴラスイッチを楽しむためにも、 ここはLambdaへ全ての通知を飛ばしておくことにしました。

もちろんLambdaに飛ばすだけだと何も起こらないので、 今回はSlackのIncomingWebhookを使ってSlackへの通知を行いました。 これだけだと既存のインテグレーションと同様のように聞こえますが、 通知されたイベントの詳細まで見て通知するしないの振り分けなどもできるので、 これだけでも結構実用性があります。

構成

  • Backlogの通知を
  • API Gatewayを経由して
  • Lambdaに飛ばして
  • LambdaからSlackへ通知を送る

構成図は書くまでもないほど簡単な構成になります。 上記をServerlessFrameworkを使って作成します。

今回実際に動かした環境のServerlessFrameworkは 2.46.0 でした。

ServerlessFrameworkの準備&デプロイ

serverless.yml

Lambda関数を一つ作り、eventsとしてAPI Gatewayを使うように記述します。 API Gatewayを使うときはhttpを指定するだけでいいんですね、楽勝です。 pathは任意ですので、今回はbacklog/webhookとしました。 その他はLambdaのRoleを定義しているくらいです。

serverless.yml

service: backlog-notification

provider:
  name: aws
  runtime: python3.8
  region: ap-northeast-1
  memorySize: 128
  role: LambdaRole

functions:
  BacklogNotification:
    handler: handler/backlog_notification.lambda_handler
    timeout: 60
    events:
      - http:
          path: backlog/webhook
          cors: true
          method: post

resources:
  Resources:
    LambdaRole:
      Type: AWS::IAM::Role
      Properties:
        RoleName: ${self:service}-lambdaRole-${self:provider.stage}
        AssumeRolePolicyDocument:
          Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Principal:
                Service:
                  - lambda.amazonaws.com
              Action: sts:AssumeRole
        ManagedPolicyArns:
          - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

package:
  exclude:
    - .git/**
    - node_modules
    - node_modules/**
    - __pycache__

Lambda関数コード

続いてLambda関数のコードです。 SLACK_HOOK_URL, BACKLOG_URL_BASEはそれぞれ定数として設定して下さい。 もう少し一般的にするには環境変数への外だしをした方が良いかもしれませんし、 もしくはSSMパラメータストアなどに値を入れる方法もあると思います。

下記はチケットが「処理済み」にされた時にのみ通知を受け取るコードとなります。

handler/backlog_notification.py

import json
from datetime import datetime, timezone, timedelta
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError

SLACK_HOOK_URL = 'https://hooks.slack.com/services/ABCDE0123/FGHIJ456789/h02SvLe5FrVpkjkCaiXeotsR'
BACKLOG_URL_BASE = 'https://abcdefg.backlog.jp'
SLACK_NOTIFIER_NAME = 'Backlog'

STATUS_SYORIZUMI = '3'


def send_to_slack(title, url='', text='', time='', color='#439FE0'):
    attachments = {
        "pretext": time,
        "title": title,
        "title_link": url,
        "text": text,
        "color": color,
    }
    payload = {
        "username": SLACK_NOTIFIER_NAME,
        "icon_emoji": 'backlog',
        "attachments": [attachments]
    }
    data = "payload=" + json.dumps(payload)
    req = Request(SLACK_HOOK_URL, data=data.encode("utf-8"), method="POST")
    try:
        response = urlopen(req)
        response.read()
        print("Message posted to {}".format(SLACK_HOOK_URL))
    except HTTPError as e:
        print("Request failed: {} {}".format(e.code, e.reason))
    except URLError as e:
        print("Server connection failed: {}".format(e.reason))


def notify_status_syorizumi(body):
    project_key = body['project']['projectKey']
    key_id = body['content']['key_id']
    issue_id = f'{project_key}-{key_id}'
    user = body['createdUser']['name']
    timestamp_utc = body['created']
    datetime_utc = datetime.strptime(timestamp_utc + '+0000',
                                     '%Y-%m-%dT%H:%M:%SZ%z')
    datetime_jst = datetime_utc.astimezone(timezone(timedelta(hours=+9)))
    timestamp_jst = datetime.strftime(datetime_jst, '%Y-%m-%d %H:%M:%S')

    ticket = f'''{project_key}-{key_id} {body['content']['summary']}'''
    url = f'{BACKLOG_URL_BASE}/view/{issue_id}'
    text = f'{user} が状態を 処理済み に変更しました'
    send_to_slack(title=ticket,
                  url=url,
                  text=text,
                  time=timestamp_jst,
                  color='good')


def is_change_to_syorizumi(body):
    try:
        changes = body['content']['changes']
        status_change = [x for x in changes if x.get('field') == 'status'][0]
        return status_change['new_value'] == STATUS_SYORIZUMI
    except Exception:
        return False


def lambda_handler(event, context):
    print(json.dumps(event))
    body = json.loads(event['body'])
    if is_change_to_syorizumi(body):
        notify_status_syorizumi(body)

SlackのAttachmentを使ったり、イベント時刻の挿入などをしているので少し複雑に見えますが、 それほど難しいことはやっておらず、 単純に飛んでくる連想配列から目的の情報を引き抜いて通知しているだけのコードになります。

ただし、Backlogからはあらゆるイベントが飛んでくるため、 場合分けのためのメソッド(is_change_to_syorizumi)と、 それに該当した場合の処理(notify_status_syorizumi)とを明確に分離させていった方が見通しがよくなると思います。

デプロイ

通常通りServerlessFrameworkをデプロイすればOKです。 ServerlessFrameworkを深く知らない方でも大抵以下のステップでデプロイできるものと思います。

# 上記2ファイルをそれぞれ配置する。
$ yarn add serverless
$ AWS_PROFILE=<profile> ./node_modules/.bin/sls deploy

デプロイ時にAPI GatewayのエンドポイントURLが表示されるので、控えておいてください。

BacklogのWebhook設定

AWS側の準備ができたので、今度はBacklog側からイベント通知を飛ばす設定をします。 なお、プロジェクト管理者でない場合はインテグレーションの設定ができませんので、 管理者に設定してもらう必要があります。

「プロジェクト管理」 -> 「インテグレーション」 -> 「Webhookの設定」と進み

「Webhookを追加する」

「Webhook名」、「WebHook URL」を設定します。 通知のする/しないは全てLambda内で処理したいので、全ての通知を受けるようにします。

URLはServerlessFrameworkのデプロイ時に表示されています。

最後に「Webhookを追加する」をクリックで設定完了です!

テスト

上記のコードでは処理済み時に通知が届くはずなので、 実際にチケットを処理済みに変更して見るとこんな通知が届くはずです。

SlackのAttachmentを使っているので、色付けやタイトルのリンクなど自由にカスタマイズできます。

まとめ

Backlogのイベントを全てLambdaへ飛ばし、 そこから独自に情報の取捨選択をしてSlackへ通知させるようにしてみました。

プロジェクト管理においては「これをしたら、次にこれしてください」とか、 「ここには必ずこれを明記してください」とか、運用でカバーせざるを得ないことが多い一方、 それが抜けると必要な情報が掌握しにくい!などが生じがちです。 完全にプログラム的な制約を課せるわけではないですが、 ルールに従っていないものの通知を飛ばせるようになるだけで、少しラクになるかと思います。

また、通知内容にリンクをつけて、 BacklogのAPIを叩くことでチケットの定型処理を行うなども考えられますので、 ひたすらラクして業務を行う夢が広がります。