use std::collections::{HashMap, HashSet};
use duckdb::params;
use crate::facts::FactsDb;
use crate::{CodeLoreError, Options, Result};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct BusFactorRow {
pub module: String,
pub total_commits: u32,
pub bus_factor: u32,
pub top_contributor: String,
pub top_contributor_share: f64,
pub model: String,
}
const SQL_COMMITS_TEMPLATE: &str = "
WITH {human_aliases},
per_module_author AS (
-- Pair-granular: joins on the exact (raw_name, raw_email) that
-- made the commit, so a human sharing a canonical with a bot
-- keeps their own commits counted while the bot pair's commits
-- are dropped row-wise.
SELECT
CASE
WHEN c.path LIKE '%/%' THEN regexp_extract(c.path, '^[^/]+', 0)
ELSE '<root>'
END AS module,
co.canonical_author AS author,
COUNT(DISTINCT c.rev) AS commits
FROM changes c
INNER JOIN commits co ON co.rev = c.rev
INNER JOIN human_aliases ha
ON ha.raw_name = co.author_name AND ha.raw_email = co.author_email
WHERE co.is_merge = FALSE
GROUP BY module, co.canonical_author
),
per_module AS (
SELECT
module,
SUM(commits) AS total_commits
FROM per_module_author
GROUP BY module
),
ranked AS (
SELECT
pma.module,
pma.author,
pma.commits,
pm.total_commits,
ROW_NUMBER() OVER (PARTITION BY pma.module ORDER BY pma.commits DESC, pma.author ASC) AS rank,
SUM(pma.commits) OVER (
PARTITION BY pma.module
ORDER BY pma.commits DESC, pma.author ASC
ROWS UNBOUNDED PRECEDING
) AS cum_commits
FROM per_module_author pma
INNER JOIN per_module pm ON pm.module = pma.module
),
bus_factor_calc AS (
SELECT
module,
MIN(rank) AS bus_factor
FROM ranked
WHERE cum_commits >= total_commits * 0.8
GROUP BY module
),
top AS (
SELECT module, author AS top_author, commits AS top_commits
FROM ranked
WHERE rank = 1
)
SELECT
pm.module,
CAST(pm.total_commits AS UINTEGER) AS total_commits,
CAST(COALESCE(bfc.bus_factor, 1) AS UINTEGER) AS bus_factor,
t.top_author,
(t.top_commits::DOUBLE / NULLIF(pm.total_commits, 0)::DOUBLE) AS top_share
FROM per_module pm
LEFT JOIN bus_factor_calc bfc ON bfc.module = pm.module
INNER JOIN top t ON t.module = pm.module
WHERE pm.module IS NOT NULL AND pm.module != ''
ORDER BY bus_factor ASC, pm.total_commits DESC, pm.module ASC
LIMIT ?
";
const SQL_DOE_EXPERTS: &str = "
SELECT
CASE
WHEN path LIKE '%/%' THEN regexp_extract(path, '^[^/]+', 0)
ELSE '<root>'
END AS module,
author,
path
FROM doe_scores
WHERE is_expert = TRUE
ORDER BY module, author, path
";
const SQL_DOE_MODULE_COMMITS_TEMPLATE: &str = "
WITH {human_aliases},
per_module AS (
-- Pair-granular (see SQL_COMMITS_TEMPLATE::per_module_author).
SELECT
CASE
WHEN c.path LIKE '%/%' THEN regexp_extract(c.path, '^[^/]+', 0)
ELSE '<root>'
END AS module,
COUNT(DISTINCT c.rev) AS total_commits
FROM changes c
INNER JOIN commits co ON co.rev = c.rev
INNER JOIN human_aliases ha
ON ha.raw_name = co.author_name AND ha.raw_email = co.author_email
WHERE co.is_merge = FALSE
GROUP BY module
)
SELECT module, CAST(total_commits AS UINTEGER)
FROM per_module
WHERE module IS NOT NULL AND module != ''
ORDER BY module
";
#[tracing::instrument(name = "bus-factor", skip_all, fields(min_revs = opts.min_revs, knowledge_model = %opts.knowledge_model))]
pub fn run_bus_factor(db: &FactsDb, opts: &Options) -> Result<Vec<BusFactorRow>> {
crate::analyses::lineage::materialize_if_needed(db, opts)?;
if opts.knowledge_model == "doe" {
run_bus_factor_doe(db, opts)
} else {
run_bus_factor_commits(db, opts)
}
}
fn run_bus_factor_commits(db: &FactsDb, opts: &Options) -> Result<Vec<BusFactorRow>> {
let row_limit: i64 = opts.rows_limit.map_or(i64::MAX, i64::from);
let sql =
SQL_COMMITS_TEMPLATE.replace("{human_aliases}", crate::analyses::query::HUMAN_ALIASES_CTE);
let sql = crate::analyses::lineage::rewrite(&sql, opts);
let mut stmt = db
.conn()
.prepare(&sql)
.map_err(|e| CodeLoreError::Analysis(format!("prepare bus-factor: {e}")))?;
let rows = stmt
.query_map(params![row_limit], |r| {
Ok(BusFactorRow {
module: r.get(0)?,
total_commits: r.get(1)?,
bus_factor: r.get(2)?,
top_contributor: r.get(3)?,
top_contributor_share: r.get::<_, Option<f64>>(4)?.unwrap_or(0.0),
model: "commits".to_string(),
})
})
.map_err(|e| CodeLoreError::Analysis(format!("query bus-factor: {e}")))?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|e| CodeLoreError::Analysis(format!("collect bus-factor: {e}")))
}
#[allow(clippy::too_many_lines)]
fn run_bus_factor_doe(db: &FactsDb, opts: &Options) -> Result<Vec<BusFactorRow>> {
crate::analyses::knowledge::shares::materialize_knowledge_shares(db, opts)?;
let commit_sql = SQL_DOE_MODULE_COMMITS_TEMPLATE
.replace("{human_aliases}", crate::analyses::query::HUMAN_ALIASES_CTE);
let commit_sql = crate::analyses::lineage::rewrite(&commit_sql, opts);
let mut commit_stmt = db
.conn()
.prepare(&commit_sql)
.map_err(|e| CodeLoreError::Analysis(format!("prepare doe module-commits: {e}")))?;
let mut module_commits: HashMap<String, u32> = HashMap::new();
let commit_rows = commit_stmt
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, u32>(1)?)))
.map_err(|e| CodeLoreError::Analysis(format!("query doe module-commits: {e}")))?;
for pair in commit_rows {
let (module, total) =
pair.map_err(|e| CodeLoreError::Analysis(format!("collect doe module-commits: {e}")))?;
module_commits.insert(module, total);
}
let mut expert_stmt = db
.conn()
.prepare(SQL_DOE_EXPERTS)
.map_err(|e| CodeLoreError::Analysis(format!("prepare doe experts: {e}")))?;
let mut module_author_files: HashMap<String, HashMap<String, HashSet<String>>> = HashMap::new();
let mut module_file_count: HashMap<String, HashSet<String>> = HashMap::new();
let expert_rows = expert_stmt
.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
))
})
.map_err(|e| CodeLoreError::Analysis(format!("query doe experts: {e}")))?;
for row in expert_rows {
let (module, author, path) =
row.map_err(|e| CodeLoreError::Analysis(format!("collect doe experts: {e}")))?;
module_file_count
.entry(module.clone())
.or_default()
.insert(path.clone());
module_author_files
.entry(module)
.or_default()
.entry(author)
.or_default()
.insert(path);
}
let row_limit: usize = opts.rows_limit.map_or(usize::MAX, |l| l as usize);
let mut results: Vec<BusFactorRow> = Vec::new();
for (module, mut author_files) in module_author_files {
let total_files = module_file_count.get(&module).map_or(0, HashSet::len);
if total_files == 0 {
continue;
}
let mut covered: HashSet<String> =
module_file_count.get(&module).cloned().unwrap_or_default();
let (initial_top_author, initial_top_count) = author_files
.iter()
.map(|(a, files)| (a.clone(), files.len()))
.max_by(|(_, ca), (_, cb)| ca.cmp(cb))
.unwrap_or_else(|| (String::new(), 0));
let mut first_author: Option<String> = None;
let mut first_author_file_count: usize = 0;
let mut removed_count: u32 = 0;
loop {
let uncovered = total_files.saturating_sub(covered.len());
if uncovered * 2 > total_files {
break;
}
if author_files.is_empty() {
break;
}
let best = author_files
.iter()
.map(|(a, files)| {
let coverage: usize = files.iter().filter(|f| covered.contains(*f)).count();
(coverage, a.clone())
})
.max_by(|(ca, aa), (cb, ab)| ca.cmp(cb).then(aa.cmp(ab).reverse()))
.map(|(count, a)| (a, count));
let Some((author, count)) = best else {
break;
};
if count == 0 {
break;
}
if first_author.is_none() {
first_author = Some(author.clone());
first_author_file_count = count;
}
let removed_files = author_files.remove(&author).unwrap_or_default();
removed_count += 1;
for file in &removed_files {
let still_covered = author_files.values().any(|fs| fs.contains(file));
if !still_covered {
covered.remove(file);
}
}
}
let (top_contributor, top_file_count) = first_author.map_or_else(
|| (initial_top_author, initial_top_count),
|a| (a, first_author_file_count),
);
#[allow(clippy::cast_precision_loss)]
let top_contributor_share = if total_files > 0 {
top_file_count as f64 / total_files as f64
} else {
0.0
};
let total_commits = module_commits.get(&module).copied().unwrap_or(0);
results.push(BusFactorRow {
module,
total_commits,
bus_factor: removed_count.max(1),
top_contributor,
top_contributor_share,
model: "doe".to_string(),
});
}
results.sort_by(|a, b| {
a.bus_factor
.cmp(&b.bus_factor)
.then(b.total_commits.cmp(&a.total_commits))
.then(a.module.cmp(&b.module))
});
results.truncate(row_limit);
Ok(results)
}