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