use crate::incidents::types::{DriftIncident, ExternalTicket};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(default)]
pub struct ExternalIntegrationConfig {
pub jira: Option<JiraConfig>,
pub linear: Option<LinearConfig>,
pub webhooks: Vec<WebhookConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct JiraConfig {
pub url: String,
pub username: String,
pub api_token: String,
pub project_key: String,
pub issue_type: String,
pub priority_mapping: std::collections::HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct LinearConfig {
pub api_key: String,
pub team_id: String,
pub priority_mapping: std::collections::HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct WebhookConfig {
pub url: String,
pub method: Option<String>,
pub headers: std::collections::HashMap<String, String>,
pub hmac_secret: Option<String>,
pub events: Vec<String>,
pub enabled: bool,
}
impl Default for WebhookConfig {
fn default() -> Self {
Self {
url: String::new(),
method: Some("POST".to_string()),
headers: std::collections::HashMap::new(),
hmac_secret: None,
events: Vec::new(),
enabled: true,
}
}
}
#[async_trait::async_trait]
pub trait ExternalIntegration: Send + Sync {
async fn create_ticket(&self, incident: &DriftIncident) -> Result<ExternalTicket, String>;
async fn update_ticket_status(&self, ticket_id: &str, status: &str) -> Result<(), String>;
}
pub struct JiraIntegration {
config: JiraConfig,
client: reqwest::Client,
}
impl JiraIntegration {
pub fn new(config: JiraConfig) -> Self {
Self {
config,
client: reqwest::Client::new(),
}
}
}
#[async_trait::async_trait]
impl ExternalIntegration for JiraIntegration {
async fn create_ticket(&self, incident: &DriftIncident) -> Result<ExternalTicket, String> {
let priority = self
.config
.priority_mapping
.get(&format!("{:?}", incident.severity))
.cloned()
.unwrap_or_else(|| "Medium".to_string());
let issue_data = serde_json::json!({
"fields": {
"project": {"key": self.config.project_key},
"summary": format!("Contract Drift: {} {} {}", incident.method, incident.endpoint, format!("{:?}", incident.incident_type)),
"description": format!(
"Contract drift detected on endpoint {} {}\n\nType: {:?}\nSeverity: {:?}\n\nDetails:\n{}",
incident.method,
incident.endpoint,
incident.incident_type,
incident.severity,
serde_json::to_string_pretty(&incident.details).unwrap_or_default()
),
"issuetype": {"name": self.config.issue_type},
"priority": {"name": priority},
}
});
let url = format!("{}/rest/api/3/issue", self.config.url);
let response = self
.client
.post(&url)
.basic_auth(&self.config.username, Some(&self.config.api_token))
.header("Content-Type", "application/json")
.json(&issue_data)
.send()
.await
.map_err(|e| format!("Failed to create Jira ticket: {}", e))?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(format!("Jira API error: {}", error_text));
}
let result: serde_json::Value = response
.json()
.await
.map_err(|e| format!("Failed to parse Jira response: {}", e))?;
let ticket_id = result["key"]
.as_str()
.ok_or_else(|| "Missing ticket key in Jira response".to_string())?
.to_string();
let ticket_url = format!("{}/browse/{}", self.config.url, ticket_id);
let metadata = if let serde_json::Value::Object(map) = result {
map.into_iter().collect()
} else {
std::collections::HashMap::new()
};
Ok(ExternalTicket {
ticket_id,
ticket_url: Some(ticket_url),
system_type: "jira".to_string(),
metadata,
})
}
async fn update_ticket_status(&self, ticket_id: &str, status: &str) -> Result<(), String> {
let url = format!("{}/rest/api/3/issue/{}/transitions", self.config.url, ticket_id);
let transition_data = serde_json::json!({
"transition": {"name": status}
});
let response = self
.client
.post(&url)
.basic_auth(&self.config.username, Some(&self.config.api_token))
.header("Content-Type", "application/json")
.json(&transition_data)
.send()
.await
.map_err(|e| format!("Failed to update Jira ticket: {}", e))?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(format!("Jira API error: {}", error_text));
}
Ok(())
}
}
pub struct WebhookIntegration {
config: WebhookConfig,
client: reqwest::Client,
}
impl WebhookIntegration {
pub fn new(config: WebhookConfig) -> Self {
Self {
config,
client: reqwest::Client::new(),
}
}
pub async fn send_incident(&self, incident: &DriftIncident) -> Result<(), String> {
let payload = serde_json::json!({
"event": "drift_incident",
"incident": incident,
"timestamp": chrono::Utc::now().to_rfc3339(),
});
let mut request = self
.client
.request(
reqwest::Method::from_bytes(
self.config.method.as_deref().unwrap_or("POST").as_bytes(),
)
.unwrap_or(reqwest::Method::POST),
&self.config.url,
)
.json(&payload);
for (key, value) in &self.config.headers {
request = request.header(key, value);
}
if let Some(ref secret) = self.config.hmac_secret {
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let payload_str = serde_json::to_string(&payload)
.map_err(|e| format!("Failed to serialize payload: {}", e))?;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.map_err(|e| format!("Failed to create HMAC: {}", e))?;
mac.update(payload_str.as_bytes());
let signature = hex::encode(mac.finalize().into_bytes());
request = request.header("X-Webhook-Signature", format!("sha256={}", signature));
}
let response =
request.send().await.map_err(|e| format!("Failed to send webhook: {}", e))?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(format!("Webhook error: {}", error_text));
}
Ok(())
}
}
pub async fn send_webhook(
config: &WebhookConfig,
payload: &serde_json::Value,
) -> Result<(), String> {
let client = reqwest::Client::new();
let mut request = client
.request(
reqwest::Method::from_bytes(config.method.as_deref().unwrap_or("POST").as_bytes())
.unwrap_or(reqwest::Method::POST),
&config.url,
)
.json(payload);
for (key, value) in &config.headers {
request = request.header(key, value);
}
if let Some(ref secret) = config.hmac_secret {
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let payload_str = serde_json::to_string(payload)
.map_err(|e| format!("Failed to serialize payload: {}", e))?;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.map_err(|e| format!("Failed to create HMAC: {}", e))?;
mac.update(payload_str.as_bytes());
let signature = hex::encode(mac.finalize().into_bytes());
request = request.header("X-Webhook-Signature", format!("sha256={}", signature));
}
let response = request.send().await.map_err(|e| format!("Failed to send webhook: {}", e))?;
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(format!("Webhook error: {}", error_text));
}
Ok(())
}