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 13:48:56 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-01-07 13:48:56 +0000
commit39cfcd950eaf31eb721c25b0e60c751d0f279bb6 (patch)
treefeb627bc905bcf50ad1925524db32a8fb2b3c58c /src
parent37c9d3e0d195b0789f9e6407b81973cf50222b76 (diff)
purgatory: more robust process_purgatory_state_events syncing
Diffstat (limited to 'src')
-rw-r--r--src/git/sync.rs425
1 files changed, 362 insertions, 63 deletions
diff --git a/src/git/sync.rs b/src/git/sync.rs
index cf6e93d..949d8e1 100644
--- a/src/git/sync.rs
+++ b/src/git/sync.rs
@@ -37,8 +37,7 @@ use tracing::{debug, info, warn};
37use nostr_sdk::Event; 37use nostr_sdk::Event;
38 38
39use crate::git::authorization::{ 39use crate::git::authorization::{
40 collect_authorized_maintainers, fetch_repository_data, pubkey_authorised_for_repo_owners, 40 collect_authorized_maintainers, fetch_repository_data, RepositoryData,
41 RepositoryData,
42}; 41};
43use crate::git::{self, oid_exists}; 42use crate::git::{self, oid_exists};
44use crate::nostr::builder::SharedDatabase; 43use crate::nostr::builder::SharedDatabase;
@@ -877,25 +876,49 @@ async fn process_purgatory_state_events(
877 let mut result = ProcessResult::default(); 876 let mut result = ProcessResult::default();
878 877
879 // Find state events in purgatory for this identifier 878 // Find state events in purgatory for this identifier
880 let purgatory_states = purgatory.find_state(identifier); 879 let mut purgatory_states = purgatory.find_state(identifier);
881 if purgatory_states.is_empty() { 880 if purgatory_states.is_empty() {
882 return result; 881 return result;
883 } 882 }
884 883
884 // Sort by created_at (oldest first) so we process events in chronological order.
885 // This ensures that when multiple state events are in purgatory, older ones
886 // get processed first, allowing newer ones to correctly supersede them.
887 purgatory_states.sort_by_key(|entry| entry.event.created_at);
888
885 debug!( 889 debug!(
886 identifier = %identifier, 890 identifier = %identifier,
887 purgatory_states_count = purgatory_states.len(), 891 purgatory_states_count = purgatory_states.len(),
888 "Checking purgatory state events for available git data" 892 "Checking purgatory state events for available git data (processing oldest first)"
889 ); 893 );
890 894
891 // Check which state events can be applied (have all required OIDs) 895 // Fetch repository data once for all state events
896 let mut db_repo_data = match fetch_repository_data(database, identifier).await {
897 Ok(data) => data,
898 Err(e) => {
899 warn!(
900 identifier = %identifier,
901 error = %e,
902 "Failed to fetch repository data for purgatory state events"
903 );
904 result
905 .errors
906 .push(format!("Failed to fetch repo data: {}", e));
907 return result;
908 }
909 };
910
911 // Collect authorized maintainers per owner (computed once)
912 let by_owner = collect_authorized_maintainers(&db_repo_data.announcements);
913
914 // Process each state event in chronological order
892 for entry in &purgatory_states { 915 for entry in &purgatory_states {
893 // Check if we have all the git data needed to apply this state event 916 // Step 0: Check if we have all the git data needed to apply this state event
894 if !can_apply_state(&entry.event, source_repo_path) { 917 if !can_apply_state(&entry.event, source_repo_path) {
895 debug!( 918 debug!(
896 identifier = %identifier, 919 identifier = %identifier,
897 event_id = %entry.event.id, 920 event_id = %entry.event.id,
898 "State event cannot be applied - missing git OIDs" 921 "State event cannot be applied - missing git OIDs in source repo"
899 ); 922 );
900 continue; 923 continue;
901 } 924 }
@@ -910,84 +933,196 @@ async fn process_purgatory_state_events(
910 error = %e, 933 error = %e,
911 "Failed to parse state event from purgatory" 934 "Failed to parse state event from purgatory"
912 ); 935 );
913 result.errors.push(format!("Failed to parse state event: {}", e)); 936 result
937 .errors
938 .push(format!("Failed to parse state event: {}", e));
914 continue; 939 continue;
915 } 940 }
916 }; 941 };
917 942
918 // Fetch repository data for authorization check 943 let state_author = state.event.pubkey.to_hex();
919 let db_repo_data = match fetch_repository_data(database, identifier).await { 944
920 Ok(data) => data, 945 // Step 1: Identify owner repos that the state event author is maintainer for
921 Err(e) => { 946 let authorized_owners: Vec<&String> = by_owner
922 warn!( 947 .iter()
923 identifier = %identifier, 948 .filter(|(_, maintainers)| maintainers.contains(&state_author))
924 event_id = %entry.event.id, 949 .map(|(owner, _)| owner)
925 error = %e, 950 .collect();
926 "Failed to fetch repository data for state event"
927 );
928 result.errors.push(format!("Failed to fetch repo data: {}", e));
929 continue;
930 }
931 };
932 951
933 // Check authorization at release time 952 if authorized_owners.is_empty() {
934 let repo_owners_authorising_pubkey =
935 pubkey_authorised_for_repo_owners(&entry.event.pubkey, &db_repo_data);
936 if repo_owners_authorising_pubkey.is_empty() {
937 debug!( 953 debug!(
938 identifier = %identifier, 954 identifier = %identifier,
939 event_id = %entry.event.id, 955 event_id = %entry.event.id,
940 pubkey = %entry.event.pubkey, 956 pubkey = %state_author,
941 "State event author no longer authorized - skipping" 957 "State event author not authorized for any owner - skipping"
942 ); 958 );
943 continue; 959 continue;
944 } 960 }
945 961
946 // Sync to owner repos and align refs 962 // Track if we applied to at least one owner repo
947 let sync_result = sync_to_owner_repos(source_repo_path, &state, &db_repo_data, git_data_path); 963 let mut applied_to_any = false;
948 result.repos_synced += sync_result.repos_synced;
949 result.refs_created += sync_result.refs_created;
950 result.refs_updated += sync_result.refs_updated;
951 result.refs_deleted += sync_result.refs_deleted;
952 964
953 // Save event to database 965 // Process each owner repo that authorizes this state event author
954 match database.save_event(&entry.event).await { 966 for owner in &authorized_owners {
955 Ok(_) => { 967 let maintainers = by_owner.get(*owner).unwrap();
956 info!( 968
969 // Step 2: Check if this state event is the latest authorized for this owner
970 // Only consider database states, not other purgatory states
971 let is_latest = is_latest_authorized_state(
972 &state,
973 maintainers,
974 &db_repo_data.states,
975 );
976
977 if !is_latest {
978 debug!(
957 identifier = %identifier, 979 identifier = %identifier,
958 event_id = %entry.event.id, 980 event_id = %entry.event.id,
959 "Saved purgatory state event to database" 981 owner = %owner,
982 "Skipping owner - a newer authorized state exists"
960 ); 983 );
984 continue;
985 }
961 986
962 // Notify WebSocket subscribers 987 // Find the announcement for this owner
963 if let Some(relay) = local_relay { 988 let announcement = db_repo_data
964 if relay.notify_event(entry.event.clone()) { 989 .announcements
965 debug!( 990 .iter()
966 identifier = %identifier, 991 .find(|a| a.event.pubkey.to_hex() == **owner);
967 event_id = %entry.event.id,
968 "Broadcast state event to WebSocket listeners"
969 );
970 }
971 }
972 992
973 // Remove from purgatory 993 let Some(announcement) = announcement else {
974 purgatory.remove_state_event(identifier, &entry.event.id); 994 continue;
975 result.states_released += 1; 995 };
976 996
977 info!( 997 let target_repo_path = git_data_path.join(announcement.repo_path());
998
999 // Step 3: Check git repo exists for that owner
1000 if !target_repo_path.exists() {
1001 debug!(
978 identifier = %identifier, 1002 identifier = %identifier,
979 event_id = %entry.event.id, 1003 owner = %owner,
980 "Released state event from purgatory" 1004 repo_path = %target_repo_path.display(),
1005 "Skipping owner - repository doesn't exist"
981 ); 1006 );
1007 continue;
982 } 1008 }
983 Err(e) => { 1009
984 warn!( 1010 // Step 4: Copy all required OIDs to that repo (unless it's source_repo_path)
985 identifier = %identifier, 1011 if target_repo_path != source_repo_path {
986 event_id = %entry.event.id, 1012 if let Err(e) =
987 error = %e, 1013 copy_missing_oids_between_repos(source_repo_path, &target_repo_path, &state)
988 "Failed to save state event to database" 1014 {
989 ); 1015 warn!(
990 result.errors.push(format!("Failed to save state event: {}", e)); 1016 identifier = %identifier,
1017 source = %source_repo_path.display(),
1018 target = %target_repo_path.display(),
1019 error = %e,
1020 "Failed to copy OIDs between repos"
1021 );
1022 result
1023 .errors
1024 .push((target_repo_path.display().to_string(), e).1);
1025 // Continue anyway - we'll try to align what we can
1026 }
1027 }
1028
1029 // Step 5: Reset the git state in that repo to match the state event
1030 // (excluding refs/nostr/*)
1031 let align_result = align_repository_with_state(&target_repo_path, &state);
1032 result.repos_synced += 1;
1033 result.refs_created += align_result.refs_created;
1034 result.refs_updated += align_result.refs_updated;
1035 result.refs_deleted += align_result.refs_deleted;
1036
1037 info!(
1038 identifier = %identifier,
1039 owner = %owner,
1040 event_id = %entry.event.id,
1041 repo_path = %target_repo_path.display(),
1042 refs_created = align_result.refs_created,
1043 refs_updated = align_result.refs_updated,
1044 refs_deleted = align_result.refs_deleted,
1045 head_set = align_result.head_set,
1046 "Aligned repository with state from purgatory"
1047 );
1048
1049 applied_to_any = true;
1050 }
1051
1052 // We have the git data now, so we should release from purgatory regardless of
1053 // whether we applied to any repo. The question is: should we save to DB or just remove?
1054 //
1055 // - If there's a newer state event from the same author already in the DB, just remove
1056 // (no point saving an older event that will never be used)
1057 // - Otherwise, save it to the DB (even if we didn't apply to any repo, because in the
1058 // future the currently-authorized state event might be deleted and this one should apply)
1059
1060 // Check if there's a newer state from the same author in the database
1061 let has_newer_from_same_author = db_repo_data.states.iter().any(|s| {
1062 s.event.pubkey == state.event.pubkey
1063 && (s.event.created_at > state.event.created_at
1064 || (s.event.created_at == state.event.created_at
1065 && s.event.id > state.event.id))
1066 });
1067
1068 if has_newer_from_same_author {
1069 // Just remove from purgatory without saving - a newer event from same author exists
1070 purgatory.remove_state_event(identifier, &entry.event.id);
1071 result.states_released += 1;
1072
1073 debug!(
1074 identifier = %identifier,
1075 event_id = %entry.event.id,
1076 author = %state_author,
1077 "Removed older state event from purgatory - newer event from same author exists in DB"
1078 );
1079 } else {
1080 // Save to database (even if we didn't apply to any repo)
1081 match database.save_event(&entry.event).await {
1082 Ok(_) => {
1083 info!(
1084 identifier = %identifier,
1085 event_id = %entry.event.id,
1086 applied_to_repos = applied_to_any,
1087 "Saved purgatory state event to database"
1088 );
1089
1090 // Notify WebSocket subscribers
1091 if let Some(relay) = local_relay {
1092 if relay.notify_event(entry.event.clone()) {
1093 debug!(
1094 identifier = %identifier,
1095 event_id = %entry.event.id,
1096 "Broadcast state event to WebSocket listeners"
1097 );
1098 }
1099 }
1100
1101 // Remove from purgatory
1102 purgatory.remove_state_event(identifier, &entry.event.id);
1103 result.states_released += 1;
1104
1105 // Add the newly saved state to db_repo_data so subsequent iterations
1106 // can correctly determine if they're the latest
1107 db_repo_data.states.push(state.clone());
1108
1109 info!(
1110 identifier = %identifier,
1111 event_id = %entry.event.id,
1112 "Released state event from purgatory"
1113 );
1114 }
1115 Err(e) => {
1116 warn!(
1117 identifier = %identifier,
1118 event_id = %entry.event.id,
1119 error = %e,
1120 "Failed to save state event to database"
1121 );
1122 result
1123 .errors
1124 .push(format!("Failed to save state event: {}", e));
1125 }
991 } 1126 }
992 } 1127 }
993 } 1128 }
@@ -995,6 +1130,45 @@ async fn process_purgatory_state_events(
995 result 1130 result
996} 1131}
997 1132
1133/// Check if a state event is the latest authorized state for a given maintainer set.
1134///
1135/// Only considers states already in the database, not other purgatory states.
1136///
1137/// # Arguments
1138/// * `state` - The state event to check
1139/// * `maintainers` - The set of authorized maintainers for the owner
1140/// * `db_states` - State events from the database
1141///
1142/// # Returns
1143/// true if this state is the latest (or equal latest) among all authorized states in the DB
1144fn is_latest_authorized_state(
1145 state: &RepositoryState,
1146 maintainers: &[String],
1147 db_states: &[RepositoryState],
1148) -> bool {
1149 // Find the latest authorized state from database
1150 let latest_db_state = db_states
1151 .iter()
1152 .filter(|s| maintainers.contains(&s.event.pubkey.to_hex()))
1153 .max_by(|a, b| {
1154 // Compare by created_at, then by event id for tie-breaking
1155 a.event
1156 .created_at
1157 .cmp(&b.event.created_at)
1158 .then_with(|| a.event.id.cmp(&b.event.id))
1159 });
1160
1161 match latest_db_state {
1162 None => true, // No other states exist in DB, this is the latest
1163 Some(latest) => {
1164 // This state is latest if it's newer, or if equal timestamp with larger event id
1165 state.event.created_at > latest.event.created_at
1166 || (state.event.created_at == latest.event.created_at
1167 && state.event.id >= latest.event.id)
1168 }
1169 }
1170}
1171
998/// Process PR events from purgatory that can now be satisfied. 1172/// Process PR events from purgatory that can now be satisfied.
999async fn process_purgatory_pr_events( 1173async fn process_purgatory_pr_events(
1000 identifier: &str, 1174 identifier: &str,
@@ -1146,6 +1320,7 @@ fn extract_owner_from_repo_path(repo_path: &Path, git_data_path: &Path) -> Optio
1146#[cfg(test)] 1320#[cfg(test)]
1147mod tests { 1321mod tests {
1148 use super::*; 1322 use super::*;
1323 use nostr_sdk::Keys;
1149 1324
1150 #[test] 1325 #[test]
1151 fn test_process_result_default() { 1326 fn test_process_result_default() {
@@ -1427,4 +1602,128 @@ mod tests {
1427 assert_eq!(owners.len(), 1); 1602 assert_eq!(owners.len(), 1);
1428 assert!(owners.contains("valid_owner")); 1603 assert!(owners.contains("valid_owner"));
1429 } 1604 }
1605
1606 // Helper function to create a test state event with specific timestamp
1607 // The `nonce` parameter ensures different events have different IDs even with same timestamp
1608 fn create_test_state_event_with_nonce(
1609 keys: &Keys,
1610 identifier: &str,
1611 created_at: u64,
1612 nonce: &str,
1613 ) -> RepositoryState {
1614 use nostr_sdk::{EventBuilder, Kind, Tag, TagKind, Timestamp};
1615
1616 let tags = vec![
1617 Tag::custom(TagKind::d(), vec![identifier.to_string()]),
1618 Tag::custom(
1619 TagKind::custom("refs/heads/main"),
1620 vec![format!("abc123{}", nonce)],
1621 ),
1622 ];
1623
1624 let event = EventBuilder::new(Kind::from(30618), nonce)
1625 .tags(tags)
1626 .custom_created_at(Timestamp::from(created_at))
1627 .sign_with_keys(keys)
1628 .unwrap();
1629
1630 RepositoryState::from_event(event).unwrap()
1631 }
1632
1633 // Helper function to create a test state event with specific timestamp
1634 fn create_test_state_event(
1635 keys: &Keys,
1636 identifier: &str,
1637 created_at: u64,
1638 ) -> RepositoryState {
1639 create_test_state_event_with_nonce(keys, identifier, created_at, "")
1640 }
1641
1642 #[test]
1643 fn test_is_latest_authorized_state_no_other_states() {
1644 let keys = Keys::generate();
1645 let state = create_test_state_event(&keys, "test-repo", 1000);
1646 let maintainers = vec![keys.public_key().to_hex()];
1647
1648 // No other states - should be latest
1649 let result = is_latest_authorized_state(&state, &maintainers, &[]);
1650 assert!(result);
1651 }
1652
1653 #[test]
1654 fn test_is_latest_authorized_state_newer_than_db() {
1655 let keys = Keys::generate();
1656 let old_state = create_test_state_event(&keys, "test-repo", 1000);
1657 let new_state = create_test_state_event(&keys, "test-repo", 2000);
1658 let maintainers = vec![keys.public_key().to_hex()];
1659
1660 // new_state is newer than old_state in db
1661 let result = is_latest_authorized_state(&new_state, &maintainers, &[old_state]);
1662 assert!(result);
1663 }
1664
1665 #[test]
1666 fn test_is_latest_authorized_state_older_than_db() {
1667 let keys = Keys::generate();
1668 let old_state = create_test_state_event(&keys, "test-repo", 1000);
1669 let new_state = create_test_state_event(&keys, "test-repo", 2000);
1670 let maintainers = vec![keys.public_key().to_hex()];
1671
1672 // old_state is older than new_state in db
1673 let result = is_latest_authorized_state(&old_state, &maintainers, &[new_state]);
1674 assert!(!result);
1675 }
1676
1677 #[test]
1678 fn test_is_latest_authorized_state_ignores_unauthorized_states() {
1679 let keys1 = Keys::generate();
1680 let keys2 = Keys::generate();
1681
1682 let state1 = create_test_state_event(&keys1, "test-repo", 1000);
1683 let state2 = create_test_state_event(&keys2, "test-repo", 2000);
1684
1685 // Only keys1 is authorized
1686 let maintainers = vec![keys1.public_key().to_hex()];
1687
1688 // state1 should be latest because state2 is not authorized
1689 let result = is_latest_authorized_state(&state1, &maintainers, &[state2]);
1690 assert!(result);
1691 }
1692
1693 #[test]
1694 fn test_is_latest_authorized_state_same_timestamp_uses_event_id() {
1695 let keys = Keys::generate();
1696
1697 // Create two states with same timestamp but different content (different event IDs)
1698 let state1 = create_test_state_event_with_nonce(&keys, "test-repo", 1000, "nonce1");
1699 let state2 = create_test_state_event_with_nonce(&keys, "test-repo", 1000, "nonce2");
1700
1701 let maintainers = vec![keys.public_key().to_hex()];
1702
1703 // The one with larger event ID should be considered latest
1704 let (latest, older) = if state1.event.id > state2.event.id {
1705 (state1, state2)
1706 } else {
1707 (state2, state1)
1708 };
1709
1710 // latest should be considered latest
1711 let result = is_latest_authorized_state(&latest, &maintainers, &[older.clone()]);
1712 assert!(result);
1713
1714 // older should not be considered latest
1715 let result = is_latest_authorized_state(&older, &maintainers, &[latest]);
1716 assert!(!result);
1717 }
1718
1719 #[test]
1720 fn test_is_latest_authorized_state_same_event_is_latest() {
1721 let keys = Keys::generate();
1722 let state = create_test_state_event(&keys, "test-repo", 1000);
1723 let maintainers = vec![keys.public_key().to_hex()];
1724
1725 // When the state being checked is also in the db_states, it should be considered latest
1726 let result = is_latest_authorized_state(&state, &maintainers, &[state.clone()]);
1727 assert!(result);
1728 }
1430} 1729}