use crate::client::Client;
use crate::config;
use crate::output;
use clap::{Args, Subcommand, ValueEnum};
use serde::Deserialize;
#[derive(Args)]
pub struct IssueArgs {
#[command(subcommand)]
pub command: IssueCommand,
}
#[derive(Subcommand)]
pub enum IssueCommand {
List {
repo: Option<String>,
#[arg(long, default_value = "open")]
state: String,
#[arg(long, default_value = "30")]
limit: u32,
},
View {
number: u32,
#[arg(long)]
repo: Option<String>,
},
Create {
#[arg(long)]
repo: Option<String>,
#[arg(long)]
title: String,
#[arg(long, default_value = "")]
body: String,
#[arg(long, conflicts_with = "parent")]
epic: bool,
#[arg(long)]
parent: Option<String>,
},
Close {
number: u32,
#[arg(long)]
repo: Option<String>,
},
Reopen {
number: u32,
#[arg(long)]
repo: Option<String>,
},
Edit {
number: u32,
#[arg(long)]
repo: Option<String>,
#[arg(long)]
title: Option<String>,
#[arg(long)]
body: Option<String>,
},
Comment {
number: u32,
#[arg(long)]
body: String,
#[arg(long)]
repo: Option<String>,
},
Comments {
number: u32,
#[arg(long)]
repo: Option<String>,
},
Milestones {
repo: Option<String>,
},
#[command(name = "create-milestone")]
CreateMilestone {
#[arg(long)]
title: String,
#[arg(long)]
description: Option<String>,
#[arg(long)]
due_date: Option<String>,
#[arg(long)]
repo: Option<String>,
},
Assign {
number: u32,
#[arg(long)]
user_id: String,
#[arg(long)]
repo: Option<String>,
},
Unassign {
number: u32,
#[arg(long)]
user_id: String,
#[arg(long)]
repo: Option<String>,
},
Links {
number: u32,
#[arg(long)]
repo: Option<String>,
},
Link {
number: u32,
#[arg(long, value_enum)]
kind: LinkKind,
#[arg(long)]
target: String,
#[arg(long)]
repo: Option<String>,
},
Unlink {
number: u32,
link_id: String,
#[arg(long)]
repo: Option<String>,
},
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum LinkKind {
Blocks,
#[value(name = "parent-of")]
ParentOf,
#[value(name = "duplicate-of")]
DuplicateOf,
#[value(name = "relates-to")]
RelatesTo,
}
impl LinkKind {
fn api_value(self) -> &'static str {
match self {
LinkKind::Blocks => "blocks",
LinkKind::ParentOf => "parent_of",
LinkKind::DuplicateOf => "duplicate_of",
LinkKind::RelatesTo => "relates_to",
}
}
}
#[derive(Debug, Deserialize)]
struct Issue {
number: Option<u32>,
title: Option<String>,
state: Option<String>,
kind: Option<String>,
body: Option<String>,
inserted_at: Option<String>,
author: Option<IssueAuthor>,
labels: Option<Vec<Label>>,
}
#[derive(Debug, Deserialize)]
struct IssueAuthor {
username: Option<String>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct Label {
name: Option<String>,
color: Option<String>,
}
pub async fn run(args: IssueArgs) -> Result<(), Box<dyn std::error::Error>> {
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 {
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 {
number,
repo,
title,
body,
} => edit_issue(repo.as_deref(), number, title.as_deref(), body.as_deref()).await,
IssueCommand::Comment { number, body, repo } => {
add_comment(repo.as_deref(), number, &body).await
}
IssueCommand::Comments { number, repo } => list_comments(repo.as_deref(), number).await,
IssueCommand::Milestones { repo } => list_milestones(repo.as_deref()).await,
IssueCommand::CreateMilestone {
title,
description,
due_date,
repo,
} => {
create_milestone(
repo.as_deref(),
&title,
description.as_deref(),
due_date.as_deref(),
)
.await
}
IssueCommand::Assign {
number,
user_id,
repo,
} => assign(repo.as_deref(), number, &user_id).await,
IssueCommand::Unassign {
number,
user_id,
repo,
} => unassign(repo.as_deref(), number, &user_id).await,
IssueCommand::Links { number, repo } => list_links(repo.as_deref(), number).await,
IssueCommand::Link {
number,
kind,
target,
repo,
} => create_link(repo.as_deref(), number, kind, &target).await,
IssueCommand::Unlink {
number,
link_id,
repo,
} => remove_link(repo.as_deref(), number, &link_id).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), ("per_page", &limit.to_string())],
)
.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") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else {
serde_json::from_value(resp).unwrap_or_default()
};
output::header(&format!("Issues ({org}/{name}) — {state}"));
let rows: Vec<Vec<String>> = issues
.iter()
.map(|i| {
let labels = i
.labels
.as_ref()
.map(|ls| {
ls.iter()
.filter_map(|l| l.name.clone())
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
vec![
format!("#{}", i.number.unwrap_or(0)),
i.title.clone().unwrap_or_default(),
output::colorize_status(i.state.as_deref().unwrap_or("?")),
i.author
.as_ref()
.and_then(|a| a.username.clone())
.unwrap_or_default(),
labels,
]
})
.collect();
output::print_table(&["#", "TITLE", "STATE", "AUTHOR", "LABELS"], &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 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())?
} else if let Some(obj) = resp.get("data") {
serde_json::from_value(obj.clone())?
} else {
serde_json::from_value(resp)?
};
output::header(&format!(
"Issue #{} — {}",
issue.number.unwrap_or(number),
issue.title.as_deref().unwrap_or("(untitled)")
));
output::detail(
"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);
}
}
if let Some(ref labels) = issue.labels {
let label_str: Vec<String> = labels.iter().filter_map(|l| l.name.clone()).collect();
if !label_str.is_empty() {
output::detail("Labels", &label_str.join(", "));
}
}
if let Some(ref ts) = issue.inserted_at {
output::detail("Created", &output::format_time(ts));
}
if let Some(ref body) = issue.body {
if !body.is_empty() {
println!("\n{body}");
}
}
Ok(())
}
async fn create(
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),
)
.await?;
let number = resp
.pointer("/issue/number")
.or_else(|| resp.pointer("/data/number"))
.or_else(|| resp.get("number"))
.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}"));
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 {
println!(
"\n {}",
format!("{url}/{org}/{name}/issues/{number}").as_str()
);
}
Ok(())
}
async fn close_issue(repo: Option<&str>, number: 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
.patch(
&format!("/{org}/{name}/issues/{number}"),
&serde_json::json!({"state": "closed"}),
)
.await?;
output::success(&format!("Closed issue #{number}"));
Ok(())
}
async fn reopen_issue(repo: Option<&str>, number: 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
.patch(
&format!("/{org}/{name}/issues/{number}"),
&serde_json::json!({"state": "open"}),
)
.await?;
output::success(&format!("Reopened issue #{number}"));
Ok(())
}
async fn edit_issue(
repo: Option<&str>,
number: u32,
title: Option<&str>,
body: 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();
if let Some(t) = title {
payload.insert("title".into(), serde_json::json!(t));
}
if let Some(b) = body {
payload.insert("body".into(), serde_json::json!(b));
}
if payload.is_empty() {
output::warn("Nothing to update — specify --title or --body");
return Ok(());
}
let _resp: serde_json::Value = client
.patch(
&format!("/{org}/{name}/issues/{number}"),
&serde_json::Value::Object(payload),
)
.await?;
output::success(&format!("Updated issue #{number}"));
Ok(())
}
async fn add_comment(
repo: Option<&str>,
number: u32,
body: &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
.post(
&format!("/{org}/{name}/issues/{number}/comments"),
&serde_json::json!({"body": body}),
)
.await?;
output::success(&format!("Added comment to issue #{number}"));
Ok(())
}
async fn list_comments(repo: Option<&str>, number: 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(&format!("/{org}/{name}/issues/{number}/comments"))
.await?;
let comments: Vec<serde_json::Value> = resp
.get("comments")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
output::header(&format!("Comments on issue #{number}"));
if comments.is_empty() {
println!(" (no comments)");
return Ok(());
}
for comment in &comments {
let author = comment
.pointer("/author/email")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let time = comment
.get("inserted_at")
.and_then(|v| v.as_str())
.map(output::format_time)
.unwrap_or_default();
let body = comment.get("body").and_then(|v| v.as_str()).unwrap_or("");
println!("\n {} — {}", author, time);
println!(" {body}");
}
Ok(())
}
async fn list_milestones(repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
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(
repo: Option<&str>,
title: &str,
description: Option<&str>,
due_date: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
output::warn(
"`anvil issue create-milestone` is deprecated; use `anvil milestone create` instead.",
);
super::milestone::create(repo, title, description.unwrap_or(""), due_date).await
}
async fn assign(
repo: Option<&str>,
number: u32,
user_id: &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
.post(
&format!("/{org}/{name}/issues/{number}/assignees"),
&serde_json::json!({"user_id": user_id}),
)
.await?;
output::success(&format!("Assigned user to issue #{number}"));
Ok(())
}
async fn unassign(
repo: Option<&str>,
number: u32,
user_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
client
.delete_empty(&format!(
"/{org}/{name}/issues/{number}/assignees/{user_id}"
))
.await?;
output::success(&format!("Unassigned user from issue #{number}"));
Ok(())
}
#[derive(Debug, Deserialize)]
struct LinkIssueRef {
org: Option<String>,
repo: Option<String>,
number: Option<u32>,
title: Option<String>,
state: Option<String>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct LinkEntry {
link_id: Option<String>,
kind: Option<String>,
issue: Option<LinkIssueRef>,
#[serde(default)]
redacted: bool,
}
#[derive(Debug, Deserialize)]
struct LinkGroups {
blocks: Vec<LinkEntry>,
blocked_by: Vec<LinkEntry>,
parent: Option<LinkEntry>,
children: Vec<LinkEntry>,
duplicate_of: Option<LinkEntry>,
duplicates: Vec<LinkEntry>,
related: Vec<LinkEntry>,
}
#[derive(Debug, Deserialize)]
struct LinksResponse {
links: LinkGroups,
}
struct TargetRef {
org: Option<String>,
repo: Option<String>,
number: u32,
}
fn parse_target_ref(input: &str) -> Result<TargetRef, Box<dyn std::error::Error>> {
let trimmed = input.trim();
let (prefix, num_str) = trimmed
.split_once('#')
.ok_or_else(|| format!("invalid --target '{input}': expected '#N' or 'org/repo#N'"))?;
let number: u32 = num_str
.parse()
.map_err(|_| format!("invalid --target '{input}': number must be a positive integer"))?;
if number == 0 {
return Err(format!("invalid --target '{input}': issue number must be > 0").into());
}
if prefix.is_empty() {
return Ok(TargetRef {
org: None,
repo: None,
number,
});
}
let (org, repo) = prefix.split_once('/').ok_or_else(|| {
format!("invalid --target '{input}': expected 'org/repo#N' when a prefix is given")
})?;
if org.is_empty() || repo.is_empty() {
return Err(format!("invalid --target '{input}': org and repo must be non-empty").into());
}
Ok(TargetRef {
org: Some(org.to_string()),
repo: Some(repo.to_string()),
number,
})
}
async fn list_links(repo: Option<&str>, number: u32) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: LinksResponse = client
.get(&format!("/{org}/{name}/issues/{number}/links"))
.await?;
output::header(&format!("Links on {org}/{name}#{number}"));
let groups: [(&str, Vec<&LinkEntry>); 7] = [
("Blocks", resp.links.blocks.iter().collect()),
("Blocked by", resp.links.blocked_by.iter().collect()),
("Parent", resp.links.parent.iter().collect::<Vec<_>>()),
("Children", resp.links.children.iter().collect()),
(
"Duplicate of",
resp.links.duplicate_of.iter().collect::<Vec<_>>(),
),
("Duplicates", resp.links.duplicates.iter().collect()),
("Related", resp.links.related.iter().collect()),
];
let mut any_printed = false;
for (label, entries) in &groups {
if entries.is_empty() {
continue;
}
any_printed = true;
println!("\n {label}");
let rows: Vec<Vec<String>> = entries.iter().map(|e| render_link_row(e)).collect();
output::print_table(&["LINK ID", "TARGET", "STATE", "TITLE"], &rows);
}
if !any_printed {
println!("\n (no links)");
}
Ok(())
}
fn render_link_row(entry: &LinkEntry) -> Vec<String> {
let link_id = entry.link_id.clone().unwrap_or_else(|| "?".to_string());
if entry.redacted {
return vec![
link_id,
"(private)".to_string(),
"?".to_string(),
String::new(),
];
}
let issue = entry.issue.as_ref();
let target = issue
.map(|i| {
format!(
"{}/{}#{}",
i.org.as_deref().unwrap_or("?"),
i.repo.as_deref().unwrap_or("?"),
i.number.unwrap_or(0),
)
})
.unwrap_or_else(|| "?".to_string());
let state = issue
.and_then(|i| i.state.as_deref())
.map(output::colorize_status)
.unwrap_or_default();
let title = issue.and_then(|i| i.title.clone()).unwrap_or_default();
vec![link_id, target, state, title]
}
async fn create_link(
repo: Option<&str>,
number: u32,
kind: LinkKind,
target: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let parsed = parse_target_ref(target)?;
let mut payload = serde_json::json!({
"kind": kind.api_value(),
"target_number": parsed.number,
});
if let (Some(o), Some(r)) = (parsed.org.as_ref(), parsed.repo.as_ref()) {
payload["target_org"] = serde_json::Value::String(o.clone());
payload["target_repo"] = serde_json::Value::String(r.clone());
}
let resp: serde_json::Value = client
.post(&format!("/{org}/{name}/issues/{number}/links"), &payload)
.await?;
let link_id = resp
.get("link_id")
.and_then(|v| v.as_str())
.unwrap_or("(unknown)");
let target_display = resp
.pointer("/issue")
.and_then(|i| {
let o = i.get("org")?.as_str()?;
let r = i.get("repo")?.as_str()?;
let n = i.get("number")?.as_u64()?;
Some(format!("{o}/{r}#{n}"))
})
.unwrap_or_else(|| target.to_string());
output::success(&format!(
"Linked {org}/{name}#{number} → {target_display} ({})",
kind.api_value()
));
output::detail("Link ID", link_id);
Ok(())
}
async fn remove_link(
repo: Option<&str>,
number: u32,
link_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
client
.delete_empty(&format!("/{org}/{name}/issues/{number}/links/{link_id}"))
.await?;
output::success(&format!(
"Removed link {link_id} from {org}/{name}#{number}"
));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_target_ref_same_repo() {
let r = parse_target_ref("#42").unwrap();
assert_eq!(r.number, 42);
assert!(r.org.is_none());
assert!(r.repo.is_none());
}
#[test]
fn parse_target_ref_cross_repo() {
let r = parse_target_ref("fangorn/anvil#7").unwrap();
assert_eq!(r.number, 7);
assert_eq!(r.org.as_deref(), Some("fangorn"));
assert_eq!(r.repo.as_deref(), Some("anvil"));
}
#[test]
fn parse_target_ref_whitespace_tolerated() {
let r = parse_target_ref(" org/repo#1 ").unwrap();
assert_eq!(r.org.as_deref(), Some("org"));
assert_eq!(r.repo.as_deref(), Some("repo"));
assert_eq!(r.number, 1);
}
#[test]
fn parse_target_ref_rejects_missing_hash() {
assert!(parse_target_ref("fangorn/anvil").is_err());
}
#[test]
fn parse_target_ref_rejects_bad_number() {
assert!(parse_target_ref("#abc").is_err());
assert!(parse_target_ref("#0").is_err());
}
#[test]
fn parse_target_ref_rejects_prefix_without_slash() {
assert!(parse_target_ref("justorg#5").is_err());
}
#[test]
fn parse_target_ref_rejects_empty_parts() {
assert!(parse_target_ref("/#5").is_err());
assert!(parse_target_ref("org/#5").is_err());
assert!(parse_target_ref("/repo#5").is_err());
}
#[test]
fn link_kind_api_values_match_server() {
assert_eq!(LinkKind::Blocks.api_value(), "blocks");
assert_eq!(LinkKind::ParentOf.api_value(), "parent_of");
assert_eq!(LinkKind::DuplicateOf.api_value(), "duplicate_of");
assert_eq!(LinkKind::RelatesTo.api_value(), "relates_to");
}
}