upleb.uk

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

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2024-09-04 11:32:05 +0100
committerDanConwayDev <DanConwayDev@protonmail.com>2024-09-04 14:23:54 +0100
commit771f944af447c202eba045936a36dee71ab797ac (patch)
treee691de4ebc8dde7ac4855e139881ff923bc254ce
parent949c6459aa7683453a7160423b689ceadb08954b (diff)
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
-rw-r--r--src/bin/git_remote_nostr/main.rs307
-rw-r--r--src/bin/ngit/main.rs2
-rw-r--r--src/bin/ngit/sub_commands/fetch.rs9
-rw-r--r--src/bin/ngit/sub_commands/init.rs12
-rw-r--r--src/bin/ngit/sub_commands/list.rs216
-rw-r--r--src/bin/ngit/sub_commands/login.rs14
-rw-r--r--src/bin/ngit/sub_commands/pull.rs24
-rw-r--r--src/bin/ngit/sub_commands/push.rs32
-rw-r--r--src/bin/ngit/sub_commands/send.rs969
-rw-r--r--src/lib/client.rs273
-rw-r--r--src/lib/git/identify_ahead_behind.rs196
-rw-r--r--src/lib/git/mod.rs266
-rw-r--r--src/lib/git/nostr_url.rs501
-rw-r--r--src/lib/git_events.rs692
-rw-r--r--src/lib/login/mod.rs7
-rw-r--r--src/lib/login/user.rs8
-rw-r--r--src/lib/mod.rs30
-rw-r--r--src/lib/repo_ref.rs2
18 files changed, 1759 insertions, 1801 deletions
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::{
15use anyhow::{anyhow, bail, Context, Result}; 15use anyhow::{anyhow, bail, Context, Result};
16use auth_git2::GitAuthenticator; 16use auth_git2::GitAuthenticator;
17use client::{ 17use client::{
18 consolidate_fetch_reports, get_events_from_cache, get_repo_ref_from_cache, 18 consolidate_fetch_reports, get_all_proposal_patch_events_from_cache, get_events_from_cache,
19 get_state_from_cache, sign_event, Connect, STATE_KIND, 19 get_proposals_and_revisions_from_cache, get_repo_ref_from_cache, get_state_from_cache,
20 send_events, sign_event, Connect, STATE_KIND,
20}; 21};
21use console::Term; 22use console::Term;
22use git::{sha1_to_oid, NostrUrlDecoded, RepoActions}; 23use git::{nostr_url::NostrUrlDecoded, sha1_to_oid, RepoActions};
23use git2::{Oid, Repository}; 24use git2::{Oid, Repository};
25use git_events::{
26 event_is_revision_root, event_to_cover_letter, generate_cover_letter_and_patch_events,
27 generate_patch_event, get_commit_id_from_patch, get_most_recent_patch_with_ancestors,
28 status_kinds, tag_value,
29};
30use ngit::{client, git, git_events, login, repo_ref, repo_state};
24use nostr::nips::{nip01::Coordinate, nip10::Marker}; 31use nostr::nips::{nip01::Coordinate, nip10::Marker};
25use nostr_sdk::{ 32use nostr_sdk::{
26 hashes::sha1::Hash as Sha1Hash, Event, EventBuilder, EventId, Kind, PublicKey, Tag, Url, 33 hashes::sha1::Hash as Sha1Hash, Event, EventBuilder, EventId, Kind, PublicKey, Tag, Url,
@@ -28,34 +35,8 @@ use nostr_sdk::{
28use nostr_signer::NostrSigner; 35use nostr_signer::NostrSigner;
29use repo_ref::RepoRef; 36use repo_ref::RepoRef;
30use repo_state::RepoState; 37use repo_state::RepoState;
31use sub_commands::{
32 list::{
33 get_all_proposal_patch_events_from_cache, get_commit_id_from_patch,
34 get_most_recent_patch_with_ancestors, get_proposals_and_revisions_from_cache, status_kinds,
35 tag_value,
36 },
37 send::{
38 event_is_revision_root, event_to_cover_letter, generate_cover_letter_and_patch_events,
39 generate_patch_event, send_events,
40 },
41};
42 38
43#[cfg(not(test))] 39use crate::{client::Client, git::Repo};
44use crate::client::Client;
45#[cfg(test)]
46use crate::client::MockConnect;
47use crate::git::Repo;
48
49mod cli;
50mod cli_interactor;
51mod client;
52mod config;
53mod git;
54mod key_handling;
55mod login;
56mod repo_ref;
57mod repo_state;
58mod sub_commands;
59 40
60#[tokio::main] 41#[tokio::main]
61async fn main() -> Result<()> { 42async fn main() -> Result<()> {
@@ -76,10 +57,7 @@ async fn main() -> Result<()> {
76 ))?; 57 ))?;
77 let git_repo_path = git_repo.get_path()?; 58 let git_repo_path = git_repo.get_path()?;
78 59
79 #[cfg(not(test))]
80 let client = Client::default(); 60 let client = Client::default();
81 #[cfg(test)]
82 let client = <MockConnect as std::default::Default>::default();
83 61
84 let decoded_nostr_url = 62 let decoded_nostr_url =
85 NostrUrlDecoded::from_str(nostr_remote_url).context("invalid nostr url")?; 63 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
155 133
156async fn fetching_with_report_for_helper( 134async fn fetching_with_report_for_helper(
157 git_repo_path: &Path, 135 git_repo_path: &Path,
158 #[cfg(test)] client: &crate::client::MockConnect, 136 client: &Client,
159 #[cfg(not(test))] client: &Client,
160 repo_coordinates: &HashSet<Coordinate>, 137 repo_coordinates: &HashSet<Coordinate>,
161) -> Result<()> { 138) -> Result<()> {
162 let term = console::Term::stderr(); 139 let term = console::Term::stderr();
@@ -662,8 +639,7 @@ async fn push(
662 nostr_remote_url: &str, 639 nostr_remote_url: &str,
663 stdin: &Stdin, 640 stdin: &Stdin,
664 initial_refspec: &str, 641 initial_refspec: &str,
665 #[cfg(test)] client: &crate::client::MockConnect, 642 client: &Client,
666 #[cfg(not(test))] client: &Client,
667 list_outputs: Option<HashMap<String, HashMap<String, String>>>, 643 list_outputs: Option<HashMap<String, HashMap<String, String>>>,
668) -> Result<()> { 644) -> Result<()> {
669 let refspecs = get_refspecs_from_push_batch(stdin, initial_refspec)?; 645 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<
1613 Ok(refspecs) 1589 Ok(refspecs)
1614} 1590}
1615 1591
1616impl RepoState { 1592trait BuildRepoState {
1617 pub async fn build( 1593 async fn build(
1594 identifier: String,
1595 state: HashMap<String, String>,
1596 signer: &NostrSigner,
1597 ) -> Result<RepoState>;
1598}
1599impl BuildRepoState for RepoState {
1600 async fn build(
1618 identifier: String, 1601 identifier: String,
1619 state: HashMap<String, String>, 1602 state: HashMap<String, String>,
1620 signer: &NostrSigner, 1603 signer: &NostrSigner,
@@ -1639,252 +1622,6 @@ impl RepoState {
1639mod tests { 1622mod tests {
1640 use super::*; 1623 use super::*;
1641 1624
1642 mod nostr_git_url_paramemters_from_str {
1643 use git::ServerProtocol;
1644 use nostr_sdk::PublicKey;
1645
1646 use super::*;
1647
1648 fn get_model_coordinate(relays: bool) -> Coordinate {
1649 Coordinate {
1650 identifier: "ngit".to_string(),
1651 public_key: PublicKey::parse(
1652 "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr",
1653 )
1654 .unwrap(),
1655 kind: nostr_sdk::Kind::GitRepoAnnouncement,
1656 relays: if relays {
1657 vec!["wss://nos.lol/".to_string()]
1658 } else {
1659 vec![]
1660 },
1661 }
1662 }
1663
1664 #[test]
1665 fn from_naddr() -> Result<()> {
1666 assert_eq!(
1667 NostrUrlDecoded::from_str(
1668 "nostr://naddr1qqzxuemfwsqs6amnwvaz7tmwdaejumr0dspzpgqgmmc409hm4xsdd74sf68a2uyf9pwel4g9mfdg8l5244t6x4jdqvzqqqrhnym0k2qj"
1669 )?,
1670 NostrUrlDecoded {
1671 coordinates: HashSet::from([Coordinate {
1672 identifier: "ngit".to_string(),
1673 public_key: PublicKey::parse(
1674 "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr",
1675 )
1676 .unwrap(),
1677 kind: nostr_sdk::Kind::GitRepoAnnouncement,
1678 relays: vec!["wss://nos.lol".to_string()], // wont add the slash
1679 }]),
1680 protocol: None,
1681 user: None,
1682 },
1683 );
1684 Ok(())
1685 }
1686 mod from_npub_slash_identifier {
1687 use super::*;
1688
1689 #[test]
1690 fn without_relay() -> Result<()> {
1691 assert_eq!(
1692 NostrUrlDecoded::from_str(
1693 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit"
1694 )?,
1695 NostrUrlDecoded {
1696 coordinates: HashSet::from([get_model_coordinate(false)]),
1697 protocol: None,
1698 user: None,
1699 },
1700 );
1701 Ok(())
1702 }
1703
1704 mod with_url_parameters {
1705
1706 use super::*;
1707
1708 #[test]
1709 fn with_relay_without_scheme_defaults_to_wss() -> Result<()> {
1710 assert_eq!(
1711 NostrUrlDecoded::from_str(
1712 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?relay=nos.lol"
1713 )?,
1714 NostrUrlDecoded {
1715 coordinates: HashSet::from([get_model_coordinate(true)]),
1716 protocol: None,
1717 user: None,
1718 },
1719 );
1720 Ok(())
1721 }
1722
1723 #[test]
1724 fn with_encoded_relay() -> Result<()> {
1725 assert_eq!(
1726 NostrUrlDecoded::from_str(&format!(
1727 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?relay={}",
1728 urlencoding::encode("wss://nos.lol")
1729 ))?,
1730 NostrUrlDecoded {
1731 coordinates: HashSet::from([get_model_coordinate(true)]),
1732 protocol: None,
1733 user: None,
1734 },
1735 );
1736 Ok(())
1737 }
1738 #[test]
1739 fn with_multiple_encoded_relays() -> Result<()> {
1740 assert_eq!(
1741 NostrUrlDecoded::from_str(&format!(
1742 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?relay={}&relay1={}",
1743 urlencoding::encode("wss://nos.lol"),
1744 urlencoding::encode("wss://relay.damus.io"),
1745 ))?,
1746 NostrUrlDecoded {
1747 coordinates: HashSet::from([Coordinate {
1748 identifier: "ngit".to_string(),
1749 public_key: PublicKey::parse(
1750 "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr",
1751 )
1752 .unwrap(),
1753 kind: nostr_sdk::Kind::GitRepoAnnouncement,
1754 relays: vec![
1755 "wss://nos.lol/".to_string(),
1756 "wss://relay.damus.io/".to_string(),
1757 ],
1758 }]),
1759 protocol: None,
1760 user: None,
1761 },
1762 );
1763 Ok(())
1764 }
1765
1766 #[test]
1767 fn with_server_protocol() -> Result<()> {
1768 assert_eq!(
1769 NostrUrlDecoded::from_str(
1770 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?protocol=ssh"
1771 )?,
1772 NostrUrlDecoded {
1773 coordinates: HashSet::from([get_model_coordinate(false)]),
1774 protocol: Some(ServerProtocol::Ssh),
1775 user: None,
1776 },
1777 );
1778 Ok(())
1779 }
1780 #[test]
1781 fn with_server_protocol_and_user() -> Result<()> {
1782 assert_eq!(
1783 NostrUrlDecoded::from_str(
1784 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?protocol=ssh&user=fred"
1785 )?,
1786 NostrUrlDecoded {
1787 coordinates: HashSet::from([get_model_coordinate(false)]),
1788 protocol: Some(ServerProtocol::Ssh),
1789 user: Some("fred".to_string()),
1790 },
1791 );
1792 Ok(())
1793 }
1794 }
1795 mod with_parameters_embedded_with_slashes {
1796 use super::*;
1797
1798 #[test]
1799 fn with_relay_without_scheme_defaults_to_wss() -> Result<()> {
1800 assert_eq!(
1801 NostrUrlDecoded::from_str(
1802 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/nos.lol/ngit"
1803 )?,
1804 NostrUrlDecoded {
1805 coordinates: HashSet::from([get_model_coordinate(true)]),
1806 protocol: None,
1807 user: None,
1808 },
1809 );
1810 Ok(())
1811 }
1812
1813 #[test]
1814 fn with_encoded_relay() -> Result<()> {
1815 assert_eq!(
1816 NostrUrlDecoded::from_str(&format!(
1817 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/{}/ngit",
1818 urlencoding::encode("wss://nos.lol")
1819 ))?,
1820 NostrUrlDecoded {
1821 coordinates: HashSet::from([get_model_coordinate(true)]),
1822 protocol: None,
1823 user: None,
1824 },
1825 );
1826 Ok(())
1827 }
1828 #[test]
1829 fn with_multiple_encoded_relays() -> Result<()> {
1830 assert_eq!(
1831 NostrUrlDecoded::from_str(&format!(
1832 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/{}/{}/ngit",
1833 urlencoding::encode("wss://nos.lol"),
1834 urlencoding::encode("wss://relay.damus.io"),
1835 ))?,
1836 NostrUrlDecoded {
1837 coordinates: HashSet::from([Coordinate {
1838 identifier: "ngit".to_string(),
1839 public_key: PublicKey::parse(
1840 "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr",
1841 )
1842 .unwrap(),
1843 kind: nostr_sdk::Kind::GitRepoAnnouncement,
1844 relays: vec![
1845 "wss://nos.lol/".to_string(),
1846 "wss://relay.damus.io/".to_string(),
1847 ],
1848 }]),
1849 protocol: None,
1850 user: None,
1851 },
1852 );
1853 Ok(())
1854 }
1855
1856 #[test]
1857 fn with_server_protocol() -> Result<()> {
1858 assert_eq!(
1859 NostrUrlDecoded::from_str(
1860 "nostr://ssh/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit"
1861 )?,
1862 NostrUrlDecoded {
1863 coordinates: HashSet::from([get_model_coordinate(false)]),
1864 protocol: Some(ServerProtocol::Ssh),
1865 user: None,
1866 },
1867 );
1868 Ok(())
1869 }
1870 #[test]
1871 fn with_server_protocol_and_user() -> Result<()> {
1872 assert_eq!(
1873 NostrUrlDecoded::from_str(
1874 "nostr://fred@ssh/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit"
1875 )?,
1876 NostrUrlDecoded {
1877 coordinates: HashSet::from([get_model_coordinate(false)]),
1878 protocol: Some(ServerProtocol::Ssh),
1879 user: Some("fred".to_string()),
1880 },
1881 );
1882 Ok(())
1883 }
1884 }
1885 }
1886 }
1887
1888 mod refspec_to_from_to { 1625 mod refspec_to_from_to {
1889 use super::*; 1626 use super::*;
1890 1627
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;
7use cli::{Cli, Commands}; 7use cli::{Cli, Commands};
8 8
9mod cli; 9mod cli;
10use ngit::*; 10use ngit::{cli_interactor, client, git, git_events, login, repo_ref};
11 11
12mod sub_commands; 12mod sub_commands;
13 13
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};
4use clap; 4use clap;
5use nostr::nips::nip01::Coordinate; 5use nostr::nips::nip01::Coordinate;
6 6
7#[cfg(not(test))]
8use crate::client::Client;
9#[cfg(test)]
10use crate::client::MockConnect;
11use crate::{ 7use crate::{
12 cli::Cli, 8 cli::Cli,
13 client::{fetching_with_report, Connect}, 9 client::{fetching_with_report, Client, Connect},
14 git::{Repo, RepoActions}, 10 git::{Repo, RepoActions},
15 repo_ref::get_repo_coordinates, 11 repo_ref::get_repo_coordinates,
16}; 12};
@@ -25,10 +21,7 @@ pub struct SubCommandArgs {
25pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { 21pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> {
26 let _ = args; 22 let _ = args;
27 let git_repo = Repo::discover().context("cannot find a git repository")?; 23 let git_repo = Repo::discover().context("cannot find a git repository")?;
28 #[cfg(not(test))]
29 let client = Client::default(); 24 let client = Client::default();
30 #[cfg(test)]
31 let client = <MockConnect as std::default::Default>::default();
32 let repo_coordinates = if command_args.repo.is_empty() { 25 let repo_coordinates = if command_args.repo.is_empty() {
33 get_repo_coordinates(&git_repo, &client).await? 26 get_repo_coordinates(&git_repo, &client).await?
34 } else { 27 } 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};
4use nostr::{nips::nip01::Coordinate, FromBech32, PublicKey, ToBech32}; 4use nostr::{nips::nip01::Coordinate, FromBech32, PublicKey, ToBech32};
5use nostr_sdk::Kind; 5use nostr_sdk::Kind;
6 6
7use super::send::send_events;
8#[cfg(not(test))]
9use crate::client::Client;
10#[cfg(test)]
11use crate::client::MockConnect;
12use crate::{ 7use crate::{
13 cli::Cli, 8 cli::Cli,
14 cli_interactor::{Interactor, InteractorPrompt, PromptInputParms}, 9 cli_interactor::{Interactor, InteractorPrompt, PromptInputParms},
15 client::{fetching_with_report, get_repo_ref_from_cache, Connect}, 10 client::{fetching_with_report, get_repo_ref_from_cache, send_events, Client, Connect},
16 git::{convert_clone_url_to_https, Repo, RepoActions}, 11 git::{nostr_url::convert_clone_url_to_https, Repo, RepoActions},
17 login, 12 login,
18 repo_ref::{ 13 repo_ref::{
19 extract_pks, get_repo_config_from_yaml, save_repo_config_to_yaml, 14 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<()> {
61 // TODO: check for empty repo 56 // TODO: check for empty repo
62 // TODO: check for existing maintaiers file 57 // TODO: check for existing maintaiers file
63 58
64 #[cfg(not(test))]
65 let mut client = Client::default(); 59 let mut client = Client::default();
66 #[cfg(test)]
67 let mut client = <MockConnect as std::default::Default>::default();
68 60
69 let repo_coordinates = if let Ok(repo_coordinates) = 61 let repo_coordinates = if let Ok(repo_coordinates) =
70 try_and_get_repo_coordinates(&git_repo, &client, false).await 62 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 @@
1use std::{collections::HashSet, io::Write, ops::Add, path::Path}; 1use std::{io::Write, ops::Add};
2 2
3use anyhow::{bail, Context, Result}; 3use anyhow::{bail, Context, Result};
4use nostr::nips::nip01::Coordinate; 4use ngit::{
5use nostr_sdk::{Kind, PublicKey}; 5 client::{get_all_proposal_patch_events_from_cache, get_proposals_and_revisions_from_cache},
6 6 git_events::{
7use super::send::event_is_patch_set_root; 7 get_commit_id_from_patch, get_most_recent_patch_with_ancestors, status_kinds, tag_value,
8#[cfg(test)] 8 },
9use crate::client::MockConnect; 9};
10#[cfg(not(test))] 10use nostr_sdk::Kind;
11use crate::client::{Client, Connect}; 11
12use crate::{ 12use crate::{
13 cli_interactor::{Interactor, InteractorPrompt, PromptChoiceParms, PromptConfirmParms}, 13 cli_interactor::{Interactor, InteractorPrompt, PromptChoiceParms, PromptConfirmParms},
14 client::{fetching_with_report, get_events_from_cache, get_repo_ref_from_cache}, 14 client::{
15 fetching_with_report, get_events_from_cache, get_repo_ref_from_cache, Client, Connect,
16 },
15 git::{str_to_sha1, Repo, RepoActions}, 17 git::{str_to_sha1, Repo, RepoActions},
16 repo_ref::{get_repo_coordinates, RepoRef}, 18 git_events::{
17 sub_commands::send::{ 19 commit_msg_from_patch_oneliner, event_is_revision_root, event_to_cover_letter,
18 commit_msg_from_patch_oneliner, event_is_cover_letter, event_is_revision_root, 20 patch_supports_commit_ids,
19 event_to_cover_letter, patch_supports_commit_ids,
20 }, 21 },
22 repo_ref::get_repo_coordinates,
21}; 23};
22 24
23#[allow(clippy::too_many_lines)] 25#[allow(clippy::too_many_lines)]
@@ -29,10 +31,7 @@ pub async fn launch() -> Result<()> {
29 // TODO: check for existing maintaiers file 31 // TODO: check for existing maintaiers file
30 // TODO: check for other claims 32 // TODO: check for other claims
31 33
32 #[cfg(not(test))]
33 let client = Client::default(); 34 let client = Client::default();
34 #[cfg(test)]
35 let client = <MockConnect as std::default::Default>::default();
36 35
37 let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?; 36 let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?;
38 37
@@ -721,186 +720,3 @@ fn check_clean(git_repo: &Repo) -> Result<()> {
721 } 720 }
722 Ok(()) 721 Ok(())
723} 722}
724
725pub fn tag_value(event: &nostr::Event, tag_name: &str) -> Result<String> {
726 Ok(event
727 .tags
728 .iter()
729 .find(|t| t.as_vec()[0].eq(tag_name))
730 .context(format!("tag '{tag_name}'not present"))?
731 .as_vec()[1]
732 .clone())
733}
734
735pub fn get_commit_id_from_patch(event: &nostr::Event) -> Result<String> {
736 let value = tag_value(event, "commit");
737
738 if value.is_ok() {
739 value
740 } else if event.content.starts_with("From ") && event.content.len().gt(&45) {
741 Ok(event.content[5..45].to_string())
742 } else {
743 bail!("event is not a patch")
744 }
745}
746
747fn get_event_parent_id(event: &nostr::Event) -> Result<String> {
748 Ok(if let Some(reply_tag) = event
749 .tags
750 .iter()
751 .find(|t| t.as_vec().len().gt(&3) && t.as_vec()[3].eq("reply"))
752 {
753 reply_tag
754 } else {
755 event
756 .tags
757 .iter()
758 .find(|t| t.as_vec().len().gt(&3) && t.as_vec()[3].eq("root"))
759 .context("no reply or root e tag present".to_string())?
760 }
761 .as_vec()[1]
762 .clone())
763}
764
765pub fn get_most_recent_patch_with_ancestors(
766 mut patches: Vec<nostr::Event>,
767) -> Result<Vec<nostr::Event>> {
768 patches.sort_by_key(|e| e.created_at);
769
770 let youngest_patch = patches.last().context("no patches found")?;
771
772 let patches_with_youngest_created_at: Vec<&nostr::Event> = patches
773 .iter()
774 .filter(|p| p.created_at.eq(&youngest_patch.created_at))
775 .collect();
776
777 let mut res = vec![];
778
779 let mut event_id_to_search = patches_with_youngest_created_at
780 .clone()
781 .iter()
782 .find(|p| {
783 !patches_with_youngest_created_at.iter().any(|p2| {
784 if let Ok(reply_to) = get_event_parent_id(p2) {
785 reply_to.eq(&p.id.to_string())
786 } else {
787 false
788 }
789 })
790 })
791 .context("cannot find patches_with_youngest_created_at")?
792 .id
793 .to_string();
794
795 while let Some(event) = patches
796 .iter()
797 .find(|e| e.id.to_string().eq(&event_id_to_search))
798 {
799 res.push(event.clone());
800 if event_is_patch_set_root(event) {
801 break;
802 }
803 event_id_to_search = get_event_parent_id(event).unwrap_or_default();
804 }
805 Ok(res)
806}
807
808pub fn status_kinds() -> Vec<nostr::Kind> {
809 vec![
810 nostr::Kind::GitStatusOpen,
811 nostr::Kind::GitStatusApplied,
812 nostr::Kind::GitStatusClosed,
813 nostr::Kind::GitStatusDraft,
814 ]
815}
816
817pub async fn get_proposals_and_revisions_from_cache(
818 git_repo_path: &Path,
819 repo_coordinates: HashSet<Coordinate>,
820) -> Result<Vec<nostr::Event>> {
821 let mut proposals = get_events_from_cache(
822 git_repo_path,
823 vec![
824 nostr::Filter::default()
825 .kind(nostr::Kind::GitPatch)
826 .custom_tag(
827 nostr::SingleLetterTag::lowercase(nostr_sdk::Alphabet::A),
828 repo_coordinates
829 .iter()
830 .map(std::string::ToString::to_string)
831 .collect::<Vec<String>>(),
832 ),
833 ],
834 )
835 .await?
836 .iter()
837 .filter(|e| event_is_patch_set_root(e))
838 .cloned()
839 .collect::<Vec<nostr::Event>>();
840 proposals.sort_by_key(|e| e.created_at);
841 proposals.reverse();
842 Ok(proposals)
843}
844
845pub async fn get_all_proposal_patch_events_from_cache(
846 git_repo_path: &Path,
847 repo_ref: &RepoRef,
848 proposal_id: &nostr::EventId,
849) -> Result<Vec<nostr::Event>> {
850 let mut commit_events = get_events_from_cache(
851 git_repo_path,
852 vec![
853 nostr::Filter::default()
854 .kind(nostr::Kind::GitPatch)
855 .event(*proposal_id),
856 nostr::Filter::default()
857 .kind(nostr::Kind::GitPatch)
858 .id(*proposal_id),
859 ],
860 )
861 .await?;
862
863 let permissioned_users: HashSet<PublicKey> = [
864 repo_ref.maintainers.clone(),
865 vec![
866 commit_events
867 .iter()
868 .find(|e| e.id().eq(proposal_id))
869 .context("proposal not in cache")?
870 .author(),
871 ],
872 ]
873 .concat()
874 .iter()
875 .copied()
876 .collect();
877 commit_events.retain(|e| permissioned_users.contains(&e.author()));
878
879 let revision_roots: HashSet<nostr::EventId> = commit_events
880 .iter()
881 .filter(|e| event_is_revision_root(e))
882 .map(nostr::Event::id)
883 .collect();
884
885 if !revision_roots.is_empty() {
886 for event in get_events_from_cache(
887 git_repo_path,
888 vec![
889 nostr::Filter::default()
890 .kind(nostr::Kind::GitPatch)
891 .events(revision_roots)
892 .authors(permissioned_users.clone()),
893 ],
894 )
895 .await?
896 {
897 commit_events.push(event);
898 }
899 }
900
901 Ok(commit_events
902 .iter()
903 .filter(|e| !event_is_cover_letter(e) && permissioned_users.contains(&e.author()))
904 .cloned()
905 .collect())
906}
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 @@
1use anyhow::{Context, Result}; 1use anyhow::{Context, Result};
2use clap; 2use clap;
3 3
4#[cfg(not(test))] 4use crate::{
5use crate::client::Client; 5 cli::Cli,
6#[cfg(test)] 6 client::{Client, Connect},
7use crate::client::MockConnect; 7 git::Repo,
8use crate::{cli::Cli, client::Connect, git::Repo, login}; 8 login,
9};
9 10
10#[derive(clap::Args)] 11#[derive(clap::Args)]
11pub struct SubCommandArgs { 12pub struct SubCommandArgs {
@@ -30,10 +31,7 @@ pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> {
30 .await?; 31 .await?;
31 Ok(()) 32 Ok(())
32 } else { 33 } else {
33 #[cfg(not(test))]
34 let client = Client::default(); 34 let client = Client::default();
35 #[cfg(test)]
36 let client = <MockConnect as std::default::Default>::default();
37 35
38 login::launch( 36 login::launch(
39 &git_repo, 37 &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 @@
1use anyhow::{bail, Context, Result}; 1use anyhow::{bail, Context, Result};
2 2
3use super::{
4 list::{
5 get_all_proposal_patch_events_from_cache, get_commit_id_from_patch,
6 get_proposals_and_revisions_from_cache, tag_value,
7 },
8 send::event_to_cover_letter,
9};
10#[cfg(test)]
11use crate::client::MockConnect;
12#[cfg(not(test))]
13use crate::client::{Client, Connect};
14use crate::{ 3use crate::{
15 client::{fetching_with_report, get_repo_ref_from_cache}, 4 client::{
5 fetching_with_report, get_all_proposal_patch_events_from_cache,
6 get_proposals_and_revisions_from_cache, get_repo_ref_from_cache, Client, Connect,
7 },
16 git::{str_to_sha1, Repo, RepoActions}, 8 git::{str_to_sha1, Repo, RepoActions},
9 git_events::{
10 event_is_revision_root, event_to_cover_letter, get_commit_id_from_patch,
11 get_most_recent_patch_with_ancestors, tag_value,
12 },
17 repo_ref::get_repo_coordinates, 13 repo_ref::get_repo_coordinates,
18 sub_commands::{list::get_most_recent_patch_with_ancestors, send::event_is_revision_root},
19}; 14};
20 15
21#[allow(clippy::too_many_lines)] 16#[allow(clippy::too_many_lines)]
@@ -34,10 +29,7 @@ pub async fn launch() -> Result<()> {
34 if branch_name == main_or_master_branch_name { 29 if branch_name == main_or_master_branch_name {
35 bail!("checkout a branch associated with a proposal first") 30 bail!("checkout a branch associated with a proposal first")
36 } 31 }
37 #[cfg(not(test))]
38 let client = Client::default(); 32 let client = Client::default();
39 #[cfg(test)]
40 let client = <MockConnect as std::default::Default>::default();
41 33
42 let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?; 34 let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?;
43 35
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 @@
1use anyhow::{bail, Context, Result}; 1use anyhow::{bail, Context, Result};
2use ngit::{client::send_events, git_events::tag_value};
2 3
3#[cfg(not(test))]
4use crate::client::Client;
5#[cfg(test)]
6use crate::client::MockConnect;
7use crate::{ 4use crate::{
8 cli::Cli, 5 cli::Cli,
9 client::{fetching_with_report, get_repo_ref_from_cache, Connect}, 6 client::{
10 git::{str_to_sha1, Repo, RepoActions}, 7 fetching_with_report, get_all_proposal_patch_events_from_cache,
8 get_proposals_and_revisions_from_cache, get_repo_ref_from_cache, Client, Connect,
9 },
10 git::{identify_ahead_behind, str_to_sha1, Repo, RepoActions},
11 git_events::{
12 event_is_revision_root, event_to_cover_letter, generate_patch_event,
13 get_commit_id_from_patch, get_most_recent_patch_with_ancestors,
14 },
11 login, 15 login,
12 repo_ref::get_repo_coordinates, 16 repo_ref::get_repo_coordinates,
13 sub_commands::{ 17 sub_commands,
14 self,
15 list::{
16 get_all_proposal_patch_events_from_cache, get_commit_id_from_patch,
17 get_most_recent_patch_with_ancestors, get_proposals_and_revisions_from_cache,
18 tag_value,
19 },
20 send::{
21 event_is_revision_root, event_to_cover_letter, generate_patch_event,
22 identify_ahead_behind, send_events,
23 },
24 },
25}; 18};
26 19
27#[derive(Debug, clap::Args)] 20#[derive(Debug, clap::Args)]
@@ -51,10 +44,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
51 if branch_name == main_or_master_branch_name { 44 if branch_name == main_or_master_branch_name {
52 bail!("checkout a branch associated with a proposal first") 45 bail!("checkout a branch associated with a proposal first")
53 } 46 }
54 #[cfg(not(test))]
55 let mut client = Client::default(); 47 let mut client = Client::default();
56 #[cfg(test)]
57 let mut client = <MockConnect as std::default::Default>::default();
58 48
59 let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?; 49 let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?;
60 50
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 @@
1use std::{path::Path, str::FromStr, time::Duration}; 1use std::path::Path;
2 2
3use anyhow::{bail, Context, Result}; 3use anyhow::{bail, Context, Result};
4use console::Style; 4use console::Style;
5use futures::future::join_all; 5use ngit::{client::send_events, git_events::generate_cover_letter_and_patch_events};
6use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
7use nostr::{ 6use nostr::{
8 nips::{ 7 nips::{nip10::Marker, nip19::Nip19Event},
9 nip01::Coordinate, 8 ToBech32,
10 nip10::Marker,
11 nip19::{Nip19, Nip19Event},
12 },
13 EventBuilder, FromBech32, Tag, TagKind, ToBech32, UncheckedUrl,
14}; 9};
15use nostr_sdk::{hashes::sha1::Hash as Sha1Hash, Kind, NostrSigner, TagStandard}; 10use nostr_sdk::hashes::sha1::Hash as Sha1Hash;
16 11
17use super::list::tag_value;
18#[cfg(not(test))]
19use crate::client::Client;
20#[cfg(test)]
21use crate::client::MockConnect;
22use crate::{ 12use crate::{
23 cli::Cli, 13 cli::Cli,
24 cli_interactor::{ 14 cli_interactor::{
25 Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptMultiChoiceParms, 15 Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptMultiChoiceParms,
26 }, 16 },
27 client::{ 17 client::{
28 fetching_with_report, get_events_from_cache, get_repo_ref_from_cache, sign_event, Connect, 18 fetching_with_report, get_events_from_cache, get_repo_ref_from_cache, Client, Connect,
29 }, 19 },
30 git::{Repo, RepoActions}, 20 git::{identify_ahead_behind, Repo, RepoActions},
21 git_events::{event_is_patch_set_root, event_tag_from_nip19_or_hex},
31 login, 22 login,
32 repo_ref::{get_repo_coordinates, RepoRef}, 23 repo_ref::get_repo_coordinates,
33}; 24};
34 25
35#[derive(Debug, clap::Args)] 26#[derive(Debug, clap::Args)]
@@ -61,10 +52,7 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re
61 .get_main_or_master_branch() 52 .get_main_or_master_branch()
62 .context("the default branches (main or master) do not exist")?; 53 .context("the default branches (main or master) do not exist")?;
63 54
64 #[cfg(not(test))]
65 let mut client = Client::default(); 55 let mut client = Client::default();
66 #[cfg(test)]
67 let mut client = <MockConnect as std::default::Default>::default();
68 56
69 let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?; 57 let repo_coordinates = get_repo_coordinates(&git_repo, &client).await?;
70 58
@@ -277,172 +265,6 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs, no_fetch: bool) -> Re
277 Ok(()) 265 Ok(())
278} 266}
279 267
280#[allow(clippy::module_name_repetitions)]
281#[allow(clippy::too_many_lines)]
282pub async fn send_events(
283 #[cfg(test)] client: &crate::client::MockConnect,
284 #[cfg(not(test))] client: &Client,
285 git_repo_path: &Path,
286 events: Vec<nostr::Event>,
287 my_write_relays: Vec<String>,
288 repo_read_relays: Vec<String>,
289 animate: bool,
290 silent: bool,
291) -> Result<()> {
292 let fallback = [
293 client.get_fallback_relays().clone(),
294 if events
295 .iter()
296 .any(|e| e.kind().eq(&Kind::GitRepoAnnouncement))
297 {
298 client.get_blaster_relays().clone()
299 } else {
300 vec![]
301 },
302 ]
303 .concat();
304 let mut relays: Vec<&String> = vec![];
305
306 let all = &[
307 repo_read_relays.clone(),
308 my_write_relays.clone(),
309 fallback.clone(),
310 ]
311 .concat();
312 // add duplicates first
313 for r in &repo_read_relays {
314 let r_clean = remove_trailing_slash(r);
315 if !my_write_relays
316 .iter()
317 .filter(|x| r_clean.eq(&remove_trailing_slash(x)))
318 .count()
319 > 1
320 && !relays.iter().any(|x| r_clean.eq(&remove_trailing_slash(x)))
321 {
322 relays.push(r);
323 }
324 }
325
326 for r in all {
327 let r_clean = remove_trailing_slash(r);
328 if !relays.iter().any(|x| r_clean.eq(&remove_trailing_slash(x))) {
329 relays.push(r);
330 }
331 }
332
333 let m = if silent {
334 MultiProgress::with_draw_target(ProgressDrawTarget::hidden())
335 } else {
336 MultiProgress::new()
337 };
338 let pb_style = ProgressStyle::with_template(if animate {
339 " {spinner} {prefix} {bar} {pos}/{len} {msg}"
340 } else {
341 " - {prefix} {bar} {pos}/{len} {msg}"
342 })?
343 .progress_chars("##-");
344
345 let pb_after_style =
346 |symbol| ProgressStyle::with_template(format!(" {symbol} {}", "{prefix} {msg}",).as_str());
347 let pb_after_style_succeeded = pb_after_style(if animate {
348 console::style("✔".to_string())
349 .for_stderr()
350 .green()
351 .to_string()
352 } else {
353 "y".to_string()
354 })?;
355
356 let pb_after_style_failed = pb_after_style(if animate {
357 console::style("✘".to_string())
358 .for_stderr()
359 .red()
360 .to_string()
361 } else {
362 "x".to_string()
363 })?;
364
365 #[allow(clippy::borrow_deref_ref)]
366 join_all(relays.iter().map(|&relay| async {
367 let relay_clean = remove_trailing_slash(&*relay);
368 let details = format!(
369 "{}{}{} {}",
370 if my_write_relays
371 .iter()
372 .any(|r| relay_clean.eq(&remove_trailing_slash(r)))
373 {
374 " [my-relay]"
375 } else {
376 ""
377 },
378 if repo_read_relays
379 .iter()
380 .any(|r| relay_clean.eq(&remove_trailing_slash(r)))
381 {
382 " [repo-relay]"
383 } else {
384 ""
385 },
386 if fallback
387 .iter()
388 .any(|r| relay_clean.eq(&remove_trailing_slash(r)))
389 {
390 " [default]"
391 } else {
392 ""
393 },
394 relay_clean,
395 );
396 let pb = m.add(
397 ProgressBar::new(events.len() as u64)
398 .with_prefix(details.to_string())
399 .with_style(pb_style.clone()),
400 );
401 if animate {
402 pb.enable_steady_tick(Duration::from_millis(300));
403 }
404 pb.inc(0); // need to make pb display intially
405 let mut failed = false;
406 for event in &events {
407 match client
408 .send_event_to(git_repo_path, relay.as_str(), event.clone())
409 .await
410 {
411 Ok(_) => pb.inc(1),
412 Err(e) => {
413 pb.set_style(pb_after_style_failed.clone());
414 pb.finish_with_message(
415 console::style(
416 e.to_string()
417 .replace("relay pool error:", "error:")
418 .replace("event not published: ", "error: "),
419 )
420 .for_stderr()
421 .red()
422 .to_string(),
423 );
424 failed = true;
425 break;
426 }
427 };
428 }
429 if !failed {
430 pb.set_style(pb_after_style_succeeded.clone());
431 pb.finish_with_message("");
432 }
433 }))
434 .await;
435 Ok(())
436}
437
438fn remove_trailing_slash(s: &String) -> String {
439 match s.as_str().strip_suffix('/') {
440 Some(s) => s,
441 None => s,
442 }
443 .to_string()
444}
445
446fn choose_commits(git_repo: &Repo, proposed_commits: Vec<Sha1Hash>) -> Result<Vec<Sha1Hash>> { 268fn choose_commits(git_repo: &Repo, proposed_commits: Vec<Sha1Hash>) -> Result<Vec<Sha1Hash>> {
447 let mut proposed_commits = if proposed_commits.len().gt(&10) { 269 let mut proposed_commits = if proposed_commits.len().gt(&10) {
448 vec![] 270 vec![]
@@ -583,781 +405,8 @@ async fn get_root_proposal_id_and_mentions_from_in_reply_to(
583 Ok((root_proposal_id, mention_tags)) 405 Ok((root_proposal_id, mention_tags))
584} 406}
585 407
586#[allow(clippy::too_many_lines)]
587pub async fn generate_cover_letter_and_patch_events(
588 cover_letter_title_description: Option<(String, String)>,
589 git_repo: &Repo,
590 commits: &[Sha1Hash],
591 signer: &NostrSigner,
592 repo_ref: &RepoRef,
593 root_proposal_id: &Option<String>,
594 mentions: &[nostr::Tag],
595) -> Result<Vec<nostr::Event>> {
596 let root_commit = git_repo
597 .get_root_commit()
598 .context("failed to get root commit of the repository")?;
599
600 let mut events = vec![];
601
602 if let Some((title, description)) = cover_letter_title_description {
603 events.push(sign_event(EventBuilder::new(
604 nostr::event::Kind::GitPatch,
605 format!(
606 "From {} Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/{}] {title}\n\n{description}",
607 commits.last().unwrap(),
608 commits.len()
609 ),
610 [
611 repo_ref.maintainers.iter().map(|m| Tag::coordinate(Coordinate {
612 kind: nostr::Kind::GitRepoAnnouncement,
613 public_key: *m,
614 identifier: repo_ref.identifier.to_string(),
615 relays: repo_ref.relays.clone(),
616 })).collect::<Vec<Tag>>(),
617 vec![
618 Tag::from_standardized(TagStandard::Reference(format!("{root_commit}"))),
619 Tag::hashtag("cover-letter"),
620 Tag::custom(
621 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
622 vec![format!("git patch cover letter: {}", title.clone())],
623 ),
624 ],
625 if let Some(event_ref) = root_proposal_id.clone() {
626 vec![
627 Tag::hashtag("root"),
628 Tag::hashtag("revision-root"),
629 // TODO check if id is for a root proposal (perhaps its for an issue?)
630 event_tag_from_nip19_or_hex(&event_ref,"proposal",Marker::Reply, false, false)?,
631 ]
632 } else {
633 vec![
634 Tag::hashtag("root"),
635 ]
636 },
637 mentions.to_vec(),
638 // this is not strictly needed but makes for prettier branch names
639 // eventually a prefix will be needed of the event id to stop 2 proposals with the same name colliding
640 // a change like this, or the removal of this tag will require the actual branch name to be tracked
641 // so pulling and pushing still work
642 if let Ok(branch_name) = git_repo.get_checked_out_branch_name() {
643 if !branch_name.eq("main")
644 && !branch_name.eq("master")
645 && !branch_name.eq("origin/main")
646 && !branch_name.eq("origin/master")
647 {
648 vec![
649 Tag::custom(
650 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")),
651 vec![if let Some(branch_name) = branch_name.strip_prefix("pr/") {
652 branch_name.to_string()
653 } else {
654 branch_name
655 }],
656 ),
657 ]
658 }
659 else { vec![] }
660 } else {
661 vec![]
662 },
663 repo_ref.maintainers
664 .iter()
665 .map(|pk| Tag::public_key(*pk))
666 .collect(),
667 ].concat(),
668 ), signer).await
669 .context("failed to create cover-letter event")?);
670 }
671
672 for (i, commit) in commits.iter().enumerate() {
673 events.push(
674 generate_patch_event(
675 git_repo,
676 &root_commit,
677 commit,
678 events.first().map(|event| event.id),
679 signer,
680 repo_ref,
681 events.last().map(nostr::Event::id),
682 if events.is_empty() && commits.len().eq(&1) {
683 None
684 } else {
685 Some(((i + 1).try_into()?, commits.len().try_into()?))
686 },
687 if events.is_empty() {
688 if let Ok(branch_name) = git_repo.get_checked_out_branch_name() {
689 if !branch_name.eq("main")
690 && !branch_name.eq("master")
691 && !branch_name.eq("origin/main")
692 && !branch_name.eq("origin/master")
693 {
694 Some(if let Some(branch_name) = branch_name.strip_prefix("pr/") {
695 branch_name.to_string()
696 } else {
697 branch_name
698 })
699 } else {
700 None
701 }
702 } else {
703 None
704 }
705 } else {
706 None
707 },
708 root_proposal_id,
709 if events.is_empty() { mentions } else { &[] },
710 )
711 .await
712 .context("failed to generate patch event")?,
713 );
714 }
715 Ok(events)
716}
717
718fn event_tag_from_nip19_or_hex(
719 reference: &str,
720 reference_name: &str,
721 marker: Marker,
722 allow_npub_reference: bool,
723 prompt_for_correction: bool,
724) -> Result<nostr::Tag> {
725 let mut bech32 = reference.to_string();
726 loop {
727 if bech32.is_empty() {
728 bech32 = Interactor::default().input(
729 PromptInputParms::default().with_prompt(&format!("{reference_name} reference")),
730 )?;
731 }
732 if let Ok(nip19) = Nip19::from_bech32(bech32.clone()) {
733 match nip19 {
734 Nip19::Event(n) => {
735 break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event {
736 event_id: n.event_id,
737 relay_url: n.relays.first().map(UncheckedUrl::new),
738 marker: Some(marker),
739 public_key: None,
740 }));
741 }
742 Nip19::EventId(id) => {
743 break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event {
744 event_id: id,
745 relay_url: None,
746 marker: Some(marker),
747 public_key: None,
748 }));
749 }
750 Nip19::Coordinate(coordinate) => {
751 break Ok(Tag::coordinate(coordinate));
752 }
753 Nip19::Profile(profile) => {
754 if allow_npub_reference {
755 break Ok(Tag::public_key(profile.public_key));
756 }
757 }
758 Nip19::Pubkey(public_key) => {
759 if allow_npub_reference {
760 break Ok(Tag::public_key(public_key));
761 }
762 }
763 _ => {}
764 }
765 }
766 if let Ok(id) = nostr::EventId::from_str(&bech32) {
767 break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event {
768 event_id: id,
769 relay_url: None,
770 marker: Some(marker),
771 public_key: None,
772 }));
773 }
774 if prompt_for_correction {
775 println!("not a valid {reference_name} event reference");
776 } else {
777 bail!(format!("not a valid {reference_name} event reference"));
778 }
779
780 bech32 = String::new();
781 }
782}
783
784pub struct CoverLetter {
785 pub title: String,
786 pub description: String,
787 pub branch_name: String,
788 pub event_id: Option<nostr::EventId>,
789}
790
791impl CoverLetter {
792 pub fn get_branch_name(&self) -> Result<String> {
793 Ok(format!(
794 "pr/{}({})",
795 self.branch_name,
796 &self
797 .event_id
798 .context("proposal root event_id must be know to get it's branch name")?
799 .to_hex()
800 .as_str()[..8],
801 ))
802 }
803}
804pub fn event_is_cover_letter(event: &nostr::Event) -> bool {
805 // TODO: look for Subject:[ PATCH 0/n ] but watch out for:
806 // [PATCH v1 0/n ] or
807 // [PATCH subsystem v2 0/n ]
808 event.kind.eq(&Kind::GitPatch)
809 && event.tags().iter().any(|t| t.as_vec()[1].eq("root"))
810 && event
811 .tags()
812 .iter()
813 .any(|t| t.as_vec()[1].eq("cover-letter"))
814}
815
816pub fn commit_msg_from_patch(patch: &nostr::Event) -> Result<String> {
817 if let Ok(msg) = tag_value(patch, "description") {
818 Ok(msg)
819 } else {
820 let start_index = patch
821 .content
822 .find("] ")
823 .context("event is not formatted as a patch or cover letter")?
824 + 2;
825 let end_index = patch.content[start_index..]
826 .find("\ndiff --git")
827 .unwrap_or(patch.content.len());
828 Ok(patch.content[start_index..end_index].to_string())
829 }
830}
831
832pub fn commit_msg_from_patch_oneliner(patch: &nostr::Event) -> Result<String> {
833 Ok(commit_msg_from_patch(patch)?
834 .split('\n')
835 .collect::<Vec<&str>>()[0]
836 .to_string())
837}
838
839pub fn event_to_cover_letter(event: &nostr::Event) -> Result<CoverLetter> {
840 if !event_is_patch_set_root(event) {
841 bail!("event is not a patch set root event (root patch or cover letter)")
842 }
843
844 let title = commit_msg_from_patch_oneliner(event)?;
845 let full = commit_msg_from_patch(event)?;
846 let description = full[title.len()..].trim().to_string();
847
848 Ok(CoverLetter {
849 title: title.clone(),
850 description,
851 // TODO should this be prefixed by format!("{}-"e.id.to_string()[..5]?)
852 branch_name: if let Ok(name) = match tag_value(event, "branch-name") {
853 Ok(name) => {
854 if !name.eq("main") && !name.eq("master") {
855 Ok(name)
856 } else {
857 Err(())
858 }
859 }
860 _ => Err(()),
861 } {
862 name
863 } else {
864 let s = title
865 .replace(' ', "-")
866 .chars()
867 .map(|c| {
868 if c.is_ascii_alphanumeric() || c.eq(&'/') {
869 c
870 } else {
871 '-'
872 }
873 })
874 .collect();
875 s
876 },
877 event_id: Some(event.id()),
878 })
879}
880
881pub fn event_is_patch_set_root(event: &nostr::Event) -> bool {
882 event.kind.eq(&Kind::GitPatch) && event.tags().iter().any(|t| t.as_vec()[1].eq("root"))
883}
884
885pub fn event_is_revision_root(event: &nostr::Event) -> bool {
886 event.kind.eq(&Kind::GitPatch)
887 && event
888 .tags()
889 .iter()
890 .any(|t| t.as_vec()[1].eq("revision-root"))
891}
892
893pub fn patch_supports_commit_ids(event: &nostr::Event) -> bool {
894 event.kind.eq(&Kind::GitPatch)
895 && event
896 .tags()
897 .iter()
898 .any(|t| t.as_vec()[0].eq("commit-pgp-sig"))
899}
900
901#[allow(clippy::too_many_arguments)]
902#[allow(clippy::too_many_lines)]
903pub async fn generate_patch_event(
904 git_repo: &Repo,
905 root_commit: &Sha1Hash,
906 commit: &Sha1Hash,
907 thread_event_id: Option<nostr::EventId>,
908 signer: &nostr_sdk::NostrSigner,
909 repo_ref: &RepoRef,
910 parent_patch_event_id: Option<nostr::EventId>,
911 series_count: Option<(u64, u64)>,
912 branch_name: Option<String>,
913 root_proposal_id: &Option<String>,
914 mentions: &[nostr::Tag],
915) -> Result<nostr::Event> {
916 let commit_parent = git_repo
917 .get_commit_parent(commit)
918 .context("failed to get parent commit")?;
919 let relay_hint = repo_ref.relays.first().map(nostr::UncheckedUrl::from);
920
921 sign_event(
922 EventBuilder::new(
923 nostr::event::Kind::GitPatch,
924 git_repo
925 .make_patch_from_commit(commit, &series_count)
926 .context(format!("cannot make patch for commit {commit}"))?,
927 [
928 repo_ref
929 .maintainers
930 .iter()
931 .map(|m| {
932 Tag::coordinate(Coordinate {
933 kind: nostr::Kind::GitRepoAnnouncement,
934 public_key: *m,
935 identifier: repo_ref.identifier.to_string(),
936 relays: repo_ref.relays.clone(),
937 })
938 })
939 .collect::<Vec<Tag>>(),
940 vec![
941 Tag::from_standardized(TagStandard::Reference(root_commit.to_string())),
942 // commit id reference is a trade-off. its now
943 // unclear which one is the root commit id but it
944 // enables easier location of code comments againt
945 // code that makes it into the main branch, assuming
946 // the commit id is correct
947 Tag::from_standardized(TagStandard::Reference(commit.to_string())),
948 Tag::custom(
949 TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
950 vec![format!(
951 "git patch: {}",
952 git_repo
953 .get_commit_message_summary(commit)
954 .unwrap_or_default()
955 )],
956 ),
957 ],
958 if let Some(thread_event_id) = thread_event_id {
959 vec![Tag::from_standardized(nostr_sdk::TagStandard::Event {
960 event_id: thread_event_id,
961 relay_url: relay_hint.clone(),
962 marker: Some(Marker::Root),
963 public_key: None,
964 })]
965 } else if let Some(event_ref) = root_proposal_id.clone() {
966 vec![
967 Tag::hashtag("root"),
968 Tag::hashtag("revision-root"),
969 // TODO check if id is for a root proposal (perhaps its for an issue?)
970 event_tag_from_nip19_or_hex(
971 &event_ref,
972 "proposal",
973 Marker::Reply,
974 false,
975 false,
976 )?,
977 ]
978 } else {
979 vec![Tag::hashtag("root")]
980 },
981 mentions.to_vec(),
982 if let Some(id) = parent_patch_event_id {
983 vec![Tag::from_standardized(nostr_sdk::TagStandard::Event {
984 event_id: id,
985 relay_url: relay_hint.clone(),
986 marker: Some(Marker::Reply),
987 public_key: None,
988 })]
989 } else {
990 vec![]
991 },
992 // see comment on branch names in cover letter event creation
993 if let Some(branch_name) = branch_name {
994 if thread_event_id.is_none() {
995 vec![Tag::custom(
996 TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")),
997 vec![branch_name.to_string()],
998 )]
999 } else {
1000 vec![]
1001 }
1002 } else {
1003 vec![]
1004 },
1005 // whilst it is in nip34 draft to tag the maintainers
1006 // I'm not sure it is a good idea because if they are
1007 // interested in all patches then their specialised
1008 // client should subscribe to patches tagged with the
1009 // repo reference. maintainers of large repos will not
1010 // be interested in every patch.
1011 repo_ref
1012 .maintainers
1013 .iter()
1014 .map(|pk| Tag::public_key(*pk))
1015 .collect(),
1016 vec![
1017 // a fallback is now in place to extract this from the patch
1018 Tag::custom(
1019 TagKind::Custom(std::borrow::Cow::Borrowed("commit")),
1020 vec![commit.to_string()],
1021 ),
1022 // this is required as patches cannot be relied upon to include the 'base
1023 // commit'
1024 Tag::custom(
1025 TagKind::Custom(std::borrow::Cow::Borrowed("parent-commit")),
1026 vec![commit_parent.to_string()],
1027 ),
1028 // this is required to ensure the commit id matches
1029 Tag::custom(
1030 TagKind::Custom(std::borrow::Cow::Borrowed("commit-pgp-sig")),
1031 vec![
1032 git_repo
1033 .extract_commit_pgp_signature(commit)
1034 .unwrap_or_default(),
1035 ],
1036 ),
1037 // removing description tag will not cause anything to break
1038 Tag::from_standardized(nostr_sdk::TagStandard::Description(
1039 git_repo.get_commit_message(commit)?.to_string(),
1040 )),
1041 Tag::custom(
1042 TagKind::Custom(std::borrow::Cow::Borrowed("author")),
1043 git_repo.get_commit_author(commit)?,
1044 ),
1045 // this is required to ensure the commit id matches
1046 Tag::custom(
1047 TagKind::Custom(std::borrow::Cow::Borrowed("committer")),
1048 git_repo.get_commit_comitter(commit)?,
1049 ),
1050 ],
1051 ]
1052 .concat(),
1053 ),
1054 signer,
1055 )
1056 .await
1057 .context("failed to sign event")
1058}
1059// TODO 408// TODO
1060// - find profile 409// - find profile
1061// - file relays 410// - file relays
1062// - find repo events 411// - find repo events
1063// - 412// -
1064
1065/**
1066 * returns `(from_branch,to_branch,ahead,behind)`
1067 */
1068pub fn identify_ahead_behind(
1069 git_repo: &Repo,
1070 from_branch: &Option<String>,
1071 to_branch: &Option<String>,
1072) -> Result<(String, String, Vec<Sha1Hash>, Vec<Sha1Hash>)> {
1073 let (from_branch, from_tip) = match from_branch {
1074 Some(name) => (
1075 name.to_string(),
1076 git_repo
1077 .get_tip_of_branch(name)
1078 .context(format!("cannot find from_branch '{name}'"))?,
1079 ),
1080 None => (
1081 if let Ok(name) = git_repo.get_checked_out_branch_name() {
1082 name
1083 } else {
1084 "head".to_string()
1085 },
1086 git_repo
1087 .get_head_commit()
1088 .context("failed to get head commit")
1089 .context(
1090 "checkout a commit or specify a from_branch. head does not reveal a commit",
1091 )?,
1092 ),
1093 };
1094
1095 let (to_branch, to_tip) = match to_branch {
1096 Some(name) => (
1097 name.to_string(),
1098 git_repo
1099 .get_tip_of_branch(name)
1100 .context(format!("cannot find to_branch '{name}'"))?,
1101 ),
1102 None => {
1103 let (name, commit) = git_repo
1104 .get_main_or_master_branch()
1105 .context("the default branches (main or master) do not exist")?;
1106 (name.to_string(), commit)
1107 }
1108 };
1109
1110 match git_repo.get_commits_ahead_behind(&to_tip, &from_tip) {
1111 Err(e) => {
1112 if e.to_string().contains("is not an ancestor of") {
1113 return Err(e).context(format!(
1114 "'{from_branch}' is not branched from '{to_branch}'"
1115 ));
1116 }
1117 Err(e).context(format!(
1118 "failed to get commits ahead and behind from '{from_branch}' to '{to_branch}'"
1119 ))
1120 }
1121 Ok((ahead, behind)) => Ok((from_branch, to_branch, ahead, behind)),
1122 }
1123}
1124
1125#[cfg(test)]
1126mod tests {
1127 use test_utils::git::GitTestRepo;
1128
1129 use super::*;
1130 mod identify_ahead_behind {
1131
1132 use super::*;
1133 use crate::git::oid_to_sha1;
1134
1135 #[test]
1136 fn when_from_branch_doesnt_exist_return_error() -> Result<()> {
1137 let test_repo = GitTestRepo::default();
1138 let git_repo = Repo::from_path(&test_repo.dir)?;
1139
1140 test_repo.populate()?;
1141 let branch_name = "doesnt_exist";
1142 assert_eq!(
1143 identify_ahead_behind(&git_repo, &Some(branch_name.to_string()), &None)
1144 .unwrap_err()
1145 .to_string(),
1146 format!("cannot find from_branch '{}'", &branch_name),
1147 );
1148 Ok(())
1149 }
1150
1151 #[test]
1152 fn when_to_branch_doesnt_exist_return_error() -> Result<()> {
1153 let test_repo = GitTestRepo::default();
1154 let git_repo = Repo::from_path(&test_repo.dir)?;
1155
1156 test_repo.populate()?;
1157 let branch_name = "doesnt_exist";
1158 assert_eq!(
1159 identify_ahead_behind(&git_repo, &None, &Some(branch_name.to_string()))
1160 .unwrap_err()
1161 .to_string(),
1162 format!("cannot find to_branch '{}'", &branch_name),
1163 );
1164 Ok(())
1165 }
1166
1167 #[test]
1168 fn when_to_branch_is_none_and_no_main_or_master_branch_return_error() -> Result<()> {
1169 let test_repo = GitTestRepo::new("notmain")?;
1170 let git_repo = Repo::from_path(&test_repo.dir)?;
1171
1172 test_repo.populate()?;
1173
1174 assert_eq!(
1175 identify_ahead_behind(&git_repo, &None, &None)
1176 .unwrap_err()
1177 .to_string(),
1178 "the default branches (main or master) do not exist",
1179 );
1180 Ok(())
1181 }
1182
1183 #[test]
1184 fn when_from_branch_is_not_head_return_as_from_branch() -> Result<()> {
1185 let test_repo = GitTestRepo::default();
1186 let git_repo = Repo::from_path(&test_repo.dir)?;
1187
1188 test_repo.populate()?;
1189 // create feature branch with 1 commit ahead
1190 test_repo.create_branch("feature")?;
1191 test_repo.checkout("feature")?;
1192 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
1193 let head_oid = test_repo.stage_and_commit("add t3.md")?;
1194
1195 // make feature branch 1 commit behind
1196 test_repo.checkout("main")?;
1197 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
1198 let main_oid = test_repo.stage_and_commit("add t4.md")?;
1199
1200 let (from_branch, to_branch, ahead, behind) =
1201 identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?;
1202
1203 assert_eq!(from_branch, "feature");
1204 assert_eq!(ahead, vec![oid_to_sha1(&head_oid)]);
1205 assert_eq!(to_branch, "main");
1206 assert_eq!(behind, vec![oid_to_sha1(&main_oid)]);
1207 Ok(())
1208 }
1209
1210 #[test]
1211 fn when_to_branch_is_not_main_return_as_to_branch() -> Result<()> {
1212 let test_repo = GitTestRepo::default();
1213 let git_repo = Repo::from_path(&test_repo.dir)?;
1214
1215 test_repo.populate()?;
1216 // create dev branch with 1 commit ahead
1217 test_repo.create_branch("dev")?;
1218 test_repo.checkout("dev")?;
1219 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
1220 let dev_oid_first = test_repo.stage_and_commit("add t3.md")?;
1221
1222 // create feature branch with 1 commit ahead of dev
1223 test_repo.create_branch("feature")?;
1224 test_repo.checkout("feature")?;
1225 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
1226 let feature_oid = test_repo.stage_and_commit("add t4.md")?;
1227
1228 // make feature branch 1 behind
1229 test_repo.checkout("dev")?;
1230 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
1231 let dev_oid = test_repo.stage_and_commit("add t3.md")?;
1232
1233 let (from_branch, to_branch, ahead, behind) = identify_ahead_behind(
1234 &git_repo,
1235 &Some("feature".to_string()),
1236 &Some("dev".to_string()),
1237 )?;
1238
1239 assert_eq!(from_branch, "feature");
1240 assert_eq!(ahead, vec![oid_to_sha1(&feature_oid)]);
1241 assert_eq!(to_branch, "dev");
1242 assert_eq!(behind, vec![oid_to_sha1(&dev_oid)]);
1243
1244 let (from_branch, to_branch, ahead, behind) =
1245 identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?;
1246
1247 assert_eq!(from_branch, "feature");
1248 assert_eq!(
1249 ahead,
1250 vec![oid_to_sha1(&feature_oid), oid_to_sha1(&dev_oid_first)]
1251 );
1252 assert_eq!(to_branch, "main");
1253 assert_eq!(behind, vec![]);
1254
1255 Ok(())
1256 }
1257 }
1258
1259 mod event_to_cover_letter {
1260 use super::*;
1261
1262 fn generate_cover_letter(title: &str, description: &str) -> Result<nostr::Event> {
1263 Ok(nostr::event::EventBuilder::new(
1264 nostr::event::Kind::GitPatch,
1265 format!("From ea897e987ea9a7a98e7a987e97987ea98e7a3334 Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/2] {title}\n\n{description}"),
1266 [
1267 Tag::hashtag("cover-letter"),
1268 Tag::hashtag("root"),
1269 ],
1270 )
1271 .to_event(&nostr::Keys::generate())?)
1272 }
1273
1274 #[test]
1275 fn basic_title() -> Result<()> {
1276 assert_eq!(
1277 event_to_cover_letter(&generate_cover_letter("the title", "description here")?)?
1278 .title,
1279 "the title",
1280 );
1281 Ok(())
1282 }
1283
1284 #[test]
1285 fn basic_description() -> Result<()> {
1286 assert_eq!(
1287 event_to_cover_letter(&generate_cover_letter("the title", "description here")?)?
1288 .description,
1289 "description here",
1290 );
1291 Ok(())
1292 }
1293
1294 #[test]
1295 fn description_trimmed() -> Result<()> {
1296 assert_eq!(
1297 event_to_cover_letter(&generate_cover_letter(
1298 "the title",
1299 " \n \ndescription here\n\n "
1300 )?)?
1301 .description,
1302 "description here",
1303 );
1304 Ok(())
1305 }
1306
1307 #[test]
1308 fn multi_line_description() -> Result<()> {
1309 assert_eq!(
1310 event_to_cover_letter(&generate_cover_letter(
1311 "the title",
1312 "description here\n\nmore here\nmore"
1313 )?)?
1314 .description,
1315 "description here\n\nmore here\nmore",
1316 );
1317 Ok(())
1318 }
1319
1320 #[test]
1321 fn new_lines_in_title_forms_part_of_description() -> Result<()> {
1322 assert_eq!(
1323 event_to_cover_letter(&generate_cover_letter(
1324 "the title\nwith new line",
1325 "description here\n\nmore here\nmore"
1326 )?)?
1327 .title,
1328 "the title",
1329 );
1330 assert_eq!(
1331 event_to_cover_letter(&generate_cover_letter(
1332 "the title\nwith new line",
1333 "description here\n\nmore here\nmore"
1334 )?)?
1335 .description,
1336 "with new line\n\ndescription here\n\nmore here\nmore",
1337 );
1338 Ok(())
1339 }
1340
1341 mod blank_description {
1342 use super::*;
1343
1344 #[test]
1345 fn title_correct() -> Result<()> {
1346 assert_eq!(
1347 event_to_cover_letter(&generate_cover_letter("the title", "")?)?.title,
1348 "the title",
1349 );
1350 Ok(())
1351 }
1352
1353 #[test]
1354 fn description_is_empty_string() -> Result<()> {
1355 assert_eq!(
1356 event_to_cover_letter(&generate_cover_letter("the title", "")?)?.description,
1357 "",
1358 );
1359 Ok(())
1360 }
1361 }
1362 }
1363}
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::{
21use anyhow::{bail, Context, Result}; 21use anyhow::{bail, Context, Result};
22use async_trait::async_trait; 22use async_trait::async_trait;
23use console::Style; 23use console::Style;
24use futures::stream::{self, StreamExt}; 24use futures::{
25use indicatif::{MultiProgress, ProgressBar, ProgressState, ProgressStyle}; 25 future::join_all,
26 stream::{self, StreamExt},
27};
28use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressState, ProgressStyle};
26#[cfg(test)] 29#[cfg(test)]
27use mockall::*; 30use mockall::*;
28use nostr::{nips::nip01::Coordinate, Event}; 31use nostr::{nips::nip01::Coordinate, Event};
@@ -34,14 +37,13 @@ use nostr_sdk::{
34use nostr_sqlite::SQLiteDatabase; 37use nostr_sqlite::SQLiteDatabase;
35 38
36use crate::{ 39use crate::{
37 config::get_dirs, 40 get_dirs,
41 git_events::{
42 event_is_cover_letter, event_is_patch_set_root, event_is_revision_root, status_kinds,
43 },
38 login::{get_logged_in_user, get_user_ref_from_cache}, 44 login::{get_logged_in_user, get_user_ref_from_cache},
39 repo_ref::RepoRef, 45 repo_ref::RepoRef,
40 repo_state::RepoState, 46 repo_state::RepoState,
41 sub_commands::{
42 list::status_kinds,
43 send::{event_is_patch_set_root, event_is_revision_root},
44 },
45}; 47};
46 48
47#[allow(clippy::struct_field_names)] 49#[allow(clippy::struct_field_names)]
@@ -1478,3 +1480,260 @@ pub async fn fetching_with_report(
1478 } 1480 }
1479 Ok(report) 1481 Ok(report)
1480} 1482}
1483
1484pub async fn get_proposals_and_revisions_from_cache(
1485 git_repo_path: &Path,
1486 repo_coordinates: HashSet<Coordinate>,
1487) -> Result<Vec<nostr::Event>> {
1488 let mut proposals = get_events_from_cache(
1489 git_repo_path,
1490 vec![
1491 nostr::Filter::default()
1492 .kind(nostr::Kind::GitPatch)
1493 .custom_tag(
1494 nostr::SingleLetterTag::lowercase(nostr_sdk::Alphabet::A),
1495 repo_coordinates
1496 .iter()
1497 .map(std::string::ToString::to_string)
1498 .collect::<Vec<String>>(),
1499 ),
1500 ],
1501 )
1502 .await?
1503 .iter()
1504 .filter(|e| event_is_patch_set_root(e))
1505 .cloned()
1506 .collect::<Vec<nostr::Event>>();
1507 proposals.sort_by_key(|e| e.created_at);
1508 proposals.reverse();
1509 Ok(proposals)
1510}
1511
1512pub async fn get_all_proposal_patch_events_from_cache(
1513 git_repo_path: &Path,
1514 repo_ref: &RepoRef,
1515 proposal_id: &nostr::EventId,
1516) -> Result<Vec<nostr::Event>> {
1517 let mut commit_events = get_events_from_cache(
1518 git_repo_path,
1519 vec![
1520 nostr::Filter::default()
1521 .kind(nostr::Kind::GitPatch)
1522 .event(*proposal_id),
1523 nostr::Filter::default()
1524 .kind(nostr::Kind::GitPatch)
1525 .id(*proposal_id),
1526 ],
1527 )
1528 .await?;
1529
1530 let permissioned_users: HashSet<PublicKey> = [
1531 repo_ref.maintainers.clone(),
1532 vec![
1533 commit_events
1534 .iter()
1535 .find(|e| e.id().eq(proposal_id))
1536 .context("proposal not in cache")?
1537 .author(),
1538 ],
1539 ]
1540 .concat()
1541 .iter()
1542 .copied()
1543 .collect();
1544 commit_events.retain(|e| permissioned_users.contains(&e.author()));
1545
1546 let revision_roots: HashSet<nostr::EventId> = commit_events
1547 .iter()
1548 .filter(|e| event_is_revision_root(e))
1549 .map(nostr::Event::id)
1550 .collect();
1551
1552 if !revision_roots.is_empty() {
1553 for event in get_events_from_cache(
1554 git_repo_path,
1555 vec![
1556 nostr::Filter::default()
1557 .kind(nostr::Kind::GitPatch)
1558 .events(revision_roots)
1559 .authors(permissioned_users.clone()),
1560 ],
1561 )
1562 .await?
1563 {
1564 commit_events.push(event);
1565 }
1566 }
1567
1568 Ok(commit_events
1569 .iter()
1570 .filter(|e| !event_is_cover_letter(e) && permissioned_users.contains(&e.author()))
1571 .cloned()
1572 .collect())
1573}
1574
1575#[allow(clippy::module_name_repetitions)]
1576#[allow(clippy::too_many_lines)]
1577pub async fn send_events(
1578 #[cfg(test)] client: &crate::client::MockConnect,
1579 #[cfg(not(test))] client: &Client,
1580 git_repo_path: &Path,
1581 events: Vec<nostr::Event>,
1582 my_write_relays: Vec<String>,
1583 repo_read_relays: Vec<String>,
1584 animate: bool,
1585 silent: bool,
1586) -> Result<()> {
1587 let fallback = [
1588 client.get_fallback_relays().clone(),
1589 if events
1590 .iter()
1591 .any(|e| e.kind().eq(&Kind::GitRepoAnnouncement))
1592 {
1593 client.get_blaster_relays().clone()
1594 } else {
1595 vec![]
1596 },
1597 ]
1598 .concat();
1599 let mut relays: Vec<&String> = vec![];
1600
1601 let all = &[
1602 repo_read_relays.clone(),
1603 my_write_relays.clone(),
1604 fallback.clone(),
1605 ]
1606 .concat();
1607 // add duplicates first
1608 for r in &repo_read_relays {
1609 let r_clean = remove_trailing_slash(r);
1610 if !my_write_relays
1611 .iter()
1612 .filter(|x| r_clean.eq(&remove_trailing_slash(x)))
1613 .count()
1614 > 1
1615 && !relays.iter().any(|x| r_clean.eq(&remove_trailing_slash(x)))
1616 {
1617 relays.push(r);
1618 }
1619 }
1620
1621 for r in all {
1622 let r_clean = remove_trailing_slash(r);
1623 if !relays.iter().any(|x| r_clean.eq(&remove_trailing_slash(x))) {
1624 relays.push(r);
1625 }
1626 }
1627
1628 let m = if silent {
1629 MultiProgress::with_draw_target(ProgressDrawTarget::hidden())
1630 } else {
1631 MultiProgress::new()
1632 };
1633 let pb_style = ProgressStyle::with_template(if animate {
1634 " {spinner} {prefix} {bar} {pos}/{len} {msg}"
1635 } else {
1636 " - {prefix} {bar} {pos}/{len} {msg}"
1637 })?
1638 .progress_chars("##-");
1639
1640 let pb_after_style =
1641 |symbol| ProgressStyle::with_template(format!(" {symbol} {}", "{prefix} {msg}",).as_str());
1642 let pb_after_style_succeeded = pb_after_style(if animate {
1643 console::style("✔".to_string())
1644 .for_stderr()
1645 .green()
1646 .to_string()
1647 } else {
1648 "y".to_string()
1649 })?;
1650
1651 let pb_after_style_failed = pb_after_style(if animate {
1652 console::style("✘".to_string())
1653 .for_stderr()
1654 .red()
1655 .to_string()
1656 } else {
1657 "x".to_string()
1658 })?;
1659
1660 #[allow(clippy::borrow_deref_ref)]
1661 join_all(relays.iter().map(|&relay| async {
1662 let relay_clean = remove_trailing_slash(&*relay);
1663 let details = format!(
1664 "{}{}{} {}",
1665 if my_write_relays
1666 .iter()
1667 .any(|r| relay_clean.eq(&remove_trailing_slash(r)))
1668 {
1669 " [my-relay]"
1670 } else {
1671 ""
1672 },
1673 if repo_read_relays
1674 .iter()
1675 .any(|r| relay_clean.eq(&remove_trailing_slash(r)))
1676 {
1677 " [repo-relay]"
1678 } else {
1679 ""
1680 },
1681 if fallback
1682 .iter()
1683 .any(|r| relay_clean.eq(&remove_trailing_slash(r)))
1684 {
1685 " [default]"
1686 } else {
1687 ""
1688 },
1689 relay_clean,
1690 );
1691 let pb = m.add(
1692 ProgressBar::new(events.len() as u64)
1693 .with_prefix(details.to_string())
1694 .with_style(pb_style.clone()),
1695 );
1696 if animate {
1697 pb.enable_steady_tick(Duration::from_millis(300));
1698 }
1699 pb.inc(0); // need to make pb display intially
1700 let mut failed = false;
1701 for event in &events {
1702 match client
1703 .send_event_to(git_repo_path, relay.as_str(), event.clone())
1704 .await
1705 {
1706 Ok(_) => pb.inc(1),
1707 Err(e) => {
1708 pb.set_style(pb_after_style_failed.clone());
1709 pb.finish_with_message(
1710 console::style(
1711 e.to_string()
1712 .replace("relay pool error:", "error:")
1713 .replace("event not published: ", "error: "),
1714 )
1715 .for_stderr()
1716 .red()
1717 .to_string(),
1718 );
1719 failed = true;
1720 break;
1721 }
1722 };
1723 }
1724 if !failed {
1725 pb.set_style(pb_after_style_succeeded.clone());
1726 pb.finish_with_message("");
1727 }
1728 }))
1729 .await;
1730 Ok(())
1731}
1732
1733fn remove_trailing_slash(s: &String) -> String {
1734 match s.as_str().strip_suffix('/') {
1735 Some(s) => s,
1736 None => s,
1737 }
1738 .to_string()
1739}
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 @@
1use anyhow::{Context, Result};
2use nostr_sdk::hashes::sha1::Hash as Sha1Hash;
3
4use super::{Repo, RepoActions};
5
6/**
7 * returns `(from_branch,to_branch,ahead,behind)`
8 */
9pub fn identify_ahead_behind(
10 git_repo: &Repo,
11 from_branch: &Option<String>,
12 to_branch: &Option<String>,
13) -> Result<(String, String, Vec<Sha1Hash>, Vec<Sha1Hash>)> {
14 let (from_branch, from_tip) = match from_branch {
15 Some(name) => (
16 name.to_string(),
17 git_repo
18 .get_tip_of_branch(name)
19 .context(format!("cannot find from_branch '{name}'"))?,
20 ),
21 None => (
22 if let Ok(name) = git_repo.get_checked_out_branch_name() {
23 name
24 } else {
25 "head".to_string()
26 },
27 git_repo
28 .get_head_commit()
29 .context("failed to get head commit")
30 .context(
31 "checkout a commit or specify a from_branch. head does not reveal a commit",
32 )?,
33 ),
34 };
35
36 let (to_branch, to_tip) = match to_branch {
37 Some(name) => (
38 name.to_string(),
39 git_repo
40 .get_tip_of_branch(name)
41 .context(format!("cannot find to_branch '{name}'"))?,
42 ),
43 None => {
44 let (name, commit) = git_repo
45 .get_main_or_master_branch()
46 .context("the default branches (main or master) do not exist")?;
47 (name.to_string(), commit)
48 }
49 };
50
51 match git_repo.get_commits_ahead_behind(&to_tip, &from_tip) {
52 Err(e) => {
53 if e.to_string().contains("is not an ancestor of") {
54 return Err(e).context(format!(
55 "'{from_branch}' is not branched from '{to_branch}'"
56 ));
57 }
58 Err(e).context(format!(
59 "failed to get commits ahead and behind from '{from_branch}' to '{to_branch}'"
60 ))
61 }
62 Ok((ahead, behind)) => Ok((from_branch, to_branch, ahead, behind)),
63 }
64}
65
66#[cfg(test)]
67mod tests {
68
69 use test_utils::git::GitTestRepo;
70
71 use super::*;
72 use crate::git::oid_to_sha1;
73
74 #[test]
75 fn when_from_branch_doesnt_exist_return_error() -> Result<()> {
76 let test_repo = GitTestRepo::default();
77 let git_repo = Repo::from_path(&test_repo.dir)?;
78
79 test_repo.populate()?;
80 let branch_name = "doesnt_exist";
81 assert_eq!(
82 identify_ahead_behind(&git_repo, &Some(branch_name.to_string()), &None)
83 .unwrap_err()
84 .to_string(),
85 format!("cannot find from_branch '{}'", &branch_name),
86 );
87 Ok(())
88 }
89
90 #[test]
91 fn when_to_branch_doesnt_exist_return_error() -> Result<()> {
92 let test_repo = GitTestRepo::default();
93 let git_repo = Repo::from_path(&test_repo.dir)?;
94
95 test_repo.populate()?;
96 let branch_name = "doesnt_exist";
97 assert_eq!(
98 identify_ahead_behind(&git_repo, &None, &Some(branch_name.to_string()))
99 .unwrap_err()
100 .to_string(),
101 format!("cannot find to_branch '{}'", &branch_name),
102 );
103 Ok(())
104 }
105
106 #[test]
107 fn when_to_branch_is_none_and_no_main_or_master_branch_return_error() -> Result<()> {
108 let test_repo = GitTestRepo::new("notmain")?;
109 let git_repo = Repo::from_path(&test_repo.dir)?;
110
111 test_repo.populate()?;
112
113 assert_eq!(
114 identify_ahead_behind(&git_repo, &None, &None)
115 .unwrap_err()
116 .to_string(),
117 "the default branches (main or master) do not exist",
118 );
119 Ok(())
120 }
121
122 #[test]
123 fn when_from_branch_is_not_head_return_as_from_branch() -> Result<()> {
124 let test_repo = GitTestRepo::default();
125 let git_repo = Repo::from_path(&test_repo.dir)?;
126
127 test_repo.populate()?;
128 // create feature branch with 1 commit ahead
129 test_repo.create_branch("feature")?;
130 test_repo.checkout("feature")?;
131 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
132 let head_oid = test_repo.stage_and_commit("add t3.md")?;
133
134 // make feature branch 1 commit behind
135 test_repo.checkout("main")?;
136 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
137 let main_oid = test_repo.stage_and_commit("add t4.md")?;
138
139 let (from_branch, to_branch, ahead, behind) =
140 identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?;
141
142 assert_eq!(from_branch, "feature");
143 assert_eq!(ahead, vec![oid_to_sha1(&head_oid)]);
144 assert_eq!(to_branch, "main");
145 assert_eq!(behind, vec![oid_to_sha1(&main_oid)]);
146 Ok(())
147 }
148
149 #[test]
150 fn when_to_branch_is_not_main_return_as_to_branch() -> Result<()> {
151 let test_repo = GitTestRepo::default();
152 let git_repo = Repo::from_path(&test_repo.dir)?;
153
154 test_repo.populate()?;
155 // create dev branch with 1 commit ahead
156 test_repo.create_branch("dev")?;
157 test_repo.checkout("dev")?;
158 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
159 let dev_oid_first = test_repo.stage_and_commit("add t3.md")?;
160
161 // create feature branch with 1 commit ahead of dev
162 test_repo.create_branch("feature")?;
163 test_repo.checkout("feature")?;
164 std::fs::write(test_repo.dir.join("t4.md"), "some content")?;
165 let feature_oid = test_repo.stage_and_commit("add t4.md")?;
166
167 // make feature branch 1 behind
168 test_repo.checkout("dev")?;
169 std::fs::write(test_repo.dir.join("t3.md"), "some content")?;
170 let dev_oid = test_repo.stage_and_commit("add t3.md")?;
171
172 let (from_branch, to_branch, ahead, behind) = identify_ahead_behind(
173 &git_repo,
174 &Some("feature".to_string()),
175 &Some("dev".to_string()),
176 )?;
177
178 assert_eq!(from_branch, "feature");
179 assert_eq!(ahead, vec![oid_to_sha1(&feature_oid)]);
180 assert_eq!(to_branch, "dev");
181 assert_eq!(behind, vec![oid_to_sha1(&dev_oid)]);
182
183 let (from_branch, to_branch, ahead, behind) =
184 identify_ahead_behind(&git_repo, &Some("feature".to_string()), &None)?;
185
186 assert_eq!(from_branch, "feature");
187 assert_eq!(
188 ahead,
189 vec![oid_to_sha1(&feature_oid), oid_to_sha1(&dev_oid_first)]
190 );
191 assert_eq!(to_branch, "main");
192 assert_eq!(behind, vec![]);
193
194 Ok(())
195 }
196}
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 @@
1use std::{ 1use std::{
2 collections::HashSet,
3 env::current_dir, 2 env::current_dir,
4 path::{Path, PathBuf}, 3 path::{Path, PathBuf},
5}; 4};
6 5
7use anyhow::{bail, Context, Result}; 6use anyhow::{bail, Context, Result};
8use git2::{DiffOptions, Oid, Revwalk}; 7use git2::{DiffOptions, Oid, Revwalk};
9use nostr::nips::nip01::Coordinate; 8pub use identify_ahead_behind::identify_ahead_behind;
10use nostr_sdk::{ 9use nostr_sdk::hashes::{sha1::Hash as Sha1Hash, Hash};
11 hashes::{sha1::Hash as Sha1Hash, Hash},
12 PublicKey, Url,
13};
14 10
15use crate::sub_commands::list::{get_commit_id_from_patch, tag_value}; 11use crate::git_events::{get_commit_id_from_patch, tag_value};
12pub mod identify_ahead_behind;
13pub mod nostr_url;
16 14
17pub struct Repo { 15pub struct Repo {
18 pub git_repo: git2::Repository, 16 pub git_repo: git2::Repository,
@@ -835,188 +833,6 @@ fn extract_sig_from_patch_tags<'a>(
835 .context("failed to create git signature") 833 .context("failed to create git signature")
836} 834}
837 835
838#[derive(Debug, PartialEq)]
839pub enum ServerProtocol {
840 Ssh,
841 Https,
842 Http,
843 Git,
844}
845
846#[derive(Debug, PartialEq)]
847pub struct NostrUrlDecoded {
848 pub coordinates: HashSet<Coordinate>,
849 pub protocol: Option<ServerProtocol>,
850 pub user: Option<String>,
851}
852
853static 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";
854
855impl NostrUrlDecoded {
856 pub fn from_str(url: &str) -> Result<Self> {
857 let mut coordinates = HashSet::new();
858 let mut protocol = None;
859 let mut user = None;
860 let mut relays = vec![];
861
862 if !url.starts_with("nostr://") {
863 bail!("nostr git url must start with nostr://");
864 }
865 // process get url parameters if present
866 for (name, value) in Url::parse(url)?.query_pairs() {
867 if name.contains("relay") {
868 let mut decoded = urlencoding::decode(&value)
869 .context("could not parse relays in nostr git url")?
870 .to_string();
871 if !decoded.starts_with("ws://") && !decoded.starts_with("wss://") {
872 decoded = format!("wss://{decoded}");
873 }
874 let url =
875 Url::parse(&decoded).context("could not parse relays in nostr git url")?;
876 relays.push(url.to_string());
877 } else if name == "protocol" {
878 protocol = match value.as_ref() {
879 "ssh" => Some(ServerProtocol::Ssh),
880 "https" => Some(ServerProtocol::Https),
881 "http" => Some(ServerProtocol::Http),
882 "git" => Some(ServerProtocol::Git),
883 _ => None,
884 };
885 } else if name == "user" {
886 user = Some(value.to_string());
887 }
888 }
889
890 let mut parts: Vec<&str> = url[8..]
891 .split('?')
892 .next()
893 .unwrap_or("")
894 .split('/')
895 .collect();
896
897 // extract optional protocol
898 if protocol.is_none() {
899 let part = parts.first().context(INCORRECT_NOSTR_URL_FORMAT_ERROR)?;
900 let protocol_str = if let Some(at_index) = part.find('@') {
901 user = Some(part[..at_index].to_string());
902 &part[at_index + 1..]
903 } else {
904 part
905 };
906 protocol = match protocol_str {
907 "ssh" => Some(ServerProtocol::Ssh),
908 "https" => Some(ServerProtocol::Https),
909 "http" => Some(ServerProtocol::Http),
910 "git" => Some(ServerProtocol::Git),
911 _ => protocol,
912 };
913 if protocol.is_some() {
914 parts.remove(0);
915 }
916 }
917 // extract naddr npub/<optional-relays>/identifer
918 let part = parts.first().context(INCORRECT_NOSTR_URL_FORMAT_ERROR)?;
919 // naddr used
920 if let Ok(coordinate) = Coordinate::parse(part) {
921 if coordinate.kind.eq(&nostr_sdk::Kind::GitRepoAnnouncement) {
922 coordinates.insert(coordinate);
923 } else {
924 bail!("naddr doesnt point to a git repository announcement");
925 }
926 // npub/<optional-relays>/identifer used
927 } else if let Ok(public_key) = PublicKey::parse(part) {
928 parts.remove(0);
929 let identifier = parts
930 .pop()
931 .context("nostr url must have an identifier eg. nostr://npub123/repo-identifier")?
932 .to_string();
933 for relay in parts {
934 let mut decoded = urlencoding::decode(relay)
935 .context("could not parse relays in nostr git url")?
936 .to_string();
937 if !decoded.starts_with("ws://") && !decoded.starts_with("wss://") {
938 decoded = format!("wss://{decoded}");
939 }
940 let url =
941 Url::parse(&decoded).context("could not parse relays in nostr git url")?;
942 relays.push(url.to_string());
943 }
944 coordinates.insert(Coordinate {
945 identifier,
946 public_key,
947 kind: nostr_sdk::Kind::GitRepoAnnouncement,
948 relays,
949 });
950 } else {
951 bail!(INCORRECT_NOSTR_URL_FORMAT_ERROR);
952 }
953
954 Ok(Self {
955 coordinates,
956 protocol,
957 user,
958 })
959 }
960}
961
962/** produce error when using local repo or custom protocols */
963pub fn convert_clone_url_to_https(url: &str) -> Result<String> {
964 // Strip credentials if present
965 let stripped_url = strip_credentials(url);
966
967 // Check if the URL is already in HTTPS format
968 if stripped_url.starts_with("https://") {
969 return Ok(stripped_url);
970 }
971 // Convert http:// to https://
972 else if stripped_url.starts_with("http://") {
973 return Ok(stripped_url.replace("http://", "https://"));
974 }
975 // Check if the URL starts with SSH
976 else if stripped_url.starts_with("ssh://") {
977 // Convert SSH to HTTPS
978 let parts: Vec<&str> = stripped_url
979 .trim_start_matches("ssh://")
980 .split('/')
981 .collect();
982 if parts.len() >= 2 {
983 // Construct the HTTPS URL
984 return Ok(format!("https://{}/{}", parts[0], parts[1..].join("/")));
985 }
986 bail!("Invalid SSH URL format: {}", url);
987 }
988 // Convert ftp:// to https://
989 else if stripped_url.starts_with("ftp://") {
990 return Ok(stripped_url.replace("ftp://", "https://"));
991 }
992 // Convert git:// to https://
993 else if stripped_url.starts_with("git://") {
994 return Ok(stripped_url.replace("git://", "https://"));
995 }
996
997 // If the URL is neither HTTPS, SSH, nor git@, return an error
998 bail!("Unsupported URL protocol: {}", url);
999}
1000
1001// Function to strip username and password from the URL
1002fn strip_credentials(url: &str) -> String {
1003 if let Some(pos) = url.find("://") {
1004 let (protocol, rest) = url.split_at(pos + 3); // Split at "://"
1005 let rest_parts: Vec<&str> = rest.split('@').collect();
1006 if rest_parts.len() > 1 {
1007 // If there are credentials, return the URL without them
1008 return format!("{}{}", protocol, rest_parts[1]);
1009 }
1010 } else if let Some(at_pos) = url.find('@') {
1011 // Handle user@host:path format
1012 let (_, rest) = url.split_at(at_pos);
1013 // This is a git@ syntax
1014 let host_and_repo = &rest[1..]; // Skip the ':'
1015 return format!("ssh://{}", host_and_repo.replace(':', "/"));
1016 }
1017 url.to_string() // Return the original URL if no credentials are found
1018}
1019
1020#[cfg(test)] 836#[cfg(test)]
1021mod tests { 837mod tests {
1022 use std::fs; 838 use std::fs;
@@ -1813,7 +1629,7 @@ mod tests {
1813 use test_utils::TEST_KEY_1_SIGNER; 1629 use test_utils::TEST_KEY_1_SIGNER;
1814 1630
1815 use super::*; 1631 use super::*;
1816 use crate::{repo_ref::RepoRef, sub_commands::send::generate_patch_event}; 1632 use crate::{git_events::generate_patch_event, repo_ref::RepoRef};
1817 1633
1818 async fn generate_patch_from_head_commit(test_repo: &GitTestRepo) -> Result<nostr::Event> { 1634 async fn generate_patch_from_head_commit(test_repo: &GitTestRepo) -> Result<nostr::Event> {
1819 let original_oid = test_repo.git_repo.head()?.peel_to_commit()?.id(); 1635 let original_oid = test_repo.git_repo.head()?.peel_to_commit()?.id();
@@ -1959,9 +1775,7 @@ mod tests {
1959 use test_utils::TEST_KEY_1_SIGNER; 1775 use test_utils::TEST_KEY_1_SIGNER;
1960 1776
1961 use super::*; 1777 use super::*;
1962 use crate::{ 1778 use crate::{git_events::generate_cover_letter_and_patch_events, repo_ref::RepoRef};
1963 repo_ref::RepoRef, sub_commands::send::generate_cover_letter_and_patch_events,
1964 };
1965 1779
1966 static BRANCH_NAME: &str = "add-example-feature"; 1780 static BRANCH_NAME: &str = "add-example-feature";
1967 // returns original_repo, cover_letter_event, patch_events 1781 // returns original_repo, cover_letter_event, patch_events
@@ -2497,70 +2311,4 @@ mod tests {
2497 Ok(()) 2311 Ok(())
2498 } 2312 }
2499 } 2313 }
2500 mod convert_clone_url_to_https {
2501 use super::*;
2502
2503 #[test]
2504 fn test_https_url() {
2505 let url = "https://github.com/user/repo.git";
2506 let result = convert_clone_url_to_https(url).unwrap();
2507 assert_eq!(result, "https://github.com/user/repo.git");
2508 }
2509
2510 #[test]
2511 fn test_http_url() {
2512 let url = "http://github.com/user/repo.git";
2513 let result = convert_clone_url_to_https(url).unwrap();
2514 assert_eq!(result, "https://github.com/user/repo.git");
2515 }
2516
2517 #[test]
2518 fn test_http_url_with_credentials() {
2519 let url = "http://username:password@github.com/user/repo.git";
2520 let result = convert_clone_url_to_https(url).unwrap();
2521 assert_eq!(result, "https://github.com/user/repo.git");
2522 }
2523
2524 #[test]
2525 fn test_git_at_url() {
2526 let url = "git@github.com:user/repo.git";
2527 let result = convert_clone_url_to_https(url).unwrap();
2528 assert_eq!(result, "https://github.com/user/repo.git");
2529 }
2530
2531 #[test]
2532 fn test_user_at_url() {
2533 let url = "user1@github.com:user/repo.git";
2534 let result = convert_clone_url_to_https(url).unwrap();
2535 assert_eq!(result, "https://github.com/user/repo.git");
2536 }
2537
2538 #[test]
2539 fn test_ssh_url() {
2540 let url = "ssh://github.com/user/repo.git";
2541 let result = convert_clone_url_to_https(url).unwrap();
2542 assert_eq!(result, "https://github.com/user/repo.git");
2543 }
2544
2545 #[test]
2546 fn test_ftp_url() {
2547 let url = "ftp://example.com/repo.git";
2548 let result = convert_clone_url_to_https(url).unwrap();
2549 assert_eq!(result, "https://example.com/repo.git");
2550 }
2551
2552 #[test]
2553 fn test_git_protocol_url() {
2554 let url = "git://example.com/repo.git";
2555 let result = convert_clone_url_to_https(url).unwrap();
2556 assert_eq!(result, "https://example.com/repo.git");
2557 }
2558
2559 #[test]
2560 fn test_invalid_url() {
2561 let url = "unsupported://example.com/repo.git";
2562 let result = convert_clone_url_to_https(url);
2563 assert!(result.is_err());
2564 }
2565 }
2566} 2314}
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 @@
1use std::collections::HashSet;
2
3use anyhow::{bail, Context, Result};
4use nostr::nips::nip01::Coordinate;
5use nostr_sdk::{PublicKey, Url};
6
7#[derive(Debug, PartialEq)]
8pub enum ServerProtocol {
9 Ssh,
10 Https,
11 Http,
12 Git,
13}
14
15#[derive(Debug, PartialEq)]
16pub struct NostrUrlDecoded {
17 pub coordinates: HashSet<Coordinate>,
18 pub protocol: Option<ServerProtocol>,
19 pub user: Option<String>,
20}
21
22static 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";
23
24impl NostrUrlDecoded {
25 pub fn from_str(url: &str) -> Result<Self> {
26 let mut coordinates = HashSet::new();
27 let mut protocol = None;
28 let mut user = None;
29 let mut relays = vec![];
30
31 if !url.starts_with("nostr://") {
32 bail!("nostr git url must start with nostr://");
33 }
34 // process get url parameters if present
35 for (name, value) in Url::parse(url)?.query_pairs() {
36 if name.contains("relay") {
37 let mut decoded = urlencoding::decode(&value)
38 .context("could not parse relays in nostr git url")?
39 .to_string();
40 if !decoded.starts_with("ws://") && !decoded.starts_with("wss://") {
41 decoded = format!("wss://{decoded}");
42 }
43 let url =
44 Url::parse(&decoded).context("could not parse relays in nostr git url")?;
45 relays.push(url.to_string());
46 } else if name == "protocol" {
47 protocol = match value.as_ref() {
48 "ssh" => Some(ServerProtocol::Ssh),
49 "https" => Some(ServerProtocol::Https),
50 "http" => Some(ServerProtocol::Http),
51 "git" => Some(ServerProtocol::Git),
52 _ => None,
53 };
54 } else if name == "user" {
55 user = Some(value.to_string());
56 }
57 }
58
59 let mut parts: Vec<&str> = url[8..]
60 .split('?')
61 .next()
62 .unwrap_or("")
63 .split('/')
64 .collect();
65
66 // extract optional protocol
67 if protocol.is_none() {
68 let part = parts.first().context(INCORRECT_NOSTR_URL_FORMAT_ERROR)?;
69 let protocol_str = if let Some(at_index) = part.find('@') {
70 user = Some(part[..at_index].to_string());
71 &part[at_index + 1..]
72 } else {
73 part
74 };
75 protocol = match protocol_str {
76 "ssh" => Some(ServerProtocol::Ssh),
77 "https" => Some(ServerProtocol::Https),
78 "http" => Some(ServerProtocol::Http),
79 "git" => Some(ServerProtocol::Git),
80 _ => protocol,
81 };
82 if protocol.is_some() {
83 parts.remove(0);
84 }
85 }
86 // extract naddr npub/<optional-relays>/identifer
87 let part = parts.first().context(INCORRECT_NOSTR_URL_FORMAT_ERROR)?;
88 // naddr used
89 if let Ok(coordinate) = Coordinate::parse(part) {
90 if coordinate.kind.eq(&nostr_sdk::Kind::GitRepoAnnouncement) {
91 coordinates.insert(coordinate);
92 } else {
93 bail!("naddr doesnt point to a git repository announcement");
94 }
95 // npub/<optional-relays>/identifer used
96 } else if let Ok(public_key) = PublicKey::parse(part) {
97 parts.remove(0);
98 let identifier = parts
99 .pop()
100 .context("nostr url must have an identifier eg. nostr://npub123/repo-identifier")?
101 .to_string();
102 for relay in parts {
103 let mut decoded = urlencoding::decode(relay)
104 .context("could not parse relays in nostr git url")?
105 .to_string();
106 if !decoded.starts_with("ws://") && !decoded.starts_with("wss://") {
107 decoded = format!("wss://{decoded}");
108 }
109 let url =
110 Url::parse(&decoded).context("could not parse relays in nostr git url")?;
111 relays.push(url.to_string());
112 }
113 coordinates.insert(Coordinate {
114 identifier,
115 public_key,
116 kind: nostr_sdk::Kind::GitRepoAnnouncement,
117 relays,
118 });
119 } else {
120 bail!(INCORRECT_NOSTR_URL_FORMAT_ERROR);
121 }
122
123 Ok(Self {
124 coordinates,
125 protocol,
126 user,
127 })
128 }
129}
130
131/** produce error when using local repo or custom protocols */
132pub fn convert_clone_url_to_https(url: &str) -> Result<String> {
133 // Strip credentials if present
134 let stripped_url = strip_credentials(url);
135
136 // Check if the URL is already in HTTPS format
137 if stripped_url.starts_with("https://") {
138 return Ok(stripped_url);
139 }
140 // Convert http:// to https://
141 else if stripped_url.starts_with("http://") {
142 return Ok(stripped_url.replace("http://", "https://"));
143 }
144 // Check if the URL starts with SSH
145 else if stripped_url.starts_with("ssh://") {
146 // Convert SSH to HTTPS
147 let parts: Vec<&str> = stripped_url
148 .trim_start_matches("ssh://")
149 .split('/')
150 .collect();
151 if parts.len() >= 2 {
152 // Construct the HTTPS URL
153 return Ok(format!("https://{}/{}", parts[0], parts[1..].join("/")));
154 }
155 bail!("Invalid SSH URL format: {}", url);
156 }
157 // Convert ftp:// to https://
158 else if stripped_url.starts_with("ftp://") {
159 return Ok(stripped_url.replace("ftp://", "https://"));
160 }
161 // Convert git:// to https://
162 else if stripped_url.starts_with("git://") {
163 return Ok(stripped_url.replace("git://", "https://"));
164 }
165
166 // If the URL is neither HTTPS, SSH, nor git@, return an error
167 bail!("Unsupported URL protocol: {}", url);
168}
169
170// Function to strip username and password from the URL
171fn strip_credentials(url: &str) -> String {
172 if let Some(pos) = url.find("://") {
173 let (protocol, rest) = url.split_at(pos + 3); // Split at "://"
174 let rest_parts: Vec<&str> = rest.split('@').collect();
175 if rest_parts.len() > 1 {
176 // If there are credentials, return the URL without them
177 return format!("{}{}", protocol, rest_parts[1]);
178 }
179 } else if let Some(at_pos) = url.find('@') {
180 // Handle user@host:path format
181 let (_, rest) = url.split_at(at_pos);
182 // This is a git@ syntax
183 let host_and_repo = &rest[1..]; // Skip the ':'
184 return format!("ssh://{}", host_and_repo.replace(':', "/"));
185 }
186 url.to_string() // Return the original URL if no credentials are found
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 mod convert_clone_url_to_https {
193 use super::*;
194
195 #[test]
196 fn test_https_url() {
197 let url = "https://github.com/user/repo.git";
198 let result = convert_clone_url_to_https(url).unwrap();
199 assert_eq!(result, "https://github.com/user/repo.git");
200 }
201
202 #[test]
203 fn test_http_url() {
204 let url = "http://github.com/user/repo.git";
205 let result = convert_clone_url_to_https(url).unwrap();
206 assert_eq!(result, "https://github.com/user/repo.git");
207 }
208
209 #[test]
210 fn test_http_url_with_credentials() {
211 let url = "http://username:password@github.com/user/repo.git";
212 let result = convert_clone_url_to_https(url).unwrap();
213 assert_eq!(result, "https://github.com/user/repo.git");
214 }
215
216 #[test]
217 fn test_git_at_url() {
218 let url = "git@github.com:user/repo.git";
219 let result = convert_clone_url_to_https(url).unwrap();
220 assert_eq!(result, "https://github.com/user/repo.git");
221 }
222
223 #[test]
224 fn test_user_at_url() {
225 let url = "user1@github.com:user/repo.git";
226 let result = convert_clone_url_to_https(url).unwrap();
227 assert_eq!(result, "https://github.com/user/repo.git");
228 }
229
230 #[test]
231 fn test_ssh_url() {
232 let url = "ssh://github.com/user/repo.git";
233 let result = convert_clone_url_to_https(url).unwrap();
234 assert_eq!(result, "https://github.com/user/repo.git");
235 }
236
237 #[test]
238 fn test_ftp_url() {
239 let url = "ftp://example.com/repo.git";
240 let result = convert_clone_url_to_https(url).unwrap();
241 assert_eq!(result, "https://example.com/repo.git");
242 }
243
244 #[test]
245 fn test_git_protocol_url() {
246 let url = "git://example.com/repo.git";
247 let result = convert_clone_url_to_https(url).unwrap();
248 assert_eq!(result, "https://example.com/repo.git");
249 }
250
251 #[test]
252 fn test_invalid_url() {
253 let url = "unsupported://example.com/repo.git";
254 let result = convert_clone_url_to_https(url);
255 assert!(result.is_err());
256 }
257 }
258
259 mod nostr_git_url_paramemters_from_str {
260 use super::*;
261
262 fn get_model_coordinate(relays: bool) -> Coordinate {
263 Coordinate {
264 identifier: "ngit".to_string(),
265 public_key: PublicKey::parse(
266 "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr",
267 )
268 .unwrap(),
269 kind: nostr_sdk::Kind::GitRepoAnnouncement,
270 relays: if relays {
271 vec!["wss://nos.lol/".to_string()]
272 } else {
273 vec![]
274 },
275 }
276 }
277
278 #[test]
279 fn from_naddr() -> Result<()> {
280 assert_eq!(
281 NostrUrlDecoded::from_str(
282 "nostr://naddr1qqzxuemfwsqs6amnwvaz7tmwdaejumr0dspzpgqgmmc409hm4xsdd74sf68a2uyf9pwel4g9mfdg8l5244t6x4jdqvzqqqrhnym0k2qj"
283 )?,
284 NostrUrlDecoded {
285 coordinates: HashSet::from([Coordinate {
286 identifier: "ngit".to_string(),
287 public_key: PublicKey::parse(
288 "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr",
289 )
290 .unwrap(),
291 kind: nostr_sdk::Kind::GitRepoAnnouncement,
292 relays: vec!["wss://nos.lol".to_string()], // wont add the slash
293 }]),
294 protocol: None,
295 user: None,
296 },
297 );
298 Ok(())
299 }
300 mod from_npub_slash_identifier {
301 use super::*;
302
303 #[test]
304 fn without_relay() -> Result<()> {
305 assert_eq!(
306 NostrUrlDecoded::from_str(
307 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit"
308 )?,
309 NostrUrlDecoded {
310 coordinates: HashSet::from([get_model_coordinate(false)]),
311 protocol: None,
312 user: None,
313 },
314 );
315 Ok(())
316 }
317
318 mod with_url_parameters {
319
320 use super::*;
321
322 #[test]
323 fn with_relay_without_scheme_defaults_to_wss() -> Result<()> {
324 assert_eq!(
325 NostrUrlDecoded::from_str(
326 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?relay=nos.lol"
327 )?,
328 NostrUrlDecoded {
329 coordinates: HashSet::from([get_model_coordinate(true)]),
330 protocol: None,
331 user: None,
332 },
333 );
334 Ok(())
335 }
336
337 #[test]
338 fn with_encoded_relay() -> Result<()> {
339 assert_eq!(
340 NostrUrlDecoded::from_str(&format!(
341 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?relay={}",
342 urlencoding::encode("wss://nos.lol")
343 ))?,
344 NostrUrlDecoded {
345 coordinates: HashSet::from([get_model_coordinate(true)]),
346 protocol: None,
347 user: None,
348 },
349 );
350 Ok(())
351 }
352 #[test]
353 fn with_multiple_encoded_relays() -> Result<()> {
354 assert_eq!(
355 NostrUrlDecoded::from_str(&format!(
356 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?relay={}&relay1={}",
357 urlencoding::encode("wss://nos.lol"),
358 urlencoding::encode("wss://relay.damus.io"),
359 ))?,
360 NostrUrlDecoded {
361 coordinates: HashSet::from([Coordinate {
362 identifier: "ngit".to_string(),
363 public_key: PublicKey::parse(
364 "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr",
365 )
366 .unwrap(),
367 kind: nostr_sdk::Kind::GitRepoAnnouncement,
368 relays: vec![
369 "wss://nos.lol/".to_string(),
370 "wss://relay.damus.io/".to_string(),
371 ],
372 }]),
373 protocol: None,
374 user: None,
375 },
376 );
377 Ok(())
378 }
379
380 #[test]
381 fn with_server_protocol() -> Result<()> {
382 assert_eq!(
383 NostrUrlDecoded::from_str(
384 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?protocol=ssh"
385 )?,
386 NostrUrlDecoded {
387 coordinates: HashSet::from([get_model_coordinate(false)]),
388 protocol: Some(ServerProtocol::Ssh),
389 user: None,
390 },
391 );
392 Ok(())
393 }
394 #[test]
395 fn with_server_protocol_and_user() -> Result<()> {
396 assert_eq!(
397 NostrUrlDecoded::from_str(
398 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit?protocol=ssh&user=fred"
399 )?,
400 NostrUrlDecoded {
401 coordinates: HashSet::from([get_model_coordinate(false)]),
402 protocol: Some(ServerProtocol::Ssh),
403 user: Some("fred".to_string()),
404 },
405 );
406 Ok(())
407 }
408 }
409 mod with_parameters_embedded_with_slashes {
410 use super::*;
411
412 #[test]
413 fn with_relay_without_scheme_defaults_to_wss() -> Result<()> {
414 assert_eq!(
415 NostrUrlDecoded::from_str(
416 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/nos.lol/ngit"
417 )?,
418 NostrUrlDecoded {
419 coordinates: HashSet::from([get_model_coordinate(true)]),
420 protocol: None,
421 user: None,
422 },
423 );
424 Ok(())
425 }
426
427 #[test]
428 fn with_encoded_relay() -> Result<()> {
429 assert_eq!(
430 NostrUrlDecoded::from_str(&format!(
431 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/{}/ngit",
432 urlencoding::encode("wss://nos.lol")
433 ))?,
434 NostrUrlDecoded {
435 coordinates: HashSet::from([get_model_coordinate(true)]),
436 protocol: None,
437 user: None,
438 },
439 );
440 Ok(())
441 }
442 #[test]
443 fn with_multiple_encoded_relays() -> Result<()> {
444 assert_eq!(
445 NostrUrlDecoded::from_str(&format!(
446 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/{}/{}/ngit",
447 urlencoding::encode("wss://nos.lol"),
448 urlencoding::encode("wss://relay.damus.io"),
449 ))?,
450 NostrUrlDecoded {
451 coordinates: HashSet::from([Coordinate {
452 identifier: "ngit".to_string(),
453 public_key: PublicKey::parse(
454 "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr",
455 )
456 .unwrap(),
457 kind: nostr_sdk::Kind::GitRepoAnnouncement,
458 relays: vec![
459 "wss://nos.lol/".to_string(),
460 "wss://relay.damus.io/".to_string(),
461 ],
462 }]),
463 protocol: None,
464 user: None,
465 },
466 );
467 Ok(())
468 }
469
470 #[test]
471 fn with_server_protocol() -> Result<()> {
472 assert_eq!(
473 NostrUrlDecoded::from_str(
474 "nostr://ssh/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit"
475 )?,
476 NostrUrlDecoded {
477 coordinates: HashSet::from([get_model_coordinate(false)]),
478 protocol: Some(ServerProtocol::Ssh),
479 user: None,
480 },
481 );
482 Ok(())
483 }
484 #[test]
485 fn with_server_protocol_and_user() -> Result<()> {
486 assert_eq!(
487 NostrUrlDecoded::from_str(
488 "nostr://fred@ssh/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit"
489 )?,
490 NostrUrlDecoded {
491 coordinates: HashSet::from([get_model_coordinate(false)]),
492 protocol: Some(ServerProtocol::Ssh),
493 user: Some("fred".to_string()),
494 },
495 );
496 Ok(())
497 }
498 }
499 }
500 }
501}
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 @@
1use std::str::FromStr;
2
3use anyhow::{bail, Context, Result};
4use nostr::nips::{nip01::Coordinate, nip10::Marker, nip19::Nip19};
5use nostr_sdk::{
6 hashes::sha1::Hash as Sha1Hash, Event, EventBuilder, FromBech32, Kind, Tag, TagKind,
7 TagStandard, UncheckedUrl,
8};
9use nostr_signer::NostrSigner;
10
11use crate::{
12 cli_interactor::{Interactor, InteractorPrompt, PromptInputParms},
13 client::sign_event,
14 git::{Repo, RepoActions},
15 repo_ref::RepoRef,
16};
17
18pub fn tag_value(event: &Event, tag_name: &str) -> Result<String> {
19 Ok(event
20 .tags
21 .iter()
22 .find(|t| t.as_vec()[0].eq(tag_name))
23 .context(format!("tag '{tag_name}'not present"))?
24 .as_vec()[1]
25 .clone())
26}
27
28pub fn get_commit_id_from_patch(event: &Event) -> Result<String> {
29 let value = tag_value(event, "commit");
30
31 if value.is_ok() {
32 value
33 } else if event.content.starts_with("From ") && event.content.len().gt(&45) {
34 Ok(event.content[5..45].to_string())
35 } else {
36 bail!("event is not a patch")
37 }
38}
39
40pub fn status_kinds() -> Vec<Kind> {
41 vec![
42 Kind::GitStatusOpen,
43 Kind::GitStatusApplied,
44 Kind::GitStatusClosed,
45 Kind::GitStatusDraft,
46 ]
47}
48
49pub fn event_is_patch_set_root(event: &Event) -> bool {
50 event.kind.eq(&Kind::GitPatch) && event.tags().iter().any(|t| t.as_vec()[1].eq("root"))
51}
52
53pub fn event_is_revision_root(event: &Event) -> bool {
54 event.kind.eq(&Kind::GitPatch)
55 && event
56 .tags()
57 .iter()
58 .any(|t| t.as_vec()[1].eq("revision-root"))
59}
60
61pub fn patch_supports_commit_ids(event: &Event) -> bool {
62 event.kind.eq(&Kind::GitPatch)
63 && event
64 .tags()
65 .iter()
66 .any(|t| t.as_vec()[0].eq("commit-pgp-sig"))
67}
68
69#[allow(clippy::too_many_arguments)]
70#[allow(clippy::too_many_lines)]
71pub async fn generate_patch_event(
72 git_repo: &Repo,
73 root_commit: &Sha1Hash,
74 commit: &Sha1Hash,
75 thread_event_id: Option<nostr::EventId>,
76 signer: &nostr_sdk::NostrSigner,
77 repo_ref: &RepoRef,
78 parent_patch_event_id: Option<nostr::EventId>,
79 series_count: Option<(u64, u64)>,
80 branch_name: Option<String>,
81 root_proposal_id: &Option<String>,
82 mentions: &[nostr::Tag],
83) -> Result<nostr::Event> {
84 let commit_parent = git_repo
85 .get_commit_parent(commit)
86 .context("failed to get parent commit")?;
87 let relay_hint = repo_ref.relays.first().map(nostr::UncheckedUrl::from);
88
89 sign_event(
90 EventBuilder::new(
91 nostr::event::Kind::GitPatch,
92 git_repo
93 .make_patch_from_commit(commit, &series_count)
94 .context(format!("cannot make patch for commit {commit}"))?,
95 [
96 repo_ref
97 .maintainers
98 .iter()
99 .map(|m| {
100 Tag::coordinate(Coordinate {
101 kind: nostr::Kind::GitRepoAnnouncement,
102 public_key: *m,
103 identifier: repo_ref.identifier.to_string(),
104 relays: repo_ref.relays.clone(),
105 })
106 })
107 .collect::<Vec<Tag>>(),
108 vec![
109 Tag::from_standardized(TagStandard::Reference(root_commit.to_string())),
110 // commit id reference is a trade-off. its now
111 // unclear which one is the root commit id but it
112 // enables easier location of code comments againt
113 // code that makes it into the main branch, assuming
114 // the commit id is correct
115 Tag::from_standardized(TagStandard::Reference(commit.to_string())),
116 Tag::custom(
117 TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
118 vec![format!(
119 "git patch: {}",
120 git_repo
121 .get_commit_message_summary(commit)
122 .unwrap_or_default()
123 )],
124 ),
125 ],
126 if let Some(thread_event_id) = thread_event_id {
127 vec![Tag::from_standardized(nostr_sdk::TagStandard::Event {
128 event_id: thread_event_id,
129 relay_url: relay_hint.clone(),
130 marker: Some(Marker::Root),
131 public_key: None,
132 })]
133 } else if let Some(event_ref) = root_proposal_id.clone() {
134 vec![
135 Tag::hashtag("root"),
136 Tag::hashtag("revision-root"),
137 // TODO check if id is for a root proposal (perhaps its for an issue?)
138 event_tag_from_nip19_or_hex(
139 &event_ref,
140 "proposal",
141 Marker::Reply,
142 false,
143 false,
144 )?,
145 ]
146 } else {
147 vec![Tag::hashtag("root")]
148 },
149 mentions.to_vec(),
150 if let Some(id) = parent_patch_event_id {
151 vec![Tag::from_standardized(nostr_sdk::TagStandard::Event {
152 event_id: id,
153 relay_url: relay_hint.clone(),
154 marker: Some(Marker::Reply),
155 public_key: None,
156 })]
157 } else {
158 vec![]
159 },
160 // see comment on branch names in cover letter event creation
161 if let Some(branch_name) = branch_name {
162 if thread_event_id.is_none() {
163 vec![Tag::custom(
164 TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")),
165 vec![branch_name.to_string()],
166 )]
167 } else {
168 vec![]
169 }
170 } else {
171 vec![]
172 },
173 // whilst it is in nip34 draft to tag the maintainers
174 // I'm not sure it is a good idea because if they are
175 // interested in all patches then their specialised
176 // client should subscribe to patches tagged with the
177 // repo reference. maintainers of large repos will not
178 // be interested in every patch.
179 repo_ref
180 .maintainers
181 .iter()
182 .map(|pk| Tag::public_key(*pk))
183 .collect(),
184 vec![
185 // a fallback is now in place to extract this from the patch
186 Tag::custom(
187 TagKind::Custom(std::borrow::Cow::Borrowed("commit")),
188 vec![commit.to_string()],
189 ),
190 // this is required as patches cannot be relied upon to include the 'base
191 // commit'
192 Tag::custom(
193 TagKind::Custom(std::borrow::Cow::Borrowed("parent-commit")),
194 vec![commit_parent.to_string()],
195 ),
196 // this is required to ensure the commit id matches
197 Tag::custom(
198 TagKind::Custom(std::borrow::Cow::Borrowed("commit-pgp-sig")),
199 vec![
200 git_repo
201 .extract_commit_pgp_signature(commit)
202 .unwrap_or_default(),
203 ],
204 ),
205 // removing description tag will not cause anything to break
206 Tag::from_standardized(nostr_sdk::TagStandard::Description(
207 git_repo.get_commit_message(commit)?.to_string(),
208 )),
209 Tag::custom(
210 TagKind::Custom(std::borrow::Cow::Borrowed("author")),
211 git_repo.get_commit_author(commit)?,
212 ),
213 // this is required to ensure the commit id matches
214 Tag::custom(
215 TagKind::Custom(std::borrow::Cow::Borrowed("committer")),
216 git_repo.get_commit_comitter(commit)?,
217 ),
218 ],
219 ]
220 .concat(),
221 ),
222 signer,
223 )
224 .await
225 .context("failed to sign event")
226}
227
228pub fn event_tag_from_nip19_or_hex(
229 reference: &str,
230 reference_name: &str,
231 marker: Marker,
232 allow_npub_reference: bool,
233 prompt_for_correction: bool,
234) -> Result<nostr::Tag> {
235 let mut bech32 = reference.to_string();
236 loop {
237 if bech32.is_empty() {
238 bech32 = Interactor::default().input(
239 PromptInputParms::default().with_prompt(&format!("{reference_name} reference")),
240 )?;
241 }
242 if let Ok(nip19) = Nip19::from_bech32(bech32.clone()) {
243 match nip19 {
244 Nip19::Event(n) => {
245 break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event {
246 event_id: n.event_id,
247 relay_url: n.relays.first().map(UncheckedUrl::new),
248 marker: Some(marker),
249 public_key: None,
250 }));
251 }
252 Nip19::EventId(id) => {
253 break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event {
254 event_id: id,
255 relay_url: None,
256 marker: Some(marker),
257 public_key: None,
258 }));
259 }
260 Nip19::Coordinate(coordinate) => {
261 break Ok(Tag::coordinate(coordinate));
262 }
263 Nip19::Profile(profile) => {
264 if allow_npub_reference {
265 break Ok(Tag::public_key(profile.public_key));
266 }
267 }
268 Nip19::Pubkey(public_key) => {
269 if allow_npub_reference {
270 break Ok(Tag::public_key(public_key));
271 }
272 }
273 _ => {}
274 }
275 }
276 if let Ok(id) = nostr::EventId::from_str(&bech32) {
277 break Ok(Tag::from_standardized(nostr_sdk::TagStandard::Event {
278 event_id: id,
279 relay_url: None,
280 marker: Some(marker),
281 public_key: None,
282 }));
283 }
284 if prompt_for_correction {
285 println!("not a valid {reference_name} event reference");
286 } else {
287 bail!(format!("not a valid {reference_name} event reference"));
288 }
289
290 bech32 = String::new();
291 }
292}
293
294#[allow(clippy::too_many_lines)]
295pub async fn generate_cover_letter_and_patch_events(
296 cover_letter_title_description: Option<(String, String)>,
297 git_repo: &Repo,
298 commits: &[Sha1Hash],
299 signer: &NostrSigner,
300 repo_ref: &RepoRef,
301 root_proposal_id: &Option<String>,
302 mentions: &[nostr::Tag],
303) -> Result<Vec<nostr::Event>> {
304 let root_commit = git_repo
305 .get_root_commit()
306 .context("failed to get root commit of the repository")?;
307
308 let mut events = vec![];
309
310 if let Some((title, description)) = cover_letter_title_description {
311 events.push(sign_event(EventBuilder::new(
312 nostr::event::Kind::GitPatch,
313 format!(
314 "From {} Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/{}] {title}\n\n{description}",
315 commits.last().unwrap(),
316 commits.len()
317 ),
318 [
319 repo_ref.maintainers.iter().map(|m| Tag::coordinate(Coordinate {
320 kind: nostr::Kind::GitRepoAnnouncement,
321 public_key: *m,
322 identifier: repo_ref.identifier.to_string(),
323 relays: repo_ref.relays.clone(),
324 })).collect::<Vec<Tag>>(),
325 vec![
326 Tag::from_standardized(TagStandard::Reference(format!("{root_commit}"))),
327 Tag::hashtag("cover-letter"),
328 Tag::custom(
329 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")),
330 vec![format!("git patch cover letter: {}", title.clone())],
331 ),
332 ],
333 if let Some(event_ref) = root_proposal_id.clone() {
334 vec![
335 Tag::hashtag("root"),
336 Tag::hashtag("revision-root"),
337 // TODO check if id is for a root proposal (perhaps its for an issue?)
338 event_tag_from_nip19_or_hex(&event_ref,"proposal",Marker::Reply, false, false)?,
339 ]
340 } else {
341 vec![
342 Tag::hashtag("root"),
343 ]
344 },
345 mentions.to_vec(),
346 // this is not strictly needed but makes for prettier branch names
347 // eventually a prefix will be needed of the event id to stop 2 proposals with the same name colliding
348 // a change like this, or the removal of this tag will require the actual branch name to be tracked
349 // so pulling and pushing still work
350 if let Ok(branch_name) = git_repo.get_checked_out_branch_name() {
351 if !branch_name.eq("main")
352 && !branch_name.eq("master")
353 && !branch_name.eq("origin/main")
354 && !branch_name.eq("origin/master")
355 {
356 vec![
357 Tag::custom(
358 nostr::TagKind::Custom(std::borrow::Cow::Borrowed("branch-name")),
359 vec![if let Some(branch_name) = branch_name.strip_prefix("pr/") {
360 branch_name.to_string()
361 } else {
362 branch_name
363 }],
364 ),
365 ]
366 }
367 else { vec![] }
368 } else {
369 vec![]
370 },
371 repo_ref.maintainers
372 .iter()
373 .map(|pk| Tag::public_key(*pk))
374 .collect(),
375 ].concat(),
376 ), signer).await
377 .context("failed to create cover-letter event")?);
378 }
379
380 for (i, commit) in commits.iter().enumerate() {
381 events.push(
382 generate_patch_event(
383 git_repo,
384 &root_commit,
385 commit,
386 events.first().map(|event| event.id),
387 signer,
388 repo_ref,
389 events.last().map(nostr::Event::id),
390 if events.is_empty() && commits.len().eq(&1) {
391 None
392 } else {
393 Some(((i + 1).try_into()?, commits.len().try_into()?))
394 },
395 if events.is_empty() {
396 if let Ok(branch_name) = git_repo.get_checked_out_branch_name() {
397 if !branch_name.eq("main")
398 && !branch_name.eq("master")
399 && !branch_name.eq("origin/main")
400 && !branch_name.eq("origin/master")
401 {
402 Some(if let Some(branch_name) = branch_name.strip_prefix("pr/") {
403 branch_name.to_string()
404 } else {
405 branch_name
406 })
407 } else {
408 None
409 }
410 } else {
411 None
412 }
413 } else {
414 None
415 },
416 root_proposal_id,
417 if events.is_empty() { mentions } else { &[] },
418 )
419 .await
420 .context("failed to generate patch event")?,
421 );
422 }
423 Ok(events)
424}
425
426pub struct CoverLetter {
427 pub title: String,
428 pub description: String,
429 pub branch_name: String,
430 pub event_id: Option<nostr::EventId>,
431}
432
433impl CoverLetter {
434 pub fn get_branch_name(&self) -> Result<String> {
435 Ok(format!(
436 "pr/{}({})",
437 self.branch_name,
438 &self
439 .event_id
440 .context("proposal root event_id must be know to get it's branch name")?
441 .to_hex()
442 .as_str()[..8],
443 ))
444 }
445}
446pub fn event_is_cover_letter(event: &nostr::Event) -> bool {
447 // TODO: look for Subject:[ PATCH 0/n ] but watch out for:
448 // [PATCH v1 0/n ] or
449 // [PATCH subsystem v2 0/n ]
450 event.kind.eq(&Kind::GitPatch)
451 && event.tags().iter().any(|t| t.as_vec()[1].eq("root"))
452 && event
453 .tags()
454 .iter()
455 .any(|t| t.as_vec()[1].eq("cover-letter"))
456}
457
458pub fn commit_msg_from_patch(patch: &nostr::Event) -> Result<String> {
459 if let Ok(msg) = tag_value(patch, "description") {
460 Ok(msg)
461 } else {
462 let start_index = patch
463 .content
464 .find("] ")
465 .context("event is not formatted as a patch or cover letter")?
466 + 2;
467 let end_index = patch.content[start_index..]
468 .find("\ndiff --git")
469 .unwrap_or(patch.content.len());
470 Ok(patch.content[start_index..end_index].to_string())
471 }
472}
473
474pub fn commit_msg_from_patch_oneliner(patch: &nostr::Event) -> Result<String> {
475 Ok(commit_msg_from_patch(patch)?
476 .split('\n')
477 .collect::<Vec<&str>>()[0]
478 .to_string())
479}
480
481pub fn event_to_cover_letter(event: &nostr::Event) -> Result<CoverLetter> {
482 if !event_is_patch_set_root(event) {
483 bail!("event is not a patch set root event (root patch or cover letter)")
484 }
485
486 let title = commit_msg_from_patch_oneliner(event)?;
487 let full = commit_msg_from_patch(event)?;
488 let description = full[title.len()..].trim().to_string();
489
490 Ok(CoverLetter {
491 title: title.clone(),
492 description,
493 // TODO should this be prefixed by format!("{}-"e.id.to_string()[..5]?)
494 branch_name: if let Ok(name) = match tag_value(event, "branch-name") {
495 Ok(name) => {
496 if !name.eq("main") && !name.eq("master") {
497 Ok(name)
498 } else {
499 Err(())
500 }
501 }
502 _ => Err(()),
503 } {
504 name
505 } else {
506 let s = title
507 .replace(' ', "-")
508 .chars()
509 .map(|c| {
510 if c.is_ascii_alphanumeric() || c.eq(&'/') {
511 c
512 } else {
513 '-'
514 }
515 })
516 .collect();
517 s
518 },
519 event_id: Some(event.id()),
520 })
521}
522
523pub fn get_most_recent_patch_with_ancestors(
524 mut patches: Vec<nostr::Event>,
525) -> Result<Vec<nostr::Event>> {
526 patches.sort_by_key(|e| e.created_at);
527
528 let youngest_patch = patches.last().context("no patches found")?;
529
530 let patches_with_youngest_created_at: Vec<&nostr::Event> = patches
531 .iter()
532 .filter(|p| p.created_at.eq(&youngest_patch.created_at))
533 .collect();
534
535 let mut res = vec![];
536
537 let mut event_id_to_search = patches_with_youngest_created_at
538 .clone()
539 .iter()
540 .find(|p| {
541 !patches_with_youngest_created_at.iter().any(|p2| {
542 if let Ok(reply_to) = get_event_parent_id(p2) {
543 reply_to.eq(&p.id.to_string())
544 } else {
545 false
546 }
547 })
548 })
549 .context("cannot find patches_with_youngest_created_at")?
550 .id
551 .to_string();
552
553 while let Some(event) = patches
554 .iter()
555 .find(|e| e.id.to_string().eq(&event_id_to_search))
556 {
557 res.push(event.clone());
558 if event_is_patch_set_root(event) {
559 break;
560 }
561 event_id_to_search = get_event_parent_id(event).unwrap_or_default();
562 }
563 Ok(res)
564}
565
566fn get_event_parent_id(event: &nostr::Event) -> Result<String> {
567 Ok(if let Some(reply_tag) = event
568 .tags
569 .iter()
570 .find(|t| t.as_vec().len().gt(&3) && t.as_vec()[3].eq("reply"))
571 {
572 reply_tag
573 } else {
574 event
575 .tags
576 .iter()
577 .find(|t| t.as_vec().len().gt(&3) && t.as_vec()[3].eq("root"))
578 .context("no reply or root e tag present".to_string())?
579 }
580 .as_vec()[1]
581 .clone())
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587
588 mod event_to_cover_letter {
589 use super::*;
590
591 fn generate_cover_letter(title: &str, description: &str) -> Result<nostr::Event> {
592 Ok(nostr::event::EventBuilder::new(
593 nostr::event::Kind::GitPatch,
594 format!("From ea897e987ea9a7a98e7a987e97987ea98e7a3334 Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/2] {title}\n\n{description}"),
595 [
596 Tag::hashtag("cover-letter"),
597 Tag::hashtag("root"),
598 ],
599 )
600 .to_event(&nostr::Keys::generate())?)
601 }
602
603 #[test]
604 fn basic_title() -> Result<()> {
605 assert_eq!(
606 event_to_cover_letter(&generate_cover_letter("the title", "description here")?)?
607 .title,
608 "the title",
609 );
610 Ok(())
611 }
612
613 #[test]
614 fn basic_description() -> Result<()> {
615 assert_eq!(
616 event_to_cover_letter(&generate_cover_letter("the title", "description here")?)?
617 .description,
618 "description here",
619 );
620 Ok(())
621 }
622
623 #[test]
624 fn description_trimmed() -> Result<()> {
625 assert_eq!(
626 event_to_cover_letter(&generate_cover_letter(
627 "the title",
628 " \n \ndescription here\n\n "
629 )?)?
630 .description,
631 "description here",
632 );
633 Ok(())
634 }
635
636 #[test]
637 fn multi_line_description() -> Result<()> {
638 assert_eq!(
639 event_to_cover_letter(&generate_cover_letter(
640 "the title",
641 "description here\n\nmore here\nmore"
642 )?)?
643 .description,
644 "description here\n\nmore here\nmore",
645 );
646 Ok(())
647 }
648
649 #[test]
650 fn new_lines_in_title_forms_part_of_description() -> Result<()> {
651 assert_eq!(
652 event_to_cover_letter(&generate_cover_letter(
653 "the title\nwith new line",
654 "description here\n\nmore here\nmore"
655 )?)?
656 .title,
657 "the title",
658 );
659 assert_eq!(
660 event_to_cover_letter(&generate_cover_letter(
661 "the title\nwith new line",
662 "description here\n\nmore here\nmore"
663 )?)?
664 .description,
665 "with new line\n\ndescription here\n\nmore here\nmore",
666 );
667 Ok(())
668 }
669
670 mod blank_description {
671 use super::*;
672
673 #[test]
674 fn title_correct() -> Result<()> {
675 assert_eq!(
676 event_to_cover_letter(&generate_cover_letter("the title", "")?)?.title,
677 "the title",
678 );
679 Ok(())
680 }
681
682 #[test]
683 fn description_is_empty_string() -> Result<()> {
684 assert_eq!(
685 event_to_cover_letter(&generate_cover_letter("the title", "")?)?.description,
686 "",
687 );
688 Ok(())
689 }
690 }
691 }
692}
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::{
19 Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptPasswordParms, 19 Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptPasswordParms,
20 }, 20 },
21 client::{fetch_public_key, get_event_from_global_cache, Connect}, 21 client::{fetch_public_key, get_event_from_global_cache, Connect},
22 config::{UserMetadata, UserRef, UserRelayRef, UserRelays},
23 git::{Repo, RepoActions}, 22 git::{Repo, RepoActions},
24 key_handling::encryption::{decrypt_key, encrypt_key},
25}; 23};
26 24
25mod key_encryption;
26use key_encryption::{decrypt_key, encrypt_key};
27mod user;
28use user::{UserMetadata, UserRef, UserRelayRef, UserRelays};
29
27/// handles the encrpytion and storage of key material 30/// handles the encrpytion and storage of key material
28#[allow(clippy::too_many_arguments)] 31#[allow(clippy::too_many_arguments)]
29pub async fn launch( 32pub 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 @@
1use anyhow::{anyhow, Result};
2use directories::ProjectDirs;
3use nostr::PublicKey; 1use nostr::PublicKey;
4use nostr_sdk::Timestamp; 2use nostr_sdk::Timestamp;
5use serde::{self, Deserialize, Serialize}; 3use serde::{self, Deserialize, Serialize};
6 4
7pub fn get_dirs() -> Result<ProjectDirs> {
8 ProjectDirs::from("", "", "ngit").ok_or(anyhow!(
9 "should find operating system home directories with rust-directories crate"
10 ))
11}
12
13#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] 5#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
14pub struct UserRef { 6pub struct UserRef {
15 pub public_key: PublicKey, 7 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 @@
1mod cli_interactor; 1pub mod cli_interactor;
2mod client; 2pub mod client;
3mod config; 3pub mod git;
4mod git; 4pub mod git_events;
5mod key_handling; 5pub mod login;
6mod login; 6pub mod repo_ref;
7mod repo_ref; 7pub mod repo_state;
8mod repo_state;
9 8
10pub use client; 9use anyhow::{anyhow, Result};
11pub use config; 10use directories::ProjectDirs;
12pub use git; 11
13pub use key_handling; 12pub fn get_dirs() -> Result<ProjectDirs> {
14pub use login; 13 ProjectDirs::from("", "", "ngit").ok_or(anyhow!(
15pub use repo_ref; 14 "should find operating system home directories with rust-directories crate"
16pub use repo_state; 15 ))
16}
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;
16use crate::{ 16use crate::{
17 cli_interactor::{Interactor, InteractorPrompt, PromptInputParms}, 17 cli_interactor::{Interactor, InteractorPrompt, PromptInputParms},
18 client::{get_event_from_global_cache, get_events_from_cache, sign_event, Connect}, 18 client::{get_event_from_global_cache, get_events_from_cache, sign_event, Connect},
19 git::{NostrUrlDecoded, Repo, RepoActions}, 19 git::{nostr_url::NostrUrlDecoded, Repo, RepoActions},
20}; 20};
21 21
22#[derive(Default)] 22#[derive(Default)]