From 819866330c7e2f535a155d1d7efaf2e12dc15dc2 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 4 Dec 2025 15:42:00 +0000 Subject: refactor: split Nip34WritePolicy into focused sub-policies Split the ~900 line Nip34WritePolicy into focused sub-policies for improved testability and maintainability: - AnnouncementPolicy - Repository announcement validation - StatePolicy - State event validation + ref alignment - PrEventPolicy - PR/PR Update validation - RelatedEventPolicy - Forward/backward reference checking The main Nip34WritePolicy now delegates to these sub-policies via a shared PolicyContext that provides domain, database, and git_data_path. Also updates: - README.md: Accurate project structure reflecting actual implementation - docs/learnings: Marks this technical debt item as complete --- src/nostr/policy/pr_event.rs | 198 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 src/nostr/policy/pr_event.rs (limited to 'src/nostr/policy/pr_event.rs') diff --git a/src/nostr/policy/pr_event.rs b/src/nostr/policy/pr_event.rs new file mode 100644 index 0000000..fee9a2a --- /dev/null +++ b/src/nostr/policy/pr_event.rs @@ -0,0 +1,198 @@ +/// 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) + } + } +} \ No newline at end of file -- cgit v1.2.3