use std::{collections::BTreeMap, sync::Arc};
use anyhow::{Context, Result, anyhow};
use nostr_sdk::prelude::*;
use rstest::*;
use test_harness::{
CloneLogin, Harness, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, PublishPatchSeriesOpts,
PublishRepoOpts, event_branch_name_tag, tag_value,
};
use tokio::sync::OnceCell;
const IDENTIFIER: &str = "git-push-pr-patch-update-to-pr";
const BIG_FILE_BYTES: usize = 100 * 1024;
struct Snapshot {
all_patch_events: Vec<Event>,
pr_events: Vec<Event>,
pr_event: Event,
pr_update_count: usize,
original_root_patch_id: EventId,
original_root_patch_pubkey: PublicKey,
original_branch_name: String,
new_commit_oid: String,
maintainer_pubkey: PublicKey,
nostr_clone_ls_refs: BTreeMap<String, String>,
}
static SNAPSHOT: OnceCell<Arc<Snapshot>> = OnceCell::const_new();
#[fixture]
async fn snapshot() -> Arc<Snapshot> {
SNAPSHOT
.get_or_init(|| async {
Arc::new(
capture_snapshot()
.await
.expect("git_push_pr::patch_update_to_pr fixture: capture_snapshot failed"),
)
})
.await
.clone()
}
async fn capture_snapshot() -> Result<Snapshot> {
let harness = Harness::builder(
env!("CARGO_BIN_EXE_ngit"),
env!("CARGO_BIN_EXE_git-remote-nostr"),
)
.with_relay("default")
.with_grasp_server("repo")
.build()
.await?;
let (_publisher, published) = harness
.publish_repo(PublishRepoOpts {
display_name: Some("patch-update-to-pr maintainer".into()),
identifier: Some(IDENTIFIER.into()),
..Default::default()
})
.await?;
let maintainer_keys = Keys::parse(&published.maintainer_nsec)
.context("published.maintainer_nsec is not a valid key")?;
let maintainer_pubkey = maintainer_keys.public_key();
let series = harness
.publish_patch_series(&published, PublishPatchSeriesOpts::default())
.await?;
let original_root_patch = series
.patch_events
.iter()
.find(|e| event_branch_name_tag(e).as_deref() == Some(series.branch_name.as_str()))
.with_context(|| {
format!(
"no patch event with branch-name={:?} in publish_patch_series output; \
has the harness changed how it tags the series root?",
series.branch_name,
)
})?;
let original_root_patch_id = original_root_patch.id;
let original_root_patch_pubkey = original_root_patch.pubkey;
let shorthand = &original_root_patch.id.to_hex()[..8];
let remote_branch = format!("pr/{}({})", series.branch_name, shorthand);
let maintainer_clone = harness
.clone_published_repo(&published, CloneLogin::AsMaintainer)
.await?;
maintainer_clone
.git_ok(
["config", "--local", "nostr.auto-pr-branches", "true"],
"enable automatic PR branches for raw remote-ref checkout",
)
.await?;
maintainer_clone
.git_ok(["fetch", "origin"], "fetch automatic PR branches")
.await?;
let snap = maintainer_clone
.snapshot()
.context("snapshotting maintainer clone before checkout")?;
let remote_ref = format!("refs/remotes/origin/{remote_branch}");
snap.refs.get(&remote_ref).with_context(|| {
format!(
"{remote_ref} missing from maintainer clone after `git clone` — \
list.rs no longer advertises patch proposals as pr/<branch>(<shorthand>) \
for non-author viewers? Available refs: {:?}",
snap.refs.keys().collect::<Vec<_>>(),
)
})?;
maintainer_clone
.git_ok(
["checkout", &remote_branch],
&format!("git checkout {remote_branch}"),
)
.await?;
let big_content: String = "x".repeat(BIG_FILE_BYTES);
std::fs::write(
maintainer_clone.dir().join("maintainer-big-update.md"),
&big_content,
)
.context("failed to write maintainer-big-update.md")?;
maintainer_clone
.git_ok(
["add", "maintainer-big-update.md"],
"git add maintainer-big-update.md",
)
.await?;
maintainer_clone
.git_ok(
[
"commit",
"-m",
"follow-up on patch series (big)",
"--no-gpg-sign",
],
"git commit maintainer-big-update.md",
)
.await?;
let new_commit_oid = maintainer_clone
.rev_parse("HEAD")
.await
.context("rev-parse HEAD after big-commit")?;
maintainer_clone
.nostr_push(["origin", &remote_branch])
.await
.context("nostr_push of big follow-up (FF) failed")?;
let all_patch_events = harness
.grasp("repo")
.events(Filter::new().kind(Kind::GitPatch))
.await?;
let pr_events = harness
.grasp("repo")
.events(Filter::new().kind(KIND_PULL_REQUEST))
.await?;
let pr_update_count = harness
.grasp("repo")
.events(Filter::new().kind(KIND_PULL_REQUEST_UPDATE))
.await?
.len();
let pr_event = pr_events
.iter()
.find(|e| event_branch_name_tag(e).as_deref() == Some(series.branch_name.as_str()))
.cloned()
.ok_or_else(|| {
anyhow!(
"no KIND_PULL_REQUEST event on GRASP with branch-name={:?} after the \
FF push; all PR event ids: {:?}",
series.branch_name,
pr_events.iter().map(|e| e.id.to_hex()).collect::<Vec<_>>(),
)
})?;
let new_clone = harness
.clone_published_repo(&published, CloneLogin::None)
.await?;
new_clone
.git_ok(
["config", "--local", "nostr.auto-pr-branches", "true"],
"enable automatic PR branches for ls-remote compatibility check",
)
.await?;
let ls_out = new_clone
.git(["ls-remote", "origin"])
.output()
.await
.context("failed to spawn git ls-remote origin on fresh clone")?;
anyhow::ensure!(
ls_out.status.success(),
"fresh-clone `git ls-remote origin` exited {:?}\nstdout: {}\nstderr: {}",
ls_out.status,
String::from_utf8_lossy(&ls_out.stdout),
String::from_utf8_lossy(&ls_out.stderr),
);
let ls_stdout = String::from_utf8(ls_out.stdout)
.context("fresh-clone `git ls-remote origin` stdout is not UTF-8")?;
let nostr_clone_ls_refs: BTreeMap<String, String> = ls_stdout
.lines()
.filter(|l| !l.is_empty() && !l.starts_with("ref: "))
.filter_map(|l| l.split_once('\t'))
.map(|(oid, name)| (name.to_string(), oid.to_string()))
.collect();
Ok(Snapshot {
all_patch_events,
pr_events,
pr_event,
pr_update_count,
original_root_patch_id,
original_root_patch_pubkey,
original_branch_name: series.branch_name.clone(),
new_commit_oid,
maintainer_pubkey,
nostr_clone_ls_refs,
})
}
#[rstest]
#[tokio::test]
async fn two_patch_events_total(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
s.all_patch_events.len(),
2,
"expected exactly 2 Kind::GitPatch events on GRASP \
(both from publish_patch_series; FF push must emit a PR not a patch); \
got {} (event ids: {:?})",
s.all_patch_events.len(),
s.all_patch_events
.iter()
.map(|e| e.id.to_hex())
.collect::<Vec<_>>(),
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn one_pr_event(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
s.pr_events.len(),
1,
"expected exactly one KIND_PULL_REQUEST event on GRASP after the \
size-triggered FF push; got {} (event ids: {:?})",
s.pr_events.len(),
s.pr_events
.iter()
.map(|e| e.id.to_hex())
.collect::<Vec<_>>(),
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn zero_pr_update_events(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
s.pr_update_count, 0,
"expected zero KIND_PULL_REQUEST_UPDATE events on GRASP after the \
patch→PR upgrade FF push; got {}",
s.pr_update_count,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn pr_event_e_tag_references_original_root(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let e_values: Vec<String> = s
.pr_event
.tags
.iter()
.filter(|t| t.as_slice().first().map(String::as_str) == Some("e"))
.filter_map(|t| t.as_slice().get(1).cloned())
.collect();
assert!(
e_values.contains(&s.original_root_patch_id.to_hex()),
"PR event should carry an `e` tag with the original root patch id {:?}; \
got e values: {:?}",
s.original_root_patch_id.to_hex(),
e_values,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn pr_event_p_tag_references_original_root_author(
#[future] snapshot: Arc<Snapshot>,
) -> Result<()> {
let s = snapshot.await;
let p_values: Vec<String> = s
.pr_event
.tags
.iter()
.filter(|t| t.as_slice().first().map(String::as_str) == Some("p"))
.filter_map(|t| t.as_slice().get(1).cloned())
.collect();
assert!(
p_values.contains(&s.original_root_patch_pubkey.to_hex()),
"PR event should carry a `p` tag with the original root patch author \
{:?}; got p values: {:?}",
s.original_root_patch_pubkey.to_hex(),
p_values,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn pr_event_branch_name_matches_original_series(
#[future] snapshot: Arc<Snapshot>,
) -> Result<()> {
let s = snapshot.await;
assert_eq!(
tag_value(&s.pr_event, "branch-name").as_deref(),
Some(s.original_branch_name.as_str()),
"PR event branch-name tag should be {:?}; got {:?}",
s.original_branch_name,
tag_value(&s.pr_event, "branch-name"),
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn pr_event_c_tag_is_new_commit(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
tag_value(&s.pr_event, "c").as_deref(),
Some(s.new_commit_oid.as_str()),
"PR event c tag should equal new commit OID {:?}; got {:?}",
s.new_commit_oid,
tag_value(&s.pr_event, "c"),
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn pr_event_authored_by_maintainer(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
assert_eq!(
s.pr_event.pubkey,
s.maintainer_pubkey,
"upgrade PR event should be authored by the maintainer ({}) who ran \
the push; got {}",
s.maintainer_pubkey.to_hex(),
s.pr_event.pubkey.to_hex(),
);
Ok(())
}
fn branch_shaped_pr_refs<'a>(
map: &'a std::collections::BTreeMap<String, String>,
branch: &str,
) -> Vec<(&'a String, &'a String)> {
let needle_prefix = format!("pr/{branch}(");
map.iter()
.filter(|(name, _)| {
let suffix = name
.strip_prefix("refs/heads/")
.or_else(|| name.strip_prefix("refs/"))
.unwrap_or(name.as_str());
suffix.starts_with(&needle_prefix) && suffix.ends_with(')')
})
.collect()
}
#[rstest]
#[tokio::test]
async fn fresh_clone_exactly_one_pr_branch(#[future] snapshot: Arc<Snapshot>) -> Result<()> {
let s = snapshot.await;
let pr_branch_refs = branch_shaped_pr_refs(&s.nostr_clone_ls_refs, &s.original_branch_name);
assert_eq!(
pr_branch_refs.len(),
2,
"expected exactly two branch-shaped proposal advertisements \
(one each under `refs/pr/` and `refs/heads/pr/` for `{}`) in the \
fresh clone; got {} (full ls-remote map: {:#?})",
s.original_branch_name,
pr_branch_refs.len(),
s.nostr_clone_ls_refs,
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn fresh_clone_pr_branch_uses_original_root_shorthand_and_latest_tip(
#[future] snapshot: Arc<Snapshot>,
) -> Result<()> {
let s = snapshot.await;
let pr_branch_refs = branch_shaped_pr_refs(&s.nostr_clone_ls_refs, &s.original_branch_name);
let (ref_name, oid) = pr_branch_refs.first().ok_or_else(|| {
anyhow!(
"no branch-shaped proposal advertisement in fresh clone ls-remote; \
full map: {:#?}",
s.nostr_clone_ls_refs,
)
})?;
let original_shorthand = &s.original_root_patch_id.to_hex()[..8];
let expected_suffix = format!("pr/{}({})", s.original_branch_name, original_shorthand);
assert!(
ref_name.ends_with(&expected_suffix),
"branch-shaped proposal advertisement {:?} should end in {:?} (using \
the original root patch id shorthand, not the PR upgrade event \
shorthand); full ls-remote map: {:#?}",
ref_name,
expected_suffix,
s.nostr_clone_ls_refs,
);
assert_eq!(
oid.as_str(),
s.new_commit_oid.as_str(),
"branch-shaped proposal advertisement {:?} should resolve to the \
new commit OID {:?} (read from the upgrade PR event's `c` tag); \
got {:?}",
ref_name,
s.new_commit_oid,
oid,
);
Ok(())
}