diff options
Diffstat (limited to 'src/lib/push.rs')
| -rw-r--r-- | src/lib/push.rs | 270 |
1 files changed, 252 insertions, 18 deletions
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 | } | ||