The third bullet of the issue's ask, dropped in the first pass and caught in review: a filter value that does not resolve is a near-miss far more often than an invention, and an error that only lists all 18 available names makes the reader do the diff by eye -- on the one occasion they already know they mistyped something. Thresholded rather than always suggesting the minimum-distance candidate: a wrong suggestion is worse than none, because it invites a second failed attempt at a name that was never there. The bound scales with the needle (a third of its length, capped at 3), so a short name does not match half the repo and a long one still tolerates a typo or two, and an unrelated word falls back to the full list. Tie-break is on length then alphabetical, so the suggestion does not depend on the order the forge happened to return its labels in.
209 lines
7.1 KiB
Rust
209 lines
7.1 KiB
Rust
//! `labels <number> [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<Action>,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum Action {
|
|
/// List labels (default when no action is given).
|
|
List,
|
|
/// Add labels by name.
|
|
Add {
|
|
/// Label names to add.
|
|
labels: Vec<String>,
|
|
},
|
|
/// Remove labels by name.
|
|
Remove {
|
|
/// Label names to remove.
|
|
labels: Vec<String>,
|
|
},
|
|
}
|
|
|
|
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<serde_json::Value> = 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<Vec<Label>> {
|
|
/// 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<Vec<i64>> {
|
|
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<i64> {
|
|
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)"));
|
|
}
|
|
}
|