Skip to main content

git_remote_htree/
nostr_client.rs

1//! Nostr client for publishing and fetching git repository references
2//!
3//! Uses kind 30064 hashtree root events, while still reading legacy kind 30078 roots:
4//! {
5//!   "kind": 30064,
6//!   "tags": [
7//!     ["d", "<repo-name>"],
8//!     ["l", "hashtree"]
9//!   ],
10//!   "content": "<merkle-root-hash>"
11//! }
12//!
13//! The merkle tree contains:
14//!   root/
15//!     refs/heads/main -> <sha>
16//!     refs/tags/v1.0 -> <sha>
17//!     objects/<sha1> -> data
18//!     objects/<sha2> -> data
19//!
20//! ## Identity file format
21//!
22//! The secrets file (`~/.hashtree/keys`) supports multiple signing keys with optional
23//! petnames:
24//! ```text
25//! nsec1... default
26//! nsec1... work
27//! nsec1... personal
28//! ```
29//!
30//! Or hex format:
31//! ```text
32//! <64-char-hex> default
33//! <64-char-hex> work
34//! ```
35//!
36//! Public read-only aliases can be stored in `~/.hashtree/aliases`:
37//! ```text
38//! npub1... sirius
39//! npub1... coworker
40//! ```
41//!
42//! For compatibility, public aliases in `~/.hashtree/keys` are also accepted.
43//!
44//! Then use: `htree://work/myrepo` or `htree://npub1.../myrepo`
45
46mod 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
72/// Event kind for hashtree roots.
73pub const KIND_HASHTREE_ROOT: u16 = 30064;
74
75/// Legacy event kind for hashtree roots originally stored as NIP-78 app data.
76pub const KIND_APP_DATA: u16 = 30078;
77
78/// NIP-34 event kinds
79pub const KIND_PULL_REQUEST: u16 = 1618;
80pub const KIND_STATUS_OPEN: u16 = 1630;
81pub const KIND_STATUS_APPLIED: u16 = 1631;
82pub const KIND_STATUS_CLOSED: u16 = 1632;
83pub const KIND_STATUS_DRAFT: u16 = 1633;
84pub const KIND_REPO_ANNOUNCEMENT: u16 = 30617;
85
86pub fn hashtree_root_kinds() -> Vec<Kind> {
87    vec![
88        Kind::Custom(KIND_HASHTREE_ROOT),
89        Kind::Custom(KIND_APP_DATA),
90    ]
91}
92
93pub fn is_hashtree_root_kind(kind: Kind) -> bool {
94    kind == Kind::Custom(KIND_HASHTREE_ROOT) || kind == Kind::Custom(KIND_APP_DATA)
95}
96
97/// Label for hashtree events
98pub const LABEL_HASHTREE: &str = "hashtree";
99pub const LABEL_GIT: &str = "git";
100const IRIS_GIT_WEB_BASE_URL: &str = "https://git.iris.to/#";
101const LOCAL_DAEMON_QUERY_TIMEOUT_SECS: u64 = 8;
102const REPO_PUBLISH_TIMEOUT: Duration = Duration::from_secs(30);
103
104fn local_daemon_query_timeout(request_timeout_secs: u64, local_daemon_only: bool) -> Duration {
105    let timeout_secs = if local_daemon_only {
106        request_timeout_secs.max(LOCAL_DAEMON_QUERY_TIMEOUT_SECS)
107    } else {
108        4
109    };
110    Duration::from_secs(timeout_secs)
111}
112
113/// Pull request status derived from trusted NIP-34 status events.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum PullRequestState {
116    Open,
117    Applied,
118    Closed,
119    Draft,
120}
121
122impl PullRequestState {
123    pub fn as_str(self) -> &'static str {
124        match self {
125            PullRequestState::Open => "open",
126            PullRequestState::Applied => "applied",
127            PullRequestState::Closed => "closed",
128            PullRequestState::Draft => "draft",
129        }
130    }
131
132    fn from_status_kind(status_kind: u16) -> Option<Self> {
133        match status_kind {
134            KIND_STATUS_OPEN => Some(PullRequestState::Open),
135            KIND_STATUS_APPLIED => Some(PullRequestState::Applied),
136            KIND_STATUS_CLOSED => Some(PullRequestState::Closed),
137            KIND_STATUS_DRAFT => Some(PullRequestState::Draft),
138            _ => None,
139        }
140    }
141
142    fn from_latest_status_kind(status_kind: Option<u16>) -> Self {
143        status_kind
144            .and_then(Self::from_status_kind)
145            .unwrap_or(PullRequestState::Open)
146    }
147}
148
149/// Filter used when listing PRs.
150#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
151pub enum PullRequestStateFilter {
152    #[default]
153    Open,
154    Applied,
155    Closed,
156    Draft,
157    All,
158}
159
160impl PullRequestStateFilter {
161    pub fn as_str(self) -> &'static str {
162        match self {
163            PullRequestStateFilter::Open => "open",
164            PullRequestStateFilter::Applied => "applied",
165            PullRequestStateFilter::Closed => "closed",
166            PullRequestStateFilter::Draft => "draft",
167            PullRequestStateFilter::All => "all",
168        }
169    }
170
171    fn includes(self, state: PullRequestState) -> bool {
172        match self {
173            PullRequestStateFilter::All => true,
174            PullRequestStateFilter::Open => state == PullRequestState::Open,
175            PullRequestStateFilter::Applied => state == PullRequestState::Applied,
176            PullRequestStateFilter::Closed => state == PullRequestState::Closed,
177            PullRequestStateFilter::Draft => state == PullRequestState::Draft,
178        }
179    }
180}
181
182/// PR metadata used by listing/filtering consumers.
183#[derive(Debug, Clone)]
184pub struct PullRequestListItem {
185    pub event_id: String,
186    pub author_pubkey: String,
187    pub state: PullRequestState,
188    pub subject: Option<String>,
189    pub commit_tip: Option<String>,
190    pub branch: Option<String>,
191    pub target_branch: Option<String>,
192    pub created_at: u64,
193}
194
195async fn fetch_events_via_raw_relay_query(
196    relays: &[String],
197    filter: Filter,
198    timeout: Duration,
199) -> Vec<Event> {
200    let request_json = ClientMessage::req(SubscriptionId::generate(), vec![filter]).as_json();
201    let mut events_by_id = HashMap::<String, Event>::new();
202
203    for relay_url in relays {
204        let relay_events = match tokio::time::timeout(timeout, async {
205            let (mut ws, _) = connect_async(relay_url).await?;
206            ws.send(WsMessage::Text(request_json.clone())).await?;
207
208            let mut relay_events = Vec::new();
209            while let Some(message) = ws.next().await {
210                let message = message?;
211                let WsMessage::Text(text) = message else {
212                    continue;
213                };
214
215                match RelayMessage::from_json(text.as_str()) {
216                    Ok(RelayMessage::Event { event, .. }) => relay_events.push(event.into_owned()),
217                    Ok(RelayMessage::EndOfStoredEvents(_)) => break,
218                    Ok(RelayMessage::Closed { message, .. }) => {
219                        debug!("Raw relay PR query closed by {}: {}", relay_url, message);
220                        break;
221                    }
222                    Ok(_) => {}
223                    Err(err) => {
224                        debug!(
225                            "Failed to parse raw relay response from {}: {}",
226                            relay_url, err
227                        );
228                    }
229                }
230            }
231
232            let _ = ws.close(None).await;
233            Ok::<Vec<Event>, anyhow::Error>(relay_events)
234        })
235        .await
236        {
237            Ok(Ok(events)) => events,
238            Ok(Err(err)) => {
239                debug!("Raw relay PR query failed for {}: {}", relay_url, err);
240                continue;
241            }
242            Err(_) => {
243                debug!("Raw relay PR query timed out for {}", relay_url);
244                continue;
245            }
246        };
247
248        for event in relay_events {
249            events_by_id.insert(event.id.to_hex(), event);
250        }
251    }
252
253    events_by_id.into_values().collect()
254}
255
256async fn connected_relay_count(client: &Client) -> (usize, usize) {
257    let relays = client.relays().await;
258    let total = relays.len();
259    let mut connected = 0;
260    for relay in relays.values() {
261        if relay.is_connected() {
262            connected += 1;
263        }
264    }
265    (connected, total)
266}
267
268async fn wait_for_any_connected_relay(client: &Client, timeout: Duration) -> bool {
269    let start = std::time::Instant::now();
270    loop {
271        if connected_relay_count(client).await.0 > 0 {
272            return true;
273        }
274        if start.elapsed() > timeout {
275            return false;
276        }
277        tokio::time::sleep(Duration::from_millis(50)).await;
278    }
279}
280
281type FetchedRefs = (HashMap<String, String>, Option<String>, Option<[u8; 32]>);
282
283#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
284enum RootResolveSource {
285    #[default]
286    Relay,
287    LocalDaemon,
288}
289
290#[derive(Debug, Clone)]
291struct ResolvedRoot {
292    root_hash: Option<String>,
293    encryption_key: Option<[u8; 32]>,
294    source: Option<RootResolveSource>,
295}
296use hashtree_config::Config;
297
298/// Result of publishing to relays
299#[derive(Debug, Clone)]
300pub struct RelayResult {
301    /// Relays that were configured
302    #[allow(dead_code)]
303    pub configured: Vec<String>,
304    /// Relays that connected
305    pub connected: Vec<String>,
306    /// Relays that failed to connect
307    pub failed: Vec<String>,
308}
309
310/// Optional NIP-34 metadata to publish alongside the htree root event.
311#[derive(Debug, Clone, Default, PartialEq, Eq)]
312pub struct RepoAnnouncementOptions {
313    /// NIP-34 earliest unique commit. Usually the root commit; forks should keep the source euc.
314    pub earliest_unique_commit: Option<String>,
315    /// Mark the repo announcement as a personal fork (`["t", "personal-fork"]`).
316    pub personal_fork: bool,
317    /// Iris/htree extension for showing the exact htree source URL.
318    pub forked_from: Option<String>,
319}
320
321/// Result of uploading to blossom servers
322#[derive(Debug, Clone)]
323pub struct BlossomResult {
324    /// Servers that were configured
325    #[allow(dead_code)]
326    pub configured: Vec<String>,
327    /// Servers that accepted uploads
328    pub succeeded: Vec<String>,
329    /// Servers that failed
330    pub failed: Vec<String>,
331    /// Whether the repo tree is complete in the local hashtree store.
332    pub local_complete: bool,
333    /// Whether remote replication was incomplete.
334    pub degraded: bool,
335}
336
337/// Nostr client for git operations
338pub struct NostrClient {
339    pubkey: String,
340    /// nostr-sdk Keys for signing
341    keys: Option<Keys>,
342    relays: Vec<String>,
343    blossom: BlossomClient,
344    /// Cached refs from remote
345    cached_refs: HashMap<String, HashMap<String, String>>,
346    /// Cached root hashes (hashtree SHA256)
347    cached_root_hash: HashMap<String, String>,
348    /// Cached encryption keys
349    cached_encryption_key: HashMap<String, [u8; 32]>,
350    /// Source of the cached root hash, used to retry tentative daemon roots via relays.
351    cached_root_source: HashMap<String, RootResolveSource>,
352    /// URL secret for link-visible repos (#k=<hex>)
353    /// If set, encryption keys from nostr are XOR-masked and need unmasking
354    url_secret: Option<[u8; 32]>,
355    /// Whether this is a private (author-only) repo using NIP-44 encryption
356    is_private: bool,
357    /// Local htree daemon URL for peer-assisted root discovery
358    local_daemon_url: Option<String>,
359    /// Require all root and blob reads to use the local daemon.
360    local_daemon_only: bool,
361    #[cfg(test)]
362    forced_fetch_refs_results: std::collections::VecDeque<Result<FetchedRefs, String>>,
363}
364
365#[derive(Debug, Clone, Default)]
366struct RootEventData {
367    root_hash: String,
368    encryption_key: Option<[u8; 32]>,
369    key_tag_name: Option<String>,
370    self_encrypted_ciphertext: Option<String>,
371    source: RootResolveSource,
372    daemon_source: Option<String>,
373    event_created_at: Option<u64>,
374    event_id: Option<String>,
375}
376
377#[derive(Debug, Deserialize)]
378struct DaemonResolveResponse {
379    hash: Option<String>,
380    #[serde(default)]
381    cid: Option<String>,
382    #[serde(default, rename = "key_tag")]
383    key: Option<String>,
384    #[serde(default, rename = "encryptedKey")]
385    encrypted_key: Option<String>,
386    #[serde(default, rename = "selfEncryptedKey")]
387    self_encrypted_key: Option<String>,
388    #[serde(default)]
389    source: Option<String>,
390    #[serde(default)]
391    created_at: Option<u64>,
392    #[serde(default)]
393    event_id: Option<String>,
394}
395
396impl NostrClient {
397    /// Create a new client with pubkey, optional secret key, url secret, is_private flag, and config
398    pub fn new(
399        pubkey: &str,
400        secret_key: Option<String>,
401        url_secret: Option<[u8; 32]>,
402        is_private: bool,
403        config: &Config,
404    ) -> Result<Self> {
405        let local_daemon_only = std::env::var("HTREE_LOCAL_DAEMON_ONLY")
406            .map(|value| {
407                !matches!(
408                    value.trim().to_ascii_lowercase().as_str(),
409                    "" | "0" | "false" | "no" | "off"
410                )
411            })
412            .unwrap_or(false);
413        Self::new_with_local_daemon_only(
414            pubkey,
415            secret_key,
416            url_secret,
417            is_private,
418            config,
419            local_daemon_only,
420        )
421    }
422
423    fn new_with_local_daemon_only(
424        pubkey: &str,
425        secret_key: Option<String>,
426        url_secret: Option<[u8; 32]>,
427        is_private: bool,
428        config: &Config,
429        local_daemon_only: bool,
430    ) -> Result<Self> {
431        // Ensure rustls has a process-wide crypto provider even when used as a library (tests).
432        let _ = rustls::crypto::ring::default_provider().install_default();
433
434        // Use provided secret, or try environment variable
435        let secret_key = secret_key.or_else(|| std::env::var("NOSTR_SECRET_KEY").ok());
436
437        // Create nostr-sdk Keys if we have a secret
438        let keys = if let Some(ref secret_hex) = secret_key {
439            let secret_bytes = hex::decode(secret_hex).context("Invalid secret key hex")?;
440            let secret = nostr::SecretKey::from_slice(&secret_bytes)
441                .map_err(|e| anyhow::anyhow!("Invalid secret key: {}", e))?;
442            Some(Keys::new(secret))
443        } else {
444            None
445        };
446
447        let detected_local_daemon =
448            hashtree_config::detect_local_daemon_url(Some(config.server.bind_address.as_str()));
449        let local_daemon_url = detected_local_daemon.clone().or_else(|| {
450            config
451                .blossom
452                .read_servers
453                .iter()
454                .find(|url| {
455                    url.starts_with("http://127.0.0.1:") || url.starts_with("http://localhost:")
456                })
457                .cloned()
458        });
459        if local_daemon_only && local_daemon_url.is_none() {
460            anyhow::bail!(
461                "HTREE_LOCAL_DAEMON_ONLY requires a running local htree daemon at {}",
462                config.server.bind_address
463            );
464        }
465
466        // Create BlossomClient (needs keys for upload auth) from the resolved
467        // config passed by the helper instead of reloading defaults from disk.
468        let blossom_keys = keys.clone().unwrap_or_else(Keys::generate);
469        let mut read_servers = if local_daemon_only {
470            vec![local_daemon_url.clone().expect("checked above")]
471        } else {
472            config.blossom.all_read_servers()
473        };
474        if let Some(local_url) = detected_local_daemon {
475            if !read_servers.iter().any(|server| server == &local_url) {
476                read_servers.insert(0, local_url);
477            }
478        }
479        let write_servers = if local_daemon_only {
480            vec![local_daemon_url.clone().expect("checked above")]
481        } else {
482            config.blossom.all_write_servers()
483        };
484        let blossom = BlossomClient::new_empty(blossom_keys)
485            .with_read_servers(read_servers)
486            .with_write_servers(write_servers)
487            .with_timeout(Duration::from_secs(120));
488
489        tracing::info!(
490            "BlossomClient created with read_servers: {:?}, write_servers: {:?}",
491            blossom.read_servers(),
492            blossom.write_servers()
493        );
494
495        let relays = if local_daemon_only {
496            Vec::new()
497        } else {
498            hashtree_config::resolve_relays(
499                &config.nostr.relays,
500                Some(config.server.bind_address.as_str()),
501            )
502        };
503
504        Ok(Self {
505            pubkey: pubkey.to_string(),
506            keys,
507            relays,
508            blossom,
509            cached_refs: HashMap::new(),
510            cached_root_hash: HashMap::new(),
511            cached_encryption_key: HashMap::new(),
512            cached_root_source: HashMap::new(),
513            url_secret,
514            is_private,
515            local_daemon_url,
516            local_daemon_only,
517            #[cfg(test)]
518            forced_fetch_refs_results: std::collections::VecDeque::new(),
519        })
520    }
521
522    #[cfg(test)]
523    pub(crate) fn force_fetch_refs_error_for_test(&mut self, message: impl Into<String>) {
524        self.forced_fetch_refs_results
525            .push_back(Err(message.into()));
526    }
527
528    #[cfg(test)]
529    pub(crate) fn force_fetch_refs_success_for_test(
530        &mut self,
531        refs: HashMap<String, String>,
532        root_hash: Option<String>,
533        encryption_key: Option<[u8; 32]>,
534    ) {
535        self.forced_fetch_refs_results
536            .push_back(Ok((refs, root_hash, encryption_key)));
537    }
538
539    #[cfg(test)]
540    pub(crate) fn cache_root_for_test(
541        &mut self,
542        repo_name: &str,
543        root_hash: String,
544        encryption_key: Option<[u8; 32]>,
545    ) {
546        self.cached_root_hash
547            .insert(repo_name.to_string(), root_hash);
548        if let Some(key) = encryption_key {
549            self.cached_encryption_key
550                .insert(repo_name.to_string(), key);
551        } else {
552            self.cached_encryption_key.remove(repo_name);
553        }
554        self.cached_root_source
555            .insert(repo_name.to_string(), RootResolveSource::Relay);
556    }
557
558    #[cfg(test)]
559    fn pop_forced_fetch_refs_result(&mut self, repo_name: &str) -> Option<Result<FetchedRefs>> {
560        self.forced_fetch_refs_results
561            .pop_front()
562            .map(|result| match result {
563                Ok((refs, root_hash, encryption_key)) => {
564                    if let Some(root) = &root_hash {
565                        self.cached_root_hash
566                            .insert(repo_name.to_string(), root.clone());
567                    } else {
568                        self.cached_root_hash.remove(repo_name);
569                    }
570                    if let Some(key) = encryption_key {
571                        self.cached_encryption_key
572                            .insert(repo_name.to_string(), key);
573                    } else {
574                        self.cached_encryption_key.remove(repo_name);
575                    }
576                    self.cached_root_source
577                        .insert(repo_name.to_string(), RootResolveSource::Relay);
578                    self.cached_refs.insert(repo_name.to_string(), refs.clone());
579                    Ok((refs, root_hash, encryption_key))
580                }
581                Err(message) => Err(anyhow::anyhow!(message)),
582            })
583    }
584
585    fn format_repo_author(pubkey_hex: &str) -> String {
586        PublicKey::from_hex(pubkey_hex)
587            .ok()
588            .and_then(|pk| pk.to_bech32().ok())
589            .unwrap_or_else(|| pubkey_hex.to_string())
590    }
591
592    fn daemon_pubkey_identifier(&self) -> String {
593        Self::format_repo_author(&self.pubkey)
594    }
595
596    /// Check if we can sign (have secret key for this pubkey)
597    #[allow(dead_code)]
598    pub fn can_sign(&self) -> bool {
599        self.keys.is_some()
600    }
601
602    pub fn list_repos(&self) -> Result<Vec<String>> {
603        block_on_result(self.list_repos_async())
604    }
605
606    pub async fn list_repos_async(&self) -> Result<Vec<String>> {
607        let client = Client::default();
608
609        for relay in &self.relays {
610            if let Err(e) = client.add_relay(relay).await {
611                warn!("Failed to add relay {}: {}", relay, e);
612            }
613        }
614        client.connect().await;
615
616        if !wait_for_any_connected_relay(&client, Duration::from_secs(2)).await {
617            let _ = client.disconnect().await;
618            return Err(anyhow::anyhow!(
619                "Failed to connect to any relay while listing repos"
620            ));
621        }
622
623        let author = PublicKey::from_hex(&self.pubkey)
624            .map_err(|e| anyhow::anyhow!("Invalid pubkey: {}", e))?;
625        let filter = build_git_repo_list_filter(author);
626
627        let events = match tokio::time::timeout(
628            Duration::from_secs(3),
629            client.fetch_events(filter, Duration::from_secs(3)),
630        )
631        .await
632        {
633            Ok(Ok(events)) => events,
634            Ok(Err(e)) => {
635                let _ = client.disconnect().await;
636                return Err(anyhow::anyhow!(
637                    "Failed to fetch git repo events from relays: {}",
638                    e
639                ));
640            }
641            Err(_) => {
642                let _ = client.disconnect().await;
643                return Err(anyhow::anyhow!(
644                    "Timed out fetching git repo events from relays"
645                ));
646            }
647        };
648
649        let _ = client.disconnect().await;
650        let events = events.to_vec();
651
652        Ok(list_git_repo_announcements(&events)
653            .into_iter()
654            .map(|repo| repo.repo_name)
655            .collect())
656    }
657
658    /// Fetch refs for a repository from nostr
659    /// Returns refs parsed from the hashtree at the root hash
660    pub fn fetch_refs(&mut self, repo_name: &str) -> Result<HashMap<String, String>> {
661        #[cfg(test)]
662        if let Some(result) = self.pop_forced_fetch_refs_result(repo_name) {
663            let (refs, _, _) = result?;
664            return Ok(refs);
665        }
666
667        let (refs, _, _) = self.fetch_refs_with_timeout(repo_name, 10)?;
668        Ok(refs)
669    }
670
671    /// Fetch refs with a quick timeout (3s) for push operations
672    /// Returns empty if timeout - allows push to proceed
673    #[allow(dead_code)]
674    pub fn fetch_refs_quick(&mut self, repo_name: &str) -> Result<HashMap<String, String>> {
675        let (refs, _, _) = self.fetch_refs_with_timeout(repo_name, 3)?;
676        Ok(refs)
677    }
678
679    /// Fetch refs and root hash info from nostr
680    /// Returns (refs, root_hash, encryption_key)
681    #[allow(dead_code)]
682    pub fn fetch_refs_with_root(&mut self, repo_name: &str) -> Result<FetchedRefs> {
683        #[cfg(test)]
684        if let Some(result) = self.pop_forced_fetch_refs_result(repo_name) {
685            return result;
686        }
687
688        self.fetch_refs_with_timeout(repo_name, 10)
689    }
690
691    pub(crate) fn refetch_refs_without_local_daemon(
692        &mut self,
693        repo_name: &str,
694        timeout_secs: u64,
695    ) -> Result<FetchedRefs> {
696        self.clear_cached_remote_state(repo_name);
697        self.fetch_refs_with_timeout_uncached(repo_name, timeout_secs, false)
698    }
699
700    fn clear_cached_remote_state(&mut self, repo_name: &str) {
701        self.cached_refs.remove(repo_name);
702        self.cached_root_hash.remove(repo_name);
703        self.cached_encryption_key.remove(repo_name);
704        self.cached_root_source.remove(repo_name);
705    }
706
707    fn cache_resolved_root(&mut self, repo_name: &str, resolved: &ResolvedRoot) {
708        if let Some(ref root) = resolved.root_hash {
709            self.cached_root_hash
710                .insert(repo_name.to_string(), root.clone());
711        } else {
712            self.cached_root_hash.remove(repo_name);
713        }
714
715        if let Some(key) = resolved.encryption_key {
716            self.cached_encryption_key
717                .insert(repo_name.to_string(), key);
718        } else {
719            self.cached_encryption_key.remove(repo_name);
720        }
721
722        if let Some(source) = resolved.source {
723            self.cached_root_source
724                .insert(repo_name.to_string(), source);
725        } else {
726            self.cached_root_source.remove(repo_name);
727        }
728    }
729
730    /// Fetch refs with configurable timeout
731    fn fetch_refs_with_timeout(
732        &mut self,
733        repo_name: &str,
734        timeout_secs: u64,
735    ) -> Result<FetchedRefs> {
736        debug!(
737            "Fetching refs for {} from {} (timeout {}s)",
738            repo_name, self.pubkey, timeout_secs
739        );
740
741        // Check cache first
742        if let Some(refs) = self.cached_refs.get(repo_name) {
743            let root = self.cached_root_hash.get(repo_name).cloned();
744            let key = self.cached_encryption_key.get(repo_name).cloned();
745            return Ok((refs.clone(), root, key));
746        }
747
748        self.fetch_refs_with_timeout_uncached(repo_name, timeout_secs, true)
749    }
750
751    fn fetch_refs_with_timeout_uncached(
752        &mut self,
753        repo_name: &str,
754        timeout_secs: u64,
755        allow_local_daemon: bool,
756    ) -> Result<FetchedRefs> {
757        let resolved = block_on_result(self.resolve_root_async_with_timeout(
758            repo_name,
759            timeout_secs,
760            allow_local_daemon,
761        ))?;
762
763        match self.fetch_refs_for_resolved_root(repo_name, &resolved) {
764            Ok(fetched) => Ok(fetched),
765            Err(err)
766                if resolved.source == Some(RootResolveSource::LocalDaemon)
767                    && allow_local_daemon
768                    && self.local_daemon_only =>
769            {
770                Err(err).with_context(|| {
771                    format!(
772                        "local-daemon-only fetch for {repo_name} failed; relay/Blossom fallback disabled"
773                    )
774                })
775            }
776            Err(err)
777                if resolved.source == Some(RootResolveSource::LocalDaemon)
778                    && allow_local_daemon
779                    && !self.local_daemon_only =>
780            {
781                warn!(
782                    "Local daemon root for {} could not be read as a valid git tree: {}. Retrying via relays.",
783                    repo_name, err
784                );
785                self.clear_cached_remote_state(repo_name);
786                self.fetch_refs_with_timeout_uncached(repo_name, timeout_secs, false)
787            }
788            Err(err) => Err(err),
789        }
790    }
791
792    fn fetch_refs_for_resolved_root(
793        &mut self,
794        repo_name: &str,
795        resolved: &ResolvedRoot,
796    ) -> Result<FetchedRefs> {
797        if resolved.source != Some(RootResolveSource::LocalDaemon) {
798            self.cache_resolved_root(repo_name, resolved);
799        }
800
801        let refs = if let Some(ref root) = resolved.root_hash {
802            block_on_result(self.fetch_refs_from_hashtree(root, resolved.encryption_key.as_ref()))?
803        } else {
804            HashMap::new()
805        };
806
807        self.cache_resolved_root(repo_name, resolved);
808        self.cached_refs.insert(repo_name.to_string(), refs.clone());
809        Ok((refs, resolved.root_hash.clone(), resolved.encryption_key))
810    }
811
812    fn parse_root_event_data_from_event(event: &Event) -> RootEventData {
813        let root_hash = event
814            .tags
815            .iter()
816            .find(|t| t.as_slice().len() >= 2 && t.as_slice()[0].as_str() == "hash")
817            .map(|t| t.as_slice()[1].to_string())
818            .unwrap_or_else(|| event.content.to_string());
819
820        let (encryption_key, key_tag_name, self_encrypted_ciphertext) = event
821            .tags
822            .iter()
823            .find_map(|t| {
824                let slice = t.as_slice();
825                if slice.len() < 2 {
826                    return None;
827                }
828                let tag_name = slice[0].as_str();
829                let tag_value = slice[1].to_string();
830                if tag_name == "selfEncryptedKey" {
831                    return Some((None, Some(tag_name.to_string()), Some(tag_value)));
832                }
833                if tag_name == "key" || tag_name == "encryptedKey" {
834                    if let Ok(bytes) = hex::decode(&tag_value) {
835                        if bytes.len() == 32 {
836                            let mut key = [0u8; 32];
837                            key.copy_from_slice(&bytes);
838                            return Some((Some(key), Some(tag_name.to_string()), None));
839                        }
840                    }
841                }
842                None
843            })
844            .unwrap_or((None, None, None));
845
846        RootEventData {
847            root_hash,
848            encryption_key,
849            key_tag_name,
850            self_encrypted_ciphertext,
851            source: RootResolveSource::Relay,
852            daemon_source: None,
853            event_created_at: Some(event.created_at.as_secs()),
854            event_id: Some(event.id.to_hex()),
855        }
856    }
857
858    fn parse_daemon_response_to_root_data(
859        response: DaemonResolveResponse,
860    ) -> Option<RootEventData> {
861        let parsed_cid = response.cid.as_deref().and_then(|cid| Cid::parse(cid).ok());
862        let daemon_source = response.source.clone();
863        let event_created_at = response.created_at;
864        let event_id = response.event_id.clone();
865        let root_hash = response
866            .hash
867            .or_else(|| parsed_cid.as_ref().map(|cid| hex::encode(cid.hash)))?;
868        if root_hash.is_empty() {
869            return None;
870        }
871
872        let mut data = RootEventData {
873            root_hash,
874            encryption_key: parsed_cid.and_then(|cid| cid.key),
875            key_tag_name: None,
876            self_encrypted_ciphertext: None,
877            source: RootResolveSource::LocalDaemon,
878            daemon_source,
879            event_created_at,
880            event_id,
881        };
882
883        if let Some(ciphertext) = response.self_encrypted_key {
884            data.key_tag_name = Some("selfEncryptedKey".to_string());
885            data.self_encrypted_ciphertext = Some(ciphertext);
886            return Some(data);
887        }
888
889        let (tag_name, tag_value) = if let Some(v) = response.encrypted_key {
890            ("encryptedKey", v)
891        } else if let Some(v) = response.key {
892            ("key", v)
893        } else {
894            return Some(data);
895        };
896
897        if let Ok(bytes) = hex::decode(&tag_value) {
898            if bytes.len() == 32 {
899                let mut key = [0u8; 32];
900                key.copy_from_slice(&bytes);
901                data.encryption_key = Some(key);
902                data.key_tag_name = Some(tag_name.to_string());
903            }
904        }
905
906        Some(data)
907    }
908
909    async fn fetch_root_from_local_daemon(
910        &self,
911        repo_name: &str,
912        timeout: Duration,
913    ) -> Option<RootEventData> {
914        let base = self.local_daemon_url.as_ref()?;
915        let pubkey = self.daemon_pubkey_identifier();
916        let refresh = if self.local_daemon_only {
917            ""
918        } else {
919            "?refresh=1"
920        };
921        let url = format!(
922            "{}/api/nostr/resolve/{}/{}{}",
923            base.trim_end_matches('/'),
924            pubkey,
925            repo_name,
926            refresh,
927        );
928
929        let client = reqwest::Client::builder().timeout(timeout).build().ok()?;
930        let response = client.get(&url).send().await.ok()?;
931        if !response.status().is_success() {
932            return None;
933        }
934
935        let payload: DaemonResolveResponse = response.json().await.ok()?;
936        let source = payload
937            .source
938            .clone()
939            .unwrap_or_else(|| "unknown".to_string());
940        let parsed = Self::parse_daemon_response_to_root_data(payload)?;
941        debug!(
942            "Resolved repo {} via local daemon source={}",
943            repo_name, source
944        );
945        Some(parsed)
946    }
947
948    fn daemon_root_needs_relay_confirmation(data: &RootEventData) -> bool {
949        if data.source != RootResolveSource::LocalDaemon {
950            return false;
951        }
952
953        !matches!(data.daemon_source.as_deref(), Some("nostr-relay" | "nostr"))
954    }
955
956    fn root_event_is_newer(left: &RootEventData, right: &RootEventData) -> bool {
957        match (left.event_created_at, right.event_created_at) {
958            (Some(left_time), Some(right_time)) => {
959                (left_time, left.event_id.as_deref().unwrap_or(""))
960                    > (right_time, right.event_id.as_deref().unwrap_or(""))
961            }
962            (Some(_), None) => true,
963            _ => false,
964        }
965    }
966
967    fn choose_newer_root_data(fallback: RootEventData, relay: RootEventData) -> RootEventData {
968        if Self::root_event_is_newer(&fallback, &relay) {
969            fallback
970        } else {
971            relay
972        }
973    }
974
975    async fn cache_public_root_in_local_daemon(
976        &self,
977        repo_name: &str,
978        root_hash: &str,
979        encryption_key: Option<(&[u8; 32], bool, bool)>,
980    ) {
981        let Some(base) = self.local_daemon_url.as_ref() else {
982            return;
983        };
984
985        match encryption_key {
986            Some((_key, false, false)) => {}
987            Some((_key, _is_link_visible, _is_self_private)) => return,
988            None => {}
989        }
990
991        let url = format!("{}/api/cache-tree-root", base.trim_end_matches('/'));
992        let pubkey = self.daemon_pubkey_identifier();
993        let payload = serde_json::json!({
994            "npub": pubkey,
995            "treeName": repo_name,
996            "hash": root_hash,
997            "visibility": "public",
998        });
999
1000        let client = match reqwest::Client::builder()
1001            .timeout(Duration::from_secs(2))
1002            .build()
1003        {
1004            Ok(client) => client,
1005            Err(err) => {
1006                debug!("Could not build local daemon cache client: {}", err);
1007                return;
1008            }
1009        };
1010
1011        match client.post(url).json(&payload).send().await {
1012            Ok(response) if response.status().is_success() => {
1013                debug!("Cached repo root for {} in local daemon", repo_name);
1014            }
1015            Ok(response) => {
1016                debug!(
1017                    "Local daemon root cache returned status {} for {}",
1018                    response.status(),
1019                    repo_name
1020                );
1021            }
1022            Err(err) => {
1023                debug!("Could not cache repo root in local daemon: {}", err);
1024            }
1025        }
1026    }
1027
1028    async fn resolve_root_async_with_timeout(
1029        &self,
1030        repo_name: &str,
1031        timeout_secs: u64,
1032        allow_local_daemon: bool,
1033    ) -> Result<ResolvedRoot> {
1034        // The daemon gathers verified observations for its own bounded window. In local-only
1035        // mode the HTTP client must outlive that window; Nostr EOSE is not a completeness signal.
1036        let local_daemon_timeout = local_daemon_query_timeout(timeout_secs, self.local_daemon_only);
1037        if self.local_daemon_only {
1038            if !allow_local_daemon {
1039                anyhow::bail!(
1040                    "local-daemon-only root lookup for {repo_name} failed; relay fallback disabled"
1041                );
1042            }
1043            let root_data = self
1044                .fetch_root_from_local_daemon(repo_name, local_daemon_timeout)
1045                .await
1046                .ok_or_else(|| {
1047                    anyhow::anyhow!(
1048                        "local-daemon-only root lookup returned no root for {repo_name}; relay fallback disabled"
1049                    )
1050                })?;
1051            return self.finish_resolved_root(repo_name, root_data);
1052        }
1053
1054        // Create nostr-sdk client
1055        let client = Client::default();
1056
1057        // Add relays
1058        for relay in &self.relays {
1059            if let Err(e) = client.add_relay(relay).await {
1060                warn!("Failed to add relay {}: {}", relay, e);
1061            }
1062        }
1063
1064        // Connect to relays - this starts async connection
1065        client.connect().await;
1066
1067        let connect_timeout = Duration::from_secs(2);
1068        let query_timeout = Duration::from_secs(timeout_secs.saturating_sub(2).max(3));
1069        let retry_delay = Duration::from_millis(300);
1070        let max_attempts = 2;
1071
1072        let start = std::time::Instant::now();
1073
1074        // Build filter for hashtree root events from this author with matching d-tag
1075        let author = PublicKey::from_hex(&self.pubkey)
1076            .map_err(|e| anyhow::anyhow!("Invalid pubkey: {}", e))?;
1077
1078        let filter = build_repo_event_filter(author, repo_name);
1079
1080        debug!("Querying relays for repo {} events", repo_name);
1081
1082        let mut root_data = None;
1083        let mut daemon_fallback: Option<RootEventData> = None;
1084        for attempt in 1..=max_attempts {
1085            if allow_local_daemon {
1086                if let Some(data) = self
1087                    .fetch_root_from_local_daemon(repo_name, local_daemon_timeout)
1088                    .await
1089                {
1090                    if Self::daemon_root_needs_relay_confirmation(&data) {
1091                        debug!(
1092                            "Local daemon resolved {} via {}; checking relays for fresher root",
1093                            repo_name,
1094                            data.daemon_source.as_deref().unwrap_or("unknown")
1095                        );
1096                        daemon_fallback = Some(data);
1097                    } else {
1098                        root_data = Some(data);
1099                        break;
1100                    }
1101                }
1102            }
1103
1104            if !allow_local_daemon && attempt == 1 {
1105                debug!(
1106                    "Skipping local daemon while resolving {} because relay retry was requested",
1107                    repo_name
1108                );
1109            }
1110
1111            if allow_local_daemon && daemon_fallback.is_none() {
1112                debug!(
1113                    "Local daemon did not resolve {}; querying relays",
1114                    repo_name
1115                );
1116            }
1117
1118            // Wait for at least one relay to connect (quick timeout - break immediately when one
1119            // connects). We retry once because relays and the local daemon can both lag briefly.
1120            let connect_start = std::time::Instant::now();
1121            let mut last_log = std::time::Instant::now();
1122            let mut has_connected_relay = false;
1123            loop {
1124                let (connected, total) = connected_relay_count(&client).await;
1125                if connected > 0 {
1126                    debug!(
1127                        "Connected to {}/{} relay(s) in {:?} (attempt {}/{})",
1128                        connected,
1129                        total,
1130                        start.elapsed(),
1131                        attempt,
1132                        max_attempts
1133                    );
1134                    has_connected_relay = true;
1135                    break;
1136                }
1137                if last_log.elapsed() > Duration::from_millis(500) {
1138                    debug!(
1139                        "Connecting to relays... (0/{} after {:?}, attempt {}/{})",
1140                        total,
1141                        start.elapsed(),
1142                        attempt,
1143                        max_attempts
1144                    );
1145                    last_log = std::time::Instant::now();
1146                }
1147                if connect_start.elapsed() > connect_timeout {
1148                    debug!(
1149                        "Timeout waiting for relay connections - continuing with local-daemon fallback"
1150                    );
1151                    break;
1152                }
1153                tokio::time::sleep(Duration::from_millis(50)).await;
1154            }
1155
1156            // Query with relay-level timeout.
1157            // Using `EventSource::relays(Some(...))` preserves partial results from responsive
1158            // relays instead of discarding everything when one relay stalls.
1159            if !has_connected_relay {
1160                if let Some(data) = daemon_fallback.take() {
1161                    debug!(
1162                        "Using local daemon root for {} because no relay connected",
1163                        repo_name
1164                    );
1165                    root_data = Some(data);
1166                    break;
1167                }
1168            }
1169
1170            let events = if has_connected_relay {
1171                match client.fetch_events(filter.clone(), query_timeout).await {
1172                    Ok(events) => events.to_vec(),
1173                    Err(e) => {
1174                        warn!("Failed to fetch events: {}", e);
1175                        vec![]
1176                    }
1177                }
1178            } else {
1179                vec![]
1180            };
1181
1182            debug!(
1183                "Got {} events from relays on attempt {}/{}",
1184                events.len(),
1185                attempt,
1186                max_attempts
1187            );
1188            let relay_event = pick_latest_repo_event(events.iter(), repo_name);
1189
1190            if let Some(event) = relay_event {
1191                debug!(
1192                    "Found relay event with root hash: {}",
1193                    &event.content[..12.min(event.content.len())]
1194                );
1195                let relay_data = Self::parse_root_event_data_from_event(event);
1196                root_data = Some(match daemon_fallback.take() {
1197                    Some(fallback) => Self::choose_newer_root_data(fallback, relay_data),
1198                    None => relay_data,
1199                });
1200                break;
1201            }
1202
1203            if attempt < max_attempts {
1204                debug!(
1205                    "No relay hashtree event found for {} on attempt {}/{}; retrying",
1206                    repo_name, attempt, max_attempts
1207                );
1208                tokio::time::sleep(retry_delay).await;
1209            } else if let Some(data) = daemon_fallback.take() {
1210                debug!(
1211                    "Using local daemon root for {} after relay lookup returned no event",
1212                    repo_name
1213                );
1214                root_data = Some(data);
1215                break;
1216            }
1217        }
1218
1219        // Disconnect
1220        let _ = client.disconnect().await;
1221
1222        let root_data = match root_data {
1223            Some(data) => data,
1224            None => {
1225                anyhow::bail!(
1226                    "Repository '{}' not found (no hashtree event published by {})",
1227                    repo_name,
1228                    Self::format_repo_author(&self.pubkey)
1229                );
1230            }
1231        };
1232
1233        self.finish_resolved_root(repo_name, root_data)
1234    }
1235
1236    fn finish_resolved_root(
1237        &self,
1238        repo_name: &str,
1239        root_data: RootEventData,
1240    ) -> Result<ResolvedRoot> {
1241        let root_hash = root_data.root_hash;
1242
1243        if root_hash.is_empty() {
1244            debug!("Empty root hash in event");
1245            return Ok(ResolvedRoot {
1246                root_hash: None,
1247                encryption_key: None,
1248                source: None,
1249            });
1250        }
1251
1252        let encryption_key = root_data.encryption_key;
1253        let key_tag_name = root_data.key_tag_name;
1254        let self_encrypted_ciphertext = root_data.self_encrypted_ciphertext;
1255        let root_source = root_data.source;
1256
1257        // Process encryption key based on tag type
1258        let unmasked_key = match key_tag_name.as_deref() {
1259            Some("encryptedKey") => {
1260                // Link-visible: XOR the masked key with url_secret
1261                if let (Some(masked), Some(secret)) = (encryption_key, self.url_secret) {
1262                    let mut unmasked = [0u8; 32];
1263                    for i in 0..32 {
1264                        unmasked[i] = masked[i] ^ secret[i];
1265                    }
1266                    Some(unmasked)
1267                } else {
1268                    anyhow::bail!(
1269                        "This repo is link-visible and requires a secret key.\n\
1270                         Use: htree://.../{repo_name}#k=<secret>\n\
1271                         Ask the repo owner for the full URL with the secret."
1272                    );
1273                }
1274            }
1275            Some("selfEncryptedKey") => {
1276                // Private: only decrypt if #private is in the URL
1277                if !self.is_private {
1278                    anyhow::bail!(
1279                        "This repo is private (author-only).\n\
1280                         Use: htree://.../{repo_name}#private\n\
1281                         Only the author can access this repo."
1282                    );
1283                }
1284
1285                // Decrypt with NIP-44 using our secret key
1286                if let Some(keys) = &self.keys {
1287                    if let Some(ciphertext) = self_encrypted_ciphertext {
1288                        // Decrypt with NIP-44 (encrypted to self)
1289                        let pubkey = keys.public_key();
1290                        match nip44::decrypt(keys.secret_key(), &pubkey, &ciphertext) {
1291                            Ok(key_hex) => {
1292                                let key_bytes =
1293                                    hex::decode(&key_hex).context("Invalid decrypted key hex")?;
1294                                if key_bytes.len() != 32 {
1295                                    anyhow::bail!("Decrypted key wrong length");
1296                                }
1297                                let mut key = [0u8; 32];
1298                                key.copy_from_slice(&key_bytes);
1299                                Some(key)
1300                            }
1301                            Err(e) => {
1302                                anyhow::bail!(
1303                                    "Failed to decrypt private repo: {}\n\
1304                                     The repo may be corrupted or published with a different key.",
1305                                    e
1306                                );
1307                            }
1308                        }
1309                    } else {
1310                        anyhow::bail!("selfEncryptedKey tag has invalid format");
1311                    }
1312                } else {
1313                    anyhow::bail!(
1314                        "Cannot access this private repo.\n\
1315                         Private repos can only be accessed by their author.\n\
1316                         You don't have the secret key for this repo's owner."
1317                    );
1318                }
1319            }
1320            Some("key") | None => {
1321                // Public: use key directly
1322                encryption_key
1323            }
1324            Some(other) => {
1325                warn!("Unknown key tag type: {}", other);
1326                encryption_key
1327            }
1328        };
1329
1330        info!(
1331            "Found root hash {} for {} (encrypted: {}, link_visible: {})",
1332            &root_hash[..12.min(root_hash.len())],
1333            repo_name,
1334            unmasked_key.is_some(),
1335            self.url_secret.is_some()
1336        );
1337
1338        Ok(ResolvedRoot {
1339            root_hash: Some(root_hash),
1340            encryption_key: unmasked_key,
1341            source: Some(root_source),
1342        })
1343    }
1344
1345    /// Decrypt data if encryption key is provided, then decode as tree node
1346    fn decrypt_and_decode(&self, data: &[u8], key: Option<&[u8; 32]>) -> Result<TreeNode> {
1347        let decrypted_data: Vec<u8>;
1348        let data_to_decode = if let Some(k) = key {
1349            decrypted_data = decrypt_chk(data, k).context("Decryption failed")?;
1350            &decrypted_data
1351        } else {
1352            data
1353        };
1354
1355        decode_tree_node(data_to_decode).context("Failed to decode tree node")
1356    }
1357
1358    /// Fetch git refs from hashtree structure
1359    /// Structure: root -> .git/ -> refs/ -> heads/main -> <sha>
1360    async fn fetch_refs_from_hashtree(
1361        &self,
1362        root_hash: &str,
1363        encryption_key: Option<&[u8; 32]>,
1364    ) -> Result<HashMap<String, String>> {
1365        let mut refs = HashMap::new();
1366        debug!(
1367            "fetch_refs_from_hashtree: downloading root {}",
1368            &root_hash[..12]
1369        );
1370
1371        // Download root directory from Blossom - propagate errors properly
1372        let root_data = match self.blossom.download(root_hash).await {
1373            Ok(data) => {
1374                debug!("Downloaded {} bytes from blossom", data.len());
1375                data
1376            }
1377            Err(e) => {
1378                anyhow::bail!(
1379                    "Failed to download root hash {}: {}",
1380                    &root_hash[..12.min(root_hash.len())],
1381                    e
1382                );
1383            }
1384        };
1385
1386        // Parse root as directory node (decrypt if needed)
1387        let root_node = self
1388            .decrypt_and_decode(&root_data, encryption_key)
1389            .with_context(|| {
1390                format!(
1391                    "Failed to decode root node {} (encrypted: {})",
1392                    &root_hash[..12.min(root_hash.len())],
1393                    encryption_key.is_some()
1394                )
1395            })?;
1396        debug!("Decoded root node with {} links", root_node.links.len());
1397
1398        // Find .git directory
1399        debug!(
1400            "Root links: {:?}",
1401            root_node
1402                .links
1403                .iter()
1404                .map(|l| l.name.as_deref())
1405                .collect::<Vec<_>>()
1406        );
1407        let git_link = root_node
1408            .links
1409            .iter()
1410            .find(|l| l.name.as_deref() == Some(".git"));
1411        let (git_hash, git_key) = match git_link {
1412            Some(link) => {
1413                debug!("Found .git link with key: {}", link.key.is_some());
1414                (hex::encode(link.hash), link.key)
1415            }
1416            None => {
1417                debug!("No .git directory in hashtree root");
1418                return Ok(refs);
1419            }
1420        };
1421
1422        // Download .git directory
1423        let git_data = match self.blossom.download(&git_hash).await {
1424            Ok(data) => data,
1425            Err(e) => {
1426                anyhow::bail!(
1427                    "Failed to download .git directory ({}): {}",
1428                    &git_hash[..12],
1429                    e
1430                );
1431            }
1432        };
1433
1434        let git_node = self
1435            .decrypt_and_decode(&git_data, git_key.as_ref())
1436            .with_context(|| {
1437                format!(
1438                    "Failed to decode .git directory {} (encrypted: {})",
1439                    &git_hash[..12.min(git_hash.len())],
1440                    git_key.is_some()
1441                )
1442            })?;
1443        debug!(
1444            "Decoded .git node with {} links: {:?}",
1445            git_node.links.len(),
1446            git_node
1447                .links
1448                .iter()
1449                .map(|l| l.name.as_deref())
1450                .collect::<Vec<_>>()
1451        );
1452
1453        // Find refs directory
1454        let refs_link = git_node
1455            .links
1456            .iter()
1457            .find(|l| l.name.as_deref() == Some("refs"));
1458        let (refs_hash, refs_key) = match refs_link {
1459            Some(link) => (hex::encode(link.hash), link.key),
1460            None => {
1461                debug!("No refs directory in .git");
1462                return Ok(refs);
1463            }
1464        };
1465
1466        // Download refs directory
1467        let refs_data =
1468            self.blossom.download(&refs_hash).await.with_context(|| {
1469                format!("Failed to download refs directory {}", &refs_hash[..12])
1470            })?;
1471
1472        let refs_node = self
1473            .decrypt_and_decode(&refs_data, refs_key.as_ref())
1474            .with_context(|| {
1475                format!(
1476                    "Failed to decode refs directory {} (encrypted: {})",
1477                    &refs_hash[..12.min(refs_hash.len())],
1478                    refs_key.is_some()
1479                )
1480            })?;
1481
1482        // Look for HEAD in .git directory
1483        if let Some(head_link) = git_node
1484            .links
1485            .iter()
1486            .find(|l| l.name.as_deref() == Some("HEAD"))
1487        {
1488            let head_hash = hex::encode(head_link.hash);
1489            let head_data = self
1490                .blossom
1491                .download(&head_hash)
1492                .await
1493                .with_context(|| format!("Failed to download HEAD {}", &head_hash[..12]))?;
1494            // HEAD is a blob, decrypt if needed
1495            let head_content = if let Some(k) = head_link.key.as_ref() {
1496                let decrypted = decrypt_chk(&head_data, k)
1497                    .with_context(|| format!("Failed to decrypt HEAD {}", &head_hash[..12]))?;
1498                String::from_utf8_lossy(&decrypted).trim().to_string()
1499            } else {
1500                String::from_utf8_lossy(&head_data).trim().to_string()
1501            };
1502            refs.insert("HEAD".to_string(), head_content);
1503        }
1504
1505        // Recursively walk refs/ subdirectories (heads, tags, etc.)
1506        for subdir_link in &refs_node.links {
1507            if subdir_link.link_type != LinkType::Dir {
1508                continue;
1509            }
1510            let subdir_name = match &subdir_link.name {
1511                Some(n) => n.clone(),
1512                None => continue,
1513            };
1514            let subdir_hash = hex::encode(subdir_link.hash);
1515
1516            self.collect_refs_recursive(
1517                &subdir_hash,
1518                subdir_link.key.as_ref(),
1519                &format!("refs/{}", subdir_name),
1520                &mut refs,
1521            )
1522            .await?;
1523        }
1524
1525        debug!("Found {} refs from hashtree", refs.len());
1526        Ok(refs)
1527    }
1528
1529    /// Recursively collect refs from a directory
1530    async fn collect_refs_recursive(
1531        &self,
1532        dir_hash: &str,
1533        dir_key: Option<&[u8; 32]>,
1534        prefix: &str,
1535        refs: &mut HashMap<String, String>,
1536    ) -> Result<()> {
1537        let dir_data = self
1538            .blossom
1539            .download(dir_hash)
1540            .await
1541            .with_context(|| format!("Failed to download refs subtree {}", &dir_hash[..12]))?;
1542
1543        let dir_node = self
1544            .decrypt_and_decode(&dir_data, dir_key)
1545            .with_context(|| {
1546                format!(
1547                    "Failed to decode refs subtree {} (encrypted: {})",
1548                    &dir_hash[..12.min(dir_hash.len())],
1549                    dir_key.is_some()
1550                )
1551            })?;
1552
1553        for link in &dir_node.links {
1554            let name = match &link.name {
1555                Some(n) => n.clone(),
1556                None => continue,
1557            };
1558            let link_hash = hex::encode(link.hash);
1559            let ref_path = format!("{}/{}", prefix, name);
1560
1561            if link.link_type == LinkType::Dir {
1562                // Recurse into subdirectory
1563                Box::pin(self.collect_refs_recursive(
1564                    &link_hash,
1565                    link.key.as_ref(),
1566                    &ref_path,
1567                    refs,
1568                ))
1569                .await?;
1570            } else {
1571                // This is a ref file - read the SHA
1572                let ref_data = self
1573                    .blossom
1574                    .download(&link_hash)
1575                    .await
1576                    .with_context(|| format!("Failed to download ref {}", ref_path))?;
1577                // Decrypt if needed
1578                let sha = if let Some(k) = link.key.as_ref() {
1579                    let decrypted = decrypt_chk(&ref_data, k)
1580                        .with_context(|| format!("Failed to decrypt ref {}", ref_path))?;
1581                    String::from_utf8_lossy(&decrypted).trim().to_string()
1582                } else {
1583                    String::from_utf8_lossy(&ref_data).trim().to_string()
1584                };
1585                if !sha.is_empty() {
1586                    debug!("Found ref {} -> {}", ref_path, sha);
1587                    refs.insert(ref_path, sha);
1588                }
1589            }
1590        }
1591
1592        Ok(())
1593    }
1594
1595    /// Update a ref in local cache (will be published with publish_repo)
1596    #[allow(dead_code)]
1597    pub fn update_ref(&mut self, repo_name: &str, ref_name: &str, sha: &str) -> Result<()> {
1598        info!("Updating ref {} -> {} for {}", ref_name, sha, repo_name);
1599
1600        let refs = self.cached_refs.entry(repo_name.to_string()).or_default();
1601        refs.insert(ref_name.to_string(), sha.to_string());
1602
1603        Ok(())
1604    }
1605
1606    /// Delete a ref from local cache
1607    pub fn delete_ref(&mut self, repo_name: &str, ref_name: &str) -> Result<()> {
1608        info!("Deleting ref {} for {}", ref_name, repo_name);
1609
1610        if let Some(refs) = self.cached_refs.get_mut(repo_name) {
1611            refs.remove(ref_name);
1612        }
1613
1614        Ok(())
1615    }
1616
1617    /// Get cached root hash for a repository
1618    pub fn get_cached_root_hash(&self, repo_name: &str) -> Option<&String> {
1619        self.cached_root_hash.get(repo_name)
1620    }
1621
1622    /// Get cached encryption key for a repository
1623    pub fn get_cached_encryption_key(&self, repo_name: &str) -> Option<&[u8; 32]> {
1624        self.cached_encryption_key.get(repo_name)
1625    }
1626
1627    pub(crate) fn cached_root_is_from_local_daemon(&self, repo_name: &str) -> bool {
1628        self.cached_root_source.get(repo_name) == Some(&RootResolveSource::LocalDaemon)
1629    }
1630
1631    pub(crate) fn local_daemon_only(&self) -> bool {
1632        self.local_daemon_only
1633    }
1634
1635    /// Get the Blossom client for direct downloads
1636    pub fn blossom(&self) -> &BlossomClient {
1637        &self.blossom
1638    }
1639
1640    /// Get the configured relay URLs
1641    pub fn relay_urls(&self) -> Vec<String> {
1642        self.relays.clone()
1643    }
1644
1645    /// Get the public key (hex)
1646    #[allow(dead_code)]
1647    pub fn pubkey(&self) -> &str {
1648        &self.pubkey
1649    }
1650
1651    /// Get the public key as npub bech32
1652    pub fn npub(&self) -> String {
1653        PublicKey::from_hex(&self.pubkey)
1654            .ok()
1655            .and_then(|pk| pk.to_bech32().ok())
1656            .unwrap_or_else(|| self.pubkey.clone())
1657    }
1658
1659    /// Publish repository to nostr as kind 30064 event
1660    /// Format:
1661    ///   kind: 30064
1662    ///   tags: [["d", repo_name], ["l", "hashtree"], ["hash", root_hash], ["key"|"encryptedKey", encryption_key]]
1663    ///   content: <merkle-root-hash>
1664    /// Returns: (npub URL, relay result with connected/failed details)
1665    /// If is_private is true, uses "encryptedKey" tag (XOR masked); otherwise uses "key" tag (plaintext CHK)
1666    pub fn publish_repo(
1667        &self,
1668        repo_name: &str,
1669        root_hash: &str,
1670        encryption_key: Option<(&[u8; 32], bool, bool)>,
1671    ) -> Result<(String, RelayResult)> {
1672        self.publish_repo_with_announcement(repo_name, root_hash, encryption_key, None)
1673    }
1674
1675    pub fn publish_repo_with_announcement(
1676        &self,
1677        repo_name: &str,
1678        root_hash: &str,
1679        encryption_key: Option<(&[u8; 32], bool, bool)>,
1680        repo_announcement: Option<RepoAnnouncementOptions>,
1681    ) -> Result<(String, RelayResult)> {
1682        let keys = self.keys.as_ref().context(format!(
1683            "Cannot push: no secret key for {}. You can only push to your own repos.",
1684            &self.pubkey[..16]
1685        ))?;
1686
1687        info!(
1688            "Publishing repo {} with root hash {} (encrypted: {})",
1689            repo_name,
1690            root_hash,
1691            encryption_key.is_some()
1692        );
1693
1694        // Create a new multi-threaded runtime for nostr-sdk which spawns background tasks
1695        block_on_result(async {
1696            tokio::time::timeout(
1697                REPO_PUBLISH_TIMEOUT,
1698                self.publish_repo_async(
1699                    keys,
1700                    repo_name,
1701                    root_hash,
1702                    encryption_key,
1703                    repo_announcement,
1704                ),
1705            )
1706            .await
1707            .map_err(|_| anyhow::anyhow!("Repository metadata publication timed out"))?
1708        })
1709    }
1710
1711    async fn publish_repo_async(
1712        &self,
1713        keys: &Keys,
1714        repo_name: &str,
1715        root_hash: &str,
1716        encryption_key: Option<(&[u8; 32], bool, bool)>,
1717        repo_announcement: Option<RepoAnnouncementOptions>,
1718    ) -> Result<(String, RelayResult)> {
1719        if self.local_daemon_only {
1720            return self
1721                .publish_repo_to_local_daemon(keys, repo_name, root_hash, encryption_key)
1722                .await;
1723        }
1724
1725        // Create nostr-sdk client with our keys
1726        let client = Client::new(keys.clone());
1727
1728        let configured: Vec<String> = self.relays.clone();
1729        let mut connected: Vec<String> = Vec::new();
1730        let mut failed: Vec<String> = Vec::new();
1731
1732        // Add relays
1733        for relay in &self.relays {
1734            if let Err(e) = client.add_relay(relay).await {
1735                warn!("Failed to add relay {}: {}", relay, e);
1736                failed.push(relay.clone());
1737            }
1738        }
1739
1740        // Connect to relays - this starts async connection in background
1741        client.connect().await;
1742
1743        // Wait for at least one relay to connect (same pattern as fetch)
1744        let _ = wait_for_any_connected_relay(&client, Duration::from_secs(3)).await;
1745
1746        let publish_created_at = next_replaceable_created_at(
1747            Timestamp::now(),
1748            latest_repo_event_created_at(
1749                &client,
1750                keys.public_key(),
1751                repo_name,
1752                Duration::from_secs(2),
1753            )
1754            .await,
1755        );
1756
1757        // Build event with tags
1758        let mut tags = vec![
1759            Tag::custom(TagKind::custom("d"), vec![repo_name.to_string()]),
1760            Tag::custom(TagKind::custom("l"), vec![LABEL_HASHTREE.to_string()]),
1761            Tag::custom(TagKind::custom("hash"), vec![root_hash.to_string()]),
1762        ];
1763
1764        // Add encryption key if present (required for decryption)
1765        // Key modes:
1766        // - selfEncryptedKey: NIP-44 encrypted to self (author-only private)
1767        // - encryptedKey: XOR masked with URL secret (link-visible)
1768        // - key: plaintext CHK (public)
1769        if let Some((key, is_link_visible, is_self_private)) = encryption_key {
1770            if is_self_private {
1771                // NIP-44 encrypt to self
1772                let pubkey = keys.public_key();
1773                let key_hex = hex::encode(key);
1774                let encrypted =
1775                    nip44::encrypt(keys.secret_key(), &pubkey, &key_hex, nip44::Version::V2)
1776                        .map_err(|e| anyhow::anyhow!("NIP-44 encryption failed: {}", e))?;
1777                tags.push(Tag::custom(
1778                    TagKind::custom("selfEncryptedKey"),
1779                    vec![encrypted],
1780                ));
1781            } else if is_link_visible {
1782                // XOR masked key
1783                tags.push(Tag::custom(
1784                    TagKind::custom("encryptedKey"),
1785                    vec![hex::encode(key)],
1786                ));
1787            } else {
1788                // Public: plaintext CHK
1789                tags.push(Tag::custom(TagKind::custom("key"), vec![hex::encode(key)]));
1790            }
1791        }
1792
1793        append_repo_discovery_labels(&mut tags, repo_name);
1794
1795        // Sign the event
1796        let event = EventBuilder::new(Kind::Custom(KIND_HASHTREE_ROOT), root_hash)
1797            .tags(tags)
1798            .custom_created_at(publish_created_at)
1799            .sign_with_keys(keys)
1800            .map_err(|e| anyhow::anyhow!("Failed to sign event: {}", e))?;
1801
1802        let ready_relays = client
1803            .relays()
1804            .await
1805            .into_iter()
1806            .filter_map(|(url, relay)| relay.is_connected().then_some(url))
1807            .collect::<Vec<_>>();
1808
1809        // Send only to relays that connected within the bounded wait above.
1810        match client.send_event_to(ready_relays, &event).await {
1811            Ok(output) => {
1812                // Track which relays confirmed
1813                for url in output.success.iter() {
1814                    let url_str = url.to_string();
1815                    if !connected.contains(&url_str) {
1816                        connected.push(url_str);
1817                    }
1818                }
1819                // Only mark as failed if we got explicit rejection
1820                for (url, _err) in output.failed.iter() {
1821                    let url_str = url.to_string();
1822                    if !failed.contains(&url_str) && !connected.contains(&url_str) {
1823                        failed.push(url_str);
1824                    }
1825                }
1826                info!(
1827                    "Sent event {} to {} relays ({} failed)",
1828                    output.id(),
1829                    output.success.len(),
1830                    output.failed.len()
1831                );
1832            }
1833            Err(e) => {
1834                warn!("Failed to send event: {}", e);
1835                // Mark all as failed
1836                for relay in &self.relays {
1837                    if !failed.contains(relay) {
1838                        failed.push(relay.clone());
1839                    }
1840                }
1841            }
1842        };
1843
1844        // Build the full htree:// URL with npub
1845        let npub_url = keys
1846            .public_key()
1847            .to_bech32()
1848            .map(|npub| format!("htree://{}/{}", npub, repo_name))
1849            .unwrap_or_else(|_| format!("htree://{}/{}", &self.pubkey[..16], repo_name));
1850
1851        let relay_validation = validate_repo_publish_relays(&configured, &connected);
1852
1853        if relay_validation.is_ok() {
1854            if let Some(repo_announcement) = repo_announcement.as_ref() {
1855                if let Err(err) = self
1856                    .publish_repo_announcement_async(
1857                        &client,
1858                        keys,
1859                        repo_name,
1860                        &npub_url,
1861                        repo_announcement,
1862                    )
1863                    .await
1864                {
1865                    warn!("Failed to publish NIP-34 repo announcement: {}", err);
1866                }
1867            }
1868        }
1869
1870        // Disconnect and give time for cleanup
1871        let _ = client.disconnect().await;
1872        tokio::time::sleep(Duration::from_millis(50)).await;
1873
1874        relay_validation?;
1875
1876        self.cache_public_root_in_local_daemon(repo_name, root_hash, encryption_key)
1877            .await;
1878
1879        Ok((
1880            npub_url,
1881            RelayResult {
1882                configured,
1883                connected,
1884                failed,
1885            },
1886        ))
1887    }
1888
1889    async fn publish_repo_to_local_daemon(
1890        &self,
1891        keys: &Keys,
1892        repo_name: &str,
1893        root_hash: &str,
1894        encryption_key: Option<(&[u8; 32], bool, bool)>,
1895    ) -> Result<(String, RelayResult)> {
1896        let existing_created_at = self
1897            .fetch_root_from_local_daemon(repo_name, Duration::from_secs(2))
1898            .await
1899            .and_then(|root| root.event_created_at)
1900            .map(Timestamp::from_secs);
1901        let mut tags = vec![
1902            Tag::custom(TagKind::custom("d"), vec![repo_name.to_string()]),
1903            Tag::custom(TagKind::custom("l"), vec![LABEL_HASHTREE.to_string()]),
1904            Tag::custom(TagKind::custom("hash"), vec![root_hash.to_string()]),
1905        ];
1906        if let Some((key, is_link_visible, is_self_private)) = encryption_key {
1907            if is_self_private {
1908                let key_hex = hex::encode(key);
1909                let encrypted = nip44::encrypt(
1910                    keys.secret_key(),
1911                    &keys.public_key(),
1912                    &key_hex,
1913                    nip44::Version::V2,
1914                )
1915                .map_err(|error| anyhow::anyhow!("NIP-44 encryption failed: {error}"))?;
1916                tags.push(Tag::custom(
1917                    TagKind::custom("selfEncryptedKey"),
1918                    vec![encrypted],
1919                ));
1920            } else if is_link_visible {
1921                tags.push(Tag::custom(
1922                    TagKind::custom("encryptedKey"),
1923                    vec![hex::encode(key)],
1924                ));
1925            } else {
1926                tags.push(Tag::custom(TagKind::custom("key"), vec![hex::encode(key)]));
1927            }
1928        }
1929        append_repo_discovery_labels(&mut tags, repo_name);
1930        let event = EventBuilder::new(Kind::Custom(KIND_HASHTREE_ROOT), root_hash)
1931            .tags(tags)
1932            .custom_created_at(next_replaceable_created_at(
1933                Timestamp::now(),
1934                existing_created_at,
1935            ))
1936            .sign_with_keys(keys)
1937            .map_err(|error| anyhow::anyhow!("Failed to sign event: {error}"))?;
1938        self.publish_event_to_local_daemon(&event).await?;
1939
1940        let npub_url = keys
1941            .public_key()
1942            .to_bech32()
1943            .map(|npub| format!("htree://{npub}/{repo_name}"))
1944            .unwrap_or_else(|_| format!("htree://{}/{repo_name}", &self.pubkey[..16]));
1945        Ok((
1946            npub_url,
1947            RelayResult {
1948                configured: Vec::new(),
1949                connected: Vec::new(),
1950                failed: Vec::new(),
1951            },
1952        ))
1953    }
1954
1955    async fn publish_event_to_local_daemon(&self, event: &Event) -> Result<()> {
1956        let base = self.local_daemon_url.as_ref().ok_or_else(|| {
1957            anyhow::anyhow!("local-daemon-only publication requires a running htree daemon")
1958        })?;
1959        let response = reqwest::Client::builder()
1960            .timeout(Duration::from_secs(4))
1961            .build()?
1962            .post(format!("{}/api/nostr/events", base.trim_end_matches('/')))
1963            .json(event)
1964            .send()
1965            .await
1966            .context("local-daemon-only Nostr publication failed")?;
1967        if !response.status().is_success() {
1968            let status = response.status();
1969            let detail = response.text().await.unwrap_or_default();
1970            anyhow::bail!(
1971                "local-daemon-only Nostr publication failed with {status}: {detail}; relay fallback disabled"
1972            );
1973        }
1974        Ok(())
1975    }
1976
1977    fn build_repo_announcement_tags(
1978        repo_name: &str,
1979        clone_url: &str,
1980        relays: &[String],
1981        options: &RepoAnnouncementOptions,
1982    ) -> Vec<Tag> {
1983        let mut tags = vec![
1984            Tag::custom(TagKind::custom("d"), vec![repo_name.to_string()]),
1985            Tag::custom(TagKind::custom("name"), vec![repo_name.to_string()]),
1986            Tag::custom(TagKind::custom("clone"), vec![clone_url.to_string()]),
1987        ];
1988
1989        if let Some(web_url) = Self::iris_git_web_url_for_htree_clone(clone_url) {
1990            tags.push(Tag::custom(TagKind::custom("web"), vec![web_url]));
1991        }
1992
1993        if !relays.is_empty() {
1994            tags.push(Tag::custom(TagKind::custom("relays"), relays.to_vec()));
1995        }
1996
1997        if let Some(euc) = options
1998            .earliest_unique_commit
1999            .as_deref()
2000            .map(str::trim)
2001            .filter(|value| !value.is_empty())
2002        {
2003            tags.push(Tag::custom(
2004                TagKind::custom("r"),
2005                vec![euc.to_string(), "euc".to_string()],
2006            ));
2007        }
2008
2009        if options.personal_fork {
2010            tags.push(Tag::custom(
2011                TagKind::custom("t"),
2012                vec!["personal-fork".to_string()],
2013            ));
2014        }
2015
2016        if let Some(forked_from) = options
2017            .forked_from
2018            .as_deref()
2019            .map(str::trim)
2020            .filter(|value| !value.is_empty())
2021        {
2022            tags.push(Tag::custom(
2023                TagKind::custom("forked-from"),
2024                vec![forked_from.to_string()],
2025            ));
2026        }
2027
2028        tags
2029    }
2030
2031    fn iris_git_web_url_for_htree_clone(clone_url: &str) -> Option<String> {
2032        let raw = clone_url.strip_prefix("htree://")?;
2033        let path = raw.split('#').next().unwrap_or(raw);
2034        let (owner, repo_path) = path.split_once('/')?;
2035        if !owner.starts_with("npub1") || repo_path.is_empty() {
2036            return None;
2037        }
2038
2039        let path = std::iter::once(owner)
2040            .chain(repo_path.split('/').filter(|segment| !segment.is_empty()))
2041            .map(Self::percent_encode_path_segment)
2042            .collect::<Vec<_>>()
2043            .join("/");
2044
2045        Some(format!("{IRIS_GIT_WEB_BASE_URL}/{path}"))
2046    }
2047
2048    fn percent_encode_path_segment(segment: &str) -> String {
2049        let mut encoded = String::with_capacity(segment.len());
2050        for byte in segment.bytes() {
2051            match byte {
2052                b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
2053                    encoded.push(byte as char)
2054                }
2055                _ => encoded.push_str(&format!("%{byte:02X}")),
2056            }
2057        }
2058        encoded
2059    }
2060
2061    async fn publish_repo_announcement_async(
2062        &self,
2063        client: &Client,
2064        keys: &Keys,
2065        repo_name: &str,
2066        clone_url: &str,
2067        options: &RepoAnnouncementOptions,
2068    ) -> Result<()> {
2069        let created_at = next_replaceable_created_at(
2070            Timestamp::now(),
2071            latest_repo_announcement_created_at(
2072                client,
2073                keys.public_key(),
2074                repo_name,
2075                Duration::from_secs(2),
2076            )
2077            .await,
2078        );
2079        let tags = Self::build_repo_announcement_tags(repo_name, clone_url, &self.relays, options);
2080        let event = EventBuilder::new(Kind::Custom(KIND_REPO_ANNOUNCEMENT), "")
2081            .tags(tags)
2082            .custom_created_at(created_at)
2083            .sign_with_keys(keys)
2084            .map_err(|e| anyhow::anyhow!("Failed to sign NIP-34 repo announcement: {}", e))?;
2085
2086        let output = client
2087            .send_event(&event)
2088            .await
2089            .map_err(|e| anyhow::anyhow!("Failed to publish NIP-34 repo announcement: {}", e))?;
2090        if output.success.is_empty() {
2091            anyhow::bail!("NIP-34 repo announcement was not confirmed by any relay");
2092        }
2093
2094        info!(
2095            "Published NIP-34 repo announcement {} to {} relays",
2096            output.id(),
2097            output.success.len()
2098        );
2099        Ok(())
2100    }
2101
2102    pub fn fetch_repo_announcement_euc(
2103        &self,
2104        owner_pubkey_hex: &str,
2105        repo_name: &str,
2106    ) -> Result<Option<String>> {
2107        block_on_result(self.fetch_repo_announcement_euc_async(owner_pubkey_hex, repo_name))
2108    }
2109
2110    async fn fetch_repo_announcement_euc_async(
2111        &self,
2112        owner_pubkey_hex: &str,
2113        repo_name: &str,
2114    ) -> Result<Option<String>> {
2115        let owner = PublicKey::from_hex(owner_pubkey_hex)
2116            .map_err(|e| anyhow::anyhow!("Invalid repo owner pubkey: {}", e))?;
2117        let client = Client::default();
2118
2119        for relay in &self.relays {
2120            if let Err(e) = client.add_relay(relay).await {
2121                debug!(
2122                    "Failed to add relay {} for repo announcement lookup: {}",
2123                    relay, e
2124                );
2125            }
2126        }
2127        client.connect().await;
2128
2129        if !wait_for_any_connected_relay(&client, Duration::from_secs(2)).await {
2130            let _ = client.disconnect().await;
2131            return Ok(None);
2132        }
2133
2134        let filter = build_repo_announcement_filter(owner, repo_name);
2135        let events = match tokio::time::timeout(
2136            Duration::from_secs(3),
2137            client.fetch_events(filter, Duration::from_secs(3)),
2138        )
2139        .await
2140        {
2141            Ok(Ok(events)) => events.to_vec(),
2142            Ok(Err(err)) => {
2143                debug!(
2144                    "Failed to fetch NIP-34 repo announcement for {}: {}",
2145                    repo_name, err
2146                );
2147                Vec::new()
2148            }
2149            Err(_) => {
2150                debug!(
2151                    "Timed out fetching NIP-34 repo announcement for {}",
2152                    repo_name
2153                );
2154                Vec::new()
2155            }
2156        };
2157
2158        let _ = client.disconnect().await;
2159        Ok(pick_latest_event(events.iter()).and_then(extract_repo_announcement_euc))
2160    }
2161
2162    /// Fetch pull requests targeting this repo, filtered by state.
2163    pub fn fetch_prs(
2164        &self,
2165        repo_name: &str,
2166        state_filter: PullRequestStateFilter,
2167    ) -> Result<Vec<PullRequestListItem>> {
2168        block_on_result(self.fetch_prs_async(repo_name, state_filter))
2169    }
2170
2171    pub async fn fetch_prs_async(
2172        &self,
2173        repo_name: &str,
2174        state_filter: PullRequestStateFilter,
2175    ) -> Result<Vec<PullRequestListItem>> {
2176        let client = Client::default();
2177
2178        for relay in &self.relays {
2179            if let Err(e) = client.add_relay(relay).await {
2180                warn!("Failed to add relay {}: {}", relay, e);
2181            }
2182        }
2183        client.connect().await;
2184
2185        // Wait for at least one relay (quick timeout)
2186        if !wait_for_any_connected_relay(&client, Duration::from_secs(2)).await {
2187            let _ = client.disconnect().await;
2188            return Err(anyhow::anyhow!(
2189                "Failed to connect to any relay while fetching PRs"
2190            ));
2191        }
2192
2193        // Query for kind 1618 PRs targeting this repo
2194        let repo_address = format!("{}:{}:{}", KIND_REPO_ANNOUNCEMENT, self.pubkey, repo_name);
2195        let pull_request_filter = Filter::new()
2196            .kind(Kind::Custom(KIND_PULL_REQUEST))
2197            .custom_tag(
2198                SingleLetterTag::lowercase(Alphabet::A),
2199                repo_address.clone(),
2200            );
2201
2202        let mut pr_events = match tokio::time::timeout(
2203            Duration::from_secs(3),
2204            client.fetch_events(pull_request_filter.clone(), Duration::from_secs(3)),
2205        )
2206        .await
2207        {
2208            Ok(Ok(events)) => events.to_vec(),
2209            Ok(Err(e)) => {
2210                let _ = client.disconnect().await;
2211                return Err(anyhow::anyhow!(
2212                    "Failed to fetch PR events from relays: {}",
2213                    e
2214                ));
2215            }
2216            Err(_) => {
2217                let _ = client.disconnect().await;
2218                return Err(anyhow::anyhow!("Timed out fetching PR events from relays"));
2219            }
2220        };
2221
2222        if pr_events.is_empty() {
2223            let fallback_events = fetch_events_via_raw_relay_query(
2224                &self.relays,
2225                pull_request_filter,
2226                Duration::from_secs(3),
2227            )
2228            .await;
2229            if !fallback_events.is_empty() {
2230                debug!(
2231                    "Raw relay fallback recovered {} PR event(s) for {}",
2232                    fallback_events.len(),
2233                    repo_name
2234                );
2235                pr_events = fallback_events;
2236            }
2237        }
2238
2239        if pr_events.is_empty() {
2240            let _ = client.disconnect().await;
2241            return Ok(Vec::new());
2242        }
2243
2244        // Collect PR event IDs for status query
2245        let pr_ids: Vec<String> = pr_events.iter().map(|e| e.id.to_hex()).collect();
2246
2247        // Query for status events referencing these PRs
2248        let status_event_filter = Filter::new()
2249            .kinds(vec![
2250                Kind::Custom(KIND_STATUS_OPEN),
2251                Kind::Custom(KIND_STATUS_APPLIED),
2252                Kind::Custom(KIND_STATUS_CLOSED),
2253                Kind::Custom(KIND_STATUS_DRAFT),
2254            ])
2255            .custom_tags(
2256                SingleLetterTag::lowercase(Alphabet::E),
2257                pr_ids.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
2258            );
2259
2260        let mut status_events = match tokio::time::timeout(
2261            Duration::from_secs(3),
2262            client.fetch_events(status_event_filter.clone(), Duration::from_secs(3)),
2263        )
2264        .await
2265        {
2266            Ok(Ok(events)) => events.to_vec(),
2267            Ok(Err(e)) => {
2268                let _ = client.disconnect().await;
2269                return Err(anyhow::anyhow!(
2270                    "Failed to fetch PR status events from relays: {}",
2271                    e
2272                ));
2273            }
2274            Err(_) => {
2275                let _ = client.disconnect().await;
2276                return Err(anyhow::anyhow!(
2277                    "Timed out fetching PR status events from relays"
2278                ));
2279            }
2280        };
2281
2282        if status_events.is_empty() {
2283            let fallback_events = fetch_events_via_raw_relay_query(
2284                &self.relays,
2285                status_event_filter,
2286                Duration::from_secs(3),
2287            )
2288            .await;
2289            if !fallback_events.is_empty() {
2290                debug!(
2291                    "Raw relay fallback recovered {} PR status event(s) for {}",
2292                    fallback_events.len(),
2293                    repo_name
2294                );
2295                status_events = fallback_events;
2296            }
2297        }
2298
2299        let _ = client.disconnect().await;
2300
2301        // Build map: pr_event_id -> latest trusted status kind
2302        let latest_status =
2303            latest_trusted_pr_status_kinds(&pr_events, &status_events, &self.pubkey);
2304
2305        let mut prs = Vec::new();
2306        for event in &pr_events {
2307            let pr_id = event.id.to_hex();
2308            let state =
2309                PullRequestState::from_latest_status_kind(latest_status.get(&pr_id).copied());
2310            if !state_filter.includes(state) {
2311                continue;
2312            }
2313
2314            let mut subject = None;
2315            let mut commit_tip = None;
2316            let mut branch = None;
2317            let mut target_branch = None;
2318
2319            for tag in event.tags.iter() {
2320                let slice = tag.as_slice();
2321                if slice.len() >= 2 {
2322                    match slice[0].as_str() {
2323                        "subject" => subject = Some(slice[1].to_string()),
2324                        "c" => commit_tip = Some(slice[1].to_string()),
2325                        "branch" => branch = Some(slice[1].to_string()),
2326                        "target-branch" => target_branch = Some(slice[1].to_string()),
2327                        _ => {}
2328                    }
2329                }
2330            }
2331
2332            prs.push(PullRequestListItem {
2333                event_id: pr_id,
2334                author_pubkey: event.pubkey.to_hex(),
2335                state,
2336                subject,
2337                commit_tip,
2338                branch,
2339                target_branch,
2340                created_at: event.created_at.as_secs(),
2341            });
2342        }
2343
2344        // Newest first; tie-break by event id for deterministic output.
2345        prs.sort_by(|left, right| {
2346            right
2347                .created_at
2348                .cmp(&left.created_at)
2349                .then_with(|| right.event_id.cmp(&left.event_id))
2350        });
2351
2352        debug!(
2353            "Found {} PRs for {} (filter: {:?})",
2354            prs.len(),
2355            repo_name,
2356            state_filter
2357        );
2358        Ok(prs)
2359    }
2360
2361    /// Publish a kind 1631 (STATUS_APPLIED) event to mark a PR as merged
2362    pub fn publish_pr_merged_status(
2363        &self,
2364        pr_event_id: &str,
2365        pr_author_pubkey: &str,
2366    ) -> Result<()> {
2367        let keys = self
2368            .keys
2369            .as_ref()
2370            .context("Cannot publish status: no secret key")?;
2371
2372        block_on_result(self.publish_pr_merged_status_async(keys, pr_event_id, pr_author_pubkey))
2373    }
2374
2375    async fn publish_pr_merged_status_async(
2376        &self,
2377        keys: &Keys,
2378        pr_event_id: &str,
2379        pr_author_pubkey: &str,
2380    ) -> Result<()> {
2381        let client = Client::new(keys.clone());
2382
2383        for relay in &self.relays {
2384            if let Err(e) = client.add_relay(relay).await {
2385                warn!("Failed to add relay {}: {}", relay, e);
2386            }
2387        }
2388        client.connect().await;
2389
2390        // Wait for at least one relay
2391        if !wait_for_any_connected_relay(&client, Duration::from_secs(3)).await {
2392            anyhow::bail!("Failed to connect to any relay for status publish");
2393        }
2394
2395        let tags = vec![
2396            Tag::custom(TagKind::custom("e"), vec![pr_event_id.to_string()]),
2397            Tag::custom(TagKind::custom("p"), vec![pr_author_pubkey.to_string()]),
2398        ];
2399
2400        let event = EventBuilder::new(Kind::Custom(KIND_STATUS_APPLIED), "")
2401            .tags(tags)
2402            .sign_with_keys(keys)
2403            .map_err(|e| anyhow::anyhow!("Failed to sign status event: {}", e))?;
2404
2405        let publish_result = match client.send_event(&event).await {
2406            Ok(output) => {
2407                if output.success.is_empty() {
2408                    Err(anyhow::anyhow!(
2409                        "PR merged status was not confirmed by any relay"
2410                    ))
2411                } else {
2412                    info!(
2413                        "Published PR merged status to {} relays",
2414                        output.success.len()
2415                    );
2416                    Ok(())
2417                }
2418            }
2419            Err(e) => Err(anyhow::anyhow!("Failed to publish PR merged status: {}", e)),
2420        };
2421
2422        let _ = client.disconnect().await;
2423        tokio::time::sleep(Duration::from_millis(50)).await;
2424        publish_result
2425    }
2426
2427    /// Upload blob to blossom server
2428    #[allow(dead_code)]
2429    pub async fn upload_blob(&self, _hash: &str, data: &[u8]) -> Result<String> {
2430        let hash = self
2431            .blossom
2432            .upload(data)
2433            .await
2434            .map_err(|e| anyhow::anyhow!("Blossom upload failed: {}", e))?;
2435        Ok(hash)
2436    }
2437
2438    /// Upload blob only if it doesn't exist
2439    #[allow(dead_code)]
2440    pub async fn upload_blob_if_missing(&self, data: &[u8]) -> Result<(String, bool)> {
2441        self.blossom
2442            .upload_if_missing(data)
2443            .await
2444            .map_err(|e| anyhow::anyhow!("Blossom upload failed: {}", e))
2445    }
2446
2447    /// Download blob from blossom server
2448    #[allow(dead_code)]
2449    pub async fn download_blob(&self, hash: &str) -> Result<Vec<u8>> {
2450        self.blossom
2451            .download(hash)
2452            .await
2453            .map_err(|e| anyhow::anyhow!("Blossom download failed: {}", e))
2454    }
2455
2456    /// Try to download blob, returns None if not found
2457    #[allow(dead_code)]
2458    pub async fn try_download_blob(&self, hash: &str) -> Option<Vec<u8>> {
2459        self.blossom.try_download(hash).await
2460    }
2461}
2462
2463#[cfg(test)]
2464mod tests;