use std::collections::HashMap;
use regex::Regex;
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use super::{DatadogSourceConfig, ExternalSignal};
pub const DATADOG_DEFAULT_CONFIDENCE: f64 = 0.95;
fn sha_regex() -> Regex {
Regex::new(r"\b([0-9a-f]{7,40})\b").expect("static regex is valid")
}
pub fn extract_commit_shas(message: &str) -> Vec<String> {
let re = sha_regex();
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for cap in re.captures_iter(message) {
if let Some(sha_m) = cap.get(1) {
let sha = sha_m.as_str().to_string();
if seen.insert(sha.clone()) {
out.push(sha);
}
}
}
out
}
#[derive(Debug, Deserialize, Serialize)]
pub struct DatadogEventsResponse {
#[serde(default)]
pub events: Vec<DatadogEvent>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct DatadogEvent {
pub id: Option<serde_json::Value>,
#[serde(default)]
pub title: String,
#[serde(default)]
pub tags: Vec<String>,
}
pub async fn has_deployment_event(
client: &reqwest::Client,
config: &DatadogSourceConfig,
sha: &str,
api_base_override: Option<&str>,
) -> bool {
let api_key = match std::env::var(&config.api_key_env) {
Ok(k) if !k.is_empty() => k,
_ => {
warn!(
api_key_env = %config.api_key_env,
"Datadog API key env var `{}` is not set — skipping Datadog lookups",
config.api_key_env,
);
return false;
}
};
let app_key = match std::env::var(&config.app_key_env) {
Ok(k) if !k.is_empty() => k,
_ => {
warn!(
app_key_env = %config.app_key_env,
"Datadog app key env var `{}` is not set — skipping Datadog lookups",
config.app_key_env,
);
return false;
}
};
let site = config.dd_site.as_deref().unwrap_or("datadoghq.com");
let base = api_base_override
.map(|u| u.to_string())
.unwrap_or_else(|| format!("https://api.{site}"));
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let start = now.saturating_sub(365 * 24 * 3600);
let url = format!(
"{base}/api/v1/events?sources=deployment&tags=commit:{sha}&start={start}&end={now}"
);
let resp = match client
.get(&url)
.header("DD-API-KEY", &api_key)
.header("DD-APPLICATION-KEY", &app_key)
.send()
.await
{
Ok(r) => r,
Err(e) => {
warn!(sha, error = %e, "Datadog Events API request failed; skipping");
return false;
}
};
if !resp.status().is_success() {
warn!(
sha,
status = %resp.status(),
"Datadog Events API returned non-success status; skipping"
);
return false;
}
match resp.json::<DatadogEventsResponse>().await {
Ok(r) => {
let found = !r.events.is_empty();
debug!(sha, found, "Datadog deployment query complete");
found
}
Err(e) => {
warn!(sha, error = %e, "failed to parse Datadog Events response; skipping");
false
}
}
}
pub async fn check_shas_batch(
client: &reqwest::Client,
config: &DatadogSourceConfig,
shas: &[String],
api_base_override: Option<&str>,
) -> HashMap<String, Option<ExternalSignal>> {
let mut out = HashMap::new();
for sha in shas {
if out.contains_key(sha) {
continue;
}
let found = has_deployment_event(client, config, sha, api_base_override).await;
let signal = if found {
let confidence = config.confidence.unwrap_or(DATADOG_DEFAULT_CONFIDENCE);
Some(ExternalSignal {
category: config.default_category.clone(),
confidence,
source: format!("datadog:deployment:{sha}"),
})
} else {
None
};
out.insert(sha.clone(), signal);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_commit_shas_full() {
let shas = extract_commit_shas("cherry-pick from a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"); assert_eq!(shas, vec!["a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"]); }
#[test]
fn extract_commit_shas_short() {
let shas = extract_commit_shas("deploy: abc1234 to production");
assert_eq!(shas, vec!["abc1234"]);
}
#[test]
fn extract_commit_shas_dedup() {
let shas = extract_commit_shas("reverts abc1234 and abc1234 again, plus def5678");
assert_eq!(shas, vec!["abc1234", "def5678"]);
}
#[test]
fn extract_commit_shas_plain_message_yields_empty() {
assert!(extract_commit_shas("feat: add login flow").is_empty());
}
#[test]
fn datadog_source_config_deserializes() {
use crate::classify::sources::SourceConfig;
let yaml = r#"
type: datadog
api_key_env: DATADOG_API_KEY
app_key_env: DATADOG_APP_KEY
dd_site: datadoghq.com
service: my-service
default_category: devops
confidence: 0.95
"#;
let cfg: SourceConfig = serde_yaml::from_str(yaml).expect("deserialize");
match cfg {
SourceConfig::Datadog(d) => {
assert_eq!(d.api_key_env, "DATADOG_API_KEY"); assert_eq!(d.app_key_env, "DATADOG_APP_KEY"); assert_eq!(d.dd_site.as_deref(), Some("datadoghq.com"));
assert_eq!(d.service.as_deref(), Some("my-service"));
assert_eq!(d.default_category, "devops");
assert!(d
.confidence
.map(|c| (c - 0.95_f64).abs() < f64::EPSILON)
.unwrap_or(false));
}
other => panic!("expected Datadog variant, got {other:?}"),
}
}
#[test]
fn datadog_source_config_unknown_field_is_rejected() {
let yaml = r#"
type: datadog
api_key_env: DATADOG_API_KEY
app_key_env: DATADOG_APP_KEY
default_category: devops
unknown_field: oops
"#;
let result: Result<crate::classify::sources::SourceConfig, _> = serde_yaml::from_str(yaml);
assert!(result.is_err(), "unknown field must be rejected");
}
#[tokio::test]
async fn fetch_and_classify_via_wiremock() {
use wiremock::matchers::{header, method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
let body = serde_json::json!({
"events": [
{
"id": 12345,
"title": "Deployment",
"tags": ["commit:abc1234", "env:production"]
}
]
});
Mock::given(method("GET"))
.and(path_regex(r"/api/v1/events.*"))
.and(header("DD-API-KEY", "test-api-key"))
.and(header("DD-APPLICATION-KEY", "test-app-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
unsafe { std::env::set_var("DD_API_KEY_WT", "test-api-key") }; unsafe { std::env::set_var("DD_APP_KEY_WT", "test-app-key") };
let config = DatadogSourceConfig {
api_key_env: "DD_API_KEY_WT".to_string(), app_key_env: "DD_APP_KEY_WT".to_string(), dd_site: Some("datadoghq.com".to_string()),
service: Some("my-service".to_string()),
default_category: "devops".to_string(),
confidence: Some(0.95),
};
let client = reqwest::Client::new();
let found = has_deployment_event(&client, &config, "abc1234", Some(&server.uri())).await;
assert!(found, "deployment event should be found");
let map = check_shas_batch(
&client,
&config,
&["abc1234".to_string()],
Some(&server.uri()),
)
.await;
let signal = map.get("abc1234").and_then(|s| s.as_ref()).expect("signal");
assert_eq!(signal.category, "devops");
assert!(
(signal.confidence - 0.95_f64).abs() < f64::EPSILON,
"confidence should be 0.95"
);
assert!(signal.source.contains("abc1234"));
unsafe { std::env::remove_var("DD_API_KEY_WT") };
unsafe { std::env::remove_var("DD_APP_KEY_WT") };
}
#[tokio::test]
async fn no_deployment_event_yields_none_signal() {
use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
let body = serde_json::json!({"events": []});
Mock::given(method("GET"))
.and(path_regex(r"/api/v1/events.*"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;
unsafe { std::env::set_var("DD_API_KEY_EMPTY", "test-api-key") }; unsafe { std::env::set_var("DD_APP_KEY_EMPTY", "test-app-key") };
let config = DatadogSourceConfig {
api_key_env: "DD_API_KEY_EMPTY".to_string(), app_key_env: "DD_APP_KEY_EMPTY".to_string(), dd_site: None,
service: None,
default_category: "devops".to_string(),
confidence: None,
};
let client = reqwest::Client::new();
let map = check_shas_batch(
&client,
&config,
&["deadbeef".to_string()],
Some(&server.uri()),
)
.await;
let signal = map.get("deadbeef").expect("key present");
assert!(
signal.is_none(),
"no events should yield None signal, got {signal:?}"
);
unsafe { std::env::remove_var("DD_API_KEY_EMPTY") };
unsafe { std::env::remove_var("DD_APP_KEY_EMPTY") };
}
}