upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-08-06 12:52:59 +0100
committerDanConwayDev <DanConwayDev@protonmail.com>2025-08-07 17:25:50 +0100
commita9b2ebf8216be34950e54dd9a446dbdc0c9c744a (patch)
tree5a103933852fbcfcd42b13716cb92eeca5325d6d /src
parent29f61ffdf155ea88b8d9aec23d28cf70baba577e (diff)
feat(send): PR fallback to user / custom grasp
if use is maintainer, push PR to all repo git servers. if user has a fork, push to all git servers it lists, and repo grasp servers. if user hasn't got a fork but has a user grasp list and pushing push to repo grasp servers fails, create a personal-fork automatically at each user grasp server and push there. fallback to prompting user for either grasp servers or git server with write permission. if user provides grasp servers, suggesting adding to user preference list.
Diffstat (limited to 'src')
-rw-r--r--src/bin/ngit/sub_commands/init.rs106
-rw-r--r--src/bin/ngit/sub_commands/send.rs275
-rw-r--r--src/lib/cli_interactor.rs87
-rw-r--r--src/lib/client.rs54
-rw-r--r--src/lib/git_events.rs1
-rw-r--r--src/lib/login/user.rs77
-rw-r--r--src/lib/repo_ref.rs83
7 files changed, 519 insertions, 164 deletions
diff --git a/src/bin/ngit/sub_commands/init.rs b/src/bin/ngit/sub_commands/init.rs
index eaaf83d..01fcaea 100644
--- a/src/bin/ngit/sub_commands/init.rs
+++ b/src/bin/ngit/sub_commands/init.rs
@@ -12,12 +12,12 @@ use console::{Style, Term};
12use dialoguer::theme::{ColorfulTheme, Theme}; 12use dialoguer::theme::{ColorfulTheme, Theme};
13use ngit::{ 13use ngit::{
14 UrlWithoutSlash, 14 UrlWithoutSlash,
15 cli_interactor::{PromptChoiceParms, PromptConfirmParms, PromptMultiChoiceParms}, 15 cli_interactor::{PromptChoiceParms, PromptConfirmParms, multi_select_with_custom_value},
16 client::{Params, send_events}, 16 client::{Params, send_events},
17 git::nostr_url::{CloneUrl, NostrUrlDecoded}, 17 git::nostr_url::{CloneUrl, NostrUrlDecoded},
18 repo_ref::{ 18 repo_ref::{
19 detect_existing_grasp_servers, extract_npub, extract_pks, normalize_grasp_server_url, 19 detect_existing_grasp_servers, extract_npub, extract_pks,
20 save_repo_config_to_yaml, 20 format_grasp_server_url_as_relay_url, normalize_grasp_server_url, save_repo_config_to_yaml,
21 }, 21 },
22}; 22};
23use nostr::{ 23use nostr::{
@@ -727,6 +727,11 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
727 web, 727 web,
728 relays: relays.clone(), 728 relays: relays.clone(),
729 blossoms, 729 blossoms,
730 hashtags: if let Some(repo_ref) = repo_ref {
731 repo_ref.hashtags
732 } else {
733 vec![]
734 },
730 trusted_maintainer: user_ref.public_key, 735 trusted_maintainer: user_ref.public_key,
731 maintainers_without_annoucnement: None, 736 maintainers_without_annoucnement: None,
732 maintainers: maintainers.clone(), 737 maintainers: maintainers.clone(),
@@ -848,93 +853,6 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
848 Ok(()) 853 Ok(())
849} 854}
850 855
851fn multi_select_with_custom_value<F>(
852 prompt: &str,
853 custom_choice_prompt: &str,
854 mut choices: Vec<String>,
855 mut defaults: Vec<bool>,
856 validate_choice: F,
857) -> Result<Vec<String>>
858where
859 F: Fn(&str) -> Result<String>,
860{
861 let mut selected_choices = vec![];
862
863 // Loop to allow users to add more choices
864 loop {
865 // Add 'add another' option at the end of the choices
866 let mut current_choices = choices.clone();
867 current_choices.push(if current_choices.is_empty() {
868 "add".to_string()
869 } else {
870 "add another".to_string()
871 });
872
873 // Create default selections based on the provided defaults
874 let mut current_defaults = defaults.clone();
875 current_defaults.push(current_choices.len() == 1); // 'add another' should not be selected by default
876
877 // Prompt for selections
878 let selected_indices: Vec<usize> = Interactor::default().multi_choice(
879 PromptMultiChoiceParms::default()
880 .with_prompt(prompt)
881 .dont_report()
882 .with_choices(current_choices.clone())
883 .with_defaults(current_defaults),
884 )?;
885
886 // Collect selected choices
887 selected_choices.clear(); // Clear previous selections to update
888 for &index in &selected_indices {
889 if index < choices.len() {
890 // Exclude 'add another' option
891 selected_choices.push(choices[index].clone());
892 }
893 }
894
895 // Check if 'add another' was selected
896 if selected_indices.contains(&(choices.len())) {
897 // Last index is 'add another'
898 let mut new_choice: String;
899 loop {
900 new_choice = Interactor::default().input(
901 PromptInputParms::default()
902 .with_prompt(custom_choice_prompt)
903 .dont_report()
904 .optional(),
905 )?;
906
907 if new_choice.is_empty() {
908 break;
909 }
910 // Validate the new choice
911 match validate_choice(&new_choice) {
912 Ok(valid_choice) => {
913 new_choice = valid_choice; // Use the fixed version of the input
914 break; // Valid choice, exit the loop
915 }
916 Err(err) => {
917 // Inform the user about the validation error
918 println!("Error: {err}");
919 }
920 }
921 }
922
923 // Add the new choice to the choices vector
924 if !new_choice.is_empty() {
925 choices.push(new_choice.clone()); // Add new choice to the end of the list
926 selected_choices.push(new_choice); // Automatically select the new choice
927 defaults.push(true); // Set the new choice as selected by default
928 }
929 } else {
930 // Exit the loop if 'add another' was not selected
931 break;
932 }
933 }
934
935 Ok(selected_choices)
936}
937
938fn format_grasp_server_url_as_clone_url( 856fn format_grasp_server_url_as_clone_url(
939 url: &str, 857 url: &str,
940 public_key: &PublicKey, 858 public_key: &PublicKey,
@@ -953,14 +871,6 @@ fn format_grasp_server_url_as_clone_url(
953 )) 871 ))
954} 872}
955 873
956fn format_grasp_server_url_as_relay_url(url: &str) -> Result<String> {
957 let grasp_server_url = normalize_grasp_server_url(url)?;
958 if grasp_server_url.contains("http://") {
959 return Ok(grasp_server_url.replace("http://", "ws://"));
960 }
961 Ok(format!("wss://{grasp_server_url}"))
962}
963
964fn format_grasp_server_url_as_blossom_url(url: &str) -> Result<String> { 874fn format_grasp_server_url_as_blossom_url(url: &str) -> Result<String> {
965 let grasp_server_url = normalize_grasp_server_url(url)?; 875 let grasp_server_url = normalize_grasp_server_url(url)?;
966 if grasp_server_url.contains("http://") { 876 if grasp_server_url.contains("http://") {
diff --git a/src/bin/ngit/sub_commands/send.rs b/src/bin/ngit/sub_commands/send.rs
index 609812b..835153e 100644
--- a/src/bin/ngit/sub_commands/send.rs
+++ b/src/bin/ngit/sub_commands/send.rs
@@ -1,16 +1,27 @@
1use std::{path::Path, str::FromStr}; 1use std::{path::Path, str::FromStr, thread, time::Duration};
2 2
3use anyhow::{Context, Result, bail}; 3use anyhow::{Context, Result, bail};
4use console::Style; 4use console::Style;
5use ngit::{ 5use ngit::{
6 cli_interactor::{PromptChoiceParms, multi_select_with_custom_value},
6 client::{Params, send_events}, 7 client::{Params, send_events},
7 git::nostr_url::CloneUrl, 8 git::nostr_url::CloneUrl,
8 git_events::{EventRefType, KIND_PULL_REQUEST, generate_cover_letter_and_patch_events}, 9 git_events::{EventRefType, KIND_PULL_REQUEST, generate_cover_letter_and_patch_events},
9 push::push_refs_and_generate_pr_or_pr_update_event, 10 push::push_refs_and_generate_pr_or_pr_update_event,
10 repo_ref::is_grasp_server, 11 repo_ref::{
12 format_grasp_server_url_as_clone_url, format_grasp_server_url_as_relay_url,
13 is_grasp_server, normalize_grasp_server_url,
14 },
11 utils::proposal_tip_is_pr_or_pr_update, 15 utils::proposal_tip_is_pr_or_pr_update,
12}; 16};
13use nostr::{ToBech32, event::Event, nips::nip19::Nip19Event}; 17use nostr::{
18 ToBech32,
19 event::Event,
20 nips::{
21 nip01::Coordinate,
22 nip19::{Nip19Coordinate, Nip19Event},
23 },
24};
14use nostr_sdk::hashes::sha1::Hash as Sha1Hash; 25use nostr_sdk::hashes::sha1::Hash as Sha1Hash;
15 26
16use crate::{ 27use crate::{
@@ -179,7 +190,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re
179 None 190 None
180 }; 191 };
181 192
182 let (signer, user_ref, _) = login::login_or_signup( 193 let (signer, mut user_ref, _) = login::login_or_signup(
183 &Some(&git_repo), 194 &Some(&git_repo),
184 &extract_signer_cli_arguments(cli_args).unwrap_or(None), 195 &extract_signer_cli_arguments(cli_args).unwrap_or(None),
185 &cli_args.password, 196 &cli_args.password,
@@ -194,20 +205,55 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re
194 commits.reverse(); 205 commits.reverse();
195 206
196 let events = if as_pr { 207 let events = if as_pr {
208 let mut to_try = vec![];
209 let mut tried = vec![];
197 let repo_grasps = repo_ref.grasp_servers(); 210 let repo_grasps = repo_ref.grasp_servers();
198 let repo_grasp_clone_urls: Vec<String> = repo_ref 211 // if the user already has a fork, or is a maintainer, use those git servers
199 .git_server 212 let mut user_repo_ref = get_repo_ref_from_cache(
200 .iter() 213 Some(git_repo_path),
201 .filter(|s| is_grasp_server(s, &repo_grasps)) 214 &Nip19Coordinate {
202 .cloned() 215 coordinate: Coordinate {
203 .collect(); 216 kind: nostr::event::Kind::GitRepoAnnouncement,
204 if repo_grasp_clone_urls.is_empty() { 217 public_key: user_ref.public_key,
218 identifier: repo_ref.identifier.clone(),
219 },
220 relays: vec![],
221 },
222 )
223 .await
224 .ok();
225 if let Some(user_repo_ref) = &user_repo_ref {
226 for url in &user_repo_ref.git_server {
227 if CloneUrl::from_str(url).is_ok() {
228 to_try.push(url.clone());
229 }
230 }
231 }
232 if !to_try.is_empty() || !repo_grasps.is_empty() {
233 println!(
234 "pushing proposal refs to {}",
235 if repo_ref.maintainers.contains(&user_ref.public_key) {
236 "repository git servers"
237 } else if to_try.is_empty() {
238 "repository grasp servers"
239 } else if repo_grasps.is_empty() {
240 "the git servers listed in your fork"
241 } else {
242 "the git servers listed in your fork and repository grasp servers"
243 }
244 );
245 } else {
205 println!( 246 println!(
206 "The repository doesn't list a grasp server which would otherwise be used to submit your proposal as nostr Pull Request." 247 "The repository doesn't list a grasp server which would otherwise be used to submit your proposal as nostr Pull Request."
207 ); 248 );
208 } 249 }
209 let mut to_try = repo_grasp_clone_urls.clone(); 250 // also use repo grasp servers
210 let mut tried = vec![]; 251 for url in &repo_ref.git_server {
252 if is_grasp_server(url, &repo_grasps) && !to_try.contains(url) {
253 to_try.push(url.clone());
254 }
255 }
256
211 let mut git_ref = None; 257 let mut git_ref = None;
212 loop { 258 loop {
213 let (events, _server_responses) = push_refs_and_generate_pr_or_pr_update_event( 259 let (events, _server_responses) = push_refs_and_generate_pr_or_pr_update_event(
@@ -217,7 +263,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re
217 &user_ref, 263 &user_ref,
218 root_proposal.as_ref(), 264 root_proposal.as_ref(),
219 &cover_letter_title_description, 265 &cover_letter_title_description,
220 &repo_grasp_clone_urls, 266 &to_try,
221 git_ref.clone(), 267 git_ref.clone(),
222 &signer, 268 &signer,
223 &console::Term::stdout(), 269 &console::Term::stdout(),
@@ -230,27 +276,194 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re
230 if let Some(events) = events { 276 if let Some(events) = events {
231 break events; 277 break events;
232 } 278 }
233 let clone_url = Interactor::default() 279 // fallback to creating user personal-fork on their grasp servers
234 .input( 280 let untried_user_grasp_servers: Vec<String> = user_ref
235 PromptInputParms::default().with_prompt("git repo url with write permission"), 281 .grasp_list
236 )? 282 .urls
237 .clone(); 283 .iter()
238 if CloneUrl::from_str(&clone_url).is_ok() { 284 .map(std::string::ToString::to_string)
239 to_try.push(clone_url); 285 .filter(|g| {
240 let mut git_ref_or_branch_name = Interactor::default() 286 // is a grasp server not in list of tried
241 .input( 287 !is_grasp_server(g, &tried)
242 PromptInputParms::default() 288 })
243 .with_prompt("ref / branch name") 289 .collect();
244 .with_default(git_ref.unwrap_or("refs/nostr/<event-id>".to_string())), 290
291 if untried_user_grasp_servers.is_empty()
292 && Interactor::default().choice(
293 PromptChoiceParms::default()
294 .with_prompt("choose alternative git server")
295 .dont_report()
296 .with_choices(vec![
297 "choose grasp server(s)".to_string(),
298 "enter a git repo url with write permission".to_string(),
299 ])
300 .with_default(0),
301 )? == 1
302 {
303 loop {
304 let clone_url = Interactor::default()
305 .input(
306 PromptInputParms::default()
307 .with_prompt("git repo url with write permission"),
308 )?
309 .clone();
310 if CloneUrl::from_str(&clone_url).is_ok() {
311 to_try.push(clone_url);
312 let mut git_ref_or_branch_name = Interactor::default()
313 .input(
314 PromptInputParms::default()
315 .with_prompt("ref / branch name")
316 .with_default(
317 git_ref.unwrap_or("refs/nostr/<event-id>".to_string()),
318 ),
319 )?
320 .clone();
321 if !git_ref_or_branch_name.starts_with("refs/") {
322 git_ref_or_branch_name = format!("refs/heads/{git_ref_or_branch_name}");
323 }
324 git_ref = Some(git_ref_or_branch_name);
325 break;
326 }
327 println!("invalid clone url");
328 }
329 continue;
330 }
331
332 let mut new_grasp_server_events: Vec<Event> = vec![];
333
334 let grasp_servers = if untried_user_grasp_servers.is_empty() {
335 let default_choices: Vec<String> = client
336 .get_grasp_default_set()
337 .iter()
338 .filter(|g| !is_grasp_server(g, &tried))
339 .cloned()
340 .collect();
341 let selections = vec![true; default_choices.len()]; // all selected by default
342 let grasp_servers = multi_select_with_custom_value(
343 "grasp server(s)",
344 "grasp server",
345 default_choices,
346 selections,
347 normalize_grasp_server_url,
348 )?;
349 if grasp_servers.is_empty() {
350 // ask again
351 continue;
352 }
353 let normalised_grasp_servers: Vec<String> = grasp_servers
354 .iter()
355 .filter_map(|g| normalize_grasp_server_url(g).ok())
356 .collect();
357 // if any grasp servers not listed in user grasp list prompt to update
358 let grasp_servers_not_in_user_prefs: Vec<String> = normalised_grasp_servers
359 .iter()
360 .filter(|g| {
361 !user_ref.grasp_list.urls.contains(
362 // unwrap is safe as we constructed g
363 &nostr::Url::parse(&format_grasp_server_url_as_relay_url(g).unwrap())
364 .unwrap(),
365 )
366 })
367 .cloned()
368 .collect();
369 if !grasp_servers_not_in_user_prefs.is_empty()
370 && Interactor::default().confirm(
371 PromptConfirmParms::default()
372 .with_prompt(
373 "add these to your list of prefered grasp servers?".to_string(),
374 )
375 .with_default(true),
245 )? 376 )?
246 .clone(); 377 {
247 if !git_ref_or_branch_name.starts_with("refs/") { 378 for g in &normalised_grasp_servers {
248 git_ref_or_branch_name = format!("refs/heads/{git_ref_or_branch_name}"); 379 let as_url = nostr::Url::parse(&format_grasp_server_url_as_relay_url(g)?)?;
380 if !user_ref.grasp_list.urls.contains(&as_url) {
381 user_ref.grasp_list.urls.push(as_url);
382 }
383 }
384 new_grasp_server_events.push(user_ref.grasp_list.to_event(&signer).await?);
249 } 385 }
250 git_ref = Some(git_ref_or_branch_name); 386 normalised_grasp_servers
251 } else { 387 } else {
252 println!("invalid clone url"); 388 println!(
389 "{} personal-fork so we can push commits to your prefered grasp servers",
390 if user_repo_ref.is_some() {
391 "Updating"
392 } else {
393 "Creating a"
394 },
395 );
396 untried_user_grasp_servers
397 };
398
399 let grasp_servers_as_personal_clone_url: Vec<String> = grasp_servers
400 .iter()
401 .filter_map(|g| {
402 format_grasp_server_url_as_clone_url(
403 g,
404 &user_ref.public_key,
405 &repo_ref.identifier,
406 )
407 .ok()
408 })
409 .collect();
410
411 // create personal-fork / update existing user repo and add these grasp servers
412 let updated_user_repo_ref = {
413 if let Some(mut user_repo_ref) = user_repo_ref {
414 for g in &grasp_servers_as_personal_clone_url {
415 let _ = user_repo_ref.add_grasp_server(g);
416 }
417 user_repo_ref
418 } else {
419 // clone repo_ref and reset as personal-fork
420 let mut user_repo_ref = repo_ref.clone();
421 user_repo_ref.trusted_maintainer = user_ref.public_key;
422 user_repo_ref.maintainers = vec![user_ref.public_key];
423 user_repo_ref.git_server = vec![];
424 user_repo_ref.relays = vec![];
425 if !user_repo_ref
426 .hashtags
427 .contains(&"personal-fork".to_string())
428 {
429 user_repo_ref.hashtags.push("personal-fork".to_string());
430 }
431 user_repo_ref
432 }
433 };
434 // pubish event to my-relays and my-fork-relays
435 new_grasp_server_events.push(updated_user_repo_ref.to_event(&signer).await?);
436 send_events(
437 &client,
438 Some(git_repo_path),
439 new_grasp_server_events,
440 user_ref.relays.write(),
441 updated_user_repo_ref.relays.clone(),
442 !cli_args.disable_cli_spinners,
443 false,
444 )
445 .await?;
446 user_repo_ref = Some(updated_user_repo_ref);
447 // wait a few seconds
448 let countdown_start = 5;
449 let term = console::Term::stdout();
450 for i in (1..=countdown_start).rev() {
451 term.write_line(
452 format!(
453 "waiting {i}s grasp servers to create your repo before we push your data"
454 )
455 .as_str(),
456 )?;
457 thread::sleep(Duration::new(1, 0)); // Sleep for 1 second
458 term.clear_last_lines(1)?;
459 }
460 term.flush().unwrap(); // Ensure the output is flushed to the terminal
461
462 // add grasp servers to to_try
463 for url in grasp_servers_as_personal_clone_url {
464 to_try.push(url);
253 } 465 }
466 // the loop with continue with the grasp servers
254 } 467 }
255 } else { 468 } else {
256 let events = generate_cover_letter_and_patch_events( 469 let events = generate_cover_letter_and_patch_events(
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 {
236 } 236 }
237} 237}
238 238
239pub fn multi_select_with_custom_value<F>(
240 prompt: &str,
241 custom_choice_prompt: &str,
242 mut choices: Vec<String>,
243 mut defaults: Vec<bool>,
244 validate_choice: F,
245) -> Result<Vec<String>>
246where
247 F: Fn(&str) -> Result<String>,
248{
249 let mut selected_choices = vec![];
250
251 // Loop to allow users to add more choices
252 loop {
253 // Add 'add another' option at the end of the choices
254 let mut current_choices = choices.clone();
255 current_choices.push(if current_choices.is_empty() {
256 "add".to_string()
257 } else {
258 "add another".to_string()
259 });
260
261 // Create default selections based on the provided defaults
262 let mut current_defaults = defaults.clone();
263 current_defaults.push(current_choices.len() == 1); // 'add another' should not be selected by default
264
265 // Prompt for selections
266 let selected_indices: Vec<usize> = Interactor::default().multi_choice(
267 PromptMultiChoiceParms::default()
268 .with_prompt(prompt)
269 .dont_report()
270 .with_choices(current_choices.clone())
271 .with_defaults(current_defaults),
272 )?;
273
274 // Collect selected choices
275 selected_choices.clear(); // Clear previous selections to update
276 for &index in &selected_indices {
277 if index < choices.len() {
278 // Exclude 'add another' option
279 selected_choices.push(choices[index].clone());
280 }
281 }
282
283 // Check if 'add another' was selected
284 if selected_indices.contains(&(choices.len())) {
285 // Last index is 'add another'
286 let mut new_choice: String;
287 loop {
288 new_choice = Interactor::default().input(
289 PromptInputParms::default()
290 .with_prompt(custom_choice_prompt)
291 .dont_report()
292 .optional(),
293 )?;
294
295 if new_choice.is_empty() {
296 break;
297 }
298 // Validate the new choice
299 match validate_choice(&new_choice) {
300 Ok(valid_choice) => {
301 new_choice = valid_choice; // Use the fixed version of the input
302 break; // Valid choice, exit the loop
303 }
304 Err(err) => {
305 // Inform the user about the validation error
306 println!("Error: {err}");
307 }
308 }
309 }
310
311 // Add the new choice to the choices vector
312 if !new_choice.is_empty() {
313 choices.push(new_choice.clone()); // Add new choice to the end of the list
314 selected_choices.push(new_choice); // Automatically select the new choice
315 defaults.push(true); // Set the new choice as selected by default
316 }
317 } else {
318 // Exit the loop if 'add another' was not selected
319 break;
320 }
321 }
322
323 Ok(selected_choices)
324}
325
239#[derive(Debug, Default)] 326#[derive(Debug, Default)]
240pub struct Printer { 327pub struct Printer {
241 printed_lines: Vec<String>, 328 printed_lines: Vec<String>,
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::{
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 },
@@ -233,7 +233,7 @@ impl Connect for Client {
233 if let Some(git_repo_path) = git_repo_path { 233 if let Some(git_repo_path) = git_repo_path {
234 save_event_in_local_cache(git_repo_path, &event).await?; 234 save_event_in_local_cache(git_repo_path, &event).await?;
235 } 235 }
236 if event.kind.eq(&Kind::GitRepoAnnouncement) { 236 if [Kind::GitRepoAnnouncement, KIND_USER_GRASP_LIST].contains(&event.kind) {
237 save_event_in_global_cache(git_repo_path, &event).await?; 237 save_event_in_global_cache(git_repo_path, &event).await?;
238 } 238 }
239 Ok(event.id) 239 Ok(event.id)
@@ -1310,17 +1310,21 @@ async fn create_relays_request(
1310 user_profiles.insert(current_user); 1310 user_profiles.insert(current_user);
1311 } 1311 }
1312 } 1312 }
1313 let mut map: HashMap<PublicKey, (Timestamp, Timestamp)> = HashMap::new(); 1313 let mut map: HashMap<PublicKey, (Timestamp, Timestamp, Timestamp)> = HashMap::new();
1314 for public_key in &user_profiles { 1314 for public_key in &user_profiles {
1315 if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await { 1315 if let Ok(user_ref) = get_user_ref_from_cache(git_repo_path, public_key).await {
1316 map.insert( 1316 map.insert(
1317 public_key.to_owned(), 1317 public_key.to_owned(),
1318 (user_ref.metadata.created_at, user_ref.relays.created_at), 1318 (
1319 user_ref.metadata.created_at,
1320 user_ref.relays.created_at,
1321 user_ref.grasp_list.created_at,
1322 ),
1319 ); 1323 );
1320 } else { 1324 } else {
1321 map.insert( 1325 map.insert(
1322 public_key.to_owned(), 1326 public_key.to_owned(),
1323 (Timestamp::from(0), Timestamp::from(0)), 1327 (Timestamp::from(0), Timestamp::from(0), Timestamp::from(0)),
1324 ); 1328 );
1325 } 1329 }
1326 } 1330 }
@@ -1547,16 +1551,22 @@ async fn process_fetched_events(
1547 { 1551 {
1548 fresh_profiles.insert(event.pubkey); 1552 fresh_profiles.insert(event.pubkey);
1549 } 1553 }
1550 } else if [Kind::RelayList, Kind::Metadata].contains(&event.kind) { 1554 } else if [Kind::RelayList, Kind::Metadata, KIND_USER_GRASP_LIST].contains(&event.kind)
1555 {
1551 if request.missing_contributor_profiles.contains(&event.pubkey) { 1556 if request.missing_contributor_profiles.contains(&event.pubkey) {
1552 report.contributor_profiles.insert(event.pubkey); 1557 report.contributor_profiles.insert(event.pubkey);
1553 } else if let Some((_, (metadata_timestamp, relay_list_timestamp))) = request 1558 } else if let Some((
1559 _,
1560 (metadata_timestamp, relay_list_timestamp, grasp_list_timestamp),
1561 )) = request
1554 .profiles_to_fetch_from_user_relays 1562 .profiles_to_fetch_from_user_relays
1555 .get_key_value(&event.pubkey) 1563 .get_key_value(&event.pubkey)
1556 { 1564 {
1557 if (Kind::Metadata.eq(&event.kind) && event.created_at.gt(metadata_timestamp)) 1565 if (Kind::Metadata.eq(&event.kind) && event.created_at.gt(metadata_timestamp))
1558 || (Kind::RelayList.eq(&event.kind) 1566 || (Kind::RelayList.eq(&event.kind)
1559 && event.created_at.gt(relay_list_timestamp)) 1567 && event.created_at.gt(relay_list_timestamp))
1568 || (KIND_USER_GRASP_LIST.eq(&event.kind)
1569 && event.created_at.gt(grasp_list_timestamp))
1560 { 1570 {
1561 report.profile_updates.insert(event.pubkey); 1571 report.profile_updates.insert(event.pubkey);
1562 } 1572 }
@@ -1718,35 +1728,21 @@ pub fn get_filter_repo_events(repo_coordinates: &HashSet<Nip19Coordinate>) -> no
1718 .map(|c| c.identifier.clone()) 1728 .map(|c| c.identifier.clone())
1719 .collect::<Vec<String>>(), 1729 .collect::<Vec<String>>(),
1720 ) 1730 )
1721 .authors(
1722 repo_coordinates
1723 .iter()
1724 .map(|c| c.public_key)
1725 .collect::<Vec<PublicKey>>(),
1726 )
1727} 1731}
1728 1732
1729pub static STATE_KIND: nostr::Kind = Kind::Custom(30618); 1733pub static STATE_KIND: nostr::Kind = Kind::Custom(30618);
1730pub fn get_filter_state_events(repo_coordinates: &HashSet<Nip19Coordinate>) -> nostr::Filter { 1734pub fn get_filter_state_events(repo_coordinates: &HashSet<Nip19Coordinate>) -> nostr::Filter {
1731 nostr::Filter::default() 1735 nostr::Filter::default().kind(STATE_KIND).identifiers(
1732 .kind(STATE_KIND) 1736 repo_coordinates
1733 .identifiers( 1737 .iter()
1734 repo_coordinates 1738 .map(|c| c.identifier.clone())
1735 .iter() 1739 .collect::<Vec<String>>(),
1736 .map(|c| c.identifier.clone()) 1740 )
1737 .collect::<Vec<String>>(),
1738 )
1739 .authors(
1740 repo_coordinates
1741 .iter()
1742 .map(|c| c.public_key)
1743 .collect::<Vec<PublicKey>>(),
1744 )
1745} 1741}
1746 1742
1747pub fn get_filter_contributor_profiles(contributors: HashSet<PublicKey>) -> nostr::Filter { 1743pub fn get_filter_contributor_profiles(contributors: HashSet<PublicKey>) -> nostr::Filter {
1748 nostr::Filter::default() 1744 nostr::Filter::default()
1749 .kinds(vec![Kind::Metadata, Kind::RelayList]) 1745 .kinds(vec![Kind::Metadata, Kind::RelayList, KIND_USER_GRASP_LIST])
1750 .authors(contributors) 1746 .authors(contributors)
1751} 1747}
1752 1748
@@ -1850,7 +1846,7 @@ pub struct FetchRequest {
1850 contributors: HashSet<PublicKey>, 1846 contributors: HashSet<PublicKey>,
1851 missing_contributor_profiles: HashSet<PublicKey>, 1847 missing_contributor_profiles: HashSet<PublicKey>,
1852 existing_events: HashSet<EventId>, 1848 existing_events: HashSet<EventId>,
1853 profiles_to_fetch_from_user_relays: HashMap<PublicKey, (Timestamp, Timestamp)>, 1849 profiles_to_fetch_from_user_relays: HashMap<PublicKey, (Timestamp, Timestamp, Timestamp)>,
1854 user_relays_for_profiles: HashSet<RelayUrl>, 1850 user_relays_for_profiles: HashSet<RelayUrl>,
1855} 1851}
1856 1852
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<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)
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/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 {
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,34 @@ 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 !clone_url.starts_with("http") {
330 bail!("invalid grasp server clone url");
331 }
332 extract_npub(clone_url)
333 .context("invalid grasp server clone url. does not contain valid npub")?;
334 if !(clone_url.ends_with(".git") || clone_url.ends_with(".git/")) {
335 bail!("invalid grasp server clone url. does not end with .git");
336 }
337
338 let relay_url = RelayUrl::parse(
339 &format_grasp_server_url_as_relay_url(clone_url)
340 .context("invalid grasp server clone url")?,
341 )
342 .context("invalid grasp server clone url")?;
343
344 if !self.relays.contains(&relay_url) {
345 self.relays.push(relay_url);
346 }
347 if !self.git_server.contains(&clone_url.to_string()) {
348 self.git_server.push(clone_url.to_string());
349 Ok(true)
350 } else {
351 Ok(false)
352 }
353 }
314} 354}
315 355
316pub async fn get_repo_coordinates_when_remote_unknown( 356pub async fn get_repo_coordinates_when_remote_unknown(
@@ -699,13 +739,49 @@ pub fn extract_npub(s: &str) -> Result<&str> {
699 } 739 }
700} 740}
701 741
742// this should be called is_grasp_server_in_list
702pub fn is_grasp_server(url: &str, grasp_servers: &[String]) -> bool { 743pub fn is_grasp_server(url: &str, grasp_servers: &[String]) -> bool {
703 if !grasp_servers.is_empty() { 744 if !grasp_servers.is_empty() {
704 if let Ok(n) = normalize_grasp_server_url(url) { 745 if let Ok(url) = normalize_grasp_server_url(url) {
705 return grasp_servers.contains(&n); 746 grasp_servers.iter().any(|s| {
747 if let Ok(s) = normalize_grasp_server_url(s) {
748 s == url
749 } else {
750 false
751 }
752 })
753 } else {
754 false
706 } 755 }
756 } else {
757 false
758 }
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()],