From ef8d6c1c4fadfb41dd80eeec51dd7934bf9b6c61 Mon Sep 17 00:00:00 2001
From: Awiteb
Date: Wed, 4 Jun 2025 12:45:13 +0000
Subject: [PATCH] feat: New `patch send` command to send patches
Signed-off-by: Awiteb
---
CHANGELOG.md | 6 +
Cargo.lock | 40 ++++-
Cargo.toml | 2 +
src/cli/commands/mod.rs | 18 +-
src/cli/commands/patch/mod.rs | 311 +++++++++++++++++++++++++++++++++
src/cli/commands/patch/send.rs | 279 +++++++++++++++++++++++++++++
src/nostr_utils/utils.rs | 16 +-
7 files changed, 662 insertions(+), 10 deletions(-)
create mode 100644 src/cli/commands/patch/mod.rs
create mode 100644 src/cli/commands/patch/send.rs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b737318..1ce7ca0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## Unreleased
+
+### Added
+
+- New `patch send` command to send patches - by Awiteb
+
## [0.2.0] - 2025-06-01
### Added
diff --git a/Cargo.lock b/Cargo.lock
index 3a761dc..617d87f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -200,19 +200,34 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb"
+[[package]]
+name = "bitcoin-internals"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b854212e29b96c8f0fe04cab11d57586c8f3257de0d146c76cb3b42b3eb9118"
+
[[package]]
name = "bitcoin-io"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b47c4ab7a93edb0c7198c5535ed9b52b63095f4e9b45279c6736cec4b856baf"
+[[package]]
+name = "bitcoin-io"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26792cd2bf245069a1c5acb06aa7ad7abe1de69b507c90b490bca81e0665d0ee"
+dependencies = [
+ "bitcoin-internals 0.4.0",
+]
+
[[package]]
name = "bitcoin_hashes"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b"
dependencies = [
- "bitcoin-internals",
+ "bitcoin-internals 0.2.0",
"hex-conservative 0.1.2",
]
@@ -222,11 +237,21 @@ version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb18c03d0db0247e147a21a6faafd5a7eb851c743db062de72018b6b7e8e4d16"
dependencies = [
- "bitcoin-io",
+ "bitcoin-io 0.1.3",
"hex-conservative 0.2.1",
"serde",
]
+[[package]]
+name = "bitcoin_hashes"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e5d09f16329cd545d7e6008b2c6b2af3a90bc678cf41ac3d2f6755943301b16"
+dependencies = [
+ "bitcoin-io 0.2.0",
+ "hex-conservative 0.3.0",
+]
+
[[package]]
name = "bitflags"
version = "2.9.1"
@@ -702,6 +727,15 @@ dependencies = [
"arrayvec",
]
+[[package]]
+name = "hex-conservative"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4afe881d0527571892c4034822e59bb10c6c991cce6abe8199b6f5cf10766f55"
+dependencies = [
+ "arrayvec",
+]
+
[[package]]
name = "hmac"
version = "0.12.1"
@@ -1081,6 +1115,7 @@ dependencies = [
name = "n34"
version = "0.2.0"
dependencies = [
+ "bitcoin_hashes 0.16.0",
"chrono",
"clap",
"clap-verbosity-flag",
@@ -1090,6 +1125,7 @@ dependencies = [
"futures",
"nostr",
"nostr-sdk",
+ "regex",
"serde",
"tempfile",
"thiserror 2.0.12",
diff --git a/Cargo.toml b/Cargo.toml
index 909874b..688d5f3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -12,6 +12,7 @@ categories = ["command-line-utilities"]
rust-version = "1.85.1"
[dependencies]
+bitcoin_hashes = "0.16.0"
chrono = "0.4.41"
clap = { version = "4.5.37", features = ["derive"] }
clap-verbosity-flag = { version = "3.0.2", default-features = false, features = [
@@ -23,6 +24,7 @@ easy-ext = "1.0.2"
futures = "0.3.31"
nostr = { version = "0.42.0", features = ["parser", "nip05"] }
nostr-sdk = "0.42.0"
+regex = "1.11.1"
serde = { version = "1.0.219", features = ["derive"] }
tempfile = "3.19.1"
thiserror = "2.0.12"
diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs
index 7616164..c8ec244 100644
--- a/src/cli/commands/mod.rs
+++ b/src/cli/commands/mod.rs
@@ -16,6 +16,8 @@
/// `issue` subcommands
mod issue;
+/// `patch` subcommands
+mod patch;
/// 'reply` command
mod reply;
/// `repo` subcommands
@@ -30,6 +32,7 @@ use clap::{ArgGroup, Args, Parser};
use nostr::key::{Keys, PublicKey, SecretKey};
use self::issue::IssueSubcommands;
+use self::patch::PatchSubcommands;
use self::reply::ReplyArgs;
use self::repo::RepoSubcommands;
use self::sets::SetsSubcommands;
@@ -88,11 +91,11 @@ pub enum Commands {
#[command(subcommand)]
subcommands: IssueSubcommands,
},
- // /// Manage patches
- // Patch {
- // #[command(subcommand)]
- // subcommands: PatchSubcommands,
- // },
+ /// Manage patches
+ Patch {
+ #[command(subcommand)]
+ subcommands: PatchSubcommands,
+ },
/// Reply to issues and patches.
Reply(ReplyArgs),
}
@@ -105,8 +108,9 @@ impl CommandRunner for Commands {
match self {
Self::Repo { subcommands } => subcommands.run(options).await,
Self::Issue { subcommands } => subcommands.run(options).await,
- Commands::Reply(args) => args.run(options).await,
- Commands::Sets { subcommands } => subcommands.run(options).await,
+ Self::Reply(args) => args.run(options).await,
+ Self::Sets { subcommands } => subcommands.run(options).await,
+ Self::Patch { subcommands } => subcommands.run(options).await,
}
}
}
diff --git a/src/cli/commands/patch/mod.rs b/src/cli/commands/patch/mod.rs
new file mode 100644
index 0000000..34066ad
--- /dev/null
+++ b/src/cli/commands/patch/mod.rs
@@ -0,0 +1,311 @@
+// 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 .
+
+/// `patch send` subcommand
+mod send;
+
+use std::{str::FromStr, sync::LazyLock};
+
+use clap::Subcommand;
+use regex::Regex;
+
+use self::send::SendArgs;
+use super::{CliOptions, CommandRunner};
+use crate::error::N34Result;
+
+
+static SUBJECT_RE: LazyLock =
+ LazyLock::new(|| Regex::new(r"(?m)^Subject: (.*(?:\n .*)*)").unwrap());
+
+static BODY_RE: LazyLock =
+ LazyLock::new(|| Regex::new(r"\n\n((?:.|\n)*?)(?:\n--[ -]|\z)").unwrap());
+
+
+#[derive(Subcommand, Debug)]
+pub enum PatchSubcommands {
+ /// Send one or more patches to a repository
+ Send(SendArgs),
+}
+
+/// Represents a git patch
+#[derive(Clone, Debug)]
+pub struct GitPatch {
+ /// Full content of the patch file
+ pub inner: String,
+ /// Short description of the patch changes
+ pub subject: String,
+ /// Detailed explanation of the patch changes
+ pub body: String,
+}
+
+impl CommandRunner for PatchSubcommands {
+ async fn run(self, options: CliOptions) -> N34Result<()> {
+ match self {
+ Self::Send(args) => args.run(options).await,
+ }
+ }
+}
+
+impl FromStr for GitPatch {
+ type Err = String;
+
+ fn from_str(patch_content: &str) -> Result {
+ // Regex for subject (handles multi-line subjects)
+ let subject = SUBJECT_RE
+ .captures(patch_content)
+ .and_then(|cap| cap.get(1))
+ .ok_or("No subject found")?
+ .as_str()
+ .trim()
+ .replace('\n', "")
+ .to_string();
+
+ // Regex for body
+ let body = BODY_RE
+ .captures(patch_content)
+ .and_then(|cap| cap.get(1))
+ .ok_or("No body found")?
+ .as_str()
+ .trim()
+ .to_string();
+ Ok(Self {
+ inner: patch_content.to_owned(),
+ subject,
+ body,
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn patch_normal() {
+ let patch_content = r#"From 24e8522268ad675996fc3b35209ce23951236bdc Mon Sep 17 00:00:00 2001
+From: Awiteb
+Date: Tue, 27 May 2025 19:20:42 +0000
+Subject: [PATCH] chore: a to abc
+
+Abc patch
+---
+ src/nostr_utils/mod.rs | 1 +
+ 1files changed, 3 insertions(+), 1 deletions(-)
+
+diff --git a/src/nostr_utils/mod.rs b/src/nostr_utils/mod.rs
+index 4120f5a..e68783c 100644
+--- a/src/nostr_utils/mod.rs
++++ b/src/nostr_utils/mod.rs
+@@ -103,31 +103,9 @@ impl CommandRunner for NewArgs {
+
+- a
++ abc
+--
+2.49.0"#;
+ let patch = GitPatch::from_str(patch_content).unwrap();
+ assert_eq!(patch.subject, "[PATCH] chore: a to abc");
+ assert_eq!(patch.body, "Abc patch");
+ }
+
+ #[test]
+ fn patch_multiline_subject() {
+ let patch_content = r#"From 24e8522268ad675996fc3b35209ce23951236bdc Mon Sep 17 00:00:00 2001
+From: Awiteb
+Date: Tue, 27 May 2025 19:20:42 +0000
+Subject: [PATCH] chore: Some long subject yes so long one Some long subject yes
+ so long one
+
+Abc patch
+---
+ src/nostr_utils/mod.rs | 1 +
+ 1files changed, 3 insertions(+), 1 deletions(-)
+
+diff --git a/src/nostr_utils/mod.rs b/src/nostr_utils/mod.rs
+index 4120f5a..e68783c 100644
+--- a/src/nostr_utils/mod.rs
++++ b/src/nostr_utils/mod.rs
+@@ -103,31 +103,9 @@ impl CommandRunner for NewArgs {
+
+- a
++ abc
+--
+2.49.0"#;
+ let patch = GitPatch::from_str(patch_content).unwrap();
+ assert_eq!(
+ patch.subject,
+ "[PATCH] chore: Some long subject yes so long one Some long subject yes so long one"
+ );
+ assert_eq!(patch.body, "Abc patch");
+ }
+
+ #[test]
+ fn patch_multiline_body() {
+ let patch_content = r#"From 24e8522268ad675996fc3b35209ce23951236bdc Mon Sep 17 00:00:00 2001
+From: Awiteb
+Date: Tue, 27 May 2025 19:20:42 +0000
+Subject: [PATCH] chore: a to abc
+
+Lorem ipsum dolor sit amet. 33 laborum galisum aut fugiat dicta vel accusamus
+aliquam vel quisquam fuga in incidunt voluptas a aliquid neque ab iure pariatur.
+Et molestiae vero a consectetur laborum et accusantium sequi. Et ratione
+atque et molestiae dolorem in asperiores amet id dolor corporis in adipisci
+aspernatur.
+---
+ src/nostr_utils/mod.rs | 1 +
+ 1files changed, 3 insertions(+), 1 deletions(-)
+
+diff --git a/src/nostr_utils/mod.rs b/src/nostr_utils/mod.rs
+index 4120f5a..e68783c 100644
+--- a/src/nostr_utils/mod.rs
++++ b/src/nostr_utils/mod.rs
+@@ -103,31 +103,9 @@ impl CommandRunner for NewArgs {
+
+- a
++ abc
+--
+2.49.0"#;
+ let patch = GitPatch::from_str(patch_content).unwrap();
+ assert_eq!(patch.subject, "[PATCH] chore: a to abc");
+ assert_eq!(
+ patch.body,
+ "Lorem ipsum dolor sit amet. 33 laborum galisum aut fugiat dicta vel accusamus
+aliquam vel quisquam fuga in incidunt voluptas a aliquid neque ab iure pariatur.
+Et molestiae vero a consectetur laborum et accusantium sequi. Et ratione
+atque et molestiae dolorem in asperiores amet id dolor corporis in adipisci
+aspernatur."
+ );
+ }
+
+ #[test]
+ fn patch_cover_letter() {
+ let patch_content = r#"From 864f3018f62ab2e1265edb670d5493dafe7d2cb2 Mon Sep 17 00:00:00 2001
+From: Awiteb
+Date: Tue, 3 Jun 2025 08:41:12 +0000
+Subject: [PATCH v2 0/7] feat: Some test just a test
+
+Cover body
+
+Awiteb (1):
+ chore: Update `README.md`
+
+ README.md | 2 +-
+
+
+base-commit: f670859b92d525874fd621452080c8479964ac6a
+--
+2.49.0"#;
+ let patch = GitPatch::from_str(patch_content).unwrap();
+ assert_eq!(patch.subject, "[PATCH v2 0/7] feat: Some test just a test");
+ assert_eq!(
+ patch.body,
+ "Cover body
+
+Awiteb (1):
+ chore: Update `README.md`
+
+ README.md | 2 +-
+
+
+base-commit: f670859b92d525874fd621452080c8479964ac6a"
+ );
+ }
+
+ #[test]
+ fn patch_multiline_cover_subject() {
+ let patch_content = r#"From 864f3018f62ab2e1265edb670d5493dafe7d2cb2 Mon Sep 17 00:00:00 2001
+From: Awiteb
+Date: Tue, 3 Jun 2025 08:41:12 +0000
+Subject: [PATCH v2 0/7] feat: Some test just a test some test just a test some
+ test just a test
+
+Cover body
+
+Awiteb (1):
+ chore: Update `README.md`
+
+ README.md | 2 +-
+
+
+base-commit: f670859b92d525874fd621452080c8479964ac6a
+--
+2.49.0"#;
+ let patch = GitPatch::from_str(patch_content).unwrap();
+ assert_eq!(
+ patch.subject,
+ "[PATCH v2 0/7] feat: Some test just a test some test just a test some test just a \
+ test"
+ );
+ assert_eq!(
+ patch.body,
+ "Cover body
+
+Awiteb (1):
+ chore: Update `README.md`
+
+ README.md | 2 +-
+
+
+base-commit: f670859b92d525874fd621452080c8479964ac6a"
+ );
+ }
+
+ #[test]
+ fn patch_multiline_cover_body() {
+ let patch_content = r#"From 864f3018f62ab2e1265edb670d5493dafe7d2cb2 Mon Sep 17 00:00:00 2001
+From: Awiteb
+Date: Tue, 3 Jun 2025 08:41:12 +0000
+Subject: [PATCH v2 0/7] feat: Some test just a test some test just a test some
+ test just a test
+
+Lorem ipsum dolor sit amet. 33 laborum galisum aut fugiat dicta vel accusamus
+aliquam vel quisquam fuga in incidunt voluptas a aliquid neque ab iure pariatur.
+Et molestiae vero a consectetur laborum et accusantium sequi. Et ratione
+atque et molestiae dolorem in asperiores amet id dolor corporis in adipisci
+aspernatur.
+
+Awiteb (1):
+ chore: Update `README.md`
+
+ README.md | 2 +-
+
+
+base-commit: f670859b92d525874fd621452080c8479964ac6a
+--
+2.49.0"#;
+ let patch = GitPatch::from_str(patch_content).unwrap();
+ assert_eq!(
+ patch.subject,
+ "[PATCH v2 0/7] feat: Some test just a test some test just a test some test just a \
+ test"
+ );
+ assert_eq!(
+ patch.body,
+ "Lorem ipsum dolor sit amet. 33 laborum galisum aut fugiat dicta vel accusamus
+aliquam vel quisquam fuga in incidunt voluptas a aliquid neque ab iure pariatur.
+Et molestiae vero a consectetur laborum et accusantium sequi. Et ratione
+atque et molestiae dolorem in asperiores amet id dolor corporis in adipisci
+aspernatur.
+
+Awiteb (1):
+ chore: Update `README.md`
+
+ README.md | 2 +-
+
+
+base-commit: f670859b92d525874fd621452080c8479964ac6a"
+ );
+ }
+}
diff --git a/src/cli/commands/patch/send.rs b/src/cli/commands/patch/send.rs
new file mode 100644
index 0000000..e64598f
--- /dev/null
+++ b/src/cli/commands/patch/send.rs
@@ -0,0 +1,279 @@
+// 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, str::FromStr};
+
+use bitcoin_hashes::sha1::Hash as HashSha1;
+use clap::Args;
+use futures::future;
+use nostr::{
+ event::{EventBuilder, EventId, Kind, Tag, TagKind, Tags, UnsignedEvent},
+ key::PublicKey,
+ nips::{nip01::Coordinate, nip10::Marker},
+ types::RelayUrl,
+};
+
+use super::GitPatch;
+use crate::{
+ cli::{
+ CliConfig,
+ CliOptions,
+ traits::CommandRunner,
+ types::{NaddrOrSet, NostrEvent, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
+ },
+ error::N34Result,
+ nostr_utils::{
+ NostrClient,
+ traits::{NaddrsUtils, ReposUtils},
+ utils,
+ },
+};
+
+/// Prefix used for git patch alt.
+const PATCH_ALT_PREFIX: &str = "git patch: ";
+
+#[derive(Args, Debug)]
+pub struct SendArgs {
+ /// 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>,
+ /// List of patch files to send (space separated).
+ ///
+ /// For p-tagging users, include them in the cover letter with
+ /// `nostr:npub1...`.
+ #[arg(value_name = "PATCH-PATH", required = true, value_parser = parse_patch_path)]
+ patches: Vec,
+ /// The earliest unique commit ID in the repository.
+ ///
+ /// Can be obtained by running `git rev-parse master` (replace 'master' with
+ /// your base branch name).
+ #[arg(long, value_name = "COMMIT-ID")]
+ euc: HashSha1,
+ /// Original patch ID if this is a revision of it
+ #[arg(long, value_name = "EVENT-ID")]
+ original_patch: Option,
+}
+
+impl CommandRunner for SendArgs {
+ async fn run(self, options: CliOptions) -> N34Result<()> {
+ let config = CliConfig::load_toml(&options.config_path)?;
+ let naddrs = utils::naddrs_or_file(
+ self.naddrs.flat_naddrs(&config.sets)?,
+ &utils::nostr_address_path()?,
+ )?;
+ let repo_coordinates = naddrs.clone().into_coordinates();
+ let relays = options.relays.clone().flat_relays(&config.sets)?;
+ let user_pubk = options.pubkey().await?;
+ let client = NostrClient::init(&options, &relays).await;
+
+ client.add_relays(&naddrs.extract_relays()).await;
+ if let Some(original_patch) = &self.original_patch {
+ client.add_relays(&original_patch.relays).await;
+ }
+ let relays_list = client.user_relays_list(user_pubk).await?;
+ client
+ .add_relays(&utils::add_read_relays(relays_list.as_ref()))
+ .await;
+ let repos_relays = client
+ .fetch_repos(&repo_coordinates)
+ .await?
+ .extract_relays();
+ client.add_relays(&repos_relays).await;
+
+ let (events, events_write_relays) = make_patch_series(
+ &client,
+ self.patches,
+ self.original_patch.as_ref().map(|e| e.event_id),
+ repos_relays.first().cloned(),
+ repo_coordinates,
+ &self.euc,
+ user_pubk,
+ )
+ .await?;
+
+ let write_relays = [
+ relays,
+ repos_relays,
+ events_write_relays,
+ naddrs.extract_relays(),
+ self.original_patch.map(|e| e.relays).unwrap_or_default(),
+ utils::add_write_relays(relays_list.as_ref()),
+ future::join_all(
+ naddrs
+ .iter()
+ .map(|c| client.read_relays_from_user(c.public_key)),
+ )
+ .await
+ .into_iter()
+ .flatten()
+ .collect(),
+ ]
+ .concat();
+
+ tracing::trace!(write_relays = ?write_relays, "Write relays of the patches");
+ let nevents = future::join_all(events.into_iter().map(|mut event| {
+ async {
+ let event_id = event.id();
+ let subject = event
+ .tags
+ .find(TagKind::Alt)
+ .and_then(Tag::content)
+ .expect("There is an alt")
+ .replace(PATCH_ALT_PREFIX, "")
+ .to_string();
+ client
+ .send_event_to(event, relays_list.as_ref(), &write_relays)
+ .await
+ .map(|r| Ok((subject, utils::new_nevent(event_id, &r)?)))?
+ }
+ }))
+ .await
+ .into_iter()
+ .collect::>>()?;
+
+ for (subject, nevent) in nevents {
+ println!("Created '{subject}': {nevent}");
+ }
+
+ Ok(())
+ }
+}
+
+fn parse_patch_path(patch_path: &str) -> Result {
+ tracing::debug!("Parsing patch file `{patch_path}`");
+ let patch_content = fs::read_to_string(patch_path)
+ .map_err(|err| format!("Failed to read patch file `{patch_path}`: {err}"))?;
+ GitPatch::from_str(&patch_content)
+}
+
+async fn make_patch_series(
+ client: &NostrClient,
+ patches: Vec,
+ original_patch: Option,
+ relay_hint: Option,
+ repo_coordinates: Vec,
+ euc: &HashSha1,
+ author_pkey: PublicKey,
+) -> N34Result<(Vec, Vec)> {
+ let mut write_relays = Vec::new();
+ let mut patch_series = Vec::new();
+ let mut patches = patches.into_iter();
+ let root_patch = patches.next().expect("Patches can't be empty");
+ let (root_event, root_relays) = make_patch(
+ client,
+ root_patch,
+ None,
+ original_patch,
+ relay_hint.as_ref(),
+ &repo_coordinates,
+ euc,
+ author_pkey,
+ )
+ .await;
+ write_relays.extend(root_relays);
+ let root_id = *root_event.id.as_ref().expect("There is an id");
+ let mut previous_patch = root_id;
+ patch_series.push(root_event);
+
+ for patch in patches {
+ let (patch_event, patch_relays) = make_patch(
+ client,
+ patch,
+ Some(root_id),
+ Some(previous_patch),
+ relay_hint.as_ref(),
+ &repo_coordinates,
+ euc,
+ author_pkey,
+ )
+ .await;
+ previous_patch = patch_event.id.expect("there is an id");
+ write_relays.extend(patch_relays);
+ patch_series.push(patch_event);
+ }
+
+ Ok((patch_series, write_relays))
+}
+
+#[allow(clippy::too_many_arguments)]
+async fn make_patch(
+ client: &NostrClient,
+ patch: GitPatch,
+ root: Option,
+ reply_to: Option,
+ write_relay: Option<&RelayUrl>,
+ repo_coordinates: &[Coordinate],
+ euc: &HashSha1,
+ author_pkey: PublicKey,
+) -> (UnsignedEvent, Vec) {
+ let content_details = client.parse_content(&patch.body).await;
+ let content_relays = content_details.write_relays.clone();
+ // NIP-34 compliance requires referencing the previous patch using `NIP-10 e
+ // reply`. However, this fails for the second patch when
+ // `EventBuilder::dedup_tags` is enabled because:
+ // 1. The tag is treated as a duplicate based on its content (the root ID).
+ // 2. The second patch would reply to the root twice:
+ // - First with the 'root' marker
+ // - Then with the 'reply' marker
+ // The `EventBuilder::dedup_tags` function then removes the 'reply' marker as a
+ // duplicate.
+ let mut safe_dedup_tags = Tags::new();
+ safe_dedup_tags.push(Tag::alt(format!("{PATCH_ALT_PREFIX}{}", patch.subject)));
+ safe_dedup_tags.push(Tag::reference(euc.to_string()));
+ safe_dedup_tags.extend(content_details.into_tags());
+ safe_dedup_tags.extend(
+ repo_coordinates
+ .iter()
+ .map(|c| Tag::coordinate(c.clone(), None)),
+ );
+ safe_dedup_tags.extend(
+ repo_coordinates
+ .iter()
+ .map(|c| Tag::public_key(c.public_key)),
+ );
+ safe_dedup_tags.dedup();
+ let mut event_builder = EventBuilder::new(Kind::GitPatch, patch.inner).tags(safe_dedup_tags);
+
+ // If the root is None, this indicates we're handling the root event
+ if let Some(root_id) = root {
+ event_builder =
+ event_builder.tag(utils::event_reply_tag(&root_id, write_relay, Marker::Root));
+ } else {
+ event_builder = event_builder.tag(Tag::hashtag("root"));
+ }
+
+ if let Some(reply_to_id) = reply_to {
+ if root.is_none() {
+ event_builder = event_builder.tags([
+ utils::event_reply_tag(&reply_to_id, write_relay, Marker::Reply),
+ Tag::hashtag("root-revision"),
+ ]);
+ } else {
+ event_builder = event_builder.tag(utils::event_reply_tag(
+ &reply_to_id,
+ write_relay,
+ Marker::Reply,
+ ));
+ }
+ }
+ (
+ event_builder.build(author_pkey),
+ content_relays.into_iter().collect(),
+ )
+}
diff --git a/src/nostr_utils/utils.rs b/src/nostr_utils/utils.rs
index 42abbf4..f1ce36b 100644
--- a/src/nostr_utils/utils.rs
+++ b/src/nostr_utils/utils.rs
@@ -23,11 +23,12 @@ use std::{
};
use nostr::{
- event::{Event, EventId, Kind, TagKind, TagStandard},
+ event::{Event, EventId, Kind, Tag, TagKind, TagStandard},
filter::{Alphabet, SingleLetterTag},
key::PublicKey,
nips::{
nip01::Coordinate,
+ nip10::Marker,
nip19::{Nip19Coordinate, Nip19Event, ToBech32},
nip34::GitRepositoryAnnouncement,
nip65::{self, RelayMetadata},
@@ -237,3 +238,16 @@ pub fn naddrs_or_file(
}
parsers::parse_nostr_address_file(address_file_path)
}
+
+/// Generate a reply tag for an event with the given ID, relay URL (if any), and
+/// marker.
+pub fn event_reply_tag(reply_to: &EventId, relay: Option<&RelayUrl>, marker: Marker) -> Tag {
+ Tag::custom(
+ TagKind::single_letter(Alphabet::E, false),
+ [
+ reply_to.to_hex(),
+ relay.map(|r| r.to_string()).unwrap_or_default(),
+ marker.to_string(),
+ ],
+ )
+}