Skip to main content

git_remote_htree/
nostr_client.rs

1//! Nostr client for publishing and fetching git repository references
2//!
3//! Uses kind 30078 (application-specific data) with hashtree structure:
4//! {
5//!   "kind": 30078,
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};
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};
64#[cfg(test)]
65use repo_metadata::pick_latest_event;
66use repo_metadata::{
67    append_repo_discovery_labels, build_git_repo_list_filter, build_repo_event_filter,
68    latest_repo_event_created_at, latest_trusted_pr_status_kinds, list_git_repo_announcements,
69    next_replaceable_created_at, pick_latest_repo_event, validate_repo_publish_relays,
70};
71
72/// Event kind for application-specific data (NIP-78)
73pub const KIND_APP_DATA: u16 = 30078;
74
75/// NIP-34 event kinds
76pub const KIND_PULL_REQUEST: u16 = 1618;
77pub const KIND_STATUS_OPEN: u16 = 1630;
78pub const KIND_STATUS_APPLIED: u16 = 1631;
79pub const KIND_STATUS_CLOSED: u16 = 1632;
80pub const KIND_STATUS_DRAFT: u16 = 1633;
81pub const KIND_REPO_ANNOUNCEMENT: u16 = 30617;
82
83/// Label for hashtree events
84pub const LABEL_HASHTREE: &str = "hashtree";
85pub const LABEL_GIT: &str = "git";
86
87/// Pull request status derived from trusted NIP-34 status events.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum PullRequestState {
90    Open,
91    Applied,
92    Closed,
93    Draft,
94}
95
96impl PullRequestState {
97    pub fn as_str(self) -> &'static str {
98        match self {
99            PullRequestState::Open => "open",
100            PullRequestState::Applied => "applied",
101            PullRequestState::Closed => "closed",
102            PullRequestState::Draft => "draft",
103        }
104    }
105
106    fn from_status_kind(status_kind: u16) -> Option<Self> {
107        match status_kind {
108            KIND_STATUS_OPEN => Some(PullRequestState::Open),
109            KIND_STATUS_APPLIED => Some(PullRequestState::Applied),
110            KIND_STATUS_CLOSED => Some(PullRequestState::Closed),
111            KIND_STATUS_DRAFT => Some(PullRequestState::Draft),
112            _ => None,
113        }
114    }
115
116    fn from_latest_status_kind(status_kind: Option<u16>) -> Self {
117        status_kind
118            .and_then(Self::from_status_kind)
119            .unwrap_or(PullRequestState::Open)
120    }
121}
122
123/// Filter used when listing PRs.
124#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
125pub enum PullRequestStateFilter {
126    #[default]
127    Open,
128    Applied,
129    Closed,
130    Draft,
131    All,
132}
133
134impl PullRequestStateFilter {
135    pub fn as_str(self) -> &'static str {
136        match self {
137            PullRequestStateFilter::Open => "open",
138            PullRequestStateFilter::Applied => "applied",
139            PullRequestStateFilter::Closed => "closed",
140            PullRequestStateFilter::Draft => "draft",
141            PullRequestStateFilter::All => "all",
142        }
143    }
144
145    fn includes(self, state: PullRequestState) -> bool {
146        match self {
147            PullRequestStateFilter::All => true,
148            PullRequestStateFilter::Open => state == PullRequestState::Open,
149            PullRequestStateFilter::Applied => state == PullRequestState::Applied,
150            PullRequestStateFilter::Closed => state == PullRequestState::Closed,
151            PullRequestStateFilter::Draft => state == PullRequestState::Draft,
152        }
153    }
154}
155
156/// PR metadata used by listing/filtering consumers.
157#[derive(Debug, Clone)]
158pub struct PullRequestListItem {
159    pub event_id: String,
160    pub author_pubkey: String,
161    pub state: PullRequestState,
162    pub subject: Option<String>,
163    pub commit_tip: Option<String>,
164    pub branch: Option<String>,
165    pub target_branch: Option<String>,
166    pub created_at: u64,
167}
168
169async fn fetch_events_via_raw_relay_query(
170    relays: &[String],
171    filter: Filter,
172    timeout: Duration,
173) -> Vec<Event> {
174    let request_json = ClientMessage::req(SubscriptionId::generate(), vec![filter]).as_json();
175    let mut events_by_id = HashMap::<String, Event>::new();
176
177    for relay_url in relays {
178        let relay_events = match tokio::time::timeout(timeout, async {
179            let (mut ws, _) = connect_async(relay_url).await?;
180            ws.send(WsMessage::Text(request_json.clone())).await?;
181
182            let mut relay_events = Vec::new();
183            while let Some(message) = ws.next().await {
184                let message = message?;
185                let WsMessage::Text(text) = message else {
186                    continue;
187                };
188
189                match RelayMessage::from_json(text.as_str()) {
190                    Ok(RelayMessage::Event { event, .. }) => relay_events.push(*event),
191                    Ok(RelayMessage::EndOfStoredEvents(_)) => break,
192                    Ok(RelayMessage::Closed { message, .. }) => {
193                        debug!("Raw relay PR query closed by {}: {}", relay_url, message);
194                        break;
195                    }
196                    Ok(_) => {}
197                    Err(err) => {
198                        debug!(
199                            "Failed to parse raw relay response from {}: {}",
200                            relay_url, err
201                        );
202                    }
203                }
204            }
205
206            let _ = ws.close(None).await;
207            Ok::<Vec<Event>, anyhow::Error>(relay_events)
208        })
209        .await
210        {
211            Ok(Ok(events)) => events,
212            Ok(Err(err)) => {
213                debug!("Raw relay PR query failed for {}: {}", relay_url, err);
214                continue;
215            }
216            Err(_) => {
217                debug!("Raw relay PR query timed out for {}", relay_url);
218                continue;
219            }
220        };
221
222        for event in relay_events {
223            events_by_id.insert(event.id.to_hex(), event);
224        }
225    }
226
227    events_by_id.into_values().collect()
228}
229
230async fn connected_relay_count(client: &Client) -> (usize, usize) {
231    let relays = client.relays().await;
232    let total = relays.len();
233    let mut connected = 0;
234    for relay in relays.values() {
235        if relay.is_connected().await {
236            connected += 1;
237        }
238    }
239    (connected, total)
240}
241
242async fn wait_for_any_connected_relay(client: &Client, timeout: Duration) -> bool {
243    let start = std::time::Instant::now();
244    loop {
245        if connected_relay_count(client).await.0 > 0 {
246            return true;
247        }
248        if start.elapsed() > timeout {
249            return false;
250        }
251        tokio::time::sleep(Duration::from_millis(50)).await;
252    }
253}
254
255type FetchedRefs = (HashMap<String, String>, Option<String>, Option<[u8; 32]>);
256use hashtree_config::Config;
257
258/// Result of publishing to relays
259#[derive(Debug, Clone)]
260pub struct RelayResult {
261    /// Relays that were configured
262    #[allow(dead_code)]
263    pub configured: Vec<String>,
264    /// Relays that connected
265    pub connected: Vec<String>,
266    /// Relays that failed to connect
267    pub failed: Vec<String>,
268}
269
270/// Result of uploading to blossom servers
271#[derive(Debug, Clone)]
272pub struct BlossomResult {
273    /// Servers that were configured
274    #[allow(dead_code)]
275    pub configured: Vec<String>,
276    /// Servers that accepted uploads
277    pub succeeded: Vec<String>,
278    /// Servers that failed
279    pub failed: Vec<String>,
280    /// Whether the repo tree is complete in the local hashtree store.
281    pub local_complete: bool,
282    /// Whether remote replication was incomplete but the local push can still be published.
283    pub degraded: bool,
284}
285
286/// Nostr client for git operations
287pub struct NostrClient {
288    pubkey: String,
289    /// nostr-sdk Keys for signing
290    keys: Option<Keys>,
291    relays: Vec<String>,
292    blossom: BlossomClient,
293    /// Cached refs from remote
294    cached_refs: HashMap<String, HashMap<String, String>>,
295    /// Cached root hashes (hashtree SHA256)
296    cached_root_hash: HashMap<String, String>,
297    /// Cached encryption keys
298    cached_encryption_key: HashMap<String, [u8; 32]>,
299    /// URL secret for link-visible repos (#k=<hex>)
300    /// If set, encryption keys from nostr are XOR-masked and need unmasking
301    url_secret: Option<[u8; 32]>,
302    /// Whether this is a private (author-only) repo using NIP-44 encryption
303    is_private: bool,
304    /// Local htree daemon URL for peer-assisted root discovery
305    local_daemon_url: Option<String>,
306}
307
308#[derive(Debug, Clone, Default)]
309struct RootEventData {
310    root_hash: String,
311    encryption_key: Option<[u8; 32]>,
312    key_tag_name: Option<String>,
313    self_encrypted_ciphertext: Option<String>,
314}
315
316#[derive(Debug, Deserialize)]
317struct DaemonResolveResponse {
318    hash: Option<String>,
319    #[serde(default)]
320    cid: Option<String>,
321    #[serde(default, rename = "key_tag")]
322    key: Option<String>,
323    #[serde(default, rename = "encryptedKey")]
324    encrypted_key: Option<String>,
325    #[serde(default, rename = "selfEncryptedKey")]
326    self_encrypted_key: Option<String>,
327    #[serde(default)]
328    source: Option<String>,
329}
330
331impl NostrClient {
332    /// Create a new client with pubkey, optional secret key, url secret, is_private flag, and config
333    pub fn new(
334        pubkey: &str,
335        secret_key: Option<String>,
336        url_secret: Option<[u8; 32]>,
337        is_private: bool,
338        config: &Config,
339    ) -> Result<Self> {
340        // Ensure rustls has a process-wide crypto provider even when used as a library (tests).
341        let _ = rustls::crypto::ring::default_provider().install_default();
342
343        // Use provided secret, or try environment variable
344        let secret_key = secret_key.or_else(|| std::env::var("NOSTR_SECRET_KEY").ok());
345
346        // Create nostr-sdk Keys if we have a secret
347        let keys = if let Some(ref secret_hex) = secret_key {
348            let secret_bytes = hex::decode(secret_hex).context("Invalid secret key hex")?;
349            let secret = nostr::SecretKey::from_slice(&secret_bytes)
350                .map_err(|e| anyhow::anyhow!("Invalid secret key: {}", e))?;
351            Some(Keys::new(secret))
352        } else {
353            None
354        };
355
356        // Create BlossomClient (needs keys for upload auth) from the resolved
357        // config passed by the helper instead of reloading defaults from disk.
358        let blossom_keys = keys.clone().unwrap_or_else(Keys::generate);
359        let mut read_servers = config.blossom.all_read_servers();
360        if let Some(local_url) =
361            hashtree_config::detect_local_daemon_url(Some(config.server.bind_address.as_str()))
362        {
363            if !read_servers.iter().any(|server| server == &local_url) {
364                read_servers.insert(0, local_url);
365            }
366        }
367        let blossom = BlossomClient::new_empty(blossom_keys)
368            .with_read_servers(read_servers)
369            .with_write_servers(config.blossom.all_write_servers())
370            .with_timeout(Duration::from_secs(30));
371
372        tracing::info!(
373            "BlossomClient created with read_servers: {:?}, write_servers: {:?}",
374            blossom.read_servers(),
375            blossom.write_servers()
376        );
377
378        let relays = hashtree_config::resolve_relays(
379            &config.nostr.relays,
380            Some(config.server.bind_address.as_str()),
381        );
382        let local_daemon_url =
383            hashtree_config::detect_local_daemon_url(Some(config.server.bind_address.as_str()))
384                .or_else(|| {
385                    config
386                        .blossom
387                        .read_servers
388                        .iter()
389                        .find(|url| {
390                            url.starts_with("http://127.0.0.1:")
391                                || url.starts_with("http://localhost:")
392                        })
393                        .cloned()
394                });
395
396        Ok(Self {
397            pubkey: pubkey.to_string(),
398            keys,
399            relays,
400            blossom,
401            cached_refs: HashMap::new(),
402            cached_root_hash: HashMap::new(),
403            cached_encryption_key: HashMap::new(),
404            url_secret,
405            is_private,
406            local_daemon_url,
407        })
408    }
409
410    fn format_repo_author(pubkey_hex: &str) -> String {
411        PublicKey::from_hex(pubkey_hex)
412            .ok()
413            .and_then(|pk| pk.to_bech32().ok())
414            .unwrap_or_else(|| pubkey_hex.to_string())
415    }
416
417    fn daemon_pubkey_identifier(&self) -> String {
418        Self::format_repo_author(&self.pubkey)
419    }
420
421    /// Check if we can sign (have secret key for this pubkey)
422    #[allow(dead_code)]
423    pub fn can_sign(&self) -> bool {
424        self.keys.is_some()
425    }
426
427    pub fn list_repos(&self) -> Result<Vec<String>> {
428        block_on_result(self.list_repos_async())
429    }
430
431    pub async fn list_repos_async(&self) -> Result<Vec<String>> {
432        let client = Client::default();
433
434        for relay in &self.relays {
435            if let Err(e) = client.add_relay(relay).await {
436                warn!("Failed to add relay {}: {}", relay, e);
437            }
438        }
439        client.connect().await;
440
441        if !wait_for_any_connected_relay(&client, Duration::from_secs(2)).await {
442            let _ = client.disconnect().await;
443            return Err(anyhow::anyhow!(
444                "Failed to connect to any relay while listing repos"
445            ));
446        }
447
448        let author = PublicKey::from_hex(&self.pubkey)
449            .map_err(|e| anyhow::anyhow!("Invalid pubkey: {}", e))?;
450        let filter = build_git_repo_list_filter(author);
451
452        let events = match tokio::time::timeout(
453            Duration::from_secs(3),
454            client.get_events_of(vec![filter], EventSource::relays(None)),
455        )
456        .await
457        {
458            Ok(Ok(events)) => events,
459            Ok(Err(e)) => {
460                let _ = client.disconnect().await;
461                return Err(anyhow::anyhow!(
462                    "Failed to fetch git repo events from relays: {}",
463                    e
464                ));
465            }
466            Err(_) => {
467                let _ = client.disconnect().await;
468                return Err(anyhow::anyhow!(
469                    "Timed out fetching git repo events from relays"
470                ));
471            }
472        };
473
474        let _ = client.disconnect().await;
475
476        Ok(list_git_repo_announcements(&events)
477            .into_iter()
478            .map(|repo| repo.repo_name)
479            .collect())
480    }
481
482    /// Fetch refs for a repository from nostr
483    /// Returns refs parsed from the hashtree at the root hash
484    pub fn fetch_refs(&mut self, repo_name: &str) -> Result<HashMap<String, String>> {
485        let (refs, _, _) = self.fetch_refs_with_timeout(repo_name, 10)?;
486        Ok(refs)
487    }
488
489    /// Fetch refs with a quick timeout (3s) for push operations
490    /// Returns empty if timeout - allows push to proceed
491    #[allow(dead_code)]
492    pub fn fetch_refs_quick(&mut self, repo_name: &str) -> Result<HashMap<String, String>> {
493        let (refs, _, _) = self.fetch_refs_with_timeout(repo_name, 3)?;
494        Ok(refs)
495    }
496
497    /// Fetch refs and root hash info from nostr
498    /// Returns (refs, root_hash, encryption_key)
499    #[allow(dead_code)]
500    pub fn fetch_refs_with_root(&mut self, repo_name: &str) -> Result<FetchedRefs> {
501        self.fetch_refs_with_timeout(repo_name, 10)
502    }
503
504    /// Fetch refs with configurable timeout
505    fn fetch_refs_with_timeout(
506        &mut self,
507        repo_name: &str,
508        timeout_secs: u64,
509    ) -> Result<FetchedRefs> {
510        debug!(
511            "Fetching refs for {} from {} (timeout {}s)",
512            repo_name, self.pubkey, timeout_secs
513        );
514
515        // Check cache first
516        if let Some(refs) = self.cached_refs.get(repo_name) {
517            let root = self.cached_root_hash.get(repo_name).cloned();
518            let key = self.cached_encryption_key.get(repo_name).cloned();
519            return Ok((refs.clone(), root, key));
520        }
521
522        let (root_hash, encryption_key) =
523            block_on_result(self.resolve_root_async_with_timeout(repo_name, timeout_secs))?;
524        if let Some(ref root) = root_hash {
525            self.cached_root_hash
526                .insert(repo_name.to_string(), root.clone());
527        }
528        if let Some(key) = encryption_key {
529            self.cached_encryption_key
530                .insert(repo_name.to_string(), key);
531        }
532
533        let refs = if let Some(ref root) = root_hash {
534            block_on_result(self.fetch_refs_from_hashtree(root, encryption_key.as_ref()))?
535        } else {
536            HashMap::new()
537        };
538        self.cached_refs.insert(repo_name.to_string(), refs.clone());
539        Ok((refs, root_hash, encryption_key))
540    }
541
542    fn parse_root_event_data_from_event(event: &Event) -> RootEventData {
543        let root_hash = event
544            .tags
545            .iter()
546            .find(|t| t.as_slice().len() >= 2 && t.as_slice()[0].as_str() == "hash")
547            .map(|t| t.as_slice()[1].to_string())
548            .unwrap_or_else(|| event.content.to_string());
549
550        let (encryption_key, key_tag_name, self_encrypted_ciphertext) = event
551            .tags
552            .iter()
553            .find_map(|t| {
554                let slice = t.as_slice();
555                if slice.len() < 2 {
556                    return None;
557                }
558                let tag_name = slice[0].as_str();
559                let tag_value = slice[1].to_string();
560                if tag_name == "selfEncryptedKey" {
561                    return Some((None, Some(tag_name.to_string()), Some(tag_value)));
562                }
563                if tag_name == "key" || tag_name == "encryptedKey" {
564                    if let Ok(bytes) = hex::decode(&tag_value) {
565                        if bytes.len() == 32 {
566                            let mut key = [0u8; 32];
567                            key.copy_from_slice(&bytes);
568                            return Some((Some(key), Some(tag_name.to_string()), None));
569                        }
570                    }
571                }
572                None
573            })
574            .unwrap_or((None, None, None));
575
576        RootEventData {
577            root_hash,
578            encryption_key,
579            key_tag_name,
580            self_encrypted_ciphertext,
581        }
582    }
583
584    fn parse_daemon_response_to_root_data(
585        response: DaemonResolveResponse,
586    ) -> Option<RootEventData> {
587        let parsed_cid = response.cid.as_deref().and_then(|cid| Cid::parse(cid).ok());
588        let root_hash = response
589            .hash
590            .or_else(|| parsed_cid.as_ref().map(|cid| hex::encode(cid.hash)))?;
591        if root_hash.is_empty() {
592            return None;
593        }
594
595        let mut data = RootEventData {
596            root_hash,
597            encryption_key: parsed_cid.and_then(|cid| cid.key),
598            key_tag_name: None,
599            self_encrypted_ciphertext: None,
600        };
601
602        if let Some(ciphertext) = response.self_encrypted_key {
603            data.key_tag_name = Some("selfEncryptedKey".to_string());
604            data.self_encrypted_ciphertext = Some(ciphertext);
605            return Some(data);
606        }
607
608        let (tag_name, tag_value) = if let Some(v) = response.encrypted_key {
609            ("encryptedKey", v)
610        } else if let Some(v) = response.key {
611            ("key", v)
612        } else {
613            return Some(data);
614        };
615
616        if let Ok(bytes) = hex::decode(&tag_value) {
617            if bytes.len() == 32 {
618                let mut key = [0u8; 32];
619                key.copy_from_slice(&bytes);
620                data.encryption_key = Some(key);
621                data.key_tag_name = Some(tag_name.to_string());
622            }
623        }
624
625        Some(data)
626    }
627
628    async fn fetch_root_from_local_daemon(
629        &self,
630        repo_name: &str,
631        timeout: Duration,
632    ) -> Option<RootEventData> {
633        let base = self.local_daemon_url.as_ref()?;
634        let pubkey = self.daemon_pubkey_identifier();
635        let url = format!(
636            "{}/api/nostr/resolve/{}/{}",
637            base.trim_end_matches('/'),
638            pubkey,
639            repo_name
640        );
641
642        let client = reqwest::Client::builder().timeout(timeout).build().ok()?;
643        let response = client.get(&url).send().await.ok()?;
644        if !response.status().is_success() {
645            return None;
646        }
647
648        let payload: DaemonResolveResponse = response.json().await.ok()?;
649        let source = payload
650            .source
651            .clone()
652            .unwrap_or_else(|| "unknown".to_string());
653        let parsed = Self::parse_daemon_response_to_root_data(payload)?;
654        debug!(
655            "Resolved repo {} via local daemon source={}",
656            repo_name, source
657        );
658        Some(parsed)
659    }
660
661    async fn cache_public_root_in_local_daemon(
662        &self,
663        repo_name: &str,
664        root_hash: &str,
665        encryption_key: Option<(&[u8; 32], bool, bool)>,
666    ) {
667        let Some(base) = self.local_daemon_url.as_ref() else {
668            return;
669        };
670
671        let key = match encryption_key {
672            Some((key, false, false)) => Some(hex::encode(key)),
673            Some((_key, _is_link_visible, _is_self_private)) => return,
674            None => None,
675        };
676
677        let url = format!("{}/api/cache-tree-root", base.trim_end_matches('/'));
678        let pubkey = self.daemon_pubkey_identifier();
679        let payload = serde_json::json!({
680            "npub": pubkey,
681            "treeName": repo_name,
682            "hash": root_hash,
683            "key": key,
684            "visibility": "public",
685        });
686
687        let client = match reqwest::Client::builder()
688            .timeout(Duration::from_secs(2))
689            .build()
690        {
691            Ok(client) => client,
692            Err(err) => {
693                debug!("Could not build local daemon cache client: {}", err);
694                return;
695            }
696        };
697
698        match client.post(url).json(&payload).send().await {
699            Ok(response) if response.status().is_success() => {
700                debug!("Cached repo root for {} in local daemon", repo_name);
701            }
702            Ok(response) => {
703                debug!(
704                    "Local daemon root cache returned status {} for {}",
705                    response.status(),
706                    repo_name
707                );
708            }
709            Err(err) => {
710                debug!("Could not cache repo root in local daemon: {}", err);
711            }
712        }
713    }
714
715    async fn resolve_root_async_with_timeout(
716        &self,
717        repo_name: &str,
718        timeout_secs: u64,
719    ) -> Result<(Option<String>, Option<[u8; 32]>)> {
720        // Create nostr-sdk client
721        let client = Client::default();
722
723        // Add relays
724        for relay in &self.relays {
725            if let Err(e) = client.add_relay(relay).await {
726                warn!("Failed to add relay {}: {}", relay, e);
727            }
728        }
729
730        // Connect to relays - this starts async connection
731        client.connect().await;
732
733        let connect_timeout = Duration::from_secs(2);
734        let query_timeout = Duration::from_secs(timeout_secs.saturating_sub(2).max(3));
735        let local_daemon_timeout = Duration::from_secs(4);
736        let retry_delay = Duration::from_millis(300);
737        let max_attempts = 2;
738
739        let start = std::time::Instant::now();
740
741        // Build filter for kind 30078 events from this author with matching d-tag
742        let author = PublicKey::from_hex(&self.pubkey)
743            .map_err(|e| anyhow::anyhow!("Invalid pubkey: {}", e))?;
744
745        let filter = build_repo_event_filter(author, repo_name);
746
747        debug!("Querying relays for repo {} events", repo_name);
748
749        let mut root_data = None;
750        for attempt in 1..=max_attempts {
751            if let Some(data) = self
752                .fetch_root_from_local_daemon(repo_name, local_daemon_timeout)
753                .await
754            {
755                root_data = Some(data);
756                break;
757            }
758
759            // Wait for at least one relay to connect (quick timeout - break immediately when one
760            // connects). We retry once because relays and the local daemon can both lag briefly.
761            let connect_start = std::time::Instant::now();
762            let mut last_log = std::time::Instant::now();
763            let mut has_connected_relay = false;
764            loop {
765                let (connected, total) = connected_relay_count(&client).await;
766                if connected > 0 {
767                    debug!(
768                        "Connected to {}/{} relay(s) in {:?} (attempt {}/{})",
769                        connected,
770                        total,
771                        start.elapsed(),
772                        attempt,
773                        max_attempts
774                    );
775                    has_connected_relay = true;
776                    break;
777                }
778                if last_log.elapsed() > Duration::from_millis(500) {
779                    debug!(
780                        "Connecting to relays... (0/{} after {:?}, attempt {}/{})",
781                        total,
782                        start.elapsed(),
783                        attempt,
784                        max_attempts
785                    );
786                    last_log = std::time::Instant::now();
787                }
788                if connect_start.elapsed() > connect_timeout {
789                    debug!(
790                        "Timeout waiting for relay connections - continuing with local-daemon fallback"
791                    );
792                    break;
793                }
794                tokio::time::sleep(Duration::from_millis(50)).await;
795            }
796
797            // Query with relay-level timeout.
798            // Using `EventSource::relays(Some(...))` preserves partial results from responsive
799            // relays instead of discarding everything when one relay stalls.
800            let events = if has_connected_relay {
801                match client
802                    .get_events_of(
803                        vec![filter.clone()],
804                        EventSource::relays(Some(query_timeout)),
805                    )
806                    .await
807                {
808                    Ok(events) => events,
809                    Err(e) => {
810                        warn!("Failed to fetch events: {}", e);
811                        vec![]
812                    }
813                }
814            } else {
815                vec![]
816            };
817
818            debug!(
819                "Got {} events from relays on attempt {}/{}",
820                events.len(),
821                attempt,
822                max_attempts
823            );
824            let relay_event = pick_latest_repo_event(events.iter(), repo_name);
825
826            if let Some(event) = relay_event {
827                debug!(
828                    "Found relay event with root hash: {}",
829                    &event.content[..12.min(event.content.len())]
830                );
831                root_data = Some(Self::parse_root_event_data_from_event(event));
832                break;
833            }
834
835            if attempt < max_attempts {
836                debug!(
837                    "No hashtree event found for {} on attempt {}/{}; retrying",
838                    repo_name, attempt, max_attempts
839                );
840                tokio::time::sleep(retry_delay).await;
841            }
842        }
843
844        // Disconnect
845        let _ = client.disconnect().await;
846
847        let root_data = match root_data {
848            Some(data) => data,
849            None => {
850                anyhow::bail!(
851                    "Repository '{}' not found (no hashtree event published by {})",
852                    repo_name,
853                    Self::format_repo_author(&self.pubkey)
854                );
855            }
856        };
857
858        let root_hash = root_data.root_hash;
859
860        if root_hash.is_empty() {
861            debug!("Empty root hash in event");
862            return Ok((None, None));
863        }
864
865        let encryption_key = root_data.encryption_key;
866        let key_tag_name = root_data.key_tag_name;
867        let self_encrypted_ciphertext = root_data.self_encrypted_ciphertext;
868
869        // Process encryption key based on tag type
870        let unmasked_key = match key_tag_name.as_deref() {
871            Some("encryptedKey") => {
872                // Link-visible: XOR the masked key with url_secret
873                if let (Some(masked), Some(secret)) = (encryption_key, self.url_secret) {
874                    let mut unmasked = [0u8; 32];
875                    for i in 0..32 {
876                        unmasked[i] = masked[i] ^ secret[i];
877                    }
878                    Some(unmasked)
879                } else {
880                    anyhow::bail!(
881                        "This repo is link-visible and requires a secret key.\n\
882                         Use: htree://.../{repo_name}#k=<secret>\n\
883                         Ask the repo owner for the full URL with the secret."
884                    );
885                }
886            }
887            Some("selfEncryptedKey") => {
888                // Private: only decrypt if #private is in the URL
889                if !self.is_private {
890                    anyhow::bail!(
891                        "This repo is private (author-only).\n\
892                         Use: htree://.../{repo_name}#private\n\
893                         Only the author can access this repo."
894                    );
895                }
896
897                // Decrypt with NIP-44 using our secret key
898                if let Some(keys) = &self.keys {
899                    if let Some(ciphertext) = self_encrypted_ciphertext {
900                        // Decrypt with NIP-44 (encrypted to self)
901                        let pubkey = keys.public_key();
902                        match nip44::decrypt(keys.secret_key(), &pubkey, &ciphertext) {
903                            Ok(key_hex) => {
904                                let key_bytes =
905                                    hex::decode(&key_hex).context("Invalid decrypted key hex")?;
906                                if key_bytes.len() != 32 {
907                                    anyhow::bail!("Decrypted key wrong length");
908                                }
909                                let mut key = [0u8; 32];
910                                key.copy_from_slice(&key_bytes);
911                                Some(key)
912                            }
913                            Err(e) => {
914                                anyhow::bail!(
915                                    "Failed to decrypt private repo: {}\n\
916                                     The repo may be corrupted or published with a different key.",
917                                    e
918                                );
919                            }
920                        }
921                    } else {
922                        anyhow::bail!("selfEncryptedKey tag has invalid format");
923                    }
924                } else {
925                    anyhow::bail!(
926                        "Cannot access this private repo.\n\
927                         Private repos can only be accessed by their author.\n\
928                         You don't have the secret key for this repo's owner."
929                    );
930                }
931            }
932            Some("key") | None => {
933                // Public: use key directly
934                encryption_key
935            }
936            Some(other) => {
937                warn!("Unknown key tag type: {}", other);
938                encryption_key
939            }
940        };
941
942        info!(
943            "Found root hash {} for {} (encrypted: {}, link_visible: {})",
944            &root_hash[..12.min(root_hash.len())],
945            repo_name,
946            unmasked_key.is_some(),
947            self.url_secret.is_some()
948        );
949
950        Ok((Some(root_hash), unmasked_key))
951    }
952
953    /// Decrypt data if encryption key is provided, then decode as tree node
954    fn decrypt_and_decode(
955        &self,
956        data: &[u8],
957        key: Option<&[u8; 32]>,
958    ) -> Option<hashtree_core::TreeNode> {
959        let decrypted_data: Vec<u8>;
960        let data_to_decode = if let Some(k) = key {
961            match decrypt_chk(data, k) {
962                Ok(d) => {
963                    decrypted_data = d;
964                    &decrypted_data
965                }
966                Err(e) => {
967                    debug!("Decryption failed: {}", e);
968                    return None;
969                }
970            }
971        } else {
972            data
973        };
974
975        match decode_tree_node(data_to_decode) {
976            Ok(node) => Some(node),
977            Err(e) => {
978                debug!("Failed to decode tree node: {}", e);
979                None
980            }
981        }
982    }
983
984    /// Fetch git refs from hashtree structure
985    /// Structure: root -> .git/ -> refs/ -> heads/main -> <sha>
986    async fn fetch_refs_from_hashtree(
987        &self,
988        root_hash: &str,
989        encryption_key: Option<&[u8; 32]>,
990    ) -> Result<HashMap<String, String>> {
991        let mut refs = HashMap::new();
992        debug!(
993            "fetch_refs_from_hashtree: downloading root {}",
994            &root_hash[..12]
995        );
996
997        // Download root directory from Blossom - propagate errors properly
998        let root_data = match self.blossom.download(root_hash).await {
999            Ok(data) => {
1000                debug!("Downloaded {} bytes from blossom", data.len());
1001                data
1002            }
1003            Err(e) => {
1004                anyhow::bail!(
1005                    "Failed to download root hash {}: {}",
1006                    &root_hash[..12.min(root_hash.len())],
1007                    e
1008                );
1009            }
1010        };
1011
1012        // Parse root as directory node (decrypt if needed)
1013        let root_node = match self.decrypt_and_decode(&root_data, encryption_key) {
1014            Some(node) => {
1015                debug!("Decoded root node with {} links", node.links.len());
1016                node
1017            }
1018            None => {
1019                debug!(
1020                    "Failed to decode root node (encryption_key: {})",
1021                    encryption_key.is_some()
1022                );
1023                return Ok(refs);
1024            }
1025        };
1026
1027        // Find .git directory
1028        debug!(
1029            "Root links: {:?}",
1030            root_node
1031                .links
1032                .iter()
1033                .map(|l| l.name.as_deref())
1034                .collect::<Vec<_>>()
1035        );
1036        let git_link = root_node
1037            .links
1038            .iter()
1039            .find(|l| l.name.as_deref() == Some(".git"));
1040        let (git_hash, git_key) = match git_link {
1041            Some(link) => {
1042                debug!("Found .git link with key: {}", link.key.is_some());
1043                (hex::encode(link.hash), link.key)
1044            }
1045            None => {
1046                debug!("No .git directory in hashtree root");
1047                return Ok(refs);
1048            }
1049        };
1050
1051        // Download .git directory
1052        let git_data = match self.blossom.download(&git_hash).await {
1053            Ok(data) => data,
1054            Err(e) => {
1055                anyhow::bail!(
1056                    "Failed to download .git directory ({}): {}",
1057                    &git_hash[..12],
1058                    e
1059                );
1060            }
1061        };
1062
1063        let git_node = match self.decrypt_and_decode(&git_data, git_key.as_ref()) {
1064            Some(node) => {
1065                debug!(
1066                    "Decoded .git node with {} links: {:?}",
1067                    node.links.len(),
1068                    node.links
1069                        .iter()
1070                        .map(|l| l.name.as_deref())
1071                        .collect::<Vec<_>>()
1072                );
1073                node
1074            }
1075            None => {
1076                debug!("Failed to decode .git node (key: {})", git_key.is_some());
1077                return Ok(refs);
1078            }
1079        };
1080
1081        // Find refs directory
1082        let refs_link = git_node
1083            .links
1084            .iter()
1085            .find(|l| l.name.as_deref() == Some("refs"));
1086        let (refs_hash, refs_key) = match refs_link {
1087            Some(link) => (hex::encode(link.hash), link.key),
1088            None => {
1089                debug!("No refs directory in .git");
1090                return Ok(refs);
1091            }
1092        };
1093
1094        // Download refs directory
1095        let refs_data = match self.blossom.try_download(&refs_hash).await {
1096            Some(data) => data,
1097            None => {
1098                debug!("Could not download refs directory");
1099                return Ok(refs);
1100            }
1101        };
1102
1103        let refs_node = match self.decrypt_and_decode(&refs_data, refs_key.as_ref()) {
1104            Some(node) => node,
1105            None => {
1106                return Ok(refs);
1107            }
1108        };
1109
1110        // Look for HEAD in .git directory
1111        if let Some(head_link) = git_node
1112            .links
1113            .iter()
1114            .find(|l| l.name.as_deref() == Some("HEAD"))
1115        {
1116            let head_hash = hex::encode(head_link.hash);
1117            if let Some(head_data) = self.blossom.try_download(&head_hash).await {
1118                // HEAD is a blob, decrypt if needed
1119                let head_content = if let Some(k) = head_link.key.as_ref() {
1120                    match decrypt_chk(&head_data, k) {
1121                        Ok(d) => String::from_utf8_lossy(&d).trim().to_string(),
1122                        Err(_) => String::from_utf8_lossy(&head_data).trim().to_string(),
1123                    }
1124                } else {
1125                    String::from_utf8_lossy(&head_data).trim().to_string()
1126                };
1127                refs.insert("HEAD".to_string(), head_content);
1128            }
1129        }
1130
1131        // Recursively walk refs/ subdirectories (heads, tags, etc.)
1132        for subdir_link in &refs_node.links {
1133            if subdir_link.link_type != LinkType::Dir {
1134                continue;
1135            }
1136            let subdir_name = match &subdir_link.name {
1137                Some(n) => n.clone(),
1138                None => continue,
1139            };
1140            let subdir_hash = hex::encode(subdir_link.hash);
1141
1142            self.collect_refs_recursive(
1143                &subdir_hash,
1144                subdir_link.key.as_ref(),
1145                &format!("refs/{}", subdir_name),
1146                &mut refs,
1147            )
1148            .await;
1149        }
1150
1151        debug!("Found {} refs from hashtree", refs.len());
1152        Ok(refs)
1153    }
1154
1155    /// Recursively collect refs from a directory
1156    async fn collect_refs_recursive(
1157        &self,
1158        dir_hash: &str,
1159        dir_key: Option<&[u8; 32]>,
1160        prefix: &str,
1161        refs: &mut HashMap<String, String>,
1162    ) {
1163        let dir_data = match self.blossom.try_download(dir_hash).await {
1164            Some(data) => data,
1165            None => return,
1166        };
1167
1168        let dir_node = match self.decrypt_and_decode(&dir_data, dir_key) {
1169            Some(node) => node,
1170            None => return,
1171        };
1172
1173        for link in &dir_node.links {
1174            let name = match &link.name {
1175                Some(n) => n.clone(),
1176                None => continue,
1177            };
1178            let link_hash = hex::encode(link.hash);
1179            let ref_path = format!("{}/{}", prefix, name);
1180
1181            if link.link_type == LinkType::Dir {
1182                // Recurse into subdirectory
1183                Box::pin(self.collect_refs_recursive(
1184                    &link_hash,
1185                    link.key.as_ref(),
1186                    &ref_path,
1187                    refs,
1188                ))
1189                .await;
1190            } else {
1191                // This is a ref file - read the SHA
1192                if let Some(ref_data) = self.blossom.try_download(&link_hash).await {
1193                    // Decrypt if needed
1194                    let sha = if let Some(k) = link.key.as_ref() {
1195                        match decrypt_chk(&ref_data, k) {
1196                            Ok(d) => String::from_utf8_lossy(&d).trim().to_string(),
1197                            Err(_) => String::from_utf8_lossy(&ref_data).trim().to_string(),
1198                        }
1199                    } else {
1200                        String::from_utf8_lossy(&ref_data).trim().to_string()
1201                    };
1202                    if !sha.is_empty() {
1203                        debug!("Found ref {} -> {}", ref_path, sha);
1204                        refs.insert(ref_path, sha);
1205                    }
1206                }
1207            }
1208        }
1209    }
1210
1211    /// Update a ref in local cache (will be published with publish_repo)
1212    #[allow(dead_code)]
1213    pub fn update_ref(&mut self, repo_name: &str, ref_name: &str, sha: &str) -> Result<()> {
1214        info!("Updating ref {} -> {} for {}", ref_name, sha, repo_name);
1215
1216        let refs = self.cached_refs.entry(repo_name.to_string()).or_default();
1217        refs.insert(ref_name.to_string(), sha.to_string());
1218
1219        Ok(())
1220    }
1221
1222    /// Delete a ref from local cache
1223    pub fn delete_ref(&mut self, repo_name: &str, ref_name: &str) -> Result<()> {
1224        info!("Deleting ref {} for {}", ref_name, repo_name);
1225
1226        if let Some(refs) = self.cached_refs.get_mut(repo_name) {
1227            refs.remove(ref_name);
1228        }
1229
1230        Ok(())
1231    }
1232
1233    /// Get cached root hash for a repository
1234    pub fn get_cached_root_hash(&self, repo_name: &str) -> Option<&String> {
1235        self.cached_root_hash.get(repo_name)
1236    }
1237
1238    /// Get cached encryption key for a repository
1239    pub fn get_cached_encryption_key(&self, repo_name: &str) -> Option<&[u8; 32]> {
1240        self.cached_encryption_key.get(repo_name)
1241    }
1242
1243    /// Get the Blossom client for direct downloads
1244    pub fn blossom(&self) -> &BlossomClient {
1245        &self.blossom
1246    }
1247
1248    /// Get the configured relay URLs
1249    pub fn relay_urls(&self) -> Vec<String> {
1250        self.relays.clone()
1251    }
1252
1253    /// Get the public key (hex)
1254    #[allow(dead_code)]
1255    pub fn pubkey(&self) -> &str {
1256        &self.pubkey
1257    }
1258
1259    /// Get the public key as npub bech32
1260    pub fn npub(&self) -> String {
1261        PublicKey::from_hex(&self.pubkey)
1262            .ok()
1263            .and_then(|pk| pk.to_bech32().ok())
1264            .unwrap_or_else(|| self.pubkey.clone())
1265    }
1266
1267    /// Publish repository to nostr as kind 30078 event
1268    /// Format:
1269    ///   kind: 30078
1270    ///   tags: [["d", repo_name], ["l", "hashtree"], ["hash", root_hash], ["key"|"encryptedKey", encryption_key]]
1271    ///   content: <merkle-root-hash>
1272    /// Returns: (npub URL, relay result with connected/failed details)
1273    /// If is_private is true, uses "encryptedKey" tag (XOR masked); otherwise uses "key" tag (plaintext CHK)
1274    pub fn publish_repo(
1275        &self,
1276        repo_name: &str,
1277        root_hash: &str,
1278        encryption_key: Option<(&[u8; 32], bool, bool)>,
1279    ) -> Result<(String, RelayResult)> {
1280        let keys = self.keys.as_ref().context(format!(
1281            "Cannot push: no secret key for {}. You can only push to your own repos.",
1282            &self.pubkey[..16]
1283        ))?;
1284
1285        info!(
1286            "Publishing repo {} with root hash {} (encrypted: {})",
1287            repo_name,
1288            root_hash,
1289            encryption_key.is_some()
1290        );
1291
1292        // Create a new multi-threaded runtime for nostr-sdk which spawns background tasks
1293        block_on_result(self.publish_repo_async(keys, repo_name, root_hash, encryption_key))
1294    }
1295
1296    async fn publish_repo_async(
1297        &self,
1298        keys: &Keys,
1299        repo_name: &str,
1300        root_hash: &str,
1301        encryption_key: Option<(&[u8; 32], bool, bool)>,
1302    ) -> Result<(String, RelayResult)> {
1303        // Create nostr-sdk client with our keys
1304        let client = Client::new(keys.clone());
1305
1306        let configured: Vec<String> = self.relays.clone();
1307        let mut connected: Vec<String> = Vec::new();
1308        let mut failed: Vec<String> = Vec::new();
1309
1310        // Add relays
1311        for relay in &self.relays {
1312            if let Err(e) = client.add_relay(relay).await {
1313                warn!("Failed to add relay {}: {}", relay, e);
1314                failed.push(relay.clone());
1315            }
1316        }
1317
1318        // Connect to relays - this starts async connection in background
1319        client.connect().await;
1320
1321        // Wait for at least one relay to connect (same pattern as fetch)
1322        let _ = wait_for_any_connected_relay(&client, Duration::from_secs(3)).await;
1323
1324        let publish_created_at = next_replaceable_created_at(
1325            Timestamp::now(),
1326            latest_repo_event_created_at(
1327                &client,
1328                keys.public_key(),
1329                repo_name,
1330                Duration::from_secs(2),
1331            )
1332            .await,
1333        );
1334
1335        // Build event with tags
1336        let mut tags = vec![
1337            Tag::custom(TagKind::custom("d"), vec![repo_name.to_string()]),
1338            Tag::custom(TagKind::custom("l"), vec![LABEL_HASHTREE.to_string()]),
1339            Tag::custom(TagKind::custom("hash"), vec![root_hash.to_string()]),
1340        ];
1341
1342        // Add encryption key if present (required for decryption)
1343        // Key modes:
1344        // - selfEncryptedKey: NIP-44 encrypted to self (author-only private)
1345        // - encryptedKey: XOR masked with URL secret (link-visible)
1346        // - key: plaintext CHK (public)
1347        if let Some((key, is_link_visible, is_self_private)) = encryption_key {
1348            if is_self_private {
1349                // NIP-44 encrypt to self
1350                let pubkey = keys.public_key();
1351                let key_hex = hex::encode(key);
1352                let encrypted =
1353                    nip44::encrypt(keys.secret_key(), &pubkey, &key_hex, nip44::Version::V2)
1354                        .map_err(|e| anyhow::anyhow!("NIP-44 encryption failed: {}", e))?;
1355                tags.push(Tag::custom(
1356                    TagKind::custom("selfEncryptedKey"),
1357                    vec![encrypted],
1358                ));
1359            } else if is_link_visible {
1360                // XOR masked key
1361                tags.push(Tag::custom(
1362                    TagKind::custom("encryptedKey"),
1363                    vec![hex::encode(key)],
1364                ));
1365            } else {
1366                // Public: plaintext CHK
1367                tags.push(Tag::custom(TagKind::custom("key"), vec![hex::encode(key)]));
1368            }
1369        }
1370
1371        append_repo_discovery_labels(&mut tags, repo_name);
1372
1373        // Sign the event
1374        let event = EventBuilder::new(Kind::Custom(KIND_APP_DATA), root_hash, tags)
1375            .custom_created_at(publish_created_at)
1376            .to_event(keys)
1377            .map_err(|e| anyhow::anyhow!("Failed to sign event: {}", e))?;
1378
1379        // Send event to connected relays
1380        match client.send_event(event.clone()).await {
1381            Ok(output) => {
1382                // Track which relays confirmed
1383                for url in output.success.iter() {
1384                    let url_str = url.to_string();
1385                    if !connected.contains(&url_str) {
1386                        connected.push(url_str);
1387                    }
1388                }
1389                // Only mark as failed if we got explicit rejection
1390                for (url, err) in output.failed.iter() {
1391                    if err.is_some() {
1392                        let url_str = url.to_string();
1393                        if !failed.contains(&url_str) && !connected.contains(&url_str) {
1394                            failed.push(url_str);
1395                        }
1396                    }
1397                }
1398                info!(
1399                    "Sent event {} to {} relays ({} failed)",
1400                    output.id(),
1401                    output.success.len(),
1402                    output.failed.len()
1403                );
1404            }
1405            Err(e) => {
1406                warn!("Failed to send event: {}", e);
1407                // Mark all as failed
1408                for relay in &self.relays {
1409                    if !failed.contains(relay) {
1410                        failed.push(relay.clone());
1411                    }
1412                }
1413            }
1414        };
1415
1416        // Build the full htree:// URL with npub
1417        let npub_url = keys
1418            .public_key()
1419            .to_bech32()
1420            .map(|npub| format!("htree://{}/{}", npub, repo_name))
1421            .unwrap_or_else(|_| format!("htree://{}/{}", &self.pubkey[..16], repo_name));
1422
1423        let relay_validation = validate_repo_publish_relays(&configured, &connected);
1424
1425        // Disconnect and give time for cleanup
1426        let _ = client.disconnect().await;
1427        tokio::time::sleep(Duration::from_millis(50)).await;
1428
1429        relay_validation?;
1430
1431        self.cache_public_root_in_local_daemon(repo_name, root_hash, encryption_key)
1432            .await;
1433
1434        Ok((
1435            npub_url,
1436            RelayResult {
1437                configured,
1438                connected,
1439                failed,
1440            },
1441        ))
1442    }
1443
1444    /// Fetch pull requests targeting this repo, filtered by state.
1445    pub fn fetch_prs(
1446        &self,
1447        repo_name: &str,
1448        state_filter: PullRequestStateFilter,
1449    ) -> Result<Vec<PullRequestListItem>> {
1450        block_on_result(self.fetch_prs_async(repo_name, state_filter))
1451    }
1452
1453    pub async fn fetch_prs_async(
1454        &self,
1455        repo_name: &str,
1456        state_filter: PullRequestStateFilter,
1457    ) -> Result<Vec<PullRequestListItem>> {
1458        let client = Client::default();
1459
1460        for relay in &self.relays {
1461            if let Err(e) = client.add_relay(relay).await {
1462                warn!("Failed to add relay {}: {}", relay, e);
1463            }
1464        }
1465        client.connect().await;
1466
1467        // Wait for at least one relay (quick timeout)
1468        if !wait_for_any_connected_relay(&client, Duration::from_secs(2)).await {
1469            let _ = client.disconnect().await;
1470            return Err(anyhow::anyhow!(
1471                "Failed to connect to any relay while fetching PRs"
1472            ));
1473        }
1474
1475        // Query for kind 1618 PRs targeting this repo
1476        let repo_address = format!("{}:{}:{}", KIND_REPO_ANNOUNCEMENT, self.pubkey, repo_name);
1477        let pull_request_filter = Filter::new()
1478            .kind(Kind::Custom(KIND_PULL_REQUEST))
1479            .custom_tag(SingleLetterTag::lowercase(Alphabet::A), vec![&repo_address]);
1480
1481        let mut pr_events = match tokio::time::timeout(
1482            Duration::from_secs(3),
1483            client.get_events_of(vec![pull_request_filter.clone()], EventSource::relays(None)),
1484        )
1485        .await
1486        {
1487            Ok(Ok(events)) => events,
1488            Ok(Err(e)) => {
1489                let _ = client.disconnect().await;
1490                return Err(anyhow::anyhow!(
1491                    "Failed to fetch PR events from relays: {}",
1492                    e
1493                ));
1494            }
1495            Err(_) => {
1496                let _ = client.disconnect().await;
1497                return Err(anyhow::anyhow!("Timed out fetching PR events from relays"));
1498            }
1499        };
1500
1501        if pr_events.is_empty() {
1502            let fallback_events = fetch_events_via_raw_relay_query(
1503                &self.relays,
1504                pull_request_filter,
1505                Duration::from_secs(3),
1506            )
1507            .await;
1508            if !fallback_events.is_empty() {
1509                debug!(
1510                    "Raw relay fallback recovered {} PR event(s) for {}",
1511                    fallback_events.len(),
1512                    repo_name
1513                );
1514                pr_events = fallback_events;
1515            }
1516        }
1517
1518        if pr_events.is_empty() {
1519            let _ = client.disconnect().await;
1520            return Ok(Vec::new());
1521        }
1522
1523        // Collect PR event IDs for status query
1524        let pr_ids: Vec<String> = pr_events.iter().map(|e| e.id.to_hex()).collect();
1525
1526        // Query for status events referencing these PRs
1527        let status_event_filter = Filter::new()
1528            .kinds(vec![
1529                Kind::Custom(KIND_STATUS_OPEN),
1530                Kind::Custom(KIND_STATUS_APPLIED),
1531                Kind::Custom(KIND_STATUS_CLOSED),
1532                Kind::Custom(KIND_STATUS_DRAFT),
1533            ])
1534            .custom_tag(
1535                SingleLetterTag::lowercase(Alphabet::E),
1536                pr_ids.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
1537            );
1538
1539        let mut status_events = match tokio::time::timeout(
1540            Duration::from_secs(3),
1541            client.get_events_of(vec![status_event_filter.clone()], EventSource::relays(None)),
1542        )
1543        .await
1544        {
1545            Ok(Ok(events)) => events,
1546            Ok(Err(e)) => {
1547                let _ = client.disconnect().await;
1548                return Err(anyhow::anyhow!(
1549                    "Failed to fetch PR status events from relays: {}",
1550                    e
1551                ));
1552            }
1553            Err(_) => {
1554                let _ = client.disconnect().await;
1555                return Err(anyhow::anyhow!(
1556                    "Timed out fetching PR status events from relays"
1557                ));
1558            }
1559        };
1560
1561        if status_events.is_empty() {
1562            let fallback_events = fetch_events_via_raw_relay_query(
1563                &self.relays,
1564                status_event_filter,
1565                Duration::from_secs(3),
1566            )
1567            .await;
1568            if !fallback_events.is_empty() {
1569                debug!(
1570                    "Raw relay fallback recovered {} PR status event(s) for {}",
1571                    fallback_events.len(),
1572                    repo_name
1573                );
1574                status_events = fallback_events;
1575            }
1576        }
1577
1578        let _ = client.disconnect().await;
1579
1580        // Build map: pr_event_id -> latest trusted status kind
1581        let latest_status =
1582            latest_trusted_pr_status_kinds(&pr_events, &status_events, &self.pubkey);
1583
1584        let mut prs = Vec::new();
1585        for event in &pr_events {
1586            let pr_id = event.id.to_hex();
1587            let state =
1588                PullRequestState::from_latest_status_kind(latest_status.get(&pr_id).copied());
1589            if !state_filter.includes(state) {
1590                continue;
1591            }
1592
1593            let mut subject = None;
1594            let mut commit_tip = None;
1595            let mut branch = None;
1596            let mut target_branch = None;
1597
1598            for tag in event.tags.iter() {
1599                let slice = tag.as_slice();
1600                if slice.len() >= 2 {
1601                    match slice[0].as_str() {
1602                        "subject" => subject = Some(slice[1].to_string()),
1603                        "c" => commit_tip = Some(slice[1].to_string()),
1604                        "branch" => branch = Some(slice[1].to_string()),
1605                        "target-branch" => target_branch = Some(slice[1].to_string()),
1606                        _ => {}
1607                    }
1608                }
1609            }
1610
1611            prs.push(PullRequestListItem {
1612                event_id: pr_id,
1613                author_pubkey: event.pubkey.to_hex(),
1614                state,
1615                subject,
1616                commit_tip,
1617                branch,
1618                target_branch,
1619                created_at: event.created_at.as_u64(),
1620            });
1621        }
1622
1623        // Newest first; tie-break by event id for deterministic output.
1624        prs.sort_by(|left, right| {
1625            right
1626                .created_at
1627                .cmp(&left.created_at)
1628                .then_with(|| right.event_id.cmp(&left.event_id))
1629        });
1630
1631        debug!(
1632            "Found {} PRs for {} (filter: {:?})",
1633            prs.len(),
1634            repo_name,
1635            state_filter
1636        );
1637        Ok(prs)
1638    }
1639
1640    /// Publish a kind 1631 (STATUS_APPLIED) event to mark a PR as merged
1641    pub fn publish_pr_merged_status(
1642        &self,
1643        pr_event_id: &str,
1644        pr_author_pubkey: &str,
1645    ) -> Result<()> {
1646        let keys = self
1647            .keys
1648            .as_ref()
1649            .context("Cannot publish status: no secret key")?;
1650
1651        block_on_result(self.publish_pr_merged_status_async(keys, pr_event_id, pr_author_pubkey))
1652    }
1653
1654    async fn publish_pr_merged_status_async(
1655        &self,
1656        keys: &Keys,
1657        pr_event_id: &str,
1658        pr_author_pubkey: &str,
1659    ) -> Result<()> {
1660        let client = Client::new(keys.clone());
1661
1662        for relay in &self.relays {
1663            if let Err(e) = client.add_relay(relay).await {
1664                warn!("Failed to add relay {}: {}", relay, e);
1665            }
1666        }
1667        client.connect().await;
1668
1669        // Wait for at least one relay
1670        if !wait_for_any_connected_relay(&client, Duration::from_secs(3)).await {
1671            anyhow::bail!("Failed to connect to any relay for status publish");
1672        }
1673
1674        let tags = vec![
1675            Tag::custom(TagKind::custom("e"), vec![pr_event_id.to_string()]),
1676            Tag::custom(TagKind::custom("p"), vec![pr_author_pubkey.to_string()]),
1677        ];
1678
1679        let event = EventBuilder::new(Kind::Custom(KIND_STATUS_APPLIED), "", tags)
1680            .to_event(keys)
1681            .map_err(|e| anyhow::anyhow!("Failed to sign status event: {}", e))?;
1682
1683        let publish_result = match client.send_event(event).await {
1684            Ok(output) => {
1685                if output.success.is_empty() {
1686                    Err(anyhow::anyhow!(
1687                        "PR merged status was not confirmed by any relay"
1688                    ))
1689                } else {
1690                    info!(
1691                        "Published PR merged status to {} relays",
1692                        output.success.len()
1693                    );
1694                    Ok(())
1695                }
1696            }
1697            Err(e) => Err(anyhow::anyhow!("Failed to publish PR merged status: {}", e)),
1698        };
1699
1700        let _ = client.disconnect().await;
1701        tokio::time::sleep(Duration::from_millis(50)).await;
1702        publish_result
1703    }
1704
1705    /// Upload blob to blossom server
1706    #[allow(dead_code)]
1707    pub async fn upload_blob(&self, _hash: &str, data: &[u8]) -> Result<String> {
1708        let hash = self
1709            .blossom
1710            .upload(data)
1711            .await
1712            .map_err(|e| anyhow::anyhow!("Blossom upload failed: {}", e))?;
1713        Ok(hash)
1714    }
1715
1716    /// Upload blob only if it doesn't exist
1717    #[allow(dead_code)]
1718    pub async fn upload_blob_if_missing(&self, data: &[u8]) -> Result<(String, bool)> {
1719        self.blossom
1720            .upload_if_missing(data)
1721            .await
1722            .map_err(|e| anyhow::anyhow!("Blossom upload failed: {}", e))
1723    }
1724
1725    /// Download blob from blossom server
1726    #[allow(dead_code)]
1727    pub async fn download_blob(&self, hash: &str) -> Result<Vec<u8>> {
1728        self.blossom
1729            .download(hash)
1730            .await
1731            .map_err(|e| anyhow::anyhow!("Blossom download failed: {}", e))
1732    }
1733
1734    /// Try to download blob, returns None if not found
1735    #[allow(dead_code)]
1736    pub async fn try_download_blob(&self, hash: &str) -> Option<Vec<u8>> {
1737        self.blossom.try_download(hash).await
1738    }
1739}
1740
1741#[cfg(test)]
1742mod tests;