1use crate::{
7 database::RecorderDatabase,
8 models::RecordedRequest,
9 sync::{DetectedChange, GitOpsConfig},
10 Result,
11};
12use mockforge_core::pr_generation::{
13 PRFileChange, PRFileChangeType, PRGenerator, PRProvider, PRRequest,
14};
15use std::path::PathBuf;
16use tracing::{debug, info, warn};
17
18pub struct GitOpsSyncHandler {
20 config: GitOpsConfig,
21 pr_generator: Option<PRGenerator>,
22 #[allow(dead_code)]
23 fixtures_dir: PathBuf,
24}
25
26impl GitOpsSyncHandler {
27 pub fn new(config: GitOpsConfig, fixtures_dir: PathBuf) -> Result<Self> {
29 let pr_generator = if config.enabled {
30 let token = config.token.as_ref().ok_or_else(|| {
31 crate::RecorderError::InvalidFilter("GitOps token not provided".to_string())
32 })?;
33
34 let provider = match config.pr_provider.to_lowercase().as_str() {
35 "gitlab" => PRProvider::GitLab,
36 _ => PRProvider::GitHub,
37 };
38
39 Some(match provider {
40 PRProvider::GitHub => PRGenerator::new_github(
41 config.repo_owner.clone(),
42 config.repo_name.clone(),
43 token.clone(),
44 config.base_branch.clone(),
45 ),
46 PRProvider::GitLab => PRGenerator::new_gitlab(
47 config.repo_owner.clone(),
48 config.repo_name.clone(),
49 token.clone(),
50 config.base_branch.clone(),
51 ),
52 })
53 } else {
54 None
55 };
56
57 Ok(Self {
58 config,
59 pr_generator,
60 fixtures_dir,
61 })
62 }
63
64 pub async fn process_sync_changes(
66 &self,
67 database: &RecorderDatabase,
68 changes: &[DetectedChange],
69 sync_cycle_id: &str,
70 ) -> Result<Option<mockforge_core::pr_generation::PRResult>> {
71 if !self.config.enabled {
72 return Ok(None);
73 }
74
75 if changes.is_empty() {
76 debug!("No changes detected, skipping GitOps PR creation");
77 return Ok(None);
78 }
79
80 let pr_generator = self.pr_generator.as_ref().ok_or_else(|| {
81 crate::RecorderError::InvalidFilter("PR generator not configured".to_string())
82 })?;
83
84 info!("Processing {} changes for GitOps PR creation", changes.len());
85
86 let mut file_changes = Vec::new();
88
89 for change in changes {
90 if let Ok(Some(request)) = database.get_request(&change.request_id).await {
92 if self.config.update_fixtures {
93 if let Some(fixture_change) =
94 self.create_fixture_file_change(database, &request, change).await?
95 {
96 file_changes.push(fixture_change);
97 }
98 }
99 }
100 }
101
102 if file_changes.is_empty() {
103 warn!("No file changes to commit, skipping PR creation");
104 return Ok(None);
105 }
106
107 let branch = format!(
109 "{}/sync-{}",
110 self.config.base_branch,
111 sync_cycle_id.split('_').next_back().unwrap_or(sync_cycle_id)
112 );
113
114 let title =
115 format!("Auto-sync: Update fixtures from upstream API changes ({})", sync_cycle_id);
116
117 let body = self.generate_pr_body(changes);
118
119 let pr_request = PRRequest {
120 title,
121 body,
122 branch,
123 files: file_changes,
124 labels: vec!["automated".to_string(), "contract-update".to_string()],
125 reviewers: vec![],
126 };
127
128 match pr_generator.create_pr(pr_request).await {
129 Ok(result) => {
130 info!("Created GitOps PR: {} - {}", result.number, result.url);
131 Ok(Some(result))
132 }
133 Err(e) => {
134 warn!("Failed to create GitOps PR: {}", e);
135 Err(crate::RecorderError::InvalidFilter(format!("Failed to create PR: {}", e)))
136 }
137 }
138 }
139
140 async fn create_fixture_file_change(
142 &self,
143 database: &RecorderDatabase,
144 request: &RecordedRequest,
145 change: &DetectedChange,
146 ) -> Result<Option<PRFileChange>> {
147 let fixture_path = self.get_fixture_path(request);
149
150 let response = database.get_response(&change.request_id).await?.ok_or_else(|| {
152 crate::RecorderError::NotFound(format!(
153 "Response not found for request {}",
154 change.request_id
155 ))
156 })?;
157
158 let fixture_content = serde_json::to_string_pretty(&serde_json::json!({
160 "id": request.id,
161 "method": request.method,
162 "path": request.path,
163 "headers": request.headers,
164 "body": request.body,
165 "response": {
166 "status_code": response.status_code,
167 "headers": response.headers,
168 "body": response.body,
169 "body_encoding": response.body_encoding,
170 },
171 "timestamp": request.timestamp,
172 }))?;
173
174 let change_type = if std::path::Path::new(&fixture_path).exists() {
176 PRFileChangeType::Update
177 } else {
178 PRFileChangeType::Create
179 };
180
181 Ok(Some(PRFileChange {
182 path: fixture_path,
183 content: fixture_content,
184 change_type,
185 }))
186 }
187
188 fn get_fixture_path(&self, request: &RecordedRequest) -> String {
190 let method = request.method.to_lowercase();
191 let path_hash = request.path.replace(['/', ':'], "_");
192
193 use std::collections::hash_map::DefaultHasher;
195 use std::hash::{Hash, Hasher};
196 let mut hasher = DefaultHasher::new();
197 request.path.hash(&mut hasher);
198 let hash = format!("{:x}", hasher.finish());
199
200 format!("fixtures/http/{}/{}/{}.json", method, path_hash, hash)
202 }
203
204 fn generate_pr_body(&self, changes: &[DetectedChange]) -> String {
206 let mut body = String::from("## Auto-sync: Upstream API Changes\n\n");
207 body.push_str("This PR was automatically generated by MockForge sync to update fixtures based on detected upstream API changes.\n\n");
208
209 body.push_str("### Summary\n\n");
210 body.push_str(&format!("- **Total changes**: {}\n", changes.len()));
211 body.push_str(&format!(
212 "- **Endpoints affected**: {}\n",
213 self.count_unique_endpoints(changes)
214 ));
215
216 body.push_str("\n### Changes\n\n");
217 for change in changes {
218 body.push_str(&format!(
219 "- `{} {}`: {} differences detected\n",
220 change.method,
221 change.path,
222 change.comparison.differences.len()
223 ));
224 }
225
226 body.push_str("\n### What Changed\n\n");
227 body.push_str("- Updated fixture files with new response data\n");
228 if self.config.update_docs {
229 body.push_str("- Updated OpenAPI specifications\n");
230 }
231 if self.config.regenerate_sdks {
232 body.push_str("- Regenerated SDKs\n");
233 }
234
235 body.push_str("\n---\n");
236 body.push_str("*This PR was automatically created by MockForge sync. Please review the changes before merging.*\n");
237
238 body
239 }
240
241 fn count_unique_endpoints(&self, changes: &[DetectedChange]) -> usize {
243 let mut endpoints = std::collections::HashSet::new();
244 for change in changes {
245 endpoints.insert(format!("{} {}", change.method, change.path));
246 }
247 endpoints.len()
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use crate::diff::{ComparisonResult, Difference, DifferenceType};
255 use tempfile::TempDir;
256
257 fn create_test_gitops_config(enabled: bool) -> GitOpsConfig {
258 GitOpsConfig {
259 enabled,
260 pr_provider: "github".to_string(),
261 repo_owner: "test-owner".to_string(),
262 repo_name: "test-repo".to_string(),
263 base_branch: "main".to_string(),
264 update_fixtures: true,
265 regenerate_sdks: false,
266 update_docs: true,
267 auto_merge: false,
268 token: Some("test-token".to_string()),
269 }
270 }
271
272 fn create_test_summary(total_differences: usize) -> crate::diff::ComparisonSummary {
273 crate::diff::ComparisonSummary {
274 total_differences,
275 added_fields: 0,
276 removed_fields: 0,
277 changed_fields: total_differences,
278 type_changes: 0,
279 }
280 }
281
282 fn create_test_change(request_id: &str, path: &str, method: &str) -> DetectedChange {
283 let differences = vec![Difference::new(
284 "$.status".to_string(),
285 DifferenceType::Changed {
286 path: "$.status".to_string(),
287 original: "200".to_string(),
288 current: "201".to_string(),
289 },
290 )];
291 DetectedChange {
292 request_id: request_id.to_string(),
293 path: path.to_string(),
294 method: method.to_string(),
295 comparison: ComparisonResult {
296 matches: false,
297 status_match: false,
298 headers_match: true,
299 body_match: true,
300 differences: differences.clone(),
301 summary: create_test_summary(differences.len()),
302 },
303 updated: false,
304 }
305 }
306
307 #[tokio::test]
308 async fn test_gitops_handler_creation_enabled() {
309 let config = create_test_gitops_config(true);
310 let temp_dir = TempDir::new().unwrap();
311 let fixtures_dir = temp_dir.path().to_path_buf();
312
313 let result = GitOpsSyncHandler::new(config, fixtures_dir);
314 assert!(result.is_ok());
315 }
316
317 #[tokio::test]
318 async fn test_gitops_handler_creation_disabled() {
319 let mut config = create_test_gitops_config(false);
320 config.token = None;
321 let temp_dir = TempDir::new().unwrap();
322 let fixtures_dir = temp_dir.path().to_path_buf();
323
324 let result = GitOpsSyncHandler::new(config, fixtures_dir);
325 assert!(result.is_ok());
326 }
327
328 #[tokio::test]
329 async fn test_gitops_handler_creation_no_token() {
330 let mut config = create_test_gitops_config(true);
331 config.token = None;
332 let temp_dir = TempDir::new().unwrap();
333 let fixtures_dir = temp_dir.path().to_path_buf();
334
335 let result = GitOpsSyncHandler::new(config, fixtures_dir);
336 assert!(result.is_err());
337 }
338
339 #[tokio::test]
340 async fn test_gitops_handler_creation_gitlab_provider() {
341 let mut config = create_test_gitops_config(true);
342 config.pr_provider = "gitlab".to_string();
343 let temp_dir = TempDir::new().unwrap();
344 let fixtures_dir = temp_dir.path().to_path_buf();
345
346 let result = GitOpsSyncHandler::new(config, fixtures_dir);
347 assert!(result.is_ok());
348 }
349
350 #[test]
351 fn test_get_fixture_path() {
352 let config = create_test_gitops_config(true);
353 let temp_dir = TempDir::new().unwrap();
354 let fixtures_dir = temp_dir.path().to_path_buf();
355
356 let handler = GitOpsSyncHandler::new(config, fixtures_dir).unwrap();
357
358 let request = RecordedRequest {
359 id: "test-123".to_string(),
360 protocol: crate::models::Protocol::Http,
361 timestamp: chrono::Utc::now(),
362 method: "GET".to_string(),
363 path: "/api/users".to_string(),
364 query_params: None,
365 headers: "{}".to_string(),
366 body: None,
367 body_encoding: "utf8".to_string(),
368 client_ip: None,
369 trace_id: None,
370 span_id: None,
371 duration_ms: None,
372 status_code: Some(200),
373 tags: None,
374 };
375
376 let fixture_path = handler.get_fixture_path(&request);
377
378 assert!(fixture_path.contains("fixtures/http/get"));
379 assert!(fixture_path.ends_with(".json"));
380 }
381
382 #[test]
383 fn test_generate_pr_body_single_change() {
384 let config = create_test_gitops_config(true);
385 let temp_dir = TempDir::new().unwrap();
386 let fixtures_dir = temp_dir.path().to_path_buf();
387
388 let handler = GitOpsSyncHandler::new(config, fixtures_dir).unwrap();
389
390 let changes = vec![create_test_change("req-1", "/api/users", "GET")];
391 let body = handler.generate_pr_body(&changes);
392
393 assert!(body.contains("**Total changes**: 1"));
394 assert!(body.contains("GET /api/users"));
395 assert!(body.contains("1 differences detected"));
396 assert!(body.contains("Updated fixture files"));
397 }
398
399 #[test]
400 fn test_generate_pr_body_multiple_changes() {
401 let config = create_test_gitops_config(true);
402 let temp_dir = TempDir::new().unwrap();
403 let fixtures_dir = temp_dir.path().to_path_buf();
404
405 let handler = GitOpsSyncHandler::new(config, fixtures_dir).unwrap();
406
407 let changes = vec![
408 create_test_change("req-1", "/api/users", "GET"),
409 create_test_change("req-2", "/api/posts", "POST"),
410 create_test_change("req-3", "/api/users", "DELETE"),
411 ];
412
413 let body = handler.generate_pr_body(&changes);
414
415 assert!(body.contains("**Total changes**: 3"));
416 assert!(body.contains("GET /api/users"));
417 assert!(body.contains("POST /api/posts"));
418 assert!(body.contains("DELETE /api/users"));
419 }
420
421 #[test]
422 fn test_generate_pr_body_with_docs_enabled() {
423 let mut config = create_test_gitops_config(true);
424 config.update_docs = true;
425 let temp_dir = TempDir::new().unwrap();
426 let fixtures_dir = temp_dir.path().to_path_buf();
427
428 let handler = GitOpsSyncHandler::new(config, fixtures_dir).unwrap();
429
430 let changes = vec![create_test_change("req-1", "/api/users", "GET")];
431 let body = handler.generate_pr_body(&changes);
432
433 assert!(body.contains("Updated OpenAPI specifications"));
434 }
435
436 #[test]
437 fn test_generate_pr_body_with_sdks_enabled() {
438 let mut config = create_test_gitops_config(true);
439 config.regenerate_sdks = true;
440 let temp_dir = TempDir::new().unwrap();
441 let fixtures_dir = temp_dir.path().to_path_buf();
442
443 let handler = GitOpsSyncHandler::new(config, fixtures_dir).unwrap();
444
445 let changes = vec![create_test_change("req-1", "/api/users", "GET")];
446 let body = handler.generate_pr_body(&changes);
447
448 assert!(body.contains("Regenerated SDKs"));
449 }
450
451 #[test]
452 fn test_count_unique_endpoints_single() {
453 let config = create_test_gitops_config(true);
454 let temp_dir = TempDir::new().unwrap();
455 let fixtures_dir = temp_dir.path().to_path_buf();
456
457 let handler = GitOpsSyncHandler::new(config, fixtures_dir).unwrap();
458
459 let changes = vec![
460 create_test_change("req-1", "/api/users", "GET"),
461 create_test_change("req-2", "/api/users", "GET"),
462 ];
463
464 let count = handler.count_unique_endpoints(&changes);
465 assert_eq!(count, 1);
466 }
467
468 #[test]
469 fn test_count_unique_endpoints_multiple() {
470 let config = create_test_gitops_config(true);
471 let temp_dir = TempDir::new().unwrap();
472 let fixtures_dir = temp_dir.path().to_path_buf();
473
474 let handler = GitOpsSyncHandler::new(config, fixtures_dir).unwrap();
475
476 let changes = vec![
477 create_test_change("req-1", "/api/users", "GET"),
478 create_test_change("req-2", "/api/posts", "GET"),
479 create_test_change("req-3", "/api/users", "POST"),
480 ];
481
482 let count = handler.count_unique_endpoints(&changes);
483 assert_eq!(count, 3);
484 }
485
486 #[test]
487 fn test_count_unique_endpoints_empty() {
488 let config = create_test_gitops_config(true);
489 let temp_dir = TempDir::new().unwrap();
490 let fixtures_dir = temp_dir.path().to_path_buf();
491
492 let handler = GitOpsSyncHandler::new(config, fixtures_dir).unwrap();
493
494 let changes = vec![];
495 let count = handler.count_unique_endpoints(&changes);
496 assert_eq!(count, 0);
497 }
498
499 #[test]
500 fn test_gitops_config_serialization() {
501 let config = create_test_gitops_config(true);
502 let json = serde_json::to_string(&config).unwrap();
503
504 assert!(json.contains("github"));
505 assert!(json.contains("test-owner"));
506 assert!(json.contains("test-repo"));
507 assert!(!json.contains("test-token")); }
509
510 #[test]
511 fn test_gitops_config_deserialization() {
512 let json = r#"{
513 "enabled": true,
514 "pr_provider": "github",
515 "repo_owner": "owner",
516 "repo_name": "repo",
517 "base_branch": "develop",
518 "update_fixtures": true,
519 "regenerate_sdks": false,
520 "update_docs": true,
521 "auto_merge": false
522 }"#;
523
524 let config: GitOpsConfig = serde_json::from_str(json).unwrap();
525 assert!(config.enabled);
526 assert_eq!(config.pr_provider, "github");
527 assert_eq!(config.repo_owner, "owner");
528 assert_eq!(config.base_branch, "develop");
529 }
530
531 #[test]
532 fn test_gitops_config_defaults() {
533 let json = r#"{
534 "enabled": true,
535 "pr_provider": "github",
536 "repo_owner": "owner",
537 "repo_name": "repo"
538 }"#;
539
540 let config: GitOpsConfig = serde_json::from_str(json).unwrap();
541 assert_eq!(config.base_branch, "main");
542 assert!(config.update_fixtures);
543 assert!(config.update_docs);
544 assert!(!config.regenerate_sdks);
545 assert!(!config.auto_merge);
546 }
547}