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