GAE/Ruby SEでつくるDeployプルリク作成Bot

# やること

デプロイ用のプルリクを作るときにmerge済みプルリクの一覧がみたいなというのとslackからコマンドでプルリクを作成したいなというモチベーションからslackコマンドでデプロイ用のプルリクを作成するBotを作ることにしました。

# 成果物

https://github.com/konchanxxx/pr-bot

# 要件

- 特定環境にdeployするためのプルリクを作成できる
- プルリクのdescriptionにmerge元ブランチにmergeされた差分プルリクが一覧化される
- slackからコマンド実行できる

# 技術選定

普段業務でGAE/Go SEは扱っているが、GAE/Ruby SEがベータ版になって使っていなかったので試しに採用してみました。またSinatraを使ったことがなかったのと簡単なツールを作るための軽量フレームワークが良さそうだなと思い使ってみることにしました。slackのコマンドはSlashCommandsというプラグインを使っています。:rocket:

- GAE/Ruby SE
- Sinatra
- Slash Commands

# 実装

## 1. GAEの設定ファイルをかく

まずはGAEにdeployするための設定ファイルを作成します。ベータ版は2.5系のRubyがランタイムになっているのでruby25を指定してサービス名はリポジトリ名に合わせておきます。この時初めてGCPプロジェクトにdeployする場合はデフォルトサービスに何かしらdeployしておく必要があります。なので当該サービス以外をdeployする予定がない場合は指定しなくて良いです。

```rb:app.yaml
runtime: ruby25

service: pr-bot

entrypoint: bundle exec ruby app.rb

includes:
  - env.yaml
```

## 2. envにGITHUBのアクセストークンを設定する

app.yamlで指定したincludes部分は環境変数を設定するファイルを定義しています。GitHubで取得したアクセストークンをこちらに定義します。またcommitされないようにgitignoreに追加しておきましょう。

[GitHubアクセストークン](https://help.github.com/ja/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line)

```rb:env.yaml
env_variables:
  GITHUB_ACCESS_TOKEN: xxxxxxxxxxx
```

```sh
$ echo "env.yaml" >> .gitignore
```

## 3. Rubyバージョンを2.5系にする

ランタイムに合わせてRubyのバージョンを設定しておきます。rbenvを使っていたので次の通り設定しました。

```sh
$ rbenv local 2.5.5
```

## 4. gem追加

今回使ってみることにしたsinatraと定数管理はconfigを使ってGithubクライアントようにoctokitを使っています。

```rb:Gemfile
source 'https://rubygems.org'

gem 'sinatra'
gem 'sinatra-contrib'

gem 'config'

gem "octokit", "~> 4.0"
```

## 5. 定数管理

コマンドでprを作るmerge元ブランチとmerge先ブランチを定義しておきます。

```yaml:config/settings.yml
organization:
  name: konchanxxx
  repos:
    pr-bot:
      from: master
      to: release/production
```

## 6. リクエストハンドラ

~~ここから急に説明が雑になりますが~~ リクエストハンドラを実装します。主にやっているのはslackコマンドで受け取った引数をパースしてリポジトリとmerge先、merge元を設定してプルリクを作成しています。丁寧に実装するならハンドラにロジックを書かずに業務ロジックを集約するアプリケーション層を実装してあげると良いと思います。

```rb:app.rb
require 'sinatra'
require 'sinatra/reloader'
require 'octokit'
require 'config'
require_relative 'src/client'
require_relative 'src/repository'
require_relative 'src/pull_request'

set :root, File.dirname(__FILE__)
register Config

get '/' do
  repository_name, from, to = params[:text].split
  organization = Settings.organization.name
  repository = Repository.new(organization, repository_name)
  repository_full_name = repository.repository_full_name

  from ||= repository.default_merge_from
  to ||= repository.default_merge_to

  begin
    res = PullRequest.create(repository_full_name, to, from)
    status 200

    text = "Successfully created a pull request!! :rocket:\n#{res['url']}"
  rescue Octokit::UnprocessableEntity => e
    status 200
    STDOUT.puts "Failed to create pull request. err=#{e}"

    text = 'Failed to create pull request. pull request already exists. :poop:'
  rescue StandardError => e
    status 500
    text = "failed to create pull request. err=#{e}"
  end

  headers \
    'Content-Type' => 'application/json'
  body res(text).to_json
end

def res(text)
  {
      text: text,
      response_type: 'in_channel'
  }
end
```

## 7. 各モジュールの追加

ハンドラで利用しているロジックを追加しておきます。

```rb:src/client.rb
require 'octokit'

class Client
  class << self
    def new
      @client ||= Octokit::Client.new access_token: ENV['GITHUB_ACCESS_TOKEN']
    end
  end
end
```

プルリクを扱うモジュール。ここでmergeされたプルリクエストの差分だけを抽出してプルリクのdescriptionに設定するようにしています。

```rb:src/pull_request.rb
require_relative 'client'

class PullRequest
  MERGE_PR_MESSAGE_REGEXP = /Merge pull request #(?<number>\d+) .*/.freeze
  attr_reader :number, :title, :link

  def initialize(number, title, link)
    @number = number
    @title = title
    @link = link
  end

  class << self
    def create(repo, to, from)
      title = deployment_title(to, from)
      body = deployment_description(repo, to, from)

      client.create_pull_request(repo, to, from, title, body)
    end

    def merged_pull_requests(repo, to, from)
      client.compare(repo, to, from).attrs[:commits].map do |d|
        m = d.attrs[:commit][:message].match(MERGE_PR_MESSAGE_REGEXP)
        next if m.nil?

        pull_request = client.pull_request(repo, m[:number]).attrs
        new(pull_request[:number], pull_request[:title], pull_request[:html_url])
      end.compact
    end

    def deployment_title(to, from)
      "deploy #{from} to #{to}"
    end

    def deployment_description(repo, to, from)
      pull_requests = merged_pull_requests(repo, to, from)

      links = pull_requests.map do |pr|
        "- [#{pr.title}](#{pr.link})"
      end.join("\n")

      <<~DESCRIPTION
        deploy #{repo} from #{from} to #{to} as follows... :rocket:

        #{links}
      DESCRIPTION
    end

    private

    def client
      @client ||= Client.new
    end
  end
end
```

```rb:src/repository.rb
class Repository
  attr_accessor :organization, :repository_name

  def initialize(organization, repository_name)
    @organization = organization
    @repository_name = repository_name
  end

  def repository_full_name
    "#{organization}/#{repository_name}"
  end

  def default_merge_from
    Settings.organization.repos.send(repository_name).from
  end

  def default_merge_to
    Settings.organization.repos.send(repository_name).to
  end
end
```

## 8. GAE/Rubyにデプロイ

gcloud SDKを使ってGAEにデプロイします。デプロイに必要な情報はapp.yamlに定義しているので下記のコマンドを実行するだけで大丈夫です。

```sh
$ gcloud app deploy
```

## 9. Slash Commandsの設定

slackからコマンド実行するためにslash commandsというアプリを追加します。
URLの箇所にデプロイしたGAEインスタンスのhostを追加します。

## 10. slackから実行してみる

slask commandsの設定でコマンドは自由に設定することができます。今回はdeployというコマンドにしました。

<img width="648" alt="スクリーンショット 2019-12-22 21.55.26.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/87070/c82b23aa-e695-aa1c-92c9-ac4056440300.png">

## 11. プルリクが作成されたことを確認する

GitHubの対象リポジトリでプルリクが作成されていることを確認します。mergeされたプルリクのリンクもつけているので良い感じにmerge対象のものを確認することができます。

![スクリーンショット 2019-12-22 22.19.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/87070/ccf9f255-05b0-7e02-b96f-2f59810d51b6.png)

# 感想

GAE/RubyとSinatraを作ってかなり手軽にBotを作成することができました。簡単なツールはこの組み合わせで作ると楽かもしれません。以前Cloud Runで似たようなツールを作ったことがありましたがdockerイメージを作らないような場合だとこちらの方が手軽かもしれません。あと次はHanamiとかも使ってみたいと思います:bow: