From 14ef7fb06c27c41bca7d689d6c91d8166985e6ec Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 6 Jul 2026 16:47:19 +0200 Subject: [PATCH] Sprint C: author-filtered stats scan, release cadence + repo size, weekly digest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 ] 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 --- docs/cli.md | 22 ++++ src/cli.rs | 16 +++ src/cli_cmds/digest.rs | 72 +++++++++++ src/cli_cmds/mod.rs | 2 + src/cli_cmds/remote.rs | 9 ++ src/config/mod.rs | 14 +++ src/detail/git.rs | 6 +- src/digest.rs | 262 +++++++++++++++++++++++++++++++++++++++ src/hub/keys.rs | 1 + src/hub/keys_dispatch.rs | 3 + src/hub/mod.rs | 1 + src/hub/modals_spawn.rs | 54 ++++++++ src/hub/state.rs | 20 +++ src/hub/state_bg.rs | 6 + src/hub/tabs_stats.rs | 2 +- src/main.rs | 2 + src/stats/cache.rs | 64 ++++++++++ src/stats/cadence.rs | 47 +++++++ src/stats/mod.rs | 242 ++++++++++++++++++++++++++++++++++++ src/stats/size.rs | 48 +++++++ 20 files changed, 890 insertions(+), 3 deletions(-) create mode 100644 src/cli_cmds/digest.rs create mode 100644 src/digest.rs create mode 100644 src/stats/cache.rs create mode 100644 src/stats/cadence.rs create mode 100644 src/stats/mod.rs create mode 100644 src/stats/size.rs diff --git a/docs/cli.md b/docs/cli.md index 40fbf46..3d6c8cc 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -36,6 +36,10 @@ vulp [--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 ` 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 # 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/.md` regardless of `send`. +`digest send discord` respects global `--dry-run`. + ## Recipes for AI agents ```bash diff --git a/src/cli.rs b/src/cli.rs index d591d4f..6c515c2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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, + }, +} + +#[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)] diff --git a/src/cli_cmds/digest.rs b/src/cli_cmds/digest.rs new file mode 100644 index 0000000..c0b24d2 --- /dev/null +++ b/src/cli_cmds/digest.rs @@ -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) -> 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 "))?; + 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 = + 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::>().join(" "); + + let provider = default_account.and_then(|acc| crate::providers::make_provider(acc).ok()); + crate::digest::build(&repos, &matcher, provider.as_deref(), &scope).await +} diff --git a/src/cli_cmds/mod.rs b/src/cli_cmds/mod.rs index 1c19d53..26dbcd9 100644 --- a/src/cli_cmds/mod.rs +++ b/src/cli_cmds/mod.rs @@ -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"), } diff --git a/src/cli_cmds/remote.rs b/src/cli_cmds/remote.rs index 16aff91..cfb182c 100644 --- a/src/cli_cmds/remote.rs +++ b/src/cli_cmds/remote.rs @@ -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 } diff --git a/src/config/mod.rs b/src/config/mod.rs index 881b0b2..8700fa3 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -48,6 +48,11 @@ pub struct AppConfig { /// CLI commands refuse to touch unless `--force` is passed. #[serde(default)] pub protected_repos: Vec, + /// 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, } 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(',') diff --git a/src/detail/git.rs b/src/detail/git.rs index 36312fa..02b4e64 100644 --- a/src/detail/git.rs +++ b/src/detail/git.rs @@ -208,10 +208,12 @@ pub fn list_branches(path: &std::path::Path) -> Vec { // ── 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 { 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![] diff --git a/src/digest.rs b/src/digest.rs new file mode 100644 index 0000000..3da29a6 --- /dev/null +++ b/src/digest.rs @@ -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::>().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::() 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 = 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 = 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::>().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::>().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 { + 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(()) +} diff --git a/src/hub/keys.rs b/src/hub/keys.rs index b3fc52b..2e1befc 100644 --- a/src/hub/keys.rs +++ b/src/hub/keys.rs @@ -161,6 +161,7 @@ pub fn tui_phase(state: &mut HubState) -> Result<(PhaseResult, Option Option { + 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() { diff --git a/src/hub/mod.rs b/src/hub/mod.rs index 344d2f7..3204347 100644 --- a/src/hub/mod.rs +++ b/src/hub/mod.rs @@ -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(), diff --git a/src/hub/modals_spawn.rs b/src/hub/modals_spawn.rs index af8c01d..dcc4ead 100644 --- a/src/hub/modals_spawn.rs +++ b/src/hub/modals_spawn.rs @@ -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::(); + 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 = + 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::>().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 = 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::(); 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 }); }); }); diff --git a/src/hub/state.rs b/src/hub/state.rs index b75af50..5316c81 100644 --- a/src/hub/state.rs +++ b/src/hub/state.rs @@ -120,6 +120,8 @@ pub struct HubState { pub profile_pull_rx: Option>, // Code intelligence background receiver (tree-sitter parsing — CPU-bound, plain thread) pub codeintel_rx: Option>, + // Weekly digest preview background receiver ([d] on Stats) + pub digest_rx: Option>, // 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() { diff --git a/src/hub/state_bg.rs b/src/hub/state_bg.rs index a192548..c735100 100644 --- a/src/hub/state_bg.rs +++ b/src/hub/state_bg.rs @@ -76,3 +76,9 @@ pub enum CodeIntelMsg { /// which requires an explicit Enter/y before `apply_rename_plan` ever runs. Preview { title: String, lines: Vec, 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, +} diff --git a/src/hub/tabs_stats.rs b/src/hub/tabs_stats.rs index 468a8f9..c1d7d22 100644 --- a/src/hub/tabs_stats.rs +++ b/src/hub/tabs_stats.rs @@ -26,7 +26,7 @@ pub fn render_stats_global(f: &mut Frame, state: &HubState, area: Rect) { let mut left_lines: Vec = 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))); diff --git a/src/main.rs b/src/main.rs index e34bffb..5a35f6b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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; diff --git a/src/stats/cache.rs b/src/stats/cache.rs new file mode 100644 index 0000000..c5a9f25 --- /dev/null +++ b/src/stats/cache.rs @@ -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, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct StatsCache { + #[serde(default)] + pub repos: HashMap, +} + +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()) + } +} diff --git a/src/stats/cadence.rs b/src/stats/cadence.rs new file mode 100644 index 0000000..6684e48 --- /dev/null +++ b/src/stats/cadence.rs @@ -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 { + 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 = String::from_utf8_lossy(&out.stdout) + .lines() + .filter_map(|l| l.trim().parse::().ok()) + .collect(); + ts.sort_unstable(); + if ts.len() < 3 { return None; } + + let mut gaps: Vec = 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() }) +} diff --git a/src/stats/mod.rs b/src/stats/mod.rs new file mode 100644 index 0000000..ceb31d0 --- /dev/null +++ b/src/stats/mod.rs @@ -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, + usernames: Vec, +} + +impl OwnEmailMatcher { + pub fn resolve(cfg: &AppConfig, accounts: &[crate::auth::Account]) -> Self { + let mut emails: HashSet = 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 = 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 { + let mut alts: Vec = 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 { + 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 { + let mut all: Vec = 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 = commits.iter().map(|c| c.date).collect(); + if days.is_empty() { return (0, 0); } + + let day_list: Vec = 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 = 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::>() + .len() +} + +/// Own commits with a date in `[from, to]` inclusive. +pub fn commits_in_range(commits: &[OwnCommit], from: NaiveDate, to: NaiveDate) -> Vec { + 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 +} diff --git a/src/stats/size.rs b/src/stats/size.rs new file mode 100644 index 0000000..6918554 --- /dev/null +++ b/src/stats/size.rs @@ -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) +}