From 79292f412eaa99739d254eb5608dfb135be71f27 Mon Sep 17 00:00:00 2001 From: aloekun Date: Thu, 2 Apr 2026 22:08:46 +0900 Subject: [PATCH] =?UTF-8?q?fix(hooks):=20CodeRabbit=20=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC4=E4=BB=B6=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C=20+=20=E3=83=9D=E3=83=BC=E3=83=AA=E3=83=B3=E3=82=B0?= =?UTF-8?q?=E9=96=93=E9=9A=942=E5=88=86=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CR#1: build_summary で not_found + actionable 時にコメント数を表示 CR#2: gh run list の API エラー時に runs 空ではなく pending (runs非空) を返す → API エラーとCI未設定を区別し、誤った CI スキップを防止 CR#3: hooks-config.toml のコメント/サンプルに pnpm push を追加 → poll_interval_secs を 30 → 120 (2分) に変更 CR#4: detect_command_type で npm push を pnpm push と区別 テスト: 77 (36 + 41) パス Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/check-ci-coderabbit/src/main.rs | 159 +++++++++++++++++++--- .claude/hooks-config.toml | 6 +- .claude/hooks-post-pr-monitor/src/main.rs | 87 +++++++----- .claude/post-pr-monitor-debug.log | 8 ++ templates/hooks-config-python.toml | 2 +- templates/hooks-config-typescript.toml | 2 +- 6 files changed, 206 insertions(+), 58 deletions(-) diff --git a/.claude/check-ci-coderabbit/src/main.rs b/.claude/check-ci-coderabbit/src/main.rs index 889003ba..f2c2a8fe 100644 --- a/.claude/check-ci-coderabbit/src/main.rs +++ b/.claude/check-ci-coderabbit/src/main.rs @@ -58,6 +58,9 @@ fn parse_args() -> Result { /// gh コマンドを実行し stdout を返す。タイムアウト 30 秒。 /// パイプのデッドロックを防ぐため、タイムアウトは別スレッドで kill し、 /// メインスレッドは wait_with_output でパイプを安全に読み取る。 +/// +/// NOTE: タイムアウト時のプロセス kill は Windows (taskkill) のみ実装。 +/// この exe は Windows 専用として設計されている (ADR-001)。 fn run_gh(args: &[&str]) -> Result { let child = Command::new("gh") .args(args) @@ -279,7 +282,14 @@ fn parse_new_comments(json: &str, push_time: &str) -> usize { .map(|t| t > push_time) .unwrap_or(false); - is_coderabbit && after_push_time + // 「処理中」通知コメントを除外 (レビュー結果ではない) + let is_review_in_progress = c + .body + .as_deref() + .map(|b| b.contains("review in progress")) + .unwrap_or(false); + + is_coderabbit && after_push_time && !is_review_in_progress }) .count() } @@ -350,11 +360,30 @@ fn decide(ci: &CiStatus, cr: &CodeRabbitStatus) -> (String, String) { return ("error".to_string(), "stop_monitoring_failure".to_string()); } - // CI または CodeRabbit が pending → 監視続行 - if ci.overall == "pending" - || cr.review_state == "pending" - || cr.review_state == "not_found" - { + // コメント/スレッドの集計 (review_state に関わらず先に計算) + let has_unresolved = cr.unresolved_threads.map(|n| n > 0).unwrap_or(false); + let effective_new = if let Some(actionable) = cr.actionable_comments { + std::cmp::max(cr.new_comments, actionable) + } else { + cr.new_comments + }; + let has_actionable = effective_new > 0 || has_unresolved; + + // CodeRabbit の review_state が not_found でもコメント/スレッドがあれば対応が必要 + // (commit status は未投稿でも inline comments は先に投稿されるケースがある) + if cr.review_state == "not_found" && has_actionable { + return ( + "action_required".to_string(), + "action_required".to_string(), + ); + } + + // CI が pending (runs 空 = no_ci は "pending" ではなく CI チェックをスキップ) + let ci_pending = ci.overall == "pending" && !ci.runs.is_empty(); + // CodeRabbit がまだレビュー中 or 未検出 (コメントもない) + let cr_pending = cr.review_state == "pending" || cr.review_state == "not_found"; + + if ci_pending || cr_pending { return ("pending".to_string(), "continue_monitoring".to_string()); } @@ -363,16 +392,8 @@ fn decide(ci: &CiStatus, cr: &CodeRabbitStatus) -> (String, String) { return ("error".to_string(), "stop_monitoring_failure".to_string()); } - // 新規コメントまたは未解決スレッドがある → 対応が必要 - let has_unresolved = cr.unresolved_threads.map(|n| n > 0).unwrap_or(false); - // actionable_comments が new_comments より多い場合はそちらを信頼 - let effective_new = if let Some(actionable) = cr.actionable_comments { - std::cmp::max(cr.new_comments, actionable) - } else { - cr.new_comments - }; - - if effective_new > 0 || has_unresolved { + // コメント/スレッドがある → 対応が必要 + if has_actionable { return ( "action_required".to_string(), "action_required".to_string(), @@ -420,7 +441,25 @@ fn build_summary(ci: &CiStatus, cr: &CodeRabbitStatus) -> String { format!("CodeRabbit: {}", parts.join("、")) } } - "pending" | "not_found" => "CodeRabbitレビュー待ち".to_string(), + "pending" => "CodeRabbitレビュー待ち".to_string(), + "not_found" => { + // not_found でもコメント/スレッドがある場合は内容を表示 + let mut parts = vec![]; + let effective_new = cr.actionable_comments.unwrap_or(cr.new_comments); + if effective_new > 0 { + parts.push(format!("新規コメント{}件", effective_new)); + } + if let Some(n) = cr.unresolved_threads { + if n > 0 { + parts.push(format!("未解決スレッド{}件", n)); + } + } + if parts.is_empty() { + "CodeRabbitレビュー待ち".to_string() + } else { + format!("CodeRabbit: {}", parts.join("、")) + } + } _ => format!("CodeRabbit状態: {}", cr.review_state), }; @@ -527,11 +566,22 @@ fn run_check(args: CliArgs) -> CheckResult { // 1. CI 状態チェック let branch = get_current_branch().unwrap_or_default(); let ci = if !branch.is_empty() { - let ci_json = run_gh(&[ + match run_gh(&[ "run", "list", "--branch", &branch, "--limit", "5", "--json", "name,conclusion", - ]) - .unwrap_or_else(|_| "[]".to_string()); - parse_ci_runs(&ci_json) + ]) { + Ok(ci_json) => parse_ci_runs(&ci_json), + Err(e) => { + // API エラー/タイムアウト → pending (runs 非空) として CI スキップを防止 + eprintln!("[check-ci-coderabbit] CI 取得エラー (pending 扱い): {}", e); + CiStatus { + overall: "pending".to_string(), + runs: vec![CiRunSummary { + name: "(API error)".to_string(), + conclusion: "".to_string(), + }], + } + } + } } else { CiStatus { overall: "pending".to_string(), @@ -761,6 +811,16 @@ mod tests { assert_eq!(parse_new_comments("[]", "2026-04-01T12:00:00Z"), 0); } + #[test] + fn comments_excludes_review_in_progress() { + let json = r#"[ + {"user":{"login":"coderabbitai[bot]"},"created_at":"2026-04-01T13:00:00Z","body":"\nCurrently processing..."}, + {"user":{"login":"coderabbitai[bot]"},"created_at":"2026-04-01T13:05:00Z","body":"_Actionable comments posted: 2_\nReview summary..."} + ]"#; + // 「処理中」コメントは除外され、レビュー結果コメントのみカウント + assert_eq!(parse_new_comments(json, "2026-04-01T12:00:00Z"), 1); + } + // --- parse_actionable_comments --- #[test] @@ -871,7 +931,10 @@ mod tests { fn decide_ci_pending() { let ci = CiStatus { overall: "pending".to_string(), - runs: vec![], + runs: vec![CiRunSummary { + name: "build".to_string(), + conclusion: "".to_string(), + }], }; let cr = CodeRabbitStatus { review_state: "success".to_string(), @@ -1013,6 +1076,58 @@ mod tests { assert_eq!(action, "stop_monitoring_failure"); } + #[test] + fn decide_cr_not_found_with_comments() { + // review_state が not_found でも actionable_comments があれば action_required + let ci = CiStatus { + overall: "success".to_string(), + runs: vec![], + }; + let cr = CodeRabbitStatus { + review_state: "not_found".to_string(), + new_comments: 0, + actionable_comments: Some(3), + unresolved_threads: Some(3), + }; + let (status, action) = decide(&ci, &cr); + assert_eq!(status, "action_required"); + assert_eq!(action, "action_required"); + } + + #[test] + fn decide_no_ci_cr_success() { + // CI runs 空 (CI 未設定) + CR 成功 → complete (CI スキップ) + let ci = CiStatus { + overall: "pending".to_string(), + runs: vec![], + }; + let cr = CodeRabbitStatus { + review_state: "success".to_string(), + new_comments: 0, + actionable_comments: Some(0), + unresolved_threads: Some(0), + }; + let (status, action) = decide(&ci, &cr); + assert_eq!(status, "complete"); + assert_eq!(action, "stop_monitoring_success"); + } + + #[test] + fn decide_no_ci_cr_not_found_no_comments() { + // CI 未設定 + CR not_found + コメントなし → pending (まだレビュー待ち) + let ci = CiStatus { + overall: "pending".to_string(), + runs: vec![], + }; + let cr = CodeRabbitStatus { + review_state: "not_found".to_string(), + ..Default::default() + }; + let (status, action) = decide(&ci, &cr); + assert_eq!(status, "pending"); + assert_eq!(action, "continue_monitoring"); + } + // --- build_summary --- #[test] diff --git a/.claude/hooks-config.toml b/.claude/hooks-config.toml index 4dddb3d4..b247ef7b 100644 --- a/.claude/hooks-config.toml +++ b/.claude/hooks-config.toml @@ -103,17 +103,17 @@ prompt = "optimize_commit_structure" # ─── PostToolUse: PR モニター ─── # -# gh pr create / git push / jj git push 検出後に +# gh pr create / git push / jj git push / pnpm push 検出後に # CI + CodeRabbit の自動モニタリングを CronCreate で開始する。 # check-ci-coderabbit.exe によるポーリングで監視し、 # 結果の action フィールドに従って Claude が行動する。 [post_pr_monitor] enabled = true -poll_interval_secs = 30 # CronCreate のポーリング間隔(秒) +poll_interval_secs = 120 # CronCreate のポーリング間隔(秒、2分) max_duration_secs = 600 # 最大監視時間(秒、10分) check_ci = true # GitHub Actions の監視 check_coderabbit = true # CodeRabbit レビューの監視 # trigger_patterns をコメントアウトまたは未設定 → デフォルトトリガー有効 -# trigger_patterns = ["gh pr create", "git push", "jj git push"] # 明示指定する場合 +# trigger_patterns = ["gh pr create", "git push", "jj git push", "pnpm push"] # 明示指定する場合 # trigger_patterns = [] # 空配列 = 全トリガー無効化(モニタリング停止) diff --git a/.claude/hooks-post-pr-monitor/src/main.rs b/.claude/hooks-post-pr-monitor/src/main.rs index de572bf2..94a04e23 100644 --- a/.claude/hooks-post-pr-monitor/src/main.rs +++ b/.claude/hooks-post-pr-monitor/src/main.rs @@ -86,11 +86,15 @@ const PAT_GIT_PUSH: &str = r"^\s*git\s+push(\s|$)"; /// jj git push const PAT_JJ_GIT_PUSH: &str = r"^\s*jj\s+git\s+push(\s|$)"; +/// pnpm push / npm push / pnpm run push (パイプライン経由の push) +const PAT_PNPM_PUSH: &str = r"^\s*(?:pnpm|npm)\s+(?:run\s+)?push(\s|$)"; + fn default_patterns() -> Vec { vec![ PAT_GH_PR_CREATE.to_string(), PAT_GIT_PUSH.to_string(), PAT_JJ_GIT_PUSH.to_string(), + PAT_PNPM_PUSH.to_string(), ] } @@ -130,6 +134,15 @@ fn detect_command_type(command: &str) -> &'static str { return "jj git push"; } } + if let Ok(re) = Regex::new(PAT_PNPM_PUSH) { + if re.is_match(command) { + // npm push と pnpm push を区別 + if command.trim_start().starts_with("npm ") { + return "npm push"; + } + return "pnpm push"; + } + } "unknown" } @@ -183,8 +196,7 @@ fn get_pr_info() -> PrInfo { /// gh コマンドを静かに実行 (stderr 抑制) fn run_gh_quiet(args: &[&str]) -> Option { - let output = Command::new("cmd") - .args(["/c", "gh"]) + let output = Command::new("gh") .args(args) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) @@ -280,37 +292,19 @@ fn emit_feedback(context: &str) { // ─── メイン ─── -/// デバッグログをファイルに追記 (テスト用 — 本番では削除) -fn debug_log(msg: &str) { - use std::io::Write; - let log_path = std::env::current_exe() - .unwrap_or_default() - .parent() - .unwrap_or(Path::new(".")) - .join("post-pr-monitor-debug.log"); - if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log_path) { - let _ = writeln!(f, "[{}] {}", utc_now_iso8601(), msg); - } - eprintln!("[post-pr-monitor] {}", msg); -} - fn run() { - debug_log("hook 起動"); - // stdin を読み込み let mut input = String::new(); if let Err(e) = std::io::stdin().read_to_string(&mut input) { - debug_log(&format!("stdin 読み込みエラー: {}", e)); + eprintln!("[post-pr-monitor] stdin 読み込みエラー: {}", e); return; } - debug_log(&format!("stdin ({} bytes): {}", input.len(), &input[..input.len().min(200)])); - // JSON パース let hook_input: HookInput = match serde_json::from_str(&input) { Ok(h) => h, Err(e) => { - debug_log(&format!("JSON パースエラー: {}", e)); + eprintln!("[post-pr-monitor] JSON パースエラー: {}", e); return; } }; @@ -318,21 +312,15 @@ fn run() { // コマンド抽出 let command = match hook_input.tool_input.and_then(|t| t.command) { Some(c) if !c.trim().is_empty() => c, - _ => { - debug_log("コマンドなし — スキップ"); - return; - } + _ => return, }; - debug_log(&format!("コマンド検出: {}", command)); - // 設定読み込み let config = load_config(); let monitor_config = config.post_pr_monitor.unwrap_or_default(); // 無効化チェック if !monitor_config.enabled.unwrap_or(true) { - debug_log("設定で無効化 — スキップ"); return; } @@ -343,12 +331,10 @@ fn run() { .unwrap_or_else(default_patterns); if !is_trigger_command(&command, &patterns) { - debug_log(&format!("トリガー不一致 — スキップ (command={})", command)); return; } // ── ここから先はマッチした場合のみ実行 ── - debug_log(&format!("トリガーマッチ! type={}", detect_command_type(&command))); let command_type = detect_command_type(&command); @@ -484,6 +470,30 @@ mod tests { assert!(!is_trigger_command("git push", &patterns)); } + #[test] + fn trigger_pnpm_push() { + let patterns = default_patterns(); + assert!(is_trigger_command("pnpm push", &patterns)); + } + + #[test] + fn trigger_pnpm_run_push() { + let patterns = default_patterns(); + assert!(is_trigger_command("pnpm run push", &patterns)); + } + + #[test] + fn trigger_npm_push() { + let patterns = default_patterns(); + assert!(is_trigger_command("npm push", &patterns)); + } + + #[test] + fn no_trigger_pnpm_build() { + let patterns = default_patterns(); + assert!(!is_trigger_command("pnpm build", &patterns)); + } + // --- detect_command_type --- #[test] @@ -501,6 +511,21 @@ mod tests { assert_eq!(detect_command_type("jj git push"), "jj git push"); } + #[test] + fn detect_pnpm_push() { + assert_eq!(detect_command_type("pnpm push"), "pnpm push"); + } + + #[test] + fn detect_npm_push() { + assert_eq!(detect_command_type("npm push"), "npm push"); + } + + #[test] + fn detect_pnpm_run_push() { + assert_eq!(detect_command_type("pnpm run push"), "pnpm push"); + } + // --- config parsing --- #[test] diff --git a/.claude/post-pr-monitor-debug.log b/.claude/post-pr-monitor-debug.log index ccf8c636..b1a8310b 100644 --- a/.claude/post-pr-monitor-debug.log +++ b/.claude/post-pr-monitor-debug.log @@ -145,3 +145,11 @@ Co-Authored-By: Claude Opus 4.6 (1M context) " - regex クレートを check-ci-coderabbit に追加 Co-Authored-By: Claude Opus 4.6 (1M context) ") +[2026-04-02T12:57:00Z] hook 起動 +[2026-04-02T12:57:00Z] stdin (1161 bytes): {"session_id":"362ead1f-3bb6-4e2c-9a8a-64a54350c916","transcript_path":"C:\\Users\\HIROKI\\.claude\\projects\\e--work-claude-code-hook-test\\362ead1f-3bb6-4e2c-9a8a-64a54350c916.jsonl","cwd":"E:\\work +[2026-04-02T12:57:00Z] コマンド検出: cd e:/work/claude-code-hook-test && jj restore .claude/post-pr-monitor-debug.log 2>&1 && jj status 2>&1 +[2026-04-02T12:57:00Z] トリガー不一致 — スキップ (command=cd e:/work/claude-code-hook-test && jj restore .claude/post-pr-monitor-debug.log 2>&1 && jj status 2>&1) +[2026-04-02T13:07:50Z] hook 起動 +[2026-04-02T13:07:50Z] stdin (4714 bytes): {"session_id":"362ead1f-3bb6-4e2c-9a8a-64a54350c916","transcript_path":"C:\\Users\\HIROKI\\.claude\\projects\\e--work-claude-code-hook-test\\362ead1f-3bb6-4e2c-9a8a-64a54350c916.jsonl","cwd":"E:\\work +[2026-04-02T13:07:50Z] コマンド検出: cd e:/work/claude-code-hook-test/.claude/hooks-post-pr-monitor && cargo test 2>&1 && echo "---" && cd ../check-ci-coderabbit && cargo test 2>&1 +[2026-04-02T13:07:50Z] トリガー不一致 — スキップ (command=cd e:/work/claude-code-hook-test/.claude/hooks-post-pr-monitor && cargo test 2>&1 && echo "---" && cd ../check-ci-coderabbit && cargo test 2>&1) diff --git a/templates/hooks-config-python.toml b/templates/hooks-config-python.toml index f029a1a0..e186fc44 100644 --- a/templates/hooks-config-python.toml +++ b/templates/hooks-config-python.toml @@ -45,7 +45,7 @@ cmd = "pnpm py-test:e2e" # [post_pr_monitor] # enabled = true -# poll_interval_secs = 30 +# poll_interval_secs = 120 # max_duration_secs = 600 # check_ci = true # check_coderabbit = true diff --git a/templates/hooks-config-typescript.toml b/templates/hooks-config-typescript.toml index 86bc4e86..cfef20ec 100644 --- a/templates/hooks-config-typescript.toml +++ b/templates/hooks-config-typescript.toml @@ -49,7 +49,7 @@ cmd = "pnpm build" # [post_pr_monitor] # enabled = true -# poll_interval_secs = 30 +# poll_interval_secs = 120 # max_duration_secs = 600 # check_ci = true # check_coderabbit = true