From 06be0bc44011411b78217459f505ed12281b32c4 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 1 Dec 2023 00:00:00 +0000 Subject: feat(prs-list) list and pull selected as branch - fetch prs and present as a selectable list - create and / or checkout branch for selected pr - apply latest patches as commits --- src/cli_interactor.rs | 9 + src/client.rs | 12 + src/git.rs | 967 ++++++++++++++++++++++++++++++++++++++++- src/sub_commands/prs/create.rs | 90 ++-- src/sub_commands/prs/list.rs | 225 ++++++++++ src/sub_commands/prs/mod.rs | 3 + test_utils/src/git.rs | 60 ++- test_utils/src/lib.rs | 167 ++++++- test_utils/src/relay.rs | 33 +- tests/prs_create.rs | 163 +++++-- tests/prs_list.rs | 768 ++++++++++++++++++++++++++++++++ 11 files changed, 2402 insertions(+), 95 deletions(-) create mode 100644 src/sub_commands/prs/list.rs create mode 100644 tests/prs_list.rs diff --git a/src/cli_interactor.rs b/src/cli_interactor.rs index e52cefb..c6cd4e5 100644 --- a/src/cli_interactor.rs +++ b/src/cli_interactor.rs @@ -40,6 +40,8 @@ impl InteractorPrompt for Interactor { } fn choice(&self, parms: PromptChoiceParms) -> Result { dialoguer::Select::with_theme(&self.theme) + .with_prompt(parms.prompt) + .report(parms.report) .items(&parms.choices) .interact() .context("failed to get choice") @@ -96,13 +98,20 @@ impl PromptConfirmParms { pub struct PromptChoiceParms { pub prompt: String, pub choices: Vec, + pub report: bool, } impl PromptChoiceParms { pub fn with_prompt>(mut self, prompt: S) -> Self { self.prompt = prompt.into(); + self.report = true; self } + + // pub fn dont_report(mut self) -> Self { + // self.report = false; + // self + // } pub fn with_choices(mut self, choices: Vec) -> Self { self.choices = choices; self diff --git a/src/client.rs b/src/client.rs index 1037b1b..860562c 100644 --- a/src/client.rs +++ b/src/client.rs @@ -20,6 +20,7 @@ use nostr::Event; pub struct Client { client: nostr_sdk::Client, fallback_relays: Vec, + more_fallback_relays: Vec, } #[cfg_attr(test, automock)] @@ -30,6 +31,7 @@ pub trait Connect { async fn set_keys(&mut self, keys: &nostr::Keys); async fn disconnect(&self) -> Result<()>; fn get_fallback_relays(&self) -> &Vec; + fn get_more_fallback_relays(&self) -> &Vec; async fn send_event_to(&self, url: &str, event: nostr::event::Event) -> Result; async fn get_events( &self, @@ -47,12 +49,17 @@ impl Connect for Client { "ws://localhost:8051".to_string(), "ws://localhost:8052".to_string(), ], + more_fallback_relays: vec![ + "ws://localhost:8055".to_string(), + "ws://localhost:8056".to_string(), + ], } } fn new(opts: Params) -> Self { Client { client: nostr_sdk::Client::new(&opts.keys.unwrap_or(nostr::Keys::generate())), fallback_relays: opts.fallback_relays, + more_fallback_relays: opts.more_fallback_relays, } } @@ -69,6 +76,10 @@ impl Connect for Client { &self.fallback_relays } + fn get_more_fallback_relays(&self) -> &Vec { + &self.more_fallback_relays + } + async fn send_event_to(&self, url: &str, event: Event) -> Result { self.client.add_relay(url, None).await?; self.client.connect_relay(url).await?; @@ -130,6 +141,7 @@ async fn get_events_of( pub struct Params { pub keys: Option, pub fallback_relays: Vec, + pub more_fallback_relays: Vec, } fn get_dedup_events(relay_results: Vec>>) -> Vec { diff --git a/src/git.rs b/src/git.rs index 281f00c..f4f73a5 100644 --- a/src/git.rs +++ b/src/git.rs @@ -6,6 +6,8 @@ use anyhow::{bail, Context, Result}; use git2::{Oid, Revwalk}; use nostr::prelude::{sha1::Hash as Sha1Hash, Hash}; +use crate::sub_commands::prs::list::tag_value; + pub struct Repo { git_repo: git2::Repository, } @@ -36,14 +38,26 @@ pub trait RepoActions { fn does_commit_exist(&self, commit: &str) -> Result; fn get_head_commit(&self) -> Result; fn get_commit_parent(&self, commit: &Sha1Hash) -> Result; + fn get_commit_message(&self, commit: &Sha1Hash) -> Result; + /// returns vector ["name", "email", "unixtime"] + /// eg ["joe bloggs", "joe@pm.me", "12176,-300"] + fn get_commit_author(&self, commit: &Sha1Hash) -> Result>; + /// returns vector ["name", "email", "unixtime"] + /// eg ["joe bloggs", "joe@pm.me", "12176,-300"] + fn get_commit_comitter(&self, commit: &Sha1Hash) -> Result>; fn get_commits_ahead_behind( &self, base_commit: &Sha1Hash, latest_commit: &Sha1Hash, ) -> Result<(Vec, Vec)>; fn make_patch_from_commit(&self, commit: &Sha1Hash) -> Result; - fn checkout(&self, ref_name: &str) -> Result<()>; + fn checkout(&self, ref_name: &str) -> Result; fn create_branch_at_commit(&self, branch_name: &str, commit: &str) -> Result<()>; + fn apply_patch_chain( + &self, + branch_name: &str, + patch_and_ancestors: Vec, + ) -> Result>; } impl RepoActions for Repo { @@ -122,7 +136,7 @@ impl RepoActions for Repo { } fn does_commit_exist(&self, commit: &str) -> Result { - if let Ok(c) = self.git_repo.find_commit(Oid::from_str(commit)?) { + if self.git_repo.find_commit(Oid::from_str(commit)?).is_ok() { Ok(true) } else { Ok(false) @@ -148,6 +162,34 @@ impl RepoActions for Repo { Ok(oid_to_sha1(&parent_oid)) } + fn get_commit_message(&self, commit: &Sha1Hash) -> Result { + Ok(self + .git_repo + .find_commit(sha1_to_oid(commit)?) + .context(format!("could not find commit {commit}"))? + .message_raw() + .context("commit message has unusual characters in (not valid utf-8)")? + .to_string()) + } + + fn get_commit_author(&self, commit: &Sha1Hash) -> Result> { + let commit = self + .git_repo + .find_commit(sha1_to_oid(commit)?) + .context(format!("could not find commit {commit}"))?; + let sig = commit.author(); + Ok(git_sig_to_tag_vec(&sig)) + } + + fn get_commit_comitter(&self, commit: &Sha1Hash) -> Result> { + let commit = self + .git_repo + .find_commit(sha1_to_oid(commit)?) + .context(format!("could not find commit {commit}"))?; + let sig = commit.committer(); + Ok(git_sig_to_tag_vec(&sig)) + } + fn make_patch_from_commit(&self, commit: &Sha1Hash) -> Result { let c = self .git_repo @@ -225,7 +267,7 @@ impl RepoActions for Repo { Ok((ahead, behind)) } - fn checkout(&self, ref_name: &str) -> Result<()> { + fn checkout(&self, ref_name: &str) -> Result { let (object, reference) = self.git_repo.revparse_ext(ref_name)?; self.git_repo.checkout_tree(&object, None)?; @@ -236,7 +278,9 @@ impl RepoActions for Repo { // this is a commit, not a reference None => self.git_repo.set_head_detached(object.id()), }?; - Ok(()) + let oid = self.git_repo.head()?.peel_to_commit()?.id(); + + Ok(oid_to_sha1(&oid)) } fn create_branch_at_commit(&self, branch_name: &str, commit: &str) -> Result<()> { @@ -244,11 +288,66 @@ impl RepoActions for Repo { .branch( branch_name, &self.git_repo.find_commit(Oid::from_str(commit)?)?, - false, + true, ) .context("branch could not be created")?; Ok(()) } + /* returns patches applied */ + fn apply_patch_chain( + &self, + branch_name: &str, + patch_and_ancestors: Vec, + ) -> Result> { + // filter out existing ancestors + let mut patches_to_apply: Vec = patch_and_ancestors + .into_iter() + .filter(|e| { + !self + .does_commit_exist(&tag_value(e, "commit").unwrap()) + .unwrap() + }) + .collect(); + + let parent_commit_id = tag_value( + if let Ok(last_patch) = patches_to_apply.last().context("no patches") { + last_patch + } else { + self.checkout(branch_name).context("latest commit in pr doesnt connect with an existing commit. Try a git pull first.")?; + return Ok(vec![]); + }, + "parent-commit", + )?; + + // check patches can be applied + if !self.does_commit_exist(&parent_commit_id)? { + bail!("cannot find parent commit ({parent_commit_id}). run git pull and try again.") + } + + // check for rebase or changes + if let Ok(current_tip) = self.get_tip_of_local_branch(branch_name) { + if !current_tip.to_string().eq(&parent_commit_id) { + // TODO: either changes have been made on the local branch or + // the latest commit in the pr has rebased onto a newer commit + // that you havn't pulled yet ask user whether + // they want to proceed + } + } + + // checkout branch + if !self.get_checked_out_branch_name()?.eq(&branch_name) { + self.create_branch_at_commit(branch_name, &parent_commit_id)?; + } + self.checkout(branch_name)?; + + // apply commits + patches_to_apply.reverse(); + + for patch in &patches_to_apply { + apply_patch(self, patch)?; + } + Ok(patches_to_apply) + } } fn oid_to_u8_20_bytes(oid: &Oid) -> [u8; 20] { @@ -285,8 +384,142 @@ fn sha1_to_oid(hash: &Sha1Hash) -> Result { Oid::from_bytes(hash.as_byte_array()).context("Sha1Hash bytes failed to produce a valid Oid") } +fn git_sig_to_tag_vec(sig: &git2::Signature) -> Vec { + vec![ + sig.name().unwrap_or("").to_string(), + sig.email().unwrap_or("").to_string(), + format!("{},{}", sig.when().seconds(), sig.when().offset_minutes()), + ] +} + +fn apply_patch(git_repo: &Repo, patch: &nostr::Event) -> Result<()> { + // check parent commit matches head + if !git_repo + .get_head_commit()? + .to_string() + .eq(&tag_value(patch, "parent-commit")?) + { + bail!( + "patch parent ({}) doesnt match current head ({})", + tag_value(patch, "parent-commit")?, + git_repo.get_head_commit()? + ); + } + + let diff_from_patch = git2::Diff::from_buffer(patch.content.as_bytes()).unwrap(); + + let mut apply_opts = git2::ApplyOptions::new(); + apply_opts.check(false); + + git_repo.git_repo.apply( + &diff_from_patch, + git2::ApplyLocation::WorkDir, + Some(&mut apply_opts), + )?; + // stage and commit + let prev_oid = git_repo.git_repo.head().unwrap().peel_to_commit()?; + + let mut index = git_repo.git_repo.index()?; + index.add_all(["."], git2::IndexAddOption::DEFAULT, None)?; + index.write()?; + + git_repo.git_repo.commit( + Some("HEAD"), + &extract_sig_from_patch_tags(&patch.tags, "author")?, + &extract_sig_from_patch_tags(&patch.tags, "committer")?, + tag_value(patch, "description")?.as_str(), + &git_repo.git_repo.find_tree(index.write_tree()?)?, + &[&prev_oid], + )?; + // end of stage and commit + // check commit applied + if git_repo + .get_head_commit()? + .to_string() + .eq(&tag_value(patch, "parent-commit")?) + { + bail!("applying patch failed"); + } + + let mut revwalk = git_repo.git_repo.revwalk().context("revwalk error")?; + revwalk.push_head().context("revwalk.push_head")?; + + for (i, oid) in revwalk.enumerate() { + if i == 0 { + let old_commit = git_repo + .git_repo + .find_commit(oid.context("cannot get oid in revwalk")?) + .context("cannot find newly added commit oid")?; + // create commit using amend which relects the original commit id + let updated_commit_oid = old_commit + .amend( + None, + Some(&old_commit.author()), + Some(&old_commit.committer()), + None, + None, + None, + ) + .context("cannot ammend commit to produce new oid")?; + // replace the commit with the wrong oid with the newly created one with the + // correct oid + git_repo + .git_repo + .head() + .context("cannot get head of git_repo")? + .set_target(updated_commit_oid, "ref commit with fix committer details") + .context("cannot update branch with fixed commit")?; + + if !updated_commit_oid + .to_string() + .eq(&tag_value(patch, "commit")?) + { + bail!( + "when applied the patch commit id ({}) doesn't match the one specified in the event tag ({})", + updated_commit_oid.to_string(), + tag_value(patch, "commit")?, + ) + } + } + } + Ok(()) +} + +fn extract_sig_from_patch_tags<'a>( + tags: &'a [nostr::Tag], + tag_name: &str, +) -> Result> { + let v = tags + .iter() + .find(|t| t.as_vec()[0].eq(tag_name)) + .context(format!("tag '{tag_name}' not present in patch"))? + .as_vec(); + if v.len() != 4 { + bail!("tag '{tag_name}' is incorrectly formatted") + } + let git_time: Vec<&str> = v[3].split(',').collect(); + if git_time.len() != 2 { + bail!("tag '{tag_name}' time is incorrectly formatted") + } + git2::Signature::new( + v[1].as_str(), + v[2].as_str(), + &git2::Time::new( + git_time[0] + .parse() + .context("tag time is incorrectly formatted")?, + git_time[1] + .parse() + .context("tag time offset is incorrectly formatted")?, + ), + ) + .context("failed to create git signature") +} + #[cfg(test)] mod tests { + use std::fs; + use test_utils::git::GitTestRepo; use super::*; @@ -308,16 +541,177 @@ mod tests { Ok(()) } + mod get_commit_message { + use super::*; + fn run(message: &str) -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + std::fs::write(test_repo.dir.join("t100.md"), "some content")?; + let oid = test_repo.stage_and_commit(message)?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + + assert_eq!(message, git_repo.get_commit_message(&oid_to_sha1(&oid))?,); + Ok(()) + } + #[test] + fn one_liner() -> Result<()> { + run("add t100.md") + } + + #[test] + fn multiline() -> Result<()> { + run("add t100.md\r\nanother line\r\nthird line") + } + + #[test] + fn trailing_newlines() -> Result<()> { + run("add t100.md\r\n\r\n\r\n\r\n\r\n\r\n") + } + + #[test] + fn unicode_characters() -> Result<()> { + run("add t100.md ❤️") + } + } + + mod get_commit_author { + use super::*; + + static NAME: &str = "carole"; + static EMAIL: &str = "carole@pm.me"; + + fn prep(time: &git2::Time) -> Result> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + fs::write(test_repo.dir.join("x1.md"), "some content")?; + let oid = test_repo.stage_and_commit_custom_signature( + "add x1.md", + Some(&git2::Signature::new(NAME, EMAIL, time)?), + None, + )?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.get_commit_author(&oid_to_sha1(&oid)) + } + + #[test] + fn name() -> Result<()> { + let res = prep(&git2::Time::new(5000, 0))?; + assert_eq!(NAME, res[0]); + Ok(()) + } + + #[test] + fn email() -> Result<()> { + let res = prep(&git2::Time::new(5000, 0))?; + assert_eq!(EMAIL, res[1]); + Ok(()) + } + + mod time { + use super::*; + + #[test] + fn no_offset() -> Result<()> { + let res = prep(&git2::Time::new(5000, 0))?; + assert_eq!("5000,0", res[2]); + Ok(()) + } + #[test] + fn positive_offset() -> Result<()> { + let res = prep(&git2::Time::new(5000, 300))?; + assert_eq!("5000,300", res[2]); + Ok(()) + } + #[test] + fn negative_offset() -> Result<()> { + let res = prep(&git2::Time::new(5000, -300))?; + assert_eq!("5000,-300", res[2]); + Ok(()) + } + } + + mod extract_sig_from_patch_tags { + use super::*; + + fn test(time: git2::Time) -> Result<()> { + assert_eq!( + extract_sig_from_patch_tags( + &[nostr::Tag::Generic( + nostr::TagKind::Custom("author".to_string()), + prep(&time)?, + )], + "author", + )? + .to_string(), + git2::Signature::new(NAME, EMAIL, &time)?.to_string(), + ); + Ok(()) + } + + #[test] + fn no_offset() -> Result<()> { + test(git2::Time::new(5000, 0)) + } + + #[test] + fn positive_offset() -> Result<()> { + test(git2::Time::new(5000, 300)) + } + + #[test] + fn negative_offset() -> Result<()> { + test(git2::Time::new(5000, -300)) + } + } + } + + mod get_commit_comitter { + use super::*; + + static NAME: &str = "carole"; + static EMAIL: &str = "carole@pm.me"; + + fn prep(time: &git2::Time) -> Result> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + fs::write(test_repo.dir.join("x1.md"), "some content")?; + let oid = test_repo.stage_and_commit_custom_signature( + "add x1.md", + None, + Some(&git2::Signature::new(NAME, EMAIL, time)?), + )?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.get_commit_comitter(&oid_to_sha1(&oid)) + } + + #[test] + fn name() -> Result<()> { + let res = prep(&git2::Time::new(5000, 0))?; + assert_eq!(NAME, res[0]); + Ok(()) + } + + #[test] + fn email() -> Result<()> { + let res = prep(&git2::Time::new(5000, 0))?; + assert_eq!(EMAIL, res[1]); + Ok(()) + } + } + mod does_commit_exist { use super::*; #[test] fn existing_commits_results_in_true() -> Result<()> { let test_repo = GitTestRepo::default(); - let oid = test_repo.populate()?; + test_repo.populate()?; let git_repo = Repo::from_path(&test_repo.dir)?; - assert!(git_repo.does_commit_exist(&"431b84edc0d2fa118d63faa3c2db9c73d630a5ae")?); + assert!(git_repo.does_commit_exist("431b84edc0d2fa118d63faa3c2db9c73d630a5ae")?); Ok(()) } @@ -325,10 +719,10 @@ mod tests { fn correctly_formatted_hash_that_doesnt_correspond_to_an_existing_commit_results_in_false() -> Result<()> { let test_repo = GitTestRepo::default(); - let oid = test_repo.populate()?; + test_repo.populate()?; let git_repo = Repo::from_path(&test_repo.dir)?; - assert!(!git_repo.does_commit_exist(&"000004edc0d2fa118d63faa3c2db9c73d630a5ae")?); + assert!(!git_repo.does_commit_exist("000004edc0d2fa118d63faa3c2db9c73d630a5ae")?); Ok(()) } @@ -336,10 +730,10 @@ mod tests { fn incorrectly_formatted_hash_that_doesnt_correspond_to_an_existing_commit_results_in_error() -> Result<()> { let test_repo = GitTestRepo::default(); - let oid = test_repo.populate()?; + test_repo.populate()?; let git_repo = Repo::from_path(&test_repo.dir)?; - assert!(!git_repo.does_commit_exist(&"00").is_err()); + assert!(git_repo.does_commit_exist("00").is_ok()); Ok(()) } } @@ -633,7 +1027,7 @@ mod tests { let branch_name = "test-name-1"; git_repo.create_branch_at_commit(branch_name, &ahead_1_oid.to_string())?; - assert!(test_repo.checkout(&branch_name).is_ok()); + assert!(test_repo.checkout(branch_name).is_ok()); Ok(()) } @@ -654,8 +1048,555 @@ mod tests { let branch_name = "test-name-1"; git_repo.create_branch_at_commit(branch_name, &ahead_1_oid.to_string())?; - assert_eq!(test_repo.checkout(&branch_name)?, ahead_1_oid); + assert_eq!(test_repo.checkout(branch_name)?, ahead_1_oid); + Ok(()) + } + + mod when_branch_already_exists { + use super::*; + + #[test] + fn when_new_tip_specified_it_is_updated() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + // create feature branch and add 2 commits + test_repo.create_branch("feature")?; + test_repo.checkout("feature")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + let ahead_1_oid = test_repo.stage_and_commit("add t3.md")?; + std::fs::write(test_repo.dir.join("t4.md"), "some content")?; + let ahead_2_oid = test_repo.stage_and_commit("add t4.md")?; + + let git_repo = Repo::from_path(&test_repo.dir)?; + + let branch_name = "test-name-1"; + git_repo.create_branch_at_commit(branch_name, &ahead_1_oid.to_string())?; + + git_repo.create_branch_at_commit(branch_name, &ahead_2_oid.to_string())?; + assert_eq!(test_repo.checkout(branch_name)?, ahead_2_oid); + Ok(()) + } + + #[test] + fn when_same_tip_is_specified_it_doesnt_error() -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + // create feature branch and add 2 commits + test_repo.create_branch("feature")?; + test_repo.checkout("feature")?; + std::fs::write(test_repo.dir.join("t3.md"), "some content")?; + let ahead_1_oid = 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 git_repo = Repo::from_path(&test_repo.dir)?; + + let branch_name = "test-name-1"; + git_repo.create_branch_at_commit(branch_name, &ahead_1_oid.to_string())?; + + git_repo.create_branch_at_commit(branch_name, &ahead_1_oid.to_string())?; + assert_eq!(test_repo.checkout(branch_name)?, ahead_1_oid); + Ok(()) + } + } + } + + mod apply_patch { + use test_utils::TEST_KEY_1_KEYS; + + use super::*; + use crate::sub_commands::prs::create::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(); + let git_repo = Repo::from_path(&test_repo.dir)?; + generate_patch_event( + &git_repo, + &git_repo.get_root_commit("main")?, + &oid_to_sha1(&original_oid), + nostr::EventId::all_zeros(), + &TEST_KEY_1_KEYS, + ) + } + fn test_patch_applies_to_repository(patch_event: nostr::Event) -> Result<()> { + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + println!("{:?}", &patch_event); + apply_patch(&git_repo, &patch_event)?; + let commit_id = tag_value(&patch_event, "commit")?; + // does commit with id exist? + assert!(git_repo.does_commit_exist(&commit_id)?); + // is commit head + assert_eq!( + test_repo + .git_repo + .head()? + .peel_to_commit()? + .id() + .to_string(), + commit_id, + ); + // applied to current checked branch (head hasn't moved to specific commit) + assert_eq!( + test_repo + .git_repo + .head()? + .shorthand() + .context("an object without a shorthand is checked out")? + .to_string(), + "main", + ); + Ok(()) } + + mod patch_created_as_commit_with_matching_id { + use test_utils::git::joe_signature; + + use super::*; + + #[test] + fn simple_signature_author_committer_same_as_git_user_0_unixtime_no_pgp_signature() + -> Result<()> { + let source_repo = GitTestRepo::default(); + source_repo.populate()?; + fs::write(source_repo.dir.join("x1.md"), "some content")?; + source_repo.stage_and_commit("add x1.md")?; + + test_patch_applies_to_repository(generate_patch_from_head_commit(&source_repo)?) + } + + #[test] + fn signature_with_specific_author_time() -> Result<()> { + let source_repo = GitTestRepo::default(); + source_repo.populate()?; + fs::write(source_repo.dir.join("x1.md"), "some content")?; + source_repo.stage_and_commit_custom_signature( + "add x1.md", + Some(&git2::Signature::new( + joe_signature().name().unwrap(), + joe_signature().email().unwrap(), + &git2::Time::new(5000, 0), + )?), + None, + )?; + + test_patch_applies_to_repository(generate_patch_from_head_commit(&source_repo)?) + } + + #[test] + fn author_name_and_email_not_current_git_user() -> Result<()> { + let source_repo = GitTestRepo::default(); + source_repo.populate()?; + fs::write(source_repo.dir.join("x1.md"), "some content")?; + source_repo.stage_and_commit_custom_signature( + "add x1.md", + Some(&git2::Signature::new( + "carole", + "carole@pm.me", + &git2::Time::new(0, 0), + )?), + None, + )?; + + test_patch_applies_to_repository(generate_patch_from_head_commit(&source_repo)?) + } + + #[test] + fn comiiter_name_and_email_not_current_git_user_or_author() -> Result<()> { + let source_repo = GitTestRepo::default(); + source_repo.populate()?; + fs::write(source_repo.dir.join("x1.md"), "some content")?; + source_repo.stage_and_commit_custom_signature( + "add x1.md", + Some(&git2::Signature::new( + "carole", + "carole@pm.me", + &git2::Time::new(0, 0), + )?), + Some(&git2::Signature::new( + "bob", + "bob@pm.me", + &git2::Time::new(0, 0), + )?), + )?; + + test_patch_applies_to_repository(generate_patch_from_head_commit(&source_repo)?) + } + + // TODO: pgp signature + + #[test] + fn unique_author_and_commiter_details() -> Result<()> { + let source_repo = GitTestRepo::default(); + source_repo.populate()?; + fs::write(source_repo.dir.join("x1.md"), "some content")?; + source_repo.stage_and_commit_custom_signature( + "add x1.md", + Some(&git2::Signature::new( + "carole", + "carole@pm.me", + &git2::Time::new(5000, 0), + )?), + Some(&git2::Signature::new( + "bob", + "bob@pm.me", + &git2::Time::new(1000, 0), + )?), + )?; + + test_patch_applies_to_repository(generate_patch_from_head_commit(&source_repo)?) + } + } + } + + mod apply_patch_chain { + use test_utils::TEST_KEY_1_KEYS; + + use super::*; + use crate::sub_commands::prs::create::generate_pr_and_patch_events; + + static BRANCH_NAME: &str = "add-example-feature"; + // returns original_repo, pr_event, patch_events + fn generate_test_repo_and_events() -> Result<(GitTestRepo, nostr::Event, Vec)> + { + let original_repo = GitTestRepo::default(); + let oid3 = original_repo.populate_with_test_branch()?; + let oid2 = original_repo.git_repo.find_commit(oid3)?.parent_id(0)?; + let oid1 = original_repo.git_repo.find_commit(oid2)?.parent_id(0)?; + // TODO: generate pr and patch events + let git_repo = Repo::from_path(&original_repo.dir)?; + + let mut events = generate_pr_and_patch_events( + "title", + "description", + BRANCH_NAME, + &git_repo, + &vec![oid_to_sha1(&oid1), oid_to_sha1(&oid2), oid_to_sha1(&oid3)], + &TEST_KEY_1_KEYS, + )?; + + events.reverse(); + + Ok((original_repo, events.pop().unwrap(), events)) + } + + mod when_branch_and_commits_dont_exist { + use super::*; + + mod when_branch_root_is_tip_of_main { + use super::*; + + #[test] + fn branch_gets_created_with_name_specified_in_pr() -> Result<()> { + let (_, _, patch_events) = generate_test_repo_and_events()?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert!( + git_repo + .get_local_branch_names()? + .contains(&BRANCH_NAME.to_string()) + ); + Ok(()) + } + + #[test] + fn branch_checked_out() -> Result<()> { + let (_, _, patch_events) = generate_test_repo_and_events()?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert_eq!( + git_repo.get_checked_out_branch_name()?, + BRANCH_NAME.to_string(), + ); + Ok(()) + } + + #[test] + fn patches_get_created_as_commits() -> Result<()> { + let (original_repo, _, patch_events) = generate_test_repo_and_events()?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert_eq!( + test_repo.git_repo.head()?.peel_to_commit()?.id(), + original_repo.git_repo.head()?.peel_to_commit()?.id(), + ); + Ok(()) + } + + #[test] + fn branch_tip_is_most_recent_patch() -> Result<()> { + let (original_repo, _, patch_events) = generate_test_repo_and_events()?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert_eq!( + git_repo.get_tip_of_local_branch(BRANCH_NAME)?, + oid_to_sha1(&original_repo.git_repo.head()?.peel_to_commit()?.id(),), + ); + Ok(()) + } + + #[test] + fn previously_checked_out_branch_tip_does_not_change() -> Result<()> { + let (_, _, patch_events) = generate_test_repo_and_events()?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let existing_branch = test_repo.get_checked_out_branch_name()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + let previous_tip_of_existing_branch = + git_repo.get_tip_of_local_branch(existing_branch.as_str())?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert_eq!( + previous_tip_of_existing_branch, + git_repo.get_tip_of_local_branch(existing_branch.as_str())?, + ); + Ok(()) + } + + #[test] + fn returns_all_patches_applied() -> Result<()> { + let (_, _, patch_events) = generate_test_repo_and_events()?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + let res = git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert_eq!(res.len(), 3); + Ok(()) + } + } + + mod when_branch_root_is_tip_behind_main { + use super::*; + + #[test] + fn branch_gets_created_with_name_specified_in_pr() -> Result<()> { + let (_, _, patch_events) = generate_test_repo_and_events()?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + std::fs::write(test_repo.dir.join("m3.md"), "some content")?; + test_repo.stage_and_commit("add m3.md")?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert!( + git_repo + .get_local_branch_names()? + .contains(&BRANCH_NAME.to_string()) + ); + Ok(()) + } + + #[test] + fn branch_checked_out() -> Result<()> { + let (_, _, patch_events) = generate_test_repo_and_events()?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + std::fs::write(test_repo.dir.join("m3.md"), "some content")?; + test_repo.stage_and_commit("add m3.md")?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert_eq!( + git_repo.get_checked_out_branch_name()?, + BRANCH_NAME.to_string(), + ); + Ok(()) + } + + #[test] + fn branch_tip_is_most_recent_patch() -> Result<()> { + let (original_repo, _, patch_events) = generate_test_repo_and_events()?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + std::fs::write(test_repo.dir.join("m3.md"), "some content")?; + test_repo.stage_and_commit("add m3.md")?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert_eq!( + git_repo.get_tip_of_local_branch(BRANCH_NAME)?, + oid_to_sha1(&original_repo.git_repo.head()?.peel_to_commit()?.id(),), + ); + Ok(()) + } + + #[test] + fn previously_checked_out_branch_tip_does_not_change() -> Result<()> { + let (_, _, patch_events) = generate_test_repo_and_events()?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + std::fs::write(test_repo.dir.join("m3.md"), "some content")?; + test_repo.stage_and_commit("add m3.md")?; + let existing_branch = test_repo.get_checked_out_branch_name()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + let previous_tip_of_existing_branch = + git_repo.get_tip_of_local_branch(existing_branch.as_str())?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert_eq!( + previous_tip_of_existing_branch, + git_repo.get_tip_of_local_branch(existing_branch.as_str())?, + ); + Ok(()) + } + + #[test] + fn returns_all_patches_applied() -> Result<()> { + let (_, _, patch_events) = generate_test_repo_and_events()?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + let res = git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert_eq!(res.len(), 3); + Ok(()) + } + } + + // TODO when_pr_root_is_tip_ahead_of_main_and_doesnt_exist + } + + mod when_branch_and_first_commits_exists { + use super::*; + + mod when_branch_already_checked_out { + use super::*; + + #[test] + fn branch_tip_is_most_recent_patch() -> Result<()> { + let (original_repo, _, mut patch_events) = generate_test_repo_and_events()?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, vec![patch_events.pop().unwrap()])?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + + assert_eq!( + git_repo.get_tip_of_local_branch(BRANCH_NAME)?, + oid_to_sha1(&original_repo.git_repo.head()?.peel_to_commit()?.id(),), + ); + Ok(()) + } + + #[test] + fn returns_all_patches_applied() -> Result<()> { + let (_, _, mut patch_events) = generate_test_repo_and_events()?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, vec![patch_events.pop().unwrap()])?; + let res = git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert_eq!(res.len(), 2); + Ok(()) + } + } + mod when_branch_not_checked_out { + use super::*; + + #[test] + fn branch_tip_is_most_recent_patch() -> Result<()> { + let (original_repo, _, mut patch_events) = generate_test_repo_and_events()?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, vec![patch_events.pop().unwrap()])?; + git_repo.checkout("main")?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + + assert_eq!( + git_repo.get_tip_of_local_branch(BRANCH_NAME)?, + oid_to_sha1(&original_repo.git_repo.head()?.peel_to_commit()?.id(),), + ); + Ok(()) + } + + #[test] + fn branch_checked_out() -> Result<()> { + let (_, _, mut patch_events) = generate_test_repo_and_events()?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, vec![patch_events.pop().unwrap()])?; + git_repo.checkout("main")?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + + assert_eq!( + git_repo.get_checked_out_branch_name()?, + BRANCH_NAME.to_string(), + ); + Ok(()) + } + + #[test] + fn returns_all_patches_applied() -> Result<()> { + let (_, _, mut patch_events) = generate_test_repo_and_events()?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, vec![patch_events.pop().unwrap()])?; + git_repo.checkout("main")?; + let res = git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert_eq!(res.len(), 2); + Ok(()) + } + } + // TODO when branch ahead (rebased or user commits) + } + mod when_branch_exists_and_is_up_to_date { + use super::*; + + mod when_branch_already_checked_out { + use super::*; + + #[test] + fn returns_all_patches_applied_0() -> Result<()> { + let (_, _, patch_events) = generate_test_repo_and_events()?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events.clone())?; + let res = git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert_eq!(res.len(), 0); + Ok(()) + } + } + mod when_branch_not_checked_out { + use super::*; + + #[test] + fn branch_checked_out() -> Result<()> { + let (_, _, patch_events) = generate_test_repo_and_events()?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events.clone())?; + git_repo.checkout("main")?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + + assert_eq!( + git_repo.get_checked_out_branch_name()?, + BRANCH_NAME.to_string(), + ); + Ok(()) + } + + #[test] + fn returns_all_patches_applied_0() -> Result<()> { + let (_, _, patch_events) = generate_test_repo_and_events()?; + let test_repo = GitTestRepo::default(); + test_repo.populate()?; + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.apply_patch_chain(BRANCH_NAME, patch_events.clone())?; + git_repo.checkout("main")?; + let res = git_repo.apply_patch_chain(BRANCH_NAME, patch_events)?; + assert_eq!(res.len(), 0); + Ok(()) + } + } + } } } diff --git a/src/sub_commands/prs/create.rs b/src/sub_commands/prs/create.rs index ce30c12..5c4e578 100644 --- a/src/sub_commands/prs/create.rs +++ b/src/sub_commands/prs/create.rs @@ -301,9 +301,9 @@ mod tests_unique_and_duplicate { pub static PR_KIND: u64 = 318; pub static PATCH_KIND: u64 = 317; -fn generate_pr_and_patch_events( - title: &String, - description: &String, +pub fn generate_pr_and_patch_events( + title: &str, + description: &str, to_branch: &str, git_repo: &Repo, commits: &Vec, @@ -314,6 +314,7 @@ fn generate_pr_and_patch_events( .context("failed to get root commit of the repository")?; let mut pr_tags = vec![ + Tag::Identifier(root_commit.to_string()), Tag::Reference(format!("r-{root_commit}")), Tag::Name(title.to_string()), Tag::Description(description.to_string()), @@ -341,42 +342,63 @@ fn generate_pr_and_patch_events( let mut events = vec![pr_event]; for commit in commits { - let commit_parent = git_repo - .get_commit_parent(commit) - .context("failed to create patch event")?; events.push( - EventBuilder::new( - nostr::event::Kind::Custom(PATCH_KIND), - git_repo - .make_patch_from_commit(commit) - .context(format!("cannot make patch for commit {commit}"))?, - &[ - Tag::Reference(format!("r-{root_commit}")), - Tag::Reference(commit.to_string()), - Tag::Reference(commit_parent.to_string()), - Tag::Event( - pr_event_id, - None, // TODO: add relay - Some(Marker::Root), - ), - Tag::Generic( - TagKind::Custom("commit".to_string()), - vec![commit.to_string()], - ), - Tag::Generic( - TagKind::Custom("parent-commit".to_string()), - vec![commit_parent.to_string()], - ), - // TODO: add Repo event tags - // TODO: people tag maintainers - // TODO: add relay tags - ], - ) - .to_event(keys)?, + generate_patch_event(git_repo, &root_commit, commit, pr_event_id, keys) + .context("failed to generate patch event")?, ); } Ok(events) } + +pub fn generate_patch_event( + git_repo: &Repo, + root_commit: &Sha1Hash, + commit: &Sha1Hash, + pr_event_id: nostr::EventId, + keys: &nostr::Keys, +) -> Result { + let commit_parent = git_repo + .get_commit_parent(commit) + .context("failed to get parent commit")?; + EventBuilder::new( + nostr::event::Kind::Custom(PATCH_KIND), + git_repo + .make_patch_from_commit(commit) + .context(format!("cannot make patch for commit {commit}"))?, + &[ + Tag::Reference(format!("r-{root_commit}")), + Tag::Reference(commit.to_string()), + Tag::Reference(commit_parent.to_string()), + Tag::Event( + pr_event_id, + None, // TODO: add relay + Some(Marker::Root), + ), + 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::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)?, + ), + // TODO: add Repo event tags + // TODO: people tag maintainers + // TODO: add relay tags + ], + ) + .to_event(keys) + .context("failed to sign event") +} // TODO // - find profile // - file relays diff --git a/src/sub_commands/prs/list.rs b/src/sub_commands/prs/list.rs new file mode 100644 index 0000000..13f29fd --- /dev/null +++ b/src/sub_commands/prs/list.rs @@ -0,0 +1,225 @@ +use anyhow::{Context, Result}; + +#[cfg(not(test))] +use crate::client::Client; +#[cfg(test)] +use crate::client::MockConnect; +use crate::{ + cli_interactor::{Interactor, InteractorPrompt, PromptChoiceParms}, + client::Connect, + git::{Repo, RepoActions}, + repo_ref, + sub_commands::prs::create::{PATCH_KIND, PR_KIND}, + Cli, +}; + +#[derive(Debug, clap::Args)] +pub struct SubCommandArgs { + /// TODO ignore merged, and closed + #[arg(long, action)] + open_only: bool, +} + +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 (main_or_master_branch_name, _) = git_repo + .get_main_or_master_branch() + .context("no main or master branch")?; + + let root_commit = git_repo + .get_root_commit(main_or_master_branch_name) + .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( + root_commit.to_string(), + &client, + client.get_more_fallback_relays().clone(), + ) + .await?; + + println!("finding PRs..."); + + let pr_events: Vec = client + .get_events( + repo_ref.relays.clone(), + vec![ + nostr::Filter::default() + .kind(nostr::Kind::Custom(PR_KIND)) + .reference(format!("r-{root_commit}")), + ], + ) + .await? + .iter() + .filter(|e| { + e.kind.as_u64() == PR_KIND + && e.tags + .iter() + .any(|t| t.as_vec().len() > 1 && t.as_vec()[1].eq(&format!("r-{root_commit}"))) + }) + .map(std::borrow::ToOwned::to_owned) + .collect(); + + // let pr_branch_names: Vec = pr_events + // .iter() + // .map(|e| { + // format!( + // "{}-{}", + // &e.id.to_string()[..5], + // if let Some(t) = e.tags.iter().find(|t| t.as_vec()[0] == + // "branch-name") { t.as_vec()[1].to_string() + // } else { + // "".to_string() + // } // git_repo.get_checked_out_branch_name(), + // ) + // }) + // .collect(); + + let selected_index = Interactor::default().choice( + PromptChoiceParms::default() + .with_prompt("All PRs") + .with_choices( + pr_events + .iter() + .map(|e| { + if let Ok(name) = tag_value(e, "name") { + name + } else { + e.id.to_string() + } + }) + .collect(), + ), + )?; + // println!("prs:{:?}", &pr_events); + + println!("finding commits..."); + + let commits_events: Vec = client + .get_events( + repo_ref.relays.clone(), + vec![ + nostr::Filter::default() + .kind(nostr::Kind::Custom(PATCH_KIND)) + .event(pr_events[selected_index].id) + .reference(format!("r-{root_commit}")), + ], + ) + .await? + .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_events[selected_index].id.to_string()) + }) + && e.tags + .iter() + .any(|t| t.as_vec().len() > 1 && t.as_vec()[1].eq(&format!("r-{root_commit}"))) + }) + .map(std::borrow::ToOwned::to_owned) + .collect(); + + // TODO: are there outstanding changes to prevent checking out a new branch? + + 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 = tag_value(&pr_events[selected_index], "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(), + ); + } + + // // TODO: look for mapping of existing branch + + // // if latest_commit_id exists locally + // if local_branch_base == latest_commit_id { + // // TODO: check if its in the main / master branch (already merged) + // // TODO: check if it has any decendants and warn. maybe the user has + // // been working on a updates to be pushed? Suggest checking + // // out that branch. + // // we could search nostr for decendants of the commit as well? + // // perhaps this is overkill + // // TODO: check out the branch which it is the tip of. if the name of the + // // branch is different then ask the user if they would like to + // // use the existing branch or create one with the name of the PR. + // // TODO: if there are no decendants and its not the tip then + // // its an ophan commit so just make a branch from this commit. + // } + // // if commits ahead exist in a branch other than main or master + // // TODO: Identify probable existing branches - check remote tracker? + // // TODO: beind head + // else { + // // TODO: look for existing branch with same name + // // TODO: create remote tracker + // git_repo.create_branch_at_commit(&branch_name, &local_branch_base); + // git_repo.checkout(&branch_name)?; + // ahead.reverse(); + // for event in ahead { + // git_repo.apply_patch(event, branch_name)?; + // } + // println!("applied!") + // } + // // TODO: check if commits in pr exist, if so look for branches with they are + // in // could we suggest pulling updates into that branch? + // // + + // TODO: checkout PR branch + 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 mut res = vec![]; + + let latest_commit_id = tag_value(patches.first().context("no patches found")?, "commit")?; + + let mut commit_id_to_search = latest_commit_id; + + while let Some(event) = patches.iter().find(|e| { + tag_value(e, "commit") + .context("patch event doesnt contain commit tag") + .unwrap() + .eq(&commit_id_to_search) + }) { + res.push(event.clone()); + commit_id_to_search = tag_value(event, "parent-commit")?; + } + Ok(res) +} diff --git a/src/sub_commands/prs/mod.rs b/src/sub_commands/prs/mod.rs index 982e866..a41c495 100644 --- a/src/sub_commands/prs/mod.rs +++ b/src/sub_commands/prs/mod.rs @@ -3,6 +3,7 @@ use clap::Subcommand; use crate::Cli; pub mod create; +pub mod list; #[derive(clap::Parser)] pub struct SubCommandArgs { @@ -13,10 +14,12 @@ pub struct SubCommandArgs { #[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/test_utils/src/git.rs b/test_utils/src/git.rs index 166693d..af87a3a 100644 --- a/test_utils/src/git.rs +++ b/test_utils/src/git.rs @@ -3,7 +3,7 @@ // implement drop? use std::{env::current_dir, fs, path::PathBuf}; -use anyhow::Result; +use anyhow::{Context, Result}; use git2::{Oid, RepositoryInitOptions, Signature, Time}; pub struct GitTestRepo { @@ -53,7 +53,27 @@ impl GitTestRepo { self.stage_and_commit("add t2.md") } + pub fn populate_with_test_branch(&self) -> Result { + self.populate()?; + self.create_branch("add-example-feature")?; + fs::write(self.dir.join("f1.md"), "some content")?; + self.stage_and_commit("add f1.md")?; + fs::write(self.dir.join("f2.md"), "some content")?; + self.stage_and_commit("add f2.md")?; + fs::write(self.dir.join("f3.md"), "some content1")?; + self.stage_and_commit("add f3.md") + } + pub fn stage_and_commit(&self, message: &str) -> Result { + self.stage_and_commit_custom_signature(message, None, None) + } + + pub fn stage_and_commit_custom_signature( + &self, + message: &str, + author: Option<&git2::Signature>, + commiter: Option<&git2::Signature>, + ) -> Result { let prev_oid = self.git_repo.head().unwrap().peel_to_commit()?; let mut index = self.git_repo.index()?; @@ -62,8 +82,8 @@ impl GitTestRepo { let oid = self.git_repo.commit( Some("HEAD"), - &joe_signature(), - &joe_signature(), + author.unwrap_or(&joe_signature()), + commiter.unwrap_or(&joe_signature()), message, &self.git_repo.find_tree(index.write_tree()?)?, &[&prev_oid], @@ -92,6 +112,40 @@ impl GitTestRepo { let oid = self.git_repo.head()?.peel_to_commit()?.id(); Ok(oid) } + + pub fn get_local_branch_names(&self) -> Result> { + let local_branches = self + .git_repo + .branches(Some(git2::BranchType::Local)) + .context("getting GitRepo branches should not error even for a blank repository")?; + + let mut branch_names = vec![]; + + for iter in local_branches { + let branch = iter?.0; + if let Some(name) = branch.name()? { + branch_names.push(name.to_string()); + } + } + Ok(branch_names) + } + + pub fn get_checked_out_branch_name(&self) -> Result { + Ok(self + .git_repo + .head()? + .shorthand() + .context("an object without a shorthand is checked out")? + .to_string()) + } + + pub fn get_tip_of_local_branch(&self, branch_name: &str) -> Result { + let branch = self + .git_repo + .find_branch(branch_name, git2::BranchType::Local) + .context(format!("cannot find branch {branch_name}"))?; + Ok(branch.into_reference().peel_to_commit()?.id()) + } } impl Drop for GitTestRepo { diff --git a/test_utils/src/lib.rs b/test_utils/src/lib.rs index e867a7b..fa80f1f 100644 --- a/test_utils/src/lib.rs +++ b/test_utils/src/lib.rs @@ -11,6 +11,10 @@ use strip_ansi_escapes::strip_str; pub mod git; pub mod relay; +pub static PR_KIND: u64 = 318; +pub static PATCH_KIND: u64 = 317; +pub static REPOSITORY_KIND: u64 = 300317; + pub static TEST_KEY_1_NSEC: &str = "nsec1ppsg5sm2aexq06juxmu9evtutr6jkwkhp98exxxvwamhru9lyx9s3rwseq"; pub static TEST_KEY_1_SK_HEX: &str = @@ -120,8 +124,6 @@ pub fn make_event_old_or_change_user( unsigned.sign(keys).unwrap() } -pub static REPOSITORY_KIND: u64 = 300317; - pub fn generate_repo_ref_event() -> nostr::Event { // taken from test git_repo let root_commit = "9ee507fc4357d7ee16a5d8901bedcd103f23c17d"; @@ -208,6 +210,20 @@ impl CliTester { i.prompt(true, default).context("initial confirm prompt")?; Ok(i) } + + pub fn expect_choice( + &mut self, + prompt: &str, + choices: Vec, + ) -> Result { + let mut i = CliTesterChoicePrompt { + tester: self, + prompt: prompt.to_string(), + choices, + }; + i.prompt(false).context("initial confirm prompt")?; + Ok(i) + } } pub struct CliTesterInputPrompt<'a> { @@ -397,6 +413,137 @@ impl CliTesterConfirmPrompt<'_> { } } +pub struct CliTesterChoicePrompt<'a> { + tester: &'a mut CliTester, + prompt: String, + choices: Vec, +} + +impl CliTesterChoicePrompt<'_> { + fn prompt(&mut self, eventually: bool) -> Result<&mut Self> { + let mut s = String::new(); + self.tester + .formatter + .format_select_prompt(&mut s, self.prompt.as_str()) + .expect("diagluer theme formatter should succeed"); + ensure!( + s.contains(self.prompt.as_str()), + "dialoguer must be broken as formatted prompt success doesnt contain prompt" + ); + + if eventually { + self.tester + .expect_eventually(sanatize(s).as_str()) + .context("expect input prompt eventually")?; + } else { + self.tester + .expect(sanatize(s).as_str()) + .context("expect confirm prompt")?; + } + + Ok(self) + } + + pub fn succeeds_with(&mut self, chosen_index: u64, report: bool) -> Result<&mut Self> { + fn show_options( + tester: &mut CliTester, + choices: &Vec, + selected_index: Option, + ) -> Result<()> { + if selected_index.is_some() { + for _ in 0..choices.len() { + tester.expect("\r").context("expect new line per choice")?; + } + } else { + tester + .expect("\r\n") + .context("expect new line before choices")?; + } + + for (index, item) in choices.iter().enumerate() { + let mut s = String::new(); + tester + .formatter + .format_select_prompt_item( + &mut s, + item.as_str(), + if let Some(i) = selected_index { + index == i + } else { + false + }, + ) + .expect("diagluer theme formatter should succeed"); + ensure!( + s.contains(item.as_str()), + "dialoguer must be broken as formatted prompt success doesnt contain prompt" + ); + tester.expect(sanatize(s)).context("expect choice item")?; + + tester + .expect(if choices.len() == index { + "\r\r" + } else { + "\r\n" + }) + .context("expect new line after choice item")?; + } + Ok(()) + } + fn show_selected( + tester: &mut CliTester, + prompt: &str, + choices: &[String], + selected_index: u64, + ) -> Result<()> { + let mut s = String::new(); + + let selected = choices[usize::try_from(selected_index)?].clone(); + tester + .formatter + .format_select_prompt_selection(&mut s, prompt, selected.as_str()) + .expect("diagluer theme formatter should succeed"); + ensure!( + s.contains(selected.as_str()), + "dialoguer must be broken as formatted prompt success doesnt contain prompt" + ); + tester.expect(sanatize(s)).context("expect choice item")?; + Ok(()) + } + + show_options(self.tester, &self.choices, None)?; + + for _ in 0..(chosen_index + 1) { + self.tester.send("j")?; + } + + self.tester.send(" ")?; + + for index in 0..(chosen_index + 1) { + show_options(self.tester, &self.choices, Some(usize::try_from(index)?))?; + } + + for _ in 0..self.choices.len() { + self.tester + .expect("\r") + .context("expect new line per option")?; + } + + self.tester + .expect("\r") + .context("expect new line after options")?; + + if report { + show_selected(self.tester, &self.prompt, &self.choices, chosen_index)?; + self.tester + .expect("\r\n") + .context("expect new line at end")?; + } + + Ok(self) + } +} + impl CliTester { pub fn new(args: I) -> Self where @@ -525,6 +672,16 @@ impl CliTester { Ok(()) } + pub fn expect_end_eventually_and_print(&mut self) -> Result<()> { + let before = self + .rexpect_session + .exp_eof() + .context("expected immediate end but got timed out")?; + println!("ended eventually with:"); + println!("{}", &before); + Ok(()) + } + pub fn expect_end_with_whitespace(&mut self) -> Result<()> { let before = self .rexpect_session @@ -551,6 +708,12 @@ impl CliTester { .context("send_line failed")?; Ok(()) } + + fn send(&mut self, s: &str) -> Result<()> { + self.rexpect_session.send(s).context("send failed")?; + self.rexpect_session.flush()?; + Ok(()) + } } /// sanatize unicode string for rexpect diff --git a/test_utils/src/relay.rs b/test_utils/src/relay.rs index 4ef34e6..50f6337 100644 --- a/test_utils/src/relay.rs +++ b/test_utils/src/relay.rs @@ -91,6 +91,27 @@ impl<'a> Relay<'a> { self.respond_eose(client_id, subscription_id.clone()) } + /// send collected events, filtered by filters, and eose + pub fn respond_standard_req( + &self, + client_id: u64, + subscription_id: &nostr::SubscriptionId, + filters: &[nostr::Filter], + ) -> Result { + // let t: Vec = self.events.iter().map(|e| e.kind).collect(); + // .filter(|e| filters.iter().any(|filter| filter.match_event(e))) + // println!("letsgo{:?}", t); + self.respond_events( + client_id, + subscription_id, + &self + .events + .iter() + .filter(|e| filters.iter().any(|filter| filter.match_event(e))) + .cloned() + .collect(), + ) + } /// listen, collect events and responds with event_listener to events or /// Ok(eventid) if event_listner is None pub async fn listen_until_close(&mut self) -> Result<()> { @@ -108,6 +129,8 @@ impl<'a> Relay<'a> { // break; } simple_websockets::Event::Message(client_id, message) => { + // println!("bla{:?}", &message); + println!( "{} Received a message from client #{}: {:?}", self.port, client_id, message @@ -118,8 +141,15 @@ impl<'a> Relay<'a> { break; } } + // println!("{:?}", &message); if let Ok(event) = get_nevent(&message) { + // println!("{:?}", &event); + // let t: Vec = self.events.iter().map(|e| e.kind).collect(); + // println!("before{:?}", t); self.events.push(event.clone()); + // let t: Vec = self.events.iter().map(|e| e.kind).collect(); + // println!("after{:?}", t); + if let Some(listner) = self.event_listener { listner(self, client_id, event)?; } else { @@ -132,7 +162,8 @@ impl<'a> Relay<'a> { if let Some(listner) = self.req_listener { listner(self, client_id, subscription_id, filters)?; } else { - self.respond_eose(client_id, subscription_id)?; + self.respond_standard_req(client_id, &subscription_id, &filters)?; + // self.respond_eose(client_id, subscription_id)?; } // respond with events // respond with EOSE diff --git a/tests/prs_create.rs b/tests/prs_create.rs index dc6eec0..d00bb9f 100644 --- a/tests/prs_create.rs +++ b/tests/prs_create.rs @@ -147,9 +147,6 @@ mod sends_pr_and_2_patches_to_3_relays { use super::*; - static PR_KIND: u64 = 318; - static PATCH_KIND: u64 = 317; - fn prep_git_repo() -> Result { let test_repo = GitTestRepo::default(); test_repo.populate()?; @@ -409,10 +406,33 @@ mod sends_pr_and_2_patches_to_3_relays { mod pr_tags { use super::*; + #[test] + #[serial] + fn pr_tags_repo_commit_as_identifier() -> Result<()> { + let (_, _, r53, r55, r56) = futures::executor::block_on(prep_run_create_pr())?; + let root_commit = GitTestRepo::default().initial_commit()?; + + for relay in [&r53, &r55, &r56] { + let pr_event: &nostr::Event = relay + .events + .iter() + .find(|e| e.kind.as_u64().eq(&PR_KIND)) + .unwrap(); + + // root commit identifier tag + assert!(pr_event.tags.iter().any( + |t| t.as_vec()[0].eq("d") && t.as_vec()[1].eq(&format!("{}", root_commit)) + )); + } + Ok(()) + } + #[test] #[serial] fn pr_tags_repo_commit() -> Result<()> { let (_, _, r53, r55, r56) = futures::executor::block_on(prep_run_create_pr())?; + let root_commit = GitTestRepo::default().initial_commit()?; + for relay in [&r53, &r55, &r56] { let pr_event: &nostr::Event = relay .events @@ -421,8 +441,10 @@ mod sends_pr_and_2_patches_to_3_relays { .unwrap(); // root commit 'r' tag - assert!(pr_event.tags.iter().any(|t| t.as_vec()[0].eq("r") - && t.as_vec()[1].eq("r-9ee507fc4357d7ee16a5d8901bedcd103f23c17d"))); + assert!( + pr_event.tags.iter().any(|t| t.as_vec()[0].eq("r") + && t.as_vec()[1].eq(&format!("r-{}", root_commit))) + ); } Ok(()) } @@ -503,50 +525,107 @@ mod sends_pr_and_2_patches_to_3_relays { mod patch_tags { use super::*; + + fn prep() -> Result { + let (_, _, r53, _, _) = futures::executor::block_on(prep_run_create_pr())?; + Ok(r53 + .events + .iter() + .find(|e| e.kind.as_u64().eq(&PATCH_KIND)) + .unwrap() + .clone()) + } + #[test] #[serial] - fn patch_tags_correctly_formatted() -> Result<()> { - let (_, _, r53, r55, r56) = futures::executor::block_on(prep_run_create_pr())?; - for relay in [&r53, &r55, &r56] { - let patch_events: Vec<&nostr::Event> = relay - .events + fn commit_and_commit_r() -> Result<()> { + static COMMIT_ID: &str = "fe973a840fba2a8ab37dd505c154854a69a6505c"; + let most_recent_patch = prep()?; + assert!( + most_recent_patch + .tags .iter() - .filter(|e| e.kind.as_u64().eq(&PATCH_KIND)) - .collect(); + .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(()) + } - static COMMIT_ID: &str = "fe973a840fba2a8ab37dd505c154854a69a6505c"; - let most_recent_patch = patch_events[0]; + #[test] + #[serial] + fn parent_commit_and_parent_commit_r() -> Result<()> { + // commit parent 'r' and 'parent-commit' tag + static COMMIT_PARENT_ID: &str = "232efb37ebc67692c9e9ff58b83c0d3d63971a0a"; + let most_recent_patch = prep()?; + assert!( + most_recent_patch + .tags + .iter() + .any(|t| t.as_vec()[0].eq("r") && t.as_vec()[1].eq(COMMIT_PARENT_ID)) + ); + assert!( + most_recent_patch.tags.iter().any( + |t| t.as_vec()[0].eq("parent-commit") && t.as_vec()[1].eq(COMMIT_PARENT_ID) + ) + ); + Ok(()) + } - // commit 'r' and 'commit' tag - 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)) - ); + #[test] + #[serial] + fn root_commit_as_r_with_r_hypen_prefix() -> Result<()> { + assert!(prep()?.tags.iter().any(|t| t.as_vec()[0].eq("r") + && t.as_vec()[1].eq("r-9ee507fc4357d7ee16a5d8901bedcd103f23c17d"))); + Ok(()) + } - // commit parent 't' and 'parent-commit' tag - static COMMIT_PARENT_ID: &str = "232efb37ebc67692c9e9ff58b83c0d3d63971a0a"; - assert!( - most_recent_patch - .tags - .iter() - .any(|t| t.as_vec()[0].eq("r") && t.as_vec()[1].eq(COMMIT_PARENT_ID)) - ); - assert!(most_recent_patch.tags.iter().any( - |t| t.as_vec()[0].eq("parent-commit") && t.as_vec()[1].eq(COMMIT_PARENT_ID) - )); + #[test] + #[serial] + fn description_with_commit_message() -> Result<()> { + assert_eq!( + prep()? + .tags + .iter() + .find(|t| t.as_vec()[0].eq("description")) + .unwrap() + .as_vec()[1], + "add t4.md" + ); + Ok(()) + } - // root commit 't' tag - assert!(most_recent_patch.tags.iter().any(|t| t.as_vec()[0].eq("r") - && t.as_vec()[1].eq("r-9ee507fc4357d7ee16a5d8901bedcd103f23c17d"))); - } + #[test] + #[serial] + fn commit_author() -> Result<()> { + assert_eq!( + prep()? + .tags + .iter() + .find(|t| t.as_vec()[0].eq("author")) + .unwrap() + .as_vec(), + vec!["author", "Joe Bloggs", "joe.bloggs@pm.me", "0,0"], + ); + Ok(()) + } + + #[test] + #[serial] + fn commit_committer() -> Result<()> { + assert_eq!( + prep()? + .tags + .iter() + .find(|t| t.as_vec()[0].eq("committer")) + .unwrap() + .as_vec(), + vec!["committer", "Joe Bloggs", "joe.bloggs@pm.me", "0,0"], + ); Ok(()) } diff --git a/tests/prs_list.rs b/tests/prs_list.rs new file mode 100644 index 0000000..7bc3935 --- /dev/null +++ b/tests/prs_list.rs @@ -0,0 +1,768 @@ +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 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", + PR_TITLE_1, + "pr a description", + )?; + cli_tester_create_pr( + &git_repo, + FEATURE_BRANCH_NAME_2, + "b", + PR_TITLE_2, + "pr b description", + )?; + cli_tester_create_pr( + &git_repo, + FEATURE_BRANCH_NAME_3, + "c", + 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, + title: &str, + description: &str, +) -> Result<()> { + create_and_populate_branch(test_repo, branch_name, prefix, false)?; + + 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()?; + 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)?; + + 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")?; + 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(()) + } + + #[test] + #[serial] + fn prompts_to_choose_from_pr_titles() -> Result<()> { + futures::executor::block_on(run_async_prompts_to_choose_from_pr_titles()) + } + } + + #[test] + #[serial] + fn pr_branch_created_with_correct_name() -> Result<()> { + let (_, test_repo) = futures::executor::block_on(prep_and_run())?; + assert_eq!( + vec![FEATURE_BRANCH_NAME_1, "main"], + test_repo.get_local_branch_names()? + ); + Ok(()) + } + + #[test] + #[serial] + fn pr_branch_checked_out() -> Result<()> { + let (_, test_repo) = futures::executor::block_on(prep_and_run())?; + assert_eq!( + FEATURE_BRANCH_NAME_1, + test_repo.get_checked_out_branch_name()?, + ); + Ok(()) + } + + #[test] + #[serial] + fn pr_branch_tip_is_most_recent_patch() -> Result<()> { + let (originating_repo, test_repo) = + futures::executor::block_on(prep_and_run())?; + 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)?; + + 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")?; + 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(()) + } + + #[test] + #[serial] + fn prompts_to_choose_from_pr_titles() -> Result<()> { + futures::executor::block_on(run_async_prompts_to_choose_from_pr_titles()) + } + } + + #[test] + #[serial] + fn pr_branch_created_with_correct_name() -> Result<()> { + let (_, test_repo) = futures::executor::block_on(prep_and_run())?; + assert_eq!( + vec![FEATURE_BRANCH_NAME_3, "main"], + test_repo.get_local_branch_names()? + ); + Ok(()) + } + + #[test] + #[serial] + fn pr_branch_checked_out() -> Result<()> { + let (_, test_repo) = futures::executor::block_on(prep_and_run())?; + assert_eq!( + FEATURE_BRANCH_NAME_3, + test_repo.get_checked_out_branch_name()?, + ); + Ok(()) + } + + #[test] + #[serial] + fn pr_branch_tip_is_most_recent_patch() -> Result<()> { + let (originating_repo, test_repo) = + futures::executor::block_on(prep_and_run())?; + 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_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)?; + 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")?; + 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(()) + } + + #[test] + #[serial] + fn prompts_to_choose_from_pr_titles() -> Result<()> { + futures::executor::block_on(run_async_prompts_to_choose_from_pr_titles()) + } + } + + #[test] + #[serial] + fn pr_branch_checked_out() -> Result<()> { + let (_, test_repo) = futures::executor::block_on(prep_and_run())?; + 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)?; + + 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")?; + 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(()) + } + + #[test] + #[serial] + fn prompts_to_choose_from_pr_titles() -> Result<()> { + futures::executor::block_on(run_async_prompts_to_choose_from_pr_titles()) + } + } + + #[test] + #[serial] + fn pr_branch_checked_out() -> Result<()> { + let (_, test_repo) = futures::executor::block_on(prep_and_run())?; + assert_eq!( + FEATURE_BRANCH_NAME_1, + test_repo.get_checked_out_branch_name()?, + ); + Ok(()) + } + + #[test] + #[serial] + fn pr_branch_tip_is_most_recent_patch() -> Result<()> { + let (originating_repo, test_repo) = + futures::executor::block_on(prep_and_run())?; + 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 + } + } + } +} -- cgit v1.2.3