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