mockforge_core/incidents/
integrations.rs1use crate::incidents::types::{DriftIncident, ExternalTicket};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize, Default)]
10#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
11#[serde(default)]
12pub struct ExternalIntegrationConfig {
13 pub jira: Option<JiraConfig>,
15 pub linear: Option<LinearConfig>,
17 pub webhooks: Vec<WebhookConfig>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
24pub struct JiraConfig {
25 pub url: String,
27 pub username: String,
29 pub api_token: String,
31 pub project_key: String,
33 pub issue_type: String,
35 pub priority_mapping: std::collections::HashMap<String, String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
42pub struct LinearConfig {
43 pub api_key: String,
45 pub team_id: String,
47 pub priority_mapping: std::collections::HashMap<String, String>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
54pub struct WebhookConfig {
55 pub url: String,
57 pub method: Option<String>,
59 pub headers: std::collections::HashMap<String, String>,
61 pub hmac_secret: Option<String>,
63 pub events: Vec<String>,
65 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#[async_trait::async_trait]
84pub trait ExternalIntegration: Send + Sync {
85 async fn create_ticket(&self, incident: &DriftIncident) -> Result<ExternalTicket, String>;
87
88 async fn update_ticket_status(&self, ticket_id: &str, status: &str) -> Result<(), String>;
90}
91
92pub struct JiraIntegration {
94 config: JiraConfig,
95 client: reqwest::Client,
96}
97
98impl JiraIntegration {
99 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 let priority = self
113 .config
114 .priority_mapping
115 .get(&format!("{:?}", incident.severity))
116 .cloned()
117 .unwrap_or_else(|| "Medium".to_string());
118
119 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 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 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
207pub struct WebhookIntegration {
209 config: WebhookConfig,
210 client: reqwest::Client,
211}
212
213impl WebhookIntegration {
214 pub fn new(config: WebhookConfig) -> Self {
216 Self {
217 config,
218 client: reqwest::Client::new(),
219 }
220 }
221
222 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 for (key, value) in &self.config.headers {
243 request = request.header(key, value);
244 }
245
246 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
275pub 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 for (key, value) in &config.headers {
292 request = request.header(key, value);
293 }
294
295 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}