use std::{ collections::{HashMap, HashSet}, fs::File, io::BufReader, str::FromStr, }; use anyhow::{bail, Context, Result}; use console::Style; use nostr::{nips::nip01::Coordinate, FromBech32, PublicKey, Tag, TagStandard, ToBech32}; use nostr_sdk::{Kind, NostrSigner, Timestamp}; use serde::{Deserialize, Serialize}; #[cfg(not(test))] use crate::client::Client; use crate::{ cli_interactor::{Interactor, InteractorPrompt, PromptInputParms}, client::{get_event_from_global_cache, get_events_from_cache, sign_event, Connect}, git::{Repo, RepoActions}, }; #[derive(Default)] pub struct RepoRef { pub name: String, pub description: String, pub identifier: String, pub root_commit: String, pub git_server: Vec, pub web: Vec, pub relays: Vec, pub maintainers: Vec, pub events: HashMap, // code languages and hashtags } impl TryFrom for RepoRef { type Error = anyhow::Error; fn try_from(event: nostr::Event) -> Result { if !event.kind.as_u16().eq(&REPO_REF_KIND) { bail!("incorrect kind"); } let mut r = Self::default(); if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("d")) { r.identifier = t.as_vec()[1].clone(); } if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("name")) { r.name = t.as_vec()[1].clone(); } if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("description")) { r.description = t.as_vec()[1].clone(); } if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("clone")) { r.git_server = t.clone().to_vec(); r.git_server.remove(0); } if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("web")) { r.web = t.clone().to_vec(); r.web.remove(0); } if let Some(t) = event.tags.iter().find(|t| { t.as_vec()[0].eq("r") && t.as_vec()[1].len().eq(&40) && git2::Oid::from_str(t.as_vec()[1].as_str()).is_ok() }) { r.root_commit = t.as_vec()[1].clone(); } if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("relays")) { r.relays = t.clone().to_vec(); r.relays.remove(0); } if let Some(t) = event.tags.iter().find(|t| t.as_vec()[0].eq("maintainers")) { let mut maintainers = t.clone().to_vec(); maintainers.remove(0); if !maintainers.contains(&event.pubkey.to_string()) { r.maintainers.push(event.pubkey); } for pk in maintainers { r.maintainers.push( nostr_sdk::prelude::PublicKey::from_str(&pk) .context(format!("cannot convert entry from maintainers tag {pk} into a valid nostr public key. it should be in hex format")) .context("invalid repository event")?, ); } } else { r.maintainers = vec![event.pubkey]; } r.events = HashMap::new(); r.events.insert( Coordinate { kind: event.kind, identifier: event.identifier().unwrap().to_string(), public_key: event.author(), relays: vec![], }, event, ); Ok(r) } } pub static REPO_REF_KIND: u16 = 30_617; impl RepoRef { pub async fn to_event(&self, signer: &NostrSigner) -> Result { sign_event( nostr_sdk::EventBuilder::new( nostr::event::Kind::Custom(REPO_REF_KIND), "", [ vec![ Tag::identifier(if self.identifier.to_string().is_empty() { // fiatjaf thought a random string. its not in the draft nip. // thread_rng() // .sample_iter(&Alphanumeric) // .take(15) // .map(char::from) // .collect() // an identifier based on first commit is better so that users dont // accidentally create two seperate identifiers for the same repo // there is a hesitancy to use the commit id // in another conversaion with fiatjaf he suggested the first 6 // character of the commit id // here we are using 7 which is the standard for shorthand commit id self.root_commit.to_string()[..7].to_string() } else { self.identifier.to_string() }), Tag::custom( nostr::TagKind::Custom(std::borrow::Cow::Borrowed("r")), vec![self.root_commit.to_string(), "euc".to_string()], ), Tag::from_standardized(TagStandard::Name(self.name.clone())), Tag::from_standardized(TagStandard::Description(self.description.clone())), Tag::custom( nostr::TagKind::Custom(std::borrow::Cow::Borrowed("clone")), self.git_server.clone(), ), Tag::custom( nostr::TagKind::Custom(std::borrow::Cow::Borrowed("web")), self.web.clone(), ), Tag::custom( nostr::TagKind::Custom(std::borrow::Cow::Borrowed("relays")), self.relays.clone(), ), Tag::custom( nostr::TagKind::Custom(std::borrow::Cow::Borrowed("maintainers")), self.maintainers .iter() .map(std::string::ToString::to_string) .collect::>(), ), Tag::custom( nostr::TagKind::Custom(std::borrow::Cow::Borrowed("alt")), vec![format!("git repository: {}", self.name.clone())], ), ], // code languages and hashtags ] .concat(), ), signer, ) .await .context("failed to create repository reference event") } /// coordinates without relay hints pub fn coordinates(&self) -> HashSet { let mut res = HashSet::new(); for m in &self.maintainers { res.insert(Coordinate { kind: Kind::Custom(REPO_REF_KIND), public_key: *m, identifier: self.identifier.clone(), relays: vec![], }); } res } /// coordinates without relay hints pub fn coordinate_with_hint(&self) -> Coordinate { Coordinate { kind: Kind::Custom(REPO_REF_KIND), public_key: *self .maintainers .first() .context("no maintainers in repo ref") .unwrap(), identifier: self.identifier.clone(), relays: if let Some(relay) = self.relays.first() { vec![relay.to_string()] } else { vec![] }, } } /// coordinates without relay hints pub fn coordinates_with_timestamps(&self) -> Vec<(Coordinate, Option)> { self.coordinates() .iter() .map(|c| (c.clone(), self.events.get(c).map(|e| e.created_at))) .collect::)>>() } } pub async fn get_repo_coordinates( git_repo: &Repo, #[cfg(test)] client: &crate::client::MockConnect, #[cfg(not(test))] client: &Client, ) -> Result> { try_and_get_repo_coordinates(git_repo, client, true).await } pub async fn try_and_get_repo_coordinates( git_repo: &Repo, #[cfg(test)] client: &crate::client::MockConnect, #[cfg(not(test))] client: &Client, prompt_user: bool, ) -> Result> { let mut repo_coordinates = HashSet::new(); if let Some(repo_override) = git_repo.get_git_config_item("nostr.repo", Some(false))? { for s in repo_override.split(',') { if let Ok(c) = Coordinate::parse(s) { repo_coordinates.insert(c); } } } // TODO: when nostr remotes functionality is added, iterate on each remote and // extract coordinates if repo_coordinates.is_empty() { if let Ok(repo_config) = get_repo_config_from_yaml(git_repo) { let maintainers = { let mut maintainers = HashSet::new(); for m in &repo_config.maintainers { if let Ok(maintainer) = PublicKey::parse(m) { maintainers.insert(maintainer); } } maintainers }; if let Some(identifier) = repo_config.identifier { for public_key in maintainers { repo_coordinates.insert(Coordinate { kind: Kind::Custom(REPO_REF_KIND), public_key, identifier: identifier.clone(), relays: vec![], }); } } else { // if repo_config.identifier.is_empty() { // this will only apply for a few repositories created before ngit v1.3 // that haven't updated their maintainers.yaml if let Ok(Some(current_user_npub)) = git_repo.get_git_config_item("nostr.npub", None) { if let Ok(current_user) = PublicKey::parse(current_user_npub) { for m in &repo_config.maintainers { if let Ok(maintainer) = PublicKey::parse(m) { if current_user.eq(&maintainer) { println!( "please run `ngit init` to add the repo identifier to maintainers.yaml" ); } } } } } // look find all repo refs with root_commit. for identifier let filter = nostr::Filter::default() .kind(nostr::Kind::Custom(REPO_REF_KIND)) .reference(git_repo.get_root_commit()?.to_string()) .authors(maintainers.clone()); let mut events = get_events_from_cache(git_repo.get_path()?, vec![filter.clone()]).await?; if events.is_empty() { events = get_event_from_global_cache(git_repo.get_path()?, vec![filter.clone()]) .await?; } if events.is_empty() { println!( "finding repository events for this repository for npubs in maintainers.yaml" ); events = client .get_events(client.get_fallback_relays().clone(), vec![filter.clone()]) .await?; } if let Some(e) = events.first() { if let Some(identifier) = e.identifier() { for m in &repo_config.maintainers { if let Ok(maintainer) = PublicKey::parse(m) { repo_coordinates.insert(Coordinate { kind: Kind::Custom(REPO_REF_KIND), public_key: maintainer, identifier: identifier.to_string(), relays: vec![], }); } } } } else { let c = ask_for_naddr()?; git_repo.save_git_config_item("nostr.repo", &c.to_bech32()?, false)?; repo_coordinates.insert(c); } } } } if repo_coordinates.is_empty() { if !prompt_user { bail!("couldn't find repo coordinates in git config nostr.repo or in maintainers.yaml"); } // TODO: present list of events filter by root_commit // TODO: fallback to search based on identifier let c = ask_for_naddr()?; // PROBLEM: we are saving this before checking whether it actually exists, which // means next time the user won't be prompted and may not know how to // change the selected repo git_repo.save_git_config_item("nostr.repo", &c.to_bech32()?, false)?; repo_coordinates.insert(c); } Ok(repo_coordinates) } fn ask_for_naddr() -> Result { let dim = Style::new().color256(247); println!( "{}", dim.apply_to("hint: https://gitworkshop.dev/repos lists repositories and their naddr"), ); Ok(loop { if let Ok(c) = Coordinate::parse( Interactor::default() .input(PromptInputParms::default().with_prompt("repository naddr"))?, ) { break c; } println!("not a valid naddr"); }) } #[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)] pub struct RepoConfigYaml { pub identifier: Option, pub maintainers: Vec, pub relays: Vec, } pub fn get_repo_config_from_yaml(git_repo: &Repo) -> Result { let path = git_repo.get_path()?.join("maintainers.yaml"); let file = File::open(path) .context("should open maintainers.yaml if it exists") .context("maintainers.yaml doesnt exist")?; let reader = BufReader::new(file); let repo_config_yaml: RepoConfigYaml = serde_yaml::from_reader(reader) .context("should read maintainers.yaml with serde_yaml") .context("maintainers.yaml incorrectly formatted")?; Ok(repo_config_yaml) } pub fn extract_pks(pk_strings: Vec) -> Result> { let mut pks: Vec = vec![]; for s in pk_strings { pks.push( nostr_sdk::prelude::PublicKey::from_bech32(s.clone()) .context(format!("cannot convert {s} into a valid nostr public key"))?, ); } Ok(pks) } pub fn save_repo_config_to_yaml( git_repo: &Repo, identifier: String, maintainers: Vec, relays: Vec, ) -> Result<()> { let path = git_repo.get_path()?.join("maintainers.yaml"); let file = if path.exists() { std::fs::OpenOptions::new() .create(true) .write(true) .truncate(true) .open(path) .context("cannot open maintainers.yaml file with write and truncate options")? } else { std::fs::File::create(path).context("cannot create maintainers.yaml file")? }; let mut maintainers_npubs = vec![]; for m in maintainers { maintainers_npubs.push( m.to_bech32() .context("cannot convert public key into npub")?, ); } serde_yaml::to_writer( file, &RepoConfigYaml { identifier: Some(identifier), maintainers: maintainers_npubs, relays, }, ) .context("cannot write maintainers to maintainers.yaml file serde_yaml") } #[cfg(test)] mod tests { use test_utils::*; use super::*; async fn create() -> nostr::Event { RepoRef { identifier: "123412341".to_string(), name: "test name".to_string(), description: "test description".to_string(), root_commit: "5e664e5a7845cd1373c79f580ca4fe29ab5b34d2".to_string(), git_server: vec!["https://localhost:1000".to_string()], web: vec![ "https://exampleproject.xyz".to_string(), "https://gitworkshop.dev/123".to_string(), ], relays: vec!["ws://relay1.io".to_string(), "ws://relay2.io".to_string()], maintainers: vec![TEST_KEY_1_KEYS.public_key(), TEST_KEY_2_KEYS.public_key()], events: HashMap::new(), } .to_event(&TEST_KEY_1_SIGNER) .await .unwrap() } mod try_from { use super::*; #[tokio::test] async fn identifier() { assert_eq!( RepoRef::try_from(create().await).unwrap().identifier, "123412341", ) } #[tokio::test] async fn name() { assert_eq!(RepoRef::try_from(create().await).unwrap().name, "test name",) } #[tokio::test] async fn description() { assert_eq!( RepoRef::try_from(create().await).unwrap().description, "test description", ) } #[tokio::test] async fn root_commit_is_r_tag() { assert_eq!( RepoRef::try_from(create().await).unwrap().root_commit, "5e664e5a7845cd1373c79f580ca4fe29ab5b34d2", ) } mod root_commit_is_empty_if_no_r_tag_which_is_sha1_format { use nostr::JsonUtil; use super::*; async fn create_with_incorrect_first_commit_ref(s: &str) -> nostr::Event { nostr::Event::from_json( create() .await .as_json() .replace("5e664e5a7845cd1373c79f580ca4fe29ab5b34d2", s), ) .unwrap() } #[tokio::test] async fn less_than_40_characters() { let s = "5e664e5a7845cd1373"; assert_eq!( RepoRef::try_from(create_with_incorrect_first_commit_ref(s).await) .unwrap() .root_commit, "", ) } #[tokio::test] async fn more_than_40_characters() { let s = "5e664e5a7845cd1373c79f580ca4fe29ab5b34d2111111111"; assert_eq!( RepoRef::try_from(create_with_incorrect_first_commit_ref(s).await) .unwrap() .root_commit, "", ) } #[tokio::test] async fn not_hex_characters() { let s = "xxx64e5a7845cd1373c79f580ca4fe29ab5b34d2"; assert_eq!( RepoRef::try_from(create_with_incorrect_first_commit_ref(s).await) .unwrap() .root_commit, "", ) } } #[tokio::test] async fn git_server() { assert_eq!( RepoRef::try_from(create().await).unwrap().git_server, vec!["https://localhost:1000"], ) } #[tokio::test] async fn web() { assert_eq!( RepoRef::try_from(create().await).unwrap().web, vec![ "https://exampleproject.xyz".to_string(), "https://gitworkshop.dev/123".to_string() ], ) } #[tokio::test] async fn relays() { assert_eq!( RepoRef::try_from(create().await).unwrap().relays, vec!["ws://relay1.io".to_string(), "ws://relay2.io".to_string()], ) } #[tokio::test] async fn maintainers() { assert_eq!( RepoRef::try_from(create().await).unwrap().maintainers, vec![TEST_KEY_1_KEYS.public_key(), TEST_KEY_2_KEYS.public_key()], ) } } mod to_event { use super::*; mod tags { use super::*; #[tokio::test] async fn identifier() { assert!( create() .await .tags .iter() .any(|t| t.as_vec()[0].eq("d") && t.as_vec()[1].eq("123412341")) ) } #[tokio::test] async fn name() { assert!( create() .await .tags .iter() .any(|t| t.as_vec()[0].eq("name") && t.as_vec()[1].eq("test name")) ) } #[tokio::test] async fn alt() { assert!( create().await.tags.iter().any(|t| t.as_vec()[0].eq("alt") && t.as_vec()[1].eq("git repository: test name")) ) } #[tokio::test] async fn description() { assert!(create().await.tags.iter().any( |t| t.as_vec()[0].eq("description") && t.as_vec()[1].eq("test description") )) } #[tokio::test] async fn root_commit_as_reference() { assert!(create().await.tags.iter().any(|t| t.as_vec()[0].eq("r") && t.as_vec()[1].eq("5e664e5a7845cd1373c79f580ca4fe29ab5b34d2"))) } #[tokio::test] async fn git_server() { assert!(create().await.tags.iter().any( |t| t.as_vec()[0].eq("clone") && t.as_vec()[1].eq("https://localhost:1000") )) } #[tokio::test] async fn relays() { let event = create().await; let relays_tag: &nostr::Tag = event .tags .iter() .find(|t| t.as_vec()[0].eq("relays")) .unwrap(); assert_eq!(relays_tag.as_vec().len(), 3); assert_eq!(relays_tag.as_vec()[1], "ws://relay1.io"); assert_eq!(relays_tag.as_vec()[2], "ws://relay2.io"); } #[tokio::test] async fn web() { let event = create().await; let web_tag: &nostr::Tag = event.tags.iter().find(|t| t.as_vec()[0].eq("web")).unwrap(); assert_eq!(web_tag.as_vec().len(), 3); assert_eq!(web_tag.as_vec()[1], "https://exampleproject.xyz"); assert_eq!(web_tag.as_vec()[2], "https://gitworkshop.dev/123"); } #[tokio::test] async fn maintainers() { let event = create().await; let maintainers_tag: &nostr::Tag = event .tags .iter() .find(|t| t.as_vec()[0].eq("maintainers")) .unwrap(); assert_eq!(maintainers_tag.as_vec().len(), 3); assert_eq!( maintainers_tag.as_vec()[1], TEST_KEY_1_KEYS.public_key().to_string() ); assert_eq!( maintainers_tag.as_vec()[2], TEST_KEY_2_KEYS.public_key().to_string() ); } #[tokio::test] async fn no_other_tags() { assert_eq!(create().await.tags.len(), 9) } } } }