Files
vibe-kanban/crates/review/src/main.rs

261 lines
9.8 KiB
Rust
Raw Normal View History

2025-12-15 19:42:13 +00:00
mod api;
mod archive;
mod claude_session;
mod config;
mod error;
mod github;
mod session_selector;
use std::time::Duration;
use anyhow::Result;
use api::{ReviewApiClient, ReviewStatus, StartRequest};
use clap::Parser;
use error::ReviewError;
use github::{checkout_commit, clone_repo, get_pr_info, parse_pr_url};
use indicatif::{ProgressBar, ProgressStyle};
use tempfile::TempDir;
use tracing::debug;
use tracing_subscriber::EnvFilter;
2025-12-15 19:47:35 +00:00
const DEFAULT_API_URL: &str = "https://api.vibekanban.com";
2025-12-15 19:42:13 +00:00
const POLL_INTERVAL: Duration = Duration::from_secs(10);
const TIMEOUT: Duration = Duration::from_secs(600); // 10 minutes
const BANNER: &str = r#"
"#;
#[derive(Parser, Debug)]
#[command(name = "review")]
#[command(
about = "Vibe-Kanban Review helps you review GitHub pull requests by turning them into a clear, story-driven summary instead of a wall of diffs. You provide a pull request URL, optionally link a Claude Code project for additional context, and it builds a narrative that highlights key events and important decisions, helping you prioritise what actually needs attention. It's particularly useful when reviewing large amounts of AI-generated code. Note that code is uploaded to and processed on Vibe-Kanban servers using AI."
)]
#[command(version)]
struct Args {
/// GitHub PR URL (e.g., https://github.com/owner/repo/pull/123)
pr_url: String,
/// Enable verbose output
#[arg(short, long, default_value_t = false)]
verbose: bool,
/// API base URL
#[arg(long, env = "REVIEW_API_URL", default_value = DEFAULT_API_URL)]
api_url: String,
}
fn show_disclaimer() {
println!();
println!(
"DISCLAIMER: Your code will be processed on our secure remote servers, all artefacts (code, AI logs, etc...) will be deleted after 14 days."
);
println!();
println!("Full terms and conditions and privacy policy: https://review.fast/terms");
println!();
println!("Press Enter to accept and continue...");
let mut input = String::new();
std::io::stdin().read_line(&mut input).ok();
}
fn prompt_email(config: &mut config::Config) -> String {
use dialoguer::Input;
let mut input: Input<String> =
Input::new().with_prompt("Email address (we'll send a link to the review here, no spam)");
if let Some(ref saved_email) = config.email {
input = input.default(saved_email.clone());
}
let email: String = input.interact_text().expect("Failed to read email");
// Save email for next time
config.email = Some(email.clone());
if let Err(e) = config.save() {
debug!("Failed to save config: {}", e);
}
email
}
fn create_spinner(message: &str) -> ProgressBar {
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.green} {msg}")
.expect("Invalid spinner template"),
);
spinner.set_message(message.to_string());
spinner.enable_steady_tick(Duration::from_millis(100));
spinner
}
#[tokio::main]
async fn main() -> Result<()> {
2026-01-06 15:58:10 +00:00
// Install rustls crypto provider before any TLS operations
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
2025-12-15 19:42:13 +00:00
let args = Args::parse();
// Initialize tracing
let filter = if args.verbose {
EnvFilter::new("debug")
} else {
EnvFilter::new("warn")
};
tracing_subscriber::fmt().with_env_filter(filter).init();
println!("{}", BANNER);
show_disclaimer();
debug!("Args: {:?}", args);
// Run the main flow and handle errors
if let Err(e) = run(args).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
Ok(())
}
async fn run(args: Args) -> Result<(), ReviewError> {
// 1. Load config and prompt for email
let mut config = config::Config::load();
let email = prompt_email(&mut config);
// 2. Parse PR URL
let spinner = create_spinner("Parsing PR URL...");
let (owner, repo, pr_number) = parse_pr_url(&args.pr_url)?;
spinner.finish_with_message(format!("PR: {owner}/{repo}#{pr_number}"));
// 3. Get PR info
let spinner = create_spinner("Fetching PR information...");
let pr_info = get_pr_info(&owner, &repo, pr_number)?;
spinner.finish_with_message(format!("PR: {}", pr_info.title));
// 4. Select Claude Code session (optional)
let session_files = match session_selector::select_session(&pr_info.head_ref_name) {
Ok(session_selector::SessionSelection::Selected(files)) => {
println!(" Selected {} session file(s)", files.len());
Some(files)
}
Ok(session_selector::SessionSelection::Skipped) => {
println!(" Skipping project attachment");
None
}
Err(e) => {
debug!("Session selection error: {}", e);
println!(" No sessions found");
None
}
};
// 5. Clone repository to temp directory
let temp_dir = TempDir::new().map_err(|e| ReviewError::CloneFailed(e.to_string()))?;
let repo_dir = temp_dir.path().join(&repo);
let spinner = create_spinner("Cloning repository...");
clone_repo(&owner, &repo, &repo_dir)?;
spinner.finish_with_message("Repository cloned");
// 6. Checkout PR head commit
let spinner = create_spinner("Checking out PR...");
checkout_commit(&pr_info.head_commit, &repo_dir)?;
spinner.finish_with_message("PR checked out");
// 7. Create tarball (with optional session data)
let spinner = create_spinner("Creating archive...");
// If sessions were selected, write .agent-messages.json to repo root
if let Some(ref files) = session_files {
let json_content = claude_session::concatenate_sessions_to_json(files)?;
let agent_messages_path = repo_dir.join(".agent-messages.json");
std::fs::write(&agent_messages_path, json_content)
.map_err(|e| ReviewError::ArchiveFailed(e.to_string()))?;
}
let payload = archive::create_tarball(&repo_dir)?;
let size_mb = payload.len() as f64 / 1_048_576.0;
spinner.finish_with_message(format!("Archive created ({size_mb:.2} MB)"));
// 8. Initialize review
let client = ReviewApiClient::new(args.api_url.clone());
let spinner = create_spinner("Initializing review...");
let init_response = client.init(&args.pr_url, &email, &pr_info.title).await?;
spinner.finish_with_message(format!("Review ID: {}", init_response.review_id));
// 9. Upload archive
let spinner = create_spinner("Uploading archive...");
client.upload(&init_response.upload_url, payload).await?;
spinner.finish_with_message("Upload complete");
// 10. Start review
let spinner = create_spinner("Starting review...");
let codebase_url = format!("r2://{}", init_response.object_key);
client
.start(StartRequest {
id: init_response.review_id.to_string(),
title: pr_info.title,
description: pr_info.description,
org: pr_info.owner,
repo: pr_info.repo,
codebase_url,
base_commit: pr_info.base_commit,
})
.await?;
spinner.finish_with_message(format!("Review started, we'll send you an email at {} when the review is ready. This can take a few minutes, you may now close the terminal", email));
// 11. Poll for completion
let spinner = create_spinner("Review in progress...");
let start_time = std::time::Instant::now();
loop {
tokio::time::sleep(POLL_INTERVAL).await;
// Check for timeout
if start_time.elapsed() > TIMEOUT {
spinner.finish_with_message("Timed out");
return Err(ReviewError::Timeout);
}
let status = client
.poll_status(&init_response.review_id.to_string())
.await?;
match status.status {
ReviewStatus::Completed => {
spinner.finish_with_message("Review completed!");
break;
}
ReviewStatus::Failed => {
spinner.finish_with_message("Review failed");
let error_msg = status.error.unwrap_or_else(|| "Unknown error".to_string());
return Err(ReviewError::ReviewFailed(error_msg));
}
_ => {
let progress = status.progress.unwrap_or_else(|| status.status.to_string());
spinner.set_message(format!("Review in progress: {progress}"));
}
}
}
// 12. Print result URL
let review_url = client.review_url(&init_response.review_id.to_string());
println!("\nReview available at:");
println!(" {review_url}");
Ok(())
}