10 Commits

Author SHA1 Message Date
teernisse
eef73decb5 fix(cli): timeline tag width, test env isolation, and logging verbosity
Miscellaneous fixes across CLI and core modules:

- Timeline: widen TAG_WIDTH from 10 to 11 to accommodate longer event
  type labels without truncation
- render.rs: save and restore LORE_ICONS env var in glyph_mode test to
  prevent interference from the test environment leaking into or from
  other tests that set LORE_ICONS
- logging.rs: adjust verbose=1 to info level (was debug), verbose=2 to
  debug — this reduces noise at -v while keeping -vv as the full debug
  experience
- issues.rs, merge_requests.rs: use infodebug! macro consistently for
  ingestion summary logging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 11:25:42 -05:00
teernisse
bb6660178c feat(sync): per-project breakdown, status enrichment progress bars, and summary polish
Add per-project detail rows beneath stage completion lines during multi-project
syncs, showing itemized counts (issues/MRs, discussions, events, statuses, diffs)
for each project. Previously, only aggregate totals were visible, making it hard
to diagnose which project contributed what during a sync.

Status enrichment gets proper progress bars replacing the old spinner-only
display: StatusEnrichmentStarted now carries a total count so the CLI can
render a determinate bar with rate and ETA. The enrichment SQL is tightened
to use IS NOT comparisons for diff-only UPDATEs (skip rows where values
haven't changed), and a follow-up touch_stmt ensures status_synced_at is
updated even for unchanged rows so staleness detection works correctly.

Other improvements:
- New ProjectSummary struct aggregates per-project metrics during ingestion
- SyncResult gains statuses_enriched + per-project summary vectors
- "Already up to date" message when sync finds zero changes
- Remove Arc<AtomicBool> tick_started pattern from docs/embed stages
  (enable_steady_tick is idempotent, the guard was unnecessary)
- Progress bar styling: dim spinner, dark_gray track, per_sec + eta display
- Tick intervals tightened from 100ms to 60ms for smoother animation
- statuses_without_widget calculation uses fetch_result.statuses.len()
  instead of subtracting enriched (more accurate when some statuses lack
  work item widgets)
- Status enrichment completion log downgraded from info to debug

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 11:25:33 -05:00
teernisse
64e73b1cab fix(graphql): handle past HTTP dates in retry-after header gracefully
Extract parse_retry_after_value(header, now) as a pure function to enable
deterministic testing of Retry-After header parsing. The previous
implementation used let-chains with SystemTime::now() inline, which made
it untestable and would panic on negative durations when the server
clock was behind or the header contained a date in the past.

Changes:
- Extract parse_retry_after_value() taking an explicit `now` parameter
- Handle past HTTP dates by returning 1 second instead of panicking on
  negative Duration (date.duration_since(now) returns Err for past dates)
- Trim whitespace from header values before parsing
- Add test for past HTTP date returning 1 second minimum
- Add test for delta-seconds with surrounding whitespace

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 11:25:19 -05:00
teernisse
361757568f refactor(cli): remove deprecated stage_spinner, migrate remaining callers to v2
Phase 7 cleanup: migrate timeline.rs and main.rs search spinner
from stage_spinner() to stage_spinner_v2() with proper icon labels,
then remove the now-unused stage_spinner() function and its tests.

No external callers remain for the old numbered-stage API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:13:06 -05:00
Taylor Eernisse
8572f6cc04 refactor(cli): polish secondary commands with icons, number formatting, and section dividers
Phase 6 of the UX overhaul. Applies consistent visual treatment across
the remaining command outputs: stats, doctor, timeline, who, count,
and drift.

Stats (stats.rs):
- Apply render::format_number() to all numeric values (documents,
  FTS indexed, embedding counts, chunks) for thousand-separator
  formatting in large databases

Doctor (doctor.rs):
- Replace Unicode check/warning/cross symbols with Icons::success(),
  Icons::warning(), Icons::error() for glyph-mode awareness
- Add summary line after checks showing "Ready/Not ready" with counts
  of passed, warnings, and failed checks separated by middle dots
- Remove "lore doctor" title header for cleaner output

Count (count.rs):
- Right-align numeric values with {:>10} format for columnar output
  in count and state breakdown displays

Timeline (timeline.rs):
- Add entity icons (issue/MR) before entity references in event rows
- Refactor format_event_tag to pad plain text before applying style,
  preventing ANSI codes from breaking column alignment
- Extract style_padded() helper for width-then-style pattern

Who (who.rs):
- Add Icons::user() before usernames in expert, workload, reviews,
  and overlap displays
- Replace manual bold section headers with render::section_divider()
  in workload view (Assigned Issues, Authored MRs, Reviewing MRs,
  Unresolved Discussions)

Drift (drift.rs):
- Add Icons::error()/success() before drift detection status line
- Replace '#' bar character with Unicode full block for similarity
  curve visualization

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:06:05 -05:00
Taylor Eernisse
d0744039ef refactor(show): polish issue and MR detail views with section dividers and icons
Phase 4 of the UX overhaul. Restructures the show issue and show MR
detail displays with consistent section layout, state icons, and
improved typography.

Issue detail changes:
- Replace bold header + box-drawing underline with indented title using
  Theme::bold() for the title text only
- Organize fields into named sections using render::section_divider():
  Details, Development, Description, Discussions
- Add state icons (Icons::issue_opened/closed) alongside text labels
- Add relative time in parentheses next to Created/Updated dates
- Switch labels from "Labels: (none)" to only showing when present,
  using format_labels_bare for clean comma-separated output
- Move URL and confidential indicator into Details section
- Closing MRs show state-colored icons (merged/opened/closed)
- Discussions use section_divider instead of bold text, remove colons
  from author lines, adjust wrap widths for consistent indentation

MR detail changes:
- Same section-divider layout: Details, Description, Discussions
- State icons for opened/merged/closed using Icons::mr_* helpers
- Draft indicator uses Icons::mr_draft() instead of [Draft] text prefix
- Relative times added to Created, Updated, Merged, Closed dates
- Reviewers and Assignees fields aligned with fixed-width labels
- Labels shown only when present, using format_labels_bare
- Discussion formatting matches issue detail style

Both views use 5-space left indent for field alignment and consistent
wrap widths (72 for descriptions, 68/66 for discussion notes/replies).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:06:05 -05:00
Taylor Eernisse
4b372dfb38 refactor(list): polish list commands with icons, compact timestamps, and styled discussions
Phase 3 of the UX overhaul. Enhances the issues, merge requests, and
notes list displays with visual indicators and improved formatting.

List display changes (src/cli/commands/list.rs):
- Add state icons to issues (opened/closed) and merge requests
  (opened/merged/closed) using Icons:: helpers alongside text labels
- Replace [DRAFT] prefix with Icons::mr_draft() glyph for draft MRs
- Switch from format_relative_time to format_relative_time_compact for
  tighter column widths in tabular output
- Switch from format_labels to format_labels_bare for unlabeled style
- Change format_discussions() return type from String to StyledCell so
  unresolved counts render with Theme::warning() color inline
- Bold the section headers ("Issues", "Merge Requests", "Notes")
  with count separated from the label for cleaner scanning
- Import Icons from render module

Test updates (src/cli/commands/list_tests.rs):
- Update format_discussions tests to assert on StyledCell.text field
  instead of raw String, since the function now returns styled output
- The unresolved-count test checks starts_with/contains to handle
  embedded ANSI escape codes from Theme::warning()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:06:05 -05:00
Taylor Eernisse
af8fc4af76 refactor(sync): overhaul progress display with stage spinners and summaries
Phase 2 of the UX overhaul. Replaces the old numbered-stage progress
system (1/4, 2/4...) and manual indicatif ProgressBar/ProgressStyle
setup with the new centralized progress helpers.

Sync command changes (src/cli/commands/sync.rs):
- Replace stage_spinner(n, total, msg) with stage_spinner_v2(icon, label, status)
  removing the rigid numbered-stage counter in favor of named stages
- Replace manual ProgressBar::new + ProgressStyle::default_bar for docs
  and embed sub-progress with nested_progress(label, len, robot_mode)
- Add finish_stage() calls that display a completion summary with
  elapsed time, e.g. "Issues  42 issues from 3 projects  1.2s"
- Each stage (Issues, MRs, Docs, Embed) now reports what it did on
  completion rather than just clearing the spinner silently
- Embed failure path uses Icons::warning() instead of inline Theme
  formatting, keeping error display consistent with success path
- Remove indicatif direct dependency from sync.rs (now handled by
  progress module)

Main entry point changes (src/main.rs):
- Add GlyphMode detection: auto-detect Unicode/Nerd Font support or
  fall back to ASCII based on --icons flag, --color=never, NO_COLOR,
  or robot mode
- Update all LoreRenderer::init() calls to pass GlyphMode alongside
  ColorMode for icon-aware rendering throughout the CLI
- Overhaul handle_error() formatting: use Icons::error() glyph,
  bold error text, arrow prefixed action suggestions, and breathing
  room with blank lines for scannability
- Migrate handle_embed() progress bar from manual ProgressBar +
  ProgressStyle to nested_progress() helper, matching sync command

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:06:05 -05:00
Taylor Eernisse
96b288ccdd refactor(search): polish search results rendering with semantic Theme styles
Phase 5 of the UX overhaul. Migrates search result display from raw
console styling to the centralized Theme system with semantic methods,
improving visual consistency and readability.

Search result changes:
- Type badges now use semantic styles (issue_ref, mr_ref) with
  fixed-width alignment for clean columnar layout
- Snippet rendering uses Theme::highlight() for matched terms and
  Theme::muted() for surrounding context, replacing bold+underline
- Metadata line uses Theme::username() for authors and per-part
  styling with middle-dot separators instead of a single dim line
- Result numbering uses muted style with right-aligned width
- Consistent 8-space indent for metadata, snippets, and explain lines
- Header line uses muted style for search mode instead of dim+parens
- Trailing blank line moved after the result loop instead of per-result

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:06:05 -05:00
teernisse
d710403567 feat(cli): add GlyphMode icon system, Theme extensions, and progress API
Phase 1 of UX skin overhaul: foundation layer that all subsequent
phases build upon.

Icons: 3-tier glyph system (Nerd Font / Unicode / ASCII) with
auto-detection from TERM_PROGRAM, LORE_ICONS env, or --icons flag.
16 semantic icon methods on Icons struct (success, warning, error,
issue states, MR states, note, search, user, sync, waiting).

Theme: 4 new semantic styles — muted (#6b7280), highlight (#fbbf24),
timing (#94a3b8), state_draft (#6b7280).

Progress: stage_spinner_v2 with icon prefix, nested_progress with
bounded bar/throughput/ETA, finish_stage for static completion lines,
format_elapsed for compact duration strings.

Utilities: format_relative_time_compact (3h, 2d, 1w, 3mo),
format_labels_bare (comma-separated without brackets).

CLI: --icons global flag, GLOBAL_FLAGS registry updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:06:05 -05:00
23 changed files with 1278 additions and 497 deletions

View File

@@ -44,6 +44,7 @@ const GLOBAL_FLAGS: &[&str] = &[
"--robot",
"--json",
"--color",
"--icons",
"--quiet",
"--no-quiet",
"--verbose",

View File

@@ -333,7 +333,7 @@ pub fn print_count(result: &CountResult) {
if let Some(system_count) = result.system_count {
println!(
"{}: {} {}",
"{}: {:>10} {}",
Theme::info().render(&result.entity),
Theme::bold().render(&count_str),
Theme::dim().render(&format!(
@@ -343,22 +343,22 @@ pub fn print_count(result: &CountResult) {
);
} else {
println!(
"{}: {}",
"{}: {:>10}",
Theme::info().render(&result.entity),
Theme::bold().render(&count_str)
);
}
if let Some(breakdown) = &result.state_breakdown {
println!(" opened: {}", render::format_number(breakdown.opened));
println!(" opened: {:>10}", render::format_number(breakdown.opened));
if let Some(merged) = breakdown.merged {
println!(" merged: {}", render::format_number(merged));
println!(" merged: {:>10}", render::format_number(merged));
}
println!(" closed: {}", render::format_number(breakdown.closed));
println!(" closed: {:>10}", render::format_number(breakdown.closed));
if let Some(locked) = breakdown.locked
&& locked > 0
{
println!(" locked: {}", render::format_number(locked));
println!(" locked: {:>10}", render::format_number(locked));
}
}
}

View File

@@ -1,4 +1,4 @@
use crate::cli::render::Theme;
use crate::cli::render::{Icons, Theme};
use serde::Serialize;
use crate::core::config::Config;
@@ -530,7 +530,7 @@ fn check_logging(config: Option<&Config>) -> LoggingCheck {
}
pub fn print_doctor_results(result: &DoctorResult) {
println!("\nlore doctor\n");
println!();
print_check("Config", &result.checks.config.result);
print_check("Database", &result.checks.database.result);
@@ -539,31 +539,53 @@ pub fn print_doctor_results(result: &DoctorResult) {
print_check("Ollama", &result.checks.ollama.result);
print_check("Logging", &result.checks.logging.result);
// Count statuses
let checks = [
&result.checks.config.result,
&result.checks.database.result,
&result.checks.gitlab.result,
&result.checks.projects.result,
&result.checks.ollama.result,
&result.checks.logging.result,
];
let passed = checks
.iter()
.filter(|c| c.status == CheckStatus::Ok)
.count();
let warnings = checks
.iter()
.filter(|c| c.status == CheckStatus::Warning)
.count();
let failed = checks
.iter()
.filter(|c| c.status == CheckStatus::Error)
.count();
println!();
let mut summary_parts = Vec::new();
if result.success {
let ollama_ok = result.checks.ollama.result.status == CheckStatus::Ok;
if ollama_ok {
println!("{}", Theme::success().render("Status: Ready"));
summary_parts.push(Theme::success().render("Ready"));
} else {
println!(
"{} {}",
Theme::success().render("Status: Ready"),
Theme::warning()
.render("(lexical search available, semantic search requires Ollama)")
);
summary_parts.push(Theme::error().render("Not ready"));
}
} else {
println!("{}", Theme::error().render("Status: Not ready"));
summary_parts.push(format!("{passed} passed"));
if warnings > 0 {
summary_parts.push(Theme::warning().render(&format!("{warnings} warning")));
}
if failed > 0 {
summary_parts.push(Theme::error().render(&format!("{failed} failed")));
}
println!(" {}", summary_parts.join(" \u{b7} "));
println!();
}
fn print_check(name: &str, result: &CheckResult) {
let symbol = match result.status {
CheckStatus::Ok => Theme::success().render("\u{2713}"),
CheckStatus::Warning => Theme::warning().render("\u{26a0}"),
CheckStatus::Error => Theme::error().render("\u{2717}"),
let icon = match result.status {
CheckStatus::Ok => Theme::success().render(Icons::success()),
CheckStatus::Warning => Theme::warning().render(Icons::warning()),
CheckStatus::Error => Theme::error().render(Icons::error()),
};
let message = result.message.as_deref().unwrap_or("");
@@ -573,5 +595,5 @@ fn print_check(name: &str, result: &CheckResult) {
CheckStatus::Error => Theme::error().render(message),
};
println!(" {symbol} {:<10} {message_styled}", name);
println!(" {icon} {:<10} {message_styled}", name);
}

View File

@@ -4,7 +4,7 @@ use std::sync::LazyLock;
use regex::Regex;
use serde::Serialize;
use crate::cli::render::Theme;
use crate::cli::render::{Icons, Theme};
use crate::cli::robot::RobotMeta;
use crate::core::config::Config;
use crate::core::db::create_connection;
@@ -428,7 +428,11 @@ pub fn print_drift_human(response: &DriftResponse) {
println!();
if response.drift_detected {
println!("{}", Theme::error().bold().render("DRIFT DETECTED"));
println!(
"{} {}",
Theme::error().render(Icons::error()),
Theme::error().bold().render("DRIFT DETECTED")
);
if let Some(dp) = &response.drift_point {
println!(
" At note #{} by @{} ({}) - similarity {:.2}",
@@ -439,7 +443,11 @@ pub fn print_drift_human(response: &DriftResponse) {
println!(" Topics: {}", response.drift_topics.join(", "));
}
} else {
println!("{}", Theme::success().render("No drift detected"));
println!(
"{} {}",
Theme::success().render(Icons::success()),
Theme::success().render("No drift detected")
);
}
println!();
@@ -450,7 +458,7 @@ pub fn print_drift_human(response: &DriftResponse) {
println!("{}", Theme::bold().render("Similarity Curve:"));
for pt in &response.similarity_curve {
let bar_len = ((pt.similarity.max(0.0)) * 30.0) as usize;
let bar: String = "#".repeat(bar_len);
let bar: String = "\u{2588}".repeat(bar_len);
println!(
" {:>3} {:.2} {} @{}",
pt.note_index, pt.similarity, bar, pt.author

View File

@@ -46,6 +46,21 @@ pub struct IngestResult {
pub mr_diffs_failed: usize,
pub status_enrichment_errors: usize,
pub status_enrichment_projects: Vec<ProjectStatusEnrichment>,
pub project_summaries: Vec<ProjectSummary>,
}
/// Per-project summary for display in stage completion sub-rows.
#[derive(Debug, Default)]
pub struct ProjectSummary {
pub path: String,
pub items_upserted: usize,
pub discussions_synced: usize,
pub events_fetched: usize,
pub events_failed: usize,
pub statuses_enriched: usize,
pub statuses_seen: usize,
pub mr_diffs_fetched: usize,
pub mr_diffs_failed: usize,
}
/// Per-project status enrichment result, collected during ingestion.
@@ -388,11 +403,11 @@ async fn run_ingest_inner(
let s = multi.add(ProgressBar::new_spinner());
s.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.blue} {msg}")
.template("{spinner:.cyan} {msg}")
.unwrap(),
);
s.set_message(format!("Fetching {type_label} from {path}..."));
s.enable_steady_tick(std::time::Duration::from_millis(100));
s.enable_steady_tick(std::time::Duration::from_millis(60));
s
};
@@ -403,12 +418,13 @@ async fn run_ingest_inner(
b.set_style(
ProgressStyle::default_bar()
.template(
" {spinner:.blue} {prefix:.cyan} Syncing discussions [{bar:30.cyan/dim}] {pos}/{len}",
" {spinner:.dim} {prefix:.cyan} Syncing discussions [{bar:30.cyan/dark_gray}] {pos}/{len} {per_sec:.dim} {eta:.dim}",
)
.unwrap()
.progress_chars("=> "),
.progress_chars(crate::cli::render::Icons::progress_chars()),
);
b.set_prefix(path.clone());
b.enable_steady_tick(std::time::Duration::from_millis(60));
b
};
@@ -445,7 +461,7 @@ async fn run_ingest_inner(
spinner_clone.finish_and_clear();
let agg_total = agg_disc_total_clone.fetch_add(total, Ordering::Relaxed) + total;
disc_bar_clone.set_length(total as u64);
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60));
stage_bar_clone.set_message(format!(
"Syncing discussions... (0/{agg_total})"
));
@@ -465,7 +481,7 @@ async fn run_ingest_inner(
spinner_clone.finish_and_clear();
let agg_total = agg_disc_total_clone.fetch_add(total, Ordering::Relaxed) + total;
disc_bar_clone.set_length(total as u64);
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60));
stage_bar_clone.set_message(format!(
"Syncing discussions... (0/{agg_total})"
));
@@ -486,11 +502,11 @@ async fn run_ingest_inner(
disc_bar_clone.set_length(total as u64);
disc_bar_clone.set_style(
ProgressStyle::default_bar()
.template(" {spinner:.blue} {prefix:.cyan} Fetching resource events [{bar:30.cyan/dim}] {pos}/{len}")
.template(" {spinner:.dim} {prefix:.cyan} Fetching resource events [{bar:30.cyan/dark_gray}] {pos}/{len} {per_sec:.dim} {eta:.dim}")
.unwrap()
.progress_chars("=> "),
.progress_chars(crate::cli::render::Icons::progress_chars()),
);
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60));
agg_events_total_clone.fetch_add(total, Ordering::Relaxed);
stage_bar_clone.set_message(
"Fetching resource events...".to_string()
@@ -510,7 +526,7 @@ async fn run_ingest_inner(
ProgressEvent::ClosesIssuesFetchStarted { total } => {
disc_bar_clone.reset();
disc_bar_clone.set_length(total as u64);
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60));
stage_bar_clone.set_message(
"Fetching closes-issues references...".to_string()
);
@@ -524,7 +540,7 @@ async fn run_ingest_inner(
ProgressEvent::MrDiffsFetchStarted { total } => {
disc_bar_clone.reset();
disc_bar_clone.set_length(total as u64);
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60));
stage_bar_clone.set_message(
"Fetching MR file changes...".to_string()
);
@@ -535,35 +551,37 @@ async fn run_ingest_inner(
ProgressEvent::MrDiffsFetchComplete { .. } => {
disc_bar_clone.finish_and_clear();
}
ProgressEvent::StatusEnrichmentStarted => {
spinner_clone.set_message(format!(
"{path_for_cb}: Enriching work item statuses..."
));
ProgressEvent::StatusEnrichmentStarted { total } => {
spinner_clone.finish_and_clear();
disc_bar_clone.reset();
disc_bar_clone.set_length(total as u64);
disc_bar_clone.set_style(
ProgressStyle::default_bar()
.template(" {spinner:.dim} {prefix:.cyan} Statuses [{bar:30.cyan/dark_gray}] {pos}/{len} {per_sec:.dim} {eta:.dim}")
.unwrap()
.progress_chars(crate::cli::render::Icons::progress_chars()),
);
disc_bar_clone.set_prefix(path_for_cb.clone());
disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60));
stage_bar_clone.set_message(
"Enriching work item statuses...".to_string()
);
}
ProgressEvent::StatusEnrichmentPageFetched { items_so_far } => {
spinner_clone.set_message(format!(
"{path_for_cb}: Fetching statuses... ({items_so_far} work items)"
));
disc_bar_clone.set_position(items_so_far as u64);
stage_bar_clone.set_message(format!(
"Enriching work item statuses... ({items_so_far} fetched)"
));
}
ProgressEvent::StatusEnrichmentWriting { total } => {
spinner_clone.set_message(format!(
"{path_for_cb}: Writing {total} statuses..."
));
disc_bar_clone.set_message(format!("Writing {total} statuses..."));
stage_bar_clone.set_message(format!(
"Writing {total} work item statuses..."
));
}
ProgressEvent::StatusEnrichmentComplete { enriched, cleared } => {
disc_bar_clone.finish_and_clear();
if enriched > 0 || cleared > 0 {
spinner_clone.set_message(format!(
"{path_for_cb}: {enriched} statuses enriched, {cleared} cleared"
));
stage_bar_clone.set_message(format!(
"Status enrichment: {enriched} enriched, {cleared} cleared"
));
@@ -656,6 +674,17 @@ async fn run_ingest_inner(
first_partial_error: result.first_partial_error.clone(),
error: result.status_enrichment_error.clone(),
});
total.project_summaries.push(ProjectSummary {
path: path.clone(),
items_upserted: result.issues_upserted,
discussions_synced: result.discussions_fetched,
events_fetched: result.resource_events_fetched,
events_failed: result.resource_events_failed,
statuses_enriched: result.statuses_enriched,
statuses_seen: result.statuses_seen,
mr_diffs_fetched: 0,
mr_diffs_failed: 0,
});
}
Ok(ProjectIngestOutcome::Mrs {
ref path,
@@ -679,6 +708,17 @@ async fn run_ingest_inner(
total.resource_events_failed += result.resource_events_failed;
total.mr_diffs_fetched += result.mr_diffs_fetched;
total.mr_diffs_failed += result.mr_diffs_failed;
total.project_summaries.push(ProjectSummary {
path: path.clone(),
items_upserted: result.mrs_upserted,
discussions_synced: result.discussions_fetched,
events_fetched: result.resource_events_fetched,
events_failed: result.resource_events_failed,
statuses_enriched: 0,
statuses_seen: 0,
mr_diffs_fetched: result.mr_diffs_fetched,
mr_diffs_failed: result.mr_diffs_failed,
});
}
}
}

View File

@@ -1,4 +1,4 @@
use crate::cli::render::{self, Align, StyledCell, Table as LoreTable, Theme};
use crate::cli::render::{self, Align, Icons, StyledCell, Table as LoreTable, Theme};
use rusqlite::Connection;
use serde::Serialize;
@@ -657,15 +657,17 @@ fn format_assignees(assignees: &[String]) -> String {
}
}
fn format_discussions(total: i64, unresolved: i64) -> String {
fn format_discussions(total: i64, unresolved: i64) -> StyledCell {
if total == 0 {
return String::new();
return StyledCell::plain(String::new());
}
if unresolved > 0 {
format!("{total}/{unresolved}!")
let text = format!("{total}/");
let warn = Theme::warning().render(&format!("{unresolved}!"));
StyledCell::plain(format!("{text}{warn}"))
} else {
format!("{total}")
StyledCell::plain(format!("{total}"))
}
}
@@ -681,7 +683,8 @@ pub fn print_list_issues(result: &ListResult) {
}
println!(
"Issues (showing {} of {})\n",
"{} {} of {}\n",
Theme::bold().render("Issues"),
result.issues.len(),
result.total_count
);
@@ -698,16 +701,17 @@ pub fn print_list_issues(result: &ListResult) {
for issue in &result.issues {
let title = render::truncate(&issue.title, 45);
let relative_time = render::format_relative_time(issue.updated_at);
let labels = render::format_labels(&issue.labels, 2);
let relative_time = render::format_relative_time_compact(issue.updated_at);
let labels = render::format_labels_bare(&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" {
StyledCell::styled(&issue.state, Theme::success())
let (icon, state_style) = if issue.state == "opened" {
(Icons::issue_opened(), Theme::success())
} else {
StyledCell::styled(&issue.state, Theme::dim())
(Icons::issue_closed(), Theme::dim())
};
let state_cell = StyledCell::styled(format!("{icon} {}", issue.state), state_style);
let mut row = vec![
StyledCell::styled(format!("#{}", issue.iid), Theme::info()),
@@ -730,7 +734,7 @@ pub fn print_list_issues(result: &ListResult) {
row.extend([
StyledCell::styled(assignee, Theme::accent()),
StyledCell::styled(labels, Theme::warning()),
StyledCell::plain(discussions),
discussions,
StyledCell::styled(relative_time, Theme::dim()),
]);
table.add_row(row);
@@ -783,7 +787,8 @@ pub fn print_list_mrs(result: &MrListResult) {
}
println!(
"Merge Requests (showing {} of {})\n",
"{} {} of {}\n",
Theme::bold().render("Merge Requests"),
result.mrs.len(),
result.total_count
);
@@ -796,22 +801,23 @@ pub fn print_list_mrs(result: &MrListResult) {
for mr in &result.mrs {
let title = if mr.draft {
format!("[DRAFT] {}", render::truncate(&mr.title, 38))
format!("{} {}", Icons::mr_draft(), render::truncate(&mr.title, 42))
} else {
render::truncate(&mr.title, 45)
};
let relative_time = render::format_relative_time(mr.updated_at);
let relative_time = render::format_relative_time_compact(mr.updated_at);
let branches = format_branches(&mr.target_branch, &mr.source_branch, 25);
let discussions = format_discussions(mr.discussion_count, mr.unresolved_count);
let state_cell = match mr.state.as_str() {
"opened" => StyledCell::styled(&mr.state, Theme::success()),
"merged" => StyledCell::styled(&mr.state, Theme::accent()),
"closed" => StyledCell::styled(&mr.state, Theme::error()),
"locked" => StyledCell::styled(&mr.state, Theme::warning()),
_ => StyledCell::styled(&mr.state, Theme::dim()),
let (icon, style) = match mr.state.as_str() {
"opened" => (Icons::mr_opened(), Theme::success()),
"merged" => (Icons::mr_merged(), Theme::accent()),
"closed" => (Icons::mr_closed(), Theme::error()),
"locked" => (Icons::mr_opened(), Theme::warning()),
_ => (Icons::mr_opened(), Theme::dim()),
};
let state_cell = StyledCell::styled(format!("{icon} {}", mr.state), style);
table.add_row(vec![
StyledCell::styled(format!("!{}", mr.iid), Theme::info()),
@@ -822,7 +828,7 @@ pub fn print_list_mrs(result: &MrListResult) {
Theme::accent(),
),
StyledCell::styled(branches, Theme::info()),
StyledCell::plain(discussions),
discussions,
StyledCell::styled(relative_time, Theme::dim()),
]);
}
@@ -909,7 +915,8 @@ pub fn print_list_notes(result: &NoteListResult) {
}
println!(
"Notes (showing {} of {})\n",
"{} {} of {}\n",
Theme::bold().render("Notes"),
result.notes.len(),
result.total_count
);
@@ -934,7 +941,7 @@ pub fn print_list_notes(result: &NoteListResult) {
.unwrap_or_default();
let path = format_note_path(note.position_new_path.as_deref(), note.position_new_line);
let parent = format_note_parent(note.noteable_type.as_deref(), note.parent_iid);
let relative_time = render::format_relative_time(note.created_at);
let relative_time = render::format_relative_time_compact(note.created_at);
let note_type = format_note_type(note.note_type.as_deref());
table.add_row(vec![

View File

@@ -62,17 +62,20 @@ fn format_labels_overflow() {
#[test]
fn format_discussions_empty() {
assert_eq!(format_discussions(0, 0), "");
assert_eq!(format_discussions(0, 0).text, "");
}
#[test]
fn format_discussions_no_unresolved() {
assert_eq!(format_discussions(5, 0), "5");
assert_eq!(format_discussions(5, 0).text, "5");
}
#[test]
fn format_discussions_with_unresolved() {
assert_eq!(format_discussions(5, 2), "5/2!");
let cell = format_discussions(5, 2);
// Text contains styled ANSI for warning-colored unresolved count
assert!(cell.text.starts_with("5/"), "got: {}", cell.text);
assert!(cell.text.contains("2!"), "got: {}", cell.text);
}
// -----------------------------------------------------------------------

View File

@@ -309,20 +309,20 @@ fn parse_json_array(json: &str) -> Vec<String> {
.collect()
}
/// Render FTS snippet with `<mark>` tags as terminal bold+underline.
/// Render FTS snippet with `<mark>` tags as terminal highlight style.
fn render_snippet(snippet: &str) -> String {
let mut result = String::new();
let mut remaining = snippet;
while let Some(start) = remaining.find("<mark>") {
result.push_str(&remaining[..start]);
result.push_str(&Theme::muted().render(&remaining[..start]));
remaining = &remaining[start + 6..];
if let Some(end) = remaining.find("</mark>") {
let highlighted = &remaining[..end];
result.push_str(&Theme::bold().underline().render(highlighted));
result.push_str(&Theme::highlight().render(highlighted));
remaining = &remaining[end + 7..];
}
}
result.push_str(remaining);
result.push_str(&Theme::muted().render(remaining));
result
}
@@ -345,32 +345,34 @@ pub fn print_search_results(response: &SearchResponse) {
"\n {} results for '{}' {}",
Theme::bold().render(&response.total_results.to_string()),
Theme::bold().render(&response.query),
Theme::dim().render(&format!("({})", response.mode))
Theme::muted().render(&response.mode)
);
println!();
for (i, result) in response.results.iter().enumerate() {
println!();
let type_badge = match result.source_type.as_str() {
"issue" => Theme::info().render("issue"),
"merge_request" => Theme::accent().render("mr"),
"discussion" => Theme::info().render("disc"),
"note" => Theme::info().render("note"),
_ => Theme::dim().render(&result.source_type),
"issue" => Theme::issue_ref().render("issue"),
"merge_request" => Theme::mr_ref().render(" mr "),
"discussion" => Theme::info().render(" disc"),
"note" => Theme::muted().render(" note"),
_ => Theme::muted().render(&format!("{:>5}", &result.source_type)),
};
// Title line: rank, type badge, title
println!(
" {} {} {}",
Theme::dim().render(&format!("{:>2}.", i + 1)),
" {:>3}. {} {}",
Theme::muted().render(&(i + 1).to_string()),
type_badge,
Theme::bold().render(&result.title)
);
// Metadata: project, author, labels — compact middle-dot line
let sep = Theme::muted().render(" \u{b7} ");
let mut meta_parts: Vec<String> = Vec::new();
meta_parts.push(result.project_path.clone());
meta_parts.push(Theme::muted().render(&result.project_path));
if let Some(ref author) = result.author {
meta_parts.push(format!("@{author}"));
meta_parts.push(Theme::username().render(&format!("@{author}")));
}
if !result.labels.is_empty() {
let label_str = if result.labels.len() <= 3 {
@@ -382,16 +384,13 @@ pub fn print_search_results(response: &SearchResponse) {
result.labels.len() - 2
)
};
meta_parts.push(label_str);
meta_parts.push(Theme::muted().render(&label_str));
}
println!(
" {}",
Theme::dim().render(&meta_parts.join(" \u{b7} "))
);
println!(" {}", meta_parts.join(&sep));
// Snippet with proper highlighting
// Snippet with highlight styling
let rendered = render_snippet(&result.snippet);
println!(" {}", Theme::dim().render(&rendered));
println!(" {rendered}");
if let Some(ref explain) = result.explain {
println!(
@@ -408,9 +407,9 @@ pub fn print_search_results(response: &SearchResponse) {
explain.rrf_score
);
}
}
println!();
}
}
#[derive(Serialize)]

View File

@@ -1,4 +1,4 @@
use crate::cli::render::{self, Theme};
use crate::cli::render::{self, Icons, Theme};
use rusqlite::Connection;
use serde::Serialize;
@@ -614,33 +614,47 @@ fn wrap_text(text: &str, width: usize, indent: &str) -> String {
}
pub fn print_show_issue(issue: &IssueDetail) {
let header = format!("Issue #{}: {}", issue.iid, issue.title);
println!("{}", Theme::bold().render(&header));
println!("{}", "\u{2501}".repeat(header.len().min(80)));
println!();
// Title line
println!(
" Issue #{}: {}",
issue.iid,
Theme::bold().render(&issue.title),
);
println!("Ref: {}", Theme::dim().render(&issue.references_full));
println!("Project: {}", Theme::info().render(&issue.project_path));
// Details section
println!("{}", render::section_divider("Details"));
let state_styled = if issue.state == "opened" {
Theme::success().render(&issue.state)
println!(
" Ref {}",
Theme::muted().render(&issue.references_full)
);
println!(
" Project {}",
Theme::info().render(&issue.project_path)
);
let (icon, state_style) = if issue.state == "opened" {
(Icons::issue_opened(), Theme::success())
} else {
Theme::dim().render(&issue.state)
(Icons::issue_closed(), Theme::dim())
};
println!("State: {}", state_styled);
println!(
" State {}",
state_style.render(&format!("{icon} {}", issue.state))
);
if let Some(status) = &issue.status_name {
println!(
" Status {}",
render::style_with_hex(status, issue.status_color.as_deref())
);
}
if issue.confidential {
println!(" {}", Theme::error().bold().render("CONFIDENTIAL"));
}
if let Some(status) = &issue.status_name {
println!(
"Status: {}",
render::style_with_hex(status, issue.status_color.as_deref())
);
}
println!("Author: @{}", issue.author_username);
println!(" Author @{}", issue.author_username);
if !issue.assignees.is_empty() {
let label = if issue.assignees.len() > 1 {
@@ -649,69 +663,82 @@ pub fn print_show_issue(issue: &IssueDetail) {
"Assignee"
};
println!(
"{}:{} {}",
" {}{} {}",
label,
" ".repeat(10 - label.len()),
" ".repeat(12 - label.len()),
issue
.assignees
.iter()
.map(|a| format!("@{}", a))
.map(|a| format!("@{a}"))
.collect::<Vec<_>>()
.join(", ")
);
}
println!("Created: {}", format_date(issue.created_at));
println!("Updated: {}", format_date(issue.updated_at));
println!(
" Created {} ({})",
format_date(issue.created_at),
render::format_relative_time_compact(issue.created_at),
);
println!(
" Updated {} ({})",
format_date(issue.updated_at),
render::format_relative_time_compact(issue.updated_at),
);
if let Some(closed_at) = &issue.closed_at {
println!("Closed: {}", closed_at);
println!(" Closed {closed_at}");
}
if let Some(due) = &issue.due_date {
println!("Due: {}", due);
println!(" Due {due}");
}
if let Some(ms) = &issue.milestone {
println!("Milestone: {}", ms);
println!(" Milestone {ms}");
}
if issue.labels.is_empty() {
println!("Labels: {}", Theme::dim().render("(none)"));
} else {
println!("Labels: {}", issue.labels.join(", "));
}
if !issue.closing_merge_requests.is_empty() {
println!();
println!("{}", Theme::bold().render("Development:"));
for mr in &issue.closing_merge_requests {
let state_indicator = match mr.state.as_str() {
"merged" => Theme::success().render(&mr.state),
"opened" => Theme::info().render(&mr.state),
"closed" => Theme::error().render(&mr.state),
_ => Theme::dim().render(&mr.state),
};
println!(" !{} {} ({})", mr.iid, mr.title, state_indicator);
}
if !issue.labels.is_empty() {
println!(
" Labels {}",
render::format_labels_bare(&issue.labels, issue.labels.len())
);
}
if let Some(url) = &issue.web_url {
println!("URL: {}", Theme::dim().render(url));
println!(" URL {}", Theme::muted().render(url));
}
println!();
// Development section
if !issue.closing_merge_requests.is_empty() {
println!("{}", render::section_divider("Development"));
for mr in &issue.closing_merge_requests {
let (mr_icon, mr_style) = match mr.state.as_str() {
"merged" => (Icons::mr_merged(), Theme::accent()),
"opened" => (Icons::mr_opened(), Theme::success()),
"closed" => (Icons::mr_closed(), Theme::error()),
_ => (Icons::mr_opened(), Theme::dim()),
};
println!(
" {} !{} {} {}",
mr_style.render(mr_icon),
mr.iid,
mr.title,
mr_style.render(&mr.state),
);
}
}
println!("{}", Theme::bold().render("Description:"));
// Description section
println!("{}", render::section_divider("Description"));
if let Some(desc) = &issue.description {
let wrapped = wrap_text(desc, 76, " ");
println!(" {}", wrapped);
let wrapped = wrap_text(desc, 72, " ");
println!(" {wrapped}");
} else {
println!(" {}", Theme::dim().render("(no description)"));
println!(" {}", Theme::muted().render("(no description)"));
}
println!();
// Discussions section
let user_discussions: Vec<&DiscussionDetail> = issue
.discussions
.iter()
@@ -719,13 +746,12 @@ pub fn print_show_issue(issue: &IssueDetail) {
.collect();
if user_discussions.is_empty() {
println!("{}", Theme::dim().render("Discussions: (none)"));
println!("\n {}", Theme::muted().render("No discussions"));
} else {
println!(
"{}",
Theme::bold().render(&format!("Discussions ({}):", user_discussions.len()))
render::section_divider(&format!("Discussions ({})", user_discussions.len()))
);
println!();
for discussion in user_discussions {
let user_notes: Vec<&NoteDetail> =
@@ -733,22 +759,22 @@ pub fn print_show_issue(issue: &IssueDetail) {
if let Some(first_note) = user_notes.first() {
println!(
" {} ({}):",
" {} {}",
Theme::info().render(&format!("@{}", first_note.author_username)),
format_date(first_note.created_at)
format_date(first_note.created_at),
);
let wrapped = wrap_text(&first_note.body, 72, " ");
println!(" {}", wrapped);
let wrapped = wrap_text(&first_note.body, 68, " ");
println!(" {wrapped}");
println!();
for reply in user_notes.iter().skip(1) {
println!(
" {} ({}):",
" {} {}",
Theme::info().render(&format!("@{}", reply.author_username)),
format_date(reply.created_at)
format_date(reply.created_at),
);
let wrapped = wrap_text(&reply.body, 68, " ");
println!(" {}", wrapped);
let wrapped = wrap_text(&reply.body, 66, " ");
println!(" {wrapped}");
println!();
}
}
@@ -757,36 +783,49 @@ pub fn print_show_issue(issue: &IssueDetail) {
}
pub fn print_show_mr(mr: &MrDetail) {
let draft_prefix = if mr.draft { "[Draft] " } else { "" };
let header = format!("MR !{}: {}{}", mr.iid, draft_prefix, mr.title);
println!("{}", Theme::bold().render(&header));
println!("{}", "\u{2501}".repeat(header.len().min(80)));
println!();
println!("Project: {}", Theme::info().render(&mr.project_path));
let state_styled = match mr.state.as_str() {
"opened" => Theme::success().render(&mr.state),
"merged" => Theme::accent().render(&mr.state),
"closed" => Theme::error().render(&mr.state),
_ => Theme::dim().render(&mr.state),
// Title line
let draft_prefix = if mr.draft {
format!("{} ", Icons::mr_draft())
} else {
String::new()
};
println!("State: {}", state_styled);
println!(
" MR !{}: {}{}",
mr.iid,
draft_prefix,
Theme::bold().render(&mr.title),
);
// Details section
println!("{}", render::section_divider("Details"));
println!(" Project {}", Theme::info().render(&mr.project_path));
let (icon, state_style) = match mr.state.as_str() {
"opened" => (Icons::mr_opened(), Theme::success()),
"merged" => (Icons::mr_merged(), Theme::accent()),
"closed" => (Icons::mr_closed(), Theme::error()),
_ => (Icons::mr_opened(), Theme::dim()),
};
println!(
" State {}",
state_style.render(&format!("{icon} {}", mr.state))
);
println!(
"Branches: {} -> {}",
" Branches {} -> {}",
Theme::info().render(&mr.source_branch),
Theme::warning().render(&mr.target_branch)
);
println!("Author: @{}", mr.author_username);
println!(" Author @{}", mr.author_username);
if !mr.assignees.is_empty() {
println!(
"Assignees: {}",
" Assignees {}",
mr.assignees
.iter()
.map(|a| format!("@{}", a))
.map(|a| format!("@{a}"))
.collect::<Vec<_>>()
.join(", ")
);
@@ -794,48 +833,63 @@ pub fn print_show_mr(mr: &MrDetail) {
if !mr.reviewers.is_empty() {
println!(
"Reviewers: {}",
" Reviewers {}",
mr.reviewers
.iter()
.map(|r| format!("@{}", r))
.map(|r| format!("@{r}"))
.collect::<Vec<_>>()
.join(", ")
);
}
println!("Created: {}", format_date(mr.created_at));
println!("Updated: {}", format_date(mr.updated_at));
println!(
" Created {} ({})",
format_date(mr.created_at),
render::format_relative_time_compact(mr.created_at),
);
println!(
" Updated {} ({})",
format_date(mr.updated_at),
render::format_relative_time_compact(mr.updated_at),
);
if let Some(merged_at) = mr.merged_at {
println!("Merged: {}", format_date(merged_at));
println!(
" Merged {} ({})",
format_date(merged_at),
render::format_relative_time_compact(merged_at),
);
}
if let Some(closed_at) = mr.closed_at {
println!("Closed: {}", format_date(closed_at));
println!(
" Closed {} ({})",
format_date(closed_at),
render::format_relative_time_compact(closed_at),
);
}
if mr.labels.is_empty() {
println!("Labels: {}", Theme::dim().render("(none)"));
} else {
println!("Labels: {}", mr.labels.join(", "));
if !mr.labels.is_empty() {
println!(
" Labels {}",
render::format_labels_bare(&mr.labels, mr.labels.len())
);
}
if let Some(url) = &mr.web_url {
println!("URL: {}", Theme::dim().render(url));
println!(" URL {}", Theme::muted().render(url));
}
println!();
println!("{}", Theme::bold().render("Description:"));
// Description section
println!("{}", render::section_divider("Description"));
if let Some(desc) = &mr.description {
let wrapped = wrap_text(desc, 76, " ");
println!(" {}", wrapped);
let wrapped = wrap_text(desc, 72, " ");
println!(" {wrapped}");
} else {
println!(" {}", Theme::dim().render("(no description)"));
println!(" {}", Theme::muted().render("(no description)"));
}
println!();
// Discussions section
let user_discussions: Vec<&MrDiscussionDetail> = mr
.discussions
.iter()
@@ -843,13 +897,12 @@ pub fn print_show_mr(mr: &MrDetail) {
.collect();
if user_discussions.is_empty() {
println!("{}", Theme::dim().render("Discussions: (none)"));
println!("\n {}", Theme::muted().render("No discussions"));
} else {
println!(
"{}",
Theme::bold().render(&format!("Discussions ({}):", user_discussions.len()))
render::section_divider(&format!("Discussions ({})", user_discussions.len()))
);
println!();
for discussion in user_discussions {
let user_notes: Vec<&MrNoteDetail> =
@@ -861,22 +914,22 @@ pub fn print_show_mr(mr: &MrDetail) {
}
println!(
" {} ({}):",
" {} {}",
Theme::info().render(&format!("@{}", first_note.author_username)),
format_date(first_note.created_at)
format_date(first_note.created_at),
);
let wrapped = wrap_text(&first_note.body, 72, " ");
println!(" {}", wrapped);
let wrapped = wrap_text(&first_note.body, 68, " ");
println!(" {wrapped}");
println!();
for reply in user_notes.iter().skip(1) {
println!(
" {} ({}):",
" {} {}",
Theme::info().render(&format!("@{}", reply.author_username)),
format_date(reply.created_at)
format_date(reply.created_at),
);
let wrapped = wrap_text(&reply.body, 68, " ");
println!(" {}", wrapped);
let wrapped = wrap_text(&reply.body, 66, " ");
println!(" {wrapped}");
println!();
}
}

View File

@@ -328,26 +328,44 @@ fn section(title: &str) {
pub fn print_stats(result: &StatsResult) {
section("Documents");
let mut parts = vec![format!("{} total", result.documents.total)];
let mut parts = vec![format!(
"{} total",
render::format_number(result.documents.total)
)];
if result.documents.issues > 0 {
parts.push(format!("{} issues", result.documents.issues));
parts.push(format!(
"{} issues",
render::format_number(result.documents.issues)
));
}
if result.documents.merge_requests > 0 {
parts.push(format!("{} MRs", result.documents.merge_requests));
parts.push(format!(
"{} MRs",
render::format_number(result.documents.merge_requests)
));
}
if result.documents.discussions > 0 {
parts.push(format!("{} discussions", result.documents.discussions));
parts.push(format!(
"{} discussions",
render::format_number(result.documents.discussions)
));
}
println!(" {}", parts.join(" \u{b7} "));
if result.documents.truncated > 0 {
println!(
" {}",
Theme::warning().render(&format!("{} truncated", result.documents.truncated))
Theme::warning().render(&format!(
"{} truncated",
render::format_number(result.documents.truncated)
))
);
}
section("Search Index");
println!(" {} FTS indexed", result.fts.indexed);
println!(
" {} FTS indexed",
render::format_number(result.fts.indexed)
);
let coverage_color = if result.embeddings.coverage_pct >= 95.0 {
Theme::success().render(&format!("{:.0}%", result.embeddings.coverage_pct))
} else if result.embeddings.coverage_pct >= 50.0 {
@@ -357,12 +375,17 @@ pub fn print_stats(result: &StatsResult) {
};
println!(
" {} embedding coverage ({}/{})",
coverage_color, result.embeddings.embedded_documents, result.documents.total,
coverage_color,
render::format_number(result.embeddings.embedded_documents),
render::format_number(result.documents.total),
);
if result.embeddings.total_chunks > 0 {
println!(
" {}",
Theme::dim().render(&format!("{} chunks", result.embeddings.total_chunks))
Theme::dim().render(&format!(
"{} chunks",
render::format_number(result.embeddings.total_chunks)
))
);
}

View File

@@ -1,20 +1,18 @@
use crate::cli::render::{self, Theme};
use indicatif::{ProgressBar, ProgressStyle};
use crate::cli::render::{self, Icons, Theme, format_number};
use serde::Serialize;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;
use tracing::Instrument;
use tracing::{info, warn};
use tracing::{debug, warn};
use crate::Config;
use crate::cli::progress::stage_spinner;
use crate::cli::progress::{finish_stage, nested_progress, stage_spinner_v2};
use crate::core::error::Result;
use crate::core::metrics::{MetricsLayer, StageTiming};
use crate::core::shutdown::ShutdownSignal;
use super::embed::run_embed;
use super::generate_docs::run_generate_docs;
use super::ingest::{DryRunPreview, IngestDisplay, run_ingest, run_ingest_dry_run};
use super::ingest::{DryRunPreview, IngestDisplay, ProjectSummary, run_ingest, run_ingest_dry_run};
#[derive(Debug, Default)]
pub struct SyncOptions {
@@ -41,6 +39,11 @@ pub struct SyncResult {
pub documents_regenerated: usize,
pub documents_embedded: usize,
pub status_enrichment_errors: usize,
pub statuses_enriched: usize,
#[serde(skip)]
pub issue_projects: Vec<ProjectSummary>,
#[serde(skip)]
pub mr_projects: Vec<ProjectSummary>,
}
pub async fn run_sync(
@@ -76,23 +79,10 @@ pub async fn run_sync(
IngestDisplay::progress_only()
};
let total_stages: u8 = if options.no_docs && options.no_embed {
2
} else if options.no_docs || options.no_embed {
3
} else {
4
};
let mut current_stage: u8 = 0;
current_stage += 1;
let spinner = stage_spinner(
current_stage,
total_stages,
"Fetching issues from GitLab...",
options.robot_mode,
);
info!("Sync stage {current_stage}/{total_stages}: ingesting issues");
// ── Stage: Issues ──
let stage_start = Instant::now();
let spinner = stage_spinner_v2(Icons::sync(), "Issues", "fetching...", options.robot_mode);
debug!("Sync: ingesting issues");
let issues_result = run_ingest(
config,
"issues",
@@ -110,21 +100,30 @@ pub async fn run_sync(
result.resource_events_fetched += issues_result.resource_events_fetched;
result.resource_events_failed += issues_result.resource_events_failed;
result.status_enrichment_errors += issues_result.status_enrichment_errors;
spinner.finish_and_clear();
for sep in &issues_result.status_enrichment_projects {
result.statuses_enriched += sep.enriched;
}
result.issue_projects = issues_result.project_summaries;
let issues_summary = format!(
"{} issues from {} {}",
format_number(result.issues_updated as i64),
issues_result.projects_synced,
if issues_result.projects_synced == 1 { "project" } else { "projects" }
);
finish_stage(&spinner, Icons::success(), "Issues", &issues_summary, stage_start.elapsed());
if !options.robot_mode {
print_issue_sub_rows(&result.issue_projects);
}
if signal.is_cancelled() {
info!("Shutdown requested after issues stage, returning partial sync results");
debug!("Shutdown requested after issues stage, returning partial sync results");
return Ok(result);
}
current_stage += 1;
let spinner = stage_spinner(
current_stage,
total_stages,
"Fetching merge requests from GitLab...",
options.robot_mode,
);
info!("Sync stage {current_stage}/{total_stages}: ingesting merge requests");
// ── Stage: MRs ──
let stage_start = Instant::now();
let spinner = stage_spinner_v2(Icons::sync(), "MRs", "fetching...", options.robot_mode);
debug!("Sync: ingesting merge requests");
let mrs_result = run_ingest(
config,
"mrs",
@@ -143,45 +142,33 @@ pub async fn run_sync(
result.resource_events_failed += mrs_result.resource_events_failed;
result.mr_diffs_fetched += mrs_result.mr_diffs_fetched;
result.mr_diffs_failed += mrs_result.mr_diffs_failed;
spinner.finish_and_clear();
result.mr_projects = mrs_result.project_summaries;
let mrs_summary = format!(
"{} merge requests from {} {}",
format_number(result.mrs_updated as i64),
mrs_result.projects_synced,
if mrs_result.projects_synced == 1 { "project" } else { "projects" }
);
finish_stage(&spinner, Icons::success(), "MRs", &mrs_summary, stage_start.elapsed());
if !options.robot_mode {
print_mr_sub_rows(&result.mr_projects);
}
if signal.is_cancelled() {
info!("Shutdown requested after MRs stage, returning partial sync results");
debug!("Shutdown requested after MRs stage, returning partial sync results");
return Ok(result);
}
// ── Stage: Docs ──
if !options.no_docs {
current_stage += 1;
let spinner = stage_spinner(
current_stage,
total_stages,
"Processing documents...",
options.robot_mode,
);
info!("Sync stage {current_stage}/{total_stages}: generating documents");
let stage_start = Instant::now();
let spinner = stage_spinner_v2(Icons::sync(), "Docs", "generating...", options.robot_mode);
debug!("Sync: generating documents");
let docs_bar = if options.robot_mode {
ProgressBar::hidden()
} else {
let b = crate::cli::progress::multi().add(ProgressBar::new(0));
b.set_style(
ProgressStyle::default_bar()
.template(
" {spinner:.blue} Processing documents [{bar:30.cyan/dim}] {pos}/{len}",
)
.unwrap()
.progress_chars("=> "),
);
b
};
let docs_bar = nested_progress("Docs", 0, options.robot_mode);
let docs_bar_clone = docs_bar.clone();
let tick_started = Arc::new(AtomicBool::new(false));
let tick_started_clone = Arc::clone(&tick_started);
let docs_cb: Box<dyn Fn(usize, usize)> = Box::new(move |processed, total| {
if total > 0 {
if !tick_started_clone.swap(true, Ordering::Relaxed) {
docs_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
}
docs_bar_clone.set_length(total as u64);
docs_bar_clone.set_position(processed as u64);
}
@@ -189,43 +176,25 @@ pub async fn run_sync(
let docs_result = run_generate_docs(config, options.full, None, Some(docs_cb))?;
result.documents_regenerated = docs_result.regenerated;
docs_bar.finish_and_clear();
spinner.finish_and_clear();
let docs_summary = format!(
"{} documents generated",
format_number(result.documents_regenerated as i64),
);
finish_stage(&spinner, Icons::success(), "Docs", &docs_summary, stage_start.elapsed());
} else {
info!("Sync: skipping document generation (--no-docs)");
debug!("Sync: skipping document generation (--no-docs)");
}
// ── Stage: Embed ──
if !options.no_embed {
current_stage += 1;
let spinner = stage_spinner(
current_stage,
total_stages,
"Generating embeddings...",
options.robot_mode,
);
info!("Sync stage {current_stage}/{total_stages}: embedding documents");
let stage_start = Instant::now();
let spinner = stage_spinner_v2(Icons::sync(), "Embed", "preparing...", options.robot_mode);
debug!("Sync: embedding documents");
let embed_bar = if options.robot_mode {
ProgressBar::hidden()
} else {
let b = crate::cli::progress::multi().add(ProgressBar::new(0));
b.set_style(
ProgressStyle::default_bar()
.template(
" {spinner:.blue} Generating embeddings [{bar:30.cyan/dim}] {pos}/{len}",
)
.unwrap()
.progress_chars("=> "),
);
b
};
let embed_bar = nested_progress("Embed", 0, options.robot_mode);
let embed_bar_clone = embed_bar.clone();
let tick_started = Arc::new(AtomicBool::new(false));
let tick_started_clone = Arc::clone(&tick_started);
let embed_cb: Box<dyn Fn(usize, usize)> = Box::new(move |processed, total| {
if total > 0 {
if !tick_started_clone.swap(true, Ordering::Relaxed) {
embed_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
}
embed_bar_clone.set_length(total as u64);
embed_bar_clone.set_position(processed as u64);
}
@@ -234,22 +203,24 @@ pub async fn run_sync(
Ok(embed_result) => {
result.documents_embedded = embed_result.docs_embedded;
embed_bar.finish_and_clear();
spinner.finish_and_clear();
let embed_summary = format!(
"{} chunks embedded",
format_number(embed_result.chunks_embedded as i64),
);
finish_stage(&spinner, Icons::success(), "Embed", &embed_summary, stage_start.elapsed());
}
Err(e) => {
embed_bar.finish_and_clear();
spinner.finish_and_clear();
if !options.robot_mode {
eprintln!(" {} Embedding skipped ({})", Theme::warning().render("warn"), e);
}
let warn_summary = format!("skipped ({})", e);
finish_stage(&spinner, Icons::warning(), "Embed", &warn_summary, stage_start.elapsed());
warn!(error = %e, "Embedding stage failed (Ollama may be unavailable), continuing");
}
}
} else {
info!("Sync: skipping embedding (--no-embed)");
debug!("Sync: skipping embedding (--no-embed)");
}
info!(
debug!(
issues = result.issues_updated,
mrs = result.mrs_updated,
discussions = result.discussions_fetched,
@@ -273,6 +244,22 @@ pub fn print_sync(
elapsed: std::time::Duration,
metrics: Option<&MetricsLayer>,
) {
let has_data = result.issues_updated > 0
|| result.mrs_updated > 0
|| result.discussions_fetched > 0
|| result.resource_events_fetched > 0
|| result.mr_diffs_fetched > 0
|| result.documents_regenerated > 0
|| result.documents_embedded > 0
|| result.statuses_enriched > 0;
if !has_data {
println!(
"\n {} ({:.1}s)\n",
Theme::dim().render("Already up to date"),
elapsed.as_secs_f64()
);
} else {
// Headline: what happened, how long
println!(
"\n {} {} issues and {} MRs in {:.1}s",
@@ -293,6 +280,9 @@ pub fn print_sync(
if result.mr_diffs_fetched > 0 {
details.push(format!("{} diffs", result.mr_diffs_fetched));
}
if result.statuses_enriched > 0 {
details.push(format!("{} statuses updated", result.statuses_enriched));
}
if !details.is_empty() {
println!(" {}", Theme::dim().render(&details.join(" \u{b7} ")));
}
@@ -325,6 +315,7 @@ pub fn print_sync(
}
println!();
}
if let Some(metrics) = metrics {
let stages = metrics.extract_timings();
@@ -334,6 +325,66 @@ pub fn print_sync(
}
}
fn print_issue_sub_rows(projects: &[ProjectSummary]) {
if projects.len() <= 1 {
return;
}
for p in projects {
let mut parts: Vec<String> = Vec::new();
parts.push(format!(
"{} {}",
p.items_upserted,
if p.items_upserted == 1 {
"issue"
} else {
"issues"
}
));
if p.discussions_synced > 0 {
parts.push(format!("{} discussions", p.discussions_synced));
}
if p.statuses_enriched > 0 {
parts.push(format!("{} statuses updated", p.statuses_enriched));
}
if p.events_fetched > 0 {
parts.push(format!("{} events", p.events_fetched));
}
let detail = parts.join(" \u{b7} ");
let _ = crate::cli::progress::multi().println(format!(
" {}",
Theme::dim().render(&format!("{:<30} {}", p.path, detail))
));
}
}
fn print_mr_sub_rows(projects: &[ProjectSummary]) {
if projects.len() <= 1 {
return;
}
for p in projects {
let mut parts: Vec<String> = Vec::new();
parts.push(format!(
"{} {}",
p.items_upserted,
if p.items_upserted == 1 { "MR" } else { "MRs" }
));
if p.discussions_synced > 0 {
parts.push(format!("{} discussions", p.discussions_synced));
}
if p.mr_diffs_fetched > 0 {
parts.push(format!("{} diffs", p.mr_diffs_fetched));
}
if p.events_fetched > 0 {
parts.push(format!("{} events", p.events_fetched));
}
let detail = parts.join(" \u{b7} ");
let _ = crate::cli::progress::multi().println(format!(
" {}",
Theme::dim().render(&format!("{:<30} {}", p.path, detail))
));
}
}
fn section(title: &str) {
println!("{}", render::section_divider(title));
}

View File

@@ -1,8 +1,8 @@
use crate::cli::render::{self, Theme};
use crate::cli::render::{self, Icons, Theme};
use serde::Serialize;
use crate::Config;
use crate::cli::progress::stage_spinner;
use crate::cli::progress::stage_spinner_v2;
use crate::core::db::create_connection;
use crate::core::error::{LoreError, Result};
use crate::core::paths::get_db_path;
@@ -96,7 +96,12 @@ pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result<Ti
let seed_result = match parsed_query {
TimelineQuery::EntityDirect { entity_type, iid } => {
// Direct seeding: synchronous, no Ollama needed
let spinner = stage_spinner(1, 3, "Resolving entity...", params.robot_mode);
let spinner = stage_spinner_v2(
Icons::search(),
"Resolve",
"Resolving entity...",
params.robot_mode,
);
let result = seed_timeline_direct(&conn, &entity_type, iid, project_id)?;
spinner.finish_and_clear();
result
@@ -111,7 +116,12 @@ pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result<Ti
});
// Stage 1+2: SEED + HYDRATE (hybrid search with FTS fallback)
let spinner = stage_spinner(1, 3, "Seeding timeline...", params.robot_mode);
let spinner = stage_spinner_v2(
Icons::search(),
"Seed",
"Seeding timeline...",
params.robot_mode,
);
let result = seed_timeline(
&conn,
Some(&client),
@@ -128,7 +138,12 @@ pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result<Ti
};
// Stage 3: EXPAND
let spinner = stage_spinner(2, 3, "Expanding cross-references...", params.robot_mode);
let spinner = stage_spinner_v2(
Icons::sync(),
"Expand",
"Expanding cross-references...",
params.robot_mode,
);
let expand_result = expand_timeline(
&conn,
&seed_result.seed_entities,
@@ -139,7 +154,12 @@ pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result<Ti
spinner.finish_and_clear();
// Stage 4: COLLECT
let spinner = stage_spinner(3, 3, "Collecting events...", params.robot_mode);
let spinner = stage_spinner_v2(
Icons::sync(),
"Collect",
"Collecting events...",
params.robot_mode,
);
let (events, total_before_limit) = collect_events(
&conn,
&seed_result.seed_entities,
@@ -202,6 +222,11 @@ pub fn print_timeline(result: &TimelineResult) {
fn print_timeline_event(event: &TimelineEvent) {
let date = render::format_date(event.timestamp);
let tag = format_event_tag(&event.event_type);
let entity_icon = match event.entity_type.as_str() {
"issue" => Icons::issue_opened(),
"merge_request" => Icons::mr_opened(),
_ => "",
};
let entity_ref = format_entity_ref(&event.entity_type, event.entity_iid);
let actor = event
.actor
@@ -211,8 +236,7 @@ fn print_timeline_event(event: &TimelineEvent) {
let expanded_marker = if event.is_seed { "" } else { " [expanded]" };
let summary = render::truncate(&event.summary, 50);
let tag_padded = format!("{:<12}", tag);
println!("{date} {tag_padded} {entity_ref:7} {summary:50} {actor}{expanded_marker}");
println!("{date} {tag} {entity_icon}{entity_ref:7} {summary:50} {actor}{expanded_marker}");
// Show snippet for evidence notes
if let TimelineEventType::NoteEvidence { snippet, .. } = &event.event_type
@@ -276,23 +300,33 @@ fn print_timeline_footer(result: &TimelineResult) {
println!();
}
/// Format event tag: pad plain text to TAG_WIDTH, then apply style.
const TAG_WIDTH: usize = 11;
fn format_event_tag(event_type: &TimelineEventType) -> String {
match event_type {
TimelineEventType::Created => Theme::success().render("CREATED"),
let (label, style) = match event_type {
TimelineEventType::Created => ("CREATED", Theme::success()),
TimelineEventType::StateChanged { state } => match state.as_str() {
"closed" => Theme::error().render("CLOSED"),
"reopened" => Theme::warning().render("REOPENED"),
_ => Theme::dim().render(&state.to_uppercase()),
"closed" => ("CLOSED", Theme::error()),
"reopened" => ("REOPENED", Theme::warning()),
_ => return style_padded(&state.to_uppercase(), TAG_WIDTH, Theme::dim()),
},
TimelineEventType::LabelAdded { .. } => Theme::info().render("LABEL+"),
TimelineEventType::LabelRemoved { .. } => Theme::info().render("LABEL-"),
TimelineEventType::MilestoneSet { .. } => Theme::accent().render("MILESTONE+"),
TimelineEventType::MilestoneRemoved { .. } => Theme::accent().render("MILESTONE-"),
TimelineEventType::Merged => Theme::info().render("MERGED"),
TimelineEventType::NoteEvidence { .. } => Theme::dim().render("NOTE"),
TimelineEventType::DiscussionThread { .. } => Theme::warning().render("THREAD"),
TimelineEventType::CrossReferenced { .. } => Theme::dim().render("REF"),
}
TimelineEventType::LabelAdded { .. } => ("LABEL+", Theme::info()),
TimelineEventType::LabelRemoved { .. } => ("LABEL-", Theme::info()),
TimelineEventType::MilestoneSet { .. } => ("MILESTONE+", Theme::accent()),
TimelineEventType::MilestoneRemoved { .. } => ("MILESTONE-", Theme::accent()),
TimelineEventType::Merged => ("MERGED", Theme::info()),
TimelineEventType::NoteEvidence { .. } => ("NOTE", Theme::dim()),
TimelineEventType::DiscussionThread { .. } => ("THREAD", Theme::warning()),
TimelineEventType::CrossReferenced { .. } => ("REF", Theme::dim()),
};
style_padded(label, TAG_WIDTH, style)
}
/// Pad text to width, then apply lipgloss style (so ANSI codes don't break alignment).
fn style_padded(text: &str, width: usize, style: lipgloss::Style) -> String {
let padded = format!("{:<width$}", text);
style.render(&padded)
}
fn format_entity_ref(entity_type: &str, iid: i64) -> String {

View File

@@ -1,4 +1,4 @@
use crate::cli::render::{self, Theme};
use crate::cli::render::{self, Icons, Theme};
use rusqlite::Connection;
use serde::Serialize;
use std::collections::{HashMap, HashSet};
@@ -1951,7 +1951,7 @@ fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) {
};
println!(
" {:<16} {:>6} {:>12} {:>6} {:>12} {:<12}{}{}",
Theme::info().render(&format!("@{}", expert.username)),
Theme::info().render(&format!("{} {}", Icons::user(), expert.username)),
expert.score,
reviews,
notes,
@@ -2004,16 +2004,18 @@ fn print_workload_human(r: &WorkloadResult) {
println!();
println!(
"{}",
Theme::bold().render(&format!("@{} -- Workload Summary", r.username))
Theme::bold().render(&format!(
"{} {} -- Workload Summary",
Icons::user(),
r.username
))
);
println!("{}", "\u{2500}".repeat(60));
if !r.assigned_issues.is_empty() {
println!();
println!(
" {} ({})",
Theme::bold().render("Assigned Issues"),
r.assigned_issues.len()
"{}",
render::section_divider(&format!("Assigned Issues ({})", r.assigned_issues.len()))
);
for item in &r.assigned_issues {
println!(
@@ -2032,11 +2034,9 @@ fn print_workload_human(r: &WorkloadResult) {
}
if !r.authored_mrs.is_empty() {
println!();
println!(
" {} ({})",
Theme::bold().render("Authored MRs"),
r.authored_mrs.len()
"{}",
render::section_divider(&format!("Authored MRs ({})", r.authored_mrs.len()))
);
for mr in &r.authored_mrs {
let draft = if mr.draft { " [draft]" } else { "" };
@@ -2057,11 +2057,9 @@ fn print_workload_human(r: &WorkloadResult) {
}
if !r.reviewing_mrs.is_empty() {
println!();
println!(
" {} ({})",
Theme::bold().render("Reviewing MRs"),
r.reviewing_mrs.len()
"{}",
render::section_divider(&format!("Reviewing MRs ({})", r.reviewing_mrs.len()))
);
for mr in &r.reviewing_mrs {
let author = mr
@@ -2086,11 +2084,12 @@ fn print_workload_human(r: &WorkloadResult) {
}
if !r.unresolved_discussions.is_empty() {
println!();
println!(
" {} ({})",
Theme::bold().render("Unresolved Discussions"),
"{}",
render::section_divider(&format!(
"Unresolved Discussions ({})",
r.unresolved_discussions.len()
))
);
for disc in &r.unresolved_discussions {
println!(
@@ -2128,7 +2127,11 @@ fn print_reviews_human(r: &ReviewsResult) {
println!();
println!(
"{}",
Theme::bold().render(&format!("@{} -- Review Patterns", r.username))
Theme::bold().render(&format!(
"{} {} -- Review Patterns",
Icons::user(),
r.username
))
);
println!("{}", "\u{2500}".repeat(60));
println!();
@@ -2289,7 +2292,7 @@ fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
println!(
" {:<16} {:<6} {:>7} {:<12} {}{}",
Theme::info().render(&format!("@{}", user.username)),
Theme::info().render(&format!("{} {}", Icons::user(), user.username)),
format_overlap_role(user),
user.touch_count,
render::format_relative_time(user.last_seen_at),

View File

@@ -44,6 +44,10 @@ pub struct Cli {
#[arg(long, global = true, value_parser = ["auto", "always", "never"], default_value = "auto", help = "Color output: auto (default), always, or never")]
pub color: String,
/// Icon set: nerd (Nerd Fonts), unicode, or ascii
#[arg(long, global = true, value_parser = ["nerd", "unicode", "ascii"], help = "Icon set: nerd (Nerd Fonts), unicode, or ascii")]
pub icons: Option<String>,
/// Suppress non-essential output
#[arg(
short = 'q',

View File

@@ -1,34 +1,82 @@
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use std::io::Write;
use std::sync::LazyLock;
use std::time::Duration;
use tracing_subscriber::fmt::MakeWriter;
use crate::cli::render::Icons;
static MULTI: LazyLock<MultiProgress> = LazyLock::new(MultiProgress::new);
pub fn multi() -> &'static MultiProgress {
&MULTI
}
/// Create a spinner for a numbered pipeline stage.
/// Stage spinner with icon prefix and elapsed time on the right.
///
/// Returns a hidden (no-op) bar in robot mode so callers can use
/// the same code path regardless of output mode.
pub fn stage_spinner(stage: u8, total: u8, msg: &str, robot_mode: bool) -> ProgressBar {
/// Template: `{spinner:.cyan} {prefix} {wide_msg} {elapsed_style:.dim}`
pub fn stage_spinner_v2(icon: &str, label: &str, msg: &str, robot_mode: bool) -> ProgressBar {
if robot_mode {
return ProgressBar::hidden();
}
let pb = multi().add(ProgressBar::new_spinner());
pb.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.blue} {prefix} {msg}")
.template(" {spinner:.cyan} {prefix} {wide_msg}")
.expect("valid template"),
);
pb.enable_steady_tick(std::time::Duration::from_millis(80));
pb.set_prefix(format!("[{stage}/{total}]"));
pb.enable_steady_tick(Duration::from_millis(60));
pb.set_prefix(format!("{icon} {label}"));
pb.set_message(msg.to_string());
pb
}
/// Nested progress bar with count, throughput, and ETA.
///
/// Template: ` {spinner:.dim} {msg} {bar:30.cyan/dark_gray} {pos}/{len} {per_sec:.dim} {eta:.dim}`
pub fn nested_progress(msg: &str, len: u64, robot_mode: bool) -> ProgressBar {
if robot_mode {
return ProgressBar::hidden();
}
let pb = multi().add(ProgressBar::new(len));
pb.set_style(
ProgressStyle::default_bar()
.template(
" {spinner:.dim} {msg} {bar:30.cyan/dark_gray} {pos}/{len} {per_sec:.dim} {eta:.dim}",
)
.expect("valid template")
.progress_chars(Icons::progress_chars()),
);
pb.enable_steady_tick(Duration::from_millis(60));
pb.set_message(msg.to_string());
pb
}
/// Replace a spinner with a static completion line showing icon, label, summary, and elapsed.
///
/// Output: ` ✓ Label summary elapsed`
pub fn finish_stage(pb: &ProgressBar, icon: &str, label: &str, summary: &str, elapsed: Duration) {
let elapsed_str = format_elapsed(elapsed);
let line = format!(" {icon} {label:<12}{summary:>40} {elapsed_str:>8}",);
pb.set_style(ProgressStyle::with_template("{msg}").expect("valid template"));
pb.finish_with_message(line);
}
/// Format a Duration as a compact human string (e.g. "1.2s", "42ms", "1m 5s").
fn format_elapsed(d: Duration) -> String {
let ms = d.as_millis();
if ms < 1000 {
format!("{ms}ms")
} else if ms < 60_000 {
format!("{:.1}s", ms as f64 / 1000.0)
} else {
let secs = d.as_secs();
let m = secs / 60;
let s = secs % 60;
format!("{m}m {s}s")
}
}
#[derive(Clone)]
pub struct SuspendingWriter;
@@ -108,34 +156,51 @@ mod tests {
drop(w);
}
// ── Progress API tests ──
#[test]
fn stage_spinner_robot_mode_returns_hidden() {
let pb = stage_spinner(1, 3, "Testing...", true);
fn stage_spinner_v2_robot_mode_returns_hidden() {
let pb = stage_spinner_v2("\u{2714}", "Issues", "fetching...", true);
assert!(pb.is_hidden());
}
#[test]
fn stage_spinner_human_mode_sets_properties() {
// In non-TTY test environments, MultiProgress may report bars as
// hidden. Verify the human-mode code path by checking that prefix
// and message are configured (robot-mode returns a bare hidden bar).
let pb = stage_spinner(1, 3, "Testing...", false);
assert_eq!(pb.prefix(), "[1/3]");
assert_eq!(pb.message(), "Testing...");
fn stage_spinner_v2_human_mode_sets_properties() {
let pb = stage_spinner_v2("\u{2714}", "Issues", "fetching...", false);
assert!(pb.prefix().contains("Issues"));
assert_eq!(pb.message(), "fetching...");
pb.finish_and_clear();
}
#[test]
fn stage_spinner_sets_prefix_format() {
let pb = stage_spinner(2, 5, "Working...", false);
assert_eq!(pb.prefix(), "[2/5]");
fn nested_progress_robot_mode_returns_hidden() {
let pb = nested_progress("Embedding...", 100, true);
assert!(pb.is_hidden());
}
#[test]
fn nested_progress_human_mode_sets_length() {
let pb = nested_progress("Embedding...", 100, false);
assert_eq!(pb.length(), Some(100));
assert_eq!(pb.message(), "Embedding...");
pb.finish_and_clear();
}
#[test]
fn stage_spinner_sets_message() {
let pb = stage_spinner(1, 3, "Seeding timeline...", false);
assert_eq!(pb.message(), "Seeding timeline...");
pb.finish_and_clear();
fn format_elapsed_sub_second() {
assert_eq!(format_elapsed(Duration::from_millis(42)), "42ms");
assert_eq!(format_elapsed(Duration::from_millis(999)), "999ms");
}
#[test]
fn format_elapsed_seconds() {
assert_eq!(format_elapsed(Duration::from_millis(1200)), "1.2s");
assert_eq!(format_elapsed(Duration::from_millis(5000)), "5.0s");
}
#[test]
fn format_elapsed_minutes() {
assert_eq!(format_elapsed(Duration::from_secs(65)), "1m 5s");
assert_eq!(format_elapsed(Duration::from_secs(120)), "2m 0s");
}
}

View File

@@ -16,17 +16,235 @@ pub enum ColorMode {
Never,
}
// ─── Glyph Mode ─────────────────────────────────────────────────────────────
/// Icon tier: Nerd Font glyphs, Unicode symbols, or plain ASCII.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GlyphMode {
Nerd,
Unicode,
Ascii,
}
impl GlyphMode {
/// Detect glyph mode from CLI flag, env, or terminal heuristics.
///
/// Precedence:
/// 1. Explicit `--icons` CLI value (passed as `cli_flag`)
/// 2. `LORE_ICONS` environment variable
/// 3. Force ASCII fallback if `force_ascii` is true (robot mode)
/// 4. Auto-detect: Nerd if `$TERM_PROGRAM` matches known Nerd-capable terminals
/// or `$NERD_FONTS=1`; otherwise Unicode
pub fn detect(cli_flag: Option<&str>, force_ascii: bool) -> Self {
// 1. CLI flag
if let Some(flag) = cli_flag {
return Self::from_str_lossy(flag);
}
// 2. Env var
if let Ok(val) = std::env::var("LORE_ICONS") {
return Self::from_str_lossy(&val);
}
// 3. Robot-safe fallback
if force_ascii {
return Self::Ascii;
}
// 4. Auto-detect
if Self::detect_nerd_capable() {
Self::Nerd
} else {
Self::Unicode
}
}
fn from_str_lossy(s: &str) -> Self {
match s.to_ascii_lowercase().as_str() {
"nerd" => Self::Nerd,
"unicode" => Self::Unicode,
"ascii" => Self::Ascii,
_ => Self::Unicode,
}
}
fn detect_nerd_capable() -> bool {
if std::env::var("NERD_FONTS")
.ok()
.is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
{
return true;
}
std::env::var("TERM_PROGRAM").ok().is_some_and(|tp| {
matches!(
tp.as_str(),
"WezTerm" | "kitty" | "Alacritty" | "iTerm2.app" | "iTerm.app"
)
})
}
}
// ─── Icons ──────────────────────────────────────────────────────────────────
/// Glyph catalog returning the right icon for the active `GlyphMode`.
pub struct Icons;
impl Icons {
fn mode() -> GlyphMode {
RENDERER.get().map_or(GlyphMode::Unicode, |r| r.glyphs)
}
// ── Status indicators ──
pub fn success() -> &'static str {
match Self::mode() {
GlyphMode::Nerd => "\u{f058}", // nf-fa-check_circle
GlyphMode::Unicode => "\u{2714}", // heavy check mark
GlyphMode::Ascii => "[ok]",
}
}
pub fn warning() -> &'static str {
match Self::mode() {
GlyphMode::Nerd => "\u{f421}", // nf-oct-alert
GlyphMode::Unicode => "\u{26a0}", // warning sign
GlyphMode::Ascii => "[!]",
}
}
pub fn error() -> &'static str {
match Self::mode() {
GlyphMode::Nerd => "\u{f467}", // nf-oct-x_circle
GlyphMode::Unicode => "\u{2716}", // heavy multiplication x
GlyphMode::Ascii => "[X]",
}
}
pub fn info() -> &'static str {
match Self::mode() {
GlyphMode::Nerd => "\u{f449}", // nf-oct-info
GlyphMode::Unicode => "\u{2139}", // information source
GlyphMode::Ascii => "[i]",
}
}
// ── Entity state ──
pub fn issue_opened() -> &'static str {
match Self::mode() {
GlyphMode::Nerd => "\u{f41b}", // nf-oct-issue_opened
GlyphMode::Unicode => "\u{25cb}", // white circle
GlyphMode::Ascii => "( )",
}
}
pub fn issue_closed() -> &'static str {
match Self::mode() {
GlyphMode::Nerd => "\u{f41d}", // nf-oct-issue_closed
GlyphMode::Unicode => "\u{25cf}", // black circle
GlyphMode::Ascii => "(x)",
}
}
pub fn mr_opened() -> &'static str {
match Self::mode() {
GlyphMode::Nerd => "\u{f407}", // nf-oct-git_pull_request
GlyphMode::Unicode => "\u{21c4}", // rightwards arrow over leftwards
GlyphMode::Ascii => "<->",
}
}
pub fn mr_merged() -> &'static str {
match Self::mode() {
GlyphMode::Nerd => "\u{f402}", // nf-oct-git_merge
GlyphMode::Unicode => "\u{2714}", // heavy check mark
GlyphMode::Ascii => "[M]",
}
}
pub fn mr_closed() -> &'static str {
match Self::mode() {
GlyphMode::Nerd => "\u{f430}", // nf-oct-git_pull_request_closed
GlyphMode::Unicode => "\u{2716}", // heavy multiplication x
GlyphMode::Ascii => "[X]",
}
}
pub fn mr_draft() -> &'static str {
match Self::mode() {
GlyphMode::Nerd => "\u{f040}", // nf-fa-pencil
GlyphMode::Unicode => "\u{270e}", // lower right pencil
GlyphMode::Ascii => "[D]",
}
}
// ── Miscellaneous ──
pub fn note() -> &'static str {
match Self::mode() {
GlyphMode::Nerd => "\u{f3ed}", // nf-oct-comment
GlyphMode::Unicode => "\u{25b8}", // black right-pointing small triangle
GlyphMode::Ascii => ">",
}
}
pub fn search() -> &'static str {
match Self::mode() {
GlyphMode::Nerd => "\u{f422}", // nf-oct-search
GlyphMode::Unicode => "\u{1f50d}", // left-pointing magnifying glass
GlyphMode::Ascii => "?",
}
}
pub fn user() -> &'static str {
match Self::mode() {
GlyphMode::Nerd => "\u{f415}", // nf-oct-person
GlyphMode::Unicode => "@",
GlyphMode::Ascii => "@",
}
}
pub fn sync() -> &'static str {
match Self::mode() {
GlyphMode::Nerd => "\u{f46a}", // nf-oct-sync
GlyphMode::Unicode => "\u{21bb}", // clockwise open circle arrow
GlyphMode::Ascii => "<>",
}
}
/// Waiting stage indicator (dimmed dot).
pub fn waiting() -> &'static str {
match Self::mode() {
GlyphMode::Nerd | GlyphMode::Unicode => "\u{00b7}", // middle dot
GlyphMode::Ascii => ".",
}
}
/// Progress bar characters: (filled, head, empty).
pub fn progress_chars() -> &'static str {
match Self::mode() {
GlyphMode::Nerd | GlyphMode::Unicode => "\u{2501}\u{2578} ",
GlyphMode::Ascii => "=> ",
}
}
}
// ─── Renderer ───────────────────────────────────────────────────────────────
/// Global renderer singleton, initialized once in `main.rs`.
static RENDERER: OnceLock<LoreRenderer> = OnceLock::new();
pub struct LoreRenderer {
/// Resolved at init time so we don't re-check TTY + NO_COLOR on every call.
colors: bool,
/// Icon tier for the session.
glyphs: GlyphMode,
}
impl LoreRenderer {
/// Initialize the global renderer. Call once at startup.
pub fn init(mode: ColorMode) {
pub fn init(mode: ColorMode, glyphs: GlyphMode) {
let colors = match mode {
ColorMode::Always => true,
ColorMode::Never => false,
@@ -35,7 +253,7 @@ impl LoreRenderer {
&& std::env::var("NO_COLOR").map_or(true, |v| v.is_empty())
}
};
let _ = RENDERER.set(LoreRenderer { colors });
let _ = RENDERER.set(LoreRenderer { colors, glyphs });
}
/// Get the global renderer. Panics if `init` hasn't been called.
@@ -49,6 +267,11 @@ impl LoreRenderer {
pub fn colors_enabled(&self) -> bool {
self.colors
}
/// The active glyph mode.
pub fn glyph_mode(&self) -> GlyphMode {
self.glyphs
}
}
/// Check if colors are enabled. Returns false if `LoreRenderer` hasn't been
@@ -176,6 +399,39 @@ impl Theme {
}
}
pub fn state_draft() -> Style {
if colors_on() {
Style::new().foreground("#6b7280")
} else {
Style::new()
}
}
// Semantic additions
pub fn muted() -> Style {
if colors_on() {
Style::new().foreground("#6b7280")
} else {
Style::new()
}
}
pub fn highlight() -> Style {
if colors_on() {
Style::new().foreground("#fbbf24").bold()
} else {
Style::new()
}
}
pub fn timing() -> Style {
if colors_on() {
Style::new().foreground("#94a3b8")
} else {
Style::new()
}
}
// Structure
pub fn section_title() -> Style {
if colors_on() {
@@ -369,6 +625,24 @@ pub fn format_labels(labels: &[String], max_shown: usize) -> String {
}
}
/// Format a slice of labels without brackets.
/// e.g. `["bug", "urgent"]` with max 2 -> `"bug, urgent"`
/// e.g. `["a", "b", "c", "d"]` with max 2 -> `"a, b +2"`
pub fn format_labels_bare(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 {
shown.join(", ")
}
}
/// Format a duration in milliseconds as a human-friendly string.
pub fn format_duration_ms(ms: u64) -> String {
if ms < 1000 {
@@ -378,6 +652,26 @@ pub fn format_duration_ms(ms: u64) -> String {
}
}
/// Format an epoch-ms timestamp as a compact relative time string.
/// Returns short forms like `3h`, `2d`, `1w`, `3mo` suitable for tight table columns.
pub fn format_relative_time_compact(ms_epoch: i64) -> String {
let now = now_ms();
let diff = now - ms_epoch;
if diff < 0 {
return "future".to_string();
}
match diff {
d if d < 60_000 => "now".to_string(),
d if d < 3_600_000 => format!("{}m", d / 60_000),
d if d < 86_400_000 => format!("{}h", d / 3_600_000),
d if d < 604_800_000 => format!("{}d", d / 86_400_000),
d if d < 2_592_000_000 => format!("{}w", d / 604_800_000),
_ => format!("{}mo", diff / 2_592_000_000),
}
}
// ─── Table Renderer ──────────────────────────────────────────────────────────
/// Column alignment for the table renderer.
@@ -946,6 +1240,135 @@ mod tests {
assert!(plain.contains("1"), "got: {plain}");
}
// ── GlyphMode ──
#[test]
fn glyph_mode_cli_flag_overrides_all() {
assert_eq!(GlyphMode::detect(Some("ascii"), false), GlyphMode::Ascii);
assert_eq!(GlyphMode::detect(Some("nerd"), false), GlyphMode::Nerd);
assert_eq!(
GlyphMode::detect(Some("unicode"), false),
GlyphMode::Unicode
);
}
#[test]
fn glyph_mode_force_ascii_is_fallback_when_no_explicit_icon_mode() {
// Clear env var so it doesn't short-circuit the force_ascii path.
// SAFETY: tests run single-threaded per process for env-var-dependent tests.
let saved = std::env::var("LORE_ICONS").ok();
unsafe { std::env::remove_var("LORE_ICONS") };
let result = GlyphMode::detect(None, true);
if let Some(v) = saved {
unsafe { std::env::set_var("LORE_ICONS", v) };
}
assert_eq!(result, GlyphMode::Ascii);
}
#[test]
fn glyph_mode_force_ascii_does_not_override_cli_flag() {
assert_eq!(GlyphMode::detect(Some("nerd"), true), GlyphMode::Nerd);
assert_eq!(GlyphMode::detect(Some("unicode"), true), GlyphMode::Unicode);
}
#[test]
fn glyph_mode_unknown_falls_back_to_unicode() {
assert_eq!(GlyphMode::detect(Some("bogus"), false), GlyphMode::Unicode);
}
// ── Icons ──
#[test]
fn icons_return_nonempty_strings() {
// Without RENDERER initialized, Icons falls back to Unicode mode
assert!(!Icons::success().is_empty());
assert!(!Icons::warning().is_empty());
assert!(!Icons::error().is_empty());
assert!(!Icons::info().is_empty());
assert!(!Icons::issue_opened().is_empty());
assert!(!Icons::issue_closed().is_empty());
assert!(!Icons::mr_opened().is_empty());
assert!(!Icons::mr_merged().is_empty());
assert!(!Icons::mr_closed().is_empty());
assert!(!Icons::mr_draft().is_empty());
assert!(!Icons::note().is_empty());
assert!(!Icons::search().is_empty());
assert!(!Icons::user().is_empty());
assert!(!Icons::sync().is_empty());
assert!(!Icons::waiting().is_empty());
assert!(!Icons::progress_chars().is_empty());
}
// ── format_labels_bare ──
#[test]
fn format_labels_bare_empty() {
assert_eq!(format_labels_bare(&[], 2), "");
}
#[test]
fn format_labels_bare_no_brackets() {
let labels = vec!["bug".to_string(), "urgent".to_string()];
assert_eq!(format_labels_bare(&labels, 2), "bug, urgent");
}
#[test]
fn format_labels_bare_overflow() {
let labels = vec![
"a".to_string(),
"b".to_string(),
"c".to_string(),
"d".to_string(),
];
assert_eq!(format_labels_bare(&labels, 2), "a, b +2");
}
// ── format_relative_time_compact ──
#[test]
fn format_relative_time_compact_now() {
let recent = now_ms() - 5_000;
assert_eq!(format_relative_time_compact(recent), "now");
}
#[test]
fn format_relative_time_compact_minutes() {
let mins_ago = now_ms() - 300_000; // 5 minutes
assert_eq!(format_relative_time_compact(mins_ago), "5m");
}
#[test]
fn format_relative_time_compact_hours() {
let hours_ago = now_ms() - 7_200_000; // 2 hours
assert_eq!(format_relative_time_compact(hours_ago), "2h");
}
#[test]
fn format_relative_time_compact_days() {
let days_ago = now_ms() - 172_800_000; // 2 days
assert_eq!(format_relative_time_compact(days_ago), "2d");
}
#[test]
fn format_relative_time_compact_weeks() {
let weeks_ago = now_ms() - 1_209_600_000; // 2 weeks
assert_eq!(format_relative_time_compact(weeks_ago), "2w");
}
#[test]
fn format_relative_time_compact_months() {
let months_ago = now_ms() - 5_184_000_000; // ~2 months
assert_eq!(format_relative_time_compact(months_ago), "2mo");
}
#[test]
fn format_relative_time_compact_future() {
let future = now_ms() + 60_000;
assert_eq!(format_relative_time_compact(future), "future");
}
// ── helpers ──
/// Strip ANSI escape codes (SGR sequences) for content assertions.
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());

View File

@@ -51,8 +51,8 @@ pub fn build_stderr_filter(verbose: u8, quiet: bool) -> EnvFilter {
}
let directives = match verbose {
0 => "lore=info,warn",
1 => "lore=debug,warn",
0 => "lore=warn",
1 => "lore=info,warn",
2 => "lore=debug,info",
_ => "lore=trace,debug",
};

View File

@@ -126,14 +126,21 @@ fn parse_retry_after(response: &reqwest::Response) -> u64 {
None => return 60,
};
parse_retry_after_value(header, SystemTime::now())
}
fn parse_retry_after_value(header: &str, now: SystemTime) -> u64 {
let header = header.trim();
if let Ok(secs) = header.parse::<u64>() {
return secs.max(1);
}
if let Ok(date) = httpdate::parse_http_date(header)
&& let Ok(delta) = date.duration_since(SystemTime::now())
{
return delta.as_secs().max(1);
if let Ok(date) = httpdate::parse_http_date(header) {
return match date.duration_since(now) {
Ok(delta) => delta.as_secs().max(1),
Err(_) => 1,
};
}
60

View File

@@ -244,6 +244,21 @@ async fn test_retry_after_invalid_falls_back_to_60() {
}
}
#[test]
fn test_retry_after_http_date_in_past_returns_one_second() {
let now = SystemTime::now();
let past = now - Duration::from_secs(120);
let date_str = httpdate::fmt_http_date(past);
assert_eq!(parse_retry_after_value(&date_str, now), 1);
}
#[test]
fn test_retry_after_delta_seconds_trims_whitespace() {
let now = SystemTime::now();
assert_eq!(parse_retry_after_value(" 120 ", now), 120);
}
#[tokio::test]
async fn test_graphql_network_error() {
let client = GraphqlClient::new("http://127.0.0.1:1", "token");

View File

@@ -2,7 +2,7 @@ use std::ops::Deref;
use futures::StreamExt;
use rusqlite::{Connection, Transaction};
use tracing::{debug, info, warn};
use tracing::{debug, warn};
use crate::Config;
use crate::core::error::{LoreError, Result};
@@ -61,7 +61,7 @@ pub async fn ingest_issues(
while let Some(issue_result) = issues_stream.next().await {
if signal.is_cancelled() {
info!("Issue ingestion interrupted by shutdown signal");
debug!("Issue ingestion interrupted by shutdown signal");
break;
}
let issue = issue_result?;
@@ -108,7 +108,7 @@ pub async fn ingest_issues(
result.issues_needing_discussion_sync = get_issues_needing_discussion_sync(conn, project_id)?;
info!(
debug!(
summary = crate::ingestion::nonzero_summary(&[
("fetched", result.fetched),
("upserted", result.upserted),

View File

@@ -1,7 +1,7 @@
use std::ops::Deref;
use rusqlite::{Connection, Transaction, params};
use tracing::{debug, info, warn};
use tracing::{debug, warn};
use crate::Config;
use crate::core::error::{LoreError, Result};
@@ -61,7 +61,7 @@ pub async fn ingest_merge_requests(
loop {
if signal.is_cancelled() {
info!("MR ingestion interrupted by shutdown signal");
debug!("MR ingestion interrupted by shutdown signal");
break;
}
let page_result = client
@@ -121,7 +121,7 @@ pub async fn ingest_merge_requests(
}
}
info!(
debug!(
summary = crate::ingestion::nonzero_summary(&[
("fetched", result.fetched),
("upserted", result.upserted),

View File

@@ -1,6 +1,6 @@
use futures::future::join_all;
use rusqlite::Connection;
use tracing::{debug, info, instrument, warn};
use tracing::{debug, instrument, warn};
use crate::Config;
use crate::core::dependent_queue::{
@@ -45,7 +45,7 @@ pub enum ProgressEvent {
MrDiffsFetchStarted { total: usize },
MrDiffFetched { current: usize, total: usize },
MrDiffsFetchComplete { fetched: usize, failed: usize },
StatusEnrichmentStarted,
StatusEnrichmentStarted { total: usize },
StatusEnrichmentPageFetched { items_so_far: usize },
StatusEnrichmentWriting { total: usize },
StatusEnrichmentComplete { enriched: usize, cleared: usize },
@@ -153,7 +153,16 @@ pub async fn ingest_project_issues_with_progress(
if config.sync.fetch_work_item_status && !signal.is_cancelled() {
use rusqlite::OptionalExtension;
emit(ProgressEvent::StatusEnrichmentStarted);
let issue_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM issues WHERE project_id = ?1",
[project_id],
|r| r.get(0),
)
.unwrap_or(0);
emit(ProgressEvent::StatusEnrichmentStarted {
total: issue_count as usize,
});
let project_path: Option<String> = conn
.query_row(
@@ -225,9 +234,10 @@ pub async fn ingest_project_issues_with_progress(
Ok((enriched, cleared)) => {
result.statuses_enriched = enriched;
result.statuses_cleared = cleared;
result.statuses_without_widget =
result.statuses_seen.saturating_sub(enriched);
info!(
result.statuses_without_widget = result
.statuses_seen
.saturating_sub(fetch_result.statuses.len());
debug!(
seen = result.statuses_seen,
enriched,
cleared,
@@ -282,7 +292,7 @@ pub async fn ingest_project_issues_with_progress(
if issues_needing_sync.is_empty() {
debug!("No issues need discussion sync");
} else {
info!(
debug!(
count = issues_needing_sync.len(),
"Starting discussion sync for issues"
);
@@ -347,7 +357,7 @@ pub async fn ingest_project_issues_with_progress(
}
}
info!(
debug!(
summary = crate::ingestion::nonzero_summary(&[
("fetched", result.issues_fetched),
("upserted", result.issues_upserted),
@@ -402,12 +412,14 @@ fn enrich_issue_statuses_txn(
}
}
// Phase 2: Apply new/updated statuses
// Phase 2: Apply new/updated statuses (only write when values actually differ)
{
let mut update_stmt = tx.prepare_cached(
"UPDATE issues SET status_name = ?1, status_category = ?2, status_color = ?3,
status_icon_name = ?4, status_synced_at = ?5
WHERE project_id = ?6 AND iid = ?7",
WHERE project_id = ?6 AND iid = ?7
AND (status_name IS NOT ?1 OR status_category IS NOT ?2
OR status_color IS NOT ?3 OR status_icon_name IS NOT ?4)",
)?;
for (iid, status) in statuses {
let rows = update_stmt.execute(rusqlite::params![
@@ -423,6 +435,14 @@ fn enrich_issue_statuses_txn(
enriched += 1;
}
}
// Update synced_at timestamp for unchanged rows too
let mut touch_stmt = tx.prepare_cached(
"UPDATE issues SET status_synced_at = ?1
WHERE project_id = ?2 AND iid = ?3 AND status_synced_at IS NOT ?1",
)?;
for iid in statuses.keys() {
touch_stmt.execute(rusqlite::params![now_ms, project_id, iid])?;
}
}
tx.commit()?;
@@ -558,7 +578,7 @@ pub async fn ingest_project_merge_requests_with_progress(
if mrs_needing_sync.is_empty() {
debug!("No MRs need discussion sync");
} else {
info!(
debug!(
count = mrs_needing_sync.len(),
"Starting discussion sync for MRs"
);
@@ -705,7 +725,7 @@ pub async fn ingest_project_merge_requests_with_progress(
result.mr_diffs_failed = diffs_result.failed;
}
info!(
debug!(
summary = crate::ingestion::nonzero_summary(&[
("fetched", result.mrs_fetched),
("upserted", result.mrs_upserted),
@@ -923,7 +943,7 @@ async fn drain_resource_events(
let reclaimed = reclaim_stale_locks(conn, config.sync.stale_lock_minutes)?;
if reclaimed > 0 {
info!(reclaimed, "Reclaimed stale resource event locks");
debug!(reclaimed, "Reclaimed stale resource event locks");
}
let claimable_counts = count_claimable_jobs(conn, project_id)?;
@@ -1063,7 +1083,7 @@ async fn drain_resource_events(
});
if result.fetched > 0 || result.failed > 0 {
info!(
debug!(
fetched = result.fetched,
failed = result.failed,
"Resource events drain complete"
@@ -1245,7 +1265,7 @@ async fn drain_mr_closes_issues(
let reclaimed = reclaim_stale_locks(conn, config.sync.stale_lock_minutes)?;
if reclaimed > 0 {
info!(reclaimed, "Reclaimed stale mr_closes_issues locks");
debug!(reclaimed, "Reclaimed stale mr_closes_issues locks");
}
let claimable_counts = count_claimable_jobs(conn, project_id)?;
@@ -1373,7 +1393,7 @@ async fn drain_mr_closes_issues(
});
if result.fetched > 0 || result.failed > 0 {
info!(
debug!(
fetched = result.fetched,
failed = result.failed,
"mr_closes_issues drain complete"
@@ -1505,7 +1525,7 @@ async fn drain_mr_diffs(
let reclaimed = reclaim_stale_locks(conn, config.sync.stale_lock_minutes)?;
if reclaimed > 0 {
info!(reclaimed, "Reclaimed stale mr_diffs locks");
debug!(reclaimed, "Reclaimed stale mr_diffs locks");
}
let claimable_counts = count_claimable_jobs(conn, project_id)?;
@@ -1630,7 +1650,7 @@ async fn drain_mr_diffs(
});
if result.fetched > 0 || result.failed > 0 {
info!(
debug!(
fetched = result.fetched,
failed = result.failed,
"mr_diffs drain complete"

View File

@@ -25,7 +25,7 @@ use lore::cli::commands::{
run_list_issues, run_list_mrs, run_search, run_show_issue, run_show_mr, run_stats, run_sync,
run_sync_status, run_timeline, run_who,
};
use lore::cli::render::{ColorMode, LoreRenderer, Theme};
use lore::cli::render::{ColorMode, GlyphMode, Icons, LoreRenderer, Theme};
use lore::cli::robot::{RobotMeta, strip_schemas};
use lore::cli::{
Cli, Commands, CountArgs, EmbedArgs, GenerateDocsArgs, IngestArgs, IssuesArgs, MrsArgs,
@@ -144,25 +144,27 @@ async fn main() {
}
}
// I1: Respect NO_COLOR convention (https://no-color.org/)
// Icon mode is independent of color flags; robot mode still defaults to ASCII.
let glyphs = GlyphMode::detect(cli.icons.as_deref(), robot_mode);
if std::env::var("NO_COLOR").is_ok_and(|v| !v.is_empty()) {
LoreRenderer::init(ColorMode::Never);
LoreRenderer::init(ColorMode::Never, glyphs);
console::set_colors_enabled(false);
} else {
match cli.color.as_str() {
"never" => {
LoreRenderer::init(ColorMode::Never);
LoreRenderer::init(ColorMode::Never, glyphs);
console::set_colors_enabled(false);
}
"always" => {
LoreRenderer::init(ColorMode::Always);
LoreRenderer::init(ColorMode::Always, glyphs);
console::set_colors_enabled(true);
}
"auto" => {
LoreRenderer::init(ColorMode::Auto);
LoreRenderer::init(ColorMode::Auto, glyphs);
}
other => {
LoreRenderer::init(ColorMode::Auto);
LoreRenderer::init(ColorMode::Auto, glyphs);
eprintln!("Warning: unknown color mode '{}', using auto", other);
}
}
@@ -409,9 +411,15 @@ fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
);
std::process::exit(gi_error.exit_code());
} else {
eprintln!("{} {}", Theme::error().render("Error:"), gi_error);
eprintln!();
eprintln!(
" {} {}",
Theme::error().render(Icons::error()),
Theme::error().bold().render(&gi_error.to_string())
);
if let Some(suggestion) = gi_error.suggestion() {
eprintln!("{} {}", Theme::warning().render("Hint:"), suggestion);
eprintln!();
eprintln!(" {suggestion}");
}
let actions = gi_error.actions();
if !actions.is_empty() {
@@ -419,11 +427,12 @@ fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
for action in &actions {
eprintln!(
" {} {}",
Theme::dim().render("$"),
Theme::dim().render("\u{2192}"),
Theme::bold().render(action)
);
}
}
eprintln!();
std::process::exit(gi_error.exit_code());
}
}
@@ -443,7 +452,13 @@ fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
})
);
} else {
eprintln!("{} {}", Theme::error().render("Error:"), e);
eprintln!();
eprintln!(
" {} {}",
Theme::error().render(Icons::error()),
Theme::error().bold().render(&e.to_string())
);
eprintln!();
}
std::process::exit(1);
}
@@ -1901,9 +1916,9 @@ async fn handle_search(
limit: args.limit,
};
let spinner = lore::cli::progress::stage_spinner(
1,
1,
let spinner = lore::cli::progress::stage_spinner_v2(
lore::cli::render::Icons::search(),
"Search",
&format!("Searching ({})...", args.mode),
robot_mode,
);
@@ -1966,7 +1981,6 @@ async fn handle_embed(
args: EmbedArgs,
robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> {
use indicatif::{ProgressBar, ProgressStyle};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -1985,18 +1999,7 @@ async fn handle_embed(
std::process::exit(130);
});
let embed_bar = if robot_mode {
ProgressBar::hidden()
} else {
let b = lore::cli::progress::multi().add(ProgressBar::new(0));
b.set_style(
ProgressStyle::default_bar()
.template(" {spinner:.blue} Generating embeddings [{bar:30.cyan/dim}] {pos}/{len}")
.unwrap()
.progress_chars("=> "),
);
b
};
let embed_bar = lore::cli::progress::nested_progress("Embedding", 0, robot_mode);
let bar_clone = embed_bar.clone();
let tick_started = Arc::new(AtomicBool::new(false));
let tick_clone = Arc::clone(&tick_started);