Skip to main content

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