use crate::ci::CiReport;
use crate::drift::DriftReport;
use crate::urlvalidation;
use anyhow::{Context, Result};
use serde::Serialize;
use std::path::Path;
#[derive(Debug, Default, serde::Deserialize, Clone)]
pub struct WebhookConfig {
pub url: Option<String>,
#[serde(default = "default_true")]
pub on_errors: bool,
#[serde(default)]
pub on_warnings: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum EventKind {
DriftDetected,
CiFailed,
Clean,
}
#[derive(Debug, Serialize)]
pub struct WebhookPayload {
pub kind: EventKind,
pub repository: String,
pub errors: usize,
pub warnings: usize,
pub summary: String,
pub timestamp: u64,
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn repo_name(root: &Path) -> String {
root.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| ".".to_string())
}
pub fn fire_from_drift(root: &Path, cfg: &WebhookConfig, report: &DriftReport) -> Result<()> {
let url = match &cfg.url {
Some(u) => u.clone(),
None => return Ok(()),
};
let errors = report.error_count();
let warnings = report.warning_count();
let should_fire =
(errors > 0 && cfg.on_errors) || (errors == 0 && warnings > 0 && cfg.on_warnings);
if !should_fire {
return Ok(());
}
let kind = if errors > 0 {
EventKind::DriftDetected
} else {
EventKind::Clean
};
let summary = if errors > 0 {
format!(
"{} drift error(s), {} warning(s) detected",
errors, warnings
)
} else if warnings > 0 {
format!("No errors, {} warning(s) detected", warnings)
} else {
"No drift detected".to_string()
};
let payload = WebhookPayload {
kind,
repository: repo_name(root),
errors,
warnings,
summary,
timestamp: now_secs(),
};
post(&url, &payload)
}
pub fn fire_from_ci(root: &Path, cfg: &WebhookConfig, report: &CiReport) -> Result<()> {
let url = match &cfg.url {
Some(u) => u.clone(),
None => return Ok(()),
};
let errors = report.total_errors();
let warnings = report.total_warnings();
let should_fire =
(errors > 0 && cfg.on_errors) || (errors == 0 && warnings > 0 && cfg.on_warnings);
if !should_fire {
return Ok(());
}
let kind = if errors > 0 {
EventKind::CiFailed
} else {
EventKind::Clean
};
let summary = if errors > 0 {
format!(
"CI failed: {} error(s) across {} step(s)",
errors,
report.steps_run.len()
)
} else {
format!(
"CI passed: {} step(s) completed with {} warning(s)",
report.steps_run.len(),
warnings
)
};
let payload = WebhookPayload {
kind,
repository: repo_name(root),
errors,
warnings,
summary,
timestamp: now_secs(),
};
post(&url, &payload)
}
fn post(url: &str, payload: &WebhookPayload) -> Result<()> {
urlvalidation::validate_url(url, true).context("webhook URL validation failed")?;
let body = serde_json::to_string(payload).context("serialising webhook payload")?;
ureq::post(url)
.set("Content-Type", "application/json")
.set("User-Agent", concat!("svccat/", env!("CARGO_PKG_VERSION")))
.send_string(&body)
.with_context(|| format!("webhook POST to {url} failed"))?;
eprintln!("webhook fired -> {url}");
Ok(())
}
const MAX_CONFIG_FILE_SIZE: u64 = 1024 * 1024;
pub fn load_config(root: &Path) -> WebhookConfig {
let path = root.join("svccat.toml");
if !path.exists() {
return WebhookConfig::default();
}
if let Ok(metadata) = std::fs::metadata(&path) {
if metadata.len() > MAX_CONFIG_FILE_SIZE {
eprintln!(
"warning: svccat.toml is too large ({} bytes, max {}). Ignoring config file.",
metadata.len(),
MAX_CONFIG_FILE_SIZE
);
return WebhookConfig::default();
}
}
#[derive(serde::Deserialize, Default)]
struct Outer {
#[serde(default)]
webhook: WebhookConfig,
}
std::fs::read_to_string(&path)
.ok()
.and_then(|text| toml::from_str::<Outer>(&text).ok())
.map(|o| o.webhook)
.unwrap_or_default()
}