ref:2ba69e2e42e9d2cce629b73381189a65a9e69c5d

fix(cli): epic add-child sends structured target (was 404) (#329 G)

`anvil epic add-child` POSTed {"kind":"parent_of","target": child} to the issue-links endpoint, but IssueLinkController reads target_number (+ optional target_org/target_repo) and never `target` — so coerce_number(nil) failed and every add-child 404'd. (issue link already sends the structured shape; epic add-child diverged.) Extract add_child_payload/1 that normalizes the child ref (bare "5", same-repo "#5", or cross-repo "org/repo#5") via issue::parse_target_ref and emits target_number [+ target_org/target_repo], matching create_link. Make parse_target_ref/TargetRef pub(crate) to share the one parser. Unit tests cover all three ref forms, garbage rejection, and a regression guard that the raw `target` key is never sent. Part of epic #329 (Workstream G). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SHA: 2ba69e2e42e9d2cce629b73381189a65a9e69c5d
Author: CI <ci@anvil.test>
Date: 2026-07-07 00:46
Parents: 1bd4dc5
2 files changed +68 -6
Type
src/commands/epic.rs +63 −1
@@ -301,6 +301,31 @@
Ok(())
}
/// Build the issue-link payload for `add-child`. The child may be a bare
/// number ("5"), a same-repo ref ("#5"), or a cross-repo ref ("org/repo#5").
/// The links endpoint reads `target_number` (+ optional `target_org`/
/// `target_repo`), NOT a raw `target` string — sending `target` made the
/// server drop it and 404 the request.
fn add_child_payload(child: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
// parse_target_ref requires a '#'; a bare number is a same-repo ref.
let normalized = if child.contains('#') {
child.trim().to_string()
} else {
format!("#{}", child.trim())
};
let parsed = crate::commands::issue::parse_target_ref(&normalized)?;
let mut payload = serde_json::json!({
"kind": "parent_of",
"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());
}
Ok(payload)
}
async fn add_child(
repo: Option<&str>,
epic_number: u32,
@@ -313,6 +338,6 @@
let _resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/issues/{epic_number}/links"),
&serde_json::json!({"kind": "parent_of", "target": child}),
&add_child_payload(child)?,
)
.await?;
@@ -504,5 +529,42 @@
repository: None,
};
assert_eq!(short_ref(&t), "#7");
}
#[test]
fn add_child_payload_bare_number_is_same_repo() {
let p = add_child_payload("5").unwrap();
assert_eq!(p["kind"], "parent_of");
assert_eq!(p["target_number"], 5);
assert!(p.get("target_org").is_none());
assert!(p.get("target_repo").is_none());
}
#[test]
fn add_child_payload_hash_ref_is_same_repo() {
let p = add_child_payload("#12").unwrap();
assert_eq!(p["target_number"], 12);
assert!(p.get("target_org").is_none());
}
#[test]
fn add_child_payload_cross_repo_ref_carries_org_and_repo() {
let p = add_child_payload("fangorn/anvil#9").unwrap();
assert_eq!(p["target_number"], 9);
assert_eq!(p["target_org"], "fangorn");
assert_eq!(p["target_repo"], "anvil");
}
#[test]
fn add_child_payload_rejects_garbage() {
assert!(add_child_payload("not-a-ref").is_err());
}
#[test]
fn add_child_payload_never_sends_raw_target() {
// Regression: the old code sent {"target": child}, which the links
// controller ignores (it reads target_number) → 404.
let p = add_child_payload("5").unwrap();
assert!(p.get("target").is_none());
}
}
src/commands/issue.rs +5 −5
@@ -664,13 +664,13 @@
links: LinkGroups,
}
struct TargetRef {
org: Option<String>,
repo: Option<String>,
number: u32,
pub(crate) struct TargetRef {
pub(crate) org: Option<String>,
pub(crate) repo: Option<String>,
pub(crate) number: u32,
}
fn parse_target_ref(input: &str) -> Result<TargetRef, Box<dyn std::error::Error>> {
pub(crate) fn parse_target_ref(input: &str) -> Result<TargetRef, Box<dyn std::error::Error>> {
let trimmed = input.trim();
let (prefix, num_str) = trimmed
.split_once('#')