From 54f1c7e0e68670926a99993651db6f4e15087250 Mon Sep 17 00:00:00 2001 From: Awiteb Date: Thu, 8 May 2025 13:03:09 +0000 Subject: [PATCH] feat: Add `issue new` command Command to create a new issue on a repo Signed-off-by: Awiteb --- src/cli/issue/mod.rs | 38 +++++++++ src/cli/issue/new.rs | 169 ++++++++++++++++++++++++++++++++++++++ src/cli/mod.rs | 14 ++-- src/nostr_utils/traits.rs | 68 ++++++++++++++- src/nostr_utils/utils.rs | 49 ++++++++++- 5 files changed, 330 insertions(+), 8 deletions(-) create mode 100644 src/cli/issue/mod.rs create mode 100644 src/cli/issue/new.rs diff --git a/src/cli/issue/mod.rs b/src/cli/issue/mod.rs new file mode 100644 index 0000000..c1bd6f3 --- /dev/null +++ b/src/cli/issue/mod.rs @@ -0,0 +1,38 @@ +// 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 . + +/// `issue new` subcommand +mod new; + +use clap::Subcommand; + +use self::new::NewArgs; +use super::{CliOptions, CommandRunner}; +use crate::error::N34Result; + +#[derive(Subcommand, Debug)] +pub enum IssueSubcommands { + /// Create a new repository issue + New(NewArgs), +} + +impl CommandRunner for IssueSubcommands { + async fn run(self, options: CliOptions) -> N34Result<()> { + match self { + Self::New(args) => args.run(options).await, + } + } +} diff --git a/src/cli/issue/new.rs b/src/cli/issue/new.rs new file mode 100644 index 0000000..29fa80a --- /dev/null +++ b/src/cli/issue/new.rs @@ -0,0 +1,169 @@ +// 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::{ArgGroup, Args}; +use nostr::{ + event::{EventBuilder, Tag, TagStandard}, + nips::nip19::Nip19Coordinate, + parser::NostrParser, +}; + +use crate::{ + cli::{CliOptions, CommandRunner, parsers}, + error::N34Result, + nostr_utils::{ + NostrClient, + traits::{NewGitRepositoryAnnouncement, TokenUtils}, + utils, + }, +}; + + +/// Arguments for the `issue new` command +#[derive(Args, Debug)] +#[clap( + group( + ArgGroup::new("issue-content") + .args(["content", "editor"]) + .required(true) + ), + group( + ArgGroup::new("issue-subject") + .args(["editor", "subject"]) + ) +)] +pub struct NewArgs { + /// Repository address + #[arg(short, long, value_parser = parsers::repo_naddr)] + naddr: Nip19Coordinate, + /// Markdown content for the issue. Cannot be used together with the + /// `--editor` flag. + #[arg(short, long)] + content: Option, + /// Opens the user's default editor to write issue content. The first line + /// will be used as the issue subject. + #[arg(short, long)] + editor: bool, + /// The issue subject. Cannot be used together with the `--editor` flag. + #[arg(long)] + subject: Option, + /// Labels for the issue. Can be specified as arguments (-l bug) or hashtags + /// in content (#bug). + #[arg(short, long)] + label: Vec, +} + +impl NewArgs { + /// Returns the subject and the content of the issue. (subject, content) + pub fn issue_content(&self) -> N34Result<(Option, String)> { + if let Some(content) = self.content.as_ref() { + if let Some(subject) = self.subject.as_ref() { + return Ok((Some(subject.trim().to_owned()), content.trim().to_owned())); + } + return Ok((None, content.trim().to_owned())); + } + // If the `self.content` is `None` then the `self.editor` is `true` + let file_content = utils::read_editor(".md")?; + if file_content.contains('\n') { + Ok(file_content + .split_once('\n') + .map(|(s, c)| (Some(s.trim().to_owned()), c.trim().to_owned())) + .expect("There is a new line")) + } else { + tracing::info!("File content contains only issue body without a subject line"); + Ok((None, file_content)) + } + } +} + +impl CommandRunner for NewArgs { + async fn run(mut self, options: CliOptions) -> N34Result<()> { + let client = NostrClient::init(&options).await; + let user_pubk = options.pubkey().await?; + let relays_list = client.user_relays_list(user_pubk).await?; + let mut write_relays = + utils::add_write_relays(options.relays.clone(), relays_list.as_ref()); + client.add_relays(&options.relays).await; + client.add_relays(&self.naddr.relays).await; + write_relays.extend(client.fetch_repo(&self.naddr).await?.relays); + + let (subject, content) = self.issue_content()?; + let tokens = NostrParser::new().parse(&content).collect::>(); + let mut p_tagged_users = tokens + .iter() + .filter_map(TokenUtils::extract_public_key) + .collect::>(); + let quote_events = tokens + .iter() + .filter_map(TokenUtils::extract_event_id) + .collect::>(); + + self.label.extend( + tokens + .iter() + .filter_map(TokenUtils::extract_hashtag) + .collect::>(), + ); + + // Add the p-tagged users read relays to our write relays. And relays with the + // nprofile/nevent to our discovery relays + for (user, relays) in &p_tagged_users { + client.add_relays(relays).await; + write_relays = + utils::add_read_relays(write_relays, client.user_relays_list(*user).await?.as_ref()) + } + for (event_id, relays) in "e_events { + client.add_relays(relays).await; + // Add the note author to the p-tagged users + if let Some(author) = client.event_author(*event_id).await? { + p_tagged_users.push((author, Vec::new())); + write_relays = utils::add_read_relays( + write_relays, + client.user_relays_list(author).await?.as_ref(), + ); + } + } + + let event = EventBuilder::new_git_issue( + self.naddr.coordinate.clone(), + content, + subject, + self.label, + )? + .pow(options.pow) + .tags(p_tagged_users.into_iter().map(|(p, _)| Tag::public_key(p))) + .tags(quote_events.into_iter().map(|(e, r)| { + Tag::from_standardized(TagStandard::Quote { + event_id: e, + relay_url: r.first().cloned(), + public_key: None, + }) + })) + .build(user_pubk); + let event_id = event.id.expect("There is an id"); + + tracing::trace!(relays = ?write_relays, "Write relays list"); + let success = client + .send_event_to(event, relays_list.as_ref(), &write_relays) + .await?; + + let nevent = utils::new_nevent(event_id, &success)?; + println!("Issue created: {nevent}"); + + Ok(()) + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 110a9b0..45655ed 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -14,6 +14,8 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +/// `issue` subcommands +mod issue; /// CLI arguments parsers pub mod parsers; /// `repo` subcommands @@ -28,6 +30,7 @@ use clap_verbosity_flag::Verbosity; use nostr::key::Keys; use nostr::{PublicKey, RelayUrl, SecretKey}; +pub use self::issue::IssueSubcommands; pub use self::repo::RepoSubcommands; pub use self::traits::CommandRunner; use crate::error::N34Result; @@ -87,11 +90,11 @@ pub enum Commands { #[command(subcommand)] subcommands: RepoSubcommands, }, - // /// Manage issues - // Issue { - // #[command(subcommand)] - // subcommands: IssueSubcommands, - // }, + /// Manage issues + Issue { + #[command(subcommand)] + subcommands: IssueSubcommands, + }, // /// Manage patches // Patch { // #[command(subcommand)] @@ -112,6 +115,7 @@ impl CommandRunner for Commands { tracing::trace!("Handling: {self:#?}"); match self { Self::Repo { subcommands } => subcommands.run(options).await, + Self::Issue { subcommands } => subcommands.run(options).await, } } } diff --git a/src/nostr_utils/traits.rs b/src/nostr_utils/traits.rs index cff3844..7abe698 100644 --- a/src/nostr_utils/traits.rs +++ b/src/nostr_utils/traits.rs @@ -16,9 +16,14 @@ use convert_case::{Case, Casing}; use nostr::{ - event::{EventBuilder, Tag, TagKind, TagStandard, Tags}, + event::{EventBuilder, EventId, Tag, TagKind, TagStandard, Tags}, key::PublicKey, - nips::nip34::GitRepositoryAnnouncement, + nips::{ + nip01::Coordinate, + nip21::Nip21, + nip34::{GitIssue, GitRepositoryAnnouncement}, + }, + parser::Token, types::{RelayUrl, Url}, }; @@ -91,4 +96,63 @@ impl EventBuilder { .tags(labels.into_iter().map(Tag::hashtag)), ) } + + /// Creates a new [`GitIssue`] event builder with the given + /// issue details. + pub fn new_git_issue( + repository: Coordinate, + content: String, + subject: Option, + labels: Vec, + ) -> N34Result { + EventBuilder::git_issue(GitIssue { + repository, + content, + subject, + labels: labels.into_iter().map(|l| l.trim().to_owned()).collect(), + }) + .map_err(N34Error::from) + } +} + +/// Helper functions for [`Token`] type +#[easy_ext::ext(TokenUtils)] +impl Token<'_> { + /// Returns `Some((public_key, relays))` from the givin token if it's npub1 + /// or nprofile1 + pub fn extract_public_key(&self) -> Option<(PublicKey, Vec)> { + match self { + Token::Nostr(nip21) => { + match nip21 { + Nip21::Pubkey(pkey) => Some((*pkey, Vec::new())), + Nip21::Profile(profile) => Some((profile.public_key, profile.relays.clone())), + _ => None, + } + } + _ => None, + } + } + + /// Returns `Some((note_id, relays))` from the givin token if it's note1 or + /// nevent1 + pub fn extract_event_id(&self) -> Option<(EventId, Vec)> { + match self { + Token::Nostr(nip21) => { + match nip21 { + Nip21::EventId(event_id) => Some((*event_id, Vec::new())), + Nip21::Event(event) => Some((event.event_id, event.relays.clone())), + _ => None, + } + } + _ => None, + } + } + + /// Returns `Some(hashtag)` from the givin token if it's a hashtag + pub fn extract_hashtag(&self) -> Option { + match self { + Token::Hashtag(tag) => Some(tag.trim().to_owned()), + _ => None, + } + } } diff --git a/src/nostr_utils/utils.rs b/src/nostr_utils/utils.rs index 470986a..4a77c72 100644 --- a/src/nostr_utils/utils.rs +++ b/src/nostr_utils/utils.rs @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use std::{fmt, str::FromStr}; +use std::{fmt, fs, str::FromStr, sync::atomic::Ordering}; use nostr::{ event::{Event, EventId, Kind, TagKind, TagStandard}, @@ -137,3 +137,50 @@ pub fn add_write_relays(mut vector: Vec, event: Option<&Event>) -> Vec } vector } + +/// Extends the given vector with read relays found in the event (if any). +pub fn add_read_relays(mut vector: Vec, event: Option<&Event>) -> Vec { + if let Some(event) = event { + vector.extend( + nip65::extract_owned_relay_list(event.clone()) + .filter_map(|(r, m)| m.is_none_or(|m| m == RelayMetadata::Read).then_some(r)), + ); + } + vector +} + + +/// Opens the user's default editor ($EDITOR) to edit a temporary file with +/// given suffix, then reads and returns the file contents. The temporary file +/// is automatically deleted. +pub fn read_editor(file_suffix: &str) -> N34Result { + let Ok(editor) = std::env::var("EDITOR") else { + return Err(N34Error::EditorNotFound); + }; + + let temp_path = tempfile::NamedTempFile::with_suffix(file_suffix)?.into_temp_path(); + + // Disable the logs to not show up in a terminal text editor + crate::EDITOR_OPEN.store(true, Ordering::Relaxed); + let exit_status = std::process::Command::new(editor) + .arg(temp_path.to_str().expect("The path is valid utf8")) + .spawn()? + .wait()?; + crate::EDITOR_OPEN.store(false, Ordering::Relaxed); + + if !exit_status.success() { + if let Some(code) = exit_status.code() { + tracing::warn!("The editor exit with `{code}` status") + } + } + + let content = fs::read_to_string(&temp_path) + .map_err(N34Error::from)? + .trim() + .to_owned(); + + if content.is_empty() { + return Err(N34Error::EmptyEditorFile); + } + Ok(content) +}