ref:31f84b1143c66787f6551571b2093236073ac584

feat(cli): add board list, milestone list/view, fix deploy routes

Board list shows columns and issues. Milestone list/view with progress. Deploy routes restructured to nest under environments. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SHA: 31f84b1143c66787f6551571b2093236073ac584
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-03-21 18:41
Parents: 8c23d5d
4 files changed +242 -6
Type
src/commands/board.rs +77 −0
@@ -1,0 +1,77 @@
use crate::client::Client;
use crate::config;
use crate::output;
use clap::{Args, Subcommand};
#[derive(Args)]
pub struct BoardArgs {
#[command(subcommand)]
pub command: BoardCommand,
}
#[derive(Subcommand)]
pub enum BoardCommand {
/// List board columns and their issues
List {
/// Repository (org/repo)
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,
}
}
async fn list(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 = match client.get(&format!("/{org}/{name}/board/columns")).await {
Ok(resp) => resp,
Err(e) => {
let err_str = format!("{}", e);
if err_str.contains("404") || err_str.contains("Feature") {
output::error("Board feature is not enabled for this repository.");
return Ok(());
}
return Err(e.into());
}
};
// Parse and display columns
let columns = resp
.get("columns")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
if columns.is_empty() {
output::info("No board columns found.");
return Ok(());
}
for col in &columns {
let col_name = col.get("name").and_then(|v| v.as_str()).unwrap_or("?");
let issues = col
.get("issues")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0);
output::header(&format!("{} ({})", col_name, issues));
if let Some(issue_list) = col.get("issues").and_then(|v| v.as_array()) {
for issue in issue_list {
let num = issue
.get("number")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let title = issue.get("title").and_then(|v| v.as_str()).unwrap_or("?");
output::detail(&format!("#{}", num), title);
}
}
}
Ok(())
}
src/commands/deploy.rs +10 −6
@@ -11,10 +11,13 @@
#[derive(Subcommand)]
pub enum DeployCommand {
/// List deployments
/// List deployments for an environment
List {
/// Repository (org/repo)
repo: Option<String>,
/// Environment name (required)
#[arg(long)]
env: String,
},
/// Create a deployment
Create {
@@ -66,7 +69,7 @@
pub async fn run(args: DeployArgs) -> Result<(), Box<dyn std::error::Error>> {
match args.command {
DeployCommand::List { repo } => list(repo.as_deref()).await,
DeployCommand::List { repo, env } => list(repo.as_deref(), &env).await,
DeployCommand::Create {
repo,
env,
@@ -81,11 +84,13 @@
}
}
async fn list(repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
async fn list(repo: Option<&str>, env: &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.get(&format!("/{org}/{name}/deployments")).await?;
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/environments/{env}/deployments"))
.await?;
let deployments: Vec<serde_json::Value> = if let Some(arr) = resp.get("deployments") {
serde_json::from_value(arr.clone()).unwrap_or_default()
@@ -134,9 +139,8 @@
let _resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/deployments"),
&format!("/{org}/{name}/environments/{env}/deployments"),
&serde_json::json!({
"environment": env,
"ref": deploy_ref,
"description": description,
}),
src/commands/milestone.rs +147 −0
@@ -1,0 +1,147 @@
use crate::client::Client;
use crate::config;
use crate::output;
use clap::{Args, Subcommand};
use serde::Deserialize;
#[derive(Args)]
pub struct MilestoneArgs {
#[command(subcommand)]
pub command: MilestoneCommand,
}
#[derive(Subcommand)]
pub enum MilestoneCommand {
/// List milestones
List {
/// Repository (org/repo)
repo: Option<String>,
},
/// View milestone details
View {
/// Milestone short ID or UUID
id: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
}
#[derive(Debug, Deserialize)]
struct Milestone {
short_id: Option<String>,
title: Option<String>,
description: Option<String>,
state: Option<String>,
due_date: Option<String>,
progress: Option<MilestoneProgress>,
}
#[derive(Debug, Deserialize)]
struct MilestoneProgress {
open: Option<u32>,
closed: Option<u32>,
percent: Option<f64>,
}
pub async fn run(args: MilestoneArgs) -> Result<(), Box<dyn std::error::Error>> {
match args.command {
MilestoneCommand::List { repo } => list(repo.as_deref()).await,
MilestoneCommand::View { id, repo } => view(&id, repo.as_deref()).await,
}
}
async fn list(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.get(&format!("/{org}/{name}/milestones")).await?;
let milestones: Vec<Milestone> = resp
.get("milestones")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
output::header(&format!("Milestones ({org}/{name})"));
if milestones.is_empty() {
output::info("No milestones found.");
return Ok(());
}
let rows: Vec<Vec<String>> = milestones
.iter()
.map(|m| {
let progress = m
.progress
.as_ref()
.map(|p| format!("{}%", p.percent.unwrap_or(0.0) as u32))
.unwrap_or_default();
vec![
m.short_id.clone().unwrap_or_default(),
m.title.clone().unwrap_or_default(),
output::colorize_status(m.state.as_deref().unwrap_or("?")),
progress,
m.due_date.clone().unwrap_or_default(),
]
})
.collect();
output::print_table(&["ID", "TITLE", "STATE", "PROGRESS", "DUE"], &rows);
Ok(())
}
async fn view(id: &str, 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
.get(&format!("/{org}/{name}/milestones/{id}"))
.await?;
let m: Milestone = resp
.get("milestone")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_else(|| {
serde_json::from_value(resp).unwrap_or(Milestone {
short_id: None,
title: None,
description: None,
state: None,
due_date: None,
progress: None,
})
});
output::header(&format!(
"Milestone {}",
m.short_id.as_deref().unwrap_or(id)
));
output::detail("Title", m.title.as_deref().unwrap_or("?"));
output::detail(
"State",
&output::colorize_status(m.state.as_deref().unwrap_or("?")),
);
if let Some(ref due) = m.due_date {
output::detail("Due", due);
}
if let Some(ref p) = m.progress {
output::detail(
"Progress",
&format!(
"{}% ({} open, {} closed)",
p.percent.unwrap_or(0.0) as u32,
p.open.unwrap_or(0),
p.closed.unwrap_or(0)
),
);
}
if let Some(ref desc) = m.description {
if !desc.is_empty() {
output::header("Description");
println!("{}", desc);
}
}
Ok(())
}
src/commands/mod.rs +8 −0
@@ -1,11 +1,13 @@
pub mod agent;
pub mod auth;
pub mod board;
pub mod branch;
pub mod ci;
pub mod commit;
pub mod deploy;
pub mod issue;
pub mod label;
pub mod milestone;
pub mod pr;
pub mod release;
pub mod repo;
@@ -57,6 +59,10 @@
/// SSH key management
#[command(name = "ssh-key")]
SshKey(ssh_key::SshKeyArgs),
/// Board operations
Board(board::BoardArgs),
/// Milestone operations
Milestone(milestone::MilestoneArgs),
}
pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
@@ -77,5 +83,7 @@
.map_err(|e| -> Box<dyn std::error::Error> { e }),
Command::Label(args) => label::run(args).await,
Command::SshKey(args) => ssh_key::run(args).await,
Command::Board(args) => board::run(args).await,
Command::Milestone(args) => milestone::run(args).await,
}
}