mod config;
mod triage_retired;
mod triage_updates;
use std::collections::BTreeMap;
use std::process::ExitCode;
use clap::{Parser, Subcommand};
use sandogasa_distgit::DistGitClient;
#[derive(Parser)]
#[command(
version,
about,
long_about = None,
before_help = concat!(
env!("CARGO_PKG_NAME"), " ", env!("CARGO_PKG_VERSION")
)
)]
struct Cli {
#[arg(short, long)]
inventory: Vec<String>,
#[arg(short = 'I', long, value_name = "DIR")]
inventory_dir: Vec<String>,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Add(AddArgs),
Config,
Export(ExportArgs),
Find(FindArgs),
Import(ImportArgs),
Remove(RemoveArgs),
Show(ShowArgs),
SyncDistgit(SyncDistgitArgs),
SyncGitlab(SyncGitlabArgs),
TriageRetired(TriageRetiredArgs),
TriageUpdates(TriageUpdatesArgs),
Validate,
}
#[derive(clap::Args)]
struct TriageRetiredArgs {
#[arg(long, default_value = "rawhide")]
branch: String,
#[arg(
long,
value_name = "NAME",
conflicts_with_all = ["start_from", "end_with"],
)]
package: Option<String>,
#[arg(long, value_name = "NAME", conflicts_with = "package")]
start_from: Option<String>,
#[arg(long, value_name = "NAME", conflicts_with = "package")]
end_with: Option<String>,
#[arg(long, env = "BUGZILLA_API_KEY")]
api_key: Option<String>,
#[arg(long)]
claim: bool,
#[arg(long)]
dry_run: bool,
#[arg(short, long)]
yes: bool,
#[arg(short, long)]
verbose: bool,
}
#[derive(clap::Args)]
struct TriageUpdatesArgs {
#[arg(long, env = "BUGZILLA_API_KEY")]
api_key: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(short, long)]
yes: bool,
#[arg(short, long)]
verbose: bool,
}
#[derive(clap::Args)]
struct AddArgs {
name: String,
#[arg(long)]
poc: Option<String>,
#[arg(long)]
reason: Option<String>,
#[arg(long)]
team: Option<String>,
#[arg(long)]
task: Option<String>,
#[arg(long, value_delimiter = ',')]
rpm: Vec<String>,
#[arg(long, value_delimiter = ',')]
workload: Vec<String>,
#[arg(long)]
track: Option<String>,
}
#[derive(clap::Args)]
struct FindArgs {
name: String,
}
#[derive(clap::Args)]
struct RemoveArgs {
name: String,
#[arg(long, value_delimiter = ',')]
rpm: Vec<String>,
}
#[derive(clap::Args)]
struct ShowArgs {
#[arg(long)]
workload: Option<String>,
#[arg(long)]
json: bool,
}
#[derive(clap::Args)]
struct ExportArgs {
#[command(subcommand)]
format: ExportFormat,
}
#[derive(Subcommand)]
enum ExportFormat {
ContentResolver {
#[arg(long)]
workload: Option<String>,
#[arg(short, long)]
output: Option<String>,
},
HsRelmon {
#[arg(long)]
workload: Option<String>,
#[arg(short, long)]
output: Option<String>,
#[arg(long, default_value = "upstream,fedora,centos,hyperscale")]
distros: String,
#[arg(long, default_value = "upstream")]
track: String,
#[arg(long)]
prune: bool,
},
}
#[derive(clap::Args)]
struct ImportArgs {
json_file: String,
#[arg(short, long, default_value = "inventory.toml")]
output: String,
#[arg(long, value_delimiter = ',', value_name = "FIELD,...")]
private_fields: Vec<String>,
#[arg(long, value_delimiter = ',', value_name = "WORKLOAD,...")]
workload: Vec<String>,
}
#[derive(clap::Args)]
#[command(group(
clap::ArgGroup::new("source")
.required(true)
.args(["user", "group"])
))]
struct SyncDistgitArgs {
#[arg(long)]
user: Option<String>,
#[arg(long)]
group: Option<String>,
#[arg(short, long, default_value = "inventory.toml")]
output: String,
#[arg(
long,
conflicts_with_all = ["include_group", "exclude_group"]
)]
no_groups: bool,
#[arg(
long,
value_delimiter = ',',
value_name = "GROUP,...",
conflicts_with = "exclude_group"
)]
include_group: Vec<String>,
#[arg(long, value_delimiter = ',', value_name = "GROUP,...")]
exclude_group: Vec<String>,
#[arg(long, value_delimiter = ',', value_name = "GLOB,...")]
exclude: Vec<String>,
#[arg(long)]
pattern: Option<String>,
#[arg(long, requires = "auto_prefix")]
end_pattern: Option<String>,
#[arg(long)]
auto_prefix: bool,
#[arg(long)]
prune: bool,
#[arg(long, default_value = "100")]
per_page: u32,
#[arg(long, value_delimiter = ',', value_name = "WORKLOAD,...")]
workload: Vec<String>,
#[arg(long)]
name: Option<String>,
}
const GITLAB_PRESETS: &[(&str, &str)] = &[
("hyperscale", "https://gitlab.com/CentOS/Hyperscale/rpms"),
(
"proposed-updates",
"https://gitlab.com/CentOS/proposed_updates/rpms",
),
(
"centos-stream",
"https://gitlab.com/redhat/centos-stream/rpms",
),
];
#[derive(clap::Args)]
#[command(group(
clap::ArgGroup::new("source")
.required(true)
.args(["url", "preset"])
))]
struct SyncGitlabArgs {
#[arg(long)]
url: Option<String>,
#[arg(long)]
preset: Option<String>,
#[arg(short, long, default_value = "inventory.toml")]
output: String,
#[arg(long, value_delimiter = ',', value_name = "GLOB,...")]
exclude: Vec<String>,
#[arg(long)]
prune: bool,
#[arg(long, value_delimiter = ',', value_name = "WORKLOAD,...")]
workload: Vec<String>,
#[arg(long)]
name: Option<String>,
}
fn workload_export_filename(
inventory: &sandogasa_inventory::Inventory,
workload_key: &str,
) -> String {
let meta = inventory.inventory.workloads.get(workload_key);
let name = meta
.and_then(|m| m.name.as_deref())
.map(|n| n.to_string())
.unwrap_or_else(|| format!("{}-{workload_key}", inventory.inventory.name));
format!("{}.yaml", name.replace(' ', "_"))
}
fn workloads_from_names(names: &[String]) -> BTreeMap<String, sandogasa_inventory::WorkloadMeta> {
names
.iter()
.map(|n| (n.clone(), sandogasa_inventory::WorkloadMeta::default()))
.collect()
}
fn resolve_inventory_paths(cli: &Cli) -> Vec<String> {
let mut paths = cli.inventory.clone();
for dir in &cli.inventory_dir {
if let Ok(entries) = std::fs::read_dir(dir) {
let mut dir_paths: Vec<String> = entries
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
.map(|e| e.path().to_string_lossy().to_string())
.collect();
dir_paths.sort();
paths.extend(dir_paths);
} else {
eprintln!("warning: could not read directory: {dir}");
}
}
paths
}
fn main() -> ExitCode {
let cli = Cli::parse();
let needs_paths = !matches!(
cli.command,
Command::Config | Command::Import(_) | Command::SyncDistgit(_) | Command::SyncGitlab(_)
);
let paths = resolve_inventory_paths(&cli);
if needs_paths && paths.is_empty() {
eprintln!("error: no inventory files specified. Use -i or -I.");
return ExitCode::FAILURE;
}
match &cli.command {
Command::Add(args) => cmd_add(&paths, args),
Command::Config => cmd_config(),
Command::Export(args) => cmd_export(&paths, args),
Command::Find(args) => cmd_find(&paths, args),
Command::Import(args) => cmd_import(args),
Command::Remove(args) => cmd_remove(&paths[0], args),
Command::Show(args) => cmd_show(&paths, args),
Command::SyncDistgit(args) => cmd_sync_distgit(args),
Command::SyncGitlab(args) => cmd_sync_gitlab(args),
Command::TriageRetired(args) => cmd_triage_retired(&paths, args),
Command::TriageUpdates(args) => cmd_triage_updates(&paths, args),
Command::Validate => cmd_validate(&paths),
}
}
fn cmd_triage_retired(paths: &[String], args: &TriageRetiredArgs) -> ExitCode {
let inventory = match sandogasa_inventory::load_and_merge(paths) {
Ok(inv) => inv,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
};
let api_key = match config::resolve_api_key(args.api_key.as_deref()) {
Ok(k) => k,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
};
let url = config::resolve_url();
let bz = sandogasa_bugzilla::BzClient::new(&url).with_api_key(api_key);
let dg = sandogasa_distgit::DistGitClient::new();
let claim_email = config::resolve_email();
if args.claim && claim_email.is_none() {
eprintln!(
"error: --claim needs a configured Bugzilla email.\n\
Set it with: poi-tracker config"
);
return ExitCode::FAILURE;
}
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => {
eprintln!("error: failed to create runtime: {e}");
return ExitCode::FAILURE;
}
};
match rt.block_on(triage_retired::run(
&inventory,
&bz,
&dg,
&args.branch,
args.package.as_deref(),
args.start_from.as_deref(),
args.end_with.as_deref(),
args.claim,
claim_email.as_deref(),
args.dry_run,
args.yes,
args.verbose,
)) {
Ok(report) => {
eprintln!(
"\n{} checked, {} retired, {} planned, {} closed, {} failed",
report.packages_checked,
report.packages_retired,
report.closes_planned,
report.closes_applied,
report.failures
);
if report.failures > 0 {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
fn cmd_config() -> ExitCode {
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => {
eprintln!("error: failed to create runtime: {e}");
return ExitCode::FAILURE;
}
};
match rt.block_on(config::cmd_config()) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
fn cmd_triage_updates(paths: &[String], args: &TriageUpdatesArgs) -> ExitCode {
let inventory = match sandogasa_inventory::load_and_merge(paths) {
Ok(inv) => inv,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
};
let api_key = match config::resolve_api_key(args.api_key.as_deref()) {
Ok(k) => k,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
};
let url = config::resolve_url();
let client = sandogasa_bugzilla::BzClient::new(&url).with_api_key(api_key);
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => {
eprintln!("error: failed to create runtime: {e}");
return ExitCode::FAILURE;
}
};
match rt.block_on(triage_updates::run(
&inventory,
&client,
args.dry_run,
args.yes,
args.verbose,
)) {
Ok(report) => {
eprintln!(
"\n{} package(s) with managed priority, {} planned update(s), \
{} applied, {} failed",
report.packages_with_priority,
report.updates_planned,
report.updates_applied,
report.failures
);
if report.failures > 0 {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
fn cmd_show(paths: &[String], args: &ShowArgs) -> ExitCode {
let inventory = match sandogasa_inventory::load_and_merge(paths) {
Ok(inv) => inv,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
};
let packages = inventory.packages_for_workload(args.workload.as_deref());
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&packages).expect("JSON serialization failed")
);
} else {
println!(
"Inventory: {} ({} package(s))\n",
inventory.inventory.name,
packages.len()
);
for pkg in &packages {
print!(" {}", pkg.name);
let wls = inventory.workloads_for_package(&pkg.name);
if !wls.is_empty() {
print!(" [{}]", wls.join(", "));
}
println!();
if let Some(ref poc) = pkg.poc {
println!(" poc: {poc}");
}
if let Some(ref reason) = pkg.reason {
println!(" reason: {reason}");
}
if let Some(ref rpms) = pkg.rpms {
println!(" rpms: {}", rpms.join(", "));
}
if let Some(ref track) = pkg.track {
println!(" track: {track}");
}
}
}
ExitCode::SUCCESS
}
fn cmd_validate(paths: &[String]) -> ExitCode {
let inventory = match sandogasa_inventory::load_and_merge(paths) {
Ok(inv) => inv,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
};
let mut errors = 0;
let mut seen = std::collections::HashSet::new();
for pkg in &inventory.package {
if !seen.insert(&pkg.name) {
eprintln!("error: duplicate package: {}", pkg.name);
errors += 1;
}
}
for window in inventory.package.windows(2) {
if window[0].name > window[1].name {
eprintln!(
"warning: packages not sorted: {} before {}",
window[0].name, window[1].name
);
}
}
let valid_fields = ["poc", "reason", "team", "task"];
for field in &inventory.inventory.private_fields {
if !valid_fields.contains(&field.as_str()) {
eprintln!("warning: unknown private field: {field}");
}
}
if errors > 0 {
eprintln!("\n{errors} error(s) found.");
ExitCode::FAILURE
} else {
println!("Inventory OK: {} package(s).", inventory.package.len());
ExitCode::SUCCESS
}
}
fn cmd_export(paths: &[String], args: &ExportArgs) -> ExitCode {
let inventory = match sandogasa_inventory::load_and_merge(paths) {
Ok(inv) => inv,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
};
match &args.format {
ExportFormat::ContentResolver { workload, output } => {
let workload_keys: Vec<&str> = match workload {
Some(w) => vec![w.as_str()],
None => {
let names = inventory.workload_names();
if names.is_empty() {
vec![]
} else {
names
}
}
};
if workload_keys.is_empty() {
let yaml =
sandogasa_inventory::content_resolver::export(&inventory, workload.as_deref());
let default_filename =
format!("{}.yaml", inventory.inventory.name.replace(' ', "_"));
let path = output.as_deref().unwrap_or(&default_filename);
if let Err(e) = std::fs::write(path, &yaml) {
eprintln!("error: failed to write {path}: {e}");
return ExitCode::FAILURE;
}
eprintln!("Wrote {path}");
} else if workload_keys.len() == 1 {
let yaml = sandogasa_inventory::content_resolver::export(
&inventory,
Some(workload_keys[0]),
);
let wl_name = workload_export_filename(&inventory, workload_keys[0]);
let path = output.as_deref().unwrap_or(&wl_name);
if let Err(e) = std::fs::write(path, &yaml) {
eprintln!("error: failed to write {path}: {e}");
return ExitCode::FAILURE;
}
eprintln!("Wrote {path}");
} else {
if output.is_some() {
eprintln!(
"error: -o/--output cannot be used when \
exporting multiple workloads"
);
return ExitCode::FAILURE;
}
for key in &workload_keys {
let yaml = sandogasa_inventory::content_resolver::export(&inventory, Some(key));
let path = workload_export_filename(&inventory, key);
if let Err(e) = std::fs::write(&path, &yaml) {
eprintln!("error: failed to write {path}: {e}");
return ExitCode::FAILURE;
}
eprintln!("Wrote {path}");
}
}
}
ExportFormat::HsRelmon {
workload,
distros,
track,
output,
prune,
} => {
let defaults = sandogasa_inventory::hs_relmon::RelmonDefaults {
distros: distros.clone(),
track: track.clone(),
file_issue: true,
};
if let Some(path) = output
&& std::path::Path::new(path).exists()
{
let result = match sandogasa_inventory::hs_relmon::merge_into_manifest(
path,
&inventory,
workload.as_deref(),
&defaults,
*prune,
) {
Ok(r) => r,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
};
if !result.stale.is_empty() && !prune {
eprintln!(
"warning: {} manifest entry/entries not in \
inventory (use --prune to remove):",
result.stale.len()
);
for name in &result.stale {
eprintln!(" {name}");
}
}
if let Err(e) = std::fs::write(path, &result.content) {
eprintln!("error: failed to write {path}: {e}");
return ExitCode::FAILURE;
}
let pruned_msg = if result.pruned > 0 {
format!(", {} pruned", result.pruned)
} else {
String::new()
};
eprintln!(
"Merged into {path}: {} new{pruned_msg}, {} total",
result.added, result.total
);
} else {
let toml = sandogasa_inventory::hs_relmon::export(
&inventory,
workload.as_deref(),
&defaults,
);
if let Some(path) = output {
if let Err(e) = std::fs::write(path, &toml) {
eprintln!("error: failed to write {path}: {e}");
return ExitCode::FAILURE;
}
eprintln!("Wrote {path}");
} else {
print!("{toml}");
}
}
}
}
ExitCode::SUCCESS
}
fn cmd_find(paths: &[String], args: &FindArgs) -> ExitCode {
let mut found = false;
for path in paths {
let inventory = match sandogasa_inventory::load(path) {
Ok(inv) => inv,
Err(e) => {
eprintln!("warning: {path}: {e}");
continue;
}
};
if let Some(pkg) = inventory.find_package(&args.name) {
found = true;
println!("{path}: {}", pkg.name);
if let Some(ref poc) = pkg.poc {
println!(" poc: {poc}");
}
if let Some(ref reason) = pkg.reason {
println!(" reason: {reason}");
}
if let Some(ref rpms) = pkg.rpms {
println!(" rpms: {}", rpms.join(", "));
}
let wls = inventory.workloads_for_package(&pkg.name);
if !wls.is_empty() {
println!(" workloads: {}", wls.join(", "));
}
if let Some(ref track) = pkg.track {
println!(" track: {track}");
}
}
}
if !found {
eprintln!("{} not found in any inventory.", args.name);
return ExitCode::FAILURE;
}
ExitCode::SUCCESS
}
fn merge_into_package(existing: &mut sandogasa_inventory::Package, args: &AddArgs) {
if !args.rpm.is_empty() {
let rpms = existing.rpms.get_or_insert_with(Vec::new);
for rpm in &args.rpm {
if !rpms.contains(rpm) {
rpms.push(rpm.clone());
}
}
rpms.sort();
}
if existing.poc.is_none() {
existing.poc.clone_from(&args.poc);
}
if existing.reason.is_none() {
existing.reason.clone_from(&args.reason);
}
if existing.team.is_none() {
existing.team.clone_from(&args.team);
}
if existing.task.is_none() {
existing.task.clone_from(&args.task);
}
if existing.track.is_none() {
existing.track.clone_from(&args.track);
}
}
fn cmd_add(paths: &[String], args: &AddArgs) -> ExitCode {
let mut target_path = None;
for path in paths {
if let Ok(inv) = sandogasa_inventory::load(path)
&& inv.find_package(&args.name).is_some()
{
target_path = Some(path.clone());
break;
}
}
let target_path = target_path.unwrap_or_else(|| paths[0].clone());
let mut inventory = match sandogasa_inventory::load(&target_path) {
Ok(inv) => inv,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
};
if let Some(existing) = inventory.find_package_mut(&args.name) {
merge_into_package(existing, args);
eprintln!("Updated {} in {target_path}", args.name);
} else {
let pkg = sandogasa_inventory::Package {
name: args.name.clone(),
poc: args.poc.clone(),
reason: args.reason.clone(),
team: args.team.clone(),
task: args.task.clone(),
rpms: if args.rpm.is_empty() {
None
} else {
Some(args.rpm.clone())
},
arch_rpms: None,
track: args.track.clone(),
repology_name: None,
distros: None,
file_issue: None,
priority: None,
};
inventory.add_package(pkg);
eprintln!("Added {} to {target_path}", args.name);
}
for wl in &args.workload {
inventory.add_to_workload(wl, &args.name);
}
if let Err(e) = sandogasa_inventory::save(&inventory, &target_path) {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
ExitCode::SUCCESS
}
fn cmd_remove(path: &str, args: &RemoveArgs) -> ExitCode {
let mut inventory = match sandogasa_inventory::load(path) {
Ok(inv) => inv,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
};
if args.rpm.is_empty() {
if !inventory.remove_package(&args.name) {
eprintln!("error: package '{}' not found", args.name);
return ExitCode::FAILURE;
}
eprintln!("Removed {} from {path}", args.name);
} else {
let pkg = match inventory.find_package_mut(&args.name) {
Some(p) => p,
None => {
eprintln!("error: package '{}' not found", args.name);
return ExitCode::FAILURE;
}
};
if let Some(ref mut rpms) = pkg.rpms {
for rpm in &args.rpm {
rpms.retain(|r| r != rpm);
}
eprintln!("Removed RPM(s) {} from {}", args.rpm.join(", "), args.name);
} else {
eprintln!("error: package '{}' has no RPM list", args.name);
return ExitCode::FAILURE;
}
}
if let Err(e) = sandogasa_inventory::save(&inventory, path) {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
ExitCode::SUCCESS
}
fn cmd_import(args: &ImportArgs) -> ExitCode {
let mut inventory = match sandogasa_inventory::import_json::import_file(&args.json_file) {
Ok(inv) => inv,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
};
if !args.private_fields.is_empty() {
inventory.inventory.private_fields = args.private_fields.clone();
}
if !args.workload.is_empty() {
let pkg_names: Vec<String> = inventory.package.iter().map(|p| p.name.clone()).collect();
for wl in &args.workload {
for name in &pkg_names {
inventory.add_to_workload(wl, name);
}
}
}
if let Err(e) = sandogasa_inventory::save(&inventory, &args.output) {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
eprintln!(
"Imported {} package(s) from {} to {}",
inventory.package.len(),
args.json_file,
args.output
);
ExitCode::SUCCESS
}
fn matches_any_pattern(name: &str, patterns: &[String]) -> bool {
if patterns.is_empty() || (patterns.len() == 1 && patterns[0].is_empty()) {
return true;
}
let lower = name.to_ascii_lowercase();
patterns.iter().any(|pat| {
if let Some(prefix) = pat.strip_suffix('*') {
lower.starts_with(&prefix.to_ascii_lowercase())
} else {
lower == pat.to_ascii_lowercase()
}
})
}
fn filter_projects<'a>(
projects: &'a [sandogasa_distgit::ProjectInfo],
args: &SyncDistgitArgs,
) -> Vec<&'a sandogasa_distgit::ProjectInfo> {
let Some(ref username) = args.user else {
return projects.iter().collect();
};
projects
.iter()
.filter(|p| {
let u = username.as_str();
let has_direct = p.access_users.owner.iter().any(|x| x == u)
|| p.access_users.admin.iter().any(|x| x == u)
|| p.access_users.commit.iter().any(|x| x == u)
|| p.access_users.collaborator.iter().any(|x| x == u)
|| p.access_users.ticket.iter().any(|x| x == u);
if has_direct {
return true;
}
if args.no_groups {
return false;
}
if !args.include_group.is_empty() {
return args
.include_group
.iter()
.any(|g| p.access_groups.contains_group(g));
}
if !args.exclude_group.is_empty() {
return !args
.exclude_group
.iter()
.any(|g| p.access_groups.contains_group(g));
}
true
})
.collect()
}
async fn sync_distgit_async(args: &SyncDistgitArgs) -> Result<(), Box<dyn std::error::Error>> {
let client = DistGitClient::new();
if let Some(ref user) = args.user {
for group in &args.include_group {
let members = client.get_group_members(group).await?;
if !members.iter().any(|m| m == user) {
return Err(format!("user '{user}' is not a member of group '{group}'").into());
}
}
for group in &args.exclude_group {
let members = client.get_group_members(group).await?;
if !members.iter().any(|m| m == user) {
eprintln!("warning: user '{user}' is not a member of group '{group}'");
}
}
}
let all_prefixes: Vec<String> = ('a'..='z')
.chain('0'..='9')
.map(|c| format!("{c}*"))
.collect();
let patterns = if args.auto_prefix {
let start = args
.pattern
.as_deref()
.map(|p| p.trim_end_matches('*'))
.unwrap_or("");
let end = args
.end_pattern
.as_deref()
.map(|p| p.trim_end_matches('*'))
.unwrap_or("");
let iter = all_prefixes.into_iter();
let iter: Box<dyn Iterator<Item = String>> = if start.is_empty() {
Box::new(iter)
} else {
Box::new(iter.skip_while(move |p| !p.starts_with(start)))
};
if end.is_empty() {
iter.collect()
} else {
iter.take_while(|p| !p.starts_with(end)).collect()
}
} else {
vec![args.pattern.clone().unwrap_or_default()]
};
let source_label = if let Some(ref user) = args.user {
format!("user:{user}")
} else {
format!("group:{}", args.group.as_deref().unwrap())
};
let mut all_projects = Vec::new();
let mut fetch_error = None;
for pat in &patterns {
let result = if pat.is_empty() {
if let Some(ref user) = args.user {
client.user_projects(user, args.per_page, None).await
} else {
client
.group_projects(args.group.as_ref().unwrap(), args.per_page, None)
.await
}
} else {
eprintln!(" pattern: {pat}");
if let Some(ref user) = args.user {
client.user_projects(user, args.per_page, Some(pat)).await
} else {
client
.group_projects(args.group.as_ref().unwrap(), args.per_page, Some(pat))
.await
}
};
match result {
Ok(p) => all_projects.extend(p),
Err(e) => {
eprintln!("error: {e}");
fetch_error = Some(e);
break;
}
}
}
sandogasa_distgit::client::dedup_projects(&mut all_projects);
let total_fetched = all_projects.len();
let mut filtered = filter_projects(&all_projects, args);
let group_excluded = total_fetched - filtered.len();
if !args.exclude.is_empty() {
filtered.retain(|p| !matches_any_pattern(&p.name, &args.exclude));
}
let pkg_excluded = total_fetched - group_excluded - filtered.len();
if group_excluded > 0 || pkg_excluded > 0 {
let mut parts = vec![format!("{total_fetched} unique")];
if group_excluded > 0 {
parts.push(format!("{group_excluded} excluded by group filter"));
}
if pkg_excluded > 0 {
parts.push(format!("{pkg_excluded} excluded by --exclude"));
}
eprintln!(" {}", parts.join(", "));
}
let mut inventory = if std::path::Path::new(&args.output).exists() {
sandogasa_inventory::load(&args.output).map_err(|e| format!("{}: {e}", args.output))?
} else {
let inv_name = args
.name
.clone()
.unwrap_or_else(|| source_label.replace(':', "-"));
sandogasa_inventory::Inventory {
inventory: sandogasa_inventory::InventoryMeta {
name: inv_name,
description: format!("Packages synced from dist-git ({source_label})"),
maintainer: source_label.clone(),
labels: vec![],
workloads: workloads_from_names(&args.workload),
private_fields: vec![],
},
package: vec![],
}
};
if let Some(ref name) = args.name {
inventory.inventory.name.clone_from(name);
}
let remote_names: std::collections::HashSet<&str> =
filtered.iter().map(|p| p.name.as_str()).collect();
let mut added = 0usize;
for p in &filtered {
if inventory.find_package(&p.name).is_some() {
continue;
}
inventory.add_package(sandogasa_inventory::Package {
name: p.name.clone(),
poc: None,
reason: None,
team: None,
task: None,
rpms: None,
arch_rpms: None,
track: None,
repology_name: None,
distros: None,
file_issue: None,
priority: None,
});
for wl in &args.workload {
inventory.add_to_workload(wl, &p.name);
}
added += 1;
}
if let Some(e) = fetch_error {
let partial = format!("{}.partial", args.output);
sandogasa_inventory::save(&inventory, &partial)?;
eprintln!(
"Saved {} package(s) to {partial} (incomplete, \
verify before renaming)",
inventory.package.len()
);
return Err(e);
}
let stale: Vec<String> = inventory
.package
.iter()
.filter(|p| !remote_names.contains(p.name.as_str()))
.filter(|p| matches_any_pattern(&p.name, &patterns))
.map(|p| p.name.clone())
.collect();
let pruned = stale.len();
if !stale.is_empty() {
if args.prune {
for name in &stale {
inventory.remove_package(name);
}
} else {
eprintln!(
"warning: {} package(s) not in sync scope \
(use --prune to remove):",
stale.len()
);
for name in &stale {
eprintln!(" {name}");
}
}
}
sandogasa_inventory::save(&inventory, &args.output)?;
let pruned_msg = if args.prune && pruned > 0 {
format!(", {pruned} pruned")
} else {
String::new()
};
eprintln!(
"Synced {source_label}: {added} new{pruned_msg}, \
{} total in {}",
inventory.package.len(),
args.output
);
Ok(())
}
fn cmd_sync_distgit(args: &SyncDistgitArgs) -> ExitCode {
if args.user.is_none()
&& (args.no_groups || !args.include_group.is_empty() || !args.exclude_group.is_empty())
{
eprintln!(
"error: --no-groups, --include-group, and \
--exclude-group only apply with --user"
);
return ExitCode::FAILURE;
}
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => {
eprintln!("error: failed to create runtime: {e}");
return ExitCode::FAILURE;
}
};
match rt.block_on(sync_distgit_async(args)) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
fn resolve_gitlab_url(args: &SyncGitlabArgs) -> Result<String, String> {
if let Some(ref url) = args.url {
return Ok(url.clone());
}
if let Some(ref preset) = args.preset {
for &(name, url) in GITLAB_PRESETS {
if name == preset.as_str() {
return Ok(url.to_string());
}
}
let valid: Vec<&str> = GITLAB_PRESETS.iter().map(|(n, _)| *n).collect();
return Err(format!(
"unknown preset '{preset}'. Valid: {}",
valid.join(", ")
));
}
Err("specify --url or --preset".to_string())
}
fn cmd_sync_gitlab(args: &SyncGitlabArgs) -> ExitCode {
let group_url = match resolve_gitlab_url(args) {
Ok(u) => u,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
};
let source_label = args.preset.clone().unwrap_or_else(|| group_url.clone());
let projects = match sandogasa_gitlab::list_group_projects(&group_url) {
Ok(p) => p,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
};
let total_fetched = projects.len();
let names: Vec<&str> = if args.exclude.is_empty() {
projects.iter().map(|p| p.name.as_str()).collect()
} else {
projects
.iter()
.map(|p| p.name.as_str())
.filter(|n| !matches_any_pattern(n, &args.exclude))
.collect()
};
let pkg_excluded = total_fetched - names.len();
if pkg_excluded > 0 {
eprintln!(" {total_fetched} fetched, {pkg_excluded} excluded");
}
let mut inventory = if std::path::Path::new(&args.output).exists() {
match sandogasa_inventory::load(&args.output) {
Ok(inv) => inv,
Err(e) => {
eprintln!("error: {}: {e}", args.output);
return ExitCode::FAILURE;
}
}
} else {
let inv_name = args.name.clone().unwrap_or_else(|| source_label.clone());
sandogasa_inventory::Inventory {
inventory: sandogasa_inventory::InventoryMeta {
name: inv_name,
description: format!("Packages synced from GitLab ({source_label})"),
maintainer: source_label.clone(),
labels: vec![],
workloads: workloads_from_names(&args.workload),
private_fields: vec![],
},
package: vec![],
}
};
if let Some(ref name) = args.name {
inventory.inventory.name.clone_from(name);
}
let remote_names: std::collections::HashSet<&str> = names.iter().copied().collect();
let mut added = 0usize;
for name in &names {
if inventory.find_package(name).is_some() {
continue;
}
inventory.add_package(sandogasa_inventory::Package {
name: name.to_string(),
poc: None,
reason: None,
team: None,
task: None,
rpms: None,
arch_rpms: None,
track: None,
repology_name: None,
distros: None,
file_issue: None,
priority: None,
});
for wl in &args.workload {
inventory.add_to_workload(wl, name);
}
added += 1;
}
let stale: Vec<String> = inventory
.package
.iter()
.filter(|p| !remote_names.contains(p.name.as_str()))
.map(|p| p.name.clone())
.collect();
let pruned = stale.len();
if !stale.is_empty() {
if args.prune {
for name in &stale {
inventory.remove_package(name);
}
} else {
eprintln!(
"warning: {} package(s) not in sync scope \
(use --prune to remove):",
stale.len()
);
for name in &stale {
eprintln!(" {name}");
}
}
}
if let Err(e) = sandogasa_inventory::save(&inventory, &args.output) {
eprintln!("error: {e}");
return ExitCode::FAILURE;
}
let pruned_msg = if args.prune && pruned > 0 {
format!(", {pruned} pruned")
} else {
String::new()
};
eprintln!(
"Synced {source_label}: {added} new{pruned_msg}, \
{} total in {}",
inventory.package.len(),
args.output
);
ExitCode::SUCCESS
}
#[cfg(test)]
mod tests {
use super::*;
use sandogasa_distgit::ProjectInfo;
fn make_project(name: &str, owner: &str, groups: &[&str]) -> ProjectInfo {
let json = serde_json::json!({
"name": name,
"access_users": {
"owner": [owner],
"admin": [],
"commit": [],
"collaborator": [],
"ticket": []
},
"access_groups": {
"admin": [],
"commit": groups,
"collaborator": [],
"ticket": []
}
});
serde_json::from_value(json).unwrap()
}
fn make_project_with_commit(
name: &str,
owner: &str,
commit_users: &[&str],
groups: &[&str],
) -> ProjectInfo {
let json = serde_json::json!({
"name": name,
"access_users": {
"owner": [owner],
"admin": [],
"commit": commit_users,
"collaborator": [],
"ticket": []
},
"access_groups": {
"admin": [],
"commit": groups,
"collaborator": [],
"ticket": []
}
});
serde_json::from_value(json).unwrap()
}
fn default_args() -> SyncDistgitArgs {
SyncDistgitArgs {
user: Some("alice".to_string()),
group: None,
output: "out.toml".to_string(),
no_groups: false,
include_group: vec![],
exclude_group: vec![],
exclude: vec![],
pattern: None,
end_pattern: None,
auto_prefix: false,
prune: false,
per_page: 100,
workload: vec![],
name: None,
}
}
#[test]
fn filter_group_mode_returns_all() {
let projects = vec![
make_project("aaa", "bob", &["rust-sig"]),
make_project("bbb", "carol", &[]),
];
let args = SyncDistgitArgs {
user: None,
group: Some("rust-sig".to_string()),
..default_args()
};
let result = filter_projects(&projects, &args);
assert_eq!(result.len(), 2);
}
#[test]
fn filter_direct_access_always_included() {
let projects = vec![make_project("pkg", "alice", &["rust-sig"])];
let mut args = default_args();
args.no_groups = true;
let result = filter_projects(&projects, &args);
assert_eq!(result.len(), 1);
}
#[test]
fn filter_default_includes_group_only() {
let projects = vec![make_project_with_commit("pkg", "bob", &[], &["rust-sig"])];
let args = default_args();
let result = filter_projects(&projects, &args);
assert_eq!(result.len(), 1);
}
#[test]
fn filter_no_groups_excludes_group_only() {
let projects = vec![make_project_with_commit("pkg", "bob", &[], &["rust-sig"])];
let mut args = default_args();
args.no_groups = true;
let result = filter_projects(&projects, &args);
assert!(result.is_empty());
}
#[test]
fn filter_no_groups_keeps_direct() {
let projects = vec![make_project("pkg", "alice", &["rust-sig"])];
let mut args = default_args();
args.no_groups = true;
let result = filter_projects(&projects, &args);
assert_eq!(result.len(), 1);
}
#[test]
fn filter_include_group_matches() {
let projects = vec![
make_project_with_commit("a", "bob", &[], &["rust-sig"]),
make_project_with_commit("b", "bob", &[], &["python-packagers-sig"]),
];
let mut args = default_args();
args.include_group = vec!["rust-sig".to_string()];
let result = filter_projects(&projects, &args);
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "a");
}
#[test]
fn filter_include_group_still_keeps_direct() {
let projects = vec![
make_project("owned", "alice", &[]),
make_project_with_commit("group-only", "bob", &[], &["python-packagers-sig"]),
];
let mut args = default_args();
args.include_group = vec!["rust-sig".to_string()];
let result = filter_projects(&projects, &args);
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "owned");
}
#[test]
fn filter_exclude_group_removes_matching() {
let projects = vec![
make_project_with_commit("a", "bob", &[], &["rust-sig"]),
make_project_with_commit("b", "bob", &[], &["python-packagers-sig"]),
];
let mut args = default_args();
args.exclude_group = vec!["rust-sig".to_string()];
let result = filter_projects(&projects, &args);
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "b");
}
#[test]
fn filter_exclude_group_keeps_direct() {
let projects = vec![
make_project("owned", "alice", &["rust-sig"]),
make_project_with_commit("group-only", "bob", &[], &["rust-sig"]),
];
let mut args = default_args();
args.exclude_group = vec!["rust-sig".to_string()];
let result = filter_projects(&projects, &args);
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "owned");
}
#[test]
fn filter_include_multiple_groups() {
let projects = vec![
make_project_with_commit("a", "bob", &[], &["rust-sig"]),
make_project_with_commit("b", "bob", &[], &["python-packagers-sig"]),
make_project_with_commit("c", "bob", &[], &["kde-sig"]),
];
let mut args = default_args();
args.include_group = vec!["rust-sig".to_string(), "python-packagers-sig".to_string()];
let result = filter_projects(&projects, &args);
assert_eq!(result.len(), 2);
assert_eq!(result[0].name, "a");
assert_eq!(result[1].name, "b");
}
#[test]
fn pattern_empty_matches_all() {
assert!(matches_any_pattern("anything", &[]));
assert!(matches_any_pattern("anything", &[String::new()]));
}
#[test]
fn pattern_prefix_matches() {
let pats = vec!["python-*".to_string()];
assert!(matches_any_pattern("python-psutil", &pats));
assert!(!matches_any_pattern("rust-libc", &pats));
}
#[test]
fn pattern_exact_matches() {
let pats = vec!["systemd".to_string()];
assert!(matches_any_pattern("systemd", &pats));
assert!(!matches_any_pattern("systemd-networkd", &pats));
}
#[test]
fn pattern_multiple_any_matches() {
let pats = vec!["a*".to_string(), "b*".to_string()];
assert!(matches_any_pattern("autoconf", &pats));
assert!(matches_any_pattern("btrfs-progs", &pats));
assert!(!matches_any_pattern("cmake", &pats));
}
#[test]
fn pattern_case_insensitive() {
let pats = vec!["p*".to_string()];
assert!(matches_any_pattern("python-psutil", &pats));
assert!(matches_any_pattern("PackageKit", &pats));
assert!(!matches_any_pattern("systemd", &pats));
}
}