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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
83
src/cli/commands/pr/apply.rs
Normal file
83
src/cli/commands/pr/apply.rs
Normal 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
|
||||
}
|
||||
}
|
||||
73
src/cli/commands/pr/close.rs
Normal file
73
src/cli/commands/pr/close.rs
Normal 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
|
||||
}
|
||||
}
|
||||
80
src/cli/commands/pr/draft.rs
Normal file
80
src/cli/commands/pr/draft.rs
Normal 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
|
||||
}
|
||||
}
|
||||
58
src/cli/commands/pr/list.rs
Normal file
58
src/cli/commands/pr/list.rs
Normal 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
|
||||
}
|
||||
}
|
||||
83
src/cli/commands/pr/merge.rs
Normal file
83
src/cli/commands/pr/merge.rs
Normal 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
|
||||
}
|
||||
}
|
||||
84
src/cli/commands/pr/mod.rs
Normal file
84
src/cli/commands/pr/mod.rs
Normal 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
194
src/cli/commands/pr/new.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
74
src/cli/commands/pr/reopen.rs
Normal file
74
src/cli/commands/pr/reopen.rs
Normal 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
|
||||
}
|
||||
}
|
||||
178
src/cli/commands/pr/update.rs
Normal file
178
src/cli/commands/pr/update.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
54
src/cli/commands/pr/view.rs
Normal file
54
src/cli/commands/pr/view.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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(¤t_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(())
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
45
src/cli/utils.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/error.rs
16
src/error.rs
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user