diff --git a/src/cli/commands/auth_test.rs b/src/cli/commands/auth_test.rs new file mode 100644 index 0000000..de98d4f --- /dev/null +++ b/src/cli/commands/auth_test.rs @@ -0,0 +1,43 @@ +//! Auth test command - verify GitLab authentication. + +use crate::core::config::Config; +use crate::core::error::{GiError, Result}; +use crate::gitlab::GitLabClient; + +/// Result of successful auth test. +pub struct AuthTestResult { + pub username: String, + pub name: String, + pub base_url: String, +} + +/// Run the auth-test command. +pub async fn run_auth_test(config_path: Option<&str>) -> Result { + // 1. Load config + let config = Config::load(config_path)?; + + // 2. Get token from environment + let token = std::env::var(&config.gitlab.token_env_var) + .map(|t| t.trim().to_string()) + .map_err(|_| GiError::TokenNotSet { + env_var: config.gitlab.token_env_var.clone(), + })?; + + if token.is_empty() { + return Err(GiError::TokenNotSet { + env_var: config.gitlab.token_env_var.clone(), + }); + } + + // 3. Create client and test auth + let client = GitLabClient::new(&config.gitlab.base_url, &token, None); + + // 4. Get current user + let user = client.get_current_user().await?; + + Ok(AuthTestResult { + username: user.username, + name: user.name, + base_url: config.gitlab.base_url, + }) +} diff --git a/src/cli/commands/count.rs b/src/cli/commands/count.rs new file mode 100644 index 0000000..09c9481 --- /dev/null +++ b/src/cli/commands/count.rs @@ -0,0 +1,190 @@ +//! Count command - display entity counts from local database. + +use console::style; +use rusqlite::Connection; + +use crate::Config; +use crate::core::db::create_connection; +use crate::core::error::Result; +use crate::core::paths::get_db_path; + +/// Result of count query. +pub struct CountResult { + pub entity: String, + pub count: i64, + pub system_count: Option, // For notes only +} + +/// Run the count command. +pub fn run_count(config: &Config, entity: &str, type_filter: Option<&str>) -> Result { + let db_path = get_db_path(config.storage.db_path.as_deref()); + let conn = create_connection(&db_path)?; + + match entity { + "issues" => count_issues(&conn), + "discussions" => count_discussions(&conn, type_filter), + "notes" => count_notes(&conn, type_filter), + "mrs" => { + // Placeholder for CP2 + Ok(CountResult { + entity: "Merge Requests".to_string(), + count: 0, + system_count: None, + }) + } + _ => Ok(CountResult { + entity: entity.to_string(), + count: 0, + system_count: None, + }), + } +} + +/// Count issues. +fn count_issues(conn: &Connection) -> Result { + let count: i64 = conn.query_row("SELECT COUNT(*) FROM issues", [], |row| row.get(0))?; + + Ok(CountResult { + entity: "Issues".to_string(), + count, + system_count: None, + }) +} + +/// Count discussions with optional noteable type filter. +fn count_discussions(conn: &Connection, type_filter: Option<&str>) -> Result { + let (count, entity_name) = match type_filter { + Some("issue") => { + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM discussions WHERE noteable_type = 'Issue'", + [], + |row| row.get(0), + )?; + (count, "Issue Discussions") + } + Some("mr") => { + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM discussions WHERE noteable_type = 'MergeRequest'", + [], + |row| row.get(0), + )?; + (count, "MR Discussions") + } + _ => { + let count: i64 = + conn.query_row("SELECT COUNT(*) FROM discussions", [], |row| row.get(0))?; + (count, "Discussions") + } + }; + + Ok(CountResult { + entity: entity_name.to_string(), + count, + system_count: None, + }) +} + +/// Count notes with optional noteable type filter. +fn count_notes(conn: &Connection, type_filter: Option<&str>) -> Result { + let (total, system_count, entity_name) = match type_filter { + Some("issue") => { + let (total, system): (i64, i64) = conn.query_row( + "SELECT COUNT(*), COALESCE(SUM(n.is_system), 0) + FROM notes n + JOIN discussions d ON n.discussion_id = d.id + WHERE d.noteable_type = 'Issue'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + (total, system, "Issue Notes") + } + Some("mr") => { + let (total, system): (i64, i64) = conn.query_row( + "SELECT COUNT(*), COALESCE(SUM(n.is_system), 0) + FROM notes n + JOIN discussions d ON n.discussion_id = d.id + WHERE d.noteable_type = 'MergeRequest'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + (total, system, "MR Notes") + } + _ => { + let (total, system): (i64, i64) = conn.query_row( + "SELECT COUNT(*), COALESCE(SUM(is_system), 0) FROM notes", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + (total, system, "Notes") + } + }; + + // Non-system notes count + let non_system = total - system_count; + + Ok(CountResult { + entity: entity_name.to_string(), + count: non_system, + system_count: Some(system_count), + }) +} + +/// Format number with thousands separators. +fn format_number(n: i64) -> String { + let s = n.to_string(); + let chars: Vec = s.chars().collect(); + let mut result = String::new(); + + for (i, c) in chars.iter().enumerate() { + if i > 0 && (chars.len() - i).is_multiple_of(3) { + result.push(','); + } + result.push(*c); + } + + result +} + +/// Print count result. +pub fn print_count(result: &CountResult) { + let count_str = format_number(result.count); + + if let Some(system_count) = result.system_count { + println!( + "{}: {} {}", + style(&result.entity).cyan(), + style(count_str).bold(), + style(format!( + "(excluding {} system)", + format_number(system_count) + )) + .dim() + ); + } else { + println!( + "{}: {}", + style(&result.entity).cyan(), + style(count_str).bold() + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_number_handles_small_numbers() { + assert_eq!(format_number(0), "0"); + assert_eq!(format_number(1), "1"); + assert_eq!(format_number(100), "100"); + assert_eq!(format_number(999), "999"); + } + + #[test] + fn format_number_adds_thousands_separators() { + assert_eq!(format_number(1000), "1,000"); + assert_eq!(format_number(12345), "12,345"); + assert_eq!(format_number(1234567), "1,234,567"); + } +} diff --git a/src/cli/commands/doctor.rs b/src/cli/commands/doctor.rs new file mode 100644 index 0000000..3d44f9b --- /dev/null +++ b/src/cli/commands/doctor.rs @@ -0,0 +1,504 @@ +//! Doctor command - check environment health. + +use console::style; +use serde::Serialize; + +use crate::core::config::Config; +use crate::core::db::{create_connection, get_schema_version, verify_pragmas}; +use crate::core::error::GiError; +use crate::core::paths::{get_config_path, get_db_path}; +use crate::gitlab::GitLabClient; + +#[derive(Debug, Clone, Serialize)] +pub struct CheckResult { + pub status: CheckStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum CheckStatus { + Ok, + Warning, + Error, +} + +#[derive(Debug, Serialize)] +pub struct DoctorResult { + pub success: bool, + pub checks: DoctorChecks, +} + +#[derive(Debug, Serialize)] +pub struct DoctorChecks { + pub config: ConfigCheck, + pub database: DatabaseCheck, + pub gitlab: GitLabCheck, + pub projects: ProjectsCheck, + pub ollama: OllamaCheck, +} + +#[derive(Debug, Serialize)] +pub struct ConfigCheck { + #[serde(flatten)] + pub result: CheckResult, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +#[derive(Debug, Serialize)] +pub struct DatabaseCheck { + #[serde(flatten)] + pub result: CheckResult, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub schema_version: Option, +} + +#[derive(Debug, Serialize)] +pub struct GitLabCheck { + #[serde(flatten)] + pub result: CheckResult, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, +} + +#[derive(Debug, Serialize)] +pub struct ProjectsCheck { + #[serde(flatten)] + pub result: CheckResult, + #[serde(skip_serializing_if = "Option::is_none")] + pub configured: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub resolved: Option, +} + +#[derive(Debug, Serialize)] +pub struct OllamaCheck { + #[serde(flatten)] + pub result: CheckResult, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, +} + +/// Run the doctor command. +pub async fn run_doctor(config_path: Option<&str>) -> DoctorResult { + let config_path_buf = get_config_path(config_path); + let config_path_str = config_path_buf.display().to_string(); + + // Check config + let (config_check, config) = check_config(&config_path_str); + + // Check database + let database_check = check_database(config.as_ref()); + + // Check GitLab + let gitlab_check = check_gitlab(config.as_ref()).await; + + // Check projects + let projects_check = check_projects(config.as_ref()); + + // Check Ollama + let ollama_check = check_ollama(config.as_ref()).await; + + // Success if all required checks pass (ollama is optional) + let success = config_check.result.status == CheckStatus::Ok + && database_check.result.status == CheckStatus::Ok + && gitlab_check.result.status == CheckStatus::Ok + && projects_check.result.status == CheckStatus::Ok; + + DoctorResult { + success, + checks: DoctorChecks { + config: config_check, + database: database_check, + gitlab: gitlab_check, + projects: projects_check, + ollama: ollama_check, + }, + } +} + +fn check_config(config_path: &str) -> (ConfigCheck, Option) { + match Config::load(Some(config_path)) { + Ok(config) => ( + ConfigCheck { + result: CheckResult { + status: CheckStatus::Ok, + message: Some(format!("Loaded from {config_path}")), + }, + path: Some(config_path.to_string()), + }, + Some(config), + ), + Err(GiError::ConfigNotFound { path }) => ( + ConfigCheck { + result: CheckResult { + status: CheckStatus::Error, + message: Some(format!("Config not found at {path}")), + }, + path: Some(path), + }, + None, + ), + Err(e) => ( + ConfigCheck { + result: CheckResult { + status: CheckStatus::Error, + message: Some(e.to_string()), + }, + path: Some(config_path.to_string()), + }, + None, + ), + } +} + +fn check_database(config: Option<&Config>) -> DatabaseCheck { + let Some(config) = config else { + return DatabaseCheck { + result: CheckResult { + status: CheckStatus::Error, + message: Some("Config not loaded".to_string()), + }, + path: None, + schema_version: None, + }; + }; + + let db_path = get_db_path(config.storage.db_path.as_deref()); + + if !db_path.exists() { + return DatabaseCheck { + result: CheckResult { + status: CheckStatus::Error, + message: Some("Database file not found. Run \"gi init\" first.".to_string()), + }, + path: Some(db_path.display().to_string()), + schema_version: None, + }; + } + + match create_connection(&db_path) { + Ok(conn) => { + let schema_version = get_schema_version(&conn); + let (pragmas_ok, issues) = verify_pragmas(&conn); + + if !pragmas_ok { + return DatabaseCheck { + result: CheckResult { + status: CheckStatus::Warning, + message: Some(format!("Pragma issues: {}", issues.join(", "))), + }, + path: Some(db_path.display().to_string()), + schema_version: Some(schema_version), + }; + } + + DatabaseCheck { + result: CheckResult { + status: CheckStatus::Ok, + message: Some(format!("{} (schema v{schema_version})", db_path.display())), + }, + path: Some(db_path.display().to_string()), + schema_version: Some(schema_version), + } + } + Err(e) => DatabaseCheck { + result: CheckResult { + status: CheckStatus::Error, + message: Some(e.to_string()), + }, + path: Some(db_path.display().to_string()), + schema_version: None, + }, + } +} + +async fn check_gitlab(config: Option<&Config>) -> GitLabCheck { + let Some(config) = config else { + return GitLabCheck { + result: CheckResult { + status: CheckStatus::Error, + message: Some("Config not loaded".to_string()), + }, + url: None, + username: None, + }; + }; + + let token = match std::env::var(&config.gitlab.token_env_var) { + Ok(t) if !t.trim().is_empty() => t.trim().to_string(), + _ => { + return GitLabCheck { + result: CheckResult { + status: CheckStatus::Error, + message: Some(format!( + "{} not set in environment", + config.gitlab.token_env_var + )), + }, + url: Some(config.gitlab.base_url.clone()), + username: None, + }; + } + }; + + let client = GitLabClient::new(&config.gitlab.base_url, &token, None); + + match client.get_current_user().await { + Ok(user) => GitLabCheck { + result: CheckResult { + status: CheckStatus::Ok, + message: Some(format!( + "{} (authenticated as @{})", + config.gitlab.base_url, user.username + )), + }, + url: Some(config.gitlab.base_url.clone()), + username: Some(user.username), + }, + Err(GiError::GitLabAuthFailed) => GitLabCheck { + result: CheckResult { + status: CheckStatus::Error, + message: Some("Authentication failed. Check your token.".to_string()), + }, + url: Some(config.gitlab.base_url.clone()), + username: None, + }, + Err(e) => GitLabCheck { + result: CheckResult { + status: CheckStatus::Error, + message: Some(e.to_string()), + }, + url: Some(config.gitlab.base_url.clone()), + username: None, + }, + } +} + +fn check_projects(config: Option<&Config>) -> ProjectsCheck { + let Some(config) = config else { + return ProjectsCheck { + result: CheckResult { + status: CheckStatus::Error, + message: Some("Config not loaded".to_string()), + }, + configured: None, + resolved: None, + }; + }; + + let configured = config.projects.len(); + let db_path = get_db_path(config.storage.db_path.as_deref()); + + if !db_path.exists() { + return ProjectsCheck { + result: CheckResult { + status: CheckStatus::Error, + message: Some("Database not found. Run \"gi init\" first.".to_string()), + }, + configured: Some(configured), + resolved: Some(0), + }; + } + + match create_connection(&db_path) { + Ok(conn) => { + let resolved: i64 = conn + .query_row("SELECT COUNT(*) FROM projects", [], |row| row.get(0)) + .unwrap_or(0); + + if resolved == 0 { + return ProjectsCheck { + result: CheckResult { + status: CheckStatus::Error, + message: Some(format!( + "{configured} configured, 0 resolved. Run \"gi init\" to resolve projects." + )), + }, + configured: Some(configured), + resolved: Some(resolved), + }; + } + + if resolved != configured as i64 { + return ProjectsCheck { + result: CheckResult { + status: CheckStatus::Warning, + message: Some(format!( + "{configured} configured, {resolved} resolved (mismatch)" + )), + }, + configured: Some(configured), + resolved: Some(resolved), + }; + } + + ProjectsCheck { + result: CheckResult { + status: CheckStatus::Ok, + message: Some(format!("{configured} configured, {resolved} resolved")), + }, + configured: Some(configured), + resolved: Some(resolved), + } + } + Err(e) => ProjectsCheck { + result: CheckResult { + status: CheckStatus::Error, + message: Some(e.to_string()), + }, + configured: Some(configured), + resolved: None, + }, + } +} + +async fn check_ollama(config: Option<&Config>) -> OllamaCheck { + let Some(config) = config else { + return OllamaCheck { + result: CheckResult { + status: CheckStatus::Warning, + message: Some("Config not loaded".to_string()), + }, + url: None, + model: None, + }; + }; + + let base_url = &config.embedding.base_url; + let model = &config.embedding.model; + + // Short timeout for Ollama check + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(2)) + .build() + .unwrap(); + + match client.get(format!("{base_url}/api/tags")).send().await { + Ok(response) if response.status().is_success() => { + #[derive(serde::Deserialize)] + struct TagsResponse { + models: Option>, + } + #[derive(serde::Deserialize)] + struct ModelInfo { + name: String, + } + + match response.json::().await { + Ok(data) => { + let models = data.models.unwrap_or_default(); + let model_names: Vec<&str> = models + .iter() + .map(|m| m.name.split(':').next().unwrap_or(&m.name)) + .collect(); + + if !model_names.iter().any(|m| *m == model) { + return OllamaCheck { + result: CheckResult { + status: CheckStatus::Warning, + message: Some(format!( + "Model \"{model}\" not found. Available: {}", + if model_names.is_empty() { + "none".to_string() + } else { + model_names.join(", ") + } + )), + }, + url: Some(base_url.clone()), + model: Some(model.clone()), + }; + } + + OllamaCheck { + result: CheckResult { + status: CheckStatus::Ok, + message: Some(format!("{base_url} (model: {model})")), + }, + url: Some(base_url.clone()), + model: Some(model.clone()), + } + } + Err(_) => OllamaCheck { + result: CheckResult { + status: CheckStatus::Warning, + message: Some("Could not parse Ollama response".to_string()), + }, + url: Some(base_url.clone()), + model: Some(model.clone()), + }, + } + } + Ok(response) => OllamaCheck { + result: CheckResult { + status: CheckStatus::Warning, + message: Some(format!("Ollama responded with {}", response.status())), + }, + url: Some(base_url.clone()), + model: Some(model.clone()), + }, + Err(_) => OllamaCheck { + result: CheckResult { + status: CheckStatus::Warning, + message: Some("Not running (semantic search unavailable)".to_string()), + }, + url: Some(base_url.clone()), + model: Some(model.clone()), + }, + } +} + +/// Format and print doctor results to console. +pub fn print_doctor_results(result: &DoctorResult) { + println!("\ngi doctor\n"); + + print_check("Config", &result.checks.config.result); + print_check("Database", &result.checks.database.result); + print_check("GitLab", &result.checks.gitlab.result); + print_check("Projects", &result.checks.projects.result); + print_check("Ollama", &result.checks.ollama.result); + + println!(); + + if result.success { + let ollama_ok = result.checks.ollama.result.status == CheckStatus::Ok; + if ollama_ok { + println!("{}", style("Status: Ready").green()); + } else { + println!( + "{} {}", + style("Status: Ready").green(), + style("(lexical search available, semantic search requires Ollama)").yellow() + ); + } + } else { + println!("{}", style("Status: Not ready").red()); + } + println!(); +} + +fn print_check(name: &str, result: &CheckResult) { + let symbol = match result.status { + CheckStatus::Ok => style("✓").green(), + CheckStatus::Warning => style("⚠").yellow(), + CheckStatus::Error => style("✗").red(), + }; + + let message = result.message.as_deref().unwrap_or(""); + let message_styled = match result.status { + CheckStatus::Ok => message.to_string(), + CheckStatus::Warning => style(message).yellow().to_string(), + CheckStatus::Error => style(message).red().to_string(), + }; + + println!(" {symbol} {:<10} {message_styled}", name); +} diff --git a/src/cli/commands/ingest.rs b/src/cli/commands/ingest.rs new file mode 100644 index 0000000..b7e309a --- /dev/null +++ b/src/cli/commands/ingest.rs @@ -0,0 +1,266 @@ +//! Ingest command - fetch data from GitLab. + +use console::style; +use indicatif::{ProgressBar, ProgressStyle}; +use rusqlite::Connection; + +use crate::Config; +use crate::core::db::create_connection; +use crate::core::error::{GiError, Result}; +use crate::core::lock::{AppLock, LockOptions}; +use crate::core::paths::get_db_path; +use crate::gitlab::GitLabClient; +use crate::ingestion::{IngestProjectResult, ProgressEvent, ingest_project_issues_with_progress}; + +/// Result of ingest command for display. +pub struct IngestResult { + pub projects_synced: usize, + pub issues_fetched: usize, + pub issues_upserted: usize, + pub labels_created: usize, + pub discussions_fetched: usize, + pub notes_upserted: usize, + pub issues_synced_discussions: usize, + pub issues_skipped_discussion_sync: usize, +} + +/// Run the ingest command. +pub async fn run_ingest( + config: &Config, + resource_type: &str, + project_filter: Option<&str>, + force: bool, + full: bool, +) -> Result { + // Only issues supported in CP1 + if resource_type != "issues" { + return Err(GiError::Other(format!( + "Resource type '{}' not yet implemented. Only 'issues' is supported.", + resource_type + ))); + } + + // Get database path and create connection + let db_path = get_db_path(config.storage.db_path.as_deref()); + let conn = create_connection(&db_path)?; + + // Acquire single-flight lock + let lock_conn = create_connection(&db_path)?; + let mut lock = AppLock::new( + lock_conn, + LockOptions { + name: "sync".to_string(), + stale_lock_minutes: config.sync.stale_lock_minutes, + heartbeat_interval_seconds: config.sync.heartbeat_interval_seconds, + }, + ); + lock.acquire(force)?; + + // Get token from environment + let token = std::env::var(&config.gitlab.token_env_var).map_err(|_| GiError::TokenNotSet { + env_var: config.gitlab.token_env_var.clone(), + })?; + + // Create GitLab client + let client = GitLabClient::new(&config.gitlab.base_url, &token, None); + + // Get projects to sync + let projects = get_projects_to_sync(&conn, &config.projects, project_filter)?; + + // If --full flag is set, reset sync cursors for a complete re-fetch + if full { + println!( + "{}", + style("Full sync: resetting cursors to fetch all data...").yellow() + ); + for (local_project_id, _, path) in &projects { + conn.execute( + "DELETE FROM sync_cursors WHERE project_id = ? AND resource_type = ?", + (*local_project_id, resource_type), + )?; + tracing::info!(project = %path, "Reset sync cursor for full re-fetch"); + } + } + + if projects.is_empty() { + if let Some(filter) = project_filter { + return Err(GiError::Other(format!( + "Project '{}' not found in configuration", + filter + ))); + } + return Err(GiError::Other( + "No projects configured. Run 'gi init' first.".to_string(), + )); + } + + let mut total = IngestResult { + projects_synced: 0, + issues_fetched: 0, + issues_upserted: 0, + labels_created: 0, + discussions_fetched: 0, + notes_upserted: 0, + issues_synced_discussions: 0, + issues_skipped_discussion_sync: 0, + }; + + println!("{}", style("Ingesting issues...").blue()); + println!(); + + // Sync each project + for (local_project_id, gitlab_project_id, path) in &projects { + // Show spinner while fetching issues + let spinner = ProgressBar::new_spinner(); + spinner.set_style( + ProgressStyle::default_spinner() + .template("{spinner:.blue} {msg}") + .unwrap(), + ); + spinner.set_message(format!("Fetching issues from {path}...")); + spinner.enable_steady_tick(std::time::Duration::from_millis(100)); + + // Progress bar for discussion sync (hidden until needed) + let disc_bar = ProgressBar::new(0); + disc_bar.set_style( + ProgressStyle::default_bar() + .template(" {spinner:.blue} Syncing discussions [{bar:30.cyan/dim}] {pos}/{len}") + .unwrap() + .progress_chars("=> "), + ); + + // Create progress callback + let spinner_clone = spinner.clone(); + let disc_bar_clone = disc_bar.clone(); + let progress_callback: crate::ingestion::ProgressCallback = + Box::new(move |event: ProgressEvent| match event { + ProgressEvent::DiscussionSyncStarted { total } => { + spinner_clone.finish_and_clear(); + disc_bar_clone.set_length(total as u64); + disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100)); + } + ProgressEvent::DiscussionSynced { current, total: _ } => { + disc_bar_clone.set_position(current as u64); + } + ProgressEvent::DiscussionSyncComplete => { + disc_bar_clone.finish_and_clear(); + } + _ => {} + }); + + let result = ingest_project_issues_with_progress( + &conn, + &client, + config, + *local_project_id, + *gitlab_project_id, + Some(progress_callback), + ) + .await?; + + spinner.finish_and_clear(); + disc_bar.finish_and_clear(); + + // Print per-project summary + print_project_summary(path, &result); + + // Aggregate totals + total.projects_synced += 1; + total.issues_fetched += result.issues_fetched; + total.issues_upserted += result.issues_upserted; + total.labels_created += result.labels_created; + total.discussions_fetched += result.discussions_fetched; + total.notes_upserted += result.notes_upserted; + total.issues_synced_discussions += result.issues_synced_discussions; + total.issues_skipped_discussion_sync += result.issues_skipped_discussion_sync; + } + + // Lock is released on drop + Ok(total) +} + +/// Get projects to sync from database, optionally filtered. +fn get_projects_to_sync( + conn: &Connection, + configured_projects: &[crate::core::config::ProjectConfig], + filter: Option<&str>, +) -> Result> { + let mut projects = Vec::new(); + + for project_config in configured_projects { + if let Some(filter_path) = filter + && !project_config.path.contains(filter_path) + { + continue; + } + + // Get project from database + let result: Option<(i64, i64)> = conn + .query_row( + "SELECT id, gitlab_project_id FROM projects WHERE path_with_namespace = ?", + [&project_config.path], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .ok(); + + if let Some((local_id, gitlab_id)) = result { + projects.push((local_id, gitlab_id, project_config.path.clone())); + } + } + + Ok(projects) +} + +/// Print summary for a single project. +fn print_project_summary(path: &str, result: &IngestProjectResult) { + let labels_str = if result.labels_created > 0 { + format!(", {} new labels", result.labels_created) + } else { + String::new() + }; + + println!( + " {}: {} issues fetched{}", + style(path).cyan(), + result.issues_upserted, + labels_str + ); + + if result.issues_synced_discussions > 0 { + println!( + " {} issues -> {} discussions, {} notes", + result.issues_synced_discussions, result.discussions_fetched, result.notes_upserted + ); + } + + if result.issues_skipped_discussion_sync > 0 { + println!( + " {} unchanged issues (discussion sync skipped)", + style(result.issues_skipped_discussion_sync).dim() + ); + } +} + +/// Print final summary. +pub fn print_ingest_summary(result: &IngestResult) { + println!(); + println!( + "{}", + style(format!( + "Total: {} issues, {} discussions, {} notes", + result.issues_upserted, result.discussions_fetched, result.notes_upserted + )) + .green() + ); + + if result.issues_skipped_discussion_sync > 0 { + println!( + "{}", + style(format!( + "Skipped discussion sync for {} unchanged issues.", + result.issues_skipped_discussion_sync + )) + .dim() + ); + } +} diff --git a/src/cli/commands/init.rs b/src/cli/commands/init.rs new file mode 100644 index 0000000..a142b9b --- /dev/null +++ b/src/cli/commands/init.rs @@ -0,0 +1,172 @@ +//! Init command - initialize configuration and database. + +use std::fs; + +use crate::core::config::{MinimalConfig, MinimalGitLabConfig, ProjectConfig}; +use crate::core::db::{create_connection, run_migrations}; +use crate::core::error::{GiError, Result}; +use crate::core::paths::{get_config_path, get_data_dir}; +use crate::gitlab::{GitLabClient, GitLabProject}; + +/// Input data for init command. +pub struct InitInputs { + pub gitlab_url: String, + pub token_env_var: String, + pub project_paths: Vec, +} + +/// Options for init command. +pub struct InitOptions { + pub config_path: Option, + pub force: bool, + pub non_interactive: bool, +} + +/// Result of successful init. +pub struct InitResult { + pub config_path: String, + pub data_dir: String, + pub user: UserInfo, + pub projects: Vec, +} + +pub struct UserInfo { + pub username: String, + pub name: String, +} + +pub struct ProjectInfo { + pub path: String, + pub name: String, +} + +/// Run the init command programmatically. +pub async fn run_init(inputs: InitInputs, options: InitOptions) -> Result { + let config_path = get_config_path(options.config_path.as_deref()); + let data_dir = get_data_dir(); + + // 1. Check if config exists + if config_path.exists() { + if options.non_interactive { + return Err(GiError::Other(format!( + "Config file exists at {}. Cannot proceed in non-interactive mode.", + config_path.display() + ))); + } + + if !options.force { + return Err(GiError::Other( + "User cancelled config overwrite.".to_string(), + )); + } + } + + // 2. Validate GitLab URL format + if url::Url::parse(&inputs.gitlab_url).is_err() { + return Err(GiError::Other(format!( + "Invalid GitLab URL: {}", + inputs.gitlab_url + ))); + } + + // 3. Check token is set in environment + let token = std::env::var(&inputs.token_env_var).map_err(|_| GiError::TokenNotSet { + env_var: inputs.token_env_var.clone(), + })?; + + // 4. Create GitLab client and test authentication + let client = GitLabClient::new(&inputs.gitlab_url, &token, None); + + let gitlab_user = client.get_current_user().await.map_err(|e| { + if matches!(e, GiError::GitLabAuthFailed) { + GiError::Other(format!("Authentication failed for {}", inputs.gitlab_url)) + } else { + e + } + })?; + + let user = UserInfo { + username: gitlab_user.username, + name: gitlab_user.name, + }; + + // 5. Validate each project path + let mut validated_projects: Vec<(ProjectInfo, GitLabProject)> = Vec::new(); + + for project_path in &inputs.project_paths { + let project = client.get_project(project_path).await.map_err(|e| { + if matches!(e, GiError::GitLabNotFound { .. }) { + GiError::Other(format!("Project not found: {project_path}")) + } else { + e + } + })?; + + validated_projects.push(( + ProjectInfo { + path: project_path.clone(), + name: project.name.clone().unwrap_or_else(|| { + project_path + .split('/') + .next_back() + .unwrap_or(project_path) + .to_string() + }), + }, + project, + )); + } + + // 6. All validations passed - now write config and setup DB + + // Create config directory if needed + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent)?; + } + + // Write minimal config (rely on serde defaults) + let config = MinimalConfig { + gitlab: MinimalGitLabConfig { + base_url: inputs.gitlab_url, + token_env_var: inputs.token_env_var, + }, + projects: inputs + .project_paths + .iter() + .map(|p| ProjectConfig { path: p.clone() }) + .collect(), + }; + + let config_json = serde_json::to_string_pretty(&config)?; + fs::write(&config_path, format!("{config_json}\n"))?; + + // 7. Create data directory and initialize database + fs::create_dir_all(&data_dir)?; + + let db_path = data_dir.join("gi.db"); + let conn = create_connection(&db_path)?; + + // Run embedded migrations + run_migrations(&conn)?; + + // 8. Insert validated projects + for (_, gitlab_project) in &validated_projects { + conn.execute( + "INSERT INTO projects (gitlab_project_id, path_with_namespace, default_branch, web_url) + VALUES (?, ?, ?, ?)", + ( + gitlab_project.id, + &gitlab_project.path_with_namespace, + &gitlab_project.default_branch, + &gitlab_project.web_url, + ), + )?; + } + + Ok(InitResult { + config_path: config_path.display().to_string(), + data_dir: data_dir.display().to_string(), + user, + projects: validated_projects.into_iter().map(|(p, _)| p).collect(), + }) +} diff --git a/src/cli/commands/list.rs b/src/cli/commands/list.rs new file mode 100644 index 0000000..9bb2198 --- /dev/null +++ b/src/cli/commands/list.rs @@ -0,0 +1,517 @@ +//! List command - display issues/MRs from local database. + +use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table}; +use rusqlite::Connection; +use serde::Serialize; + +use crate::Config; +use crate::core::db::create_connection; +use crate::core::error::Result; +use crate::core::paths::get_db_path; +use crate::core::time::{ms_to_iso, now_ms, parse_since}; + +/// Issue row for display. +#[derive(Debug, Serialize)] +pub struct IssueListRow { + pub iid: i64, + pub title: String, + pub state: String, + pub author_username: String, + pub created_at: i64, + pub updated_at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub web_url: Option, + pub project_path: String, + pub labels: Vec, + pub assignees: Vec, + pub discussion_count: i64, + pub unresolved_count: i64, +} + +/// Serializable version for JSON output. +#[derive(Serialize)] +pub struct IssueListRowJson { + pub iid: i64, + pub title: String, + pub state: String, + pub author_username: String, + pub labels: Vec, + pub assignees: Vec, + pub discussion_count: i64, + pub unresolved_count: i64, + pub created_at_iso: String, + pub updated_at_iso: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub web_url: Option, + pub project_path: String, +} + +impl From<&IssueListRow> for IssueListRowJson { + fn from(row: &IssueListRow) -> Self { + Self { + iid: row.iid, + title: row.title.clone(), + state: row.state.clone(), + author_username: row.author_username.clone(), + labels: row.labels.clone(), + assignees: row.assignees.clone(), + discussion_count: row.discussion_count, + unresolved_count: row.unresolved_count, + created_at_iso: ms_to_iso(row.created_at), + updated_at_iso: ms_to_iso(row.updated_at), + web_url: row.web_url.clone(), + project_path: row.project_path.clone(), + } + } +} + +/// Result of list query. +#[derive(Serialize)] +pub struct ListResult { + pub issues: Vec, + pub total_count: usize, +} + +/// JSON output structure. +#[derive(Serialize)] +pub struct ListResultJson { + pub issues: Vec, + pub total_count: usize, + pub showing: usize, +} + +impl From<&ListResult> for ListResultJson { + fn from(result: &ListResult) -> Self { + Self { + issues: result.issues.iter().map(IssueListRowJson::from).collect(), + total_count: result.total_count, + showing: result.issues.len(), + } + } +} + +/// Filter options for list query. +pub struct ListFilters<'a> { + pub limit: usize, + pub project: Option<&'a str>, + pub state: Option<&'a str>, + pub author: Option<&'a str>, + pub assignee: Option<&'a str>, + pub labels: Option<&'a [String]>, + pub milestone: Option<&'a str>, + pub since: Option<&'a str>, + pub due_before: Option<&'a str>, + pub has_due_date: bool, + pub sort: &'a str, + pub order: &'a str, +} + +/// Run the list issues command. +pub fn run_list_issues(config: &Config, filters: ListFilters) -> Result { + let db_path = get_db_path(config.storage.db_path.as_deref()); + let conn = create_connection(&db_path)?; + + let result = query_issues(&conn, &filters)?; + Ok(result) +} + +/// Query issues from database with enriched data. +fn query_issues(conn: &Connection, filters: &ListFilters) -> Result { + // Build WHERE clause + let mut where_clauses = Vec::new(); + let mut params: Vec> = Vec::new(); + + if let Some(project) = filters.project { + where_clauses.push("p.path_with_namespace LIKE ?"); + params.push(Box::new(format!("%{project}%"))); + } + + if let Some(state) = filters.state { + if state != "all" { + where_clauses.push("i.state = ?"); + params.push(Box::new(state.to_string())); + } + } + + // Handle author filter (strip leading @ if present) + if let Some(author) = filters.author { + let username = author.strip_prefix('@').unwrap_or(author); + where_clauses.push("i.author_username = ?"); + params.push(Box::new(username.to_string())); + } + + // Handle assignee filter (strip leading @ if present) + if let Some(assignee) = filters.assignee { + let username = assignee.strip_prefix('@').unwrap_or(assignee); + where_clauses.push( + "EXISTS (SELECT 1 FROM issue_assignees ia + WHERE ia.issue_id = i.id AND ia.username = ?)", + ); + params.push(Box::new(username.to_string())); + } + + // Handle since filter + if let Some(since_str) = filters.since { + if let Some(cutoff_ms) = parse_since(since_str) { + where_clauses.push("i.updated_at >= ?"); + params.push(Box::new(cutoff_ms)); + } + } + + // Handle label filters (AND logic - all labels must be present) + if let Some(labels) = filters.labels { + for label in labels { + where_clauses.push( + "EXISTS (SELECT 1 FROM issue_labels il + JOIN labels l ON il.label_id = l.id + WHERE il.issue_id = i.id AND l.name = ?)", + ); + params.push(Box::new(label.clone())); + } + } + + // Handle milestone filter + if let Some(milestone) = filters.milestone { + where_clauses.push("i.milestone_title = ?"); + params.push(Box::new(milestone.to_string())); + } + + // Handle due_before filter + if let Some(due_before) = filters.due_before { + where_clauses.push("i.due_date IS NOT NULL AND i.due_date <= ?"); + params.push(Box::new(due_before.to_string())); + } + + // Handle has_due_date filter + if filters.has_due_date { + where_clauses.push("i.due_date IS NOT NULL"); + } + + let where_sql = if where_clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", where_clauses.join(" AND ")) + }; + + // Get total count + let count_sql = format!( + "SELECT COUNT(*) FROM issues i + JOIN projects p ON i.project_id = p.id + {where_sql}" + ); + + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let total_count: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?; + let total_count = total_count as usize; + + // Build ORDER BY + let sort_column = match filters.sort { + "created" => "i.created_at", + "iid" => "i.iid", + _ => "i.updated_at", // default + }; + let order = if filters.order == "asc" { "ASC" } else { "DESC" }; + + // Get issues with enriched data + let query_sql = format!( + "SELECT + i.iid, + i.title, + i.state, + i.author_username, + i.created_at, + i.updated_at, + i.web_url, + p.path_with_namespace, + (SELECT GROUP_CONCAT(l.name, ',') + FROM issue_labels il + JOIN labels l ON il.label_id = l.id + WHERE il.issue_id = i.id) AS labels_csv, + (SELECT GROUP_CONCAT(ia.username, ',') + FROM issue_assignees ia + WHERE ia.issue_id = i.id) AS assignees_csv, + COALESCE(d.total, 0) AS discussion_count, + COALESCE(d.unresolved, 0) AS unresolved_count + FROM issues i + JOIN projects p ON i.project_id = p.id + LEFT JOIN ( + SELECT issue_id, + COUNT(*) as total, + SUM(CASE WHEN resolvable = 1 AND resolved = 0 THEN 1 ELSE 0 END) as unresolved + FROM discussions + WHERE issue_id IS NOT NULL + GROUP BY issue_id + ) d ON d.issue_id = i.id + {where_sql} + ORDER BY {sort_column} {order} + LIMIT ?" + ); + + params.push(Box::new(filters.limit as i64)); + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let mut stmt = conn.prepare(&query_sql)?; + let issues = stmt + .query_map(param_refs.as_slice(), |row| { + let labels_csv: Option = row.get(8)?; + let labels = labels_csv + .map(|s| s.split(',').map(String::from).collect()) + .unwrap_or_default(); + + let assignees_csv: Option = row.get(9)?; + let assignees = assignees_csv + .map(|s| s.split(',').map(String::from).collect()) + .unwrap_or_default(); + + Ok(IssueListRow { + iid: row.get(0)?, + title: row.get(1)?, + state: row.get(2)?, + author_username: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + web_url: row.get(6)?, + project_path: row.get(7)?, + labels, + assignees, + discussion_count: row.get(10)?, + unresolved_count: row.get(11)?, + }) + })? + .filter_map(|r| r.ok()) + .collect(); + + Ok(ListResult { + issues, + total_count, + }) +} + +/// Format relative time from ms epoch. +fn format_relative_time(ms_epoch: i64) -> String { + let now = now_ms(); + let diff = now - ms_epoch; + + match diff { + d if d < 60_000 => "just now".to_string(), + d if d < 3_600_000 => format!("{} min ago", d / 60_000), + d if d < 86_400_000 => format!("{} hours ago", d / 3_600_000), + d if d < 604_800_000 => format!("{} days ago", d / 86_400_000), + d if d < 2_592_000_000 => format!("{} weeks ago", d / 604_800_000), + _ => format!("{} months ago", diff / 2_592_000_000), + } +} + +/// Truncate string to max width with ellipsis. +fn truncate_with_ellipsis(s: &str, max_width: usize) -> String { + if s.chars().count() <= max_width { + s.to_string() + } else { + let truncated: String = s.chars().take(max_width.saturating_sub(3)).collect(); + format!("{truncated}...") + } +} + +/// Format labels for display: [bug, urgent +2] +fn format_labels(labels: &[String], max_shown: usize) -> String { + if labels.is_empty() { + return String::new(); + } + + let shown: Vec<&str> = labels.iter().take(max_shown).map(|s| s.as_str()).collect(); + let overflow = labels.len().saturating_sub(max_shown); + + if overflow > 0 { + format!("[{} +{}]", shown.join(", "), overflow) + } else { + format!("[{}]", shown.join(", ")) + } +} + +/// Format assignees for display: @user1, @user2 +1 +fn format_assignees(assignees: &[String]) -> String { + if assignees.is_empty() { + return "-".to_string(); + } + + let max_shown = 2; + let shown: Vec = assignees + .iter() + .take(max_shown) + .map(|s| format!("@{}", truncate_with_ellipsis(s, 10))) + .collect(); + let overflow = assignees.len().saturating_sub(max_shown); + + if overflow > 0 { + format!("{} +{}", shown.join(", "), overflow) + } else { + shown.join(", ") + } +} + +/// Format discussion count: "3/1!" (3 total, 1 unresolved) +fn format_discussions(total: i64, unresolved: i64) -> String { + if total == 0 { + return String::new(); + } + + if unresolved > 0 { + format!("{total}/{unresolved}!") + } else { + format!("{total}") + } +} + +/// Print issues list as a formatted table. +pub fn print_list_issues(result: &ListResult) { + if result.issues.is_empty() { + println!("No issues found."); + return; + } + + println!( + "Issues (showing {} of {})\n", + result.issues.len(), + result.total_count + ); + + let mut table = Table::new(); + table + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(vec![ + Cell::new("IID").add_attribute(Attribute::Bold), + Cell::new("Title").add_attribute(Attribute::Bold), + Cell::new("State").add_attribute(Attribute::Bold), + Cell::new("Assignee").add_attribute(Attribute::Bold), + Cell::new("Labels").add_attribute(Attribute::Bold), + Cell::new("Disc").add_attribute(Attribute::Bold), + Cell::new("Updated").add_attribute(Attribute::Bold), + ]); + + for issue in &result.issues { + let title = truncate_with_ellipsis(&issue.title, 45); + let relative_time = format_relative_time(issue.updated_at); + let labels = format_labels(&issue.labels, 2); + let assignee = format_assignees(&issue.assignees); + let discussions = format_discussions(issue.discussion_count, issue.unresolved_count); + + let state_cell = if issue.state == "opened" { + Cell::new(&issue.state).fg(Color::Green) + } else { + Cell::new(&issue.state).fg(Color::DarkGrey) + }; + + table.add_row(vec![ + Cell::new(format!("#{}", issue.iid)).fg(Color::Cyan), + Cell::new(title), + state_cell, + Cell::new(assignee).fg(Color::Magenta), + Cell::new(labels).fg(Color::Yellow), + Cell::new(discussions), + Cell::new(relative_time).fg(Color::DarkGrey), + ]); + } + + println!("{table}"); +} + +/// Print issues list as JSON. +pub fn print_list_issues_json(result: &ListResult) { + let json_result = ListResultJson::from(result); + match serde_json::to_string_pretty(&json_result) { + Ok(json) => println!("{json}"), + Err(e) => eprintln!("Error serializing to JSON: {e}"), + } +} + +/// Open issue in browser. Returns the URL that was opened. +pub fn open_issue_in_browser(result: &ListResult) -> Option { + let first_issue = result.issues.first()?; + let url = first_issue.web_url.as_ref()?; + + match open::that(url) { + Ok(()) => { + println!("Opened: {url}"); + Some(url.clone()) + } + Err(e) => { + eprintln!("Failed to open browser: {e}"); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_leaves_short_strings_alone() { + assert_eq!(truncate_with_ellipsis("short", 10), "short"); + } + + #[test] + fn truncate_adds_ellipsis_to_long_strings() { + assert_eq!( + truncate_with_ellipsis("this is a very long title", 15), + "this is a ve..." + ); + } + + #[test] + fn truncate_handles_exact_length() { + assert_eq!(truncate_with_ellipsis("exactly10!", 10), "exactly10!"); + } + + #[test] + fn relative_time_formats_correctly() { + let now = now_ms(); + + assert_eq!(format_relative_time(now - 30_000), "just now"); // 30s ago + assert_eq!(format_relative_time(now - 120_000), "2 min ago"); // 2 min ago + assert_eq!(format_relative_time(now - 7_200_000), "2 hours ago"); // 2 hours ago + assert_eq!(format_relative_time(now - 172_800_000), "2 days ago"); // 2 days ago + } + + #[test] + fn format_labels_empty() { + assert_eq!(format_labels(&[], 2), ""); + } + + #[test] + fn format_labels_single() { + assert_eq!(format_labels(&["bug".to_string()], 2), "[bug]"); + } + + #[test] + fn format_labels_multiple() { + let labels = vec!["bug".to_string(), "urgent".to_string()]; + assert_eq!(format_labels(&labels, 2), "[bug, urgent]"); + } + + #[test] + fn format_labels_overflow() { + let labels = vec![ + "bug".to_string(), + "urgent".to_string(), + "wip".to_string(), + "blocked".to_string(), + ]; + assert_eq!(format_labels(&labels, 2), "[bug, urgent +2]"); + } + + #[test] + fn format_discussions_empty() { + assert_eq!(format_discussions(0, 0), ""); + } + + #[test] + fn format_discussions_no_unresolved() { + assert_eq!(format_discussions(5, 0), "5"); + } + + #[test] + fn format_discussions_with_unresolved() { + assert_eq!(format_discussions(5, 2), "5/2!"); + } +} diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs new file mode 100644 index 0000000..db0ecb6 --- /dev/null +++ b/src/cli/commands/mod.rs @@ -0,0 +1,21 @@ +//! CLI command implementations. + +pub mod auth_test; +pub mod count; +pub mod doctor; +pub mod ingest; +pub mod init; +pub mod list; +pub mod show; +pub mod sync_status; + +pub use auth_test::run_auth_test; +pub use count::{print_count, run_count}; +pub use doctor::{print_doctor_results, run_doctor}; +pub use ingest::{print_ingest_summary, run_ingest}; +pub use init::{InitInputs, InitOptions, InitResult, run_init}; +pub use list::{ + ListFilters, open_issue_in_browser, print_list_issues, print_list_issues_json, run_list_issues, +}; +pub use show::{print_show_issue, run_show_issue}; +pub use sync_status::{print_sync_status, run_sync_status}; diff --git a/src/cli/commands/show.rs b/src/cli/commands/show.rs new file mode 100644 index 0000000..1898195 --- /dev/null +++ b/src/cli/commands/show.rs @@ -0,0 +1,392 @@ +//! Show command - display detailed entity information from local database. + +use console::style; +use rusqlite::Connection; + +use crate::Config; +use crate::core::db::create_connection; +use crate::core::error::{GiError, Result}; +use crate::core::paths::get_db_path; +use crate::core::time::ms_to_iso; + +/// Issue metadata for display. +#[derive(Debug)] +pub struct IssueDetail { + pub id: i64, + pub iid: i64, + pub title: String, + pub description: Option, + pub state: String, + pub author_username: String, + pub created_at: i64, + pub updated_at: i64, + pub web_url: Option, + pub project_path: String, + pub labels: Vec, + pub discussions: Vec, +} + +/// Discussion detail for display. +#[derive(Debug)] +pub struct DiscussionDetail { + pub notes: Vec, + pub individual_note: bool, +} + +/// Note detail for display. +#[derive(Debug)] +pub struct NoteDetail { + pub author_username: String, + pub body: String, + pub created_at: i64, + pub is_system: bool, +} + +/// Run the show issue command. +pub fn run_show_issue( + config: &Config, + iid: i64, + project_filter: Option<&str>, +) -> Result { + let db_path = get_db_path(config.storage.db_path.as_deref()); + let conn = create_connection(&db_path)?; + + // Find the issue + let issue = find_issue(&conn, iid, project_filter)?; + + // Load labels + let labels = get_issue_labels(&conn, issue.id)?; + + // Load discussions with notes + let discussions = get_issue_discussions(&conn, issue.id)?; + + Ok(IssueDetail { + id: issue.id, + iid: issue.iid, + title: issue.title, + description: issue.description, + state: issue.state, + author_username: issue.author_username, + created_at: issue.created_at, + updated_at: issue.updated_at, + web_url: issue.web_url, + project_path: issue.project_path, + labels, + discussions, + }) +} + +/// Internal issue row from query. +struct IssueRow { + id: i64, + iid: i64, + title: String, + description: Option, + state: String, + author_username: String, + created_at: i64, + updated_at: i64, + web_url: Option, + project_path: String, +} + +/// Find issue by iid, optionally filtered by project. +fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result { + let (sql, params): (&str, Vec>) = match project_filter { + Some(project) => ( + "SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username, + i.created_at, i.updated_at, i.web_url, p.path_with_namespace + FROM issues i + JOIN projects p ON i.project_id = p.id + WHERE i.iid = ? AND p.path_with_namespace LIKE ?", + vec![Box::new(iid), Box::new(format!("%{}%", project))], + ), + None => ( + "SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username, + i.created_at, i.updated_at, i.web_url, p.path_with_namespace + FROM issues i + JOIN projects p ON i.project_id = p.id + WHERE i.iid = ?", + vec![Box::new(iid)], + ), + }; + + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let mut stmt = conn.prepare(sql)?; + let issues: Vec = stmt + .query_map(param_refs.as_slice(), |row| { + Ok(IssueRow { + id: row.get(0)?, + iid: row.get(1)?, + title: row.get(2)?, + description: row.get(3)?, + state: row.get(4)?, + author_username: row.get(5)?, + created_at: row.get(6)?, + updated_at: row.get(7)?, + web_url: row.get(8)?, + project_path: row.get(9)?, + }) + })? + .filter_map(|r| r.ok()) + .collect(); + + match issues.len() { + 0 => Err(GiError::NotFound(format!("Issue #{} not found", iid))), + 1 => Ok(issues.into_iter().next().unwrap()), + _ => { + let projects: Vec = issues.iter().map(|i| i.project_path.clone()).collect(); + Err(GiError::Ambiguous(format!( + "Issue #{} exists in multiple projects: {}. Use --project to specify.", + iid, + projects.join(", ") + ))) + } + } +} + +/// Get labels for an issue. +fn get_issue_labels(conn: &Connection, issue_id: i64) -> Result> { + let mut stmt = conn.prepare( + "SELECT l.name FROM labels l + JOIN issue_labels il ON l.id = il.label_id + WHERE il.issue_id = ? + ORDER BY l.name", + )?; + + let labels = stmt + .query_map([issue_id], |row| row.get(0))? + .filter_map(|r| r.ok()) + .collect(); + + Ok(labels) +} + +/// Get discussions with notes for an issue. +fn get_issue_discussions(conn: &Connection, issue_id: i64) -> Result> { + // First get all discussions + let mut disc_stmt = conn.prepare( + "SELECT id, individual_note FROM discussions + WHERE issue_id = ? + ORDER BY first_note_at", + )?; + + let disc_rows: Vec<(i64, bool)> = disc_stmt + .query_map([issue_id], |row| { + let individual: i64 = row.get(1)?; + Ok((row.get(0)?, individual == 1)) + })? + .filter_map(|r| r.ok()) + .collect(); + + // Then get notes for each discussion + let mut note_stmt = conn.prepare( + "SELECT author_username, body, created_at, is_system + FROM notes + WHERE discussion_id = ? + ORDER BY position", + )?; + + let mut discussions = Vec::new(); + for (disc_id, individual_note) in disc_rows { + let notes: Vec = note_stmt + .query_map([disc_id], |row| { + let is_system: i64 = row.get(3)?; + Ok(NoteDetail { + author_username: row.get(0)?, + body: row.get(1)?, + created_at: row.get(2)?, + is_system: is_system == 1, + }) + })? + .filter_map(|r| r.ok()) + .collect(); + + // Filter out discussions with only system notes + let has_user_notes = notes.iter().any(|n| !n.is_system); + if has_user_notes || notes.is_empty() { + discussions.push(DiscussionDetail { + notes, + individual_note, + }); + } + } + + Ok(discussions) +} + +/// Format date from ms epoch. +fn format_date(ms: i64) -> String { + let iso = ms_to_iso(ms); + // Extract just the date part (YYYY-MM-DD) + iso.split('T').next().unwrap_or(&iso).to_string() +} + +/// Truncate text with ellipsis. +fn truncate(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else { + format!("{}...", &s[..max_len.saturating_sub(3)]) + } +} + +/// Wrap text to width, with indent prefix on continuation lines. +fn wrap_text(text: &str, width: usize, indent: &str) -> String { + let mut result = String::new(); + let mut current_line = String::new(); + + for word in text.split_whitespace() { + if current_line.is_empty() { + current_line = word.to_string(); + } else if current_line.len() + 1 + word.len() <= width { + current_line.push(' '); + current_line.push_str(word); + } else { + if !result.is_empty() { + result.push('\n'); + result.push_str(indent); + } + result.push_str(¤t_line); + current_line = word.to_string(); + } + } + + if !current_line.is_empty() { + if !result.is_empty() { + result.push('\n'); + result.push_str(indent); + } + result.push_str(¤t_line); + } + + result +} + +/// Print issue detail. +pub fn print_show_issue(issue: &IssueDetail) { + // Header + let header = format!("Issue #{}: {}", issue.iid, issue.title); + println!("{}", style(&header).bold()); + println!("{}", "━".repeat(header.len().min(80))); + println!(); + + // Metadata + println!("Project: {}", style(&issue.project_path).cyan()); + + let state_styled = if issue.state == "opened" { + style(&issue.state).green() + } else { + style(&issue.state).dim() + }; + println!("State: {}", state_styled); + + println!("Author: @{}", issue.author_username); + println!("Created: {}", format_date(issue.created_at)); + println!("Updated: {}", format_date(issue.updated_at)); + + if issue.labels.is_empty() { + println!("Labels: {}", style("(none)").dim()); + } else { + println!("Labels: {}", issue.labels.join(", ")); + } + + if let Some(url) = &issue.web_url { + println!("URL: {}", style(url).dim()); + } + + println!(); + + // Description + println!("{}", style("Description:").bold()); + if let Some(desc) = &issue.description { + let truncated = truncate(desc, 500); + let wrapped = wrap_text(&truncated, 76, " "); + println!(" {}", wrapped); + } else { + println!(" {}", style("(no description)").dim()); + } + + println!(); + + // Discussions + let user_discussions: Vec<&DiscussionDetail> = issue + .discussions + .iter() + .filter(|d| d.notes.iter().any(|n| !n.is_system)) + .collect(); + + if user_discussions.is_empty() { + println!("{}", style("Discussions: (none)").dim()); + } else { + println!( + "{}", + style(format!("Discussions ({}):", user_discussions.len())).bold() + ); + println!(); + + for discussion in user_discussions { + let user_notes: Vec<&NoteDetail> = + discussion.notes.iter().filter(|n| !n.is_system).collect(); + + if let Some(first_note) = user_notes.first() { + // First note of discussion (not indented) + println!( + " {} ({}):", + style(format!("@{}", first_note.author_username)).cyan(), + format_date(first_note.created_at) + ); + let wrapped = wrap_text(&truncate(&first_note.body, 300), 72, " "); + println!(" {}", wrapped); + println!(); + + // Replies (indented) + for reply in user_notes.iter().skip(1) { + println!( + " {} ({}):", + style(format!("@{}", reply.author_username)).cyan(), + format_date(reply.created_at) + ); + let wrapped = wrap_text(&truncate(&reply.body, 300), 68, " "); + println!(" {}", wrapped); + println!(); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_leaves_short_strings() { + assert_eq!(truncate("short", 10), "short"); + } + + #[test] + fn truncate_adds_ellipsis() { + assert_eq!(truncate("this is a long string", 10), "this is..."); + } + + #[test] + fn wrap_text_single_line() { + assert_eq!(wrap_text("hello world", 80, " "), "hello world"); + } + + #[test] + fn wrap_text_multiple_lines() { + let result = wrap_text("one two three four five", 10, " "); + assert!(result.contains('\n')); + } + + #[test] + fn format_date_extracts_date_part() { + // 2024-01-15T00:00:00Z in milliseconds + let ms = 1705276800000; + let date = format_date(ms); + assert!(date.starts_with("2024-01-15")); + } +} diff --git a/src/cli/commands/sync_status.rs b/src/cli/commands/sync_status.rs new file mode 100644 index 0000000..3b57382 --- /dev/null +++ b/src/cli/commands/sync_status.rs @@ -0,0 +1,303 @@ +//! Sync status command - display synchronization state from local database. + +use console::style; +use rusqlite::Connection; + +use crate::Config; +use crate::core::db::create_connection; +use crate::core::error::Result; +use crate::core::paths::get_db_path; +use crate::core::time::ms_to_iso; + +/// Sync run information. +#[derive(Debug)] +pub struct SyncRunInfo { + pub id: i64, + pub started_at: i64, + pub finished_at: Option, + pub status: String, + pub command: String, + pub error: Option, +} + +/// Cursor position information. +#[derive(Debug)] +pub struct CursorInfo { + pub project_path: String, + pub resource_type: String, + pub updated_at_cursor: Option, + pub tie_breaker_id: Option, +} + +/// Data summary counts. +#[derive(Debug)] +pub struct DataSummary { + pub issue_count: i64, + pub discussion_count: i64, + pub note_count: i64, + pub system_note_count: i64, +} + +/// Complete sync status result. +#[derive(Debug)] +pub struct SyncStatusResult { + pub last_run: Option, + pub cursors: Vec, + pub summary: DataSummary, +} + +/// Run the sync-status command. +pub fn run_sync_status(config: &Config) -> Result { + let db_path = get_db_path(config.storage.db_path.as_deref()); + let conn = create_connection(&db_path)?; + + let last_run = get_last_sync_run(&conn)?; + let cursors = get_cursor_positions(&conn)?; + let summary = get_data_summary(&conn)?; + + Ok(SyncStatusResult { + last_run, + cursors, + summary, + }) +} + +/// Get the most recent sync run. +fn get_last_sync_run(conn: &Connection) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, started_at, finished_at, status, command, error + FROM sync_runs + ORDER BY started_at DESC + LIMIT 1", + )?; + + let result = stmt.query_row([], |row| { + Ok(SyncRunInfo { + id: row.get(0)?, + started_at: row.get(1)?, + finished_at: row.get(2)?, + status: row.get(3)?, + command: row.get(4)?, + error: row.get(5)?, + }) + }); + + match result { + Ok(info) => Ok(Some(info)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } +} + +/// Get cursor positions for all projects/resource types. +fn get_cursor_positions(conn: &Connection) -> Result> { + let mut stmt = conn.prepare( + "SELECT p.path_with_namespace, sc.resource_type, sc.updated_at_cursor, sc.tie_breaker_id + FROM sync_cursors sc + JOIN projects p ON sc.project_id = p.id + ORDER BY p.path_with_namespace, sc.resource_type", + )?; + + let cursors: std::result::Result, _> = stmt + .query_map([], |row| { + Ok(CursorInfo { + project_path: row.get(0)?, + resource_type: row.get(1)?, + updated_at_cursor: row.get(2)?, + tie_breaker_id: row.get(3)?, + }) + })? + .collect(); + + Ok(cursors?) +} + +/// Get data summary counts. +fn get_data_summary(conn: &Connection) -> Result { + let issue_count: i64 = conn + .query_row("SELECT COUNT(*) FROM issues", [], |row| row.get(0)) + .unwrap_or(0); + + let discussion_count: i64 = conn + .query_row("SELECT COUNT(*) FROM discussions", [], |row| row.get(0)) + .unwrap_or(0); + + let (note_count, system_note_count): (i64, i64) = conn + .query_row( + "SELECT COUNT(*), COALESCE(SUM(is_system), 0) FROM notes", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap_or((0, 0)); + + Ok(DataSummary { + issue_count, + discussion_count, + note_count, + system_note_count, + }) +} + +/// Format duration in milliseconds to human-readable string. +fn format_duration(ms: i64) -> String { + let seconds = ms / 1000; + let minutes = seconds / 60; + let hours = minutes / 60; + + if hours > 0 { + format!("{}h {}m {}s", hours, minutes % 60, seconds % 60) + } else if minutes > 0 { + format!("{}m {}s", minutes, seconds % 60) + } else { + format!("{}s", seconds) + } +} + +/// Format number with thousands separators. +fn format_number(n: i64) -> String { + let is_negative = n < 0; + let abs_n = n.abs(); + let s = abs_n.to_string(); + let chars: Vec = s.chars().collect(); + let mut result = String::new(); + + if is_negative { + result.push('-'); + } + + for (i, c) in chars.iter().enumerate() { + if i > 0 && (chars.len() - i).is_multiple_of(3) { + result.push(','); + } + result.push(*c); + } + + result +} + +/// Print sync status result. +pub fn print_sync_status(result: &SyncStatusResult) { + // Last Sync section + println!("{}", style("Last Sync").bold().underlined()); + println!(); + + match &result.last_run { + Some(run) => { + let status_styled = match run.status.as_str() { + "succeeded" => style(&run.status).green(), + "failed" => style(&run.status).red(), + "running" => style(&run.status).yellow(), + _ => style(&run.status).dim(), + }; + + println!(" Status: {}", status_styled); + println!(" Command: {}", run.command); + println!(" Started: {}", ms_to_iso(run.started_at)); + + if let Some(finished) = run.finished_at { + println!(" Completed: {}", ms_to_iso(finished)); + let duration = finished - run.started_at; + println!(" Duration: {}", format_duration(duration)); + } + + if let Some(error) = &run.error { + println!(" Error: {}", style(error).red()); + } + } + None => { + println!(" {}", style("No sync runs recorded yet.").dim()); + println!( + " {}", + style("Run 'gi ingest --type=issues' to start.").dim() + ); + } + } + + println!(); + + // Cursor Positions section + println!("{}", style("Cursor Positions").bold().underlined()); + println!(); + + if result.cursors.is_empty() { + println!(" {}", style("No cursors recorded yet.").dim()); + } else { + for cursor in &result.cursors { + println!( + " {} ({}):", + style(&cursor.project_path).cyan(), + cursor.resource_type + ); + + match cursor.updated_at_cursor { + Some(ts) if ts > 0 => { + println!(" Last updated_at: {}", ms_to_iso(ts)); + } + _ => { + println!(" Last updated_at: {}", style("Not started").dim()); + } + } + + if let Some(id) = cursor.tie_breaker_id { + println!(" Last GitLab ID: {}", id); + } + } + } + + println!(); + + // Data Summary section + println!("{}", style("Data Summary").bold().underlined()); + println!(); + + println!( + " Issues: {}", + style(format_number(result.summary.issue_count)).bold() + ); + println!( + " Discussions: {}", + style(format_number(result.summary.discussion_count)).bold() + ); + + let user_notes = result.summary.note_count - result.summary.system_note_count; + println!( + " Notes: {} {}", + style(format_number(user_notes)).bold(), + style(format!( + "(excluding {} system)", + format_number(result.summary.system_note_count) + )) + .dim() + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_duration_handles_seconds() { + assert_eq!(format_duration(5_000), "5s"); + assert_eq!(format_duration(59_000), "59s"); + } + + #[test] + fn format_duration_handles_minutes() { + assert_eq!(format_duration(60_000), "1m 0s"); + assert_eq!(format_duration(90_000), "1m 30s"); + assert_eq!(format_duration(300_000), "5m 0s"); + } + + #[test] + fn format_duration_handles_hours() { + assert_eq!(format_duration(3_600_000), "1h 0m 0s"); + assert_eq!(format_duration(5_400_000), "1h 30m 0s"); + assert_eq!(format_duration(3_723_000), "1h 2m 3s"); + } + + #[test] + fn format_number_adds_thousands_separators() { + assert_eq!(format_number(1000), "1,000"); + assert_eq!(format_number(1234567), "1,234,567"); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..5be101f --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,168 @@ +//! CLI module with clap command definitions. + +pub mod commands; + +use clap::{Parser, Subcommand}; + +/// GitLab Inbox - Unified notification management +#[derive(Parser)] +#[command(name = "gi")] +#[command(version, about, long_about = None)] +pub struct Cli { + /// Path to config file + #[arg(short, long, global = true)] + pub config: Option, + + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Initialize configuration and database + Init { + /// Skip overwrite confirmation + #[arg(long)] + force: bool, + + /// Fail if prompts would be shown + #[arg(long)] + non_interactive: bool, + }, + + /// Verify GitLab authentication + AuthTest, + + /// Check environment health + Doctor { + /// Output as JSON + #[arg(long)] + json: bool, + }, + + /// Show version information + Version, + + /// Create timestamped database backup + Backup, + + /// Delete database and reset all state + Reset { + /// Skip confirmation prompt + #[arg(long)] + confirm: bool, + }, + + /// Run pending database migrations + Migrate, + + /// Show sync state + SyncStatus, + + /// Ingest data from GitLab + Ingest { + /// Resource type to ingest + #[arg(long, value_parser = ["issues", "merge_requests"])] + r#type: String, + + /// Filter to single project + #[arg(long)] + project: Option, + + /// Override stale sync lock + #[arg(long)] + force: bool, + + /// Full re-sync: reset cursors and fetch all data from scratch + #[arg(long)] + full: bool, + }, + + /// List issues or MRs from local database + List { + /// Entity type to list + #[arg(value_parser = ["issues", "mrs"])] + entity: String, + + /// Maximum results + #[arg(long, default_value = "50")] + limit: usize, + + /// Filter by project path + #[arg(long)] + project: Option, + + /// Filter by state + #[arg(long, value_parser = ["opened", "closed", "all"])] + state: Option, + + /// Filter by author username + #[arg(long)] + author: Option, + + /// Filter by assignee username + #[arg(long)] + assignee: Option, + + /// Filter by label (repeatable, AND logic) + #[arg(long)] + label: Option>, + + /// Filter by milestone title + #[arg(long)] + milestone: Option, + + /// Filter by time (7d, 2w, 1m, or YYYY-MM-DD) + #[arg(long)] + since: Option, + + /// Filter by due date (before this date, YYYY-MM-DD) + #[arg(long)] + due_before: Option, + + /// Show only issues with a due date + #[arg(long)] + has_due_date: bool, + + /// Sort field + #[arg(long, value_parser = ["updated", "created", "iid"], default_value = "updated")] + sort: String, + + /// Sort order + #[arg(long, value_parser = ["desc", "asc"], default_value = "desc")] + order: String, + + /// Open first matching issue in browser + #[arg(long)] + open: bool, + + /// Output as JSON + #[arg(long)] + json: bool, + }, + + /// Count entities in local database + Count { + /// Entity type to count + #[arg(value_parser = ["issues", "mrs", "discussions", "notes"])] + entity: String, + + /// Filter by noteable type (for discussions/notes) + #[arg(long, value_parser = ["issue", "mr"])] + r#type: Option, + }, + + /// Show detailed entity information + Show { + /// Entity type to show + #[arg(value_parser = ["issue", "mr"])] + entity: String, + + /// Entity IID + iid: i64, + + /// Filter by project path (required if iid is ambiguous) + #[arg(long)] + project: Option, + }, +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..2538c96 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,11 @@ +//! GitLab Inbox - Semantic search for GitLab issues, MRs, and discussions. +//! +//! A self-hosted CLI tool that consolidates GitLab notifications into a unified inbox +//! with semantic search capabilities. + +pub mod cli; +pub mod core; +pub mod gitlab; +pub mod ingestion; + +pub use core::{Config, GiError, Result}; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d4db2ed --- /dev/null +++ b/src/main.rs @@ -0,0 +1,447 @@ +//! GitLab Inbox CLI entry point. + +use clap::Parser; +use console::style; +use dialoguer::{Confirm, Input}; +use tracing_subscriber::EnvFilter; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; + +use gi::Config; +use gi::cli::commands::{ + InitInputs, InitOptions, ListFilters, open_issue_in_browser, print_count, + print_doctor_results, print_ingest_summary, print_list_issues, print_list_issues_json, + print_show_issue, print_sync_status, run_auth_test, run_count, run_doctor, run_ingest, + run_init, run_list_issues, run_show_issue, run_sync_status, +}; +use gi::core::db::{create_connection, get_schema_version, run_migrations}; +use gi::core::paths::get_db_path; +use gi::cli::{Cli, Commands}; +use gi::core::paths::get_config_path; + +#[tokio::main] +async fn main() { + // Initialize logging with indicatif support for clean progress bar output + let indicatif_layer = tracing_indicatif::IndicatifLayer::new(); + + tracing_subscriber::registry() + .with( + tracing_subscriber::fmt::layer() + .with_target(false) + .with_writer(indicatif_layer.get_stderr_writer()), + ) + .with( + EnvFilter::from_default_env() + .add_directive("gi=info".parse().unwrap()) + .add_directive("warn".parse().unwrap()), + ) + .with(indicatif_layer) + .init(); + + let cli = Cli::parse(); + + let result = match cli.command { + Commands::Init { + force, + non_interactive, + } => handle_init(cli.config.as_deref(), force, non_interactive).await, + Commands::AuthTest => handle_auth_test(cli.config.as_deref()).await, + Commands::Doctor { json } => handle_doctor(cli.config.as_deref(), json).await, + Commands::Version => { + println!("gi version {}", env!("CARGO_PKG_VERSION")); + Ok(()) + } + Commands::Backup => { + println!("gi backup - not yet implemented"); + Ok(()) + } + Commands::Reset { confirm: _ } => { + println!("gi reset - not yet implemented"); + Ok(()) + } + Commands::Migrate => handle_migrate(cli.config.as_deref()).await, + Commands::SyncStatus => handle_sync_status(cli.config.as_deref()).await, + Commands::Ingest { + r#type, + project, + force, + full, + } => handle_ingest(cli.config.as_deref(), &r#type, project.as_deref(), force, full).await, + Commands::List { + entity, + limit, + project, + state, + author, + assignee, + label, + milestone, + since, + due_before, + has_due_date, + sort, + order, + open, + json, + } => { + handle_list( + cli.config.as_deref(), + &entity, + limit, + project.as_deref(), + state.as_deref(), + author.as_deref(), + assignee.as_deref(), + label.as_deref(), + milestone.as_deref(), + since.as_deref(), + due_before.as_deref(), + has_due_date, + &sort, + &order, + open, + json, + ) + .await + } + Commands::Count { entity, r#type } => { + handle_count(cli.config.as_deref(), &entity, r#type.as_deref()).await + } + Commands::Show { + entity, + iid, + project, + } => handle_show(cli.config.as_deref(), &entity, iid, project.as_deref()).await, + }; + + if let Err(e) = result { + eprintln!("{} {}", style("Error:").red(), e); + std::process::exit(1); + } +} + +async fn handle_init( + config_override: Option<&str>, + force: bool, + non_interactive: bool, +) -> Result<(), Box> { + let config_path = get_config_path(config_override); + let mut confirmed_overwrite = force; + + // Check if config exists and handle overwrite + if config_path.exists() { + if non_interactive { + eprintln!( + "{}", + style(format!( + "Config file exists at {}. Cannot proceed in non-interactive mode.", + config_path.display() + )) + .red() + ); + std::process::exit(2); + } + + if !force { + let confirm = Confirm::new() + .with_prompt(format!( + "Config file exists at {}. Overwrite?", + config_path.display() + )) + .default(false) + .interact()?; + + if !confirm { + println!("{}", style("Cancelled.").yellow()); + std::process::exit(2); + } + confirmed_overwrite = true; + } + } + + // Prompt for GitLab URL + let gitlab_url: String = Input::new() + .with_prompt("GitLab URL") + .default("https://gitlab.com".to_string()) + .validate_with(|input: &String| -> Result<(), &str> { + if url::Url::parse(input).is_ok() { + Ok(()) + } else { + Err("Please enter a valid URL") + } + }) + .interact_text()?; + + // Prompt for token env var + let token_env_var: String = Input::new() + .with_prompt("Token environment variable name") + .default("GITLAB_TOKEN".to_string()) + .interact_text()?; + + // Prompt for project paths + let project_paths_input: String = Input::new() + .with_prompt("Project paths (comma-separated, e.g., group/project)") + .validate_with(|input: &String| -> Result<(), &str> { + if input.trim().is_empty() { + Err("Please enter at least one project path") + } else { + Ok(()) + } + }) + .interact_text()?; + + let project_paths: Vec = project_paths_input + .split(',') + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + .collect(); + + println!("{}", style("\nValidating configuration...").blue()); + + let result = run_init( + InitInputs { + gitlab_url, + token_env_var, + project_paths, + }, + InitOptions { + config_path: config_override.map(String::from), + force: confirmed_overwrite, + non_interactive, + }, + ) + .await?; + + println!( + "{}", + style(format!( + "\n✓ Authenticated as @{} ({})", + result.user.username, result.user.name + )) + .green() + ); + + for project in &result.projects { + println!( + "{}", + style(format!("✓ {} ({})", project.path, project.name)).green() + ); + } + + println!( + "{}", + style(format!("\n✓ Config written to {}", result.config_path)).green() + ); + println!( + "{}", + style(format!("✓ Database initialized at {}", result.data_dir)).green() + ); + println!( + "{}", + style("\nSetup complete! Run 'gi doctor' to verify.").blue() + ); + + Ok(()) +} + +async fn handle_auth_test(config_override: Option<&str>) -> Result<(), Box> { + match run_auth_test(config_override).await { + Ok(result) => { + println!("Authenticated as @{} ({})", result.username, result.name); + println!("GitLab: {}", result.base_url); + Ok(()) + } + Err(e) => { + eprintln!("{}", style(format!("Error: {e}")).red()); + std::process::exit(1); + } + } +} + +async fn handle_doctor( + config_override: Option<&str>, + json: bool, +) -> Result<(), Box> { + let result = run_doctor(config_override).await; + + if json { + println!("{}", serde_json::to_string_pretty(&result)?); + } else { + print_doctor_results(&result); + } + + if !result.success { + std::process::exit(1); + } + + Ok(()) +} + +async fn handle_ingest( + config_override: Option<&str>, + resource_type: &str, + project_filter: Option<&str>, + force: bool, + full: bool, +) -> Result<(), Box> { + let config = Config::load(config_override)?; + + match run_ingest(&config, resource_type, project_filter, force, full).await { + Ok(result) => { + print_ingest_summary(&result); + Ok(()) + } + Err(e) => { + eprintln!("{}", style(format!("Error: {e}")).red()); + std::process::exit(1); + } + } +} + +async fn handle_list( + config_override: Option<&str>, + entity: &str, + limit: usize, + project_filter: Option<&str>, + state_filter: Option<&str>, + author_filter: Option<&str>, + assignee_filter: Option<&str>, + label_filter: Option<&[String]>, + milestone_filter: Option<&str>, + since_filter: Option<&str>, + due_before_filter: Option<&str>, + has_due_date: bool, + sort: &str, + order: &str, + open_browser: bool, + json_output: bool, +) -> Result<(), Box> { + let config = Config::load(config_override)?; + + match entity { + "issues" => { + let filters = ListFilters { + limit, + project: project_filter, + state: state_filter, + author: author_filter, + assignee: assignee_filter, + labels: label_filter, + milestone: milestone_filter, + since: since_filter, + due_before: due_before_filter, + has_due_date, + sort, + order, + }; + + let result = run_list_issues(&config, filters)?; + + if open_browser { + open_issue_in_browser(&result); + } else if json_output { + print_list_issues_json(&result); + } else { + print_list_issues(&result); + } + + Ok(()) + } + "mrs" => { + println!("MR listing not yet implemented. Only 'issues' is supported in CP1."); + Ok(()) + } + _ => { + eprintln!("{}", style(format!("Unknown entity: {entity}")).red()); + std::process::exit(1); + } + } +} + +async fn handle_count( + config_override: Option<&str>, + entity: &str, + type_filter: Option<&str>, +) -> Result<(), Box> { + let config = Config::load(config_override)?; + + let result = run_count(&config, entity, type_filter)?; + print_count(&result); + Ok(()) +} + +async fn handle_sync_status( + config_override: Option<&str>, +) -> Result<(), Box> { + let config = Config::load(config_override)?; + + let result = run_sync_status(&config)?; + print_sync_status(&result); + Ok(()) +} + +async fn handle_show( + config_override: Option<&str>, + entity: &str, + iid: i64, + project_filter: Option<&str>, +) -> Result<(), Box> { + let config = Config::load(config_override)?; + + match entity { + "issue" => { + let result = run_show_issue(&config, iid, project_filter)?; + print_show_issue(&result); + Ok(()) + } + "mr" => { + println!("MR details not yet implemented. Only 'issue' is supported in CP1."); + Ok(()) + } + _ => { + eprintln!("{}", style(format!("Unknown entity: {entity}")).red()); + std::process::exit(1); + } + } +} + +async fn handle_migrate(config_override: Option<&str>) -> Result<(), Box> { + let config = Config::load(config_override)?; + let db_path = get_db_path(config.storage.db_path.as_deref()); + + if !db_path.exists() { + eprintln!( + "{}", + style(format!("Database not found at {}", db_path.display())).red() + ); + eprintln!("{}", style("Run 'gi init' first to create the database.").yellow()); + std::process::exit(1); + } + + let conn = create_connection(&db_path)?; + let before_version = get_schema_version(&conn); + + println!( + "{}", + style(format!("Current schema version: {}", before_version)).blue() + ); + + run_migrations(&conn)?; + + let after_version = get_schema_version(&conn); + + if after_version > before_version { + println!( + "{}", + style(format!( + "Migrations applied: {} -> {}", + before_version, after_version + )) + .green() + ); + } else { + println!("{}", style("Database is already up to date.").green()); + } + + Ok(()) +}