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

447
src/main.rs Normal file
View 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(())
}