use crate::{
incidents::types::DriftIncident,
pr_generation::{PRFileChange, PRFileChangeType, PRGenerator, PRRequest, PRResult},
Result,
};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DriftGitOpsConfig {
pub enabled: bool,
pub pr_config: Option<crate::pr_generation::PRGenerationConfig>,
#[serde(default = "default_true")]
pub update_openapi_specs: bool,
#[serde(default = "default_true")]
pub update_fixtures: bool,
#[serde(default)]
pub regenerate_clients: bool,
#[serde(default)]
pub run_tests: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openapi_spec_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fixtures_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clients_dir: Option<String>,
#[serde(default = "default_branch_prefix")]
pub branch_prefix: String,
}
fn default_true() -> bool {
true
}
fn default_branch_prefix() -> String {
"mockforge/drift-fix".to_string()
}
impl Default for DriftGitOpsConfig {
fn default() -> Self {
Self {
enabled: false,
pr_config: None,
update_openapi_specs: true,
update_fixtures: true,
regenerate_clients: false,
run_tests: false,
openapi_spec_dir: None,
fixtures_dir: None,
clients_dir: None,
branch_prefix: "mockforge/drift-fix".to_string(),
}
}
}
pub struct DriftGitOpsHandler {
config: DriftGitOpsConfig,
pr_generator: Option<PRGenerator>,
}
impl DriftGitOpsHandler {
pub fn new(config: DriftGitOpsConfig) -> Result<Self> {
let pr_generator = if config.enabled {
if let Some(ref pr_config) = config.pr_config {
if pr_config.enabled {
let token = pr_config.token.clone().ok_or_else(|| {
crate::Error::internal("PR token not configured".to_string())
})?;
let generator = match pr_config.provider {
crate::pr_generation::PRProvider::GitHub => PRGenerator::new_github(
pr_config.owner.clone(),
pr_config.repo.clone(),
token,
pr_config.base_branch.clone(),
),
crate::pr_generation::PRProvider::GitLab => PRGenerator::new_gitlab(
pr_config.owner.clone(),
pr_config.repo.clone(),
token,
pr_config.base_branch.clone(),
),
};
Some(generator)
} else {
None
}
} else {
None
}
} else {
None
};
Ok(Self {
config,
pr_generator,
})
}
pub async fn generate_pr_from_incidents(
&self,
incidents: &[DriftIncident],
) -> Result<Option<PRResult>> {
if !self.config.enabled {
return Ok(None);
}
if incidents.is_empty() {
return Ok(None);
}
let pr_generator = self
.pr_generator
.as_ref()
.ok_or_else(|| crate::Error::internal("PR generator not configured"))?;
let mut file_changes = Vec::new();
for incident in incidents {
if self.config.update_openapi_specs {
if let Some(openapi_changes) = self.create_openapi_changes(incident).await? {
file_changes.extend(openapi_changes);
}
}
if self.config.update_fixtures {
if let Some(fixture_changes) = self.create_fixture_changes(incident).await? {
file_changes.extend(fixture_changes);
}
}
}
if file_changes.is_empty() {
return Ok(None);
}
let branch =
format!("{}/{}", self.config.branch_prefix, &uuid::Uuid::new_v4().to_string()[..8]);
let title = self.generate_pr_title(incidents);
let body = self.generate_pr_body(incidents);
let pr_request = PRRequest {
title,
body,
branch,
files: file_changes,
labels: vec![
"automated".to_string(),
"drift-fix".to_string(),
"contract-update".to_string(),
],
reviewers: vec![],
};
match pr_generator.create_pr(pr_request).await {
Ok(result) => {
tracing::info!("Created drift GitOps PR: {} - {}", result.number, result.url);
Ok(Some(result))
}
Err(e) => {
tracing::warn!("Failed to create drift GitOps PR: {}", e);
Err(e)
}
}
}
async fn create_openapi_changes(
&self,
incident: &DriftIncident,
) -> Result<Option<Vec<PRFileChange>>> {
let corrections = if let Some(after_sample) = &incident.after_sample {
if let Some(corrections) = after_sample.get("corrections") {
corrections.as_array().cloned().unwrap_or_default()
} else {
vec![]
}
} else {
vec![]
};
if corrections.is_empty() {
return Ok(None);
}
let spec_path = if let Some(ref spec_dir) = self.config.openapi_spec_dir {
PathBuf::from(spec_dir).join("openapi.yaml")
} else {
PathBuf::from("openapi.yaml")
};
let endpoint_pointer = incident.endpoint.replace('/', "~1");
let method_lower = incident.method.to_lowercase();
let patch_ops: Vec<serde_json::Value> = corrections
.iter()
.filter_map(|correction| {
let op =
correction.get("op").and_then(|v| v.as_str()).unwrap_or("replace").to_string();
let patch_path = if let Some(p) = correction.get("path").and_then(|v| v.as_str()) {
p.to_string()
} else if let Some(field) = correction.get("field").and_then(|v| v.as_str()) {
format!(
"/paths/{}/{}/requestBody/content/application~1json/schema/properties/{}",
endpoint_pointer,
method_lower,
field.replace('/', "~1")
)
} else {
return None;
};
let mut patch_op = serde_json::json!({
"op": op,
"path": patch_path,
});
if op != "remove" {
if let Some(value) = correction.get("value") {
patch_op["value"] = value.clone();
} else if let Some(expected) = correction.get("expected") {
patch_op["value"] = expected.clone();
}
}
if let Some(from) = correction.get("from").and_then(|v| v.as_str()) {
patch_op["from"] = serde_json::json!(from);
}
Some(patch_op)
})
.collect();
if patch_ops.is_empty() {
return Ok(None);
}
let patch_document = serde_json::json!({
"openapi_patch": {
"format": "json-patch+rfc6902",
"incident_id": incident.id,
"endpoint": format!("{} {}", incident.method, incident.endpoint),
"generated_at": chrono::Utc::now().to_rfc3339(),
},
"operations": patch_ops,
});
let spec_content = serde_json::to_string_pretty(&patch_document)
.map_err(|e| crate::Error::config(format!("Failed to serialize patch: {}", e)))?;
let patch_path = spec_path.with_extension("patch.json");
Ok(Some(vec![PRFileChange {
path: patch_path.to_string_lossy().to_string(),
content: spec_content,
change_type: PRFileChangeType::Create,
}]))
}
async fn create_fixture_changes(
&self,
incident: &DriftIncident,
) -> Result<Option<Vec<PRFileChange>>> {
let fixture_data = if let Some(after_sample) = &incident.after_sample {
after_sample.clone()
} else {
incident.details.clone()
};
let fixtures_dir = self
.config
.fixtures_dir
.as_ref()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("fixtures"));
let method = incident.method.to_lowercase();
let path_hash = incident.endpoint.replace(['/', ':'], "_");
let fixture_path =
fixtures_dir.join("http").join(&method).join(format!("{}.json", path_hash));
let fixture_content = serde_json::to_string_pretty(&fixture_data)
.map_err(|e| crate::Error::config(format!("Failed to serialize fixture: {}", e)))?;
let change_type = if fixture_path.exists() {
PRFileChangeType::Update
} else {
PRFileChangeType::Create
};
Ok(Some(vec![PRFileChange {
path: fixture_path.to_string_lossy().to_string(),
content: fixture_content,
change_type,
}]))
}
fn generate_pr_title(&self, incidents: &[DriftIncident]) -> String {
if incidents.len() == 1 {
let incident = &incidents[0];
format!(
"Fix drift: {} {} - {:?}",
incident.method, incident.endpoint, incident.incident_type
)
} else {
format!(
"Fix drift: {} incidents across {} endpoints",
incidents.len(),
incidents
.iter()
.map(|i| format!("{} {}", i.method, i.endpoint))
.collect::<std::collections::HashSet<_>>()
.len()
)
}
}
fn generate_pr_body(&self, incidents: &[DriftIncident]) -> String {
let mut body = String::from("## Drift Budget Violation Fix\n\n");
body.push_str(
"This PR was automatically generated by MockForge to fix drift budget violations.\n\n",
);
body.push_str("### Summary\n\n");
body.push_str(&format!("- **Total incidents**: {}\n", incidents.len()));
let breaking_count = incidents
.iter()
.filter(|i| {
matches!(i.incident_type, crate::incidents::types::IncidentType::BreakingChange)
})
.count();
let threshold_count = incidents.len() - breaking_count;
body.push_str(&format!("- **Breaking changes**: {}\n", breaking_count));
body.push_str(&format!("- **Threshold exceeded**: {}\n", threshold_count));
body.push_str("\n### Affected Endpoints\n\n");
for incident in incidents {
body.push_str(&format!(
"- `{} {}` - {:?} ({:?})\n",
incident.method, incident.endpoint, incident.incident_type, incident.severity
));
}
body.push_str("\n### Changes Made\n\n");
if self.config.update_openapi_specs {
body.push_str("- Updated OpenAPI specifications with corrections\n");
}
if self.config.update_fixtures {
body.push_str("- Updated fixture files with new response data\n");
}
if self.config.regenerate_clients {
body.push_str("- Regenerated client SDKs\n");
}
if self.config.run_tests {
body.push_str("- Ran tests (see CI results)\n");
}
body.push_str("\n### Incident Details\n\n");
for incident in incidents {
body.push_str(&format!("#### {} {}\n\n", incident.method, incident.endpoint));
body.push_str(&format!("- **Incident ID**: `{}`\n", incident.id));
body.push_str(&format!("- **Type**: {:?}\n", incident.incident_type));
body.push_str(&format!("- **Severity**: {:?}\n", incident.severity));
if let Some(breaking_changes) = incident.details.get("breaking_changes") {
body.push_str(&format!("- **Breaking Changes**: {}\n", breaking_changes));
}
if let Some(non_breaking_changes) = incident.details.get("non_breaking_changes") {
body.push_str(&format!("- **Non-Breaking Changes**: {}\n", non_breaking_changes));
}
body.push('\n');
}
body.push_str("---\n");
body.push_str("*This PR was automatically created by MockForge drift budget monitoring. Please review the changes before merging.*\n");
body
}
}