ref:29f37345fe1438de0dcd16e0365214e632bee688

feat(cli): label edit/delete subcommands + --json across the label family (#329 F+B)

- `anvil label edit <name> [--new-name --color --description]` PATCHes the new /labels/:name endpoint; `anvil label delete <name>` DELETEs it. - --json wired through the whole family: list echoes the server payload; create/edit return {"ok":true,"label":…}; delete/add/remove return the standard confirmation envelope. Integration tests (real binary vs wiremock) for edit/delete/list JSON. Part of epic fangorn/anvil#329 (Workstreams F + B). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SHA: 29f37345fe1438de0dcd16e0365214e632bee688
Author: CI <ci@anvil.test>
Date: 2026-07-07 03:54
Parents: 3e4fd4f
2 files changed +188 -1
Type
src/commands/label.rs +109 −1
@@ -32,6 +32,31 @@
#[arg(long)]
repo: Option<String>,
},
/// Edit a label definition (rename / recolor / redescribe)
Edit {
/// Current label name
name: String,
/// New name
#[arg(long)]
new_name: Option<String>,
/// New color (hex, e.g. "#00ff00")
#[arg(long)]
color: Option<String>,
/// New description
#[arg(long)]
description: Option<String>,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Delete a label definition (removes it from all issues)
Delete {
/// Label name
name: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Add a label to an issue
Add {
/// Issue number
@@ -74,6 +99,23 @@
description,
repo,
} => create(repo.as_deref(), &name, &color, description.as_deref()).await,
LabelCommand::Edit {
name,
new_name,
color,
description,
repo,
} => {
edit(
repo.as_deref(),
&name,
new_name.as_deref(),
color.as_deref(),
description.as_deref(),
)
.await
}
LabelCommand::Delete { name, repo } => delete(repo.as_deref(), &name).await,
LabelCommand::Add { issue, name, repo } => add(repo.as_deref(), issue, &name).await,
LabelCommand::Remove { issue, name, repo } => remove(repo.as_deref(), issue, &name).await,
}
@@ -83,6 +125,10 @@
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/labels")).await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let labels: Vec<Label> = resp
.get("labels")
.and_then(|v| serde_json::from_value(v.clone()).ok())
@@ -114,13 +160,61 @@
if let Some(desc) = description {
payload["description"] = serde_json::json!(desc);
}
let _resp: serde_json::Value = client
let resp: serde_json::Value = client
.post(&format!("/{org}/{name}/labels"), &payload)
.await?;
if output::is_json() {
output::json_ok("label", resp);
return Ok(());
}
output::success(&format!("Created label '{label_name}'"));
Ok(())
}
async fn edit(
repo: Option<&str>,
label_name: &str,
new_name: Option<&str>,
color: Option<&str>,
description: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let mut payload = serde_json::json!({});
if let Some(v) = new_name {
payload["new_name"] = serde_json::json!(v);
}
if let Some(v) = color {
payload["color"] = serde_json::json!(v);
}
if let Some(v) = description {
payload["description"] = serde_json::json!(v);
}
let resp: serde_json::Value = client
.patch(&format!("/{org}/{name}/labels/{label_name}"), &payload)
.await?;
if output::is_json() {
output::json_ok("label", resp);
return Ok(());
}
output::success(&format!("Updated label '{label_name}'"));
Ok(())
}
async fn delete(repo: Option<&str>, label_name: &str) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
client
.delete_empty(&format!("/{org}/{name}/labels/{label_name}"))
.await?;
if output::is_json() {
output::json_ok("deleted", serde_json::json!(label_name));
return Ok(());
}
output::success(&format!("Deleted label '{label_name}'"));
Ok(())
}
async fn add(
repo: Option<&str>,
issue_number: u32,
@@ -134,6 +228,13 @@
&serde_json::json!({"name": label_name}),
)
.await?;
if output::is_json() {
output::json_ok(
"label_added",
serde_json::json!({"issue": issue_number, "name": label_name}),
);
return Ok(());
}
output::success(&format!(
"Added label '{label_name}' to issue #{issue_number}"
));
@@ -152,6 +253,13 @@
"/{org}/{name}/issues/{issue_number}/labels/{label_name}"
))
.await?;
if output::is_json() {
output::json_ok(
"label_removed",
serde_json::json!({"issue": issue_number, "name": label_name}),
);
return Ok(());
}
output::success(&format!(
"Removed label '{label_name}' from issue #{issue_number}"
));
tests/json_output.rs +79 −0
@@ -592,3 +592,82 @@
assert_eq!(v["unlinked"]["requirement_id"], "STD-B-002");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_edit_emits_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/api/v1/test-org/test-repo/labels/bug"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"name": "defect", "color": "#00ff00", "description": null
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"label",
"edit",
"bug",
"--new-name",
"defect",
"--color",
"#00ff00",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["label"]["name"], "defect");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_delete_emits_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/api/v1/test-org/test-repo/labels/bug"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&["label", "delete", "bug", "--repo", "test-org/test-repo"],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["deleted"], "bug");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn label_list_emits_json() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/test-org/test-repo/labels"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"labels": [{"name": "bug", "color": "#ff0000", "description": null}]
})))
.mount(&server)
.await;
let out = run_json(&server.uri(), &["label", "list", "test-org/test-repo"]);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["labels"][0]["name"], "bug");
}