From 364356a573973eb75945850be52a993882d57dd7 Mon Sep 17 00:00:00 2001
From: Awiteb
Date: Tue, 10 Jun 2025 16:16:57 +0000
Subject: [PATCH] feat: New `patch fetch` command to fetch patches
Signed-off-by: Awiteb
---
CHANGELOG.md | 1 +
README.md | 6 +-
src/cli/commands/patch/fetch.rs | 153 +++++++++++++++++++++++++++
src/cli/commands/patch/mod.rs | 180 +++++++++++++++++++++++++++++++-
src/error.rs | 6 ++
src/nostr_utils/mod.rs | 10 ++
6 files changed, 350 insertions(+), 6 deletions(-)
create mode 100644 src/cli/commands/patch/fetch.rs
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,