upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/git/mod.rs4
-rw-r--r--src/nostr/builder.rs71
-rw-r--r--src/nostr/policy/pr_event.rs396
-rw-r--r--src/nostr/policy/state.rs187
4 files changed, 223 insertions, 435 deletions
diff --git a/src/git/mod.rs b/src/git/mod.rs
index 1847c8c..d34f98b 100644
--- a/src/git/mod.rs
+++ b/src/git/mod.rs
@@ -94,10 +94,6 @@ pub fn oid_exists(repo_path: &Path, oid: &str) -> bool {
94 } 94 }
95} 95}
96 96
97pub fn is_valid_oid(oid: &str) -> bool {
98 oid.len() >= 5 && oid.len() <= 40 && oid.chars().all(|c| c.is_digit(16))
99}
100
101/// Set the repository HEAD to point to a branch 97/// Set the repository HEAD to point to a branch
102/// 98///
103/// This updates the HEAD symbolic ref to point to the specified branch. 99/// This updates the HEAD symbolic ref to point to the specified branch.
diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs
index 37fa025..3d7a0d8 100644
--- a/src/nostr/builder.rs
+++ b/src/nostr/builder.rs
@@ -168,8 +168,54 @@ impl Nip34WritePolicy {
168 async fn handle_pr_event(&self, event: &Event) -> WritePolicyResult { 168 async fn handle_pr_event(&self, event: &Event) -> WritePolicyResult {
169 let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex()); 169 let event_id_str = event.id.to_bech32().unwrap_or_else(|_| event.id.to_hex());
170 170
171 // Check if git data exists (checks placeholders and commit existence) 171 // duplicate check in purgatory
172 match self.pr_event_policy.check_git_data_exists(event).await { 172 let in_purgatory = self
173 .ctx
174 .purgatory
175 .find_pr(&event.id.to_hex())
176 .is_some_and(|e| e.event.is_some());
177 if in_purgatory {
178 tracing::debug!(
179 "processed PR event duplicate (already in purgatory): {}",
180 event.id,
181 );
182 return WritePolicyResult::Reject {
183 status: true, // Client sees OK
184 message: "duplicate: in purgatory".into(),
185 };
186 }
187
188 // duplicate check in db
189 match &self.ctx.database.check_id(&event.id).await {
190 Ok(DatabaseEventStatus::Saved) => {
191 return WritePolicyResult::Reject {
192 status: true, // Client sees OK
193 message: "duplicate".into(),
194 };
195 }
196 Ok(DatabaseEventStatus::Deleted) => {
197 return WritePolicyResult::Reject {
198 status: false,
199 message: "invalid: accepted deletion request for this event".into(),
200 };
201 }
202 Err(e) => {
203 return WritePolicyResult::Reject {
204 status: false,
205 message: format!("error: internal error: {e}").into(),
206 };
207 }
208 _ => {} // continue
209 }
210
211 // Reject PRs unrelated to stored repositories / events
212 match self.handle_related_event(event, "PR").await {
213 WritePolicyResult::Accept => {} // continue
214 rejected => return rejected,
215 }
216
217 // Check if git data exists (delete any incorrect commits at refs/nostr/<event-id>, copies correct data to relivant repositories)
218 match self.pr_event_policy.git_data_check(event).await {
173 Ok(false) => { 219 Ok(false) => {
174 // No git data exists - add to purgatory 220 // No git data exists - add to purgatory
175 let commit = event 221 let commit = event
@@ -196,18 +242,19 @@ impl Nip34WritePolicy {
196 .purgatory 242 .purgatory
197 .add_pr(event.clone(), event.id.to_hex(), commit.clone()); 243 .add_pr(event.clone(), event.id.to_hex(), commit.clone());
198 244
199 return WritePolicyResult::Reject { 245 WritePolicyResult::Reject {
200 status: true, // Client sees OK 246 status: true, // Client sees OK
201 message: format!( 247 message: format!(
202 "purgatory: PR event stored, waiting for git push with commit {}", 248 "purgatory: PR event stored, waiting for git push with commit {}",
203 commit 249 commit
204 ) 250 )
205 .into(), 251 .into(),
206 }; 252 }
207 } 253 }
208 Ok(true) => { 254 Ok(true) => {
209 // Git data exists - proceed with normal validation 255 // Git data exists - proceed with normal validation
210 tracing::debug!("Git data exists for PR event {}", event_id_str); 256 tracing::debug!("Git data exists for PR event {}", event_id_str);
257 WritePolicyResult::Accept
211 } 258 }
212 Err(e) => { 259 Err(e) => {
213 // Error checking git data - reject event 260 // Error checking git data - reject event
@@ -216,23 +263,9 @@ impl Nip34WritePolicy {
216 event_id_str, 263 event_id_str,
217 e 264 e
218 ); 265 );
219 return WritePolicyResult::reject(format!("Failed to check git data: {}", e)); 266 WritePolicyResult::reject(format!("Failed to check git data: {}", e))
220 } 267 }
221 } 268 }
222
223 // Validate refs/nostr refs for this PR event
224 // This deletes any refs/nostr/<event-id> that points to wrong commit
225 if let Err(e) = self.pr_event_policy.validate_nostr_ref(event).await {
226 tracing::warn!(
227 "Failed to validate refs/nostr for PR event {}: {}",
228 event_id_str,
229 e
230 );
231 // Don't reject - just log the error and proceed with normal validation
232 }
233
234 // Continue with reference checking (same as related events)
235 self.handle_related_event(event, "PR").await
236 } 269 }
237 270
238 /// Handle events that must reference accepted repositories or events 271 /// Handle events that must reference accepted repositories or events
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}
diff --git a/src/nostr/policy/state.rs b/src/nostr/policy/state.rs
index 13f2549..1203890 100644
--- a/src/nostr/policy/state.rs
+++ b/src/nostr/policy/state.rs
@@ -6,15 +6,12 @@ use nostr_relay_builder::builder::WritePolicyResult;
6/// 6///
7/// Handles validation of NIP-34 repository state events (kind 30618) 7/// Handles validation of NIP-34 repository state events (kind 30618)
8/// and aligns git refs with authorized state according to GRASP-01. 8/// and aligns git refs with authorized state according to GRASP-01.
9use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag}; 9use nostr_relay_builder::prelude::Event;
10 10
11use super::PolicyContext; 11use super::PolicyContext;
12use crate::git::authorization::{collect_authorized_maintainers, fetch_repository_data}; 12use crate::git::authorization::{collect_authorized_maintainers, fetch_repository_data};
13use crate::git::{self}; 13use crate::git::{self};
14use crate::nostr::events::{ 14use crate::nostr::events::{validate_state, RepositoryAnnouncement, RepositoryState};
15 validate_state, RepositoryAnnouncement, RepositoryState, KIND_REPOSITORY_ANNOUNCEMENT,
16 KIND_REPOSITORY_STATE,
17};
18 15
19/// Result of aligning a repository with authorized state 16/// Result of aligning a repository with authorized state
20#[derive(Debug, Default)] 17#[derive(Debug, Default)]
@@ -168,186 +165,6 @@ impl StatePolicy {
168 } 165 }
169 } 166 }
170 167
171 /// Check if any git repositories exist for the given identifier
172 ///
173 /// Scans the git_data_path for any directories matching the pattern:
174 /// `<any-npub>/<identifier>.git`
175 ///
176 /// This is used to distinguish "no git data yet" from "not authorized".
177 fn has_git_data_for_identifier(&self, identifier: &str) -> bool {
178 let git_data_path = &self.ctx.git_data_path;
179
180 // Check if git_data_path exists
181 if !git_data_path.exists() {
182 return false;
183 }
184
185 // Scan for any npub directories
186 let read_dir = match std::fs::read_dir(git_data_path) {
187 Ok(dir) => dir,
188 Err(_) => return false,
189 };
190
191 for entry in read_dir.flatten() {
192 if let Ok(file_type) = entry.file_type() {
193 if file_type.is_dir() {
194 // Check if <npub>/<identifier>.git exists
195 let repo_path = entry.path().join(format!("{}.git", identifier));
196 if repo_path.exists() {
197 return true;
198 }
199 }
200 }
201 }
202
203 false
204 }
205
206 /// Check if this state event is the latest for its identifier among authorized authors
207 ///
208 /// A state is considered "latest" if no other state event in the database
209 /// from an authorized author has a newer timestamp.
210 async fn is_latest_state_for_identifier(
211 &self,
212 state: &RepositoryState,
213 authorized_pubkeys: &[PublicKey],
214 ) -> Result<bool, String> {
215 let filter = Filter::new()
216 .kind(Kind::from(KIND_REPOSITORY_STATE))
217 .custom_tag(
218 SingleLetterTag::lowercase(Alphabet::D),
219 state.identifier.clone(),
220 );
221
222 match self.ctx.database.query(filter).await {
223 Ok(events) => {
224 for event in events {
225 // Skip comparing to self (same event ID)
226 if event.id == state.event.id {
227 continue;
228 }
229 // Only consider events from authorized authors for this announcement
230 if !authorized_pubkeys.contains(&event.pubkey) {
231 continue;
232 }
233 // If any existing event from an authorized author is newer, this is not the latest
234 if event.created_at > state.event.created_at {
235 tracing::debug!(
236 "State {} is not latest: found newer state {} from {} (ts {} > {})",
237 state.event.id.to_hex(),
238 event.id.to_hex(),
239 event.pubkey.to_hex(),
240 event.created_at.as_secs(),
241 state.event.created_at.as_secs()
242 );
243 return Ok(false);
244 }
245 }
246 Ok(true)
247 }
248 Err(e) => Err(format!("Database query failed: {}", e)),
249 }
250 }
251
252 /// Find all repository announcements where the given pubkey is authorized
253 async fn find_authorized_announcements(
254 &self,
255 identifier: &str,
256 state_author: &PublicKey,
257 ) -> Result<Vec<RepositoryAnnouncement>, String> {
258 let filter = Filter::new()
259 .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT))
260 .custom_tag(
261 SingleLetterTag::lowercase(Alphabet::D),
262 identifier.to_string(),
263 );
264
265 match self.ctx.database.query(filter).await {
266 Ok(events) => {
267 let mut authorized = Vec::new();
268 let state_author_hex = state_author.to_hex();
269
270 for event in events {
271 if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) {
272 // Check if state author is authorized for this announcement
273 let is_owner = event.pubkey == *state_author;
274 let is_maintainer = announcement.maintainers.contains(&state_author_hex);
275
276 if is_owner || is_maintainer {
277 tracing::debug!(
278 "Found authorized announcement for {}: owner={}, maintainer={}",
279 identifier,
280 if is_owner {
281 event.pubkey.to_hex()
282 } else {
283 "n/a".to_string()
284 },
285 is_maintainer
286 );
287 authorized.push(announcement);
288 }
289 }
290 }
291 Ok(authorized)
292 }
293 Err(e) => Err(format!("Database query failed: {}", e)),
294 }
295 }
296
297 /// Identify all owner repositories for which this state event is the latest authorized state
298 async fn identify_owner_repositories(
299 &self,
300 state: &RepositoryState,
301 ) -> Result<Vec<(RepositoryAnnouncement, std::path::PathBuf)>, String> {
302 // Find all announcements where state author is authorized
303 let announcements = self
304 .find_authorized_announcements(&state.identifier, &state.event.pubkey)
305 .await?;
306
307 if announcements.is_empty() {
308 tracing::debug!(
309 "No authorized announcements found for state {} by {}",
310 state.identifier,
311 state.event.pubkey.to_hex()
312 );
313 return Ok(Vec::new());
314 }
315
316 let mut owner_repos = Vec::new();
317
318 for announcement in announcements {
319 // Build the list of authorized pubkeys for this specific announcement
320 let mut authorized_pubkeys = vec![announcement.event.pubkey];
321 for maintainer_hex in &announcement.maintainers {
322 if let Ok(pk) = PublicKey::from_hex(maintainer_hex) {
323 authorized_pubkeys.push(pk);
324 }
325 }
326
327 // Check if this is the latest state event for THIS announcement's context
328 if !self
329 .is_latest_state_for_identifier(state, &authorized_pubkeys)
330 .await?
331 {
332 tracing::debug!(
333 "Skipping {} in {}'s repo - not the latest state event for this context",
334 state.identifier,
335 announcement.event.pubkey.to_hex()
336 );
337 continue;
338 }
339
340 // Build repository path: <git_data_path>/<owner_npub>/<identifier>.git
341 let repo_path = self
342 .ctx
343 .git_data_path
344 .join(announcement.repo_path().clone());
345 owner_repos.push((announcement, repo_path));
346 }
347
348 Ok(owner_repos)
349 }
350
351 /// Align a repository's refs with the authorized state 168 /// Align a repository's refs with the authorized state
352 /// 169 ///
353 /// This function: 170 /// This function: