use std::collections::BTreeMap;
use sandogasa_bodhi::BodhiClient;
use sandogasa_bodhi::models::{BodhiRelease, Update};
use sandogasa_bugzilla::BzClient;
use sandogasa_bugzilla::models::Bug;
use sandogasa_distgit::DistGitClient;
use sandogasa_inventory::{Inventory, Priority};
use sandogasa_koji::parse_nvr;
use crate::semver_audit::version_at_least;
use sandogasa_bugclass::bugzilla::extract_new_version;
pub const RELEASE_MONITORING_REPORTER: &str = "upstream-release-monitoring@fedoraproject.org";
pub const PRODUCTS: &[&str] = &["Fedora", "Fedora EPEL"];
#[derive(Debug, Clone)]
pub struct PriorityUpdate {
pub bug_id: u64,
pub component: String,
pub summary: String,
pub current_priority: String,
pub target_priority: Priority,
}
#[derive(Debug)]
pub enum PackageOutcome {
NoPriority,
OptedOut,
NoBugs,
AllAlreadyTriaged(usize),
Updates(Vec<PriorityUpdate>),
}
pub fn plan_package(package: &str, resolved: Option<Priority>, bugs: &[Bug]) -> PackageOutcome {
let target = match resolved {
None => return PackageOutcome::NoPriority,
Some(Priority::Unspecified) => return PackageOutcome::OptedOut,
Some(p) => p,
};
if bugs.is_empty() {
return PackageOutcome::NoBugs;
}
let mut updates = Vec::new();
let mut already_triaged = 0usize;
for bug in bugs {
if bug.priority != "unspecified" {
already_triaged += 1;
continue;
}
updates.push(PriorityUpdate {
bug_id: bug.id,
component: package.to_string(),
summary: bug.summary.clone(),
current_priority: bug.priority.clone(),
target_priority: target,
});
}
if updates.is_empty() {
PackageOutcome::AllAlreadyTriaged(already_triaged)
} else {
PackageOutcome::Updates(updates)
}
}
pub fn bug_search_query(component: &str) -> String {
let mut parts: Vec<String> = vec![
format!("component={}", urlencode(component)),
format!("reporter={}", urlencode(RELEASE_MONITORING_REPORTER)),
"bug_status=__open__".to_string(),
];
for product in PRODUCTS {
parts.push(format!("product={}", urlencode(product)));
}
parts.join("&")
}
pub fn batch_bug_query(email: &str, any_reporter: bool) -> String {
let mut parts: Vec<String> = vec![
"bug_status=__open__".to_string(),
format!("email1={}", urlencode(email)),
"emailassigned_to1=1".to_string(),
"emailcc1=1".to_string(),
"emailtype1=equals".to_string(),
];
if !any_reporter {
parts.insert(
0,
format!("reporter={}", urlencode(RELEASE_MONITORING_REPORTER)),
);
}
for product in PRODUCTS {
parts.push(format!("product={}", urlencode(product)));
}
parts.join("&")
}
pub fn group_bugs_by_component(bugs: Vec<Bug>) -> BTreeMap<String, Vec<Bug>> {
let mut map: BTreeMap<String, Vec<Bug>> = BTreeMap::new();
for bug in bugs {
let Some(component) = bug.component.first() else {
continue;
};
map.entry(component.clone()).or_default().push(bug);
}
map
}
fn urlencode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
pub fn group_by_component(updates: &[PriorityUpdate]) -> BTreeMap<String, Vec<&PriorityUpdate>> {
let mut out: BTreeMap<String, Vec<&PriorityUpdate>> = BTreeMap::new();
for u in updates {
out.entry(u.component.clone()).or_default().push(u);
}
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BuildSource {
Bodhi { alias: String, stable: bool },
DistGit,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AddressingBuild {
pub nvr: String,
pub source: BuildSource,
}
impl AddressingBuild {
pub fn is_stable(&self) -> bool {
match &self.source {
BuildSource::Bodhi { stable, .. } => *stable,
BuildSource::DistGit => true,
}
}
}
type SpecCache = BTreeMap<(String, String), Option<(String, Option<String>)>>;
#[derive(Debug, Clone)]
pub struct ReleaseFinding {
pub release: String,
pub build: Option<AddressingBuild>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StaleAction {
CloseErrata,
Modified,
AskClose,
}
#[derive(Debug, Clone)]
pub struct StaleBugPlan {
pub bug_id: u64,
pub component: String,
pub summary: String,
pub version: String,
pub action: StaleAction,
pub fixed_in: String,
pub findings: Vec<ReleaseFinding>,
}
pub fn find_addressing(
updates: &[Update],
package: &str,
target_version: &str,
) -> Option<AddressingBuild> {
let mut best: Option<(AddressingBuild, String)> = None;
for update in updates {
let stable = update.status == "stable";
for build in &update.builds {
let Some((name, version, _)) = parse_nvr(&build.nvr) else {
continue;
};
if name != package || !version_at_least(version, target_version) {
continue;
}
let replace = match &best {
None => true,
Some((cur, cur_version)) => {
version_at_least(version, cur_version)
&& (version != cur_version || (stable && !cur.is_stable()))
}
};
if replace {
best = Some((
AddressingBuild {
nvr: build.nvr.clone(),
source: BuildSource::Bodhi {
alias: update.alias.clone(),
stable,
},
},
version.to_string(),
));
}
}
}
best.map(|(b, _)| b)
}
pub fn plan_stale_bug(
bug: &Bug,
component: &str,
version: &str,
findings: Vec<ReleaseFinding>,
) -> Option<StaleBugPlan> {
let addressed: Vec<&AddressingBuild> =
findings.iter().filter_map(|f| f.build.as_ref()).collect();
if addressed.is_empty() {
return None;
}
let action = if addressed.iter().any(|b| !b.is_stable()) {
StaleAction::Modified
} else if addressed.len() == findings.len() {
StaleAction::CloseErrata
} else {
StaleAction::AskClose
};
let mut nvrs: Vec<&str> = Vec::new();
for b in &addressed {
if !nvrs.contains(&b.nvr.as_str()) {
nvrs.push(&b.nvr);
}
}
let fixed_in = nvrs.join(" ");
if action == StaleAction::Modified && bug.status == "MODIFIED" && !bug.cf_fixed_in.is_empty() {
return None;
}
Some(StaleBugPlan {
bug_id: bug.id,
component: component.to_string(),
summary: bug.summary.clone(),
version: version.to_string(),
action,
fixed_in,
findings,
})
}
pub fn stale_comment(plan: &StaleBugPlan) -> String {
let mut out = format!(
"Bodhi has builds addressing this update (version {} or \
newer):\n",
plan.version
);
for f in &plan.findings {
match &f.build {
Some(b) => match &b.source {
BuildSource::Bodhi { alias, stable } => out.push_str(&format!(
" {}: {} — https://bodhi.fedoraproject.org/updates/{} ({})\n",
f.release,
b.nvr,
alias,
if *stable { "stable" } else { "testing" }
)),
BuildSource::DistGit => out.push_str(&format!(
" {}: {} (already in dist-git; shipped before \
this release existed)\n",
f.release, b.nvr
)),
},
None => out.push_str(&format!(" {}: no update found\n", f.release)),
}
}
out.push('\n');
out.push_str(match plan.action {
StaleAction::CloseErrata => {
"The new version is in stable updates for every active \
release this package has a branch for; closing as ERRATA."
}
StaleAction::Modified => {
"Some updates are still in testing; marking this bug \
MODIFIED until they reach stable."
}
StaleAction::AskClose => {
"The releases without an update are listed above — their \
branches are not expected to rebase; closing as ERRATA."
}
});
out
}
fn release_rank(name: &str) -> u64 {
name.chars()
.filter(|c| c.is_ascii_digit())
.collect::<String>()
.parse()
.unwrap_or(0)
}
fn dist_tag(release: &BodhiRelease) -> String {
let n = release_rank(&release.name);
if release.id_prefix == "FEDORA-EPEL" {
format!(".el{n}")
} else {
format!(".fc{n}")
}
}
pub fn nvr_from_spec(
package: &str,
version: &str,
release_field: Option<&str>,
dist: &str,
) -> String {
if let Some(rel) = release_field {
let expanded = rel.replace("%{?dist}", dist).replace("%{dist}", dist);
if !expanded.contains('%') {
return format!("{package}-{version}-{expanded}");
}
}
format!("{package}-{version}")
}
#[allow(clippy::too_many_arguments)]
pub async fn run(
inventory: &Inventory,
client: &BzClient,
dg: &DistGitClient,
bodhi: &BodhiClient,
filter: &crate::WalkFilterArgs,
batch_email: Option<&str>,
skip_stale: bool,
close_stale: bool,
dry_run: bool,
yes: bool,
verbose: bool,
) -> Result<RunReport, String> {
let mut all_updates: Vec<PriorityUpdate> = Vec::new();
let mut stale_plans: Vec<StaleBugPlan> = Vec::new();
let mut packages_with_priority = 0usize;
let mut releases: Option<Vec<BodhiRelease>> = None;
let mut updates_cache: BTreeMap<(String, String), Vec<Update>> = BTreeMap::new();
let mut spec_cache = SpecCache::new();
let batch_bugs: Option<BTreeMap<String, Vec<Bug>>> = match batch_email {
Some(email) => {
if verbose {
eprintln!("[poi-tracker] batch: querying bugs for {email}");
}
let bugs = client
.search(&batch_bug_query(email, false), 0)
.await
.map_err(|e| format!("Bugzilla batch search: {e}"))?;
if verbose {
eprintln!("[poi-tracker] batch: {} open bug(s) found", bugs.len());
}
Some(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 (run triage-retired)",
pkg.name
);
}
continue;
}
if pkg.is_retired_on("rawhide") {
marked_retired += 1;
if verbose {
eprintln!(
"[poi-tracker] {}: marked retired on rawhide in the \
inventory; skipping (run triage-retired)",
pkg.name
);
}
continue;
}
let resolved = inventory.priority_for(&pkg.name);
let target = match resolved {
None => {
if verbose {
eprintln!("[poi-tracker] {}: no priority configured", pkg.name);
}
None
}
Some(Priority::Unspecified) => {
if verbose {
eprintln!("[poi-tracker] {}: priority=unspecified (opt-out)", pkg.name);
}
None
}
Some(p) => {
packages_with_priority += 1;
Some(p)
}
};
if target.is_none() && skip_stale {
continue;
}
let per_pkg;
let bugs: &[Bug] = match &batch_bugs {
Some(map) => map.get(&pkg.name).map(Vec::as_slice).unwrap_or(&[]),
None => {
if verbose {
eprintln!(
"[poi-tracker] {}: searching release-monitoring bugs",
pkg.name
);
}
let query = bug_search_query(&pkg.name);
per_pkg = client
.search(&query, 0)
.await
.map_err(|e| format!("Bugzilla search for {}: {e}", pkg.name))?;
&per_pkg
}
};
if target.is_some() {
match plan_package(&pkg.name, resolved, bugs) {
PackageOutcome::NoPriority | PackageOutcome::OptedOut => {}
PackageOutcome::NoBugs => {
if verbose {
eprintln!(
"[poi-tracker] {}: no open release-monitoring bugs",
pkg.name
);
}
}
PackageOutcome::AllAlreadyTriaged(n) => {
if verbose {
eprintln!(
"[poi-tracker] {}: {n} open bug(s) already triaged",
pkg.name
);
}
}
PackageOutcome::Updates(updates) => {
all_updates.extend(updates);
}
}
}
if skip_stale || bugs.is_empty() {
continue;
}
plan_stale_for_package(
&pkg.name,
bugs,
dg,
bodhi,
&mut releases,
&mut updates_cache,
&mut spec_cache,
&mut stale_plans,
verbose,
)
.await;
}
if marked_retired > 0 {
eprintln!(
"({marked_retired} package(s) skipped: marked retired on \
rawhide in the inventory)"
);
}
print_plan(&all_updates);
print_stale_plan(&stale_plans);
let mut report = RunReport {
packages_with_priority,
updates_planned: all_updates.len(),
updates_applied: 0,
stale_planned: stale_plans.len(),
stale_applied: 0,
failures: 0,
};
if all_updates.is_empty() && stale_plans.is_empty() {
return Ok(report);
}
if dry_run {
eprintln!("\n(dry-run: not applying)");
return Ok(report);
}
let ask_count = stale_plans
.iter()
.filter(|p| p.action == StaleAction::AskClose)
.count();
let close_partial = if ask_count == 0 || close_stale {
close_stale
} else if yes {
eprintln!(
"(skipping {ask_count} partially-addressed bug(s); pass \
--close-stale to close them under -y)"
);
false
} else {
confirm(&format!(
"\nClose {ask_count} bug(s) addressed only in some \
releases as ERRATA?"
))?
};
if !close_partial {
stale_plans.retain(|p| p.action != StaleAction::AskClose);
}
report.stale_planned = stale_plans.len();
let closing: Vec<u64> = stale_plans
.iter()
.filter(|p| p.action != StaleAction::Modified)
.map(|p| p.bug_id)
.collect();
all_updates.retain(|u| !closing.contains(&u.bug_id));
report.updates_planned = all_updates.len();
let total = all_updates.len() + stale_plans.len();
if total == 0 {
return Ok(report);
}
if !yes && !confirm(&format!("\nApply {total} update(s)?"))? {
eprintln!("aborted.");
return Ok(report);
}
for u in &all_updates {
let body = serde_json::json!({"priority": u.target_priority.as_bugzilla_str()});
match client.update(u.bug_id, &body).await {
Ok(()) => {
report.updates_applied += 1;
eprintln!(
"updated bug {} ({}): {} -> {}",
u.bug_id,
u.component,
u.current_priority,
u.target_priority.as_bugzilla_str()
);
}
Err(e) => {
report.failures += 1;
eprintln!("error: bug {} ({}): {e}", u.bug_id, u.component);
}
}
}
for plan in &stale_plans {
let mut body = serde_json::json!({
"cf_fixed_in": plan.fixed_in,
"comment": { "body": stale_comment(plan) },
});
let outcome = match plan.action {
StaleAction::Modified => {
body["status"] = serde_json::json!("MODIFIED");
"-> MODIFIED"
}
StaleAction::CloseErrata | StaleAction::AskClose => {
body["status"] = serde_json::json!("CLOSED");
body["resolution"] = serde_json::json!("ERRATA");
"-> CLOSED/ERRATA"
}
};
match client.update(plan.bug_id, &body).await {
Ok(()) => {
report.stale_applied += 1;
eprintln!(
"updated bug {} ({}): {outcome} (fixed in: {})",
plan.bug_id, plan.component, plan.fixed_in
);
}
Err(e) => {
report.failures += 1;
eprintln!("error: bug {} ({}): {e}", plan.bug_id, plan.component);
}
}
}
Ok(report)
}
#[allow(clippy::too_many_arguments)]
async fn plan_stale_for_package(
package: &str,
bugs: &[Bug],
dg: &DistGitClient,
bodhi: &BodhiClient,
releases: &mut Option<Vec<BodhiRelease>>,
updates_cache: &mut BTreeMap<(String, String), Vec<Update>>,
spec_cache: &mut SpecCache,
out: &mut Vec<StaleBugPlan>,
verbose: bool,
) {
let with_version: Vec<(&Bug, String)> = bugs
.iter()
.filter_map(|b| extract_new_version(&b.summary, package).map(|v| (b, v)))
.collect();
if with_version.is_empty() {
return;
}
if releases.is_none() {
match bodhi.active_releases().await {
Ok(r) => *releases = Some(r),
Err(e) => {
eprintln!("warning: cannot fetch Bodhi releases: {e}");
return;
}
}
}
let releases = releases.as_ref().unwrap();
let branches = match dg.list_branches(package).await {
Ok(b) => b,
Err(e) => {
eprintln!("warning: {package}: cannot list dist-git branches: {e}");
return;
}
};
for (bug, version) in with_version {
let prefix = match bug.product.as_str() {
"Fedora" => "FEDORA",
"Fedora EPEL" => "FEDORA-EPEL",
_ => continue,
};
let mut relevant: Vec<&BodhiRelease> = releases
.iter()
.filter(|r| r.id_prefix == prefix && branches.iter().any(|b| b == &r.branch))
.collect();
if relevant.is_empty() {
continue;
}
relevant.sort_by_key(|r| std::cmp::Reverse(release_rank(&r.name)));
let mut findings = Vec::with_capacity(relevant.len());
let mut failed = false;
let mut rawhide_pending = false;
for rel in &relevant {
let key = (package.to_string(), rel.name.clone());
if !updates_cache.contains_key(&key) {
if verbose {
eprintln!("[poi-tracker] {package}: querying Bodhi for {}", rel.name);
}
match bodhi
.updates_for_package(package, &rel.name, &["stable", "testing"])
.await
{
Ok(u) => {
updates_cache.insert(key.clone(), u);
}
Err(e) => {
eprintln!(
"warning: {package}: Bodhi query for {} failed: {e}",
rel.name
);
failed = true;
break;
}
}
}
let mut build = find_addressing(&updates_cache[&key], package, &version);
if build.is_none() {
let spec_key = (package.to_string(), rel.branch.clone());
if !spec_cache.contains_key(&spec_key) {
if verbose {
eprintln!(
"[poi-tracker] {package}: checking {} dist-git spec",
rel.branch
);
}
let parsed = match dg.fetch_spec(package, &rel.branch).await {
Ok(spec) => crate::semver_audit::parse_spec_version(&spec)
.map(|v| (v, crate::semver_audit::parse_spec_field(&spec, "Release"))),
Err(_) => None,
};
spec_cache.insert(spec_key.clone(), parsed);
}
if let Some((spec_version, release_field)) = &spec_cache[&spec_key]
&& version_at_least(spec_version, &version)
{
build = Some(AddressingBuild {
nvr: nvr_from_spec(
package,
spec_version,
release_field.as_deref(),
&dist_tag(rel),
),
source: BuildSource::DistGit,
});
}
}
if build.is_none() && rel.branch == "rawhide" {
rawhide_pending = true;
break;
}
findings.push(ReleaseFinding {
release: rel.name.clone(),
build,
});
}
if failed {
continue;
}
if rawhide_pending {
if verbose {
eprintln!(
"[poi-tracker] {package}: bug {} ({version}) not yet in \
rawhide; skipping stable-release checks",
bug.id
);
}
continue;
}
if let Some(plan) = plan_stale_bug(bug, package, &version, findings) {
out.push(plan);
} else if verbose {
eprintln!(
"[poi-tracker] {package}: bug {} ({version}) still pending",
bug.id
);
}
}
}
#[derive(Debug, Default)]
pub struct RunReport {
pub packages_with_priority: usize,
pub updates_planned: usize,
pub updates_applied: usize,
pub stale_planned: usize,
pub stale_applied: usize,
pub failures: usize,
}
fn print_plan(updates: &[PriorityUpdate]) {
if updates.is_empty() {
println!("Nothing to update.");
return;
}
println!("Planned priority updates:");
let grouped = group_by_component(updates);
for (component, entries) in &grouped {
println!(
" {component} ({} bug(s) → {}):",
entries.len(),
entries[0].target_priority.as_bugzilla_str()
);
for u in entries {
println!(
" bug {} [{}]: {}",
u.bug_id, u.current_priority, u.summary
);
}
}
println!("\nTotal: {} update(s).", updates.len());
}
fn print_stale_plan(plans: &[StaleBugPlan]) {
if plans.is_empty() {
return;
}
println!("\nBugs already addressed in Bodhi:");
for (action, heading) in [
(
StaleAction::CloseErrata,
"Close as ERRATA (stable everywhere)",
),
(StaleAction::Modified, "Mark MODIFIED (still in testing)"),
(
StaleAction::AskClose,
"Addressed only in some releases (close on confirm / --close-stale)",
),
] {
let group: Vec<&StaleBugPlan> = plans.iter().filter(|p| p.action == action).collect();
if group.is_empty() {
continue;
}
println!(" {heading}:");
for p in &group {
println!(
" bug {} ({}): {} — fixed in: {}",
p.bug_id, p.component, p.summary, p.fixed_in
);
}
}
}
pub(crate) fn confirm(prompt: &str) -> Result<bool, String> {
use std::io::{BufRead, Write};
eprint!("{prompt} [y/N]: ");
std::io::stderr().flush().map_err(|e| e.to_string())?;
let mut line = String::new();
std::io::stdin()
.lock()
.read_line(&mut line)
.map_err(|e| e.to_string())?;
Ok(line.trim().eq_ignore_ascii_case("y"))
}
#[cfg(test)]
mod tests {
use super::*;
fn make_bug(id: u64, priority: &str, summary: &str) -> Bug {
serde_json::from_value(serde_json::json!({
"id": id,
"summary": summary,
"status": "NEW",
"resolution": "",
"product": "Fedora",
"component": ["python-django"],
"severity": "unspecified",
"priority": priority,
"assigned_to": "nobody@fedoraproject.org",
"creator": RELEASE_MONITORING_REPORTER,
"creation_time": "2026-05-01T00:00:00Z",
"last_change_time": "2026-05-01T00:00:00Z",
}))
.unwrap()
}
#[test]
fn plan_no_resolved_priority_is_no_priority() {
let outcome = plan_package("any", None, &[make_bug(1, "unspecified", "x")]);
assert!(matches!(outcome, PackageOutcome::NoPriority));
}
#[test]
fn plan_explicit_unspecified_is_opt_out() {
let outcome = plan_package(
"any",
Some(Priority::Unspecified),
&[make_bug(1, "unspecified", "x")],
);
assert!(matches!(outcome, PackageOutcome::OptedOut));
}
#[test]
fn plan_no_bugs_returns_no_bugs() {
let outcome = plan_package("any", Some(Priority::High), &[]);
assert!(matches!(outcome, PackageOutcome::NoBugs));
}
#[test]
fn plan_updates_only_unspecified_bugs() {
let bugs = vec![
make_bug(1, "unspecified", "django 5.1.3 is available"),
make_bug(2, "low", "django 5.1.2 is available"),
make_bug(3, "unspecified", "django 5.0.9 is available"),
make_bug(4, "urgent", "django 4.2.16 is available"),
];
let outcome = plan_package("python-django", Some(Priority::High), &bugs);
match outcome {
PackageOutcome::Updates(updates) => {
assert_eq!(updates.len(), 2);
let ids: Vec<u64> = updates.iter().map(|u| u.bug_id).collect();
assert_eq!(ids, vec![1, 3]);
assert!(updates.iter().all(|u| u.target_priority == Priority::High));
}
other => panic!("expected Updates, got {other:?}"),
}
}
#[test]
fn plan_all_already_triaged() {
let bugs = vec![make_bug(1, "low", "x"), make_bug(2, "medium", "y")];
let outcome = plan_package("any", Some(Priority::High), &bugs);
match outcome {
PackageOutcome::AllAlreadyTriaged(n) => assert_eq!(n, 2),
other => panic!("expected AllAlreadyTriaged, got {other:?}"),
}
}
fn make_update(alias: &str, status: &str, nvrs: &[&str]) -> Update {
serde_json::from_value(serde_json::json!({
"alias": alias,
"status": status,
"builds": nvrs.iter().map(|n| serde_json::json!({"nvr": n})).collect::<Vec<_>>(),
}))
.unwrap()
}
fn finding(release: &str, build: Option<(&str, &str, bool)>) -> ReleaseFinding {
ReleaseFinding {
release: release.to_string(),
build: build.map(|(nvr, alias, stable)| AddressingBuild {
nvr: nvr.to_string(),
source: BuildSource::Bodhi {
alias: alias.to_string(),
stable,
},
}),
}
}
#[test]
fn find_addressing_picks_highest_matching_build() {
let updates = vec![
make_update("FEDORA-1", "stable", &["foo-1.2.0-1.fc43", "bar-9-1.fc43"]),
make_update("FEDORA-2", "testing", &["foo-1.3.0-1.fc43"]),
];
let best = find_addressing(&updates, "foo", "1.2.0").unwrap();
assert_eq!(best.nvr, "foo-1.3.0-1.fc43");
assert_eq!(
best.source,
BuildSource::Bodhi {
alias: "FEDORA-2".to_string(),
stable: false
}
);
assert!(!best.is_stable());
}
#[test]
fn find_addressing_prefers_stable_on_version_tie() {
let updates = vec![
make_update("FEDORA-T", "testing", &["foo-1.2.0-1.fc43"]),
make_update("FEDORA-S", "stable", &["foo-1.2.0-2.fc43"]),
];
let best = find_addressing(&updates, "foo", "1.2.0").unwrap();
assert!(matches!(
best.source,
BuildSource::Bodhi { ref alias, stable: true } if alias == "FEDORA-S"
));
}
#[test]
fn nvr_from_spec_expands_dist() {
assert_eq!(
nvr_from_spec("foo", "1.2.0", Some("1%{?dist}"), ".fc43"),
"foo-1.2.0-1.fc43"
);
assert_eq!(
nvr_from_spec("foo", "1.2.0", Some("%autorelease"), ".fc43"),
"foo-1.2.0"
);
assert_eq!(nvr_from_spec("foo", "1.2.0", None, ".fc43"), "foo-1.2.0");
}
#[test]
fn plan_stale_dedupes_identical_nvrs() {
let bug = make_bug(1, "unspecified", "foo-1.2.0 is available");
let distgit = |rel: &str| ReleaseFinding {
release: rel.to_string(),
build: Some(AddressingBuild {
nvr: "foo-1.2.0".to_string(),
source: BuildSource::DistGit,
}),
};
let plan =
plan_stale_bug(&bug, "foo", "1.2.0", vec![distgit("F45"), distgit("F43")]).unwrap();
assert_eq!(plan.action, StaleAction::CloseErrata);
assert_eq!(plan.fixed_in, "foo-1.2.0");
}
#[test]
fn find_addressing_ignores_older_versions_and_other_packages() {
let updates = vec![make_update(
"FEDORA-1",
"stable",
&["foo-1.1.0-1.fc43", "foolish-2.0-1.fc43"],
)];
assert!(find_addressing(&updates, "foo", "1.2.0").is_none());
}
#[test]
fn plan_stale_pending_when_nothing_addresses() {
let bug = make_bug(1, "unspecified", "foo-1.2.0 is available");
let findings = vec![finding("F45", None), finding("F43", None)];
assert!(plan_stale_bug(&bug, "foo", "1.2.0", findings).is_none());
}
#[test]
fn plan_stale_close_when_stable_everywhere() {
let bug = make_bug(1, "unspecified", "foo-1.2.0 is available");
let findings = vec![
finding("F45", Some(("foo-1.2.0-1.fc45", "FEDORA-A", true))),
finding("F43", Some(("foo-1.2.0-1.fc43", "FEDORA-B", true))),
];
let plan = plan_stale_bug(&bug, "foo", "1.2.0", findings).unwrap();
assert_eq!(plan.action, StaleAction::CloseErrata);
assert_eq!(plan.fixed_in, "foo-1.2.0-1.fc45 foo-1.2.0-1.fc43");
}
#[test]
fn plan_stale_modified_when_any_testing() {
let bug = make_bug(1, "unspecified", "foo-1.2.0 is available");
let findings = vec![
finding("F45", Some(("foo-1.2.0-1.fc45", "FEDORA-A", true))),
finding("F43", Some(("foo-1.2.0-1.fc43", "FEDORA-B", false))),
];
let plan = plan_stale_bug(&bug, "foo", "1.2.0", findings).unwrap();
assert_eq!(plan.action, StaleAction::Modified);
}
#[test]
fn plan_stale_ask_when_partially_addressed_stable() {
let bug = make_bug(1, "unspecified", "foo-1.2.0 is available");
let findings = vec![
finding("F45", Some(("foo-1.2.0-1.fc45", "FEDORA-A", true))),
finding("F43", None),
];
let plan = plan_stale_bug(&bug, "foo", "1.2.0", findings).unwrap();
assert_eq!(plan.action, StaleAction::AskClose);
assert_eq!(plan.fixed_in, "foo-1.2.0-1.fc45");
}
#[test]
fn plan_stale_skips_already_modified_with_fixed_in() {
let mut bug = make_bug(1, "unspecified", "foo-1.2.0 is available");
bug.status = "MODIFIED".to_string();
bug.cf_fixed_in = "foo-1.2.0-1.fc43".to_string();
let findings = vec![finding(
"F43",
Some(("foo-1.2.0-1.fc43", "FEDORA-B", false)),
)];
assert!(plan_stale_bug(&bug, "foo", "1.2.0", findings).is_none());
}
#[test]
fn stale_comment_lists_releases_and_action() {
let bug = make_bug(1, "unspecified", "foo-1.2.0 is available");
let findings = vec![
finding("F45", Some(("foo-1.2.0-1.fc45", "FEDORA-A", true))),
finding("F43", None),
];
let plan = plan_stale_bug(&bug, "foo", "1.2.0", findings).unwrap();
let comment = stale_comment(&plan);
assert!(comment.contains("F45: foo-1.2.0-1.fc45"));
assert!(comment.contains("https://bodhi.fedoraproject.org/updates/FEDORA-A"));
assert!(comment.contains("(stable)"));
assert!(comment.contains("F43: no update found"));
assert!(comment.contains("closing as ERRATA"));
}
#[test]
fn release_rank_orders_names() {
assert!(release_rank("F45") > release_rank("F43"));
assert!(release_rank("EPEL-10") > release_rank("EPEL-9"));
}
#[test]
fn bug_search_query_includes_required_filters() {
let q = bug_search_query("python-django");
assert!(q.contains("component=python-django"));
assert!(q.contains("bug_status=__open__"));
assert!(q.contains("product=Fedora"));
assert!(q.contains("product=Fedora%20EPEL"));
assert!(q.contains("reporter=upstream-release-monitoring%40fedoraproject.org"));
}
use wiremock::matchers::{body_partial_json, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn test_inventory(priority: Option<&str>) -> Inventory {
let prio = priority
.map(|p| format!("priority = \"{p}\"\n"))
.unwrap_or_default();
toml::from_str(&format!(
"[inventory]\n\
name = \"test\"\n\
description = \"test\"\n\
maintainer = \"tester\"\n\
\n\
[[package]]\n\
name = \"foo\"\n\
{prio}"
))
.unwrap()
}
fn bug_json(id: u64, summary: &str) -> serde_json::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": RELEASE_MONITORING_REPORTER,
"creation_time": "2026-05-01T00:00:00Z",
"last_change_time": "2026-05-01T00:00:00Z",
})
}
async fn mount_common(server: &MockServer) {
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("component", "foo"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bugs": [bug_json(1, "foo-1.2.0 is available")],
"total_matches": 1
})))
.mount(server)
.await;
Mock::given(method("GET"))
.and(path("/releases/"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"releases": [
{"name": "F45", "branch": "rawhide", "id_prefix": "FEDORA", "state": "pending"},
{"name": "F43", "branch": "f43", "id_prefix": "FEDORA", "state": "current"}
],
"total": 2, "page": 1, "pages": 1
})))
.mount(server)
.await;
Mock::given(method("GET"))
.and(path("/api/0/rpms/foo/git/branches"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"branches": ["rawhide", "f43"]
})))
.mount(server)
.await;
}
fn updates_response(updates: serde_json::Value) -> ResponseTemplate {
ResponseTemplate::new(200).set_body_json(serde_json::json!({
"updates": updates, "total": 1, "page": 1, "pages": 1
}))
}
#[tokio::test]
async fn run_closes_bug_stable_everywhere_with_distgit_fallback() {
let server = MockServer::start().await;
mount_common(&server).await;
Mock::given(method("GET"))
.and(path("/updates/"))
.and(query_param("releases", "F45"))
.respond_with(updates_response(serde_json::json!([{
"alias": "FEDORA-2026-aaa",
"status": "stable",
"builds": [{"nvr": "foo-1.2.0-1.fc45"}]
}])))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/updates/"))
.and(query_param("releases", "F43"))
.respond_with(updates_response(serde_json::json!([])))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rpms/foo/raw/f43/f/foo.spec"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("Name: foo\nVersion: 1.2.0\nRelease: 1%{?dist}\n"),
)
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path("/rest/bug/1"))
.and(body_partial_json(serde_json::json!({
"status": "CLOSED",
"resolution": "ERRATA",
"cf_fixed_in": "foo-1.2.0-1.fc45 foo-1.2.0-1.fc43"
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.expect(1)
.mount(&server)
.await;
let inventory = test_inventory(Some("high"));
let bz = BzClient::new(&server.uri());
let dg = DistGitClient::with_base_url(&server.uri());
let bodhi = BodhiClient::with_base_url(&server.uri());
let report = run(
&inventory,
&bz,
&dg,
&bodhi,
&crate::WalkFilterArgs::default(),
None,
false,
false,
false,
true,
false,
)
.await
.unwrap();
assert_eq!(report.stale_applied, 1);
assert_eq!(
report.updates_planned, 0,
"priority bump dropped for closing bug"
);
assert_eq!(report.failures, 0);
}
#[tokio::test]
async fn run_marks_modified_when_update_in_testing() {
let server = MockServer::start().await;
mount_common(&server).await;
Mock::given(method("GET"))
.and(path("/updates/"))
.and(query_param("releases", "F45"))
.respond_with(updates_response(serde_json::json!([{
"alias": "FEDORA-2026-aaa",
"status": "stable",
"builds": [{"nvr": "foo-1.2.0-1.fc45"}]
}])))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/updates/"))
.and(query_param("releases", "F43"))
.respond_with(updates_response(serde_json::json!([{
"alias": "FEDORA-2026-bbb",
"status": "testing",
"builds": [{"nvr": "foo-1.2.0-1.fc43"}]
}])))
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path("/rest/bug/1"))
.and(body_partial_json(serde_json::json!({"status": "MODIFIED"})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.expect(1)
.mount(&server)
.await;
let inventory = test_inventory(None);
let bz = BzClient::new(&server.uri());
let dg = DistGitClient::with_base_url(&server.uri());
let bodhi = BodhiClient::with_base_url(&server.uri());
let report = run(
&inventory,
&bz,
&dg,
&bodhi,
&crate::WalkFilterArgs::default(),
None,
false,
false,
false,
true,
false,
)
.await
.unwrap();
assert_eq!(report.stale_applied, 1);
}
#[tokio::test]
async fn run_skips_partial_close_under_yes_without_close_stale() {
let server = MockServer::start().await;
mount_common(&server).await;
Mock::given(method("GET"))
.and(path("/updates/"))
.and(query_param("releases", "F45"))
.respond_with(updates_response(serde_json::json!([{
"alias": "FEDORA-2026-aaa",
"status": "stable",
"builds": [{"nvr": "foo-1.2.0-1.fc45"}]
}])))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/updates/"))
.and(query_param("releases", "F43"))
.respond_with(updates_response(serde_json::json!([])))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rpms/foo/raw/f43/f/foo.spec"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("Name: foo\nVersion: 1.1.0\nRelease: 1%{?dist}\n"),
)
.mount(&server)
.await;
let inventory = test_inventory(None);
let bz = BzClient::new(&server.uri());
let dg = DistGitClient::with_base_url(&server.uri());
let bodhi = BodhiClient::with_base_url(&server.uri());
let report = run(
&inventory,
&bz,
&dg,
&bodhi,
&crate::WalkFilterArgs::default(),
None,
false,
false,
false,
true,
false,
)
.await
.unwrap();
assert_eq!(report.stale_planned, 0, "AskClose dropped under -y");
assert_eq!(report.stale_applied, 0);
}
#[tokio::test]
async fn run_short_circuits_stable_checks_when_rawhide_pending() {
let server = MockServer::start().await;
mount_common(&server).await;
Mock::given(method("GET"))
.and(path("/updates/"))
.and(query_param("releases", "F45"))
.respond_with(updates_response(serde_json::json!([])))
.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.1.0\nRelease: 1%{?dist}\n"),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/updates/"))
.and(query_param("releases", "F43"))
.respond_with(updates_response(serde_json::json!([])))
.expect(0)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rpms/foo/raw/f43/f/foo.spec"))
.respond_with(ResponseTemplate::new(200).set_body_string(""))
.expect(0)
.mount(&server)
.await;
let inventory = test_inventory(None);
let bz = BzClient::new(&server.uri());
let dg = DistGitClient::with_base_url(&server.uri());
let bodhi = BodhiClient::with_base_url(&server.uri());
let report = run(
&inventory,
&bz,
&dg,
&bodhi,
&crate::WalkFilterArgs::default(),
None,
false,
false,
false,
true,
true,
)
.await
.unwrap();
assert_eq!(report.stale_planned, 0);
}
#[test]
fn batch_bug_query_filters_by_email_assignee_or_cc() {
let q = batch_bug_query("user@example.com", false);
assert!(q.contains("reporter=upstream-release-monitoring%40fedoraproject.org"));
assert!(q.contains("bug_status=__open__"));
assert!(q.contains("email1=user%40example.com"));
assert!(q.contains("emailassigned_to1=1"));
assert!(q.contains("emailcc1=1"));
assert!(q.contains("emailtype1=equals"));
assert!(q.contains("product=Fedora"));
assert!(!q.contains("component="));
}
#[test]
fn group_bugs_by_component_groups() {
let mut a = make_bug(1, "unspecified", "foo-1.0 is available");
a.component = vec!["foo".to_string()];
let mut b = make_bug(2, "unspecified", "bar-2.0 is available");
b.component = vec!["bar".to_string()];
let mut c = make_bug(3, "unspecified", "foo-1.1 is available");
c.component = vec!["foo".to_string()];
let map = group_bugs_by_component(vec![a, b, c]);
assert_eq!(map.len(), 2);
assert_eq!(map["foo"].len(), 2);
assert_eq!(map["bar"].len(), 1);
}
#[tokio::test]
async fn run_skips_packages_marked_retired() {
let inventory: Inventory = toml::from_str(
"[inventory]\n\
name = \"test\"\n\
description = \"test\"\n\
maintainer = \"tester\"\n\
\n\
[[package]]\n\
name = \"foo\"\n\
priority = \"high\"\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 bodhi = BodhiClient::with_base_url("http://127.0.0.1:1");
let report = run(
&inventory,
&bz,
&dg,
&bodhi,
&crate::WalkFilterArgs::default(),
None,
false,
false,
false,
true,
false,
)
.await
.unwrap();
assert_eq!(report.updates_planned, 0);
assert_eq!(report.stale_planned, 0);
}
#[tokio::test]
async fn run_batch_mode_makes_one_bugzilla_query() {
let server = MockServer::start().await;
let mut other = bug_json(99, "other-pkg-3.0 is available");
other["component"] = serde_json::json!(["other-pkg"]);
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("email1", "me@example.com"))
.and(query_param("emailassigned_to1", "1"))
.and(query_param("emailcc1", "1"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bugs": [bug_json(1, "foo-1.2.0 is available"), other],
"total_matches": 2
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/releases/"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"releases": [
{"name": "F45", "branch": "rawhide", "id_prefix": "FEDORA", "state": "pending"}
],
"total": 1, "page": 1, "pages": 1
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/0/rpms/foo/git/branches"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"branches": ["rawhide"]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/updates/"))
.and(query_param("releases", "F45"))
.respond_with(updates_response(serde_json::json!([{
"alias": "FEDORA-2026-aaa",
"status": "stable",
"builds": [{"nvr": "foo-1.2.0-1.fc45"}]
}])))
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path("/rest/bug/1"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.expect(1)
.mount(&server)
.await;
let inventory = test_inventory(None);
let bz = BzClient::new(&server.uri());
let dg = DistGitClient::with_base_url(&server.uri());
let bodhi = BodhiClient::with_base_url(&server.uri());
let report = run(
&inventory,
&bz,
&dg,
&bodhi,
&crate::WalkFilterArgs::default(),
Some("me@example.com"),
false,
false,
false,
true,
false,
)
.await
.unwrap();
assert_eq!(report.stale_applied, 1);
assert_eq!(report.failures, 0);
}
#[test]
fn group_by_component_groups_and_orders() {
let updates = vec![
PriorityUpdate {
bug_id: 1,
component: "python-django".into(),
summary: "a".into(),
current_priority: "unspecified".into(),
target_priority: Priority::High,
},
PriorityUpdate {
bug_id: 2,
component: "ansible".into(),
summary: "b".into(),
current_priority: "unspecified".into(),
target_priority: Priority::Medium,
},
PriorityUpdate {
bug_id: 3,
component: "python-django".into(),
summary: "c".into(),
current_priority: "unspecified".into(),
target_priority: Priority::High,
},
];
let grouped = group_by_component(&updates);
let keys: Vec<&String> = grouped.keys().collect();
assert_eq!(keys, vec!["ansible", "python-django"]);
assert_eq!(grouped["python-django"].len(), 2);
assert_eq!(grouped["ansible"].len(), 1);
}
}