From 6e9245542f070c39a1975f0d53d88913c4ac667d Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Sun, 1 Oct 2023 00:00:00 +0100 Subject: feat(prs-create) find commits and create events - identify commits - create pull request event - create patch events --- src/sub_commands/mod.rs | 1 + src/sub_commands/prs/create.rs | 371 +++++++++++++++++++++++++++++++++++++++++ src/sub_commands/prs/mod.rs | 22 +++ 3 files changed, 394 insertions(+) create mode 100644 src/sub_commands/prs/create.rs create mode 100644 src/sub_commands/prs/mod.rs (limited to 'src/sub_commands') diff --git a/src/sub_commands/mod.rs b/src/sub_commands/mod.rs index 320cbbb..3c3da1d 100644 --- a/src/sub_commands/mod.rs +++ b/src/sub_commands/mod.rs @@ -1 +1,2 @@ pub mod login; +pub mod prs; diff --git a/src/sub_commands/prs/create.rs b/src/sub_commands/prs/create.rs new file mode 100644 index 0000000..dd32c65 --- /dev/null +++ b/src/sub_commands/prs/create.rs @@ -0,0 +1,371 @@ +use anyhow::{bail, Context, Result}; +use nostr::{prelude::sha1::Hash as Sha1Hash, EventBuilder, Marker, Tag, TagKind}; + +use crate::{ + cli_interactor::{Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms}, + git::{Repo, RepoActions}, + login, Cli, +}; + +#[derive(Debug, clap::Args)] +pub struct SubCommandArgs { + #[clap(short, long)] + /// title of pull request (defaults to first line of first commit) + title: Option, + #[clap(short, long)] + /// optional 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, +} + +pub 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, 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 = match &args.title { + Some(t) => t.clone(), + None => Interactor::default().input(PromptInputParms::default().with_prompt("title"))?, + }; + + let description = match &args.description { + Some(t) => t.clone(), + None => Interactor::default() + .input(PromptInputParms::default().with_prompt("description (Optional)"))?, + }; + + let root_commit = git_repo + .get_root_commit(to_branch.as_str()) + .context("failed to get root commit of the repository")?; + // create PR event + + let keys = login::launch(&cli_args.nsec, &cli_args.password)?; + + let pr_event = EventBuilder::new( + nostr::event::Kind::Custom(318), + format!("{title}\r\n\r\n{description}"), + &[Tag::Hashtag(format!("r-{root_commit}"))], + // TODO: suggested branch name + // Tag::Generic( + // TagKind::Custom("suggested-branch-name".to_string()), + // vec![], + // ), + // TODO: add Repo event as root + // TODO: people tag maintainers + // TODO: add relay tags + ) + .to_event(&keys) + .context("failed to create pr event")?; + + let mut patch_events = vec![]; + for commit in &ahead { + let commit_parent = git_repo + .get_commit_parent(commit) + .context("failed to create patch event")?; + patch_events.push( + EventBuilder::new( + nostr::event::Kind::Custom(317), + git_repo + .make_patch_from_commit(commit) + .context(format!("cannot make patch for commit {commit}"))?, + &[ + Tag::Hashtag(format!("r-{root_commit}")), + Tag::Hashtag(commit.to_string()), + Tag::Hashtag(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), + ); + } + + // TODO check if there is already a similarly named PR + // TODO connect to relays and post + + Ok(()) +} + +// 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(()) + } + } +} diff --git a/src/sub_commands/prs/mod.rs b/src/sub_commands/prs/mod.rs new file mode 100644 index 0000000..c316e73 --- /dev/null +++ b/src/sub_commands/prs/mod.rs @@ -0,0 +1,22 @@ +use anyhow::Result; +use clap::Subcommand; + +use crate::Cli; +pub mod create; + +#[derive(clap::Parser)] +pub struct SubCommandArgs { + #[command(subcommand)] + pub prs_command: Commands, +} + +#[derive(Debug, Subcommand)] +pub enum Commands { + Create(create::SubCommandArgs), +} + +pub fn launch(cli_args: &Cli, pr_args: &SubCommandArgs) -> Result<()> { + match &pr_args.prs_command { + Commands::Create(args) => create::launch(cli_args, pr_args, args), + } +} -- cgit v1.2.3