やること
デプロイ用のプルリクを作るときにmerge済みプルリクの一覧がみたいなというのとslackからコマンドでプルリクを作成したいなというモチベーションからslackコマンドでデプロイ用のプルリクを作成するBotを作ることにしました。
成果物
要件
- 特定環境にdeployするためのプルリクを作成できる
- プルリクのdescriptionにmerge元ブランチにmergeされた差分プルリクが一覧化される
- slackからコマンド実行できる
技術選定
普段業務でGAE/Go SEは扱っているが、GAE/Ruby SEがベータ版になって使っていなかったので試しに採用してみました。またSinatraを使ったことがなかったのと簡単なツールを作るための軽量フレームワークが良さそうだなと思い使ってみることにしました。slackのコマンドはSlashCommandsというプラグインを使っています。
- GAE/Ruby SE
- Sinatra
- Slash Commands
実装
1. GAEの設定ファイルをかく
まずはGAEにdeployするための設定ファイルを作成します。ベータ版は2.5系のRubyがランタイムになっているのでruby25を指定してサービス名はリポジトリ名に合わせておきます。この時初めてGCPプロジェクトにdeployする場合はデフォルトサービスに何かしらdeployしておく必要があります。なので当該サービス以外をdeployする予定がない場合は指定しなくて良いです。
runtime: ruby25
service: pr-bot
entrypoint: bundle exec ruby app.rb
includes:
- env.yaml
2. envにGITHUBのアクセストークンを設定する
app.yamlで指定したincludes部分は環境変数を設定するファイルを定義しています。GitHubで取得したアクセストークンをこちらに定義します。またcommitされないようにgitignoreに追加しておきましょう。
env_variables:
GITHUB_ACCESS_TOKEN: xxxxxxxxxxx
$ echo "env.yaml" >> .gitignore
3. Rubyバージョンを2.5系にする
ランタイムに合わせてRubyのバージョンを設定しておきます。rbenvを使っていたので次の通り設定しました。
$ rbenv local 2.5.5
4. gem追加
今回使ってみることにしたsinatraと定数管理はconfigを使ってGithubクライアントようにoctokitを使っています。
source 'https://rubygems.org'
gem 'sinatra'
gem 'sinatra-contrib'
gem 'config'
gem "octokit", "~> 4.0"
5. 定数管理
コマンドでprを作るmerge元ブランチとmerge先ブランチを定義しておきます。
organization:
name: konchanxxx
repos:
pr-bot:
from: master
to: release/production
6. リクエストハンドラ
ここから急に説明が雑になりますが リクエストハンドラを実装します。主にやっているのはslackコマンドで受け取った引数をパースしてリポジトリとmerge先、merge元を設定してプルリクを作成しています。丁寧に実装するならハンドラにロジックを書かずに業務ロジックを集約するアプリケーション層を実装してあげると良いと思います。
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. 各モジュールの追加
ハンドラで利用しているロジックを追加しておきます。
require 'octokit'
class Client
class << self
def new
@client ||= Octokit::Client.new access_token: ENV['GITHUB_ACCESS_TOKEN']
end
end
end
プルリクを扱うモジュール。ここでmergeされたプルリクエストの差分だけを抽出してプルリクのdescriptionに設定するようにしています。
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
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に定義しているので下記のコマンドを実行するだけで大丈夫です。
$ gcloud app deploy
9. Slash Commandsの設定
slackからコマンド実行するためにslash commandsというアプリを追加します。
URLの箇所にデプロイしたGAEインスタンスのhostを追加します。
10. slackから実行してみる
slask commandsの設定でコマンドは自由に設定することができます。今回はdeployというコマンドにしました。
11. プルリクが作成されたことを確認する
GitHubの対象リポジトリでプルリクが作成されていることを確認します。mergeされたプルリクのリンクもつけているので良い感じにmerge対象のものを確認することができます。
感想
GAE/RubyとSinatraを作ってかなり手軽にBotを作成することができました。簡単なツールはこの組み合わせで作ると楽かもしれません。以前Cloud Runで似たようなツールを作ったことがありましたがdockerイメージを作らないような場合だとこちらの方が手軽かもしれません。あと次はHanamiとかも使ってみたいと思います