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:
303
src/cli/commands/sync_status.rs
Normal file
303
src/cli/commands/sync_status.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user