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