//! `pr edit` against a mock forge that records every write it receives. //! Drives the compiled binary with real stdin: `--body-file -` can be read //! once, so a body resolved twice reaches the forge as an empty string. use std::io::{BufRead as _, BufReader, Read as _, Write as _}; use std::net::{TcpListener, TcpStream}; use std::path::PathBuf; use std::process::{Command, Stdio}; use std::sync::{Arc, Mutex}; const NUMBER: &str = "7"; /// `(method, path, body)` of each PATCH the mock forge received. type Writes = Arc>>; /// A unique scratch `HYPERHIVE_STATE_DIR` holding a `forge-token` file, /// same manual-tmpdir pattern as `credential_helper.rs`. fn scratch_state_dir(tag: &str) -> PathBuf { let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |d| d.as_nanos()); let dir = std::env::temp_dir().join(format!("hive-forge-pr-edit-test-{tag}-{ts}")); std::fs::create_dir_all(&dir).expect("create scratch state dir"); std::fs::write(dir.join("forge-token"), "test-token\n").expect("seed forge-token"); dir } /// Answer one request: #7 is a PR; both edit endpoints echo a minimal /// object and record the PATCH body. fn serve(mut stream: TcpStream, writes: &Writes) { let mut reader = BufReader::new(stream.try_clone().expect("clone stream")); let mut request_line = String::new(); reader .read_line(&mut request_line) .expect("read request line"); let mut content_length = 0usize; loop { let mut line = String::new(); if reader.read_line(&mut line).expect("read header") == 0 || line == "\r\n" { break; } if let Some((name, value)) = line.split_once(':') && name.eq_ignore_ascii_case("content-length") { content_length = value.trim().parse().expect("numeric content-length"); } } let mut raw = vec![0u8; content_length]; reader.read_exact(&mut raw).expect("read request body"); let mut parts = request_line.split_whitespace(); let method = parts.next().unwrap_or("").to_owned(); let target = parts.next().unwrap_or(""); let path = target.split('?').next().unwrap_or("").to_owned(); if method == "PATCH" { let body = serde_json::from_slice(&raw).expect("PATCH body is JSON"); writes .lock() .expect("lock") .push((method.clone(), path, body)); } let status = if method == "PATCH" { "201 Created" } else { "200 OK" }; let body = serde_json::json!({ "number": NUMBER.parse::().expect("numeric NUMBER"), "title": "t", "state": "open", "pull_request": {"merged": false}, "requested_reviewers": [], }) .to_string(); let response = format!( "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len() ); stream .write_all(response.as_bytes()) .expect("write response"); } /// Start the mock forge on a loopback port; returns its base URL and the /// shared record of `PATCH` requests. fn mock_forge() -> (String, Writes) { let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock forge"); let addr = listener.local_addr().expect("mock forge addr"); let writes: Writes = Arc::default(); let seen = Arc::clone(&writes); std::thread::spawn(move || { for stream in listener.incoming() { serve(stream.expect("accept"), &seen); } }); (format!("http://{addr}"), writes) } /// Run `hive-forge -r o/r pr edit 7 ` with `stdin` piped in. Returns /// `(success, stderr, `PATCH` requests)`. fn pr_edit( tag: &str, args: &[&str], stdin: &str, ) -> (bool, String, Vec<(String, String, serde_json::Value)>) { let (url, writes) = mock_forge(); let mut child = Command::new(env!("CARGO_BIN_EXE_hive-forge")) .args(["-r", "o/r", "pr", "edit", NUMBER]) .args(args) .env("HIVE_FORGE_URL", url) .env("HYPERHIVE_STATE_DIR", scratch_state_dir(tag)) .env_remove("HIVE_FORGE_TOKEN_FILE") .env_remove("HIVE_LABEL") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .expect("spawn hive-forge"); child .stdin .take() .expect("piped stdin") .write_all(stdin.as_bytes()) .expect("write stdin"); let out = child.wait_with_output().expect("wait for hive-forge"); let recorded = writes.lock().expect("lock").clone(); ( out.status.success(), String::from_utf8_lossy(&out.stderr).into_owned(), recorded, ) } #[test] fn stdin_body_without_base_is_sent_once_to_the_issue_endpoint() { let (ok, stderr, writes) = pr_edit("stdin-issue", &["--body-file", "-"], "stdin body\n"); assert!(ok, "{stderr}"); assert_eq!(writes.len(), 1, "{writes:?}"); assert_eq!(writes[0].1, format!("/api/v1/repos/o/r/issues/{NUMBER}")); assert_eq!(writes[0].2["body"], "stdin body\n"); assert!(writes[0].2.get("base").is_none(), "{writes:?}"); } #[test] fn stdin_body_with_base_is_sent_to_the_pulls_endpoint() { let (ok, stderr, writes) = pr_edit( "stdin-pulls", &["--body-file", "-", "--base", "main"], "stdin body\n", ); assert!(ok, "{stderr}"); assert_eq!(writes.len(), 1, "{writes:?}"); assert_eq!(writes[0].1, format!("/api/v1/repos/o/r/pulls/{NUMBER}")); assert_eq!(writes[0].2["body"], "stdin body\n"); assert_eq!(writes[0].2["base"], "main"); } #[test] fn base_with_milestone_zero_is_refused_before_any_write() { let (ok, stderr, writes) = pr_edit( "milestone-zero", &["--base", "main", "--milestone", "0"], "", ); assert!(!ok); assert!(stderr.contains("--milestone 0"), "{stderr}"); assert!(writes.is_empty(), "{writes:?}"); }