Skip to content
Open
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
187 changes: 187 additions & 0 deletions .github/cross-review/run.sh
Original file line number Diff line number Diff line change
@@ -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>
${diff}
</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"
12 changes: 12 additions & 0 deletions .github/cross-review/verify.md
Original file line number Diff line number Diff line change
@@ -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 の指摘を、確かめないまま事実として書かない。ファイルは変更しない。
129 changes: 129 additions & 0 deletions .github/workflows/cross-review.yml
Original file line number Diff line number Diff line change
@@ -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='<!-- cross-review -->'
{
echo "$marker"
echo "### クロスレビュー(agy → Claude)"
echo
cat comment.md
echo
echo "<sub>$(grep -m 1 '^CROSS_REVIEW_OK' agy-review.txt | sed 's/^CROSS_REVIEW_OK: //') / Cross review workflow による自動投稿</sub>"
} > 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
Loading