From ccfebafa0f58b710e312d16c5a4bf7eb4278a096 Mon Sep 17 00:00:00 2001 From: RyoheiHashimoto Date: Tue, 15 Sep 2026 01:59:49 +0900 Subject: [PATCH 1/2] ci: cross-review pull requests with agy and verify with Claude On every pull request, agy (Antigravity CLI, Gemini) reviews the diff and Claude checks each finding against the code, then posts one comment on the pull request, updated on later pushes. The job fails only when the review cannot run; findings do not fail it. Drafts and pull requests from forks are skipped, since forks get no secrets. Needs the GEMINI_API_KEY and CLAUDE_CODE_OAUTH_TOKEN secrets. .github/cross-review/run.sh is a copy of the script behind the local cross-review skill. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016NTf9Pw7SBKQgkRsDWyKsd --- .github/cross-review/run.sh | 187 +++++++++++++++++++++++++++++ .github/cross-review/verify.md | 12 ++ .github/workflows/cross-review.yml | 114 ++++++++++++++++++ 3 files changed, 313 insertions(+) create mode 100755 .github/cross-review/run.sh create mode 100644 .github/cross-review/verify.md create mode 100644 .github/workflows/cross-review.yml diff --git a/.github/cross-review/run.sh b/.github/cross-review/run.sh new file mode 100755 index 0000000..6921b44 --- /dev/null +++ b/.github/cross-review/run.sh @@ -0,0 +1,187 @@ +#!/bin/bash +# cross-review: 差分を Antigravity CLI(agy)にレビューさせ、結果を検査して出力する。 +# 使い方: run.sh [git diff の引数...] 省略時は HEAD(コミットしていない変更すべて。未追跡のファイルも含む) +# 出力の1行目: CROSS_REVIEW_OK / CROSS_REVIEW_EMPTY / CROSS_REVIEW_FAILED +# HEAD(作業ツリー)のレビューに成功したときは、その状態を .git/cross-review-ok に記録する。 +# コミット前の hook(gate.py)は、この記録と今の状態を比べる。 +# +# agy 1.2.2 の headless で確かめた挙動(2026-09-12〜13): +# - 許可のない操作は拒否され、その場で打ち切られる。それでも status=SUCCESS・exit 0 で応答は空になり、 +# denied_actions にだけ残る。カレントディレクトリの読み取りも --add-dir を付けないと拒否される。 +# - --print-timeout を超えると途中までの応答で status=SUCCESS・exit 0 になる(stderr に "print timeout")。 +# - ツールを使う回で、拒否もタイムアウトもないのに応答が空のまま SUCCESS で終わることがある +# (obd2 の 1ac4459 で3回中1回。会話の記録にも最終回答が残っていない)。 +# なので終了コードと status では判定せず、denied_actions・stderr・空の応答を見る。 +# 打ち切りと空の応答のときは、ツールを使わない指示で1回だけ再実行する。 +set -u + +here=$(cd "$(dirname "$0")" && pwd) +MODEL="${CROSS_REVIEW_MODEL:-gemini-3.1-pro-high}" +MAX_BYTES=400000 +BUDGET=540 # 秒。Claude Code の Bash ツールの上限(10分)に収める + +fail() { echo "CROSS_REVIEW_FAILED: $*"; exit 1; } +# 作業ツリーの状態ハッシュ。state.py がないとき(リポジトリに写した run.sh など)や、計算できないときは空 +state_of() { + [ -f "$here/state.py" ] || return 0 + local out + out=$(python3 "$here/state.py" "$root" 2>/dev/null) || return 0 + printf '%s' "${out%% *}" +} + +command -v agy >/dev/null || fail "agy が見つかりません(~/.local/bin/agy)" +root=$(git rev-parse --show-toplevel 2>/dev/null) || fail "git リポジトリの中で実行してください" +cd "$root" || fail "cd $root に失敗しました" + +[ $# -eq 0 ] && set -- HEAD +worktree=0 +[ $# -eq 1 ] && [ "$1" = "HEAD" ] && worktree=1 + +diff=$(git diff "$@") || fail "git diff $* が失敗しました" +untracked=$(git ls-files --others --exclude-standard) +state_before="" +if [ "$worktree" = 1 ]; then + # 作業ツリーのレビューでは、未追跡のファイルも新規ファイルとして差分に含める(コミットされうるので) + while IFS= read -r -d '' f; do + diff="${diff} +$(git diff --no-index -- /dev/null "$f")" + done < <(git ls-files --others --exclude-standard -z) + untracked="" + state_before=$(state_of) +fi +if [ -z "$diff" ]; then + echo "CROSS_REVIEW_EMPTY: 差分がありません(git diff $*)" + exit 0 +fi +bytes=$(printf '%s' "$diff" | wc -c | tr -d ' ') +[ "$bytes" -le "$MAX_BYTES" ] || fail "差分が ${bytes} バイトあり大きすぎます。パスかコミット範囲を絞るか、生成物を .gitignore に入れてください" + +intro="あなたはコードレビュアーです。下の git diff をレビューし、バグ・回帰・セキュリティ問題だけを重大度順に挙げてください。スタイルや好みの指摘は不要です。各指摘には file:line と根拠を付けてください。問題がなければ「指摘なし」とだけ答えてください。" +with_reads="周辺のコードを確かめたいときは、このリポジトリ内のファイルの読み取りだけを使ってください。シェルコマンドは実行できません(実行しようとすると、その場でレビューが打ち切られます)。ファイルは変更しないでください。" +no_tools="ツールは一切使わず、diff の内容だけから判断してください。" +body=" +${diff} +" + +out=$(mktemp) +err=$(mktemp) +trap 'rm -f "$out" "$err"' EXIT + +run_agy() { # $1 = プロンプト, $2 = 制限時間(秒) + agy -p "$1" --add-dir "$root" --model "$MODEL" --output-format json --print-timeout "${2}s" >"$out" 2>"$err" + rc=$? +} +# 直前の結果が「打ち切り」か「空の応答」なら、理由(denied / empty)と会話 ID を出す +retry_reason() { + python3 - "$out" "$err" <<'PY' 2>/dev/null +import json, sys +try: + d = json.load(open(sys.argv[1], encoding="utf-8")) +except Exception: + sys.exit() +if d.get("status") != "SUCCESS" or "print timeout" in open(sys.argv[2], encoding="utf-8", errors="replace").read(): + sys.exit() +if d.get("denied_actions"): + print("denied", d.get("conversation_id") or "-") +elif not (d.get("response") or "").strip(): + print("empty", d.get("conversation_id") or "-") +PY +} + +start=$(date +%s) +note="" +run_agy "${intro} +${with_reads} + +${body}" "$BUDGET" +read -r reason first_id <<<"$(retry_reason)" +if [ -n "${reason:-}" ]; then + case "$reason" in + denied) why="agy が許可のない操作を試みて打ち切られた" ;; + *) why="agy の応答が空だった" ;; + esac + left=$(( BUDGET - ($(date +%s) - start) )) + if [ "$left" -ge 60 ]; then + note="1回目は ${why}ため、ツールを使わない指示で再実行した(1回目の会話 ID: ${first_id})" + run_agy "${intro} +${no_tools} + +${body}" "$left" + else + note="1回目は ${why}が、残り時間が足りないため再実行しなかった(1回目の会話 ID: ${first_id})" + fi +fi + +python3 - "$out" "$err" "$rc" "$MODEL" "$untracked" "$note" <<'PY' +import json, sys + +out, err, rc, model, untracked, note = sys.argv[1], sys.argv[2], int(sys.argv[3]), sys.argv[4], sys.argv[5], sys.argv[6] +stderr = open(err, encoding="utf-8", errors="replace").read().strip() +cid = "" + + +def fail(msg): + print("CROSS_REVIEW_FAILED:", msg) + if cid: + print(f"会話 ID: {cid}(agy の記録は ~/.gemini/antigravity-cli/brain/ と conversations/ にある)") + if note: + print("注: " + note) + if stderr: + print("--- agy stderr ---") + print(stderr) + sys.exit(1) + + +try: + d = json.load(open(out, encoding="utf-8")) +except Exception as e: + fail(f"agy の出力を JSON として読めません(exit={rc}, {e})") +cid = d.get("conversation_id") or "" +if d.get("status") != "SUCCESS": + fail(f"status={d.get('status')} error={d.get('error')}") +if "print timeout" in stderr: + fail("制限時間内に終わらず、途中までの応答しかありません。範囲を絞ってください") +if d.get("denied_actions"): + fail("agy が許可のない操作を試みて打ち切られました: " + json.dumps(d["denied_actions"], ensure_ascii=False)) +resp = (d.get("response") or "").strip() +if not resp: + fail("agy の応答が空です") +if rc != 0: + fail(f"exit={rc}") + +u = d.get("usage") or {} +print(f"CROSS_REVIEW_OK: model={model} duration={d.get('duration_seconds')}s tokens={u.get('total_tokens')}") +if note: + print("注: " + note) +files = [f for f in untracked.splitlines() if f.strip()] +if files: + print(f"未追跡のファイル {len(files)} 件は今回のレビュー対象外:") + for f in files[:20]: + print(" " + f) + if len(files) > 20: + print(f" ...ほか {len(files) - 20} 件") +print("--- agy review ---") +print(resp) +PY +st=$? +if [ "$st" -eq 0 ]; then + if [ "$worktree" = 1 ]; then + state_after=$(state_of) + if [ ! -f "$here/state.py" ]; then + echo "注: state.py がないので、クロスレビュー済みの記録はしなかった(コミット前の確認には使えない)" + elif [ -n "$state_before" ] && [ "$state_before" = "$state_after" ]; then + printf '%s %s\n' "$state_before" "$(date +%s)" > "$(git rev-parse --git-dir)/cross-review-ok" + echo "記録: この作業ツリーの状態をクロスレビュー済みとして記録した(コミット前の確認に使う)" + else + echo "注: レビューの途中で作業ツリーが変わったか、状態を計算できなかったので、クロスレビュー済みとして記録しなかった。コミットするには、もう一度実行が必要" + fi + fi + echo "--- 対象の差分(git diff $*) ---" + if [ "$bytes" -le 50000 ]; then + printf '%s\n' "$diff" + else + git diff --stat "$@" + echo "(差分が大きいので --stat だけ表示。中身は必要な箇所を読んで確かめる)" + fi +fi +exit "$st" diff --git a/.github/cross-review/verify.md b/.github/cross-review/verify.md new file mode 100644 index 0000000..492208b --- /dev/null +++ b/.github/cross-review/verify.md @@ -0,0 +1,12 @@ +あなたは、この PR のクロスレビューの確認役です。作業ディレクトリのルートにある agy-review.txt に、agy(Gemini)がこの PR の差分をレビューした結果と、差分そのもの(`--- 対象の差分` より後)があります。 + +1. agy-review.txt を読む。 +2. `--- agy review ---` と `--- 対象の差分` の間にある agy の指摘を1件ずつ、実際のコードと突き合わせる。該当箇所・呼び出し元・テストは Read と Grep で確かめる。 +3. PR に載せるコメントの本文だけを、日本語の Markdown で出力する。前置きや作業の経過は書かない。形式は次のとおり。 + - **確認済み**: 実際のコードで問題を確かめたもの。根拠の file:line を付ける。 + - **誤り**: agy の読み違い。なぜ違うかを一言添える。 + - **判断保留**: 確かめきれなかったもの。何が分かれば判断できるかを書く。 + - **agy が挙げていない点**: 自分で気づいた問題があれば書く。なければこの節は省く。 + + 該当がない節は「なし」と書く。agy の指摘がなかったときは、その旨と、自分で差分を確かめた結果を短く書く。 +4. agy の指摘を、確かめないまま事実として書かない。ファイルは変更しない。 diff --git a/.github/workflows/cross-review.yml b/.github/workflows/cross-review.yml new file mode 100644 index 0000000..982f434 --- /dev/null +++ b/.github/workflows/cross-review.yml @@ -0,0 +1,114 @@ +name: Cross review + +# PR ごとに、agy(Antigravity CLI。中のモデルは Gemini)が差分をレビューし、 +# Claude がその指摘を実際のコードで確かめて、結果を PR にコメントする。 +# ジョブが失敗するのは、レビューを実行できなかったときだけ(指摘があっても失敗にはしない)。 +# +# 必要な Secret: GEMINI_API_KEY(Google AI Studio のキー)、CLAUDE_CODE_OAUTH_TOKEN(claude setup-token) +# モデルを変えるときは、リポジトリの Variable CROSS_REVIEW_MODEL に agy のモデル名を入れる。 +# .github/cross-review/run.sh は ~/.claude/skills/cross-review/run.sh の写し。 + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: cross-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + review: + # fork からの PR には Secret が渡らないので対象外(メンテナが手元で /cross-review を実行する) + if: ${{ !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 # マージコミットと、その親(base 側)を取るため + + - name: Install agy and Claude Code + run: | + curl -fsSL https://antigravity.google/cli/install.sh | bash + curl -fsSL https://claude.ai/install.sh | bash + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + mkdir -p ~/.gemini/antigravity-cli + echo '{"modelProvider":"gemini"}' > ~/.gemini/antigravity-cli/settings.json + # curl | bash は、ダウンロードに失敗しても成功扱いで進むので、ここで入ったことを確かめる + test -x "$HOME/.local/bin/agy" && test -x "$HOME/.local/bin/claude" \ + || { echo "::error::agy か Claude Code のインストールに失敗しました"; exit 1; } + + - name: List the models the Gemini API key can use + env: + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + run: agy models || true + + - name: Review with agy + id: agy + env: + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + CROSS_REVIEW_MODEL: ${{ vars.CROSS_REVIEW_MODEL || 'gemini-3.1-pro-high' }} + run: | + # チェックアウトしたのは PR を base に重ねたマージコミットなので、親との差分が PR の変更になる。 + # 出力を先に作業ツリーへ書くと未追跡ファイルとして数えられるので、一時ディレクトリに書いてから写す。 + set +e + .github/cross-review/run.sh HEAD^1 HEAD > "$RUNNER_TEMP/agy-review.txt" 2>&1 + cp "$RUNNER_TEMP/agy-review.txt" agy-review.txt + cat agy-review.txt + # 結果の行は1行目とは限らない(git の警告などが先に出ることがある)ので、探して判定する + result=$(grep -m 1 '^CROSS_REVIEW_' agy-review.txt) + case "$result" in + CROSS_REVIEW_OK*) echo "result=ok" >> "$GITHUB_OUTPUT" ;; + CROSS_REVIEW_EMPTY*) echo "result=empty" >> "$GITHUB_OUTPUT" ;; + *) echo "::error::agy のレビューを実行できませんでした: ${result:-結果の行がありません}"; exit 1 ;; + esac + + - name: Verify with Claude + if: steps.agy.outputs.result == 'ok' + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + run: | + claude -p "$(cat .github/cross-review/verify.md)" \ + --allowedTools "Read,Grep,Glob" --max-turns 40 \ + --output-format json < /dev/null > claude.json || true + python3 - <<'PY' + import json, sys + try: + d = json.load(open("claude.json")) + except Exception as e: + print(f"::error::Claude の出力を読めません: {e}") + sys.exit(1) + r = (d.get("result") or "").strip() + if d.get("is_error") or not r: + print(f"::error::Claude の確認に失敗しました: {d.get('subtype')} {r[:300]}") + sys.exit(1) + open("comment.md", "w").write(r + "\n") + PY + + - name: Post the result to the PR + if: steps.agy.outputs.result == 'ok' + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + run: | + # 同じ PR には1つのコメントを上書きし続ける(目印は HTML コメント) + marker='' + { + echo "$marker" + echo "### クロスレビュー(agy → Claude)" + echo + cat comment.md + echo + echo "$(grep -m 1 '^CROSS_REVIEW_OK' agy-review.txt | sed 's/^CROSS_REVIEW_OK: //') / Cross review workflow による自動投稿" + } > body.md + id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" --paginate \ + --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | contains(\"$marker\"))) | .id" | tail -n 1) + if [ -n "$id" ]; then + gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -F body=@body.md > /dev/null + else + gh pr comment "$PR" --body-file body.md + fi From 50e5e1ed4989caf060ac26790254c54969384eb6 Mon Sep 17 00:00:00 2001 From: RyoheiHashimoto Date: Tue, 15 Sep 2026 02:57:54 +0900 Subject: [PATCH 2/2] ci: fall back to other Flash models when agy is refused The free tier of the Gemini API gives Gemini 3.1 Pro a quota of zero (429), and Flash models are sometimes refused as overloaded (503). When the review fails for either reason, or runs out of time while agy retries, try the next Flash model instead of failing the check. The job may now take up to an hour, since agy retries a refused model for about nine minutes before giving up. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016NTf9Pw7SBKQgkRsDWyKsd --- .github/workflows/cross-review.yml | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/.github/workflows/cross-review.yml b/.github/workflows/cross-review.yml index 982f434..ec9a160 100644 --- a/.github/workflows/cross-review.yml +++ b/.github/workflows/cross-review.yml @@ -6,6 +6,7 @@ name: Cross review # # 必要な Secret: GEMINI_API_KEY(Google AI Studio のキー)、CLAUDE_CODE_OAUTH_TOKEN(claude setup-token) # モデルを変えるときは、リポジトリの Variable CROSS_REVIEW_MODEL に agy のモデル名を入れる。 +# 無料枠のキーでは Pro が使えないので、Flash(例: gemini-3.8-flash-high)を入れる。 # .github/cross-review/run.sh は ~/.claude/skills/cross-review/run.sh の写し。 on: @@ -25,7 +26,8 @@ jobs: # fork からの PR には Secret が渡らないので対象外(メンテナが手元で /cross-review を実行する) if: ${{ !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository }} runs-on: ubuntu-latest - timeout-minutes: 30 + # 混雑で断られると、agy は1つのモデルで9分ほど再試行してから諦める。次のモデルに切り替える分の余裕を見ておく + timeout-minutes: 60 steps: - uses: actions/checkout@v4 with: @@ -55,16 +57,29 @@ jobs: run: | # チェックアウトしたのは PR を base に重ねたマージコミットなので、親との差分が PR の変更になる。 # 出力を先に作業ツリーへ書くと未追跡ファイルとして数えられるので、一時ディレクトリに書いてから写す。 + # 無料枠のキーは Pro の割り当てが 0 で(429)、Flash も混雑で断られることがある(503)。 + # そのとき(と、混雑で制限時間を使い切ったとき)だけ、次のモデルで試す。 + # Variable に複数のモデルを空白区切りで書くと、その順に試す。 set +e - .github/cross-review/run.sh HEAD^1 HEAD > "$RUNNER_TEMP/agy-review.txt" 2>&1 - cp "$RUNNER_TEMP/agy-review.txt" agy-review.txt - cat agy-review.txt - # 結果の行は1行目とは限らない(git の警告などが先に出ることがある)ので、探して判定する - result=$(grep -m 1 '^CROSS_REVIEW_' agy-review.txt) + out="$RUNNER_TEMP/agy-review.txt" + tried=" " + for m in $CROSS_REVIEW_MODEL gemini-3.8-flash-high gemini-3.7-flash-high gemini-3.6-flash-high; do + case "$tried" in *" $m "*) continue ;; esac + tried="$tried$m " + echo "::group::agy ($m)" + CROSS_REVIEW_MODEL="$m" .github/cross-review/run.sh HEAD^1 HEAD > "$out" 2>&1 + cat "$out" + echo "::endgroup::" + # 結果の行は1行目とは限らない(git の警告などが先に出ることがある)ので、探して判定する + result=$(grep -m 1 '^CROSS_REVIEW_' "$out") + case "$result" in CROSS_REVIEW_OK*|CROSS_REVIEW_EMPTY*) break ;; esac + grep -qE 'Error (429|503)|RESOURCE_EXHAUSTED|UNAVAILABLE|制限時間内に終わらず' "$out" || break + done + cp "$out" agy-review.txt case "$result" in CROSS_REVIEW_OK*) echo "result=ok" >> "$GITHUB_OUTPUT" ;; CROSS_REVIEW_EMPTY*) echo "result=empty" >> "$GITHUB_OUTPUT" ;; - *) echo "::error::agy のレビューを実行できませんでした: ${result:-結果の行がありません}"; exit 1 ;; + *) echo "::error::agy のレビューを実行できませんでした(試したモデル:${tried}): ${result:-結果の行がありません}"; exit 1 ;; esac - name: Verify with Claude