use anyhow::{Context, Result};
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use crate::github::PendingReview;
use crate::notifications;
use crate::writer;
const INSTRUCTION_FILE: &str = "instruction.md";
pub const PID_FILE: &str = ".prctrl-monitor.pid";
pub fn delegate_to_claude(review: &PendingReview, custom_instruction_path: Option<PathBuf>) -> Result<String> {
let (custom_instructions, source_path) = if let Some(path) = custom_instruction_path {
if path.exists() {
if let Ok(content) = fs::read_to_string(&path) {
(content, path.to_str().map(|s| s.to_string()))
} else {
read_custom_instructions()
}
} else {
read_custom_instructions()
}
} else {
read_custom_instructions()
};
if let Some(path) = source_path {
println!("📖 Using custom instructions from: {}", path);
} else {
println!("📖 Using default review instructions");
}
let prompt = format!(
"You are a senior engineer doing a code review pre-screening.\n\
PR: {title} (#{number}) by {author}\n\
Repo: {repo}\n\
Link: {url}\n\
Size: +{add} / -{del} lines\n\
\n\
CUSTOM REVIEW INSTRUCTIONS:\n {instructions}\n\
Based on this context and the custom instructions above, provide:\n\
1. A 2-sentence summary of what this PR likely does\n\
2. Key things the reviewer should pay attention to (considering the custom instructions)\n\
3. A recommendation: [REVIEW NOW] if urgent/small, [DELEGATE] if large/low priority",
title = review.pr_title,
number = review.pr_number,
author = review.pr_author,
repo = review.repo,
url = review.pr_url,
add = review.additions,
del = review.deletions,
instructions = custom_instructions
);
let output = Command::new("claude")
.args(["--print", "--model", "opus", &prompt])
.env_remove("CLAUDECODE")
.output()
.context("Failed to run `claude` CLI — is it installed?")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("claude CLI failed: {}", stderr.trim());
}
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(text)
}
pub fn is_monitor_running() -> bool {
if let Ok(pid_str) = fs::read_to_string(PID_FILE) {
if let Ok(pid) = pid_str.trim().parse::<u32>() {
#[cfg(unix)]
{
match Command::new("kill").arg("-0").arg(pid.to_string()).status() {
Ok(_) => return true, Err(_) => {
let _ = fs::remove_file(PID_FILE);
return false;
}
}
}
#[cfg(not(unix))]
return true; }
}
false
}
pub fn write_pid_file() -> Result<()> {
let pid = std::process::id();
fs::write(PID_FILE, pid.to_string())?;
Ok(())
}
pub fn remove_pid_file() -> Result<()> {
if PathBuf::from(PID_FILE).exists() {
fs::remove_file(PID_FILE)?;
}
Ok(())
}
pub fn open_in_browser(url: &str) -> Result<()> {
#[cfg(target_os = "macos")]
{
Command::new("open").arg(url).status()?;
}
#[cfg(target_os = "windows")]
{
Command::new("cmd").args(["/c", "start", url]).status()?;
}
#[cfg(target_os = "linux")]
{
Command::new("xdg-open").arg(url).status()?;
}
Ok(())
}
fn read_custom_instructions() -> (String, Option<String>) {
if let Ok(path) = std::env::var("RD_INSTRUCTION_PATH") {
let path_buf = PathBuf::from(&path);
if path_buf.exists() {
if let Ok(content) = fs::read_to_string(&path) {
return (content, Some(path));
}
}
}
let current_path = PathBuf::from(INSTRUCTION_FILE);
if current_path.exists() {
if let Ok(content) = fs::read_to_string(¤t_path) {
if let Some(path) = current_path.to_str() {
return (content, Some(path.to_string()));
}
}
}
if let Some(home_dir) = dirs::home_dir() {
let config_path = home_dir.join(".prctrl").join(INSTRUCTION_FILE);
if config_path.exists() {
if let Ok(content) = fs::read_to_string(&config_path) {
if let Some(path) = config_path.to_str() {
return (content, Some(path.to_string()));
}
}
}
}
(String::from("Follow the standard code review process."), None)
}
pub fn open_in_intellij(path: &PathBuf) -> Result<()> {
#[cfg(target_os = "macos")]
{
let ideas = ["IntelliJ IDEA", "Goland", "CLion", "PhpStorm", "WebStorm", "PyCharm", "RubyMine", "Rider"];
for idea in ideas {
if Command::new("open")
.arg("-a")
.arg(idea)
.arg(path)
.status()
.is_ok()
{
return Ok(());
}
}
Command::new("open").arg(path).status()?;
}
#[cfg(not(target_os = "macos"))]
{
#[cfg(target_os = "windows")]
Command::new("cmd").args(["/c", "start", "", path.to_str().unwrap()]).status()?;
#[cfg(target_os = "linux")]
Command::new("xdg-open").arg(path).status()?;
}
Ok(())
}
pub fn kill_existing_monitor() -> Result<bool> {
if let Ok(pid_str) = fs::read_to_string(PID_FILE) {
if let Ok(pid) = pid_str.trim().parse::<u32>() {
#[cfg(unix)]
{
match Command::new("kill").arg(pid.to_string()).status() {
Ok(_) => {
remove_pid_file()?;
return Ok(true);
}
Err(_) => {
remove_pid_file()?; return Ok(false);
}
}
}
#[cfg(not(unix))]
{
remove_pid_file()?;
return Ok(false);
}
}
}
Ok(false)
}
#[allow(clippy::too_many_arguments)]
pub async fn monitor_new_prs(
token: &str,
org: &str,
repos: &[String],
username: &str,
teams: &[String],
include_mine: bool,
include_drafts: bool,
exclude_prefixes: &[String],
crew_members: &[String],
interval_seconds: u64,
send_notifications: bool,
auto_open_browser: bool,
interactive_mode: bool,
output_dir: Option<PathBuf>,
) -> Result<()> {
if is_monitor_running() {
return Err(anyhow::anyhow!("A monitor process is already running. Use 'prctrl monitor-stop' to stop it first."));
}
write_pid_file()?;
let running = Arc::new(AtomicBool::new(true));
let running_clone = running.clone();
ctrlc::set_handler(move || {
running_clone.store(false, Ordering::SeqCst);
println!("\n🛑 Monitor stopped by user");
let _ = remove_pid_file();
std::process::exit(0);
})?;
println!("\n📋 Monitor Configuration:");
if let Ok(current_dir) = std::env::current_dir() {
println!(" 📁 Working directory: {}", current_dir.display());
}
let (_, instruction_path) = read_custom_instructions();
match instruction_path {
Some(path) => println!(" 📖 Instructions: {}", path),
None => println!(" 📖 Instructions: Using built-in defaults"),
}
println!(" 🔧 Environment:");
println!(" - Interval: {} seconds", interval_seconds);
println!(" - Notifications: {}", if send_notifications { "enabled" } else { "disabled" });
println!(" - Interactive: {}", if interactive_mode { "enabled" } else { "disabled" });
println!(" 🎯 Filters:");
println!(" - Include mine: {}", include_mine);
println!(" - Include drafts: {}", include_drafts);
println!(" - Crew mode: {}", !crew_members.is_empty());
if !exclude_prefixes.is_empty() {
println!(" - Exclude prefixes: {:?}", exclude_prefixes);
} else {
println!(" - Exclude prefixes: none");
}
let mut last_pr_count = 0;
loop {
let pending = crate::github::fetch_pending_reviews(
token,
org,
repos,
username,
teams,
include_mine,
include_drafts,
exclude_prefixes,
crew_members,
)
.await?;
let current_count = pending.len();
if current_count > last_pr_count {
let new_prs: Vec<_> = pending.iter().rev().take(current_count - last_pr_count).collect();
for pr in new_prs {
println!("🔔 New PR detected: #{} - {}", pr.pr_number, pr.pr_title);
if send_notifications {
let notification_title = format!("New PR: #{}", pr.pr_number);
let notification_message = format!("{} by {} in {}", pr.pr_title, pr.pr_author, pr.repo);
if notifications::send_mac_notification(¬ification_title, ¬ification_message, Some(&pr.pr_url), auto_open_browser) {
if auto_open_browser {
println!("✓ macOS notification sent (Chrome will open automatically)");
} else {
println!("✓ macOS notification sent (URL included in message)");
}
} else {
println!("⚠ Failed to send macOS notification");
}
}
if interactive_mode {
println!("\n🎯 Quick Actions for PR #{}:", pr.pr_number);
println!(" [d] Delegate to Claude for review");
println!(" [o] Open PR in browser");
println!(" [i] Open in IntelliJ");
println!(" [s] Skip this PR");
println!(" [q] Quit interactive mode");
print!("\nChoose action: ");
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let choice = input.trim().to_lowercase();
match choice.as_str() {
"d" | "delegate" => {
println!("⏳ Delegating PR #{} to Claude...", pr.pr_number);
match crate::dispatcher::delegate_to_claude(pr, None) {
Ok(summary) => {
println!("✅ Claude review completed:");
println!(" {}", summary.lines().next().unwrap_or("No summary"));
if let Some(ref dir) = output_dir {
let path = writer::write_review(dir, pr, Some(&summary))?;
println!(" 💾 Saved to {}", path.display());
println!(" 🎯 Opening Claude review in IntelliJ...");
open_in_intellij(&path)?;
}
}
Err(e) => println!("❌ Failed to delegate: {}", e),
}
}
"o" | "open" => {
open_in_browser(&pr.pr_url)?;
}
"i" | "intellij" => {
let temp_dir = PathBuf::from("./reviews");
std::fs::create_dir_all(&temp_dir)?;
let temp_path = temp_dir.join(format!("temp-pr-{}.md", pr.pr_number));
writer::write_review(&temp_dir, pr, None)?;
open_in_intellij(&temp_path)?;
}
"s" | "skip" => {
println!("⏭️ Skipping PR #{}", pr.pr_number);
}
"q" | "quit" => {
println!("👋 Exiting interactive mode, continuing monitoring...");
break;
}
_ => {
println!("❓ Unknown choice: {}", choice);
}
}
}
}
}
last_pr_count = current_count;
if !running.load(Ordering::SeqCst) {
println!("🛑 Monitor stopped");
break;
}
println!("Waiting {} seconds before next check...", interval_seconds);
thread::sleep(Duration::from_secs(interval_seconds));
}
let _ = remove_pid_file();
Ok(())
}