use std::fs;
use std::path::PathBuf;
use std::thread;
use std::time::{Duration, Instant};
use crate::interaction::{
ChannelCapabilities, Decision, InteractionRequest, InteractionResponse, Notification,
};
use crate::review_channel::{ReviewChannel, ReviewChannelError};
pub struct WebhookChannel {
endpoint: PathBuf,
poll_interval: Duration,
timeout: Duration,
channel_id: String,
}
impl WebhookChannel {
pub fn new(endpoint: &str) -> Self {
Self {
endpoint: PathBuf::from(endpoint),
poll_interval: Duration::from_secs(2),
timeout: Duration::from_secs(3600), channel_id: format!("webhook:{}", endpoint),
}
}
pub fn with_poll_interval(mut self, interval: Duration) -> Self {
self.poll_interval = interval;
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
fn request_path(&self, id: &str) -> PathBuf {
self.endpoint.join(format!("request-{}.json", id))
}
fn response_path(&self, id: &str) -> PathBuf {
self.endpoint.join(format!("response-{}.json", id))
}
}
#[derive(Debug, serde::Deserialize)]
struct WebhookResponse {
decision: String,
#[serde(default)]
reasoning: Option<String>,
#[serde(default)]
responder_id: Option<String>,
}
impl ReviewChannel for WebhookChannel {
fn request_interaction(
&self,
request: &InteractionRequest,
) -> Result<InteractionResponse, ReviewChannelError> {
let id = request.interaction_id.to_string();
fs::create_dir_all(&self.endpoint)?;
let request_json = serde_json::to_string_pretty(request)
.map_err(|e| ReviewChannelError::Other(format!("serialization error: {}", e)))?;
fs::write(self.request_path(&id), &request_json)?;
let start = Instant::now();
let response_path = self.response_path(&id);
loop {
if response_path.exists() {
let content = fs::read_to_string(&response_path)?;
let _ = fs::remove_file(self.request_path(&id));
let _ = fs::remove_file(&response_path);
let webhook_resp: WebhookResponse =
serde_json::from_str(&content).map_err(|e| {
ReviewChannelError::InvalidResponse(format!("invalid response JSON: {}", e))
})?;
let decision = parse_decision(&webhook_resp.decision, &webhook_resp.reasoning)?;
let mut response = InteractionResponse::new(request.interaction_id, decision);
if let Some(reasoning) = webhook_resp.reasoning {
response = response.with_reasoning(reasoning);
}
if let Some(responder) = webhook_resp.responder_id {
response = response.with_responder(responder);
} else {
response = response.with_responder(&self.channel_id);
}
return Ok(response);
}
if start.elapsed() > self.timeout {
let _ = fs::remove_file(self.request_path(&id));
return Err(ReviewChannelError::Timeout);
}
thread::sleep(self.poll_interval);
}
}
fn notify(&self, notification: &Notification) -> Result<(), ReviewChannelError> {
fs::create_dir_all(&self.endpoint)?;
let path = self.endpoint.join(format!(
"notification-{}.json",
chrono::Utc::now().timestamp_millis()
));
let json = serde_json::to_string_pretty(notification)
.map_err(|e| ReviewChannelError::Other(format!("serialization error: {}", e)))?;
fs::write(&path, json)?;
Ok(())
}
fn capabilities(&self) -> ChannelCapabilities {
ChannelCapabilities {
supports_async: true,
supports_rich_media: true,
supports_threads: false,
}
}
fn channel_id(&self) -> &str {
&self.channel_id
}
}
fn parse_decision(s: &str, reasoning: &Option<String>) -> Result<Decision, ReviewChannelError> {
match s.to_lowercase().as_str() {
"approve" | "approved" => Ok(Decision::Approve),
"reject" | "rejected" | "deny" | "denied" => Ok(Decision::Reject {
reason: reasoning
.clone()
.unwrap_or_else(|| "rejected via webhook".to_string()),
}),
"discuss" => Ok(Decision::Discuss),
other => Err(ReviewChannelError::InvalidResponse(format!(
"unknown decision: '{}'. Expected: approve, reject, discuss",
other,
))),
}
}
pub struct SlackChannel {
#[allow(dead_code)]
channel_id: String,
}
impl SlackChannel {
pub fn new(_token: &str, _channel: &str) -> Self {
Self {
channel_id: "slack:stub".to_string(),
}
}
}
pub struct EmailChannel {
#[allow(dead_code)]
channel_id: String,
}
impl EmailChannel {
pub fn new(_smtp_host: &str, _to: &str) -> Self {
Self {
channel_id: "email:stub".to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::interaction::{InteractionKind, Urgency};
use tempfile::TempDir;
fn test_request() -> InteractionRequest {
InteractionRequest::new(
InteractionKind::DraftReview,
serde_json::json!({"draft_id": "test-123"}),
Urgency::Blocking,
)
}
#[test]
fn webhook_writes_request_file() {
let dir = TempDir::new().unwrap();
let channel = WebhookChannel::new(dir.path().to_str().unwrap());
let request = test_request();
let id = request.interaction_id.to_string();
let response_path = dir.path().join(format!("response-{}.json", id));
fs::write(
&response_path,
r#"{"decision": "approve", "reasoning": "looks good"}"#,
)
.unwrap();
let resp = channel.request_interaction(&request).unwrap();
assert_eq!(resp.decision, Decision::Approve);
assert_eq!(resp.reasoning.unwrap(), "looks good");
}
#[test]
fn webhook_timeout_on_missing_response() {
let dir = TempDir::new().unwrap();
let channel = WebhookChannel::new(dir.path().to_str().unwrap())
.with_timeout(Duration::from_millis(100))
.with_poll_interval(Duration::from_millis(20));
let request = test_request();
let result = channel.request_interaction(&request);
assert!(matches!(result, Err(ReviewChannelError::Timeout)));
}
#[test]
fn webhook_reject_decision() {
let dir = TempDir::new().unwrap();
let channel = WebhookChannel::new(dir.path().to_str().unwrap());
let request = test_request();
let id = request.interaction_id.to_string();
let response_path = dir.path().join(format!("response-{}.json", id));
fs::write(
&response_path,
r#"{"decision": "reject", "reasoning": "needs work"}"#,
)
.unwrap();
let resp = channel.request_interaction(&request).unwrap();
assert!(matches!(resp.decision, Decision::Reject { .. }));
}
#[test]
fn webhook_notification_writes_file() {
let dir = TempDir::new().unwrap();
let channel = WebhookChannel::new(dir.path().to_str().unwrap());
let notification = Notification::info("test notification");
channel.notify(¬ification).unwrap();
let files: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.starts_with("notification-"))
})
.collect();
assert_eq!(files.len(), 1);
}
#[test]
fn parse_decision_variants() {
let none = &None;
assert_eq!(parse_decision("approve", none).unwrap(), Decision::Approve);
assert_eq!(parse_decision("Approved", none).unwrap(), Decision::Approve);
assert!(matches!(
parse_decision("reject", none).unwrap(),
Decision::Reject { .. }
));
assert!(matches!(
parse_decision("denied", none).unwrap(),
Decision::Reject { .. }
));
assert_eq!(parse_decision("discuss", none).unwrap(), Decision::Discuss);
assert!(parse_decision("invalid", none).is_err());
}
#[test]
fn build_channel_terminal() {
use crate::review_channel::{build_channel, ReviewChannelConfig};
let config = ReviewChannelConfig::default();
let channel = build_channel(&config).unwrap();
assert_eq!(channel.channel_id(), "terminal:stdio");
}
#[test]
fn build_channel_auto_approve() {
use crate::review_channel::{build_channel, ReviewChannelConfig};
let config = ReviewChannelConfig {
channel_type: "auto-approve".into(),
..Default::default()
};
let channel = build_channel(&config).unwrap();
assert_eq!(channel.channel_id(), "auto-approve");
}
#[test]
fn build_channel_webhook() {
use crate::review_channel::{build_channel, ReviewChannelConfig};
let dir = TempDir::new().unwrap();
let config = ReviewChannelConfig {
channel_type: "webhook".into(),
channel_config: Some(serde_json::json!({
"endpoint": dir.path().to_str().unwrap()
})),
..Default::default()
};
let channel = build_channel(&config).unwrap();
assert!(channel.channel_id().starts_with("webhook:"));
}
#[test]
fn build_channel_unknown_type_errors() {
use crate::review_channel::{build_channel, ReviewChannelConfig};
let config = ReviewChannelConfig {
channel_type: "carrier-pigeon".into(),
..Default::default()
};
assert!(build_channel(&config).is_err());
}
}