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};
64use repo_metadata::{
65 append_repo_discovery_labels, build_git_repo_list_filter, build_repo_announcement_filter,
66 build_repo_event_filter, extract_repo_announcement_euc, latest_repo_announcement_created_at,
67 latest_repo_event_created_at, latest_trusted_pr_status_kinds, list_git_repo_announcements,
68 next_replaceable_created_at, pick_latest_event, pick_latest_repo_event,
69 validate_repo_publish_relays,
70};
71
72pub const KIND_HASHTREE_ROOT: u16 = 30064;
74
75pub const KIND_APP_DATA: u16 = 30078;
77
78pub const KIND_PULL_REQUEST: u16 = 1618;
80pub const KIND_STATUS_OPEN: u16 = 1630;
81pub const KIND_STATUS_APPLIED: u16 = 1631;
82pub const KIND_STATUS_CLOSED: u16 = 1632;
83pub const KIND_STATUS_DRAFT: u16 = 1633;
84pub const KIND_REPO_ANNOUNCEMENT: u16 = 30617;
85
86pub fn hashtree_root_kinds() -> Vec<Kind> {
87 vec![
88 Kind::Custom(KIND_HASHTREE_ROOT),
89 Kind::Custom(KIND_APP_DATA),
90 ]
91}
92
93pub fn is_hashtree_root_kind(kind: Kind) -> bool {
94 kind == Kind::Custom(KIND_HASHTREE_ROOT) || kind == Kind::Custom(KIND_APP_DATA)
95}
96
97pub const LABEL_HASHTREE: &str = "hashtree";
99pub const LABEL_GIT: &str = "git";
100const IRIS_GIT_WEB_BASE_URL: &str = "https://git.iris.to/#";
101const LOCAL_DAEMON_QUERY_TIMEOUT_SECS: u64 = 8;
102const REPO_PUBLISH_TIMEOUT: Duration = Duration::from_secs(30);
103
104fn local_daemon_query_timeout(request_timeout_secs: u64, local_daemon_only: bool) -> Duration {
105 let timeout_secs = if local_daemon_only {
106 request_timeout_secs.max(LOCAL_DAEMON_QUERY_TIMEOUT_SECS)
107 } else {
108 4
109 };
110 Duration::from_secs(timeout_secs)
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum PullRequestState {
116 Open,
117 Applied,
118 Closed,
119 Draft,
120}
121
122impl PullRequestState {
123 pub fn as_str(self) -> &'static str {
124 match self {
125 PullRequestState::Open => "open",
126 PullRequestState::Applied => "applied",
127 PullRequestState::Closed => "closed",
128 PullRequestState::Draft => "draft",
129 }
130 }
131
132 fn from_status_kind(status_kind: u16) -> Option<Self> {
133 match status_kind {
134 KIND_STATUS_OPEN => Some(PullRequestState::Open),
135 KIND_STATUS_APPLIED => Some(PullRequestState::Applied),
136 KIND_STATUS_CLOSED => Some(PullRequestState::Closed),
137 KIND_STATUS_DRAFT => Some(PullRequestState::Draft),
138 _ => None,
139 }
140 }
141
142 fn from_latest_status_kind(status_kind: Option<u16>) -> Self {
143 status_kind
144 .and_then(Self::from_status_kind)
145 .unwrap_or(PullRequestState::Open)
146 }
147}
148
149#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
151pub enum PullRequestStateFilter {
152 #[default]
153 Open,
154 Applied,
155 Closed,
156 Draft,
157 All,
158}
159
160impl PullRequestStateFilter {
161 pub fn as_str(self) -> &'static str {
162 match self {
163 PullRequestStateFilter::Open => "open",
164 PullRequestStateFilter::Applied => "applied",
165 PullRequestStateFilter::Closed => "closed",
166 PullRequestStateFilter::Draft => "draft",
167 PullRequestStateFilter::All => "all",
168 }
169 }
170
171 fn includes(self, state: PullRequestState) -> bool {
172 match self {
173 PullRequestStateFilter::All => true,
174 PullRequestStateFilter::Open => state == PullRequestState::Open,
175 PullRequestStateFilter::Applied => state == PullRequestState::Applied,
176 PullRequestStateFilter::Closed => state == PullRequestState::Closed,
177 PullRequestStateFilter::Draft => state == PullRequestState::Draft,
178 }
179 }
180}
181
182#[derive(Debug, Clone)]
184pub struct PullRequestListItem {
185 pub event_id: String,
186 pub author_pubkey: String,
187 pub state: PullRequestState,
188 pub subject: Option<String>,
189 pub commit_tip: Option<String>,
190 pub branch: Option<String>,
191 pub target_branch: Option<String>,
192 pub created_at: u64,
193}
194
195struct RawRelayQueryResult {
196 events: Vec<Event>,
197 completed: bool,
198}
199
200async fn fetch_events_via_raw_relay_query(
201 relays: &[String],
202 filter: Filter,
203 timeout: Duration,
204) -> RawRelayQueryResult {
205 let request_json = ClientMessage::req(SubscriptionId::generate(), vec![filter]).as_json();
206 let mut events_by_id = HashMap::<String, Event>::new();
207 let mut completed = false;
208
209 for relay_url in relays {
210 let relay_events = match tokio::time::timeout(timeout, async {
211 let (mut ws, _) = connect_async(relay_url).await?;
212 ws.send(WsMessage::Text(request_json.clone())).await?;
213
214 let mut relay_events = Vec::new();
215 let mut reached_eose = false;
216 while let Some(message) = ws.next().await {
217 let message = message?;
218 let WsMessage::Text(text) = message else {
219 continue;
220 };
221
222 match RelayMessage::from_json(text.as_str()) {
223 Ok(RelayMessage::Event { event, .. }) => relay_events.push(event.into_owned()),
224 Ok(RelayMessage::EndOfStoredEvents(_)) => {
225 reached_eose = true;
226 break;
227 }
228 Ok(RelayMessage::Closed { message, .. }) => {
229 debug!("Raw relay PR query closed by {}: {}", relay_url, message);
230 break;
231 }
232 Ok(_) => {}
233 Err(err) => {
234 debug!(
235 "Failed to parse raw relay response from {}: {}",
236 relay_url, err
237 );
238 }
239 }
240 }
241
242 let _ = ws.close(None).await;
243 Ok::<(Vec<Event>, bool), anyhow::Error>((relay_events, reached_eose))
244 })
245 .await
246 {
247 Ok(Ok((events, reached_eose))) => {
248 completed |= reached_eose;
249 events
250 }
251 Ok(Err(err)) => {
252 debug!("Raw relay PR query failed for {}: {}", relay_url, err);
253 continue;
254 }
255 Err(_) => {
256 debug!("Raw relay PR query timed out for {}", relay_url);
257 continue;
258 }
259 };
260
261 for event in relay_events {
262 events_by_id.insert(event.id.to_hex(), event);
263 }
264 }
265
266 RawRelayQueryResult {
267 events: events_by_id.into_values().collect(),
268 completed,
269 }
270}
271
272async fn connected_relay_count(client: &Client) -> (usize, usize) {
273 let relays = client.relays().await;
274 let total = relays.len();
275 let mut connected = 0;
276 for relay in relays.values() {
277 if relay.is_connected() {
278 connected += 1;
279 }
280 }
281 (connected, total)
282}
283
284async fn wait_for_any_connected_relay(client: &Client, timeout: Duration) -> bool {
285 let start = std::time::Instant::now();
286 loop {
287 if connected_relay_count(client).await.0 > 0 {
288 return true;
289 }
290 if start.elapsed() > timeout {
291 return false;
292 }
293 tokio::time::sleep(Duration::from_millis(50)).await;
294 }
295}
296
297type FetchedRefs = (HashMap<String, String>, Option<String>, Option<[u8; 32]>);
298
299#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
300enum RootResolveSource {
301 #[default]
302 Relay,
303 LocalDaemon,
304}
305
306#[derive(Debug, Clone)]
307struct ResolvedRoot {
308 root_hash: Option<String>,
309 encryption_key: Option<[u8; 32]>,
310 source: Option<RootResolveSource>,
311}
312use hashtree_config::Config;
313
314#[derive(Debug, Clone)]
316pub struct RelayResult {
317 #[allow(dead_code)]
319 pub configured: Vec<String>,
320 pub connected: Vec<String>,
322 pub failed: Vec<String>,
324}
325
326#[derive(Debug, Clone, Default, PartialEq, Eq)]
328pub struct RepoAnnouncementOptions {
329 pub earliest_unique_commit: Option<String>,
331 pub personal_fork: bool,
333 pub forked_from: Option<String>,
335}
336
337#[derive(Debug, Clone)]
339pub struct BlossomResult {
340 #[allow(dead_code)]
342 pub configured: Vec<String>,
343 pub succeeded: Vec<String>,
345 pub failed: Vec<String>,
347 pub local_complete: bool,
349 pub degraded: bool,
351}
352
353pub struct NostrClient {
355 pubkey: String,
356 keys: Option<Keys>,
358 relays: Vec<String>,
359 blossom: BlossomClient,
360 cached_refs: HashMap<String, HashMap<String, String>>,
362 cached_root_hash: HashMap<String, String>,
364 cached_encryption_key: HashMap<String, [u8; 32]>,
366 cached_root_source: HashMap<String, RootResolveSource>,
368 url_secret: Option<[u8; 32]>,
371 is_private: bool,
373 local_daemon_url: Option<String>,
375 local_daemon_only: bool,
377 #[cfg(test)]
378 forced_fetch_refs_results: std::collections::VecDeque<Result<FetchedRefs, String>>,
379}
380
381#[derive(Debug, Clone, Default)]
382struct RootEventData {
383 root_hash: String,
384 encryption_key: Option<[u8; 32]>,
385 key_tag_name: Option<String>,
386 self_encrypted_ciphertext: Option<String>,
387 source: RootResolveSource,
388 daemon_source: Option<String>,
389 event_created_at: Option<u64>,
390 event_id: Option<String>,
391}
392
393#[derive(Debug, Deserialize)]
394struct DaemonResolveResponse {
395 hash: Option<String>,
396 #[serde(default)]
397 cid: Option<String>,
398 #[serde(default, rename = "key_tag")]
399 key: Option<String>,
400 #[serde(default, rename = "encryptedKey")]
401 encrypted_key: Option<String>,
402 #[serde(default, rename = "selfEncryptedKey")]
403 self_encrypted_key: Option<String>,
404 #[serde(default)]
405 source: Option<String>,
406 #[serde(default)]
407 created_at: Option<u64>,
408 #[serde(default)]
409 event_id: Option<String>,
410}
411
412impl NostrClient {
413 pub fn new(
415 pubkey: &str,
416 secret_key: Option<String>,
417 url_secret: Option<[u8; 32]>,
418 is_private: bool,
419 config: &Config,
420 ) -> Result<Self> {
421 let local_daemon_only = std::env::var("HTREE_LOCAL_DAEMON_ONLY")
422 .map(|value| {
423 !matches!(
424 value.trim().to_ascii_lowercase().as_str(),
425 "" | "0" | "false" | "no" | "off"
426 )
427 })
428 .unwrap_or(false);
429 Self::new_with_local_daemon_only(
430 pubkey,
431 secret_key,
432 url_secret,
433 is_private,
434 config,
435 local_daemon_only,
436 )
437 }
438
439 fn new_with_local_daemon_only(
440 pubkey: &str,
441 secret_key: Option<String>,
442 url_secret: Option<[u8; 32]>,
443 is_private: bool,
444 config: &Config,
445 local_daemon_only: bool,
446 ) -> Result<Self> {
447 let _ = rustls::crypto::ring::default_provider().install_default();
449
450 let secret_key = secret_key.or_else(|| std::env::var("NOSTR_SECRET_KEY").ok());
452
453 let keys = if let Some(ref secret_hex) = secret_key {
455 let secret_bytes = hex::decode(secret_hex).context("Invalid secret key hex")?;
456 let secret = nostr::SecretKey::from_slice(&secret_bytes)
457 .map_err(|e| anyhow::anyhow!("Invalid secret key: {}", e))?;
458 Some(Keys::new(secret))
459 } else {
460 None
461 };
462
463 let detected_local_daemon =
464 hashtree_config::detect_local_daemon_url(Some(config.server.bind_address.as_str()));
465 let local_daemon_url = detected_local_daemon.clone().or_else(|| {
466 config
467 .blossom
468 .read_servers
469 .iter()
470 .find(|url| {
471 url.starts_with("http://127.0.0.1:") || url.starts_with("http://localhost:")
472 })
473 .cloned()
474 });
475 if local_daemon_only && local_daemon_url.is_none() {
476 anyhow::bail!(
477 "HTREE_LOCAL_DAEMON_ONLY requires a running local htree daemon at {}",
478 config.server.bind_address
479 );
480 }
481
482 let blossom_keys = keys.clone().unwrap_or_else(Keys::generate);
485 let mut read_servers = if local_daemon_only {
486 vec![local_daemon_url.clone().expect("checked above")]
487 } else {
488 config.blossom.all_read_servers()
489 };
490 if let Some(local_url) = detected_local_daemon {
491 if !read_servers.iter().any(|server| server == &local_url) {
492 read_servers.insert(0, local_url);
493 }
494 }
495 let write_servers = if local_daemon_only {
496 vec![local_daemon_url.clone().expect("checked above")]
497 } else {
498 config.blossom.all_write_servers()
499 };
500 let blossom = BlossomClient::new_empty(blossom_keys)
501 .with_read_servers(read_servers)
502 .with_write_servers(write_servers)
503 .with_timeout(Duration::from_secs(120));
504
505 tracing::info!(
506 "BlossomClient created with read_servers: {:?}, write_servers: {:?}",
507 blossom.read_servers(),
508 blossom.write_servers()
509 );
510
511 let relays = if local_daemon_only {
512 Vec::new()
513 } else {
514 hashtree_config::resolve_relays(
515 &config.nostr.relays,
516 Some(config.server.bind_address.as_str()),
517 )
518 };
519
520 Ok(Self {
521 pubkey: pubkey.to_string(),
522 keys,
523 relays,
524 blossom,
525 cached_refs: HashMap::new(),
526 cached_root_hash: HashMap::new(),
527 cached_encryption_key: HashMap::new(),
528 cached_root_source: HashMap::new(),
529 url_secret,
530 is_private,
531 local_daemon_url,
532 local_daemon_only,
533 #[cfg(test)]
534 forced_fetch_refs_results: std::collections::VecDeque::new(),
535 })
536 }
537
538 #[cfg(test)]
539 pub(crate) fn force_fetch_refs_error_for_test(&mut self, message: impl Into<String>) {
540 self.forced_fetch_refs_results
541 .push_back(Err(message.into()));
542 }
543
544 #[cfg(test)]
545 pub(crate) fn force_fetch_refs_success_for_test(
546 &mut self,
547 refs: HashMap<String, String>,
548 root_hash: Option<String>,
549 encryption_key: Option<[u8; 32]>,
550 ) {
551 self.forced_fetch_refs_results
552 .push_back(Ok((refs, root_hash, encryption_key)));
553 }
554
555 #[cfg(test)]
556 pub(crate) fn cache_root_for_test(
557 &mut self,
558 repo_name: &str,
559 root_hash: String,
560 encryption_key: Option<[u8; 32]>,
561 ) {
562 self.cached_root_hash
563 .insert(repo_name.to_string(), root_hash);
564 if let Some(key) = encryption_key {
565 self.cached_encryption_key
566 .insert(repo_name.to_string(), key);
567 } else {
568 self.cached_encryption_key.remove(repo_name);
569 }
570 self.cached_root_source
571 .insert(repo_name.to_string(), RootResolveSource::Relay);
572 }
573
574 #[cfg(test)]
575 fn pop_forced_fetch_refs_result(&mut self, repo_name: &str) -> Option<Result<FetchedRefs>> {
576 self.forced_fetch_refs_results
577 .pop_front()
578 .map(|result| match result {
579 Ok((refs, root_hash, encryption_key)) => {
580 if let Some(root) = &root_hash {
581 self.cached_root_hash
582 .insert(repo_name.to_string(), root.clone());
583 } else {
584 self.cached_root_hash.remove(repo_name);
585 }
586 if let Some(key) = encryption_key {
587 self.cached_encryption_key
588 .insert(repo_name.to_string(), key);
589 } else {
590 self.cached_encryption_key.remove(repo_name);
591 }
592 self.cached_root_source
593 .insert(repo_name.to_string(), RootResolveSource::Relay);
594 self.cached_refs.insert(repo_name.to_string(), refs.clone());
595 Ok((refs, root_hash, encryption_key))
596 }
597 Err(message) => Err(anyhow::anyhow!(message)),
598 })
599 }
600
601 fn format_repo_author(pubkey_hex: &str) -> String {
602 PublicKey::from_hex(pubkey_hex)
603 .ok()
604 .and_then(|pk| pk.to_bech32().ok())
605 .unwrap_or_else(|| pubkey_hex.to_string())
606 }
607
608 fn daemon_pubkey_identifier(&self) -> String {
609 Self::format_repo_author(&self.pubkey)
610 }
611
612 #[allow(dead_code)]
614 pub fn can_sign(&self) -> bool {
615 self.keys.is_some()
616 }
617
618 pub fn list_repos(&self) -> Result<Vec<String>> {
619 block_on_result(self.list_repos_async())
620 }
621
622 pub async fn list_repos_async(&self) -> Result<Vec<String>> {
623 let client = Client::default();
624
625 for relay in &self.relays {
626 if let Err(e) = client.add_relay(relay).await {
627 warn!("Failed to add relay {}: {}", relay, e);
628 }
629 }
630 client.connect().await;
631
632 if !wait_for_any_connected_relay(&client, Duration::from_secs(2)).await {
633 let _ = client.disconnect().await;
634 return Err(anyhow::anyhow!(
635 "Failed to connect to any relay while listing repos"
636 ));
637 }
638
639 let author = PublicKey::from_hex(&self.pubkey)
640 .map_err(|e| anyhow::anyhow!("Invalid pubkey: {}", e))?;
641 let filter = build_git_repo_list_filter(author);
642
643 let events = match tokio::time::timeout(
644 Duration::from_secs(3),
645 client.fetch_events(filter, Duration::from_secs(3)),
646 )
647 .await
648 {
649 Ok(Ok(events)) => events,
650 Ok(Err(e)) => {
651 let _ = client.disconnect().await;
652 return Err(anyhow::anyhow!(
653 "Failed to fetch git repo events from relays: {}",
654 e
655 ));
656 }
657 Err(_) => {
658 let _ = client.disconnect().await;
659 return Err(anyhow::anyhow!(
660 "Timed out fetching git repo events from relays"
661 ));
662 }
663 };
664
665 let _ = client.disconnect().await;
666 let events = events.to_vec();
667
668 Ok(list_git_repo_announcements(&events)
669 .into_iter()
670 .map(|repo| repo.repo_name)
671 .collect())
672 }
673
674 pub fn fetch_refs(&mut self, repo_name: &str) -> Result<HashMap<String, String>> {
677 #[cfg(test)]
678 if let Some(result) = self.pop_forced_fetch_refs_result(repo_name) {
679 let (refs, _, _) = result?;
680 return Ok(refs);
681 }
682
683 let (refs, _, _) = self.fetch_refs_with_timeout(repo_name, 10)?;
684 Ok(refs)
685 }
686
687 #[allow(dead_code)]
690 pub fn fetch_refs_quick(&mut self, repo_name: &str) -> Result<HashMap<String, String>> {
691 let (refs, _, _) = self.fetch_refs_with_timeout(repo_name, 3)?;
692 Ok(refs)
693 }
694
695 #[allow(dead_code)]
698 pub fn fetch_refs_with_root(&mut self, repo_name: &str) -> Result<FetchedRefs> {
699 #[cfg(test)]
700 if let Some(result) = self.pop_forced_fetch_refs_result(repo_name) {
701 return result;
702 }
703
704 self.fetch_refs_with_timeout(repo_name, 10)
705 }
706
707 pub(crate) fn refetch_refs_without_local_daemon(
708 &mut self,
709 repo_name: &str,
710 timeout_secs: u64,
711 ) -> Result<FetchedRefs> {
712 self.clear_cached_remote_state(repo_name);
713 self.fetch_refs_with_timeout_uncached(repo_name, timeout_secs, false)
714 }
715
716 fn clear_cached_remote_state(&mut self, repo_name: &str) {
717 self.cached_refs.remove(repo_name);
718 self.cached_root_hash.remove(repo_name);
719 self.cached_encryption_key.remove(repo_name);
720 self.cached_root_source.remove(repo_name);
721 }
722
723 fn cache_resolved_root(&mut self, repo_name: &str, resolved: &ResolvedRoot) {
724 if let Some(ref root) = resolved.root_hash {
725 self.cached_root_hash
726 .insert(repo_name.to_string(), root.clone());
727 } else {
728 self.cached_root_hash.remove(repo_name);
729 }
730
731 if let Some(key) = resolved.encryption_key {
732 self.cached_encryption_key
733 .insert(repo_name.to_string(), key);
734 } else {
735 self.cached_encryption_key.remove(repo_name);
736 }
737
738 if let Some(source) = resolved.source {
739 self.cached_root_source
740 .insert(repo_name.to_string(), source);
741 } else {
742 self.cached_root_source.remove(repo_name);
743 }
744 }
745
746 fn fetch_refs_with_timeout(
748 &mut self,
749 repo_name: &str,
750 timeout_secs: u64,
751 ) -> Result<FetchedRefs> {
752 debug!(
753 "Fetching refs for {} from {} (timeout {}s)",
754 repo_name, self.pubkey, timeout_secs
755 );
756
757 if let Some(refs) = self.cached_refs.get(repo_name) {
759 let root = self.cached_root_hash.get(repo_name).cloned();
760 let key = self.cached_encryption_key.get(repo_name).cloned();
761 return Ok((refs.clone(), root, key));
762 }
763
764 self.fetch_refs_with_timeout_uncached(repo_name, timeout_secs, true)
765 }
766
767 fn fetch_refs_with_timeout_uncached(
768 &mut self,
769 repo_name: &str,
770 timeout_secs: u64,
771 allow_local_daemon: bool,
772 ) -> Result<FetchedRefs> {
773 let resolved = block_on_result(self.resolve_root_async_with_timeout(
774 repo_name,
775 timeout_secs,
776 allow_local_daemon,
777 ))?;
778
779 match self.fetch_refs_for_resolved_root(repo_name, &resolved) {
780 Ok(fetched) => Ok(fetched),
781 Err(err)
782 if resolved.source == Some(RootResolveSource::LocalDaemon)
783 && allow_local_daemon
784 && self.local_daemon_only =>
785 {
786 Err(err).with_context(|| {
787 format!(
788 "local-daemon-only fetch for {repo_name} failed; relay/Blossom fallback disabled"
789 )
790 })
791 }
792 Err(err)
793 if resolved.source == Some(RootResolveSource::LocalDaemon)
794 && allow_local_daemon
795 && !self.local_daemon_only =>
796 {
797 warn!(
798 "Local daemon root for {} could not be read as a valid git tree: {}. Retrying via relays.",
799 repo_name, err
800 );
801 self.clear_cached_remote_state(repo_name);
802 self.fetch_refs_with_timeout_uncached(repo_name, timeout_secs, false)
803 }
804 Err(err) => Err(err),
805 }
806 }
807
808 fn fetch_refs_for_resolved_root(
809 &mut self,
810 repo_name: &str,
811 resolved: &ResolvedRoot,
812 ) -> Result<FetchedRefs> {
813 if resolved.source != Some(RootResolveSource::LocalDaemon) {
814 self.cache_resolved_root(repo_name, resolved);
815 }
816
817 let refs = if let Some(ref root) = resolved.root_hash {
818 block_on_result(self.fetch_refs_from_hashtree(root, resolved.encryption_key.as_ref()))?
819 } else {
820 HashMap::new()
821 };
822
823 self.cache_resolved_root(repo_name, resolved);
824 self.cached_refs.insert(repo_name.to_string(), refs.clone());
825 Ok((refs, resolved.root_hash.clone(), resolved.encryption_key))
826 }
827
828 fn parse_root_event_data_from_event(event: &Event) -> RootEventData {
829 let root_hash = event
830 .tags
831 .iter()
832 .find(|t| t.as_slice().len() >= 2 && t.as_slice()[0].as_str() == "hash")
833 .map(|t| t.as_slice()[1].to_string())
834 .unwrap_or_else(|| event.content.to_string());
835
836 let (encryption_key, key_tag_name, self_encrypted_ciphertext) = event
837 .tags
838 .iter()
839 .find_map(|t| {
840 let slice = t.as_slice();
841 if slice.len() < 2 {
842 return None;
843 }
844 let tag_name = slice[0].as_str();
845 let tag_value = slice[1].to_string();
846 if tag_name == "selfEncryptedKey" {
847 return Some((None, Some(tag_name.to_string()), Some(tag_value)));
848 }
849 if tag_name == "key" || tag_name == "encryptedKey" {
850 if let Ok(bytes) = hex::decode(&tag_value) {
851 if bytes.len() == 32 {
852 let mut key = [0u8; 32];
853 key.copy_from_slice(&bytes);
854 return Some((Some(key), Some(tag_name.to_string()), None));
855 }
856 }
857 }
858 None
859 })
860 .unwrap_or((None, None, None));
861
862 RootEventData {
863 root_hash,
864 encryption_key,
865 key_tag_name,
866 self_encrypted_ciphertext,
867 source: RootResolveSource::Relay,
868 daemon_source: None,
869 event_created_at: Some(event.created_at.as_secs()),
870 event_id: Some(event.id.to_hex()),
871 }
872 }
873
874 fn parse_daemon_response_to_root_data(
875 response: DaemonResolveResponse,
876 ) -> Option<RootEventData> {
877 let parsed_cid = response.cid.as_deref().and_then(|cid| Cid::parse(cid).ok());
878 let daemon_source = response.source.clone();
879 let event_created_at = response.created_at;
880 let event_id = response.event_id.clone();
881 let root_hash = response
882 .hash
883 .or_else(|| parsed_cid.as_ref().map(|cid| hex::encode(cid.hash)))?;
884 if root_hash.is_empty() {
885 return None;
886 }
887
888 let mut data = RootEventData {
889 root_hash,
890 encryption_key: parsed_cid.and_then(|cid| cid.key),
891 key_tag_name: None,
892 self_encrypted_ciphertext: None,
893 source: RootResolveSource::LocalDaemon,
894 daemon_source,
895 event_created_at,
896 event_id,
897 };
898
899 if let Some(ciphertext) = response.self_encrypted_key {
900 data.key_tag_name = Some("selfEncryptedKey".to_string());
901 data.self_encrypted_ciphertext = Some(ciphertext);
902 return Some(data);
903 }
904
905 let (tag_name, tag_value) = if let Some(v) = response.encrypted_key {
906 ("encryptedKey", v)
907 } else if let Some(v) = response.key {
908 ("key", v)
909 } else {
910 return Some(data);
911 };
912
913 if let Ok(bytes) = hex::decode(&tag_value) {
914 if bytes.len() == 32 {
915 let mut key = [0u8; 32];
916 key.copy_from_slice(&bytes);
917 data.encryption_key = Some(key);
918 data.key_tag_name = Some(tag_name.to_string());
919 }
920 }
921
922 Some(data)
923 }
924
925 async fn fetch_root_from_local_daemon(
926 &self,
927 repo_name: &str,
928 timeout: Duration,
929 ) -> Option<RootEventData> {
930 let base = self.local_daemon_url.as_ref()?;
931 let pubkey = self.daemon_pubkey_identifier();
932 let refresh = if self.local_daemon_only {
933 ""
934 } else {
935 "?refresh=1"
936 };
937 let url = format!(
938 "{}/api/nostr/resolve/{}/{}{}",
939 base.trim_end_matches('/'),
940 pubkey,
941 repo_name,
942 refresh,
943 );
944
945 let client = reqwest::Client::builder().timeout(timeout).build().ok()?;
946 let response = client.get(&url).send().await.ok()?;
947 if !response.status().is_success() {
948 return None;
949 }
950
951 let payload: DaemonResolveResponse = response.json().await.ok()?;
952 let source = payload
953 .source
954 .clone()
955 .unwrap_or_else(|| "unknown".to_string());
956 let parsed = Self::parse_daemon_response_to_root_data(payload)?;
957 debug!(
958 "Resolved repo {} via local daemon source={}",
959 repo_name, source
960 );
961 Some(parsed)
962 }
963
964 fn daemon_root_needs_relay_confirmation(data: &RootEventData) -> bool {
965 if data.source != RootResolveSource::LocalDaemon {
966 return false;
967 }
968
969 !matches!(data.daemon_source.as_deref(), Some("nostr-relay" | "nostr"))
970 }
971
972 fn root_event_is_newer(left: &RootEventData, right: &RootEventData) -> bool {
973 match (left.event_created_at, right.event_created_at) {
974 (Some(left_time), Some(right_time)) => {
975 (left_time, left.event_id.as_deref().unwrap_or(""))
976 > (right_time, right.event_id.as_deref().unwrap_or(""))
977 }
978 (Some(_), None) => true,
979 _ => false,
980 }
981 }
982
983 fn choose_newer_root_data(fallback: RootEventData, relay: RootEventData) -> RootEventData {
984 if Self::root_event_is_newer(&fallback, &relay) {
985 fallback
986 } else {
987 relay
988 }
989 }
990
991 async fn cache_public_root_in_local_daemon(
992 &self,
993 repo_name: &str,
994 root_hash: &str,
995 encryption_key: Option<(&[u8; 32], bool, bool)>,
996 ) {
997 let Some(base) = self.local_daemon_url.as_ref() else {
998 return;
999 };
1000
1001 match encryption_key {
1002 Some((_key, false, false)) => {}
1003 Some((_key, _is_link_visible, _is_self_private)) => return,
1004 None => {}
1005 }
1006
1007 let url = format!("{}/api/cache-tree-root", base.trim_end_matches('/'));
1008 let pubkey = self.daemon_pubkey_identifier();
1009 let payload = serde_json::json!({
1010 "npub": pubkey,
1011 "treeName": repo_name,
1012 "hash": root_hash,
1013 "visibility": "public",
1014 });
1015
1016 let client = match reqwest::Client::builder()
1017 .timeout(Duration::from_secs(2))
1018 .build()
1019 {
1020 Ok(client) => client,
1021 Err(err) => {
1022 debug!("Could not build local daemon cache client: {}", err);
1023 return;
1024 }
1025 };
1026
1027 match client.post(url).json(&payload).send().await {
1028 Ok(response) if response.status().is_success() => {
1029 debug!("Cached repo root for {} in local daemon", repo_name);
1030 }
1031 Ok(response) => {
1032 debug!(
1033 "Local daemon root cache returned status {} for {}",
1034 response.status(),
1035 repo_name
1036 );
1037 }
1038 Err(err) => {
1039 debug!("Could not cache repo root in local daemon: {}", err);
1040 }
1041 }
1042 }
1043
1044 async fn resolve_root_async_with_timeout(
1045 &self,
1046 repo_name: &str,
1047 timeout_secs: u64,
1048 allow_local_daemon: bool,
1049 ) -> Result<ResolvedRoot> {
1050 let local_daemon_timeout = local_daemon_query_timeout(timeout_secs, self.local_daemon_only);
1053 if self.local_daemon_only {
1054 if !allow_local_daemon {
1055 anyhow::bail!(
1056 "local-daemon-only root lookup for {repo_name} failed; relay fallback disabled"
1057 );
1058 }
1059 let root_data = self
1060 .fetch_root_from_local_daemon(repo_name, local_daemon_timeout)
1061 .await
1062 .ok_or_else(|| {
1063 anyhow::anyhow!(
1064 "local-daemon-only root lookup returned no root for {repo_name}; relay fallback disabled"
1065 )
1066 })?;
1067 return self.finish_resolved_root(repo_name, root_data);
1068 }
1069
1070 let client = Client::default();
1072
1073 for relay in &self.relays {
1075 if let Err(e) = client.add_relay(relay).await {
1076 warn!("Failed to add relay {}: {}", relay, e);
1077 }
1078 }
1079
1080 client.connect().await;
1082
1083 let connect_timeout = Duration::from_secs(2);
1084 let query_timeout = Duration::from_secs(timeout_secs.saturating_sub(2).max(3));
1085 let retry_delay = Duration::from_millis(300);
1086 let max_attempts = 2;
1087
1088 let start = std::time::Instant::now();
1089
1090 let author = PublicKey::from_hex(&self.pubkey)
1092 .map_err(|e| anyhow::anyhow!("Invalid pubkey: {}", e))?;
1093
1094 let filter = build_repo_event_filter(author, repo_name);
1095
1096 debug!("Querying relays for repo {} events", repo_name);
1097
1098 let mut root_data = None;
1099 let mut daemon_fallback: Option<RootEventData> = None;
1100 for attempt in 1..=max_attempts {
1101 if allow_local_daemon {
1102 if let Some(data) = self
1103 .fetch_root_from_local_daemon(repo_name, local_daemon_timeout)
1104 .await
1105 {
1106 if Self::daemon_root_needs_relay_confirmation(&data) {
1107 debug!(
1108 "Local daemon resolved {} via {}; checking relays for fresher root",
1109 repo_name,
1110 data.daemon_source.as_deref().unwrap_or("unknown")
1111 );
1112 daemon_fallback = Some(data);
1113 } else {
1114 root_data = Some(data);
1115 break;
1116 }
1117 }
1118 }
1119
1120 if !allow_local_daemon && attempt == 1 {
1121 debug!(
1122 "Skipping local daemon while resolving {} because relay retry was requested",
1123 repo_name
1124 );
1125 }
1126
1127 if allow_local_daemon && daemon_fallback.is_none() {
1128 debug!(
1129 "Local daemon did not resolve {}; querying relays",
1130 repo_name
1131 );
1132 }
1133
1134 let connect_start = std::time::Instant::now();
1137 let mut last_log = std::time::Instant::now();
1138 let mut has_connected_relay = false;
1139 loop {
1140 let (connected, total) = connected_relay_count(&client).await;
1141 if connected > 0 {
1142 debug!(
1143 "Connected to {}/{} relay(s) in {:?} (attempt {}/{})",
1144 connected,
1145 total,
1146 start.elapsed(),
1147 attempt,
1148 max_attempts
1149 );
1150 has_connected_relay = true;
1151 break;
1152 }
1153 if last_log.elapsed() > Duration::from_millis(500) {
1154 debug!(
1155 "Connecting to relays... (0/{} after {:?}, attempt {}/{})",
1156 total,
1157 start.elapsed(),
1158 attempt,
1159 max_attempts
1160 );
1161 last_log = std::time::Instant::now();
1162 }
1163 if connect_start.elapsed() > connect_timeout {
1164 debug!(
1165 "Timeout waiting for relay connections - continuing with local-daemon fallback"
1166 );
1167 break;
1168 }
1169 tokio::time::sleep(Duration::from_millis(50)).await;
1170 }
1171
1172 if !has_connected_relay {
1176 if let Some(data) = daemon_fallback.take() {
1177 debug!(
1178 "Using local daemon root for {} because no relay connected",
1179 repo_name
1180 );
1181 root_data = Some(data);
1182 break;
1183 }
1184 }
1185
1186 let events = if has_connected_relay {
1187 match client.fetch_events(filter.clone(), query_timeout).await {
1188 Ok(events) => events.to_vec(),
1189 Err(e) => {
1190 warn!("Failed to fetch events: {}", e);
1191 vec![]
1192 }
1193 }
1194 } else {
1195 vec![]
1196 };
1197
1198 debug!(
1199 "Got {} events from relays on attempt {}/{}",
1200 events.len(),
1201 attempt,
1202 max_attempts
1203 );
1204 let relay_event = pick_latest_repo_event(events.iter(), repo_name);
1205
1206 if let Some(event) = relay_event {
1207 debug!(
1208 "Found relay event with root hash: {}",
1209 &event.content[..12.min(event.content.len())]
1210 );
1211 let relay_data = Self::parse_root_event_data_from_event(event);
1212 root_data = Some(match daemon_fallback.take() {
1213 Some(fallback) => Self::choose_newer_root_data(fallback, relay_data),
1214 None => relay_data,
1215 });
1216 break;
1217 }
1218
1219 if attempt < max_attempts {
1220 debug!(
1221 "No relay hashtree event found for {} on attempt {}/{}; retrying",
1222 repo_name, attempt, max_attempts
1223 );
1224 tokio::time::sleep(retry_delay).await;
1225 } else if let Some(data) = daemon_fallback.take() {
1226 debug!(
1227 "Using local daemon root for {} after relay lookup returned no event",
1228 repo_name
1229 );
1230 root_data = Some(data);
1231 break;
1232 }
1233 }
1234
1235 let _ = client.disconnect().await;
1237
1238 let root_data = match root_data {
1239 Some(data) => data,
1240 None => {
1241 anyhow::bail!(
1242 "Repository '{}' not found (no hashtree event published by {})",
1243 repo_name,
1244 Self::format_repo_author(&self.pubkey)
1245 );
1246 }
1247 };
1248
1249 self.finish_resolved_root(repo_name, root_data)
1250 }
1251
1252 fn finish_resolved_root(
1253 &self,
1254 repo_name: &str,
1255 root_data: RootEventData,
1256 ) -> Result<ResolvedRoot> {
1257 let root_hash = root_data.root_hash;
1258
1259 if root_hash.is_empty() {
1260 debug!("Empty root hash in event");
1261 return Ok(ResolvedRoot {
1262 root_hash: None,
1263 encryption_key: None,
1264 source: None,
1265 });
1266 }
1267
1268 let encryption_key = root_data.encryption_key;
1269 let key_tag_name = root_data.key_tag_name;
1270 let self_encrypted_ciphertext = root_data.self_encrypted_ciphertext;
1271 let root_source = root_data.source;
1272
1273 let unmasked_key = match key_tag_name.as_deref() {
1275 Some("encryptedKey") => {
1276 if let (Some(masked), Some(secret)) = (encryption_key, self.url_secret) {
1278 let mut unmasked = [0u8; 32];
1279 for i in 0..32 {
1280 unmasked[i] = masked[i] ^ secret[i];
1281 }
1282 Some(unmasked)
1283 } else {
1284 anyhow::bail!(
1285 "This repo is link-visible and requires a secret key.\n\
1286 Use: htree://.../{repo_name}#k=<secret>\n\
1287 Ask the repo owner for the full URL with the secret."
1288 );
1289 }
1290 }
1291 Some("selfEncryptedKey") => {
1292 if !self.is_private {
1294 anyhow::bail!(
1295 "This repo is private (author-only).\n\
1296 Use: htree://.../{repo_name}#private\n\
1297 Only the author can access this repo."
1298 );
1299 }
1300
1301 if let Some(keys) = &self.keys {
1303 if let Some(ciphertext) = self_encrypted_ciphertext {
1304 let pubkey = keys.public_key();
1306 match nip44::decrypt(keys.secret_key(), &pubkey, &ciphertext) {
1307 Ok(key_hex) => {
1308 let key_bytes =
1309 hex::decode(&key_hex).context("Invalid decrypted key hex")?;
1310 if key_bytes.len() != 32 {
1311 anyhow::bail!("Decrypted key wrong length");
1312 }
1313 let mut key = [0u8; 32];
1314 key.copy_from_slice(&key_bytes);
1315 Some(key)
1316 }
1317 Err(e) => {
1318 anyhow::bail!(
1319 "Failed to decrypt private repo: {}\n\
1320 The repo may be corrupted or published with a different key.",
1321 e
1322 );
1323 }
1324 }
1325 } else {
1326 anyhow::bail!("selfEncryptedKey tag has invalid format");
1327 }
1328 } else {
1329 anyhow::bail!(
1330 "Cannot access this private repo.\n\
1331 Private repos can only be accessed by their author.\n\
1332 You don't have the secret key for this repo's owner."
1333 );
1334 }
1335 }
1336 Some("key") | None => {
1337 encryption_key
1339 }
1340 Some(other) => {
1341 warn!("Unknown key tag type: {}", other);
1342 encryption_key
1343 }
1344 };
1345
1346 info!(
1347 "Found root hash {} for {} (encrypted: {}, link_visible: {})",
1348 &root_hash[..12.min(root_hash.len())],
1349 repo_name,
1350 unmasked_key.is_some(),
1351 self.url_secret.is_some()
1352 );
1353
1354 Ok(ResolvedRoot {
1355 root_hash: Some(root_hash),
1356 encryption_key: unmasked_key,
1357 source: Some(root_source),
1358 })
1359 }
1360
1361 fn decrypt_and_decode(&self, data: &[u8], key: Option<&[u8; 32]>) -> Result<TreeNode> {
1363 let decrypted_data: Vec<u8>;
1364 let data_to_decode = if let Some(k) = key {
1365 decrypted_data = decrypt_chk(data, k).context("Decryption failed")?;
1366 &decrypted_data
1367 } else {
1368 data
1369 };
1370
1371 decode_tree_node(data_to_decode).context("Failed to decode tree node")
1372 }
1373
1374 async fn fetch_refs_from_hashtree(
1377 &self,
1378 root_hash: &str,
1379 encryption_key: Option<&[u8; 32]>,
1380 ) -> Result<HashMap<String, String>> {
1381 let mut refs = HashMap::new();
1382 debug!(
1383 "fetch_refs_from_hashtree: downloading root {}",
1384 &root_hash[..12]
1385 );
1386
1387 let root_data = match self.blossom.download(root_hash).await {
1389 Ok(data) => {
1390 debug!("Downloaded {} bytes from blossom", data.len());
1391 data
1392 }
1393 Err(e) => {
1394 anyhow::bail!(
1395 "Failed to download root hash {}: {}",
1396 &root_hash[..12.min(root_hash.len())],
1397 e
1398 );
1399 }
1400 };
1401
1402 let root_node = self
1404 .decrypt_and_decode(&root_data, encryption_key)
1405 .with_context(|| {
1406 format!(
1407 "Failed to decode root node {} (encrypted: {})",
1408 &root_hash[..12.min(root_hash.len())],
1409 encryption_key.is_some()
1410 )
1411 })?;
1412 debug!("Decoded root node with {} links", root_node.links.len());
1413
1414 debug!(
1416 "Root links: {:?}",
1417 root_node
1418 .links
1419 .iter()
1420 .map(|l| l.name.as_deref())
1421 .collect::<Vec<_>>()
1422 );
1423 let git_link = root_node
1424 .links
1425 .iter()
1426 .find(|l| l.name.as_deref() == Some(".git"));
1427 let (git_hash, git_key) = match git_link {
1428 Some(link) => {
1429 debug!("Found .git link with key: {}", link.key.is_some());
1430 (hex::encode(link.hash), link.key)
1431 }
1432 None => {
1433 debug!("No .git directory in hashtree root");
1434 return Ok(refs);
1435 }
1436 };
1437
1438 let git_data = match self.blossom.download(&git_hash).await {
1440 Ok(data) => data,
1441 Err(e) => {
1442 anyhow::bail!(
1443 "Failed to download .git directory ({}): {}",
1444 &git_hash[..12],
1445 e
1446 );
1447 }
1448 };
1449
1450 let git_node = self
1451 .decrypt_and_decode(&git_data, git_key.as_ref())
1452 .with_context(|| {
1453 format!(
1454 "Failed to decode .git directory {} (encrypted: {})",
1455 &git_hash[..12.min(git_hash.len())],
1456 git_key.is_some()
1457 )
1458 })?;
1459 debug!(
1460 "Decoded .git node with {} links: {:?}",
1461 git_node.links.len(),
1462 git_node
1463 .links
1464 .iter()
1465 .map(|l| l.name.as_deref())
1466 .collect::<Vec<_>>()
1467 );
1468
1469 let refs_link = git_node
1471 .links
1472 .iter()
1473 .find(|l| l.name.as_deref() == Some("refs"));
1474 let (refs_hash, refs_key) = match refs_link {
1475 Some(link) => (hex::encode(link.hash), link.key),
1476 None => {
1477 debug!("No refs directory in .git");
1478 return Ok(refs);
1479 }
1480 };
1481
1482 let refs_data =
1484 self.blossom.download(&refs_hash).await.with_context(|| {
1485 format!("Failed to download refs directory {}", &refs_hash[..12])
1486 })?;
1487
1488 let refs_node = self
1489 .decrypt_and_decode(&refs_data, refs_key.as_ref())
1490 .with_context(|| {
1491 format!(
1492 "Failed to decode refs directory {} (encrypted: {})",
1493 &refs_hash[..12.min(refs_hash.len())],
1494 refs_key.is_some()
1495 )
1496 })?;
1497
1498 if let Some(head_link) = git_node
1500 .links
1501 .iter()
1502 .find(|l| l.name.as_deref() == Some("HEAD"))
1503 {
1504 let head_hash = hex::encode(head_link.hash);
1505 let head_data = self
1506 .blossom
1507 .download(&head_hash)
1508 .await
1509 .with_context(|| format!("Failed to download HEAD {}", &head_hash[..12]))?;
1510 let head_content = if let Some(k) = head_link.key.as_ref() {
1512 let decrypted = decrypt_chk(&head_data, k)
1513 .with_context(|| format!("Failed to decrypt HEAD {}", &head_hash[..12]))?;
1514 String::from_utf8_lossy(&decrypted).trim().to_string()
1515 } else {
1516 String::from_utf8_lossy(&head_data).trim().to_string()
1517 };
1518 refs.insert("HEAD".to_string(), head_content);
1519 }
1520
1521 for subdir_link in &refs_node.links {
1523 if subdir_link.link_type != LinkType::Dir {
1524 continue;
1525 }
1526 let subdir_name = match &subdir_link.name {
1527 Some(n) => n.clone(),
1528 None => continue,
1529 };
1530 let subdir_hash = hex::encode(subdir_link.hash);
1531
1532 self.collect_refs_recursive(
1533 &subdir_hash,
1534 subdir_link.key.as_ref(),
1535 &format!("refs/{}", subdir_name),
1536 &mut refs,
1537 )
1538 .await?;
1539 }
1540
1541 debug!("Found {} refs from hashtree", refs.len());
1542 Ok(refs)
1543 }
1544
1545 async fn collect_refs_recursive(
1547 &self,
1548 dir_hash: &str,
1549 dir_key: Option<&[u8; 32]>,
1550 prefix: &str,
1551 refs: &mut HashMap<String, String>,
1552 ) -> Result<()> {
1553 let dir_data = self
1554 .blossom
1555 .download(dir_hash)
1556 .await
1557 .with_context(|| format!("Failed to download refs subtree {}", &dir_hash[..12]))?;
1558
1559 let dir_node = self
1560 .decrypt_and_decode(&dir_data, dir_key)
1561 .with_context(|| {
1562 format!(
1563 "Failed to decode refs subtree {} (encrypted: {})",
1564 &dir_hash[..12.min(dir_hash.len())],
1565 dir_key.is_some()
1566 )
1567 })?;
1568
1569 for link in &dir_node.links {
1570 let name = match &link.name {
1571 Some(n) => n.clone(),
1572 None => continue,
1573 };
1574 let link_hash = hex::encode(link.hash);
1575 let ref_path = format!("{}/{}", prefix, name);
1576
1577 if link.link_type == LinkType::Dir {
1578 Box::pin(self.collect_refs_recursive(
1580 &link_hash,
1581 link.key.as_ref(),
1582 &ref_path,
1583 refs,
1584 ))
1585 .await?;
1586 } else {
1587 let ref_data = self
1589 .blossom
1590 .download(&link_hash)
1591 .await
1592 .with_context(|| format!("Failed to download ref {}", ref_path))?;
1593 let sha = if let Some(k) = link.key.as_ref() {
1595 let decrypted = decrypt_chk(&ref_data, k)
1596 .with_context(|| format!("Failed to decrypt ref {}", ref_path))?;
1597 String::from_utf8_lossy(&decrypted).trim().to_string()
1598 } else {
1599 String::from_utf8_lossy(&ref_data).trim().to_string()
1600 };
1601 if !sha.is_empty() {
1602 debug!("Found ref {} -> {}", ref_path, sha);
1603 refs.insert(ref_path, sha);
1604 }
1605 }
1606 }
1607
1608 Ok(())
1609 }
1610
1611 #[allow(dead_code)]
1613 pub fn update_ref(&mut self, repo_name: &str, ref_name: &str, sha: &str) -> Result<()> {
1614 info!("Updating ref {} -> {} for {}", ref_name, sha, repo_name);
1615
1616 let refs = self.cached_refs.entry(repo_name.to_string()).or_default();
1617 refs.insert(ref_name.to_string(), sha.to_string());
1618
1619 Ok(())
1620 }
1621
1622 pub fn delete_ref(&mut self, repo_name: &str, ref_name: &str) -> Result<()> {
1624 info!("Deleting ref {} for {}", ref_name, repo_name);
1625
1626 if let Some(refs) = self.cached_refs.get_mut(repo_name) {
1627 refs.remove(ref_name);
1628 }
1629
1630 Ok(())
1631 }
1632
1633 pub fn get_cached_root_hash(&self, repo_name: &str) -> Option<&String> {
1635 self.cached_root_hash.get(repo_name)
1636 }
1637
1638 pub fn get_cached_encryption_key(&self, repo_name: &str) -> Option<&[u8; 32]> {
1640 self.cached_encryption_key.get(repo_name)
1641 }
1642
1643 pub(crate) fn cached_root_is_from_local_daemon(&self, repo_name: &str) -> bool {
1644 self.cached_root_source.get(repo_name) == Some(&RootResolveSource::LocalDaemon)
1645 }
1646
1647 pub(crate) fn local_daemon_only(&self) -> bool {
1648 self.local_daemon_only
1649 }
1650
1651 pub fn blossom(&self) -> &BlossomClient {
1653 &self.blossom
1654 }
1655
1656 pub fn relay_urls(&self) -> Vec<String> {
1658 self.relays.clone()
1659 }
1660
1661 #[allow(dead_code)]
1663 pub fn pubkey(&self) -> &str {
1664 &self.pubkey
1665 }
1666
1667 pub fn npub(&self) -> String {
1669 PublicKey::from_hex(&self.pubkey)
1670 .ok()
1671 .and_then(|pk| pk.to_bech32().ok())
1672 .unwrap_or_else(|| self.pubkey.clone())
1673 }
1674
1675 pub fn publish_repo(
1683 &self,
1684 repo_name: &str,
1685 root_hash: &str,
1686 encryption_key: Option<(&[u8; 32], bool, bool)>,
1687 ) -> Result<(String, RelayResult)> {
1688 self.publish_repo_with_announcement(repo_name, root_hash, encryption_key, None)
1689 }
1690
1691 pub fn publish_repo_with_announcement(
1692 &self,
1693 repo_name: &str,
1694 root_hash: &str,
1695 encryption_key: Option<(&[u8; 32], bool, bool)>,
1696 repo_announcement: Option<RepoAnnouncementOptions>,
1697 ) -> Result<(String, RelayResult)> {
1698 let keys = self.keys.as_ref().context(format!(
1699 "Cannot push: no secret key for {}. You can only push to your own repos.",
1700 &self.pubkey[..16]
1701 ))?;
1702
1703 info!(
1704 "Publishing repo {} with root hash {} (encrypted: {})",
1705 repo_name,
1706 root_hash,
1707 encryption_key.is_some()
1708 );
1709
1710 block_on_result(async {
1712 tokio::time::timeout(
1713 REPO_PUBLISH_TIMEOUT,
1714 self.publish_repo_async(
1715 keys,
1716 repo_name,
1717 root_hash,
1718 encryption_key,
1719 repo_announcement,
1720 ),
1721 )
1722 .await
1723 .map_err(|_| anyhow::anyhow!("Repository metadata publication timed out"))?
1724 })
1725 }
1726
1727 async fn publish_repo_async(
1728 &self,
1729 keys: &Keys,
1730 repo_name: &str,
1731 root_hash: &str,
1732 encryption_key: Option<(&[u8; 32], bool, bool)>,
1733 repo_announcement: Option<RepoAnnouncementOptions>,
1734 ) -> Result<(String, RelayResult)> {
1735 if self.local_daemon_only {
1736 return self
1737 .publish_repo_to_local_daemon(keys, repo_name, root_hash, encryption_key)
1738 .await;
1739 }
1740
1741 let client = Client::new(keys.clone());
1743
1744 let configured: Vec<String> = self.relays.clone();
1745 let mut connected: Vec<String> = Vec::new();
1746 let mut failed: Vec<String> = Vec::new();
1747
1748 for relay in &self.relays {
1750 if let Err(e) = client.add_relay(relay).await {
1751 warn!("Failed to add relay {}: {}", relay, e);
1752 failed.push(relay.clone());
1753 }
1754 }
1755
1756 client.connect().await;
1758
1759 let _ = wait_for_any_connected_relay(&client, Duration::from_secs(3)).await;
1761
1762 let publish_created_at = next_replaceable_created_at(
1763 Timestamp::now(),
1764 latest_repo_event_created_at(
1765 &client,
1766 keys.public_key(),
1767 repo_name,
1768 Duration::from_secs(2),
1769 )
1770 .await,
1771 );
1772
1773 let mut tags = vec![
1775 Tag::custom(TagKind::custom("d"), vec![repo_name.to_string()]),
1776 Tag::custom(TagKind::custom("l"), vec![LABEL_HASHTREE.to_string()]),
1777 Tag::custom(TagKind::custom("hash"), vec![root_hash.to_string()]),
1778 ];
1779
1780 if let Some((key, is_link_visible, is_self_private)) = encryption_key {
1786 if is_self_private {
1787 let pubkey = keys.public_key();
1789 let key_hex = hex::encode(key);
1790 let encrypted =
1791 nip44::encrypt(keys.secret_key(), &pubkey, &key_hex, nip44::Version::V2)
1792 .map_err(|e| anyhow::anyhow!("NIP-44 encryption failed: {}", e))?;
1793 tags.push(Tag::custom(
1794 TagKind::custom("selfEncryptedKey"),
1795 vec![encrypted],
1796 ));
1797 } else if is_link_visible {
1798 tags.push(Tag::custom(
1800 TagKind::custom("encryptedKey"),
1801 vec![hex::encode(key)],
1802 ));
1803 } else {
1804 tags.push(Tag::custom(TagKind::custom("key"), vec![hex::encode(key)]));
1806 }
1807 }
1808
1809 append_repo_discovery_labels(&mut tags, repo_name);
1810
1811 let event = EventBuilder::new(Kind::Custom(KIND_HASHTREE_ROOT), root_hash)
1813 .tags(tags)
1814 .custom_created_at(publish_created_at)
1815 .sign_with_keys(keys)
1816 .map_err(|e| anyhow::anyhow!("Failed to sign event: {}", e))?;
1817
1818 let ready_relays = client
1819 .relays()
1820 .await
1821 .into_iter()
1822 .filter_map(|(url, relay)| relay.is_connected().then_some(url))
1823 .collect::<Vec<_>>();
1824
1825 match client.send_event_to(ready_relays, &event).await {
1827 Ok(output) => {
1828 for url in output.success.iter() {
1830 let url_str = url.to_string();
1831 if !connected.contains(&url_str) {
1832 connected.push(url_str);
1833 }
1834 }
1835 for (url, _err) in output.failed.iter() {
1837 let url_str = url.to_string();
1838 if !failed.contains(&url_str) && !connected.contains(&url_str) {
1839 failed.push(url_str);
1840 }
1841 }
1842 info!(
1843 "Sent event {} to {} relays ({} failed)",
1844 output.id(),
1845 output.success.len(),
1846 output.failed.len()
1847 );
1848 }
1849 Err(e) => {
1850 warn!("Failed to send event: {}", e);
1851 for relay in &self.relays {
1853 if !failed.contains(relay) {
1854 failed.push(relay.clone());
1855 }
1856 }
1857 }
1858 };
1859
1860 let npub_url = keys
1862 .public_key()
1863 .to_bech32()
1864 .map(|npub| format!("htree://{}/{}", npub, repo_name))
1865 .unwrap_or_else(|_| format!("htree://{}/{}", &self.pubkey[..16], repo_name));
1866
1867 let relay_validation = validate_repo_publish_relays(&configured, &connected);
1868
1869 if relay_validation.is_ok() {
1870 if let Some(repo_announcement) = repo_announcement.as_ref() {
1871 if let Err(err) = self
1872 .publish_repo_announcement_async(
1873 &client,
1874 keys,
1875 repo_name,
1876 &npub_url,
1877 repo_announcement,
1878 )
1879 .await
1880 {
1881 warn!("Failed to publish NIP-34 repo announcement: {}", err);
1882 }
1883 }
1884 }
1885
1886 let _ = client.disconnect().await;
1888 tokio::time::sleep(Duration::from_millis(50)).await;
1889
1890 relay_validation?;
1891
1892 self.cache_public_root_in_local_daemon(repo_name, root_hash, encryption_key)
1893 .await;
1894
1895 Ok((
1896 npub_url,
1897 RelayResult {
1898 configured,
1899 connected,
1900 failed,
1901 },
1902 ))
1903 }
1904
1905 async fn publish_repo_to_local_daemon(
1906 &self,
1907 keys: &Keys,
1908 repo_name: &str,
1909 root_hash: &str,
1910 encryption_key: Option<(&[u8; 32], bool, bool)>,
1911 ) -> Result<(String, RelayResult)> {
1912 let existing_created_at = self
1913 .fetch_root_from_local_daemon(repo_name, Duration::from_secs(2))
1914 .await
1915 .and_then(|root| root.event_created_at)
1916 .map(Timestamp::from_secs);
1917 let mut tags = vec![
1918 Tag::custom(TagKind::custom("d"), vec![repo_name.to_string()]),
1919 Tag::custom(TagKind::custom("l"), vec![LABEL_HASHTREE.to_string()]),
1920 Tag::custom(TagKind::custom("hash"), vec![root_hash.to_string()]),
1921 ];
1922 if let Some((key, is_link_visible, is_self_private)) = encryption_key {
1923 if is_self_private {
1924 let key_hex = hex::encode(key);
1925 let encrypted = nip44::encrypt(
1926 keys.secret_key(),
1927 &keys.public_key(),
1928 &key_hex,
1929 nip44::Version::V2,
1930 )
1931 .map_err(|error| anyhow::anyhow!("NIP-44 encryption failed: {error}"))?;
1932 tags.push(Tag::custom(
1933 TagKind::custom("selfEncryptedKey"),
1934 vec![encrypted],
1935 ));
1936 } else if is_link_visible {
1937 tags.push(Tag::custom(
1938 TagKind::custom("encryptedKey"),
1939 vec![hex::encode(key)],
1940 ));
1941 } else {
1942 tags.push(Tag::custom(TagKind::custom("key"), vec![hex::encode(key)]));
1943 }
1944 }
1945 append_repo_discovery_labels(&mut tags, repo_name);
1946 let event = EventBuilder::new(Kind::Custom(KIND_HASHTREE_ROOT), root_hash)
1947 .tags(tags)
1948 .custom_created_at(next_replaceable_created_at(
1949 Timestamp::now(),
1950 existing_created_at,
1951 ))
1952 .sign_with_keys(keys)
1953 .map_err(|error| anyhow::anyhow!("Failed to sign event: {error}"))?;
1954 self.publish_event_to_local_daemon(&event).await?;
1955
1956 let npub_url = keys
1957 .public_key()
1958 .to_bech32()
1959 .map(|npub| format!("htree://{npub}/{repo_name}"))
1960 .unwrap_or_else(|_| format!("htree://{}/{repo_name}", &self.pubkey[..16]));
1961 Ok((
1962 npub_url,
1963 RelayResult {
1964 configured: Vec::new(),
1965 connected: Vec::new(),
1966 failed: Vec::new(),
1967 },
1968 ))
1969 }
1970
1971 async fn publish_event_to_local_daemon(&self, event: &Event) -> Result<()> {
1972 let base = self.local_daemon_url.as_ref().ok_or_else(|| {
1973 anyhow::anyhow!("local-daemon-only publication requires a running htree daemon")
1974 })?;
1975 let response = reqwest::Client::builder()
1976 .timeout(Duration::from_secs(4))
1977 .build()?
1978 .post(format!("{}/api/nostr/events", base.trim_end_matches('/')))
1979 .json(event)
1980 .send()
1981 .await
1982 .context("local-daemon-only Nostr publication failed")?;
1983 if !response.status().is_success() {
1984 let status = response.status();
1985 let detail = response.text().await.unwrap_or_default();
1986 anyhow::bail!(
1987 "local-daemon-only Nostr publication failed with {status}: {detail}; relay fallback disabled"
1988 );
1989 }
1990 Ok(())
1991 }
1992
1993 fn build_repo_announcement_tags(
1994 repo_name: &str,
1995 clone_url: &str,
1996 relays: &[String],
1997 options: &RepoAnnouncementOptions,
1998 ) -> Vec<Tag> {
1999 let mut tags = vec![
2000 Tag::custom(TagKind::custom("d"), vec![repo_name.to_string()]),
2001 Tag::custom(TagKind::custom("name"), vec![repo_name.to_string()]),
2002 Tag::custom(TagKind::custom("clone"), vec![clone_url.to_string()]),
2003 ];
2004
2005 if let Some(web_url) = Self::iris_git_web_url_for_htree_clone(clone_url) {
2006 tags.push(Tag::custom(TagKind::custom("web"), vec![web_url]));
2007 }
2008
2009 if !relays.is_empty() {
2010 tags.push(Tag::custom(TagKind::custom("relays"), relays.to_vec()));
2011 }
2012
2013 if let Some(euc) = options
2014 .earliest_unique_commit
2015 .as_deref()
2016 .map(str::trim)
2017 .filter(|value| !value.is_empty())
2018 {
2019 tags.push(Tag::custom(
2020 TagKind::custom("r"),
2021 vec![euc.to_string(), "euc".to_string()],
2022 ));
2023 }
2024
2025 if options.personal_fork {
2026 tags.push(Tag::custom(
2027 TagKind::custom("t"),
2028 vec!["personal-fork".to_string()],
2029 ));
2030 }
2031
2032 if let Some(forked_from) = options
2033 .forked_from
2034 .as_deref()
2035 .map(str::trim)
2036 .filter(|value| !value.is_empty())
2037 {
2038 tags.push(Tag::custom(
2039 TagKind::custom("forked-from"),
2040 vec![forked_from.to_string()],
2041 ));
2042 }
2043
2044 tags
2045 }
2046
2047 fn iris_git_web_url_for_htree_clone(clone_url: &str) -> Option<String> {
2048 let raw = clone_url.strip_prefix("htree://")?;
2049 let path = raw.split('#').next().unwrap_or(raw);
2050 let (owner, repo_path) = path.split_once('/')?;
2051 if !owner.starts_with("npub1") || repo_path.is_empty() {
2052 return None;
2053 }
2054
2055 let path = std::iter::once(owner)
2056 .chain(repo_path.split('/').filter(|segment| !segment.is_empty()))
2057 .map(Self::percent_encode_path_segment)
2058 .collect::<Vec<_>>()
2059 .join("/");
2060
2061 Some(format!("{IRIS_GIT_WEB_BASE_URL}/{path}"))
2062 }
2063
2064 fn percent_encode_path_segment(segment: &str) -> String {
2065 let mut encoded = String::with_capacity(segment.len());
2066 for byte in segment.bytes() {
2067 match byte {
2068 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
2069 encoded.push(byte as char)
2070 }
2071 _ => encoded.push_str(&format!("%{byte:02X}")),
2072 }
2073 }
2074 encoded
2075 }
2076
2077 async fn publish_repo_announcement_async(
2078 &self,
2079 client: &Client,
2080 keys: &Keys,
2081 repo_name: &str,
2082 clone_url: &str,
2083 options: &RepoAnnouncementOptions,
2084 ) -> Result<()> {
2085 let created_at = next_replaceable_created_at(
2086 Timestamp::now(),
2087 latest_repo_announcement_created_at(
2088 client,
2089 keys.public_key(),
2090 repo_name,
2091 Duration::from_secs(2),
2092 )
2093 .await,
2094 );
2095 let tags = Self::build_repo_announcement_tags(repo_name, clone_url, &self.relays, options);
2096 let event = EventBuilder::new(Kind::Custom(KIND_REPO_ANNOUNCEMENT), "")
2097 .tags(tags)
2098 .custom_created_at(created_at)
2099 .sign_with_keys(keys)
2100 .map_err(|e| anyhow::anyhow!("Failed to sign NIP-34 repo announcement: {}", e))?;
2101
2102 let output = client
2103 .send_event(&event)
2104 .await
2105 .map_err(|e| anyhow::anyhow!("Failed to publish NIP-34 repo announcement: {}", e))?;
2106 if output.success.is_empty() {
2107 anyhow::bail!("NIP-34 repo announcement was not confirmed by any relay");
2108 }
2109
2110 info!(
2111 "Published NIP-34 repo announcement {} to {} relays",
2112 output.id(),
2113 output.success.len()
2114 );
2115 Ok(())
2116 }
2117
2118 pub fn fetch_repo_announcement_euc(
2119 &self,
2120 owner_pubkey_hex: &str,
2121 repo_name: &str,
2122 ) -> Result<Option<String>> {
2123 block_on_result(self.fetch_repo_announcement_euc_async(owner_pubkey_hex, repo_name))
2124 }
2125
2126 async fn fetch_repo_announcement_euc_async(
2127 &self,
2128 owner_pubkey_hex: &str,
2129 repo_name: &str,
2130 ) -> Result<Option<String>> {
2131 let owner = PublicKey::from_hex(owner_pubkey_hex)
2132 .map_err(|e| anyhow::anyhow!("Invalid repo owner pubkey: {}", e))?;
2133 let client = Client::default();
2134
2135 for relay in &self.relays {
2136 if let Err(e) = client.add_relay(relay).await {
2137 debug!(
2138 "Failed to add relay {} for repo announcement lookup: {}",
2139 relay, e
2140 );
2141 }
2142 }
2143 client.connect().await;
2144
2145 if !wait_for_any_connected_relay(&client, Duration::from_secs(2)).await {
2146 let _ = client.disconnect().await;
2147 return Ok(None);
2148 }
2149
2150 let filter = build_repo_announcement_filter(owner, repo_name);
2151 let events = match tokio::time::timeout(
2152 Duration::from_secs(3),
2153 client.fetch_events(filter, Duration::from_secs(3)),
2154 )
2155 .await
2156 {
2157 Ok(Ok(events)) => events.to_vec(),
2158 Ok(Err(err)) => {
2159 debug!(
2160 "Failed to fetch NIP-34 repo announcement for {}: {}",
2161 repo_name, err
2162 );
2163 Vec::new()
2164 }
2165 Err(_) => {
2166 debug!(
2167 "Timed out fetching NIP-34 repo announcement for {}",
2168 repo_name
2169 );
2170 Vec::new()
2171 }
2172 };
2173
2174 let _ = client.disconnect().await;
2175 Ok(pick_latest_event(events.iter()).and_then(extract_repo_announcement_euc))
2176 }
2177
2178 pub fn fetch_prs(
2180 &self,
2181 repo_name: &str,
2182 state_filter: PullRequestStateFilter,
2183 ) -> Result<Vec<PullRequestListItem>> {
2184 block_on_result(self.fetch_prs_async(repo_name, state_filter))
2185 }
2186
2187 pub async fn fetch_prs_async(
2188 &self,
2189 repo_name: &str,
2190 state_filter: PullRequestStateFilter,
2191 ) -> Result<Vec<PullRequestListItem>> {
2192 let client = Client::default();
2193
2194 for relay in &self.relays {
2195 if let Err(e) = client.add_relay(relay).await {
2196 warn!("Failed to add relay {}: {}", relay, e);
2197 }
2198 }
2199 client.connect().await;
2200
2201 if !wait_for_any_connected_relay(&client, Duration::from_secs(2)).await {
2203 let _ = client.disconnect().await;
2204 return Err(anyhow::anyhow!(
2205 "Failed to connect to any relay while fetching PRs"
2206 ));
2207 }
2208
2209 let repo_address = format!("{}:{}:{}", KIND_REPO_ANNOUNCEMENT, self.pubkey, repo_name);
2211 let pull_request_filter = Filter::new()
2212 .kind(Kind::Custom(KIND_PULL_REQUEST))
2213 .custom_tag(
2214 SingleLetterTag::lowercase(Alphabet::A),
2215 repo_address.clone(),
2216 );
2217
2218 let mut pr_events = match tokio::time::timeout(
2219 Duration::from_secs(3),
2220 client.fetch_events(pull_request_filter.clone(), Duration::from_secs(3)),
2221 )
2222 .await
2223 {
2224 Ok(Ok(events)) => events.to_vec(),
2225 Ok(Err(e)) => {
2226 let _ = client.disconnect().await;
2227 return Err(anyhow::anyhow!(
2228 "Failed to fetch PR events from relays: {}",
2229 e
2230 ));
2231 }
2232 Err(_) => {
2233 let _ = client.disconnect().await;
2234 return Err(anyhow::anyhow!("Timed out fetching PR events from relays"));
2235 }
2236 };
2237
2238 if pr_events.is_empty() {
2239 let fallback = fetch_events_via_raw_relay_query(
2240 &self.relays,
2241 pull_request_filter,
2242 Duration::from_secs(3),
2243 )
2244 .await;
2245 if !fallback.completed {
2246 let _ = client.disconnect().await;
2247 return Err(anyhow::anyhow!(
2248 "Timed out waiting for a complete PR event query"
2249 ));
2250 }
2251 if !fallback.events.is_empty() {
2252 debug!(
2253 "Raw relay fallback recovered {} PR event(s) for {}",
2254 fallback.events.len(),
2255 repo_name
2256 );
2257 pr_events = fallback.events;
2258 }
2259 }
2260
2261 if pr_events.is_empty() {
2262 let _ = client.disconnect().await;
2263 return Ok(Vec::new());
2264 }
2265
2266 let pr_ids: Vec<String> = pr_events.iter().map(|e| e.id.to_hex()).collect();
2268
2269 let status_event_filter = Filter::new()
2271 .kinds(vec![
2272 Kind::Custom(KIND_STATUS_OPEN),
2273 Kind::Custom(KIND_STATUS_APPLIED),
2274 Kind::Custom(KIND_STATUS_CLOSED),
2275 Kind::Custom(KIND_STATUS_DRAFT),
2276 ])
2277 .custom_tags(
2278 SingleLetterTag::lowercase(Alphabet::E),
2279 pr_ids.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
2280 );
2281
2282 let mut status_events = match tokio::time::timeout(
2283 Duration::from_secs(3),
2284 client.fetch_events(status_event_filter.clone(), Duration::from_secs(3)),
2285 )
2286 .await
2287 {
2288 Ok(Ok(events)) => events.to_vec(),
2289 Ok(Err(e)) => {
2290 let _ = client.disconnect().await;
2291 return Err(anyhow::anyhow!(
2292 "Failed to fetch PR status events from relays: {}",
2293 e
2294 ));
2295 }
2296 Err(_) => {
2297 let _ = client.disconnect().await;
2298 return Err(anyhow::anyhow!(
2299 "Timed out fetching PR status events from relays"
2300 ));
2301 }
2302 };
2303
2304 if status_events.is_empty() {
2305 let fallback = fetch_events_via_raw_relay_query(
2306 &self.relays,
2307 status_event_filter,
2308 Duration::from_secs(3),
2309 )
2310 .await;
2311 if !fallback.completed {
2312 let _ = client.disconnect().await;
2313 return Err(anyhow::anyhow!(
2314 "Timed out waiting for a complete PR status query"
2315 ));
2316 }
2317 if !fallback.events.is_empty() {
2318 debug!(
2319 "Raw relay fallback recovered {} PR status event(s) for {}",
2320 fallback.events.len(),
2321 repo_name
2322 );
2323 status_events = fallback.events;
2324 }
2325 }
2326
2327 let _ = client.disconnect().await;
2328
2329 let latest_status =
2331 latest_trusted_pr_status_kinds(&pr_events, &status_events, &self.pubkey);
2332
2333 let mut prs = Vec::new();
2334 for event in &pr_events {
2335 let pr_id = event.id.to_hex();
2336 let state =
2337 PullRequestState::from_latest_status_kind(latest_status.get(&pr_id).copied());
2338 if !state_filter.includes(state) {
2339 continue;
2340 }
2341
2342 let mut subject = None;
2343 let mut commit_tip = None;
2344 let mut branch = None;
2345 let mut target_branch = None;
2346
2347 for tag in event.tags.iter() {
2348 let slice = tag.as_slice();
2349 if slice.len() >= 2 {
2350 match slice[0].as_str() {
2351 "subject" => subject = Some(slice[1].to_string()),
2352 "c" => commit_tip = Some(slice[1].to_string()),
2353 "branch" => branch = Some(slice[1].to_string()),
2354 "target-branch" => target_branch = Some(slice[1].to_string()),
2355 _ => {}
2356 }
2357 }
2358 }
2359
2360 prs.push(PullRequestListItem {
2361 event_id: pr_id,
2362 author_pubkey: event.pubkey.to_hex(),
2363 state,
2364 subject,
2365 commit_tip,
2366 branch,
2367 target_branch,
2368 created_at: event.created_at.as_secs(),
2369 });
2370 }
2371
2372 prs.sort_by(|left, right| {
2374 right
2375 .created_at
2376 .cmp(&left.created_at)
2377 .then_with(|| right.event_id.cmp(&left.event_id))
2378 });
2379
2380 debug!(
2381 "Found {} PRs for {} (filter: {:?})",
2382 prs.len(),
2383 repo_name,
2384 state_filter
2385 );
2386 Ok(prs)
2387 }
2388
2389 pub fn publish_pr_merged_status(
2391 &self,
2392 pr_event_id: &str,
2393 pr_author_pubkey: &str,
2394 ) -> Result<()> {
2395 let keys = self
2396 .keys
2397 .as_ref()
2398 .context("Cannot publish status: no secret key")?;
2399
2400 block_on_result(self.publish_pr_merged_status_async(keys, pr_event_id, pr_author_pubkey))
2401 }
2402
2403 async fn publish_pr_merged_status_async(
2404 &self,
2405 keys: &Keys,
2406 pr_event_id: &str,
2407 pr_author_pubkey: &str,
2408 ) -> Result<()> {
2409 let client = Client::new(keys.clone());
2410
2411 for relay in &self.relays {
2412 if let Err(e) = client.add_relay(relay).await {
2413 warn!("Failed to add relay {}: {}", relay, e);
2414 }
2415 }
2416 client.connect().await;
2417
2418 if !wait_for_any_connected_relay(&client, Duration::from_secs(3)).await {
2420 anyhow::bail!("Failed to connect to any relay for status publish");
2421 }
2422
2423 let tags = vec![
2424 Tag::custom(TagKind::custom("e"), vec![pr_event_id.to_string()]),
2425 Tag::custom(TagKind::custom("p"), vec![pr_author_pubkey.to_string()]),
2426 ];
2427
2428 let event = EventBuilder::new(Kind::Custom(KIND_STATUS_APPLIED), "")
2429 .tags(tags)
2430 .sign_with_keys(keys)
2431 .map_err(|e| anyhow::anyhow!("Failed to sign status event: {}", e))?;
2432
2433 let publish_result = match client.send_event(&event).await {
2434 Ok(output) => {
2435 if output.success.is_empty() {
2436 Err(anyhow::anyhow!(
2437 "PR merged status was not confirmed by any relay"
2438 ))
2439 } else {
2440 info!(
2441 "Published PR merged status to {} relays",
2442 output.success.len()
2443 );
2444 Ok(())
2445 }
2446 }
2447 Err(e) => Err(anyhow::anyhow!("Failed to publish PR merged status: {}", e)),
2448 };
2449
2450 let _ = client.disconnect().await;
2451 tokio::time::sleep(Duration::from_millis(50)).await;
2452 publish_result
2453 }
2454
2455 #[allow(dead_code)]
2457 pub async fn upload_blob(&self, _hash: &str, data: &[u8]) -> Result<String> {
2458 let hash = self
2459 .blossom
2460 .upload(data)
2461 .await
2462 .map_err(|e| anyhow::anyhow!("Blossom upload failed: {}", e))?;
2463 Ok(hash)
2464 }
2465
2466 #[allow(dead_code)]
2468 pub async fn upload_blob_if_missing(&self, data: &[u8]) -> Result<(String, bool)> {
2469 self.blossom
2470 .upload_if_missing(data)
2471 .await
2472 .map_err(|e| anyhow::anyhow!("Blossom upload failed: {}", e))
2473 }
2474
2475 #[allow(dead_code)]
2477 pub async fn download_blob(&self, hash: &str) -> Result<Vec<u8>> {
2478 self.blossom
2479 .download(hash)
2480 .await
2481 .map_err(|e| anyhow::anyhow!("Blossom download failed: {}", e))
2482 }
2483
2484 #[allow(dead_code)]
2486 pub async fn try_download_blob(&self, hash: &str) -> Option<Vec<u8>> {
2487 self.blossom.try_download(hash).await
2488 }
2489}
2490
2491#[cfg(test)]
2492mod tests;