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};
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]>);
256use hashtree_config::Config;
257
258#[derive(Debug, Clone)]
260pub struct RelayResult {
261 #[allow(dead_code)]
263 pub configured: Vec<String>,
264 pub connected: Vec<String>,
266 pub failed: Vec<String>,
268}
269
270#[derive(Debug, Clone)]
272pub struct BlossomResult {
273 #[allow(dead_code)]
275 pub configured: Vec<String>,
276 pub succeeded: Vec<String>,
278 pub failed: Vec<String>,
280 pub local_complete: bool,
282 pub degraded: bool,
284}
285
286pub struct NostrClient {
288 pubkey: String,
289 keys: Option<Keys>,
291 relays: Vec<String>,
292 blossom: BlossomClient,
293 cached_refs: HashMap<String, HashMap<String, String>>,
295 cached_root_hash: HashMap<String, String>,
297 cached_encryption_key: HashMap<String, [u8; 32]>,
299 url_secret: Option<[u8; 32]>,
302 is_private: bool,
304 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 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 let _ = rustls::crypto::ring::default_provider().install_default();
342
343 let secret_key = secret_key.or_else(|| std::env::var("NOSTR_SECRET_KEY").ok());
345
346 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 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 #[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 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 #[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 #[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 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 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 let client = Client::default();
723
724 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 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 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 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 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 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 let unmasked_key = match key_tag_name.as_deref() {
866 Some("encryptedKey") => {
867 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 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 if let Some(keys) = &self.keys {
894 if let Some(ciphertext) = self_encrypted_ciphertext {
895 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 if let Some(ref_data) = self.blossom.try_download(&link_hash).await {
1188 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 #[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 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 pub fn get_cached_root_hash(&self, repo_name: &str) -> Option<&String> {
1230 self.cached_root_hash.get(repo_name)
1231 }
1232
1233 pub fn get_cached_encryption_key(&self, repo_name: &str) -> Option<&[u8; 32]> {
1235 self.cached_encryption_key.get(repo_name)
1236 }
1237
1238 pub fn blossom(&self) -> &BlossomClient {
1240 &self.blossom
1241 }
1242
1243 pub fn relay_urls(&self) -> Vec<String> {
1245 self.relays.clone()
1246 }
1247
1248 #[allow(dead_code)]
1250 pub fn pubkey(&self) -> &str {
1251 &self.pubkey
1252 }
1253
1254 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 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 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 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 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 client.connect().await;
1315
1316 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 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 if let Some((key, is_link_visible, is_self_private)) = encryption_key {
1343 if is_self_private {
1344 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 tags.push(Tag::custom(
1357 TagKind::custom("encryptedKey"),
1358 vec![hex::encode(key)],
1359 ));
1360 } else {
1361 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 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 match client.send_event(&event).await {
1377 Ok(output) => {
1378 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 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 for relay in &self.relays {
1403 if !failed.contains(relay) {
1404 failed.push(relay.clone());
1405 }
1406 }
1407 }
1408 };
1409
1410 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 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 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 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 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 let pr_ids: Vec<String> = pr_events.iter().map(|e| e.id.to_hex()).collect();
1522
1523 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 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 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 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 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 #[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 #[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 #[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 #[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;