diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-05 15:05:53 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-05 15:05:53 +0000 |
| commit | b29c0362bfb881febf9848e40023ac588d1e9aa7 (patch) | |
| tree | b8ab4a5f27a7ac458270985216c5cf0b4c7a65d4 /src | |
| parent | 3f50107062d55a15decc47e93fd4e9f473de86e8 (diff) | |
sync PR refs (refs/nostr/<event-id>) to all owner repos when push received
When a push to refs/nostr/<event-id> is received (PR data), the git data
is now synced to all other owner repositories that share maintainers with
the source owner. This mirrors the behavior added for state event data.
Changes:
- Add sync_pr_refs_to_owner_repos() function in git/sync.rs
- Add PrSyncResult struct to track sync statistics
- Add copy_single_commit_between_repos() helper function
- Call PR sync in handle_receive_pack after successful push
- Add unit test for PrSyncResult default values
Diffstat (limited to 'src')
| -rw-r--r-- | src/git/handlers.rs | 52 | ||||
| -rw-r--r-- | src/git/sync.rs | 221 |
2 files changed, 271 insertions, 2 deletions
diff --git a/src/git/handlers.rs b/src/git/handlers.rs index e86d2a3..23a15ba 100644 --- a/src/git/handlers.rs +++ b/src/git/handlers.rs | |||
| @@ -15,8 +15,8 @@ use super::protocol::{GitService, PktLine}; | |||
| 15 | use super::subprocess::GitSubprocess; | 15 | use super::subprocess::GitSubprocess; |
| 16 | use super::try_set_head_if_available; | 16 | use super::try_set_head_if_available; |
| 17 | 17 | ||
| 18 | use crate::git::authorization::{authorize_push, fetch_repository_data}; | 18 | use crate::git::authorization::{authorize_push, fetch_repository_data, parse_pushed_refs}; |
| 19 | use crate::git::sync::sync_to_owner_repos; | 19 | use crate::git::sync::{sync_pr_refs_to_owner_repos, sync_to_owner_repos}; |
| 20 | use crate::nostr::builder::SharedDatabase; | 20 | use crate::nostr::builder::SharedDatabase; |
| 21 | use crate::nostr::events::{KIND_PR, KIND_PR_UPDATE, KIND_REPOSITORY_STATE}; | 21 | use crate::nostr::events::{KIND_PR, KIND_PR_UPDATE, KIND_REPOSITORY_STATE}; |
| 22 | use crate::purgatory::Purgatory; | 22 | use crate::purgatory::Purgatory; |
| @@ -383,6 +383,54 @@ pub async fn handle_receive_pack( | |||
| 383 | } | 383 | } |
| 384 | } | 384 | } |
| 385 | 385 | ||
| 386 | // Sync PR data (refs/nostr/<event-id>) to other owner repositories | ||
| 387 | // Parse pushed refs to find refs/nostr/* refs | ||
| 388 | let pushed_refs = parse_pushed_refs(&request_body); | ||
| 389 | let pr_refs: Vec<(String, String)> = pushed_refs | ||
| 390 | .iter() | ||
| 391 | .filter_map(|(_, new_oid, ref_name)| { | ||
| 392 | ref_name.strip_prefix("refs/nostr/").map(|event_id| { | ||
| 393 | (event_id.to_string(), new_oid.clone()) | ||
| 394 | }) | ||
| 395 | }) | ||
| 396 | .collect(); | ||
| 397 | |||
| 398 | if !pr_refs.is_empty() { | ||
| 399 | match fetch_repository_data(&database, identifier).await { | ||
| 400 | Ok(db_repo_data) => { | ||
| 401 | let git_data_path_buf = std::path::PathBuf::from(git_data_path); | ||
| 402 | let pr_sync_result = sync_pr_refs_to_owner_repos( | ||
| 403 | &repo_path, | ||
| 404 | &pr_refs, | ||
| 405 | &db_repo_data, | ||
| 406 | &git_data_path_buf, | ||
| 407 | owner_pubkey, | ||
| 408 | ); | ||
| 409 | |||
| 410 | if pr_sync_result.repos_synced > 0 { | ||
| 411 | info!( | ||
| 412 | "Synced {} PR refs to {} other owner repositories for {}", | ||
| 413 | pr_sync_result.refs_created, | ||
| 414 | pr_sync_result.repos_synced, | ||
| 415 | identifier | ||
| 416 | ); | ||
| 417 | } | ||
| 418 | |||
| 419 | if !pr_sync_result.errors.is_empty() { | ||
| 420 | for (repo, error) in &pr_sync_result.errors { | ||
| 421 | warn!("Error syncing PR ref to {}: {}", repo, error); | ||
| 422 | } | ||
| 423 | } | ||
| 424 | } | ||
| 425 | Err(e) => { | ||
| 426 | warn!( | ||
| 427 | "Failed to fetch repository data for PR sync after push to {}: {}", | ||
| 428 | identifier, e | ||
| 429 | ); | ||
| 430 | } | ||
| 431 | } | ||
| 432 | } | ||
| 433 | |||
| 386 | Ok(Response::builder() | 434 | Ok(Response::builder() |
| 387 | .status(StatusCode::OK) | 435 | .status(StatusCode::OK) |
| 388 | .header( | 436 | .header( |
diff --git a/src/git/sync.rs b/src/git/sync.rs index c99eb43..998f490 100644 --- a/src/git/sync.rs +++ b/src/git/sync.rs | |||
| @@ -7,6 +7,8 @@ | |||
| 7 | //! copied to other owner repos that authorize the same state | 7 | //! copied to other owner repos that authorize the same state |
| 8 | //! 2. Purgatory sync fetches git data from remote - needs to distribute to all | 8 | //! 2. Purgatory sync fetches git data from remote - needs to distribute to all |
| 9 | //! authorized owner repos | 9 | //! authorized owner repos |
| 10 | //! 3. A push to refs/nostr/<event-id> (PR data) is received - needs to be synced | ||
| 11 | //! to all other owner repos that share maintainers | ||
| 10 | //! | 12 | //! |
| 11 | //! ## Architecture | 13 | //! ## Architecture |
| 12 | //! | 14 | //! |
| @@ -54,6 +56,217 @@ pub struct AlignmentResult { | |||
| 54 | pub head_set: bool, | 56 | pub head_set: bool, |
| 55 | } | 57 | } |
| 56 | 58 | ||
| 59 | /// Result of syncing PR refs to owner repositories | ||
| 60 | #[derive(Debug, Default)] | ||
| 61 | pub struct PrSyncResult { | ||
| 62 | /// Number of repositories synced | ||
| 63 | pub repos_synced: usize, | ||
| 64 | /// Number of refs created across all repos | ||
| 65 | pub refs_created: usize, | ||
| 66 | /// Errors encountered (repo path -> error message) | ||
| 67 | pub errors: Vec<(String, String)>, | ||
| 68 | } | ||
| 69 | |||
| 70 | /// Sync PR data (refs/nostr/<event-id>) from a source repository to all other | ||
| 71 | /// owner repositories that share maintainers. | ||
| 72 | /// | ||
| 73 | /// This function: | ||
| 74 | /// 1. Collects all authorized maintainers per owner from announcements | ||
| 75 | /// 2. For each owner that shares at least one maintainer with the source owner: | ||
| 76 | /// - Copies missing OIDs for the PR commits | ||
| 77 | /// - Creates the refs/nostr/<event-id> ref pointing to the same commit | ||
| 78 | /// | ||
| 79 | /// # Arguments | ||
| 80 | /// * `source_repo_path` - Path to the repository that has the PR git data | ||
| 81 | /// * `pr_refs` - List of (event_id, commit_hash) tuples for PR refs that were pushed | ||
| 82 | /// * `db_repo_data` - Repository data from database (announcements + states) | ||
| 83 | /// * `git_data_path` - Base path for git repositories | ||
| 84 | /// * `source_owner_pubkey` - The owner pubkey of the source repository | ||
| 85 | /// | ||
| 86 | /// # Returns | ||
| 87 | /// A `PrSyncResult` with statistics about what was synced | ||
| 88 | pub fn sync_pr_refs_to_owner_repos( | ||
| 89 | source_repo_path: &Path, | ||
| 90 | pr_refs: &[(String, String)], // (event_id, commit_hash) | ||
| 91 | db_repo_data: &RepositoryData, | ||
| 92 | git_data_path: &Path, | ||
| 93 | source_owner_pubkey: &str, | ||
| 94 | ) -> PrSyncResult { | ||
| 95 | let mut result = PrSyncResult::default(); | ||
| 96 | |||
| 97 | if pr_refs.is_empty() { | ||
| 98 | return result; | ||
| 99 | } | ||
| 100 | |||
| 101 | // Collect authorized maintainers per owner | ||
| 102 | let by_owner = collect_authorized_maintainers(&db_repo_data.announcements); | ||
| 103 | |||
| 104 | // Get the maintainer set for the source owner | ||
| 105 | let source_maintainers = match by_owner.get(source_owner_pubkey) { | ||
| 106 | Some(maintainers) => maintainers, | ||
| 107 | None => { | ||
| 108 | debug!( | ||
| 109 | "No maintainer set found for source owner {}", | ||
| 110 | source_owner_pubkey | ||
| 111 | ); | ||
| 112 | return result; | ||
| 113 | } | ||
| 114 | }; | ||
| 115 | |||
| 116 | debug!( | ||
| 117 | source_owner = %source_owner_pubkey, | ||
| 118 | pr_refs_count = pr_refs.len(), | ||
| 119 | owners = by_owner.len(), | ||
| 120 | "Syncing PR refs to owner repositories" | ||
| 121 | ); | ||
| 122 | |||
| 123 | for (owner, maintainers) in &by_owner { | ||
| 124 | // Skip the source owner - we already have the data there | ||
| 125 | if owner == source_owner_pubkey { | ||
| 126 | continue; | ||
| 127 | } | ||
| 128 | |||
| 129 | // Check if this owner shares any maintainers with the source owner | ||
| 130 | // (i.e., there's overlap in their maintainer sets) | ||
| 131 | let has_shared_maintainer = maintainers.iter().any(|m| source_maintainers.contains(m)); | ||
| 132 | |||
| 133 | if !has_shared_maintainer { | ||
| 134 | debug!( | ||
| 135 | owner = %owner, | ||
| 136 | "Skipping owner - no shared maintainers with source" | ||
| 137 | ); | ||
| 138 | continue; | ||
| 139 | } | ||
| 140 | |||
| 141 | // Find the announcement for this owner | ||
| 142 | let announcement = db_repo_data | ||
| 143 | .announcements | ||
| 144 | .iter() | ||
| 145 | .find(|a| a.event.pubkey.to_hex() == *owner); | ||
| 146 | |||
| 147 | let Some(announcement) = announcement else { | ||
| 148 | continue; | ||
| 149 | }; | ||
| 150 | |||
| 151 | let target_repo_path = git_data_path.join(announcement.repo_path()); | ||
| 152 | |||
| 153 | if !target_repo_path.exists() { | ||
| 154 | debug!( | ||
| 155 | owner = %owner, | ||
| 156 | repo_path = %target_repo_path.display(), | ||
| 157 | "Skipping owner - repository doesn't exist" | ||
| 158 | ); | ||
| 159 | continue; | ||
| 160 | } | ||
| 161 | |||
| 162 | // Sync each PR ref | ||
| 163 | let mut refs_created_for_owner = 0; | ||
| 164 | for (event_id, commit_hash) in pr_refs { | ||
| 165 | // Copy the commit if missing | ||
| 166 | if !oid_exists(&target_repo_path, commit_hash) { | ||
| 167 | if let Err(e) = | ||
| 168 | copy_single_commit_between_repos(source_repo_path, &target_repo_path, commit_hash) | ||
| 169 | { | ||
| 170 | warn!( | ||
| 171 | event_id = %event_id, | ||
| 172 | source = %source_repo_path.display(), | ||
| 173 | target = %target_repo_path.display(), | ||
| 174 | error = %e, | ||
| 175 | "Failed to copy PR commit between repos" | ||
| 176 | ); | ||
| 177 | result | ||
| 178 | .errors | ||
| 179 | .push((target_repo_path.display().to_string(), e)); | ||
| 180 | continue; | ||
| 181 | } | ||
| 182 | } | ||
| 183 | |||
| 184 | // Create the refs/nostr/<event-id> ref | ||
| 185 | let ref_name = format!("refs/nostr/{}", event_id); | ||
| 186 | match git::update_ref(&target_repo_path, &ref_name, commit_hash) { | ||
| 187 | Ok(()) => { | ||
| 188 | info!( | ||
| 189 | event_id = %event_id, | ||
| 190 | commit = %commit_hash, | ||
| 191 | target = %target_repo_path.display(), | ||
| 192 | "Created PR ref in target repository" | ||
| 193 | ); | ||
| 194 | refs_created_for_owner += 1; | ||
| 195 | } | ||
| 196 | Err(e) => { | ||
| 197 | warn!( | ||
| 198 | event_id = %event_id, | ||
| 199 | target = %target_repo_path.display(), | ||
| 200 | error = %e, | ||
| 201 | "Failed to create PR ref in target repository" | ||
| 202 | ); | ||
| 203 | result.errors.push((target_repo_path.display().to_string(), e)); | ||
| 204 | } | ||
| 205 | } | ||
| 206 | } | ||
| 207 | |||
| 208 | if refs_created_for_owner > 0 { | ||
| 209 | result.repos_synced += 1; | ||
| 210 | result.refs_created += refs_created_for_owner; | ||
| 211 | |||
| 212 | info!( | ||
| 213 | owner = %owner, | ||
| 214 | repo_path = %target_repo_path.display(), | ||
| 215 | refs_created = refs_created_for_owner, | ||
| 216 | "Synced PR refs to owner repository" | ||
| 217 | ); | ||
| 218 | } | ||
| 219 | } | ||
| 220 | |||
| 221 | info!( | ||
| 222 | repos_synced = result.repos_synced, | ||
| 223 | refs_created = result.refs_created, | ||
| 224 | errors = result.errors.len(), | ||
| 225 | "Completed PR ref sync to owner repositories" | ||
| 226 | ); | ||
| 227 | |||
| 228 | result | ||
| 229 | } | ||
| 230 | |||
| 231 | /// Copy a single commit from source repository to target repository | ||
| 232 | fn copy_single_commit_between_repos( | ||
| 233 | source_repo: &Path, | ||
| 234 | target_repo: &Path, | ||
| 235 | commit_hash: &str, | ||
| 236 | ) -> Result<(), String> { | ||
| 237 | debug!( | ||
| 238 | "Copying commit {} from {} to {}", | ||
| 239 | commit_hash, | ||
| 240 | source_repo.display(), | ||
| 241 | target_repo.display() | ||
| 242 | ); | ||
| 243 | |||
| 244 | let output = Command::new("git") | ||
| 245 | .args([ | ||
| 246 | "fetch", | ||
| 247 | source_repo.to_str().ok_or("Invalid source path")?, | ||
| 248 | commit_hash, | ||
| 249 | ]) | ||
| 250 | .current_dir(target_repo) | ||
| 251 | .output() | ||
| 252 | .map_err(|e| format!("Failed to execute git fetch: {}", e))?; | ||
| 253 | |||
| 254 | if !output.status.success() { | ||
| 255 | let stderr = String::from_utf8_lossy(&output.stderr); | ||
| 256 | return Err(format!( | ||
| 257 | "git fetch failed for commit {}: {}", | ||
| 258 | commit_hash, stderr | ||
| 259 | )); | ||
| 260 | } | ||
| 261 | |||
| 262 | debug!( | ||
| 263 | "Copied commit {} to {}", | ||
| 264 | commit_hash, | ||
| 265 | target_repo.display() | ||
| 266 | ); | ||
| 267 | Ok(()) | ||
| 268 | } | ||
| 269 | |||
| 57 | /// Sync git data from a source repository to all other owner repositories | 270 | /// Sync git data from a source repository to all other owner repositories |
| 58 | /// that authorize the given state event. | 271 | /// that authorize the given state event. |
| 59 | /// | 272 | /// |
| @@ -450,4 +663,12 @@ mod tests { | |||
| 450 | assert_eq!(result.refs_deleted, 0); | 663 | assert_eq!(result.refs_deleted, 0); |
| 451 | assert!(!result.head_set); | 664 | assert!(!result.head_set); |
| 452 | } | 665 | } |
| 666 | |||
| 667 | #[test] | ||
| 668 | fn test_pr_sync_result_default() { | ||
| 669 | let result = PrSyncResult::default(); | ||
| 670 | assert_eq!(result.repos_synced, 0); | ||
| 671 | assert_eq!(result.refs_created, 0); | ||
| 672 | assert!(result.errors.is_empty()); | ||
| 673 | } | ||
| 453 | } | 674 | } |