feat(cli): Implement complete command-line interface

Provides a user-friendly CLI for all GitLab Inbox operations.

src/cli/mod.rs - Clap command definitions:
- Global --config flag for alternate config path
- Subcommands: init, auth-test, doctor, version, backup, reset,
  migrate, sync-status, ingest, list, count, show
- Ingest supports --type (issues/merge_requests), --project filter,
  --force lock override, --full resync
- List supports rich filtering: --state, --author, --assignee,
  --label, --milestone, --since, --due-before, --has-due-date
- List supports --sort (updated/created/iid), --order (asc/desc)
- List supports --open to launch browser, --json for scripting

src/cli/commands/ - Command implementations:

init.rs: Interactive configuration wizard
- Prompts for GitLab URL, token env var, projects to track
- Creates config file and initializes database
- Supports --force overwrite and --non-interactive mode

auth_test.rs: Verify GitLab authentication
- Calls /api/v4/user to validate token
- Displays username and GitLab instance URL

doctor.rs: Environment health check
- Validates config file exists and parses correctly
- Checks database connectivity and migration state
- Verifies GitLab authentication
- Reports token environment variable status
- Supports --json output for CI integration

ingest.rs: Data synchronization from GitLab
- Acquires sync lock with stale detection
- Shows progress bars for issues and discussions
- Reports sync statistics on completion
- Supports --full flag to reset cursors and refetch all data

list.rs: Query local database
- Formatted table output with comfy-table
- Filters build dynamic SQL with parameterized queries
- Username filters normalize @ prefix automatically
- --open flag uses 'open' crate for cross-platform browser launch
- --json outputs array of issue objects

show.rs: Detailed entity view
- Displays issue metadata in structured format
- Shows full description with markdown
- Lists labels, assignees, milestone
- Shows discussion threads with notes

count.rs: Entity statistics
- Counts issues, discussions, or notes
- Supports --type filter for discussions/notes

sync_status.rs: Display sync watermarks
- Shows last sync time per project
- Displays cursor positions for debugging

src/main.rs - Application entry point:
- Initializes tracing subscriber with env-filter
- Parses CLI arguments via clap
- Dispatches to appropriate command handler
- Consistent error formatting for all failure modes

src/lib.rs - Library entry point:
- Exports cli, core, gitlab, ingestion modules
- Re-exports Config, GiError, Result for convenience

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-01-26 11:28:52 -05:00
parent cd60350c6d
commit 8fb890c528
12 changed files with 3034 additions and 0 deletions

View File

@@ -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<AuthTestResult> {
// 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,
})
}

190
src/cli/commands/count.rs Normal file
View File

@@ -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<i64>, // For notes only
}
/// Run the count command.
pub fn run_count(config: &Config, entity: &str, type_filter: Option<&str>) -> Result<CountResult> {
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<CountResult> {
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<CountResult> {
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<CountResult> {
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<char> = 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");
}
}

504
src/cli/commands/doctor.rs Normal file
View File

@@ -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<String>,
}
#[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<String>,
}
#[derive(Debug, Serialize)]
pub struct DatabaseCheck {
#[serde(flatten)]
pub result: CheckResult,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub schema_version: Option<i32>,
}
#[derive(Debug, Serialize)]
pub struct GitLabCheck {
#[serde(flatten)]
pub result: CheckResult,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ProjectsCheck {
#[serde(flatten)]
pub result: CheckResult,
#[serde(skip_serializing_if = "Option::is_none")]
pub configured: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolved: Option<i64>,
}
#[derive(Debug, Serialize)]
pub struct OllamaCheck {
#[serde(flatten)]
pub result: CheckResult,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
/// 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<Config>) {
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<Vec<ModelInfo>>,
}
#[derive(serde::Deserialize)]
struct ModelInfo {
name: String,
}
match response.json::<TagsResponse>().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);
}

266
src/cli/commands/ingest.rs Normal file
View File

@@ -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<IngestResult> {
// 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<Vec<(i64, i64, String)>> {
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()
);
}
}

172
src/cli/commands/init.rs Normal file
View File

@@ -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<String>,
}
/// Options for init command.
pub struct InitOptions {
pub config_path: Option<String>,
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<ProjectInfo>,
}
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<InitResult> {
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(),
})
}

517
src/cli/commands/list.rs Normal file
View File

@@ -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<String>,
pub project_path: String,
pub labels: Vec<String>,
pub assignees: Vec<String>,
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<String>,
pub assignees: Vec<String>,
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<String>,
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<IssueListRow>,
pub total_count: usize,
}
/// JSON output structure.
#[derive(Serialize)]
pub struct ListResultJson {
pub issues: Vec<IssueListRowJson>,
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<ListResult> {
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<ListResult> {
// Build WHERE clause
let mut where_clauses = Vec::new();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = 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<String> = row.get(8)?;
let labels = labels_csv
.map(|s| s.split(',').map(String::from).collect())
.unwrap_or_default();
let assignees_csv: Option<String> = 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<String> = 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<String> {
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!");
}
}

21
src/cli/commands/mod.rs Normal file
View File

@@ -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};

392
src/cli/commands/show.rs Normal file
View File

@@ -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<String>,
pub state: String,
pub author_username: String,
pub created_at: i64,
pub updated_at: i64,
pub web_url: Option<String>,
pub project_path: String,
pub labels: Vec<String>,
pub discussions: Vec<DiscussionDetail>,
}
/// Discussion detail for display.
#[derive(Debug)]
pub struct DiscussionDetail {
pub notes: Vec<NoteDetail>,
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<IssueDetail> {
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<String>,
state: String,
author_username: String,
created_at: i64,
updated_at: i64,
web_url: Option<String>,
project_path: String,
}
/// Find issue by iid, optionally filtered by project.
fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result<IssueRow> {
let (sql, params): (&str, Vec<Box<dyn rusqlite::ToSql>>) = 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<IssueRow> = 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<String> = 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<Vec<String>> {
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<Vec<DiscussionDetail>> {
// 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<NoteDetail> = 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(&current_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(&current_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"));
}
}

View File

@@ -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<i64>,
pub status: String,
pub command: String,
pub error: Option<String>,
}
/// Cursor position information.
#[derive(Debug)]
pub struct CursorInfo {
pub project_path: String,
pub resource_type: String,
pub updated_at_cursor: Option<i64>,
pub tie_breaker_id: Option<i64>,
}
/// 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<SyncRunInfo>,
pub cursors: Vec<CursorInfo>,
pub summary: DataSummary,
}
/// Run the sync-status command.
pub fn run_sync_status(config: &Config) -> Result<SyncStatusResult> {
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<Option<SyncRunInfo>> {
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<Vec<CursorInfo>> {
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<Vec<_>, _> = 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<DataSummary> {
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<char> = 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");
}
}

168
src/cli/mod.rs Normal file
View File

@@ -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<String>,
#[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<String>,
/// 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<String>,
/// Filter by state
#[arg(long, value_parser = ["opened", "closed", "all"])]
state: Option<String>,
/// Filter by author username
#[arg(long)]
author: Option<String>,
/// Filter by assignee username
#[arg(long)]
assignee: Option<String>,
/// Filter by label (repeatable, AND logic)
#[arg(long)]
label: Option<Vec<String>>,
/// Filter by milestone title
#[arg(long)]
milestone: Option<String>,
/// Filter by time (7d, 2w, 1m, or YYYY-MM-DD)
#[arg(long)]
since: Option<String>,
/// Filter by due date (before this date, YYYY-MM-DD)
#[arg(long)]
due_before: Option<String>,
/// 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<String>,
},
/// 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<String>,
},
}