upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/sub_commands/prs
diff options
context:
space:
mode:
Diffstat (limited to 'src/sub_commands/prs')
-rw-r--r--src/sub_commands/prs/create.rs953
-rw-r--r--src/sub_commands/prs/list.rs267
-rw-r--r--src/sub_commands/prs/mod.rs25
3 files changed, 0 insertions, 1245 deletions
diff --git a/src/sub_commands/prs/create.rs b/src/sub_commands/prs/create.rs
deleted file mode 100644
index 35e29d3..0000000
--- a/src/sub_commands/prs/create.rs
+++ /dev/null
@@ -1,953 +0,0 @@
1use std::time::Duration;
2
3use anyhow::{bail, Context, Result};
4use futures::future::join_all;
5use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
6use nostr::{prelude::sha1::Hash as Sha1Hash, EventBuilder, Marker, Tag, TagKind};
7
8use super::list::tag_value;
9#[cfg(not(test))]
10use crate::client::Client;
11#[cfg(test)]
12use crate::client::MockConnect;
13use crate::{
14 cli_interactor::{Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms},
15 client::Connect,
16 git::{Repo, RepoActions},
17 login,
18 repo_ref::{self, RepoRef, REPO_REF_KIND},
19 Cli,
20};
21
22#[derive(Debug, clap::Args)]
23pub struct SubCommandArgs {
24 #[clap(short, long)]
25 /// optional cover letter title
26 title: Option<String>,
27 #[clap(short, long)]
28 /// optional cover letter description
29 description: Option<String>,
30 #[clap(long)]
31 /// branch to get changes from (defaults to head)
32 from_branch: Option<String>,
33 #[clap(long)]
34 /// destination branch (defaults to main or master)
35 to_branch: Option<String>,
36 /// don't ask about a cover letter
37 #[arg(long, action)]
38 no_cover_letter: bool,
39}
40
41#[allow(clippy::too_many_lines)]
42pub async fn launch(
43 cli_args: &Cli,
44 _pr_args: &super::SubCommandArgs,
45 args: &SubCommandArgs,
46) -> Result<()> {
47 let git_repo = Repo::discover().context("cannot find a git repository")?;
48
49 let (from_branch, to_branch, mut ahead, behind) =
50 identify_ahead_behind(&git_repo, &args.from_branch, &args.to_branch)?;
51
52 if ahead.is_empty() {
53 bail!(format!(
54 "'{from_branch}' is 0 commits ahead of '{to_branch}' so no patches were created"
55 ));
56 }
57
58 if behind.is_empty() {
59 println!(
60 "creating patch for {} commits from '{from_branch}' that can be merged into '{to_branch}'",
61 ahead.len(),
62 );
63 } else {
64 if !Interactor::default().confirm(
65 PromptConfirmParms::default()
66 .with_prompt(
67 format!(
68 "'{from_branch}' is {} commits behind '{to_branch}' and {} ahead. Consider rebasing before sending patches. Proceed anyway?",
69 behind.len(),
70 ahead.len(),
71 )
72 )
73 .with_default(false)
74 ).context("failed to get confirmation response from interactor confirm")? {
75 bail!("aborting so branch can be rebased");
76 }
77 println!(
78 "creating patch for {} commit{} from '{from_branch}' that {} {} behind '{to_branch}'",
79 ahead.len(),
80 if ahead.len() > 1 { "s" } else { "" },
81 if ahead.len() > 1 { "are" } else { "is" },
82 behind.len(),
83 );
84 }
85
86 let title = if args.no_cover_letter {
87 None
88 } else {
89 match &args.title {
90 Some(t) => Some(t.clone()),
91 None => {
92 if Interactor::default().confirm(
93 PromptConfirmParms::default()
94 .with_default(false)
95 .with_prompt("include cover letter?"),
96 )? {
97 Some(
98 Interactor::default()
99 .input(PromptInputParms::default().with_prompt("title"))?
100 .clone(),
101 )
102 } else {
103 None
104 }
105 }
106 }
107 };
108
109 let cover_letter_title_description = if let Some(title) = title {
110 Some((
111 title,
112 if let Some(t) = &args.description {
113 t.clone()
114 } else {
115 Interactor::default()
116 .input(PromptInputParms::default().with_prompt("cover letter description"))?
117 .clone()
118 },
119 ))
120 } else {
121 None
122 };
123
124 #[cfg(not(test))]
125 let mut client = Client::default();
126 #[cfg(test)]
127 let mut client = <MockConnect as std::default::Default>::default();
128
129 let (keys, user_ref) = login::launch(&cli_args.nsec, &cli_args.password, Some(&client)).await?;
130
131 client.set_keys(&keys).await;
132
133 let repo_ref = repo_ref::fetch(
134 &git_repo,
135 git_repo
136 .get_root_commit()
137 .context("failed to get root commit of the repository")?
138 .to_string(),
139 &client,
140 user_ref.relays.write(),
141 )
142 .await?;
143
144 // oldest first
145 ahead.reverse();
146
147 let events = generate_pr_and_patch_events(
148 cover_letter_title_description.clone(),
149 &git_repo,
150 &ahead,
151 &keys,
152 &repo_ref,
153 )?;
154
155 println!(
156 "posting {} patches {} a covering letter...",
157 if cover_letter_title_description.is_none() {
158 events.len()
159 } else {
160 events.len() - 1
161 },
162 if cover_letter_title_description.is_none() {
163 "without"
164 } else {
165 "with"
166 }
167 );
168
169 send_events(
170 &client,
171 events,
172 user_ref.relays.write(),
173 repo_ref.relays.clone(),
174 !cli_args.disable_cli_spinners,
175 )
176 .await?;
177 // TODO check if there is already a similarly named
178 Ok(())
179}
180
181pub async fn send_events(
182 #[cfg(test)] client: &crate::client::MockConnect,
183 #[cfg(not(test))] client: &Client,
184 events: Vec<nostr::Event>,
185 my_write_relays: Vec<String>,
186 repo_read_relays: Vec<String>,
187 animate: bool,
188) -> Result<()> {
189 let (_, _, _, all) = unique_and_duplicate_all(&my_write_relays, &repo_read_relays);
190
191 let m = MultiProgress::new();
192 let pb_style = ProgressStyle::with_template(if animate {
193 " {spinner} {prefix} {bar} {pos}/{len} {msg}"
194 } else {
195 " - {prefix} {bar} {pos}/{len} {msg}"
196 })?
197 .progress_chars("##-");
198
199 let pb_after_style =
200 |symbol| ProgressStyle::with_template(format!(" {symbol} {}", "{prefix} {msg}",).as_str());
201 let pb_after_style_succeeded = pb_after_style(if animate {
202 console::style("✔".to_string())
203 .for_stderr()
204 .green()
205 .to_string()
206 } else {
207 "y".to_string()
208 })?;
209
210 let pb_after_style_failed = pb_after_style(if animate {
211 console::style("✘".to_string())
212 .for_stderr()
213 .red()
214 .to_string()
215 } else {
216 "x".to_string()
217 })?;
218
219 join_all(all.iter().map(|&relay| async {
220 let details = format!(
221 "{}{} {}",
222 if my_write_relays.iter().any(|r| relay.eq(r)) {
223 " [my-relay]"
224 } else {
225 ""
226 },
227 if repo_read_relays.iter().any(|r| relay.eq(r)) {
228 " [repo-relay]"
229 } else {
230 ""
231 },
232 *relay,
233 );
234 let pb = m.add(
235 ProgressBar::new(events.len() as u64)
236 .with_prefix(details.to_string())
237 .with_style(pb_style.clone()),
238 );
239 if animate {
240 pb.enable_steady_tick(Duration::from_millis(300));
241 }
242 pb.inc(0); // need to make pb display intially
243 let mut failed = false;
244 for event in &events {
245 match client.send_event_to(relay.as_str(), event.clone()).await {
246 Ok(_) => pb.inc(1),
247 Err(e) => {
248 pb.set_style(pb_after_style_failed.clone());
249 pb.finish_with_message(
250 console::style(
251 e.to_string()
252 .replace("relay pool error:", "error:")
253 .replace("event not published: ", ""),
254 )
255 .for_stderr()
256 .red()
257 .to_string(),
258 );
259 failed = true;
260 break;
261 }
262 };
263 }
264 if !failed {
265 pb.set_style(pb_after_style_succeeded.clone());
266 pb.finish_with_message("");
267 }
268 }))
269 .await;
270 Ok(())
271}
272
273/// returns `(unique_vec1, unique_vec2, duplicates, all)`
274fn unique_and_duplicate_all<'a, S>(
275 vec1: &'a Vec<S>,
276 vec2: &'a Vec<S>,
277) -> (Vec<&'a S>, Vec<&'a S>, Vec<&'a S>, Vec<&'a S>)
278where
279 S: PartialEq,
280{
281 let mut vec1_u = vec![];
282 let mut vec2_u = vec![];
283 let mut dup = vec![];
284 let mut all = vec![];
285 for s1 in vec1 {
286 if vec2.iter().any(|s2| s1.eq(s2)) {
287 dup.push(s1);
288 } else {
289 vec1_u.push(s1);
290 }
291 }
292 for s2 in vec2 {
293 if !vec1.iter().any(|s1| s2.eq(s1)) {
294 vec2_u.push(s2);
295 }
296 }
297 for a in [&dup, &vec1_u, &vec2_u] {
298 for e in a {
299 all.push(&**e);
300 }
301 }
302 (vec1_u, vec2_u, dup, all)
303}
304
305mod tests_unique_and_duplicate {
306
307 #[test]
308 fn correct_number_of_unique_and_duplicate_items() {
309 let v1 = vec![
310 "t1".to_string(),
311 "t2".to_string(),
312 "t3".to_string(),
313 "t4".to_string(),
314 "t5".to_string(),
315 ];
316 let v2 = vec![
317 "t3".to_string(),
318 "t4".to_string(),
319 "t5".to_string(),
320 "t6".to_string(),
321 ];
322
323 let (v1_u, v2_u, d, a) = super::unique_and_duplicate_all(&v1, &v2);
324
325 assert_eq!(v1_u.len(), 2);
326 assert_eq!(v2_u.len(), 1);
327 assert_eq!(d.len(), 3);
328 assert_eq!(a.len(), 6);
329 }
330 #[test]
331 fn all_begins_with_duplicates() {
332 let v1 = vec![
333 "t1".to_string(),
334 "t2".to_string(),
335 "t3".to_string(),
336 "t4".to_string(),
337 "t5".to_string(),
338 ];
339 let v2 = vec![
340 "t3".to_string(),
341 "t4".to_string(),
342 "t5".to_string(),
343 "t6".to_string(),
344 ];
345
346 let (_, _, d, a) = super::unique_and_duplicate_all(&v1, &v2);
347
348 assert_eq!(a[0], d[0]);
349 }
350}
351
352pub static PATCH_KIND: u64 = 1617;
353
354pub fn generate_pr_and_patch_events(
355 cover_letter_title_description: Option<(String, String)>,
356 git_repo: &Repo,
357 commits: &Vec<Sha1Hash>,
358 keys: &nostr::Keys,
359 repo_ref: &RepoRef,
360) -> Result<Vec<nostr::Event>> {
361 let root_commit = git_repo
362 .get_root_commit()
363 .context("failed to get root commit of the repository")?;
364
365 let mut events = vec![];
366
367 if let Some((title, description)) = cover_letter_title_description {
368 events.push(EventBuilder::new(
369 nostr::event::Kind::Custom(PATCH_KIND),
370 format!(
371 "From {} Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/{}] {title}\n\n{description}",
372 commits.last().unwrap(),
373 commits.len()
374 ),
375 [
376 vec![
377 // TODO: why not tag all maintainer identifiers?
378 Tag::A {
379 kind: nostr::Kind::Custom(REPO_REF_KIND),
380 public_key: *repo_ref.maintainers.first()
381 .context("repo reference should always have at least one maintainer - the issuer of the repo event")
382 ?,
383 identifier: repo_ref.identifier.to_string(),
384 relay_url: repo_ref.relays.first().map(nostr::UncheckedUrl::from).clone(),
385 },
386 Tag::Reference(format!("{root_commit}")),
387 Tag::Hashtag("cover-letter".to_string()),
388 Tag::Hashtag("root".to_string()),
389 ],
390 if let Ok(branch_name) = git_repo.get_checked_out_branch_name() {
391 vec![Tag::Generic(
392 TagKind::Custom("branch-name".to_string()),
393 vec![branch_name],
394 )]
395 } else {
396 vec![]
397 },
398 repo_ref.maintainers
399 .iter()
400 .map(|pk| Tag::public_key(*pk))
401 .collect(),
402 ].concat(),
403 )
404 .to_event(keys)
405 .context("failed to create cover-letter event")?);
406 }
407
408 for (i, commit) in commits.iter().enumerate() {
409 events.push(
410 generate_patch_event(
411 git_repo,
412 &root_commit,
413 commit,
414 events.first().map(|event| event.id),
415 keys,
416 repo_ref,
417 events.last().map(nostr::Event::id),
418 if events.is_empty() {
419 None
420 } else {
421 Some(((i + 1).try_into()?, commits.len().try_into()?))
422 },
423 if events.is_empty() {
424 if let Ok(branch_name) = git_repo.get_checked_out_branch_name() {
425 Some(branch_name)
426 } else {
427 None
428 }
429 } else {
430 None
431 },
432 )
433 .context("failed to generate patch event")?,
434 );
435 }
436 Ok(events)
437}
438
439pub struct CoverLetter {
440 pub title: String,
441 pub description: String,
442 pub branch_name: String,
443}
444
445pub fn event_is_cover_letter(event: &nostr::Event) -> bool {
446 // TODO: look for Subject:[ PATCH 0/n ] but watch out for:
447 // [PATCH v1 0/n ] or
448 // [PATCH subsystem v2 0/n ]
449 event.kind.as_u64().eq(&PATCH_KIND)
450 && event.iter_tags().any(|t| t.as_vec()[1].eq("root"))
451 && event.iter_tags().any(|t| t.as_vec()[1].eq("cover-letter"))
452}
453pub fn event_to_cover_letter(event: &nostr::Event) -> Result<CoverLetter> {
454 if !event_is_patch_set_root(event) {
455 bail!("event is not a patch set root event (root patch or cover letter)")
456 }
457 let title_index = event
458 .content
459 .find("] ")
460 .context("event is not formatted as a patch or cover letter")?
461 + 2;
462 let description_index = event.content[title_index..]
463 .find('\n')
464 .unwrap_or(event.content.len() - 1 - title_index)
465 + title_index;
466
467 let title = if let Ok(msg) = tag_value(event, "description") {
468 msg.split('\n').collect::<Vec<&str>>()[0].to_string()
469 } else {
470 event.content[title_index..description_index].to_string()
471 };
472
473 // note: if the description field is removed from patch events like in gitstr,
474 // then this will show entire patch. I'm not sure it is ever displayed though
475 let description = if let Ok(msg) = tag_value(event, "description") {
476 if let Some((_before, after)) = msg.split_once('\n') {
477 after.trim().to_string()
478 } else {
479 String::new()
480 }
481 } else {
482 event.content[description_index..].trim().to_string()
483 };
484
485 Ok(CoverLetter {
486 title: title.clone(),
487 description,
488 // TODO should this be prefixed by format!("{}-"e.id.to_string()[..5]?)
489 branch_name: if let Ok(name) = tag_value(event, "branch-name") {
490 name
491 } else {
492 let s = title
493 .replace(' ', "-")
494 .chars()
495 .map(|c| {
496 if c.is_ascii_alphanumeric() || c.eq(&'/') {
497 c
498 } else {
499 '-'
500 }
501 })
502 .collect();
503 s
504 },
505 })
506}
507
508pub fn event_is_patch_set_root(event: &nostr::Event) -> bool {
509 event.kind.as_u64().eq(&PATCH_KIND) && event.iter_tags().any(|t| t.as_vec()[1].eq("root"))
510}
511
512#[allow(clippy::too_many_arguments)]
513pub fn generate_patch_event(
514 git_repo: &Repo,
515 root_commit: &Sha1Hash,
516 commit: &Sha1Hash,
517 thread_event_id: Option<nostr::EventId>,
518 keys: &nostr::Keys,
519 repo_ref: &RepoRef,
520 parent_patch_event_id: Option<nostr::EventId>,
521 series_count: Option<(u64, u64)>,
522 branch_name: Option<String>,
523) -> Result<nostr::Event> {
524 let commit_parent = git_repo
525 .get_commit_parent(commit)
526 .context("failed to get parent commit")?;
527 let relay_hint = repo_ref.relays.first().map(nostr::UncheckedUrl::from);
528 EventBuilder::new(
529 nostr::event::Kind::Custom(PATCH_KIND),
530 git_repo
531 .make_patch_from_commit(commit,&series_count)
532 .context(format!("cannot make patch for commit {commit}"))?,
533 [
534 vec![
535 Tag::A {
536 kind: nostr::Kind::Custom(REPO_REF_KIND),
537 public_key: *repo_ref.maintainers.first()
538 .context("repo reference should always have at least one maintainer - the issuer of the repo event")
539 ?,
540 identifier: repo_ref.identifier.to_string(),
541 relay_url: relay_hint.clone(),
542 },
543 Tag::Reference(format!("{root_commit}")),
544 // commit id reference is a trade-off. its now
545 // unclear which one is the root commit id but it
546 // enables easier location of code comments againt
547 // code that makes it into the main branch, assuming
548 // the commit id is correct
549 Tag::Reference(commit.to_string()),
550
551 if let Some(thread_event_id) = thread_event_id { Tag::Event {
552 event_id: thread_event_id,
553 relay_url: relay_hint.clone(),
554 marker: Some(Marker::Root),
555 } }
556 else {
557 Tag::Hashtag("root".to_string())
558 },
559 ],
560 if let Some(id) = parent_patch_event_id {
561 vec![Tag::Event {
562 event_id: id,
563 relay_url: relay_hint.clone(),
564 marker: Some(Marker::Reply),
565 }]
566 } else {
567 vec![]
568 },
569 if let Some(branch_name) = branch_name {
570 if thread_event_id.is_none() {
571 vec![
572 Tag::Generic(
573 TagKind::Custom("branch-name".to_string()),
574 vec![branch_name.to_string()],
575 )
576 ]
577 }
578 else { vec![]}
579 }
580 else { vec![]},
581 // whilst it is in nip34 draft to tag the maintainers
582 // I'm not sure it is a good idea because if they are
583 // interested in all patches then their specialised
584 // client should subscribe to patches tagged with the
585 // repo reference. maintainers of large repos will not
586 // be interested in every patch.
587 repo_ref.maintainers
588 .iter()
589 .map(|pk| Tag::public_key(*pk))
590 .collect(),
591 vec![
592 Tag::Generic(
593 TagKind::Custom("commit".to_string()),
594 vec![commit.to_string()],
595 ),
596 Tag::Generic(
597 TagKind::Custom("parent-commit".to_string()),
598 vec![commit_parent.to_string()],
599 ),
600 Tag::Generic(
601 TagKind::Custom("commit-pgp-sig".to_string()),
602 vec![
603 git_repo
604 .extract_commit_pgp_signature(commit)
605 .unwrap_or_default(),
606 ],
607 ),
608 Tag::Description(git_repo.get_commit_message(commit)?.to_string()),
609 Tag::Generic(
610 TagKind::Custom("author".to_string()),
611 git_repo.get_commit_author(commit)?,
612 ),
613 Tag::Generic(
614 TagKind::Custom("committer".to_string()),
615 git_repo.get_commit_comitter(commit)?,
616 ),
617 ],
618 ]
619 .concat(),
620 )
621 .to_event(keys)
622 .context("failed to sign event")
623}
624// TODO
625// - find profile
626// - file relays
627// - find repo events
628// -
629
630/**
631 * returns `(from_branch,to_branch,ahead,behind)`
632 */
633fn identify_ahead_behind(
634 git_repo: &Repo,
635 from_branch: &Option<String>,
636 to_branch: &Option<String>,
637) -> Result<(String, String, Vec<Sha1Hash>, Vec<Sha1Hash>)> {
638 let (from_branch, from_tip) = match from_branch {
639 Some(name) => (
640 name.to_string(),
641 git_repo
642 .get_tip_of_local_branch(name)
643 .context(format!("cannot find from_branch '{name}'"))?,
644 ),
645 None => (
646 "head".to_string(),
647 git_repo
648 .get_head_commit()
649 .context("failed to get head commit")
650 .context(
651 "checkout a commit or specify a from_branch. head does not reveal a commit",
652 )?,
653 ),
654 };
655
656 let (to_branch, to_tip) = match to_branch {
657 Some(name) => (
658 name.to_string(),
659 git_repo
660 .get_tip_of_local_branch(name)
661 .context(format!("cannot find to_branch '{name}'"))?,
662 ),
663 None => {
664 let (name, commit) = git_repo
665 .get_main_or_master_branch()
666 .context("a destination branch (to_branch) is not specified and the defaults (main or master) do not exist")?;
667 (name.to_string(), commit)
668 }
669 };
670
671 match git_repo.get_commits_ahead_behind(&to_tip, &from_tip) {
672 Err(e) => {
673 if e.to_string().contains("is not an ancestor of") {
674 return Err(e).context(format!(
675 "'{from_branch}' is not branched from '{to_branch}'"
676 ));
677 }
678 Err(e).context(format!(
679 "failed to get commits ahead and behind from '{from_branch}' to '{to_branch}'"
680 ))
681 }
682 Ok((ahead, behind)) => Ok((from_branch, to_branch, ahead, behind)),
683 }
684}
685
686#[cfg(test)]
687mod tests {
688 use test_utils::git::GitTestRepo;
689
690 use super::*;
691 mod identify_ahead_behind {
692
693 use super::*;
694 use crate::git::oid_to_sha1;
695
696 #[test]
697 fn when_from_branch_doesnt_exist_return_error() -> Result<()> {
698 let test_repo = GitTestRepo::default();
699 let git_repo = Repo::from_path(&test_repo.dir)?;
700
701 test_repo.populate()?;
702 let branch_name = "doesnt_exist";
703 assert_eq!(
704 identify_ahead_behind(&git_repo, &Some(branch_name.to_string()), &None)
705 .unwrap_err()
706 .to_string(),
707 format!("cannot find from_branch '{}'", &branch_name),
708 );
709 Ok(())
710 }
711
712 #[test]
713 fn when_to_branch_doesnt_exist_return_error() -> Result<()> {
714 let test_repo = GitTestRepo::default();
715 let git_repo = Repo::from_path(&test_repo.dir)?;
716
717 test_repo.populate()?;
718 let branch_name = "doesnt_exist";
719 assert_eq!(
720 identify_ahead_behind(&git_repo, &None, &Some(branch_name.to_string()))
721 .unwrap_err()
722 .to_string(),
723 format!("cannot find to_branch '{}'", &branch_name),
724 );
725 Ok(())
726 }
727
728 #[test]
729 fn when_to_branch_is_none_and_no_main_or_master_branch_return_error() -> Result<()> {
730 let test_repo = GitTestRepo::new("notmain")?;
731 let git_repo = Repo::from_path(&test_repo.dir)?;
732
733 test_repo.populate()?;
734
735 assert_eq!(
736 identify_ahead_behind(&git_repo, &None, &None)
737 .unwrap_err()
738 .to_string(),
739 "a destination branch (to_branch) is not specified and the defaults (main or master) do not exist",
740 );
741 Ok(())
742 }
743
744 #[test]
745 fn when_from_branch_is_none_return_as_head() -> Result<()> {
746 let test_repo = GitTestRepo::default();
747 let git_repo = Repo::from_path(&test_repo.dir)?;
748
749 test_repo.populate()?;
750 // create feature branch with 1 commit ahead
751 test_repo.create_branch("feature")?;
752 test_repo.checkout("feature")?;
753 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
754 let head_oid = test_repo.stage_and_commit("add t3.md")?;
755
756 // make feature branch 1 commit behind
757 test_repo.checkout("main")?;
758 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
759 let main_oid = test_repo.stage_and_commit("add t4.md")?;
760 // checkout feature
761 test_repo.checkout("feature")?;
762
763 let (from_branch, to_branch, ahead, behind) =
764 identify_ahead_behind(&git_repo, &None, &None)?;
765
766 assert_eq!(from_branch, "head");
767 assert_eq!(ahead, vec![oid_to_sha1(&head_oid)]);
768 assert_eq!(to_branch, "main");
769 assert_eq!(behind, vec![oid_to_sha1(&main_oid)]);
770 Ok(())
771 }
772
773 #[test]
774 fn when_from_branch_is_not_head_return_as_from_branch() -> Result<()> {
775 let test_repo = GitTestRepo::default();
776 let git_repo = Repo::from_path(&test_repo.dir)?;
777
778 test_repo.populate()?;
779 // create feature branch with 1 commit ahead
780 test_repo.create_branch("feature")?;
781 test_repo.checkout("feature")?;
782 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
783 let head_oid = test_repo.stage_and_commit("add t3.md")?;
784
785 // make feature branch 1 commit behind
786 test_repo.checkout("main")?;
787 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
788 let main_oid = test_repo.stage_and_commit("add t4.md")?;
789
790 let (from_branch, to_branch, ahead, behind) =
791 identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?;
792
793 assert_eq!(from_branch, "feature");
794 assert_eq!(ahead, vec![oid_to_sha1(&head_oid)]);
795 assert_eq!(to_branch, "main");
796 assert_eq!(behind, vec![oid_to_sha1(&main_oid)]);
797 Ok(())
798 }
799
800 #[test]
801 fn when_to_branch_is_not_main_return_as_to_branch() -> Result<()> {
802 let test_repo = GitTestRepo::default();
803 let git_repo = Repo::from_path(&test_repo.dir)?;
804
805 test_repo.populate()?;
806 // create dev branch with 1 commit ahead
807 test_repo.create_branch("dev")?;
808 test_repo.checkout("dev")?;
809 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
810 let dev_oid_first = test_repo.stage_and_commit("add t3.md")?;
811
812 // create feature branch with 1 commit ahead of dev
813 test_repo.create_branch("feature")?;
814 test_repo.checkout("feature")?;
815 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
816 let feature_oid = test_repo.stage_and_commit("add t4.md")?;
817
818 // make feature branch 1 behind
819 test_repo.checkout("dev")?;
820 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
821 let dev_oid = test_repo.stage_and_commit("add t3.md")?;
822
823 let (from_branch, to_branch, ahead, behind) = identify_ahead_behind(
824 &git_repo,
825 &Some("feature".to_string()),
826 &Some("dev".to_string()),
827 )?;
828
829 assert_eq!(from_branch, "feature");
830 assert_eq!(ahead, vec![oid_to_sha1(&feature_oid)]);
831 assert_eq!(to_branch, "dev");
832 assert_eq!(behind, vec![oid_to_sha1(&dev_oid)]);
833
834 let (from_branch, to_branch, ahead, behind) =
835 identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?;
836
837 assert_eq!(from_branch, "feature");
838 assert_eq!(
839 ahead,
840 vec![oid_to_sha1(&feature_oid), oid_to_sha1(&dev_oid_first)]
841 );
842 assert_eq!(to_branch, "main");
843 assert_eq!(behind, vec![]);
844
845 Ok(())
846 }
847 }
848
849 mod event_to_cover_letter {
850 use super::*;
851
852 fn generate_cover_letter(title: &str, description: &str) -> Result<nostr::Event> {
853 Ok(nostr::event::EventBuilder::new(
854 nostr::event::Kind::Custom(PATCH_KIND),
855 format!("From ea897e987ea9a7a98e7a987e97987ea98e7a3334 Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/2] {title}\n\n{description}"),
856 [
857 Tag::Hashtag("cover-letter".to_string()),
858 Tag::Hashtag("root".to_string()),
859 ],
860 )
861 .to_event(&nostr::Keys::generate())?)
862 }
863
864 #[test]
865 fn basic_title() -> Result<()> {
866 assert_eq!(
867 event_to_cover_letter(&generate_cover_letter("the title", "description here")?)?
868 .title,
869 "the title",
870 );
871 Ok(())
872 }
873
874 #[test]
875 fn basic_description() -> Result<()> {
876 assert_eq!(
877 event_to_cover_letter(&generate_cover_letter("the title", "description here")?)?
878 .description,
879 "description here",
880 );
881 Ok(())
882 }
883
884 #[test]
885 fn description_trimmed() -> Result<()> {
886 assert_eq!(
887 event_to_cover_letter(&generate_cover_letter(
888 "the title",
889 " \n \ndescription here\n\n "
890 )?)?
891 .description,
892 "description here",
893 );
894 Ok(())
895 }
896
897 #[test]
898 fn multi_line_description() -> Result<()> {
899 assert_eq!(
900 event_to_cover_letter(&generate_cover_letter(
901 "the title",
902 "description here\n\nmore here\nmore"
903 )?)?
904 .description,
905 "description here\n\nmore here\nmore",
906 );
907 Ok(())
908 }
909
910 #[test]
911 fn new_lines_in_title_forms_part_of_description() -> Result<()> {
912 assert_eq!(
913 event_to_cover_letter(&generate_cover_letter(
914 "the title\nwith new line",
915 "description here\n\nmore here\nmore"
916 )?)?
917 .title,
918 "the title",
919 );
920 assert_eq!(
921 event_to_cover_letter(&generate_cover_letter(
922 "the title\nwith new line",
923 "description here\n\nmore here\nmore"
924 )?)?
925 .description,
926 "with new line\n\ndescription here\n\nmore here\nmore",
927 );
928 Ok(())
929 }
930
931 mod blank_description {
932 use super::*;
933
934 #[test]
935 fn title_correct() -> Result<()> {
936 assert_eq!(
937 event_to_cover_letter(&generate_cover_letter("the title", "")?)?.title,
938 "the title",
939 );
940 Ok(())
941 }
942
943 #[test]
944 fn description_is_empty_string() -> Result<()> {
945 assert_eq!(
946 event_to_cover_letter(&generate_cover_letter("the title", "")?)?.description,
947 "",
948 );
949 Ok(())
950 }
951 }
952 }
953}
diff --git a/src/sub_commands/prs/list.rs b/src/sub_commands/prs/list.rs
deleted file mode 100644
index d4dcfec..0000000
--- a/src/sub_commands/prs/list.rs
+++ /dev/null
@@ -1,267 +0,0 @@
1use anyhow::{bail, Context, Result};
2
3use super::create::event_is_patch_set_root;
4#[cfg(not(test))]
5use crate::client::Client;
6#[cfg(test)]
7use crate::client::MockConnect;
8use crate::{
9 cli_interactor::{Interactor, InteractorPrompt, PromptChoiceParms, PromptConfirmParms},
10 client::Connect,
11 git::{Repo, RepoActions},
12 repo_ref::{self, RepoRef, REPO_REF_KIND},
13 sub_commands::prs::create::{event_is_cover_letter, event_to_cover_letter, PATCH_KIND},
14 Cli,
15};
16
17#[derive(Debug, clap::Args)]
18pub struct SubCommandArgs {
19 /// TODO ignore merged, and closed
20 #[arg(long, action)]
21 open_only: bool,
22}
23
24#[allow(clippy::too_many_lines)]
25pub async fn launch(
26 _cli_args: &Cli,
27 _pr_args: &super::SubCommandArgs,
28 _args: &SubCommandArgs,
29) -> Result<()> {
30 let git_repo = Repo::discover().context("cannot find a git repository")?;
31
32 let root_commit = git_repo
33 .get_root_commit()
34 .context("failed to get root commit of the repository")?;
35
36 // TODO: check for empty repo
37 // TODO: check for existing maintaiers file
38 // TODO: check for other claims
39
40 #[cfg(not(test))]
41 let client = Client::default();
42 #[cfg(test)]
43 let client = <MockConnect as std::default::Default>::default();
44
45 let repo_ref = repo_ref::fetch(
46 &git_repo,
47 root_commit.to_string(),
48 &client,
49 client.get_fallback_relays().clone(),
50 )
51 .await?;
52
53 println!("finding PRs...");
54
55 let pr_events: Vec<nostr::Event> =
56 find_pr_events(&client, &repo_ref, &root_commit.to_string()).await?;
57
58 let selected_index = Interactor::default().choice(
59 PromptChoiceParms::default()
60 .with_prompt("All PRs")
61 .with_choices(
62 pr_events
63 .iter()
64 .map(|e| {
65 if let Ok(cl) = event_to_cover_letter(e) {
66 cl.title
67 } else if let Ok(msg) = tag_value(e, "description") {
68 msg.split('\n').collect::<Vec<&str>>()[0].to_string()
69 } else {
70 e.id.to_string()
71 }
72 })
73 .collect(),
74 ),
75 )?;
76
77 println!("finding commits...");
78
79 let commits_events: Vec<nostr::Event> =
80 find_commits_for_pr_event(&client, &pr_events[selected_index], &repo_ref).await?;
81
82 confirm_checkout(&git_repo)?;
83
84 let most_recent_pr_patch_chain = get_most_recent_patch_with_ancestors(commits_events)
85 .context("cannot get most recent patch for PR")?;
86
87 let branch_name: String = event_to_cover_letter(&pr_events[selected_index])
88 .context("cannot assign a branch name as event is not a patch set root")?
89 .branch_name;
90
91 let applied = git_repo
92 .apply_patch_chain(&branch_name, most_recent_pr_patch_chain)
93 .context("cannot apply patch chain")?;
94
95 if applied.is_empty() {
96 println!("checked out PR branch. no new commits to pull");
97 } else {
98 println!(
99 "checked out PR branch. pulled {} new commits",
100 applied.len(),
101 );
102 }
103 Ok(())
104}
105
106fn confirm_checkout(git_repo: &Repo) -> Result<()> {
107 if !Interactor::default().confirm(
108 PromptConfirmParms::default()
109 .with_prompt("check out branch?")
110 .with_default(true),
111 )? {
112 bail!("Exiting...");
113 }
114
115 if git_repo.has_outstanding_changes()? {
116 bail!(
117 "cannot pull PR branch when repository is not clean. discard or stash (un)staged changes and try again."
118 );
119 }
120 Ok(())
121}
122
123pub fn tag_value(event: &nostr::Event, tag_name: &str) -> Result<String> {
124 Ok(event
125 .tags
126 .iter()
127 .find(|t| t.as_vec()[0].eq(tag_name))
128 .context(format!("tag '{tag_name}'not present"))?
129 .as_vec()[1]
130 .clone())
131}
132
133pub fn get_most_recent_patch_with_ancestors(
134 mut patches: Vec<nostr::Event>,
135) -> Result<Vec<nostr::Event>> {
136 patches.sort_by_key(|e| e.created_at);
137
138 let first_patch = patches.first().context("no patches found")?;
139
140 let patches_with_youngest_created_at: Vec<&nostr::Event> = patches
141 .iter()
142 .filter(|p| p.created_at.eq(&first_patch.created_at))
143 .collect();
144
145 let latest_commit_id = tag_value(
146 // get the first patch which isn't a parent of a patch event created at the same
147 // time
148 patches_with_youngest_created_at
149 .clone()
150 .iter()
151 .find(|p| {
152 if let Ok(commit) = tag_value(p, "commit") {
153 !patches_with_youngest_created_at.iter().any(|p2| {
154 if let Ok(parent) = tag_value(p2, "parent-commit") {
155 commit.eq(&parent)
156 } else {
157 false // skip
158 }
159 })
160 } else {
161 false // skip
162 }
163 })
164 .context("cannot find patches_with_youngest_created_at")?,
165 "commit",
166 )?;
167
168 let mut res = vec![];
169
170 let mut commit_id_to_search = latest_commit_id;
171
172 while let Some(event) = patches.iter().find(|e| {
173 if let Ok(commit) = tag_value(e, "commit") {
174 commit.eq(&commit_id_to_search)
175 } else {
176 false // skip
177 }
178 }) {
179 res.push(event.clone());
180 commit_id_to_search = tag_value(event, "parent-commit")?;
181 }
182 Ok(res)
183}
184
185pub async fn find_pr_events(
186 #[cfg(test)] client: &crate::client::MockConnect,
187 #[cfg(not(test))] client: &Client,
188 repo_ref: &RepoRef,
189 root_commit: &str,
190) -> Result<Vec<nostr::Event>> {
191 Ok(client
192 .get_events(
193 repo_ref.relays.clone(),
194 vec![
195 nostr::Filter::default()
196 .kind(nostr::Kind::Custom(PATCH_KIND))
197 .custom_tag(nostr::Alphabet::T, vec!["root"])
198 .identifiers(
199 repo_ref
200 .maintainers
201 .iter()
202 .map(|m| format!("{REPO_REF_KIND}:{m}:{}", repo_ref.identifier)),
203 ),
204 // also pick up prs from the same repo but no target at our maintainers repo events
205 nostr::Filter::default()
206 .kind(nostr::Kind::Custom(PATCH_KIND))
207 .custom_tag(nostr::Alphabet::T, vec!["root"])
208 .reference(root_commit),
209 ],
210 )
211 .await
212 .context("cannot get pr events")?
213 .iter()
214 .filter(|e| {
215 event_is_patch_set_root(e)
216 && (e
217 .tags
218 .iter()
219 .any(|t| t.as_vec().len() > 1 && t.as_vec()[1].eq(root_commit))
220 || e.tags.iter().any(|t| {
221 t.as_vec().len() > 1
222 && repo_ref
223 .maintainers
224 .iter()
225 .map(|m| format!("{REPO_REF_KIND}:{m}:{}", repo_ref.identifier))
226 .any(|d| t.as_vec()[1].eq(&d))
227 }))
228 })
229 .map(std::borrow::ToOwned::to_owned)
230 .collect::<Vec<nostr::Event>>())
231}
232
233pub async fn find_commits_for_pr_event(
234 #[cfg(test)] client: &crate::client::MockConnect,
235 #[cfg(not(test))] client: &Client,
236 pr_event: &nostr::Event,
237 repo_ref: &RepoRef,
238) -> Result<Vec<nostr::Event>> {
239 let mut patch_events: Vec<nostr::Event> = client
240 .get_events(
241 repo_ref.relays.clone(),
242 vec![
243 nostr::Filter::default()
244 .kind(nostr::Kind::Custom(PATCH_KIND))
245 // this requires every patch to reference the root event
246 // this will not pick up v2,v3 patch sets
247 // TODO: fetch commits for v2.. patch sets
248 .event(pr_event.id),
249 ],
250 )
251 .await
252 .context("cannot fetch patch events")?
253 .iter()
254 .filter(|e| {
255 e.kind.as_u64() == PATCH_KIND
256 && e.tags
257 .iter()
258 .any(|t| t.as_vec().len() > 2 && t.as_vec()[1].eq(&pr_event.id.to_string()))
259 })
260 .map(std::borrow::ToOwned::to_owned)
261 .collect();
262
263 if !event_is_cover_letter(pr_event) {
264 patch_events.push(pr_event.clone());
265 }
266 Ok(patch_events)
267}
diff --git a/src/sub_commands/prs/mod.rs b/src/sub_commands/prs/mod.rs
deleted file mode 100644
index a41c495..0000000
--- a/src/sub_commands/prs/mod.rs
+++ /dev/null
@@ -1,25 +0,0 @@
1use anyhow::Result;
2use clap::Subcommand;
3
4use crate::Cli;
5pub mod create;
6pub mod list;
7
8#[derive(clap::Parser)]
9pub struct SubCommandArgs {
10 #[command(subcommand)]
11 pub prs_command: Commands,
12}
13
14#[derive(Debug, Subcommand)]
15pub enum Commands {
16 Create(create::SubCommandArgs),
17 List(list::SubCommandArgs),
18}
19
20pub async fn launch(cli_args: &Cli, pr_args: &SubCommandArgs) -> Result<()> {
21 match &pr_args.prs_command {
22 Commands::Create(args) => create::launch(cli_args, pr_args, args).await,
23 Commands::List(args) => list::launch(cli_args, pr_args, args).await,
24 }
25}