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(), + ], + ) +}