Remote review (#1521)
This commit is contained in:
committed by
GitHub
parent
5710cc3371
commit
fd9e5e5d79
29
crates/review/Cargo.toml
Normal file
29
crates/review/Cargo.toml
Normal 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
208
crates/review/src/api.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
106
crates/review/src/archive.rs
Normal file
106
crates/review/src/archive.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
513
crates/review/src/claude_session.rs
Normal file
513
crates/review/src/claude_session.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
47
crates/review/src/config.rs
Normal file
47
crates/review/src/config.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
43
crates/review/src/error.rs
Normal file
43
crates/review/src/error.rs
Normal 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
229
crates/review/src/github.rs
Normal 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
255
crates/review/src/main.rs
Normal 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(())
|
||||
}
|
||||
173
crates/review/src/session_selector.rs
Normal file
173
crates/review/src/session_selector.rs
Normal 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" })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user