| 1 | //! Who made a repository: the authors of the commits on its default branch, |
| 2 | //! each matched to their account by an address they confirmed (or their |
| 3 | //! noreply address), g1t as itself, and anyone else by the name on their |
| 4 | //! commits. Several addresses of one account count as one contributor. |
| 5 | |
| 6 | use std::collections::{BTreeMap, HashMap}; |
| 7 | |
| 8 | use g1t_contracts::about::{Contributor, ContributorKind, MAX_CONTRIBUTOR_WEEKS, MAX_CONTRIBUTORS, WeekCommits, next_week, week_of}; |
| 9 | use g1t_contracts::accounts::EmailOwner; |
| 10 | use g1t_contracts::coding_agents::coding_agent_for; |
| 11 | |
| 12 | /// The address g1t's own commits carry. |
| 13 | pub const G1T_EMAIL: &str = "g1t@users.noreply.g1t.sh"; |
| 14 | |
| 15 | /// The addresses g1t's agents and merge queue committed as before they |
| 16 | /// committed as g1t (2026-10-06): history keeps them, and they are g1t's. |
| 17 | /// The site matches the same set (apps/web/app/lib/commit-people.ts). |
| 18 | pub const LEGACY_G1T_EMAILS: [&str; 3] = ["agent@g1t.sh", "queue@g1t.sh", "mergecheck@g1t.sh"]; |
| 19 | |
| 20 | /// One commit's author, as history gives it. |
| 21 | #[derive(Clone, Debug)] |
| 22 | pub struct Authored { |
| 23 | pub name: String, |
| 24 | pub email: String, |
| 25 | /// RFC 3339. |
| 26 | pub at: String, |
| 27 | } |
| 28 | |
| 29 | /// Whether a commit's author is g1t itself. |
| 30 | pub fn is_g1t(author: &Authored) -> bool { |
| 31 | let email = author.email.trim(); |
| 32 | email.eq_ignore_ascii_case(G1T_EMAIL) || LEGACY_G1T_EMAILS.iter().any(|legacy| email.eq_ignore_ascii_case(legacy)) |
| 33 | } |
| 34 | |
| 35 | #[derive(Default)] |
| 36 | struct Tally { |
| 37 | kind: Option<ContributorKind>, |
| 38 | name: String, |
| 39 | username: Option<String>, |
| 40 | avatar: Option<String>, |
| 41 | agent: Option<String>, |
| 42 | commits: u32, |
| 43 | first_at: String, |
| 44 | last_at: String, |
| 45 | weeks: BTreeMap<String, u32>, |
| 46 | } |
| 47 | |
| 48 | /// The contributors, most commits first (then by name), at most |
| 49 | /// `MAX_CONTRIBUTORS`, the most active `MAX_CONTRIBUTOR_WEEKS` with their |
| 50 | /// weeks; how many there are; and every commit by week, oldest first, with |
| 51 | /// the empty weeks between. |
| 52 | pub fn tally(commits: &[Authored], owners: &HashMap<String, EmailOwner>) -> (Vec<Contributor>, u32, Vec<WeekCommits>) { |
| 53 | let mut by_key: HashMap<String, Tally> = HashMap::new(); |
| 54 | let mut all_weeks: BTreeMap<String, u32> = BTreeMap::new(); |
| 55 | for commit in commits { |
| 56 | let email = commit.email.trim().to_ascii_lowercase(); |
| 57 | let (key, kind, name, username, avatar, agent) = if is_g1t(commit) { |
| 58 | ("g1t".to_owned(), ContributorKind::G1t, "g1t".to_owned(), None, None, None) |
| 59 | } else if let Some(owner) = owners.get(&email) { |
| 60 | (format!("user:{}", owner.id), ContributorKind::User, owner.username.clone(), Some(owner.username.clone()), owner.avatar.clone(), None) |
| 61 | } else if let Some(agent) = coding_agent_for(&email) { |
| 62 | // One contributor per agent, whichever of its addresses a commit carries. |
| 63 | (format!("agent:{agent}"), ContributorKind::Agent, commit.name.trim().to_owned(), None, None, Some(agent.to_owned())) |
| 64 | } else { |
| 65 | let key = if email.is_empty() { format!("name:{}", commit.name.trim().to_lowercase()) } else { format!("email:{email}") }; |
| 66 | (key, ContributorKind::Author, commit.name.trim().to_owned(), None, None, None) |
| 67 | }; |
| 68 | let tally = by_key.entry(key).or_default(); |
| 69 | if tally.kind.is_none() { |
| 70 | *tally = Tally { kind: Some(kind), name, username, avatar, agent, first_at: commit.at.clone(), last_at: commit.at.clone(), ..Tally::default() }; |
| 71 | } |
| 72 | tally.commits += 1; |
| 73 | if commit.at < tally.first_at { |
| 74 | tally.first_at = commit.at.clone(); |
| 75 | } |
| 76 | if commit.at > tally.last_at { |
| 77 | tally.last_at = commit.at.clone(); |
| 78 | } |
| 79 | if let Some(week) = week_of(&commit.at) { |
| 80 | *tally.weeks.entry(week.clone()).or_default() += 1; |
| 81 | *all_weeks.entry(week).or_default() += 1; |
| 82 | } |
| 83 | } |
| 84 | let total = by_key.len() as u32; |
| 85 | let mut tallies: Vec<Tally> = by_key.into_values().collect(); |
| 86 | tallies.sort_by(|a, b| b.commits.cmp(&a.commits).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); |
| 87 | tallies.truncate(MAX_CONTRIBUTORS); |
| 88 | let contributors = tallies |
| 89 | .into_iter() |
| 90 | .enumerate() |
| 91 | .map(|(rank, tally)| Contributor { |
| 92 | kind: tally.kind.unwrap_or(ContributorKind::Author), |
| 93 | name: tally.name, |
| 94 | username: tally.username, |
| 95 | avatar: tally.avatar, |
| 96 | agent: tally.agent, |
| 97 | commits: tally.commits, |
| 98 | first_at: tally.first_at, |
| 99 | last_at: tally.last_at, |
| 100 | weeks: if rank < MAX_CONTRIBUTOR_WEEKS { |
| 101 | tally.weeks.into_iter().map(|(week, commits)| WeekCommits { week, commits }).collect() |
| 102 | } else { |
| 103 | Vec::new() |
| 104 | }, |
| 105 | }) |
| 106 | .collect(); |
| 107 | (contributors, total, filled(&all_weeks)) |
| 108 | } |
| 109 | |
| 110 | /// Every week from the first to the last, with none for those without. |
| 111 | fn filled(weeks: &BTreeMap<String, u32>) -> Vec<WeekCommits> { |
| 112 | let (Some(first), Some(last)) = (weeks.keys().next(), weeks.keys().next_back()) else { |
| 113 | return Vec::new(); |
| 114 | }; |
| 115 | let mut out = Vec::new(); |
| 116 | let mut week = first.clone(); |
| 117 | // At most twenty years of weeks, whatever the dates say. |
| 118 | while week <= *last && out.len() < 1_100 { |
| 119 | out.push(WeekCommits { commits: weeks.get(&week).copied().unwrap_or(0), week: week.clone() }); |
| 120 | match next_week(&week) { |
| 121 | Some(next) => week = next, |
| 122 | None => break, |
| 123 | } |
| 124 | } |
| 125 | out |
| 126 | } |
| 127 | |
| 128 | #[cfg(test)] |
| 129 | mod tests { |
| 130 | use super::*; |
| 131 | |
| 132 | fn authored(name: &str, email: &str, at: &str) -> Authored { |
| 133 | Authored { name: name.into(), email: email.into(), at: at.into() } |
| 134 | } |
| 135 | |
| 136 | #[test] |
| 137 | fn authors_are_matched_merged_and_ranked() { |
| 138 | let commits = vec![ |
| 139 | authored("Ada", "ada@example.com", "2026-10-07T10:00:00Z"), |
| 140 | authored("Ada L", "ADA@work.example", "2026-09-30T10:00:00Z"), |
| 141 | authored("g1t", G1T_EMAIL, "2026-10-06T10:00:00Z"), |
| 142 | authored("g1t", G1T_EMAIL, "2026-10-01T10:00:00Z"), |
| 143 | authored("g1t", G1T_EMAIL, "2026-09-20T10:00:00Z"), |
| 144 | authored("Sam", "sam@example.com", "2026-09-16T10:00:00Z"), |
| 145 | ]; |
| 146 | let ada = EmailOwner { id: "usr_ada".into(), username: "ada".into(), avatar: Some("abc".into()) }; |
| 147 | let owners = HashMap::from([("ada@example.com".to_owned(), ada.clone()), ("ada@work.example".to_owned(), ada)]); |
| 148 | let (contributors, total, weeks) = tally(&commits, &owners); |
| 149 | assert_eq!(total, 3); |
| 150 | assert_eq!(contributors[0].kind, ContributorKind::G1t); |
| 151 | assert_eq!(contributors[0].commits, 3); |
| 152 | assert_eq!(contributors[1].username.as_deref(), Some("ada")); |
| 153 | assert_eq!(contributors[1].commits, 2, "both of Ada's addresses"); |
| 154 | assert_eq!(contributors[1].first_at, "2026-09-30T10:00:00Z"); |
| 155 | assert_eq!(contributors[1].last_at, "2026-10-07T10:00:00Z"); |
| 156 | assert_eq!(contributors[1].avatar.as_deref(), Some("abc")); |
| 157 | assert_eq!(contributors[2].kind, ContributorKind::Author); |
| 158 | assert_eq!(contributors[2].name, "Sam"); |
| 159 | // Weeks of 2026-09-14 through 2026-10-05, none skipped. |
| 160 | let named: Vec<(&str, u32)> = weeks.iter().map(|week| (week.week.as_str(), week.commits)).collect(); |
| 161 | assert_eq!(named, vec![("2026-09-14", 2), ("2026-09-21", 0), ("2026-09-28", 2), ("2026-10-05", 2)]); |
| 162 | assert_eq!(contributors[1].weeks.len(), 2); |
| 163 | } |
| 164 | |
| 165 | #[test] |
| 166 | fn coding_agents_are_agents_by_their_address_whatever_the_name() { |
| 167 | let commits = vec![ |
| 168 | authored("Claude", "noreply@anthropic.com", "2026-10-01T10:00:00Z"), |
| 169 | authored("claude[bot]", "209825114+claude[bot]@users.noreply.github.com", "2026-10-02T10:00:00Z"), |
| 170 | authored("Claude", "claude@example.com", "2026-10-03T10:00:00Z"), |
| 171 | ]; |
| 172 | let (contributors, total, _) = tally(&commits, &HashMap::new()); |
| 173 | assert_eq!(total, 2); |
| 174 | assert_eq!(contributors[0].kind, ContributorKind::Agent); |
| 175 | assert_eq!(contributors[0].agent.as_deref(), Some("claude")); |
| 176 | assert_eq!(contributors[0].commits, 2, "both of its addresses"); |
| 177 | assert_eq!(contributors[1].kind, ContributorKind::Author, "a person who calls themselves Claude stays a person"); |
| 178 | } |
| 179 | |
| 180 | #[test] |
| 181 | fn g1t_older_addresses_are_g1t() { |
| 182 | let commits = vec![ |
| 183 | authored("g1t agent", "agent@g1t.sh", "2026-10-01T10:00:00Z"), |
| 184 | authored("g1t merge queue", "Queue@g1t.sh", "2026-10-02T10:00:00Z"), |
| 185 | authored("g1t", G1T_EMAIL, "2026-10-07T10:00:00Z"), |
| 186 | authored("Not g1t", "someone@g1t.sh", "2026-10-07T10:00:00Z"), |
| 187 | ]; |
| 188 | let (contributors, total, _) = tally(&commits, &HashMap::new()); |
| 189 | assert_eq!(total, 2); |
| 190 | assert_eq!(contributors[0].kind, ContributorKind::G1t); |
| 191 | assert_eq!(contributors[0].name, "g1t"); |
| 192 | assert_eq!(contributors[0].commits, 3); |
| 193 | assert_eq!(contributors[1].kind, ContributorKind::Author, "only g1t's own addresses"); |
| 194 | } |
| 195 | |
| 196 | #[test] |
| 197 | fn no_history_is_no_one() { |
| 198 | let (contributors, total, weeks) = tally(&[], &HashMap::new()); |
| 199 | assert!(contributors.is_empty() && weeks.is_empty()); |
| 200 | assert_eq!(total, 0); |
| 201 | } |
| 202 | } |