Skip to main content

contributor_graphs/
model.rs

1use serde::Serialize;
2
3/// One manual `matcher -> group` rule, optionally limited to a date window.
4/// Several rules may share a matcher to give a contributor different
5/// affiliations over time; where windows overlap the later `since` wins.
6#[derive(Debug, Clone)]
7pub struct GroupRule {
8    pub matcher: String,
9    pub group: String,
10    /// Inclusive start (unix seconds); `None` means open at the start.
11    pub since: Option<i64>,
12    /// Exclusive end (unix seconds); `None` means open at the end.
13    pub until: Option<i64>,
14}
15
16impl GroupRule {
17    /// Whether this rule's window contains a commit at `ts`.
18    pub fn covers(&self, ts: i64) -> bool {
19        self.since.is_none_or(|s| ts >= s) && self.until.is_none_or(|u| ts < u)
20    }
21    pub fn dated(&self) -> bool {
22        self.since.is_some() || self.until.is_some()
23    }
24}
25
26/// The `git log` filters that affect which commits a source yields. Grouped so
27/// they travel together and form part of the history cache key.
28#[derive(Debug, Clone, Default, PartialEq, Eq)]
29pub struct CommitFilter {
30    pub since: Option<String>,
31    pub until: Option<String>,
32    pub no_merges: bool,
33}
34
35/// A single commit as parsed from `git log`.
36#[derive(Debug, Clone)]
37pub struct Commit {
38    pub sha: String,
39    pub ts: i64,
40    pub name: String,
41    pub email: String,
42    /// `(name, email)` of each `Co-authored-by` trailer on the commit.
43    pub coauthors: Vec<(String, String)>,
44    /// Index of the source this commit came from (see `analyze_many`). 0 for a
45    /// single-source run.
46    pub src: u32,
47}
48
49/// One merged contributor identity, ready for rendering. Also reused for
50/// affiliation aggregates, where one "row" stands for a whole organisation.
51#[derive(Debug, Clone, Serialize)]
52pub struct Contributor {
53    pub name: String,
54    pub login: Option<String>,
55    pub avatar: Option<String>,
56    pub url: Option<String>,
57    pub first: i64,
58    pub last: i64,
59    pub commits: u32,
60    pub bot: bool,
61    pub group: Option<String>,
62    /// Number of people behind this row (1 for an individual; N for an
63    /// affiliation aggregate).
64    pub members: u32,
65    /// Names of the largest contributors in an aggregate (for tooltips).
66    #[serde(skip_serializing_if = "Vec::is_empty")]
67    pub member_names: Vec<String>,
68    /// Month index (months since 1970-01) of the first entry in `months`.
69    pub m0: i32,
70    /// Commits per calendar month, from `m0` through the last active month.
71    /// Includes co-authored commits (full credit); `co_months` is the subset
72    /// so the interactive page can subtract it when co-authors are toggled off.
73    pub months: Vec<u32>,
74    /// Co-authored commits per month, aligned to `m0` (a subset of `months`).
75    #[serde(skip_serializing_if = "Vec::is_empty")]
76    pub co_months: Vec<u32>,
77    /// Of `commits`, how many the person was a co-author on (not the author).
78    #[serde(skip_serializing_if = "is_zero")]
79    pub co_commits: u32,
80    /// Per-month affiliation, aligned to `m0`, when the person has time-bounded
81    /// manual affiliations (so their row is coloured by org over time). `None`
82    /// when a single affiliation applies throughout (the usual case).
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub month_groups: Option<Vec<Option<String>>>,
85}
86
87fn is_zero(n: &u32) -> bool {
88    *n == 0
89}
90
91/// Collapse contributors into one row per affiliation. People without a
92/// detected group fall into a single bucket labelled `unaffiliated`.
93pub fn aggregate_by_group(contributors: &[Contributor], unaffiliated: &str) -> Vec<Contributor> {
94    use std::collections::HashMap;
95
96    struct Agg {
97        commits: u32,
98        co_commits: u32,
99        first: i64,
100        last: i64,
101        months: HashMap<i32, u32>,
102        co_months: HashMap<i32, u32>,
103        members: Vec<(String, u32)>,
104    }
105    let mut order: Vec<String> = Vec::new();
106    let mut map: HashMap<String, Agg> = HashMap::new();
107    macro_rules! agg {
108        ($key:expr) => {
109            map.entry($key.clone()).or_insert_with(|| {
110                order.push($key.clone());
111                Agg {
112                    commits: 0,
113                    co_commits: 0,
114                    first: i64::MAX,
115                    last: i64::MIN,
116                    months: HashMap::new(),
117                    co_months: HashMap::new(),
118                    members: Vec::new(),
119                }
120            })
121        };
122    }
123
124    for c in contributors {
125        let default_key = c.group.clone().unwrap_or_else(|| unaffiliated.to_string());
126        match &c.month_groups {
127            // Single affiliation throughout: the whole person joins one group.
128            None => {
129                let a = agg!(default_key);
130                a.commits += c.commits;
131                a.co_commits += c.co_commits;
132                a.first = a.first.min(c.first);
133                a.last = a.last.max(c.last);
134                a.members.push((c.name.clone(), c.commits));
135                for (i, &v) in c.months.iter().enumerate() {
136                    if v > 0 {
137                        *a.months.entry(c.m0 + i as i32).or_insert(0) += v;
138                    }
139                }
140                for (i, &v) in c.co_months.iter().enumerate() {
141                    if v > 0 {
142                        *a.co_months.entry(c.m0 + i as i32).or_insert(0) += v;
143                    }
144                }
145            }
146            // Time-bounded affiliations: split each month into its active org.
147            Some(mg) => {
148                let key_at = |i: usize| {
149                    mg.get(i)
150                        .cloned()
151                        .flatten()
152                        .unwrap_or_else(|| default_key.clone())
153                };
154                let mut per_group: HashMap<String, u32> = HashMap::new();
155                for (i, &v) in c.months.iter().enumerate() {
156                    if v == 0 {
157                        continue;
158                    }
159                    let key = key_at(i);
160                    let m = c.m0 + i as i32;
161                    let ts = month_start_ts(m);
162                    let a = agg!(key);
163                    *a.months.entry(m).or_insert(0) += v;
164                    a.commits += v;
165                    a.first = a.first.min(ts);
166                    a.last = a.last.max(ts);
167                    *per_group.entry(key).or_insert(0) += v;
168                }
169                for (i, &v) in c.co_months.iter().enumerate() {
170                    if v == 0 {
171                        continue;
172                    }
173                    let key = key_at(i);
174                    let a = agg!(key);
175                    *a.co_months.entry(c.m0 + i as i32).or_insert(0) += v;
176                    a.co_commits += v;
177                }
178                for (key, n) in per_group {
179                    agg!(key).members.push((c.name.clone(), n));
180                }
181            }
182        }
183    }
184
185    order
186        .into_iter()
187        .map(|key| {
188            let agg = map.remove(&key).unwrap();
189            let m0 = *agg.months.keys().min().unwrap_or(&month_index(agg.first));
190            let m1 = *agg.months.keys().max().unwrap_or(&m0);
191            let len = (m1 - m0 + 1).clamp(1, 6000) as usize;
192            let mut months = vec![0u32; len];
193            for (&m, &v) in &agg.months {
194                if let Some(slot) = months.get_mut((m - m0) as usize) {
195                    *slot += v;
196                }
197            }
198            let mut co_months = vec![0u32; if agg.co_months.is_empty() { 0 } else { len }];
199            for (&m, &v) in &agg.co_months {
200                if let Some(slot) = co_months.get_mut((m - m0) as usize) {
201                    *slot += v;
202                }
203            }
204            let mut members = agg.members;
205            members.sort_by_key(|(_, commits)| std::cmp::Reverse(*commits));
206            let member_count = members.len() as u32;
207            let member_names = members.into_iter().take(8).map(|(n, _)| n).collect();
208            Contributor {
209                name: key.clone(),
210                login: None,
211                avatar: None,
212                url: None,
213                first: agg.first,
214                last: agg.last,
215                commits: agg.commits,
216                bot: false,
217                group: Some(key),
218                members: member_count,
219                member_names,
220                m0,
221                months,
222                co_months,
223                co_commits: agg.co_commits,
224                month_groups: None,
225            }
226        })
227        .collect()
228}
229
230#[derive(Debug, Clone, Serialize)]
231pub struct RepoMeta {
232    pub name: String,
233    pub url: Option<String>,
234    pub slug: Option<String>,
235    pub branch: String,
236    pub first: i64,
237    pub last: i64,
238    pub total_commits: u64,
239    pub total_contributors: usize,
240    pub generated: String,
241    /// Owner/org avatar as a data URI, for the interactive page header.
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub owner_avatar: Option<String>,
244    /// The GitHub repository description, when available.
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub description: Option<String>,
247}
248
249pub fn month_index(ts: i64) -> i32 {
250    use chrono::{Datelike, TimeZone, Utc};
251    let dt = Utc.timestamp_opt(ts, 0).single().unwrap_or_default();
252    (dt.year() - 1970) * 12 + dt.month0() as i32
253}
254
255pub fn month_start_ts(mi: i32) -> i64 {
256    use chrono::{TimeZone, Utc};
257    let year = 1970 + mi.div_euclid(12);
258    let month = mi.rem_euclid(12) as u32 + 1;
259    Utc.with_ymd_and_hms(year, month, 1, 0, 0, 0)
260        .single()
261        .map(|d| d.timestamp())
262        .unwrap_or_default()
263}
264
265pub fn format_month_year(ts: i64) -> String {
266    use chrono::{TimeZone, Utc};
267    Utc.timestamp_opt(ts, 0)
268        .single()
269        .map(|d| d.format("%b %Y").to_string())
270        .unwrap_or_default()
271}
272
273pub fn thousands(n: u64) -> String {
274    let s = n.to_string();
275    let mut out = String::new();
276    for (i, c) in s.chars().enumerate() {
277        if i > 0 && (s.len() - i).is_multiple_of(3) {
278            out.push(',');
279        }
280        out.push(c);
281    }
282    out
283}