@@ -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");
}
}