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::{
16 DriftBudget, DriftBudgetConfig, DriftBudgetEngine, DriftResult,
17};
18use mockforge_core::incidents::types::DriftIncident;
19use mockforge_core::incidents::{
20 IncidentManager, IncidentQuery, IncidentSeverity, IncidentStatus, IncidentType,
21};
22
23#[derive(Clone)]
25pub struct DriftBudgetState {
26 pub engine: Arc<DriftBudgetEngine>,
28 pub incident_manager: Arc<IncidentManager>,
30 pub gitops_handler: Option<Arc<mockforge_core::drift_gitops::DriftGitOpsHandler>>,
32}
33
34#[derive(Debug, Deserialize, Serialize)]
36pub struct CreateDriftBudgetRequest {
37 pub endpoint: String,
39 pub method: String,
41 pub max_breaking_changes: Option<u32>,
43 pub max_non_breaking_changes: Option<u32>,
45 pub severity_threshold: Option<String>,
47 pub enabled: Option<bool>,
49 pub workspace_id: Option<String>,
51}
52
53#[derive(Debug, Serialize)]
55pub struct DriftBudgetResponse {
56 pub id: String,
58 pub endpoint: String,
60 pub method: String,
62 pub budget: DriftBudget,
64 pub workspace_id: Option<String>,
66}
67
68#[derive(Debug, Deserialize)]
70pub struct ListIncidentsRequest {
71 pub status: Option<String>,
73 pub severity: Option<String>,
75 pub endpoint: Option<String>,
77 pub method: Option<String>,
79 pub incident_type: Option<String>,
81 pub workspace_id: Option<String>,
83 pub limit: Option<usize>,
85 pub offset: Option<usize>,
87}
88
89#[derive(Debug, Serialize)]
91pub struct ListIncidentsResponse {
92 pub incidents: Vec<DriftIncident>,
94 pub total: usize,
96}
97
98#[derive(Debug, Deserialize)]
100pub struct UpdateIncidentRequest {
101 pub status: Option<String>,
103 pub external_ticket_id: Option<String>,
105 pub external_ticket_url: Option<String>,
107}
108
109#[derive(Debug, Deserialize)]
111pub struct ResolveIncidentRequest {
112 pub note: Option<String>,
114}
115
116pub async fn create_budget(
120 State(state): State<DriftBudgetState>,
121 Json(request): Json<CreateDriftBudgetRequest>,
122) -> Result<Json<DriftBudgetResponse>, StatusCode> {
123 let budget = DriftBudget {
124 max_breaking_changes: request.max_breaking_changes.unwrap_or(0),
125 max_non_breaking_changes: request.max_non_breaking_changes.unwrap_or(10),
126 max_field_churn_percent: None,
127 time_window_days: None,
128 severity_threshold: request
129 .severity_threshold
130 .as_deref()
131 .and_then(|s| match s.to_lowercase().as_str() {
132 "critical" => Some(mockforge_core::ai_contract_diff::MismatchSeverity::Critical),
133 "high" => Some(mockforge_core::ai_contract_diff::MismatchSeverity::High),
134 "medium" => Some(mockforge_core::ai_contract_diff::MismatchSeverity::Medium),
135 "low" => Some(mockforge_core::ai_contract_diff::MismatchSeverity::Low),
136 _ => None,
137 })
138 .unwrap_or(mockforge_core::ai_contract_diff::MismatchSeverity::High),
139 enabled: request.enabled.unwrap_or(true),
140 };
141
142 let budget_id = format!("{}:{}:{}", request.method, request.endpoint, uuid::Uuid::new_v4());
144
145 let mut config = state.engine.config().clone();
147 let key = format!("{} {}", request.method, request.endpoint);
148 config.per_endpoint_budgets.insert(key, budget.clone());
149
150 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 Ok(Json(serde_json::json!({
172 "budgets": []
173 })))
174}
175
176pub async fn get_budget(
180 State(_state): State<DriftBudgetState>,
181 Path(_id): Path<String>,
182) -> Result<Json<serde_json::Value>, StatusCode> {
183 Err(StatusCode::NOT_IMPLEMENTED)
184}
185
186#[derive(Debug, Deserialize)]
190pub struct GetBudgetQuery {
191 pub endpoint: String,
193 pub method: String,
195 pub workspace_id: Option<String>,
197 pub service_name: Option<String>,
199 pub tags: Option<String>,
201}
202
203pub async fn get_budget_for_endpoint(
207 State(state): State<DriftBudgetState>,
208 Query(params): Query<GetBudgetQuery>,
209) -> Result<Json<serde_json::Value>, StatusCode> {
210 let tags = params
211 .tags
212 .as_ref()
213 .map(|t| t.split(',').map(|s| s.trim().to_string()).collect::<Vec<_>>());
214
215 let budget = state.engine.get_budget_for_endpoint(
216 ¶ms.endpoint,
217 ¶ms.method,
218 params.workspace_id.as_deref(),
219 params.service_name.as_deref(),
220 tags.as_deref(),
221 );
222
223 Ok(Json(serde_json::json!({
224 "endpoint": params.endpoint,
225 "method": params.method,
226 "workspace_id": params.workspace_id,
227 "service_name": params.service_name,
228 "budget": budget,
229 })))
230}
231
232#[derive(Debug, Deserialize, Serialize)]
234pub struct CreateWorkspaceBudgetRequest {
235 pub workspace_id: String,
237 pub max_breaking_changes: Option<u32>,
239 pub max_non_breaking_changes: Option<u32>,
241 pub max_field_churn_percent: Option<f64>,
243 pub time_window_days: Option<u32>,
245 pub enabled: Option<bool>,
247}
248
249#[derive(Debug, Deserialize, Serialize)]
251pub struct CreateServiceBudgetRequest {
252 pub service_name: String,
254 pub max_breaking_changes: Option<u32>,
256 pub max_non_breaking_changes: Option<u32>,
258 pub max_field_churn_percent: Option<f64>,
260 pub time_window_days: Option<u32>,
262 pub enabled: Option<bool>,
264}
265
266pub async fn create_workspace_budget(
270 State(state): State<DriftBudgetState>,
271 Json(request): Json<CreateWorkspaceBudgetRequest>,
272) -> Result<Json<serde_json::Value>, StatusCode> {
273 let budget = DriftBudget {
274 max_breaking_changes: request.max_breaking_changes.unwrap_or(0),
275 max_non_breaking_changes: request.max_non_breaking_changes.unwrap_or(10),
276 max_field_churn_percent: request.max_field_churn_percent,
277 time_window_days: request.time_window_days,
278 severity_threshold: mockforge_core::ai_contract_diff::MismatchSeverity::High,
279 enabled: request.enabled.unwrap_or(true),
280 };
281
282 let mut config = state.engine.config().clone();
283 config
284 .per_workspace_budgets
285 .insert(request.workspace_id.clone(), budget.clone());
286
287 Ok(Json(serde_json::json!({
291 "workspace_id": request.workspace_id,
292 "budget": budget,
293 })))
294}
295
296pub async fn create_service_budget(
300 State(state): State<DriftBudgetState>,
301 Json(request): Json<CreateServiceBudgetRequest>,
302) -> Result<Json<serde_json::Value>, StatusCode> {
303 let budget = DriftBudget {
304 max_breaking_changes: request.max_breaking_changes.unwrap_or(0),
305 max_non_breaking_changes: request.max_non_breaking_changes.unwrap_or(10),
306 max_field_churn_percent: request.max_field_churn_percent,
307 time_window_days: request.time_window_days,
308 severity_threshold: mockforge_core::ai_contract_diff::MismatchSeverity::High,
309 enabled: request.enabled.unwrap_or(true),
310 };
311
312 let mut config = state.engine.config().clone();
313 config.per_service_budgets.insert(request.service_name.clone(), budget.clone());
314
315 Ok(Json(serde_json::json!({
319 "service_name": request.service_name,
320 "budget": budget,
321 })))
322}
323
324#[derive(Debug, Deserialize)]
326pub struct GeneratePRRequest {
327 pub incident_ids: Option<Vec<String>>,
329 pub workspace_id: Option<String>,
331 pub status: Option<String>,
333}
334
335pub async fn generate_gitops_pr(
339 State(state): State<DriftBudgetState>,
340 Json(request): Json<GeneratePRRequest>,
341) -> Result<Json<serde_json::Value>, StatusCode> {
342 let handler = state.gitops_handler.as_ref().ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
343
344 let mut query = IncidentQuery::default();
346
347 if let Some(incident_ids) = &request.incident_ids {
348 let all_incidents = state.incident_manager.query_incidents(query).await;
351 let incidents: Vec<_> =
352 all_incidents.into_iter().filter(|inc| incident_ids.contains(&inc.id)).collect();
353
354 match handler.generate_pr_from_incidents(&incidents).await {
355 Ok(Some(pr_result)) => Ok(Json(serde_json::json!({
356 "success": true,
357 "pr": pr_result,
358 }))),
359 Ok(None) => Ok(Json(serde_json::json!({
360 "success": false,
361 "message": "No PR generated (no file changes or incidents)",
362 }))),
363 Err(e) => Err(StatusCode::INTERNAL_SERVER_ERROR),
364 }
365 } else {
366 query.workspace_id = request.workspace_id;
368 if let Some(status_str) = &request.status {
369 query.status = match status_str.as_str() {
370 "open" => Some(IncidentStatus::Open),
371 "acknowledged" => Some(IncidentStatus::Acknowledged),
372 _ => None,
373 };
374 }
375
376 let incidents = state.incident_manager.query_incidents(query).await;
377
378 match handler.generate_pr_from_incidents(&incidents).await {
379 Ok(Some(pr_result)) => Ok(Json(serde_json::json!({
380 "success": true,
381 "pr": pr_result,
382 "incidents_included": incidents.len(),
383 }))),
384 Ok(None) => Ok(Json(serde_json::json!({
385 "success": false,
386 "message": "No PR generated (no file changes or incidents)",
387 }))),
388 Err(e) => Err(StatusCode::INTERNAL_SERVER_ERROR),
389 }
390 }
391}
392
393#[derive(Debug, Deserialize)]
397pub struct GetMetricsQuery {
398 pub endpoint: Option<String>,
400 pub method: Option<String>,
402 pub workspace_id: Option<String>,
404 pub days: Option<u32>,
406}
407
408pub async fn get_drift_metrics(
412 State(state): State<DriftBudgetState>,
413 Query(params): Query<GetMetricsQuery>,
414) -> Result<Json<serde_json::Value>, StatusCode> {
415 let mut query = IncidentQuery::default();
417 query.endpoint = params.endpoint;
418 query.method = params.method;
419 query.workspace_id = params.workspace_id;
420
421 if let Some(days) = params.days {
423 let start_date = chrono::Utc::now()
424 .checked_sub_signed(chrono::Duration::days(days as i64))
425 .map(|dt| dt.timestamp())
426 .unwrap_or(0);
427 query.start_date = Some(start_date);
428 }
429
430 let incidents = state.incident_manager.query_incidents(query).await;
431
432 let total_incidents = incidents.len();
434 let breaking_changes = incidents
435 .iter()
436 .filter(|i| matches!(i.incident_type, IncidentType::BreakingChange))
437 .count();
438 let threshold_exceeded = total_incidents - breaking_changes;
439
440 let by_severity: std::collections::HashMap<String, usize> =
441 incidents.iter().fold(std::collections::HashMap::new(), |mut acc, inc| {
442 let key = format!("{:?}", inc.severity).to_lowercase();
443 *acc.entry(key).or_insert(0) += 1;
444 acc
445 });
446
447 Ok(Json(serde_json::json!({
448 "total_incidents": total_incidents,
449 "breaking_changes": breaking_changes,
450 "threshold_exceeded": threshold_exceeded,
451 "by_severity": by_severity,
452 "incidents": incidents.iter().take(100).collect::<Vec<_>>(), })))
454}
455
456pub async fn list_incidents(
460 State(state): State<DriftBudgetState>,
461 Query(params): Query<HashMap<String, String>>,
462) -> Result<Json<ListIncidentsResponse>, StatusCode> {
463 let mut query = IncidentQuery::default();
464
465 if let Some(status_str) = params.get("status") {
466 query.status = match status_str.as_str() {
467 "open" => Some(IncidentStatus::Open),
468 "acknowledged" => Some(IncidentStatus::Acknowledged),
469 "resolved" => Some(IncidentStatus::Resolved),
470 "closed" => Some(IncidentStatus::Closed),
471 _ => None,
472 };
473 }
474
475 if let Some(severity_str) = params.get("severity") {
476 query.severity = match severity_str.as_str() {
477 "critical" => Some(IncidentSeverity::Critical),
478 "high" => Some(IncidentSeverity::High),
479 "medium" => Some(IncidentSeverity::Medium),
480 "low" => Some(IncidentSeverity::Low),
481 _ => None,
482 };
483 }
484
485 if let Some(endpoint) = params.get("endpoint") {
486 query.endpoint = Some(endpoint.clone());
487 }
488
489 if let Some(method) = params.get("method") {
490 query.method = Some(method.clone());
491 }
492
493 if let Some(incident_type_str) = params.get("incident_type") {
494 query.incident_type = match incident_type_str.as_str() {
495 "breaking_change" => Some(IncidentType::BreakingChange),
496 "threshold_exceeded" => Some(IncidentType::ThresholdExceeded),
497 _ => None,
498 };
499 }
500
501 if let Some(workspace_id) = params.get("workspace_id") {
502 query.workspace_id = Some(workspace_id.clone());
503 }
504
505 if let Some(limit_str) = params.get("limit") {
506 if let Ok(limit) = limit_str.parse() {
507 query.limit = Some(limit);
508 }
509 }
510
511 if let Some(offset_str) = params.get("offset") {
512 if let Ok(offset) = offset_str.parse() {
513 query.offset = Some(offset);
514 }
515 }
516
517 let incidents = state.incident_manager.query_incidents(query).await;
518 let total = incidents.len();
519
520 Ok(Json(ListIncidentsResponse { incidents, total }))
521}
522
523pub async fn get_incident(
527 State(state): State<DriftBudgetState>,
528 Path(id): Path<String>,
529) -> Result<Json<DriftIncident>, StatusCode> {
530 state
531 .incident_manager
532 .get_incident(&id)
533 .await
534 .map(Json)
535 .ok_or(StatusCode::NOT_FOUND)
536}
537
538pub async fn update_incident(
542 State(state): State<DriftBudgetState>,
543 Path(id): Path<String>,
544 Json(request): Json<UpdateIncidentRequest>,
545) -> Result<Json<DriftIncident>, StatusCode> {
546 let mut incident =
547 state.incident_manager.get_incident(&id).await.ok_or(StatusCode::NOT_FOUND)?;
548
549 if let Some(status_str) = request.status {
550 match status_str.as_str() {
551 "acknowledged" => {
552 incident = state
553 .incident_manager
554 .acknowledge_incident(&id)
555 .await
556 .ok_or(StatusCode::NOT_FOUND)?;
557 }
558 "resolved" => {
559 incident = state
560 .incident_manager
561 .resolve_incident(&id)
562 .await
563 .ok_or(StatusCode::NOT_FOUND)?;
564 }
565 "closed" => {
566 incident = state
567 .incident_manager
568 .close_incident(&id)
569 .await
570 .ok_or(StatusCode::NOT_FOUND)?;
571 }
572 _ => {}
573 }
574 }
575
576 if let Some(ticket_id) = request.external_ticket_id {
577 incident = state
578 .incident_manager
579 .link_external_ticket(&id, ticket_id, request.external_ticket_url)
580 .await
581 .ok_or(StatusCode::NOT_FOUND)?;
582 }
583
584 Ok(Json(incident))
585}
586
587pub async fn resolve_incident(
591 State(state): State<DriftBudgetState>,
592 Path(id): Path<String>,
593 Json(_request): Json<ResolveIncidentRequest>,
594) -> Result<Json<DriftIncident>, StatusCode> {
595 state
596 .incident_manager
597 .resolve_incident(&id)
598 .await
599 .map(Json)
600 .ok_or(StatusCode::NOT_FOUND)
601}
602
603pub async fn get_incident_stats(
607 State(state): State<DriftBudgetState>,
608) -> Result<Json<serde_json::Value>, StatusCode> {
609 let stats = state.incident_manager.get_statistics().await;
610 Ok(Json(serde_json::json!({
611 "stats": stats
612 })))
613}
614
615pub fn drift_budget_router(state: DriftBudgetState) -> axum::Router {
617 use axum::{
618 routing::{get, patch, post},
619 Router,
620 };
621
622 Router::new()
623 .route("/api/v1/drift/budgets", post(create_budget))
624 .route("/api/v1/drift/budgets", get(list_budgets))
625 .route("/api/v1/drift/budgets/lookup", get(get_budget_for_endpoint))
626 .route("/api/v1/drift/budgets/workspace", post(create_workspace_budget))
627 .route("/api/v1/drift/budgets/service", post(create_service_budget))
628 .route("/api/v1/drift/budgets/{id}", get(get_budget))
629 .route("/api/v1/drift/incidents", get(list_incidents))
630 .route("/api/v1/drift/incidents/stats", get(get_incident_stats))
631 .route("/api/v1/drift/incidents/{id}", get(get_incident))
632 .route("/api/v1/drift/incidents/{id}", patch(update_incident))
633 .route("/api/v1/drift/incidents/{id}/resolve", post(resolve_incident))
634 .route("/api/v1/drift/gitops/generate-pr", post(generate_gitops_pr))
635 .route("/api/v1/drift/metrics", get(get_drift_metrics))
636 .with_state(state)
637}