From 4360aa192ccb384af5169599f0e32f794f7d8b7d Mon Sep 17 00:00:00 2001 From: Awiteb Date: Fri, 12 Sep 2025 06:43:23 +0000 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + README.md | 2 +- docs/SUMMARY.md | 12 +- docs/commands.md | 12 ++ docs/index.html | 4 +- docs/patch/apply.md | 2 +- docs/patch/reopen.md | 2 +- docs/pr/README.md | 43 ++++++ docs/pr/apply.md | 24 +++ docs/pr/close.md | 18 +++ docs/pr/draft.md | 17 +++ docs/pr/list.md | 17 +++ docs/pr/merge.md | 22 +++ docs/pr/new.md | 30 ++++ docs/pr/reopen.md | 17 +++ docs/pr/update.md | 25 ++++ docs/pr/view.md | 18 +++ src/cli/commands/issue/list.rs | 15 +- src/cli/commands/mod.rs | 10 +- src/cli/commands/patch/apply.rs | 8 +- src/cli/commands/patch/close.rs | 4 +- src/cli/commands/patch/draft.rs | 6 +- src/cli/commands/patch/list.rs | 15 +- src/cli/commands/patch/merge.rs | 8 +- src/cli/commands/patch/reopen.rs | 4 +- src/cli/commands/pr/apply.rs | 83 +++++++++++ src/cli/commands/pr/close.rs | 73 +++++++++ src/cli/commands/pr/draft.rs | 80 ++++++++++ src/cli/commands/pr/list.rs | 58 ++++++++ src/cli/commands/pr/merge.rs | 83 +++++++++++ src/cli/commands/pr/mod.rs | 84 +++++++++++ src/cli/commands/pr/new.rs | 194 ++++++++++++++++++++++++ src/cli/commands/pr/reopen.rs | 74 ++++++++++ src/cli/commands/pr/update.rs | 178 ++++++++++++++++++++++ src/cli/commands/pr/view.rs | 54 +++++++ src/cli/common_commands.rs | 244 ++++++++++++++++++------------- src/cli/mod.rs | 2 + src/cli/types.rs | 68 +++++++++ src/cli/utils.rs | 45 ++++++ src/error.rs | 16 +- src/nostr_utils/mod.rs | 45 +++++- src/nostr_utils/traits.rs | 89 +++++++++++ src/nostr_utils/utils.rs | 51 ++++++- 43 files changed, 1714 insertions(+), 143 deletions(-) create mode 100644 docs/pr/README.md create mode 100644 docs/pr/apply.md create mode 100644 docs/pr/close.md create mode 100644 docs/pr/draft.md create mode 100644 docs/pr/list.md create mode 100644 docs/pr/merge.md create mode 100644 docs/pr/new.md create mode 100644 docs/pr/reopen.md create mode 100644 docs/pr/update.md create mode 100644 docs/pr/view.md create mode 100644 src/cli/commands/pr/apply.rs create mode 100644 src/cli/commands/pr/close.rs create mode 100644 src/cli/commands/pr/draft.rs create mode 100644 src/cli/commands/pr/list.rs create mode 100644 src/cli/commands/pr/merge.rs create mode 100644 src/cli/commands/pr/mod.rs create mode 100644 src/cli/commands/pr/new.rs create mode 100644 src/cli/commands/pr/reopen.rs create mode 100644 src/cli/commands/pr/update.rs create mode 100644 src/cli/commands/pr/view.rs create mode 100644 src/cli/utils.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 80b898b..5b8d0f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - New command `repo state` - by Awiteb +- Support pull requests - by Awiteb ### Breaking Change diff --git a/README.md b/README.md index 205d6c8..13168dd 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Check the documentation at [n34.dev] - [X] Issues (Send, view and list) - [X] Replies - [X] Issues and patches status -- [ ] Pull requests ([nostr-protocol/nips#1966]) +- [X] Pull requests ([nostr-protocol/nips#1966]) - [X] Gossip Model ([NIP-65]) - [X] Proof of Work ([NIP-13]) - [X] `nostr:` URI scheme, in the issue/reply content ([NIP-21]) diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index d5d757d..523c09b 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -16,6 +16,7 @@ - [Broadcast and Update a Git Repository](repo/announce.md) - [View Git Repository Details](repo/view.md) - [Repository State Announcements](repo/state.md) + - [Reply to Issues and Patches](reply.md) - [Issue Management](issue/README.md) - [Create an Issue](issue/new.md) - [View an Issue By ID](issue/view.md) @@ -32,4 +33,13 @@ - [Apply an Open Patch](patch/apply.md) - [Merge an Open Patch](patch/merge.md) - [List Repositories Patches](patch/list.md) - - [Reply to Issues and Patches](reply.md) + - [Pull Request Management](pr/README.md) + - [Create a Pull Request](pr/new.md) + - [Update a Pull Request](pr/update.md) + - [View a Pull Request](pr/view.md) + - [List Pull Requests](pr/list.md) + - [Close a Pull Request](pr/close.md) + - [Convert to Draft](pr/draft.md) + - [Reopen a Pull Request](pr/reopen.md) + - [Mark as Applied](pr/apply.md) + - [Merge a Pull Request](pr/merge.md) diff --git a/docs/commands.md b/docs/commands.md index 1adce82..725a1c3 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,5 +1,17 @@ # Command-Line Usage +## Philosophy + +`n34` emphasizes simplicity by exclusively handling Nostr tasks, requiring +users to manage Git operations themselves. For instance, it won't merge or +rebase patches into a branch but will prompt you to specify where to write them, +allowing you to merge them using `git-am`. Similarly, when sending patches, it +won't ask for the commits but will request the patch files instead. + +This approach enhances simplicity and freedom, giving you full control over +your workflow. Additionally, it simplifies software maintenance by avoiding +unnecessary complexity. + ## Options The `n34` command-line tool accepts the following options: diff --git a/docs/index.html b/docs/index.html index c3646b3..4782336 100644 --- a/docs/index.html +++ b/docs/index.html @@ -375,8 +375,8 @@ Issues and patches status
  • - - Pull requests (nostr-protocol/nips#1966) + + Pull requests (nostr-protocol/nips#1966)
  • diff --git a/docs/patch/apply.md b/docs/patch/apply.md index d1fb7e6..d594583 100644 --- a/docs/patch/apply.md +++ b/docs/patch/apply.md @@ -10,7 +10,7 @@ Usage: n34 patch apply [OPTIONS] [APPLIED_COMMITS]... Arguments: The open patch id to apply it. Must be orignal root patch or revision root - [APPLIED_COMMITS]... The applied commits + ... The applied commits Options: --repo Repository addresses diff --git a/docs/patch/reopen.md b/docs/patch/reopen.md index 7e0119f..cc24b10 100644 --- a/docs/patch/reopen.md +++ b/docs/patch/reopen.md @@ -15,6 +15,6 @@ Options: --repo Repository addresses ``` -Issue a kind `1632` (Close status) for the specified patch. The patch have to +Issue a kind `1630` (Open status) for the specified patch. The patch have to be closed or drafted. diff --git a/docs/pr/README.md b/docs/pr/README.md new file mode 100644 index 0000000..86c63ef --- /dev/null +++ b/docs/pr/README.md @@ -0,0 +1,43 @@ +# Pull Request Management + +Pull requests are a new addition to [NIP-34], proposed by [DanConwayDev] in +[nostr-protocol/nips#1966]. The concept is straightforward: it involves an event +that notifies you of changes on a Git server, which can be any Git server. This +event contains two key pieces of information, the Git repository URL and the +commit tip. You can add this remote to your project, check out the specified +commit, and then review or merge/rebase the changes as needed. Additionally, +there is a `PR-update` event that informs you when the previous commit tip is no +longer the head of the changes, such as when new changes are added or a rebase +occurs. + +## Why Use the Commit Tip? + +Using the commit tip is preferable to relying on a branch name. A branch name +simply points to a commit, so checking out a branch is effectively the same +as checking out a specific commit. However, if we use the branch name instead +of the commit, the changes can be altered by anyone, not just the pull request +author. By checking out a specific commit ID, we ensure that the changes are +exactly as intended by the pull request author and have not been tampered with +by a malicious party. + +## Does `n34` Handle Git Operations? + +No, `n34` does not perform any Git operations. You can use the `n34 pr view` +command to retrieve the pull request clone URL and the commit tip, allowing +you to fetch the changes manually. Similarly, `n34 pr new` and `n34 pr update` +do not interact with Git directly. They only prompt you for the clone URL and +commit tip to construct the event. For more details on the philosophy behind +`n34`, refer to the [Philosophy] section. + +## Why use pull requests instead of patches? + +Pull requests are useful for large changes. Most relays limit events to 60KB, +so if your changes exceed that limit (per event, not in total), consider using +pull requests. For [GRASP] servers, the limit is usually higher. Before using pull +requests, check the relays, patches are more decentralized. + +[NIP-34]: https://github.com/nostr-protocol/nips/blob/master/34.md +[DanConwayDev]: https://danconwaydev.com +[nostr-protocol/nips#1966]: https://github.com/nostr-protocol/nips/pull/1966 +[Philosophy]: https://n34.dev/commands.html#philosophy +[GRASP]: https://ngit.dev/grasp diff --git a/docs/pr/apply.md b/docs/pr/apply.md new file mode 100644 index 0000000..af61467 --- /dev/null +++ b/docs/pr/apply.md @@ -0,0 +1,24 @@ +# Mark as Applied + +> `n34 pr apply` command + +**Usage:** +``` +Usage: n34 pr apply [OPTIONS] ... + +Arguments: + The open PR id to apply it + ... The applied commits + +Options: + --repo Repository addresses +``` + +Creates a kind `1631` event (Applied/Merged status) for the specified PR. The PR +must be in open status. + +The `APPLIED_COMMITS` field serves to inform clients about the status of +specific commits, whether they have been applied or not. If you need to retrieve +the list of commits from a specific point (such as the tip of the master branch) +up to the `HEAD`, you can use the following Git command: `git log --pretty=%H +'origin/master..HEAD'`. diff --git a/docs/pr/close.md b/docs/pr/close.md new file mode 100644 index 0000000..b7c5c54 --- /dev/null +++ b/docs/pr/close.md @@ -0,0 +1,18 @@ +# Close a Pull Request + +> `n34 pr close` command + +**Usage:** +``` +Usage: n34 pr close [OPTIONS] + +Arguments: + The open/draft PR id to close it + +Options: + --repo Repository addresses +``` + +Issue a kind `1632` (Close status) for the specified PR. The PR have to be open +or drafted. + diff --git a/docs/pr/draft.md b/docs/pr/draft.md new file mode 100644 index 0000000..b5ee0d0 --- /dev/null +++ b/docs/pr/draft.md @@ -0,0 +1,17 @@ +# Convert to Draft + +> `n34 pr draft` command + +**Usage:** +``` +Usage: n34 pr draft [OPTIONS] + +Arguments: + The open PR id to draft it + +Options: + --repo Repository addresses +``` + +Issue a kind `1633` (Draft status) for the specified PR. The PR have to +be open. diff --git a/docs/pr/list.md b/docs/pr/list.md new file mode 100644 index 0000000..be0b0d8 --- /dev/null +++ b/docs/pr/list.md @@ -0,0 +1,17 @@ +# List Pull Requests + +> `n34 pr list` command + +**Usage:** +``` +Usage: n34 pr list [OPTIONS] [NADDR-NIP05-OR-SET]... + +Arguments: + [NADDR-NIP05-OR-SET]... Repository addresses + +Options: + --limit Maximum number of patches to list [default: 15] +``` + +List the repositories pull requests. By default `n34` will look for +`nostr-address` file and extract the repositories from it. diff --git a/docs/pr/merge.md b/docs/pr/merge.md new file mode 100644 index 0000000..e7d81cf --- /dev/null +++ b/docs/pr/merge.md @@ -0,0 +1,22 @@ +# Merge a Pull Request + +> `n34 pr merge` command + +**Usage:** +``` +Usage: n34 pr merge [OPTIONS] + +Arguments: + The open PR id to merge it + The merge commit id + +Options: + --repo Repository addresses +``` + +Creates a kind `1631` event (Applied/Merged status) for the specified PR. The +PR must be in open status. + +You can get the `MERGE_COMMIT` commit using `git rev-parse HEAD` command if +the merge commit in the `HEAD` or use `HEAD~n` where the `n` is the number of +commits the merge commit before the HEAD diff --git a/docs/pr/new.md b/docs/pr/new.md new file mode 100644 index 0000000..3ed1c68 --- /dev/null +++ b/docs/pr/new.md @@ -0,0 +1,30 @@ +# Create a Pull Request + +> `n34 pr new` command + +**Usage:** +``` +Usage: n34 pr new [OPTIONS] <--subject |--editor> ... + +Arguments: + The SHA-1 hash of the commit at the tip of the PR branch + ... Repositories to clone for the pull request, separated by commas + +Options: + --repo Repository addresses + --body The body content of the pull request. Cannot be used together with the `--editor` flag + --subject The subject or title of the pull request. Cannot be used together with the `--editor` flag + -e, --editor Opens the user's default editor to write PR subject and body + --labels Labels to associate with the pull request, separated by commas + --branch The branch name for the pull request + --grasp Push the pull request to the repository GRASP server +``` + +Submit a pull request to the repositories specified using the `--repo` option or +obtained from the `nostr-address` file. + +Utilize the `--grasp` option if you intend to send the pull request to the +[GRASP] servers. Note that `n34` will not automatically send the pull request +for you but will indicate where to push your changes. + +[GRASP]: https://ngit.dev/grasp diff --git a/docs/pr/reopen.md b/docs/pr/reopen.md new file mode 100644 index 0000000..d952804 --- /dev/null +++ b/docs/pr/reopen.md @@ -0,0 +1,17 @@ +# Reopen a Pull Request + +> `n34 pr reopen` command + +**Usage:** +``` +Usage: n34 pr reopen [OPTIONS] + +Arguments: + The closed/drafted patch id to reopen it + +Options: + --repo Repository addresses +``` + +Issue a kind `1630` (Open status) for the specified PR. The PR have to +be closed or drafted. diff --git a/docs/pr/update.md b/docs/pr/update.md new file mode 100644 index 0000000..a32acfc --- /dev/null +++ b/docs/pr/update.md @@ -0,0 +1,25 @@ +# Update a Pull Request + +> `n34 pr update` command + +**Usage:** +``` +Usage: n34 pr update [OPTIONS] ... + +Arguments: + Original PR ID + The SHA-1 hash of the commit at the tip of the PR branch + ... Repositories to clone for the pull request, separated by commas + +Options: + --repo Repository addresses + --grasp Push the pull request update to the repository GRASP server +``` + +Update an existing pull request with the new changes. + +Utilize the `--grasp` option if you intend to send the pull request to the +[GRASP] servers. Note that `n34` will not automatically send the pull request +for you but will indicate where to push your changes. + +[GRASP]: https://ngit.dev/grasp diff --git a/docs/pr/view.md b/docs/pr/view.md new file mode 100644 index 0000000..210f846 --- /dev/null +++ b/docs/pr/view.md @@ -0,0 +1,18 @@ +# View a Pull Request + +> `n34 pr view` command + +**Usage:** +``` +Usage: n34 pr view [OPTIONS] + +Arguments: + Pull request ID + +Options: + --repo Repository addresses +``` + +View a specific pull request. This includes the pull request title, labels, +description, clone URLs, and the latest commit tip. Use this information to +fetch the changes. diff --git a/src/cli/commands/issue/list.rs b/src/cli/commands/issue/list.rs index 0a67e2d..9bf2f54 100644 --- a/src/cli/commands/issue/list.rs +++ b/src/cli/commands/issue/list.rs @@ -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 } } diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index 44445c1..b4e0e91 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -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) } } diff --git a/src/cli/commands/patch/apply.rs b/src/cli/commands/patch/apply.rs index b105539..f0ca7da 100644 --- a/src/cli/commands/patch/apply.rs +++ b/src/cli/commands/patch/apply.rs @@ -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(()) }, diff --git a/src/cli/commands/patch/close.rs b/src/cli/commands/patch/close.rs index ca90256..b7b45a1 100644 --- a/src/cli/commands/patch/close.rs +++ b/src/cli/commands/patch/close.rs @@ -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, diff --git a/src/cli/commands/patch/draft.rs b/src/cli/commands/patch/draft.rs index 1d85ffb..22d465a 100644 --- a/src/cli/commands/patch/draft.rs +++ b/src/cli/commands/patch/draft.rs @@ -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(), )); } diff --git a/src/cli/commands/patch/list.rs b/src/cli/commands/patch/list.rs index efccac3..9f38efa 100644 --- a/src/cli/commands/patch/list.rs +++ b/src/cli/commands/patch/list.rs @@ -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 } } diff --git a/src/cli/commands/patch/merge.rs b/src/cli/commands/patch/merge.rs index 489d905..15a4602 100644 --- a/src/cli/commands/patch/merge.rs +++ b/src/cli/commands/patch/merge.rs @@ -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(), )); } diff --git a/src/cli/commands/patch/reopen.rs b/src/cli/commands/patch/reopen.rs index 890323b..b0a03c5 100644 --- a/src/cli/commands/patch/reopen.rs +++ b/src/cli/commands/patch/reopen.rs @@ -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, diff --git a/src/cli/commands/pr/apply.rs b/src/cli/commands/pr/apply.rs new file mode 100644 index 0000000..6dfe35f --- /dev/null +++ b/src/cli/commands/pr/apply.rs @@ -0,0 +1,83 @@ +// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr +// Copyright (C) 2025 Awiteb +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use 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>, + /// The open PR id to apply it. + pr_id: NostrEvent, + /// The applied commits. + #[arg(required = true)] + applied_commits: Vec, +} + +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 + } +} diff --git a/src/cli/commands/pr/close.rs b/src/cli/commands/pr/close.rs new file mode 100644 index 0000000..4dcfb4b --- /dev/null +++ b/src/cli/commands/pr/close.rs @@ -0,0 +1,73 @@ +// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr +// Copyright (C) 2025 Awiteb +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use 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>, + /// 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 + } +} diff --git a/src/cli/commands/pr/draft.rs b/src/cli/commands/pr/draft.rs new file mode 100644 index 0000000..fc5a653 --- /dev/null +++ b/src/cli/commands/pr/draft.rs @@ -0,0 +1,80 @@ +// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr +// Copyright (C) 2025 Awiteb +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use 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>, + /// 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 + } +} diff --git a/src/cli/commands/pr/list.rs b/src/cli/commands/pr/list.rs new file mode 100644 index 0000000..77cd91c --- /dev/null +++ b/src/cli/commands/pr/list.rs @@ -0,0 +1,58 @@ +// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr +// Copyright (C) 2025 Awiteb +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use std::num::NonZeroUsize; + +use clap::Args; + +use crate::{ + cli::{ + CliOptions, + common_commands, + traits::CommandRunner, + types::{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>, + /// 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 + } +} diff --git a/src/cli/commands/pr/merge.rs b/src/cli/commands/pr/merge.rs new file mode 100644 index 0000000..8cab8db --- /dev/null +++ b/src/cli/commands/pr/merge.rs @@ -0,0 +1,83 @@ +// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr +// Copyright (C) 2025 Awiteb +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use 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>, + /// 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 + } +} diff --git a/src/cli/commands/pr/mod.rs b/src/cli/commands/pr/mod.rs new file mode 100644 index 0000000..381b70f --- /dev/null +++ b/src/cli/commands/pr/mod.rs @@ -0,0 +1,84 @@ +// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr +// Copyright (C) 2025 Awiteb +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +/// `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) + } +} diff --git a/src/cli/commands/pr/new.rs b/src/cli/commands/pr/new.rs new file mode 100644 index 0000000..3fdbdea --- /dev/null +++ b/src/cli/commands/pr/new.rs @@ -0,0 +1,194 @@ +// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr +// Copyright (C) 2025 Awiteb +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use std::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>, + /// The body content of the pull request. Cannot be used together with the + /// `--editor` flag. + #[arg(long, group = "pr-body")] + body: Option, + /// The subject or title of the pull request. Cannot be used together with + /// the `--editor` flag. + #[arg(long, group = "pr-subject")] + subject: Option, + /// 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, + /// The branch name for the pull request. + #[arg(long)] + branch: Option, + /// 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 ` + commit: Sha1Hash, + /// Repositories to clone for the pull request, separated by commas. + #[arg(value_delimiter = ',', group = "clone-or-grasp")] + clones: Vec, +} + +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(()) + } +} diff --git a/src/cli/commands/pr/reopen.rs b/src/cli/commands/pr/reopen.rs new file mode 100644 index 0000000..e52d5d2 --- /dev/null +++ b/src/cli/commands/pr/reopen.rs @@ -0,0 +1,74 @@ +// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr +// Copyright (C) 2025 Awiteb +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use 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>, + /// 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 + } +} diff --git a/src/cli/commands/pr/update.rs b/src/cli/commands/pr/update.rs new file mode 100644 index 0000000..21ef3d0 --- /dev/null +++ b/src/cli/commands/pr/update.rs @@ -0,0 +1,178 @@ +// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr +// Copyright (C) 2025 Awiteb +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use std::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>, + /// 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 ` + commit: Sha1Hash, + /// Repositories to clone for the pull request, separated by commas. + #[arg(value_delimiter = ',', group = "clone-or-grasp")] + clones: Vec, +} + +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(()) + } +} diff --git a/src/cli/commands/pr/view.rs b/src/cli/commands/pr/view.rs new file mode 100644 index 0000000..399bf20 --- /dev/null +++ b/src/cli/commands/pr/view.rs @@ -0,0 +1,54 @@ +// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr +// Copyright (C) 2025 Awiteb +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use 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>, + /// 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::(options, self.naddrs, self.pr_id).await + } +} diff --git a/src/cli/common_commands.rs b/src/cli/common_commands.rs index baa30cc..9a4f069 100644 --- a/src/cli/common_commands.rs +++ b/src/cli/common_commands.rs @@ -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( options: CliOptions, - patch_id: NostrEvent, + patch_pr_id: NostrEvent, naddrs: Option>, new_status: PatchPrStatus, merge_or_applied_commits: Option>>, merge_or_applied_patches: Vec, check_fn: impl FnOnce(&PatchPrStatus) -> N34Result<()>, ) -> N34Result<()> { + EntityType::is_pr_or_patch::(); + let entity_type = EntityType::from_u8::(); + 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( options: CliOptions, naddrs: Option>, - 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::(); 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::(), + |(_, (_, k))| *k, ); let lines = events - .map(|(event, status)| format_patch_and_issue(&event, status)) + .map(|(event, (status, _))| format_entity::(&event, status)) .collect::>(); let max_width = lines @@ -370,33 +387,40 @@ fn get_patch_root_revision(patch_event: &Event) -> N34Result<(EventId, Option) -> 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(event: &Event, status: &str) -> String { + let entity_type = EntityType::from_u8::(); - 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( 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( }, )) .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( .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( }; 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(()) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 735915a..b930c99 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -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; diff --git a/src/cli/types.rs b/src/cli/types.rs index 68c6a37..320054b 100644 --- a/src/cli/types.rs +++ b/src/cli/types.rs @@ -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() -> 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 { + let entity = EntityType::from_u8::(); + 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. diff --git a/src/cli/utils.rs b/src/cli/utils.rs new file mode 100644 index 0000000..a608d0e --- /dev/null +++ b/src/cli/utils.rs @@ -0,0 +1,45 @@ +// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr +// Copyright (C) 2025 Awiteb +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use std::io::{self, Write}; + +/// Displays the given prompt and reads a line of input from the user. +pub fn read_line(prompt: &str) -> io::Result { + { + 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 { + 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, + } + } +} diff --git a/src/error.rs b/src/error.rs index 61169b6..cd53e6f 100644 --- a/src/error.rs +++ b/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 { diff --git a/src/nostr_utils/mod.rs b/src/nostr_utils/mod.rs index e94eeeb..905b4c3 100644 --- a/src/nostr_utils/mod.rs +++ b/src/nostr_utils/mod.rs @@ -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 { + 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, + ) -> N34Result { + 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> { - 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; } diff --git a/src/nostr_utils/traits.rs b/src/nostr_utils/traits.rs index 11b464c..3f88b59 100644 --- a/src/nostr_utils/traits.rs +++ b/src/nostr_utils/traits.rs @@ -14,8 +14,11 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +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 { 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 { @@ -241,6 +258,57 @@ impl Vec { } } +#[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 { + /// 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 `.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() + } +} diff --git a/src/nostr_utils/utils.rs b/src/nostr_utils/utils.rs index bddbf74..2f3b6d9 100644 --- a/src/nostr_utils/utils.rs +++ b/src/nostr_utils/utils.rs @@ -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::>() .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, + author_pubkey: PublicKey, + event_builder: EventBuilder, +) -> N34Result { + 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) +}