opssignal_core/providers/
teams.rs1use crate::delivery::Provider;
2use crate::signal::{Severity, Signal, SignalError};
3use crate::template::{self, Templates};
4
5pub struct TeamsProvider {
9 pub webhook_url: String,
10 client: reqwest::Client,
11 templates: Option<Templates>,
12}
13
14impl TeamsProvider {
15 pub fn new(webhook_url: String) -> Self {
16 TeamsProvider {
17 webhook_url,
18 client: reqwest::Client::new(),
19 templates: None,
20 }
21 }
22
23 pub fn with_templates(mut self, templates: Option<Templates>) -> Self {
25 self.templates = templates;
26 self
27 }
28
29 fn color_for(severity: Severity) -> &'static str {
31 match severity {
32 Severity::Info => "default",
33 Severity::Success => "good",
34 Severity::Warning => "warning",
35 Severity::Error | Severity::Critical => "attention",
36 }
37 }
38
39 fn card_for(&self, signal: &Signal) -> serde_json::Value {
40 let title = template::render(&self.templates, "title", signal, &[])
41 .unwrap_or_else(|| signal.title.clone());
42 let message = template::render(&self.templates, "text", signal, &[])
43 .or_else(|| signal.message.clone());
44 let mut body = vec![serde_json::json!({
45 "type": "TextBlock",
46 "text": title,
47 "weight": "bolder",
48 "size": "medium",
49 "color": Self::color_for(signal.severity),
50 "wrap": true,
51 })];
52 if let Some(message) = message {
53 body.push(serde_json::json!({
54 "type": "TextBlock",
55 "text": message,
56 "wrap": true,
57 }));
58 }
59
60 let mut facts = vec![
61 fact("Severity", &signal.severity.to_string()),
62 fact("Source", &signal.source),
63 fact("Event", &signal.event_type),
64 ];
65 let optional = [
66 ("Environment", &signal.environment),
67 ("Service", &signal.service),
68 ("Component", &signal.component),
69 ("Team", &signal.team),
70 ("Run ID", &signal.run_id),
71 ];
72 for (title, value) in optional {
73 if let Some(value) = value {
74 facts.push(fact(title, value));
75 }
76 }
77 body.push(serde_json::json!({ "type": "FactSet", "facts": facts }));
78
79 let mut card = serde_json::json!({
80 "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
81 "type": "AdaptiveCard",
82 "version": "1.4",
83 "body": body,
84 });
85 if let Some(log_url) = &signal.log_url {
86 card["actions"] = serde_json::json!([{
87 "type": "Action.OpenUrl",
88 "title": "View logs",
89 "url": log_url,
90 }]);
91 }
92
93 serde_json::json!({
94 "type": "message",
95 "attachments": [{
96 "contentType": "application/vnd.microsoft.card.adaptive",
97 "contentUrl": null,
98 "content": card,
99 }],
100 })
101 }
102}
103
104fn fact(title: &str, value: &str) -> serde_json::Value {
105 serde_json::json!({ "title": title, "value": value })
106}
107
108#[async_trait::async_trait]
109impl Provider for TeamsProvider {
110 fn name(&self) -> &'static str {
111 "teams"
112 }
113
114 fn preview(&self, signal: &Signal) -> Option<serde_json::Value> {
115 Some(self.card_for(signal))
116 }
117
118 async fn send(&self, signal: &Signal) -> Result<(), SignalError> {
119 let resp = self
120 .client
121 .post(&self.webhook_url)
122 .json(&self.card_for(signal))
123 .send()
124 .await
125 .map_err(|e| SignalError::Delivery(e.to_string()))?;
126
127 if !resp.status().is_success() {
128 return Err(SignalError::Delivery(format!(
129 "teams webhook returned status {}",
130 resp.status()
131 )));
132 }
133 Ok(())
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use crate::signal::SignalInput;
141 use wiremock::matchers::{body_partial_json, header, method, path};
142 use wiremock::{Mock, MockServer, ResponseTemplate};
143
144 fn signal(severity: Severity) -> Signal {
145 Signal::from_input(SignalInput {
146 source: "airflow".into(),
147 event_type: "task_failed".into(),
148 title: "dbt model failed".into(),
149 message: Some("model orders timed out".into()),
150 severity,
151 environment: Some("prod".into()),
152 log_url: Some("https://logs.example.test/run/1".into()),
153 ..Default::default()
154 })
155 }
156
157 fn facts(card: &serde_json::Value) -> Vec<(String, String)> {
158 card["attachments"][0]["content"]["body"]
159 .as_array()
160 .unwrap()
161 .iter()
162 .find(|b| b["type"] == "FactSet")
163 .unwrap()["facts"]
164 .as_array()
165 .unwrap()
166 .iter()
167 .map(|f| {
168 (
169 f["title"].as_str().unwrap().to_string(),
170 f["value"].as_str().unwrap().to_string(),
171 )
172 })
173 .collect()
174 }
175
176 #[test]
177 fn severities_map_to_card_colours() {
178 assert_eq!(TeamsProvider::color_for(Severity::Info), "default");
179 assert_eq!(TeamsProvider::color_for(Severity::Success), "good");
180 assert_eq!(TeamsProvider::color_for(Severity::Warning), "warning");
181 assert_eq!(TeamsProvider::color_for(Severity::Error), "attention");
182 assert_eq!(TeamsProvider::color_for(Severity::Critical), "attention");
183 }
184
185 #[test]
186 fn card_has_title_message_facts_and_log_link() {
187 let card = TeamsProvider::new(String::new()).card_for(&signal(Severity::Error));
188 assert_eq!(card["type"], "message");
189 let attachment = &card["attachments"][0];
190 assert_eq!(
191 attachment["contentType"],
192 "application/vnd.microsoft.card.adaptive"
193 );
194 let content = &attachment["content"];
195 assert_eq!(content["type"], "AdaptiveCard");
196 assert_eq!(content["body"][0]["text"], "dbt model failed");
197 assert_eq!(content["body"][0]["color"], "attention");
198 assert_eq!(content["body"][1]["text"], "model orders timed out");
199 assert_eq!(
200 content["actions"][0]["url"],
201 "https://logs.example.test/run/1"
202 );
203 assert_eq!(
204 facts(&card),
205 vec![
206 ("Severity".to_string(), "error".to_string()),
207 ("Source".to_string(), "airflow".to_string()),
208 ("Event".to_string(), "task_failed".to_string()),
209 ("Environment".to_string(), "prod".to_string()),
210 ]
211 );
212 }
213
214 #[test]
215 fn minimal_signal_has_no_message_block_or_actions() {
216 let card = TeamsProvider::new(String::new()).card_for(&Signal::from_input(SignalInput {
217 source: "ci".into(),
218 event_type: "build_failed".into(),
219 title: "main is red".into(),
220 ..Default::default()
221 }));
222 let content = &card["attachments"][0]["content"];
223 let body = content["body"].as_array().unwrap();
224 assert_eq!(body.len(), 2, "title and facts only");
225 assert_eq!(body[1]["type"], "FactSet");
226 assert!(content.get("actions").is_none());
227 }
228
229 #[tokio::test]
230 async fn posts_the_card_to_the_webhook() {
231 let server = MockServer::start().await;
232 Mock::given(method("POST"))
233 .and(path("/workflows/abc"))
234 .and(header("content-type", "application/json"))
235 .and(body_partial_json(serde_json::json!({ "type": "message" })))
236 .respond_with(ResponseTemplate::new(202))
237 .expect(1)
238 .mount(&server)
239 .await;
240
241 TeamsProvider::new(format!("{}/workflows/abc", server.uri()))
242 .send(&signal(Severity::Warning))
243 .await
244 .expect("202 is success");
245 }
246
247 #[tokio::test]
248 async fn non_success_status_is_a_delivery_error() {
249 let server = MockServer::start().await;
250 Mock::given(method("POST"))
251 .respond_with(ResponseTemplate::new(403))
252 .mount(&server)
253 .await;
254
255 let err = TeamsProvider::new(server.uri())
256 .send(&signal(Severity::Error))
257 .await
258 .expect_err("403 must fail");
259 assert!(matches!(err, SignalError::Delivery(_)));
260 assert!(err.to_string().contains("403"));
261 }
262
263 #[test]
264 fn title_and_text_templates_replace_the_text_blocks() {
265 let provider = TeamsProvider::new(String::new()).with_templates(template::for_test(
266 "teams",
267 &[
268 ("title", "{{ severity|upper }}: {{ title }}"),
269 ("text", "{{ message }} - see {{ log_url }}"),
270 ],
271 ));
272 let card = provider.card_for(&signal(Severity::Error));
273 let body = &card["attachments"][0]["content"]["body"];
274 assert_eq!(body[0]["text"], "ERROR: dbt model failed");
275 assert_eq!(
276 body[1]["text"],
277 "model orders timed out - see https://logs.example.test/run/1"
278 );
279 assert!(facts(&card).contains(&("Source".to_string(), "airflow".to_string())));
281 }
282}