From 8b09cff5e30c8db22dbbe918873930ad1aa0c302 Mon Sep 17 00:00:00 2001
From: Awiteb
Date: Sat, 28 Jun 2025 13:46:23 +0000
Subject: [PATCH] feat: New `patch` subcommands apply,close,draft,merge and
reopen to manage the patch status
Signed-off-by: Awiteb
---
CHANGELOG.md | 6 ++
README.md | 2 +-
src/cli/commands/patch/apply.rs | 77 +++++++++++++++
src/cli/commands/patch/close.rs | 66 +++++++++++++
src/cli/commands/patch/draft.rs | 73 ++++++++++++++
src/cli/commands/patch/merge.rs | 77 +++++++++++++++
src/cli/commands/patch/mod.rs | 93 ++++++++++++++++--
src/cli/commands/patch/reopen.rs | 67 +++++++++++++
src/cli/common_commands.rs | 157 ++++++++++++++++++++++++++++++-
src/nostr_utils/mod.rs | 74 ++++++++++++++-
src/nostr_utils/traits.rs | 41 +++++++-
11 files changed, 718 insertions(+), 15 deletions(-)
create mode 100644 src/cli/commands/patch/apply.rs
create mode 100644 src/cli/commands/patch/close.rs
create mode 100644 src/cli/commands/patch/draft.rs
create mode 100644 src/cli/commands/patch/merge.rs
create mode 100644 src/cli/commands/patch/reopen.rs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f1915db..4ab47c4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- New `issue view` command to view an issue - by Awiteb
- New `patch fetch` command to fetch patches - by Awiteb
- New `issue {reopen,close,resolve}` commands to manage issue status - by Awiteb
+- New `patch` subcommands apply,close,draft,merge and reopen to manage the patch status - by Awiteb
+
+### Dependencies
+
+- Add `either@1.15.0` to the dependencies - by Awiteb
+- Update `nostr` to `0.42.2` - by Awiteb
### Fixed
diff --git a/README.md b/README.md
index ca084cc..1c56982 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@ details, see the following section.
- [X] Patches (Send and fetch)
- [X] Issues (Send and view)
- [X] Replies
-- [ ] Issues and patches status
+- [X] 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/apply.rs b/src/cli/commands/patch/apply.rs
new file mode 100644
index 0000000..882ba2e
--- /dev/null
+++ b/src/cli/commands/patch/apply.rs
@@ -0,0 +1,77 @@
+// 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 bitcoin_hashes::Sha1;
+use clap::Args;
+
+use super::PatchStatus;
+use crate::{
+ cli::{
+ CliOptions,
+ traits::CommandRunner,
+ types::{NaddrOrSet, NostrEvent},
+ },
+ error::{N34Error, N34Result},
+};
+
+#[derive(Debug, Args)]
+pub struct ApplyArgs {
+ /// 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>,
+ /// The open patch id to apply it. Must be orignal root patch or
+ /// revision root
+ patch_id: NostrEvent,
+ /// The applied commits
+ #[arg(num_args = 1..)]
+ applied_commits: Vec,
+}
+
+impl CommandRunner for ApplyArgs {
+ async fn run(self, options: CliOptions) -> N34Result<()> {
+ crate::cli::common_commands::patch_status_command(
+ options,
+ self.patch_id,
+ self.naddrs,
+ PatchStatus::MergedApplied,
+ Some(either::Either::Right(self.applied_commits)),
+ |patch_status| {
+ if patch_status.is_merged_or_applied() {
+ return Err(N34Error::InvalidStatus(
+ "You can't apply an already applied patch".to_owned(),
+ ));
+ }
+
+ if patch_status.is_closed() {
+ return Err(N34Error::InvalidStatus(
+ "You can't apply a closed patch".to_owned(),
+ ));
+ }
+
+ if patch_status.is_drafted() {
+ return Err(N34Error::InvalidStatus(
+ "You can't apply a drafted patch".to_owned(),
+ ));
+ }
+ Ok(())
+ },
+ )
+ .await
+ }
+}
diff --git a/src/cli/commands/patch/close.rs b/src/cli/commands/patch/close.rs
new file mode 100644
index 0000000..31588ed
--- /dev/null
+++ b/src/cli/commands/patch/close.rs
@@ -0,0 +1,66 @@
+// 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 clap::Args;
+
+use super::PatchStatus;
+use crate::{
+ cli::{
+ CliOptions,
+ traits::CommandRunner,
+ types::{NaddrOrSet, NostrEvent},
+ },
+ error::{N34Error, N34Result},
+};
+
+#[derive(Debug, Args)]
+pub struct CloseArgs {
+ /// 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>,
+ /// The open/drafted patch id to close it. Must be orignal root patch
+ patch_id: NostrEvent,
+}
+
+impl CommandRunner for CloseArgs {
+ async fn run(self, options: CliOptions) -> N34Result<()> {
+ crate::cli::common_commands::patch_status_command(
+ options,
+ self.patch_id,
+ self.naddrs,
+ PatchStatus::Closed,
+ None,
+ |patch_status| {
+ if patch_status.is_closed() {
+ return Err(N34Error::InvalidStatus(
+ "You can't close an already closed patch".to_owned(),
+ ));
+ }
+
+ if patch_status.is_merged_or_applied() {
+ return Err(N34Error::InvalidStatus(
+ "You can't close a merged/applied patch".to_owned(),
+ ));
+ }
+ Ok(())
+ },
+ )
+ .await
+ }
+}
diff --git a/src/cli/commands/patch/draft.rs b/src/cli/commands/patch/draft.rs
new file mode 100644
index 0000000..0cedf44
--- /dev/null
+++ b/src/cli/commands/patch/draft.rs
@@ -0,0 +1,73 @@
+// 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 clap::Args;
+
+use super::PatchStatus;
+use crate::{
+ cli::{
+ CliOptions,
+ traits::CommandRunner,
+ types::{NaddrOrSet, NostrEvent},
+ },
+ error::{N34Error, N34Result},
+};
+
+#[derive(Debug, Args)]
+pub struct DraftArgs {
+ /// 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>,
+ /// The open patch id to draft it. Must be orignal root patch
+ patch_id: NostrEvent,
+}
+
+impl CommandRunner for DraftArgs {
+ async fn run(self, options: CliOptions) -> N34Result<()> {
+ crate::cli::common_commands::patch_status_command(
+ options,
+ self.patch_id,
+ self.naddrs,
+ PatchStatus::Draft,
+ None,
+ |patch_status| {
+ if patch_status.is_drafted() {
+ return Err(N34Error::InvalidStatus(
+ "You can't draft an already drafted patch".to_owned(),
+ ));
+ }
+
+ if patch_status.is_closed() {
+ return Err(N34Error::InvalidStatus(
+ "You can't draft a closed patch".to_owned(),
+ ));
+ }
+
+ if patch_status.is_merged_or_applied() {
+ return Err(N34Error::InvalidStatus(
+ "You can't draft a merged/applied patch".to_owned(),
+ ));
+ }
+
+ Ok(())
+ },
+ )
+ .await
+ }
+}
diff --git a/src/cli/commands/patch/merge.rs b/src/cli/commands/patch/merge.rs
new file mode 100644
index 0000000..1262f17
--- /dev/null
+++ b/src/cli/commands/patch/merge.rs
@@ -0,0 +1,77 @@
+// 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 bitcoin_hashes::Sha1;
+use clap::Args;
+
+use super::PatchStatus;
+use crate::{
+ cli::{
+ CliOptions,
+ traits::CommandRunner,
+ types::{NaddrOrSet, NostrEvent},
+ },
+ error::{N34Error, N34Result},
+};
+
+#[derive(Debug, Args)]
+pub struct MergeArgs {
+ /// 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>,
+ /// The open patch id to merge it. Must be orignal root patch or
+ /// revision root
+ patch_id: NostrEvent,
+ /// The merge commit id
+ merge_commit: Sha1,
+}
+
+impl CommandRunner for MergeArgs {
+ async fn run(self, options: CliOptions) -> N34Result<()> {
+ crate::cli::common_commands::patch_status_command(
+ options,
+ self.patch_id,
+ self.naddrs,
+ PatchStatus::MergedApplied,
+ Some(either::Either::Left(self.merge_commit)),
+ |patch_status| {
+ if patch_status.is_merged_or_applied() {
+ return Err(N34Error::InvalidStatus(
+ "You can't merge an already merged patch".to_owned(),
+ ));
+ }
+
+ if patch_status.is_closed() {
+ return Err(N34Error::InvalidStatus(
+ "You can't merge a closed patch".to_owned(),
+ ));
+ }
+
+ if patch_status.is_drafted() {
+ return Err(N34Error::InvalidStatus(
+ "You can't merge a drafted patch".to_owned(),
+ ));
+ }
+
+ Ok(())
+ },
+ )
+ .await
+ }
+}
diff --git a/src/cli/commands/patch/mod.rs b/src/cli/commands/patch/mod.rs
index 1aca687..f9248ab 100644
--- a/src/cli/commands/patch/mod.rs
+++ b/src/cli/commands/patch/mod.rs
@@ -14,8 +14,18 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
+/// `patch apply` suubcommand
+mod apply;
+/// `patch close` subcommand
+mod close;
+/// `patch draft` subcommand
+mod draft;
/// `patch fetch` subcommand
mod fetch;
+/// `patch merge` subcommand
+mod merge;
+/// `patch reopen` subcommand
+mod reopen;
/// `patch send` subcommand
mod send;
@@ -26,14 +36,19 @@ use std::{
};
use clap::Subcommand;
+use nostr::event::Kind;
use regex::Regex;
+use self::apply::ApplyArgs;
+use self::close::CloseArgs;
+use self::draft::DraftArgs;
use self::fetch::FetchArgs;
+use self::merge::MergeArgs;
+use self::reopen::ReopenArgs;
use self::send::SendArgs;
use super::{CliOptions, CommandRunner};
use crate::error::{N34Error, N34Result};
-
/// Regular expression for extracting the patch subject.
static SUBJECT_RE: LazyLock =
LazyLock::new(|| Regex::new(r"(?m)^Subject: (.*(?:\n .*)*)").unwrap());
@@ -49,10 +64,20 @@ static PATCH_VERSION_NUMBER_RE: LazyLock = LazyLock::new(|| {
#[derive(Subcommand, Debug)]
pub enum PatchSubcommands {
- /// Send one or more patches to a repository
+ /// Send one or more patches to a repository.
Send(SendArgs),
- /// Fetches a patch by its id
+ /// Fetches a patch by its id.
Fetch(FetchArgs),
+ /// Closes an open or drafted patch.
+ Close(CloseArgs),
+ /// Converts the closed or open patch to draft state.
+ Draft(DraftArgs),
+ /// Reopens a closed or drafted patch.
+ Reopen(ReopenArgs),
+ /// Set an open patch status to applied.
+ Apply(ApplyArgs),
+ /// Set an open patch status to merged.
+ Merge(MergeArgs),
}
/// Represents a git patch
@@ -66,6 +91,64 @@ pub struct GitPatch {
pub body: String,
}
+#[derive(Debug)]
+pub enum PatchStatus {
+ /// The patch is currently open
+ Open,
+ /// The patch has been merged/applied
+ MergedApplied,
+ /// The patch has been closed
+ Closed,
+ /// A patch that has been drafted but not yet applied.
+ Draft,
+}
+
+impl PatchStatus {
+ /// Maps the issue status to its corresponding Nostr kind.
+ pub fn kind(&self) -> Kind {
+ match self {
+ Self::Open => Kind::GitStatusOpen,
+ Self::MergedApplied => Kind::GitStatusApplied,
+ Self::Closed => Kind::GitStatusClosed,
+ Self::Draft => Kind::GitStatusDraft,
+ }
+ }
+
+ /// Check if the patch is open.
+ pub fn is_open(&self) -> bool {
+ matches!(self, Self::Open)
+ }
+
+ /// Check if the patch is merged/applied.
+ pub fn is_merged_or_applied(&self) -> bool {
+ matches!(self, Self::MergedApplied)
+ }
+
+ /// Check if the patch is closed.
+ pub fn is_closed(&self) -> bool {
+ matches!(self, Self::Closed)
+ }
+
+ /// Check if the patch is drafted
+ pub fn is_drafted(&self) -> bool {
+ matches!(self, Self::Draft)
+ }
+}
+
+impl TryFrom for PatchStatus {
+ type Error = N34Error;
+
+ fn try_from(kind: Kind) -> Result {
+ match kind {
+ Kind::GitStatusOpen => Ok(Self::Open),
+ Kind::GitStatusApplied => Ok(Self::MergedApplied),
+ Kind::GitStatusClosed => Ok(Self::Closed),
+ Kind::GitStatusDraft => Ok(Self::Draft),
+ _ => Err(N34Error::InvalidPatchStatus(kind)),
+ }
+ }
+}
+
impl GitPatch {
/// Returns the patch file name from the subject
pub fn filename(&self, parent: impl AsRef) -> N34Result {
@@ -90,7 +173,7 @@ impl GitPatch {
impl CommandRunner for PatchSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> {
- crate::run_command!(self, options, & Send Fetch)
+ crate::run_command!(self, options, & Send Fetch Close Reopen Draft Apply Merge)
}
}
@@ -170,7 +253,6 @@ fn patch_file_name(subject: &str) -> N34Result {
.replace("--", "-"))
}
-
#[cfg(test)]
mod tests {
use super::*;
@@ -202,7 +284,6 @@ index 4120f5a..e68783c 100644
assert_eq!(patch.body, "Abc patch");
}
-
#[test]
fn patch_normal_with_patch_in_content() {
let patch_content = r#"From 24e8522268ad675996fc3b35209ce23951236bdc Mon Sep 17 00:00:00 2001
diff --git a/src/cli/commands/patch/reopen.rs b/src/cli/commands/patch/reopen.rs
new file mode 100644
index 0000000..72b27b8
--- /dev/null
+++ b/src/cli/commands/patch/reopen.rs
@@ -0,0 +1,67 @@
+// 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 clap::Args;
+
+use super::PatchStatus;
+use crate::{
+ cli::{
+ CliOptions,
+ traits::CommandRunner,
+ types::{NaddrOrSet, NostrEvent},
+ },
+ error::{N34Error, N34Result},
+};
+
+#[derive(Debug, Args)]
+pub struct ReopenArgs {
+ /// 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>,
+ /// The closed/drafted patch id to reopen it. Must be orignal root patch
+ patch_id: NostrEvent,
+}
+
+impl CommandRunner for ReopenArgs {
+ async fn run(self, options: CliOptions) -> N34Result<()> {
+ crate::cli::common_commands::patch_status_command(
+ options,
+ self.patch_id,
+ self.naddrs,
+ PatchStatus::Open,
+ None,
+ |patch_status| {
+ if patch_status.is_open() {
+ return Err(N34Error::InvalidStatus(
+ "You can't open an already open patch".to_owned(),
+ ));
+ }
+
+ if patch_status.is_merged_or_applied() {
+ return Err(N34Error::InvalidStatus(
+ "You can't open a merged/applied patch".to_owned(),
+ ));
+ }
+
+ Ok(())
+ },
+ )
+ .await
+ }
+}
diff --git a/src/cli/common_commands.rs b/src/cli/common_commands.rs
index dac77a6..fc95eca 100644
--- a/src/cli/common_commands.rs
+++ b/src/cli/common_commands.rs
@@ -14,27 +14,32 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
+use std::iter;
+
+use bitcoin_hashes::Sha1;
+use either::Either;
use nostr::{
- event::{EventBuilder, Tag},
+ event::{EventBuilder, Tag, TagKind},
filter::Filter,
nips::nip10::Marker,
};
use super::{
issue::IssueStatus,
+ patch::PatchStatus,
types::{NaddrOrSet, NostrEvent},
};
use crate::{
cli::CliOptions,
error::{N34Error, N34Result},
- nostr_utils::traits::ReposUtils,
+ nostr_utils::traits::{GitPatchUtils, ReposUtils},
};
use crate::{
cli::types::{OptionNaddrOrSetVecExt, RelayOrSetVecExt},
nostr_utils::{NostrClient, traits::NaddrsUtils, utils},
};
-/// Updates an issue's status to `new_status` after validating it with
+/// Updates the issue's status to `new_status` after validating it with
/// `check_fn`.
pub async fn issue_status_command(
options: CliOptions,
@@ -94,14 +99,16 @@ pub async fn issue_status_command(
.build(user_pkey);
let event_id = status_event.id.expect("There is an id");
- let user_relays_list = client.user_relays_list(issue_event.pubkey).await?;
+ let user_relays_list = client.user_relays_list(user_pkey).await?;
let write_relays = [
relays,
naddrs.extract_relays(),
repos.extract_relays(),
utils::add_write_relays(user_relays_list.as_ref()),
client.read_relays_from_user(issue_event.pubkey).await,
- client.read_relays_from_users(&maintainers).await,
+ client
+ .read_relays_from_users(&[maintainers, owners].concat())
+ .await,
]
.concat();
@@ -113,3 +120,143 @@ pub async fn issue_status_command(
Ok(())
}
+
+/// Updates the patch's status to `new_status` after validating it with
+/// `check_fn`.
+pub async fn patch_status_command(
+ options: CliOptions,
+ patch_id: NostrEvent,
+ naddrs: Option>,
+ new_status: PatchStatus,
+ merge_or_applied_commits: Option>>,
+ check_fn: impl FnOnce(&PatchStatus) -> N34Result<()>,
+) -> N34Result<()> {
+ let user_pkey = options.pubkey().await?;
+ let naddrs = utils::naddrs_or_file(
+ 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;
+ client
+ .add_relays(&[naddrs.extract_relays(), patch_id.relays].concat())
+ .await;
+
+ let owners = naddrs.extract_owners();
+ let coordinates = naddrs.clone().into_coordinates();
+ let repos = client.fetch_repos(&coordinates).await?;
+ let maintainers = repos.extract_maintainers();
+ let relay_hint = repos.extract_relays().first().cloned();
+ client.add_relays(&repos.extract_relays()).await;
+
+ let patch_event = client.fetch_patch(patch_id.event_id).await?;
+
+ if patch_event.is_revision_patch() && !new_status.is_merged_or_applied() {
+ return Err(N34Error::InvalidStatus(
+ "Invalid action for patch revision. Only 'apply' or 'merge' are allowed, 'open', \
+ 'close', and 'draft' are not supported."
+ .to_owned(),
+ ));
+ }
+
+ let (root_patch, root_revision) = if patch_event.is_revision_patch() {
+ (
+ patch_event.root_patch_from_revision()?,
+ Some(patch_event.id),
+ )
+ } else if patch_event.is_root_patch() {
+ (patch_event.id, None)
+ } else {
+ return Err(N34Error::NotRootPatch);
+ };
+
+ let patch_status = client
+ .fetch_patch_status(
+ root_patch,
+ root_revision,
+ [maintainers.as_slice(), &[patch_event.pubkey], &owners].concat(),
+ )
+ .await?;
+
+ check_fn(&patch_status)?;
+
+ let mut status_builder = EventBuilder::new(new_status.kind(), "")
+ .pow(options.pow.unwrap_or_default())
+ .tag(utils::event_reply_tag(
+ &root_patch,
+ relay_hint.as_ref(),
+ Marker::Root,
+ ))
+ .tag(Tag::public_key(patch_event.pubkey))
+ .tags(maintainers.iter().map(|p| Tag::public_key(*p)))
+ .tags(owners.iter().map(|p| Tag::public_key(*p)))
+ .tags(
+ coordinates
+ .into_iter()
+ .map(|c| Tag::coordinate(c, relay_hint.clone())),
+ );
+
+ if new_status.is_merged_or_applied() {
+ if let Some(merge_commit) = merge_or_applied_commits
+ .as_ref()
+ .and_then(|e| e.as_ref().left())
+ {
+ let commit = merge_commit.to_string();
+ status_builder = status_builder
+ .tag(Tag::custom(
+ TagKind::custom("merge-commit"),
+ iter::once(&commit),
+ ))
+ .tag(Tag::reference(commit));
+ } else if let Some(applied_commits) = merge_or_applied_commits.and_then(|e| e.right()) {
+ let commits = applied_commits
+ .iter()
+ .map(ToString::to_string)
+ .collect::>();
+ status_builder = status_builder
+ .tag(Tag::custom(TagKind::custom("applied-as-commits"), &commits))
+ .tags(commits.into_iter().map(Tag::reference));
+ };
+
+ let root = if let Some(root_revision) = root_revision {
+ status_builder = status_builder.tag(utils::event_reply_tag(
+ &root_revision,
+ relay_hint.as_ref(),
+ Marker::Reply,
+ ));
+ root_revision
+ } else {
+ root_patch
+ };
+ let patches = client.fetch_patch_series(root, patch_event.pubkey).await?;
+ status_builder = status_builder.tags(
+ patches
+ .into_iter()
+ .map(|p| utils::event_reply_tag(&p.id, relay_hint.as_ref(), Marker::Mention)),
+ );
+ }
+
+ let status_event = status_builder.dedup_tags().build(user_pkey);
+
+ let event_id = status_event.id.expect("There is an id");
+ let user_relays_list = client.user_relays_list(user_pkey).await?;
+ let write_relays = [
+ relays,
+ naddrs.extract_relays(),
+ repos.extract_relays(),
+ utils::add_write_relays(user_relays_list.as_ref()),
+ client.read_relays_from_user(patch_event.pubkey).await,
+ client
+ .read_relays_from_users(&[maintainers, owners].concat())
+ .await,
+ ]
+ .concat();
+
+ let success = client
+ .send_event_to(status_event, user_relays_list.as_ref(), &write_relays)
+ .await?;
+ let nevent = utils::new_nevent(event_id, &success)?;
+ println!("Patch status created: {nevent}");
+
+ Ok(())
+}
diff --git a/src/nostr_utils/mod.rs b/src/nostr_utils/mod.rs
index 8f060a1..a5308cb 100644
--- a/src/nostr_utils/mod.rs
+++ b/src/nostr_utils/mod.rs
@@ -23,7 +23,7 @@ use std::{collections::HashSet, time::Duration};
use futures::future;
use nostr::{
- event::{Event, EventId, Kind, Tag, TagStandard, Tags, UnsignedEvent},
+ event::{Event, EventId, Kind, Tag, TagKind, TagStandard, Tags, UnsignedEvent},
filter::Filter,
key::PublicKey,
nips::{
@@ -39,7 +39,7 @@ use nostr_sdk::Client;
use traits::TokenUtils;
use crate::{
- cli::{CliOptions, issue::IssueStatus},
+ cli::{CliOptions, issue::IssueStatus, patch::PatchStatus},
error::{N34Error, N34Result},
};
@@ -239,6 +239,13 @@ impl NostrClient {
.collect()
}
+ /// Fetch the patch by the given id. None if not found
+ pub async fn fetch_patch(&self, patch_id: EventId) -> N34Result {
+ self.fetch_event(Filter::new().id(patch_id).kind(Kind::GitPatch))
+ .await?
+ .ok_or(N34Error::CanNotFoundPatch)
+ }
+
/// Returns the username for a given public key. If no username is found,
/// falls back to a shortened version of the public key.
pub async fn get_username(&self, user: PublicKey) -> String {
@@ -279,6 +286,69 @@ impl NostrClient {
.unwrap_or_else(|| Ok(IssueStatus::Open))
}
+ /// Gets the status of a patch. If it's a revision patch, checks if it's
+ /// closed when the root patch is already merged/applied but doesn't
+ /// reference this revision. Defaults to Open status if no status event
+ /// is found.
+ pub async fn fetch_patch_status(
+ &self,
+ root_patch: EventId,
+ root_revision: Option,
+ authorized_pubkeys: Vec,
+ ) -> N34Result {
+ let (root_status, event_tags) = self
+ .fetch_events(
+ Filter::new()
+ .event(root_patch)
+ .kinds([
+ Kind::GitStatusOpen,
+ Kind::GitStatusApplied,
+ Kind::GitStatusClosed,
+ Kind::GitStatusDraft,
+ ])
+ .authors(utils::dedup(authorized_pubkeys.into_iter())),
+ )
+ .await?
+ .into_iter()
+ .max_by_key(|e| e.created_at)
+ .map(|status| N34Result::Ok((PatchStatus::try_from(status.kind)?, status.tags)))
+ .unwrap_or_else(|| Ok((PatchStatus::Open, Tags::new())))?;
+
+ if let Some(revision_id) = root_revision {
+ if root_status.is_merged_or_applied()
+ && !event_tags
+ .filter(TagKind::e())
+ .any(|t| t.is_reply() && t.content().is_some_and(|c| c == revision_id.to_hex()))
+ {
+ return Ok(PatchStatus::Closed);
+ }
+ }
+
+ Ok(root_status)
+ }
+
+ pub async fn fetch_patch_series(
+ &self,
+ root_patch_id: EventId,
+ root_patch_author: PublicKey,
+ ) -> N34Result> {
+ Ok(self
+ .fetch_events(
+ Filter::new()
+ .kind(Kind::GitPatch)
+ .author(root_patch_author)
+ .event(root_patch_id),
+ )
+ .await?
+ .into_iter()
+ .filter(|e| {
+ e.tags.iter().any(|t| {
+ t.is_root() && t.content().is_some_and(|c| c == root_patch_id.to_hex())
+ })
+ })
+ .collect())
+ }
+
/// Finds the root issue or patch for a given event. If the event is already
/// a root (issue/patch), returns it directly. For comments, follows
/// parent/root references until finding the root or failing. Returns
diff --git a/src/nostr_utils/traits.rs b/src/nostr_utils/traits.rs
index 029792d..1241e11 100644
--- a/src/nostr_utils/traits.rs
+++ b/src/nostr_utils/traits.rs
@@ -16,7 +16,7 @@
use convert_case::{Case, Casing};
use nostr::{
- event::{EventBuilder, EventId, Tag, TagKind, TagStandard, Tags},
+ event::{Event, EventBuilder, EventId, Kind, Tag, TagKind, TagStandard, Tags},
key::PublicKey,
nips::{
nip01::Coordinate,
@@ -216,4 +216,43 @@ impl Vec {
self.iter().flat_map(|r| r.maintainers.clone()).collect()
}
}
+
+/// Utility functions for working with patch events
+#[easy_ext::ext(GitPatchUtils)]
+impl Event {
+ /// Returns whether the patch is a root or not
+ pub fn is_root_patch(&self) -> bool {
+ self.kind == Kind::GitPatch
+ && self
+ .tags
+ .filter(TagKind::t())
+ .any(|t| t.content() == Some("root"))
+ }
+
+ /// Returns whether the patch is patch-revision or not
+ pub fn is_revision_patch(&self) -> bool {
+ self.kind == Kind::GitPatch
+ && self
+ .tags
+ .filter(TagKind::t())
+ .any(|t| t.content() == Some("root-revision"))
+ }
+
+ /// Gets the root patch ID from a patch-revision event by finding the `e`
+ /// tag that replies to it. Fails if no such tag is found or if the tag
+ /// contains an invalid event ID.
+ pub fn root_patch_from_revision(&self) -> N34Result {
+ self.tags
+ .iter()
+ .find(|tag| tag.is_reply())
+ .ok_or_else(|| {
+ N34Error::InvalidEvent(
+ "A patch revision without `e`-reply to the root patch".to_owned(),
+ )
+ })?
+ .content()
+ .ok_or_else(|| N34Error::InvalidEvent("`e` tag without an event".to_owned()))?
+ .parse()
+ .map_err(|err| N34Error::InvalidEvent(format!("Invalid event ID in `e` tag: {err}")))
+ }
}