Something went wrong. Try again.
unified development activity
Something went wrong. Try again.
9.0 kB · 257 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258//! Conventional Commits types per week, and per day for the most recent//! days, for the commit-types card. A short period reads daily bins; a long//! one reads weekly bins.//!//! A logical change's subject is the first line of its earliest commit. Only//! Tangled and named GitHub commits carry one; redacted work commits are//! counted separately as having no subject, never guessed at.
use std::collections::HashSet;
use chrono::{Datelike, Duration, NaiveDate};use serde::Serialize;
/// The types the card lists, in order. Anything else that parses is `other`.const TYPES: &[&str] = &[ "feat", "fix", "docs", "test", "refactor", "perf", "build", "ci", "chore", "style", "revert",];
#[derive(Debug, Default, Serialize)]pub struct CommitTypes { /// Monday of each UTC week, oldest first. pub weeks: Vec<String>, /// Each of the last few UTC days, oldest first, ending on the last day. pub days: Vec<String>, /// Types with at least one commit, most used first. pub types: Vec<TypeRow>, /// Subjects that aren't Conventional Commits. pub untyped: Bins, /// Changes with no subject at all (redacted work commits). pub no_subject: Bins,}
#[derive(Debug, Serialize)]pub struct TypeRow { pub name: String, #[serde(flatten)] pub bins: Bins,}
/// One count binned two ways: by week over the whole window, and by day/// over its last `days`.#[derive(Debug, Default, Clone, PartialEq, Serialize)]pub struct Bins { pub total: u64, pub weekly: Vec<u64>, pub daily: Vec<u64>,}
impl Bins { fn new(weeks: usize, days: usize) -> Self { Self { total: 0, weekly: vec![0; weeks], daily: vec![0; days], } }
fn add(&mut self, week: usize, day: Option<usize>) { self.total += 1; self.weekly[week] += 1; if let Some(day) = day { self.daily[day] += 1; } }}
/// The type a subject declares: `feat(scope)!: …` is `feat`. Common/// spellings fold into the standard name; an unknown word is `other`.pub fn commit_type(subject: &str) -> Option<&'static str> { let head = subject.trim_start().split_once(':')?.0; let head = head.strip_suffix('!').unwrap_or(head); let word = match head.split_once('(') { Some((word, scope)) if scope.ends_with(')') => word, Some(_) => return None, None => head, }; if word.is_empty() || !word.chars().all(|c| c.is_ascii_alphabetic()) { return None; } let word = word.to_ascii_lowercase(); let word = match word.as_str() { "feature" | "features" => "feat", "bugfix" | "hotfix" | "fixes" => "fix", "doc" => "docs", "tests" => "test", other => other, }; Some( TYPES .iter() .copied() .find(|t| *t == word) .unwrap_or("other"), )}
/// Bucket `(author_did, authored_at, subject)` rows from `start` through/// `end` into weeks, and the last `days` of them into days, keeping only/// authors in `mine`.pub fn binned( rows: &[(String, String, String)], mine: &HashSet<String>, start: NaiveDate, end: NaiveDate, days: usize,) -> CommitTypes { let monday = |day: NaiveDate| day - Duration::days(day.weekday().num_days_from_monday() as i64); let first = monday(start); let count = ((monday(end) - first).num_days() / 7 + 1) as usize; let first_day = end - Duration::days(days as i64 - 1); let mut names: Vec<&str> = TYPES.to_vec(); names.push("other"); let mut bins = vec![Bins::new(count, days); names.len()]; let mut out = CommitTypes { weeks: (0..count) .map(|week| (first + Duration::days(7 * week as i64)).to_string()) .collect(), days: (0..days) .map(|day| (first_day + Duration::days(day as i64)).to_string()) .collect(), untyped: Bins::new(count, days), no_subject: Bins::new(count, days), types: Vec::new(), }; for (author, authored_at, subject) in rows { if !mine.contains(author) { continue; } let Some(day) = authored_at .get(0..10) .and_then(|day| NaiveDate::parse_from_str(day, "%Y-%m-%d").ok()) else { continue; }; if day < start || day > end { continue; } let week = ((monday(day) - first).num_days() / 7) as usize; let daily = (day >= first_day).then(|| (day - first_day).num_days() as usize); let subject = subject.lines().next().unwrap_or("").trim(); if subject.is_empty() { out.no_subject.add(week, daily); continue; } let Some(kind) = commit_type(subject) else { out.untyped.add(week, daily); continue; }; let row = names .iter() .position(|name| *name == kind) .unwrap_or(names.len() - 1); bins[row].add(week, daily); } out.types = names .iter() .zip(bins) .map(|(name, bins)| TypeRow { name: (*name).to_string(), bins, }) .filter(|row| row.bins.total > 0) .collect(); out.types.sort_by(|a, b| { b.bins .total .cmp(&a.bins.total) .then_with(|| a.name.cmp(&b.name)) }); out}
#[cfg(test)]mod tests { use super::*;
#[test] fn subjects_parse_to_their_declared_type() { for (subject, want) in [ ("feat: a wheel", Some("feat")), ("fix(db)!: fold old identities", Some("fix")), ("Docs: typo", Some("docs")), ("feature: x", Some("feat")), ("tests: more", Some("test")), ("wip: halfway", Some("other")), ("Merge branch 'main'", None), ("Update README.md", None), ("fix(unclosed: x", None), ("see: http://x", Some("other")), ("12: numbers", None), ] { assert_eq!(commit_type(subject), want, "{subject}"); } }
#[test] fn rows_land_in_monday_weeks_and_unused_types_drop() { let row = |did: &str, at: &str, subject: &str| (did.into(), at.into(), subject.into()); let rows = [ row("me", "2026-10-19T09:00:00Z", "feat: a"), row("me", "2026-10-25T23:00:00Z", "feat: b"), row("me", "2026-10-26T01:00:00Z", "fix: c"), row("me", "2026-10-26T02:00:00Z", "Update x"), row("me", "2026-10-26T03:00:00Z", ""), row("someone", "2026-10-26T04:00:00Z", "feat: d"), ]; let mine: HashSet<String> = ["me".to_string()].into(); let start = NaiveDate::from_ymd_opt(2026, 10, 20).unwrap(); let end = NaiveDate::from_ymd_opt(2026, 10, 27).unwrap(); let types = binned(&rows, &mine, start, end, 3); // The 19th is before the window; the 20th's week starts on the 19th. assert_eq!(types.weeks, ["2026-10-19", "2026-10-26"]); let summary: Vec<_> = types .types .iter() .map(|row| (row.name.as_str(), row.bins.weekly.clone())) .collect(); assert_eq!(summary, [("feat", vec![1, 0]), ("fix", vec![0, 1])]); assert_eq!((types.untyped.total, types.no_subject.total), (1, 1)); }
#[test] fn the_last_days_are_binned_by_day_too() { let row = |at: &str, subject: &str| ("me".into(), at.into(), subject.into()); let rows = [ row("2026-10-24T23:59:00Z", "feat: before the days"), row("2026-10-25T00:00:00Z", "feat: first day"), row("2026-10-27T09:00:00Z", "fix: last day"), row("2026-10-27T10:00:00Z", "fix: last day again"), row("2026-10-26T10:00:00Z", "Update x"), row("2026-10-26T11:00:00Z", ""), ]; let mine: HashSet<String> = ["me".to_string()].into(); let start = NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(); let end = NaiveDate::from_ymd_opt(2026, 10, 27).unwrap(); let types = binned(&rows, &mine, start, end, 3); assert_eq!(types.days, ["2026-10-25", "2026-10-26", "2026-10-27"]); let daily: Vec<_> = types .types .iter() .map(|row| (row.name.as_str(), row.bins.total, row.bins.daily.clone())) .collect(); // The 24th counts in its week and the total, but not in a day. assert_eq!( daily, [("feat", 2, vec![1, 0, 0]), ("fix", 2, vec![0, 0, 2])] ); assert_eq!(types.untyped.daily, [0, 1, 0]); assert_eq!(types.no_subject.daily, [0, 1, 0]); // Every row's daily bins sum to no more than its weekly ones. for row in &types.types { let weekly: u64 = row.bins.weekly.iter().sum(); assert_eq!(weekly, row.bins.total); assert!(row.bins.daily.iter().sum::<u64>() <= weekly); } }}