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