1 #![forbid(unsafe_code)]2 //! M3b+M3c e2e: prs, labels, milestones against temp repos via the binary.3 use std::path::{Path, PathBuf};4 use std::process::Command;5 use std::sync::atomic::{AtomicU64, Ordering};6 7 static CTR: AtomicU64 = AtomicU64::new(0);8 9 fn bin() -> String {10 env!("CARGO_BIN_EXE_f0rge").to_string()11 }12 13 fn tmp_repo(tag: &str) -> PathBuf {14 let n = CTR.fetch_add(1, Ordering::Relaxed);15 let d = std::env::temp_dir().join(format!(16 "f0rge-m3-{}-{}-{n}",17 std::process::id(),18 tag19 ));20 let _ = std::fs::remove_dir_all(&d);21 std::fs::create_dir_all(&d).unwrap();22 d23 }24 25 fn git(repo: &Path, args: &[&str]) {26 let o = Command::new("git").args(args).current_dir(repo).output().unwrap();27 assert!(o.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&o.stderr));28 }29 30 fn f0rge(repo: &Path, args: &[&str]) -> (i32, String, String) {31 let o = Command::new(bin()).args(args).current_dir(repo).output().unwrap();32 (33 o.status.code().unwrap_or(-1),34 String::from_utf8_lossy(&o.stdout).into_owned(),35 String::from_utf8_lossy(&o.stderr).into_owned(),36 )37 }38 39 fn setup_repo() -> PathBuf {40 let repo = tmp_repo("e2e");41 git(&repo, &["init"]);42 git(&repo, &["config", "user.email", "[email protected]"]);43 git(&repo, &["config", "user.name", "t"]);44 git(&repo, &["config", "commit.gpgsign", "false"]);45 std::fs::write(repo.join("a.txt"), "line1\nline2\n").unwrap();46 git(&repo, &["add", "-A"]);47 git(&repo, &["commit", "-m", "init"]);48 git(&repo, &["checkout", "-b", "main"]);49 let (c, _, e) = f0rge(&repo, &["init"]);50 assert_eq!(c, 0, "init: {e}");51 repo52 }53 54 fn last_json_line(out: &str) -> serde_json::Value {55 let line = out.lines().last().unwrap_or("").trim();56 serde_json::from_str(line).expect("json line")57 }58 59 #[test]60 fn m3b_prs_happy_and_conflict() {61 let repo = setup_repo();62 // feat branch with a change.63 git(&repo, &["checkout", "-b", "feat"]);64 std::fs::write(repo.join("a.txt"), "line1-changed\nline2\n").unwrap();65 git(&repo, &["add", "-A"]);66 git(&repo, &["commit", "-m", "feat"]);67 git(&repo, &["checkout", "main"]);68 69 // create.70 let issue = "11111111-1111-4111-8111-111111111111";71 let (c, o, e) = f0rge(72 &repo,73 &["pr", "create", "--base", "main", "--head", "feat", "--title", "T1", "--body", "B1", "--issue", issue, "--json"],74 );75 assert_eq!(c, 0, "create: {e}");76 let v = last_json_line(&o);77 assert_eq!(v["title"], "T1");78 assert_eq!(v["state"], "open");79 assert_eq!(v["base"], "main");80 assert_eq!(v["head"], "feat");81 assert_eq!(v["number"], 1);82 let id = v["databaseId"].as_str().unwrap().to_string();83 assert!(repo.join(".forge").join("prs").join(&id).join("patch.diff").exists());84 assert!(repo.join(".forge").join("prs").join(&id).join("pr.md").exists());85 86 // diff prints the patch.87 let (c, o, _) = f0rge(&repo, &["pr", "diff", &id]);88 assert_eq!(c, 0);89 assert!(o.contains("line1-changed"), "diff body");90 91 // list filtering.92 let (c, o, _) = f0rge(&repo, &["pr", "list", "--json", "--state", "open"]);93 assert_eq!(c, 0);94 assert!(o.contains(&id));95 let (c, o, _) = f0rge(&repo, &["pr", "list", "--json", "--state", "closed"]);96 assert_eq!(c, 0);97 assert_eq!(o.trim(), "[]");98 99 // review + comment append files.100 let (c, _, e) = f0rge(&repo, &["pr", "review", &id, "--body", "lgtm", "--author", "z", "--state", "approved"]);101 assert_eq!(c, 0, "review: {e}");102 let (c, _, e) = f0rge(&repo, &["pr", "comment", &id, "--body", "nit"]);103 assert_eq!(c, 0, "comment: {e}");104 let rv: Vec<_> = std::fs::read_dir(repo.join(".forge").join("prs").join(&id).join("reviews")).unwrap().collect();105 assert_eq!(rv.len(), 2);106 107 // merge happy path.108 let (c, _, e) = f0rge(&repo, &["pr", "merge", &id]);109 assert_eq!(c, 0, "merge: {e}");110 let prmd = std::fs::read_to_string(repo.join(".forge").join("prs").join(&id).join("pr.md")).unwrap();111 assert!(prmd.contains("state: merged"));112 let merges: Vec<_> = std::fs::read_dir(repo.join(".forge").join("merges")).unwrap().collect();113 assert_eq!(merges.len(), 1);114 115 // conflict path: new branch off main, conflicting main advance after create.116 git(&repo, &["checkout", "-b", "feat2"]);117 std::fs::write(repo.join("a.txt"), "line1-feat2\nline2\n").unwrap();118 git(&repo, &["add", "-A"]);119 git(&repo, &["commit", "-m", "feat2"]);120 git(&repo, &["checkout", "main"]);121 let (c, o, _) = f0rge(&repo, &["pr", "create", "--base", "main", "--head", "feat2", "--title", "T2", "--json"]);122 assert_eq!(c, 0);123 let id2 = last_json_line(&o)["databaseId"].as_str().unwrap().to_string();124 std::fs::write(repo.join("a.txt"), "line1-main\nline2\n").unwrap();125 git(&repo, &["add", "-A"]);126 git(&repo, &["commit", "-m", "main-advance"]);127 let (c, _, e) = f0rge(&repo, &["pr", "merge", &id2]);128 assert_eq!(c, 1, "conflict must exit 1");129 assert_eq!(e.lines().count(), 1, "one-line error, got: {e}");130 assert!(e.contains("a.txt"), "conflict list names file: {e}");131 let prmd2 = std::fs::read_to_string(repo.join(".forge").join("prs").join(&id2).join("pr.md")).unwrap();132 assert!(prmd2.contains("state: open"), "conflict leaves PR open");133 134 // close path.135 let (c, _, e) = f0rge(&repo, &["pr", "close", &id2]);136 assert_eq!(c, 0, "close: {e}");137 let prmd2 = std::fs::read_to_string(repo.join(".forge").join("prs").join(&id2).join("pr.md")).unwrap();138 assert!(prmd2.contains("state: closed"));139 let (c, o, _) = f0rge(&repo, &["pr", "list", "--json", "--state", "closed"]);140 assert_eq!(c, 0);141 assert!(o.contains(&id2));142 let _ = std::fs::remove_dir_all(&repo);143 }144 145 #[test]146 fn m3c_labels_milestones() {147 let repo = setup_repo();148 // labels.149 let (c, _, e) = f0rge(&repo, &["label", "create", "--name", "bug", "--color", "ff0000", "--description", "Bugs", "--json"]);150 assert_eq!(c, 0, "label create: {e}");151 let (c, o, _) = f0rge(&repo, &["label", "list", "--json"]);152 assert_eq!(c, 0);153 assert!(o.contains("\"bug\""));154 let (c, _, _) = f0rge(&repo, &["label", "create", "--name", "bug"]);155 assert_eq!(c, 2, "duplicate label exits 2");156 let (c, o, e) = f0rge(&repo, &["label", "edit", "--name", "bug", "--color", "00ff00", "--json"]);157 assert_eq!(c, 0, "label edit: {e}");158 assert!(last_json_line(&o)["color"] == "00ff00");159 let (c, _, _) = f0rge(&repo, &["label", "edit", "--name", "nope"]);160 assert_eq!(c, 2, "missing label exits 2");161 // milestones.162 let (c, _, e) = f0rge(&repo, &["milestone", "create", "--name", "v1", "--due", "2025-10-01", "--description", "First", "--json"]);163 assert_eq!(c, 0, "ms create: {e}");164 let (c, o, _) = f0rge(&repo, &["milestone", "list", "--json"]);165 assert_eq!(c, 0);166 assert!(o.contains("\"v1\""));167 let (c, _, _) = f0rge(&repo, &["milestone", "create", "--name", "v1"]);168 assert_eq!(c, 2, "duplicate milestone exits 2");169 let (c, _, e) = f0rge(&repo, &["milestone", "edit", "--name", "v1", "--due", "2025-11-01"]);170 assert_eq!(c, 0, "ms edit: {e}");171 let (c, _, _) = f0rge(&repo, &["milestone", "edit", "--name", "nope"]);172 assert_eq!(c, 2, "missing milestone exits 2");173 let ms = std::fs::read_to_string(repo.join(".forge").join("milestones.md")).unwrap();174 assert!(ms.contains("2025-11-01"));175 let _ = std::fs::remove_dir_all(&repo);176 }