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:
517
src/cli/commands/list.rs
Normal file
517
src/cli/commands/list.rs
Normal file
@@ -0,0 +1,517 @@
|
||||
//! List command - display issues/MRs from local database.
|
||||
|
||||
use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table};
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
|
||||
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, now_ms, parse_since};
|
||||
|
||||
/// Issue row for display.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IssueListRow {
|
||||
pub iid: i64,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub author_username: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub web_url: Option<String>,
|
||||
pub project_path: String,
|
||||
pub labels: Vec<String>,
|
||||
pub assignees: Vec<String>,
|
||||
pub discussion_count: i64,
|
||||
pub unresolved_count: i64,
|
||||
}
|
||||
|
||||
/// Serializable version for JSON output.
|
||||
#[derive(Serialize)]
|
||||
pub struct IssueListRowJson {
|
||||
pub iid: i64,
|
||||
pub title: String,
|
||||
pub state: String,
|
||||
pub author_username: String,
|
||||
pub labels: Vec<String>,
|
||||
pub assignees: Vec<String>,
|
||||
pub discussion_count: i64,
|
||||
pub unresolved_count: i64,
|
||||
pub created_at_iso: String,
|
||||
pub updated_at_iso: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub web_url: Option<String>,
|
||||
pub project_path: String,
|
||||
}
|
||||
|
||||
impl From<&IssueListRow> for IssueListRowJson {
|
||||
fn from(row: &IssueListRow) -> Self {
|
||||
Self {
|
||||
iid: row.iid,
|
||||
title: row.title.clone(),
|
||||
state: row.state.clone(),
|
||||
author_username: row.author_username.clone(),
|
||||
labels: row.labels.clone(),
|
||||
assignees: row.assignees.clone(),
|
||||
discussion_count: row.discussion_count,
|
||||
unresolved_count: row.unresolved_count,
|
||||
created_at_iso: ms_to_iso(row.created_at),
|
||||
updated_at_iso: ms_to_iso(row.updated_at),
|
||||
web_url: row.web_url.clone(),
|
||||
project_path: row.project_path.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of list query.
|
||||
#[derive(Serialize)]
|
||||
pub struct ListResult {
|
||||
pub issues: Vec<IssueListRow>,
|
||||
pub total_count: usize,
|
||||
}
|
||||
|
||||
/// JSON output structure.
|
||||
#[derive(Serialize)]
|
||||
pub struct ListResultJson {
|
||||
pub issues: Vec<IssueListRowJson>,
|
||||
pub total_count: usize,
|
||||
pub showing: usize,
|
||||
}
|
||||
|
||||
impl From<&ListResult> for ListResultJson {
|
||||
fn from(result: &ListResult) -> Self {
|
||||
Self {
|
||||
issues: result.issues.iter().map(IssueListRowJson::from).collect(),
|
||||
total_count: result.total_count,
|
||||
showing: result.issues.len(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter options for list query.
|
||||
pub struct ListFilters<'a> {
|
||||
pub limit: usize,
|
||||
pub project: Option<&'a str>,
|
||||
pub state: Option<&'a str>,
|
||||
pub author: Option<&'a str>,
|
||||
pub assignee: Option<&'a str>,
|
||||
pub labels: Option<&'a [String]>,
|
||||
pub milestone: Option<&'a str>,
|
||||
pub since: Option<&'a str>,
|
||||
pub due_before: Option<&'a str>,
|
||||
pub has_due_date: bool,
|
||||
pub sort: &'a str,
|
||||
pub order: &'a str,
|
||||
}
|
||||
|
||||
/// Run the list issues command.
|
||||
pub fn run_list_issues(config: &Config, filters: ListFilters) -> Result<ListResult> {
|
||||
let db_path = get_db_path(config.storage.db_path.as_deref());
|
||||
let conn = create_connection(&db_path)?;
|
||||
|
||||
let result = query_issues(&conn, &filters)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Query issues from database with enriched data.
|
||||
fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult> {
|
||||
// Build WHERE clause
|
||||
let mut where_clauses = Vec::new();
|
||||
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(project) = filters.project {
|
||||
where_clauses.push("p.path_with_namespace LIKE ?");
|
||||
params.push(Box::new(format!("%{project}%")));
|
||||
}
|
||||
|
||||
if let Some(state) = filters.state {
|
||||
if state != "all" {
|
||||
where_clauses.push("i.state = ?");
|
||||
params.push(Box::new(state.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// Handle author filter (strip leading @ if present)
|
||||
if let Some(author) = filters.author {
|
||||
let username = author.strip_prefix('@').unwrap_or(author);
|
||||
where_clauses.push("i.author_username = ?");
|
||||
params.push(Box::new(username.to_string()));
|
||||
}
|
||||
|
||||
// Handle assignee filter (strip leading @ if present)
|
||||
if let Some(assignee) = filters.assignee {
|
||||
let username = assignee.strip_prefix('@').unwrap_or(assignee);
|
||||
where_clauses.push(
|
||||
"EXISTS (SELECT 1 FROM issue_assignees ia
|
||||
WHERE ia.issue_id = i.id AND ia.username = ?)",
|
||||
);
|
||||
params.push(Box::new(username.to_string()));
|
||||
}
|
||||
|
||||
// Handle since filter
|
||||
if let Some(since_str) = filters.since {
|
||||
if let Some(cutoff_ms) = parse_since(since_str) {
|
||||
where_clauses.push("i.updated_at >= ?");
|
||||
params.push(Box::new(cutoff_ms));
|
||||
}
|
||||
}
|
||||
|
||||
// Handle label filters (AND logic - all labels must be present)
|
||||
if let Some(labels) = filters.labels {
|
||||
for label in labels {
|
||||
where_clauses.push(
|
||||
"EXISTS (SELECT 1 FROM issue_labels il
|
||||
JOIN labels l ON il.label_id = l.id
|
||||
WHERE il.issue_id = i.id AND l.name = ?)",
|
||||
);
|
||||
params.push(Box::new(label.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// Handle milestone filter
|
||||
if let Some(milestone) = filters.milestone {
|
||||
where_clauses.push("i.milestone_title = ?");
|
||||
params.push(Box::new(milestone.to_string()));
|
||||
}
|
||||
|
||||
// Handle due_before filter
|
||||
if let Some(due_before) = filters.due_before {
|
||||
where_clauses.push("i.due_date IS NOT NULL AND i.due_date <= ?");
|
||||
params.push(Box::new(due_before.to_string()));
|
||||
}
|
||||
|
||||
// Handle has_due_date filter
|
||||
if filters.has_due_date {
|
||||
where_clauses.push("i.due_date IS NOT NULL");
|
||||
}
|
||||
|
||||
let where_sql = if where_clauses.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("WHERE {}", where_clauses.join(" AND "))
|
||||
};
|
||||
|
||||
// Get total count
|
||||
let count_sql = format!(
|
||||
"SELECT COUNT(*) FROM issues i
|
||||
JOIN projects p ON i.project_id = p.id
|
||||
{where_sql}"
|
||||
);
|
||||
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
let total_count: i64 = conn.query_row(&count_sql, param_refs.as_slice(), |row| row.get(0))?;
|
||||
let total_count = total_count as usize;
|
||||
|
||||
// Build ORDER BY
|
||||
let sort_column = match filters.sort {
|
||||
"created" => "i.created_at",
|
||||
"iid" => "i.iid",
|
||||
_ => "i.updated_at", // default
|
||||
};
|
||||
let order = if filters.order == "asc" { "ASC" } else { "DESC" };
|
||||
|
||||
// Get issues with enriched data
|
||||
let query_sql = format!(
|
||||
"SELECT
|
||||
i.iid,
|
||||
i.title,
|
||||
i.state,
|
||||
i.author_username,
|
||||
i.created_at,
|
||||
i.updated_at,
|
||||
i.web_url,
|
||||
p.path_with_namespace,
|
||||
(SELECT GROUP_CONCAT(l.name, ',')
|
||||
FROM issue_labels il
|
||||
JOIN labels l ON il.label_id = l.id
|
||||
WHERE il.issue_id = i.id) AS labels_csv,
|
||||
(SELECT GROUP_CONCAT(ia.username, ',')
|
||||
FROM issue_assignees ia
|
||||
WHERE ia.issue_id = i.id) AS assignees_csv,
|
||||
COALESCE(d.total, 0) AS discussion_count,
|
||||
COALESCE(d.unresolved, 0) AS unresolved_count
|
||||
FROM issues i
|
||||
JOIN projects p ON i.project_id = p.id
|
||||
LEFT JOIN (
|
||||
SELECT issue_id,
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN resolvable = 1 AND resolved = 0 THEN 1 ELSE 0 END) as unresolved
|
||||
FROM discussions
|
||||
WHERE issue_id IS NOT NULL
|
||||
GROUP BY issue_id
|
||||
) d ON d.issue_id = i.id
|
||||
{where_sql}
|
||||
ORDER BY {sort_column} {order}
|
||||
LIMIT ?"
|
||||
);
|
||||
|
||||
params.push(Box::new(filters.limit as i64));
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let mut stmt = conn.prepare(&query_sql)?;
|
||||
let issues = stmt
|
||||
.query_map(param_refs.as_slice(), |row| {
|
||||
let labels_csv: Option<String> = row.get(8)?;
|
||||
let labels = labels_csv
|
||||
.map(|s| s.split(',').map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let assignees_csv: Option<String> = row.get(9)?;
|
||||
let assignees = assignees_csv
|
||||
.map(|s| s.split(',').map(String::from).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(IssueListRow {
|
||||
iid: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
state: row.get(2)?,
|
||||
author_username: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
web_url: row.get(6)?,
|
||||
project_path: row.get(7)?,
|
||||
labels,
|
||||
assignees,
|
||||
discussion_count: row.get(10)?,
|
||||
unresolved_count: row.get(11)?,
|
||||
})
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(ListResult {
|
||||
issues,
|
||||
total_count,
|
||||
})
|
||||
}
|
||||
|
||||
/// Format relative time from ms epoch.
|
||||
fn format_relative_time(ms_epoch: i64) -> String {
|
||||
let now = now_ms();
|
||||
let diff = now - ms_epoch;
|
||||
|
||||
match diff {
|
||||
d if d < 60_000 => "just now".to_string(),
|
||||
d if d < 3_600_000 => format!("{} min ago", d / 60_000),
|
||||
d if d < 86_400_000 => format!("{} hours ago", d / 3_600_000),
|
||||
d if d < 604_800_000 => format!("{} days ago", d / 86_400_000),
|
||||
d if d < 2_592_000_000 => format!("{} weeks ago", d / 604_800_000),
|
||||
_ => format!("{} months ago", diff / 2_592_000_000),
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate string to max width with ellipsis.
|
||||
fn truncate_with_ellipsis(s: &str, max_width: usize) -> String {
|
||||
if s.chars().count() <= max_width {
|
||||
s.to_string()
|
||||
} else {
|
||||
let truncated: String = s.chars().take(max_width.saturating_sub(3)).collect();
|
||||
format!("{truncated}...")
|
||||
}
|
||||
}
|
||||
|
||||
/// Format labels for display: [bug, urgent +2]
|
||||
fn format_labels(labels: &[String], max_shown: usize) -> String {
|
||||
if labels.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let shown: Vec<&str> = labels.iter().take(max_shown).map(|s| s.as_str()).collect();
|
||||
let overflow = labels.len().saturating_sub(max_shown);
|
||||
|
||||
if overflow > 0 {
|
||||
format!("[{} +{}]", shown.join(", "), overflow)
|
||||
} else {
|
||||
format!("[{}]", shown.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
/// Format assignees for display: @user1, @user2 +1
|
||||
fn format_assignees(assignees: &[String]) -> String {
|
||||
if assignees.is_empty() {
|
||||
return "-".to_string();
|
||||
}
|
||||
|
||||
let max_shown = 2;
|
||||
let shown: Vec<String> = assignees
|
||||
.iter()
|
||||
.take(max_shown)
|
||||
.map(|s| format!("@{}", truncate_with_ellipsis(s, 10)))
|
||||
.collect();
|
||||
let overflow = assignees.len().saturating_sub(max_shown);
|
||||
|
||||
if overflow > 0 {
|
||||
format!("{} +{}", shown.join(", "), overflow)
|
||||
} else {
|
||||
shown.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Format discussion count: "3/1!" (3 total, 1 unresolved)
|
||||
fn format_discussions(total: i64, unresolved: i64) -> String {
|
||||
if total == 0 {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
if unresolved > 0 {
|
||||
format!("{total}/{unresolved}!")
|
||||
} else {
|
||||
format!("{total}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Print issues list as a formatted table.
|
||||
pub fn print_list_issues(result: &ListResult) {
|
||||
if result.issues.is_empty() {
|
||||
println!("No issues found.");
|
||||
return;
|
||||
}
|
||||
|
||||
println!(
|
||||
"Issues (showing {} of {})\n",
|
||||
result.issues.len(),
|
||||
result.total_count
|
||||
);
|
||||
|
||||
let mut table = Table::new();
|
||||
table
|
||||
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||
.set_header(vec![
|
||||
Cell::new("IID").add_attribute(Attribute::Bold),
|
||||
Cell::new("Title").add_attribute(Attribute::Bold),
|
||||
Cell::new("State").add_attribute(Attribute::Bold),
|
||||
Cell::new("Assignee").add_attribute(Attribute::Bold),
|
||||
Cell::new("Labels").add_attribute(Attribute::Bold),
|
||||
Cell::new("Disc").add_attribute(Attribute::Bold),
|
||||
Cell::new("Updated").add_attribute(Attribute::Bold),
|
||||
]);
|
||||
|
||||
for issue in &result.issues {
|
||||
let title = truncate_with_ellipsis(&issue.title, 45);
|
||||
let relative_time = format_relative_time(issue.updated_at);
|
||||
let labels = format_labels(&issue.labels, 2);
|
||||
let assignee = format_assignees(&issue.assignees);
|
||||
let discussions = format_discussions(issue.discussion_count, issue.unresolved_count);
|
||||
|
||||
let state_cell = if issue.state == "opened" {
|
||||
Cell::new(&issue.state).fg(Color::Green)
|
||||
} else {
|
||||
Cell::new(&issue.state).fg(Color::DarkGrey)
|
||||
};
|
||||
|
||||
table.add_row(vec![
|
||||
Cell::new(format!("#{}", issue.iid)).fg(Color::Cyan),
|
||||
Cell::new(title),
|
||||
state_cell,
|
||||
Cell::new(assignee).fg(Color::Magenta),
|
||||
Cell::new(labels).fg(Color::Yellow),
|
||||
Cell::new(discussions),
|
||||
Cell::new(relative_time).fg(Color::DarkGrey),
|
||||
]);
|
||||
}
|
||||
|
||||
println!("{table}");
|
||||
}
|
||||
|
||||
/// Print issues list as JSON.
|
||||
pub fn print_list_issues_json(result: &ListResult) {
|
||||
let json_result = ListResultJson::from(result);
|
||||
match serde_json::to_string_pretty(&json_result) {
|
||||
Ok(json) => println!("{json}"),
|
||||
Err(e) => eprintln!("Error serializing to JSON: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open issue in browser. Returns the URL that was opened.
|
||||
pub fn open_issue_in_browser(result: &ListResult) -> Option<String> {
|
||||
let first_issue = result.issues.first()?;
|
||||
let url = first_issue.web_url.as_ref()?;
|
||||
|
||||
match open::that(url) {
|
||||
Ok(()) => {
|
||||
println!("Opened: {url}");
|
||||
Some(url.clone())
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to open browser: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn truncate_leaves_short_strings_alone() {
|
||||
assert_eq!(truncate_with_ellipsis("short", 10), "short");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_adds_ellipsis_to_long_strings() {
|
||||
assert_eq!(
|
||||
truncate_with_ellipsis("this is a very long title", 15),
|
||||
"this is a ve..."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_handles_exact_length() {
|
||||
assert_eq!(truncate_with_ellipsis("exactly10!", 10), "exactly10!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_time_formats_correctly() {
|
||||
let now = now_ms();
|
||||
|
||||
assert_eq!(format_relative_time(now - 30_000), "just now"); // 30s ago
|
||||
assert_eq!(format_relative_time(now - 120_000), "2 min ago"); // 2 min ago
|
||||
assert_eq!(format_relative_time(now - 7_200_000), "2 hours ago"); // 2 hours ago
|
||||
assert_eq!(format_relative_time(now - 172_800_000), "2 days ago"); // 2 days ago
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_labels_empty() {
|
||||
assert_eq!(format_labels(&[], 2), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_labels_single() {
|
||||
assert_eq!(format_labels(&["bug".to_string()], 2), "[bug]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_labels_multiple() {
|
||||
let labels = vec!["bug".to_string(), "urgent".to_string()];
|
||||
assert_eq!(format_labels(&labels, 2), "[bug, urgent]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_labels_overflow() {
|
||||
let labels = vec![
|
||||
"bug".to_string(),
|
||||
"urgent".to_string(),
|
||||
"wip".to_string(),
|
||||
"blocked".to_string(),
|
||||
];
|
||||
assert_eq!(format_labels(&labels, 2), "[bug, urgent +2]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_discussions_empty() {
|
||||
assert_eq!(format_discussions(0, 0), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_discussions_no_unresolved() {
|
||||
assert_eq!(format_discussions(5, 0), "5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_discussions_with_unresolved() {
|
||||
assert_eq!(format_discussions(5, 2), "5/2!");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user