//! `labels [list|add|remove] [labels...]` — manage labels on //! an issue or PR. Default action: list. use anyhow::{Result, bail}; use clap::{Args as ClapArgs, Subcommand}; use forgejo_api::structs::{DeleteLabelsOption, IssueLabelsOption, IssueListLabelsQuery, Label}; use serde_json::json; use crate::client::{Client, index}; use crate::verbs::print_json; #[derive(ClapArgs)] pub struct Args { /// Issue or PR number. pub(crate) number: u64, #[command(subcommand)] action: Option, } #[derive(Subcommand)] enum Action { /// List labels (default when no action is given). List, /// Add labels by name. Add { /// Label names to add. labels: Vec, }, /// Remove labels by name. Remove { /// Label names to remove. labels: Vec, }, } pub fn run(client: &Client, args: Args) -> Result<()> { let (owner, name) = client.owner_repo()?; let idx = index(args.number)?; match args.action.unwrap_or(Action::List) { Action::List => { let labels = client.api().issue_get_labels(owner, name, idx).send()?; print_label_names(&labels); } Action::Add { labels } => { if labels.is_empty() { bail!("hive-forge labels add: pass at least one label name"); } let all = repo_labels(client)?; let ids: Vec = resolve_ids(&all, &labels)? .into_iter() .map(|id| json!(id)) .collect(); let resp = client .api() .issue_add_label( owner, name, idx, IssueLabelsOption { labels: Some(ids), updated_at: None, }, ) .send()?; print_label_names(&resp); } Action::Remove { labels } => { if labels.is_empty() { bail!("hive-forge labels remove: pass at least one label name"); } let all = repo_labels(client)?; for label in &labels { if let Some(id) = lookup_id(&all, label) { let _ = client .api() .issue_remove_label( owner, name, idx, &id.to_string(), DeleteLabelsOption { updated_at: None }, ) .send(); } } let labels = client.api().issue_get_labels(owner, name, idx).send()?; print_label_names(&labels); } } Ok(()) } /// The repo's whole label set, for name → id resolution. `pub(crate)` so /// other verbs (`issue-create`, `pr-create`, `list`) can resolve a label /// name without duplicating the lookup. /// /// Paginated, where this used to ask for one page of 100 — **a page size /// with no page loop is a lie the size of the page.** Truncation here is /// worse than it looks: it doesn't drop a result, it makes a *valid* /// label past the cut fail to resolve, and then prints an "available /// labels" list that is itself incomplete, so the error argues for the /// typo. Same root as the `list` trailer bug (assuming one request /// returns the whole population), opposite direction — that one was a /// false positive, this is a false negative. pub(crate) fn repo_labels(client: &Client) -> Result> { /// Generous cap so a misbehaving server can't spin us forever. const MAX_PAGES: u32 = 50; const PAGE: u32 = 100; let (owner, name) = client.owner_repo()?; let mut all = Vec::new(); for page in 1..=MAX_PAGES { let (_, batch) = client .api() .issue_list_labels(owner, name, IssueListLabelsQuery::default()) .page(page) .page_size(PAGE) .send()?; let short = batch.len() < PAGE as usize; all.extend(batch); if short { break; } } Ok(all) } /// Resolve label names to ids, hard-erroring if any name doesn't match an /// existing repo label. A typo used to silently produce fewer labels than /// intended with no signal — not even a nonzero exit code — so callers /// (`labels add`, `issue-create --label`, `pr-create --label`) had no way /// to notice without manually diffing what they asked for against what /// landed. The error lists both the exact names that didn't resolve and /// every label actually available on the repo, so it's fixable from the /// error alone without a second round-trip to `labels list`. pub(crate) fn resolve_ids(all: &[Label], names: &[String]) -> Result> { let mut ids = Vec::with_capacity(names.len()); let mut unresolved = Vec::new(); for n in names { match lookup_id(all, n) { Some(id) => ids.push(id), None => unresolved.push(n.as_str()), } } if !unresolved.is_empty() { let available: Vec<&str> = all.iter().filter_map(|l| l.name.as_deref()).collect(); bail!( "unresolved label name(s): {} — available labels: {}", crate::verbs::with_suggestions(&unresolved, &available), if available.is_empty() { "(none)".to_owned() } else { available.join(", ") } ); } Ok(ids) } fn lookup_id(all: &[Label], name: &str) -> Option { all.iter() .find(|l| l.name.as_deref() == Some(name)) .and_then(|l| l.id) } fn print_label_names(labels: &[Label]) { let names: Vec<&str> = labels.iter().filter_map(|l| l.name.as_deref()).collect(); let _ = print_json(&json!(names)); } #[cfg(test)] mod tests { use super::resolve_ids; use forgejo_api::structs::Label; fn label(id: i64, name: &str) -> Label { Label { color: Some(String::new()), description: Some(String::new()), exclusive: None, id: Some(id), is_archived: None, name: Some(name.to_owned()), url: None, } } #[test] fn all_names_resolve_returns_ids_in_order() { let all = vec![label(1, "area/ops"), label(2, "type/bug")]; let ids = resolve_ids(&all, &["type/bug".to_owned(), "area/ops".to_owned()]).unwrap(); assert_eq!(ids, vec![2, 1]); } #[test] fn unresolved_name_errors_listing_the_typo_and_available_labels() { let all = vec![label(1, "area/ops"), label(2, "type/bug")]; let err = resolve_ids(&all, &["area/op".to_owned()]).unwrap_err(); let msg = err.to_string(); assert!(msg.contains("area/op"), "missing typo'd name: {msg}"); assert!(msg.contains("area/ops"), "missing available label: {msg}"); assert!(msg.contains("type/bug"), "missing available label: {msg}"); } #[test] fn unresolved_name_on_empty_repo_says_none_available() { let err = resolve_ids(&[], &["anything".to_owned()]).unwrap_err(); assert!(err.to_string().contains("(none)")); } }