use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::Json,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use chrono;
use mockforge_core::contract_drift::{DriftBudget, DriftBudgetEngine};
use mockforge_core::incidents::types::DriftIncident;
use mockforge_core::incidents::{
IncidentManager, IncidentQuery, IncidentSeverity, IncidentStatus, IncidentType,
};
#[derive(Clone)]
pub struct DriftBudgetState {
pub engine: Arc<DriftBudgetEngine>,
pub incident_manager: Arc<IncidentManager>,
pub gitops_handler: Option<Arc<mockforge_core::drift_gitops::DriftGitOpsHandler>>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct CreateDriftBudgetRequest {
pub endpoint: String,
pub method: String,
pub max_breaking_changes: Option<u32>,
pub max_non_breaking_changes: Option<u32>,
pub severity_threshold: Option<String>,
pub enabled: Option<bool>,
pub workspace_id: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct DriftBudgetResponse {
pub id: String,
pub endpoint: String,
pub method: String,
pub budget: DriftBudget,
pub workspace_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ListIncidentsRequest {
pub status: Option<String>,
pub severity: Option<String>,
pub endpoint: Option<String>,
pub method: Option<String>,
pub incident_type: Option<String>,
pub workspace_id: Option<String>,
pub limit: Option<usize>,
pub offset: Option<usize>,
}
#[derive(Debug, Serialize)]
pub struct ListIncidentsResponse {
pub incidents: Vec<DriftIncident>,
pub total: usize,
}
#[derive(Debug, Deserialize)]
pub struct UpdateIncidentRequest {
pub status: Option<String>,
pub external_ticket_id: Option<String>,
pub external_ticket_url: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ResolveIncidentRequest {
pub note: Option<String>,
}
pub async fn create_budget(
State(_state): State<DriftBudgetState>,
Json(request): Json<CreateDriftBudgetRequest>,
) -> Result<Json<DriftBudgetResponse>, StatusCode> {
let budget = DriftBudget {
max_breaking_changes: request.max_breaking_changes.unwrap_or(0),
max_non_breaking_changes: request.max_non_breaking_changes.unwrap_or(10),
max_field_churn_percent: None,
time_window_days: None,
severity_threshold: request
.severity_threshold
.as_deref()
.and_then(|s| match s.to_lowercase().as_str() {
"critical" => Some(mockforge_core::ai_contract_diff::MismatchSeverity::Critical),
"high" => Some(mockforge_core::ai_contract_diff::MismatchSeverity::High),
"medium" => Some(mockforge_core::ai_contract_diff::MismatchSeverity::Medium),
"low" => Some(mockforge_core::ai_contract_diff::MismatchSeverity::Low),
_ => None,
})
.unwrap_or(mockforge_core::ai_contract_diff::MismatchSeverity::High),
enabled: request.enabled.unwrap_or(true),
};
let budget_id = format!("{}:{}:{}", request.method, request.endpoint, uuid::Uuid::new_v4());
let key = format!("{} {}", request.method, request.endpoint);
let _ = key;
Ok(Json(DriftBudgetResponse {
id: budget_id,
endpoint: request.endpoint,
method: request.method,
budget,
workspace_id: request.workspace_id,
}))
}
pub async fn list_budgets(
State(state): State<DriftBudgetState>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let config = state.engine.config();
let budgets: Vec<serde_json::Value> = config
.per_endpoint_budgets
.iter()
.map(|(key, budget)| {
let parts: Vec<&str> = key.splitn(2, ' ').collect();
let (method, endpoint) = if parts.len() == 2 {
(parts[0].to_string(), parts[1].to_string())
} else {
("GET".to_string(), key.clone())
};
serde_json::json!({
"id": key,
"method": method,
"endpoint": endpoint,
"budget": {
"max_breaking_changes": budget.max_breaking_changes,
"max_non_breaking_changes": budget.max_non_breaking_changes,
"enabled": budget.enabled,
}
})
})
.collect();
Ok(Json(serde_json::json!({
"budgets": budgets,
"total": budgets.len(),
})))
}
pub async fn get_budget(
State(state): State<DriftBudgetState>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let config = state.engine.config();
if let Some(budget) = config.per_endpoint_budgets.get(&id) {
let parts: Vec<&str> = id.splitn(2, ' ').collect();
let (method, endpoint) = if parts.len() == 2 {
(parts[0].to_string(), parts[1].to_string())
} else {
("GET".to_string(), id.clone())
};
Ok(Json(serde_json::json!({
"id": id,
"method": method,
"endpoint": endpoint,
"budget": {
"max_breaking_changes": budget.max_breaking_changes,
"max_non_breaking_changes": budget.max_non_breaking_changes,
"enabled": budget.enabled,
}
})))
} else {
Err(StatusCode::NOT_FOUND)
}
}
#[derive(Debug, Deserialize)]
pub struct GetBudgetQuery {
pub endpoint: String,
pub method: String,
pub workspace_id: Option<String>,
pub service_name: Option<String>,
pub tags: Option<String>,
}
pub async fn get_budget_for_endpoint(
State(state): State<DriftBudgetState>,
Query(params): Query<GetBudgetQuery>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let tags = params
.tags
.as_ref()
.map(|t| t.split(',').map(|s| s.trim().to_string()).collect::<Vec<_>>());
let budget = state.engine.get_budget_for_endpoint(
¶ms.endpoint,
¶ms.method,
params.workspace_id.as_deref(),
params.service_name.as_deref(),
tags.as_deref(),
);
Ok(Json(serde_json::json!({
"endpoint": params.endpoint,
"method": params.method,
"workspace_id": params.workspace_id,
"service_name": params.service_name,
"budget": budget,
})))
}
#[derive(Debug, Deserialize, Serialize)]
pub struct CreateWorkspaceBudgetRequest {
pub workspace_id: String,
pub max_breaking_changes: Option<u32>,
pub max_non_breaking_changes: Option<u32>,
pub max_field_churn_percent: Option<f64>,
pub time_window_days: Option<u32>,
pub enabled: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct CreateServiceBudgetRequest {
pub service_name: String,
pub max_breaking_changes: Option<u32>,
pub max_non_breaking_changes: Option<u32>,
pub max_field_churn_percent: Option<f64>,
pub time_window_days: Option<u32>,
pub enabled: Option<bool>,
}
pub async fn create_workspace_budget(
State(state): State<DriftBudgetState>,
Json(request): Json<CreateWorkspaceBudgetRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let budget = DriftBudget {
max_breaking_changes: request.max_breaking_changes.unwrap_or(0),
max_non_breaking_changes: request.max_non_breaking_changes.unwrap_or(10),
max_field_churn_percent: request.max_field_churn_percent,
time_window_days: request.time_window_days,
severity_threshold: mockforge_core::ai_contract_diff::MismatchSeverity::High,
enabled: request.enabled.unwrap_or(true),
};
let mut config = state.engine.config().clone();
config
.per_workspace_budgets
.insert(request.workspace_id.clone(), budget.clone());
Ok(Json(serde_json::json!({
"workspace_id": request.workspace_id,
"budget": budget,
})))
}
pub async fn create_service_budget(
State(state): State<DriftBudgetState>,
Json(request): Json<CreateServiceBudgetRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let budget = DriftBudget {
max_breaking_changes: request.max_breaking_changes.unwrap_or(0),
max_non_breaking_changes: request.max_non_breaking_changes.unwrap_or(10),
max_field_churn_percent: request.max_field_churn_percent,
time_window_days: request.time_window_days,
severity_threshold: mockforge_core::ai_contract_diff::MismatchSeverity::High,
enabled: request.enabled.unwrap_or(true),
};
let mut config = state.engine.config().clone();
config.per_service_budgets.insert(request.service_name.clone(), budget.clone());
Ok(Json(serde_json::json!({
"service_name": request.service_name,
"budget": budget,
})))
}
#[derive(Debug, Deserialize)]
pub struct GeneratePRRequest {
pub incident_ids: Option<Vec<String>>,
pub workspace_id: Option<String>,
pub status: Option<String>,
}
pub async fn generate_gitops_pr(
State(state): State<DriftBudgetState>,
Json(request): Json<GeneratePRRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let handler = state.gitops_handler.as_ref().ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
let mut query = IncidentQuery::default();
if let Some(incident_ids) = &request.incident_ids {
let all_incidents = state.incident_manager.query_incidents(query).await;
let incidents: Vec<_> =
all_incidents.into_iter().filter(|inc| incident_ids.contains(&inc.id)).collect();
match handler.generate_pr_from_incidents(&incidents).await {
Ok(Some(pr_result)) => {
#[cfg(feature = "pipelines")]
{
use mockforge_pipelines::{publish_event, PipelineEvent};
use uuid::Uuid;
let workspace_id = incidents
.first()
.and_then(|inc| inc.workspace_id.as_ref())
.and_then(|ws_id| Uuid::parse_str(ws_id).ok());
let threshold_exceeded_count = incidents
.iter()
.filter(|inc| matches!(inc.incident_type, IncidentType::ThresholdExceeded))
.count();
if threshold_exceeded_count > 0 {
let endpoint = incidents
.first()
.map(|inc| format!("{} {}", inc.method, inc.endpoint))
.unwrap_or_else(|| "unknown".to_string());
let event = PipelineEvent::drift_threshold_exceeded(
workspace_id.unwrap_or_else(Uuid::new_v4),
endpoint,
threshold_exceeded_count as i32,
incidents.len() as i32,
);
if let Err(e) = publish_event(event) {
tracing::warn!(
"Failed to publish drift threshold exceeded event: {}",
e
);
}
}
}
Ok(Json(serde_json::json!({
"success": true,
"pr": pr_result,
})))
}
Ok(None) => Ok(Json(serde_json::json!({
"success": false,
"message": "No PR generated (no file changes or incidents)",
}))),
Err(_e) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
} else {
let workspace_id_str = request.workspace_id.clone();
#[cfg(not(feature = "pipelines"))]
let _ = &workspace_id_str;
query.workspace_id = request.workspace_id;
if let Some(status_str) = &request.status {
query.status = match status_str.as_str() {
"open" => Some(IncidentStatus::Open),
"acknowledged" => Some(IncidentStatus::Acknowledged),
_ => None,
};
}
let incidents = state.incident_manager.query_incidents(query).await;
match handler.generate_pr_from_incidents(&incidents).await {
Ok(Some(pr_result)) => {
#[cfg(feature = "pipelines")]
{
use mockforge_pipelines::{publish_event, PipelineEvent};
use uuid::Uuid;
let workspace_id = workspace_id_str
.as_ref()
.and_then(|ws_id| Uuid::parse_str(ws_id).ok())
.or_else(|| {
incidents
.first()
.and_then(|inc| inc.workspace_id.as_ref())
.and_then(|ws_id| Uuid::parse_str(ws_id).ok())
})
.unwrap_or_else(Uuid::new_v4);
let threshold_exceeded_count = incidents
.iter()
.filter(|inc| matches!(inc.incident_type, IncidentType::ThresholdExceeded))
.count();
if threshold_exceeded_count > 0 {
let endpoint = incidents
.first()
.map(|inc| format!("{} {}", inc.method, inc.endpoint))
.unwrap_or_else(|| "unknown".to_string());
let event = PipelineEvent::drift_threshold_exceeded(
workspace_id,
endpoint,
threshold_exceeded_count as i32,
incidents.len() as i32,
);
if let Err(e) = publish_event(event) {
tracing::warn!(
"Failed to publish drift threshold exceeded event: {}",
e
);
}
}
}
Ok(Json(serde_json::json!({
"success": true,
"pr": pr_result,
"incidents_included": incidents.len(),
})))
}
Ok(None) => Ok(Json(serde_json::json!({
"success": false,
"message": "No PR generated (no file changes or incidents)",
}))),
Err(_e) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
}
#[derive(Debug, Deserialize)]
pub struct GetMetricsQuery {
pub endpoint: Option<String>,
pub method: Option<String>,
pub workspace_id: Option<String>,
pub days: Option<u32>,
}
pub async fn get_drift_metrics(
State(state): State<DriftBudgetState>,
Query(params): Query<GetMetricsQuery>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let mut query = IncidentQuery {
endpoint: params.endpoint,
method: params.method,
workspace_id: params.workspace_id,
..IncidentQuery::default()
};
if let Some(days) = params.days {
let start_date = chrono::Utc::now()
.checked_sub_signed(chrono::Duration::days(days as i64))
.map(|dt| dt.timestamp())
.unwrap_or(0);
query.start_date = Some(start_date);
}
let incidents = state.incident_manager.query_incidents(query).await;
let total_incidents = incidents.len();
let breaking_changes = incidents
.iter()
.filter(|i| matches!(i.incident_type, IncidentType::BreakingChange))
.count();
let threshold_exceeded = total_incidents - breaking_changes;
let by_severity: HashMap<String, usize> =
incidents.iter().fold(HashMap::new(), |mut acc, inc| {
let key = format!("{:?}", inc.severity).to_lowercase();
*acc.entry(key).or_insert(0) += 1;
acc
});
Ok(Json(serde_json::json!({
"total_incidents": total_incidents,
"breaking_changes": breaking_changes,
"threshold_exceeded": threshold_exceeded,
"by_severity": by_severity,
"incidents": incidents.iter().take(100).collect::<Vec<_>>(), })))
}
pub async fn list_incidents(
State(state): State<DriftBudgetState>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<ListIncidentsResponse>, StatusCode> {
let mut query = IncidentQuery::default();
if let Some(status_str) = params.get("status") {
query.status = match status_str.as_str() {
"open" => Some(IncidentStatus::Open),
"acknowledged" => Some(IncidentStatus::Acknowledged),
"resolved" => Some(IncidentStatus::Resolved),
"closed" => Some(IncidentStatus::Closed),
_ => None,
};
}
if let Some(severity_str) = params.get("severity") {
query.severity = match severity_str.as_str() {
"critical" => Some(IncidentSeverity::Critical),
"high" => Some(IncidentSeverity::High),
"medium" => Some(IncidentSeverity::Medium),
"low" => Some(IncidentSeverity::Low),
_ => None,
};
}
if let Some(endpoint) = params.get("endpoint") {
query.endpoint = Some(endpoint.clone());
}
if let Some(method) = params.get("method") {
query.method = Some(method.clone());
}
if let Some(incident_type_str) = params.get("incident_type") {
query.incident_type = match incident_type_str.as_str() {
"breaking_change" => Some(IncidentType::BreakingChange),
"threshold_exceeded" => Some(IncidentType::ThresholdExceeded),
_ => None,
};
}
if let Some(workspace_id) = params.get("workspace_id") {
query.workspace_id = Some(workspace_id.clone());
}
if let Some(limit_str) = params.get("limit") {
if let Ok(limit) = limit_str.parse() {
query.limit = Some(limit);
}
}
if let Some(offset_str) = params.get("offset") {
if let Ok(offset) = offset_str.parse() {
query.offset = Some(offset);
}
}
let incidents = state.incident_manager.query_incidents(query).await;
let total = incidents.len();
Ok(Json(ListIncidentsResponse { incidents, total }))
}
pub async fn get_incident(
State(state): State<DriftBudgetState>,
Path(id): Path<String>,
) -> Result<Json<DriftIncident>, StatusCode> {
state
.incident_manager
.get_incident(&id)
.await
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
pub async fn update_incident(
State(state): State<DriftBudgetState>,
Path(id): Path<String>,
Json(request): Json<UpdateIncidentRequest>,
) -> Result<Json<DriftIncident>, StatusCode> {
let mut incident =
state.incident_manager.get_incident(&id).await.ok_or(StatusCode::NOT_FOUND)?;
if let Some(status_str) = request.status {
match status_str.as_str() {
"acknowledged" => {
incident = state
.incident_manager
.acknowledge_incident(&id)
.await
.ok_or(StatusCode::NOT_FOUND)?;
}
"resolved" => {
incident = state
.incident_manager
.resolve_incident(&id)
.await
.ok_or(StatusCode::NOT_FOUND)?;
}
"closed" => {
incident = state
.incident_manager
.close_incident(&id)
.await
.ok_or(StatusCode::NOT_FOUND)?;
}
other => {
tracing::warn!(
"Invalid incident status '{}': expected acknowledged, resolved, or closed",
other
);
return Err(StatusCode::BAD_REQUEST);
}
}
}
if let Some(ticket_id) = request.external_ticket_id {
incident = state
.incident_manager
.link_external_ticket(&id, ticket_id, request.external_ticket_url)
.await
.ok_or(StatusCode::NOT_FOUND)?;
}
Ok(Json(incident))
}
pub async fn resolve_incident(
State(state): State<DriftBudgetState>,
Path(id): Path<String>,
Json(_request): Json<ResolveIncidentRequest>,
) -> Result<Json<DriftIncident>, StatusCode> {
state
.incident_manager
.resolve_incident(&id)
.await
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
pub async fn get_incident_stats(
State(state): State<DriftBudgetState>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let stats = state.incident_manager.get_statistics().await;
Ok(Json(serde_json::json!({
"stats": stats
})))
}
pub fn drift_budget_router(state: DriftBudgetState) -> axum::Router {
use axum::{
routing::{get, patch, post},
Router,
};
Router::new()
.route("/api/v1/drift/budgets", post(create_budget))
.route("/api/v1/drift/budgets", get(list_budgets))
.route("/api/v1/drift/budgets/lookup", get(get_budget_for_endpoint))
.route("/api/v1/drift/budgets/workspace", post(create_workspace_budget))
.route("/api/v1/drift/budgets/service", post(create_service_budget))
.route("/api/v1/drift/budgets/{id}", get(get_budget))
.route("/api/v1/drift/incidents", get(list_incidents))
.route("/api/v1/drift/incidents/stats", get(get_incident_stats))
.route("/api/v1/drift/incidents/{id}", get(get_incident))
.route("/api/v1/drift/incidents/{id}", patch(update_incident))
.route("/api/v1/drift/incidents/{id}/resolve", post(resolve_incident))
.route("/api/v1/drift/gitops/generate-pr", post(generate_gitops_pr))
.route("/api/v1/drift/metrics", get(get_drift_metrics))
.with_state(state)
}