リポジトリ内の古いブランチを issue にリストアップするスクリプトを GitHub Actions で動かす

この記事は、[Supershipグループ Advent Calendar 2022](https://qiita.com/advent-calendar/2022/supership) の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つずつ見るためのコマンドです。今回は次のように実行することにしました。

```shell
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 ファイル

```yaml
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 のスクリプトはこんな感じになっています。ここでは要点のみ説明します。詳細についてはコード内のコメントを参照してください。

```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 は次のようになります。個人名の部分等は隠しています。
![スクリーンショット 2022-12-08 11.20.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18011/840793ab-be04-a869-f9ac-073b6dc33613.png)


人ごとのブランチ一覧はこのように年ごとにさらに分割されます。 `yoshihara` というのは私のことです。

![スクリーンショット 2022-12-08 11.21.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/18011/688b7cf5-eab5-f5d1-bd34-d7542854ad97.png)

（mster ってなんだ……）

初めて実行した時に気づいたのですが、すでに退職した人のブランチもけっこうあって、やっぱり片付けはまず全部の物を出すのが大事なんだな……となっています。ちなみに一番古いブランチは、最終コミットが 2009 年のものでした。

## まとめ

ブランチの掃除をしたくて古いブランチをリストアップするスクリプトを作りました。

共有等のために GitHub issue にリストアップすることにしたので、GitHub Actions を使って実行するようにしました。 `gh` コマンド等で issue 作成等が楽にできるので便利でした。また GitHub Actions で実行することで個人が Personal access token を作成せずとも、リポジトリの管理のためのスクリプトを実行できるのも便利だなと感じています。あとスクリプト自体も個人のマシンに眠らせるのではなく、リポジトリにて管理できるのも属人化を防げてよさそうです。（今回のはさして属人化を避けなくてもいいかもですが）

## **最後に**

Supershipではプロダクト開発やサービス開発に関わる人を絶賛募集しております。ご興味がある方は次のリンクよりご確認ください。

[Supership株式会社 採用情報](https://supership-hd.jp/recruit/)

よろしくお願いします。