Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
397 changes: 397 additions & 0 deletions lib/tool_kit/github/client.ex
Original file line number Diff line number Diff line change
@@ -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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [HIGH] ページネーション(複数ページ取得)のサポートが欠如しています。GitHub API の多くのエンドポイントはデフォルトで30件、最大100件しか返しません。list_brancheslist_commitslist_pull_requests などは大規模リポジトリで結果が切り捨てられる可能性があります。

Link ヘッダを解析して自動的に全ページを取得するオプション(例: :paginate フラグ)、または少なくともレスポンスヘッダを呼び出し元に公開する仕組みを検討してください。現状では呼び出し側がページネーションを制御する手段がありません。

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

据え置きます(設計意図)。本モジュールは epic D2 の方針で「薄い HTTP 境界」に徹し、orchestration はツール側に残します。採用予定の registry-manager / thesis-monitor はいずれも per_page(最大 100)で足りる規模で、thesis-monitor 側には「100 件超は非現実的なためページネーションは追わない」と明記されています。また「呼び出し側が制御する手段がない」は当たりません。各ヘルパは呼び出し元の :params を保持してマージするため(64e42eb で明確化)、params: [page: 2] を渡せば手動ページングできますし、汎用 get/2 でも任意のクエリを指定できます。Link ヘッダの自動追跡が必要になった時点で別 issue として検討します。

def list_branches(repo, opts \\ []) do

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [HIGH] ページネーション(複数ページ)への対応が欠けています。GitHub REST API の多くのエンドポイントは Link ヘッダを使ったページネーションを返しますが、現在の実装は1ページ目しか取得できません。list_branches/2list_commits/2list_pull_requests/2 などで per_page を受け付けているにもかかわらず、全件取得のための自動ページネーション機能がないため、デフォルトの30件(または指定件数)を超えるデータが存在する場合にサイレントに切り捨てられます。少なくともドキュメントに「1ページ分のみ返す」旨を明記するか、Link ヘッダを辿る仕組みを検討してください。

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

据え置きます(前ラウンドの同指摘 #14 (comment) への返信と同じ根拠)。本モジュールは epic D2 の方針で薄い HTTP 境界に徹し、採用予定の 2 ツールはいずれも per_page(最大 100)で足りる運用です。各ヘルパの @doc は「:per_page を指定できる」と単ページ取得の語彙で書いており、params: [page: N] による手動ページングも可能です(マージ挙動はテストで担保)。Link ヘッダ自動追跡が必要になった時点で別 issue にします。

{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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ [LOW] classify_response/1 の2xxマッチ(status in 200..299)は Req.Responsestatus フィールドが整数であることを前提としています。一方、http_response 型定義は non_neg_integer() と広く定義されており、100番台や300番台のステータスが {:ok, body} として返される可能性があります。リダイレクト(3xx)は Req がデフォルトでフォローするため実害は少ないですが、型定義を 200..299 に絞るか、ガード節のコメントで意図を明示することを推奨します。

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

据え置きます(前提の誤りを指摘)。status in 200..299 のガードは 2xx のみにマッチするため、1xx / 3xx が {:ok, body} になることはありません。両者は最後の節に落ちて {:error, {:http_error, status, message}} に分類されます(例: 304 → {:http_error, 304, "HTTP 304"})。また 3xx は Req が既定でリダイレクトを追跡するため、終端レスポンスとして現れるのは例外的です。http_responsenon_neg_integer() は入力型(Req のレスポンス全般)の記述であり、分類結果を緩めるものではありません。

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ [LOW] gh_cli_token/0ErlangError のみをrescueしていますが、System.cmd/3 が失敗する場合は ErlangError ではなく File.Error や他の例外が発生する可能性があります。また、gh コマンドが存在するが認証されていない場合(exit code != 0)のエラーメッセージが固定文字列で、実際のエラー出力が捨てられています。デバッグ性向上のため、実際の出力をエラーメッセージに含めることを検討してください:

{output, _exit_code} -> {:error, "GitHub CLI authentication failed: #{String.trim(output)}"}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

一部修正しました(64e42eb)。認証失敗時(exit code != 0)は gh の実出力(stderr 込み)をエラーメッセージに含めるようにしました。rescue については、System.cmd/3 が実行ファイル不在時に送出するのは ErlangError(:enoent)であり、File.ErrorSystem.cmd/3 からは発生しないため、現状の ErlangError rescue のままとします(PATH 不在ケースはテストで検証済み)。

{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"}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [MEDIUM] gh_cli_token/0ErlangError のみをレスキューしていますが、System.cmd/3gh コマンドを見つけられない場合に投げる例外は Elixir/OTP のバージョンによって異なる可能性があります。現在のテスト(PATH を空にする)で動作確認済みとのことですが、File.Error や他の例外が漏れるリスクがあります。より安全にするには rescue e -> {:error, inspect(e)} のように広くキャッチするか、:enoent を明示的に処理することを検討してください。

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

据え置きます。System.cmd/3 は実行ファイル不在時に :erlang.error(:enoent) 由来の ErlangError を送出します(Elixir 本体の実装で OTP バージョンに依存しません)。File.ErrorSystem.cmd/3 からは発生しません。rescue e -> {:error, inspect(e)} のような広いキャッチは、引数の型誤りなど本来クラッシュすべきバグまで握りつぶすため採用しません。PATH 不在ケースは ClientGhCliTest で実挙動を検証済みです。

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [MEDIUM] put_params/2 関数は既存の :params を上書きします。もし呼び出し元が opts:params を既に設定していた場合、それが失われます。Keyword.mergeKeyword.update を使って既存のパラメータとマージする実装の方が安全です:

defp put_params(opts, params) do
  present = Enum.reject(params, fn {_key, value} -> is_nil(value) end)
  case present do
    [] -> opts
    _ ->
      existing = Keyword.get(opts, :params, [])
      Keyword.put(opts, :params, Keyword.merge(existing, present))
  end
end

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

修正しました(64e42eb)。put_params/2Keyword.update/4 + Keyword.merge/2 に変更し、呼び出し元の :params を保持するようにしました(同名キーはヘルパ側優先)。params: [page: 2]per_page: 5 が両立することを確認するテストも追加しています。

case Enum.reject(params, fn {_key, value} -> is_nil(value) end) do
[] -> opts
present -> Keyword.update(opts, :params, present, &Keyword.merge(&1, present))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [MEDIUM] put_params/2 の実装でヘルパ側のパラメータが呼び出し元の :params より優先されます(Keyword.merge(&1, present) は後者が優先)。コメントには「同名キーはヘルパ側を優先する」と書かれており意図的ですが、呼び出し元が明示的に渡したパラメータが上書きされる挙動は直感に反する可能性があります。優先順位の根拠をドキュメントに明記することを推奨します。

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

据え置きます(挙動は意図どおり・根拠をここに明記)。ヘルパの名前付きオプション(since: / state: など)はそのヘルパの明示的な API であり、汎用の :params 経由の同名キーより優先されるべきです。そうしないと list_commits(repo, since: x, params: [since: y]) のような呼び出しでヘルパのシグネチャが嘘になります。優先順位は private 関数のコメントに記載済みで、:params 併用の挙動はテスト「ヘルパは呼び出し元の :params を保持したままマージする」で担保しています。

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
Loading
Loading