mockforge_core/drift_gitops/
handler.rs

1//! GitOps handler for drift budget violations
2//!
3//! This handler generates pull requests when drift budgets are exceeded,
4//! updating OpenAPI specs, fixtures, and optionally triggering client generation.
5
6use crate::{
7    incidents::types::DriftIncident,
8    pr_generation::{PRFileChange, PRFileChangeType, PRGenerator, PRRequest, PRResult},
9    Result,
10};
11use serde::{Deserialize, Serialize};
12use std::path::PathBuf;
13
14/// Configuration for drift GitOps handler
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct DriftGitOpsConfig {
17    /// Whether GitOps mode is enabled
18    pub enabled: bool,
19    /// PR generation configuration (used to build PRGenerator)
20    pub pr_config: Option<crate::pr_generation::PRGenerationConfig>,
21    /// Whether to update OpenAPI specs
22    #[serde(default = "default_true")]
23    pub update_openapi_specs: bool,
24    /// Whether to update fixture files
25    #[serde(default = "default_true")]
26    pub update_fixtures: bool,
27    /// Whether to regenerate client SDKs
28    #[serde(default)]
29    pub regenerate_clients: bool,
30    /// Whether to run tests
31    #[serde(default)]
32    pub run_tests: bool,
33    /// Base directory for OpenAPI specs
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub openapi_spec_dir: Option<String>,
36    /// Base directory for fixtures
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub fixtures_dir: Option<String>,
39    /// Base directory for generated clients
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub clients_dir: Option<String>,
42    /// Branch prefix for generated branches
43    #[serde(default = "default_branch_prefix")]
44    pub branch_prefix: String,
45}
46
47fn default_true() -> bool {
48    true
49}
50
51fn default_branch_prefix() -> String {
52    "mockforge/drift-fix".to_string()
53}
54
55impl Default for DriftGitOpsConfig {
56    fn default() -> Self {
57        Self {
58            enabled: false,
59            pr_config: None,
60            update_openapi_specs: true,
61            update_fixtures: true,
62            regenerate_clients: false,
63            run_tests: false,
64            openapi_spec_dir: None,
65            fixtures_dir: None,
66            clients_dir: None,
67            branch_prefix: "mockforge/drift-fix".to_string(),
68        }
69    }
70}
71
72/// GitOps handler for drift budget violations
73pub struct DriftGitOpsHandler {
74    config: DriftGitOpsConfig,
75    pr_generator: Option<PRGenerator>,
76}
77
78impl DriftGitOpsHandler {
79    /// Create a new drift GitOps handler
80    pub fn new(config: DriftGitOpsConfig) -> Result<Self> {
81        // Build PR generator from config if enabled
82        let pr_generator = if config.enabled {
83            if let Some(ref pr_config) = config.pr_config {
84                if pr_config.enabled {
85                    let token = pr_config.token.clone().ok_or_else(|| {
86                        crate::Error::generic("PR token not configured".to_string())
87                    })?;
88
89                    let generator = match pr_config.provider {
90                        crate::pr_generation::PRProvider::GitHub => PRGenerator::new_github(
91                            pr_config.owner.clone(),
92                            pr_config.repo.clone(),
93                            token,
94                            pr_config.base_branch.clone(),
95                        ),
96                        crate::pr_generation::PRProvider::GitLab => PRGenerator::new_gitlab(
97                            pr_config.owner.clone(),
98                            pr_config.repo.clone(),
99                            token,
100                            pr_config.base_branch.clone(),
101                        ),
102                    };
103                    Some(generator)
104                } else {
105                    None
106                }
107            } else {
108                None
109            }
110        } else {
111            None
112        };
113
114        Ok(Self {
115            config,
116            pr_generator,
117        })
118    }
119
120    /// Generate a PR from drift incidents
121    ///
122    /// This method processes drift incidents and generates a PR with:
123    /// - Updated OpenAPI specs (if corrections are available)
124    /// - Updated fixture files
125    /// - Optionally regenerated client SDKs
126    /// - Optionally test execution
127    pub async fn generate_pr_from_incidents(
128        &self,
129        incidents: &[DriftIncident],
130    ) -> Result<Option<PRResult>> {
131        if !self.config.enabled {
132            return Ok(None);
133        }
134
135        if incidents.is_empty() {
136            return Ok(None);
137        }
138
139        let pr_generator = self
140            .pr_generator
141            .as_ref()
142            .ok_or_else(|| crate::Error::generic("PR generator not configured"))?;
143
144        // Collect file changes from incidents
145        let mut file_changes = Vec::new();
146
147        for incident in incidents {
148            // Add OpenAPI spec updates if enabled and corrections are available
149            if self.config.update_openapi_specs {
150                if let Some(openapi_changes) = self.create_openapi_changes(incident).await? {
151                    file_changes.extend(openapi_changes);
152                }
153            }
154
155            // Add fixture updates if enabled
156            if self.config.update_fixtures {
157                if let Some(fixture_changes) = self.create_fixture_changes(incident).await? {
158                    file_changes.extend(fixture_changes);
159                }
160            }
161        }
162
163        if file_changes.is_empty() {
164            return Ok(None);
165        }
166
167        // Generate branch name
168        let branch = format!(
169            "{}/{}",
170            self.config.branch_prefix,
171            uuid::Uuid::new_v4().to_string()[..8].to_string()
172        );
173
174        // Generate PR title and body
175        let title = self.generate_pr_title(incidents);
176        let body = self.generate_pr_body(incidents);
177
178        // Create PR request
179        let pr_request = PRRequest {
180            title,
181            body,
182            branch,
183            files: file_changes,
184            labels: vec![
185                "automated".to_string(),
186                "drift-fix".to_string(),
187                "contract-update".to_string(),
188            ],
189            reviewers: vec![],
190        };
191
192        // Create PR
193        match pr_generator.create_pr(pr_request).await {
194            Ok(result) => {
195                tracing::info!("Created drift GitOps PR: {} - {}", result.number, result.url);
196                Ok(Some(result))
197            }
198            Err(e) => {
199                tracing::warn!("Failed to create drift GitOps PR: {}", e);
200                Err(e)
201            }
202        }
203    }
204
205    /// Create OpenAPI spec changes from incident
206    async fn create_openapi_changes(
207        &self,
208        incident: &DriftIncident,
209    ) -> Result<Option<Vec<PRFileChange>>> {
210        // Extract corrections from incident details or after_sample
211        let corrections = if let Some(after_sample) = &incident.after_sample {
212            if let Some(corrections) = after_sample.get("corrections") {
213                corrections.as_array().cloned().unwrap_or_default()
214            } else {
215                vec![]
216            }
217        } else {
218            vec![]
219        };
220
221        if corrections.is_empty() {
222            return Ok(None);
223        }
224
225        // Determine OpenAPI spec file path
226        let spec_path = if let Some(ref spec_dir) = self.config.openapi_spec_dir {
227            // Try to find spec file based on endpoint
228            // For now, use a default path - in a full implementation, we'd search for the spec
229            PathBuf::from(spec_dir).join("openapi.yaml")
230        } else {
231            PathBuf::from("openapi.yaml")
232        };
233
234        // Apply corrections to OpenAPI spec
235        // Note: In a full implementation, we'd:
236        // 1. Load the existing OpenAPI spec
237        // 2. Apply JSON Patch corrections
238        // 3. Serialize the updated spec
239        // For now, we'll create a placeholder that indicates what needs to be updated
240        let updated_spec = serde_json::json!({
241            "note": "OpenAPI spec should be updated based on drift corrections",
242            "endpoint": format!("{} {}", incident.method, incident.endpoint),
243            "corrections": corrections,
244            "incident_id": incident.id,
245        });
246
247        // Use JSON format for now (YAML would require serde_yaml)
248        let spec_content = serde_json::to_string_pretty(&updated_spec)
249            .map_err(|e| crate::Error::generic(format!("Failed to serialize spec: {}", e)))?;
250
251        Ok(Some(vec![PRFileChange {
252            path: spec_path.to_string_lossy().to_string(),
253            content: spec_content,
254            change_type: PRFileChangeType::Update,
255        }]))
256    }
257
258    /// Create fixture file changes from incident
259    async fn create_fixture_changes(
260        &self,
261        incident: &DriftIncident,
262    ) -> Result<Option<Vec<PRFileChange>>> {
263        // Use after_sample as the updated fixture
264        let fixture_data = if let Some(after_sample) = &incident.after_sample {
265            after_sample.clone()
266        } else {
267            // Fall back to incident details
268            incident.details.clone()
269        };
270
271        // Determine fixture file path
272        let fixtures_dir = self
273            .config
274            .fixtures_dir
275            .as_ref()
276            .map(|d| PathBuf::from(d))
277            .unwrap_or_else(|| PathBuf::from("fixtures"));
278
279        let method = incident.method.to_lowercase();
280        let path_hash = incident.endpoint.replace(['/', ':'], "_");
281        let fixture_path =
282            fixtures_dir.join("http").join(&method).join(format!("{}.json", path_hash));
283
284        let fixture_content = serde_json::to_string_pretty(&fixture_data)
285            .map_err(|e| crate::Error::generic(format!("Failed to serialize fixture: {}", e)))?;
286
287        // Determine if this is a create or update
288        // Note: We can't check file existence in async context easily, so default to Update
289        // In a full implementation, we'd check the file system or track this in metadata
290        let change_type = PRFileChangeType::Update;
291
292        Ok(Some(vec![PRFileChange {
293            path: fixture_path.to_string_lossy().to_string(),
294            content: fixture_content,
295            change_type,
296        }]))
297    }
298
299    /// Generate PR title from incidents
300    fn generate_pr_title(&self, incidents: &[DriftIncident]) -> String {
301        if incidents.len() == 1 {
302            let incident = &incidents[0];
303            format!(
304                "Fix drift: {} {} - {:?}",
305                incident.method, incident.endpoint, incident.incident_type
306            )
307        } else {
308            format!(
309                "Fix drift: {} incidents across {} endpoints",
310                incidents.len(),
311                incidents
312                    .iter()
313                    .map(|i| format!("{} {}", i.method, i.endpoint))
314                    .collect::<std::collections::HashSet<_>>()
315                    .len()
316            )
317        }
318    }
319
320    /// Generate PR body from incidents
321    fn generate_pr_body(&self, incidents: &[DriftIncident]) -> String {
322        let mut body = String::from("## Drift Budget Violation Fix\n\n");
323        body.push_str(
324            "This PR was automatically generated by MockForge to fix drift budget violations.\n\n",
325        );
326
327        body.push_str("### Summary\n\n");
328        body.push_str(&format!("- **Total incidents**: {}\n", incidents.len()));
329
330        let breaking_count = incidents
331            .iter()
332            .filter(|i| {
333                matches!(i.incident_type, crate::incidents::types::IncidentType::BreakingChange)
334            })
335            .count();
336        let threshold_count = incidents.len() - breaking_count;
337
338        body.push_str(&format!("- **Breaking changes**: {}\n", breaking_count));
339        body.push_str(&format!("- **Threshold exceeded**: {}\n", threshold_count));
340
341        body.push_str("\n### Affected Endpoints\n\n");
342        for incident in incidents {
343            body.push_str(&format!(
344                "- `{} {}` - {:?} ({:?})\n",
345                incident.method, incident.endpoint, incident.incident_type, incident.severity
346            ));
347        }
348
349        body.push_str("\n### Changes Made\n\n");
350        if self.config.update_openapi_specs {
351            body.push_str("- Updated OpenAPI specifications with corrections\n");
352        }
353        if self.config.update_fixtures {
354            body.push_str("- Updated fixture files with new response data\n");
355        }
356        if self.config.regenerate_clients {
357            body.push_str("- Regenerated client SDKs\n");
358        }
359        if self.config.run_tests {
360            body.push_str("- Ran tests (see CI results)\n");
361        }
362
363        body.push_str("\n### Incident Details\n\n");
364        for incident in incidents {
365            body.push_str(&format!("#### {} {}\n\n", incident.method, incident.endpoint));
366            body.push_str(&format!("- **Incident ID**: `{}`\n", incident.id));
367            body.push_str(&format!("- **Type**: {:?}\n", incident.incident_type));
368            body.push_str(&format!("- **Severity**: {:?}\n", incident.severity));
369
370            if let Some(breaking_changes) = incident.details.get("breaking_changes") {
371                body.push_str(&format!("- **Breaking Changes**: {}\n", breaking_changes));
372            }
373            if let Some(non_breaking_changes) = incident.details.get("non_breaking_changes") {
374                body.push_str(&format!("- **Non-Breaking Changes**: {}\n", non_breaking_changes));
375            }
376
377            body.push_str("\n");
378        }
379
380        body.push_str("---\n");
381        body.push_str("*This PR was automatically created by MockForge drift budget monitoring. Please review the changes before merging.*\n");
382
383        body
384    }
385}