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
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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() })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user