ref:94b2339ca9b03e0cc0d8e810565930afd6294bbe

feat(epic): add anvil epic subcommand + --epic / --parent on issue create

issue create: - --epic flag — sends kind=epic in the API payload - --parent <ref> flag — sends parent in the API payload; server atomically links via parent_of and auto-marks the parent as :epic - --epic and --parent are mutually exclusive (clap conflicts_with) - Issue struct deserializes "kind"; view shows it when non-standard New `anvil epic` subcommand: - list — kind:epic filter; per-page limit; empty-state hint - view — header, state, children table, progress percentage - children — bare children table - add-child / remove-child — wraps the existing /links endpoints with kind=parent_of; remove-child resolves the link id by walking the children list and matching the ref - mark / mark --unmark — promotes / demotes via PATCH on the issue with the kind field Cross-repo refs (`org/repo#5`) work everywhere, plus bare numbers ("5") and same-repo refs ("#5"). 4 unit tests for the ref-matching helpers. Total tests: 22/22 passing. cargo clippy clean. Server-side support landed in fangorn/anvil ce8b969 (kind/parent on create + serialize kind on responses). Part of fangorn/anvil#118 — child #112 (CLI).
SHA: 94b2339ca9b03e0cc0d8e810565930afd6294bbe
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-05-01 04:42
Parents: 4ace024
3 files changed +493 -6
Type
src/commands/epic.rs +448 −0
@@ -1,0 +1,448 @@
use crate::client::Client;
use crate::config;
use crate::output;
use clap::{Args, Subcommand};
use serde::Deserialize;
/// Epic operations. Epics are issues with `kind: "epic"` that group
/// related sub-issues via `parent_of` links. The CLI commands here are
/// thin wrappers over the same issue endpoints used elsewhere.
#[derive(Args)]
pub struct EpicArgs {
#[command(subcommand)]
pub command: EpicCommand,
}
#[derive(Subcommand)]
pub enum EpicCommand {
/// List epics in the repository (issues with kind: epic).
List {
/// Repository (org/repo)
repo: Option<String>,
/// Filter by state: open, closed, all
#[arg(long, default_value = "open")]
state: String,
/// Maximum results
#[arg(long, default_value = "50")]
limit: u32,
},
/// View an epic with its progress and children list.
View {
/// Epic issue number
number: u32,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Print just the children list (table form).
Children {
/// Epic issue number
number: u32,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Add a child issue to an epic.
#[command(name = "add-child")]
AddChild {
/// Epic issue number
epic: u32,
/// Child issue ref ("5", "#5", or "org/repo#5")
child: String,
/// Repository (org/repo) — the epic's repo
#[arg(long)]
repo: Option<String>,
},
/// Remove a child link from an epic.
#[command(name = "remove-child")]
RemoveChild {
/// Epic issue number
epic: u32,
/// Child issue ref ("5", "#5", or "org/repo#5")
child: String,
/// Repository (org/repo) — the epic's repo
#[arg(long)]
repo: Option<String>,
},
/// Mark an existing issue as an epic (or revert with --unmark).
Mark {
/// Issue number
number: u32,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
/// Demote back to a standard issue.
#[arg(long)]
unmark: bool,
},
}
#[derive(Debug, Deserialize)]
struct EpicSummary {
number: Option<u32>,
title: Option<String>,
state: Option<String>,
kind: Option<String>,
}
#[derive(Debug, Deserialize)]
struct LinkEntry {
id: Option<String>,
kind: Option<String>,
target_issue: Option<TargetIssue>,
}
#[derive(Debug, Deserialize)]
struct TargetIssue {
number: Option<u32>,
title: Option<String>,
state: Option<String>,
repository: Option<RepoRef>,
}
#[derive(Debug, Deserialize)]
struct RepoRef {
org_slug: Option<String>,
slug: Option<String>,
}
pub async fn run(args: EpicArgs) -> Result<(), Box<dyn std::error::Error>> {
match args.command {
EpicCommand::List { repo, state, limit } => list(repo.as_deref(), &state, limit).await,
EpicCommand::View { number, repo } => view(repo.as_deref(), number).await,
EpicCommand::Children { number, repo } => children(repo.as_deref(), number).await,
EpicCommand::AddChild { epic, child, repo } => {
add_child(repo.as_deref(), epic, &child).await
}
EpicCommand::RemoveChild { epic, child, repo } => {
remove_child(repo.as_deref(), epic, &child).await
}
EpicCommand::Mark {
number,
repo,
unmark,
} => mark(repo.as_deref(), number, unmark).await,
}
}
async fn list(
repo: Option<&str>,
state: &str,
limit: u32,
) -> 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_with_query(
&format!("/{org}/{name}/issues"),
&[
("state", state),
("kind", "epic"),
("per_page", &limit.to_string()),
],
)
.await?;
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") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else {
serde_json::from_value(resp).unwrap_or_default()
};
output::header(&format!("Epics ({org}/{name}) — {state}"));
if issues.is_empty() {
println!("\n No epics yet. Create one with: anvil issue create --epic --title \"...\"");
return Ok(());
}
let rows: Vec<Vec<String>> = issues
.iter()
.filter(|i| i.kind.as_deref() == Some("epic"))
.map(|i| {
vec![
format!("#{}", i.number.unwrap_or(0)),
i.title.clone().unwrap_or_default(),
output::colorize_status(i.state.as_deref().unwrap_or("?")),
]
})
.collect();
output::print_table(&["#", "TITLE", "STATE"], &rows);
Ok(())
}
async fn view(repo: Option<&str>, number: u32) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let issue: serde_json::Value = client
.get(&format!("/{org}/{name}/issues/{number}"))
.await?;
let title = issue
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("(untitled)");
let state = issue.get("state").and_then(|v| v.as_str()).unwrap_or("?");
let kind = issue
.get("kind")
.and_then(|v| v.as_str())
.unwrap_or("standard");
output::header(&format!("Epic #{number} — {title}"));
output::detail("State", &output::colorize_status(state));
if kind != "epic" {
println!(
"\n Note: this issue's kind is \"{kind}\", not \"epic\". \
Run `anvil epic mark {number}` to promote it."
);
}
let children_links = fetch_children(&client, &org, &name, number).await?;
let total = children_links.len();
let closed = children_links
.iter()
.filter(|l| l.target_issue.as_ref().and_then(|i| i.state.as_deref()) == Some("closed"))
.count();
let pct = (100 * closed).checked_div(total).unwrap_or(0);
println!("\n Progress: {closed} of {total} closed ({pct}%)");
if !children_links.is_empty() {
let rows: Vec<Vec<String>> = children_links
.iter()
.filter_map(|l| l.target_issue.as_ref())
.map(|t| {
vec![
short_ref(t),
t.title.clone().unwrap_or_default(),
output::colorize_status(t.state.as_deref().unwrap_or("?")),
]
})
.collect();
println!();
output::print_table(&["REF", "TITLE", "STATE"], &rows);
}
Ok(())
}
async fn children(repo: Option<&str>, number: u32) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let links = fetch_children(&client, &org, &name, number).await?;
if links.is_empty() {
println!("Epic #{number} has no children.");
return Ok(());
}
let rows: Vec<Vec<String>> = links
.iter()
.filter_map(|l| l.target_issue.as_ref())
.map(|t| {
vec![
short_ref(t),
t.title.clone().unwrap_or_default(),
output::colorize_status(t.state.as_deref().unwrap_or("?")),
]
})
.collect();
output::print_table(&["REF", "TITLE", "STATE"], &rows);
Ok(())
}
async fn add_child(
repo: Option<&str>,
epic_number: u32,
child: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
// Reuse the existing issue link endpoint with kind=parent_of.
let _resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/issues/{epic_number}/links"),
&serde_json::json!({"kind": "parent_of", "target": child}),
)
.await?;
output::success(&format!("Added child {child} to epic #{epic_number}"));
Ok(())
}
async fn remove_child(
repo: Option<&str>,
epic_number: u32,
child: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
// Find the parent_of link from this epic to the given child, then delete it.
let links = fetch_children(&client, &org, &name, epic_number).await?;
let target = links.iter().find(|l| {
l.target_issue
.as_ref()
.map(|t| matches_child_ref(t, child))
.unwrap_or(false)
});
let link_id = match target.and_then(|l| l.id.as_deref()) {
Some(id) => id,
None => {
return Err(format!("No parent_of link from epic #{epic_number} to {child}").into());
}
};
client
.delete_empty(&format!(
"/{org}/{name}/issues/{epic_number}/links/{link_id}"
))
.await?;
output::success(&format!("Removed child {child} from epic #{epic_number}"));
Ok(())
}
async fn mark(
repo: Option<&str>,
number: u32,
unmark: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let new_kind = if unmark { "standard" } else { "epic" };
let _resp: serde_json::Value = client
.patch(
&format!("/{org}/{name}/issues/{number}"),
&serde_json::json!({"kind": new_kind}),
)
.await?;
let verb = if unmark { "Demoted" } else { "Marked" };
output::success(&format!("{verb} issue #{number} as {new_kind}"));
Ok(())
}
async fn fetch_children(
client: &Client,
org: &str,
name: &str,
number: u32,
) -> Result<Vec<LinkEntry>, Box<dyn std::error::Error>> {
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/issues/{number}/links"))
.await?;
let raw = resp
.get("links")
.or_else(|| resp.get("data"))
.cloned()
.unwrap_or(resp);
let entries: Vec<LinkEntry> = serde_json::from_value(raw).unwrap_or_default();
Ok(entries
.into_iter()
.filter(|l| l.kind.as_deref() == Some("parent_of"))
.collect())
}
fn short_ref(t: &TargetIssue) -> String {
let n = t.number.unwrap_or(0);
match t.repository.as_ref() {
Some(RepoRef {
org_slug: Some(o),
slug: Some(r),
}) => format!("{o}/{r}#{n}"),
_ => format!("#{n}"),
}
}
fn matches_child_ref(t: &TargetIssue, child_ref: &str) -> bool {
let n = t.number.unwrap_or(0);
let same_repo = format!("#{n}") == child_ref || child_ref == n.to_string();
if same_repo {
return true;
}
if let Some(RepoRef {
org_slug: Some(o),
slug: Some(r),
}) = t.repository.as_ref()
{
format!("{o}/{r}#{n}") == child_ref
} else {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mk_target(org: Option<&str>, repo: Option<&str>, number: u32) -> TargetIssue {
TargetIssue {
number: Some(number),
title: None,
state: None,
repository: Some(RepoRef {
org_slug: org.map(String::from),
slug: repo.map(String::from),
}),
}
}
#[test]
fn matches_bare_number() {
let t = mk_target(Some("org"), Some("repo"), 5);
assert!(matches_child_ref(&t, "5"));
assert!(matches_child_ref(&t, "#5"));
}
#[test]
fn matches_full_cross_repo_ref() {
let t = mk_target(Some("org"), Some("repo"), 5);
assert!(matches_child_ref(&t, "org/repo#5"));
assert!(!matches_child_ref(&t, "other/repo#5"));
assert!(!matches_child_ref(&t, "org/repo#6"));
}
#[test]
fn short_ref_uses_full_when_repo_known() {
let t = mk_target(Some("o"), Some("r"), 7);
assert_eq!(short_ref(&t), "o/r#7");
}
#[test]
fn short_ref_falls_back_to_hash_n_when_repo_unknown() {
let t = TargetIssue {
number: Some(7),
title: None,
state: None,
repository: None,
};
assert_eq!(short_ref(&t), "#7");
}
}
src/commands/issue.rs +41 −6
@@ -42,6 +42,15 @@
/// Issue body/description
#[arg(long, default_value = "")]
body: String,
/// Mark this issue as an epic. Mutually exclusive with --parent.
#[arg(long, conflicts_with = "parent")]
epic: bool,
/// Create as a child of this issue. Accepts a number ("5"),
/// a same-repo ref ("#5"), or a cross-repo ref ("org/repo#5").
/// Atomically links via parent_of and auto-marks the parent
/// as an epic.
#[arg(long)]
parent: Option<String>,
},
/// Close an issue
Close {
@@ -196,6 +205,7 @@
number: Option<u32>,
title: Option<String>,
state: Option<String>,
kind: Option<String>,
body: Option<String>,
inserted_at: Option<String>,
author: Option<IssueAuthor>,
@@ -218,7 +228,13 @@
match args.command {
IssueCommand::List { repo, state, limit } => list(repo.as_deref(), &state, limit).await,
IssueCommand::View { number, repo } => view(repo.as_deref(), number).await,
IssueCommand::Create {
IssueCommand::Create { repo, title, body } => create(repo.as_deref(), &title, &body).await,
repo,
title,
body,
epic,
parent,
} => create(repo.as_deref(), &title, &body, epic, parent.as_deref()).await,
IssueCommand::Close { number, repo } => close_issue(repo.as_deref(), number).await,
IssueCommand::Reopen { number, repo } => reopen_issue(repo.as_deref(), number).await,
IssueCommand::Edit {
@@ -354,6 +370,11 @@
"State",
&output::colorize_status(issue.state.as_deref().unwrap_or("?")),
);
if let Some(ref kind) = issue.kind {
if kind != "standard" {
output::detail("Kind", kind);
}
}
if let Some(ref author) = issue.author {
if let Some(ref name) = author.username {
output::detail("Author", name);
@@ -381,16 +402,25 @@
repo: Option<&str>,
title: &str,
body: &str,
epic: bool,
parent: 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::Map::new();
payload.insert("title".into(), serde_json::json!(title));
payload.insert("body".into(), serde_json::json!(body));
if epic {
payload.insert("kind".into(), serde_json::json!("epic"));
}
if let Some(p) = parent {
payload.insert("parent".into(), serde_json::json!(p));
}
let resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/issues"),
&serde_json::Value::Object(payload),
&serde_json::json!({
"title": title,
"body": body,
}),
)
.await?;
@@ -402,7 +432,12 @@
.and_then(|v| v.as_u64())
.unwrap_or(0);
let kind_label = if epic { "epic " } else { "" };
output::success(&format!("Created {kind_label}issue #{number}: {title}"));
output::success(&format!("Created issue #{number}: {title}"));
if let Some(p) = parent {
output::detail("Linked as child of", p);
}
let config = crate::config::Config::load()?;
if let Some(ref url) = config.server_url {
src/commands/mod.rs +4 −0
@@ -5,6 +5,7 @@
pub mod ci;
pub mod commit;
pub mod deploy;
pub mod epic;
pub mod issue;
pub mod label;
pub mod milestone;
@@ -38,6 +39,8 @@
Pr(pr::PrArgs),
/// Issue operations
Issue(issue::IssueArgs),
/// Epic operations (epics group related issues)
Epic(epic::EpicArgs),
/// CI pipeline operations
Ci(ci::CiArgs),
/// Commit operations
@@ -71,6 +74,7 @@
Command::Repo(args) => repo::run(args).await,
Command::Pr(args) => pr::run(args).await,
Command::Issue(args) => issue::run(args).await,
Command::Epic(args) => epic::run(args).await,
Command::Ci(args) => ci::run(args).await,
Command::Commit(args) => commit::run(args).await,
Command::Branch(args) => branch::run(args).await,