こんにちは、渡部です。
業務では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.pmap
は Enum.map
を並列に行う関数です。この関数はElixirのレシピサイトを参考にしました。
プログラミングElixir本では
Devin Torresが私に、Erlang 宇宙のすべての本で、並列マップ関数がなければならないと、法律で定められていると気づかせてくれた。
とあるように時間がかかる関数をそれそれの要素に別のプロセスを作って適用していくので、早くなるようです。
今回はHTTPリクエスト一つ一つが時間がかかりそうだったので、並列マップを使ってサーバーからのレスポンスに変換しています。結果はサーバーからのレスポンス時間の差もあると思いますが、 Enum.map
より数秒程度早くすることができたように思います。
コードが&だらけになるのはなんとかならないだろうか・・・。
まとめ
これでGitLabにプッシュされたら、Rubocopが走ってくれる素敵なコーディングライフが・・・ふふふっ
と、思っていたのですが、そんなGemが実はすでにGitHubにありました。
( ゚д゚) ・・・ (つд⊂)ゴシゴシ (;゚д゚)
やっぱり考えることが一緒の人も世の中にはいるんですね(涙) しかも高機能・・・
今回のコードは車輪の再開発に終わってしまいましたが、Elixirにだいぶ慣れることができました。 個人的にはマクロをある程度理解できたので良かったです。
これにめげずにElixirでの色々なものを作って行きたいと思います! 見つけたGemも使いたいと思います。
※ 今回のコードは https://github.com/watanany/gitlab_rubocop にアップされているので参考にどうぞ(汚いですが)