diff --git a/CHANGELOG.md b/CHANGELOG.md index a64daf4..a3e5c81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New `config pow` command to set the default PoW difficulty - by Awiteb - New `config relays` command to set the default fallbacks relays - by Awiteb - New `issue view` command to view an issue - by Awiteb +- New `patch fetch` command to fetch patches - by Awiteb ### Refactor diff --git a/README.md b/README.md index 119ec90..ca084cc 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,10 @@ details, see the following section. - [X] Repository announcements - [ ] Repository state announcements -- [ ] Patches (Send and download) -- [ ] Issues (Send and view) +- [X] Patches (Send and fetch) +- [X] Issues (Send and view) - [X] Replies -- [ ] Status +- [ ] Issues and patches status - [X] Gossip Model ([NIP-65]) - [X] Proof of Work ([NIP-13]) - [X] `nostr:` URI scheme, in the issue/reply content ([NIP-21]) diff --git a/src/cli/commands/patch/fetch.rs b/src/cli/commands/patch/fetch.rs new file mode 100644 index 0000000..4e6ea24 --- /dev/null +++ b/src/cli/commands/patch/fetch.rs @@ -0,0 +1,153 @@ +// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr +// Copyright (C) 2025 Awiteb +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use std::{ + fs, + path::{Path, PathBuf}, + str::FromStr, +}; + +use clap::Args; +use nostr::{ + event::{Kind, TagKind}, + filter::Filter, + nips::{nip01::Coordinate, nip19::ToBech32}, +}; + +use crate::{ + cli::{ + CliOptions, + traits::CommandRunner, + types::{NaddrOrSet, NostrEvent, OptionNaddrOrSetVecExt, RelayOrSetVecExt}, + }, + error::{N34Error, N34Result}, + nostr_utils::{ + NostrClient, + traits::{NaddrsUtils, ReposUtils}, + utils, + }, +}; + +#[derive(Debug, Args)] +pub struct FetchArgs { + /// Repository address in `naddr` format (`naddr1...`), NIP-05 format + /// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`. + /// + /// If omitted, looks for a `nostr-address` file. + #[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")] + naddrs: Option>, + /// Output directory for the patches. Default to the current directory + #[arg(short, long, value_name = "PATH")] + output: Option, + /// The patch id to fetch it + patch_id: NostrEvent, +} + +impl CommandRunner for FetchArgs { + const NEED_SIGNER: bool = false; + + async fn run(self, options: CliOptions) -> N34Result<()> { + let naddrs = utils::naddrs_or_file( + self.naddrs.flat_naddrs(&options.config.sets)?, + &utils::nostr_address_path()?, + )?; + let relays = options.relays.clone().flat_relays(&options.config.sets)?; + let client = NostrClient::init(&options, &relays).await; + let output_path = self.output.unwrap_or_default(); + + client + .add_relays( + &[ + naddrs.extract_relays(), + self.patch_id.relays, + client + .fetch_repos(&naddrs.into_coordinates()) + .await? + .extract_relays(), + ] + .concat(), + ) + .await; + + let root_patch = client + .fetch_event( + Filter::new() + .id(self.patch_id.event_id) + .kind(Kind::GitPatch), + ) + .await? + .ok_or(N34Error::CanNotFoundPatch)?; + + if !root_patch + .tags + .iter() + .any(|t| t.kind() == TagKind::t() && t.content().is_some_and(|c| c == "root")) + { + return Err(N34Error::NotRootPatch); + } + let root_first_coordinate = Coordinate::parse( + root_patch + .tags + .find(TagKind::a()) + .and_then(|t| t.content()) + .ok_or(N34Error::InvalidEvent( + "The patch does not contain the tag `a` coordinate of the repository" + .to_owned(), + ))?, + ) + .map_err(|err| N34Error::InvalidEvent(err.to_string()))?; + let root_author = root_patch.pubkey; + let root_patch = super::GitPatch::from_str(&root_patch.content) + .map_err(|err| N34Error::InvalidEvent(format!("Failed to parse the patch: {err}")))?; + + tracing::info!("Found the root patch: `{}`", root_patch.subject); + + let mut patches = client + .fetch_events( + Filter::new() + .kind(Kind::GitPatch) + .author(root_author) + .event(self.patch_id.event_id) + .coordinate(&root_first_coordinate), + ) + .await? + .into_iter() + .map(|p| { + let patch = super::GitPatch::from_str(&p.content).map_err(|err| { + N34Error::InvalidEvent(format!( + "Failed to parse the patch `{}`: {err}", + p.id.to_bech32().expect("Infallible") + )) + })?; + N34Result::Ok((patch.filename(&output_path)?, patch)) + }) + .collect::>>()?; + patches.push((root_patch.filename(&output_path)?, root_patch)); + patches.sort_unstable_by_key(|p| p.0.clone()); + patches.dedup_by_key(|p| p.0.clone()); + + if output_path.as_path() != Path::new("") && !output_path.exists() { + fs::create_dir_all(&output_path)?; + } + + for (patch_path, patch) in patches { + tracing::info!("Writeing `{}` in `{}`", patch.subject, patch_path.display()); + fs::write(patch_path, patch.inner)?; + } + + Ok(()) + } +} diff --git a/src/cli/commands/patch/mod.rs b/src/cli/commands/patch/mod.rs index da83c49..1aca687 100644 --- a/src/cli/commands/patch/mod.rs +++ b/src/cli/commands/patch/mod.rs @@ -14,17 +14,24 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +/// `patch fetch` subcommand +mod fetch; /// `patch send` subcommand mod send; -use std::{str::FromStr, sync::LazyLock}; +use std::{ + path::{Path, PathBuf}, + str::FromStr, + sync::LazyLock, +}; use clap::Subcommand; use regex::Regex; +use self::fetch::FetchArgs; use self::send::SendArgs; use super::{CliOptions, CommandRunner}; -use crate::error::N34Result; +use crate::error::{N34Error, N34Result}; /// Regular expression for extracting the patch subject. @@ -35,10 +42,17 @@ static SUBJECT_RE: LazyLock = static BODY_RE: LazyLock = LazyLock::new(|| Regex::new(r"\n\n((?:.|\n)*?)(?:\n--[ -]|\z)").unwrap()); +/// Regular expiration for extracting the patch version and number +static PATCH_VERSION_NUMBER_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"\[PATCH\s+(?:v(?\d+)\s*)?(?\d+)/(?:\d+)").unwrap() +}); + #[derive(Subcommand, Debug)] pub enum PatchSubcommands { /// Send one or more patches to a repository Send(SendArgs), + /// Fetches a patch by its id + Fetch(FetchArgs), } /// Represents a git patch @@ -52,9 +66,31 @@ pub struct GitPatch { pub body: String, } +impl GitPatch { + /// Returns the patch file name from the subject + pub fn filename(&self, parent: impl AsRef) -> N34Result { + let (patch_version, patch_number) = if self.subject.contains("[PATCH]") { + (String::new(), "1") + } else { + patch_version_and_subject(&self.subject)? + }; + + let patch_name = if patch_number == "0" { + "cover-letter".to_owned() + } else { + patch_file_name(&self.subject)? + }; + + Ok(parent + .as_ref() + .join(format!("{patch_version}{:0>4}-{patch_name}", patch_number).replace("--", "-")) + .with_extension("patch")) + } +} + impl CommandRunner for PatchSubcommands { async fn run(self, options: CliOptions) -> N34Result<()> { - crate::run_command!(self, options, &Send) + crate::run_command!(self, options, & Send Fetch) } } @@ -88,6 +124,53 @@ impl FromStr for GitPatch { } } +/// Extracts the version prefix and patch number from a patch subject string. +/// +/// The version prefix is formatted as "v{version}-" if present, or an empty +/// string. The patch number is mandatory and will cause an error if not found. +fn patch_version_and_subject(subject: &str) -> N34Result<(String, &str)> { + let captures = PATCH_VERSION_NUMBER_RE.captures(subject).ok_or_else(|| { + N34Error::InvalidEvent(format!("Can not parse the patch subject `{subject}`")) + })?; + Ok(( + captures + .name("version") + .map(|m| format!("v{}-", m.as_str())) + .unwrap_or_default(), + captures + .name("number") + .map(|m| m.as_str()) + .expect("It's not optional, regex will fail if it's not found"), + )) +} + +/// Extracts a clean file name from the patch subject by removing version info +/// and special characters. Converts to lowercase and ensures the name only +/// contains alphanumeric, '.', '-', or '_' characters. +fn patch_file_name(subject: &str) -> N34Result { + Ok(subject + .split_once("]") + .ok_or_else(|| { + N34Error::InvalidEvent(format!( + "Invalid patch subject. No `[PATCH ...]`: `{subject}`", + )) + })? + .1 + .trim() + .to_lowercase() + .replace( + |c: char| !c.is_ascii_alphanumeric() && !['.', '-', '_'].contains(&c), + "-", + ) + .chars() + .take(60) + .collect::() + .trim_matches('-') + .trim() + .replace("--", "-")) +} + + #[cfg(test)] mod tests { use super::*; @@ -339,4 +422,95 @@ Awiteb (1): base-commit: f670859b92d525874fd621452080c8479964ac6a" ); } + + #[test] + fn normal_patch_filename() { + let mut patch = GitPatch { + inner: String::new(), + subject: String::new(), + body: String::new(), + }; + + patch.subject = "[PATCH v2 0/3] feat: Some test just a test".to_owned(); + assert_eq!( + patch.filename("").unwrap(), + PathBuf::from("v2-0000-cover-letter.patch") + ); + patch.subject = "[PATCH 0/3] feat: Some test just a test".to_owned(); + assert_eq!( + patch.filename("").unwrap(), + PathBuf::from("0000-cover-letter.patch") + ); + patch.subject = "[PATCH v2 1/3] feat: Some test just a test".to_owned(); + assert_eq!( + patch.filename("").unwrap(), + PathBuf::from("v2-0001-feat-some-test-just-a-test.patch") + ); + patch.subject = "[PATCH v42 1/3] feat: Some test just a test".to_owned(); + assert_eq!( + patch.filename("").unwrap(), + PathBuf::from("v42-0001-feat-some-test-just-a-test.patch") + ); + patch.subject = "[PATCH v42 23/30] feat: Some test just a test".to_owned(); + assert_eq!( + patch.filename("").unwrap(), + PathBuf::from("v42-0023-feat-some-test-just-a-test.patch") + ); + patch.subject = "[PATCH 1/3] feat: Some test just a test".to_owned(); + assert_eq!( + patch.filename("").unwrap(), + PathBuf::from("0001-feat-some-test-just-a-test.patch") + ); + patch.subject = "[PATCH 32/50] feat: Some test just a test".to_owned(); + assert_eq!( + patch.filename("").unwrap(), + PathBuf::from("0032-feat-some-test-just-a-test.patch") + ); + patch.subject = "[PATCH v100 32/50] feat: some long subject some long subject some long \ + subject some long subject" + .to_owned(); + assert_eq!( + patch.filename("").unwrap(), + PathBuf::from( + "v100-0032-feat-some-long-subject-some-long-subject-some-long-subject-s.patch" + ) + ); + } + + #[test] + fn patch_filename_without_patch() { + let mut patch = GitPatch { + inner: String::new(), + subject: "[RFC v5 1/2] Something".to_owned(), + body: String::new(), + }; + + assert!(patch.filename("").is_err()); + patch.subject = "Something".to_owned(); + assert!(patch.filename("").is_err()); + } + + #[test] + fn patch_filename_without_number() { + let mut patch = GitPatch { + inner: String::new(), + subject: "[PATCH v5 /2] Something".to_owned(), + body: String::new(), + }; + + assert!(patch.filename("").is_err()); + patch.subject = "[PATCH v5 2/] Something".to_owned(); + assert!(patch.filename("").is_err()); + } + + #[test] + fn patch_filename_without_version() { + let patch = GitPatch { + inner: String::new(), + subject: "[PATCH 1/2] Something".to_owned(), + body: String::new(), + }; + + assert!(patch.filename("").is_ok()); + } } diff --git a/src/error.rs b/src/error.rs index 0e59df5..e2f94d2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -85,6 +85,12 @@ pub enum N34Error { "Issue not found, make sure it is in the relays and make sure that the ID is an issue ID" )] CanNotFoundIssue, + #[error( + "Patch not found, make sure it is in the relays and make sure that the ID is an patch ID" + )] + CanNotFoundPatch, + #[error("The given patch id is not a root patch")] + NotRootPatch, } impl N34Error { diff --git a/src/nostr_utils/mod.rs b/src/nostr_utils/mod.rs index 8178335..f5a1a92 100644 --- a/src/nostr_utils/mod.rs +++ b/src/nostr_utils/mod.rs @@ -206,6 +206,16 @@ impl NostrClient { .first_owned()) } + /// Fetches the events matching the given filter + pub async fn fetch_events(&self, filter: Filter) -> N34Result> { + // Multiply timeout by 5 to account for multiple events being fetched + Ok(self + .client + .fetch_events(filter, CLIENT_TIMEOUT * 5) + .await? + .to_vec()) + } + /// Try to fetch the reposotoies and returns them pub async fn fetch_repos( &self,