1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct IntegrationConfig {
8 pub slack: Option<SlackConfig>,
9 pub teams: Option<TeamsConfig>,
10 pub jira: Option<JiraConfig>,
11 pub pagerduty: Option<PagerDutyConfig>,
12 pub grafana: Option<GrafanaConfig>,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct SlackConfig {
18 pub webhook_url: String,
19 pub channel: String,
20 pub username: Option<String>,
21 pub icon_emoji: Option<String>,
22 pub mention_users: Vec<String>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct TeamsConfig {
28 pub webhook_url: String,
29 pub mention_users: Vec<String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct JiraConfig {
35 pub url: String,
36 pub username: String,
37 pub api_token: String,
38 pub project_key: String,
39 pub issue_type: String,
40 pub priority: Option<String>,
41 pub assignee: Option<String>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct PagerDutyConfig {
47 pub routing_key: String,
48 pub severity: Option<String>,
49 pub dedup_key_prefix: Option<String>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct GrafanaConfig {
55 pub url: String,
56 pub api_key: String,
57 pub dashboard_uid: Option<String>,
58 pub folder_uid: Option<String>,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
63pub enum NotificationSeverity {
64 Info,
65 Warning,
66 Error,
67 Critical,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct Notification {
73 pub title: String,
74 pub message: String,
75 pub severity: NotificationSeverity,
76 pub timestamp: chrono::DateTime<chrono::Utc>,
77 pub metadata: HashMap<String, serde_json::Value>,
78}
79
80pub struct SlackNotifier {
82 config: SlackConfig,
83 client: reqwest::Client,
84}
85
86impl SlackNotifier {
87 pub fn new(config: SlackConfig) -> Self {
88 Self {
89 config,
90 client: reqwest::Client::new(),
91 }
92 }
93
94 pub async fn send(&self, notification: &Notification) -> Result<()> {
95 let color = match notification.severity {
96 NotificationSeverity::Info => "#36a64f",
97 NotificationSeverity::Warning => "#ff9900",
98 NotificationSeverity::Error => "#ff0000",
99 NotificationSeverity::Critical => "#8b0000",
100 };
101
102 let mentions = if !self.config.mention_users.is_empty() {
103 format!(
104 "\n{}",
105 self.config
106 .mention_users
107 .iter()
108 .map(|u| format!("<@{}>", u))
109 .collect::<Vec<_>>()
110 .join(" ")
111 )
112 } else {
113 String::new()
114 };
115
116 let payload = serde_json::json!({
117 "channel": self.config.channel,
118 "username": self.config.username.as_deref().unwrap_or("MockForge"),
119 "icon_emoji": self.config.icon_emoji.as_deref().unwrap_or(":robot_face:"),
120 "attachments": [{
121 "color": color,
122 "title": notification.title,
123 "text": format!("{}{}", notification.message, mentions),
124 "timestamp": notification.timestamp.timestamp(),
125 "fields": notification.metadata.iter().map(|(k, v)| {
126 serde_json::json!({
127 "title": k,
128 "value": v.to_string(),
129 "short": true
130 })
131 }).collect::<Vec<_>>()
132 }]
133 });
134
135 self.client
136 .post(&self.config.webhook_url)
137 .json(&payload)
138 .send()
139 .await
140 .context("Failed to send Slack notification")?;
141
142 Ok(())
143 }
144}
145
146pub struct TeamsNotifier {
148 config: TeamsConfig,
149 client: reqwest::Client,
150}
151
152impl TeamsNotifier {
153 pub fn new(config: TeamsConfig) -> Self {
154 Self {
155 config,
156 client: reqwest::Client::new(),
157 }
158 }
159
160 pub async fn send(&self, notification: &Notification) -> Result<()> {
161 let theme_color = match notification.severity {
162 NotificationSeverity::Info => "0078D4",
163 NotificationSeverity::Warning => "FFA500",
164 NotificationSeverity::Error => "FF0000",
165 NotificationSeverity::Critical => "8B0000",
166 };
167
168 let mentions = if !self.config.mention_users.is_empty() {
169 format!(
170 "\n\n{}",
171 self.config
172 .mention_users
173 .iter()
174 .map(|u| format!("<at>{}</at>", u))
175 .collect::<Vec<_>>()
176 .join(" ")
177 )
178 } else {
179 String::new()
180 };
181
182 let facts: Vec<_> = notification
183 .metadata
184 .iter()
185 .map(|(k, v)| {
186 serde_json::json!({
187 "name": k,
188 "value": v.to_string()
189 })
190 })
191 .collect();
192
193 let payload = serde_json::json!({
194 "@type": "MessageCard",
195 "@context": "https://schema.org/extensions",
196 "themeColor": theme_color,
197 "summary": notification.title,
198 "sections": [{
199 "activityTitle": notification.title,
200 "activitySubtitle": format!("Severity: {:?}", notification.severity),
201 "text": format!("{}{}", notification.message, mentions),
202 "facts": facts
203 }]
204 });
205
206 self.client
207 .post(&self.config.webhook_url)
208 .json(&payload)
209 .send()
210 .await
211 .context("Failed to send Teams notification")?;
212
213 Ok(())
214 }
215}
216
217pub struct JiraIntegration {
219 config: JiraConfig,
220 client: reqwest::Client,
221}
222
223impl JiraIntegration {
224 pub fn new(config: JiraConfig) -> Self {
225 Self {
226 config,
227 client: reqwest::Client::new(),
228 }
229 }
230
231 pub async fn create_ticket(&self, notification: &Notification) -> Result<String> {
232 let description = format!(
233 "{}\n\n*Metadata:*\n{}",
234 notification.message,
235 notification
236 .metadata
237 .iter()
238 .map(|(k, v)| format!("* {}: {}", k, v))
239 .collect::<Vec<_>>()
240 .join("\n")
241 );
242
243 let priority = self.config.priority.as_deref().or({
244 Some(match notification.severity {
245 NotificationSeverity::Critical => "Highest",
246 NotificationSeverity::Error => "High",
247 NotificationSeverity::Warning => "Medium",
248 NotificationSeverity::Info => "Low",
249 })
250 });
251
252 let mut fields = serde_json::json!({
253 "project": {
254 "key": self.config.project_key
255 },
256 "summary": notification.title,
257 "description": description,
258 "issuetype": {
259 "name": self.config.issue_type
260 }
261 });
262
263 if let Some(priority) = priority {
264 fields["priority"] = serde_json::json!({ "name": priority });
265 }
266
267 if let Some(assignee) = &self.config.assignee {
268 fields["assignee"] = serde_json::json!({ "name": assignee });
269 }
270
271 let payload = serde_json::json!({ "fields": fields });
272
273 let url = format!("{}/rest/api/2/issue", self.config.url);
274
275 let response = self
276 .client
277 .post(&url)
278 .basic_auth(&self.config.username, Some(&self.config.api_token))
279 .json(&payload)
280 .send()
281 .await
282 .context("Failed to create Jira ticket")?;
283
284 let result: serde_json::Value = response.json().await?;
285 let ticket_key = result["key"]
286 .as_str()
287 .context("Failed to get ticket key from response")?
288 .to_string();
289
290 Ok(ticket_key)
291 }
292
293 pub async fn update_ticket(&self, ticket_key: &str, comment: &str) -> Result<()> {
294 let payload = serde_json::json!({
295 "body": comment
296 });
297
298 let url = format!("{}/rest/api/2/issue/{}/comment", self.config.url, ticket_key);
299
300 self.client
301 .post(&url)
302 .basic_auth(&self.config.username, Some(&self.config.api_token))
303 .json(&payload)
304 .send()
305 .await
306 .context("Failed to add comment to Jira ticket")?;
307
308 Ok(())
309 }
310}
311
312pub struct PagerDutyIntegration {
314 config: PagerDutyConfig,
315 client: reqwest::Client,
316}
317
318impl PagerDutyIntegration {
319 pub fn new(config: PagerDutyConfig) -> Self {
320 Self {
321 config,
322 client: reqwest::Client::new(),
323 }
324 }
325
326 pub async fn trigger_incident(&self, notification: &Notification) -> Result<String> {
327 let severity = self.config.severity.as_deref().or({
328 Some(match notification.severity {
329 NotificationSeverity::Critical => "critical",
330 NotificationSeverity::Error => "error",
331 NotificationSeverity::Warning => "warning",
332 NotificationSeverity::Info => "info",
333 })
334 });
335
336 let dedup_key = format!(
337 "{}-{}",
338 self.config.dedup_key_prefix.as_deref().unwrap_or("mockforge"),
339 notification.timestamp.timestamp()
340 );
341
342 let payload = serde_json::json!({
343 "routing_key": self.config.routing_key,
344 "event_action": "trigger",
345 "dedup_key": dedup_key,
346 "payload": {
347 "summary": notification.title,
348 "severity": severity,
349 "source": "MockForge",
350 "timestamp": notification.timestamp.to_rfc3339(),
351 "custom_details": notification.metadata
352 }
353 });
354
355 let response = self
356 .client
357 .post("https://events.pagerduty.com/v2/enqueue")
358 .json(&payload)
359 .send()
360 .await
361 .context("Failed to trigger PagerDuty incident")?;
362
363 let _result: serde_json::Value = response.json().await?;
364 Ok(dedup_key)
365 }
366
367 pub async fn resolve_incident(&self, dedup_key: &str) -> Result<()> {
368 let payload = serde_json::json!({
369 "routing_key": self.config.routing_key,
370 "event_action": "resolve",
371 "dedup_key": dedup_key
372 });
373
374 self.client
375 .post("https://events.pagerduty.com/v2/enqueue")
376 .json(&payload)
377 .send()
378 .await
379 .context("Failed to resolve PagerDuty incident")?;
380
381 Ok(())
382 }
383}
384
385pub struct GrafanaIntegration {
387 config: GrafanaConfig,
388 client: reqwest::Client,
389}
390
391impl GrafanaIntegration {
392 pub fn new(config: GrafanaConfig) -> Self {
393 Self {
394 config,
395 client: reqwest::Client::new(),
396 }
397 }
398
399 pub async fn create_annotation(&self, notification: &Notification) -> Result<()> {
400 let tags = vec![
401 format!("severity:{:?}", notification.severity).to_lowercase(),
402 "mockforge".to_string(),
403 ];
404
405 let payload = serde_json::json!({
406 "text": notification.message,
407 "tags": tags,
408 "time": notification.timestamp.timestamp_millis(),
409 "dashboardUID": self.config.dashboard_uid
410 });
411
412 let url = format!("{}/api/annotations", self.config.url);
413
414 self.client
415 .post(&url)
416 .header("Authorization", format!("Bearer {}", self.config.api_key))
417 .json(&payload)
418 .send()
419 .await
420 .context("Failed to create Grafana annotation")?;
421
422 Ok(())
423 }
424
425 pub async fn create_dashboard(&self, dashboard_json: serde_json::Value) -> Result<String> {
426 let payload = serde_json::json!({
427 "dashboard": dashboard_json,
428 "folderUid": self.config.folder_uid,
429 "overwrite": false
430 });
431
432 let url = format!("{}/api/dashboards/db", self.config.url);
433
434 let response = self
435 .client
436 .post(&url)
437 .header("Authorization", format!("Bearer {}", self.config.api_key))
438 .json(&payload)
439 .send()
440 .await
441 .context("Failed to create Grafana dashboard")?;
442
443 let result: serde_json::Value = response.json().await?;
444 let uid = result["uid"].as_str().context("Failed to get dashboard UID")?.to_string();
445
446 Ok(uid)
447 }
448}
449
450pub struct IntegrationManager {
452 slack: Option<SlackNotifier>,
453 teams: Option<TeamsNotifier>,
454 jira: Option<JiraIntegration>,
455 pagerduty: Option<PagerDutyIntegration>,
456 grafana: Option<GrafanaIntegration>,
457}
458
459impl IntegrationManager {
460 pub fn new(config: IntegrationConfig) -> Self {
461 Self {
462 slack: config.slack.map(SlackNotifier::new),
463 teams: config.teams.map(TeamsNotifier::new),
464 jira: config.jira.map(JiraIntegration::new),
465 pagerduty: config.pagerduty.map(PagerDutyIntegration::new),
466 grafana: config.grafana.map(GrafanaIntegration::new),
467 }
468 }
469
470 pub async fn notify(&self, notification: &Notification) -> Result<NotificationResults> {
472 let mut results = NotificationResults::default();
473
474 if let Some(slack) = &self.slack {
476 match slack.send(notification).await {
477 Ok(_) => results.slack_sent = true,
478 Err(e) => results.errors.push(format!("Slack: {}", e)),
479 }
480 }
481
482 if let Some(teams) = &self.teams {
484 match teams.send(notification).await {
485 Ok(_) => results.teams_sent = true,
486 Err(e) => results.errors.push(format!("Teams: {}", e)),
487 }
488 }
489
490 if let Some(jira) = &self.jira {
492 if matches!(
493 notification.severity,
494 NotificationSeverity::Error | NotificationSeverity::Critical
495 ) {
496 match jira.create_ticket(notification).await {
497 Ok(key) => {
498 results.jira_ticket = Some(key);
499 }
500 Err(e) => results.errors.push(format!("Jira: {}", e)),
501 }
502 }
503 }
504
505 if let Some(pd) = &self.pagerduty {
507 if notification.severity == NotificationSeverity::Critical {
508 match pd.trigger_incident(notification).await {
509 Ok(key) => {
510 results.pagerduty_incident = Some(key);
511 }
512 Err(e) => results.errors.push(format!("PagerDuty: {}", e)),
513 }
514 }
515 }
516
517 if let Some(grafana) = &self.grafana {
519 match grafana.create_annotation(notification).await {
520 Ok(_) => results.grafana_annotated = true,
521 Err(e) => results.errors.push(format!("Grafana: {}", e)),
522 }
523 }
524
525 Ok(results)
526 }
527}
528
529#[derive(Debug, Default, Serialize, Deserialize)]
530pub struct NotificationResults {
531 pub slack_sent: bool,
532 pub teams_sent: bool,
533 pub jira_ticket: Option<String>,
534 pub pagerduty_incident: Option<String>,
535 pub grafana_annotated: bool,
536 pub errors: Vec<String>,
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542
543 #[test]
544 fn test_notification_creation() {
545 let notification = Notification {
546 title: "Test Alert".to_string(),
547 message: "This is a test".to_string(),
548 severity: NotificationSeverity::Warning,
549 timestamp: chrono::Utc::now(),
550 metadata: HashMap::new(),
551 };
552
553 assert_eq!(notification.severity, NotificationSeverity::Warning);
554 }
555}