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

+1
View File
@@ -161,6 +161,7 @@ pub fn tui_phase(state: &mut HubState) -> Result<(PhaseResult, Option<crate::tui
state.poll_activity();
state.poll_release_info();
state.poll_codeintel();
state.poll_digest();
keys_poll::poll_pull_all(state);
keys_poll::poll_create_repo(state);
keys_poll::poll_profile_pull(state);
+3
View File
@@ -280,6 +280,9 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option<PhaseRes
state.global_stats_period = state.global_stats_period.prev();
super::keys::maybe_load_global_stats(state);
}
KeyCode::Char('d') if state.tab == Tab::Stats => {
super::modals_spawn::spawn_digest_preview(state);
}
KeyCode::Char('v') if state.tab == Tab::Overview => {
if let Some(Ok(latest)) = &state.latest_release {
if !latest.html_url.is_empty() {
+1
View File
@@ -145,6 +145,7 @@ pub async fn run_overview_tui(
create_repo_rx: None,
profile_pull_rx: None,
codeintel_rx: None,
digest_rx: None,
latest_release: None,
current_release: None,
account_since: HashMap::new(),
+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 });
});
});
+20
View File
@@ -120,6 +120,8 @@ pub struct HubState {
pub profile_pull_rx: Option<std::sync::mpsc::Receiver<ProfilePullMsg>>,
// Code intelligence background receiver (tree-sitter parsing — CPU-bound, plain thread)
pub codeintel_rx: Option<std::sync::mpsc::Receiver<CodeIntelMsg>>,
// Weekly digest preview background receiver ([d] on Stats)
pub digest_rx: Option<std::sync::mpsc::Receiver<DigestMsg>>,
// Full app config, needed by background threads that create/push repos via providers
pub cfg: crate::config::AppConfig,
}
@@ -482,6 +484,24 @@ impl HubState {
}
}
/// Check if the background digest-preview job ([d] on Stats) finished,
/// and if so, show it as a `Modal::Info`.
pub fn poll_digest(&mut self) {
let done = match &self.digest_rx {
None => return,
Some(rx) => match rx.try_recv() {
Ok(msg) => Some(msg),
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => return,
},
};
if let Some(msg) = done {
self.digest_rx = None;
self.status_msg = None;
self.modal = Modal::Info { title: "Weekly digest (preview — nothing sent)".into(), lines: msg.lines, scroll: 0 };
}
}
pub fn poll_diff(&mut self) {
let rx_ready = if let Some((ri, rx)) = &self.diff_rx {
match rx.try_recv() {
+6
View File
@@ -76,3 +76,9 @@ pub enum CodeIntelMsg {
/// which requires an explicit Enter/y before `apply_rename_plan` ever runs.
Preview { title: String, lines: Vec<String>, plan: crate::codeintel::RenamePlan },
}
/// Result of building the weekly digest in the background (`[d]` on Stats)
/// — a content preview before anything is ever posted to Discord.
pub struct DigestMsg {
pub lines: Vec<String>,
}
+1 -1
View File
@@ -26,7 +26,7 @@ pub fn render_stats_global(f: &mut Frame, state: &HubState, area: Rect) {
let mut left_lines: Vec<Line> = vec![];
left_lines.push(Line::from(Span::styled(
format!(" Global Statistics [7:{}] [p] change period", state.global_stats_period.label()),
format!(" Global Statistics [7:{}] [p] change period [d] weekly digest preview", state.global_stats_period.label()),
yel,
)));
left_lines.push(Line::from(Span::styled(" ─────────────────────────────────────────────────────────", dim)));