こんにちは。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-revs
でgit-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本体にレポートするようにしましょう。余裕があればパッチを投げるとなおよしです
- RuboCopのバグの可能性があります。経験上たまにあります。
- 自動修正の内容が不適切だと思われる
- Slackで相談しましょう
- 相談の結果、適用しない、となった場合は
rubocop.yml
の設定を更新するPRを出して、自動で出たPRをクローズしてください - RuboCopのバグの可能性もあります。その場合は上記に加えてRuboCop本体にレポートしましょう
- 忙しくて見れない・忘れていた
- パスしてください。翌日同じ内容でPRが出るので、パスする旨コメントしてクローズしてください
導入から4ヶ月経っての感想とまとめ
前半で「2020/05/29時点で」とあるとおり、実はこの仕組みを稼働させ始めたのは今年の6月前半。気がつけば4ヶ月が経ちました。たまに予期しない修正に対応したり、スクリプトを直したりというお手入れは必要でしたが、概ね順調に動いていると認識しています。1388行あった .rubocop_todo.yml
も766行まで減りました。 GitHub CLI をGitHub Actionsで動かすのに苦労したり、動かすまでにはけっこうトライアンドエラーがあったのですが、継続的かつ自動的なコードベースの改善が進むようになったので概ね期待通りの結果が得られたと思っています。
ということで、 heyではエンジニアのみならずデザイナー、PM、CS、セールス、コーポレートなど全職種で一緒に働く仲間を探しています。ソースコードの自動修正に興味がある方もない方も、是非とも下記URLの採用ページをチラッと見てもらえると嬉しいです。ちょっと話を聞きたい!というだけの方も歓迎です。お気軽にご連絡ください!