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();
410 query.workspace_id = request.workspace_id;
411 if let Some(status_str) = &request.status {
412 query.status = match status_str.as_str() {
413 "open" => Some(IncidentStatus::Open),
414 "acknowledged" => Some(IncidentStatus::Acknowledged),
415 _ => None,
416 };
417 }
418
419 let incidents = state.incident_manager.query_incidents(query).await;
420
421 match handler.generate_pr_from_incidents(&incidents).await {
422 Ok(Some(pr_result)) => {
423 #[cfg(feature = "pipelines")]
425 {
426 use mockforge_pipelines::{publish_event, PipelineEvent};
427 use uuid::Uuid;
428
429 let workspace_id = workspace_id_str
431 .as_ref()
432 .and_then(|ws_id| Uuid::parse_str(ws_id).ok())
433 .or_else(|| {
434 incidents
435 .first()
436 .and_then(|inc| inc.workspace_id.as_ref())
437 .and_then(|ws_id| Uuid::parse_str(ws_id).ok())
438 })
439 .unwrap_or_else(Uuid::new_v4);
440
441 let threshold_exceeded_count = incidents
443 .iter()
444 .filter(|inc| matches!(inc.incident_type, IncidentType::ThresholdExceeded))
445 .count();
446
447 if threshold_exceeded_count > 0 {
448 let endpoint = incidents
450 .first()
451 .map(|inc| format!("{} {}", inc.method, inc.endpoint))
452 .unwrap_or_else(|| "unknown".to_string());
453
454 let event = PipelineEvent::drift_threshold_exceeded(
455 workspace_id,
456 endpoint,
457 threshold_exceeded_count as i32,
458 incidents.len() as i32,
459 );
460
461 if let Err(e) = publish_event(event) {
462 tracing::warn!(
463 "Failed to publish drift threshold exceeded event: {}",
464 e
465 );
466 }
467 }
468 }
469
470 Ok(Json(serde_json::json!({
471 "success": true,
472 "pr": pr_result,
473 "incidents_included": incidents.len(),
474 })))
475 }
476 Ok(None) => Ok(Json(serde_json::json!({
477 "success": false,
478 "message": "No PR generated (no file changes or incidents)",
479 }))),
480 Err(e) => Err(StatusCode::INTERNAL_SERVER_ERROR),
481 }
482 }
483}
484
485#[derive(Debug, Deserialize)]
489pub struct GetMetricsQuery {
490 pub endpoint: Option<String>,
492 pub method: Option<String>,
494 pub workspace_id: Option<String>,
496 pub days: Option<u32>,
498}
499
500pub async fn get_drift_metrics(
504 State(state): State<DriftBudgetState>,
505 Query(params): Query<GetMetricsQuery>,
506) -> Result<Json<serde_json::Value>, StatusCode> {
507 let mut query = IncidentQuery::default();
509 query.endpoint = params.endpoint;
510 query.method = params.method;
511 query.workspace_id = params.workspace_id;
512
513 if let Some(days) = params.days {
515 let start_date = chrono::Utc::now()
516 .checked_sub_signed(chrono::Duration::days(days as i64))
517 .map(|dt| dt.timestamp())
518 .unwrap_or(0);
519 query.start_date = Some(start_date);
520 }
521
522 let incidents = state.incident_manager.query_incidents(query).await;
523
524 let total_incidents = incidents.len();
526 let breaking_changes = incidents
527 .iter()
528 .filter(|i| matches!(i.incident_type, IncidentType::BreakingChange))
529 .count();
530 let threshold_exceeded = total_incidents - breaking_changes;
531
532 let by_severity: std::collections::HashMap<String, usize> =
533 incidents.iter().fold(std::collections::HashMap::new(), |mut acc, inc| {
534 let key = format!("{:?}", inc.severity).to_lowercase();
535 *acc.entry(key).or_insert(0) += 1;
536 acc
537 });
538
539 Ok(Json(serde_json::json!({
540 "total_incidents": total_incidents,
541 "breaking_changes": breaking_changes,
542 "threshold_exceeded": threshold_exceeded,
543 "by_severity": by_severity,
544 "incidents": incidents.iter().take(100).collect::<Vec<_>>(), })))
546}
547
548pub async fn list_incidents(
552 State(state): State<DriftBudgetState>,
553 Query(params): Query<HashMap<String, String>>,
554) -> Result<Json<ListIncidentsResponse>, StatusCode> {
555 let mut query = IncidentQuery::default();
556
557 if let Some(status_str) = params.get("status") {
558 query.status = match status_str.as_str() {
559 "open" => Some(IncidentStatus::Open),
560 "acknowledged" => Some(IncidentStatus::Acknowledged),
561 "resolved" => Some(IncidentStatus::Resolved),
562 "closed" => Some(IncidentStatus::Closed),
563 _ => None,
564 };
565 }
566
567 if let Some(severity_str) = params.get("severity") {
568 query.severity = match severity_str.as_str() {
569 "critical" => Some(IncidentSeverity::Critical),
570 "high" => Some(IncidentSeverity::High),
571 "medium" => Some(IncidentSeverity::Medium),
572 "low" => Some(IncidentSeverity::Low),
573 _ => None,
574 };
575 }
576
577 if let Some(endpoint) = params.get("endpoint") {
578 query.endpoint = Some(endpoint.clone());
579 }
580
581 if let Some(method) = params.get("method") {
582 query.method = Some(method.clone());
583 }
584
585 if let Some(incident_type_str) = params.get("incident_type") {
586 query.incident_type = match incident_type_str.as_str() {
587 "breaking_change" => Some(IncidentType::BreakingChange),
588 "threshold_exceeded" => Some(IncidentType::ThresholdExceeded),
589 _ => None,
590 };
591 }
592
593 if let Some(workspace_id) = params.get("workspace_id") {
594 query.workspace_id = Some(workspace_id.clone());
595 }
596
597 if let Some(limit_str) = params.get("limit") {
598 if let Ok(limit) = limit_str.parse() {
599 query.limit = Some(limit);
600 }
601 }
602
603 if let Some(offset_str) = params.get("offset") {
604 if let Ok(offset) = offset_str.parse() {
605 query.offset = Some(offset);
606 }
607 }
608
609 let incidents = state.incident_manager.query_incidents(query).await;
610 let total = incidents.len();
611
612 Ok(Json(ListIncidentsResponse { incidents, total }))
613}
614
615pub async fn get_incident(
619 State(state): State<DriftBudgetState>,
620 Path(id): Path<String>,
621) -> Result<Json<DriftIncident>, StatusCode> {
622 state
623 .incident_manager
624 .get_incident(&id)
625 .await
626 .map(Json)
627 .ok_or(StatusCode::NOT_FOUND)
628}
629
630pub async fn update_incident(
634 State(state): State<DriftBudgetState>,
635 Path(id): Path<String>,
636 Json(request): Json<UpdateIncidentRequest>,
637) -> Result<Json<DriftIncident>, StatusCode> {
638 let mut incident =
639 state.incident_manager.get_incident(&id).await.ok_or(StatusCode::NOT_FOUND)?;
640
641 if let Some(status_str) = request.status {
642 match status_str.as_str() {
643 "acknowledged" => {
644 incident = state
645 .incident_manager
646 .acknowledge_incident(&id)
647 .await
648 .ok_or(StatusCode::NOT_FOUND)?;
649 }
650 "resolved" => {
651 incident = state
652 .incident_manager
653 .resolve_incident(&id)
654 .await
655 .ok_or(StatusCode::NOT_FOUND)?;
656 }
657 "closed" => {
658 incident = state
659 .incident_manager
660 .close_incident(&id)
661 .await
662 .ok_or(StatusCode::NOT_FOUND)?;
663 }
664 _ => {}
665 }
666 }
667
668 if let Some(ticket_id) = request.external_ticket_id {
669 incident = state
670 .incident_manager
671 .link_external_ticket(&id, ticket_id, request.external_ticket_url)
672 .await
673 .ok_or(StatusCode::NOT_FOUND)?;
674 }
675
676 Ok(Json(incident))
677}
678
679pub async fn resolve_incident(
683 State(state): State<DriftBudgetState>,
684 Path(id): Path<String>,
685 Json(_request): Json<ResolveIncidentRequest>,
686) -> Result<Json<DriftIncident>, StatusCode> {
687 state
688 .incident_manager
689 .resolve_incident(&id)
690 .await
691 .map(Json)
692 .ok_or(StatusCode::NOT_FOUND)
693}
694
695pub async fn get_incident_stats(
699 State(state): State<DriftBudgetState>,
700) -> Result<Json<serde_json::Value>, StatusCode> {
701 let stats = state.incident_manager.get_statistics().await;
702 Ok(Json(serde_json::json!({
703 "stats": stats
704 })))
705}
706
707pub fn drift_budget_router(state: DriftBudgetState) -> axum::Router {
709 use axum::{
710 routing::{get, patch, post},
711 Router,
712 };
713
714 Router::new()
715 .route("/api/v1/drift/budgets", post(create_budget))
716 .route("/api/v1/drift/budgets", get(list_budgets))
717 .route("/api/v1/drift/budgets/lookup", get(get_budget_for_endpoint))
718 .route("/api/v1/drift/budgets/workspace", post(create_workspace_budget))
719 .route("/api/v1/drift/budgets/service", post(create_service_budget))
720 .route("/api/v1/drift/budgets/{id}", get(get_budget))
721 .route("/api/v1/drift/incidents", get(list_incidents))
722 .route("/api/v1/drift/incidents/stats", get(get_incident_stats))
723 .route("/api/v1/drift/incidents/{id}", get(get_incident))
724 .route("/api/v1/drift/incidents/{id}", patch(update_incident))
725 .route("/api/v1/drift/incidents/{id}/resolve", post(resolve_incident))
726 .route("/api/v1/drift/gitops/generate-pr", post(generate_gitops_pr))
727 .route("/api/v1/drift/metrics", get(get_drift_metrics))
728 .with_state(state)
729}