1mod identity;
47mod repo_metadata;
48
49use crate::runtime::block_on_result;
50use anyhow::{Context, Result};
51use futures::{SinkExt, StreamExt};
52use hashtree_blossom::BlossomClient;
53use hashtree_core::{decode_tree_node, decrypt_chk, Cid, LinkType, TreeNode};
54use nostr_sdk::prelude::*;
55use serde::Deserialize;
56use std::collections::HashMap;
57use std::time::Duration;
58use tokio_tungstenite::{connect_async, tungstenite::Message as WsMessage};
59use tracing::{debug, info, warn};
60
61pub use identity::{
62 load_key_lists, load_keys, resolve_identity, resolve_self_identity, StoredKey, StoredKeyLists,
63};
64use repo_metadata::{
65 append_repo_discovery_labels, build_git_repo_list_filter, build_repo_announcement_filter,
66 build_repo_event_filter, extract_repo_announcement_euc, latest_repo_announcement_created_at,
67 latest_repo_event_created_at, latest_trusted_pr_status_kinds, list_git_repo_announcements,
68 next_replaceable_created_at, pick_latest_event, pick_latest_repo_event,
69 validate_repo_publish_relays,
70};
71
72pub const KIND_APP_DATA: u16 = 30078;
74
75pub const KIND_PULL_REQUEST: u16 = 1618;
77pub const KIND_STATUS_OPEN: u16 = 1630;
78pub const KIND_STATUS_APPLIED: u16 = 1631;
79pub const KIND_STATUS_CLOSED: u16 = 1632;
80pub const KIND_STATUS_DRAFT: u16 = 1633;
81pub const KIND_REPO_ANNOUNCEMENT: u16 = 30617;
82
83pub const LABEL_HASHTREE: &str = "hashtree";
85pub const LABEL_GIT: &str = "git";
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum PullRequestState {
90 Open,
91 Applied,
92 Closed,
93 Draft,
94}
95
96impl PullRequestState {
97 pub fn as_str(self) -> &'static str {
98 match self {
99 PullRequestState::Open => "open",
100 PullRequestState::Applied => "applied",
101 PullRequestState::Closed => "closed",
102 PullRequestState::Draft => "draft",
103 }
104 }
105
106 fn from_status_kind(status_kind: u16) -> Option<Self> {
107 match status_kind {
108 KIND_STATUS_OPEN => Some(PullRequestState::Open),
109 KIND_STATUS_APPLIED => Some(PullRequestState::Applied),
110 KIND_STATUS_CLOSED => Some(PullRequestState::Closed),
111 KIND_STATUS_DRAFT => Some(PullRequestState::Draft),
112 _ => None,
113 }
114 }
115
116 fn from_latest_status_kind(status_kind: Option<u16>) -> Self {
117 status_kind
118 .and_then(Self::from_status_kind)
119 .unwrap_or(PullRequestState::Open)
120 }
121}
122
123#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
125pub enum PullRequestStateFilter {
126 #[default]
127 Open,
128 Applied,
129 Closed,
130 Draft,
131 All,
132}
133
134impl PullRequestStateFilter {
135 pub fn as_str(self) -> &'static str {
136 match self {
137 PullRequestStateFilter::Open => "open",
138 PullRequestStateFilter::Applied => "applied",
139 PullRequestStateFilter::Closed => "closed",
140 PullRequestStateFilter::Draft => "draft",
141 PullRequestStateFilter::All => "all",
142 }
143 }
144
145 fn includes(self, state: PullRequestState) -> bool {
146 match self {
147 PullRequestStateFilter::All => true,
148 PullRequestStateFilter::Open => state == PullRequestState::Open,
149 PullRequestStateFilter::Applied => state == PullRequestState::Applied,
150 PullRequestStateFilter::Closed => state == PullRequestState::Closed,
151 PullRequestStateFilter::Draft => state == PullRequestState::Draft,
152 }
153 }
154}
155
156#[derive(Debug, Clone)]
158pub struct PullRequestListItem {
159 pub event_id: String,
160 pub author_pubkey: String,
161 pub state: PullRequestState,
162 pub subject: Option<String>,
163 pub commit_tip: Option<String>,
164 pub branch: Option<String>,
165 pub target_branch: Option<String>,
166 pub created_at: u64,
167}
168
169async fn fetch_events_via_raw_relay_query(
170 relays: &[String],
171 filter: Filter,
172 timeout: Duration,
173) -> Vec<Event> {
174 let request_json = ClientMessage::req(SubscriptionId::generate(), vec![filter]).as_json();
175 let mut events_by_id = HashMap::<String, Event>::new();
176
177 for relay_url in relays {
178 let relay_events = match tokio::time::timeout(timeout, async {
179 let (mut ws, _) = connect_async(relay_url).await?;
180 ws.send(WsMessage::Text(request_json.clone())).await?;
181
182 let mut relay_events = Vec::new();
183 while let Some(message) = ws.next().await {
184 let message = message?;
185 let WsMessage::Text(text) = message else {
186 continue;
187 };
188
189 match RelayMessage::from_json(text.as_str()) {
190 Ok(RelayMessage::Event { event, .. }) => relay_events.push(event.into_owned()),
191 Ok(RelayMessage::EndOfStoredEvents(_)) => break,
192 Ok(RelayMessage::Closed { message, .. }) => {
193 debug!("Raw relay PR query closed by {}: {}", relay_url, message);
194 break;
195 }
196 Ok(_) => {}
197 Err(err) => {
198 debug!(
199 "Failed to parse raw relay response from {}: {}",
200 relay_url, err
201 );
202 }
203 }
204 }
205
206 let _ = ws.close(None).await;
207 Ok::<Vec<Event>, anyhow::Error>(relay_events)
208 })
209 .await
210 {
211 Ok(Ok(events)) => events,
212 Ok(Err(err)) => {
213 debug!("Raw relay PR query failed for {}: {}", relay_url, err);
214 continue;
215 }
216 Err(_) => {
217 debug!("Raw relay PR query timed out for {}", relay_url);
218 continue;
219 }
220 };
221
222 for event in relay_events {
223 events_by_id.insert(event.id.to_hex(), event);
224 }
225 }
226
227 events_by_id.into_values().collect()
228}
229
230async fn connected_relay_count(client: &Client) -> (usize, usize) {
231 let relays = client.relays().await;
232 let total = relays.len();
233 let mut connected = 0;
234 for relay in relays.values() {
235 if relay.is_connected() {
236 connected += 1;
237 }
238 }
239 (connected, total)
240}
241
242async fn wait_for_any_connected_relay(client: &Client, timeout: Duration) -> bool {
243 let start = std::time::Instant::now();
244 loop {
245 if connected_relay_count(client).await.0 > 0 {
246 return true;
247 }
248 if start.elapsed() > timeout {
249 return false;
250 }
251 tokio::time::sleep(Duration::from_millis(50)).await;
252 }
253}
254
255type FetchedRefs = (HashMap<String, String>, Option<String>, Option<[u8; 32]>);
256
257#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
258enum RootResolveSource {
259 #[default]
260 Relay,
261 LocalDaemon,
262}
263
264#[derive(Debug, Clone)]
265struct ResolvedRoot {
266 root_hash: Option<String>,
267 encryption_key: Option<[u8; 32]>,
268 source: Option<RootResolveSource>,
269}
270use hashtree_config::Config;
271
272#[derive(Debug, Clone)]
274pub struct RelayResult {
275 #[allow(dead_code)]
277 pub configured: Vec<String>,
278 pub connected: Vec<String>,
280 pub failed: Vec<String>,
282}
283
284#[derive(Debug, Clone, Default, PartialEq, Eq)]
286pub struct RepoAnnouncementOptions {
287 pub earliest_unique_commit: Option<String>,
289 pub personal_fork: bool,
291 pub forked_from: Option<String>,
293}
294
295#[derive(Debug, Clone)]
297pub struct BlossomResult {
298 #[allow(dead_code)]
300 pub configured: Vec<String>,
301 pub succeeded: Vec<String>,
303 pub failed: Vec<String>,
305 pub local_complete: bool,
307 pub degraded: bool,
309}
310
311pub struct NostrClient {
313 pubkey: String,
314 keys: Option<Keys>,
316 relays: Vec<String>,
317 blossom: BlossomClient,
318 cached_refs: HashMap<String, HashMap<String, String>>,
320 cached_root_hash: HashMap<String, String>,
322 cached_encryption_key: HashMap<String, [u8; 32]>,
324 cached_root_source: HashMap<String, RootResolveSource>,
326 url_secret: Option<[u8; 32]>,
329 is_private: bool,
331 local_daemon_url: Option<String>,
333}
334
335#[derive(Debug, Clone, Default)]
336struct RootEventData {
337 root_hash: String,
338 encryption_key: Option<[u8; 32]>,
339 key_tag_name: Option<String>,
340 self_encrypted_ciphertext: Option<String>,
341 source: RootResolveSource,
342}
343
344#[derive(Debug, Deserialize)]
345struct DaemonResolveResponse {
346 hash: Option<String>,
347 #[serde(default)]
348 cid: Option<String>,
349 #[serde(default, rename = "key_tag")]
350 key: Option<String>,
351 #[serde(default, rename = "encryptedKey")]
352 encrypted_key: Option<String>,
353 #[serde(default, rename = "selfEncryptedKey")]
354 self_encrypted_key: Option<String>,
355 #[serde(default)]
356 source: Option<String>,
357}
358
359impl NostrClient {
360 pub fn new(
362 pubkey: &str,
363 secret_key: Option<String>,
364 url_secret: Option<[u8; 32]>,
365 is_private: bool,
366 config: &Config,
367 ) -> Result<Self> {
368 let _ = rustls::crypto::ring::default_provider().install_default();
370
371 let secret_key = secret_key.or_else(|| std::env::var("NOSTR_SECRET_KEY").ok());
373
374 let keys = if let Some(ref secret_hex) = secret_key {
376 let secret_bytes = hex::decode(secret_hex).context("Invalid secret key hex")?;
377 let secret = nostr::SecretKey::from_slice(&secret_bytes)
378 .map_err(|e| anyhow::anyhow!("Invalid secret key: {}", e))?;
379 Some(Keys::new(secret))
380 } else {
381 None
382 };
383
384 let blossom_keys = keys.clone().unwrap_or_else(Keys::generate);
387 let mut read_servers = config.blossom.all_read_servers();
388 if let Some(local_url) =
389 hashtree_config::detect_local_daemon_url(Some(config.server.bind_address.as_str()))
390 {
391 if !read_servers.iter().any(|server| server == &local_url) {
392 read_servers.insert(0, local_url);
393 }
394 }
395 let blossom = BlossomClient::new_empty(blossom_keys)
396 .with_read_servers(read_servers)
397 .with_write_servers(config.blossom.all_write_servers())
398 .with_timeout(Duration::from_secs(30));
399
400 tracing::info!(
401 "BlossomClient created with read_servers: {:?}, write_servers: {:?}",
402 blossom.read_servers(),
403 blossom.write_servers()
404 );
405
406 let relays = hashtree_config::resolve_relays(
407 &config.nostr.relays,
408 Some(config.server.bind_address.as_str()),
409 );
410 let local_daemon_url =
411 hashtree_config::detect_local_daemon_url(Some(config.server.bind_address.as_str()))
412 .or_else(|| {
413 config
414 .blossom
415 .read_servers
416 .iter()
417 .find(|url| {
418 url.starts_with("http://127.0.0.1:")
419 || url.starts_with("http://localhost:")
420 })
421 .cloned()
422 });
423
424 Ok(Self {
425 pubkey: pubkey.to_string(),
426 keys,
427 relays,
428 blossom,
429 cached_refs: HashMap::new(),
430 cached_root_hash: HashMap::new(),
431 cached_encryption_key: HashMap::new(),
432 cached_root_source: HashMap::new(),
433 url_secret,
434 is_private,
435 local_daemon_url,
436 })
437 }
438
439 fn format_repo_author(pubkey_hex: &str) -> String {
440 PublicKey::from_hex(pubkey_hex)
441 .ok()
442 .and_then(|pk| pk.to_bech32().ok())
443 .unwrap_or_else(|| pubkey_hex.to_string())
444 }
445
446 fn daemon_pubkey_identifier(&self) -> String {
447 Self::format_repo_author(&self.pubkey)
448 }
449
450 #[allow(dead_code)]
452 pub fn can_sign(&self) -> bool {
453 self.keys.is_some()
454 }
455
456 pub fn list_repos(&self) -> Result<Vec<String>> {
457 block_on_result(self.list_repos_async())
458 }
459
460 pub async fn list_repos_async(&self) -> Result<Vec<String>> {
461 let client = Client::default();
462
463 for relay in &self.relays {
464 if let Err(e) = client.add_relay(relay).await {
465 warn!("Failed to add relay {}: {}", relay, e);
466 }
467 }
468 client.connect().await;
469
470 if !wait_for_any_connected_relay(&client, Duration::from_secs(2)).await {
471 let _ = client.disconnect().await;
472 return Err(anyhow::anyhow!(
473 "Failed to connect to any relay while listing repos"
474 ));
475 }
476
477 let author = PublicKey::from_hex(&self.pubkey)
478 .map_err(|e| anyhow::anyhow!("Invalid pubkey: {}", e))?;
479 let filter = build_git_repo_list_filter(author);
480
481 let events = match tokio::time::timeout(
482 Duration::from_secs(3),
483 client.fetch_events(filter, Duration::from_secs(3)),
484 )
485 .await
486 {
487 Ok(Ok(events)) => events,
488 Ok(Err(e)) => {
489 let _ = client.disconnect().await;
490 return Err(anyhow::anyhow!(
491 "Failed to fetch git repo events from relays: {}",
492 e
493 ));
494 }
495 Err(_) => {
496 let _ = client.disconnect().await;
497 return Err(anyhow::anyhow!(
498 "Timed out fetching git repo events from relays"
499 ));
500 }
501 };
502
503 let _ = client.disconnect().await;
504 let events = events.to_vec();
505
506 Ok(list_git_repo_announcements(&events)
507 .into_iter()
508 .map(|repo| repo.repo_name)
509 .collect())
510 }
511
512 pub fn fetch_refs(&mut self, repo_name: &str) -> Result<HashMap<String, String>> {
515 let (refs, _, _) = self.fetch_refs_with_timeout(repo_name, 10)?;
516 Ok(refs)
517 }
518
519 #[allow(dead_code)]
522 pub fn fetch_refs_quick(&mut self, repo_name: &str) -> Result<HashMap<String, String>> {
523 let (refs, _, _) = self.fetch_refs_with_timeout(repo_name, 3)?;
524 Ok(refs)
525 }
526
527 #[allow(dead_code)]
530 pub fn fetch_refs_with_root(&mut self, repo_name: &str) -> Result<FetchedRefs> {
531 self.fetch_refs_with_timeout(repo_name, 10)
532 }
533
534 pub(crate) fn refetch_refs_without_local_daemon(
535 &mut self,
536 repo_name: &str,
537 timeout_secs: u64,
538 ) -> Result<FetchedRefs> {
539 self.clear_cached_remote_state(repo_name);
540 self.fetch_refs_with_timeout_uncached(repo_name, timeout_secs, false)
541 }
542
543 fn clear_cached_remote_state(&mut self, repo_name: &str) {
544 self.cached_refs.remove(repo_name);
545 self.cached_root_hash.remove(repo_name);
546 self.cached_encryption_key.remove(repo_name);
547 self.cached_root_source.remove(repo_name);
548 }
549
550 fn cache_resolved_root(&mut self, repo_name: &str, resolved: &ResolvedRoot) {
551 if let Some(ref root) = resolved.root_hash {
552 self.cached_root_hash
553 .insert(repo_name.to_string(), root.clone());
554 } else {
555 self.cached_root_hash.remove(repo_name);
556 }
557
558 if let Some(key) = resolved.encryption_key {
559 self.cached_encryption_key
560 .insert(repo_name.to_string(), key);
561 } else {
562 self.cached_encryption_key.remove(repo_name);
563 }
564
565 if let Some(source) = resolved.source {
566 self.cached_root_source
567 .insert(repo_name.to_string(), source);
568 } else {
569 self.cached_root_source.remove(repo_name);
570 }
571 }
572
573 fn fetch_refs_with_timeout(
575 &mut self,
576 repo_name: &str,
577 timeout_secs: u64,
578 ) -> Result<FetchedRefs> {
579 debug!(
580 "Fetching refs for {} from {} (timeout {}s)",
581 repo_name, self.pubkey, timeout_secs
582 );
583
584 if let Some(refs) = self.cached_refs.get(repo_name) {
586 let root = self.cached_root_hash.get(repo_name).cloned();
587 let key = self.cached_encryption_key.get(repo_name).cloned();
588 return Ok((refs.clone(), root, key));
589 }
590
591 self.fetch_refs_with_timeout_uncached(repo_name, timeout_secs, true)
592 }
593
594 fn fetch_refs_with_timeout_uncached(
595 &mut self,
596 repo_name: &str,
597 timeout_secs: u64,
598 allow_local_daemon: bool,
599 ) -> Result<FetchedRefs> {
600 let resolved = block_on_result(self.resolve_root_async_with_timeout(
601 repo_name,
602 timeout_secs,
603 allow_local_daemon,
604 ))?;
605
606 match self.fetch_refs_for_resolved_root(repo_name, &resolved) {
607 Ok(fetched) => Ok(fetched),
608 Err(err)
609 if resolved.source == Some(RootResolveSource::LocalDaemon)
610 && allow_local_daemon =>
611 {
612 warn!(
613 "Local daemon root for {} could not be read as a valid git tree: {}. Retrying via relays.",
614 repo_name, err
615 );
616 self.clear_cached_remote_state(repo_name);
617 self.fetch_refs_with_timeout_uncached(repo_name, timeout_secs, false)
618 }
619 Err(err) => Err(err),
620 }
621 }
622
623 fn fetch_refs_for_resolved_root(
624 &mut self,
625 repo_name: &str,
626 resolved: &ResolvedRoot,
627 ) -> Result<FetchedRefs> {
628 if resolved.source != Some(RootResolveSource::LocalDaemon) {
629 self.cache_resolved_root(repo_name, resolved);
630 }
631
632 let refs = if let Some(ref root) = resolved.root_hash {
633 block_on_result(self.fetch_refs_from_hashtree(root, resolved.encryption_key.as_ref()))?
634 } else {
635 HashMap::new()
636 };
637
638 self.cache_resolved_root(repo_name, resolved);
639 self.cached_refs.insert(repo_name.to_string(), refs.clone());
640 Ok((refs, resolved.root_hash.clone(), resolved.encryption_key))
641 }
642
643 fn parse_root_event_data_from_event(event: &Event) -> RootEventData {
644 let root_hash = event
645 .tags
646 .iter()
647 .find(|t| t.as_slice().len() >= 2 && t.as_slice()[0].as_str() == "hash")
648 .map(|t| t.as_slice()[1].to_string())
649 .unwrap_or_else(|| event.content.to_string());
650
651 let (encryption_key, key_tag_name, self_encrypted_ciphertext) = event
652 .tags
653 .iter()
654 .find_map(|t| {
655 let slice = t.as_slice();
656 if slice.len() < 2 {
657 return None;
658 }
659 let tag_name = slice[0].as_str();
660 let tag_value = slice[1].to_string();
661 if tag_name == "selfEncryptedKey" {
662 return Some((None, Some(tag_name.to_string()), Some(tag_value)));
663 }
664 if tag_name == "key" || tag_name == "encryptedKey" {
665 if let Ok(bytes) = hex::decode(&tag_value) {
666 if bytes.len() == 32 {
667 let mut key = [0u8; 32];
668 key.copy_from_slice(&bytes);
669 return Some((Some(key), Some(tag_name.to_string()), None));
670 }
671 }
672 }
673 None
674 })
675 .unwrap_or((None, None, None));
676
677 RootEventData {
678 root_hash,
679 encryption_key,
680 key_tag_name,
681 self_encrypted_ciphertext,
682 source: RootResolveSource::Relay,
683 }
684 }
685
686 fn parse_daemon_response_to_root_data(
687 response: DaemonResolveResponse,
688 ) -> Option<RootEventData> {
689 let parsed_cid = response.cid.as_deref().and_then(|cid| Cid::parse(cid).ok());
690 let root_hash = response
691 .hash
692 .or_else(|| parsed_cid.as_ref().map(|cid| hex::encode(cid.hash)))?;
693 if root_hash.is_empty() {
694 return None;
695 }
696
697 let mut data = RootEventData {
698 root_hash,
699 encryption_key: parsed_cid.and_then(|cid| cid.key),
700 key_tag_name: None,
701 self_encrypted_ciphertext: None,
702 source: RootResolveSource::LocalDaemon,
703 };
704
705 if let Some(ciphertext) = response.self_encrypted_key {
706 data.key_tag_name = Some("selfEncryptedKey".to_string());
707 data.self_encrypted_ciphertext = Some(ciphertext);
708 return Some(data);
709 }
710
711 let (tag_name, tag_value) = if let Some(v) = response.encrypted_key {
712 ("encryptedKey", v)
713 } else if let Some(v) = response.key {
714 ("key", v)
715 } else {
716 return Some(data);
717 };
718
719 if let Ok(bytes) = hex::decode(&tag_value) {
720 if bytes.len() == 32 {
721 let mut key = [0u8; 32];
722 key.copy_from_slice(&bytes);
723 data.encryption_key = Some(key);
724 data.key_tag_name = Some(tag_name.to_string());
725 }
726 }
727
728 Some(data)
729 }
730
731 async fn fetch_root_from_local_daemon(
732 &self,
733 repo_name: &str,
734 timeout: Duration,
735 ) -> Option<RootEventData> {
736 let base = self.local_daemon_url.as_ref()?;
737 let pubkey = self.daemon_pubkey_identifier();
738 let url = format!(
739 "{}/api/nostr/resolve/{}/{}",
740 base.trim_end_matches('/'),
741 pubkey,
742 repo_name
743 );
744
745 let client = reqwest::Client::builder().timeout(timeout).build().ok()?;
746 let response = client.get(&url).send().await.ok()?;
747 if !response.status().is_success() {
748 return None;
749 }
750
751 let payload: DaemonResolveResponse = response.json().await.ok()?;
752 let source = payload
753 .source
754 .clone()
755 .unwrap_or_else(|| "unknown".to_string());
756 let parsed = Self::parse_daemon_response_to_root_data(payload)?;
757 debug!(
758 "Resolved repo {} via local daemon source={}",
759 repo_name, source
760 );
761 Some(parsed)
762 }
763
764 async fn cache_public_root_in_local_daemon(
765 &self,
766 repo_name: &str,
767 root_hash: &str,
768 encryption_key: Option<(&[u8; 32], bool, bool)>,
769 ) {
770 let Some(base) = self.local_daemon_url.as_ref() else {
771 return;
772 };
773
774 let key = match encryption_key {
775 Some((key, false, false)) => Some(hex::encode(key)),
776 Some((_key, _is_link_visible, _is_self_private)) => return,
777 None => None,
778 };
779
780 let url = format!("{}/api/cache-tree-root", base.trim_end_matches('/'));
781 let pubkey = self.daemon_pubkey_identifier();
782 let payload = serde_json::json!({
783 "npub": pubkey,
784 "treeName": repo_name,
785 "hash": root_hash,
786 "key": key,
787 "visibility": "public",
788 });
789
790 let client = match reqwest::Client::builder()
791 .timeout(Duration::from_secs(2))
792 .build()
793 {
794 Ok(client) => client,
795 Err(err) => {
796 debug!("Could not build local daemon cache client: {}", err);
797 return;
798 }
799 };
800
801 match client.post(url).json(&payload).send().await {
802 Ok(response) if response.status().is_success() => {
803 debug!("Cached repo root for {} in local daemon", repo_name);
804 }
805 Ok(response) => {
806 debug!(
807 "Local daemon root cache returned status {} for {}",
808 response.status(),
809 repo_name
810 );
811 }
812 Err(err) => {
813 debug!("Could not cache repo root in local daemon: {}", err);
814 }
815 }
816 }
817
818 async fn resolve_root_async_with_timeout(
819 &self,
820 repo_name: &str,
821 timeout_secs: u64,
822 allow_local_daemon: bool,
823 ) -> Result<ResolvedRoot> {
824 let client = Client::default();
826
827 for relay in &self.relays {
829 if let Err(e) = client.add_relay(relay).await {
830 warn!("Failed to add relay {}: {}", relay, e);
831 }
832 }
833
834 client.connect().await;
836
837 let connect_timeout = Duration::from_secs(2);
838 let query_timeout = Duration::from_secs(timeout_secs.saturating_sub(2).max(3));
839 let local_daemon_timeout = Duration::from_secs(4);
840 let retry_delay = Duration::from_millis(300);
841 let max_attempts = 2;
842
843 let start = std::time::Instant::now();
844
845 let author = PublicKey::from_hex(&self.pubkey)
847 .map_err(|e| anyhow::anyhow!("Invalid pubkey: {}", e))?;
848
849 let filter = build_repo_event_filter(author, repo_name);
850
851 debug!("Querying relays for repo {} events", repo_name);
852
853 let mut root_data = None;
854 for attempt in 1..=max_attempts {
855 if allow_local_daemon {
856 if let Some(data) = self
857 .fetch_root_from_local_daemon(repo_name, local_daemon_timeout)
858 .await
859 {
860 root_data = Some(data);
861 break;
862 }
863 }
864
865 if !allow_local_daemon && attempt == 1 {
866 debug!(
867 "Skipping local daemon while resolving {} because relay retry was requested",
868 repo_name
869 );
870 }
871
872 if allow_local_daemon {
873 debug!(
874 "Local daemon did not resolve {}; querying relays",
875 repo_name
876 );
877 }
878
879 let connect_start = std::time::Instant::now();
882 let mut last_log = std::time::Instant::now();
883 let mut has_connected_relay = false;
884 loop {
885 let (connected, total) = connected_relay_count(&client).await;
886 if connected > 0 {
887 debug!(
888 "Connected to {}/{} relay(s) in {:?} (attempt {}/{})",
889 connected,
890 total,
891 start.elapsed(),
892 attempt,
893 max_attempts
894 );
895 has_connected_relay = true;
896 break;
897 }
898 if last_log.elapsed() > Duration::from_millis(500) {
899 debug!(
900 "Connecting to relays... (0/{} after {:?}, attempt {}/{})",
901 total,
902 start.elapsed(),
903 attempt,
904 max_attempts
905 );
906 last_log = std::time::Instant::now();
907 }
908 if connect_start.elapsed() > connect_timeout {
909 debug!(
910 "Timeout waiting for relay connections - continuing with local-daemon fallback"
911 );
912 break;
913 }
914 tokio::time::sleep(Duration::from_millis(50)).await;
915 }
916
917 let events = if has_connected_relay {
921 match client.fetch_events(filter.clone(), query_timeout).await {
922 Ok(events) => events.to_vec(),
923 Err(e) => {
924 warn!("Failed to fetch events: {}", e);
925 vec![]
926 }
927 }
928 } else {
929 vec![]
930 };
931
932 debug!(
933 "Got {} events from relays on attempt {}/{}",
934 events.len(),
935 attempt,
936 max_attempts
937 );
938 let relay_event = pick_latest_repo_event(events.iter(), repo_name);
939
940 if let Some(event) = relay_event {
941 debug!(
942 "Found relay event with root hash: {}",
943 &event.content[..12.min(event.content.len())]
944 );
945 root_data = Some(Self::parse_root_event_data_from_event(event));
946 break;
947 }
948
949 if attempt < max_attempts {
950 debug!(
951 "No hashtree event found for {} on attempt {}/{}; retrying",
952 repo_name, attempt, max_attempts
953 );
954 tokio::time::sleep(retry_delay).await;
955 }
956 }
957
958 let _ = client.disconnect().await;
960
961 let root_data = match root_data {
962 Some(data) => data,
963 None => {
964 anyhow::bail!(
965 "Repository '{}' not found (no hashtree event published by {})",
966 repo_name,
967 Self::format_repo_author(&self.pubkey)
968 );
969 }
970 };
971
972 let root_hash = root_data.root_hash;
973
974 if root_hash.is_empty() {
975 debug!("Empty root hash in event");
976 return Ok(ResolvedRoot {
977 root_hash: None,
978 encryption_key: None,
979 source: None,
980 });
981 }
982
983 let encryption_key = root_data.encryption_key;
984 let key_tag_name = root_data.key_tag_name;
985 let self_encrypted_ciphertext = root_data.self_encrypted_ciphertext;
986 let root_source = root_data.source;
987
988 let unmasked_key = match key_tag_name.as_deref() {
990 Some("encryptedKey") => {
991 if let (Some(masked), Some(secret)) = (encryption_key, self.url_secret) {
993 let mut unmasked = [0u8; 32];
994 for i in 0..32 {
995 unmasked[i] = masked[i] ^ secret[i];
996 }
997 Some(unmasked)
998 } else {
999 anyhow::bail!(
1000 "This repo is link-visible and requires a secret key.\n\
1001 Use: htree://.../{repo_name}#k=<secret>\n\
1002 Ask the repo owner for the full URL with the secret."
1003 );
1004 }
1005 }
1006 Some("selfEncryptedKey") => {
1007 if !self.is_private {
1009 anyhow::bail!(
1010 "This repo is private (author-only).\n\
1011 Use: htree://.../{repo_name}#private\n\
1012 Only the author can access this repo."
1013 );
1014 }
1015
1016 if let Some(keys) = &self.keys {
1018 if let Some(ciphertext) = self_encrypted_ciphertext {
1019 let pubkey = keys.public_key();
1021 match nip44::decrypt(keys.secret_key(), &pubkey, &ciphertext) {
1022 Ok(key_hex) => {
1023 let key_bytes =
1024 hex::decode(&key_hex).context("Invalid decrypted key hex")?;
1025 if key_bytes.len() != 32 {
1026 anyhow::bail!("Decrypted key wrong length");
1027 }
1028 let mut key = [0u8; 32];
1029 key.copy_from_slice(&key_bytes);
1030 Some(key)
1031 }
1032 Err(e) => {
1033 anyhow::bail!(
1034 "Failed to decrypt private repo: {}\n\
1035 The repo may be corrupted or published with a different key.",
1036 e
1037 );
1038 }
1039 }
1040 } else {
1041 anyhow::bail!("selfEncryptedKey tag has invalid format");
1042 }
1043 } else {
1044 anyhow::bail!(
1045 "Cannot access this private repo.\n\
1046 Private repos can only be accessed by their author.\n\
1047 You don't have the secret key for this repo's owner."
1048 );
1049 }
1050 }
1051 Some("key") | None => {
1052 encryption_key
1054 }
1055 Some(other) => {
1056 warn!("Unknown key tag type: {}", other);
1057 encryption_key
1058 }
1059 };
1060
1061 info!(
1062 "Found root hash {} for {} (encrypted: {}, link_visible: {})",
1063 &root_hash[..12.min(root_hash.len())],
1064 repo_name,
1065 unmasked_key.is_some(),
1066 self.url_secret.is_some()
1067 );
1068
1069 Ok(ResolvedRoot {
1070 root_hash: Some(root_hash),
1071 encryption_key: unmasked_key,
1072 source: Some(root_source),
1073 })
1074 }
1075
1076 fn decrypt_and_decode(&self, data: &[u8], key: Option<&[u8; 32]>) -> Result<TreeNode> {
1078 let decrypted_data: Vec<u8>;
1079 let data_to_decode = if let Some(k) = key {
1080 decrypted_data = decrypt_chk(data, k).context("Decryption failed")?;
1081 &decrypted_data
1082 } else {
1083 data
1084 };
1085
1086 decode_tree_node(data_to_decode).context("Failed to decode tree node")
1087 }
1088
1089 async fn fetch_refs_from_hashtree(
1092 &self,
1093 root_hash: &str,
1094 encryption_key: Option<&[u8; 32]>,
1095 ) -> Result<HashMap<String, String>> {
1096 let mut refs = HashMap::new();
1097 debug!(
1098 "fetch_refs_from_hashtree: downloading root {}",
1099 &root_hash[..12]
1100 );
1101
1102 let root_data = match self.blossom.download(root_hash).await {
1104 Ok(data) => {
1105 debug!("Downloaded {} bytes from blossom", data.len());
1106 data
1107 }
1108 Err(e) => {
1109 anyhow::bail!(
1110 "Failed to download root hash {}: {}",
1111 &root_hash[..12.min(root_hash.len())],
1112 e
1113 );
1114 }
1115 };
1116
1117 let root_node = self
1119 .decrypt_and_decode(&root_data, encryption_key)
1120 .with_context(|| {
1121 format!(
1122 "Failed to decode root node {} (encrypted: {})",
1123 &root_hash[..12.min(root_hash.len())],
1124 encryption_key.is_some()
1125 )
1126 })?;
1127 debug!("Decoded root node with {} links", root_node.links.len());
1128
1129 debug!(
1131 "Root links: {:?}",
1132 root_node
1133 .links
1134 .iter()
1135 .map(|l| l.name.as_deref())
1136 .collect::<Vec<_>>()
1137 );
1138 let git_link = root_node
1139 .links
1140 .iter()
1141 .find(|l| l.name.as_deref() == Some(".git"));
1142 let (git_hash, git_key) = match git_link {
1143 Some(link) => {
1144 debug!("Found .git link with key: {}", link.key.is_some());
1145 (hex::encode(link.hash), link.key)
1146 }
1147 None => {
1148 debug!("No .git directory in hashtree root");
1149 return Ok(refs);
1150 }
1151 };
1152
1153 let git_data = match self.blossom.download(&git_hash).await {
1155 Ok(data) => data,
1156 Err(e) => {
1157 anyhow::bail!(
1158 "Failed to download .git directory ({}): {}",
1159 &git_hash[..12],
1160 e
1161 );
1162 }
1163 };
1164
1165 let git_node = self
1166 .decrypt_and_decode(&git_data, git_key.as_ref())
1167 .with_context(|| {
1168 format!(
1169 "Failed to decode .git directory {} (encrypted: {})",
1170 &git_hash[..12.min(git_hash.len())],
1171 git_key.is_some()
1172 )
1173 })?;
1174 debug!(
1175 "Decoded .git node with {} links: {:?}",
1176 git_node.links.len(),
1177 git_node
1178 .links
1179 .iter()
1180 .map(|l| l.name.as_deref())
1181 .collect::<Vec<_>>()
1182 );
1183
1184 let refs_link = git_node
1186 .links
1187 .iter()
1188 .find(|l| l.name.as_deref() == Some("refs"));
1189 let (refs_hash, refs_key) = match refs_link {
1190 Some(link) => (hex::encode(link.hash), link.key),
1191 None => {
1192 debug!("No refs directory in .git");
1193 return Ok(refs);
1194 }
1195 };
1196
1197 let refs_data =
1199 self.blossom.download(&refs_hash).await.with_context(|| {
1200 format!("Failed to download refs directory {}", &refs_hash[..12])
1201 })?;
1202
1203 let refs_node = self
1204 .decrypt_and_decode(&refs_data, refs_key.as_ref())
1205 .with_context(|| {
1206 format!(
1207 "Failed to decode refs directory {} (encrypted: {})",
1208 &refs_hash[..12.min(refs_hash.len())],
1209 refs_key.is_some()
1210 )
1211 })?;
1212
1213 if let Some(head_link) = git_node
1215 .links
1216 .iter()
1217 .find(|l| l.name.as_deref() == Some("HEAD"))
1218 {
1219 let head_hash = hex::encode(head_link.hash);
1220 let head_data = self
1221 .blossom
1222 .download(&head_hash)
1223 .await
1224 .with_context(|| format!("Failed to download HEAD {}", &head_hash[..12]))?;
1225 let head_content = if let Some(k) = head_link.key.as_ref() {
1227 let decrypted = decrypt_chk(&head_data, k)
1228 .with_context(|| format!("Failed to decrypt HEAD {}", &head_hash[..12]))?;
1229 String::from_utf8_lossy(&decrypted).trim().to_string()
1230 } else {
1231 String::from_utf8_lossy(&head_data).trim().to_string()
1232 };
1233 refs.insert("HEAD".to_string(), head_content);
1234 }
1235
1236 for subdir_link in &refs_node.links {
1238 if subdir_link.link_type != LinkType::Dir {
1239 continue;
1240 }
1241 let subdir_name = match &subdir_link.name {
1242 Some(n) => n.clone(),
1243 None => continue,
1244 };
1245 let subdir_hash = hex::encode(subdir_link.hash);
1246
1247 self.collect_refs_recursive(
1248 &subdir_hash,
1249 subdir_link.key.as_ref(),
1250 &format!("refs/{}", subdir_name),
1251 &mut refs,
1252 )
1253 .await?;
1254 }
1255
1256 debug!("Found {} refs from hashtree", refs.len());
1257 Ok(refs)
1258 }
1259
1260 async fn collect_refs_recursive(
1262 &self,
1263 dir_hash: &str,
1264 dir_key: Option<&[u8; 32]>,
1265 prefix: &str,
1266 refs: &mut HashMap<String, String>,
1267 ) -> Result<()> {
1268 let dir_data = self
1269 .blossom
1270 .download(dir_hash)
1271 .await
1272 .with_context(|| format!("Failed to download refs subtree {}", &dir_hash[..12]))?;
1273
1274 let dir_node = self
1275 .decrypt_and_decode(&dir_data, dir_key)
1276 .with_context(|| {
1277 format!(
1278 "Failed to decode refs subtree {} (encrypted: {})",
1279 &dir_hash[..12.min(dir_hash.len())],
1280 dir_key.is_some()
1281 )
1282 })?;
1283
1284 for link in &dir_node.links {
1285 let name = match &link.name {
1286 Some(n) => n.clone(),
1287 None => continue,
1288 };
1289 let link_hash = hex::encode(link.hash);
1290 let ref_path = format!("{}/{}", prefix, name);
1291
1292 if link.link_type == LinkType::Dir {
1293 Box::pin(self.collect_refs_recursive(
1295 &link_hash,
1296 link.key.as_ref(),
1297 &ref_path,
1298 refs,
1299 ))
1300 .await?;
1301 } else {
1302 let ref_data = self
1304 .blossom
1305 .download(&link_hash)
1306 .await
1307 .with_context(|| format!("Failed to download ref {}", ref_path))?;
1308 let sha = if let Some(k) = link.key.as_ref() {
1310 let decrypted = decrypt_chk(&ref_data, k)
1311 .with_context(|| format!("Failed to decrypt ref {}", ref_path))?;
1312 String::from_utf8_lossy(&decrypted).trim().to_string()
1313 } else {
1314 String::from_utf8_lossy(&ref_data).trim().to_string()
1315 };
1316 if !sha.is_empty() {
1317 debug!("Found ref {} -> {}", ref_path, sha);
1318 refs.insert(ref_path, sha);
1319 }
1320 }
1321 }
1322
1323 Ok(())
1324 }
1325
1326 #[allow(dead_code)]
1328 pub fn update_ref(&mut self, repo_name: &str, ref_name: &str, sha: &str) -> Result<()> {
1329 info!("Updating ref {} -> {} for {}", ref_name, sha, repo_name);
1330
1331 let refs = self.cached_refs.entry(repo_name.to_string()).or_default();
1332 refs.insert(ref_name.to_string(), sha.to_string());
1333
1334 Ok(())
1335 }
1336
1337 pub fn delete_ref(&mut self, repo_name: &str, ref_name: &str) -> Result<()> {
1339 info!("Deleting ref {} for {}", ref_name, repo_name);
1340
1341 if let Some(refs) = self.cached_refs.get_mut(repo_name) {
1342 refs.remove(ref_name);
1343 }
1344
1345 Ok(())
1346 }
1347
1348 pub fn get_cached_root_hash(&self, repo_name: &str) -> Option<&String> {
1350 self.cached_root_hash.get(repo_name)
1351 }
1352
1353 pub fn get_cached_encryption_key(&self, repo_name: &str) -> Option<&[u8; 32]> {
1355 self.cached_encryption_key.get(repo_name)
1356 }
1357
1358 pub(crate) fn cached_root_is_from_local_daemon(&self, repo_name: &str) -> bool {
1359 self.cached_root_source.get(repo_name) == Some(&RootResolveSource::LocalDaemon)
1360 }
1361
1362 pub fn blossom(&self) -> &BlossomClient {
1364 &self.blossom
1365 }
1366
1367 pub fn relay_urls(&self) -> Vec<String> {
1369 self.relays.clone()
1370 }
1371
1372 #[allow(dead_code)]
1374 pub fn pubkey(&self) -> &str {
1375 &self.pubkey
1376 }
1377
1378 pub fn npub(&self) -> String {
1380 PublicKey::from_hex(&self.pubkey)
1381 .ok()
1382 .and_then(|pk| pk.to_bech32().ok())
1383 .unwrap_or_else(|| self.pubkey.clone())
1384 }
1385
1386 pub fn publish_repo(
1394 &self,
1395 repo_name: &str,
1396 root_hash: &str,
1397 encryption_key: Option<(&[u8; 32], bool, bool)>,
1398 ) -> Result<(String, RelayResult)> {
1399 self.publish_repo_with_announcement(repo_name, root_hash, encryption_key, None)
1400 }
1401
1402 pub fn publish_repo_with_announcement(
1403 &self,
1404 repo_name: &str,
1405 root_hash: &str,
1406 encryption_key: Option<(&[u8; 32], bool, bool)>,
1407 repo_announcement: Option<RepoAnnouncementOptions>,
1408 ) -> Result<(String, RelayResult)> {
1409 let keys = self.keys.as_ref().context(format!(
1410 "Cannot push: no secret key for {}. You can only push to your own repos.",
1411 &self.pubkey[..16]
1412 ))?;
1413
1414 info!(
1415 "Publishing repo {} with root hash {} (encrypted: {})",
1416 repo_name,
1417 root_hash,
1418 encryption_key.is_some()
1419 );
1420
1421 block_on_result(self.publish_repo_async(
1423 keys,
1424 repo_name,
1425 root_hash,
1426 encryption_key,
1427 repo_announcement,
1428 ))
1429 }
1430
1431 async fn publish_repo_async(
1432 &self,
1433 keys: &Keys,
1434 repo_name: &str,
1435 root_hash: &str,
1436 encryption_key: Option<(&[u8; 32], bool, bool)>,
1437 repo_announcement: Option<RepoAnnouncementOptions>,
1438 ) -> Result<(String, RelayResult)> {
1439 let client = Client::new(keys.clone());
1441
1442 let configured: Vec<String> = self.relays.clone();
1443 let mut connected: Vec<String> = Vec::new();
1444 let mut failed: Vec<String> = Vec::new();
1445
1446 for relay in &self.relays {
1448 if let Err(e) = client.add_relay(relay).await {
1449 warn!("Failed to add relay {}: {}", relay, e);
1450 failed.push(relay.clone());
1451 }
1452 }
1453
1454 client.connect().await;
1456
1457 let _ = wait_for_any_connected_relay(&client, Duration::from_secs(3)).await;
1459
1460 let publish_created_at = next_replaceable_created_at(
1461 Timestamp::now(),
1462 latest_repo_event_created_at(
1463 &client,
1464 keys.public_key(),
1465 repo_name,
1466 Duration::from_secs(2),
1467 )
1468 .await,
1469 );
1470
1471 let mut tags = vec![
1473 Tag::custom(TagKind::custom("d"), vec![repo_name.to_string()]),
1474 Tag::custom(TagKind::custom("l"), vec![LABEL_HASHTREE.to_string()]),
1475 Tag::custom(TagKind::custom("hash"), vec![root_hash.to_string()]),
1476 ];
1477
1478 if let Some((key, is_link_visible, is_self_private)) = encryption_key {
1484 if is_self_private {
1485 let pubkey = keys.public_key();
1487 let key_hex = hex::encode(key);
1488 let encrypted =
1489 nip44::encrypt(keys.secret_key(), &pubkey, &key_hex, nip44::Version::V2)
1490 .map_err(|e| anyhow::anyhow!("NIP-44 encryption failed: {}", e))?;
1491 tags.push(Tag::custom(
1492 TagKind::custom("selfEncryptedKey"),
1493 vec![encrypted],
1494 ));
1495 } else if is_link_visible {
1496 tags.push(Tag::custom(
1498 TagKind::custom("encryptedKey"),
1499 vec![hex::encode(key)],
1500 ));
1501 } else {
1502 tags.push(Tag::custom(TagKind::custom("key"), vec![hex::encode(key)]));
1504 }
1505 }
1506
1507 append_repo_discovery_labels(&mut tags, repo_name);
1508
1509 let event = EventBuilder::new(Kind::Custom(KIND_APP_DATA), root_hash)
1511 .tags(tags)
1512 .custom_created_at(publish_created_at)
1513 .sign_with_keys(keys)
1514 .map_err(|e| anyhow::anyhow!("Failed to sign event: {}", e))?;
1515
1516 match client.send_event(&event).await {
1518 Ok(output) => {
1519 for url in output.success.iter() {
1521 let url_str = url.to_string();
1522 if !connected.contains(&url_str) {
1523 connected.push(url_str);
1524 }
1525 }
1526 for (url, _err) in output.failed.iter() {
1528 let url_str = url.to_string();
1529 if !failed.contains(&url_str) && !connected.contains(&url_str) {
1530 failed.push(url_str);
1531 }
1532 }
1533 info!(
1534 "Sent event {} to {} relays ({} failed)",
1535 output.id(),
1536 output.success.len(),
1537 output.failed.len()
1538 );
1539 }
1540 Err(e) => {
1541 warn!("Failed to send event: {}", e);
1542 for relay in &self.relays {
1544 if !failed.contains(relay) {
1545 failed.push(relay.clone());
1546 }
1547 }
1548 }
1549 };
1550
1551 let npub_url = keys
1553 .public_key()
1554 .to_bech32()
1555 .map(|npub| format!("htree://{}/{}", npub, repo_name))
1556 .unwrap_or_else(|_| format!("htree://{}/{}", &self.pubkey[..16], repo_name));
1557
1558 let relay_validation = validate_repo_publish_relays(&configured, &connected);
1559
1560 if relay_validation.is_ok() {
1561 if let Some(repo_announcement) = repo_announcement.as_ref() {
1562 if let Err(err) = self
1563 .publish_repo_announcement_async(
1564 &client,
1565 keys,
1566 repo_name,
1567 &npub_url,
1568 repo_announcement,
1569 )
1570 .await
1571 {
1572 warn!("Failed to publish NIP-34 repo announcement: {}", err);
1573 }
1574 }
1575 }
1576
1577 let _ = client.disconnect().await;
1579 tokio::time::sleep(Duration::from_millis(50)).await;
1580
1581 relay_validation?;
1582
1583 self.cache_public_root_in_local_daemon(repo_name, root_hash, encryption_key)
1584 .await;
1585
1586 Ok((
1587 npub_url,
1588 RelayResult {
1589 configured,
1590 connected,
1591 failed,
1592 },
1593 ))
1594 }
1595
1596 fn build_repo_announcement_tags(
1597 repo_name: &str,
1598 clone_url: &str,
1599 relays: &[String],
1600 options: &RepoAnnouncementOptions,
1601 ) -> Vec<Tag> {
1602 let mut tags = vec![
1603 Tag::custom(TagKind::custom("d"), vec![repo_name.to_string()]),
1604 Tag::custom(TagKind::custom("name"), vec![repo_name.to_string()]),
1605 Tag::custom(TagKind::custom("clone"), vec![clone_url.to_string()]),
1606 ];
1607
1608 if !relays.is_empty() {
1609 tags.push(Tag::custom(TagKind::custom("relays"), relays.to_vec()));
1610 }
1611
1612 if let Some(euc) = options
1613 .earliest_unique_commit
1614 .as_deref()
1615 .map(str::trim)
1616 .filter(|value| !value.is_empty())
1617 {
1618 tags.push(Tag::custom(
1619 TagKind::custom("r"),
1620 vec![euc.to_string(), "euc".to_string()],
1621 ));
1622 }
1623
1624 if options.personal_fork {
1625 tags.push(Tag::custom(
1626 TagKind::custom("t"),
1627 vec!["personal-fork".to_string()],
1628 ));
1629 }
1630
1631 if let Some(forked_from) = options
1632 .forked_from
1633 .as_deref()
1634 .map(str::trim)
1635 .filter(|value| !value.is_empty())
1636 {
1637 tags.push(Tag::custom(
1638 TagKind::custom("forked-from"),
1639 vec![forked_from.to_string()],
1640 ));
1641 }
1642
1643 tags
1644 }
1645
1646 async fn publish_repo_announcement_async(
1647 &self,
1648 client: &Client,
1649 keys: &Keys,
1650 repo_name: &str,
1651 clone_url: &str,
1652 options: &RepoAnnouncementOptions,
1653 ) -> Result<()> {
1654 let created_at = next_replaceable_created_at(
1655 Timestamp::now(),
1656 latest_repo_announcement_created_at(
1657 client,
1658 keys.public_key(),
1659 repo_name,
1660 Duration::from_secs(2),
1661 )
1662 .await,
1663 );
1664 let tags = Self::build_repo_announcement_tags(repo_name, clone_url, &self.relays, options);
1665 let event = EventBuilder::new(Kind::Custom(KIND_REPO_ANNOUNCEMENT), "")
1666 .tags(tags)
1667 .custom_created_at(created_at)
1668 .sign_with_keys(keys)
1669 .map_err(|e| anyhow::anyhow!("Failed to sign NIP-34 repo announcement: {}", e))?;
1670
1671 let output = client
1672 .send_event(&event)
1673 .await
1674 .map_err(|e| anyhow::anyhow!("Failed to publish NIP-34 repo announcement: {}", e))?;
1675 if output.success.is_empty() {
1676 anyhow::bail!("NIP-34 repo announcement was not confirmed by any relay");
1677 }
1678
1679 info!(
1680 "Published NIP-34 repo announcement {} to {} relays",
1681 output.id(),
1682 output.success.len()
1683 );
1684 Ok(())
1685 }
1686
1687 pub fn fetch_repo_announcement_euc(
1688 &self,
1689 owner_pubkey_hex: &str,
1690 repo_name: &str,
1691 ) -> Result<Option<String>> {
1692 block_on_result(self.fetch_repo_announcement_euc_async(owner_pubkey_hex, repo_name))
1693 }
1694
1695 async fn fetch_repo_announcement_euc_async(
1696 &self,
1697 owner_pubkey_hex: &str,
1698 repo_name: &str,
1699 ) -> Result<Option<String>> {
1700 let owner = PublicKey::from_hex(owner_pubkey_hex)
1701 .map_err(|e| anyhow::anyhow!("Invalid repo owner pubkey: {}", e))?;
1702 let client = Client::default();
1703
1704 for relay in &self.relays {
1705 if let Err(e) = client.add_relay(relay).await {
1706 debug!(
1707 "Failed to add relay {} for repo announcement lookup: {}",
1708 relay, e
1709 );
1710 }
1711 }
1712 client.connect().await;
1713
1714 if !wait_for_any_connected_relay(&client, Duration::from_secs(2)).await {
1715 let _ = client.disconnect().await;
1716 return Ok(None);
1717 }
1718
1719 let filter = build_repo_announcement_filter(owner, repo_name);
1720 let events = match tokio::time::timeout(
1721 Duration::from_secs(3),
1722 client.fetch_events(filter, Duration::from_secs(3)),
1723 )
1724 .await
1725 {
1726 Ok(Ok(events)) => events.to_vec(),
1727 Ok(Err(err)) => {
1728 debug!(
1729 "Failed to fetch NIP-34 repo announcement for {}: {}",
1730 repo_name, err
1731 );
1732 Vec::new()
1733 }
1734 Err(_) => {
1735 debug!(
1736 "Timed out fetching NIP-34 repo announcement for {}",
1737 repo_name
1738 );
1739 Vec::new()
1740 }
1741 };
1742
1743 let _ = client.disconnect().await;
1744 Ok(pick_latest_event(events.iter()).and_then(extract_repo_announcement_euc))
1745 }
1746
1747 pub fn fetch_prs(
1749 &self,
1750 repo_name: &str,
1751 state_filter: PullRequestStateFilter,
1752 ) -> Result<Vec<PullRequestListItem>> {
1753 block_on_result(self.fetch_prs_async(repo_name, state_filter))
1754 }
1755
1756 pub async fn fetch_prs_async(
1757 &self,
1758 repo_name: &str,
1759 state_filter: PullRequestStateFilter,
1760 ) -> Result<Vec<PullRequestListItem>> {
1761 let client = Client::default();
1762
1763 for relay in &self.relays {
1764 if let Err(e) = client.add_relay(relay).await {
1765 warn!("Failed to add relay {}: {}", relay, e);
1766 }
1767 }
1768 client.connect().await;
1769
1770 if !wait_for_any_connected_relay(&client, Duration::from_secs(2)).await {
1772 let _ = client.disconnect().await;
1773 return Err(anyhow::anyhow!(
1774 "Failed to connect to any relay while fetching PRs"
1775 ));
1776 }
1777
1778 let repo_address = format!("{}:{}:{}", KIND_REPO_ANNOUNCEMENT, self.pubkey, repo_name);
1780 let pull_request_filter = Filter::new()
1781 .kind(Kind::Custom(KIND_PULL_REQUEST))
1782 .custom_tag(
1783 SingleLetterTag::lowercase(Alphabet::A),
1784 repo_address.clone(),
1785 );
1786
1787 let mut pr_events = match tokio::time::timeout(
1788 Duration::from_secs(3),
1789 client.fetch_events(pull_request_filter.clone(), Duration::from_secs(3)),
1790 )
1791 .await
1792 {
1793 Ok(Ok(events)) => events.to_vec(),
1794 Ok(Err(e)) => {
1795 let _ = client.disconnect().await;
1796 return Err(anyhow::anyhow!(
1797 "Failed to fetch PR events from relays: {}",
1798 e
1799 ));
1800 }
1801 Err(_) => {
1802 let _ = client.disconnect().await;
1803 return Err(anyhow::anyhow!("Timed out fetching PR events from relays"));
1804 }
1805 };
1806
1807 if pr_events.is_empty() {
1808 let fallback_events = fetch_events_via_raw_relay_query(
1809 &self.relays,
1810 pull_request_filter,
1811 Duration::from_secs(3),
1812 )
1813 .await;
1814 if !fallback_events.is_empty() {
1815 debug!(
1816 "Raw relay fallback recovered {} PR event(s) for {}",
1817 fallback_events.len(),
1818 repo_name
1819 );
1820 pr_events = fallback_events;
1821 }
1822 }
1823
1824 if pr_events.is_empty() {
1825 let _ = client.disconnect().await;
1826 return Ok(Vec::new());
1827 }
1828
1829 let pr_ids: Vec<String> = pr_events.iter().map(|e| e.id.to_hex()).collect();
1831
1832 let status_event_filter = Filter::new()
1834 .kinds(vec![
1835 Kind::Custom(KIND_STATUS_OPEN),
1836 Kind::Custom(KIND_STATUS_APPLIED),
1837 Kind::Custom(KIND_STATUS_CLOSED),
1838 Kind::Custom(KIND_STATUS_DRAFT),
1839 ])
1840 .custom_tags(
1841 SingleLetterTag::lowercase(Alphabet::E),
1842 pr_ids.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
1843 );
1844
1845 let mut status_events = match tokio::time::timeout(
1846 Duration::from_secs(3),
1847 client.fetch_events(status_event_filter.clone(), Duration::from_secs(3)),
1848 )
1849 .await
1850 {
1851 Ok(Ok(events)) => events.to_vec(),
1852 Ok(Err(e)) => {
1853 let _ = client.disconnect().await;
1854 return Err(anyhow::anyhow!(
1855 "Failed to fetch PR status events from relays: {}",
1856 e
1857 ));
1858 }
1859 Err(_) => {
1860 let _ = client.disconnect().await;
1861 return Err(anyhow::anyhow!(
1862 "Timed out fetching PR status events from relays"
1863 ));
1864 }
1865 };
1866
1867 if status_events.is_empty() {
1868 let fallback_events = fetch_events_via_raw_relay_query(
1869 &self.relays,
1870 status_event_filter,
1871 Duration::from_secs(3),
1872 )
1873 .await;
1874 if !fallback_events.is_empty() {
1875 debug!(
1876 "Raw relay fallback recovered {} PR status event(s) for {}",
1877 fallback_events.len(),
1878 repo_name
1879 );
1880 status_events = fallback_events;
1881 }
1882 }
1883
1884 let _ = client.disconnect().await;
1885
1886 let latest_status =
1888 latest_trusted_pr_status_kinds(&pr_events, &status_events, &self.pubkey);
1889
1890 let mut prs = Vec::new();
1891 for event in &pr_events {
1892 let pr_id = event.id.to_hex();
1893 let state =
1894 PullRequestState::from_latest_status_kind(latest_status.get(&pr_id).copied());
1895 if !state_filter.includes(state) {
1896 continue;
1897 }
1898
1899 let mut subject = None;
1900 let mut commit_tip = None;
1901 let mut branch = None;
1902 let mut target_branch = None;
1903
1904 for tag in event.tags.iter() {
1905 let slice = tag.as_slice();
1906 if slice.len() >= 2 {
1907 match slice[0].as_str() {
1908 "subject" => subject = Some(slice[1].to_string()),
1909 "c" => commit_tip = Some(slice[1].to_string()),
1910 "branch" => branch = Some(slice[1].to_string()),
1911 "target-branch" => target_branch = Some(slice[1].to_string()),
1912 _ => {}
1913 }
1914 }
1915 }
1916
1917 prs.push(PullRequestListItem {
1918 event_id: pr_id,
1919 author_pubkey: event.pubkey.to_hex(),
1920 state,
1921 subject,
1922 commit_tip,
1923 branch,
1924 target_branch,
1925 created_at: event.created_at.as_secs(),
1926 });
1927 }
1928
1929 prs.sort_by(|left, right| {
1931 right
1932 .created_at
1933 .cmp(&left.created_at)
1934 .then_with(|| right.event_id.cmp(&left.event_id))
1935 });
1936
1937 debug!(
1938 "Found {} PRs for {} (filter: {:?})",
1939 prs.len(),
1940 repo_name,
1941 state_filter
1942 );
1943 Ok(prs)
1944 }
1945
1946 pub fn publish_pr_merged_status(
1948 &self,
1949 pr_event_id: &str,
1950 pr_author_pubkey: &str,
1951 ) -> Result<()> {
1952 let keys = self
1953 .keys
1954 .as_ref()
1955 .context("Cannot publish status: no secret key")?;
1956
1957 block_on_result(self.publish_pr_merged_status_async(keys, pr_event_id, pr_author_pubkey))
1958 }
1959
1960 async fn publish_pr_merged_status_async(
1961 &self,
1962 keys: &Keys,
1963 pr_event_id: &str,
1964 pr_author_pubkey: &str,
1965 ) -> Result<()> {
1966 let client = Client::new(keys.clone());
1967
1968 for relay in &self.relays {
1969 if let Err(e) = client.add_relay(relay).await {
1970 warn!("Failed to add relay {}: {}", relay, e);
1971 }
1972 }
1973 client.connect().await;
1974
1975 if !wait_for_any_connected_relay(&client, Duration::from_secs(3)).await {
1977 anyhow::bail!("Failed to connect to any relay for status publish");
1978 }
1979
1980 let tags = vec![
1981 Tag::custom(TagKind::custom("e"), vec![pr_event_id.to_string()]),
1982 Tag::custom(TagKind::custom("p"), vec![pr_author_pubkey.to_string()]),
1983 ];
1984
1985 let event = EventBuilder::new(Kind::Custom(KIND_STATUS_APPLIED), "")
1986 .tags(tags)
1987 .sign_with_keys(keys)
1988 .map_err(|e| anyhow::anyhow!("Failed to sign status event: {}", e))?;
1989
1990 let publish_result = match client.send_event(&event).await {
1991 Ok(output) => {
1992 if output.success.is_empty() {
1993 Err(anyhow::anyhow!(
1994 "PR merged status was not confirmed by any relay"
1995 ))
1996 } else {
1997 info!(
1998 "Published PR merged status to {} relays",
1999 output.success.len()
2000 );
2001 Ok(())
2002 }
2003 }
2004 Err(e) => Err(anyhow::anyhow!("Failed to publish PR merged status: {}", e)),
2005 };
2006
2007 let _ = client.disconnect().await;
2008 tokio::time::sleep(Duration::from_millis(50)).await;
2009 publish_result
2010 }
2011
2012 #[allow(dead_code)]
2014 pub async fn upload_blob(&self, _hash: &str, data: &[u8]) -> Result<String> {
2015 let hash = self
2016 .blossom
2017 .upload(data)
2018 .await
2019 .map_err(|e| anyhow::anyhow!("Blossom upload failed: {}", e))?;
2020 Ok(hash)
2021 }
2022
2023 #[allow(dead_code)]
2025 pub async fn upload_blob_if_missing(&self, data: &[u8]) -> Result<(String, bool)> {
2026 self.blossom
2027 .upload_if_missing(data)
2028 .await
2029 .map_err(|e| anyhow::anyhow!("Blossom upload failed: {}", e))
2030 }
2031
2032 #[allow(dead_code)]
2034 pub async fn download_blob(&self, hash: &str) -> Result<Vec<u8>> {
2035 self.blossom
2036 .download(hash)
2037 .await
2038 .map_err(|e| anyhow::anyhow!("Blossom download failed: {}", e))
2039 }
2040
2041 #[allow(dead_code)]
2043 pub async fn try_download_blob(&self, hash: &str) -> Option<Vec<u8>> {
2044 self.blossom.try_download(hash).await
2045 }
2046}
2047
2048#[cfg(test)]
2049mod tests;