#[derive(Debug, PartialEq, Eq, serde::Deserialize)]
pub(crate) struct PrInfo {
pub(crate) number: u64,
#[serde(rename = "baseRefName")]
pub(crate) base_ref_name: String,
#[serde(rename = "baseRefOid")]
pub(crate) base_ref_oid: String,
#[serde(rename = "headRefOid")]
pub(crate) head_ref_oid: String,
}
pub(crate) fn parse_pr_view_json(json: &str) -> anyhow::Result<PrInfo> {
Ok(serde_json::from_str(json)?)
}
pub(crate) fn ensure_fetched_head_matches(
number: u64,
fetched_head: &str,
reported_head: &str,
) -> anyhow::Result<()> {
if fetched_head == reported_head {
return Ok(());
}
anyhow::bail!(
"fetched PR #{number} head ({fetched_head}) does not match `gh`'s reported head \
({reported_head}); this usually means the PR belongs to a different repository than \
the target clone's `origin` remote, or the PR was updated between resolving it \
and fetching it — verify `origin` points at the PR's repository and re-run",
);
}
pub(crate) fn fetch_pr_info(arg: &str) -> anyhow::Result<PrInfo> {
let output = std::process::Command::new("gh")
.args([
"pr",
"view",
arg,
"--json",
"number,baseRefName,baseRefOid,headRefOid",
])
.output()?;
if !output.status.success() {
anyhow::bail!(
"gh pr view {arg} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
parse_pr_view_json(&String::from_utf8(output.stdout)?)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn should_parse_pr_view_json_into_pr_info() {
let json = r#"{"number":123,"baseRefName":"main","baseRefOid":"base789","headRefOid":"abc123def456"}"#;
let actual = parse_pr_view_json(json).expect("expected valid JSON to parse");
assert_eq!(
PrInfo {
number: 123,
base_ref_name: "main".to_string(),
base_ref_oid: "base789".to_string(),
head_ref_oid: "abc123def456".to_string(),
},
actual
);
}
#[test]
fn should_fail_to_parse_pr_view_json_when_a_required_field_is_missing() {
let json = r#"{"number":123,"baseRefName":"main"}"#;
let actual = parse_pr_view_json(json);
assert!(actual.is_err());
}
#[test]
fn should_accept_fetched_head_when_it_matches_the_reported_head() {
let actual = ensure_fetched_head_matches(76, "abc123", "abc123");
assert!(actual.is_ok());
}
#[test]
fn should_reject_fetched_head_with_a_repository_hint_when_it_differs_from_the_reported_head() {
let actual = ensure_fetched_head_matches(76, "abc123", "def456");
let message = actual.expect_err("expected a mismatch error").to_string();
assert_eq!(
"fetched PR #76 head (abc123) does not match `gh`'s reported head (def456); this \
usually means the PR belongs to a different repository than the target clone's \
`origin` remote, or the PR was updated between resolving it and fetching it — \
verify `origin` points at the PR's repository and re-run",
message
);
}
}