From a625be66cfbced5f96cb0123a286937543e7273c Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 1 Aug 2025 09:39:36 +0100 Subject: refactor: move patch size evaluation fn to lib so we can use it in ngit as well as remote helper --- src/bin/git_remote_nostr/push.rs | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) (limited to 'src/bin') diff --git a/src/bin/git_remote_nostr/push.rs b/src/bin/git_remote_nostr/push.rs index 9ba7c30..38b6fc4 100644 --- a/src/bin/git_remote_nostr/push.rs +++ b/src/bin/git_remote_nostr/push.rs @@ -357,7 +357,7 @@ async fn process_proposal_refspecs( ); } if proposal.kind.eq(&KIND_PULL_REQUEST) - || are_commits_too_big_for_patches(git_repo, &ahead) + || git_repo.are_commits_too_big_for_patches(&ahead) { for event in generate_patches_or_pr_event_or_pr_updates( git_repo, @@ -441,19 +441,6 @@ async fn process_proposal_refspecs( Ok((events, rejected_proposal_refspecs)) } -fn are_commits_too_big_for_patches(git_repo: &Repo, commits: &[Sha1Hash]) -> bool { - commits.iter().any(|commit| { - if let Ok(patch) = git_repo.make_patch_from_commit(commit, &None) { - patch.len() - > ((65 // max recomended patch event size specified in nip34 in kb - // allownace for nostr event wrapper (id, pubkey, tags, sig) - - 1) * 1024) - } else { - true - } - }) -} - #[allow(clippy::too_many_lines)] async fn generate_patches_or_pr_event_or_pr_updates( git_repo: &Repo, @@ -466,7 +453,7 @@ async fn generate_patches_or_pr_event_or_pr_updates( ) -> Result> { let mut events: Vec = vec![]; let use_pr = root_proposal.is_some_and(|proposal| proposal.kind.eq(&KIND_PULL_REQUEST)) - || are_commits_too_big_for_patches(git_repo, ahead); + || git_repo.are_commits_too_big_for_patches(ahead); if use_pr { let repo_grasps = repo_ref.grasp_servers(); -- cgit v1.2.3 From a23efd79edb59be0e9c84f1fbeea45664dd63515 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 1 Aug 2025 09:54:35 +0100 Subject: refactor(send): abstract proposal commit checks as the function is too long --- src/bin/ngit/sub_commands/send.rs | 83 +++++++++++++++++++++++---------------- 1 file changed, 50 insertions(+), 33 deletions(-) (limited to 'src/bin') diff --git a/src/bin/ngit/sub_commands/send.rs b/src/bin/ngit/sub_commands/send.rs index 8b49e37..34965a1 100644 --- a/src/bin/ngit/sub_commands/send.rs +++ b/src/bin/ngit/sub_commands/send.rs @@ -104,39 +104,13 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re let (first_commit_ahead, behind) = git_repo.get_commits_ahead_behind(&main_tip, commits.last().context("no commits")?)?; - // check proposal ahead of origin/main - if first_commit_ahead.len().gt(&1) && !Interactor::default().confirm( - PromptConfirmParms::default() - .with_prompt( - format!("proposal builds on a commit {} ahead of '{main_branch_name}' - do you want to continue?", first_commit_ahead.len() - 1) - ) - .with_default(false) - ).context("failed to get confirmation response from interactor confirm")? { - bail!("aborting because selected commits were ahead of origin/master"); - } - - // check if a selected commit is already in origin - if commits.iter().any(|c| c.eq(&main_tip)) { - if !Interactor::default().confirm( - PromptConfirmParms::default() - .with_prompt( - format!("proposal contains commit(s) already in '{main_branch_name}'. proceed anyway?") - ) - .with_default(false) - ).context("failed to get confirmation response from interactor confirm")? { - bail!("aborting as proposal contains commit(s) already in '{main_branch_name}'"); - } - } - // check proposal isn't behind origin/main - else if !behind.is_empty() && !Interactor::default().confirm( - PromptConfirmParms::default() - .with_prompt( - format!("proposal is {} behind '{main_branch_name}'. consider rebasing before submission. proceed anyway?", behind.len()) - ) - .with_default(false) - ).context("failed to get confirmation response from interactor confirm")? { - bail!("aborting so commits can be rebased"); - } + check_commits_are_suitable_for_proposal( + &first_commit_ahead, + &commits, + &behind, + main_branch_name, + &main_tip, + )?; let title = if args.no_cover_letter { None @@ -269,6 +243,49 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re Ok(()) } +fn check_commits_are_suitable_for_proposal( + first_commit_ahead: &[Sha1Hash], + commits: &[Sha1Hash], + behind: &[Sha1Hash], + main_branch_name: &str, + main_tip: &Sha1Hash, +) -> Result<()> { + // check proposal ahead of origin/main + if first_commit_ahead.len().gt(&1) && !Interactor::default().confirm( + PromptConfirmParms::default() + .with_prompt( + format!("proposal builds on a commit {} ahead of '{main_branch_name}' - do you want to continue?", first_commit_ahead.len() - 1) + ) + .with_default(false) + ).context("failed to get confirmation response from interactor confirm")? { + bail!("aborting because selected commits were ahead of origin/master"); + } + + // check if a selected commit is already in origin + if commits.iter().any(|c| c.eq(main_tip)) { + if !Interactor::default().confirm( + PromptConfirmParms::default() + .with_prompt( + format!("proposal contains commit(s) already in '{main_branch_name}'. proceed anyway?") + ) + .with_default(false) + ).context("failed to get confirmation response from interactor confirm")? { + bail!("aborting as proposal contains commit(s) already in '{main_branch_name}'"); + } + } + // check proposal isn't behind origin/main + else if !behind.is_empty() && !Interactor::default().confirm( + PromptConfirmParms::default() + .with_prompt( + format!("proposal is {} behind '{main_branch_name}'. consider rebasing before submission. proceed anyway?", behind.len()) + ) + .with_default(false) + ).context("failed to get confirmation response from interactor confirm")? { + bail!("aborting so commits can be rebased"); + } + Ok(()) +} + fn choose_commits(git_repo: &Repo, proposed_commits: Vec) -> Result> { let mut proposed_commits = if proposed_commits.len().gt(&10) { vec![] -- cgit v1.2.3 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 --- src/bin/ngit/sub_commands/send.rs | 3 +-- src/lib/repo_ref.rs | 2 +- tests/ngit_list.rs | 2 +- tests/ngit_send.rs | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) (limited to 'src/bin') diff --git a/src/bin/ngit/sub_commands/send.rs b/src/bin/ngit/sub_commands/send.rs index 34965a1..9f1857f 100644 --- a/src/bin/ngit/sub_commands/send.rs +++ b/src/bin/ngit/sub_commands/send.rs @@ -225,8 +225,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re println!( "{}", dim.apply_to(format!( - "view in gitworkshop.dev: https://gitworkshop.dev/repo/{}/proposal/{}", - repo_ref.coordinate_with_hint().to_bech32()?, + "view in gitworkshop.dev: https://gitworkshop.dev/{}", &event_bech32, )) ); diff --git a/src/lib/repo_ref.rs b/src/lib/repo_ref.rs index bca4a3b..a3e1317 100644 --- a/src/lib/repo_ref.rs +++ b/src/lib/repo_ref.rs @@ -448,7 +448,7 @@ async fn get_repo_coordinate_from_user_prompt( println!( "{}", dim.apply_to( - "hint: https://gitworkshop.dev/repos lists repositories and their nostr address" + "hint: https://gitworkshop.dev/search lists repositories and their nostr address" ), ); let git_repo_path = git_repo.get_path()?; 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 3d04fb224b68187a67b9db0a37f662b5c5382f1e Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Mon, 4 Aug 2025 08:12:51 +0100 Subject: refactor: abstract pr event generation & ref push so that we can use it in `ngit send` --- src/bin/git_remote_nostr/push.rs | 206 ++++++++++++++++++++++----------------- 1 file changed, 115 insertions(+), 91 deletions(-) (limited to 'src/bin') diff --git a/src/bin/git_remote_nostr/push.rs b/src/bin/git_remote_nostr/push.rs index 38b6fc4..3478608 100644 --- a/src/bin/git_remote_nostr/push.rs +++ b/src/bin/git_remote_nostr/push.rs @@ -452,101 +452,28 @@ async fn generate_patches_or_pr_event_or_pr_updates( term: &Term, ) -> Result> { let mut events: Vec = vec![]; - let use_pr = root_proposal.is_some_and(|proposal| proposal.kind.eq(&KIND_PULL_REQUEST)) - || git_repo.are_commits_too_big_for_patches(ahead); + let parent_is_pr = root_proposal.is_some_and(|proposal| proposal.kind.eq(&KIND_PULL_REQUEST)); + let use_pr = parent_is_pr || git_repo.are_commits_too_big_for_patches(ahead); if use_pr { - let repo_grasps = repo_ref.grasp_servers(); - let repo_grasp_clone_urls = repo_ref - .git_server - .iter() - .filter(|s| is_grasp_server(s, &repo_grasps)); - - let mut unsigned_pr_event: Option = None; - let mut failed_clone_urls = vec![]; - for clone_url in repo_grasp_clone_urls { - let mut draft_pr_event = if let Some(ref unsigned_pr_event) = unsigned_pr_event { - unsigned_pr_event.clone() - } else { - generate_unsigned_pr_or_update_event( - git_repo, - repo_ref, - &user_ref.public_key, - root_proposal, - ahead.first().context("no commits to push")?, - &[clone_url], - &[], - )? - }; - - let refspec = format!( - "{}:refs/nostr/{}", - ahead.first().unwrap(), - draft_pr_event.id() - ); - - if let Err(error) = push_to_remote_url(git_repo, clone_url, &[refspec], term) { - failed_clone_urls.push(clone_url); - term.write_line( - format!( - "push: error sending commit data to {}: {error}", - normalize_grasp_server_url(clone_url)? - ) - .as_str(), - )?; + for event in push_refs_and_generate_pr_or_pr_update_event( + git_repo, + repo_ref, + ahead.first().context("no commits to push")?, + user_ref, + root_proposal, + signer, + term, + ) + .await.context( + if parent_is_pr { + "couldn't generate PR update event" } else { - term.write_line( - format!( - "push: commit data sent to {}", - normalize_grasp_server_url(clone_url)? - ) - .as_str(), - )?; - unsigned_pr_event = Some(draft_pr_event); + "a commit in your proposal is too big for a nostr patch so we tried to create it as a nostr PR instead. Unfortunately this failed." } - } - if unsigned_pr_event.is_none() { - bail!( - "a commit in your proposal is too big for a nostr patch. The repository doesnt list a grasp server which would otherwise be used to submit your proposal as nostr Pull Request. Soon ngit will support pushing your changes to a different git / grasp git server." - ); - - // TODO get grasp_default_set servers that aren't in repo_grasps - // cycle through until one succeeds TODO create - // personal-fork announcement with grasp servers and - // push, after a few seconds push ref/nostr/eventid. if - // one success break out of for loop and continue - } - if let Some(unsigned_pr_event) = unsigned_pr_event { - let pr_event = sign_draft_event( - unsigned_pr_event, - signer, - if root_proposal.is_some_and(|proposal| proposal.kind.eq(&Kind::GitPatch)) { - "Pull Request Replacing Original Patch" - } else if root_proposal.is_some() { - "Pull Request Update" - } else { - "Pull Request" - } - .to_string(), - ) - .await?; - events.push(pr_event); - if root_proposal.is_some_and(|proposal| proposal.kind.eq(&Kind::GitPatch)) { - events.push( - create_close_status_for_original_patch( - signer, - repo_ref, - root_proposal.unwrap(), - ) - .await?, - ); - } - } else { - bail!( - "a commit in your proposal is too big for a nostr patch. tried to use submit as a nostr Pull Request but could not find a grasp server that would accept your changes" - ); - // TODO suggest `ngit send` where user could specify their own clone - // url to push to once that feature is added + )? + { + events.push(event); } } else { for patch in generate_cover_letter_and_patch_events( @@ -567,6 +494,103 @@ async fn generate_patches_or_pr_event_or_pr_updates( Ok(events) } +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>, + signer: &Arc, + term: &Term, +) -> Result> { + let mut events: Vec = vec![]; + let repo_grasps = repo_ref.grasp_servers(); + let repo_grasp_clone_urls = repo_ref + .git_server + .iter() + .filter(|s| is_grasp_server(s, &repo_grasps)); + + let mut unsigned_pr_event: Option = None; + let mut failed_clone_urls = vec![]; + for clone_url in repo_grasp_clone_urls { + let mut draft_pr_event = if let Some(ref unsigned_pr_event) = unsigned_pr_event { + unsigned_pr_event.clone() + } else { + generate_unsigned_pr_or_update_event( + git_repo, + repo_ref, + &user_ref.public_key, + root_proposal, + tip, + &[clone_url], + &[], + )? + }; + + let refspec = format!("{}:refs/nostr/{}", tip, draft_pr_event.id()); + + if let Err(error) = push_to_remote_url(git_repo, clone_url, &[refspec], term) { + failed_clone_urls.push(clone_url); + term.write_line( + format!( + "push: error sending commit data to {}: {error}", + normalize_grasp_server_url(clone_url)? + ) + .as_str(), + )?; + } else { + term.write_line( + format!( + "push: commit data sent to {}", + normalize_grasp_server_url(clone_url)? + ) + .as_str(), + )?; + unsigned_pr_event = Some(draft_pr_event); + } + } + if unsigned_pr_event.is_none() { + bail!( + "The repository doesnt list a grasp server which would otherwise be used to submit your proposal as nostr Pull Request. Soon ngit will support pushing your changes to a different git / grasp git server." + ); + + // TODO get grasp_default_set servers that aren't in repo_grasps + // cycle through until one succeeds TODO create + // personal-fork announcement with grasp servers and + // push, after a few seconds push ref/nostr/eventid. if + // one success break out of for loop and continue + } + if let Some(unsigned_pr_event) = unsigned_pr_event { + let pr_event = sign_draft_event( + unsigned_pr_event, + signer, + if root_proposal.is_some_and(|proposal| proposal.kind.eq(&Kind::GitPatch)) { + "Pull Request Replacing Original Patch" + } else if root_proposal.is_some() { + "Pull Request Update" + } else { + "Pull Request" + } + .to_string(), + ) + .await?; + events.push(pr_event); + if root_proposal.is_some_and(|proposal| proposal.kind.eq(&Kind::GitPatch)) { + events.push( + create_close_status_for_original_patch(signer, repo_ref, root_proposal.unwrap()) + .await?, + ); + } + } else { + bail!( + "a commit in your proposal is too big for a nostr patch. tried to use submit as a nostr Pull Request but could not find a grasp server that would accept your changes" + ); + // TODO suggest `ngit send` where user could specify their own clone + // url to push to once that feature is added + } + Ok(events) +} + type HashMapUrlRefspecs = HashMap>; #[allow(clippy::too_many_lines)] -- cgit v1.2.3 From f76fe63da5f2c2f85215e86c8ecc63eda7c93902 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Mon, 4 Aug 2025 08:50:54 +0100 Subject: refactor: move generate pr event fn into lib for future use in `ngit send` --- src/bin/git_remote_nostr/push.rs | 164 ++------------------------------------ src/lib/push.rs | 168 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 172 insertions(+), 160 deletions(-) (limited to 'src/bin') diff --git a/src/bin/git_remote_nostr/push.rs b/src/bin/git_remote_nostr/push.rs index 3478608..e588a5a 100644 --- a/src/bin/git_remote_nostr/push.rs +++ b/src/bin/git_remote_nostr/push.rs @@ -10,25 +10,24 @@ use client::{get_events_from_local_cache, get_state_from_cache, send_events, sig use console::Term; use git::{RepoActions, sha1_to_oid}; use git_events::{ - generate_cover_letter_and_patch_events, generate_patch_event, - generate_unsigned_pr_or_update_event, get_commit_id_from_patch, + generate_cover_letter_and_patch_events, generate_patch_event, get_commit_id_from_patch, }; use git2::{Oid, Repository}; use ngit::{ - client::{self, get_event_from_cache_by_id, sign_draft_event}, + client::{self, get_event_from_cache_by_id}, git::{self, nostr_url::NostrUrlDecoded}, git_events::{self, KIND_PULL_REQUEST, event_to_cover_letter, get_event_root}, list::list_from_remotes, login::{self, user::UserRef}, - push::{push_to_remote, push_to_remote_url}, - repo_ref::{self, get_repo_config_from_yaml, is_grasp_server, normalize_grasp_server_url}, + push::{push_refs_and_generate_pr_or_pr_update_event, push_to_remote}, + repo_ref::{self, get_repo_config_from_yaml, is_grasp_server}, repo_state, utils::{ find_proposal_and_patches_by_branch_name, get_all_proposals, get_remote_name_by_url, get_short_git_server_name, read_line, }, }; -use nostr::{event::UnsignedEvent, nips::nip10::Marker}; +use nostr::nips::nip10::Marker; use nostr_sdk::{ Event, EventBuilder, EventId, Kind, NostrSigner, PublicKey, RelayUrl, Tag, TagStandard, hashes::sha1::Hash as Sha1Hash, @@ -494,103 +493,6 @@ async fn generate_patches_or_pr_event_or_pr_updates( Ok(events) } -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>, - signer: &Arc, - term: &Term, -) -> Result> { - let mut events: Vec = vec![]; - let repo_grasps = repo_ref.grasp_servers(); - let repo_grasp_clone_urls = repo_ref - .git_server - .iter() - .filter(|s| is_grasp_server(s, &repo_grasps)); - - let mut unsigned_pr_event: Option = None; - let mut failed_clone_urls = vec![]; - for clone_url in repo_grasp_clone_urls { - let mut draft_pr_event = if let Some(ref unsigned_pr_event) = unsigned_pr_event { - unsigned_pr_event.clone() - } else { - generate_unsigned_pr_or_update_event( - git_repo, - repo_ref, - &user_ref.public_key, - root_proposal, - tip, - &[clone_url], - &[], - )? - }; - - let refspec = format!("{}:refs/nostr/{}", tip, draft_pr_event.id()); - - if let Err(error) = push_to_remote_url(git_repo, clone_url, &[refspec], term) { - failed_clone_urls.push(clone_url); - term.write_line( - format!( - "push: error sending commit data to {}: {error}", - normalize_grasp_server_url(clone_url)? - ) - .as_str(), - )?; - } else { - term.write_line( - format!( - "push: commit data sent to {}", - normalize_grasp_server_url(clone_url)? - ) - .as_str(), - )?; - unsigned_pr_event = Some(draft_pr_event); - } - } - if unsigned_pr_event.is_none() { - bail!( - "The repository doesnt list a grasp server which would otherwise be used to submit your proposal as nostr Pull Request. Soon ngit will support pushing your changes to a different git / grasp git server." - ); - - // TODO get grasp_default_set servers that aren't in repo_grasps - // cycle through until one succeeds TODO create - // personal-fork announcement with grasp servers and - // push, after a few seconds push ref/nostr/eventid. if - // one success break out of for loop and continue - } - if let Some(unsigned_pr_event) = unsigned_pr_event { - let pr_event = sign_draft_event( - unsigned_pr_event, - signer, - if root_proposal.is_some_and(|proposal| proposal.kind.eq(&Kind::GitPatch)) { - "Pull Request Replacing Original Patch" - } else if root_proposal.is_some() { - "Pull Request Update" - } else { - "Pull Request" - } - .to_string(), - ) - .await?; - events.push(pr_event); - if root_proposal.is_some_and(|proposal| proposal.kind.eq(&Kind::GitPatch)) { - events.push( - create_close_status_for_original_patch(signer, repo_ref, root_proposal.unwrap()) - .await?, - ); - } - } else { - bail!( - "a commit in your proposal is too big for a nostr patch. tried to use submit as a nostr Pull Request but could not find a grasp server that would accept your changes" - ); - // TODO suggest `ngit send` where user could specify their own clone - // url to push to once that feature is added - } - Ok(events) -} - type HashMapUrlRefspecs = HashMap>; #[allow(clippy::too_many_lines)] @@ -1283,62 +1185,6 @@ async fn create_merge_status( .await } -async fn create_close_status_for_original_patch( - signer: &Arc, - repo_ref: &RepoRef, - proposal: &Event, -) -> Result { - let mut public_keys = repo_ref - .maintainers - .iter() - .copied() - .collect::>(); - public_keys.insert(proposal.pubkey); - - sign_event( - EventBuilder::new(nostr::event::Kind::GitStatusClosed, String::new()).tags( - [ - vec![ - Tag::custom( - nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")), - vec![ - "Git patch closed as forthcoming update is too large. Replacing with Pull Request" - .to_string(), - ], - ), - Tag::from_standardized(nostr::TagStandard::Event { - event_id: proposal.id, - relay_url: repo_ref.relays.first().cloned(), - marker: Some(Marker::Root), - public_key: None, - uppercase: false, - }), - ], - public_keys.iter().map(|pk| Tag::public_key(*pk)).collect(), - repo_ref - .coordinates() - .iter() - .map(|c| { - Tag::from_standardized(TagStandard::Coordinate { - coordinate: c.coordinate.clone(), - relay_url: c.relays.first().cloned(), - uppercase: false, - }) - }) - .collect::>(), - vec![ - Tag::from_standardized(nostr::TagStandard::Reference( - repo_ref.root_commit.to_string(), - )), - ], - ] - .concat(), - ), - signer, - "close status for original patch".to_string(), - ) - .await -} async fn get_proposal_and_revision_root_from_patch( git_repo: &Repo, patch: &Event, diff --git a/src/lib/push.rs b/src/lib/push.rs index 0d0ec93..1c09555 100644 --- a/src/lib/push.rs +++ b/src/lib/push.rs @@ -1,19 +1,31 @@ use std::{ + collections::HashSet, sync::{Arc, Mutex}, time::Instant, }; -use anyhow::{Result, anyhow}; +use anyhow::{Result, anyhow, bail}; use auth_git2::GitAuthenticator; use console::Term; +use nostr::{ + event::{Event, EventBuilder, Kind, Tag, TagStandard, UnsignedEvent}, + hashes::sha1::Hash as Sha1Hash, + key::PublicKey, + nips::nip10::Marker, + signer::NostrSigner, +}; use crate::{ cli_interactor::count_lines_per_msg_vec, + client::{sign_draft_event, sign_event}, git::{ Repo, nostr_url::{CloneUrl, NostrUrlDecoded}, oid_to_shorthand_string, }, + git_events::generate_unsigned_pr_or_update_event, + login::user::UserRef, + repo_ref::{RepoRef, is_grasp_server, normalize_grasp_server_url}, utils::{ Direction, get_short_git_server_name, get_write_protocols_to_try, join_with_and, set_protocol_preference, @@ -308,3 +320,157 @@ impl<'a> PushReporter<'a> { } } } + +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>, + signer: &Arc, + term: &Term, +) -> Result> { + let mut events: Vec = vec![]; + let repo_grasps = repo_ref.grasp_servers(); + let repo_grasp_clone_urls = repo_ref + .git_server + .iter() + .filter(|s| is_grasp_server(s, &repo_grasps)); + + let mut unsigned_pr_event: Option = None; + let mut failed_clone_urls = vec![]; + for clone_url in repo_grasp_clone_urls { + let mut draft_pr_event = if let Some(ref unsigned_pr_event) = unsigned_pr_event { + unsigned_pr_event.clone() + } else { + generate_unsigned_pr_or_update_event( + git_repo, + repo_ref, + &user_ref.public_key, + root_proposal, + tip, + &[clone_url], + &[], + )? + }; + + let refspec = format!("{}:refs/nostr/{}", tip, draft_pr_event.id()); + + if let Err(error) = push_to_remote_url(git_repo, clone_url, &[refspec], term) { + failed_clone_urls.push(clone_url); + term.write_line( + format!( + "push: error sending commit data to {}: {error}", + normalize_grasp_server_url(clone_url)? + ) + .as_str(), + )?; + } else { + term.write_line( + format!( + "push: commit data sent to {}", + normalize_grasp_server_url(clone_url)? + ) + .as_str(), + )?; + unsigned_pr_event = Some(draft_pr_event); + } + } + if unsigned_pr_event.is_none() { + bail!( + "The repository doesnt list a grasp server which would otherwise be used to submit your proposal as nostr Pull Request. Soon ngit will support pushing your changes to a different git / grasp git server." + ); + + // TODO get grasp_default_set servers that aren't in repo_grasps + // cycle through until one succeeds TODO create + // personal-fork announcement with grasp servers and + // push, after a few seconds push ref/nostr/eventid. if + // one success break out of for loop and continue + } + if let Some(unsigned_pr_event) = unsigned_pr_event { + let pr_event = sign_draft_event( + unsigned_pr_event, + signer, + if root_proposal.is_some_and(|proposal| proposal.kind.eq(&Kind::GitPatch)) { + "Pull Request Replacing Original Patch" + } else if root_proposal.is_some() { + "Pull Request Update" + } else { + "Pull Request" + } + .to_string(), + ) + .await?; + events.push(pr_event); + if root_proposal.is_some_and(|proposal| proposal.kind.eq(&Kind::GitPatch)) { + events.push( + create_close_status_for_original_patch(signer, repo_ref, root_proposal.unwrap()) + .await?, + ); + } + } else { + bail!( + "a commit in your proposal is too big for a nostr patch. tried to use submit as a nostr Pull Request but could not find a grasp server that would accept your changes" + ); + // TODO suggest `ngit send` where user could specify their own clone + // url to push to once that feature is added + } + Ok(events) +} + +async fn create_close_status_for_original_patch( + signer: &Arc, + repo_ref: &RepoRef, + proposal: &Event, +) -> Result { + let mut public_keys = repo_ref + .maintainers + .iter() + .copied() + .collect::>(); + public_keys.insert(proposal.pubkey); + + sign_event( + EventBuilder::new(nostr::event::Kind::GitStatusClosed, String::new()).tags( + [ + vec![ + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")), + vec![ + "Git patch closed as forthcoming update is too large. Replacing with Pull Request" + .to_string(), + ], + ), + Tag::from_standardized(nostr::TagStandard::Event { + event_id: proposal.id, + relay_url: repo_ref.relays.first().cloned(), + marker: Some(Marker::Root), + public_key: None, + uppercase: false, + }), + ], + public_keys.iter().map(|pk| Tag::public_key(*pk)).collect(), + repo_ref + .coordinates() + .iter() + .map(|c| { + Tag::from_standardized(TagStandard::Coordinate { + coordinate: c.coordinate.clone(), + relay_url: c.relays.first().cloned(), + uppercase: false, + }) + }) + .collect::>(), + vec![ + Tag::from_standardized(nostr::TagStandard::Reference( + repo_ref.root_commit.to_string(), + )), + ], + ] + .concat(), + ), + signer, + "close status for original patch".to_string(), + ) + .await +} -- 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 'src/bin') 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 From dee39c39116773fde22c4fe30a87d54d1d3658e2 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Tue, 5 Aug 2025 11:08:26 +0100 Subject: feat(send): push PR to custom clone url if the repo doesnt list any grasp servers, or pushing to them fails --- src/bin/git_remote_nostr/push.rs | 44 +++++++++++++++++++-------- src/bin/ngit/sub_commands/send.rs | 64 ++++++++++++++++++++++++++++++--------- src/lib/push.rs | 55 ++++++++++++++------------------- 3 files changed, 103 insertions(+), 60 deletions(-) (limited to 'src/bin') diff --git a/src/bin/git_remote_nostr/push.rs b/src/bin/git_remote_nostr/push.rs index 3967699..4552b91 100644 --- a/src/bin/git_remote_nostr/push.rs +++ b/src/bin/git_remote_nostr/push.rs @@ -450,18 +450,38 @@ async fn generate_patches_or_pr_event_or_pr_updates( signer: &Arc, term: &Term, ) -> Result> { - let mut events: Vec = vec![]; let parent_is_pr = root_proposal.is_some_and(|proposal| proposal.kind.eq(&KIND_PULL_REQUEST)); let use_pr = parent_is_pr || git_repo.are_commits_too_big_for_patches(ahead); if use_pr { - for event in push_refs_and_generate_pr_or_pr_update_event( + let repo_grasps = repo_ref.grasp_servers(); + let repo_grasp_clone_urls: Vec = repo_ref + .git_server + .iter() + .filter(|s| is_grasp_server(s, &repo_grasps)) + .cloned() + .collect(); + + if repo_grasp_clone_urls.is_empty() { + // TODO get grasp_default_set servers that aren't in repo_grasps + // cycle through until one succeeds TODO create + // personal-fork announcement with grasp servers and + // push, after a few seconds push ref/nostr/eventid. if + // one success break out of for loop and continue + + bail!( + "The repository doesnt list a grasp server which would otherwise be used to submit your proposal as nostr Pull Request. Soon ngit will support pushing your changes to a different git / grasp git server." + ); + } + + if let (Some(events), _) = push_refs_and_generate_pr_or_pr_update_event( git_repo, repo_ref, ahead.first().context("no commits to push")?, user_ref, root_proposal, &None, + &repo_grasp_clone_urls, signer, term, ) @@ -471,12 +491,17 @@ async fn generate_patches_or_pr_event_or_pr_updates( } else { "a commit in your proposal is too big for a nostr patch so we tried to create it as a nostr PR instead. Unfortunately this failed." } - )? - { - events.push(event); + )? { + Ok(events) + } else { + bail!( + "a commit in your proposal is too big for a nostr patch. tried to use submit as a nostr Pull Request but could not find a grasp server that would accept your changes" + ); + // TODO suggest `ngit send` where user could specify their own clone + // url to push to once that feature is added } } else { - for patch in generate_cover_letter_and_patch_events( + generate_cover_letter_and_patch_events( None, git_repo, ahead, @@ -485,13 +510,8 @@ async fn generate_patches_or_pr_event_or_pr_updates( &root_proposal.map(|proposal| proposal.id.to_string()), &[], ) - .await? - { - events.push(patch); - } + .await } - - Ok(events) } type HashMapUrlRefspecs = HashMap>; diff --git a/src/bin/ngit/sub_commands/send.rs b/src/bin/ngit/sub_commands/send.rs index 0aefb03..69ad1e6 100644 --- a/src/bin/ngit/sub_commands/send.rs +++ b/src/bin/ngit/sub_commands/send.rs @@ -1,11 +1,13 @@ -use std::path::Path; +use std::{path::Path, str::FromStr}; use anyhow::{Context, Result, bail}; use console::Style; use ngit::{ client::{Params, send_events}, + git::nostr_url::CloneUrl, git_events::{EventRefType, KIND_PULL_REQUEST, generate_cover_letter_and_patch_events}, push::push_refs_and_generate_pr_or_pr_update_event, + repo_ref::is_grasp_server, utils::proposal_tip_is_pr_or_pr_update, }; use nostr::{ToBech32, event::Event, nips::nip19::Nip19Event}; @@ -192,20 +194,52 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re commits.reverse(); 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? - - // TODO - // - allow specifying clone url and ref + let repo_grasps = repo_ref.grasp_servers(); + let repo_grasp_clone_urls: Vec = repo_ref + .git_server + .iter() + .filter(|s| is_grasp_server(s, &repo_grasps)) + .cloned() + .collect(); + if repo_grasp_clone_urls.is_empty() { + println!( + "The repository doesn't list a grasp server which would otherwise be used to submit your proposal as nostr Pull Request." + ); + } + let mut to_try = repo_grasp_clone_urls.clone(); + let mut tried = vec![]; + loop { + let (events, _server_responses) = 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, + &repo_grasp_clone_urls, + &signer, + &console::Term::stdout(), + ) + .await?; + for url in to_try { + tried.push(url); + } + to_try = vec![]; + if let Some(events) = events { + break events; + } + let clone_url = Interactor::default() + .input( + PromptInputParms::default().with_prompt("git repo url with write permission"), + )? + .clone(); + if CloneUrl::from_str(&clone_url).is_ok() { + to_try.push(clone_url); + // TODO customise ref to push + } else { + println!("invalid clone url"); + } + } } else { let events = generate_cover_letter_and_patch_events( cover_letter_title_description.clone(), diff --git a/src/lib/push.rs b/src/lib/push.rs index bcd368b..4c2d8f1 100644 --- a/src/lib/push.rs +++ b/src/lib/push.rs @@ -4,7 +4,7 @@ use std::{ time::Instant, }; -use anyhow::{Result, anyhow, bail}; +use anyhow::{Result, anyhow}; use auth_git2::GitAuthenticator; use console::Term; use nostr::{ @@ -25,7 +25,7 @@ use crate::{ }, git_events::generate_unsigned_pr_or_update_event, login::user::UserRef, - repo_ref::{RepoRef, is_grasp_server, normalize_grasp_server_url}, + repo_ref::{RepoRef, normalize_grasp_server_url}, utils::{ Direction, get_short_git_server_name, get_write_protocols_to_try, join_with_and, set_protocol_preference, @@ -329,19 +329,14 @@ pub async fn push_refs_and_generate_pr_or_pr_update_event( user_ref: &UserRef, root_proposal: Option<&Event>, title_description_overide: &Option<(String, String)>, + servers: &[String], signer: &Arc, term: &Term, -) -> Result> { - let mut events: Vec = vec![]; - let repo_grasps = repo_ref.grasp_servers(); - let repo_grasp_clone_urls = repo_ref - .git_server - .iter() - .filter(|s| is_grasp_server(s, &repo_grasps)); +) -> Result<(Option>, Vec<(String, Result<()>)>)> { + let mut responses = vec![]; let mut unsigned_pr_event: Option = None; - let mut failed_clone_urls = vec![]; - for clone_url in repo_grasp_clone_urls { + for clone_url in servers { let mut draft_pr_event = if let Some(ref unsigned_pr_event) = unsigned_pr_event { unsigned_pr_event.clone() } else { @@ -360,7 +355,6 @@ pub async fn push_refs_and_generate_pr_or_pr_update_event( let refspec = format!("{}:refs/nostr/{}", tip, draft_pr_event.id()); if let Err(error) = push_to_remote_url(git_repo, clone_url, &[refspec], term) { - failed_clone_urls.push(clone_url); term.write_line( format!( "push: error sending commit data to {}: {error}", @@ -368,7 +362,9 @@ pub async fn push_refs_and_generate_pr_or_pr_update_event( ) .as_str(), )?; + responses.push((clone_url.clone(), Err(error))); } else { + responses.push((clone_url.clone(), Ok(()))); term.write_line( format!( "push: commit data sent to {}", @@ -379,17 +375,6 @@ pub async fn push_refs_and_generate_pr_or_pr_update_event( unsigned_pr_event = Some(draft_pr_event); } } - if unsigned_pr_event.is_none() { - bail!( - "The repository doesnt list a grasp server which would otherwise be used to submit your proposal as nostr Pull Request. Soon ngit will support pushing your changes to a different git / grasp git server." - ); - - // TODO get grasp_default_set servers that aren't in repo_grasps - // cycle through until one succeeds TODO create - // personal-fork announcement with grasp servers and - // push, after a few seconds push ref/nostr/eventid. if - // one success break out of for loop and continue - } if let Some(unsigned_pr_event) = unsigned_pr_event { let pr_event = sign_draft_event( unsigned_pr_event, @@ -404,21 +389,25 @@ pub async fn push_refs_and_generate_pr_or_pr_update_event( .to_string(), ) .await?; - events.push(pr_event); if root_proposal.is_some_and(|proposal| proposal.kind.eq(&Kind::GitPatch)) { - events.push( - create_close_status_for_original_patch(signer, repo_ref, root_proposal.unwrap()) + Ok(( + Some(vec![ + pr_event, + create_close_status_for_original_patch( + signer, + repo_ref, + root_proposal.unwrap(), + ) .await?, - ); + ]), + responses, + )) + } else { + Ok((Some(vec![pr_event]), responses)) } } else { - bail!( - "a commit in your proposal is too big for a nostr patch. tried to use submit as a nostr Pull Request but could not find a grasp server that would accept your changes" - ); - // TODO suggest `ngit send` where user could specify their own clone - // url to push to once that feature is added + Ok((None, responses)) } - Ok(events) } async fn create_close_status_for_original_patch( -- cgit v1.2.3 From 29f61ffdf155ea88b8d9aec23d28cf70baba577e Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Tue, 5 Aug 2025 11:38:09 +0100 Subject: feat(send): custom ref for PR clone url allow specifying ref for pushing PR to custom clone url --- src/bin/git_remote_nostr/push.rs | 1 + src/bin/ngit/sub_commands/send.rs | 14 +++++++++++++- src/lib/push.rs | 8 +++++++- 3 files changed, 21 insertions(+), 2 deletions(-) (limited to 'src/bin') diff --git a/src/bin/git_remote_nostr/push.rs b/src/bin/git_remote_nostr/push.rs index 4552b91..f98e792 100644 --- a/src/bin/git_remote_nostr/push.rs +++ b/src/bin/git_remote_nostr/push.rs @@ -482,6 +482,7 @@ async fn generate_patches_or_pr_event_or_pr_updates( root_proposal, &None, &repo_grasp_clone_urls, + None, signer, term, ) diff --git a/src/bin/ngit/sub_commands/send.rs b/src/bin/ngit/sub_commands/send.rs index 69ad1e6..609812b 100644 --- a/src/bin/ngit/sub_commands/send.rs +++ b/src/bin/ngit/sub_commands/send.rs @@ -208,6 +208,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re } let mut to_try = repo_grasp_clone_urls.clone(); let mut tried = vec![]; + let mut git_ref = None; loop { let (events, _server_responses) = push_refs_and_generate_pr_or_pr_update_event( &git_repo, @@ -217,6 +218,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re root_proposal.as_ref(), &cover_letter_title_description, &repo_grasp_clone_urls, + git_ref.clone(), &signer, &console::Term::stdout(), ) @@ -235,7 +237,17 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re .clone(); if CloneUrl::from_str(&clone_url).is_ok() { to_try.push(clone_url); - // TODO customise ref to push + let mut git_ref_or_branch_name = Interactor::default() + .input( + PromptInputParms::default() + .with_prompt("ref / branch name") + .with_default(git_ref.unwrap_or("refs/nostr/".to_string())), + )? + .clone(); + if !git_ref_or_branch_name.starts_with("refs/") { + git_ref_or_branch_name = format!("refs/heads/{git_ref_or_branch_name}"); + } + git_ref = Some(git_ref_or_branch_name); } else { println!("invalid clone url"); } diff --git a/src/lib/push.rs b/src/lib/push.rs index 4c2d8f1..c202397 100644 --- a/src/lib/push.rs +++ b/src/lib/push.rs @@ -330,6 +330,7 @@ pub async fn push_refs_and_generate_pr_or_pr_update_event( root_proposal: Option<&Event>, title_description_overide: &Option<(String, String)>, servers: &[String], + git_ref: Option, signer: &Arc, term: &Term, ) -> Result<(Option>, Vec<(String, Result<()>)>)> { @@ -352,7 +353,12 @@ pub async fn push_refs_and_generate_pr_or_pr_update_event( )? }; - let refspec = format!("{}:refs/nostr/{}", tip, draft_pr_event.id()); + let git_ref_used = git_ref + .clone() + .unwrap_or("refs/nostr/".to_string()) + .replace("", &draft_pr_event.id().to_string()); + + let refspec = format!("{tip}:{git_ref_used}"); if let Err(error) = push_to_remote_url(git_repo, clone_url, &[refspec], term) { term.write_line( -- cgit v1.2.3 From a9b2ebf8216be34950e54dd9a446dbdc0c9c744a Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 6 Aug 2025 12:52:59 +0100 Subject: feat(send): PR fallback to user / custom grasp if use is maintainer, push PR to all repo git servers. if user has a fork, push to all git servers it lists, and repo grasp servers. if user hasn't got a fork but has a user grasp list and pushing push to repo grasp servers fails, create a personal-fork automatically at each user grasp server and push there. fallback to prompting user for either grasp servers or git server with write permission. if user provides grasp servers, suggesting adding to user preference list. --- src/bin/ngit/sub_commands/init.rs | 106 ++------------- src/bin/ngit/sub_commands/send.rs | 275 +++++++++++++++++++++++++++++++++----- src/lib/cli_interactor.rs | 87 ++++++++++++ src/lib/client.rs | 54 ++++---- src/lib/git_events.rs | 1 + src/lib/login/user.rs | 77 ++++++++++- src/lib/repo_ref.rs | 83 +++++++++++- 7 files changed, 519 insertions(+), 164 deletions(-) (limited to 'src/bin') diff --git a/src/bin/ngit/sub_commands/init.rs b/src/bin/ngit/sub_commands/init.rs index eaaf83d..01fcaea 100644 --- a/src/bin/ngit/sub_commands/init.rs +++ b/src/bin/ngit/sub_commands/init.rs @@ -12,12 +12,12 @@ use console::{Style, Term}; use dialoguer::theme::{ColorfulTheme, Theme}; use ngit::{ UrlWithoutSlash, - cli_interactor::{PromptChoiceParms, PromptConfirmParms, PromptMultiChoiceParms}, + cli_interactor::{PromptChoiceParms, PromptConfirmParms, multi_select_with_custom_value}, client::{Params, send_events}, git::nostr_url::{CloneUrl, NostrUrlDecoded}, repo_ref::{ - detect_existing_grasp_servers, extract_npub, extract_pks, normalize_grasp_server_url, - save_repo_config_to_yaml, + detect_existing_grasp_servers, extract_npub, extract_pks, + format_grasp_server_url_as_relay_url, normalize_grasp_server_url, save_repo_config_to_yaml, }, }; use nostr::{ @@ -727,6 +727,11 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { web, relays: relays.clone(), blossoms, + hashtags: if let Some(repo_ref) = repo_ref { + repo_ref.hashtags + } else { + vec![] + }, trusted_maintainer: user_ref.public_key, maintainers_without_annoucnement: None, maintainers: maintainers.clone(), @@ -848,93 +853,6 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { Ok(()) } -fn multi_select_with_custom_value( - prompt: &str, - custom_choice_prompt: &str, - mut choices: Vec, - mut defaults: Vec, - validate_choice: F, -) -> Result> -where - F: Fn(&str) -> Result, -{ - let mut selected_choices = vec![]; - - // Loop to allow users to add more choices - loop { - // Add 'add another' option at the end of the choices - let mut current_choices = choices.clone(); - current_choices.push(if current_choices.is_empty() { - "add".to_string() - } else { - "add another".to_string() - }); - - // Create default selections based on the provided defaults - let mut current_defaults = defaults.clone(); - current_defaults.push(current_choices.len() == 1); // 'add another' should not be selected by default - - // Prompt for selections - let selected_indices: Vec = Interactor::default().multi_choice( - PromptMultiChoiceParms::default() - .with_prompt(prompt) - .dont_report() - .with_choices(current_choices.clone()) - .with_defaults(current_defaults), - )?; - - // Collect selected choices - selected_choices.clear(); // Clear previous selections to update - for &index in &selected_indices { - if index < choices.len() { - // Exclude 'add another' option - selected_choices.push(choices[index].clone()); - } - } - - // Check if 'add another' was selected - if selected_indices.contains(&(choices.len())) { - // Last index is 'add another' - let mut new_choice: String; - loop { - new_choice = Interactor::default().input( - PromptInputParms::default() - .with_prompt(custom_choice_prompt) - .dont_report() - .optional(), - )?; - - if new_choice.is_empty() { - break; - } - // Validate the new choice - match validate_choice(&new_choice) { - Ok(valid_choice) => { - new_choice = valid_choice; // Use the fixed version of the input - break; // Valid choice, exit the loop - } - Err(err) => { - // Inform the user about the validation error - println!("Error: {err}"); - } - } - } - - // Add the new choice to the choices vector - if !new_choice.is_empty() { - choices.push(new_choice.clone()); // Add new choice to the end of the list - selected_choices.push(new_choice); // Automatically select the new choice - defaults.push(true); // Set the new choice as selected by default - } - } else { - // Exit the loop if 'add another' was not selected - break; - } - } - - Ok(selected_choices) -} - fn format_grasp_server_url_as_clone_url( url: &str, public_key: &PublicKey, @@ -953,14 +871,6 @@ fn format_grasp_server_url_as_clone_url( )) } -fn format_grasp_server_url_as_relay_url(url: &str) -> Result { - let grasp_server_url = normalize_grasp_server_url(url)?; - if grasp_server_url.contains("http://") { - return Ok(grasp_server_url.replace("http://", "ws://")); - } - Ok(format!("wss://{grasp_server_url}")) -} - fn format_grasp_server_url_as_blossom_url(url: &str) -> Result { let grasp_server_url = normalize_grasp_server_url(url)?; if grasp_server_url.contains("http://") { diff --git a/src/bin/ngit/sub_commands/send.rs b/src/bin/ngit/sub_commands/send.rs index 609812b..835153e 100644 --- a/src/bin/ngit/sub_commands/send.rs +++ b/src/bin/ngit/sub_commands/send.rs @@ -1,16 +1,27 @@ -use std::{path::Path, str::FromStr}; +use std::{path::Path, str::FromStr, thread, time::Duration}; use anyhow::{Context, Result, bail}; use console::Style; use ngit::{ + cli_interactor::{PromptChoiceParms, multi_select_with_custom_value}, client::{Params, send_events}, git::nostr_url::CloneUrl, git_events::{EventRefType, KIND_PULL_REQUEST, generate_cover_letter_and_patch_events}, push::push_refs_and_generate_pr_or_pr_update_event, - repo_ref::is_grasp_server, + repo_ref::{ + format_grasp_server_url_as_clone_url, format_grasp_server_url_as_relay_url, + is_grasp_server, normalize_grasp_server_url, + }, utils::proposal_tip_is_pr_or_pr_update, }; -use nostr::{ToBech32, event::Event, nips::nip19::Nip19Event}; +use nostr::{ + ToBech32, + event::Event, + nips::{ + nip01::Coordinate, + nip19::{Nip19Coordinate, Nip19Event}, + }, +}; use nostr_sdk::hashes::sha1::Hash as Sha1Hash; use crate::{ @@ -179,7 +190,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re None }; - let (signer, user_ref, _) = login::login_or_signup( + let (signer, mut user_ref, _) = login::login_or_signup( &Some(&git_repo), &extract_signer_cli_arguments(cli_args).unwrap_or(None), &cli_args.password, @@ -194,20 +205,55 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re commits.reverse(); let events = if as_pr { + let mut to_try = vec![]; + let mut tried = vec![]; let repo_grasps = repo_ref.grasp_servers(); - let repo_grasp_clone_urls: Vec = repo_ref - .git_server - .iter() - .filter(|s| is_grasp_server(s, &repo_grasps)) - .cloned() - .collect(); - if repo_grasp_clone_urls.is_empty() { + // if the user already has a fork, or is a maintainer, use those git servers + let mut user_repo_ref = get_repo_ref_from_cache( + Some(git_repo_path), + &Nip19Coordinate { + coordinate: Coordinate { + kind: nostr::event::Kind::GitRepoAnnouncement, + public_key: user_ref.public_key, + identifier: repo_ref.identifier.clone(), + }, + relays: vec![], + }, + ) + .await + .ok(); + if let Some(user_repo_ref) = &user_repo_ref { + for url in &user_repo_ref.git_server { + if CloneUrl::from_str(url).is_ok() { + to_try.push(url.clone()); + } + } + } + if !to_try.is_empty() || !repo_grasps.is_empty() { + println!( + "pushing proposal refs to {}", + if repo_ref.maintainers.contains(&user_ref.public_key) { + "repository git servers" + } else if to_try.is_empty() { + "repository grasp servers" + } else if repo_grasps.is_empty() { + "the git servers listed in your fork" + } else { + "the git servers listed in your fork and repository grasp servers" + } + ); + } else { println!( "The repository doesn't list a grasp server which would otherwise be used to submit your proposal as nostr Pull Request." ); } - let mut to_try = repo_grasp_clone_urls.clone(); - let mut tried = vec![]; + // also use repo grasp servers + for url in &repo_ref.git_server { + if is_grasp_server(url, &repo_grasps) && !to_try.contains(url) { + to_try.push(url.clone()); + } + } + let mut git_ref = None; loop { let (events, _server_responses) = push_refs_and_generate_pr_or_pr_update_event( @@ -217,7 +263,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re &user_ref, root_proposal.as_ref(), &cover_letter_title_description, - &repo_grasp_clone_urls, + &to_try, git_ref.clone(), &signer, &console::Term::stdout(), @@ -230,27 +276,194 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re if let Some(events) = events { break events; } - let clone_url = Interactor::default() - .input( - PromptInputParms::default().with_prompt("git repo url with write permission"), - )? - .clone(); - if CloneUrl::from_str(&clone_url).is_ok() { - to_try.push(clone_url); - let mut git_ref_or_branch_name = Interactor::default() - .input( - PromptInputParms::default() - .with_prompt("ref / branch name") - .with_default(git_ref.unwrap_or("refs/nostr/".to_string())), + // fallback to creating user personal-fork on their grasp servers + let untried_user_grasp_servers: Vec = user_ref + .grasp_list + .urls + .iter() + .map(std::string::ToString::to_string) + .filter(|g| { + // is a grasp server not in list of tried + !is_grasp_server(g, &tried) + }) + .collect(); + + if untried_user_grasp_servers.is_empty() + && Interactor::default().choice( + PromptChoiceParms::default() + .with_prompt("choose alternative git server") + .dont_report() + .with_choices(vec![ + "choose grasp server(s)".to_string(), + "enter a git repo url with write permission".to_string(), + ]) + .with_default(0), + )? == 1 + { + loop { + let clone_url = Interactor::default() + .input( + PromptInputParms::default() + .with_prompt("git repo url with write permission"), + )? + .clone(); + if CloneUrl::from_str(&clone_url).is_ok() { + to_try.push(clone_url); + let mut git_ref_or_branch_name = Interactor::default() + .input( + PromptInputParms::default() + .with_prompt("ref / branch name") + .with_default( + git_ref.unwrap_or("refs/nostr/".to_string()), + ), + )? + .clone(); + if !git_ref_or_branch_name.starts_with("refs/") { + git_ref_or_branch_name = format!("refs/heads/{git_ref_or_branch_name}"); + } + git_ref = Some(git_ref_or_branch_name); + break; + } + println!("invalid clone url"); + } + continue; + } + + let mut new_grasp_server_events: Vec = vec![]; + + let grasp_servers = if untried_user_grasp_servers.is_empty() { + let default_choices: Vec = client + .get_grasp_default_set() + .iter() + .filter(|g| !is_grasp_server(g, &tried)) + .cloned() + .collect(); + let selections = vec![true; default_choices.len()]; // all selected by default + let grasp_servers = multi_select_with_custom_value( + "grasp server(s)", + "grasp server", + default_choices, + selections, + normalize_grasp_server_url, + )?; + if grasp_servers.is_empty() { + // ask again + continue; + } + let normalised_grasp_servers: Vec = grasp_servers + .iter() + .filter_map(|g| normalize_grasp_server_url(g).ok()) + .collect(); + // if any grasp servers not listed in user grasp list prompt to update + let grasp_servers_not_in_user_prefs: Vec = normalised_grasp_servers + .iter() + .filter(|g| { + !user_ref.grasp_list.urls.contains( + // unwrap is safe as we constructed g + &nostr::Url::parse(&format_grasp_server_url_as_relay_url(g).unwrap()) + .unwrap(), + ) + }) + .cloned() + .collect(); + if !grasp_servers_not_in_user_prefs.is_empty() + && Interactor::default().confirm( + PromptConfirmParms::default() + .with_prompt( + "add these to your list of prefered grasp servers?".to_string(), + ) + .with_default(true), )? - .clone(); - if !git_ref_or_branch_name.starts_with("refs/") { - git_ref_or_branch_name = format!("refs/heads/{git_ref_or_branch_name}"); + { + for g in &normalised_grasp_servers { + let as_url = nostr::Url::parse(&format_grasp_server_url_as_relay_url(g)?)?; + if !user_ref.grasp_list.urls.contains(&as_url) { + user_ref.grasp_list.urls.push(as_url); + } + } + new_grasp_server_events.push(user_ref.grasp_list.to_event(&signer).await?); } - git_ref = Some(git_ref_or_branch_name); + normalised_grasp_servers } else { - println!("invalid clone url"); + println!( + "{} personal-fork so we can push commits to your prefered grasp servers", + if user_repo_ref.is_some() { + "Updating" + } else { + "Creating a" + }, + ); + untried_user_grasp_servers + }; + + let grasp_servers_as_personal_clone_url: Vec = grasp_servers + .iter() + .filter_map(|g| { + format_grasp_server_url_as_clone_url( + g, + &user_ref.public_key, + &repo_ref.identifier, + ) + .ok() + }) + .collect(); + + // create personal-fork / update existing user repo and add these grasp servers + let updated_user_repo_ref = { + if let Some(mut user_repo_ref) = user_repo_ref { + for g in &grasp_servers_as_personal_clone_url { + let _ = user_repo_ref.add_grasp_server(g); + } + user_repo_ref + } else { + // clone repo_ref and reset as personal-fork + let mut user_repo_ref = repo_ref.clone(); + user_repo_ref.trusted_maintainer = user_ref.public_key; + user_repo_ref.maintainers = vec![user_ref.public_key]; + user_repo_ref.git_server = vec![]; + user_repo_ref.relays = vec![]; + if !user_repo_ref + .hashtags + .contains(&"personal-fork".to_string()) + { + user_repo_ref.hashtags.push("personal-fork".to_string()); + } + user_repo_ref + } + }; + // pubish event to my-relays and my-fork-relays + new_grasp_server_events.push(updated_user_repo_ref.to_event(&signer).await?); + send_events( + &client, + Some(git_repo_path), + new_grasp_server_events, + user_ref.relays.write(), + updated_user_repo_ref.relays.clone(), + !cli_args.disable_cli_spinners, + false, + ) + .await?; + user_repo_ref = Some(updated_user_repo_ref); + // wait a few seconds + let countdown_start = 5; + let term = console::Term::stdout(); + for i in (1..=countdown_start).rev() { + term.write_line( + format!( + "waiting {i}s grasp servers to create your repo before we push your data" + ) + .as_str(), + )?; + thread::sleep(Duration::new(1, 0)); // Sleep for 1 second + term.clear_last_lines(1)?; + } + term.flush().unwrap(); // Ensure the output is flushed to the terminal + + // add grasp servers to to_try + for url in grasp_servers_as_personal_clone_url { + to_try.push(url); } + // the loop with continue with the grasp servers } } else { let events = generate_cover_letter_and_patch_events( diff --git a/src/lib/cli_interactor.rs b/src/lib/cli_interactor.rs index 8fca81d..8bcda19 100644 --- a/src/lib/cli_interactor.rs +++ b/src/lib/cli_interactor.rs @@ -236,6 +236,93 @@ impl PromptMultiChoiceParms { } } +pub fn multi_select_with_custom_value( + prompt: &str, + custom_choice_prompt: &str, + mut choices: Vec, + mut defaults: Vec, + validate_choice: F, +) -> Result> +where + F: Fn(&str) -> Result, +{ + let mut selected_choices = vec![]; + + // Loop to allow users to add more choices + loop { + // Add 'add another' option at the end of the choices + let mut current_choices = choices.clone(); + current_choices.push(if current_choices.is_empty() { + "add".to_string() + } else { + "add another".to_string() + }); + + // Create default selections based on the provided defaults + let mut current_defaults = defaults.clone(); + current_defaults.push(current_choices.len() == 1); // 'add another' should not be selected by default + + // Prompt for selections + let selected_indices: Vec = Interactor::default().multi_choice( + PromptMultiChoiceParms::default() + .with_prompt(prompt) + .dont_report() + .with_choices(current_choices.clone()) + .with_defaults(current_defaults), + )?; + + // Collect selected choices + selected_choices.clear(); // Clear previous selections to update + for &index in &selected_indices { + if index < choices.len() { + // Exclude 'add another' option + selected_choices.push(choices[index].clone()); + } + } + + // Check if 'add another' was selected + if selected_indices.contains(&(choices.len())) { + // Last index is 'add another' + let mut new_choice: String; + loop { + new_choice = Interactor::default().input( + PromptInputParms::default() + .with_prompt(custom_choice_prompt) + .dont_report() + .optional(), + )?; + + if new_choice.is_empty() { + break; + } + // Validate the new choice + match validate_choice(&new_choice) { + Ok(valid_choice) => { + new_choice = valid_choice; // Use the fixed version of the input + break; // Valid choice, exit the loop + } + Err(err) => { + // Inform the user about the validation error + println!("Error: {err}"); + } + } + } + + // Add the new choice to the choices vector + if !new_choice.is_empty() { + choices.push(new_choice.clone()); // Add new choice to the end of the list + selected_choices.push(new_choice); // Automatically select the new choice + defaults.push(true); // Set the new choice as selected by default + } + } else { + // Exit the loop if 'add another' was not selected + break; + } + } + + Ok(selected_choices) +} + #[derive(Debug, Default)] pub struct Printer { printed_lines: Vec, diff --git a/src/lib/client.rs b/src/lib/client.rs index b27f9b1..9ce3e24 100644 --- a/src/lib/client.rs +++ b/src/lib/client.rs @@ -53,7 +53,7 @@ use crate::{ get_dirs, git::{Repo, RepoActions, get_git_config_item}, git_events::{ - KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, event_is_cover_letter, + KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, KIND_USER_GRASP_LIST, event_is_cover_letter, event_is_patch_set_root, event_is_revision_root, event_is_valid_pr_or_pr_update, status_kinds, }, @@ -233,7 +233,7 @@ impl Connect for Client { if let Some(git_repo_path) = git_repo_path { save_event_in_local_cache(git_repo_path, &event).await?; } - if event.kind.eq(&Kind::GitRepoAnnouncement) { + if [Kind::GitRepoAnnouncement, KIND_USER_GRASP_LIST].contains(&event.kind) { save_event_in_global_cache(git_repo_path, &event).await?; } Ok(event.id) @@ -1310,17 +1310,21 @@ async fn create_relays_request( user_profiles.insert(current_user); } } - let mut map: HashMap = HashMap::new(); + let mut map: HashMap = HashMap::new(); for public_key in &user_profiles { if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await { map.insert( public_key.to_owned(), - (user_ref.metadata.created_at, user_ref.relays.created_at), + ( + user_ref.metadata.created_at, + user_ref.relays.created_at, + user_ref.grasp_list.created_at, + ), ); } else { map.insert( public_key.to_owned(), - (Timestamp::from(0), Timestamp::from(0)), + (Timestamp::from(0), Timestamp::from(0), Timestamp::from(0)), ); } } @@ -1547,16 +1551,22 @@ async fn process_fetched_events( { fresh_profiles.insert(event.pubkey); } - } else if [Kind::RelayList, Kind::Metadata].contains(&event.kind) { + } else if [Kind::RelayList, Kind::Metadata, KIND_USER_GRASP_LIST].contains(&event.kind) + { if request.missing_contributor_profiles.contains(&event.pubkey) { report.contributor_profiles.insert(event.pubkey); - } else if let Some((_, (metadata_timestamp, relay_list_timestamp))) = request + } else if let Some(( + _, + (metadata_timestamp, relay_list_timestamp, grasp_list_timestamp), + )) = request .profiles_to_fetch_from_user_relays .get_key_value(&event.pubkey) { if (Kind::Metadata.eq(&event.kind) && event.created_at.gt(metadata_timestamp)) || (Kind::RelayList.eq(&event.kind) && event.created_at.gt(relay_list_timestamp)) + || (KIND_USER_GRASP_LIST.eq(&event.kind) + && event.created_at.gt(grasp_list_timestamp)) { report.profile_updates.insert(event.pubkey); } @@ -1718,35 +1728,21 @@ pub fn get_filter_repo_events(repo_coordinates: &HashSet) -> no .map(|c| c.identifier.clone()) .collect::>(), ) - .authors( - repo_coordinates - .iter() - .map(|c| c.public_key) - .collect::>(), - ) } pub static STATE_KIND: nostr::Kind = Kind::Custom(30618); pub fn get_filter_state_events(repo_coordinates: &HashSet) -> nostr::Filter { - nostr::Filter::default() - .kind(STATE_KIND) - .identifiers( - repo_coordinates - .iter() - .map(|c| c.identifier.clone()) - .collect::>(), - ) - .authors( - repo_coordinates - .iter() - .map(|c| c.public_key) - .collect::>(), - ) + nostr::Filter::default().kind(STATE_KIND).identifiers( + repo_coordinates + .iter() + .map(|c| c.identifier.clone()) + .collect::>(), + ) } pub fn get_filter_contributor_profiles(contributors: HashSet) -> nostr::Filter { nostr::Filter::default() - .kinds(vec![Kind::Metadata, Kind::RelayList]) + .kinds(vec![Kind::Metadata, Kind::RelayList, KIND_USER_GRASP_LIST]) .authors(contributors) } @@ -1850,7 +1846,7 @@ pub struct FetchRequest { contributors: HashSet, missing_contributor_profiles: HashSet, existing_events: HashSet, - profiles_to_fetch_from_user_relays: HashMap, + profiles_to_fetch_from_user_relays: HashMap, user_relays_for_profiles: HashSet, } diff --git a/src/lib/git_events.rs b/src/lib/git_events.rs index bbfcbea..76c31de 100644 --- a/src/lib/git_events.rs +++ b/src/lib/git_events.rs @@ -63,6 +63,7 @@ pub fn status_kinds() -> Vec { pub const KIND_PULL_REQUEST: Kind = Kind::Custom(1618); pub const KIND_PULL_REQUEST_UPDATE: Kind = Kind::Custom(1619); +pub const KIND_USER_GRASP_LIST: Kind = Kind::Custom(10317); pub fn event_is_patch_set_root(event: &Event) -> bool { event.kind.eq(&Kind::GitPatch) diff --git a/src/lib/login/user.rs b/src/lib/login/user.rs index 071cb25..0b702ef 100644 --- a/src/lib/login/user.rs +++ b/src/lib/login/user.rs @@ -1,7 +1,7 @@ -use std::{collections::HashSet, path::Path}; +use std::{collections::HashSet, path::Path, sync::Arc}; use anyhow::{Context, Result, bail}; -use nostr::PublicKey; +use nostr::{PublicKey, Url, event::Tag, signer::NostrSigner}; use nostr_sdk::{Alphabet, JsonUtil, Kind, SingleLetterTag, Timestamp, ToBech32}; use serde::{self, Deserialize, Serialize}; @@ -9,13 +9,17 @@ use serde::{self, Deserialize, Serialize}; use crate::client::Client; #[cfg(test)] use crate::client::MockConnect; -use crate::client::{Connect, get_event_from_global_cache}; +use crate::{ + client::{Connect, get_event_from_global_cache, sign_event}, + git_events::KIND_USER_GRASP_LIST, +}; #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub struct UserRef { pub public_key: PublicKey, pub metadata: UserMetadata, pub relays: UserRelays, + pub grasp_list: UserGraspList, } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] @@ -48,6 +52,35 @@ impl UserRelays { } } +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct UserGraspList { + pub urls: Vec, + pub created_at: Timestamp, +} + +impl UserGraspList { + pub async fn to_event(&mut self, signer: &Arc) -> Result { + let event = sign_event( + nostr_sdk::EventBuilder::new(KIND_USER_GRASP_LIST, "").tags( + self.urls + .iter() + .map(|url| { + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("g")), + vec![url.to_string()], + ) + }) + .collect::>(), + ), + signer, + "user grasp list".to_string(), + ) + .await?; + self.created_at = event.created_at; + Ok(event) + } +} + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub struct UserRelayRef { pub url: String, @@ -84,6 +117,7 @@ pub async fn get_user_details( public_key: public_key.to_owned(), metadata: extract_user_metadata(public_key, &[])?, relays: extract_user_relays(public_key, &[]), + grasp_list: extract_user_grasp_list(public_key, &[]), }; if cache_only { Ok(empty) @@ -117,6 +151,9 @@ pub async fn get_user_ref_from_cache( nostr::Filter::default() .author(*public_key) .kind(Kind::RelayList), + nostr::Filter::default() + .author(*public_key) + .kind(KIND_USER_GRASP_LIST), ]; let events = get_event_from_global_cache(git_repo_path, filters.clone()).await?; @@ -128,6 +165,7 @@ pub async fn get_user_ref_from_cache( public_key: public_key.to_owned(), metadata: extract_user_metadata(public_key, &events)?, relays: extract_user_relays(public_key, &events), + grasp_list: extract_user_grasp_list(public_key, &events), }) } @@ -215,3 +253,36 @@ pub fn extract_user_relays(public_key: &nostr::PublicKey, events: &[nostr::Event }, } } + +pub fn extract_user_grasp_list( + public_key: &nostr::PublicKey, + events: &[nostr::Event], +) -> UserGraspList { + let event = events + .iter() + .filter(|e| e.kind.eq(&KIND_USER_GRASP_LIST) && e.pubkey.eq(public_key)) + .max_by_key(|e| e.created_at); + + UserGraspList { + urls: if let Some(event) = event { + event + .tags + .iter() + .filter_map(|t| { + if t.as_slice().len() > 1 && t.as_slice()[0] == "g" { + Url::parse(&t.as_slice()[1]).ok() + } else { + None + } + }) + .collect() + } else { + vec![] + }, + created_at: if let Some(event) = event { + event.created_at + } else { + Timestamp::from(0) + }, + } +} diff --git a/src/lib/repo_ref.rs b/src/lib/repo_ref.rs index a3e1317..e3f71a1 100644 --- a/src/lib/repo_ref.rs +++ b/src/lib/repo_ref.rs @@ -40,6 +40,7 @@ pub struct RepoRef { pub web: Vec, pub relays: Vec, pub blossoms: Vec, + pub hashtags: Vec, pub maintainers: Vec, pub trusted_maintainer: PublicKey, // set to None if not known @@ -71,6 +72,7 @@ impl TryFrom<(nostr::Event, Option)> for RepoRef { web: Vec::new(), relays: Vec::new(), blossoms: Vec::new(), + hashtags: Vec::new(), maintainers: Vec::new(), trusted_maintainer: trusted_maintainer.unwrap_or(event.pubkey), maintainers_without_annoucnement: None, @@ -118,6 +120,7 @@ impl TryFrom<(nostr::Event, Option)> for RepoRef { } } } + [t, hashtag, ..] if t == "t" => r.hashtags.push(hashtag.clone()), [t, blossoms @ ..] if t == "blossoms" => { for b in blossoms { if let Ok(b) = Url::parse(b) { @@ -217,6 +220,15 @@ impl RepoRef { vec![format!("git repository: {}", self.name.clone())], ), ], + self.hashtags + .iter() + .map(|h| { + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("t")), + vec![h.clone()], + ) + }) + .collect(), if self.blossoms.is_empty() { vec![] } else { @@ -311,6 +323,34 @@ impl RepoRef { pub fn grasp_servers(&self) -> Vec { detect_existing_grasp_servers(Some(self), &[], &[], &self.identifier) } + + // returns false if already present so didn't need adding + pub fn add_grasp_server(&mut self, clone_url: &str) -> Result { + if !clone_url.starts_with("http") { + bail!("invalid grasp server clone url"); + } + extract_npub(clone_url) + .context("invalid grasp server clone url. does not contain valid npub")?; + if !(clone_url.ends_with(".git") || clone_url.ends_with(".git/")) { + bail!("invalid grasp server clone url. does not end with .git"); + } + + let relay_url = RelayUrl::parse( + &format_grasp_server_url_as_relay_url(clone_url) + .context("invalid grasp server clone url")?, + ) + .context("invalid grasp server clone url")?; + + if !self.relays.contains(&relay_url) { + self.relays.push(relay_url); + } + if !self.git_server.contains(&clone_url.to_string()) { + self.git_server.push(clone_url.to_string()); + Ok(true) + } else { + Ok(false) + } + } } pub async fn get_repo_coordinates_when_remote_unknown( @@ -699,13 +739,49 @@ pub fn extract_npub(s: &str) -> Result<&str> { } } +// this should be called is_grasp_server_in_list pub fn is_grasp_server(url: &str, grasp_servers: &[String]) -> bool { if !grasp_servers.is_empty() { - if let Ok(n) = normalize_grasp_server_url(url) { - return grasp_servers.contains(&n); + if let Ok(url) = normalize_grasp_server_url(url) { + grasp_servers.iter().any(|s| { + if let Ok(s) = normalize_grasp_server_url(s) { + s == url + } else { + false + } + }) + } else { + false } + } else { + false + } +} + +pub fn format_grasp_server_url_as_relay_url(url: &str) -> Result { + let grasp_server_url = normalize_grasp_server_url(url)?; + if grasp_server_url.contains("http://") { + return Ok(grasp_server_url.replace("http://", "ws://")); } - false + Ok(format!("wss://{grasp_server_url}")) +} + +pub fn format_grasp_server_url_as_clone_url( + grasp_server: &str, + public_key: &PublicKey, + identifier: &str, +) -> Result { + let grasp_server_url = normalize_grasp_server_url(grasp_server)?; + + let prefix = if grasp_server_url.contains("http://") { + "" + } else { + "https://" + }; + Ok(format!( + "{prefix}{grasp_server_url}/{}/{identifier}.git", + public_key.to_bech32()? + )) } #[cfg(test)] @@ -730,6 +806,7 @@ mod tests { RelayUrl::parse("ws://relay2.io").unwrap(), ], blossoms: vec![], + hashtags: vec![], trusted_maintainer: TEST_KEY_1_KEYS.public_key(), maintainers_without_annoucnement: None, maintainers: vec![TEST_KEY_1_KEYS.public_key(), TEST_KEY_2_KEYS.public_key()], -- cgit v1.2.3 From 8c7a7ca2f538fd9240906f6eb746e55d75a6f4fd Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 6 Aug 2025 13:13:57 +0100 Subject: feat(init): use user grasp list for defaults instead of relying on hardcoded grasp server options. couldn't we look up those selected for other repos for the user instead? --- src/bin/ngit/sub_commands/init.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) (limited to 'src/bin') diff --git a/src/bin/ngit/sub_commands/init.rs b/src/bin/ngit/sub_commands/init.rs index 01fcaea..6f3a357 100644 --- a/src/bin/ngit/sub_commands/init.rs +++ b/src/bin/ngit/sub_commands/init.rs @@ -265,12 +265,24 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { &identifier, ); let mut selections: Vec = vec![true; options.len()]; // Initialize selections based on existing options + let empty = options.is_empty(); + for user_grasp_option in user_ref.grasp_list.urls { + // Check if any option contains the user_grasp_option as a substring + if !options + .iter() + .any(|option| option.contains(user_grasp_option.as_str())) + { + options.push(user_grasp_option.to_string()); // Add if not found + selections.push(empty); // mark as selected if no existing grasp otherwise not + } + } + let empty = options.is_empty(); for fallback in fallback_grasp_servers { // Check if any option contains the fallback as a substring if !options.iter().any(|option| option.contains(fallback)) { options.push(fallback.clone()); // Add fallback if not found - selections.push(empty); // mark as selected if no existing ngit relay otherwise not + selections.push(empty); // mark as selected if no existing selections otherwise not } } let selected = multi_select_with_custom_value( -- cgit v1.2.3 From 28d0ab562d3e0f62a56821cd503ed9d9d5e4610f Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 6 Aug 2025 16:25:42 +0100 Subject: fix(send): print event description before publish so its clear from the TUI what has just been sent --- src/bin/ngit/sub_commands/send.rs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) (limited to 'src/bin') diff --git a/src/bin/ngit/sub_commands/send.rs b/src/bin/ngit/sub_commands/send.rs index 835153e..fa2b64d 100644 --- a/src/bin/ngit/sub_commands/send.rs +++ b/src/bin/ngit/sub_commands/send.rs @@ -6,7 +6,10 @@ use ngit::{ cli_interactor::{PromptChoiceParms, multi_select_with_custom_value}, client::{Params, send_events}, git::nostr_url::CloneUrl, - git_events::{EventRefType, KIND_PULL_REQUEST, generate_cover_letter_and_patch_events}, + git_events::{ + EventRefType, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, + generate_cover_letter_and_patch_events, + }, push::push_refs_and_generate_pr_or_pr_update_event, repo_ref::{ format_grasp_server_url_as_clone_url, format_grasp_server_url_as_relay_url, @@ -16,7 +19,7 @@ use ngit::{ }; use nostr::{ ToBech32, - event::Event, + event::{Event, Kind}, nips::{ nip01::Coordinate, nip19::{Nip19Coordinate, Nip19Event}, @@ -255,7 +258,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re } let mut git_ref = None; - loop { + let events = loop { let (events, _server_responses) = push_refs_and_generate_pr_or_pr_update_event( &git_repo, &repo_ref, @@ -464,7 +467,18 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re to_try.push(url); } // the loop with continue with the grasp servers - } + }; + println!( + "posting {}", + if events.iter().any(|e| e.kind.eq(&Kind::GitStatusClosed)) { + "proposal revision as new PR event, and a close status for the old patch" + } else if events.iter().any(|e| e.kind.eq(&KIND_PULL_REQUEST_UPDATE)) { + "proposal revision as PR update event" + } else { + "proposal as PR event" + } + ); + events } else { let events = generate_cover_letter_and_patch_events( cover_letter_title_description.clone(), -- cgit v1.2.3 From afaa472196059bbfbd41a39b23d8ffb4c28d0805 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 7 Aug 2025 08:41:39 +0100 Subject: refactor: rename fn `is_grasp_server_in_list` to make it's purpose clearer --- src/bin/git_remote_nostr/fetch.rs | 4 ++-- src/bin/git_remote_nostr/push.rs | 6 +++--- src/bin/ngit/sub_commands/send.rs | 8 ++++---- src/lib/list.rs | 4 ++-- src/lib/repo_ref.rs | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) (limited to 'src/bin') diff --git a/src/bin/git_remote_nostr/fetch.rs b/src/bin/git_remote_nostr/fetch.rs index 221d964..2cc87da 100644 --- a/src/bin/git_remote_nostr/fetch.rs +++ b/src/bin/git_remote_nostr/fetch.rs @@ -18,7 +18,7 @@ use ngit::{ }, git_events::{KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, tag_value}, login::get_curent_user, - repo_ref::{RepoRef, is_grasp_server}, + repo_ref::{RepoRef, is_grasp_server_in_list}, utils::{ Direction, find_proposal_and_patches_by_branch_name, get_oids_from_fetch_batch, get_open_or_draft_proposals, get_read_protocols_to_try, join_with_and, @@ -96,7 +96,7 @@ pub async fn run_fetch( git_server_url, &repo_ref.to_nostr_git_url(&None), &term, - is_grasp_server(git_server_url, &repo_ref.grasp_servers()), + is_grasp_server_in_list(git_server_url, &repo_ref.grasp_servers()), ) { errors.push(error); } diff --git a/src/bin/git_remote_nostr/push.rs b/src/bin/git_remote_nostr/push.rs index f98e792..d42c904 100644 --- a/src/bin/git_remote_nostr/push.rs +++ b/src/bin/git_remote_nostr/push.rs @@ -20,7 +20,7 @@ use ngit::{ list::list_from_remotes, login::{self, user::UserRef}, push::{push_refs_and_generate_pr_or_pr_update_event, push_to_remote}, - repo_ref::{self, get_repo_config_from_yaml, is_grasp_server}, + repo_ref::{self, get_repo_config_from_yaml, is_grasp_server_in_list}, repo_state, utils::{ find_proposal_and_patches_by_branch_name, get_all_proposals, get_remote_name_by_url, @@ -153,7 +153,7 @@ pub async fn run_push( &repo_ref.to_nostr_git_url(&None), &remote_refspecs, &term, - is_grasp_server(&git_server_url, &repo_ref.grasp_servers()), + is_grasp_server_in_list(&git_server_url, &repo_ref.grasp_servers()), ); } } @@ -458,7 +458,7 @@ async fn generate_patches_or_pr_event_or_pr_updates( let repo_grasp_clone_urls: Vec = repo_ref .git_server .iter() - .filter(|s| is_grasp_server(s, &repo_grasps)) + .filter(|s| is_grasp_server_in_list(s, &repo_grasps)) .cloned() .collect(); diff --git a/src/bin/ngit/sub_commands/send.rs b/src/bin/ngit/sub_commands/send.rs index fa2b64d..05054fd 100644 --- a/src/bin/ngit/sub_commands/send.rs +++ b/src/bin/ngit/sub_commands/send.rs @@ -13,7 +13,7 @@ use ngit::{ push::push_refs_and_generate_pr_or_pr_update_event, repo_ref::{ format_grasp_server_url_as_clone_url, format_grasp_server_url_as_relay_url, - is_grasp_server, normalize_grasp_server_url, + is_grasp_server_in_list, normalize_grasp_server_url, }, utils::proposal_tip_is_pr_or_pr_update, }; @@ -252,7 +252,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re } // also use repo grasp servers for url in &repo_ref.git_server { - if is_grasp_server(url, &repo_grasps) && !to_try.contains(url) { + if is_grasp_server_in_list(url, &repo_grasps) && !to_try.contains(url) { to_try.push(url.clone()); } } @@ -287,7 +287,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re .map(std::string::ToString::to_string) .filter(|g| { // is a grasp server not in list of tried - !is_grasp_server(g, &tried) + !is_grasp_server_in_list(g, &tried) }) .collect(); @@ -338,7 +338,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re let default_choices: Vec = client .get_grasp_default_set() .iter() - .filter(|g| !is_grasp_server(g, &tried)) + .filter(|g| !is_grasp_server_in_list(g, &tried)) .cloned() .collect(); let selections = vec![true; default_choices.len()]; // all selected by default diff --git a/src/lib/list.rs b/src/lib/list.rs index b940546..b867858 100644 --- a/src/lib/list.rs +++ b/src/lib/list.rs @@ -9,7 +9,7 @@ use crate::{ Repo, RepoActions, nostr_url::{CloneUrl, NostrUrlDecoded, ServerProtocol}, }, - repo_ref::is_grasp_server, + repo_ref::is_grasp_server_in_list, utils::{Direction, get_read_protocols_to_try, join_with_and, set_protocol_preference}, }; @@ -23,7 +23,7 @@ pub fn list_from_remotes( let mut remote_states = HashMap::new(); let mut errors = HashMap::new(); for url in git_servers { - let is_grasp_server = is_grasp_server(url, grasp_servers); + let is_grasp_server = is_grasp_server_in_list(url, grasp_servers); match list_from_remote(term, git_repo, url, decoded_nostr_url, is_grasp_server) { Err(error) => { errors.insert(url, error); diff --git a/src/lib/repo_ref.rs b/src/lib/repo_ref.rs index e3f71a1..5e857e9 100644 --- a/src/lib/repo_ref.rs +++ b/src/lib/repo_ref.rs @@ -740,7 +740,7 @@ pub fn extract_npub(s: &str) -> Result<&str> { } // this should be called is_grasp_server_in_list -pub fn is_grasp_server(url: &str, grasp_servers: &[String]) -> bool { +pub fn is_grasp_server_in_list(url: &str, grasp_servers: &[String]) -> bool { if !grasp_servers.is_empty() { if let Ok(url) = normalize_grasp_server_url(url) { grasp_servers.iter().any(|s| { -- cgit v1.2.3 From 646d05e44946c5a248cb8c5b7d852ed316b9592e Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 7 Aug 2025 10:21:28 +0100 Subject: fix(send): push PR refs to non-grasp servers attempt to use a range of protocols instead of unath http --- src/bin/ngit/sub_commands/sync.rs | 66 ++++++++++++++++++++++++--------------- src/lib/push.rs | 64 ++++++++++++++++++++++++++++--------- 2 files changed, 90 insertions(+), 40 deletions(-) (limited to 'src/bin') diff --git a/src/bin/ngit/sub_commands/sync.rs b/src/bin/ngit/sub_commands/sync.rs index c1a3484..e7033b2 100644 --- a/src/bin/ngit/sub_commands/sync.rs +++ b/src/bin/ngit/sub_commands/sync.rs @@ -127,33 +127,47 @@ pub async fn launch(args: &SubCommandArgs) -> Result<()> { term.write_line(&format!("{remote_name} already in sync"))?; } // report already in sync - } else if let Err(error) = push_to_remote( - &git_repo, - url, - &decoded_nostr_url, - &refspecs, - &term, - *is_grasp_server, - ) { - term.write_line(&format!( - "error pushing updates to {remote_name}: error: {error}" - ))?; - } else if *is_grasp_server || args.force { - term.write_line(&format!("{remote_name} sync completed"))?; - // TODO we only know if there was an error but not if it - // rejected any updates } else { - // we should report on refs not force pushed - term.write_line(&format!("{remote_name} sync completed"))?; - } - for name in ¬_deleted { - term.write_line(&format!(" - {name} not deleted"))?; - } - for name in ¬_updated { - term.write_line(&format!(" - {name} not updated due to conflicts"))?; - } - if !not_updated.is_empty() || !not_deleted.is_empty() { - term.write_line("run `ngit sync --force` to delete refs or overwrite conflicts and potentially lose work")?; + match push_to_remote( + &git_repo, + url, + &decoded_nostr_url, + &refspecs, + &term, + *is_grasp_server, + ) { + Err(error) => { + term.write_line(&format!( + "error pushing updates to {remote_name}: error: {error}" + ))?; + } + Ok(failed_refs) => { + if failed_refs.is_empty() { + if *is_grasp_server || args.force { + term.write_line(&format!("{remote_name} sync completed"))?; + // TODO we only know if there was an error but not + // if it rejected any + // updates + } else { + // we should report on refs not force pushed + term.write_line(&format!("{remote_name} sync completed"))?; + } + } else { + term.write_line(&format!( + "{remote_name} sync completed but not all changes were accepted" + ))?; + } + for name in ¬_deleted { + term.write_line(&format!(" - {name} not deleted"))?; + } + for name in ¬_updated { + term.write_line(&format!(" - {name} not updated due to conflicts"))?; + } + if !not_updated.is_empty() || !not_deleted.is_empty() { + term.write_line("run `ngit sync --force` to delete refs or overwrite conflicts and potentially lose work")?; + } + } + } } } diff --git a/src/lib/push.rs b/src/lib/push.rs index 2aafc1d..a5a29a2 100644 --- a/src/lib/push.rs +++ b/src/lib/push.rs @@ -4,7 +4,7 @@ use std::{ time::Instant, }; -use anyhow::{Result, anyhow}; +use anyhow::{Context, Result, anyhow}; use auth_git2::GitAuthenticator; use console::Term; use nostr::{ @@ -19,19 +19,20 @@ use crate::{ cli_interactor::count_lines_per_msg_vec, client::{sign_draft_event, sign_event}, git::{ - Repo, + Repo, RepoActions, nostr_url::{CloneUrl, NostrUrlDecoded}, oid_to_shorthand_string, }, git_events::generate_unsigned_pr_or_update_event, login::user::UserRef, - repo_ref::{RepoRef, normalize_grasp_server_url}, + repo_ref::{RepoRef, is_grasp_server_clone_url, normalize_grasp_server_url}, utils::{ Direction, get_short_git_server_name, get_write_protocols_to_try, join_with_and, set_protocol_preference, }, }; +// returns failed refs as a HashMaps of failed refspec and their error pub fn push_to_remote( git_repo: &Repo, git_server_url: &str, @@ -39,13 +40,14 @@ pub fn push_to_remote( remote_refspecs: &[String], term: &Term, is_grasp_server: bool, -) -> Result<()> { +) -> Result> { let server_url = git_server_url.parse::()?; let protocols_to_attempt = get_write_protocols_to_try(git_repo, &server_url, decoded_nostr_url, is_grasp_server); let mut failed_protocols = vec![]; let mut success = false; + let mut failed_refs = HashMap::new(); for protocol in &protocols_to_attempt { term.write_line(format!("push: {} over {protocol}...", server_url.short_name(),).as_str())?; @@ -59,14 +61,9 @@ pub fn push_to_remote( )?; failed_protocols.push(protocol); } - Ok(failed_refs) => { - if let Some((_, error)) = failed_refs.iter().next() { - term.write_line( - format!("push: {formatted_url} failed over {protocol}: {error}").as_str(), - )?; - failed_protocols.push(protocol); - } else { - success = true; + Ok(failed_refs_on_protocol) => { + success = true; + if failed_refs_on_protocol.is_empty() { if !failed_protocols.is_empty() { term.write_line(format!("push: succeeded over {protocol}").as_str())?; let _ = set_protocol_preference( @@ -77,12 +74,25 @@ pub fn push_to_remote( ); } break; + } else { + term.write_line( + format!( + "push: {formatted_url} with {protocol} complete but {}ref{} not accepted:", + if remote_refspecs.len() != failed_protocols.len() { "some " } else {""}, + if remote_refspecs.len() == 1 { "s"} else {""}, + ).as_str(), + )?; + for (git_ref, error) in &failed_refs_on_protocol { + term.write_line(format!("push: - {git_ref}: {error}").as_str())?; + } + failed_refs = failed_refs_on_protocol; } + break; } } } if success { - Ok(()) + Ok(failed_refs) } else { let error = anyhow!( "{} failed over {}{}", @@ -382,7 +392,33 @@ pub async fn push_refs_and_generate_pr_or_pr_update_event( let refspec = format!("{tip}:{git_ref_used}"); - match push_to_remote_url(git_repo, clone_url, &[refspec], term) { + let res = if is_grasp_server_clone_url(clone_url) { + push_to_remote_url(git_repo, clone_url, &[refspec], term) + } else { + // anticipated only when pushing to user's own repo or a personal-fork with + // non-grasp git servers. this is used to extract prefered protocols / ssh + // details from nostr url + let decoded_nostr_url = { + if let Ok(Some((_, decoded_nostr_url))) = git_repo + .get_first_nostr_remote_when_in_ngit_binary() + .await.context("failed to list git remotes") + .context("no `nostr://` remote detected. `ngit sync` must be run from a repo with a nostr remote") { + decoded_nostr_url + } else { + repo_ref.to_nostr_git_url(&Some(git_repo)) + } + }; + push_to_remote( + git_repo, + clone_url, + &decoded_nostr_url, + &[refspec], + term, + false, + ) + }; + + match res { Err(error) => { let normalized_url = normalize_grasp_server_url(clone_url)?; term.write_line(&format!( -- cgit v1.2.3 From 896267959bc9e436d7c5d2ee0ff8c8c088fc7274 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 7 Aug 2025 13:02:08 +0100 Subject: fix(send): PR cli output to keep the user informed of whats happening / happend --- src/bin/ngit/sub_commands/init.rs | 19 ++++--------------- src/bin/ngit/sub_commands/send.rs | 25 ++++++++++++++----------- src/lib/cli_interactor.rs | 18 +++++++++++++++++- 3 files changed, 35 insertions(+), 27 deletions(-) (limited to 'src/bin') diff --git a/src/bin/ngit/sub_commands/init.rs b/src/bin/ngit/sub_commands/init.rs index 6f3a357..98daee4 100644 --- a/src/bin/ngit/sub_commands/init.rs +++ b/src/bin/ngit/sub_commands/init.rs @@ -9,10 +9,12 @@ use std::{ use anyhow::{Context, Result, bail}; use console::{Style, Term}; -use dialoguer::theme::{ColorfulTheme, Theme}; use ngit::{ UrlWithoutSlash, - cli_interactor::{PromptChoiceParms, PromptConfirmParms, multi_select_with_custom_value}, + cli_interactor::{ + PromptChoiceParms, PromptConfirmParms, multi_select_with_custom_value, + show_multi_input_prompt_success, + }, client::{Params, send_events}, git::nostr_url::{CloneUrl, NostrUrlDecoded}, repo_ref::{ @@ -904,19 +906,6 @@ fn parse_relay_url(s: &str) -> Result { .context(format!("failed to parse relay url: {s}")) } -pub fn show_multi_input_prompt_success(label: &str, values: &[String]) { - let values_str: Vec<&str> = values.iter().map(std::string::String::as_str).collect(); - eprintln!("{}", { - let mut s = String::new(); - let _ = ColorfulTheme::default().format_multi_select_prompt_selection( - &mut s, - label, - &values_str, - ); - s - }); -} - fn push_main_or_master_branch(git_repo: &Repo) -> Result<()> { let main_branch_name = { let local_branches = git_repo diff --git a/src/bin/ngit/sub_commands/send.rs b/src/bin/ngit/sub_commands/send.rs index 05054fd..3ae941f 100644 --- a/src/bin/ngit/sub_commands/send.rs +++ b/src/bin/ngit/sub_commands/send.rs @@ -3,7 +3,9 @@ use std::{path::Path, str::FromStr, thread, time::Duration}; use anyhow::{Context, Result, bail}; use console::Style; use ngit::{ - cli_interactor::{PromptChoiceParms, multi_select_with_custom_value}, + cli_interactor::{ + PromptChoiceParms, multi_select_with_custom_value, show_multi_input_prompt_success, + }, client::{Params, send_events}, git::nostr_url::CloneUrl, git_events::{ @@ -343,12 +345,13 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re .collect(); let selections = vec![true; default_choices.len()]; // all selected by default let grasp_servers = multi_select_with_custom_value( - "grasp server(s)", + "alternative grasp server(s)", "grasp server", default_choices, selections, normalize_grasp_server_url, )?; + show_multi_input_prompt_success("alternative grasp server(s)", &grasp_servers); if grasp_servers.is_empty() { // ask again continue; @@ -388,16 +391,16 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re } normalised_grasp_servers } else { - println!( - "{} personal-fork so we can push commits to your prefered grasp servers", - if user_repo_ref.is_some() { - "Updating" - } else { - "Creating a" - }, - ); untried_user_grasp_servers }; + println!( + "{} personal-fork so we can push commits to your prefered grasp servers", + if user_repo_ref.is_some() { + "Updating" + } else { + "Creating a" + }, + ); let grasp_servers_as_personal_clone_url: Vec = grasp_servers .iter() @@ -415,7 +418,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re let updated_user_repo_ref = { if let Some(mut user_repo_ref) = user_repo_ref { for g in &grasp_servers_as_personal_clone_url { - let _ = user_repo_ref.add_grasp_server(g); + user_repo_ref.add_grasp_server(g)?; } user_repo_ref } else { diff --git a/src/lib/cli_interactor.rs b/src/lib/cli_interactor.rs index 8bcda19..e944bf9 100644 --- a/src/lib/cli_interactor.rs +++ b/src/lib/cli_interactor.rs @@ -1,5 +1,8 @@ use anyhow::{Context, Result}; -use dialoguer::{Confirm, Input, Password, theme::ColorfulTheme}; +use dialoguer::{ + Confirm, Input, Password, + theme::{ColorfulTheme, Theme}, +}; use indicatif::TermLike; #[cfg(test)] use mockall::*; @@ -323,6 +326,19 @@ where Ok(selected_choices) } +pub fn show_multi_input_prompt_success(label: &str, values: &[String]) { + let values_str: Vec<&str> = values.iter().map(std::string::String::as_str).collect(); + eprintln!("{}", { + let mut s = String::new(); + let _ = ColorfulTheme::default().format_multi_select_prompt_selection( + &mut s, + label, + &values_str, + ); + s + }); +} + #[derive(Debug, Default)] pub struct Printer { printed_lines: Vec, -- cgit v1.2.3 From fa7adf840ac2d78defee398a61b60888f615622a Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 7 Aug 2025 17:49:02 +0100 Subject: fix(send): refs not confirmed are usually accepted having implemented 3b5c48f5a2a4b9be5d14baa8f5e801fefd5c1166, a ref pushed to refs/nostr/ on a github repo was accepted but was not confirmed --- src/bin/ngit/sub_commands/sync.rs | 4 ++-- src/lib/push.rs | 15 +++------------ 2 files changed, 5 insertions(+), 14 deletions(-) (limited to 'src/bin') diff --git a/src/bin/ngit/sub_commands/sync.rs b/src/bin/ngit/sub_commands/sync.rs index e7033b2..00dfe75 100644 --- a/src/bin/ngit/sub_commands/sync.rs +++ b/src/bin/ngit/sub_commands/sync.rs @@ -141,8 +141,8 @@ pub async fn launch(args: &SubCommandArgs) -> Result<()> { "error pushing updates to {remote_name}: error: {error}" ))?; } - Ok(failed_refs) => { - if failed_refs.is_empty() { + Ok(updated_refs) => { + if updated_refs.values().all(std::option::Option::is_none) { if *is_grasp_server || args.force { term.write_line(&format!("{remote_name} sync completed"))?; // TODO we only know if there was an error but not diff --git a/src/lib/push.rs b/src/lib/push.rs index 0ee66cf..8cb0212 100644 --- a/src/lib/push.rs +++ b/src/lib/push.rs @@ -63,10 +63,9 @@ pub fn push_to_remote( } Ok(ref_updates_on_protocol) => { success = true; - if remote_refspecs.len() == ref_updates_on_protocol.len() - && ref_updates_on_protocol - .values() - .all(|error| error.is_none()) + if ref_updates_on_protocol + .values() + .all(|error| error.is_none()) { if !failed_protocols.is_empty() { term.write_line(format!("push: succeeded over {protocol}").as_str())?; @@ -444,14 +443,6 @@ pub async fn push_refs_and_generate_pr_or_pr_update_event( "push: error sending commit data to {normalized_url}: {error}" ))?; responses.push((clone_url.clone(), Err(anyhow!(error.clone())))); - } else if ref_updates.is_empty() { - term.write_line(&format!( - "push: error sending commit data to {normalized_url}: server didn't confirm acceptance" - ))?; - responses.push(( - clone_url.clone(), - Err(anyhow!("server didn't confirm acceptance")), - )); } else { responses.push((clone_url.clone(), Ok(()))); term.write_line(&format!("push: commit data sent to {normalized_url}"))?; -- cgit v1.2.3