Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-forge/tests/pr_edit.rs
atlas 9e3a00825b hive-forge: pr edit --base retargets a PR
Forgejo's issue edit endpoint (/issues/<n>) has no base field, so `pr edit`
could not retarget a stacked PR once its base merged. `--base <BRANCH>`
(non-empty) switches the edit onto PATCH /pulls/<n>, which carries `base`
alongside title/body/state/milestone. Without `--base`, `pr edit` is
unchanged and still goes through the issue endpoint.
2026-10-13 08:37:13 +02:00

167 lines
5.9 KiB
Rust

//! `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<Mutex<Vec<(String, String, serde_json::Value)>>>;
/// 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::<u64>().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 <args>` 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:?}");
}