diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/bin/git_remote_nostr/main.rs | 307 | ||||
| -rw-r--r-- | src/bin/ngit/main.rs | 2 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/fetch.rs | 9 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/init.rs | 12 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/list.rs | 216 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/login.rs | 14 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/pull.rs | 24 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/push.rs | 32 | ||||
| -rw-r--r-- | src/bin/ngit/sub_commands/send.rs | 969 | ||||
| -rw-r--r-- | src/lib/client.rs | 273 | ||||
| -rw-r--r-- | src/lib/git/identify_ahead_behind.rs | 196 | ||||
| -rw-r--r-- | src/lib/git/mod.rs | 266 | ||||
| -rw-r--r-- | src/lib/git/nostr_url.rs | 501 | ||||
| -rw-r--r-- | src/lib/git_events.rs | 692 | ||||
| -rw-r--r-- | src/lib/login/mod.rs | 7 | ||||
| -rw-r--r-- | src/lib/login/user.rs | 8 | ||||
| -rw-r--r-- | src/lib/mod.rs | 30 | ||||
| -rw-r--r-- | src/lib/repo_ref.rs | 2 |
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::{ | |||
| 15 | use anyhow::{anyhow, bail, Context, Result}; | 15 | use anyhow::{anyhow, bail, Context, Result}; |
| 16 | use auth_git2::GitAuthenticator; | 16 | use auth_git2::GitAuthenticator; |
| 17 | use client::{ | 17 | use 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 | }; |
| 21 | use console::Term; | 22 | use console::Term; |
| 22 | use git::{sha1_to_oid, NostrUrlDecoded, RepoActions}; | 23 | use git::{nostr_url::NostrUrlDecoded, sha1_to_oid, RepoActions}; |
| 23 | use git2::{Oid, Repository}; | 24 | use git2::{Oid, Repository}; |
| 25 | use 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 | }; | ||
| 30 | use ngit::{client, git, git_events, login, repo_ref, repo_state}; | ||
| 24 | use nostr::nips::{nip01::Coordinate, nip10::Marker}; | 31 | use nostr::nips::{nip01::Coordinate, nip10::Marker}; |
| 25 | use nostr_sdk::{ | 32 | use 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::{ | |||
| 28 | use nostr_signer::NostrSigner; | 35 | use nostr_signer::NostrSigner; |
| 29 | use repo_ref::RepoRef; | 36 | use repo_ref::RepoRef; |
| 30 | use repo_state::RepoState; | 37 | use repo_state::RepoState; |
| 31 | use 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))] | 39 | use crate::{client::Client, git::Repo}; |
| 44 | use crate::client::Client; | ||
| 45 | #[cfg(test)] | ||
| 46 | use crate::client::MockConnect; | ||
| 47 | use crate::git::Repo; | ||
| 48 | |||
| 49 | mod cli; | ||
| 50 | mod cli_interactor; | ||
| 51 | mod client; | ||
| 52 | mod config; | ||
| 53 | mod git; | ||
| 54 | mod key_handling; | ||
| 55 | mod login; | ||
| 56 | mod repo_ref; | ||
| 57 | mod repo_state; | ||
| 58 | mod sub_commands; | ||
| 59 | 40 | ||
| 60 | #[tokio::main] | 41 | #[tokio::main] |
| 61 | async fn main() -> Result<()> { | 42 | async 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 | ||
| 156 | async fn fetching_with_report_for_helper( | 134 | async 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 | ||
| 1616 | impl RepoState { | 1592 | trait 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 | } | ||
| 1599 | impl 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 { | |||
| 1639 | mod tests { | 1622 | mod 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; | |||
| 7 | use cli::{Cli, Commands}; | 7 | use cli::{Cli, Commands}; |
| 8 | 8 | ||
| 9 | mod cli; | 9 | mod cli; |
| 10 | use ngit::*; | 10 | use ngit::{cli_interactor, client, git, git_events, login, repo_ref}; |
| 11 | 11 | ||
| 12 | mod sub_commands; | 12 | mod 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}; | |||
| 4 | use clap; | 4 | use clap; |
| 5 | use nostr::nips::nip01::Coordinate; | 5 | use nostr::nips::nip01::Coordinate; |
| 6 | 6 | ||
| 7 | #[cfg(not(test))] | ||
| 8 | use crate::client::Client; | ||
| 9 | #[cfg(test)] | ||
| 10 | use crate::client::MockConnect; | ||
| 11 | use crate::{ | 7 | use 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 { | |||
| 25 | pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { | 21 | pub 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}; | |||
| 4 | use nostr::{nips::nip01::Coordinate, FromBech32, PublicKey, ToBech32}; | 4 | use nostr::{nips::nip01::Coordinate, FromBech32, PublicKey, ToBech32}; |
| 5 | use nostr_sdk::Kind; | 5 | use nostr_sdk::Kind; |
| 6 | 6 | ||
| 7 | use super::send::send_events; | ||
| 8 | #[cfg(not(test))] | ||
| 9 | use crate::client::Client; | ||
| 10 | #[cfg(test)] | ||
| 11 | use crate::client::MockConnect; | ||
| 12 | use crate::{ | 7 | use 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 @@ | |||
| 1 | use std::{collections::HashSet, io::Write, ops::Add, path::Path}; | 1 | use std::{io::Write, ops::Add}; |
| 2 | 2 | ||
| 3 | use anyhow::{bail, Context, Result}; | 3 | use anyhow::{bail, Context, Result}; |
| 4 | use nostr::nips::nip01::Coordinate; | 4 | use ngit::{ |
| 5 | use nostr_sdk::{Kind, PublicKey}; | 5 | client::{get_all_proposal_patch_events_from_cache, get_proposals_and_revisions_from_cache}, |
| 6 | 6 | git_events::{ | |
| 7 | use 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 | }, |
| 9 | use crate::client::MockConnect; | 9 | }; |
| 10 | #[cfg(not(test))] | 10 | use nostr_sdk::Kind; |
| 11 | use crate::client::{Client, Connect}; | 11 | |
| 12 | use crate::{ | 12 | use 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 | |||
| 725 | pub 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 | |||
| 735 | pub 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 | |||
| 747 | fn 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 | |||
| 765 | pub 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 | |||
| 808 | pub 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 | |||
| 817 | pub 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 | |||
| 845 | pub 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 @@ | |||
| 1 | use anyhow::{Context, Result}; | 1 | use anyhow::{Context, Result}; |
| 2 | use clap; | 2 | use clap; |
| 3 | 3 | ||
| 4 | #[cfg(not(test))] | 4 | use crate::{ |
| 5 | use crate::client::Client; | 5 | cli::Cli, |
| 6 | #[cfg(test)] | 6 | client::{Client, Connect}, |
| 7 | use crate::client::MockConnect; | 7 | git::Repo, |
| 8 | use crate::{cli::Cli, client::Connect, git::Repo, login}; | 8 | login, |
| 9 | }; | ||
| 9 | 10 | ||
| 10 | #[derive(clap::Args)] | 11 | #[derive(clap::Args)] |
| 11 | pub struct SubCommandArgs { | 12 | pub 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 @@ | |||
| 1 | use anyhow::{bail, Context, Result}; | 1 | use anyhow::{bail, Context, Result}; |
| 2 | 2 | ||
| 3 | use 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)] | ||
| 11 | use crate::client::MockConnect; | ||
| 12 | #[cfg(not(test))] | ||
| 13 | use crate::client::{Client, Connect}; | ||
| 14 | use crate::{ | 3 | use 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 @@ | |||
| 1 | use anyhow::{bail, Context, Result}; | 1 | use anyhow::{bail, Context, Result}; |
| 2 | use ngit::{client::send_events, git_events::tag_value}; | ||
| 2 | 3 | ||
| 3 | #[cfg(not(test))] | ||
| 4 | use crate::client::Client; | ||
| 5 | #[cfg(test)] | ||
| 6 | use crate::client::MockConnect; | ||
| 7 | use crate::{ | 4 | use 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 @@ | |||
| 1 | use std::{path::Path, str::FromStr, time::Duration}; | 1 | use std::path::Path; |
| 2 | 2 | ||
| 3 | use anyhow::{bail, Context, Result}; | 3 | use anyhow::{bail, Context, Result}; |
| 4 | use console::Style; | 4 | use console::Style; |
| 5 | use futures::future::join_all; | 5 | use ngit::{client::send_events, git_events::generate_cover_letter_and_patch_events}; |
| 6 | use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle}; | ||
| 7 | use nostr::{ | 6 | use 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 | }; |
| 15 | use nostr_sdk::{hashes::sha1::Hash as Sha1Hash, Kind, NostrSigner, TagStandard}; | 10 | use nostr_sdk::hashes::sha1::Hash as Sha1Hash; |
| 16 | 11 | ||
| 17 | use super::list::tag_value; | ||
| 18 | #[cfg(not(test))] | ||
| 19 | use crate::client::Client; | ||
| 20 | #[cfg(test)] | ||
| 21 | use crate::client::MockConnect; | ||
| 22 | use crate::{ | 12 | use 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)] | ||
| 282 | pub 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 | |||
| 438 | fn 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 | |||
| 446 | fn choose_commits(git_repo: &Repo, proposed_commits: Vec<Sha1Hash>) -> Result<Vec<Sha1Hash>> { | 268 | fn 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)] | ||
| 587 | pub 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 | |||
| 718 | fn 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 | |||
| 784 | pub struct CoverLetter { | ||
| 785 | pub title: String, | ||
| 786 | pub description: String, | ||
| 787 | pub branch_name: String, | ||
| 788 | pub event_id: Option<nostr::EventId>, | ||
| 789 | } | ||
| 790 | |||
| 791 | impl 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 | } | ||
| 804 | pub 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 | |||
| 816 | pub 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 | |||
| 832 | pub 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 | |||
| 839 | pub 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 | |||
| 881 | pub 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 | |||
| 885 | pub 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 | |||
| 893 | pub 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)] | ||
| 903 | pub 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 | */ | ||
| 1068 | pub 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)] | ||
| 1126 | mod 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::{ | |||
| 21 | use anyhow::{bail, Context, Result}; | 21 | use anyhow::{bail, Context, Result}; |
| 22 | use async_trait::async_trait; | 22 | use async_trait::async_trait; |
| 23 | use console::Style; | 23 | use console::Style; |
| 24 | use futures::stream::{self, StreamExt}; | 24 | use futures::{ |
| 25 | use indicatif::{MultiProgress, ProgressBar, ProgressState, ProgressStyle}; | 25 | future::join_all, |
| 26 | stream::{self, StreamExt}, | ||
| 27 | }; | ||
| 28 | use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressState, ProgressStyle}; | ||
| 26 | #[cfg(test)] | 29 | #[cfg(test)] |
| 27 | use mockall::*; | 30 | use mockall::*; |
| 28 | use nostr::{nips::nip01::Coordinate, Event}; | 31 | use nostr::{nips::nip01::Coordinate, Event}; |
| @@ -34,14 +37,13 @@ use nostr_sdk::{ | |||
| 34 | use nostr_sqlite::SQLiteDatabase; | 37 | use nostr_sqlite::SQLiteDatabase; |
| 35 | 38 | ||
| 36 | use crate::{ | 39 | use 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 | |||
| 1484 | pub 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 | |||
| 1512 | pub 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)] | ||
| 1577 | pub 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 | |||
| 1733 | fn 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 @@ | |||
| 1 | use anyhow::{Context, Result}; | ||
| 2 | use nostr_sdk::hashes::sha1::Hash as Sha1Hash; | ||
| 3 | |||
| 4 | use super::{Repo, RepoActions}; | ||
| 5 | |||
| 6 | /** | ||
| 7 | * returns `(from_branch,to_branch,ahead,behind)` | ||
| 8 | */ | ||
| 9 | pub 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)] | ||
| 67 | mod 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 @@ | |||
| 1 | use std::{ | 1 | use std::{ |
| 2 | collections::HashSet, | ||
| 3 | env::current_dir, | 2 | env::current_dir, |
| 4 | path::{Path, PathBuf}, | 3 | path::{Path, PathBuf}, |
| 5 | }; | 4 | }; |
| 6 | 5 | ||
| 7 | use anyhow::{bail, Context, Result}; | 6 | use anyhow::{bail, Context, Result}; |
| 8 | use git2::{DiffOptions, Oid, Revwalk}; | 7 | use git2::{DiffOptions, Oid, Revwalk}; |
| 9 | use nostr::nips::nip01::Coordinate; | 8 | pub use identify_ahead_behind::identify_ahead_behind; |
| 10 | use nostr_sdk::{ | 9 | use nostr_sdk::hashes::{sha1::Hash as Sha1Hash, Hash}; |
| 11 | hashes::{sha1::Hash as Sha1Hash, Hash}, | ||
| 12 | PublicKey, Url, | ||
| 13 | }; | ||
| 14 | 10 | ||
| 15 | use crate::sub_commands::list::{get_commit_id_from_patch, tag_value}; | 11 | use crate::git_events::{get_commit_id_from_patch, tag_value}; |
| 12 | pub mod identify_ahead_behind; | ||
| 13 | pub mod nostr_url; | ||
| 16 | 14 | ||
| 17 | pub struct Repo { | 15 | pub 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)] | ||
| 839 | pub enum ServerProtocol { | ||
| 840 | Ssh, | ||
| 841 | Https, | ||
| 842 | Http, | ||
| 843 | Git, | ||
| 844 | } | ||
| 845 | |||
| 846 | #[derive(Debug, PartialEq)] | ||
| 847 | pub struct NostrUrlDecoded { | ||
| 848 | pub coordinates: HashSet<Coordinate>, | ||
| 849 | pub protocol: Option<ServerProtocol>, | ||
| 850 | pub user: Option<String>, | ||
| 851 | } | ||
| 852 | |||
| 853 | static INCORRECT_NOSTR_URL_FORMAT_ERROR: &str = "incorrect nostr git url format. try nostr://naddr123 or nostr://npub123/my-repo or nostr://ssh/npub123/relay.damus.io/my-repo"; | ||
| 854 | |||
| 855 | impl 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 */ | ||
| 963 | pub 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 | ||
| 1002 | fn 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)] |
| 1021 | mod tests { | 837 | mod 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 @@ | |||
| 1 | use std::collections::HashSet; | ||
| 2 | |||
| 3 | use anyhow::{bail, Context, Result}; | ||
| 4 | use nostr::nips::nip01::Coordinate; | ||
| 5 | use nostr_sdk::{PublicKey, Url}; | ||
| 6 | |||
| 7 | #[derive(Debug, PartialEq)] | ||
| 8 | pub enum ServerProtocol { | ||
| 9 | Ssh, | ||
| 10 | Https, | ||
| 11 | Http, | ||
| 12 | Git, | ||
| 13 | } | ||
| 14 | |||
| 15 | #[derive(Debug, PartialEq)] | ||
| 16 | pub struct NostrUrlDecoded { | ||
| 17 | pub coordinates: HashSet<Coordinate>, | ||
| 18 | pub protocol: Option<ServerProtocol>, | ||
| 19 | pub user: Option<String>, | ||
| 20 | } | ||
| 21 | |||
| 22 | static INCORRECT_NOSTR_URL_FORMAT_ERROR: &str = "incorrect nostr git url format. try nostr://naddr123 or nostr://npub123/my-repo or nostr://ssh/npub123/relay.damus.io/my-repo"; | ||
| 23 | |||
| 24 | impl 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 */ | ||
| 132 | pub 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 | ||
| 171 | fn 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)] | ||
| 190 | mod 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 @@ | |||
| 1 | use std::str::FromStr; | ||
| 2 | |||
| 3 | use anyhow::{bail, Context, Result}; | ||
| 4 | use nostr::nips::{nip01::Coordinate, nip10::Marker, nip19::Nip19}; | ||
| 5 | use nostr_sdk::{ | ||
| 6 | hashes::sha1::Hash as Sha1Hash, Event, EventBuilder, FromBech32, Kind, Tag, TagKind, | ||
| 7 | TagStandard, UncheckedUrl, | ||
| 8 | }; | ||
| 9 | use nostr_signer::NostrSigner; | ||
| 10 | |||
| 11 | use crate::{ | ||
| 12 | cli_interactor::{Interactor, InteractorPrompt, PromptInputParms}, | ||
| 13 | client::sign_event, | ||
| 14 | git::{Repo, RepoActions}, | ||
| 15 | repo_ref::RepoRef, | ||
| 16 | }; | ||
| 17 | |||
| 18 | pub 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 | |||
| 28 | pub 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 | |||
| 40 | pub fn status_kinds() -> Vec<Kind> { | ||
| 41 | vec![ | ||
| 42 | Kind::GitStatusOpen, | ||
| 43 | Kind::GitStatusApplied, | ||
| 44 | Kind::GitStatusClosed, | ||
| 45 | Kind::GitStatusDraft, | ||
| 46 | ] | ||
| 47 | } | ||
| 48 | |||
| 49 | pub 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 | |||
| 53 | pub 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 | |||
| 61 | pub 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)] | ||
| 71 | pub 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 | |||
| 228 | pub 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)] | ||
| 295 | pub 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 | |||
| 426 | pub struct CoverLetter { | ||
| 427 | pub title: String, | ||
| 428 | pub description: String, | ||
| 429 | pub branch_name: String, | ||
| 430 | pub event_id: Option<nostr::EventId>, | ||
| 431 | } | ||
| 432 | |||
| 433 | impl 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 | } | ||
| 446 | pub 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 | |||
| 458 | pub 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 | |||
| 474 | pub 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 | |||
| 481 | pub 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 | |||
| 523 | pub 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 | |||
| 566 | fn 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)] | ||
| 585 | mod 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 | ||
| 25 | mod key_encryption; | ||
| 26 | use key_encryption::{decrypt_key, encrypt_key}; | ||
| 27 | mod user; | ||
| 28 | use 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)] |
| 29 | pub async fn launch( | 32 | pub async fn launch( |
diff --git a/src/lib/login/user.rs b/src/lib/login/user.rs index 547fe7e..46652db 100644 --- a/src/lib/login/user.rs +++ b/src/lib/login/user.rs | |||
| @@ -1,15 +1,7 @@ | |||
| 1 | use anyhow::{anyhow, Result}; | ||
| 2 | use directories::ProjectDirs; | ||
| 3 | use nostr::PublicKey; | 1 | use nostr::PublicKey; |
| 4 | use nostr_sdk::Timestamp; | 2 | use nostr_sdk::Timestamp; |
| 5 | use serde::{self, Deserialize, Serialize}; | 3 | use serde::{self, Deserialize, Serialize}; |
| 6 | 4 | ||
| 7 | pub 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)] |
| 14 | pub struct UserRef { | 6 | pub 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 @@ | |||
| 1 | mod cli_interactor; | 1 | pub mod cli_interactor; |
| 2 | mod client; | 2 | pub mod client; |
| 3 | mod config; | 3 | pub mod git; |
| 4 | mod git; | 4 | pub mod git_events; |
| 5 | mod key_handling; | 5 | pub mod login; |
| 6 | mod login; | 6 | pub mod repo_ref; |
| 7 | mod repo_ref; | 7 | pub mod repo_state; |
| 8 | mod repo_state; | ||
| 9 | 8 | ||
| 10 | pub use client; | 9 | use anyhow::{anyhow, Result}; |
| 11 | pub use config; | 10 | use directories::ProjectDirs; |
| 12 | pub use git; | 11 | |
| 13 | pub use key_handling; | 12 | pub fn get_dirs() -> Result<ProjectDirs> { |
| 14 | pub use login; | 13 | ProjectDirs::from("", "", "ngit").ok_or(anyhow!( |
| 15 | pub use repo_ref; | 14 | "should find operating system home directories with rust-directories crate" |
| 16 | pub 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; | |||
| 16 | use crate::{ | 16 | use 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)] |