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