diff --git a/.claude/check-ci-coderabbit/Cargo.lock b/.claude/check-ci-coderabbit/Cargo.lock new file mode 100644 index 00000000..21d27b65 --- /dev/null +++ b/.claude/check-ci-coderabbit/Cargo.lock @@ -0,0 +1,146 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "check-ci-coderabbit" +version = "0.1.0" +dependencies = [ + "regex", + "serde", + "serde_json", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/.claude/check-ci-coderabbit/Cargo.toml b/.claude/check-ci-coderabbit/Cargo.toml new file mode 100644 index 00000000..eda4b044 --- /dev/null +++ b/.claude/check-ci-coderabbit/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "check-ci-coderabbit" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +regex = "1" + +[profile.release] +opt-level = 3 +lto = true +strip = true diff --git a/.claude/check-ci-coderabbit/src/main.rs b/.claude/check-ci-coderabbit/src/main.rs new file mode 100644 index 00000000..889003ba --- /dev/null +++ b/.claude/check-ci-coderabbit/src/main.rs @@ -0,0 +1,1082 @@ +//! CI・CodeRabbit 状態チェッカー (スタンドアロン exe) +//! +//! push / PR 作成後に CI (GitHub Actions) と CodeRabbit レビューの状態を +//! gh CLI 経由で取得し、構造化 JSON を stdout に出力する。 +//! CronCreate のポーリングジョブから定期的に呼び出される想定。 +//! +//! 使い方: +//! check-ci-coderabbit.exe --push-time "2026-04-01T12:00:00Z" [--repo owner/repo] [--pr 42] +//! +//! 終了コード: +//! 0 - チェック完了 (結果は stdout JSON の action フィールドを参照) +//! 1 - 引数エラーまたは致命的エラー + +use serde::{Deserialize, Serialize}; +use std::process::Command; +use std::time::Duration; + +// ─── CLI 引数 ─── + +struct CliArgs { + push_time: String, + repo: Option, + pr: Option, +} + +fn parse_args() -> Result { + let args: Vec = std::env::args().collect(); + let mut push_time = None; + let mut repo = None; + let mut pr = None; + + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--push-time" => { + i += 1; + push_time = args.get(i).cloned(); + } + "--repo" => { + i += 1; + repo = args.get(i).cloned(); + } + "--pr" => { + i += 1; + pr = args.get(i).and_then(|s| s.parse::().ok()); + } + _ => {} + } + i += 1; + } + + let push_time = push_time.ok_or("--push-time は必須です")?; + Ok(CliArgs { push_time, repo, pr }) +} + +// ─── gh CLI 実行 ─── + +/// gh コマンドを実行し stdout を返す。タイムアウト 30 秒。 +/// パイプのデッドロックを防ぐため、タイムアウトは別スレッドで kill し、 +/// メインスレッドは wait_with_output でパイプを安全に読み取る。 +fn run_gh(args: &[&str]) -> Result { + let child = Command::new("gh") + .args(args) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| format!("gh の起動に失敗: {}", e))?; + + // タイムアウト用: done_flag で早期終了、timeout_flag でタイムアウト判定 + let child_id = child.id(); + let timeout_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let done_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let flag_clone = timeout_flag.clone(); + let done_clone = done_flag.clone(); + + // タイマースレッドは 100ms 刻みで done_flag をチェックし、早期終了する + std::thread::spawn(move || { + let deadline = std::time::Instant::now() + Duration::from_secs(30); + while std::time::Instant::now() < deadline { + if done_clone.load(std::sync::atomic::Ordering::Relaxed) { + return; // プロセス完了 → スレッド即終了 + } + std::thread::sleep(Duration::from_millis(100)); + } + // タイムアウト到達 + flag_clone.store(true, std::sync::atomic::Ordering::Relaxed); + #[cfg(target_os = "windows")] + { + let _ = Command::new("taskkill") + .args(["/F", "/PID", &child_id.to_string()]) + .output(); + } + }); + + let output = child + .wait_with_output() + .map_err(|e| format!("gh 出力の取得に失敗: {}", e))?; + + // プロセス完了をタイマースレッドに通知 + done_flag.store(true, std::sync::atomic::Ordering::Relaxed); + + if timeout_flag.load(std::sync::atomic::Ordering::Relaxed) { + return Err("gh コマンドがタイムアウトしました".to_string()); + } + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(format!("gh エラー: {}", stderr)) + } +} + +// ─── 出力モデル ─── + +#[derive(Serialize)] +struct CheckResult { + status: String, + action: String, + ci: CiStatus, + coderabbit: CodeRabbitStatus, + summary: String, +} + +#[derive(Serialize, Default)] +struct CiStatus { + overall: String, + runs: Vec, +} + +#[derive(Serialize, Clone)] +struct CiRunSummary { + name: String, + conclusion: String, +} + +#[derive(Serialize, Default)] +struct CodeRabbitStatus { + review_state: String, + new_comments: usize, + actionable_comments: Option, + unresolved_threads: Option, +} + +// ─── gh CLI 出力パースモデル ─── + +#[derive(Deserialize)] +struct GhRunItem { + name: String, + conclusion: Option, +} + +#[derive(Deserialize)] +struct GhStatusItem { + context: Option, + state: Option, +} + +#[derive(Deserialize)] +#[allow(dead_code)] // body はデシリアライズに必要だがフィルタでは未使用 +struct GhComment { + user: Option, + body: Option, + created_at: Option, +} + +#[derive(Deserialize)] +struct GhUser { + login: Option, +} + +#[derive(Deserialize)] +struct GhReview { + user: Option, + body: Option, + submitted_at: Option, +} + +// ─── パース関数 (テスト可能な純粋関数) ─── + +/// gh run list の JSON をパースして CI 状態を返す +fn parse_ci_runs(json: &str) -> CiStatus { + let items: Vec = serde_json::from_str(json).unwrap_or_else(|e| { + eprintln!("[check-ci-coderabbit] CI runs JSON パースエラー: {}", e); + vec![] + }); + + if items.is_empty() { + return CiStatus { + overall: "pending".to_string(), + runs: vec![], + }; + } + + let runs: Vec = items + .iter() + .map(|item| CiRunSummary { + name: item.name.clone(), + conclusion: item.conclusion.clone().unwrap_or_else(|| "pending".to_string()), + }) + .collect(); + + let has_pending = items.iter().any(|i| { + matches!( + i.conclusion.as_deref(), + None | Some("") | Some("pending") | Some("queued") | Some("in_progress") | Some("waiting") + ) + }); + + let has_failure = items.iter().any(|i| { + matches!( + i.conclusion.as_deref(), + Some("failure") | Some("cancelled") | Some("timed_out") | Some("action_required") | Some("stale") + ) + }); + + let overall = if has_pending { + "pending" + } else if has_failure { + "failure" + } else { + "success" + }; + + CiStatus { + overall: overall.to_string(), + runs, + } +} + +/// gh api .../statuses の JSON から CodeRabbit のレビュー状態を返す +fn parse_coderabbit_status(json: &str) -> String { + let items: Vec = serde_json::from_str(json).unwrap_or_else(|e| { + eprintln!("[check-ci-coderabbit] statuses JSON パースエラー: {}", e); + vec![] + }); + + let cr_statuses: Vec<&GhStatusItem> = items + .iter() + .filter(|s| { + s.context + .as_deref() + .map(|c| c.contains("CodeRabbit")) + .unwrap_or(false) + }) + .collect(); + + if cr_statuses.is_empty() { + return "not_found".to_string(); + } + + // 最後のエントリ (最新) の state を返す + cr_statuses + .last() + .and_then(|s| s.state.clone()) + .unwrap_or_else(|| "not_found".to_string()) +} + +/// PR コメントの JSON から push_time 以降の CodeRabbit 新規コメント数を返す +fn parse_new_comments(json: &str, push_time: &str) -> usize { + let comments: Vec = serde_json::from_str(json).unwrap_or_else(|e| { + eprintln!("[check-ci-coderabbit] comments JSON パースエラー: {}", e); + vec![] + }); + + comments + .iter() + .filter(|c| { + let is_coderabbit = c + .user + .as_ref() + .and_then(|u| u.login.as_deref()) + .map(|l| l == "coderabbitai[bot]") + .unwrap_or(false); + + let after_push_time = c + .created_at + .as_deref() + .map(|t| t > push_time) + .unwrap_or(false); + + is_coderabbit && after_push_time + }) + .count() +} + +/// PR レビューの JSON から最新の CodeRabbit レビューの "Actionable comments posted: N" を抽出 +fn parse_actionable_comments(json: &str, push_time: &str) -> Option { + let reviews: Vec = serde_json::from_str(json).unwrap_or_else(|e| { + eprintln!("[check-ci-coderabbit] reviews JSON パースエラー: {}", e); + vec![] + }); + + let latest = reviews + .iter() + .filter(|r| { + let is_coderabbit = r + .user + .as_ref() + .and_then(|u| u.login.as_deref()) + .map(|l| l == "coderabbitai[bot]") + .unwrap_or(false); + + let after_push_time = r + .submitted_at + .as_deref() + .map(|t| t > push_time) + .unwrap_or(false); + + is_coderabbit && after_push_time + }) + .last()?; + + let body = latest.body.as_deref()?; + + // "Actionable comments posted: 3" のようなパターンを抽出 + extract_actionable_count(body) +} + +/// 文字列から "Actionable comments posted: N" の N を抽出 +fn extract_actionable_count(body: &str) -> Option { + let marker = "Actionable comments posted: "; + let pos = body.find(marker)?; + let rest = &body[pos + marker.len()..]; + let num_str: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + num_str.parse::().ok() +} + +/// GraphQL レスポンスから未解決スレッド数をパースする +fn parse_unresolved_threads(json: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(json).ok()?; + let nodes = value + .pointer("/data/repository/pullRequest/reviewThreads/nodes")? + .as_array()?; + + let unresolved = nodes + .iter() + .filter(|n| n.get("isResolved").and_then(|v| v.as_bool()) == Some(false)) + .count(); + + Some(unresolved) +} + +// ─── 判定ロジック ─── + +/// CI と CodeRabbit の状態から (status, action) を決定する +fn decide(ci: &CiStatus, cr: &CodeRabbitStatus) -> (String, String) { + // CI が失敗 → 即座に報告 + if ci.overall == "failure" { + 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" + { + return ("pending".to_string(), "continue_monitoring".to_string()); + } + + // CodeRabbit がエラー + if cr.review_state == "failure" || cr.review_state == "error" { + 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 { + return ( + "action_required".to_string(), + "action_required".to_string(), + ); + } + + // すべて OK + ("complete".to_string(), "stop_monitoring_success".to_string()) +} + +/// 人間向けサマリーを生成 +fn build_summary(ci: &CiStatus, cr: &CodeRabbitStatus) -> String { + let ci_part = match ci.overall.as_str() { + "success" => "CI成功".to_string(), + "failure" => { + let failed: Vec<&str> = ci + .runs + .iter() + .filter(|r| r.conclusion == "failure") + .map(|r| r.name.as_str()) + .collect(); + format!("CI失敗 ({})", failed.join(", ")) + } + _ => "CI実行中".to_string(), + }; + + let cr_part = match cr.review_state.as_str() { + "success" => { + let mut parts = vec![]; + let effective = cr + .actionable_comments + .map(|a| std::cmp::max(a, cr.new_comments)) + .unwrap_or(cr.new_comments); + if effective > 0 { + parts.push(format!("新規指摘{}件", effective)); + } + 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("、")) + } + } + "pending" | "not_found" => "CodeRabbitレビュー待ち".to_string(), + _ => format!("CodeRabbit状態: {}", cr.review_state), + }; + + format!("{}。{}", ci_part, cr_part) +} + +// ─── 自動取得ヘルパー ─── + +fn auto_detect_repo() -> Result { + run_gh(&["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]) +} + +fn auto_detect_pr() -> Result { + let output = run_gh(&["pr", "view", "--json", "number", "-q", ".number"])?; + output + .parse::() + .map_err(|_| format!("PR番号のパースに失敗: {}", output)) +} + +fn get_current_branch() -> Result { + let child = Command::new("git") + .args(["branch", "--show-current"]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|e| format!("git branch の起動に失敗: {}", e))?; + let output = child + .wait_with_output() + .map_err(|e| format!("git branch の実行に失敗: {}", e))?; + // Note: wait_with_output 自体にはタイムアウトがないが、 + // 呼び出し元の CronCreate ジョブ全体にタイムアウトがあるため実用上問題ない + let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if branch.is_empty() { + Err("現在のブランチを取得できませんでした".to_string()) + } else { + Ok(branch) + } +} + +fn get_head_sha() -> Result { + run_gh(&["pr", "view", "--json", "headRefOid", "-q", ".headRefOid"]) +} + +// ─── 入力値検証 ─── + +/// repo が "owner/name" 形式 (英数字・ハイフン・ドット・アンダースコア) であることを検証 +fn is_valid_repo(repo: &str) -> bool { + let re = regex::Regex::new(r"^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$").unwrap(); + re.is_match(repo) +} + +/// head_sha が 40文字の16進数であることを検証 +fn is_valid_sha(sha: &str) -> bool { + sha.len() == 40 && sha.chars().all(|c| c.is_ascii_hexdigit()) +} + +// ─── メインロジック ─── + +fn run_check(args: CliArgs) -> CheckResult { + let repo_result = args + .repo + .map(Ok) + .unwrap_or_else(|| auto_detect_repo()); + let pr_result = args + .pr + .map(Ok) + .unwrap_or_else(|| auto_detect_pr()); + + // エラーメッセージを事前に抽出 (unwrap_or で move される前に) + let repo_err = repo_result.as_ref().err().cloned(); + let pr_err = pr_result.as_ref().err().cloned(); + let repo = repo_result.map(|r| r).unwrap_or_default(); + let pr = pr_result.unwrap_or(0); + + if repo.is_empty() || pr == 0 || !is_valid_repo(&repo) { + let mut reasons = vec![]; + if repo.is_empty() { + let detail = repo_err.unwrap_or_else(|| "不明".to_string()); + reasons.push(format!("リポジトリ取得失敗: {}", detail)); + } else if !is_valid_repo(&repo) { + reasons.push(format!("リポジトリ名が不正: {}", repo)); + } + if pr == 0 { + let detail = pr_err.unwrap_or_else(|| "不明".to_string()); + reasons.push(format!("PR番号取得失敗: {}", detail)); + } + let summary = format!("初期化エラー: {}", reasons.join("; ")); + eprintln!("[check-ci-coderabbit] {}", summary); + return CheckResult { + status: "error".to_string(), + action: "stop_monitoring_failure".to_string(), + ci: CiStatus { + overall: "error".to_string(), + runs: vec![], + }, + coderabbit: CodeRabbitStatus { + review_state: "error".to_string(), + ..Default::default() + }, + summary, + }; + } + + // 1. CI 状態チェック + let branch = get_current_branch().unwrap_or_default(); + let ci = if !branch.is_empty() { + let ci_json = run_gh(&[ + "run", "list", "--branch", &branch, "--limit", "5", "--json", "name,conclusion", + ]) + .unwrap_or_else(|_| "[]".to_string()); + parse_ci_runs(&ci_json) + } else { + CiStatus { + overall: "pending".to_string(), + runs: vec![], + } + }; + + // 2. CodeRabbit commit status + let head_sha = get_head_sha().unwrap_or_default(); + let cr_state = if !head_sha.is_empty() && is_valid_sha(&head_sha) { + let statuses_json = run_gh(&[ + "api", + &format!("repos/{}/commits/{}/statuses", repo, head_sha), + ]) + .unwrap_or_else(|_| "[]".to_string()); + parse_coderabbit_status(&statuses_json) + } else { + "not_found".to_string() + }; + + // 3. 新規コメント + let pr_str = pr.to_string(); + let comments_json = run_gh(&[ + "api", + &format!("repos/{}/issues/{}/comments", repo, pr_str), + ]) + .unwrap_or_else(|_| "[]".to_string()); + let new_comments = parse_new_comments(&comments_json, &args.push_time); + + // 4. Actionable comments クロスチェック + let reviews_json = run_gh(&[ + "api", + &format!("repos/{}/pulls/{}/reviews", repo, pr_str), + ]) + .unwrap_or_else(|_| "[]".to_string()); + let actionable = parse_actionable_comments(&reviews_json, &args.push_time); + + // 5. 未解決スレッド (GraphQL) — 値直接埋め込み (入力は is_valid_repo で検証済み) + let (owner, name) = repo.split_once('/').unwrap_or(("", "")); + let unresolved = if !owner.is_empty() && !name.is_empty() { + let query = format!( + r#"{{ repository(owner: "{}", name: "{}") {{ pullRequest(number: {}) {{ reviewThreads(first: 100) {{ nodes {{ isResolved }} }} }} }} }}"#, + owner, name, pr + ); + let graphql_json = run_gh(&[ + "api", "graphql", + "-f", &format!("query={}", query), + ]) + .unwrap_or_else(|e| { + eprintln!("[check-ci-coderabbit] GraphQL クエリ失敗: {}", e); + "{}".to_string() + }); + parse_unresolved_threads(&graphql_json) + } else { + None + }; + + let cr = CodeRabbitStatus { + review_state: cr_state, + new_comments, + actionable_comments: actionable, + unresolved_threads: unresolved, + }; + + let (status, action) = decide(&ci, &cr); + let summary = build_summary(&ci, &cr); + + CheckResult { + status, + action, + ci, + coderabbit: cr, + summary, + } +} + +fn main() { + let args = match parse_args() { + Ok(a) => a, + Err(e) => { + eprintln!("[check-ci-coderabbit] エラー: {}", e); + eprintln!("使い方: check-ci-coderabbit.exe --push-time [--repo owner/repo] [--pr N]"); + std::process::exit(1); + } + }; + + let result = run_check(args); + let json = serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string()); + println!("{}", json); +} + +// ─── テスト ─── + +#[cfg(test)] +mod tests { + use super::*; + + // --- parse_ci_runs --- + + #[test] + fn ci_all_success() { + let json = r#"[ + {"name": "build", "conclusion": "success"}, + {"name": "test", "conclusion": "success"} + ]"#; + let ci = parse_ci_runs(json); + assert_eq!(ci.overall, "success"); + assert_eq!(ci.runs.len(), 2); + } + + #[test] + fn ci_one_failure() { + let json = r#"[ + {"name": "build", "conclusion": "success"}, + {"name": "test", "conclusion": "failure"} + ]"#; + let ci = parse_ci_runs(json); + assert_eq!(ci.overall, "failure"); + } + + #[test] + fn ci_pending_null_conclusion() { + let json = r#"[ + {"name": "build", "conclusion": null}, + {"name": "test", "conclusion": "success"} + ]"#; + let ci = parse_ci_runs(json); + assert_eq!(ci.overall, "pending"); + } + + #[test] + fn ci_pending_in_progress() { + let json = r#"[ + {"name": "build", "conclusion": "in_progress"} + ]"#; + let ci = parse_ci_runs(json); + assert_eq!(ci.overall, "pending"); + } + + #[test] + fn ci_empty_runs() { + let json = "[]"; + let ci = parse_ci_runs(json); + assert_eq!(ci.overall, "pending"); + assert!(ci.runs.is_empty()); + } + + #[test] + fn ci_cancelled_is_failure() { + let json = r#"[{"name": "deploy", "conclusion": "cancelled"}]"#; + let ci = parse_ci_runs(json); + assert_eq!(ci.overall, "failure"); + } + + // --- parse_coderabbit_status --- + + #[test] + fn cr_status_success() { + let json = r#"[ + {"context": "CodeRabbit", "state": "success"} + ]"#; + assert_eq!(parse_coderabbit_status(json), "success"); + } + + #[test] + fn cr_status_pending() { + let json = r#"[ + {"context": "CodeRabbit", "state": "pending"} + ]"#; + assert_eq!(parse_coderabbit_status(json), "pending"); + } + + #[test] + fn cr_status_not_found() { + let json = r#"[ + {"context": "ci/build", "state": "success"} + ]"#; + assert_eq!(parse_coderabbit_status(json), "not_found"); + } + + #[test] + fn cr_status_empty() { + assert_eq!(parse_coderabbit_status("[]"), "not_found"); + } + + #[test] + fn cr_status_multiple_takes_last() { + let json = r#"[ + {"context": "CodeRabbit", "state": "pending"}, + {"context": "CodeRabbit", "state": "success"} + ]"#; + assert_eq!(parse_coderabbit_status(json), "success"); + } + + // --- parse_new_comments --- + + #[test] + fn comments_filters_by_time() { + let json = r#"[ + {"user": {"login": "coderabbitai[bot]"}, "body": "_old comment", "created_at": "2026-04-01T10:00:00Z"}, + {"user": {"login": "coderabbitai[bot]"}, "body": "_new comment", "created_at": "2026-04-01T12:30:00Z"} + ]"#; + assert_eq!(parse_new_comments(json, "2026-04-01T12:00:00Z"), 1); + } + + #[test] + fn comments_filters_by_user() { + let json = r#"[ + {"user": {"login": "someuser"}, "body": "_comment", "created_at": "2026-04-01T12:30:00Z"}, + {"user": {"login": "coderabbitai[bot]"}, "body": "_comment", "created_at": "2026-04-01T12:30:00Z"} + ]"#; + assert_eq!(parse_new_comments(json, "2026-04-01T12:00:00Z"), 1); + } + + #[test] + fn comments_filters_coderabbit_user_only() { + // body の内容に関係なく、coderabbitai[bot] のコメントは全てカウント + let json = r#"[ + {"user": {"login": "coderabbitai[bot]"}, "body": "Summary of changes", "created_at": "2026-04-01T12:30:00Z"}, + {"user": {"login": "coderabbitai[bot]"}, "body": "", "created_at": "2026-04-01T12:30:00Z"} + ]"#; + assert_eq!(parse_new_comments(json, "2026-04-01T12:00:00Z"), 2); + } + + #[test] + fn comments_empty() { + assert_eq!(parse_new_comments("[]", "2026-04-01T12:00:00Z"), 0); + } + + // --- parse_actionable_comments --- + + #[test] + fn actionable_extracts_count() { + let json = r#"[ + {"user": {"login": "coderabbitai[bot]"}, "body": "Some review\nActionable comments posted: 3\nMore text", "submitted_at": "2026-04-01T12:30:00Z"} + ]"#; + assert_eq!( + parse_actionable_comments(json, "2026-04-01T12:00:00Z"), + Some(3) + ); + } + + #[test] + fn actionable_no_match() { + let json = r#"[ + {"user": {"login": "coderabbitai[bot]"}, "body": "No actionable items", "submitted_at": "2026-04-01T12:30:00Z"} + ]"#; + assert_eq!( + parse_actionable_comments(json, "2026-04-01T12:00:00Z"), + None + ); + } + + #[test] + fn actionable_filters_by_time() { + let json = r#"[ + {"user": {"login": "coderabbitai[bot]"}, "body": "Actionable comments posted: 5", "submitted_at": "2026-04-01T10:00:00Z"} + ]"#; + // submitted_at は push_time より前なのでフィルタされる + assert_eq!( + parse_actionable_comments(json, "2026-04-01T12:00:00Z"), + None + ); + } + + // --- extract_actionable_count --- + + #[test] + fn extract_count_from_body() { + assert_eq!( + extract_actionable_count("Actionable comments posted: 7"), + Some(7) + ); + } + + #[test] + fn extract_count_zero() { + assert_eq!( + extract_actionable_count("Actionable comments posted: 0"), + Some(0) + ); + } + + #[test] + fn extract_count_not_found() { + assert_eq!(extract_actionable_count("No issues found"), None); + } + + // --- parse_unresolved_threads --- + + #[test] + fn unresolved_threads_counts() { + let json = r#"{ + "data": { + "repository": { + "pullRequest": { + "reviewThreads": { + "nodes": [ + {"isResolved": false}, + {"isResolved": true}, + {"isResolved": false} + ] + } + } + } + } + }"#; + assert_eq!(parse_unresolved_threads(json), Some(2)); + } + + #[test] + fn unresolved_threads_all_resolved() { + let json = r#"{ + "data": { + "repository": { + "pullRequest": { + "reviewThreads": { + "nodes": [ + {"isResolved": true} + ] + } + } + } + } + }"#; + assert_eq!(parse_unresolved_threads(json), Some(0)); + } + + #[test] + fn unresolved_threads_invalid_json() { + assert_eq!(parse_unresolved_threads("{}"), None); + } + + // --- decide --- + + #[test] + fn decide_ci_pending() { + let ci = CiStatus { + overall: "pending".to_string(), + runs: vec![], + }; + let cr = CodeRabbitStatus { + review_state: "success".to_string(), + ..Default::default() + }; + let (status, action) = decide(&ci, &cr); + assert_eq!(status, "pending"); + assert_eq!(action, "continue_monitoring"); + } + + #[test] + fn decide_cr_pending() { + let ci = CiStatus { + overall: "success".to_string(), + runs: vec![], + }; + let cr = CodeRabbitStatus { + review_state: "pending".to_string(), + ..Default::default() + }; + let (status, action) = decide(&ci, &cr); + assert_eq!(status, "pending"); + assert_eq!(action, "continue_monitoring"); + } + + #[test] + fn decide_cr_not_found() { + let ci = CiStatus { + overall: "success".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"); + } + + #[test] + fn decide_ci_failure() { + let ci = CiStatus { + overall: "failure".to_string(), + runs: vec![CiRunSummary { + name: "test".to_string(), + conclusion: "failure".to_string(), + }], + }; + let cr = CodeRabbitStatus { + review_state: "success".to_string(), + ..Default::default() + }; + let (status, action) = decide(&ci, &cr); + assert_eq!(status, "error"); + assert_eq!(action, "stop_monitoring_failure"); + } + + #[test] + fn decide_new_comments() { + let ci = CiStatus { + overall: "success".to_string(), + runs: vec![], + }; + let cr = CodeRabbitStatus { + review_state: "success".to_string(), + new_comments: 2, + actionable_comments: None, + unresolved_threads: Some(0), + }; + let (status, action) = decide(&ci, &cr); + assert_eq!(status, "action_required"); + assert_eq!(action, "action_required"); + } + + #[test] + fn decide_unresolved_threads() { + let ci = CiStatus { + overall: "success".to_string(), + runs: vec![], + }; + let cr = CodeRabbitStatus { + review_state: "success".to_string(), + new_comments: 0, + actionable_comments: None, + unresolved_threads: Some(3), + }; + let (status, action) = decide(&ci, &cr); + assert_eq!(status, "action_required"); + assert_eq!(action, "action_required"); + } + + #[test] + fn decide_actionable_overrides_new_comments() { + let ci = CiStatus { + overall: "success".to_string(), + runs: vec![], + }; + let cr = CodeRabbitStatus { + review_state: "success".to_string(), + new_comments: 0, + actionable_comments: Some(3), // レビュー本文では3件、コメントAPIでは0件 + unresolved_threads: Some(0), + }; + let (status, action) = decide(&ci, &cr); + assert_eq!(status, "action_required"); + assert_eq!(action, "action_required"); + } + + #[test] + fn decide_all_clean() { + let ci = CiStatus { + overall: "success".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_cr_failure() { + let ci = CiStatus { + overall: "success".to_string(), + runs: vec![], + }; + let cr = CodeRabbitStatus { + review_state: "failure".to_string(), + ..Default::default() + }; + let (status, action) = decide(&ci, &cr); + assert_eq!(status, "error"); + assert_eq!(action, "stop_monitoring_failure"); + } + + // --- build_summary --- + + #[test] + fn summary_all_clean() { + let ci = CiStatus { + overall: "success".to_string(), + runs: vec![], + }; + let cr = CodeRabbitStatus { + review_state: "success".to_string(), + new_comments: 0, + actionable_comments: Some(0), + unresolved_threads: Some(0), + }; + let summary = build_summary(&ci, &cr); + assert!(summary.contains("CI成功")); + assert!(summary.contains("指摘なし")); + } + + #[test] + fn summary_ci_failure() { + let ci = CiStatus { + overall: "failure".to_string(), + runs: vec![CiRunSummary { + name: "test".to_string(), + conclusion: "failure".to_string(), + }], + }; + let cr = CodeRabbitStatus::default(); + let summary = build_summary(&ci, &cr); + assert!(summary.contains("CI失敗")); + assert!(summary.contains("test")); + } + + #[test] + fn summary_with_comments_and_threads() { + let ci = CiStatus { + overall: "success".to_string(), + runs: vec![], + }; + let cr = CodeRabbitStatus { + review_state: "success".to_string(), + new_comments: 2, + actionable_comments: Some(3), + unresolved_threads: Some(1), + }; + let summary = build_summary(&ci, &cr); + assert!(summary.contains("新規指摘3件")); + assert!(summary.contains("未解決スレッド1件")); + } + + // --- parse_args --- + + #[test] + fn parse_args_extracts_push_time() { + // parse_args reads from std::env::args, so we test the logic indirectly + // by testing the struct construction + let args = CliArgs { + push_time: "2026-04-01T12:00:00Z".to_string(), + repo: Some("owner/repo".to_string()), + pr: Some(42), + }; + assert_eq!(args.push_time, "2026-04-01T12:00:00Z"); + assert_eq!(args.repo, Some("owner/repo".to_string())); + assert_eq!(args.pr, Some(42)); + } +} diff --git a/.claude/hooks-config.toml b/.claude/hooks-config.toml index 9de110f6..4dddb3d4 100644 --- a/.claude/hooks-config.toml +++ b/.claude/hooks-config.toml @@ -100,3 +100,20 @@ cmd = "pnpm review:ai" name = "commit_refactor" type = "ai" prompt = "optimize_commit_structure" + +# ─── PostToolUse: PR モニター ─── +# +# gh pr create / git push / jj git push 検出後に +# CI + CodeRabbit の自動モニタリングを CronCreate で開始する。 +# check-ci-coderabbit.exe によるポーリングで監視し、 +# 結果の action フィールドに従って Claude が行動する。 + +[post_pr_monitor] +enabled = true +poll_interval_secs = 30 # CronCreate のポーリング間隔(秒) +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 = [] # 空配列 = 全トリガー無効化(モニタリング停止) diff --git a/.claude/hooks-post-pr-monitor/Cargo.lock b/.claude/hooks-post-pr-monitor/Cargo.lock new file mode 100644 index 00000000..d0d259e1 --- /dev/null +++ b/.claude/hooks-post-pr-monitor/Cargo.lock @@ -0,0 +1,228 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hooks-post-pr-monitor" +version = "0.1.0" +dependencies = [ + "regex", + "serde", + "serde_json", + "toml", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/.claude/hooks-post-pr-monitor/Cargo.toml b/.claude/hooks-post-pr-monitor/Cargo.toml new file mode 100644 index 00000000..85db042e --- /dev/null +++ b/.claude/hooks-post-pr-monitor/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "hooks-post-pr-monitor" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +toml = "0.8" +regex = "1.10" + +[profile.release] +opt-level = 3 +lto = true +strip = true diff --git a/.claude/hooks-post-pr-monitor/src/main.rs b/.claude/hooks-post-pr-monitor/src/main.rs new file mode 100644 index 00000000..de572bf2 --- /dev/null +++ b/.claude/hooks-post-pr-monitor/src/main.rs @@ -0,0 +1,688 @@ +//! PostToolUse hook: PR モニター起動トリガー +//! +//! Bash ツール実行後に gh pr create / git push / jj git push を検出し、 +//! CronCreate で check-ci-coderabbit を起動する指示を Claude に返す。 +//! +//! 入力 (stdin): {"tool_input": {"command": "gh pr create ..."}} +//! 出力 (stdout): {"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "..."}} +//! +//! 非対象コマンドの場合は何も出力せず exit 0。 + +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::Command; + +// ─── stdin モデル ─── + +#[derive(Deserialize)] +struct HookInput { + tool_input: Option, +} + +#[derive(Deserialize)] +struct ToolInput { + command: Option, +} + +// ─── stdout モデル ─── + +#[derive(Serialize)] +struct HookOutput { + #[serde(rename = "hookSpecificOutput")] + hook_specific_output: HookSpecificOutput, +} + +#[derive(Serialize)] +struct HookSpecificOutput { + #[serde(rename = "hookEventName")] + hook_event_name: String, + #[serde(rename = "additionalContext")] + additional_context: String, +} + +// ─── 設定 ─── + +#[derive(Deserialize, Default)] +struct Config { + post_pr_monitor: Option, +} + +#[derive(Deserialize, Clone)] +struct PostPrMonitorConfig { + enabled: Option, + poll_interval_secs: Option, + max_duration_secs: Option, + check_ci: Option, + check_coderabbit: Option, + trigger_patterns: Option>, +} + +impl Default for PostPrMonitorConfig { + fn default() -> Self { + Self { + enabled: Some(true), + poll_interval_secs: Some(DEFAULT_POLL_INTERVAL), + max_duration_secs: Some(DEFAULT_MAX_DURATION), + check_ci: Some(true), + check_coderabbit: Some(true), + trigger_patterns: None, + } + } +} + +const DEFAULT_POLL_INTERVAL: u64 = 30; +const DEFAULT_MAX_DURATION: u64 = 600; + +// ─── デフォルトトリガーパターン ─── + +/// gh pr create (オプション付き、gh -R owner/repo pr create 等) +const PAT_GH_PR_CREATE: &str = r"^\s*gh\s+(?:.*\s+)?pr\s+create(\s|$)"; + +/// git push (git stash push / git submodule push を除外) +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|$)"; + +fn default_patterns() -> Vec { + vec![ + PAT_GH_PR_CREATE.to_string(), + PAT_GIT_PUSH.to_string(), + PAT_JJ_GIT_PUSH.to_string(), + ] +} + +// ─── コマンド検出 ─── + +/// コマンド文字列がトリガーパターンにマッチするか判定 +fn is_trigger_command(command: &str, patterns: &[String]) -> bool { + for pat in patterns { + match Regex::new(pat) { + Ok(re) => { + if re.is_match(command) { + return true; + } + } + Err(e) => { + eprintln!("[post-pr-monitor] 無効な正規表現パターン \"{}\": {}", pat, e); + } + } + } + false +} + +/// マッチしたコマンドの種別を返す (ログ用) +fn detect_command_type(command: &str) -> &'static str { + if let Ok(re) = Regex::new(PAT_GH_PR_CREATE) { + if re.is_match(command) { + return "gh pr create"; + } + } + if let Ok(re) = Regex::new(PAT_GIT_PUSH) { + if re.is_match(command) { + return "git push"; + } + } + if let Ok(re) = Regex::new(PAT_JJ_GIT_PUSH) { + if re.is_match(command) { + return "jj git push"; + } + } + "unknown" +} + +// ─── 設定ファイル読み込み ─── + +fn config_path() -> PathBuf { + std::env::current_exe() + .unwrap_or_default() + .parent() + .unwrap_or(Path::new(".")) + .join("hooks-config.toml") +} + +fn load_config() -> Config { + let path = config_path(); + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => return Config::default(), + }; + toml::from_str(&content).unwrap_or_else(|e| { + eprintln!("[post-pr-monitor] hooks-config.toml パースエラー (デフォルト使用): {}", e); + Config::default() + }) +} + +// ─── check-ci-coderabbit.exe のパス解決 ─── + +fn checker_exe_path() -> PathBuf { + std::env::current_exe() + .unwrap_or_default() + .parent() + .unwrap_or(Path::new(".")) + .join("check-ci-coderabbit.exe") +} + +// ─── PR 情報取得 ─── + +struct PrInfo { + pr_number: Option, + repo: Option, +} + +fn get_pr_info() -> PrInfo { + let pr_number = run_gh_quiet(&["pr", "view", "--json", "number", "-q", ".number"]) + .and_then(|s| s.parse::().ok()); + + let repo = run_gh_quiet(&["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]); + + PrInfo { pr_number, repo } +} + +/// gh コマンドを静かに実行 (stderr 抑制) +fn run_gh_quiet(args: &[&str]) -> Option { + let output = Command::new("cmd") + .args(["/c", "gh"]) + .args(args) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .output() + .ok()?; + + if output.status.success() { + let s = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if s.is_empty() { + None + } else { + Some(s) + } + } else { + None + } +} + +// ─── additionalContext 構築 ─── + +fn build_additional_context( + command_type: &str, + pr_info: &PrInfo, + push_time: &str, + checker_path: &Path, + config: &PostPrMonitorConfig, +) -> String { + let interval = config.poll_interval_secs.unwrap_or(DEFAULT_POLL_INTERVAL); + let max_dur = config.max_duration_secs.unwrap_or(DEFAULT_MAX_DURATION); + let max_dur_min = max_dur / 60; + + let pr_label = pr_info + .pr_number + .map(|n| format!("PR #{}", n)) + .unwrap_or_else(|| "PR".to_string()); + + let checker = checker_path.to_string_lossy(); + + // check-ci-coderabbit の引数を組み立て + let mut checker_args = format!("--push-time \"{}\"", push_time); + if let Some(ref repo) = pr_info.repo { + checker_args.push_str(&format!(" --repo \"{}\"", repo)); + } + if let Some(pr) = pr_info.pr_number { + checker_args.push_str(&format!(" --pr {}", pr)); + } + + let check_scope = match ( + config.check_ci.unwrap_or(true), + config.check_coderabbit.unwrap_or(true), + ) { + (true, true) => "CI + CodeRabbit", + (true, false) => "CI", + (false, true) => "CodeRabbit", + (false, false) => "なし", + }; + + format!( + r#"[post-pr-monitor] {command_type} 検出 ({pr_label})。{check_scope} の自動監視を開始してください。 + +[CronCreate 設定] +CronCreate で {interval}秒間隔の監視ジョブを作成してください。 +- command: "{checker}" {checker_args} +- 最大監視時間: {max_dur_min}分({max_dur}秒経過後は CronDelete で停止) + +[監視結果の読み方] +check-ci-coderabbit は JSON を返します。action フィールドに従って行動してください: +- continue_monitoring → 何もせず次回チェックを待つ +- stop_monitoring_success → CronDelete で監視停止。「CI・CodeRabbit 共に成功、新規指摘なし」と報告 +- stop_monitoring_failure → CronDelete で監視停止。ci.runs や summary をユーザーに報告 +- action_required → CronDelete で監視停止。coderabbit の new_comments と unresolved_threads を確認し、/post-pr-create-review-check で詳細を取得して対応方針をまとめ、ユーザーに判断を仰ぐ(勝手に修正しない) + +[対応完了後の返信ルール] +CodeRabbit の全コメントに必ず返信すること(対応済み・対応不要の両方。resolve はしない)。 +返信は必ず push 後に行うこと(修正コミット → push → 返信の順)。"# + ) +} + +// ─── stdout 出力 ─── + +fn emit_feedback(context: &str) { + let output = HookOutput { + hook_specific_output: HookSpecificOutput { + hook_event_name: "PostToolUse".to_string(), + additional_context: context.to_string(), + }, + }; + match serde_json::to_string(&output) { + Ok(json) => println!("{}", json), + Err(e) => eprintln!("[post-pr-monitor] JSON シリアライズエラー: {}", e), + } +} + +// ─── メイン ─── + +/// デバッグログをファイルに追記 (テスト用 — 本番では削除) +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)); + 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)); + return; + } + }; + + // コマンド抽出 + let command = match hook_input.tool_input.and_then(|t| t.command) { + Some(c) if !c.trim().is_empty() => c, + _ => { + debug_log("コマンドなし — スキップ"); + 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; + } + + // トリガーパターン判定 + let patterns = monitor_config + .trigger_patterns + .clone() + .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); + + // push 時刻を記録 (UTC ISO 8601) + let push_time = utc_now_iso8601(); + + // PR 情報を取得 + let pr_info = get_pr_info(); + + // check-ci-coderabbit.exe のパス + let checker_path = checker_exe_path(); + + // additionalContext を構築して出力 + let context = + build_additional_context(command_type, &pr_info, &push_time, &checker_path, &monitor_config); + emit_feedback(&context); +} + +/// epoch seconds を ISO 8601 UTC 文字列に変換する (std のみ, chrono 不要) +/// Howard Hinnant の civil_from_days アルゴリズムを使用 +fn epoch_secs_to_iso8601(epoch: u64) -> String { + let secs_per_day: u64 = 86400; + let day_count = (epoch / secs_per_day) as i64; + let time_of_day = epoch % secs_per_day; + + // Howard Hinnant's civil_from_days (epoch = 1970-01-01) + let z = day_count + 719468; // shift to 0000-03-01 epoch + let era = (if z >= 0 { z } else { z - 146096 }) / 146097; + let doe = (z - era * 146097) as u64; // day of era [0, 146096] + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + + let hour = time_of_day / 3600; + let min = (time_of_day % 3600) / 60; + let sec = time_of_day % 60; + + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", + y, m, d, hour, min, sec + ) +} + +/// 現在の UTC 時刻を ISO 8601 形式で返す +fn utc_now_iso8601() -> String { + use std::time::SystemTime; + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default(); + epoch_secs_to_iso8601(now.as_secs()) +} + +fn main() { + run(); +} + +// ─── テスト ─── + +#[cfg(test)] +mod tests { + use super::*; + + // --- is_trigger_command --- + + #[test] + fn trigger_gh_pr_create() { + let patterns = default_patterns(); + assert!(is_trigger_command("gh pr create --title \"test\"", &patterns)); + } + + #[test] + fn trigger_gh_pr_create_with_repo() { + let patterns = default_patterns(); + assert!(is_trigger_command("gh -R owner/repo pr create", &patterns)); + } + + #[test] + fn trigger_git_push() { + let patterns = default_patterns(); + assert!(is_trigger_command("git push origin main", &patterns)); + } + + #[test] + fn trigger_git_push_bare() { + let patterns = default_patterns(); + assert!(is_trigger_command("git push", &patterns)); + } + + #[test] + fn trigger_jj_git_push() { + let patterns = default_patterns(); + assert!(is_trigger_command("jj git push", &patterns)); + } + + #[test] + fn no_trigger_gh_pr_view() { + let patterns = default_patterns(); + assert!(!is_trigger_command("gh pr view", &patterns)); + } + + #[test] + fn no_trigger_git_status() { + let patterns = default_patterns(); + assert!(!is_trigger_command("git status", &patterns)); + } + + #[test] + fn no_trigger_git_stash_push() { + let patterns = default_patterns(); + assert!(!is_trigger_command("git stash push -m \"wip\"", &patterns)); + } + + #[test] + fn no_trigger_npm_run() { + let patterns = default_patterns(); + assert!(!is_trigger_command("npm run build", &patterns)); + } + + #[test] + fn no_trigger_empty() { + let patterns = default_patterns(); + assert!(!is_trigger_command("", &patterns)); + } + + #[test] + fn custom_trigger_patterns() { + let patterns = vec![r"^\s*my-push-cmd".to_string()]; + assert!(is_trigger_command("my-push-cmd --force", &patterns)); + assert!(!is_trigger_command("git push", &patterns)); + } + + // --- detect_command_type --- + + #[test] + fn detect_gh_pr_create() { + assert_eq!(detect_command_type("gh pr create --title test"), "gh pr create"); + } + + #[test] + fn detect_git_push() { + assert_eq!(detect_command_type("git push origin main"), "git push"); + } + + #[test] + fn detect_jj_git_push() { + assert_eq!(detect_command_type("jj git push"), "jj git push"); + } + + // --- config parsing --- + + #[test] + fn config_parses_post_pr_monitor() { + let toml_str = r#" +[post_pr_monitor] +enabled = true +poll_interval_secs = 45 +max_duration_secs = 900 +check_ci = true +check_coderabbit = false +trigger_patterns = ["^my-push"] +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + let m = config.post_pr_monitor.unwrap(); + assert_eq!(m.enabled, Some(true)); + assert_eq!(m.poll_interval_secs, Some(45)); + assert_eq!(m.max_duration_secs, Some(900)); + assert_eq!(m.check_ci, Some(true)); + assert_eq!(m.check_coderabbit, Some(false)); + assert_eq!(m.trigger_patterns.unwrap(), vec!["^my-push"]); + } + + #[test] + fn config_defaults_when_empty() { + let toml_str = "[post_pr_monitor]\n"; + let config: Config = toml::from_str(toml_str).unwrap(); + let m = config.post_pr_monitor.unwrap(); + assert_eq!(m.enabled, None); + assert_eq!(m.poll_interval_secs, None); + } + + #[test] + fn config_missing_section() { + let toml_str = "[stop_quality]\nstep_timeout = 60\n"; + let config: Config = toml::from_str(toml_str).unwrap(); + assert!(config.post_pr_monitor.is_none()); + } + + // --- build_additional_context --- + + #[test] + fn context_contains_cron_instruction() { + let pr_info = PrInfo { + pr_number: Some(42), + repo: Some("owner/repo".to_string()), + }; + let config = PostPrMonitorConfig::default(); + let context = build_additional_context( + "gh pr create", + &pr_info, + "2026-04-01T12:00:00Z", + Path::new("C:\\test\\check-ci-coderabbit.exe"), + &config, + ); + assert!(context.contains("CronCreate")); + assert!(context.contains("30秒間隔")); + assert!(context.contains("PR #42")); + assert!(context.contains("owner/repo")); + assert!(context.contains("2026-04-01T12:00:00Z")); + assert!(context.contains("check-ci-coderabbit.exe")); + } + + #[test] + fn context_with_custom_interval() { + let pr_info = PrInfo { + pr_number: Some(1), + repo: Some("o/r".to_string()), + }; + let config = PostPrMonitorConfig { + poll_interval_secs: Some(60), + max_duration_secs: Some(300), + ..Default::default() + }; + let context = build_additional_context( + "git push", + &pr_info, + "2026-04-01T12:00:00Z", + Path::new("checker.exe"), + &config, + ); + assert!(context.contains("60秒間隔")); + assert!(context.contains("5分")); + } + + #[test] + fn context_without_pr_number() { + let pr_info = PrInfo { + pr_number: None, + repo: Some("owner/repo".to_string()), + }; + let config = PostPrMonitorConfig::default(); + let context = build_additional_context( + "git push", + &pr_info, + "2026-04-01T12:00:00Z", + Path::new("checker.exe"), + &config, + ); + // PR番号なしの場合は "PR" のみ表示 + assert!(context.contains("(PR)")); + } + + // --- HookInput parsing --- + + #[test] + fn parse_hook_input_with_command() { + let json = r#"{"tool_input": {"command": "gh pr create --title test"}}"#; + let input: HookInput = serde_json::from_str(json).unwrap(); + assert_eq!( + input.tool_input.unwrap().command.unwrap(), + "gh pr create --title test" + ); + } + + #[test] + fn parse_hook_input_without_command() { + let json = r#"{"tool_input": {"file_path": "src/main.rs"}}"#; + let input: HookInput = serde_json::from_str(json).unwrap(); + assert!(input.tool_input.unwrap().command.is_none()); + } + + #[test] + fn parse_hook_input_empty() { + let json = r#"{}"#; + let input: HookInput = serde_json::from_str(json).unwrap(); + assert!(input.tool_input.is_none()); + } + + // --- emit_feedback --- + + #[test] + fn hook_output_serializes_correctly() { + let output = HookOutput { + hook_specific_output: HookSpecificOutput { + hook_event_name: "PostToolUse".to_string(), + additional_context: "test context".to_string(), + }, + }; + let json = serde_json::to_string(&output).unwrap(); + assert!(json.contains("hookSpecificOutput")); + assert!(json.contains("hookEventName")); + assert!(json.contains("PostToolUse")); + assert!(json.contains("additionalContext")); + assert!(json.contains("test context")); + } + + // --- disabled config --- + + #[test] + fn disabled_config() { + let toml_str = r#" +[post_pr_monitor] +enabled = false +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + let m = config.post_pr_monitor.unwrap(); + assert_eq!(m.enabled, Some(false)); + } + + // --- epoch_secs_to_iso8601 --- + + #[test] + fn epoch_zero() { + assert_eq!(epoch_secs_to_iso8601(0), "1970-01-01T00:00:00Z"); + } + + #[test] + fn epoch_known_date() { + // 2026-04-01T12:00:00Z = day 20544 * 86400 + 43200 = 1775044800 + assert_eq!(epoch_secs_to_iso8601(1775044800), "2026-04-01T12:00:00Z"); + } + + #[test] + fn epoch_leap_year() { + // 2024-02-29T00:00:00Z = 1709164800 + assert_eq!(epoch_secs_to_iso8601(1709164800), "2024-02-29T00:00:00Z"); + } + + #[test] + fn epoch_end_of_day() { + // 2026-04-01T23:59:59Z = day 20544 * 86400 + 86399 = 1775087999 + assert_eq!(epoch_secs_to_iso8601(1775087999), "2026-04-01T23:59:59Z"); + } +} diff --git a/.claude/post-pr-monitor-debug.log b/.claude/post-pr-monitor-debug.log new file mode 100644 index 00000000..ccf8c636 --- /dev/null +++ b/.claude/post-pr-monitor-debug.log @@ -0,0 +1,147 @@ +[2026-04-02T11:39:48Z] hook 起動 +[2026-04-02T11:39:48Z] stdin (1024 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-02T11:39:48Z] コマンド検出: cd e:/work/claude-code-hook-test/.claude/hooks-post-pr-monitor && cargo build --release 2>&1 && cp target/release/hooks-post-pr-monitor.exe ../hooks-post-pr-monitor.exe && rm -f ../post-pr-monitor-debug.log && echo "build done, log cleared" +[2026-04-02T11:39:48Z] トリガー不一致 — スキップ (command=cd e:/work/claude-code-hook-test/.claude/hooks-post-pr-monitor && cargo build --release 2>&1 && cp target/release/hooks-post-pr-monitor.exe ../hooks-post-pr-monitor.exe && rm -f ../post-pr-monitor-debug.log && echo "build done, log cleared") +[2026-04-02T11:39:56Z] hook 起動 +[2026-04-02T11:39:56Z] stdin (615 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-02T11:39:56Z] コマンド検出: echo "hook fire test" +[2026-04-02T11:39:56Z] トリガー不一致 — スキップ (command=echo "hook fire test") +[2026-04-02T11:40:04Z] hook 起動 +[2026-04-02T11:40:04Z] stdin (2057 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-02T11:40:04Z] コマンド検出: cat "e:/work/claude-code-hook-test/.claude/post-pr-monitor-debug.log" 2>&1 || echo "ログファイルが存在しません" +[2026-04-02T11:40:04Z] トリガー不一致 — スキップ (command=cat "e:/work/claude-code-hook-test/.claude/post-pr-monitor-debug.log" 2>&1 || echo "ログファイルが存在しません") +[2026-04-02T11:43:46Z] hook 起動 +[2026-04-02T11:43:46Z] stdin (807 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-02T11:43:46Z] コマンド検出: "e:/work/claude-code-hook-test/.claude/check-ci-coderabbit.exe" --push-time "2026-04-02T10:00:00Z" --repo "aloekun/claude-code-hook-test" --pr 10 2>&1 +[2026-04-02T11:43:46Z] トリガー不一致 — スキップ (command="e:/work/claude-code-hook-test/.claude/check-ci-coderabbit.exe" --push-time "2026-04-02T10:00:00Z" --repo "aloekun/claude-code-hook-test" --pr 10 2>&1) +[2026-04-02T11:45:09Z] hook 起動 +[2026-04-02T11:45:09Z] stdin (679 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-02T11:45:09Z] コマンド検出: tasklist | grep -i "check-ci\|gh.exe" 2>&1 +[2026-04-02T11:45:09Z] トリガー不一致 — スキップ (command=tasklist | grep -i "check-ci\|gh.exe" 2>&1) +[2026-04-02T11:45:18Z] hook 起動 +[2026-04-02T11:45:18Z] stdin (1198 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-02T11:45:18Z] コマンド検出: cat "C:/Users/HIROKI/AppData/Local/Temp/claude/e--work-claude-code-hook-test/9559f06a-430d-481b-ac76-768a33308873/tasks/bo5wtajr3.output" 2>&1 || echo "ファイルなし" +[2026-04-02T11:45:18Z] トリガー不一致 — スキップ (command=cat "C:/Users/HIROKI/AppData/Local/Temp/claude/e--work-claude-code-hook-test/9559f06a-430d-481b-ac76-768a33308873/tasks/bo5wtajr3.output" 2>&1 || echo "ファイルなし") +[2026-04-02T11:46:27Z] hook 起動 +[2026-04-02T11:46:27Z] stdin (711 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-02T11:46:27Z] コマンド検出: which gh 2>&1 && gh --version 2>&1 +[2026-04-02T11:46:27Z] トリガー不一致 — スキップ (command=which gh 2>&1 && gh --version 2>&1) +[2026-04-02T11:49:18Z] hook 起動 +[2026-04-02T11:49:18Z] stdin (953 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-02T11:49:18Z] コマンド検出: cd e:/work/claude-code-hook-test/.claude/check-ci-coderabbit && cargo build --release 2>&1 && cp target/release/check-ci-coderabbit.exe ../check-ci-coderabbit.exe && echo "build done" +[2026-04-02T11:49:18Z] トリガー不一致 — スキップ (command=cd e:/work/claude-code-hook-test/.claude/check-ci-coderabbit && cargo build --release 2>&1 && cp target/release/check-ci-coderabbit.exe ../check-ci-coderabbit.exe && echo "build done") +[2026-04-02T11:50:29Z] hook 起動 +[2026-04-02T11:50:29Z] stdin (808 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-02T11:50:29Z] コマンド検出: "e:/work/claude-code-hook-test/.claude/check-ci-coderabbit.exe" --push-time "2026-04-02T10:00:00Z" --repo "aloekun/claude-code-hook-test" --pr 10 2>&1 +[2026-04-02T11:50:29Z] トリガー不一致 — スキップ (command="e:/work/claude-code-hook-test/.claude/check-ci-coderabbit.exe" --push-time "2026-04-02T10:00:00Z" --repo "aloekun/claude-code-hook-test" --pr 10 2>&1) +[2026-04-02T11:51:41Z] hook 起動 +[2026-04-02T11:51:41Z] stdin (1151 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-02T11:51:41Z] コマンド検出: cat "C:/Users/HIROKI/AppData/Local/Temp/claude/e--work-claude-code-hook-test/9559f06a-430d-481b-ac76-768a33308873/tasks/b3gkv07l1.output" 2>&1 +[2026-04-02T11:51:41Z] トリガー不一致 — スキップ (command=cat "C:/Users/HIROKI/AppData/Local/Temp/claude/e--work-claude-code-hook-test/9559f06a-430d-481b-ac76-768a33308873/tasks/b3gkv07l1.output" 2>&1) +[2026-04-02T11:51:58Z] hook 起動 +[2026-04-02T11:51:58Z] stdin (858 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-02T11:51:58Z] コマンド検出: gh api graphql -f 'query={ repository(owner: "aloekun", name: "claude-code-hook-test") { pullRequest(number: 10) { reviewThreads(first: 100) { nodes { isResolved } } } } }' 2>&1 +[2026-04-02T11:51:58Z] トリガー不一致 — スキップ (command=gh api graphql -f 'query={ repository(owner: "aloekun", name: "claude-code-hook-test") { pullRequest(number: 10) { reviewThreads(first: 100) { nodes { isResolved } } } } }' 2>&1) +[2026-04-02T11:53:51Z] hook 起動 +[2026-04-02T11:53:51Z] stdin (963 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-02T11:53:51Z] コマンド検出: cd e:/work/claude-code-hook-test/.claude/check-ci-coderabbit && cargo build --release 2>&1 && cp target/release/check-ci-coderabbit.exe ../check-ci-coderabbit.exe && echo "build done" +[2026-04-02T11:53:51Z] トリガー不一致 — スキップ (command=cd e:/work/claude-code-hook-test/.claude/check-ci-coderabbit && cargo build --release 2>&1 && cp target/release/check-ci-coderabbit.exe ../check-ci-coderabbit.exe && echo "build done") +[2026-04-02T11:55:02Z] hook 起動 +[2026-04-02T11:55:02Z] stdin (813 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-02T11:55:02Z] コマンド検出: "e:/work/claude-code-hook-test/.claude/check-ci-coderabbit.exe" --push-time "2026-04-02T10:00:00Z" --repo "aloekun/claude-code-hook-test" --pr 10 2>&1 +[2026-04-02T11:55:02Z] トリガー不一致 — スキップ (command="e:/work/claude-code-hook-test/.claude/check-ci-coderabbit.exe" --push-time "2026-04-02T10:00:00Z" --repo "aloekun/claude-code-hook-test" --pr 10 2>&1) +[2026-04-02T11:56:15Z] hook 起動 +[2026-04-02T11:56:15Z] stdin (1160 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-02T11:56:15Z] コマンド検出: cat "C:/Users/HIROKI/AppData/Local/Temp/claude/e--work-claude-code-hook-test/9559f06a-430d-481b-ac76-768a33308873/tasks/bkpq14sqp.output" 2>&1 +[2026-04-02T11:56:15Z] トリガー不一致 — スキップ (command=cat "C:/Users/HIROKI/AppData/Local/Temp/claude/e--work-claude-code-hook-test/9559f06a-430d-481b-ac76-768a33308873/tasks/bkpq14sqp.output" 2>&1) +[2026-04-02T11:57:37Z] hook 起動 +[2026-04-02T11:57:37Z] stdin (2629 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-02T11:57:37Z] コマンド検出: cd e:/work/claude-code-hook-test/.claude/check-ci-coderabbit && cargo test 2>&1 +[2026-04-02T11:57:37Z] トリガー不一致 — スキップ (command=cd e:/work/claude-code-hook-test/.claude/check-ci-coderabbit && cargo test 2>&1) +[2026-04-02T11:58:30Z] hook 起動 +[2026-04-02T11:58:30Z] stdin (942 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-02T11:58:30Z] コマンド検出: cd e:/work/claude-code-hook-test/.claude/check-ci-coderabbit && cargo build --release 2>&1 && cp target/release/check-ci-coderabbit.exe ../check-ci-coderabbit.exe && echo "build done" +[2026-04-02T11:58:30Z] トリガー不一致 — スキップ (command=cd e:/work/claude-code-hook-test/.claude/check-ci-coderabbit && cargo build --release 2>&1 && cp target/release/check-ci-coderabbit.exe ../check-ci-coderabbit.exe && echo "build done") +[2026-04-02T11:58:48Z] hook 起動 +[2026-04-02T11:58:48Z] stdin (1107 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-02T11:58:48Z] コマンド検出: "e:/work/claude-code-hook-test/.claude/check-ci-coderabbit.exe" --push-time "2026-04-02T10:00:00Z" --repo "aloekun/claude-code-hook-test" --pr 10 2>&1 +[2026-04-02T11:58:48Z] トリガー不一致 — スキップ (command="e:/work/claude-code-hook-test/.claude/check-ci-coderabbit.exe" --push-time "2026-04-02T10:00:00Z" --repo "aloekun/claude-code-hook-test" --pr 10 2>&1) +[2026-04-02T11:59:04Z] hook 起動 +[2026-04-02T11:59:04Z] stdin (656 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-02T11:59:04Z] コマンド検出: gh api repos/aloekun/claude-code-hook-test/issues/10/comments --jq '.[].user.login' 2>&1 +[2026-04-02T11:59:04Z] トリガー不一致 — スキップ (command=gh api repos/aloekun/claude-code-hook-test/issues/10/comments --jq '.[].user.login' 2>&1) +[2026-04-02T11:59:14Z] hook 起動 +[2026-04-02T11:59:14Z] stdin (938 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-02T11:59:14Z] コマンド検出: gh api repos/aloekun/claude-code-hook-test/issues/10/comments --jq '.[0] | {user: .user.login, created_at: .created_at, body_head: (.body[:100])}' 2>&1 +[2026-04-02T11:59:14Z] トリガー不一致 — スキップ (command=gh api repos/aloekun/claude-code-hook-test/issues/10/comments --jq '.[0] | {user: .user.login, created_at: .created_at, body_head: (.body[:100])}' 2>&1) +[2026-04-02T12:06:06Z] hook 起動 +[2026-04-02T12:06:06Z] stdin (3044 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:06:06Z] コマンド検出: cd e:/work/claude-code-hook-test/.claude/check-ci-coderabbit && cargo test 2>&1 +[2026-04-02T12:06:06Z] トリガー不一致 — スキップ (command=cd e:/work/claude-code-hook-test/.claude/check-ci-coderabbit && cargo test 2>&1) +[2026-04-02T12:06:52Z] hook 起動 +[2026-04-02T12:06:53Z] stdin (947 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:06:53Z] コマンド検出: cd e:/work/claude-code-hook-test/.claude/check-ci-coderabbit && cargo build --release 2>&1 && cp target/release/check-ci-coderabbit.exe ../check-ci-coderabbit.exe && echo "build done" +[2026-04-02T12:06:53Z] トリガー不一致 — スキップ (command=cd e:/work/claude-code-hook-test/.claude/check-ci-coderabbit && cargo build --release 2>&1 && cp target/release/check-ci-coderabbit.exe ../check-ci-coderabbit.exe && echo "build done") +[2026-04-02T12:07:28Z] hook 起動 +[2026-04-02T12:07:28Z] stdin (1108 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:07:28Z] コマンド検出: "e:/work/claude-code-hook-test/.claude/check-ci-coderabbit.exe" --push-time "2026-04-02T09:00:00Z" --repo "aloekun/claude-code-hook-test" --pr 10 2>&1 +[2026-04-02T12:07:28Z] トリガー不一致 — スキップ (command="e:/work/claude-code-hook-test/.claude/check-ci-coderabbit.exe" --push-time "2026-04-02T09:00:00Z" --repo "aloekun/claude-code-hook-test" --pr 10 2>&1) +[2026-04-02T12:18:37Z] hook 起動 +[2026-04-02T12:18:37Z] stdin (74 bytes): {"tool_name":"Bash","tool_input":{"command":"gh pr create --title test"}} + +[2026-04-02T12:18:37Z] コマンド検出: gh pr create --title test +[2026-04-02T12:18:37Z] トリガーマッチ! type=gh pr create +[2026-04-02T12:18:38Z] hook 起動 +[2026-04-02T12:18:38Z] stdin (2523 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:18:38Z] コマンド検出: cd e:/work/claude-code-hook-test && echo '{"tool_name":"Bash","tool_input":{"command":"gh pr create --title test"}}' | .claude/hooks-post-pr-monitor.exe 2>&1 +[2026-04-02T12:18:38Z] トリガー不一致 — スキップ (command=cd e:/work/claude-code-hook-test && echo '{"tool_name":"Bash","tool_input":{"command":"gh pr create --title test"}}' | .claude/hooks-post-pr-monitor.exe 2>&1) +[2026-04-02T12:19:07Z] hook 起動 +[2026-04-02T12:19:07Z] stdin (17118 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:19:07Z] コマンド検出: cat "e:/work/claude-code-hook-test/.claude/post-pr-monitor-debug.log" 2>&1 || echo "ログファイルなし" +[2026-04-02T12:19:07Z] トリガー不一致 — スキップ (command=cat "e:/work/claude-code-hook-test/.claude/post-pr-monitor-debug.log" 2>&1 || echo "ログファイルなし") +[2026-04-02T12:20:44Z] hook 起動 +[2026-04-02T12:20:44Z] stdin (1530 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:20:44Z] コマンド検出: tail -5 "e:/work/claude-code-hook-test/.claude/post-pr-monitor-debug.log" 2>&1 +[2026-04-02T12:20:44Z] トリガー不一致 — スキップ (command=tail -5 "e:/work/claude-code-hook-test/.claude/post-pr-monitor-debug.log" 2>&1) +[2026-04-02T12:22:35Z] hook 起動 +[2026-04-02T12:22:35Z] stdin (654 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:22:35Z] コマンド検出: grep -c "gh pr create" "e:/work/claude-code-hook-test/.claude/post-pr-monitor-debug.log" 2>/dev/null || echo 0 +[2026-04-02T12:22:35Z] トリガー不一致 — スキップ (command=grep -c "gh pr create" "e:/work/claude-code-hook-test/.claude/post-pr-monitor-debug.log" 2>/dev/null || echo 0) +[2026-04-02T12:22:43Z] hook 起動 +[2026-04-02T12:22:43Z] stdin (1697 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:22:43Z] コマンド検出: grep "gh pr create" "e:/work/claude-code-hook-test/.claude/post-pr-monitor-debug.log" 2>&1 +[2026-04-02T12:22:43Z] トリガー不一致 — スキップ (command=grep "gh pr create" "e:/work/claude-code-hook-test/.claude/post-pr-monitor-debug.log" 2>&1) +[2026-04-02T12:24:25Z] hook 起動 +[2026-04-02T12:24:25Z] stdin (4341 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:24:25Z] コマンド検出: cd e:/work/claude-code-hook-test/.claude/hooks-post-pr-monitor && cargo test 2>&1 && cd ../check-ci-coderabbit && cargo test 2>&1 +[2026-04-02T12:24:25Z] トリガー不一致 — スキップ (command=cd e:/work/claude-code-hook-test/.claude/hooks-post-pr-monitor && cargo test 2>&1 && cd ../check-ci-coderabbit && cargo test 2>&1) +[2026-04-02T12:24:38Z] hook 起動 +[2026-04-02T12:24:38Z] stdin (3065 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:24:38Z] コマンド検出: cd e:/work/claude-code-hook-test && pnpm build:hooks 2>&1 +[2026-04-02T12:24:38Z] トリガー不一致 — スキップ (command=cd e:/work/claude-code-hook-test && pnpm build:hooks 2>&1) +[2026-04-02T12:24:54Z] hook 起動 +[2026-04-02T12:24:54Z] stdin (1570 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:24:54Z] コマンド検出: cd e:/work/claude-code-hook-test && jj describe -m "fix(hooks): レビュー指摘 High 7件修正 + デバッグログ付き E2E テスト準備 + +- H1: GraphQL を -F 変数渡しに変更 (インジェクション防止) +- H2: run_gh デッドロック防止 (try_wait → wait_with_output + タイムアウトスレッド) +- H3: repo/head_sha の入力値検証追加 +- H4: serde_json パースエラーの eprintln ログ追加 (4箇所) +- H5: post-pr-monitor stdin/JSON エラーログ追加 +- H6: 無効な正規表現パターンの警告ログ追加 +- H7: auto_detect 失敗理由を summary に反映 +- regex クレートを check-ci-coderabbit に追加 + +Co-Authored-By: Claude Opus 4.6 (1M context) " +[2026-04-02T12:24:54Z] トリガー不一致 — スキップ (command=cd e:/work/claude-code-hook-test && jj describe -m "fix(hooks): レビュー指摘 High 7件修正 + デバッグログ付き E2E テスト準備 + +- H1: GraphQL を -F 変数渡しに変更 (インジェクション防止) +- H2: run_gh デッドロック防止 (try_wait → wait_with_output + タイムアウトスレッド) +- H3: repo/head_sha の入力値検証追加 +- H4: serde_json パースエラーの eprintln ログ追加 (4箇所) +- H5: post-pr-monitor stdin/JSON エラーログ追加 +- H6: 無効な正規表現パターンの警告ログ追加 +- H7: auto_detect 失敗理由を summary に反映 +- regex クレートを check-ci-coderabbit に追加 + +Co-Authored-By: Claude Opus 4.6 (1M context) ") diff --git a/.claude/settings.local.json.template b/.claude/settings.local.json.template index 06b86a73..40e54513 100644 --- a/.claude/settings.local.json.template +++ b/.claude/settings.local.json.template @@ -28,6 +28,16 @@ "timeout": 30 } ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"{{PROJECT_DIR}}\\.claude\\hooks-post-pr-monitor.exe\"", + "timeout": 30 + } + ] } ], "Stop": [ diff --git a/.gitignore b/.gitignore index 960bf72d..b2557d2d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ .claude/hooks-post-tool-linter.exe .claude/hooks-stop-quality.exe .claude/hooks-push-pipeline.exe +.claude/hooks-post-pr-monitor.exe +.claude/check-ci-coderabbit.exe # Deploy targets (contains local paths; create from deploy-targets.template.json) scripts/deploy-targets.json @@ -25,3 +27,5 @@ __pycache__/ .claude/hooks-post-tool-linter/target/ .claude/hooks-stop-quality/target/ .claude/hooks-push-pipeline/target/ +.claude/hooks-post-pr-monitor/target/ +.claude/check-ci-coderabbit/target/ diff --git a/CLAUDE.md b/CLAUDE.md index 94cd3c51..10b4e61f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,7 @@ - [ADR-006: hooks の設定駆動型アーキテクチャ](docs/adr/adr-006-config-driven-hooks.md) - [ADR-007: カスタムリンターの正規表現層/AST層の線引き](docs/adr/adr-007-custom-linter-layer-boundary.md) - [ADR-008: Push Pipeline ハーネスの実装](docs/adr/adr-008-push-pipeline-harness.md) +- [ADR-009: Post-PR Monitor — push/PR作成後の CI・CodeRabbit 自動監視](docs/adr/adr-009-post-pr-monitor.md) ## Build diff --git a/docs/adr/adr-009-post-pr-monitor.md b/docs/adr/adr-009-post-pr-monitor.md new file mode 100644 index 00000000..cfca707c --- /dev/null +++ b/docs/adr/adr-009-post-pr-monitor.md @@ -0,0 +1,129 @@ +# ADR-009: Post-PR Monitor — push/PR作成後の CI・CodeRabbit 自動監視 + +## ステータス + +承認済み (2026-04-01) + +## コンテキスト + +public リポジトリでは PR 作成や push 後に CodeRabbit による自動レビューが行われるが、 +レビュー完了までに 3〜5 分程度かかるため、手動で待機・確認するのは摩擦が大きい。 +同様に CI (GitHub Actions) の成否も push 後にしか確認できない。 + +これらを自動的に監視し、結果を Claude 経由で報告する仕組みが必要。 + +### 参考にした既存実装 + +- shomatan/cc-knowledge の `post-push-monitor.sh` + `check-ci-coderabbit.sh` + - PostToolUse hook (bash) で `gh pr create` / `git push` を検出 + - CronCreate で定期ポーリング、gh API で CI・CodeRabbit 状態を取得 + - 構造化 JSON で判定結果を返す + +ただし、シェルスクリプトは Windows 環境で直接実行できず、テスト可能性も低いため、 +本プロジェクトの方針(ADR-001: Rust 実装)に従い Rust exe で再実装する。 + +### 検討した選択肢 + +1. **シェルスクリプトをそのまま採用し、Git Bash / WSL で実行** + - Windows 環境依存が増える。テスト可能性が低い。 + +2. **Skill 内で Claude が gh コマンドを直接実行** + - テスト不要だが、判定ロジックが AI 依存になり安定性に欠ける。 + +3. **Rust exe × 2 (hook トリガー + スタンドアロン checker) + Skill + CronCreate** + - 判定ロジックが確定的。unit test で網羅可能。Windows ネイティブ。 + - CronCreate で定期ポーリング、Claude はスクリプトの出力を解釈するだけ。 + +## 決定 + +**選択肢 3 を採用する。** + +### アーキテクチャ + +```text +Claude が "gh pr create" / "git push" を実行 + │ + ▼ +PostToolUse hook (hooks-post-pr-monitor.exe) + ├─ コマンド検出 (regex) + ├─ PR番号・リポジトリ情報を gh CLI で取得 + ├─ PUSH_TIME を記録 (UTC ISO 8601) + └─ additionalContext で CronCreate 指示を返す + │ + ▼ +Claude が CronCreate で定期ジョブ作成 (30秒間隔) + │ + ▼ (定期実行) +check-ci-coderabbit.exe --push-time --repo --pr + ├─ CI 状態チェック (gh run list) + ├─ CodeRabbit 状態チェック (gh api .../statuses) + ├─ 新規コメント取得 (gh api .../comments, created_at > push_time) + ├─ レビュー本文クロスチェック (Actionable comments posted: N) + ├─ 未解決スレッド (gh api graphql) + └─ 判定 JSON を stdout 出力 + │ + ▼ +Claude が action フィールドに従い行動 +``` + +### コンポーネント + +| コンポーネント | 種別 | 役割 | +|---|---|---| +| `hooks-post-pr-monitor.exe` | PostToolUse hook (Rust) | コマンド検出 → CronCreate 指示 | +| `check-ci-coderabbit.exe` | スタンドアロン CLI (Rust) | CI・CodeRabbit 状態チェック → JSON 出力 | +| `post-pr-create-review-check` SKILL.md | Claude Skill | 監視結果の解釈・報告手順 | +| `hooks-config.toml [post_pr_monitor]` | 設定 | ポーリング間隔・監視対象の設定 | + +### 判定ロジック + +| CI 状態 | CodeRabbit 状態 | 指摘/未解決 | action | +|---------|----------------|------------|--------| +| pending | * | * | `continue_monitoring` | +| * | pending/not_found | * | `continue_monitoring` | +| failure | * | * | `stop_monitoring_failure` | +| * | failure/error | * | `stop_monitoring_failure` | +| success | success | あり | `action_required` | +| success | success | なし | `stop_monitoring_success` | + +### 新規指摘の判定 + +**`created_at > PUSH_TIME` でフィルタ。`commit_id == HEAD` は使わない。** + +理由: fix コミット push で HEAD が変わるが、CodeRabbit コメントは前コミットに紐づいたまま。 +`commit_id` フィルタでは前コミットの指摘が全て見落とされる。 + +### 設定 (hooks-config.toml) + +```toml +[post_pr_monitor] +enabled = true +poll_interval_secs = 30 +max_duration_secs = 600 +check_ci = true +check_coderabbit = true +``` + +## 影響 + +### Positive + +- push/PR 作成後の CI・CodeRabbit 確認を自動化し、開発フローの摩擦を削減 +- 判定ロジックが Rust の純粋関数に分離されており、unit test で網羅可能 +- 既存のビルド・配布フロー (`pnpm build:hooks`, `pnpm deploy:hooks`) にそのまま乗る +- `hooks-config.toml` でプロジェクトごとにポーリング間隔・監視対象をカスタマイズ可能 + +### Negative + +- PostToolUse hook が全 Bash コマンドで発火するため、非対象コマンドでの性能影響に注意が必要 + (stdin パース + regex チェックのみで即座に exit するよう設計で対処) +- CronCreate は Claude のセッション内機能であり、セッション終了時にジョブも消える +- `check-ci-coderabbit` は gh CLI に依存するため、gh 未インストール環境では動作しない + +## 参考 + +- ADR-001 — hooks の実装言語として Rust を採用 +- ADR-003 — hooks の配置規則とビルド戦略 +- ADR-006 — hooks の設定駆動型アーキテクチャ +- ADR-008 — Push Pipeline ハーネスの実装 +- [shomatan/cc-knowledge](https://github.com/shomatan/cc-knowledge) — 参考実装 (shell script) diff --git a/package.json b/package.json index 2d009127..fa8dcac3 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,10 @@ "build:hooks-post-tool-linter": "cd .claude/hooks-post-tool-linter && cargo build --release && cp target/release/hooks-post-tool-linter.exe ../hooks-post-tool-linter.exe", "build:hooks-stop-quality": "cd .claude/hooks-stop-quality && cargo build --release && cp target/release/hooks-stop-quality.exe ../hooks-stop-quality.exe", "build:hooks-push-pipeline": "cd .claude/hooks-push-pipeline && cargo build --release && cp target/release/hooks-push-pipeline.exe ../hooks-push-pipeline.exe", + "build:hooks-post-pr-monitor": "cd .claude/hooks-post-pr-monitor && cargo build --release && cp target/release/hooks-post-pr-monitor.exe ../hooks-post-pr-monitor.exe", + "build:check-ci-coderabbit": "cd .claude/check-ci-coderabbit && cargo build --release && cp target/release/check-ci-coderabbit.exe ../check-ci-coderabbit.exe", "build:hooks-settings": "node -e \"const fs=require('fs');const t=fs.readFileSync('.claude/settings.local.json.template','utf8');const p=process.cwd().replace(/\\\\/g,'\\\\\\\\');fs.writeFileSync('.claude/settings.local.json',t.replace(/\\{\\{PROJECT_DIR\\}\\}/g,p))\" && echo settings.local.json generated", - "build:hooks": "pnpm build:hooks-pre-tool-validate && pnpm build:hooks-post-tool-linter && pnpm build:hooks-stop-quality && pnpm build:hooks-push-pipeline && pnpm build:hooks-settings", + "build:hooks": "pnpm build:hooks-pre-tool-validate && pnpm build:hooks-post-tool-linter && pnpm build:hooks-stop-quality && pnpm build:hooks-push-pipeline && pnpm build:hooks-post-pr-monitor && pnpm build:check-ci-coderabbit && pnpm build:hooks-settings", "review:ai": "claude -p \"/pre-push-review\"", "push": ".\\.claude\\hooks-push-pipeline.exe", "deploy:hooks": "npx tsx scripts/deploy-hooks.ts", diff --git a/scripts/deploy-hooks.ts b/scripts/deploy-hooks.ts index 72411991..fbd1f442 100644 --- a/scripts/deploy-hooks.ts +++ b/scripts/deploy-hooks.ts @@ -24,6 +24,8 @@ const EXE_FILES = [ "hooks-post-tool-linter.exe", "hooks-stop-quality.exe", "hooks-push-pipeline.exe", + "hooks-post-pr-monitor.exe", + "check-ci-coderabbit.exe", ]; const SETTINGS_TEMPLATE = "settings.local.json.template"; diff --git a/templates/hooks-config-python.toml b/templates/hooks-config-python.toml index 4eed26d0..f029a1a0 100644 --- a/templates/hooks-config-python.toml +++ b/templates/hooks-config-python.toml @@ -38,3 +38,14 @@ cmd = "pnpm py-test" [[stop_quality.steps]] name = "py-test:e2e" cmd = "pnpm py-test:e2e" + +# ─── PostToolUse: PR モニター ─── +# gh pr create / git push 検出後に CI + CodeRabbit を自動監視する。 +# public リポジトリで CodeRabbit が有効な場合に設定してください。 + +# [post_pr_monitor] +# enabled = true +# poll_interval_secs = 30 +# 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 c3197a88..86bc4e86 100644 --- a/templates/hooks-config-typescript.toml +++ b/templates/hooks-config-typescript.toml @@ -42,3 +42,14 @@ cmd = "pnpm typecheck" [[stop_quality.steps]] name = "build" cmd = "pnpm build" + +# ─── PostToolUse: PR モニター ─── +# gh pr create / git push 検出後に CI + CodeRabbit を自動監視する。 +# public リポジトリで CodeRabbit が有効な場合に設定してください。 + +# [post_pr_monitor] +# enabled = true +# poll_interval_secs = 30 +# max_duration_secs = 600 +# check_ci = true +# check_coderabbit = true