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

+64
View File
@@ -0,0 +1,64 @@
//! Persisted Tier-B stats (`~/.vulp/stats_cache.toml`): daily size samples,
//! kept separate from `meta_cache.toml` on purpose — that file is rewritten
//! wholesale on every save and read fully at startup, so growing it with
//! daily history would slow down every launch for data only the Stats tab
//! needs. Updated only during `[R]`/`vulp refresh`, never on tab entry.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SizeSample {
pub date: chrono::NaiveDate,
pub git_kb: u64,
pub tree_kb: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RepoStatsEntry {
#[serde(default)]
pub size_samples: Vec<SizeSample>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct StatsCache {
#[serde(default)]
pub repos: HashMap<String, RepoStatsEntry>,
}
fn file_path() -> PathBuf {
dirs::home_dir().unwrap_or_default().join(".vulp").join("stats_cache.toml")
}
pub fn load() -> StatsCache {
let p = file_path();
if !p.exists() { return StatsCache::default(); }
toml::from_str(&std::fs::read_to_string(p).unwrap_or_default()).unwrap_or_default()
}
pub fn save(cache: &StatsCache) -> anyhow::Result<()> {
let p = file_path();
if let Some(parent) = p.parent() { std::fs::create_dir_all(parent)?; }
std::fs::write(p, toml::to_string_pretty(cache)?)?;
Ok(())
}
impl StatsCache {
/// Records today's size sample for `repo`, replacing any sample already
/// recorded today rather than appending a duplicate — max 1/day even if
/// `[R]` runs multiple times in one day.
pub fn record_size(&mut self, repo: &str, git_kb: u64, tree_kb: u64) {
let today = chrono::Local::now().date_naive();
let entry = self.repos.entry(repo.to_string()).or_default();
entry.size_samples.retain(|s| s.date != today);
entry.size_samples.push(SizeSample { date: today, git_kb, tree_kb });
entry.size_samples.sort_by_key(|s| s.date);
}
// kept: Sprint D Stats-tab redesign (size/trend display)
#[allow(dead_code)]
pub fn latest_size(&self, repo: &str) -> Option<&SizeSample> {
self.repos.get(repo).and_then(|e| e.size_samples.last())
}
}
+47
View File
@@ -0,0 +1,47 @@
//! Release cadence: how often a repo actually ships, from local tags.
/// Per-repo release cadence, derived from local tag `creatordate`s (a tag
/// made through the GitHub web UI only exists locally after a fetch — that's
/// fine, since this is meant to reflect what you can see without a network
/// round-trip; reconciling with the provider's releases API happens
/// separately during `[R]` refresh).
// kept: Sprint D Stats-tab redesign — no persist needed (Tier A, cheap enough
// to recompute per session), so this is a pure on-demand query for now.
#[allow(dead_code)]
#[derive(Debug, Clone, Copy)]
pub struct ReleaseCadence {
/// Median days between consecutive tags — median, not mean, so one long
/// dry spell can't dominate the number.
pub median_days: f64,
pub last_days_ago: i64,
pub tag_count: usize,
}
/// Computes cadence for `path`, or `None` when there are fewer than 3 tags
/// (a median from 1-2 samples doesn't mean anything).
#[allow(dead_code)]
pub fn release_cadence(path: &std::path::Path) -> Option<ReleaseCadence> {
let out = std::process::Command::new("git")
.arg("-C").arg(path)
.args(["for-each-ref", "refs/tags", "--format=%(creatordate:unix)", "--sort=creatordate"])
.output().ok()?;
let mut ts: Vec<i64> = String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| l.trim().parse::<i64>().ok())
.collect();
ts.sort_unstable();
if ts.len() < 3 { return None; }
let mut gaps: Vec<i64> = ts.windows(2).map(|w| (w[1] - w[0]) / 86400).collect();
gaps.sort_unstable();
let mid = gaps.len() / 2;
let median_days = if gaps.len() % 2 == 1 {
gaps[mid] as f64
} else {
(gaps[mid - 1] + gaps[mid]) as f64 / 2.0
};
let last = *ts.last()?;
let last_days_ago = (chrono::Utc::now().timestamp() - last) / 86400;
Some(ReleaseCadence { median_days, last_days_ago, tag_count: ts.len() })
}
+242
View File
@@ -0,0 +1,242 @@
//! Author-filtered activity analytics: the existing `GlobalStats` (Overview/
//! old Stats tab) counts *every* commit on HEAD, which for a workspace with
//! big forks (95%+ of this workspace's commits are upstream history) makes
//! "your activity" fictional. This module scans only commits whose author
//! email is actually yours — streak, 52-week heatmap, hour/day histograms —
//! sharing one git invocation per repo so adding a metric never costs a
//! second scan.
pub mod cache;
pub mod cadence;
pub mod size;
use crate::config::AppConfig;
use chrono::{Datelike, Local, NaiveDate};
use std::collections::{HashMap, HashSet};
/// One of your own commits: the local calendar date and hour it was made.
#[derive(Debug, Clone, Copy)]
pub struct OwnCommit {
pub date: NaiveDate,
// kept: hour_pattern (Sprint D Stats-tab redesign)
#[allow(dead_code)]
pub hour: u32,
}
/// Resolves which author identities count as "you": configured
/// `stats_author_emails`, falling back to `git config user.email`, plus
/// every configured account's GitHub no-reply address (`{id+}username@
/// users.noreply.github.com`) — the most common gap, since squash-merges
/// done from the GitHub web UI carry that address instead of your real one.
pub struct OwnEmailMatcher {
emails: HashSet<String>,
usernames: Vec<String>,
}
impl OwnEmailMatcher {
pub fn resolve(cfg: &AppConfig, accounts: &[crate::auth::Account]) -> Self {
let mut emails: HashSet<String> = cfg.stats_author_emails.iter()
.map(|e| e.trim().to_lowercase())
.filter(|e| !e.is_empty())
.collect();
if emails.is_empty() {
if let Ok(out) = std::process::Command::new("git")
.args(["config", "--get", "user.email"]).output()
{
let e = String::from_utf8_lossy(&out.stdout).trim().to_lowercase();
if !e.is_empty() { emails.insert(e); }
}
}
if emails.is_empty() && !cfg.email.trim().is_empty() {
emails.insert(cfg.email.trim().to_lowercase());
}
let usernames: Vec<String> = accounts.iter()
.map(|a| a.username.trim().to_lowercase())
.filter(|u| !u.is_empty())
.collect();
Self { emails, usernames }
}
/// Whether `email` belongs to you.
pub fn is_own(&self, email: &str) -> bool {
let email = email.trim().to_lowercase();
if self.emails.contains(&email) { return true; }
let Some(at) = email.rfind('@') else { return false };
if &email[at..] != "@users.noreply.github.com" { return false; }
let local = &email[..at];
let uname = local.rsplit('+').next().unwrap_or(local);
self.usernames.iter().any(|u| u == uname)
}
/// A combined `--author` extended-regex matching every known identity —
/// lets `git log` do the filtering instead of materializing every
/// commit (some forks in this workspace have thousands) just to throw
/// most of them away in Rust. `None` when nothing is configured at all.
fn author_regex(&self) -> Option<String> {
let mut alts: Vec<String> = self.emails.iter()
.map(|e| format!("<{}>", escape_ere(e)))
.collect();
alts.extend(self.usernames.iter().map(|u| {
format!(r"<[0-9]*\+?{}@users\.noreply\.github\.com>", escape_ere(u))
}));
if alts.is_empty() { None } else { Some(alts.join("|")) }
}
}
fn escape_ere(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if "\\.^$|()[]{}*+?".contains(c) { out.push('\\'); }
out.push(c);
}
out
}
/// Scans one repo's local branches for your own commits. Filters at the git
/// level via `--author` (cheap even on huge histories), then re-checks each
/// result against `matcher.is_own` as a safety net against regex quirks.
pub fn scan_repo_own_commits(path: &std::path::Path, matcher: &OwnEmailMatcher) -> Vec<OwnCommit> {
let Some(regex) = matcher.author_regex() else { return vec![] };
let out = std::process::Command::new("git")
.arg("-C").arg(path)
.args(["log", "--branches", "-i", &format!("--author={regex}")])
.args(["--date=format-local:%Y-%m-%dT%H", "--pretty=format:%ae\x1f%ad"])
.output();
let Ok(out) = out else { return vec![] };
String::from_utf8_lossy(&out.stdout).lines().filter_map(|line| {
let (email, ad) = line.split_once('\x1f')?;
if !matcher.is_own(email) { return None; }
let (date_s, hour_s) = ad.split_once('T')?;
let date = NaiveDate::parse_from_str(date_s, "%Y-%m-%d").ok()?;
let hour: u32 = hour_s.parse().ok()?;
Some(OwnCommit { date, hour })
}).collect()
}
/// Scans every repo and merges the result into one date-sorted list.
pub fn scan_workspace_own_commits(
repos: &[crate::status::RepoSummary], matcher: &OwnEmailMatcher,
) -> Vec<OwnCommit> {
let mut all: Vec<OwnCommit> = repos.iter()
.flat_map(|r| scan_repo_own_commits(&r.path, matcher))
.collect();
all.sort_by_key(|c| c.date);
all
}
/// Current and longest streaks of consecutive local-calendar days with at
/// least one commit. Grace rule: an empty *today* doesn't break the current
/// streak until local midnight passes (checking at 8am shouldn't show a
/// streak of yesterday's length as already broken).
pub fn streak(commits: &[OwnCommit]) -> (u32, u32) {
let days: std::collections::BTreeSet<NaiveDate> = commits.iter().map(|c| c.date).collect();
if days.is_empty() { return (0, 0); }
let day_list: Vec<NaiveDate> = days.iter().copied().collect();
let mut longest = 1u32;
let mut run = 1u32;
for w in day_list.windows(2) {
if w[1] - w[0] == chrono::Duration::days(1) {
run += 1;
} else {
longest = longest.max(run);
run = 1;
}
}
longest = longest.max(run);
let today = Local::now().date_naive();
let mut cursor = today;
if !days.contains(&cursor) { cursor -= chrono::Duration::days(1); }
let mut current = 0u32;
while days.contains(&cursor) {
current += 1;
cursor -= chrono::Duration::days(1);
}
(current, longest)
}
/// 364 days ending today, Monday-aligned, as `(date, count)` — a fixed 52×7
/// grid a heatmap can render directly (missing days are `0`, not absent).
// kept: Sprint D Stats-tab redesign (heatmap mockup in debata-2026-07-06/02-ux.md)
#[allow(dead_code)]
pub fn heatmap_52w(commits: &[OwnCommit]) -> Vec<(NaiveDate, u32)> {
let mut counts: HashMap<NaiveDate, u32> = HashMap::new();
for c in commits { *counts.entry(c.date).or_default() += 1; }
let today = Local::now().date_naive();
let rough_start = today - chrono::Duration::days(363);
let start = rough_start - chrono::Duration::days(rough_start.weekday().num_days_from_monday() as i64);
let mut out = Vec::new();
let mut d = start;
while d <= today {
out.push((d, counts.get(&d).copied().unwrap_or(0)));
d += chrono::Duration::days(1);
}
out
}
/// Weekday histogram (Mon=0..Sun=6) over the last `since_days` days.
// kept: Sprint D Stats-tab redesign
#[allow(dead_code)]
pub fn day_pattern(commits: &[OwnCommit], since_days: i64) -> [u32; 7] {
let cutoff = Local::now().date_naive() - chrono::Duration::days(since_days);
let mut out = [0u32; 7];
for c in commits {
if c.date > cutoff { out[c.date.weekday().num_days_from_monday() as usize] += 1; }
}
out
}
/// Local-hour histogram (0..23) over the last `since_days` days.
// kept: Sprint D Stats-tab redesign
#[allow(dead_code)]
pub fn hour_pattern(commits: &[OwnCommit], since_days: i64) -> [u32; 24] {
let cutoff = Local::now().date_naive() - chrono::Duration::days(since_days);
let mut out = [0u32; 24];
for c in commits {
if c.date > cutoff && (c.hour as usize) < 24 { out[c.hour as usize] += 1; }
}
out
}
/// Number of own commits in the last `since_days` days.
// kept: Sprint D Stats-tab redesign (tile: "commits this period")
#[allow(dead_code)]
pub fn commits_since(commits: &[OwnCommit], since_days: i64) -> usize {
let cutoff = Local::now().date_naive() - chrono::Duration::days(since_days);
commits.iter().filter(|c| c.date > cutoff).count()
}
/// Number of distinct calendar days with >=1 own commit in the last
/// `since_days` days.
// kept: Sprint D Stats-tab redesign (tile: "active days")
#[allow(dead_code)]
pub fn active_days_since(commits: &[OwnCommit], since_days: i64) -> usize {
let cutoff = Local::now().date_naive() - chrono::Duration::days(since_days);
commits.iter().filter(|c| c.date > cutoff)
.map(|c| c.date)
.collect::<HashSet<_>>()
.len()
}
/// Own commits with a date in `[from, to]` inclusive.
pub fn commits_in_range(commits: &[OwnCommit], from: NaiveDate, to: NaiveDate) -> Vec<OwnCommit> {
commits.iter().copied().filter(|c| c.date >= from && c.date <= to).collect()
}
/// Per-repo own-commit counts within `[from, to]`, sorted descending.
pub fn top_repos_in_range(
repos: &[crate::status::RepoSummary], matcher: &OwnEmailMatcher,
from: NaiveDate, to: NaiveDate,
) -> Vec<(String, usize)> {
let mut out: Vec<(String, usize)> = repos.iter().filter_map(|r| {
let n = scan_repo_own_commits(&r.path, matcher).into_iter()
.filter(|c| c.date >= from && c.date <= to)
.count();
if n > 0 { Some((r.name.clone(), n)) } else { None }
}).collect();
out.sort_by(|a, b| b.1.cmp(&a.1));
out
}
+48
View File
@@ -0,0 +1,48 @@
//! Repo size, measured as two separate signals rather than one sum: git
//! object-store size (a growing number usually means bloating history —
//! binaries, accidentally committed build output) and working-tree size
//! (usually just `node_modules`/`target`, unrelated to the git history
//! problem). Conflating them would have hidden exactly the pathology that
//! motivated adding this metric: vulp's own `target/` growing to 539 MB in
//! its committed history.
/// One repo's size, in KiB.
#[derive(Debug, Clone, Copy, Default)]
pub struct RepoSize {
/// Loose + packed git objects (`git count-objects -v`).
pub git_kb: u64,
/// Working tree excluding `.git` (`du -sk --exclude=.git`).
pub tree_kb: u64,
}
pub fn repo_size(path: &std::path::Path) -> RepoSize {
RepoSize { git_kb: count_objects_kb(path), tree_kb: du_kb_excluding_git(path) }
}
fn count_objects_kb(path: &std::path::Path) -> u64 {
let out = std::process::Command::new("git")
.arg("-C").arg(path).args(["count-objects", "-v"])
.output();
let Ok(out) = out else { return 0 };
let text = String::from_utf8_lossy(&out.stdout);
let mut size = 0u64;
let mut size_pack = 0u64;
for line in text.lines() {
if let Some(v) = line.strip_prefix("size: ") { size = v.trim().parse().unwrap_or(0); }
if let Some(v) = line.strip_prefix("size-pack: ") { size_pack = v.trim().parse().unwrap_or(0); }
}
size + size_pack
}
fn du_kb_excluding_git(path: &std::path::Path) -> u64 {
let out = std::process::Command::new("du")
.args(["-sk", "--exclude=.git"])
.arg(path)
.output();
let Ok(out) = out else { return 0 };
String::from_utf8_lossy(&out.stdout)
.split_whitespace()
.next()
.and_then(|s| s.parse().ok())
.unwrap_or(0)
}