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