From 387dd32a37f9dadd07a6af4ffdab2e27f21e2826 Mon Sep 17 00:00:00 2001
From: Awiteb
Date: Wed, 2 Jul 2025 21:53:52 +0000
Subject: [PATCH] feat: New `patch list` commands to list the repo patches
Signed-off-by: Awiteb
---
CHANGELOG.md | 4 +
src/cli/commands/patch/list.rs | 46 +++++++++
src/cli/commands/patch/mod.rs | 31 +++++-
src/cli/common_commands.rs | 179 +++++++++++++++++++++++++++++----
src/nostr_utils/utils.rs | 12 +++
5 files changed, 252 insertions(+), 20 deletions(-)
create mode 100644 src/cli/commands/patch/list.rs
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]