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