189 lines
6.3 KiB
Rust
189 lines
6.3 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(())
|
|
}
|
|
|
|
/// First page (100) of the repo's label set, for name → id resolution.
|
|
/// `pub(crate)` so other creation verbs (`issue-create`, `pr-create`) can
|
|
/// resolve a `--label` name to the id the create-payload structs need
|
|
/// without duplicating the lookup.
|
|
pub(crate) fn repo_labels(client: &Client) -> Result<Vec<Label>> {
|
|
let (owner, name) = client.owner_repo()?;
|
|
let (_, labels) = client
|
|
.api()
|
|
.issue_list_labels(owner, name, IssueListLabelsQuery::default())
|
|
.page_size(100)
|
|
.send()?;
|
|
Ok(labels)
|
|
}
|
|
|
|
/// 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: {}",
|
|
unresolved.join(", "),
|
|
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)"));
|
|
}
|
|
}
|