upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/bin/git_remote_nostr/push.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/bin/git_remote_nostr/push.rs')
-rw-r--r--src/bin/git_remote_nostr/push.rs333
1 files changed, 278 insertions, 55 deletions
diff --git a/src/bin/git_remote_nostr/push.rs b/src/bin/git_remote_nostr/push.rs
index 9ff8af0..909a0ab 100644
--- a/src/bin/git_remote_nostr/push.rs
+++ b/src/bin/git_remote_nostr/push.rs
@@ -12,23 +12,24 @@ use client::{get_events_from_local_cache, get_state_from_cache, send_events, sig
12use console::Term; 12use console::Term;
13use git::{RepoActions, sha1_to_oid}; 13use git::{RepoActions, sha1_to_oid};
14use git_events::{ 14use git_events::{
15 generate_cover_letter_and_patch_events, generate_patch_event, get_commit_id_from_patch, 15 generate_cover_letter_and_patch_events, generate_patch_event,
16 generate_unsigned_pr_or_update_event, get_commit_id_from_patch,
16}; 17};
17use git2::{Oid, Repository}; 18use git2::{Oid, Repository};
18use ngit::{ 19use ngit::{
19 cli_interactor::count_lines_per_msg_vec, 20 cli_interactor::count_lines_per_msg_vec,
20 client::{self, get_event_from_cache_by_id}, 21 client::{self, get_event_from_cache_by_id, sign_draft_event},
21 git::{ 22 git::{
22 self, 23 self,
23 nostr_url::{CloneUrl, NostrUrlDecoded}, 24 nostr_url::{CloneUrl, NostrUrlDecoded},
24 oid_to_shorthand_string, 25 oid_to_shorthand_string,
25 }, 26 },
26 git_events::{self, event_to_cover_letter, get_event_root}, 27 git_events::{self, KIND_PULL_REQUEST, event_to_cover_letter, get_event_root},
27 login::{self, user::UserRef}, 28 login::{self, user::UserRef},
28 repo_ref::{self, get_repo_config_from_yaml, is_grasp_server}, 29 repo_ref::{self, get_repo_config_from_yaml, is_grasp_server, normalize_grasp_server_url},
29 repo_state, 30 repo_state,
30}; 31};
31use nostr::nips::nip10::Marker; 32use nostr::{event::UnsignedEvent, nips::nip10::Marker};
32use nostr_sdk::{ 33use nostr_sdk::{
33 Event, EventBuilder, EventId, Kind, NostrSigner, PublicKey, RelayUrl, Tag, TagStandard, 34 Event, EventBuilder, EventId, Kind, NostrSigner, PublicKey, RelayUrl, Tag, TagStandard,
34 hashes::sha1::Hash as Sha1Hash, 35 hashes::sha1::Hash as Sha1Hash,
@@ -65,7 +66,7 @@ pub async fn run_push(
65 .cloned() 66 .cloned()
66 .collect::<Vec<String>>(); 67 .collect::<Vec<String>>();
67 68
68 let mut git_server_refspecs = refspecs 69 let mut git_state_refspecs = refspecs
69 .iter() 70 .iter()
70 .filter(|r| !r.contains("refs/heads/pr/")) 71 .filter(|r| !r.contains("refs/heads/pr/"))
71 .cloned() 72 .cloned()
@@ -105,12 +106,12 @@ pub async fn run_push(
105 let (rejected_refspecs, remote_refspecs) = create_rejected_refspecs_and_remotes_refspecs( 106 let (rejected_refspecs, remote_refspecs) = create_rejected_refspecs_and_remotes_refspecs(
106 &term, 107 &term,
107 git_repo, 108 git_repo,
108 &git_server_refspecs, 109 &git_state_refspecs,
109 &existing_state, 110 &existing_state,
110 &list_outputs, 111 &list_outputs,
111 )?; 112 )?;
112 113
113 git_server_refspecs.retain(|refspec| { 114 git_state_refspecs.retain(|refspec| {
114 if let Some(rejected) = rejected_refspecs.get(&refspec.to_string()) { 115 if let Some(rejected) = rejected_refspecs.get(&refspec.to_string()) {
115 let (_, to) = refspec_to_from_to(refspec).unwrap(); 116 let (_, to) = refspec_to_from_to(refspec).unwrap();
116 println!("error {to} {} out of sync with nostr", rejected.join(" ")); 117 println!("error {to} {} out of sync with nostr", rejected.join(" "));
@@ -121,11 +122,11 @@ pub async fn run_push(
121 }); 122 });
122 123
123 // all refspecs aren't rejected 124 // all refspecs aren't rejected
124 if !(git_server_refspecs.is_empty() && proposal_refspecs.is_empty()) { 125 if !(git_state_refspecs.is_empty() && proposal_refspecs.is_empty()) {
125 let (rejected_proposal_refspecs, rejected) = create_and_publish_events( 126 let (rejected_proposal_refspecs, rejected) = create_and_publish_events_and_proposals(
126 git_repo, 127 git_repo,
127 repo_ref, 128 repo_ref,
128 &git_server_refspecs, 129 &git_state_refspecs,
129 &proposal_refspecs, 130 &proposal_refspecs,
130 client, 131 client,
131 existing_state, 132 existing_state,
@@ -134,7 +135,7 @@ pub async fn run_push(
134 .await?; 135 .await?;
135 136
136 if !rejected { 137 if !rejected {
137 for refspec in git_server_refspecs.iter().chain(proposal_refspecs.iter()) { 138 for refspec in git_state_refspecs.iter().chain(proposal_refspecs.iter()) {
138 if rejected_proposal_refspecs.contains(refspec) { 139 if rejected_proposal_refspecs.contains(refspec) {
139 continue; 140 continue;
140 } 141 }
@@ -153,7 +154,7 @@ pub async fn run_push(
153 for (git_server_url, remote_refspecs) in remote_refspecs { 154 for (git_server_url, remote_refspecs) in remote_refspecs {
154 let remote_refspecs = remote_refspecs 155 let remote_refspecs = remote_refspecs
155 .iter() 156 .iter()
156 .filter(|refspec| git_server_refspecs.contains(refspec)) 157 .filter(|refspec| git_state_refspecs.contains(refspec))
157 .cloned() 158 .cloned()
158 .collect::<Vec<String>>(); 159 .collect::<Vec<String>>();
159 if !refspecs.is_empty() { 160 if !refspecs.is_empty() {
@@ -174,7 +175,7 @@ pub async fn run_push(
174 Ok(()) 175 Ok(())
175} 176}
176 177
177async fn create_and_publish_events( 178async fn create_and_publish_events_and_proposals(
178 git_repo: &Repo, 179 git_repo: &Repo,
179 repo_ref: &RepoRef, 180 repo_ref: &RepoRef,
180 git_server_refspecs: &Vec<String>, 181 git_server_refspecs: &Vec<String>,
@@ -320,18 +321,23 @@ async fn process_proposal_refspecs(
320 { 321 {
321 if refspec.starts_with('+') { 322 if refspec.starts_with('+') {
322 // force push 323 // force push
323 let (_, main_tip) = git_repo.get_main_or_master_branch()?; 324 let (main_branch_name, main_tip) = git_repo.get_main_or_master_branch()?;
324 let (mut ahead, _) = 325 let (mut ahead, _) =
325 git_repo.get_commits_ahead_behind(&main_tip, &tip_of_pushed_branch)?; 326 git_repo.get_commits_ahead_behind(&main_tip, &tip_of_pushed_branch)?;
326 ahead.reverse(); 327 ahead.reverse();
327 for patch in generate_cover_letter_and_patch_events( 328 if ahead.is_empty() {
328 None, 329 bail!(
330 "cannot push '{from}' as proposal as branch isn't ahead of '{main_branch_name}'"
331 );
332 }
333 for patch in generate_patches_or_pr_event_or_pr_updates(
329 git_repo, 334 git_repo,
335 repo_ref,
330 &ahead, 336 &ahead,
337 user_ref,
338 Some(proposal),
331 signer, 339 signer,
332 repo_ref, 340 term,
333 &Some(proposal.id.to_string()),
334 &[],
335 ) 341 )
336 .await? 342 .await?
337 { 343 {
@@ -355,27 +361,50 @@ async fn process_proposal_refspecs(
355 }; 361 };
356 let mut parent_patch = tip_patch.clone(); 362 let mut parent_patch = tip_patch.clone();
357 ahead.reverse(); 363 ahead.reverse();
358 for (i, commit) in ahead.iter().enumerate() { 364 if ahead.is_empty() {
359 let new_patch = generate_patch_event( 365 bail!(
366 "cannot push '{from}' as proposal as branch isn't ahead of proposal on nostr"
367 );
368 }
369 if proposal.kind.eq(&KIND_PULL_REQUEST)
370 || are_commits_too_big_for_patches(git_repo, &ahead)
371 {
372 for event in generate_patches_or_pr_event_or_pr_updates(
360 git_repo, 373 git_repo,
361 &git_repo.get_root_commit()?,
362 commit,
363 Some(thread_id),
364 signer,
365 repo_ref, 374 repo_ref,
366 Some(parent_patch.id), 375 &ahead,
367 Some(( 376 user_ref,
368 (patches.len() + i + 1).try_into().unwrap(), 377 Some(proposal),
369 (patches.len() + ahead.len()).try_into().unwrap(), 378 signer,
370 )), 379 term,
371 None,
372 &None,
373 &[],
374 ) 380 )
375 .await 381 .await?
376 .context("failed to make patch event from commit")?; 382 {
377 events.push(new_patch.clone()); 383 events.push(event);
378 parent_patch = new_patch; 384 }
385 } else {
386 for (i, commit) in ahead.iter().enumerate() {
387 let new_patch = generate_patch_event(
388 git_repo,
389 &git_repo.get_root_commit()?,
390 commit,
391 Some(thread_id),
392 signer,
393 repo_ref,
394 Some(parent_patch.id),
395 Some((
396 (patches.len() + i + 1).try_into().unwrap(),
397 (patches.len() + ahead.len()).try_into().unwrap(),
398 )),
399 None,
400 &None,
401 &[],
402 )
403 .await
404 .context("failed to make patch event from commit")?;
405 events.push(new_patch.clone());
406 parent_patch = new_patch;
407 }
379 } 408 }
380 } else { 409 } else {
381 // we shouldn't get here 410 // we shouldn't get here
@@ -400,22 +429,21 @@ async fn process_proposal_refspecs(
400 } 429 }
401 } else { 430 } else {
402 // TODO new proposal / couldn't find exisiting proposal 431 // TODO new proposal / couldn't find exisiting proposal
403 let (_, main_tip) = git_repo.get_main_or_master_branch()?; 432 let (main_branch_name, main_tip) = git_repo.get_main_or_master_branch()?;
404 let (mut ahead, _) = 433 let (mut ahead, _) =
405 git_repo.get_commits_ahead_behind(&main_tip, &tip_of_pushed_branch)?; 434 git_repo.get_commits_ahead_behind(&main_tip, &tip_of_pushed_branch)?;
406 ahead.reverse(); 435 ahead.reverse();
407 for patch in generate_cover_letter_and_patch_events( 436 if ahead.is_empty() {
408 None, 437 bail!(
409 git_repo, 438 "cannot push '{from}' as proposal as branch isn't ahead of '{main_branch_name}'"
410 &ahead, 439 );
411 signer, 440 }
412 repo_ref, 441 for event in generate_patches_or_pr_event_or_pr_updates(
413 &None, 442 git_repo, repo_ref, &ahead, user_ref, None, signer, term,
414 &[],
415 ) 443 )
416 .await? 444 .await?
417 { 445 {
418 events.push(patch); 446 events.push(event);
419 } 447 }
420 } 448 }
421 } 449 }
@@ -423,6 +451,145 @@ async fn process_proposal_refspecs(
423 Ok((events, rejected_proposal_refspecs)) 451 Ok((events, rejected_proposal_refspecs))
424} 452}
425 453
454fn are_commits_too_big_for_patches(git_repo: &Repo, commits: &[Sha1Hash]) -> bool {
455 commits.iter().any(|commit| {
456 if let Ok(patch) = git_repo.make_patch_from_commit(commit, &None) {
457 patch.len()
458 > ((65 // max recomended patch event size specified in nip34 in kb
459 // allownace for nostr event wrapper (id, pubkey, tags, sig)
460 - 1) * 1024)
461 } else {
462 true
463 }
464 })
465}
466
467#[allow(clippy::too_many_lines)]
468async fn generate_patches_or_pr_event_or_pr_updates(
469 git_repo: &Repo,
470 repo_ref: &RepoRef,
471 ahead: &[Sha1Hash],
472 user_ref: &UserRef,
473 root_proposal: Option<&Event>,
474 signer: &Arc<dyn NostrSigner>,
475 term: &Term,
476) -> Result<Vec<Event>> {
477 let mut events: Vec<Event> = vec![];
478 let use_pr = root_proposal.is_some_and(|proposal| proposal.kind.eq(&KIND_PULL_REQUEST))
479 || are_commits_too_big_for_patches(git_repo, ahead);
480
481 if use_pr {
482 let repo_grasps = repo_ref.grasp_servers();
483 let repo_grasp_clone_urls = repo_ref
484 .git_server
485 .iter()
486 .filter(|s| is_grasp_server(s, &repo_grasps));
487
488 let mut unsigned_pr_event: Option<UnsignedEvent> = None;
489 let mut failed_clone_urls = vec![];
490 for clone_url in repo_grasp_clone_urls {
491 let mut draft_pr_event = if let Some(ref unsigned_pr_event) = unsigned_pr_event {
492 unsigned_pr_event.clone()
493 } else {
494 generate_unsigned_pr_or_update_event(
495 git_repo,
496 repo_ref,
497 &user_ref.public_key,
498 root_proposal,
499 ahead.first().context("no commits to push")?,
500 &[clone_url],
501 &[],
502 )?
503 };
504
505 let refspec = format!(
506 "{}:refs/nostr/{}",
507 ahead.first().unwrap(),
508 draft_pr_event.id()
509 );
510
511 if let Err(error) = push_to_remote_url(git_repo, clone_url, &[refspec], term) {
512 failed_clone_urls.push(clone_url);
513 term.write_line(
514 format!(
515 "push: error sending commit data to {}: {error}",
516 normalize_grasp_server_url(clone_url)?
517 )
518 .as_str(),
519 )?;
520 } else {
521 term.write_line(
522 format!(
523 "push: commit data sent to {}",
524 normalize_grasp_server_url(clone_url)?
525 )
526 .as_str(),
527 )?;
528 unsigned_pr_event = Some(draft_pr_event);
529 }
530 }
531 if unsigned_pr_event.is_none() {
532 bail!(
533 "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."
534 );
535
536 // TODO get grasp_default_set servers that aren't in repo_grasps
537 // cycle through until one succeeds TODO create
538 // personal-fork announcement with grasp servers and
539 // push, after a few seconds push ref/nostr/eventid. if
540 // one success break out of for loop and continue
541 }
542 if let Some(unsigned_pr_event) = unsigned_pr_event {
543 let pr_event = sign_draft_event(
544 unsigned_pr_event,
545 signer,
546 if root_proposal.is_some_and(|proposal| proposal.kind.eq(&Kind::GitPatch)) {
547 "Pull Request Replacing Original Patch"
548 } else if root_proposal.is_some() {
549 "Pull Request Update"
550 } else {
551 "Pull Request"
552 }
553 .to_string(),
554 )
555 .await?;
556 events.push(pr_event);
557 if root_proposal.is_some_and(|proposal| proposal.kind.eq(&Kind::GitPatch)) {
558 events.push(
559 create_close_status_for_original_patch(
560 signer,
561 repo_ref,
562 root_proposal.unwrap(),
563 )
564 .await?,
565 );
566 }
567 } else {
568 bail!(
569 "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"
570 );
571 // TODO suggest `ngit send` where user could specify their own clone
572 // url to push to once that feature is added
573 }
574 } else {
575 for patch in generate_cover_letter_and_patch_events(
576 None,
577 git_repo,
578 ahead,
579 signer,
580 repo_ref,
581 &root_proposal.map(|proposal| proposal.id.to_string()),
582 &[],
583 )
584 .await?
585 {
586 events.push(patch);
587 }
588 }
589
590 Ok(events)
591}
592
426fn push_to_remote( 593fn push_to_remote(
427 git_repo: &Repo, 594 git_repo: &Repo,
428 git_server_url: &str, 595 git_server_url: &str,
@@ -1079,7 +1246,7 @@ type MergedProposalsInfo =
1079async fn get_merged_proposals_info( 1246async fn get_merged_proposals_info(
1080 git_repo: &Repo, 1247 git_repo: &Repo,
1081 ahead: &Vec<Sha1Hash>, 1248 ahead: &Vec<Sha1Hash>,
1082 available_patches: &[Event], 1249 available_patches_pr_pr_update: &[Event],
1083) -> Result<MergedProposalsInfo> { 1250) -> Result<MergedProposalsInfo> {
1084 let mut proposals: MergedProposalsInfo = HashMap::new(); 1251 let mut proposals: MergedProposalsInfo = HashMap::new();
1085 1252
@@ -1089,19 +1256,19 @@ async fn get_merged_proposals_info(
1089 // are in ahead 1256 // are in ahead
1090 if commit.parent_count() > 1 { 1257 if commit.parent_count() > 1 {
1091 for parent in commit.parents() { 1258 for parent in commit.parents() {
1092 for patch_event in available_patches 1259 for event in available_patches_pr_pr_update
1093 .iter() 1260 .iter()
1094 .filter(|e| { 1261 .filter(|e| {
1095 e.tags.iter().any(|t| { 1262 e.tags.iter().any(|t| {
1096 t.as_slice().len() > 1 1263 t.as_slice().len() > 1
1097 && t.as_slice()[0].eq("commit") 1264 && (t.as_slice()[0].eq("commit") || t.as_slice()[0].eq("c"))
1098 && t.as_slice()[1].eq(&parent.id().to_string()) 1265 && t.as_slice()[1].eq(&parent.id().to_string())
1099 }) 1266 })
1100 }) 1267 })
1101 .collect::<Vec<&Event>>() 1268 .collect::<Vec<&Event>>()
1102 { 1269 {
1103 if let Ok((proposal_id, revision_id)) = 1270 if let Ok((proposal_id, revision_id)) =
1104 get_proposal_and_revision_root_from_patch(git_repo, patch_event).await 1271 get_proposal_and_revision_root_from_patch(git_repo, event).await
1105 { 1272 {
1106 let (entry_revision_id, merged_patches) = 1273 let (entry_revision_id, merged_patches) =
1107 proposals.entry(proposal_id).or_default(); 1274 proposals.entry(proposal_id).or_default();
@@ -1114,12 +1281,12 @@ async fn get_merged_proposals_info(
1114 } else { 1281 } else {
1115 // three way merge or fast forward merge commits 1282 // three way merge or fast forward merge commits
1116 // note: ahead included commits of three-way merged branches 1283 // note: ahead included commits of three-way merged branches
1117 let mut matching_patches = available_patches 1284 let mut matching_patches = available_patches_pr_pr_update
1118 .iter() 1285 .iter()
1119 .filter(|e| { 1286 .filter(|e| {
1120 e.tags.iter().any(|t| { 1287 e.tags.iter().any(|t| {
1121 t.as_slice().len() > 1 1288 t.as_slice().len() > 1
1122 && t.as_slice()[0].eq("commit") 1289 && (t.as_slice()[0].eq("commit") || t.as_slice()[0].eq("c"))
1123 && t.as_slice()[1].eq(&commit_hash.to_string()) 1290 && t.as_slice()[1].eq(&commit_hash.to_string())
1124 }) 1291 })
1125 }) 1292 })
@@ -1144,7 +1311,7 @@ async fn get_merged_proposals_info(
1144 // applied commits - this is done after so that merged revisions take priority 1311 // applied commits - this is done after so that merged revisions take priority
1145 if matching_patches.is_empty() { 1312 if matching_patches.is_empty() {
1146 let author = git_repo.get_commit_author(commit_hash)?; 1313 let author = git_repo.get_commit_author(commit_hash)?;
1147 matching_patches = available_patches 1314 matching_patches = available_patches_pr_pr_update
1148 .iter() 1315 .iter()
1149 .filter(|e| { 1316 .filter(|e| {
1150 if let Ok(patch_author) = get_patch_author(e) { 1317 if let Ok(patch_author) = get_patch_author(e) {
@@ -1391,6 +1558,62 @@ async fn create_merge_status(
1391 .await 1558 .await
1392} 1559}
1393 1560
1561async fn create_close_status_for_original_patch(
1562 signer: &Arc<dyn NostrSigner>,
1563 repo_ref: &RepoRef,
1564 proposal: &Event,
1565) -> Result<Event> {
1566 let mut public_keys = repo_ref
1567 .maintainers
1568 .iter()
1569 .copied()
1570 .collect::<HashSet<PublicKey>>();
1571 public_keys.insert(proposal.pubkey);
1572
1573 sign_event(
1574 EventBuilder::new(nostr::event::Kind::GitStatusClosed, String::new()).tags(
1575 [
1576 vec![
1577 Tag::custom(
1578 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
1579 vec![
1580 "Git patch closed as forthcoming update is too large. Replacing with Pull Request"
1581 .to_string(),
1582 ],
1583 ),
1584 Tag::from_standardized(nostr::TagStandard::Event {
1585 event_id: proposal.id,
1586 relay_url: repo_ref.relays.first().cloned(),
1587 marker: Some(Marker::Root),
1588 public_key: None,
1589 uppercase: false,
1590 }),
1591 ],
1592 public_keys.iter().map(|pk| Tag::public_key(*pk)).collect(),
1593 repo_ref
1594 .coordinates()
1595 .iter()
1596 .map(|c| {
1597 Tag::from_standardized(TagStandard::Coordinate {
1598 coordinate: c.coordinate.clone(),
1599 relay_url: c.relays.first().cloned(),
1600 uppercase: false,
1601 })
1602 })
1603 .collect::<Vec<Tag>>(),
1604 vec![
1605 Tag::from_standardized(nostr::TagStandard::Reference(
1606 repo_ref.root_commit.to_string(),
1607 )),
1608 ],
1609 ]
1610 .concat(),
1611 ),
1612 signer,
1613 "close status for original patch".to_string(),
1614 )
1615 .await
1616}
1394async fn get_proposal_and_revision_root_from_patch( 1617async fn get_proposal_and_revision_root_from_patch(
1395 git_repo: &Repo, 1618 git_repo: &Repo,
1396 patch: &Event, 1619 patch: &Event,