From c0847f928c32adb0b4dfc3b73ee77fa3cdb5ec21 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 14 Feb 2024 08:41:02 +0000 Subject: feat!: move `prs create`>`send`, `prs list`>`list` remove unnecessary hierachy of `prs` which is also a troublesome term replace the concept of `create` which aligns more to the PR github model to `send` which aligns more with the git patch model --- src/git.rs | 6 +- src/main.rs | 9 +- src/sub_commands/claim.rs | 2 +- src/sub_commands/list.rs | 263 ++++++++++ src/sub_commands/mod.rs | 3 +- src/sub_commands/prs/create.rs | 953 --------------------------------- src/sub_commands/prs/list.rs | 267 ---------- src/sub_commands/prs/mod.rs | 25 - src/sub_commands/pull.rs | 2 +- src/sub_commands/push.rs | 4 +- src/sub_commands/send.rs | 950 +++++++++++++++++++++++++++++++++ tests/list.rs | 975 ++++++++++++++++++++++++++++++++++ tests/prs_create.rs | 1131 ---------------------------------------- tests/prs_list.rs | 977 ---------------------------------- tests/pull.rs | 3 +- tests/push.rs | 3 +- tests/send.rs | 1124 +++++++++++++++++++++++++++++++++++++++ 17 files changed, 3329 insertions(+), 3368 deletions(-) create mode 100644 src/sub_commands/list.rs delete mode 100644 src/sub_commands/prs/create.rs delete mode 100644 src/sub_commands/prs/list.rs delete mode 100644 src/sub_commands/prs/mod.rs create mode 100644 src/sub_commands/send.rs create mode 100644 tests/list.rs delete mode 100644 tests/prs_create.rs delete mode 100644 tests/prs_list.rs create mode 100644 tests/send.rs diff --git a/src/git.rs b/src/git.rs index cd42724..113a63c 100644 --- a/src/git.rs +++ b/src/git.rs @@ -6,7 +6,7 @@ use anyhow::{bail, Context, Result}; use git2::{DiffOptions, Oid, Revwalk}; use nostr::prelude::{sha1::Hash as Sha1Hash, Hash}; -use crate::sub_commands::prs::list::tag_value; +use crate::sub_commands::list::tag_value; pub struct Repo { git_repo: git2::Repository, @@ -1251,7 +1251,7 @@ mod tests { mod apply_patch { use super::*; - use crate::{repo_ref::RepoRef, sub_commands::prs::create::generate_patch_event}; + use crate::{repo_ref::RepoRef, sub_commands::send::generate_patch_event}; fn generate_patch_from_head_commit(test_repo: &GitTestRepo) -> Result { let original_oid = test_repo.git_repo.head()?.peel_to_commit()?.id(); @@ -1405,7 +1405,7 @@ mod tests { use test_utils::TEST_KEY_1_KEYS; use super::*; - use crate::{repo_ref::RepoRef, sub_commands::prs::create::generate_pr_and_patch_events}; + use crate::{repo_ref::RepoRef, sub_commands::send::generate_pr_and_patch_events}; static BRANCH_NAME: &str = "add-example-feature"; // returns original_repo, pr_event, patch_events diff --git a/src/main.rs b/src/main.rs index 85b2812..539d9ff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,8 +36,10 @@ enum Commands { Login(sub_commands::login::SubCommandArgs), /// issue repository reference event as a maintainers Claim(sub_commands::claim::SubCommandArgs), - /// create and issue prs - Prs(sub_commands::prs::SubCommandArgs), + /// send a PR / patch / patch set via nostr events + Send(sub_commands::send::SubCommandArgs), + /// list open PRs / patches / patch sets and pull / apply them a branch + List(sub_commands::list::SubCommandArgs), /// pull latest commits in pr linked to checked out branch Pull, /// push commits to current checked out pr branch @@ -50,7 +52,8 @@ async fn main() -> Result<()> { match &cli.command { Commands::Login(args) => sub_commands::login::launch(&cli, args).await, Commands::Claim(args) => sub_commands::claim::launch(&cli, args).await, - Commands::Prs(args) => sub_commands::prs::launch(&cli, args).await, + Commands::Send(args) => sub_commands::send::launch(&cli, args).await, + Commands::List(args) => sub_commands::list::launch(&cli, args).await, Commands::Pull => sub_commands::pull::launch().await, Commands::Push => sub_commands::push::launch(&cli).await, } diff --git a/src/sub_commands/claim.rs b/src/sub_commands/claim.rs index d3a80c8..a95021d 100644 --- a/src/sub_commands/claim.rs +++ b/src/sub_commands/claim.rs @@ -1,6 +1,6 @@ use anyhow::{Context, Result}; -use super::prs::create::send_events; +use super::send::send_events; #[cfg(not(test))] use crate::client::Client; #[cfg(test)] diff --git a/src/sub_commands/list.rs b/src/sub_commands/list.rs new file mode 100644 index 0000000..49cbf6d --- /dev/null +++ b/src/sub_commands/list.rs @@ -0,0 +1,263 @@ +use anyhow::{bail, Context, Result}; + +use super::send::event_is_patch_set_root; +#[cfg(not(test))] +use crate::client::Client; +#[cfg(test)] +use crate::client::MockConnect; +use crate::{ + cli_interactor::{Interactor, InteractorPrompt, PromptChoiceParms, PromptConfirmParms}, + client::Connect, + git::{Repo, RepoActions}, + repo_ref::{self, RepoRef, REPO_REF_KIND}, + sub_commands::send::{event_is_cover_letter, event_to_cover_letter, PATCH_KIND}, + Cli, +}; + +#[derive(Debug, clap::Args)] +pub struct SubCommandArgs { + /// TODO ignore merged, and closed + #[arg(long, action)] + open_only: bool, +} + +#[allow(clippy::too_many_lines)] +pub async fn launch(_cli_args: &Cli, _args: &SubCommandArgs) -> Result<()> { + let git_repo = Repo::discover().context("cannot find a git repository")?; + + let root_commit = git_repo + .get_root_commit() + .context("failed to get root commit of the repository")?; + + // TODO: check for empty repo + // TODO: check for existing maintaiers file + // TODO: check for other claims + + #[cfg(not(test))] + let client = Client::default(); + #[cfg(test)] + let client = ::default(); + + let repo_ref = repo_ref::fetch( + &git_repo, + root_commit.to_string(), + &client, + client.get_fallback_relays().clone(), + ) + .await?; + + println!("finding PRs..."); + + let pr_events: Vec = + find_pr_events(&client, &repo_ref, &root_commit.to_string()).await?; + + let selected_index = Interactor::default().choice( + PromptChoiceParms::default() + .with_prompt("All PRs") + .with_choices( + pr_events + .iter() + .map(|e| { + if let Ok(cl) = event_to_cover_letter(e) { + cl.title + } else if let Ok(msg) = tag_value(e, "description") { + msg.split('\n').collect::>()[0].to_string() + } else { + e.id.to_string() + } + }) + .collect(), + ), + )?; + + println!("finding commits..."); + + let commits_events: Vec = + find_commits_for_pr_event(&client, &pr_events[selected_index], &repo_ref).await?; + + confirm_checkout(&git_repo)?; + + let most_recent_pr_patch_chain = get_most_recent_patch_with_ancestors(commits_events) + .context("cannot get most recent patch for PR")?; + + let branch_name: String = event_to_cover_letter(&pr_events[selected_index]) + .context("cannot assign a branch name as event is not a patch set root")? + .branch_name; + + let applied = git_repo + .apply_patch_chain(&branch_name, most_recent_pr_patch_chain) + .context("cannot apply patch chain")?; + + if applied.is_empty() { + println!("checked out PR branch. no new commits to pull"); + } else { + println!( + "checked out PR branch. pulled {} new commits", + applied.len(), + ); + } + Ok(()) +} + +fn confirm_checkout(git_repo: &Repo) -> Result<()> { + if !Interactor::default().confirm( + PromptConfirmParms::default() + .with_prompt("check out branch?") + .with_default(true), + )? { + bail!("Exiting..."); + } + + if git_repo.has_outstanding_changes()? { + bail!( + "cannot pull PR branch when repository is not clean. discard or stash (un)staged changes and try again." + ); + } + Ok(()) +} + +pub fn tag_value(event: &nostr::Event, tag_name: &str) -> Result { + Ok(event + .tags + .iter() + .find(|t| t.as_vec()[0].eq(tag_name)) + .context(format!("tag '{tag_name}'not present"))? + .as_vec()[1] + .clone()) +} + +pub fn get_most_recent_patch_with_ancestors( + mut patches: Vec, +) -> Result> { + patches.sort_by_key(|e| e.created_at); + + let first_patch = patches.first().context("no patches found")?; + + let patches_with_youngest_created_at: Vec<&nostr::Event> = patches + .iter() + .filter(|p| p.created_at.eq(&first_patch.created_at)) + .collect(); + + let latest_commit_id = tag_value( + // get the first patch which isn't a parent of a patch event created at the same + // time + patches_with_youngest_created_at + .clone() + .iter() + .find(|p| { + if let Ok(commit) = tag_value(p, "commit") { + !patches_with_youngest_created_at.iter().any(|p2| { + if let Ok(parent) = tag_value(p2, "parent-commit") { + commit.eq(&parent) + } else { + false // skip + } + }) + } else { + false // skip + } + }) + .context("cannot find patches_with_youngest_created_at")?, + "commit", + )?; + + let mut res = vec![]; + + let mut commit_id_to_search = latest_commit_id; + + while let Some(event) = patches.iter().find(|e| { + if let Ok(commit) = tag_value(e, "commit") { + commit.eq(&commit_id_to_search) + } else { + false // skip + } + }) { + res.push(event.clone()); + commit_id_to_search = tag_value(event, "parent-commit")?; + } + Ok(res) +} + +pub async fn find_pr_events( + #[cfg(test)] client: &crate::client::MockConnect, + #[cfg(not(test))] client: &Client, + repo_ref: &RepoRef, + root_commit: &str, +) -> Result> { + Ok(client + .get_events( + repo_ref.relays.clone(), + vec![ + nostr::Filter::default() + .kind(nostr::Kind::Custom(PATCH_KIND)) + .custom_tag(nostr::Alphabet::T, vec!["root"]) + .identifiers( + repo_ref + .maintainers + .iter() + .map(|m| format!("{REPO_REF_KIND}:{m}:{}", repo_ref.identifier)), + ), + // also pick up prs from the same repo but no target at our maintainers repo events + nostr::Filter::default() + .kind(nostr::Kind::Custom(PATCH_KIND)) + .custom_tag(nostr::Alphabet::T, vec!["root"]) + .reference(root_commit), + ], + ) + .await + .context("cannot get pr events")? + .iter() + .filter(|e| { + event_is_patch_set_root(e) + && (e + .tags + .iter() + .any(|t| t.as_vec().len() > 1 && t.as_vec()[1].eq(root_commit)) + || e.tags.iter().any(|t| { + t.as_vec().len() > 1 + && repo_ref + .maintainers + .iter() + .map(|m| format!("{REPO_REF_KIND}:{m}:{}", repo_ref.identifier)) + .any(|d| t.as_vec()[1].eq(&d)) + })) + }) + .map(std::borrow::ToOwned::to_owned) + .collect::>()) +} + +pub async fn find_commits_for_pr_event( + #[cfg(test)] client: &crate::client::MockConnect, + #[cfg(not(test))] client: &Client, + pr_event: &nostr::Event, + repo_ref: &RepoRef, +) -> Result> { + let mut patch_events: Vec = client + .get_events( + repo_ref.relays.clone(), + vec![ + nostr::Filter::default() + .kind(nostr::Kind::Custom(PATCH_KIND)) + // this requires every patch to reference the root event + // this will not pick up v2,v3 patch sets + // TODO: fetch commits for v2.. patch sets + .event(pr_event.id), + ], + ) + .await + .context("cannot fetch patch events")? + .iter() + .filter(|e| { + e.kind.as_u64() == PATCH_KIND + && e.tags + .iter() + .any(|t| t.as_vec().len() > 2 && t.as_vec()[1].eq(&pr_event.id.to_string())) + }) + .map(std::borrow::ToOwned::to_owned) + .collect(); + + if !event_is_cover_letter(pr_event) { + patch_events.push(pr_event.clone()); + } + Ok(patch_events) +} diff --git a/src/sub_commands/mod.rs b/src/sub_commands/mod.rs index 8be9004..b16d50f 100644 --- a/src/sub_commands/mod.rs +++ b/src/sub_commands/mod.rs @@ -1,5 +1,6 @@ pub mod claim; +pub mod list; pub mod login; -pub mod prs; pub mod pull; pub mod push; +pub mod send; 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 @@ -use std::time::Duration; - -use anyhow::{bail, Context, Result}; -use futures::future::join_all; -use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; -use nostr::{prelude::sha1::Hash as Sha1Hash, EventBuilder, Marker, Tag, TagKind}; - -use super::list::tag_value; -#[cfg(not(test))] -use crate::client::Client; -#[cfg(test)] -use crate::client::MockConnect; -use crate::{ - cli_interactor::{Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms}, - client::Connect, - git::{Repo, RepoActions}, - login, - repo_ref::{self, RepoRef, REPO_REF_KIND}, - Cli, -}; - -#[derive(Debug, clap::Args)] -pub struct SubCommandArgs { - #[clap(short, long)] - /// optional cover letter title - title: Option, - #[clap(short, long)] - /// optional cover letter description - description: Option, - #[clap(long)] - /// branch to get changes from (defaults to head) - from_branch: Option, - #[clap(long)] - /// destination branch (defaults to main or master) - to_branch: Option, - /// don't ask about a cover letter - #[arg(long, action)] - no_cover_letter: bool, -} - -#[allow(clippy::too_many_lines)] -pub async fn launch( - cli_args: &Cli, - _pr_args: &super::SubCommandArgs, - args: &SubCommandArgs, -) -> Result<()> { - let git_repo = Repo::discover().context("cannot find a git repository")?; - - let (from_branch, to_branch, mut ahead, behind) = - identify_ahead_behind(&git_repo, &args.from_branch, &args.to_branch)?; - - if ahead.is_empty() { - bail!(format!( - "'{from_branch}' is 0 commits ahead of '{to_branch}' so no patches were created" - )); - } - - if behind.is_empty() { - println!( - "creating patch for {} commits from '{from_branch}' that can be merged into '{to_branch}'", - ahead.len(), - ); - } else { - if !Interactor::default().confirm( - PromptConfirmParms::default() - .with_prompt( - format!( - "'{from_branch}' is {} commits behind '{to_branch}' and {} ahead. Consider rebasing before sending patches. Proceed anyway?", - behind.len(), - ahead.len(), - ) - ) - .with_default(false) - ).context("failed to get confirmation response from interactor confirm")? { - bail!("aborting so branch can be rebased"); - } - println!( - "creating patch for {} commit{} from '{from_branch}' that {} {} behind '{to_branch}'", - ahead.len(), - if ahead.len() > 1 { "s" } else { "" }, - if ahead.len() > 1 { "are" } else { "is" }, - behind.len(), - ); - } - - let title = if args.no_cover_letter { - None - } else { - match &args.title { - Some(t) => Some(t.clone()), - None => { - if Interactor::default().confirm( - PromptConfirmParms::default() - .with_default(false) - .with_prompt("include cover letter?"), - )? { - Some( - Interactor::default() - .input(PromptInputParms::default().with_prompt("title"))? - .clone(), - ) - } else { - None - } - } - } - }; - - let cover_letter_title_description = if let Some(title) = title { - Some(( - title, - if let Some(t) = &args.description { - t.clone() - } else { - Interactor::default() - .input(PromptInputParms::default().with_prompt("cover letter description"))? - .clone() - }, - )) - } else { - None - }; - - #[cfg(not(test))] - let mut client = Client::default(); - #[cfg(test)] - let mut client = ::default(); - - let (keys, user_ref) = login::launch(&cli_args.nsec, &cli_args.password, Some(&client)).await?; - - client.set_keys(&keys).await; - - let repo_ref = repo_ref::fetch( - &git_repo, - git_repo - .get_root_commit() - .context("failed to get root commit of the repository")? - .to_string(), - &client, - user_ref.relays.write(), - ) - .await?; - - // oldest first - ahead.reverse(); - - let events = generate_pr_and_patch_events( - cover_letter_title_description.clone(), - &git_repo, - &ahead, - &keys, - &repo_ref, - )?; - - println!( - "posting {} patches {} a covering letter...", - if cover_letter_title_description.is_none() { - events.len() - } else { - events.len() - 1 - }, - if cover_letter_title_description.is_none() { - "without" - } else { - "with" - } - ); - - send_events( - &client, - events, - user_ref.relays.write(), - repo_ref.relays.clone(), - !cli_args.disable_cli_spinners, - ) - .await?; - // TODO check if there is already a similarly named - Ok(()) -} - -pub async fn send_events( - #[cfg(test)] client: &crate::client::MockConnect, - #[cfg(not(test))] client: &Client, - events: Vec, - my_write_relays: Vec, - repo_read_relays: Vec, - animate: bool, -) -> Result<()> { - let (_, _, _, all) = unique_and_duplicate_all(&my_write_relays, &repo_read_relays); - - let m = MultiProgress::new(); - let pb_style = ProgressStyle::with_template(if animate { - " {spinner} {prefix} {bar} {pos}/{len} {msg}" - } else { - " - {prefix} {bar} {pos}/{len} {msg}" - })? - .progress_chars("##-"); - - let pb_after_style = - |symbol| ProgressStyle::with_template(format!(" {symbol} {}", "{prefix} {msg}",).as_str()); - let pb_after_style_succeeded = pb_after_style(if animate { - console::style("✔".to_string()) - .for_stderr() - .green() - .to_string() - } else { - "y".to_string() - })?; - - let pb_after_style_failed = pb_after_style(if animate { - console::style("✘".to_string()) - .for_stderr() - .red() - .to_string() - } else { - "x".to_string() - })?; - - join_all(all.iter().map(|&relay| async { - let details = format!( - "{}{} {}", - if my_write_relays.iter().any(|r| relay.eq(r)) { - " [my-relay]" - } else { - "" - }, - if repo_read_relays.iter().any(|r| relay.eq(r)) { - " [repo-relay]" - } else { - "" - }, - *relay, - ); - let pb = m.add( - ProgressBar::new(events.len() as u64) - .with_prefix(details.to_string()) - .with_style(pb_style.clone()), - ); - if animate { - pb.enable_steady_tick(Duration::from_millis(300)); - } - pb.inc(0); // need to make pb display intially - let mut failed = false; - for event in &events { - match client.send_event_to(relay.as_str(), event.clone()).await { - Ok(_) => pb.inc(1), - Err(e) => { - pb.set_style(pb_after_style_failed.clone()); - pb.finish_with_message( - console::style( - e.to_string() - .replace("relay pool error:", "error:") - .replace("event not published: ", ""), - ) - .for_stderr() - .red() - .to_string(), - ); - failed = true; - break; - } - }; - } - if !failed { - pb.set_style(pb_after_style_succeeded.clone()); - pb.finish_with_message(""); - } - })) - .await; - Ok(()) -} - -/// returns `(unique_vec1, unique_vec2, duplicates, all)` -fn unique_and_duplicate_all<'a, S>( - vec1: &'a Vec, - vec2: &'a Vec, -) -> (Vec<&'a S>, Vec<&'a S>, Vec<&'a S>, Vec<&'a S>) -where - S: PartialEq, -{ - let mut vec1_u = vec![]; - let mut vec2_u = vec![]; - let mut dup = vec![]; - let mut all = vec![]; - for s1 in vec1 { - if vec2.iter().any(|s2| s1.eq(s2)) { - dup.push(s1); - } else { - vec1_u.push(s1); - } - } - for s2 in vec2 { - if !vec1.iter().any(|s1| s2.eq(s1)) { - vec2_u.push(s2); - } - } - for a in [&dup, &vec1_u, &vec2_u] { - for e in a { - all.push(&**e); - } - } - (vec1_u, vec2_u, dup, all) -} - -mod tests_unique_and_duplicate { - - #[test] - fn correct_number_of_unique_and_duplicate_items() { - let v1 = vec![ - "t1".to_string(), - "t2".to_string(), - "t3".to_string(), - "t4".to_string(), - "t5".to_string(), - ]; - let v2 = vec![ - "t3".to_string(), - "t4".to_string(), - "t5".to_string(), - "t6".to_string(), - ]; - - let (v1_u, v2_u, d, a) = super::unique_and_duplicate_all(&v1, &v2); - - assert_eq!(v1_u.len(), 2); - assert_eq!(v2_u.len(), 1); - assert_eq!(d.len(), 3); - assert_eq!(a.len(), 6); - } - #[test] - fn all_begins_with_duplicates() { - let v1 = vec![ - "t1".to_string(), - "t2".to_string(), - "t3".to_string(), - "t4".to_string(), - "t5".to_string(), - ]; - let v2 = vec![ - "t3".to_string(), - "t4".to_string(), - "t5".to_string(), - "t6".to_string(), - ]; - - let (_, _, d, a) = super::unique_and_duplicate_all(&v1, &v2); - - assert_eq!(a[0], d[0]); - } -} - -pub static PATCH_KIND: u64 = 1617; - -pub fn generate_pr_and_patch_events( - cover_letter_title_description: Option<(String, String)>, - git_repo: &Repo, - commits: &Vec, - keys: &nostr::Keys, - repo_ref: &RepoRef, -) -> Result> { - let root_commit = git_repo - .get_root_commit() - .context("failed to get root commit of the repository")?; - - let mut events = vec![]; - - if let Some((title, description)) = cover_letter_title_description { - events.push(EventBuilder::new( - nostr::event::Kind::Custom(PATCH_KIND), - format!( - "From {} Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/{}] {title}\n\n{description}", - commits.last().unwrap(), - commits.len() - ), - [ - vec![ - // TODO: why not tag all maintainer identifiers? - Tag::A { - kind: nostr::Kind::Custom(REPO_REF_KIND), - public_key: *repo_ref.maintainers.first() - .context("repo reference should always have at least one maintainer - the issuer of the repo event") - ?, - identifier: repo_ref.identifier.to_string(), - relay_url: repo_ref.relays.first().map(nostr::UncheckedUrl::from).clone(), - }, - Tag::Reference(format!("{root_commit}")), - Tag::Hashtag("cover-letter".to_string()), - Tag::Hashtag("root".to_string()), - ], - if let Ok(branch_name) = git_repo.get_checked_out_branch_name() { - vec![Tag::Generic( - TagKind::Custom("branch-name".to_string()), - vec![branch_name], - )] - } else { - vec![] - }, - repo_ref.maintainers - .iter() - .map(|pk| Tag::public_key(*pk)) - .collect(), - ].concat(), - ) - .to_event(keys) - .context("failed to create cover-letter event")?); - } - - for (i, commit) in commits.iter().enumerate() { - events.push( - generate_patch_event( - git_repo, - &root_commit, - commit, - events.first().map(|event| event.id), - keys, - repo_ref, - events.last().map(nostr::Event::id), - if events.is_empty() { - None - } else { - Some(((i + 1).try_into()?, commits.len().try_into()?)) - }, - if events.is_empty() { - if let Ok(branch_name) = git_repo.get_checked_out_branch_name() { - Some(branch_name) - } else { - None - } - } else { - None - }, - ) - .context("failed to generate patch event")?, - ); - } - Ok(events) -} - -pub struct CoverLetter { - pub title: String, - pub description: String, - pub branch_name: String, -} - -pub fn event_is_cover_letter(event: &nostr::Event) -> bool { - // TODO: look for Subject:[ PATCH 0/n ] but watch out for: - // [PATCH v1 0/n ] or - // [PATCH subsystem v2 0/n ] - event.kind.as_u64().eq(&PATCH_KIND) - && event.iter_tags().any(|t| t.as_vec()[1].eq("root")) - && event.iter_tags().any(|t| t.as_vec()[1].eq("cover-letter")) -} -pub fn event_to_cover_letter(event: &nostr::Event) -> Result { - if !event_is_patch_set_root(event) { - bail!("event is not a patch set root event (root patch or cover letter)") - } - let title_index = event - .content - .find("] ") - .context("event is not formatted as a patch or cover letter")? - + 2; - let description_index = event.content[title_index..] - .find('\n') - .unwrap_or(event.content.len() - 1 - title_index) - + title_index; - - let title = if let Ok(msg) = tag_value(event, "description") { - msg.split('\n').collect::>()[0].to_string() - } else { - event.content[title_index..description_index].to_string() - }; - - // note: if the description field is removed from patch events like in gitstr, - // then this will show entire patch. I'm not sure it is ever displayed though - let description = if let Ok(msg) = tag_value(event, "description") { - if let Some((_before, after)) = msg.split_once('\n') { - after.trim().to_string() - } else { - String::new() - } - } else { - event.content[description_index..].trim().to_string() - }; - - Ok(CoverLetter { - title: title.clone(), - description, - // TODO should this be prefixed by format!("{}-"e.id.to_string()[..5]?) - branch_name: if let Ok(name) = tag_value(event, "branch-name") { - name - } else { - let s = title - .replace(' ', "-") - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c.eq(&'/') { - c - } else { - '-' - } - }) - .collect(); - s - }, - }) -} - -pub fn event_is_patch_set_root(event: &nostr::Event) -> bool { - event.kind.as_u64().eq(&PATCH_KIND) && event.iter_tags().any(|t| t.as_vec()[1].eq("root")) -} - -#[allow(clippy::too_many_arguments)] -pub fn generate_patch_event( - git_repo: &Repo, - root_commit: &Sha1Hash, - commit: &Sha1Hash, - thread_event_id: Option, - keys: &nostr::Keys, - repo_ref: &RepoRef, - parent_patch_event_id: Option, - series_count: Option<(u64, u64)>, - branch_name: Option, -) -> Result { - let commit_parent = git_repo - .get_commit_parent(commit) - .context("failed to get parent commit")?; - let relay_hint = repo_ref.relays.first().map(nostr::UncheckedUrl::from); - EventBuilder::new( - nostr::event::Kind::Custom(PATCH_KIND), - git_repo - .make_patch_from_commit(commit,&series_count) - .context(format!("cannot make patch for commit {commit}"))?, - [ - vec![ - Tag::A { - kind: nostr::Kind::Custom(REPO_REF_KIND), - public_key: *repo_ref.maintainers.first() - .context("repo reference should always have at least one maintainer - the issuer of the repo event") - ?, - identifier: repo_ref.identifier.to_string(), - relay_url: relay_hint.clone(), - }, - Tag::Reference(format!("{root_commit}")), - // commit id reference is a trade-off. its now - // unclear which one is the root commit id but it - // enables easier location of code comments againt - // code that makes it into the main branch, assuming - // the commit id is correct - Tag::Reference(commit.to_string()), - - if let Some(thread_event_id) = thread_event_id { Tag::Event { - event_id: thread_event_id, - relay_url: relay_hint.clone(), - marker: Some(Marker::Root), - } } - else { - Tag::Hashtag("root".to_string()) - }, - ], - if let Some(id) = parent_patch_event_id { - vec![Tag::Event { - event_id: id, - relay_url: relay_hint.clone(), - marker: Some(Marker::Reply), - }] - } else { - vec![] - }, - if let Some(branch_name) = branch_name { - if thread_event_id.is_none() { - vec![ - Tag::Generic( - TagKind::Custom("branch-name".to_string()), - vec![branch_name.to_string()], - ) - ] - } - else { vec![]} - } - else { vec![]}, - // whilst it is in nip34 draft to tag the maintainers - // I'm not sure it is a good idea because if they are - // interested in all patches then their specialised - // client should subscribe to patches tagged with the - // repo reference. maintainers of large repos will not - // be interested in every patch. - repo_ref.maintainers - .iter() - .map(|pk| Tag::public_key(*pk)) - .collect(), - vec![ - Tag::Generic( - TagKind::Custom("commit".to_string()), - vec![commit.to_string()], - ), - Tag::Generic( - TagKind::Custom("parent-commit".to_string()), - vec![commit_parent.to_string()], - ), - Tag::Generic( - TagKind::Custom("commit-pgp-sig".to_string()), - vec![ - git_repo - .extract_commit_pgp_signature(commit) - .unwrap_or_default(), - ], - ), - Tag::Description(git_repo.get_commit_message(commit)?.to_string()), - Tag::Generic( - TagKind::Custom("author".to_string()), - git_repo.get_commit_author(commit)?, - ), - Tag::Generic( - TagKind::Custom("committer".to_string()), - git_repo.get_commit_comitter(commit)?, - ), - ], - ] - .concat(), - ) - .to_event(keys) - .context("failed to sign event") -} -// TODO -// - find profile -// - file relays -// - find repo events -// - - -/** - * returns `(from_branch,to_branch,ahead,behind)` - */ -fn identify_ahead_behind( - git_repo: &Repo, - from_branch: &Option, - to_branch: &Option, -) -> Result<(String, String, Vec, Vec)> { - let (from_branch, from_tip) = match from_branch { - Some(name) => ( - name.to_string(), - git_repo - .get_tip_of_local_branch(name) - .context(format!("cannot find from_branch '{name}'"))?, - ), - None => ( - "head".to_string(), - git_repo - .get_head_commit() - .context("failed to get head commit") - .context( - "checkout a commit or specify a from_branch. head does not reveal a commit", - )?, - ), - }; - - let (to_branch, to_tip) = match to_branch { - Some(name) => ( - name.to_string(), - git_repo - .get_tip_of_local_branch(name) - .context(format!("cannot find to_branch '{name}'"))?, - ), - None => { - let (name, commit) = git_repo - .get_main_or_master_branch() - .context("a destination branch (to_branch) is not specified and the defaults (main or master) do not exist")?; - (name.to_string(), commit) - } - }; - - match git_repo.get_commits_ahead_behind(&to_tip, &from_tip) { - Err(e) => { - if e.to_string().contains("is not an ancestor of") { - return Err(e).context(format!( - "'{from_branch}' is not branched from '{to_branch}'" - )); - } - Err(e).context(format!( - "failed to get commits ahead and behind from '{from_branch}' to '{to_branch}'" - )) - } - Ok((ahead, behind)) => Ok((from_branch, to_branch, ahead, behind)), - } -} - -#[cfg(test)] -mod tests { - use test_utils::git::GitTestRepo; - - use super::*; - mod identify_ahead_behind { - - use super::*; - use crate::git::oid_to_sha1; - - #[test] - fn when_from_branch_doesnt_exist_return_error() -> Result<()> { - let test_repo = GitTestRepo::default(); - let git_repo = Repo::from_path(&test_repo.dir)?; - - test_repo.populate()?; - let branch_name = "doesnt_exist"; - assert_eq!( - identify_ahead_behind(&git_repo, &Some(branch_name.to_string()), &None) - .unwrap_err() - .to_string(), - format!("cannot find from_branch '{}'", &branch_name), - ); - Ok(()) - } - - #[test] - fn when_to_branch_doesnt_exist_return_error() -> Result<()> { - let test_repo = GitTestRepo::default(); - let git_repo = Repo::from_path(&test_repo.dir)?; - - test_repo.populate()?; - let branch_name = "doesnt_exist"; - assert_eq!( - identify_ahead_behind(&git_repo, &None, &Some(branch_name.to_string())) - .unwrap_err() - .to_string(), - format!("cannot find to_branch '{}'", &branch_name), - ); - Ok(()) - } - - #[test] - fn when_to_branch_is_none_and_no_main_or_master_branch_return_error() -> Result<()> { - let test_repo = GitTestRepo::new("notmain")?; - let git_repo = Repo::from_path(&test_repo.dir)?; - - test_repo.populate()?; - - assert_eq!( - identify_ahead_behind(&git_repo, &None, &None) - .unwrap_err() - .to_string(), - "a destination branch (to_branch) is not specified and the defaults (main or master) do not exist", - ); - Ok(()) - } - - #[test] - fn when_from_branch_is_none_return_as_head() -> Result<()> { - let test_repo = GitTestRepo::default(); - let git_repo = Repo::from_path(&test_repo.dir)?; - - test_repo.populate()?; - // create feature branch with 1 commit ahead - test_repo.create_branch("feature")?; - test_repo.checkout("feature")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - let head_oid = test_repo.stage_and_commit("add t3.md")?; - - // make feature branch 1 commit behind - test_repo.checkout("main")?; - std::fs::write(test_repo.dir.join("t4.md"), "some content")?; - let main_oid = test_repo.stage_and_commit("add t4.md")?; - // checkout feature - test_repo.checkout("feature")?; - - let (from_branch, to_branch, ahead, behind) = - identify_ahead_behind(&git_repo, &None, &None)?; - - assert_eq!(from_branch, "head"); - assert_eq!(ahead, vec![oid_to_sha1(&head_oid)]); - assert_eq!(to_branch, "main"); - assert_eq!(behind, vec![oid_to_sha1(&main_oid)]); - Ok(()) - } - - #[test] - fn when_from_branch_is_not_head_return_as_from_branch() -> Result<()> { - let test_repo = GitTestRepo::default(); - let git_repo = Repo::from_path(&test_repo.dir)?; - - test_repo.populate()?; - // create feature branch with 1 commit ahead - test_repo.create_branch("feature")?; - test_repo.checkout("feature")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - let head_oid = test_repo.stage_and_commit("add t3.md")?; - - // make feature branch 1 commit behind - test_repo.checkout("main")?; - std::fs::write(test_repo.dir.join("t4.md"), "some content")?; - let main_oid = test_repo.stage_and_commit("add t4.md")?; - - let (from_branch, to_branch, ahead, behind) = - identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?; - - assert_eq!(from_branch, "feature"); - assert_eq!(ahead, vec![oid_to_sha1(&head_oid)]); - assert_eq!(to_branch, "main"); - assert_eq!(behind, vec![oid_to_sha1(&main_oid)]); - Ok(()) - } - - #[test] - fn when_to_branch_is_not_main_return_as_to_branch() -> Result<()> { - let test_repo = GitTestRepo::default(); - let git_repo = Repo::from_path(&test_repo.dir)?; - - test_repo.populate()?; - // create dev branch with 1 commit ahead - test_repo.create_branch("dev")?; - test_repo.checkout("dev")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - let dev_oid_first = test_repo.stage_and_commit("add t3.md")?; - - // create feature branch with 1 commit ahead of dev - test_repo.create_branch("feature")?; - test_repo.checkout("feature")?; - std::fs::write(test_repo.dir.join("t4.md"), "some content")?; - let feature_oid = test_repo.stage_and_commit("add t4.md")?; - - // make feature branch 1 behind - test_repo.checkout("dev")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - let dev_oid = test_repo.stage_and_commit("add t3.md")?; - - let (from_branch, to_branch, ahead, behind) = identify_ahead_behind( - &git_repo, - &Some("feature".to_string()), - &Some("dev".to_string()), - )?; - - assert_eq!(from_branch, "feature"); - assert_eq!(ahead, vec![oid_to_sha1(&feature_oid)]); - assert_eq!(to_branch, "dev"); - assert_eq!(behind, vec![oid_to_sha1(&dev_oid)]); - - let (from_branch, to_branch, ahead, behind) = - identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?; - - assert_eq!(from_branch, "feature"); - assert_eq!( - ahead, - vec![oid_to_sha1(&feature_oid), oid_to_sha1(&dev_oid_first)] - ); - assert_eq!(to_branch, "main"); - assert_eq!(behind, vec![]); - - Ok(()) - } - } - - mod event_to_cover_letter { - use super::*; - - fn generate_cover_letter(title: &str, description: &str) -> Result { - Ok(nostr::event::EventBuilder::new( - nostr::event::Kind::Custom(PATCH_KIND), - format!("From ea897e987ea9a7a98e7a987e97987ea98e7a3334 Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/2] {title}\n\n{description}"), - [ - Tag::Hashtag("cover-letter".to_string()), - Tag::Hashtag("root".to_string()), - ], - ) - .to_event(&nostr::Keys::generate())?) - } - - #[test] - fn basic_title() -> Result<()> { - assert_eq!( - event_to_cover_letter(&generate_cover_letter("the title", "description here")?)? - .title, - "the title", - ); - Ok(()) - } - - #[test] - fn basic_description() -> Result<()> { - assert_eq!( - event_to_cover_letter(&generate_cover_letter("the title", "description here")?)? - .description, - "description here", - ); - Ok(()) - } - - #[test] - fn description_trimmed() -> Result<()> { - assert_eq!( - event_to_cover_letter(&generate_cover_letter( - "the title", - " \n \ndescription here\n\n " - )?)? - .description, - "description here", - ); - Ok(()) - } - - #[test] - fn multi_line_description() -> Result<()> { - assert_eq!( - event_to_cover_letter(&generate_cover_letter( - "the title", - "description here\n\nmore here\nmore" - )?)? - .description, - "description here\n\nmore here\nmore", - ); - Ok(()) - } - - #[test] - fn new_lines_in_title_forms_part_of_description() -> Result<()> { - assert_eq!( - event_to_cover_letter(&generate_cover_letter( - "the title\nwith new line", - "description here\n\nmore here\nmore" - )?)? - .title, - "the title", - ); - assert_eq!( - event_to_cover_letter(&generate_cover_letter( - "the title\nwith new line", - "description here\n\nmore here\nmore" - )?)? - .description, - "with new line\n\ndescription here\n\nmore here\nmore", - ); - Ok(()) - } - - mod blank_description { - use super::*; - - #[test] - fn title_correct() -> Result<()> { - assert_eq!( - event_to_cover_letter(&generate_cover_letter("the title", "")?)?.title, - "the title", - ); - Ok(()) - } - - #[test] - fn description_is_empty_string() -> Result<()> { - assert_eq!( - event_to_cover_letter(&generate_cover_letter("the title", "")?)?.description, - "", - ); - Ok(()) - } - } - } -} 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 @@ -use anyhow::{bail, Context, Result}; - -use super::create::event_is_patch_set_root; -#[cfg(not(test))] -use crate::client::Client; -#[cfg(test)] -use crate::client::MockConnect; -use crate::{ - cli_interactor::{Interactor, InteractorPrompt, PromptChoiceParms, PromptConfirmParms}, - client::Connect, - git::{Repo, RepoActions}, - repo_ref::{self, RepoRef, REPO_REF_KIND}, - sub_commands::prs::create::{event_is_cover_letter, event_to_cover_letter, PATCH_KIND}, - Cli, -}; - -#[derive(Debug, clap::Args)] -pub struct SubCommandArgs { - /// TODO ignore merged, and closed - #[arg(long, action)] - open_only: bool, -} - -#[allow(clippy::too_many_lines)] -pub async fn launch( - _cli_args: &Cli, - _pr_args: &super::SubCommandArgs, - _args: &SubCommandArgs, -) -> Result<()> { - let git_repo = Repo::discover().context("cannot find a git repository")?; - - let root_commit = git_repo - .get_root_commit() - .context("failed to get root commit of the repository")?; - - // TODO: check for empty repo - // TODO: check for existing maintaiers file - // TODO: check for other claims - - #[cfg(not(test))] - let client = Client::default(); - #[cfg(test)] - let client = ::default(); - - let repo_ref = repo_ref::fetch( - &git_repo, - root_commit.to_string(), - &client, - client.get_fallback_relays().clone(), - ) - .await?; - - println!("finding PRs..."); - - let pr_events: Vec = - find_pr_events(&client, &repo_ref, &root_commit.to_string()).await?; - - let selected_index = Interactor::default().choice( - PromptChoiceParms::default() - .with_prompt("All PRs") - .with_choices( - pr_events - .iter() - .map(|e| { - if let Ok(cl) = event_to_cover_letter(e) { - cl.title - } else if let Ok(msg) = tag_value(e, "description") { - msg.split('\n').collect::>()[0].to_string() - } else { - e.id.to_string() - } - }) - .collect(), - ), - )?; - - println!("finding commits..."); - - let commits_events: Vec = - find_commits_for_pr_event(&client, &pr_events[selected_index], &repo_ref).await?; - - confirm_checkout(&git_repo)?; - - let most_recent_pr_patch_chain = get_most_recent_patch_with_ancestors(commits_events) - .context("cannot get most recent patch for PR")?; - - let branch_name: String = event_to_cover_letter(&pr_events[selected_index]) - .context("cannot assign a branch name as event is not a patch set root")? - .branch_name; - - let applied = git_repo - .apply_patch_chain(&branch_name, most_recent_pr_patch_chain) - .context("cannot apply patch chain")?; - - if applied.is_empty() { - println!("checked out PR branch. no new commits to pull"); - } else { - println!( - "checked out PR branch. pulled {} new commits", - applied.len(), - ); - } - Ok(()) -} - -fn confirm_checkout(git_repo: &Repo) -> Result<()> { - if !Interactor::default().confirm( - PromptConfirmParms::default() - .with_prompt("check out branch?") - .with_default(true), - )? { - bail!("Exiting..."); - } - - if git_repo.has_outstanding_changes()? { - bail!( - "cannot pull PR branch when repository is not clean. discard or stash (un)staged changes and try again." - ); - } - Ok(()) -} - -pub fn tag_value(event: &nostr::Event, tag_name: &str) -> Result { - Ok(event - .tags - .iter() - .find(|t| t.as_vec()[0].eq(tag_name)) - .context(format!("tag '{tag_name}'not present"))? - .as_vec()[1] - .clone()) -} - -pub fn get_most_recent_patch_with_ancestors( - mut patches: Vec, -) -> Result> { - patches.sort_by_key(|e| e.created_at); - - let first_patch = patches.first().context("no patches found")?; - - let patches_with_youngest_created_at: Vec<&nostr::Event> = patches - .iter() - .filter(|p| p.created_at.eq(&first_patch.created_at)) - .collect(); - - let latest_commit_id = tag_value( - // get the first patch which isn't a parent of a patch event created at the same - // time - patches_with_youngest_created_at - .clone() - .iter() - .find(|p| { - if let Ok(commit) = tag_value(p, "commit") { - !patches_with_youngest_created_at.iter().any(|p2| { - if let Ok(parent) = tag_value(p2, "parent-commit") { - commit.eq(&parent) - } else { - false // skip - } - }) - } else { - false // skip - } - }) - .context("cannot find patches_with_youngest_created_at")?, - "commit", - )?; - - let mut res = vec![]; - - let mut commit_id_to_search = latest_commit_id; - - while let Some(event) = patches.iter().find(|e| { - if let Ok(commit) = tag_value(e, "commit") { - commit.eq(&commit_id_to_search) - } else { - false // skip - } - }) { - res.push(event.clone()); - commit_id_to_search = tag_value(event, "parent-commit")?; - } - Ok(res) -} - -pub async fn find_pr_events( - #[cfg(test)] client: &crate::client::MockConnect, - #[cfg(not(test))] client: &Client, - repo_ref: &RepoRef, - root_commit: &str, -) -> Result> { - Ok(client - .get_events( - repo_ref.relays.clone(), - vec![ - nostr::Filter::default() - .kind(nostr::Kind::Custom(PATCH_KIND)) - .custom_tag(nostr::Alphabet::T, vec!["root"]) - .identifiers( - repo_ref - .maintainers - .iter() - .map(|m| format!("{REPO_REF_KIND}:{m}:{}", repo_ref.identifier)), - ), - // also pick up prs from the same repo but no target at our maintainers repo events - nostr::Filter::default() - .kind(nostr::Kind::Custom(PATCH_KIND)) - .custom_tag(nostr::Alphabet::T, vec!["root"]) - .reference(root_commit), - ], - ) - .await - .context("cannot get pr events")? - .iter() - .filter(|e| { - event_is_patch_set_root(e) - && (e - .tags - .iter() - .any(|t| t.as_vec().len() > 1 && t.as_vec()[1].eq(root_commit)) - || e.tags.iter().any(|t| { - t.as_vec().len() > 1 - && repo_ref - .maintainers - .iter() - .map(|m| format!("{REPO_REF_KIND}:{m}:{}", repo_ref.identifier)) - .any(|d| t.as_vec()[1].eq(&d)) - })) - }) - .map(std::borrow::ToOwned::to_owned) - .collect::>()) -} - -pub async fn find_commits_for_pr_event( - #[cfg(test)] client: &crate::client::MockConnect, - #[cfg(not(test))] client: &Client, - pr_event: &nostr::Event, - repo_ref: &RepoRef, -) -> Result> { - let mut patch_events: Vec = client - .get_events( - repo_ref.relays.clone(), - vec![ - nostr::Filter::default() - .kind(nostr::Kind::Custom(PATCH_KIND)) - // this requires every patch to reference the root event - // this will not pick up v2,v3 patch sets - // TODO: fetch commits for v2.. patch sets - .event(pr_event.id), - ], - ) - .await - .context("cannot fetch patch events")? - .iter() - .filter(|e| { - e.kind.as_u64() == PATCH_KIND - && e.tags - .iter() - .any(|t| t.as_vec().len() > 2 && t.as_vec()[1].eq(&pr_event.id.to_string())) - }) - .map(std::borrow::ToOwned::to_owned) - .collect(); - - if !event_is_cover_letter(pr_event) { - patch_events.push(pr_event.clone()); - } - Ok(patch_events) -} 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 @@ -use anyhow::Result; -use clap::Subcommand; - -use crate::Cli; -pub mod create; -pub mod list; - -#[derive(clap::Parser)] -pub struct SubCommandArgs { - #[command(subcommand)] - pub prs_command: Commands, -} - -#[derive(Debug, Subcommand)] -pub enum Commands { - Create(create::SubCommandArgs), - List(list::SubCommandArgs), -} - -pub async fn launch(cli_args: &Cli, pr_args: &SubCommandArgs) -> Result<()> { - match &pr_args.prs_command { - Commands::Create(args) => create::launch(cli_args, pr_args, args).await, - Commands::List(args) => list::launch(cli_args, pr_args, args).await, - } -} diff --git a/src/sub_commands/pull.rs b/src/sub_commands/pull.rs index 2b20d3d..fc6db37 100644 --- a/src/sub_commands/pull.rs +++ b/src/sub_commands/pull.rs @@ -9,7 +9,7 @@ use crate::{ git::{Repo, RepoActions}, repo_ref, sub_commands::{ - prs::list::get_most_recent_patch_with_ancestors, push::fetch_pr_and_most_recent_patch_chain, + list::get_most_recent_patch_with_ancestors, push::fetch_pr_and_most_recent_patch_chain, }, }; diff --git a/src/sub_commands/push.rs b/src/sub_commands/push.rs index eb42699..cc1f480 100644 --- a/src/sub_commands/push.rs +++ b/src/sub_commands/push.rs @@ -10,12 +10,12 @@ use crate::{ git::{str_to_sha1, Repo, RepoActions}, login, repo_ref::{self, RepoRef}, - sub_commands::prs::{ - create::{event_to_cover_letter, generate_patch_event, send_events}, + sub_commands::{ list::{ find_commits_for_pr_event, find_pr_events, get_most_recent_patch_with_ancestors, tag_value, }, + send::{event_to_cover_letter, generate_patch_event, send_events}, }, Cli, }; diff --git a/src/sub_commands/send.rs b/src/sub_commands/send.rs new file mode 100644 index 0000000..2c1dec8 --- /dev/null +++ b/src/sub_commands/send.rs @@ -0,0 +1,950 @@ +use std::time::Duration; + +use anyhow::{bail, Context, Result}; +use futures::future::join_all; +use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; +use nostr::{prelude::sha1::Hash as Sha1Hash, EventBuilder, Marker, Tag, TagKind}; + +use super::list::tag_value; +#[cfg(not(test))] +use crate::client::Client; +#[cfg(test)] +use crate::client::MockConnect; +use crate::{ + cli_interactor::{Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms}, + client::Connect, + git::{Repo, RepoActions}, + login, + repo_ref::{self, RepoRef, REPO_REF_KIND}, + Cli, +}; + +#[derive(Debug, clap::Args)] +pub struct SubCommandArgs { + #[clap(short, long)] + /// optional cover letter title + title: Option, + #[clap(short, long)] + /// optional cover letter description + description: Option, + #[clap(long)] + /// branch to get changes from (defaults to head) + from_branch: Option, + #[clap(long)] + /// destination branch (defaults to main or master) + to_branch: Option, + /// don't ask about a cover letter + #[arg(long, action)] + no_cover_letter: bool, +} + +#[allow(clippy::too_many_lines)] +pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { + let git_repo = Repo::discover().context("cannot find a git repository")?; + + let (from_branch, to_branch, mut ahead, behind) = + identify_ahead_behind(&git_repo, &args.from_branch, &args.to_branch)?; + + if ahead.is_empty() { + bail!(format!( + "'{from_branch}' is 0 commits ahead of '{to_branch}' so no patches were created" + )); + } + + if behind.is_empty() { + println!( + "creating patch for {} commits from '{from_branch}' that can be merged into '{to_branch}'", + ahead.len(), + ); + } else { + if !Interactor::default().confirm( + PromptConfirmParms::default() + .with_prompt( + format!( + "'{from_branch}' is {} commits behind '{to_branch}' and {} ahead. Consider rebasing before sending patches. Proceed anyway?", + behind.len(), + ahead.len(), + ) + ) + .with_default(false) + ).context("failed to get confirmation response from interactor confirm")? { + bail!("aborting so branch can be rebased"); + } + println!( + "creating patch for {} commit{} from '{from_branch}' that {} {} behind '{to_branch}'", + ahead.len(), + if ahead.len() > 1 { "s" } else { "" }, + if ahead.len() > 1 { "are" } else { "is" }, + behind.len(), + ); + } + + let title = if args.no_cover_letter { + None + } else { + match &args.title { + Some(t) => Some(t.clone()), + None => { + if Interactor::default().confirm( + PromptConfirmParms::default() + .with_default(false) + .with_prompt("include cover letter?"), + )? { + Some( + Interactor::default() + .input(PromptInputParms::default().with_prompt("title"))? + .clone(), + ) + } else { + None + } + } + } + }; + + let cover_letter_title_description = if let Some(title) = title { + Some(( + title, + if let Some(t) = &args.description { + t.clone() + } else { + Interactor::default() + .input(PromptInputParms::default().with_prompt("cover letter description"))? + .clone() + }, + )) + } else { + None + }; + + #[cfg(not(test))] + let mut client = Client::default(); + #[cfg(test)] + let mut client = ::default(); + + let (keys, user_ref) = login::launch(&cli_args.nsec, &cli_args.password, Some(&client)).await?; + + client.set_keys(&keys).await; + + let repo_ref = repo_ref::fetch( + &git_repo, + git_repo + .get_root_commit() + .context("failed to get root commit of the repository")? + .to_string(), + &client, + user_ref.relays.write(), + ) + .await?; + + // oldest first + ahead.reverse(); + + let events = generate_pr_and_patch_events( + cover_letter_title_description.clone(), + &git_repo, + &ahead, + &keys, + &repo_ref, + )?; + + println!( + "posting {} patches {} a covering letter...", + if cover_letter_title_description.is_none() { + events.len() + } else { + events.len() - 1 + }, + if cover_letter_title_description.is_none() { + "without" + } else { + "with" + } + ); + + send_events( + &client, + events, + user_ref.relays.write(), + repo_ref.relays.clone(), + !cli_args.disable_cli_spinners, + ) + .await?; + // TODO check if there is already a similarly named + Ok(()) +} + +#[allow(clippy::module_name_repetitions)] +pub async fn send_events( + #[cfg(test)] client: &crate::client::MockConnect, + #[cfg(not(test))] client: &Client, + events: Vec, + my_write_relays: Vec, + repo_read_relays: Vec, + animate: bool, +) -> Result<()> { + let (_, _, _, all) = unique_and_duplicate_all(&my_write_relays, &repo_read_relays); + + let m = MultiProgress::new(); + let pb_style = ProgressStyle::with_template(if animate { + " {spinner} {prefix} {bar} {pos}/{len} {msg}" + } else { + " - {prefix} {bar} {pos}/{len} {msg}" + })? + .progress_chars("##-"); + + let pb_after_style = + |symbol| ProgressStyle::with_template(format!(" {symbol} {}", "{prefix} {msg}",).as_str()); + let pb_after_style_succeeded = pb_after_style(if animate { + console::style("✔".to_string()) + .for_stderr() + .green() + .to_string() + } else { + "y".to_string() + })?; + + let pb_after_style_failed = pb_after_style(if animate { + console::style("✘".to_string()) + .for_stderr() + .red() + .to_string() + } else { + "x".to_string() + })?; + + join_all(all.iter().map(|&relay| async { + let details = format!( + "{}{} {}", + if my_write_relays.iter().any(|r| relay.eq(r)) { + " [my-relay]" + } else { + "" + }, + if repo_read_relays.iter().any(|r| relay.eq(r)) { + " [repo-relay]" + } else { + "" + }, + *relay, + ); + let pb = m.add( + ProgressBar::new(events.len() as u64) + .with_prefix(details.to_string()) + .with_style(pb_style.clone()), + ); + if animate { + pb.enable_steady_tick(Duration::from_millis(300)); + } + pb.inc(0); // need to make pb display intially + let mut failed = false; + for event in &events { + match client.send_event_to(relay.as_str(), event.clone()).await { + Ok(_) => pb.inc(1), + Err(e) => { + pb.set_style(pb_after_style_failed.clone()); + pb.finish_with_message( + console::style( + e.to_string() + .replace("relay pool error:", "error:") + .replace("event not published: ", ""), + ) + .for_stderr() + .red() + .to_string(), + ); + failed = true; + break; + } + }; + } + if !failed { + pb.set_style(pb_after_style_succeeded.clone()); + pb.finish_with_message(""); + } + })) + .await; + Ok(()) +} + +/// returns `(unique_vec1, unique_vec2, duplicates, all)` +fn unique_and_duplicate_all<'a, S>( + vec1: &'a Vec, + vec2: &'a Vec, +) -> (Vec<&'a S>, Vec<&'a S>, Vec<&'a S>, Vec<&'a S>) +where + S: PartialEq, +{ + let mut vec1_u = vec![]; + let mut vec2_u = vec![]; + let mut dup = vec![]; + let mut all = vec![]; + for s1 in vec1 { + if vec2.iter().any(|s2| s1.eq(s2)) { + dup.push(s1); + } else { + vec1_u.push(s1); + } + } + for s2 in vec2 { + if !vec1.iter().any(|s1| s2.eq(s1)) { + vec2_u.push(s2); + } + } + for a in [&dup, &vec1_u, &vec2_u] { + for e in a { + all.push(&**e); + } + } + (vec1_u, vec2_u, dup, all) +} + +mod tests_unique_and_duplicate { + + #[test] + fn correct_number_of_unique_and_duplicate_items() { + let v1 = vec![ + "t1".to_string(), + "t2".to_string(), + "t3".to_string(), + "t4".to_string(), + "t5".to_string(), + ]; + let v2 = vec![ + "t3".to_string(), + "t4".to_string(), + "t5".to_string(), + "t6".to_string(), + ]; + + let (v1_u, v2_u, d, a) = super::unique_and_duplicate_all(&v1, &v2); + + assert_eq!(v1_u.len(), 2); + assert_eq!(v2_u.len(), 1); + assert_eq!(d.len(), 3); + assert_eq!(a.len(), 6); + } + #[test] + fn all_begins_with_duplicates() { + let v1 = vec![ + "t1".to_string(), + "t2".to_string(), + "t3".to_string(), + "t4".to_string(), + "t5".to_string(), + ]; + let v2 = vec![ + "t3".to_string(), + "t4".to_string(), + "t5".to_string(), + "t6".to_string(), + ]; + + let (_, _, d, a) = super::unique_and_duplicate_all(&v1, &v2); + + assert_eq!(a[0], d[0]); + } +} + +pub static PATCH_KIND: u64 = 1617; + +pub fn generate_pr_and_patch_events( + cover_letter_title_description: Option<(String, String)>, + git_repo: &Repo, + commits: &Vec, + keys: &nostr::Keys, + repo_ref: &RepoRef, +) -> Result> { + let root_commit = git_repo + .get_root_commit() + .context("failed to get root commit of the repository")?; + + let mut events = vec![]; + + if let Some((title, description)) = cover_letter_title_description { + events.push(EventBuilder::new( + nostr::event::Kind::Custom(PATCH_KIND), + format!( + "From {} Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/{}] {title}\n\n{description}", + commits.last().unwrap(), + commits.len() + ), + [ + vec![ + // TODO: why not tag all maintainer identifiers? + Tag::A { + kind: nostr::Kind::Custom(REPO_REF_KIND), + public_key: *repo_ref.maintainers.first() + .context("repo reference should always have at least one maintainer - the issuer of the repo event") + ?, + identifier: repo_ref.identifier.to_string(), + relay_url: repo_ref.relays.first().map(nostr::UncheckedUrl::from).clone(), + }, + Tag::Reference(format!("{root_commit}")), + Tag::Hashtag("cover-letter".to_string()), + Tag::Hashtag("root".to_string()), + ], + if let Ok(branch_name) = git_repo.get_checked_out_branch_name() { + vec![Tag::Generic( + TagKind::Custom("branch-name".to_string()), + vec![branch_name], + )] + } else { + vec![] + }, + repo_ref.maintainers + .iter() + .map(|pk| Tag::public_key(*pk)) + .collect(), + ].concat(), + ) + .to_event(keys) + .context("failed to create cover-letter event")?); + } + + for (i, commit) in commits.iter().enumerate() { + events.push( + generate_patch_event( + git_repo, + &root_commit, + commit, + events.first().map(|event| event.id), + keys, + repo_ref, + events.last().map(nostr::Event::id), + if events.is_empty() { + None + } else { + Some(((i + 1).try_into()?, commits.len().try_into()?)) + }, + if events.is_empty() { + if let Ok(branch_name) = git_repo.get_checked_out_branch_name() { + Some(branch_name) + } else { + None + } + } else { + None + }, + ) + .context("failed to generate patch event")?, + ); + } + Ok(events) +} + +pub struct CoverLetter { + pub title: String, + pub description: String, + pub branch_name: String, +} + +pub fn event_is_cover_letter(event: &nostr::Event) -> bool { + // TODO: look for Subject:[ PATCH 0/n ] but watch out for: + // [PATCH v1 0/n ] or + // [PATCH subsystem v2 0/n ] + event.kind.as_u64().eq(&PATCH_KIND) + && event.iter_tags().any(|t| t.as_vec()[1].eq("root")) + && event.iter_tags().any(|t| t.as_vec()[1].eq("cover-letter")) +} +pub fn event_to_cover_letter(event: &nostr::Event) -> Result { + if !event_is_patch_set_root(event) { + bail!("event is not a patch set root event (root patch or cover letter)") + } + let title_index = event + .content + .find("] ") + .context("event is not formatted as a patch or cover letter")? + + 2; + let description_index = event.content[title_index..] + .find('\n') + .unwrap_or(event.content.len() - 1 - title_index) + + title_index; + + let title = if let Ok(msg) = tag_value(event, "description") { + msg.split('\n').collect::>()[0].to_string() + } else { + event.content[title_index..description_index].to_string() + }; + + // note: if the description field is removed from patch events like in gitstr, + // then this will show entire patch. I'm not sure it is ever displayed though + let description = if let Ok(msg) = tag_value(event, "description") { + if let Some((_before, after)) = msg.split_once('\n') { + after.trim().to_string() + } else { + String::new() + } + } else { + event.content[description_index..].trim().to_string() + }; + + Ok(CoverLetter { + title: title.clone(), + description, + // TODO should this be prefixed by format!("{}-"e.id.to_string()[..5]?) + branch_name: if let Ok(name) = tag_value(event, "branch-name") { + name + } else { + let s = title + .replace(' ', "-") + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c.eq(&'/') { + c + } else { + '-' + } + }) + .collect(); + s + }, + }) +} + +pub fn event_is_patch_set_root(event: &nostr::Event) -> bool { + event.kind.as_u64().eq(&PATCH_KIND) && event.iter_tags().any(|t| t.as_vec()[1].eq("root")) +} + +#[allow(clippy::too_many_arguments)] +pub fn generate_patch_event( + git_repo: &Repo, + root_commit: &Sha1Hash, + commit: &Sha1Hash, + thread_event_id: Option, + keys: &nostr::Keys, + repo_ref: &RepoRef, + parent_patch_event_id: Option, + series_count: Option<(u64, u64)>, + branch_name: Option, +) -> Result { + let commit_parent = git_repo + .get_commit_parent(commit) + .context("failed to get parent commit")?; + let relay_hint = repo_ref.relays.first().map(nostr::UncheckedUrl::from); + EventBuilder::new( + nostr::event::Kind::Custom(PATCH_KIND), + git_repo + .make_patch_from_commit(commit,&series_count) + .context(format!("cannot make patch for commit {commit}"))?, + [ + vec![ + Tag::A { + kind: nostr::Kind::Custom(REPO_REF_KIND), + public_key: *repo_ref.maintainers.first() + .context("repo reference should always have at least one maintainer - the issuer of the repo event") + ?, + identifier: repo_ref.identifier.to_string(), + relay_url: relay_hint.clone(), + }, + Tag::Reference(format!("{root_commit}")), + // commit id reference is a trade-off. its now + // unclear which one is the root commit id but it + // enables easier location of code comments againt + // code that makes it into the main branch, assuming + // the commit id is correct + Tag::Reference(commit.to_string()), + + if let Some(thread_event_id) = thread_event_id { Tag::Event { + event_id: thread_event_id, + relay_url: relay_hint.clone(), + marker: Some(Marker::Root), + } } + else { + Tag::Hashtag("root".to_string()) + }, + ], + if let Some(id) = parent_patch_event_id { + vec![Tag::Event { + event_id: id, + relay_url: relay_hint.clone(), + marker: Some(Marker::Reply), + }] + } else { + vec![] + }, + if let Some(branch_name) = branch_name { + if thread_event_id.is_none() { + vec![ + Tag::Generic( + TagKind::Custom("branch-name".to_string()), + vec![branch_name.to_string()], + ) + ] + } + else { vec![]} + } + else { vec![]}, + // whilst it is in nip34 draft to tag the maintainers + // I'm not sure it is a good idea because if they are + // interested in all patches then their specialised + // client should subscribe to patches tagged with the + // repo reference. maintainers of large repos will not + // be interested in every patch. + repo_ref.maintainers + .iter() + .map(|pk| Tag::public_key(*pk)) + .collect(), + vec![ + Tag::Generic( + TagKind::Custom("commit".to_string()), + vec![commit.to_string()], + ), + Tag::Generic( + TagKind::Custom("parent-commit".to_string()), + vec![commit_parent.to_string()], + ), + Tag::Generic( + TagKind::Custom("commit-pgp-sig".to_string()), + vec![ + git_repo + .extract_commit_pgp_signature(commit) + .unwrap_or_default(), + ], + ), + Tag::Description(git_repo.get_commit_message(commit)?.to_string()), + Tag::Generic( + TagKind::Custom("author".to_string()), + git_repo.get_commit_author(commit)?, + ), + Tag::Generic( + TagKind::Custom("committer".to_string()), + git_repo.get_commit_comitter(commit)?, + ), + ], + ] + .concat(), + ) + .to_event(keys) + .context("failed to sign event") +} +// TODO +// - find profile +// - file relays +// - find repo events +// - + +/** + * returns `(from_branch,to_branch,ahead,behind)` + */ +fn identify_ahead_behind( + git_repo: &Repo, + from_branch: &Option, + to_branch: &Option, +) -> Result<(String, String, Vec, Vec)> { + let (from_branch, from_tip) = match from_branch { + Some(name) => ( + name.to_string(), + git_repo + .get_tip_of_local_branch(name) + .context(format!("cannot find from_branch '{name}'"))?, + ), + None => ( + "head".to_string(), + git_repo + .get_head_commit() + .context("failed to get head commit") + .context( + "checkout a commit or specify a from_branch. head does not reveal a commit", + )?, + ), + }; + + let (to_branch, to_tip) = match to_branch { + Some(name) => ( + name.to_string(), + git_repo + .get_tip_of_local_branch(name) + .context(format!("cannot find to_branch '{name}'"))?, + ), + None => { + let (name, commit) = git_repo + .get_main_or_master_branch() + .context("a destination branch (to_branch) is not specified and the defaults (main or master) do not exist")?; + (name.to_string(), commit) + } + }; + + match git_repo.get_commits_ahead_behind(&to_tip, &from_tip) { + Err(e) => { + if e.to_string().contains("is not an ancestor of") { + return Err(e).context(format!( + "'{from_branch}' is not branched from '{to_branch}'" + )); + } + Err(e).context(format!( + "failed to get commits ahead and behind from '{from_branch}' to '{to_branch}'" + )) + } + Ok((ahead, behind)) => Ok((from_branch, to_branch, ahead, behind)), + } +} + +#[cfg(test)] +mod tests { + use test_utils::git::GitTestRepo; + + use super::*; + mod identify_ahead_behind { + + use super::*; + use crate::git::oid_to_sha1; + + #[test] + fn when_from_branch_doesnt_exist_return_error() -> Result<()> { + let test_repo = GitTestRepo::default(); + let git_repo = Repo::from_path(&test_repo.dir)?; + + test_repo.populate()?; + let branch_name = "doesnt_exist"; + assert_eq!( + identify_ahead_behind(&git_repo, &Some(branch_name.to_string()), &None) + .unwrap_err() + .to_string(), + format!("cannot find from_branch '{}'", &branch_name), + ); + Ok(()) + } + + #[test] + fn when_to_branch_doesnt_exist_return_error() -> Result<()> { + let test_repo = GitTestRepo::default(); + let git_repo = Repo::from_path(&test_repo.dir)?; + + test_repo.populate()?; + let branch_name = "doesnt_exist"; + assert_eq!( + identify_ahead_behind(&git_repo, &None, &Some(branch_name.to_string())) + .unwrap_err() + .to_string(), + format!("cannot find to_branch '{}'", &branch_name), + ); + Ok(()) + } + + #[test] + fn when_to_branch_is_none_and_no_main_or_master_branch_return_error() -> Result<()> { + let test_repo = GitTestRepo::new("notmain")?; + let git_repo = Repo::from_path(&test_repo.dir)?; + + test_repo.populate()?; + + assert_eq!( + identify_ahead_behind(&git_repo, &None, &None) + .unwrap_err() + .to_string(), + "a destination branch (to_branch) is not specified and the defaults (main or master) do not exist", + ); + Ok(()) + } + + #[test] + fn when_from_branch_is_none_return_as_head() -> Result<()> { + let test_repo = GitTestRepo::default(); + let git_repo = Repo::from_path(&test_repo.dir)?; + + test_repo.populate()?; + // create feature branch with 1 commit ahead + test_repo.create_branch("feature")?; + test_repo.checkout("feature")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + let head_oid = test_repo.stage_and_commit("add t3.md")?; + + // make feature branch 1 commit behind + test_repo.checkout("main")?; + std::fs::write(test_repo.dir.join("t4.md"), "some content")?; + let main_oid = test_repo.stage_and_commit("add t4.md")?; + // checkout feature + test_repo.checkout("feature")?; + + let (from_branch, to_branch, ahead, behind) = + identify_ahead_behind(&git_repo, &None, &None)?; + + assert_eq!(from_branch, "head"); + assert_eq!(ahead, vec![oid_to_sha1(&head_oid)]); + assert_eq!(to_branch, "main"); + assert_eq!(behind, vec![oid_to_sha1(&main_oid)]); + Ok(()) + } + + #[test] + fn when_from_branch_is_not_head_return_as_from_branch() -> Result<()> { + let test_repo = GitTestRepo::default(); + let git_repo = Repo::from_path(&test_repo.dir)?; + + test_repo.populate()?; + // create feature branch with 1 commit ahead + test_repo.create_branch("feature")?; + test_repo.checkout("feature")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + let head_oid = test_repo.stage_and_commit("add t3.md")?; + + // make feature branch 1 commit behind + test_repo.checkout("main")?; + std::fs::write(test_repo.dir.join("t4.md"), "some content")?; + let main_oid = test_repo.stage_and_commit("add t4.md")?; + + let (from_branch, to_branch, ahead, behind) = + identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?; + + assert_eq!(from_branch, "feature"); + assert_eq!(ahead, vec![oid_to_sha1(&head_oid)]); + assert_eq!(to_branch, "main"); + assert_eq!(behind, vec![oid_to_sha1(&main_oid)]); + Ok(()) + } + + #[test] + fn when_to_branch_is_not_main_return_as_to_branch() -> Result<()> { + let test_repo = GitTestRepo::default(); + let git_repo = Repo::from_path(&test_repo.dir)?; + + test_repo.populate()?; + // create dev branch with 1 commit ahead + test_repo.create_branch("dev")?; + test_repo.checkout("dev")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + let dev_oid_first = test_repo.stage_and_commit("add t3.md")?; + + // create feature branch with 1 commit ahead of dev + test_repo.create_branch("feature")?; + test_repo.checkout("feature")?; + std::fs::write(test_repo.dir.join("t4.md"), "some content")?; + let feature_oid = test_repo.stage_and_commit("add t4.md")?; + + // make feature branch 1 behind + test_repo.checkout("dev")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + let dev_oid = test_repo.stage_and_commit("add t3.md")?; + + let (from_branch, to_branch, ahead, behind) = identify_ahead_behind( + &git_repo, + &Some("feature".to_string()), + &Some("dev".to_string()), + )?; + + assert_eq!(from_branch, "feature"); + assert_eq!(ahead, vec![oid_to_sha1(&feature_oid)]); + assert_eq!(to_branch, "dev"); + assert_eq!(behind, vec![oid_to_sha1(&dev_oid)]); + + let (from_branch, to_branch, ahead, behind) = + identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?; + + assert_eq!(from_branch, "feature"); + assert_eq!( + ahead, + vec![oid_to_sha1(&feature_oid), oid_to_sha1(&dev_oid_first)] + ); + assert_eq!(to_branch, "main"); + assert_eq!(behind, vec![]); + + Ok(()) + } + } + + mod event_to_cover_letter { + use super::*; + + fn generate_cover_letter(title: &str, description: &str) -> Result { + Ok(nostr::event::EventBuilder::new( + nostr::event::Kind::Custom(PATCH_KIND), + format!("From ea897e987ea9a7a98e7a987e97987ea98e7a3334 Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/2] {title}\n\n{description}"), + [ + Tag::Hashtag("cover-letter".to_string()), + Tag::Hashtag("root".to_string()), + ], + ) + .to_event(&nostr::Keys::generate())?) + } + + #[test] + fn basic_title() -> Result<()> { + assert_eq!( + event_to_cover_letter(&generate_cover_letter("the title", "description here")?)? + .title, + "the title", + ); + Ok(()) + } + + #[test] + fn basic_description() -> Result<()> { + assert_eq!( + event_to_cover_letter(&generate_cover_letter("the title", "description here")?)? + .description, + "description here", + ); + Ok(()) + } + + #[test] + fn description_trimmed() -> Result<()> { + assert_eq!( + event_to_cover_letter(&generate_cover_letter( + "the title", + " \n \ndescription here\n\n " + )?)? + .description, + "description here", + ); + Ok(()) + } + + #[test] + fn multi_line_description() -> Result<()> { + assert_eq!( + event_to_cover_letter(&generate_cover_letter( + "the title", + "description here\n\nmore here\nmore" + )?)? + .description, + "description here\n\nmore here\nmore", + ); + Ok(()) + } + + #[test] + fn new_lines_in_title_forms_part_of_description() -> Result<()> { + assert_eq!( + event_to_cover_letter(&generate_cover_letter( + "the title\nwith new line", + "description here\n\nmore here\nmore" + )?)? + .title, + "the title", + ); + assert_eq!( + event_to_cover_letter(&generate_cover_letter( + "the title\nwith new line", + "description here\n\nmore here\nmore" + )?)? + .description, + "with new line\n\ndescription here\n\nmore here\nmore", + ); + Ok(()) + } + + mod blank_description { + use super::*; + + #[test] + fn title_correct() -> Result<()> { + assert_eq!( + event_to_cover_letter(&generate_cover_letter("the title", "")?)?.title, + "the title", + ); + Ok(()) + } + + #[test] + fn description_is_empty_string() -> Result<()> { + assert_eq!( + event_to_cover_letter(&generate_cover_letter("the title", "")?)?.description, + "", + ); + Ok(()) + } + } + } +} diff --git a/tests/list.rs b/tests/list.rs new file mode 100644 index 0000000..0d1d4e9 --- /dev/null +++ b/tests/list.rs @@ -0,0 +1,975 @@ +use anyhow::Result; +use futures::join; +use serial_test::serial; +use test_utils::{git::GitTestRepo, relay::Relay, *}; + +static FEATURE_BRANCH_NAME_1: &str = "feature-example-t"; +static FEATURE_BRANCH_NAME_2: &str = "feature-example-f"; +static FEATURE_BRANCH_NAME_3: &str = "feature-example-c"; +static FEATURE_BRANCH_NAME_4: &str = "feature-example-d"; + +static PR_TITLE_1: &str = "pr a"; +static PR_TITLE_2: &str = "pr b"; +static PR_TITLE_3: &str = "pr c"; + +fn cli_tester_create_prs() -> Result { + let git_repo = GitTestRepo::default(); + git_repo.populate()?; + cli_tester_create_pr( + &git_repo, + FEATURE_BRANCH_NAME_1, + "a", + Some((PR_TITLE_1, "pr a description")), + )?; + cli_tester_create_pr( + &git_repo, + FEATURE_BRANCH_NAME_2, + "b", + Some((PR_TITLE_2, "pr b description")), + )?; + cli_tester_create_pr( + &git_repo, + FEATURE_BRANCH_NAME_3, + "c", + Some((PR_TITLE_3, "pr c description")), + )?; + Ok(git_repo) +} + +fn create_and_populate_branch( + test_repo: &GitTestRepo, + branch_name: &str, + prefix: &str, + only_one_commit: bool, +) -> Result<()> { + test_repo.checkout("main")?; + test_repo.create_branch(branch_name)?; + test_repo.checkout(branch_name)?; + std::fs::write( + test_repo.dir.join(format!("{}3.md", prefix)), + "some content", + )?; + test_repo.stage_and_commit(format!("add {}3.md", prefix).as_str())?; + if !only_one_commit { + std::fs::write( + test_repo.dir.join(format!("{}4.md", prefix)), + "some content", + )?; + test_repo.stage_and_commit(format!("add {}4.md", prefix).as_str())?; + } + Ok(()) +} + +fn cli_tester_create_pr( + test_repo: &GitTestRepo, + branch_name: &str, + prefix: &str, + cover_letter_title_and_description: Option<(&str, &str)>, +) -> Result<()> { + create_and_populate_branch(test_repo, branch_name, prefix, false)?; + + if let Some((title, description)) = cover_letter_title_and_description { + let mut p = CliTester::new_from_dir( + &test_repo.dir, + [ + "--nsec", + TEST_KEY_1_NSEC, + "--password", + TEST_PASSWORD, + "--disable-cli-spinners", + "send", + "--title", + format!("\"{title}\"").as_str(), + "--description", + format!("\"{description}\"").as_str(), + ], + ); + p.expect_end_eventually()?; + } else { + let mut p = CliTester::new_from_dir( + &test_repo.dir, + [ + "--nsec", + TEST_KEY_1_NSEC, + "--password", + TEST_PASSWORD, + "--disable-cli-spinners", + "send", + "--no-cover-letter", + ], + ); + p.expect_end_eventually()?; + } + Ok(()) +} + +mod when_main_branch_is_uptodate { + use super::*; + + mod when_pr_branch_doesnt_exist { + use super::*; + + mod when_main_is_checked_out { + use super::*; + + mod when_first_pr_selected { + use super::*; + + // TODO: test when other prs with the same name but from other repositories are + // present on relays + async fn prep_and_run() -> Result<(GitTestRepo, GitTestRepo)> { + // fallback (51,52) user write (53, 55) repo (55, 56) + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new(8051, None, None), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + ); + + r51.events.push(generate_test_key_1_relay_list_event()); + r51.events.push(generate_test_key_1_metadata_event("fred")); + r51.events.push(generate_repo_ref_event()); + + r55.events.push(generate_repo_ref_event()); + r55.events.push(generate_test_key_1_metadata_event("fred")); + r55.events.push(generate_test_key_1_relay_list_event()); + + let cli_tester_handle = + std::thread::spawn(move || -> Result<(GitTestRepo, GitTestRepo)> { + let originating_repo = cli_tester_create_prs()?; + + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + + p.expect("finding PRs...\r\n")?; + let mut c = p.expect_choice( + "All PRs", + vec![ + format!("\"{PR_TITLE_1}\""), + format!("\"{PR_TITLE_2}\""), + format!("\"{PR_TITLE_3}\""), + ], + )?; + c.succeeds_with(0, true)?; + let mut confirm = + p.expect_confirm_eventually("check out branch?", Some(true))?; + confirm.succeeds_with(None)?; + p.expect_end_eventually_and_print()?; + + for p in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + p)?; + } + Ok((originating_repo, test_repo)) + }); + + // launch relay + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + let res = cli_tester_handle.join().unwrap()?; + + Ok(res) + } + + mod cli_prompts { + use super::*; + async fn run_async_prompts_to_choose_from_pr_titles() -> Result<()> { + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new(8051, None, None), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + ); + + r51.events.push(generate_test_key_1_relay_list_event()); + r51.events.push(generate_test_key_1_metadata_event("fred")); + r51.events.push(generate_repo_ref_event()); + + r55.events.push(generate_repo_ref_event()); + r55.events.push(generate_test_key_1_metadata_event("fred")); + r55.events.push(generate_test_key_1_relay_list_event()); + + let cli_tester_handle = std::thread::spawn(move || -> Result<()> { + cli_tester_create_prs()?; + + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + + p.expect("finding PRs...\r\n")?; + let mut c = p.expect_choice( + "All PRs", + vec![ + format!("\"{PR_TITLE_1}\""), + format!("\"{PR_TITLE_2}\""), + format!("\"{PR_TITLE_3}\""), + ], + )?; + c.succeeds_with(0, true)?; + p.expect("finding commits...\r\n")?; + let mut confirm = p.expect_confirm("check out branch?", Some(true))?; + confirm.succeeds_with(None)?; + p.expect("checked out PR branch. pulled 2 new commits\r\n")?; + p.expect_end()?; + + for p in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + p)?; + } + Ok(()) + }); + + // launch relay + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + cli_tester_handle.join().unwrap()?; + println!("{:?}", r55.events); + Ok(()) + } + + #[tokio::test] + #[serial] + async fn prompts_to_choose_from_pr_titles() -> Result<()> { + let _ = run_async_prompts_to_choose_from_pr_titles().await; + Ok(()) + } + } + + #[tokio::test] + #[serial] + async fn pr_branch_created_with_correct_name() -> Result<()> { + let (_, test_repo) = prep_and_run().await?; + assert_eq!( + vec![FEATURE_BRANCH_NAME_1, "main"], + test_repo.get_local_branch_names()? + ); + Ok(()) + } + + #[tokio::test] + #[serial] + async fn pr_branch_checked_out() -> Result<()> { + let (_, test_repo) = prep_and_run().await?; + assert_eq!( + FEATURE_BRANCH_NAME_1, + test_repo.get_checked_out_branch_name()?, + ); + Ok(()) + } + + #[tokio::test] + #[serial] + async fn pr_branch_tip_is_most_recent_patch() -> Result<()> { + let (originating_repo, test_repo) = prep_and_run().await?; + assert_eq!( + originating_repo.get_tip_of_local_branch(FEATURE_BRANCH_NAME_1)?, + test_repo.get_tip_of_local_branch(FEATURE_BRANCH_NAME_1)?, + ); + Ok(()) + } + } + mod when_third_pr_selected { + use super::*; + + async fn prep_and_run() -> Result<(GitTestRepo, GitTestRepo)> { + // fallback (51,52) user write (53, 55) repo (55, 56) + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new(8051, None, None), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + ); + + r51.events.push(generate_test_key_1_relay_list_event()); + r51.events.push(generate_test_key_1_metadata_event("fred")); + r51.events.push(generate_repo_ref_event()); + + r55.events.push(generate_repo_ref_event()); + r55.events.push(generate_test_key_1_metadata_event("fred")); + r55.events.push(generate_test_key_1_relay_list_event()); + + let cli_tester_handle = + std::thread::spawn(move || -> Result<(GitTestRepo, GitTestRepo)> { + let originating_repo = cli_tester_create_prs()?; + + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + + p.expect("finding PRs...\r\n")?; + let mut c = p.expect_choice( + "All PRs", + vec![ + format!("\"{PR_TITLE_1}\""), + format!("\"{PR_TITLE_2}\""), + format!("\"{PR_TITLE_3}\""), + ], + )?; + c.succeeds_with(2, true)?; + let mut confirm = + p.expect_confirm_eventually("check out branch?", Some(true))?; + confirm.succeeds_with(None)?; + p.expect_end_eventually_and_print()?; + + for p in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + p)?; + } + Ok((originating_repo, test_repo)) + }); + + // launch relay + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + let res = cli_tester_handle.join().unwrap()?; + + Ok(res) + } + + mod cli_prompts { + use super::*; + async fn run_async_prompts_to_choose_from_pr_titles() -> Result<()> { + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new(8051, None, None), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + ); + + r51.events.push(generate_test_key_1_relay_list_event()); + r51.events.push(generate_test_key_1_metadata_event("fred")); + r51.events.push(generate_repo_ref_event()); + + r55.events.push(generate_repo_ref_event()); + r55.events.push(generate_test_key_1_metadata_event("fred")); + r55.events.push(generate_test_key_1_relay_list_event()); + + let cli_tester_handle = std::thread::spawn(move || -> Result<()> { + cli_tester_create_prs()?; + + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + + p.expect("finding PRs...\r\n")?; + let mut c = p.expect_choice( + "All PRs", + vec![ + format!("\"{PR_TITLE_1}\""), + format!("\"{PR_TITLE_2}\""), + format!("\"{PR_TITLE_3}\""), + ], + )?; + c.succeeds_with(2, true)?; + p.expect("finding commits...\r\n")?; + let mut confirm = p.expect_confirm("check out branch?", Some(true))?; + confirm.succeeds_with(None)?; + p.expect("checked out PR branch. pulled 2 new commits\r\n")?; + p.expect_end()?; + + for p in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + p)?; + } + Ok(()) + }); + + // launch relay + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + cli_tester_handle.join().unwrap()?; + println!("{:?}", r55.events); + Ok(()) + } + + #[tokio::test] + #[serial] + async fn prompts_to_choose_from_pr_titles() -> Result<()> { + let _ = run_async_prompts_to_choose_from_pr_titles().await; + Ok(()) + } + } + + #[tokio::test] + #[serial] + async fn pr_branch_created_with_correct_name() -> Result<()> { + let (_, test_repo) = prep_and_run().await?; + assert_eq!( + vec![FEATURE_BRANCH_NAME_3, "main"], + test_repo.get_local_branch_names()? + ); + Ok(()) + } + + #[tokio::test] + #[serial] + async fn pr_branch_checked_out() -> Result<()> { + let (_, test_repo) = prep_and_run().await?; + assert_eq!( + FEATURE_BRANCH_NAME_3, + test_repo.get_checked_out_branch_name()?, + ); + Ok(()) + } + + #[tokio::test] + #[serial] + async fn pr_branch_tip_is_most_recent_patch() -> Result<()> { + let (originating_repo, test_repo) = prep_and_run().await?; + assert_eq!( + originating_repo.get_tip_of_local_branch(FEATURE_BRANCH_NAME_3)?, + test_repo.get_tip_of_local_branch(FEATURE_BRANCH_NAME_3)?, + ); + Ok(()) + } + } + mod when_forth_pr_has_no_cover_letter { + use super::*; + + async fn prep_and_run() -> Result<(GitTestRepo, GitTestRepo)> { + // fallback (51,52) user write (53, 55) repo (55, 56) + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new(8051, None, None), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + ); + + r51.events.push(generate_test_key_1_relay_list_event()); + r51.events.push(generate_test_key_1_metadata_event("fred")); + r51.events.push(generate_repo_ref_event()); + + r55.events.push(generate_repo_ref_event()); + r55.events.push(generate_test_key_1_metadata_event("fred")); + r55.events.push(generate_test_key_1_relay_list_event()); + + let cli_tester_handle = + std::thread::spawn(move || -> Result<(GitTestRepo, GitTestRepo)> { + let originating_repo = cli_tester_create_prs()?; + cli_tester_create_pr( + &originating_repo, + FEATURE_BRANCH_NAME_4, + "d", + None, + )?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + + p.expect("finding PRs...\r\n")?; + let mut c = p.expect_choice( + "All PRs", + vec![ + format!("\"{PR_TITLE_1}\""), + format!("\"{PR_TITLE_2}\""), + format!("\"{PR_TITLE_3}\""), + format!("add d3.md"), // commit msg title + ], + )?; + c.succeeds_with(3, true)?; + let mut confirm = + p.expect_confirm_eventually("check out branch?", Some(true))?; + confirm.succeeds_with(None)?; + p.expect_end_eventually_and_print()?; + + for p in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + p)?; + } + Ok((originating_repo, test_repo)) + }); + + // launch relay + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + let res = cli_tester_handle.join().unwrap()?; + + Ok(res) + } + + mod cli_prompts { + use super::*; + async fn run_async_prompts_to_choose_from_pr_titles() -> Result<()> { + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new(8051, None, None), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + ); + + r51.events.push(generate_test_key_1_relay_list_event()); + r51.events.push(generate_test_key_1_metadata_event("fred")); + r51.events.push(generate_repo_ref_event()); + + r55.events.push(generate_repo_ref_event()); + r55.events.push(generate_test_key_1_metadata_event("fred")); + r55.events.push(generate_test_key_1_relay_list_event()); + + let cli_tester_handle = std::thread::spawn(move || -> Result<()> { + let originating_repo = cli_tester_create_prs()?; + cli_tester_create_pr( + &originating_repo, + FEATURE_BRANCH_NAME_4, + "d", + None, + )?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + + p.expect("finding PRs...\r\n")?; + let mut c = p.expect_choice( + "All PRs", + vec![ + format!("\"{PR_TITLE_1}\""), + format!("\"{PR_TITLE_2}\""), + format!("\"{PR_TITLE_3}\""), + format!("add d3.md"), // commit msg title + ], + )?; + c.succeeds_with(3, true)?; + p.expect("finding commits...\r\n")?; + let mut confirm = p.expect_confirm("check out branch?", Some(true))?; + confirm.succeeds_with(None)?; + p.expect("checked out PR branch. pulled 2 new commits\r\n")?; + p.expect_end()?; + + for p in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + p)?; + } + Ok(()) + }); + + // launch relay + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + cli_tester_handle.join().unwrap()?; + println!("{:?}", r55.events); + Ok(()) + } + + #[tokio::test] + #[serial] + async fn prompts_to_choose_from_pr_titles() -> Result<()> { + let _ = run_async_prompts_to_choose_from_pr_titles().await; + Ok(()) + } + } + + #[tokio::test] + #[serial] + async fn pr_branch_created_with_correct_name() -> Result<()> { + let (_, test_repo) = prep_and_run().await?; + assert_eq!( + vec![FEATURE_BRANCH_NAME_4, "main"], + test_repo.get_local_branch_names()? + ); + Ok(()) + } + + #[tokio::test] + #[serial] + async fn pr_branch_checked_out() -> Result<()> { + let (_, test_repo) = prep_and_run().await?; + assert_eq!( + FEATURE_BRANCH_NAME_4, + test_repo.get_checked_out_branch_name()?, + ); + Ok(()) + } + + #[tokio::test] + #[serial] + async fn pr_branch_tip_is_most_recent_patch() -> Result<()> { + let (originating_repo, test_repo) = prep_and_run().await?; + assert_eq!( + originating_repo.get_tip_of_local_branch(FEATURE_BRANCH_NAME_4)?, + test_repo.get_tip_of_local_branch(FEATURE_BRANCH_NAME_4)?, + ); + Ok(()) + } + } + } + } + + mod when_pr_branch_exists { + use super::*; + + mod when_main_is_checked_out { + use super::*; + + mod when_branch_is_up_to_date { + use super::*; + async fn prep_and_run() -> Result<(GitTestRepo, GitTestRepo)> { + // fallback (51,52) user write (53, 55) repo (55, 56) + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new(8051, None, None), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + ); + + r51.events.push(generate_test_key_1_relay_list_event()); + r51.events.push(generate_test_key_1_metadata_event("fred")); + r51.events.push(generate_repo_ref_event()); + + r55.events.push(generate_repo_ref_event()); + r55.events.push(generate_test_key_1_metadata_event("fred")); + r55.events.push(generate_test_key_1_relay_list_event()); + + let cli_tester_handle = + std::thread::spawn(move || -> Result<(GitTestRepo, GitTestRepo)> { + let originating_repo = cli_tester_create_prs()?; + + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + + create_and_populate_branch( + &test_repo, + FEATURE_BRANCH_NAME_1, + "a", + false, + )?; + test_repo.checkout("main")?; + p.expect("finding PRs...\r\n")?; + let mut c = p.expect_choice( + "All PRs", + vec![ + format!("\"{PR_TITLE_1}\""), + format!("\"{PR_TITLE_2}\""), + format!("\"{PR_TITLE_3}\""), + ], + )?; + c.succeeds_with(0, true)?; + let mut confirm = + p.expect_confirm_eventually("check out branch?", Some(true))?; + confirm.succeeds_with(None)?; + p.expect_end_eventually_and_print()?; + + for p in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + p)?; + } + Ok((originating_repo, test_repo)) + }); + + // launch relay + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + let res = cli_tester_handle.join().unwrap()?; + + Ok(res) + } + + mod cli_prompts { + use super::*; + async fn run_async_prompts_to_choose_from_pr_titles() -> Result<()> { + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new(8051, None, None), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + ); + + r51.events.push(generate_test_key_1_relay_list_event()); + r51.events.push(generate_test_key_1_metadata_event("fred")); + r51.events.push(generate_repo_ref_event()); + + r55.events.push(generate_repo_ref_event()); + r55.events.push(generate_test_key_1_metadata_event("fred")); + r55.events.push(generate_test_key_1_relay_list_event()); + + let cli_tester_handle = std::thread::spawn(move || -> Result<()> { + cli_tester_create_prs()?; + + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + + create_and_populate_branch( + &test_repo, + FEATURE_BRANCH_NAME_1, + "a", + false, + )?; + test_repo.checkout("main")?; + + p.expect("finding PRs...\r\n")?; + let mut c = p.expect_choice( + "All PRs", + vec![ + format!("\"{PR_TITLE_1}\""), + format!("\"{PR_TITLE_2}\""), + format!("\"{PR_TITLE_3}\""), + ], + )?; + c.succeeds_with(0, true)?; + p.expect("finding commits...\r\n")?; + let mut confirm = p.expect_confirm("check out branch?", Some(true))?; + confirm.succeeds_with(None)?; + p.expect("checked out PR branch. no new commits to pull\r\n")?; + p.expect_end()?; + + for p in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + p)?; + } + Ok(()) + }); + + // launch relay + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + cli_tester_handle.join().unwrap()?; + println!("{:?}", r55.events); + Ok(()) + } + + #[tokio::test] + #[serial] + async fn prompts_to_choose_from_pr_titles() -> Result<()> { + let _ = run_async_prompts_to_choose_from_pr_titles().await; + Ok(()) + } + } + + #[tokio::test] + #[serial] + async fn pr_branch_checked_out() -> Result<()> { + let (_, test_repo) = prep_and_run().await?; + assert_eq!( + FEATURE_BRANCH_NAME_1, + test_repo.get_checked_out_branch_name()?, + ); + Ok(()) + } + } + + mod when_branch_is_behind { + use super::*; + + async fn prep_and_run() -> Result<(GitTestRepo, GitTestRepo)> { + // fallback (51,52) user write (53, 55) repo (55, 56) + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new(8051, None, None), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + ); + + r51.events.push(generate_test_key_1_relay_list_event()); + r51.events.push(generate_test_key_1_metadata_event("fred")); + r51.events.push(generate_repo_ref_event()); + + r55.events.push(generate_repo_ref_event()); + r55.events.push(generate_test_key_1_metadata_event("fred")); + r55.events.push(generate_test_key_1_relay_list_event()); + + let cli_tester_handle = + std::thread::spawn(move || -> Result<(GitTestRepo, GitTestRepo)> { + let originating_repo = cli_tester_create_prs()?; + + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + + create_and_populate_branch( + &test_repo, + FEATURE_BRANCH_NAME_1, + "a", + true, + )?; + test_repo.checkout("main")?; + + p.expect("finding PRs...\r\n")?; + let mut c = p.expect_choice( + "All PRs", + vec![ + format!("\"{PR_TITLE_1}\""), + format!("\"{PR_TITLE_2}\""), + format!("\"{PR_TITLE_3}\""), + ], + )?; + c.succeeds_with(0, true)?; + let mut confirm = + p.expect_confirm_eventually("check out branch?", Some(true))?; + confirm.succeeds_with(None)?; + p.expect_end_eventually_and_print()?; + + for p in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + p)?; + } + Ok((originating_repo, test_repo)) + }); + + // launch relay + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + let res = cli_tester_handle.join().unwrap()?; + + Ok(res) + } + + mod cli_prompts { + use super::*; + async fn run_async_prompts_to_choose_from_pr_titles() -> Result<()> { + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new(8051, None, None), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new(8055, None, None), + Relay::new(8056, None, None), + ); + + r51.events.push(generate_test_key_1_relay_list_event()); + r51.events.push(generate_test_key_1_metadata_event("fred")); + r51.events.push(generate_repo_ref_event()); + + r55.events.push(generate_repo_ref_event()); + r55.events.push(generate_test_key_1_metadata_event("fred")); + r55.events.push(generate_test_key_1_relay_list_event()); + + let cli_tester_handle = std::thread::spawn(move || -> Result<()> { + cli_tester_create_prs()?; + + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let mut p = CliTester::new_from_dir(&test_repo.dir, ["list"]); + + create_and_populate_branch( + &test_repo, + FEATURE_BRANCH_NAME_1, + "a", + true, + )?; + test_repo.checkout("main")?; + + p.expect("finding PRs...\r\n")?; + let mut c = p.expect_choice( + "All PRs", + vec![ + format!("\"{PR_TITLE_1}\""), + format!("\"{PR_TITLE_2}\""), + format!("\"{PR_TITLE_3}\""), + ], + )?; + c.succeeds_with(0, true)?; + p.expect("finding commits...\r\n")?; + let mut confirm = p.expect_confirm("check out branch?", Some(true))?; + confirm.succeeds_with(None)?; + p.expect("checked out PR branch. pulled 1 new commits\r\n")?; + p.expect_end()?; + + for p in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + p)?; + } + Ok(()) + }); + + // launch relay + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + cli_tester_handle.join().unwrap()?; + println!("{:?}", r55.events); + Ok(()) + } + + #[tokio::test] + #[serial] + async fn prompts_to_choose_from_pr_titles() -> Result<()> { + let _ = run_async_prompts_to_choose_from_pr_titles().await; + Ok(()) + } + } + + #[tokio::test] + #[serial] + async fn pr_branch_checked_out() -> Result<()> { + let (_, test_repo) = prep_and_run().await?; + assert_eq!( + FEATURE_BRANCH_NAME_1, + test_repo.get_checked_out_branch_name()?, + ); + Ok(()) + } + + #[tokio::test] + #[serial] + async fn pr_branch_tip_is_most_recent_patch() -> Result<()> { + let (originating_repo, test_repo) = prep_and_run().await?; + assert_eq!( + originating_repo.get_tip_of_local_branch(FEATURE_BRANCH_NAME_1)?, + test_repo.get_tip_of_local_branch(FEATURE_BRANCH_NAME_1)?, + ); + Ok(()) + } + } + + mod when_branch_is_ahead { + // use super::*; + // TODO latest commit in pr builds off an older commit in pr + // instead of previous. + // TODO current git user created commit on branch + } + + mod when_latest_event_rebases_branch { + // use super::*; + // TODO + } + } + } +} diff --git a/tests/prs_create.rs b/tests/prs_create.rs deleted file mode 100644 index 5d55ab9..0000000 --- a/tests/prs_create.rs +++ /dev/null @@ -1,1131 +0,0 @@ -use anyhow::Result; -use futures::join; -use serial_test::serial; -use test_utils::{git::GitTestRepo, relay::Relay, *}; - -#[test] -fn when_to_branch_doesnt_exist_return_error() -> Result<()> { - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let mut p = CliTester::new_from_dir( - &test_repo.dir, - ["prs", "create", "--to-branch", "nonexistant"], - ); - p.expect("Error: cannot find to_branch 'nonexistant'")?; - Ok(()) -} - -#[test] -fn when_no_to_branch_specified_and_no_main_or_master_branch_return_error() -> Result<()> { - let test_repo = GitTestRepo::new("notmain")?; - test_repo.populate()?; - let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "create"]); - p.expect("Error: a destination branch (to_branch) is not specified and the defaults (main or master) do not exist")?; - Ok(()) -} - -#[test] -fn when_from_branch_doesnt_exist_return_error() -> Result<()> { - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let mut p = CliTester::new_from_dir( - &test_repo.dir, - ["prs", "create", "--from-branch", "nonexistant"], - ); - p.expect("Error: cannot find from_branch 'nonexistant'")?; - Ok(()) -} - -#[test] -fn when_no_commits_ahead_of_main_return_error() -> Result<()> { - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - // create feature branch with 1 commit ahead - test_repo.create_branch("feature")?; - test_repo.checkout("feature")?; - - let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "create"]); - p.expect("Error: 'head' is 0 commits ahead of 'main' so no patches were created")?; - Ok(()) -} - -mod when_commits_behind_ask_to_proceed { - use super::*; - - fn prep_test_repo() -> Result { - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - // create feature branch with 2 commit ahead - test_repo.create_branch("feature")?; - test_repo.checkout("feature")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - test_repo.stage_and_commit("add t3.md")?; - std::fs::write(test_repo.dir.join("t4.md"), "some content")?; - test_repo.stage_and_commit("add t4.md")?; - // checkout main and add 1 commit - test_repo.checkout("main")?; - std::fs::write(test_repo.dir.join("t5.md"), "some content")?; - test_repo.stage_and_commit("add t5.md")?; - // checkout feature branch - test_repo.checkout("feature")?; - Ok(test_repo) - } - static BEHIND_LEN: u8 = 1; - static AHEAD_LEN: u8 = 2; - - fn expect_confirm_prompt( - p: &mut CliTester, - behind: u8, - ahead: u8, - ) -> Result { - p.expect_confirm( - format!("'head' is {behind} commits behind 'main' and {ahead} ahead. Consider rebasing before sending patches. Proceed anyway?").as_str(), - Some(false), - ) - } - - #[test] - fn asked_with_default_no() -> Result<()> { - let test_repo = prep_test_repo()?; - - let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "create"]); - expect_confirm_prompt(&mut p, BEHIND_LEN, AHEAD_LEN)?; - p.exit()?; - Ok(()) - } - - #[test] - fn when_response_is_false_aborts() -> Result<()> { - let test_repo = prep_test_repo()?; - - let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "create"]); - - expect_confirm_prompt(&mut p, BEHIND_LEN, AHEAD_LEN)?.succeeds_with(Some(false))?; - - p.expect_end_with("Error: aborting so branch can be rebased\r\n")?; - - Ok(()) - } - #[test] - #[serial] - fn when_response_is_true_proceeds() -> Result<()> { - let test_repo = prep_test_repo()?; - - let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "create"]); - expect_confirm_prompt(&mut p, BEHIND_LEN, AHEAD_LEN)?.succeeds_with(Some(true))?; - p.expect( - format!("creating patch for {AHEAD_LEN} commits from 'head' that are {BEHIND_LEN} behind 'main'",) - .as_str(), - )?; - p.exit()?; - Ok(()) - } -} - -#[test] -#[serial] -fn cli_message_creating_patches() -> Result<()> { - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - // create feature branch with 2 commit ahead - test_repo.create_branch("feature")?; - test_repo.checkout("feature")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - test_repo.stage_and_commit("add t3.md")?; - std::fs::write(test_repo.dir.join("t4.md"), "some content")?; - test_repo.stage_and_commit("add t4.md")?; - - let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "create"]); - - p.expect("creating patch for 2 commits from 'head' that can be merged into 'main'")?; - p.exit()?; - Ok(()) -} - -fn is_cover_letter(event: &nostr::Event) -> bool { - event.kind.as_u64().eq(&PATCH_KIND) - && event.iter_tags().any(|t| t.as_vec()[1].eq("cover-letter")) -} - -fn is_patch(event: &nostr::Event) -> bool { - event.kind.as_u64().eq(&PATCH_KIND) - && !event.iter_tags().any(|t| t.as_vec()[1].eq("cover-letter")) -} - -fn prep_git_repo() -> Result { - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - // create feature branch with 2 commit ahead - test_repo.create_branch("feature")?; - test_repo.checkout("feature")?; - std::fs::write(test_repo.dir.join("t3.md"), "some content")?; - test_repo.stage_and_commit("add t3.md")?; - std::fs::write(test_repo.dir.join("t4.md"), "some content")?; - test_repo.stage_and_commit("add t4.md")?; - Ok(test_repo) -} - -fn cli_tester_create_pr(git_repo: &GitTestRepo, include_cover_letter: bool) -> CliTester { - let mut args = vec![ - "--nsec", - TEST_KEY_1_NSEC, - "--password", - TEST_PASSWORD, - "--disable-cli-spinners", - "prs", - "create", - ]; - if include_cover_letter { - for arg in [ - "--title", - "exampletitle", - "--description", - "exampledescription", - ] { - args.push(arg); - } - } else { - args.push("--no-cover-letter"); - } - CliTester::new_from_dir(&git_repo.dir, args) -} - -fn expect_msgs_first(p: &mut CliTester, include_cover_letter: bool) -> Result<()> { - p.expect("creating patch for 2 commits from 'head' that can be merged into 'main'\r\n")?; - p.expect("searching for your details...\r\n")?; - p.expect("\r")?; - p.expect("logged in as fred\r\n")?; - p.expect(format!( - "posting 2 patches {} a covering letter...\r\n", - if include_cover_letter { - "with" - } else { - "without" - } - ))?; - Ok(()) -} - -async fn prep_run_create_pr( - include_cover_letter: bool, -) -> Result<( - Relay<'static>, - Relay<'static>, - Relay<'static>, - Relay<'static>, - Relay<'static>, -)> { - let git_repo = prep_git_repo()?; - // fallback (51,52) user write (53, 55) repo (55, 56) - let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( - Relay::new( - 8051, - None, - Some(&|relay, client_id, subscription_id, _| -> Result<()> { - relay.respond_events( - client_id, - &subscription_id, - &vec![ - generate_test_key_1_metadata_event("fred"), - generate_test_key_1_relay_list_event(), - ], - )?; - Ok(()) - }), - ), - Relay::new(8052, None, None), - Relay::new(8053, None, None), - Relay::new( - 8055, - None, - Some(&|relay, client_id, subscription_id, _| -> Result<()> { - relay.respond_events( - client_id, - &subscription_id, - &vec![generate_repo_ref_event()], - )?; - Ok(()) - }), - ), - Relay::new(8056, None, None), - ); - - // // check relay had the right number of events - let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - let mut p = cli_tester_create_pr(&git_repo, include_cover_letter); - p.expect_end_eventually()?; - for p in [51, 52, 53, 55, 56] { - relay::shutdown_relay(8000 + p)?; - } - Ok(()) - }); - - // launch relay - let _ = join!( - r51.listen_until_close(), - r52.listen_until_close(), - r53.listen_until_close(), - r55.listen_until_close(), - r56.listen_until_close(), - ); - cli_tester_handle.join().unwrap()?; - Ok((r51, r52, r53, r55, r56)) -} - -mod sends_cover_letter_and_2_patches_to_3_relays { - - use super::*; - #[tokio::test] - #[serial] - async fn only_1_pr_kind_event_sent_to_each_relay() -> Result<()> { - let (_, _, r53, r55, r56) = prep_run_create_pr(true).await?; - for relay in [&r53, &r55, &r56] { - assert_eq!( - relay.events.iter().filter(|e| is_cover_letter(e)).count(), - 1, - ); - } - Ok(()) - } - - #[tokio::test] - #[serial] - async fn only_1_pr_kind_event_sent_to_user_relays() -> Result<()> { - let (_, _, r53, r55, _) = prep_run_create_pr(true).await?; - for relay in [&r53, &r55] { - assert_eq!( - relay.events.iter().filter(|e| is_cover_letter(e)).count(), - 1, - ); - } - Ok(()) - } - - #[tokio::test] - #[serial] - async fn only_1_pr_kind_event_sent_to_repo_relays() -> Result<()> { - let (_, _, _, r55, r56) = prep_run_create_pr(true).await?; - for relay in [&r55, &r56] { - assert_eq!( - relay.events.iter().filter(|e| is_cover_letter(e)).count(), - 1 - ); - } - Ok(()) - } - - #[tokio::test] - #[serial] - async fn pr_not_sent_to_fallback_relay() -> Result<()> { - let (r51, r52, _, _, _) = prep_run_create_pr(true).await?; - for relay in [&r51, &r52] { - assert_eq!( - relay.events.iter().filter(|e| is_cover_letter(e)).count(), - 0, - ); - } - Ok(()) - } - - #[tokio::test] - #[serial] - async fn only_2_patch_kind_events_sent_to_each_relay() -> Result<()> { - let (_, _, r53, r55, r56) = prep_run_create_pr(true).await?; - for relay in [&r53, &r55, &r56] { - assert_eq!(relay.events.iter().filter(|e| is_patch(e)).count(), 2,); - } - Ok(()) - } - - #[tokio::test] - #[serial] - async fn patch_content_contains_patch_in_email_format_with_patch_series_numbers() -> Result<()> - { - let (_, _, r53, r55, r56) = prep_run_create_pr(true).await?; - for relay in [&r53, &r55, &r56] { - let patch_events: Vec<&nostr::Event> = - relay.events.iter().filter(|e| is_patch(e)).collect(); - - assert_eq!( - patch_events[1].content, - "\ - From fe973a840fba2a8ab37dd505c154854a69a6505c Mon Sep 17 00:00:00 2001\n\ - From: Joe Bloggs \n\ - Date: Thu, 1 Jan 1970 00:00:00 +0000\n\ - Subject: [PATCH 2/2] add t4.md\n\ - \n\ - ---\n \ - t4.md | 1 +\n \ - 1 file changed, 1 insertion(+)\n \ - create mode 100644 t4.md\n\ - \n\ - diff --git a/t4.md b/t4.md\n\ - new file mode 100644\n\ - index 0000000..f0eec86\n\ - --- /dev/null\n\ - +++ b/t4.md\n\ - @@ -0,0 +1 @@\n\ - +some content\n\\ \ - No newline at end of file\n\ - --\n\ - libgit2 1.7.1\n\ - \n\ - ", - ); - assert_eq!( - patch_events[0].content, - "\ - From 232efb37ebc67692c9e9ff58b83c0d3d63971a0a Mon Sep 17 00:00:00 2001\n\ - From: Joe Bloggs \n\ - Date: Thu, 1 Jan 1970 00:00:00 +0000\n\ - Subject: [PATCH 1/2] add t3.md\n\ - \n\ - ---\n \ - t3.md | 1 +\n \ - 1 file changed, 1 insertion(+)\n \ - create mode 100644 t3.md\n\ - \n\ - diff --git a/t3.md b/t3.md\n\ - new file mode 100644\n\ - index 0000000..f0eec86\n\ - --- /dev/null\n\ - +++ b/t3.md\n\ - @@ -0,0 +1 @@\n\ - +some content\n\\ \ - No newline at end of file\n\ - --\n\ - libgit2 1.7.1\n\ - \n\ - ", - ); - } - Ok(()) - } - - mod pr_tags { - use super::*; - - #[tokio::test] - #[serial] - async fn root_commit_as_r() -> Result<()> { - let (_, _, r53, r55, r56) = prep_run_create_pr(true).await?; - for relay in [&r53, &r55, &r56] { - let pr_event: &nostr::Event = - relay.events.iter().find(|e| is_cover_letter(e)).unwrap(); - - assert_eq!( - pr_event - .iter_tags() - .find(|t| t.as_vec()[0].eq("r")) - .unwrap() - .as_vec()[1], - "9ee507fc4357d7ee16a5d8901bedcd103f23c17d" - ); - } - Ok(()) - } - - #[tokio::test] - #[serial] - async fn a_tag_for_repo_event() -> Result<()> { - let (_, _, r53, r55, r56) = prep_run_create_pr(true).await?; - for relay in [&r53, &r55, &r56] { - let pr_event: &nostr::Event = - relay.events.iter().find(|e| is_cover_letter(e)).unwrap(); - assert!(pr_event.iter_tags().any(|t| t.as_vec()[0].eq("a") - && t.as_vec()[1].eq(&format!( - "{REPOSITORY_KIND}:{TEST_KEY_1_PUBKEY_HEX}:{}", - generate_repo_ref_event().identifier().unwrap() - )))); - } - Ok(()) - } - - #[tokio::test] - #[serial] - async fn p_tags_for_maintainers() -> Result<()> { - let maintainers = &generate_repo_ref_event() - .iter_tags() - .find(|t| t.as_vec()[0].eq(&"maintainers")) - .unwrap() - .as_vec() - .clone()[1..]; - let (_, _, r53, r55, r56) = prep_run_create_pr(true).await?; - for relay in [&r53, &r55, &r56] { - for m in maintainers { - let pr_event: &nostr::Event = - relay.events.iter().find(|e| is_cover_letter(e)).unwrap(); - assert!( - pr_event - .iter_tags() - .any(|t| { t.as_vec()[0].eq("p") && t.as_vec()[1].eq(m) }) - ); - } - } - Ok(()) - } - - #[tokio::test] - #[serial] - async fn t_tag_cover_letter() -> Result<()> { - let (_, _, r53, r55, r56) = prep_run_create_pr(true).await?; - for relay in [&r53, &r55, &r56] { - let pr_event: &nostr::Event = - relay.events.iter().find(|e| is_cover_letter(e)).unwrap(); - assert!( - pr_event - .iter_tags() - .any(|t| { t.as_vec()[0].eq("t") && t.as_vec()[1].eq(&"cover-letter") }) - ); - } - Ok(()) - } - - #[tokio::test] - #[serial] - async fn t_tag_root() -> Result<()> { - let (_, _, r53, r55, r56) = prep_run_create_pr(true).await?; - for relay in [&r53, &r55, &r56] { - let pr_event: &nostr::Event = - relay.events.iter().find(|e| is_cover_letter(e)).unwrap(); - assert!( - pr_event - .iter_tags() - .any(|t| { t.as_vec()[0].eq("t") && t.as_vec()[1].eq(&"root") }) - ); - } - Ok(()) - } - - #[tokio::test] - #[serial] - async fn pr_tags_branch_name() -> Result<()> { - let (_, _, r53, r55, r56) = prep_run_create_pr(true).await?; - for relay in [&r53, &r55, &r56] { - let pr_event: &nostr::Event = - relay.events.iter().find(|e| is_cover_letter(e)).unwrap(); - - // branch-name tag - assert_eq!( - pr_event - .iter_tags() - .find(|t| t.as_vec()[0].eq("branch-name")) - .unwrap() - .as_vec()[1], - "feature" - ); - } - Ok(()) - } - } - - mod patch_tags { - use super::*; - - async fn prep() -> Result { - let (_, _, r53, _, _) = prep_run_create_pr(true).await?; - Ok(r53.events.iter().find(|e| is_patch(e)).unwrap().clone()) - } - - #[tokio::test] - #[serial] - async fn commit_and_commit_r() -> Result<()> { - static COMMIT_ID: &str = "232efb37ebc67692c9e9ff58b83c0d3d63971a0a"; - let most_recent_patch = prep().await?; - assert!( - most_recent_patch - .tags - .iter() - .any(|t| t.as_vec()[0].eq("r") && t.as_vec()[1].eq(COMMIT_ID)) - ); - assert!( - most_recent_patch - .tags - .iter() - .any(|t| t.as_vec()[0].eq("commit") && t.as_vec()[1].eq(COMMIT_ID)) - ); - Ok(()) - } - - #[tokio::test] - #[serial] - async fn parent_commit() -> Result<()> { - // commit parent 'r' and 'parent-commit' tag - static COMMIT_PARENT_ID: &str = "431b84edc0d2fa118d63faa3c2db9c73d630a5ae"; - let most_recent_patch = prep().await?; - assert_eq!( - most_recent_patch - .tags - .iter() - .find(|t| t.as_vec()[0].eq("parent-commit")) - .unwrap() - .as_vec()[1], - COMMIT_PARENT_ID, - ); - Ok(()) - } - - #[tokio::test] - #[serial] - async fn root_commit_as_r() -> Result<()> { - assert!(prep().await?.tags.iter().any(|t| t.as_vec()[0].eq("r") - && t.as_vec()[1].eq("9ee507fc4357d7ee16a5d8901bedcd103f23c17d"))); - Ok(()) - } - - #[tokio::test] - #[serial] - async fn p_tags_for_maintainers() -> Result<()> { - let maintainers = &generate_repo_ref_event() - .iter_tags() - .find(|t| t.as_vec()[0].eq(&"maintainers")) - .unwrap() - .as_vec() - .clone()[1..]; - for m in maintainers { - assert!( - prep() - .await? - .iter_tags() - .any(|t| { t.as_vec()[0].eq("p") && t.as_vec()[1].eq(m) }) - ); - } - Ok(()) - } - - #[tokio::test] - #[serial] - async fn a_tag_for_repo_event() -> Result<()> { - assert!(prep().await?.tags.iter().any(|t| { - t.as_vec()[0].eq("a") - && t.as_vec()[1].eq(&format!( - "{REPOSITORY_KIND}:{TEST_KEY_1_PUBKEY_HEX}:{}", - generate_repo_ref_event().identifier().unwrap() - )) - })); - Ok(()) - } - - #[tokio::test] - #[serial] - async fn description_with_commit_message() -> Result<()> { - assert_eq!( - prep() - .await? - .tags - .iter() - .find(|t| t.as_vec()[0].eq("description")) - .unwrap() - .as_vec()[1], - "add t3.md" - ); - Ok(()) - } - - #[tokio::test] - #[serial] - async fn commit_author() -> Result<()> { - assert_eq!( - prep() - .await? - .tags - .iter() - .find(|t| t.as_vec()[0].eq("author")) - .unwrap() - .as_vec(), - vec!["author", "Joe Bloggs", "joe.bloggs@pm.me", "0", "0"], - ); - Ok(()) - } - - #[tokio::test] - #[serial] - async fn commit_committer() -> Result<()> { - assert_eq!( - prep() - .await? - .tags - .iter() - .find(|t| t.as_vec()[0].eq("committer")) - .unwrap() - .as_vec(), - vec!["committer", "Joe Bloggs", "joe.bloggs@pm.me", "0", "0"], - ); - Ok(()) - } - - #[tokio::test] - #[serial] - async fn patch_tags_pr_event_as_root() -> Result<()> { - let (_, _, r53, r55, r56) = prep_run_create_pr(true).await?; - for relay in [&r53, &r55, &r56] { - let patch_events: Vec<&nostr::Event> = - relay.events.iter().filter(|e| is_patch(e)).collect(); - - let most_recent_patch = patch_events[0]; - let pr_event = relay.events.iter().find(|e| is_cover_letter(e)).unwrap(); - - let root_event_tag = most_recent_patch - .tags - .iter() - .find(|t| { - t.as_vec()[0].eq("e") && t.as_vec().len().eq(&4) && t.as_vec()[3].eq("root") - }) - .unwrap(); - - assert_eq!(root_event_tag.as_vec()[1], pr_event.id.to_string()); - } - Ok(()) - } - - #[tokio::test] - #[serial] - async fn second_patch_tags_first_with_reply() -> Result<()> { - let (_, _, r53, r55, r56) = prep_run_create_pr(true).await?; - for relay in [&r53, &r55, &r56] { - let patch_events = relay - .events - .iter() - .filter(|e| is_patch(e)) - .collect::>(); - assert_eq!( - patch_events[1] - .iter_tags() - .find(|t| t.as_vec()[0].eq("e") - && t.as_vec().len().eq(&4) - && t.as_vec()[3].eq("reply")) - .unwrap() - .as_vec()[1], - patch_events[0].id.to_string(), - ); - } - Ok(()) - } - - #[tokio::test] - #[serial] - async fn no_t_root_tag() -> Result<()> { - assert!( - !prep() - .await? - .tags - .iter() - .any(|t| t.as_vec()[0].eq("t") && t.as_vec()[1].eq("root")) - ); - Ok(()) - } - } - mod cli_ouput { - use super::*; - - async fn run_test_async() -> Result<()> { - let git_repo = prep_git_repo()?; - - let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( - Relay::new( - 8051, - None, - Some(&|relay, client_id, subscription_id, _| -> Result<()> { - relay.respond_events( - client_id, - &subscription_id, - &vec![ - generate_test_key_1_metadata_event("fred"), - generate_test_key_1_relay_list_event(), - ], - )?; - Ok(()) - }), - ), - Relay::new(8052, None, None), - Relay::new(8053, None, None), - Relay::new( - 8055, - None, - Some(&|relay, client_id, subscription_id, _| -> Result<()> { - relay.respond_events( - client_id, - &subscription_id, - &vec![generate_repo_ref_event()], - )?; - Ok(()) - }), - ), - Relay::new(8056, None, None), - ); - - // // check relay had the right number of events - let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - let mut p = cli_tester_create_pr(&git_repo, true); - expect_msgs_first(&mut p, true)?; - relay::expect_send_with_progress( - &mut p, - vec![ - (" [my-relay] [repo-relay] ws://localhost:8055", true, ""), - (" [my-relay] ws://localhost:8053", true, ""), - (" [repo-relay] ws://localhost:8056", true, ""), - ], - 3, - )?; - p.expect_end_with_whitespace()?; - for p in [51, 52, 53, 55, 56] { - relay::shutdown_relay(8000 + p)?; - } - Ok(()) - }); - - // launch relay - let _ = join!( - r51.listen_until_close(), - r52.listen_until_close(), - r53.listen_until_close(), - r55.listen_until_close(), - r56.listen_until_close(), - ); - cli_tester_handle.join().unwrap()?; - Ok(()) - } - - #[tokio::test] - #[serial] - async fn check_cli_output() -> Result<()> { - run_test_async().await?; - Ok(()) - } - } - - mod first_event_rejected_by_1_relay { - use super::*; - - mod only_first_rejected_event_sent_to_relay { - use super::*; - - async fn run_test_async() -> Result<()> { - let git_repo = prep_git_repo()?; - - let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( - Relay::new( - 8051, - None, - Some(&|relay, client_id, subscription_id, _| -> Result<()> { - relay.respond_events( - client_id, - &subscription_id, - &vec![ - generate_test_key_1_metadata_event("fred"), - generate_test_key_1_relay_list_event(), - ], - )?; - Ok(()) - }), - ), - Relay::new(8052, None, None), - Relay::new(8053, None, None), - Relay::new( - 8055, - None, - Some(&|relay, client_id, subscription_id, _| -> Result<()> { - relay.respond_events( - client_id, - &subscription_id, - &vec![generate_repo_ref_event()], - )?; - Ok(()) - }), - ), - Relay::new( - 8056, - Some(&|relay, client_id, event| -> Result<()> { - relay.respond_ok(client_id, event, Some("Payment Required"))?; - Ok(()) - }), - None, - ), - ); - - // // check relay had the right number of events - let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - let mut p = cli_tester_create_pr(&git_repo, true); - p.expect_end_eventually()?; - for p in [51, 52, 53, 55, 56] { - relay::shutdown_relay(8000 + p)?; - } - Ok(()) - }); - - // launch relay - let _ = join!( - r51.listen_until_close(), - r52.listen_until_close(), - r53.listen_until_close(), - r55.listen_until_close(), - r56.listen_until_close(), - ); - cli_tester_handle.join().unwrap()?; - - assert_eq!(r56.events.len(), 1); - - Ok(()) - } - - #[tokio::test] - #[serial] - async fn only_first_rejected_event_sent_to_relay() -> Result<()> { - run_test_async().await?; - Ok(()) - } - } - - mod cli_show_rejection_with_comment { - use super::*; - - async fn run_test_async() -> Result<(Relay<'static>, Relay<'static>, Relay<'static>)> { - let git_repo = prep_git_repo()?; - - let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( - Relay::new( - 8051, - None, - Some(&|relay, client_id, subscription_id, _| -> Result<()> { - relay.respond_events( - client_id, - &subscription_id, - &vec![ - generate_test_key_1_metadata_event("fred"), - generate_test_key_1_relay_list_event(), - ], - )?; - Ok(()) - }), - ), - Relay::new(8052, None, None), - Relay::new(8053, None, None), - Relay::new( - 8055, - None, - Some(&|relay, client_id, subscription_id, _| -> Result<()> { - relay.respond_events( - client_id, - &subscription_id, - &vec![generate_repo_ref_event()], - )?; - Ok(()) - }), - ), - Relay::new( - 8056, - Some(&|relay, client_id, event| -> Result<()> { - relay.respond_ok(client_id, event, Some("Payment Required"))?; - Ok(()) - }), - None, - ), - ); - - // // check relay had the right number of events - let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - let mut p = cli_tester_create_pr(&git_repo, true); - expect_msgs_first(&mut p, true)?; - // p.expect_end_with("bla")?; - relay::expect_send_with_progress( - &mut p, - vec![ - (" [my-relay] [repo-relay] ws://localhost:8055", true, ""), - (" [my-relay] ws://localhost:8053", true, ""), - ( - " [repo-relay] ws://localhost:8056", - false, - "error: Payment Required", - ), - ], - 3, - )?; - p.expect_end_with_whitespace()?; - for p in [51, 52, 53, 55, 56] { - relay::shutdown_relay(8000 + p)?; - } - - Ok(()) - }); - - // launch relay - let _ = join!( - r51.listen_until_close(), - r52.listen_until_close(), - r53.listen_until_close(), - r55.listen_until_close(), - r56.listen_until_close(), - ); - cli_tester_handle.join().unwrap()?; - Ok((r51, r52, r53)) - } - - #[tokio::test] - #[serial] - async fn cli_show_rejection_with_comment() -> Result<()> { - run_test_async().await?; - Ok(()) - } - } - } -} - -mod sends_2_patches_without_cover_letter { - use super::*; - - mod cli_ouput { - use super::*; - - async fn run_test_async() -> Result<()> { - let git_repo = prep_git_repo()?; - - let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( - Relay::new( - 8051, - None, - Some(&|relay, client_id, subscription_id, _| -> Result<()> { - relay.respond_events( - client_id, - &subscription_id, - &vec![ - generate_test_key_1_metadata_event("fred"), - generate_test_key_1_relay_list_event(), - ], - )?; - Ok(()) - }), - ), - Relay::new(8052, None, None), - Relay::new(8053, None, None), - Relay::new( - 8055, - None, - Some(&|relay, client_id, subscription_id, _| -> Result<()> { - relay.respond_events( - client_id, - &subscription_id, - &vec![generate_repo_ref_event()], - )?; - Ok(()) - }), - ), - Relay::new(8056, None, None), - ); - - // // check relay had the right number of events - let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - let mut p = cli_tester_create_pr(&git_repo, false); - - expect_msgs_first(&mut p, false)?; - relay::expect_send_with_progress( - &mut p, - vec![ - (" [my-relay] [repo-relay] ws://localhost:8055", true, ""), - (" [my-relay] ws://localhost:8053", true, ""), - (" [repo-relay] ws://localhost:8056", true, ""), - ], - 2, - )?; - p.expect_end_with_whitespace()?; - for p in [51, 52, 53, 55, 56] { - relay::shutdown_relay(8000 + p)?; - } - Ok(()) - }); - - // launch relay - let _ = join!( - r51.listen_until_close(), - r52.listen_until_close(), - r53.listen_until_close(), - r55.listen_until_close(), - r56.listen_until_close(), - ); - cli_tester_handle.join().unwrap()?; - Ok(()) - } - - #[tokio::test] - #[serial] - async fn check_cli_output() -> Result<()> { - run_test_async().await?; - Ok(()) - } - } - - #[tokio::test] - #[serial] - async fn no_cover_letter_event() -> Result<()> { - let (_, _, r53, r55, r56) = prep_run_create_pr(false).await?; - for relay in [&r53, &r55, &r56] { - assert_eq!( - relay.events.iter().filter(|e| is_cover_letter(e)).count(), - 0, - ); - } - Ok(()) - } - - #[tokio::test] - #[serial] - async fn two_patch_events() -> Result<()> { - let (_, _, r53, r55, r56) = prep_run_create_pr(false).await?; - for relay in [&r53, &r55, &r56] { - assert_eq!(relay.events.iter().filter(|e| is_patch(e)).count(), 2); - } - Ok(()) - } - - #[tokio::test] - #[serial] - // TODO check this is the ancestor - async fn first_patch_with_root_t_tag() -> Result<()> { - let (_, _, r53, r55, r56) = prep_run_create_pr(false).await?; - for relay in [&r53, &r55, &r56] { - let patch_events = relay - .events - .iter() - .filter(|e| is_patch(e)) - .collect::>(); - - // first patch tagged as root - assert!( - patch_events[0] - .iter_tags() - .any(|t| t.as_vec()[0].eq("t") && t.as_vec()[1].eq("root")) - ); - // second patch not tagged as root - assert!( - !patch_events[1] - .iter_tags() - .any(|t| t.as_vec()[0].eq("t") && t.as_vec()[1].eq("root")) - ); - } - Ok(()) - } - - #[tokio::test] - #[serial] - async fn second_patch_lists_first_as_root() -> Result<()> { - let (_, _, r53, r55, r56) = prep_run_create_pr(false).await?; - for relay in [&r53, &r55, &r56] { - let patch_events = relay - .events - .iter() - .filter(|e| is_patch(e)) - .collect::>(); - - assert_eq!( - patch_events[1] - .iter_tags() - .find(|t| t.as_vec()[0].eq("e") - && t.as_vec().len().eq(&4) - && t.as_vec()[3].eq("root")) - .unwrap() - .as_vec()[1], - patch_events[0].id.to_string(), - ); - } - Ok(()) - } -} diff --git a/tests/prs_list.rs b/tests/prs_list.rs deleted file mode 100644 index 7c0d8ec..0000000 --- a/tests/prs_list.rs +++ /dev/null @@ -1,977 +0,0 @@ -use anyhow::Result; -use futures::join; -use serial_test::serial; -use test_utils::{git::GitTestRepo, relay::Relay, *}; - -static FEATURE_BRANCH_NAME_1: &str = "feature-example-t"; -static FEATURE_BRANCH_NAME_2: &str = "feature-example-f"; -static FEATURE_BRANCH_NAME_3: &str = "feature-example-c"; -static FEATURE_BRANCH_NAME_4: &str = "feature-example-d"; - -static PR_TITLE_1: &str = "pr a"; -static PR_TITLE_2: &str = "pr b"; -static PR_TITLE_3: &str = "pr c"; - -fn cli_tester_create_prs() -> Result { - let git_repo = GitTestRepo::default(); - git_repo.populate()?; - cli_tester_create_pr( - &git_repo, - FEATURE_BRANCH_NAME_1, - "a", - Some((PR_TITLE_1, "pr a description")), - )?; - cli_tester_create_pr( - &git_repo, - FEATURE_BRANCH_NAME_2, - "b", - Some((PR_TITLE_2, "pr b description")), - )?; - cli_tester_create_pr( - &git_repo, - FEATURE_BRANCH_NAME_3, - "c", - Some((PR_TITLE_3, "pr c description")), - )?; - Ok(git_repo) -} - -fn create_and_populate_branch( - test_repo: &GitTestRepo, - branch_name: &str, - prefix: &str, - only_one_commit: bool, -) -> Result<()> { - test_repo.checkout("main")?; - test_repo.create_branch(branch_name)?; - test_repo.checkout(branch_name)?; - std::fs::write( - test_repo.dir.join(format!("{}3.md", prefix)), - "some content", - )?; - test_repo.stage_and_commit(format!("add {}3.md", prefix).as_str())?; - if !only_one_commit { - std::fs::write( - test_repo.dir.join(format!("{}4.md", prefix)), - "some content", - )?; - test_repo.stage_and_commit(format!("add {}4.md", prefix).as_str())?; - } - Ok(()) -} - -fn cli_tester_create_pr( - test_repo: &GitTestRepo, - branch_name: &str, - prefix: &str, - cover_letter_title_and_description: Option<(&str, &str)>, -) -> Result<()> { - create_and_populate_branch(test_repo, branch_name, prefix, false)?; - - if let Some((title, description)) = cover_letter_title_and_description { - let mut p = CliTester::new_from_dir( - &test_repo.dir, - [ - "--nsec", - TEST_KEY_1_NSEC, - "--password", - TEST_PASSWORD, - "--disable-cli-spinners", - "prs", - "create", - "--title", - format!("\"{title}\"").as_str(), - "--description", - format!("\"{description}\"").as_str(), - ], - ); - p.expect_end_eventually()?; - } else { - let mut p = CliTester::new_from_dir( - &test_repo.dir, - [ - "--nsec", - TEST_KEY_1_NSEC, - "--password", - TEST_PASSWORD, - "--disable-cli-spinners", - "prs", - "create", - "--no-cover-letter", - ], - ); - p.expect_end_eventually()?; - } - Ok(()) -} - -mod when_main_branch_is_uptodate { - use super::*; - - mod when_pr_branch_doesnt_exist { - use super::*; - - mod when_main_is_checked_out { - use super::*; - - mod when_first_pr_selected { - use super::*; - - // TODO: test when other prs with the same name but from other repositories are - // present on relays - async fn prep_and_run() -> Result<(GitTestRepo, GitTestRepo)> { - // fallback (51,52) user write (53, 55) repo (55, 56) - let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( - Relay::new(8051, None, None), - Relay::new(8052, None, None), - Relay::new(8053, None, None), - Relay::new(8055, None, None), - Relay::new(8056, None, None), - ); - - r51.events.push(generate_test_key_1_relay_list_event()); - r51.events.push(generate_test_key_1_metadata_event("fred")); - r51.events.push(generate_repo_ref_event()); - - r55.events.push(generate_repo_ref_event()); - r55.events.push(generate_test_key_1_metadata_event("fred")); - r55.events.push(generate_test_key_1_relay_list_event()); - - let cli_tester_handle = - std::thread::spawn(move || -> Result<(GitTestRepo, GitTestRepo)> { - let originating_repo = cli_tester_create_prs()?; - - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "list"]); - - p.expect("finding PRs...\r\n")?; - let mut c = p.expect_choice( - "All PRs", - vec![ - format!("\"{PR_TITLE_1}\""), - format!("\"{PR_TITLE_2}\""), - format!("\"{PR_TITLE_3}\""), - ], - )?; - c.succeeds_with(0, true)?; - let mut confirm = - p.expect_confirm_eventually("check out branch?", Some(true))?; - confirm.succeeds_with(None)?; - p.expect_end_eventually_and_print()?; - - for p in [51, 52, 53, 55, 56] { - relay::shutdown_relay(8000 + p)?; - } - Ok((originating_repo, test_repo)) - }); - - // launch relay - let _ = join!( - r51.listen_until_close(), - r52.listen_until_close(), - r53.listen_until_close(), - r55.listen_until_close(), - r56.listen_until_close(), - ); - let res = cli_tester_handle.join().unwrap()?; - - Ok(res) - } - - mod cli_prompts { - use super::*; - async fn run_async_prompts_to_choose_from_pr_titles() -> Result<()> { - let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( - Relay::new(8051, None, None), - Relay::new(8052, None, None), - Relay::new(8053, None, None), - Relay::new(8055, None, None), - Relay::new(8056, None, None), - ); - - r51.events.push(generate_test_key_1_relay_list_event()); - r51.events.push(generate_test_key_1_metadata_event("fred")); - r51.events.push(generate_repo_ref_event()); - - r55.events.push(generate_repo_ref_event()); - r55.events.push(generate_test_key_1_metadata_event("fred")); - r55.events.push(generate_test_key_1_relay_list_event()); - - let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - cli_tester_create_prs()?; - - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "list"]); - - p.expect("finding PRs...\r\n")?; - let mut c = p.expect_choice( - "All PRs", - vec![ - format!("\"{PR_TITLE_1}\""), - format!("\"{PR_TITLE_2}\""), - format!("\"{PR_TITLE_3}\""), - ], - )?; - c.succeeds_with(0, true)?; - p.expect("finding commits...\r\n")?; - let mut confirm = p.expect_confirm("check out branch?", Some(true))?; - confirm.succeeds_with(None)?; - p.expect("checked out PR branch. pulled 2 new commits\r\n")?; - p.expect_end()?; - - for p in [51, 52, 53, 55, 56] { - relay::shutdown_relay(8000 + p)?; - } - Ok(()) - }); - - // launch relay - let _ = join!( - r51.listen_until_close(), - r52.listen_until_close(), - r53.listen_until_close(), - r55.listen_until_close(), - r56.listen_until_close(), - ); - cli_tester_handle.join().unwrap()?; - println!("{:?}", r55.events); - Ok(()) - } - - #[tokio::test] - #[serial] - async fn prompts_to_choose_from_pr_titles() -> Result<()> { - let _ = run_async_prompts_to_choose_from_pr_titles().await; - Ok(()) - } - } - - #[tokio::test] - #[serial] - async fn pr_branch_created_with_correct_name() -> Result<()> { - let (_, test_repo) = prep_and_run().await?; - assert_eq!( - vec![FEATURE_BRANCH_NAME_1, "main"], - test_repo.get_local_branch_names()? - ); - Ok(()) - } - - #[tokio::test] - #[serial] - async fn pr_branch_checked_out() -> Result<()> { - let (_, test_repo) = prep_and_run().await?; - assert_eq!( - FEATURE_BRANCH_NAME_1, - test_repo.get_checked_out_branch_name()?, - ); - Ok(()) - } - - #[tokio::test] - #[serial] - async fn pr_branch_tip_is_most_recent_patch() -> Result<()> { - let (originating_repo, test_repo) = prep_and_run().await?; - assert_eq!( - originating_repo.get_tip_of_local_branch(FEATURE_BRANCH_NAME_1)?, - test_repo.get_tip_of_local_branch(FEATURE_BRANCH_NAME_1)?, - ); - Ok(()) - } - } - mod when_third_pr_selected { - use super::*; - - async fn prep_and_run() -> Result<(GitTestRepo, GitTestRepo)> { - // fallback (51,52) user write (53, 55) repo (55, 56) - let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( - Relay::new(8051, None, None), - Relay::new(8052, None, None), - Relay::new(8053, None, None), - Relay::new(8055, None, None), - Relay::new(8056, None, None), - ); - - r51.events.push(generate_test_key_1_relay_list_event()); - r51.events.push(generate_test_key_1_metadata_event("fred")); - r51.events.push(generate_repo_ref_event()); - - r55.events.push(generate_repo_ref_event()); - r55.events.push(generate_test_key_1_metadata_event("fred")); - r55.events.push(generate_test_key_1_relay_list_event()); - - let cli_tester_handle = - std::thread::spawn(move || -> Result<(GitTestRepo, GitTestRepo)> { - let originating_repo = cli_tester_create_prs()?; - - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "list"]); - - p.expect("finding PRs...\r\n")?; - let mut c = p.expect_choice( - "All PRs", - vec![ - format!("\"{PR_TITLE_1}\""), - format!("\"{PR_TITLE_2}\""), - format!("\"{PR_TITLE_3}\""), - ], - )?; - c.succeeds_with(2, true)?; - let mut confirm = - p.expect_confirm_eventually("check out branch?", Some(true))?; - confirm.succeeds_with(None)?; - p.expect_end_eventually_and_print()?; - - for p in [51, 52, 53, 55, 56] { - relay::shutdown_relay(8000 + p)?; - } - Ok((originating_repo, test_repo)) - }); - - // launch relay - let _ = join!( - r51.listen_until_close(), - r52.listen_until_close(), - r53.listen_until_close(), - r55.listen_until_close(), - r56.listen_until_close(), - ); - let res = cli_tester_handle.join().unwrap()?; - - Ok(res) - } - - mod cli_prompts { - use super::*; - async fn run_async_prompts_to_choose_from_pr_titles() -> Result<()> { - let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( - Relay::new(8051, None, None), - Relay::new(8052, None, None), - Relay::new(8053, None, None), - Relay::new(8055, None, None), - Relay::new(8056, None, None), - ); - - r51.events.push(generate_test_key_1_relay_list_event()); - r51.events.push(generate_test_key_1_metadata_event("fred")); - r51.events.push(generate_repo_ref_event()); - - r55.events.push(generate_repo_ref_event()); - r55.events.push(generate_test_key_1_metadata_event("fred")); - r55.events.push(generate_test_key_1_relay_list_event()); - - let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - cli_tester_create_prs()?; - - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "list"]); - - p.expect("finding PRs...\r\n")?; - let mut c = p.expect_choice( - "All PRs", - vec![ - format!("\"{PR_TITLE_1}\""), - format!("\"{PR_TITLE_2}\""), - format!("\"{PR_TITLE_3}\""), - ], - )?; - c.succeeds_with(2, true)?; - p.expect("finding commits...\r\n")?; - let mut confirm = p.expect_confirm("check out branch?", Some(true))?; - confirm.succeeds_with(None)?; - p.expect("checked out PR branch. pulled 2 new commits\r\n")?; - p.expect_end()?; - - for p in [51, 52, 53, 55, 56] { - relay::shutdown_relay(8000 + p)?; - } - Ok(()) - }); - - // launch relay - let _ = join!( - r51.listen_until_close(), - r52.listen_until_close(), - r53.listen_until_close(), - r55.listen_until_close(), - r56.listen_until_close(), - ); - cli_tester_handle.join().unwrap()?; - println!("{:?}", r55.events); - Ok(()) - } - - #[tokio::test] - #[serial] - async fn prompts_to_choose_from_pr_titles() -> Result<()> { - let _ = run_async_prompts_to_choose_from_pr_titles().await; - Ok(()) - } - } - - #[tokio::test] - #[serial] - async fn pr_branch_created_with_correct_name() -> Result<()> { - let (_, test_repo) = prep_and_run().await?; - assert_eq!( - vec![FEATURE_BRANCH_NAME_3, "main"], - test_repo.get_local_branch_names()? - ); - Ok(()) - } - - #[tokio::test] - #[serial] - async fn pr_branch_checked_out() -> Result<()> { - let (_, test_repo) = prep_and_run().await?; - assert_eq!( - FEATURE_BRANCH_NAME_3, - test_repo.get_checked_out_branch_name()?, - ); - Ok(()) - } - - #[tokio::test] - #[serial] - async fn pr_branch_tip_is_most_recent_patch() -> Result<()> { - let (originating_repo, test_repo) = prep_and_run().await?; - assert_eq!( - originating_repo.get_tip_of_local_branch(FEATURE_BRANCH_NAME_3)?, - test_repo.get_tip_of_local_branch(FEATURE_BRANCH_NAME_3)?, - ); - Ok(()) - } - } - mod when_forth_pr_has_no_cover_letter { - use super::*; - - async fn prep_and_run() -> Result<(GitTestRepo, GitTestRepo)> { - // fallback (51,52) user write (53, 55) repo (55, 56) - let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( - Relay::new(8051, None, None), - Relay::new(8052, None, None), - Relay::new(8053, None, None), - Relay::new(8055, None, None), - Relay::new(8056, None, None), - ); - - r51.events.push(generate_test_key_1_relay_list_event()); - r51.events.push(generate_test_key_1_metadata_event("fred")); - r51.events.push(generate_repo_ref_event()); - - r55.events.push(generate_repo_ref_event()); - r55.events.push(generate_test_key_1_metadata_event("fred")); - r55.events.push(generate_test_key_1_relay_list_event()); - - let cli_tester_handle = - std::thread::spawn(move || -> Result<(GitTestRepo, GitTestRepo)> { - let originating_repo = cli_tester_create_prs()?; - cli_tester_create_pr( - &originating_repo, - FEATURE_BRANCH_NAME_4, - "d", - None, - )?; - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "list"]); - - p.expect("finding PRs...\r\n")?; - let mut c = p.expect_choice( - "All PRs", - vec![ - format!("\"{PR_TITLE_1}\""), - format!("\"{PR_TITLE_2}\""), - format!("\"{PR_TITLE_3}\""), - format!("add d3.md"), // commit msg title - ], - )?; - c.succeeds_with(3, true)?; - let mut confirm = - p.expect_confirm_eventually("check out branch?", Some(true))?; - confirm.succeeds_with(None)?; - p.expect_end_eventually_and_print()?; - - for p in [51, 52, 53, 55, 56] { - relay::shutdown_relay(8000 + p)?; - } - Ok((originating_repo, test_repo)) - }); - - // launch relay - let _ = join!( - r51.listen_until_close(), - r52.listen_until_close(), - r53.listen_until_close(), - r55.listen_until_close(), - r56.listen_until_close(), - ); - let res = cli_tester_handle.join().unwrap()?; - - Ok(res) - } - - mod cli_prompts { - use super::*; - async fn run_async_prompts_to_choose_from_pr_titles() -> Result<()> { - let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( - Relay::new(8051, None, None), - Relay::new(8052, None, None), - Relay::new(8053, None, None), - Relay::new(8055, None, None), - Relay::new(8056, None, None), - ); - - r51.events.push(generate_test_key_1_relay_list_event()); - r51.events.push(generate_test_key_1_metadata_event("fred")); - r51.events.push(generate_repo_ref_event()); - - r55.events.push(generate_repo_ref_event()); - r55.events.push(generate_test_key_1_metadata_event("fred")); - r55.events.push(generate_test_key_1_relay_list_event()); - - let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - let originating_repo = cli_tester_create_prs()?; - cli_tester_create_pr( - &originating_repo, - FEATURE_BRANCH_NAME_4, - "d", - None, - )?; - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "list"]); - - p.expect("finding PRs...\r\n")?; - let mut c = p.expect_choice( - "All PRs", - vec![ - format!("\"{PR_TITLE_1}\""), - format!("\"{PR_TITLE_2}\""), - format!("\"{PR_TITLE_3}\""), - format!("add d3.md"), // commit msg title - ], - )?; - c.succeeds_with(3, true)?; - p.expect("finding commits...\r\n")?; - let mut confirm = p.expect_confirm("check out branch?", Some(true))?; - confirm.succeeds_with(None)?; - p.expect("checked out PR branch. pulled 2 new commits\r\n")?; - p.expect_end()?; - - for p in [51, 52, 53, 55, 56] { - relay::shutdown_relay(8000 + p)?; - } - Ok(()) - }); - - // launch relay - let _ = join!( - r51.listen_until_close(), - r52.listen_until_close(), - r53.listen_until_close(), - r55.listen_until_close(), - r56.listen_until_close(), - ); - cli_tester_handle.join().unwrap()?; - println!("{:?}", r55.events); - Ok(()) - } - - #[tokio::test] - #[serial] - async fn prompts_to_choose_from_pr_titles() -> Result<()> { - let _ = run_async_prompts_to_choose_from_pr_titles().await; - Ok(()) - } - } - - #[tokio::test] - #[serial] - async fn pr_branch_created_with_correct_name() -> Result<()> { - let (_, test_repo) = prep_and_run().await?; - assert_eq!( - vec![FEATURE_BRANCH_NAME_4, "main"], - test_repo.get_local_branch_names()? - ); - Ok(()) - } - - #[tokio::test] - #[serial] - async fn pr_branch_checked_out() -> Result<()> { - let (_, test_repo) = prep_and_run().await?; - assert_eq!( - FEATURE_BRANCH_NAME_4, - test_repo.get_checked_out_branch_name()?, - ); - Ok(()) - } - - #[tokio::test] - #[serial] - async fn pr_branch_tip_is_most_recent_patch() -> Result<()> { - let (originating_repo, test_repo) = prep_and_run().await?; - assert_eq!( - originating_repo.get_tip_of_local_branch(FEATURE_BRANCH_NAME_4)?, - test_repo.get_tip_of_local_branch(FEATURE_BRANCH_NAME_4)?, - ); - Ok(()) - } - } - } - } - - mod when_pr_branch_exists { - use super::*; - - mod when_main_is_checked_out { - use super::*; - - mod when_branch_is_up_to_date { - use super::*; - async fn prep_and_run() -> Result<(GitTestRepo, GitTestRepo)> { - // fallback (51,52) user write (53, 55) repo (55, 56) - let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( - Relay::new(8051, None, None), - Relay::new(8052, None, None), - Relay::new(8053, None, None), - Relay::new(8055, None, None), - Relay::new(8056, None, None), - ); - - r51.events.push(generate_test_key_1_relay_list_event()); - r51.events.push(generate_test_key_1_metadata_event("fred")); - r51.events.push(generate_repo_ref_event()); - - r55.events.push(generate_repo_ref_event()); - r55.events.push(generate_test_key_1_metadata_event("fred")); - r55.events.push(generate_test_key_1_relay_list_event()); - - let cli_tester_handle = - std::thread::spawn(move || -> Result<(GitTestRepo, GitTestRepo)> { - let originating_repo = cli_tester_create_prs()?; - - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "list"]); - - create_and_populate_branch( - &test_repo, - FEATURE_BRANCH_NAME_1, - "a", - false, - )?; - test_repo.checkout("main")?; - p.expect("finding PRs...\r\n")?; - let mut c = p.expect_choice( - "All PRs", - vec![ - format!("\"{PR_TITLE_1}\""), - format!("\"{PR_TITLE_2}\""), - format!("\"{PR_TITLE_3}\""), - ], - )?; - c.succeeds_with(0, true)?; - let mut confirm = - p.expect_confirm_eventually("check out branch?", Some(true))?; - confirm.succeeds_with(None)?; - p.expect_end_eventually_and_print()?; - - for p in [51, 52, 53, 55, 56] { - relay::shutdown_relay(8000 + p)?; - } - Ok((originating_repo, test_repo)) - }); - - // launch relay - let _ = join!( - r51.listen_until_close(), - r52.listen_until_close(), - r53.listen_until_close(), - r55.listen_until_close(), - r56.listen_until_close(), - ); - let res = cli_tester_handle.join().unwrap()?; - - Ok(res) - } - - mod cli_prompts { - use super::*; - async fn run_async_prompts_to_choose_from_pr_titles() -> Result<()> { - let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( - Relay::new(8051, None, None), - Relay::new(8052, None, None), - Relay::new(8053, None, None), - Relay::new(8055, None, None), - Relay::new(8056, None, None), - ); - - r51.events.push(generate_test_key_1_relay_list_event()); - r51.events.push(generate_test_key_1_metadata_event("fred")); - r51.events.push(generate_repo_ref_event()); - - r55.events.push(generate_repo_ref_event()); - r55.events.push(generate_test_key_1_metadata_event("fred")); - r55.events.push(generate_test_key_1_relay_list_event()); - - let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - cli_tester_create_prs()?; - - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "list"]); - - create_and_populate_branch( - &test_repo, - FEATURE_BRANCH_NAME_1, - "a", - false, - )?; - test_repo.checkout("main")?; - - p.expect("finding PRs...\r\n")?; - let mut c = p.expect_choice( - "All PRs", - vec![ - format!("\"{PR_TITLE_1}\""), - format!("\"{PR_TITLE_2}\""), - format!("\"{PR_TITLE_3}\""), - ], - )?; - c.succeeds_with(0, true)?; - p.expect("finding commits...\r\n")?; - let mut confirm = p.expect_confirm("check out branch?", Some(true))?; - confirm.succeeds_with(None)?; - p.expect("checked out PR branch. no new commits to pull\r\n")?; - p.expect_end()?; - - for p in [51, 52, 53, 55, 56] { - relay::shutdown_relay(8000 + p)?; - } - Ok(()) - }); - - // launch relay - let _ = join!( - r51.listen_until_close(), - r52.listen_until_close(), - r53.listen_until_close(), - r55.listen_until_close(), - r56.listen_until_close(), - ); - cli_tester_handle.join().unwrap()?; - println!("{:?}", r55.events); - Ok(()) - } - - #[tokio::test] - #[serial] - async fn prompts_to_choose_from_pr_titles() -> Result<()> { - let _ = run_async_prompts_to_choose_from_pr_titles().await; - Ok(()) - } - } - - #[tokio::test] - #[serial] - async fn pr_branch_checked_out() -> Result<()> { - let (_, test_repo) = prep_and_run().await?; - assert_eq!( - FEATURE_BRANCH_NAME_1, - test_repo.get_checked_out_branch_name()?, - ); - Ok(()) - } - } - - mod when_branch_is_behind { - use super::*; - - async fn prep_and_run() -> Result<(GitTestRepo, GitTestRepo)> { - // fallback (51,52) user write (53, 55) repo (55, 56) - let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( - Relay::new(8051, None, None), - Relay::new(8052, None, None), - Relay::new(8053, None, None), - Relay::new(8055, None, None), - Relay::new(8056, None, None), - ); - - r51.events.push(generate_test_key_1_relay_list_event()); - r51.events.push(generate_test_key_1_metadata_event("fred")); - r51.events.push(generate_repo_ref_event()); - - r55.events.push(generate_repo_ref_event()); - r55.events.push(generate_test_key_1_metadata_event("fred")); - r55.events.push(generate_test_key_1_relay_list_event()); - - let cli_tester_handle = - std::thread::spawn(move || -> Result<(GitTestRepo, GitTestRepo)> { - let originating_repo = cli_tester_create_prs()?; - - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "list"]); - - create_and_populate_branch( - &test_repo, - FEATURE_BRANCH_NAME_1, - "a", - true, - )?; - test_repo.checkout("main")?; - - p.expect("finding PRs...\r\n")?; - let mut c = p.expect_choice( - "All PRs", - vec![ - format!("\"{PR_TITLE_1}\""), - format!("\"{PR_TITLE_2}\""), - format!("\"{PR_TITLE_3}\""), - ], - )?; - c.succeeds_with(0, true)?; - let mut confirm = - p.expect_confirm_eventually("check out branch?", Some(true))?; - confirm.succeeds_with(None)?; - p.expect_end_eventually_and_print()?; - - for p in [51, 52, 53, 55, 56] { - relay::shutdown_relay(8000 + p)?; - } - Ok((originating_repo, test_repo)) - }); - - // launch relay - let _ = join!( - r51.listen_until_close(), - r52.listen_until_close(), - r53.listen_until_close(), - r55.listen_until_close(), - r56.listen_until_close(), - ); - let res = cli_tester_handle.join().unwrap()?; - - Ok(res) - } - - mod cli_prompts { - use super::*; - async fn run_async_prompts_to_choose_from_pr_titles() -> Result<()> { - let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( - Relay::new(8051, None, None), - Relay::new(8052, None, None), - Relay::new(8053, None, None), - Relay::new(8055, None, None), - Relay::new(8056, None, None), - ); - - r51.events.push(generate_test_key_1_relay_list_event()); - r51.events.push(generate_test_key_1_metadata_event("fred")); - r51.events.push(generate_repo_ref_event()); - - r55.events.push(generate_repo_ref_event()); - r55.events.push(generate_test_key_1_metadata_event("fred")); - r55.events.push(generate_test_key_1_relay_list_event()); - - let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - cli_tester_create_prs()?; - - let test_repo = GitTestRepo::default(); - test_repo.populate()?; - let mut p = CliTester::new_from_dir(&test_repo.dir, ["prs", "list"]); - - create_and_populate_branch( - &test_repo, - FEATURE_BRANCH_NAME_1, - "a", - true, - )?; - test_repo.checkout("main")?; - - p.expect("finding PRs...\r\n")?; - let mut c = p.expect_choice( - "All PRs", - vec![ - format!("\"{PR_TITLE_1}\""), - format!("\"{PR_TITLE_2}\""), - format!("\"{PR_TITLE_3}\""), - ], - )?; - c.succeeds_with(0, true)?; - p.expect("finding commits...\r\n")?; - let mut confirm = p.expect_confirm("check out branch?", Some(true))?; - confirm.succeeds_with(None)?; - p.expect("checked out PR branch. pulled 1 new commits\r\n")?; - p.expect_end()?; - - for p in [51, 52, 53, 55, 56] { - relay::shutdown_relay(8000 + p)?; - } - Ok(()) - }); - - // launch relay - let _ = join!( - r51.listen_until_close(), - r52.listen_until_close(), - r53.listen_until_close(), - r55.listen_until_close(), - r56.listen_until_close(), - ); - cli_tester_handle.join().unwrap()?; - println!("{:?}", r55.events); - Ok(()) - } - - #[tokio::test] - #[serial] - async fn prompts_to_choose_from_pr_titles() -> Result<()> { - let _ = run_async_prompts_to_choose_from_pr_titles().await; - Ok(()) - } - } - - #[tokio::test] - #[serial] - async fn pr_branch_checked_out() -> Result<()> { - let (_, test_repo) = prep_and_run().await?; - assert_eq!( - FEATURE_BRANCH_NAME_1, - test_repo.get_checked_out_branch_name()?, - ); - Ok(()) - } - - #[tokio::test] - #[serial] - async fn pr_branch_tip_is_most_recent_patch() -> Result<()> { - let (originating_repo, test_repo) = prep_and_run().await?; - assert_eq!( - originating_repo.get_tip_of_local_branch(FEATURE_BRANCH_NAME_1)?, - test_repo.get_tip_of_local_branch(FEATURE_BRANCH_NAME_1)?, - ); - Ok(()) - } - } - - mod when_branch_is_ahead { - // use super::*; - // TODO latest commit in pr builds off an older commit in pr - // instead of previous. - // TODO current git user created commit on branch - } - - mod when_latest_event_rebases_branch { - // use super::*; - // TODO - } - } - } -} diff --git a/tests/pull.rs b/tests/pull.rs index 92d0ba9..d3064a3 100644 --- a/tests/pull.rs +++ b/tests/pull.rs @@ -79,8 +79,7 @@ fn cli_tester_create_pr( "--password", TEST_PASSWORD, "--disable-cli-spinners", - "prs", - "create", + "send", "--title", format!("\"{title}\"").as_str(), "--description", diff --git a/tests/push.rs b/tests/push.rs index 4af5bd4..9791d9d 100644 --- a/tests/push.rs +++ b/tests/push.rs @@ -79,8 +79,7 @@ fn cli_tester_create_pr( "--password", TEST_PASSWORD, "--disable-cli-spinners", - "prs", - "create", + "send", "--title", format!("\"{title}\"").as_str(), "--description", diff --git a/tests/send.rs b/tests/send.rs new file mode 100644 index 0000000..a109918 --- /dev/null +++ b/tests/send.rs @@ -0,0 +1,1124 @@ +use anyhow::Result; +use futures::join; +use serial_test::serial; +use test_utils::{git::GitTestRepo, relay::Relay, *}; + +#[test] +fn when_to_branch_doesnt_exist_return_error() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "--to-branch", "nonexistant"]); + p.expect("Error: cannot find to_branch 'nonexistant'")?; + Ok(()) +} + +#[test] +fn when_no_to_branch_specified_and_no_main_or_master_branch_return_error() -> Result<()> { + let test_repo = GitTestRepo::new("notmain")?; + test_repo.populate()?; + let mut p = CliTester::new_from_dir(&test_repo.dir, ["send"]); + p.expect("Error: a destination branch (to_branch) is not specified and the defaults (main or master) do not exist")?; + Ok(()) +} + +#[test] +fn when_from_branch_doesnt_exist_return_error() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let mut p = CliTester::new_from_dir(&test_repo.dir, ["send", "--from-branch", "nonexistant"]); + p.expect("Error: cannot find from_branch 'nonexistant'")?; + Ok(()) +} + +#[test] +fn when_no_commits_ahead_of_main_return_error() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + // create feature branch with 1 commit ahead + test_repo.create_branch("feature")?; + test_repo.checkout("feature")?; + + let mut p = CliTester::new_from_dir(&test_repo.dir, ["send"]); + p.expect("Error: 'head' is 0 commits ahead of 'main' so no patches were created")?; + Ok(()) +} + +mod when_commits_behind_ask_to_proceed { + use super::*; + + fn prep_test_repo() -> Result { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + // create feature branch with 2 commit ahead + test_repo.create_branch("feature")?; + test_repo.checkout("feature")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + test_repo.stage_and_commit("add t3.md")?; + std::fs::write(test_repo.dir.join("t4.md"), "some content")?; + test_repo.stage_and_commit("add t4.md")?; + // checkout main and add 1 commit + test_repo.checkout("main")?; + std::fs::write(test_repo.dir.join("t5.md"), "some content")?; + test_repo.stage_and_commit("add t5.md")?; + // checkout feature branch + test_repo.checkout("feature")?; + Ok(test_repo) + } + static BEHIND_LEN: u8 = 1; + static AHEAD_LEN: u8 = 2; + + fn expect_confirm_prompt( + p: &mut CliTester, + behind: u8, + ahead: u8, + ) -> Result { + p.expect_confirm( + format!("'head' is {behind} commits behind 'main' and {ahead} ahead. Consider rebasing before sending patches. Proceed anyway?").as_str(), + Some(false), + ) + } + + #[test] + fn asked_with_default_no() -> Result<()> { + let test_repo = prep_test_repo()?; + + let mut p = CliTester::new_from_dir(&test_repo.dir, ["send"]); + expect_confirm_prompt(&mut p, BEHIND_LEN, AHEAD_LEN)?; + p.exit()?; + Ok(()) + } + + #[test] + fn when_response_is_false_aborts() -> Result<()> { + let test_repo = prep_test_repo()?; + + let mut p = CliTester::new_from_dir(&test_repo.dir, ["send"]); + + expect_confirm_prompt(&mut p, BEHIND_LEN, AHEAD_LEN)?.succeeds_with(Some(false))?; + + p.expect_end_with("Error: aborting so branch can be rebased\r\n")?; + + Ok(()) + } + #[test] + #[serial] + fn when_response_is_true_proceeds() -> Result<()> { + let test_repo = prep_test_repo()?; + + let mut p = CliTester::new_from_dir(&test_repo.dir, ["send"]); + expect_confirm_prompt(&mut p, BEHIND_LEN, AHEAD_LEN)?.succeeds_with(Some(true))?; + p.expect( + format!("creating patch for {AHEAD_LEN} commits from 'head' that are {BEHIND_LEN} behind 'main'",) + .as_str(), + )?; + p.exit()?; + Ok(()) + } +} + +#[test] +#[serial] +fn cli_message_creating_patches() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + // create feature branch with 2 commit ahead + test_repo.create_branch("feature")?; + test_repo.checkout("feature")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + test_repo.stage_and_commit("add t3.md")?; + std::fs::write(test_repo.dir.join("t4.md"), "some content")?; + test_repo.stage_and_commit("add t4.md")?; + + let mut p = CliTester::new_from_dir(&test_repo.dir, ["send"]); + + p.expect("creating patch for 2 commits from 'head' that can be merged into 'main'")?; + p.exit()?; + Ok(()) +} + +fn is_cover_letter(event: &nostr::Event) -> bool { + event.kind.as_u64().eq(&PATCH_KIND) + && event.iter_tags().any(|t| t.as_vec()[1].eq("cover-letter")) +} + +fn is_patch(event: &nostr::Event) -> bool { + event.kind.as_u64().eq(&PATCH_KIND) + && !event.iter_tags().any(|t| t.as_vec()[1].eq("cover-letter")) +} + +fn prep_git_repo() -> Result { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + // create feature branch with 2 commit ahead + test_repo.create_branch("feature")?; + test_repo.checkout("feature")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + test_repo.stage_and_commit("add t3.md")?; + std::fs::write(test_repo.dir.join("t4.md"), "some content")?; + test_repo.stage_and_commit("add t4.md")?; + Ok(test_repo) +} + +fn cli_tester_create_pr(git_repo: &GitTestRepo, include_cover_letter: bool) -> CliTester { + let mut args = vec![ + "--nsec", + TEST_KEY_1_NSEC, + "--password", + TEST_PASSWORD, + "--disable-cli-spinners", + "send", + ]; + if include_cover_letter { + for arg in [ + "--title", + "exampletitle", + "--description", + "exampledescription", + ] { + args.push(arg); + } + } else { + args.push("--no-cover-letter"); + } + CliTester::new_from_dir(&git_repo.dir, args) +} + +fn expect_msgs_first(p: &mut CliTester, include_cover_letter: bool) -> Result<()> { + p.expect("creating patch for 2 commits from 'head' that can be merged into 'main'\r\n")?; + p.expect("searching for your details...\r\n")?; + p.expect("\r")?; + p.expect("logged in as fred\r\n")?; + p.expect(format!( + "posting 2 patches {} a covering letter...\r\n", + if include_cover_letter { + "with" + } else { + "without" + } + ))?; + Ok(()) +} + +async fn prep_run_create_pr( + include_cover_letter: bool, +) -> Result<( + Relay<'static>, + Relay<'static>, + Relay<'static>, + Relay<'static>, + Relay<'static>, +)> { + let git_repo = prep_git_repo()?; + // fallback (51,52) user write (53, 55) repo (55, 56) + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new( + 8051, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![ + generate_test_key_1_metadata_event("fred"), + generate_test_key_1_relay_list_event(), + ], + )?; + Ok(()) + }), + ), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new( + 8055, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![generate_repo_ref_event()], + )?; + Ok(()) + }), + ), + Relay::new(8056, None, None), + ); + + // // check relay had the right number of events + let cli_tester_handle = std::thread::spawn(move || -> Result<()> { + let mut p = cli_tester_create_pr(&git_repo, include_cover_letter); + p.expect_end_eventually()?; + for p in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + p)?; + } + Ok(()) + }); + + // launch relay + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + cli_tester_handle.join().unwrap()?; + Ok((r51, r52, r53, r55, r56)) +} + +mod sends_cover_letter_and_2_patches_to_3_relays { + + use super::*; + #[tokio::test] + #[serial] + async fn only_1_pr_kind_event_sent_to_each_relay() -> Result<()> { + let (_, _, r53, r55, r56) = prep_run_create_pr(true).await?; + for relay in [&r53, &r55, &r56] { + assert_eq!( + relay.events.iter().filter(|e| is_cover_letter(e)).count(), + 1, + ); + } + Ok(()) + } + + #[tokio::test] + #[serial] + async fn only_1_pr_kind_event_sent_to_user_relays() -> Result<()> { + let (_, _, r53, r55, _) = prep_run_create_pr(true).await?; + for relay in [&r53, &r55] { + assert_eq!( + relay.events.iter().filter(|e| is_cover_letter(e)).count(), + 1, + ); + } + Ok(()) + } + + #[tokio::test] + #[serial] + async fn only_1_pr_kind_event_sent_to_repo_relays() -> Result<()> { + let (_, _, _, r55, r56) = prep_run_create_pr(true).await?; + for relay in [&r55, &r56] { + assert_eq!( + relay.events.iter().filter(|e| is_cover_letter(e)).count(), + 1 + ); + } + Ok(()) + } + + #[tokio::test] + #[serial] + async fn pr_not_sent_to_fallback_relay() -> Result<()> { + let (r51, r52, _, _, _) = prep_run_create_pr(true).await?; + for relay in [&r51, &r52] { + assert_eq!( + relay.events.iter().filter(|e| is_cover_letter(e)).count(), + 0, + ); + } + Ok(()) + } + + #[tokio::test] + #[serial] + async fn only_2_patch_kind_events_sent_to_each_relay() -> Result<()> { + let (_, _, r53, r55, r56) = prep_run_create_pr(true).await?; + for relay in [&r53, &r55, &r56] { + assert_eq!(relay.events.iter().filter(|e| is_patch(e)).count(), 2,); + } + Ok(()) + } + + #[tokio::test] + #[serial] + async fn patch_content_contains_patch_in_email_format_with_patch_series_numbers() -> Result<()> + { + let (_, _, r53, r55, r56) = prep_run_create_pr(true).await?; + for relay in [&r53, &r55, &r56] { + let patch_events: Vec<&nostr::Event> = + relay.events.iter().filter(|e| is_patch(e)).collect(); + + assert_eq!( + patch_events[1].content, + "\ + From fe973a840fba2a8ab37dd505c154854a69a6505c Mon Sep 17 00:00:00 2001\n\ + From: Joe Bloggs \n\ + Date: Thu, 1 Jan 1970 00:00:00 +0000\n\ + Subject: [PATCH 2/2] add t4.md\n\ + \n\ + ---\n \ + t4.md | 1 +\n \ + 1 file changed, 1 insertion(+)\n \ + create mode 100644 t4.md\n\ + \n\ + diff --git a/t4.md b/t4.md\n\ + new file mode 100644\n\ + index 0000000..f0eec86\n\ + --- /dev/null\n\ + +++ b/t4.md\n\ + @@ -0,0 +1 @@\n\ + +some content\n\\ \ + No newline at end of file\n\ + --\n\ + libgit2 1.7.1\n\ + \n\ + ", + ); + assert_eq!( + patch_events[0].content, + "\ + From 232efb37ebc67692c9e9ff58b83c0d3d63971a0a Mon Sep 17 00:00:00 2001\n\ + From: Joe Bloggs \n\ + Date: Thu, 1 Jan 1970 00:00:00 +0000\n\ + Subject: [PATCH 1/2] add t3.md\n\ + \n\ + ---\n \ + t3.md | 1 +\n \ + 1 file changed, 1 insertion(+)\n \ + create mode 100644 t3.md\n\ + \n\ + diff --git a/t3.md b/t3.md\n\ + new file mode 100644\n\ + index 0000000..f0eec86\n\ + --- /dev/null\n\ + +++ b/t3.md\n\ + @@ -0,0 +1 @@\n\ + +some content\n\\ \ + No newline at end of file\n\ + --\n\ + libgit2 1.7.1\n\ + \n\ + ", + ); + } + Ok(()) + } + + mod pr_tags { + use super::*; + + #[tokio::test] + #[serial] + async fn root_commit_as_r() -> Result<()> { + let (_, _, r53, r55, r56) = prep_run_create_pr(true).await?; + for relay in [&r53, &r55, &r56] { + let pr_event: &nostr::Event = + relay.events.iter().find(|e| is_cover_letter(e)).unwrap(); + + assert_eq!( + pr_event + .iter_tags() + .find(|t| t.as_vec()[0].eq("r")) + .unwrap() + .as_vec()[1], + "9ee507fc4357d7ee16a5d8901bedcd103f23c17d" + ); + } + Ok(()) + } + + #[tokio::test] + #[serial] + async fn a_tag_for_repo_event() -> Result<()> { + let (_, _, r53, r55, r56) = prep_run_create_pr(true).await?; + for relay in [&r53, &r55, &r56] { + let pr_event: &nostr::Event = + relay.events.iter().find(|e| is_cover_letter(e)).unwrap(); + assert!(pr_event.iter_tags().any(|t| t.as_vec()[0].eq("a") + && t.as_vec()[1].eq(&format!( + "{REPOSITORY_KIND}:{TEST_KEY_1_PUBKEY_HEX}:{}", + generate_repo_ref_event().identifier().unwrap() + )))); + } + Ok(()) + } + + #[tokio::test] + #[serial] + async fn p_tags_for_maintainers() -> Result<()> { + let maintainers = &generate_repo_ref_event() + .iter_tags() + .find(|t| t.as_vec()[0].eq(&"maintainers")) + .unwrap() + .as_vec() + .clone()[1..]; + let (_, _, r53, r55, r56) = prep_run_create_pr(true).await?; + for relay in [&r53, &r55, &r56] { + for m in maintainers { + let pr_event: &nostr::Event = + relay.events.iter().find(|e| is_cover_letter(e)).unwrap(); + assert!( + pr_event + .iter_tags() + .any(|t| { t.as_vec()[0].eq("p") && t.as_vec()[1].eq(m) }) + ); + } + } + Ok(()) + } + + #[tokio::test] + #[serial] + async fn t_tag_cover_letter() -> Result<()> { + let (_, _, r53, r55, r56) = prep_run_create_pr(true).await?; + for relay in [&r53, &r55, &r56] { + let pr_event: &nostr::Event = + relay.events.iter().find(|e| is_cover_letter(e)).unwrap(); + assert!( + pr_event + .iter_tags() + .any(|t| { t.as_vec()[0].eq("t") && t.as_vec()[1].eq(&"cover-letter") }) + ); + } + Ok(()) + } + + #[tokio::test] + #[serial] + async fn t_tag_root() -> Result<()> { + let (_, _, r53, r55, r56) = prep_run_create_pr(true).await?; + for relay in [&r53, &r55, &r56] { + let pr_event: &nostr::Event = + relay.events.iter().find(|e| is_cover_letter(e)).unwrap(); + assert!( + pr_event + .iter_tags() + .any(|t| { t.as_vec()[0].eq("t") && t.as_vec()[1].eq(&"root") }) + ); + } + Ok(()) + } + + #[tokio::test] + #[serial] + async fn pr_tags_branch_name() -> Result<()> { + let (_, _, r53, r55, r56) = prep_run_create_pr(true).await?; + for relay in [&r53, &r55, &r56] { + let pr_event: &nostr::Event = + relay.events.iter().find(|e| is_cover_letter(e)).unwrap(); + + // branch-name tag + assert_eq!( + pr_event + .iter_tags() + .find(|t| t.as_vec()[0].eq("branch-name")) + .unwrap() + .as_vec()[1], + "feature" + ); + } + Ok(()) + } + } + + mod patch_tags { + use super::*; + + async fn prep() -> Result { + let (_, _, r53, _, _) = prep_run_create_pr(true).await?; + Ok(r53.events.iter().find(|e| is_patch(e)).unwrap().clone()) + } + + #[tokio::test] + #[serial] + async fn commit_and_commit_r() -> Result<()> { + static COMMIT_ID: &str = "232efb37ebc67692c9e9ff58b83c0d3d63971a0a"; + let most_recent_patch = prep().await?; + assert!( + most_recent_patch + .tags + .iter() + .any(|t| t.as_vec()[0].eq("r") && t.as_vec()[1].eq(COMMIT_ID)) + ); + assert!( + most_recent_patch + .tags + .iter() + .any(|t| t.as_vec()[0].eq("commit") && t.as_vec()[1].eq(COMMIT_ID)) + ); + Ok(()) + } + + #[tokio::test] + #[serial] + async fn parent_commit() -> Result<()> { + // commit parent 'r' and 'parent-commit' tag + static COMMIT_PARENT_ID: &str = "431b84edc0d2fa118d63faa3c2db9c73d630a5ae"; + let most_recent_patch = prep().await?; + assert_eq!( + most_recent_patch + .tags + .iter() + .find(|t| t.as_vec()[0].eq("parent-commit")) + .unwrap() + .as_vec()[1], + COMMIT_PARENT_ID, + ); + Ok(()) + } + + #[tokio::test] + #[serial] + async fn root_commit_as_r() -> Result<()> { + assert!(prep().await?.tags.iter().any(|t| t.as_vec()[0].eq("r") + && t.as_vec()[1].eq("9ee507fc4357d7ee16a5d8901bedcd103f23c17d"))); + Ok(()) + } + + #[tokio::test] + #[serial] + async fn p_tags_for_maintainers() -> Result<()> { + let maintainers = &generate_repo_ref_event() + .iter_tags() + .find(|t| t.as_vec()[0].eq(&"maintainers")) + .unwrap() + .as_vec() + .clone()[1..]; + for m in maintainers { + assert!( + prep() + .await? + .iter_tags() + .any(|t| { t.as_vec()[0].eq("p") && t.as_vec()[1].eq(m) }) + ); + } + Ok(()) + } + + #[tokio::test] + #[serial] + async fn a_tag_for_repo_event() -> Result<()> { + assert!(prep().await?.tags.iter().any(|t| { + t.as_vec()[0].eq("a") + && t.as_vec()[1].eq(&format!( + "{REPOSITORY_KIND}:{TEST_KEY_1_PUBKEY_HEX}:{}", + generate_repo_ref_event().identifier().unwrap() + )) + })); + Ok(()) + } + + #[tokio::test] + #[serial] + async fn description_with_commit_message() -> Result<()> { + assert_eq!( + prep() + .await? + .tags + .iter() + .find(|t| t.as_vec()[0].eq("description")) + .unwrap() + .as_vec()[1], + "add t3.md" + ); + Ok(()) + } + + #[tokio::test] + #[serial] + async fn commit_author() -> Result<()> { + assert_eq!( + prep() + .await? + .tags + .iter() + .find(|t| t.as_vec()[0].eq("author")) + .unwrap() + .as_vec(), + vec!["author", "Joe Bloggs", "joe.bloggs@pm.me", "0", "0"], + ); + Ok(()) + } + + #[tokio::test] + #[serial] + async fn commit_committer() -> Result<()> { + assert_eq!( + prep() + .await? + .tags + .iter() + .find(|t| t.as_vec()[0].eq("committer")) + .unwrap() + .as_vec(), + vec!["committer", "Joe Bloggs", "joe.bloggs@pm.me", "0", "0"], + ); + Ok(()) + } + + #[tokio::test] + #[serial] + async fn patch_tags_pr_event_as_root() -> Result<()> { + let (_, _, r53, r55, r56) = prep_run_create_pr(true).await?; + for relay in [&r53, &r55, &r56] { + let patch_events: Vec<&nostr::Event> = + relay.events.iter().filter(|e| is_patch(e)).collect(); + + let most_recent_patch = patch_events[0]; + let pr_event = relay.events.iter().find(|e| is_cover_letter(e)).unwrap(); + + let root_event_tag = most_recent_patch + .tags + .iter() + .find(|t| { + t.as_vec()[0].eq("e") && t.as_vec().len().eq(&4) && t.as_vec()[3].eq("root") + }) + .unwrap(); + + assert_eq!(root_event_tag.as_vec()[1], pr_event.id.to_string()); + } + Ok(()) + } + + #[tokio::test] + #[serial] + async fn second_patch_tags_first_with_reply() -> Result<()> { + let (_, _, r53, r55, r56) = prep_run_create_pr(true).await?; + for relay in [&r53, &r55, &r56] { + let patch_events = relay + .events + .iter() + .filter(|e| is_patch(e)) + .collect::>(); + assert_eq!( + patch_events[1] + .iter_tags() + .find(|t| t.as_vec()[0].eq("e") + && t.as_vec().len().eq(&4) + && t.as_vec()[3].eq("reply")) + .unwrap() + .as_vec()[1], + patch_events[0].id.to_string(), + ); + } + Ok(()) + } + + #[tokio::test] + #[serial] + async fn no_t_root_tag() -> Result<()> { + assert!( + !prep() + .await? + .tags + .iter() + .any(|t| t.as_vec()[0].eq("t") && t.as_vec()[1].eq("root")) + ); + Ok(()) + } + } + mod cli_ouput { + use super::*; + + async fn run_test_async() -> Result<()> { + let git_repo = prep_git_repo()?; + + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new( + 8051, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![ + generate_test_key_1_metadata_event("fred"), + generate_test_key_1_relay_list_event(), + ], + )?; + Ok(()) + }), + ), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new( + 8055, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![generate_repo_ref_event()], + )?; + Ok(()) + }), + ), + Relay::new(8056, None, None), + ); + + // // check relay had the right number of events + let cli_tester_handle = std::thread::spawn(move || -> Result<()> { + let mut p = cli_tester_create_pr(&git_repo, true); + expect_msgs_first(&mut p, true)?; + relay::expect_send_with_progress( + &mut p, + vec![ + (" [my-relay] [repo-relay] ws://localhost:8055", true, ""), + (" [my-relay] ws://localhost:8053", true, ""), + (" [repo-relay] ws://localhost:8056", true, ""), + ], + 3, + )?; + p.expect_end_with_whitespace()?; + for p in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + p)?; + } + Ok(()) + }); + + // launch relay + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + cli_tester_handle.join().unwrap()?; + Ok(()) + } + + #[tokio::test] + #[serial] + async fn check_cli_output() -> Result<()> { + run_test_async().await?; + Ok(()) + } + } + + mod first_event_rejected_by_1_relay { + use super::*; + + mod only_first_rejected_event_sent_to_relay { + use super::*; + + async fn run_test_async() -> Result<()> { + let git_repo = prep_git_repo()?; + + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new( + 8051, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![ + generate_test_key_1_metadata_event("fred"), + generate_test_key_1_relay_list_event(), + ], + )?; + Ok(()) + }), + ), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new( + 8055, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![generate_repo_ref_event()], + )?; + Ok(()) + }), + ), + Relay::new( + 8056, + Some(&|relay, client_id, event| -> Result<()> { + relay.respond_ok(client_id, event, Some("Payment Required"))?; + Ok(()) + }), + None, + ), + ); + + // // check relay had the right number of events + let cli_tester_handle = std::thread::spawn(move || -> Result<()> { + let mut p = cli_tester_create_pr(&git_repo, true); + p.expect_end_eventually()?; + for p in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + p)?; + } + Ok(()) + }); + + // launch relay + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + cli_tester_handle.join().unwrap()?; + + assert_eq!(r56.events.len(), 1); + + Ok(()) + } + + #[tokio::test] + #[serial] + async fn only_first_rejected_event_sent_to_relay() -> Result<()> { + run_test_async().await?; + Ok(()) + } + } + + mod cli_show_rejection_with_comment { + use super::*; + + async fn run_test_async() -> Result<(Relay<'static>, Relay<'static>, Relay<'static>)> { + let git_repo = prep_git_repo()?; + + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new( + 8051, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![ + generate_test_key_1_metadata_event("fred"), + generate_test_key_1_relay_list_event(), + ], + )?; + Ok(()) + }), + ), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new( + 8055, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![generate_repo_ref_event()], + )?; + Ok(()) + }), + ), + Relay::new( + 8056, + Some(&|relay, client_id, event| -> Result<()> { + relay.respond_ok(client_id, event, Some("Payment Required"))?; + Ok(()) + }), + None, + ), + ); + + // // check relay had the right number of events + let cli_tester_handle = std::thread::spawn(move || -> Result<()> { + let mut p = cli_tester_create_pr(&git_repo, true); + expect_msgs_first(&mut p, true)?; + // p.expect_end_with("bla")?; + relay::expect_send_with_progress( + &mut p, + vec![ + (" [my-relay] [repo-relay] ws://localhost:8055", true, ""), + (" [my-relay] ws://localhost:8053", true, ""), + ( + " [repo-relay] ws://localhost:8056", + false, + "error: Payment Required", + ), + ], + 3, + )?; + p.expect_end_with_whitespace()?; + for p in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + p)?; + } + + Ok(()) + }); + + // launch relay + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + cli_tester_handle.join().unwrap()?; + Ok((r51, r52, r53)) + } + + #[tokio::test] + #[serial] + async fn cli_show_rejection_with_comment() -> Result<()> { + run_test_async().await?; + Ok(()) + } + } + } +} + +mod sends_2_patches_without_cover_letter { + use super::*; + + mod cli_ouput { + use super::*; + + async fn run_test_async() -> Result<()> { + let git_repo = prep_git_repo()?; + + let (mut r51, mut r52, mut r53, mut r55, mut r56) = ( + Relay::new( + 8051, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![ + generate_test_key_1_metadata_event("fred"), + generate_test_key_1_relay_list_event(), + ], + )?; + Ok(()) + }), + ), + Relay::new(8052, None, None), + Relay::new(8053, None, None), + Relay::new( + 8055, + None, + Some(&|relay, client_id, subscription_id, _| -> Result<()> { + relay.respond_events( + client_id, + &subscription_id, + &vec![generate_repo_ref_event()], + )?; + Ok(()) + }), + ), + Relay::new(8056, None, None), + ); + + // // check relay had the right number of events + let cli_tester_handle = std::thread::spawn(move || -> Result<()> { + let mut p = cli_tester_create_pr(&git_repo, false); + + expect_msgs_first(&mut p, false)?; + relay::expect_send_with_progress( + &mut p, + vec![ + (" [my-relay] [repo-relay] ws://localhost:8055", true, ""), + (" [my-relay] ws://localhost:8053", true, ""), + (" [repo-relay] ws://localhost:8056", true, ""), + ], + 2, + )?; + p.expect_end_with_whitespace()?; + for p in [51, 52, 53, 55, 56] { + relay::shutdown_relay(8000 + p)?; + } + Ok(()) + }); + + // launch relay + let _ = join!( + r51.listen_until_close(), + r52.listen_until_close(), + r53.listen_until_close(), + r55.listen_until_close(), + r56.listen_until_close(), + ); + cli_tester_handle.join().unwrap()?; + Ok(()) + } + + #[tokio::test] + #[serial] + async fn check_cli_output() -> Result<()> { + run_test_async().await?; + Ok(()) + } + } + + #[tokio::test] + #[serial] + async fn no_cover_letter_event() -> Result<()> { + let (_, _, r53, r55, r56) = prep_run_create_pr(false).await?; + for relay in [&r53, &r55, &r56] { + assert_eq!( + relay.events.iter().filter(|e| is_cover_letter(e)).count(), + 0, + ); + } + Ok(()) + } + + #[tokio::test] + #[serial] + async fn two_patch_events() -> Result<()> { + let (_, _, r53, r55, r56) = prep_run_create_pr(false).await?; + for relay in [&r53, &r55, &r56] { + assert_eq!(relay.events.iter().filter(|e| is_patch(e)).count(), 2); + } + Ok(()) + } + + #[tokio::test] + #[serial] + // TODO check this is the ancestor + async fn first_patch_with_root_t_tag() -> Result<()> { + let (_, _, r53, r55, r56) = prep_run_create_pr(false).await?; + for relay in [&r53, &r55, &r56] { + let patch_events = relay + .events + .iter() + .filter(|e| is_patch(e)) + .collect::>(); + + // first patch tagged as root + assert!( + patch_events[0] + .iter_tags() + .any(|t| t.as_vec()[0].eq("t") && t.as_vec()[1].eq("root")) + ); + // second patch not tagged as root + assert!( + !patch_events[1] + .iter_tags() + .any(|t| t.as_vec()[0].eq("t") && t.as_vec()[1].eq("root")) + ); + } + Ok(()) + } + + #[tokio::test] + #[serial] + async fn second_patch_lists_first_as_root() -> Result<()> { + let (_, _, r53, r55, r56) = prep_run_create_pr(false).await?; + for relay in [&r53, &r55, &r56] { + let patch_events = relay + .events + .iter() + .filter(|e| is_patch(e)) + .collect::>(); + + assert_eq!( + patch_events[1] + .iter_tags() + .find(|t| t.as_vec()[0].eq("e") + && t.as_vec().len().eq(&4) + && t.as_vec()[3].eq("root")) + .unwrap() + .as_vec()[1], + patch_events[0].id.to_string(), + ); + } + Ok(()) + } +} -- cgit v1.2.3