Skip to main content

mockforge_recorder/
sync_gitops.rs

1//! GitOps integration for sync operations
2//!
3//! This module provides functionality to create Git branches and PRs instead of
4//! directly updating the database when sync changes are detected.
5
6use 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
18/// GitOps sync handler
19pub struct GitOpsSyncHandler {
20    config: GitOpsConfig,
21    pr_generator: Option<PRGenerator>,
22    #[allow(dead_code)]
23    fixtures_dir: PathBuf,
24}
25
26impl GitOpsSyncHandler {
27    /// Create a new GitOps sync handler
28    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    /// Process sync changes and create a PR if GitOps mode is enabled
65    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        // Collect file changes
87        let mut file_changes = Vec::new();
88
89        for change in changes {
90            // Get the request to determine fixture path
91            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        // Create PR
108        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    /// Create a file change for a fixture update
141    async fn create_fixture_file_change(
142        &self,
143        database: &RecorderDatabase,
144        request: &RecordedRequest,
145        change: &DetectedChange,
146    ) -> Result<Option<PRFileChange>> {
147        // Determine fixture file path
148        let fixture_path = self.get_fixture_path(request);
149
150        // Get the updated response from the database
151        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        // Serialize the updated fixture
159        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        // Determine if this is a create or update
175        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    /// Get the fixture file path for a request
189    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 a simple hash of the path for the filename
194        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        // Return relative path from repo root
201        format!("fixtures/http/{}/{}/{}.json", method, path_hash, hash)
202    }
203
204    /// Generate PR body with change summary
205    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    /// Count unique endpoints in changes
242    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")); // Token should be skipped
508    }
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}