feat: New patch subcommands apply,close,draft,merge and reopen to manage the patch status

Signed-off-by: Awiteb <a@4rs.nl>
This commit is contained in:
Awiteb
2025-06-28 13:46:23 +00:00
parent d6dd63f576
commit 8b09cff5e3
11 changed files with 718 additions and 15 deletions

View File

@@ -0,0 +1,77 @@
// 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 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<Vec<NaddrOrSet>>,
/// 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<Sha1>,
}
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
}
}

View File

@@ -0,0 +1,66 @@
// 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 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<Vec<NaddrOrSet>>,
/// 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
}
}

View File

@@ -0,0 +1,73 @@
// 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 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<Vec<NaddrOrSet>>,
/// 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
}
}

View File

@@ -0,0 +1,77 @@
// 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 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<Vec<NaddrOrSet>>,
/// 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
}
}

View File

@@ -14,8 +14,18 @@
// 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>.
/// `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<Regex> =
LazyLock::new(|| Regex::new(r"(?m)^Subject: (.*(?:\n .*)*)").unwrap());
@@ -49,10 +64,20 @@ static PATCH_VERSION_NUMBER_RE: LazyLock<Regex> = 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<Kind> for PatchStatus {
type Error = N34Error;
fn try_from(kind: Kind) -> Result<Self, Self::Error> {
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<Path>) -> N34Result<PathBuf> {
@@ -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<String> {
.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

View File

@@ -0,0 +1,67 @@
// 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 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<Vec<NaddrOrSet>>,
/// 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
}
}

View File

@@ -14,27 +14,32 @@
// 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::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<Vec<NaddrOrSet>>,
new_status: PatchStatus,
merge_or_applied_commits: Option<Either<Sha1, Vec<Sha1>>>,
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::<Vec<_>>();
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(())
}

View File

@@ -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<Event> {
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<EventId>,
authorized_pubkeys: Vec<PublicKey>,
) -> N34Result<PatchStatus> {
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<Vec<Event>> {
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

View File

@@ -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<GitRepositoryAnnouncement> {
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<EventId> {
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}")))
}
}