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    fixtures_dir: PathBuf,
23}
24
25impl GitOpsSyncHandler {
26    /// Create a new GitOps sync handler
27    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    /// Process sync changes and create a PR if GitOps mode is enabled
64    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        // Collect file changes
86        let mut file_changes = Vec::new();
87
88        for change in changes {
89            // Get the request to determine fixture path
90            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        // Create PR
107        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    /// Create a file change for a fixture update
140    async fn create_fixture_file_change(
141        &self,
142        database: &RecorderDatabase,
143        request: &RecordedRequest,
144        change: &DetectedChange,
145    ) -> Result<Option<PRFileChange>> {
146        // Determine fixture file path
147        let fixture_path = self.get_fixture_path(request);
148
149        // Get the updated response from the database
150        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        // Serialize the updated fixture
158        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        // Determine if this is a create or update
174        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    /// Get the fixture file path for a request
188    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 a simple hash of the path for the filename
193        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        // Return relative path from repo root
200        format!("fixtures/http/{}/{}/{}.json", method, path_hash, hash)
201    }
202
203    /// Generate PR body with change summary
204    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    /// Count unique endpoints in changes
241    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")); // Token should be skipped
507    }
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}