feat: support pull requests

Support for pull requests, it's not standard in NIPs yet[1] but there must be
two clients that support it to be included in NIPs, n34 is one of them.

This support:
- Create a new PR `n34 pr new`
- Update PR tip `n34 pr update`
- View a PR `n34 pr view`
- List PRs `n34 pr list`
- Add `PR` and `PR-update` kinds as roots in `NostrClient::find_root`
- Support issuing statuses for PRs
- Support GRASP PR and PR-update
- Update the docs

[1] https://github.com/nostr-protocol/nips/pull/1966
This commit is contained in:
Awiteb
2025-09-12 06:43:23 +00:00
parent cc3aed0e89
commit 4360aa192c
43 changed files with 1714 additions and 143 deletions

View File

@@ -19,7 +19,12 @@ use std::num::NonZeroUsize;
use clap::Args;
use crate::{
cli::{CliOptions, common_commands, traits::CommandRunner, types::NaddrOrSet},
cli::{
CliOptions,
common_commands,
traits::CommandRunner,
types::{EntityType, NaddrOrSet},
},
error::N34Result,
};
@@ -42,7 +47,11 @@ 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, false, self.limit.into())
.await
common_commands::list_pr_patches_and_issues::<{ EntityType::Issue as u8 }>(
options,
self.naddrs,
self.limit.into(),
)
.await
}
}

View File

@@ -20,6 +20,8 @@ pub mod config;
pub mod issue;
/// `patch` subcommands
pub mod patch;
/// `pr` subcommands
pub mod pr;
/// `reply` command
pub mod reply;
/// `repo` subcommands
@@ -40,6 +42,7 @@ use nostr_connect::client::NostrConnect;
use self::config::ConfigSubcommands;
use self::issue::IssueSubcommands;
use self::patch::PatchSubcommands;
use self::pr::PrSubcommands;
use self::reply::ReplyArgs;
use self::repo::RepoSubcommands;
use self::sets::SetsSubcommands;
@@ -113,6 +116,11 @@ pub enum Commands {
#[command(subcommand)]
subcommands: PatchSubcommands,
},
/// Manage pull requests
Pr {
#[command(subcommand)]
subcommands: PrSubcommands,
},
/// Manage configuration
Config {
#[command(subcommand)]
@@ -197,6 +205,6 @@ impl CommandRunner for Commands {
tracing::trace!("Options: {options:#?}");
tracing::trace!("Handling: {self:#?}");
crate::run_command!(self, options, Repo Issue Sets Patch Config & Reply)
crate::run_command!(self, options, Repo Issue Sets Patch Pr Config & Reply)
}
}

View File

@@ -22,7 +22,7 @@ use crate::{
cli::{
CliOptions,
traits::{CommandRunner, VecNostrEventExt},
types::{NaddrOrSet, NostrEvent},
types::{EntityType, NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
};
@@ -55,7 +55,7 @@ pub struct ApplyArgs {
impl CommandRunner for ApplyArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_status_command(
crate::cli::common_commands::patch_pr_status_command::<{ EntityType::Patch as u8 }>(
options,
self.patch_id,
self.naddrs,
@@ -76,9 +76,7 @@ impl CommandRunner for ApplyArgs {
}
if patch_status.is_drafted() {
return Err(N34Error::InvalidStatus(
"You can't apply a drafted patch".to_owned(),
));
return Err(N34Error::InvalidStatus("Cannot apply a draft".to_owned()));
}
Ok(())
},

View File

@@ -21,7 +21,7 @@ use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent},
types::{EntityType, NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
};
@@ -46,7 +46,7 @@ pub struct CloseArgs {
impl CommandRunner for CloseArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_status_command(
crate::cli::common_commands::patch_pr_status_command::<{ EntityType::Patch as u8 }>(
options,
self.patch_id,
self.naddrs,

View File

@@ -21,7 +21,7 @@ use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent},
types::{EntityType, NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
};
@@ -46,7 +46,7 @@ pub struct DraftArgs {
impl CommandRunner for DraftArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_status_command(
crate::cli::common_commands::patch_pr_status_command::<{ EntityType::Patch as u8 }>(
options,
self.patch_id,
self.naddrs,
@@ -56,7 +56,7 @@ impl CommandRunner for DraftArgs {
|patch_status| {
if patch_status.is_drafted() {
return Err(N34Error::InvalidStatus(
"You can't draft an already drafted patch".to_owned(),
"You can't draft an already draft patch".to_owned(),
));
}

View File

@@ -19,7 +19,12 @@ use std::num::NonZeroUsize;
use clap::Args;
use crate::{
cli::{CliOptions, common_commands, traits::CommandRunner, types::NaddrOrSet},
cli::{
CliOptions,
common_commands,
traits::CommandRunner,
types::{EntityType, NaddrOrSet},
},
error::N34Result,
};
@@ -42,7 +47,11 @@ 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
common_commands::list_pr_patches_and_issues::<{ EntityType::Patch as u8 }>(
options,
self.naddrs,
self.limit.into(),
)
.await
}
}

View File

@@ -22,7 +22,7 @@ use crate::{
cli::{
CliOptions,
traits::{CommandRunner, VecNostrEventExt},
types::{NaddrOrSet, NostrEvent},
types::{EntityType, NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
};
@@ -54,7 +54,7 @@ pub struct MergeArgs {
impl CommandRunner for MergeArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_status_command(
crate::cli::common_commands::patch_pr_status_command::<{ EntityType::Patch as u8 }>(
options,
self.patch_id,
self.naddrs,
@@ -64,7 +64,7 @@ impl CommandRunner for MergeArgs {
|patch_status| {
if patch_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"You can't merge an already merged patch".to_owned(),
"You can't merge an already merged/applied patch".to_owned(),
));
}
@@ -76,7 +76,7 @@ impl CommandRunner for MergeArgs {
if patch_status.is_drafted() {
return Err(N34Error::InvalidStatus(
"You can't merge a drafted patch".to_owned(),
"You can't merge a draft patch".to_owned(),
));
}

View File

@@ -21,7 +21,7 @@ use crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent},
types::{EntityType, NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
};
@@ -46,7 +46,7 @@ pub struct ReopenArgs {
impl CommandRunner for ReopenArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_status_command(
crate::cli::common_commands::patch_pr_status_command::<{ EntityType::Patch as u8 }>(
options,
self.patch_id,
self.naddrs,

View File

@@ -0,0 +1,83 @@
// 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 nostr::hashes::sha1::Hash as Sha1Hash;
use crate::{
cli::{
CliOptions,
patch::PatchPrStatus,
traits::CommandRunner,
types::{EntityType, NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct ApplyArgs {
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// The open PR id to apply it.
pr_id: NostrEvent,
/// The applied commits.
#[arg(required = true)]
applied_commits: Vec<Sha1Hash>,
}
impl CommandRunner for ApplyArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_pr_status_command::<{ EntityType::PullRequest as u8 }>(
options,
self.pr_id,
self.naddrs,
PatchPrStatus::MergedApplied,
Some(either::Either::Right(self.applied_commits)),
Vec::new(),
|pr_status| {
if pr_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"You can't apply an already applied pull request".to_owned(),
));
}
if pr_status.is_closed() {
return Err(N34Error::InvalidStatus(
"You can't apply a closed pull request".to_owned(),
));
}
if pr_status.is_drafted() {
return Err(N34Error::InvalidStatus(
"Cannot apply a draft pull request".to_owned(),
));
}
Ok(())
},
)
.await
}
}

View File

@@ -0,0 +1,73 @@
// 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 crate::{
cli::{
CliOptions,
patch::PatchPrStatus,
traits::CommandRunner,
types::{EntityType, NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct CloseArgs {
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// The open/draft PR id to close it.
pr_id: NostrEvent,
}
impl CommandRunner for CloseArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_pr_status_command::<{ EntityType::PullRequest as u8 }>(
options,
self.pr_id,
self.naddrs,
PatchPrStatus::Closed,
None,
Vec::new(),
|pr_status| {
if pr_status.is_closed() {
return Err(N34Error::InvalidStatus(
"You can't close an already closed pull request".to_owned(),
));
}
if pr_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"You can't close a merged/applied pull request".to_owned(),
));
}
Ok(())
},
)
.await
}
}

View File

@@ -0,0 +1,80 @@
// 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 crate::{
cli::{
CliOptions,
patch::PatchPrStatus,
traits::CommandRunner,
types::{EntityType, NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct DraftArgs {
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// The open PR id to draft it.
pr_id: NostrEvent,
}
impl CommandRunner for DraftArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_pr_status_command::<{ EntityType::PullRequest as u8 }>(
options,
self.pr_id,
self.naddrs,
PatchPrStatus::Draft,
None,
Vec::new(),
|pr_status| {
if pr_status.is_drafted() {
return Err(N34Error::InvalidStatus(
"You can't draft an already draft pull request".to_owned(),
));
}
if pr_status.is_closed() {
return Err(N34Error::InvalidStatus(
"You can't draft a closed pull request".to_owned(),
));
}
if pr_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"You can't draft a merged/applied pull request".to_owned(),
));
}
Ok(())
},
)
.await
}
}

View File

@@ -0,0 +1,58 @@
// 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::{EntityType, NaddrOrSet},
},
error::N34Result,
};
#[derive(Args, Debug)]
pub struct ListArgs {
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(value_name = "NADDR-NIP05-OR-SET", value_delimiter = ',')]
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_pr_patches_and_issues::<{ EntityType::PullRequest as u8 }>(
options,
self.naddrs,
self.limit.into(),
)
.await
}
}

View File

@@ -0,0 +1,83 @@
// 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 nostr::hashes::sha1::Hash as Sha1Hash;
use crate::{
cli::{
CliOptions,
patch::PatchPrStatus,
traits::CommandRunner,
types::{EntityType, NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct MergeArgs {
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// The open PR id to merge it.
pr_id: NostrEvent,
/// The merge commit id
merge_commit: Sha1Hash,
}
impl CommandRunner for MergeArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_pr_status_command::<{ EntityType::PullRequest as u8 }>(
options,
self.pr_id,
self.naddrs,
PatchPrStatus::MergedApplied,
Some(either::Either::Left(self.merge_commit)),
Vec::new(),
|pr_status| {
if pr_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"You can't merge an already merged/applied pull request".to_owned(),
));
}
if pr_status.is_closed() {
return Err(N34Error::InvalidStatus(
"You can't merge a closed pull request".to_owned(),
));
}
if pr_status.is_drafted() {
return Err(N34Error::InvalidStatus(
"You can't merge a draft pull request".to_owned(),
));
}
Ok(())
},
)
.await
}
}

View File

@@ -0,0 +1,84 @@
// 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>.
/// `pr apply` suubcommand
mod apply;
/// `pr close` subcommand
mod close;
/// `pr draft` subcommand
mod draft;
/// `pr list` subcommand
mod list;
/// `pr merge` subcommand
mod merge;
/// `pr new` subcommand
mod new;
/// `pr reopen` subcommand
mod reopen;
/// `pr update` subcommand
mod update;
/// `pr view` subcommand
mod view;
use clap::Subcommand;
use self::apply::ApplyArgs;
use self::close::CloseArgs;
use self::draft::DraftArgs;
use self::list::ListArgs;
use self::merge::MergeArgs;
use self::new::NewArgs;
use self::reopen::ReopenArgs;
use self::update::UpdateArgs;
use self::view::ViewArgs;
use crate::{
cli::{CliOptions, traits::CommandRunner},
error::N34Result,
};
/// The kind of the pull request
pub const PR_KIND: nostr::event::Kind = nostr::event::Kind::Custom(1618);
/// The kind of the pull request update
pub const PR_UPDATE_KIND: nostr::event::Kind = nostr::event::Kind::Custom(1619);
#[derive(Subcommand, Debug)]
pub enum PrSubcommands {
/// Create a pull request.
New(NewArgs),
/// Update a pull request.
Update(UpdateArgs),
/// View a pull request.
View(ViewArgs),
/// List pull requests.
List(ListArgs),
/// Close a pull request.
Close(CloseArgs),
/// Convert to draft.
Draft(DraftArgs),
/// Reopen pull request.
Reopen(ReopenArgs),
/// Mark as applied.
Apply(ApplyArgs),
/// Merge a pull request.
Merge(MergeArgs),
}
impl CommandRunner for PrSubcommands {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::run_command!(self, options, & New Update View List Close Draft Reopen Apply Merge)
}
}

194
src/cli/commands/pr/new.rs Normal file
View File

@@ -0,0 +1,194 @@
// 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::iter;
use clap::{ArgGroup, Args};
use nostr::{
event::{EventBuilder, Tag, TagKind, TagStandard},
filter::Alphabet,
hashes::sha1::Hash as Sha1Hash,
};
use crate::{
cli::{
CliOptions,
traits::{CommandRunner, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
types::NaddrOrSet,
},
error::N34Result,
nostr_utils::{
NostrClient,
traits::{NaddrsUtils, ReposUtils},
utils,
},
};
#[derive(Args, Debug)]
#[clap(
group(
ArgGroup::new("pr-subject")
.required(true)
),
group(
ArgGroup::new("clone-or-grasp")
.required(true)
)
)]
pub struct NewArgs {
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// The body content of the pull request. Cannot be used together with the
/// `--editor` flag.
#[arg(long, group = "pr-body")]
body: Option<String>,
/// The subject or title of the pull request. Cannot be used together with
/// the `--editor` flag.
#[arg(long, group = "pr-subject")]
subject: Option<String>,
/// Opens the user's default editor to write PR subject and body.
///
/// The first line will be used as the issue subject.
#[arg(short, long, group = "pr-subject", group = "pr-body")]
editor: bool,
/// Labels to associate with the pull request, separated by commas.
#[arg(long, value_delimiter = ',')]
labels: Vec<String>,
/// The branch name for the pull request.
#[arg(long)]
branch: Option<String>,
/// Push the pull request to the repository GRASP server.
#[arg(long, group = "clone-or-grasp")]
grasp: bool,
/// The SHA-1 hash of the commit at the tip of the PR branch.
///
/// You can get it using `git rev-parse <branch-name>`
commit: Sha1Hash,
/// Repositories to clone for the pull request, separated by commas.
#[arg(value_delimiter = ',', group = "clone-or-grasp")]
clones: Vec<nostr::Url>,
}
impl CommandRunner for NewArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
let naddrs = utils::check_empty_naddrs(utils::naddrs_or_file(
self.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;
let naddrs_relays = naddrs.extract_relays();
client.add_relays(&naddrs_relays).await;
let coordinates = naddrs.into_coordinates();
let repos = client.fetch_repos(coordinates.as_slice()).await?;
let maintainers = repos.extract_maintainers();
let repos_relays = repos.extract_relays();
client.add_relays(&repos_relays).await;
let user_pubk = client.pubkey().await?;
let relays_list = client.user_relays_list(user_pubk).await?;
client
.add_relays(&utils::add_read_relays(relays_list.as_ref()))
.await;
let (subject, body) = utils::subject_and_body(self.subject, self.body, ".md")?;
let body_details = if let Some(body) = &body {
Some(client.parse_content(body).await)
} else {
None
};
let mut event_builder = EventBuilder::new(super::PR_KIND, body.unwrap_or_default())
.dedup_tags()
.pow(options.pow.unwrap_or_default())
.tags(
coordinates
.into_iter()
.map(|c| Tag::coordinate(c, repos_relays.first().cloned())),
)
.tags(maintainers.iter().map(|p| Tag::public_key(*p)))
.tags(
body_details
.clone()
.map(|c| c.into_tags())
.unwrap_or_default(),
)
.tag(Tag::from_standardized_without_cell(TagStandard::Subject(
subject,
)))
.tags(self.labels.into_iter().map(Tag::hashtag))
.tag(Tag::custom(
TagKind::single_letter(Alphabet::C, false),
iter::once(self.commit.to_string()),
));
if let Some(euc) = repos.extract_euc() {
event_builder = event_builder.tag(Tag::reference(euc.to_string()))
}
if let Some(branch) = self.branch {
event_builder = event_builder.tag(Tag::custom(
TagKind::custom("branch-name"),
iter::once(branch),
));
}
let event = if self.grasp {
utils::build_grasp_event(&repos, user_pubk, event_builder.clone())?
} else {
// Since `grasp` is false, `clones` must be provided
event_builder = event_builder.tag(Tag::custom(
TagKind::custom("clone"),
self.clones.iter().map(ToString::to_string),
));
event_builder.build(user_pubk)
};
let event_id = event.id.expect("There is an id");
let write_relays = [
relays,
repos_relays,
naddrs_relays,
utils::add_write_relays(relays_list.as_ref()),
// Include read relays for each maintainer (if found)
client.read_relays_from_users(&maintainers).await,
body_details
.map(|c| c.write_relays.into_iter().collect())
.unwrap_or_default(),
]
.concat();
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!("Pull request created: {nevent}");
Ok(())
}
}

View File

@@ -0,0 +1,74 @@
// 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 crate::{
cli::{
CliOptions,
patch::PatchPrStatus,
traits::CommandRunner,
types::{EntityType, NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
};
#[derive(Debug, Args)]
pub struct ReopenArgs {
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// The closed/drafted patch id to reopen it.
pr_id: NostrEvent,
}
impl CommandRunner for ReopenArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::patch_pr_status_command::<{ EntityType::PullRequest as u8 }>(
options,
self.pr_id,
self.naddrs,
PatchPrStatus::Open,
None,
Vec::new(),
|pr_status| {
if pr_status.is_open() {
return Err(N34Error::InvalidStatus(
"You can't open an already open pull request".to_owned(),
));
}
if pr_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"You can't open a merged/applied pull request".to_owned(),
));
}
Ok(())
},
)
.await
}
}

View File

@@ -0,0 +1,178 @@
// 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::iter;
use clap::{ArgGroup, Args};
use nostr::{
event::{EventBuilder, Tag, TagKind, TagStandard, Tags},
filter::{Alphabet, Filter},
hashes::sha1::Hash as Sha1Hash,
};
use crate::{
cli::{
CliOptions,
traits::{CommandRunner, OptionNaddrOrSetVecExt, RelayOrSetVecExt},
types::{NaddrOrSet, NostrEvent},
},
error::{N34Error, N34Result},
nostr_utils::{
NostrClient,
traits::{NaddrsUtils, ReposUtils},
utils,
},
};
#[derive(Args, Debug)]
#[clap(
group(
ArgGroup::new("clone-or-grasp")
.required(true)
)
)]
pub struct UpdateArgs {
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// Original PR ID
#[arg(value_name = "EVENT-ID")]
original_pr: NostrEvent,
/// Push the pull request update to the repository GRASP server.
#[arg(long, group = "clone-or-grasp")]
grasp: bool,
/// The SHA-1 hash of the commit at the tip of the PR branch.
///
/// You can get it using `git rev-parse <branch-name>`
commit: Sha1Hash,
/// Repositories to clone for the pull request, separated by commas.
#[arg(value_delimiter = ',', group = "clone-or-grasp")]
clones: Vec<nostr::Url>,
}
impl CommandRunner for UpdateArgs {
async fn run(self, options: CliOptions) -> N34Result<()> {
let naddrs = utils::check_empty_naddrs(utils::naddrs_or_file(
self.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;
let naddrs_relays = naddrs.extract_relays();
client.add_relays(&naddrs_relays).await;
let coordinates = naddrs.into_coordinates();
let repos = client.fetch_repos(coordinates.as_slice()).await?;
let maintainers = repos.extract_maintainers();
let repos_relays = repos.extract_relays();
client.add_relays(&repos_relays).await;
let user_pubk = client.pubkey().await?;
let relays_list = client.user_relays_list(user_pubk).await?;
client
.add_relays(&utils::add_read_relays(relays_list.as_ref()))
.await;
let Some(orignal_pr) = client
.fetch_event(
Filter::new()
.id(self.original_pr.event_id)
.kind(super::PR_KIND),
)
.await?
else {
tracing::error!("Can't find the original pull request");
return Err(N34Error::EventNotFound);
};
// TODO: Use `CommentTarget` to mention the orignal PR
let mut nip22_orignal_pr = Tags::new();
nip22_orignal_pr.push(Tag::from_standardized_without_cell(TagStandard::Event {
event_id: orignal_pr.id,
relay_url: None,
marker: None,
public_key: Some(orignal_pr.pubkey),
uppercase: true,
}));
nip22_orignal_pr.push(Tag::from_standardized_without_cell(
TagStandard::PublicKey {
public_key: orignal_pr.pubkey,
relay_url: None,
alias: None,
uppercase: true,
},
));
nip22_orignal_pr.push(Tag::from_standardized_without_cell(TagStandard::Kind {
kind: orignal_pr.kind,
uppercase: true,
}));
let mut event_builder = EventBuilder::new(super::PR_UPDATE_KIND, "")
.pow(options.pow.unwrap_or_default())
.tags(nip22_orignal_pr)
.tags(
coordinates
.into_iter()
.map(|c| Tag::coordinate(c, repos_relays.first().cloned())),
)
.tags(maintainers.iter().map(|p| Tag::public_key(*p)))
.tag(Tag::custom(
TagKind::single_letter(Alphabet::C, false),
iter::once(self.commit.to_string()),
));
let event = if self.grasp {
utils::build_grasp_event(&repos, user_pubk, event_builder.clone())?
} else {
// Since `grasp` is false, `clones` must be provided
event_builder = event_builder.tag(Tag::custom(
TagKind::custom("clone"),
self.clones.iter().map(ToString::to_string),
));
event_builder.build(user_pubk)
};
let event_id = event.id.expect("There is an id");
let write_relays = [
relays,
repos_relays,
naddrs_relays,
utils::add_write_relays(relays_list.as_ref()),
// Include read relays for each maintainer (if found)
client.read_relays_from_users(&maintainers).await,
]
.concat();
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!("PR update tip created: {nevent}");
Ok(())
}
}

View File

@@ -0,0 +1,54 @@
// 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 crate::{
cli::{
CliOptions,
traits::CommandRunner,
types::{NaddrOrSet, NostrEvent},
},
error::N34Result,
};
#[derive(Args, Debug)]
pub struct ViewArgs {
/// Repository addresses
///
/// In `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or
/// `_@4rs.nl/n34`), or a set name like `kernel`, separated by commas.
///
/// If omitted, looks for a `nostr-address` file.
#[arg(
value_name = "NADDR-NIP05-OR-SET",
long = "repo",
value_delimiter = ','
)]
naddrs: Option<Vec<NaddrOrSet>>,
/// Pull request ID
#[arg(value_name = "EVENT-ID")]
pr_id: NostrEvent,
}
impl CommandRunner for ViewArgs {
const NEED_SIGNER: bool = false;
async fn run(self, options: CliOptions) -> N34Result<()> {
crate::cli::common_commands::view_pr_issue::<true>(options, self.naddrs, self.pr_id).await
}
}

View File

@@ -19,7 +19,7 @@ use std::{iter, str::FromStr, sync::Arc};
use either::Either;
use futures::future;
use nostr::{
event::{Event, EventBuilder, EventId, Kind, Tag, TagKind},
event::{Event, EventBuilder, EventId, Kind, Tag, TagKind, TagStandard},
filter::{Alphabet, Filter, SingleLetterTag},
hashes::sha1::Hash as Sha1Hash,
nips::{nip10::Marker, nip19::ToBech32},
@@ -31,19 +31,18 @@ use super::{
patch::PatchPrStatus,
types::{NaddrOrSet, NostrEvent},
};
use crate::{
cli::traits::{OptionNaddrOrSetVecExt, RelayOrSetVecExt},
nostr_utils::{
NostrClient,
traits::{NaddrsUtils, TagsExt},
utils,
},
};
use crate::{
cli::{CliOptions, patch::GitPatch},
error::{N34Error, N34Result},
nostr_utils::traits::{GitIssuePrMetadata, GitPatchUtils, ReposUtils},
};
use crate::{
cli::{
traits::{OptionNaddrOrSetVecExt, RelayOrSetVecExt},
types::EntityType,
},
nostr_utils::{NostrClient, traits::NaddrsUtils, utils},
};
/// Updates the issue's status to `new_status` after validating it with
/// `check_fn`.
@@ -127,17 +126,20 @@ pub async fn issue_status_command(
Ok(())
}
/// Updates the patch's status to `new_status` after validating it with
/// `check_fn`.
pub async fn patch_status_command(
/// Updates the patch/pr's status to `new_status` after validating it with
/// `check_fn`. The `ENTITY_TYPE` can only be a pull request or a patch
pub async fn patch_pr_status_command<const ENTITY_TYPE: u8>(
options: CliOptions,
patch_id: NostrEvent,
patch_pr_id: NostrEvent,
naddrs: Option<Vec<NaddrOrSet>>,
new_status: PatchPrStatus,
merge_or_applied_commits: Option<Either<Sha1Hash, Vec<Sha1Hash>>>,
merge_or_applied_patches: Vec<EventId>,
check_fn: impl FnOnce(&PatchPrStatus) -> N34Result<()>,
) -> N34Result<()> {
EntityType::is_pr_or_patch::<ENTITY_TYPE>();
let entity_type = EntityType::from_u8::<ENTITY_TYPE>();
let naddrs = utils::naddrs_or_file(
naddrs.flat_naddrs(&options.config.sets)?,
&utils::nostr_address_path()?,
@@ -146,7 +148,7 @@ pub async fn patch_status_command(
let client = NostrClient::init(&options, &relays).await;
let user_pubk = client.pubkey().await?;
client
.add_relays(&[naddrs.extract_relays(), patch_id.relays].concat())
.add_relays(&[naddrs.extract_relays(), patch_pr_id.relays].concat())
.await;
let owners = naddrs.extract_owners();
@@ -156,9 +158,14 @@ pub async fn patch_status_command(
let relay_hint = repos.extract_relays().first().cloned();
client.add_relays(&repos.extract_relays()).await;
let patch_event = client.fetch_patch(patch_id.event_id).await?;
let event = if entity_type.is_patch() {
client.fetch_patch(patch_pr_id.event_id).await?
} else {
client.fetch_pr(patch_pr_id.event_id).await?
};
let authorized_pubkeys = [maintainers.as_slice(), &[event.pubkey], &owners].concat();
if patch_event.is_revision_patch() && !new_status.is_merged_or_applied() {
if entity_type.is_patch() && event.is_revision_patch() && !new_status.is_merged_or_applied() {
return Err(N34Error::InvalidStatus(
"Invalid action for patch revision. Only 'apply' or 'merge' are allowed, 'open', \
'close', and 'draft' are not supported."
@@ -166,27 +173,31 @@ pub async fn patch_status_command(
));
}
let (root_patch, root_revision) = get_patch_root_revision(&patch_event)?;
let patch_status = client
.fetch_patch_status(
root_patch,
root_revision,
[maintainers.as_slice(), &[patch_event.pubkey], &owners].concat(),
)
.await?;
let (root_patch_or_pr, root_revision) = if entity_type.is_patch() {
get_patch_root_revision(&event)?
} else {
(event.id, None)
};
let current_status = if entity_type.is_patch() {
client
.fetch_patch_status(root_patch_or_pr, root_revision, authorized_pubkeys.clone())
.await?
} else {
client
.fetch_pr_status(event.id, authorized_pubkeys.clone())
.await?
};
check_fn(&patch_status)?;
check_fn(&current_status)?;
let mut status_builder = EventBuilder::new(new_status.kind(), "")
.pow(options.pow.unwrap_or_default())
.tag(utils::event_reply_tag(
&root_patch,
&root_patch_or_pr,
relay_hint.as_ref(),
Marker::Root,
))
.tag(Tag::public_key(patch_event.pubkey))
.tags(maintainers.iter().map(|p| Tag::public_key(*p)))
.tags(owners.iter().map(|p| Tag::public_key(*p)))
.tags(authorized_pubkeys.iter().map(|p| Tag::public_key(*p)))
.tags(
coordinates
.into_iter()
@@ -215,6 +226,7 @@ pub async fn patch_status_command(
.tags(commits.into_iter().map(Tag::reference));
};
// Patch only
if let Some(root_revision) = root_revision {
status_builder = status_builder.tag(utils::event_reply_tag(
&root_revision,
@@ -223,6 +235,7 @@ pub async fn patch_status_command(
));
}
// Patch only
if !merge_or_applied_patches.is_empty() {
status_builder = status_builder.tags(
build_patches_quote(client.clone(), relay_hint.clone(), merge_or_applied_patches)
@@ -232,7 +245,6 @@ pub async fn patch_status_command(
}
let status_event = status_builder.dedup_tags().build(user_pubk);
let event_id = status_event.id.expect("There is an id");
let user_relays_list = client.user_relays_list(user_pubk).await?;
let write_relays = [
@@ -240,10 +252,7 @@ pub async fn patch_status_command(
naddrs.extract_relays(),
repos.extract_relays(),
utils::add_write_relays(user_relays_list.as_ref()),
client.read_relays_from_user(patch_event.pubkey).await,
client
.read_relays_from_users(&[maintainers, owners].concat())
.await,
client.read_relays_from_users(&authorized_pubkeys).await,
]
.concat();
@@ -256,13 +265,14 @@ 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(
/// Fetches and displays pull requests, patches, and issues for specified
/// repositories. The `limit` parameter sets the maximum number of items to
/// retrieve.
///
/// The `ENTITY_TYPE` const is `[EntityType]` enum as u8.
pub async fn list_pr_patches_and_issues<const ENTITY_TYPE: u8>(
options: CliOptions,
naddrs: Option<Vec<NaddrOrSet>>,
list_patches: bool,
limit: usize,
) -> N34Result<()> {
let naddrs = utils::check_empty_naddrs(utils::naddrs_or_file(
@@ -270,6 +280,7 @@ pub async fn list_patches_and_issues(
&utils::nostr_address_path()?,
)?)?;
let entity_type = EntityType::from_u8::<ENTITY_TYPE>();
let relays = options.relays.clone().flat_relays(&options.config.sets)?;
let client = NostrClient::init(&options, &relays).await;
client.add_relays(&naddrs.extract_relays()).await;
@@ -283,18 +294,12 @@ pub async fn list_patches_and_issues(
.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)
.kind(entity_type.kind())
.limit(limit);
if list_patches {
if entity_type.is_patch() {
filter = filter.hashtag("root");
}
@@ -312,23 +317,35 @@ pub async fn list_patches_and_issues(
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)?
let status = match entity_type {
EntityType::PullRequest => {
c.fetch_pr_status(
event.id,
[keys.as_slice(), &[event.pubkey]].concat(),
)
.await
.map(|s| (s.as_str(), s.kind().as_u16()))?
}
EntityType::Patch => {
let (root, root_revision) = get_patch_root_revision(&event)?;
c.fetch_patch_status(
root,
root_revision,
[keys.as_slice(), &[event.pubkey]].concat(),
)
.await
.map(|s| (s.as_str(), s.kind().as_u16()))?
}
EntityType::Issue => {
c.fetch_issue_status(
event.id,
[keys.as_slice(), &[event.pubkey]].concat(),
)
.await
.map(|s| (s.as_str(), s.kind().as_u16()))?
}
};
N34Result::Ok((event, status))
}
}),
@@ -336,11 +353,11 @@ pub async fn list_patches_and_issues(
.await
.into_iter()
.filter_map(|r| r.ok()),
|(_, status)| status.as_ref().either_into::<Kind>(),
|(_, (_, k))| *k,
);
let lines = events
.map(|(event, status)| format_patch_and_issue(&event, status))
.map(|(event, (status, _))| format_entity::<ENTITY_TYPE>(&event, status))
.collect::<Vec<String>>();
let max_width = lines
@@ -370,33 +387,40 @@ fn get_patch_root_revision(patch_event: &Event) -> N34Result<(EventId, Option<Ev
}
}
/// 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_event_labels();
let subject = event.extract_event_subject();
/// Formats patch, issue or PR. For patches, extracts the
/// subject line from the Git patch format. For issues and PRs, combines the
/// subject with labels. The output includes status and formatted ID.
fn format_entity<const ENTITY_TYPE: u8>(event: &Event, status: &str) -> String {
let entity_type = EntityType::from_u8::<ENTITY_TYPE>();
if labels.is_empty() {
subject.to_owned()
} else {
format!(r#""{subject}" {labels}"#)
let subject = match entity_type {
EntityType::Patch => {
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()
})
}
_ => {
// Issues and PRs
let labels = event.extract_event_labels();
let subject = event.extract_event_subject();
if labels.is_empty() {
subject.to_owned()
} else {
format!(r#""{subject}" {labels}"#)
}
}
};
format!(
"({status}) {}\nID: {}\n",
utils::smart_wrap(&subject, 85),
@@ -455,14 +479,8 @@ pub async fn view_pr_issue<const IS_PR: bool>(
client.add_relays(&naddrs.extract_relays()).await;
client.add_relays(&event_id.relays).await;
client
.add_relays(
&client
.fetch_repos(&naddrs.into_coordinates())
.await?
.extract_relays(),
)
.await;
let repos = client.fetch_repos(&naddrs.into_coordinates()).await?;
client.add_relays(&repos.extract_relays()).await;
let event = client
.fetch_event(Filter::new().id(event_id.event_id).kind(
@@ -475,7 +493,27 @@ pub async fn view_pr_issue<const IS_PR: bool>(
},
))
.await?
.ok_or(N34Error::CanNotFoundIssue)?;
.ok_or(
const {
if IS_PR {
N34Error::CanNotFoundPr
} else {
N34Error::CanNotFoundIssue
}
},
)?;
let authorized_pubkeys = [repos.extract_maintainers().as_slice(), &[event.pubkey]].concat();
let status = if IS_PR {
client
.fetch_pr_status(event.id, authorized_pubkeys)
.await?
.to_string()
} else {
client
.fetch_issue_status(event.id, authorized_pubkeys)
.await?
.to_string()
};
let event_subject = utils::smart_wrap(event.extract_event_subject(), 70);
let event_author = client.get_username(event.pubkey).await;
@@ -516,10 +554,12 @@ pub async fn view_pr_issue<const IS_PR: bool>(
.as_ref()
.unwrap_or(&event)
.tags
.map_tag(TagKind::Clone, |t| {
.iter()
.filter_map(|t| t.as_standardized())
.find_map(|t| {
match t {
nostr::event::TagStandard::GitClone(urls) => urls.clone(),
_ => unreachable!(),
TagStandard::GitClone(urls) if !urls.is_empty() => Some(urls.clone()),
_ => None,
}
})
.unwrap_or_default();
@@ -533,7 +573,7 @@ pub async fn view_pr_issue<const IS_PR: bool>(
};
println!(
"{event_subject} - [by {event_author}]\n{event_labels}{}{pr_data}",
"({status}) {event_subject} - [by {event_author}]\n{event_labels}{}{pr_data}",
utils::smart_wrap(&event.content, 80)
);
Ok(())

View File

@@ -32,6 +32,8 @@ pub mod parsers;
pub mod traits;
/// Common helper types used throughout the CLI.
pub mod types;
/// CLI utils
pub mod utils;
use clap::Parser;

View File

@@ -53,6 +53,18 @@ pub enum RelayOrSet {
Set(String),
}
/// Enum representing the type of entity to handle in common commands.
#[derive(Debug, Copy, Clone)]
#[repr(u8)]
pub enum EntityType {
/// Pull Request
PullRequest,
/// Patch
Patch,
/// Issue
Issue,
}
/// Parses and represents a Nostr `nevent1` or `note1`.
#[derive(Debug, Clone)]
pub struct NostrEvent {
@@ -78,6 +90,62 @@ impl AuthUrlHandler for EchoAuthUrl {
}
}
impl EntityType {
/// Returns true if the entity is a pull request.
#[inline]
pub const fn is_pr(&self) -> bool {
matches!(self, Self::PullRequest)
}
/// Returns true if the entity is a patch.
#[inline]
pub const fn is_patch(&self) -> bool {
matches!(self, Self::Patch)
}
/// Returns true if the entity is an issue.
#[inline]
pub const fn is_issue(&self) -> bool {
matches!(self, Self::Issue)
}
/// Returns the kind of the entity
#[inline]
pub const fn kind(&self) -> Kind {
match self {
Self::PullRequest => crate::cli::pr::PR_KIND,
Self::Patch => Kind::GitPatch,
Self::Issue => Kind::GitIssue,
}
}
/// Converts a [`u8`] value to the corresponding enum variant.
#[inline]
pub const fn from_u8<const NUM: u8>() -> Self {
const {
match NUM {
val if val == Self::PullRequest as u8 => Self::PullRequest,
val if val == Self::Patch as u8 => Self::Patch,
val if val == Self::Issue as u8 => Self::Issue,
_ => {
panic!("No enum with the given numeric value")
}
}
}
}
/// Ensures the entity is either a pull request or a patch. Compilation
/// will fail if the entity is neither.
pub const fn is_pr_or_patch<const ENTITY_TYPE: u8>() {
const {
let entity = EntityType::from_u8::<ENTITY_TYPE>();
if !entity.is_pr() && !entity.is_patch() {
panic!("The entity should be a pull request or a patch")
}
}
}
}
impl NaddrOrSet {
/// Returns the naddr if `Naddr` or try to get the relays from the set.
/// Returns error if the set naddrs are empty or the set not found.

45
src/cli/utils.rs Normal file
View File

@@ -0,0 +1,45 @@
// 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::io::{self, Write};
/// Displays the given prompt and reads a line of input from the user.
pub fn read_line(prompt: &str) -> io::Result<String> {
{
let mut stdout = io::stdout().lock();
write!(&mut stdout, "{prompt}: ")?;
_ = stdout.flush();
}
let mut user_input = String::new();
io::stdin().read_line(&mut user_input)?;
Ok(user_input.trim().to_owned())
}
/// Prompts the user with a message and repeatedly asks until they enter a valid
/// boolean response. Recognizes "yes", "y", "true" for `true` and "no", "n",
/// "false" for `false`.
pub fn prompt_bool(prompt: &str) -> io::Result<bool> {
loop {
let user_input = read_line(prompt)?.to_ascii_lowercase();
match user_input.as_str() {
"yes" | "y" | "true" => return Ok(true),
"no" | "n" | "false" => return Ok(false),
_ => continue,
}
}
}