From a9b2ebf8216be34950e54dd9a446dbdc0c9c744a Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 6 Aug 2025 12:52:59 +0100 Subject: feat(send): PR fallback to user / custom grasp if use is maintainer, push PR to all repo git servers. if user has a fork, push to all git servers it lists, and repo grasp servers. if user hasn't got a fork but has a user grasp list and pushing push to repo grasp servers fails, create a personal-fork automatically at each user grasp server and push there. fallback to prompting user for either grasp servers or git server with write permission. if user provides grasp servers, suggesting adding to user preference list. --- src/bin/ngit/sub_commands/init.rs | 106 ++------------- src/bin/ngit/sub_commands/send.rs | 275 +++++++++++++++++++++++++++++++++----- src/lib/cli_interactor.rs | 87 ++++++++++++ src/lib/client.rs | 54 ++++---- src/lib/git_events.rs | 1 + src/lib/login/user.rs | 77 ++++++++++- src/lib/repo_ref.rs | 83 +++++++++++- 7 files changed, 519 insertions(+), 164 deletions(-) (limited to 'src') diff --git a/src/bin/ngit/sub_commands/init.rs b/src/bin/ngit/sub_commands/init.rs index eaaf83d..01fcaea 100644 --- a/src/bin/ngit/sub_commands/init.rs +++ b/src/bin/ngit/sub_commands/init.rs @@ -12,12 +12,12 @@ use console::{Style, Term}; use dialoguer::theme::{ColorfulTheme, Theme}; use ngit::{ UrlWithoutSlash, - cli_interactor::{PromptChoiceParms, PromptConfirmParms, PromptMultiChoiceParms}, + cli_interactor::{PromptChoiceParms, PromptConfirmParms, multi_select_with_custom_value}, client::{Params, send_events}, git::nostr_url::{CloneUrl, NostrUrlDecoded}, repo_ref::{ - detect_existing_grasp_servers, extract_npub, extract_pks, normalize_grasp_server_url, - save_repo_config_to_yaml, + detect_existing_grasp_servers, extract_npub, extract_pks, + format_grasp_server_url_as_relay_url, normalize_grasp_server_url, save_repo_config_to_yaml, }, }; use nostr::{ @@ -727,6 +727,11 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { web, relays: relays.clone(), blossoms, + hashtags: if let Some(repo_ref) = repo_ref { + repo_ref.hashtags + } else { + vec![] + }, trusted_maintainer: user_ref.public_key, maintainers_without_annoucnement: None, maintainers: maintainers.clone(), @@ -848,93 +853,6 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { Ok(()) } -fn multi_select_with_custom_value( - prompt: &str, - custom_choice_prompt: &str, - mut choices: Vec, - mut defaults: Vec, - validate_choice: F, -) -> Result> -where - F: Fn(&str) -> Result, -{ - let mut selected_choices = vec![]; - - // Loop to allow users to add more choices - loop { - // Add 'add another' option at the end of the choices - let mut current_choices = choices.clone(); - current_choices.push(if current_choices.is_empty() { - "add".to_string() - } else { - "add another".to_string() - }); - - // Create default selections based on the provided defaults - let mut current_defaults = defaults.clone(); - current_defaults.push(current_choices.len() == 1); // 'add another' should not be selected by default - - // Prompt for selections - let selected_indices: Vec = Interactor::default().multi_choice( - PromptMultiChoiceParms::default() - .with_prompt(prompt) - .dont_report() - .with_choices(current_choices.clone()) - .with_defaults(current_defaults), - )?; - - // Collect selected choices - selected_choices.clear(); // Clear previous selections to update - for &index in &selected_indices { - if index < choices.len() { - // Exclude 'add another' option - selected_choices.push(choices[index].clone()); - } - } - - // Check if 'add another' was selected - if selected_indices.contains(&(choices.len())) { - // Last index is 'add another' - let mut new_choice: String; - loop { - new_choice = Interactor::default().input( - PromptInputParms::default() - .with_prompt(custom_choice_prompt) - .dont_report() - .optional(), - )?; - - if new_choice.is_empty() { - break; - } - // Validate the new choice - match validate_choice(&new_choice) { - Ok(valid_choice) => { - new_choice = valid_choice; // Use the fixed version of the input - break; // Valid choice, exit the loop - } - Err(err) => { - // Inform the user about the validation error - println!("Error: {err}"); - } - } - } - - // Add the new choice to the choices vector - if !new_choice.is_empty() { - choices.push(new_choice.clone()); // Add new choice to the end of the list - selected_choices.push(new_choice); // Automatically select the new choice - defaults.push(true); // Set the new choice as selected by default - } - } else { - // Exit the loop if 'add another' was not selected - break; - } - } - - Ok(selected_choices) -} - fn format_grasp_server_url_as_clone_url( url: &str, public_key: &PublicKey, @@ -953,14 +871,6 @@ fn format_grasp_server_url_as_clone_url( )) } -fn format_grasp_server_url_as_relay_url(url: &str) -> Result { - let grasp_server_url = normalize_grasp_server_url(url)?; - if grasp_server_url.contains("http://") { - return Ok(grasp_server_url.replace("http://", "ws://")); - } - Ok(format!("wss://{grasp_server_url}")) -} - fn format_grasp_server_url_as_blossom_url(url: &str) -> Result { let grasp_server_url = normalize_grasp_server_url(url)?; if grasp_server_url.contains("http://") { diff --git a/src/bin/ngit/sub_commands/send.rs b/src/bin/ngit/sub_commands/send.rs index 609812b..835153e 100644 --- a/src/bin/ngit/sub_commands/send.rs +++ b/src/bin/ngit/sub_commands/send.rs @@ -1,16 +1,27 @@ -use std::{path::Path, str::FromStr}; +use std::{path::Path, str::FromStr, thread, time::Duration}; use anyhow::{Context, Result, bail}; use console::Style; use ngit::{ + cli_interactor::{PromptChoiceParms, multi_select_with_custom_value}, client::{Params, send_events}, git::nostr_url::CloneUrl, git_events::{EventRefType, KIND_PULL_REQUEST, generate_cover_letter_and_patch_events}, push::push_refs_and_generate_pr_or_pr_update_event, - repo_ref::is_grasp_server, + repo_ref::{ + format_grasp_server_url_as_clone_url, format_grasp_server_url_as_relay_url, + is_grasp_server, normalize_grasp_server_url, + }, utils::proposal_tip_is_pr_or_pr_update, }; -use nostr::{ToBech32, event::Event, nips::nip19::Nip19Event}; +use nostr::{ + ToBech32, + event::Event, + nips::{ + nip01::Coordinate, + nip19::{Nip19Coordinate, Nip19Event}, + }, +}; use nostr_sdk::hashes::sha1::Hash as Sha1Hash; use crate::{ @@ -179,7 +190,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re None }; - let (signer, user_ref, _) = login::login_or_signup( + let (signer, mut user_ref, _) = login::login_or_signup( &Some(&git_repo), &extract_signer_cli_arguments(cli_args).unwrap_or(None), &cli_args.password, @@ -194,20 +205,55 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re commits.reverse(); let events = if as_pr { + let mut to_try = vec![]; + let mut tried = vec![]; let repo_grasps = repo_ref.grasp_servers(); - let repo_grasp_clone_urls: Vec = repo_ref - .git_server - .iter() - .filter(|s| is_grasp_server(s, &repo_grasps)) - .cloned() - .collect(); - if repo_grasp_clone_urls.is_empty() { + // if the user already has a fork, or is a maintainer, use those git servers + let mut user_repo_ref = get_repo_ref_from_cache( + Some(git_repo_path), + &Nip19Coordinate { + coordinate: Coordinate { + kind: nostr::event::Kind::GitRepoAnnouncement, + public_key: user_ref.public_key, + identifier: repo_ref.identifier.clone(), + }, + relays: vec![], + }, + ) + .await + .ok(); + if let Some(user_repo_ref) = &user_repo_ref { + for url in &user_repo_ref.git_server { + if CloneUrl::from_str(url).is_ok() { + to_try.push(url.clone()); + } + } + } + if !to_try.is_empty() || !repo_grasps.is_empty() { + println!( + "pushing proposal refs to {}", + if repo_ref.maintainers.contains(&user_ref.public_key) { + "repository git servers" + } else if to_try.is_empty() { + "repository grasp servers" + } else if repo_grasps.is_empty() { + "the git servers listed in your fork" + } else { + "the git servers listed in your fork and repository grasp servers" + } + ); + } else { println!( "The repository doesn't list a grasp server which would otherwise be used to submit your proposal as nostr Pull Request." ); } - let mut to_try = repo_grasp_clone_urls.clone(); - let mut tried = vec![]; + // also use repo grasp servers + for url in &repo_ref.git_server { + if is_grasp_server(url, &repo_grasps) && !to_try.contains(url) { + to_try.push(url.clone()); + } + } + let mut git_ref = None; loop { let (events, _server_responses) = push_refs_and_generate_pr_or_pr_update_event( @@ -217,7 +263,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re &user_ref, root_proposal.as_ref(), &cover_letter_title_description, - &repo_grasp_clone_urls, + &to_try, git_ref.clone(), &signer, &console::Term::stdout(), @@ -230,27 +276,194 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re if let Some(events) = events { break events; } - let clone_url = Interactor::default() - .input( - PromptInputParms::default().with_prompt("git repo url with write permission"), - )? - .clone(); - if CloneUrl::from_str(&clone_url).is_ok() { - to_try.push(clone_url); - let mut git_ref_or_branch_name = Interactor::default() - .input( - PromptInputParms::default() - .with_prompt("ref / branch name") - .with_default(git_ref.unwrap_or("refs/nostr/".to_string())), + // fallback to creating user personal-fork on their grasp servers + let untried_user_grasp_servers: Vec = user_ref + .grasp_list + .urls + .iter() + .map(std::string::ToString::to_string) + .filter(|g| { + // is a grasp server not in list of tried + !is_grasp_server(g, &tried) + }) + .collect(); + + if untried_user_grasp_servers.is_empty() + && Interactor::default().choice( + PromptChoiceParms::default() + .with_prompt("choose alternative git server") + .dont_report() + .with_choices(vec![ + "choose grasp server(s)".to_string(), + "enter a git repo url with write permission".to_string(), + ]) + .with_default(0), + )? == 1 + { + loop { + let clone_url = Interactor::default() + .input( + PromptInputParms::default() + .with_prompt("git repo url with write permission"), + )? + .clone(); + if CloneUrl::from_str(&clone_url).is_ok() { + to_try.push(clone_url); + let mut git_ref_or_branch_name = Interactor::default() + .input( + PromptInputParms::default() + .with_prompt("ref / branch name") + .with_default( + git_ref.unwrap_or("refs/nostr/".to_string()), + ), + )? + .clone(); + if !git_ref_or_branch_name.starts_with("refs/") { + git_ref_or_branch_name = format!("refs/heads/{git_ref_or_branch_name}"); + } + git_ref = Some(git_ref_or_branch_name); + break; + } + println!("invalid clone url"); + } + continue; + } + + let mut new_grasp_server_events: Vec = vec![]; + + let grasp_servers = if untried_user_grasp_servers.is_empty() { + let default_choices: Vec = client + .get_grasp_default_set() + .iter() + .filter(|g| !is_grasp_server(g, &tried)) + .cloned() + .collect(); + let selections = vec![true; default_choices.len()]; // all selected by default + let grasp_servers = multi_select_with_custom_value( + "grasp server(s)", + "grasp server", + default_choices, + selections, + normalize_grasp_server_url, + )?; + if grasp_servers.is_empty() { + // ask again + continue; + } + let normalised_grasp_servers: Vec = grasp_servers + .iter() + .filter_map(|g| normalize_grasp_server_url(g).ok()) + .collect(); + // if any grasp servers not listed in user grasp list prompt to update + let grasp_servers_not_in_user_prefs: Vec = normalised_grasp_servers + .iter() + .filter(|g| { + !user_ref.grasp_list.urls.contains( + // unwrap is safe as we constructed g + &nostr::Url::parse(&format_grasp_server_url_as_relay_url(g).unwrap()) + .unwrap(), + ) + }) + .cloned() + .collect(); + if !grasp_servers_not_in_user_prefs.is_empty() + && Interactor::default().confirm( + PromptConfirmParms::default() + .with_prompt( + "add these to your list of prefered grasp servers?".to_string(), + ) + .with_default(true), )? - .clone(); - if !git_ref_or_branch_name.starts_with("refs/") { - git_ref_or_branch_name = format!("refs/heads/{git_ref_or_branch_name}"); + { + for g in &normalised_grasp_servers { + let as_url = nostr::Url::parse(&format_grasp_server_url_as_relay_url(g)?)?; + if !user_ref.grasp_list.urls.contains(&as_url) { + user_ref.grasp_list.urls.push(as_url); + } + } + new_grasp_server_events.push(user_ref.grasp_list.to_event(&signer).await?); } - git_ref = Some(git_ref_or_branch_name); + normalised_grasp_servers } else { - println!("invalid clone url"); + println!( + "{} personal-fork so we can push commits to your prefered grasp servers", + if user_repo_ref.is_some() { + "Updating" + } else { + "Creating a" + }, + ); + untried_user_grasp_servers + }; + + let grasp_servers_as_personal_clone_url: Vec = grasp_servers + .iter() + .filter_map(|g| { + format_grasp_server_url_as_clone_url( + g, + &user_ref.public_key, + &repo_ref.identifier, + ) + .ok() + }) + .collect(); + + // create personal-fork / update existing user repo and add these grasp servers + let updated_user_repo_ref = { + if let Some(mut user_repo_ref) = user_repo_ref { + for g in &grasp_servers_as_personal_clone_url { + let _ = user_repo_ref.add_grasp_server(g); + } + user_repo_ref + } else { + // clone repo_ref and reset as personal-fork + let mut user_repo_ref = repo_ref.clone(); + user_repo_ref.trusted_maintainer = user_ref.public_key; + user_repo_ref.maintainers = vec![user_ref.public_key]; + user_repo_ref.git_server = vec![]; + user_repo_ref.relays = vec![]; + if !user_repo_ref + .hashtags + .contains(&"personal-fork".to_string()) + { + user_repo_ref.hashtags.push("personal-fork".to_string()); + } + user_repo_ref + } + }; + // pubish event to my-relays and my-fork-relays + new_grasp_server_events.push(updated_user_repo_ref.to_event(&signer).await?); + send_events( + &client, + Some(git_repo_path), + new_grasp_server_events, + user_ref.relays.write(), + updated_user_repo_ref.relays.clone(), + !cli_args.disable_cli_spinners, + false, + ) + .await?; + user_repo_ref = Some(updated_user_repo_ref); + // wait a few seconds + let countdown_start = 5; + let term = console::Term::stdout(); + for i in (1..=countdown_start).rev() { + term.write_line( + format!( + "waiting {i}s grasp servers to create your repo before we push your data" + ) + .as_str(), + )?; + thread::sleep(Duration::new(1, 0)); // Sleep for 1 second + term.clear_last_lines(1)?; + } + term.flush().unwrap(); // Ensure the output is flushed to the terminal + + // add grasp servers to to_try + for url in grasp_servers_as_personal_clone_url { + to_try.push(url); } + // the loop with continue with the grasp servers } } else { let events = generate_cover_letter_and_patch_events( diff --git a/src/lib/cli_interactor.rs b/src/lib/cli_interactor.rs index 8fca81d..8bcda19 100644 --- a/src/lib/cli_interactor.rs +++ b/src/lib/cli_interactor.rs @@ -236,6 +236,93 @@ impl PromptMultiChoiceParms { } } +pub fn multi_select_with_custom_value( + prompt: &str, + custom_choice_prompt: &str, + mut choices: Vec, + mut defaults: Vec, + validate_choice: F, +) -> Result> +where + F: Fn(&str) -> Result, +{ + let mut selected_choices = vec![]; + + // Loop to allow users to add more choices + loop { + // Add 'add another' option at the end of the choices + let mut current_choices = choices.clone(); + current_choices.push(if current_choices.is_empty() { + "add".to_string() + } else { + "add another".to_string() + }); + + // Create default selections based on the provided defaults + let mut current_defaults = defaults.clone(); + current_defaults.push(current_choices.len() == 1); // 'add another' should not be selected by default + + // Prompt for selections + let selected_indices: Vec = Interactor::default().multi_choice( + PromptMultiChoiceParms::default() + .with_prompt(prompt) + .dont_report() + .with_choices(current_choices.clone()) + .with_defaults(current_defaults), + )?; + + // Collect selected choices + selected_choices.clear(); // Clear previous selections to update + for &index in &selected_indices { + if index < choices.len() { + // Exclude 'add another' option + selected_choices.push(choices[index].clone()); + } + } + + // Check if 'add another' was selected + if selected_indices.contains(&(choices.len())) { + // Last index is 'add another' + let mut new_choice: String; + loop { + new_choice = Interactor::default().input( + PromptInputParms::default() + .with_prompt(custom_choice_prompt) + .dont_report() + .optional(), + )?; + + if new_choice.is_empty() { + break; + } + // Validate the new choice + match validate_choice(&new_choice) { + Ok(valid_choice) => { + new_choice = valid_choice; // Use the fixed version of the input + break; // Valid choice, exit the loop + } + Err(err) => { + // Inform the user about the validation error + println!("Error: {err}"); + } + } + } + + // Add the new choice to the choices vector + if !new_choice.is_empty() { + choices.push(new_choice.clone()); // Add new choice to the end of the list + selected_choices.push(new_choice); // Automatically select the new choice + defaults.push(true); // Set the new choice as selected by default + } + } else { + // Exit the loop if 'add another' was not selected + break; + } + } + + Ok(selected_choices) +} + #[derive(Debug, Default)] pub struct Printer { printed_lines: Vec, diff --git a/src/lib/client.rs b/src/lib/client.rs index b27f9b1..9ce3e24 100644 --- a/src/lib/client.rs +++ b/src/lib/client.rs @@ -53,7 +53,7 @@ use crate::{ get_dirs, git::{Repo, RepoActions, get_git_config_item}, git_events::{ - KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, event_is_cover_letter, + KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE, KIND_USER_GRASP_LIST, event_is_cover_letter, event_is_patch_set_root, event_is_revision_root, event_is_valid_pr_or_pr_update, status_kinds, }, @@ -233,7 +233,7 @@ impl Connect for Client { if let Some(git_repo_path) = git_repo_path { save_event_in_local_cache(git_repo_path, &event).await?; } - if event.kind.eq(&Kind::GitRepoAnnouncement) { + if [Kind::GitRepoAnnouncement, KIND_USER_GRASP_LIST].contains(&event.kind) { save_event_in_global_cache(git_repo_path, &event).await?; } Ok(event.id) @@ -1310,17 +1310,21 @@ async fn create_relays_request( user_profiles.insert(current_user); } } - let mut map: HashMap = HashMap::new(); + let mut map: HashMap = HashMap::new(); for public_key in &user_profiles { if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await { map.insert( public_key.to_owned(), - (user_ref.metadata.created_at, user_ref.relays.created_at), + ( + user_ref.metadata.created_at, + user_ref.relays.created_at, + user_ref.grasp_list.created_at, + ), ); } else { map.insert( public_key.to_owned(), - (Timestamp::from(0), Timestamp::from(0)), + (Timestamp::from(0), Timestamp::from(0), Timestamp::from(0)), ); } } @@ -1547,16 +1551,22 @@ async fn process_fetched_events( { fresh_profiles.insert(event.pubkey); } - } else if [Kind::RelayList, Kind::Metadata].contains(&event.kind) { + } else if [Kind::RelayList, Kind::Metadata, KIND_USER_GRASP_LIST].contains(&event.kind) + { if request.missing_contributor_profiles.contains(&event.pubkey) { report.contributor_profiles.insert(event.pubkey); - } else if let Some((_, (metadata_timestamp, relay_list_timestamp))) = request + } else if let Some(( + _, + (metadata_timestamp, relay_list_timestamp, grasp_list_timestamp), + )) = request .profiles_to_fetch_from_user_relays .get_key_value(&event.pubkey) { if (Kind::Metadata.eq(&event.kind) && event.created_at.gt(metadata_timestamp)) || (Kind::RelayList.eq(&event.kind) && event.created_at.gt(relay_list_timestamp)) + || (KIND_USER_GRASP_LIST.eq(&event.kind) + && event.created_at.gt(grasp_list_timestamp)) { report.profile_updates.insert(event.pubkey); } @@ -1718,35 +1728,21 @@ pub fn get_filter_repo_events(repo_coordinates: &HashSet) -> no .map(|c| c.identifier.clone()) .collect::>(), ) - .authors( - repo_coordinates - .iter() - .map(|c| c.public_key) - .collect::>(), - ) } pub static STATE_KIND: nostr::Kind = Kind::Custom(30618); pub fn get_filter_state_events(repo_coordinates: &HashSet) -> nostr::Filter { - nostr::Filter::default() - .kind(STATE_KIND) - .identifiers( - repo_coordinates - .iter() - .map(|c| c.identifier.clone()) - .collect::>(), - ) - .authors( - repo_coordinates - .iter() - .map(|c| c.public_key) - .collect::>(), - ) + nostr::Filter::default().kind(STATE_KIND).identifiers( + repo_coordinates + .iter() + .map(|c| c.identifier.clone()) + .collect::>(), + ) } pub fn get_filter_contributor_profiles(contributors: HashSet) -> nostr::Filter { nostr::Filter::default() - .kinds(vec![Kind::Metadata, Kind::RelayList]) + .kinds(vec![Kind::Metadata, Kind::RelayList, KIND_USER_GRASP_LIST]) .authors(contributors) } @@ -1850,7 +1846,7 @@ pub struct FetchRequest { contributors: HashSet, missing_contributor_profiles: HashSet, existing_events: HashSet, - profiles_to_fetch_from_user_relays: HashMap, + profiles_to_fetch_from_user_relays: HashMap, user_relays_for_profiles: HashSet, } diff --git a/src/lib/git_events.rs b/src/lib/git_events.rs index bbfcbea..76c31de 100644 --- a/src/lib/git_events.rs +++ b/src/lib/git_events.rs @@ -63,6 +63,7 @@ pub fn status_kinds() -> Vec { pub const KIND_PULL_REQUEST: Kind = Kind::Custom(1618); pub const KIND_PULL_REQUEST_UPDATE: Kind = Kind::Custom(1619); +pub const KIND_USER_GRASP_LIST: Kind = Kind::Custom(10317); pub fn event_is_patch_set_root(event: &Event) -> bool { event.kind.eq(&Kind::GitPatch) diff --git a/src/lib/login/user.rs b/src/lib/login/user.rs index 071cb25..0b702ef 100644 --- a/src/lib/login/user.rs +++ b/src/lib/login/user.rs @@ -1,7 +1,7 @@ -use std::{collections::HashSet, path::Path}; +use std::{collections::HashSet, path::Path, sync::Arc}; use anyhow::{Context, Result, bail}; -use nostr::PublicKey; +use nostr::{PublicKey, Url, event::Tag, signer::NostrSigner}; use nostr_sdk::{Alphabet, JsonUtil, Kind, SingleLetterTag, Timestamp, ToBech32}; use serde::{self, Deserialize, Serialize}; @@ -9,13 +9,17 @@ use serde::{self, Deserialize, Serialize}; use crate::client::Client; #[cfg(test)] use crate::client::MockConnect; -use crate::client::{Connect, get_event_from_global_cache}; +use crate::{ + client::{Connect, get_event_from_global_cache, sign_event}, + git_events::KIND_USER_GRASP_LIST, +}; #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub struct UserRef { pub public_key: PublicKey, pub metadata: UserMetadata, pub relays: UserRelays, + pub grasp_list: UserGraspList, } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] @@ -48,6 +52,35 @@ impl UserRelays { } } +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct UserGraspList { + pub urls: Vec, + pub created_at: Timestamp, +} + +impl UserGraspList { + pub async fn to_event(&mut self, signer: &Arc) -> Result { + let event = sign_event( + nostr_sdk::EventBuilder::new(KIND_USER_GRASP_LIST, "").tags( + self.urls + .iter() + .map(|url| { + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("g")), + vec![url.to_string()], + ) + }) + .collect::>(), + ), + signer, + "user grasp list".to_string(), + ) + .await?; + self.created_at = event.created_at; + Ok(event) + } +} + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub struct UserRelayRef { pub url: String, @@ -84,6 +117,7 @@ pub async fn get_user_details( public_key: public_key.to_owned(), metadata: extract_user_metadata(public_key, &[])?, relays: extract_user_relays(public_key, &[]), + grasp_list: extract_user_grasp_list(public_key, &[]), }; if cache_only { Ok(empty) @@ -117,6 +151,9 @@ pub async fn get_user_ref_from_cache( nostr::Filter::default() .author(*public_key) .kind(Kind::RelayList), + nostr::Filter::default() + .author(*public_key) + .kind(KIND_USER_GRASP_LIST), ]; let events = get_event_from_global_cache(git_repo_path, filters.clone()).await?; @@ -128,6 +165,7 @@ pub async fn get_user_ref_from_cache( public_key: public_key.to_owned(), metadata: extract_user_metadata(public_key, &events)?, relays: extract_user_relays(public_key, &events), + grasp_list: extract_user_grasp_list(public_key, &events), }) } @@ -215,3 +253,36 @@ pub fn extract_user_relays(public_key: &nostr::PublicKey, events: &[nostr::Event }, } } + +pub fn extract_user_grasp_list( + public_key: &nostr::PublicKey, + events: &[nostr::Event], +) -> UserGraspList { + let event = events + .iter() + .filter(|e| e.kind.eq(&KIND_USER_GRASP_LIST) && e.pubkey.eq(public_key)) + .max_by_key(|e| e.created_at); + + UserGraspList { + urls: if let Some(event) = event { + event + .tags + .iter() + .filter_map(|t| { + if t.as_slice().len() > 1 && t.as_slice()[0] == "g" { + Url::parse(&t.as_slice()[1]).ok() + } else { + None + } + }) + .collect() + } else { + vec![] + }, + created_at: if let Some(event) = event { + event.created_at + } else { + Timestamp::from(0) + }, + } +} diff --git a/src/lib/repo_ref.rs b/src/lib/repo_ref.rs index a3e1317..e3f71a1 100644 --- a/src/lib/repo_ref.rs +++ b/src/lib/repo_ref.rs @@ -40,6 +40,7 @@ pub struct RepoRef { pub web: Vec, pub relays: Vec, pub blossoms: Vec, + pub hashtags: Vec, pub maintainers: Vec, pub trusted_maintainer: PublicKey, // set to None if not known @@ -71,6 +72,7 @@ impl TryFrom<(nostr::Event, Option)> for RepoRef { web: Vec::new(), relays: Vec::new(), blossoms: Vec::new(), + hashtags: Vec::new(), maintainers: Vec::new(), trusted_maintainer: trusted_maintainer.unwrap_or(event.pubkey), maintainers_without_annoucnement: None, @@ -118,6 +120,7 @@ impl TryFrom<(nostr::Event, Option)> for RepoRef { } } } + [t, hashtag, ..] if t == "t" => r.hashtags.push(hashtag.clone()), [t, blossoms @ ..] if t == "blossoms" => { for b in blossoms { if let Ok(b) = Url::parse(b) { @@ -217,6 +220,15 @@ impl RepoRef { vec![format!("git repository: {}", self.name.clone())], ), ], + self.hashtags + .iter() + .map(|h| { + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("t")), + vec![h.clone()], + ) + }) + .collect(), if self.blossoms.is_empty() { vec![] } else { @@ -311,6 +323,34 @@ impl RepoRef { pub fn grasp_servers(&self) -> Vec { detect_existing_grasp_servers(Some(self), &[], &[], &self.identifier) } + + // returns false if already present so didn't need adding + pub fn add_grasp_server(&mut self, clone_url: &str) -> Result { + if !clone_url.starts_with("http") { + bail!("invalid grasp server clone url"); + } + extract_npub(clone_url) + .context("invalid grasp server clone url. does not contain valid npub")?; + if !(clone_url.ends_with(".git") || clone_url.ends_with(".git/")) { + bail!("invalid grasp server clone url. does not end with .git"); + } + + let relay_url = RelayUrl::parse( + &format_grasp_server_url_as_relay_url(clone_url) + .context("invalid grasp server clone url")?, + ) + .context("invalid grasp server clone url")?; + + if !self.relays.contains(&relay_url) { + self.relays.push(relay_url); + } + if !self.git_server.contains(&clone_url.to_string()) { + self.git_server.push(clone_url.to_string()); + Ok(true) + } else { + Ok(false) + } + } } pub async fn get_repo_coordinates_when_remote_unknown( @@ -699,13 +739,49 @@ pub fn extract_npub(s: &str) -> Result<&str> { } } +// this should be called is_grasp_server_in_list pub fn is_grasp_server(url: &str, grasp_servers: &[String]) -> bool { if !grasp_servers.is_empty() { - if let Ok(n) = normalize_grasp_server_url(url) { - return grasp_servers.contains(&n); + if let Ok(url) = normalize_grasp_server_url(url) { + grasp_servers.iter().any(|s| { + if let Ok(s) = normalize_grasp_server_url(s) { + s == url + } else { + false + } + }) + } else { + false } + } else { + false + } +} + +pub fn format_grasp_server_url_as_relay_url(url: &str) -> Result { + let grasp_server_url = normalize_grasp_server_url(url)?; + if grasp_server_url.contains("http://") { + return Ok(grasp_server_url.replace("http://", "ws://")); } - false + Ok(format!("wss://{grasp_server_url}")) +} + +pub fn format_grasp_server_url_as_clone_url( + grasp_server: &str, + public_key: &PublicKey, + identifier: &str, +) -> Result { + let grasp_server_url = normalize_grasp_server_url(grasp_server)?; + + let prefix = if grasp_server_url.contains("http://") { + "" + } else { + "https://" + }; + Ok(format!( + "{prefix}{grasp_server_url}/{}/{identifier}.git", + public_key.to_bech32()? + )) } #[cfg(test)] @@ -730,6 +806,7 @@ mod tests { RelayUrl::parse("ws://relay2.io").unwrap(), ], blossoms: vec![], + hashtags: vec![], trusted_maintainer: TEST_KEY_1_KEYS.public_key(), maintainers_without_annoucnement: None, maintainers: vec![TEST_KEY_1_KEYS.public_key(), TEST_KEY_2_KEYS.public_key()], -- cgit v1.2.3