upleb.uk

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

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/bin/git_remote_nostr/fetch.rs4
-rw-r--r--src/bin/git_remote_nostr/push.rs201
-rw-r--r--src/bin/ngit/sub_commands/init.rs137
-rw-r--r--src/bin/ngit/sub_commands/send.rs499
-rw-r--r--src/bin/ngit/sub_commands/sync.rs66
-rw-r--r--src/lib/cli_interactor.rs105
-rw-r--r--src/lib/client.rs54
-rw-r--r--src/lib/git/mod.rs14
-rw-r--r--src/lib/git_events.rs11
-rw-r--r--src/lib/list.rs4
-rw-r--r--src/lib/login/user.rs77
-rw-r--r--src/lib/push.rs270
-rw-r--r--src/lib/repo_ref.rs87
-rw-r--r--src/lib/utils.rs36
-rw-r--r--test_utils/src/lib.rs2
-rw-r--r--tests/ngit_list.rs2
-rw-r--r--tests/ngit_send.rs86
17 files changed, 1187 insertions, 468 deletions
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::{
18 }, 18 },
19 git_events::{KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, tag_value}, 19 git_events::{KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, tag_value},
20 login::get_curent_user, 20 login::get_curent_user,
21 repo_ref::{RepoRef, is_grasp_server}, 21 repo_ref::{RepoRef, is_grasp_server_in_list},
22 utils::{ 22 utils::{
23 Direction, find_proposal_and_patches_by_branch_name, get_oids_from_fetch_batch, 23 Direction, find_proposal_and_patches_by_branch_name, get_oids_from_fetch_batch,
24 get_open_or_draft_proposals, get_read_protocols_to_try, join_with_and, 24 get_open_or_draft_proposals, get_read_protocols_to_try, join_with_and,
@@ -96,7 +96,7 @@ pub async fn run_fetch(
96 git_server_url, 96 git_server_url,
97 &repo_ref.to_nostr_git_url(&None), 97 &repo_ref.to_nostr_git_url(&None),
98 &term, 98 &term,
99 is_grasp_server(git_server_url, &repo_ref.grasp_servers()), 99 is_grasp_server_in_list(git_server_url, &repo_ref.grasp_servers()),
100 ) { 100 ) {
101 errors.push(error); 101 errors.push(error);
102 } 102 }
diff --git a/src/bin/git_remote_nostr/push.rs b/src/bin/git_remote_nostr/push.rs
index d0cf923..1738790 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
10use console::Term; 10use console::Term;
11use git::{RepoActions, sha1_to_oid}; 11use git::{RepoActions, sha1_to_oid};
12use git_events::{ 12use git_events::{
13 generate_cover_letter_and_patch_events, generate_patch_event, 13 generate_cover_letter_and_patch_events, generate_patch_event, get_commit_id_from_patch,
14 generate_unsigned_pr_or_update_event, get_commit_id_from_patch,
15}; 14};
16use git2::{Oid, Repository}; 15use git2::{Oid, Repository};
17use ngit::{ 16use ngit::{
18 client::{self, get_event_from_cache_by_id, sign_draft_event}, 17 client::{self, get_event_from_cache_by_id},
19 git::{self, nostr_url::NostrUrlDecoded}, 18 git::{self, nostr_url::NostrUrlDecoded},
20 git_events::{self, KIND_PULL_REQUEST, event_to_cover_letter, get_event_root}, 19 git_events::{self, KIND_PULL_REQUEST, event_to_cover_letter, get_event_root},
21 list::list_from_remotes, 20 list::list_from_remotes,
22 login::{self, user::UserRef}, 21 login::{self, user::UserRef},
23 push::{push_to_remote, push_to_remote_url}, 22 push::{push_refs_and_generate_pr_or_pr_update_event, push_to_remote},
24 repo_ref::{self, get_repo_config_from_yaml, is_grasp_server, normalize_grasp_server_url}, 23 repo_ref::{self, get_repo_config_from_yaml, is_grasp_server_in_list},
25 repo_state, 24 repo_state,
26 utils::{ 25 utils::{
27 find_proposal_and_patches_by_branch_name, get_all_proposals, get_remote_name_by_url, 26 find_proposal_and_patches_by_branch_name, get_all_proposals, get_remote_name_by_url,
28 get_short_git_server_name, read_line, 27 get_short_git_server_name, read_line,
29 }, 28 },
30}; 29};
31use nostr::{event::UnsignedEvent, nips::nip10::Marker}; 30use nostr::nips::nip10::Marker;
32use nostr_sdk::{ 31use nostr_sdk::{
33 Event, EventBuilder, EventId, Kind, NostrSigner, PublicKey, RelayUrl, Tag, TagStandard, 32 Event, EventBuilder, EventId, Kind, NostrSigner, PublicKey, RelayUrl, Tag, TagStandard,
34 hashes::sha1::Hash as Sha1Hash, 33 hashes::sha1::Hash as Sha1Hash,
@@ -154,7 +153,7 @@ pub async fn run_push(
154 &repo_ref.to_nostr_git_url(&None), 153 &repo_ref.to_nostr_git_url(&None),
155 &remote_refspecs, 154 &remote_refspecs,
156 &term, 155 &term,
157 is_grasp_server(&git_server_url, &repo_ref.grasp_servers()), 156 is_grasp_server_in_list(&git_server_url, &repo_ref.grasp_servers()),
158 ); 157 );
159 } 158 }
160 } 159 }
@@ -357,7 +356,7 @@ async fn process_proposal_refspecs(
357 ); 356 );
358 } 357 }
359 if proposal.kind.eq(&KIND_PULL_REQUEST) 358 if proposal.kind.eq(&KIND_PULL_REQUEST)
360 || are_commits_too_big_for_patches(git_repo, &ahead) 359 || git_repo.are_commits_too_big_for_patches(&ahead)
361 { 360 {
362 for event in generate_patches_or_pr_event_or_pr_updates( 361 for event in generate_patches_or_pr_event_or_pr_updates(
363 git_repo, 362 git_repo,
@@ -441,19 +440,6 @@ async fn process_proposal_refspecs(
441 Ok((events, rejected_proposal_refspecs)) 440 Ok((events, rejected_proposal_refspecs))
442} 441}
443 442
444fn are_commits_too_big_for_patches(git_repo: &Repo, commits: &[Sha1Hash]) -> bool {
445 commits.iter().any(|commit| {
446 if let Ok(patch) = git_repo.make_patch_from_commit(commit, &None) {
447 patch.len()
448 > ((65 // max recomended patch event size specified in nip34 in kb
449 // allownace for nostr event wrapper (id, pubkey, tags, sig)
450 - 1) * 1024)
451 } else {
452 true
453 }
454 })
455}
456
457#[allow(clippy::too_many_lines)] 443#[allow(clippy::too_many_lines)]
458async fn generate_patches_or_pr_event_or_pr_updates( 444async fn generate_patches_or_pr_event_or_pr_updates(
459 git_repo: &Repo, 445 git_repo: &Repo,
@@ -464,96 +450,50 @@ async fn generate_patches_or_pr_event_or_pr_updates(
464 signer: &Arc<dyn NostrSigner>, 450 signer: &Arc<dyn NostrSigner>,
465 term: &Term, 451 term: &Term,
466) -> Result<Vec<Event>> { 452) -> Result<Vec<Event>> {
467 let mut events: Vec<Event> = vec![]; 453 let parent_is_pr = root_proposal.is_some_and(|proposal| proposal.kind.eq(&KIND_PULL_REQUEST));
468 let use_pr = root_proposal.is_some_and(|proposal| proposal.kind.eq(&KIND_PULL_REQUEST)) 454 let use_pr = parent_is_pr || git_repo.are_commits_too_big_for_patches(ahead);
469 || are_commits_too_big_for_patches(git_repo, ahead);
470 455
471 if use_pr { 456 if use_pr {
472 let repo_grasps = repo_ref.grasp_servers(); 457 let repo_grasps = repo_ref.grasp_servers();
473 let repo_grasp_clone_urls = repo_ref 458 let repo_grasp_clone_urls: Vec<String> = repo_ref
474 .git_server 459 .git_server
475 .iter() 460 .iter()
476 .filter(|s| is_grasp_server(s, &repo_grasps)); 461 .filter(|s| is_grasp_server_in_list(s, &repo_grasps))
477 462 .cloned()
478 let mut unsigned_pr_event: Option<UnsignedEvent> = None; 463 .collect();
479 let mut failed_clone_urls = vec![];
480 for clone_url in repo_grasp_clone_urls {
481 let mut draft_pr_event = if let Some(ref unsigned_pr_event) = unsigned_pr_event {
482 unsigned_pr_event.clone()
483 } else {
484 generate_unsigned_pr_or_update_event(
485 git_repo,
486 repo_ref,
487 &user_ref.public_key,
488 root_proposal,
489 ahead.first().context("no commits to push")?,
490 &[clone_url],
491 &[],
492 )?
493 };
494
495 let refspec = format!(
496 "{}:refs/nostr/{}",
497 ahead.first().unwrap(),
498 draft_pr_event.id()
499 );
500
501 if let Err(error) = push_to_remote_url(git_repo, clone_url, &[refspec], term) {
502 failed_clone_urls.push(clone_url);
503 term.write_line(
504 format!(
505 "push: error sending commit data to {}: {error}",
506 normalize_grasp_server_url(clone_url)?
507 )
508 .as_str(),
509 )?;
510 } else {
511 term.write_line(
512 format!(
513 "push: commit data sent to {}",
514 normalize_grasp_server_url(clone_url)?
515 )
516 .as_str(),
517 )?;
518 unsigned_pr_event = Some(draft_pr_event);
519 }
520 }
521 if unsigned_pr_event.is_none() {
522 bail!(
523 "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."
524 );
525 464
465 if repo_grasp_clone_urls.is_empty() {
526 // TODO get grasp_default_set servers that aren't in repo_grasps 466 // TODO get grasp_default_set servers that aren't in repo_grasps
527 // cycle through until one succeeds TODO create 467 // cycle through until one succeeds TODO create
528 // personal-fork announcement with grasp servers and 468 // personal-fork announcement with grasp servers and
529 // push, after a few seconds push ref/nostr/eventid. if 469 // push, after a few seconds push ref/nostr/eventid. if
530 // one success break out of for loop and continue 470 // one success break out of for loop and continue
471
472 bail!(
473 "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."
474 );
531 } 475 }
532 if let Some(unsigned_pr_event) = unsigned_pr_event { 476
533 let pr_event = sign_draft_event( 477 if let (Some(events), _) = push_refs_and_generate_pr_or_pr_update_event(
534 unsigned_pr_event, 478 git_repo,
535 signer, 479 repo_ref,
536 if root_proposal.is_some_and(|proposal| proposal.kind.eq(&Kind::GitPatch)) { 480 ahead.first().context("no commits to push")?,
537 "Pull Request Replacing Original Patch" 481 user_ref,
538 } else if root_proposal.is_some() { 482 root_proposal,
539 "Pull Request Update" 483 &None,
540 } else { 484 &repo_grasp_clone_urls,
541 "Pull Request" 485 None,
542 } 486 signer,
543 .to_string(), 487 term,
544 ) 488 )
545 .await?; 489 .await.context(
546 events.push(pr_event); 490 if parent_is_pr {
547 if root_proposal.is_some_and(|proposal| proposal.kind.eq(&Kind::GitPatch)) { 491 "couldn't generate PR update event"
548 events.push( 492 } else {
549 create_close_status_for_original_patch( 493 "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."
550 signer,
551 repo_ref,
552 root_proposal.unwrap(),
553 )
554 .await?,
555 );
556 } 494 }
495 )? {
496 Ok(events)
557 } else { 497 } else {
558 bail!( 498 bail!(
559 "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" 499 "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"
@@ -562,7 +502,7 @@ async fn generate_patches_or_pr_event_or_pr_updates(
562 // url to push to once that feature is added 502 // url to push to once that feature is added
563 } 503 }
564 } else { 504 } else {
565 for patch in generate_cover_letter_and_patch_events( 505 generate_cover_letter_and_patch_events(
566 None, 506 None,
567 git_repo, 507 git_repo,
568 ahead, 508 ahead,
@@ -571,13 +511,8 @@ async fn generate_patches_or_pr_event_or_pr_updates(
571 &root_proposal.map(|proposal| proposal.id.to_string()), 511 &root_proposal.map(|proposal| proposal.id.to_string()),
572 &[], 512 &[],
573 ) 513 )
574 .await? 514 .await
575 {
576 events.push(patch);
577 }
578 } 515 }
579
580 Ok(events)
581} 516}
582 517
583type HashMapUrlRefspecs = HashMap<String, Vec<String>>; 518type HashMapUrlRefspecs = HashMap<String, Vec<String>>;
@@ -1272,62 +1207,6 @@ async fn create_merge_status(
1272 .await 1207 .await
1273} 1208}
1274 1209
1275async fn create_close_status_for_original_patch(
1276 signer: &Arc<dyn NostrSigner>,
1277 repo_ref: &RepoRef,
1278 proposal: &Event,
1279) -> Result<Event> {
1280 let mut public_keys = repo_ref
1281 .maintainers
1282 .iter()
1283 .copied()
1284 .collect::<HashSet<PublicKey>>();
1285 public_keys.insert(proposal.pubkey);
1286
1287 sign_event(
1288 EventBuilder::new(nostr::event::Kind::GitStatusClosed, String::new()).tags(
1289 [
1290 vec![
1291 Tag::custom(
1292 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
1293 vec![
1294 "Git patch closed as forthcoming update is too large. Replacing with Pull Request"
1295 .to_string(),
1296 ],
1297 ),
1298 Tag::from_standardized(nostr::TagStandard::Event {
1299 event_id: proposal.id,
1300 relay_url: repo_ref.relays.first().cloned(),
1301 marker: Some(Marker::Root),
1302 public_key: None,
1303 uppercase: false,
1304 }),
1305 ],
1306 public_keys.iter().map(|pk| Tag::public_key(*pk)).collect(),
1307 repo_ref
1308 .coordinates()
1309 .iter()
1310 .map(|c| {
1311 Tag::from_standardized(TagStandard::Coordinate {
1312 coordinate: c.coordinate.clone(),
1313 relay_url: c.relays.first().cloned(),
1314 uppercase: false,
1315 })
1316 })
1317 .collect::<Vec<Tag>>(),
1318 vec![
1319 Tag::from_standardized(nostr::TagStandard::Reference(
1320 repo_ref.root_commit.to_string(),
1321 )),
1322 ],
1323 ]
1324 .concat(),
1325 ),
1326 signer,
1327 "close status for original patch".to_string(),
1328 )
1329 .await
1330}
1331async fn get_proposal_and_revision_root_from_patch( 1210async fn get_proposal_and_revision_root_from_patch(
1332 git_repo: &Repo, 1211 git_repo: &Repo,
1333 patch: &Event, 1212 patch: &Event,
diff --git a/src/bin/ngit/sub_commands/init.rs b/src/bin/ngit/sub_commands/init.rs
index eaaf83d..98daee4 100644
--- a/src/bin/ngit/sub_commands/init.rs
+++ b/src/bin/ngit/sub_commands/init.rs
@@ -9,15 +9,17 @@ use std::{
9 9
10use anyhow::{Context, Result, bail}; 10use anyhow::{Context, Result, bail};
11use console::{Style, Term}; 11use console::{Style, Term};
12use dialoguer::theme::{ColorfulTheme, Theme};
13use ngit::{ 12use ngit::{
14 UrlWithoutSlash, 13 UrlWithoutSlash,
15 cli_interactor::{PromptChoiceParms, PromptConfirmParms, PromptMultiChoiceParms}, 14 cli_interactor::{
15 PromptChoiceParms, PromptConfirmParms, multi_select_with_custom_value,
16 show_multi_input_prompt_success,
17 },
16 client::{Params, send_events}, 18 client::{Params, send_events},
17 git::nostr_url::{CloneUrl, NostrUrlDecoded}, 19 git::nostr_url::{CloneUrl, NostrUrlDecoded},
18 repo_ref::{ 20 repo_ref::{
19 detect_existing_grasp_servers, extract_npub, extract_pks, normalize_grasp_server_url, 21 detect_existing_grasp_servers, extract_npub, extract_pks,
20 save_repo_config_to_yaml, 22 format_grasp_server_url_as_relay_url, normalize_grasp_server_url, save_repo_config_to_yaml,
21 }, 23 },
22}; 24};
23use nostr::{ 25use nostr::{
@@ -266,11 +268,23 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
266 ); 268 );
267 let mut selections: Vec<bool> = vec![true; options.len()]; // Initialize selections based on existing options 269 let mut selections: Vec<bool> = vec![true; options.len()]; // Initialize selections based on existing options
268 let empty = options.is_empty(); 270 let empty = options.is_empty();
271 for user_grasp_option in user_ref.grasp_list.urls {
272 // Check if any option contains the user_grasp_option as a substring
273 if !options
274 .iter()
275 .any(|option| option.contains(user_grasp_option.as_str()))
276 {
277 options.push(user_grasp_option.to_string()); // Add if not found
278 selections.push(empty); // mark as selected if no existing grasp otherwise not
279 }
280 }
281
282 let empty = options.is_empty();
269 for fallback in fallback_grasp_servers { 283 for fallback in fallback_grasp_servers {
270 // Check if any option contains the fallback as a substring 284 // Check if any option contains the fallback as a substring
271 if !options.iter().any(|option| option.contains(fallback)) { 285 if !options.iter().any(|option| option.contains(fallback)) {
272 options.push(fallback.clone()); // Add fallback if not found 286 options.push(fallback.clone()); // Add fallback if not found
273 selections.push(empty); // mark as selected if no existing ngit relay otherwise not 287 selections.push(empty); // mark as selected if no existing selections otherwise not
274 } 288 }
275 } 289 }
276 let selected = multi_select_with_custom_value( 290 let selected = multi_select_with_custom_value(
@@ -727,6 +741,11 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
727 web, 741 web,
728 relays: relays.clone(), 742 relays: relays.clone(),
729 blossoms, 743 blossoms,
744 hashtags: if let Some(repo_ref) = repo_ref {
745 repo_ref.hashtags
746 } else {
747 vec![]
748 },
730 trusted_maintainer: user_ref.public_key, 749 trusted_maintainer: user_ref.public_key,
731 maintainers_without_annoucnement: None, 750 maintainers_without_annoucnement: None,
732 maintainers: maintainers.clone(), 751 maintainers: maintainers.clone(),
@@ -848,93 +867,6 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
848 Ok(()) 867 Ok(())
849} 868}
850 869
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( 870fn format_grasp_server_url_as_clone_url(
939 url: &str, 871 url: &str,
940 public_key: &PublicKey, 872 public_key: &PublicKey,
@@ -953,14 +885,6 @@ fn format_grasp_server_url_as_clone_url(
953 )) 885 ))
954} 886}
955 887
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> { 888fn format_grasp_server_url_as_blossom_url(url: &str) -> Result<String> {
965 let grasp_server_url = normalize_grasp_server_url(url)?; 889 let grasp_server_url = normalize_grasp_server_url(url)?;
966 if grasp_server_url.contains("http://") { 890 if grasp_server_url.contains("http://") {
@@ -982,19 +906,6 @@ fn parse_relay_url(s: &str) -> Result<RelayUrl> {
982 .context(format!("failed to parse relay url: {s}")) 906 .context(format!("failed to parse relay url: {s}"))
983} 907}
984 908
985pub fn show_multi_input_prompt_success(label: &str, values: &[String]) {
986 let values_str: Vec<&str> = values.iter().map(std::string::String::as_str).collect();
987 eprintln!("{}", {
988 let mut s = String::new();
989 let _ = ColorfulTheme::default().format_multi_select_prompt_selection(
990 &mut s,
991 label,
992 &values_str,
993 );
994 s
995 });
996}
997
998fn push_main_or_master_branch(git_repo: &Repo) -> Result<()> { 909fn push_main_or_master_branch(git_repo: &Repo) -> Result<()> {
999 let main_branch_name = { 910 let main_branch_name = {
1000 let local_branches = git_repo 911 let local_branches = git_repo
diff --git a/src/bin/ngit/sub_commands/send.rs b/src/bin/ngit/sub_commands/send.rs
index 8b49e37..3ae941f 100644
--- a/src/bin/ngit/sub_commands/send.rs
+++ b/src/bin/ngit/sub_commands/send.rs
@@ -1,12 +1,32 @@
1use std::path::Path; 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::{
7 PromptChoiceParms, multi_select_with_custom_value, show_multi_input_prompt_success,
8 },
6 client::{Params, send_events}, 9 client::{Params, send_events},
7 git_events::{EventRefType, generate_cover_letter_and_patch_events}, 10 git::nostr_url::CloneUrl,
11 git_events::{
12 EventRefType, KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE,
13 generate_cover_letter_and_patch_events,
14 },
15 push::push_refs_and_generate_pr_or_pr_update_event,
16 repo_ref::{
17 format_grasp_server_url_as_clone_url, format_grasp_server_url_as_relay_url,
18 is_grasp_server_in_list, normalize_grasp_server_url,
19 },
20 utils::proposal_tip_is_pr_or_pr_update,
21};
22use nostr::{
23 ToBech32,
24 event::{Event, Kind},
25 nips::{
26 nip01::Coordinate,
27 nip19::{Nip19Coordinate, Nip19Event},
28 },
8}; 29};
9use nostr::{ToBech32, nips::nip19::Nip19Event};
10use nostr_sdk::hashes::sha1::Hash as Sha1Hash; 30use nostr_sdk::hashes::sha1::Hash as Sha1Hash;
11 31
12use crate::{ 32use crate::{
@@ -60,12 +80,14 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re
60 fetching_with_report(git_repo_path, &client, &repo_coordinates).await?; 80 fetching_with_report(git_repo_path, &client, &repo_coordinates).await?;
61 } 81 }
62 82
63 let (root_proposal_id, mention_tags) = 83 let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?;
64 get_root_proposal_id_and_mentions_from_in_reply_to(git_repo.get_path()?, &args.in_reply_to) 84
85 let (root_proposal, mention_tags) =
86 get_root_proposal_and_mentions_from_in_reply_to(git_repo.get_path()?, &args.in_reply_to)
65 .await?; 87 .await?;
66 88
67 if let Some(root_ref) = args.in_reply_to.first() { 89 if let Some(root_ref) = args.in_reply_to.first() {
68 if root_proposal_id.is_some() { 90 if root_proposal.is_some() {
69 println!("creating proposal revision for: {root_ref}"); 91 println!("creating proposal revision for: {root_ref}");
70 } 92 }
71 } 93 }
@@ -104,41 +126,38 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re
104 let (first_commit_ahead, behind) = 126 let (first_commit_ahead, behind) =
105 git_repo.get_commits_ahead_behind(&main_tip, commits.last().context("no commits")?)?; 127 git_repo.get_commits_ahead_behind(&main_tip, commits.last().context("no commits")?)?;
106 128
107 // check proposal ahead of origin/main 129 check_commits_are_suitable_for_proposal(
108 if first_commit_ahead.len().gt(&1) && !Interactor::default().confirm( 130 &first_commit_ahead,
109 PromptConfirmParms::default() 131 &commits,
110 .with_prompt( 132 &behind,
111 format!("proposal builds on a commit {} ahead of '{main_branch_name}' - do you want to continue?", first_commit_ahead.len() - 1) 133 main_branch_name,
112 ) 134 &main_tip,
113 .with_default(false) 135 )?;
114 ).context("failed to get confirmation response from interactor confirm")? { 136
115 bail!("aborting because selected commits were ahead of origin/master"); 137 let as_pr = {
116 } 138 if let Some(root_proposal) = &root_proposal {
117 139 proposal_tip_is_pr_or_pr_update(git_repo_path, &repo_ref, &root_proposal.id).await?
118 // check if a selected commit is already in origin 140 } else {
119 if commits.iter().any(|c| c.eq(&main_tip)) { 141 false
120 if !Interactor::default().confirm(
121 PromptConfirmParms::default()
122 .with_prompt(
123 format!("proposal contains commit(s) already in '{main_branch_name}'. proceed anyway?")
124 )
125 .with_default(false)
126 ).context("failed to get confirmation response from interactor confirm")? {
127 bail!("aborting as proposal contains commit(s) already in '{main_branch_name}'");
128 } 142 }
129 } 143 } || git_repo.are_commits_too_big_for_patches(&commits);
130 // check proposal isn't behind origin/main
131 else if !behind.is_empty() && !Interactor::default().confirm(
132 PromptConfirmParms::default()
133 .with_prompt(
134 format!("proposal is {} behind '{main_branch_name}'. consider rebasing before submission. proceed anyway?", behind.len())
135 )
136 .with_default(false)
137 ).context("failed to get confirmation response from interactor confirm")? {
138 bail!("aborting so commits can be rebased");
139 }
140 144
141 let title = if args.no_cover_letter { 145 let title = if as_pr {
146 match &args.title {
147 Some(t) => Some(t.clone()),
148 None => {
149 if root_proposal.is_none() {
150 Some(
151 Interactor::default()
152 .input(PromptInputParms::default().with_prompt("title"))?
153 .clone(),
154 )
155 } else {
156 None
157 }
158 }
159 }
160 } else if args.no_cover_letter {
142 None 161 None
143 } else { 162 } else {
144 match &args.title { 163 match &args.title {
@@ -168,7 +187,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re
168 t.clone() 187 t.clone()
169 } else { 188 } else {
170 Interactor::default() 189 Interactor::default()
171 .input(PromptInputParms::default().with_prompt("cover letter description"))? 190 .input(PromptInputParms::default().with_prompt("description"))?
172 .clone() 191 .clone()
173 }, 192 },
174 )) 193 ))
@@ -176,7 +195,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re
176 None 195 None
177 }; 196 };
178 197
179 let (signer, user_ref, _) = login::login_or_signup( 198 let (signer, mut user_ref, _) = login::login_or_signup(
180 &Some(&git_repo), 199 &Some(&git_repo),
181 &extract_signer_cli_arguments(cli_args).unwrap_or(None), 200 &extract_signer_cli_arguments(cli_args).unwrap_or(None),
182 &cli_args.password, 201 &cli_args.password,
@@ -187,42 +206,316 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re
187 206
188 client.set_signer(signer.clone()).await; 207 client.set_signer(signer.clone()).await;
189 208
190 let repo_ref = get_repo_ref_from_cache(Some(git_repo_path), &repo_coordinates).await?;
191
192 // oldest first 209 // oldest first
193 commits.reverse(); 210 commits.reverse();
194 211
195 let events = generate_cover_letter_and_patch_events( 212 let events = if as_pr {
196 cover_letter_title_description.clone(), 213 let mut to_try = vec![];
197 &git_repo, 214 let mut tried = vec![];
198 &commits, 215 let repo_grasps = repo_ref.grasp_servers();
199 &signer, 216 // if the user already has a fork, or is a maintainer, use those git servers
200 &repo_ref, 217 let mut user_repo_ref = get_repo_ref_from_cache(
201 &root_proposal_id, 218 Some(git_repo_path),
202 &mention_tags, 219 &Nip19Coordinate {
203 ) 220 coordinate: Coordinate {
204 .await?; 221 kind: nostr::event::Kind::GitRepoAnnouncement,
205 222 public_key: user_ref.public_key,
206 println!( 223 identifier: repo_ref.identifier.clone(),
207 "posting {} patch{} {} a covering letter...", 224 },
208 if cover_letter_title_description.is_none() { 225 relays: vec![],
209 events.len() 226 },
210 } else { 227 )
211 events.len() - 1 228 .await
212 }, 229 .ok();
213 if cover_letter_title_description.is_none() && events.len().eq(&1) 230 if let Some(user_repo_ref) = &user_repo_ref {
214 || cover_letter_title_description.is_some() && events.len().eq(&2) 231 for url in &user_repo_ref.git_server {
215 { 232 if CloneUrl::from_str(url).is_ok() {
216 "" 233 to_try.push(url.clone());
217 } else { 234 }
218 "es" 235 }
219 }, 236 }
220 if cover_letter_title_description.is_none() { 237 if !to_try.is_empty() || !repo_grasps.is_empty() {
221 "without" 238 println!(
239 "pushing proposal refs to {}",
240 if repo_ref.maintainers.contains(&user_ref.public_key) {
241 "repository git servers"
242 } else if to_try.is_empty() {
243 "repository grasp servers"
244 } else if repo_grasps.is_empty() {
245 "the git servers listed in your fork"
246 } else {
247 "the git servers listed in your fork and repository grasp servers"
248 }
249 );
222 } else { 250 } else {
223 "with" 251 println!(
252 "The repository doesn't list a grasp server which would otherwise be used to submit your proposal as nostr Pull Request."
253 );
254 }
255 // also use repo grasp servers
256 for url in &repo_ref.git_server {
257 if is_grasp_server_in_list(url, &repo_grasps) && !to_try.contains(url) {
258 to_try.push(url.clone());
259 }
224 } 260 }
225 ); 261
262 let mut git_ref = None;
263 let events = loop {
264 let (events, _server_responses) = push_refs_and_generate_pr_or_pr_update_event(
265 &git_repo,
266 &repo_ref,
267 commits.last().context("no commits")?,
268 &user_ref,
269 root_proposal.as_ref(),
270 &cover_letter_title_description,
271 &to_try,
272 git_ref.clone(),
273 &signer,
274 &console::Term::stdout(),
275 )
276 .await?;
277 for url in to_try {
278 tried.push(url);
279 }
280 to_try = vec![];
281 if let Some(events) = events {
282 break events;
283 }
284 // fallback to creating user personal-fork on their grasp servers
285 let untried_user_grasp_servers: Vec<String> = user_ref
286 .grasp_list
287 .urls
288 .iter()
289 .map(std::string::ToString::to_string)
290 .filter(|g| {
291 // is a grasp server not in list of tried
292 !is_grasp_server_in_list(g, &tried)
293 })
294 .collect();
295
296 if untried_user_grasp_servers.is_empty()
297 && Interactor::default().choice(
298 PromptChoiceParms::default()
299 .with_prompt("choose alternative git server")
300 .dont_report()
301 .with_choices(vec![
302 "choose grasp server(s)".to_string(),
303 "enter a git repo url with write permission".to_string(),
304 ])
305 .with_default(0),
306 )? == 1
307 {
308 loop {
309 let clone_url = Interactor::default()
310 .input(
311 PromptInputParms::default()
312 .with_prompt("git repo url with write permission"),
313 )?
314 .clone();
315 if CloneUrl::from_str(&clone_url).is_ok() {
316 to_try.push(clone_url);
317 let mut git_ref_or_branch_name = Interactor::default()
318 .input(
319 PromptInputParms::default()
320 .with_prompt("ref / branch name")
321 .with_default(
322 git_ref.unwrap_or("refs/nostr/<event-id>".to_string()),
323 ),
324 )?
325 .clone();
326 if !git_ref_or_branch_name.starts_with("refs/") {
327 git_ref_or_branch_name = format!("refs/heads/{git_ref_or_branch_name}");
328 }
329 git_ref = Some(git_ref_or_branch_name);
330 break;
331 }
332 println!("invalid clone url");
333 }
334 continue;
335 }
336
337 let mut new_grasp_server_events: Vec<Event> = vec![];
338
339 let grasp_servers = if untried_user_grasp_servers.is_empty() {
340 let default_choices: Vec<String> = client
341 .get_grasp_default_set()
342 .iter()
343 .filter(|g| !is_grasp_server_in_list(g, &tried))
344 .cloned()
345 .collect();
346 let selections = vec![true; default_choices.len()]; // all selected by default
347 let grasp_servers = multi_select_with_custom_value(
348 "alternative grasp server(s)",
349 "grasp server",
350 default_choices,
351 selections,
352 normalize_grasp_server_url,
353 )?;
354 show_multi_input_prompt_success("alternative grasp server(s)", &grasp_servers);
355 if grasp_servers.is_empty() {
356 // ask again
357 continue;
358 }
359 let normalised_grasp_servers: Vec<String> = grasp_servers
360 .iter()
361 .filter_map(|g| normalize_grasp_server_url(g).ok())
362 .collect();
363 // if any grasp servers not listed in user grasp list prompt to update
364 let grasp_servers_not_in_user_prefs: Vec<String> = normalised_grasp_servers
365 .iter()
366 .filter(|g| {
367 !user_ref.grasp_list.urls.contains(
368 // unwrap is safe as we constructed g
369 &nostr::Url::parse(&format_grasp_server_url_as_relay_url(g).unwrap())
370 .unwrap(),
371 )
372 })
373 .cloned()
374 .collect();
375 if !grasp_servers_not_in_user_prefs.is_empty()
376 && Interactor::default().confirm(
377 PromptConfirmParms::default()
378 .with_prompt(
379 "add these to your list of prefered grasp servers?".to_string(),
380 )
381 .with_default(true),
382 )?
383 {
384 for g in &normalised_grasp_servers {
385 let as_url = nostr::Url::parse(&format_grasp_server_url_as_relay_url(g)?)?;
386 if !user_ref.grasp_list.urls.contains(&as_url) {
387 user_ref.grasp_list.urls.push(as_url);
388 }
389 }
390 new_grasp_server_events.push(user_ref.grasp_list.to_event(&signer).await?);
391 }
392 normalised_grasp_servers
393 } else {
394 untried_user_grasp_servers
395 };
396 println!(
397 "{} personal-fork so we can push commits to your prefered grasp servers",
398 if user_repo_ref.is_some() {
399 "Updating"
400 } else {
401 "Creating a"
402 },
403 );
404
405 let grasp_servers_as_personal_clone_url: Vec<String> = grasp_servers
406 .iter()
407 .filter_map(|g| {
408 format_grasp_server_url_as_clone_url(
409 g,
410 &user_ref.public_key,
411 &repo_ref.identifier,
412 )
413 .ok()
414 })
415 .collect();
416
417 // create personal-fork / update existing user repo and add these grasp servers
418 let updated_user_repo_ref = {
419 if let Some(mut user_repo_ref) = user_repo_ref {
420 for g in &grasp_servers_as_personal_clone_url {
421 user_repo_ref.add_grasp_server(g)?;
422 }
423 user_repo_ref
424 } else {
425 // clone repo_ref and reset as personal-fork
426 let mut user_repo_ref = repo_ref.clone();
427 user_repo_ref.trusted_maintainer = user_ref.public_key;
428 user_repo_ref.maintainers = vec![user_ref.public_key];
429 user_repo_ref.git_server = vec![];
430 user_repo_ref.relays = vec![];
431 if !user_repo_ref
432 .hashtags
433 .contains(&"personal-fork".to_string())
434 {
435 user_repo_ref.hashtags.push("personal-fork".to_string());
436 }
437 user_repo_ref
438 }
439 };
440 // pubish event to my-relays and my-fork-relays
441 new_grasp_server_events.push(updated_user_repo_ref.to_event(&signer).await?);
442 send_events(
443 &client,
444 Some(git_repo_path),
445 new_grasp_server_events,
446 user_ref.relays.write(),
447 updated_user_repo_ref.relays.clone(),
448 !cli_args.disable_cli_spinners,
449 false,
450 )
451 .await?;
452 user_repo_ref = Some(updated_user_repo_ref);
453 // wait a few seconds
454 let countdown_start = 5;
455 let term = console::Term::stdout();
456 for i in (1..=countdown_start).rev() {
457 term.write_line(
458 format!(
459 "waiting {i}s grasp servers to create your repo before we push your data"
460 )
461 .as_str(),
462 )?;
463 thread::sleep(Duration::new(1, 0)); // Sleep for 1 second
464 term.clear_last_lines(1)?;
465 }
466 term.flush().unwrap(); // Ensure the output is flushed to the terminal
467
468 // add grasp servers to to_try
469 for url in grasp_servers_as_personal_clone_url {
470 to_try.push(url);
471 }
472 // the loop with continue with the grasp servers
473 };
474 println!(
475 "posting {}",
476 if events.iter().any(|e| e.kind.eq(&Kind::GitStatusClosed)) {
477 "proposal revision as new PR event, and a close status for the old patch"
478 } else if events.iter().any(|e| e.kind.eq(&KIND_PULL_REQUEST_UPDATE)) {
479 "proposal revision as PR update event"
480 } else {
481 "proposal as PR event"
482 }
483 );
484 events
485 } else {
486 let events = generate_cover_letter_and_patch_events(
487 cover_letter_title_description.clone(),
488 &git_repo,
489 &commits,
490 &signer,
491 &repo_ref,
492 &root_proposal.as_ref().map(|e| e.id.to_string()),
493 &mention_tags,
494 )
495 .await?;
496
497 println!(
498 "posting {} patch{} {} a covering letter...",
499 if cover_letter_title_description.is_none() {
500 events.len()
501 } else {
502 events.len() - 1
503 },
504 if cover_letter_title_description.is_none() && events.len().eq(&1)
505 || cover_letter_title_description.is_some() && events.len().eq(&2)
506 {
507 ""
508 } else {
509 "es"
510 },
511 if cover_letter_title_description.is_none() {
512 "without"
513 } else {
514 "with"
515 }
516 );
517 events
518 };
226 519
227 send_events( 520 send_events(
228 &client, 521 &client,
@@ -235,7 +528,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re
235 ) 528 )
236 .await?; 529 .await?;
237 530
238 if root_proposal_id.is_none() { 531 if root_proposal.is_none() {
239 if let Some(event) = events.first() { 532 if let Some(event) = events.first() {
240 let event_bech32 = if let Some(relay) = repo_ref.relays.first() { 533 let event_bech32 = if let Some(relay) = repo_ref.relays.first() {
241 Nip19Event { 534 Nip19Event {
@@ -251,8 +544,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re
251 println!( 544 println!(
252 "{}", 545 "{}",
253 dim.apply_to(format!( 546 dim.apply_to(format!(
254 "view in gitworkshop.dev: https://gitworkshop.dev/repo/{}/proposal/{}", 547 "view in gitworkshop.dev: https://gitworkshop.dev/{}",
255 repo_ref.coordinate_with_hint().to_bech32()?,
256 &event_bech32, 548 &event_bech32,
257 )) 549 ))
258 ); 550 );
@@ -269,6 +561,49 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re
269 Ok(()) 561 Ok(())
270} 562}
271 563
564fn check_commits_are_suitable_for_proposal(
565 first_commit_ahead: &[Sha1Hash],
566 commits: &[Sha1Hash],
567 behind: &[Sha1Hash],
568 main_branch_name: &str,
569 main_tip: &Sha1Hash,
570) -> Result<()> {
571 // check proposal ahead of origin/main
572 if first_commit_ahead.len().gt(&1) && !Interactor::default().confirm(
573 PromptConfirmParms::default()
574 .with_prompt(
575 format!("proposal builds on a commit {} ahead of '{main_branch_name}' - do you want to continue?", first_commit_ahead.len() - 1)
576 )
577 .with_default(false)
578 ).context("failed to get confirmation response from interactor confirm")? {
579 bail!("aborting because selected commits were ahead of origin/master");
580 }
581
582 // check if a selected commit is already in origin
583 if commits.iter().any(|c| c.eq(main_tip)) {
584 if !Interactor::default().confirm(
585 PromptConfirmParms::default()
586 .with_prompt(
587 format!("proposal contains commit(s) already in '{main_branch_name}'. proceed anyway?")
588 )
589 .with_default(false)
590 ).context("failed to get confirmation response from interactor confirm")? {
591 bail!("aborting as proposal contains commit(s) already in '{main_branch_name}'");
592 }
593 }
594 // check proposal isn't behind origin/main
595 else if !behind.is_empty() && !Interactor::default().confirm(
596 PromptConfirmParms::default()
597 .with_prompt(
598 format!("proposal is {} behind '{main_branch_name}'. consider rebasing before submission. proceed anyway?", behind.len())
599 )
600 .with_default(false)
601 ).context("failed to get confirmation response from interactor confirm")? {
602 bail!("aborting so commits can be rebased");
603 }
604 Ok(())
605}
606
272fn choose_commits(git_repo: &Repo, proposed_commits: Vec<Sha1Hash>) -> Result<Vec<Sha1Hash>> { 607fn choose_commits(git_repo: &Repo, proposed_commits: Vec<Sha1Hash>) -> Result<Vec<Sha1Hash>> {
273 let mut proposed_commits = if proposed_commits.len().gt(&10) { 608 let mut proposed_commits = if proposed_commits.len().gt(&10) {
274 vec![] 609 vec![]
@@ -360,11 +695,11 @@ fn summarise_commit_for_selection(git_repo: &Repo, commit: &Sha1Hash) -> Result<
360 )) 695 ))
361} 696}
362 697
363async fn get_root_proposal_id_and_mentions_from_in_reply_to( 698async fn get_root_proposal_and_mentions_from_in_reply_to(
364 git_repo_path: &Path, 699 git_repo_path: &Path,
365 in_reply_to: &[String], 700 in_reply_to: &[String],
366) -> Result<(Option<String>, Vec<nostr::Tag>)> { 701) -> Result<(Option<Event>, Vec<nostr::Tag>)> {
367 let root_proposal_id = if let Some(first) = in_reply_to.first() { 702 let root_proposal = if let Some(first) = in_reply_to.first() {
368 match event_tag_from_nip19_or_hex(first, "in-reply-to", EventRefType::Root, true, false)? 703 match event_tag_from_nip19_or_hex(first, "in-reply-to", EventRefType::Root, true, false)?
369 .as_standardized() 704 .as_standardized()
370 { 705 {
@@ -382,8 +717,8 @@ async fn get_root_proposal_id_and_mentions_from_in_reply_to(
382 .await?; 717 .await?;
383 718
384 if let Some(first) = events.iter().find(|e| e.id.eq(event_id)) { 719 if let Some(first) = events.iter().find(|e| e.id.eq(event_id)) {
385 if event_is_patch_set_root(first) { 720 if event_is_patch_set_root(first) || first.kind.eq(&KIND_PULL_REQUEST) {
386 Some(event_id.to_string()) 721 Some(first.clone())
387 } else { 722 } else {
388 None 723 None
389 } 724 }
@@ -399,7 +734,7 @@ async fn get_root_proposal_id_and_mentions_from_in_reply_to(
399 734
400 let mut mention_tags = vec![]; 735 let mut mention_tags = vec![];
401 for (i, reply_to) in in_reply_to.iter().enumerate() { 736 for (i, reply_to) in in_reply_to.iter().enumerate() {
402 if i.ne(&0) || root_proposal_id.is_none() { 737 if i.ne(&0) || root_proposal.is_none() {
403 mention_tags.push( 738 mention_tags.push(
404 event_tag_from_nip19_or_hex( 739 event_tag_from_nip19_or_hex(
405 reply_to, 740 reply_to,
@@ -415,7 +750,7 @@ async fn get_root_proposal_id_and_mentions_from_in_reply_to(
415 } 750 }
416 } 751 }
417 752
418 Ok((root_proposal_id, mention_tags)) 753 Ok((root_proposal, mention_tags))
419} 754}
420 755
421// TODO 756// TODO
diff --git a/src/bin/ngit/sub_commands/sync.rs b/src/bin/ngit/sub_commands/sync.rs
index c1a3484..00dfe75 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<()> {
127 term.write_line(&format!("{remote_name} already in sync"))?; 127 term.write_line(&format!("{remote_name} already in sync"))?;
128 } 128 }
129 // report already in sync 129 // report already in sync
130 } else if let Err(error) = push_to_remote(
131 &git_repo,
132 url,
133 &decoded_nostr_url,
134 &refspecs,
135 &term,
136 *is_grasp_server,
137 ) {
138 term.write_line(&format!(
139 "error pushing updates to {remote_name}: error: {error}"
140 ))?;
141 } else if *is_grasp_server || args.force {
142 term.write_line(&format!("{remote_name} sync completed"))?;
143 // TODO we only know if there was an error but not if it
144 // rejected any updates
145 } else { 130 } else {
146 // we should report on refs not force pushed 131 match push_to_remote(
147 term.write_line(&format!("{remote_name} sync completed"))?; 132 &git_repo,
148 } 133 url,
149 for name in &not_deleted { 134 &decoded_nostr_url,
150 term.write_line(&format!(" - {name} not deleted"))?; 135 &refspecs,
151 } 136 &term,
152 for name in &not_updated { 137 *is_grasp_server,
153 term.write_line(&format!(" - {name} not updated due to conflicts"))?; 138 ) {
154 } 139 Err(error) => {
155 if !not_updated.is_empty() || !not_deleted.is_empty() { 140 term.write_line(&format!(
156 term.write_line("run `ngit sync --force` to delete refs or overwrite conflicts and potentially lose work")?; 141 "error pushing updates to {remote_name}: error: {error}"
142 ))?;
143 }
144 Ok(updated_refs) => {
145 if updated_refs.values().all(std::option::Option::is_none) {
146 if *is_grasp_server || args.force {
147 term.write_line(&format!("{remote_name} sync completed"))?;
148 // TODO we only know if there was an error but not
149 // if it rejected any
150 // updates
151 } else {
152 // we should report on refs not force pushed
153 term.write_line(&format!("{remote_name} sync completed"))?;
154 }
155 } else {
156 term.write_line(&format!(
157 "{remote_name} sync completed but not all changes were accepted"
158 ))?;
159 }
160 for name in &not_deleted {
161 term.write_line(&format!(" - {name} not deleted"))?;
162 }
163 for name in &not_updated {
164 term.write_line(&format!(" - {name} not updated due to conflicts"))?;
165 }
166 if !not_updated.is_empty() || !not_deleted.is_empty() {
167 term.write_line("run `ngit sync --force` to delete refs or overwrite conflicts and potentially lose work")?;
168 }
169 }
170 }
157 } 171 }
158 } 172 }
159 173
diff --git a/src/lib/cli_interactor.rs b/src/lib/cli_interactor.rs
index 8fca81d..e944bf9 100644
--- a/src/lib/cli_interactor.rs
+++ b/src/lib/cli_interactor.rs
@@ -1,5 +1,8 @@
1use anyhow::{Context, Result}; 1use anyhow::{Context, Result};
2use dialoguer::{Confirm, Input, Password, theme::ColorfulTheme}; 2use dialoguer::{
3 Confirm, Input, Password,
4 theme::{ColorfulTheme, Theme},
5};
3use indicatif::TermLike; 6use indicatif::TermLike;
4#[cfg(test)] 7#[cfg(test)]
5use mockall::*; 8use mockall::*;
@@ -236,6 +239,106 @@ impl PromptMultiChoiceParms {
236 } 239 }
237} 240}
238 241
242pub fn multi_select_with_custom_value<F>(
243 prompt: &str,
244 custom_choice_prompt: &str,
245 mut choices: Vec<String>,
246 mut defaults: Vec<bool>,
247 validate_choice: F,
248) -> Result<Vec<String>>
249where
250 F: Fn(&str) -> Result<String>,
251{
252 let mut selected_choices = vec![];
253
254 // Loop to allow users to add more choices
255 loop {
256 // Add 'add another' option at the end of the choices
257 let mut current_choices = choices.clone();
258 current_choices.push(if current_choices.is_empty() {
259 "add".to_string()
260 } else {
261 "add another".to_string()
262 });
263
264 // Create default selections based on the provided defaults
265 let mut current_defaults = defaults.clone();
266 current_defaults.push(current_choices.len() == 1); // 'add another' should not be selected by default
267
268 // Prompt for selections
269 let selected_indices: Vec<usize> = Interactor::default().multi_choice(
270 PromptMultiChoiceParms::default()
271 .with_prompt(prompt)
272 .dont_report()
273 .with_choices(current_choices.clone())
274 .with_defaults(current_defaults),
275 )?;
276
277 // Collect selected choices
278 selected_choices.clear(); // Clear previous selections to update
279 for &index in &selected_indices {
280 if index < choices.len() {
281 // Exclude 'add another' option
282 selected_choices.push(choices[index].clone());
283 }
284 }
285
286 // Check if 'add another' was selected
287 if selected_indices.contains(&(choices.len())) {
288 // Last index is 'add another'
289 let mut new_choice: String;
290 loop {
291 new_choice = Interactor::default().input(
292 PromptInputParms::default()
293 .with_prompt(custom_choice_prompt)
294 .dont_report()
295 .optional(),
296 )?;
297
298 if new_choice.is_empty() {
299 break;
300 }
301 // Validate the new choice
302 match validate_choice(&new_choice) {
303 Ok(valid_choice) => {
304 new_choice = valid_choice; // Use the fixed version of the input
305 break; // Valid choice, exit the loop
306 }
307 Err(err) => {
308 // Inform the user about the validation error
309 println!("Error: {err}");
310 }
311 }
312 }
313
314 // Add the new choice to the choices vector
315 if !new_choice.is_empty() {
316 choices.push(new_choice.clone()); // Add new choice to the end of the list
317 selected_choices.push(new_choice); // Automatically select the new choice
318 defaults.push(true); // Set the new choice as selected by default
319 }
320 } else {
321 // Exit the loop if 'add another' was not selected
322 break;
323 }
324 }
325
326 Ok(selected_choices)
327}
328
329pub fn show_multi_input_prompt_success(label: &str, values: &[String]) {
330 let values_str: Vec<&str> = values.iter().map(std::string::String::as_str).collect();
331 eprintln!("{}", {
332 let mut s = String::new();
333 let _ = ColorfulTheme::default().format_multi_select_prompt_selection(
334 &mut s,
335 label,
336 &values_str,
337 );
338 s
339 });
340}
341
239#[derive(Debug, Default)] 342#[derive(Debug, Default)]
240pub struct Printer { 343pub struct Printer {
241 printed_lines: Vec<String>, 344 printed_lines: Vec<String>,
diff --git a/src/lib/client.rs b/src/lib/client.rs
index 3fcfba4..0984b19 100644
--- a/src/lib/client.rs
+++ b/src/lib/client.rs
@@ -53,7 +53,7 @@ use crate::{
53 get_dirs, 53 get_dirs,
54 git::{Repo, RepoActions, get_git_config_item}, 54 git::{Repo, RepoActions, get_git_config_item},
55 git_events::{ 55 git_events::{
56 KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, event_is_cover_letter, 56 KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, KIND_USER_GRASP_LIST, event_is_cover_letter,
57 event_is_patch_set_root, event_is_revision_root, event_is_valid_pr_or_pr_update, 57 event_is_patch_set_root, event_is_revision_root, event_is_valid_pr_or_pr_update,
58 status_kinds, 58 status_kinds,
59 }, 59 },
@@ -241,7 +241,7 @@ impl Connect for Client {
241 if let Some(git_repo_path) = git_repo_path { 241 if let Some(git_repo_path) = git_repo_path {
242 save_event_in_local_cache(git_repo_path, &event).await?; 242 save_event_in_local_cache(git_repo_path, &event).await?;
243 } 243 }
244 if event.kind.eq(&Kind::GitRepoAnnouncement) { 244 if [Kind::GitRepoAnnouncement, KIND_USER_GRASP_LIST].contains(&event.kind) {
245 save_event_in_global_cache(git_repo_path, &event).await?; 245 save_event_in_global_cache(git_repo_path, &event).await?;
246 } 246 }
247 Ok(event.id) 247 Ok(event.id)
@@ -1308,17 +1308,21 @@ async fn create_relays_request(
1308 user_profiles.insert(current_user); 1308 user_profiles.insert(current_user);
1309 } 1309 }
1310 } 1310 }
1311 let mut map: HashMap<PublicKey, (Timestamp, Timestamp)> = HashMap::new(); 1311 let mut map: HashMap<PublicKey, (Timestamp, Timestamp, Timestamp)> = HashMap::new();
1312 for public_key in &user_profiles { 1312 for public_key in &user_profiles {
1313 if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await { 1313 if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await {
1314 map.insert( 1314 map.insert(
1315 public_key.to_owned(), 1315 public_key.to_owned(),
1316 (user_ref.metadata.created_at, user_ref.relays.created_at), 1316 (
1317 user_ref.metadata.created_at,
1318 user_ref.relays.created_at,
1319 user_ref.grasp_list.created_at,
1320 ),
1317 ); 1321 );
1318 } else { 1322 } else {
1319 map.insert( 1323 map.insert(
1320 public_key.to_owned(), 1324 public_key.to_owned(),
1321 (Timestamp::from(0), Timestamp::from(0)), 1325 (Timestamp::from(0), Timestamp::from(0), Timestamp::from(0)),
1322 ); 1326 );
1323 } 1327 }
1324 } 1328 }
@@ -1545,16 +1549,22 @@ async fn process_fetched_events(
1545 { 1549 {
1546 fresh_profiles.insert(event.pubkey); 1550 fresh_profiles.insert(event.pubkey);
1547 } 1551 }
1548 } else if [Kind::RelayList, Kind::Metadata].contains(&event.kind) { 1552 } else if [Kind::RelayList, Kind::Metadata, KIND_USER_GRASP_LIST].contains(&event.kind)
1553 {
1549 if request.missing_contributor_profiles.contains(&event.pubkey) { 1554 if request.missing_contributor_profiles.contains(&event.pubkey) {
1550 report.contributor_profiles.insert(event.pubkey); 1555 report.contributor_profiles.insert(event.pubkey);
1551 } else if let Some((_, (metadata_timestamp, relay_list_timestamp))) = request 1556 } else if let Some((
1557 _,
1558 (metadata_timestamp, relay_list_timestamp, grasp_list_timestamp),
1559 )) = request
1552 .profiles_to_fetch_from_user_relays 1560 .profiles_to_fetch_from_user_relays
1553 .get_key_value(&event.pubkey) 1561 .get_key_value(&event.pubkey)
1554 { 1562 {
1555 if (Kind::Metadata.eq(&event.kind) && event.created_at.gt(metadata_timestamp)) 1563 if (Kind::Metadata.eq(&event.kind) && event.created_at.gt(metadata_timestamp))
1556 || (Kind::RelayList.eq(&event.kind) 1564 || (Kind::RelayList.eq(&event.kind)
1557 && event.created_at.gt(relay_list_timestamp)) 1565 && event.created_at.gt(relay_list_timestamp))
1566 || (KIND_USER_GRASP_LIST.eq(&event.kind)
1567 && event.created_at.gt(grasp_list_timestamp))
1558 { 1568 {
1559 report.profile_updates.insert(event.pubkey); 1569 report.profile_updates.insert(event.pubkey);
1560 } 1570 }
@@ -1716,35 +1726,21 @@ pub fn get_filter_repo_events(repo_coordinates: &HashSet<Nip19Coordinate>) -> no
1716 .map(|c| c.identifier.clone()) 1726 .map(|c| c.identifier.clone())
1717 .collect::<Vec<String>>(), 1727 .collect::<Vec<String>>(),
1718 ) 1728 )
1719 .authors(
1720 repo_coordinates
1721 .iter()
1722 .map(|c| c.public_key)
1723 .collect::<Vec<PublicKey>>(),
1724 )
1725} 1729}
1726 1730
1727pub static STATE_KIND: nostr::Kind = Kind::Custom(30618); 1731pub static STATE_KIND: nostr::Kind = Kind::Custom(30618);
1728pub fn get_filter_state_events(repo_coordinates: &HashSet<Nip19Coordinate>) -> nostr::Filter { 1732pub fn get_filter_state_events(repo_coordinates: &HashSet<Nip19Coordinate>) -> nostr::Filter {
1729 nostr::Filter::default() 1733 nostr::Filter::default().kind(STATE_KIND).identifiers(
1730 .kind(STATE_KIND) 1734 repo_coordinates
1731 .identifiers( 1735 .iter()
1732 repo_coordinates 1736 .map(|c| c.identifier.clone())
1733 .iter() 1737 .collect::<Vec<String>>(),
1734 .map(|c| c.identifier.clone()) 1738 )
1735 .collect::<Vec<String>>(),
1736 )
1737 .authors(
1738 repo_coordinates
1739 .iter()
1740 .map(|c| c.public_key)
1741 .collect::<Vec<PublicKey>>(),
1742 )
1743} 1739}
1744 1740
1745pub fn get_filter_contributor_profiles(contributors: HashSet<PublicKey>) -> nostr::Filter { 1741pub fn get_filter_contributor_profiles(contributors: HashSet<PublicKey>) -> nostr::Filter {
1746 nostr::Filter::default() 1742 nostr::Filter::default()
1747 .kinds(vec![Kind::Metadata, Kind::RelayList]) 1743 .kinds(vec![Kind::Metadata, Kind::RelayList, KIND_USER_GRASP_LIST])
1748 .authors(contributors) 1744 .authors(contributors)
1749} 1745}
1750 1746
@@ -1848,7 +1844,7 @@ pub struct FetchRequest {
1848 contributors: HashSet<PublicKey>, 1844 contributors: HashSet<PublicKey>,
1849 missing_contributor_profiles: HashSet<PublicKey>, 1845 missing_contributor_profiles: HashSet<PublicKey>,
1850 existing_events: HashSet<EventId>, 1846 existing_events: HashSet<EventId>,
1851 profiles_to_fetch_from_user_relays: HashMap<PublicKey, (Timestamp, Timestamp)>, 1847 profiles_to_fetch_from_user_relays: HashMap<PublicKey, (Timestamp, Timestamp, Timestamp)>,
1852 user_relays_for_profiles: HashSet<RelayUrl>, 1848 user_relays_for_profiles: HashSet<RelayUrl>,
1853} 1849}
1854 1850
diff --git a/src/lib/git/mod.rs b/src/lib/git/mod.rs
index b275b49..3d5297f 100644
--- a/src/lib/git/mod.rs
+++ b/src/lib/git/mod.rs
@@ -75,6 +75,7 @@ pub trait RepoActions {
75 commit: &Sha1Hash, 75 commit: &Sha1Hash,
76 series_count: &Option<(u64, u64)>, 76 series_count: &Option<(u64, u64)>,
77 ) -> Result<String>; 77 ) -> Result<String>;
78 fn are_commits_too_big_for_patches(&self, commits: &[Sha1Hash]) -> bool;
78 fn extract_commit_pgp_signature(&self, commit: &Sha1Hash) -> Result<String>; 79 fn extract_commit_pgp_signature(&self, commit: &Sha1Hash) -> Result<String>;
79 fn checkout(&self, ref_name: &str) -> Result<Sha1Hash>; 80 fn checkout(&self, ref_name: &str) -> Result<Sha1Hash>;
80 fn create_branch_at_commit(&self, branch_name: &str, commit: &str) -> Result<()>; 81 fn create_branch_at_commit(&self, branch_name: &str, commit: &str) -> Result<()>;
@@ -380,6 +381,19 @@ impl RepoActions for Repo {
380 .to_owned()) 381 .to_owned())
381 } 382 }
382 383
384 fn are_commits_too_big_for_patches(&self, commits: &[Sha1Hash]) -> bool {
385 commits.iter().any(|commit| {
386 if let Ok(patch) = self.make_patch_from_commit(commit, &None) {
387 patch.len()
388 > ((65 // max recomended patch event size specified in nip34 in kb
389 // allownace for nostr event wrapper (id, pubkey, tags, sig)
390 - 1) * 1024)
391 } else {
392 true
393 }
394 })
395 }
396
383 fn extract_commit_pgp_signature(&self, commit: &Sha1Hash) -> Result<String> { 397 fn extract_commit_pgp_signature(&self, commit: &Sha1Hash) -> Result<String> {
384 let oid = Oid::from_bytes(commit.as_byte_array()).context(format!( 398 let oid = Oid::from_bytes(commit.as_byte_array()).context(format!(
385 "failed to convert commit_id format for {}", 399 "failed to convert commit_id format for {}",
diff --git a/src/lib/git_events.rs b/src/lib/git_events.rs
index 8ba5419..5ea630a 100644
--- a/src/lib/git_events.rs
+++ b/src/lib/git_events.rs
@@ -63,6 +63,7 @@ pub fn status_kinds() -> Vec<Kind> {
63 63
64pub const KIND_PULL_REQUEST: Kind = Kind::Custom(1618); 64pub const KIND_PULL_REQUEST: Kind = Kind::Custom(1618);
65pub const KIND_PULL_REQUEST_UPDATE: Kind = Kind::Custom(1619); 65pub const KIND_PULL_REQUEST_UPDATE: Kind = Kind::Custom(1619);
66pub const KIND_USER_GRASP_LIST: Kind = Kind::Custom(10317);
66 67
67pub fn event_is_patch_set_root(event: &Event) -> bool { 68pub fn event_is_patch_set_root(event: &Event) -> bool {
68 event.kind.eq(&Kind::GitPatch) 69 event.kind.eq(&Kind::GitPatch)
@@ -376,11 +377,13 @@ pub fn event_tag_from_nip19_or_hex(
376 } 377 }
377} 378}
378 379
380#[allow(clippy::too_many_arguments)]
379pub fn generate_unsigned_pr_or_update_event( 381pub fn generate_unsigned_pr_or_update_event(
380 git_repo: &Repo, 382 git_repo: &Repo,
381 repo_ref: &RepoRef, 383 repo_ref: &RepoRef,
382 signing_public_key: &PublicKey, 384 signing_public_key: &PublicKey,
383 root_proposal: Option<&Event>, 385 root_proposal: Option<&Event>,
386 title_description_overide: &Option<(String, String)>,
384 commit: &Sha1Hash, 387 commit: &Sha1Hash,
385 clone_url_hint: &[&str], 388 clone_url_hint: &[&str],
386 mentions: &[nostr::Tag], 389 mentions: &[nostr::Tag],
@@ -395,13 +398,17 @@ pub fn generate_unsigned_pr_or_update_event(
395 None 398 None
396 }; 399 };
397 400
398 let title = if let Some(cl) = &root_patch_cover_letter { 401 let title = if let Some((title, _)) = &title_description_overide {
402 title.clone()
403 } else if let Some(cl) = &root_patch_cover_letter {
399 cl.title.clone() 404 cl.title.clone()
400 } else { 405 } else {
401 git_repo.get_commit_message_summary(commit)? 406 git_repo.get_commit_message_summary(commit)?
402 }; 407 };
403 408
404 let description = if let Some(cl) = &root_patch_cover_letter { 409 let description = if let Some((_, description)) = &title_description_overide {
410 description.clone()
411 } else if let Some(cl) = &root_patch_cover_letter {
405 cl.description.clone() 412 cl.description.clone()
406 } else { 413 } else {
407 let mut description = git_repo.get_commit_message(commit)?.trim().to_string(); 414 let mut description = git_repo.get_commit_message(commit)?.trim().to_string();
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::{
9 Repo, RepoActions, 9 Repo, RepoActions,
10 nostr_url::{CloneUrl, NostrUrlDecoded, ServerProtocol}, 10 nostr_url::{CloneUrl, NostrUrlDecoded, ServerProtocol},
11 }, 11 },
12 repo_ref::is_grasp_server, 12 repo_ref::is_grasp_server_in_list,
13 utils::{Direction, get_read_protocols_to_try, join_with_and, set_protocol_preference}, 13 utils::{Direction, get_read_protocols_to_try, join_with_and, set_protocol_preference},
14}; 14};
15 15
@@ -23,7 +23,7 @@ pub fn list_from_remotes(
23 let mut remote_states = HashMap::new(); 23 let mut remote_states = HashMap::new();
24 let mut errors = HashMap::new(); 24 let mut errors = HashMap::new();
25 for url in git_servers { 25 for url in git_servers {
26 let is_grasp_server = is_grasp_server(url, grasp_servers); 26 let is_grasp_server = is_grasp_server_in_list(url, grasp_servers);
27 match list_from_remote(term, git_repo, url, decoded_nostr_url, is_grasp_server) { 27 match list_from_remote(term, git_repo, url, decoded_nostr_url, is_grasp_server) {
28 Err(error) => { 28 Err(error) => {
29 errors.insert(url, error); 29 errors.insert(url, error);
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 @@
1use std::{collections::HashSet, path::Path}; 1use std::{collections::HashSet, path::Path, sync::Arc};
2 2
3use anyhow::{Context, Result, bail}; 3use anyhow::{Context, Result, bail};
4use nostr::PublicKey; 4use nostr::{PublicKey, Url, event::Tag, signer::NostrSigner};
5use nostr_sdk::{Alphabet, JsonUtil, Kind, SingleLetterTag, Timestamp, ToBech32}; 5use nostr_sdk::{Alphabet, JsonUtil, Kind, SingleLetterTag, Timestamp, ToBech32};
6use serde::{self, Deserialize, Serialize}; 6use serde::{self, Deserialize, Serialize};
7 7
@@ -9,13 +9,17 @@ use serde::{self, Deserialize, Serialize};
9use crate::client::Client; 9use crate::client::Client;
10#[cfg(test)] 10#[cfg(test)]
11use crate::client::MockConnect; 11use crate::client::MockConnect;
12use crate::client::{Connect, get_event_from_global_cache}; 12use crate::{
13 client::{Connect, get_event_from_global_cache, sign_event},
14 git_events::KIND_USER_GRASP_LIST,
15};
13 16
14#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] 17#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
15pub struct UserRef { 18pub struct UserRef {
16 pub public_key: PublicKey, 19 pub public_key: PublicKey,
17 pub metadata: UserMetadata, 20 pub metadata: UserMetadata,
18 pub relays: UserRelays, 21 pub relays: UserRelays,
22 pub grasp_list: UserGraspList,
19} 23}
20 24
21#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] 25#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
@@ -49,6 +53,35 @@ impl UserRelays {
49} 53}
50 54
51#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] 55#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
56pub struct UserGraspList {
57 pub urls: Vec<Url>,
58 pub created_at: Timestamp,
59}
60
61impl UserGraspList {
62 pub async fn to_event(&mut self, signer: &Arc<dyn NostrSigner>) -> Result<nostr::Event> {
63 let event = sign_event(
64 nostr_sdk::EventBuilder::new(KIND_USER_GRASP_LIST, "").tags(
65 self.urls
66 .iter()
67 .map(|url| {
68 Tag::custom(
69 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("g")),
70 vec![url.to_string()],
71 )
72 })
73 .collect::<Vec<_>>(),
74 ),
75 signer,
76 "user grasp list".to_string(),
77 )
78 .await?;
79 self.created_at = event.created_at;
80 Ok(event)
81 }
82}
83
84#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
52pub struct UserRelayRef { 85pub struct UserRelayRef {
53 pub url: String, 86 pub url: String,
54 pub read: bool, 87 pub read: bool,
@@ -84,6 +117,7 @@ pub async fn get_user_details(
84 public_key: public_key.to_owned(), 117 public_key: public_key.to_owned(),
85 metadata: extract_user_metadata(public_key, &[])?, 118 metadata: extract_user_metadata(public_key, &[])?,
86 relays: extract_user_relays(public_key, &[]), 119 relays: extract_user_relays(public_key, &[]),
120 grasp_list: extract_user_grasp_list(public_key, &[]),
87 }; 121 };
88 if cache_only { 122 if cache_only {
89 Ok(empty) 123 Ok(empty)
@@ -117,6 +151,9 @@ pub async fn get_user_ref_from_cache(
117 nostr::Filter::default() 151 nostr::Filter::default()
118 .author(*public_key) 152 .author(*public_key)
119 .kind(Kind::RelayList), 153 .kind(Kind::RelayList),
154 nostr::Filter::default()
155 .author(*public_key)
156 .kind(KIND_USER_GRASP_LIST),
120 ]; 157 ];
121 158
122 let events = get_event_from_global_cache(git_repo_path, filters.clone()).await?; 159 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(
128 public_key: public_key.to_owned(), 165 public_key: public_key.to_owned(),
129 metadata: extract_user_metadata(public_key, &events)?, 166 metadata: extract_user_metadata(public_key, &events)?,
130 relays: extract_user_relays(public_key, &events), 167 relays: extract_user_relays(public_key, &events),
168 grasp_list: extract_user_grasp_list(public_key, &events),
131 }) 169 })
132} 170}
133 171
@@ -215,3 +253,36 @@ pub fn extract_user_relays(public_key: &nostr::PublicKey, events: &[nostr::Event
215 }, 253 },
216 } 254 }
217} 255}
256
257pub fn extract_user_grasp_list(
258 public_key: &nostr::PublicKey,
259 events: &[nostr::Event],
260) -> UserGraspList {
261 let event = events
262 .iter()
263 .filter(|e| e.kind.eq(&KIND_USER_GRASP_LIST) && e.pubkey.eq(public_key))
264 .max_by_key(|e| e.created_at);
265
266 UserGraspList {
267 urls: if let Some(event) = event {
268 event
269 .tags
270 .iter()
271 .filter_map(|t| {
272 if t.as_slice().len() > 1 && t.as_slice()[0] == "g" {
273 Url::parse(&t.as_slice()[1]).ok()
274 } else {
275 None
276 }
277 })
278 .collect()
279 } else {
280 vec![]
281 },
282 created_at: if let Some(event) = event {
283 event.created_at
284 } else {
285 Timestamp::from(0)
286 },
287 }
288}
diff --git a/src/lib/push.rs b/src/lib/push.rs
index 0d0ec93..8cb0212 100644
--- a/src/lib/push.rs
+++ b/src/lib/push.rs
@@ -1,25 +1,38 @@
1use std::{ 1use std::{
2 collections::{HashMap, HashSet},
2 sync::{Arc, Mutex}, 3 sync::{Arc, Mutex},
3 time::Instant, 4 time::Instant,
4}; 5};
5 6
6use anyhow::{Result, anyhow}; 7use anyhow::{Context, Result, anyhow};
7use auth_git2::GitAuthenticator; 8use auth_git2::GitAuthenticator;
8use console::Term; 9use console::Term;
10use nostr::{
11 event::{Event, EventBuilder, Kind, Tag, TagStandard, UnsignedEvent},
12 hashes::sha1::Hash as Sha1Hash,
13 key::PublicKey,
14 nips::nip10::Marker,
15 signer::NostrSigner,
16};
9 17
10use crate::{ 18use crate::{
11 cli_interactor::count_lines_per_msg_vec, 19 cli_interactor::count_lines_per_msg_vec,
20 client::{sign_draft_event, sign_event},
12 git::{ 21 git::{
13 Repo, 22 Repo, RepoActions,
14 nostr_url::{CloneUrl, NostrUrlDecoded}, 23 nostr_url::{CloneUrl, NostrUrlDecoded},
15 oid_to_shorthand_string, 24 oid_to_shorthand_string,
16 }, 25 },
26 git_events::generate_unsigned_pr_or_update_event,
27 login::user::UserRef,
28 repo_ref::{RepoRef, is_grasp_server_clone_url, normalize_grasp_server_url},
17 utils::{ 29 utils::{
18 Direction, get_short_git_server_name, get_write_protocols_to_try, join_with_and, 30 Direction, get_short_git_server_name, get_write_protocols_to_try, join_with_and,
19 set_protocol_preference, 31 set_protocol_preference,
20 }, 32 },
21}; 33};
22 34
35// returns a HashMap of refs responded to and any related cancellation reasons
23pub fn push_to_remote( 36pub fn push_to_remote(
24 git_repo: &Repo, 37 git_repo: &Repo,
25 git_server_url: &str, 38 git_server_url: &str,
@@ -27,35 +40,65 @@ pub fn push_to_remote(
27 remote_refspecs: &[String], 40 remote_refspecs: &[String],
28 term: &Term, 41 term: &Term,
29 is_grasp_server: bool, 42 is_grasp_server: bool,
30) -> Result<()> { 43) -> Result<HashMap<String, Option<String>>> {
31 let server_url = git_server_url.parse::<CloneUrl>()?; 44 let server_url = git_server_url.parse::<CloneUrl>()?;
32 let protocols_to_attempt = 45 let protocols_to_attempt =
33 get_write_protocols_to_try(git_repo, &server_url, decoded_nostr_url, is_grasp_server); 46 get_write_protocols_to_try(git_repo, &server_url, decoded_nostr_url, is_grasp_server);
34 47
35 let mut failed_protocols = vec![]; 48 let mut failed_protocols = vec![];
36 let mut success = false; 49 let mut success = false;
50 let mut ref_updates = HashMap::new();
37 51
38 for protocol in &protocols_to_attempt { 52 for protocol in &protocols_to_attempt {
39 term.write_line(format!("push: {} over {protocol}...", server_url.short_name(),).as_str())?; 53 term.write_line(format!("push: {} over {protocol}...", server_url.short_name(),).as_str())?;
40 54
41 let formatted_url = server_url.format_as(protocol, &decoded_nostr_url.user)?; 55 let formatted_url = server_url.format_as(protocol, &decoded_nostr_url.user)?;
42 56
43 if let Err(error) = push_to_remote_url(git_repo, &formatted_url, remote_refspecs, term) { 57 match push_to_remote_url(git_repo, &formatted_url, remote_refspecs, term) {
44 term.write_line( 58 Err(error) => {
45 format!("push: {formatted_url} failed over {protocol}: {error}").as_str(), 59 term.write_line(
46 )?; 60 format!("push: {formatted_url} failed over {protocol}: {error}").as_str(),
47 failed_protocols.push(protocol); 61 )?;
48 } else { 62 failed_protocols.push(protocol);
49 success = true; 63 }
50 if !failed_protocols.is_empty() { 64 Ok(ref_updates_on_protocol) => {
51 term.write_line(format!("push: succeeded over {protocol}").as_str())?; 65 success = true;
52 let _ = set_protocol_preference(git_repo, protocol, &server_url, &Direction::Push); 66 if ref_updates_on_protocol
67 .values()
68 .all(|error| error.is_none())
69 {
70 if !failed_protocols.is_empty() {
71 term.write_line(format!("push: succeeded over {protocol}").as_str())?;
72 let _ = set_protocol_preference(
73 git_repo,
74 protocol,
75 &server_url,
76 &Direction::Push,
77 );
78 }
79 break;
80 } else {
81 term.write_line(
82 format!(
83 "push: {formatted_url} with {protocol} complete but {}ref{} not accepted:",
84 if remote_refspecs.len() != failed_protocols.len() { "some " } else {""},
85 if remote_refspecs.len() == 1 { "s"} else {""},
86 ).as_str(),
87 )?;
88 for (git_ref, error) in &ref_updates_on_protocol {
89 if let Some(error) = error {
90 term.write_line(format!("push: - {git_ref}: {error}").as_str())?;
91 }
92 }
93 // TODO do we want to report on the refs that weren't responded to?
94 ref_updates = ref_updates_on_protocol;
95 }
96 break;
53 } 97 }
54 break;
55 } 98 }
56 } 99 }
57 if success { 100 if success {
58 Ok(()) 101 Ok(ref_updates)
59 } else { 102 } else {
60 let error = anyhow!( 103 let error = anyhow!(
61 "{} failed over {}{}", 104 "{} failed over {}{}",
@@ -72,12 +115,13 @@ pub fn push_to_remote(
72 } 115 }
73} 116}
74 117
118// returns HashMaps of refspecs responded to and any failure message
75pub fn push_to_remote_url( 119pub fn push_to_remote_url(
76 git_repo: &Repo, 120 git_repo: &Repo,
77 git_server_url: &str, 121 git_server_url: &str,
78 remote_refspecs: &[String], 122 remote_refspecs: &[String],
79 term: &Term, 123 term: &Term,
80) -> Result<()> { 124) -> Result<HashMap<String, Option<String>>> {
81 let git_config = git_repo.git_repo.config()?; 125 let git_config = git_repo.git_repo.config()?;
82 let mut git_server_remote = git_repo.git_repo.remote_anonymous(git_server_url)?; 126 let mut git_server_remote = git_repo.git_repo.remote_anonymous(git_server_url)?;
83 let auth = GitAuthenticator::default(); 127 let auth = GitAuthenticator::default();
@@ -91,6 +135,9 @@ pub fn push_to_remote_url(
91 let push_reporter = Arc::clone(&push_reporter); 135 let push_reporter = Arc::clone(&push_reporter);
92 move |name, error| { 136 move |name, error| {
93 let mut reporter = push_reporter.lock().unwrap(); 137 let mut reporter = push_reporter.lock().unwrap();
138 reporter
139 .ref_updates
140 .insert(name.to_string(), error.map(|s| s.to_string()));
94 if let Some(error) = error { 141 if let Some(error) = error {
95 let existing_lines = reporter.count_all_existing_lines(); 142 let existing_lines = reporter.count_all_existing_lines();
96 reporter.update_reference_errors.push(format!( 143 reporter.update_reference_errors.push(format!(
@@ -115,7 +162,11 @@ pub fn push_to_remote_url(
115 .unwrap_or("") 162 .unwrap_or("")
116 .replace("refs/heads/", "") 163 .replace("refs/heads/", "")
117 .replace("refs/tags/", "tags/"); 164 .replace("refs/tags/", "tags/");
118 let msg = if update.dst().is_zero() { 165 let msg = if let Some(Some(_)) =
166 reporter.ref_updates.get(update.dst_refname().unwrap_or(""))
167 {
168 format!("push: - [failed] {dst_refname}")
169 } else if update.dst().is_zero() {
119 format!("push: - [delete] {dst_refname}") 170 format!("push: - [delete] {dst_refname}")
120 } else if update.src().is_zero() { 171 } else if update.src().is_zero() {
121 if update.dst_refname().unwrap_or("").contains("refs/tags") { 172 if update.dst_refname().unwrap_or("").contains("refs/tags") {
@@ -174,7 +225,8 @@ pub fn push_to_remote_url(
174 push_options.remote_callbacks(remote_callbacks); 225 push_options.remote_callbacks(remote_callbacks);
175 git_server_remote.push(remote_refspecs, Some(&mut push_options))?; 226 git_server_remote.push(remote_refspecs, Some(&mut push_options))?;
176 let _ = git_server_remote.disconnect(); 227 let _ = git_server_remote.disconnect();
177 Ok(()) 228 let reporter = push_reporter.lock().unwrap();
229 Ok(reporter.ref_updates.clone())
178} 230}
179 231
180#[allow(clippy::cast_precision_loss)] 232#[allow(clippy::cast_precision_loss)]
@@ -223,6 +275,7 @@ pub struct PushReporter<'a> {
223 negotiation: Vec<String>, 275 negotiation: Vec<String>,
224 transfer_progress_msgs: Vec<String>, 276 transfer_progress_msgs: Vec<String>,
225 update_reference_errors: Vec<String>, 277 update_reference_errors: Vec<String>,
278 ref_updates: HashMap<String, Option<String>>,
226 term: &'a console::Term, 279 term: &'a console::Term,
227 start_time: Option<Instant>, 280 start_time: Option<Instant>,
228 end_time: Option<Instant>, 281 end_time: Option<Instant>,
@@ -234,6 +287,7 @@ impl<'a> PushReporter<'a> {
234 negotiation: vec![], 287 negotiation: vec![],
235 transfer_progress_msgs: vec![], 288 transfer_progress_msgs: vec![],
236 update_reference_errors: vec![], 289 update_reference_errors: vec![],
290 ref_updates: HashMap::new(),
237 term, 291 term,
238 start_time: None, 292 start_time: None,
239 end_time: None, 293 end_time: None,
@@ -308,3 +362,183 @@ impl<'a> PushReporter<'a> {
308 } 362 }
309 } 363 }
310} 364}
365
366#[allow(clippy::too_many_arguments)]
367pub async fn push_refs_and_generate_pr_or_pr_update_event(
368 git_repo: &Repo,
369 repo_ref: &RepoRef,
370 tip: &Sha1Hash,
371 user_ref: &UserRef,
372 root_proposal: Option<&Event>,
373 title_description_overide: &Option<(String, String)>,
374 servers: &[String],
375 git_ref: Option<String>,
376 signer: &Arc<dyn NostrSigner>,
377 term: &Term,
378) -> Result<(Option<Vec<Event>>, Vec<(String, Result<()>)>)> {
379 let mut responses: Vec<(String, Result<()>)> = vec![];
380
381 let mut unsigned_pr_event: Option<UnsignedEvent> = None;
382 for clone_url in servers {
383 let mut draft_pr_event = if let Some(ref unsigned_pr_event) = unsigned_pr_event {
384 unsigned_pr_event.clone()
385 } else {
386 generate_unsigned_pr_or_update_event(
387 git_repo,
388 repo_ref,
389 &user_ref.public_key,
390 root_proposal,
391 title_description_overide,
392 tip,
393 &[clone_url],
394 &[],
395 )?
396 };
397
398 let git_ref_used = git_ref
399 .clone()
400 .unwrap_or("refs/nostr/<event-id>".to_string())
401 .replace("<event-id>", &draft_pr_event.id().to_string());
402
403 let refspec = format!("{tip}:{git_ref_used}");
404
405 let res = if is_grasp_server_clone_url(clone_url) {
406 push_to_remote_url(git_repo, clone_url, &[refspec], term)
407 } else {
408 // anticipated only when pushing to user's own repo or a personal-fork with
409 // non-grasp git servers. this is used to extract prefered protocols / ssh
410 // details from nostr url
411 let decoded_nostr_url = {
412 if let Ok(Some((_, decoded_nostr_url))) = git_repo
413 .get_first_nostr_remote_when_in_ngit_binary()
414 .await.context("failed to list git remotes")
415 .context("no `nostr://` remote detected. `ngit sync` must be run from a repo with a nostr remote") {
416 decoded_nostr_url
417 } else {
418 repo_ref.to_nostr_git_url(&Some(git_repo))
419 }
420 };
421 push_to_remote(
422 git_repo,
423 clone_url,
424 &decoded_nostr_url,
425 &[refspec],
426 term,
427 false,
428 )
429 };
430
431 match res {
432 Err(error) => {
433 let normalized_url = normalize_grasp_server_url(clone_url)?;
434 term.write_line(&format!(
435 "push: error sending commit data to {normalized_url}: {error}"
436 ))?;
437 responses.push((clone_url.clone(), Err(anyhow!(error))));
438 }
439 Ok(ref_updates) => {
440 let normalized_url = normalize_grasp_server_url(clone_url)?;
441 if let Some((_, Some(error))) = ref_updates.iter().next() {
442 term.write_line(&format!(
443 "push: error sending commit data to {normalized_url}: {error}"
444 ))?;
445 responses.push((clone_url.clone(), Err(anyhow!(error.clone()))));
446 } else {
447 responses.push((clone_url.clone(), Ok(())));
448 term.write_line(&format!("push: commit data sent to {normalized_url}"))?;
449 unsigned_pr_event = Some(draft_pr_event);
450 }
451 }
452 }
453 }
454 if let Some(unsigned_pr_event) = unsigned_pr_event {
455 let pr_event = sign_draft_event(
456 unsigned_pr_event,
457 signer,
458 if root_proposal.is_some_and(|proposal| proposal.kind.eq(&Kind::GitPatch)) {
459 "Pull Request Replacing Original Patch"
460 } else if root_proposal.is_some() {
461 "Pull Request Update"
462 } else {
463 "Pull Request"
464 }
465 .to_string(),
466 )
467 .await?;
468 if root_proposal.is_some_and(|proposal| proposal.kind.eq(&Kind::GitPatch)) {
469 Ok((
470 Some(vec![
471 pr_event,
472 create_close_status_for_original_patch(
473 signer,
474 repo_ref,
475 root_proposal.unwrap(),
476 )
477 .await?,
478 ]),
479 responses,
480 ))
481 } else {
482 Ok((Some(vec![pr_event]), responses))
483 }
484 } else {
485 Ok((None, responses))
486 }
487}
488
489async fn create_close_status_for_original_patch(
490 signer: &Arc<dyn NostrSigner>,
491 repo_ref: &RepoRef,
492 proposal: &Event,
493) -> Result<Event> {
494 let mut public_keys = repo_ref
495 .maintainers
496 .iter()
497 .copied()
498 .collect::<HashSet<PublicKey>>();
499 public_keys.insert(proposal.pubkey);
500
501 sign_event(
502 EventBuilder::new(nostr::event::Kind::GitStatusClosed, String::new()).tags(
503 [
504 vec![
505 Tag::custom(
506 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
507 vec![
508 "Git patch closed as forthcoming update is too large. Replacing with Pull Request"
509 .to_string(),
510 ],
511 ),
512 Tag::from_standardized(nostr::TagStandard::Event {
513 event_id: proposal.id,
514 relay_url: repo_ref.relays.first().cloned(),
515 marker: Some(Marker::Root),
516 public_key: None,
517 uppercase: false,
518 }),
519 ],
520 public_keys.iter().map(|pk| Tag::public_key(*pk)).collect(),
521 repo_ref
522 .coordinates()
523 .iter()
524 .map(|c| {
525 Tag::from_standardized(TagStandard::Coordinate {
526 coordinate: c.coordinate.clone(),
527 relay_url: c.relays.first().cloned(),
528 uppercase: false,
529 })
530 })
531 .collect::<Vec<Tag>>(),
532 vec![
533 Tag::from_standardized(nostr::TagStandard::Reference(
534 repo_ref.root_commit.to_string(),
535 )),
536 ],
537 ]
538 .concat(),
539 ),
540 signer,
541 "close status for original patch".to_string(),
542 )
543 .await
544}
diff --git a/src/lib/repo_ref.rs b/src/lib/repo_ref.rs
index bca4a3b..b2bd381 100644
--- a/src/lib/repo_ref.rs
+++ b/src/lib/repo_ref.rs
@@ -40,6 +40,7 @@ pub struct RepoRef {
40 pub web: Vec<String>, 40 pub web: Vec<String>,
41 pub relays: Vec<RelayUrl>, 41 pub relays: Vec<RelayUrl>,
42 pub blossoms: Vec<Url>, 42 pub blossoms: Vec<Url>,
43 pub hashtags: Vec<String>,
43 pub maintainers: Vec<PublicKey>, 44 pub maintainers: Vec<PublicKey>,
44 pub trusted_maintainer: PublicKey, 45 pub trusted_maintainer: PublicKey,
45 // set to None if not known 46 // set to None if not known
@@ -71,6 +72,7 @@ impl TryFrom<(nostr::Event, Option<PublicKey>)> for RepoRef {
71 web: Vec::new(), 72 web: Vec::new(),
72 relays: Vec::new(), 73 relays: Vec::new(),
73 blossoms: Vec::new(), 74 blossoms: Vec::new(),
75 hashtags: Vec::new(),
74 maintainers: Vec::new(), 76 maintainers: Vec::new(),
75 trusted_maintainer: trusted_maintainer.unwrap_or(event.pubkey), 77 trusted_maintainer: trusted_maintainer.unwrap_or(event.pubkey),
76 maintainers_without_annoucnement: None, 78 maintainers_without_annoucnement: None,
@@ -118,6 +120,7 @@ impl TryFrom<(nostr::Event, Option<PublicKey>)> for RepoRef {
118 } 120 }
119 } 121 }
120 } 122 }
123 [t, hashtag, ..] if t == "t" => r.hashtags.push(hashtag.clone()),
121 [t, blossoms @ ..] if t == "blossoms" => { 124 [t, blossoms @ ..] if t == "blossoms" => {
122 for b in blossoms { 125 for b in blossoms {
123 if let Ok(b) = Url::parse(b) { 126 if let Ok(b) = Url::parse(b) {
@@ -217,6 +220,15 @@ impl RepoRef {
217 vec![format!("git repository: {}", self.name.clone())], 220 vec![format!("git repository: {}", self.name.clone())],
218 ), 221 ),
219 ], 222 ],
223 self.hashtags
224 .iter()
225 .map(|h| {
226 Tag::custom(
227 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("t")),
228 vec![h.clone()],
229 )
230 })
231 .collect(),
220 if self.blossoms.is_empty() { 232 if self.blossoms.is_empty() {
221 vec![] 233 vec![]
222 } else { 234 } else {
@@ -311,6 +323,29 @@ impl RepoRef {
311 pub fn grasp_servers(&self) -> Vec<String> { 323 pub fn grasp_servers(&self) -> Vec<String> {
312 detect_existing_grasp_servers(Some(self), &[], &[], &self.identifier) 324 detect_existing_grasp_servers(Some(self), &[], &[], &self.identifier)
313 } 325 }
326
327 // returns false if already present so didn't need adding
328 pub fn add_grasp_server(&mut self, clone_url: &str) -> Result<bool> {
329 if !is_grasp_server_clone_url(clone_url) {
330 bail!("invalid grasp server clone url. does not end with .git");
331 }
332
333 let relay_url = RelayUrl::parse(
334 &format_grasp_server_url_as_relay_url(clone_url)
335 .context("invalid grasp server clone url")?,
336 )
337 .context("invalid grasp server clone url")?;
338
339 if !self.relays.contains(&relay_url) {
340 self.relays.push(relay_url);
341 }
342 if !self.git_server.contains(&clone_url.to_string()) {
343 self.git_server.push(clone_url.to_string());
344 Ok(true)
345 } else {
346 Ok(false)
347 }
348 }
314} 349}
315 350
316pub async fn get_repo_coordinates_when_remote_unknown( 351pub async fn get_repo_coordinates_when_remote_unknown(
@@ -448,7 +483,7 @@ async fn get_repo_coordinate_from_user_prompt(
448 println!( 483 println!(
449 "{}", 484 "{}",
450 dim.apply_to( 485 dim.apply_to(
451 "hint: https://gitworkshop.dev/repos lists repositories and their nostr address" 486 "hint: https://gitworkshop.dev/search lists repositories and their nostr address"
452 ), 487 ),
453 ); 488 );
454 let git_repo_path = git_repo.get_path()?; 489 let git_repo_path = git_repo.get_path()?;
@@ -699,13 +734,54 @@ pub fn extract_npub(s: &str) -> Result<&str> {
699 } 734 }
700} 735}
701 736
702pub fn is_grasp_server(url: &str, grasp_servers: &[String]) -> bool { 737pub fn is_grasp_server_in_list(url: &str, grasp_servers: &[String]) -> bool {
703 if !grasp_servers.is_empty() { 738 if !grasp_servers.is_empty() {
704 if let Ok(n) = normalize_grasp_server_url(url) { 739 if let Ok(url) = normalize_grasp_server_url(url) {
705 return grasp_servers.contains(&n); 740 grasp_servers.iter().any(|s| {
741 if let Ok(s) = normalize_grasp_server_url(s) {
742 s == url
743 } else {
744 false
745 }
746 })
747 } else {
748 false
706 } 749 }
750 } else {
751 false
752 }
753}
754
755pub fn is_grasp_server_clone_url(url: &str) -> bool {
756 extract_npub(url).is_ok()
757 && (url.ends_with(".git") || url.ends_with(".git/"))
758 && url.starts_with("http")
759}
760
761pub fn format_grasp_server_url_as_relay_url(url: &str) -> Result<String> {
762 let grasp_server_url = normalize_grasp_server_url(url)?;
763 if grasp_server_url.contains("http://") {
764 return Ok(grasp_server_url.replace("http://", "ws://"));
707 } 765 }
708 false 766 Ok(format!("wss://{grasp_server_url}"))
767}
768
769pub fn format_grasp_server_url_as_clone_url(
770 grasp_server: &str,
771 public_key: &PublicKey,
772 identifier: &str,
773) -> Result<String> {
774 let grasp_server_url = normalize_grasp_server_url(grasp_server)?;
775
776 let prefix = if grasp_server_url.contains("http://") {
777 ""
778 } else {
779 "https://"
780 };
781 Ok(format!(
782 "{prefix}{grasp_server_url}/{}/{identifier}.git",
783 public_key.to_bech32()?
784 ))
709} 785}
710 786
711#[cfg(test)] 787#[cfg(test)]
@@ -730,6 +806,7 @@ mod tests {
730 RelayUrl::parse("ws://relay2.io").unwrap(), 806 RelayUrl::parse("ws://relay2.io").unwrap(),
731 ], 807 ],
732 blossoms: vec![], 808 blossoms: vec![],
809 hashtags: vec![],
733 trusted_maintainer: TEST_KEY_1_KEYS.public_key(), 810 trusted_maintainer: TEST_KEY_1_KEYS.public_key(),
734 maintainers_without_annoucnement: None, 811 maintainers_without_annoucnement: None,
735 maintainers: vec![TEST_KEY_1_KEYS.public_key(), TEST_KEY_2_KEYS.public_key()], 812 maintainers: vec![TEST_KEY_1_KEYS.public_key(), TEST_KEY_2_KEYS.public_key()],
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::{
3 collections::HashMap, 3 collections::HashMap,
4 fmt, 4 fmt,
5 io::{self, Stdin}, 5 io::{self, Stdin},
6 path::Path,
6 str::FromStr, 7 str::FromStr,
7}; 8};
8 9
9use anyhow::{Context, Result, bail}; 10use anyhow::{Context, Result, bail};
10use git2::Repository; 11use git2::Repository;
12use nostr::nips::nip19::ToBech32;
11use nostr_sdk::{Event, EventId, Kind, PublicKey, Url}; 13use nostr_sdk::{Event, EventId, Kind, PublicKey, Url};
12 14
13use crate::{ 15use crate::{
@@ -20,7 +22,8 @@ use crate::{
20 nostr_url::{CloneUrl, NostrUrlDecoded, ServerProtocol}, 22 nostr_url::{CloneUrl, NostrUrlDecoded, ServerProtocol},
21 }, 23 },
22 git_events::{ 24 git_events::{
23 event_is_revision_root, get_pr_tip_event_or_most_recent_patch_with_ancestors, get_status, 25 KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, event_is_revision_root,
26 get_pr_tip_event_or_most_recent_patch_with_ancestors, get_status,
24 is_event_proposal_root_for_branch, status_kinds, 27 is_event_proposal_root_for_branch, status_kinds,
25 }, 28 },
26 repo_ref::RepoRef, 29 repo_ref::RepoRef,
@@ -187,6 +190,37 @@ pub async fn get_all_proposals(
187 Ok(all_proposals) 190 Ok(all_proposals)
188} 191}
189 192
193pub async fn proposal_tip_is_pr_or_pr_update(
194 git_repo_path: &Path,
195 repo_ref: &RepoRef,
196 proposal_id: &EventId,
197) -> Result<bool> {
198 let commits_events =
199 get_all_proposal_patch_pr_pr_update_events_from_cache(git_repo_path, repo_ref, proposal_id)
200 .await
201 .context(format!(
202 "cannot get existing proposal events for {}",
203 proposal_id.to_bech32()?
204 ))?;
205 let most_recent_proposal_patch_chain = get_pr_tip_event_or_most_recent_patch_with_ancestors(
206 commits_events.clone(),
207 )
208 .context(format!(
209 "cannot find tip from proposal events for {}",
210 proposal_id.to_bech32()?,
211 ))?;
212
213 Ok([KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE].contains(
214 &most_recent_proposal_patch_chain
215 .first()
216 .context(format!(
217 "cannot find any proposal events for {}",
218 proposal_id.to_bech32()?
219 ))?
220 .kind,
221 ))
222}
223
190pub fn find_proposal_and_patches_by_branch_name<'a>( 224pub fn find_proposal_and_patches_by_branch_name<'a>(
191 refstr: &'a str, 225 refstr: &'a str,
192 proposals: &'a HashMap<EventId, (Event, Vec<Event>)>, 226 proposals: &'a HashMap<EventId, (Event, Vec<Event>)>,
diff --git a/test_utils/src/lib.rs b/test_utils/src/lib.rs
index 6d0dd45..3e348a4 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(
210} 210}
211/// enough to fool event_is_patch_set_root 211/// enough to fool event_is_patch_set_root
212pub fn get_pretend_proposal_root_event() -> nostr::Event { 212pub fn get_pretend_proposal_root_event() -> nostr::Event {
213 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() 213 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 <joe.bloggs@pm.me>\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()
214} 214}
215 215
216/// wrapper for a cli testing tool - currently wraps rexpect and dialoguer 216/// wrapper for a cli testing tool - currently wraps rexpect and dialoguer
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 {
79 test_repo.populate()?; 79 test_repo.populate()?;
80 let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); 80 let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]);
81 p.expect( 81 p.expect(
82 "hint: https://gitworkshop.dev/repos lists repositories and their nostr address\r\n", 82 "hint: https://gitworkshop.dev/search lists repositories and their nostr address\r\n",
83 )?; 83 )?;
84 if invalid_input { 84 if invalid_input {
85 let mut input = p.expect_input("nostr repository")?; 85 let mut input = p.expect_input("nostr repository")?;
diff --git a/tests/ngit_send.rs b/tests/ngit_send.rs
index b0c1768..a5bb564 100644
--- a/tests/ngit_send.rs
+++ b/tests/ngit_send.rs
@@ -37,6 +37,25 @@ mod when_commits_behind_ask_to_proceed {
37 Ok(test_repo) 37 Ok(test_repo)
38 } 38 }
39 39
40 fn create_relay_51() -> Result<Relay<'static>> {
41 Ok(Relay::new(
42 8051,
43 None,
44 Some(&|relay, client_id, subscription_id, _| -> Result<()> {
45 relay.respond_events(
46 client_id,
47 &subscription_id,
48 &vec![
49 generate_test_key_1_metadata_event("fred"),
50 generate_test_key_1_relay_list_event(),
51 generate_repo_ref_event(),
52 ],
53 )?;
54 Ok(())
55 }),
56 ))
57 }
58
40 fn expect_confirm_prompt(p: &mut CliTester) -> Result<CliTesterConfirmPrompt> { 59 fn expect_confirm_prompt(p: &mut CliTester) -> Result<CliTesterConfirmPrompt> {
41 p.expect("fetching updates...\r\n")?; 60 p.expect("fetching updates...\r\n")?;
42 p.expect_eventually("\r\n")?; // may be 'no updates' or some updates 61 p.expect_eventually("\r\n")?; // may be 'no updates' or some updates
@@ -49,37 +68,62 @@ mod when_commits_behind_ask_to_proceed {
49 ) 68 )
50 } 69 }
51 70
52 #[test] 71 #[tokio::test]
53 fn asked_with_default_no() -> Result<()> { 72 #[serial]
73 async fn asked_with_default_no() -> Result<()> {
54 let test_repo = prep_test_repo()?; 74 let test_repo = prep_test_repo()?;
75 let mut r51 = create_relay_51()?;
76 // // check relay had the right number of events
77 let cli_tester_handle = std::thread::spawn(move || -> Result<()> {
78 let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "HEAD~2"]);
79 expect_confirm_prompt(&mut p)?;
80 p.exit()?;
81 relay::shutdown_relay(8051)?;
82 Ok(())
83 });
55 84
56 let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "HEAD~2"]); 85 // launch relay
57 expect_confirm_prompt(&mut p)?; 86 r51.listen_until_close().await?;
58 p.exit()?; 87 cli_tester_handle.join().unwrap()?;
59 Ok(()) 88 Ok(())
60 } 89 }
61 90
62 #[test] 91 #[tokio::test]
63 fn when_response_is_false_aborts() -> Result<()> { 92 #[serial]
93 async fn when_response_is_false_aborts() -> Result<()> {
64 let test_repo = prep_test_repo()?; 94 let test_repo = prep_test_repo()?;
95 let mut r51 = create_relay_51()?;
96 let cli_tester_handle = std::thread::spawn(move || -> Result<()> {
97 let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "HEAD~2"]);
98 expect_confirm_prompt(&mut p)?.succeeds_with(Some(false))?;
99 p.expect_end_with("Error: aborting so commits can be rebased\r\n")?;
100 relay::shutdown_relay(8051)?;
101 Ok(())
102 });
65 103
66 let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "HEAD~2"]); 104 // launch relay
67 105 r51.listen_until_close().await?;
68 expect_confirm_prompt(&mut p)?.succeeds_with(Some(false))?; 106 cli_tester_handle.join().unwrap()?;
69
70 p.expect_end_with("Error: aborting so commits can be rebased\r\n")?;
71
72 Ok(()) 107 Ok(())
73 } 108 }
74 #[test] 109
110 #[tokio::test]
75 #[serial] 111 #[serial]
76 fn when_response_is_true_proceeds() -> Result<()> { 112 async fn when_response_is_true_proceeds() -> Result<()> {
77 let test_repo = prep_test_repo()?; 113 let test_repo = prep_test_repo()?;
114 let mut r51 = create_relay_51()?;
115 let cli_tester_handle = std::thread::spawn(move || -> Result<()> {
116 let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "HEAD~2"]);
117 expect_confirm_prompt(&mut p)?.succeeds_with(Some(true))?;
118 p.expect("? include cover letter")?;
119 p.exit()?;
120 relay::shutdown_relay(8051)?;
121 Ok(())
122 });
78 123
79 let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "HEAD~2"]); 124 // launch relay
80 expect_confirm_prompt(&mut p)?.succeeds_with(Some(true))?; 125 r51.listen_until_close().await?;
81 p.expect("? include cover letter")?; 126 cli_tester_handle.join().unwrap()?;
82 p.exit()?;
83 Ok(()) 127 Ok(())
84 } 128 }
85} 129}
@@ -158,7 +202,7 @@ fn expect_msgs_first(p: &mut CliTester, include_cover_letter: bool) -> Result<()
158} 202}
159 203
160fn expect_msgs_after(p: &mut CliTester) -> Result<()> { 204fn expect_msgs_after(p: &mut CliTester) -> Result<()> {
161 p.expect_after_whitespace("view in gitworkshop.dev: https://gitworkshop.dev/repo")?; 205 p.expect_after_whitespace("view in gitworkshop.dev: https://gitworkshop.dev/")?;
162 p.expect_eventually("\r\n")?; 206 p.expect_eventually("\r\n")?;
163 p.expect("view in another client: https://njump.me/")?; 207 p.expect("view in another client: https://njump.me/")?;
164 p.expect_eventually("\r\n")?; 208 p.expect_eventually("\r\n")?;
@@ -1619,7 +1663,7 @@ mod root_proposal_specified_using_in_reply_to_with_range_of_head_2_and_cover_let
1619 .unwrap() 1663 .unwrap()
1620 .as_slice()[1], 1664 .as_slice()[1],
1621 // id of state nevent 1665 // id of state nevent
1622 "431e58eb8e1b4e20292d1d5bbe81d5cfb042e1bc165de32eddfdd52245a4cce4", 1666 "000c104861e34a453481ab23e7de21a6baf475b394479705363b035936732528",
1623 ); 1667 );
1624 } 1668 }
1625 Ok(()) 1669 Ok(())