Skip to main content

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 =
169            format!("{}/{}", self.config.branch_prefix, &uuid::Uuid::new_v4().to_string()[..8]);
170
171        // Generate PR title and body
172        let title = self.generate_pr_title(incidents);
173        let body = self.generate_pr_body(incidents);
174
175        // Create PR request
176        let pr_request = PRRequest {
177            title,
178            body,
179            branch,
180            files: file_changes,
181            labels: vec![
182                "automated".to_string(),
183                "drift-fix".to_string(),
184                "contract-update".to_string(),
185            ],
186            reviewers: vec![],
187        };
188
189        // Create PR
190        match pr_generator.create_pr(pr_request).await {
191            Ok(result) => {
192                tracing::info!("Created drift GitOps PR: {} - {}", result.number, result.url);
193                // Note: Pipeline event emission for drift threshold exceeded should be handled
194                // by the caller that invokes this method, to avoid circular dependencies.
195                // The caller can check if a PR was created and emit the appropriate event.
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    ///
207    /// Generates a JSON Patch (RFC 6902) file describing the operations needed
208    /// to bring the OpenAPI spec in line with observed API behaviour.
209    async fn create_openapi_changes(
210        &self,
211        incident: &DriftIncident,
212    ) -> Result<Option<Vec<PRFileChange>>> {
213        // Extract corrections from incident details or after_sample
214        let corrections = if let Some(after_sample) = &incident.after_sample {
215            if let Some(corrections) = after_sample.get("corrections") {
216                corrections.as_array().cloned().unwrap_or_default()
217            } else {
218                vec![]
219            }
220        } else {
221            vec![]
222        };
223
224        if corrections.is_empty() {
225            return Ok(None);
226        }
227
228        // Determine OpenAPI spec file path
229        let spec_path = if let Some(ref spec_dir) = self.config.openapi_spec_dir {
230            PathBuf::from(spec_dir).join("openapi.yaml")
231        } else {
232            PathBuf::from("openapi.yaml")
233        };
234
235        // Convert corrections into RFC 6902 JSON Patch operations
236        let endpoint_pointer = incident.endpoint.replace('/', "~1");
237        let method_lower = incident.method.to_lowercase();
238
239        let patch_ops: Vec<serde_json::Value> = corrections
240            .iter()
241            .filter_map(|correction| {
242                // Each correction may carry its own op/path/value, or we derive them
243                let op =
244                    correction.get("op").and_then(|v| v.as_str()).unwrap_or("replace").to_string();
245
246                let patch_path = if let Some(p) = correction.get("path").and_then(|v| v.as_str()) {
247                    p.to_string()
248                } else if let Some(field) = correction.get("field").and_then(|v| v.as_str()) {
249                    // Build a path into the endpoint's schema
250                    format!(
251                        "/paths/{}/{}/requestBody/content/application~1json/schema/properties/{}",
252                        endpoint_pointer,
253                        method_lower,
254                        field.replace('/', "~1")
255                    )
256                } else {
257                    return None;
258                };
259
260                let mut patch_op = serde_json::json!({
261                    "op": op,
262                    "path": patch_path,
263                });
264
265                // Add value for add/replace operations
266                if op != "remove" {
267                    if let Some(value) = correction.get("value") {
268                        patch_op["value"] = value.clone();
269                    } else if let Some(expected) = correction.get("expected") {
270                        patch_op["value"] = expected.clone();
271                    }
272                }
273
274                // Preserve from for move/copy
275                if let Some(from) = correction.get("from").and_then(|v| v.as_str()) {
276                    patch_op["from"] = serde_json::json!(from);
277                }
278
279                Some(patch_op)
280            })
281            .collect();
282
283        if patch_ops.is_empty() {
284            return Ok(None);
285        }
286
287        // Build the patch document with metadata
288        let patch_document = serde_json::json!({
289            "openapi_patch": {
290                "format": "json-patch+rfc6902",
291                "incident_id": incident.id,
292                "endpoint": format!("{} {}", incident.method, incident.endpoint),
293                "generated_at": chrono::Utc::now().to_rfc3339(),
294            },
295            "operations": patch_ops,
296        });
297
298        let spec_content = serde_json::to_string_pretty(&patch_document)
299            .map_err(|e| crate::Error::generic(format!("Failed to serialize patch: {}", e)))?;
300
301        // The patch file sits alongside the spec so reviewers can inspect it
302        let patch_path = spec_path.with_extension("patch.json");
303
304        Ok(Some(vec![PRFileChange {
305            path: patch_path.to_string_lossy().to_string(),
306            content: spec_content,
307            change_type: PRFileChangeType::Create,
308        }]))
309    }
310
311    /// Create fixture file changes from incident
312    async fn create_fixture_changes(
313        &self,
314        incident: &DriftIncident,
315    ) -> Result<Option<Vec<PRFileChange>>> {
316        // Use after_sample as the updated fixture
317        let fixture_data = if let Some(after_sample) = &incident.after_sample {
318            after_sample.clone()
319        } else {
320            // Fall back to incident details
321            incident.details.clone()
322        };
323
324        // Determine fixture file path
325        let fixtures_dir = self
326            .config
327            .fixtures_dir
328            .as_ref()
329            .map(PathBuf::from)
330            .unwrap_or_else(|| PathBuf::from("fixtures"));
331
332        let method = incident.method.to_lowercase();
333        let path_hash = incident.endpoint.replace(['/', ':'], "_");
334        let fixture_path =
335            fixtures_dir.join("http").join(&method).join(format!("{}.json", path_hash));
336
337        let fixture_content = serde_json::to_string_pretty(&fixture_data)
338            .map_err(|e| crate::Error::generic(format!("Failed to serialize fixture: {}", e)))?;
339
340        // Determine if this is a create or update based on file existence
341        let change_type = if fixture_path.exists() {
342            PRFileChangeType::Update
343        } else {
344            PRFileChangeType::Create
345        };
346
347        Ok(Some(vec![PRFileChange {
348            path: fixture_path.to_string_lossy().to_string(),
349            content: fixture_content,
350            change_type,
351        }]))
352    }
353
354    /// Generate PR title from incidents
355    fn generate_pr_title(&self, incidents: &[DriftIncident]) -> String {
356        if incidents.len() == 1 {
357            let incident = &incidents[0];
358            format!(
359                "Fix drift: {} {} - {:?}",
360                incident.method, incident.endpoint, incident.incident_type
361            )
362        } else {
363            format!(
364                "Fix drift: {} incidents across {} endpoints",
365                incidents.len(),
366                incidents
367                    .iter()
368                    .map(|i| format!("{} {}", i.method, i.endpoint))
369                    .collect::<std::collections::HashSet<_>>()
370                    .len()
371            )
372        }
373    }
374
375    /// Generate PR body from incidents
376    fn generate_pr_body(&self, incidents: &[DriftIncident]) -> String {
377        let mut body = String::from("## Drift Budget Violation Fix\n\n");
378        body.push_str(
379            "This PR was automatically generated by MockForge to fix drift budget violations.\n\n",
380        );
381
382        body.push_str("### Summary\n\n");
383        body.push_str(&format!("- **Total incidents**: {}\n", incidents.len()));
384
385        let breaking_count = incidents
386            .iter()
387            .filter(|i| {
388                matches!(i.incident_type, crate::incidents::types::IncidentType::BreakingChange)
389            })
390            .count();
391        let threshold_count = incidents.len() - breaking_count;
392
393        body.push_str(&format!("- **Breaking changes**: {}\n", breaking_count));
394        body.push_str(&format!("- **Threshold exceeded**: {}\n", threshold_count));
395
396        body.push_str("\n### Affected Endpoints\n\n");
397        for incident in incidents {
398            body.push_str(&format!(
399                "- `{} {}` - {:?} ({:?})\n",
400                incident.method, incident.endpoint, incident.incident_type, incident.severity
401            ));
402        }
403
404        body.push_str("\n### Changes Made\n\n");
405        if self.config.update_openapi_specs {
406            body.push_str("- Updated OpenAPI specifications with corrections\n");
407        }
408        if self.config.update_fixtures {
409            body.push_str("- Updated fixture files with new response data\n");
410        }
411        if self.config.regenerate_clients {
412            body.push_str("- Regenerated client SDKs\n");
413        }
414        if self.config.run_tests {
415            body.push_str("- Ran tests (see CI results)\n");
416        }
417
418        body.push_str("\n### Incident Details\n\n");
419        for incident in incidents {
420            body.push_str(&format!("#### {} {}\n\n", incident.method, incident.endpoint));
421            body.push_str(&format!("- **Incident ID**: `{}`\n", incident.id));
422            body.push_str(&format!("- **Type**: {:?}\n", incident.incident_type));
423            body.push_str(&format!("- **Severity**: {:?}\n", incident.severity));
424
425            if let Some(breaking_changes) = incident.details.get("breaking_changes") {
426                body.push_str(&format!("- **Breaking Changes**: {}\n", breaking_changes));
427            }
428            if let Some(non_breaking_changes) = incident.details.get("non_breaking_changes") {
429                body.push_str(&format!("- **Non-Breaking Changes**: {}\n", non_breaking_changes));
430            }
431
432            body.push('\n');
433        }
434
435        body.push_str("---\n");
436        body.push_str("*This PR was automatically created by MockForge drift budget monitoring. Please review the changes before merging.*\n");
437
438        body
439    }
440}