ref:0388a7b511a9f6a2d0f5755a7d015e7729cef91a

feat(cli): milestone edit/close/reopen/delete + --json family (#329 F+B)

- `anvil milestone edit <id> [--title --description --due-date]`, `close <id>`, `reopen <id>`, `delete <id>` against the new milestone write endpoints (short-id or UUID). - --json across the family: list/view echo the server payload; create/edit/close/reopen return {"ok":true,"milestone":…}; delete the confirmation envelope. Integration test drives edit→close→delete end-to-end via wiremock. Part of epic fangorn/anvil#329 (Workstreams F + B). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SHA: 0388a7b511a9f6a2d0f5755a7d015e7729cef91a
Author: CI <ci@anvil.test>
Date: 2026-07-07 03:59
Parents: 29f3734
2 files changed +205 -0
Type
src/commands/milestone.rs +138 −0
@@ -40,6 +40,47 @@
#[arg(long)]
due_date: Option<String>,
},
/// Edit a milestone's title / description / due date
Edit {
/// Milestone short ID or UUID
id: String,
/// New title
#[arg(long)]
title: Option<String>,
/// New description
#[arg(long)]
description: Option<String>,
/// New due date (YYYY-MM-DD)
#[arg(long)]
due_date: Option<String>,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Close a milestone
Close {
/// Milestone short ID or UUID
id: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Reopen a closed milestone
Reopen {
/// Milestone short ID or UUID
id: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Delete a milestone
Delete {
/// Milestone short ID or UUID
id: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
}
#[derive(Debug, Deserialize)]
@@ -69,9 +110,94 @@
description,
due_date,
} => create(repo.as_deref(), &title, &description, due_date.as_deref()).await,
MilestoneCommand::Edit {
id,
title,
description,
due_date,
repo,
} => {
edit(
repo.as_deref(),
&id,
title.as_deref(),
description.as_deref(),
due_date.as_deref(),
)
.await
}
MilestoneCommand::Close { id, repo } => set_state(repo.as_deref(), &id, "close").await,
MilestoneCommand::Reopen { id, repo } => set_state(repo.as_deref(), &id, "reopen").await,
MilestoneCommand::Delete { id, repo } => delete(repo.as_deref(), &id).await,
}
}
async fn edit(
repo: Option<&str>,
id: &str,
title: Option<&str>,
description: Option<&str>,
due_date: 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) = title {
payload["title"] = serde_json::json!(v);
}
if let Some(v) = description {
payload["description"] = serde_json::json!(v);
}
if let Some(v) = due_date {
payload["due_date"] = serde_json::json!(v);
}
let resp: serde_json::Value = client
.patch(&format!("/{org}/{name}/milestones/{id}"), &payload)
.await?;
if output::is_json() {
output::json_ok("milestone", resp["milestone"].clone());
return Ok(());
}
output::success(&format!("Updated milestone '{id}'"));
Ok(())
}
async fn set_state(
repo: Option<&str>,
id: &str,
verb: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/milestones/{id}/{verb}"),
&serde_json::json!({}),
)
.await?;
if output::is_json() {
output::json_ok("milestone", resp["milestone"].clone());
return Ok(());
}
let state = resp["milestone"]["state"].as_str().unwrap_or(verb);
output::success(&format!("Milestone '{id}' is now {state}"));
Ok(())
}
async fn delete(repo: Option<&str>, id: &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}/milestones/{id}"))
.await?;
if output::is_json() {
output::json_ok("deleted", serde_json::json!(id));
return Ok(());
}
output::success(&format!("Deleted milestone '{id}'"));
Ok(())
}
pub async fn create(
repo: Option<&str>,
title: &str,
@@ -105,6 +231,10 @@
.and_then(|v| v.as_str())
.unwrap_or("?");
if output::is_json() {
output::json_ok("milestone", resp["milestone"].clone());
return Ok(());
}
output::success(&format!("Created milestone {short_id}: {title}"));
Ok(())
}
@@ -114,6 +244,10 @@
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/milestones")).await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
if output::is_json() {
output::print_json(&resp);
@@ -161,6 +295,10 @@
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/milestones/{id}"))
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
if output::is_json() {
output::print_json(&resp);
tests/json_output.rs +67 −0
@@ -671,3 +671,70 @@
assert_eq!(v["labels"][0]["name"], "bug");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn milestone_edit_close_delete_emit_ok_envelopes() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/api/v1/test-org/test-repo/milestones/abc123"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"milestone": {"short_id": "abc123", "title": "v1.1", "state": "open"}
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/v1/test-org/test-repo/milestones/abc123/close"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"milestone": {"short_id": "abc123", "title": "v1.1", "state": "closed"}
})))
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path("/api/v1/test-org/test-repo/milestones/abc123"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let edit = run_json(
&server.uri(),
&[
"milestone",
"edit",
"abc123",
"--title",
"v1.1",
"--repo",
"test-org/test-repo",
],
);
assert!(edit.status.success());
let v = stdout_json(&edit);
assert_eq!(v["ok"], true);
assert_eq!(v["milestone"]["title"], "v1.1");
let close = run_json(
&server.uri(),
&[
"milestone",
"close",
"abc123",
"--repo",
"test-org/test-repo",
],
);
assert!(close.status.success());
assert_eq!(stdout_json(&close)["milestone"]["state"], "closed");
let del = run_json(
&server.uri(),
&[
"milestone",
"delete",
"abc123",
"--repo",
"test-org/test-repo",
],
);
assert!(del.status.success());
assert_eq!(stdout_json(&del)["deleted"], "abc123");
}