/// PR Event Policy - PR/PR Update validation /// /// Handles validation of NIP-34 PR events (kind 1618) and PR Update events (kind 1619) /// according to GRASP-01 specification. use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag}; use super::PolicyContext; use crate::git; use crate::nostr::events::{RepositoryAnnouncement, KIND_REPOSITORY_ANNOUNCEMENT}; /// Policy for validating PR and PR Update events #[derive(Clone)] pub struct PrEventPolicy { ctx: PolicyContext, } impl PrEventPolicy { pub fn new(ctx: PolicyContext) -> Self { Self { ctx } } /// 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, /// this checks if a corresponding refs/nostr/ ref exists in the /// repository and validates that it points to the correct commit (from the /// `c` tag). If the ref exists but points to a different commit, the ref is /// deleted. /// /// PR and PR Update events can have multiple `a` tags to update multiple /// repositories simultaneously. /// /// This is part of GRASP-01 compliance: ensuring refs/nostr refs are consistent /// with their corresponding events. /// /// # Returns /// Ok(Some(n)) if n refs were deleted, Ok(None) if no action taken, Err on failure pub async fn validate_nostr_ref(&self, event: &Event) -> Result, String> { let event_id = event.id.to_hex(); // Extract the `c` tag (commit hash) from the PR event let expected_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 expected_commit = match expected_commit { Some(c) => c, None => { tracing::debug!( "PR event {} has no 'c' tag, skipping ref validation", event_id ); return Ok(None); } }; // Extract ALL `a` tags (repository references) from the PR event // PR events can reference multiple repositories // Format: 30617:: 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() { tracing::debug!( "PR event {} has no repo 'a' tags, skipping ref validation", event_id ); return Ok(None); } let mut deleted_count = 0; // Process each repository reference for repo_ref in repo_refs { // Parse the repo reference: 30617:: let parts: Vec<&str> = repo_ref.split(':').collect(); if parts.len() < 3 { tracing::debug!( "PR event {} has invalid 'a' tag format: {}", event_id, repo_ref ); continue; } let repo_pubkey = match PublicKey::from_hex(parts[1]) { Ok(pk) => pk, Err(_) => { tracing::debug!( "PR event {} has invalid pubkey in 'a' tag: {}", event_id, parts[1] ); 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() { tracing::debug!( "No repository announcement found for PR event {} (repo {}:{})", event_id, repo_pubkey.to_hex(), identifier ); continue; } // Process each matching announcement (there could be multiple) for announcement_event in announcements { let announcement = match RepositoryAnnouncement::from_event(announcement_event) { Ok(a) => a, Err(e) => { tracing::warn!( "Failed to parse announcement for PR {} validation: {}", event_id, e ); continue; } }; // Build repository path let repo_path = self.ctx.git_data_path.join(announcement.repo_path()); // Validate the ref match git::validate_nostr_ref(&repo_path, &event_id, &expected_commit) { Ok(true) => { tracing::info!( "Deleted mismatched refs/nostr/{} in {} (expected commit {})", event_id, repo_path.display(), expected_commit ); deleted_count += 1; } Ok(false) => { tracing::debug!( "refs/nostr/{} in {} is valid or doesn't exist", event_id, repo_path.display() ); } Err(e) => { tracing::warn!( "Failed to validate refs/nostr/{} in {}: {}", event_id, repo_path.display(), e ); } } } } if deleted_count > 0 { Ok(Some(deleted_count)) } else { Ok(None) } } }