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 && config.token.is_some() {
29            let provider = match config.pr_provider.to_lowercase().as_str() {
30                "gitlab" => PRProvider::GitLab,
31                _ => PRProvider::GitHub,
32            };
33
34            let token = config.token.as_ref().ok_or_else(|| {
35                crate::RecorderError::InvalidFilter("GitOps token not provided".to_string())
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('_').last().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}