Compare commits
10 Commits
ebf64816c9
...
eef73decb5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eef73decb5 | ||
|
|
bb6660178c | ||
|
|
64e73b1cab | ||
|
|
361757568f | ||
|
|
8572f6cc04 | ||
|
|
d0744039ef | ||
|
|
4b372dfb38 | ||
|
|
af8fc4af76 | ||
|
|
96b288ccdd | ||
|
|
d710403567 |
@@ -44,6 +44,7 @@ const GLOBAL_FLAGS: &[&str] = &[
|
|||||||
"--robot",
|
"--robot",
|
||||||
"--json",
|
"--json",
|
||||||
"--color",
|
"--color",
|
||||||
|
"--icons",
|
||||||
"--quiet",
|
"--quiet",
|
||||||
"--no-quiet",
|
"--no-quiet",
|
||||||
"--verbose",
|
"--verbose",
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ pub fn print_count(result: &CountResult) {
|
|||||||
|
|
||||||
if let Some(system_count) = result.system_count {
|
if let Some(system_count) = result.system_count {
|
||||||
println!(
|
println!(
|
||||||
"{}: {} {}",
|
"{}: {:>10} {}",
|
||||||
Theme::info().render(&result.entity),
|
Theme::info().render(&result.entity),
|
||||||
Theme::bold().render(&count_str),
|
Theme::bold().render(&count_str),
|
||||||
Theme::dim().render(&format!(
|
Theme::dim().render(&format!(
|
||||||
@@ -343,22 +343,22 @@ pub fn print_count(result: &CountResult) {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
println!(
|
println!(
|
||||||
"{}: {}",
|
"{}: {:>10}",
|
||||||
Theme::info().render(&result.entity),
|
Theme::info().render(&result.entity),
|
||||||
Theme::bold().render(&count_str)
|
Theme::bold().render(&count_str)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(breakdown) = &result.state_breakdown {
|
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 {
|
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
|
if let Some(locked) = breakdown.locked
|
||||||
&& locked > 0
|
&& locked > 0
|
||||||
{
|
{
|
||||||
println!(" locked: {}", render::format_number(locked));
|
println!(" locked: {:>10}", render::format_number(locked));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::cli::render::Theme;
|
use crate::cli::render::{Icons, Theme};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::core::config::Config;
|
use crate::core::config::Config;
|
||||||
@@ -530,7 +530,7 @@ fn check_logging(config: Option<&Config>) -> LoggingCheck {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn print_doctor_results(result: &DoctorResult) {
|
pub fn print_doctor_results(result: &DoctorResult) {
|
||||||
println!("\nlore doctor\n");
|
println!();
|
||||||
|
|
||||||
print_check("Config", &result.checks.config.result);
|
print_check("Config", &result.checks.config.result);
|
||||||
print_check("Database", &result.checks.database.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("Ollama", &result.checks.ollama.result);
|
||||||
print_check("Logging", &result.checks.logging.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!();
|
println!();
|
||||||
|
|
||||||
|
let mut summary_parts = Vec::new();
|
||||||
if result.success {
|
if result.success {
|
||||||
let ollama_ok = result.checks.ollama.result.status == CheckStatus::Ok;
|
summary_parts.push(Theme::success().render("Ready"));
|
||||||
if ollama_ok {
|
|
||||||
println!("{}", Theme::success().render("Status: Ready"));
|
|
||||||
} else {
|
|
||||||
println!(
|
|
||||||
"{} {}",
|
|
||||||
Theme::success().render("Status: Ready"),
|
|
||||||
Theme::warning()
|
|
||||||
.render("(lexical search available, semantic search requires Ollama)")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
println!("{}", Theme::error().render("Status: Not ready"));
|
summary_parts.push(Theme::error().render("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!();
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_check(name: &str, result: &CheckResult) {
|
fn print_check(name: &str, result: &CheckResult) {
|
||||||
let symbol = match result.status {
|
let icon = match result.status {
|
||||||
CheckStatus::Ok => Theme::success().render("\u{2713}"),
|
CheckStatus::Ok => Theme::success().render(Icons::success()),
|
||||||
CheckStatus::Warning => Theme::warning().render("\u{26a0}"),
|
CheckStatus::Warning => Theme::warning().render(Icons::warning()),
|
||||||
CheckStatus::Error => Theme::error().render("\u{2717}"),
|
CheckStatus::Error => Theme::error().render(Icons::error()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let message = result.message.as_deref().unwrap_or("");
|
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),
|
CheckStatus::Error => Theme::error().render(message),
|
||||||
};
|
};
|
||||||
|
|
||||||
println!(" {symbol} {:<10} {message_styled}", name);
|
println!(" {icon} {:<10} {message_styled}", name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::sync::LazyLock;
|
|||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::cli::render::Theme;
|
use crate::cli::render::{Icons, Theme};
|
||||||
use crate::cli::robot::RobotMeta;
|
use crate::cli::robot::RobotMeta;
|
||||||
use crate::core::config::Config;
|
use crate::core::config::Config;
|
||||||
use crate::core::db::create_connection;
|
use crate::core::db::create_connection;
|
||||||
@@ -428,7 +428,11 @@ pub fn print_drift_human(response: &DriftResponse) {
|
|||||||
println!();
|
println!();
|
||||||
|
|
||||||
if response.drift_detected {
|
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 {
|
if let Some(dp) = &response.drift_point {
|
||||||
println!(
|
println!(
|
||||||
" At note #{} by @{} ({}) - similarity {:.2}",
|
" At note #{} by @{} ({}) - similarity {:.2}",
|
||||||
@@ -439,7 +443,11 @@ pub fn print_drift_human(response: &DriftResponse) {
|
|||||||
println!(" Topics: {}", response.drift_topics.join(", "));
|
println!(" Topics: {}", response.drift_topics.join(", "));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
println!("{}", Theme::success().render("No drift detected"));
|
println!(
|
||||||
|
"{} {}",
|
||||||
|
Theme::success().render(Icons::success()),
|
||||||
|
Theme::success().render("No drift detected")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
@@ -450,7 +458,7 @@ pub fn print_drift_human(response: &DriftResponse) {
|
|||||||
println!("{}", Theme::bold().render("Similarity Curve:"));
|
println!("{}", Theme::bold().render("Similarity Curve:"));
|
||||||
for pt in &response.similarity_curve {
|
for pt in &response.similarity_curve {
|
||||||
let bar_len = ((pt.similarity.max(0.0)) * 30.0) as usize;
|
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!(
|
println!(
|
||||||
" {:>3} {:.2} {} @{}",
|
" {:>3} {:.2} {} @{}",
|
||||||
pt.note_index, pt.similarity, bar, pt.author
|
pt.note_index, pt.similarity, bar, pt.author
|
||||||
|
|||||||
@@ -46,6 +46,21 @@ pub struct IngestResult {
|
|||||||
pub mr_diffs_failed: usize,
|
pub mr_diffs_failed: usize,
|
||||||
pub status_enrichment_errors: usize,
|
pub status_enrichment_errors: usize,
|
||||||
pub status_enrichment_projects: Vec<ProjectStatusEnrichment>,
|
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.
|
/// Per-project status enrichment result, collected during ingestion.
|
||||||
@@ -388,11 +403,11 @@ async fn run_ingest_inner(
|
|||||||
let s = multi.add(ProgressBar::new_spinner());
|
let s = multi.add(ProgressBar::new_spinner());
|
||||||
s.set_style(
|
s.set_style(
|
||||||
ProgressStyle::default_spinner()
|
ProgressStyle::default_spinner()
|
||||||
.template("{spinner:.blue} {msg}")
|
.template("{spinner:.cyan} {msg}")
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
);
|
);
|
||||||
s.set_message(format!("Fetching {type_label} from {path}..."));
|
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
|
s
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -403,12 +418,13 @@ async fn run_ingest_inner(
|
|||||||
b.set_style(
|
b.set_style(
|
||||||
ProgressStyle::default_bar()
|
ProgressStyle::default_bar()
|
||||||
.template(
|
.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()
|
.unwrap()
|
||||||
.progress_chars("=> "),
|
.progress_chars(crate::cli::render::Icons::progress_chars()),
|
||||||
);
|
);
|
||||||
b.set_prefix(path.clone());
|
b.set_prefix(path.clone());
|
||||||
|
b.enable_steady_tick(std::time::Duration::from_millis(60));
|
||||||
b
|
b
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -445,7 +461,7 @@ async fn run_ingest_inner(
|
|||||||
spinner_clone.finish_and_clear();
|
spinner_clone.finish_and_clear();
|
||||||
let agg_total = agg_disc_total_clone.fetch_add(total, Ordering::Relaxed) + total;
|
let agg_total = agg_disc_total_clone.fetch_add(total, Ordering::Relaxed) + total;
|
||||||
disc_bar_clone.set_length(total as u64);
|
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!(
|
stage_bar_clone.set_message(format!(
|
||||||
"Syncing discussions... (0/{agg_total})"
|
"Syncing discussions... (0/{agg_total})"
|
||||||
));
|
));
|
||||||
@@ -465,7 +481,7 @@ async fn run_ingest_inner(
|
|||||||
spinner_clone.finish_and_clear();
|
spinner_clone.finish_and_clear();
|
||||||
let agg_total = agg_disc_total_clone.fetch_add(total, Ordering::Relaxed) + total;
|
let agg_total = agg_disc_total_clone.fetch_add(total, Ordering::Relaxed) + total;
|
||||||
disc_bar_clone.set_length(total as u64);
|
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!(
|
stage_bar_clone.set_message(format!(
|
||||||
"Syncing discussions... (0/{agg_total})"
|
"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_length(total as u64);
|
||||||
disc_bar_clone.set_style(
|
disc_bar_clone.set_style(
|
||||||
ProgressStyle::default_bar()
|
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()
|
.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);
|
agg_events_total_clone.fetch_add(total, Ordering::Relaxed);
|
||||||
stage_bar_clone.set_message(
|
stage_bar_clone.set_message(
|
||||||
"Fetching resource events...".to_string()
|
"Fetching resource events...".to_string()
|
||||||
@@ -510,7 +526,7 @@ async fn run_ingest_inner(
|
|||||||
ProgressEvent::ClosesIssuesFetchStarted { total } => {
|
ProgressEvent::ClosesIssuesFetchStarted { total } => {
|
||||||
disc_bar_clone.reset();
|
disc_bar_clone.reset();
|
||||||
disc_bar_clone.set_length(total as u64);
|
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(
|
stage_bar_clone.set_message(
|
||||||
"Fetching closes-issues references...".to_string()
|
"Fetching closes-issues references...".to_string()
|
||||||
);
|
);
|
||||||
@@ -524,7 +540,7 @@ async fn run_ingest_inner(
|
|||||||
ProgressEvent::MrDiffsFetchStarted { total } => {
|
ProgressEvent::MrDiffsFetchStarted { total } => {
|
||||||
disc_bar_clone.reset();
|
disc_bar_clone.reset();
|
||||||
disc_bar_clone.set_length(total as u64);
|
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(
|
stage_bar_clone.set_message(
|
||||||
"Fetching MR file changes...".to_string()
|
"Fetching MR file changes...".to_string()
|
||||||
);
|
);
|
||||||
@@ -535,35 +551,37 @@ async fn run_ingest_inner(
|
|||||||
ProgressEvent::MrDiffsFetchComplete { .. } => {
|
ProgressEvent::MrDiffsFetchComplete { .. } => {
|
||||||
disc_bar_clone.finish_and_clear();
|
disc_bar_clone.finish_and_clear();
|
||||||
}
|
}
|
||||||
ProgressEvent::StatusEnrichmentStarted => {
|
ProgressEvent::StatusEnrichmentStarted { total } => {
|
||||||
spinner_clone.set_message(format!(
|
spinner_clone.finish_and_clear();
|
||||||
"{path_for_cb}: Enriching work item statuses..."
|
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(
|
stage_bar_clone.set_message(
|
||||||
"Enriching work item statuses...".to_string()
|
"Enriching work item statuses...".to_string()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
ProgressEvent::StatusEnrichmentPageFetched { items_so_far } => {
|
ProgressEvent::StatusEnrichmentPageFetched { items_so_far } => {
|
||||||
spinner_clone.set_message(format!(
|
disc_bar_clone.set_position(items_so_far as u64);
|
||||||
"{path_for_cb}: Fetching statuses... ({items_so_far} work items)"
|
|
||||||
));
|
|
||||||
stage_bar_clone.set_message(format!(
|
stage_bar_clone.set_message(format!(
|
||||||
"Enriching work item statuses... ({items_so_far} fetched)"
|
"Enriching work item statuses... ({items_so_far} fetched)"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
ProgressEvent::StatusEnrichmentWriting { total } => {
|
ProgressEvent::StatusEnrichmentWriting { total } => {
|
||||||
spinner_clone.set_message(format!(
|
disc_bar_clone.set_message(format!("Writing {total} statuses..."));
|
||||||
"{path_for_cb}: Writing {total} statuses..."
|
|
||||||
));
|
|
||||||
stage_bar_clone.set_message(format!(
|
stage_bar_clone.set_message(format!(
|
||||||
"Writing {total} work item statuses..."
|
"Writing {total} work item statuses..."
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
ProgressEvent::StatusEnrichmentComplete { enriched, cleared } => {
|
ProgressEvent::StatusEnrichmentComplete { enriched, cleared } => {
|
||||||
|
disc_bar_clone.finish_and_clear();
|
||||||
if enriched > 0 || cleared > 0 {
|
if enriched > 0 || cleared > 0 {
|
||||||
spinner_clone.set_message(format!(
|
|
||||||
"{path_for_cb}: {enriched} statuses enriched, {cleared} cleared"
|
|
||||||
));
|
|
||||||
stage_bar_clone.set_message(format!(
|
stage_bar_clone.set_message(format!(
|
||||||
"Status enrichment: {enriched} enriched, {cleared} cleared"
|
"Status enrichment: {enriched} enriched, {cleared} cleared"
|
||||||
));
|
));
|
||||||
@@ -656,6 +674,17 @@ async fn run_ingest_inner(
|
|||||||
first_partial_error: result.first_partial_error.clone(),
|
first_partial_error: result.first_partial_error.clone(),
|
||||||
error: result.status_enrichment_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 {
|
Ok(ProjectIngestOutcome::Mrs {
|
||||||
ref path,
|
ref path,
|
||||||
@@ -679,6 +708,17 @@ async fn run_ingest_inner(
|
|||||||
total.resource_events_failed += result.resource_events_failed;
|
total.resource_events_failed += result.resource_events_failed;
|
||||||
total.mr_diffs_fetched += result.mr_diffs_fetched;
|
total.mr_diffs_fetched += result.mr_diffs_fetched;
|
||||||
total.mr_diffs_failed += result.mr_diffs_failed;
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 rusqlite::Connection;
|
||||||
use serde::Serialize;
|
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 {
|
if total == 0 {
|
||||||
return String::new();
|
return StyledCell::plain(String::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
if unresolved > 0 {
|
if unresolved > 0 {
|
||||||
format!("{total}/{unresolved}!")
|
let text = format!("{total}/");
|
||||||
|
let warn = Theme::warning().render(&format!("{unresolved}!"));
|
||||||
|
StyledCell::plain(format!("{text}{warn}"))
|
||||||
} else {
|
} else {
|
||||||
format!("{total}")
|
StyledCell::plain(format!("{total}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,7 +683,8 @@ pub fn print_list_issues(result: &ListResult) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"Issues (showing {} of {})\n",
|
"{} {} of {}\n",
|
||||||
|
Theme::bold().render("Issues"),
|
||||||
result.issues.len(),
|
result.issues.len(),
|
||||||
result.total_count
|
result.total_count
|
||||||
);
|
);
|
||||||
@@ -698,16 +701,17 @@ pub fn print_list_issues(result: &ListResult) {
|
|||||||
|
|
||||||
for issue in &result.issues {
|
for issue in &result.issues {
|
||||||
let title = render::truncate(&issue.title, 45);
|
let title = render::truncate(&issue.title, 45);
|
||||||
let relative_time = render::format_relative_time(issue.updated_at);
|
let relative_time = render::format_relative_time_compact(issue.updated_at);
|
||||||
let labels = render::format_labels(&issue.labels, 2);
|
let labels = render::format_labels_bare(&issue.labels, 2);
|
||||||
let assignee = format_assignees(&issue.assignees);
|
let assignee = format_assignees(&issue.assignees);
|
||||||
let discussions = format_discussions(issue.discussion_count, issue.unresolved_count);
|
let discussions = format_discussions(issue.discussion_count, issue.unresolved_count);
|
||||||
|
|
||||||
let state_cell = if issue.state == "opened" {
|
let (icon, state_style) = if issue.state == "opened" {
|
||||||
StyledCell::styled(&issue.state, Theme::success())
|
(Icons::issue_opened(), Theme::success())
|
||||||
} else {
|
} 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![
|
let mut row = vec![
|
||||||
StyledCell::styled(format!("#{}", issue.iid), Theme::info()),
|
StyledCell::styled(format!("#{}", issue.iid), Theme::info()),
|
||||||
@@ -730,7 +734,7 @@ pub fn print_list_issues(result: &ListResult) {
|
|||||||
row.extend([
|
row.extend([
|
||||||
StyledCell::styled(assignee, Theme::accent()),
|
StyledCell::styled(assignee, Theme::accent()),
|
||||||
StyledCell::styled(labels, Theme::warning()),
|
StyledCell::styled(labels, Theme::warning()),
|
||||||
StyledCell::plain(discussions),
|
discussions,
|
||||||
StyledCell::styled(relative_time, Theme::dim()),
|
StyledCell::styled(relative_time, Theme::dim()),
|
||||||
]);
|
]);
|
||||||
table.add_row(row);
|
table.add_row(row);
|
||||||
@@ -783,7 +787,8 @@ pub fn print_list_mrs(result: &MrListResult) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"Merge Requests (showing {} of {})\n",
|
"{} {} of {}\n",
|
||||||
|
Theme::bold().render("Merge Requests"),
|
||||||
result.mrs.len(),
|
result.mrs.len(),
|
||||||
result.total_count
|
result.total_count
|
||||||
);
|
);
|
||||||
@@ -796,22 +801,23 @@ pub fn print_list_mrs(result: &MrListResult) {
|
|||||||
|
|
||||||
for mr in &result.mrs {
|
for mr in &result.mrs {
|
||||||
let title = if mr.draft {
|
let title = if mr.draft {
|
||||||
format!("[DRAFT] {}", render::truncate(&mr.title, 38))
|
format!("{} {}", Icons::mr_draft(), render::truncate(&mr.title, 42))
|
||||||
} else {
|
} else {
|
||||||
render::truncate(&mr.title, 45)
|
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 branches = format_branches(&mr.target_branch, &mr.source_branch, 25);
|
||||||
let discussions = format_discussions(mr.discussion_count, mr.unresolved_count);
|
let discussions = format_discussions(mr.discussion_count, mr.unresolved_count);
|
||||||
|
|
||||||
let state_cell = match mr.state.as_str() {
|
let (icon, style) = match mr.state.as_str() {
|
||||||
"opened" => StyledCell::styled(&mr.state, Theme::success()),
|
"opened" => (Icons::mr_opened(), Theme::success()),
|
||||||
"merged" => StyledCell::styled(&mr.state, Theme::accent()),
|
"merged" => (Icons::mr_merged(), Theme::accent()),
|
||||||
"closed" => StyledCell::styled(&mr.state, Theme::error()),
|
"closed" => (Icons::mr_closed(), Theme::error()),
|
||||||
"locked" => StyledCell::styled(&mr.state, Theme::warning()),
|
"locked" => (Icons::mr_opened(), Theme::warning()),
|
||||||
_ => StyledCell::styled(&mr.state, Theme::dim()),
|
_ => (Icons::mr_opened(), Theme::dim()),
|
||||||
};
|
};
|
||||||
|
let state_cell = StyledCell::styled(format!("{icon} {}", mr.state), style);
|
||||||
|
|
||||||
table.add_row(vec![
|
table.add_row(vec![
|
||||||
StyledCell::styled(format!("!{}", mr.iid), Theme::info()),
|
StyledCell::styled(format!("!{}", mr.iid), Theme::info()),
|
||||||
@@ -822,7 +828,7 @@ pub fn print_list_mrs(result: &MrListResult) {
|
|||||||
Theme::accent(),
|
Theme::accent(),
|
||||||
),
|
),
|
||||||
StyledCell::styled(branches, Theme::info()),
|
StyledCell::styled(branches, Theme::info()),
|
||||||
StyledCell::plain(discussions),
|
discussions,
|
||||||
StyledCell::styled(relative_time, Theme::dim()),
|
StyledCell::styled(relative_time, Theme::dim()),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -909,7 +915,8 @@ pub fn print_list_notes(result: &NoteListResult) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"Notes (showing {} of {})\n",
|
"{} {} of {}\n",
|
||||||
|
Theme::bold().render("Notes"),
|
||||||
result.notes.len(),
|
result.notes.len(),
|
||||||
result.total_count
|
result.total_count
|
||||||
);
|
);
|
||||||
@@ -934,7 +941,7 @@ pub fn print_list_notes(result: &NoteListResult) {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let path = format_note_path(note.position_new_path.as_deref(), note.position_new_line);
|
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 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());
|
let note_type = format_note_type(note.note_type.as_deref());
|
||||||
|
|
||||||
table.add_row(vec![
|
table.add_row(vec![
|
||||||
|
|||||||
@@ -62,17 +62,20 @@ fn format_labels_overflow() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_discussions_empty() {
|
fn format_discussions_empty() {
|
||||||
assert_eq!(format_discussions(0, 0), "");
|
assert_eq!(format_discussions(0, 0).text, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_discussions_no_unresolved() {
|
fn format_discussions_no_unresolved() {
|
||||||
assert_eq!(format_discussions(5, 0), "5");
|
assert_eq!(format_discussions(5, 0).text, "5");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_discussions_with_unresolved() {
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -309,20 +309,20 @@ fn parse_json_array(json: &str) -> Vec<String> {
|
|||||||
.collect()
|
.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 {
|
fn render_snippet(snippet: &str) -> String {
|
||||||
let mut result = String::new();
|
let mut result = String::new();
|
||||||
let mut remaining = snippet;
|
let mut remaining = snippet;
|
||||||
while let Some(start) = remaining.find("<mark>") {
|
while let Some(start) = remaining.find("<mark>") {
|
||||||
result.push_str(&remaining[..start]);
|
result.push_str(&Theme::muted().render(&remaining[..start]));
|
||||||
remaining = &remaining[start + 6..];
|
remaining = &remaining[start + 6..];
|
||||||
if let Some(end) = remaining.find("</mark>") {
|
if let Some(end) = remaining.find("</mark>") {
|
||||||
let highlighted = &remaining[..end];
|
let highlighted = &remaining[..end];
|
||||||
result.push_str(&Theme::bold().underline().render(highlighted));
|
result.push_str(&Theme::highlight().render(highlighted));
|
||||||
remaining = &remaining[end + 7..];
|
remaining = &remaining[end + 7..];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.push_str(remaining);
|
result.push_str(&Theme::muted().render(remaining));
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,35 +342,37 @@ pub fn print_search_results(response: &SearchResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"\n {} results for '{}' {}",
|
"\n {} results for '{}' {}",
|
||||||
Theme::bold().render(&response.total_results.to_string()),
|
Theme::bold().render(&response.total_results.to_string()),
|
||||||
Theme::bold().render(&response.query),
|
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() {
|
for (i, result) in response.results.iter().enumerate() {
|
||||||
|
println!();
|
||||||
|
|
||||||
let type_badge = match result.source_type.as_str() {
|
let type_badge = match result.source_type.as_str() {
|
||||||
"issue" => Theme::info().render("issue"),
|
"issue" => Theme::issue_ref().render("issue"),
|
||||||
"merge_request" => Theme::accent().render("mr"),
|
"merge_request" => Theme::mr_ref().render(" mr "),
|
||||||
"discussion" => Theme::info().render("disc"),
|
"discussion" => Theme::info().render(" disc"),
|
||||||
"note" => Theme::info().render("note"),
|
"note" => Theme::muted().render(" note"),
|
||||||
_ => Theme::dim().render(&result.source_type),
|
_ => Theme::muted().render(&format!("{:>5}", &result.source_type)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Title line: rank, type badge, title
|
// Title line: rank, type badge, title
|
||||||
println!(
|
println!(
|
||||||
" {} {} {}",
|
" {:>3}. {} {}",
|
||||||
Theme::dim().render(&format!("{:>2}.", i + 1)),
|
Theme::muted().render(&(i + 1).to_string()),
|
||||||
type_badge,
|
type_badge,
|
||||||
Theme::bold().render(&result.title)
|
Theme::bold().render(&result.title)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Metadata: project, author, labels — compact middle-dot line
|
// Metadata: project, author, labels — compact middle-dot line
|
||||||
|
let sep = Theme::muted().render(" \u{b7} ");
|
||||||
let mut meta_parts: Vec<String> = Vec::new();
|
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 {
|
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() {
|
if !result.labels.is_empty() {
|
||||||
let label_str = if result.labels.len() <= 3 {
|
let label_str = if result.labels.len() <= 3 {
|
||||||
@@ -382,20 +384,17 @@ pub fn print_search_results(response: &SearchResponse) {
|
|||||||
result.labels.len() - 2
|
result.labels.len() - 2
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
meta_parts.push(label_str);
|
meta_parts.push(Theme::muted().render(&label_str));
|
||||||
}
|
}
|
||||||
println!(
|
println!(" {}", meta_parts.join(&sep));
|
||||||
" {}",
|
|
||||||
Theme::dim().render(&meta_parts.join(" \u{b7} "))
|
|
||||||
);
|
|
||||||
|
|
||||||
// Snippet with proper highlighting
|
// Snippet with highlight styling
|
||||||
let rendered = render_snippet(&result.snippet);
|
let rendered = render_snippet(&result.snippet);
|
||||||
println!(" {}", Theme::dim().render(&rendered));
|
println!(" {rendered}");
|
||||||
|
|
||||||
if let Some(ref explain) = result.explain {
|
if let Some(ref explain) = result.explain {
|
||||||
println!(
|
println!(
|
||||||
" {} vec={} fts={} rrf={:.4}",
|
" {} vec={} fts={} rrf={:.4}",
|
||||||
Theme::accent().render("explain"),
|
Theme::accent().render("explain"),
|
||||||
explain
|
explain
|
||||||
.vector_rank
|
.vector_rank
|
||||||
@@ -408,9 +407,9 @@ pub fn print_search_results(response: &SearchResponse) {
|
|||||||
explain.rrf_score
|
explain.rrf_score
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::cli::render::{self, Theme};
|
use crate::cli::render::{self, Icons, Theme};
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
@@ -614,33 +614,47 @@ fn wrap_text(text: &str, width: usize, indent: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn print_show_issue(issue: &IssueDetail) {
|
pub fn print_show_issue(issue: &IssueDetail) {
|
||||||
let header = format!("Issue #{}: {}", issue.iid, issue.title);
|
// Title line
|
||||||
println!("{}", Theme::bold().render(&header));
|
println!(
|
||||||
println!("{}", "\u{2501}".repeat(header.len().min(80)));
|
" Issue #{}: {}",
|
||||||
println!();
|
issue.iid,
|
||||||
|
Theme::bold().render(&issue.title),
|
||||||
|
);
|
||||||
|
|
||||||
println!("Ref: {}", Theme::dim().render(&issue.references_full));
|
// Details section
|
||||||
println!("Project: {}", Theme::info().render(&issue.project_path));
|
println!("{}", render::section_divider("Details"));
|
||||||
|
|
||||||
let state_styled = if issue.state == "opened" {
|
println!(
|
||||||
Theme::success().render(&issue.state)
|
" 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 {
|
} else {
|
||||||
Theme::dim().render(&issue.state)
|
(Icons::issue_closed(), Theme::dim())
|
||||||
};
|
};
|
||||||
println!("State: {}", state_styled);
|
println!(
|
||||||
|
" State {}",
|
||||||
if issue.confidential {
|
state_style.render(&format!("{icon} {}", issue.state))
|
||||||
println!(" {}", Theme::error().bold().render("CONFIDENTIAL"));
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(status) = &issue.status_name {
|
if let Some(status) = &issue.status_name {
|
||||||
println!(
|
println!(
|
||||||
"Status: {}",
|
" Status {}",
|
||||||
render::style_with_hex(status, issue.status_color.as_deref())
|
render::style_with_hex(status, issue.status_color.as_deref())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("Author: @{}", issue.author_username);
|
if issue.confidential {
|
||||||
|
println!(" {}", Theme::error().bold().render("CONFIDENTIAL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(" Author @{}", issue.author_username);
|
||||||
|
|
||||||
if !issue.assignees.is_empty() {
|
if !issue.assignees.is_empty() {
|
||||||
let label = if issue.assignees.len() > 1 {
|
let label = if issue.assignees.len() > 1 {
|
||||||
@@ -649,69 +663,82 @@ pub fn print_show_issue(issue: &IssueDetail) {
|
|||||||
"Assignee"
|
"Assignee"
|
||||||
};
|
};
|
||||||
println!(
|
println!(
|
||||||
"{}:{} {}",
|
" {}{} {}",
|
||||||
label,
|
label,
|
||||||
" ".repeat(10 - label.len()),
|
" ".repeat(12 - label.len()),
|
||||||
issue
|
issue
|
||||||
.assignees
|
.assignees
|
||||||
.iter()
|
.iter()
|
||||||
.map(|a| format!("@{}", a))
|
.map(|a| format!("@{a}"))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ")
|
.join(", ")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("Created: {}", format_date(issue.created_at));
|
println!(
|
||||||
println!("Updated: {}", format_date(issue.updated_at));
|
" 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 {
|
if let Some(closed_at) = &issue.closed_at {
|
||||||
println!("Closed: {}", closed_at);
|
println!(" Closed {closed_at}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(due) = &issue.due_date {
|
if let Some(due) = &issue.due_date {
|
||||||
println!("Due: {}", due);
|
println!(" Due {due}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ms) = &issue.milestone {
|
if let Some(ms) = &issue.milestone {
|
||||||
println!("Milestone: {}", ms);
|
println!(" Milestone {ms}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if issue.labels.is_empty() {
|
if !issue.labels.is_empty() {
|
||||||
println!("Labels: {}", Theme::dim().render("(none)"));
|
println!(
|
||||||
} else {
|
" Labels {}",
|
||||||
println!("Labels: {}", issue.labels.join(", "));
|
render::format_labels_bare(&issue.labels, issue.labels.len())
|
||||||
}
|
);
|
||||||
|
|
||||||
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 let Some(url) = &issue.web_url {
|
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 {
|
if let Some(desc) = &issue.description {
|
||||||
let wrapped = wrap_text(desc, 76, " ");
|
let wrapped = wrap_text(desc, 72, " ");
|
||||||
println!(" {}", wrapped);
|
println!(" {wrapped}");
|
||||||
} else {
|
} else {
|
||||||
println!(" {}", Theme::dim().render("(no description)"));
|
println!(" {}", Theme::muted().render("(no description)"));
|
||||||
}
|
}
|
||||||
|
|
||||||
println!();
|
// Discussions section
|
||||||
|
|
||||||
let user_discussions: Vec<&DiscussionDetail> = issue
|
let user_discussions: Vec<&DiscussionDetail> = issue
|
||||||
.discussions
|
.discussions
|
||||||
.iter()
|
.iter()
|
||||||
@@ -719,13 +746,12 @@ pub fn print_show_issue(issue: &IssueDetail) {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if user_discussions.is_empty() {
|
if user_discussions.is_empty() {
|
||||||
println!("{}", Theme::dim().render("Discussions: (none)"));
|
println!("\n {}", Theme::muted().render("No discussions"));
|
||||||
} else {
|
} else {
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
Theme::bold().render(&format!("Discussions ({}):", user_discussions.len()))
|
render::section_divider(&format!("Discussions ({})", user_discussions.len()))
|
||||||
);
|
);
|
||||||
println!();
|
|
||||||
|
|
||||||
for discussion in user_discussions {
|
for discussion in user_discussions {
|
||||||
let user_notes: Vec<&NoteDetail> =
|
let user_notes: Vec<&NoteDetail> =
|
||||||
@@ -733,22 +759,22 @@ pub fn print_show_issue(issue: &IssueDetail) {
|
|||||||
|
|
||||||
if let Some(first_note) = user_notes.first() {
|
if let Some(first_note) = user_notes.first() {
|
||||||
println!(
|
println!(
|
||||||
" {} ({}):",
|
" {} {}",
|
||||||
Theme::info().render(&format!("@{}", first_note.author_username)),
|
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, " ");
|
let wrapped = wrap_text(&first_note.body, 68, " ");
|
||||||
println!(" {}", wrapped);
|
println!(" {wrapped}");
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
for reply in user_notes.iter().skip(1) {
|
for reply in user_notes.iter().skip(1) {
|
||||||
println!(
|
println!(
|
||||||
" {} ({}):",
|
" {} {}",
|
||||||
Theme::info().render(&format!("@{}", reply.author_username)),
|
Theme::info().render(&format!("@{}", reply.author_username)),
|
||||||
format_date(reply.created_at)
|
format_date(reply.created_at),
|
||||||
);
|
);
|
||||||
let wrapped = wrap_text(&reply.body, 68, " ");
|
let wrapped = wrap_text(&reply.body, 66, " ");
|
||||||
println!(" {}", wrapped);
|
println!(" {wrapped}");
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -757,36 +783,49 @@ pub fn print_show_issue(issue: &IssueDetail) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn print_show_mr(mr: &MrDetail) {
|
pub fn print_show_mr(mr: &MrDetail) {
|
||||||
let draft_prefix = if mr.draft { "[Draft] " } else { "" };
|
// Title line
|
||||||
let header = format!("MR !{}: {}{}", mr.iid, draft_prefix, mr.title);
|
let draft_prefix = if mr.draft {
|
||||||
println!("{}", Theme::bold().render(&header));
|
format!("{} ", Icons::mr_draft())
|
||||||
println!("{}", "\u{2501}".repeat(header.len().min(80)));
|
} else {
|
||||||
println!();
|
String::new()
|
||||||
|
|
||||||
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),
|
|
||||||
};
|
};
|
||||||
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!(
|
println!(
|
||||||
"Branches: {} -> {}",
|
" Branches {} -> {}",
|
||||||
Theme::info().render(&mr.source_branch),
|
Theme::info().render(&mr.source_branch),
|
||||||
Theme::warning().render(&mr.target_branch)
|
Theme::warning().render(&mr.target_branch)
|
||||||
);
|
);
|
||||||
|
|
||||||
println!("Author: @{}", mr.author_username);
|
println!(" Author @{}", mr.author_username);
|
||||||
|
|
||||||
if !mr.assignees.is_empty() {
|
if !mr.assignees.is_empty() {
|
||||||
println!(
|
println!(
|
||||||
"Assignees: {}",
|
" Assignees {}",
|
||||||
mr.assignees
|
mr.assignees
|
||||||
.iter()
|
.iter()
|
||||||
.map(|a| format!("@{}", a))
|
.map(|a| format!("@{a}"))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ")
|
.join(", ")
|
||||||
);
|
);
|
||||||
@@ -794,48 +833,63 @@ pub fn print_show_mr(mr: &MrDetail) {
|
|||||||
|
|
||||||
if !mr.reviewers.is_empty() {
|
if !mr.reviewers.is_empty() {
|
||||||
println!(
|
println!(
|
||||||
"Reviewers: {}",
|
" Reviewers {}",
|
||||||
mr.reviewers
|
mr.reviewers
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| format!("@{}", r))
|
.map(|r| format!("@{r}"))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ")
|
.join(", ")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("Created: {}", format_date(mr.created_at));
|
println!(
|
||||||
println!("Updated: {}", format_date(mr.updated_at));
|
" 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 {
|
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 {
|
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() {
|
if !mr.labels.is_empty() {
|
||||||
println!("Labels: {}", Theme::dim().render("(none)"));
|
println!(
|
||||||
} else {
|
" Labels {}",
|
||||||
println!("Labels: {}", mr.labels.join(", "));
|
render::format_labels_bare(&mr.labels, mr.labels.len())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(url) = &mr.web_url {
|
if let Some(url) = &mr.web_url {
|
||||||
println!("URL: {}", Theme::dim().render(url));
|
println!(" URL {}", Theme::muted().render(url));
|
||||||
}
|
}
|
||||||
|
|
||||||
println!();
|
// Description section
|
||||||
|
println!("{}", render::section_divider("Description"));
|
||||||
println!("{}", Theme::bold().render("Description:"));
|
|
||||||
if let Some(desc) = &mr.description {
|
if let Some(desc) = &mr.description {
|
||||||
let wrapped = wrap_text(desc, 76, " ");
|
let wrapped = wrap_text(desc, 72, " ");
|
||||||
println!(" {}", wrapped);
|
println!(" {wrapped}");
|
||||||
} else {
|
} else {
|
||||||
println!(" {}", Theme::dim().render("(no description)"));
|
println!(" {}", Theme::muted().render("(no description)"));
|
||||||
}
|
}
|
||||||
|
|
||||||
println!();
|
// Discussions section
|
||||||
|
|
||||||
let user_discussions: Vec<&MrDiscussionDetail> = mr
|
let user_discussions: Vec<&MrDiscussionDetail> = mr
|
||||||
.discussions
|
.discussions
|
||||||
.iter()
|
.iter()
|
||||||
@@ -843,13 +897,12 @@ pub fn print_show_mr(mr: &MrDetail) {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if user_discussions.is_empty() {
|
if user_discussions.is_empty() {
|
||||||
println!("{}", Theme::dim().render("Discussions: (none)"));
|
println!("\n {}", Theme::muted().render("No discussions"));
|
||||||
} else {
|
} else {
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
Theme::bold().render(&format!("Discussions ({}):", user_discussions.len()))
|
render::section_divider(&format!("Discussions ({})", user_discussions.len()))
|
||||||
);
|
);
|
||||||
println!();
|
|
||||||
|
|
||||||
for discussion in user_discussions {
|
for discussion in user_discussions {
|
||||||
let user_notes: Vec<&MrNoteDetail> =
|
let user_notes: Vec<&MrNoteDetail> =
|
||||||
@@ -861,22 +914,22 @@ pub fn print_show_mr(mr: &MrDetail) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
" {} ({}):",
|
" {} {}",
|
||||||
Theme::info().render(&format!("@{}", first_note.author_username)),
|
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, " ");
|
let wrapped = wrap_text(&first_note.body, 68, " ");
|
||||||
println!(" {}", wrapped);
|
println!(" {wrapped}");
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
for reply in user_notes.iter().skip(1) {
|
for reply in user_notes.iter().skip(1) {
|
||||||
println!(
|
println!(
|
||||||
" {} ({}):",
|
" {} {}",
|
||||||
Theme::info().render(&format!("@{}", reply.author_username)),
|
Theme::info().render(&format!("@{}", reply.author_username)),
|
||||||
format_date(reply.created_at)
|
format_date(reply.created_at),
|
||||||
);
|
);
|
||||||
let wrapped = wrap_text(&reply.body, 68, " ");
|
let wrapped = wrap_text(&reply.body, 66, " ");
|
||||||
println!(" {}", wrapped);
|
println!(" {wrapped}");
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -328,26 +328,44 @@ fn section(title: &str) {
|
|||||||
|
|
||||||
pub fn print_stats(result: &StatsResult) {
|
pub fn print_stats(result: &StatsResult) {
|
||||||
section("Documents");
|
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 {
|
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 {
|
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 {
|
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} "));
|
println!(" {}", parts.join(" \u{b7} "));
|
||||||
if result.documents.truncated > 0 {
|
if result.documents.truncated > 0 {
|
||||||
println!(
|
println!(
|
||||||
" {}",
|
" {}",
|
||||||
Theme::warning().render(&format!("{} truncated", result.documents.truncated))
|
Theme::warning().render(&format!(
|
||||||
|
"{} truncated",
|
||||||
|
render::format_number(result.documents.truncated)
|
||||||
|
))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
section("Search Index");
|
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 {
|
let coverage_color = if result.embeddings.coverage_pct >= 95.0 {
|
||||||
Theme::success().render(&format!("{:.0}%", result.embeddings.coverage_pct))
|
Theme::success().render(&format!("{:.0}%", result.embeddings.coverage_pct))
|
||||||
} else if result.embeddings.coverage_pct >= 50.0 {
|
} else if result.embeddings.coverage_pct >= 50.0 {
|
||||||
@@ -357,12 +375,17 @@ pub fn print_stats(result: &StatsResult) {
|
|||||||
};
|
};
|
||||||
println!(
|
println!(
|
||||||
" {} embedding coverage ({}/{})",
|
" {} 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 {
|
if result.embeddings.total_chunks > 0 {
|
||||||
println!(
|
println!(
|
||||||
" {}",
|
" {}",
|
||||||
Theme::dim().render(&format!("{} chunks", result.embeddings.total_chunks))
|
Theme::dim().render(&format!(
|
||||||
|
"{} chunks",
|
||||||
|
render::format_number(result.embeddings.total_chunks)
|
||||||
|
))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
use crate::cli::render::{self, Theme};
|
use crate::cli::render::{self, Icons, Theme, format_number};
|
||||||
use indicatif::{ProgressBar, ProgressStyle};
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::sync::Arc;
|
use std::time::Instant;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use tracing::Instrument;
|
use tracing::Instrument;
|
||||||
use tracing::{info, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
use crate::Config;
|
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::error::Result;
|
||||||
use crate::core::metrics::{MetricsLayer, StageTiming};
|
use crate::core::metrics::{MetricsLayer, StageTiming};
|
||||||
use crate::core::shutdown::ShutdownSignal;
|
use crate::core::shutdown::ShutdownSignal;
|
||||||
|
|
||||||
use super::embed::run_embed;
|
use super::embed::run_embed;
|
||||||
use super::generate_docs::run_generate_docs;
|
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)]
|
#[derive(Debug, Default)]
|
||||||
pub struct SyncOptions {
|
pub struct SyncOptions {
|
||||||
@@ -41,6 +39,11 @@ pub struct SyncResult {
|
|||||||
pub documents_regenerated: usize,
|
pub documents_regenerated: usize,
|
||||||
pub documents_embedded: usize,
|
pub documents_embedded: usize,
|
||||||
pub status_enrichment_errors: 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(
|
pub async fn run_sync(
|
||||||
@@ -76,23 +79,10 @@ pub async fn run_sync(
|
|||||||
IngestDisplay::progress_only()
|
IngestDisplay::progress_only()
|
||||||
};
|
};
|
||||||
|
|
||||||
let total_stages: u8 = if options.no_docs && options.no_embed {
|
// ── Stage: Issues ──
|
||||||
2
|
let stage_start = Instant::now();
|
||||||
} else if options.no_docs || options.no_embed {
|
let spinner = stage_spinner_v2(Icons::sync(), "Issues", "fetching...", options.robot_mode);
|
||||||
3
|
debug!("Sync: ingesting issues");
|
||||||
} 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");
|
|
||||||
let issues_result = run_ingest(
|
let issues_result = run_ingest(
|
||||||
config,
|
config,
|
||||||
"issues",
|
"issues",
|
||||||
@@ -110,21 +100,30 @@ pub async fn run_sync(
|
|||||||
result.resource_events_fetched += issues_result.resource_events_fetched;
|
result.resource_events_fetched += issues_result.resource_events_fetched;
|
||||||
result.resource_events_failed += issues_result.resource_events_failed;
|
result.resource_events_failed += issues_result.resource_events_failed;
|
||||||
result.status_enrichment_errors += issues_result.status_enrichment_errors;
|
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() {
|
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);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
current_stage += 1;
|
// ── Stage: MRs ──
|
||||||
let spinner = stage_spinner(
|
let stage_start = Instant::now();
|
||||||
current_stage,
|
let spinner = stage_spinner_v2(Icons::sync(), "MRs", "fetching...", options.robot_mode);
|
||||||
total_stages,
|
debug!("Sync: ingesting merge requests");
|
||||||
"Fetching merge requests from GitLab...",
|
|
||||||
options.robot_mode,
|
|
||||||
);
|
|
||||||
info!("Sync stage {current_stage}/{total_stages}: ingesting merge requests");
|
|
||||||
let mrs_result = run_ingest(
|
let mrs_result = run_ingest(
|
||||||
config,
|
config,
|
||||||
"mrs",
|
"mrs",
|
||||||
@@ -143,45 +142,33 @@ pub async fn run_sync(
|
|||||||
result.resource_events_failed += mrs_result.resource_events_failed;
|
result.resource_events_failed += mrs_result.resource_events_failed;
|
||||||
result.mr_diffs_fetched += mrs_result.mr_diffs_fetched;
|
result.mr_diffs_fetched += mrs_result.mr_diffs_fetched;
|
||||||
result.mr_diffs_failed += mrs_result.mr_diffs_failed;
|
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() {
|
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);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Stage: Docs ──
|
||||||
if !options.no_docs {
|
if !options.no_docs {
|
||||||
current_stage += 1;
|
let stage_start = Instant::now();
|
||||||
let spinner = stage_spinner(
|
let spinner = stage_spinner_v2(Icons::sync(), "Docs", "generating...", options.robot_mode);
|
||||||
current_stage,
|
debug!("Sync: generating documents");
|
||||||
total_stages,
|
|
||||||
"Processing documents...",
|
|
||||||
options.robot_mode,
|
|
||||||
);
|
|
||||||
info!("Sync stage {current_stage}/{total_stages}: generating documents");
|
|
||||||
|
|
||||||
let docs_bar = if options.robot_mode {
|
let docs_bar = nested_progress("Docs", 0, 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_clone = docs_bar.clone();
|
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| {
|
let docs_cb: Box<dyn Fn(usize, usize)> = Box::new(move |processed, total| {
|
||||||
if total > 0 {
|
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_length(total as u64);
|
||||||
docs_bar_clone.set_position(processed 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))?;
|
let docs_result = run_generate_docs(config, options.full, None, Some(docs_cb))?;
|
||||||
result.documents_regenerated = docs_result.regenerated;
|
result.documents_regenerated = docs_result.regenerated;
|
||||||
docs_bar.finish_and_clear();
|
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 {
|
} else {
|
||||||
info!("Sync: skipping document generation (--no-docs)");
|
debug!("Sync: skipping document generation (--no-docs)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Stage: Embed ──
|
||||||
if !options.no_embed {
|
if !options.no_embed {
|
||||||
current_stage += 1;
|
let stage_start = Instant::now();
|
||||||
let spinner = stage_spinner(
|
let spinner = stage_spinner_v2(Icons::sync(), "Embed", "preparing...", options.robot_mode);
|
||||||
current_stage,
|
debug!("Sync: embedding documents");
|
||||||
total_stages,
|
|
||||||
"Generating embeddings...",
|
|
||||||
options.robot_mode,
|
|
||||||
);
|
|
||||||
info!("Sync stage {current_stage}/{total_stages}: embedding documents");
|
|
||||||
|
|
||||||
let embed_bar = if options.robot_mode {
|
let embed_bar = nested_progress("Embed", 0, 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_clone = embed_bar.clone();
|
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| {
|
let embed_cb: Box<dyn Fn(usize, usize)> = Box::new(move |processed, total| {
|
||||||
if total > 0 {
|
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_length(total as u64);
|
||||||
embed_bar_clone.set_position(processed as u64);
|
embed_bar_clone.set_position(processed as u64);
|
||||||
}
|
}
|
||||||
@@ -234,22 +203,24 @@ pub async fn run_sync(
|
|||||||
Ok(embed_result) => {
|
Ok(embed_result) => {
|
||||||
result.documents_embedded = embed_result.docs_embedded;
|
result.documents_embedded = embed_result.docs_embedded;
|
||||||
embed_bar.finish_and_clear();
|
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) => {
|
Err(e) => {
|
||||||
embed_bar.finish_and_clear();
|
embed_bar.finish_and_clear();
|
||||||
spinner.finish_and_clear();
|
let warn_summary = format!("skipped ({})", e);
|
||||||
if !options.robot_mode {
|
finish_stage(&spinner, Icons::warning(), "Embed", &warn_summary, stage_start.elapsed());
|
||||||
eprintln!(" {} Embedding skipped ({})", Theme::warning().render("warn"), e);
|
|
||||||
}
|
|
||||||
warn!(error = %e, "Embedding stage failed (Ollama may be unavailable), continuing");
|
warn!(error = %e, "Embedding stage failed (Ollama may be unavailable), continuing");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
info!("Sync: skipping embedding (--no-embed)");
|
debug!("Sync: skipping embedding (--no-embed)");
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(
|
debug!(
|
||||||
issues = result.issues_updated,
|
issues = result.issues_updated,
|
||||||
mrs = result.mrs_updated,
|
mrs = result.mrs_updated,
|
||||||
discussions = result.discussions_fetched,
|
discussions = result.discussions_fetched,
|
||||||
@@ -273,58 +244,78 @@ pub fn print_sync(
|
|||||||
elapsed: std::time::Duration,
|
elapsed: std::time::Duration,
|
||||||
metrics: Option<&MetricsLayer>,
|
metrics: Option<&MetricsLayer>,
|
||||||
) {
|
) {
|
||||||
// Headline: what happened, how long
|
let has_data = result.issues_updated > 0
|
||||||
println!(
|
|| result.mrs_updated > 0
|
||||||
"\n {} {} issues and {} MRs in {:.1}s",
|
|| result.discussions_fetched > 0
|
||||||
Theme::success().bold().render("Synced"),
|
|| result.resource_events_fetched > 0
|
||||||
Theme::bold().render(&result.issues_updated.to_string()),
|
|| result.mr_diffs_fetched > 0
|
||||||
Theme::bold().render(&result.mrs_updated.to_string()),
|
|| result.documents_regenerated > 0
|
||||||
elapsed.as_secs_f64()
|
|| result.documents_embedded > 0
|
||||||
);
|
|| result.statuses_enriched > 0;
|
||||||
|
|
||||||
// Detail: supporting counts, compact middle-dot format, zero-suppressed
|
if !has_data {
|
||||||
let mut details: Vec<String> = Vec::new();
|
println!(
|
||||||
if result.discussions_fetched > 0 {
|
"\n {} ({:.1}s)\n",
|
||||||
details.push(format!("{} discussions", result.discussions_fetched));
|
Theme::dim().render("Already up to date"),
|
||||||
}
|
elapsed.as_secs_f64()
|
||||||
if result.resource_events_fetched > 0 {
|
);
|
||||||
details.push(format!("{} events", result.resource_events_fetched));
|
} else {
|
||||||
}
|
// Headline: what happened, how long
|
||||||
if result.mr_diffs_fetched > 0 {
|
println!(
|
||||||
details.push(format!("{} diffs", result.mr_diffs_fetched));
|
"\n {} {} issues and {} MRs in {:.1}s",
|
||||||
}
|
Theme::success().bold().render("Synced"),
|
||||||
if !details.is_empty() {
|
Theme::bold().render(&result.issues_updated.to_string()),
|
||||||
println!(" {}", Theme::dim().render(&details.join(" \u{b7} ")));
|
Theme::bold().render(&result.mrs_updated.to_string()),
|
||||||
}
|
elapsed.as_secs_f64()
|
||||||
|
);
|
||||||
|
|
||||||
// Documents: regeneration + embedding as a second detail line
|
// Detail: supporting counts, compact middle-dot format, zero-suppressed
|
||||||
let mut doc_parts: Vec<String> = Vec::new();
|
let mut details: Vec<String> = Vec::new();
|
||||||
if result.documents_regenerated > 0 {
|
if result.discussions_fetched > 0 {
|
||||||
doc_parts.push(format!("{} docs regenerated", result.documents_regenerated));
|
details.push(format!("{} discussions", result.discussions_fetched));
|
||||||
}
|
}
|
||||||
if result.documents_embedded > 0 {
|
if result.resource_events_fetched > 0 {
|
||||||
doc_parts.push(format!("{} embedded", result.documents_embedded));
|
details.push(format!("{} events", result.resource_events_fetched));
|
||||||
}
|
}
|
||||||
if !doc_parts.is_empty() {
|
if result.mr_diffs_fetched > 0 {
|
||||||
println!(" {}", Theme::dim().render(&doc_parts.join(" \u{b7} ")));
|
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} ")));
|
||||||
|
}
|
||||||
|
|
||||||
// Errors: visually prominent, only if non-zero
|
// Documents: regeneration + embedding as a second detail line
|
||||||
let mut errors: Vec<String> = Vec::new();
|
let mut doc_parts: Vec<String> = Vec::new();
|
||||||
if result.resource_events_failed > 0 {
|
if result.documents_regenerated > 0 {
|
||||||
errors.push(format!("{} event failures", result.resource_events_failed));
|
doc_parts.push(format!("{} docs regenerated", result.documents_regenerated));
|
||||||
}
|
}
|
||||||
if result.mr_diffs_failed > 0 {
|
if result.documents_embedded > 0 {
|
||||||
errors.push(format!("{} diff failures", result.mr_diffs_failed));
|
doc_parts.push(format!("{} embedded", result.documents_embedded));
|
||||||
}
|
}
|
||||||
if result.status_enrichment_errors > 0 {
|
if !doc_parts.is_empty() {
|
||||||
errors.push(format!("{} status errors", result.status_enrichment_errors));
|
println!(" {}", Theme::dim().render(&doc_parts.join(" \u{b7} ")));
|
||||||
}
|
}
|
||||||
if !errors.is_empty() {
|
|
||||||
println!(" {}", Theme::error().render(&errors.join(" \u{b7} ")));
|
|
||||||
}
|
|
||||||
|
|
||||||
println!();
|
// Errors: visually prominent, only if non-zero
|
||||||
|
let mut errors: Vec<String> = Vec::new();
|
||||||
|
if result.resource_events_failed > 0 {
|
||||||
|
errors.push(format!("{} event failures", result.resource_events_failed));
|
||||||
|
}
|
||||||
|
if result.mr_diffs_failed > 0 {
|
||||||
|
errors.push(format!("{} diff failures", result.mr_diffs_failed));
|
||||||
|
}
|
||||||
|
if result.status_enrichment_errors > 0 {
|
||||||
|
errors.push(format!("{} status errors", result.status_enrichment_errors));
|
||||||
|
}
|
||||||
|
if !errors.is_empty() {
|
||||||
|
println!(" {}", Theme::error().render(&errors.join(" \u{b7} ")));
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(metrics) = metrics {
|
if let Some(metrics) = metrics {
|
||||||
let stages = metrics.extract_timings();
|
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) {
|
fn section(title: &str) {
|
||||||
println!("{}", render::section_divider(title));
|
println!("{}", render::section_divider(title));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use crate::cli::render::{self, Theme};
|
use crate::cli::render::{self, Icons, Theme};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::Config;
|
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::db::create_connection;
|
||||||
use crate::core::error::{LoreError, Result};
|
use crate::core::error::{LoreError, Result};
|
||||||
use crate::core::paths::get_db_path;
|
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 {
|
let seed_result = match parsed_query {
|
||||||
TimelineQuery::EntityDirect { entity_type, iid } => {
|
TimelineQuery::EntityDirect { entity_type, iid } => {
|
||||||
// Direct seeding: synchronous, no Ollama needed
|
// 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)?;
|
let result = seed_timeline_direct(&conn, &entity_type, iid, project_id)?;
|
||||||
spinner.finish_and_clear();
|
spinner.finish_and_clear();
|
||||||
result
|
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)
|
// 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(
|
let result = seed_timeline(
|
||||||
&conn,
|
&conn,
|
||||||
Some(&client),
|
Some(&client),
|
||||||
@@ -128,7 +138,12 @@ pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result<Ti
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Stage 3: EXPAND
|
// 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(
|
let expand_result = expand_timeline(
|
||||||
&conn,
|
&conn,
|
||||||
&seed_result.seed_entities,
|
&seed_result.seed_entities,
|
||||||
@@ -139,7 +154,12 @@ pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result<Ti
|
|||||||
spinner.finish_and_clear();
|
spinner.finish_and_clear();
|
||||||
|
|
||||||
// Stage 4: COLLECT
|
// 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(
|
let (events, total_before_limit) = collect_events(
|
||||||
&conn,
|
&conn,
|
||||||
&seed_result.seed_entities,
|
&seed_result.seed_entities,
|
||||||
@@ -202,6 +222,11 @@ pub fn print_timeline(result: &TimelineResult) {
|
|||||||
fn print_timeline_event(event: &TimelineEvent) {
|
fn print_timeline_event(event: &TimelineEvent) {
|
||||||
let date = render::format_date(event.timestamp);
|
let date = render::format_date(event.timestamp);
|
||||||
let tag = format_event_tag(&event.event_type);
|
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 entity_ref = format_entity_ref(&event.entity_type, event.entity_iid);
|
||||||
let actor = event
|
let actor = event
|
||||||
.actor
|
.actor
|
||||||
@@ -211,8 +236,7 @@ fn print_timeline_event(event: &TimelineEvent) {
|
|||||||
let expanded_marker = if event.is_seed { "" } else { " [expanded]" };
|
let expanded_marker = if event.is_seed { "" } else { " [expanded]" };
|
||||||
|
|
||||||
let summary = render::truncate(&event.summary, 50);
|
let summary = render::truncate(&event.summary, 50);
|
||||||
let tag_padded = format!("{:<12}", tag);
|
println!("{date} {tag} {entity_icon}{entity_ref:7} {summary:50} {actor}{expanded_marker}");
|
||||||
println!("{date} {tag_padded} {entity_ref:7} {summary:50} {actor}{expanded_marker}");
|
|
||||||
|
|
||||||
// Show snippet for evidence notes
|
// Show snippet for evidence notes
|
||||||
if let TimelineEventType::NoteEvidence { snippet, .. } = &event.event_type
|
if let TimelineEventType::NoteEvidence { snippet, .. } = &event.event_type
|
||||||
@@ -276,23 +300,33 @@ fn print_timeline_footer(result: &TimelineResult) {
|
|||||||
println!();
|
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 {
|
fn format_event_tag(event_type: &TimelineEventType) -> String {
|
||||||
match event_type {
|
let (label, style) = match event_type {
|
||||||
TimelineEventType::Created => Theme::success().render("CREATED"),
|
TimelineEventType::Created => ("CREATED", Theme::success()),
|
||||||
TimelineEventType::StateChanged { state } => match state.as_str() {
|
TimelineEventType::StateChanged { state } => match state.as_str() {
|
||||||
"closed" => Theme::error().render("CLOSED"),
|
"closed" => ("CLOSED", Theme::error()),
|
||||||
"reopened" => Theme::warning().render("REOPENED"),
|
"reopened" => ("REOPENED", Theme::warning()),
|
||||||
_ => Theme::dim().render(&state.to_uppercase()),
|
_ => return style_padded(&state.to_uppercase(), TAG_WIDTH, Theme::dim()),
|
||||||
},
|
},
|
||||||
TimelineEventType::LabelAdded { .. } => Theme::info().render("LABEL+"),
|
TimelineEventType::LabelAdded { .. } => ("LABEL+", Theme::info()),
|
||||||
TimelineEventType::LabelRemoved { .. } => Theme::info().render("LABEL-"),
|
TimelineEventType::LabelRemoved { .. } => ("LABEL-", Theme::info()),
|
||||||
TimelineEventType::MilestoneSet { .. } => Theme::accent().render("MILESTONE+"),
|
TimelineEventType::MilestoneSet { .. } => ("MILESTONE+", Theme::accent()),
|
||||||
TimelineEventType::MilestoneRemoved { .. } => Theme::accent().render("MILESTONE-"),
|
TimelineEventType::MilestoneRemoved { .. } => ("MILESTONE-", Theme::accent()),
|
||||||
TimelineEventType::Merged => Theme::info().render("MERGED"),
|
TimelineEventType::Merged => ("MERGED", Theme::info()),
|
||||||
TimelineEventType::NoteEvidence { .. } => Theme::dim().render("NOTE"),
|
TimelineEventType::NoteEvidence { .. } => ("NOTE", Theme::dim()),
|
||||||
TimelineEventType::DiscussionThread { .. } => Theme::warning().render("THREAD"),
|
TimelineEventType::DiscussionThread { .. } => ("THREAD", Theme::warning()),
|
||||||
TimelineEventType::CrossReferenced { .. } => Theme::dim().render("REF"),
|
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 {
|
fn format_entity_ref(entity_type: &str, iid: i64) -> String {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::cli::render::{self, Theme};
|
use crate::cli::render::{self, Icons, Theme};
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
@@ -1951,7 +1951,7 @@ fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) {
|
|||||||
};
|
};
|
||||||
println!(
|
println!(
|
||||||
" {:<16} {:>6} {:>12} {:>6} {:>12} {:<12}{}{}",
|
" {:<16} {:>6} {:>12} {:>6} {:>12} {:<12}{}{}",
|
||||||
Theme::info().render(&format!("@{}", expert.username)),
|
Theme::info().render(&format!("{} {}", Icons::user(), expert.username)),
|
||||||
expert.score,
|
expert.score,
|
||||||
reviews,
|
reviews,
|
||||||
notes,
|
notes,
|
||||||
@@ -2004,16 +2004,18 @@ fn print_workload_human(r: &WorkloadResult) {
|
|||||||
println!();
|
println!();
|
||||||
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));
|
println!("{}", "\u{2500}".repeat(60));
|
||||||
|
|
||||||
if !r.assigned_issues.is_empty() {
|
if !r.assigned_issues.is_empty() {
|
||||||
println!();
|
|
||||||
println!(
|
println!(
|
||||||
" {} ({})",
|
"{}",
|
||||||
Theme::bold().render("Assigned Issues"),
|
render::section_divider(&format!("Assigned Issues ({})", r.assigned_issues.len()))
|
||||||
r.assigned_issues.len()
|
|
||||||
);
|
);
|
||||||
for item in &r.assigned_issues {
|
for item in &r.assigned_issues {
|
||||||
println!(
|
println!(
|
||||||
@@ -2032,11 +2034,9 @@ fn print_workload_human(r: &WorkloadResult) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !r.authored_mrs.is_empty() {
|
if !r.authored_mrs.is_empty() {
|
||||||
println!();
|
|
||||||
println!(
|
println!(
|
||||||
" {} ({})",
|
"{}",
|
||||||
Theme::bold().render("Authored MRs"),
|
render::section_divider(&format!("Authored MRs ({})", r.authored_mrs.len()))
|
||||||
r.authored_mrs.len()
|
|
||||||
);
|
);
|
||||||
for mr in &r.authored_mrs {
|
for mr in &r.authored_mrs {
|
||||||
let draft = if mr.draft { " [draft]" } else { "" };
|
let draft = if mr.draft { " [draft]" } else { "" };
|
||||||
@@ -2057,11 +2057,9 @@ fn print_workload_human(r: &WorkloadResult) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !r.reviewing_mrs.is_empty() {
|
if !r.reviewing_mrs.is_empty() {
|
||||||
println!();
|
|
||||||
println!(
|
println!(
|
||||||
" {} ({})",
|
"{}",
|
||||||
Theme::bold().render("Reviewing MRs"),
|
render::section_divider(&format!("Reviewing MRs ({})", r.reviewing_mrs.len()))
|
||||||
r.reviewing_mrs.len()
|
|
||||||
);
|
);
|
||||||
for mr in &r.reviewing_mrs {
|
for mr in &r.reviewing_mrs {
|
||||||
let author = mr
|
let author = mr
|
||||||
@@ -2086,11 +2084,12 @@ fn print_workload_human(r: &WorkloadResult) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !r.unresolved_discussions.is_empty() {
|
if !r.unresolved_discussions.is_empty() {
|
||||||
println!();
|
|
||||||
println!(
|
println!(
|
||||||
" {} ({})",
|
"{}",
|
||||||
Theme::bold().render("Unresolved Discussions"),
|
render::section_divider(&format!(
|
||||||
r.unresolved_discussions.len()
|
"Unresolved Discussions ({})",
|
||||||
|
r.unresolved_discussions.len()
|
||||||
|
))
|
||||||
);
|
);
|
||||||
for disc in &r.unresolved_discussions {
|
for disc in &r.unresolved_discussions {
|
||||||
println!(
|
println!(
|
||||||
@@ -2128,7 +2127,11 @@ fn print_reviews_human(r: &ReviewsResult) {
|
|||||||
println!();
|
println!();
|
||||||
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!("{}", "\u{2500}".repeat(60));
|
||||||
println!();
|
println!();
|
||||||
@@ -2289,7 +2292,7 @@ fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
|
|||||||
|
|
||||||
println!(
|
println!(
|
||||||
" {:<16} {:<6} {:>7} {:<12} {}{}",
|
" {:<16} {:<6} {:>7} {:<12} {}{}",
|
||||||
Theme::info().render(&format!("@{}", user.username)),
|
Theme::info().render(&format!("{} {}", Icons::user(), user.username)),
|
||||||
format_overlap_role(user),
|
format_overlap_role(user),
|
||||||
user.touch_count,
|
user.touch_count,
|
||||||
render::format_relative_time(user.last_seen_at),
|
render::format_relative_time(user.last_seen_at),
|
||||||
|
|||||||
@@ -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")]
|
#[arg(long, global = true, value_parser = ["auto", "always", "never"], default_value = "auto", help = "Color output: auto (default), always, or never")]
|
||||||
pub color: String,
|
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
|
/// Suppress non-essential output
|
||||||
#[arg(
|
#[arg(
|
||||||
short = 'q',
|
short = 'q',
|
||||||
|
|||||||
@@ -1,34 +1,82 @@
|
|||||||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
use std::time::Duration;
|
||||||
use tracing_subscriber::fmt::MakeWriter;
|
use tracing_subscriber::fmt::MakeWriter;
|
||||||
|
|
||||||
|
use crate::cli::render::Icons;
|
||||||
|
|
||||||
static MULTI: LazyLock<MultiProgress> = LazyLock::new(MultiProgress::new);
|
static MULTI: LazyLock<MultiProgress> = LazyLock::new(MultiProgress::new);
|
||||||
|
|
||||||
pub fn multi() -> &'static MultiProgress {
|
pub fn multi() -> &'static MultiProgress {
|
||||||
&MULTI
|
&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
|
/// Template: `{spinner:.cyan} {prefix} {wide_msg} {elapsed_style:.dim}`
|
||||||
/// the same code path regardless of output mode.
|
pub fn stage_spinner_v2(icon: &str, label: &str, msg: &str, robot_mode: bool) -> ProgressBar {
|
||||||
pub fn stage_spinner(stage: u8, total: u8, msg: &str, robot_mode: bool) -> ProgressBar {
|
|
||||||
if robot_mode {
|
if robot_mode {
|
||||||
return ProgressBar::hidden();
|
return ProgressBar::hidden();
|
||||||
}
|
}
|
||||||
let pb = multi().add(ProgressBar::new_spinner());
|
let pb = multi().add(ProgressBar::new_spinner());
|
||||||
pb.set_style(
|
pb.set_style(
|
||||||
ProgressStyle::default_spinner()
|
ProgressStyle::default_spinner()
|
||||||
.template("{spinner:.blue} {prefix} {msg}")
|
.template(" {spinner:.cyan} {prefix} {wide_msg}")
|
||||||
.expect("valid template"),
|
.expect("valid template"),
|
||||||
);
|
);
|
||||||
pb.enable_steady_tick(std::time::Duration::from_millis(80));
|
pb.enable_steady_tick(Duration::from_millis(60));
|
||||||
pb.set_prefix(format!("[{stage}/{total}]"));
|
pb.set_prefix(format!("{icon} {label}"));
|
||||||
pb.set_message(msg.to_string());
|
pb.set_message(msg.to_string());
|
||||||
pb
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct SuspendingWriter;
|
pub struct SuspendingWriter;
|
||||||
|
|
||||||
@@ -108,34 +156,51 @@ mod tests {
|
|||||||
drop(w);
|
drop(w);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Progress API tests ──
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stage_spinner_robot_mode_returns_hidden() {
|
fn stage_spinner_v2_robot_mode_returns_hidden() {
|
||||||
let pb = stage_spinner(1, 3, "Testing...", true);
|
let pb = stage_spinner_v2("\u{2714}", "Issues", "fetching...", true);
|
||||||
assert!(pb.is_hidden());
|
assert!(pb.is_hidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stage_spinner_human_mode_sets_properties() {
|
fn stage_spinner_v2_human_mode_sets_properties() {
|
||||||
// In non-TTY test environments, MultiProgress may report bars as
|
let pb = stage_spinner_v2("\u{2714}", "Issues", "fetching...", false);
|
||||||
// hidden. Verify the human-mode code path by checking that prefix
|
assert!(pb.prefix().contains("Issues"));
|
||||||
// and message are configured (robot-mode returns a bare hidden bar).
|
assert_eq!(pb.message(), "fetching...");
|
||||||
let pb = stage_spinner(1, 3, "Testing...", false);
|
|
||||||
assert_eq!(pb.prefix(), "[1/3]");
|
|
||||||
assert_eq!(pb.message(), "Testing...");
|
|
||||||
pb.finish_and_clear();
|
pb.finish_and_clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stage_spinner_sets_prefix_format() {
|
fn nested_progress_robot_mode_returns_hidden() {
|
||||||
let pb = stage_spinner(2, 5, "Working...", false);
|
let pb = nested_progress("Embedding...", 100, true);
|
||||||
assert_eq!(pb.prefix(), "[2/5]");
|
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();
|
pb.finish_and_clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stage_spinner_sets_message() {
|
fn format_elapsed_sub_second() {
|
||||||
let pb = stage_spinner(1, 3, "Seeding timeline...", false);
|
assert_eq!(format_elapsed(Duration::from_millis(42)), "42ms");
|
||||||
assert_eq!(pb.message(), "Seeding timeline...");
|
assert_eq!(format_elapsed(Duration::from_millis(999)), "999ms");
|
||||||
pb.finish_and_clear();
|
}
|
||||||
|
|
||||||
|
#[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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,17 +16,235 @@ pub enum ColorMode {
|
|||||||
Never,
|
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`.
|
/// Global renderer singleton, initialized once in `main.rs`.
|
||||||
static RENDERER: OnceLock<LoreRenderer> = OnceLock::new();
|
static RENDERER: OnceLock<LoreRenderer> = OnceLock::new();
|
||||||
|
|
||||||
pub struct LoreRenderer {
|
pub struct LoreRenderer {
|
||||||
/// Resolved at init time so we don't re-check TTY + NO_COLOR on every call.
|
/// Resolved at init time so we don't re-check TTY + NO_COLOR on every call.
|
||||||
colors: bool,
|
colors: bool,
|
||||||
|
/// Icon tier for the session.
|
||||||
|
glyphs: GlyphMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LoreRenderer {
|
impl LoreRenderer {
|
||||||
/// Initialize the global renderer. Call once at startup.
|
/// Initialize the global renderer. Call once at startup.
|
||||||
pub fn init(mode: ColorMode) {
|
pub fn init(mode: ColorMode, glyphs: GlyphMode) {
|
||||||
let colors = match mode {
|
let colors = match mode {
|
||||||
ColorMode::Always => true,
|
ColorMode::Always => true,
|
||||||
ColorMode::Never => false,
|
ColorMode::Never => false,
|
||||||
@@ -35,7 +253,7 @@ impl LoreRenderer {
|
|||||||
&& std::env::var("NO_COLOR").map_or(true, |v| v.is_empty())
|
&& 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.
|
/// Get the global renderer. Panics if `init` hasn't been called.
|
||||||
@@ -49,6 +267,11 @@ impl LoreRenderer {
|
|||||||
pub fn colors_enabled(&self) -> bool {
|
pub fn colors_enabled(&self) -> bool {
|
||||||
self.colors
|
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
|
/// 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
|
// Structure
|
||||||
pub fn section_title() -> Style {
|
pub fn section_title() -> Style {
|
||||||
if colors_on() {
|
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.
|
/// Format a duration in milliseconds as a human-friendly string.
|
||||||
pub fn format_duration_ms(ms: u64) -> String {
|
pub fn format_duration_ms(ms: u64) -> String {
|
||||||
if ms < 1000 {
|
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 ──────────────────────────────────────────────────────────
|
// ─── Table Renderer ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Column alignment for the table renderer.
|
/// Column alignment for the table renderer.
|
||||||
@@ -946,6 +1240,135 @@ mod tests {
|
|||||||
assert!(plain.contains("1"), "got: {plain}");
|
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.
|
/// Strip ANSI escape codes (SGR sequences) for content assertions.
|
||||||
fn strip_ansi(s: &str) -> String {
|
fn strip_ansi(s: &str) -> String {
|
||||||
let mut out = String::with_capacity(s.len());
|
let mut out = String::with_capacity(s.len());
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ pub fn build_stderr_filter(verbose: u8, quiet: bool) -> EnvFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let directives = match verbose {
|
let directives = match verbose {
|
||||||
0 => "lore=info,warn",
|
0 => "lore=warn",
|
||||||
1 => "lore=debug,warn",
|
1 => "lore=info,warn",
|
||||||
2 => "lore=debug,info",
|
2 => "lore=debug,info",
|
||||||
_ => "lore=trace,debug",
|
_ => "lore=trace,debug",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -126,14 +126,21 @@ fn parse_retry_after(response: &reqwest::Response) -> u64 {
|
|||||||
None => return 60,
|
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>() {
|
if let Ok(secs) = header.parse::<u64>() {
|
||||||
return secs.max(1);
|
return secs.max(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(date) = httpdate::parse_http_date(header)
|
if let Ok(date) = httpdate::parse_http_date(header) {
|
||||||
&& let Ok(delta) = date.duration_since(SystemTime::now())
|
return match date.duration_since(now) {
|
||||||
{
|
Ok(delta) => delta.as_secs().max(1),
|
||||||
return delta.as_secs().max(1);
|
Err(_) => 1,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
60
|
60
|
||||||
|
|||||||
@@ -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]
|
#[tokio::test]
|
||||||
async fn test_graphql_network_error() {
|
async fn test_graphql_network_error() {
|
||||||
let client = GraphqlClient::new("http://127.0.0.1:1", "token");
|
let client = GraphqlClient::new("http://127.0.0.1:1", "token");
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::ops::Deref;
|
|||||||
|
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use rusqlite::{Connection, Transaction};
|
use rusqlite::{Connection, Transaction};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
use crate::Config;
|
use crate::Config;
|
||||||
use crate::core::error::{LoreError, Result};
|
use crate::core::error::{LoreError, Result};
|
||||||
@@ -61,7 +61,7 @@ pub async fn ingest_issues(
|
|||||||
|
|
||||||
while let Some(issue_result) = issues_stream.next().await {
|
while let Some(issue_result) = issues_stream.next().await {
|
||||||
if signal.is_cancelled() {
|
if signal.is_cancelled() {
|
||||||
info!("Issue ingestion interrupted by shutdown signal");
|
debug!("Issue ingestion interrupted by shutdown signal");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let issue = issue_result?;
|
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)?;
|
result.issues_needing_discussion_sync = get_issues_needing_discussion_sync(conn, project_id)?;
|
||||||
|
|
||||||
info!(
|
debug!(
|
||||||
summary = crate::ingestion::nonzero_summary(&[
|
summary = crate::ingestion::nonzero_summary(&[
|
||||||
("fetched", result.fetched),
|
("fetched", result.fetched),
|
||||||
("upserted", result.upserted),
|
("upserted", result.upserted),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::ops::Deref;
|
use std::ops::Deref;
|
||||||
|
|
||||||
use rusqlite::{Connection, Transaction, params};
|
use rusqlite::{Connection, Transaction, params};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
use crate::Config;
|
use crate::Config;
|
||||||
use crate::core::error::{LoreError, Result};
|
use crate::core::error::{LoreError, Result};
|
||||||
@@ -61,7 +61,7 @@ pub async fn ingest_merge_requests(
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
if signal.is_cancelled() {
|
if signal.is_cancelled() {
|
||||||
info!("MR ingestion interrupted by shutdown signal");
|
debug!("MR ingestion interrupted by shutdown signal");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let page_result = client
|
let page_result = client
|
||||||
@@ -121,7 +121,7 @@ pub async fn ingest_merge_requests(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(
|
debug!(
|
||||||
summary = crate::ingestion::nonzero_summary(&[
|
summary = crate::ingestion::nonzero_summary(&[
|
||||||
("fetched", result.fetched),
|
("fetched", result.fetched),
|
||||||
("upserted", result.upserted),
|
("upserted", result.upserted),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use futures::future::join_all;
|
use futures::future::join_all;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use tracing::{debug, info, instrument, warn};
|
use tracing::{debug, instrument, warn};
|
||||||
|
|
||||||
use crate::Config;
|
use crate::Config;
|
||||||
use crate::core::dependent_queue::{
|
use crate::core::dependent_queue::{
|
||||||
@@ -45,7 +45,7 @@ pub enum ProgressEvent {
|
|||||||
MrDiffsFetchStarted { total: usize },
|
MrDiffsFetchStarted { total: usize },
|
||||||
MrDiffFetched { current: usize, total: usize },
|
MrDiffFetched { current: usize, total: usize },
|
||||||
MrDiffsFetchComplete { fetched: usize, failed: usize },
|
MrDiffsFetchComplete { fetched: usize, failed: usize },
|
||||||
StatusEnrichmentStarted,
|
StatusEnrichmentStarted { total: usize },
|
||||||
StatusEnrichmentPageFetched { items_so_far: usize },
|
StatusEnrichmentPageFetched { items_so_far: usize },
|
||||||
StatusEnrichmentWriting { total: usize },
|
StatusEnrichmentWriting { total: usize },
|
||||||
StatusEnrichmentComplete { enriched: usize, cleared: 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() {
|
if config.sync.fetch_work_item_status && !signal.is_cancelled() {
|
||||||
use rusqlite::OptionalExtension;
|
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
|
let project_path: Option<String> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -225,9 +234,10 @@ pub async fn ingest_project_issues_with_progress(
|
|||||||
Ok((enriched, cleared)) => {
|
Ok((enriched, cleared)) => {
|
||||||
result.statuses_enriched = enriched;
|
result.statuses_enriched = enriched;
|
||||||
result.statuses_cleared = cleared;
|
result.statuses_cleared = cleared;
|
||||||
result.statuses_without_widget =
|
result.statuses_without_widget = result
|
||||||
result.statuses_seen.saturating_sub(enriched);
|
.statuses_seen
|
||||||
info!(
|
.saturating_sub(fetch_result.statuses.len());
|
||||||
|
debug!(
|
||||||
seen = result.statuses_seen,
|
seen = result.statuses_seen,
|
||||||
enriched,
|
enriched,
|
||||||
cleared,
|
cleared,
|
||||||
@@ -282,7 +292,7 @@ pub async fn ingest_project_issues_with_progress(
|
|||||||
if issues_needing_sync.is_empty() {
|
if issues_needing_sync.is_empty() {
|
||||||
debug!("No issues need discussion sync");
|
debug!("No issues need discussion sync");
|
||||||
} else {
|
} else {
|
||||||
info!(
|
debug!(
|
||||||
count = issues_needing_sync.len(),
|
count = issues_needing_sync.len(),
|
||||||
"Starting discussion sync for issues"
|
"Starting discussion sync for issues"
|
||||||
);
|
);
|
||||||
@@ -347,7 +357,7 @@ pub async fn ingest_project_issues_with_progress(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(
|
debug!(
|
||||||
summary = crate::ingestion::nonzero_summary(&[
|
summary = crate::ingestion::nonzero_summary(&[
|
||||||
("fetched", result.issues_fetched),
|
("fetched", result.issues_fetched),
|
||||||
("upserted", result.issues_upserted),
|
("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(
|
let mut update_stmt = tx.prepare_cached(
|
||||||
"UPDATE issues SET status_name = ?1, status_category = ?2, status_color = ?3,
|
"UPDATE issues SET status_name = ?1, status_category = ?2, status_color = ?3,
|
||||||
status_icon_name = ?4, status_synced_at = ?5
|
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 {
|
for (iid, status) in statuses {
|
||||||
let rows = update_stmt.execute(rusqlite::params![
|
let rows = update_stmt.execute(rusqlite::params![
|
||||||
@@ -423,6 +435,14 @@ fn enrich_issue_statuses_txn(
|
|||||||
enriched += 1;
|
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()?;
|
tx.commit()?;
|
||||||
@@ -558,7 +578,7 @@ pub async fn ingest_project_merge_requests_with_progress(
|
|||||||
if mrs_needing_sync.is_empty() {
|
if mrs_needing_sync.is_empty() {
|
||||||
debug!("No MRs need discussion sync");
|
debug!("No MRs need discussion sync");
|
||||||
} else {
|
} else {
|
||||||
info!(
|
debug!(
|
||||||
count = mrs_needing_sync.len(),
|
count = mrs_needing_sync.len(),
|
||||||
"Starting discussion sync for MRs"
|
"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;
|
result.mr_diffs_failed = diffs_result.failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(
|
debug!(
|
||||||
summary = crate::ingestion::nonzero_summary(&[
|
summary = crate::ingestion::nonzero_summary(&[
|
||||||
("fetched", result.mrs_fetched),
|
("fetched", result.mrs_fetched),
|
||||||
("upserted", result.mrs_upserted),
|
("upserted", result.mrs_upserted),
|
||||||
@@ -923,7 +943,7 @@ async fn drain_resource_events(
|
|||||||
|
|
||||||
let reclaimed = reclaim_stale_locks(conn, config.sync.stale_lock_minutes)?;
|
let reclaimed = reclaim_stale_locks(conn, config.sync.stale_lock_minutes)?;
|
||||||
if reclaimed > 0 {
|
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)?;
|
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 {
|
if result.fetched > 0 || result.failed > 0 {
|
||||||
info!(
|
debug!(
|
||||||
fetched = result.fetched,
|
fetched = result.fetched,
|
||||||
failed = result.failed,
|
failed = result.failed,
|
||||||
"Resource events drain complete"
|
"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)?;
|
let reclaimed = reclaim_stale_locks(conn, config.sync.stale_lock_minutes)?;
|
||||||
if reclaimed > 0 {
|
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)?;
|
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 {
|
if result.fetched > 0 || result.failed > 0 {
|
||||||
info!(
|
debug!(
|
||||||
fetched = result.fetched,
|
fetched = result.fetched,
|
||||||
failed = result.failed,
|
failed = result.failed,
|
||||||
"mr_closes_issues drain complete"
|
"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)?;
|
let reclaimed = reclaim_stale_locks(conn, config.sync.stale_lock_minutes)?;
|
||||||
if reclaimed > 0 {
|
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)?;
|
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 {
|
if result.fetched > 0 || result.failed > 0 {
|
||||||
info!(
|
debug!(
|
||||||
fetched = result.fetched,
|
fetched = result.fetched,
|
||||||
failed = result.failed,
|
failed = result.failed,
|
||||||
"mr_diffs drain complete"
|
"mr_diffs drain complete"
|
||||||
|
|||||||
59
src/main.rs
59
src/main.rs
@@ -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_list_issues, run_list_mrs, run_search, run_show_issue, run_show_mr, run_stats, run_sync,
|
||||||
run_sync_status, run_timeline, run_who,
|
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::robot::{RobotMeta, strip_schemas};
|
||||||
use lore::cli::{
|
use lore::cli::{
|
||||||
Cli, Commands, CountArgs, EmbedArgs, GenerateDocsArgs, IngestArgs, IssuesArgs, MrsArgs,
|
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()) {
|
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);
|
console::set_colors_enabled(false);
|
||||||
} else {
|
} else {
|
||||||
match cli.color.as_str() {
|
match cli.color.as_str() {
|
||||||
"never" => {
|
"never" => {
|
||||||
LoreRenderer::init(ColorMode::Never);
|
LoreRenderer::init(ColorMode::Never, glyphs);
|
||||||
console::set_colors_enabled(false);
|
console::set_colors_enabled(false);
|
||||||
}
|
}
|
||||||
"always" => {
|
"always" => {
|
||||||
LoreRenderer::init(ColorMode::Always);
|
LoreRenderer::init(ColorMode::Always, glyphs);
|
||||||
console::set_colors_enabled(true);
|
console::set_colors_enabled(true);
|
||||||
}
|
}
|
||||||
"auto" => {
|
"auto" => {
|
||||||
LoreRenderer::init(ColorMode::Auto);
|
LoreRenderer::init(ColorMode::Auto, glyphs);
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
LoreRenderer::init(ColorMode::Auto);
|
LoreRenderer::init(ColorMode::Auto, glyphs);
|
||||||
eprintln!("Warning: unknown color mode '{}', using auto", other);
|
eprintln!("Warning: unknown color mode '{}', using auto", other);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -409,21 +411,28 @@ fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
|
|||||||
);
|
);
|
||||||
std::process::exit(gi_error.exit_code());
|
std::process::exit(gi_error.exit_code());
|
||||||
} else {
|
} 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() {
|
if let Some(suggestion) = gi_error.suggestion() {
|
||||||
eprintln!("{} {}", Theme::warning().render("Hint:"), suggestion);
|
eprintln!();
|
||||||
|
eprintln!(" {suggestion}");
|
||||||
}
|
}
|
||||||
let actions = gi_error.actions();
|
let actions = gi_error.actions();
|
||||||
if !actions.is_empty() {
|
if !actions.is_empty() {
|
||||||
eprintln!();
|
eprintln!();
|
||||||
for action in &actions {
|
for action in &actions {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" {} {}",
|
" {} {}",
|
||||||
Theme::dim().render("$"),
|
Theme::dim().render("\u{2192}"),
|
||||||
Theme::bold().render(action)
|
Theme::bold().render(action)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
eprintln!();
|
||||||
std::process::exit(gi_error.exit_code());
|
std::process::exit(gi_error.exit_code());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -443,7 +452,13 @@ fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} 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);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
@@ -1901,9 +1916,9 @@ async fn handle_search(
|
|||||||
limit: args.limit,
|
limit: args.limit,
|
||||||
};
|
};
|
||||||
|
|
||||||
let spinner = lore::cli::progress::stage_spinner(
|
let spinner = lore::cli::progress::stage_spinner_v2(
|
||||||
1,
|
lore::cli::render::Icons::search(),
|
||||||
1,
|
"Search",
|
||||||
&format!("Searching ({})...", args.mode),
|
&format!("Searching ({})...", args.mode),
|
||||||
robot_mode,
|
robot_mode,
|
||||||
);
|
);
|
||||||
@@ -1966,7 +1981,6 @@ async fn handle_embed(
|
|||||||
args: EmbedArgs,
|
args: EmbedArgs,
|
||||||
robot_mode: bool,
|
robot_mode: bool,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
use indicatif::{ProgressBar, ProgressStyle};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
@@ -1985,18 +1999,7 @@ async fn handle_embed(
|
|||||||
std::process::exit(130);
|
std::process::exit(130);
|
||||||
});
|
});
|
||||||
|
|
||||||
let embed_bar = if robot_mode {
|
let embed_bar = lore::cli::progress::nested_progress("Embedding", 0, 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 bar_clone = embed_bar.clone();
|
let bar_clone = embed_bar.clone();
|
||||||
let tick_started = Arc::new(AtomicBool::new(false));
|
let tick_started = Arc::new(AtomicBool::new(false));
|
||||||
let tick_clone = Arc::clone(&tick_started);
|
let tick_clone = Arc::clone(&tick_started);
|
||||||
|
|||||||
Reference in New Issue
Block a user