use std::collections::BTreeMap;
use sandogasa_bugzilla::BzClient;
use sandogasa_bugzilla::models::Bug;
use sandogasa_distgit::DistGitClient;
use sandogasa_inventory::Inventory;
pub const RELEASE_MONITORING_REPORTER: &str = "upstream-release-monitoring@fedoraproject.org";
#[derive(Debug, Clone)]
pub struct BugClose {
pub bug_id: u64,
pub component: String,
pub summary: String,
pub current_status: String,
}
#[derive(Debug)]
pub enum PackageOutcome {
NotRetired,
RetiredNoBugs,
RetiredClose(Vec<BugClose>),
}
pub fn plan_package(package: &str, retired: bool, bugs: &[Bug]) -> PackageOutcome {
if !retired {
return PackageOutcome::NotRetired;
}
let opens: Vec<BugClose> = bugs
.iter()
.filter(|b| b.status != "CLOSED")
.map(|b| BugClose {
bug_id: b.id,
component: package.to_string(),
summary: b.summary.clone(),
current_status: b.status.clone(),
})
.collect();
if opens.is_empty() {
PackageOutcome::RetiredNoBugs
} else {
PackageOutcome::RetiredClose(opens)
}
}
pub fn product_version_for_branch(branch: &str) -> (&'static str, String) {
if branch.starts_with("epel") {
("Fedora EPEL", branch.to_string())
} else {
("Fedora", branch.to_string())
}
}
pub fn bug_search_query(component: &str, branch: &str) -> String {
let (product, version) = product_version_for_branch(branch);
[
format!("component={}", urlencode(component)),
format!("product={}", urlencode(product)),
format!("version={}", urlencode(&version)),
format!("reporter={}", urlencode(RELEASE_MONITORING_REPORTER)),
"bug_status=__open__".to_string(),
]
.join("&")
}
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 print_package_closes(component: &str, closes: &[BugClose]) {
println!("{component} ({} bug(s)):", closes.len());
for c in closes {
println!(" bug {} [{}]: {}", c.bug_id, c.current_status, c.summary);
}
}
pub fn should_include(
name: &str,
only: Option<&str>,
start_from: Option<&str>,
end_with: Option<&str>,
) -> bool {
if let Some(o) = only {
return name == o;
}
if let Some(s) = start_from
&& name < s
{
return false;
}
if let Some(e) = end_with
&& name > e
{
return false;
}
true
}
pub async fn retry<F, Fut, T, E>(
label: &str,
attempts: usize,
mut f: F,
verbose: bool,
) -> Result<T, E>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T, E>>,
E: std::fmt::Display,
{
let mut last: Option<E> = None;
for attempt in 1..=attempts {
match f().await {
Ok(v) => return Ok(v),
Err(e) => {
if attempt < attempts {
let backoff = 1u64 << (attempt - 1).min(4); if verbose {
eprintln!(
"[poi-tracker] {label} attempt {attempt}/{attempts} failed: {e}; \
retrying in {backoff}s"
);
}
tokio::time::sleep(std::time::Duration::from_secs(backoff)).await;
}
last = Some(e);
}
}
}
Err(last.expect("loop ran at least once"))
}
pub const RETRY_ATTEMPTS: usize = 3;
pub fn close_comment(package: &str, branch: &str) -> String {
format!(
"Package `{package}` is retired on the `{branch}` dist-git \
branch (the `dead.package` marker is present); closing as \
CANTFIX since there's no live package to update."
)
}
#[derive(Debug, Default)]
pub struct RunReport {
pub packages_checked: usize,
pub packages_retired: usize,
pub closes_planned: usize,
pub closes_applied: usize,
pub failures: usize,
}
#[allow(clippy::too_many_arguments)]
pub async fn run(
inventory: &Inventory,
bz: &BzClient,
dg: &DistGitClient,
branch: &str,
only_package: Option<&str>,
start_from: Option<&str>,
end_with: Option<&str>,
claim: bool,
claim_email: Option<&str>,
dry_run: bool,
yes: bool,
verbose: bool,
) -> Result<RunReport, String> {
let mut all_closes: Vec<BugClose> = Vec::new();
let mut packages_checked = 0usize;
let mut packages_retired = 0usize;
for pkg in &inventory.package {
if !should_include(&pkg.name, only_package, start_from, end_with) {
continue;
}
packages_checked += 1;
if verbose {
eprintln!(
"[poi-tracker] {}: checking retirement on {branch}",
pkg.name
);
}
let retired = retry(
&format!("is_retired({}, {branch})", pkg.name),
RETRY_ATTEMPTS,
|| dg.is_retired(&pkg.name, branch),
verbose,
)
.await
.map_err(|e| format!("dist-git is_retired for {}: {e}", pkg.name))?;
if !retired {
continue;
}
packages_retired += 1;
if verbose {
eprintln!(
"[poi-tracker] {}: retired on {branch}, searching open bugs",
pkg.name
);
}
let query = bug_search_query(&pkg.name, branch);
let bugs = 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))?;
match plan_package(&pkg.name, true, &bugs) {
PackageOutcome::NotRetired => unreachable!("retired check passed above"),
PackageOutcome::RetiredNoBugs => {
if verbose {
eprintln!(
"[poi-tracker] {}: retired but no open bugs to close",
pkg.name
);
}
}
PackageOutcome::RetiredClose(closes) => {
print_package_closes(&pkg.name, &closes);
all_closes.extend(closes);
}
}
}
if all_closes.is_empty() {
println!("No retired packages with open release-monitoring bugs.");
} else {
print_tally(&all_closes);
}
let mut report = RunReport {
packages_checked,
packages_retired,
closes_planned: all_closes.len(),
closes_applied: 0,
failures: 0,
};
if all_closes.is_empty() {
return Ok(report);
}
if dry_run {
eprintln!("\n(dry-run: not applying)");
return Ok(report);
}
let want_claim = if claim {
claim_email.is_some()
} else if yes || claim_email.is_none() {
false
} else {
confirm(&format!(
"Also claim ownership (assigned_to = {})?",
claim_email.unwrap()
))?
};
let active_claim_email = if want_claim {
let e = claim_email.unwrap();
eprintln!("claiming ownership as {e}");
Some(e.to_string())
} else {
None
};
if !yes && !confirm(&format!("\nClose {} bug(s) as CANTFIX?", all_closes.len()))? {
eprintln!("aborted.");
return Ok(report);
}
for c in &all_closes {
let mut body = serde_json::json!({
"status": "CLOSED",
"resolution": "CANTFIX",
"comment": { "body": close_comment(&c.component, branch) },
});
if let Some(ref email) = active_claim_email {
body["assigned_to"] = serde_json::json!(email);
}
match bz.update(c.bug_id, &body).await {
Ok(()) => {
report.closes_applied += 1;
eprintln!(
"closed bug {} ({}): {} -> CLOSED/CANTFIX",
c.bug_id, c.component, c.current_status
);
}
Err(e) => {
report.failures += 1;
eprintln!("error: bug {} ({}): {e}", c.bug_id, c.component);
}
}
}
Ok(report)
}
fn print_tally(closes: &[BugClose]) {
let mut by_pkg: BTreeMap<&str, Vec<&BugClose>> = BTreeMap::new();
for c in closes {
by_pkg.entry(c.component.as_str()).or_default().push(c);
}
println!(
"\nTotal: {} closure(s) across {} package(s):",
closes.len(),
by_pkg.len()
);
for (pkg, bugs) in &by_pkg {
let ids: Vec<String> = bugs.iter().map(|b| format!("rhbz#{}", b.bug_id)).collect();
println!(" {pkg}: {}", ids.join(", "));
}
}
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, status: &str, summary: &str) -> Bug {
serde_json::from_value(serde_json::json!({
"id": id,
"summary": summary,
"status": status,
"resolution": "",
"product": "Fedora",
"component": ["foo"],
"severity": "unspecified",
"priority": "unspecified",
"assigned_to": "nobody@fedoraproject.org",
"creator": RELEASE_MONITORING_REPORTER,
"creation_time": "2026-01-01T00:00:00Z",
"last_change_time": "2026-01-01T00:00:00Z",
}))
.unwrap()
}
#[test]
fn plan_skips_live_packages() {
let outcome = plan_package("foo", false, &[make_bug(1, "NEW", "x")]);
assert!(matches!(outcome, PackageOutcome::NotRetired));
}
#[test]
fn plan_no_bugs_when_retired_with_empty_search() {
let outcome = plan_package("foo", true, &[]);
assert!(matches!(outcome, PackageOutcome::RetiredNoBugs));
}
#[test]
fn plan_closes_only_open_bugs_when_retired() {
let bugs = vec![
make_bug(1, "NEW", "foo 1.0 available"),
make_bug(2, "ASSIGNED", "foo 0.9 available"),
make_bug(3, "CLOSED", "foo 0.8 available"),
];
let outcome = plan_package("foo", true, &bugs);
match outcome {
PackageOutcome::RetiredClose(closes) => {
assert_eq!(closes.len(), 2);
let ids: Vec<u64> = closes.iter().map(|c| c.bug_id).collect();
assert_eq!(ids, vec![1, 2]);
}
other => panic!("expected RetiredClose, got {other:?}"),
}
}
#[test]
fn product_version_picks_epel_for_epel_branches() {
assert_eq!(
product_version_for_branch("epel10"),
("Fedora EPEL", "epel10".to_string())
);
assert_eq!(
product_version_for_branch("epel9"),
("Fedora EPEL", "epel9".to_string())
);
}
#[test]
fn product_version_picks_fedora_for_rawhide_and_fnn() {
assert_eq!(
product_version_for_branch("rawhide"),
("Fedora", "rawhide".to_string())
);
assert_eq!(
product_version_for_branch("f43"),
("Fedora", "f43".to_string())
);
}
#[test]
fn bug_search_query_scopes_to_branch() {
let q = bug_search_query("python-django6", "epel10");
assert!(q.contains("component=python-django6"));
assert!(q.contains("product=Fedora%20EPEL"));
assert!(q.contains("version=epel10"));
assert!(q.contains("bug_status=__open__"));
assert!(q.contains("reporter=upstream-release-monitoring%40fedoraproject.org"));
let q = bug_search_query("foo", "rawhide");
assert!(q.contains("product=Fedora&"));
assert!(q.contains("version=rawhide"));
}
#[test]
fn close_comment_mentions_package_and_branch() {
let c = close_comment("python-django6", "epel10");
assert!(c.contains("python-django6"));
assert!(c.contains("epel10"));
assert!(c.contains("CANTFIX"));
}
#[test]
fn should_include_no_filters_keeps_everything() {
assert!(should_include("foo", None, None, None));
assert!(should_include("zzz", None, None, None));
}
#[test]
fn should_include_only_matches_exact() {
assert!(should_include("foo", Some("foo"), None, None));
assert!(!should_include("foo-utils", Some("foo"), None, None));
assert!(!should_include("foo", Some("bar"), None, None));
}
#[test]
fn should_include_start_from_is_inclusive() {
assert!(!should_include("apple", None, Some("mango"), None));
assert!(should_include("mango", None, Some("mango"), None));
assert!(should_include("zebra", None, Some("mango"), None));
}
#[test]
fn should_include_end_with_is_inclusive() {
assert!(should_include("apple", None, None, Some("mango")));
assert!(should_include("mango", None, None, Some("mango")));
assert!(!should_include("zebra", None, None, Some("mango")));
}
#[test]
fn should_include_range_bounds_both_inclusive() {
let s = Some("rust-nu-cli");
let e = Some("rust-nu-engine");
assert!(!should_include("rust-itertools", None, s, e));
assert!(should_include("rust-nu-cli", None, s, e));
assert!(should_include("rust-nu-cmd-base", None, s, e));
assert!(should_include("rust-nu-engine", None, s, e));
assert!(!should_include("rust-nu-utils", None, s, e));
}
}