use clap::{Parser, Subcommand};
use std::io::{BufRead, IsTerminal, Write as _};
use std::path::PathBuf;
use std::time::Instant;
const EXIT_SUCCESS: i32 = 0;
const EXIT_AUTH: i32 = 1;
const EXIT_NETWORK: i32 = 2;
#[allow(dead_code)]
const EXIT_RATE_LIMIT: i32 = 3;
const EXIT_CONFIG: i32 = 4;
#[derive(Subcommand, Debug)]
enum Commands {
List {
#[arg(long)]
show_snoozed: bool,
},
Open {
index: usize,
},
Snooze {
index: usize,
#[arg(long, value_name = "DURATION")]
r#for: Option<String>,
},
Unsnooze {
index: usize,
},
Init,
}
#[derive(Parser, Debug)]
#[command(name = "pr-bro")]
#[command(about = "GitHub PR review prioritization CLI", long_about = None)]
#[command(version)]
struct Cli {
#[arg(short, long, global = true)]
verbose: bool,
#[arg(short, long, global = true)]
config: Option<String>,
#[arg(long, global = true)]
non_interactive: bool,
#[arg(long, global = true, default_value = "table")]
format: String,
#[arg(long, global = true)]
no_cache: bool,
#[arg(long, global = true)]
clear_cache: bool,
#[arg(long, global = true)]
no_version_check: bool,
#[command(subcommand)]
command: Option<Commands>,
}
#[tokio::main]
async fn main() {
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
let cli = Cli::parse();
let config_path_str = cli.config.clone();
let command = cli.command.unwrap_or(Commands::List {
show_snoozed: false,
});
let start_time = Instant::now();
if cli.clear_cache {
let cache_path = pr_bro::github::get_cache_path();
println!("Clearing cache at: {}", cache_path.display());
match pr_bro::github::clear_cache() {
Ok(()) => {
println!("Cache cleared.");
std::process::exit(EXIT_SUCCESS);
}
Err(e) => {
eprintln!("Failed to clear cache: {}", e);
std::process::exit(EXIT_CONFIG);
}
}
}
let evicted = pr_bro::github::evict_stale_entries();
if cli.verbose && evicted > 0 {
eprintln!(
"Evicted {} stale cache entries (older than 7 days)",
evicted
);
}
if matches!(command, Commands::Init) {
let config_path = config_path_str.map(PathBuf::from);
match pr_bro::config::run_init_wizard(config_path) {
Ok(()) => std::process::exit(EXIT_SUCCESS),
Err(e) => {
eprintln!("Init failed: {:#}", e);
std::process::exit(EXIT_CONFIG);
}
}
}
let config_path = config_path_str.as_ref().map(PathBuf::from);
let resolved_path = config_path
.clone()
.unwrap_or_else(pr_bro::config::get_config_path);
let config = if !resolved_path.exists() {
if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
eprintln!("No config found at {}", resolved_path.display());
eprint!("Would you like to create one now? [Y/n] ");
let _ = std::io::stderr().flush();
let mut answer = String::new();
let _ = std::io::stdin().lock().read_line(&mut answer);
let answer = answer.trim().to_lowercase();
if answer.is_empty() || answer == "y" || answer == "yes" {
match pr_bro::config::run_init_wizard(config_path) {
Ok(()) => {
let reload_path = config_path_str.map(PathBuf::from);
match pr_bro::config::load_config(reload_path) {
Ok(c) => c,
Err(e) => {
eprintln!("Config error after init: {:#}", e);
std::process::exit(EXIT_CONFIG);
}
}
}
Err(e) => {
eprintln!("Init failed: {:#}", e);
std::process::exit(EXIT_CONFIG);
}
}
} else {
eprintln!("No config file found. Run `pr-bro init` to create one.");
std::process::exit(EXIT_CONFIG);
}
} else {
eprintln!(
"Config file not found at {}. Run `pr-bro init` to create one.",
resolved_path.display()
);
std::process::exit(EXIT_CONFIG);
}
} else {
match pr_bro::config::load_config(config_path) {
Ok(c) => c,
Err(e) => {
eprintln!("Config error: {:#}", e);
std::process::exit(EXIT_CONFIG);
}
}
};
if cli.verbose {
eprintln!("Loaded {} queries from config", config.queries.len());
for (i, query) in config.queries.iter().enumerate() {
eprintln!(
" Query {}: {} ({})",
i + 1,
query.name.as_deref().unwrap_or("(unnamed)"),
query.query
);
}
}
let global_scoring = config.scoring.clone().unwrap_or_default();
if let Err(errors) = pr_bro::scoring::validate_scoring(&global_scoring) {
eprintln!("Scoring config errors:");
for error in errors {
eprintln!(" - {}", error);
}
std::process::exit(EXIT_CONFIG);
}
for (i, query) in config.queries.iter().enumerate() {
if let Some(ref scoring) = query.scoring {
if let Err(errors) = pr_bro::scoring::validate_scoring(scoring) {
eprintln!(
"Scoring config errors in query '{}' (index {}):",
query.name.as_deref().unwrap_or("unnamed"),
i
);
for error in errors {
eprintln!(" - {}", error);
}
std::process::exit(EXIT_CONFIG);
}
}
}
let snooze_path = pr_bro::snooze::get_snooze_path();
let mut snooze_state = match pr_bro::snooze::load_snooze_state(&snooze_path) {
Ok(s) => s,
Err(e) => {
eprintln!("Warning: Could not load snooze state: {}", e);
pr_bro::snooze::SnoozeState::new()
}
};
snooze_state.clean_expired();
let theme = pr_bro::tui::resolve_theme(&config.theme);
if config.queries.is_empty() {
eprintln!("No queries configured in config file.");
eprintln!("Add queries to ~/.config/pr-bro/config.yaml:");
eprintln!(" queries:");
eprintln!(" - name: my-reviews");
eprintln!(" query: \"is:pr review-requested:@me\"");
std::process::exit(EXIT_CONFIG);
}
let token = match pr_bro::credentials::setup_token_if_missing() {
Ok(t) => t,
Err(e) => {
eprintln!("Credential error: {}", e);
std::process::exit(EXIT_AUTH);
}
};
if cli.verbose {
if pr_bro::credentials::get_token_from_env().is_some() {
eprintln!(
"Token retrieved from {} env var",
pr_bro::credentials::ENV_TOKEN_VAR
);
} else {
eprintln!("Token provided via prompt");
}
}
let cache_config = pr_bro::github::CacheConfig {
enabled: !cli.no_cache,
};
if cli.verbose {
let status = if cache_config.enabled {
"enabled"
} else {
"disabled (--no-cache)"
};
eprintln!(
"Cache: {} ({})",
status,
pr_bro::github::get_cache_path().display()
);
}
let (client, cache_handle) = match pr_bro::github::create_client(&token, &cache_config) {
Ok(result) => result,
Err(e) => {
eprintln!("Failed to create GitHub client: {}", e);
std::process::exit(EXIT_NETWORK);
}
};
let auth_username: Option<String> = match client.current().user().await {
Ok(user) => {
if cli.verbose {
eprintln!("Authenticated as: {}", user.login);
}
Some(user.login)
}
Err(e) => {
if cli.verbose {
eprintln!("Warning: Could not fetch authenticated user: {}", e);
}
None
}
};
let is_interactive = std::io::stdout().is_terminal() && !cli.non_interactive;
if is_interactive
&& matches!(
command,
Commands::List {
show_snoozed: false
}
)
{
if cli.verbose {
eprintln!("Launching TUI mode...");
}
let app = pr_bro::tui::App::new_loading(
snooze_state,
snooze_path,
config,
cache_config,
cache_handle,
cli.verbose,
auth_username.clone(),
cli.no_version_check,
theme,
);
if let Err(e) = pr_bro::tui::run_tui(app, client).await {
eprintln!("TUI error: {}", e);
std::process::exit(EXIT_NETWORK);
}
std::process::exit(EXIT_SUCCESS);
}
let mut current_client = client;
let mut current_auth_username = auth_username;
let (active_scored, snoozed_scored, _rate_limit) = loop {
match pr_bro::fetch::fetch_and_score_prs(
¤t_client,
&config,
&snooze_state,
&cache_config,
cli.verbose,
current_auth_username.as_deref(),
)
.await
{
Ok(result) => break result,
Err(e) => {
if e.downcast_ref::<pr_bro::fetch::AuthError>().is_some() {
eprintln!("Authentication failed: {}", e);
let new_token = match pr_bro::credentials::reprompt_for_token() {
Ok(t) => t,
Err(e) => {
eprintln!("Failed to get new token: {}", e);
std::process::exit(EXIT_AUTH);
}
};
current_client = match pr_bro::github::create_client(&new_token, &cache_config)
{
Ok((c, _handle)) => c,
Err(e) => {
eprintln!("Failed to create GitHub client: {}", e);
std::process::exit(EXIT_NETWORK);
}
};
current_auth_username = match current_client.current().user().await {
Ok(user) => {
if cli.verbose {
eprintln!("Re-authenticated as: {}", user.login);
}
Some(user.login)
}
Err(e) => {
if cli.verbose {
eprintln!("Warning: Could not fetch authenticated user: {}", e);
}
None
}
};
continue;
}
eprintln!("Failed to fetch PRs: {}", e);
std::process::exit(EXIT_NETWORK);
}
}
};
let scored_prs = match &command {
Commands::List { show_snoozed: true } | Commands::Unsnooze { .. } => snoozed_scored,
_ => active_scored,
};
match command {
Commands::List { show_snoozed: _ } => {
let scored_refs: Vec<pr_bro::output::ScoredPr> = scored_prs
.iter()
.map(|(pr, result)| pr_bro::output::ScoredPr {
pr,
score: result.score,
incomplete: result.incomplete,
})
.collect();
let use_colors = pr_bro::output::should_use_colors();
if cli.format == "tsv" {
let output = pr_bro::output::format_tsv(&scored_refs);
if !output.is_empty() {
println!("{}", output);
}
} else if cli.verbose && !scored_refs.is_empty() {
for scored in &scored_refs {
println!(
"{}",
pr_bro::output::format_pr_detail(scored.pr, use_colors)
);
println!(
" Score: {}",
pr_bro::output::format_score(scored.score, scored.incomplete)
);
println!();
}
} else {
let output = pr_bro::output::format_scored_table(&scored_refs, use_colors);
println!("{}", output);
}
if cli.verbose {
eprintln!();
eprintln!(
"Total: {} PRs in {:?}",
scored_prs.len(),
start_time.elapsed()
);
}
}
Commands::Open { index } => {
if scored_prs.is_empty() {
eprintln!("No pull requests found. Nothing to open.");
std::process::exit(EXIT_SUCCESS);
}
if index < 1 || index > scored_prs.len() {
eprintln!(
"Invalid index {}. Must be between 1 and {}.",
index,
scored_prs.len()
);
std::process::exit(EXIT_CONFIG);
}
let (pr, _result) = &scored_prs[index - 1];
if let Err(e) = pr_bro::browser::open_url(&pr.url) {
eprintln!("Failed to open browser: {}", e);
std::process::exit(EXIT_NETWORK);
}
println!("Opening PR #{} in browser: {}", pr.number, pr.url);
}
Commands::Snooze {
index,
r#for: duration,
} => {
if scored_prs.is_empty() {
eprintln!("No pull requests found. Nothing to snooze.");
std::process::exit(EXIT_SUCCESS);
}
if index < 1 || index > scored_prs.len() {
eprintln!(
"Invalid index {}. Must be between 1 and {}.",
index,
scored_prs.len()
);
std::process::exit(EXIT_CONFIG);
}
let (pr, _) = &scored_prs[index - 1];
let snooze_until = if let Some(dur_str) = duration {
let std_duration = humantime::parse_duration(&dur_str).unwrap_or_else(|_| {
eprintln!(
"Invalid duration '{}'. Use formats like: 2h, 3d, 1w",
dur_str
);
std::process::exit(EXIT_CONFIG);
});
let chrono_duration =
chrono::Duration::from_std(std_duration).unwrap_or_else(|_| {
eprintln!("Duration '{}' is too large.", dur_str);
std::process::exit(EXIT_CONFIG);
});
Some(chrono::Utc::now() + chrono_duration)
} else {
None
};
snooze_state.snooze(pr.url.clone(), snooze_until);
if let Err(e) = pr_bro::snooze::save_snooze_state(&snooze_path, &snooze_state) {
eprintln!("Failed to save snooze state: {}", e);
std::process::exit(EXIT_CONFIG);
}
let duration_msg = match snooze_until {
Some(until) => format!(" until {}", until.format("%Y-%m-%d %H:%M UTC")),
None => " indefinitely".to_string(),
};
println!("Snoozed PR #{}{}: {}", pr.number, duration_msg, pr.title);
}
Commands::Unsnooze { index } => {
if scored_prs.is_empty() {
eprintln!("No snoozed pull requests found. Nothing to unsnooze.");
std::process::exit(EXIT_SUCCESS);
}
if index < 1 || index > scored_prs.len() {
eprintln!(
"Invalid index {}. Must be between 1 and {}.",
index,
scored_prs.len()
);
std::process::exit(EXIT_CONFIG);
}
let (pr, _) = &scored_prs[index - 1];
let removed = snooze_state.unsnooze(&pr.url);
if removed {
if let Err(e) = pr_bro::snooze::save_snooze_state(&snooze_path, &snooze_state) {
eprintln!("Failed to save snooze state: {}", e);
std::process::exit(EXIT_CONFIG);
}
println!("Unsnoozed PR #{}: {}", pr.number, pr.title);
} else {
eprintln!("PR #{} was not snoozed.", pr.number);
}
}
Commands::Init => unreachable!("Init is handled before config loading"),
}
std::process::exit(EXIT_SUCCESS);
}