Skip to main content

contributor_graphs/
model.rs

1use serde::Serialize;
2
3/// A single commit as parsed from `git log`.
4#[derive(Debug, Clone)]
5pub struct Commit {
6    pub sha: String,
7    pub ts: i64,
8    pub name: String,
9    pub email: String,
10}
11
12/// One merged contributor identity, ready for rendering. Also reused for
13/// affiliation aggregates, where one "row" stands for a whole organisation.
14#[derive(Debug, Clone, Serialize)]
15pub struct Contributor {
16    pub name: String,
17    pub login: Option<String>,
18    pub avatar: Option<String>,
19    pub url: Option<String>,
20    pub first: i64,
21    pub last: i64,
22    pub commits: u32,
23    pub bot: bool,
24    pub group: Option<String>,
25    /// Number of people behind this row (1 for an individual; N for an
26    /// affiliation aggregate).
27    pub members: u32,
28    /// Names of the largest contributors in an aggregate (for tooltips).
29    #[serde(skip_serializing_if = "Vec::is_empty")]
30    pub member_names: Vec<String>,
31    /// Month index (months since 1970-01) of the first entry in `months`.
32    pub m0: i32,
33    /// Commits per calendar month, from `m0` through the last active month.
34    pub months: Vec<u32>,
35}
36
37/// Collapse contributors into one row per affiliation. People without a
38/// detected group fall into a single bucket labelled `unaffiliated`.
39pub fn aggregate_by_group(contributors: &[Contributor], unaffiliated: &str) -> Vec<Contributor> {
40    use std::collections::HashMap;
41
42    struct Agg {
43        commits: u32,
44        first: i64,
45        last: i64,
46        months: HashMap<i32, u32>,
47        members: Vec<(String, u32)>,
48    }
49    let mut order: Vec<String> = Vec::new();
50    let mut map: HashMap<String, Agg> = HashMap::new();
51
52    for c in contributors {
53        let key = c.group.clone().unwrap_or_else(|| unaffiliated.to_string());
54        let agg = map.entry(key.clone()).or_insert_with(|| {
55            order.push(key.clone());
56            Agg {
57                commits: 0,
58                first: i64::MAX,
59                last: i64::MIN,
60                months: HashMap::new(),
61                members: Vec::new(),
62            }
63        });
64        agg.commits += c.commits;
65        agg.first = agg.first.min(c.first);
66        agg.last = agg.last.max(c.last);
67        agg.members.push((c.name.clone(), c.commits));
68        for (i, &v) in c.months.iter().enumerate() {
69            if v > 0 {
70                *agg.months.entry(c.m0 + i as i32).or_insert(0) += v;
71            }
72        }
73    }
74
75    order
76        .into_iter()
77        .map(|key| {
78            let agg = map.remove(&key).unwrap();
79            let m0 = *agg.months.keys().min().unwrap_or(&month_index(agg.first));
80            let m1 = *agg.months.keys().max().unwrap_or(&m0);
81            let len = (m1 - m0 + 1).clamp(1, 6000) as usize;
82            let mut months = vec![0u32; len];
83            for (&m, &v) in &agg.months {
84                if let Some(slot) = months.get_mut((m - m0) as usize) {
85                    *slot += v;
86                }
87            }
88            let mut members = agg.members;
89            members.sort_by_key(|(_, commits)| std::cmp::Reverse(*commits));
90            let member_count = members.len() as u32;
91            let member_names = members.into_iter().take(8).map(|(n, _)| n).collect();
92            Contributor {
93                name: key.clone(),
94                login: None,
95                avatar: None,
96                url: None,
97                first: agg.first,
98                last: agg.last,
99                commits: agg.commits,
100                bot: false,
101                group: Some(key),
102                members: member_count,
103                member_names,
104                m0,
105                months,
106            }
107        })
108        .collect()
109}
110
111#[derive(Debug, Clone, Serialize)]
112pub struct RepoMeta {
113    pub name: String,
114    pub url: Option<String>,
115    pub slug: Option<String>,
116    pub branch: String,
117    pub first: i64,
118    pub last: i64,
119    pub total_commits: u64,
120    pub total_contributors: usize,
121    pub generated: String,
122    /// Owner/org avatar as a data URI, for the interactive page header.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub owner_avatar: Option<String>,
125}
126
127pub fn month_index(ts: i64) -> i32 {
128    use chrono::{Datelike, TimeZone, Utc};
129    let dt = Utc.timestamp_opt(ts, 0).single().unwrap_or_default();
130    (dt.year() - 1970) * 12 + dt.month0() as i32
131}
132
133pub fn month_start_ts(mi: i32) -> i64 {
134    use chrono::{TimeZone, Utc};
135    let year = 1970 + mi.div_euclid(12);
136    let month = mi.rem_euclid(12) as u32 + 1;
137    Utc.with_ymd_and_hms(year, month, 1, 0, 0, 0)
138        .single()
139        .map(|d| d.timestamp())
140        .unwrap_or_default()
141}
142
143pub fn format_month_year(ts: i64) -> String {
144    use chrono::{TimeZone, Utc};
145    Utc.timestamp_opt(ts, 0)
146        .single()
147        .map(|d| d.format("%b %Y").to_string())
148        .unwrap_or_default()
149}
150
151pub fn thousands(n: u64) -> String {
152    let s = n.to_string();
153    let mut out = String::new();
154    for (i, c) in s.chars().enumerate() {
155        if i > 0 && (s.len() - i).is_multiple_of(3) {
156            out.push(',');
157        }
158        out.push(c);
159    }
160    out
161}