use std::collections::HashSet;
use sandogasa_bugzilla::BzClient;
use sandogasa_bugzilla::models::Bug;
use crate::BugKind;
#[derive(Debug, Clone, Default)]
pub struct TrackerIds {
pub ftbfs: HashSet<u64>,
pub fti: HashSet<u64>,
}
pub fn extract_new_version(summary: &str, component: &str) -> Option<String> {
let body = summary.trim().strip_suffix(" is available")?;
let rest = body.strip_prefix(component)?;
let version = rest.strip_prefix('-').unwrap_or(rest);
(!version.is_empty()).then(|| version.to_string())
}
pub fn classify(bug: &Bug, trackers: &TrackerIds) -> BugKind {
if bug.component.iter().any(|c| c == "Package Review") {
return BugKind::Review;
}
if bug.blocks.iter().any(|id| trackers.ftbfs.contains(id)) {
return BugKind::Ftbfs;
}
if bug.blocks.iter().any(|id| trackers.fti.contains(id)) {
return BugKind::Fti;
}
if bug.summary.starts_with("CVE-")
|| bug
.keywords
.iter()
.any(|k| k == "SecurityTracking" || k == "Security")
{
return BugKind::Security;
}
if bug.keywords.iter().any(|k| k == "FutureFeature") {
let component = bug.component.first().map(|s| s.as_str()).unwrap_or("");
if !component.is_empty()
&& bug.summary.starts_with(component)
&& bug.summary.contains("is available")
{
return BugKind::Update;
}
}
if bug.summary.to_lowercase().contains("branch") {
return BugKind::Branch;
}
BugKind::Other
}
pub async fn lookup_trackers(bz: &BzClient, versions: &[u32], verbose: bool) -> TrackerIds {
let mut ftbfs = HashSet::new();
let mut fti = HashSet::new();
let mut aliases = vec![
"RAWHIDEFTBFS".to_string(),
"RAWHIDEFailsToInstall".to_string(),
];
for ver in versions {
aliases.push(format!("F{ver}FTBFS"));
aliases.push(format!("F{ver}FailsToInstall"));
}
if verbose {
eprintln!("[bugclass] looking up FTBFS/FTI tracker bugs");
}
let alias_params: Vec<String> = aliases.iter().map(|a| format!("alias={a}")).collect();
let query = alias_params.join("&");
if let Ok(bugs) = bz.search(&query, 0).await {
for bug in &bugs {
for alias in &bug.alias {
if alias.ends_with("FTBFS") {
ftbfs.insert(bug.id);
} else if alias.ends_with("FailsToInstall") {
fti.insert(bug.id);
}
}
}
}
if verbose {
eprintln!(
"[bugclass] found {} FTBFS and {} FTI tracker(s)",
ftbfs.len(),
fti.len()
);
}
TrackerIds { ftbfs, fti }
}
#[cfg(test)]
mod tests {
use super::*;
fn make_bug(summary: &str, component: &str, keywords: &[&str], blocks: &[u64]) -> Bug {
Bug {
id: 1,
summary: summary.to_string(),
status: "NEW".to_string(),
resolution: String::new(),
product: "Fedora".to_string(),
component: vec![component.to_string()],
severity: String::new(),
priority: String::new(),
assigned_to: String::new(),
creator: String::new(),
creation_time: chrono::Utc::now(),
last_change_time: chrono::Utc::now(),
keywords: keywords.iter().map(|s| s.to_string()).collect(),
alias: vec![],
depends_on: vec![],
blocks: blocks.to_vec(),
see_also: vec![],
cc: vec![],
flags: vec![],
version: vec![],
cf_fixed_in: String::new(),
}
}
#[test]
fn extract_new_version_handles_real_summaries() {
assert_eq!(
extract_new_version(
"transmission-remote-cli-1.7.1 is available",
"transmission-remote-cli"
)
.as_deref(),
Some("1.7.1")
);
assert_eq!(
extract_new_version(
"python-peak-rules-0.5a1.dev-r2707 is available",
"python-peak-rules"
)
.as_deref(),
Some("0.5a1.dev-r2707")
);
}
#[test]
fn extract_new_version_rejects_unrecognized() {
assert_eq!(extract_new_version("something unrelated", "foo"), None);
assert_eq!(
extract_new_version("otherpkg-1.0 is available", "foo"),
None
);
}
#[test]
fn classify_review() {
let trackers = TrackerIds::default();
let bug = make_bug("Review Request: rust-foo", "Package Review", &[], &[]);
assert_eq!(classify(&bug, &trackers), BugKind::Review);
}
#[test]
fn classify_security_by_summary() {
let trackers = TrackerIds::default();
let bug = make_bug("CVE-2026-1234 foo: buffer overflow", "foo", &[], &[]);
assert_eq!(classify(&bug, &trackers), BugKind::Security);
}
#[test]
fn classify_security_by_keyword() {
let trackers = TrackerIds::default();
let bug = make_bug("foo: buffer overflow", "foo", &["SecurityTracking"], &[]);
assert_eq!(classify(&bug, &trackers), BugKind::Security);
}
#[test]
fn classify_update_request() {
let trackers = TrackerIds::default();
let bug = make_bug("fish-4.0 is available", "fish", &["FutureFeature"], &[]);
assert_eq!(classify(&bug, &trackers), BugKind::Update);
}
#[test]
fn classify_branch_request() {
let trackers = TrackerIds::default();
let bug = make_bug("Please branch rust-foo for epel10", "foo", &[], &[]);
assert_eq!(classify(&bug, &trackers), BugKind::Branch);
}
#[test]
fn classify_ftbfs() {
let trackers = TrackerIds {
ftbfs: HashSet::from([999]),
fti: HashSet::new(),
};
let bug = make_bug("foo FTBFS in rawhide", "foo", &[], &[999]);
assert_eq!(classify(&bug, &trackers), BugKind::Ftbfs);
}
#[test]
fn classify_fti() {
let trackers = TrackerIds {
ftbfs: HashSet::new(),
fti: HashSet::from([888]),
};
let bug = make_bug("foo fails to install", "foo", &[], &[888]);
assert_eq!(classify(&bug, &trackers), BugKind::Fti);
}
#[test]
fn classify_other() {
let trackers = TrackerIds::default();
let bug = make_bug("foo crashes on startup", "foo", &[], &[]);
assert_eq!(classify(&bug, &trackers), BugKind::Other);
}
}