use std::collections::{BTreeMap, HashMap, HashSet};
use chrono::{DateTime, Utc};
use crate::core::config::Config;
use crate::core::db::Database;
use crate::report::models::{
ActivityWeights, AuthorSummary, DeveloperActivitySummary, DoraMetrics, QualitySummary,
ReportSummary, WeeklyVelocity,
};
use super::accumulate::{iso_week_label, RowFlags, WeekTotal};
use super::{CommitRow, PrRow};
pub(super) struct VelocityInputs {
pub(super) cycle_time_avg: f64,
pub(super) cycle_time_median: f64,
pub(super) pr_throughput_per_week: f64,
pub(super) pr_count: usize,
pub(super) pr_per_week: HashMap<String, usize>,
}
pub(super) fn compute_velocity_inputs(prs: &[PrRow]) -> VelocityInputs {
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
};
VelocityInputs {
cycle_time_avg,
cycle_time_median,
pr_throughput_per_week,
pr_count,
pr_per_week,
}
}
pub(super) fn build_weekly_velocity(
week_totals: &BTreeMap<String, WeekTotal>,
pr_per_week: &HashMap<String, usize>,
cycle_time_avg: f64,
) -> 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()
}
pub(super) fn compute_dora(
rows: &[CommitRow],
flags: &RowFlags,
category_total: &HashMap<String, usize>,
prs: &[PrRow],
cycle_time_avg: f64,
total_weeks: usize,
revert_count: usize,
) -> DoraMetrics {
let total_weeks_f = total_weeks.max(1) as f64;
let total_commits = rows.len();
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(flags.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,
);
DoraMetrics {
deployment_frequency,
lead_time_hours: cycle_time_avg,
change_failure_rate,
mttr_hours,
performance_level,
}
}
pub(super) fn compute_quality(
total_commits: usize,
category_total: &HashMap<String, usize>,
revert_count: usize,
) -> QualitySummary {
let bugfix_total = category_total
.get("bugfix")
.copied()
.unwrap_or(0)
.max(revert_count);
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
};
QualitySummary {
quality_score,
revert_count,
revert_pct,
bugfix_pct,
defect_rate,
}
}
pub(super) fn build_summary(
rows: &[CommitRow],
total_commits: usize,
total_authors: usize,
total_weeks: usize,
min_ts: DateTime<Utc>,
max_ts: DateTime<Utc>,
) -> ReportSummary {
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());
ReportSummary {
date_range,
total_commits,
total_developers: total_authors,
total_weeks,
classification_coverage_pct,
}
}
pub(super) 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()
}
pub(super) 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()
}
pub(super) 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))
}
pub(super) 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));
}
}
pub(super) 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
}