From bb6660178ceec33de692db2c87cdfe2fd5a63d2f Mon Sep 17 00:00:00 2001 From: teernisse Date: Sat, 14 Feb 2026 11:25:19 -0500 Subject: [PATCH] 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 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 --- src/cli/commands/ingest.rs | 88 +++++++++---- src/cli/commands/sync.rs | 226 +++++++++++++++++++++++----------- src/ingestion/orchestrator.rs | 56 ++++++--- 3 files changed, 257 insertions(+), 113 deletions(-) diff --git a/src/cli/commands/ingest.rs b/src/cli/commands/ingest.rs index 24468ed..442382b 100644 --- a/src/cli/commands/ingest.rs +++ b/src/cli/commands/ingest.rs @@ -46,6 +46,21 @@ pub struct IngestResult { pub mr_diffs_failed: usize, pub status_enrichment_errors: usize, pub status_enrichment_projects: Vec, + pub project_summaries: Vec, +} + +/// Per-project summary for display in stage completion sub-rows. +#[derive(Debug, Default)] +pub struct ProjectSummary { + pub path: String, + pub items_upserted: usize, + pub discussions_synced: usize, + pub events_fetched: usize, + pub events_failed: usize, + pub statuses_enriched: usize, + pub statuses_seen: usize, + pub mr_diffs_fetched: usize, + pub mr_diffs_failed: usize, } /// Per-project status enrichment result, collected during ingestion. @@ -388,11 +403,11 @@ async fn run_ingest_inner( let s = multi.add(ProgressBar::new_spinner()); s.set_style( ProgressStyle::default_spinner() - .template("{spinner:.blue} {msg}") + .template("{spinner:.cyan} {msg}") .unwrap(), ); s.set_message(format!("Fetching {type_label} from {path}...")); - s.enable_steady_tick(std::time::Duration::from_millis(100)); + s.enable_steady_tick(std::time::Duration::from_millis(60)); s }; @@ -403,12 +418,13 @@ async fn run_ingest_inner( b.set_style( ProgressStyle::default_bar() .template( - " {spinner:.blue} {prefix:.cyan} Syncing discussions [{bar:30.cyan/dim}] {pos}/{len}", + " {spinner:.dim} {prefix:.cyan} Syncing discussions [{bar:30.cyan/dark_gray}] {pos}/{len} {per_sec:.dim} {eta:.dim}", ) .unwrap() - .progress_chars("=> "), + .progress_chars(crate::cli::render::Icons::progress_chars()), ); b.set_prefix(path.clone()); + b.enable_steady_tick(std::time::Duration::from_millis(60)); b }; @@ -445,7 +461,7 @@ async fn run_ingest_inner( spinner_clone.finish_and_clear(); let agg_total = agg_disc_total_clone.fetch_add(total, Ordering::Relaxed) + total; disc_bar_clone.set_length(total as u64); - disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100)); + disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60)); stage_bar_clone.set_message(format!( "Syncing discussions... (0/{agg_total})" )); @@ -465,7 +481,7 @@ async fn run_ingest_inner( spinner_clone.finish_and_clear(); let agg_total = agg_disc_total_clone.fetch_add(total, Ordering::Relaxed) + total; disc_bar_clone.set_length(total as u64); - disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100)); + disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60)); stage_bar_clone.set_message(format!( "Syncing discussions... (0/{agg_total})" )); @@ -486,11 +502,11 @@ async fn run_ingest_inner( disc_bar_clone.set_length(total as u64); disc_bar_clone.set_style( ProgressStyle::default_bar() - .template(" {spinner:.blue} {prefix:.cyan} Fetching resource events [{bar:30.cyan/dim}] {pos}/{len}") + .template(" {spinner:.dim} {prefix:.cyan} Fetching resource events [{bar:30.cyan/dark_gray}] {pos}/{len} {per_sec:.dim} {eta:.dim}") .unwrap() - .progress_chars("=> "), + .progress_chars(crate::cli::render::Icons::progress_chars()), ); - disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100)); + disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60)); agg_events_total_clone.fetch_add(total, Ordering::Relaxed); stage_bar_clone.set_message( "Fetching resource events...".to_string() @@ -510,7 +526,7 @@ async fn run_ingest_inner( ProgressEvent::ClosesIssuesFetchStarted { total } => { disc_bar_clone.reset(); disc_bar_clone.set_length(total as u64); - disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100)); + disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60)); stage_bar_clone.set_message( "Fetching closes-issues references...".to_string() ); @@ -524,7 +540,7 @@ async fn run_ingest_inner( ProgressEvent::MrDiffsFetchStarted { total } => { disc_bar_clone.reset(); disc_bar_clone.set_length(total as u64); - disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(100)); + disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60)); stage_bar_clone.set_message( "Fetching MR file changes...".to_string() ); @@ -535,35 +551,37 @@ async fn run_ingest_inner( ProgressEvent::MrDiffsFetchComplete { .. } => { disc_bar_clone.finish_and_clear(); } - ProgressEvent::StatusEnrichmentStarted => { - spinner_clone.set_message(format!( - "{path_for_cb}: Enriching work item statuses..." - )); + ProgressEvent::StatusEnrichmentStarted { total } => { + spinner_clone.finish_and_clear(); + disc_bar_clone.reset(); + disc_bar_clone.set_length(total as u64); + disc_bar_clone.set_style( + ProgressStyle::default_bar() + .template(" {spinner:.dim} {prefix:.cyan} Statuses [{bar:30.cyan/dark_gray}] {pos}/{len} {per_sec:.dim} {eta:.dim}") + .unwrap() + .progress_chars(crate::cli::render::Icons::progress_chars()), + ); + disc_bar_clone.set_prefix(path_for_cb.clone()); + disc_bar_clone.enable_steady_tick(std::time::Duration::from_millis(60)); stage_bar_clone.set_message( "Enriching work item statuses...".to_string() ); } ProgressEvent::StatusEnrichmentPageFetched { items_so_far } => { - spinner_clone.set_message(format!( - "{path_for_cb}: Fetching statuses... ({items_so_far} work items)" - )); + disc_bar_clone.set_position(items_so_far as u64); stage_bar_clone.set_message(format!( "Enriching work item statuses... ({items_so_far} fetched)" )); } ProgressEvent::StatusEnrichmentWriting { total } => { - spinner_clone.set_message(format!( - "{path_for_cb}: Writing {total} statuses..." - )); + disc_bar_clone.set_message(format!("Writing {total} statuses...")); stage_bar_clone.set_message(format!( "Writing {total} work item statuses..." )); } ProgressEvent::StatusEnrichmentComplete { enriched, cleared } => { + disc_bar_clone.finish_and_clear(); if enriched > 0 || cleared > 0 { - spinner_clone.set_message(format!( - "{path_for_cb}: {enriched} statuses enriched, {cleared} cleared" - )); stage_bar_clone.set_message(format!( "Status enrichment: {enriched} enriched, {cleared} cleared" )); @@ -656,6 +674,17 @@ async fn run_ingest_inner( first_partial_error: result.first_partial_error.clone(), error: result.status_enrichment_error.clone(), }); + total.project_summaries.push(ProjectSummary { + path: path.clone(), + items_upserted: result.issues_upserted, + discussions_synced: result.discussions_fetched, + events_fetched: result.resource_events_fetched, + events_failed: result.resource_events_failed, + statuses_enriched: result.statuses_enriched, + statuses_seen: result.statuses_seen, + mr_diffs_fetched: 0, + mr_diffs_failed: 0, + }); } Ok(ProjectIngestOutcome::Mrs { ref path, @@ -679,6 +708,17 @@ async fn run_ingest_inner( total.resource_events_failed += result.resource_events_failed; total.mr_diffs_fetched += result.mr_diffs_fetched; total.mr_diffs_failed += result.mr_diffs_failed; + total.project_summaries.push(ProjectSummary { + path: path.clone(), + items_upserted: result.mrs_upserted, + discussions_synced: result.discussions_fetched, + events_fetched: result.resource_events_fetched, + events_failed: result.resource_events_failed, + statuses_enriched: 0, + statuses_seen: 0, + mr_diffs_fetched: result.mr_diffs_fetched, + mr_diffs_failed: result.mr_diffs_failed, + }); } } } diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs index 2526da0..23e58da 100644 --- a/src/cli/commands/sync.rs +++ b/src/cli/commands/sync.rs @@ -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, + #[serde(skip)] + pub mr_projects: Vec, } 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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)); } diff --git a/src/ingestion/orchestrator.rs b/src/ingestion/orchestrator.rs index 54c2417..cab8650 100644 --- a/src/ingestion/orchestrator.rs +++ b/src/ingestion/orchestrator.rs @@ -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 = 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"