1use super::html_escape;
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum NotificationLevel {
17 All,
21 Verbose,
23 ErrorsOnly,
25 None,
27}
28
29impl NotificationLevel {
30 pub fn from_config() -> Self {
32 match crate::config::get("notifications.level") {
33 Ok(val) => match val.as_str() {
34 "verbose" => Self::Verbose,
35 "errors_only" => Self::ErrorsOnly,
36 "none" => Self::None,
37 _ => Self::All,
38 },
39 Err(_) => Self::All,
40 }
41 }
42
43 pub fn should_notify(&self, status: &str) -> bool {
51 match self {
52 Self::All => matches!(
53 status,
54 "done" | "in_review" | "approved" | "blocked" | "failed"
55 ),
56 Self::Verbose => true,
57 Self::ErrorsOnly => matches!(status, "blocked" | "failed"),
58 Self::None => false,
59 }
60 }
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct TaskNotification {
66 pub task_id: String,
67 pub title: String,
68 pub status: String,
69 pub agent: String,
70 pub duration_seconds: f64,
71 pub summary: String,
72 pub repo: Option<String>,
74 #[serde(default)]
78 pub notify_target: Option<String>,
79 #[serde(default)]
81 pub pr_number: Option<String>,
82}
83
84fn github_link(repo: Option<&str>, task_id: &str, pr_number: Option<&str>) -> Option<String> {
87 let repo = repo?;
88 if let Some(pr) = pr_number {
89 return Some(format!("https://github.com/{repo}/pull/{pr}"));
90 }
91 if !task_id.starts_with("internal:") && task_id.chars().all(|c| c.is_ascii_digit()) {
93 return Some(format!("https://github.com/{repo}/issues/{task_id}"));
94 }
95 None
96}
97
98impl TaskNotification {
99 pub fn format_telegram(&self) -> String {
105 let emoji = status_emoji(&self.status);
106 let duration = format_duration(self.duration_seconds);
107 let link_suffix = match github_link(
108 self.repo.as_deref(),
109 &self.task_id,
110 self.pr_number.as_deref(),
111 ) {
112 Some(url) => format!("\n\n<a href=\"{}\">View on GitHub</a>", html_escape(&url)),
113 None => String::new(),
114 };
115
116 format!(
117 "{emoji} <b>{title}</b>\n\
118 {status} · <code>{agent}</code> · {duration} · #{task_id}\n\
119 \n\
120 {summary}{link_suffix}",
121 emoji = emoji,
122 title = html_escape(&self.title),
123 status = html_escape(&self.status),
124 agent = html_escape(&self.agent),
125 duration = duration,
126 task_id = html_escape(&self.task_id),
127 summary = html_escape(&self.summary),
128 link_suffix = link_suffix,
129 )
130 }
131
132 pub fn format_slack(&self) -> String {
136 let emoji = status_emoji(&self.status);
137 let duration = format_duration(self.duration_seconds);
138 let link_suffix = match github_link(
139 self.repo.as_deref(),
140 &self.task_id,
141 self.pr_number.as_deref(),
142 ) {
143 Some(url) => format!("\n\n<{url}|View on GitHub>"),
144 None => String::new(),
145 };
146
147 format!(
148 "{emoji} *{title}*\n\
149 {status} · `{agent}` · {duration} · #{task_id}\n\
150 \n\
151 {summary}{link_suffix}",
152 emoji = emoji,
153 title = self.title,
154 status = self.status,
155 agent = self.agent,
156 duration = duration,
157 task_id = self.task_id,
158 summary = self.summary,
159 link_suffix = link_suffix,
160 )
161 }
162
163 pub fn format_discord(&self) -> String {
165 let emoji = status_emoji(&self.status);
166 let duration = format_duration(self.duration_seconds);
167 let link_suffix = match github_link(
168 self.repo.as_deref(),
169 &self.task_id,
170 self.pr_number.as_deref(),
171 ) {
172 Some(url) => format!("\n\n[View on GitHub]({url})"),
173 None => String::new(),
174 };
175
176 format!(
177 "{emoji} **{title}**\n\
178 {status} · `{agent}` · {duration} · #{task_id}\n\
179 \n\
180 {summary}{link_suffix}",
181 emoji = emoji,
182 title = self.title,
183 status = self.status,
184 agent = self.agent,
185 duration = duration,
186 task_id = self.task_id,
187 summary = self.summary,
188 link_suffix = link_suffix,
189 )
190 }
191
192 pub fn format_with_project(&self, channel: &str) -> String {
197 let project_name = self
198 .repo
199 .as_deref()
200 .and_then(|r| r.split('/').next_back())
201 .unwrap_or("unknown");
202
203 let base = match channel {
204 "discord" => self.format_discord(),
205 "slack" => self.format_slack(),
206 _ => self.format_telegram(),
207 };
208
209 let project_name = if channel == "telegram" {
210 html_escape(project_name)
211 } else {
212 project_name.to_string()
213 };
214 format!("[{project_name}] {base}")
215 }
216}
217
218fn status_emoji(status: &str) -> &'static str {
219 match status {
220 "done" => "✅",
221 "in_review" => "🔍",
222 "approved" => "👍",
223 "in_progress" => "🔄",
224 "needs_review" => "⚠️",
225 "blocked" => "🚫",
226 "failed" => "❌",
227 _ => "📋",
228 }
229}
230
231pub fn format_duration(seconds: f64) -> String {
233 if seconds < 60.0 {
234 format!("{seconds:.0}s")
235 } else if seconds < 3600.0 {
236 format!("{:.0}m {:.0}s", (seconds / 60.0).floor(), seconds % 60.0)
237 } else {
238 let hours = (seconds / 3600.0).floor();
239 let mins = ((seconds % 3600.0) / 60.0).floor();
240 format!("{hours:.0}h {mins:.0}m")
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn notification_level_should_notify_all_active_review_only() {
250 let level = NotificationLevel::All;
251 assert!(level.should_notify("done"));
253 assert!(level.should_notify("in_review"));
254 assert!(level.should_notify("blocked"));
255 assert!(level.should_notify("failed"));
256 assert!(!level.should_notify("needs_review"));
259 assert!(!level.should_notify("new"));
261 assert!(!level.should_notify("routed"));
262 assert!(!level.should_notify("in_progress"));
263 }
264
265 #[test]
266 fn notification_level_should_notify_verbose_fires_for_all() {
267 let level = NotificationLevel::Verbose;
268 assert!(level.should_notify("done"));
269 assert!(level.should_notify("needs_review"));
270 assert!(level.should_notify("blocked"));
271 assert!(level.should_notify("failed"));
272 assert!(level.should_notify("new"));
273 assert!(level.should_notify("routed"));
274 assert!(level.should_notify("in_progress"));
275 assert!(level.should_notify("in_review"));
276 }
277
278 #[test]
279 fn notification_level_should_notify_errors_only() {
280 let level = NotificationLevel::ErrorsOnly;
281 assert!(!level.should_notify("done"));
282 assert!(!level.should_notify("in_progress"));
283 assert!(!level.should_notify("in_review"));
284 assert!(!level.should_notify("needs_review"));
285 assert!(level.should_notify("blocked"));
286 assert!(level.should_notify("failed"));
287 }
288
289 #[test]
290 fn notification_level_should_notify_none() {
291 let level = NotificationLevel::None;
292 assert!(!level.should_notify("done"));
293 assert!(!level.should_notify("needs_review"));
294 assert!(!level.should_notify("in_review"));
295 assert!(!level.should_notify("blocked"));
296 }
297
298 #[test]
299 fn notification_level_default_is_all() {
300 let level = NotificationLevel::from_config();
302 assert_eq!(level, NotificationLevel::All);
303 }
304
305 #[test]
306 fn format_duration_seconds() {
307 assert_eq!(format_duration(30.0), "30s");
308 assert_eq!(format_duration(0.0), "0s");
309 assert_eq!(format_duration(59.9), "60s");
310 }
311
312 #[test]
313 fn format_duration_minutes() {
314 assert_eq!(format_duration(90.0), "1m 30s");
315 assert_eq!(format_duration(3599.0), "59m 59s");
316 }
317
318 #[test]
319 fn format_duration_hours() {
320 assert_eq!(format_duration(3600.0), "1h 0m");
321 assert_eq!(format_duration(7200.0), "2h 0m");
322 assert_eq!(format_duration(5400.0), "1h 30m");
323 }
324
325 #[test]
326 fn format_telegram_done() {
327 let n = TaskNotification {
328 task_id: "42".to_string(),
329 title: "Fix auth bug".to_string(),
330 status: "done".to_string(),
331 agent: "claude".to_string(),
332 duration_seconds: 120.0,
333 summary: "Fixed the OAuth flow".to_string(),
334 repo: Some("owner/my-project".to_string()),
335 notify_target: None,
336 pr_number: None,
337 };
338 let msg = n.format_telegram();
339 assert!(msg.contains("✅"));
340 assert!(
342 msg.contains("<b>Fix auth bug</b>"),
343 "title should be the headline: {msg}"
344 );
345 assert!(msg.contains("#42"));
346 assert!(msg.contains("done"));
347 assert!(msg.contains("claude"));
348 assert!(msg.contains("2m 0s"));
349 assert!(msg.contains("Fixed the OAuth flow"));
350 }
351
352 #[test]
353 fn format_telegram_escapes_markdown() {
354 let n = TaskNotification {
355 task_id: "1".to_string(),
356 title: "Fix _underscores_ and *bold*".to_string(),
357 status: "done".to_string(),
358 agent: "claude".to_string(),
359 duration_seconds: 10.0,
360 summary: "Done".to_string(),
361 repo: None,
362 notify_target: None,
363 pr_number: None,
364 };
365 let msg = n.format_telegram();
366 assert!(msg.contains("Fix _underscores_ and *bold*"));
369 assert!(msg.contains("<b>Fix _underscores_ and *bold*</b>"));
371 }
372
373 #[test]
374 fn format_telegram_escapes_status_with_underscores() {
375 let n = TaskNotification {
376 task_id: "5".to_string(),
377 title: "Test task".to_string(),
378 status: "needs_review".to_string(),
379 agent: "claude".to_string(),
380 duration_seconds: 30.0,
381 summary: "Ready for review".to_string(),
382 repo: None,
383 notify_target: None,
384 pr_number: None,
385 };
386 let msg = n.format_telegram();
387 assert!(msg.contains("needs_review"));
390 }
391
392 #[test]
393 fn format_telegram_escapes_agent_with_underscores() {
394 let n = TaskNotification {
395 task_id: "6".to_string(),
396 title: "Test".to_string(),
397 status: "done".to_string(),
398 agent: "my_agent".to_string(),
399 duration_seconds: 15.0,
400 summary: "Done".to_string(),
401 repo: None,
402 notify_target: None,
403 pr_number: None,
404 };
405 let msg = n.format_telegram();
406 assert!(msg.contains("<code>my_agent</code>"));
409 }
410
411 #[test]
412 fn format_slack_done() {
413 let n = TaskNotification {
414 task_id: "7".to_string(),
415 title: "Refactor engine".to_string(),
416 status: "done".to_string(),
417 agent: "claude".to_string(),
418 duration_seconds: 60.0,
419 summary: "Decomposed mod.rs".to_string(),
420 repo: Some("org/refactor-repo".to_string()),
421 notify_target: None,
422 pr_number: None,
423 };
424 let msg = n.format_slack();
425 assert!(msg.contains("✅"));
426 assert!(
427 msg.contains("*Refactor engine*"),
428 "title should be the headline: {msg}"
429 );
430 assert!(msg.contains("#7"));
431 assert!(msg.contains("done"));
432 assert!(msg.contains("`claude`"));
433 assert!(msg.contains("1m 0s"));
434 assert!(msg.contains("Decomposed mod.rs"));
435 }
436
437 #[test]
438 fn format_discord_needs_review() {
439 let n = TaskNotification {
440 task_id: "99".to_string(),
441 title: "Deploy service".to_string(),
442 status: "needs_review".to_string(),
443 agent: "codex".to_string(),
444 duration_seconds: 1800.0,
445 summary: "Timed out waiting for tests".to_string(),
446 repo: Some("acme/deploy-svc".to_string()),
447 notify_target: None,
448 pr_number: None,
449 };
450 let msg = n.format_discord();
451 assert!(msg.contains("⚠️"));
452 assert!(
453 msg.contains("**Deploy service**"),
454 "title should be the headline: {msg}"
455 );
456 assert!(msg.contains("#99"));
457 assert!(msg.contains("needs_review"));
458 assert!(msg.contains("codex"));
459 assert!(msg.contains("30m 0s"));
460 }
461
462 #[test]
463 fn format_with_project_prepends_repo_name() {
464 let n = TaskNotification {
465 task_id: "10".to_string(),
466 title: "Add feature".to_string(),
467 status: "done".to_string(),
468 agent: "claude".to_string(),
469 duration_seconds: 45.0,
470 summary: "Feature added".to_string(),
471 repo: Some("acme/widgets".to_string()),
472 notify_target: None,
473 pr_number: None,
474 };
475 let msg = n.format_with_project("telegram");
476 assert!(msg.starts_with("[widgets] "));
477 assert!(msg.contains("#10"));
478 assert!(msg.contains("<b>Add feature</b>"));
479 }
480
481 #[test]
482 fn format_with_project_unknown_when_no_repo() {
483 let n = TaskNotification {
484 task_id: "11".to_string(),
485 title: "Task".to_string(),
486 status: "done".to_string(),
487 agent: "claude".to_string(),
488 duration_seconds: 5.0,
489 summary: "Done".to_string(),
490 repo: None,
491 notify_target: None,
492 pr_number: None,
493 };
494 let msg = n.format_with_project("discord");
495 assert!(msg.starts_with("[unknown] "));
496 }
497
498 #[test]
499 fn status_emoji_mapping() {
500 assert_eq!(status_emoji("done"), "✅");
501 assert_eq!(status_emoji("in_review"), "🔍");
502 assert_eq!(status_emoji("in_progress"), "🔄");
503 assert_eq!(status_emoji("needs_review"), "⚠️");
504 assert_eq!(status_emoji("blocked"), "🚫");
505 assert_eq!(status_emoji("failed"), "❌");
506 assert_eq!(status_emoji("unknown"), "📋");
507 }
508
509 #[test]
510 fn format_with_project_includes_repo_name() {
511 let n = TaskNotification {
512 task_id: "42".into(),
513 title: "Fix bug".into(),
514 status: "done".into(),
515 agent: "claude".into(),
516 duration_seconds: 90.0,
517 summary: "Fixed it".into(),
518 repo: Some("owner/myproject".into()),
519 notify_target: None,
520 pr_number: None,
521 };
522 let formatted = n.format_with_project("telegram");
523 assert!(
524 formatted.starts_with("[myproject]"),
525 "should start with project name: {formatted}"
526 );
527 assert!(formatted.contains("Fix bug"));
528 }
529
530 #[test]
531 fn format_with_project_no_repo_still_works() {
532 let n = TaskNotification {
533 task_id: "42".into(),
534 title: "Fix bug".into(),
535 status: "done".into(),
536 agent: "claude".into(),
537 duration_seconds: 90.0,
538 summary: "Fixed it".into(),
539 repo: None,
540 notify_target: None,
541 pr_number: None,
542 };
543 let formatted = n.format_with_project("telegram");
544 assert!(
545 formatted.starts_with("[unknown]"),
546 "should fallback: {formatted}"
547 );
548 }
549
550 #[test]
551 fn title_appears_before_metadata_in_telegram() {
552 let n = TaskNotification {
553 task_id: "internal:151694".to_string(),
554 title: "Morning briefing: daily focus plan".to_string(),
555 status: "done".to_string(),
556 agent: "opencode".to_string(),
557 duration_seconds: 1821.0,
558 summary: "Recap delivered".to_string(),
559 repo: Some("owner/repo".to_string()),
560 notify_target: None,
561 pr_number: None,
562 };
563 let msg = n.format_telegram();
564 let title_pos = msg.find("Morning briefing").expect("title present");
565 let id_pos = msg.find("#internal:151694").expect("id present");
566 assert!(
567 title_pos < id_pos,
568 "title must appear before task id: {msg}"
569 );
570 }
571
572 #[test]
573 fn title_appears_before_metadata_in_discord() {
574 let n = TaskNotification {
575 task_id: "internal:5".to_string(),
576 title: "Deploy svc".to_string(),
577 status: "done".to_string(),
578 agent: "codex".to_string(),
579 duration_seconds: 60.0,
580 summary: "ok".to_string(),
581 repo: Some("acme/svc".to_string()),
582 notify_target: None,
583 pr_number: None,
584 };
585 let msg = n.format_discord();
586 let title_pos = msg.find("Deploy svc").expect("title present");
587 let id_pos = msg.find("#internal:5").expect("id present");
588 assert!(title_pos < id_pos, "title before id: {msg}");
589 }
590
591 #[test]
592 fn format_with_project_uses_channel_format() {
593 let n = TaskNotification {
594 task_id: "5".into(),
595 title: "Deploy".into(),
596 status: "done".into(),
597 agent: "codex".into(),
598 duration_seconds: 60.0,
599 summary: "Deployed".into(),
600 repo: Some("acme/svc".into()),
601 notify_target: None,
602 pr_number: None,
603 };
604 let tg = n.format_with_project("telegram");
605 let dc = n.format_with_project("discord");
606 assert!(tg.starts_with("[svc]"));
608 assert!(dc.starts_with("[svc]"));
609 assert!(dc.contains("**Deploy**"));
611 assert!(tg.contains("<b>Deploy</b>"));
612 assert!(tg.contains("#5"));
614 assert!(dc.contains("#5"));
615 }
616
617 #[test]
618 fn telegram_appends_pr_link_when_pr_number_present() {
619 let n = TaskNotification {
620 task_id: "internal:151696".to_string(),
621 title: "Market intelligence".to_string(),
622 status: "done".to_string(),
623 agent: "claude".to_string(),
624 duration_seconds: 39.0,
625 summary: "Trending topics".to_string(),
626 repo: Some("owner/repo".to_string()),
627 notify_target: None,
628 pr_number: Some("4242".to_string()),
629 };
630 let msg = n.format_telegram();
631 assert!(
632 msg.contains("https://github.com/owner/repo/pull/4242"),
633 "expected PR link in {msg}"
634 );
635 assert!(msg.contains("View on GitHub"));
636 }
637
638 #[test]
639 fn telegram_falls_back_to_issue_link_for_external_task_without_pr() {
640 let n = TaskNotification {
641 task_id: "1234".to_string(),
642 title: "An issue".to_string(),
643 status: "needs_review".to_string(),
644 agent: "claude".to_string(),
645 duration_seconds: 10.0,
646 summary: "Awaiting review".to_string(),
647 repo: Some("owner/repo".to_string()),
648 notify_target: None,
649 pr_number: None,
650 };
651 let msg = n.format_telegram();
652 assert!(
653 msg.contains("https://github.com/owner/repo/issues/1234"),
654 "expected issue link in {msg}"
655 );
656 }
657
658 #[test]
659 fn no_link_for_internal_task_without_pr() {
660 let n = TaskNotification {
661 task_id: "internal:99".to_string(),
662 title: "Internal task".to_string(),
663 status: "done".to_string(),
664 agent: "claude".to_string(),
665 duration_seconds: 5.0,
666 summary: "Done".to_string(),
667 repo: Some("owner/repo".to_string()),
668 notify_target: None,
669 pr_number: None,
670 };
671 let msg = n.format_telegram();
672 assert!(!msg.contains("github.com"), "should not link: {msg}");
673 }
674
675 #[test]
676 fn discord_appends_pr_link() {
677 let n = TaskNotification {
678 task_id: "internal:7".to_string(),
679 title: "T".to_string(),
680 status: "done".to_string(),
681 agent: "claude".to_string(),
682 duration_seconds: 1.0,
683 summary: "S".to_string(),
684 repo: Some("owner/repo".to_string()),
685 notify_target: None,
686 pr_number: Some("9".to_string()),
687 };
688 let msg = n.format_discord();
689 assert!(msg.contains("[View on GitHub](https://github.com/owner/repo/pull/9)"));
690 }
691
692 #[test]
693 fn slack_appends_pr_link() {
694 let n = TaskNotification {
695 task_id: "internal:7".to_string(),
696 title: "T".to_string(),
697 status: "done".to_string(),
698 agent: "claude".to_string(),
699 duration_seconds: 1.0,
700 summary: "S".to_string(),
701 repo: Some("owner/repo".to_string()),
702 notify_target: None,
703 pr_number: Some("9".to_string()),
704 };
705 let msg = n.format_slack();
706 assert!(msg.contains("<https://github.com/owner/repo/pull/9|View on GitHub>"));
707 }
708}