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:
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()],