ref:0430ea2cdebfb93074aa52a637b5dbd2b27d00f2

feat(cli): --json flag, pr diff/checkout, commit view, milestone create

CLI gap audit follow-ups (closes #10, #11, #12, #13, #17). All work is CLI-only — no new server endpoints required for any of these. * **--json** (#10): top-level global flag. When passed, every list/view command short-circuits the table renderer and pretty-prints the raw server JSON. Implemented as a process-global atomic so the flag propagates without threading through every fn signature. Wired into the most-used surfaces: pr list/view, issue list/view, epic list/view, ci list/view, milestone list/view, repo view, branch list, commit list. Other list/view commands (label, ssh-key, requirement, release, deploy, agent, board, runner) remain table-only — follow-up. * **anvil pr diff <num>** (#11): resolves the PR via existing `GET /pulls/:n` to get base/head branches, runs `git fetch origin base head`, then `git diff origin/base...origin/head`. --name-only flag for file list. --remote to override. Pure local git wrapper. * **anvil pr checkout <num>** (#12): resolves PR head_branch, refuses to clobber uncommitted changes (mirrors `gh pr checkout`), `git fetch` + `git checkout -B <head> origin/<head>`. Pure local git wrapper. * **anvil commit view <sha>** (#17): wraps `git show` (with --stat --no-patch by default; --diff to include patch). Pure local git. * **anvil milestone create** (#13): server already has `POST /milestones`, just adds the CLI verb. The other CRUD verbs (edit/close/reopen/delete) need server endpoints and are blocked. The duplicate `anvil issue milestones` and `anvil issue create-milestone` now emit deprecation warnings and delegate to the milestone command. Tests: * src/output.rs: roundtrip tests for set_json_mode/is_json + smoke test for print_json. * src/commands/pr.rs: clap parsing for `pr diff` and `pr checkout`. * src/commands/commit.rs: clap parsing for `commit view`. * src/commands/milestone.rs: clap parsing for `milestone create`. 39/39 cargo tests pass. cargo clippy --all-targets -D warnings clean. cargo fmt clean. Verified live against production: - `anvil pr list fangorn/anvil-cli --json` returns valid JSON - `anvil pr view 13 --repo fangorn/anvil-cli --json` returns valid JSON - `anvil milestone --help` shows new `create` verb - `anvil pr --help` shows new `diff` and `checkout` verbs - `anvil commit --help` shows new `view` verb Blocked on server work (filed separately): #14 anvil pr rebase — needs POST /pulls/:n/rebase #15 anvil branch ... — needs branch CRUD + protection endpoints #16 anvil status/inbox — needs aggregation endpoint Closes #10, #11, #12, #13, #17
SHA: 0430ea2cdebfb93074aa52a637b5dbd2b27d00f2
Author: CI <ci@anvil.test>
Date: 2026-05-09 03:52
Parents: 337a754
10 files changed +511 -56
Type
src/commands/branch.rs +5 −0
@@ -38,6 +38,11 @@
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/branches")).await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let branches: Vec<Branch> = if let Some(arr) = resp.get("branches") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else if let Some(arr) = resp.get("data") {
src/commands/ci.rs +10 −0
@@ -172,6 +172,11 @@
.get_with_query(&format!("/{org}/{name}/ci/runs"), &query_refs)
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let runs: Vec<CiRun> = if let Some(arr) = resp.get("ci_runs") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else if let Some(arr) = resp.get("data") {
@@ -227,6 +232,11 @@
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/ci/runs/{id}")).await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let run: CiRun = if let Some(obj) = resp.get("ci_run") {
serde_json::from_value(obj.clone())?
src/commands/commit.rs +68 −0
@@ -23,6 +23,14 @@
#[arg(long, default_value = "20")]
limit: u32,
},
/// View a single commit (uses local `git show`)
View {
/// Commit SHA
sha: String,
/// Include the diff/patch
#[arg(long)]
diff: bool,
},
}
#[derive(Debug, Deserialize)]
@@ -45,9 +53,28 @@
ref_name,
limit,
} => list(repo.as_deref(), &ref_name, limit).await,
CommitCommand::View { sha, diff } => view(&sha, diff).await,
}
}
async fn view(sha: &str, diff: bool) -> Result<(), Box<dyn std::error::Error>> {
// v1: just shell out to `git show` — works in any cloned repo. The
// server endpoint for commit metadata exists but no diff endpoint;
// local `git show` covers both metadata and patch in one shot.
let mut args: Vec<&str> = vec!["show"];
if !diff {
args.push("--stat");
args.push("--no-patch");
}
args.push(sha);
let status = std::process::Command::new("git").args(&args).status()?;
if !status.success() {
return Err(format!("git show {sha} failed").into());
}
Ok(())
}
async fn list(
repo: Option<&str>,
ref_name: &str,
@@ -63,6 +90,11 @@
)
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let commits: Vec<Commit> = if let Some(arr) = resp.get("commits") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else {
@@ -106,4 +138,40 @@
output::print_table(&["SHA", "MESSAGE", "AUTHOR", "DATE"], &rows);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[derive(Parser)]
#[command(no_binary_name = true)]
struct CommitCli {
#[command(subcommand)]
command: CommitCommand,
}
fn parse(args: &[&str]) -> CommitCommand {
CommitCli::try_parse_from(args).expect("parse").command
}
#[test]
fn view_parses_without_diff() {
match parse(&["view", "abc123"]) {
CommitCommand::View { sha, diff } => {
assert_eq!(sha, "abc123");
assert!(!diff);
}
_ => panic!("expected View"),
}
}
#[test]
fn view_parses_with_diff() {
match parse(&["view", "abc123", "--diff"]) {
CommitCommand::View { diff, .. } => assert!(diff),
_ => panic!("expected View"),
}
}
}
src/commands/epic.rs +11 −0
@@ -172,6 +172,11 @@
)
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let issues: Vec<EpicSummary> = if let Some(arr) = resp.get("issues") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else if let Some(arr) = resp.get("data") {
@@ -211,6 +216,12 @@
let issue: serde_json::Value = client
.get(&format!("/{org}/{name}/issues/{number}"))
.await?;
if output::is_json() {
output::print_json(&issue);
return Ok(());
}
let title = issue
.get("title")
.and_then(|v| v.as_str())
src/commands/issue.rs +21 −55
@@ -302,6 +302,11 @@
)
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let issues: Vec<Issue> = if let Some(arr) = resp.get("issues") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else if let Some(arr) = resp.get("data") {
@@ -351,6 +356,11 @@
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/issues/{number}"))
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let issue: Issue = if let Some(obj) = resp.get("issue") {
serde_json::from_value(obj.clone())?
@@ -563,47 +573,13 @@
}
async fn list_milestones(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<serde_json::Value> = resp
.get("milestones")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
output::header(&format!("Milestones ({org}/{name})"));
let rows: Vec<Vec<String>> = milestones
.iter()
.map(|m| {
vec![
m.get("title")
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string(),
m.get("state")
.and_then(|v| v.as_str())
.map(output::colorize_status)
.unwrap_or_default(),
format!(
"{}/{}",
m.get("closed_issues").and_then(|v| v.as_u64()).unwrap_or(0),
m.get("open_issues").and_then(|v| v.as_u64()).unwrap_or(0)
+ m.get("closed_issues").and_then(|v| v.as_u64()).unwrap_or(0)
),
format!(
"{}%",
m.get("progress_percent")
.and_then(|v| v.as_f64())
.unwrap_or(0.0) as u32
),
m.get("due_date")
.and_then(|v| v.as_str())
.unwrap_or("none")
.to_string(),
]
})
.collect();
output::print_table(&["TITLE", "STATE", "PROGRESS", "%", "DUE"], &rows);
Ok(())
output::warn("`anvil issue milestones` is deprecated; use `anvil milestone list` instead.");
super::milestone::run(super::milestone::MilestoneArgs {
command: super::milestone::MilestoneCommand::List {
repo: repo.map(|s| s.to_string()),
},
})
.await
}
async fn create_milestone(
@@ -612,20 +588,10 @@
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!({"title": title});
if let Some(d) = description {
payload["description"] = serde_json::json!(d);
output::warn(
"`anvil issue create-milestone` is deprecated; use `anvil milestone create` instead.",
);
super::milestone::create(repo, title, description.unwrap_or(""), due_date).await
}
if let Some(dd) = due_date {
payload["due_date"] = serde_json::json!(dd);
}
let _resp: serde_json::Value = client
.post(&format!("/{org}/{name}/milestones"), &payload)
.await?;
output::success(&format!("Created milestone '{title}'"));
Ok(())
}
async fn assign(
src/commands/milestone.rs +112 −0
@@ -25,6 +25,21 @@
#[arg(long)]
repo: Option<String>,
},
/// Create a milestone
Create {
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
/// Milestone title
#[arg(long)]
title: String,
/// Description (optional)
#[arg(long, default_value = "")]
description: String,
/// Due date (YYYY-MM-DD)
#[arg(long)]
due_date: Option<String>,
},
}
#[derive(Debug, Deserialize)]
@@ -48,14 +63,62 @@
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")
@@ -99,6 +162,11 @@
.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())
@@ -144,4 +212,48 @@
}
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"),
}
}
}
src/commands/mod.rs +7 −0
@@ -25,6 +25,11 @@
version = include_str!(concat!(env!("OUT_DIR"), "/version.txt"))
)]
pub struct Cli {
/// Output JSON instead of human-readable tables/details. Applies to
/// list and view subcommands. Useful for scripting.
#[arg(long, global = true)]
pub json: bool,
#[command(subcommand)]
pub command: Command,
}
@@ -69,6 +74,8 @@
}
pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
crate::output::set_json_mode(cli.json);
match cli.command {
Command::Auth(args) => auth::run(args).await,
Command::Repo(args) => repo::run(args).await,
src/commands/pr.rs +206 −1
@@ -130,6 +130,31 @@
#[arg(long)]
repo: Option<String>,
},
/// Print the PR diff to stdout (uses local git)
Diff {
/// PR number
number: u32,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
/// Print only file names, not the patch
#[arg(long)]
name_only: bool,
/// Git remote to fetch from
#[arg(long, default_value = "origin")]
remote: String,
},
/// Fetch and check out a PR locally (uses local git)
Checkout {
/// PR number
number: u32,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
/// Git remote to fetch from
#[arg(long, default_value = "origin")]
remote: String,
},
}
#[derive(Debug, Deserialize)]
@@ -194,9 +219,105 @@
body,
repo,
} => add_pr_comment(repo.as_deref(), number, &file, line, &body).await,
PrCommand::Diff {
number,
repo,
name_only,
remote,
} => diff(repo.as_deref(), number, name_only, &remote).await,
PrCommand::Checkout {
number,
repo,
remote,
} => checkout(repo.as_deref(), number, &remote).await,
}
}
/// Resolve a PR's base/head branches via the API. Used by diff/checkout.
async fn resolve_pr_branches(
repo: Option<&str>,
number: u32,
) -> Result<(String, String), 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}/pulls/{number}")).await?;
let pr: PullRequest = if let Some(obj) = resp.get("pull_request") {
serde_json::from_value(obj.clone())?
} else if let Some(obj) = resp.get("data") {
serde_json::from_value(obj.clone())?
} else {
serde_json::from_value(resp)?
};
let base = pr.base_branch.ok_or("PR response missing base_branch")?;
let head = pr.head_branch.ok_or("PR response missing head_branch")?;
Ok((base, head))
}
async fn diff(
repo: Option<&str>,
number: u32,
name_only: bool,
remote: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let (base, head) = resolve_pr_branches(repo, number).await?;
// Fetch both branches so the diff is against the server's current view.
let fetch = std::process::Command::new("git")
.args(["fetch", remote, &base, &head])
.status()?;
if !fetch.success() {
return Err(format!("git fetch {remote} {base} {head} failed").into());
}
let mut args: Vec<String> = vec!["diff".into()];
if name_only {
args.push("--name-only".into());
}
// Three-dot syntax: diff from the merge-base of remote/base..remote/head.
args.push(format!("{remote}/{base}...{remote}/{head}"));
let status = std::process::Command::new("git").args(&args).status()?;
if !status.success() {
return Err("git diff failed".into());
}
Ok(())
}
async fn checkout(
repo: Option<&str>,
number: u32,
remote: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let (_base, head) = resolve_pr_branches(repo, number).await?;
// Refuse to clobber uncommitted changes — match `gh pr checkout` behavior.
let dirty = std::process::Command::new("git")
.args(["diff-index", "--quiet", "HEAD", "--"])
.status()?;
if !dirty.success() {
return Err("working tree has uncommitted changes; commit or stash before checkout".into());
}
let fetch = std::process::Command::new("git")
.args(["fetch", remote, &head])
.status()?;
if !fetch.success() {
return Err(format!("git fetch {remote} {head} failed").into());
}
// Create or update the local branch tracking remote/head, then check it out.
let checkout = std::process::Command::new("git")
.args(["checkout", "-B", &head, &format!("{remote}/{head}")])
.status()?;
if !checkout.success() {
return Err(format!("git checkout {head} failed").into());
}
output::success(&format!("Checked out PR #{number} ({head})"));
Ok(())
}
async fn list(
repo: Option<&str>,
state: &str,
@@ -212,6 +333,11 @@
)
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let prs: Vec<PullRequest> = if let Some(arr) = resp.get("pull_requests") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else if let Some(arr) = resp.get("data") {
@@ -253,12 +379,17 @@
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/pulls/{number}")).await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let pr: PullRequest = if let Some(obj) = resp.get("pull_request") {
serde_json::from_value(obj.clone())?
} else if let Some(obj) = resp.get("data") {
serde_json::from_value(obj.clone())?
} else {
serde_json::from_value(resp)?
serde_json::from_value(resp.clone())?
};
output::header(&format!(
@@ -521,4 +652,78 @@
.await?;
output::success(&format!("Added comment on {file}:{line} in PR #{number}"));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[derive(Parser)]
#[command(no_binary_name = true)]
struct PrCli {
#[command(subcommand)]
command: PrCommand,
}
fn parse(args: &[&str]) -> PrCommand {
PrCli::try_parse_from(args).expect("parse").command
}
#[test]
fn diff_parses_with_defaults() {
match parse(&["diff", "42"]) {
PrCommand::Diff {
number,
repo,
name_only,
remote,
} => {
assert_eq!(number, 42);
assert!(repo.is_none());
assert!(!name_only);
assert_eq!(remote, "origin");
}
other => panic!("expected Diff, got {:?}", std::mem::discriminant(&other)),
}
}
#[test]
fn diff_parses_with_name_only_and_custom_remote() {
match parse(&["diff", "42", "--name-only", "--remote", "upstream"]) {
PrCommand::Diff {
name_only, remote, ..
} => {
assert!(name_only);
assert_eq!(remote, "upstream");
}
_ => panic!("expected Diff"),
}
}
#[test]
fn checkout_parses() {
match parse(&["checkout", "42"]) {
PrCommand::Checkout {
number,
repo,
remote,
} => {
assert_eq!(number, 42);
assert!(repo.is_none());
assert_eq!(remote, "origin");
}
_ => panic!("expected Checkout"),
}
}
#[test]
fn checkout_parses_with_repo() {
match parse(&["checkout", "42", "--repo", "fangorn/anvil"]) {
PrCommand::Checkout { repo, .. } => {
assert_eq!(repo.as_deref(), Some("fangorn/anvil"));
}
_ => panic!("expected Checkout"),
}
}
}
src/commands/repo.rs +7 −0
@@ -75,6 +75,13 @@
async fn view(repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
if output::is_json() {
let resp: serde_json::Value = client.get(&format!("/{org}/{name}")).await?;
output::print_json(&resp);
return Ok(());
}
let repo: Repo = client.get(&format!("/{org}/{name}")).await?;
output::header(&format!("{org}/{}", repo.slug.as_deref().unwrap_or(&name)));
src/output.rs +64 −0
@@ -1,5 +1,32 @@
use colored::Colorize;
use std::sync::atomic::{AtomicBool, Ordering};
/// Output format: human-readable (default) vs. JSON for scripting.
/// Set once at program start by main.rs based on the top-level --json flag.
static JSON_MODE: AtomicBool = AtomicBool::new(false);
/// Switch all subsequent renderers into JSON mode. Idempotent.
pub fn set_json_mode(enabled: bool) {
JSON_MODE.store(enabled, Ordering::Relaxed);
}
/// Whether the CLI was invoked with --json. List/view subcommands check
/// this and emit raw JSON instead of a table.
pub fn is_json() -> bool {
JSON_MODE.load(Ordering::Relaxed)
}
/// Pretty-print a serde_json::Value to stdout. Used by list/view
/// subcommands when `--json` is in effect.
pub fn print_json(value: &serde_json::Value) {
match serde_json::to_string_pretty(value) {
Ok(s) => println!("{s}"),
// to_string_pretty only fails on non-string map keys; serde_json::Value
// can't construct those, so this is unreachable in practice.
Err(_) => println!("{value}"),
}
}
/// Print a key-value detail line.
pub fn detail(key: &str, value: &str) {
println!("{:>14} {}", key.bold(), value);
@@ -87,5 +114,42 @@
})
.collect();
println!(" {}", line.join(" "));
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Note: JSON_MODE is process-global, so these tests run serially via
/// `set_json_mode(false)` cleanup in each test. They share the same
/// process under `cargo test`, so explicit reset prevents flakes.
#[test]
fn json_mode_off_by_default() {
// Reset first in case another test left it on.
set_json_mode(false);
assert!(!is_json());
}
#[test]
fn set_json_mode_round_trip() {
set_json_mode(true);
assert!(is_json());
set_json_mode(false);
assert!(!is_json());
}
#[test]
fn print_json_pretty_prints() {
// We can't easily capture stdout from a library test without
// restructuring print_json to take a Writer. Instead, smoke-test
// that it doesn't panic on common shapes — the actual output
// format is verified end-to-end via the CLI smoke tests.
let v = serde_json::json!({"foo": "bar", "n": 42, "arr": [1, 2, 3]});
print_json(&v);
let v_array = serde_json::json!([1, 2, 3]);
print_json(&v_array);
let v_null = serde_json::Value::Null;
print_json(&v_null);
}
}