ref:3839e72afed26385eeb9d00adff2f3abbba1915a

feat(cli): board write commands + issue move + --json (#329 F+B)

The kanban was UI-only from the CLI's perspective — `board list` was the whole surface. Adds: - `anvil board init` (default columns, 409 when already initialized), `board create-column` / `edit-column` / `delete-column` against the new board write endpoints (short-id or UUID). - `anvil issue move <N> --column <uuid>` via PATCH /issues/:number column_id (the endpoint that already backed the web kanban's drag). - --json across board list + all new commands ({"ok":true,…} envelopes). Integration test drives board init + issue move via wiremock. Part of epic fangorn/anvil#329 (Workstreams F + B). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SHA: 3839e72afed26385eeb9d00adff2f3abbba1915a
Author: CI <ci@anvil.test>
Date: 2026-07-07 04:02
Parents: 0388a7b
3 files changed +288 -0
Type
src/commands/board.rs +200 −0
@@ -16,14 +16,209 @@
/// Repository (org/repo)
repo: Option<String>,
},
/// Initialize the default board columns (Backlog/In Progress/Done)
Init {
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Create a board column
#[command(name = "create-column")]
CreateColumn {
/// Column name
name: String,
/// Position (0-based)
#[arg(long)]
position: Option<u32>,
/// WIP limit
#[arg(long)]
wip_limit: Option<u32>,
/// Closing issues moves them here
#[arg(long)]
closed_column: bool,
/// Column color (hex)
#[arg(long)]
color: Option<String>,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Edit a board column
#[command(name = "edit-column")]
EditColumn {
/// Column short ID or UUID
id: String,
/// New name
#[arg(long)]
name: Option<String>,
/// New position
#[arg(long)]
position: Option<u32>,
/// New WIP limit
#[arg(long)]
wip_limit: Option<u32>,
/// New color (hex)
#[arg(long)]
color: Option<String>,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Delete a board column (must be empty)
#[command(name = "delete-column")]
DeleteColumn {
/// Column short ID or UUID
id: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
}
pub async fn run(args: BoardArgs) -> Result<(), Box<dyn std::error::Error>> {
match args.command {
BoardCommand::List { repo } => list(repo.as_deref()).await,
BoardCommand::Init { repo } => init(repo.as_deref()).await,
BoardCommand::CreateColumn {
name,
position,
wip_limit,
closed_column,
color,
repo,
} => {
create_column(
repo.as_deref(),
&name,
position,
wip_limit,
closed_column,
color.as_deref(),
)
.await
}
BoardCommand::EditColumn {
id,
name,
position,
wip_limit,
color,
repo,
} => {
edit_column(
repo.as_deref(),
&id,
name.as_deref(),
position,
wip_limit,
color.as_deref(),
)
.await
}
BoardCommand::DeleteColumn { id, repo } => delete_column(repo.as_deref(), &id).await,
}
}
async fn init(repo: Option<&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}/board/initialize"),
&serde_json::json!({}),
)
.await?;
if output::is_json() {
output::json_ok("columns", resp["columns"].clone());
return Ok(());
}
let count = resp["columns"].as_array().map(|a| a.len()).unwrap_or(0);
output::success(&format!("Initialized board with {count} columns"));
Ok(())
}
async fn create_column(
repo: Option<&str>,
col_name: &str,
position: Option<u32>,
wip_limit: Option<u32>,
closed_column: bool,
color: 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!({"name": col_name});
if let Some(v) = position {
payload["position"] = serde_json::json!(v);
}
if let Some(v) = wip_limit {
payload["wip_limit"] = serde_json::json!(v);
}
if closed_column {
payload["is_closed_column"] = serde_json::json!(true);
}
if let Some(v) = color {
payload["color"] = serde_json::json!(v);
}
let resp: serde_json::Value = client
.post(&format!("/{org}/{name}/board/columns"), &payload)
.await?;
if output::is_json() {
output::json_ok("column", resp["column"].clone());
return Ok(());
}
output::success(&format!("Created column '{col_name}'"));
Ok(())
}
async fn edit_column(
repo: Option<&str>,
id: &str,
new_name: Option<&str>,
position: Option<u32>,
wip_limit: Option<u32>,
color: 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["name"] = serde_json::json!(v);
}
if let Some(v) = position {
payload["position"] = serde_json::json!(v);
}
if let Some(v) = wip_limit {
payload["wip_limit"] = serde_json::json!(v);
}
if let Some(v) = color {
payload["color"] = serde_json::json!(v);
}
let resp: serde_json::Value = client
.patch(&format!("/{org}/{name}/board/columns/{id}"), &payload)
.await?;
if output::is_json() {
output::json_ok("column", resp["column"].clone());
return Ok(());
}
output::success(&format!("Updated column '{id}'"));
Ok(())
}
async fn delete_column(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}/board/columns/{id}"))
.await?;
if output::is_json() {
output::json_ok("deleted", serde_json::json!(id));
return Ok(());
}
output::success(&format!("Deleted column '{id}'"));
Ok(())
}
async fn list(repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -39,6 +234,11 @@
return Err(e.into());
}
};
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
// Parse and display columns
let columns = resp
src/commands/issue.rs +37 −0
@@ -176,6 +176,17 @@
#[arg(long)]
repo: Option<String>,
},
/// Move an issue to a board column
Move {
/// Issue number
number: u32,
/// Target column UUID (the `id` field in `anvil board list --json`)
#[arg(long)]
column: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Manually link a requirement (REQ-*) or standard (STD-*) to an issue
#[command(name = "link-req")]
LinkReq {
@@ -306,6 +317,11 @@
link_id,
repo,
} => remove_link(repo.as_deref(), number, &link_id).await,
IssueCommand::Move {
number,
column,
repo,
} => move_issue(repo.as_deref(), number, &column).await,
IssueCommand::LinkReq {
number,
requirement_id,
@@ -867,5 +883,26 @@
output::success(&format!(
"Removed link {link_id} from {org}/{name}#{number}"
));
Ok(())
}
async fn move_issue(
repo: Option<&str>,
number: u32,
column: &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
.patch(
&format!("/{org}/{name}/issues/{number}"),
&serde_json::json!({ "column_id": column }),
)
.await?;
if output::is_json() {
output::json_ok("issue", resp);
return Ok(());
}
output::success(&format!("Moved issue #{number} to column {column}"));
Ok(())
}
tests/json_output.rs +51 −0
@@ -738,3 +738,54 @@
assert_eq!(stdout_json(&del)["deleted"], "abc123");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn board_init_and_issue_move_emit_ok_envelopes() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/test-org/test-repo/board/initialize"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"columns": [{"id": "c1", "short_id": "aaa", "name": "Backlog", "position": 0}]
})))
.mount(&server)
.await;
Mock::given(method("PATCH"))
.and(path("/api/v1/test-org/test-repo/issues/7"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"number": 7, "column_id": "c1"
})))
.mount(&server)
.await;
let init = run_json(
&server.uri(),
&["board", "init", "--repo", "test-org/test-repo"],
);
assert!(
init.status.success(),
"stderr: {}",
String::from_utf8_lossy(&init.stderr)
);
let v = stdout_json(&init);
assert_eq!(v["ok"], true);
assert_eq!(v["columns"][0]["name"], "Backlog");
let mv = run_json(
&server.uri(),
&[
"issue",
"move",
"7",
"--column",
"c1",
"--repo",
"test-org/test-repo",
],
);
assert!(
mv.status.success(),
"stderr: {}",
String::from_utf8_lossy(&mv.stderr)
);
assert_eq!(stdout_json(&mv)["ok"], true);
}