use std::collections::{BTreeMap, HashMap, HashSet};
use chrono::{DateTime, Datelike, Utc};
use regex::Regex;
use tracing::{debug, warn};
use crate::core::config::Config;
use crate::core::db::Database;
use crate::report::errors::Result;
use crate::report::models::{
ActivityWeights, AuthorSummary, DeveloperActivitySummary, DoraMetrics, QualitySummary,
ReportData, ReportSummary, RepositorySummary, UntrackedCommit, VelocitySummary, WeeklyActivity,
WeeklyCategorization, WeeklyMetrics, WeeklyVelocity,
};
pub struct Aggregator;
struct CommitRow {
sha: String,
author_name: String,
author_email: String,
timestamp: DateTime<Utc>,
repository: String,
insertions: i64,
deletions: i64,
files_changed: i64,
category: Option<String>,
message: String,
ticketed: bool,
}
struct PrRow {
created_at: DateTime<Utc>,
merged_at: Option<DateTime<Utc>>,
}
const DEFAULT_BOILERPLATE_PATTERNS: &[&str] = &[
r"^[Mm]erge branch",
r"^[Mm]erge pull request",
r"^[Bb]ump version",
r"^[Uu]pdate package-lock",
r"^[Uu]pdate yarn\.lock",
r"[Gg]enerated by",
r"[Aa]uto-generated",
];
const DEFAULT_REVERT_PATTERNS: &[&str] = &[r"^[Rr]evert", r"^[Ff]ix.*[Rr]evert"];
const BOILERPLATE_LINES_THRESHOLD: i64 = 500;
fn is_boilerplate(message: &str, lines_changed: i64, patterns: &[Regex]) -> bool {
let first_line = message.lines().next().unwrap_or(message);
if lines_changed > BOILERPLATE_LINES_THRESHOLD {
if lines_changed > BOILERPLATE_LINES_THRESHOLD * 10 {
return true;
}
}
patterns.iter().any(|p| p.is_match(first_line))
}
fn is_revert(message: &str, patterns: &[Regex]) -> bool {
let first_line = message.lines().next().unwrap_or(message);
patterns.iter().any(|p| p.is_match(first_line))
}
fn compile_patterns(patterns: &[&str]) -> Vec<Regex> {
patterns
.iter()
.filter_map(|p| match Regex::new(p) {
Ok(r) => Some(r),
Err(e) => {
warn!(pattern = %p, error = %e, "skipping invalid regex pattern");
None
}
})
.collect()
}
impl Aggregator {
pub fn build(db: &Database, config: &Config) -> Result<ReportData> {
let rows = Self::load_rows(db)?;
let prs = Self::load_prs(db).unwrap_or_default();
let unresolved_db = Self::count_unresolved_author_commits(db).unwrap_or(0);
let mut data = Self::aggregate(rows, prs);
data.repository_coverage = data.repositories.len();
let alias_set = configured_alias_emails(config);
let unresolved_authors = if alias_set.is_empty() {
0
} else {
data.authors
.iter()
.filter(|a| !alias_set.contains(&a.email.to_lowercase()))
.count()
};
data.unresolved_authors = unresolved_authors;
data.unresolved_author_commits = unresolved_db;
check_weekly_coverage_drift(db, &data.weekly_metrics);
if unresolved_db > 0 {
tracing::warn!(
count = unresolved_db,
"WARNING: {unresolved_db} commits have unresolved author identities and may \
inflate developer counts. Run `tga aliases list` to review, or extend \
`developer_aliases` in the config to map missing identities."
);
}
Ok(data)
}
fn count_unresolved_author_commits(db: &Database) -> Result<usize> {
let conn = db.connection();
let n: i64 = conn
.query_row(
"SELECT COUNT(*) FROM commits WHERE author_id IS NULL",
[],
|r| r.get(0),
)
.map_err(crate::core::TgaError::from)?;
Ok(n as usize)
}
fn load_prs(db: &Database) -> Result<Vec<PrRow>> {
let conn = db.connection();
let mut stmt = conn
.prepare("SELECT created_at, merged_at FROM pull_requests")
.map_err(crate::core::TgaError::from)?;
let rows = stmt
.query_map([], |row| {
let created: String = row.get(0)?;
let merged: Option<String> = row.get(1)?;
Ok((created, merged))
})
.map_err(crate::core::TgaError::from)?;
let mut out = Vec::new();
for r in rows {
let (created_s, merged_s) = r.map_err(crate::core::TgaError::from)?;
let created_at = match DateTime::parse_from_rfc3339(&created_s) {
Ok(dt) => dt.with_timezone(&Utc),
Err(_) => continue,
};
let merged_at = merged_s
.as_deref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc));
out.push(PrRow {
created_at,
merged_at,
});
}
Ok(out)
}
fn load_rows(db: &Database) -> Result<Vec<CommitRow>> {
let conn = db.connection();
let mut stmt = conn
.prepare(
"SELECT c.sha, \
COALESCE(a.canonical_name, c.author_name) AS author_name, \
COALESCE(NULLIF(a.canonical_email, ''), c.author_email) AS author_email, \
c.timestamp, c.repository, \
c.insertions, c.deletions, c.files_changed, cl.category, \
c.message, c.ticketed \
FROM commits c \
LEFT JOIN authors a ON a.id = c.author_id \
LEFT JOIN classifications cl ON cl.id = c.classification_id",
)
.map_err(crate::core::TgaError::from)?;
let rows = stmt
.query_map([], |row| {
let ts_str: String = row.get(3)?;
let timestamp = DateTime::parse_from_rfc3339(&ts_str)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now());
let ticketed: i64 = row.get(10).unwrap_or(0);
Ok(CommitRow {
sha: row.get(0)?,
author_name: row.get(1)?,
author_email: row.get(2)?,
timestamp,
repository: row.get(4)?,
insertions: row.get(5)?,
deletions: row.get(6)?,
files_changed: row.get(7)?,
category: row.get(8)?,
message: row.get(9)?,
ticketed: ticketed != 0,
})
})
.map_err(crate::core::TgaError::from)?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(crate::core::TgaError::from)?);
}
debug!(count = out.len(), "loaded commit rows for aggregation");
Ok(out)
}
fn aggregate(rows: Vec<CommitRow>, prs: Vec<PrRow>) -> ReportData {
let generated_at = Utc::now().to_rfc3339();
let mut data = ReportData::empty(generated_at);
if rows.is_empty() {
return data;
}
let boilerplate_re = compile_patterns(DEFAULT_BOILERPLATE_PATTERNS);
let revert_re = compile_patterns(DEFAULT_REVERT_PATTERNS);
let mut row_is_boilerplate: Vec<bool> = Vec::with_capacity(rows.len());
let mut row_is_revert: Vec<bool> = Vec::with_capacity(rows.len());
for row in &rows {
let lines = row.insertions + row.deletions;
row_is_boilerplate.push(is_boilerplate(&row.message, lines, &boilerplate_re));
row_is_revert.push(is_revert(&row.message, &revert_re));
}
let boilerplate_count = row_is_boilerplate.iter().filter(|b| **b).count();
let revert_count = row_is_revert.iter().filter(|b| **b).count();
let mut min_ts = rows[0].timestamp;
let mut max_ts = rows[0].timestamp;
struct AuthorAcc {
name: String,
email: String,
commits: usize,
insertions: i64,
deletions: i64,
files_changed: i64,
categories: HashMap<String, usize>,
first: DateTime<Utc>,
last: DateTime<Utc>,
}
let mut authors: HashMap<String, AuthorAcc> = HashMap::new();
struct RepoAcc {
commits: usize,
authors: HashSet<String>,
insertions: i64,
deletions: i64,
categories: HashMap<String, usize>,
}
let mut repos: HashMap<String, RepoAcc> = HashMap::new();
struct WeekAcc {
commits: usize,
insertions: i64,
deletions: i64,
categories: HashMap<String, usize>,
}
let mut weekly: BTreeMap<(String, String, String), WeekAcc> = BTreeMap::new();
let mut category_total: HashMap<String, usize> = HashMap::new();
#[derive(Default)]
struct WeekTotal {
commits: usize,
categories: HashMap<String, usize>,
developers: HashSet<String>,
}
let mut week_totals: BTreeMap<String, WeekTotal> = BTreeMap::new();
let mut dev_weeks: HashMap<String, HashSet<String>> = HashMap::new();
let mut dev_categories: HashMap<String, HashMap<String, usize>> = HashMap::new();
let mut dev_ticketed: HashMap<String, usize> = HashMap::new();
for (idx, row) in rows.iter().enumerate() {
if row.timestamp < min_ts {
min_ts = row.timestamp;
}
if row.timestamp > max_ts {
max_ts = row.timestamp;
}
let key = row.author_email.clone();
let a = authors.entry(key).or_insert_with(|| AuthorAcc {
name: row.author_name.clone(),
email: row.author_email.clone(),
commits: 0,
insertions: 0,
deletions: 0,
files_changed: 0,
categories: HashMap::new(),
first: row.timestamp,
last: row.timestamp,
});
if row.author_name.len() > a.name.len() {
a.name = row.author_name.clone();
}
a.commits += 1;
a.insertions += row.insertions;
a.deletions += row.deletions;
a.files_changed += row.files_changed;
if row.timestamp < a.first {
a.first = row.timestamp;
}
if row.timestamp > a.last {
a.last = row.timestamp;
}
if let Some(cat) = &row.category {
*a.categories.entry(cat.clone()).or_insert(0) += 1;
}
let r = repos
.entry(row.repository.clone())
.or_insert_with(|| RepoAcc {
commits: 0,
authors: HashSet::new(),
insertions: 0,
deletions: 0,
categories: HashMap::new(),
});
r.commits += 1;
r.authors.insert(row.author_email.clone());
r.insertions += row.insertions;
r.deletions += row.deletions;
if let Some(cat) = &row.category {
*r.categories.entry(cat.clone()).or_insert(0) += 1;
}
let week = iso_week_label(&row.timestamp);
let wkey = (week, row.author_email.clone(), row.repository.clone());
let w = weekly.entry(wkey).or_insert_with(|| WeekAcc {
commits: 0,
insertions: 0,
deletions: 0,
categories: HashMap::new(),
});
w.commits += 1;
w.insertions += row.insertions;
w.deletions += row.deletions;
if let Some(cat) = &row.category {
*w.categories.entry(cat.clone()).or_insert(0) += 1;
}
if let Some(cat) = &row.category {
*category_total.entry(cat.clone()).or_insert(0) += 1;
}
let week_label = iso_week_label(&row.timestamp);
let wt = week_totals.entry(week_label.clone()).or_default();
wt.commits += 1;
wt.developers.insert(row.author_email.clone());
if row_is_boilerplate[idx] {
*wt.categories.entry("boilerplate".to_string()).or_insert(0) += 1;
} else if let Some(cat) = &row.category {
*wt.categories.entry(cat.clone()).or_insert(0) += 1;
} else {
*wt.categories.entry("unclassified".to_string()).or_insert(0) += 1;
}
dev_weeks
.entry(row.author_email.clone())
.or_default()
.insert(week_label);
if let Some(cat) = &row.category {
*dev_categories
.entry(row.author_email.clone())
.or_default()
.entry(cat.clone())
.or_insert(0) += 1;
}
if row.ticketed {
*dev_ticketed.entry(row.author_email.clone()).or_insert(0) += 1;
}
}
let mut author_summaries: Vec<AuthorSummary> = authors
.into_values()
.map(|a| AuthorSummary {
name: a.name,
email: a.email,
commit_count: a.commits,
insertions: a.insertions,
deletions: a.deletions,
files_changed: a.files_changed,
categories: a.categories,
first_commit: a.first.to_rfc3339(),
last_commit: a.last.to_rfc3339(),
})
.collect();
author_summaries.sort_by_key(|a| std::cmp::Reverse(a.commit_count));
let mut repo_summaries: Vec<RepositorySummary> = repos
.into_iter()
.map(|(name, r)| {
let mut top: Vec<(String, usize)> = r.categories.into_iter().collect();
top.sort_by_key(|t| std::cmp::Reverse(t.1));
RepositorySummary {
name,
commit_count: r.commits,
author_count: r.authors.len(),
insertions: r.insertions,
deletions: r.deletions,
top_categories: top,
}
})
.collect();
repo_summaries.sort_by_key(|r| std::cmp::Reverse(r.commit_count));
let email_to_name: HashMap<String, String> = author_summaries
.iter()
.map(|a| (a.email.clone(), a.name.clone()))
.collect();
let weekly_activity: Vec<WeeklyActivity> = weekly
.into_iter()
.map(|((week, email, repository), w)| WeeklyActivity {
week,
author: email_to_name.get(&email).cloned().unwrap_or(email),
repository,
commit_count: w.commits,
insertions: w.insertions,
deletions: w.deletions,
categories: w.categories,
})
.collect();
let total_commits = rows.len();
let total_authors = author_summaries.len();
let total_weeks = week_totals.len();
let weekly_metrics: Vec<WeeklyMetrics> = week_totals
.iter()
.map(|(week, wt)| WeeklyMetrics {
week: week.clone(),
total_commits: wt.commits,
feature_commits: *wt.categories.get("feature").unwrap_or(&0),
bugfix_commits: *wt.categories.get("bugfix").unwrap_or(&0),
maintenance_commits: *wt.categories.get("maintenance").unwrap_or(&0),
refactor_commits: *wt.categories.get("refactor").unwrap_or(&0),
test_commits: *wt.categories.get("test").unwrap_or(&0),
doc_commits: *wt.categories.get("documentation").unwrap_or(&0)
+ *wt.categories.get("docs").unwrap_or(&0),
active_developers: wt.developers.len(),
story_points: 0.0,
})
.collect();
let mut weekly_categorization: Vec<WeeklyCategorization> = Vec::new();
for (week, wt) in &week_totals {
let total = wt.commits as f64;
let mut entries: Vec<(&String, &usize)> = wt.categories.iter().collect();
entries.sort_by_key(|e| e.0);
for (cat, count) in entries {
weekly_categorization.push(WeeklyCategorization {
week: week.clone(),
change_type: cat.clone(),
commit_count: *count,
pct_of_week: if total > 0.0 {
(*count as f64) * 100.0 / total
} else {
0.0
},
});
}
}
let mut untracked_commits: Vec<UntrackedCommit> = rows
.iter()
.filter(|r| !r.ticketed && r.category.as_deref() != Some("boilerplate"))
.filter(|r| {
r.category.is_none() || r.category.as_deref() == Some("unclassified") || !r.ticketed
})
.map(|r| UntrackedCommit {
sha: r.sha.clone(),
author: email_to_name
.get(&r.author_email)
.cloned()
.unwrap_or_else(|| r.author_name.clone()),
date: r.timestamp.to_rfc3339(),
message: r.message.lines().next().unwrap_or("").to_string(),
})
.collect();
untracked_commits.sort_by(|a, b| b.date.cmp(&a.date));
let mut cycle_times: Vec<f64> = prs
.iter()
.filter_map(|p| {
p.merged_at.map(|m| {
let secs = (m - p.created_at).num_seconds();
(secs as f64) / 3600.0
})
})
.filter(|h| *h >= 0.5 && *h <= 720.0)
.collect();
cycle_times.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let pr_count = cycle_times.len();
let cycle_time_avg = if pr_count == 0 {
0.0
} else {
cycle_times.iter().sum::<f64>() / pr_count as f64
};
let cycle_time_median = if pr_count == 0 {
0.0
} else {
cycle_times[pr_count / 2]
};
let mut pr_per_week: HashMap<String, usize> = HashMap::new();
for pr in &prs {
if let Some(merged) = pr.merged_at {
*pr_per_week.entry(iso_week_label(&merged)).or_insert(0) += 1;
}
}
let pr_throughput_per_week = if pr_per_week.is_empty() {
0.0
} else {
pr_per_week.values().copied().sum::<usize>() as f64 / pr_per_week.len() as f64
};
let velocity = Some(VelocitySummary {
pr_cycle_time_avg_hours: cycle_time_avg,
pr_cycle_time_median_hours: cycle_time_median,
pr_throughput_per_week,
revision_rate: 0.0,
pr_count,
});
let weekly_velocity: Vec<WeeklyVelocity> = week_totals
.iter()
.map(|(week, wt)| {
let prs_merged = *pr_per_week.get(week).unwrap_or(&0);
let active = wt.developers.len();
let commits_per_dev = if active == 0 {
0.0
} else {
wt.commits as f64 / active as f64
};
WeeklyVelocity {
week: week.clone(),
prs_merged,
avg_pr_cycle_time_hours: cycle_time_avg,
story_points: 0.0,
commits_per_developer: commits_per_dev,
}
})
.collect();
let total_weeks_f = total_weeks.max(1) as f64;
let deploys = prs.iter().filter(|p| p.merged_at.is_some()).count();
let deployment_frequency = deploys as f64 / total_weeks_f;
let bugfix_total = category_total
.get("bugfix")
.copied()
.unwrap_or(0)
.max(revert_count);
let change_failure_rate = if total_commits == 0 {
0.0
} else {
bugfix_total as f64 / total_commits as f64
};
let mut bugfix_ts: Vec<DateTime<Utc>> = rows
.iter()
.zip(row_is_revert.iter())
.filter(|(r, is_rev)| **is_rev || r.category.as_deref() == Some("bugfix"))
.map(|(r, _)| r.timestamp)
.collect();
bugfix_ts.sort();
let mttr_hours = if bugfix_ts.len() < 2 {
0.0
} else {
let mut gaps: Vec<f64> = Vec::new();
for w in bugfix_ts.windows(2) {
let secs = (w[1] - w[0]).num_seconds().abs();
gaps.push(secs as f64 / 3600.0);
}
gaps.iter().sum::<f64>() / gaps.len() as f64
};
let performance_level = dora_level(
deployment_frequency,
cycle_time_avg,
change_failure_rate,
mttr_hours,
);
let dora = Some(DoraMetrics {
deployment_frequency,
lead_time_hours: cycle_time_avg,
change_failure_rate,
mttr_hours,
performance_level,
});
let bugfix_pct = if total_commits == 0 {
0.0
} else {
bugfix_total as f64 / total_commits as f64
};
let revert_pct = if total_commits == 0 {
0.0
} else {
revert_count as f64 / total_commits as f64
};
let raw_quality = 1.0 - (bugfix_pct * 0.4) - (revert_pct * 0.6);
let quality_score = raw_quality.clamp(0.0, 1.0);
let non_bugfix = total_commits.saturating_sub(bugfix_total);
let defect_rate = if non_bugfix == 0 {
0.0
} else {
bugfix_total as f64 / non_bugfix as f64
};
let quality = Some(QualitySummary {
quality_score,
revert_count,
revert_pct,
bugfix_pct,
defect_rate,
});
let weights = ActivityWeights::default();
let developer_activity =
compute_developer_activity(&author_summaries, &dev_weeks, &dev_categories, &weights);
let classified_commits = rows.iter().filter(|r| r.category.is_some()).count();
let classification_coverage_pct = if total_commits == 0 {
0.0
} else {
classified_commits as f64 * 100.0 / total_commits as f64
};
let date_range = format!("{} .. {}", min_ts.to_rfc3339(), max_ts.to_rfc3339());
let summary = Some(ReportSummary {
date_range,
total_commits,
total_developers: total_authors,
total_weeks,
classification_coverage_pct,
});
data.total_commits = total_commits;
data.total_authors = total_authors;
data.period_start = Some(min_ts.to_rfc3339());
data.period_end = Some(max_ts.to_rfc3339());
data.authors = author_summaries;
data.repositories = repo_summaries;
data.weekly_activity = weekly_activity;
data.category_breakdown = category_total;
data.weekly_metrics = weekly_metrics;
data.developer_activity = developer_activity;
data.summary = summary;
data.untracked_commits = untracked_commits;
data.weekly_categorization = weekly_categorization;
data.weekly_velocity = weekly_velocity;
data.dora = dora;
data.velocity = velocity;
data.quality = quality;
data.boilerplate_count = boilerplate_count;
data.revert_count = revert_count;
let _ = dev_ticketed;
data
}
}
fn compute_developer_activity(
authors: &[AuthorSummary],
dev_weeks: &HashMap<String, HashSet<String>>,
dev_categories: &HashMap<String, HashMap<String, usize>>,
weights: &ActivityWeights,
) -> Vec<DeveloperActivitySummary> {
if authors.is_empty() {
return Vec::new();
}
fn norm(values: &[f64], idx: usize) -> f64 {
let min = values.iter().copied().fold(f64::INFINITY, f64::min);
let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
if (max - min).abs() < f64::EPSILON {
0.0
} else {
(values[idx] - min) / (max - min)
}
}
let commits_v: Vec<f64> = authors.iter().map(|a| a.commit_count as f64).collect();
let impact_v: Vec<f64> = authors
.iter()
.map(|a| (a.insertions + a.deletions) as f64)
.collect();
let complexity_v: Vec<f64> = authors
.iter()
.map(|a| {
if a.commit_count == 0 {
0.0
} else {
a.files_changed as f64 / a.commit_count as f64
}
})
.collect();
let prs_v: Vec<f64> = vec![0.0; authors.len()];
let ticketing_v: Vec<f64> = authors
.iter()
.map(|a| a.categories.values().copied().sum::<usize>() as f64)
.collect();
authors
.iter()
.enumerate()
.map(|(i, a)| {
let score = weights.commits * norm(&commits_v, i)
+ weights.prs * norm(&prs_v, i)
+ weights.code_impact * norm(&impact_v, i)
+ weights.complexity * norm(&complexity_v, i)
+ weights.ticketing * norm(&ticketing_v, i);
let active_weeks = dev_weeks.get(&a.email).map(|s| s.len()).unwrap_or(0);
let avg_commits_per_week = if active_weeks == 0 {
0.0
} else {
a.commit_count as f64 / active_weeks as f64
};
let primary_work_type = dev_categories
.get(&a.email)
.and_then(|m| m.iter().max_by_key(|(_, v)| **v).map(|(k, _)| k.clone()))
.unwrap_or_else(|| "unknown".to_string());
DeveloperActivitySummary {
developer_id: a.email.clone(),
display_name: a.name.clone(),
total_commits: a.commit_count,
active_weeks,
avg_commits_per_week,
primary_work_type,
story_points_total: 0.0,
activity_score: score,
}
})
.collect()
}
fn dora_level(deploys_per_week: f64, lead_h: f64, cfr: f64, mttr_h: f64) -> String {
let elite = deploys_per_week >= 1.0 && lead_h < 1.0 && cfr < 0.15 && mttr_h < 1.0;
if elite {
return "elite".to_string();
}
let high = deploys_per_week >= 0.25 && lead_h < 168.0 && cfr < 0.30 && mttr_h < 24.0;
if high {
return "high".to_string();
}
let medium = deploys_per_week >= 0.04 && lead_h < 720.0 && cfr < 0.30 && mttr_h < 168.0;
if medium {
return "medium".to_string();
}
"low".to_string()
}
fn parse_iso_week_label(label: &str) -> Option<(i32, u32)> {
let (year_s, week_s) = label.split_once("-W")?;
let year: i32 = year_s.parse().ok()?;
let week: u32 = week_s.parse().ok()?;
Some((year, week))
}
fn check_weekly_coverage_drift(
db: &Database,
weekly_metrics: &[crate::report::models::WeeklyMetrics],
) {
if weekly_metrics.len() < 2 {
return;
}
let mut prev: Option<(String, i64)> = None;
for wm in weekly_metrics {
let (year, week) = match parse_iso_week_label(&wm.week) {
Some(v) => v,
None => continue,
};
let count = match crate::core::db::repo_count_for_week(db, year, week) {
Ok(Some(n)) => n,
_ => continue,
};
if let Some((prev_label, prev_count)) = &prev {
if *prev_count != count {
tracing::warn!(
prev_week = %prev_label,
prev_repo_count = prev_count,
week = %wm.week,
repo_count = count,
"WARNING: Week-over-week comparison may be inaccurate — W{prev} was \
collected with {n_prev} repos, W{cur} with {n_cur} repos. Re-run \
`tga collect --force --from <week-start> --to <week-end>` for the \
prior week to normalize coverage.",
prev = prev_label,
n_prev = prev_count,
cur = wm.week,
n_cur = count,
);
}
}
prev = Some((wm.week.clone(), count));
}
}
fn configured_alias_emails(config: &Config) -> HashSet<String> {
let mut out: HashSet<String> = HashSet::new();
for entries in config.developer_aliases.values() {
for e in entries {
if e.contains('@') {
out.insert(e.to_lowercase());
}
}
}
if let Some(team) = &config.team {
for m in &team.members {
if m.email.contains('@') {
out.insert(m.email.to_lowercase());
}
for a in &m.aliases {
if a.contains('@') {
out.insert(a.to_lowercase());
}
}
}
}
out
}
fn iso_week_label(ts: &DateTime<Utc>) -> String {
let iso = ts.iso_week();
format!("{}-W{:02}", iso.year(), iso.week())
}