Pull Request がマージされたら author に Slack でメンションが飛ぶようにしたらリリースサイクルを短くできた話

# はじめに

この記事は [Opt Technologies Advent Calendar 2019](https://qiita.com/advent-calendar/2019/opt-technologies) 11日目の記事です。
10日目の記事は [@hogeta_](https://twitter.com/hogeta_) さんによる [Colaboratoryを使ったSQLレビューのすヽめ](https://qiita.com/hogeta_/items/0a8e1566be7d00d567aa) 、12日目の記事は [@gcchaan](https://twitter.com/gcchaan) さんによる [serverless-cloudform の紹介](https://qiita.com/gcchaan/items/2c92ea06c7e3cfae1d38) です。

今回は、リリースという作業に思いを馳せつつ、リリースサイクルを短くするための具体的なアプローチを紹介します。
なお、本記事では Web アプリの開発を前提に話を進めます。

# リリースのあるべき姿

## いつすべきか

我々が生み出したソースコードは、以下の理由から可及的速やかにリリースされるべきです。

新機能にせよバグ修正にせよ、ユーザーはアプリケーションがより使いやすく日を待っているはずです。
もし待たれていないなら、そもそもそのプロダクトの在り方から考え直した方が良いでしょう。

また、前回のリリースとの diff が少なければ、仮にバグが発生したとしても原因特定が比較的容易に済むはずです。
原因特定とまではいかなくとも、revert しなければならないマージコミットがどれかくらいはすぐに分かるはずです。

## 誰がすべきか

では、誰がリリースしたら良いのでしょうか？
担当者を1人決めるという方法もありますが、それにはいくつかの問題があります。

まず、その人がボトルネックになってしまう可能性があります。
担当者が他の作業に追われていたり休暇を取っていたりすると、リリース作業が滞ってしまい開発チーム全体の作業に影響を与えてしまうかも知れません。

次に、リリースの手順を正確に知っている人が担当者だけになってしまいます。
例えば担当者が転職していなくなった時、誰もリリースができないという事態に発展しかねません。

また、リリース対象の変更を担当者が熟知しているとは限らないため、リリース後の動作検証に漏れが発生することも考えられます。
その変更を生み出した開発者に聞けばある程度は解消されるとは思いますが、コミュニケーションコストが発生します。
（チーム内で変更内容がきちんと共有されている状態の方が健全では？というツッコミもあるとは思いますが。）

以上より、リリースは特定の誰かではなく、開発メンバー全員が交代で行うべきです。
また、できればリリース対象を開発したメンバーがリリースまでやって欲しいところです。

## 結局どういう運用にしたか

以上を踏まえ、僕のいるプロダクトチームでは **GitHub 上で Pull Request がマージされたら、その Pull Request の author が直ちにリリースする** というルールを定めました。
初めは reviewer が Slack 上で author にマージした旨を伝えてリリースを促すという運用だったのですが、これは煩雑だという話になり、author に通知する部分を CircleCI にやらせることになりました。

# author にメンションを飛ばすスクリプトの紹介

CircleCI の設定はこんな感じです（関係のあるところだけ抜粋）。
master にマージされた直後に Ruby スクリプトを実行するようにしています。

（2020/02/10 追記）
本記事では GitHub のアクセストークンをクエリパラメータとして利用してますが、この使い方は現在非推奨なようです。
https://developer.github.com/changes/2019-11-05-deprecated-passwords-and-authorizations-api/#authenticating-using-query-parameters

```yaml:.circleci/config.yml
workflows:
  main:
    jobs:
      - notify-to-slack-for-approval-prod:
          filters:
            branches:
              only:
                - master
jobs:
  notify-to-slack-for-approval-prod:
    docker:
      - image: circleci/ruby:2.5.3
    steps:
      - checkout:
          path: ~/repo
      - run: ruby ~/repo/.circleci/ruby/notify_to_slack_for_approval_prod.rb
```

で、その中で叩いてる `notify_to_slack_for_approval_prod.rb` はこんな感じです。

```ruby:.circleci/ruby/notify_to_slack_for_approval_prod.rb
require 'net/http'
require 'json'

def github_access_token
  ENV['GITHUB_TOKEN']
end

def hash_of_last_commit_in_master
  ENV['CIRCLE_SHA1']
end

def link_to_circle_ci_workflow
  "https://circleci.com/workflow-run/#{ENV['CIRCLE_WORKFLOW_ID']}"
end

def slack_user_id(github_user_id)
  id_map = {
    999999 => 'XXXXXXXXX',
    999999 => 'XXXXXXXXX',
    999999 => 'XXXXXXXXX'
  }
  id_map[github_user_id]
end

def author_id_of_last_merged_pull_request
  uri = URI.parse('https://api.github.com/search/issues')
  params = {
    q: "repo:opt-tech/xxxxx is:pr is:merged base:master #{hash_of_last_commit_in_master}",
    access_token: github_access_token
  }
  uri.query = URI.encode_www_form(params)
  request = Net::HTTP::Get.new(uri)
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = (uri.scheme == 'https')
  response = http.request(request)

  raise 'GitHub APIの呼び出しに失敗しました' if response.code != '200'

  JSON.parse(response.body)['items'][0]['user']['id']
end

def send_slack_message
  uri = URI.parse('https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXX/xxxxxxxxxxxxxxxxxxxxxxxx')
  user_id = slack_user_id(author_id_of_last_merged_pull_request)
  mention = user_id ? "<@#{user_id}>" : '<!subteam^XXXXXXXXX|@dev_team>'
  text = <<TEXT
  #{mention}
  本番環境へのリリースを承認してください。
  リリース手順: https://path/to/document
  Workflow: #{link_to_circle_ci_workflow}
TEXT
  params = { text: text }
  request = Net::HTTP::Post.new(uri.path, 'Content-type' => 'application/json')
  request.body = params.to_json
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = (uri.scheme == 'https')
  response = http.request(request)

  raise 'Slack APIの呼び出しに失敗しました' if response.code != '200'
end

send_slack_message
```


それではスクリプトの詳しい説明をします。
まずは `require` から。

```ruby
require 'net/http'
require 'json'
```

[net/http](https://docs.ruby-lang.org/ja/latest/library/net=2fhttp.html) は HTTP を扱う標準ライブラリ、[json](https://docs.ruby-lang.org/ja/latest/library/json.html) は JSON を扱う標準ライブラリです。
Ruby は様々なライブラリが言語本体と同梱されているので、この手のスクリプトを書く時は非常に便利です。

```ruby
def github_access_token
  ENV['GITHUB_TOKEN']
end

def hash_of_last_commit_in_master
  ENV['CIRCLE_SHA1']
end

def link_to_circle_ci_workflow
  "https://circleci.com/workflow-run/#{ENV['CIRCLE_WORKFLOW_ID']}"
end
```
これらのメソッドは環境変数を参照しています。

`GITHUB_TOKEN` は自力で作成し、CircleCI の Environment Variables に予めセットしておく必要があります。
トークンの作り方は[こちら](https://help.github.com/ja/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line)。
権限は repo を全てとかでいけそうな気がします。

`CIRCLE_SHA1` と `CIRCLE_WORKFLOW_ID` は、CircleCI が用意してくれています。
前者が最後のコミットハッシュ、後者が CircleCI のワークフローの ID です（[参考](https://circleci.com/docs/2.0/env-vars/#built-in-environment-variables)）。
これらをどう使うかは後述します。

```ruby
def slack_user_id(github_user_id)
  id_map = {
    999999 => 'XXXXXXXXX',
    999999 => 'XXXXXXXXX',
    999999 => 'XXXXXXXXX'
  }
  id_map[github_user_id]
end
```
GitHub の user ID を Slack の user ID にマッピングするメソッドです。
Slack 上で Pull Request の author にメンションを飛ばすときに必要になります。
GitHub の user ID は[このサイト](https://caius.github.io/github_id/)とかで取得できます。
Slack の user ID は、Slack 上で ID が欲しいユーザーのプロフィールを開き、「通話を開始」の右側にあるボタンを押すと出てきます。

```ruby
def author_id_of_last_merged_pull_request
  uri = URI.parse('https://api.github.com/search/issues')
  params = {
    q: "repo:opt-tech/xxxxx is:pr is:merged base:master #{hash_of_last_commit_in_master}",
    access_token: github_access_token
  }
  uri.query = URI.encode_www_form(params)
  request = Net::HTTP::Get.new(uri)
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = (uri.scheme == 'https')
  response = http.request(request)

  raise 'GitHub APIの呼び出しに失敗しました' if response.code != '200'

  JSON.parse(response.body)['items'][0]['user']['id']
end
```

最後にマージされた Pull Request の author の GitHub user ID を取得するメソッドです。
Pull Request を検索する API を叩いていますが、ここで先に紹介した GitHub のトークンとコミットハッシュが必要になります。
API を叩く時のパラメータは[この記事](https://moznion.hatenadiary.com/entry/2018/08/18/154442)とかを参考にしました。
返ってくる Pull Request は1つしかないはずなので、 `JSON.parse(response.body)['items'][0]['user']['id']` に目的の ID が入っていると決め打っています。

```ruby
def send_slack_message
  uri = URI.parse('https://hooks.slack.com/services/XXXXXXXXX/XXXXXXXX/xxxxxxxxxxxxxxxxxxxxxxxx')
  user_id = slack_user_id(author_id_of_last_merged_pull_request)
  mention = user_id ? "<@#{user_id}>" : '<!subteam^XXXXXXXXX|@dev_team>'
  text = <<TEXT
  #{mention}
  本番環境へのデプロイを承認してください。
  リリース手順: https://path/to/document
  Workflow: #{link_to_circle_ci_workflow}
TEXT
  params = { text: text }
  request = Net::HTTP::Post.new(uri.path, 'Content-type' => 'application/json')
  request.body = params.to_json
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = (uri.scheme == 'https')
  response = http.request(request)

  raise 'Slack APIの呼び出しに失敗しました' if response.code != '200'
end
```

GitHub user ID を元に Slack にメッセージを投稿するメソッドです。
Slack にメッセージを投稿する方法はいくつかありますが、今回は [Incoming Webhook](https://slack.com/intl/ja-jp/help/articles/115005265063) を利用しています。
こいつは URL 自体がトークンっぽい役割を担ってくれているので、GitHub の時みたいに環境変数にトークンを仕込んでそれを使うとかしなくて大丈夫です。
まず、GitHub の API から Pull Request の author の user ID を取得し、それを Slack の user ID に変換します。
次に、その ID を元に、author にメンションを飛ばしつつリリースを促すメッセージを構築します。
もし Slack user ID の取得に失敗した（取得した GitHub user ID が未知のものであった）場合は、開発チーム全体にメンションが飛ぶようにします。
（メンションの飛ばし方は[こちら](https://api.slack.com/reference/surfaces/formatting)。個人的な感想ですが、結構分かりにくいです。）
このプロダクトでは CircleCI のワークフロー上で（一部だけ）リリースができるようになっているので、投稿するメッセージには CircleCI のワークフローへのリンクも記載しています。
後は API を叩いて完了です。

これで Pull Request の author に通知を飛ばせるようになりました。

# 運用してみた所感

今年の9月頃からこの運用を継続していますが、概ね好印象です。
それまでは誰かが気を回さないと放置されがちだったリリース作業が迅速に行われるようになった気がします。
誰がやるのかわからなくなることもほとんどなくなりました。
小さい Pull Request がいくつもマージされる日は Slack 上での通知も頻繁に飛ぶようになるので、それが結構鬱陶しいのではないかと危惧していましたが、個人的にはそんなに気になりませんでした。

# 今後の展望

短い期間でリリースを繰り返すと、リリース1回にかかる時間の長さがネックになってきます。
なので、今後は CircleCI のワークフロー上で全てのリリース作業を完結できるようにすることで、ワンクリックでリリースが完了するような状況を作りたいと考えています。

# 最後に

リリースというものは開発と比較して軽視されがちかも知れませんが、プロダクト開発においては必須の作業です。
こういった作業を効率化することで間接的にプロダクトの成長に貢献するというのも、それなりに大切な視点だと思います。
リリースフローを見直してみるのも、悪くないんじゃないでしょうか。