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:
1 parent
d99660420f
commit
14ef7fb06c
20 files changed
+890
-3
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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)));
|
||||
|
||||
Reference in new issue
Block a user