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 {
repo: Option<String>,
},
View {
id: String,
#[arg(long)]
repo: Option<String>,
},
Create {
#[arg(long)]
repo: Option<String>,
#[arg(long)]
title: String,
#[arg(long, default_value = "")]
description: String,
#[arg(long)]
due_date: 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,
MilestoneCommand::Create {
repo,
title,
description,
due_date,
} => create(repo.as_deref(), &title, &description, due_date.as_deref()).await,
}
}
pub async fn create(
repo: Option<&str>,
title: &str,
description: &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!({
"title": title,
"description": description,
});
if let Some(d) = due_date {
payload["due_date"] = serde_json::Value::String(d.to_string());
}
let resp: serde_json::Value = client
.post(&format!("/{org}/{name}/milestones"), &payload)
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let short_id = resp
.pointer("/milestone/short_id")
.or_else(|| resp.pointer("/data/short_id"))
.or_else(|| resp.get("short_id"))
.and_then(|v| v.as_str())
.unwrap_or("?");
output::success(&format!("Created milestone {short_id}: {title}"));
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)?;
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/milestones")).await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
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?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
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(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[derive(Parser)]
#[command(no_binary_name = true)]
struct MilestoneCli {
#[command(subcommand)]
command: MilestoneCommand,
}
fn parse(args: &[&str]) -> MilestoneCommand {
MilestoneCli::try_parse_from(args).expect("parse").command
}
#[test]
fn create_parses_required_title() {
match parse(&["create", "--title", "v1.0"]) {
MilestoneCommand::Create {
title,
description,
due_date,
..
} => {
assert_eq!(title, "v1.0");
assert_eq!(description, "");
assert!(due_date.is_none());
}
_ => panic!("expected Create"),
}
}
#[test]
fn create_parses_due_date() {
match parse(&["create", "--title", "v1", "--due-date", "2026-12-31"]) {
MilestoneCommand::Create { due_date, .. } => {
assert_eq!(due_date.as_deref(), Some("2026-12-31"));
}
_ => panic!("expected Create"),
}
}
}