1#![allow(deprecated)]
7
8use axum::{
9 extract::{Path, Query, State},
10 http::StatusCode,
11 response::Json,
12};
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::sync::Arc;
16
17use chrono;
18use mockforge_core::contract_drift::{DriftBudget, DriftBudgetEngine};
19use mockforge_core::incidents::types::DriftIncident;
20use mockforge_core::incidents::{
21 IncidentManager, IncidentQuery, IncidentSeverity, IncidentStatus, IncidentType,
22};
23
24#[derive(Clone)]
26pub struct DriftBudgetState {
27 pub engine: Arc<DriftBudgetEngine>,
29 pub incident_manager: Arc<IncidentManager>,
31 pub gitops_handler: Option<Arc<mockforge_core::drift_gitops::DriftGitOpsHandler>>,
33}
34
35#[derive(Debug, Deserialize, Serialize)]
37pub struct CreateDriftBudgetRequest {
38 pub endpoint: String,
40 pub method: String,
42 pub max_breaking_changes: Option<u32>,
44 pub max_non_breaking_changes: Option<u32>,
46 pub severity_threshold: Option<String>,
48 pub enabled: Option<bool>,
50 pub workspace_id: Option<String>,
52}
53
54#[derive(Debug, Serialize)]
56pub struct DriftBudgetResponse {
57 pub id: String,
59 pub endpoint: String,
61 pub method: String,
63 pub budget: DriftBudget,
65 pub workspace_id: Option<String>,
67}
68
69#[derive(Debug, Deserialize)]
71pub struct ListIncidentsRequest {
72 pub status: Option<String>,
74 pub severity: Option<String>,
76 pub endpoint: Option<String>,
78 pub method: Option<String>,
80 pub incident_type: Option<String>,
82 pub workspace_id: Option<String>,
84 pub limit: Option<usize>,
86 pub offset: Option<usize>,
88}
89
90#[derive(Debug, Serialize)]
92pub struct ListIncidentsResponse {
93 pub incidents: Vec<DriftIncident>,
95 pub total: usize,
97}
98
99#[derive(Debug, Deserialize)]
101pub struct UpdateIncidentRequest {
102 pub status: Option<String>,
104 pub external_ticket_id: Option<String>,
106 pub external_ticket_url: Option<String>,
108}
109
110#[derive(Debug, Deserialize)]
112pub struct ResolveIncidentRequest {
113 pub note: Option<String>,
115}
116
117pub async fn create_budget(
121 State(_state): State<DriftBudgetState>,
122 Json(request): Json<CreateDriftBudgetRequest>,
123) -> Result<Json<DriftBudgetResponse>, StatusCode> {
124 let budget = DriftBudget {
125 max_breaking_changes: request.max_breaking_changes.unwrap_or(0),
126 max_non_breaking_changes: request.max_non_breaking_changes.unwrap_or(10),
127 max_field_churn_percent: None,
128 time_window_days: None,
129 severity_threshold: request
130 .severity_threshold
131 .as_deref()
132 .and_then(|s| match s.to_lowercase().as_str() {
133 "critical" => Some(mockforge_core::ai_contract_diff::MismatchSeverity::Critical),
134 "high" => Some(mockforge_core::ai_contract_diff::MismatchSeverity::High),
135 "medium" => Some(mockforge_core::ai_contract_diff::MismatchSeverity::Medium),
136 "low" => Some(mockforge_core::ai_contract_diff::MismatchSeverity::Low),
137 _ => None,
138 })
139 .unwrap_or(mockforge_core::ai_contract_diff::MismatchSeverity::High),
140 enabled: request.enabled.unwrap_or(true),
141 };
142
143 let budget_id = format!("{}:{}:{}", request.method, request.endpoint, uuid::Uuid::new_v4());
145
146 let key = format!("{} {}", request.method, request.endpoint);
148
149 let _ = key;
153
154 Ok(Json(DriftBudgetResponse {
155 id: budget_id,
156 endpoint: request.endpoint,
157 method: request.method,
158 budget,
159 workspace_id: request.workspace_id,
160 }))
161}
162
163pub async fn list_budgets(
167 State(state): State<DriftBudgetState>,
168) -> Result<Json<serde_json::Value>, StatusCode> {
169 let config = state.engine.config();
170 let budgets: Vec<serde_json::Value> = config
171 .per_endpoint_budgets
172 .iter()
173 .map(|(key, budget)| {
174 let parts: Vec<&str> = key.splitn(2, ' ').collect();
176 let (method, endpoint) = if parts.len() == 2 {
177 (parts[0].to_string(), parts[1].to_string())
178 } else {
179 ("GET".to_string(), key.clone())
180 };
181 serde_json::json!({
182 "id": key,
183 "method": method,
184 "endpoint": endpoint,
185 "budget": {
186 "max_breaking_changes": budget.max_breaking_changes,
187 "max_non_breaking_changes": budget.max_non_breaking_changes,
188 "enabled": budget.enabled,
189 }
190 })
191 })
192 .collect();
193
194 Ok(Json(serde_json::json!({
195 "budgets": budgets,
196 "total": budgets.len(),
197 })))
198}
199
200pub async fn get_budget(
204 State(state): State<DriftBudgetState>,
205 Path(id): Path<String>,
206) -> Result<Json<serde_json::Value>, StatusCode> {
207 let config = state.engine.config();
208 if let Some(budget) = config.per_endpoint_budgets.get(&id) {
209 let parts: Vec<&str> = id.splitn(2, ' ').collect();
210 let (method, endpoint) = if parts.len() == 2 {
211 (parts[0].to_string(), parts[1].to_string())
212 } else {
213 ("GET".to_string(), id.clone())
214 };
215 Ok(Json(serde_json::json!({
216 "id": id,
217 "method": method,
218 "endpoint": endpoint,
219 "budget": {
220 "max_breaking_changes": budget.max_breaking_changes,
221 "max_non_breaking_changes": budget.max_non_breaking_changes,
222 "enabled": budget.enabled,
223 }
224 })))
225 } else {
226 Err(StatusCode::NOT_FOUND)
227 }
228}
229
230#[derive(Debug, Deserialize)]
234pub struct GetBudgetQuery {
235 pub endpoint: String,
237 pub method: String,
239 pub workspace_id: Option<String>,
241 pub service_name: Option<String>,
243 pub tags: Option<String>,
245}
246
247pub async fn get_budget_for_endpoint(
251 State(state): State<DriftBudgetState>,
252 Query(params): Query<GetBudgetQuery>,
253) -> Result<Json<serde_json::Value>, StatusCode> {
254 let tags = params
255 .tags
256 .as_ref()
257 .map(|t| t.split(',').map(|s| s.trim().to_string()).collect::<Vec<_>>());
258
259 let budget = state.engine.get_budget_for_endpoint(
260 ¶ms.endpoint,
261 ¶ms.method,
262 params.workspace_id.as_deref(),
263 params.service_name.as_deref(),
264 tags.as_deref(),
265 );
266
267 Ok(Json(serde_json::json!({
268 "endpoint": params.endpoint,
269 "method": params.method,
270 "workspace_id": params.workspace_id,
271 "service_name": params.service_name,
272 "budget": budget,
273 })))
274}
275
276#[derive(Debug, Deserialize, Serialize)]
278pub struct CreateWorkspaceBudgetRequest {
279 pub workspace_id: String,
281 pub max_breaking_changes: Option<u32>,
283 pub max_non_breaking_changes: Option<u32>,
285 pub max_field_churn_percent: Option<f64>,
287 pub time_window_days: Option<u32>,
289 pub enabled: Option<bool>,
291}
292
293#[derive(Debug, Deserialize, Serialize)]
295pub struct CreateServiceBudgetRequest {
296 pub service_name: String,
298 pub max_breaking_changes: Option<u32>,
300 pub max_non_breaking_changes: Option<u32>,
302 pub max_field_churn_percent: Option<f64>,
304 pub time_window_days: Option<u32>,
306 pub enabled: Option<bool>,
308}
309
310pub async fn create_workspace_budget(
314 State(state): State<DriftBudgetState>,
315 Json(request): Json<CreateWorkspaceBudgetRequest>,
316) -> Result<Json<serde_json::Value>, StatusCode> {
317 let budget = DriftBudget {
318 max_breaking_changes: request.max_breaking_changes.unwrap_or(0),
319 max_non_breaking_changes: request.max_non_breaking_changes.unwrap_or(10),
320 max_field_churn_percent: request.max_field_churn_percent,
321 time_window_days: request.time_window_days,
322 severity_threshold: mockforge_core::ai_contract_diff::MismatchSeverity::High,
323 enabled: request.enabled.unwrap_or(true),
324 };
325
326 let mut config = state.engine.config().clone();
327 config
328 .per_workspace_budgets
329 .insert(request.workspace_id.clone(), budget.clone());
330
331 Ok(Json(serde_json::json!({
335 "workspace_id": request.workspace_id,
336 "budget": budget,
337 })))
338}
339
340pub async fn create_service_budget(
344 State(state): State<DriftBudgetState>,
345 Json(request): Json<CreateServiceBudgetRequest>,
346) -> Result<Json<serde_json::Value>, StatusCode> {
347 let budget = DriftBudget {
348 max_breaking_changes: request.max_breaking_changes.unwrap_or(0),
349 max_non_breaking_changes: request.max_non_breaking_changes.unwrap_or(10),
350 max_field_churn_percent: request.max_field_churn_percent,
351 time_window_days: request.time_window_days,
352 severity_threshold: mockforge_core::ai_contract_diff::MismatchSeverity::High,
353 enabled: request.enabled.unwrap_or(true),
354 };
355
356 let mut config = state.engine.config().clone();
357 config.per_service_budgets.insert(request.service_name.clone(), budget.clone());
358
359 Ok(Json(serde_json::json!({
363 "service_name": request.service_name,
364 "budget": budget,
365 })))
366}
367
368#[derive(Debug, Deserialize)]
370pub struct GeneratePRRequest {
371 pub incident_ids: Option<Vec<String>>,
373 pub workspace_id: Option<String>,
375 pub status: Option<String>,
377}
378
379pub async fn generate_gitops_pr(
383 State(state): State<DriftBudgetState>,
384 Json(request): Json<GeneratePRRequest>,
385) -> Result<Json<serde_json::Value>, StatusCode> {
386 let handler = state.gitops_handler.as_ref().ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
387
388 let mut query = IncidentQuery::default();
390
391 if let Some(incident_ids) = &request.incident_ids {
392 let all_incidents = state.incident_manager.query_incidents(query).await;
395 let incidents: Vec<_> =
396 all_incidents.into_iter().filter(|inc| incident_ids.contains(&inc.id)).collect();
397
398 match handler.generate_pr_from_incidents(&incidents).await {
399 Ok(Some(pr_result)) => {
400 #[cfg(feature = "pipelines")]
402 {
403 use mockforge_pipelines::{publish_event, PipelineEvent};
404 use uuid::Uuid;
405
406 let workspace_id = incidents
408 .first()
409 .and_then(|inc| inc.workspace_id.as_ref())
410 .and_then(|ws_id| Uuid::parse_str(ws_id).ok());
411
412 let threshold_exceeded_count = incidents
414 .iter()
415 .filter(|inc| matches!(inc.incident_type, IncidentType::ThresholdExceeded))
416 .count();
417
418 if threshold_exceeded_count > 0 {
419 let endpoint = incidents
421 .first()
422 .map(|inc| format!("{} {}", inc.method, inc.endpoint))
423 .unwrap_or_else(|| "unknown".to_string());
424
425 let event = PipelineEvent::drift_threshold_exceeded(
426 workspace_id.unwrap_or_else(Uuid::new_v4),
427 endpoint,
428 threshold_exceeded_count as i32,
429 incidents.len() as i32,
430 );
431
432 if let Err(e) = publish_event(event) {
433 tracing::warn!(
434 "Failed to publish drift threshold exceeded event: {}",
435 e
436 );
437 }
438 }
439 }
440
441 Ok(Json(serde_json::json!({
442 "success": true,
443 "pr": pr_result,
444 })))
445 }
446 Ok(None) => Ok(Json(serde_json::json!({
447 "success": false,
448 "message": "No PR generated (no file changes or incidents)",
449 }))),
450 Err(_e) => Err(StatusCode::INTERNAL_SERVER_ERROR),
451 }
452 } else {
453 let workspace_id_str = request.workspace_id.clone();
455 #[cfg(not(feature = "pipelines"))]
456 let _ = &workspace_id_str;
457 query.workspace_id = request.workspace_id;
458 if let Some(status_str) = &request.status {
459 query.status = match status_str.as_str() {
460 "open" => Some(IncidentStatus::Open),
461 "acknowledged" => Some(IncidentStatus::Acknowledged),
462 _ => None,
463 };
464 }
465
466 let incidents = state.incident_manager.query_incidents(query).await;
467
468 match handler.generate_pr_from_incidents(&incidents).await {
469 Ok(Some(pr_result)) => {
470 #[cfg(feature = "pipelines")]
472 {
473 use mockforge_pipelines::{publish_event, PipelineEvent};
474 use uuid::Uuid;
475
476 let workspace_id = workspace_id_str
478 .as_ref()
479 .and_then(|ws_id| Uuid::parse_str(ws_id).ok())
480 .or_else(|| {
481 incidents
482 .first()
483 .and_then(|inc| inc.workspace_id.as_ref())
484 .and_then(|ws_id| Uuid::parse_str(ws_id).ok())
485 })
486 .unwrap_or_else(Uuid::new_v4);
487
488 let threshold_exceeded_count = incidents
490 .iter()
491 .filter(|inc| matches!(inc.incident_type, IncidentType::ThresholdExceeded))
492 .count();
493
494 if threshold_exceeded_count > 0 {
495 let endpoint = incidents
497 .first()
498 .map(|inc| format!("{} {}", inc.method, inc.endpoint))
499 .unwrap_or_else(|| "unknown".to_string());
500
501 let event = PipelineEvent::drift_threshold_exceeded(
502 workspace_id,
503 endpoint,
504 threshold_exceeded_count as i32,
505 incidents.len() as i32,
506 );
507
508 if let Err(e) = publish_event(event) {
509 tracing::warn!(
510 "Failed to publish drift threshold exceeded event: {}",
511 e
512 );
513 }
514 }
515 }
516
517 Ok(Json(serde_json::json!({
518 "success": true,
519 "pr": pr_result,
520 "incidents_included": incidents.len(),
521 })))
522 }
523 Ok(None) => Ok(Json(serde_json::json!({
524 "success": false,
525 "message": "No PR generated (no file changes or incidents)",
526 }))),
527 Err(_e) => Err(StatusCode::INTERNAL_SERVER_ERROR),
528 }
529 }
530}
531
532#[derive(Debug, Deserialize)]
536pub struct GetMetricsQuery {
537 pub endpoint: Option<String>,
539 pub method: Option<String>,
541 pub workspace_id: Option<String>,
543 pub days: Option<u32>,
545}
546
547pub async fn get_drift_metrics(
551 State(state): State<DriftBudgetState>,
552 Query(params): Query<GetMetricsQuery>,
553) -> Result<Json<serde_json::Value>, StatusCode> {
554 let mut query = IncidentQuery {
556 endpoint: params.endpoint,
557 method: params.method,
558 workspace_id: params.workspace_id,
559 ..IncidentQuery::default()
560 };
561
562 if let Some(days) = params.days {
564 let start_date = chrono::Utc::now()
565 .checked_sub_signed(chrono::Duration::days(days as i64))
566 .map(|dt| dt.timestamp())
567 .unwrap_or(0);
568 query.start_date = Some(start_date);
569 }
570
571 let incidents = state.incident_manager.query_incidents(query).await;
572
573 let total_incidents = incidents.len();
575 let breaking_changes = incidents
576 .iter()
577 .filter(|i| matches!(i.incident_type, IncidentType::BreakingChange))
578 .count();
579 let threshold_exceeded = total_incidents - breaking_changes;
580
581 let by_severity: HashMap<String, usize> =
582 incidents.iter().fold(HashMap::new(), |mut acc, inc| {
583 let key = format!("{:?}", inc.severity).to_lowercase();
584 *acc.entry(key).or_insert(0) += 1;
585 acc
586 });
587
588 Ok(Json(serde_json::json!({
589 "total_incidents": total_incidents,
590 "breaking_changes": breaking_changes,
591 "threshold_exceeded": threshold_exceeded,
592 "by_severity": by_severity,
593 "incidents": incidents.iter().take(100).collect::<Vec<_>>(), })))
595}
596
597pub async fn list_incidents(
601 State(state): State<DriftBudgetState>,
602 Query(params): Query<HashMap<String, String>>,
603) -> Result<Json<ListIncidentsResponse>, StatusCode> {
604 let mut query = IncidentQuery::default();
605
606 if let Some(status_str) = params.get("status") {
607 query.status = match status_str.as_str() {
608 "open" => Some(IncidentStatus::Open),
609 "acknowledged" => Some(IncidentStatus::Acknowledged),
610 "resolved" => Some(IncidentStatus::Resolved),
611 "closed" => Some(IncidentStatus::Closed),
612 _ => None,
613 };
614 }
615
616 if let Some(severity_str) = params.get("severity") {
617 query.severity = match severity_str.as_str() {
618 "critical" => Some(IncidentSeverity::Critical),
619 "high" => Some(IncidentSeverity::High),
620 "medium" => Some(IncidentSeverity::Medium),
621 "low" => Some(IncidentSeverity::Low),
622 _ => None,
623 };
624 }
625
626 if let Some(endpoint) = params.get("endpoint") {
627 query.endpoint = Some(endpoint.clone());
628 }
629
630 if let Some(method) = params.get("method") {
631 query.method = Some(method.clone());
632 }
633
634 if let Some(incident_type_str) = params.get("incident_type") {
635 query.incident_type = match incident_type_str.as_str() {
636 "breaking_change" => Some(IncidentType::BreakingChange),
637 "threshold_exceeded" => Some(IncidentType::ThresholdExceeded),
638 _ => None,
639 };
640 }
641
642 if let Some(workspace_id) = params.get("workspace_id") {
643 query.workspace_id = Some(workspace_id.clone());
644 }
645
646 if let Some(limit_str) = params.get("limit") {
647 if let Ok(limit) = limit_str.parse() {
648 query.limit = Some(limit);
649 }
650 }
651
652 if let Some(offset_str) = params.get("offset") {
653 if let Ok(offset) = offset_str.parse() {
654 query.offset = Some(offset);
655 }
656 }
657
658 let incidents = state.incident_manager.query_incidents(query).await;
659 let total = incidents.len();
660
661 Ok(Json(ListIncidentsResponse { incidents, total }))
662}
663
664pub async fn get_incident(
668 State(state): State<DriftBudgetState>,
669 Path(id): Path<String>,
670) -> Result<Json<DriftIncident>, StatusCode> {
671 state
672 .incident_manager
673 .get_incident(&id)
674 .await
675 .map(Json)
676 .ok_or(StatusCode::NOT_FOUND)
677}
678
679pub async fn update_incident(
683 State(state): State<DriftBudgetState>,
684 Path(id): Path<String>,
685 Json(request): Json<UpdateIncidentRequest>,
686) -> Result<Json<DriftIncident>, StatusCode> {
687 let mut incident =
688 state.incident_manager.get_incident(&id).await.ok_or(StatusCode::NOT_FOUND)?;
689
690 if let Some(status_str) = request.status {
691 match status_str.as_str() {
692 "acknowledged" => {
693 incident = state
694 .incident_manager
695 .acknowledge_incident(&id)
696 .await
697 .ok_or(StatusCode::NOT_FOUND)?;
698 }
699 "resolved" => {
700 incident = state
701 .incident_manager
702 .resolve_incident(&id)
703 .await
704 .ok_or(StatusCode::NOT_FOUND)?;
705 }
706 "closed" => {
707 incident = state
708 .incident_manager
709 .close_incident(&id)
710 .await
711 .ok_or(StatusCode::NOT_FOUND)?;
712 }
713 other => {
714 tracing::warn!(
715 "Invalid incident status '{}': expected acknowledged, resolved, or closed",
716 other
717 );
718 return Err(StatusCode::BAD_REQUEST);
719 }
720 }
721 }
722
723 if let Some(ticket_id) = request.external_ticket_id {
724 incident = state
725 .incident_manager
726 .link_external_ticket(&id, ticket_id, request.external_ticket_url)
727 .await
728 .ok_or(StatusCode::NOT_FOUND)?;
729 }
730
731 Ok(Json(incident))
732}
733
734pub async fn resolve_incident(
738 State(state): State<DriftBudgetState>,
739 Path(id): Path<String>,
740 Json(_request): Json<ResolveIncidentRequest>,
741) -> Result<Json<DriftIncident>, StatusCode> {
742 state
743 .incident_manager
744 .resolve_incident(&id)
745 .await
746 .map(Json)
747 .ok_or(StatusCode::NOT_FOUND)
748}
749
750pub async fn get_incident_stats(
754 State(state): State<DriftBudgetState>,
755) -> Result<Json<serde_json::Value>, StatusCode> {
756 let stats = state.incident_manager.get_statistics().await;
757 Ok(Json(serde_json::json!({
758 "stats": stats
759 })))
760}
761
762pub fn drift_budget_router(state: DriftBudgetState) -> axum::Router {
764 use axum::{
765 routing::{get, patch, post},
766 Router,
767 };
768
769 Router::new()
770 .route("/api/v1/drift/budgets", post(create_budget))
771 .route("/api/v1/drift/budgets", get(list_budgets))
772 .route("/api/v1/drift/budgets/lookup", get(get_budget_for_endpoint))
773 .route("/api/v1/drift/budgets/workspace", post(create_workspace_budget))
774 .route("/api/v1/drift/budgets/service", post(create_service_budget))
775 .route("/api/v1/drift/budgets/{id}", get(get_budget))
776 .route("/api/v1/drift/incidents", get(list_incidents))
777 .route("/api/v1/drift/incidents/stats", get(get_incident_stats))
778 .route("/api/v1/drift/incidents/{id}", get(get_incident))
779 .route("/api/v1/drift/incidents/{id}", patch(update_incident))
780 .route("/api/v1/drift/incidents/{id}/resolve", post(resolve_incident))
781 .route("/api/v1/drift/gitops/generate-pr", post(generate_gitops_pr))
782 .route("/api/v1/drift/metrics", get(get_drift_metrics))
783 .with_state(state)
784}