upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src/bin/ngit
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-08-06 12:52:59 +0100
committerDanConwayDev <DanConwayDev@protonmail.com>2025-08-07 17:25:50 +0100
commita9b2ebf8216be34950e54dd9a446dbdc0c9c744a (patch)
tree5a103933852fbcfcd42b13716cb92eeca5325d6d /src/bin/ngit
parent29f61ffdf155ea88b8d9aec23d28cf70baba577e (diff)
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.
Diffstat (limited to 'src/bin/ngit')
-rw-r--r--src/bin/ngit/sub_commands/init.rs106
-rw-r--r--src/bin/ngit/sub_commands/send.rs275
2 files changed, 252 insertions, 129 deletions
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};
12use dialoguer::theme::{ColorfulTheme, Theme}; 12use dialoguer::theme::{ColorfulTheme, Theme};
13use ngit::{ 13use ngit::{
14 UrlWithoutSlash, 14 UrlWithoutSlash,
15 cli_interactor::{PromptChoiceParms, PromptConfirmParms, PromptMultiChoiceParms}, 15 cli_interactor::{PromptChoiceParms, PromptConfirmParms, multi_select_with_custom_value},
16 client::{Params, send_events}, 16 client::{Params, send_events},
17 git::nostr_url::{CloneUrl, NostrUrlDecoded}, 17 git::nostr_url::{CloneUrl, NostrUrlDecoded},
18 repo_ref::{ 18 repo_ref::{
19 detect_existing_grasp_servers, extract_npub, extract_pks, normalize_grasp_server_url, 19 detect_existing_grasp_servers, extract_npub, extract_pks,
20 save_repo_config_to_yaml, 20 format_grasp_server_url_as_relay_url, normalize_grasp_server_url, save_repo_config_to_yaml,
21 }, 21 },
22}; 22};
23use nostr::{ 23use nostr::{
@@ -727,6 +727,11 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
727 web, 727 web,
728 relays: relays.clone(), 728 relays: relays.clone(),
729 blossoms, 729 blossoms,
730 hashtags: if let Some(repo_ref) = repo_ref {
731 repo_ref.hashtags
732 } else {
733 vec![]
734 },
730 trusted_maintainer: user_ref.public_key, 735 trusted_maintainer: user_ref.public_key,
731 maintainers_without_annoucnement: None, 736 maintainers_without_annoucnement: None,
732 maintainers: maintainers.clone(), 737 maintainers: maintainers.clone(),
@@ -848,93 +853,6 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
848 Ok(()) 853 Ok(())
849} 854}
850 855
851fn multi_select_with_custom_value<F>(
852 prompt: &str,
853 custom_choice_prompt: &str,
854 mut choices: Vec<String>,
855 mut defaults: Vec<bool>,
856 validate_choice: F,
857) -> Result<Vec<String>>
858where
859 F: Fn(&str) -> Result<String>,
860{
861 let mut selected_choices = vec![];
862
863 // Loop to allow users to add more choices
864 loop {
865 // Add 'add another' option at the end of the choices
866 let mut current_choices = choices.clone();
867 current_choices.push(if current_choices.is_empty() {
868 "add".to_string()
869 } else {
870 "add another".to_string()
871 });
872
873 // Create default selections based on the provided defaults
874 let mut current_defaults = defaults.clone();
875 current_defaults.push(current_choices.len() == 1); // 'add another' should not be selected by default
876
877 // Prompt for selections
878 let selected_indices: Vec<usize> = Interactor::default().multi_choice(
879 PromptMultiChoiceParms::default()
880 .with_prompt(prompt)
881 .dont_report()
882 .with_choices(current_choices.clone())
883 .with_defaults(current_defaults),
884 )?;
885
886 // Collect selected choices
887 selected_choices.clear(); // Clear previous selections to update
888 for &index in &selected_indices {
889 if index < choices.len() {
890 // Exclude 'add another' option
891 selected_choices.push(choices[index].clone());
892 }
893 }
894
895 // Check if 'add another' was selected
896 if selected_indices.contains(&(choices.len())) {
897 // Last index is 'add another'
898 let mut new_choice: String;
899 loop {
900 new_choice = Interactor::default().input(
901 PromptInputParms::default()
902 .with_prompt(custom_choice_prompt)
903 .dont_report()
904 .optional(),
905 )?;
906
907 if new_choice.is_empty() {
908 break;
909 }
910 // Validate the new choice
911 match validate_choice(&new_choice) {
912 Ok(valid_choice) => {
913 new_choice = valid_choice; // Use the fixed version of the input
914 break; // Valid choice, exit the loop
915 }
916 Err(err) => {
917 // Inform the user about the validation error
918 println!("Error: {err}");
919 }
920 }
921 }
922
923 // Add the new choice to the choices vector
924 if !new_choice.is_empty() {
925 choices.push(new_choice.clone()); // Add new choice to the end of the list
926 selected_choices.push(new_choice); // Automatically select the new choice
927 defaults.push(true); // Set the new choice as selected by default
928 }
929 } else {
930 // Exit the loop if 'add another' was not selected
931 break;
932 }
933 }
934
935 Ok(selected_choices)
936}
937
938fn format_grasp_server_url_as_clone_url( 856fn format_grasp_server_url_as_clone_url(
939 url: &str, 857 url: &str,
940 public_key: &PublicKey, 858 public_key: &PublicKey,
@@ -953,14 +871,6 @@ fn format_grasp_server_url_as_clone_url(
953 )) 871 ))
954} 872}
955 873
956fn format_grasp_server_url_as_relay_url(url: &str) -> Result<String> {
957 let grasp_server_url = normalize_grasp_server_url(url)?;
958 if grasp_server_url.contains("http://") {
959 return Ok(grasp_server_url.replace("http://", "ws://"));
960 }
961 Ok(format!("wss://{grasp_server_url}"))
962}
963
964fn format_grasp_server_url_as_blossom_url(url: &str) -> Result<String> { 874fn format_grasp_server_url_as_blossom_url(url: &str) -> Result<String> {
965 let grasp_server_url = normalize_grasp_server_url(url)?; 875 let grasp_server_url = normalize_grasp_server_url(url)?;
966 if grasp_server_url.contains("http://") { 876 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 @@
1use std::{path::Path, str::FromStr}; 1use std::{path::Path, str::FromStr, thread, time::Duration};
2 2
3use anyhow::{Context, Result, bail}; 3use anyhow::{Context, Result, bail};
4use console::Style; 4use console::Style;
5use ngit::{ 5use ngit::{
6 cli_interactor::{PromptChoiceParms, multi_select_with_custom_value},
6 client::{Params, send_events}, 7 client::{Params, send_events},
7 git::nostr_url::CloneUrl, 8 git::nostr_url::CloneUrl,
8 git_events::{EventRefType, KIND_PULL_REQUEST, generate_cover_letter_and_patch_events}, 9 git_events::{EventRefType, KIND_PULL_REQUEST, generate_cover_letter_and_patch_events},
9 push::push_refs_and_generate_pr_or_pr_update_event, 10 push::push_refs_and_generate_pr_or_pr_update_event,
10 repo_ref::is_grasp_server, 11 repo_ref::{
12 format_grasp_server_url_as_clone_url, format_grasp_server_url_as_relay_url,
13 is_grasp_server, normalize_grasp_server_url,
14 },
11 utils::proposal_tip_is_pr_or_pr_update, 15 utils::proposal_tip_is_pr_or_pr_update,
12}; 16};
13use nostr::{ToBech32, event::Event, nips::nip19::Nip19Event}; 17use nostr::{
18 ToBech32,
19 event::Event,
20 nips::{
21 nip01::Coordinate,
22 nip19::{Nip19Coordinate, Nip19Event},
23 },
24};
14use nostr_sdk::hashes::sha1::Hash as Sha1Hash; 25use nostr_sdk::hashes::sha1::Hash as Sha1Hash;
15 26
16use crate::{ 27use crate::{
@@ -179,7 +190,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re
179 None 190 None
180 }; 191 };
181 192
182 let (signer, user_ref, _) = login::login_or_signup( 193 let (signer, mut user_ref, _) = login::login_or_signup(
183 &Some(&git_repo), 194 &Some(&git_repo),
184 &extract_signer_cli_arguments(cli_args).unwrap_or(None), 195 &extract_signer_cli_arguments(cli_args).unwrap_or(None),
185 &cli_args.password, 196 &cli_args.password,
@@ -194,20 +205,55 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re
194 commits.reverse(); 205 commits.reverse();
195 206
196 let events = if as_pr { 207 let events = if as_pr {
208 let mut to_try = vec![];
209 let mut tried = vec![];
197 let repo_grasps = repo_ref.grasp_servers(); 210 let repo_grasps = repo_ref.grasp_servers();
198 let repo_grasp_clone_urls: Vec<String> = repo_ref 211 // if the user already has a fork, or is a maintainer, use those git servers
199 .git_server 212 let mut user_repo_ref = get_repo_ref_from_cache(
200 .iter() 213 Some(git_repo_path),
201 .filter(|s| is_grasp_server(s, &repo_grasps)) 214 &Nip19Coordinate {
202 .cloned() 215 coordinate: Coordinate {
203 .collect(); 216 kind: nostr::event::Kind::GitRepoAnnouncement,
204 if repo_grasp_clone_urls.is_empty() { 217 public_key: user_ref.public_key,
218 identifier: repo_ref.identifier.clone(),
219 },
220 relays: vec![],
221 },
222 )
223 .await
224 .ok();
225 if let Some(user_repo_ref) = &user_repo_ref {
226 for url in &user_repo_ref.git_server {
227 if CloneUrl::from_str(url).is_ok() {
228 to_try.push(url.clone());
229 }
230 }
231 }
232 if !to_try.is_empty() || !repo_grasps.is_empty() {
233 println!(
234 "pushing proposal refs to {}",
235 if repo_ref.maintainers.contains(&user_ref.public_key) {
236 "repository git servers"
237 } else if to_try.is_empty() {
238 "repository grasp servers"
239 } else if repo_grasps.is_empty() {
240 "the git servers listed in your fork"
241 } else {
242 "the git servers listed in your fork and repository grasp servers"
243 }
244 );
245 } else {
205 println!( 246 println!(
206 "The repository doesn't list a grasp server which would otherwise be used to submit your proposal as nostr Pull Request." 247 "The repository doesn't list a grasp server which would otherwise be used to submit your proposal as nostr Pull Request."
207 ); 248 );
208 } 249 }
209 let mut to_try = repo_grasp_clone_urls.clone(); 250 // also use repo grasp servers
210 let mut tried = vec![]; 251 for url in &repo_ref.git_server {
252 if is_grasp_server(url, &repo_grasps) && !to_try.contains(url) {
253 to_try.push(url.clone());
254 }
255 }
256
211 let mut git_ref = None; 257 let mut git_ref = None;
212 loop { 258 loop {
213 let (events, _server_responses) = push_refs_and_generate_pr_or_pr_update_event( 259 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
217 &user_ref, 263 &user_ref,
218 root_proposal.as_ref(), 264 root_proposal.as_ref(),
219 &cover_letter_title_description, 265 &cover_letter_title_description,
220 &repo_grasp_clone_urls, 266 &to_try,
221 git_ref.clone(), 267 git_ref.clone(),
222 &signer, 268 &signer,
223 &console::Term::stdout(), 269 &console::Term::stdout(),
@@ -230,27 +276,194 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re
230 if let Some(events) = events { 276 if let Some(events) = events {
231 break events; 277 break events;
232 } 278 }
233 let clone_url = Interactor::default() 279 // fallback to creating user personal-fork on their grasp servers
234 .input( 280 let untried_user_grasp_servers: Vec<String> = user_ref
235 PromptInputParms::default().with_prompt("git repo url with write permission"), 281 .grasp_list
236 )? 282 .urls
237 .clone(); 283 .iter()
238 if CloneUrl::from_str(&clone_url).is_ok() { 284 .map(std::string::ToString::to_string)
239 to_try.push(clone_url); 285 .filter(|g| {
240 let mut git_ref_or_branch_name = Interactor::default() 286 // is a grasp server not in list of tried
241 .input( 287 !is_grasp_server(g, &tried)
242 PromptInputParms::default() 288 })
243 .with_prompt("ref / branch name") 289 .collect();
244 .with_default(git_ref.unwrap_or("refs/nostr/<event-id>".to_string())), 290
291 if untried_user_grasp_servers.is_empty()
292 && Interactor::default().choice(
293 PromptChoiceParms::default()
294 .with_prompt("choose alternative git server")
295 .dont_report()
296 .with_choices(vec![
297 "choose grasp server(s)".to_string(),
298 "enter a git repo url with write permission".to_string(),
299 ])
300 .with_default(0),
301 )? == 1
302 {
303 loop {
304 let clone_url = Interactor::default()
305 .input(
306 PromptInputParms::default()
307 .with_prompt("git repo url with write permission"),
308 )?
309 .clone();
310 if CloneUrl::from_str(&clone_url).is_ok() {
311 to_try.push(clone_url);
312 let mut git_ref_or_branch_name = Interactor::default()
313 .input(
314 PromptInputParms::default()
315 .with_prompt("ref / branch name")
316 .with_default(
317 git_ref.unwrap_or("refs/nostr/<event-id>".to_string()),
318 ),
319 )?
320 .clone();
321 if !git_ref_or_branch_name.starts_with("refs/") {
322 git_ref_or_branch_name = format!("refs/heads/{git_ref_or_branch_name}");
323 }
324 git_ref = Some(git_ref_or_branch_name);
325 break;
326 }
327 println!("invalid clone url");
328 }
329 continue;
330 }
331
332 let mut new_grasp_server_events: Vec<Event> = vec![];
333
334 let grasp_servers = if untried_user_grasp_servers.is_empty() {
335 let default_choices: Vec<String> = client
336 .get_grasp_default_set()
337 .iter()
338 .filter(|g| !is_grasp_server(g, &tried))
339 .cloned()
340 .collect();
341 let selections = vec![true; default_choices.len()]; // all selected by default
342 let grasp_servers = multi_select_with_custom_value(
343 "grasp server(s)",
344 "grasp server",
345 default_choices,
346 selections,
347 normalize_grasp_server_url,
348 )?;
349 if grasp_servers.is_empty() {
350 // ask again
351 continue;
352 }
353 let normalised_grasp_servers: Vec<String> = grasp_servers
354 .iter()
355 .filter_map(|g| normalize_grasp_server_url(g).ok())
356 .collect();
357 // if any grasp servers not listed in user grasp list prompt to update
358 let grasp_servers_not_in_user_prefs: Vec<String> = normalised_grasp_servers
359 .iter()
360 .filter(|g| {
361 !user_ref.grasp_list.urls.contains(
362 // unwrap is safe as we constructed g
363 &nostr::Url::parse(&format_grasp_server_url_as_relay_url(g).unwrap())
364 .unwrap(),
365 )
366 })
367 .cloned()
368 .collect();
369 if !grasp_servers_not_in_user_prefs.is_empty()
370 && Interactor::default().confirm(
371 PromptConfirmParms::default()
372 .with_prompt(
373 "add these to your list of prefered grasp servers?".to_string(),
374 )
375 .with_default(true),
245 )? 376 )?
246 .clone(); 377 {
247 if !git_ref_or_branch_name.starts_with("refs/") { 378 for g in &normalised_grasp_servers {
248 git_ref_or_branch_name = format!("refs/heads/{git_ref_or_branch_name}"); 379 let as_url = nostr::Url::parse(&format_grasp_server_url_as_relay_url(g)?)?;
380 if !user_ref.grasp_list.urls.contains(&as_url) {
381 user_ref.grasp_list.urls.push(as_url);
382 }
383 }
384 new_grasp_server_events.push(user_ref.grasp_list.to_event(&signer).await?);
249 } 385 }
250 git_ref = Some(git_ref_or_branch_name); 386 normalised_grasp_servers
251 } else { 387 } else {
252 println!("invalid clone url"); 388 println!(
389 "{} personal-fork so we can push commits to your prefered grasp servers",
390 if user_repo_ref.is_some() {
391 "Updating"
392 } else {
393 "Creating a"
394 },
395 );
396 untried_user_grasp_servers
397 };
398
399 let grasp_servers_as_personal_clone_url: Vec<String> = grasp_servers
400 .iter()
401 .filter_map(|g| {
402 format_grasp_server_url_as_clone_url(
403 g,
404 &user_ref.public_key,
405 &repo_ref.identifier,
406 )
407 .ok()
408 })
409 .collect();
410
411 // create personal-fork / update existing user repo and add these grasp servers
412 let updated_user_repo_ref = {
413 if let Some(mut user_repo_ref) = user_repo_ref {
414 for g in &grasp_servers_as_personal_clone_url {
415 let _ = user_repo_ref.add_grasp_server(g);
416 }
417 user_repo_ref
418 } else {
419 // clone repo_ref and reset as personal-fork
420 let mut user_repo_ref = repo_ref.clone();
421 user_repo_ref.trusted_maintainer = user_ref.public_key;
422 user_repo_ref.maintainers = vec![user_ref.public_key];
423 user_repo_ref.git_server = vec![];
424 user_repo_ref.relays = vec![];
425 if !user_repo_ref
426 .hashtags
427 .contains(&"personal-fork".to_string())
428 {
429 user_repo_ref.hashtags.push("personal-fork".to_string());
430 }
431 user_repo_ref
432 }
433 };
434 // pubish event to my-relays and my-fork-relays
435 new_grasp_server_events.push(updated_user_repo_ref.to_event(&signer).await?);
436 send_events(
437 &client,
438 Some(git_repo_path),
439 new_grasp_server_events,
440 user_ref.relays.write(),
441 updated_user_repo_ref.relays.clone(),
442 !cli_args.disable_cli_spinners,
443 false,
444 )
445 .await?;
446 user_repo_ref = Some(updated_user_repo_ref);
447 // wait a few seconds
448 let countdown_start = 5;
449 let term = console::Term::stdout();
450 for i in (1..=countdown_start).rev() {
451 term.write_line(
452 format!(
453 "waiting {i}s grasp servers to create your repo before we push your data"
454 )
455 .as_str(),
456 )?;
457 thread::sleep(Duration::new(1, 0)); // Sleep for 1 second
458 term.clear_last_lines(1)?;
459 }
460 term.flush().unwrap(); // Ensure the output is flushed to the terminal
461
462 // add grasp servers to to_try
463 for url in grasp_servers_as_personal_clone_url {
464 to_try.push(url);
253 } 465 }
466 // the loop with continue with the grasp servers
254 } 467 }
255 } else { 468 } else {
256 let events = generate_cover_letter_and_patch_events( 469 let events = generate_cover_letter_and_patch_events(