読者です 読者をやめる 読者になる 読者になる

GitLabのマージリクエスト時に自動でRubocopを走らせたい

こんにちは、渡部です。

業務ではGitLabでソースコードを管理しています。

RubyではPerlと同じように「多様性は善」という哲学がありますが、 プロジェクトではある程度コードの見た目を統一したいと思うことも多いと思います。

Rubocopなどでソースコードがコーディング規約に従っているかチェックをかけたいのですが、 GitLabでソースコードの管理をしているなら、GitLabにプッシュされたときにRubocopで検査すれば良い と思い立ったので実際に作ってみました。

まだElixirに慣れていないので手を動かす良い機会だ!と思ったので今回はElixirで実装しています。

作りたいものの流れ

流れとしては、
1. GitLabのマージリクエストを定期的に監視する
2. 新しいマージリクエストがあったら、変更されたファイルをリクエストする
3. 変更されたファイルをローカルに一時ファイルとして保存し、Rubocopでチェックする
4. Rubocopの出力をマージリクエストのコメントに書き込む
5. 同じマージリクエストをRubocopでチェックしないように、マージリクエストIDをファイルに保存する

ローカルのマシンがGitLabからのリクエストを受け取れないのでWEB-hookが使えないので、定期的に監視するようにしました。

今回は使用した外部モジュールは下の3つです。

  • poison・・・JSONのエンコード・デコード
  • httpoison・・・HTTPリクエスト
  • elixir-temp・・・一時ファイルの作成

コーディング

■ config/config.exs

GitLabのAPIをたたくの必要なプライベートトークン、GitLab自体のURL、監視したいプロジェクトIDをコードから参照できるように、config.exs に書き込みます。

use Mix.Config

config :gitlab_agent, private_token: System.get_env("GITLAB_PRIVATE_TOKEN"),
                      url: System.get_env("GITLAB_URL"),
                      project_id: 2

プロジェクトIDは「$GITLAB_URL/api/v3/projects?private_token=$GITLAB_PRIVATE_TOKEN」にGETリクエストを送ることで得られます。 (他にやり方あるのかな・・・)


■ lib/gitlab.ex

HTTPoisonにはHTTPoison.Baseのマクロを使って別のモジュールを組み立てることができるようです。 毎回GitLabのURLを書くのは手間なので、今回はこのマクロで新しいモジュールを作りました。

defmodule Gitlab do
  use HTTPoison.Base

  # process_options を追加したいのでHTTPoison.Baseマクロの関数を再定義する
  def request(method, url, body \\ "", headers \\ [], options \\ []) do
    url =
      if Keyword.has_key?(options, :params) do
        url <> "?" <> URI.encode_query(options[:params])
      else
        url
      end
    url = process_url(to_string(url))
    body = process_request_body(body)
    headers = process_request_headers(headers)
    options = process_options(options)  # ここだけ追加
    HTTPoison.Base.request(__MODULE__, method, url, body, headers, options, &process_status_code/1, &process_headers/1, &process_response_body/1)
  end

  # HTTPoison.Baseの関数をオーバーライドする。
  defp process_url(path) do
    gitlab_url <> "/api/v3" <> path
  end

  defp process_request_headers(headers) when is_map(headers) do
    # GitLabのプライベートトークンをHTTPリクエストのヘッダに追加する
    Enum.into(headers, "PRIVATE-TOKEN": private_token)
  end

  defp process_request_headers(headers) do
    # GitLabのプライベートトークンをHTTPリクエストのヘッダに追加する
    headers ++ ["PRIVATE-TOKEN": private_token]
  end

  defp process_response_body(body) do
    # HTTPリクエストのレスポンスをJSONだと仮定して、デコードする
    # こうすると、get!やpost!などの戻り値がHTTPoison.Response構造体になります
    body
    |> Poison.decode!
  end

  # Original pipeline factor to use in `request` function
  defp process_options(options) do
    # セキュリティ系のエラーが発生したので、オプションで :insecure を指定
    # (オレオレ証明書だからか・・・?)
    options ++ [hackney: [:insecure]]
  end

  # Utility functions
  def gitlab_url do
    Application.get_env(:gitlab_agent, :url)
  end

  def private_token do
    Application.get_env(:gitlab_agent, :private_token)
  end

  def to_path(list) do
    # RESTFulなURLをリストから組み立てる
    ["/" | list]
    |> Enum.map(fn
      e when is_atom(e) -> Atom.to_string(e)
      e when is_integer(e) -> Integer.to_string(e)
      e -> e
    end)
    |> Path.join
  end
end

■ lib/gitlab_agent.ex

GitLabに実際にリクエストを送るモジュールです。

実行は、$ mix run -e 'GitlabAgent.main' で行えます。

defmodule GitlabAgent do
  def main do
    project_id = Application.get_env(:gitlab_agent, :project_id)

    # すでにチェック済みのマージリクエストIDをファイルから取り出す
    data_path = "./data/merge_requests.bin"
    content = File.read!(data_path)

    records =
      if String.length(content) != 0 do
        :erlang.binary_to_term(content)
      else
        []
      end

    # プロジェクトのオープンかつWIPでないマージリクエストのIDを取得していく
    merge_request_ids = Gitlab.to_path([:projects, project_id, :merge_requests])
                        |> Gitlab.get!([], params: [state: "opened"])
                        |> Map.get(:body)
                        |> Enum.filter(&(!&1["work_in_progress"]))
                        |> Enum.map(&(&1["id"]))
                        |> Enum.filter(&(!(&1 in records)))

    # マージリクエストそれぞれに処理を行っていく
    merge_request_ids
    |> Enum.each(fn(merge_request_id) ->
      # Get single merge request response
      res = Gitlab.to_path([:projects, project_id, :merge_requests, merge_request_id, :changes]) |> Gitlab.get!

      # 変更があるファイルを取得するためのHTTPレスポンスを得る
      reses = res.body["changes"]
              |> Parallel.pmap(fn(change) ->
                Gitlab.to_path([:projects, res.body["source_project_id"], :repository, :files])
                |> Gitlab.get!([], params: [file_path: change["new_path"], ref: res.body["source_branch"]])
              end)

      # 変更があるファイルの名前のリストを得る
      files = reses
              |> Enum.filter(&(ruby_file?(&1.body["file_path"])))
              |> Enum.map(&(&1.body["file_path"]))

      # 変更があるファイルの内容のリストを得る
      contents = reses
                 |> Enum.filter(&(ruby_file?(&1.body["file_path"])))
                 |> Enum.map(&(&1.body["content"]))
                 |> Enum.map(&Base.decode64!/1)

      # 一時ファイルを上の内容をコピーしつつ作っていく
      tempfiles = contents
                  |> Parallel.pmap(fn(c)-> Temp.open!(nil, &(IO.write(&1, c))) end)

      # Rubocopを一時ファイルに適用して出力をとる
      rubocop_outputs = tempfiles
                        |> Parallel.pmap(&rubocop_output/1)

      # Rubocopの出力にあるファイル名を一時ファイルの名前から正しいファイルの名前に置き換える
      rubocop_outputs = :lists.zip3(files, tempfiles, rubocop_outputs)
                        |> Enum.map(fn({f, t, o}) -> String.replace(o, ~r(#{t}), f) end)

      rubocop_outputs
      |> Enum.each(&IO.puts/1)

      # マージリクエストのコメントにRubocopの結果を書いていく
      rubocop_outputs
      |> Parallel.pmap(fn(body) ->
        Gitlab.to_path([:projects, project_id, :merge_requests, merge_request_id])
        |> Gitlab.post!({:multipart, [{"body", "```\n#{body}\n```"}]})
      end)
      |> Enum.map(&(&1.body))
      |> Enum.map(&Poison.encode!/1)
      |> Enum.each(&IO.puts/1)

      # すでにチェックしたマージリクエストを再チェックしないようにマージリクエストIDをファイルに書き込む
      records = [merge_request_id  | records]
      File.write!(data_path, :erlang.term_to_binary(records))
    end)

    # 10分後にもう一度マージリクエストをチェックする
    :timer.sleep(10 * 60 * 1000)
    main
  end

  def ruby_file?(path) do
    String.ends_with?(path, ".rb")
  end

  def rubocop_output(path) do
    {output, _} = System.cmd("rubocop", [path])
    output
  end
end

コードについて

Parallel.pmap

コード内の Parallel.pmapEnum.map を並列に行う関数です。この関数はElixirのレシピサイトを参考にしました。

プログラミングElixir本では

Devin Torresが私に、Erlang 宇宙のすべての本で、並列マップ関数がなければならないと、法律で定められていると気づかせてくれた。

とあるように時間がかかる関数をそれそれの要素に別のプロセスを作って適用していくので、早くなるようです。

今回はHTTPリクエスト一つ一つが時間がかかりそうだったので、並列マップを使ってサーバーからのレスポンスに変換しています。結果はサーバーからのレスポンス時間の差もあると思いますが、 Enum.map より数秒程度早くすることができたように思います。

コードが&だらけになるのはなんとかならないだろうか・・・。

まとめ

これでGitLabにプッシュされたら、Rubocopが走ってくれる素敵なコーディングライフが・・・ふふふっ

と、思っていたのですが、そんなGemが実はすでにGitHubにありました

( ゚д゚) ・・・ (つд⊂)ゴシゴシ (;゚д゚)

やっぱり考えることが一緒の人も世の中にはいるんですね(涙) しかも高機能・・・

今回のコードは車輪の再開発に終わってしまいましたが、Elixirにだいぶ慣れることができました。 個人的にはマクロをある程度理解できたので良かったです。

これにめげずにElixirでの色々なものを作って行きたいと思います! 見つけたGemも使いたいと思います。

※ 今回のコードは https://github.com/watanany/gitlab_rubocop にアップされているので参考にどうぞ(汚いですが)