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