mockforge_core/incidents/
integrations.rs

1//! External system integrations for incidents
2//!
3//! This module provides integrations with external systems like Jira, Linear, etc.
4
5use crate::incidents::types::{DriftIncident, ExternalTicket};
6use serde::{Deserialize, Serialize};
7
8/// Configuration for external integrations
9#[derive(Debug, Clone, Serialize, Deserialize, Default)]
10#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
11#[serde(default)]
12pub struct ExternalIntegrationConfig {
13    /// Jira configuration
14    pub jira: Option<JiraConfig>,
15    /// Linear configuration
16    pub linear: Option<LinearConfig>,
17    /// Generic webhook configuration
18    pub webhooks: Vec<WebhookConfig>,
19}
20
21/// Jira integration configuration
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
24pub struct JiraConfig {
25    /// Jira base URL
26    pub url: String,
27    /// Username or email
28    pub username: String,
29    /// API token
30    pub api_token: String,
31    /// Project key
32    pub project_key: String,
33    /// Issue type
34    pub issue_type: String,
35    /// Priority mapping (incident severity -> Jira priority)
36    pub priority_mapping: std::collections::HashMap<String, String>,
37}
38
39/// Linear integration configuration
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
42pub struct LinearConfig {
43    /// Linear API key
44    pub api_key: String,
45    /// Team ID
46    pub team_id: String,
47    /// Priority mapping
48    pub priority_mapping: std::collections::HashMap<String, String>,
49}
50
51/// Webhook configuration
52#[derive(Debug, Clone, Serialize, Deserialize)]
53#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
54pub struct WebhookConfig {
55    /// Webhook URL
56    pub url: String,
57    /// HTTP method (default: POST)
58    pub method: Option<String>,
59    /// Headers to include
60    pub headers: std::collections::HashMap<String, String>,
61    /// HMAC secret for signature verification (optional)
62    pub hmac_secret: Option<String>,
63    /// Events to subscribe to (empty means all events)
64    pub events: Vec<String>,
65    /// Whether webhook is enabled
66    pub enabled: bool,
67}
68
69impl Default for WebhookConfig {
70    fn default() -> Self {
71        Self {
72            url: String::new(),
73            method: Some("POST".to_string()),
74            headers: std::collections::HashMap::new(),
75            hmac_secret: None,
76            events: Vec::new(),
77            enabled: true,
78        }
79    }
80}
81
82/// Trait for external integrations
83#[async_trait::async_trait]
84pub trait ExternalIntegration: Send + Sync {
85    /// Create a ticket from an incident
86    async fn create_ticket(&self, incident: &DriftIncident) -> Result<ExternalTicket, String>;
87
88    /// Update ticket status
89    async fn update_ticket_status(&self, ticket_id: &str, status: &str) -> Result<(), String>;
90}
91
92/// Jira integration implementation
93pub struct JiraIntegration {
94    config: JiraConfig,
95    client: reqwest::Client,
96}
97
98impl JiraIntegration {
99    /// Create a new Jira integration
100    pub fn new(config: JiraConfig) -> Self {
101        Self {
102            config,
103            client: reqwest::Client::new(),
104        }
105    }
106}
107
108#[async_trait::async_trait]
109impl ExternalIntegration for JiraIntegration {
110    async fn create_ticket(&self, incident: &DriftIncident) -> Result<ExternalTicket, String> {
111        // Map incident severity to Jira priority
112        let priority = self
113            .config
114            .priority_mapping
115            .get(&format!("{:?}", incident.severity))
116            .cloned()
117            .unwrap_or_else(|| "Medium".to_string());
118
119        // Create Jira issue
120        let issue_data = serde_json::json!({
121            "fields": {
122                "project": {"key": self.config.project_key},
123                "summary": format!("Contract Drift: {} {} {}", incident.method, incident.endpoint, format!("{:?}", incident.incident_type)),
124                "description": format!(
125                    "Contract drift detected on endpoint {} {}\n\nType: {:?}\nSeverity: {:?}\n\nDetails:\n{}",
126                    incident.method,
127                    incident.endpoint,
128                    incident.incident_type,
129                    incident.severity,
130                    serde_json::to_string_pretty(&incident.details).unwrap_or_default()
131                ),
132                "issuetype": {"name": self.config.issue_type},
133                "priority": {"name": priority},
134            }
135        });
136
137        let url = format!("{}/rest/api/3/issue", self.config.url);
138        let response = self
139            .client
140            .post(&url)
141            .basic_auth(&self.config.username, Some(&self.config.api_token))
142            .header("Content-Type", "application/json")
143            .json(&issue_data)
144            .send()
145            .await
146            .map_err(|e| format!("Failed to create Jira ticket: {}", e))?;
147
148        if !response.status().is_success() {
149            let error_text = response.text().await.unwrap_or_default();
150            return Err(format!("Jira API error: {}", error_text));
151        }
152
153        let result: serde_json::Value = response
154            .json()
155            .await
156            .map_err(|e| format!("Failed to parse Jira response: {}", e))?;
157
158        let ticket_id = result["key"]
159            .as_str()
160            .ok_or_else(|| "Missing ticket key in Jira response".to_string())?
161            .to_string();
162
163        let ticket_url = format!("{}/browse/{}", self.config.url, ticket_id);
164
165        // Convert result to HashMap
166        let metadata = if let serde_json::Value::Object(map) = result {
167            map.into_iter().map(|(k, v)| (k, v)).collect()
168        } else {
169            std::collections::HashMap::new()
170        };
171
172        Ok(ExternalTicket {
173            ticket_id,
174            ticket_url: Some(ticket_url),
175            system_type: "jira".to_string(),
176            metadata,
177        })
178    }
179
180    async fn update_ticket_status(&self, ticket_id: &str, status: &str) -> Result<(), String> {
181        // Jira status updates require transitions
182        // This is a simplified implementation
183        let url = format!("{}/rest/api/3/issue/{}/transitions", self.config.url, ticket_id);
184        let transition_data = serde_json::json!({
185            "transition": {"name": status}
186        });
187
188        let response = self
189            .client
190            .post(&url)
191            .basic_auth(&self.config.username, Some(&self.config.api_token))
192            .header("Content-Type", "application/json")
193            .json(&transition_data)
194            .send()
195            .await
196            .map_err(|e| format!("Failed to update Jira ticket: {}", e))?;
197
198        if !response.status().is_success() {
199            let error_text = response.text().await.unwrap_or_default();
200            return Err(format!("Jira API error: {}", error_text));
201        }
202
203        Ok(())
204    }
205}
206
207/// Generic webhook integration
208pub struct WebhookIntegration {
209    config: WebhookConfig,
210    client: reqwest::Client,
211}
212
213impl WebhookIntegration {
214    /// Create a new webhook integration
215    pub fn new(config: WebhookConfig) -> Self {
216        Self {
217            config,
218            client: reqwest::Client::new(),
219        }
220    }
221
222    /// Send incident to webhook
223    pub async fn send_incident(&self, incident: &DriftIncident) -> Result<(), String> {
224        let payload = serde_json::json!({
225            "event": "drift_incident",
226            "incident": incident,
227            "timestamp": chrono::Utc::now().to_rfc3339(),
228        });
229
230        let mut request = self
231            .client
232            .request(
233                reqwest::Method::from_bytes(
234                    self.config.method.as_deref().unwrap_or("POST").as_bytes(),
235                )
236                .unwrap_or(reqwest::Method::POST),
237                &self.config.url,
238            )
239            .json(&payload);
240
241        // Add headers
242        for (key, value) in &self.config.headers {
243            request = request.header(key, value);
244        }
245
246        // Add HMAC signature if configured
247        if let Some(ref secret) = self.config.hmac_secret {
248            use hmac::{Hmac, Mac};
249            use sha2::Sha256;
250            type HmacSha256 = Hmac<Sha256>;
251
252            let payload_str = serde_json::to_string(&payload)
253                .map_err(|e| format!("Failed to serialize payload: {}", e))?;
254
255            let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
256                .map_err(|e| format!("Failed to create HMAC: {}", e))?;
257            mac.update(payload_str.as_bytes());
258            let signature = hex::encode(mac.finalize().into_bytes());
259
260            request = request.header("X-Webhook-Signature", format!("sha256={}", signature));
261        }
262
263        let response =
264            request.send().await.map_err(|e| format!("Failed to send webhook: {}", e))?;
265
266        if !response.status().is_success() {
267            let error_text = response.text().await.unwrap_or_default();
268            return Err(format!("Webhook error: {}", error_text));
269        }
270
271        Ok(())
272    }
273}
274
275/// Send a webhook notification
276pub async fn send_webhook(
277    config: &WebhookConfig,
278    payload: &serde_json::Value,
279) -> Result<(), String> {
280    let client = reqwest::Client::new();
281
282    let mut request = client
283        .request(
284            reqwest::Method::from_bytes(config.method.as_deref().unwrap_or("POST").as_bytes())
285                .unwrap_or(reqwest::Method::POST),
286            &config.url,
287        )
288        .json(payload);
289
290    // Add headers
291    for (key, value) in &config.headers {
292        request = request.header(key, value);
293    }
294
295    // Add HMAC signature if configured
296    if let Some(ref secret) = config.hmac_secret {
297        use hmac::{Hmac, Mac};
298        use sha2::Sha256;
299        type HmacSha256 = Hmac<Sha256>;
300
301        let payload_str = serde_json::to_string(payload)
302            .map_err(|e| format!("Failed to serialize payload: {}", e))?;
303
304        let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
305            .map_err(|e| format!("Failed to create HMAC: {}", e))?;
306        mac.update(payload_str.as_bytes());
307        let signature = hex::encode(mac.finalize().into_bytes());
308
309        request = request.header("X-Webhook-Signature", format!("sha256={}", signature));
310    }
311
312    let response = request.send().await.map_err(|e| format!("Failed to send webhook: {}", e))?;
313
314    if !response.status().is_success() {
315        let error_text = response.text().await.unwrap_or_default();
316        return Err(format!("Webhook error: {}", error_text));
317    }
318
319    Ok(())
320}