From 70d0197e85ae4ef85202781f6d2dc9e76bd508b3 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Wed, 24 Dec 2025 08:02:12 +0000 Subject: feat(purgatory): add broken purgatory implementation --- src/git/authorization.rs | 113 +++++++-- src/git/handlers.rs | 321 +++++++++++++++-------- src/git/mod.rs | 68 +++++ src/http/mod.rs | 11 +- src/lib.rs | 1 + src/main.rs | 61 ++++- src/nostr/builder.rs | 116 ++++++++- src/nostr/policy/mod.rs | 5 + src/nostr/policy/pr_event.rs | 149 +++++++++++ src/nostr/policy/state.rs | 55 +++- src/purgatory/helpers.rs | 435 +++++++++++++++++++++++++++++++ src/purgatory/mod.rs | 593 +++++++++++++++++++++++++++++++++++++++++++ src/purgatory/types.rs | 99 ++++++++ 13 files changed, 1886 insertions(+), 141 deletions(-) create mode 100644 src/purgatory/helpers.rs create mode 100644 src/purgatory/mod.rs create mode 100644 src/purgatory/types.rs (limited to 'src') diff --git a/src/git/authorization.rs b/src/git/authorization.rs index 4896fc0..fbeaf9e 100644 --- a/src/git/authorization.rs +++ b/src/git/authorization.rs @@ -31,7 +31,7 @@ use anyhow::{anyhow, Result}; use nostr_relay_builder::prelude::*; use nostr_sdk::{EventId, ToBech32}; use std::collections::{HashMap, HashSet}; -use tracing::debug; +use tracing::{debug, info, warn}; use crate::nostr::builder::SharedDatabase; use crate::nostr::events::{ @@ -325,26 +325,31 @@ pub async fn get_authorization_from_db( /// Get the authorization result for a repository scoped to a specific owner /// -/// Unlike `get_authorization_from_db`, this function scopes the authorization -/// to a specific owner's announcement. This is the correct approach for Git push -/// authorization where the URL path specifies the owner. +/// Push authorization checks ONLY purgatory for state events. The database represents +/// the current git state, while purgatory holds the intended future state that pushes +/// should be authorized against. /// /// A push to `alice/my-repo` should only consider authorization from alice's /// announcement, not bob's announcement for the same identifier. /// /// It: -/// 1. Fetches all announcements and states for the identifier -/// 2. Collects authorized maintainers from all announcements (grouped by owner) -/// 3. Looks up the authorized set for the specific owner -/// 4. Finds the latest state event from an authorized maintainer +/// 1. Fetches announcements for the identifier +/// 2. Collects authorized maintainers from owner's announcement +/// 3. Checks purgatory for matching state events from authorized maintainers /// /// Returns an `AuthorizationResult` that indicates whether a push is authorized. -pub async fn get_authorization_for_owner( +pub async fn get_state_authorization_for_specific_owner_repo( database: &SharedDatabase, identifier: &str, owner_pubkey: &str, + purgatory: &std::sync::Arc, + pushed_refs: &[(String, String, String)], + repo_path: &std::path::Path, ) -> Result { - // Fetch all repository data with a single query + use crate::git::list_refs; + use crate::purgatory::RefUpdate; + + // Fetch announcements only - we don't need database states let repo_data = fetch_repository_data(database, identifier).await?; if repo_data.announcements.is_empty() { @@ -380,16 +385,82 @@ pub async fn get_authorization_for_owner( owner_pubkey ); - // Find the latest authorized state from owner's maintainer set - match find_latest_authorized_state(&repo_data.states, &authorized) { - Some(state) => Ok(AuthorizationResult::authorized( - state.clone(), - authorized.into_iter().collect(), - )), - None => Ok(AuthorizationResult::denied( - "No state event found from authorized publishers", - )), + // Check purgatory for matching state events + // Convert pushed refs to RefUpdate (filter out refs/nostr/* refs) + let pushed_updates: Vec = pushed_refs + .iter() + .filter(|(_, _, name)| !name.starts_with("refs/nostr/")) + .map(|(old_oid, new_oid, ref_name)| RefUpdate { + old_oid: old_oid.clone(), + new_oid: new_oid.clone(), + ref_name: ref_name.clone(), + }) + .collect(); + + // Get local refs from repository + let local_refs_list = list_refs(repo_path).unwrap_or_default(); + let local_refs: HashMap = local_refs_list.into_iter().collect(); + + // Find matching state events in purgatory + let matching_events = purgatory.find_matching_states(identifier, &pushed_updates, &local_refs); + + if !matching_events.is_empty() { + debug!( + "Found {} matching state event(s) in purgatory", + matching_events.len() + ); + + // Filter to authorized events and collect them + let authorized_events: Vec = matching_events + .into_iter() + .filter(|event| { + let author_hex = event.pubkey.to_hex(); + authorized.contains(&author_hex) + }) + .collect(); + + if !authorized_events.is_empty() { + // Find the latest event + let latest_authorized = authorized_events + .iter() + .max_by_key(|event| event.created_at) + .unwrap(); // Safe because we checked the vec is not empty + + // Parse the event into RepositoryState + if let Ok(state) = RepositoryState::from_event(latest_authorized.clone()) { + info!( + "Authorized by state event {} from purgatory (author: {})", + latest_authorized.id, + latest_authorized + .pubkey + .to_bech32() + .unwrap_or_else(|_| latest_authorized.pubkey.to_hex()) + ); + + return Ok(AuthorizationResult { + authorized: true, + reason: "Authorized by state event in purgatory".to_string(), + state: Some(state), + maintainers: authorized.into_iter().collect(), + purgatory_events: vec![latest_authorized.clone()], + }); + } else { + warn!( + "Failed to parse purgatory event {} as RepositoryState", + latest_authorized.id + ); + } + } else { + debug!("Purgatory events found but none from authorized authors"); + } + } else { + debug!("No matching state events found in purgatory"); } + + // No matching state found in purgatory + Ok(AuthorizationResult::denied( + "No state event found in purgatory from authorized publishers", + )) } /// Result of authorization check @@ -403,6 +474,8 @@ pub struct AuthorizationResult { pub state: Option, /// The set of valid maintainers (authorized publishers) pub maintainers: Vec, + /// Events from purgatory that authorized this push (state, PR, PR-update events) + pub purgatory_events: Vec, } impl AuthorizationResult { @@ -413,6 +486,7 @@ impl AuthorizationResult { reason: "Push matches latest authorized state".to_string(), state: Some(state), maintainers, + purgatory_events: vec![], } } @@ -423,6 +497,7 @@ impl AuthorizationResult { reason: reason.into(), state: None, maintainers: vec![], + purgatory_events: vec![], } } } diff --git a/src/git/handlers.rs b/src/git/handlers.rs index 8e5f5e1..df6f0e9 100644 --- a/src/git/handlers.rs +++ b/src/git/handlers.rs @@ -4,20 +4,23 @@ use http_body_util::Full; use hyper::{body::Bytes, Response, StatusCode}; +use nostr_sdk::prelude::*; use std::path::PathBuf; +use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tracing::{debug, error, info, warn}; use super::authorization::{ - get_authorization_for_owner, parse_pushed_refs, validate_nostr_ref_pushes, validate_push_refs, - AuthorizationResult, + get_state_authorization_for_specific_owner_repo, parse_pushed_refs, validate_nostr_ref_pushes, + validate_push_refs, AuthorizationResult, }; use super::protocol::{GitService, PktLine}; use super::subprocess::GitSubprocess; use super::try_set_head_if_available; use crate::nostr::builder::SharedDatabase; -use crate::nostr::events::RepositoryState; +use crate::nostr::events::{RepositoryState, KIND_PR, KIND_PR_UPDATE, KIND_REPOSITORY_STATE}; +use crate::purgatory::Purgatory; /// Handle GET /info/refs?service=git-{upload,receive}-pack /// @@ -168,18 +171,24 @@ pub async fn handle_upload_pack( /// Also per GRASP-01: "MUST set repository HEAD per repository state announcement /// as soon as the git data related to that branch has been received." /// +/// Also purgatory GRASP-01: "Accepted repo state announcements, PRs and PR Updates +/// SHOULD be accepted with message "purgatory: won't be served until git data arrives" +/// and kepted in purgatory (not served) until the related git data arrives and +/// otherwise discarded after 30 minutes." +/// /// # Arguments /// * `repo_path` - Path to the bare git repository /// * `request_body` - The git pack data from the client -/// * `database` - Optional database reference for authorization queries +/// * `database` - Database reference for authorization queries /// * `identifier` - The repository identifier (d tag) for authorization lookup /// * `owner_pubkey` - The owner's public key (hex) from the URL path, scoping authorization pub async fn handle_receive_pack( repo_path: PathBuf, request_body: Bytes, - database: Option, + database: SharedDatabase, identifier: &str, owner_pubkey: &str, + purgatory: Arc, ) -> Result>, GitError> { debug!("Handling receive-pack for {:?}", repo_path); @@ -187,37 +196,46 @@ pub async fn handle_receive_pack( return Err(GitError::RepositoryNotFound); } - // Keep track of state for HEAD setting after push + // Keep track of state and events for processing after push let mut authorized_state: Option = None; + let mut authorized_events: Vec = Vec::new(); - // GRASP Authorization Check (if database is provided) - if let Some(ref db) = database { - info!( - "Authorizing push for {} owned by {} via database query", - identifier, owner_pubkey - ); + // GRASP Authorization Check + info!( + "Authorizing push for {} owned by {} via database query", + identifier, owner_pubkey + ); - match authorize_push(db, identifier, owner_pubkey, &request_body).await { - Ok(auth_result) => { - if !auth_result.authorized { - warn!("Push rejected for {}: {}", identifier, auth_result.reason); - return Err(GitError::Unauthorized); - } - info!( - "Push authorized for {} - {} maintainers", - identifier, - auth_result.maintainers.len() - ); - // Save the state for HEAD setting after push - authorized_state = auth_result.state; - } - Err(e) => { - warn!("Authorization check failed for {}: {}", identifier, e); + match authorize_push( + &database, + identifier, + owner_pubkey, + &request_body, + &purgatory, + &repo_path, + ) + .await + { + Ok(auth_result) => { + if !auth_result.authorized { + warn!("Push rejected for {}: {}", identifier, auth_result.reason); return Err(GitError::Unauthorized); } + info!( + "Push authorized for {} - {} maintainers, {} purgatory events", + identifier, + auth_result.maintainers.len(), + auth_result.purgatory_events.len() + ); + // Save the state for HEAD setting after push + authorized_state = auth_result.state.clone(); + // Save the purgatory events for database saving after push + authorized_events = auth_result.purgatory_events; + } + Err(e) => { + warn!("Authorization check failed for {}: {}", identifier, e); + return Err(GitError::Unauthorized); } - } else { - debug!("No database provided - accepting push without authorization"); } // Spawn git receive-pack @@ -265,7 +283,7 @@ pub async fn handle_receive_pack( // GRASP-01: Set HEAD after git data is received // "MUST set repository HEAD per repository state announcement // as soon as the git data related to that branch has been received." - if let Some(state) = authorized_state { + if let Some(ref state) = authorized_state { if let Some(head_ref) = &state.head { if let Some(branch_name) = state.get_head_branch() { if let Some(commit) = state.get_branch_commit(branch_name) { @@ -288,6 +306,43 @@ pub async fn handle_receive_pack( } } + // Save all events from purgatory that authorized this push and remove them from purgatory + // This includes state events, PR events, and PR-update events + if !authorized_events.is_empty() { + info!( + "Saving {} purgatory event(s) to database after successful push", + authorized_events.len() + ); + + for event in &authorized_events { + match database.save_event(event).await { + Ok(_) => { + info!("Saved purgatory event {} to database", event.id); + // TODO let broadcast_success = local_relay.notify_event(event.clone()); + warn!("TODO Here we need to broadcast on open websockets for live listeners. eventid; {}", event.id); + // Remove from purgatory based on event kind + if event.kind == Kind::from(KIND_REPOSITORY_STATE) { + purgatory.remove_state_event(identifier, &event.id); + info!("Removed state event {} from purgatory", event.id); + } else if event.kind == Kind::from(KIND_PR) + || event.kind == Kind::from(KIND_PR_UPDATE) + { + // Extract event ID from the event itself (it's the event.id) + let event_id_hex = event.id.to_hex(); + purgatory.remove_pr(&event_id_hex); + info!("Removed PR event {} from purgatory", event.id); + } + } + Err(e) => { + warn!( + "Failed to save purgatory event {} to database: {}", + event.id, e + ); + } + } + } + } + Ok(Response::builder() .status(StatusCode::OK) .header( @@ -302,115 +357,175 @@ pub async fn handle_receive_pack( /// Perform GRASP authorization for a push operation /// /// This function queries the database directly (not via WebSocket): -/// 1. Fetches announcement and state events for the identifier -/// 2. Filters to the specific owner's announcement -/// 3. Collects authorized publishers from that announcement (owner + maintainers) -/// 4. Gets the latest authorized state from those publishers -/// 5. Validates that pushed refs match the state -/// 6. Validates refs/nostr/ has valid event id and if event exists, `c` tag matches ref +/// 1. Parses the pushed refs from the git pack protocol +/// 2. Separates refs/nostr/ refs from normal refs +/// 3. For normal refs: validates against state events in purgatory +/// 4. For refs/nostr/ refs: validates event ID format and collects PR/PR-update events from purgatory +/// 5. Returns all authorizing events (state + PR/PR-update) in the result async fn authorize_push( database: &SharedDatabase, identifier: &str, owner_pubkey: &str, request_body: &Bytes, + purgatory: &Arc, + repo_path: &std::path::Path, ) -> anyhow::Result { debug!( "Authorizing push for {} owned by {} via database query", identifier, owner_pubkey ); - // Parse refs from the push request FIRST to check if this is a refs/nostr/ push + // Parse refs from the push request let pushed_refs = parse_pushed_refs(request_body); debug!("Parsed {} refs from push request", pushed_refs.len()); for (old_oid, new_oid, ref_name) in &pushed_refs { debug!(" {} {} -> {}", ref_name, old_oid, new_oid); } - // Separate refs/nostr/ refs from other refs - // Per GRASP-01: "MUST accept pushes via this service to `refs/nostr/`" - let (nostr_refs, other_refs): (Vec<_>, Vec<_>) = pushed_refs + // Separate refs/nostr/ refs from state refs + let (nostr_refs, state_refs): (Vec<_>, Vec<_>) = pushed_refs .iter() .partition(|(_, _, ref_name)| ref_name.starts_with("refs/nostr/")); - // Validate refs/nostr/ refs if any exist + // Collect all purgatory events that authorize this push + let mut purgatory_events = Vec::new(); + + // Handle refs/nostr/ refs - validate and collect PR/PR-update events from purgatory if !nostr_refs.is_empty() { debug!( - "Found {} refs/nostr/ refs - validating against events", + "Found {} refs/nostr/ refs - validating and collecting from purgatory", nostr_refs.len() ); - // Validate refs/nostr/ pushes: checks event ID format and commit matching - let nostr_refs_owned: Vec<(String, String, String)> = nostr_refs - .into_iter() - .map(|(a, b, c)| (a.clone(), b.clone(), c.clone())) - .collect(); - if let Err(e) = validate_nostr_ref_pushes(database, &nostr_refs_owned).await { - warn!("refs/nostr/ validation failed: {}", e); - return Ok(AuthorizationResult::denied(format!( - "refs/nostr/ validation failed: {}", - e - ))); + for (_, new_oid, ref_name) in &nostr_refs { + // Extract event ID from ref name + if let Some(event_id_hex) = ref_name.strip_prefix("refs/nostr/") { + // Validate event ID format + if EventId::parse(event_id_hex).is_err() { + warn!("Invalid event ID format in ref: {}", ref_name); + return Ok(AuthorizationResult::denied(format!( + "Invalid event ID format in ref: {}", + ref_name + ))); + } + + // Check purgatory for PR event + if let Some(entry) = purgatory.find_pr(event_id_hex) { + if let Some(event) = entry.event { + // Verify commit matches + if entry.commit == *new_oid { + debug!( + "Found matching PR event {} in purgatory for ref {}", + event_id_hex, ref_name + ); + purgatory_events.push(event); + } else { + warn!( + "PR event {} in purgatory has commit mismatch: expected {}, got {}", + event_id_hex, entry.commit, new_oid + ); + return Ok(AuthorizationResult::denied(format!( + "PR event {} commit mismatch: expected {}, got {}", + event_id_hex, entry.commit, new_oid + ))); + } + } else { + // Placeholder exists - allow push (git-data-first scenario) + debug!( + "Found placeholder already for PR event {} in purgatory - as we dont have the event and therefore dont know the required commit_id we allow overwriting with a different commit_id", + event_id_hex + ); + } + } else { + // No entry in purgatory - check database for existing event + let nostr_refs_owned = vec![(String::new(), new_oid.clone(), ref_name.clone())]; + if let Err(e) = validate_nostr_ref_pushes(database, &nostr_refs_owned).await { + warn!("refs/nostr/ validation failed: {}", e); + return Ok(AuthorizationResult::denied(format!( + "refs/nostr/ validation failed: {}", + e + ))); + } + debug!( + "No purgatory entry for {} - validated against database", + event_id_hex + ); + } + } } - debug!("refs/nostr/ push validated successfully"); } - // If only refs/nostr/ refs, we're done - return success - if other_refs.is_empty() { - debug!("Only refs/nostr/ refs in push - authorization complete"); - return Ok(AuthorizationResult { - authorized: true, - reason: "Push to refs/nostr/ validated against events".to_string(), - state: None, - maintainers: vec![], - }); - } + // Handle normal refs - validate against state events + if !state_refs.is_empty() { + debug!( + "Found {} non-refs/nostr/ refs - checking state authorization", + state_refs.len() + ); - // For non-refs/nostr/ refs, require state validation - debug!( - "Found {} non-refs/nostr/ refs - checking state authorization", - other_refs.len() - ); - let auth_result = get_authorization_for_owner(database, identifier, owner_pubkey).await?; + let auth_result = get_state_authorization_for_specific_owner_repo( + database, + identifier, + owner_pubkey, + purgatory, + &pushed_refs, //it would be better to accept state_refs but thats in different format + repo_path, + ) + .await?; - if !auth_result.authorized { - return Ok(auth_result); - } + if !auth_result.authorized { + return Ok(auth_result); + } - // Convert other_refs for validation - let other_refs_owned: Vec<(String, String, String)> = other_refs - .into_iter() - .map(|(a, b, c)| (a.clone(), b.clone(), c.clone())) - .collect(); + // Collect state events from purgatory + purgatory_events.extend(auth_result.purgatory_events); - // Validate non-refs/nostr/ refs against state - if let Some(ref state) = auth_result.state { - debug!( - "Validating against state with {} branches", - state.branches.len() - ); + // Validate refs against state + let other_refs_owned: Vec<(String, String, String)> = state_refs + .into_iter() + .map(|(a, b, c)| (a.clone(), b.clone(), c.clone())) + .collect(); - // If we have a state event but couldn't parse any refs, reject the push. - // This protects against parsing failures allowing unauthorized pushes. - if other_refs_owned.is_empty() && !state.branches.is_empty() { - warn!("No refs parsed from push request but state event has branches - rejecting"); - return Ok(AuthorizationResult::denied( - "Failed to parse refs from push request - cannot validate against state", - )); - } + if let Some(ref state) = auth_result.state { + debug!( + "Validating against state with {} branches", + state.branches.len() + ); + + if other_refs_owned.is_empty() && !state.branches.is_empty() { + warn!("No refs parsed from push request but state event has branches - rejecting"); + return Ok(AuthorizationResult::denied( + "Failed to parse refs from push request - cannot validate against state", + )); + } - if let Err(e) = validate_push_refs(state, &other_refs_owned) { - warn!("Ref validation failed: {}", e); - return Ok(AuthorizationResult::denied(format!( - "Ref validation failed: {}", - e - ))); + if let Err(e) = validate_push_refs(state, &other_refs_owned) { + warn!("Ref validation failed: {}", e); + return Ok(AuthorizationResult::denied(format!( + "Ref validation failed: {}", + e + ))); + } + debug!("Ref validation passed"); } - debug!("Ref validation passed"); - } else { - warn!("No state in auth_result - cannot validate refs"); + + // Return result with purgatory events + return Ok(AuthorizationResult { + authorized: true, + reason: auth_result.reason, + state: auth_result.state, + maintainers: auth_result.maintainers, + purgatory_events, + }); } - Ok(auth_result) + // Only refs/nostr/ refs - return success with collected events + Ok(AuthorizationResult { + authorized: true, + reason: "Push to refs/nostr/ validated".to_string(), + state: None, + maintainers: vec![], + purgatory_events, + }) } /// Errors that can occur in Git handlers diff --git a/src/git/mod.rs b/src/git/mod.rs index 599a94b..5c99b3e 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -340,6 +340,74 @@ pub fn validate_nostr_ref( Ok(true) } +/// Clean up placeholder refs from all repositories on shutdown. +/// +/// Walks through all git repositories in the git_data_path and deletes +/// `refs/nostr/` refs for the given event IDs. This is called +/// on shutdown to clean up placeholders created when git data arrived +/// before the corresponding PR event. +/// +/// # Arguments +/// * `git_data_path` - Base directory containing git repositories +/// * `event_ids` - Event IDs whose refs/nostr/ refs should be deleted +/// +/// # Returns +/// Number of refs successfully deleted +pub fn cleanup_placeholder_refs(git_data_path: &str, event_ids: &[String]) -> usize { + if event_ids.is_empty() { + return 0; + } + + let git_path = PathBuf::from(git_data_path); + if !git_path.exists() { + debug!("Git data path does not exist: {}", git_data_path); + return 0; + } + + let mut deleted_count = 0; + + // Walk through all repositories (npub/repo.git structure) + if let Ok(npub_entries) = std::fs::read_dir(&git_path) { + for npub_entry in npub_entries.flatten() { + if !npub_entry.path().is_dir() { + continue; + } + + // For each npub directory, check repos + if let Ok(repo_entries) = std::fs::read_dir(npub_entry.path()) { + for repo_entry in repo_entries.flatten() { + let repo_path = repo_entry.path(); + if !repo_path.is_dir() || !repo_path.to_string_lossy().ends_with(".git") { + continue; + } + + // Try to delete refs/nostr/ for each placeholder event + for event_id in event_ids { + let ref_name = format!("refs/nostr/{}", event_id); + if delete_ref(&repo_path, &ref_name).is_ok() { + deleted_count += 1; + info!( + "Cleaned up placeholder ref {} from {}", + ref_name, + repo_path.display() + ); + } + } + } + } + } + } + + if deleted_count > 0 { + info!( + "Shutdown cleanup: removed {} placeholder refs from git repositories", + deleted_count + ); + } + + deleted_count +} + /// Get the current HEAD ref from a repository /// /// # Arguments diff --git a/src/http/mod.rs b/src/http/mod.rs index 91a6067..d62cc4a 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -27,6 +27,7 @@ use crate::config::Config; use crate::git; use crate::metrics::Metrics; use crate::nostr::builder::SharedDatabase; +use crate::purgatory::Purgatory; /// CORS headers required by GRASP-01 specification (lines 40-47) const CORS_ALLOW_ORIGIN: &str = "*"; @@ -94,6 +95,8 @@ struct HttpService { database: SharedDatabase, /// Optional metrics for Prometheus endpoint metrics: Option>, + /// Purgatory for event/git coordination + purgatory: Arc, } impl HttpService { @@ -103,6 +106,7 @@ impl HttpService { remote: SocketAddr, database: SharedDatabase, metrics: Option>, + purgatory: Arc, ) -> Self { Self { relay, @@ -110,6 +114,7 @@ impl HttpService { remote, database, metrics, + purgatory, } } } @@ -126,6 +131,7 @@ impl Service> for HttpService { let method = req.method().clone(); let git_data_path = self.config.effective_git_data_path(); let database = self.database.clone(); + let purgatory = self.purgatory.clone(); // Handle OPTIONS preflight requests (CORS) // GRASP-01 spec line 47: Respond to OPTIONS with 204 No Content @@ -225,9 +231,10 @@ impl Service> for HttpService { let result = git::handlers::handle_receive_pack( repo_path, body_bytes.clone(), - Some(database.clone()), + database.clone(), &identifier, &owner_pubkey_hex, + purgatory.clone(), ) .await; @@ -497,6 +504,7 @@ pub async fn run_server( relay: LocalRelay, database: SharedDatabase, metrics: Option>, + purgatory: Arc, ) -> anyhow::Result<()> { let bind_addr: SocketAddr = config.bind_address.parse()?; @@ -515,6 +523,7 @@ pub async fn run_server( addr, database.clone(), metrics.clone(), + purgatory.clone(), ); tokio::spawn(async move { diff --git a/src/lib.rs b/src/lib.rs index a1306c4..8befd6f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,4 +3,5 @@ pub mod git; pub mod http; pub mod metrics; pub mod nostr; +pub mod purgatory; pub mod sync; diff --git a/src/main.rs b/src/main.rs index ddb198e..e39c1ab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,17 @@ use std::sync::Arc; +use std::time::Duration; use anyhow::Result; +use tokio::signal; use tracing::{info, Level}; use tracing_subscriber::FmtSubscriber; use ngit_grasp::{ config::{Config, DatabaseBackend}, - http, + git, http, metrics::Metrics, nostr, + purgatory::Purgatory, sync::SyncManager, }; @@ -45,9 +48,13 @@ async fn main() -> Result<()> { None }; + // Create purgatory for event/git coordination + let purgatory = Arc::new(Purgatory::new()); + info!("Purgatory initialized for event coordination"); + // Create Nostr relay with NIP-34 validation // Returns both the relay and database for direct queries in handlers - if let Ok(relay_with_db) = nostr::builder::create_relay(&config).await { + if let Ok(relay_with_db) = nostr::builder::create_relay(&config, purgatory.clone()).await { info!( "Relay created with NIP-34 validation for domain: {}", config.domain @@ -79,9 +86,57 @@ async fn main() -> Result<()> { sync_manager.run().await; }); + // Spawn background cleanup task + let cleanup_purgatory = purgatory.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(60)); + loop { + interval.tick().await; + let (state_removed, pr_removed) = cleanup_purgatory.cleanup(); + if state_removed > 0 || pr_removed > 0 { + info!( + "Purgatory cleanup: removed {} state events, {} PR events", + state_removed, pr_removed + ); + } + } + }); + info!("Purgatory cleanup task started (60s interval)"); + + // Setup shutdown handler for purgatory cleanup + let shutdown_purgatory = purgatory.clone(); + let git_data_path = config.effective_git_data_path(); + // Start HTTP server with integrated relay and database info!("Starting HTTP server on {}", config.bind_address); - http::run_server(config, relay_with_db.relay, relay_with_db.database, metrics).await?; + + // Run server until shutdown signal, then cleanup + tokio::select! { + result = http::run_server( + config, + relay_with_db.relay, + relay_with_db.database, + metrics, + purgatory, + ) => { + if let Err(e) = result { + return Err(e); + } + } + _ = signal::ctrl_c() => { + info!("Received shutdown signal, cleaning up..."); + } + } + + // Cleanup placeholder refs on shutdown + let placeholder_ids = shutdown_purgatory.get_placeholder_event_ids(); + if !placeholder_ids.is_empty() { + info!( + "Cleaning up {} placeholder refs/nostr/ refs on shutdown", + placeholder_ids.len() + ); + git::cleanup_placeholder_refs(&git_data_path, &placeholder_ids); + } } Ok(()) diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index 8dd6291..2b4d524 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs @@ -13,7 +13,7 @@ use nostr_relay_builder::prelude::*; use crate::config::{Config, DatabaseBackend}; use crate::nostr::events::{ - RepositoryAnnouncement, RepositoryState, KIND_PR, KIND_PR_UPDATE, KIND_REPOSITORY_ANNOUNCEMENT, + RepositoryAnnouncement, KIND_PR, KIND_PR_UPDATE, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE, KIND_USER_GRASP_LIST, }; use crate::nostr::policy::{ @@ -57,8 +57,9 @@ impl Nip34WritePolicy { domain: impl Into, database: SharedDatabase, git_data_path: impl Into, + purgatory: std::sync::Arc, ) -> Self { - let ctx = PolicyContext::new(domain, database, git_data_path); + let ctx = PolicyContext::new(domain, database, git_data_path, purgatory); Self { announcement_policy: AnnouncementPolicy::new(ctx.clone()), state_policy: StatePolicy::new(ctx.clone()), @@ -143,21 +144,50 @@ impl Nip34WritePolicy { match self.state_policy.validate(event) { StateResult::Accept => { - // Parse state to get HEAD and branch info - match RepositoryState::from_event(event.clone()) { - Ok(_state) => { - // Process state alignment asynchronously - if let Err(e) = self.state_policy.process_state_event(event).await { - tracing::warn!("Failed to process state event {}: {}", event_id_str, e); + // Parse state to get identifier for purgatory message + let identifier = event + .tags + .iter() + .find_map(|tag| { + let tag_vec = tag.clone().to_vec(); + if tag_vec.len() >= 2 && tag_vec[0] == "d" { + Some(tag_vec[1].clone()) + } else { + None } + }) + .unwrap_or_else(|| "unknown".to_string()); - tracing::debug!("Accepted repository state: {}", event_id_str); + // Process state alignment asynchronously + match self.state_policy.process_state_event(event).await { + Ok(0) => { + // No repos aligned - event was added to purgatory + tracing::info!( + "State event {} added to purgatory: waiting for git data for identifier {}", + event_id_str, + identifier + ); + WritePolicyResult::Reject { + status: true, // Client sees OK + message: format!( + "purgatory: state event stored, waiting for git push for {}", + identifier + ) + .into(), + } + } + Ok(count) => { + // Successfully aligned repos + tracing::debug!( + "Accepted repository state {}: aligned {} repo(s)", + event_id_str, + count + ); WritePolicyResult::Accept } Err(e) => { - tracing::warn!("Failed to parse repository state {}: {}", event_id_str, e); - // Still accept the event even if we can't parse it - // The validation passed, so it's structurally valid + tracing::warn!("Failed to process state event {}: {}", event_id_str, e); + // Still accept the event even if processing failed WritePolicyResult::Accept } } @@ -173,6 +203,58 @@ impl Nip34WritePolicy { async fn handle_pr_event(&self, event: &Event) -> WritePolicyResult { let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); + // Check if git data exists (checks placeholders and commit existence) + match self.pr_event_policy.check_git_data_exists(event).await { + Ok(false) => { + // No git data exists - add to purgatory + let commit = event + .tags + .iter() + .find_map(|tag| { + let tag_vec = tag.clone().to_vec(); + if tag_vec.len() >= 2 && tag_vec[0] == "c" { + Some(tag_vec[1].clone()) + } else { + None + } + }) + .unwrap_or_else(|| "unknown".to_string()); + + tracing::info!( + "PR event {} added to purgatory: waiting for git push with commit {}", + event_id_str, + commit + ); + + // Add to purgatory + self.ctx + .purgatory + .add_pr(event.clone(), event.id.to_hex(), commit.clone()); + + return WritePolicyResult::Reject { + status: true, // Client sees OK + message: format!( + "purgatory: PR event stored, waiting for git push with commit {}", + commit + ) + .into(), + }; + } + Ok(true) => { + // Git data exists - proceed with normal validation + tracing::debug!("Git data exists for PR event {}", event_id_str); + } + Err(e) => { + // Error checking git data - reject event + tracing::warn!( + "Failed to check git data for PR event {}: {}", + event_id_str, + e + ); + return WritePolicyResult::reject(format!("Failed to check git data: {}", e)); + } + } + // Validate refs/nostr refs for this PR event // This deletes any refs/nostr/ that points to wrong commit if let Err(e) = self.pr_event_policy.validate_nostr_ref(event).await { @@ -289,7 +371,10 @@ pub struct RelayWithDatabase { /// Returns a `RelayWithDatabase` struct containing: /// - The `LocalRelay` for handling WebSocket connections /// - The `SharedDatabase` for direct database queries (e.g., push authorization) -pub async fn create_relay(config: &Config) -> Result { +pub async fn create_relay( + config: &Config, + purgatory: Arc, +) -> Result { tracing::info!("Configuring nostr relay with GRASP-01 validation..."); // Determine database path @@ -337,7 +422,10 @@ pub async fn create_relay(config: &Config) -> Result { // Build relay with GRASP-01 validation // Clone Arc for the write policy so both relay and policy can access the database let git_data_path = config.effective_git_data_path(); - let write_policy = Nip34WritePolicy::new(&config.domain, database.clone(), &git_data_path); + + // Create write policy with purgatory integration + let write_policy = + Nip34WritePolicy::new(&config.domain, database.clone(), &git_data_path, purgatory); let relay = LocalRelayBuilder::default() .database(database.clone()) diff --git a/src/nostr/policy/mod.rs b/src/nostr/policy/mod.rs index 19db5f6..2a446fe 100644 --- a/src/nostr/policy/mod.rs +++ b/src/nostr/policy/mod.rs @@ -16,6 +16,8 @@ pub use related::{ReferenceResult, RelatedEventPolicy}; pub use state::{AlignmentResult, StatePolicy, StateResult}; use super::SharedDatabase; +use crate::purgatory::Purgatory; +use std::sync::Arc; /// Shared context for all sub-policies #[derive(Clone)] @@ -23,6 +25,7 @@ pub struct PolicyContext { pub domain: String, pub database: SharedDatabase, pub git_data_path: std::path::PathBuf, + pub purgatory: Arc, } impl PolicyContext { @@ -30,11 +33,13 @@ impl PolicyContext { domain: impl Into, database: SharedDatabase, git_data_path: impl Into, + purgatory: Arc, ) -> Self { Self { domain: domain.into(), database, git_data_path: git_data_path.into(), + purgatory, } } } diff --git a/src/nostr/policy/pr_event.rs b/src/nostr/policy/pr_event.rs index 53da369..c7602b0 100644 --- a/src/nostr/policy/pr_event.rs +++ b/src/nostr/policy/pr_event.rs @@ -19,6 +19,155 @@ impl PrEventPolicy { Self { ctx } } + /// Check if git data exists for a PR event + /// + /// This checks: + /// 1. If a placeholder exists (git-data-first scenario) + /// 2. If the commit exists in any relevant repository + /// + /// # Returns + /// - `Ok(true)` if git data ready (either placeholder found or commit exists) + /// - `Ok(false)` if git data missing (should add to purgatory) + /// - `Err(msg)` on errors + pub async fn check_git_data_exists(&self, event: &Event) -> Result { + let event_id = event.id.to_hex(); + + // Extract the `c` tag (commit hash) from the PR event + let commit = event.tags.iter().find_map(|tag| { + let tag_vec = tag.clone().to_vec(); + if tag_vec.len() >= 2 && tag_vec[0] == "c" { + Some(tag_vec[1].clone()) + } else { + None + } + }); + + let commit = match commit { + Some(c) => c, + None => { + return Err(format!("PR event {} has no 'c' tag", event_id)); + } + }; + + // Check for placeholder first (git-data-first scenario) + if let Some(placeholder_commit) = self.ctx.purgatory.find_pr_placeholder(&event_id) { + if placeholder_commit == commit { + // Perfect match - git data arrived first with matching commit + tracing::debug!( + "Found matching placeholder for PR event {} with commit {}", + event_id, + commit + ); + // Remove placeholder - event processing will continue normally + self.ctx.purgatory.remove_pr(&event_id); + return Ok(true); + } else { + // Placeholder has different commit - incoming event supersedes + tracing::info!( + "PR event {} supersedes placeholder: event expects commit {}, placeholder has {}", + event_id, + commit, + placeholder_commit + ); + // Remove placeholder with old commit data + self.ctx.purgatory.remove_pr(&event_id); + // TODO: Also remove git data (refs/nostr/) - Phase 5 + // Fall through to check if new commit exists + } + } + + // Check if commit exists in any repository referenced by this PR + // Extract ALL `a` tags (repository references) from the PR event + let repo_refs: Vec = event + .tags + .iter() + .filter_map(|tag| { + let tag_vec = tag.clone().to_vec(); + if tag_vec.len() >= 2 && tag_vec[0] == "a" && tag_vec[1].starts_with("30617:") { + Some(tag_vec[1].clone()) + } else { + None + } + }) + .collect(); + + if repo_refs.is_empty() { + // No repo references - cannot check git data + // This is unusual but let it through (other validation will catch issues) + return Ok(true); + } + + // Check each repository to see if commit exists + for repo_ref in repo_refs { + // Parse the repo reference: 30617:: + let parts: Vec<&str> = repo_ref.split(':').collect(); + if parts.len() < 3 { + continue; + } + + let repo_pubkey = match PublicKey::from_hex(parts[1]) { + Ok(pk) => pk, + Err(_) => continue, + }; + let identifier = parts[2]; + + // Look up repository announcement to get the npub for path + let filter = Filter::new() + .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT)) + .author(repo_pubkey) + .custom_tag( + SingleLetterTag::lowercase(Alphabet::D), + identifier.to_string(), + ); + + let announcements: Vec = match self.ctx.database.query(filter).await { + Ok(events) => events.into_iter().collect(), + Err(e) => { + tracing::warn!( + "Failed to query for repository announcement for PR {}: {}", + event_id, + e + ); + continue; + } + }; + + if announcements.is_empty() { + continue; + } + + // Check each matching announcement + for announcement_event in announcements { + let announcement = match RepositoryAnnouncement::from_event(announcement_event) { + Ok(a) => a, + Err(_) => continue, + }; + + // Build repository path + let repo_path = self.ctx.git_data_path.join(announcement.repo_path()); + + // Check if commit exists + if git::commit_exists(&repo_path, &commit) { + tracing::debug!( + "Found commit {} for PR event {} in repository {}", + commit, + event_id, + repo_path.display() + ); + return Ok(true); + } + } + } + + // No git data found - should add to purgatory + tracing::debug!( + "No git data found for PR event {} with commit {}", + event_id, + commit + ); + Ok(false) + } + /// Validate refs/nostr/ ref against a PR or PR Update event's `c` tag /// /// When a PR event (kind 1618) or PR Update event (kind 1619) is received, diff --git a/src/nostr/policy/state.rs b/src/nostr/policy/state.rs index 43349e2..5e749ed 100644 --- a/src/nostr/policy/state.rs +++ b/src/nostr/policy/state.rs @@ -66,6 +66,24 @@ impl StatePolicy { let state = RepositoryState::from_event(event.clone()) .map_err(|e| format!("Failed to parse state: {}", e))?; + // Check if ANY git repositories exist for this identifier (regardless of authorization) + // This helps us distinguish "no git data yet" from "not authorized" or "not latest" + let has_any_git_data = self.has_git_data_for_identifier(&state.identifier); + + if !has_any_git_data { + // No git data exists yet - add to purgatory + tracing::debug!( + "No git data found for identifier {}, adding state event {} to purgatory", + state.identifier, + event.id.to_hex() + ); + self.ctx + .purgatory + .add_state(event.clone(), state.identifier.clone(), event.pubkey); + // Return 0 repos aligned, but this is not an error + return Ok(0); + } + // Identify owner repositories for which this is the latest authorized state let owner_repos = self.identify_owner_repositories(&state).await?; let repo_count = owner_repos.len(); @@ -97,13 +115,48 @@ impl StatePolicy { ); } else { tracing::debug!( - "No owner repos to align for state - git data not available yet or not latest" + "No owner repos to align for state - git data exists but author not authorized or not latest" ); } Ok(total_aligned) } + /// Check if any git repositories exist for the given identifier + /// + /// Scans the git_data_path for any directories matching the pattern: + /// `/.git` + /// + /// This is used to distinguish "no git data yet" from "not authorized". + fn has_git_data_for_identifier(&self, identifier: &str) -> bool { + let git_data_path = &self.ctx.git_data_path; + + // Check if git_data_path exists + if !git_data_path.exists() { + return false; + } + + // Scan for any npub directories + let read_dir = match std::fs::read_dir(git_data_path) { + Ok(dir) => dir, + Err(_) => return false, + }; + + for entry in read_dir.flatten() { + if let Ok(file_type) = entry.file_type() { + if file_type.is_dir() { + // Check if /.git exists + let repo_path = entry.path().join(format!("{}.git", identifier)); + if repo_path.exists() { + return true; + } + } + } + } + + false + } + /// Check if this state event is the latest for its identifier among authorized authors /// /// A state is considered "latest" if no other state event in the database diff --git a/src/purgatory/helpers.rs b/src/purgatory/helpers.rs new file mode 100644 index 0000000..5df6cc8 --- /dev/null +++ b/src/purgatory/helpers.rs @@ -0,0 +1,435 @@ +//! Helper functions for purgatory state event processing. +//! +//! These functions handle the late-binding extraction and matching of git refs +//! from state events. Refs are extracted at git push time rather than event +//! arrival time to enable flexible matching logic. + +use super::{RefPair, RefUpdate}; +use nostr_sdk::prelude::*; +use std::collections::HashMap; + +/// Extract ref pairs from a state event (kind 30618). +/// +/// Parses all `refs/heads/*` and `refs/tags/*` tags from the event, +/// creating RefPair instances with the full ref name and target object SHA. +/// +/// # Arguments +/// * `event` - The state event to extract refs from +/// +/// # Returns +/// Vector of RefPair instances, one for each ref tag found +/// +/// # Tag Format +/// State events use custom tags where the tag kind is the ref name: +/// - Tag kind: "refs/heads/main" or "refs/tags/v1.0" +/// - First value: commit SHA or annotated tag SHA +/// +/// # Example +/// ```ignore +/// // Event with tags: +/// // ["refs/heads/main", "abc123..."] +/// // ["refs/tags/v1.0", "def456..."] +/// let refs = extract_refs_from_state(&event); +/// // Returns: [ +/// // RefPair { ref_name: "refs/heads/main", object_sha: "abc123..." }, +/// // RefPair { ref_name: "refs/tags/v1.0", object_sha: "def456..." } +/// // ] +/// ``` +pub fn extract_refs_from_state(event: &Event) -> Vec { + event + .tags + .iter() + .filter_map(|tag| { + // Check if this is a custom tag with a ref name + if let TagKind::Custom(ref_name) = tag.kind() { + let ref_str = ref_name.as_ref(); + + // Only process refs/heads/* and refs/tags/* + if ref_str.starts_with("refs/heads/") || ref_str.starts_with("refs/tags/") { + // Get the object SHA (first value in tag) + let parts = tag.clone().to_vec(); + if parts.len() >= 2 { + return Some(RefPair { + ref_name: ref_str.to_string(), + object_sha: parts[1].clone(), + }); + } + } + } + None + }) + .collect() +} + +/// Check if a state event can be satisfied by ref updates plus local refs. +/// +/// Returns true if applying the ref updates to local state results in exactly +/// the state declared in the event. This means: +/// 1. Filter local_refs to only branches (refs/heads/*) and tags (refs/tags/*) +/// 2. Apply pushed_updates to create a "would-be" state +/// 3. Compare would-be state with event's declared state - must match exactly +/// +/// This implements correct authorization: the push must transform local state +/// into the declared state, accounting for additions, deletions, and modifications. +/// +/// # Arguments +/// * `event` - The state event to check +/// * `pushed_updates` - Ref updates in the current push operation +/// * `local_refs` - Refs already existing locally (ref_name -> SHA) +/// +/// # Returns +/// true if push transforms local state into declared state, false otherwise +/// +/// # Example +/// ```ignore +/// // State event declares: refs/heads/main@abc123 +/// // Local: refs/heads/main@old123, refs/heads/dev@def456 +/// // Push updates: main old123->abc123, dev def456->0000 (delete) +/// // Result: false (event doesn't declare dev deletion) +/// ``` +pub fn can_satisfy_state( + event: &Event, + pushed_updates: &[RefUpdate], + local_refs: &HashMap, +) -> bool { + let state_refs = extract_refs_from_state(event); + + // Filter local_refs to only branches and tags + let mut would_be_state: HashMap = local_refs + .iter() + .filter(|(ref_name, _)| { + ref_name.starts_with("refs/heads/") || ref_name.starts_with("refs/tags/") + }) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + + // Apply all pushed updates to create the would-be state + for update in pushed_updates { + // Only process branches and tags + if !update.ref_name.starts_with("refs/heads/") && !update.ref_name.starts_with("refs/tags/") + { + continue; + } + + if update.is_deletion() { + // Remove from would-be state + would_be_state.remove(&update.ref_name); + } else { + // Create or modify in would-be state + would_be_state.insert(update.ref_name.clone(), update.new_oid.clone()); + } + } + + // Convert event's state refs to a HashMap for comparison + let declared_state: HashMap = state_refs + .into_iter() + .map(|r| (r.ref_name, r.object_sha)) + .collect(); + + // would_be_state must exactly match declared_state + would_be_state == declared_state +} + +/// Get refs from state event that aren't in pushed_refs. +/// +/// Returns refs that need to be present but aren't being pushed. +/// These refs should exist in local_refs for the state to be satisfiable. +/// Useful for error messages showing what's missing. +/// +/// # Arguments +/// * `event` - The state event to check +/// * `pushed_refs` - Refs being pushed in the current operation +/// +/// # Returns +/// Vector of RefPair instances for refs not in pushed_refs +/// +/// # Example +/// ```ignore +/// // State event declares: refs/heads/main@abc123, refs/heads/dev@def456 +/// // Pushed: refs/heads/main@abc123 +/// // Result: [RefPair { ref_name: "refs/heads/dev", object_sha: "def456" }] +/// ``` +pub fn get_unpushed_refs(event: &Event, pushed_refs: &[RefPair]) -> Vec { + let state_refs = extract_refs_from_state(event); + + state_refs + .into_iter() + .filter(|state_ref| { + // Include if NOT in pushed_refs (by name and SHA) + !pushed_refs.iter().any(|pushed_ref| { + pushed_ref.ref_name == state_ref.ref_name + && pushed_ref.object_sha == state_ref.object_sha + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr_sdk::{EventBuilder, Keys, Tag}; + + fn create_test_state_event(identifier: &str, refs: Vec<(&str, &str)>) -> Event { + let keys = Keys::generate(); + let mut tags = vec![Tag::custom(TagKind::d(), vec![identifier.to_string()])]; + + for (ref_name, sha) in refs { + tags.push(Tag::custom( + TagKind::custom(ref_name), + vec![sha.to_string()], + )); + } + + EventBuilder::new(Kind::from(30618), "") + .tags(tags) + .sign_with_keys(&keys) + .unwrap() + } + + #[test] + fn test_extract_refs_from_state() { + let event = create_test_state_event( + "test-repo", + vec![ + ("refs/heads/main", "abc123"), + ("refs/heads/dev", "def456"), + ("refs/tags/v1.0", "789xyz"), + ], + ); + + let refs = extract_refs_from_state(&event); + + assert_eq!(refs.len(), 3); + assert!(refs + .iter() + .any(|r| r.ref_name == "refs/heads/main" && r.object_sha == "abc123")); + assert!(refs + .iter() + .any(|r| r.ref_name == "refs/heads/dev" && r.object_sha == "def456")); + assert!(refs + .iter() + .any(|r| r.ref_name == "refs/tags/v1.0" && r.object_sha == "789xyz")); + } + + #[test] + fn test_extract_refs_ignores_non_ref_tags() { + let keys = Keys::generate(); + let tags = vec![ + Tag::custom(TagKind::d(), vec!["test-repo".to_string()]), + Tag::custom( + TagKind::custom("refs/heads/main"), + vec!["abc123".to_string()], + ), + Tag::custom(TagKind::custom("some-other-tag"), vec!["value".to_string()]), + ]; + + let event = EventBuilder::new(Kind::from(30618), "") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + + let refs = extract_refs_from_state(&event); + + // Should only extract the refs/heads/main tag + assert_eq!(refs.len(), 1); + assert_eq!(refs[0].ref_name, "refs/heads/main"); + } + + #[test] + fn test_can_satisfy_state_all_in_pushed() { + let event = create_test_state_event( + "test-repo", + vec![("refs/heads/main", "abc123"), ("refs/heads/dev", "def456")], + ); + + let pushed_updates = vec![ + RefUpdate { + old_oid: "0000000000000000000000000000000000000000".to_string(), + new_oid: "abc123".to_string(), + ref_name: "refs/heads/main".to_string(), + }, + RefUpdate { + old_oid: "0000000000000000000000000000000000000000".to_string(), + new_oid: "def456".to_string(), + ref_name: "refs/heads/dev".to_string(), + }, + ]; + + let local_refs = HashMap::new(); + + assert!(can_satisfy_state(&event, &pushed_updates, &local_refs)); + } + + #[test] + fn test_can_satisfy_state_split_between_pushed_and_local() { + let event = create_test_state_event( + "test-repo", + vec![("refs/heads/main", "abc123"), ("refs/heads/dev", "def456")], + ); + + let pushed_updates = vec![RefUpdate { + old_oid: "0000000000000000000000000000000000000000".to_string(), + new_oid: "abc123".to_string(), + ref_name: "refs/heads/main".to_string(), + }]; + + let mut local_refs = HashMap::new(); + local_refs.insert("refs/heads/dev".to_string(), "def456".to_string()); + + assert!(can_satisfy_state(&event, &pushed_updates, &local_refs)); + } + + #[test] + fn test_can_satisfy_state_missing_ref() { + let event = create_test_state_event( + "test-repo", + vec![("refs/heads/main", "abc123"), ("refs/heads/dev", "def456")], + ); + + let pushed_updates = vec![RefUpdate { + old_oid: "0000000000000000000000000000000000000000".to_string(), + new_oid: "abc123".to_string(), + ref_name: "refs/heads/main".to_string(), + }]; + + let local_refs = HashMap::new(); + + // dev ref is missing + assert!(!can_satisfy_state(&event, &pushed_updates, &local_refs)); + } + + #[test] + fn test_can_satisfy_state_modification() { + let event = create_test_state_event( + "test-repo", + vec![("refs/heads/main", "abc123"), ("refs/heads/dev", "def456")], + ); + + let pushed_updates = vec![ + RefUpdate { + old_oid: "old123".to_string(), + new_oid: "abc123".to_string(), + ref_name: "refs/heads/main".to_string(), + }, + RefUpdate { + old_oid: "wrong-sha".to_string(), + new_oid: "def456".to_string(), + ref_name: "refs/heads/dev".to_string(), + }, + ]; + + let mut local_refs = HashMap::new(); + local_refs.insert("refs/heads/main".to_string(), "old123".to_string()); + local_refs.insert("refs/heads/dev".to_string(), "wrong-sha".to_string()); + + // Should succeed because push updates both to match event + assert!(can_satisfy_state(&event, &pushed_updates, &local_refs)); + } + + #[test] + fn test_can_satisfy_state_rejects_extra_refs() { + let event = create_test_state_event("test-repo", vec![("refs/heads/main", "abc123")]); + + let pushed_updates = vec![ + RefUpdate { + old_oid: "0000000000000000000000000000000000000000".to_string(), + new_oid: "abc123".to_string(), + ref_name: "refs/heads/main".to_string(), + }, + RefUpdate { + old_oid: "old456".to_string(), + new_oid: "def456".to_string(), + ref_name: "refs/heads/dev".to_string(), + }, + ]; + + let mut local_refs = HashMap::new(); + local_refs.insert("refs/heads/dev".to_string(), "old456".to_string()); + + // Should fail because event doesn't declare dev + assert!(!can_satisfy_state(&event, &pushed_updates, &local_refs)); + } + + #[test] + fn test_can_satisfy_state_filters_non_branch_tag_refs() { + let event = create_test_state_event("test-repo", vec![("refs/heads/main", "abc123")]); + + let pushed_updates = vec![RefUpdate { + old_oid: "0000000000000000000000000000000000000000".to_string(), + new_oid: "abc123".to_string(), + ref_name: "refs/heads/main".to_string(), + }]; + + let mut local_refs = HashMap::new(); + // Add some non-branch/non-tag refs that should be filtered out + local_refs.insert("refs/pull/123/head".to_string(), "xyz789".to_string()); + local_refs.insert("refs/some/other/thing".to_string(), "aaa111".to_string()); + + // Should succeed - non-branch/tag refs are filtered out + assert!(can_satisfy_state(&event, &pushed_updates, &local_refs)); + } + + #[test] + fn test_can_satisfy_state_empty_event() { + let event = create_test_state_event("test-repo", vec![]); + let pushed_refs = vec![]; + let local_refs = HashMap::new(); + + // Empty state event is satisfied + assert!(can_satisfy_state(&event, &pushed_refs, &local_refs)); + } + + #[test] + fn test_get_unpushed_refs() { + let event = create_test_state_event( + "test-repo", + vec![ + ("refs/heads/main", "abc123"), + ("refs/heads/dev", "def456"), + ("refs/tags/v1.0", "789xyz"), + ], + ); + + let pushed_refs = vec![RefPair { + ref_name: "refs/heads/main".to_string(), + object_sha: "abc123".to_string(), + }]; + + let unpushed = get_unpushed_refs(&event, &pushed_refs); + + assert_eq!(unpushed.len(), 2); + assert!(unpushed.iter().any(|r| r.ref_name == "refs/heads/dev")); + assert!(unpushed.iter().any(|r| r.ref_name == "refs/tags/v1.0")); + } + + #[test] + fn test_get_unpushed_refs_all_pushed() { + let event = create_test_state_event("test-repo", vec![("refs/heads/main", "abc123")]); + + let pushed_refs = vec![RefPair { + ref_name: "refs/heads/main".to_string(), + object_sha: "abc123".to_string(), + }]; + + let unpushed = get_unpushed_refs(&event, &pushed_refs); + + assert_eq!(unpushed.len(), 0); + } + + #[test] + fn test_get_unpushed_refs_sha_mismatch() { + let event = create_test_state_event("test-repo", vec![("refs/heads/main", "abc123")]); + + let pushed_refs = vec![RefPair { + ref_name: "refs/heads/main".to_string(), + object_sha: "different-sha".to_string(), // Different SHA + }]; + + let unpushed = get_unpushed_refs(&event, &pushed_refs); + + // Should still be unpushed because SHA doesn't match + assert_eq!(unpushed.len(), 1); + assert_eq!(unpushed[0].ref_name, "refs/heads/main"); + assert_eq!(unpushed[0].object_sha, "abc123"); + } +} diff --git a/src/purgatory/mod.rs b/src/purgatory/mod.rs new file mode 100644 index 0000000..18a55d5 --- /dev/null +++ b/src/purgatory/mod.rs @@ -0,0 +1,593 @@ +//! Purgatory: In-memory holding area for events awaiting git data. +//! +//! Solves the "which arrives first?" problem where either nostr events or git pushes +//! can arrive in any order. Events and git data are held temporarily until their +//! counterpart arrives, at which point they can be processed together. +//! +//! ## Architecture +//! +//! - **In-memory only**: Data is lost on restart (acceptable per spec) +//! - **Thread-safe**: Uses DashMap for concurrent access from multiple handlers +//! - **Automatic expiry**: Entries expire after 30 minutes by default +//! - **Separate stores**: State events and PR events use different indexing strategies + +mod helpers; +mod types; + +pub use helpers::{can_satisfy_state, extract_refs_from_state, get_unpushed_refs}; +pub use types::{PrPurgatoryEntry, RefPair, RefUpdate, StatePurgatoryEntry}; + +use dashmap::DashMap; +use nostr_sdk::prelude::*; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +/// Default expiry duration for purgatory entries (30 minutes) +const DEFAULT_EXPIRY: Duration = Duration::from_secs(1800); + +/// Main purgatory structure holding events awaiting git data. +/// +/// Provides thread-safe concurrent access to two separate stores: +/// - State events indexed by repository identifier +/// - PR events indexed by event ID +#[derive(Clone)] +pub struct Purgatory { + /// State events (kind 30618) indexed by repository identifier. + /// Multiple state events can wait for the same identifier (different maintainers). + state_events: Arc>>, + + /// PR events (kind 1617/1618) or placeholders indexed by event ID (hex string). + /// Event ID is from the 'e' tag in the PR event itself. + pr_events: Arc>, +} + +impl Purgatory { + /// Create a new empty purgatory. + pub fn new() -> Self { + Self { + state_events: Arc::new(DashMap::new()), + pr_events: Arc::new(DashMap::new()), + } + } + + /// Add a state event to purgatory. + /// + /// The event will expire after the default duration unless matched with git data. + /// Multiple state events for the same identifier are allowed (from different authors). + /// + /// # Arguments + /// * `event` - The state event (kind 30618) to hold + /// * `identifier` - The repository identifier from the 'd' tag + /// * `author` - The event author's public key + pub fn add_state(&self, event: Event, identifier: String, author: PublicKey) { + let now = Instant::now(); + let entry = StatePurgatoryEntry { + event, + identifier: identifier.clone(), + author, + created_at: now, + expires_at: now + DEFAULT_EXPIRY, + }; + + self.state_events.entry(identifier).or_default().push(entry); + } + + /// Add a PR event to purgatory. + /// + /// The event will expire after the default duration unless matched with git data. + /// + /// # Arguments + /// * `event` - The PR event (kind 1617/1618) to hold + /// * `event_id` - The event ID (hex string) from the 'e' tag + /// * `commit` - The commit SHA from the 'c' tag + pub fn add_pr(&self, event: Event, event_id: String, commit: String) { + let now = Instant::now(); + let entry = PrPurgatoryEntry { + event: Some(event), + commit, + created_at: now, + expires_at: now + DEFAULT_EXPIRY, + }; + + self.pr_events.insert(event_id, entry); + } + + /// Add a PR placeholder (git data arrived before PR event). + /// + /// Creates a placeholder entry waiting for the corresponding PR event. + /// + /// # Arguments + /// * `event_id` - The expected event ID (from git ref name) + /// * `commit` - The commit SHA that was pushed + pub fn add_pr_placeholder(&self, event_id: String, commit: String) { + let now = Instant::now(); + let entry = PrPurgatoryEntry { + event: None, // Placeholder - no event yet + commit, + created_at: now, + expires_at: now + DEFAULT_EXPIRY, + }; + + self.pr_events.insert(event_id, entry); + } + + /// Find state events waiting for a specific repository identifier. + /// + /// Returns all state events (from all maintainers) waiting for git data + /// matching this identifier. + /// + /// # Arguments + /// * `identifier` - The repository identifier to search for + /// + /// # Returns + /// Vector of state events waiting for this identifier, or empty vec if none found + pub fn find_state(&self, identifier: &str) -> Vec { + self.state_events + .get(identifier) + .map(|entries| entries.clone()) + .unwrap_or_default() + } + + /// Find a PR event or placeholder by event ID. + /// + /// # Arguments + /// * `event_id` - The event ID to search for + /// + /// # Returns + /// The PR entry if found, None otherwise + pub fn find_pr(&self, event_id: &str) -> Option { + self.pr_events.get(event_id).map(|entry| entry.clone()) + } + + /// Find a PR placeholder specifically (git-data-first scenario). + /// + /// Returns the commit SHA only if a placeholder exists (entry with no event). + /// Used to distinguish placeholders from actual PR events. + /// + /// # Arguments + /// * `event_id` - The event ID to search for + /// + /// # Returns + /// Some(commit_sha) if a placeholder exists, None if no entry or entry has an event + pub fn find_pr_placeholder(&self, event_id: &str) -> Option { + self.pr_events.get(event_id).and_then(|entry| { + if entry.event.is_none() { + Some(entry.commit.clone()) + } else { + None + } + }) + } + + /// Remove a state event from purgatory. + /// + /// Removes all entries for the given identifier. + /// + /// # Arguments + /// * `identifier` - The repository identifier to remove + pub fn remove_state(&self, identifier: &str) { + self.state_events.remove(identifier); + } + + /// Remove a specific state event by comparing the full event. + /// + /// This allows removing a single state event while leaving others + /// for the same identifier intact. + /// + /// # Arguments + /// * `identifier` - The repository identifier + /// * `event_id` - The specific event ID to remove + pub fn remove_state_event(&self, identifier: &str, event_id: &EventId) { + if let Some(mut entries) = self.state_events.get_mut(identifier) { + entries.retain(|entry| entry.event.id != *event_id); + if entries.is_empty() { + drop(entries); // Release lock before removal + self.state_events.remove(identifier); + } + } + } + + /// Find state events that could be satisfied by ref updates. + /// + /// Returns state events waiting for this identifier where applying the + /// ref updates to local state results in exactly the declared state. + /// Uses late-binding ref extraction at git push time. + /// + /// # Arguments + /// * `identifier` - The repository identifier to search for + /// * `pushed_updates` - Ref updates in the current push operation + /// * `local_refs` - Refs already existing locally (ref_name -> SHA) + /// + /// # Returns + /// Vector of events that can be satisfied by the push + pub fn find_matching_states( + &self, + identifier: &str, + pushed_updates: &[RefUpdate], + local_refs: &std::collections::HashMap, + ) -> Vec { + self.state_events + .get(identifier) + .map(|entries| { + entries + .iter() + .filter(|entry| { + helpers::can_satisfy_state(&entry.event, pushed_updates, local_refs) + }) + .map(|entry| entry.event.clone()) + .collect() + }) + .unwrap_or_default() + } + + /// Extend expiry for state events about to be processed. + /// + /// Ensures entries have at least `duration` remaining on their timer. + /// Sets expiry to max(current_expiry, now + duration). + /// + /// # Arguments + /// * `identifier` - The repository identifier + /// * `event_ids` - Event IDs to extend expiry for + /// * `duration` - Minimum duration to guarantee from now + pub fn extend_expiry(&self, identifier: &str, event_ids: &[EventId], duration: Duration) { + if let Some(mut entries) = self.state_events.get_mut(identifier) { + let now = Instant::now(); + let new_expiry = now + duration; + + for entry in entries.iter_mut() { + if event_ids.contains(&entry.event.id) { + // Set to max of current expiry and new expiry + if entry.expires_at < new_expiry { + entry.expires_at = new_expiry; + } + } + } + } + } + + /// Remove a PR event or placeholder from purgatory. + /// + /// # Arguments + /// * `event_id` - The event ID to remove + pub fn remove_pr(&self, event_id: &str) { + self.pr_events.remove(event_id); + } + + /// Get all PR placeholder event IDs (git-data-first entries without events). + /// + /// Returns event IDs for entries where git data arrived before the PR event. + /// These correspond to `refs/nostr/` refs that should be cleaned up + /// on shutdown since they don't have corresponding events. + /// + /// # Returns + /// Vector of event IDs (hex strings) for placeholder entries + pub fn get_placeholder_event_ids(&self) -> Vec { + self.pr_events + .iter() + .filter_map(|entry| { + if entry.value().event.is_none() { + Some(entry.key().clone()) + } else { + None + } + }) + .collect() + } + + /// Remove expired entries from purgatory. + /// + /// Should be called periodically (every 60 seconds) by background task to clean up + /// entries that have exceeded their expiry deadline. + /// + /// # Returns + /// Tuple of (num_state_removed, num_pr_removed) + pub fn cleanup(&self) -> (usize, usize) { + let now = Instant::now(); + let mut state_removed = 0; + + // Remove expired state events + self.state_events.retain(|_, entries| { + let original_len = entries.len(); + entries.retain(|entry| entry.expires_at > now); + state_removed += original_len - entries.len(); + !entries.is_empty() + }); + + // Remove expired PR events + let expired_prs: Vec = self + .pr_events + .iter() + .filter(|entry| entry.value().expires_at <= now) + .map(|entry| entry.key().clone()) + .collect(); + + let pr_removed = expired_prs.len(); + for event_id in expired_prs { + self.pr_events.remove(&event_id); + } + + (state_removed, pr_removed) + } + + /// Remove expired entries from purgatory (legacy method). + /// + /// # Returns + /// Total number of entries removed (state + PR events) + #[deprecated(since = "0.1.0", note = "Use cleanup() instead for separate counts")] + pub fn remove_expired(&self) -> usize { + let (state, pr) = self.cleanup(); + state + pr + } + + /// Get current count of entries in purgatory. + /// + /// # Returns + /// Tuple of (state_event_count, pr_event_count) + pub fn count(&self) -> (usize, usize) { + let state_count: usize = self.state_events.iter().map(|e| e.value().len()).sum(); + let pr_count = self.pr_events.len(); + (state_count, pr_count) + } + + /// Clear all entries from purgatory (for testing). + #[cfg(test)] + pub fn clear(&self) { + self.state_events.clear(); + self.pr_events.clear(); + } +} + +impl Default for Purgatory { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_purgatory_creation() { + let purgatory = Purgatory::new(); + let (state_count, pr_count) = purgatory.count(); + assert_eq!(state_count, 0); + assert_eq!(pr_count, 0); + } + + #[test] + fn test_purgatory_count() { + let purgatory = Purgatory::new(); + + // Add some test data + let keys = Keys::generate(); + let event = EventBuilder::text_note("test") + .sign_with_keys(&keys) + .unwrap(); + + purgatory.add_state(event.clone(), "test-repo".to_string(), keys.public_key()); + purgatory.add_pr(event, "test-event-id".to_string(), "abc123".to_string()); + + let (state_count, pr_count) = purgatory.count(); + assert_eq!(state_count, 1); + assert_eq!(pr_count, 1); + } +} + +#[test] +fn test_pr_event_vs_placeholder() { + let purgatory = Purgatory::new(); + let keys = Keys::generate(); + let event = EventBuilder::text_note("test PR") + .sign_with_keys(&keys) + .unwrap(); + + // Add a PR event with actual event + purgatory.add_pr( + event.clone(), + "event-id-1".to_string(), + "commit-abc".to_string(), + ); + + // Add a placeholder (no event) + purgatory.add_pr_placeholder("event-id-2".to_string(), "commit-def".to_string()); + + // find_pr should find both + assert!(purgatory.find_pr("event-id-1").is_some()); + assert!(purgatory.find_pr("event-id-2").is_some()); + + // find_pr_placeholder should only find the placeholder + assert!(purgatory.find_pr_placeholder("event-id-1").is_none()); + assert_eq!( + purgatory.find_pr_placeholder("event-id-2"), + Some("commit-def".to_string()) + ); +} + +#[test] +fn test_pr_placeholder_creation_and_retrieval() { + let purgatory = Purgatory::new(); + + // Add a placeholder + purgatory.add_pr_placeholder("placeholder-id".to_string(), "commit-123".to_string()); + + // Should be findable by find_pr + let entry = purgatory.find_pr("placeholder-id"); + assert!(entry.is_some()); + let entry = entry.unwrap(); + assert!(entry.event.is_none()); // No event yet + assert_eq!(entry.commit, "commit-123"); + + // Should be findable by find_pr_placeholder + let commit = purgatory.find_pr_placeholder("placeholder-id"); + assert_eq!(commit, Some("commit-123".to_string())); +} + +#[test] +fn test_cleanup_removes_expired_entries() { + use std::time::Duration; + + let purgatory = Purgatory::new(); + let keys = Keys::generate(); + + // Create events + let state_event = EventBuilder::text_note("state event") + .sign_with_keys(&keys) + .unwrap(); + let pr_event = EventBuilder::text_note("pr event") + .sign_with_keys(&keys) + .unwrap(); + + // Add entries to purgatory + purgatory.add_state( + state_event.clone(), + "test-repo".to_string(), + keys.public_key(), + ); + purgatory.add_pr(pr_event, "pr-123".to_string(), "commit-abc".to_string()); + purgatory.add_pr_placeholder("pr-456".to_string(), "commit-def".to_string()); + + // Verify entries are there + let (state_count, pr_count) = purgatory.count(); + assert_eq!(state_count, 1); + assert_eq!(pr_count, 2); + + // Manually expire entries by modifying their expiry time + // (This is a bit hacky but needed for testing without waiting 30 minutes) + if let Some(mut entries) = purgatory.state_events.get_mut("test-repo") { + for entry in entries.iter_mut() { + entry.expires_at = Instant::now() - Duration::from_secs(1); + } + } + + // Expire PR events + for mut entry in purgatory.pr_events.iter_mut() { + entry.value_mut().expires_at = Instant::now() - Duration::from_secs(1); + } + + // Run cleanup + let (state_removed, pr_removed) = purgatory.cleanup(); + + // Verify counts + assert_eq!(state_removed, 1); + assert_eq!(pr_removed, 2); + + // Verify entries are gone + let (state_count, pr_count) = purgatory.count(); + assert_eq!(state_count, 0); + assert_eq!(pr_count, 0); +} + +#[test] +fn test_cleanup_preserves_non_expired_entries() { + let purgatory = Purgatory::new(); + let keys = Keys::generate(); + + let state_event = EventBuilder::text_note("state event") + .sign_with_keys(&keys) + .unwrap(); + let pr_event = EventBuilder::text_note("pr event") + .sign_with_keys(&keys) + .unwrap(); + + // Add fresh entries + purgatory.add_state(state_event, "test-repo".to_string(), keys.public_key()); + purgatory.add_pr(pr_event, "pr-123".to_string(), "commit-abc".to_string()); + + // Run cleanup + let (state_removed, pr_removed) = purgatory.cleanup(); + + // Nothing should be removed + assert_eq!(state_removed, 0); + assert_eq!(pr_removed, 0); + + // Verify entries are still there + let (state_count, pr_count) = purgatory.count(); + assert_eq!(state_count, 1); + assert_eq!(pr_count, 1); +} + +#[test] +fn test_cleanup_mixed_expired_and_fresh() { + use std::time::Duration; + + let purgatory = Purgatory::new(); + let keys = Keys::generate(); + + // Add multiple state events for same repo + let event1 = EventBuilder::text_note("event1") + .sign_with_keys(&keys) + .unwrap(); + let event2 = EventBuilder::text_note("event2") + .sign_with_keys(&keys) + .unwrap(); + + purgatory.add_state(event1, "test-repo".to_string(), keys.public_key()); + purgatory.add_state(event2, "test-repo".to_string(), keys.public_key()); + + // Expire only the first one + if let Some(mut entries) = purgatory.state_events.get_mut("test-repo") { + if let Some(entry) = entries.get_mut(0) { + entry.expires_at = Instant::now() - Duration::from_secs(1); + } + } + + // Add PR events + let pr1 = EventBuilder::text_note("pr1") + .sign_with_keys(&keys) + .unwrap(); + let pr2 = EventBuilder::text_note("pr2") + .sign_with_keys(&keys) + .unwrap(); + + purgatory.add_pr(pr1, "pr-1".to_string(), "commit-1".to_string()); + purgatory.add_pr(pr2, "pr-2".to_string(), "commit-2".to_string()); + + // Expire only first PR + if let Some(mut entry) = purgatory.pr_events.get_mut("pr-1") { + entry.expires_at = Instant::now() - Duration::from_secs(1); + } + + // Run cleanup + let (state_removed, pr_removed) = purgatory.cleanup(); + + // One of each should be removed + assert_eq!(state_removed, 1); + assert_eq!(pr_removed, 1); + + // Verify remaining counts + let (state_count, pr_count) = purgatory.count(); + assert_eq!(state_count, 1); // One state event remains + assert_eq!(pr_count, 1); // One PR event remains +} + +#[test] +fn test_remove_expired_legacy_method() { + use std::time::Duration; + + let purgatory = Purgatory::new(); + let keys = Keys::generate(); + + let state_event = EventBuilder::text_note("state") + .sign_with_keys(&keys) + .unwrap(); + let pr_event = EventBuilder::text_note("pr").sign_with_keys(&keys).unwrap(); + + purgatory.add_state(state_event, "repo".to_string(), keys.public_key()); + purgatory.add_pr(pr_event, "pr-id".to_string(), "commit".to_string()); + + // Expire both + if let Some(mut entries) = purgatory.state_events.get_mut("repo") { + for entry in entries.iter_mut() { + entry.expires_at = Instant::now() - Duration::from_secs(1); + } + } + for mut entry in purgatory.pr_events.iter_mut() { + entry.value_mut().expires_at = Instant::now() - Duration::from_secs(1); + } + + // Test legacy method returns total + #[allow(deprecated)] + let total = purgatory.remove_expired(); + assert_eq!(total, 2); // 1 state + 1 PR +} diff --git a/src/purgatory/types.rs b/src/purgatory/types.rs new file mode 100644 index 0000000..9c47616 --- /dev/null +++ b/src/purgatory/types.rs @@ -0,0 +1,99 @@ +//! Core data types for the purgatory system. +//! +//! Purgatory is an in-memory holding area for nostr events that depend on git data +//! that hasn't arrived yet, and vice versa. This solves the "which arrives first?" +//! problem where either the nostr event or git push can arrive first. + +use nostr_sdk::prelude::*; +use std::time::Instant; + +/// A reference name and its target object. +/// +/// Used to identify specific git refs (branches, tags) that a state event +/// is waiting for. The combination of ref_name and object_sha uniquely +/// identifies a git reference at a specific point in time. +#[derive(Debug, Clone, Hash, Eq, PartialEq)] +pub struct RefPair { + /// Full ref name, e.g., "refs/heads/main" or "refs/tags/v1.0" + pub ref_name: String, + /// Target object SHA (commit or annotated tag) + pub object_sha: String, +} + +/// A git reference update from receive-pack protocol. +/// +/// Represents the full update information: what the ref was, what it will be, +/// and which ref is being updated. This allows detection of: +/// - Additions: old_oid is all zeros +/// - Deletions: new_oid is all zeros +/// - Modifications: both are non-zero but different +#[derive(Debug, Clone, Hash, Eq, PartialEq)] +pub struct RefUpdate { + /// Old object SHA (40 zeros = ref is being created) + pub old_oid: String, + /// New object SHA (40 zeros = ref is being deleted) + pub new_oid: String, + /// Full ref name, e.g., "refs/heads/main" or "refs/tags/v1.0" + pub ref_name: String, +} + +impl RefUpdate { + /// Check if this update is creating a new ref + pub fn is_creation(&self) -> bool { + self.old_oid == "0000000000000000000000000000000000000000" + } + + /// Check if this update is deleting a ref + pub fn is_deletion(&self) -> bool { + self.new_oid == "0000000000000000000000000000000000000000" + } + + /// Check if this update is modifying an existing ref + pub fn is_modification(&self) -> bool { + !self.is_creation() && !self.is_deletion() + } +} + +/// Entry for a state event (kind 30618) waiting in purgatory. +/// +/// State events declare the current state of a repository but may arrive +/// before the corresponding git data has been pushed. This entry holds +/// the event and associated metadata until the git data arrives. +#[derive(Debug, Clone)] +pub struct StatePurgatoryEntry { + /// The nostr state event (kind 30618) awaiting git data + pub event: Event, + + /// The repository identifier from the event's 'd' tag + pub identifier: String, + + /// Event author pubkey + pub author: PublicKey, + + /// When this entry was added to purgatory + pub created_at: Instant, + + /// Expiry deadline (30 min from creation, may be extended) + pub expires_at: Instant, +} + +/// Entry for a PR event (kind 1617/1618) or placeholder waiting in purgatory. +/// +/// PR events reference specific commits but may arrive before the git push +/// containing those commits. Alternatively, a git push may arrive first, +/// creating a placeholder entry waiting for the corresponding PR event. +#[derive(Debug, Clone)] +pub struct PrPurgatoryEntry { + /// The nostr PR event, if received (None = git data arrived first) + pub event: Option, + + /// The expected commit SHA from 'c' tag (if event exists) + /// or the actual commit pushed (if git arrived first) + pub commit: String, + + /// When this entry was added to purgatory + pub created_at: Instant, + + /// Expiry deadline (30 min from creation, may be extended) + pub expires_at: Instant, +} -- cgit v1.2.3