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    // Update engine config with new budget
144    let mut config = state.engine.config().clone();
145    let key = format!("{} {}", request.method, request.endpoint);
146    config.per_endpoint_budgets.insert(key, budget.clone());
147
148    // Note: In a full implementation, this would persist to database
149    // For now, we just update the engine config
150    // state.engine.update_config(config);
151
152    Ok(Json(DriftBudgetResponse {
153        id: budget_id,
154        endpoint: request.endpoint,
155        method: request.method,
156        budget,
157        workspace_id: request.workspace_id,
158    }))
159}
160
161/// List drift budgets
162///
163/// GET /api/v1/drift/budgets
164pub async fn list_budgets(
165    State(_state): State<DriftBudgetState>,
166) -> Result<Json<serde_json::Value>, StatusCode> {
167    // In a full implementation, this would query from database
168    // For now, return empty list
169    Ok(Json(serde_json::json!({
170        "budgets": []
171    })))
172}
173
174/// Get a specific drift budget
175///
176/// GET /api/v1/drift/budgets/{id}
177pub async fn get_budget(
178    State(_state): State<DriftBudgetState>,
179    Path(_id): Path<String>,
180) -> Result<Json<serde_json::Value>, StatusCode> {
181    Err(StatusCode::NOT_IMPLEMENTED)
182}
183
184/// Get budget for a specific endpoint/workspace/service
185///
186/// GET /api/v1/drift/budgets/lookup?endpoint=/api/users&method=GET&workspace_id=...
187#[derive(Debug, Deserialize)]
188pub struct GetBudgetQuery {
189    /// Endpoint path
190    pub endpoint: String,
191    /// HTTP method
192    pub method: String,
193    /// Optional workspace ID
194    pub workspace_id: Option<String>,
195    /// Optional service name
196    pub service_name: Option<String>,
197    /// Optional comma-separated tags
198    pub tags: Option<String>,
199}
200
201/// Get budget for endpoint
202///
203/// GET /api/v1/drift/budgets/lookup
204pub async fn get_budget_for_endpoint(
205    State(state): State<DriftBudgetState>,
206    Query(params): Query<GetBudgetQuery>,
207) -> Result<Json<serde_json::Value>, StatusCode> {
208    let tags = params
209        .tags
210        .as_ref()
211        .map(|t| t.split(',').map(|s| s.trim().to_string()).collect::<Vec<_>>());
212
213    let budget = state.engine.get_budget_for_endpoint(
214        &params.endpoint,
215        &params.method,
216        params.workspace_id.as_deref(),
217        params.service_name.as_deref(),
218        tags.as_deref(),
219    );
220
221    Ok(Json(serde_json::json!({
222        "endpoint": params.endpoint,
223        "method": params.method,
224        "workspace_id": params.workspace_id,
225        "service_name": params.service_name,
226        "budget": budget,
227    })))
228}
229
230/// Request to create workspace/service/tag budget
231#[derive(Debug, Deserialize, Serialize)]
232pub struct CreateWorkspaceBudgetRequest {
233    /// Workspace ID
234    pub workspace_id: String,
235    /// Maximum allowed breaking changes
236    pub max_breaking_changes: Option<u32>,
237    /// Maximum allowed non-breaking changes
238    pub max_non_breaking_changes: Option<u32>,
239    /// Maximum field churn percentage
240    pub max_field_churn_percent: Option<f64>,
241    /// Time window in days
242    pub time_window_days: Option<u32>,
243    /// Whether the budget is enabled
244    pub enabled: Option<bool>,
245}
246
247/// Request to create a service-level drift budget
248#[derive(Debug, Deserialize, Serialize)]
249pub struct CreateServiceBudgetRequest {
250    /// Service name
251    pub service_name: String,
252    /// Maximum allowed breaking changes
253    pub max_breaking_changes: Option<u32>,
254    /// Maximum allowed non-breaking changes
255    pub max_non_breaking_changes: Option<u32>,
256    /// Maximum field churn percentage
257    pub max_field_churn_percent: Option<f64>,
258    /// Time window in days
259    pub time_window_days: Option<u32>,
260    /// Whether the budget is enabled
261    pub enabled: Option<bool>,
262}
263
264/// Create or update workspace budget
265///
266/// POST /api/v1/drift/budgets/workspace
267pub async fn create_workspace_budget(
268    State(state): State<DriftBudgetState>,
269    Json(request): Json<CreateWorkspaceBudgetRequest>,
270) -> Result<Json<serde_json::Value>, StatusCode> {
271    let budget = DriftBudget {
272        max_breaking_changes: request.max_breaking_changes.unwrap_or(0),
273        max_non_breaking_changes: request.max_non_breaking_changes.unwrap_or(10),
274        max_field_churn_percent: request.max_field_churn_percent,
275        time_window_days: request.time_window_days,
276        severity_threshold: mockforge_core::ai_contract_diff::MismatchSeverity::High,
277        enabled: request.enabled.unwrap_or(true),
278    };
279
280    let mut config = state.engine.config().clone();
281    config
282        .per_workspace_budgets
283        .insert(request.workspace_id.clone(), budget.clone());
284
285    // Note: In a full implementation, this would persist to database
286    // state.engine.update_config(config);
287
288    Ok(Json(serde_json::json!({
289        "workspace_id": request.workspace_id,
290        "budget": budget,
291    })))
292}
293
294/// Create or update service budget
295///
296/// POST /api/v1/drift/budgets/service
297pub async fn create_service_budget(
298    State(state): State<DriftBudgetState>,
299    Json(request): Json<CreateServiceBudgetRequest>,
300) -> Result<Json<serde_json::Value>, StatusCode> {
301    let budget = DriftBudget {
302        max_breaking_changes: request.max_breaking_changes.unwrap_or(0),
303        max_non_breaking_changes: request.max_non_breaking_changes.unwrap_or(10),
304        max_field_churn_percent: request.max_field_churn_percent,
305        time_window_days: request.time_window_days,
306        severity_threshold: mockforge_core::ai_contract_diff::MismatchSeverity::High,
307        enabled: request.enabled.unwrap_or(true),
308    };
309
310    let mut config = state.engine.config().clone();
311    config.per_service_budgets.insert(request.service_name.clone(), budget.clone());
312
313    // Note: In a full implementation, this would persist to database
314    // state.engine.update_config(config);
315
316    Ok(Json(serde_json::json!({
317        "service_name": request.service_name,
318        "budget": budget,
319    })))
320}
321
322/// Request to generate GitOps PR from incidents
323#[derive(Debug, Deserialize)]
324pub struct GeneratePRRequest {
325    /// Optional list of specific incident IDs
326    pub incident_ids: Option<Vec<String>>,
327    /// Optional workspace ID filter
328    pub workspace_id: Option<String>,
329    /// Optional status filter (e.g., "open")
330    pub status: Option<String>,
331}
332
333/// Generate GitOps PR from drift incidents
334///
335/// POST /api/v1/drift/gitops/generate-pr
336pub async fn generate_gitops_pr(
337    State(state): State<DriftBudgetState>,
338    Json(request): Json<GeneratePRRequest>,
339) -> Result<Json<serde_json::Value>, StatusCode> {
340    let handler = state.gitops_handler.as_ref().ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
341
342    // Get incidents to include in PR
343    let mut query = IncidentQuery::default();
344
345    if let Some(incident_ids) = &request.incident_ids {
346        // Filter by specific incident IDs
347        // Note: IncidentQuery doesn't support ID filtering yet, so we'll get all and filter
348        let all_incidents = state.incident_manager.query_incidents(query).await;
349        let incidents: Vec<_> =
350            all_incidents.into_iter().filter(|inc| incident_ids.contains(&inc.id)).collect();
351
352        match handler.generate_pr_from_incidents(&incidents).await {
353            Ok(Some(pr_result)) => {
354                // Emit pipeline event for drift threshold exceeded
355                #[cfg(feature = "pipelines")]
356                {
357                    use mockforge_pipelines::{publish_event, PipelineEvent};
358                    use uuid::Uuid;
359
360                    // Extract workspace_id from incidents (use first incident's workspace if available)
361                    let workspace_id = incidents
362                        .first()
363                        .and_then(|inc| inc.workspace_id.as_ref())
364                        .and_then(|ws_id| Uuid::parse_str(ws_id).ok());
365
366                    // Count threshold-exceeded incidents
367                    let threshold_exceeded_count = incidents
368                        .iter()
369                        .filter(|inc| matches!(inc.incident_type, IncidentType::ThresholdExceeded))
370                        .count();
371
372                    if threshold_exceeded_count > 0 {
373                        // Get a representative endpoint for the event
374                        let endpoint = incidents
375                            .first()
376                            .map(|inc| format!("{} {}", inc.method, inc.endpoint))
377                            .unwrap_or_else(|| "unknown".to_string());
378
379                        let event = PipelineEvent::drift_threshold_exceeded(
380                            workspace_id.unwrap_or_else(Uuid::new_v4),
381                            endpoint,
382                            threshold_exceeded_count as i32,
383                            incidents.len() as i32,
384                        );
385
386                        if let Err(e) = publish_event(event) {
387                            tracing::warn!(
388                                "Failed to publish drift threshold exceeded event: {}",
389                                e
390                            );
391                        }
392                    }
393                }
394
395                Ok(Json(serde_json::json!({
396                    "success": true,
397                    "pr": pr_result,
398                })))
399            }
400            Ok(None) => Ok(Json(serde_json::json!({
401                "success": false,
402                "message": "No PR generated (no file changes or incidents)",
403            }))),
404            Err(_e) => Err(StatusCode::INTERNAL_SERVER_ERROR),
405        }
406    } else {
407        // Filter by workspace and/or status
408        let workspace_id_str = request.workspace_id.clone();
409        #[cfg(not(feature = "pipelines"))]
410        let _ = &workspace_id_str;
411        query.workspace_id = request.workspace_id;
412        if let Some(status_str) = &request.status {
413            query.status = match status_str.as_str() {
414                "open" => Some(IncidentStatus::Open),
415                "acknowledged" => Some(IncidentStatus::Acknowledged),
416                _ => None,
417            };
418        }
419
420        let incidents = state.incident_manager.query_incidents(query).await;
421
422        match handler.generate_pr_from_incidents(&incidents).await {
423            Ok(Some(pr_result)) => {
424                // Emit pipeline event for drift threshold exceeded
425                #[cfg(feature = "pipelines")]
426                {
427                    use mockforge_pipelines::{publish_event, PipelineEvent};
428                    use uuid::Uuid;
429
430                    // Extract workspace_id from cloned string or incidents
431                    let workspace_id = workspace_id_str
432                        .as_ref()
433                        .and_then(|ws_id| Uuid::parse_str(ws_id).ok())
434                        .or_else(|| {
435                            incidents
436                                .first()
437                                .and_then(|inc| inc.workspace_id.as_ref())
438                                .and_then(|ws_id| Uuid::parse_str(ws_id).ok())
439                        })
440                        .unwrap_or_else(Uuid::new_v4);
441
442                    // Count threshold-exceeded incidents
443                    let threshold_exceeded_count = incidents
444                        .iter()
445                        .filter(|inc| matches!(inc.incident_type, IncidentType::ThresholdExceeded))
446                        .count();
447
448                    if threshold_exceeded_count > 0 {
449                        // Get a representative endpoint for the event
450                        let endpoint = incidents
451                            .first()
452                            .map(|inc| format!("{} {}", inc.method, inc.endpoint))
453                            .unwrap_or_else(|| "unknown".to_string());
454
455                        let event = PipelineEvent::drift_threshold_exceeded(
456                            workspace_id,
457                            endpoint,
458                            threshold_exceeded_count as i32,
459                            incidents.len() as i32,
460                        );
461
462                        if let Err(e) = publish_event(event) {
463                            tracing::warn!(
464                                "Failed to publish drift threshold exceeded event: {}",
465                                e
466                            );
467                        }
468                    }
469                }
470
471                Ok(Json(serde_json::json!({
472                    "success": true,
473                    "pr": pr_result,
474                    "incidents_included": incidents.len(),
475                })))
476            }
477            Ok(None) => Ok(Json(serde_json::json!({
478                "success": false,
479                "message": "No PR generated (no file changes or incidents)",
480            }))),
481            Err(_e) => Err(StatusCode::INTERNAL_SERVER_ERROR),
482        }
483    }
484}
485
486/// Get drift metrics over time
487///
488/// GET /api/v1/drift/metrics?endpoint=/api/users&method=GET&days=30
489#[derive(Debug, Deserialize)]
490pub struct GetMetricsQuery {
491    /// Optional endpoint filter
492    pub endpoint: Option<String>,
493    /// Optional HTTP method filter
494    pub method: Option<String>,
495    /// Optional workspace ID filter
496    pub workspace_id: Option<String>,
497    /// Lookback window in days
498    pub days: Option<u32>,
499}
500
501/// Get drift metrics
502///
503/// GET /api/v1/drift/metrics
504pub async fn get_drift_metrics(
505    State(state): State<DriftBudgetState>,
506    Query(params): Query<GetMetricsQuery>,
507) -> Result<Json<serde_json::Value>, StatusCode> {
508    // Query incidents for metrics
509    let mut query = IncidentQuery::default();
510    query.endpoint = params.endpoint;
511    query.method = params.method;
512    query.workspace_id = params.workspace_id;
513
514    // Filter by date range if days specified
515    if let Some(days) = params.days {
516        let start_date = chrono::Utc::now()
517            .checked_sub_signed(chrono::Duration::days(days as i64))
518            .map(|dt| dt.timestamp())
519            .unwrap_or(0);
520        query.start_date = Some(start_date);
521    }
522
523    let incidents = state.incident_manager.query_incidents(query).await;
524
525    // Calculate metrics
526    let total_incidents = incidents.len();
527    let breaking_changes = incidents
528        .iter()
529        .filter(|i| matches!(i.incident_type, IncidentType::BreakingChange))
530        .count();
531    let threshold_exceeded = total_incidents - breaking_changes;
532
533    let by_severity: HashMap<String, usize> =
534        incidents.iter().fold(HashMap::new(), |mut acc, inc| {
535            let key = format!("{:?}", inc.severity).to_lowercase();
536            *acc.entry(key).or_insert(0) += 1;
537            acc
538        });
539
540    Ok(Json(serde_json::json!({
541        "total_incidents": total_incidents,
542        "breaking_changes": breaking_changes,
543        "threshold_exceeded": threshold_exceeded,
544        "by_severity": by_severity,
545        "incidents": incidents.iter().take(100).collect::<Vec<_>>(), // Limit to first 100
546    })))
547}
548
549/// List incidents
550///
551/// GET /api/v1/drift/incidents
552pub async fn list_incidents(
553    State(state): State<DriftBudgetState>,
554    Query(params): Query<HashMap<String, String>>,
555) -> Result<Json<ListIncidentsResponse>, StatusCode> {
556    let mut query = IncidentQuery::default();
557
558    if let Some(status_str) = params.get("status") {
559        query.status = match status_str.as_str() {
560            "open" => Some(IncidentStatus::Open),
561            "acknowledged" => Some(IncidentStatus::Acknowledged),
562            "resolved" => Some(IncidentStatus::Resolved),
563            "closed" => Some(IncidentStatus::Closed),
564            _ => None,
565        };
566    }
567
568    if let Some(severity_str) = params.get("severity") {
569        query.severity = match severity_str.as_str() {
570            "critical" => Some(IncidentSeverity::Critical),
571            "high" => Some(IncidentSeverity::High),
572            "medium" => Some(IncidentSeverity::Medium),
573            "low" => Some(IncidentSeverity::Low),
574            _ => None,
575        };
576    }
577
578    if let Some(endpoint) = params.get("endpoint") {
579        query.endpoint = Some(endpoint.clone());
580    }
581
582    if let Some(method) = params.get("method") {
583        query.method = Some(method.clone());
584    }
585
586    if let Some(incident_type_str) = params.get("incident_type") {
587        query.incident_type = match incident_type_str.as_str() {
588            "breaking_change" => Some(IncidentType::BreakingChange),
589            "threshold_exceeded" => Some(IncidentType::ThresholdExceeded),
590            _ => None,
591        };
592    }
593
594    if let Some(workspace_id) = params.get("workspace_id") {
595        query.workspace_id = Some(workspace_id.clone());
596    }
597
598    if let Some(limit_str) = params.get("limit") {
599        if let Ok(limit) = limit_str.parse() {
600            query.limit = Some(limit);
601        }
602    }
603
604    if let Some(offset_str) = params.get("offset") {
605        if let Ok(offset) = offset_str.parse() {
606            query.offset = Some(offset);
607        }
608    }
609
610    let incidents = state.incident_manager.query_incidents(query).await;
611    let total = incidents.len();
612
613    Ok(Json(ListIncidentsResponse { incidents, total }))
614}
615
616/// Get a specific incident
617///
618/// GET /api/v1/drift/incidents/{id}
619pub async fn get_incident(
620    State(state): State<DriftBudgetState>,
621    Path(id): Path<String>,
622) -> Result<Json<DriftIncident>, StatusCode> {
623    state
624        .incident_manager
625        .get_incident(&id)
626        .await
627        .map(Json)
628        .ok_or(StatusCode::NOT_FOUND)
629}
630
631/// Update an incident
632///
633/// PATCH /api/v1/drift/incidents/{id}
634pub async fn update_incident(
635    State(state): State<DriftBudgetState>,
636    Path(id): Path<String>,
637    Json(request): Json<UpdateIncidentRequest>,
638) -> Result<Json<DriftIncident>, StatusCode> {
639    let mut incident =
640        state.incident_manager.get_incident(&id).await.ok_or(StatusCode::NOT_FOUND)?;
641
642    if let Some(status_str) = request.status {
643        match status_str.as_str() {
644            "acknowledged" => {
645                incident = state
646                    .incident_manager
647                    .acknowledge_incident(&id)
648                    .await
649                    .ok_or(StatusCode::NOT_FOUND)?;
650            }
651            "resolved" => {
652                incident = state
653                    .incident_manager
654                    .resolve_incident(&id)
655                    .await
656                    .ok_or(StatusCode::NOT_FOUND)?;
657            }
658            "closed" => {
659                incident = state
660                    .incident_manager
661                    .close_incident(&id)
662                    .await
663                    .ok_or(StatusCode::NOT_FOUND)?;
664            }
665            _ => {}
666        }
667    }
668
669    if let Some(ticket_id) = request.external_ticket_id {
670        incident = state
671            .incident_manager
672            .link_external_ticket(&id, ticket_id, request.external_ticket_url)
673            .await
674            .ok_or(StatusCode::NOT_FOUND)?;
675    }
676
677    Ok(Json(incident))
678}
679
680/// Resolve an incident
681///
682/// POST /api/v1/drift/incidents/{id}/resolve
683pub async fn resolve_incident(
684    State(state): State<DriftBudgetState>,
685    Path(id): Path<String>,
686    Json(_request): Json<ResolveIncidentRequest>,
687) -> Result<Json<DriftIncident>, StatusCode> {
688    state
689        .incident_manager
690        .resolve_incident(&id)
691        .await
692        .map(Json)
693        .ok_or(StatusCode::NOT_FOUND)
694}
695
696/// Get incident statistics
697///
698/// GET /api/v1/drift/incidents/stats
699pub async fn get_incident_stats(
700    State(state): State<DriftBudgetState>,
701) -> Result<Json<serde_json::Value>, StatusCode> {
702    let stats = state.incident_manager.get_statistics().await;
703    Ok(Json(serde_json::json!({
704        "stats": stats
705    })))
706}
707
708/// Create drift budget router
709pub fn drift_budget_router(state: DriftBudgetState) -> axum::Router {
710    use axum::{
711        routing::{get, patch, post},
712        Router,
713    };
714
715    Router::new()
716        .route("/api/v1/drift/budgets", post(create_budget))
717        .route("/api/v1/drift/budgets", get(list_budgets))
718        .route("/api/v1/drift/budgets/lookup", get(get_budget_for_endpoint))
719        .route("/api/v1/drift/budgets/workspace", post(create_workspace_budget))
720        .route("/api/v1/drift/budgets/service", post(create_service_budget))
721        .route("/api/v1/drift/budgets/{id}", get(get_budget))
722        .route("/api/v1/drift/incidents", get(list_incidents))
723        .route("/api/v1/drift/incidents/stats", get(get_incident_stats))
724        .route("/api/v1/drift/incidents/{id}", get(get_incident))
725        .route("/api/v1/drift/incidents/{id}", patch(update_incident))
726        .route("/api/v1/drift/incidents/{id}/resolve", post(resolve_incident))
727        .route("/api/v1/drift/gitops/generate-pr", post(generate_gitops_pr))
728        .route("/api/v1/drift/metrics", get(get_drift_metrics))
729        .with_state(state)
730}