ref:061c436e32d64b842de789924619dd13acac09f4

feat(cli): requirement import / export (#329 B / #239)

Completes #239's CLI surface: - `anvil requirement import <file|-> [--format yaml|json] [--dry-run]` posts the new /requirements/import endpoint; dry-run prints the create/update/unchanged/errors summary (or the raw preview with --json), otherwise the applied counts. - `anvil requirement export` prints the repo's requirements in the import schema (parent linkage emitted as the parent's requirement_id), so export → import round-trips including into another repo. Descriptions aren't exported (list endpoint doesn't carry them; import is partial-update shaped so their absence never clobbers). Integration test: import --dry-run from a file round-trips the preview. Part of epic fangorn/anvil#329 (Workstream B). Completes #239's link/unlink/import/export set together with the earlier link/unlink commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SHA: 061c436e32d64b842de789924619dd13acac09f4
Author: CI <ci@anvil.test>
Date: 2026-07-07 04:14
Parents: b3e9c9d
2 files changed +176 -0
Type
src/commands/requirement.rs +135 −0
@@ -161,6 +161,26 @@
#[arg(long)]
clear: bool,
},
/// Bulk-import requirements from a YAML/JSON file (or stdin with "-")
Import {
/// Path to the file ("-" for stdin)
file: String,
/// Input format: yaml (default) | json
#[arg(long, default_value = "yaml")]
format: String,
/// Preview the diff without writing
#[arg(long)]
dry_run: bool,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Export the repo's requirements as import-shaped YAML (round-trips with `import`)
Export {
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
}
#[derive(Args)]
@@ -436,5 +456,12 @@
}
RequirementCommand::Status(args) => status(args).await,
RequirementCommand::Seed { repo, clear } => seed(repo.as_deref(), clear).await,
RequirementCommand::Import {
file,
format,
dry_run,
repo,
} => import_requirements(repo.as_deref(), &file, &format, dry_run).await,
RequirementCommand::Export { repo } => export_requirements(repo.as_deref()).await,
}
}
@@ -2198,6 +2225,114 @@
],
},
]
}
async fn import_requirements(
repo: Option<&str>,
file: &str,
format: &str,
dry_run: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let text = if file == "-" {
use std::io::Read;
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
buf
} else {
std::fs::read_to_string(file)?
};
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/requirements/import"),
&serde_json::json!({"format": format, "text": text, "dry_run": dry_run}),
)
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
if dry_run {
let p = &resp["preview"];
let count = |k: &str| p[k].as_array().map(|a| a.len()).unwrap_or(0);
output::header("Import preview (dry run)");
println!(
" {} create · {} update · {} unchanged · {} error(s)",
count("create"),
count("update"),
count("unchanged"),
count("errors")
);
for e in p["errors"].as_array().unwrap_or(&vec![]) {
output::warn(&format!(
"{}: {}",
e["requirement_id"].as_str().unwrap_or("?"),
e["reason"].as_str().unwrap_or("?")
));
}
} else {
let a = &resp["applied"];
output::success(&format!(
"Imported: {} created, {} updated, {} unchanged",
a["created"], a["updated"], a["unchanged"]
));
}
Ok(())
}
/// Fetch the repo's requirements and print them in the import schema, so
/// `export > f.yml` then `import f.yml` round-trips (including into another
/// repo). Parent linkage is emitted as the parent's requirement_id.
/// Descriptions are not exported — the list endpoint doesn't carry them, and
/// import is partial-update shaped so their absence never clobbers anything.
async fn export_requirements(repo: Option<&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.get(&format!("/{org}/{name}/requirements")).await?;
let reqs = resp["requirements"].as_array().cloned().unwrap_or_default();
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
// Map UUID -> requirement_id so `parent` round-trips through import.
let mut id_to_req_id = std::collections::HashMap::new();
for r in &reqs {
if let (Some(uuid), Some(rid)) = (r["id"].as_str(), r["requirement_id"].as_str()) {
id_to_req_id.insert(uuid.to_string(), rid.to_string());
}
}
for r in &reqs {
println!(
"- requirement_id: {}",
yaml_str(r["requirement_id"].as_str().unwrap_or("?"))
);
if let Some(t) = r["title"].as_str() {
println!(" title: {}", yaml_str(t));
}
for key in ["category", "priority", "status"] {
if let Some(v) = r[key].as_str() {
println!(" {key}: {}", yaml_str(v));
}
}
if let Some(parent_uuid) = r["parent_id"].as_str() {
if let Some(parent_rid) = id_to_req_id.get(parent_uuid) {
println!(" parent: {}", yaml_str(parent_rid));
}
}
}
Ok(())
}
/// Quote a YAML scalar defensively (double-quoted style with escapes).
fn yaml_str(s: &str) -> String {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}
#[cfg(test)]
tests/json_output.rs +41 −0
@@ -789,3 +789,44 @@
assert_eq!(stdout_json(&mv)["ok"], true);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn requirement_import_dry_run_round_trips() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/test-org/test-repo/requirements/import"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"dry_run": true,
"preview": {
"create": [{"requirement_id": "REQ-A-001", "title": "A"}],
"update": [], "unchanged": [], "errors": []
}
})))
.mount(&server)
.await;
let dir = std::env::temp_dir().join(format!("anvil-import-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("reqs.yml");
std::fs::write(&file, "- requirement_id: REQ-A-001\n title: A\n").unwrap();
let out = run_json(
&server.uri(),
&[
"requirement",
"import",
file.to_str().unwrap(),
"--dry-run",
"--repo",
"test-org/test-repo",
],
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let v = stdout_json(&out);
assert_eq!(v["dry_run"], true);
assert_eq!(v["preview"]["create"][0]["requirement_id"], "REQ-A-001");
}