1use axum::{
6 extract::{Path, Query, State},
7 http::StatusCode,
8 response::Json,
9};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::sync::Arc;
13
14use chrono;
15use mockforge_core::contract_drift::{DriftBudget, DriftBudgetEngine};
16use mockforge_core::incidents::types::DriftIncident;
17use mockforge_core::incidents::{
18 IncidentManager, IncidentQuery, IncidentSeverity, IncidentStatus, IncidentType,
19};
20
21#[derive(Clone)]
23pub struct DriftBudgetState {
24 pub engine: Arc<DriftBudgetEngine>,
26 pub incident_manager: Arc<IncidentManager>,
28 pub gitops_handler: Option<Arc<mockforge_core::drift_gitops::DriftGitOpsHandler>>,
30}
31
32#[derive(Debug, Deserialize, Serialize)]
34pub struct CreateDriftBudgetRequest {
35 pub endpoint: String,
37 pub method: String,
39 pub max_breaking_changes: Option<u32>,
41 pub max_non_breaking_changes: Option<u32>,
43 pub severity_threshold: Option<String>,
45 pub enabled: Option<bool>,
47 pub workspace_id: Option<String>,
49}
50
51#[derive(Debug, Serialize)]
53pub struct DriftBudgetResponse {
54 pub id: String,
56 pub endpoint: String,
58 pub method: String,
60 pub budget: DriftBudget,
62 pub workspace_id: Option<String>,
64}
65
66#[derive(Debug, Deserialize)]
68pub struct ListIncidentsRequest {
69 pub status: Option<String>,
71 pub severity: Option<String>,
73 pub endpoint: Option<String>,
75 pub method: Option<String>,
77 pub incident_type: Option<String>,
79 pub workspace_id: Option<String>,
81 pub limit: Option<usize>,
83 pub offset: Option<usize>,
85}
86
87#[derive(Debug, Serialize)]
89pub struct ListIncidentsResponse {
90 pub incidents: Vec<DriftIncident>,
92 pub total: usize,
94}
95
96#[derive(Debug, Deserialize)]
98pub struct UpdateIncidentRequest {
99 pub status: Option<String>,
101 pub external_ticket_id: Option<String>,
103 pub external_ticket_url: Option<String>,
105}
106
107#[derive(Debug, Deserialize)]
109pub struct ResolveIncidentRequest {
110 pub note: Option<String>,
112}
113
114pub async fn create_budget(
118 State(state): State<DriftBudgetState>,
119 Json(request): Json<CreateDriftBudgetRequest>,
120) -> Result<Json<DriftBudgetResponse>, StatusCode> {
121 let budget = DriftBudget {
122 max_breaking_changes: request.max_breaking_changes.unwrap_or(0),
123 max_non_breaking_changes: request.max_non_breaking_changes.unwrap_or(10),
124 max_field_churn_percent: None,
125 time_window_days: None,
126 severity_threshold: request
127 .severity_threshold
128 .as_deref()
129 .and_then(|s| match s.to_lowercase().as_str() {
130 "critical" => Some(mockforge_core::ai_contract_diff::MismatchSeverity::Critical),
131 "high" => Some(mockforge_core::ai_contract_diff::MismatchSeverity::High),
132 "medium" => Some(mockforge_core::ai_contract_diff::MismatchSeverity::Medium),
133 "low" => Some(mockforge_core::ai_contract_diff::MismatchSeverity::Low),
134 _ => None,
135 })
136 .unwrap_or(mockforge_core::ai_contract_diff::MismatchSeverity::High),
137 enabled: request.enabled.unwrap_or(true),
138 };
139
140 let budget_id = format!("{}:{}:{}", request.method, request.endpoint, uuid::Uuid::new_v4());
142
143 let mut config = state.engine.config().clone();
145 let key = format!("{} {}", request.method, request.endpoint);
146 config.per_endpoint_budgets.insert(key, budget.clone());
147
148 Ok(Json(DriftBudgetResponse {
153 id: budget_id,
154 endpoint: request.endpoint,
155 method: request.method,
156 budget,
157 workspace_id: request.workspace_id,
158 }))
159}
160
161pub async fn list_budgets(
165 State(_state): State<DriftBudgetState>,
166) -> Result<Json<serde_json::Value>, StatusCode> {
167 Ok(Json(serde_json::json!({
170 "budgets": []
171 })))
172}
173
174pub async fn get_budget(
178 State(_state): State<DriftBudgetState>,
179 Path(_id): Path<String>,
180) -> Result<Json<serde_json::Value>, StatusCode> {
181 Err(StatusCode::NOT_IMPLEMENTED)
182}
183
184#[derive(Debug, Deserialize)]
188pub struct GetBudgetQuery {
189 pub endpoint: String,
191 pub method: String,
193 pub workspace_id: Option<String>,
195 pub service_name: Option<String>,
197 pub tags: Option<String>,
199}
200
201pub async fn get_budget_for_endpoint(
205 State(state): State<DriftBudgetState>,
206 Query(params): Query<GetBudgetQuery>,
207) -> Result<Json<serde_json::Value>, StatusCode> {
208 let tags = params
209 .tags
210 .as_ref()
211 .map(|t| t.split(',').map(|s| s.trim().to_string()).collect::<Vec<_>>());
212
213 let budget = state.engine.get_budget_for_endpoint(
214 ¶ms.endpoint,
215 ¶ms.method,
216 params.workspace_id.as_deref(),
217 params.service_name.as_deref(),
218 tags.as_deref(),
219 );
220
221 Ok(Json(serde_json::json!({
222 "endpoint": params.endpoint,
223 "method": params.method,
224 "workspace_id": params.workspace_id,
225 "service_name": params.service_name,
226 "budget": budget,
227 })))
228}
229
230#[derive(Debug, Deserialize, Serialize)]
232pub struct CreateWorkspaceBudgetRequest {
233 pub workspace_id: String,
235 pub max_breaking_changes: Option<u32>,
237 pub max_non_breaking_changes: Option<u32>,
239 pub max_field_churn_percent: Option<f64>,
241 pub time_window_days: Option<u32>,
243 pub enabled: Option<bool>,
245}
246
247#[derive(Debug, Deserialize, Serialize)]
249pub struct CreateServiceBudgetRequest {
250 pub service_name: String,
252 pub max_breaking_changes: Option<u32>,
254 pub max_non_breaking_changes: Option<u32>,
256 pub max_field_churn_percent: Option<f64>,
258 pub time_window_days: Option<u32>,
260 pub enabled: Option<bool>,
262}
263
264pub async fn create_workspace_budget(
268 State(state): State<DriftBudgetState>,
269 Json(request): Json<CreateWorkspaceBudgetRequest>,
270) -> Result<Json<serde_json::Value>, StatusCode> {
271 let budget = DriftBudget {
272 max_breaking_changes: request.max_breaking_changes.unwrap_or(0),
273 max_non_breaking_changes: request.max_non_breaking_changes.unwrap_or(10),
274 max_field_churn_percent: request.max_field_churn_percent,
275 time_window_days: request.time_window_days,
276 severity_threshold: mockforge_core::ai_contract_diff::MismatchSeverity::High,
277 enabled: request.enabled.unwrap_or(true),
278 };
279
280 let mut config = state.engine.config().clone();
281 config
282 .per_workspace_budgets
283 .insert(request.workspace_id.clone(), budget.clone());
284
285 Ok(Json(serde_json::json!({
289 "workspace_id": request.workspace_id,
290 "budget": budget,
291 })))
292}
293
294pub async fn create_service_budget(
298 State(state): State<DriftBudgetState>,
299 Json(request): Json<CreateServiceBudgetRequest>,
300) -> Result<Json<serde_json::Value>, StatusCode> {
301 let budget = DriftBudget {
302 max_breaking_changes: request.max_breaking_changes.unwrap_or(0),
303 max_non_breaking_changes: request.max_non_breaking_changes.unwrap_or(10),
304 max_field_churn_percent: request.max_field_churn_percent,
305 time_window_days: request.time_window_days,
306 severity_threshold: mockforge_core::ai_contract_diff::MismatchSeverity::High,
307 enabled: request.enabled.unwrap_or(true),
308 };
309
310 let mut config = state.engine.config().clone();
311 config.per_service_budgets.insert(request.service_name.clone(), budget.clone());
312
313 Ok(Json(serde_json::json!({
317 "service_name": request.service_name,
318 "budget": budget,
319 })))
320}
321
322#[derive(Debug, Deserialize)]
324pub struct GeneratePRRequest {
325 pub incident_ids: Option<Vec<String>>,
327 pub workspace_id: Option<String>,
329 pub status: Option<String>,
331}
332
333pub async fn generate_gitops_pr(
337 State(state): State<DriftBudgetState>,
338 Json(request): Json<GeneratePRRequest>,
339) -> Result<Json<serde_json::Value>, StatusCode> {
340 let handler = state.gitops_handler.as_ref().ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
341
342 let mut query = IncidentQuery::default();
344
345 if let Some(incident_ids) = &request.incident_ids {
346 let all_incidents = state.incident_manager.query_incidents(query).await;
349 let incidents: Vec<_> =
350 all_incidents.into_iter().filter(|inc| incident_ids.contains(&inc.id)).collect();
351
352 match handler.generate_pr_from_incidents(&incidents).await {
353 Ok(Some(pr_result)) => {
354 #[cfg(feature = "pipelines")]
356 {
357 use mockforge_pipelines::{publish_event, PipelineEvent};
358 use uuid::Uuid;
359
360 let workspace_id = incidents
362 .first()
363 .and_then(|inc| inc.workspace_id.as_ref())
364 .and_then(|ws_id| Uuid::parse_str(ws_id).ok());
365
366 let threshold_exceeded_count = incidents
368 .iter()
369 .filter(|inc| matches!(inc.incident_type, IncidentType::ThresholdExceeded))
370 .count();
371
372 if threshold_exceeded_count > 0 {
373 let endpoint = incidents
375 .first()
376 .map(|inc| format!("{} {}", inc.method, inc.endpoint))
377 .unwrap_or_else(|| "unknown".to_string());
378
379 let event = PipelineEvent::drift_threshold_exceeded(
380 workspace_id.unwrap_or_else(Uuid::new_v4),
381 endpoint,
382 threshold_exceeded_count as i32,
383 incidents.len() as i32,
384 );
385
386 if let Err(e) = publish_event(event) {
387 tracing::warn!(
388 "Failed to publish drift threshold exceeded event: {}",
389 e
390 );
391 }
392 }
393 }
394
395 Ok(Json(serde_json::json!({
396 "success": true,
397 "pr": pr_result,
398 })))
399 }
400 Ok(None) => Ok(Json(serde_json::json!({
401 "success": false,
402 "message": "No PR generated (no file changes or incidents)",
403 }))),
404 Err(_e) => Err(StatusCode::INTERNAL_SERVER_ERROR),
405 }
406 } else {
407 let workspace_id_str = request.workspace_id.clone();
409 #[cfg(not(feature = "pipelines"))]
410 let _ = &workspace_id_str;
411 query.workspace_id = request.workspace_id;
412 if let Some(status_str) = &request.status {
413 query.status = match status_str.as_str() {
414 "open" => Some(IncidentStatus::Open),
415 "acknowledged" => Some(IncidentStatus::Acknowledged),
416 _ => None,
417 };
418 }
419
420 let incidents = state.incident_manager.query_incidents(query).await;
421
422 match handler.generate_pr_from_incidents(&incidents).await {
423 Ok(Some(pr_result)) => {
424 #[cfg(feature = "pipelines")]
426 {
427 use mockforge_pipelines::{publish_event, PipelineEvent};
428 use uuid::Uuid;
429
430 let workspace_id = workspace_id_str
432 .as_ref()
433 .and_then(|ws_id| Uuid::parse_str(ws_id).ok())
434 .or_else(|| {
435 incidents
436 .first()
437 .and_then(|inc| inc.workspace_id.as_ref())
438 .and_then(|ws_id| Uuid::parse_str(ws_id).ok())
439 })
440 .unwrap_or_else(Uuid::new_v4);
441
442 let threshold_exceeded_count = incidents
444 .iter()
445 .filter(|inc| matches!(inc.incident_type, IncidentType::ThresholdExceeded))
446 .count();
447
448 if threshold_exceeded_count > 0 {
449 let endpoint = incidents
451 .first()
452 .map(|inc| format!("{} {}", inc.method, inc.endpoint))
453 .unwrap_or_else(|| "unknown".to_string());
454
455 let event = PipelineEvent::drift_threshold_exceeded(
456 workspace_id,
457 endpoint,
458 threshold_exceeded_count as i32,
459 incidents.len() as i32,
460 );
461
462 if let Err(e) = publish_event(event) {
463 tracing::warn!(
464 "Failed to publish drift threshold exceeded event: {}",
465 e
466 );
467 }
468 }
469 }
470
471 Ok(Json(serde_json::json!({
472 "success": true,
473 "pr": pr_result,
474 "incidents_included": incidents.len(),
475 })))
476 }
477 Ok(None) => Ok(Json(serde_json::json!({
478 "success": false,
479 "message": "No PR generated (no file changes or incidents)",
480 }))),
481 Err(_e) => Err(StatusCode::INTERNAL_SERVER_ERROR),
482 }
483 }
484}
485
486#[derive(Debug, Deserialize)]
490pub struct GetMetricsQuery {
491 pub endpoint: Option<String>,
493 pub method: Option<String>,
495 pub workspace_id: Option<String>,
497 pub days: Option<u32>,
499}
500
501pub async fn get_drift_metrics(
505 State(state): State<DriftBudgetState>,
506 Query(params): Query<GetMetricsQuery>,
507) -> Result<Json<serde_json::Value>, StatusCode> {
508 let mut query = IncidentQuery::default();
510 query.endpoint = params.endpoint;
511 query.method = params.method;
512 query.workspace_id = params.workspace_id;
513
514 if let Some(days) = params.days {
516 let start_date = chrono::Utc::now()
517 .checked_sub_signed(chrono::Duration::days(days as i64))
518 .map(|dt| dt.timestamp())
519 .unwrap_or(0);
520 query.start_date = Some(start_date);
521 }
522
523 let incidents = state.incident_manager.query_incidents(query).await;
524
525 let total_incidents = incidents.len();
527 let breaking_changes = incidents
528 .iter()
529 .filter(|i| matches!(i.incident_type, IncidentType::BreakingChange))
530 .count();
531 let threshold_exceeded = total_incidents - breaking_changes;
532
533 let by_severity: HashMap<String, usize> =
534 incidents.iter().fold(HashMap::new(), |mut acc, inc| {
535 let key = format!("{:?}", inc.severity).to_lowercase();
536 *acc.entry(key).or_insert(0) += 1;
537 acc
538 });
539
540 Ok(Json(serde_json::json!({
541 "total_incidents": total_incidents,
542 "breaking_changes": breaking_changes,
543 "threshold_exceeded": threshold_exceeded,
544 "by_severity": by_severity,
545 "incidents": incidents.iter().take(100).collect::<Vec<_>>(), })))
547}
548
549pub async fn list_incidents(
553 State(state): State<DriftBudgetState>,
554 Query(params): Query<HashMap<String, String>>,
555) -> Result<Json<ListIncidentsResponse>, StatusCode> {
556 let mut query = IncidentQuery::default();
557
558 if let Some(status_str) = params.get("status") {
559 query.status = match status_str.as_str() {
560 "open" => Some(IncidentStatus::Open),
561 "acknowledged" => Some(IncidentStatus::Acknowledged),
562 "resolved" => Some(IncidentStatus::Resolved),
563 "closed" => Some(IncidentStatus::Closed),
564 _ => None,
565 };
566 }
567
568 if let Some(severity_str) = params.get("severity") {
569 query.severity = match severity_str.as_str() {
570 "critical" => Some(IncidentSeverity::Critical),
571 "high" => Some(IncidentSeverity::High),
572 "medium" => Some(IncidentSeverity::Medium),
573 "low" => Some(IncidentSeverity::Low),
574 _ => None,
575 };
576 }
577
578 if let Some(endpoint) = params.get("endpoint") {
579 query.endpoint = Some(endpoint.clone());
580 }
581
582 if let Some(method) = params.get("method") {
583 query.method = Some(method.clone());
584 }
585
586 if let Some(incident_type_str) = params.get("incident_type") {
587 query.incident_type = match incident_type_str.as_str() {
588 "breaking_change" => Some(IncidentType::BreakingChange),
589 "threshold_exceeded" => Some(IncidentType::ThresholdExceeded),
590 _ => None,
591 };
592 }
593
594 if let Some(workspace_id) = params.get("workspace_id") {
595 query.workspace_id = Some(workspace_id.clone());
596 }
597
598 if let Some(limit_str) = params.get("limit") {
599 if let Ok(limit) = limit_str.parse() {
600 query.limit = Some(limit);
601 }
602 }
603
604 if let Some(offset_str) = params.get("offset") {
605 if let Ok(offset) = offset_str.parse() {
606 query.offset = Some(offset);
607 }
608 }
609
610 let incidents = state.incident_manager.query_incidents(query).await;
611 let total = incidents.len();
612
613 Ok(Json(ListIncidentsResponse { incidents, total }))
614}
615
616pub async fn get_incident(
620 State(state): State<DriftBudgetState>,
621 Path(id): Path<String>,
622) -> Result<Json<DriftIncident>, StatusCode> {
623 state
624 .incident_manager
625 .get_incident(&id)
626 .await
627 .map(Json)
628 .ok_or(StatusCode::NOT_FOUND)
629}
630
631pub async fn update_incident(
635 State(state): State<DriftBudgetState>,
636 Path(id): Path<String>,
637 Json(request): Json<UpdateIncidentRequest>,
638) -> Result<Json<DriftIncident>, StatusCode> {
639 let mut incident =
640 state.incident_manager.get_incident(&id).await.ok_or(StatusCode::NOT_FOUND)?;
641
642 if let Some(status_str) = request.status {
643 match status_str.as_str() {
644 "acknowledged" => {
645 incident = state
646 .incident_manager
647 .acknowledge_incident(&id)
648 .await
649 .ok_or(StatusCode::NOT_FOUND)?;
650 }
651 "resolved" => {
652 incident = state
653 .incident_manager
654 .resolve_incident(&id)
655 .await
656 .ok_or(StatusCode::NOT_FOUND)?;
657 }
658 "closed" => {
659 incident = state
660 .incident_manager
661 .close_incident(&id)
662 .await
663 .ok_or(StatusCode::NOT_FOUND)?;
664 }
665 _ => {}
666 }
667 }
668
669 if let Some(ticket_id) = request.external_ticket_id {
670 incident = state
671 .incident_manager
672 .link_external_ticket(&id, ticket_id, request.external_ticket_url)
673 .await
674 .ok_or(StatusCode::NOT_FOUND)?;
675 }
676
677 Ok(Json(incident))
678}
679
680pub async fn resolve_incident(
684 State(state): State<DriftBudgetState>,
685 Path(id): Path<String>,
686 Json(_request): Json<ResolveIncidentRequest>,
687) -> Result<Json<DriftIncident>, StatusCode> {
688 state
689 .incident_manager
690 .resolve_incident(&id)
691 .await
692 .map(Json)
693 .ok_or(StatusCode::NOT_FOUND)
694}
695
696pub async fn get_incident_stats(
700 State(state): State<DriftBudgetState>,
701) -> Result<Json<serde_json::Value>, StatusCode> {
702 let stats = state.incident_manager.get_statistics().await;
703 Ok(Json(serde_json::json!({
704 "stats": stats
705 })))
706}
707
708pub fn drift_budget_router(state: DriftBudgetState) -> axum::Router {
710 use axum::{
711 routing::{get, patch, post},
712 Router,
713 };
714
715 Router::new()
716 .route("/api/v1/drift/budgets", post(create_budget))
717 .route("/api/v1/drift/budgets", get(list_budgets))
718 .route("/api/v1/drift/budgets/lookup", get(get_budget_for_endpoint))
719 .route("/api/v1/drift/budgets/workspace", post(create_workspace_budget))
720 .route("/api/v1/drift/budgets/service", post(create_service_budget))
721 .route("/api/v1/drift/budgets/{id}", get(get_budget))
722 .route("/api/v1/drift/incidents", get(list_incidents))
723 .route("/api/v1/drift/incidents/stats", get(get_incident_stats))
724 .route("/api/v1/drift/incidents/{id}", get(get_incident))
725 .route("/api/v1/drift/incidents/{id}", patch(update_incident))
726 .route("/api/v1/drift/incidents/{id}/resolve", post(resolve_incident))
727 .route("/api/v1/drift/gitops/generate-pr", post(generate_gitops_pr))
728 .route("/api/v1/drift/metrics", get(get_drift_metrics))
729 .with_state(state)
730}