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
5use 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/// State for drift budget handlers
22#[derive(Clone)]
23pub struct DriftBudgetState {
24    /// Drift budget engine
25    pub engine: Arc<DriftBudgetEngine>,
26    /// Incident manager
27    pub incident_manager: Arc<IncidentManager>,
28    /// GitOps handler (optional)
29    pub gitops_handler: Option<Arc<mockforge_core::drift_gitops::DriftGitOpsHandler>>,
30}
31
32/// Request to create or update a drift budget
33#[derive(Debug, Deserialize, Serialize)]
34pub struct CreateDriftBudgetRequest {
35    /// Endpoint path
36    pub endpoint: String,
37    /// HTTP method
38    pub method: String,
39    /// Maximum breaking changes allowed
40    pub max_breaking_changes: Option<u32>,
41    /// Maximum non-breaking changes allowed
42    pub max_non_breaking_changes: Option<u32>,
43    /// Severity threshold
44    pub severity_threshold: Option<String>,
45    /// Whether enabled
46    pub enabled: Option<bool>,
47    /// Workspace ID (optional)
48    pub workspace_id: Option<String>,
49}
50
51/// Response for drift budget operations
52#[derive(Debug, Serialize)]
53pub struct DriftBudgetResponse {
54    /// Budget ID
55    pub id: String,
56    /// Endpoint
57    pub endpoint: String,
58    /// Method
59    pub method: String,
60    /// Budget configuration
61    pub budget: DriftBudget,
62    /// Workspace ID
63    pub workspace_id: Option<String>,
64}
65
66/// Request to query incidents
67#[derive(Debug, Deserialize)]
68pub struct ListIncidentsRequest {
69    /// Filter by status
70    pub status: Option<String>,
71    /// Filter by severity
72    pub severity: Option<String>,
73    /// Filter by endpoint
74    pub endpoint: Option<String>,
75    /// Filter by method
76    pub method: Option<String>,
77    /// Filter by incident type
78    pub incident_type: Option<String>,
79    /// Filter by workspace ID
80    pub workspace_id: Option<String>,
81    /// Limit results
82    pub limit: Option<usize>,
83    /// Offset for pagination
84    pub offset: Option<usize>,
85}
86
87/// Response for listing incidents
88#[derive(Debug, Serialize)]
89pub struct ListIncidentsResponse {
90    /// List of incidents
91    pub incidents: Vec<DriftIncident>,
92    /// Total count
93    pub total: usize,
94}
95
96/// Request to update incident status
97#[derive(Debug, Deserialize)]
98pub struct UpdateIncidentRequest {
99    /// New status
100    pub status: Option<String>,
101    /// External ticket ID
102    pub external_ticket_id: Option<String>,
103    /// External ticket URL
104    pub external_ticket_url: Option<String>,
105}
106
107/// Request to resolve incident
108#[derive(Debug, Deserialize)]
109pub struct ResolveIncidentRequest {
110    /// Optional resolution note
111    pub note: Option<String>,
112}
113
114/// Create or update a drift budget
115///
116/// POST /api/v1/drift/budgets
117pub 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    // Generate budget ID
141    let budget_id = format!("{}:{}:{}", request.method, request.endpoint, uuid::Uuid::new_v4());
142
143    // Build the key for this budget (matches the format used by list_budgets/get_budget)
144    let key = format!("{} {}", request.method, request.endpoint);
145
146    // Note: DriftBudgetEngine.config is not behind interior mutability (RwLock),
147    // so budget creation is returned but not persisted in the running engine.
148    // A future refactor should wrap config in RwLock for runtime mutation.
149    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
160/// List drift budgets
161///
162/// GET /api/v1/drift/budgets
163pub 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            // Key format is "METHOD /path"
172            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
197/// Get a specific drift budget
198///
199/// GET /api/v1/drift/budgets/{id}
200pub 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/// Get budget for a specific endpoint/workspace/service
228///
229/// GET /api/v1/drift/budgets/lookup?endpoint=/api/users&method=GET&workspace_id=...
230#[derive(Debug, Deserialize)]
231pub struct GetBudgetQuery {
232    /// Endpoint path
233    pub endpoint: String,
234    /// HTTP method
235    pub method: String,
236    /// Optional workspace ID
237    pub workspace_id: Option<String>,
238    /// Optional service name
239    pub service_name: Option<String>,
240    /// Optional comma-separated tags
241    pub tags: Option<String>,
242}
243
244/// Get budget for endpoint
245///
246/// GET /api/v1/drift/budgets/lookup
247pub 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        &params.endpoint,
258        &params.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/// Request to create workspace/service/tag budget
274#[derive(Debug, Deserialize, Serialize)]
275pub struct CreateWorkspaceBudgetRequest {
276    /// Workspace ID
277    pub workspace_id: String,
278    /// Maximum allowed breaking changes
279    pub max_breaking_changes: Option<u32>,
280    /// Maximum allowed non-breaking changes
281    pub max_non_breaking_changes: Option<u32>,
282    /// Maximum field churn percentage
283    pub max_field_churn_percent: Option<f64>,
284    /// Time window in days
285    pub time_window_days: Option<u32>,
286    /// Whether the budget is enabled
287    pub enabled: Option<bool>,
288}
289
290/// Request to create a service-level drift budget
291#[derive(Debug, Deserialize, Serialize)]
292pub struct CreateServiceBudgetRequest {
293    /// Service name
294    pub service_name: String,
295    /// Maximum allowed breaking changes
296    pub max_breaking_changes: Option<u32>,
297    /// Maximum allowed non-breaking changes
298    pub max_non_breaking_changes: Option<u32>,
299    /// Maximum field churn percentage
300    pub max_field_churn_percent: Option<f64>,
301    /// Time window in days
302    pub time_window_days: Option<u32>,
303    /// Whether the budget is enabled
304    pub enabled: Option<bool>,
305}
306
307/// Create or update workspace budget
308///
309/// POST /api/v1/drift/budgets/workspace
310pub 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    // Note: In a full implementation, this would persist to database
329    // state.engine.update_config(config);
330
331    Ok(Json(serde_json::json!({
332        "workspace_id": request.workspace_id,
333        "budget": budget,
334    })))
335}
336
337/// Create or update service budget
338///
339/// POST /api/v1/drift/budgets/service
340pub 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    // Note: In a full implementation, this would persist to database
357    // state.engine.update_config(config);
358
359    Ok(Json(serde_json::json!({
360        "service_name": request.service_name,
361        "budget": budget,
362    })))
363}
364
365/// Request to generate GitOps PR from incidents
366#[derive(Debug, Deserialize)]
367pub struct GeneratePRRequest {
368    /// Optional list of specific incident IDs
369    pub incident_ids: Option<Vec<String>>,
370    /// Optional workspace ID filter
371    pub workspace_id: Option<String>,
372    /// Optional status filter (e.g., "open")
373    pub status: Option<String>,
374}
375
376/// Generate GitOps PR from drift incidents
377///
378/// POST /api/v1/drift/gitops/generate-pr
379pub 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    // Get incidents to include in PR
386    let mut query = IncidentQuery::default();
387
388    if let Some(incident_ids) = &request.incident_ids {
389        // Filter by specific incident IDs
390        // Note: IncidentQuery doesn't support ID filtering yet, so we'll get all and filter
391        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                // Emit pipeline event for drift threshold exceeded
398                #[cfg(feature = "pipelines")]
399                {
400                    use mockforge_pipelines::{publish_event, PipelineEvent};
401                    use uuid::Uuid;
402
403                    // Extract workspace_id from incidents (use first incident's workspace if available)
404                    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                    // Count threshold-exceeded incidents
410                    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                        // Get a representative endpoint for the event
417                        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        // Filter by workspace and/or status
451        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                // Emit pipeline event for drift threshold exceeded
468                #[cfg(feature = "pipelines")]
469                {
470                    use mockforge_pipelines::{publish_event, PipelineEvent};
471                    use uuid::Uuid;
472
473                    // Extract workspace_id from cloned string or incidents
474                    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                    // Count threshold-exceeded incidents
486                    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                        // Get a representative endpoint for the event
493                        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/// Get drift metrics over time
530///
531/// GET /api/v1/drift/metrics?endpoint=/api/users&method=GET&days=30
532#[derive(Debug, Deserialize)]
533pub struct GetMetricsQuery {
534    /// Optional endpoint filter
535    pub endpoint: Option<String>,
536    /// Optional HTTP method filter
537    pub method: Option<String>,
538    /// Optional workspace ID filter
539    pub workspace_id: Option<String>,
540    /// Lookback window in days
541    pub days: Option<u32>,
542}
543
544/// Get drift metrics
545///
546/// GET /api/v1/drift/metrics
547pub async fn get_drift_metrics(
548    State(state): State<DriftBudgetState>,
549    Query(params): Query<GetMetricsQuery>,
550) -> Result<Json<serde_json::Value>, StatusCode> {
551    // Query incidents for metrics
552    let mut query = IncidentQuery::default();
553    query.endpoint = params.endpoint;
554    query.method = params.method;
555    query.workspace_id = params.workspace_id;
556
557    // Filter by date range if days specified
558    if let Some(days) = params.days {
559        let start_date = chrono::Utc::now()
560            .checked_sub_signed(chrono::Duration::days(days as i64))
561            .map(|dt| dt.timestamp())
562            .unwrap_or(0);
563        query.start_date = Some(start_date);
564    }
565
566    let incidents = state.incident_manager.query_incidents(query).await;
567
568    // Calculate metrics
569    let total_incidents = incidents.len();
570    let breaking_changes = incidents
571        .iter()
572        .filter(|i| matches!(i.incident_type, IncidentType::BreakingChange))
573        .count();
574    let threshold_exceeded = total_incidents - breaking_changes;
575
576    let by_severity: HashMap<String, usize> =
577        incidents.iter().fold(HashMap::new(), |mut acc, inc| {
578            let key = format!("{:?}", inc.severity).to_lowercase();
579            *acc.entry(key).or_insert(0) += 1;
580            acc
581        });
582
583    Ok(Json(serde_json::json!({
584        "total_incidents": total_incidents,
585        "breaking_changes": breaking_changes,
586        "threshold_exceeded": threshold_exceeded,
587        "by_severity": by_severity,
588        "incidents": incidents.iter().take(100).collect::<Vec<_>>(), // Limit to first 100
589    })))
590}
591
592/// List incidents
593///
594/// GET /api/v1/drift/incidents
595pub async fn list_incidents(
596    State(state): State<DriftBudgetState>,
597    Query(params): Query<HashMap<String, String>>,
598) -> Result<Json<ListIncidentsResponse>, StatusCode> {
599    let mut query = IncidentQuery::default();
600
601    if let Some(status_str) = params.get("status") {
602        query.status = match status_str.as_str() {
603            "open" => Some(IncidentStatus::Open),
604            "acknowledged" => Some(IncidentStatus::Acknowledged),
605            "resolved" => Some(IncidentStatus::Resolved),
606            "closed" => Some(IncidentStatus::Closed),
607            _ => None,
608        };
609    }
610
611    if let Some(severity_str) = params.get("severity") {
612        query.severity = match severity_str.as_str() {
613            "critical" => Some(IncidentSeverity::Critical),
614            "high" => Some(IncidentSeverity::High),
615            "medium" => Some(IncidentSeverity::Medium),
616            "low" => Some(IncidentSeverity::Low),
617            _ => None,
618        };
619    }
620
621    if let Some(endpoint) = params.get("endpoint") {
622        query.endpoint = Some(endpoint.clone());
623    }
624
625    if let Some(method) = params.get("method") {
626        query.method = Some(method.clone());
627    }
628
629    if let Some(incident_type_str) = params.get("incident_type") {
630        query.incident_type = match incident_type_str.as_str() {
631            "breaking_change" => Some(IncidentType::BreakingChange),
632            "threshold_exceeded" => Some(IncidentType::ThresholdExceeded),
633            _ => None,
634        };
635    }
636
637    if let Some(workspace_id) = params.get("workspace_id") {
638        query.workspace_id = Some(workspace_id.clone());
639    }
640
641    if let Some(limit_str) = params.get("limit") {
642        if let Ok(limit) = limit_str.parse() {
643            query.limit = Some(limit);
644        }
645    }
646
647    if let Some(offset_str) = params.get("offset") {
648        if let Ok(offset) = offset_str.parse() {
649            query.offset = Some(offset);
650        }
651    }
652
653    let incidents = state.incident_manager.query_incidents(query).await;
654    let total = incidents.len();
655
656    Ok(Json(ListIncidentsResponse { incidents, total }))
657}
658
659/// Get a specific incident
660///
661/// GET /api/v1/drift/incidents/{id}
662pub async fn get_incident(
663    State(state): State<DriftBudgetState>,
664    Path(id): Path<String>,
665) -> Result<Json<DriftIncident>, StatusCode> {
666    state
667        .incident_manager
668        .get_incident(&id)
669        .await
670        .map(Json)
671        .ok_or(StatusCode::NOT_FOUND)
672}
673
674/// Update an incident
675///
676/// PATCH /api/v1/drift/incidents/{id}
677pub async fn update_incident(
678    State(state): State<DriftBudgetState>,
679    Path(id): Path<String>,
680    Json(request): Json<UpdateIncidentRequest>,
681) -> Result<Json<DriftIncident>, StatusCode> {
682    let mut incident =
683        state.incident_manager.get_incident(&id).await.ok_or(StatusCode::NOT_FOUND)?;
684
685    if let Some(status_str) = request.status {
686        match status_str.as_str() {
687            "acknowledged" => {
688                incident = state
689                    .incident_manager
690                    .acknowledge_incident(&id)
691                    .await
692                    .ok_or(StatusCode::NOT_FOUND)?;
693            }
694            "resolved" => {
695                incident = state
696                    .incident_manager
697                    .resolve_incident(&id)
698                    .await
699                    .ok_or(StatusCode::NOT_FOUND)?;
700            }
701            "closed" => {
702                incident = state
703                    .incident_manager
704                    .close_incident(&id)
705                    .await
706                    .ok_or(StatusCode::NOT_FOUND)?;
707            }
708            _ => {}
709        }
710    }
711
712    if let Some(ticket_id) = request.external_ticket_id {
713        incident = state
714            .incident_manager
715            .link_external_ticket(&id, ticket_id, request.external_ticket_url)
716            .await
717            .ok_or(StatusCode::NOT_FOUND)?;
718    }
719
720    Ok(Json(incident))
721}
722
723/// Resolve an incident
724///
725/// POST /api/v1/drift/incidents/{id}/resolve
726pub async fn resolve_incident(
727    State(state): State<DriftBudgetState>,
728    Path(id): Path<String>,
729    Json(_request): Json<ResolveIncidentRequest>,
730) -> Result<Json<DriftIncident>, StatusCode> {
731    state
732        .incident_manager
733        .resolve_incident(&id)
734        .await
735        .map(Json)
736        .ok_or(StatusCode::NOT_FOUND)
737}
738
739/// Get incident statistics
740///
741/// GET /api/v1/drift/incidents/stats
742pub async fn get_incident_stats(
743    State(state): State<DriftBudgetState>,
744) -> Result<Json<serde_json::Value>, StatusCode> {
745    let stats = state.incident_manager.get_statistics().await;
746    Ok(Json(serde_json::json!({
747        "stats": stats
748    })))
749}
750
751/// Create drift budget router
752pub fn drift_budget_router(state: DriftBudgetState) -> axum::Router {
753    use axum::{
754        routing::{get, patch, post},
755        Router,
756    };
757
758    Router::new()
759        .route("/api/v1/drift/budgets", post(create_budget))
760        .route("/api/v1/drift/budgets", get(list_budgets))
761        .route("/api/v1/drift/budgets/lookup", get(get_budget_for_endpoint))
762        .route("/api/v1/drift/budgets/workspace", post(create_workspace_budget))
763        .route("/api/v1/drift/budgets/service", post(create_service_budget))
764        .route("/api/v1/drift/budgets/{id}", get(get_budget))
765        .route("/api/v1/drift/incidents", get(list_incidents))
766        .route("/api/v1/drift/incidents/stats", get(get_incident_stats))
767        .route("/api/v1/drift/incidents/{id}", get(get_incident))
768        .route("/api/v1/drift/incidents/{id}", patch(update_incident))
769        .route("/api/v1/drift/incidents/{id}/resolve", post(resolve_incident))
770        .route("/api/v1/drift/gitops/generate-pr", post(generate_gitops_pr))
771        .route("/api/v1/drift/metrics", get(get_drift_metrics))
772        .with_state(state)
773}