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}"))) + } }