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