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

+22
View File
@@ -36,6 +36,10 @@ vulp <command> [--json] # non-interactive (scripts/AI)
- **Secret scan**: `sync --action commit-push` refuses to commit staged
changes that look like credentials (see Git operations); override with
`--allow-secrets`.
- **Discord webhook secret**: `vulp digest webhook <url>` stores the URL in
`~/.vulp/.tokens` (mode 0600) the same way provider tokens are stored —
never in `config.toml`, never printed back, never logged. Whoever has the
URL can post to (and edit/delete messages in) that channel.
- **`VULP_AGENT`** (env var, any non-empty value): every activity-log entry
written while it's set gets a `· via agent` marker in its detail, so human
and agent actions are distinguishable in `vulp activity` and the TUI feed.
@@ -139,6 +143,24 @@ review and commit the file yourself. Defaults come from config:
`default_license` (mit) and `license_holder` (falls back to
`git config user.name`, then the account username).
## Weekly digest
```
vulp digest [--json] # build + print this week's digest, archive to ~/.vulp/digests/
vulp digest send discord # also POST it to the saved Discord webhook
vulp digest webhook <url> # store/replace the webhook URL (secret — see Guardrails)
```
The digest counts only **your own** commits — filtered by `stats_author_emails`
(config; falls back to `git config user.email`) plus each configured
account's GitHub no-reply address — not every commit in the workspace
(most repos here are 90%+ upstream/fork history). It reports: own commits
this week vs last week, active days, current streak, top 3 repos by own
commits, local tags created this week, PR/issue open+merged/closed counts
(provider search API, needs an account), and todos added/done. Every build
is archived to `~/.vulp/digests/<ISO-week>.md` regardless of `send`.
`digest send discord` respects global `--dry-run`.
## Recipes for AI agents
```bash
+16
View File
@@ -133,6 +133,22 @@ pub enum Commands {
Accounts,
/// Fetch metadata/PRs/issues + merged/closed counters from provider APIs
Refresh,
/// Weekly digest of your own activity (commits/releases/PRs/issues/todos)
Digest {
#[command(subcommand)]
action: Option<DigestCmd>,
},
}
#[derive(Subcommand)]
pub enum DigestCmd {
/// Post this week's digest to a saved destination
Send {
/// Currently only "discord"
target: String,
},
/// Store/replace the Discord webhook URL used by `digest send discord`
Webhook { url: String },
}
#[derive(Subcommand)]
+72
View File
@@ -0,0 +1,72 @@
//! `vulp digest` — build/show/send the weekly digest of own activity.
use super::{die, out_json};
use crate::cli::DigestCmd;
use crate::config::AppConfig;
use anyhow::Result;
pub async fn digest(cfg: &AppConfig, guard: super::Guard, json: bool, action: Option<DigestCmd>) -> Result<()> {
match action {
Some(DigestCmd::Webhook { url }) => {
if !url.starts_with("https://discord.com/api/webhooks/")
&& !url.starts_with("https://discordapp.com/api/webhooks/")
{
die(2, "expected a Discord webhook URL (https://discord.com/api/webhooks/...)");
}
crate::auth::store_token(crate::digest::WEBHOOK_ID, &url)?;
println!("✓ webhook stored");
Ok(())
}
Some(DigestCmd::Send { target }) => {
if target != "discord" {
die(2, "only 'discord' is supported as a send target");
}
let d = build(cfg).await;
let path = crate::digest::archive(&d)?;
if guard.dry_run {
println!("DRY RUN: would POST digest embed to the saved Discord webhook");
println!("(archived: {})", path.display());
return Ok(());
}
let webhook = crate::auth::get_token(crate::digest::WEBHOOK_ID)
.map_err(|_| anyhow::anyhow!("no webhook stored — run: vulp digest webhook <url>"))?;
let embed = crate::digest::render_discord_embed(&d);
crate::digest::send_discord(&embed, &webhook).await?;
println!("✓ sent to Discord (archived: {})", path.display());
Ok(())
}
None => {
let d = build(cfg).await;
let path = crate::digest::archive(&d)?;
if json {
#[derive(serde::Serialize)]
struct Out { plaintext: String, archived: String }
out_json(&Out {
plaintext: crate::digest::render_plaintext(&d),
archived: path.display().to_string(),
});
} else {
print!("{}", crate::digest::render_plaintext(&d));
eprintln!("(archived: {})", path.display());
}
Ok(())
}
}
}
/// Gathers workspace state and builds the digest — shared by `show` and
/// `send` so both report on exactly the same numbers.
async fn build(cfg: &AppConfig) -> crate::digest::WeeklyDigest {
let repos = super::scan_repos(cfg);
let accounts = crate::auth::load_accounts(cfg).unwrap_or_default();
let matcher = crate::stats::OwnEmailMatcher::resolve(cfg, &accounts);
let mut owners: std::collections::HashSet<String> =
repos.iter().filter_map(|r| r.owner.clone()).collect();
let default_account = accounts.iter().find(|a| a.is_default).or(accounts.first());
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());
crate::digest::build(&repos, &matcher, provider.as_deref(), &scope).await
}
+2
View File
@@ -6,6 +6,7 @@
//! Exit codes: 0 ok · 1 execution error · 2 bad usage (clap) ·
//! 3 auth missing/invalid · 4 not found.
mod digest;
mod items;
mod remote;
mod workspace;
@@ -48,6 +49,7 @@ pub async fn run(cmd: Commands, json: bool, guard: Guard, cfg: &AppConfig) -> Re
remote::init(cfg, guard, &name, private, &description).await,
Commands::Accounts => remote::accounts(cfg, json),
Commands::Refresh => remote::refresh(cfg, json).await,
Commands::Digest { action } => digest::digest(cfg, guard, json, action).await,
Commands::Config | Commands::Auth(_) => unreachable!("handled in app.rs"),
}
+9
View File
@@ -378,6 +378,15 @@ pub async fn refresh(cfg: &AppConfig, json: bool) -> Result<()> {
}
let _ = crate::cache::save(&cache);
// Tier-B local measurement (repo size trend) — no network, cheap, only
// ever recorded during a refresh (never on a plain read like `vulp status`).
let mut stats_cache = crate::stats::cache::load();
for r in &repos {
let size = crate::stats::size::repo_size(&r.path);
stats_cache.record_size(&r.name, size.git_kb, size.tree_kb);
}
let _ = crate::stats::cache::save(&stats_cache);
if json {
#[derive(serde::Serialize)]
struct Out { repos_refreshed: usize, open_prs: usize, open_issues: usize }
+14
View File
@@ -48,6 +48,11 @@ pub struct AppConfig {
/// CLI commands refuse to touch unless `--force` is passed.
#[serde(default)]
pub protected_repos: Vec<String>,
/// Author emails counted as "you" for streak/heatmap/digest stats.
/// Empty = fall back to `git config user.email`, then `email`. GitHub's
/// no-reply address for each configured account is always matched too.
#[serde(default)]
pub stats_author_emails: Vec<String>,
}
fn default_license_kind() -> String { "mit".into() }
@@ -87,6 +92,7 @@ impl Default for AppConfig {
license_holder: String::new(),
default_license: default_license_kind(),
protected_repos: vec![],
stats_author_emails: vec![],
}
}
}
@@ -113,6 +119,7 @@ impl AppConfig {
("license_holder", self.license_holder.clone()),
("default_license", self.default_license.clone()),
("protected_repos", self.protected_repos.join(", ")),
("stats_author_emails", self.stats_author_emails.join(", ")),
]
}
@@ -143,6 +150,13 @@ impl AppConfig {
.filter(|s| !s.is_empty())
.collect();
}
"stats_author_emails" => {
self.stats_author_emails = value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
"workspace_groups" => {
self.workspace_groups = value
.split(',')
+4 -2
View File
@@ -208,10 +208,12 @@ pub fn list_branches(path: &std::path::Path) -> Vec<BranchEntry> {
// ── contributors ─────────────────────────────────────────────────────────────
/// Contributors by commit count across all branches (`git shortlog -sn --all`).
/// Contributors by commit count across local branches (`git shortlog -sn
/// --branches`). Deliberately not `--all`: that also counts remote-tracking
/// refs, so after a fetch every contributor gets double-counted.
pub fn list_contributors(path: &std::path::Path) -> Vec<ContribEntry> {
let out = std::process::Command::new("git")
.arg("-C").arg(path).args(["shortlog", "-sn", "--all"])
.arg("-C").arg(path).args(["shortlog", "-sn", "--branches"])
.output()
.unwrap_or_else(|_| std::process::Output {
status: std::process::ExitStatus::default(), stdout: vec![], stderr: vec![]
+262
View File
@@ -0,0 +1,262 @@
//! Weekly digest: one data struct, three renderers (Discord embed, archival
//! markdown, plaintext for the TUI/CLI) — no per-channel string sewing.
//! Delivery is opt-in (`vulp digest send discord`); vulp never posts
//! anything on its own. The webhook URL is a full secret (whoever has it
//! can post to and edit/delete messages in that channel) — stored the same
//! way provider tokens are, via `crate::auth::store_token`, never in
//! `config.toml`, never logged.
use crate::providers::Provider;
use anyhow::Result;
use chrono::{Datelike, Local, NaiveDate};
/// The token-store id under which the Discord webhook URL is kept
/// (`~/.vulp/.tokens`, mode 0600 — see `crate::auth`).
pub const WEBHOOK_ID: &str = "discord_webhook_default";
pub struct WeeklyDigest {
pub iso_week: (i32, u32),
pub range: (NaiveDate, NaiveDate),
pub own_commits: usize,
pub prev_week_commits: usize,
pub active_days: u8,
pub streak: u32,
pub top_repos: Vec<(String, usize)>,
pub releases: Vec<(String, String)>,
pub prs_opened: u64,
pub prs_merged: u64,
pub issues_opened: u64,
pub issues_closed: u64,
pub todos_done: usize,
pub todos_added: usize,
}
/// Builds this week's digest. `provider`/`search_scope` are optional — pass
/// `None`/empty when no account is configured or the caller wants a
/// network-free preview; PR/issue counts are simply `0` in that case.
pub async fn build(
repos: &[crate::status::RepoSummary],
matcher: &crate::stats::OwnEmailMatcher,
provider: Option<&dyn Provider>,
search_scope: &str,
) -> WeeklyDigest {
let today = Local::now().date_naive();
let this_monday = today - chrono::Duration::days(today.weekday().num_days_from_monday() as i64);
let this_sunday = this_monday + chrono::Duration::days(6);
let prev_monday = this_monday - chrono::Duration::days(7);
let prev_sunday = this_monday - chrono::Duration::days(1);
let all_commits = crate::stats::scan_workspace_own_commits(repos, matcher);
let week_commits = crate::stats::commits_in_range(&all_commits, this_monday, this_sunday);
let prev_commits = crate::stats::commits_in_range(&all_commits, prev_monday, prev_sunday);
let (streak_cur, _) = crate::stats::streak(&all_commits);
let active_days = week_commits.iter().map(|c| c.date)
.collect::<std::collections::HashSet<_>>().len() as u8;
let top_repos = crate::stats::top_repos_in_range(repos, matcher, this_monday, this_sunday)
.into_iter().take(3).collect();
let releases = local_releases_in_range(repos, this_monday, this_sunday);
let (prs_opened, prs_merged, issues_opened, issues_closed) = match provider {
Some(p) if !search_scope.is_empty() => {
let range_q = format!("{}..{}", this_monday, this_sunday);
let po = p.count_search(&format!("is:pr created:{range_q} {search_scope}")).await.unwrap_or(0);
let pm = p.count_search(&format!("is:pr is:merged merged:{range_q} {search_scope}")).await.unwrap_or(0);
let io = p.count_search(&format!("is:issue created:{range_q} {search_scope}")).await.unwrap_or(0);
let ic = p.count_search(&format!("is:issue closed:{range_q} {search_scope}")).await.unwrap_or(0);
(po, pm, io, ic)
}
_ => (0, 0, 0, 0),
};
let todos = crate::todos::load();
let todos_added = todos.iter().filter(|t| ts_in_range(t.created_at, this_monday, this_sunday)).count();
let todos_done = todos.iter()
.filter(|t| t.status == crate::todos::TodoStatus::Done && ts_in_range(t.updated_at, this_monday, this_sunday))
.count();
WeeklyDigest {
iso_week: (today.iso_week().year(), today.iso_week().week()),
range: (this_monday, this_sunday),
own_commits: week_commits.len(),
prev_week_commits: prev_commits.len(),
active_days,
streak: streak_cur,
top_repos, releases,
prs_opened, prs_merged, issues_opened, issues_closed,
todos_done, todos_added,
}
}
fn ts_in_range(ts: i64, from: NaiveDate, to: NaiveDate) -> bool {
let Some(dt) = chrono::DateTime::from_timestamp(ts, 0) else { return false };
let date = dt.with_timezone(&Local).date_naive();
date >= from && date <= to
}
/// Local tags (repo, tag name) whose `creatordate` falls in `[from, to]` —
/// tags made on the GitHub web UI only show up here after a fetch.
fn local_releases_in_range(
repos: &[crate::status::RepoSummary], from: NaiveDate, to: NaiveDate,
) -> Vec<(String, String)> {
let mut out = Vec::new();
for r in repos {
let output = std::process::Command::new("git")
.arg("-C").arg(&r.path)
.args(["for-each-ref", "refs/tags", "--format=%(creatordate:unix) %(refname:short)"])
.output();
let Ok(output) = output else { continue };
for line in String::from_utf8_lossy(&output.stdout).lines() {
let Some((ts_s, tag)) = line.split_once(' ') else { continue };
let Ok(ts) = ts_s.parse::<i64>() else { continue };
if ts_in_range(ts, from, to) {
out.push((r.name.clone(), tag.to_string()));
}
}
}
out
}
// ── renderers ────────────────────────────────────────────────────────────────
fn delta_label(delta: i64) -> String {
match delta.cmp(&0) {
std::cmp::Ordering::Greater => format!("▲ +{delta}"),
std::cmp::Ordering::Less => format!("▼ {delta}"),
std::cmp::Ordering::Equal => "=".into(),
}
}
/// ~10-line plaintext render for the TUI preview and `vulp digest`.
pub fn render_plaintext(d: &WeeklyDigest) -> String {
let delta = delta_label(d.own_commits as i64 - d.prev_week_commits as i64);
let mut out = String::new();
out.push_str(&format!(
"Tygodniówka {}-W{:02} · {} – {}\n",
d.iso_week.0, d.iso_week.1, d.range.0.format("%d.%m"), d.range.1.format("%d.%m"),
));
out.push_str(&format!(
"{} commitów ({delta} vs poprz. tydzień) · {} dni aktywnych · streak {} dni\n",
d.own_commits, d.active_days, d.streak,
));
if !d.top_repos.is_empty() {
let s: Vec<String> = d.top_repos.iter().map(|(n, c)| format!("{n} {c}")).collect();
out.push_str(&format!("Top repo: {}\n", s.join(" · ")));
}
if !d.releases.is_empty() {
let s: Vec<String> = d.releases.iter().map(|(r, t)| format!("{r} {t}")).collect();
out.push_str(&format!("Release'y: {}\n", s.join(", ")));
}
out.push_str(&format!(
"PR: {} otwarte, {} merged · Issues: {} nowe, {} zamknięte\n",
d.prs_opened, d.prs_merged, d.issues_opened, d.issues_closed,
));
out.push_str(&format!("Todo: {} ukończonych, {} nowych\n", d.todos_done, d.todos_added));
out
}
/// Archival markdown render, one file per ISO week
/// (`~/.vulp/digests/2026-W27.md`) — grep-able, diff-able, readable by an
/// AI agent through the CLI without needing Discord at all.
pub fn render_markdown(d: &WeeklyDigest) -> String {
let delta = delta_label(d.own_commits as i64 - d.prev_week_commits as i64);
let mut out = String::new();
out.push_str(&format!("# Tygodniówka {}-W{:02} · {} – {}\n\n", d.iso_week.0, d.iso_week.1, d.range.0, d.range.1));
out.push_str(&format!(
"**{} commitów** ({delta} vs poprzedni tydzień) · {} dni aktywnych · streak **{} dni**\n\n",
d.own_commits, d.active_days, d.streak,
));
if !d.top_repos.is_empty() {
out.push_str("## Top repo\n\n");
for (n, c) in &d.top_repos { out.push_str(&format!("- `{n}` — {c}\n")); }
out.push('\n');
}
if !d.releases.is_empty() {
out.push_str("## Release'y\n\n");
for (r, t) in &d.releases { out.push_str(&format!("- `{r}` **{t}**\n")); }
out.push('\n');
}
out.push_str("## PR / Issues\n\n");
out.push_str(&format!(
"PR: {} otwarte, {} merged \nIssues: {} nowe, {} zamknięte\n\n",
d.prs_opened, d.prs_merged, d.issues_opened, d.issues_closed,
));
out.push_str("## Todo\n\n");
out.push_str(&format!("✔ {} ukończonych · +{} nowych\n", d.todos_done, d.todos_added));
out
}
/// Discord embed body — embeds get layout/color/fields that plain `content`
/// markdown can't, and self-limit well within Discord's per-embed caps.
pub fn render_discord_embed(d: &WeeklyDigest) -> serde_json::Value {
let delta = delta_label(d.own_commits as i64 - d.prev_week_commits as i64);
let title = format!(
"Tygodniówka {}-W{:02} · {} – {}",
d.iso_week.0, d.iso_week.1, d.range.0.format("%d.%m"), d.range.1.format("%d.%m"),
);
let description = format!(
"**{} commitów** ({delta} vs poprzedni tydzień) · {} dni aktywnych · streak **{} dni**",
d.own_commits, d.active_days, d.streak,
);
let mut fields = Vec::new();
if !d.top_repos.is_empty() {
let val = d.top_repos.iter().map(|(n, c)| format!("`{n}` {c}")).collect::<Vec<_>>().join(" · ");
fields.push(serde_json::json!({ "name": "Top repo", "value": val, "inline": false }));
}
if !d.releases.is_empty() {
let val = d.releases.iter().map(|(r, t)| format!("`{r}` **{t}**")).collect::<Vec<_>>().join("\n");
fields.push(serde_json::json!({ "name": "Release'y", "value": val, "inline": true }));
}
fields.push(serde_json::json!({
"name": "PR / Issues", "inline": false,
"value": format!("PR: {} otwarte, {} merged · Issues: {} nowe, {} zamknięte",
d.prs_opened, d.prs_merged, d.issues_opened, d.issues_closed),
}));
fields.push(serde_json::json!({
"name": "Todo", "inline": true,
"value": format!("✔ {} ukończonych · +{} nowych", d.todos_done, d.todos_added),
}));
serde_json::json!({
"username": "vulp",
"embeds": [{
"title": title,
"color": 15105570,
"description": description,
"fields": fields,
"footer": { "text": format!("vulp digest · wysłano {}", Local::now().format("%a %H:%M")) },
}],
})
}
/// Path a digest for `d`'s ISO week archives to.
pub fn archive_path(d: &WeeklyDigest) -> std::path::PathBuf {
dirs::home_dir().unwrap_or_default()
.join(".vulp").join("digests")
.join(format!("{}-W{:02}.md", d.iso_week.0, d.iso_week.1))
}
/// Writes the markdown render to the archive, creating the directory if
/// needed. Called every time a digest is built (`show` and `send` alike) —
/// idempotent, since it's keyed by ISO week.
pub fn archive(d: &WeeklyDigest) -> Result<std::path::PathBuf> {
let path = archive_path(d);
if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; }
std::fs::write(&path, render_markdown(d))?;
Ok(path)
}
/// POSTs the embed to a Discord webhook. `?wait=true` so a failure surfaces
/// as an error instead of firing blind. Never logs `webhook_url` — the URL
/// itself is the secret.
pub async fn send_discord(embed: &serde_json::Value, webhook_url: &str) -> Result<()> {
let client = reqwest::Client::new();
let resp = client.post(format!("{webhook_url}?wait=true")).json(embed).send().await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("Discord webhook POST failed: {status} — {text}");
}
Ok(())
}
+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)));
+2
View File
@@ -13,6 +13,7 @@ mod codeintel;
mod config;
mod debug;
mod detail;
mod digest;
mod hub;
mod labels;
mod legacy;
@@ -22,6 +23,7 @@ mod projects;
mod providers;
mod release_info;
mod secrets;
mod stats;
mod status;
mod todos;
mod tui;
+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)
}