From dd05e050cdd9018f58d2742a3b09913951dcc6ee Mon Sep 17 00:00:00 2001 From: Awiteb Date: Wed, 13 Aug 2025 16:33:15 +0000 Subject: [PATCH] feat: New command `repo state` Signed-off-by: Awiteb --- README.md | 2 +- docs/SUMMARY.md | 1 + docs/index.html | 6 +- docs/repo/state.md | 29 +++++++ src/cli/commands/repo/mod.rs | 7 +- src/cli/commands/repo/state.rs | 149 +++++++++++++++++++++++++++++++++ src/cli/parsers.rs | 20 +++++ 7 files changed, 209 insertions(+), 5 deletions(-) create mode 100644 docs/repo/state.md create mode 100644 src/cli/commands/repo/state.rs diff --git a/README.md b/README.md index c160bdd..205d6c8 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Check the documentation at [n34.dev] ## Features - [X] Repository announcements -- [ ] Repository state announcements +- [X] Repository state announcements - [X] Patches (Send, fetch and list) - [X] Issues (Send, view and list) - [X] Replies diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 78a8509..d5d757d 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -15,6 +15,7 @@ - [Manage Repositories](repo/README.md) - [Broadcast and Update a Git Repository](repo/announce.md) - [View Git Repository Details](repo/view.md) + - [Repository State Announcements](repo/state.md) - [Issue Management](issue/README.md) - [Create an Issue](issue/new.md) - [View an Issue By ID](issue/view.md) diff --git a/docs/index.html b/docs/index.html index 52f726e..c3646b3 100644 --- a/docs/index.html +++ b/docs/index.html @@ -355,8 +355,8 @@ Repository announcements
  • - - Repository state announcements + + Repository state announcements
  • @@ -400,7 +400,7 @@
  • - Signing using NIP-07 proxy (nostr-browser-signer-proxy) + Signing using NIP-07 proxy (nostr-browser-signer-proxy)
  • diff --git a/docs/repo/state.md b/docs/repo/state.md new file mode 100644 index 0000000..da8732f --- /dev/null +++ b/docs/repo/state.md @@ -0,0 +1,29 @@ +# Repository State Announcements + +> `n34 repo state` command + +**Usage:** +``` +Repository state announcements + +Usage: n34 repo state [OPTIONS] + +Arguments: + Name of the repository's primary branch, such as 'master' or 'main' + +Options: + --repo Repository address in `naddr` format (`naddr1...`), NIP-05 format (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel` + --tags Tags to announce a state for, in the format `=`. Separated by comma + --branches Branches to announce a state for, in the format `=`. Separated by comma +``` + +This command allows you to announce your repository state, which is useful for +pushing to permissionless git repositories like the [GRASP] relay. The relay +will verify your repository state and permit pushing commits only if they match +the announced state. + +To get the commit ID that a branch or tag points to, use `git rev-parse +`. You can automate this process by creating a script to +generate the required input for this command. + +[GRASP]: https://ngit.dev/grasp diff --git a/src/cli/commands/repo/mod.rs b/src/cli/commands/repo/mod.rs index 7ac8d7c..7389a00 100644 --- a/src/cli/commands/repo/mod.rs +++ b/src/cli/commands/repo/mod.rs @@ -16,12 +16,15 @@ /// `repo announce` subcommand mod announce; +/// `repo state` subcommand +mod state; /// `repo view` subcommand mod view; use clap::Subcommand; use self::announce::AnnounceArgs; +use self::state::StateArgs; use self::view::ViewArgs; use super::{CliOptions, CommandRunner}; use crate::error::N34Result; @@ -32,10 +35,12 @@ pub enum RepoSubcommands { View(ViewArgs), /// Broadcast and update a git repository Announce(AnnounceArgs), + /// Repository state announcements + State(StateArgs), } impl CommandRunner for RepoSubcommands { async fn run(self, options: CliOptions) -> N34Result<()> { - crate::run_command!(self, options, & View Announce) + crate::run_command!(self, options, & View Announce State) } } diff --git a/src/cli/commands/repo/state.rs b/src/cli/commands/repo/state.rs new file mode 100644 index 0000000..64d5922 --- /dev/null +++ b/src/cli/commands/repo/state.rs @@ -0,0 +1,149 @@ +// 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::borrow::Cow; + +use clap::Args; +use nostr::event::{Kind, Tag, TagKind}; +use nostr::{event::EventBuilder, hashes::sha1::Hash as Sha1Hash}; + +use crate::nostr_utils::traits::ReposUtils; +use crate::{ + cli::{ + CliOptions, + CommandRunner, + parsers, + traits::{OptionNaddrOrSetVecExt, RelayOrSetVecExt}, + types::NaddrOrSet, + }, + error::N34Result, + nostr_utils::{NostrClient, traits::NaddrsUtils, utils}, +}; + +/// Prefix for branch references in Git. +const HEADS_REFS: &str = "refs/heads/"; + +/// Prefix for tag references in Git. +const TAGS_REFS: &str = "refs/tags/"; + +/// Repository state announcements kind +const REPO_STATE_KIND: Kind = Kind::Custom(30618); + +/// `HEAD` tag kind +const HEAD_TAG_KIND: TagKind = TagKind::Custom(Cow::Borrowed("HEAD")); + +/// Arguments for the `repo state` command +#[derive(Args, Debug)] +pub struct StateArgs { + /// Repository address in `naddr` format (`naddr1...`), NIP-05 format + /// (`4rs.nl/n34` or `_@4rs.nl/n34`), or a set name like `kernel`. + /// + /// If omitted, looks for a `nostr-address` file. + #[arg(value_name = "NADDR-NIP05-OR-SET", long = "repo")] + naddrs: Option>, + /// Tags to announce a state for, in the format `=`. + /// Separated by comma. + /// + /// Example: `v0.4.0=9aa3b62de02a63aa6a0d49efa7c484aa550cef56`. + #[arg(long, value_delimiter = ',', value_parser = parsers::name_and_sha1)] + tags: Vec<(String, Sha1Hash)>, + /// Branches to announce a state for, in the format + /// `=`. Separated by comma. + /// + /// Example: `master=9aa3b62de02a63aa6a0d49efa7c484aa550cef56`. + #[arg(long, value_delimiter = ',', value_parser = parsers::name_and_sha1)] + branches: Vec<(String, Sha1Hash)>, + /// Name of the repository's primary branch, such as 'master' or 'main'. + head: String, +} + +impl CommandRunner for StateArgs { + 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 user_pubk = client.pubkey().await?; + client.add_relays(&naddrs.extract_relays()).await; + + let repos = client + .fetch_repos(&naddrs.clone().into_coordinates()) + .await?; + let repos_id = repos + .first() + .cloned() + .expect("It's not empty, checked above") + .id; + + let mut event_builder = EventBuilder::new(REPO_STATE_KIND, "") + .dedup_tags() + .pow(options.pow.unwrap_or_default()) + .tag(Tag::identifier(&repos_id)) + .tag(Tag::custom( + HEAD_TAG_KIND, + &[format!("ref: {HEADS_REFS}{}", self.head)], + )); + + if !self.branches.is_empty() { + event_builder = event_builder.tags(refs_tags::(self.branches)); + } + + if !self.tags.is_empty() { + event_builder = event_builder.tags(refs_tags::(self.tags)); + } + + let event = event_builder.build(user_pubk); + let event_id = event.id.expect("There is an id"); + let user_relays_list = client.user_relays_list(user_pubk).await?; + let write_relays = [ + relays, + naddrs.extract_relays(), + repos.extract_relays(), + utils::add_write_relays(user_relays_list.as_ref()), + // Include read relays for each maintainer (if found) + client + .read_relays_from_users(&repos.extract_maintainers()) + .await, + ] + .concat(); + + tracing::trace!(relays = ?write_relays, "Write relays list"); + let success = client + .send_event_to(event, user_relays_list.as_ref(), &write_relays) + .await?; + + let nevent = utils::new_nevent(event_id, &success)?; + let naddr = utils::repo_naddr(repos_id, user_pubk, &success)?; + println!("Event created: {nevent}"); + println!("State address: {naddr}"); + + Ok(()) + } +} + +/// Build the refs tags +#[inline] +fn refs_tags(refs: Vec<(String, Sha1Hash)>) -> impl IntoIterator { + refs.into_iter().map(|(tag, commit)| { + Tag::parse(&[ + format!("{}{tag}", if IS_HEADS { HEADS_REFS } else { TAGS_REFS }), + commit.to_string(), + ]) + .expect("Not an empty tag") + }) +} diff --git a/src/cli/parsers.rs b/src/cli/parsers.rs index aba0950..02743ee 100644 --- a/src/cli/parsers.rs +++ b/src/cli/parsers.rs @@ -22,6 +22,7 @@ use std::{ use nostr::{ Kind, + hashes::sha1::Hash as Sha1Hash, nips::{ nip19::{FromBech32, Nip19Coordinate, ToBech32}, nip46::NostrConnectURI, @@ -84,6 +85,25 @@ pub fn parse_bunker_url(bunker_url: &str) -> N34Result { } } +/// Parses a string in the format `string=sha1` into a name and SHA1 hash. +/// The input must contain exactly one `=` character. If the format is invalid +/// or the SHA1 hash is incorrect, an error is returned. +pub fn name_and_sha1(value: &str) -> Result<(String, Sha1Hash), String> { + if value.chars().filter(|c| *c == '=').count() != 1 { + return Err("the syntax is `string=SHA1-commit-id`".to_owned()); + } + + let (name, sha1_hash) = value.split_once('=').expect("There is one `=`"); + + Ok(( + name.trim_matches(['"', '\'']).to_owned(), + sha1_hash + .trim_matches(['"', '\'']) + .parse() + .map_err(|_| "Invalid SHA1 commit id".to_owned())?, + )) +} + /// Serializes a set of NIP-19 coordinates as a list of bech32 strings. pub fn ser_naddrs(naddr: &HashSet, serializer: S) -> Result where