upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src/nostr/policy/pr_event.rs
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-12-31 09:18:21 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-12-31 10:49:09 +0000
commit768fe91caa676e4501aa26e14e01ca47f3ea4ca1 (patch)
treee697becb6b2253909d399073f5c2bd2d571fcf5e /src/nostr/policy/pr_event.rs
parent3d6901831904141166d9ed8f47813c45cba109b6 (diff)
purgatory: fix pr event recieve code
Diffstat (limited to 'src/nostr/policy/pr_event.rs')
-rw-r--r--src/nostr/policy/pr_event.rs396
1 files changed, 169 insertions, 227 deletions
diff --git a/src/nostr/policy/pr_event.rs b/src/nostr/policy/pr_event.rs
index c7602b0..ff3bade 100644
--- a/src/nostr/policy/pr_event.rs
+++ b/src/nostr/policy/pr_event.rs
@@ -2,11 +2,12 @@
2/// 2///
3/// Handles validation of NIP-34 PR events (kind 1618) and PR Update events (kind 1619) 3/// Handles validation of NIP-34 PR events (kind 1618) and PR Update events (kind 1619)
4/// according to GRASP-01 specification. 4/// according to GRASP-01 specification.
5use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag}; 5use anyhow::{bail, Result};
6use nostr_relay_builder::prelude::Event;
6 7
7use super::PolicyContext; 8use super::PolicyContext;
8use crate::git; 9use crate::git;
9use crate::nostr::events::{RepositoryAnnouncement, KIND_REPOSITORY_ANNOUNCEMENT}; 10use crate::git::authorization::{collect_authorized_maintainers, fetch_repository_data};
10 11
11/// Policy for validating PR and PR Update events 12/// Policy for validating PR and PR Update events
12#[derive(Clone)] 13#[derive(Clone)]
@@ -21,15 +22,18 @@ impl PrEventPolicy {
21 22
22 /// Check if git data exists for a PR event 23 /// Check if git data exists for a PR event
23 /// 24 ///
24 /// This checks: 25 /// This unified method checks for git data existence and handles:
25 /// 1. If a placeholder exists (git-data-first scenario) 26 /// 1. Placeholder validation (git-data-first scenario)
26 /// 2. If the commit exists in any relevant repository 27 /// 2. Commit existence in referenced repositories
28 /// 3. Deletion of incorrect refs/nostr/<event-id> refs
29 /// 4. Deletion of incorrect placeholders
30 /// 5. Copying git data to all referenced repositories when found
27 /// 31 ///
28 /// # Returns 32 /// # Returns
29 /// - `Ok(true)` if git data ready (either placeholder found or commit exists) 33 /// - `Ok(true)` if git data ready (commit exists and is synced to all repos)
30 /// - `Ok(false)` if git data missing (should add to purgatory) 34 /// - `Ok(false)` if git data missing (should add to purgatory)
31 /// - `Err(msg)` on errors 35 /// - `Err(msg)` on errors
32 pub async fn check_git_data_exists(&self, event: &Event) -> Result<bool, String> { 36 pub async fn git_data_check(&self, event: &Event) -> Result<bool> {
33 let event_id = event.id.to_hex(); 37 let event_id = event.id.to_hex();
34 38
35 // Extract the `c` tag (commit hash) from the PR event 39 // Extract the `c` tag (commit hash) from the PR event
@@ -45,7 +49,7 @@ impl PrEventPolicy {
45 let commit = match commit { 49 let commit = match commit {
46 Some(c) => c, 50 Some(c) => c,
47 None => { 51 None => {
48 return Err(format!("PR event {} has no 'c' tag", event_id)); 52 bail!(format!("PR event {} has no 'c' tag", event_id));
49 } 53 }
50 }; 54 };
51 55
@@ -60,7 +64,7 @@ impl PrEventPolicy {
60 ); 64 );
61 // Remove placeholder - event processing will continue normally 65 // Remove placeholder - event processing will continue normally
62 self.ctx.purgatory.remove_pr(&event_id); 66 self.ctx.purgatory.remove_pr(&event_id);
63 return Ok(true); 67 // Continue to validate and sync refs across all repos
64 } else { 68 } else {
65 // Placeholder has different commit - incoming event supersedes 69 // Placeholder has different commit - incoming event supersedes
66 tracing::info!( 70 tracing::info!(
@@ -69,148 +73,124 @@ impl PrEventPolicy {
69 commit, 73 commit,
70 placeholder_commit 74 placeholder_commit
71 ); 75 );
72 // Remove placeholder with old commit data 76 // Remove incorrect placeholder
73 self.ctx.purgatory.remove_pr(&event_id); 77 self.ctx.purgatory.remove_pr(&event_id);
74 // TODO: Also remove git data (refs/nostr/<event-id>) - Phase 5 78 // Delete incorrect git data (refs/nostr/<event-id>) from all repos
75 // Fall through to check if new commit exists 79 // This will be handled below when we validate refs
76 } 80 }
77 } 81 }
78 82
79 // Check if commit exists in any repository referenced by this PR 83 let repo_paths = self.find_relevant_repo_paths(event).await?;
80 // Extract ALL `a` tags (repository references) from the PR event
81 let repo_refs: Vec<String> = event
82 .tags
83 .iter()
84 .filter_map(|tag| {
85 let tag_vec = tag.clone().to_vec();
86 if tag_vec.len() >= 2 && tag_vec[0] == "a" && tag_vec[1].starts_with("30617:") {
87 Some(tag_vec[1].clone())
88 } else {
89 None
90 }
91 })
92 .collect();
93 84
94 if repo_refs.is_empty() { 85 if repo_paths.is_empty() {
95 // No repo references - cannot check git data 86 tracing::debug!("No repository paths found for PR event {}", event_id);
96 // This is unusual but let it through (other validation will catch issues) 87 return Ok(false);
97 return Ok(true);
98 } 88 }
99 89
100 // Check each repository to see if commit exists 90 // delete incorrect refs/nostr/<event-id>
101 for repo_ref in repo_refs { 91 for repo_path in &repo_paths {
102 // Parse the repo reference: 30617:<pubkey>:<identifier> 92 // First, validate/delete any incorrect refs/nostr/<event-id>
103 let parts: Vec<&str> = repo_ref.split(':').collect(); 93 match git::validate_nostr_ref(repo_path, &event_id, &commit) {
104 if parts.len() < 3 { 94 Ok(true) => {
105 continue; 95 tracing::info!(
106 } 96 "Deleted mismatched refs/nostr/{} in {}",
107 97 event_id,
108 let repo_pubkey = match PublicKey::from_hex(parts[1]) { 98 repo_path.display()
109 Ok(pk) => pk, 99 );
110 Err(_) => continue, 100 }
111 }; 101 Ok(false) => {}
112 let identifier = parts[2];
113
114 // Look up repository announcement to get the npub for path
115 let filter = Filter::new()
116 .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT))
117 .author(repo_pubkey)
118 .custom_tag(
119 SingleLetterTag::lowercase(Alphabet::D),
120 identifier.to_string(),
121 );
122
123 let announcements: Vec<Event> = match self.ctx.database.query(filter).await {
124 Ok(events) => events.into_iter().collect(),
125 Err(e) => { 102 Err(e) => {
126 tracing::warn!( 103 tracing::warn!(
127 "Failed to query for repository announcement for PR {}: {}", 104 "Failed to validate refs/nostr/{} in {}: {}",
128 event_id, 105 event_id,
106 repo_path.display(),
129 e 107 e
130 ); 108 );
131 continue;
132 } 109 }
133 }; 110 }
111 }
134 112
135 if announcements.is_empty() { 113 // find location of correct git data (if exists)
136 continue; 114 let mut source_repo: Option<std::path::PathBuf> = None;
115 for repo_path in &repo_paths {
116 // Check if commit exists in this repository
117 if git::commit_exists(repo_path, &commit) {
118 source_repo = Some(repo_path.clone());
119 tracing::debug!(
120 "Found commit {} in repository {}",
121 commit,
122 repo_path.display()
123 );
124 break;
137 } 125 }
126 }
138 127
139 // Check each matching announcement 128 // Copy commit to all other referenced repositories
140 for announcement_event in announcements { 129 if let Some(source_repo) = source_repo {
141 let announcement = match RepositoryAnnouncement::from_event(announcement_event) { 130 for repo_path in &repo_paths {
142 Ok(a) => a, 131 if repo_path == &source_repo {
143 Err(_) => continue, 132 // Skip source repo
144 }; 133 continue;
134 }
145 135
146 // Build repository path 136 // Check if repository exists
147 let repo_path = self.ctx.git_data_path.join(announcement.repo_path()); 137 if !repo_path.exists() {
138 tracing::debug!(
139 "Repository {} does not exist, skipping sync",
140 repo_path.display()
141 );
142 continue;
143 }
148 144
149 // Check if commit exists 145 // Check if commit already exists
150 if git::commit_exists(&repo_path, &commit) { 146 if git::commit_exists(repo_path, &commit) {
151 tracing::debug!( 147 tracing::debug!(
152 "Found commit {} for PR event {} in repository {}", 148 "Commit {} already exists in {}, skipping sync",
153 commit, 149 commit,
154 event_id,
155 repo_path.display() 150 repo_path.display()
156 ); 151 );
157 return Ok(true); 152 continue;
158 } 153 }
159 }
160 }
161
162 // No git data found - should add to purgatory
163 tracing::debug!(
164 "No git data found for PR event {} with commit {}",
165 event_id,
166 commit
167 );
168 Ok(false)
169 }
170
171 /// Validate refs/nostr/<event-id> ref against a PR or PR Update event's `c` tag
172 ///
173 /// When a PR event (kind 1618) or PR Update event (kind 1619) is received,
174 /// this checks if a corresponding refs/nostr/<event-id> ref exists in the
175 /// repository and validates that it points to the correct commit (from the
176 /// `c` tag). If the ref exists but points to a different commit, the ref is
177 /// deleted.
178 ///
179 /// PR and PR Update events can have multiple `a` tags to update multiple
180 /// repositories simultaneously.
181 ///
182 /// This is part of GRASP-01 compliance: ensuring refs/nostr refs are consistent
183 /// with their corresponding events.
184 ///
185 /// # Returns
186 /// Ok(Some(n)) if n refs were deleted, Ok(None) if no action taken, Err on failure
187 pub async fn validate_nostr_ref(&self, event: &Event) -> Result<Option<usize>, String> {
188 let event_id = event.id.to_hex();
189 154
190 // Extract the `c` tag (commit hash) from the PR event 155 // Fetch commit from source repo to target repo
191 let expected_commit = event.tags.iter().find_map(|tag| { 156 tracing::info!(
192 let tag_vec = tag.clone().to_vec(); 157 "Syncing commit {} from {} to {}",
193 if tag_vec.len() >= 2 && tag_vec[0] == "c" { 158 commit,
194 Some(tag_vec[1].clone()) 159 source_repo.display(),
195 } else { 160 repo_path.display()
196 None
197 }
198 });
199
200 let expected_commit = match expected_commit {
201 Some(c) => c,
202 None => {
203 tracing::debug!(
204 "PR event {} has no 'c' tag, skipping ref validation",
205 event_id
206 ); 161 );
207 return Ok(None); 162
163 match self.copy_commit(&source_repo, repo_path, &commit).await {
164 Ok(()) => {
165 tracing::info!(
166 "Successfully synced commit {} to {}",
167 commit,
168 repo_path.display()
169 );
170 }
171 Err(e) => {
172 tracing::warn!(
173 "Failed to sync commit {} to {}: {}",
174 commit,
175 repo_path.display(),
176 e
177 );
178 }
179 }
208 } 180 }
209 }; 181 Ok(true)
182 } else {
183 tracing::debug!(
184 "No git data found for PR event {} with commit {}",
185 event_id,
186 commit
187 );
188 Ok(false)
189 }
190 }
210 191
192 async fn find_relevant_repo_paths(&self, event: &Event) -> Result<Vec<std::path::PathBuf>> {
211 // Extract ALL `a` tags (repository references) from the PR event 193 // Extract ALL `a` tags (repository references) from the PR event
212 // PR events can reference multiple repositories
213 // Format: 30617:<pubkey>:<identifier>
214 let repo_refs: Vec<String> = event 194 let repo_refs: Vec<String> = event
215 .tags 195 .tags
216 .iter() 196 .iter()
@@ -225,123 +205,85 @@ impl PrEventPolicy {
225 .collect(); 205 .collect();
226 206
227 if repo_refs.is_empty() { 207 if repo_refs.is_empty() {
228 tracing::debug!( 208 return Ok(Vec::new());
229 "PR event {} has no repo 'a' tags, skipping ref validation",
230 event_id
231 );
232 return Ok(None);
233 } 209 }
234 210
235 let mut deleted_count = 0; 211 // 1. Find identifier from first a tag starting with "30617:"
212 let parts: Vec<&str> = repo_refs[0].split(':').collect();
213 if parts.len() < 3 {
214 return Err(anyhow::anyhow!("Invalid repository reference format"));
215 }
216 let identifier = parts[2];
217
218 // 2. Fetch repo data
219 let db_repo_data = fetch_repository_data(&self.ctx.database, identifier).await?;
236 220
237 // Process each repository reference 221 // 3. Extract list of maintainers from "a 30617:<maintainer>:<identifier>" tags
238 for repo_ref in repo_refs { 222 let mut maintainer_pubkeys = std::collections::HashSet::new();
239 // Parse the repo reference: 30617:<pubkey>:<identifier> 223 for repo_ref in &repo_refs {
240 let parts: Vec<&str> = repo_ref.split(':').collect(); 224 let parts: Vec<&str> = repo_ref.split(':').collect();
241 if parts.len() < 3 { 225 if parts.len() >= 2 {
242 tracing::debug!( 226 maintainer_pubkeys.insert(parts[1].to_string());
243 "PR event {} has invalid 'a' tag format: {}",
244 event_id,
245 repo_ref
246 );
247 continue;
248 } 227 }
228 }
249 229
250 let repo_pubkey = match PublicKey::from_hex(parts[1]) { 230 // 4. Identify owner repos that list any of the maintainers using this function
251 Ok(pk) => pk, 231 let by_owner = collect_authorized_maintainers(&db_repo_data.announcements);
252 Err(_) => {
253 tracing::debug!(
254 "PR event {} has invalid pubkey in 'a' tag: {}",
255 event_id,
256 parts[1]
257 );
258 continue;
259 }
260 };
261 let identifier = parts[2];
262
263 // Look up repository announcement to get the npub for path
264 let filter = Filter::new()
265 .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT))
266 .author(repo_pubkey)
267 .custom_tag(
268 SingleLetterTag::lowercase(Alphabet::D),
269 identifier.to_string(),
270 );
271
272 let announcements: Vec<Event> = match self.ctx.database.query(filter).await {
273 Ok(events) => events.into_iter().collect(),
274 Err(e) => {
275 tracing::warn!(
276 "Failed to query for repository announcement for PR {}: {}",
277 event_id,
278 e
279 );
280 continue;
281 }
282 };
283
284 if announcements.is_empty() {
285 tracing::debug!(
286 "No repository announcement found for PR event {} (repo {}:{})",
287 event_id,
288 repo_pubkey.to_hex(),
289 identifier
290 );
291 continue;
292 }
293 232
294 // Process each matching announcement (there could be multiple) 233 // 5. Return the repo_path for each owner whose authorized maintainers include any of our maintainers
295 for announcement_event in announcements { 234 let mut repo_paths = Vec::new();
296 let announcement = match RepositoryAnnouncement::from_event(announcement_event) { 235 for announcement in &db_repo_data.announcements {
297 Ok(a) => a, 236 let owner_pubkey = announcement.event.pubkey.to_hex();
298 Err(e) => {
299 tracing::warn!(
300 "Failed to parse announcement for PR {} validation: {}",
301 event_id,
302 e
303 );
304 continue;
305 }
306 };
307 237
308 // Build repository path 238 // Check if this owner's authorized maintainers overlap with our maintainer list
309 let repo_path = self.ctx.git_data_path.join(announcement.repo_path()); 239 if let Some(authorized_maintainers) = by_owner.get(&owner_pubkey) {
240 let has_overlap = authorized_maintainers
241 .iter()
242 .any(|m| maintainer_pubkeys.contains(m));
310 243
311 // Validate the ref 244 if has_overlap {
312 match git::validate_nostr_ref(&repo_path, &event_id, &expected_commit) { 245 let repo_path = self.ctx.git_data_path.join(announcement.repo_path());
313 Ok(true) => { 246 repo_paths.push(repo_path);
314 tracing::info!(
315 "Deleted mismatched refs/nostr/{} in {} (expected commit {})",
316 event_id,
317 repo_path.display(),
318 expected_commit
319 );
320 deleted_count += 1;
321 }
322 Ok(false) => {
323 tracing::debug!(
324 "refs/nostr/{} in {} is valid or doesn't exist",
325 event_id,
326 repo_path.display()
327 );
328 }
329 Err(e) => {
330 tracing::warn!(
331 "Failed to validate refs/nostr/{} in {}: {}",
332 event_id,
333 repo_path.display(),
334 e
335 );
336 }
337 } 247 }
338 } 248 }
339 } 249 }
340 250
341 if deleted_count > 0 { 251 Ok(repo_paths)
342 Ok(Some(deleted_count)) 252 }
343 } else { 253 /// Copy a commit from source repository to target repository
344 Ok(None) 254 ///
255 /// Uses `git fetch` to copy a specific commit between local repositories.
256 ///
257 /// # Arguments
258 /// * `source_repo` - Path to repository containing the commit
259 /// * `target_repo` - Path to repository to receive the commit
260 /// * `commit` - Commit hash to copy
261 ///
262 /// # Returns
263 /// Ok(()) on success, Err with error message on failure
264 async fn copy_commit(
265 &self,
266 source_repo: &std::path::Path,
267 target_repo: &std::path::Path,
268 commit: &str,
269 ) -> Result<(), String> {
270 use std::process::Command;
271
272 let output = Command::new("git")
273 .args([
274 "fetch",
275 source_repo.to_str().ok_or("Invalid source path")?,
276 commit,
277 ])
278 .current_dir(target_repo)
279 .output()
280 .map_err(|e| format!("Failed to execute git fetch: {}", e))?;
281
282 if !output.status.success() {
283 let stderr = String::from_utf8_lossy(&output.stderr);
284 return Err(format!("git fetch failed: {}", stderr));
345 } 285 }
286
287 Ok(())
346 } 288 }
347} 289}