upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/sub_commands/send.rs
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2024-09-04 08:04:48 +0100
committerDanConwayDev <DanConwayDev@protonmail.com>2024-09-04 13:30:59 +0100
commit949c6459aa7683453a7160423b689ceadb08954b (patch)
tree230c26ecb11b99916e5570e548673eb09ecf0a36 /src/sub_commands/send.rs
parenta825311f2c55661aaab3a163bda9109295c96044 (diff)
refactor: organise into lib and bin structure
the make the code more readable this commit just moves the files, the next commit should fix the imports
Diffstat (limited to 'src/sub_commands/send.rs')
-rw-r--r--src/sub_commands/send.rs1363
1 files changed, 0 insertions, 1363 deletions
diff --git a/src/sub_commands/send.rs b/src/sub_commands/send.rs
deleted file mode 100644
index 3c4df9d..0000000
--- a/src/sub_commands/send.rs
+++ /dev/null
@@ -1,1363 +0,0 @@
1use std::{path::Path, str::FromStr, time::Duration};
2
3use anyhow::{bail, Context, Result};
4use console::Style;
5use futures::future::join_all;
6use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
7use nostr::{
8 nips::{
9 nip01::Coordinate,
10 nip10::Marker,
11 nip19::{Nip19, Nip19Event},
12 },
13 EventBuilder, FromBech32, Tag, TagKind, ToBech32, UncheckedUrl,
14};
15use nostr_sdk::{hashes::sha1::Hash as Sha1Hash, Kind, NostrSigner, TagStandard};
16
17use super::list::tag_value;
18#[cfg(not(test))]
19use crate::client::Client;
20#[cfg(test)]
21use crate::client::MockConnect;
22use crate::{
23 cli::Cli,
24 cli_interactor::{
25 Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptMultiChoiceParms,
26 },
27 client::{
28 fetching_with_report, get_events_from_cache, get_repo_ref_from_cache, sign_event, Connect,
29 },
30 git::{Repo, RepoActions},
31 login,
32 repo_ref::{get_repo_coordinates, RepoRef},
33};
34
35#[derive(Debug, clap::Args)]
36pub struct SubCommandArgs {
37 #[arg(default_value = "")]
38 /// commits to send as proposal; like in `git format-patch` eg. HEAD~2
39 pub(crate) since_or_range: String,
40 #[clap(long, value_parser, num_args = 0.., value_delimiter = ' ')]
41 /// references to an existing proposal for which this is a new
42 /// version and/or events / npubs to tag as mentions
43 pub(crate) in_reply_to: Vec<String>,
44 /// don't prompt for a cover letter
45 #[arg(long, action)]
46 pub(crate) no_cover_letter: bool,
47 /// optional cover letter title
48 #[clap(short, long)]
49 pub(crate) title: Option<String>,
50 #[clap(short, long)]
51 /// optional cover letter description
52 pub(crate) description: Option<String>,
53}
54
55#[allow(clippy::too_many_lines)]
56pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Result<()> {
57 let git_repo = Repo::discover().context("cannot find a git repository")?;
58 let git_repo_path = git_repo.get_path()?;
59
60 let (main_branch_name, main_tip) = git_repo
61 .get_main_or_master_branch()
62 .context("the default branches (main or master) do not exist")?;
63
64 #[cfg(not(test))]
65 let mut client = Client::default();
66 #[cfg(test)]
67 let mut client = <MockConnect as std::default::Default>::default();
68
69 let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?;
70
71 if !no_fetch {
72 fetching_with_report(git_repo_path, &client, &repo_coordinates).await?;
73 }
74
75 let (root_proposal_id, mention_tags) =
76 get_root_proposal_id_and_mentions_from_in_reply_to(git_repo.get_path()?, &args.in_reply_to)
77 .await?;
78
79 if let Some(root_ref) = args.in_reply_to.first() {
80 if root_proposal_id.is_some() {
81 println!("creating proposal revision for: {root_ref}");
82 }
83 }
84
85 let mut commits: Vec<Sha1Hash> = {
86 if args.since_or_range.is_empty() {
87 let branch_name = git_repo.get_checked_out_branch_name()?;
88 let proposed_commits = if branch_name.eq(main_branch_name) {
89 vec![main_tip]
90 } else {
91 let (_, _, ahead, _) = identify_ahead_behind(&git_repo, &None, &None)?;
92 ahead
93 };
94 choose_commits(&git_repo, proposed_commits)?
95 } else {
96 git_repo
97 .parse_starting_commits(&args.since_or_range)
98 .context("cannot parse specified starting commit or range")?
99 }
100 };
101
102 if commits.is_empty() {
103 bail!("no commits selected");
104 }
105 println!("creating proposal from {} commits:", commits.len());
106
107 let dim = Style::new().color256(247);
108 for commit in &commits {
109 println!(
110 "{} {}",
111 dim.apply_to(commit.to_string().chars().take(7).collect::<String>()),
112 git_repo.get_commit_message_summary(commit)?
113 );
114 }
115
116 let (first_commit_ahead, behind) =
117 git_repo.get_commits_ahead_behind(&main_tip, commits.last().context("no commits")?)?;
118
119 // check proposal ahead of origin/main
120 if first_commit_ahead.len().gt(&1) && !Interactor::default().confirm(
121 PromptConfirmParms::default()
122 .with_prompt(
123 format!("proposal builds on a commit {} ahead of '{main_branch_name}' - do you want to continue?", first_commit_ahead.len() - 1)
124 )
125 .with_default(false)
126 ).context("failed to get confirmation response from interactor confirm")? {
127 bail!("aborting because selected commits were ahead of origin/master");
128 }
129
130 // check if a selected commit is already in origin
131 if commits.iter().any(|c| c.eq(&main_tip)) {
132 if !Interactor::default().confirm(
133 PromptConfirmParms::default()
134 .with_prompt(
135 format!("proposal contains commit(s) already in '{main_branch_name}'. proceed anyway?")
136 )
137 .with_default(false)
138 ).context("failed to get confirmation response from interactor confirm")? {
139 bail!("aborting as proposal contains commit(s) already in '{main_branch_name}'");
140 }
141 }
142 // check proposal isn't behind origin/main
143 else if !behind.is_empty() && !Interactor::default().confirm(
144 PromptConfirmParms::default()
145 .with_prompt(
146 format!("proposal is {} behind '{main_branch_name}'. consider rebasing before submission. proceed anyway?", behind.len())
147 )
148 .with_default(false)
149 ).context("failed to get confirmation response from interactor confirm")? {
150 bail!("aborting so commits can be rebased");
151 }
152
153 let title = if args.no_cover_letter {
154 None
155 } else {
156 match &args.title {
157 Some(t) => Some(t.clone()),
158 None => {
159 if Interactor::default().confirm(
160 PromptConfirmParms::default()
161 .with_default(false)
162 .with_prompt("include cover letter?"),
163 )? {
164 Some(
165 Interactor::default()
166 .input(PromptInputParms::default().with_prompt("title"))?
167 .clone(),
168 )
169 } else {
170 None
171 }
172 }
173 }
174 };
175
176 let cover_letter_title_description = if let Some(title) = title {
177 Some((
178 title,
179 if let Some(t) = &args.description {
180 t.clone()
181 } else {
182 Interactor::default()
183 .input(PromptInputParms::default().with_prompt("cover letter description"))?
184 .clone()
185 },
186 ))
187 } else {
188 None
189 };
190 let (signer, user_ref) = login::launch(
191 &git_repo,
192 &cli_args.bunker_uri,
193 &cli_args.bunker_app_key,
194 &cli_args.nsec,
195 &cli_args.password,
196 Some(&client),
197 false,
198 false,
199 )
200 .await?;
201
202 client.set_signer(signer.clone()).await;
203
204 let repo_ref = get_repo_ref_from_cache(git_repo_path, &repo_coordinates).await?;
205
206 // oldest first
207 commits.reverse();
208
209 let events = generate_cover_letter_and_patch_events(
210 cover_letter_title_description.clone(),
211 &git_repo,
212 &commits,
213 &signer,
214 &repo_ref,
215 &root_proposal_id,
216 &mention_tags,
217 )
218 .await?;
219
220 println!(
221 "posting {} patch{} {} a covering letter...",
222 if cover_letter_title_description.is_none() {
223 events.len()
224 } else {
225 events.len() - 1
226 },
227 if cover_letter_title_description.is_none() && events.len().eq(&1)
228 || cover_letter_title_description.is_some() && events.len().eq(&2)
229 {
230 ""
231 } else {
232 "es"
233 },
234 if cover_letter_title_description.is_none() {
235 "without"
236 } else {
237 "with"
238 }
239 );
240
241 send_events(
242 &client,
243 git_repo_path,
244 events.clone(),
245 user_ref.relays.write(),
246 repo_ref.relays.clone(),
247 !cli_args.disable_cli_spinners,
248 false,
249 )
250 .await?;
251
252 if root_proposal_id.is_none() {
253 if let Some(event) = events.first() {
254 let event_bech32 = if let Some(relay) = repo_ref.relays.first() {
255 Nip19Event::new(event.id(), vec![relay]).to_bech32()?
256 } else {
257 event.id().to_bech32()?
258 };
259 println!(
260 "{}",
261 dim.apply_to(format!(
262 "view in gitworkshop.dev: https://gitworkshop.dev/repo/{}/proposal/{}",
263 repo_ref.coordinate_with_hint().to_bech32()?,
264 &event_bech32,
265 ))
266 );
267 println!(
268 "{}",
269 dim.apply_to(format!(
270 "view in another client: https://njump.me/{}",
271 &event_bech32,
272 ))
273 );
274 }
275 }
276 // TODO check if there is already a similarly named
277 Ok(())
278}
279
280#[allow(clippy::module_name_repetitions)]
281#[allow(clippy::too_many_lines)]
282pub async fn send_events(
283 #[cfg(test)] client: &crate::client::MockConnect,
284 #[cfg(not(test))] client: &Client,
285 git_repo_path: &Path,
286 events: Vec<nostr::Event>,
287 my_write_relays: Vec<String>,
288 repo_read_relays: Vec<String>,
289 animate: bool,
290 silent: bool,
291) -> Result<()> {
292 let fallback = [
293 client.get_fallback_relays().clone(),
294 if events
295 .iter()
296 .any(|e| e.kind().eq(&Kind::GitRepoAnnouncement))
297 {
298 client.get_blaster_relays().clone()
299 } else {
300 vec![]
301 },
302 ]
303 .concat();
304 let mut relays: Vec<&String> = vec![];
305
306 let all = &[
307 repo_read_relays.clone(),
308 my_write_relays.clone(),
309 fallback.clone(),
310 ]
311 .concat();
312 // add duplicates first
313 for r in &repo_read_relays {
314 let r_clean = remove_trailing_slash(r);
315 if !my_write_relays
316 .iter()
317 .filter(|x| r_clean.eq(&remove_trailing_slash(x)))
318 .count()
319 > 1
320 && !relays.iter().any(|x| r_clean.eq(&remove_trailing_slash(x)))
321 {
322 relays.push(r);
323 }
324 }
325
326 for r in all {
327 let r_clean = remove_trailing_slash(r);
328 if !relays.iter().any(|x| r_clean.eq(&remove_trailing_slash(x))) {
329 relays.push(r);
330 }
331 }
332
333 let m = if silent {
334 MultiProgress::with_draw_target(ProgressDrawTarget::hidden())
335 } else {
336 MultiProgress::new()
337 };
338 let pb_style = ProgressStyle::with_template(if animate {
339 " {spinner} {prefix} {bar} {pos}/{len} {msg}"
340 } else {
341 " - {prefix} {bar} {pos}/{len} {msg}"
342 })?
343 .progress_chars("##-");
344
345 let pb_after_style =
346 |symbol| ProgressStyle::with_template(format!(" {symbol} {}", "{prefix} {msg}",).as_str());
347 let pb_after_style_succeeded = pb_after_style(if animate {
348 console::style("✔".to_string())
349 .for_stderr()
350 .green()
351 .to_string()
352 } else {
353 "y".to_string()
354 })?;
355
356 let pb_after_style_failed = pb_after_style(if animate {
357 console::style("✘".to_string())
358 .for_stderr()
359 .red()
360 .to_string()
361 } else {
362 "x".to_string()
363 })?;
364
365 #[allow(clippy::borrow_deref_ref)]
366 join_all(relays.iter().map(|&relay| async {
367 let relay_clean = remove_trailing_slash(&*relay);
368 let details = format!(
369 "{}{}{} {}",
370 if my_write_relays
371 .iter()
372 .any(|r| relay_clean.eq(&remove_trailing_slash(r)))
373 {
374 " [my-relay]"
375 } else {
376 ""
377 },
378 if repo_read_relays
379 .iter()
380 .any(|r| relay_clean.eq(&remove_trailing_slash(r)))
381 {
382 " [repo-relay]"
383 } else {
384 ""
385 },
386 if fallback
387 .iter()
388 .any(|r| relay_clean.eq(&remove_trailing_slash(r)))
389 {
390 " [default]"
391 } else {
392 ""
393 },
394 relay_clean,
395 );
396 let pb = m.add(
397 ProgressBar::new(events.len() as u64)
398 .with_prefix(details.to_string())
399 .with_style(pb_style.clone()),
400 );
401 if animate {
402 pb.enable_steady_tick(Duration::from_millis(300));
403 }
404 pb.inc(0); // need to make pb display intially
405 let mut failed = false;
406 for event in &events {
407 match client
408 .send_event_to(git_repo_path, relay.as_str(), event.clone())
409 .await
410 {
411 Ok(_) => pb.inc(1),
412 Err(e) => {
413 pb.set_style(pb_after_style_failed.clone());
414 pb.finish_with_message(
415 console::style(
416 e.to_string()
417 .replace("relay pool error:", "error:")
418 .replace("event not published: ", "error: "),
419 )
420 .for_stderr()
421 .red()
422 .to_string(),
423 );
424 failed = true;
425 break;
426 }
427 };
428 }
429 if !failed {
430 pb.set_style(pb_after_style_succeeded.clone());
431 pb.finish_with_message("");
432 }
433 }))
434 .await;
435 Ok(())
436}
437
438fn remove_trailing_slash(s: &String) -> String {
439 match s.as_str().strip_suffix('/') {
440 Some(s) => s,
441 None => s,
442 }
443 .to_string()
444}
445
446fn choose_commits(git_repo: &Repo, proposed_commits: Vec<Sha1Hash>) -> Result<Vec<Sha1Hash>> {
447 let mut proposed_commits = if proposed_commits.len().gt(&10) {
448 vec![]
449 } else {
450 proposed_commits
451 };
452
453 let tip_of_head = git_repo.get_tip_of_branch(&git_repo.get_checked_out_branch_name()?)?;
454 let most_recent_commit = proposed_commits.first().unwrap_or(&tip_of_head);
455
456 let mut last_15_commits = vec![*most_recent_commit];
457
458 while last_15_commits.len().lt(&15) {
459 if let Ok(parent_commit) = git_repo.get_commit_parent(last_15_commits.last().unwrap()) {
460 last_15_commits.push(parent_commit);
461 } else {
462 break;
463 }
464 }
465
466 let term = console::Term::stderr();
467 let mut printed_error_line = false;
468
469 let selected_commits = 'outer: loop {
470 let selected = Interactor::default().multi_choice(
471 PromptMultiChoiceParms::default()
472 .with_prompt("select commits for proposal")
473 .dont_report()
474 .with_choices(
475 last_15_commits
476 .iter()
477 .map(|h| summarise_commit_for_selection(git_repo, h).unwrap())
478 .collect(),
479 )
480 .with_defaults(
481 last_15_commits
482 .iter()
483 .map(|h| proposed_commits.iter().any(|c| c.eq(h)))
484 .collect(),
485 ),
486 )?;
487 proposed_commits = selected.iter().map(|i| last_15_commits[*i]).collect();
488
489 if printed_error_line {
490 term.clear_last_lines(1)?;
491 }
492
493 if proposed_commits.is_empty() {
494 term.write_line("no commits selected")?;
495 printed_error_line = true;
496 continue;
497 }
498 for (i, selected_i) in selected.iter().enumerate() {
499 if i.gt(&0) && selected_i.ne(&(selected[i - 1] + 1)) {
500 term.write_line("commits must be consecutive. try again.")?;
501 printed_error_line = true;
502 continue 'outer;
503 }
504 }
505
506 break proposed_commits;
507 };
508 Ok(selected_commits)
509}
510
511fn summarise_commit_for_selection(git_repo: &Repo, commit: &Sha1Hash) -> Result<String> {
512 let references = git_repo.get_refs(commit)?;
513 let dim = Style::new().color256(247);
514 let prefix = format!("({})", git_repo.get_commit_author(commit)?[0],);
515 let references_string = if references.is_empty() {
516 String::new()
517 } else {
518 format!(
519 " {}",
520 references
521 .iter()
522 .map(|r| format!("[{r}]"))
523 .collect::<Vec<String>>()
524 .join(" ")
525 )
526 };
527
528 Ok(format!(
529 "{} {}{} {}",
530 dim.apply_to(prefix),
531 git_repo.get_commit_message_summary(commit)?,
532 Style::new().magenta().apply_to(references_string),
533 dim.apply_to(commit.to_string().chars().take(7).collect::<String>(),),
534 ))
535}
536
537async fn get_root_proposal_id_and_mentions_from_in_reply_to(
538 git_repo_path: &Path,
539 in_reply_to: &[String],
540) -> Result<(Option<String>, Vec<nostr::Tag>)> {
541 let root_proposal_id = if let Some(first) = in_reply_to.first() {
542 match event_tag_from_nip19_or_hex(first, "in-reply-to", Marker::Root, true, false)?
543 .as_standardized()
544 {
545 Some(nostr_sdk::TagStandard::Event {
546 event_id,
547 relay_url: _,
548 marker: _,
549 public_key: _,
550 }) => {
551 let events =
552 get_events_from_cache(git_repo_path, vec![nostr::Filter::new().id(*event_id)])
553 .await?;
554
555 if let Some(first) = events.iter().find(|e| e.id.eq(event_id)) {
556 if event_is_patch_set_root(first) {
557 Some(event_id.to_string())
558 } else {
559 None
560 }
561 } else {
562 None
563 }
564 }
565 _ => None,
566 }
567 } else {
568 return Ok((None, vec![]));
569 };
570
571 let mut mention_tags = vec![];
572 for (i, reply_to) in in_reply_to.iter().enumerate() {
573 if i.ne(&0) || root_proposal_id.is_none() {
574 mention_tags.push(
575 event_tag_from_nip19_or_hex(reply_to, "in-reply-to", Marker::Mention, true, false)
576 .context(format!(
577 "{reply_to} in 'in-reply-to' not a valid nostr reference"
578 ))?,
579 );
580 }
581 }
582
583 Ok((root_proposal_id, mention_tags))
584}
585
586#[allow(clippy::too_many_lines)]
587pub async fn generate_cover_letter_and_patch_events(
588 cover_letter_title_description: Option<(String, String)>,
589 git_repo: &Repo,
590 commits: &[Sha1Hash],
591 signer: &NostrSigner,
592 repo_ref: &RepoRef,
593 root_proposal_id: &Option<String>,
594 mentions: &[nostr::Tag],
595) -> Result<Vec<nostr::Event>> {
596 let root_commit = git_repo
597 .get_root_commit()
598 .context("failed to get root commit of the repository")?;
599
600 let mut events = vec![];
601
602 if let Some((title, description)) = cover_letter_title_description {
603 events.push(sign_event(EventBuilder::new(
604 nostr::event::Kind::GitPatch,
605 format!(
606 "From {} Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/{}] {title}\n\n{description}",
607 commits.last().unwrap(),
608 commits.len()
609 ),
610 [
611 repo_ref.maintainers.iter().map(|m| Tag::coordinate(Coordinate {
612 kind: nostr::Kind::GitRepoAnnouncement,
613 public_key: *m,
614 identifier: repo_ref.identifier.to_string(),
615 relays: repo_ref.relays.clone(),
616 })).collect::<Vec<Tag>>(),
617 vec![
618 Tag::from_standardized(TagStandard::Reference(format!("{root_commit}"))),
619 Tag::hashtag("cover-letter"),
620 Tag::custom(
621 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
622 vec![format!("git patch cover letter: {}", title.clone())],
623 ),
624 ],
625 if let Some(event_ref) = root_proposal_id.clone() {
626 vec![
627 Tag::hashtag("root"),
628 Tag::hashtag("revision-root"),
629 // TODO check if id is for a root proposal (perhaps its for an issue?)
630 event_tag_from_nip19_or_hex(&event_ref,"proposal",Marker::Reply, false, false)?,
631 ]
632 } else {
633 vec![
634 Tag::hashtag("root"),
635 ]
636 },
637 mentions.to_vec(),
638 // this is not strictly needed but makes for prettier branch names
639 // eventually a prefix will be needed of the event id to stop 2 proposals with the same name colliding
640 // a change like this, or the removal of this tag will require the actual branch name to be tracked
641 // so pulling and pushing still work
642 if let Ok(branch_name) = git_repo.get_checked_out_branch_name() {
643 if !branch_name.eq("main")
644 && !branch_name.eq("master")
645 && !branch_name.eq("origin/main")
646 && !branch_name.eq("origin/master")
647 {
648 vec![
649 Tag::custom(
650 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")),
651 vec![if let Some(branch_name) = branch_name.strip_prefix("pr/") {
652 branch_name.to_string()
653 } else {
654 branch_name
655 }],
656 ),
657 ]
658 }
659 else { vec![] }
660 } else {
661 vec![]
662 },
663 repo_ref.maintainers
664 .iter()
665 .map(|pk| Tag::public_key(*pk))
666 .collect(),
667 ].concat(),
668 ), signer).await
669 .context("failed to create cover-letter event")?);
670 }
671
672 for (i, commit) in commits.iter().enumerate() {
673 events.push(
674 generate_patch_event(
675 git_repo,
676 &root_commit,
677 commit,
678 events.first().map(|event| event.id),
679 signer,
680 repo_ref,
681 events.last().map(nostr::Event::id),
682 if events.is_empty() && commits.len().eq(&1) {
683 None
684 } else {
685 Some(((i + 1).try_into()?, commits.len().try_into()?))
686 },
687 if events.is_empty() {
688 if let Ok(branch_name) = git_repo.get_checked_out_branch_name() {
689 if !branch_name.eq("main")
690 && !branch_name.eq("master")
691 && !branch_name.eq("origin/main")
692 && !branch_name.eq("origin/master")
693 {
694 Some(if let Some(branch_name) = branch_name.strip_prefix("pr/") {
695 branch_name.to_string()
696 } else {
697 branch_name
698 })
699 } else {
700 None
701 }
702 } else {
703 None
704 }
705 } else {
706 None
707 },
708 root_proposal_id,
709 if events.is_empty() { mentions } else { &[] },
710 )
711 .await
712 .context("failed to generate patch event")?,
713 );
714 }
715 Ok(events)
716}
717
718fn event_tag_from_nip19_or_hex(
719 reference: &str,
720 reference_name: &str,
721 marker: Marker,
722 allow_npub_reference: bool,
723 prompt_for_correction: bool,
724) -> Result<nostr::Tag> {
725 let mut bech32 = reference.to_string();
726 loop {
727 if bech32.is_empty() {
728 bech32 = Interactor::default().input(
729 PromptInputParms::default().with_prompt(&format!("{reference_name} reference")),
730 )?;
731 }
732 if let Ok(nip19) = Nip19::from_bech32(bech32.clone()) {
733 match nip19 {
734 Nip19::Event(n) => {
735 break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event {
736 event_id: n.event_id,
737 relay_url: n.relays.first().map(UncheckedUrl::new),
738 marker: Some(marker),
739 public_key: None,
740 }));
741 }
742 Nip19::EventId(id) => {
743 break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event {
744 event_id: id,
745 relay_url: None,
746 marker: Some(marker),
747 public_key: None,
748 }));
749 }
750 Nip19::Coordinate(coordinate) => {
751 break Ok(Tag::coordinate(coordinate));
752 }
753 Nip19::Profile(profile) => {
754 if allow_npub_reference {
755 break Ok(Tag::public_key(profile.public_key));
756 }
757 }
758 Nip19::Pubkey(public_key) => {
759 if allow_npub_reference {
760 break Ok(Tag::public_key(public_key));
761 }
762 }
763 _ => {}
764 }
765 }
766 if let Ok(id) = nostr::EventId::from_str(&bech32) {
767 break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event {
768 event_id: id,
769 relay_url: None,
770 marker: Some(marker),
771 public_key: None,
772 }));
773 }
774 if prompt_for_correction {
775 println!("not a valid {reference_name} event reference");
776 } else {
777 bail!(format!("not a valid {reference_name} event reference"));
778 }
779
780 bech32 = String::new();
781 }
782}
783
784pub struct CoverLetter {
785 pub title: String,
786 pub description: String,
787 pub branch_name: String,
788 pub event_id: Option<nostr::EventId>,
789}
790
791impl CoverLetter {
792 pub fn get_branch_name(&self) -> Result<String> {
793 Ok(format!(
794 "pr/{}({})",
795 self.branch_name,
796 &self
797 .event_id
798 .context("proposal root event_id must be know to get it's branch name")?
799 .to_hex()
800 .as_str()[..8],
801 ))
802 }
803}
804pub fn event_is_cover_letter(event: &nostr::Event) -> bool {
805 // TODO: look for Subject:[ PATCH 0/n ] but watch out for:
806 // [PATCH v1 0/n ] or
807 // [PATCH subsystem v2 0/n ]
808 event.kind.eq(&Kind::GitPatch)
809 && event.tags().iter().any(|t| t.as_vec()[1].eq("root"))
810 && event
811 .tags()
812 .iter()
813 .any(|t| t.as_vec()[1].eq("cover-letter"))
814}
815
816pub fn commit_msg_from_patch(patch: &nostr::Event) -> Result<String> {
817 if let Ok(msg) = tag_value(patch, "description") {
818 Ok(msg)
819 } else {
820 let start_index = patch
821 .content
822 .find("] ")
823 .context("event is not formatted as a patch or cover letter")?
824 + 2;
825 let end_index = patch.content[start_index..]
826 .find("\ndiff --git")
827 .unwrap_or(patch.content.len());
828 Ok(patch.content[start_index..end_index].to_string())
829 }
830}
831
832pub fn commit_msg_from_patch_oneliner(patch: &nostr::Event) -> Result<String> {
833 Ok(commit_msg_from_patch(patch)?
834 .split('\n')
835 .collect::<Vec<&str>>()[0]
836 .to_string())
837}
838
839pub fn event_to_cover_letter(event: &nostr::Event) -> Result<CoverLetter> {
840 if !event_is_patch_set_root(event) {
841 bail!("event is not a patch set root event (root patch or cover letter)")
842 }
843
844 let title = commit_msg_from_patch_oneliner(event)?;
845 let full = commit_msg_from_patch(event)?;
846 let description = full[title.len()..].trim().to_string();
847
848 Ok(CoverLetter {
849 title: title.clone(),
850 description,
851 // TODO should this be prefixed by format!("{}-"e.id.to_string()[..5]?)
852 branch_name: if let Ok(name) = match tag_value(event, "branch-name") {
853 Ok(name) => {
854 if !name.eq("main") && !name.eq("master") {
855 Ok(name)
856 } else {
857 Err(())
858 }
859 }
860 _ => Err(()),
861 } {
862 name
863 } else {
864 let s = title
865 .replace(' ', "-")
866 .chars()
867 .map(|c| {
868 if c.is_ascii_alphanumeric() || c.eq(&'/') {
869 c
870 } else {
871 '-'
872 }
873 })
874 .collect();
875 s
876 },
877 event_id: Some(event.id()),
878 })
879}
880
881pub fn event_is_patch_set_root(event: &nostr::Event) -> bool {
882 event.kind.eq(&Kind::GitPatch) && event.tags().iter().any(|t| t.as_vec()[1].eq("root"))
883}
884
885pub fn event_is_revision_root(event: &nostr::Event) -> bool {
886 event.kind.eq(&Kind::GitPatch)
887 && event
888 .tags()
889 .iter()
890 .any(|t| t.as_vec()[1].eq("revision-root"))
891}
892
893pub fn patch_supports_commit_ids(event: &nostr::Event) -> bool {
894 event.kind.eq(&Kind::GitPatch)
895 && event
896 .tags()
897 .iter()
898 .any(|t| t.as_vec()[0].eq("commit-pgp-sig"))
899}
900
901#[allow(clippy::too_many_arguments)]
902#[allow(clippy::too_many_lines)]
903pub async fn generate_patch_event(
904 git_repo: &Repo,
905 root_commit: &Sha1Hash,
906 commit: &Sha1Hash,
907 thread_event_id: Option<nostr::EventId>,
908 signer: &nostr_sdk::NostrSigner,
909 repo_ref: &RepoRef,
910 parent_patch_event_id: Option<nostr::EventId>,
911 series_count: Option<(u64, u64)>,
912 branch_name: Option<String>,
913 root_proposal_id: &Option<String>,
914 mentions: &[nostr::Tag],
915) -> Result<nostr::Event> {
916 let commit_parent = git_repo
917 .get_commit_parent(commit)
918 .context("failed to get parent commit")?;
919 let relay_hint = repo_ref.relays.first().map(nostr::UncheckedUrl::from);
920
921 sign_event(
922 EventBuilder::new(
923 nostr::event::Kind::GitPatch,
924 git_repo
925 .make_patch_from_commit(commit, &series_count)
926 .context(format!("cannot make patch for commit {commit}"))?,
927 [
928 repo_ref
929 .maintainers
930 .iter()
931 .map(|m| {
932 Tag::coordinate(Coordinate {
933 kind: nostr::Kind::GitRepoAnnouncement,
934 public_key: *m,
935 identifier: repo_ref.identifier.to_string(),
936 relays: repo_ref.relays.clone(),
937 })
938 })
939 .collect::<Vec<Tag>>(),
940 vec![
941 Tag::from_standardized(TagStandard::Reference(root_commit.to_string())),
942 // commit id reference is a trade-off. its now
943 // unclear which one is the root commit id but it
944 // enables easier location of code comments againt
945 // code that makes it into the main branch, assuming
946 // the commit id is correct
947 Tag::from_standardized(TagStandard::Reference(commit.to_string())),
948 Tag::custom(
949 TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
950 vec![format!(
951 "git patch: {}",
952 git_repo
953 .get_commit_message_summary(commit)
954 .unwrap_or_default()
955 )],
956 ),
957 ],
958 if let Some(thread_event_id) = thread_event_id {
959 vec![Tag::from_standardized(nostr_sdk::TagStandard::Event {
960 event_id: thread_event_id,
961 relay_url: relay_hint.clone(),
962 marker: Some(Marker::Root),
963 public_key: None,
964 })]
965 } else if let Some(event_ref) = root_proposal_id.clone() {
966 vec![
967 Tag::hashtag("root"),
968 Tag::hashtag("revision-root"),
969 // TODO check if id is for a root proposal (perhaps its for an issue?)
970 event_tag_from_nip19_or_hex(
971 &event_ref,
972 "proposal",
973 Marker::Reply,
974 false,
975 false,
976 )?,
977 ]
978 } else {
979 vec![Tag::hashtag("root")]
980 },
981 mentions.to_vec(),
982 if let Some(id) = parent_patch_event_id {
983 vec![Tag::from_standardized(nostr_sdk::TagStandard::Event {
984 event_id: id,
985 relay_url: relay_hint.clone(),
986 marker: Some(Marker::Reply),
987 public_key: None,
988 })]
989 } else {
990 vec![]
991 },
992 // see comment on branch names in cover letter event creation
993 if let Some(branch_name) = branch_name {
994 if thread_event_id.is_none() {
995 vec![Tag::custom(
996 TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")),
997 vec![branch_name.to_string()],
998 )]
999 } else {
1000 vec![]
1001 }
1002 } else {
1003 vec![]
1004 },
1005 // whilst it is in nip34 draft to tag the maintainers
1006 // I'm not sure it is a good idea because if they are
1007 // interested in all patches then their specialised
1008 // client should subscribe to patches tagged with the
1009 // repo reference. maintainers of large repos will not
1010 // be interested in every patch.
1011 repo_ref
1012 .maintainers
1013 .iter()
1014 .map(|pk| Tag::public_key(*pk))
1015 .collect(),
1016 vec![
1017 // a fallback is now in place to extract this from the patch
1018 Tag::custom(
1019 TagKind::Custom(std::borrow::Cow::Borrowed("commit")),
1020 vec![commit.to_string()],
1021 ),
1022 // this is required as patches cannot be relied upon to include the 'base
1023 // commit'
1024 Tag::custom(
1025 TagKind::Custom(std::borrow::Cow::Borrowed("parent-commit")),
1026 vec![commit_parent.to_string()],
1027 ),
1028 // this is required to ensure the commit id matches
1029 Tag::custom(
1030 TagKind::Custom(std::borrow::Cow::Borrowed("commit-pgp-sig")),
1031 vec![
1032 git_repo
1033 .extract_commit_pgp_signature(commit)
1034 .unwrap_or_default(),
1035 ],
1036 ),
1037 // removing description tag will not cause anything to break
1038 Tag::from_standardized(nostr_sdk::TagStandard::Description(
1039 git_repo.get_commit_message(commit)?.to_string(),
1040 )),
1041 Tag::custom(
1042 TagKind::Custom(std::borrow::Cow::Borrowed("author")),
1043 git_repo.get_commit_author(commit)?,
1044 ),
1045 // this is required to ensure the commit id matches
1046 Tag::custom(
1047 TagKind::Custom(std::borrow::Cow::Borrowed("committer")),
1048 git_repo.get_commit_comitter(commit)?,
1049 ),
1050 ],
1051 ]
1052 .concat(),
1053 ),
1054 signer,
1055 )
1056 .await
1057 .context("failed to sign event")
1058}
1059// TODO
1060// - find profile
1061// - file relays
1062// - find repo events
1063// -
1064
1065/**
1066 * returns `(from_branch,to_branch,ahead,behind)`
1067 */
1068pub fn identify_ahead_behind(
1069 git_repo: &Repo,
1070 from_branch: &Option<String>,
1071 to_branch: &Option<String>,
1072) -> Result<(String, String, Vec<Sha1Hash>, Vec<Sha1Hash>)> {
1073 let (from_branch, from_tip) = match from_branch {
1074 Some(name) => (
1075 name.to_string(),
1076 git_repo
1077 .get_tip_of_branch(name)
1078 .context(format!("cannot find from_branch '{name}'"))?,
1079 ),
1080 None => (
1081 if let Ok(name) = git_repo.get_checked_out_branch_name() {
1082 name
1083 } else {
1084 "head".to_string()
1085 },
1086 git_repo
1087 .get_head_commit()
1088 .context("failed to get head commit")
1089 .context(
1090 "checkout a commit or specify a from_branch. head does not reveal a commit",
1091 )?,
1092 ),
1093 };
1094
1095 let (to_branch, to_tip) = match to_branch {
1096 Some(name) => (
1097 name.to_string(),
1098 git_repo
1099 .get_tip_of_branch(name)
1100 .context(format!("cannot find to_branch '{name}'"))?,
1101 ),
1102 None => {
1103 let (name, commit) = git_repo
1104 .get_main_or_master_branch()
1105 .context("the default branches (main or master) do not exist")?;
1106 (name.to_string(), commit)
1107 }
1108 };
1109
1110 match git_repo.get_commits_ahead_behind(&to_tip, &from_tip) {
1111 Err(e) => {
1112 if e.to_string().contains("is not an ancestor of") {
1113 return Err(e).context(format!(
1114 "'{from_branch}' is not branched from '{to_branch}'"
1115 ));
1116 }
1117 Err(e).context(format!(
1118 "failed to get commits ahead and behind from '{from_branch}' to '{to_branch}'"
1119 ))
1120 }
1121 Ok((ahead, behind)) => Ok((from_branch, to_branch, ahead, behind)),
1122 }
1123}
1124
1125#[cfg(test)]
1126mod tests {
1127 use test_utils::git::GitTestRepo;
1128
1129 use super::*;
1130 mod identify_ahead_behind {
1131
1132 use super::*;
1133 use crate::git::oid_to_sha1;
1134
1135 #[test]
1136 fn when_from_branch_doesnt_exist_return_error() -> Result<()> {
1137 let test_repo = GitTestRepo::default();
1138 let git_repo = Repo::from_path(&test_repo.dir)?;
1139
1140 test_repo.populate()?;
1141 let branch_name = "doesnt_exist";
1142 assert_eq!(
1143 identify_ahead_behind(&git_repo, &Some(branch_name.to_string()), &None)
1144 .unwrap_err()
1145 .to_string(),
1146 format!("cannot find from_branch '{}'", &branch_name),
1147 );
1148 Ok(())
1149 }
1150
1151 #[test]
1152 fn when_to_branch_doesnt_exist_return_error() -> Result<()> {
1153 let test_repo = GitTestRepo::default();
1154 let git_repo = Repo::from_path(&test_repo.dir)?;
1155
1156 test_repo.populate()?;
1157 let branch_name = "doesnt_exist";
1158 assert_eq!(
1159 identify_ahead_behind(&git_repo, &None, &Some(branch_name.to_string()))
1160 .unwrap_err()
1161 .to_string(),
1162 format!("cannot find to_branch '{}'", &branch_name),
1163 );
1164 Ok(())
1165 }
1166
1167 #[test]
1168 fn when_to_branch_is_none_and_no_main_or_master_branch_return_error() -> Result<()> {
1169 let test_repo = GitTestRepo::new("notmain")?;
1170 let git_repo = Repo::from_path(&test_repo.dir)?;
1171
1172 test_repo.populate()?;
1173
1174 assert_eq!(
1175 identify_ahead_behind(&git_repo, &None, &None)
1176 .unwrap_err()
1177 .to_string(),
1178 "the default branches (main or master) do not exist",
1179 );
1180 Ok(())
1181 }
1182
1183 #[test]
1184 fn when_from_branch_is_not_head_return_as_from_branch() -> Result<()> {
1185 let test_repo = GitTestRepo::default();
1186 let git_repo = Repo::from_path(&test_repo.dir)?;
1187
1188 test_repo.populate()?;
1189 // create feature branch with 1 commit ahead
1190 test_repo.create_branch("feature")?;
1191 test_repo.checkout("feature")?;
1192 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
1193 let head_oid = test_repo.stage_and_commit("add t3.md")?;
1194
1195 // make feature branch 1 commit behind
1196 test_repo.checkout("main")?;
1197 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
1198 let main_oid = test_repo.stage_and_commit("add t4.md")?;
1199
1200 let (from_branch, to_branch, ahead, behind) =
1201 identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?;
1202
1203 assert_eq!(from_branch, "feature");
1204 assert_eq!(ahead, vec![oid_to_sha1(&head_oid)]);
1205 assert_eq!(to_branch, "main");
1206 assert_eq!(behind, vec![oid_to_sha1(&main_oid)]);
1207 Ok(())
1208 }
1209
1210 #[test]
1211 fn when_to_branch_is_not_main_return_as_to_branch() -> Result<()> {
1212 let test_repo = GitTestRepo::default();
1213 let git_repo = Repo::from_path(&test_repo.dir)?;
1214
1215 test_repo.populate()?;
1216 // create dev branch with 1 commit ahead
1217 test_repo.create_branch("dev")?;
1218 test_repo.checkout("dev")?;
1219 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
1220 let dev_oid_first = test_repo.stage_and_commit("add t3.md")?;
1221
1222 // create feature branch with 1 commit ahead of dev
1223 test_repo.create_branch("feature")?;
1224 test_repo.checkout("feature")?;
1225 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
1226 let feature_oid = test_repo.stage_and_commit("add t4.md")?;
1227
1228 // make feature branch 1 behind
1229 test_repo.checkout("dev")?;
1230 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
1231 let dev_oid = test_repo.stage_and_commit("add t3.md")?;
1232
1233 let (from_branch, to_branch, ahead, behind) = identify_ahead_behind(
1234 &git_repo,
1235 &Some("feature".to_string()),
1236 &Some("dev".to_string()),
1237 )?;
1238
1239 assert_eq!(from_branch, "feature");
1240 assert_eq!(ahead, vec![oid_to_sha1(&feature_oid)]);
1241 assert_eq!(to_branch, "dev");
1242 assert_eq!(behind, vec![oid_to_sha1(&dev_oid)]);
1243
1244 let (from_branch, to_branch, ahead, behind) =
1245 identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?;
1246
1247 assert_eq!(from_branch, "feature");
1248 assert_eq!(
1249 ahead,
1250 vec![oid_to_sha1(&feature_oid), oid_to_sha1(&dev_oid_first)]
1251 );
1252 assert_eq!(to_branch, "main");
1253 assert_eq!(behind, vec![]);
1254
1255 Ok(())
1256 }
1257 }
1258
1259 mod event_to_cover_letter {
1260 use super::*;
1261
1262 fn generate_cover_letter(title: &str, description: &str) -> Result<nostr::Event> {
1263 Ok(nostr::event::EventBuilder::new(
1264 nostr::event::Kind::GitPatch,
1265 format!("From ea897e987ea9a7a98e7a987e97987ea98e7a3334 Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/2] {title}\n\n{description}"),
1266 [
1267 Tag::hashtag("cover-letter"),
1268 Tag::hashtag("root"),
1269 ],
1270 )
1271 .to_event(&nostr::Keys::generate())?)
1272 }
1273
1274 #[test]
1275 fn basic_title() -> Result<()> {
1276 assert_eq!(
1277 event_to_cover_letter(&generate_cover_letter("the title", "description here")?)?
1278 .title,
1279 "the title",
1280 );
1281 Ok(())
1282 }
1283
1284 #[test]
1285 fn basic_description() -> Result<()> {
1286 assert_eq!(
1287 event_to_cover_letter(&generate_cover_letter("the title", "description here")?)?
1288 .description,
1289 "description here",
1290 );
1291 Ok(())
1292 }
1293
1294 #[test]
1295 fn description_trimmed() -> Result<()> {
1296 assert_eq!(
1297 event_to_cover_letter(&generate_cover_letter(
1298 "the title",
1299 " \n \ndescription here\n\n "
1300 )?)?
1301 .description,
1302 "description here",
1303 );
1304 Ok(())
1305 }
1306
1307 #[test]
1308 fn multi_line_description() -> Result<()> {
1309 assert_eq!(
1310 event_to_cover_letter(&generate_cover_letter(
1311 "the title",
1312 "description here\n\nmore here\nmore"
1313 )?)?
1314 .description,
1315 "description here\n\nmore here\nmore",
1316 );
1317 Ok(())
1318 }
1319
1320 #[test]
1321 fn new_lines_in_title_forms_part_of_description() -> Result<()> {
1322 assert_eq!(
1323 event_to_cover_letter(&generate_cover_letter(
1324 "the title\nwith new line",
1325 "description here\n\nmore here\nmore"
1326 )?)?
1327 .title,
1328 "the title",
1329 );
1330 assert_eq!(
1331 event_to_cover_letter(&generate_cover_letter(
1332 "the title\nwith new line",
1333 "description here\n\nmore here\nmore"
1334 )?)?
1335 .description,
1336 "with new line\n\ndescription here\n\nmore here\nmore",
1337 );
1338 Ok(())
1339 }
1340
1341 mod blank_description {
1342 use super::*;
1343
1344 #[test]
1345 fn title_correct() -> Result<()> {
1346 assert_eq!(
1347 event_to_cover_letter(&generate_cover_letter("the title", "")?)?.title,
1348 "the title",
1349 );
1350 Ok(())
1351 }
1352
1353 #[test]
1354 fn description_is_empty_string() -> Result<()> {
1355 assert_eq!(
1356 event_to_cover_letter(&generate_cover_letter("the title", "")?)?.description,
1357 "",
1358 );
1359 Ok(())
1360 }
1361 }
1362 }
1363}