use git2::Signature; use ngit::git_events::KIND_PULL_REQUEST; use rstest::*; use super::*; #[tokio::test] #[serial] async fn new_branch_when_no_state_event_exists() -> Result<()> { generate_repo_with_state_event().await?; Ok(()) } mod two_branches_in_batch_one_added_one_updated { use super::*; // Fixture that runs expensive setup once and captures all verification data #[fixture] async fn scenario() -> TwoBranchesScenario { let git_repo = prep_git_repo().expect("failed to prep git repo"); let source_git_repo = GitTestRepo::recreate_as_bare(&git_repo).expect("failed to create bare git repo"); std::fs::write(git_repo.dir.join("commit.md"), "some content") .expect("failed to write commit.md"); let main_commit_id = git_repo .stage_and_commit("commit.md") .expect("failed to commit main"); git_repo .create_branch("vnext") .expect("failed to create vnext branch"); git_repo .checkout("vnext") .expect("failed to checkout vnext"); std::fs::write(git_repo.dir.join("vnext.md"), "some content") .expect("failed to write vnext.md"); let vnext_commit_id = git_repo .stage_and_commit("vnext.md") .expect("failed to commit vnext"); let events = vec![ generate_test_key_1_metadata_event("fred"), generate_test_key_1_relay_list_event(), generate_repo_ref_event_with_git_server(vec![ source_git_repo.dir.to_str().unwrap().to_string(), ]), generate_repo_ref_event_with_git_server_with_keys( vec![source_git_repo.dir.to_str().unwrap().to_string()], &TEST_KEY_2_KEYS, ), ]; // fallback (51,52) user write (53, 55) repo (55, 56) blaster (57) let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = ( Relay::new(8051, None, None), Relay::new(8052, None, None), Relay::new(8053, None, None), Relay::new(8055, None, None), Relay::new(8056, None, None), Relay::new(8057, None, None), ); r51.events = events.clone(); r55.events = events; let main_commit_id_clone = main_commit_id; let vnext_commit_id_clone = vnext_commit_id; let cli_tester_handle = std::thread::spawn(move || -> Result<(bool, bool, bool, bool)> { let mut p = cli_tester_after_nostr_fetch_and_sent_list_for_push_responds(&git_repo)?; p.send_line("push refs/heads/main:refs/heads/main")?; p.send_line("push refs/heads/vnext:refs/heads/vnext")?; p.send_line("")?; p.expect_eventually("\r\n\r\n")?; p.exit()?; for p in [51, 52, 53, 55, 56, 57] { relay::shutdown_relay(8000 + p)?; } // Capture verification data let main_on_server = source_git_repo.get_tip_of_local_branch("main")? == main_commit_id_clone; let vnext_on_server = source_git_repo.get_tip_of_local_branch("vnext")? == vnext_commit_id_clone; let main_remote_ref_matches = git_repo .git_repo .find_reference("refs/remotes/nostr/main")? .peel_to_commit()? .id() == main_commit_id_clone; let vnext_remote_ref_matches = git_repo .git_repo .find_reference("refs/remotes/nostr/vnext")? .peel_to_commit()? .id() == vnext_commit_id_clone; Ok(( main_on_server, vnext_on_server, main_remote_ref_matches, vnext_remote_ref_matches, )) }); // Launch relays let _ = join!( r51.listen_until_close(), r52.listen_until_close(), r53.listen_until_close(), r55.listen_until_close(), r56.listen_until_close(), r57.listen_until_close(), ); let (main_on_server, vnext_on_server, main_remote_ref_matches, vnext_remote_ref_matches) = cli_tester_handle .join() .unwrap() .expect("cli tester failed"); TwoBranchesScenario { main_commit_id: main_commit_id.to_string(), vnext_commit_id: vnext_commit_id.to_string(), main_on_server, vnext_on_server, main_remote_ref_matches, vnext_remote_ref_matches, } } #[rstest] #[tokio::test] #[serial] async fn updates_branch_on_git_server(#[future] scenario: TwoBranchesScenario) -> Result<()> { let s = scenario.await; assert!( s.main_on_server, "main branch should be updated on git server" ); assert!( s.vnext_on_server, "vnext branch should be updated on git server" ); Ok(()) } #[rstest] #[tokio::test] #[serial] async fn remote_refs_updated_in_local_git( #[future] scenario: TwoBranchesScenario, ) -> Result<()> { let s = scenario.await; assert!( s.main_remote_ref_matches, "main remote ref should match commit" ); assert!( s.vnext_remote_ref_matches, "vnext remote ref should match commit" ); Ok(()) } #[tokio::test] #[serial] async fn prints_git_helper_ok_respose() -> Result<()> { let git_repo = prep_git_repo()?; let source_git_repo = GitTestRepo::recreate_as_bare(&git_repo)?; std::fs::write(git_repo.dir.join("commit.md"), "some content")?; let main_commit_id = git_repo.stage_and_commit("commit.md")?; git_repo.create_branch("vnext")?; git_repo.checkout("vnext")?; std::fs::write(git_repo.dir.join("vnext.md"), "some content")?; git_repo.stage_and_commit("vnext.md")?; let events = vec![ generate_test_key_1_metadata_event("fred"), generate_test_key_1_relay_list_event(), generate_repo_ref_event_with_git_server(vec![ source_git_repo.dir.to_str().unwrap().to_string(), ]), generate_repo_ref_event_with_git_server_with_keys( vec![source_git_repo.dir.to_str().unwrap().to_string()], &TEST_KEY_2_KEYS, ), ]; // fallback (51,52) user write (53, 55) repo (55, 56) blaster (57) let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = ( Relay::new(8051, None, None), Relay::new(8052, None, None), Relay::new(8053, None, None), Relay::new(8055, None, None), Relay::new(8056, None, None), Relay::new(8057, None, None), ); r51.events = events.clone(); r55.events = events; let cli_tester_handle = std::thread::spawn(move || -> Result<()> { assert_ne!( source_git_repo.get_tip_of_local_branch("main")?, main_commit_id ); let mut p = cli_tester_after_nostr_fetch_and_sent_list_for_push_responds(&git_repo)?; p.send_line("push refs/heads/main:refs/heads/main")?; p.send_line("push refs/heads/vnext:refs/heads/vnext")?; p.send_line("")?; p.expect_eventually("ok ")?; p.expect("refs/heads/main\r\n")?; p.expect("ok refs/heads/vnext\r\n")?; p.expect_eventually("\r\n\r\n")?; p.exit()?; for p in [51, 52, 53, 55, 56, 57] { relay::shutdown_relay(8000 + p)?; } Ok(()) }); // launch relays let _ = join!( r51.listen_until_close(), r52.listen_until_close(), r53.listen_until_close(), r55.listen_until_close(), r56.listen_until_close(), r57.listen_until_close(), ); cli_tester_handle.join().unwrap()?; Ok(()) } #[tokio::test] #[serial] async fn when_no_existing_state_event_state_on_git_server_published_in_nostr_state_event() -> Result<()> { let git_repo = prep_git_repo()?; let source_git_repo = GitTestRepo::recreate_as_bare(&git_repo)?; std::fs::write(git_repo.dir.join("commit.md"), "some content")?; let main_commit_id = git_repo.stage_and_commit("commit.md")?; git_repo.create_branch("vnext")?; git_repo.checkout("vnext")?; std::fs::write(git_repo.dir.join("vnext.md"), "some content")?; let vnext_commit_id = git_repo.stage_and_commit("vnext.md")?; let events = vec![ generate_test_key_1_metadata_event("fred"), generate_test_key_1_relay_list_event(), generate_repo_ref_event_with_git_server(vec![ source_git_repo.dir.to_str().unwrap().to_string(), ]), generate_repo_ref_event_with_git_server_with_keys( vec![source_git_repo.dir.to_str().unwrap().to_string()], &TEST_KEY_2_KEYS, ), ]; // fallback (51,52) user write (53, 55) repo (55, 56) blaster (57) let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = ( Relay::new(8051, None, None), Relay::new(8052, None, None), Relay::new(8053, None, None), Relay::new(8055, None, None), Relay::new(8056, None, None), Relay::new(8057, None, None), ); r51.events = events.clone(); r55.events = events; let cli_tester_handle = std::thread::spawn(move || -> Result<()> { let mut p = cli_tester_after_nostr_fetch_and_sent_list_for_push_responds(&git_repo)?; p.send_line("push refs/heads/main:refs/heads/main")?; p.send_line("push refs/heads/vnext:refs/heads/vnext")?; p.send_line("")?; p.expect_eventually_and_print("\r\n\r\n")?; p.exit()?; for p in [51, 52, 53, 55, 56, 57] { relay::shutdown_relay(8000 + p)?; } Ok(()) }); // launch relays let _ = join!( r51.listen_until_close(), r52.listen_until_close(), r53.listen_until_close(), r55.listen_until_close(), r56.listen_until_close(), r57.listen_until_close(), ); cli_tester_handle.join().unwrap()?; let state_event = r56 .events .iter() .find(|e| e.kind.eq(&STATE_KIND)) .context("state event not created")?; assert_eq!( state_event.tags.identifier(), generate_repo_ref_event().tags.identifier(), ); // println!("{:#?}", state_event); assert_eq!( state_event .tags .iter() .filter(|t| t.kind().to_string().as_str().ne("d")) .map(|t| t.as_slice().to_vec()) .collect::>>(), HashSet::from([ vec!["HEAD".to_string(), "ref: refs/heads/main".to_string()], vec!["refs/heads/main".to_string(), main_commit_id.to_string()], vec!["refs/heads/vnext".to_string(), vnext_commit_id.to_string()], ]), ); Ok(()) } #[tokio::test] #[serial] async fn existing_state_event_published_in_nostr_state_event() -> Result<()> { let (state_event, source_git_repo) = generate_repo_with_state_event().await?; let git_repo = prep_git_repo()?; let example_branch_commit_id = git_repo.get_tip_of_local_branch("main")?.to_string(); // same as example std::fs::write(git_repo.dir.join("new.md"), "some content")?; let main_commit_id = git_repo.stage_and_commit("new.md")?; git_repo.create_branch("vnext")?; git_repo.checkout("vnext")?; std::fs::write(git_repo.dir.join("more.md"), "some content")?; let vnext_commit_id = git_repo.stage_and_commit("more.md")?; let events = vec![ generate_test_key_1_metadata_event("fred"), generate_test_key_1_relay_list_event(), generate_repo_ref_event_with_git_server(vec![ source_git_repo.dir.to_str().unwrap().to_string(), ]), generate_repo_ref_event_with_git_server_with_keys( vec![source_git_repo.dir.to_str().unwrap().to_string()], &TEST_KEY_2_KEYS, ), state_event.clone(), ]; // fallback (51,52) user write (53, 55) repo (55, 56) blaster (57) let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = ( Relay::new(8051, None, None), Relay::new(8052, None, None), Relay::new(8053, None, None), Relay::new(8055, None, None), Relay::new(8056, None, None), Relay::new(8057, None, None), ); r51.events = events.clone(); r55.events = events; let cli_tester_handle = std::thread::spawn(move || -> Result<()> { let mut p = cli_tester_after_nostr_fetch_and_sent_list_for_push_responds(&git_repo)?; p.send_line("push refs/heads/main:refs/heads/main")?; p.send_line("push refs/heads/vnext:refs/heads/vnext")?; p.send_line("")?; p.expect_eventually_and_print("\r\n\r\n")?; p.exit()?; for p in [51, 52, 53, 55, 56, 57] { relay::shutdown_relay(8000 + p)?; } // local refs updated assert_eq!( git_repo .git_repo .find_reference("refs/remotes/nostr/main")? .peel_to_commit()? .id(), main_commit_id, ); assert_eq!( git_repo .git_repo .find_reference("refs/remotes/nostr/vnext")? .peel_to_commit()? .id(), vnext_commit_id ); Ok(()) }); // launch relays let _ = join!( r51.listen_until_close(), r52.listen_until_close(), r53.listen_until_close(), r55.listen_until_close(), r56.listen_until_close(), r57.listen_until_close(), ); cli_tester_handle.join().unwrap()?; // git_server updated assert_eq!( source_git_repo.get_tip_of_local_branch("main")?, main_commit_id ); assert_eq!( source_git_repo.get_tip_of_local_branch("vnext")?, vnext_commit_id ); // state annoucement updated let state_event = r56 .events .iter() .find(|e| e.kind.eq(&STATE_KIND)) .context("state event not created")?; // println!("{:#?}", state_event); assert_eq!( state_event .tags .iter() .filter(|t| t.kind().to_string().as_str().ne("d")) .map(|t| t.as_slice().to_vec()) .collect::>>(), HashSet::from([ vec!["HEAD".to_string(), "ref: refs/heads/main".to_string()], vec!["refs/heads/main".to_string(), main_commit_id.to_string()], vec![ "refs/heads/example-branch".to_string(), example_branch_commit_id.to_string() ], vec!["refs/heads/vnext".to_string(), vnext_commit_id.to_string()], ]), ); Ok(()) } } mod delete_one_branch { use super::*; // Scenario struct - holds only cloneable verification data from expensive setup #[derive(Clone)] struct DeleteBranchScenario { vnext_commit_id_str: String, branch_was_deleted_on_server: bool, remote_ref_was_deleted_locally: bool, } // Fixture: runs expensive setup once and captures results for verification #[fixture] async fn scenario() -> DeleteBranchScenario { let git_repo = prep_git_repo().unwrap(); git_repo.create_branch("vnext").unwrap(); git_repo.checkout("vnext").unwrap(); std::fs::write(git_repo.dir.join("vnext.md"), "some content").unwrap(); let vnext_commit_id = git_repo.stage_and_commit("vnext.md").unwrap(); let vnext_commit_id_str = vnext_commit_id.to_string(); let source_git_repo = GitTestRepo::recreate_as_bare(&git_repo).unwrap(); // Add remote ref for local deletion test git_repo .git_repo .reference("refs/remotes/nostr/vnext", vnext_commit_id, true, "") .unwrap(); let events = vec![ generate_test_key_1_metadata_event("fred"), generate_test_key_1_relay_list_event(), generate_repo_ref_event_with_git_server(vec![ source_git_repo.dir.to_str().unwrap().to_string(), ]), generate_repo_ref_event_with_git_server_with_keys( vec![source_git_repo.dir.to_str().unwrap().to_string()], &TEST_KEY_2_KEYS, ), ]; let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = ( Relay::new(8051, None, None), Relay::new(8052, None, None), Relay::new(8053, None, None), Relay::new(8055, None, None), Relay::new(8056, None, None), Relay::new(8057, None, None), ); r51.events = events.clone(); r55.events = events; // Run the expensive operation ONCE - capture verification data let (server_deleted, local_deleted) = { let cli_tester_handle = std::thread::spawn(move || -> Result<(bool, bool)> { let mut p = cli_tester_after_nostr_fetch_and_sent_list_for_push_responds(&git_repo) .unwrap(); p.send_line("push :refs/heads/vnext").unwrap(); p.send_line("").unwrap(); p.expect_eventually_and_print("\r\n\r\n").unwrap(); p.exit().unwrap(); for p in [51, 52, 53, 55, 56, 57] { relay::shutdown_relay(8000 + p).unwrap(); } // Capture results for later verification let server_deleted = source_git_repo .git_repo .find_reference("refs/heads/vnext") .is_err(); let local_deleted = git_repo .git_repo .find_reference("refs/remotes/nostr/vnext") .is_err(); Ok((server_deleted, local_deleted)) }); let _ = join!( r51.listen_until_close(), r52.listen_until_close(), r53.listen_until_close(), r55.listen_until_close(), r56.listen_until_close(), r57.listen_until_close(), ); cli_tester_handle.join().unwrap().unwrap() }; DeleteBranchScenario { vnext_commit_id_str, branch_was_deleted_on_server: server_deleted, remote_ref_was_deleted_locally: local_deleted, } } // POC Test 1: Verify branch deleted on git server #[rstest] #[tokio::test] #[serial] async fn deletes_branch_on_git_server(#[future] scenario: DeleteBranchScenario) -> Result<()> { let s = scenario.await; assert!( s.branch_was_deleted_on_server, "Branch should be deleted on git server" ); Ok(()) } // POC Test 2: Verify remote refs deleted locally #[rstest] #[tokio::test] #[serial] async fn remote_refs_updated_in_local_git( #[future] scenario: DeleteBranchScenario, ) -> Result<()> { let s = scenario.await; assert!( s.remote_ref_was_deleted_locally, "Remote ref should be deleted locally" ); Ok(()) } // POC Test 3: Verify commit ID was valid #[rstest] #[tokio::test] #[serial] async fn verify_commit_id_captured(#[future] scenario: DeleteBranchScenario) -> Result<()> { let s = scenario.await; assert_eq!( s.vnext_commit_id_str.len(), 40, "Should have valid commit SHA" ); Ok(()) } mod when_existing_state_event { use super::*; #[tokio::test] #[serial] async fn state_event_updated_and_branch_deleted_and_ok_printed() -> Result<()> { let (state_event, source_git_repo) = generate_repo_with_state_event().await?; let git_repo = prep_git_repo()?; let main_commit_id = git_repo.get_tip_of_local_branch("main")?.to_string(); // same as example let events = vec![ generate_test_key_1_metadata_event("fred"), generate_test_key_1_relay_list_event(), generate_repo_ref_event_with_git_server(vec![ source_git_repo.dir.to_str().unwrap().to_string(), ]), generate_repo_ref_event_with_git_server_with_keys( vec![source_git_repo.dir.to_str().unwrap().to_string()], &TEST_KEY_2_KEYS, ), state_event.clone(), ]; // fallback (51,52) user write (53, 55) repo (55, 56) blaster (57) let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = ( Relay::new(8051, None, None), Relay::new(8052, None, None), Relay::new(8053, None, None), Relay::new(8055, None, None), Relay::new(8056, None, None), Relay::new(8057, None, None), ); r51.events = events.clone(); r55.events = events; let cli_tester_handle = std::thread::spawn(move || -> Result<()> { let mut p = cli_tester_after_nostr_fetch_and_sent_list_for_push_responds(&git_repo)?; p.send_line("push :refs/heads/example-branch")?; p.send_line("")?; p.expect_eventually("ok ")?; p.expect("refs/heads/example-branch\r\n")?; p.expect_eventually("\r\n\r\n")?; p.exit()?; for p in [51, 52, 53, 55, 56, 57] { relay::shutdown_relay(8000 + p)?; } Ok(()) }); // launch relays let _ = join!( r51.listen_until_close(), r52.listen_until_close(), r53.listen_until_close(), r55.listen_until_close(), r56.listen_until_close(), r57.listen_until_close(), ); cli_tester_handle.join().unwrap()?; let state_event = r56 .events .iter() .find(|e| e.kind.eq(&STATE_KIND)) .context("state event not created")?; // println!("{:#?}", state_event); assert_eq!( state_event .tags .iter() .filter(|t| t.kind().to_string().as_str().ne("d")) .map(|t| t.as_slice().to_vec()) .collect::>>(), HashSet::from([ vec!["HEAD".to_string(), "ref: refs/heads/main".to_string()], vec!["refs/heads/main".to_string(), main_commit_id.to_string()], ]), ); Ok(()) } mod already_deleted_on_git_server { use super::*; #[tokio::test] #[serial] async fn existing_state_event_updated_and_ok_printed() -> Result<()> { let (state_event, source_git_repo) = generate_repo_with_state_event().await?; { // delete branch on git server let tmp_repo = GitTestRepo::clone_repo(&source_git_repo)?; let mut remote = tmp_repo.git_repo.find_remote("origin")?; remote.push(&[":refs/heads/example-branch"], None)?; } let git_repo = prep_git_repo()?; let main_commit_id = git_repo.get_tip_of_local_branch("main")?.to_string(); // same as example let events = vec![ generate_test_key_1_metadata_event("fred"), generate_test_key_1_relay_list_event(), generate_repo_ref_event_with_git_server(vec![ source_git_repo.dir.to_str().unwrap().to_string(), ]), generate_repo_ref_event_with_git_server_with_keys( vec![source_git_repo.dir.to_str().unwrap().to_string()], &TEST_KEY_2_KEYS, ), state_event.clone(), ]; // fallback (51,52) user write (53, 55) repo (55, 56) blaster (57) let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = ( Relay::new(8051, None, None), Relay::new(8052, None, None), Relay::new(8053, None, None), Relay::new(8055, None, None), Relay::new(8056, None, None), Relay::new(8057, None, None), ); r51.events = events.clone(); r55.events = events; let cli_tester_handle = std::thread::spawn(move || -> Result<()> { let mut p = cli_tester_after_nostr_fetch_and_sent_list_for_push_responds(&git_repo)?; p.send_line("push :refs/heads/example-branch")?; p.send_line("")?; p.expect_eventually("ok ")?; p.expect("refs/heads/example-branch\r\n")?; p.expect_eventually("\r\n")?; p.exit()?; for p in [51, 52, 53, 55, 56, 57] { relay::shutdown_relay(8000 + p)?; } Ok(()) }); // launch relays let _ = join!( r51.listen_until_close(), r52.listen_until_close(), r53.listen_until_close(), r55.listen_until_close(), r56.listen_until_close(), r57.listen_until_close(), ); cli_tester_handle.join().unwrap()?; let state_event = r56 .events .iter() .find(|e| e.kind.eq(&STATE_KIND)) .context("state event not created")?; // println!("{:#?}", state_event); assert_eq!( state_event .tags .iter() .filter(|t| t.kind().to_string().as_str().ne("d")) .map(|t| t.as_slice().to_vec()) .collect::>>(), HashSet::from([ vec!["HEAD".to_string(), "ref: refs/heads/main".to_string()], vec!["refs/heads/main".to_string(), main_commit_id.to_string()], ]), ); Ok(()) } } } } #[tokio::test] #[serial] async fn pushes_to_all_git_servers_listed_and_ok_printed() -> Result<()> { let (state_event, source_git_repo) = generate_repo_with_state_event().await?; let second_source_git_repo = GitTestRepo::duplicate(&source_git_repo)?; // uppdate announcement with extra git server let git_repo = prep_git_repo()?; std::fs::write(git_repo.dir.join("new.md"), "some content")?; let main_commit_id = git_repo.stage_and_commit("new.md")?; let events = vec![ generate_test_key_1_metadata_event("fred"), generate_test_key_1_relay_list_event(), generate_repo_ref_event_with_git_server(vec![ source_git_repo.dir.to_str().unwrap().to_string(), second_source_git_repo.dir.to_str().unwrap().to_string(), ]), generate_repo_ref_event_with_git_server_with_keys( vec![source_git_repo.dir.to_str().unwrap().to_string()], &TEST_KEY_2_KEYS, ), state_event.clone(), ]; // fallback (51,52) user write (53, 55) repo (55, 56) blaster (57) let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = ( Relay::new(8051, None, None), Relay::new(8052, None, None), Relay::new(8053, None, None), Relay::new(8055, None, None), Relay::new(8056, None, None), Relay::new(8057, None, None), ); r51.events = events.clone(); r55.events = events; let cli_tester_handle = std::thread::spawn(move || -> Result<()> { let mut p = cli_tester_after_nostr_fetch_and_sent_list_for_push_responds(&git_repo)?; p.send_line("push refs/heads/main:refs/heads/main")?; p.send_line("")?; p.expect_eventually("ok ")?; p.expect("refs/heads/main\r\n")?; p.expect_eventually("\r\n\r\n")?; p.exit()?; for p in [51, 52, 53, 55, 56, 57] { relay::shutdown_relay(8000 + p)?; } Ok(()) }); // launch relays let _ = join!( r51.listen_until_close(), r52.listen_until_close(), r53.listen_until_close(), r55.listen_until_close(), r56.listen_until_close(), r57.listen_until_close(), ); cli_tester_handle.join().unwrap()?; // git_server updated assert_eq!( source_git_repo.get_tip_of_local_branch("main")?, main_commit_id ); assert_eq!( second_source_git_repo.get_tip_of_local_branch("main")?, main_commit_id ); Ok(()) } #[tokio::test] #[serial] async fn proposal_three_way_merge_commit_pushed_to_main_leads_to_status_event_issued() -> Result<()> { let (events, source_git_repo) = prep_source_repo_and_events_including_proposals().await?; let _source_path = source_git_repo.dir.to_str().unwrap().to_string(); let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = ( Relay::new(8051, None, None), Relay::new(8052, None, None), Relay::new(8053, None, None), Relay::new(8055, None, None), Relay::new(8056, None, None), Relay::new(8057, None, None), ); r51.events = events.clone(); r55.events = events.clone(); #[allow(clippy::mutable_key_type)] let before = r55.events.iter().cloned().collect::>(); let cli_tester_handle = std::thread::spawn(move || -> Result<(String, Oid)> { let branch_name = get_proposal_branch_name_from_events(&events, FEATURE_BRANCH_NAME_1)?; let git_repo = clone_git_repo_with_nostr_url()?; git_repo.checkout_remote_branch(&branch_name)?; git_repo.checkout("refs/heads/main")?; std::fs::write(git_repo.dir.join("new.md"), "some content")?; git_repo.stage_and_commit("new.md")?; CliTester::new_git_with_remote_helper_from_dir( &git_repo.dir, ["merge", &branch_name, "-m", "proposal merge commit message"], ) .expect_end_eventually_and_print()?; let oid = git_repo.get_tip_of_local_branch("main")?; let mut p = CliTester::new_git_with_remote_helper_from_dir(&git_repo.dir, ["push"]); cli_expect_nostr_fetch(&mut p)?; p.expect("git servers: listing refs...\r\n")?; p.expect_eventually("merge commit ")?; // shorthand merge commit id appears in this gap p.expect_eventually(": create nostr proposal status event\r\n")?; // status updates printed here p.expect_eventually(format!("To {}\r\n", get_nostr_remote_url()?).as_str())?; let output = p.expect_end_eventually()?; for p in [51, 52, 53, 55, 56, 57] { relay::shutdown_relay(8000 + p)?; } Ok((output, oid)) }); // launch relays let _ = join!( r51.listen_until_close(), r52.listen_until_close(), r53.listen_until_close(), r55.listen_until_close(), r56.listen_until_close(), r57.listen_until_close(), ); let (output, merge_oid) = cli_tester_handle.join().unwrap()?; assert_eq!( output, format!( " 431b84e..{} main -> main\r\n", &merge_oid.to_string()[..7] ) ); let new_events = r55 .events .iter() .cloned() .collect::>() .difference(&before) .cloned() .collect::>(); assert_eq!(new_events.len(), 2, "{new_events:?}"); let proposal_cover_letter_event = r55 .events .iter() .find(|e| { e.tags .iter() .find(|t| t.as_slice()[0].eq("branch-name")) .is_some_and(|t| t.as_slice()[1].eq(FEATURE_BRANCH_NAME_1)) }) .unwrap(); let merge_status = new_events .iter() .find(|e| e.kind.eq(&Kind::GitStatusApplied)) .unwrap(); assert_eq!( vec!["merge-commit-id".to_string(), merge_oid.to_string()], merge_status .tags .iter() .find(|t| t.as_slice()[0].eq("merge-commit-id")) .unwrap() .clone() .to_vec(), "status sets correct merge-commit-id tag {merge_status:?}" ); let proposal_tip = r55 .events .iter() .filter(|e| { e.tags .iter() .any(|t| t.as_slice()[1].eq(&proposal_cover_letter_event.id.to_string())) && e.kind.eq(&Kind::GitPatch) }) .next_back() .unwrap(); assert_eq!( proposal_tip.id.to_string(), merge_status .tags .iter() .find(|t| t.as_slice()[0].eq("q")) .unwrap() .as_slice()[1], "status mentions proposal tip event \r\nmerge status:\r\n{}\r\nproposal tip:\r\n{}", merge_status.as_json(), proposal_tip.as_json(), ); assert_eq!( proposal_cover_letter_event.id.to_string(), merge_status .tags .iter() .find(|t| t.is_root()) .unwrap() .as_slice()[1], "status tags proposal id as root \r\nmerge status:\r\n{}\r\nproposal:\r\n{}", merge_status.as_json(), proposal_cover_letter_event.as_json(), ); Ok(()) } #[tokio::test] #[serial] async fn proposal_fast_forward_merge_commits_pushed_to_main_leads_to_status_event_issued() -> Result<()> { // let (events, source_git_repo) = prep_source_repo_and_events_including_proposals().await?; let _source_path = source_git_repo.dir.to_str().unwrap().to_string(); let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = ( Relay::new(8051, None, None), Relay::new(8052, None, None), Relay::new(8053, None, None), Relay::new(8055, None, None), Relay::new(8056, None, None), Relay::new(8057, None, None), ); r51.events = events.clone(); r55.events = events.clone(); #[allow(clippy::mutable_key_type)] let before = r55.events.iter().cloned().collect::>(); let cli_tester_handle = std::thread::spawn(move || -> Result<(String, Oid)> { let branch_name = get_proposal_branch_name_from_events(&events, FEATURE_BRANCH_NAME_1)?; let git_repo = clone_git_repo_with_nostr_url()?; git_repo.checkout_remote_branch(&branch_name)?; git_repo.checkout("refs/heads/main")?; CliTester::new_git_with_remote_helper_from_dir( &git_repo.dir, ["merge", &branch_name, "-m", "proposal merge commit message"], ) .expect_end_eventually_and_print()?; let oid = git_repo.get_tip_of_local_branch("main")?; let mut p = CliTester::new_git_with_remote_helper_from_dir(&git_repo.dir, ["push"]); cli_expect_nostr_fetch(&mut p)?; p.expect("git servers: listing refs...\r\n")?; p.expect_eventually(format!( "fast-forward merge: create nostr proposal status event for {branch_name}\r\n" ))?; // status updates printed here p.expect_eventually(format!("To {}\r\n", get_nostr_remote_url()?).as_str())?; let output = p.expect_end_eventually()?; for p in [51, 52, 53, 55, 56, 57] { relay::shutdown_relay(8000 + p)?; } Ok((output, oid)) }); // launch relays let _ = join!( r51.listen_until_close(), r52.listen_until_close(), r53.listen_until_close(), r55.listen_until_close(), r56.listen_until_close(), r57.listen_until_close(), ); let (output, tip_oid) = cli_tester_handle.join().unwrap()?; assert_eq!( output, format!( " 431b84e..{} main -> main\r\n", &tip_oid.to_string()[..7] ) ); let new_events = r55 .events .iter() .cloned() .collect::>() .difference(&before) .cloned() .collect::>(); assert_eq!(new_events.len(), 2, "{new_events:?}"); let proposal_cover_letter_event = r55 .events .iter() .find(|e| { e.tags .iter() .find(|t| t.as_slice()[0].eq("branch-name")) .is_some_and(|t| t.as_slice()[1].eq(FEATURE_BRANCH_NAME_1)) }) .unwrap(); let proposal_patches: Vec<&Event> = r55 .events .iter() .filter(|e| { e.kind == Kind::GitPatch && e.tags .iter() .any(|t| t.as_slice()[1].eq(&proposal_cover_letter_event.id.to_string())) }) .collect(); let merge_status = new_events .iter() .find(|e| e.kind.eq(&Kind::GitStatusApplied)) .unwrap(); let patch_commit_ids_parents_first = proposal_patches .iter() .map(|e| { e.tags .iter() .find(|t| t.as_slice()[0].eq("commit")) .unwrap() .as_slice()[1] .to_string() }) .collect::>(); assert_eq!( // HashSet::::from_iter(vec![ // "merge-commit-id".to_string(), // "6bd4f54bdf6a9ef2ec09e88e7a8d05376b0c1ff4".to_string(), // "f1d302586abad23c259ebd684c2bba233f9ed3d1".to_string(), // ]), HashSet::from_iter( [ vec!["merge-commit-id".to_string()], patch_commit_ids_parents_first ] .concat() .iter() .cloned() ), HashSet::::from_iter( merge_status .tags .iter() .find(|t| t.as_slice()[0].eq("merge-commit-id")) .unwrap() .clone() .to_vec() .iter() .cloned() ), "status sets correct merge-commit-id tag {merge_status:?}" ); for patch_id in proposal_patches .iter() .map(|e| e.id.to_string()) .collect::>() { assert!( merge_status .tags .iter() .any(|t| t.as_slice()[0].eq("q") && t.as_slice()[1] == patch_id), "merge status doesnt mention proposal patch {patch_id} \r\nmerge status:\r\n{}", merge_status.as_json(), ); } assert_eq!( proposal_cover_letter_event.id.to_string(), merge_status .tags .iter() .find(|t| t.is_root()) .unwrap() .as_slice()[1], "status tags proposal id as root \r\nmerge status:\r\n{}\r\nproposal:\r\n{}", merge_status.as_json(), proposal_cover_letter_event.as_json(), ); Ok(()) } #[tokio::test] #[serial] async fn proposal_commits_applied_and_pushed_to_main_leads_to_status_event_issued() -> Result<()> { // let (events, source_git_repo) = prep_source_repo_and_events_including_proposals().await?; let _source_path = source_git_repo.dir.to_str().unwrap().to_string(); let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = ( Relay::new(8051, None, None), Relay::new(8052, None, None), Relay::new(8053, None, None), Relay::new(8055, None, None), Relay::new(8056, None, None), Relay::new(8057, None, None), ); r51.events = events.clone(); r55.events = events.clone(); #[allow(clippy::mutable_key_type)] let before = r55.events.iter().cloned().collect::>(); let cli_tester_handle = std::thread::spawn(move || -> Result<(String, Oid, Oid)> { let branch_name = get_proposal_branch_name_from_events(&events, FEATURE_BRANCH_NAME_1)?; let git_repo = clone_git_repo_with_nostr_url()?; create_and_populate_branch( &git_repo, "tmptmp", "a", false, Some(&Signature::now("Different User", "other.user@nostr.com")?), )?; let applied_proposal_tip = git_repo.get_tip_of_local_branch("tmptmp")?; git_repo.git_repo.branch( "main", &git_repo.git_repo.find_commit(applied_proposal_tip)?, true, )?; let main_oid = git_repo.checkout("refs/heads/main")?; assert_eq!(applied_proposal_tip, main_oid); let mut p = CliTester::new_git_with_remote_helper_from_dir(&git_repo.dir, ["push"]); cli_expect_nostr_fetch(&mut p)?; p.expect("git servers: listing refs...\r\n")?; p.expect_eventually(format!( "applied commits from proposal: create nostr proposal status event for {branch_name}\r\n" ))?; // status updates printed here p.expect_eventually(format!("To {}\r\n", get_nostr_remote_url()?).as_str())?; let output = p.expect_end_eventually()?; for p in [51, 52, 53, 55, 56, 57] { relay::shutdown_relay(8000 + p)?; } let first_applied_commit_oid = git_repo .git_repo .find_commit(applied_proposal_tip)? .parent(0)? .id(); Ok((output, first_applied_commit_oid, applied_proposal_tip)) }); // launch relays let _ = join!( r51.listen_until_close(), r52.listen_until_close(), r53.listen_until_close(), r55.listen_until_close(), r56.listen_until_close(), r57.listen_until_close(), ); let (output, first_oid, tip_oid) = cli_tester_handle.join().unwrap()?; assert_eq!( output, format!( " 431b84e..{} main -> main\r\n", &tip_oid.to_string()[..7] ) ); let new_events = r55 .events .iter() .cloned() .collect::>() .difference(&before) .cloned() .collect::>(); assert_eq!(new_events.len(), 2, "{new_events:?}"); let proposal_cover_letter_event = r55 .events .iter() .find(|e| { e.tags .iter() .find(|t| t.as_slice()[0].eq("branch-name")) .is_some_and(|t| t.as_slice()[1].eq(FEATURE_BRANCH_NAME_1)) }) .unwrap(); let proposal_patches: Vec<&Event> = r55 .events .iter() .filter(|e| { e.kind == Kind::GitPatch && e.tags .iter() .any(|t| t.as_slice()[1].eq(&proposal_cover_letter_event.id.to_string())) }) .collect(); let merge_status = new_events .iter() .find(|e| e.kind.eq(&Kind::GitStatusApplied)) .unwrap(); assert_eq!( HashSet::::from_iter(vec![ "applied-as-commits".to_string(), first_oid.to_string(), tip_oid.to_string(), ]), HashSet::::from_iter( merge_status .tags .iter() .find(|t| t.as_slice()[0].eq("applied-as-commits")) .unwrap() .clone() .to_vec() .iter() .cloned(), ), "status sets correct applied-as-commits tag {merge_status:?}" ); for patch_id in proposal_patches .iter() .map(|e| e.id.to_string()) .collect::>() { assert!( merge_status .tags .iter() .any(|t| t.as_slice()[0].eq("q") && t.as_slice()[1] == patch_id), "merge status doesnt mention proposal patch {patch_id} \r\nmerge status:\r\n{}", merge_status.as_json(), ); } assert_eq!( proposal_cover_letter_event.id.to_string(), merge_status .tags .iter() .find(|t| t.is_root()) .unwrap() .as_slice()[1], "status tags proposal id as root \r\nmerge status:\r\n{}\r\nproposal:\r\n{}", merge_status.as_json(), proposal_cover_letter_event.as_json(), ); Ok(()) } #[tokio::test] #[serial] async fn push_2_commits_to_existing_proposal() -> Result<()> { let (events, source_git_repo) = prep_source_repo_and_events_including_proposals().await?; let _source_path = source_git_repo.dir.to_str().unwrap().to_string(); let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = ( Relay::new(8051, None, None), Relay::new(8052, None, None), Relay::new(8053, None, None), Relay::new(8055, None, None), Relay::new(8056, None, None), Relay::new(8057, None, None), ); r51.events = events.clone(); r55.events = events.clone(); #[allow(clippy::mutable_key_type)] let before = r55.events.iter().cloned().collect::>(); let cli_tester_handle = std::thread::spawn(move || -> Result<(String, String)> { let branch_name = get_proposal_branch_name_from_events(&events, FEATURE_BRANCH_NAME_1)?; let git_repo = clone_git_repo_with_nostr_url()?; git_repo.checkout_remote_branch(&branch_name)?; std::fs::write(git_repo.dir.join("new.md"), "some content")?; git_repo.stage_and_commit("new.md")?; std::fs::write(git_repo.dir.join("new2.md"), "some content")?; git_repo.stage_and_commit("new2.md")?; let mut p = CliTester::new_git_with_remote_helper_from_dir(&git_repo.dir, ["push"]); cli_expect_nostr_fetch(&mut p)?; p.expect("git servers: listing refs...\r\n")?; p.expect_eventually_and_print(format!("To {}\r\n", get_nostr_remote_url()?).as_str())?; let output = p.expect_end_eventually()?; for p in [51, 52, 53, 55, 56, 57] { relay::shutdown_relay(8000 + p)?; } Ok((output, branch_name)) }); // launch relays let _ = join!( r51.listen_until_close(), r52.listen_until_close(), r53.listen_until_close(), r55.listen_until_close(), r56.listen_until_close(), r57.listen_until_close(), ); let (output, branch_name) = cli_tester_handle.join().unwrap()?; assert_eq!( output, format!(" 2d1b467..9d83ff4 {branch_name} -> {branch_name}\r\n").as_str(), ); let new_events = r55 .events .iter() .cloned() .collect::>() .difference(&before) .cloned() .collect::>(); assert_eq!(new_events.len(), 2); let first_new_patch = new_events .iter() .find(|e| e.content.contains("new.md")) .unwrap(); let second_new_patch = new_events .iter() .find(|e| e.content.contains("new2.md")) .unwrap(); assert!( first_new_patch.content.contains("[PATCH 3/4]"), "first patch labeled with [PATCH 3/4]" ); assert!( second_new_patch.content.contains("[PATCH 4/4]"), "second patch labeled with [PATCH 4/4]" ); let proposal = r55 .events .iter() .find(|e| { e.tags .iter() .find(|t| t.as_slice()[0].eq("branch-name")) .is_some_and(|t| t.as_slice()[1].eq(FEATURE_BRANCH_NAME_1)) }) .unwrap(); assert_eq!( proposal.id.to_string(), first_new_patch .tags .iter() .find(|t| t.is_root()) .unwrap() .as_slice()[1], "first patch sets proposal id as root" ); assert_eq!( first_new_patch.id.to_string(), second_new_patch .tags .iter() .find(|t| t.is_reply()) .unwrap() .as_slice()[1], "second new patch replies to the first new patch" ); let previous_proposal_tip_event = r55 .events .iter() .find(|e| { e.tags .iter() .any(|t| t.as_slice()[1].eq(&proposal.id.to_string())) && e.content.contains("[PATCH 2/2]") }) .unwrap(); assert_eq!( previous_proposal_tip_event.id.to_string(), first_new_patch .tags .iter() .find(|t| t.is_reply()) .unwrap() .as_slice()[1], "first patch replies to the previous tip of proposal" ); Ok(()) } #[tokio::test] #[serial] async fn force_push_creates_proposal_revision() -> Result<()> { let (events, source_git_repo) = prep_source_repo_and_events_including_proposals().await?; let _source_path = source_git_repo.dir.to_str().unwrap().to_string(); let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = ( Relay::new(8051, None, None), Relay::new(8052, None, None), Relay::new(8053, None, None), Relay::new(8055, None, None), Relay::new(8056, None, None), Relay::new(8057, None, None), ); r51.events = events.clone(); r55.events = events.clone(); #[allow(clippy::mutable_key_type)] let before = r55.events.iter().cloned().collect::>(); let cli_tester_handle = std::thread::spawn(move || -> Result<(String, String)> { let branch_name = get_proposal_branch_name_from_events(&events, FEATURE_BRANCH_NAME_1)?; let git_repo = clone_git_repo_with_nostr_url()?; let oid = git_repo.checkout_remote_branch(&branch_name)?; // remove last commit git_repo.checkout("main")?; git_repo.git_repo.branch( &branch_name, &git_repo.git_repo.find_commit(oid)?.parent(0)?, true, )?; git_repo.checkout(&branch_name)?; std::fs::write(git_repo.dir.join("new.md"), "some content")?; git_repo.stage_and_commit("new.md")?; std::fs::write(git_repo.dir.join("new2.md"), "some content")?; git_repo.stage_and_commit("new2.md")?; let mut p = CliTester::new_git_with_remote_helper_from_dir(&git_repo.dir, ["push", "--force"]); cli_expect_nostr_fetch(&mut p)?; p.expect("git servers: listing refs...\r\n")?; p.expect_eventually_and_print(format!("To {}\r\n", get_nostr_remote_url()?).as_str())?; let output = p.expect_end_eventually()?; for p in [51, 52, 53, 55, 56, 57] { relay::shutdown_relay(8000 + p)?; } Ok((output, branch_name)) }); // launch relays let _ = join!( r51.listen_until_close(), r52.listen_until_close(), r53.listen_until_close(), r55.listen_until_close(), r56.listen_until_close(), r57.listen_until_close(), ); let (output, branch_name) = cli_tester_handle.join().unwrap()?; assert_eq!( output, format!(" + 2d1b467...ead85e0 {branch_name} -> {branch_name} (forced update)\r\n").as_str(), ); let new_events = r55 .events .iter() .cloned() .collect::>() .difference(&before) .cloned() .collect::>(); assert_eq!(new_events.len(), 3); let proposal = r55 .events .iter() .find(|e| { e.tags .iter() .find(|t| t.as_slice()[0].eq("branch-name")) .is_some_and(|t| t.as_slice()[1].eq(FEATURE_BRANCH_NAME_1)) }) .unwrap(); let revision_root_patch = new_events .iter() .find(|e| { e.tags .iter() .any(|t| ["revision-root", "root-revision"].contains(&t.as_slice()[1].as_str())) }) .unwrap(); assert_eq!( proposal.id.to_string(), revision_root_patch .tags .iter() .find(|t| t.is_reply()) .unwrap() .as_slice()[1], "revision root patch replies to original proposal" ); assert!( revision_root_patch.content.contains("[PATCH 1/3]"), "revision root labeled with [PATCH 1/3] event: {revision_root_patch:?}", ); let second_patch = new_events .iter() .find(|e| e.content.contains("new.md")) .unwrap(); let third_patch = new_events .iter() .find(|e| e.content.contains("new2.md")) .unwrap(); assert!( second_patch.content.contains("[PATCH 2/3]"), "second patch labeled with [PATCH 2/3]" ); assert!( third_patch.content.contains("[PATCH 3/3]"), "third patch labeled with [PATCH 3/3]" ); assert_eq!( revision_root_patch.id.to_string(), second_patch .tags .iter() .find(|t| t.is_root()) .unwrap() .as_slice()[1], "second patch sets revision id as root" ); assert_eq!( second_patch.id.to_string(), third_patch .tags .iter() .find(|t| t.is_reply()) .unwrap() .as_slice()[1], "third patch replies to the second new patch" ); Ok(()) } #[tokio::test] #[serial] async fn push_new_pr_branch_creates_proposal() -> Result<()> { let (events, source_git_repo) = prep_source_repo_and_events_including_proposals().await?; let _source_path = source_git_repo.dir.to_str().unwrap().to_string(); let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = ( Relay::new(8051, None, None), Relay::new(8052, None, None), Relay::new(8053, None, None), Relay::new(8055, None, None), Relay::new(8056, None, None), Relay::new(8057, None, None), ); r51.events = events.clone(); r55.events = events.clone(); #[allow(clippy::mutable_key_type)] let before = r55.events.iter().cloned().collect::>(); let branch_name = "pr/my-new-proposal"; let cli_tester_handle = std::thread::spawn(move || -> Result { let mut git_repo = clone_git_repo_with_nostr_url()?; git_repo.delete_dir_on_drop = false; git_repo.create_branch(branch_name)?; git_repo.checkout(branch_name)?; std::fs::write(git_repo.dir.join("new.md"), "some content")?; git_repo.stage_and_commit("new.md")?; std::fs::write(git_repo.dir.join("new2.md"), "some content")?; git_repo.stage_and_commit("new2.md")?; let mut p = CliTester::new_git_with_remote_helper_from_dir( &git_repo.dir, ["push", "-u", "origin", branch_name], ); cli_expect_nostr_fetch(&mut p)?; p.expect("git servers: listing refs...\r\n")?; p.expect_eventually_and_print(format!("To {}\r\n", get_nostr_remote_url()?).as_str())?; let output = p.expect_end_eventually()?; for p in [51, 52, 53, 55, 56, 57] { relay::shutdown_relay(8000 + p)?; } Ok(output) }); // launch relays let _ = join!( r51.listen_until_close(), r52.listen_until_close(), r53.listen_until_close(), r55.listen_until_close(), r56.listen_until_close(), r57.listen_until_close(), ); let output = cli_tester_handle.join().unwrap()?; assert_eq!( output, format!(" * [new branch] {branch_name} -> {branch_name}\r\nbranch '{branch_name}' set up to track 'origin/{branch_name}'.\r\n").as_str(), ); let new_events = r55 .events .iter() .cloned() .collect::>() .difference(&before) .cloned() .collect::>(); assert_eq!(new_events.len(), 2); let proposal = new_events .iter() .find(|e| e.tags.iter().any(|t| t.as_slice()[1].eq("root"))) .unwrap(); assert!( proposal.content.contains("new.md"), "first patch is proposal root" ); assert!( proposal.content.contains("[PATCH 1/2]"), "proposal root labeled with[PATCH 1/2] event: {proposal:?}", ); assert_eq!( proposal .tags .iter() .find(|t| t.as_slice()[0].eq("branch-name")) .unwrap() .as_slice()[1], branch_name.replace("pr/", ""), ); let second_patch = new_events .iter() .find(|e| e.content.contains("new2.md")) .unwrap(); assert!( second_patch.content.contains("[PATCH 2/2]"), "second patch labeled with [PATCH 2/2]" ); assert_eq!( proposal.id.to_string(), second_patch .tags .iter() .find(|t| t.is_root()) .unwrap() .as_slice()[1], "second patch sets proposal id as root" ); Ok(()) } #[tokio::test] #[serial] async fn push_new_pr_branch_with_title_description_options_creates_pr_with_custom_title_description() -> Result<()> { let (events, source_git_repo) = prep_source_repo_and_events_including_proposals().await?; let _source_path = source_git_repo.dir.to_str().unwrap().to_string(); let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = ( Relay::new(8051, None, None), Relay::new(8052, None, None), Relay::new(8053, None, None), Relay::new(8055, None, None), Relay::new(8056, None, None), Relay::new(8057, None, None), ); r51.events = events.clone(); r55.events = events.clone(); #[allow(clippy::mutable_key_type)] let before = r55.events.iter().cloned().collect::>(); let branch_name = "pr/my-pr-with-title"; let cli_tester_handle = std::thread::spawn(move || -> Result { let mut git_repo = clone_git_repo_with_nostr_url()?; git_repo.delete_dir_on_drop = false; git_repo.create_branch(branch_name)?; git_repo.checkout(branch_name)?; let large_content = "x".repeat(70 * 1024); std::fs::write(git_repo.dir.join("large_file.txt"), large_content)?; git_repo.stage_and_commit("large_file.txt")?; let mut p = CliTester::new_git_with_remote_helper_from_dir( &git_repo.dir, [ "push", "--push-option=title=Custom PR Title", "--push-option=description=Custom PR description here", "-u", "origin", branch_name, ], ); cli_expect_nostr_fetch(&mut p)?; p.expect("git servers: listing refs...\r\n")?; p.expect_eventually_and_print(format!("To {}\r\n", get_nostr_remote_url()?).as_str())?; let output = p.expect_end_eventually()?; for p in [51, 52, 53, 55, 56, 57] { relay::shutdown_relay(8000 + p)?; } Ok(output) }); let _ = join!( r51.listen_until_close(), r52.listen_until_close(), r53.listen_until_close(), r55.listen_until_close(), r56.listen_until_close(), r57.listen_until_close(), ); let output = cli_tester_handle.join().unwrap()?; assert_eq!( output, format!(" * [new branch] {branch_name} -> {branch_name}\r\nbranch '{branch_name}' set up to track 'origin/{branch_name}'.\r\n").as_str(), ); let new_events = r55 .events .iter() .cloned() .collect::>() .difference(&before) .cloned() .collect::>(); assert_eq!(new_events.len(), 1, "should create exactly 1 PR event"); let pr_event = new_events.first().unwrap(); assert!( pr_event.kind.eq(&KIND_PULL_REQUEST), "event should be a PR event" ); let title_tag = pr_event.tags.iter().find(|t| t.as_slice()[0].eq("subject")); assert!( title_tag.is_some(), "PR event should have a subject tag for title" ); assert_eq!( title_tag.unwrap().as_slice()[1], "Custom PR Title", "title should match push-option" ); assert_eq!( pr_event.content, "Custom PR description here", "description should match push-option" ); let branch_name_tag = pr_event .tags .iter() .find(|t| t.as_slice()[0].eq("branch-name")); assert!( branch_name_tag.is_some(), "PR event should have a branch-name tag" ); assert_eq!( branch_name_tag.unwrap().as_slice()[1], branch_name.replace("pr/", ""), "branch-name tag should match" ); Ok(()) } #[tokio::test] #[serial] async fn push_with_escaped_newlines_in_description_creates_pr_with_multiline_description() -> Result<()> { let (events, source_git_repo) = prep_source_repo_and_events_including_proposals().await?; let _source_path = source_git_repo.dir.to_str().unwrap().to_string(); let (mut r51, mut r52, mut r53, mut r55, mut r56, mut r57) = ( Relay::new(8051, None, None), Relay::new(8052, None, None), Relay::new(8053, None, None), Relay::new(8055, None, None), Relay::new(8056, None, None), Relay::new(8057, None, None), ); r51.events = events.clone(); r55.events = events.clone(); #[allow(clippy::mutable_key_type)] let before = r55.events.iter().cloned().collect::>(); let branch_name = "pr/my-pr-multiline"; let cli_tester_handle = std::thread::spawn(move || -> Result { let mut git_repo = clone_git_repo_with_nostr_url()?; git_repo.delete_dir_on_drop = false; git_repo.create_branch(branch_name)?; git_repo.checkout(branch_name)?; let large_content = "x".repeat(70 * 1024); std::fs::write(git_repo.dir.join("large_file.txt"), large_content)?; git_repo.stage_and_commit("large_file.txt")?; // Use \\n in the push-option value — the two-character escape sequence let mut p = CliTester::new_git_with_remote_helper_from_dir( &git_repo.dir, [ "push", "--push-option=title=Multiline PR", r"--push-option=description=First line\n\nSecond paragraph\nThird line", "-u", "origin", branch_name, ], ); cli_expect_nostr_fetch(&mut p)?; p.expect("git servers: listing refs...\r\n")?; p.expect_eventually_and_print(format!("To {}\r\n", get_nostr_remote_url()?).as_str())?; let output = p.expect_end_eventually()?; for p in [51, 52, 53, 55, 56, 57] { relay::shutdown_relay(8000 + p)?; } Ok(output) }); let _ = join!( r51.listen_until_close(), r52.listen_until_close(), r53.listen_until_close(), r55.listen_until_close(), r56.listen_until_close(), r57.listen_until_close(), ); let output = cli_tester_handle.join().unwrap()?; assert_eq!( output, format!(" * [new branch] {branch_name} -> {branch_name}\r\nbranch '{branch_name}' set up to track 'origin/{branch_name}'.\r\n").as_str(), ); let new_events = r55 .events .iter() .cloned() .collect::>() .difference(&before) .cloned() .collect::>(); assert_eq!(new_events.len(), 1, "should create exactly 1 PR event"); let pr_event = new_events.first().unwrap(); assert!( pr_event.kind.eq(&KIND_PULL_REQUEST), "event should be a PR event" ); let title_tag = pr_event.tags.iter().find(|t| t.as_slice()[0].eq("subject")); assert!( title_tag.is_some(), "PR event should have a subject tag for title" ); assert_eq!( title_tag.unwrap().as_slice()[1], "Multiline PR", "title should match push-option" ); // The \\n sequences should have been decoded into real newlines assert_eq!( pr_event.content, "First line\n\nSecond paragraph\nThird line", "description should contain real newlines from escaped \\n sequences" ); Ok(()) } mod push_from_another_maintainer { // TODO that has issued announcement // - that is listed by trusted maintainer - succeeds (covered by above // tests) // - that isn't listed by trusted maintainer - fails // TODO that hasn't yet issued announcement // - that is listed by trusted maintainer - fails }