Remote review (#1521)

This commit is contained in:
Louis Knight-Webb
2025-12-15 19:42:13 +00:00
committed by GitHub
parent 5710cc3371
commit fd9e5e5d79
95 changed files with 10506 additions and 195 deletions

29
crates/review/Cargo.toml Normal file
View File

@@ -0,0 +1,29 @@
[package]
name = "review"
version = "0.0.134"
edition = "2024"
publish = false
[[bin]]
name = "review"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive", "env"] }
tokio = { workspace = true }
reqwest = { version = "0.12", features = ["json", "stream"] }
serde = { workspace = true }
serde_json = { workspace = true }
tar = "0.4"
flate2 = "1.0"
indicatif = "0.17"
anyhow = { workspace = true }
thiserror = { workspace = true }
uuid = { version = "1.0", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
tempfile = "3.8"
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
dialoguer = "0.11"
dirs = "5.0"
toml = "0.8"

208
crates/review/src/api.rs Normal file
View File

@@ -0,0 +1,208 @@
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tracing::debug;
use uuid::Uuid;
use crate::error::ReviewError;
/// API client for the review service
pub struct ReviewApiClient {
client: Client,
base_url: String,
}
/// Response from POST /review/init
#[derive(Debug, Deserialize)]
pub struct InitResponse {
pub review_id: Uuid,
pub upload_url: String,
pub object_key: String,
}
/// Request body for POST /review/init
#[derive(Debug, Serialize)]
struct InitRequest {
gh_pr_url: String,
email: String,
pr_title: String,
}
/// Request body for POST /review/start
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StartRequest {
pub id: String,
pub title: String,
pub description: String,
pub org: String,
pub repo: String,
pub codebase_url: String,
pub base_commit: String,
}
/// Response from GET /review/{id}/status
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StatusResponse {
pub status: ReviewStatus,
pub progress: Option<String>,
pub error: Option<String>,
}
/// Possible review statuses
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewStatus {
Queued,
Extracting,
Running,
Completed,
Failed,
}
impl std::fmt::Display for ReviewStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ReviewStatus::Queued => write!(f, "queued"),
ReviewStatus::Extracting => write!(f, "extracting"),
ReviewStatus::Running => write!(f, "running"),
ReviewStatus::Completed => write!(f, "completed"),
ReviewStatus::Failed => write!(f, "failed"),
}
}
}
impl ReviewApiClient {
/// Create a new API client
pub fn new(base_url: String) -> Self {
Self {
client: Client::new(),
base_url,
}
}
/// Initialize a review upload and get a presigned URL
pub async fn init(
&self,
pr_url: &str,
email: &str,
pr_title: &str,
) -> Result<InitResponse, ReviewError> {
let url = format!("{}/v1/review/init", self.base_url);
debug!("POST {url}");
let response = self
.client
.post(&url)
.json(&InitRequest {
gh_pr_url: pr_url.to_string(),
email: email.to_string(),
pr_title: pr_title.to_string(),
})
.send()
.await
.map_err(|e| ReviewError::ApiError(e.to_string()))?;
if !response.status().is_success() {
let status = response.status();
let body = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(ReviewError::ApiError(format!("{status}: {body}")));
}
let init_response: InitResponse = response
.json()
.await
.map_err(|e| ReviewError::ApiError(e.to_string()))?;
debug!("Review ID: {}", init_response.review_id);
Ok(init_response)
}
/// Upload the tarball to the presigned URL
pub async fn upload(&self, upload_url: &str, payload: Vec<u8>) -> Result<(), ReviewError> {
debug!("PUT {} ({} bytes)", upload_url, payload.len());
let response = self
.client
.put(upload_url)
.header("Content-Type", "application/gzip")
.body(payload)
.send()
.await
.map_err(|e| ReviewError::UploadFailed(e.to_string()))?;
if !response.status().is_success() {
let status = response.status();
let body = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(ReviewError::UploadFailed(format!("{status}: {body}")));
}
Ok(())
}
/// Start the review process
pub async fn start(&self, request: StartRequest) -> Result<(), ReviewError> {
let url = format!("{}/v1/review/start", self.base_url);
debug!("POST {url}");
let response = self
.client
.post(&url)
.json(&request)
.send()
.await
.map_err(|e| ReviewError::ApiError(e.to_string()))?;
if !response.status().is_success() {
let status = response.status();
let body = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(ReviewError::ApiError(format!("{status}: {body}")));
}
Ok(())
}
/// Poll the review status
pub async fn poll_status(&self, review_id: &str) -> Result<StatusResponse, ReviewError> {
let url = format!("{}/v1/review/{}/status", self.base_url, review_id);
debug!("GET {url}");
let response = self
.client
.get(&url)
.send()
.await
.map_err(|e| ReviewError::ApiError(e.to_string()))?;
if !response.status().is_success() {
let status = response.status();
let body = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(ReviewError::ApiError(format!("{status}: {body}")));
}
let status_response: StatusResponse = response
.json()
.await
.map_err(|e| ReviewError::ApiError(e.to_string()))?;
Ok(status_response)
}
/// Get the review URL for a given review ID
pub fn review_url(&self, review_id: &str) -> String {
format!("{}/review/{}", self.base_url, review_id)
}
}

View File

@@ -0,0 +1,106 @@
use std::{fs::File, path::Path};
use flate2::{Compression, write::GzEncoder};
use tar::Builder;
use tracing::debug;
use crate::error::ReviewError;
/// Create a tar.gz archive from a directory
pub fn create_tarball(source_dir: &Path) -> Result<Vec<u8>, ReviewError> {
debug!("Creating tarball from {}", source_dir.display());
let mut buffer = Vec::new();
{
let encoder = GzEncoder::new(&mut buffer, Compression::default());
let mut archive = Builder::new(encoder);
add_directory_to_archive(&mut archive, source_dir, source_dir)?;
let encoder = archive
.into_inner()
.map_err(|e| ReviewError::ArchiveFailed(e.to_string()))?;
encoder
.finish()
.map_err(|e| ReviewError::ArchiveFailed(e.to_string()))?;
}
debug!("Created tarball: {} bytes", buffer.len());
Ok(buffer)
}
fn add_directory_to_archive<W: std::io::Write>(
archive: &mut Builder<W>,
base_dir: &Path,
current_dir: &Path,
) -> Result<(), ReviewError> {
let entries =
std::fs::read_dir(current_dir).map_err(|e| ReviewError::ArchiveFailed(e.to_string()))?;
for entry in entries {
let entry = entry.map_err(|e| ReviewError::ArchiveFailed(e.to_string()))?;
let path = entry.path();
let relative_path = path
.strip_prefix(base_dir)
.map_err(|e| ReviewError::ArchiveFailed(e.to_string()))?;
let metadata = entry
.metadata()
.map_err(|e| ReviewError::ArchiveFailed(e.to_string()))?;
if metadata.is_dir() {
// Recursively add directory contents
add_directory_to_archive(archive, base_dir, &path)?;
} else if metadata.is_file() {
// Add file to archive
let mut file =
File::open(&path).map_err(|e| ReviewError::ArchiveFailed(e.to_string()))?;
archive
.append_file(relative_path, &mut file)
.map_err(|e| ReviewError::ArchiveFailed(e.to_string()))?;
}
// Skip symlinks and other special files
}
Ok(())
}
#[cfg(test)]
mod tests {
use tempfile::TempDir;
use super::*;
#[test]
fn test_create_tarball() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
// Create some test files
std::fs::write(base.join("file1.txt"), "content1").unwrap();
std::fs::create_dir(base.join("subdir")).unwrap();
std::fs::write(base.join("subdir/file2.txt"), "content2").unwrap();
let tarball = create_tarball(base).expect("Should create tarball");
// Verify tarball is not empty
assert!(!tarball.is_empty());
// Decompress and verify contents
let decoder = flate2::read::GzDecoder::new(&tarball[..]);
let mut archive = tar::Archive::new(decoder);
let entries: Vec<_> = archive
.entries()
.unwrap()
.map(|e| e.unwrap().path().unwrap().to_string_lossy().to_string())
.collect();
assert!(entries.contains(&"file1.txt".to_string()));
assert!(entries.contains(&"subdir/file2.txt".to_string()));
}
}

View File

@@ -0,0 +1,513 @@
use std::{
fs::{self, File},
io::{BufRead, BufReader},
path::{Path, PathBuf},
time::SystemTime,
};
use serde::Deserialize;
use tracing::debug;
use crate::error::ReviewError;
/// Represents a Claude Code project directory
#[derive(Debug, Clone)]
pub struct ClaudeProject {
pub path: PathBuf,
pub name: String,
pub git_branch: Option<String>,
pub first_prompt: Option<String>,
pub session_count: usize,
pub modified_at: SystemTime,
}
/// Represents a single session file within a project
#[derive(Debug, Clone)]
pub struct ClaudeSession {
pub path: PathBuf,
pub git_branch: Option<String>,
pub first_prompt: Option<String>,
pub modified_at: SystemTime,
}
/// A JSONL record for metadata extraction
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct JsonlRecord {
git_branch: Option<String>,
message: Option<JsonlMessage>,
}
/// Message within a JSONL record
#[derive(Debug, Deserialize)]
struct JsonlMessage {
role: Option<String>,
content: Option<serde_json::Value>,
}
/// Get the Claude projects directory path (~/.claude/projects)
pub fn get_claude_projects_dir() -> Option<PathBuf> {
dirs::home_dir().map(|home| home.join(".claude").join("projects"))
}
/// Discover all Claude projects, sorted by modification time (most recent first)
/// Aggregates session metadata (git_branch, first_prompt, session_count) from each project's sessions
pub fn discover_projects() -> Result<Vec<ClaudeProject>, ReviewError> {
let projects_dir = get_claude_projects_dir().ok_or_else(|| {
ReviewError::SessionDiscoveryFailed("Could not find home directory".into())
})?;
if !projects_dir.exists() {
debug!(
"Claude projects directory does not exist: {:?}",
projects_dir
);
return Ok(Vec::new());
}
let mut projects = Vec::new();
let entries = fs::read_dir(&projects_dir)
.map_err(|e| ReviewError::SessionDiscoveryFailed(e.to_string()))?;
for entry in entries {
let entry = entry.map_err(|e| ReviewError::SessionDiscoveryFailed(e.to_string()))?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let metadata = entry
.metadata()
.map_err(|e| ReviewError::SessionDiscoveryFailed(e.to_string()))?;
let modified_at = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
// Extract a friendly name from the directory name
// e.g., "-private-var-...-worktrees-a04a-store-payloads-i" -> "store-payloads-i"
let dir_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let name = extract_project_name(dir_name);
// Discover sessions to get aggregated metadata
let sessions = discover_sessions_in_dir(&path)?;
let session_count = sessions.len();
// Skip projects with no sessions
if session_count == 0 {
continue;
}
// Get metadata from the most recent session
let most_recent = &sessions[0]; // Already sorted by modification time
let git_branch = most_recent.git_branch.clone();
let first_prompt = most_recent.first_prompt.clone();
projects.push(ClaudeProject {
path,
name,
git_branch,
first_prompt,
session_count,
modified_at,
});
}
// Sort by modification time, most recent first
projects.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
Ok(projects)
}
/// Extract a friendly project name from the Claude directory name
fn extract_project_name(dir_name: &str) -> String {
// Directory names look like:
// "-private-var-folders-m1-9q-ct1913z10v6wbnv54j25r0000gn-T-vibe-kanban-worktrees-a04a-store-payloads-i"
// We want to extract the meaningful part after "worktrees-"
if let Some(idx) = dir_name.find("worktrees-") {
let after_worktrees = &dir_name[idx + "worktrees-".len()..];
// Skip the short hash prefix (e.g., "a04a-")
if let Some(dash_idx) = after_worktrees.find('-') {
return after_worktrees[dash_idx + 1..].to_string();
}
return after_worktrees.to_string();
}
// Fallback: use last segment after the final dash
dir_name.rsplit('-').next().unwrap_or(dir_name).to_string()
}
/// Discover sessions in a project, excluding agent-* files
pub fn discover_sessions(project: &ClaudeProject) -> Result<Vec<ClaudeSession>, ReviewError> {
discover_sessions_in_dir(&project.path)
}
/// Discover sessions in a directory, excluding agent-* files
fn discover_sessions_in_dir(dir_path: &Path) -> Result<Vec<ClaudeSession>, ReviewError> {
let mut sessions = Vec::new();
let entries =
fs::read_dir(dir_path).map_err(|e| ReviewError::SessionDiscoveryFailed(e.to_string()))?;
for entry in entries {
let entry = entry.map_err(|e| ReviewError::SessionDiscoveryFailed(e.to_string()))?;
let path = entry.path();
// Only process .jsonl files
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
// Skip agent-* files
if file_name.starts_with("agent-") {
continue;
}
let metadata = entry
.metadata()
.map_err(|e| ReviewError::SessionDiscoveryFailed(e.to_string()))?;
let modified_at = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
// Extract metadata from the JSONL file
let (git_branch, first_prompt) = extract_session_metadata(&path);
sessions.push(ClaudeSession {
path,
git_branch,
first_prompt,
modified_at,
});
}
// Sort by modification time, most recent first
sessions.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
Ok(sessions)
}
/// Extract session metadata from a JSONL file
/// Returns: (git_branch, first_prompt)
fn extract_session_metadata(path: &Path) -> (Option<String>, Option<String>) {
let file = match File::open(path) {
Ok(f) => f,
Err(_) => return (None, None),
};
let reader = BufReader::new(file);
let mut git_branch: Option<String> = None;
let mut first_prompt: Option<String> = None;
// Check first 50 lines for metadata
for line in reader.lines().take(50) {
let line = match line {
Ok(l) => l,
Err(_) => continue,
};
if line.trim().is_empty() {
continue;
}
if let Ok(record) = serde_json::from_str::<JsonlRecord>(&line) {
// Extract git branch if not already found
if git_branch.is_none() && record.git_branch.is_some() {
git_branch = record.git_branch;
}
// Extract first user prompt if not already found
if first_prompt.is_none()
&& let Some(ref message) = record.message
&& message.role.as_deref() == Some("user")
&& let Some(ref content) = message.content
{
// Content can be a string or an array
if let Some(text) = content.as_str() {
first_prompt = Some(truncate_string(text, 60));
}
}
// Stop early if we have both
if git_branch.is_some() && first_prompt.is_some() {
break;
}
}
}
(git_branch, first_prompt)
}
/// Truncate a string to max length, adding "..." if truncated
fn truncate_string(s: &str, max_len: usize) -> String {
// Replace newlines with spaces for display
let s = s.replace('\n', " ");
if s.len() <= max_len {
s
} else {
format!("{}...", &s[..max_len - 3])
}
}
/// Find projects matching a specific git branch using fuzzy matching
/// Returns matching projects with all their sessions
pub fn find_projects_by_branch(
projects: &[ClaudeProject],
target_branch: &str,
) -> Result<Vec<(ClaudeProject, Vec<ClaudeSession>)>, ReviewError> {
let mut matches = Vec::new();
for project in projects {
// Check if project's branch matches
if let Some(ref project_branch) = project.git_branch
&& branches_match(target_branch, project_branch)
{
let sessions = discover_sessions(project)?;
matches.push((project.clone(), sessions));
}
}
// Sort by modification time, most recent first
matches.sort_by(|a, b| b.0.modified_at.cmp(&a.0.modified_at));
Ok(matches)
}
/// Check if two branch names match using fuzzy matching
fn branches_match(target: &str, session_branch: &str) -> bool {
let target_normalized = normalize_branch(target);
let session_normalized = normalize_branch(session_branch);
// Exact match after normalization
if target_normalized == session_normalized {
return true;
}
// Check if the slug portions match (e.g., "feature-auth" matches "vk/feature-auth")
let target_slug = extract_branch_slug(&target_normalized);
let session_slug = extract_branch_slug(&session_normalized);
target_slug == session_slug && !target_slug.is_empty()
}
/// Normalize a branch name by stripping common prefixes
fn normalize_branch(branch: &str) -> String {
let branch = branch.strip_prefix("refs/heads/").unwrap_or(branch);
branch.to_lowercase()
}
/// Extract the "slug" portion of a branch name
/// e.g., "vk/a04a-store-payloads-i" -> "a04a-store-payloads-i"
fn extract_branch_slug(branch: &str) -> String {
// Split by '/' and take the last part
branch.rsplit('/').next().unwrap_or(branch).to_string()
}
/// A record with timestamp for sorting
struct TimestampedMessage {
timestamp: String,
message: serde_json::Value,
}
/// Concatenate multiple JSONL files into a single JSON array of messages.
///
/// Filters to include only:
/// - User messages (role = "user")
/// - Assistant messages with text content (role = "assistant" with content[].type = "text")
///
/// For assistant messages, only text content blocks are kept (tool_use, etc. are filtered out).
pub fn concatenate_sessions_to_json(session_paths: &[PathBuf]) -> Result<String, ReviewError> {
let mut all_messages: Vec<TimestampedMessage> = Vec::new();
for path in session_paths {
let file = File::open(path)
.map_err(|e| ReviewError::JsonlParseFailed(format!("{}: {}", path.display(), e)))?;
let reader = BufReader::new(file);
for (line_num, line) in reader.lines().enumerate() {
let line = line.map_err(|e| {
ReviewError::JsonlParseFailed(format!("{}:{}: {}", path.display(), line_num + 1, e))
})?;
if line.trim().is_empty() {
continue;
}
let record: serde_json::Value = serde_json::from_str(&line).map_err(|e| {
ReviewError::JsonlParseFailed(format!("{}:{}: {}", path.display(), line_num + 1, e))
})?;
// Extract timestamp for sorting
let timestamp = record
.get("timestamp")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
// Extract and filter the message
if let Some(message) = extract_filtered_message(&record) {
all_messages.push(TimestampedMessage { timestamp, message });
}
}
}
// Sort by timestamp
all_messages.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
// Extract just the messages
let messages: Vec<serde_json::Value> = all_messages.into_iter().map(|m| m.message).collect();
serde_json::to_string(&messages).map_err(|e| ReviewError::JsonlParseFailed(e.to_string()))
}
/// Extract and filter a message from a JSONL record.
///
/// Returns Some(message) if the record should be included, None otherwise.
/// - User messages: include if content is a string, or if content array has text blocks
/// - Assistant messages: include if content array has text blocks (filter out tool_use, etc.)
fn extract_filtered_message(record: &serde_json::Value) -> Option<serde_json::Value> {
let message = record.get("message")?;
let role = message.get("role")?.as_str()?;
let content = message.get("content")?;
match role {
"user" => {
// If content is a string, include directly
if content.is_string() {
return Some(message.clone());
}
// If content is an array, filter to text blocks only
if let Some(content_array) = content.as_array() {
let text_blocks: Vec<serde_json::Value> = content_array
.iter()
.filter(|block| block.get("type").and_then(|t| t.as_str()) == Some("text"))
.cloned()
.collect();
// Skip if no text content (e.g., only tool_result)
if text_blocks.is_empty() {
return None;
}
// Create filtered message with only text content
let mut filtered_message = serde_json::Map::new();
filtered_message.insert(
"role".to_string(),
serde_json::Value::String("user".to_string()),
);
filtered_message
.insert("content".to_string(), serde_json::Value::Array(text_blocks));
return Some(serde_json::Value::Object(filtered_message));
}
None
}
"assistant" => {
// Filter assistant messages to only include text content
if let Some(content_array) = content.as_array() {
// Filter to only text blocks
let text_blocks: Vec<serde_json::Value> = content_array
.iter()
.filter(|block| block.get("type").and_then(|t| t.as_str()) == Some("text"))
.cloned()
.collect();
// Skip if no text content
if text_blocks.is_empty() {
return None;
}
// Create filtered message with only text content
let mut filtered_message = serde_json::Map::new();
filtered_message.insert(
"role".to_string(),
serde_json::Value::String("assistant".to_string()),
);
filtered_message
.insert("content".to_string(), serde_json::Value::Array(text_blocks));
Some(serde_json::Value::Object(filtered_message))
} else {
// Content is not an array (unusual), skip
None
}
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_project_name() {
assert_eq!(
extract_project_name(
"-private-var-folders-m1-9q-ct1913z10v6wbnv54j25r0000gn-T-vibe-kanban-worktrees-a04a-store-payloads-i"
),
"store-payloads-i"
);
assert_eq!(
extract_project_name(
"-private-var-folders-m1-9q-ct1913z10v6wbnv54j25r0000gn-T-vibe-kanban-worktrees-1ff1-new-rust-binary"
),
"new-rust-binary"
);
}
#[test]
fn test_branches_match() {
// Exact match
assert!(branches_match("feature-auth", "feature-auth"));
// With prefix
assert!(branches_match("feature-auth", "vk/feature-auth"));
assert!(branches_match("vk/feature-auth", "feature-auth"));
// Slug matching
assert!(branches_match(
"a04a-store-payloads-i",
"vk/a04a-store-payloads-i"
));
// Case insensitive
assert!(branches_match("Feature-Auth", "feature-auth"));
// Non-matches
assert!(!branches_match("feature-auth", "feature-other"));
assert!(!branches_match("main", "feature-auth"));
// Regression tests: substring matches should NOT match
// (these were incorrectly matching before the fix)
assert!(!branches_match("vk/d13f-remove-compare-c", "c"));
assert!(!branches_match("vk/d13f-remove-compare-c", "compare"));
assert!(!branches_match("feature-auth", "auth"));
assert!(!branches_match("feature-auth", "feature"));
}
#[test]
fn test_normalize_branch() {
assert_eq!(normalize_branch("refs/heads/main"), "main");
assert_eq!(normalize_branch("Feature-Auth"), "feature-auth");
assert_eq!(normalize_branch("vk/feature-auth"), "vk/feature-auth");
}
#[test]
fn test_extract_branch_slug() {
assert_eq!(extract_branch_slug("vk/feature-auth"), "feature-auth");
assert_eq!(extract_branch_slug("feature-auth"), "feature-auth");
assert_eq!(
extract_branch_slug("user/prefix/feature-auth"),
"feature-auth"
);
}
}

View File

@@ -0,0 +1,47 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub email: Option<String>,
}
impl Config {
/// Get the path to the config file (~/.config/vibe-kanban/review.toml)
fn config_path() -> Option<PathBuf> {
dirs::config_dir().map(|p| p.join("vibe-kanban").join("review.toml"))
}
/// Load config from disk, returning default if file doesn't exist
pub fn load() -> Self {
let Some(path) = Self::config_path() else {
return Self::default();
};
if !path.exists() {
return Self::default();
}
match std::fs::read_to_string(&path) {
Ok(contents) => toml::from_str(&contents).unwrap_or_default(),
Err(_) => Self::default(),
}
}
/// Save config to disk
pub fn save(&self) -> std::io::Result<()> {
let Some(path) = Self::config_path() else {
return Ok(());
};
// Create parent directories if needed
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let contents = toml::to_string_pretty(self).unwrap_or_default();
std::fs::write(&path, contents)
}
}

View File

@@ -0,0 +1,43 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ReviewError {
#[error("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/")]
GhNotInstalled,
#[error("GitHub CLI is not authenticated. Run 'gh auth login' first.")]
GhNotAuthenticated,
#[error("Invalid GitHub PR URL format. Expected: https://github.com/owner/repo/pull/123")]
InvalidPrUrl,
#[error("Failed to get PR information: {0}")]
PrInfoFailed(String),
#[error("Failed to clone repository: {0}")]
CloneFailed(String),
#[error("Failed to checkout PR: {0}")]
CheckoutFailed(String),
#[error("Failed to create archive: {0}")]
ArchiveFailed(String),
#[error("API request failed: {0}")]
ApiError(String),
#[error("Upload failed: {0}")]
UploadFailed(String),
#[error("Review failed: {0}")]
ReviewFailed(String),
#[error("Review timed out after 10 minutes")]
Timeout,
#[error("Failed to discover Claude Code sessions: {0}")]
SessionDiscoveryFailed(String),
#[error("Failed to parse JSONL file: {0}")]
JsonlParseFailed(String),
}

229
crates/review/src/github.rs Normal file
View File

@@ -0,0 +1,229 @@
use std::{path::Path, process::Command};
use serde::Deserialize;
use tracing::debug;
use crate::error::ReviewError;
/// Information about a pull request
#[derive(Debug)]
pub struct PrInfo {
pub owner: String,
pub repo: String,
pub title: String,
pub description: String,
pub base_commit: String,
pub head_commit: String,
pub head_ref_name: String,
}
/// Response from `gh pr view --json`
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GhPrView {
title: String,
body: String,
base_ref_oid: String,
head_ref_oid: String,
head_ref_name: String,
}
/// Parse a GitHub PR URL to extract owner, repo, and PR number
///
/// Expected format: https://github.com/owner/repo/pull/123
pub fn parse_pr_url(url: &str) -> Result<(String, String, i64), ReviewError> {
let url = url.trim();
// Remove trailing slashes
let url = url.trim_end_matches('/');
// Try to parse as URL
let parts: Vec<&str> = url.split('/').collect();
// Find the index of "github.com" and then extract owner/repo/pull/number
let github_idx = parts
.iter()
.position(|&p| p == "github.com")
.ok_or(ReviewError::InvalidPrUrl)?;
// We need at least: github.com / owner / repo / pull / number
if parts.len() < github_idx + 5 {
return Err(ReviewError::InvalidPrUrl);
}
let owner = parts[github_idx + 1].to_string();
let repo = parts[github_idx + 2].to_string();
if parts[github_idx + 3] != "pull" {
return Err(ReviewError::InvalidPrUrl);
}
let pr_number: i64 = parts[github_idx + 4]
.parse()
.map_err(|_| ReviewError::InvalidPrUrl)?;
if owner.is_empty() || repo.is_empty() || pr_number <= 0 {
return Err(ReviewError::InvalidPrUrl);
}
Ok((owner, repo, pr_number))
}
/// Check if the GitHub CLI is installed
fn ensure_gh_available() -> Result<(), ReviewError> {
let output = Command::new("which")
.arg("gh")
.output()
.map_err(|_| ReviewError::GhNotInstalled)?;
if !output.status.success() {
return Err(ReviewError::GhNotInstalled);
}
Ok(())
}
/// Get PR information using `gh pr view`
pub fn get_pr_info(owner: &str, repo: &str, pr_number: i64) -> Result<PrInfo, ReviewError> {
ensure_gh_available()?;
debug!("Fetching PR info for {owner}/{repo}#{pr_number}");
let output = Command::new("gh")
.args([
"pr",
"view",
&pr_number.to_string(),
"--repo",
&format!("{owner}/{repo}"),
"--json",
"title,body,baseRefOid,headRefOid,headRefName",
])
.output()
.map_err(|e| ReviewError::PrInfoFailed(e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let lower = stderr.to_ascii_lowercase();
if lower.contains("authentication")
|| lower.contains("gh auth login")
|| lower.contains("unauthorized")
{
return Err(ReviewError::GhNotAuthenticated);
}
return Err(ReviewError::PrInfoFailed(stderr.to_string()));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let pr_view: GhPrView =
serde_json::from_str(&stdout).map_err(|e| ReviewError::PrInfoFailed(e.to_string()))?;
Ok(PrInfo {
owner: owner.to_string(),
repo: repo.to_string(),
title: pr_view.title,
description: pr_view.body,
base_commit: pr_view.base_ref_oid,
head_commit: pr_view.head_ref_oid,
head_ref_name: pr_view.head_ref_name,
})
}
/// Clone a repository using `gh repo clone`
pub fn clone_repo(owner: &str, repo: &str, target_dir: &Path) -> Result<(), ReviewError> {
ensure_gh_available()?;
debug!("Cloning {owner}/{repo} to {}", target_dir.display());
let output = Command::new("gh")
.args([
"repo",
"clone",
&format!("{owner}/{repo}"),
target_dir
.to_str()
.ok_or_else(|| ReviewError::CloneFailed("Invalid target path".to_string()))?,
])
.output()
.map_err(|e| ReviewError::CloneFailed(e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(ReviewError::CloneFailed(stderr.to_string()));
}
Ok(())
}
/// Checkout a specific commit by SHA
///
/// This is more reliable than `gh pr checkout` because it works even when
/// the PR's branch has been deleted (common for merged PRs).
pub fn checkout_commit(commit_sha: &str, repo_dir: &Path) -> Result<(), ReviewError> {
debug!("Fetching commit {commit_sha} in {}", repo_dir.display());
// First, fetch the specific commit
let output = Command::new("git")
.args(["fetch", "origin", commit_sha])
.current_dir(repo_dir)
.output()
.map_err(|e| ReviewError::CheckoutFailed(e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(ReviewError::CheckoutFailed(format!(
"Failed to fetch commit: {stderr}"
)));
}
debug!("Checking out commit {commit_sha}");
// Then checkout the commit
let output = Command::new("git")
.args(["checkout", commit_sha])
.current_dir(repo_dir)
.output()
.map_err(|e| ReviewError::CheckoutFailed(e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(ReviewError::CheckoutFailed(format!(
"Failed to checkout commit: {stderr}"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_pr_url_valid() {
let (owner, repo, pr) = parse_pr_url("https://github.com/anthropics/claude-code/pull/123")
.expect("Should parse valid URL");
assert_eq!(owner, "anthropics");
assert_eq!(repo, "claude-code");
assert_eq!(pr, 123);
}
#[test]
fn test_parse_pr_url_with_trailing_slash() {
let (owner, repo, pr) =
parse_pr_url("https://github.com/owner/repo/pull/456/").expect("Should parse");
assert_eq!(owner, "owner");
assert_eq!(repo, "repo");
assert_eq!(pr, 456);
}
#[test]
fn test_parse_pr_url_invalid_format() {
assert!(parse_pr_url("https://github.com/owner/repo").is_err());
assert!(parse_pr_url("https://github.com/owner/repo/issues/123").is_err());
assert!(parse_pr_url("https://gitlab.com/owner/repo/pull/123").is_err());
assert!(parse_pr_url("not a url").is_err());
}
}

255
crates/review/src/main.rs Normal file
View File

@@ -0,0 +1,255 @@
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(())
}

View File

@@ -0,0 +1,173 @@
use std::{path::PathBuf, time::SystemTime};
use dialoguer::{Select, theme::ColorfulTheme};
use tracing::debug;
use crate::{
claude_session::{
ClaudeProject, discover_projects, discover_sessions, find_projects_by_branch,
},
error::ReviewError,
};
/// Result of session selection process
pub enum SessionSelection {
/// User selected session files to include (all sessions from a project)
Selected(Vec<PathBuf>),
/// User chose to skip session attachment
Skipped,
}
/// Prompt user to select a Claude Code project
///
/// Flow:
/// 1. Try auto-match by branch name
/// 2. If match found, confirm with user
/// 3. If no match or user declines, show scrollable project list
/// 4. Allow user to skip entirely
///
/// When a project is selected, ALL sessions from that project are included.
pub fn select_session(pr_branch: &str) -> Result<SessionSelection, ReviewError> {
debug!(
"Looking for Claude Code projects matching branch: {}",
pr_branch
);
let projects = discover_projects()?;
if projects.is_empty() {
debug!("No Claude Code projects found");
return Ok(SessionSelection::Skipped);
}
// Try auto-match by branch
let matches = find_projects_by_branch(&projects, pr_branch)?;
if !matches.is_empty() {
// Found a matching project, ask for confirmation
let (project, sessions) = &matches[0];
println!();
println!();
println!(
"Found matching Claude Code project for branch '{}'",
pr_branch
);
println!(" Project: {}", project.name);
if let Some(ref prompt) = project.first_prompt {
println!(" \"{}\"", prompt);
}
println!(
" {} session{} · Last modified: {}",
project.session_count,
if project.session_count == 1 { "" } else { "s" },
format_time_ago(project.modified_at)
);
println!();
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Use this project to improve review quality?")
.items(&[
"Yes, use this project",
"No, choose a different project",
"Skip (generate review from just code changes)",
])
.default(0)
.interact()
.map_err(|e| ReviewError::SessionDiscoveryFailed(e.to_string()))?;
match selection {
0 => {
// Yes, use all sessions from this project
let paths: Vec<PathBuf> = sessions.iter().map(|s| s.path.clone()).collect();
return Ok(SessionSelection::Selected(paths));
}
2 => {
// Skip
return Ok(SessionSelection::Skipped);
}
_ => {
// Fall through to manual selection
}
}
}
// Manual selection: select a project
select_project(&projects)
}
/// Manual project selection - returns all sessions from selected project
fn select_project(projects: &[ClaudeProject]) -> Result<SessionSelection, ReviewError> {
// Build project list with rich metadata
let mut items: Vec<String> = Vec::new();
items.push("Skip (no project)\n".to_string());
items.extend(projects.iter().map(format_project_item));
items.push("Skip (no project)\n".to_string());
println!();
println!();
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Select a Claude Code project to improve review quality")
.items(&items)
.default(0)
.max_length(5)
.interact()
.map_err(|e| ReviewError::SessionDiscoveryFailed(e.to_string()))?;
// Skip option
if selection == 0 || selection == items.len() - 1 {
return Ok(SessionSelection::Skipped);
}
let project = &projects[selection];
let sessions = discover_sessions(project)?;
// Return all session paths from this project
let paths: Vec<PathBuf> = sessions.iter().map(|s| s.path.clone()).collect();
Ok(SessionSelection::Selected(paths))
}
/// Format a project item for display in the selection list
fn format_project_item(project: &ClaudeProject) -> String {
let prompt_line = project
.first_prompt
.as_ref()
.map(|p| format!("\n \"{}\"", p))
.unwrap_or_default();
let branch = project
.git_branch
.as_ref()
.map(|b| format!("branch: {}", b))
.unwrap_or_else(|| "no branch".to_string());
format!(
"{}{}\n {} · {} session{} · {}\n",
project.name,
prompt_line,
branch,
project.session_count,
if project.session_count == 1 { "" } else { "s" },
format_time_ago(project.modified_at)
)
}
/// Format a SystemTime as a human-readable "time ago" string
fn format_time_ago(time: SystemTime) -> String {
let now = SystemTime::now();
let duration = now.duration_since(time).unwrap_or_default();
let secs = duration.as_secs();
if secs < 60 {
"just now".to_string()
} else if secs < 3600 {
let mins = secs / 60;
format!("{} minute{} ago", mins, if mins == 1 { "" } else { "s" })
} else if secs < 86400 {
let hours = secs / 3600;
format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" })
} else {
let days = secs / 86400;
format!("{} day{} ago", days, if days == 1 { "" } else { "s" })
}
}