256 lines
9.6 KiB
Rust
256 lines
9.6 KiB
Rust
|
|
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;
|
||
|
|
|
||
|
|
const DEFAULT_API_URL: &str = "https://api.dev.vibekanban.com";
|
||
|
|
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<()> {
|
||
|
|
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(())
|
||
|
|
}
|