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.
167 lines
5.9 KiB
Rust
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:?}");
|
|
}
|