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:
447
src/main.rs
Normal file
447
src/main.rs
Normal file
@@ -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<dyn std::error::Error>> {
|
||||
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<String> = 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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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(())
|
||||
}
|
||||
Reference in New Issue
Block a user