feat: New patch fetch command to fetch patches

Signed-off-by: Awiteb <a@4rs.nl>
This commit is contained in:
Awiteb
2025-06-10 16:16:57 +00:00
parent 2a3e2e09c0
commit 364356a573
6 changed files with 350 additions and 6 deletions

View File

@@ -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 pow` command to set the default PoW difficulty - by Awiteb
- New `config relays` command to set the default fallbacks relays - 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 `issue view` command to view an issue - by Awiteb
- New `patch fetch` command to fetch patches - by Awiteb
### Refactor ### Refactor

View File

@@ -20,10 +20,10 @@ details, see the following section.
- [X] Repository announcements - [X] Repository announcements
- [ ] Repository state announcements - [ ] Repository state announcements
- [ ] Patches (Send and download) - [X] Patches (Send and fetch)
- [ ] Issues (Send and view) - [X] Issues (Send and view)
- [X] Replies - [X] Replies
- [ ] Status - [ ] Issues and patches status
- [X] Gossip Model ([NIP-65]) - [X] Gossip Model ([NIP-65])
- [X] Proof of Work ([NIP-13]) - [X] Proof of Work ([NIP-13])
- [X] `nostr:` URI scheme, in the issue/reply content ([NIP-21]) - [X] `nostr:` URI scheme, in the issue/reply content ([NIP-21])

View File

@@ -0,0 +1,153 @@
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
// Copyright (C) 2025 Awiteb <a@4rs.nl>
//
// 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 <https://gnu.org/licenses/gpl-3.0.html>.
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<Vec<NaddrOrSet>>,
/// Output directory for the patches. Default to the current directory
#[arg(short, long, value_name = "PATH")]
output: Option<PathBuf>,
/// 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::<N34Result<Vec<_>>>()?;
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(())
}
}

View File

@@ -14,17 +14,24 @@
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>. // along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
/// `patch fetch` subcommand
mod fetch;
/// `patch send` subcommand /// `patch send` subcommand
mod send; mod send;
use std::{str::FromStr, sync::LazyLock}; use std::{
path::{Path, PathBuf},
str::FromStr,
sync::LazyLock,
};
use clap::Subcommand; use clap::Subcommand;
use regex::Regex; use regex::Regex;
use self::fetch::FetchArgs;
use self::send::SendArgs; use self::send::SendArgs;
use super::{CliOptions, CommandRunner}; use super::{CliOptions, CommandRunner};
use crate::error::N34Result; use crate::error::{N34Error, N34Result};
/// Regular expression for extracting the patch subject. /// Regular expression for extracting the patch subject.
@@ -35,10 +42,17 @@ static SUBJECT_RE: LazyLock<Regex> =
static BODY_RE: LazyLock<Regex> = static BODY_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\n\n((?:.|\n)*?)(?:\n--[ -]|\z)").unwrap()); 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<Regex> = LazyLock::new(|| {
Regex::new(r"\[PATCH\s+(?:v(?<version>\d+)\s*)?(?<number>\d+)/(?:\d+)").unwrap()
});
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
pub enum PatchSubcommands { pub enum PatchSubcommands {
/// Send one or more patches to a repository /// Send one or more patches to a repository
Send(SendArgs), Send(SendArgs),
/// Fetches a patch by its id
Fetch(FetchArgs),
} }
/// Represents a git patch /// Represents a git patch
@@ -52,9 +66,31 @@ pub struct GitPatch {
pub body: String, pub body: String,
} }
impl GitPatch {
/// Returns the patch file name from the subject
pub fn filename(&self, parent: impl AsRef<Path>) -> N34Result<PathBuf> {
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 { impl CommandRunner for PatchSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> { 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<String> {
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::<String>()
.trim_matches('-')
.trim()
.replace("--", "-"))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -339,4 +422,95 @@ Awiteb (1):
base-commit: f670859b92d525874fd621452080c8479964ac6a" 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());
}
} }

View File

@@ -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" "Issue not found, make sure it is in the relays and make sure that the ID is an issue ID"
)] )]
CanNotFoundIssue, 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 { impl N34Error {

View File

@@ -206,6 +206,16 @@ impl NostrClient {
.first_owned()) .first_owned())
} }
/// Fetches the events matching the given filter
pub async fn fetch_events(&self, filter: Filter) -> N34Result<Vec<Event>> {
// 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 /// Try to fetch the reposotoies and returns them
pub async fn fetch_repos( pub async fn fetch_repos(
&self, &self,