ようへいの日々精進XP

よかろうもん

hub でハブられそうになったのでメモ 〜 まぼろしのプルリクエスト 〜

追記

プルリクエストは静かにクローズされました. 丁寧にコメント頂いて嬉しかったです.

github.com

これは

qiita.com

初老丸 Advent Calendar 2018 第 1 日目の記事になる予定です.

tl;dr

octorelease という Gem をリリースする際に過去のプルリクエストを git log から拾ってリリースノートを自動生成するツールを利用しようとしたら, それに依存する hub でハマってしまったのでメモしておきます.

github.com

github.com

何にハマったのか

octorelease (厳密に言うと, hub) を利用するにあたって, 事前に Github API を利用する為に ${HOME}/.config/hub に以下のような設定を YAML で設定しておきます.

---
github.com:
  - user: xxxxxx
    oauth_token: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

この YAML の書き方が実は問題だったのです... (後述)

rake octorelease を実行すると以下のような例外が発生しました.

rake aborted!
NoMethodError: undefined method `[]=' for nil:NilClass
/path/to/vendor/bundle/ruby/2.5.0/gems/hub-1.12.4/lib/hub/github_api.rb:511:in `block in yaml_load'
/path/to/vendor/bundle/ruby/2.5.0/gems/hub-1.12.4/lib/hub/github_api.rb:502:in `each'
/path/to/vendor/bundle/ruby/2.5.0/gems/hub-1.12.4/lib/hub/github_api.rb:502:in `yaml_load'
/path/to/vendor/bundle/ruby/2.5.0/gems/hub-1.12.4/lib/hub/github_api.rb:483:in `load'
/path/to/vendor/bundle/ruby/2.5.0/gems/hub-1.12.4/lib/hub/github_api.rb:456:in `initialize'
/path/to/vendor/bundle/ruby/2.5.0/gems/octorelease-0.0.6/lib/octorelease.rb:9:in `new'
/path/to/vendor/bundle/ruby/2.5.0/gems/octorelease-0.0.6/lib/octorelease.rb:9:in `block in <top (required)>'
...
Tasks: TOP => octorelease

例外メッセージを見る限りだと, octorelease が依存している hub というライブラリで問題が発生しているようです.

何がどうだったのか

octorelease が依存しているライブラリ

hub というコマンドラインツールで, ライブラリとしても利用が可能なようです. あまり詳しく見ていないので, 間違っていたら指摘をお願い致します.

github.com

ちなみに, 現在は Golang で書き直されているようです.

デバッグしてみる

例外のメッセージを見てみると, hub の github_api.rb というコードの 511 行目で何か起きているようです. 以下, 前後のコードの抜粋です.

...
      def yaml_load(string)
        hash = {}
        host = nil
        string.split("\n").each do |line|
          case line
          when /^---\s*$/, /^\s*(?:#|$)/
            # ignore
          when /^(.+):\s*$/
            host = hash[$1] = []
          when /^([- ]) (.+?): (.+)/
            key, value = $2, $3
            host << {} if $1 == '-'
            host.last[key] = value.gsub(/^'|'$/, '')
          else
            raise "unsupported YAML line: #{line}"
          end
        end
        hash
      end
..

メソッド名を見ると, YAML ファイル (${HOME}/,config/hub) を読み込んで解析するようなメソッドのようですので, irb を起動してこのメソッドをデバッグしてみたいと思います. 用意する YAML ファイルは, 以下のような内容となります.

github.com:
  - user: foobar
    oauth_token: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

試しに Ruby 標準の YAML ライブラリを利用して解析してみます. 尚, 検証に利用する Ruby の環境は以下の通りです.

$ ruby --version
ruby 2.5.1p57 (2018-03-29 revision 63029) [x86_64-darwin17

以下のように, Ruby 標準 YAML ライブラリの load_file メソッドを利用してファイルから YAML を読み込みます.

irb(main):001:0> require 'yaml'
=> true
irb(main):003:0> YAML.load_file('sample.yml')
=> {"github.com"=>[{"user"=>"foobar", "oauth_token"=>"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}]}

ちゃんと解析されて Ruby のハッシュオブジェクトとして読み込まれました. 次に hub で YAML を読み込んでみます. octrelease のソースコードを見る限りだと, hub の Hub::GitHubAPI::FileStore というクラスをインスタンス化する際に YAML ファイルを引数として渡してあげると良さそうです.

irb(main):004:0> require 'hub'
=> true
irb(main):006:0> Hub::GitHubAPI::FileStore.new 'sample.yml'
Traceback (most recent call last):
...
        5: from /path/to/vendor/bundle/ruby/2.5.0/gems/hub-1.12.4/lib/hub/github_api.rb:456:in `initialize'
        4: from /path/to/vendor/bundle/ruby/2.5.0/gems/hub-1.12.4/lib/hub/github_api.rb:483:in `load'
        3: from /path/to/vendor/bundle/ruby/2.5.0/gems/hub-1.12.4/lib/hub/github_api.rb:502:in `yaml_load'
        2: from /path/to/vendor/bundle/ruby/2.5.0/gems/hub-1.12.4/lib/hub/github_api.rb:502:in `each'
        1: from /path/to/vendor/bundle/ruby/2.5.0/gems/hub-1.12.4/lib/hub/github_api.rb:511:in `block in yaml_load'
NoMethodError (undefined method `[]=' for nil:NilClass)

冒頭の例外と同じ内容の例外が発生しました. この例外を回避する為, 試行錯誤した結果, 以下のような YAML ファイルの中身にすることで例外を回避することを確認しました. (github.com: 以下に半角スペースのインデントが無い状態です.)

github.com:
- user: xxxxxx
  oauth_token: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

この YAML を一行にすると以下のようになります.

---\ngithub.com:\n- user: foobar\n  oauth_key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n

これを, 標準の YAML ライブラリで読み込んでみます.

irb(main):001:0> require 'yaml'
=> true
irb(main):002:0> YAML.load("---\ngithub.com:\n- user: foobar\n  oauth_key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n")
=> {"github.com"=>[{"user"=>"foobar", "oauth_key"=>"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}]}

一応, YAML として読み込むことが出来るようですが, こちら をざっくりと読んでみたところ, YAML 自体は半角スペースによるインデントを使って構造化しているので, 半角スペースでインデントは必須なんではなかろうかと考えていますが, github.com: 以下に半角スペースのインデントが無い状態でも問題は無いようです.

デバッグしてみる (2) 〜 何が起きているのか 〜

インデントが無い YAML でも問題は無かったのですが, 少々納得がいかなかったので, 引き続きデバッグを進めてみたいと思います.

hub の github_api.rb, 511 行目前後にフォーカスしてみます.

...
          when /^([- ]) (.+?): (.+)/
            key, value = $2, $3
            host << {} if $1 == '-'
            host.last[key] = value.gsub(/^'|'$/, '')
...

文字列として読み込まれた YAML を 1 行毎に正規表現を使ってキャプチャして特殊変数の $1$3 に放り込んでいるようです. そして, $1- であれば, host という変数に空の {} (ハッシュ) を追加して, $2 をハッシュのキー (変数 key) として, $3key に対する値 (変数 value) としてハッシュを生成していくことを意図しているようです.

では, このコードを含む yaml_load メソッドだけを切り出して, 以下のような小さなコードでデバッグしてみたいと思います. デバッグには byebug という Gem で配布しているデバッガを利用します.

require 'minitest/autorun'

class HubYamlLoadTest < Minitest::Test
  def test_yaml_load_my_pattern
    yaml = "---\ngithub.com:\n  - user: foobar\n    oauth_key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n"
    expect = {"github.com"=>[{"user"=>"foobar", "oauth_key"=>"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}]}
    assert_equal yaml_load(yaml), expect
  end
end

def yaml_load(string)
  hash = {}
  host = nil
  string.split("\n").each do |line|
    case line
    when /^---\s*$/, /^\s*(?:#|$)/
      # ignore
    when /^(.+):\s*$/
      host = hash[$1] = []
    when /(^[- ]) (.+?): (.+)/
      key, value = $2, $3
      host << {} if $1 == '-'
      require 'byebug'; byebug # ここにデバッガを差し込む
      host.last[key] = value.gsub(/^'|'$/, '')
    else
      raise "unsupported YAML line: #{line}"
    end
  end
  hash
end

byebug の詳しい使い方については, 他の書籍やサイトをご覧下さい.

このテストコードを実行してみます.

$ bundle exec ruby test.rb
Run options: --seed 54106

# Running:


[25, 34] in /Users/kawahara/sandboxies/octorelease/test.rb
   25:       host = hash[$1] = []
   26:     when /(^[- ]) (.+?): (.+)/
   27:       key, value = $2, $3
   28:       host << {} if $1 == '-'
   29:       require 'byebug'; byebug
=> 30:       host.last[key] = value.gsub(/^'|'$/, '')
   31:     else
   32:       raise "unsupported YAML line: #{line}"
   33:     end
   34:   end
(byebug)

byebug のプロンプトが現れ, デバッガを差し込んだ次の行で処理が一時停止しています. この状態で, 各変数にどのような値が格納されているのか確認しています.

...
(byebug) key
"- user"
(byebug) value
"foobar"
(byebug) host
[]

変数 host[] と空の配列になっており, 変数 host はハッシュである前提で host.last[key] が実行されるので, キーに変数 key が代入することが出来ずに例外が発生してしまっているようです.

デバッグしてみる (3) 〜 じゃあ, どうするのか 〜

以下のような変更を加えてみます.

$ diff -u test.rb fix.rb
--- test.rb     2018-11-22 07:09:51.000000000 +0900
+++ fix.rb      2018-11-22 07:09:42.000000000 +0900
@@ -25,7 +25,8 @@
       host = hash[$1] = []
     when /(^[- ]) (.+?): (.+)/
       key, value = $2, $3
-      host << {} if $1 == '-'
+      host << {} if $1 == '-' or $2 =~ /^\s*-\s*/
+      key.gsub!(/^\s*-\s*|^\s*/, '')
       require 'byebug'; byebug
       host.last[key] = value.gsub(/^'|'$/, '')
     else

この状態で, 変更したコード (fix.rb) を実行してみます.

$ bundle exec ruby fix.rb
Run options: --seed 49205

# Running:


[26, 35] in /Users/kawahara/sandboxies/octorelease/fix.rb
   26:     when /(^[- ]) (.+?): (.+)/
   27:       key, value = $2, $3
   28:       host << {} if $1 == '-' or $2 =~ /^\s*-\s*/
   29:       key.gsub!(/^\s*-\s*|^\s*/, '')
   30:       require 'byebug'; byebug
=> 31:       host.last[key] = value.gsub(/^'|'$/, '')
   32:     else
   33:       raise "unsupported YAML line: #{line}"
   34:     end
   35:   end
(byebug) key
"user"
(byebug) value
"foobar"
(byebug) host
[{}]

変数 host に空のハッシュ {} が格納されていることを確認しました. では, デバッガを外して実行してみます.

$ bundle exec ruby fix.rb
Run options: --seed 17705

# Running:

.

Finished in 0.001235s, 809.7167 runs/s, 809.7167 assertions/s.

1 runs, 1 assertions, 0 failures, 0 errors, 0 skips

いい感じですね. また, github.com: 以下に半角スペースのインデントが無い YAML でも正しく解析されるかも検証しておきたいので, 以下のようにテストを書きました.

require 'minitest/autorun'

class HubYamlLoadTest < Minitest::Test
  def test_yaml_load_ok_pattern
    yaml = "---\ngithub.com:\n- user: foobar\n  oauth_key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n"
    expect = {"github.com"=>[{"user"=>"foobar", "oauth_key"=>"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}]}
    assert_equal yaml_load(yaml), expect
  end

  def test_yaml_load_my_pattern
    yaml = "---\ngithub.com:\n  - user: foobar\n    oauth_key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n"
    expect = {"github.com"=>[{"user"=>"foobar", "oauth_key"=>"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}]}
    assert_equal yaml_load(yaml), expect
  end
end

def yaml_load(string)
  hash = {}
  host = nil
  string.split("\n").each do |line|
    case line
    when /^---\s*$/, /^\s*(?:#|$)/
      # ignore
    when /^(.+):\s*$/
      host = hash[$1] = []
    when /(^[- ]) (.+?): (.+)/
      key, value = $2, $3
      host << {} if $1 == '-' or $2 =~ /^\s*-\s*/
      key.gsub!(/^\s*-\s*|^\s*/, '')
      # require 'byebug'; byebug
      host.last[key] = value.gsub(/^'|'$/, '')
    else
      raise "unsupported YAML line: #{line}"
    end
  end
  hash
end

これを実行してみます.

$ bundle exec ruby fix.rb
Run options: --seed 30867

# Running:

..

Finished in 0.003929s, 509.0354 runs/s, 509.0354 assertions/s.

2 runs, 2 assertions, 0 failures, 0 errors, 0 skips

いい感じです. 2 つの YAML パターンが, この yaml_load メソッドで解析出来るようになったはずです.

で, どうしたのか

まぼろしのプルリクエス

hub のリポジトリを見ると, Ruby で実装された hub は既にサポートされていないのではと思ってしまう程, master ブランチには Golang のコードが並んでいます. ブランチを github:1.12-stable を切り替えると様子が一変して Ruby で実装された hub を拝むことが出来ます. さらに, .travis.yml を見ると, 以下のようにテスト対象にはいにしへの Ruby バージョンが並んでいます.

sudo: false
language: ruby
before_install:
  - script/bootstrap
  - export PATH=~/bin:"$PATH"
script: script/test
bundler_args: --without development --deployment --jobs=3 --retry=3
cache: bundler
rvm:
  - 1.8.7
  - 1.9.2
  - 1.9.3
  - 2.0.0
  - 2.1.5
  - 2.2.0-preview2
notifications:
  email: false

どうやら, Ruby 版の hub はメンテナンスされていないようです. また, #1591 を見ろということでリンクが張っていますが, このリンクを踏んでも 404 となりページが存在していません. このページが無いことすらメンテナンスされていないということは本当にメンテナンスされていないと思って間違いないようです.

ですが, 一応, 以下のようなプルリクエストを作成してみました.

github.com

きっとマージされること無く, ひっそりとクローズされることでしょう.

なぜ, オレオレ YAML パーサーなのか

既にサポートされていないであろう Ruby 版 hub の .travis.yml を見ながら考えました. そもそも Ruby には YAML を読み込んだり, 書き出したりするライブラリがあるのに, なぜ hub にはオレオレ YAML パーサーが実装されているのか. 答えのようなコミットを発見しました.

github.com

2013 年の 12 月くらいまでは標準の YAML パーサーを利用していたようですが, YAML を解析する速度が思ったよりも遅かった為, オレオレ YAML パーサーに書き換えたとのことでした.

なるほどです. 実際に標準の YAML とオレオレパーサーでどのくらいの速度の違いがあるかについては別の機会に検証してみたいと思います.

ということで

octorelease から始まって, それが依存する hub のソースコードを見ながら色々と学びがありました. きっとプルリクエストはマージされずにまぼろしとなってしまうとは思いますが, これを糧に引き続き Ruby 道に精進して参る所存です.

有難うございました.