BacklogからSlackに通知する何かを作った

Created
Aug 7, 2023 6:32 AM
Tags
BacklogSlack
Editor
Tomoya Kabe
image

チーフエンジニアの加辺です。

Elastic Infraでは普段のコミュニケーションにSlackを、タスク管理にBacklogを採用しています。

そうするとBacklogの更新通知をSlackで受けたくなるのですが、BacklogはSlackとの連携機能を提供していません…

(Typetalkがあるからという大人の事情でしょうか…?)

事情はともあれ、Backlogはwebhook機能を提供しています。

これによりプロジェクトのアクティビティを外部に送信(JSON POST)することができるわけです。

今回はこれを利用してBacklog上での動きをSlackに通知することにしました。

基本アーキテクチャ

今回はu-minorさんのQiita記事を参考にし、API Gateway + Lambda( + DynamoDB)で実現することにしました。

ソースコードも公開されているのですが、あえて自分での実装を選びました。

言語はまだLambdaでは使ったことがなかったので、作法を覚えるのも兼ねてGoにしました。

構成図はこちらです。シンプルなサーバレス構成です。

image
  • BacklogおよびSlackのロゴはそれぞれのロゴ利用ガイドラインに沿って利用しています
  • この図および本記事の実装は、株式会社ヌーラボあるいはSlack Technologies, Inc が作成したものでも、これに関連したものでも、サポートするものでもありません

実装

やっていきましょう

…と、その前にやらないことを決めます

  • 個別のメンバーにDM通知はしない
    • 1プロジェクト1 channel(group)に通知
    • コメントをお知らせする機能は(文面には入れるが機能としては)無視
  • 全てを完璧に通知しようとしない
    • 何を通知してほしいか、それぞれの通知で何を知りたいかは人によって異なる
    • 通知文面は実装者の独断もといセンスで定める
    • SubversionやGit連携の機能は利用していないので、commit/push/pull requestは通知対象から除外する

実装Phase1 webhookを受けられるようにする

なにはともあれwebhookからLambdaが呼び出されないことには始まりません。

ここは特別な工夫はいらず、qiita記事通りの構成を作りました。

API Gatewayを作成し、Lambdaが呼び出されるように設定するだけです。

元々はAPI Gateway Proxyを使うコードにしようと思ったのですがやっぱりやめた残骸が残っています(意味はないです)

func PostSlack(event BacklogRequestFormat) {
        // ここでいろいろする
}

func HandleRequest(ctx context.Context, event BacklogRequestFormat) (events.APIGatewayProxyResponse, error) {
        lctx, ok := lambdacontext.FromContext(ctx)
        if !ok {
                return events.APIGatewayProxyResponse{}, &ErrorResponse{
                        Message: "FromContextError",
                        Code:    400,
                }
        }
        log.Printf("Starting %s v%s", lambdacontext.FunctionName, lambdacontext.FunctionVersion)
        log.Printf("lambda ctx info:%+v", lctx)
        log.Printf("event: %+v", event)
        PostSlack(event) // やりたい処理はここに書く
        // ここから先は特に意味がない
        return events.APIGatewayProxyResponse{
                StatusCode: http.StatusOK,
                Body:       "OK",
        }, nil
}

func main() {
        lambda.Start(HandleRequest)
}

実装Phase2 JSONの形を見て構造体を定義していく

webhookのテスト送信をしてみたり、実際にイベントを発生させたりしてJSONがどんなものかを見ていきます。

残念ながらBacklogのイベントサンプルは中身があまりアテにならない(後述)ので、なるべく本物のイベントを発生させていきます。

Goにはそれほど慣れていませんが、「Golang JSON」とかで検索すれば具体的にどうやってJSONをGoの構造体に変換できるかを書いているサイトはいくらでも出てきます。

こういう構造体を山のように定義していきます。

この型定義だけでろくにコメントもないのに180行くらいのファイルができました。

type BacklogRequestFormat struct {
        CreatedUser   BacklogUserFormat           `json:"createdUser"`
        Created       time.Time                   `json:"created"`
        Project       BacklogProject              `json:"project"`
        ID            int                         `json:"id"`
        Type          int                         `json:"type"`
        Content       BacklogContentFormat        `json:"content"`
        Notifications []BacklogNotificationFormat `json:"notifications"`
}

Backlogの通知タイプはこの記事を書いている2018/09/08時点で26種類あります。時々増えているようです。

通知タイプごとにJSONの形は多少異なっていて、このあたりで汎用的な1つの通知ロジックで全ての通知をいい感じにするのを諦めました。

実装Phase3 実際に通知する文面を構築するロジックを組んでいく

ここでそれぞれの通知メッセージを組み上げていきます。

1つの通知ロジックで全てを処理するのはかなり厳しそうですが、かといって全てがバラバラというわけでもなく、Issue関連のもの、共有ファイル関連のもの、などはほぼ同様のロジックで実現できるので、それほど大変ではありませんでした。

こういうのはだいたい見栄えと読み易さが大事なので、SlackのAttachment機能を理解し、いい感じの通知が飛ぶように地味な調整を繰り返していきました。

Slackへの通知はslack-go-webhookを使いました。ざっくり以下のようにAttachmentにFieldを追加していきます:

attachment := slack.Attachment{}
attachment.AddField(slack.Field{Title: "Issue", Value: issueField, Short: true})
attachment.AddField(slack.Field{Title: "Type", Value: event.Content.IssueType.Name, Short: true})
attachment.AddField(slack.Field{Title: "Assignee", Value: event.Content.AssigneeString(), Short: false})
attachment.AddField(slack.Field{Title: "Due", Value: event.Content.DueDateString(), Short: true})
attachment.AddField(slack.Field{Title: "Priority", Value: event.Content.Priority.Name, Short: true})
attachment.AddField(slack.Field{Title: "Category", Value: event.Content.Categories(","), Short: true})
attachment.AddField(slack.Field{Title: "Milestone", Value: event.Content.Milestones(","), Short: true})
attachment.AddField(slack.Field{Title: "Description", Value: event.Content.Description})

実装Phase4 通知先を設定するロジックを組んでいく

今回の通知では「各プロジェクトごとに特定のSlack Incoming Webhookを作成し、イベントは全部そこに通知する」ということにしていました(つまり「コメントを通知」のような機能は使わない)。

したがってプロジェクトごとに通知先を振り分ける機能が必要で、今回はその役割をDynamoDBに担ってもらうことにしました。

Webhook requestには必ず project.projectKey というJSON keyが含まれていたので、これDynamoDBのprimary keyにし、Slackのwebhook endpointをDynamoDBに格納することで設定するようにしています。

image

Slackに通知するときの通知先を規定するテーブル

完成!

見た目の善し悪しはともかく、重要そうなデータを含めたものを所定のchannel/groupに通知することができました!

image

Backlogのwebhookのイケてないところ

Backlogのwebhookは概ね適切に処理できますが、なぜこうなった?と思うところがいくつかあるので記載しておきます。

今後実装する人が同じ罠を踏まなくて済むように…

最後の1つは絶対にBacklog側で修正可能なはずなので要望を投稿しましたが、1ヶ月経過した現在のところ反応はありません…残念

content.commentの形が一意でない

Comment        json.RawMessage             `json:"comment"`

ID系がintだったりstringだったりする

  • ほとんどのケースでID系のフィールドはintです。
  • しかしtype=14(課題をまとめて更新)のlink 中ではkey_id とid がintではなくstringとして送信されてきます。
  • 今回はstringとして受けることにしましたが、json.Numberを使うと綺麗に処理できるかもしれません。

camelCaseとsnake_caseがイベントによって異なる

  • content のkeyとしては殆どのケースでcamelCaseです。
  • milestone系のイベント時のみ startDate でなく start_date でJSONが組み立てられています。
    • typeによって content.startDate の場合と content.start_date の場合があるということです
  • 実装では仕方がないので StartDateSnake を定義しています。
    • typeによってはこちらを参照するようにしています。

新しめのwebhook eventはサンプル送信機能がない

  • 「プロジェクトにグループが参加/脱退」イベントはサンプル送信がありません。
  • したがって自分で実際にイベントを発生させてwebhookを動かさないとJSONの形式がわかりません。

archivedフィールドが含まれるテスト送信が失敗する

  • content.milestone[*].archived や content.version[*].archived のようなbool型を含む部分は実際にはJSON bool (たいていfalse )が飛んできます。
  • しかしwebhook設定画面でテスト送信を行うとJSON string型の "false" が飛んできて、boolを期待するUnmarshalは失敗します。テストになりません。

参考サイト

API Gateway + Lambda を使って Backlog 通知を Slack で受け取る

Go で構造の一部が動的に変わる JSON を扱いたい

Goのjson.Unmarshalで値にJSONのnumber、string両方の可能性がある場合