この記事は、Supershipグループ Advent Calendar 2022 の15日目の記事になります。
「ちょっとしたスクリプトの置き場として GitHub Actions を活用してみた」という感じの内容になっています。
はじめに
私が開発に参加しているプロダクトはもう10年以上開発が続いています。そのリポジトリにあるリモートブランチのうち、一部はもう使われないまま放置されてしまっています。これだと、CI 実行時の checkout 等で全ブランチの情報を取得するのに余計な時間がかかってしまいます。一方で、作業のバックアップ等で置いておきたいブランチもあり、その判断はブランチを作成した本人にしかできないはずです。
というわけで、ある程度古いブランチを人ごとに分類してリストアップし、それを個人ごとに確認してもらうとよさそう、と考え、そのリストアップを自動でやってみました。
どこにリストアップするか
色々考えて、GitHub の issue が一番よさそう、となりました。
私だけかもですが、ブランチ名だけ見せられても「これ何のブランチだっけ?調べるの面倒だなぁ」となりそうなので、そのブランチを使った PR があればその PR の情報も併記したかったからです。issue であれば、PR の番号を書いておけば勝手にリンクにしてくれます。
またリストアップ結果は開発者同士で共有するものなので、使い慣れている GitHub issue が一番とっつきやすそう、というのもあります。
どうやってリストアップするか
ブランチは大量にあることが予想されるため、リストアップ処理は自動でやりたいです。とすると、上述の PR の情報取得等も自動で行うことになります。つまり GitHub の API 等を使う必要が出てくるのですが、 Personal access token の用意はあまりやりたくありません。扱いを慎重にする必要があるのと、あと掃除するのに前準備が必要だと掃除自体がされなくなるので、なるべく楽にやりたいです。
というわけで GitHub Actions(の GitHub hosted runner)を使うことにしました。GitHub Actions なら、GITHUB_TOKEN
という API を叩いたりするための token を自動で用意して workflow の実行が終われば自動で破棄してくれます。また workflow_dispatch
で実行するようにすればブラウザ上で操作が完結します。毎日使うようなスクリプトでもないため、「使おうと思った時には動かし方を忘れている」可能性が高く、なるべく楽に実行するために GitHub Actions にすることにしました。
なお、ブランチが紐づく PR の情報取得については、 GitHub の CLI である gh
コマンドを使うことにしました。これは GitHub hosted runner だと標準でインストールされており、環境変数の GITHUB_TOKEN
に token をセットしておけば対象リポジトリの操作が可能です。API を叩くよりも手軽で、今回やりたいことはすべて賄えるので、今回は gh
コマンドを使うことにしました。
「ブランチの作成者」の定義
問題となるのが、「ブランチの作成者はどうやって取得するか」です。結論から言うと不可能で、リポジトリにその情報が残っていません。そのため、便宜上、「そのブランチの最終コミットの作成者」を便宜上ブランチの作成者としました。複数人でコミットしているブランチだとズレが生じるのですが、大体のパターンではこれで問題ないはずです。(逆に「最初のコミットの作成者」でもいいかもしれません)
ちなみに、「古いブランチ」の「古い」は「ブランチへの最終コミットが古い」としています。この辺はまあそんなに直感とズレないはずです。多分。
リストアップ処理について
上述の定義なので、ブランチの作成者と古さについては、「そのブランチの最終コミットの作成者とコミット日時」を見ればいいことがわかります。これを取得するために git
の for-each-ref
コマンドを使います。 for-each-ref
はその名の通り ref(コミットを間接的に参照するもの。ブランチ名とか)を1つずつ見るためのコマンドです。今回は次のように実行することにしました。
git for-each-ref --format='%(refname),%(authorname),%(committerdate)' refs/remotes/origin
これを実行すると、リモートの origin
にある各ブランチを、 ブランチ名,作成者,コミット日時
のフォーマットで出力します。ちなみに refs/remotes/origin
の部分を refs/tags/
に置き換えれば、タグで同じことができます。
次に、コミット日時でフィルタリングして作成者ごとにブランチを分類して……という処理をします。ここからシェルスクリプトで……と最初は思ってたんですが、結構やることが大変だったので Ruby スクリプトにしました。Ruby にしたのは、このプロダクトが Rails でできており、開発メンバーが全員触りやすい言語だからです。
issue の作り方
上述の通り gh
コマンドを使って issue を作ります。
ただ、試しに issue の本文にリストアップ結果をすべて載せようとしたら文字数制限をオーバーしてしまいました。(上限は確か6万文字ぐらいだったはずで、 gh
コマンドがエラーメッセージで教えてくれます)
そのため、issue の本文には説明と作成者の一覧を載せ、実際のブランチの一覧は作成者ごとにコメントで載せるようにしました。よくよく考えると、ブランチ作成者に対して permalink でブランチのリストを渡せるので、この方がその後の削除作業もやりやすい気がしています。
ここからは実際の GitHub Actions の workflow ファイルと Ruby スクリプトを載せ、それぞれに上記で触れていない細かい解説をしていきます。
workflow ファイル
name: create issue for stale branches
on:
workflow_dispatch:
jobs:
create_branch_list_issue:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: read
steps:
- uses: ruby/setup-ruby@v1
with:
ruby-version: 2.7
bundler-cache: false # 不要な bundle install を実行しないように false にしておく
- uses: actions/checkout@v3
with:
ref: master
# 実行しているスクリプトの使い方はスクリプト内のコメントを参照
- name: create issue
run: |
./.github/workflows/bin/generate_stale_branches_list.rb ./.github/workflows/resources/author_name_identification_for_github.csv
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
workflow の設定
先述の通り、workflow の 発火の種類は workflow_dispatch
にしておきます。こうすることでリポジトリの Actions
タブを開いて手動実行できます。もし定期的に実行したい場合は cron
を使うとよさそうです。(今回はまだ様子見なので設定せず)
job の設定
次に job の設定に入りますが、pemissions
は GITHUB_TOKEN
の権限を設定しています。素のままだとリポジトリの操作をなんでもできてしまって危険なので、今回必要なものだけにしています。(とはいえ、実際のところ contents: read
できてしまえば十分なんでもできてそうな気も個人的にはしていますが、使う action 等に気を配れば、下手に個人で personal access token を発行したりするよりはマシかなあ……と思っています)
timeout
は workflow の実行に想定外に時間がかかった時、強制的に終了させるための設定です。おもに課金回りのためです。
各 step について
まず Ruby のセットアップをしています。Ruby は 2.7 を使ってますがこれはプロダクトで使っている Ruby のバージョンと合わせて、後述しますがローカルでの動作確認時に追加の ruby インストールを不要にしているからです。
次にリポジトリを checkout して、実行したい Ruby スクリプトを取得しています。
最後にこの Ruby スクリプトを実行しています。スクリプト内部で gh
コマンドを叩くため、 GITHUB_TOKEN
環境変数を設定しています。引数で渡しているファイルもありますが、これの使い方についても後述します。
ところで、この Ruby スクリプトや渡している引数のファイルについてはそれぞれ .github/workflows/bin/
や .github/workflows/resources/
に置いているのですが、何かルールがあったりするんですかね?とりあえず不便はないのでこうしてます。
Ruby スクリプト
Ruby のスクリプトはこんな感じになっています。ここでは要点のみ説明します。詳細についてはコード内のコメントを参照してください。
#!/usr/bin/env ruby
# リポジトリのブランチ掃除用のスクリプト。
# 今年に入ってからコミットが積まれていないブランチを洗い出し、作成人別に出力する。
# PR が作られているものはその PR の番号も表示する。
#
# 便宜上、ブランチの先頭コミットの作成者(作成日時)を、そのブランチの作成者(最終更新日時)としてこのスクリプトでは扱う。
# 他人のブランチにコミットを乗せただけの場合等にズレが生じるが、ブランチ自体の情報がほぼ取得できないためこのようにしている。
#
# 名寄せ用のファイルは CSV フォーマットで、1列目に名寄せ前の名前、2列目に名寄せ後の名前を記載する。
# 例えば、 yoshihara,takahashi とした場合、yoshihara が最後にコミットを積んだブランチは takahashi のブランチとして表示する。
require "English"
require "json"
require "shellwords"
require "time"
# 任意のブランチを示すクラス
class Branch
attr_reader :name, :author_name, :date
# @param name [String] ブランチ名
# @param author_name [String] ブランチ作成者名
# @param date [Time] ブランチ作成日時
def initialize(name, author_name, date)
@name = name
@author_name = author_name
@date = date
end
# 必要なブランチ情報を取得するためのコマンドを実行する。
#
# @param identifications [Hash] 名寄せの法則を表すデータ
# @return [Array<Branch>] 取得したブランチを表す Branch オブジェクトの配列
def self.get_all_branches_info_from_repository!(identifications)
branch_info_command = <<~COMMAND
git fetch -p && git for-each-ref --format='%(refname),%(authorname),%(committerdate)' refs/remotes/origin
COMMAND
result = `#{branch_info_command}`.chomp
result.lines.map do |line|
raw_name, raw_author_name, raw_date = *line.chomp.split(",")
name = raw_name.delete_prefix("refs/remotes/origin/")
author_name = identifications.fetch(raw_author_name, raw_author_name)
date = Time.parse(raw_date)
new(name, author_name, date)
end
end
# markdown で表示する用に整形した文字列を返す。
#
# @return [String] markdown 文字列
def to_markdown
"- [ ] #{name}"
end
end
# 任意の PR を表すクラス
# NOTE: 格納するデータはすべて gh コマンドの結果を想定
class Pr
attr_reader :branch_name, :number
# すべての PR の情報を GitHub から取得する。
#
# @return [Array<Pr>] 取得した PR を表すオブジェクトの配列
def self.get_all_prs_info!
# NOTE: 5000 は PR の総数をカバーする程度の数値
result = `gh pr list -s all --json headRefName,number -L 5000`.chomp
JSON.parse(result).map do |hash|
new(hash["headRefName"], hash["number"])
end
end
# @param branch_name [String] PR のトピックブランチ名
# @param number [Integer] number
def initialize(branch_name, number)
@branch_name = branch_name
@number = number
end
end
# 結果を書き込む issue を表すクラス
class Issue
# issue に書く固定の説明
DESCRIPTION = <<~DESCRIPTION.freeze
ブランチ棚卸しのために作られた issue です。
作成者別にコメントが分かれているので、具体的なブランチ名等はそちらを参照してください。
チェックボックスは適宜使用してください。
DESCRIPTION
attr_reader :url
# @param url [String] issue の URL
def initialize(url)
@url = url
end
# 結果を書くための issue を作成する。
#
# @param extra_description [String] 固定の説明の後ろに追加する説明
# @return [Issue] 作成した issue に対応するオブジェクト
def self.create(extra_description)
description = [DESCRIPTION, extra_description].join("\n")
url = `gh issue create --title "最終コミットが去年のブランチ一覧" --body "#{description}"`.chomp
new(url)
end
# issue にコメントを書き込む。
#
# @param body [String] コメントする文章
def add_comment(body)
# NOTE: バッククォートでコマンドを実行するとエラー出力が取れず、
# またコメント作成はその後の処理に影響を出さないため、失敗しても理由がわからない。
# そのためエラー出力を標準出力にマージしている。
puts `gh issue comment #{url} --body #{body.shellescape} 2>&1`
end
end
# 今年の 1/1 00:00 をブランチを出力対象にするかどうかの基準にする
OLD_DATETIME = Time.new(Time.now.year, 01, 01, 00, 00, 00, "+09:00")
# ここからスクリプト本体
if ARGV.empty?
puts "名寄せルールを書いたファイルを指定してください。GitHub issue ではなくファイルに出力したい場合は、2つ目の引数にそのファイル名を指定してください。"
exit(false)
end
puts "ブランチ選出基準: 最終コミット日時が #{OLD_DATETIME} より前"
puts "ブランチ取得"
# 名寄せファイルのパース
name_identifications_file_path = ARGV[0]
identifications = File.read(name_identifications_file_path).lines.map { _1.chomp.split(",") }.to_h
all_branches = Branch.get_all_branches_info_from_repository!(identifications)
target_branches = all_branches.select { _1.date < OLD_DATETIME }
target_branches.sort_by! { _1.author_name }
puts "PR の情報取得"
prs = Pr.get_all_prs_info!
begin
output = nil
authors_list = target_branches.map { _1.author_name }.uniq.map { "- [ ] #{_1}"}.join("\n")
if ARGV[1]
output = File.new(ARGV[1], "w")
output.puts(authors_list + "\n\n")
else
output = Issue.create(authors_list)
end
puts "出力: #{output}"
if output.is_a?(Issue)
flush = -> (body) { output.add_comment(body) }
else
flush = -> (body) { output.puts(body) }
end
# NOTE: 作成者ごとにコメントしているが、たまたま量が多かった時にそうしたため、通常はそこまでしなくてもいいかもしれない
target_branches.group_by { _1.author_name }.each do |author, author_branches|
body = "## #{author}\n\n"
author_branches.sort_by { _1.date }.group_by { _1.date.year }.each do |year, branches|
body << "### #{year}\n\n"
branches.sort_by { _1.name }.each do |branch|
body << branch.to_markdown
pr = prs.detect { _1.branch_name == branch.name }
body << " ##{pr.number}" if pr
body << "\n"
end
body << "\n"
end
flush.call(body)
end
ensure
output.close if output.is_a?(File)
end
全体の処理の流れについて
Branch
, Pr
, Issue
といったクラスを定義して処理を分け、最後にそれらを使ってブランチ情報取得→古いものに絞り込み→ PR 情報取得→出力、という流れで実装しています。
本来した方がよさそうなエラーハンドリング等はしていないので適当感は否めませんが、まあ開発者が実行するものだし開発者ならエラーは読めるはずなので、あまり手を入れすぎないようにしています。
引数の使い方
Ruby スクリプトに渡していた引数の使い方ですが、これはコミットの author の名寄せのためです。コミットの author は、コミットしたマシンにある ~/.gitconfig
の name 設定になったり GitHub のアカウント名になったりと、コミット作成状況によってさまざまです。(後者は GitHub 上での Merge ボタンを使ったマージ等の時になります)
そのため、単純に git for-each-ref
で author 名を取得すると1人の開発者で複数の名前が該当することになり、それらをすべて同一の名前として扱うために名寄せ用のファイルを用意しています。中身は(Ruby スクリプトの先頭のコメントにもある通り)カンマ区切りで用意します。
gh コマンドの使いどころについて
gh
コマンドは上述の通り issue やそのコメントの作成に使っているのですが、ブランチから PR の情報を取得するのにも使っています。と言っても直接引っぱってこれるわけではなく、 gh pr list -s all --json headRefName,number -L 5000
と実行してブランチ名と PR 番号の組み合わせを示す JSON 文字列を取得し、それを Ruby でパースして使っています。 5000
の部分は PR の総数より十分多い数にしています。(今考えると来年実行した時用にもっと大きな数字にしておいた方がよかったかもしれない。。)
デバッグ用の実装
上記のコードを見ると、issue にリストアップすると言いつつファイル書き出しもサポートしているのですが、これはローカルで動作確認する時の処理です。リストアップ部分の結果を確認したい時に毎回 issue を作りたくなかったので、結果をファイルに書き出せるようにしました。
余談
実装について余談ですが、 Issue
クラスに puts
メソッドを生やせば、 flush
という lambda
を定義する必要はなくなります。ただ、一回それでやったらその puts
メソッドの中でデバッグしようと( Kernel
の方のを呼びたくて) puts
メソッドを書いて動かしてしまい、結果として issue に無限にコメントを追加する悲惨な事態になったのでやめました。ちなみにその時は slack への通知で気づいたので、慌てて GitHub の workflow 実行画面から実行停止をして止めました。
作成された issue
実際に実行した結果の issue は次のようになります。個人名の部分等は隠しています。
人ごとのブランチ一覧はこのように年ごとにさらに分割されます。 yoshihara
というのは私のことです。
(mster ってなんだ……)
初めて実行した時に気づいたのですが、すでに退職した人のブランチもけっこうあって、やっぱり片付けはまず全部の物を出すのが大事なんだな……となっています。ちなみに一番古いブランチは、最終コミットが 2009 年のものでした。
まとめ
ブランチの掃除をしたくて古いブランチをリストアップするスクリプトを作りました。
共有等のために GitHub issue にリストアップすることにしたので、GitHub Actions を使って実行するようにしました。 gh
コマンド等で issue 作成等が楽にできるので便利でした。また GitHub Actions で実行することで個人が Personal access token を作成せずとも、リポジトリの管理のためのスクリプトを実行できるのも便利だなと感じています。あとスクリプト自体も個人のマシンに眠らせるのではなく、リポジトリにて管理できるのも属人化を防げてよさそうです。(今回のはさして属人化を避けなくてもいいかもですが)
最後に
Supershipではプロダクト開発やサービス開発に関わる人を絶賛募集しております。ご興味がある方は次のリンクよりご確認ください。
よろしくお願いします。