Sprint C: author-filtered stats scan, release cadence + repo size, weekly digest

- src/stats/: OwnEmailMatcher (config emails + GH no-reply per account) +
  git-side --author filtering (cheap even on the pterodactyl fork's 4700+
  commits); streak (current/longest, midnight-grace rule), 52-week heatmap,
  day/hour histograms — one scan feeds all of them, data layer only for now
  (visual Stats-tab redesign stays in Sprint D's mockup-first cycle)
- src/stats/cadence.rs: release cadence (median tag gap, local tags, Tier A)
- src/stats/size.rs + cache.rs: git-object vs working-tree size, persisted
  in new ~/.vulp/stats_cache.toml (max 1 sample/day), recorded only during
  [R]/vulp refresh — confirms vulp's own filter-repo cleanup (539MB->834KB)
- src/digest.rs: WeeklyDigest struct + 3 renderers (Discord embed/markdown
  archive/plaintext); vulp digest [send discord|webhook <url>] CLI, webhook
  stored via existing auth token store (~/.vulp/.tokens, 0600), never logged;
  send respects --dry-run
- TUI: [d] on Stats previews the digest in a Modal::Info before anything is
  ever sent
- fix: list_contributors used --all (double-counts after fetching remotes),
  now --branches
- config: stats_author_emails field (TUI-editable)
- verified: streak/delta/top-repo/release math against a hand-built commit
  history, webhook validation+permissions, dry-run, real refresh (51 repos,
  vulp's own entry confirms the filter-repo win), TUI preview via pyte
This commit is contained in:
dualfroz committed 2026-07-06 16:47:19 +02:00
1 parent d99660420f
commit 14ef7fb06c
20 files changed
+890 -3

No files matched your search

+54
View File
@@ -665,6 +665,44 @@ pub fn open_action_menu(state: &mut HubState) {
};
}
/// Builds the weekly digest in the background ([d] on Stats) and shows it
/// as a `Modal::Info` when done — a content preview before anything is ever
/// posted to Discord (see `crate::digest`).
pub fn spawn_digest_preview(state: &mut HubState) {
use super::state::DigestMsg;
if state.digest_rx.is_some() {
state.status_msg = Some("Digest already loading...".into());
return;
}
let repos = state.repos.clone();
let cfg = state.cfg.clone();
let accounts = crate::auth::load_accounts(&cfg).unwrap_or_default();
let (tx, rx) = std::sync::mpsc::channel::<DigestMsg>();
state.digest_rx = Some(rx);
state.status_msg = Some("Building weekly digest...".into());
std::thread::spawn(move || {
let rt = match tokio::runtime::Runtime::new() { Ok(rt) => rt, Err(_) => return };
rt.block_on(async move {
let matcher = crate::stats::OwnEmailMatcher::resolve(&cfg, &accounts);
let default_account = accounts.iter().find(|a| a.is_default).or(accounts.first());
let mut owners: std::collections::HashSet<String> =
repos.iter().filter_map(|r| r.owner.clone()).collect();
if let Some(acc) = default_account { owners.insert(acc.username.clone()); }
let scope = owners.iter().map(|o| format!("user:{o}")).collect::<Vec<_>>().join(" ");
let provider = default_account.and_then(|acc| crate::providers::make_provider(acc).ok());
let d = crate::digest::build(&repos, &matcher, provider.as_deref(), &scope).await;
let lines: Vec<String> = crate::digest::render_plaintext(&d)
.lines().map(|s| s.to_string()).collect();
let _ = tx.send(DigestMsg { lines });
});
});
}
/// Runs the full API refresh ([R]) in the background: per-repo metadata,
/// open PR/issue lists, and workspace-wide merged/closed counters. Progress
/// streams through `state.refresh_rx` (polled by `keys_poll::poll_refresh`),
@@ -691,6 +729,11 @@ pub fn spawn_api_refresh(state: &mut HubState) {
(r.name.clone(), r.owner.clone(), basename, r.branch.clone())
})
.collect();
// For the Tier-B local measurements (release cadence, repo size) below —
// those need the on-disk path, not the provider slug.
let repo_paths: Vec<(String, std::path::PathBuf)> = state.repos.iter()
.map(|r| (r.name.clone(), r.path.clone()))
.collect();
let (tx, rx) = std::sync::mpsc::channel::<RefreshMsg>();
state.refresh_rx = Some(rx);
@@ -791,6 +834,17 @@ pub fn spawn_api_refresh(state: &mut HubState) {
closed_counts = Some((merged, closed, ciss));
}
// Tier-B local measurements (release cadence, repo size) — no
// network, cheap (~0.2s/50 repos measured), so a plain sequential
// pass is fine. Only run during `[R]`, never on tab entry.
let _ = tx.send(RefreshMsg::Progress("Measuring release cadence + repo size...".into()));
let mut stats_cache = crate::stats::cache::load();
for (name, path) in &repo_paths {
let size = crate::stats::size::repo_size(path);
stats_cache.record_size(name, size.git_kb, size.tree_kb);
}
let _ = crate::stats::cache::save(&stats_cache);
let _ = tx.send(RefreshMsg::Done { metas, prs, issues, closed_counts });
});
});