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 = format!(
169 "{}/{}",
170 self.config.branch_prefix,
171 uuid::Uuid::new_v4().to_string()[..8].to_string()
172 );
173
174 let title = self.generate_pr_title(incidents);
176 let body = self.generate_pr_body(incidents);
177
178 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 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 async fn create_openapi_changes(
207 &self,
208 incident: &DriftIncident,
209 ) -> Result<Option<Vec<PRFileChange>>> {
210 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 let spec_path = if let Some(ref spec_dir) = self.config.openapi_spec_dir {
227 PathBuf::from(spec_dir).join("openapi.yaml")
230 } else {
231 PathBuf::from("openapi.yaml")
232 };
233
234 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 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 async fn create_fixture_changes(
260 &self,
261 incident: &DriftIncident,
262 ) -> Result<Option<Vec<PRFileChange>>> {
263 let fixture_data = if let Some(after_sample) = &incident.after_sample {
265 after_sample.clone()
266 } else {
267 incident.details.clone()
269 };
270
271 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 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 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 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}