diff --git a/src/cli/commands/issue/new.rs b/src/cli/commands/issue/new.rs index 9c51898..850ec19 100644 --- a/src/cli/commands/issue/new.rs +++ b/src/cli/commands/issue/new.rs @@ -16,12 +16,20 @@ use clap::{ArgGroup, Args}; -use nostr::{event::EventBuilder, nips::nip19::Nip19Coordinate}; +use futures::future; +use nostr::{ + event::{EventBuilder, Tag}, + nips::nip19::Nip19Coordinate, +}; use crate::{ cli::{CliOptions, CommandRunner, parsers}, error::N34Result, - nostr_utils::{NostrClient, traits::NewGitRepositoryAnnouncement, utils}, + nostr_utils::{ + NostrClient, + traits::{NaddrsUtils, NewGitRepositoryAnnouncement, ReposUtils}, + utils, + }, }; @@ -44,7 +52,7 @@ pub struct NewArgs { /// /// If not provided, `n34` will look for the `nostr-address` file. #[arg(value_name = "NADDR-OR-NIP05", long = "repo", value_parser = parsers::repo_naddr)] - naddr: Option, + naddrs: Option>, /// Markdown content for the issue. Cannot be used together with the /// `--editor` flag. #[arg(short, long)] @@ -89,24 +97,54 @@ impl CommandRunner for NewArgs { async fn run(self, options: CliOptions) -> N34Result<()> { let client = NostrClient::init(&options).await; let user_pubk = options.pubkey().await?; - let naddr = utils::naddr_or_file(self.naddr.clone(), &utils::nostr_address_path()?)?; + let naddrs = utils::naddrs_or_file(self.naddrs.clone(), &utils::nostr_address_path()?)?; + let mut naddrs_iter = naddrs.clone().into_iter(); - client.add_relays(&naddr.relays).await; + client.add_relays(&naddrs.extract_relays()).await; let relays_list = client.user_relays_list(user_pubk).await?; let mut write_relays = utils::add_write_relays(options.relays.clone(), relays_list.as_ref()); - write_relays.extend(client.fetch_repo(&naddr.coordinate).await?.relays); + write_relays.extend( + client + .fetch_repos(&naddrs.into_coordinates()) + .await? + .extract_relays(), + ); let (subject, content) = self.issue_content()?; let content_details = client.parse_content(&content).await; write_relays.extend(content_details.write_relays.clone()); - let event = - EventBuilder::new_git_issue(naddr.coordinate.clone(), content, subject, self.label)? - .tags(content_details.into_tags()) - .pow(options.pow) - .build(user_pubk); + // Include read relays for each repository owner (if found) + write_relays.extend( + future::join_all( + naddrs_iter + .clone() + .map(|c| client.read_relays_from_user(Vec::new(), c.public_key)), + ) + .await + .into_iter() + .flatten(), + ); + + let event = EventBuilder::new_git_issue( + naddrs_iter + .next() + .expect("There is at least one address") + .coordinate + .clone(), + content, + subject, + self.label, + )? + .pow(options.pow) + .tags(content_details.into_tags()) + // p-tag the reset of the reposotoies owners + .tags(naddrs_iter.clone().map(|n| Tag::public_key(n.public_key))) + // a-tag the reset of the reposotoies + .tags(naddrs_iter.map(|n| Tag::coordinate(n.coordinate, n.relays.first().cloned()))) + .build(user_pubk); let event_id = event.id.expect("There is an id"); tracing::trace!(relays = ?write_relays, "Write relays list"); diff --git a/src/cli/commands/reply.rs b/src/cli/commands/reply.rs index a4aeb87..258a51f 100644 --- a/src/cli/commands/reply.rs +++ b/src/cli/commands/reply.rs @@ -17,6 +17,7 @@ use std::{fs, str::FromStr}; use clap::{ArgGroup, Args}; +use futures::future; use nostr::{ event::{Event, EventBuilder, EventId, Kind, Tag}, filter::Filter, @@ -31,7 +32,11 @@ use super::{CliOptions, CommandRunner}; use crate::{ cli::parsers, error::{N34Error, N34Result}, - nostr_utils::{NostrClient, utils}, + nostr_utils::{ + NostrClient, + traits::{NaddrsUtils, ReposUtils}, + utils, + }, }; /// Length of a Nostr npub (public key) in characters. @@ -101,7 +106,7 @@ pub struct ReplyArgs { /// If not provided, `n34` will look for the `nostr-address` file and if not /// found, will get it from the root event if found. #[arg(value_name = "NADDR-OR-NIP05", long = "repo", value_parser = parsers::repo_naddr)] - naddr: Option, + naddrs: Option>, /// The comment (cannot be used with --editor) #[arg(short, long)] comment: Option, @@ -116,13 +121,13 @@ impl CommandRunner for ReplyArgs { let client = NostrClient::init(&options).await; let user_pubk = options.pubkey().await?; - let repo_naddr = if let Some(naddr) = self.naddr { - client.add_relays(&naddr.relays).await; - Some(naddr.coordinate) + let repo_naddrs = if let Some(naddrs) = self.naddrs { + client.add_relays(&naddrs.extract_relays()).await; + Some(naddrs) } else if fs::exists(&nostr_address_path).is_ok() { - let naddr = utils::naddr_or_file(None, &nostr_address_path)?; - client.add_relays(&naddr.relays).await; - Some(naddr.coordinate) + let naddrs = utils::naddrs_or_file(None, &nostr_address_path)?; + client.add_relays(&naddrs.extract_relays()).await; + Some(naddrs) } else { None }; @@ -139,16 +144,28 @@ impl CommandRunner for ReplyArgs { .ok_or(N34Error::EventNotFound)?; let root = client.find_root(reply_to.clone()).await?; - let repo_naddr = if let Some(naddr) = repo_naddr { - naddr + let repos_coordinate = if let Some(naddrs) = repo_naddrs { + naddrs.into_coordinates() } else if let Some(ref root_event) = root { - naddr_from_root(root_event)? + coordinates_from_root(root_event)? } else { return Err(N34Error::NotFoundRepo); }; - let repo = client.fetch_repo(&repo_naddr).await?; - write_relays.extend(repo.relays.clone()); + let repos = client.fetch_repos(&repos_coordinate).await?; + // Merge repository announcement relays into write relays + write_relays.extend(repos.extract_relays()); + // Include read relays for each repository owner (if found) + write_relays.extend( + future::join_all( + repos_coordinate + .iter() + .map(|c| client.read_relays_from_user(Vec::new(), c.public_key)), + ) + .await + .into_iter() + .flatten(), + ); write_relays = client .read_relays_from_user(write_relays, reply_to.pubkey) @@ -173,11 +190,11 @@ impl CommandRunner for ReplyArgs { content, &reply_to, root.as_ref(), - repo.relays.first().cloned(), + repos.first().and_then(|r| r.relays.first()).cloned(), ) - .tag(Tag::public_key(repo_naddr.public_key)) - .tags(content_details.into_tags()) .pow(options.pow) + .tags(content_details.into_tags()) + .tag(Tag::public_key(reply_to.pubkey)) .build(user_pubk); let event_id = event.id.expect("There is an id"); @@ -245,15 +262,19 @@ async fn quote_reply_to_content(client: &NostrClient, quoted_event: &Event) -> S /// Gets the repository coordinate from a root Nostr event's tags. /// The event must contain a coordinate tag with GitRepoAnnouncement kind. -fn naddr_from_root(root: &Event) -> N34Result { - Ok(root +fn coordinates_from_root(root: &Event) -> N34Result> { + let coordinates: Vec = root .tags .coordinates() - .find(|c| c.kind == Kind::GitRepoAnnouncement) - .ok_or_else(|| { - N34Error::InvalidEvent( - "The Git issue/patch does not specify a target repository".to_owned(), - ) - })? - .clone()) + .filter(|c| c.kind == Kind::GitRepoAnnouncement) + .cloned() + .collect(); + + if coordinates.is_empty() { + return Err(N34Error::InvalidEvent( + "The Git issue/patch does not specify a target repository".to_owned(), + )); + } + + Ok(coordinates) } diff --git a/src/cli/commands/repo/announce.rs b/src/cli/commands/repo/announce.rs index 3fd762a..4b1009b 100644 --- a/src/cli/commands/repo/announce.rs +++ b/src/cli/commands/repo/announce.rs @@ -14,9 +14,10 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use std::fs; +use std::{fs, io::Write}; use clap::Args; +use futures::future; use nostr::{event::EventBuilder, key::PublicKey, types::Url}; use crate::{ @@ -25,6 +26,22 @@ use crate::{ nostr_utils::{NostrClient, traits::NewGitRepositoryAnnouncement, utils}, }; +/// Header written to new `nostr-address` files. Contains two trailing newline +/// for formatting. +const NOSTR_ADDRESS_FILE_HEADER: &str = r##"# This file contains NIP-19 `naddr` entities for repositories that accept this +# project's issues and patches. +# +# The file acts as a **read-only reference** for retrieving repository relays +# when embedded in an `naddr` and mentions those repositories when opening +# patches or issues. Modifications here will not affect in the relays, as the +# file is **explicitly untracked**. Its goal is to simplify contributions by +# removing the need for manual address entry. +# +# Each entry must start with "naddr". Embedded relays are **strongly recommended** +# to assist client-side discovery. +# +# Empty lines are ignored. Lines starting with "#" are treated as comments. +"##; /// Arguments for the `repo announce` command #[derive(Args, Debug)] @@ -66,12 +83,25 @@ impl CommandRunner for AnnounceArgs { let client = NostrClient::init(&options).await; let user_pubk = options.pubkey().await?; let relays_list = client.user_relays_list(user_pubk).await?; - let write_relays = utils::add_write_relays(options.relays.clone(), relays_list.as_ref()); + let mut write_relays = + utils::add_write_relays(options.relays.clone(), relays_list.as_ref()); if !self.maintainers.contains(&user_pubk) { self.maintainers.insert(0, user_pubk); } + // Include read relays for each maintainer (if found) + write_relays.extend( + future::join_all( + self.maintainers + .iter() + .map(|pkey| client.read_relays_from_user(Vec::new(), *pkey)), + ) + .await + .into_iter() + .flatten(), + ); + let event = EventBuilder::new_git_repo( self.repo_id, self.name.map(utils::str_trim), @@ -90,9 +120,21 @@ impl CommandRunner for AnnounceArgs { let naddr = utils::repo_naddr(user_pubk, &options.relays)?; if self.address_file { - let path = std::env::current_dir()?.join(NOSTR_ADDRESS_FILE); - tracing::info!("Create the `nostr-address` file at `{}`", path.display()); - fs::write(path, &naddr)?; + let address_path = std::env::current_dir()?.join(NOSTR_ADDRESS_FILE); + if !address_path.exists() { + tracing::info!( + "Creating new address file: '{NOSTR_ADDRESS_FILE}' at path '{}' with default \ + header", + address_path.display() + ); + fs::write(&address_path, NOSTR_ADDRESS_FILE_HEADER)?; + } + + let mut file = fs::OpenOptions::new().append(true).open(&address_path)?; + + tracing::info!("Appending naddr '{naddr}' to address file: '{NOSTR_ADDRESS_FILE}'",); + file.write_all(naddr.as_bytes())?; + tracing::info!("Successfully wrote naddr to address file"); } client diff --git a/src/cli/commands/repo/view.rs b/src/cli/commands/repo/view.rs index e1f5ba2..ff6f679 100644 --- a/src/cli/commands/repo/view.rs +++ b/src/cli/commands/repo/view.rs @@ -22,7 +22,7 @@ use nostr::nips::nip19::Nip19Coordinate; use crate::{ cli::{CliOptions, CommandRunner, parsers}, error::N34Result, - nostr_utils::{NostrClient, utils}, + nostr_utils::{NostrClient, traits::NaddrsUtils, utils}, }; /// Arguments for the `repo view` command @@ -33,46 +33,51 @@ pub struct ViewArgs { /// /// If not provided, `n34` will look for the `nostr-address` file. #[arg(value_name = "NADDR-OR-NIP05", long = "repo", value_parser = parsers::repo_naddr)] - naddr: Option, + naddrs: Option>, } impl CommandRunner for ViewArgs { async fn run(self, options: CliOptions) -> N34Result<()> { // FIXME: The signer is not required here - let naddr = utils::naddr_or_file(self.naddr, &utils::nostr_address_path()?)?; + let naddrs = utils::naddrs_or_file(self.naddrs, &utils::nostr_address_path()?)?; let client = NostrClient::init(&options).await; - client.add_relays(&naddr.relays).await; + client.add_relays(&naddrs.extract_relays()).await; - let repo = client.fetch_repo(&naddr.coordinate).await?; - let mut msg = format!("ID: {}", repo.id); + let repos = client.fetch_repos(&naddrs.into_coordinates()).await?; + let mut repos_details: Vec = Vec::new(); - if let Some(name) = repo.name { - msg.push_str(&format!("\nName: {name}")); - } - if let Some(desc) = repo.description { - msg.push_str(&format!("\nDescription: {desc}")); - } - if !repo.web.is_empty() { - msg.push_str(&format!("\nWebpages:\n{}", format_list(repo.web))); - } - if !repo.clone.is_empty() { - msg.push_str(&format!("\nClone urls:\n{}", format_list(repo.clone))); - } - if !repo.relays.is_empty() { - msg.push_str(&format!("\nRelays:\n{}", format_list(repo.relays))); - } - if let Some(euc) = repo.euc { - msg.push_str(&format!("\nEarliest unique commit: {euc}")); - } - if !repo.maintainers.is_empty() { - msg.push_str(&format!( - "\nMaintainers:\n{}", - format_list(repo.maintainers) - )); + for repo in repos { + let mut repo_details = format!("ID: {}", repo.id); + + if let Some(name) = repo.name { + repo_details.push_str(&format!("\nName: {name}")); + } + if let Some(desc) = repo.description { + repo_details.push_str(&format!("\nDescription: {desc}")); + } + if !repo.web.is_empty() { + repo_details.push_str(&format!("\nWebpages:\n{}", format_list(repo.web))); + } + if !repo.clone.is_empty() { + repo_details.push_str(&format!("\nClone urls:\n{}", format_list(repo.clone))); + } + if !repo.relays.is_empty() { + repo_details.push_str(&format!("\nRelays:\n{}", format_list(repo.relays))); + } + if let Some(euc) = repo.euc { + repo_details.push_str(&format!("\nEarliest unique commit: {euc}")); + } + if !repo.maintainers.is_empty() { + repo_details.push_str(&format!( + "\nMaintainers:\n{}", + format_list(repo.maintainers) + )); + } + repos_details.push(repo_details); } - println!("{msg}"); + println!("{}", repos_details.join("\n----------\n")); Ok(()) } } diff --git a/src/cli/parsers.rs b/src/cli/parsers.rs index 09c2cc8..92279ab 100644 --- a/src/cli/parsers.rs +++ b/src/cli/parsers.rs @@ -14,6 +14,8 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +use std::{fs, path::Path}; + use nostr::{ Kind, nips::{ @@ -24,6 +26,8 @@ use nostr::{ }; use tokio::runtime::Handle; +use crate::error::{N34Error, N34Result}; + fn parse_nip5_repo(nip5: &str, repo_id: &str) -> Result { let (username, domain) = nip5.split_once("@").unwrap_or(("_", nip5)); @@ -42,6 +46,34 @@ fn parse_nip5_repo(nip5: &str, repo_id: &str) -> Result .expect("The relays is `RelayUrl`")) } +fn parse_repo_naddr(repo_naddr: &str) -> Result { + let naddr = Nip19Coordinate::from_bech32(repo_naddr).map_err(|err| err.to_string())?; + if naddr.relays.is_empty() { + tracing::warn!("The repository naddr does not contain any relay hints"); + } + + (naddr.kind == Kind::GitRepoAnnouncement) + .then_some(naddr) + .ok_or_else(|| "Invalid naddr: must be of kind 30617 (GitRepoAnnouncement)".to_owned()) +} + +/// Parses a nostr-address file into a NIP-19 coordinates. Expects the file to +/// contain a repository announcements. +pub fn parse_nostr_address_file(file_path: &Path) -> N34Result> { + let addresses = fs::read_to_string(file_path) + .map_err(N34Error::CanNotReadNostrAddressFile)? + .split("\n") + .filter_map(|line| { + (!line.starts_with("#") && !line.trim().is_empty()) + .then_some(parse_repo_naddr(line).map_err(N34Error::InvalidNostrAddressFileContent)) + }) + .collect::>>()?; + if addresses.is_empty() { + return Err(N34Error::EmptyNostrAddressFile); + } + Ok(addresses) +} + /// Parses a Git repository address which can be either: /// - A bech32-encoded naddr (e.g. "naddr1...") for Git repository announcements /// (kind 30617) @@ -50,20 +82,19 @@ fn parse_nip5_repo(nip5: &str, repo_id: &str) -> Result /// /// Returns an error for invalid formats, failed bech32 decoding, wrong event /// kind. -pub fn repo_naddr(repo_address: &str) -> Result { - if repo_address.contains("/") { - let (nip5, repo_id) = repo_address.split_once("/").expect("There is a `/`"); - return parse_nip5_repo(nip5, repo_id); - } +pub fn repo_naddr(repo: &str) -> Result { + let repo = repo.trim(); - let naddr = Nip19Coordinate::from_bech32(repo_address).map_err(|err| err.to_string())?; - if naddr.kind != Kind::GitRepoAnnouncement { - return Err("The naddr is not repo announcement address".to_owned()); + if repo.contains("/") { + let (nip5, repo_id) = repo.split_once("/").expect("There is a `/`"); + parse_nip5_repo(nip5, repo_id) + } else if repo.starts_with("naddr1") { + parse_repo_naddr(repo) + } else { + Err( + "Invalid repository address format. It can be A NIP-05 identifier with repository ID \ + in format `/` or A valid naddr1 string (NIP-19)" + .to_owned(), + ) } - - if naddr.relays.is_empty() { - tracing::warn!("The repository naddr does not contain any relay hints"); - } - - Ok(naddr) } diff --git a/src/nostr_utils/mod.rs b/src/nostr_utils/mod.rs index 63474de..0e9bdc6 100644 --- a/src/nostr_utils/mod.rs +++ b/src/nostr_utils/mod.rs @@ -195,20 +195,27 @@ impl NostrClient { .first_owned()) } - /// Try to fetch a repository and returns it - pub async fn fetch_repo( + /// Try to fetch the reposotoies and returns them + pub async fn fetch_repos( &self, - repo_naddr: &Coordinate, - ) -> N34Result { - let filter = Filter::new() - .author(repo_naddr.public_key) - .kind(Kind::GitRepoAnnouncement) - .identifier(&repo_naddr.identifier); - - self.fetch_event(filter) - .await? - .map(|e| utils::event_into_repo(e, &repo_naddr.identifier)) - .ok_or(N34Error::NotFoundRepo) + repo_naddrs: &[Coordinate], + ) -> N34Result> { + future::join_all(repo_naddrs.iter().map(|c| { + async { + self.fetch_event( + Filter::new() + .author(c.public_key) + .identifier(&c.identifier) + .kind(Kind::GitRepoAnnouncement), + ) + .await? + .map(|e| utils::event_into_repo(e, &c.identifier)) + .ok_or(N34Error::NotFoundRepo) + } + })) + .await + .into_iter() + .collect() } /// Finds the root issue or patch for a given event. If the event is already diff --git a/src/nostr_utils/traits.rs b/src/nostr_utils/traits.rs index f0fad70..ac04455 100644 --- a/src/nostr_utils/traits.rs +++ b/src/nostr_utils/traits.rs @@ -20,6 +20,7 @@ use nostr::{ key::PublicKey, nips::{ nip01::Coordinate, + nip19::Nip19Coordinate, nip21::Nip21, nip34::{GitIssue, GitRepositoryAnnouncement}, }, @@ -164,3 +165,26 @@ impl Token<'_> { } } } + +/// Utility functions for working with lists of NIP-19 coordinates +#[easy_ext::ext(NaddrsUtils)] +impl Vec { + /// Converts these coordinate addresses to basic coordinates + pub fn into_coordinates(self) -> Vec { + self.into_iter().map(|n| n.coordinate).collect() + } + + /// Extracts all relay URLs from these coordinates + pub fn extract_relays(&self) -> Vec { + self.iter().flat_map(|n| n.relays.clone()).collect() + } +} + +/// Utility functions for working with lists of repository announcement +#[easy_ext::ext(ReposUtils)] +impl Vec { + /// Extracts all relay URLs from these reposotoies + pub fn extract_relays(&self) -> Vec { + self.iter().flat_map(|n| n.relays.clone()).collect() + } +} diff --git a/src/nostr_utils/utils.rs b/src/nostr_utils/utils.rs index bc91806..6524967 100644 --- a/src/nostr_utils/utils.rs +++ b/src/nostr_utils/utils.rs @@ -220,21 +220,12 @@ pub fn nostr_address_path() -> std::io::Result { /// If the given coordinate is Some, return it. Otherwise, try to read and parse /// the first non-comment line from the `nostr-address` file. -pub fn naddr_or_file( - naddr: Option, +pub fn naddrs_or_file( + naddrs: Option>, address_file_path: &Path, -) -> N34Result { - if let Some(naddr) = naddr { - return Ok(naddr); +) -> N34Result> { + if let Some(naddrs) = naddrs { + return Ok(naddrs); } - - parsers::repo_naddr( - fs::read_to_string(address_file_path) - .map_err(N34Error::CanNotReadNostrAddressFile)? - .split("\n") - .find(|line| !line.starts_with("#") && !line.trim().is_empty()) - .ok_or(N34Error::EmptyNostrAddressFile)? - .trim(), - ) - .map_err(N34Error::InvalidNostrAddressFileContent) + parsers::parse_nostr_address_file(address_file_path) }