1use std::fs;
13use std::path::PathBuf;
14use std::thread;
15use std::time::{Duration, Instant};
16
17use crate::interaction::{
18 ChannelCapabilities, Decision, InteractionRequest, InteractionResponse, Notification,
19};
20use crate::review_channel::{ReviewChannel, ReviewChannelError};
21
22pub struct WebhookChannel {
31 endpoint: PathBuf,
32 poll_interval: Duration,
33 timeout: Duration,
34 channel_id: String,
35}
36
37impl WebhookChannel {
38 pub fn new(endpoint: &str) -> Self {
40 Self {
41 endpoint: PathBuf::from(endpoint),
42 poll_interval: Duration::from_secs(2),
43 timeout: Duration::from_secs(3600), channel_id: format!("webhook:{}", endpoint),
45 }
46 }
47
48 pub fn with_poll_interval(mut self, interval: Duration) -> Self {
50 self.poll_interval = interval;
51 self
52 }
53
54 pub fn with_timeout(mut self, timeout: Duration) -> Self {
56 self.timeout = timeout;
57 self
58 }
59
60 fn request_path(&self, id: &str) -> PathBuf {
61 self.endpoint.join(format!("request-{}.json", id))
62 }
63
64 fn response_path(&self, id: &str) -> PathBuf {
65 self.endpoint.join(format!("response-{}.json", id))
66 }
67}
68
69#[derive(Debug, serde::Deserialize)]
71struct WebhookResponse {
72 decision: String,
73 #[serde(default)]
74 reasoning: Option<String>,
75 #[serde(default)]
76 responder_id: Option<String>,
77}
78
79impl ReviewChannel for WebhookChannel {
80 fn request_interaction(
81 &self,
82 request: &InteractionRequest,
83 ) -> Result<InteractionResponse, ReviewChannelError> {
84 let id = request.interaction_id.to_string();
85
86 fs::create_dir_all(&self.endpoint)?;
88
89 let request_json = serde_json::to_string_pretty(request)
91 .map_err(|e| ReviewChannelError::Other(format!("serialization error: {}", e)))?;
92 fs::write(self.request_path(&id), &request_json)?;
93
94 let start = Instant::now();
96 let response_path = self.response_path(&id);
97
98 loop {
99 if response_path.exists() {
100 let content = fs::read_to_string(&response_path)?;
101 let _ = fs::remove_file(self.request_path(&id));
103 let _ = fs::remove_file(&response_path);
104
105 let webhook_resp: WebhookResponse =
106 serde_json::from_str(&content).map_err(|e| {
107 ReviewChannelError::InvalidResponse(format!("invalid response JSON: {}", e))
108 })?;
109
110 let decision = parse_decision(&webhook_resp.decision, &webhook_resp.reasoning)?;
111 let mut response = InteractionResponse::new(request.interaction_id, decision);
112 if let Some(reasoning) = webhook_resp.reasoning {
113 response = response.with_reasoning(reasoning);
114 }
115 if let Some(responder) = webhook_resp.responder_id {
116 response = response.with_responder(responder);
117 } else {
118 response = response.with_responder(&self.channel_id);
119 }
120
121 return Ok(response);
122 }
123
124 if start.elapsed() > self.timeout {
125 let _ = fs::remove_file(self.request_path(&id));
127 return Err(ReviewChannelError::Timeout);
128 }
129
130 thread::sleep(self.poll_interval);
131 }
132 }
133
134 fn notify(&self, notification: &Notification) -> Result<(), ReviewChannelError> {
135 fs::create_dir_all(&self.endpoint)?;
136 let path = self.endpoint.join(format!(
137 "notification-{}.json",
138 chrono::Utc::now().timestamp_millis()
139 ));
140 let json = serde_json::to_string_pretty(notification)
141 .map_err(|e| ReviewChannelError::Other(format!("serialization error: {}", e)))?;
142 fs::write(&path, json)?;
143 Ok(())
144 }
145
146 fn capabilities(&self) -> ChannelCapabilities {
147 ChannelCapabilities {
148 supports_async: true,
149 supports_rich_media: true,
150 supports_threads: false,
151 }
152 }
153
154 fn channel_id(&self) -> &str {
155 &self.channel_id
156 }
157}
158
159fn parse_decision(s: &str, reasoning: &Option<String>) -> Result<Decision, ReviewChannelError> {
160 match s.to_lowercase().as_str() {
161 "approve" | "approved" => Ok(Decision::Approve),
162 "reject" | "rejected" | "deny" | "denied" => Ok(Decision::Reject {
163 reason: reasoning
164 .clone()
165 .unwrap_or_else(|| "rejected via webhook".to_string()),
166 }),
167 "discuss" => Ok(Decision::Discuss),
168 other => Err(ReviewChannelError::InvalidResponse(format!(
169 "unknown decision: '{}'. Expected: approve, reject, discuss",
170 other,
171 ))),
172 }
173}
174
175pub struct SlackChannel {
180 #[allow(dead_code)]
181 channel_id: String,
182}
183
184impl SlackChannel {
185 pub fn new(_token: &str, _channel: &str) -> Self {
186 Self {
187 channel_id: "slack:stub".to_string(),
188 }
189 }
190}
191
192pub struct EmailChannel {
196 #[allow(dead_code)]
197 channel_id: String,
198}
199
200impl EmailChannel {
201 pub fn new(_smtp_host: &str, _to: &str) -> Self {
202 Self {
203 channel_id: "email:stub".to_string(),
204 }
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use crate::interaction::{InteractionKind, Urgency};
212 use tempfile::TempDir;
213 fn test_request() -> InteractionRequest {
214 InteractionRequest::new(
215 InteractionKind::DraftReview,
216 serde_json::json!({"draft_id": "test-123"}),
217 Urgency::Blocking,
218 )
219 }
220
221 #[test]
222 fn webhook_writes_request_file() {
223 let dir = TempDir::new().unwrap();
224 let channel = WebhookChannel::new(dir.path().to_str().unwrap());
225
226 let request = test_request();
227 let id = request.interaction_id.to_string();
228
229 let response_path = dir.path().join(format!("response-{}.json", id));
231 fs::write(
232 &response_path,
233 r#"{"decision": "approve", "reasoning": "looks good"}"#,
234 )
235 .unwrap();
236
237 let resp = channel.request_interaction(&request).unwrap();
238 assert_eq!(resp.decision, Decision::Approve);
239 assert_eq!(resp.reasoning.unwrap(), "looks good");
240 }
241
242 #[test]
243 fn webhook_timeout_on_missing_response() {
244 let dir = TempDir::new().unwrap();
245 let channel = WebhookChannel::new(dir.path().to_str().unwrap())
246 .with_timeout(Duration::from_millis(100))
247 .with_poll_interval(Duration::from_millis(20));
248
249 let request = test_request();
250 let result = channel.request_interaction(&request);
251 assert!(matches!(result, Err(ReviewChannelError::Timeout)));
252 }
253
254 #[test]
255 fn webhook_reject_decision() {
256 let dir = TempDir::new().unwrap();
257 let channel = WebhookChannel::new(dir.path().to_str().unwrap());
258
259 let request = test_request();
260 let id = request.interaction_id.to_string();
261
262 let response_path = dir.path().join(format!("response-{}.json", id));
263 fs::write(
264 &response_path,
265 r#"{"decision": "reject", "reasoning": "needs work"}"#,
266 )
267 .unwrap();
268
269 let resp = channel.request_interaction(&request).unwrap();
270 assert!(matches!(resp.decision, Decision::Reject { .. }));
271 }
272
273 #[test]
274 fn webhook_notification_writes_file() {
275 let dir = TempDir::new().unwrap();
276 let channel = WebhookChannel::new(dir.path().to_str().unwrap());
277
278 let notification = Notification::info("test notification");
279 channel.notify(¬ification).unwrap();
280
281 let files: Vec<_> = fs::read_dir(dir.path())
282 .unwrap()
283 .filter_map(|e| e.ok())
284 .filter(|e| {
285 e.file_name()
286 .to_str()
287 .is_some_and(|n| n.starts_with("notification-"))
288 })
289 .collect();
290 assert_eq!(files.len(), 1);
291 }
292
293 #[test]
294 fn parse_decision_variants() {
295 let none = &None;
296 assert_eq!(parse_decision("approve", none).unwrap(), Decision::Approve);
297 assert_eq!(parse_decision("Approved", none).unwrap(), Decision::Approve);
298 assert!(matches!(
299 parse_decision("reject", none).unwrap(),
300 Decision::Reject { .. }
301 ));
302 assert!(matches!(
303 parse_decision("denied", none).unwrap(),
304 Decision::Reject { .. }
305 ));
306 assert_eq!(parse_decision("discuss", none).unwrap(), Decision::Discuss);
307 assert!(parse_decision("invalid", none).is_err());
308 }
309
310 #[test]
311 fn build_channel_terminal() {
312 use crate::review_channel::{build_channel, ReviewChannelConfig};
313 let config = ReviewChannelConfig::default();
314 let channel = build_channel(&config).unwrap();
315 assert_eq!(channel.channel_id(), "terminal:stdio");
316 }
317
318 #[test]
319 fn build_channel_auto_approve() {
320 use crate::review_channel::{build_channel, ReviewChannelConfig};
321 let config = ReviewChannelConfig {
322 channel_type: "auto-approve".into(),
323 ..Default::default()
324 };
325 let channel = build_channel(&config).unwrap();
326 assert_eq!(channel.channel_id(), "auto-approve");
327 }
328
329 #[test]
330 fn build_channel_webhook() {
331 use crate::review_channel::{build_channel, ReviewChannelConfig};
332 let dir = TempDir::new().unwrap();
333 let config = ReviewChannelConfig {
334 channel_type: "webhook".into(),
335 channel_config: Some(serde_json::json!({
336 "endpoint": dir.path().to_str().unwrap()
337 })),
338 ..Default::default()
339 };
340 let channel = build_channel(&config).unwrap();
341 assert!(channel.channel_id().starts_with("webhook:"));
342 }
343
344 #[test]
345 fn build_channel_unknown_type_errors() {
346 use crate::review_channel::{build_channel, ReviewChannelConfig};
347 let config = ReviewChannelConfig {
348 channel_type: "carrier-pigeon".into(),
349 ..Default::default()
350 };
351 assert!(build_channel(&config).is_err());
352 }
353}