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,
}
}
}

View File

@@ -123,9 +123,14 @@ pub enum N34Error {
)]
CanNotFoundIssue,
#[error(
"Patch not found, make sure it is in the relays and make sure that the ID is an patch ID"
"Patch not found, make sure it is in the relays and make sure that the ID is a patch ID"
)]
CanNotFoundPatch,
#[error(
"Pull request not found, make sure it is in the relays and make sure that the ID is an \
pull request ID"
)]
CanNotFoundPr,
#[error(r#"The given patch id is not a root patch. It must contains `["t", "root"]` tag"#)]
NotRootPatch,
#[error("This status kind can't be set for an issue: {0}")]
@@ -134,7 +139,7 @@ pub enum N34Error {
InvalidPatchStatus(Kind),
#[error("Can't find the root patch of the given patch-revision")]
RevisionRootNotFound,
#[error("Invalid status for the issue/patch: {0}")]
#[error("Invalid status: {0}")]
InvalidStatus(String),
#[error("Not valid bunker URL")]
NotBunkerUrl,
@@ -143,6 +148,13 @@ pub enum N34Error {
keyring is enabled to store it"
)]
SecretKeyKeyringWithoutEntry,
#[error(
"Your push was not accepted by any GRASP server. Try creating a personal fork, pushing to \
your own GRASP servers, and then make a pull request with your GRASP repos as a clone"
)]
PushRejectedByGraspServers,
#[error("The repository doesn't contains any GRASP servers")]
NoGraspServer,
}
impl N34Error {

View File

@@ -41,6 +41,7 @@ use traits::TokenUtils;
use crate::{
cli::{CliOptions, issue::IssueStatus, patch::PatchPrStatus},
error::{N34Error, N34Result},
nostr_utils::traits::KindExt,
};
/// Timeout duration for the client.
@@ -258,6 +259,13 @@ impl NostrClient {
.ok_or(N34Error::CanNotFoundPatch)
}
/// Fetch the pull request by the given id. None if not found
pub async fn fetch_pr(&self, pr_id: EventId) -> N34Result<Event> {
self.fetch_event(Filter::new().id(pr_id).kind(crate::cli::pr::PR_KIND))
.await?
.ok_or(N34Error::CanNotFoundPr)
}
/// Returns the username for a given public key. If no username is found,
/// falls back to a shortened version of the public key.
pub async fn get_username(&self, user: PublicKey) -> String {
@@ -298,6 +306,27 @@ impl NostrClient {
.unwrap_or_else(|| Ok(IssueStatus::Open))
}
/// Get the latest status of PR by its ID, only considering status
/// events from authorized_pubkeys. If no valid status event is found,
/// defaults to Open.
pub async fn fetch_pr_status(
&self,
pr_id: EventId,
authorized_pubkeys: Vec<PublicKey>,
) -> N34Result<PatchPrStatus> {
self.fetch_events(
Filter::new()
.event(pr_id)
.kinds(PatchPrStatus::all_kinds())
.authors(utils::dedup(authorized_pubkeys.into_iter())),
)
.await?
.into_iter()
.max_by_key(|e| e.created_at)
.map(|status| PatchPrStatus::try_from(status.kind))
.unwrap_or_else(|| Ok(PatchPrStatus::Open))
}
/// Gets the status of a patch. If it's a revision patch, checks if it's
/// closed when the root patch is already merged/applied but doesn't
/// reference this revision. Defaults to Open status if no status event
@@ -355,17 +384,17 @@ impl NostrClient {
.collect())
}
/// Finds the root issue or patch for a given event. If the event is already
/// a root (issue/patch), returns it directly. For comments, follows
/// parent/root references until finding the root or failing. Returns
/// None if no root can be found.
/// Finds the root issue, PR or patch for a given event. If the event is
/// already a root (issue/patch/PR), returns it directly. For comments,
/// follows parent/root references until finding the root or failing.
/// Returns None if no root can be found.
pub async fn find_root(&self, mut event: Event) -> N34Result<Option<Event>> {
if !matches!(event.kind, Kind::GitIssue | Kind::GitPatch | Kind::Comment) {
if !event.kind.can_reply_to() {
return Err(N34Error::CanNotReplyToEvent);
}
loop {
if matches!(event.kind, Kind::GitIssue | Kind::GitPatch) {
if event.kind.is_root_kind() {
return Ok(Some(event));
}
@@ -375,7 +404,7 @@ impl NostrClient {
self.add_relay_hint(relay_hint.cloned()).await;
let root_event = self.fetch_event(Filter::new().id(*id)).await?;
if let Some(ref root_event) = root_event
&& !matches!(root_event.kind, Kind::GitIssue | Kind::GitPatch)
&& !root_event.kind.is_root_kind()
{
return Err(N34Error::CanNotReplyToEvent);
}
@@ -390,7 +419,7 @@ impl NostrClient {
}
}
// Break if: no root/parent tags found, parent/root event fetch failed
// Break if: no root/parent tags found
break;
}

View File

@@ -14,8 +14,11 @@
// 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::ops::Deref;
use convert_case::{Case, Casing};
use nostr::hashes::sha1::Hash as Sha1Hash;
use nostr::nips::nip19::FromBech32;
use nostr::{
event::{Event, EventBuilder, EventId, Kind, Tag, TagKind, TagStandard, Tags},
key::PublicKey,
@@ -228,6 +231,20 @@ impl Vec<GitRepositoryAnnouncement> {
self.iter().flat_map(|n| n.relays.clone()).collect()
}
/// Extract the grasp servers.
#[inline]
pub fn extract_grasp_servers(&self) -> Vec<&Url> {
self.iter()
.flat_map(RepoUtils::extract_grasp_servers)
.collect()
}
/// Extract all clones
#[inline]
pub fn extract_clones(&self) -> Vec<&Url> {
self.iter().flat_map(|r| r.clone.deref()).collect()
}
/// Extract all the maintainers from these repositories
#[inline]
pub fn extract_maintainers(&self) -> Vec<PublicKey> {
@@ -241,6 +258,57 @@ impl Vec<GitRepositoryAnnouncement> {
}
}
#[easy_ext::ext(RepoUtils)]
impl GitRepositoryAnnouncement {
/// Retrieves the GRASP servers from the list of clones based on the relay's
/// GRASP check.
pub fn extract_grasp_servers(&self) -> Vec<&Url> {
self.clone
.iter()
.filter(|clone| self.relays.is_grasp(clone))
.collect()
}
}
#[easy_ext::ext(RelayUrlsUtils)]
impl Vec<RelayUrl> {
/// Checks if the provided clone URL is a GRASP clone URL and is present in
/// the relays.
pub fn is_grasp(&self, clone_url: &Url) -> bool {
clone_url.is_grasp_clone()
&& self.iter().any(|relay| {
clone_url
.as_str()
.trim_start_matches("https://")
.trim_start_matches("http://")
.starts_with(
relay
.as_str_without_trailing_slash()
.trim_start_matches("wss://")
.trim_start_matches("ws://"),
)
})
}
}
#[easy_ext::ext(IsGraspClone)]
impl Url {
/// Checks if the URL is a GRASP clone URL. A GRASP clone URL is identified
/// by having a `<string>.git` suffix in the last part and a valid public
/// key in the second last part.
pub fn is_grasp_clone(&self) -> bool {
let mut parts = self.as_str().split('/').rev().take(2);
parts
.next()
.and_then(|r| (r.len() > 4 && r.ends_with(".git")).then_some(r))
.is_some()
&& parts
.next()
.and_then(|p| PublicKey::from_bech32(p).ok())
.is_some()
}
}
/// Utility functions for working with patch events
#[easy_ext::ext(GitPatchUtils)]
impl Event {
@@ -318,3 +386,24 @@ impl nostr_keyring::Error {
matches!(self, nostr_keyring::Error::Keyring(KeyringError::NoEntry))
}
}
#[easy_ext::ext(KindExt)]
impl Kind {
/// Checks if the kind is a root kind, such as GitIssue, GitPatch, GitPr or
/// GitPrUpdate.
pub fn is_root_kind(&self) -> bool {
match self {
val if val == &crate::cli::pr::PR_KIND || val == &crate::cli::pr::PR_UPDATE_KIND => {
true
}
Kind::GitIssue | Kind::GitPatch => true,
_ => false,
}
}
/// Returns true if this kind supports replies, such as Comment, GitIssue,
/// GitPatch, GitPr, or GitPrUpdate.
pub fn can_reply_to(&self) -> bool {
self == &Kind::Comment || self.is_root_kind()
}
}

View File

@@ -17,13 +17,14 @@
use std::{
fmt,
fs,
iter,
path::{Path, PathBuf},
str::FromStr,
sync::atomic::Ordering,
};
use nostr::{
event::{Event, EventId, Kind, Tag, TagKind, TagStandard},
event::{Event, EventBuilder, EventId, Kind, Tag, TagKind, TagStandard, UnsignedEvent},
filter::Alphabet,
key::PublicKey,
nips::{
@@ -38,8 +39,9 @@ use nostr::{
use super::traits::TagsExt;
use crate::{
cli::{NOSTR_ADDRESS_FILE, parsers},
cli::{NOSTR_ADDRESS_FILE, parsers, utils as cli_utils},
error::{N34Error, N34Result},
nostr_utils::traits::ReposUtils,
};
/// Returns the value of the given tag
@@ -351,3 +353,48 @@ where
.collect::<Vec<String>>()
.join("\n")
}
/// Creates a grasp event by extracting grasp servers from the provided
/// repositories. If no grasp servers are found, an error is returned. If one or
/// more are found, the user is prompted to push to each server until one is
/// successful. The successful event is returned, or an error if no push
/// succeeds.
pub fn build_grasp_event(
repos: &Vec<GitRepositoryAnnouncement>,
author_pubkey: PublicKey,
event_builder: EventBuilder,
) -> N34Result<UnsignedEvent> {
let mut event = None;
let grasp_repos = repos.extract_grasp_servers();
tracing::debug!("GRASP repos: {grasp_repos:?}");
if grasp_repos.is_empty() {
tracing::trace!(
"Repository clones: {:?}",
dedup(repos.extract_clones().iter())
);
return Err(N34Error::NoGraspServer);
}
for grasp_repo in grasp_repos {
let mut grasp_event = event_builder
.clone()
.tag(Tag::custom(
TagKind::custom("clone"),
iter::once(grasp_repo.to_string()),
))
.build(author_pubkey);
println!(
"Attempt to push your changes to `refs/nostr/{}` in the repository: {}",
grasp_event.id(),
grasp_repo
);
if cli_utils::prompt_bool("Was the push successful? (yes/no)")? {
event = Some(grasp_event);
break;
}
}
event.ok_or(N34Error::PushRejectedByGraspServers)
}