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 key = format!("{} {}", request.method, request.endpoint);
145
146 let _ = key;
150
151 Ok(Json(DriftBudgetResponse {
152 id: budget_id,
153 endpoint: request.endpoint,
154 method: request.method,
155 budget,
156 workspace_id: request.workspace_id,
157 }))
158}
159
160pub async fn list_budgets(
164 State(state): State<DriftBudgetState>,
165) -> Result<Json<serde_json::Value>, StatusCode> {
166 let config = state.engine.config();
167 let budgets: Vec<serde_json::Value> = config
168 .per_endpoint_budgets
169 .iter()
170 .map(|(key, budget)| {
171 let parts: Vec<&str> = key.splitn(2, ' ').collect();
173 let (method, endpoint) = if parts.len() == 2 {
174 (parts[0].to_string(), parts[1].to_string())
175 } else {
176 ("GET".to_string(), key.clone())
177 };
178 serde_json::json!({
179 "id": key,
180 "method": method,
181 "endpoint": endpoint,
182 "budget": {
183 "max_breaking_changes": budget.max_breaking_changes,
184 "max_non_breaking_changes": budget.max_non_breaking_changes,
185 "enabled": budget.enabled,
186 }
187 })
188 })
189 .collect();
190
191 Ok(Json(serde_json::json!({
192 "budgets": budgets,
193 "total": budgets.len(),
194 })))
195}
196
197pub async fn get_budget(
201 State(state): State<DriftBudgetState>,
202 Path(id): Path<String>,
203) -> Result<Json<serde_json::Value>, StatusCode> {
204 let config = state.engine.config();
205 if let Some(budget) = config.per_endpoint_budgets.get(&id) {
206 let parts: Vec<&str> = id.splitn(2, ' ').collect();
207 let (method, endpoint) = if parts.len() == 2 {
208 (parts[0].to_string(), parts[1].to_string())
209 } else {
210 ("GET".to_string(), id.clone())
211 };
212 Ok(Json(serde_json::json!({
213 "id": id,
214 "method": method,
215 "endpoint": endpoint,
216 "budget": {
217 "max_breaking_changes": budget.max_breaking_changes,
218 "max_non_breaking_changes": budget.max_non_breaking_changes,
219 "enabled": budget.enabled,
220 }
221 })))
222 } else {
223 Err(StatusCode::NOT_FOUND)
224 }
225}
226
227#[derive(Debug, Deserialize)]
231pub struct GetBudgetQuery {
232 pub endpoint: String,
234 pub method: String,
236 pub workspace_id: Option<String>,
238 pub service_name: Option<String>,
240 pub tags: Option<String>,
242}
243
244pub async fn get_budget_for_endpoint(
248 State(state): State<DriftBudgetState>,
249 Query(params): Query<GetBudgetQuery>,
250) -> Result<Json<serde_json::Value>, StatusCode> {
251 let tags = params
252 .tags
253 .as_ref()
254 .map(|t| t.split(',').map(|s| s.trim().to_string()).collect::<Vec<_>>());
255
256 let budget = state.engine.get_budget_for_endpoint(
257 ¶ms.endpoint,
258 ¶ms.method,
259 params.workspace_id.as_deref(),
260 params.service_name.as_deref(),
261 tags.as_deref(),
262 );
263
264 Ok(Json(serde_json::json!({
265 "endpoint": params.endpoint,
266 "method": params.method,
267 "workspace_id": params.workspace_id,
268 "service_name": params.service_name,
269 "budget": budget,
270 })))
271}
272
273#[derive(Debug, Deserialize, Serialize)]
275pub struct CreateWorkspaceBudgetRequest {
276 pub workspace_id: String,
278 pub max_breaking_changes: Option<u32>,
280 pub max_non_breaking_changes: Option<u32>,
282 pub max_field_churn_percent: Option<f64>,
284 pub time_window_days: Option<u32>,
286 pub enabled: Option<bool>,
288}
289
290#[derive(Debug, Deserialize, Serialize)]
292pub struct CreateServiceBudgetRequest {
293 pub service_name: String,
295 pub max_breaking_changes: Option<u32>,
297 pub max_non_breaking_changes: Option<u32>,
299 pub max_field_churn_percent: Option<f64>,
301 pub time_window_days: Option<u32>,
303 pub enabled: Option<bool>,
305}
306
307pub async fn create_workspace_budget(
311 State(state): State<DriftBudgetState>,
312 Json(request): Json<CreateWorkspaceBudgetRequest>,
313) -> Result<Json<serde_json::Value>, StatusCode> {
314 let budget = DriftBudget {
315 max_breaking_changes: request.max_breaking_changes.unwrap_or(0),
316 max_non_breaking_changes: request.max_non_breaking_changes.unwrap_or(10),
317 max_field_churn_percent: request.max_field_churn_percent,
318 time_window_days: request.time_window_days,
319 severity_threshold: mockforge_core::ai_contract_diff::MismatchSeverity::High,
320 enabled: request.enabled.unwrap_or(true),
321 };
322
323 let mut config = state.engine.config().clone();
324 config
325 .per_workspace_budgets
326 .insert(request.workspace_id.clone(), budget.clone());
327
328 Ok(Json(serde_json::json!({
332 "workspace_id": request.workspace_id,
333 "budget": budget,
334 })))
335}
336
337pub async fn create_service_budget(
341 State(state): State<DriftBudgetState>,
342 Json(request): Json<CreateServiceBudgetRequest>,
343) -> Result<Json<serde_json::Value>, StatusCode> {
344 let budget = DriftBudget {
345 max_breaking_changes: request.max_breaking_changes.unwrap_or(0),
346 max_non_breaking_changes: request.max_non_breaking_changes.unwrap_or(10),
347 max_field_churn_percent: request.max_field_churn_percent,
348 time_window_days: request.time_window_days,
349 severity_threshold: mockforge_core::ai_contract_diff::MismatchSeverity::High,
350 enabled: request.enabled.unwrap_or(true),
351 };
352
353 let mut config = state.engine.config().clone();
354 config.per_service_budgets.insert(request.service_name.clone(), budget.clone());
355
356 Ok(Json(serde_json::json!({
360 "service_name": request.service_name,
361 "budget": budget,
362 })))
363}
364
365#[derive(Debug, Deserialize)]
367pub struct GeneratePRRequest {
368 pub incident_ids: Option<Vec<String>>,
370 pub workspace_id: Option<String>,
372 pub status: Option<String>,
374}
375
376pub async fn generate_gitops_pr(
380 State(state): State<DriftBudgetState>,
381 Json(request): Json<GeneratePRRequest>,
382) -> Result<Json<serde_json::Value>, StatusCode> {
383 let handler = state.gitops_handler.as_ref().ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
384
385 let mut query = IncidentQuery::default();
387
388 if let Some(incident_ids) = &request.incident_ids {
389 let all_incidents = state.incident_manager.query_incidents(query).await;
392 let incidents: Vec<_> =
393 all_incidents.into_iter().filter(|inc| incident_ids.contains(&inc.id)).collect();
394
395 match handler.generate_pr_from_incidents(&incidents).await {
396 Ok(Some(pr_result)) => {
397 #[cfg(feature = "pipelines")]
399 {
400 use mockforge_pipelines::{publish_event, PipelineEvent};
401 use uuid::Uuid;
402
403 let workspace_id = incidents
405 .first()
406 .and_then(|inc| inc.workspace_id.as_ref())
407 .and_then(|ws_id| Uuid::parse_str(ws_id).ok());
408
409 let threshold_exceeded_count = incidents
411 .iter()
412 .filter(|inc| matches!(inc.incident_type, IncidentType::ThresholdExceeded))
413 .count();
414
415 if threshold_exceeded_count > 0 {
416 let endpoint = incidents
418 .first()
419 .map(|inc| format!("{} {}", inc.method, inc.endpoint))
420 .unwrap_or_else(|| "unknown".to_string());
421
422 let event = PipelineEvent::drift_threshold_exceeded(
423 workspace_id.unwrap_or_else(Uuid::new_v4),
424 endpoint,
425 threshold_exceeded_count as i32,
426 incidents.len() as i32,
427 );
428
429 if let Err(e) = publish_event(event) {
430 tracing::warn!(
431 "Failed to publish drift threshold exceeded event: {}",
432 e
433 );
434 }
435 }
436 }
437
438 Ok(Json(serde_json::json!({
439 "success": true,
440 "pr": pr_result,
441 })))
442 }
443 Ok(None) => Ok(Json(serde_json::json!({
444 "success": false,
445 "message": "No PR generated (no file changes or incidents)",
446 }))),
447 Err(_e) => Err(StatusCode::INTERNAL_SERVER_ERROR),
448 }
449 } else {
450 let workspace_id_str = request.workspace_id.clone();
452 #[cfg(not(feature = "pipelines"))]
453 let _ = &workspace_id_str;
454 query.workspace_id = request.workspace_id;
455 if let Some(status_str) = &request.status {
456 query.status = match status_str.as_str() {
457 "open" => Some(IncidentStatus::Open),
458 "acknowledged" => Some(IncidentStatus::Acknowledged),
459 _ => None,
460 };
461 }
462
463 let incidents = state.incident_manager.query_incidents(query).await;
464
465 match handler.generate_pr_from_incidents(&incidents).await {
466 Ok(Some(pr_result)) => {
467 #[cfg(feature = "pipelines")]
469 {
470 use mockforge_pipelines::{publish_event, PipelineEvent};
471 use uuid::Uuid;
472
473 let workspace_id = workspace_id_str
475 .as_ref()
476 .and_then(|ws_id| Uuid::parse_str(ws_id).ok())
477 .or_else(|| {
478 incidents
479 .first()
480 .and_then(|inc| inc.workspace_id.as_ref())
481 .and_then(|ws_id| Uuid::parse_str(ws_id).ok())
482 })
483 .unwrap_or_else(Uuid::new_v4);
484
485 let threshold_exceeded_count = incidents
487 .iter()
488 .filter(|inc| matches!(inc.incident_type, IncidentType::ThresholdExceeded))
489 .count();
490
491 if threshold_exceeded_count > 0 {
492 let endpoint = incidents
494 .first()
495 .map(|inc| format!("{} {}", inc.method, inc.endpoint))
496 .unwrap_or_else(|| "unknown".to_string());
497
498 let event = PipelineEvent::drift_threshold_exceeded(
499 workspace_id,
500 endpoint,
501 threshold_exceeded_count as i32,
502 incidents.len() as i32,
503 );
504
505 if let Err(e) = publish_event(event) {
506 tracing::warn!(
507 "Failed to publish drift threshold exceeded event: {}",
508 e
509 );
510 }
511 }
512 }
513
514 Ok(Json(serde_json::json!({
515 "success": true,
516 "pr": pr_result,
517 "incidents_included": incidents.len(),
518 })))
519 }
520 Ok(None) => Ok(Json(serde_json::json!({
521 "success": false,
522 "message": "No PR generated (no file changes or incidents)",
523 }))),
524 Err(_e) => Err(StatusCode::INTERNAL_SERVER_ERROR),
525 }
526 }
527}
528
529#[derive(Debug, Deserialize)]
533pub struct GetMetricsQuery {
534 pub endpoint: Option<String>,
536 pub method: Option<String>,
538 pub workspace_id: Option<String>,
540 pub days: Option<u32>,
542}
543
544pub async fn get_drift_metrics(
548 State(state): State<DriftBudgetState>,
549 Query(params): Query<GetMetricsQuery>,
550) -> Result<Json<serde_json::Value>, StatusCode> {
551 let mut query = IncidentQuery {
553 endpoint: params.endpoint,
554 method: params.method,
555 workspace_id: params.workspace_id,
556 ..IncidentQuery::default()
557 };
558
559 if let Some(days) = params.days {
561 let start_date = chrono::Utc::now()
562 .checked_sub_signed(chrono::Duration::days(days as i64))
563 .map(|dt| dt.timestamp())
564 .unwrap_or(0);
565 query.start_date = Some(start_date);
566 }
567
568 let incidents = state.incident_manager.query_incidents(query).await;
569
570 let total_incidents = incidents.len();
572 let breaking_changes = incidents
573 .iter()
574 .filter(|i| matches!(i.incident_type, IncidentType::BreakingChange))
575 .count();
576 let threshold_exceeded = total_incidents - breaking_changes;
577
578 let by_severity: HashMap<String, usize> =
579 incidents.iter().fold(HashMap::new(), |mut acc, inc| {
580 let key = format!("{:?}", inc.severity).to_lowercase();
581 *acc.entry(key).or_insert(0) += 1;
582 acc
583 });
584
585 Ok(Json(serde_json::json!({
586 "total_incidents": total_incidents,
587 "breaking_changes": breaking_changes,
588 "threshold_exceeded": threshold_exceeded,
589 "by_severity": by_severity,
590 "incidents": incidents.iter().take(100).collect::<Vec<_>>(), })))
592}
593
594pub async fn list_incidents(
598 State(state): State<DriftBudgetState>,
599 Query(params): Query<HashMap<String, String>>,
600) -> Result<Json<ListIncidentsResponse>, StatusCode> {
601 let mut query = IncidentQuery::default();
602
603 if let Some(status_str) = params.get("status") {
604 query.status = match status_str.as_str() {
605 "open" => Some(IncidentStatus::Open),
606 "acknowledged" => Some(IncidentStatus::Acknowledged),
607 "resolved" => Some(IncidentStatus::Resolved),
608 "closed" => Some(IncidentStatus::Closed),
609 _ => None,
610 };
611 }
612
613 if let Some(severity_str) = params.get("severity") {
614 query.severity = match severity_str.as_str() {
615 "critical" => Some(IncidentSeverity::Critical),
616 "high" => Some(IncidentSeverity::High),
617 "medium" => Some(IncidentSeverity::Medium),
618 "low" => Some(IncidentSeverity::Low),
619 _ => None,
620 };
621 }
622
623 if let Some(endpoint) = params.get("endpoint") {
624 query.endpoint = Some(endpoint.clone());
625 }
626
627 if let Some(method) = params.get("method") {
628 query.method = Some(method.clone());
629 }
630
631 if let Some(incident_type_str) = params.get("incident_type") {
632 query.incident_type = match incident_type_str.as_str() {
633 "breaking_change" => Some(IncidentType::BreakingChange),
634 "threshold_exceeded" => Some(IncidentType::ThresholdExceeded),
635 _ => None,
636 };
637 }
638
639 if let Some(workspace_id) = params.get("workspace_id") {
640 query.workspace_id = Some(workspace_id.clone());
641 }
642
643 if let Some(limit_str) = params.get("limit") {
644 if let Ok(limit) = limit_str.parse() {
645 query.limit = Some(limit);
646 }
647 }
648
649 if let Some(offset_str) = params.get("offset") {
650 if let Ok(offset) = offset_str.parse() {
651 query.offset = Some(offset);
652 }
653 }
654
655 let incidents = state.incident_manager.query_incidents(query).await;
656 let total = incidents.len();
657
658 Ok(Json(ListIncidentsResponse { incidents, total }))
659}
660
661pub async fn get_incident(
665 State(state): State<DriftBudgetState>,
666 Path(id): Path<String>,
667) -> Result<Json<DriftIncident>, StatusCode> {
668 state
669 .incident_manager
670 .get_incident(&id)
671 .await
672 .map(Json)
673 .ok_or(StatusCode::NOT_FOUND)
674}
675
676pub async fn update_incident(
680 State(state): State<DriftBudgetState>,
681 Path(id): Path<String>,
682 Json(request): Json<UpdateIncidentRequest>,
683) -> Result<Json<DriftIncident>, StatusCode> {
684 let mut incident =
685 state.incident_manager.get_incident(&id).await.ok_or(StatusCode::NOT_FOUND)?;
686
687 if let Some(status_str) = request.status {
688 match status_str.as_str() {
689 "acknowledged" => {
690 incident = state
691 .incident_manager
692 .acknowledge_incident(&id)
693 .await
694 .ok_or(StatusCode::NOT_FOUND)?;
695 }
696 "resolved" => {
697 incident = state
698 .incident_manager
699 .resolve_incident(&id)
700 .await
701 .ok_or(StatusCode::NOT_FOUND)?;
702 }
703 "closed" => {
704 incident = state
705 .incident_manager
706 .close_incident(&id)
707 .await
708 .ok_or(StatusCode::NOT_FOUND)?;
709 }
710 other => {
711 tracing::warn!(
712 "Invalid incident status '{}': expected acknowledged, resolved, or closed",
713 other
714 );
715 return Err(StatusCode::BAD_REQUEST);
716 }
717 }
718 }
719
720 if let Some(ticket_id) = request.external_ticket_id {
721 incident = state
722 .incident_manager
723 .link_external_ticket(&id, ticket_id, request.external_ticket_url)
724 .await
725 .ok_or(StatusCode::NOT_FOUND)?;
726 }
727
728 Ok(Json(incident))
729}
730
731pub async fn resolve_incident(
735 State(state): State<DriftBudgetState>,
736 Path(id): Path<String>,
737 Json(_request): Json<ResolveIncidentRequest>,
738) -> Result<Json<DriftIncident>, StatusCode> {
739 state
740 .incident_manager
741 .resolve_incident(&id)
742 .await
743 .map(Json)
744 .ok_or(StatusCode::NOT_FOUND)
745}
746
747pub async fn get_incident_stats(
751 State(state): State<DriftBudgetState>,
752) -> Result<Json<serde_json::Value>, StatusCode> {
753 let stats = state.incident_manager.get_statistics().await;
754 Ok(Json(serde_json::json!({
755 "stats": stats
756 })))
757}
758
759pub fn drift_budget_router(state: DriftBudgetState) -> axum::Router {
761 use axum::{
762 routing::{get, patch, post},
763 Router,
764 };
765
766 Router::new()
767 .route("/api/v1/drift/budgets", post(create_budget))
768 .route("/api/v1/drift/budgets", get(list_budgets))
769 .route("/api/v1/drift/budgets/lookup", get(get_budget_for_endpoint))
770 .route("/api/v1/drift/budgets/workspace", post(create_workspace_budget))
771 .route("/api/v1/drift/budgets/service", post(create_service_budget))
772 .route("/api/v1/drift/budgets/{id}", get(get_budget))
773 .route("/api/v1/drift/incidents", get(list_incidents))
774 .route("/api/v1/drift/incidents/stats", get(get_incident_stats))
775 .route("/api/v1/drift/incidents/{id}", get(get_incident))
776 .route("/api/v1/drift/incidents/{id}", patch(update_incident))
777 .route("/api/v1/drift/incidents/{id}/resolve", post(resolve_incident))
778 .route("/api/v1/drift/gitops/generate-pr", post(generate_gitops_pr))
779 .route("/api/v1/drift/metrics", get(get_drift_metrics))
780 .with_state(state)
781}