From dd039684f07159ef502b0046382e9f2533702094 Mon Sep 17 00:00:00 2001 From: aloekun Date: Wed, 15 Apr 2026 16:33:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(cli-pr-monitor):=20takt=20=E3=83=99?= =?UTF-8?q?=E3=83=BC=E3=82=B9=E3=81=AE=20sequential=20chain=20=E3=81=AB?= =?UTF-8?q?=E7=A7=BB=E8=A1=8C=20(Phase=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit daemon spawn + CronCreate を廃止し、in-process ポーリング + takt 分析に移行: - Stage 1: poll_loop (Rust, in-process) - CI/CodeRabbit を同期ポーリング - Stage 2: collect_findings - .takt/review-comments.json に書き出し - Stage 3: run_takt - post-pr-review ワークフローで深刻度別分析 - Stage 4: print_report - stdout にレポート出力 ADR-015 の「機械的ステップは Rust、AI ステップは takt」原則を適用。 takt はオプショナル (pr-monitor-config.toml に [takt] セクションがなければスキップ)。 ADR-018 で決定を記録。ADR-009 の daemon + CronCreate 部分を Supersede。 --- .../facets/instructions/analyze-coderabbit.md | 62 +++++ .takt/workflows/post-pr-review.yaml | 42 ++++ CLAUDE.md | 1 + docs/adr/adr-018-pr-monitor-takt-migration.md | 104 ++++++++ package.json | 2 - pr-monitor-config.toml | 17 ++ scripts/deploy-hooks.ts | 6 + src/cli-pr-monitor/src/config.rs | 175 +++++++++---- src/cli-pr-monitor/src/main.rs | 29 +-- src/cli-pr-monitor/src/runner.rs | 46 ++++ src/cli-pr-monitor/src/stages/collect.rs | 57 +++++ src/cli-pr-monitor/src/stages/create_pr.rs | 7 +- src/cli-pr-monitor/src/stages/daemon.rs | 186 -------------- src/cli-pr-monitor/src/stages/mod.rs | 5 +- src/cli-pr-monitor/src/stages/monitor.rs | 170 ++++++------- src/cli-pr-monitor/src/stages/poll.rs | 230 ++++++++++++++++++ src/cli-pr-monitor/src/stages/takt.rs | 26 ++ src/cli-pr-monitor/src/util.rs | 14 +- src/cli-push-runner/src/stages/diff.rs | 32 +++ templates/pr-monitor-config.toml | 19 ++ 20 files changed, 889 insertions(+), 341 deletions(-) create mode 100644 .takt/facets/instructions/analyze-coderabbit.md create mode 100644 .takt/workflows/post-pr-review.yaml create mode 100644 docs/adr/adr-018-pr-monitor-takt-migration.md create mode 100644 pr-monitor-config.toml create mode 100644 src/cli-pr-monitor/src/stages/collect.rs delete mode 100644 src/cli-pr-monitor/src/stages/daemon.rs create mode 100644 src/cli-pr-monitor/src/stages/poll.rs create mode 100644 src/cli-pr-monitor/src/stages/takt.rs create mode 100644 templates/pr-monitor-config.toml diff --git a/.takt/facets/instructions/analyze-coderabbit.md b/.takt/facets/instructions/analyze-coderabbit.md new file mode 100644 index 00000000..2f32eed5 --- /dev/null +++ b/.takt/facets/instructions/analyze-coderabbit.md @@ -0,0 +1,62 @@ +# CodeRabbit Review Analysis + +## Input + +Read `.takt/review-comments.json`. This file contains the output from `check-ci-coderabbit.exe`, including: +- `ci`: GitHub Actions CI status (overall + per-run results) +- `coderabbit`: CodeRabbit review state (review_state, new_comments, actionable_comments, unresolved_threads) +- `findings`: Array of structured findings (severity, file, line, issue, suggestion, source) +- `action`: Terminal action from the monitor ("action_required", "stop_monitoring_success", etc.) +- `summary`: Human-readable summary + +## Task + +1. Read the JSON file with the Read tool +2. Parse the `findings` array and `coderabbit` state +3. Classify each finding by severity: Critical > High > Major > Medium > Minor > Low > Info +4. Group findings by file path +5. For each Critical/High/Major finding, provide: + - Root cause analysis (why this is a problem) + - Recommended fix approach + - Impact if not addressed + +## Output Format + +Produce a structured Markdown report: + +```markdown +## CodeRabbit Analysis Report + +### Summary +- CI: [status] +- CodeRabbit: [N] findings ([X] critical/high, [Y] medium, [Z] low) +- Verdict: PASS / FAIL + +### Findings by Severity + +#### Critical / High +| # | File (Line) | Issue | Recommended Action | +|---|-------------|-------|--------------------| +| 1 | path:line | ... | ... | + +#### Medium +... + +#### Low / Info +... + +### Recommended Actions +1. [Prioritized action items for critical/high findings] +``` + +## Verdict Rules + +- **FAIL**: Any Critical or High or Major severity finding exists +- **PASS**: Only Medium or lower severity findings (or no findings) + +## Important + +- Do NOT modify any code. This is analysis only. +- Do NOT fabricate findings. Report only what is in the JSON. +- If the findings array is empty, report "No actionable findings" with verdict PASS. +- If the JSON file is missing or empty, report the error and exit. diff --git a/.takt/workflows/post-pr-review.yaml b/.takt/workflows/post-pr-review.yaml new file mode 100644 index 00000000..0fdbde1b --- /dev/null +++ b/.takt/workflows/post-pr-review.yaml @@ -0,0 +1,42 @@ +name: post-pr-review +description: > + CodeRabbit レビューコメントを分析し、深刻度別レポートを出力する。 + 入力: .takt/review-comments.json (Rust exe が書き出し) + Phase 1: 分析のみ。Phase 2 で fix loop を追加予定。 + +workflow_config: + provider_options: + codex: + network_access: true + opencode: + network_access: true + +max_steps: 5 +initial_step: analyze + +steps: + # --------------------------------------------------------------------------- + # Step 1: analyze + # CodeRabbit の指摘を深刻度別に分類し、構造化レポートを出力する。 + # Phase 2 で fix / re-review ステップを追加予定。 + # --------------------------------------------------------------------------- + - name: analyze + edit: false + persona: code-reviewer + policy: review + knowledge: architecture + provider_options: + claude: + allowed_tools: + - Read + - Glob + - Grep + - Bash + instruction: analyze-coderabbit + output_contracts: + report: + - name: coderabbit-analysis.md + format: coderabbit-analysis + rules: + - condition: Analysis complete + next: COMPLETE diff --git a/CLAUDE.md b/CLAUDE.md index 516965f6..6b2bc0fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,7 @@ - [ADR-015: Push Pipeline を takt ベースの push-runner に移行](docs/adr/adr-015-push-runner-takt-migration.md) *(Supersedes ADR-008 の push 前パイプライン部分)* - [ADR-016: Claude Code Bash ツールでの長時間コマンド実行戦略](docs/adr/adr-016-long-running-command-strategy.md) - [ADR-017: takt バージョン固定と検証環境の維持](docs/adr/adr-017-takt-version-pinning.md) +- [ADR-018: cli-pr-monitor の takt ベース移行と CronCreate 廃止](docs/adr/adr-018-pr-monitor-takt-migration.md) *(Supersedes ADR-009 の daemon + CronCreate 部分)* ## Build diff --git a/docs/adr/adr-018-pr-monitor-takt-migration.md b/docs/adr/adr-018-pr-monitor-takt-migration.md new file mode 100644 index 00000000..79ab4134 --- /dev/null +++ b/docs/adr/adr-018-pr-monitor-takt-migration.md @@ -0,0 +1,104 @@ +# ADR-018: cli-pr-monitor の takt ベース移行と CronCreate 廃止 + +## ステータス + +承認済み (2026-04-15) + +Supersedes: ADR-009 (Post-PR Monitor) の daemon + CronCreate アーキテクチャ部分 + +## コンテキスト + +### 問題 + +ADR-009 で導入した cli-pr-monitor は daemon spawn + CronCreate による「お願いベース」の通知フローを採用していた: + +1. **4 段階の間接連携**: daemon → state file → CronCreate → Claude 読み取り → スキル発動。各段階で失敗しうる +2. **CronCreate の信頼性**: Claude Code がセッション状態によっては CronCreate を正しく実行しない場合がある +3. **AI 分析の欠如**: CodeRabbit 指摘は state file に生の findings として保存されるが、深刻度分析や対応方針の提示は Claude の「お願いベース」 +4. **ADR-015 との不整合**: push-runner は takt ベースに移行済みだが、pr-monitor は旧アーキテクチャのまま + +### ADR-015 の成功パターン + +push-runner で確立された「機械的ステップは Rust、AI ステップは takt」の分離原則が有効であることが実証されている。同じパターンを pr-monitor にも適用する。 + +## 決定 + +### daemon + CronCreate を廃止し、in-process sequential chain + takt に移行する + +**パイプライン構成:** + +```text +cli-pr-monitor.exe --monitor-only + | + +-- Stage 1: poll_loop (Rust, in-process, blocking) + | check-ci-coderabbit.exe を 2分間隔で実行 + | 最大 10分タイムアウト + | state file を毎回更新 (debug/observability 用) + | + +-- Stage 2: collect_findings (Rust) + | action_required or findings ありの場合: + | .takt/review-comments.json に書き出し + | + +-- Stage 3: run_takt (takt, optional) + | pnpm exec takt -w post-pr-review -t "analyze PR review" + | review-comments.json を読み、深刻度別レポートを stdout 出力 + | + +-- Stage 4: print_report (stdout) +``` + +### 設計原則 + +1. **機械的ステップは Rust**: ポーリング、state 管理、JSON 書き出しは Rust exe 内で直接実行 +2. **AI ステップは takt**: CodeRabbit 指摘の分析・深刻度分類は takt ワークフローで実行 +3. **takt はオプショナル**: `pr-monitor-config.toml` に `[takt]` セクションがなければポーリング結果のみ報告 +4. **CronCreate 不要**: in-process blocking で完了まで待ち、Bash tool の `run_in_background` で完了通知 + +### 設定ファイルの分離 + +`hooks-config.toml` の `[post_pr_monitor]` セクションから `pr-monitor-config.toml` に移行: + +```toml +[monitor] +enabled = true +poll_interval_secs = 120 +max_duration_secs = 600 +check_ci = true +check_coderabbit = true + +[takt] +workflow = "post-pr-review" +task = "analyze PR review comments" +extra_args = ["--pipeline", "--skip-git"] +``` + +## 影響 + +### 廃止 + +- `--daemon` フラグ: バックグラウンド daemon モードを削除 +- `stages/daemon.rs`: spawn_daemon + run_daemon を削除 +- CronCreate 指示の stdout 出力: print_cron_instruction を削除 +- `pnpm mark-notified` / `pnpm check-monitor` スクリプト: 不要に +- `hooks-config.toml` の `[post_pr_monitor]` セクション: `pr-monitor-config.toml` に移行 + +### 維持 + +- `--monitor-only` フラグ: `pnpm push` チェーンからの呼び出し +- `--mark-notified` フラグ: 後方互換性のため残す(state file は debug 用に残る) +- `check-ci-coderabbit.exe`: ポーリングで使用 +- `lib-report-formatter`: Finding 構造体を継続使用 +- state file (`pr-monitor-state.json`): debug/observability 用に維持 + +### 新規追加 + +- `stages/poll.rs`: in-process 同期ポーリングループ +- `stages/collect.rs`: .takt/review-comments.json 書き出し +- `stages/takt.rs`: takt ワークフロー呼び出し +- `pr-monitor-config.toml`: 専用設定ファイル +- `.takt/workflows/post-pr-review.yaml`: takt ワークフロー (Phase 1: 分析のみ) +- `.takt/facets/instructions/analyze-coderabbit.md`: 分析用 instruction + +## 次ステップ (スコープ外) + +- **Phase 2: fix loop + re-push**: takt ワークフローに fix ステップを追加し、CodeRabbit 指摘の自動修正 + re-push まで一気通貫で処理 +- **push-runner との共通化**: fix loop / report ロジックの共通 takt instruction 化 diff --git a/package.json b/package.json index d3fedb9b..2a97a24e 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,6 @@ "create-pr": ".\\.claude\\cli-pr-monitor.exe", "merge-pr": ".\\.claude\\cli-merge-pipeline.exe", "check-ci": ".\\.claude\\check-ci-coderabbit.exe", - "mark-notified": ".\\.claude\\cli-pr-monitor.exe --mark-notified", - "check-monitor": "type .\\.claude\\pr-monitor-state.json 2>NUL || echo No active monitor", "deploy:hooks": "npx tsx scripts/deploy-hooks.ts", "py-lint": "ruff check src/", "py-test": "python -m pytest tests/", diff --git a/pr-monitor-config.toml b/pr-monitor-config.toml new file mode 100644 index 00000000..63e547cb --- /dev/null +++ b/pr-monitor-config.toml @@ -0,0 +1,17 @@ +# pr-monitor-config.toml -- cli-pr-monitor (takt ベース) の設定 +# +# pnpm create-pr / pnpm push (--monitor-only) で起動される +# cli-pr-monitor.exe がこのファイルを読み込む。 +# カレントディレクトリ (リポジトリルート) を優先的に検索する。 + +[monitor] +enabled = true +poll_interval_secs = 120 +max_duration_secs = 600 +check_ci = true +check_coderabbit = true + +[takt] +workflow = "post-pr-review" +task = "analyze PR review comments" +extra_args = ["--pipeline", "--skip-git"] diff --git a/scripts/deploy-hooks.ts b/scripts/deploy-hooks.ts index b5526ed5..48d2eed0 100644 --- a/scripts/deploy-hooks.ts +++ b/scripts/deploy-hooks.ts @@ -108,6 +108,12 @@ function deployTo(targetDir: string): boolean { "See templates/push-runner-config.toml for a starting point" ); + notifyIfMissing( + join(targetDir, "pr-monitor-config.toml"), + "pr-monitor-config.toml not found — takt pr-monitor requires this at repo root", + "See templates/pr-monitor-config.toml for a starting point" + ); + const templateSrc = join(CLAUDE_DIR, SETTINGS_TEMPLATE); if (existsSync(templateSrc)) { const template = readFileSync(templateSrc, "utf8"); diff --git a/src/cli-pr-monitor/src/config.rs b/src/cli-pr-monitor/src/config.rs index 5df04a67..b888eea9 100644 --- a/src/cli-pr-monitor/src/config.rs +++ b/src/cli-pr-monitor/src/config.rs @@ -1,6 +1,8 @@ use serde::Deserialize; use std::path::{Path, PathBuf}; +use crate::log::log_info; + pub(crate) const DEFAULT_POLL_INTERVAL: u64 = 120; pub(crate) const DEFAULT_MAX_DURATION: u64 = 600; pub(crate) const DEFAULT_STEP_TIMEOUT_SECS: u64 = 300; @@ -8,51 +10,112 @@ pub(crate) const DEFAULT_CHECK_TIMEOUT_SECS: u64 = 60; #[derive(Deserialize, Default)] pub(crate) struct Config { - pub(crate) post_pr_monitor: Option, + #[serde(default)] + pub(crate) monitor: MonitorConfig, + pub(crate) takt: Option, } #[derive(Deserialize, Clone)] -pub(crate) struct PostPrMonitorConfig { - pub(crate) enabled: Option, - pub(crate) poll_interval_secs: Option, - pub(crate) max_duration_secs: Option, - pub(crate) check_ci: Option, - pub(crate) check_coderabbit: Option, +pub(crate) struct MonitorConfig { + #[serde(default = "default_enabled")] + pub(crate) enabled: bool, + #[serde(default = "default_poll_interval")] + pub(crate) poll_interval_secs: u64, + #[serde(default = "default_max_duration")] + pub(crate) max_duration_secs: u64, + #[serde(default = "default_check_ci")] + pub(crate) check_ci: bool, + #[serde(default = "default_check_coderabbit")] + pub(crate) check_coderabbit: bool, } -impl Default for PostPrMonitorConfig { +fn default_enabled() -> bool { + true +} +fn default_poll_interval() -> u64 { + DEFAULT_POLL_INTERVAL +} +fn default_max_duration() -> u64 { + DEFAULT_MAX_DURATION +} +fn default_check_ci() -> bool { + true +} +fn default_check_coderabbit() -> bool { + true +} + +impl Default for MonitorConfig { 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), + enabled: default_enabled(), + poll_interval_secs: default_poll_interval(), + max_duration_secs: default_max_duration(), + check_ci: default_check_ci(), + check_coderabbit: default_check_coderabbit(), } } } -pub(crate) fn config_path() -> PathBuf { - std::env::current_exe() +#[derive(Deserialize, Clone)] +pub(crate) struct TaktConfig { + pub(crate) workflow: String, + pub(crate) task: String, + pub(crate) extra_args: Option>, +} + +fn config_path() -> PathBuf { + let filename = "pr-monitor-config.toml"; + + // 1. CWD を優先 (pnpm scripts はリポジトリルートで実行される) + let cwd_path = Path::new(filename).to_path_buf(); + if cwd_path.exists() { + return cwd_path; + } + + // 2. exe が .claude/ 配下にある場合は repo ルートも見る + let exe_dir = std::env::current_exe() .unwrap_or_default() .parent() .unwrap_or(Path::new(".")) - .join("hooks-config.toml") + .to_path_buf(); + + if exe_dir.file_name().and_then(|n| n.to_str()) == Some(".claude") { + let repo_root_candidate = exe_dir.parent().unwrap_or(Path::new(".")).join(filename); + if repo_root_candidate.exists() { + return repo_root_candidate; + } + } + + exe_dir.join(filename) } pub(crate) fn load_config() -> Config { let path = config_path(); let content = match std::fs::read_to_string(&path) { Ok(c) => c, - Err(_) => return Config::default(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + log_info("pr-monitor-config.toml が見つかりません (デフォルト使用)"); + return Config::default(); + } + Err(e) => { + log_info(&format!( + "pr-monitor-config.toml 読み込み失敗 (デフォルト使用): {}", + e + )); + return Config::default(); + } }; - toml::from_str(&content).unwrap_or_else(|e| { - eprintln!( - "[post-pr-monitor] hooks-config.toml パースエラー (デフォルト使用): {}", - e - ); - Config::default() - }) + match toml::from_str(&content) { + Ok(config) => config, + Err(e) => { + log_info(&format!( + "pr-monitor-config.toml パースエラー (デフォルト使用): {}", + e + )); + Config::default() + } + } } #[cfg(test)] @@ -60,48 +123,74 @@ mod tests { use super::*; #[test] - fn config_parses_post_pr_monitor() { + fn config_parses_full() { let toml_str = r#" -[post_pr_monitor] +[monitor] enabled = true poll_interval_secs = 45 max_duration_secs = 900 check_ci = true check_coderabbit = false + +[takt] +workflow = "post-pr-review" +task = "analyze PR review comments" +extra_args = ["--pipeline", "--skip-git"] "#; 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!(config.monitor.enabled, true); + assert_eq!(config.monitor.poll_interval_secs, 45); + assert_eq!(config.monitor.max_duration_secs, 900); + assert_eq!(config.monitor.check_ci, true); + assert_eq!(config.monitor.check_coderabbit, false); + + let takt = config.takt.unwrap(); + assert_eq!(takt.workflow, "post-pr-review"); + assert_eq!(takt.task, "analyze PR review comments"); + assert_eq!(takt.extra_args.as_ref().unwrap().len(), 2); } #[test] - fn config_defaults_when_empty() { - let toml_str = "[post_pr_monitor]\n"; + fn config_monitor_only_no_takt() { + let toml_str = r#" +[monitor] +enabled = true +"#; 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); + assert_eq!(config.monitor.enabled, true); + assert!(config.takt.is_none()); } #[test] - fn config_missing_section() { - let toml_str = "[stop_quality]\nstep_timeout = 60\n"; + fn config_defaults_when_empty_monitor() { + let toml_str = "[monitor]\n"; let config: Config = toml::from_str(toml_str).unwrap(); - assert!(config.post_pr_monitor.is_none()); + // serde(default) により空の [monitor] でも MonitorConfig::default() と同じ値 + assert_eq!(config.monitor.enabled, true); + assert_eq!(config.monitor.poll_interval_secs, DEFAULT_POLL_INTERVAL); } #[test] fn disabled_config() { let toml_str = r#" -[post_pr_monitor] +[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)); + assert_eq!(config.monitor.enabled, false); + } + + #[test] + fn config_takt_extra_args_optional() { + let toml_str = r#" +[monitor] + +[takt] +workflow = "w" +task = "t" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + let takt = config.takt.unwrap(); + assert!(takt.extra_args.is_none()); } } diff --git a/src/cli-pr-monitor/src/main.rs b/src/cli-pr-monitor/src/main.rs index ef7d081e..ae355089 100644 --- a/src/cli-pr-monitor/src/main.rs +++ b/src/cli-pr-monitor/src/main.rs @@ -1,18 +1,16 @@ -//! Post-PR Monitor (スタンドアロン exe) +//! Post-PR Monitor //! -//! PR 作成と監視を一貫して行うスタンドアロン CLI。 -//! push-pipeline と同じ「ガード + 専用コマンド」パターンで動作する。 +//! PR 作成と CI/CodeRabbit 監視を一貫して行うスタンドアロン CLI。 +//! ポーリング完了後、pr-monitor-config.toml に [takt] セクションがあれば +//! takt ワークフローで CodeRabbit 指摘を分析する (任意)。 //! //! モード: -//! デフォルト (PR 作成): gh pr create を実行 → daemon 起動 → CronCreate 指示を stdout 出力 +//! デフォルト (PR 作成): gh pr create → in-process ポーリング → (任意) takt 分析 //! pnpm create-pr -- --title "..." --body "..." //! -//! --monitor-only: PR が存在すれば daemon 起動、なければ exit 0 +//! --monitor-only: PR が存在すれば in-process ポーリング → (任意) takt 分析 //! pnpm push 完了後にチェインで呼ばれる //! -//! --daemon: バックグラウンドで check-ci-coderabbit.exe をポーリングし state file を更新 -//! PR Create / Monitor-Only から自動スポーンされる -//! //! --mark-notified: state file の notified フラグを true にする //! Claude が結果を処理した後に呼ばれる //! @@ -27,24 +25,11 @@ mod stages; mod state; mod util; -use std::path::PathBuf; - -use stages::{run_create_pr, run_daemon, run_mark_notified, run_monitor_only}; -use state::state_file_path; +use stages::{run_create_pr, run_mark_notified, run_monitor_only}; fn main() { let args: Vec = std::env::args().collect(); - if args.iter().any(|a| a == "--daemon") { - let state_file = args - .iter() - .position(|a| a == "--state-file") - .and_then(|i| args.get(i + 1)) - .map(PathBuf::from) - .unwrap_or_else(state_file_path); - std::process::exit(run_daemon(&state_file)); - } - if args.iter().any(|a| a == "--mark-notified") { std::process::exit(run_mark_notified()); } diff --git a/src/cli-pr-monitor/src/runner.rs b/src/cli-pr-monitor/src/runner.rs index 29ce5c3f..4e813fac 100644 --- a/src/cli-pr-monitor/src/runner.rs +++ b/src/cli-pr-monitor/src/runner.rs @@ -106,6 +106,52 @@ pub(crate) fn run_gh_quiet(args: &[&str]) -> Option { } } +/// takt ワークフロー実行のデフォルトタイムアウト (10 分) +const TAKT_TIMEOUT_SECS: u64 = 600; + +/// stdio を継承してコマンドを実行する (takt 呼び出し用、タイムアウト付き) +pub(crate) fn run_cmd_inherit(label: &str, program: &str, args: &[&str]) -> bool { + crate::log::log_info(&format!("{}: {} {}", label, program, args.join(" "))); + let mut child = match Command::new(program) + .args(args) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn() + { + Ok(c) => c, + Err(e) => { + crate::log::log_info(&format!("{} の起動に失敗: {}", label, e)); + return false; + } + }; + + let deadline = std::time::Instant::now() + Duration::from_secs(TAKT_TIMEOUT_SECS); + loop { + match child.try_wait() { + Ok(Some(status)) => return status.success(), + Ok(None) => { + if std::time::Instant::now() >= deadline { + crate::log::log_info(&format!( + "{} タイムアウト ({}秒)", + label, TAKT_TIMEOUT_SECS + )); + let _ = child.kill(); + let _ = child.wait(); + return false; + } + std::thread::sleep(Duration::from_millis(500)); + } + Err(e) => { + crate::log::log_info(&format!("{} の待機に失敗: {}", label, e)); + let _ = child.kill(); + let _ = child.wait(); + return false; + } + } + } +} + pub(crate) fn checker_exe_path() -> PathBuf { std::env::current_exe() .unwrap_or_default() diff --git a/src/cli-pr-monitor/src/stages/collect.rs b/src/cli-pr-monitor/src/stages/collect.rs new file mode 100644 index 00000000..7435cbc4 --- /dev/null +++ b/src/cli-pr-monitor/src/stages/collect.rs @@ -0,0 +1,57 @@ +use std::path::Path; + +use crate::log::log_info; +use crate::stages::poll::PollResult; + +const OUTPUT_PATH: &str = ".takt/review-comments.json"; + +/// PollResult を .takt/review-comments.json に書き出す +/// +/// instruction (analyze-coderabbit.md) が期待するフィールド: +/// action, summary, ci, coderabbit, findings +pub(crate) fn collect_findings(result: &PollResult) -> bool { + // instruction が期待するスキーマに合わせたラッパーを構築 + let wrapper = serde_json::json!({ + "action": result.action, + "summary": result.summary, + "ci": result.ci, + "coderabbit": result.coderabbit, + "findings": result.findings, + "check_output": result.check_output, + }); + + let output_path = Path::new(OUTPUT_PATH); + + // .takt/ ディレクトリが存在しない場合は作成 + if let Some(parent) = output_path.parent() { + if !parent.exists() { + if let Err(e) = std::fs::create_dir_all(parent) { + log_info(&format!("{} ディレクトリ作成失敗: {}", parent.display(), e)); + return false; + } + } + } + + let json = match serde_json::to_string_pretty(&wrapper) { + Ok(j) => j, + Err(e) => { + log_info(&format!("review-comments JSON シリアライズ失敗: {}", e)); + return false; + } + }; + + match std::fs::write(output_path, &json) { + Ok(()) => { + log_info(&format!( + "書き出し完了: {} ({} bytes)", + OUTPUT_PATH, + json.len() + )); + true + } + Err(e) => { + log_info(&format!("{} 書き込み失敗: {}", OUTPUT_PATH, e)); + false + } + } +} diff --git a/src/cli-pr-monitor/src/stages/create_pr.rs b/src/cli-pr-monitor/src/stages/create_pr.rs index 2a39e7a8..7c0ec3e6 100644 --- a/src/cli-pr-monitor/src/stages/create_pr.rs +++ b/src/cli-pr-monitor/src/stages/create_pr.rs @@ -204,13 +204,16 @@ pub(crate) fn run_create_pr(gh_args: &[String]) -> i32 { PrInfo { pr_number: pr_number_from_url, repo, + push_time: Some(push_time.clone()), } } else { log_info("PR URL からの番号取得失敗、gh コマンドで検索"); - get_pr_info() + let mut info = get_pr_info(); + info.push_time = Some(push_time.clone()); + info }; - start_monitoring(&pr_info, &push_time) + start_monitoring(&pr_info) } #[cfg(test)] diff --git a/src/cli-pr-monitor/src/stages/daemon.rs b/src/cli-pr-monitor/src/stages/daemon.rs deleted file mode 100644 index d7342e65..00000000 --- a/src/cli-pr-monitor/src/stages/daemon.rs +++ /dev/null @@ -1,186 +0,0 @@ -use std::path::Path; -use std::process::Command; -use std::time::Duration; - -use crate::config::{ - load_config, DEFAULT_CHECK_TIMEOUT_SECS, DEFAULT_MAX_DURATION, DEFAULT_POLL_INTERVAL, -}; -use crate::log::{log_info, truncate_safe}; -use crate::runner::{checker_exe_path, run_cmd_direct}; -use crate::state::{ - read_state_from, update_state_from_check_result, write_state_to, CiState, CodeRabbitState, -}; -use crate::util::utc_now_iso8601; - -// ─── Daemon スポーン (Windows detached process) ─── - -#[cfg(target_os = "windows")] -pub(crate) fn spawn_daemon(state_file: &Path) -> Result { - use std::os::windows::process::CommandExt; - - const CREATE_NO_WINDOW: u32 = 0x08000000; - const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; - - let exe = std::env::current_exe().map_err(|e| format!("exe パス取得失敗: {}", e))?; - - let child = Command::new(&exe) - .args(["--daemon", "--state-file", &state_file.to_string_lossy()]) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP) - .spawn() - .map_err(|e| format!("daemon スポーン失敗: {}", e))?; - - Ok(child.id()) -} - -#[cfg(not(target_os = "windows"))] -pub(crate) fn spawn_daemon(state_file: &Path) -> Result { - let exe = std::env::current_exe().map_err(|e| format!("exe パス取得失敗: {}", e))?; - - let child = Command::new(&exe) - .args(["--daemon", "--state-file", &state_file.to_string_lossy()]) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - .map_err(|e| format!("daemon スポーン失敗: {}", e))?; - - Ok(child.id()) -} - -// ─── Daemon モード ─── - -pub(crate) fn run_daemon(state_file: &Path) -> i32 { - let config = load_config(); - let monitor_config = config.post_pr_monitor.unwrap_or_default(); - let poll_interval = monitor_config - .poll_interval_secs - .unwrap_or(DEFAULT_POLL_INTERVAL); - let max_duration = monitor_config - .max_duration_secs - .unwrap_or(DEFAULT_MAX_DURATION); - let skip_ci = !monitor_config.check_ci.unwrap_or(true); - let skip_coderabbit = !monitor_config.check_coderabbit.unwrap_or(true); - - let checker = checker_exe_path(); - if !checker.exists() { - log_info(&format!( - "check-ci-coderabbit.exe が見つかりません: {}", - checker.display() - )); - if let Some(mut state) = read_state_from(state_file) { - state.daemon_status = "error".to_string(); - state.summary = "check-ci-coderabbit.exe が見つかりません".to_string(); - let _ = write_state_to(state_file, &state); - } - return 1; - } - - let start = std::time::Instant::now(); - - loop { - // 1. Read current state (state file 削除検出で graceful exit) - let mut state = match read_state_from(state_file) { - Some(s) => s, - None => { - log_info("state file が見つかりません、daemon を終了します"); - return 0; - } - }; - - // 2. Build checker arguments - let mut checker_args: Vec = - vec!["--push-time".to_string(), state.started_at.clone()]; - if let Some(ref repo) = state.repo { - checker_args.push("--repo".to_string()); - checker_args.push(repo.clone()); - } - if let Some(pr) = state.pr { - checker_args.push("--pr".to_string()); - checker_args.push(pr.to_string()); - } - - // 3. Run check-ci-coderabbit.exe - let (success, output) = run_cmd_direct( - &checker.to_string_lossy(), - &[], - &checker_args, - DEFAULT_CHECK_TIMEOUT_SECS, - ); - - // 4. Parse output and update state (checker 失敗時はエラーを state に書き出して停止) - if !success { - state.daemon_status = "error".to_string(); - state.summary = format!( - "check-ci-coderabbit.exe 失敗: {}", - truncate_safe(&output, 200) - ); - state.notified = false; - let _ = write_state_to(state_file, &state); - log_info(&format!("checker 失敗: {}", truncate_safe(&output, 200))); - return 1; - } - - let result = match serde_json::from_str::(&output) { - Ok(r) => r, - Err(e) => { - state.daemon_status = "error".to_string(); - state.summary = format!("checker 出力の JSON パース失敗: {}", e); - state.notified = false; - let _ = write_state_to(state_file, &state); - log_info(&format!("JSON パース失敗: {}", e)); - return 1; - } - }; - update_state_from_check_result(&mut state, &result); - - // check_ci=false / check_coderabbit=false の場合、スキップした側を成功扱い - if skip_ci { - state.ci = Some(CiState { - overall: "skipped".into(), - runs: vec![], - }); - } - if skip_coderabbit { - state.coderabbit = Some(CodeRabbitState { - review_state: "skipped".into(), - new_comments: 0, - actionable_comments: None, - unresolved_threads: None, - }); - // coderabbit スキップ時は action_required を無視して success に - if state.action == "action_required" { - state.action = "stop_monitoring_success".to_string(); - } - } - - state.last_checked = Some(utc_now_iso8601()); - state.notified = false; // 新しいデータを書いたので notified をリセット - - // 5. Check terminal action -> exit - if state.action != "continue_monitoring" { - state.daemon_status = "completed".to_string(); - let _ = write_state_to(state_file, &state); - log_info(&format!( - "監視完了: action={}, summary={}", - state.action, state.summary - )); - return 0; - } - - // 6. Check timeout - if start.elapsed() >= Duration::from_secs(max_duration) { - state.daemon_status = "timed_out".to_string(); - state.summary = format!("監視タイムアウト ({}秒)", max_duration); - let _ = write_state_to(state_file, &state); - log_info(&format!("監視タイムアウト ({}秒)", max_duration)); - return 0; - } - - // 7. Write updated state and sleep - let _ = write_state_to(state_file, &state); - std::thread::sleep(Duration::from_secs(poll_interval)); - } -} diff --git a/src/cli-pr-monitor/src/stages/mod.rs b/src/cli-pr-monitor/src/stages/mod.rs index cd5518b7..5de43797 100644 --- a/src/cli-pr-monitor/src/stages/mod.rs +++ b/src/cli-pr-monitor/src/stages/mod.rs @@ -1,9 +1,10 @@ +pub(crate) mod collect; mod create_pr; -mod daemon; mod mark_notified; mod monitor; +pub(crate) mod poll; +pub(crate) mod takt; pub(crate) use create_pr::run_create_pr; -pub(crate) use daemon::run_daemon; pub(crate) use mark_notified::run_mark_notified; pub(crate) use monitor::run_monitor_only; diff --git a/src/cli-pr-monitor/src/stages/monitor.rs b/src/cli-pr-monitor/src/stages/monitor.rs index b326b134..47b22002 100644 --- a/src/cli-pr-monitor/src/stages/monitor.rs +++ b/src/cli-pr-monitor/src/stages/monitor.rs @@ -1,94 +1,59 @@ -use crate::config::{load_config, PostPrMonitorConfig, DEFAULT_POLL_INTERVAL}; +use crate::config::load_config; use crate::log::log_info; -use crate::stages::daemon::spawn_daemon; -use crate::state::{state_file_path, write_state, PrMonitorState}; +use crate::stages::collect::collect_findings; +use crate::stages::poll::run_poll_loop; +use crate::stages::takt::run_takt; use crate::util::{get_pr_info, utc_now_iso8601, PrInfo}; -// ─── stdout CronCreate 指示 ─── +// ─── 監視開始 (sequential chain) ─── -fn print_cron_instruction(state: &PrMonitorState, config: &PostPrMonitorConfig) { - let pr_label = state - .pr - .map(|n| format!("PR #{}", n)) - .unwrap_or_else(|| "PR".to_string()); - - let interval = config.poll_interval_secs.unwrap_or(DEFAULT_POLL_INTERVAL); - - 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) => "なし", - }; - - println!( - r#" -{pr_label} の {check_scope} 監視 daemon を起動しました (PID: {pid})。 - -【必須】daemon 起動後は確認なしで即座に CronCreate を実行すること。 -CronCreate {{ - cron: "*/{interval_min} * * * *", - prompt: "cat .claude/pr-monitor-state.json を実行し、action フィールドに従って行動:\n- continue_monitoring → 何もせず待つ\n- stop_monitoring_success → CronDelete。成功を報告\n- stop_monitoring_failure → CronDelete。summary を報告\n- action_required → CronDelete。/post-pr-create-review-check で詳細確認\ndaemon_status が completed/timed_out/error なら CronDelete。処理後は pnpm mark-notified を実行。" -}} - -手動確認: cat .claude/pr-monitor-state.json"#, - pr_label = pr_label, - check_scope = check_scope, - pid = state - .daemon_pid - .map(|p| p.to_string()) - .unwrap_or_else(|| "?".to_string()), - interval_min = (interval / 60).max(1), - ); -} - -// ─── 監視開始 (共通ロジック) ─── - -pub(crate) fn start_monitoring(pr_info: &PrInfo, push_time: &str) -> i32 { +pub(crate) fn start_monitoring(pr_info: &PrInfo) -> i32 { let config = load_config(); - let monitor_config = config.post_pr_monitor.unwrap_or_default(); - if !monitor_config.enabled.unwrap_or(true) { + if !config.monitor.enabled { log_info("監視は設定で無効化されています"); return 0; } - let state_path = state_file_path(); - - // 初期 state 作成 -> 先に書き出してから daemon をスポーン - // (daemon は state file がないと即終了するため、書き込みを先に行う) - let mut state = PrMonitorState::new( - pr_info.pr_number, - pr_info.repo.clone(), - push_time.to_string(), - ); - - if let Err(e) = write_state(&state) { - log_info(&format!("初期 state 書き込み失敗: {}", e)); - return 1; - } + let pr_label = pr_info + .pr_number + .map(|n| format!("PR #{}", n)) + .unwrap_or_else(|| "PR".to_string()); - // Daemon スポーン (state file が存在する状態で起動) - match spawn_daemon(&state_path) { - Ok(pid) => { - state.daemon_pid = Some(pid); - log_info(&format!("daemon スポーン完了 (PID: {})", pid)); - } - Err(e) => { - state.daemon_status = "error".to_string(); - state.summary = format!("daemon スポーン失敗: {}", e); - log_info(&format!("daemon スポーン失敗: {}", e)); + log_info(&format!("{} の監視を開始", pr_label)); + + // Stage 1: poll_loop (in-process, blocking) + let poll_result = run_poll_loop(&config.monitor, pr_info); + + log_info(&format!( + "ポーリング完了: action={}, summary={}", + poll_result.action, poll_result.summary + )); + + // Stage 2: collect_findings -> .takt/review-comments.json + // takt 分析は CodeRabbit 起因のシグナルに限定する (CI-only 失敗では起動しない) + let has_coderabbit_findings = !poll_result.findings.is_empty() + || poll_result + .coderabbit + .as_ref() + .map(|c| c.new_comments > 0 || c.unresolved_threads.unwrap_or(0) > 0) + .unwrap_or(false); + + if has_coderabbit_findings { + if !collect_findings(&poll_result) { + log_info("review-comments.json 書き出し失敗 (takt 分析をスキップ)"); + } else if let Some(takt_config) = &config.takt { + // Stage 3: takt analysis + if !run_takt(takt_config) { + log_info("takt 分析失敗 (非致命的: ポーリング結果はそのまま報告)"); + } + } else { + log_info("takt 設定なし: AI 分析をスキップ"); } } - // daemon PID を含む最終 state を書き込み - let _ = write_state(&state); - - // stdout に CronCreate 指示を出力 - print_cron_instruction(&state, &monitor_config); + // Stage 4: report to stdout + print_report(&poll_result, &pr_label); 0 } @@ -97,13 +62,12 @@ pub(crate) fn start_monitoring(pr_info: &PrInfo, push_time: &str) -> i32 { pub(crate) fn run_monitor_only() -> i32 { let config = load_config(); - let monitor_config = config.post_pr_monitor.unwrap_or_default(); - if !monitor_config.enabled.unwrap_or(true) { + if !config.monitor.enabled { return 0; } - let pr_info = get_pr_info(); + let mut pr_info = get_pr_info(); if pr_info.pr_number.is_none() { log_info("PR が存在しないため、監視をスキップします"); @@ -112,6 +76,48 @@ pub(crate) fn run_monitor_only() -> i32 { log_info("監視のみモード (既存 PR 検出)"); - let push_time = utc_now_iso8601(); - start_monitoring(&pr_info, &push_time) + pr_info.push_time = Some(utc_now_iso8601()); + start_monitoring(&pr_info) +} + +// ─── レポート出力 ─── + +fn print_report(result: &crate::stages::poll::PollResult, pr_label: &str) { + let ci_status = result + .ci + .as_ref() + .map(|c| c.overall.as_str()) + .unwrap_or("unknown"); + + let cr_status = result + .coderabbit + .as_ref() + .map(|c| { + format!( + "新規コメント{}件, 未解決スレッド{}件", + c.new_comments, + c.unresolved_threads.unwrap_or(0) + ) + }) + .unwrap_or_else(|| "unknown".into()); + + let findings_count = result.findings.len(); + + println!(); + println!("=== {} 監視完了 ===", pr_label); + println!("CI: {}", ci_status); + println!("CodeRabbit: {}", cr_status); + println!("action: {}", result.action); + println!("summary: {}", result.summary); + + if findings_count > 0 { + println!(); + println!("--- findings ({} 件) ---", findings_count); + for f in &result.findings { + println!( + " [{}] {}:{} - {} ({})", + f.severity, f.file, f.line, f.issue, f.source + ); + } + } } diff --git a/src/cli-pr-monitor/src/stages/poll.rs b/src/cli-pr-monitor/src/stages/poll.rs new file mode 100644 index 00000000..7dc42bd5 --- /dev/null +++ b/src/cli-pr-monitor/src/stages/poll.rs @@ -0,0 +1,230 @@ +use lib_report_formatter::Finding; +use std::time::Duration; + +use crate::config::{MonitorConfig, DEFAULT_CHECK_TIMEOUT_SECS}; +use crate::log::{log_info, truncate_safe}; +use crate::runner::{checker_exe_path, run_cmd_direct}; +use crate::state::{ + update_state_from_check_result, write_state, CiState, CodeRabbitState, PrMonitorState, +}; +use crate::util::{utc_now_iso8601, PrInfo}; + +pub(crate) struct PollResult { + pub(crate) action: String, + pub(crate) summary: String, + pub(crate) ci: Option, + pub(crate) coderabbit: Option, + pub(crate) findings: Vec, + pub(crate) check_output: Option, +} + +/// in-process 同期ポーリングループ (daemon.rs の同期版) +pub(crate) fn run_poll_loop(config: &MonitorConfig, pr_info: &PrInfo) -> PollResult { + let poll_interval = config.poll_interval_secs; + let max_duration = config.max_duration_secs; + let skip_ci = !config.check_ci; + let skip_coderabbit = !config.check_coderabbit; + + let checker = checker_exe_path(); + if !checker.exists() { + log_info(&format!( + "check-ci-coderabbit.exe が見つかりません: {}", + checker.display() + )); + return PollResult { + action: "error".into(), + summary: "check-ci-coderabbit.exe が見つかりません".into(), + ci: None, + coderabbit: None, + findings: Vec::new(), + check_output: None, + }; + } + + let push_time = pr_info + .push_time + .as_deref() + .unwrap_or("1970-01-01T00:00:00Z"); + + let start = std::time::Instant::now(); + + loop { + // Build checker arguments + let mut checker_args: Vec = vec!["--push-time".to_string(), push_time.to_string()]; + if let Some(ref repo) = pr_info.repo { + checker_args.push("--repo".to_string()); + checker_args.push(repo.clone()); + } + if let Some(pr) = pr_info.pr_number { + checker_args.push("--pr".to_string()); + checker_args.push(pr.to_string()); + } + + // Run check-ci-coderabbit.exe + let (success, output) = run_cmd_direct( + &checker.to_string_lossy(), + &[], + &checker_args, + DEFAULT_CHECK_TIMEOUT_SECS, + ); + + if !success { + log_info(&format!("checker 失敗: {}", truncate_safe(&output, 200))); + return PollResult { + action: "error".into(), + summary: format!( + "check-ci-coderabbit.exe 失敗: {}", + truncate_safe(&output, 200) + ), + ci: None, + coderabbit: None, + findings: Vec::new(), + check_output: None, + }; + } + + let result = match serde_json::from_str::(&output) { + Ok(r) => r, + Err(e) => { + log_info(&format!("JSON パース失敗: {}", e)); + return PollResult { + action: "error".into(), + summary: format!("checker 出力の JSON パース失敗: {}", e), + ci: None, + coderabbit: None, + findings: Vec::new(), + check_output: None, + }; + } + }; + + // Update state from check result + let mut state = PrMonitorState::new( + pr_info.pr_number, + pr_info.repo.clone(), + push_time.to_string(), + ); + update_state_from_check_result(&mut state, &result); + + // Skip handling: skipped なチェックを成功扱いにした後、action を再計算する + if skip_ci { + state.ci = Some(CiState { + overall: "skipped".into(), + runs: vec![], + }); + } + if skip_coderabbit { + state.coderabbit = Some(CodeRabbitState { + review_state: "skipped".into(), + new_comments: 0, + actionable_comments: None, + unresolved_threads: None, + }); + state.findings = Vec::new(); + } + if skip_ci || skip_coderabbit { + state.action = recompute_action(&state, skip_ci, skip_coderabbit); + } + + state.last_checked = Some(utc_now_iso8601()); + + // Write state for debug/observability + let _ = write_state(&state); + + log_info(&format!( + "ポーリング: action={}, summary={}", + state.action, state.summary + )); + + // Terminal action -> return result + if state.action != "continue_monitoring" { + return PollResult { + action: state.action, + summary: state.summary, + ci: state.ci, + coderabbit: state.coderabbit, + findings: state.findings, + check_output: Some(result), + }; + } + + // Timeout check + if start.elapsed() >= Duration::from_secs(max_duration) { + log_info(&format!("監視タイムアウト ({}秒)", max_duration)); + return PollResult { + action: "timed_out".into(), + summary: format!("監視タイムアウト ({}秒)", max_duration), + ci: state.ci, + coderabbit: state.coderabbit, + findings: state.findings, + check_output: Some(result), + }; + } + + // Sleep before next poll + std::thread::sleep(Duration::from_secs(poll_interval)); + } +} + +/// skip 適用後に、有効なチェックだけを見て action を再導出する +fn recompute_action(state: &PrMonitorState, skip_ci: bool, skip_coderabbit: bool) -> String { + let ci_ok = skip_ci + || state + .ci + .as_ref() + .map(|c| c.overall == "success" || c.overall == "skipped") + .unwrap_or(false); + + let cr_ok = skip_coderabbit + || state + .coderabbit + .as_ref() + .map(|c| { + c.review_state == "skipped" + || (c.new_comments == 0 && c.unresolved_threads.unwrap_or(0) == 0) + }) + .unwrap_or(false); + + let ci_pending = !skip_ci + && state + .ci + .as_ref() + .map(|c| c.overall == "pending") + .unwrap_or(true); + + let cr_pending = !skip_coderabbit + && state + .coderabbit + .as_ref() + .map(|c| c.review_state == "not_found" || c.review_state == "pending") + .unwrap_or(true); + + if ci_pending || cr_pending { + return "continue_monitoring".into(); + } + + let ci_failed = !skip_ci + && state + .ci + .as_ref() + .map(|c| c.overall == "failure") + .unwrap_or(false); + + let cr_action_required = !skip_coderabbit + && state + .coderabbit + .as_ref() + .map(|c| c.new_comments > 0 || c.unresolved_threads.unwrap_or(0) > 0) + .unwrap_or(false); + + if ci_failed { + "stop_monitoring_failure".into() + } else if cr_action_required { + "action_required".into() + } else if ci_ok && cr_ok { + "stop_monitoring_success".into() + } else { + // Fallback: keep original action + state.action.clone() + } +} diff --git a/src/cli-pr-monitor/src/stages/takt.rs b/src/cli-pr-monitor/src/stages/takt.rs new file mode 100644 index 00000000..43cbf8f7 --- /dev/null +++ b/src/cli-pr-monitor/src/stages/takt.rs @@ -0,0 +1,26 @@ +use crate::config::TaktConfig; +use crate::log::log_info; +use crate::runner::run_cmd_inherit; + +pub(crate) fn run_takt(config: &TaktConfig) -> bool { + log_info(&format!("takt ワークフロー '{}' を起動", config.workflow)); + + let mut args = vec!["exec", "takt", "-w", &config.workflow, "-t", &config.task]; + + let extra: Vec<&str> = config + .extra_args + .as_ref() + .map(|v| v.iter().map(String::as_str).collect()) + .unwrap_or_default(); + args.extend(extra); + + let success = run_cmd_inherit("takt", "pnpm", &args); + + if success { + log_info("takt ワークフロー完了"); + } else { + log_info("takt ワークフロー失敗"); + } + + success +} diff --git a/src/cli-pr-monitor/src/util.rs b/src/cli-pr-monitor/src/util.rs index 1f358065..3f93c5a0 100644 --- a/src/cli-pr-monitor/src/util.rs +++ b/src/cli-pr-monitor/src/util.rs @@ -6,6 +6,7 @@ use crate::runner::run_gh_quiet; pub(crate) struct PrInfo { pub(crate) pr_number: Option, pub(crate) repo: Option, + pub(crate) push_time: Option, } /// PR 情報を取得する(多段フォールバック) @@ -27,7 +28,11 @@ pub(crate) fn get_pr_info() -> PrInfo { .and_then(|s| s.parse::().ok()); if pr_number.is_some() { - return PrInfo { pr_number, repo }; + return PrInfo { + pr_number, + repo, + push_time: None, + }; } // Strategy B: jj bookmark -> gh pr list --head (全ブックマークを順に試す) @@ -47,13 +52,18 @@ pub(crate) fn get_pr_info() -> PrInfo { .and_then(|s| s.parse::().ok()); if pr_number.is_some() { - return PrInfo { pr_number, repo }; + return PrInfo { + pr_number, + repo, + push_time: None, + }; } } PrInfo { pr_number: None, repo, + push_time: None, } } diff --git a/src/cli-push-runner/src/stages/diff.rs b/src/cli-push-runner/src/stages/diff.rs index f3b91c80..c1efc9f3 100644 --- a/src/cli-push-runner/src/stages/diff.rs +++ b/src/cli-push-runner/src/stages/diff.rs @@ -34,6 +34,14 @@ pub(crate) fn run_diff(config: &DiffConfig) -> bool { } }; + if output.is_empty() { + log_stage( + "diff", + "diff 出力が空です。レビュー対象の変更がありません。diff コマンドの revision 指定を確認してください。", + ); + return false; + } + let path = Path::new(&config.output_path); if let Some(parent) = path.parent() { if let Err(e) = std::fs::create_dir_all(parent) { @@ -74,4 +82,28 @@ mod tests { line_count ); } + + #[test] + fn run_diff_returns_false_when_output_is_empty() { + let out_path = std::env::temp_dir().join("test-run-diff-empty.txt"); + // Ensure a clean slate in case a previous run left the file. + let _ = std::fs::remove_file(&out_path); + + let config = DiffConfig { + // `type nul` produces zero bytes on Windows. + command: "type nul".to_string(), + output_path: out_path.to_string_lossy().into_owned(), + }; + + let result = run_diff(&config); + + assert!( + !result, + "run_diff must return false when the diff command produces empty output" + ); + assert!( + !out_path.exists(), + "output file must not be created for an empty diff" + ); + } } diff --git a/templates/pr-monitor-config.toml b/templates/pr-monitor-config.toml new file mode 100644 index 00000000..7cbcb705 --- /dev/null +++ b/templates/pr-monitor-config.toml @@ -0,0 +1,19 @@ +# pr-monitor-config.toml -- cli-pr-monitor (takt ベース) の設定 +# +# pnpm create-pr / pnpm push (--monitor-only) で起動される +# cli-pr-monitor.exe がこのファイルを読み込む。 + +[monitor] +enabled = true +poll_interval_secs = 120 +max_duration_secs = 600 +check_ci = true +check_coderabbit = true + +# takt セクションを有効にすると、ポーリング完了後に +# CodeRabbit 指摘の AI 分析を実行する。 +# takt がインストールされていない場合はコメントアウトすること。 +# [takt] +# workflow = "post-pr-review" +# task = "analyze PR review comments" +# extra_args = ["--pipeline", "--skip-git"]