feat: New patch list commands to list the repo patches

Signed-off-by: Awiteb <a@4rs.nl>
This commit is contained in:
Awiteb
2025-07-02 21:53:52 +00:00
parent 5c5987e07b
commit 387dd32a37
5 changed files with 252 additions and 20 deletions

View File

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

View File

@@ -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<Kind> 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)
}
}

View File

@@ -14,14 +14,15 @@
// 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 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<Vec<NaddrOrSet>>,
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::<Kind>(),
);
let lines = events
.map(|(event, status)| format_patch_and_issue(&event, status))
.collect::<Vec<String>>();
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<EventId>)> {
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<PatchStatus, IssueStatus>) -> 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")
)
}