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
43 changes: 41 additions & 2 deletions .claude/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,46 @@ result = subprocess.run(
)
```

### 3. `post_pr_ai_review.py`
### 3. `post_git_push_ci.py`

**目的**: git push後にGitHub Actions CIの状態を監視し、結果を報告

**トリガー**: `PostToolUse(Bash)` で `git push` の成功を検出

**動作**:

- `git push` 成功後に自動実行
- GitHub Actions ワークフローの起動を確認
- CIの実行状態を監視(最大5分)
- 成功/失敗の結果を報告
- ブロックはしない(結果を表示のみ)

**前提条件**:

- GitHub CLI (`gh`) がインストール済み
- GitHub Actions ワークフローが設定済み

**設定例**:

```json
{
"hooks": {
"PostToolUse": [
{
"matcher": "tool_name == 'Bash'",
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/post_git_push_ci.py"
}
]
}
]
}
}
```

### 4. `post_pr_ai_review.py`

**目的**: PR作成後にAI(Codex + Gemini)による自動コードレビューを実行

Expand Down Expand Up @@ -196,7 +235,7 @@ result = subprocess.run(
}
```

### 4. `pre_exit_plan_ai_review.py`
### 5. `pre_exit_plan_ai_review.py`

**目的**: プラン作成後、ExitPlanMode実行前にAI(Codex + Gemini)によるプランレビューを実行

Expand Down
204 changes: 204 additions & 0 deletions .claude/hooks/post_git_push_ci.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""
git push後にGitHub Actions CIを監視するPostToolUseフック

git push 成功後に自動的にCIの状態を確認し、結果を報告します。
"""
import sys
import json
import subprocess
import re
import time

# Read input from Claude
data = json.load(sys.stdin)

tool_name = data.get("tool_name", "")
tool_input = data.get("tool_input", {}) or {}
tool_response = data.get("tool_response", {}) or {}

# Bashツールでない場合はスキップ
if tool_name != "Bash":
sys.exit(0)

# コマンドを取得
command = tool_input.get("command", "").strip()

# git push コマンドかどうかを判定
if not command.startswith("git push"):
sys.exit(0)

# --help や --dry-run は除外
if "--help" in command or "-h" in command or "--dry-run" in command or "-n" in command:
sys.exit(0)

# ツール実行が成功したかチェック
stdout = tool_response.get("stdout", "")
stderr = tool_response.get("stderr", "")
combined_output = stdout + stderr

# push成功のパターン(新しいブランチ or 既存ブランチへのpush)
success_patterns = [
r"\[new branch\]",
r"\.\..*->", # abc123..def456 main -> main
r"set up to track",
r"Everything up-to-date"
]

# エラーパターン
error_patterns = [
r"error:",
r"fatal:",
r"rejected"
]

# エラーがあればスキップ
for pattern in error_patterns:
if re.search(pattern, combined_output, re.IGNORECASE):
sys.exit(0)

# 成功パターンがなければスキップ
is_success = any(re.search(p, combined_output) for p in success_patterns)
if not is_success:
sys.exit(0)

print("", file=sys.stderr)
print("=" * 60, file=sys.stderr)
print("🚀 Push完了。GitHub Actions CIを確認中...", file=sys.stderr)
print("=" * 60, file=sys.stderr)


def get_current_branch():
"""現在のブランチ名を取得"""
try:
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
text=True,
timeout=10
)
return result.stdout.strip()
except Exception:
return None


def get_latest_run():
"""最新のworkflow runを取得"""
try:
branch = get_current_branch()
if not branch:
return None

# 少し待ってからCIの状態を確認(ワークフロー起動に時間がかかる場合があるため)
time.sleep(3)

result = subprocess.run(
["gh", "run", "list", "--branch", branch, "--limit", "1", "--json", "databaseId,status,conclusion,name,workflowName,headSha,createdAt"],
capture_output=True,
text=True,
timeout=30
)

if result.returncode != 0:
return None

runs = json.loads(result.stdout)
if runs:
return runs[0]
return None

Comment on lines +85 to +109

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

GitHub CLI gh run list --json fields headSha documentation

💡 Result:

gh run list supports JSON output via --json <fields>. One of the valid fields is headSha (the run’s commit SHA). (cli.github.com)

Docs / reference

Example

gh run list --json headSha

Citations:


🌐 Web query:

GitHub CLI gh run list default ordering newest first chronological

💡 Result:

gh run list shows recent workflow runs and (in practice) the output is newest-first (reverse chronological). The command has no --sort/--order flags, so you can’t switch it to oldest-first directly. [1]

If you need chronological (oldest-first) ordering, fetch JSON and sort client-side, e.g.:

gh run list --limit 100 --json databaseId,createdAt,displayTitle,status,conclusion \
  --jq 'sort_by(.createdAt)[]'

Notes:

  • GitHub’s workflow-runs REST API does not clearly guarantee the default ordering in the docs; it’s commonly observed as created_at descending (newest first), but if you need a hard guarantee you must sort yourself. [2][3]

Sources: GitHub CLI manual for gh run list [1]; GitHub REST API workflow-runs docs (ordering not explicitly guaranteed) [2]; GitHub Community discussion on observed default order [3].


🏁 Script executed:

cat -n .claude/hooks/post_git_push_ci.py | head -120

Repository: keito4/config

Length of output: 3700


Match CI runs to the pushed commit to avoid stale status.

gh run list --limit 1 right after a push can return the previous run if the new run hasn't started yet, which can misreport CI status. The current code already fetches headSha but doesn't use it for matching. Replace the single-query approach with a retry loop that matches runs by the current HEAD SHA over a 30-second window.

🛠️ Suggested fix (match run by HEAD SHA with a short retry window)
+def get_current_head_sha():
+    """現在のHEAD SHAを取得"""
+    try:
+        result = subprocess.run(
+            ["git", "rev-parse", "HEAD"],
+            capture_output=True,
+            text=True,
+            timeout=10
+        )
+        if result.returncode != 0:
+            return None
+        return result.stdout.strip()
+    except Exception:
+        return None
+
 def get_latest_run():
     """最新のworkflow runを取得"""
     try:
         branch = get_current_branch()
-        if not branch:
+        head_sha = get_current_head_sha()
+        if not branch or not head_sha:
             return None
 
-        # 少し待ってからCIの状態を確認(ワークフロー起動に時間がかかる場合があるため)
-        time.sleep(3)
-
-        result = subprocess.run(
-            ["gh", "run", "list", "--branch", branch, "--limit", "1", "--json", "databaseId,status,conclusion,name,workflowName,headSha,createdAt"],
-            capture_output=True,
-            text=True,
-            timeout=30
-        )
-
-        if result.returncode != 0:
-            return None
-
-        runs = json.loads(result.stdout)
-        if runs:
-            return runs[0]
-        return None
+        deadline = time.time() + 30
+        while time.time() < deadline:
+            time.sleep(3)
+            result = subprocess.run(
+                ["gh", "run", "list", "--branch", branch, "--limit", "20",
+                 "--json", "databaseId,status,conclusion,name,workflowName,headSha,createdAt"],
+                capture_output=True,
+                text=True,
+                timeout=30
+            )
+            if result.returncode != 0:
+                return None
+            runs = json.loads(result.stdout)
+            for run in runs:
+                if run.get("headSha") == head_sha:
+                    return run
+        return None
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def get_latest_run():
"""最新のworkflow runを取得"""
try:
branch = get_current_branch()
if not branch:
return None
# 少し待ってからCIの状態を確認(ワークフロー起動に時間がかかる場合があるため)
time.sleep(3)
result = subprocess.run(
["gh", "run", "list", "--branch", branch, "--limit", "1", "--json", "databaseId,status,conclusion,name,workflowName,headSha,createdAt"],
capture_output=True,
text=True,
timeout=30
)
if result.returncode != 0:
return None
runs = json.loads(result.stdout)
if runs:
return runs[0]
return None
def get_current_head_sha():
"""現在のHEAD SHAを取得"""
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
capture_output=True,
text=True,
timeout=10
)
if result.returncode != 0:
return None
return result.stdout.strip()
except Exception:
return None
def get_latest_run():
"""最新のworkflow runを取得"""
try:
branch = get_current_branch()
head_sha = get_current_head_sha()
if not branch or not head_sha:
return None
deadline = time.time() + 30
while time.time() < deadline:
time.sleep(3)
result = subprocess.run(
["gh", "run", "list", "--branch", branch, "--limit", "20",
"--json", "databaseId,status,conclusion,name,workflowName,headSha,createdAt"],
capture_output=True,
text=True,
timeout=30
)
if result.returncode != 0:
return None
runs = json.loads(result.stdout)
for run in runs:
if run.get("headSha") == head_sha:
return run
return None
🧰 Tools
🪛 Ruff (0.14.14)

92-92: Comment contains ambiguous (FULLWIDTH LEFT PARENTHESIS). Did you mean ( (LEFT PARENTHESIS)?

(RUF003)


92-92: Comment contains ambiguous (FULLWIDTH RIGHT PARENTHESIS). Did you mean ) (RIGHT PARENTHESIS)?

(RUF003)


95-95: subprocess call: check for execution of untrusted input

(S603)


96-96: Starting a process with a partial executable path

(S607)


108-108: Consider moving this statement to an else block

(TRY300)

🤖 Prompt for AI Agents
In @.claude/hooks/post_git_push_ci.py around lines 85 - 109, get_latest_run may
return a stale workflow because it queries the latest run once; change it to
retry for up to 30 seconds and return the run whose headSha equals the current
HEAD SHA. Specifically, inside get_latest_run (which already calls
get_current_branch and parses gh run list output), loop with a short sleep
(e.g., 1–3s) until a timeout, call gh run list (increase --limit to fetch
several recent runs) each iteration, parse runs and search for a run where
run["headSha"] == current_head_sha (obtain current_head_sha via git rev-parse
HEAD), and return that matched run; if timeout elapses return None. Ensure
subprocess.run error handling and timeouts remain intact and keep existing
return shapes.

except Exception as e:
print(f"⚠️ CI状態取得エラー: {e}", file=sys.stderr)
return None


def watch_ci_run(run_id, timeout_seconds=300):
"""CIの実行を監視(最大5分)"""
print(f"\n🔄 CI実行を監視中... (最大{timeout_seconds // 60}分)", file=sys.stderr)

start_time = time.time()
check_interval = 15 # 15秒ごとにチェック

while time.time() - start_time < timeout_seconds:
try:
result = subprocess.run(
["gh", "run", "view", str(run_id), "--json", "status,conclusion,jobs"],
capture_output=True,
text=True,
timeout=30
)

if result.returncode != 0:
break

run_data = json.loads(result.stdout)
status = run_data.get("status", "")
conclusion = run_data.get("conclusion", "")

if status == "completed":
return conclusion, run_data.get("jobs", [])

# 進行中の場合は待機
elapsed = int(time.time() - start_time)
print(f" ⏳ {elapsed}秒経過... (status: {status})", file=sys.stderr)
time.sleep(check_interval)

except Exception as e:
print(f"⚠️ 監視エラー: {e}", file=sys.stderr)
break

return "timeout", []


# メイン処理
run = get_latest_run()

if not run:
print("⚠️ GitHub Actions ワークフローが見つかりません", file=sys.stderr)
print(" (CI未設定、またはpush直後でまだ起動していない可能性があります)", file=sys.stderr)
sys.exit(0)

run_id = run.get("databaseId")
workflow_name = run.get("workflowName", run.get("name", "Unknown"))
status = run.get("status", "")
conclusion = run.get("conclusion", "")

print(f"\n📋 ワークフロー: {workflow_name}", file=sys.stderr)
print(f" Run ID: {run_id}", file=sys.stderr)
print(f" Status: {status}", file=sys.stderr)

if status == "completed":
# 既に完了している場合
if conclusion == "success":
print("\n✅ CI成功!", file=sys.stderr)
elif conclusion == "failure":
print("\n❌ CI失敗", file=sys.stderr)
print(f" 詳細: gh run view {run_id}", file=sys.stderr)
else:
print(f"\n⚠️ CI結果: {conclusion}", file=sys.stderr)
else:
# 実行中の場合は監視
conclusion, jobs = watch_ci_run(run_id)

if conclusion == "success":
print("\n✅ CI成功!", file=sys.stderr)
elif conclusion == "failure":
print("\n❌ CI失敗", file=sys.stderr)
# 失敗したジョブを表示
failed_jobs = [j for j in jobs if j.get("conclusion") == "failure"]
if failed_jobs:
print("\n失敗したジョブ:", file=sys.stderr)
for job in failed_jobs:
print(f" - {job.get('name', 'Unknown')}", file=sys.stderr)
print(f"\n 詳細: gh run view {run_id}", file=sys.stderr)
elif conclusion == "timeout":
print("\n⏰ CI監視タイムアウト(まだ実行中)", file=sys.stderr)
print(f" 詳細: gh run view {run_id} --watch", file=sys.stderr)
else:
print(f"\n⚠️ CI結果: {conclusion}", file=sys.stderr)

print("", file=sys.stderr)
print("=" * 60, file=sys.stderr)

# PostToolUseフックは常に成功で終了(ブロックしない)
sys.exit(0)
9 changes: 9 additions & 0 deletions .devcontainer/claude-settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,15 @@
}
],
"PostToolUse": [
{
"matcher": "tool_name == 'Bash'",
"hooks": [
{
"type": "command",
"command": "python3 /home/vscode/.claude/hooks/post_git_push_ci.py"
}
]
},
{
"matcher": "tool_name == 'Bash'",
"hooks": [
Expand Down
Loading