use std::collections::BTreeMap;
use sandogasa_bugclass::bugzilla::extract_new_version;
use sandogasa_bugzilla::BzClient;
use sandogasa_bugzilla::models::Bug;
use sandogasa_distgit::DistGitClient;
use sandogasa_inventory::Inventory;
use serde::Serialize;
use crate::triage_retired::{RETRY_ATTEMPTS, retry};
use crate::triage_updates::bug_search_query;
const CURRENT_BRANCH: &str = "rawhide";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Bump {
UpToDate,
NonBreaking,
Breaking,
Retired,
NeedsReview,
}
impl Bump {
fn label(self) -> &'static str {
match self {
Bump::UpToDate => "Up to date (stale bug)",
Bump::NonBreaking => "Non-breaking",
Bump::Breaking => "Breaking",
Bump::Retired => "Retired (update request invalid)",
Bump::NeedsReview => "Needs review",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct AuditEntry {
pub package: String,
pub current: String,
pub new: String,
pub bump: Bump,
pub bug_id: u64,
}
fn numeric_components(version: &str) -> Option<Vec<u64>> {
let v = version.trim();
let v = v.split('+').next().unwrap_or(v);
if v.is_empty() {
return None;
}
v.split('.').map(|c| c.parse::<u64>().ok()).collect()
}
pub fn version_at_least(candidate: &str, target: &str) -> bool {
match (numeric_components(candidate), numeric_components(target)) {
(Some(c), Some(t)) => {
let width = c.len().max(t.len());
let pad = |v: &[u64]| -> Vec<u64> {
(0..width).map(|i| v.get(i).copied().unwrap_or(0)).collect()
};
pad(&c) >= pad(&t)
}
_ => candidate == target,
}
}
pub fn classify(current: &str, new: &str) -> Bump {
let (Some(cur), Some(new_c)) = (numeric_components(current), numeric_components(new)) else {
return Bump::NeedsReview;
};
let width = cur.len().max(new_c.len());
let cur: Vec<u64> = (0..width)
.map(|i| cur.get(i).copied().unwrap_or(0))
.collect();
let new_c: Vec<u64> = (0..width)
.map(|i| new_c.get(i).copied().unwrap_or(0))
.collect();
if new_c == cur {
return Bump::UpToDate;
}
if new_c < cur {
return Bump::NeedsReview;
}
let Some(lead) = cur.iter().position(|&x| x != 0) else {
return Bump::NeedsReview;
};
if (0..=lead).any(|i| cur[i] != new_c[i]) {
Bump::Breaking
} else {
Bump::NonBreaking
}
}
pub fn classify_with_status(current: Option<&str>, new: &str, retired: bool) -> Bump {
match current {
Some(cur) => classify(cur, new),
None if retired => Bump::Retired,
None => Bump::NeedsReview,
}
}
pub fn parse_spec_field(spec: &str, tag: &str) -> Option<String> {
let prefix = format!("{tag}:");
for line in spec.lines() {
if let Some(rest) = line.trim_start().strip_prefix(&prefix) {
let v = rest.trim();
if !v.is_empty() {
return Some(v.to_string());
}
}
}
None
}
pub fn parse_spec_version(spec: &str) -> Option<String> {
parse_spec_field(spec, "Version")
}
fn pick_latest(bugs: &[Bug], component: &str) -> Option<(u64, String)> {
let mut best: Option<(u64, String, Option<Vec<u64>>)> = None;
for bug in bugs {
let Some(version) = extract_new_version(&bug.summary, component) else {
continue;
};
let parsed = numeric_components(&version);
let better = match &best {
None => true,
Some((_, _, best_parsed)) => match (&parsed, best_parsed) {
(Some(a), Some(b)) => a > b,
(Some(_), None) => true,
_ => false,
},
};
if better {
best = Some((bug.id, version, parsed));
}
}
best.map(|(id, version, _)| (id, version))
}
pub async fn run(
inventory: &Inventory,
bz: &BzClient,
dg: &DistGitClient,
filter: &crate::WalkFilterArgs,
non_breaking_only: bool,
batch_email: Option<&str>,
verbose: bool,
) -> Result<Vec<AuditEntry>, String> {
let mut entries = Vec::new();
let batch_bugs = match batch_email {
Some(email) => {
if verbose {
eprintln!("[poi-tracker] batch: querying bugs for {email}");
}
let query = crate::triage_updates::batch_bug_query(email, false);
let bugs = retry(
"batch bug search",
RETRY_ATTEMPTS,
|| bz.search(&query, 0),
verbose,
)
.await
.map_err(|e| format!("Bugzilla batch search: {e}"))?;
Some(crate::triage_updates::group_bugs_by_component(bugs))
}
None => None,
};
let mut marked_retired = 0usize;
for pkg in &inventory.package {
if !filter.matches(&pkg.name) {
continue;
}
if pkg.is_unshipped() {
marked_retired += 1;
if verbose {
eprintln!(
"[poi-tracker] {}: marked unshipped in the \
inventory; skipping",
pkg.name
);
}
continue;
}
if pkg.is_retired_on(CURRENT_BRANCH) {
marked_retired += 1;
if verbose {
eprintln!(
"[poi-tracker] {}: marked retired on {CURRENT_BRANCH} in \
the inventory; skipping",
pkg.name
);
}
continue;
}
if verbose {
eprintln!("[poi-tracker] {}: checking for pending update", pkg.name);
}
let per_pkg;
let bugs: &[Bug] = match &batch_bugs {
Some(map) => map.get(&pkg.name).map(Vec::as_slice).unwrap_or(&[]),
None => {
let query = bug_search_query(&pkg.name);
per_pkg = retry(
&format!("bug search for {}", pkg.name),
RETRY_ATTEMPTS,
|| bz.search(&query, 0),
verbose,
)
.await
.map_err(|e| format!("Bugzilla search for {}: {e}", pkg.name))?;
&per_pkg
}
};
let Some((bug_id, new)) = pick_latest(bugs, &pkg.name) else {
continue;
};
let current = match dg.fetch_spec(&pkg.name, CURRENT_BRANCH).await {
Ok(spec) => parse_spec_version(&spec),
Err(e) => {
if verbose {
eprintln!("[poi-tracker] {}: cannot read rawhide spec: {e}", pkg.name);
}
None
}
};
let retired = current.is_none()
&& dg
.is_retired(&pkg.name, CURRENT_BRANCH)
.await
.unwrap_or(false);
let mut bump = classify_with_status(current.as_deref(), &new, retired);
if current.as_deref() == Some(new.as_str()) {
bump = Bump::UpToDate;
}
let current_str = match (¤t, retired) {
(Some(cur), _) => cur.clone(),
(None, true) => "(retired)".to_string(),
(None, false) => "?".to_string(),
};
if non_breaking_only && bump != Bump::NonBreaking {
continue;
}
entries.push(AuditEntry {
package: pkg.name.clone(),
current: current_str,
new,
bump,
bug_id,
});
}
if marked_retired > 0 {
eprintln!(
"({marked_retired} package(s) skipped: marked retired on \
{CURRENT_BRANCH} in the inventory)"
);
}
Ok(entries)
}
pub fn print_report(entries: &[AuditEntry]) {
if entries.is_empty() {
println!("No pending updates.");
return;
}
let mut by_bump: BTreeMap<&str, Vec<&AuditEntry>> = BTreeMap::new();
for e in entries {
by_bump.entry(e.bump.label()).or_default().push(e);
}
for kind in [
Bump::NonBreaking,
Bump::Breaking,
Bump::UpToDate,
Bump::Retired,
Bump::NeedsReview,
] {
let Some(group) = by_bump.get(kind.label()) else {
continue;
};
println!("\n{} ({}):", kind.label(), group.len());
for e in group {
println!(
" {} {} -> {} (rhbz#{})",
e.package, e.current, e.new, e.bug_id
);
}
if kind == Bump::Retired {
println!(" (run `poi-tracker triage-retired` to close these)");
}
if kind == Bump::UpToDate {
println!(
" (run `poi-tracker triage-updates` to record fixed \
builds and close these)"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn run_skips_packages_marked_retired() {
let inventory: sandogasa_inventory::Inventory = toml::from_str(
"[inventory]\n\
name = \"test\"\n\
description = \"test\"\n\
maintainer = \"tester\"\n\
\n\
[[package]]\n\
name = \"foo\"\n\
retired_on = [\"rawhide\"]\n",
)
.unwrap();
let bz = BzClient::new("http://127.0.0.1:1");
let dg = DistGitClient::with_base_url("http://127.0.0.1:1");
let entries = run(
&inventory,
&bz,
&dg,
&crate::WalkFilterArgs::default(),
false,
None,
false,
)
.await
.unwrap();
assert!(entries.is_empty());
}
#[tokio::test]
async fn run_batch_mode_classifies_from_one_query() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("email1", "me@example.com"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bugs": [{
"id": 7,
"summary": "foo-1.2.0 is available",
"status": "NEW",
"resolution": "",
"product": "Fedora",
"component": ["foo"],
"severity": "unspecified",
"priority": "unspecified",
"assigned_to": "me@example.com",
"creator": "upstream-release-monitoring@fedoraproject.org",
"creation_time": "2026-05-01T00:00:00Z",
"last_change_time": "2026-05-01T00:00:00Z",
}],
"total_matches": 1
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rpms/foo/raw/rawhide/f/foo.spec"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("Name: foo\nVersion: 1.2.0\nRelease: 1%{?dist}\n"),
)
.mount(&server)
.await;
let inventory: sandogasa_inventory::Inventory = toml::from_str(
"[inventory]\n\
name = \"test\"\n\
description = \"test\"\n\
maintainer = \"tester\"\n\
\n\
[[package]]\n\
name = \"foo\"\n",
)
.unwrap();
let bz = BzClient::new(&server.uri());
let dg = DistGitClient::with_base_url(&server.uri());
let entries = run(
&inventory,
&bz,
&dg,
&crate::WalkFilterArgs::default(),
false,
Some("me@example.com"),
false,
)
.await
.unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].package, "foo");
assert_eq!(entries[0].bump, Bump::UpToDate);
print_report(&entries);
}
#[test]
fn classify_minor_and_patch_are_non_breaking() {
assert_eq!(classify("1.4.2", "1.5.0"), Bump::NonBreaking);
assert_eq!(classify("1.4.2", "1.4.3"), Bump::NonBreaking);
assert_eq!(classify("1.4", "1.4.1"), Bump::NonBreaking);
}
#[test]
fn classify_major_is_breaking() {
assert_eq!(classify("1.4.2", "2.0.0"), Bump::Breaking);
}
#[test]
fn classify_same_version_is_up_to_date() {
assert_eq!(classify("0.6.1", "0.6.1"), Bump::UpToDate);
assert_eq!(classify("1.4", "1.4.0"), Bump::UpToDate);
}
#[test]
fn classify_zero_x_follows_cargo_rule() {
assert_eq!(classify("0.4.0", "0.5.0"), Bump::Breaking);
assert_eq!(classify("0.4.2", "0.4.3"), Bump::NonBreaking);
assert_eq!(classify("0.0.3", "0.0.4"), Bump::Breaking);
}
#[test]
fn classify_non_numeric_needs_review() {
assert_eq!(classify("1.0", "2.0rc1"), Bump::NeedsReview);
assert_eq!(classify("5.000a", "5.000b"), Bump::NeedsReview);
assert_eq!(classify("1.2.3", "1.2.4.dev-r1"), Bump::NeedsReview);
}
#[test]
fn classify_ignores_build_metadata() {
assert_eq!(classify("1.6.2", "1.7.0+v1.7.0"), Bump::NonBreaking);
assert_eq!(classify("1.6.2+v1.6.2", "2.0.0"), Bump::Breaking);
assert_eq!(classify("1.7.0+v1.6.0", "1.7.0+v1.7.0"), Bump::UpToDate);
assert!(version_at_least("1.7.0+v1.7.0", "1.7.0"));
}
#[test]
fn classify_downgrade_needs_review() {
assert_eq!(classify("2.0.0", "1.9.0"), Bump::NeedsReview);
}
#[test]
fn version_at_least_compares_numerically() {
assert!(version_at_least("1.10.0", "1.9.0"));
assert!(version_at_least("0.6.1", "0.6.1"));
assert!(version_at_least("1.4", "1.4.0"));
assert!(!version_at_least("1.4.0", "1.4.1"));
assert!(version_at_least("2.0rc1", "2.0rc1"));
assert!(!version_at_least("2.0rc2", "2.0rc1"));
}
#[test]
fn classify_with_status_handles_unreadable_current() {
assert_eq!(
classify_with_status(Some("1.4.2"), "1.5.0", false),
Bump::NonBreaking
);
assert_eq!(classify_with_status(None, "0.9.0", true), Bump::Retired);
assert_eq!(
classify_with_status(None, "0.9.0", false),
Bump::NeedsReview
);
}
#[test]
fn parse_spec_version_reads_version_line() {
let spec = "Name: foo\nVersion: 1.2.3\nRelease: 1%{?dist}\n";
assert_eq!(parse_spec_version(spec).as_deref(), Some("1.2.3"));
}
#[test]
fn parse_spec_version_absent() {
assert_eq!(parse_spec_version("Name: foo\nRelease: 1\n"), None);
}
fn bug(id: u64, summary: &str) -> Bug {
serde_json::from_value(serde_json::json!({
"id": id,
"summary": summary,
"status": "NEW",
"resolution": "",
"product": "Fedora",
"component": ["foo"],
"severity": "unspecified",
"priority": "unspecified",
"assigned_to": "nobody@fedoraproject.org",
"creator": "upstream-release-monitoring@fedoraproject.org",
"creation_time": "2026-01-01T00:00:00Z",
"last_change_time": "2026-01-01T00:00:00Z",
}))
.unwrap()
}
#[test]
fn pick_latest_chooses_highest_version() {
let bugs = vec![
bug(1, "foo-1.2.0 is available"),
bug(2, "foo-1.10.0 is available"),
bug(3, "foo-1.3.0 is available"),
];
assert_eq!(pick_latest(&bugs, "foo"), Some((2, "1.10.0".to_string())));
}
#[test]
fn pick_latest_none_when_unparseable_summary() {
let bugs = vec![bug(1, "wat")];
assert_eq!(pick_latest(&bugs, "foo"), None);
}
}