STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

たまってしまった .rubocop_todo.yml をGitHub Actionsで継続的かつ自動的に倒す方法

こんにちは。heyのCTOをやっている藤村です。

実はCTOになる前はSTORESのRailsのコードを改善する仕事をしていました。その頃に、たまってしまっている.rubocop_todo.ymlをなんとか手間をかけずに消化していきたいな〜と思い、少しづつ自動的に消化する仕組みを作りました。この記事ではその仕組みをご紹介します。

rubocop_todo.yml とは

既存のコードベースに対してRuboCopを適用すると大量の違反箇所が出てしまい使い物にならないという問題があります。それの解決策として、既存のコードで違反しているファイルを無視する設定を .rubocop_todo.yml というファイルに保存して .rubocop.yml で読み込み、既にある違反はいったん無視する、という方法が用意されています。

Configuration - RuboCop: The Ruby Linter that Serves and Protects

ファイルはrubocop --auto-gen-configで生成されます。実際の .rubocop_todo.yml はこのような様子になります(実物はもっとメチャクチャに長いです)。

# Offense count: 18
# Configuration parameters: AllowSafeAssignment.
Lint/AssignmentInCondition:
  Exclude:
    - 'app/controllers/application_controller.rb'
    - 'app/controllers/auth_controller.rb'
    - 'app/controllers/orders_controller.rb'
    - 'app/decorators/store_decorator.rb'
    - 'app/models/order.rb'
    - 'app/models/store.rb'

# Offense count: 2
Lint/IneffectiveAccessModifier:
  Exclude:
    - 'app/models/item.rb'

現状と問題点

STORES.jpのリポジトリの最初のコミットは2012年5月9日、RuboCopの導入は2015年3月8日でした。導入時点で .rubocop_todo.yml は644行。2020/05/29時点では1388行あります。違反を指摘されている箇所はなんと26315箇所でした。

これはつまり26315箇所の良くない点があるということになります。なんとかしたい!

さらに、.rubocop_todo.yml の運用にも問題がありました。日々コードは変更されていくので、.rubocop_todo.ymlで無視した違反が既に存在しない、何なら既にファイルが存在しない、という事態が発生します。本来であれば日々更新してコードの変化に追随していく必要があるのですが、特にプロセスや自動化はなく気がついたら更新する、という運用になっていました。

どうなっていればいいの?

もちろん、全ての違反が修正され、.rubocop_todo.ymlがなくなるのが理想です。しかし、これだけ違反があると一気にその状態を実現するのは難しいです。ということで、継続的に違反を修正し、.rubocop_todo.ymlが更新される仕組みを作ろうと考えました。

どうすればいいか考える

さて継続的かつ自動的に.rubocop_todo.ymlにある違反を倒していくにはどうすればよいでしょうか。

まず思いつくのは地道に違反を手で修正していく方法ですが、人間がやる仕事は最小限にしたいですね。RuboCopには自動で違反を直す機能があるので、それを活用する方向で考えることにしました。

一度に全ての修正をかけるのは差分の大きさを考えると現実的ではありません。部分的に修正していく方法として、数ファイルずつ行う方法を思いつきました。

rubocop -a
git add $(git diff --name-only origin/master |head -3)`

とすれば3ファイルずつ違反を直せます。しかし、これだとどのCopにかけられた修正なのかわからないのでレビューが難しいという欠点があります。修正しているうちにRuboCopの設定の是非を疑うということもよくあるはずで、そのような状況で設定を見直し改善しながら修正を進めるのも難しくなりそうです。

たどり着いたやり方

最終的には「Cop数個ずつ修正を流してPRを出していく」という基本戦略に落ち着きました。これであればほどよい差分の量で設定を見直しつつ修正をかけていくことができます。

大まかな流れは下記の通りです。これを定期的にGitHub Actionsで行っています。

  • rubocop --auto-gen-config する
    • 前回の実行からコードが変更されているはずなので、その差分を.rubocop_todo.yml に反映
  • .rubocop_todo.ymlの一番上にある自動修正可能なCopで自動修正をかける
  • コミットする
  • rubocop --auto-gen-config する
    • .rubocop_todo.yml から対応したCopの違反の記録が消える
  • コミットする
  • PRを出す

自動修正で git-blame が汚れることの対策

RuboCopに限らず、フォーマッターによるコードの自動修正は「git-blameが汚れる」という難点があります。しかしGit 2.23から--ignore-revs-file オプションで指定したファイルに列挙されたコミットを無視できるようになりました。

列挙するファイルの名前は、いくつかのリポジトリを見ると .git-blame-ignore-revs とするのが定番のようです。git config blame.ignoreRevsFile .git-blame-ignore-revsgit-blame時にこのファイルにあるリビジョンを自動的に無視するようになります。

実際のコード

最終的なコードはこのようになりました。

require 'yaml'
require 'rubocop'
require 'active_support/core_ext/string'

RUBOCOP_A_PER_COMMAND = ENV.fetch('RUBOCOP_A_PER_COMMAND', 3)

RUBOCOP_AUTO_GEN_CONFIG_COMMAND = "bundle exec rubocop --auto-gen-config --no-auto-gen-timestamp"

ADD_COMMITS_FROM_ORIGIN_MASTER_TO_GIT_BLAME_IGNORE_REVS_COMMAND = <<~CMD
  git log master...HEAD --format="%n# %s%n%H" . ":(exclude).rubocop_todo.yml" >> .git-blame-ignore-revs
CMD

def sh(*args)
  puts "--> $ #{args.join(' ')}"
  system(*args) || abort
end

def without_rubocop_todo
  sh "echo '' > .rubocop_todo.yml"
  yield
ensure
  sh "git checkout .rubocop_todo.yml"
end

def autocorrect_one_correctable_cop
  todos = YAML.load_file './.rubocop_todo.yml'
  config = RuboCop::ConfigLoader.default_configuration
  cop_name, value = todos.detect { |k, _v|
    cop = "RuboCop::Cop::#{k.gsub('/', '::')}".constantize.new(config)
    cop.correctable? && cop.safe_autocorrect?
  }

  rubocop_a_command = "bundle exec rubocop -a --only #{cop_name}"
  without_rubocop_todo do
    sh rubocop_a_command
  end
  sh "git add ."
  sh 'git', 'commit', '-m', rubocop_a_command
end

def regenerate_rubocop_todo
  sh RUBOCOP_AUTO_GEN_CONFIG_COMMAND
  sh "git add .rubocop_todo.yml"
  sh "git commit -m '#{RUBOCOP_AUTO_GEN_CONFIG_COMMAND}' || echo 'No changes'"
end

def add_git_blame_ignore_revs
  sh ADD_COMMITS_FROM_ORIGIN_MASTER_TO_GIT_BLAME_IGNORE_REVS_COMMAND
  sh "git add .git-blame-ignore-revs"
  sh 'git', 'commit', '-m', <<~COMMIT_COMMENT
    Ignore changes by '$ rubocop -a' in git-blame

    #{ADD_COMMITS_FROM_ORIGIN_MASTER_TO_GIT_BLAME_IGNORE_REVS_COMMAND}
  COMMIT_COMMENT
end

if $0 == __FILE__
  # actions/checkout@v2 が先頭のコミット一つしかチェックアウトしないので
  # ここで origin/master を fetch する必要があった
  sh "git fetch origin master"
  sh "git checkout master"
  timestamp = DateTime.now.iso8601
  sh "git checkout -b rubocop-auto-correct-#{timestamp.tr '^[a-zA-Z0-9]', '-'}"
  regenerate_rubocop_todo
  RUBOCOP_A_PER_COMMAND.times do
    autocorrect_one_correctable_cop
    regenerate_rubocop_todo
  end
  add_git_blame_ignore_revs
  sh %|gh pr create -B master -t "rubocop --auto-correct #{timestamp}" -b ""|
end

運用

GitHub Actionsの設定で月火水木に実行されるようにしています。金曜は極力リリースをしない運用のため避けています。

レビューについては、Railsのコードを見ているメンバーによるラウンドロビンとしました。マージ、デプロイまで担当しています。

また、トラブルシューティングのために下記のようなFAQを用意しました。

  • 自動修正の内容の是非が判断しかねる
    • Slackで相談しましょう
  • 自動修正の内容が間違っている
    • RuboCopのバグの可能性があります。経験上たまにあります。rubocop.ymlの設定を更新して、バグったCopを無視してください
      • RuboCopが治らないと修正ができないので、RuboCop本体にレポートするようにしましょう。余裕があればパッチを投げるとなおよしです
  • 自動修正の内容が不適切だと思われる
    • Slackで相談しましょう
    • 相談の結果、適用しない、となった場合は rubocop.yml の設定を更新するPRを出して、自動で出たPRをクローズしてください
    • RuboCopのバグの可能性もあります。その場合は上記に加えてRuboCop本体にレポートしましょう
  • 忙しくて見れない・忘れていた
    • パスしてください。翌日同じ内容でPRが出るので、パスする旨コメントしてクローズしてください

導入から4ヶ月経っての感想とまとめ

前半で「2020/05/29時点で」とあるとおり、実はこの仕組みを稼働させ始めたのは今年の6月前半。気がつけば4ヶ月が経ちました。たまに予期しない修正に対応したり、スクリプトを直したりというお手入れは必要でしたが、概ね順調に動いていると認識しています。1388行あった .rubocop_todo.yml も766行まで減りました。 GitHub CLIGitHub Actionsで動かすのに苦労したり、動かすまでにはけっこうトライアンドエラーがあったのですが、継続的かつ自動的なコードベースの改善が進むようになったので概ね期待通りの結果が得られたと思っています。

ということで、 heyではエンジニアのみならずデザイナー、PM、CS、セールス、コーポレートなど全職種で一緒に働く仲間を探しています。ソースコードの自動修正に興味がある方もない方も、是非とも下記URLの採用ページをチラッと見てもらえると嬉しいです。ちょっと話を聞きたい!というだけの方も歓迎です。お気軽にご連絡ください!

https://hello.hey.jp/