diff --git a/CHANGELOG.md b/CHANGELOG.md index 19b6fc5..bf171a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 - View the repo maintainers as `npub` - by Awiteb +- New `patch list` commands to list the repo patches - by Awiteb ### Dependencies @@ -29,6 +30,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Not to return an error if `nostr-address` file does not exist - by Awiteb +- Spelling in help content - by DanConwayDev +- Fix a typo in `EmptySetRelays` error message - by Awiteb +- Require a repo in `repo view` command - by Awiteb ### Refactor diff --git a/src/cli/commands/patch/list.rs b/src/cli/commands/patch/list.rs new file mode 100644 index 0000000..76c5b26 --- /dev/null +++ b/src/cli/commands/patch/list.rs @@ -0,0 +1,46 @@ +// 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 std::num::NonZeroUsize; + +use clap::Args; + +use crate::{ + cli::{CliOptions, common_commands, traits::CommandRunner, types::NaddrOrSet}, + error::N34Result, +}; + +#[derive(Debug, Args)] +pub struct ListArgs { + /// 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")] + naddrs: Option>, + /// Maximum number of patches to list + #[arg(long, default_value = "15")] + limit: NonZeroUsize, +} + +impl CommandRunner for ListArgs { + const NEED_SIGNER: bool = false; + + async fn run(self, options: CliOptions) -> N34Result<()> { + common_commands::list_patches_and_issues(options, self.naddrs, true, self.limit.into()) + .await + } +} diff --git a/src/cli/commands/patch/mod.rs b/src/cli/commands/patch/mod.rs index f12e04e..236820a 100644 --- a/src/cli/commands/patch/mod.rs +++ b/src/cli/commands/patch/mod.rs @@ -22,6 +22,8 @@ mod close; mod draft; /// `patch fetch` subcommand mod fetch; +/// `patch list` subcommand +mod list; /// `patch merge` subcommand mod merge; /// `patch reopen` subcommand @@ -30,6 +32,7 @@ mod reopen; mod send; use std::{ + fmt, path::{Path, PathBuf}, str::FromStr, sync::LazyLock, @@ -43,6 +46,7 @@ use self::apply::ApplyArgs; use self::close::CloseArgs; use self::draft::DraftArgs; use self::fetch::FetchArgs; +use self::list::ListArgs; use self::merge::MergeArgs; use self::reopen::ReopenArgs; use self::send::SendArgs; @@ -78,6 +82,8 @@ pub enum PatchSubcommands { Apply(ApplyArgs), /// Set an open patch status to merged. Merge(MergeArgs), + /// List the repositories patches. + List(ListArgs), } /// Represents a git patch @@ -115,6 +121,16 @@ impl PatchStatus { } } + /// Returns the string representation of the patch status. + pub const fn as_str(&self) -> &'static str { + match self { + Self::Open => "Open", + Self::MergedApplied => "Merged/Applied", + Self::Closed => "Closed", + Self::Draft => "Draft", + } + } + /// Check if the patch is open. #[inline] pub fn is_open(&self) -> bool { @@ -140,6 +156,19 @@ impl PatchStatus { } } +impl From<&PatchStatus> for Kind { + fn from(status: &PatchStatus) -> Self { + status.kind() + } +} + +impl fmt::Display for PatchStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + + impl TryFrom for PatchStatus { type Error = N34Error; @@ -178,7 +207,7 @@ impl GitPatch { impl CommandRunner for PatchSubcommands { async fn run(self, options: CliOptions) -> N34Result<()> { - crate::run_command!(self, options, & Send Fetch Close Reopen Draft Apply Merge) + crate::run_command!(self, options, & Send Fetch Close Reopen Draft Apply Merge List) } } diff --git a/src/cli/common_commands.rs b/src/cli/common_commands.rs index bc85f5f..d2c16ee 100644 --- a/src/cli/common_commands.rs +++ b/src/cli/common_commands.rs @@ -14,14 +14,15 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use std::iter; +use std::{iter, str::FromStr, sync::Arc}; use either::Either; +use futures::future; use nostr::{ - event::{EventBuilder, Tag, TagKind}, + event::{Event, EventBuilder, EventId, Kind, Tag, TagKind}, filter::Filter, hashes::sha1::Hash as Sha1Hash, - nips::nip10::Marker, + nips::{nip10::Marker, nip19::ToBech32}, }; use super::{ @@ -29,15 +30,15 @@ use super::{ patch::PatchStatus, types::{NaddrOrSet, NostrEvent}, }; -use crate::{ - cli::CliOptions, - error::{N34Error, N34Result}, - nostr_utils::traits::{GitPatchUtils, ReposUtils}, -}; use crate::{ cli::types::{OptionNaddrOrSetVecExt, RelayOrSetVecExt}, nostr_utils::{NostrClient, traits::NaddrsUtils, utils}, }; +use crate::{ + cli::{CliOptions, patch::GitPatch}, + error::{N34Error, N34Result}, + nostr_utils::traits::{GitIssueUtils, GitPatchUtils, ReposUtils}, +}; /// Updates the issue's status to `new_status` after validating it with /// `check_fn`. @@ -159,17 +160,7 @@ pub async fn patch_status_command( )); } - 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 (root_patch, root_revision) = get_patch_root_revision(&patch_event)?; let patch_status = client .fetch_patch_status( root_patch, @@ -260,3 +251,153 @@ pub async fn patch_status_command( Ok(()) } + +/// Fetch and display patches and issues for given repositories. +/// If `list_patches` is true, lists patches instead of issues. +/// `limit` controls the maximum number of items to fetch. +pub async fn list_patches_and_issues( + options: CliOptions, + naddrs: Option>, + list_patches: bool, + limit: usize, +) -> N34Result<()> { + let naddrs = utils::check_empty_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()).await; + + let coordinates = naddrs.clone().into_coordinates(); + let repos = client.fetch_repos(&coordinates).await?; + let authorized_pubkeys = [naddrs.extract_owners(), repos.extract_maintainers()].concat(); + client.add_relays(&repos.extract_relays()).await; + // This helps discover issues and their status. + client + .add_relays(&client.read_relays_from_users(&authorized_pubkeys).await) + .await; + + + let kind = if list_patches { + Kind::GitPatch + } else { + Kind::GitIssue + }; + + let mut filter = Filter::new() + .coordinates(coordinates.iter()) + .kind(kind) + .limit(limit); + + if list_patches { + filter = filter.hashtag("root"); + } + + let arc_client = Arc::new(client); + // Events are sorted by kind in ascending order: + // 1630 (Open), 1631 (Resolved/Applied), 1632 (Closed), 1633 (Draft) + let events = utils::sort_by_key( + future::join_all( + arc_client + .fetch_events(filter) + .await? + .into_iter() + .take(limit) + .map(|event| { + let c = arc_client.clone(); + let keys = authorized_pubkeys.clone(); + async move { + let status = if list_patches { + let (root, root_revision) = get_patch_root_revision(&event)?; + c.fetch_patch_status( + root, + root_revision, + [keys.as_slice(), &[event.pubkey]].concat(), + ) + .await + .map(Either::Left)? + } else { + c.fetch_issue_status( + event.id, + [keys.as_slice(), &[event.pubkey]].concat(), + ) + .await + .map(Either::Right)? + }; + N34Result::Ok((event, status)) + } + }), + ) + .await + .into_iter() + .filter_map(|r| r.ok()), + |(_, status)| status.as_ref().either_into::(), + ); + + let lines = events + .map(|(event, status)| format_patch_and_issue(&event, status)) + .collect::>(); + + let max_width = lines + .iter() + .map(|s| s.split_once('\n').map_or(85, |(l, _)| l.chars().count())) + .max() + .unwrap_or(85) + .max(67); // length of the event id + + println!("{}", lines.join(&format!("{}\n", "-".repeat(max_width)))); + + Ok(()) +} + + +/// Returns a tuple of (root_id, patch_id) if this is a valid root or revision +/// patch. +fn get_patch_root_revision(patch_event: &Event) -> N34Result<(EventId, Option)> { + if patch_event.is_revision_patch() { + Ok(( + patch_event.root_patch_from_revision()?, + Some(patch_event.id), + )) + } else if patch_event.is_root_patch() { + Ok((patch_event.id, None)) + } else { + Err(N34Error::NotRootPatch) + } +} + +/// Formats an event as either a patch or an issue. For patches, extracts the +/// subject line from the Git patch format. For issues, combines the subject +/// with labels. The output includes status and formatted ID. +fn format_patch_and_issue(event: &Event, status: Either) -> String { + let subject = if status.is_left() { + GitPatch::from_str(&event.content) + .map(|p| p.subject) + .unwrap_or_else(|_| { + event + .content + .lines() + .find(|line| line.trim().starts_with("Subject: ")) + .unwrap_or_default() + .trim() + .trim_start_matches("Subject: ") + .to_owned() + }) + } else { + let labels = event.extract_issue_labels(); + let subject = event.extract_issue_subject(); + + if labels.is_empty() { + subject.to_owned() + } else { + format!(r#""{subject}" {labels}"#) + } + }; + format!( + "({status}) {}\nID: {}\n", + utils::smart_wrap(&subject, 85), + event.id.to_bech32().expect("Infallible") + ) +} diff --git a/src/nostr_utils/utils.rs b/src/nostr_utils/utils.rs index a404300..f4f490f 100644 --- a/src/nostr_utils/utils.rs +++ b/src/nostr_utils/utils.rs @@ -114,6 +114,18 @@ where vector } +/// Sorts items from the iterator using the given key function. +/// The sorting is unstable, but faster than stable sorting. +pub fn sort_by_key(iterator: I, key: impl FnMut(&T) -> K) -> impl Iterator +where + I: IntoIterator, + K: Ord, +{ + let mut vector = Vec::::from_iter(iterator); + vector.sort_unstable_by_key(key); + vector.into_iter() +} + /// Creates a new NIP-19 nevent string from an event ID and up to 3 unique relay /// URLs. #[inline]