mockforge_contracts/incidents/
integrations.rs1use crate::incidents::types::{DriftIncident, ExternalTicket};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize, Default)]
10#[serde(default)]
11pub struct ExternalIntegrationConfig {
12 pub jira: Option<JiraConfig>,
14 pub linear: Option<LinearConfig>,
16 pub webhooks: Vec<WebhookConfig>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct JiraConfig {
23 pub url: String,
25 pub username: String,
27 pub api_token: String,
29 pub project_key: String,
31 pub issue_type: String,
33 pub priority_mapping: std::collections::HashMap<String, String>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct LinearConfig {
40 pub api_key: String,
42 pub team_id: String,
44 pub priority_mapping: std::collections::HashMap<String, String>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct WebhookConfig {
51 pub url: String,
53 pub method: Option<String>,
55 pub headers: std::collections::HashMap<String, String>,
57 pub hmac_secret: Option<String>,
59 pub events: Vec<String>,
61 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#[async_trait::async_trait]
80pub trait ExternalIntegration: Send + Sync {
81 async fn create_ticket(&self, incident: &DriftIncident) -> Result<ExternalTicket, String>;
83
84 async fn update_ticket_status(&self, ticket_id: &str, status: &str) -> Result<(), String>;
86}
87
88pub struct JiraIntegration {
90 config: JiraConfig,
91 client: reqwest::Client,
92}
93
94impl JiraIntegration {
95 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
198pub struct WebhookIntegration {
200 config: WebhookConfig,
201 client: reqwest::Client,
202}
203
204impl WebhookIntegration {
205 pub fn new(config: WebhookConfig) -> Self {
207 Self {
208 config,
209 client: reqwest::Client::new(),
210 }
211 }
212
213 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
264pub 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}