diff --git a/lib/tool_kit/github/client.ex b/lib/tool_kit/github/client.ex new file mode 100644 index 0000000..169940e --- /dev/null +++ b/lib/tool_kit/github/client.ex @@ -0,0 +1,397 @@ +defmodule ToolKit.GitHub.Client do + @moduledoc """ + GitHub REST API への薄い HTTP ラッパ(Req ベース)。 + + 各ツールが共通で使う endpoint ヘルパとエラー分類を提供する。 + orchestration とレスポンスの解釈(パース)はツール側の責務とし、 + 本モジュールは HTTP 境界に徹する。 + + ## 認証 + + トークンは `:token_provider` オプションで注入する。省略時は + GitHub CLI(`gh auth token`)から取得する(`gh_cli_token/0`)。 + + ## オプション + + すべての関数が共通で受け取る: + + * `:token_provider` - `(-> {:ok, token} | {:error, reason})` 形式の関数。 + 既定は `gh_cli_token/0` + * `:base_url` - API のベース URL(既定 `"https://api.github.com"`) + * `:receive_timeout` - 応答タイムアウト(ミリ秒、既定 30_000) + * `:user_agent` - User-Agent ヘッダ(既定 `"elixir-tool-kit"`) + * `:req_options` - Req にそのまま渡す追加オプション + (テストでの `plug: {Req.Test, Name}` 差し替えなど) + + ## 戻り値とエラー分類 + + * `{:ok, body}` - 2xx。ボディは Req がデコードした値 + * `{:error, :not_found}` - 404 + * `{:error, :unauthorized}` - 401 / 403(トークンの権限不足)。 + 404 と区別して返すため、呼び出し側は「存在しない」と + 「権限がない」を混同せずに扱える + * `{:error, {:http_error, status, message}}` - その他の 4xx / 5xx + * `{:error, {:request_failed, reason}}` - 通信自体の失敗 + * `{:error, {:token_error, reason}}` - トークン取得の失敗 + """ + + @default_base_url "https://api.github.com" + @default_receive_timeout 30_000 + @default_user_agent "elixir-tool-kit" + # REST API の日付バージョン(X-GitHub-Api-Version) + @api_version "2022-11-28" + + @type token_provider :: (-> {:ok, String.t()} | {:error, term()}) + + @type error :: + :not_found + | :unauthorized + | {:http_error, non_neg_integer(), String.t()} + | {:request_failed, term()} + | {:token_error, term()} + + @type result :: {:ok, term()} | {:error, error()} + + @type method :: :get | :post | :put | :patch | :delete + + @typedoc "Req のレスポンス相当(`Req.Response.t()` を含む)" + @type http_response :: %{ + :status => non_neg_integer(), + :body => term(), + optional(atom()) => term() + } + + # --------------------------------------------------------------- + # 汎用リクエスト + # --------------------------------------------------------------- + + @doc """ + GET リクエストを送る。 + """ + @spec get(String.t(), keyword()) :: result() + def get(path, opts \\ []), do: request(:get, path, opts) + + @doc """ + POST リクエストを送る(`body` は JSON として送信)。 + """ + @spec post(String.t(), term(), keyword()) :: result() + def post(path, body, opts \\ []), do: request(:post, path, Keyword.put(opts, :json, body)) + + @doc """ + PUT リクエストを送る(`body` は JSON として送信)。 + """ + @spec put(String.t(), term(), keyword()) :: result() + def put(path, body, opts \\ []), do: request(:put, path, Keyword.put(opts, :json, body)) + + @doc """ + PATCH リクエストを送る(`body` は JSON として送信)。 + """ + @spec patch(String.t(), term(), keyword()) :: result() + def patch(path, body, opts \\ []), do: request(:patch, path, Keyword.put(opts, :json, body)) + + @doc """ + 任意のメソッドでリクエストを送る。 + + `path` は `"/repos/owner/repo"` のようなベース URL からの相対パス。 + クエリパラメータは `:params`(keyword)で渡す。 + """ + @spec request(method(), String.t(), keyword()) :: result() + def request(method, path, opts \\ []) do + case fetch_token(opts) do + {:ok, token} -> + method + |> run_request(path, token, opts) + |> classify_response() + + {:error, reason} -> + {:error, {:token_error, reason}} + end + end + + # --------------------------------------------------------------- + # contents API + # --------------------------------------------------------------- + + @doc """ + ファイル内容を取得する(contents API)。 + + レスポンスには base64 の `"content"` と楽観ロック用の `"sha"` が + 含まれる。テキストが必要なら `decode_content/1` か `get_file_text/3` + を使う。`:ref` オプションでブランチ・タグ・SHA を指定できる。 + """ + @spec get_file_contents(String.t(), String.t(), keyword()) :: result() + def get_file_contents(repo, file_path, opts \\ []) do + {ref, opts} = Keyword.pop(opts, :ref) + get("/repos/#{repo}/contents/#{file_path}", put_params(opts, ref: ref)) + end + + @doc """ + ファイルを作成・更新する(contents API)。 + + `content` は生テキストを受け取り、内部で base64 エンコードする。 + 更新時は `:sha` オプションに取得済みの blob SHA を渡すこと + (楽観ロック。競合すると 409 が返る)。新規作成時は `:sha` を + 省略する。`:branch` オプションでコミット先ブランチを指定できる。 + """ + @spec put_file_contents(String.t(), String.t(), String.t(), String.t(), keyword()) :: result() + def put_file_contents(repo, file_path, content, commit_message, opts \\ []) do + {sha, opts} = Keyword.pop(opts, :sha) + {branch, opts} = Keyword.pop(opts, :branch) + + body = + %{message: commit_message, content: Base.encode64(content)} + |> put_present(:sha, sha) + |> put_present(:branch, branch) + + put("/repos/#{repo}/contents/#{file_path}", body, opts) + end + + @doc """ + ファイルを取得してテキストにデコードするところまで行う。 + + blob SHA も必要な場合(更新の前段)は `get_file_contents/3` を使い、 + `decode_content/1` と組み合わせること。 + """ + @spec get_file_text(String.t(), String.t(), keyword()) :: result() + def get_file_text(repo, file_path, opts \\ []) do + with {:ok, body} <- get_file_contents(repo, file_path, opts) do + decode_content(body) + end + end + + @doc """ + contents API のレスポンスからテキストを取り出す(純関数)。 + + `"content"` は 60 桁ごとに改行が入った base64 で返るため、 + 改行を除去してからデコードする。 + + ## Examples + + iex> ToolKit.GitHub.Client.decode_content(%{"content" => "aGVsbG8=", "encoding" => "base64"}) + {:ok, "hello"} + + """ + @spec decode_content(term()) :: {:ok, String.t()} | {:error, :invalid_content} + def decode_content(%{"content" => content, "encoding" => "base64"}) when is_binary(content) do + decoded = + content + |> String.replace(["\n", "\r"], "") + |> Base.decode64() + + case decoded do + {:ok, text} -> {:ok, text} + :error -> {:error, :invalid_content} + end + end + + def decode_content(_body), do: {:error, :invalid_content} + + # --------------------------------------------------------------- + # repo / commits / pulls / issues + # --------------------------------------------------------------- + + @doc """ + リポジトリ情報を取得する。 + """ + @spec get_repository(String.t(), keyword()) :: result() + def get_repository(repo, opts \\ []), do: get("/repos/#{repo}", opts) + + @doc """ + ブランチ一覧を取得する。`:per_page` を指定できる。 + """ + @spec list_branches(String.t(), keyword()) :: result() + def list_branches(repo, opts \\ []) do + {params, opts} = Keyword.split(opts, [:per_page]) + get("/repos/#{repo}/branches", put_params(opts, params)) + end + + @doc """ + コミット一覧を取得する。`:since`(ISO8601)・`:author`・`:per_page` + を指定できる。 + """ + @spec list_commits(String.t(), keyword()) :: result() + def list_commits(repo, opts \\ []) do + {params, opts} = Keyword.split(opts, [:since, :author, :per_page]) + get("/repos/#{repo}/commits", put_params(opts, params)) + end + + @doc """ + プルリクエスト一覧を取得する。`:state`(open / closed / all)・ + `:per_page` を指定できる。 + """ + @spec list_pull_requests(String.t(), keyword()) :: result() + def list_pull_requests(repo, opts \\ []) do + {params, opts} = Keyword.split(opts, [:state, :per_page]) + get("/repos/#{repo}/pulls", put_params(opts, params)) + end + + @doc """ + プルリクエストのレビュー一覧を取得する。`:per_page` を指定できる。 + """ + @spec list_pull_request_reviews(String.t(), pos_integer(), keyword()) :: result() + def list_pull_request_reviews(repo, pr_number, opts \\ []) do + {params, opts} = Keyword.split(opts, [:per_page]) + get("/repos/#{repo}/pulls/#{pr_number}/reviews", put_params(opts, params)) + end + + @doc """ + プルリクエストの保留中レビューリクエスト(依頼済みレビュアー)を取得する。 + """ + @spec get_requested_reviewers(String.t(), pos_integer(), keyword()) :: result() + def get_requested_reviewers(repo, pr_number, opts \\ []) do + get("/repos/#{repo}/pulls/#{pr_number}/requested_reviewers", opts) + end + + @doc """ + Issue / プルリクエストにコメントを投稿する。 + """ + @spec create_issue_comment(String.t(), pos_integer(), String.t(), keyword()) :: result() + def create_issue_comment(repo, issue_number, comment_body, opts \\ []) do + post("/repos/#{repo}/issues/#{issue_number}/comments", %{body: comment_body}, opts) + end + + @doc """ + プルリクエストをクローズする(archive 前の整理などに使う)。 + """ + @spec close_pull_request(String.t(), pos_integer(), keyword()) :: result() + def close_pull_request(repo, pr_number, opts \\ []) do + patch("/repos/#{repo}/pulls/#{pr_number}", %{state: "closed"}, opts) + end + + @doc """ + リポジトリを archive する。 + """ + @spec archive_repository(String.t(), keyword()) :: result() + def archive_repository(repo, opts \\ []) do + patch("/repos/#{repo}", %{archived: true}, opts) + end + + # --------------------------------------------------------------- + # エラー分類 + # --------------------------------------------------------------- + + @doc """ + Req の結果をエラー分類済みの `t:result/0` に写す(純関数)。 + """ + @spec classify_response({:ok, http_response()} | {:error, term()}) :: result() + def classify_response({:ok, %{status: status, body: body}}) when status in 200..299, + do: {:ok, body} + + def classify_response({:ok, %{status: 404}}), do: {:error, :not_found} + + def classify_response({:ok, %{status: status}}) when status in [401, 403], + do: {:error, :unauthorized} + + def classify_response({:ok, %{status: status, body: body}}), + do: {:error, {:http_error, status, extract_error_message(body, status)}} + + def classify_response({:error, reason}), do: {:error, {:request_failed, reason}} + + @doc """ + エラーが 404(Not Found)かを判定する。 + + 分類済みの reason(`:not_found`)と `{:error, reason}` タプルの + どちらも受け取れる。 + """ + @spec not_found_error?(term()) :: boolean() + def not_found_error?(:not_found), do: true + def not_found_error?({:error, :not_found}), do: true + def not_found_error?(_other), do: false + + @doc """ + エラーが 401 / 403(認証・権限不足)かを判定する。 + + 分類済みの reason(`:unauthorized`)と `{:error, reason}` タプルの + どちらも受け取れる。 + """ + @spec unauthorized_error?(term()) :: boolean() + def unauthorized_error?(:unauthorized), do: true + def unauthorized_error?({:error, :unauthorized}), do: true + def unauthorized_error?(_other), do: false + + # --------------------------------------------------------------- + # トークン取得・URL 構築 + # --------------------------------------------------------------- + + @doc """ + 既定のトークンプロバイダ。GitHub CLI(`gh auth token`)から取得する。 + + 外部コマンド実行のためテストカバレッジの対象外。 + """ + @spec gh_cli_token() :: {:ok, String.t()} | {:error, String.t()} + def gh_cli_token do + case System.cmd("gh", ["auth", "token"], stderr_to_stdout: true) do + {token, 0} -> + {:ok, String.trim(token)} + + {output, _exit_code} -> + {:error, "GitHub CLI authentication failed (run 'gh auth login'): #{String.trim(output)}"} + end + rescue + ErlangError -> {:error, "GitHub CLI (gh) not found in PATH"} + end + + @doc """ + ベース URL とパスをスラッシュ 1 個で結合する(純関数)。 + """ + @spec build_url(String.t(), String.t()) :: String.t() + def build_url(base_url, path) do + String.trim_trailing(base_url, "/") <> "/" <> String.trim_leading(path, "/") + end + + # --------------------------------------------------------------- + # プライベート関数 + # --------------------------------------------------------------- + + defp fetch_token(opts) do + provider = Keyword.get(opts, :token_provider, &gh_cli_token/0) + provider.() + end + + defp run_request(method, path, token, opts) do + base_url = Keyword.get(opts, :base_url, @default_base_url) + + req_opts = + [ + method: method, + url: build_url(base_url, path), + headers: build_headers(token, opts), + receive_timeout: Keyword.get(opts, :receive_timeout, @default_receive_timeout), + retry: false + ] + |> put_present(:params, opts[:params]) + |> put_present(:json, opts[:json]) + |> Keyword.merge(Keyword.get(opts, :req_options, [])) + + Req.request(req_opts) + end + + defp build_headers(token, opts) do + [ + {"accept", "application/vnd.github+json"}, + {"x-github-api-version", @api_version}, + {"authorization", "Bearer " <> token}, + {"user-agent", Keyword.get(opts, :user_agent, @default_user_agent)} + ] + end + + defp extract_error_message(%{"message" => message}, _status), do: message + defp extract_error_message(body, status) when is_binary(body), do: "#{status} - #{body}" + defp extract_error_message(_body, status), do: "HTTP #{status}" + + # クエリパラメータ(nil の値は落とす)を opts の :params にマージする。 + # 呼び出し元が :params を渡していた場合は保持し、同名キーはヘルパ側を優先する + defp put_params(opts, params) do + case Enum.reject(params, fn {_key, value} -> is_nil(value) end) do + [] -> opts + present -> Keyword.update(opts, :params, present, &Keyword.merge(&1, present)) + end + end + + defp put_present(map, _key, nil) when is_map(map), do: map + defp put_present(map, key, value) when is_map(map), do: Map.put(map, key, value) + defp put_present(keyword, _key, nil) when is_list(keyword), do: keyword + + defp put_present(keyword, key, value) when is_list(keyword), + do: Keyword.put(keyword, key, value) +end diff --git a/mix.exs b/mix.exs index dbcb9cf..ad8d855 100644 --- a/mix.exs +++ b/mix.exs @@ -35,6 +35,8 @@ defmodule ToolKit.MixProject do {:jason, "~> 1.4"}, {:yaml_elixir, "~> 2.9"}, {:req, "~> 0.5"}, + # Req.Test(Plug ベースの HTTP スタブ)用 + {:plug, "~> 1.16", only: [:test]}, {:dialyxir, "~> 1.4", only: [:dev], runtime: false}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false} ] diff --git a/mix.lock b/mix.lock index 0f7376f..8931a92 100644 --- a/mix.lock +++ b/mix.lock @@ -11,6 +11,8 @@ "mint": {:hex, :mint, "1.9.3", "3337184d69179695c7a9f1714d92c11e629d36c8c037a21cf490131d3d150554", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5f7c9342480c069dbbc4eeac3490303c9e01870ff01a7f1d29b6107054fc1e74"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "req": {:hex, :req, "0.6.3", "7fe5e68792ff0546e45d5919104fa1764a13694cfe3e48c8a0f32ad051ae77e4", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "e85b5c6c990e6c3f52bbba68e6f099118f2b8252825f96c7c3636b97a3de307d"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, diff --git a/test/tool_kit/github/client_gh_cli_test.exs b/test/tool_kit/github/client_gh_cli_test.exs new file mode 100644 index 0000000..9e61c0f --- /dev/null +++ b/test/tool_kit/github/client_gh_cli_test.exs @@ -0,0 +1,28 @@ +defmodule ToolKit.GitHub.ClientGhCliTest do + # PATH 環境変数を書き換えるテストを含むため、他のテストと並列実行しない + use ExUnit.Case, async: false + + alias ToolKit.GitHub.Client + + describe "gh_cli_token/0(既定プロバイダ)" do + # 環境依存(gh の有無・認証状態)のため、戻り値の形だけを検証する + test "認証状態にかかわらず {:ok, token} か {:error, message} を返す" do + case Client.gh_cli_token() do + {:ok, token} -> assert is_binary(token) and token != "" + {:error, message} -> assert is_binary(message) + end + end + + test "gh が見つからない場合はエラーメッセージを返す" do + original_path = System.get_env("PATH") + + try do + System.put_env("PATH", "") + assert {:error, message} = Client.gh_cli_token() + assert message =~ "gh" + after + System.put_env("PATH", original_path) + end + end + end +end diff --git a/test/tool_kit/github/client_test.exs b/test/tool_kit/github/client_test.exs new file mode 100644 index 0000000..9b17650 --- /dev/null +++ b/test/tool_kit/github/client_test.exs @@ -0,0 +1,403 @@ +defmodule ToolKit.GitHub.ClientTest do + use ExUnit.Case, async: true + + alias ToolKit.GitHub.Client + + doctest ToolKit.GitHub.Client + + @stub ToolKit.GitHub.ClientStub + + # 実 HTTP は呼ばず、Req.Test の Plug スタブへ差し替える共通オプション + defp opts(extra \\ []) do + Keyword.merge( + [ + token_provider: fn -> {:ok, "test-token"} end, + req_options: [plug: {Req.Test, @stub}] + ], + extra + ) + end + + defp stub_capture(test_pid, response_body) do + Req.Test.stub(@stub, fn conn -> + {:ok, raw_body, conn} = Plug.Conn.read_body(conn) + conn = Plug.Conn.fetch_query_params(conn) + + send( + test_pid, + {:request, + %{method: conn.method, path: conn.request_path, query: conn.query_params, body: raw_body}} + ) + + Req.Test.json(conn, response_body) + end) + end + + describe "request/3 の共通挙動" do + test "Bearer トークンと GitHub API 用ヘッダを送る" do + Req.Test.stub(@stub, fn conn -> + assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer test-token"] + assert Plug.Conn.get_req_header(conn, "accept") == ["application/vnd.github+json"] + assert Plug.Conn.get_req_header(conn, "x-github-api-version") == ["2022-11-28"] + assert [user_agent] = Plug.Conn.get_req_header(conn, "user-agent") + assert user_agent =~ "elixir-tool-kit" + Req.Test.json(conn, %{"ok" => true}) + end) + + assert {:ok, %{"ok" => true}} = Client.get("/repos/smkwlab/demo", opts()) + end + + test ":user_agent オプションで User-Agent を差し替えられる" do + Req.Test.stub(@stub, fn conn -> + assert Plug.Conn.get_req_header(conn, "user-agent") == ["my-tool/1.0"] + Req.Test.json(conn, %{}) + end) + + assert {:ok, _} = Client.get("/user", opts(user_agent: "my-tool/1.0")) + end + + test "token_provider の失敗は {:token_error, reason} に包んで返す" do + failing = fn -> {:error, :no_token} end + + assert {:error, {:token_error, :no_token}} = + Client.get("/repos/smkwlab/demo", opts(token_provider: failing)) + end + + test "post/2 は JSON ボディを送る" do + stub_capture(self(), %{"id" => 1}) + + assert {:ok, %{"id" => 1}} = + Client.post("/repos/smkwlab/demo/labels", %{name: "bug"}, opts()) + + assert_received {:request, request} + assert request.method == "POST" + assert Jason.decode!(request.body) == %{"name" => "bug"} + end + + test "put/2 と patch/2 は対応する HTTP メソッドを使う" do + stub_capture(self(), %{}) + assert {:ok, _} = Client.put("/x", %{a: 1}, opts()) + assert_received {:request, %{method: "PUT"}} + + stub_capture(self(), %{}) + assert {:ok, _} = Client.patch("/x", %{a: 1}, opts()) + assert_received {:request, %{method: "PATCH"}} + end + + test "通信自体の失敗は {:request_failed, reason} を返す" do + Req.Test.stub(@stub, fn conn -> + Req.Test.transport_error(conn, :timeout) + end) + + assert {:error, {:request_failed, %Req.TransportError{reason: :timeout}}} = + Client.get("/repos/smkwlab/demo", opts()) + end + end + + describe "エラー分類(HTTP 経由)" do + test "404 は {:error, :not_found}" do + Req.Test.stub(@stub, fn conn -> + conn |> Plug.Conn.put_status(404) |> Req.Test.json(%{"message" => "Not Found"}) + end) + + assert {:error, :not_found} = Client.get("/repos/smkwlab/none", opts()) + end + + test "401 と 403 は {:error, :unauthorized}" do + for status <- [401, 403] do + Req.Test.stub(@stub, fn conn -> + conn |> Plug.Conn.put_status(status) |> Req.Test.json(%{"message" => "Bad credentials"}) + end) + + assert {:error, :unauthorized} = Client.get("/repos/smkwlab/private", opts()) + end + end + + test "その他のエラーは {:http_error, status, message}" do + Req.Test.stub(@stub, fn conn -> + conn |> Plug.Conn.put_status(422) |> Req.Test.json(%{"message" => "Validation Failed"}) + end) + + assert {:error, {:http_error, 422, "Validation Failed"}} = + Client.get("/repos/smkwlab/demo", opts()) + end + end + + describe "classify_response/1(純関数)" do + test "2xx はボディをそのまま返す" do + assert Client.classify_response({:ok, %{status: 200, body: %{"a" => 1}}}) == + {:ok, %{"a" => 1}} + + assert Client.classify_response({:ok, %{status: 201, body: nil}}) == {:ok, nil} + end + + test "404 / 401 / 403 を分類する" do + assert Client.classify_response({:ok, %{status: 404, body: %{}}}) == {:error, :not_found} + assert Client.classify_response({:ok, %{status: 401, body: %{}}}) == {:error, :unauthorized} + assert Client.classify_response({:ok, %{status: 403, body: %{}}}) == {:error, :unauthorized} + end + + test "エラーメッセージはボディ形式に応じて抽出する" do + assert Client.classify_response({:ok, %{status: 500, body: %{"message" => "boom"}}}) == + {:error, {:http_error, 500, "boom"}} + + assert Client.classify_response({:ok, %{status: 500, body: "internal error"}}) == + {:error, {:http_error, 500, "500 - internal error"}} + + assert Client.classify_response({:ok, %{status: 502, body: %{"other" => true}}}) == + {:error, {:http_error, 502, "HTTP 502"}} + end + + test "Req のエラーは {:request_failed, reason} に包む" do + assert Client.classify_response({:error, :nxdomain}) == + {:error, {:request_failed, :nxdomain}} + end + end + + describe "build_url/2(純関数)" do + test "base_url と path をスラッシュ 1 個で結合する" do + assert Client.build_url("https://api.github.com", "/repos/a/b") == + "https://api.github.com/repos/a/b" + + assert Client.build_url("https://api.github.com/", "repos/a/b") == + "https://api.github.com/repos/a/b" + + assert Client.build_url("https://ghe.example.com/api/v3/", "/repos/a/b") == + "https://ghe.example.com/api/v3/repos/a/b" + end + end + + describe "contents ヘルパ" do + test "get_file_contents/3 は contents API を GET する" do + stub_capture(self(), %{"content" => "e30=", "encoding" => "base64", "sha" => "abc"}) + + assert {:ok, %{"sha" => "abc"}} = + Client.get_file_contents("smkwlab/repo", "data/registry.json", opts()) + + assert_received {:request, request} + assert request.method == "GET" + assert request.path == "/repos/smkwlab/repo/contents/data/registry.json" + assert request.query == %{} + end + + test "get_file_contents/3 は :ref をクエリに載せる" do + stub_capture(self(), %{}) + assert {:ok, _} = Client.get_file_contents("smkwlab/repo", "README.md", opts(ref: "main")) + + assert_received {:request, %{query: %{"ref" => "main"}}} + end + + test "put_file_contents/5 は base64 化した内容と SHA を PUT する" do + stub_capture(self(), %{"commit" => %{"sha" => "new"}}) + + assert {:ok, _} = + Client.put_file_contents( + "smkwlab/repo", + "data/registry.json", + ~s({"students": []}), + "chore: update registry", + opts(sha: "oldsha", branch: "main") + ) + + assert_received {:request, request} + assert request.method == "PUT" + assert request.path == "/repos/smkwlab/repo/contents/data/registry.json" + + body = Jason.decode!(request.body) + assert body["message"] == "chore: update registry" + assert body["sha"] == "oldsha" + assert body["branch"] == "main" + assert Base.decode64!(body["content"]) == ~s({"students": []}) + end + + test "put_file_contents/5 は SHA なし(新規作成)なら sha キーを送らない" do + stub_capture(self(), %{}) + + assert {:ok, _} = + Client.put_file_contents("smkwlab/repo", "new.txt", "hello", "add file", opts()) + + assert_received {:request, request} + body = Jason.decode!(request.body) + refute Map.has_key?(body, "sha") + refute Map.has_key?(body, "branch") + end + + test "get_file_text/3 は取得とデコードをまとめて行う" do + encoded = Base.encode64("hello world") + stub_capture(self(), %{"content" => encoded, "encoding" => "base64"}) + + assert {:ok, "hello world"} = Client.get_file_text("smkwlab/repo", "hello.txt", opts()) + end + + test "get_file_text/3 は取得エラーをそのまま返す" do + Req.Test.stub(@stub, fn conn -> + conn |> Plug.Conn.put_status(404) |> Req.Test.json(%{"message" => "Not Found"}) + end) + + assert {:error, :not_found} = Client.get_file_text("smkwlab/repo", "none.txt", opts()) + end + end + + describe "decode_content/1(純関数)" do + test "60 桁ごとの改行入り base64 をデコードする" do + text = String.duplicate("あいうえお", 30) + + wrapped = + text + |> Base.encode64() + |> String.codepoints() + |> Enum.chunk_every(60) + |> Enum.map_join("\n", &Enum.join/1) + + assert Client.decode_content(%{"content" => wrapped, "encoding" => "base64"}) == {:ok, text} + end + + test "不正な base64 は {:error, :invalid_content}" do + assert Client.decode_content(%{"content" => "%%%", "encoding" => "base64"}) == + {:error, :invalid_content} + end + + test "想定外の形式は {:error, :invalid_content}" do + assert Client.decode_content(%{"encoding" => "base64"}) == {:error, :invalid_content} + + assert Client.decode_content(%{"content" => "e30=", "encoding" => "utf-8"}) == + {:error, :invalid_content} + + assert Client.decode_content("not a map") == {:error, :invalid_content} + end + end + + describe "repo / commits / pulls ヘルパ" do + test "get_repository/2 はリポジトリ情報を GET する" do + stub_capture(self(), %{"full_name" => "smkwlab/repo"}) + + assert {:ok, %{"full_name" => "smkwlab/repo"}} = + Client.get_repository("smkwlab/repo", opts()) + + assert_received {:request, %{method: "GET", path: "/repos/smkwlab/repo"}} + end + + test "list_branches/2 はブランチ一覧を GET する" do + stub_capture(self(), [%{"name" => "main"}]) + + assert {:ok, [%{"name" => "main"}]} = + Client.list_branches("smkwlab/repo", opts(per_page: 100)) + + assert_received {:request, request} + assert request.path == "/repos/smkwlab/repo/branches" + assert request.query == %{"per_page" => "100"} + end + + test "list_commits/2 は since / author / per_page をクエリに載せる" do + stub_capture(self(), []) + + assert {:ok, []} = + Client.list_commits( + "smkwlab/repo", + opts(since: "2026-07-01T00:00:00Z", author: "student", per_page: 10) + ) + + assert_received {:request, request} + assert request.path == "/repos/smkwlab/repo/commits" + + assert request.query == %{ + "since" => "2026-07-01T00:00:00Z", + "author" => "student", + "per_page" => "10" + } + end + + test "list_commits/2 は nil のパラメータを送らない" do + stub_capture(self(), []) + assert {:ok, []} = Client.list_commits("smkwlab/repo", opts(author: nil, per_page: 1)) + + assert_received {:request, %{query: %{"per_page" => "1"} = query}} + refute Map.has_key?(query, "author") + end + + test "ヘルパは呼び出し元の :params を保持したままマージする" do + stub_capture(self(), []) + + assert {:ok, []} = Client.list_commits("smkwlab/repo", opts(per_page: 5, params: [page: 2])) + + assert_received {:request, %{query: %{"per_page" => "5", "page" => "2"}}} + end + + test "list_pull_requests/2 は state / per_page をクエリに載せる" do + stub_capture(self(), []) + + assert {:ok, []} = + Client.list_pull_requests("smkwlab/repo", opts(state: "all", per_page: 100)) + + assert_received {:request, request} + assert request.path == "/repos/smkwlab/repo/pulls" + assert request.query == %{"state" => "all", "per_page" => "100"} + end + + test "list_pull_request_reviews/3 はレビュー一覧を GET する" do + stub_capture(self(), []) + assert {:ok, []} = Client.list_pull_request_reviews("smkwlab/repo", 12, opts(per_page: 100)) + + assert_received {:request, request} + assert request.path == "/repos/smkwlab/repo/pulls/12/reviews" + assert request.query == %{"per_page" => "100"} + end + + test "get_requested_reviewers/3 はレビューリクエストを GET する" do + stub_capture(self(), %{"users" => []}) + assert {:ok, %{"users" => []}} = Client.get_requested_reviewers("smkwlab/repo", 12, opts()) + + assert_received {:request, %{path: "/repos/smkwlab/repo/pulls/12/requested_reviewers"}} + end + + test "create_issue_comment/4 はコメントを POST する" do + stub_capture(self(), %{"id" => 1}) + + assert {:ok, _} = Client.create_issue_comment("smkwlab/repo", 34, "対応しました", opts()) + + assert_received {:request, request} + assert request.method == "POST" + assert request.path == "/repos/smkwlab/repo/issues/34/comments" + assert Jason.decode!(request.body) == %{"body" => "対応しました"} + end + + test "close_pull_request/3 は state: closed を PATCH する" do + stub_capture(self(), %{"state" => "closed"}) + + assert {:ok, _} = Client.close_pull_request("smkwlab/repo", 5, opts()) + + assert_received {:request, request} + assert request.method == "PATCH" + assert request.path == "/repos/smkwlab/repo/pulls/5" + assert Jason.decode!(request.body) == %{"state" => "closed"} + end + + test "archive_repository/2 は archived: true を PATCH する" do + stub_capture(self(), %{"archived" => true}) + + assert {:ok, _} = Client.archive_repository("smkwlab/repo", opts()) + + assert_received {:request, request} + assert request.method == "PATCH" + assert request.path == "/repos/smkwlab/repo" + assert Jason.decode!(request.body) == %{"archived" => true} + end + end + + describe "エラー述語" do + test "not_found_error?/1 は :not_found と {:error, :not_found} を真とする" do + assert Client.not_found_error?(:not_found) + assert Client.not_found_error?({:error, :not_found}) + refute Client.not_found_error?(:unauthorized) + refute Client.not_found_error?({:error, {:http_error, 500, "boom"}}) + refute Client.not_found_error?("GitHub API error (404)") + end + + test "unauthorized_error?/1 は :unauthorized と {:error, :unauthorized} を真とする" do + assert Client.unauthorized_error?(:unauthorized) + assert Client.unauthorized_error?({:error, :unauthorized}) + refute Client.unauthorized_error?(:not_found) + refute Client.unauthorized_error?(nil) + end + end +end