feat: New issue {reopen,close,resolve} commands to manage issue status

Signed-off-by: Awiteb <a@4rs.nl>
This commit is contained in:
Awiteb
2025-06-14 09:37:47 +00:00
parent 63f6c1fb11
commit a9a2cb2b16
9 changed files with 421 additions and 5 deletions

View File

@@ -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 `config relays` command to set the default fallbacks relays - by Awiteb
- New `issue view` command to view an issue - by Awiteb - New `issue view` command to view an issue - by Awiteb
- New `patch fetch` command to fetch patches - by Awiteb - New `patch fetch` command to fetch patches - by Awiteb
- New `issue {reopen,close,resolve}` commands to manage issue status - by Awiteb
### Refactor ### Refactor

View File

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

View File

@@ -14,17 +14,27 @@
// You should have received a copy of the GNU General Public License // 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>. // along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
/// `issue close` subcommand
mod close;
/// `issue new` subcommand /// `issue new` subcommand
mod new; mod new;
/// `issue reopen` subcommand
mod reopen;
/// `issue resolve` subcommand
mod resolve;
/// `issue view` subcommand /// `issue view` subcommand
mod view; mod view;
use clap::Subcommand; use clap::Subcommand;
use nostr::event::Kind;
use self::close::CloseArgs;
use self::new::NewArgs; use self::new::NewArgs;
use self::reopen::ReopenArgs;
use self::resolve::ResolveArgs;
use self::view::ViewArgs; use self::view::ViewArgs;
use super::{CliOptions, CommandRunner}; use super::{CliOptions, CommandRunner};
use crate::error::N34Result; use crate::error::{N34Error, N34Result};
/// Prefix used for git issue alt. /// Prefix used for git issue alt.
pub const ISSUE_ALT_PREFIX: &str = "git issue: "; pub const ISSUE_ALT_PREFIX: &str = "git issue: ";
@@ -35,10 +45,66 @@ pub enum IssueSubcommands {
New(NewArgs), New(NewArgs),
/// View an issue by its ID /// View an issue by its ID
View(ViewArgs), 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<Kind> for IssueStatus {
type Error = N34Error;
fn try_from(kind: Kind) -> Result<Self, Self::Error> {
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 { impl CommandRunner for IssueSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> { async fn run(self, options: CliOptions) -> N34Result<()> {
crate::run_command!(self, options, & New View) crate::run_command!(self, options, & New View Reopen Close Resolve)
} }
} }

View File

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

View File

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

123
src/cli/common_commands.rs Normal file
View File

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

View File

@@ -16,6 +16,8 @@
/// Commands module /// Commands module
pub mod commands; pub mod commands;
/// Common commands used by multiply commands
pub mod common_commands;
/// The CLI config /// The CLI config
pub mod config; pub mod config;
/// Default lazy values for CLI arguments /// Default lazy values for CLI arguments

View File

@@ -16,7 +16,7 @@
use std::process::ExitCode; 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 nostr_sdk::client::Error as ClientError;
use crate::cli::ConfigError; 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" "Patch not found, make sure it is in the relays and make sure that the ID is an patch ID"
)] )]
CanNotFoundPatch, 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, 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 { impl N34Error {

View File

@@ -39,7 +39,7 @@ use nostr_sdk::Client;
use traits::TokenUtils; use traits::TokenUtils;
use crate::{ use crate::{
cli::CliOptions, cli::{CliOptions, issue::IssueStatus},
error::{N34Error, N34Result}, error::{N34Error, N34Result},
}; };
@@ -239,6 +239,8 @@ impl NostrClient {
.collect() .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 { pub async fn get_username(&self, user: PublicKey) -> String {
self.fetch_event(Filter::new().kind(Kind::Metadata).author(user)) self.fetch_event(Filter::new().kind(Kind::Metadata).author(user))
.await .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<PublicKey>,
) -> N34Result<IssueStatus> {
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 /// 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 /// a root (issue/patch), returns it directly. For comments, follows
/// parent/root references until finding the root or failing. Returns /// parent/root references until finding the root or failing. Returns