From 948c8595acea9a783a38002371c40185868ce923 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Sun, 18 Aug 2024 08:04:49 +0100 Subject: feat(remote): `push` publish merge event when a merge commit is being pushed that merges a patch in a proposal --- src/git.rs | 2 +- src/git_remote_helper.rs | 230 ++++++++++++++++++++++++++++++++++++++++++++- tests/git_remote_helper.rs | 152 +++++++++++++++++++++++++++++- 3 files changed, 378 insertions(+), 6 deletions(-) diff --git a/src/git.rs b/src/git.rs index c351a27..eebca29 100644 --- a/src/git.rs +++ b/src/git.rs @@ -787,7 +787,7 @@ pub fn oid_to_sha1(oid: &Oid) -> Sha1Hash { } /// `Sha1Hash` to git2 `Oid` object -fn sha1_to_oid(hash: &Sha1Hash) -> Result { +pub fn sha1_to_oid(hash: &Sha1Hash) -> Result { Oid::from_bytes(hash.as_byte_array()).context("Sha1Hash bytes failed to produce a valid Oid") } diff --git a/src/git_remote_helper.rs b/src/git_remote_helper.rs index 97db69a..a03c6cf 100644 --- a/src/git_remote_helper.rs +++ b/src/git_remote_helper.rs @@ -18,9 +18,9 @@ use client::{ consolidate_fetch_reports, get_events_from_cache, get_repo_ref_from_cache, get_state_from_cache, sign_event, Connect, STATE_KIND, }; -use git::RepoActions; +use git::{sha1_to_oid, RepoActions}; use git2::{Oid, Repository}; -use nostr::nips::nip01::Coordinate; +use nostr::nips::{nip01::Coordinate, nip10::Marker}; use nostr_sdk::{ hashes::sha1::Hash as Sha1Hash, Event, EventBuilder, EventId, Kind, PublicKey, Tag, Url, }; @@ -713,6 +713,18 @@ async fn push( RepoState::build(repo_ref.identifier.clone(), new_state, &signer).await?; events.push(new_repo_state.event); + + for event in get_merged_status_events( + repo_ref, + git_repo, + nostr_remote_url, + &signer, + &git_server_refspecs, + ) + .await? + { + events.push(event); + } } let mut rejected_proposal_refspecs = vec![]; @@ -1115,6 +1127,220 @@ fn generate_updated_state( Ok(new_state) } +async fn get_merged_status_events( + repo_ref: &RepoRef, + git_repo: &Repo, + remote_nostr_url: &str, + signer: &NostrSigner, + refspecs_to_git_server: &Vec, +) -> Result> { + let mut events = vec![]; + for refspec in refspecs_to_git_server { + let (from, to) = refspec_to_from_to(refspec)?; + if to.eq("refs/heads/main") || to.eq("refs/heads/master") { + let tip_of_pushed_branch = git_repo.get_commit_or_tip_of_reference(from)?; + let Ok(tip_of_remote_branch) = git_repo.get_commit_or_tip_of_reference( + &refspec_remote_ref_name(&git_repo.git_repo, refspec, remote_nostr_url)?, + ) else { + // branch not on remote + continue; + }; + let (ahead, _) = + git_repo.get_commits_ahead_behind(&tip_of_remote_branch, &tip_of_pushed_branch)?; + for commit_hash in ahead { + let commit = git_repo.git_repo.find_commit(sha1_to_oid(&commit_hash)?)?; + if commit.parent_count() > 1 { + // merge commit + for parent in commit.parents() { + // lookup parent id + let commit_events = get_events_from_cache( + git_repo.get_path()?, + vec![ + nostr::Filter::default() + .kind(nostr::Kind::GitPatch) + .reference(parent.id().to_string()), + ], + ) + .await?; + if let Some(commit_event) = commit_events.iter().find(|e| { + e.tags.iter().any(|t| { + t.as_vec()[0].eq("commit") + && t.as_vec()[1].eq(&parent.id().to_string()) + }) + }) { + let (proposal_id, revision_id) = + get_proposal_and_revision_root_from_patch(git_repo, commit_event) + .await?; + // TODO: write to terminal to tell user + events.push( + create_merge_status( + signer, + repo_ref, + &get_event_from_cache_by_id(git_repo, &proposal_id).await?, + &if let Some(revision_id) = revision_id { + Some( + get_event_from_cache_by_id(git_repo, &revision_id) + .await?, + ) + } else { + None + }, + &commit_hash, + commit_event.id(), + ) + .await?, + ); + } + } + } + } + } + } + Ok(events) +} + +async fn get_event_from_cache_by_id(git_repo: &Repo, event_id: &EventId) -> Result { + Ok(get_events_from_cache( + git_repo.get_path()?, + vec![nostr::Filter::default().id(*event_id)], + ) + .await? + .first() + .context("cannot find event in cache")? + .clone()) +} + +async fn create_merge_status( + signer: &NostrSigner, + repo_ref: &RepoRef, + proposal: &Event, + revision: &Option, + merge_commit: &Sha1Hash, + merged_patch: EventId, +) -> Result { + let mut public_keys = repo_ref + .maintainers + .iter() + .copied() + .collect::>(); + public_keys.insert(proposal.author()); + if let Some(revision) = revision { + public_keys.insert(revision.author()); + } + sign_event( + EventBuilder::new( + nostr::event::Kind::GitStatusApplied, + String::new(), + [ + vec![ + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")), + vec!["git proposal merged / applied".to_string()], + ), + Tag::from_standardized(nostr::TagStandard::Event { + event_id: proposal.id(), + relay_url: repo_ref.relays.first().map(nostr::UncheckedUrl::new), + marker: Some(Marker::Root), + public_key: None, + }), + Tag::from_standardized(nostr::TagStandard::Event { + event_id: merged_patch, + relay_url: repo_ref.relays.first().map(nostr::UncheckedUrl::new), + marker: Some(Marker::Mention), + public_key: None, + }), + ], + if let Some(revision) = revision { + vec![Tag::from_standardized(nostr::TagStandard::Event { + event_id: revision.id(), + relay_url: repo_ref.relays.first().map(nostr::UncheckedUrl::new), + marker: Some(Marker::Root), + public_key: None, + })] + } else { + vec![] + }, + public_keys.iter().map(|pk| Tag::public_key(*pk)).collect(), + repo_ref + .coordinates() + .iter() + .map(|c| Tag::coordinate(c.clone())) + .collect::>(), + vec![ + Tag::from_standardized(nostr::TagStandard::Reference( + repo_ref.root_commit.to_string(), + )), + Tag::from_standardized(nostr::TagStandard::Reference(format!( + "{merge_commit}" + ))), + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("merge-commit-id")), + vec![format!("{merge_commit}")], + ), + ], + ] + .concat(), + ), + signer, + ) + .await +} + +async fn get_proposal_and_revision_root_from_patch( + git_repo: &Repo, + patch: &Event, +) -> Result<(EventId, Option)> { + let proposal_or_revision = if patch.tags.iter().any(|t| t.as_vec()[1].eq("root")) { + patch.clone() + } else { + let proposal_or_revision_id = EventId::parse( + if let Some(t) = patch.tags.iter().find(|t| t.is_root()) { + t.clone() + } else if let Some(t) = patch.tags.iter().find(|t| t.is_reply()) { + t.clone() + } else { + Tag::event(patch.id()) + } + .as_vec()[1] + .clone(), + )?; + + get_events_from_cache( + git_repo.get_path()?, + vec![nostr::Filter::default().id(proposal_or_revision_id)], + ) + .await? + .first() + .unwrap() + .clone() + }; + + if !proposal_or_revision.kind().eq(&Kind::GitPatch) { + bail!("thread root is not a git patch"); + } + + if proposal_or_revision + .tags + .iter() + .any(|t| t.as_vec()[1].eq("revision-root")) + { + Ok(( + EventId::parse( + proposal_or_revision + .tags + .iter() + .find(|t| t.is_reply()) + .unwrap() + .as_vec()[1] + .clone(), + )?, + Some(proposal_or_revision.id()), + )) + } else { + Ok((proposal_or_revision.id(), None)) + } +} + fn update_remote_refs_pushed( git_repo: &Repository, refspec: &str, diff --git a/tests/git_remote_helper.rs b/tests/git_remote_helper.rs index 17138e4..98637e8 100644 --- a/tests/git_remote_helper.rs +++ b/tests/git_remote_helper.rs @@ -2,8 +2,9 @@ use std::{collections::HashSet, env::current_dir}; use anyhow::{Context, Result}; use futures::join; +use git2::Oid; use nostr::nips::nip01::Coordinate; -use nostr_sdk::{secp256k1::rand, Kind, ToBech32}; +use nostr_sdk::{secp256k1::rand, Event, JsonUtil, Kind, ToBech32}; use relay::Relay; use serial_test::serial; use test_utils::{git::GitTestRepo, *}; @@ -41,6 +42,9 @@ fn set_git_nostr_login_config(test_repo: &GitTestRepo) -> Result<()> { .context("cannot open git config")?; config.set_str("nostr.nsec", TEST_KEY_2_NSEC)?; config.set_str("nostr.npub", TEST_KEY_2_NPUB)?; + config.set_str("user.name", "test name")?; + config.set_str("user.email", "test@test.com")?; + config.set_bool("commit.gpgSign", false)?; Ok(()) } @@ -808,8 +812,6 @@ mod fetch { mod push { - use nostr_sdk::Event; - use super::*; #[tokio::test] @@ -1710,6 +1712,150 @@ mod push { Ok(()) } + #[tokio::test] + #[serial] + async fn proposal_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(); + + 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(format!("fetching refs list: {}...\r\n\r", source_path).as_str())?; + p.expect(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, oid) = cli_tester_handle.join().unwrap()?; + + assert_eq!( + output, + format!(" 431b84e..{} main -> main\r\n", &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 = r55 + .events + .iter() + .find(|e| { + e.iter_tags() + .find(|t| t.as_vec()[0].eq("branch-name")) + .is_some_and(|t| t.as_vec()[1].eq(FEATURE_BRANCH_NAME_1)) + }) + .unwrap(); + + let merge_status = new_events + .iter() + .find(|e| e.kind().eq(&Kind::GitStatusApplied)) + .unwrap(); + + assert_eq!( + oid.to_string(), + merge_status + .tags + .iter() + .find(|t| t.as_vec()[0].eq("merge-commit-id")) + .unwrap() + .as_vec()[1], + "status sets correct merge-commit-id tag" + ); + + let proposal_tip = r55 + .events + .iter() + .filter(|e| { + e.iter_tags() + .any(|t| t.as_vec()[1].eq(&proposal.id().to_string())) + && e.kind().eq(&Kind::GitPatch) + }) + .last() + .unwrap(); + + assert_eq!( + proposal_tip.id().to_string(), + merge_status + .tags + .iter() + .find(|t| t.as_vec().len().eq(&4) && t.as_vec()[3].eq("mention")) + .unwrap() + .as_vec()[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.id().to_string(), + merge_status + .tags + .iter() + .find(|t| t.is_root()) + .unwrap() + .as_vec()[1], + "status tags proposal id as root \r\nmerge status:\r\n{}\r\nproposal:\r\n{}", + merge_status.as_json(), + proposal.as_json(), + ); + + Ok(()) + } + #[tokio::test] #[serial] async fn push_2_commits_to_existing_proposal() -> Result<()> { -- cgit v1.2.3