@@ -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)]