diff options
Diffstat (limited to 'src/git/sync.rs')
| -rw-r--r-- | src/git/sync.rs | 648 |
1 files changed, 647 insertions, 1 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 | ||
| 20 | use std::collections::{HashMap, HashSet}; | 32 | use std::collections::{HashMap, HashSet}; |
| 21 | use std::path::Path; | 33 | use std::path::Path; |
| @@ -24,9 +36,55 @@ use tracing::{debug, info, warn}; | |||
| 24 | 36 | ||
| 25 | use nostr_sdk::Event; | 37 | use nostr_sdk::Event; |
| 26 | 38 | ||
| 27 | use crate::git::authorization::{collect_authorized_maintainers, RepositoryData}; | 39 | use crate::git::authorization::{ |
| 40 | collect_authorized_maintainers, fetch_repository_data, pubkey_authorised_for_repo_owners, | ||
| 41 | RepositoryData, | ||
| 42 | }; | ||
| 28 | use crate::git::{self, oid_exists}; | 43 | use crate::git::{self, oid_exists}; |
| 44 | use crate::nostr::builder::SharedDatabase; | ||
| 29 | use crate::nostr::events::RepositoryState; | 45 | use crate::nostr::events::RepositoryState; |
| 46 | use 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)] | ||
| 54 | pub 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 | |||
| 71 | impl 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 | ||
| 740 | pub 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 | ||
| 767 | pub 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 | ||
| 812 | pub 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, ¤t_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. | ||
| 870 | async 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. | ||
| 1011 | async 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. | ||
| 1148 | fn 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)] |
| 669 | mod tests { | 1159 | mod 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); |