use anyhow::{Context, Result};
use std::process::Command;
use crate::db::sentinel::SentinelDispatch;
use super::config::NotificationConfig;
pub fn notify_dispatch_completed(
config: &NotificationConfig,
dispatch: &SentinelDispatch,
outcome: &str,
findings_summary: &str,
) {
if !config.enabled {
return;
}
let message = build_notification_message(dispatch, outcome, findings_summary);
for url in &config.webhook_urls {
if let Err(e) = send_webhook(url, &message, NotificationConfig::is_slack_url(url)) {
tracing::warn!("notification to {} failed: {e}", mask_url(url));
}
}
}
fn build_notification_message(
dispatch: &SentinelDispatch,
outcome: &str,
findings_summary: &str,
) -> NotificationMessage {
let status_emoji = match outcome {
"success" => "white_check_mark",
"failure" => "x",
"exhausted" => "no_entry_sign",
"orphaned" => "ghost",
_ => "question",
};
let status_text = match outcome {
"success" if dispatch.label.contains("fix") => "Fixed",
"success" => "Reproduced",
"failure" if dispatch.label.contains("fix") => "Could not fix",
"failure" => "Could not reproduce",
"exhausted" => "All attempts exhausted",
"orphaned" => "Orphaned (worktree removed)",
other => other,
};
let model = dispatch.model_used.as_deref().unwrap_or("unknown");
let gh_link = dispatch
.gh_issue_number
.map_or_else(|| dispatch.signal_ref.clone(), |n| format!("GH#{n}"));
let summary = if findings_summary.len() > 300 {
format!("{}...", &findings_summary[..300])
} else {
findings_summary.to_string()
};
NotificationMessage {
status_emoji: status_emoji.to_string(),
status_text: status_text.to_string(),
signal_ref: dispatch.signal_ref.clone(),
gh_link,
title: dispatch.signal_title.clone(),
model: model.to_string(),
attempt: dispatch.attempt_number,
summary,
}
}
struct NotificationMessage {
status_emoji: String,
status_text: String,
signal_ref: String,
gh_link: String,
title: String,
model: String,
attempt: i32,
summary: String,
}
impl NotificationMessage {
fn to_slack_json(&self) -> serde_json::Value {
serde_json::json!({
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": format!(":{}: Sentinel: {}", self.status_emoji, self.status_text),
"emoji": true,
}
},
{
"type": "section",
"fields": [
{ "type": "mrkdwn", "text": format!("*Signal:* {}", self.signal_ref) },
{ "type": "mrkdwn", "text": format!("*Issue:* {}", self.gh_link) },
{ "type": "mrkdwn", "text": format!("*Model:* {}", self.model) },
{ "type": "mrkdwn", "text": format!("*Attempt:* {} of 2", self.attempt) },
]
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": format!("*{}*\n{}", self.title, self.summary),
}
}
]
})
}
fn to_generic_json(&self) -> serde_json::Value {
serde_json::json!({
"event": "sentinel.dispatch.completed",
"status": self.status_text,
"signal_ref": self.signal_ref,
"title": self.title,
"model": self.model,
"attempt": self.attempt,
"summary": self.summary,
})
}
}
fn send_webhook(url: &str, message: &NotificationMessage, is_slack: bool) -> Result<()> {
let payload = if is_slack {
message.to_slack_json()
} else {
message.to_generic_json()
};
let body = serde_json::to_string(&payload)?;
let output = Command::new("curl")
.args([
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"-X",
"POST",
"-H",
"Content-Type: application/json",
"-d",
&body,
url,
])
.output()
.context("Failed to run curl for webhook notification")?;
let status_code = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !status_code.starts_with('2') {
anyhow::bail!("webhook returned HTTP {status_code}");
}
Ok(())
}
fn mask_url(url: &str) -> String {
url.find("//")
.and_then(|p| url[p + 2..].find('/').map(|q| p + 2 + q))
.map_or_else(|| "***".to_string(), |pos| format!("{}/*****", &url[..pos]))
}