refactor(commands): Add IngestDisplay, resolve_project, and color-aware tables

Ingest:
- Introduce IngestDisplay struct with show_progress/show_text booleans
  to decouple progress bars from text output. Replaces the robot_mode
  bool parameter with explicit display control, enabling sync to show
  progress without duplicating summary text (progress_only mode).
- Use resolve_project() for --project filtering instead of LIKE queries,
  providing proper error messages for ambiguous or missing projects.

List:
- Add colored_cell() helper that checks console::colors_enabled() before
  applying comfy-table foreground colors, bridging the gap between the
  console and comfy-table crates for --color flag support.
- Use resolve_project() for project filtering (exact ID match).
- Improve since filter to return explicit errors instead of silently
  ignoring invalid values.
- Improve format_relative_time for proper singular/plural forms.

Search:
- Validate --after/--updated-after with explicit error messages.
- Handle optional title field (Option<String>) in HydratedRow.

Show:
- Use resolve_project() for project disambiguation.

Sync:
- Thread robot_mode via SyncOptions for IngestDisplay selection.
- Use IngestDisplay::progress_only() in interactive sync mode.

GenerateDocs:
- Use resolve_project() for --project filtering.

Co-Authored-By: Claude (us.anthropic.claude-opus-4-5-20251101-v1:0) <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-01-30 16:54:36 -05:00
parent 585b746461
commit 667f70e177
7 changed files with 207 additions and 104 deletions

View File

@@ -6,10 +6,21 @@ use serde::Serialize;
use crate::Config;
use crate::core::db::create_connection;
use crate::core::error::Result;
use crate::core::error::{LoreError, Result};
use crate::core::paths::get_db_path;
use crate::core::project::resolve_project;
use crate::core::time::{ms_to_iso, now_ms, parse_since};
/// Apply foreground color to a Cell only if colors are enabled.
fn colored_cell(content: impl std::fmt::Display, color: Color) -> Cell {
let cell = Cell::new(content);
if console::colors_enabled() {
cell.fg(color)
} else {
cell
}
}
/// Issue row for display.
#[derive(Debug, Serialize)]
pub struct IssueListRow {
@@ -232,11 +243,9 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(project) = filters.project {
// Exact match or suffix match after '/' to avoid partial matches
// e.g. "foo" matches "group/foo" but NOT "group/foobar"
where_clauses.push("(p.path_with_namespace = ? OR p.path_with_namespace LIKE ?)");
params.push(Box::new(project.to_string()));
params.push(Box::new(format!("%/{project}")));
let project_id = resolve_project(conn, project)?;
where_clauses.push("i.project_id = ?");
params.push(Box::new(project_id));
}
if let Some(state) = filters.state
@@ -264,9 +273,13 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
}
// Handle since filter
if let Some(since_str) = filters.since
&& let Some(cutoff_ms) = parse_since(since_str)
{
if let Some(since_str) = filters.since {
let cutoff_ms = parse_since(since_str).ok_or_else(|| {
LoreError::Other(format!(
"Invalid --since value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.",
since_str
))
})?;
where_clauses.push("i.updated_at >= ?");
params.push(Box::new(cutoff_ms));
}
@@ -419,11 +432,9 @@ fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult>
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(project) = filters.project {
// Exact match or suffix match after '/' to avoid partial matches
// e.g. "foo" matches "group/foo" but NOT "group/foobar"
where_clauses.push("(p.path_with_namespace = ? OR p.path_with_namespace LIKE ?)");
params.push(Box::new(project.to_string()));
params.push(Box::new(format!("%/{project}")));
let project_id = resolve_project(conn, project)?;
where_clauses.push("m.project_id = ?");
params.push(Box::new(project_id));
}
if let Some(state) = filters.state
@@ -461,9 +472,13 @@ fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult>
}
// Handle since filter
if let Some(since_str) = filters.since
&& let Some(cutoff_ms) = parse_since(since_str)
{
if let Some(since_str) = filters.since {
let cutoff_ms = parse_since(since_str).ok_or_else(|| {
LoreError::Other(format!(
"Invalid --since value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.",
since_str
))
})?;
where_clauses.push("m.updated_at >= ?");
params.push(Box::new(cutoff_ms));
}
@@ -628,10 +643,22 @@ fn format_relative_time(ms_epoch: i64) -> String {
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),
d if d < 86_400_000 => {
let n = d / 3_600_000;
format!("{n} {} ago", if n == 1 { "hour" } else { "hours" })
}
d if d < 604_800_000 => {
let n = d / 86_400_000;
format!("{n} {} ago", if n == 1 { "day" } else { "days" })
}
d if d < 2_592_000_000 => {
let n = d / 604_800_000;
format!("{n} {} ago", if n == 1 { "week" } else { "weeks" })
}
_ => {
let n = diff / 2_592_000_000;
format!("{n} {} ago", if n == 1 { "month" } else { "months" })
}
}
}
@@ -735,19 +762,19 @@ pub fn print_list_issues(result: &ListResult) {
let discussions = format_discussions(issue.discussion_count, issue.unresolved_count);
let state_cell = if issue.state == "opened" {
Cell::new(&issue.state).fg(Color::Green)
colored_cell(&issue.state, Color::Green)
} else {
Cell::new(&issue.state).fg(Color::DarkGrey)
colored_cell(&issue.state, Color::DarkGrey)
};
table.add_row(vec![
Cell::new(format!("#{}", issue.iid)).fg(Color::Cyan),
colored_cell(format!("#{}", issue.iid), Color::Cyan),
Cell::new(title),
state_cell,
Cell::new(assignee).fg(Color::Magenta),
Cell::new(labels).fg(Color::Yellow),
colored_cell(assignee, Color::Magenta),
colored_cell(labels, Color::Yellow),
Cell::new(discussions),
Cell::new(relative_time).fg(Color::DarkGrey),
colored_cell(relative_time, Color::DarkGrey),
]);
}
@@ -807,7 +834,6 @@ pub fn print_list_mrs(result: &MrListResult) {
]);
for mr in &result.mrs {
// Add [DRAFT] prefix for draft MRs
let title = if mr.draft {
format!("[DRAFT] {}", truncate_with_ellipsis(&mr.title, 38))
} else {
@@ -819,25 +845,24 @@ pub fn print_list_mrs(result: &MrListResult) {
let discussions = format_discussions(mr.discussion_count, mr.unresolved_count);
let state_cell = match mr.state.as_str() {
"opened" => Cell::new(&mr.state).fg(Color::Green),
"merged" => Cell::new(&mr.state).fg(Color::Magenta),
"closed" => Cell::new(&mr.state).fg(Color::Red),
"locked" => Cell::new(&mr.state).fg(Color::Yellow),
_ => Cell::new(&mr.state).fg(Color::DarkGrey),
"opened" => colored_cell(&mr.state, Color::Green),
"merged" => colored_cell(&mr.state, Color::Magenta),
"closed" => colored_cell(&mr.state, Color::Red),
"locked" => colored_cell(&mr.state, Color::Yellow),
_ => colored_cell(&mr.state, Color::DarkGrey),
};
table.add_row(vec![
Cell::new(format!("!{}", mr.iid)).fg(Color::Cyan),
colored_cell(format!("!{}", mr.iid), Color::Cyan),
Cell::new(title),
state_cell,
Cell::new(format!(
"@{}",
truncate_with_ellipsis(&mr.author_username, 12)
))
.fg(Color::Magenta),
Cell::new(branches).fg(Color::Blue),
colored_cell(
format!("@{}", truncate_with_ellipsis(&mr.author_username, 12)),
Color::Magenta,
),
colored_cell(branches, Color::Blue),
Cell::new(discussions),
Cell::new(relative_time).fg(Color::DarkGrey),
colored_cell(relative_time, Color::DarkGrey),
]);
}