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::{
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/// State for drift budget handlers
24#[derive(Clone)]
25pub struct DriftBudgetState {
26    /// Drift budget engine
27    pub engine: Arc<DriftBudgetEngine>,
28    /// Incident manager
29    pub incident_manager: Arc<IncidentManager>,
30    /// GitOps handler (optional)
31    pub gitops_handler: Option<Arc<mockforge_core::drift_gitops::DriftGitOpsHandler>>,
32}
33
34/// Request to create or update a drift budget
35#[derive(Debug, Deserialize, Serialize)]
36pub struct CreateDriftBudgetRequest {
37    /// Endpoint path
38    pub endpoint: String,
39    /// HTTP method
40    pub method: String,
41    /// Maximum breaking changes allowed
42    pub max_breaking_changes: Option<u32>,
43    /// Maximum non-breaking changes allowed
44    pub max_non_breaking_changes: Option<u32>,
45    /// Severity threshold
46    pub severity_threshold: Option<String>,
47    /// Whether enabled
48    pub enabled: Option<bool>,
49    /// Workspace ID (optional)
50    pub workspace_id: Option<String>,
51}
52
53/// Response for drift budget operations
54#[derive(Debug, Serialize)]
55pub struct DriftBudgetResponse {
56    /// Budget ID
57    pub id: String,
58    /// Endpoint
59    pub endpoint: String,
60    /// Method
61    pub method: String,
62    /// Budget configuration
63    pub budget: DriftBudget,
64    /// Workspace ID
65    pub workspace_id: Option<String>,
66}
67
68/// Request to query incidents
69#[derive(Debug, Deserialize)]
70pub struct ListIncidentsRequest {
71    /// Filter by status
72    pub status: Option<String>,
73    /// Filter by severity
74    pub severity: Option<String>,
75    /// Filter by endpoint
76    pub endpoint: Option<String>,
77    /// Filter by method
78    pub method: Option<String>,
79    /// Filter by incident type
80    pub incident_type: Option<String>,
81    /// Filter by workspace ID
82    pub workspace_id: Option<String>,
83    /// Limit results
84    pub limit: Option<usize>,
85    /// Offset for pagination
86    pub offset: Option<usize>,
87}
88
89/// Response for listing incidents
90#[derive(Debug, Serialize)]
91pub struct ListIncidentsResponse {
92    /// List of incidents
93    pub incidents: Vec<DriftIncident>,
94    /// Total count
95    pub total: usize,
96}
97
98/// Request to update incident status
99#[derive(Debug, Deserialize)]
100pub struct UpdateIncidentRequest {
101    /// New status
102    pub status: Option<String>,
103    /// External ticket ID
104    pub external_ticket_id: Option<String>,
105    /// External ticket URL
106    pub external_ticket_url: Option<String>,
107}
108
109/// Request to resolve incident
110#[derive(Debug, Deserialize)]
111pub struct ResolveIncidentRequest {
112    /// Optional resolution note
113    pub note: Option<String>,
114}
115
116/// Create or update a drift budget
117///
118/// POST /api/v1/drift/budgets
119pub 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    // Generate budget ID
143    let budget_id = format!("{}:{}:{}", request.method, request.endpoint, uuid::Uuid::new_v4());
144
145    // Update engine config with new budget
146    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    // Note: In a full implementation, this would persist to database
151    // For now, we just update the engine config
152    // state.engine.update_config(config);
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    // In a full implementation, this would query from database
170    // For now, return empty list
171    Ok(Json(serde_json::json!({
172        "budgets": []
173    })))
174}
175
176/// Get a specific drift budget
177///
178/// GET /api/v1/drift/budgets/{id}
179pub 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/// Get budget for a specific endpoint/workspace/service
187///
188/// GET /api/v1/drift/budgets/lookup?endpoint=/api/users&method=GET&workspace_id=...
189#[derive(Debug, Deserialize)]
190pub struct GetBudgetQuery {
191    /// Endpoint path
192    pub endpoint: String,
193    /// HTTP method
194    pub method: String,
195    /// Optional workspace ID
196    pub workspace_id: Option<String>,
197    /// Optional service name
198    pub service_name: Option<String>,
199    /// Optional comma-separated tags
200    pub tags: Option<String>,
201}
202
203/// Get budget for endpoint
204///
205/// GET /api/v1/drift/budgets/lookup
206pub 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        &params.endpoint,
217        &params.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/// Request to create workspace/service/tag budget
233#[derive(Debug, Deserialize, Serialize)]
234pub struct CreateWorkspaceBudgetRequest {
235    /// Workspace ID
236    pub workspace_id: String,
237    /// Maximum allowed breaking changes
238    pub max_breaking_changes: Option<u32>,
239    /// Maximum allowed non-breaking changes
240    pub max_non_breaking_changes: Option<u32>,
241    /// Maximum field churn percentage
242    pub max_field_churn_percent: Option<f64>,
243    /// Time window in days
244    pub time_window_days: Option<u32>,
245    /// Whether the budget is enabled
246    pub enabled: Option<bool>,
247}
248
249/// Request to create a service-level drift budget
250#[derive(Debug, Deserialize, Serialize)]
251pub struct CreateServiceBudgetRequest {
252    /// Service name
253    pub service_name: String,
254    /// Maximum allowed breaking changes
255    pub max_breaking_changes: Option<u32>,
256    /// Maximum allowed non-breaking changes
257    pub max_non_breaking_changes: Option<u32>,
258    /// Maximum field churn percentage
259    pub max_field_churn_percent: Option<f64>,
260    /// Time window in days
261    pub time_window_days: Option<u32>,
262    /// Whether the budget is enabled
263    pub enabled: Option<bool>,
264}
265
266/// Create or update workspace budget
267///
268/// POST /api/v1/drift/budgets/workspace
269pub 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    // Note: In a full implementation, this would persist to database
288    // state.engine.update_config(config);
289
290    Ok(Json(serde_json::json!({
291        "workspace_id": request.workspace_id,
292        "budget": budget,
293    })))
294}
295
296/// Create or update service budget
297///
298/// POST /api/v1/drift/budgets/service
299pub 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    // Note: In a full implementation, this would persist to database
316    // state.engine.update_config(config);
317
318    Ok(Json(serde_json::json!({
319        "service_name": request.service_name,
320        "budget": budget,
321    })))
322}
323
324/// Request to generate GitOps PR from incidents
325#[derive(Debug, Deserialize)]
326pub struct GeneratePRRequest {
327    /// Optional list of specific incident IDs
328    pub incident_ids: Option<Vec<String>>,
329    /// Optional workspace ID filter
330    pub workspace_id: Option<String>,
331    /// Optional status filter (e.g., "open")
332    pub status: Option<String>,
333}
334
335/// Generate GitOps PR from drift incidents
336///
337/// POST /api/v1/drift/gitops/generate-pr
338pub 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    // Get incidents to include in PR
345    let mut query = IncidentQuery::default();
346
347    if let Some(incident_ids) = &request.incident_ids {
348        // Filter by specific incident IDs
349        // Note: IncidentQuery doesn't support ID filtering yet, so we'll get all and filter
350        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        // Filter by workspace and/or status
367        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/// Get drift metrics over time
394///
395/// GET /api/v1/drift/metrics?endpoint=/api/users&method=GET&days=30
396#[derive(Debug, Deserialize)]
397pub struct GetMetricsQuery {
398    /// Optional endpoint filter
399    pub endpoint: Option<String>,
400    /// Optional HTTP method filter
401    pub method: Option<String>,
402    /// Optional workspace ID filter
403    pub workspace_id: Option<String>,
404    /// Lookback window in days
405    pub days: Option<u32>,
406}
407
408/// Get drift metrics
409///
410/// GET /api/v1/drift/metrics
411pub async fn get_drift_metrics(
412    State(state): State<DriftBudgetState>,
413    Query(params): Query<GetMetricsQuery>,
414) -> Result<Json<serde_json::Value>, StatusCode> {
415    // Query incidents for metrics
416    let mut query = IncidentQuery::default();
417    query.endpoint = params.endpoint;
418    query.method = params.method;
419    query.workspace_id = params.workspace_id;
420
421    // Filter by date range if days specified
422    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    // Calculate metrics
433    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<_>>(), // Limit to first 100
453    })))
454}
455
456/// List incidents
457///
458/// GET /api/v1/drift/incidents
459pub 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
523/// Get a specific incident
524///
525/// GET /api/v1/drift/incidents/{id}
526pub 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
538/// Update an incident
539///
540/// PATCH /api/v1/drift/incidents/{id}
541pub 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
587/// Resolve an incident
588///
589/// POST /api/v1/drift/incidents/{id}/resolve
590pub 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
603/// Get incident statistics
604///
605/// GET /api/v1/drift/incidents/stats
606pub 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
615/// Create drift budget router
616pub 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}