diff --git a/CHANGELOG.md b/CHANGELOG.md index a3e5c81..d9d39b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New `config relays` command to set the default fallbacks relays - by Awiteb - 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 ### Refactor diff --git a/src/cli/commands/issue/close.rs b/src/cli/commands/issue/close.rs new file mode 100644 index 0000000..e5b49dd --- /dev/null +++ b/src/cli/commands/issue/close.rs @@ -0,0 +1,65 @@ +// 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::IssueStatus; +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 opened issue id to close it + issue_id: NostrEvent, +} + +impl CommandRunner for CloseArgs { + async fn run(self, options: CliOptions) -> N34Result<()> { + crate::cli::common_commands::issue_status_command( + options, + self.issue_id, + self.naddrs, + IssueStatus::Closed, + |issue_status| { + if issue_status.is_closed() { + return Err(N34Error::InvalidStatus( + "You can't close an already closed issue".to_owned(), + )); + } + + if issue_status.is_resolved() { + return Err(N34Error::InvalidStatus( + "You can't close a resolved issue".to_owned(), + )); + } + Ok(()) + }, + ) + .await + } +} diff --git a/src/cli/commands/issue/mod.rs b/src/cli/commands/issue/mod.rs index 40268f9..204f495 100644 --- a/src/cli/commands/issue/mod.rs +++ b/src/cli/commands/issue/mod.rs @@ -14,17 +14,27 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +/// `issue close` subcommand +mod close; /// `issue new` subcommand mod new; +/// `issue reopen` subcommand +mod reopen; +/// `issue resolve` subcommand +mod resolve; /// `issue view` subcommand mod view; use clap::Subcommand; +use nostr::event::Kind; +use self::close::CloseArgs; use self::new::NewArgs; +use self::reopen::ReopenArgs; +use self::resolve::ResolveArgs; use self::view::ViewArgs; use super::{CliOptions, CommandRunner}; -use crate::error::N34Result; +use crate::error::{N34Error, N34Result}; /// Prefix used for git issue alt. pub const ISSUE_ALT_PREFIX: &str = "git issue: "; @@ -35,10 +45,66 @@ pub enum IssueSubcommands { New(NewArgs), /// View an issue by its ID View(ViewArgs), + /// Reopens a closed issue. + Reopen(ReopenArgs), + /// Closes an open issue. + Close(CloseArgs), + /// Resolves an open issue. + Resolve(ResolveArgs), +} + +/// Possible states for a Git issue +#[derive(Debug)] +pub enum IssueStatus { + /// The issue is currently open + Open, + /// The issue has been resolved + Resolved, + /// The issue has been closed + Closed, +} + +impl IssueStatus { + /// Maps the issue status to its corresponding Nostr kind. + pub fn kind(&self) -> Kind { + match self { + Self::Open => Kind::GitStatusOpen, + Self::Resolved => Kind::GitStatusApplied, + Self::Closed => Kind::GitStatusClosed, + } + } + + /// Check if the issue is open. + pub fn is_open(&self) -> bool { + matches!(self, Self::Open) + } + + /// Check if the issue is resolved. + pub fn is_resolved(&self) -> bool { + matches!(self, Self::Resolved) + } + + /// Check if the issue is closed. + pub fn is_closed(&self) -> bool { + matches!(self, Self::Closed) + } +} + +impl TryFrom for IssueStatus { + type Error = N34Error; + + fn try_from(kind: Kind) -> Result { + match kind { + Kind::GitStatusOpen => Ok(Self::Open), + Kind::GitStatusApplied => Ok(Self::Resolved), + Kind::GitStatusClosed => Ok(Self::Closed), + _ => Err(N34Error::InvalidIssueStatus(kind)), + } + } } impl CommandRunner for IssueSubcommands { async fn run(self, options: CliOptions) -> N34Result<()> { - crate::run_command!(self, options, & New View) + crate::run_command!(self, options, & New View Reopen Close Resolve) } } diff --git a/src/cli/commands/issue/reopen.rs b/src/cli/commands/issue/reopen.rs new file mode 100644 index 0000000..43ddc7d --- /dev/null +++ b/src/cli/commands/issue/reopen.rs @@ -0,0 +1,65 @@ +// 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::IssueStatus; +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 issue id to reopen it + issue_id: NostrEvent, +} + +impl CommandRunner for ReopenArgs { + async fn run(self, options: CliOptions) -> N34Result<()> { + crate::cli::common_commands::issue_status_command( + options, + self.issue_id, + self.naddrs, + IssueStatus::Open, + |issue_status| { + if issue_status.is_open() { + return Err(N34Error::InvalidStatus( + "You can't reopen an opened issue".to_owned(), + )); + } + + if issue_status.is_resolved() { + return Err(N34Error::InvalidStatus( + "You can't open a resolved issue".to_owned(), + )); + } + Ok(()) + }, + ) + .await + } +} diff --git a/src/cli/commands/issue/resolve.rs b/src/cli/commands/issue/resolve.rs new file mode 100644 index 0000000..0cc64d1 --- /dev/null +++ b/src/cli/commands/issue/resolve.rs @@ -0,0 +1,59 @@ +// 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::IssueStatus; +use crate::{ + cli::{ + CliOptions, + traits::CommandRunner, + types::{NaddrOrSet, NostrEvent}, + }, + error::{N34Error, N34Result}, +}; + +#[derive(Debug, Args)] +pub struct ResolveArgs { + /// 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 opened issue id to resolve it + issue_id: NostrEvent, +} + +impl CommandRunner for ResolveArgs { + async fn run(self, options: CliOptions) -> N34Result<()> { + crate::cli::common_commands::issue_status_command( + options, + self.issue_id, + self.naddrs, + IssueStatus::Resolved, + |issue_status| { + if issue_status.is_resolved() { + return Err(N34Error::InvalidStatus( + "You can't resolve an resolved issue".to_owned(), + )); + } + Ok(()) + }, + ) + .await + } +} diff --git a/src/cli/common_commands.rs b/src/cli/common_commands.rs new file mode 100644 index 0000000..689b09a --- /dev/null +++ b/src/cli/common_commands.rs @@ -0,0 +1,123 @@ +// 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 nostr::{ + event::{EventBuilder, Tag}, + filter::Filter, + nips::nip10::Marker, +}; + +use super::{ + issue::IssueStatus, + types::{NaddrOrSet, NostrEvent}, +}; +use crate::{ + cli::CliOptions, + error::{N34Error, N34Result}, + nostr_utils::traits::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 +/// `check_fn`. +pub async fn issue_status_command( + options: CliOptions, + issue_id: NostrEvent, + naddrs: Option>, + new_status: IssueStatus, + check_fn: impl FnOnce(&IssueStatus) -> 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(), issue_id.relays].concat()) + .await; + + let coordinates = naddrs.clone().into_coordinates(); + let repos = client.fetch_repos(&coordinates).await?; + let maintainers = utils::dedup(repos.iter().flat_map(|r| r.maintainers.clone())); + let relay_hint = repos.extract_relays().first().cloned(); + client.add_relays(&repos.extract_relays()).await; + + let issue_event = client + .fetch_event(Filter::new().id(issue_id.event_id)) + .await? + .ok_or(N34Error::CanNotFoundIssue)?; + + let issue_status = client + .fetch_issue_status( + issue_id.event_id, + [maintainers.as_slice(), &[issue_event.pubkey]].concat(), + ) + .await?; + + check_fn(&issue_status)?; + + let status_event = EventBuilder::new(new_status.kind(), "") + .pow(options.pow.unwrap_or_default()) + .tag(utils::event_reply_tag( + &issue_id.event_id, + relay_hint.as_ref(), + Marker::Root, + )) + .tag(Tag::public_key(issue_event.pubkey)) + .tags(maintainers.iter().map(|p| Tag::public_key(*p))) + .tags( + coordinates + .into_iter() + .map(|c| Tag::coordinate(c, relay_hint.clone())), + ) + .dedup_tags() + .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 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, + // TODO: Make this a function and use it elsewhere. + client + .fetch_events( + Filter::new() + .kind(nostr::event::Kind::RelayList) + .authors(maintainers), + ) + .await? + .into_iter() + .flat_map(|e| utils::add_read_relays(Some(&e))) + .collect(), + ] + .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!("Issue status created: {nevent}"); + + Ok(()) +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index d5cd925..452bffc 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -16,6 +16,8 @@ /// Commands module pub mod commands; +/// Common commands used by multiply commands +pub mod common_commands; /// The CLI config pub mod config; /// Default lazy values for CLI arguments diff --git a/src/error.rs b/src/error.rs index e2f94d2..25fa6f6 100644 --- a/src/error.rs +++ b/src/error.rs @@ -16,7 +16,7 @@ use std::process::ExitCode; -use nostr::event::builder::Error as EventBuilderError; +use nostr::event::{Kind, builder::Error as EventBuilderError}; use nostr_sdk::client::Error as ClientError; use crate::cli::ConfigError; @@ -89,8 +89,16 @@ pub enum N34Error { "Patch not found, make sure it is in the relays and make sure that the ID is an patch ID" )] CanNotFoundPatch, - #[error("The given patch id is not a root patch")] + #[error(r#"The given patch id is not a root patch. It must contains `["t", "root"]` tag"#)] NotRootPatch, + #[error("This status kind can't be set for an issue: {0}")] + InvalidIssueStatus(Kind), + #[error("This status kind can't be set for a patch: {0}")] + InvalidPatchStatus(Kind), + #[error("Can't find the root patch of the given patch-revision")] + RevisionRootNotFound, + #[error("Invalid status for the issue/patch: {0}")] + InvalidStatus(String), } impl N34Error { diff --git a/src/nostr_utils/mod.rs b/src/nostr_utils/mod.rs index f5a1a92..54d3b65 100644 --- a/src/nostr_utils/mod.rs +++ b/src/nostr_utils/mod.rs @@ -39,7 +39,7 @@ use nostr_sdk::Client; use traits::TokenUtils; use crate::{ - cli::CliOptions, + cli::{CliOptions, issue::IssueStatus}, error::{N34Error, N34Result}, }; @@ -239,6 +239,8 @@ impl NostrClient { .collect() } + /// 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 { self.fetch_event(Filter::new().kind(Kind::Metadata).author(user)) .await @@ -252,6 +254,31 @@ impl NostrClient { }) } + /// Get the latest status of an issue by its ID, only considering status + /// events from authorized_pubkeys. If no valid status event is found, + /// defaults to Open. + pub async fn fetch_issue_status( + &self, + issue_id: EventId, + authorized_pubkeys: Vec, + ) -> N34Result { + self.fetch_events( + Filter::new() + .event(issue_id) + .kinds([ + Kind::GitStatusOpen, + Kind::GitStatusApplied, + Kind::GitStatusClosed, + ]) + .authors(utils::dedup(authorized_pubkeys.into_iter())), + ) + .await? + .into_iter() + .max_by_key(|e| e.created_at) + .map(|status| IssueStatus::try_from(status.kind)) + .unwrap_or_else(|| Ok(IssueStatus::Open)) + } + /// 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