From 771f944af447c202eba045936a36dee71ab797ac Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 4 Sep 2024 11:32:05 +0100 Subject: refactor: fix imports, etc based on restructure move some functions out of ngit and into lib/mod and lib/git_events remove MockConnect from binaries so it is only used in the library. this was done: * mainly because automocks were not being imported from lib into each binary * but also because the these functions were being tested with MockConnect --- src/bin/git_remote_nostr/main.rs | 307 +---------- src/bin/ngit/main.rs | 2 +- src/bin/ngit/sub_commands/fetch.rs | 9 +- src/bin/ngit/sub_commands/init.rs | 12 +- src/bin/ngit/sub_commands/list.rs | 216 +------- src/bin/ngit/sub_commands/login.rs | 14 +- src/bin/ngit/sub_commands/pull.rs | 24 +- src/bin/ngit/sub_commands/push.rs | 32 +- src/bin/ngit/sub_commands/send.rs | 969 +---------------------------------- src/lib/client.rs | 273 +++++++++- src/lib/git/identify_ahead_behind.rs | 196 +++++++ src/lib/git/mod.rs | 266 +--------- src/lib/git/nostr_url.rs | 501 ++++++++++++++++++ src/lib/git_events.rs | 692 +++++++++++++++++++++++++ src/lib/login/mod.rs | 7 +- src/lib/login/user.rs | 8 - src/lib/mod.rs | 30 +- src/lib/repo_ref.rs | 2 +- 18 files changed, 1759 insertions(+), 1801 deletions(-) create mode 100644 src/lib/git/identify_ahead_behind.rs create mode 100644 src/lib/git/nostr_url.rs create mode 100644 src/lib/git_events.rs (limited to 'src') diff --git a/src/bin/git_remote_nostr/main.rs b/src/bin/git_remote_nostr/main.rs index a5244bf..3e08efe 100644 --- a/src/bin/git_remote_nostr/main.rs +++ b/src/bin/git_remote_nostr/main.rs @@ -15,12 +15,19 @@ use std::{ use anyhow::{anyhow, bail, Context, Result}; use auth_git2::GitAuthenticator; use client::{ - consolidate_fetch_reports, get_events_from_cache, get_repo_ref_from_cache, - get_state_from_cache, sign_event, Connect, STATE_KIND, + consolidate_fetch_reports, get_all_proposal_patch_events_from_cache, get_events_from_cache, + get_proposals_and_revisions_from_cache, get_repo_ref_from_cache, get_state_from_cache, + send_events, sign_event, Connect, STATE_KIND, }; use console::Term; -use git::{sha1_to_oid, NostrUrlDecoded, RepoActions}; +use git::{nostr_url::NostrUrlDecoded, sha1_to_oid, RepoActions}; use git2::{Oid, Repository}; +use git_events::{ + event_is_revision_root, event_to_cover_letter, generate_cover_letter_and_patch_events, + generate_patch_event, get_commit_id_from_patch, get_most_recent_patch_with_ancestors, + status_kinds, tag_value, +}; +use ngit::{client, git, git_events, login, repo_ref, repo_state}; use nostr::nips::{nip01::Coordinate, nip10::Marker}; use nostr_sdk::{ hashes::sha1::Hash as Sha1Hash, Event, EventBuilder, EventId, Kind, PublicKey, Tag, Url, @@ -28,34 +35,8 @@ use nostr_sdk::{ use nostr_signer::NostrSigner; use repo_ref::RepoRef; use repo_state::RepoState; -use sub_commands::{ - list::{ - get_all_proposal_patch_events_from_cache, get_commit_id_from_patch, - get_most_recent_patch_with_ancestors, get_proposals_and_revisions_from_cache, status_kinds, - tag_value, - }, - send::{ - event_is_revision_root, event_to_cover_letter, generate_cover_letter_and_patch_events, - generate_patch_event, send_events, - }, -}; -#[cfg(not(test))] -use crate::client::Client; -#[cfg(test)] -use crate::client::MockConnect; -use crate::git::Repo; - -mod cli; -mod cli_interactor; -mod client; -mod config; -mod git; -mod key_handling; -mod login; -mod repo_ref; -mod repo_state; -mod sub_commands; +use crate::{client::Client, git::Repo}; #[tokio::main] async fn main() -> Result<()> { @@ -76,10 +57,7 @@ async fn main() -> Result<()> { ))?; let git_repo_path = git_repo.get_path()?; - #[cfg(not(test))] let client = Client::default(); - #[cfg(test)] - let client = ::default(); let decoded_nostr_url = NostrUrlDecoded::from_str(nostr_remote_url).context("invalid nostr url")?; @@ -155,8 +133,7 @@ pub(crate) fn read_line<'a>(stdin: &io::Stdin, line: &'a mut String) -> io::Resu async fn fetching_with_report_for_helper( git_repo_path: &Path, - #[cfg(test)] client: &crate::client::MockConnect, - #[cfg(not(test))] client: &Client, + client: &Client, repo_coordinates: &HashSet, ) -> Result<()> { let term = console::Term::stderr(); @@ -662,8 +639,7 @@ async fn push( nostr_remote_url: &str, stdin: &Stdin, initial_refspec: &str, - #[cfg(test)] client: &crate::client::MockConnect, - #[cfg(not(test))] client: &Client, + client: &Client, list_outputs: Option>>, ) -> Result<()> { let refspecs = get_refspecs_from_push_batch(stdin, initial_refspec)?; @@ -1613,8 +1589,15 @@ fn get_refspecs_from_push_batch(stdin: &Stdin, initial_refspec: &str) -> Result< Ok(refspecs) } -impl RepoState { - pub async fn build( +trait BuildRepoState { + async fn build( + identifier: String, + state: HashMap, + signer: &NostrSigner, + ) -> Result; +} +impl BuildRepoState for RepoState { + async fn build( identifier: String, state: HashMap, signer: &NostrSigner, @@ -1639,252 +1622,6 @@ impl RepoState { mod tests { use super::*; - mod nostr_git_url_paramemters_from_str { - use git::ServerProtocol; - use nostr_sdk::PublicKey; - - use super::*; - - fn get_model_coordinate(relays: bool) -> Coordinate { - Coordinate { - identifier: "ngit".to_string(), - public_key: PublicKey::parse( - "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr", - ) - .unwrap(), - kind: nostr_sdk::Kind::GitRepoAnnouncement, - relays: if relays { - vec!["wss://nos.lol/".to_string()] - } else { - vec![] - }, - } - } - - #[test] - fn from_naddr() -> Result<()> { - assert_eq!( - NostrUrlDecoded::from_str( - "nostr://naddr1qqzxuemfwsqs6amnwvaz7tmwdaejumr0dspzpgqgmmc409hm4xsdd74sf68a2uyf9pwel4g9mfdg8l5244t6x4jdqvzqqqrhnym0k2qj" - )?, - NostrUrlDecoded { - coordinates: HashSet::from([Coordinate { - identifier: "ngit".to_string(), - public_key: PublicKey::parse( - "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr", - ) - .unwrap(), - kind: nostr_sdk::Kind::GitRepoAnnouncement, - relays: vec!["wss://nos.lol".to_string()], // wont add the slash - }]), - protocol: None, - user: None, - }, - ); - Ok(()) - } - mod from_npub_slash_identifier { - use super::*; - - #[test] - fn without_relay() -> Result<()> { - assert_eq!( - NostrUrlDecoded::from_str( - "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit" - )?, - NostrUrlDecoded { - coordinates: HashSet::from([get_model_coordinate(false)]), - protocol: None, - user: None, - }, - ); - Ok(()) - } - - mod with_url_parameters { - - use super::*; - - #[test] - fn with_relay_without_scheme_defaults_to_wss() -> Result<()> { - assert_eq!( - NostrUrlDecoded::from_str( - "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?relay=nos.lol" - )?, - NostrUrlDecoded { - coordinates: HashSet::from([get_model_coordinate(true)]), - protocol: None, - user: None, - }, - ); - Ok(()) - } - - #[test] - fn with_encoded_relay() -> Result<()> { - assert_eq!( - NostrUrlDecoded::from_str(&format!( - "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?relay={}", - urlencoding::encode("wss://nos.lol") - ))?, - NostrUrlDecoded { - coordinates: HashSet::from([get_model_coordinate(true)]), - protocol: None, - user: None, - }, - ); - Ok(()) - } - #[test] - fn with_multiple_encoded_relays() -> Result<()> { - assert_eq!( - NostrUrlDecoded::from_str(&format!( - "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?relay={}&relay1={}", - urlencoding::encode("wss://nos.lol"), - urlencoding::encode("wss://relay.damus.io"), - ))?, - NostrUrlDecoded { - coordinates: HashSet::from([Coordinate { - identifier: "ngit".to_string(), - public_key: PublicKey::parse( - "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr", - ) - .unwrap(), - kind: nostr_sdk::Kind::GitRepoAnnouncement, - relays: vec![ - "wss://nos.lol/".to_string(), - "wss://relay.damus.io/".to_string(), - ], - }]), - protocol: None, - user: None, - }, - ); - Ok(()) - } - - #[test] - fn with_server_protocol() -> Result<()> { - assert_eq!( - NostrUrlDecoded::from_str( - "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?protocol=ssh" - )?, - NostrUrlDecoded { - coordinates: HashSet::from([get_model_coordinate(false)]), - protocol: Some(ServerProtocol::Ssh), - user: None, - }, - ); - Ok(()) - } - #[test] - fn with_server_protocol_and_user() -> Result<()> { - assert_eq!( - NostrUrlDecoded::from_str( - "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?protocol=ssh&user=fred" - )?, - NostrUrlDecoded { - coordinates: HashSet::from([get_model_coordinate(false)]), - protocol: Some(ServerProtocol::Ssh), - user: Some("fred".to_string()), - }, - ); - Ok(()) - } - } - mod with_parameters_embedded_with_slashes { - use super::*; - - #[test] - fn with_relay_without_scheme_defaults_to_wss() -> Result<()> { - assert_eq!( - NostrUrlDecoded::from_str( - "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/nos.lol/ngit" - )?, - NostrUrlDecoded { - coordinates: HashSet::from([get_model_coordinate(true)]), - protocol: None, - user: None, - }, - ); - Ok(()) - } - - #[test] - fn with_encoded_relay() -> Result<()> { - assert_eq!( - NostrUrlDecoded::from_str(&format!( - "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/{}/ngit", - urlencoding::encode("wss://nos.lol") - ))?, - NostrUrlDecoded { - coordinates: HashSet::from([get_model_coordinate(true)]), - protocol: None, - user: None, - }, - ); - Ok(()) - } - #[test] - fn with_multiple_encoded_relays() -> Result<()> { - assert_eq!( - NostrUrlDecoded::from_str(&format!( - "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/{}/{}/ngit", - urlencoding::encode("wss://nos.lol"), - urlencoding::encode("wss://relay.damus.io"), - ))?, - NostrUrlDecoded { - coordinates: HashSet::from([Coordinate { - identifier: "ngit".to_string(), - public_key: PublicKey::parse( - "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr", - ) - .unwrap(), - kind: nostr_sdk::Kind::GitRepoAnnouncement, - relays: vec![ - "wss://nos.lol/".to_string(), - "wss://relay.damus.io/".to_string(), - ], - }]), - protocol: None, - user: None, - }, - ); - Ok(()) - } - - #[test] - fn with_server_protocol() -> Result<()> { - assert_eq!( - NostrUrlDecoded::from_str( - "nostr://ssh/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit" - )?, - NostrUrlDecoded { - coordinates: HashSet::from([get_model_coordinate(false)]), - protocol: Some(ServerProtocol::Ssh), - user: None, - }, - ); - Ok(()) - } - #[test] - fn with_server_protocol_and_user() -> Result<()> { - assert_eq!( - NostrUrlDecoded::from_str( - "nostr://fred@ssh/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit" - )?, - NostrUrlDecoded { - coordinates: HashSet::from([get_model_coordinate(false)]), - protocol: Some(ServerProtocol::Ssh), - user: Some("fred".to_string()), - }, - ); - Ok(()) - } - } - } - } - mod refspec_to_from_to { use super::*; diff --git a/src/bin/ngit/main.rs b/src/bin/ngit/main.rs index 97e5981..45cbef5 100644 --- a/src/bin/ngit/main.rs +++ b/src/bin/ngit/main.rs @@ -7,7 +7,7 @@ use clap::Parser; use cli::{Cli, Commands}; mod cli; -use ngit::*; +use ngit::{cli_interactor, client, git, git_events, login, repo_ref}; mod sub_commands; diff --git a/src/bin/ngit/sub_commands/fetch.rs b/src/bin/ngit/sub_commands/fetch.rs index b1e83c5..c69f1c5 100644 --- a/src/bin/ngit/sub_commands/fetch.rs +++ b/src/bin/ngit/sub_commands/fetch.rs @@ -4,13 +4,9 @@ use anyhow::{Context, Result}; use clap; use nostr::nips::nip01::Coordinate; -#[cfg(not(test))] -use crate::client::Client; -#[cfg(test)] -use crate::client::MockConnect; use crate::{ cli::Cli, - client::{fetching_with_report, Connect}, + client::{fetching_with_report, Client, Connect}, git::{Repo, RepoActions}, repo_ref::get_repo_coordinates, }; @@ -25,10 +21,7 @@ pub struct SubCommandArgs { pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { let _ = args; let git_repo = Repo::discover().context("cannot find a git repository")?; - #[cfg(not(test))] let client = Client::default(); - #[cfg(test)] - let client = ::default(); let repo_coordinates = if command_args.repo.is_empty() { get_repo_coordinates(&git_repo, &client).await? } else { diff --git a/src/bin/ngit/sub_commands/init.rs b/src/bin/ngit/sub_commands/init.rs index 5b7e03d..f7e1ee0 100644 --- a/src/bin/ngit/sub_commands/init.rs +++ b/src/bin/ngit/sub_commands/init.rs @@ -4,16 +4,11 @@ use anyhow::{Context, Result}; use nostr::{nips::nip01::Coordinate, FromBech32, PublicKey, ToBech32}; use nostr_sdk::Kind; -use super::send::send_events; -#[cfg(not(test))] -use crate::client::Client; -#[cfg(test)] -use crate::client::MockConnect; use crate::{ cli::Cli, cli_interactor::{Interactor, InteractorPrompt, PromptInputParms}, - client::{fetching_with_report, get_repo_ref_from_cache, Connect}, - git::{convert_clone_url_to_https, Repo, RepoActions}, + client::{fetching_with_report, get_repo_ref_from_cache, send_events, Client, Connect}, + git::{nostr_url::convert_clone_url_to_https, Repo, RepoActions}, login, repo_ref::{ extract_pks, get_repo_config_from_yaml, save_repo_config_to_yaml, @@ -61,10 +56,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { // TODO: check for empty repo // TODO: check for existing maintaiers file - #[cfg(not(test))] let mut client = Client::default(); - #[cfg(test)] - let mut client = ::default(); let repo_coordinates = if let Ok(repo_coordinates) = try_and_get_repo_coordinates(&git_repo, &client, false).await diff --git a/src/bin/ngit/sub_commands/list.rs b/src/bin/ngit/sub_commands/list.rs index ac1f4ab..0755e3b 100644 --- a/src/bin/ngit/sub_commands/list.rs +++ b/src/bin/ngit/sub_commands/list.rs @@ -1,23 +1,25 @@ -use std::{collections::HashSet, io::Write, ops::Add, path::Path}; +use std::{io::Write, ops::Add}; use anyhow::{bail, Context, Result}; -use nostr::nips::nip01::Coordinate; -use nostr_sdk::{Kind, PublicKey}; - -use super::send::event_is_patch_set_root; -#[cfg(test)] -use crate::client::MockConnect; -#[cfg(not(test))] -use crate::client::{Client, Connect}; +use ngit::{ + client::{get_all_proposal_patch_events_from_cache, get_proposals_and_revisions_from_cache}, + git_events::{ + get_commit_id_from_patch, get_most_recent_patch_with_ancestors, status_kinds, tag_value, + }, +}; +use nostr_sdk::Kind; + use crate::{ cli_interactor::{Interactor, InteractorPrompt, PromptChoiceParms, PromptConfirmParms}, - client::{fetching_with_report, get_events_from_cache, get_repo_ref_from_cache}, + client::{ + fetching_with_report, get_events_from_cache, get_repo_ref_from_cache, Client, Connect, + }, git::{str_to_sha1, Repo, RepoActions}, - repo_ref::{get_repo_coordinates, RepoRef}, - sub_commands::send::{ - commit_msg_from_patch_oneliner, event_is_cover_letter, event_is_revision_root, - event_to_cover_letter, patch_supports_commit_ids, + git_events::{ + commit_msg_from_patch_oneliner, event_is_revision_root, event_to_cover_letter, + patch_supports_commit_ids, }, + repo_ref::get_repo_coordinates, }; #[allow(clippy::too_many_lines)] @@ -29,10 +31,7 @@ pub async fn launch() -> Result<()> { // 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_coordinates = get_repo_coordinates(&git_repo, &client).await?; @@ -721,186 +720,3 @@ fn check_clean(git_repo: &Repo) -> Result<()> { } 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_commit_id_from_patch(event: &nostr::Event) -> Result { - let value = tag_value(event, "commit"); - - if value.is_ok() { - value - } else if event.content.starts_with("From ") && event.content.len().gt(&45) { - Ok(event.content[5..45].to_string()) - } else { - bail!("event is not a patch") - } -} - -fn get_event_parent_id(event: &nostr::Event) -> Result { - Ok(if let Some(reply_tag) = event - .tags - .iter() - .find(|t| t.as_vec().len().gt(&3) && t.as_vec()[3].eq("reply")) - { - reply_tag - } else { - event - .tags - .iter() - .find(|t| t.as_vec().len().gt(&3) && t.as_vec()[3].eq("root")) - .context("no reply or root e tag present".to_string())? - } - .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 youngest_patch = patches.last().context("no patches found")?; - - let patches_with_youngest_created_at: Vec<&nostr::Event> = patches - .iter() - .filter(|p| p.created_at.eq(&youngest_patch.created_at)) - .collect(); - - let mut res = vec![]; - - let mut event_id_to_search = patches_with_youngest_created_at - .clone() - .iter() - .find(|p| { - !patches_with_youngest_created_at.iter().any(|p2| { - if let Ok(reply_to) = get_event_parent_id(p2) { - reply_to.eq(&p.id.to_string()) - } else { - false - } - }) - }) - .context("cannot find patches_with_youngest_created_at")? - .id - .to_string(); - - while let Some(event) = patches - .iter() - .find(|e| e.id.to_string().eq(&event_id_to_search)) - { - res.push(event.clone()); - if event_is_patch_set_root(event) { - break; - } - event_id_to_search = get_event_parent_id(event).unwrap_or_default(); - } - Ok(res) -} - -pub fn status_kinds() -> Vec { - vec![ - nostr::Kind::GitStatusOpen, - nostr::Kind::GitStatusApplied, - nostr::Kind::GitStatusClosed, - nostr::Kind::GitStatusDraft, - ] -} - -pub async fn get_proposals_and_revisions_from_cache( - git_repo_path: &Path, - repo_coordinates: HashSet, -) -> Result> { - let mut proposals = get_events_from_cache( - git_repo_path, - vec![ - nostr::Filter::default() - .kind(nostr::Kind::GitPatch) - .custom_tag( - nostr::SingleLetterTag::lowercase(nostr_sdk::Alphabet::A), - repo_coordinates - .iter() - .map(std::string::ToString::to_string) - .collect::>(), - ), - ], - ) - .await? - .iter() - .filter(|e| event_is_patch_set_root(e)) - .cloned() - .collect::>(); - proposals.sort_by_key(|e| e.created_at); - proposals.reverse(); - Ok(proposals) -} - -pub async fn get_all_proposal_patch_events_from_cache( - git_repo_path: &Path, - repo_ref: &RepoRef, - proposal_id: &nostr::EventId, -) -> Result> { - let mut commit_events = get_events_from_cache( - git_repo_path, - vec![ - nostr::Filter::default() - .kind(nostr::Kind::GitPatch) - .event(*proposal_id), - nostr::Filter::default() - .kind(nostr::Kind::GitPatch) - .id(*proposal_id), - ], - ) - .await?; - - let permissioned_users: HashSet = [ - repo_ref.maintainers.clone(), - vec![ - commit_events - .iter() - .find(|e| e.id().eq(proposal_id)) - .context("proposal not in cache")? - .author(), - ], - ] - .concat() - .iter() - .copied() - .collect(); - commit_events.retain(|e| permissioned_users.contains(&e.author())); - - let revision_roots: HashSet = commit_events - .iter() - .filter(|e| event_is_revision_root(e)) - .map(nostr::Event::id) - .collect(); - - if !revision_roots.is_empty() { - for event in get_events_from_cache( - git_repo_path, - vec![ - nostr::Filter::default() - .kind(nostr::Kind::GitPatch) - .events(revision_roots) - .authors(permissioned_users.clone()), - ], - ) - .await? - { - commit_events.push(event); - } - } - - Ok(commit_events - .iter() - .filter(|e| !event_is_cover_letter(e) && permissioned_users.contains(&e.author())) - .cloned() - .collect()) -} diff --git a/src/bin/ngit/sub_commands/login.rs b/src/bin/ngit/sub_commands/login.rs index 8a3788f..df7efa5 100644 --- a/src/bin/ngit/sub_commands/login.rs +++ b/src/bin/ngit/sub_commands/login.rs @@ -1,11 +1,12 @@ use anyhow::{Context, Result}; use clap; -#[cfg(not(test))] -use crate::client::Client; -#[cfg(test)] -use crate::client::MockConnect; -use crate::{cli::Cli, client::Connect, git::Repo, login}; +use crate::{ + cli::Cli, + client::{Client, Connect}, + git::Repo, + login, +}; #[derive(clap::Args)] pub struct SubCommandArgs { @@ -30,10 +31,7 @@ pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { .await?; Ok(()) } else { - #[cfg(not(test))] let client = Client::default(); - #[cfg(test)] - let client = ::default(); login::launch( &git_repo, diff --git a/src/bin/ngit/sub_commands/pull.rs b/src/bin/ngit/sub_commands/pull.rs index e33a744..b66422d 100644 --- a/src/bin/ngit/sub_commands/pull.rs +++ b/src/bin/ngit/sub_commands/pull.rs @@ -1,21 +1,16 @@ use anyhow::{bail, Context, Result}; -use super::{ - list::{ - get_all_proposal_patch_events_from_cache, get_commit_id_from_patch, - get_proposals_and_revisions_from_cache, tag_value, - }, - send::event_to_cover_letter, -}; -#[cfg(test)] -use crate::client::MockConnect; -#[cfg(not(test))] -use crate::client::{Client, Connect}; use crate::{ - client::{fetching_with_report, get_repo_ref_from_cache}, + client::{ + fetching_with_report, get_all_proposal_patch_events_from_cache, + get_proposals_and_revisions_from_cache, get_repo_ref_from_cache, Client, Connect, + }, git::{str_to_sha1, Repo, RepoActions}, + git_events::{ + event_is_revision_root, event_to_cover_letter, get_commit_id_from_patch, + get_most_recent_patch_with_ancestors, tag_value, + }, repo_ref::get_repo_coordinates, - sub_commands::{list::get_most_recent_patch_with_ancestors, send::event_is_revision_root}, }; #[allow(clippy::too_many_lines)] @@ -34,10 +29,7 @@ pub async fn launch() -> Result<()> { if branch_name == main_or_master_branch_name { bail!("checkout a branch associated with a proposal first") } - #[cfg(not(test))] let client = Client::default(); - #[cfg(test)] - let client = ::default(); let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?; diff --git a/src/bin/ngit/sub_commands/push.rs b/src/bin/ngit/sub_commands/push.rs index 7a82c7a..79065fc 100644 --- a/src/bin/ngit/sub_commands/push.rs +++ b/src/bin/ngit/sub_commands/push.rs @@ -1,27 +1,20 @@ use anyhow::{bail, Context, Result}; +use ngit::{client::send_events, git_events::tag_value}; -#[cfg(not(test))] -use crate::client::Client; -#[cfg(test)] -use crate::client::MockConnect; use crate::{ cli::Cli, - client::{fetching_with_report, get_repo_ref_from_cache, Connect}, - git::{str_to_sha1, Repo, RepoActions}, + client::{ + fetching_with_report, get_all_proposal_patch_events_from_cache, + get_proposals_and_revisions_from_cache, get_repo_ref_from_cache, Client, Connect, + }, + git::{identify_ahead_behind, str_to_sha1, Repo, RepoActions}, + git_events::{ + event_is_revision_root, event_to_cover_letter, generate_patch_event, + get_commit_id_from_patch, get_most_recent_patch_with_ancestors, + }, login, repo_ref::get_repo_coordinates, - sub_commands::{ - self, - list::{ - get_all_proposal_patch_events_from_cache, get_commit_id_from_patch, - get_most_recent_patch_with_ancestors, get_proposals_and_revisions_from_cache, - tag_value, - }, - send::{ - event_is_revision_root, event_to_cover_letter, generate_patch_event, - identify_ahead_behind, send_events, - }, - }, + sub_commands, }; #[derive(Debug, clap::Args)] @@ -51,10 +44,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { if branch_name == main_or_master_branch_name { bail!("checkout a branch associated with a proposal first") } - #[cfg(not(test))] let mut client = Client::default(); - #[cfg(test)] - let mut client = ::default(); let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?; diff --git a/src/bin/ngit/sub_commands/send.rs b/src/bin/ngit/sub_commands/send.rs index 3c4df9d..a807305 100644 --- a/src/bin/ngit/sub_commands/send.rs +++ b/src/bin/ngit/sub_commands/send.rs @@ -1,35 +1,26 @@ -use std::{path::Path, str::FromStr, time::Duration}; +use std::path::Path; use anyhow::{bail, Context, Result}; use console::Style; -use futures::future::join_all; -use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; +use ngit::{client::send_events, git_events::generate_cover_letter_and_patch_events}; use nostr::{ - nips::{ - nip01::Coordinate, - nip10::Marker, - nip19::{Nip19, Nip19Event}, - }, - EventBuilder, FromBech32, Tag, TagKind, ToBech32, UncheckedUrl, + nips::{nip10::Marker, nip19::Nip19Event}, + ToBech32, }; -use nostr_sdk::{hashes::sha1::Hash as Sha1Hash, Kind, NostrSigner, TagStandard}; +use nostr_sdk::hashes::sha1::Hash as Sha1Hash; -use super::list::tag_value; -#[cfg(not(test))] -use crate::client::Client; -#[cfg(test)] -use crate::client::MockConnect; use crate::{ cli::Cli, cli_interactor::{ Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptMultiChoiceParms, }, client::{ - fetching_with_report, get_events_from_cache, get_repo_ref_from_cache, sign_event, Connect, + fetching_with_report, get_events_from_cache, get_repo_ref_from_cache, Client, Connect, }, - git::{Repo, RepoActions}, + git::{identify_ahead_behind, Repo, RepoActions}, + git_events::{event_is_patch_set_root, event_tag_from_nip19_or_hex}, login, - repo_ref::{get_repo_coordinates, RepoRef}, + repo_ref::get_repo_coordinates, }; #[derive(Debug, clap::Args)] @@ -61,10 +52,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re .get_main_or_master_branch() .context("the default branches (main or master) do not exist")?; - #[cfg(not(test))] let mut client = Client::default(); - #[cfg(test)] - let mut client = ::default(); let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?; @@ -277,172 +265,6 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re Ok(()) } -#[allow(clippy::module_name_repetitions)] -#[allow(clippy::too_many_lines)] -pub async fn send_events( - #[cfg(test)] client: &crate::client::MockConnect, - #[cfg(not(test))] client: &Client, - git_repo_path: &Path, - events: Vec, - my_write_relays: Vec, - repo_read_relays: Vec, - animate: bool, - silent: bool, -) -> Result<()> { - let fallback = [ - client.get_fallback_relays().clone(), - if events - .iter() - .any(|e| e.kind().eq(&Kind::GitRepoAnnouncement)) - { - client.get_blaster_relays().clone() - } else { - vec![] - }, - ] - .concat(); - let mut relays: Vec<&String> = vec![]; - - let all = &[ - repo_read_relays.clone(), - my_write_relays.clone(), - fallback.clone(), - ] - .concat(); - // add duplicates first - for r in &repo_read_relays { - let r_clean = remove_trailing_slash(r); - if !my_write_relays - .iter() - .filter(|x| r_clean.eq(&remove_trailing_slash(x))) - .count() - > 1 - && !relays.iter().any(|x| r_clean.eq(&remove_trailing_slash(x))) - { - relays.push(r); - } - } - - for r in all { - let r_clean = remove_trailing_slash(r); - if !relays.iter().any(|x| r_clean.eq(&remove_trailing_slash(x))) { - relays.push(r); - } - } - - let m = if silent { - MultiProgress::with_draw_target(ProgressDrawTarget::hidden()) - } else { - 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() - })?; - - #[allow(clippy::borrow_deref_ref)] - join_all(relays.iter().map(|&relay| async { - let relay_clean = remove_trailing_slash(&*relay); - let details = format!( - "{}{}{} {}", - if my_write_relays - .iter() - .any(|r| relay_clean.eq(&remove_trailing_slash(r))) - { - " [my-relay]" - } else { - "" - }, - if repo_read_relays - .iter() - .any(|r| relay_clean.eq(&remove_trailing_slash(r))) - { - " [repo-relay]" - } else { - "" - }, - if fallback - .iter() - .any(|r| relay_clean.eq(&remove_trailing_slash(r))) - { - " [default]" - } else { - "" - }, - relay_clean, - ); - 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(git_repo_path, 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: ", "error: "), - ) - .for_stderr() - .red() - .to_string(), - ); - failed = true; - break; - } - }; - } - if !failed { - pb.set_style(pb_after_style_succeeded.clone()); - pb.finish_with_message(""); - } - })) - .await; - Ok(()) -} - -fn remove_trailing_slash(s: &String) -> String { - match s.as_str().strip_suffix('/') { - Some(s) => s, - None => s, - } - .to_string() -} - fn choose_commits(git_repo: &Repo, proposed_commits: Vec) -> Result> { let mut proposed_commits = if proposed_commits.len().gt(&10) { vec![] @@ -583,781 +405,8 @@ async fn get_root_proposal_id_and_mentions_from_in_reply_to( Ok((root_proposal_id, mention_tags)) } -#[allow(clippy::too_many_lines)] -pub async fn generate_cover_letter_and_patch_events( - cover_letter_title_description: Option<(String, String)>, - git_repo: &Repo, - commits: &[Sha1Hash], - signer: &NostrSigner, - repo_ref: &RepoRef, - root_proposal_id: &Option, - mentions: &[nostr::Tag], -) -> 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(sign_event(EventBuilder::new( - nostr::event::Kind::GitPatch, - format!( - "From {} Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/{}] {title}\n\n{description}", - commits.last().unwrap(), - commits.len() - ), - [ - repo_ref.maintainers.iter().map(|m| Tag::coordinate(Coordinate { - kind: nostr::Kind::GitRepoAnnouncement, - public_key: *m, - identifier: repo_ref.identifier.to_string(), - relays: repo_ref.relays.clone(), - })).collect::>(), - vec![ - Tag::from_standardized(TagStandard::Reference(format!("{root_commit}"))), - Tag::hashtag("cover-letter"), - Tag::custom( - nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")), - vec![format!("git patch cover letter: {}", title.clone())], - ), - ], - if let Some(event_ref) = root_proposal_id.clone() { - vec![ - Tag::hashtag("root"), - Tag::hashtag("revision-root"), - // TODO check if id is for a root proposal (perhaps its for an issue?) - event_tag_from_nip19_or_hex(&event_ref,"proposal",Marker::Reply, false, false)?, - ] - } else { - vec![ - Tag::hashtag("root"), - ] - }, - mentions.to_vec(), - // this is not strictly needed but makes for prettier branch names - // eventually a prefix will be needed of the event id to stop 2 proposals with the same name colliding - // a change like this, or the removal of this tag will require the actual branch name to be tracked - // so pulling and pushing still work - if let Ok(branch_name) = git_repo.get_checked_out_branch_name() { - if !branch_name.eq("main") - && !branch_name.eq("master") - && !branch_name.eq("origin/main") - && !branch_name.eq("origin/master") - { - vec![ - Tag::custom( - nostr::TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")), - vec![if let Some(branch_name) = branch_name.strip_prefix("pr/") { - branch_name.to_string() - } else { - branch_name - }], - ), - ] - } - else { vec![] } - } else { - vec![] - }, - repo_ref.maintainers - .iter() - .map(|pk| Tag::public_key(*pk)) - .collect(), - ].concat(), - ), signer).await - .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), - signer, - repo_ref, - events.last().map(nostr::Event::id), - if events.is_empty() && commits.len().eq(&1) { - 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() { - if !branch_name.eq("main") - && !branch_name.eq("master") - && !branch_name.eq("origin/main") - && !branch_name.eq("origin/master") - { - Some(if let Some(branch_name) = branch_name.strip_prefix("pr/") { - branch_name.to_string() - } else { - branch_name - }) - } else { - None - } - } else { - None - } - } else { - None - }, - root_proposal_id, - if events.is_empty() { mentions } else { &[] }, - ) - .await - .context("failed to generate patch event")?, - ); - } - Ok(events) -} - -fn event_tag_from_nip19_or_hex( - reference: &str, - reference_name: &str, - marker: Marker, - allow_npub_reference: bool, - prompt_for_correction: bool, -) -> Result { - let mut bech32 = reference.to_string(); - loop { - if bech32.is_empty() { - bech32 = Interactor::default().input( - PromptInputParms::default().with_prompt(&format!("{reference_name} reference")), - )?; - } - if let Ok(nip19) = Nip19::from_bech32(bech32.clone()) { - match nip19 { - Nip19::Event(n) => { - break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event { - event_id: n.event_id, - relay_url: n.relays.first().map(UncheckedUrl::new), - marker: Some(marker), - public_key: None, - })); - } - Nip19::EventId(id) => { - break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event { - event_id: id, - relay_url: None, - marker: Some(marker), - public_key: None, - })); - } - Nip19::Coordinate(coordinate) => { - break Ok(Tag::coordinate(coordinate)); - } - Nip19::Profile(profile) => { - if allow_npub_reference { - break Ok(Tag::public_key(profile.public_key)); - } - } - Nip19::Pubkey(public_key) => { - if allow_npub_reference { - break Ok(Tag::public_key(public_key)); - } - } - _ => {} - } - } - if let Ok(id) = nostr::EventId::from_str(&bech32) { - break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event { - event_id: id, - relay_url: None, - marker: Some(marker), - public_key: None, - })); - } - if prompt_for_correction { - println!("not a valid {reference_name} event reference"); - } else { - bail!(format!("not a valid {reference_name} event reference")); - } - - bech32 = String::new(); - } -} - -pub struct CoverLetter { - pub title: String, - pub description: String, - pub branch_name: String, - pub event_id: Option, -} - -impl CoverLetter { - pub fn get_branch_name(&self) -> Result { - Ok(format!( - "pr/{}({})", - self.branch_name, - &self - .event_id - .context("proposal root event_id must be know to get it's branch name")? - .to_hex() - .as_str()[..8], - )) - } -} -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.eq(&Kind::GitPatch) - && event.tags().iter().any(|t| t.as_vec()[1].eq("root")) - && event - .tags() - .iter() - .any(|t| t.as_vec()[1].eq("cover-letter")) -} - -pub fn commit_msg_from_patch(patch: &nostr::Event) -> Result { - if let Ok(msg) = tag_value(patch, "description") { - Ok(msg) - } else { - let start_index = patch - .content - .find("] ") - .context("event is not formatted as a patch or cover letter")? - + 2; - let end_index = patch.content[start_index..] - .find("\ndiff --git") - .unwrap_or(patch.content.len()); - Ok(patch.content[start_index..end_index].to_string()) - } -} - -pub fn commit_msg_from_patch_oneliner(patch: &nostr::Event) -> Result { - Ok(commit_msg_from_patch(patch)? - .split('\n') - .collect::>()[0] - .to_string()) -} - -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 = commit_msg_from_patch_oneliner(event)?; - let full = commit_msg_from_patch(event)?; - let description = full[title.len()..].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) = match tag_value(event, "branch-name") { - Ok(name) => { - if !name.eq("main") && !name.eq("master") { - Ok(name) - } else { - Err(()) - } - } - _ => Err(()), - } { - name - } else { - let s = title - .replace(' ', "-") - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c.eq(&'/') { - c - } else { - '-' - } - }) - .collect(); - s - }, - event_id: Some(event.id()), - }) -} - -pub fn event_is_patch_set_root(event: &nostr::Event) -> bool { - event.kind.eq(&Kind::GitPatch) && event.tags().iter().any(|t| t.as_vec()[1].eq("root")) -} - -pub fn event_is_revision_root(event: &nostr::Event) -> bool { - event.kind.eq(&Kind::GitPatch) - && event - .tags() - .iter() - .any(|t| t.as_vec()[1].eq("revision-root")) -} - -pub fn patch_supports_commit_ids(event: &nostr::Event) -> bool { - event.kind.eq(&Kind::GitPatch) - && event - .tags() - .iter() - .any(|t| t.as_vec()[0].eq("commit-pgp-sig")) -} - -#[allow(clippy::too_many_arguments)] -#[allow(clippy::too_many_lines)] -pub async fn generate_patch_event( - git_repo: &Repo, - root_commit: &Sha1Hash, - commit: &Sha1Hash, - thread_event_id: Option, - signer: &nostr_sdk::NostrSigner, - repo_ref: &RepoRef, - parent_patch_event_id: Option, - series_count: Option<(u64, u64)>, - branch_name: Option, - root_proposal_id: &Option, - mentions: &[nostr::Tag], -) -> 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); - - sign_event( - EventBuilder::new( - nostr::event::Kind::GitPatch, - git_repo - .make_patch_from_commit(commit, &series_count) - .context(format!("cannot make patch for commit {commit}"))?, - [ - repo_ref - .maintainers - .iter() - .map(|m| { - Tag::coordinate(Coordinate { - kind: nostr::Kind::GitRepoAnnouncement, - public_key: *m, - identifier: repo_ref.identifier.to_string(), - relays: repo_ref.relays.clone(), - }) - }) - .collect::>(), - vec![ - Tag::from_standardized(TagStandard::Reference(root_commit.to_string())), - // 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::from_standardized(TagStandard::Reference(commit.to_string())), - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("alt")), - vec![format!( - "git patch: {}", - git_repo - .get_commit_message_summary(commit) - .unwrap_or_default() - )], - ), - ], - if let Some(thread_event_id) = thread_event_id { - vec![Tag::from_standardized(nostr_sdk::TagStandard::Event { - event_id: thread_event_id, - relay_url: relay_hint.clone(), - marker: Some(Marker::Root), - public_key: None, - })] - } else if let Some(event_ref) = root_proposal_id.clone() { - vec![ - Tag::hashtag("root"), - Tag::hashtag("revision-root"), - // TODO check if id is for a root proposal (perhaps its for an issue?) - event_tag_from_nip19_or_hex( - &event_ref, - "proposal", - Marker::Reply, - false, - false, - )?, - ] - } else { - vec![Tag::hashtag("root")] - }, - mentions.to_vec(), - if let Some(id) = parent_patch_event_id { - vec![Tag::from_standardized(nostr_sdk::TagStandard::Event { - event_id: id, - relay_url: relay_hint.clone(), - marker: Some(Marker::Reply), - public_key: None, - })] - } else { - vec![] - }, - // see comment on branch names in cover letter event creation - if let Some(branch_name) = branch_name { - if thread_event_id.is_none() { - vec![Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")), - 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![ - // a fallback is now in place to extract this from the patch - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("commit")), - vec![commit.to_string()], - ), - // this is required as patches cannot be relied upon to include the 'base - // commit' - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("parent-commit")), - vec![commit_parent.to_string()], - ), - // this is required to ensure the commit id matches - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("commit-pgp-sig")), - vec![ - git_repo - .extract_commit_pgp_signature(commit) - .unwrap_or_default(), - ], - ), - // removing description tag will not cause anything to break - Tag::from_standardized(nostr_sdk::TagStandard::Description( - git_repo.get_commit_message(commit)?.to_string(), - )), - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("author")), - git_repo.get_commit_author(commit)?, - ), - // this is required to ensure the commit id matches - Tag::custom( - TagKind::Custom(std::borrow::Cow::Borrowed("committer")), - git_repo.get_commit_comitter(commit)?, - ), - ], - ] - .concat(), - ), - signer, - ) - .await - .context("failed to sign event") -} // TODO // - find profile // - file relays // - find repo events // - - -/** - * returns `(from_branch,to_branch,ahead,behind)` - */ -pub 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_branch(name) - .context(format!("cannot find from_branch '{name}'"))?, - ), - None => ( - if let Ok(name) = git_repo.get_checked_out_branch_name() { - name - } else { - "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_branch(name) - .context(format!("cannot find to_branch '{name}'"))?, - ), - None => { - let (name, commit) = git_repo - .get_main_or_master_branch() - .context("the default branches (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(), - "the default branches (main or master) do not exist", - ); - 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::GitPatch, - format!("From ea897e987ea9a7a98e7a987e97987ea98e7a3334 Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/2] {title}\n\n{description}"), - [ - Tag::hashtag("cover-letter"), - Tag::hashtag("root"), - ], - ) - .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/lib/client.rs b/src/lib/client.rs index abde217..ace880b 100644 --- a/src/lib/client.rs +++ b/src/lib/client.rs @@ -21,8 +21,11 @@ use std::{ use anyhow::{bail, Context, Result}; use async_trait::async_trait; use console::Style; -use futures::stream::{self, StreamExt}; -use indicatif::{MultiProgress, ProgressBar, ProgressState, ProgressStyle}; +use futures::{ + future::join_all, + stream::{self, StreamExt}, +}; +use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressState, ProgressStyle}; #[cfg(test)] use mockall::*; use nostr::{nips::nip01::Coordinate, Event}; @@ -34,14 +37,13 @@ use nostr_sdk::{ use nostr_sqlite::SQLiteDatabase; use crate::{ - config::get_dirs, + get_dirs, + git_events::{ + event_is_cover_letter, event_is_patch_set_root, event_is_revision_root, status_kinds, + }, login::{get_logged_in_user, get_user_ref_from_cache}, repo_ref::RepoRef, repo_state::RepoState, - sub_commands::{ - list::status_kinds, - send::{event_is_patch_set_root, event_is_revision_root}, - }, }; #[allow(clippy::struct_field_names)] @@ -1478,3 +1480,260 @@ pub async fn fetching_with_report( } Ok(report) } + +pub async fn get_proposals_and_revisions_from_cache( + git_repo_path: &Path, + repo_coordinates: HashSet, +) -> Result> { + let mut proposals = get_events_from_cache( + git_repo_path, + vec![ + nostr::Filter::default() + .kind(nostr::Kind::GitPatch) + .custom_tag( + nostr::SingleLetterTag::lowercase(nostr_sdk::Alphabet::A), + repo_coordinates + .iter() + .map(std::string::ToString::to_string) + .collect::>(), + ), + ], + ) + .await? + .iter() + .filter(|e| event_is_patch_set_root(e)) + .cloned() + .collect::>(); + proposals.sort_by_key(|e| e.created_at); + proposals.reverse(); + Ok(proposals) +} + +pub async fn get_all_proposal_patch_events_from_cache( + git_repo_path: &Path, + repo_ref: &RepoRef, + proposal_id: &nostr::EventId, +) -> Result> { + let mut commit_events = get_events_from_cache( + git_repo_path, + vec![ + nostr::Filter::default() + .kind(nostr::Kind::GitPatch) + .event(*proposal_id), + nostr::Filter::default() + .kind(nostr::Kind::GitPatch) + .id(*proposal_id), + ], + ) + .await?; + + let permissioned_users: HashSet = [ + repo_ref.maintainers.clone(), + vec![ + commit_events + .iter() + .find(|e| e.id().eq(proposal_id)) + .context("proposal not in cache")? + .author(), + ], + ] + .concat() + .iter() + .copied() + .collect(); + commit_events.retain(|e| permissioned_users.contains(&e.author())); + + let revision_roots: HashSet = commit_events + .iter() + .filter(|e| event_is_revision_root(e)) + .map(nostr::Event::id) + .collect(); + + if !revision_roots.is_empty() { + for event in get_events_from_cache( + git_repo_path, + vec![ + nostr::Filter::default() + .kind(nostr::Kind::GitPatch) + .events(revision_roots) + .authors(permissioned_users.clone()), + ], + ) + .await? + { + commit_events.push(event); + } + } + + Ok(commit_events + .iter() + .filter(|e| !event_is_cover_letter(e) && permissioned_users.contains(&e.author())) + .cloned() + .collect()) +} + +#[allow(clippy::module_name_repetitions)] +#[allow(clippy::too_many_lines)] +pub async fn send_events( + #[cfg(test)] client: &crate::client::MockConnect, + #[cfg(not(test))] client: &Client, + git_repo_path: &Path, + events: Vec, + my_write_relays: Vec, + repo_read_relays: Vec, + animate: bool, + silent: bool, +) -> Result<()> { + let fallback = [ + client.get_fallback_relays().clone(), + if events + .iter() + .any(|e| e.kind().eq(&Kind::GitRepoAnnouncement)) + { + client.get_blaster_relays().clone() + } else { + vec![] + }, + ] + .concat(); + let mut relays: Vec<&String> = vec![]; + + let all = &[ + repo_read_relays.clone(), + my_write_relays.clone(), + fallback.clone(), + ] + .concat(); + // add duplicates first + for r in &repo_read_relays { + let r_clean = remove_trailing_slash(r); + if !my_write_relays + .iter() + .filter(|x| r_clean.eq(&remove_trailing_slash(x))) + .count() + > 1 + && !relays.iter().any(|x| r_clean.eq(&remove_trailing_slash(x))) + { + relays.push(r); + } + } + + for r in all { + let r_clean = remove_trailing_slash(r); + if !relays.iter().any(|x| r_clean.eq(&remove_trailing_slash(x))) { + relays.push(r); + } + } + + let m = if silent { + MultiProgress::with_draw_target(ProgressDrawTarget::hidden()) + } else { + 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() + })?; + + #[allow(clippy::borrow_deref_ref)] + join_all(relays.iter().map(|&relay| async { + let relay_clean = remove_trailing_slash(&*relay); + let details = format!( + "{}{}{} {}", + if my_write_relays + .iter() + .any(|r| relay_clean.eq(&remove_trailing_slash(r))) + { + " [my-relay]" + } else { + "" + }, + if repo_read_relays + .iter() + .any(|r| relay_clean.eq(&remove_trailing_slash(r))) + { + " [repo-relay]" + } else { + "" + }, + if fallback + .iter() + .any(|r| relay_clean.eq(&remove_trailing_slash(r))) + { + " [default]" + } else { + "" + }, + relay_clean, + ); + 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(git_repo_path, 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: ", "error: "), + ) + .for_stderr() + .red() + .to_string(), + ); + failed = true; + break; + } + }; + } + if !failed { + pb.set_style(pb_after_style_succeeded.clone()); + pb.finish_with_message(""); + } + })) + .await; + Ok(()) +} + +fn remove_trailing_slash(s: &String) -> String { + match s.as_str().strip_suffix('/') { + Some(s) => s, + None => s, + } + .to_string() +} diff --git a/src/lib/git/identify_ahead_behind.rs b/src/lib/git/identify_ahead_behind.rs new file mode 100644 index 0000000..c98c994 --- /dev/null +++ b/src/lib/git/identify_ahead_behind.rs @@ -0,0 +1,196 @@ +use anyhow::{Context, Result}; +use nostr_sdk::hashes::sha1::Hash as Sha1Hash; + +use super::{Repo, RepoActions}; + +/** + * returns `(from_branch,to_branch,ahead,behind)` + */ +pub 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_branch(name) + .context(format!("cannot find from_branch '{name}'"))?, + ), + None => ( + if let Ok(name) = git_repo.get_checked_out_branch_name() { + name + } else { + "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_branch(name) + .context(format!("cannot find to_branch '{name}'"))?, + ), + None => { + let (name, commit) = git_repo + .get_main_or_master_branch() + .context("the default branches (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::*; + 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(), + "the default branches (main or master) do not exist", + ); + 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/lib/git/mod.rs b/src/lib/git/mod.rs index 5919667..f92272f 100644 --- a/src/lib/git/mod.rs +++ b/src/lib/git/mod.rs @@ -1,18 +1,16 @@ use std::{ - collections::HashSet, env::current_dir, path::{Path, PathBuf}, }; use anyhow::{bail, Context, Result}; use git2::{DiffOptions, Oid, Revwalk}; -use nostr::nips::nip01::Coordinate; -use nostr_sdk::{ - hashes::{sha1::Hash as Sha1Hash, Hash}, - PublicKey, Url, -}; +pub use identify_ahead_behind::identify_ahead_behind; +use nostr_sdk::hashes::{sha1::Hash as Sha1Hash, Hash}; -use crate::sub_commands::list::{get_commit_id_from_patch, tag_value}; +use crate::git_events::{get_commit_id_from_patch, tag_value}; +pub mod identify_ahead_behind; +pub mod nostr_url; pub struct Repo { pub git_repo: git2::Repository, @@ -835,188 +833,6 @@ fn extract_sig_from_patch_tags<'a>( .context("failed to create git signature") } -#[derive(Debug, PartialEq)] -pub enum ServerProtocol { - Ssh, - Https, - Http, - Git, -} - -#[derive(Debug, PartialEq)] -pub struct NostrUrlDecoded { - pub coordinates: HashSet, - pub protocol: Option, - pub user: Option, -} - -static INCORRECT_NOSTR_URL_FORMAT_ERROR: &str = "incorrect nostr git url format. try nostr://naddr123 or nostr://npub123/my-repo or nostr://ssh/npub123/relay.damus.io/my-repo"; - -impl NostrUrlDecoded { - pub fn from_str(url: &str) -> Result { - let mut coordinates = HashSet::new(); - let mut protocol = None; - let mut user = None; - let mut relays = vec![]; - - if !url.starts_with("nostr://") { - bail!("nostr git url must start with nostr://"); - } - // process get url parameters if present - for (name, value) in Url::parse(url)?.query_pairs() { - if name.contains("relay") { - let mut decoded = urlencoding::decode(&value) - .context("could not parse relays in nostr git url")? - .to_string(); - if !decoded.starts_with("ws://") && !decoded.starts_with("wss://") { - decoded = format!("wss://{decoded}"); - } - let url = - Url::parse(&decoded).context("could not parse relays in nostr git url")?; - relays.push(url.to_string()); - } else if name == "protocol" { - protocol = match value.as_ref() { - "ssh" => Some(ServerProtocol::Ssh), - "https" => Some(ServerProtocol::Https), - "http" => Some(ServerProtocol::Http), - "git" => Some(ServerProtocol::Git), - _ => None, - }; - } else if name == "user" { - user = Some(value.to_string()); - } - } - - let mut parts: Vec<&str> = url[8..] - .split('?') - .next() - .unwrap_or("") - .split('/') - .collect(); - - // extract optional protocol - if protocol.is_none() { - let part = parts.first().context(INCORRECT_NOSTR_URL_FORMAT_ERROR)?; - let protocol_str = if let Some(at_index) = part.find('@') { - user = Some(part[..at_index].to_string()); - &part[at_index + 1..] - } else { - part - }; - protocol = match protocol_str { - "ssh" => Some(ServerProtocol::Ssh), - "https" => Some(ServerProtocol::Https), - "http" => Some(ServerProtocol::Http), - "git" => Some(ServerProtocol::Git), - _ => protocol, - }; - if protocol.is_some() { - parts.remove(0); - } - } - // extract naddr npub//identifer - let part = parts.first().context(INCORRECT_NOSTR_URL_FORMAT_ERROR)?; - // naddr used - if let Ok(coordinate) = Coordinate::parse(part) { - if coordinate.kind.eq(&nostr_sdk::Kind::GitRepoAnnouncement) { - coordinates.insert(coordinate); - } else { - bail!("naddr doesnt point to a git repository announcement"); - } - // npub//identifer used - } else if let Ok(public_key) = PublicKey::parse(part) { - parts.remove(0); - let identifier = parts - .pop() - .context("nostr url must have an identifier eg. nostr://npub123/repo-identifier")? - .to_string(); - for relay in parts { - let mut decoded = urlencoding::decode(relay) - .context("could not parse relays in nostr git url")? - .to_string(); - if !decoded.starts_with("ws://") && !decoded.starts_with("wss://") { - decoded = format!("wss://{decoded}"); - } - let url = - Url::parse(&decoded).context("could not parse relays in nostr git url")?; - relays.push(url.to_string()); - } - coordinates.insert(Coordinate { - identifier, - public_key, - kind: nostr_sdk::Kind::GitRepoAnnouncement, - relays, - }); - } else { - bail!(INCORRECT_NOSTR_URL_FORMAT_ERROR); - } - - Ok(Self { - coordinates, - protocol, - user, - }) - } -} - -/** produce error when using local repo or custom protocols */ -pub fn convert_clone_url_to_https(url: &str) -> Result { - // Strip credentials if present - let stripped_url = strip_credentials(url); - - // Check if the URL is already in HTTPS format - if stripped_url.starts_with("https://") { - return Ok(stripped_url); - } - // Convert http:// to https:// - else if stripped_url.starts_with("http://") { - return Ok(stripped_url.replace("http://", "https://")); - } - // Check if the URL starts with SSH - else if stripped_url.starts_with("ssh://") { - // Convert SSH to HTTPS - let parts: Vec<&str> = stripped_url - .trim_start_matches("ssh://") - .split('/') - .collect(); - if parts.len() >= 2 { - // Construct the HTTPS URL - return Ok(format!("https://{}/{}", parts[0], parts[1..].join("/"))); - } - bail!("Invalid SSH URL format: {}", url); - } - // Convert ftp:// to https:// - else if stripped_url.starts_with("ftp://") { - return Ok(stripped_url.replace("ftp://", "https://")); - } - // Convert git:// to https:// - else if stripped_url.starts_with("git://") { - return Ok(stripped_url.replace("git://", "https://")); - } - - // If the URL is neither HTTPS, SSH, nor git@, return an error - bail!("Unsupported URL protocol: {}", url); -} - -// Function to strip username and password from the URL -fn strip_credentials(url: &str) -> String { - if let Some(pos) = url.find("://") { - let (protocol, rest) = url.split_at(pos + 3); // Split at "://" - let rest_parts: Vec<&str> = rest.split('@').collect(); - if rest_parts.len() > 1 { - // If there are credentials, return the URL without them - return format!("{}{}", protocol, rest_parts[1]); - } - } else if let Some(at_pos) = url.find('@') { - // Handle user@host:path format - let (_, rest) = url.split_at(at_pos); - // This is a git@ syntax - let host_and_repo = &rest[1..]; // Skip the ':' - return format!("ssh://{}", host_and_repo.replace(':', "/")); - } - url.to_string() // Return the original URL if no credentials are found -} - #[cfg(test)] mod tests { use std::fs; @@ -1813,7 +1629,7 @@ mod tests { use test_utils::TEST_KEY_1_SIGNER; use super::*; - use crate::{repo_ref::RepoRef, sub_commands::send::generate_patch_event}; + use crate::{git_events::generate_patch_event, repo_ref::RepoRef}; async fn generate_patch_from_head_commit(test_repo: &GitTestRepo) -> Result { let original_oid = test_repo.git_repo.head()?.peel_to_commit()?.id(); @@ -1959,9 +1775,7 @@ mod tests { use test_utils::TEST_KEY_1_SIGNER; use super::*; - use crate::{ - repo_ref::RepoRef, sub_commands::send::generate_cover_letter_and_patch_events, - }; + use crate::{git_events::generate_cover_letter_and_patch_events, repo_ref::RepoRef}; static BRANCH_NAME: &str = "add-example-feature"; // returns original_repo, cover_letter_event, patch_events @@ -2497,70 +2311,4 @@ mod tests { Ok(()) } } - mod convert_clone_url_to_https { - use super::*; - - #[test] - fn test_https_url() { - let url = "https://github.com/user/repo.git"; - let result = convert_clone_url_to_https(url).unwrap(); - assert_eq!(result, "https://github.com/user/repo.git"); - } - - #[test] - fn test_http_url() { - let url = "http://github.com/user/repo.git"; - let result = convert_clone_url_to_https(url).unwrap(); - assert_eq!(result, "https://github.com/user/repo.git"); - } - - #[test] - fn test_http_url_with_credentials() { - let url = "http://username:password@github.com/user/repo.git"; - let result = convert_clone_url_to_https(url).unwrap(); - assert_eq!(result, "https://github.com/user/repo.git"); - } - - #[test] - fn test_git_at_url() { - let url = "git@github.com:user/repo.git"; - let result = convert_clone_url_to_https(url).unwrap(); - assert_eq!(result, "https://github.com/user/repo.git"); - } - - #[test] - fn test_user_at_url() { - let url = "user1@github.com:user/repo.git"; - let result = convert_clone_url_to_https(url).unwrap(); - assert_eq!(result, "https://github.com/user/repo.git"); - } - - #[test] - fn test_ssh_url() { - let url = "ssh://github.com/user/repo.git"; - let result = convert_clone_url_to_https(url).unwrap(); - assert_eq!(result, "https://github.com/user/repo.git"); - } - - #[test] - fn test_ftp_url() { - let url = "ftp://example.com/repo.git"; - let result = convert_clone_url_to_https(url).unwrap(); - assert_eq!(result, "https://example.com/repo.git"); - } - - #[test] - fn test_git_protocol_url() { - let url = "git://example.com/repo.git"; - let result = convert_clone_url_to_https(url).unwrap(); - assert_eq!(result, "https://example.com/repo.git"); - } - - #[test] - fn test_invalid_url() { - let url = "unsupported://example.com/repo.git"; - let result = convert_clone_url_to_https(url); - assert!(result.is_err()); - } - } } diff --git a/src/lib/git/nostr_url.rs b/src/lib/git/nostr_url.rs new file mode 100644 index 0000000..ce3e973 --- /dev/null +++ b/src/lib/git/nostr_url.rs @@ -0,0 +1,501 @@ +use std::collections::HashSet; + +use anyhow::{bail, Context, Result}; +use nostr::nips::nip01::Coordinate; +use nostr_sdk::{PublicKey, Url}; + +#[derive(Debug, PartialEq)] +pub enum ServerProtocol { + Ssh, + Https, + Http, + Git, +} + +#[derive(Debug, PartialEq)] +pub struct NostrUrlDecoded { + pub coordinates: HashSet, + pub protocol: Option, + pub user: Option, +} + +static INCORRECT_NOSTR_URL_FORMAT_ERROR: &str = "incorrect nostr git url format. try nostr://naddr123 or nostr://npub123/my-repo or nostr://ssh/npub123/relay.damus.io/my-repo"; + +impl NostrUrlDecoded { + pub fn from_str(url: &str) -> Result { + let mut coordinates = HashSet::new(); + let mut protocol = None; + let mut user = None; + let mut relays = vec![]; + + if !url.starts_with("nostr://") { + bail!("nostr git url must start with nostr://"); + } + // process get url parameters if present + for (name, value) in Url::parse(url)?.query_pairs() { + if name.contains("relay") { + let mut decoded = urlencoding::decode(&value) + .context("could not parse relays in nostr git url")? + .to_string(); + if !decoded.starts_with("ws://") && !decoded.starts_with("wss://") { + decoded = format!("wss://{decoded}"); + } + let url = + Url::parse(&decoded).context("could not parse relays in nostr git url")?; + relays.push(url.to_string()); + } else if name == "protocol" { + protocol = match value.as_ref() { + "ssh" => Some(ServerProtocol::Ssh), + "https" => Some(ServerProtocol::Https), + "http" => Some(ServerProtocol::Http), + "git" => Some(ServerProtocol::Git), + _ => None, + }; + } else if name == "user" { + user = Some(value.to_string()); + } + } + + let mut parts: Vec<&str> = url[8..] + .split('?') + .next() + .unwrap_or("") + .split('/') + .collect(); + + // extract optional protocol + if protocol.is_none() { + let part = parts.first().context(INCORRECT_NOSTR_URL_FORMAT_ERROR)?; + let protocol_str = if let Some(at_index) = part.find('@') { + user = Some(part[..at_index].to_string()); + &part[at_index + 1..] + } else { + part + }; + protocol = match protocol_str { + "ssh" => Some(ServerProtocol::Ssh), + "https" => Some(ServerProtocol::Https), + "http" => Some(ServerProtocol::Http), + "git" => Some(ServerProtocol::Git), + _ => protocol, + }; + if protocol.is_some() { + parts.remove(0); + } + } + // extract naddr npub//identifer + let part = parts.first().context(INCORRECT_NOSTR_URL_FORMAT_ERROR)?; + // naddr used + if let Ok(coordinate) = Coordinate::parse(part) { + if coordinate.kind.eq(&nostr_sdk::Kind::GitRepoAnnouncement) { + coordinates.insert(coordinate); + } else { + bail!("naddr doesnt point to a git repository announcement"); + } + // npub//identifer used + } else if let Ok(public_key) = PublicKey::parse(part) { + parts.remove(0); + let identifier = parts + .pop() + .context("nostr url must have an identifier eg. nostr://npub123/repo-identifier")? + .to_string(); + for relay in parts { + let mut decoded = urlencoding::decode(relay) + .context("could not parse relays in nostr git url")? + .to_string(); + if !decoded.starts_with("ws://") && !decoded.starts_with("wss://") { + decoded = format!("wss://{decoded}"); + } + let url = + Url::parse(&decoded).context("could not parse relays in nostr git url")?; + relays.push(url.to_string()); + } + coordinates.insert(Coordinate { + identifier, + public_key, + kind: nostr_sdk::Kind::GitRepoAnnouncement, + relays, + }); + } else { + bail!(INCORRECT_NOSTR_URL_FORMAT_ERROR); + } + + Ok(Self { + coordinates, + protocol, + user, + }) + } +} + +/** produce error when using local repo or custom protocols */ +pub fn convert_clone_url_to_https(url: &str) -> Result { + // Strip credentials if present + let stripped_url = strip_credentials(url); + + // Check if the URL is already in HTTPS format + if stripped_url.starts_with("https://") { + return Ok(stripped_url); + } + // Convert http:// to https:// + else if stripped_url.starts_with("http://") { + return Ok(stripped_url.replace("http://", "https://")); + } + // Check if the URL starts with SSH + else if stripped_url.starts_with("ssh://") { + // Convert SSH to HTTPS + let parts: Vec<&str> = stripped_url + .trim_start_matches("ssh://") + .split('/') + .collect(); + if parts.len() >= 2 { + // Construct the HTTPS URL + return Ok(format!("https://{}/{}", parts[0], parts[1..].join("/"))); + } + bail!("Invalid SSH URL format: {}", url); + } + // Convert ftp:// to https:// + else if stripped_url.starts_with("ftp://") { + return Ok(stripped_url.replace("ftp://", "https://")); + } + // Convert git:// to https:// + else if stripped_url.starts_with("git://") { + return Ok(stripped_url.replace("git://", "https://")); + } + + // If the URL is neither HTTPS, SSH, nor git@, return an error + bail!("Unsupported URL protocol: {}", url); +} + +// Function to strip username and password from the URL +fn strip_credentials(url: &str) -> String { + if let Some(pos) = url.find("://") { + let (protocol, rest) = url.split_at(pos + 3); // Split at "://" + let rest_parts: Vec<&str> = rest.split('@').collect(); + if rest_parts.len() > 1 { + // If there are credentials, return the URL without them + return format!("{}{}", protocol, rest_parts[1]); + } + } else if let Some(at_pos) = url.find('@') { + // Handle user@host:path format + let (_, rest) = url.split_at(at_pos); + // This is a git@ syntax + let host_and_repo = &rest[1..]; // Skip the ':' + return format!("ssh://{}", host_and_repo.replace(':', "/")); + } + url.to_string() // Return the original URL if no credentials are found +} + +#[cfg(test)] +mod tests { + use super::*; + mod convert_clone_url_to_https { + use super::*; + + #[test] + fn test_https_url() { + let url = "https://github.com/user/repo.git"; + let result = convert_clone_url_to_https(url).unwrap(); + assert_eq!(result, "https://github.com/user/repo.git"); + } + + #[test] + fn test_http_url() { + let url = "http://github.com/user/repo.git"; + let result = convert_clone_url_to_https(url).unwrap(); + assert_eq!(result, "https://github.com/user/repo.git"); + } + + #[test] + fn test_http_url_with_credentials() { + let url = "http://username:password@github.com/user/repo.git"; + let result = convert_clone_url_to_https(url).unwrap(); + assert_eq!(result, "https://github.com/user/repo.git"); + } + + #[test] + fn test_git_at_url() { + let url = "git@github.com:user/repo.git"; + let result = convert_clone_url_to_https(url).unwrap(); + assert_eq!(result, "https://github.com/user/repo.git"); + } + + #[test] + fn test_user_at_url() { + let url = "user1@github.com:user/repo.git"; + let result = convert_clone_url_to_https(url).unwrap(); + assert_eq!(result, "https://github.com/user/repo.git"); + } + + #[test] + fn test_ssh_url() { + let url = "ssh://github.com/user/repo.git"; + let result = convert_clone_url_to_https(url).unwrap(); + assert_eq!(result, "https://github.com/user/repo.git"); + } + + #[test] + fn test_ftp_url() { + let url = "ftp://example.com/repo.git"; + let result = convert_clone_url_to_https(url).unwrap(); + assert_eq!(result, "https://example.com/repo.git"); + } + + #[test] + fn test_git_protocol_url() { + let url = "git://example.com/repo.git"; + let result = convert_clone_url_to_https(url).unwrap(); + assert_eq!(result, "https://example.com/repo.git"); + } + + #[test] + fn test_invalid_url() { + let url = "unsupported://example.com/repo.git"; + let result = convert_clone_url_to_https(url); + assert!(result.is_err()); + } + } + + mod nostr_git_url_paramemters_from_str { + use super::*; + + fn get_model_coordinate(relays: bool) -> Coordinate { + Coordinate { + identifier: "ngit".to_string(), + public_key: PublicKey::parse( + "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr", + ) + .unwrap(), + kind: nostr_sdk::Kind::GitRepoAnnouncement, + relays: if relays { + vec!["wss://nos.lol/".to_string()] + } else { + vec![] + }, + } + } + + #[test] + fn from_naddr() -> Result<()> { + assert_eq!( + NostrUrlDecoded::from_str( + "nostr://naddr1qqzxuemfwsqs6amnwvaz7tmwdaejumr0dspzpgqgmmc409hm4xsdd74sf68a2uyf9pwel4g9mfdg8l5244t6x4jdqvzqqqrhnym0k2qj" + )?, + NostrUrlDecoded { + coordinates: HashSet::from([Coordinate { + identifier: "ngit".to_string(), + public_key: PublicKey::parse( + "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr", + ) + .unwrap(), + kind: nostr_sdk::Kind::GitRepoAnnouncement, + relays: vec!["wss://nos.lol".to_string()], // wont add the slash + }]), + protocol: None, + user: None, + }, + ); + Ok(()) + } + mod from_npub_slash_identifier { + use super::*; + + #[test] + fn without_relay() -> Result<()> { + assert_eq!( + NostrUrlDecoded::from_str( + "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit" + )?, + NostrUrlDecoded { + coordinates: HashSet::from([get_model_coordinate(false)]), + protocol: None, + user: None, + }, + ); + Ok(()) + } + + mod with_url_parameters { + + use super::*; + + #[test] + fn with_relay_without_scheme_defaults_to_wss() -> Result<()> { + assert_eq!( + NostrUrlDecoded::from_str( + "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?relay=nos.lol" + )?, + NostrUrlDecoded { + coordinates: HashSet::from([get_model_coordinate(true)]), + protocol: None, + user: None, + }, + ); + Ok(()) + } + + #[test] + fn with_encoded_relay() -> Result<()> { + assert_eq!( + NostrUrlDecoded::from_str(&format!( + "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?relay={}", + urlencoding::encode("wss://nos.lol") + ))?, + NostrUrlDecoded { + coordinates: HashSet::from([get_model_coordinate(true)]), + protocol: None, + user: None, + }, + ); + Ok(()) + } + #[test] + fn with_multiple_encoded_relays() -> Result<()> { + assert_eq!( + NostrUrlDecoded::from_str(&format!( + "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?relay={}&relay1={}", + urlencoding::encode("wss://nos.lol"), + urlencoding::encode("wss://relay.damus.io"), + ))?, + NostrUrlDecoded { + coordinates: HashSet::from([Coordinate { + identifier: "ngit".to_string(), + public_key: PublicKey::parse( + "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr", + ) + .unwrap(), + kind: nostr_sdk::Kind::GitRepoAnnouncement, + relays: vec![ + "wss://nos.lol/".to_string(), + "wss://relay.damus.io/".to_string(), + ], + }]), + protocol: None, + user: None, + }, + ); + Ok(()) + } + + #[test] + fn with_server_protocol() -> Result<()> { + assert_eq!( + NostrUrlDecoded::from_str( + "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?protocol=ssh" + )?, + NostrUrlDecoded { + coordinates: HashSet::from([get_model_coordinate(false)]), + protocol: Some(ServerProtocol::Ssh), + user: None, + }, + ); + Ok(()) + } + #[test] + fn with_server_protocol_and_user() -> Result<()> { + assert_eq!( + NostrUrlDecoded::from_str( + "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?protocol=ssh&user=fred" + )?, + NostrUrlDecoded { + coordinates: HashSet::from([get_model_coordinate(false)]), + protocol: Some(ServerProtocol::Ssh), + user: Some("fred".to_string()), + }, + ); + Ok(()) + } + } + mod with_parameters_embedded_with_slashes { + use super::*; + + #[test] + fn with_relay_without_scheme_defaults_to_wss() -> Result<()> { + assert_eq!( + NostrUrlDecoded::from_str( + "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/nos.lol/ngit" + )?, + NostrUrlDecoded { + coordinates: HashSet::from([get_model_coordinate(true)]), + protocol: None, + user: None, + }, + ); + Ok(()) + } + + #[test] + fn with_encoded_relay() -> Result<()> { + assert_eq!( + NostrUrlDecoded::from_str(&format!( + "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/{}/ngit", + urlencoding::encode("wss://nos.lol") + ))?, + NostrUrlDecoded { + coordinates: HashSet::from([get_model_coordinate(true)]), + protocol: None, + user: None, + }, + ); + Ok(()) + } + #[test] + fn with_multiple_encoded_relays() -> Result<()> { + assert_eq!( + NostrUrlDecoded::from_str(&format!( + "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/{}/{}/ngit", + urlencoding::encode("wss://nos.lol"), + urlencoding::encode("wss://relay.damus.io"), + ))?, + NostrUrlDecoded { + coordinates: HashSet::from([Coordinate { + identifier: "ngit".to_string(), + public_key: PublicKey::parse( + "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr", + ) + .unwrap(), + kind: nostr_sdk::Kind::GitRepoAnnouncement, + relays: vec![ + "wss://nos.lol/".to_string(), + "wss://relay.damus.io/".to_string(), + ], + }]), + protocol: None, + user: None, + }, + ); + Ok(()) + } + + #[test] + fn with_server_protocol() -> Result<()> { + assert_eq!( + NostrUrlDecoded::from_str( + "nostr://ssh/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit" + )?, + NostrUrlDecoded { + coordinates: HashSet::from([get_model_coordinate(false)]), + protocol: Some(ServerProtocol::Ssh), + user: None, + }, + ); + Ok(()) + } + #[test] + fn with_server_protocol_and_user() -> Result<()> { + assert_eq!( + NostrUrlDecoded::from_str( + "nostr://fred@ssh/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit" + )?, + NostrUrlDecoded { + coordinates: HashSet::from([get_model_coordinate(false)]), + protocol: Some(ServerProtocol::Ssh), + user: Some("fred".to_string()), + }, + ); + Ok(()) + } + } + } + } +} diff --git a/src/lib/git_events.rs b/src/lib/git_events.rs new file mode 100644 index 0000000..8689b33 --- /dev/null +++ b/src/lib/git_events.rs @@ -0,0 +1,692 @@ +use std::str::FromStr; + +use anyhow::{bail, Context, Result}; +use nostr::nips::{nip01::Coordinate, nip10::Marker, nip19::Nip19}; +use nostr_sdk::{ + hashes::sha1::Hash as Sha1Hash, Event, EventBuilder, FromBech32, Kind, Tag, TagKind, + TagStandard, UncheckedUrl, +}; +use nostr_signer::NostrSigner; + +use crate::{ + cli_interactor::{Interactor, InteractorPrompt, PromptInputParms}, + client::sign_event, + git::{Repo, RepoActions}, + repo_ref::RepoRef, +}; + +pub fn tag_value(event: &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_commit_id_from_patch(event: &Event) -> Result { + let value = tag_value(event, "commit"); + + if value.is_ok() { + value + } else if event.content.starts_with("From ") && event.content.len().gt(&45) { + Ok(event.content[5..45].to_string()) + } else { + bail!("event is not a patch") + } +} + +pub fn status_kinds() -> Vec { + vec![ + Kind::GitStatusOpen, + Kind::GitStatusApplied, + Kind::GitStatusClosed, + Kind::GitStatusDraft, + ] +} + +pub fn event_is_patch_set_root(event: &Event) -> bool { + event.kind.eq(&Kind::GitPatch) && event.tags().iter().any(|t| t.as_vec()[1].eq("root")) +} + +pub fn event_is_revision_root(event: &Event) -> bool { + event.kind.eq(&Kind::GitPatch) + && event + .tags() + .iter() + .any(|t| t.as_vec()[1].eq("revision-root")) +} + +pub fn patch_supports_commit_ids(event: &Event) -> bool { + event.kind.eq(&Kind::GitPatch) + && event + .tags() + .iter() + .any(|t| t.as_vec()[0].eq("commit-pgp-sig")) +} + +#[allow(clippy::too_many_arguments)] +#[allow(clippy::too_many_lines)] +pub async fn generate_patch_event( + git_repo: &Repo, + root_commit: &Sha1Hash, + commit: &Sha1Hash, + thread_event_id: Option, + signer: &nostr_sdk::NostrSigner, + repo_ref: &RepoRef, + parent_patch_event_id: Option, + series_count: Option<(u64, u64)>, + branch_name: Option, + root_proposal_id: &Option, + mentions: &[nostr::Tag], +) -> 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); + + sign_event( + EventBuilder::new( + nostr::event::Kind::GitPatch, + git_repo + .make_patch_from_commit(commit, &series_count) + .context(format!("cannot make patch for commit {commit}"))?, + [ + repo_ref + .maintainers + .iter() + .map(|m| { + Tag::coordinate(Coordinate { + kind: nostr::Kind::GitRepoAnnouncement, + public_key: *m, + identifier: repo_ref.identifier.to_string(), + relays: repo_ref.relays.clone(), + }) + }) + .collect::>(), + vec![ + Tag::from_standardized(TagStandard::Reference(root_commit.to_string())), + // 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::from_standardized(TagStandard::Reference(commit.to_string())), + Tag::custom( + TagKind::Custom(std::borrow::Cow::Borrowed("alt")), + vec![format!( + "git patch: {}", + git_repo + .get_commit_message_summary(commit) + .unwrap_or_default() + )], + ), + ], + if let Some(thread_event_id) = thread_event_id { + vec![Tag::from_standardized(nostr_sdk::TagStandard::Event { + event_id: thread_event_id, + relay_url: relay_hint.clone(), + marker: Some(Marker::Root), + public_key: None, + })] + } else if let Some(event_ref) = root_proposal_id.clone() { + vec![ + Tag::hashtag("root"), + Tag::hashtag("revision-root"), + // TODO check if id is for a root proposal (perhaps its for an issue?) + event_tag_from_nip19_or_hex( + &event_ref, + "proposal", + Marker::Reply, + false, + false, + )?, + ] + } else { + vec![Tag::hashtag("root")] + }, + mentions.to_vec(), + if let Some(id) = parent_patch_event_id { + vec![Tag::from_standardized(nostr_sdk::TagStandard::Event { + event_id: id, + relay_url: relay_hint.clone(), + marker: Some(Marker::Reply), + public_key: None, + })] + } else { + vec![] + }, + // see comment on branch names in cover letter event creation + if let Some(branch_name) = branch_name { + if thread_event_id.is_none() { + vec![Tag::custom( + TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")), + 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![ + // a fallback is now in place to extract this from the patch + Tag::custom( + TagKind::Custom(std::borrow::Cow::Borrowed("commit")), + vec![commit.to_string()], + ), + // this is required as patches cannot be relied upon to include the 'base + // commit' + Tag::custom( + TagKind::Custom(std::borrow::Cow::Borrowed("parent-commit")), + vec![commit_parent.to_string()], + ), + // this is required to ensure the commit id matches + Tag::custom( + TagKind::Custom(std::borrow::Cow::Borrowed("commit-pgp-sig")), + vec![ + git_repo + .extract_commit_pgp_signature(commit) + .unwrap_or_default(), + ], + ), + // removing description tag will not cause anything to break + Tag::from_standardized(nostr_sdk::TagStandard::Description( + git_repo.get_commit_message(commit)?.to_string(), + )), + Tag::custom( + TagKind::Custom(std::borrow::Cow::Borrowed("author")), + git_repo.get_commit_author(commit)?, + ), + // this is required to ensure the commit id matches + Tag::custom( + TagKind::Custom(std::borrow::Cow::Borrowed("committer")), + git_repo.get_commit_comitter(commit)?, + ), + ], + ] + .concat(), + ), + signer, + ) + .await + .context("failed to sign event") +} + +pub fn event_tag_from_nip19_or_hex( + reference: &str, + reference_name: &str, + marker: Marker, + allow_npub_reference: bool, + prompt_for_correction: bool, +) -> Result { + let mut bech32 = reference.to_string(); + loop { + if bech32.is_empty() { + bech32 = Interactor::default().input( + PromptInputParms::default().with_prompt(&format!("{reference_name} reference")), + )?; + } + if let Ok(nip19) = Nip19::from_bech32(bech32.clone()) { + match nip19 { + Nip19::Event(n) => { + break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event { + event_id: n.event_id, + relay_url: n.relays.first().map(UncheckedUrl::new), + marker: Some(marker), + public_key: None, + })); + } + Nip19::EventId(id) => { + break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event { + event_id: id, + relay_url: None, + marker: Some(marker), + public_key: None, + })); + } + Nip19::Coordinate(coordinate) => { + break Ok(Tag::coordinate(coordinate)); + } + Nip19::Profile(profile) => { + if allow_npub_reference { + break Ok(Tag::public_key(profile.public_key)); + } + } + Nip19::Pubkey(public_key) => { + if allow_npub_reference { + break Ok(Tag::public_key(public_key)); + } + } + _ => {} + } + } + if let Ok(id) = nostr::EventId::from_str(&bech32) { + break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event { + event_id: id, + relay_url: None, + marker: Some(marker), + public_key: None, + })); + } + if prompt_for_correction { + println!("not a valid {reference_name} event reference"); + } else { + bail!(format!("not a valid {reference_name} event reference")); + } + + bech32 = String::new(); + } +} + +#[allow(clippy::too_many_lines)] +pub async fn generate_cover_letter_and_patch_events( + cover_letter_title_description: Option<(String, String)>, + git_repo: &Repo, + commits: &[Sha1Hash], + signer: &NostrSigner, + repo_ref: &RepoRef, + root_proposal_id: &Option, + mentions: &[nostr::Tag], +) -> 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(sign_event(EventBuilder::new( + nostr::event::Kind::GitPatch, + format!( + "From {} Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/{}] {title}\n\n{description}", + commits.last().unwrap(), + commits.len() + ), + [ + repo_ref.maintainers.iter().map(|m| Tag::coordinate(Coordinate { + kind: nostr::Kind::GitRepoAnnouncement, + public_key: *m, + identifier: repo_ref.identifier.to_string(), + relays: repo_ref.relays.clone(), + })).collect::>(), + vec![ + Tag::from_standardized(TagStandard::Reference(format!("{root_commit}"))), + Tag::hashtag("cover-letter"), + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")), + vec![format!("git patch cover letter: {}", title.clone())], + ), + ], + if let Some(event_ref) = root_proposal_id.clone() { + vec![ + Tag::hashtag("root"), + Tag::hashtag("revision-root"), + // TODO check if id is for a root proposal (perhaps its for an issue?) + event_tag_from_nip19_or_hex(&event_ref,"proposal",Marker::Reply, false, false)?, + ] + } else { + vec![ + Tag::hashtag("root"), + ] + }, + mentions.to_vec(), + // this is not strictly needed but makes for prettier branch names + // eventually a prefix will be needed of the event id to stop 2 proposals with the same name colliding + // a change like this, or the removal of this tag will require the actual branch name to be tracked + // so pulling and pushing still work + if let Ok(branch_name) = git_repo.get_checked_out_branch_name() { + if !branch_name.eq("main") + && !branch_name.eq("master") + && !branch_name.eq("origin/main") + && !branch_name.eq("origin/master") + { + vec![ + Tag::custom( + nostr::TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")), + vec![if let Some(branch_name) = branch_name.strip_prefix("pr/") { + branch_name.to_string() + } else { + branch_name + }], + ), + ] + } + else { vec![] } + } else { + vec![] + }, + repo_ref.maintainers + .iter() + .map(|pk| Tag::public_key(*pk)) + .collect(), + ].concat(), + ), signer).await + .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), + signer, + repo_ref, + events.last().map(nostr::Event::id), + if events.is_empty() && commits.len().eq(&1) { + 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() { + if !branch_name.eq("main") + && !branch_name.eq("master") + && !branch_name.eq("origin/main") + && !branch_name.eq("origin/master") + { + Some(if let Some(branch_name) = branch_name.strip_prefix("pr/") { + branch_name.to_string() + } else { + branch_name + }) + } else { + None + } + } else { + None + } + } else { + None + }, + root_proposal_id, + if events.is_empty() { mentions } else { &[] }, + ) + .await + .context("failed to generate patch event")?, + ); + } + Ok(events) +} + +pub struct CoverLetter { + pub title: String, + pub description: String, + pub branch_name: String, + pub event_id: Option, +} + +impl CoverLetter { + pub fn get_branch_name(&self) -> Result { + Ok(format!( + "pr/{}({})", + self.branch_name, + &self + .event_id + .context("proposal root event_id must be know to get it's branch name")? + .to_hex() + .as_str()[..8], + )) + } +} +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.eq(&Kind::GitPatch) + && event.tags().iter().any(|t| t.as_vec()[1].eq("root")) + && event + .tags() + .iter() + .any(|t| t.as_vec()[1].eq("cover-letter")) +} + +pub fn commit_msg_from_patch(patch: &nostr::Event) -> Result { + if let Ok(msg) = tag_value(patch, "description") { + Ok(msg) + } else { + let start_index = patch + .content + .find("] ") + .context("event is not formatted as a patch or cover letter")? + + 2; + let end_index = patch.content[start_index..] + .find("\ndiff --git") + .unwrap_or(patch.content.len()); + Ok(patch.content[start_index..end_index].to_string()) + } +} + +pub fn commit_msg_from_patch_oneliner(patch: &nostr::Event) -> Result { + Ok(commit_msg_from_patch(patch)? + .split('\n') + .collect::>()[0] + .to_string()) +} + +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 = commit_msg_from_patch_oneliner(event)?; + let full = commit_msg_from_patch(event)?; + let description = full[title.len()..].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) = match tag_value(event, "branch-name") { + Ok(name) => { + if !name.eq("main") && !name.eq("master") { + Ok(name) + } else { + Err(()) + } + } + _ => Err(()), + } { + name + } else { + let s = title + .replace(' ', "-") + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c.eq(&'/') { + c + } else { + '-' + } + }) + .collect(); + s + }, + event_id: Some(event.id()), + }) +} + +pub fn get_most_recent_patch_with_ancestors( + mut patches: Vec, +) -> Result> { + patches.sort_by_key(|e| e.created_at); + + let youngest_patch = patches.last().context("no patches found")?; + + let patches_with_youngest_created_at: Vec<&nostr::Event> = patches + .iter() + .filter(|p| p.created_at.eq(&youngest_patch.created_at)) + .collect(); + + let mut res = vec![]; + + let mut event_id_to_search = patches_with_youngest_created_at + .clone() + .iter() + .find(|p| { + !patches_with_youngest_created_at.iter().any(|p2| { + if let Ok(reply_to) = get_event_parent_id(p2) { + reply_to.eq(&p.id.to_string()) + } else { + false + } + }) + }) + .context("cannot find patches_with_youngest_created_at")? + .id + .to_string(); + + while let Some(event) = patches + .iter() + .find(|e| e.id.to_string().eq(&event_id_to_search)) + { + res.push(event.clone()); + if event_is_patch_set_root(event) { + break; + } + event_id_to_search = get_event_parent_id(event).unwrap_or_default(); + } + Ok(res) +} + +fn get_event_parent_id(event: &nostr::Event) -> Result { + Ok(if let Some(reply_tag) = event + .tags + .iter() + .find(|t| t.as_vec().len().gt(&3) && t.as_vec()[3].eq("reply")) + { + reply_tag + } else { + event + .tags + .iter() + .find(|t| t.as_vec().len().gt(&3) && t.as_vec()[3].eq("root")) + .context("no reply or root e tag present".to_string())? + } + .as_vec()[1] + .clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + + mod event_to_cover_letter { + use super::*; + + fn generate_cover_letter(title: &str, description: &str) -> Result { + Ok(nostr::event::EventBuilder::new( + nostr::event::Kind::GitPatch, + format!("From ea897e987ea9a7a98e7a987e97987ea98e7a3334 Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/2] {title}\n\n{description}"), + [ + Tag::hashtag("cover-letter"), + Tag::hashtag("root"), + ], + ) + .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/lib/login/mod.rs b/src/lib/login/mod.rs index 19bb97c..7364edf 100644 --- a/src/lib/login/mod.rs +++ b/src/lib/login/mod.rs @@ -19,11 +19,14 @@ use crate::{ Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptPasswordParms, }, client::{fetch_public_key, get_event_from_global_cache, Connect}, - config::{UserMetadata, UserRef, UserRelayRef, UserRelays}, git::{Repo, RepoActions}, - key_handling::encryption::{decrypt_key, encrypt_key}, }; +mod key_encryption; +use key_encryption::{decrypt_key, encrypt_key}; +mod user; +use user::{UserMetadata, UserRef, UserRelayRef, UserRelays}; + /// handles the encrpytion and storage of key material #[allow(clippy::too_many_arguments)] pub async fn launch( diff --git a/src/lib/login/user.rs b/src/lib/login/user.rs index 547fe7e..46652db 100644 --- a/src/lib/login/user.rs +++ b/src/lib/login/user.rs @@ -1,15 +1,7 @@ -use anyhow::{anyhow, Result}; -use directories::ProjectDirs; use nostr::PublicKey; use nostr_sdk::Timestamp; use serde::{self, Deserialize, Serialize}; -pub fn get_dirs() -> Result { - ProjectDirs::from("", "", "ngit").ok_or(anyhow!( - "should find operating system home directories with rust-directories crate" - )) -} - #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub struct UserRef { pub public_key: PublicKey, diff --git a/src/lib/mod.rs b/src/lib/mod.rs index 61dfc49..6e6f6fe 100644 --- a/src/lib/mod.rs +++ b/src/lib/mod.rs @@ -1,16 +1,16 @@ -mod cli_interactor; -mod client; -mod config; -mod git; -mod key_handling; -mod login; -mod repo_ref; -mod repo_state; +pub mod cli_interactor; +pub mod client; +pub mod git; +pub mod git_events; +pub mod login; +pub mod repo_ref; +pub mod repo_state; -pub use client; -pub use config; -pub use git; -pub use key_handling; -pub use login; -pub use repo_ref; -pub use repo_state; +use anyhow::{anyhow, Result}; +use directories::ProjectDirs; + +pub fn get_dirs() -> Result { + ProjectDirs::from("", "", "ngit").ok_or(anyhow!( + "should find operating system home directories with rust-directories crate" + )) +} diff --git a/src/lib/repo_ref.rs b/src/lib/repo_ref.rs index 0e57d96..e498c86 100644 --- a/src/lib/repo_ref.rs +++ b/src/lib/repo_ref.rs @@ -16,7 +16,7 @@ use crate::client::Client; use crate::{ cli_interactor::{Interactor, InteractorPrompt, PromptInputParms}, client::{get_event_from_global_cache, get_events_from_cache, sign_event, Connect}, - git::{NostrUrlDecoded, Repo, RepoActions}, + git::{nostr_url::NostrUrlDecoded, Repo, RepoActions}, }; #[derive(Default)] -- cgit v1.2.3