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..ec9a160 --- /dev/null +++ b/.github/workflows/cross-review.yml @@ -0,0 +1,129 @@ +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 のモデル名を入れる。 +# 無料枠のキーでは Pro が使えないので、Flash(例: gemini-3.8-flash-high)を入れる。 +# .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 + # 混雑で断られると、agy は1つのモデルで9分ほど再試行してから諦める。次のモデルに切り替える分の余裕を見ておく + timeout-minutes: 60 + 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 の変更になる。 + # 出力を先に作業ツリーへ書くと未追跡ファイルとして数えられるので、一時ディレクトリに書いてから写す。 + # 無料枠のキーは Pro の割り当てが 0 で(429)、Flash も混雑で断られることがある(503)。 + # そのとき(と、混雑で制限時間を使い切ったとき)だけ、次のモデルで試す。 + # Variable に複数のモデルを空白区切りで書くと、その順に試す。 + set +e + 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 のレビューを実行できませんでした(試したモデル:${tried}): ${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