feat(sync): per-project breakdown, status enrichment progress bars, and summary polish
Add per-project detail rows beneath stage completion lines during multi-project syncs, showing itemized counts (issues/MRs, discussions, events, statuses, diffs) for each project. Previously, only aggregate totals were visible, making it hard to diagnose which project contributed what during a sync. Status enrichment gets proper progress bars replacing the old spinner-only display: StatusEnrichmentStarted now carries a total count so the CLI can render a determinate bar with rate and ETA. The enrichment SQL is tightened to use IS NOT comparisons for diff-only UPDATEs (skip rows where values haven't changed), and a follow-up touch_stmt ensures status_synced_at is updated even for unchanged rows so staleness detection works correctly. Other improvements: - New ProjectSummary struct aggregates per-project metrics during ingestion - SyncResult gains statuses_enriched + per-project summary vectors - "Already up to date" message when sync finds zero changes - Remove Arc<AtomicBool> tick_started pattern from docs/embed stages (enable_steady_tick is idempotent, the guard was unnecessary) - Progress bar styling: dim spinner, dark_gray track, per_sec + eta display - Tick intervals tightened from 100ms to 60ms for smoother animation - statuses_without_widget calculation uses fetch_result.statuses.len() instead of subtracting enriched (more accurate when some statuses lack work item widgets) - Status enrichment completion log downgraded from info to debug Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,8 @@
|
||||
use crate::cli::render::{self, Icons, Theme, format_number};
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Instant;
|
||||
use tracing::Instrument;
|
||||
use tracing::{info, warn};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::Config;
|
||||
use crate::cli::progress::{finish_stage, nested_progress, stage_spinner_v2};
|
||||
@@ -14,7 +12,7 @@ use crate::core::shutdown::ShutdownSignal;
|
||||
|
||||
use super::embed::run_embed;
|
||||
use super::generate_docs::run_generate_docs;
|
||||
use super::ingest::{DryRunPreview, IngestDisplay, run_ingest, run_ingest_dry_run};
|
||||
use super::ingest::{DryRunPreview, IngestDisplay, ProjectSummary, run_ingest, run_ingest_dry_run};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SyncOptions {
|
||||
@@ -41,6 +39,11 @@ pub struct SyncResult {
|
||||
pub documents_regenerated: usize,
|
||||
pub documents_embedded: usize,
|
||||
pub status_enrichment_errors: usize,
|
||||
pub statuses_enriched: usize,
|
||||
#[serde(skip)]
|
||||
pub issue_projects: Vec<ProjectSummary>,
|
||||
#[serde(skip)]
|
||||
pub mr_projects: Vec<ProjectSummary>,
|
||||
}
|
||||
|
||||
pub async fn run_sync(
|
||||
@@ -79,7 +82,7 @@ pub async fn run_sync(
|
||||
// ── Stage: Issues ──
|
||||
let stage_start = Instant::now();
|
||||
let spinner = stage_spinner_v2(Icons::sync(), "Issues", "fetching...", options.robot_mode);
|
||||
info!("Sync: ingesting issues");
|
||||
debug!("Sync: ingesting issues");
|
||||
let issues_result = run_ingest(
|
||||
config,
|
||||
"issues",
|
||||
@@ -97,6 +100,10 @@ pub async fn run_sync(
|
||||
result.resource_events_fetched += issues_result.resource_events_fetched;
|
||||
result.resource_events_failed += issues_result.resource_events_failed;
|
||||
result.status_enrichment_errors += issues_result.status_enrichment_errors;
|
||||
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),
|
||||
@@ -104,16 +111,19 @@ pub async fn run_sync(
|
||||
if issues_result.projects_synced == 1 { "project" } else { "projects" }
|
||||
);
|
||||
finish_stage(&spinner, Icons::success(), "Issues", &issues_summary, stage_start.elapsed());
|
||||
if !options.robot_mode {
|
||||
print_issue_sub_rows(&result.issue_projects);
|
||||
}
|
||||
|
||||
if signal.is_cancelled() {
|
||||
info!("Shutdown requested after issues stage, returning partial sync results");
|
||||
debug!("Shutdown requested after issues stage, returning partial sync results");
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── Stage: MRs ──
|
||||
let stage_start = Instant::now();
|
||||
let spinner = stage_spinner_v2(Icons::sync(), "MRs", "fetching...", options.robot_mode);
|
||||
info!("Sync: ingesting merge requests");
|
||||
debug!("Sync: ingesting merge requests");
|
||||
let mrs_result = run_ingest(
|
||||
config,
|
||||
"mrs",
|
||||
@@ -132,6 +142,7 @@ pub async fn run_sync(
|
||||
result.resource_events_failed += mrs_result.resource_events_failed;
|
||||
result.mr_diffs_fetched += mrs_result.mr_diffs_fetched;
|
||||
result.mr_diffs_failed += mrs_result.mr_diffs_failed;
|
||||
result.mr_projects = mrs_result.project_summaries;
|
||||
let mrs_summary = format!(
|
||||
"{} merge requests from {} {}",
|
||||
format_number(result.mrs_updated as i64),
|
||||
@@ -139,9 +150,12 @@ pub async fn run_sync(
|
||||
if mrs_result.projects_synced == 1 { "project" } else { "projects" }
|
||||
);
|
||||
finish_stage(&spinner, Icons::success(), "MRs", &mrs_summary, stage_start.elapsed());
|
||||
if !options.robot_mode {
|
||||
print_mr_sub_rows(&result.mr_projects);
|
||||
}
|
||||
|
||||
if signal.is_cancelled() {
|
||||
info!("Shutdown requested after MRs stage, returning partial sync results");
|
||||
debug!("Shutdown requested after MRs stage, returning partial sync results");
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
@@ -149,17 +163,12 @@ pub async fn run_sync(
|
||||
if !options.no_docs {
|
||||
let stage_start = Instant::now();
|
||||
let spinner = stage_spinner_v2(Icons::sync(), "Docs", "generating...", options.robot_mode);
|
||||
info!("Sync: generating documents");
|
||||
debug!("Sync: generating documents");
|
||||
|
||||
let docs_bar = nested_progress("Docs", 0, options.robot_mode);
|
||||
let docs_bar_clone = docs_bar.clone();
|
||||
let tick_started = Arc::new(AtomicBool::new(false));
|
||||
let tick_started_clone = Arc::clone(&tick_started);
|
||||
let docs_cb: Box<dyn Fn(usize, usize)> = Box::new(move |processed, total| {
|
||||
if total > 0 {
|
||||
if !tick_started_clone.swap(true, Ordering::Relaxed) {
|
||||
docs_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||
}
|
||||
docs_bar_clone.set_length(total as u64);
|
||||
docs_bar_clone.set_position(processed as u64);
|
||||
}
|
||||
@@ -173,24 +182,19 @@ pub async fn run_sync(
|
||||
);
|
||||
finish_stage(&spinner, Icons::success(), "Docs", &docs_summary, stage_start.elapsed());
|
||||
} else {
|
||||
info!("Sync: skipping document generation (--no-docs)");
|
||||
debug!("Sync: skipping document generation (--no-docs)");
|
||||
}
|
||||
|
||||
// ── Stage: Embed ──
|
||||
if !options.no_embed {
|
||||
let stage_start = Instant::now();
|
||||
let spinner = stage_spinner_v2(Icons::sync(), "Embed", "preparing...", options.robot_mode);
|
||||
info!("Sync: embedding documents");
|
||||
debug!("Sync: embedding documents");
|
||||
|
||||
let embed_bar = nested_progress("Embed", 0, options.robot_mode);
|
||||
let embed_bar_clone = embed_bar.clone();
|
||||
let tick_started = Arc::new(AtomicBool::new(false));
|
||||
let tick_started_clone = Arc::clone(&tick_started);
|
||||
let embed_cb: Box<dyn Fn(usize, usize)> = Box::new(move |processed, total| {
|
||||
if total > 0 {
|
||||
if !tick_started_clone.swap(true, Ordering::Relaxed) {
|
||||
embed_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||
}
|
||||
embed_bar_clone.set_length(total as u64);
|
||||
embed_bar_clone.set_position(processed as u64);
|
||||
}
|
||||
@@ -213,10 +217,10 @@ pub async fn run_sync(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!("Sync: skipping embedding (--no-embed)");
|
||||
debug!("Sync: skipping embedding (--no-embed)");
|
||||
}
|
||||
|
||||
info!(
|
||||
debug!(
|
||||
issues = result.issues_updated,
|
||||
mrs = result.mrs_updated,
|
||||
discussions = result.discussions_fetched,
|
||||
@@ -240,58 +244,78 @@ pub fn print_sync(
|
||||
elapsed: std::time::Duration,
|
||||
metrics: Option<&MetricsLayer>,
|
||||
) {
|
||||
// Headline: what happened, how long
|
||||
println!(
|
||||
"\n {} {} issues and {} MRs in {:.1}s",
|
||||
Theme::success().bold().render("Synced"),
|
||||
Theme::bold().render(&result.issues_updated.to_string()),
|
||||
Theme::bold().render(&result.mrs_updated.to_string()),
|
||||
elapsed.as_secs_f64()
|
||||
);
|
||||
let has_data = result.issues_updated > 0
|
||||
|| result.mrs_updated > 0
|
||||
|| result.discussions_fetched > 0
|
||||
|| result.resource_events_fetched > 0
|
||||
|| result.mr_diffs_fetched > 0
|
||||
|| result.documents_regenerated > 0
|
||||
|| result.documents_embedded > 0
|
||||
|| result.statuses_enriched > 0;
|
||||
|
||||
// Detail: supporting counts, compact middle-dot format, zero-suppressed
|
||||
let mut details: Vec<String> = Vec::new();
|
||||
if result.discussions_fetched > 0 {
|
||||
details.push(format!("{} discussions", result.discussions_fetched));
|
||||
}
|
||||
if result.resource_events_fetched > 0 {
|
||||
details.push(format!("{} events", result.resource_events_fetched));
|
||||
}
|
||||
if result.mr_diffs_fetched > 0 {
|
||||
details.push(format!("{} diffs", result.mr_diffs_fetched));
|
||||
}
|
||||
if !details.is_empty() {
|
||||
println!(" {}", Theme::dim().render(&details.join(" \u{b7} ")));
|
||||
}
|
||||
if !has_data {
|
||||
println!(
|
||||
"\n {} ({:.1}s)\n",
|
||||
Theme::dim().render("Already up to date"),
|
||||
elapsed.as_secs_f64()
|
||||
);
|
||||
} else {
|
||||
// Headline: what happened, how long
|
||||
println!(
|
||||
"\n {} {} issues and {} MRs in {:.1}s",
|
||||
Theme::success().bold().render("Synced"),
|
||||
Theme::bold().render(&result.issues_updated.to_string()),
|
||||
Theme::bold().render(&result.mrs_updated.to_string()),
|
||||
elapsed.as_secs_f64()
|
||||
);
|
||||
|
||||
// Documents: regeneration + embedding as a second detail line
|
||||
let mut doc_parts: Vec<String> = Vec::new();
|
||||
if result.documents_regenerated > 0 {
|
||||
doc_parts.push(format!("{} docs regenerated", result.documents_regenerated));
|
||||
}
|
||||
if result.documents_embedded > 0 {
|
||||
doc_parts.push(format!("{} embedded", result.documents_embedded));
|
||||
}
|
||||
if !doc_parts.is_empty() {
|
||||
println!(" {}", Theme::dim().render(&doc_parts.join(" \u{b7} ")));
|
||||
}
|
||||
// Detail: supporting counts, compact middle-dot format, zero-suppressed
|
||||
let mut details: Vec<String> = Vec::new();
|
||||
if result.discussions_fetched > 0 {
|
||||
details.push(format!("{} discussions", result.discussions_fetched));
|
||||
}
|
||||
if result.resource_events_fetched > 0 {
|
||||
details.push(format!("{} events", result.resource_events_fetched));
|
||||
}
|
||||
if result.mr_diffs_fetched > 0 {
|
||||
details.push(format!("{} diffs", result.mr_diffs_fetched));
|
||||
}
|
||||
if result.statuses_enriched > 0 {
|
||||
details.push(format!("{} statuses updated", result.statuses_enriched));
|
||||
}
|
||||
if !details.is_empty() {
|
||||
println!(" {}", Theme::dim().render(&details.join(" \u{b7} ")));
|
||||
}
|
||||
|
||||
// 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} ")));
|
||||
}
|
||||
// Documents: regeneration + embedding as a second detail line
|
||||
let mut doc_parts: Vec<String> = Vec::new();
|
||||
if result.documents_regenerated > 0 {
|
||||
doc_parts.push(format!("{} docs regenerated", result.documents_regenerated));
|
||||
}
|
||||
if result.documents_embedded > 0 {
|
||||
doc_parts.push(format!("{} embedded", result.documents_embedded));
|
||||
}
|
||||
if !doc_parts.is_empty() {
|
||||
println!(" {}", Theme::dim().render(&doc_parts.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 {
|
||||
let stages = metrics.extract_timings();
|
||||
@@ -301,6 +325,66 @@ pub fn print_sync(
|
||||
}
|
||||
}
|
||||
|
||||
fn print_issue_sub_rows(projects: &[ProjectSummary]) {
|
||||
if projects.len() <= 1 {
|
||||
return;
|
||||
}
|
||||
for p in projects {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
parts.push(format!(
|
||||
"{} {}",
|
||||
p.items_upserted,
|
||||
if p.items_upserted == 1 {
|
||||
"issue"
|
||||
} else {
|
||||
"issues"
|
||||
}
|
||||
));
|
||||
if p.discussions_synced > 0 {
|
||||
parts.push(format!("{} discussions", p.discussions_synced));
|
||||
}
|
||||
if p.statuses_enriched > 0 {
|
||||
parts.push(format!("{} statuses updated", p.statuses_enriched));
|
||||
}
|
||||
if p.events_fetched > 0 {
|
||||
parts.push(format!("{} events", p.events_fetched));
|
||||
}
|
||||
let detail = parts.join(" \u{b7} ");
|
||||
let _ = crate::cli::progress::multi().println(format!(
|
||||
" {}",
|
||||
Theme::dim().render(&format!("{:<30} {}", p.path, detail))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn print_mr_sub_rows(projects: &[ProjectSummary]) {
|
||||
if projects.len() <= 1 {
|
||||
return;
|
||||
}
|
||||
for p in projects {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
parts.push(format!(
|
||||
"{} {}",
|
||||
p.items_upserted,
|
||||
if p.items_upserted == 1 { "MR" } else { "MRs" }
|
||||
));
|
||||
if p.discussions_synced > 0 {
|
||||
parts.push(format!("{} discussions", p.discussions_synced));
|
||||
}
|
||||
if p.mr_diffs_fetched > 0 {
|
||||
parts.push(format!("{} diffs", p.mr_diffs_fetched));
|
||||
}
|
||||
if p.events_fetched > 0 {
|
||||
parts.push(format!("{} events", p.events_fetched));
|
||||
}
|
||||
let detail = parts.join(" \u{b7} ");
|
||||
let _ = crate::cli::progress::multi().println(format!(
|
||||
" {}",
|
||||
Theme::dim().render(&format!("{:<30} {}", p.path, detail))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn section(title: &str) {
|
||||
println!("{}", render::section_divider(title));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user