Skip to content

feat: add GitHub REST client (ToolKit.GitHub.Client) - #14

Merged
toshi0806 merged 2 commits into
mainfrom
issue-5-github-client
Jul 23, 2026
Merged

feat: add GitHub REST client (ToolKit.GitHub.Client)#14
toshi0806 merged 2 commits into
mainfrom
issue-5-github-client

Conversation

@toshi0806

Copy link
Copy Markdown
Member

概要

registry-manager / thesis-monitor が共通で使える Req ベースの GitHub REST ラッパ ToolKit.GitHub.Client を追加します(K5)。orchestration 層と純パース層はツール側に残す方針(epic D2)のため持ち込んでいません。

変更内容

  • lib/tool_kit/github/client.ex
    • token はプロバイダ関数注入: :token_provider(-> {:ok, token} | {:error, reason})。既定は gh auth token(gh_cli_token/0)。thesis-monitor は TokenManager をそのまま渡せる
    • :base_url / :receive_timeout / :user_agent / :req_options を全関数共通オプションとして受け付け
    • 汎用リクエスト(get/2 post/3 put/3 patch/3 request/3)
    • endpoint ヘルパ: contents GET/PUT(SHA 楽観ロック・base64)、get_file_text/3decode_content/1、repo 情報、branches、commits(since/author/per_page)、pulls(一覧・reviews・requested_reviewers)、issue コメント、PR close、repo archive
    • エラー分類: 404 → {:error, :not_found}、401/403 → {:error, :unauthorized}、その他 → {:http_error, status, message} / {:request_failed, reason} / {:token_error, reason}。述語 not_found_error?/1 / unauthorized_error?/1
  • test/tool_kit/github/client_test.exs
    • 実 HTTP なし。Req.Test(Plug スタブ)で全 endpoint ヘルパ・ヘッダ・エラー分類を検証
    • classify_response/1 / build_url/2 / decode_content/1 は純関数としてユニットテスト
  • mix.exs: plug(test only、Req.Test 用)を追加

確認済み

  • mix format --check-formatted / mix credo --strict(0 issues)/ mix dialyzer(0 errors)/ mix test --cover 全 green
  • 10 doctests + 105 tests, 0 failures / カバレッジ Total 91.24%(threshold 80)

Resolves #5
親 epic: smkwlab/latex-ecosystem#146

https://claude.ai/code/session_015FdaeD1BANgCMUcNTUjKVV

Add a thin Req-based wrapper for the GitHub REST API shared by
registry-manager and thesis-monitor:

- token injection via :token_provider (default: gh auth token)
- generic get/post/put/patch plus endpoint helpers for contents
  (GET/PUT with SHA optimistic locking and base64), repository info,
  branches, commits, pulls (list/reviews/requested reviewers),
  issue comments, PR close, and repository archive
- error classification: 404 -> :not_found, 401/403 -> :unauthorized,
  with not_found_error?/1 and unauthorized_error?/1 predicates
- tests use Req.Test (Plug stub); no real HTTP in the suite

Orchestration and response parsing stay in each tool (epic D2).

Resolves #5

Claude-Session: https://claude.ai/code/session_015FdaeD1BANgCMUcNTUjKVV

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

全体的に設計が明確で、テストカバレッジも十分です。以下にいくつかの重要な指摘事項を挙げます。

@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 として検討します。

Comment thread lib/tool_kit/github/client.ex Outdated

defp build_headers(token, opts) do
[
{"accept", "application/vnd.github.v3+json"},

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] GitHub API のバージョン指定が古い形式です。application/vnd.github.v3+json は非推奨となっており、GitHub の現在の推奨は application/vnd.github+json(バージョン番号なし)です。また、GitHub は X-GitHub-Api-Version: 2022-11-28 ヘッダによる明示的なAPIバージョン固定を推奨しています。将来的なAPI変更への耐性を高めるため、ヘッダの更新を検討してください。

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)。Accept を application/vnd.github+json に変更し、X-GitHub-Api-Version: 2022-11-28 を常時送るようにしました。テストのヘッダ検証も更新済みです。

defp extract_error_message(_body, status), do: "HTTP #{status}"

# クエリパラメータ(nil の値は落とす)を opts の :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 が両立することを確認するテストも追加しています。

Comment thread test/tool_kit/github/client_test.exs Outdated
end
end

test "gh が見つからない場合はエラーメッセージを返す" 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] gh が見つからない場合 のテストで System.put_env("PATH", "") を使っていますが、これはプロセス全体の環境変数を変更するため、async: true で並列実行される他のテストに影響を与える可能性があります。ExUnit.CaptureLogMox を使ったモック、あるいはこのテストだけ async: false にするか、テストを分離したモジュールに移動することを検討してください。

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)。PATH を書き換える 2 テストを ToolKit.GitHub.ClientGhCliTest(async: false)へ分離し、並列実行中の他テストへ影響しないようにしました。Mox 等の新規依存は本ライブラリの方針(依存最小)に沿って導入していません。

"""
@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 不在ケースはテストで検証済み)。

- use application/vnd.github+json and pin X-GitHub-Api-Version
- merge helper query params with caller-provided :params
- keep gh CLI stderr output in the authentication error message
- move PATH-mutating gh_cli_token tests to a non-async module

Refs #5

Claude-Session: https://claude.ai/code/session_015FdaeD1BANgCMUcNTUjKVV

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

全体的に設計が明確で、型仕様・ドキュメント・テストが充実しています。以下にいくつかの重要な指摘事項を挙げます。

ブランチ一覧を取得する。`:per_page` を指定できる。
"""
@spec list_branches(String.t(), keyword()) :: result()
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 にします。

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

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 を保持したままマージする」で担保しています。

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

test "gh が見つからない場合はエラーメッセージを返す" do
original_path = System.get_env("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] System.put_env("PATH", "") でPATHを空にするアプローチは、async: false にしているとはいえ、テスト実行中に他のプロセスが PATH を参照する場合に影響を与えます。また、try/after ブロックで元の値を復元していますが、original_pathnil(環境変数未設定)の場合に System.put_env/2nil を渡してしまいます。original_path || "" のようなフォールバックを追加してください。

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.

据え置きます。mix test を起動できている時点で PATH は必ず設定されており(シェル・CI いずれも PATH 経由で elixir を解決)、System.get_env("PATH") が nil になる実行環境は存在しません。仮に nil だった場合も after 節の System.put_env/2 が即座に例外を上げてテストが騒がしく失敗するだけで、静かな汚染は起きません。テスト専用コードにこの仮定のためのフォールバックを足すより、現状の単純さを優先します。

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 のレスポンス全般)の記述であり、分類結果を緩めるものではありません。

@toshi0806

Copy link
Copy Markdown
Member Author

AI レビュー対応まとめ

指摘 10 件(初回 5 + 再走 5)に対応しました。最新 commit 64e42eb で CI / AI レビューとも success です。

修正(64e42eb)

  • [MEDIUM] Accept を application/vnd.github+json に更新し X-GitHub-Api-Version: 2022-11-28 を常時送信
  • [MEDIUM] put_params/2 が呼び出し元の :params を上書きしていた点を Keyword.merge に変更(保持テスト追加)
  • [MEDIUM] PATH を書き換えるテストを async: false の別モジュールへ分離
  • [LOW] 認証失敗時に gh の実出力をエラーメッセージへ含めるよう変更

据え置き(各スレッドに根拠を返信)

  • [HIGH x2] ページネーション自動追跡: epic D2 の薄いラッパ方針。両ツールとも per_page で足り、params: [page: N] で手動ページング可能。必要になれば別 issue
  • [MEDIUM] put_params/2 の優先順位: ヘルパの名前付きオプションが API 表面であり優先が正しい
  • [MEDIUM] rescue の範囲: System.cmd/3 の実行ファイル不在は ErlangError で固定。広いキャッチはバグ隠蔽のため不採用
  • [MEDIUM] PATH nil フォールバック: mix test 起動時点で PATH は必ず存在。nil でも after 節が即例外で静かな汚染なし
  • [LOW] classify_response/1 の 1xx/3xx: ガードは 2xx のみマッチし、1xx/3xx は {:http_error, ...} に分類される(誤指摘)

@toshi0806
toshi0806 merged commit 1379f3a into main Jul 23, 2026
5 checks passed
@toshi0806
toshi0806 deleted the issue-5-github-client branch July 23, 2026 00:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add GitHub REST client (ToolKit.GitHub.Client)

1 participant