@@ -1,7 +1,7 @@
use crate::client::Client;
use crate::config;
use crate::output;
use clap::{Args, Subcommand};
use clap::{Args, Subcommand, ValueEnum};
use serde::Deserialize;
#[derive(Args)]
@@ -135,8 +135,62 @@
#[arg(long)]
repo: Option<String>,
},
/// List cross-reference links for an issue
Links {
/// Issue number
number: u32,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Create a cross-reference link from an issue
Link {
/// Issue number (the source)
number: u32,
/// Relationship kind
#[arg(long, value_enum)]
kind: LinkKind,
/// Target issue: `#N` for same-repo, `org/repo#N` for cross-repo
#[arg(long)]
target: String,
/// Repository (org/repo) — the source repo
#[arg(long)]
repo: Option<String>,
},
/// Remove a cross-reference link from an issue
Unlink {
/// Issue number
number: u32,
/// Link ID (from `anvil issue links`)
link_id: String,
/// Repository (org/repo)
#[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>,
@@ -202,5 +256,17 @@
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,
}
}
@@ -558,4 +624,275 @@
.await?;
output::success(&format!("Unassigned user from issue #{number}"));
Ok(())
}
// ── Links ────────────────────────────────────────────────────────────────
#[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");
}
}