upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-01-07 12:24:42 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-01-07 12:24:42 +0000
commite72edbae86affcb9fc0429bd197639bf438ffb6c (patch)
treeb59f31287d10fe9b22d7e5af4cb3aa94ef06dc8a /src
parent18bfb246029a848a0b307e7c8a8e4df57addabb2 (diff)
Add unified process_newly_available_git_data function
Implement the unified function that handles all post-git-data-available processing, regardless of how data arrived (git push or purgatory sync). This function: - Discovers satisfiable events from purgatory (state and PR events) - Syncs OIDs to authorized owner repos - Aligns refs and sets HEAD - Saves events to database - Notifies WebSocket subscribers - Removes from purgatory New additions: - ProcessResult struct for tracking processing outcomes - process_newly_available_git_data async function in src/git/sync.rs - Helper functions: extract_identifier_from_repo_path, extract_identifier_from_pr_event - Purgatory::find_prs_for_identifier method for PR event discovery - Unit tests for all helper functions Also fixes: - Simplified extract_domain to avoid url crate dependency - Removed unused imports in sync/loop.rs
Diffstat (limited to 'src')
-rw-r--r--src/git/sync.rs648
-rw-r--r--src/purgatory/mod.rs27
-rw-r--r--src/purgatory/sync/functions.rs22
-rw-r--r--src/purgatory/sync/loop.rs2
4 files changed, 694 insertions, 5 deletions
diff --git a/src/git/sync.rs b/src/git/sync.rs
index 9a8af5a..e57a0cc 100644
--- a/src/git/sync.rs
+++ b/src/git/sync.rs
@@ -16,6 +16,18 @@
16//! repository identifier, and they may share maintainers. When a state event 16//! repository identifier, and they may share maintainers. When a state event
17//! authorizes a push, that push should be reflected in ALL owner repositories 17//! authorizes a push, that push should be reflected in ALL owner repositories
18//! that would authorize the same state. 18//! that would authorize the same state.
19//!
20//! ## Unified Processing
21//!
22//! The `process_newly_available_git_data` function provides unified processing
23//! for newly available git data, regardless of how it arrived (git push or
24//! purgatory sync). This ensures consistent behavior for:
25//! - Discovering satisfiable events from purgatory
26//! - Syncing OIDs to authorized owner repos
27//! - Aligning refs (+ setting HEAD)
28//! - Saving events to database
29//! - Notifying WebSocket subscribers
30//! - Removing from purgatory
19 31
20use std::collections::{HashMap, HashSet}; 32use std::collections::{HashMap, HashSet};
21use std::path::Path; 33use std::path::Path;
@@ -24,9 +36,55 @@ use tracing::{debug, info, warn};
24 36
25use nostr_sdk::Event; 37use nostr_sdk::Event;
26 38
27use crate::git::authorization::{collect_authorized_maintainers, RepositoryData}; 39use crate::git::authorization::{
40 collect_authorized_maintainers, fetch_repository_data, pubkey_authorised_for_repo_owners,
41 RepositoryData,
42};
28use crate::git::{self, oid_exists}; 43use crate::git::{self, oid_exists};
44use crate::nostr::builder::SharedDatabase;
29use crate::nostr::events::RepositoryState; 45use crate::nostr::events::RepositoryState;
46use crate::purgatory::{can_satisfy_state, Purgatory};
47
48/// Result of processing newly available git data.
49///
50/// This struct captures what happened when we tried to release events from
51/// purgatory after new git data became available (whether from a git push
52/// or from purgatory sync fetching OIDs from remote servers).
53#[derive(Debug, Default, Clone)]
54pub struct ProcessResult {
55 /// Number of state events released from purgatory
56 pub states_released: usize,
57 /// Number of PR events released from purgatory
58 pub prs_released: usize,
59 /// Number of repositories synced (OIDs copied + refs aligned)
60 pub repos_synced: usize,
61 /// Number of refs created across all repos
62 pub refs_created: usize,
63 /// Number of refs updated across all repos
64 pub refs_updated: usize,
65 /// Number of refs deleted across all repos
66 pub refs_deleted: usize,
67 /// Errors encountered (non-fatal)
68 pub errors: Vec<String>,
69}
70
71impl ProcessResult {
72 /// Check if any events were released
73 pub fn released_any(&self) -> bool {
74 self.states_released > 0 || self.prs_released > 0
75 }
76
77 /// Merge another ProcessResult into this one
78 pub fn merge(&mut self, other: ProcessResult) {
79 self.states_released += other.states_released;
80 self.prs_released += other.prs_released;
81 self.repos_synced += other.repos_synced;
82 self.refs_created += other.refs_created;
83 self.refs_updated += other.refs_updated;
84 self.refs_deleted += other.refs_deleted;
85 self.errors.extend(other.errors);
86 }
87}
30 88
31/// Result of syncing git data to owner repositories 89/// Result of syncing git data to owner repositories
32#[derive(Debug, Default)] 90#[derive(Debug, Default)]
@@ -665,11 +723,599 @@ pub fn align_repository_with_state(repo_path: &Path, state: &RepositoryState) ->
665 result 723 result
666} 724}
667 725
726// =============================================================================
727// Unified Git Data Processing
728// =============================================================================
729
730/// Extract repository identifier from a repository path.
731///
732/// Given a path like `{git_data_path}/{npub}/{identifier}.git`, extracts the identifier.
733///
734/// # Arguments
735/// * `repo_path` - Full path to the git repository
736/// * `git_data_path` - Base path for git repositories
737///
738/// # Returns
739/// The identifier if the path matches the expected pattern, None otherwise
740pub fn extract_identifier_from_repo_path(repo_path: &Path, git_data_path: &Path) -> Option<String> {
741 // Get the relative path from git_data_path
742 let relative = repo_path.strip_prefix(git_data_path).ok()?;
743
744 // Expected structure: {npub}/{identifier}.git
745 let components: Vec<_> = relative.components().collect();
746 if components.len() != 2 {
747 return None;
748 }
749
750 // Get the repo directory name (e.g., "my-repo.git")
751 let repo_name = components[1].as_os_str().to_str()?;
752
753 // Strip the .git suffix
754 repo_name.strip_suffix(".git").map(|s| s.to_string())
755}
756
757/// Extract repository identifier from a PR event.
758///
759/// PR events reference repositories via `a` tags with format `30617:<owner_pubkey>:<identifier>`.
760/// This function extracts the identifier from the first matching `a` tag.
761///
762/// # Arguments
763/// * `event` - The PR event (kind 1617 or 1618)
764///
765/// # Returns
766/// The identifier if found, None otherwise
767pub fn extract_identifier_from_pr_event(event: &Event) -> Option<String> {
768 for tag in event.tags.iter() {
769 let tag_vec = tag.clone().to_vec();
770 if tag_vec.len() >= 2 && tag_vec[0] == "a" && tag_vec[1].starts_with("30617:") {
771 // Format: 30617:<owner_pubkey>:<identifier>
772 let parts: Vec<&str> = tag_vec[1].split(':').collect();
773 if parts.len() >= 3 {
774 return Some(parts[2].to_string());
775 }
776 }
777 }
778 None
779}
780
781/// Unified processing of newly available git data.
782///
783/// This function is called whenever git data becomes available, whether from:
784/// - A successful `git push` (handle_receive_pack)
785/// - Purgatory sync fetching OIDs from remote servers
786///
787/// It handles all post-git-data-available processing:
788/// 1. Discovers satisfiable events from purgatory (state events and PR events)
789/// 2. For each satisfiable state event:
790/// - Syncs OIDs to authorized owner repos
791/// - Aligns refs (+ sets HEAD)
792/// - Saves event to database
793/// - Notifies WebSocket subscribers
794/// - Removes from purgatory
795/// 3. For each satisfiable PR event:
796/// - Syncs commit to owner repos
797/// - Creates refs/nostr/<event-id> refs
798/// - Saves event to database
799/// - Notifies WebSocket subscribers
800/// - Removes from purgatory
801///
802/// # Arguments
803/// * `source_repo_path` - Path to the repository that has the new git data
804/// * `new_oids` - Set of OIDs that were just made available (used for logging/debugging)
805/// * `database` - Database for saving events and querying repository data
806/// * `local_relay` - Local relay for notifying WebSocket subscribers (optional)
807/// * `purgatory` - Purgatory instance to check for satisfiable events
808/// * `git_data_path` - Base path for git repositories
809///
810/// # Returns
811/// A `ProcessResult` describing what was processed
812pub async fn process_newly_available_git_data(
813 source_repo_path: &Path,
814 new_oids: &HashSet<String>,
815 database: &SharedDatabase,
816 local_relay: Option<&nostr_relay_builder::LocalRelay>,
817 purgatory: &Purgatory,
818 git_data_path: &Path,
819) -> anyhow::Result<ProcessResult> {
820 let mut result = ProcessResult::default();
821
822 // Extract identifier from repo path
823 let identifier = match extract_identifier_from_repo_path(source_repo_path, git_data_path) {
824 Some(id) => id,
825 None => {
826 debug!(
827 repo_path = %source_repo_path.display(),
828 "Could not extract identifier from repo path"
829 );
830 return Ok(result);
831 }
832 };
833
834 debug!(
835 identifier = %identifier,
836 new_oids_count = new_oids.len(),
837 "Processing newly available git data"
838 );
839
840 // Get current refs from the repository for state matching
841 let current_refs: HashMap<String, String> = git::list_refs(source_repo_path)
842 .unwrap_or_default()
843 .into_iter()
844 .collect();
845
846 // Process state events from purgatory
847 let state_result =
848 process_purgatory_state_events(&identifier, source_repo_path, &current_refs, database, local_relay, purgatory, git_data_path).await;
849 result.merge(state_result);
850
851 // Process PR events from purgatory
852 let pr_result =
853 process_purgatory_pr_events(&identifier, source_repo_path, database, local_relay, purgatory, git_data_path).await;
854 result.merge(pr_result);
855
856 if result.released_any() {
857 info!(
858 identifier = %identifier,
859 states_released = result.states_released,
860 prs_released = result.prs_released,
861 repos_synced = result.repos_synced,
862 "Released events from purgatory after git data became available"
863 );
864 }
865
866 Ok(result)
867}
868
869/// Process state events from purgatory that can now be satisfied.
870async fn process_purgatory_state_events(
871 identifier: &str,
872 source_repo_path: &Path,
873 current_refs: &HashMap<String, String>,
874 database: &SharedDatabase,
875 local_relay: Option<&nostr_relay_builder::LocalRelay>,
876 purgatory: &Purgatory,
877 git_data_path: &Path,
878) -> ProcessResult {
879 let mut result = ProcessResult::default();
880
881 // Find state events in purgatory for this identifier
882 let purgatory_states = purgatory.find_state(identifier);
883 if purgatory_states.is_empty() {
884 return result;
885 }
886
887 debug!(
888 identifier = %identifier,
889 purgatory_states_count = purgatory_states.len(),
890 "Checking purgatory state events for satisfaction"
891 );
892
893 // Build ref updates from current refs (treating all as "creations" for matching purposes)
894 let ref_updates: Vec<crate::purgatory::RefUpdate> = current_refs
895 .iter()
896 .map(|(ref_name, commit)| crate::purgatory::RefUpdate {
897 old_oid: "0000000000000000000000000000000000000000".to_string(),
898 new_oid: commit.clone(),
899 ref_name: ref_name.clone(),
900 })
901 .collect();
902
903 // Check which state events can be satisfied
904 for entry in &purgatory_states {
905 // Check if this state event can be satisfied with current refs
906 if !can_satisfy_state(&entry.event, &ref_updates, current_refs) {
907 debug!(
908 identifier = %identifier,
909 event_id = %entry.event.id,
910 "State event cannot be satisfied with current refs"
911 );
912 continue;
913 }
914
915 // Parse the state event
916 let state = match RepositoryState::from_event(entry.event.clone()) {
917 Ok(s) => s,
918 Err(e) => {
919 warn!(
920 identifier = %identifier,
921 event_id = %entry.event.id,
922 error = %e,
923 "Failed to parse state event from purgatory"
924 );
925 result.errors.push(format!("Failed to parse state event: {}", e));
926 continue;
927 }
928 };
929
930 // Fetch repository data for authorization check
931 let db_repo_data = match fetch_repository_data(database, identifier).await {
932 Ok(data) => data,
933 Err(e) => {
934 warn!(
935 identifier = %identifier,
936 event_id = %entry.event.id,
937 error = %e,
938 "Failed to fetch repository data for state event"
939 );
940 result.errors.push(format!("Failed to fetch repo data: {}", e));
941 continue;
942 }
943 };
944
945 // Check authorization at release time
946 let repo_owners_authorising_pubkey =
947 pubkey_authorised_for_repo_owners(&entry.event.pubkey, &db_repo_data);
948 if repo_owners_authorising_pubkey.is_empty() {
949 debug!(
950 identifier = %identifier,
951 event_id = %entry.event.id,
952 pubkey = %entry.event.pubkey,
953 "State event author no longer authorized - skipping"
954 );
955 continue;
956 }
957
958 // Sync to owner repos and align refs
959 let sync_result = sync_to_owner_repos(source_repo_path, &state, &db_repo_data, git_data_path);
960 result.repos_synced += sync_result.repos_synced;
961 result.refs_created += sync_result.refs_created;
962 result.refs_updated += sync_result.refs_updated;
963 result.refs_deleted += sync_result.refs_deleted;
964
965 // Save event to database
966 match database.save_event(&entry.event).await {
967 Ok(_) => {
968 info!(
969 identifier = %identifier,
970 event_id = %entry.event.id,
971 "Saved purgatory state event to database"
972 );
973
974 // Notify WebSocket subscribers
975 if let Some(relay) = local_relay {
976 if relay.notify_event(entry.event.clone()) {
977 debug!(
978 identifier = %identifier,
979 event_id = %entry.event.id,
980 "Broadcast state event to WebSocket listeners"
981 );
982 }
983 }
984
985 // Remove from purgatory
986 purgatory.remove_state_event(identifier, &entry.event.id);
987 result.states_released += 1;
988
989 info!(
990 identifier = %identifier,
991 event_id = %entry.event.id,
992 "Released state event from purgatory"
993 );
994 }
995 Err(e) => {
996 warn!(
997 identifier = %identifier,
998 event_id = %entry.event.id,
999 error = %e,
1000 "Failed to save state event to database"
1001 );
1002 result.errors.push(format!("Failed to save state event: {}", e));
1003 }
1004 }
1005 }
1006
1007 result
1008}
1009
1010/// Process PR events from purgatory that can now be satisfied.
1011async fn process_purgatory_pr_events(
1012 identifier: &str,
1013 source_repo_path: &Path,
1014 database: &SharedDatabase,
1015 local_relay: Option<&nostr_relay_builder::LocalRelay>,
1016 purgatory: &Purgatory,
1017 git_data_path: &Path,
1018) -> ProcessResult {
1019 let mut result = ProcessResult::default();
1020
1021 // Find PR events in purgatory for this identifier
1022 let purgatory_prs = purgatory.find_prs_for_identifier(identifier);
1023 if purgatory_prs.is_empty() {
1024 return result;
1025 }
1026
1027 debug!(
1028 identifier = %identifier,
1029 purgatory_prs_count = purgatory_prs.len(),
1030 "Checking purgatory PR events for satisfaction"
1031 );
1032
1033 // Fetch repository data for syncing
1034 let db_repo_data = match fetch_repository_data(database, identifier).await {
1035 Ok(data) => data,
1036 Err(e) => {
1037 warn!(
1038 identifier = %identifier,
1039 error = %e,
1040 "Failed to fetch repository data for PR events"
1041 );
1042 result.errors.push(format!("Failed to fetch repo data: {}", e));
1043 return result;
1044 }
1045 };
1046
1047 for entry in purgatory_prs {
1048 // Only process entries that have actual events (not placeholders)
1049 let event = match &entry.event {
1050 Some(e) => e,
1051 None => continue,
1052 };
1053
1054 // Check if the commit exists in the source repo
1055 if !oid_exists(source_repo_path, &entry.commit) {
1056 debug!(
1057 identifier = %identifier,
1058 event_id = %event.id,
1059 commit = %entry.commit,
1060 "PR commit not available yet"
1061 );
1062 continue;
1063 }
1064
1065 // Sync PR ref to owner repos
1066 let pr_refs = vec![(event.id.to_hex(), entry.commit.clone())];
1067 let pr_events = vec![event.clone()];
1068
1069 // Get owner pubkey from source repo path
1070 let owner_pubkey = extract_owner_from_repo_path(source_repo_path, git_data_path)
1071 .unwrap_or_default();
1072
1073 let sync_result = sync_pr_refs_to_tagged_owner_repos(
1074 source_repo_path,
1075 &pr_refs,
1076 &pr_events,
1077 &db_repo_data,
1078 git_data_path,
1079 &owner_pubkey,
1080 );
1081 result.repos_synced += sync_result.repos_synced;
1082 result.refs_created += sync_result.refs_created;
1083
1084 // Create the ref in the source repo if it doesn't exist
1085 let ref_name = format!("refs/nostr/{}", event.id.to_hex());
1086 if git::get_ref_commit(source_repo_path, &ref_name).is_none() {
1087 if let Err(e) = git::update_ref(source_repo_path, &ref_name, &entry.commit) {
1088 warn!(
1089 identifier = %identifier,
1090 event_id = %event.id,
1091 error = %e,
1092 "Failed to create PR ref in source repo"
1093 );
1094 } else {
1095 result.refs_created += 1;
1096 }
1097 }
1098
1099 // Save event to database
1100 match database.save_event(event).await {
1101 Ok(_) => {
1102 info!(
1103 identifier = %identifier,
1104 event_id = %event.id,
1105 "Saved purgatory PR event to database"
1106 );
1107
1108 // Notify WebSocket subscribers
1109 if let Some(relay) = local_relay {
1110 if relay.notify_event(event.clone()) {
1111 debug!(
1112 identifier = %identifier,
1113 event_id = %event.id,
1114 "Broadcast PR event to WebSocket listeners"
1115 );
1116 }
1117 }
1118
1119 // Remove from purgatory
1120 let event_id_hex = event.id.to_hex();
1121 purgatory.remove_pr(&event_id_hex);
1122 result.prs_released += 1;
1123
1124 info!(
1125 identifier = %identifier,
1126 event_id = %event.id,
1127 "Released PR event from purgatory"
1128 );
1129 }
1130 Err(e) => {
1131 warn!(
1132 identifier = %identifier,
1133 event_id = %event.id,
1134 error = %e,
1135 "Failed to save PR event to database"
1136 );
1137 result.errors.push(format!("Failed to save PR event: {}", e));
1138 }
1139 }
1140 }
1141
1142 result
1143}
1144
1145/// Extract owner pubkey from a repository path.
1146///
1147/// Given a path like `{git_data_path}/{npub}/{identifier}.git`, extracts the npub.
1148fn extract_owner_from_repo_path(repo_path: &Path, git_data_path: &Path) -> Option<String> {
1149 let relative = repo_path.strip_prefix(git_data_path).ok()?;
1150 let components: Vec<_> = relative.components().collect();
1151 if components.len() >= 1 {
1152 components[0].as_os_str().to_str().map(|s| s.to_string())
1153 } else {
1154 None
1155 }
1156}
1157
668#[cfg(test)] 1158#[cfg(test)]
669mod tests { 1159mod tests {
670 use super::*; 1160 use super::*;
671 1161
672 #[test] 1162 #[test]
1163 fn test_process_result_default() {
1164 let result = ProcessResult::default();
1165 assert_eq!(result.states_released, 0);
1166 assert_eq!(result.prs_released, 0);
1167 assert_eq!(result.repos_synced, 0);
1168 assert!(!result.released_any());
1169 }
1170
1171 #[test]
1172 fn test_process_result_released_any() {
1173 let mut result = ProcessResult::default();
1174 assert!(!result.released_any());
1175
1176 result.states_released = 1;
1177 assert!(result.released_any());
1178
1179 result.states_released = 0;
1180 result.prs_released = 1;
1181 assert!(result.released_any());
1182 }
1183
1184 #[test]
1185 fn test_process_result_merge() {
1186 let mut result1 = ProcessResult {
1187 states_released: 1,
1188 prs_released: 2,
1189 repos_synced: 3,
1190 refs_created: 4,
1191 refs_updated: 5,
1192 refs_deleted: 6,
1193 errors: vec!["error1".to_string()],
1194 };
1195
1196 let result2 = ProcessResult {
1197 states_released: 10,
1198 prs_released: 20,
1199 repos_synced: 30,
1200 refs_created: 40,
1201 refs_updated: 50,
1202 refs_deleted: 60,
1203 errors: vec!["error2".to_string()],
1204 };
1205
1206 result1.merge(result2);
1207
1208 assert_eq!(result1.states_released, 11);
1209 assert_eq!(result1.prs_released, 22);
1210 assert_eq!(result1.repos_synced, 33);
1211 assert_eq!(result1.refs_created, 44);
1212 assert_eq!(result1.refs_updated, 55);
1213 assert_eq!(result1.refs_deleted, 66);
1214 assert_eq!(result1.errors.len(), 2);
1215 }
1216
1217 #[test]
1218 fn test_extract_identifier_from_repo_path_valid() {
1219 use std::path::PathBuf;
1220
1221 let git_data_path = PathBuf::from("/data/git");
1222 let repo_path = PathBuf::from("/data/git/npub1abc123/my-repo.git");
1223
1224 let result = extract_identifier_from_repo_path(&repo_path, &git_data_path);
1225 assert_eq!(result, Some("my-repo".to_string()));
1226 }
1227
1228 #[test]
1229 fn test_extract_identifier_from_repo_path_nested() {
1230 use std::path::PathBuf;
1231
1232 let git_data_path = PathBuf::from("/var/lib/ngit/git");
1233 let repo_path = PathBuf::from("/var/lib/ngit/git/npub1xyz/ngit-grasp.git");
1234
1235 let result = extract_identifier_from_repo_path(&repo_path, &git_data_path);
1236 assert_eq!(result, Some("ngit-grasp".to_string()));
1237 }
1238
1239 #[test]
1240 fn test_extract_identifier_from_repo_path_invalid_no_git_suffix() {
1241 use std::path::PathBuf;
1242
1243 let git_data_path = PathBuf::from("/data/git");
1244 let repo_path = PathBuf::from("/data/git/npub1abc123/my-repo");
1245
1246 let result = extract_identifier_from_repo_path(&repo_path, &git_data_path);
1247 assert_eq!(result, None);
1248 }
1249
1250 #[test]
1251 fn test_extract_identifier_from_repo_path_invalid_wrong_depth() {
1252 use std::path::PathBuf;
1253
1254 let git_data_path = PathBuf::from("/data/git");
1255 let repo_path = PathBuf::from("/data/git/my-repo.git"); // Missing npub level
1256
1257 let result = extract_identifier_from_repo_path(&repo_path, &git_data_path);
1258 assert_eq!(result, None);
1259 }
1260
1261 #[test]
1262 fn test_extract_identifier_from_pr_event_valid() {
1263 use nostr_sdk::{EventBuilder, Keys, Kind, Tag, TagKind};
1264
1265 let keys = Keys::generate();
1266 let tags = vec![Tag::custom(
1267 TagKind::Custom("a".into()),
1268 vec!["30617:abc123def456:test-repo".to_string()],
1269 )];
1270
1271 let event = EventBuilder::new(Kind::from(1618), "PR content")
1272 .tags(tags)
1273 .sign_with_keys(&keys)
1274 .unwrap();
1275
1276 let result = extract_identifier_from_pr_event(&event);
1277 assert_eq!(result, Some("test-repo".to_string()));
1278 }
1279
1280 #[test]
1281 fn test_extract_identifier_from_pr_event_missing_tag() {
1282 use nostr_sdk::{EventBuilder, Keys, Kind, Tag, TagKind};
1283
1284 let keys = Keys::generate();
1285 let tags = vec![Tag::custom(
1286 TagKind::Custom("c".into()),
1287 vec!["commit123".to_string()],
1288 )];
1289
1290 let event = EventBuilder::new(Kind::from(1618), "PR content")
1291 .tags(tags)
1292 .sign_with_keys(&keys)
1293 .unwrap();
1294
1295 let result = extract_identifier_from_pr_event(&event);
1296 assert_eq!(result, None);
1297 }
1298
1299 #[test]
1300 fn test_extract_identifier_from_pr_event_wrong_kind_a_tag() {
1301 use nostr_sdk::{EventBuilder, Keys, Kind, Tag, TagKind};
1302
1303 let keys = Keys::generate();
1304 let tags = vec![Tag::custom(
1305 TagKind::Custom("a".into()),
1306 vec!["30618:abc123:test-repo".to_string()], // 30618 not 30617
1307 )];
1308
1309 let event = EventBuilder::new(Kind::from(1618), "PR content")
1310 .tags(tags)
1311 .sign_with_keys(&keys)
1312 .unwrap();
1313
1314 let result = extract_identifier_from_pr_event(&event);
1315 assert_eq!(result, None);
1316 }
1317
1318 #[test]
673 fn test_sync_result_default() { 1319 fn test_sync_result_default() {
674 let result = SyncResult::default(); 1320 let result = SyncResult::default();
675 assert_eq!(result.repos_synced, 0); 1321 assert_eq!(result.repos_synced, 0);
diff --git a/src/purgatory/mod.rs b/src/purgatory/mod.rs
index fcb812b..11fe41f 100644
--- a/src/purgatory/mod.rs
+++ b/src/purgatory/mod.rs
@@ -365,6 +365,33 @@ impl Purgatory {
365 }) 365 })
366 } 366 }
367 367
368 /// Find all PR events for a specific repository identifier.
369 ///
370 /// PR events reference repositories via `a` tags with format `30617:<owner_pubkey>:<identifier>`.
371 /// This function scans all PR entries and returns those that reference the given identifier.
372 ///
373 /// Note: This is a linear scan since PR events are indexed by event_id, not by identifier.
374 /// For repositories with many PR events, this could be optimized with a secondary index.
375 ///
376 /// # Arguments
377 /// * `identifier` - The repository identifier to search for
378 ///
379 /// # Returns
380 /// Vector of PR purgatory entries that reference this identifier
381 pub fn find_prs_for_identifier(&self, identifier: &str) -> Vec<PrPurgatoryEntry> {
382 self.pr_events
383 .iter()
384 .filter(|entry| {
385 if let Some(ref event) = entry.value().event {
386 Self::event_references_identifier(event, identifier)
387 } else {
388 false
389 }
390 })
391 .map(|entry| entry.value().clone())
392 .collect()
393 }
394
368 /// Remove a state event from purgatory. 395 /// Remove a state event from purgatory.
369 /// 396 ///
370 /// Removes all entries for the given identifier. 397 /// Removes all entries for the given identifier.
diff --git a/src/purgatory/sync/functions.rs b/src/purgatory/sync/functions.rs
index 751dd5e..13b2e47 100644
--- a/src/purgatory/sync/functions.rs
+++ b/src/purgatory/sync/functions.rs
@@ -20,16 +20,32 @@ use super::throttle::ThrottleManager;
20 20
21/// Extract domain from a URL. 21/// Extract domain from a URL.
22/// 22///
23/// Supports HTTP(S) URLs. SSH URLs (git@...) are not supported.
24///
23/// # Examples 25/// # Examples
24/// 26///
25/// ```ignore 27/// ```ignore
26/// assert_eq!(extract_domain("https://github.com/foo/bar.git"), Some("github.com".to_string())); 28/// assert_eq!(extract_domain("https://github.com/foo/bar.git"), Some("github.com".to_string()));
29/// assert_eq!(extract_domain("http://example.com:8080/repo.git"), Some("example.com".to_string()));
27/// assert_eq!(extract_domain("git@github.com:foo/bar.git"), None); // SSH URLs not supported 30/// assert_eq!(extract_domain("git@github.com:foo/bar.git"), None); // SSH URLs not supported
28/// ``` 31/// ```
29fn extract_domain(url: &str) -> Option<String> { 32fn extract_domain(url: &str) -> Option<String> {
30 url::Url::parse(url) 33 // Simple URL parsing for HTTP(S) URLs
31 .ok() 34 // Format: scheme://[user@]host[:port]/path
32 .and_then(|u| u.host_str().map(|s| s.to_string())) 35 let url = url.strip_prefix("https://").or_else(|| url.strip_prefix("http://"))?;
36
37 // Remove user info if present (e.g., "user@host" -> "host")
38 let url = url.split('@').last()?;
39
40 // Extract host (before first '/' or ':')
41 let host = url.split('/').next()?;
42 let host = host.split(':').next()?;
43
44 if host.is_empty() {
45 None
46 } else {
47 Some(host.to_string())
48 }
33} 49}
34 50
35/// Find the next URL to try for an identifier. 51/// Find the next URL to try for an identifier.
diff --git a/src/purgatory/sync/loop.rs b/src/purgatory/sync/loop.rs
index aaf1300..ebca766 100644
--- a/src/purgatory/sync/loop.rs
+++ b/src/purgatory/sync/loop.rs
@@ -12,7 +12,7 @@
12use std::sync::Arc; 12use std::sync::Arc;
13use std::time::Duration; 13use std::time::Duration;
14use tokio::task::JoinHandle; 14use tokio::task::JoinHandle;
15use tracing::{debug, info, warn}; 15use tracing::{debug, info};
16 16
17use crate::purgatory::Purgatory; 17use crate::purgatory::Purgatory;
18 18