mockforge_core/drift_gitops/
handler.rs1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct DriftGitOpsConfig {
17 pub enabled: bool,
19 pub pr_config: Option<crate::pr_generation::PRGenerationConfig>,
21 #[serde(default = "default_true")]
23 pub update_openapi_specs: bool,
24 #[serde(default = "default_true")]
26 pub update_fixtures: bool,
27 #[serde(default)]
29 pub regenerate_clients: bool,
30 #[serde(default)]
32 pub run_tests: bool,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub openapi_spec_dir: Option<String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub fixtures_dir: Option<String>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub clients_dir: Option<String>,
42 #[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
72pub struct DriftGitOpsHandler {
74 config: DriftGitOpsConfig,
75 pr_generator: Option<PRGenerator>,
76}
77
78impl DriftGitOpsHandler {
79 pub fn new(config: DriftGitOpsConfig) -> Result<Self> {
81 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 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 let mut file_changes = Vec::new();
146
147 for incident in incidents {
148 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 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 let branch =
169 format!("{}/{}", self.config.branch_prefix, &uuid::Uuid::new_v4().to_string()[..8]);
170
171 let title = self.generate_pr_title(incidents);
173 let body = self.generate_pr_body(incidents);
174
175 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 match pr_generator.create_pr(pr_request).await {
191 Ok(result) => {
192 tracing::info!("Created drift GitOps PR: {} - {}", result.number, result.url);
193 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 async fn create_openapi_changes(
210 &self,
211 incident: &DriftIncident,
212 ) -> Result<Option<Vec<PRFileChange>>> {
213 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 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 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 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 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 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 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 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 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 async fn create_fixture_changes(
313 &self,
314 incident: &DriftIncident,
315 ) -> Result<Option<Vec<PRFileChange>>> {
316 let fixture_data = if let Some(after_sample) = &incident.after_sample {
318 after_sample.clone()
319 } else {
320 incident.details.clone()
322 };
323
324 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 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 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 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}