use std::collections::HashMap;
use rusqlite::params;
use crate::core::db::Database;
use crate::report::errors::{ReportError, Result};
#[derive(Debug, Clone)]
pub struct EffortHistogram {
pub histogram: HashMap<String, u32>,
pub scored_commits: u64,
pub total_commits: u64,
}
pub fn query_effort_histogram(
db: &Database,
email: &str,
since: Option<&str>,
until: Option<&str>,
) -> Result<EffortHistogram> {
let conn = db.connection();
let total_commits: u64 = {
let mut stmt = conn
.prepare(
"SELECT COUNT(*) FROM commits c \
JOIN authors a ON a.id = c.author_id \
WHERE LOWER(a.canonical_email) = LOWER(?1) \
AND (?2 IS NULL OR c.timestamp >= ?2) \
AND (?3 IS NULL OR c.timestamp <= ?3)",
)
.map_err(crate::core::TgaError::from)?;
stmt.query_row(params![email, since, until], |r| r.get::<_, i64>(0))
.map_err(crate::core::TgaError::from)? as u64
};
let mut stmt = conn
.prepare(
"SELECT fce.size, COUNT(*) AS cnt \
FROM fact_commit_effort fce \
JOIN commits c ON c.sha = fce.sha \
JOIN authors a ON a.id = c.author_id \
WHERE LOWER(a.canonical_email) = LOWER(?1) \
AND (?2 IS NULL OR c.timestamp >= ?2) \
AND (?3 IS NULL OR c.timestamp <= ?3) \
GROUP BY fce.size \
ORDER BY CASE fce.size \
WHEN 'XS' THEN 1 WHEN 'S' THEN 2 WHEN 'M' THEN 3 \
WHEN 'L' THEN 4 WHEN 'XL' THEN 5 ELSE 6 END",
)
.map_err(crate::core::TgaError::from)?;
let rows = stmt
.query_map(params![email, since, until], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})
.map_err(crate::core::TgaError::from)?;
let mut histogram: HashMap<String, u32> = HashMap::new();
let mut scored_commits: u64 = 0;
for r in rows {
let (size, count) = r.map_err(crate::core::TgaError::from)?;
let count_u32 = count as u32;
scored_commits += u64::from(count_u32);
histogram.insert(size, count_u32);
}
Ok(EffortHistogram {
histogram,
scored_commits,
total_commits,
})
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PrMetrics {
pub total: u64,
pub merged: u64,
pub avg_cycle_time_hours: Option<f64>,
pub median_cycle_time_hours: Option<f64>,
pub p95_cycle_time_hours: Option<f64>,
}
pub(super) const P95_MIN_SAMPLE: usize = 20;
pub(super) const CYCLE_TIME_MIN_HOURS: f64 = 0.5;
pub(super) const CYCLE_TIME_MAX_HOURS: f64 = 720.0;
pub fn query_pr_metrics(
db: &Database,
logins: &[String],
since: Option<&str>,
until: Option<&str>,
) -> Result<PrMetrics> {
if logins.is_empty() {
return Ok(PrMetrics {
total: 0,
merged: 0,
avg_cycle_time_hours: None,
median_cycle_time_hours: None,
p95_cycle_time_hours: None,
});
}
let conn = db.connection();
let placeholders: String = logins
.iter()
.enumerate()
.map(|(i, _)| format!("?{}", i + 3)) .collect::<Vec<_>>()
.join(", ");
let count_sql = format!(
"SELECT COUNT(*), COUNT(CASE WHEN state = 'merged' THEN 1 END) \
FROM pull_requests \
WHERE author IN ({placeholders}) \
AND (?1 IS NULL OR created_at >= ?1) \
AND (?2 IS NULL OR created_at <= ?2)"
);
let mut count_stmt = conn
.prepare(&count_sql)
.map_err(crate::core::TgaError::from)?;
let (total, merged): (u64, u64) = {
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![
Box::new(since.map(str::to_string)),
Box::new(until.map(str::to_string)),
];
for login in logins {
params_vec.push(Box::new(login.clone()));
}
let params_refs: Vec<&dyn rusqlite::ToSql> =
params_vec.iter().map(|b| b.as_ref()).collect();
count_stmt
.query_row(params_refs.as_slice(), |row| {
Ok((row.get::<_, i64>(0)? as u64, row.get::<_, i64>(1)? as u64))
})
.map_err(crate::core::TgaError::from)?
};
if merged == 0 {
return Ok(PrMetrics {
total,
merged,
avg_cycle_time_hours: None,
median_cycle_time_hours: None,
p95_cycle_time_hours: None,
});
}
let durations_sql = format!(
"SELECT (julianday(merged_at) - julianday(created_at)) * 24.0 \
FROM pull_requests \
WHERE author IN ({placeholders}) \
AND state = 'merged' \
AND merged_at IS NOT NULL \
AND (?1 IS NULL OR created_at >= ?1) \
AND (?2 IS NULL OR created_at <= ?2)"
);
let mut dur_stmt = conn
.prepare(&durations_sql)
.map_err(crate::core::TgaError::from)?;
let mut durations: Vec<f64> = {
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![
Box::new(since.map(str::to_string)),
Box::new(until.map(str::to_string)),
];
for login in logins {
params_vec.push(Box::new(login.clone()));
}
let params_refs: Vec<&dyn rusqlite::ToSql> =
params_vec.iter().map(|b| b.as_ref()).collect();
let rows = dur_stmt
.query_map(params_refs.as_slice(), |row| row.get::<_, f64>(0))
.map_err(crate::core::TgaError::from)?;
let mut v = Vec::new();
for r in rows {
let h = r.map_err(crate::core::TgaError::from)?;
if (CYCLE_TIME_MIN_HOURS..=CYCLE_TIME_MAX_HOURS).contains(&h) {
v.push(h);
}
}
v
};
if durations.is_empty() {
return Ok(PrMetrics {
total,
merged,
avg_cycle_time_hours: None,
median_cycle_time_hours: None,
p95_cycle_time_hours: None,
});
}
durations.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = durations.len();
let avg = durations.iter().sum::<f64>() / n as f64;
let median = durations[n / 2];
let p95 = if n >= P95_MIN_SAMPLE {
Some(durations[(n * 95) / 100])
} else {
None
};
Ok(PrMetrics {
total,
merged,
avg_cycle_time_hours: Some(avg),
median_cycle_time_hours: Some(median),
p95_cycle_time_hours: p95,
})
}
#[derive(Debug, Clone)]
pub struct CommitSummary {
pub total_commits: u64,
pub ticketed_commits: u64,
pub repositories: Vec<String>,
pub first_commit: Option<String>,
pub last_commit: Option<String>,
pub insertions: i64,
pub deletions: i64,
}
pub fn query_commit_summary(
db: &Database,
email: &str,
since: Option<&str>,
until: Option<&str>,
) -> Result<CommitSummary> {
let conn = db.connection();
let mut stmt = conn
.prepare(
"SELECT COUNT(*), \
COUNT(CASE WHEN c.ticketed = 1 THEN 1 END), \
MIN(c.timestamp), MAX(c.timestamp), \
SUM(c.insertions), SUM(c.deletions) \
FROM commits c \
JOIN authors a ON a.id = c.author_id \
WHERE LOWER(a.canonical_email) = LOWER(?1) \
AND (?2 IS NULL OR c.timestamp >= ?2) \
AND (?3 IS NULL OR c.timestamp <= ?3)",
)
.map_err(crate::core::TgaError::from)?;
let (total, ticketed, first_commit, last_commit, insertions, deletions) = stmt
.query_row(params![email, since, until], |row| {
Ok((
row.get::<_, i64>(0)? as u64,
row.get::<_, i64>(1)? as u64,
row.get::<_, Option<String>>(2)?,
row.get::<_, Option<String>>(3)?,
row.get::<_, Option<i64>>(4)?.unwrap_or(0),
row.get::<_, Option<i64>>(5)?.unwrap_or(0),
))
})
.map_err(crate::core::TgaError::from)?;
let mut repo_stmt = conn
.prepare(
"SELECT DISTINCT c.repository \
FROM commits c \
JOIN authors a ON a.id = c.author_id \
WHERE LOWER(a.canonical_email) = LOWER(?1) \
AND (?2 IS NULL OR c.timestamp >= ?2) \
AND (?3 IS NULL OR c.timestamp <= ?3) \
ORDER BY c.repository",
)
.map_err(crate::core::TgaError::from)?;
let repo_rows = repo_stmt
.query_map(params![email, since, until], |row| row.get::<_, String>(0))
.map_err(crate::core::TgaError::from)?;
let mut repositories = Vec::new();
for r in repo_rows {
repositories.push(r.map_err(crate::core::TgaError::from)?);
}
Ok(CommitSummary {
total_commits: total,
ticketed_commits: ticketed,
repositories,
first_commit,
last_commit,
insertions,
deletions,
})
}
pub fn extract_provider_logins(aliases_json: &str) -> Vec<String> {
let aliases: Vec<String> = serde_json::from_str(aliases_json).unwrap_or_default();
aliases.into_iter().filter(|a| !a.contains('@')).collect()
}
pub fn query_author_categories(
db: &Database,
email: &str,
since: Option<&str>,
until: Option<&str>,
) -> Result<HashMap<String, usize>> {
let conn = db.connection();
let mut stmt = conn
.prepare(
"SELECT cl.category, COUNT(*) \
FROM commits c \
JOIN authors a ON a.id = c.author_id \
LEFT JOIN classifications cl ON cl.id = c.classification_id \
WHERE LOWER(a.canonical_email) = LOWER(?1) \
AND cl.category IS NOT NULL \
AND (?2 IS NULL OR c.timestamp >= ?2) \
AND (?3 IS NULL OR c.timestamp <= ?3) \
GROUP BY cl.category",
)
.map_err(crate::core::TgaError::from)?;
let rows = stmt
.query_map(params![email, since, until], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})
.map_err(crate::core::TgaError::from)?;
let mut map: HashMap<String, usize> = HashMap::new();
for r in rows {
let (cat, cnt) = r.map_err(crate::core::TgaError::from)?;
map.insert(cat, cnt as usize);
}
Ok(map)
}
pub fn lookup_author_for_drilldown(
db: &Database,
email: &str,
) -> Result<Option<(i64, String, String, String)>> {
let conn = db.connection();
let result: rusqlite::Result<(i64, String, String, String)> = conn.query_row(
"SELECT id, canonical_name, canonical_email, aliases \
FROM authors WHERE LOWER(canonical_email) = LOWER(?1) LIMIT 1",
params![email],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
);
match result {
Ok(row) => Ok(Some(row)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(ReportError::Core(crate::core::TgaError::from(e))),
}
}