use std::collections::{BTreeMap, BTreeSet};
use crate::classify::classifier::ClassificationEngine;
use crate::classify::errors::{ClassifyError, Result};
use crate::classify::pipeline::{configured_categories, ClassificationPipeline};
use crate::classify::rules::CategoryDef;
use crate::classify::tiers::jev_obfuscate::{KnownNames, RunNames};
use crate::classify::tiers::jev_trailers::trailer_names;
use crate::classify::tiers::llm::LlmClassifier;
use crate::classify::tiers::llm_context::CommitContext;
use crate::core::config::Config;
use crate::core::db::Database;
use super::pipeline_db::CommitRow;
fn push_repo(out: &mut Vec<String>, value: &str) {
let value = value.trim();
if value.is_empty() {
return;
}
out.push(value.to_string());
if value.contains('/') {
let parts = value.split('/').map(str::trim).filter(|p| !p.is_empty());
out.extend(parts.map(String::from));
}
}
fn tenant(url: &str) -> Option<&str> {
let url = url.trim();
let rest = url.split_once("://").map_or(url, |(_, r)| r);
let (authority, path) = rest.split_once('/').unwrap_or((rest, ""));
let host = authority.rsplit('@').next()?;
let lower = host.to_ascii_lowercase();
if lower == "dev.azure.com" {
return path.split('/').next().filter(|s| !s.is_empty());
}
let hosted = [".visualstudio.com", ".atlassian.net"]
.iter()
.any(|s| lower.ends_with(s));
hosted.then(|| host.split('.').next()).flatten()
}
pub(crate) fn known_names(config: &Config) -> KnownNames {
let mut names = KnownNames::default();
let repos = &mut names.repos;
for r in &config.repositories {
r.name.iter().for_each(|n| push_repo(repos, n));
r.org.iter().for_each(|n| push_repo(repos, n));
if let Some(base) = r.path.file_name().and_then(|b| b.to_str()) {
push_repo(repos, base);
}
}
if let Some(g) = &config.github {
g.org
.iter()
.chain(&g.orgs)
.chain(&g.repo)
.for_each(|n| push_repo(repos, n));
}
if let Some(b) = &config.bitbucket {
let one = b.workspace.iter().chain(&b.repo_slug);
one.chain(&b.workspaces).for_each(|n| push_repo(repos, n));
}
if let Some(ado) = config.pm.as_ref().and_then(|p| p.azure_devops.as_ref()) {
tenant(&ado.organization_url)
.into_iter()
.for_each(|n| push_repo(repos, n));
ado.project
.iter()
.chain(&ado.projects)
.for_each(|n| push_repo(repos, n));
}
if let Some(site) = config
.jira
.as_ref()
.and_then(|j| j.url.as_deref())
.and_then(tenant)
{
push_repo(repos, site);
}
if let Some(c) = &config.classification {
let keys = c.repo_categories.keys().filter(|k| !k.contains('*'));
keys.for_each(|k| push_repo(repos, k.split_once(':').map_or(k, |(r, _)| r)));
}
let mut person = |n: &str| {
if !n.contains('@') {
names.people.push(n.to_string());
}
};
if let Some(team) = &config.team {
for m in &team.members {
person(&m.name);
m.aliases.iter().for_each(|a| person(a));
}
for (alias, canonical) in &team.aliases {
person(alias);
person(canonical);
}
}
for (canonical, aliases) in &config.developer_aliases {
person(canonical);
aliases.iter().for_each(|a| person(a));
}
names
}
pub(super) fn db_people(db: &Database) -> Result<Vec<String>> {
fn add(value: &str, out: &mut BTreeSet<String>) {
match value.split_once('@') {
Some((local, _)) => {
out.insert(local.to_string());
for p in local
.split('+')
.filter(|p| !p.bytes().all(|b| b.is_ascii_digit()))
{
out.insert(p.to_string());
}
}
None => {
out.insert(value.to_string());
}
}
}
let conn = db.connection();
let mut people = BTreeSet::new();
let mut stmt = conn
.prepare("SELECT DISTINCT author_name, author_email FROM commits")
.map_err(crate::core::TgaError::from)?;
let rows = stmt
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
.map_err(crate::core::TgaError::from)?;
for row in rows {
let (name, email) = row.map_err(crate::core::TgaError::from)?;
add(&name, &mut people);
add(&email, &mut people);
}
let mut stmt = conn
.prepare("SELECT canonical_name, canonical_email, aliases FROM authors")
.map_err(crate::core::TgaError::from)?;
let rows = stmt
.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
))
})
.map_err(crate::core::TgaError::from)?;
for row in rows {
let (name, email, aliases) = row.map_err(crate::core::TgaError::from)?;
add(&name, &mut people);
add(&email, &mut people);
let aliases: Vec<String> = serde_json::from_str(&aliases)?;
for a in &aliases {
add(a, &mut people);
}
}
const OTHER_PEOPLE: [&str; 9] = [
"SELECT author FROM pull_requests",
"SELECT reviewer_id FROM pr_reviewers",
"SELECT display_name FROM pr_reviewers",
"SELECT assignee FROM linear_issues",
"SELECT author FROM fact_ticket_transitions",
"SELECT author FROM fact_jira_comment_detail",
"SELECT pm_name FROM fact_pm_effort",
"SELECT author_email FROM fact_weekly_quality",
"SELECT author_email FROM fact_weekly_engineer",
];
for sql in OTHER_PEOPLE {
let mut stmt = conn.prepare(sql).map_err(crate::core::TgaError::from)?;
let rows = stmt
.query_map([], |r| r.get::<_, Option<String>>(0))
.map_err(crate::core::TgaError::from)?;
for row in rows {
if let Some(v) = row.map_err(crate::core::TgaError::from)? {
add(&v, &mut people);
}
}
}
Ok(people
.into_iter()
.filter(|p| !p.trim().is_empty())
.collect())
}
pub(super) fn db_file_paths(db: &Database) -> Result<Vec<String>> {
let conn = db.connection();
let mut stmt = conn
.prepare("SELECT DISTINCT path FROM files")
.map_err(crate::core::TgaError::from)?;
let rows = stmt
.query_map([], |r| r.get::<_, String>(0))
.map_err(crate::core::TgaError::from)?;
let mut out = Vec::new();
for row in rows {
out.push(row.map_err(crate::core::TgaError::from)?);
}
Ok(out)
}
pub(super) fn db_trailer_people(db: &Database) -> Result<Vec<String>> {
let conn = db.connection();
let mut stmt = conn
.prepare("SELECT message FROM commits WHERE instr(message, ':') > 0")
.map_err(crate::core::TgaError::from)?;
let mut rows = stmt.query([]).map_err(crate::core::TgaError::from)?;
let mut names = BTreeSet::new();
while let Some(row) = rows.next().map_err(crate::core::TgaError::from)? {
let message: String = row.get(0).map_err(crate::core::TgaError::from)?;
names.extend(trailer_names([message.as_str()]).map_err(jev_init)?);
}
Ok(names.into_iter().collect())
}
pub(super) fn db_repositories(db: &Database) -> Result<Vec<String>> {
let conn = db.connection();
let mut stmt = conn
.prepare("SELECT repository FROM commits UNION SELECT repository FROM pull_requests")
.map_err(crate::core::TgaError::from)?;
let rows = stmt
.query_map([], |r| r.get::<_, Option<String>>(0))
.map_err(crate::core::TgaError::from)?;
let mut out = Vec::new();
for row in rows {
out.extend(row.map_err(crate::core::TgaError::from)?);
}
Ok(out)
}
fn jev_init(e: impl std::fmt::Display) -> ClassifyError {
ClassifyError::Config(format!("LLM provider init failed (jev): {e}"))
}
pub(super) fn prepare_jev(
engine: &ClassificationEngine,
db: &Database,
commits: &[CommitRow],
contexts: &BTreeMap<usize, CommitContext>,
) -> Result<()> {
if !engine.llm_jev_obfuscates() {
return Ok(());
}
let mut people = db_people(db)?;
people.extend(db_trailer_people(db)?);
let names = RunNames {
people,
paths: db_file_paths(db)?,
repos: db_repositories(db)?,
};
let messages: Vec<&str> = commits.iter().map(|c| c.message.as_str()).collect();
let contexts: Vec<&CommitContext> = contexts.values().collect();
engine
.llm_prepare(&messages, &contexts, &names)
.map_err(jev_init)
}
impl ClassificationPipeline {
pub(super) fn attach_jev_context(&self, llm: LlmClassifier) -> Result<LlmClassifier> {
if !llm.is_jev() {
return Ok(llm);
}
let (ruleset, _) = self.load_ruleset()?;
let mut names = if llm.jev_obfuscates() {
known_names(&self.config)
} else {
KnownNames::default()
};
for r in &ruleset.rules {
names.vocab.push(r.category.clone());
names.vocab.extend(r.keywords.iter().cloned());
names.vocab.extend(r.patterns.iter().cloned());
}
let mut categories = match self.llm_categories()? {
Some(c) => c,
None => configured_categories(ruleset),
};
let (map, source) = self.bucket_map_with_source()?;
let offered = map
.fine_categories()
.filter(|_| source.is_consumer_supplied());
for fine in offered {
if !categories.iter().any(|c| c.name.eq_ignore_ascii_case(fine)) {
names.vocab.push(fine.to_string());
categories.push(CategoryDef::new(fine));
}
}
llm.with_jev_context(categories, names)
.map_err(|e| ClassifyError::Config(format!("LLM provider init failed (jev): {e}")))
}
}