ref:3e4fd4fb6dc52e7881bbbee2a74257708d3a5ff7

feat(cli): issue link-req / unlink-req — manual requirement links (#329 C)

CLI half of the manual requirement↔issue link surface: `anvil issue link-req <N> <REQ-…|STD-…>` POSTs the new /issues/:number/requirement-links endpoint (server resolves REQ-* against the repo, STD-* against the org; manual links survive #264 rescans), and `unlink-req` DELETEs the link regardless of source. Both honor --json with the standard {"ok":true,…} envelope. Integration tests (real binary vs wiremock) for both envelopes. Part of epic fangorn/anvil#329 (Workstream C). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SHA: 3e4fd4fb6dc52e7881bbbee2a74257708d3a5ff7
Author: CI <ci@anvil.test>
Date: 2026-07-07 03:27
Parents: 2ba69e2
2 files changed +142 -0
Type
src/commands/issue.rs +76 −0
@@ -176,6 +176,28 @@
#[arg(long)]
repo: Option<String>,
},
/// Manually link a requirement (REQ-*) or standard (STD-*) to an issue
#[command(name = "link-req")]
LinkReq {
/// Issue number
number: u32,
/// Requirement ID (REQ-…) or Standard ID (STD-…)
requirement_id: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Remove a requirement/standard link from an issue (any source)
#[command(name = "unlink-req")]
UnlinkReq {
/// Issue number
number: u32,
/// Requirement ID (REQ-…) or Standard ID (STD-…)
requirement_id: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
}
#[derive(Debug, Clone, Copy, ValueEnum)]
@@ -284,5 +306,15 @@
link_id,
repo,
} => remove_link(repo.as_deref(), number, &link_id).await,
IssueCommand::LinkReq {
number,
requirement_id,
repo,
} => link_requirement(repo.as_deref(), number, &requirement_id).await,
IssueCommand::UnlinkReq {
number,
requirement_id,
repo,
} => unlink_requirement(repo.as_deref(), number, &requirement_id).await,
}
}
@@ -835,5 +867,49 @@
output::success(&format!(
"Removed link {link_id} from {org}/{name}#{number}"
));
Ok(())
}
async fn link_requirement(
repo: Option<&str>,
number: u32,
requirement_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}/requirement-links"),
&serde_json::json!({ "requirement_id": requirement_id }),
)
.await?;
if output::is_json() {
output::json_ok("linked", resp["linked"].clone());
return Ok(());
}
output::success(&format!("Linked {requirement_id} to issue #{number}"));
Ok(())
}
async fn unlink_requirement(
repo: Option<&str>,
number: u32,
requirement_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}/requirement-links/{requirement_id}"
))
.await?;
if output::is_json() {
output::json_ok(
"unlinked",
serde_json::json!({ "issue": number, "requirement_id": requirement_id }),
);
return Ok(());
}
output::success(&format!("Unlinked {requirement_id} from issue #{number}"));
Ok(())
}
tests/json_output.rs +66 −0
@@ -526,3 +526,69 @@
assert_eq!(v["unlinked"]["test"], "user can log in");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn issue_link_req_emits_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(
"/api/v1/test-org/test-repo/issues/7/requirement-links",
))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"linked": {"requirement_id": "REQ-A-001", "title": "Auth", "kind": "requirement"}
})))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"issue",
"link-req",
"7",
"REQ-A-001",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["linked"]["requirement_id"], "REQ-A-001");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn issue_unlink_req_emits_ok_envelope() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path(
"/api/v1/test-org/test-repo/issues/7/requirement-links/STD-B-002",
))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let out = run_json(
&server.uri(),
&[
"issue",
"unlink-req",
"7",
"STD-B-002",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["ok"], true);
assert_eq!(v["unlinked"]["requirement_id"], "STD-B-002");
}