2018-03-02

compare_linker_wrapperコードリーディング

compare_linker_wraper (v1.0.2)のコードリーディングをしました。

使い方はこんな感じで使います。第一引数がGemfile.lockのファイルパス、baseが比較元のブランチ、formatterは出力のフォーマッタです。

compare-linker-wrapper 'Gemfile.lock' \
  --base origin/master \
  --formatter CompareLinker::Formatter::Markdown

exe/compare-linker-wrapperはCompareLinkerWrapper::CLI.startを呼び出します。

CompareLinkerWrapper::CLI.start(ARGV)

Thorで作られており、default_commandがcompareになっています。compareでは引数を調整しつつ、CompareLinkerWrapper::Linker#linkを呼びだして、戻り値をputsで標準出力に吐き出します。

module CompareLinkerWrapper
  class CLI < Thor
# ...
    desc 'compare', 'compare gemfile.lock'
    option :debug, type: :boolean, default: false
    option :verbose, type: :boolean, default: false
    option :base, type: :string, default: 'origin/master'
    option :head, type: :string, default: 'HEAD'
    option :file, type: :array
    option :formatter, type: :string, default: 'CompareLinker::Formatter::Text'

    def compare(*args)
      setup_logger(options)
      params = {
        head: options[:head],
        base: options[:base],
        formatter: options[:formatter]
      }
      if options[:file]
        params[:file] = options[:file]
      else
        params[:file] = args
      end
      puts Linker.new('.').link(params)
    rescue StandardError => e
      suggest_messages(options)
      raise e
    end
    default_command :compare

CompareLinkerWrapper::Linker#linkは比較元と比較先のGemfile.lockをパースして比較します。Gemfile.lockはgitのgemを使って取得しています。

module CompareLinkerWrapper
  class Linker
# ...
    def link(params)
      formatter = add_formatter(params[:formatter])
      comments = []

      params[:file].each do |gemfile_lock|
        old_lockfile = parse(git.show(params[:base], gemfile_lock))
        new_lockfile = parse(git.show(params[:head], gemfile_lock))

        comparator = ::CompareLinker::LockfileComparator.new
        comparator.compare(old_lockfile, new_lockfile)
        compare_links = comparator.updated_gems.map do |gem_name, gem_info|
          # ...後述
        end
        comments << compare_links
      end
      comments
    end

parseはBundler::LockfileParserのインスタンスを生成します。LockfileParserはその名の通りGemfile.lockをパースしてオブジェクト化したものです。

    def parse(lock_file)
      Bundler::LockfileParser.new(lock_file)
    end

CompareLinker#compareではGemfile.lockのspecsのバージョンやリビジョンが異なっていれば、specの情報をupdated_gemsに格納します。

class CompareLinker
  class LockfileComparator
    attr_accessor :updated_gems

    def initialize
      @updated_gems = {}
    end

    def compare(old_lockfile, new_lockfile)
      old_lockfile.specs.each do |old_spec|
        new_lockfile.specs.each do |new_spec|
          if old_spec.name == new_spec.name
            old_rev = old_spec.source.options["revision"]
            new_rev = new_spec.source.options["revision"]
            if old_rev && new_rev && (old_rev != new_rev)
              _, owner, gem_name = old_spec.source.uri.match(/github\.com\/([^\/]+)\/([^.]+)/).to_a
              updated_gems[old_spec.name] = {
                owner: owner,
                gem_name: gem_name,
                old_rev: old_rev,
                new_rev: new_rev,
              }
            elsif old_spec.version != new_spec.version
              updated_gems[old_spec.name] = {
                owner: nil,
                gem_name: old_spec.name,
                old_ver: old_spec.version.to_s,
                new_ver: new_spec.version.to_s,
              }
            end
          end
        end
      end
      updated_gems
    end
  end
end

updated_gemsのループ内の処理は以下のようになっています。

if gem_info[:owner].nil?
  finder = ::CompareLinker::GithubLinkFinder.new(client)
  finder.find(gem_name)
  if finder.repo_owner.nil?
    gem_info[:homepage_uri] = finder.homepage_uri
    formatter.format(gem_info)
  else
    gem_info[:repo_owner] = finder.repo_owner
    gem_info[:repo_name] = finder.repo_name

    tag_finder = ::CompareLinker::GithubTagFinder.new(client)
    old_tag = tag_finder.find(finder.repo_full_name, gem_info[:old_ver])
    new_tag = tag_finder.find(finder.repo_full_name, gem_info[:new_ver])

    if old_tag && new_tag
      gem_info[:old_tag] = old_tag.name
      gem_info[:new_tag] = new_tag.name
      formatter.format(gem_info)
    else
      formatter.format(gem_info)
    end
  end
else
  formatter.format(gem_info)
end

ownerがnilの場合=gitではなくrubygemsなどから取得した場合はGithubLinkerFinerでホームページURIやソースコードURIからgithub.comにマッチするものを取得し、そこからリポジトリ名とオーナー名を取得します。

class CompareLinker
  class GithubLinkFinder
    attr_reader :octokit, :repo_owner, :repo_name, :homepage_uri

    def initialize(octokit)
      @octokit = octokit
    end

    def find(gem_name)
      gem_info = JSON.parse(
        HTTPClient.get_content("https://rubygems.org/api/v1/gems/#{gem_name}.json")
      )

      github_url = [
        gem_info["homepage_uri"],
        gem_info["source_code_uri"]
      ].find { |uri| uri.to_s.match(/github\.com\//) }

      if github_url = redirect_url(github_url)
        _, @repo_owner, @repo_name = github_url.match(%r!github\.com/([^/]+)/([^/]+)!).to_a
      else
        @homepage_uri = gem_info["homepage_uri"]
      end

    rescue HTTPClient::BadResponseError
      @homepage_uri = "https://rubygems.org/gems/#{gem_name}"
    end

リポジトリ名が取得できたらさらにGithubTagFinderによって対象のバージョンのタグを取得します。

class CompareLinker
  class GithubTagFinder
    attr_reader :octokit

    def initialize(octokit)
      @octokit = octokit
    end

    def find(repo_full_name, gem_version)
      tags = octokit.tags(repo_full_name)
      if tags
        tags.find { |tag|
          tag.name == gem_version || tag.name == "v#{gem_version}"
        }
      end
    end
  end
end

このようにしてgem_infoのハッシュをハンドリングしてCompareLinker::Formatter::XXX#formatで文字列にフォーマットします。Markdownの場合は以下の通りです。

require "ostruct"

class CompareLinker
  class Formatter
    class Markdown < Base
      def format(gem_info)
        g = OpenStruct.new(gem_info)

        text = "* [ ] "
        text += case
        when g.owner && g.old_rev && g.new_rev
          "#{g.gem_name}: https://github.com/#{g.owner}/#{g.gem_name}/compare/#{g.old_rev}...#{g.new_rev}"
        when g.homepage_uri
          "[#{g.gem_name}](#{g.homepage_uri}): #{g.old_ver} => #{g.new_ver}"
        when g.old_tag && g.new_tag
          "#{g.gem_name}: https://github.com/#{g.repo_owner}/#{g.repo_name}/compare/#{g.old_tag}...#{g.new_tag}"
        when g.repo_owner && g.repo_name
          "[#{g.gem_name}](https://github.com/#{g.repo_owner}/#{g.repo_name}): #{g.old_ver} => #{g.new_ver}"
        else
          "#{g.gem_name}: (link not found) #{g.old_ver} => #{g.new_ver}"
        end

        if downgrade?(g.old_ver, g.new_ver, g.old_tag, g.new_tag)
          text += " (downgrade)"
        end

        text
      end
    end
  end
end
このエントリーをはてなブックマークに追加