Skip to main content

mockforge_http/handlers/
drift_budget.rs

1//! Drift budget and incident management handlers
2//!
3//! This module provides HTTP handlers for managing drift budgets and incidents.
4
5// DriftBudgetEngine / IncidentManager depend on OpenApiSpec and stay in core.
6#![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/// State for drift budget handlers
25#[derive(Clone)]
26pub struct DriftBudgetState {
27    /// Drift budget engine
28    pub engine: Arc<DriftBudgetEngine>,
29    /// Incident manager
30    pub incident_manager: Arc<IncidentManager>,
31    /// GitOps handler (optional)
32    pub gitops_handler: Option<Arc<mockforge_core::drift_gitops::DriftGitOpsHandler>>,
33}
34
35/// Request to create or update a drift budget
36#[derive(Debug, Deserialize, Serialize)]
37pub struct CreateDriftBudgetRequest {
38    /// Endpoint path
39    pub endpoint: String,
40    /// HTTP method
41    pub method: String,
42    /// Maximum breaking changes allowed
43    pub max_breaking_changes: Option<u32>,
44    /// Maximum non-breaking changes allowed
45    pub max_non_breaking_changes: Option<u32>,
46    /// Severity threshold
47    pub severity_threshold: Option<String>,
48    /// Whether enabled
49    pub enabled: Option<bool>,
50    /// Workspace ID (optional)
51    pub workspace_id: Option<String>,
52}
53
54/// Response for drift budget operations
55#[derive(Debug, Serialize)]
56pub struct DriftBudgetResponse {
57    /// Budget ID
58    pub id: String,
59    /// Endpoint
60    pub endpoint: String,
61    /// Method
62    pub method: String,
63    /// Budget configuration
64    pub budget: DriftBudget,
65    /// Workspace ID
66    pub workspace_id: Option<String>,
67}
68
69/// Request to query incidents
70#[derive(Debug, Deserialize)]
71pub struct ListIncidentsRequest {
72    /// Filter by status
73    pub status: Option<String>,
74    /// Filter by severity
75    pub severity: Option<String>,
76    /// Filter by endpoint
77    pub endpoint: Option<String>,
78    /// Filter by method
79    pub method: Option<String>,
80    /// Filter by incident type
81    pub incident_type: Option<String>,
82    /// Filter by workspace ID
83    pub workspace_id: Option<String>,
84    /// Limit results
85    pub limit: Option<usize>,
86    /// Offset for pagination
87    pub offset: Option<usize>,
88}
89
90/// Response for listing incidents
91#[derive(Debug, Serialize)]
92pub struct ListIncidentsResponse {
93    /// List of incidents
94    pub incidents: Vec<DriftIncident>,
95    /// Total count
96    pub total: usize,
97}
98
99/// Request to update incident status
100#[derive(Debug, Deserialize)]
101pub struct UpdateIncidentRequest {
102    /// New status
103    pub status: Option<String>,
104    /// External ticket ID
105    pub external_ticket_id: Option<String>,
106    /// External ticket URL
107    pub external_ticket_url: Option<String>,
108}
109
110/// Request to resolve incident
111#[derive(Debug, Deserialize)]
112pub struct ResolveIncidentRequest {
113    /// Optional resolution note
114    pub note: Option<String>,
115}
116
117/// Create or update a drift budget
118///
119/// POST /api/v1/drift/budgets
120pub 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    // Generate budget ID
144    let budget_id = format!("{}:{}:{}", request.method, request.endpoint, uuid::Uuid::new_v4());
145
146    // Build the key for this budget (matches the format used by list_budgets/get_budget)
147    let key = format!("{} {}", request.method, request.endpoint);
148
149    // Note: DriftBudgetEngine.config is not behind interior mutability (RwLock),
150    // so budget creation is returned but not persisted in the running engine.
151    // A future refactor should wrap config in RwLock for runtime mutation.
152    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
163/// List drift budgets
164///
165/// GET /api/v1/drift/budgets
166pub 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            // Key format is "METHOD /path"
175            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
200/// Get a specific drift budget
201///
202/// GET /api/v1/drift/budgets/{id}
203pub 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/// Get budget for a specific endpoint/workspace/service
231///
232/// GET /api/v1/drift/budgets/lookup?endpoint=/api/users&method=GET&workspace_id=...
233#[derive(Debug, Deserialize)]
234pub struct GetBudgetQuery {
235    /// Endpoint path
236    pub endpoint: String,
237    /// HTTP method
238    pub method: String,
239    /// Optional workspace ID
240    pub workspace_id: Option<String>,
241    /// Optional service name
242    pub service_name: Option<String>,
243    /// Optional comma-separated tags
244    pub tags: Option<String>,
245}
246
247/// Get budget for endpoint
248///
249/// GET /api/v1/drift/budgets/lookup
250pub 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        &params.endpoint,
261        &params.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/// Request to create workspace/service/tag budget
277#[derive(Debug, Deserialize, Serialize)]
278pub struct CreateWorkspaceBudgetRequest {
279    /// Workspace ID
280    pub workspace_id: String,
281    /// Maximum allowed breaking changes
282    pub max_breaking_changes: Option<u32>,
283    /// Maximum allowed non-breaking changes
284    pub max_non_breaking_changes: Option<u32>,
285    /// Maximum field churn percentage
286    pub max_field_churn_percent: Option<f64>,
287    /// Time window in days
288    pub time_window_days: Option<u32>,
289    /// Whether the budget is enabled
290    pub enabled: Option<bool>,
291}
292
293/// Request to create a service-level drift budget
294#[derive(Debug, Deserialize, Serialize)]
295pub struct CreateServiceBudgetRequest {
296    /// Service name
297    pub service_name: String,
298    /// Maximum allowed breaking changes
299    pub max_breaking_changes: Option<u32>,
300    /// Maximum allowed non-breaking changes
301    pub max_non_breaking_changes: Option<u32>,
302    /// Maximum field churn percentage
303    pub max_field_churn_percent: Option<f64>,
304    /// Time window in days
305    pub time_window_days: Option<u32>,
306    /// Whether the budget is enabled
307    pub enabled: Option<bool>,
308}
309
310/// Create or update workspace budget
311///
312/// POST /api/v1/drift/budgets/workspace
313pub 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    // Note: In a full implementation, this would persist to database
332    // state.engine.update_config(config);
333
334    Ok(Json(serde_json::json!({
335        "workspace_id": request.workspace_id,
336        "budget": budget,
337    })))
338}
339
340/// Create or update service budget
341///
342/// POST /api/v1/drift/budgets/service
343pub 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    // Note: In a full implementation, this would persist to database
360    // state.engine.update_config(config);
361
362    Ok(Json(serde_json::json!({
363        "service_name": request.service_name,
364        "budget": budget,
365    })))
366}
367
368/// Request to generate GitOps PR from incidents
369#[derive(Debug, Deserialize)]
370pub struct GeneratePRRequest {
371    /// Optional list of specific incident IDs
372    pub incident_ids: Option<Vec<String>>,
373    /// Optional workspace ID filter
374    pub workspace_id: Option<String>,
375    /// Optional status filter (e.g., "open")
376    pub status: Option<String>,
377}
378
379/// Generate GitOps PR from drift incidents
380///
381/// POST /api/v1/drift/gitops/generate-pr
382pub 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    // Get incidents to include in PR
389    let mut query = IncidentQuery::default();
390
391    if let Some(incident_ids) = &request.incident_ids {
392        // Filter by specific incident IDs
393        // Note: IncidentQuery doesn't support ID filtering yet, so we'll get all and filter
394        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                // Emit pipeline event for drift threshold exceeded
401                #[cfg(feature = "pipelines")]
402                {
403                    use mockforge_pipelines::{publish_event, PipelineEvent};
404                    use uuid::Uuid;
405
406                    // Extract workspace_id from incidents (use first incident's workspace if available)
407                    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                    // Count threshold-exceeded incidents
413                    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                        // Get a representative endpoint for the event
420                        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        // Filter by workspace and/or status
454        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                // Emit pipeline event for drift threshold exceeded
471                #[cfg(feature = "pipelines")]
472                {
473                    use mockforge_pipelines::{publish_event, PipelineEvent};
474                    use uuid::Uuid;
475
476                    // Extract workspace_id from cloned string or incidents
477                    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                    // Count threshold-exceeded incidents
489                    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                        // Get a representative endpoint for the event
496                        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/// Get drift metrics over time
533///
534/// GET /api/v1/drift/metrics?endpoint=/api/users&method=GET&days=30
535#[derive(Debug, Deserialize)]
536pub struct GetMetricsQuery {
537    /// Optional endpoint filter
538    pub endpoint: Option<String>,
539    /// Optional HTTP method filter
540    pub method: Option<String>,
541    /// Optional workspace ID filter
542    pub workspace_id: Option<String>,
543    /// Lookback window in days
544    pub days: Option<u32>,
545}
546
547/// Get drift metrics
548///
549/// GET /api/v1/drift/metrics
550pub async fn get_drift_metrics(
551    State(state): State<DriftBudgetState>,
552    Query(params): Query<GetMetricsQuery>,
553) -> Result<Json<serde_json::Value>, StatusCode> {
554    // Query incidents for metrics
555    let mut query = IncidentQuery {
556        endpoint: params.endpoint,
557        method: params.method,
558        workspace_id: params.workspace_id,
559        ..IncidentQuery::default()
560    };
561
562    // Filter by date range if days specified
563    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    // Calculate metrics
574    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<_>>(), // Limit to first 100
594    })))
595}
596
597/// List incidents
598///
599/// GET /api/v1/drift/incidents
600pub 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
664/// Get a specific incident
665///
666/// GET /api/v1/drift/incidents/{id}
667pub 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
679/// Update an incident
680///
681/// PATCH /api/v1/drift/incidents/{id}
682pub 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
734/// Resolve an incident
735///
736/// POST /api/v1/drift/incidents/{id}/resolve
737pub 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
750/// Get incident statistics
751///
752/// GET /api/v1/drift/incidents/stats
753pub 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
762/// Create drift budget router
763pub 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}