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! ( " \n Review available at: " ) ;
println! ( " {review_url} " ) ;
Ok ( ( ) )
}