1 //! M3a acceptance: full issue lifecycle on a temp repo via the binary.2 //! create/list(filters)/view/comment/close/reopen/assign/label/edit,3 //! golden JSON field order, error codes, budgets, derived-number stability.4 5 use std::path::{Path, PathBuf};6 use std::process::Command;7 8 fn bin() -> Command {9 Command::new(env!("CARGO_BIN_EXE_f0rge"))10 }11 12 fn run_in(dir: &Path, args: &[&str]) -> (String, String, i32) {13 let out = bin().current_dir(dir).args(args).output().expect("run f0rge");14 (15 String::from_utf8_lossy(&out.stdout).into_owned(),16 String::from_utf8_lossy(&out.stderr).into_owned(),17 out.status.code().unwrap_or(-1),18 )19 }20 21 fn tmp(tag: &str) -> PathBuf {22 let d = std::env::temp_dir()23 .join(format!("f0rge-m3a-{tag}-{}-{}", std::process::id(), line!()));24 let _ = std::fs::remove_dir_all(&d);25 std::fs::create_dir_all(&d).unwrap();26 d27 }28 29 fn jv(s: &str) -> serde_json::Value {30 serde_json::from_str(s.trim()).expect("json line")31 }32 33 fn run_in_stdin(dir: &Path, args: &[&str], input: &str) -> (String, String, i32) {34 use std::io::Write;35 let mut kid = bin()36 .current_dir(dir)37 .args(args)38 .stdin(std::process::Stdio::piped())39 .stdout(std::process::Stdio::piped())40 .stderr(std::process::Stdio::piped())41 .spawn()42 .expect("spawn f0rge");43 kid.stdin.take().unwrap().write_all(input.as_bytes()).unwrap();44 let out = kid.wait_with_output().expect("wait f0rge");45 (46 String::from_utf8_lossy(&out.stdout).into_owned(),47 String::from_utf8_lossy(&out.stderr).into_owned(),48 out.status.code().unwrap_or(-1),49 )50 }51 52 fn field_order(s: &str, keys: &[&str]) {53 let mut last = 0;54 for k in keys {55 let i = s.find(&format!("\"{k}\":")).unwrap_or_else(|| panic!("missing {k} in {s}"));56 assert!(i >= last, "order broken at {k} in {s}");57 last = i;58 }59 }60 61 const ISSUE_KEYS: &[&str] =62 &["number", "title", "state", "labels", "assignees", "milestone", "body", "createdAt", "comments"];63 64 #[test]65 fn full_lifecycle() {66 let dir = tmp("life");67 let (out, err, code) = run_in(&dir, &["init"]);68 assert_eq!((code, out.as_str(), err.as_str()), (0, "", ""));69 70 // create A (full) + B (minimal)71 let (a_out, _, code) = run_in(72 &dir,73 &["issue", "create", "--title", "Alpha", "--body", "first body", "--label", "bug",74 "--assignee", "ann", "--milestone", "v1", "--json"],75 );76 assert_eq!(code, 0);77 assert_eq!(a_out.lines().count(), 1);78 assert!(!a_out.contains("\", \"") && !a_out.contains("\": "));79 field_order(&a_out, ISSUE_KEYS);80 let a = jv(&a_out);81 assert_eq!(a["title"], "Alpha");82 assert_eq!(a["state"], "open");83 assert_eq!(a["labels"], serde_json::json!(["bug"]));84 assert_eq!(a["assignees"], serde_json::json!(["ann"]));85 assert_eq!(a["milestone"], "v1");86 assert_eq!(a["body"], "first body\n");87 assert_eq!(a["comments"], 0);88 assert!(a["createdAt"].as_str().is_some());89 let aid = uuid::Uuid::parse_str(a["number"].as_u64().unwrap().to_string().as_str()).ok();90 assert!(aid.is_none()); // number is numeric, not uuid91 let anum = a["number"].as_u64().unwrap();92 let _ = anum; // snapshot only; re-resolved from list below93 94 let (b_out, _, code) = run_in(&dir, &["issue", "create", "--title", "Beta", "--json"]);95 assert_eq!(code, 0);96 let b = jv(&b_out);97 assert_eq!(b["labels"], serde_json::json!([]));98 assert_eq!(b["milestone"], serde_json::Value::Null);99 100 // numbers never stored in files101 let issues_dir = dir.join(".forge/issues");102 for e in std::fs::read_dir(&issues_dir).unwrap().flatten() {103 let md = issues_dir.join(e.file_name()).join("issue.md");104 let t = std::fs::read_to_string(&md).unwrap();105 assert!(!t.contains("number"), "number stored in {}", md.display());106 }107 108 // list stable across runs109 let (l1, _, code) = run_in(&dir, &["issue", "list", "--json"]);110 assert_eq!(code, 0);111 let (l2, _, _) = run_in(&dir, &["issue", "list", "--json"]);112 assert_eq!(l1, l2);113 assert_eq!(l1.lines().count(), 2);114 for line in l1.lines() {115 field_order(line, ISSUE_KEYS);116 }117 // numbers are derived snapshots: re-resolve current numbers by title118 let mut by_title = std::collections::BTreeMap::new();119 for line in l1.lines() {120 let v = jv(line);121 by_title.insert(v["title"].as_str().unwrap().to_string(), v["number"].as_u64().unwrap());122 }123 assert_eq!(by_title.len(), 2);124 let mut nums: Vec<u64> = by_title.values().copied().collect();125 nums.sort();126 assert_eq!(nums, vec![1, 2]);127 let anum = by_title["Alpha"];128 let bnum = by_title["Beta"];129 assert_ne!(anum, bnum);130 let _ = bnum;131 132 // filters133 let (o, _, _) = run_in(&dir, &["issue", "list", "--json", "--label", "bug"]);134 assert_eq!(o.lines().count(), 1);135 assert_eq!(jv(&o)["title"], "Alpha");136 let (o, _, _) = run_in(&dir, &["issue", "list", "--json", "--assignee", "ann"]);137 assert_eq!(jv(&o)["title"], "Alpha");138 let (o, _, _) = run_in(&dir, &["issue", "list", "--json", "--state", "open"]);139 assert_eq!(o.lines().count(), 2);140 let (o, _, _) = run_in(&dir, &["issue", "list", "--json", "--search", "BETA"]);141 assert_eq!(o.lines().count(), 1);142 assert_eq!(jv(&o)["title"], "Beta");143 let (o, _, _) = run_in(&dir, &["issue", "list", "--json", "--milestone", "v1"]);144 assert_eq!(jv(&o)["title"], "Alpha");145 let (o, _, _) = run_in(&dir, &["issue", "list", "--json", "--label", "nope"]);146 assert_eq!(o, "[]\n");147 148 // view by number + uuid, json has comments array149 let (v, _, code) = run_in(&dir, &["issue", "view", &anum.to_string(), "--json"]);150 assert_eq!(code, 0);151 field_order(&v, &["number", "title", "state", "labels", "assignees", "milestone", "body", "createdAt", "comments"]);152 let vv = jv(&v);153 assert_eq!(vv["comments"], serde_json::json!([]));154 assert_eq!(vv["body"], "first body\n");155 156 // comment append-only: two comments = two files, first untouched157 let (c1, _, code) =158 run_in(&dir, &["issue", "comment", &anum.to_string(), "--body", "hello", "--author", "bob"]);159 assert_eq!(code, 0);160 let c1v = jv(&c1);161 assert_eq!(c1v["author"], "bob");162 assert_eq!(c1v["body"], "hello\n");163 let cdir = issues_dir164 .read_dir()165 .unwrap()166 .flatten()167 .map(|e| issues_dir.join(e.file_name()))168 .find(|p| {169 std::fs::read_to_string(p.join("issue.md")).unwrap().contains("Alpha")170 || std::fs::read_to_string(p.join("issue.md")).unwrap().contains("Alpha2")171 });172 // locate A's dir via list number is awkward; find comments dir with 1 file173 let mut found: Vec<PathBuf> = Vec::new();174 for e in std::fs::read_dir(&issues_dir).unwrap().flatten() {175 let cd = issues_dir.join(e.file_name()).join("comments");176 if cd.is_dir() {177 found.push(cd);178 }179 }180 assert_eq!(found.len(), 1);181 let first_file = std::fs::read_dir(&found[0]).unwrap().flatten().next().unwrap().path();182 let first_bytes = std::fs::read(&first_file).unwrap();183 let _ = cdir;184 let (_, _, code) =185 run_in(&dir, &["issue", "comment", &anum.to_string(), "--body", "second", "--json"]);186 assert_eq!(code, 0);187 assert_eq!(std::fs::read(&first_file).unwrap(), first_bytes); // never edited188 assert_eq!(std::fs::read_dir(&found[0]).unwrap().count(), 2);189 let (v, _, _) = run_in(&dir, &["issue", "view", &anum.to_string(), "--json"]);190 let vv = jv(&v);191 assert_eq!(vv["comments"].as_array().unwrap().len(), 2);192 let by_body = |b: &str| {193 vv["comments"].as_array().unwrap().iter().find(|c| c["body"] == b).cloned().expect("comment body")194 };195 assert_eq!(by_body("hello\n")["author"], "bob");196 assert_eq!(by_body("second\n")["author"], serde_json::Value::Null);197 assert_eq!(vv["number"], anum);198 199 // close / reopen round-trip via number200 let (o, _, code) = run_in(&dir, &["issue", "close", &anum.to_string()]);201 assert_eq!(code, 0);202 assert_eq!(jv(&o)["state"], "closed");203 let (o, _, _) = run_in(&dir, &["issue", "list", "--json", "--state", "closed"]);204 assert_eq!(o.lines().count(), 1);205 let (o, _, _) = run_in(&dir, &["issue", "list", "--json", "--state", "open"]);206 assert_eq!(o.lines().count(), 1); // only Beta207 let (o, _, code) = run_in(&dir, &["issue", "reopen", &anum.to_string()]);208 assert_eq!(code, 0);209 assert_eq!(jv(&o)["state"], "open");210 211 // assign append + label append, then edit212 let (o, _, code) = run_in(&dir, &["issue", "assign", &anum.to_string(), "--assignee", "zed"]);213 assert_eq!(code, 0);214 let av = jv(&o);215 assert!(av["assignees"].as_array().unwrap().contains(&serde_json::json!("ann")));216 assert!(av["assignees"].as_array().unwrap().contains(&serde_json::json!("zed")));217 let (o, _, code) = run_in(&dir, &["issue", "label", &anum.to_string(), "--label", "urgent"]);218 assert_eq!(code, 0);219 let lv = jv(&o);220 assert!(lv["labels"].as_array().unwrap().contains(&serde_json::json!("bug")));221 assert!(lv["labels"].as_array().unwrap().contains(&serde_json::json!("urgent")));222 let (o, _, code) =223 run_in(&dir, &["issue", "edit", &anum.to_string(), "--title", "Alpha2", "--body", "new"]);224 assert_eq!(code, 0);225 let ev = jv(&o);226 assert_eq!(ev["title"], "Alpha2");227 assert_eq!(ev["body"], "new\n");228 assert_eq!(ev["number"], anum); // number stable through mutations229 230 // human view shows body + comments231 let (h, _, code) = run_in(&dir, &["issue", "view", &anum.to_string()]);232 assert_eq!(code, 0);233 assert!(h.contains("Alpha2") && h.contains("new") && h.contains("hello"));234 235 let _ = std::fs::remove_dir_all(&dir);236 }237 238 #[test]239 fn errors_and_budgets() {240 let dir = tmp("err");241 run_in(&dir, &["init"]);242 243 // missing title -> usage exit 2, one line244 let (_, err, code) = run_in(&dir, &["issue", "create", "--body", "x"]);245 assert_eq!(code, 2);246 assert_eq!(err.lines().count(), 1);247 // no verb -> exit 2248 let (_, err, code) = run_in(&dir, &["issue"]);249 assert_eq!(code, 2);250 assert_eq!(err.lines().count(), 1);251 // unknown flag -> exit 2252 let (_, err, code) = run_in(&dir, &["issue", "list", "--bogus", "1"]);253 assert_eq!(code, 2);254 assert_eq!(err.lines().count(), 1);255 // bad state -> exit 2256 let (_, err, code) = run_in(&dir, &["issue", "list", "--state", "bogus"]);257 assert_eq!(code, 2);258 assert_eq!(err.lines().count(), 1);259 // unknown id -> runtime exit 1, one line260 let (_, err, code) = run_in(&dir, &["issue", "view", "9999"]);261 assert_eq!(code, 1);262 assert_eq!(err.lines().count(), 1);263 // comment without body -> exit 2264 let (o, _, _) = run_in(&dir, &["issue", "create", "--title", "T", "--json"]);265 let n = jv(&o)["number"].to_string();266 let (_, err, code) = run_in(&dir, &["issue", "comment", &n]);267 assert_eq!(code, 2);268 assert_eq!(err.lines().count(), 1);269 // edit with neither field -> exit 2270 let (_, err, code) = run_in(&dir, &["issue", "edit", &n]);271 assert_eq!(code, 2);272 assert_eq!(err.lines().count(), 1);273 274 // help budget275 let (h, _, code) = run_in(&dir, &["issue", "-h"]);276 assert_eq!(code, 0);277 assert!(h.lines().count() <= 8, "help lines:\n{h}");278 assert!(h.len() <= 250, "help bytes {}:\n{h}", h.len());279 for w in ["create", "list", "view", "comment", "close", "reopen", "assign", "label", "edit"] {280 assert!(h.contains(w), "help missing {w}:\n{h}");281 }282 283 let _ = std::fs::remove_dir_all(&dir);284 }285 286 #[test]287 fn create_rejects_state_and_silent_flags() {288 // #31: gh-literal, no silent accepts on create.289 let dir = tmp("reject");290 run_in(&dir, &["init"]);291 for flag in ["--state", "--search", "--author"] {292 let (_, err, code) = run_in(&dir, &["issue", "create", "--title", "T", flag, "x"]);293 assert_eq!(code, 2, "flag {flag}");294 assert_eq!(err.lines().count(), 1, "flag {flag}: {err:?}");295 assert!(err.contains(flag), "flag {flag} unnamed: {err:?}");296 }297 let (_, err, code) = run_in(&dir, &["issue", "create", "--title", "T", "--remove"]);298 assert_eq!(code, 2);299 assert_eq!(err.lines().count(), 1);300 assert!(err.contains("--remove"), "{err:?}");301 // default state only via create; list --state still filters.302 let (o, _, code) = run_in(&dir, &["issue", "create", "--title", "T", "--json"]);303 assert_eq!(code, 0);304 assert_eq!(jv(&o)["state"], "open");305 let (o, _, code) = run_in(&dir, &["issue", "list", "--json", "--state", "open"]);306 assert_eq!(code, 0);307 assert_eq!(o.lines().count(), 1);308 let _ = std::fs::remove_dir_all(&dir);309 }310 311 #[test]312 fn edit_milestone_label_assignee_and_state_rejection() {313 // #43: edit gains --milestone/--label/--assignee (append, like the314 // label/assign verbs); --state stays rejected with the try-path hint.315 let dir = tmp("editflags");316 run_in(&dir, &["init"]);317 let (o, _, code) = run_in(318 &dir,319 &["issue", "create", "--title", "E", "--body", "b", "--label", "bug",320 "--assignee", "ann", "--milestone", "v1", "--json"],321 );322 assert_eq!(code, 0);323 let n = jv(&o)["number"].to_string();324 325 // --milestone regroups, everything else intact326 let (o, _, code) = run_in(&dir, &["issue", "edit", &n, "--milestone", "v2"]);327 assert_eq!(code, 0);328 let v = jv(&o);329 assert_eq!(v["milestone"], "v2");330 assert_eq!(v["labels"], serde_json::json!(["bug"]));331 assert_eq!(v["assignees"], serde_json::json!(["ann"]));332 assert_eq!(v["title"], "E");333 assert_eq!(v["body"], "b\n");334 335 // --label/--assignee append with dedup (verb semantics, no replace)336 let (o, _, code) = run_in(337 &dir,338 &["issue", "edit", &n, "--label", "urgent", "--label", "bug", "--assignee", "zed"],339 );340 assert_eq!(code, 0);341 let v = jv(&o);342 assert_eq!(v["labels"], serde_json::json!(["bug", "urgent"]));343 assert_eq!(v["assignees"], serde_json::json!(["ann", "zed"]));344 assert_eq!(v["milestone"], "v2");345 346 // --state rejected: exit 2, one line, try-path hint; nothing changed347 let (_, err, code) = run_in(&dir, &["issue", "edit", &n, "--state", "closed"]);348 assert_eq!(code, 2);349 assert_eq!(err.lines().count(), 1);350 assert!(err.contains("--state") && err.contains("(try: f0rge issue edit --help)"), "{err:?}");351 let (o, _, _) = run_in(&dir, &["issue", "view", &n, "--json"]);352 assert_eq!(jv(&o)["state"], "open");353 354 let _ = std::fs::remove_dir_all(&dir);355 }356 357 #[test]358 fn body_file_create_and_comment() {359 // #44: --body-file <path|-> on create + comment; stdin via `-`.360 let dir = tmp("bodyfile");361 run_in(&dir, &["init"]);362 let hostile = "shell-hostile: `back` $var \"dq\" 'sq'\nline2 `$(x)`\n";363 std::fs::write(dir.join("notes.md"), hostile).unwrap();364 365 let (o, _, code) =366 run_in(&dir, &["issue", "create", "--title", "F", "--body-file", "notes.md", "--json"]);367 assert_eq!(code, 0);368 let v = jv(&o);369 assert_eq!(v["body"], hostile);370 let n = v["number"].to_string();371 372 // stdin via `-`373 let (o, _, code) = run_in_stdin(374 &dir,375 &["issue", "create", "--title", "S", "--body-file", "-", "--json"],376 "stdin `body` $x\n",377 );378 assert_eq!(code, 0);379 assert_eq!(jv(&o)["body"], "stdin `body` $x\n");380 381 // comment from file + stdin382 let (o, _, code) =383 run_in(&dir, &["issue", "comment", &n, "--body-file", "notes.md", "--json"]);384 assert_eq!(code, 0);385 assert_eq!(jv(&o)["body"], hostile);386 let (o, _, code) = run_in_stdin(387 &dir,388 &["issue", "comment", &n, "--body-file", "-", "--json"],389 "c2\n",390 );391 assert_eq!(code, 0);392 assert_eq!(jv(&o)["body"], "c2\n");393 394 // --body with --body-file rejected (exit 2, one line)395 let (_, err, code) = run_in(396 &dir,397 &["issue", "create", "--title", "X", "--body", "b", "--body-file", "notes.md"],398 );399 assert_eq!(code, 2);400 assert_eq!(err.lines().count(), 1);401 402 // missing file -> runtime exit 1, one line403 let (_, err, code) =404 run_in(&dir, &["issue", "create", "--title", "X", "--body-file", "nope.md"]);405 assert_eq!(code, 1);406 assert_eq!(err.lines().count(), 1);407 408 let _ = std::fs::remove_dir_all(&dir);409 }410 411 #[test]412 fn field_preservation_regression() {413 // #45: an issue carrying milestone+labels+assignee survives label/assign414 // edits with ALL fields intact (the silent-wipe class stays dead).415 let dir = tmp("preserve");416 run_in(&dir, &["init"]);417 let (o, _, code) = run_in(418 &dir,419 &["issue", "create", "--title", "P", "--body", "keep", "--label", "bug",420 "--assignee", "ann", "--milestone", "v9", "--json"],421 );422 assert_eq!(code, 0);423 let n = jv(&o)["number"].to_string();424 425 // label verb (append) must not wipe milestone/assignees/body426 let (o, _, code) = run_in(&dir, &["issue", "label", &n, "--label", "urgent"]);427 assert_eq!(code, 0);428 let v = jv(&o);429 assert_eq!(v["labels"], serde_json::json!(["bug", "urgent"]));430 assert_eq!(v["milestone"], "v9");431 assert_eq!(v["assignees"], serde_json::json!(["ann"]));432 assert_eq!(v["body"], "keep\n");433 434 // assign verb (append) must not wipe milestone/labels/body435 let (o, _, code) = run_in(&dir, &["issue", "assign", &n, "--assignee", "zed"]);436 assert_eq!(code, 0);437 let v = jv(&o);438 assert_eq!(v["assignees"], serde_json::json!(["ann", "zed"]));439 assert_eq!(v["milestone"], "v9");440 assert_eq!(v["labels"], serde_json::json!(["bug", "urgent"]));441 442 // edit --title keeps the rest (partial-record-write class stays dead)443 let (o, _, code) = run_in(&dir, &["issue", "edit", &n, "--title", "P2"]);444 assert_eq!(code, 0);445 let v = jv(&o);446 assert_eq!(v["title"], "P2");447 assert_eq!(v["state"], "open");448 assert_eq!(v["milestone"], "v9");449 assert_eq!(v["labels"], serde_json::json!(["bug", "urgent"]));450 assert_eq!(v["assignees"], serde_json::json!(["ann", "zed"]));451 assert_eq!(v["body"], "keep\n");452 453 // raw record still carries every field454 let issues_dir = dir.join(".forge/issues");455 let mut files = Vec::new();456 for e in std::fs::read_dir(&issues_dir).unwrap().flatten() {457 files.push(std::fs::read_to_string(issues_dir.join(e.file_name()).join("issue.md")).unwrap());458 }459 assert_eq!(files.len(), 1);460 for needle in ["P2", "bug", "urgent", "ann", "zed", "v9", "keep"] {461 assert!(files[0].contains(needle), "record lost `{needle}`:\n{}", files[0]);462 }463 464 // human list shows labels (+ milestone when set)465 let (h, _, code) = run_in(&dir, &["issue", "list"]);466 assert_eq!(code, 0);467 assert!(h.contains("bug") && h.contains("urgent") && h.contains("v9"), "list:\n{h}");468 469 let _ = std::fs::remove_dir_all(&dir);470 }