feat: New command repo state

Signed-off-by: Awiteb <a@4rs.nl>
This commit is contained in:
Awiteb
2025-08-13 16:33:15 +00:00
parent 9aa3b62de0
commit dd05e050cd
7 changed files with 209 additions and 5 deletions

View File

@@ -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)
}
}

View File

@@ -0,0 +1,149 @@
// 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::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<Vec<NaddrOrSet>>,
/// Tags to announce a state for, in the format `<tag-name>=<commit-id>`.
/// 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
/// `<branch-name>=<commit-id>`. 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::<true>(self.branches));
}
if !self.tags.is_empty() {
event_builder = event_builder.tags(refs_tags::<false>(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<const IS_HEADS: bool>(refs: Vec<(String, Sha1Hash)>) -> impl IntoIterator<Item = Tag> {
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")
})
}

View File

@@ -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<NostrConnectURI> {
}
}
/// 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<S>(naddr: &HashSet<Nip19Coordinate>, serializer: S) -> Result<S::Ok, S::Error>
where