From 056c400c5eb6cd8b105f38eea43922f71b0204dc Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 1 Aug 2025 15:55:01 +0100 Subject: fix: use new gitworkshop.dev url format where just the nevent will do --- tests/ngit_list.rs | 2 +- tests/ngit_send.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'tests') diff --git a/tests/ngit_list.rs b/tests/ngit_list.rs index 0547ad4..39385d6 100644 --- a/tests/ngit_list.rs +++ b/tests/ngit_list.rs @@ -79,7 +79,7 @@ mod cannot_find_repo_event { test_repo.populate()?; let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); p.expect( - "hint: https://gitworkshop.dev/repos lists repositories and their nostr address\r\n", + "hint: https://gitworkshop.dev/search lists repositories and their nostr address\r\n", )?; if invalid_input { let mut input = p.expect_input("nostr repository")?; diff --git a/tests/ngit_send.rs b/tests/ngit_send.rs index 9a46469..ec72667 100644 --- a/tests/ngit_send.rs +++ b/tests/ngit_send.rs @@ -158,7 +158,7 @@ fn expect_msgs_first(p: &mut CliTester, include_cover_letter: bool) -> Result<() } fn expect_msgs_after(p: &mut CliTester) -> Result<()> { - p.expect_after_whitespace("view in gitworkshop.dev: https://gitworkshop.dev/repo")?; + p.expect_after_whitespace("view in gitworkshop.dev: https://gitworkshop.dev/")?; p.expect_eventually("\r\n")?; p.expect("view in another client: https://njump.me/")?; p.expect_eventually("\r\n")?; -- cgit v1.2.3 From f48677bad3f3dabb80992806e0e4c8ad4d45c716 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Mon, 4 Aug 2025 11:50:39 +0100 Subject: feat(send): support PR and PR update events send as a PR if the commit would make patches that are too big for nostr events. send as a PR update if the proposal is PR. send as a PR, revising a patch root, if patches would be too big. in tests `get_pretend_proposal_root_event` has to be a actual proposal with a tip, rather than just a cover letter, so we have replaced it. --- src/bin/git_remote_nostr/push.rs | 1 + src/bin/ngit/sub_commands/send.rs | 137 +++++++++++++++++++++++++------------- src/lib/git_events.rs | 10 ++- src/lib/push.rs | 3 + src/lib/utils.rs | 36 +++++++++- test_utils/src/lib.rs | 2 +- tests/ngit_send.rs | 84 +++++++++++++++++------ 7 files changed, 202 insertions(+), 71 deletions(-) (limited to 'tests') diff --git a/src/bin/git_remote_nostr/push.rs b/src/bin/git_remote_nostr/push.rs index e588a5a..3967699 100644 --- a/src/bin/git_remote_nostr/push.rs +++ b/src/bin/git_remote_nostr/push.rs @@ -461,6 +461,7 @@ async fn generate_patches_or_pr_event_or_pr_updates( ahead.first().context("no commits to push")?, user_ref, root_proposal, + &None, signer, term, ) diff --git a/src/bin/ngit/sub_commands/send.rs b/src/bin/ngit/sub_commands/send.rs index 9f1857f..0aefb03 100644 --- a/src/bin/ngit/sub_commands/send.rs +++ b/src/bin/ngit/sub_commands/send.rs @@ -4,9 +4,11 @@ use anyhow::{Context, Result, bail}; use console::Style; use ngit::{ client::{Params, send_events}, - git_events::{EventRefType, generate_cover_letter_and_patch_events}, + git_events::{EventRefType, KIND_PULL_REQUEST, generate_cover_letter_and_patch_events}, + push::push_refs_and_generate_pr_or_pr_update_event, + utils::proposal_tip_is_pr_or_pr_update, }; -use nostr::{ToBech32, nips::nip19::Nip19Event}; +use nostr::{ToBech32, event::Event, nips::nip19::Nip19Event}; use nostr_sdk::hashes::sha1::Hash as Sha1Hash; use crate::{ @@ -60,12 +62,14 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; } - let (root_proposal_id, mention_tags) = - get_root_proposal_id_and_mentions_from_in_reply_to(git_repo.get_path()?, &args.in_reply_to) + let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?; + + let (root_proposal, mention_tags) = + get_root_proposal_and_mentions_from_in_reply_to(git_repo.get_path()?, &args.in_reply_to) .await?; if let Some(root_ref) = args.in_reply_to.first() { - if root_proposal_id.is_some() { + if root_proposal.is_some() { println!("creating proposal revision for: {root_ref}"); } } @@ -112,7 +116,30 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re &main_tip, )?; - let title = if args.no_cover_letter { + let as_pr = { + if let Some(root_proposal) = &root_proposal { + proposal_tip_is_pr_or_pr_update(git_repo_path, &repo_ref, &root_proposal.id).await? + } else { + false + } + } || git_repo.are_commits_too_big_for_patches(&commits); + + let title = if as_pr { + match &args.title { + Some(t) => Some(t.clone()), + None => { + if root_proposal.is_none() { + Some( + Interactor::default() + .input(PromptInputParms::default().with_prompt("title"))? + .clone(), + ) + } else { + None + } + } + } + } else if args.no_cover_letter { None } else { match &args.title { @@ -142,7 +169,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re t.clone() } else { Interactor::default() - .input(PromptInputParms::default().with_prompt("cover letter description"))? + .input(PromptInputParms::default().with_prompt("description"))? .clone() }, )) @@ -161,42 +188,58 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re client.set_signer(signer.clone()).await; - let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?; - // oldest first commits.reverse(); - let events = generate_cover_letter_and_patch_events( - cover_letter_title_description.clone(), - &git_repo, - &commits, - &signer, - &repo_ref, - &root_proposal_id, - &mention_tags, - ) - .await?; + let events = if as_pr { + push_refs_and_generate_pr_or_pr_update_event( + &git_repo, + &repo_ref, + commits.last().context("no commits")?, + &user_ref, + root_proposal.as_ref(), + &cover_letter_title_description, + &signer, + &console::Term::stdout(), + ) + .await? - println!( - "posting {} patch{} {} a covering letter...", - if cover_letter_title_description.is_none() { - events.len() - } else { - events.len() - 1 - }, - if cover_letter_title_description.is_none() && events.len().eq(&1) - || cover_letter_title_description.is_some() && events.len().eq(&2) - { - "" - } else { - "es" - }, - if cover_letter_title_description.is_none() { - "without" - } else { - "with" - } - ); + // TODO + // - allow specifying clone url and ref + } else { + let events = generate_cover_letter_and_patch_events( + cover_letter_title_description.clone(), + &git_repo, + &commits, + &signer, + &repo_ref, + &root_proposal.as_ref().map(|e| e.id.to_string()), + &mention_tags, + ) + .await?; + + println!( + "posting {} patch{} {} a covering letter...", + if cover_letter_title_description.is_none() { + events.len() + } else { + events.len() - 1 + }, + if cover_letter_title_description.is_none() && events.len().eq(&1) + || cover_letter_title_description.is_some() && events.len().eq(&2) + { + "" + } else { + "es" + }, + if cover_letter_title_description.is_none() { + "without" + } else { + "with" + } + ); + events + }; send_events( &client, @@ -209,7 +252,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re ) .await?; - if root_proposal_id.is_none() { + if root_proposal.is_none() { if let Some(event) = events.first() { let event_bech32 = if let Some(relay) = repo_ref.relays.first() { Nip19Event { @@ -376,11 +419,11 @@ fn summarise_commit_for_selection(git_repo: &Repo, commit: &Sha1Hash) -> Result< )) } -async fn get_root_proposal_id_and_mentions_from_in_reply_to( +async fn get_root_proposal_and_mentions_from_in_reply_to( git_repo_path: &Path, in_reply_to: &[String], -) -> Result<(Option, Vec)> { - let root_proposal_id = if let Some(first) = in_reply_to.first() { +) -> Result<(Option, Vec)> { + let root_proposal = if let Some(first) = in_reply_to.first() { match event_tag_from_nip19_or_hex(first, "in-reply-to", EventRefType::Root, true, false)? .as_standardized() { @@ -398,8 +441,8 @@ async fn get_root_proposal_id_and_mentions_from_in_reply_to( .await?; if let Some(first) = events.iter().find(|e| e.id.eq(event_id)) { - if event_is_patch_set_root(first) { - Some(event_id.to_string()) + if event_is_patch_set_root(first) || first.kind.eq(&KIND_PULL_REQUEST) { + Some(first.clone()) } else { None } @@ -415,7 +458,7 @@ async fn get_root_proposal_id_and_mentions_from_in_reply_to( let mut mention_tags = vec![]; for (i, reply_to) in in_reply_to.iter().enumerate() { - if i.ne(&0) || root_proposal_id.is_none() { + if i.ne(&0) || root_proposal.is_none() { mention_tags.push( event_tag_from_nip19_or_hex( reply_to, @@ -431,7 +474,7 @@ async fn get_root_proposal_id_and_mentions_from_in_reply_to( } } - Ok((root_proposal_id, mention_tags)) + Ok((root_proposal, mention_tags)) } // TODO diff --git a/src/lib/git_events.rs b/src/lib/git_events.rs index 79f5772..bbfcbea 100644 --- a/src/lib/git_events.rs +++ b/src/lib/git_events.rs @@ -376,11 +376,13 @@ pub fn event_tag_from_nip19_or_hex( } } +#[allow(clippy::too_many_arguments)] pub fn generate_unsigned_pr_or_update_event( git_repo: &Repo, repo_ref: &RepoRef, signing_public_key: &PublicKey, root_proposal: Option<&Event>, + title_description_overide: &Option<(String, String)>, commit: &Sha1Hash, clone_url_hint: &[&str], mentions: &[nostr::Tag], @@ -395,13 +397,17 @@ pub fn generate_unsigned_pr_or_update_event( None }; - let title = if let Some(cl) = &root_patch_cover_letter { + let title = if let Some((title, _)) = &title_description_overide { + title.clone() + } else if let Some(cl) = &root_patch_cover_letter { cl.title.clone() } else { git_repo.get_commit_message_summary(commit)? }; - let description = if let Some(cl) = &root_patch_cover_letter { + let description = if let Some((_, description)) = &title_description_overide { + description.clone() + } else if let Some(cl) = &root_patch_cover_letter { cl.description.clone() } else { let mut description = git_repo.get_commit_message(commit)?.trim().to_string(); diff --git a/src/lib/push.rs b/src/lib/push.rs index 1c09555..bcd368b 100644 --- a/src/lib/push.rs +++ b/src/lib/push.rs @@ -321,12 +321,14 @@ impl<'a> PushReporter<'a> { } } +#[allow(clippy::too_many_arguments)] pub async fn push_refs_and_generate_pr_or_pr_update_event( git_repo: &Repo, repo_ref: &RepoRef, tip: &Sha1Hash, user_ref: &UserRef, root_proposal: Option<&Event>, + title_description_overide: &Option<(String, String)>, signer: &Arc, term: &Term, ) -> Result> { @@ -348,6 +350,7 @@ pub async fn push_refs_and_generate_pr_or_pr_update_event( repo_ref, &user_ref.public_key, root_proposal, + title_description_overide, tip, &[clone_url], &[], diff --git a/src/lib/utils.rs b/src/lib/utils.rs index 431757f..431a14f 100644 --- a/src/lib/utils.rs +++ b/src/lib/utils.rs @@ -3,11 +3,13 @@ use std::{ collections::HashMap, fmt, io::{self, Stdin}, + path::Path, str::FromStr, }; use anyhow::{Context, Result, bail}; use git2::Repository; +use nostr::nips::nip19::ToBech32; use nostr_sdk::{Event, EventId, Kind, PublicKey, Url}; use crate::{ @@ -20,7 +22,8 @@ use crate::{ nostr_url::{CloneUrl, NostrUrlDecoded, ServerProtocol}, }, git_events::{ - event_is_revision_root, get_pr_tip_event_or_most_recent_patch_with_ancestors, get_status, + KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, event_is_revision_root, + get_pr_tip_event_or_most_recent_patch_with_ancestors, get_status, is_event_proposal_root_for_branch, status_kinds, }, repo_ref::RepoRef, @@ -187,6 +190,37 @@ pub async fn get_all_proposals( Ok(all_proposals) } +pub async fn proposal_tip_is_pr_or_pr_update( + git_repo_path: &Path, + repo_ref: &RepoRef, + proposal_id: &EventId, +) -> Result { + let commits_events = + get_all_proposal_patch_pr_pr_update_events_from_cache(git_repo_path, repo_ref, proposal_id) + .await + .context(format!( + "cannot get existing proposal events for {}", + proposal_id.to_bech32()? + ))?; + let most_recent_proposal_patch_chain = get_pr_tip_event_or_most_recent_patch_with_ancestors( + commits_events.clone(), + ) + .context(format!( + "cannot find tip from proposal events for {}", + proposal_id.to_bech32()?, + ))?; + + Ok([KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE].contains( + &most_recent_proposal_patch_chain + .first() + .context(format!( + "cannot find any proposal events for {}", + proposal_id.to_bech32()? + ))? + .kind, + )) +} + pub fn find_proposal_and_patches_by_branch_name<'a>( refstr: &'a str, proposals: &'a HashMap)>, diff --git a/test_utils/src/lib.rs b/test_utils/src/lib.rs index 3ae004f..12cac76 100644 --- a/test_utils/src/lib.rs +++ b/test_utils/src/lib.rs @@ -210,7 +210,7 @@ pub fn generate_repo_ref_event_with_git_server_with_keys( } /// enough to fool event_is_patch_set_root pub fn get_pretend_proposal_root_event() -> nostr::Event { - serde_json::from_str(r#"{"id":"431e58eb8e1b4e20292d1d5bbe81d5cfb042e1bc165de32eddfdd52245a4cce4","pubkey":"f53e4bcd7a9cdef049cf6467d638a1321958acd3b71eb09823fd6fadb023d768","created_at":1721404213,"kind":1617,"tags":[["a","30617:ba882566eff14f3baa976103998c452d27fe95b65a796a6a9f92628bced76fe5:9ee507fc4357d7ee16a5d8901bedcd103f23c17d-consider-it-random"],["a","30617:f53e4bcd7a9cdef049cf6467d638a1321958acd3b71eb09823fd6fadb023d768:9ee507fc4357d7ee16a5d8901bedcd103f23c17d-consider-it-random"],["r","9ee507fc4357d7ee16a5d8901bedcd103f23c17d"],["t","cover-letter"],["alt","git patch cover letter: exampletitle"],["t","root"],["e","8cb75aa4cda10a3a0f3242dc49d36159d30b3185bf63414cf6ce17f5c14a73b1","","mention"],["branch-name","feature"],["p","ba882566eff14f3baa976103998c452d27fe95b65a796a6a9f92628bced76fe5"],["p","f53e4bcd7a9cdef049cf6467d638a1321958acd3b71eb09823fd6fadb023d768"]],"content":"From fe973a840fba2a8ab37dd505c154854a69a6505c Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/2] exampletitle\n\nexampledescription","sig":"37d5b2338bf9fd9d598e6494ae88af9a8dbd52330cfe9d025ee55e35e2f3f55e931ba039d9f7fed8e6fc40206e47619a24f730f8eddc2a07ccfb3988a5005170"}"#).unwrap() + serde_json::from_str(r#"{"id":"000c104861e34a453481ab23e7de21a6baf475b394479705363b035936732528","pubkey":"f53e4bcd7a9cdef049cf6467d638a1321958acd3b71eb09823fd6fadb023d768","created_at":1754322009,"kind":1617,"tags":[["a","30617:f53e4bcd7a9cdef049cf6467d638a1321958acd3b71eb09823fd6fadb023d768:9ee507fc4357d7ee16a5d8901bedcd103f23c17d-consider-it-random","ws://localhost:8055"],["a","30617:ba882566eff14f3baa976103998c452d27fe95b65a796a6a9f92628bced76fe5:9ee507fc4357d7ee16a5d8901bedcd103f23c17d-consider-it-random","ws://localhost:8055"],["r","9ee507fc4357d7ee16a5d8901bedcd103f23c17d"],["r","232efb37ebc67692c9e9ff58b83c0d3d63971a0a"],["alt","git patch: add t3.md"],["t","root"],["branch-name","feature"],["p","ba882566eff14f3baa976103998c452d27fe95b65a796a6a9f92628bced76fe5"],["commit","232efb37ebc67692c9e9ff58b83c0d3d63971a0a"],["parent-commit","431b84edc0d2fa118d63faa3c2db9c73d630a5ae"],["commit-pgp-sig",""],["description","add t3.md"],["author","Joe Bloggs","joe.bloggs@pm.me","0","0"],["committer","Joe Bloggs","joe.bloggs@pm.me","0","0"]],"content":"From 232efb37ebc67692c9e9ff58b83c0d3d63971a0a Mon Sep 17 00:00:00 2001\nFrom: Joe Bloggs \nDate: Thu, 1 Jan 1970 00:00:00 +0000\nSubject: [PATCH 1/2] add t3.md\n\n---\n t3.md | 1 +\n 1 file changed, 1 insertion(+)\n create mode 100644 t3.md\n\ndiff --git a/t3.md b/t3.md\nnew file mode 100644\nindex 0000000..f0eec86\n--- /dev/null\n+++ b/t3.md\n@@ -0,0 +1 @@\n+some content\n\\ No newline at end of file\n--\nlibgit2 1.9.1\n\n","sig":"65577fea803ea464bb073273a3fbfbdb5bfdaa64fb3b1d029ee8f3729fde051ad90610d08e441335f365b6c1d6f2270909bc37d12433ca82f0b2928b7a503e31"}"#).unwrap() } /// wrapper for a cli testing tool - currently wraps rexpect and dialoguer diff --git a/tests/ngit_send.rs b/tests/ngit_send.rs index ec72667..e128bd9 100644 --- a/tests/ngit_send.rs +++ b/tests/ngit_send.rs @@ -37,6 +37,25 @@ mod when_commits_behind_ask_to_proceed { Ok(test_repo) } + fn create_relay_51() -> Result> { + Ok(Relay::new( + 8051, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![ + generate_test_key_1_metadata_event("fred"), + generate_test_key_1_relay_list_event(), + generate_repo_ref_event(), + ], + )?; + Ok(()) + }), + )) + } + fn expect_confirm_prompt(p: &mut CliTester) -> Result { p.expect("fetching updates...\r\n")?; p.expect_eventually("\r\n")?; // may be 'no updates' or some updates @@ -49,37 +68,62 @@ mod when_commits_behind_ask_to_proceed { ) } - #[test] - fn asked_with_default_no() -> Result<()> { + #[tokio::test] + #[serial] + async fn asked_with_default_no() -> Result<()> { let test_repo = prep_test_repo()?; + let mut r51 = create_relay_51()?; + // // check relay had the right number of events + let cli_tester_handle = std::thread::spawn(move || -> Result<()> { + let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "HEAD~2"]); + expect_confirm_prompt(&mut p)?; + p.exit()?; + relay::shutdown_relay(8051)?; + Ok(()) + }); - let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "HEAD~2"]); - expect_confirm_prompt(&mut p)?; - p.exit()?; + // launch relay + r51.listen_until_close().await?; + cli_tester_handle.join().unwrap()?; Ok(()) } - #[test] - fn when_response_is_false_aborts() -> Result<()> { + #[tokio::test] + #[serial] + async fn when_response_is_false_aborts() -> Result<()> { let test_repo = prep_test_repo()?; + let mut r51 = create_relay_51()?; + let cli_tester_handle = std::thread::spawn(move || -> Result<()> { + let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "HEAD~2"]); + expect_confirm_prompt(&mut p)?.succeeds_with(Some(false))?; + p.expect_end_with("Error: aborting so commits can be rebased\r\n")?; + relay::shutdown_relay(8051)?; + Ok(()) + }); - let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "HEAD~2"]); - - expect_confirm_prompt(&mut p)?.succeeds_with(Some(false))?; - - p.expect_end_with("Error: aborting so commits can be rebased\r\n")?; - + // launch relay + r51.listen_until_close().await?; + cli_tester_handle.join().unwrap()?; Ok(()) } - #[test] + + #[tokio::test] #[serial] - fn when_response_is_true_proceeds() -> Result<()> { + async fn when_response_is_true_proceeds() -> Result<()> { let test_repo = prep_test_repo()?; + let mut r51 = create_relay_51()?; + let cli_tester_handle = std::thread::spawn(move || -> Result<()> { + let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "HEAD~2"]); + expect_confirm_prompt(&mut p)?.succeeds_with(Some(true))?; + p.expect("? include cover letter")?; + p.exit()?; + relay::shutdown_relay(8051)?; + Ok(()) + }); - let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "HEAD~2"]); - expect_confirm_prompt(&mut p)?.succeeds_with(Some(true))?; - p.expect("? include cover letter")?; - p.exit()?; + // launch relay + r51.listen_until_close().await?; + cli_tester_handle.join().unwrap()?; Ok(()) } } @@ -1620,7 +1664,7 @@ mod root_proposal_specified_using_in_reply_to_with_range_of_head_2_and_cover_let .unwrap() .as_slice()[1], // id of state nevent - "431e58eb8e1b4e20292d1d5bbe81d5cfb042e1bc165de32eddfdd52245a4cce4", + "000c104861e34a453481ab23e7de21a6baf475b394479705363b035936732528", ); } Ok(()) -- cgit v1.2.3