Skip to main content

ta_changeset/
webhook_channel.rs

1// webhook_channel.rs — Webhook-based ReviewChannel implementation (v0.5.3).
2//
3// Posts InteractionRequest JSON to a configured endpoint and awaits
4// an InteractionResponse. For the MVP, this uses a file-based exchange
5// pattern: TA writes the request to a file, an external process reads
6// it and writes a response file. This avoids adding HTTP client deps
7// to the data-model crate.
8//
9// For production use, integrate with reqwest in a higher-level crate
10// or use the external webhook adapter pattern.
11
12use 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
22/// File-based webhook channel for external review integrations.
23///
24/// Exchange pattern:
25/// 1. TA writes `{endpoint}/request-{id}.json` with the InteractionRequest
26/// 2. External process reads it, decides, writes `{endpoint}/response-{id}.json`
27/// 3. TA polls for the response file and parses it
28///
29/// The `endpoint` is a directory path (for file-based) or URL (for future HTTP).
30pub struct WebhookChannel {
31    endpoint: PathBuf,
32    poll_interval: Duration,
33    timeout: Duration,
34    channel_id: String,
35}
36
37impl WebhookChannel {
38    /// Create a new webhook channel with a directory endpoint.
39    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), // 1 hour default
44            channel_id: format!("webhook:{}", endpoint),
45        }
46    }
47
48    /// Set the polling interval.
49    pub fn with_poll_interval(mut self, interval: Duration) -> Self {
50        self.poll_interval = interval;
51        self
52    }
53
54    /// Set the timeout.
55    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/// The response file format that external integrations write.
70#[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        // Ensure endpoint directory exists.
87        fs::create_dir_all(&self.endpoint)?;
88
89        // Write the request file.
90        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        // Poll for response file.
95        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                // Clean up files.
102                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                // Clean up request file on timeout.
126                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
175/// Stub for future Slack integration (v0.5.3).
176///
177/// Will use Block Kit cards for draft review and button callbacks for
178/// approve/reject/discuss. Requires `reqwest` for Slack API calls.
179pub 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
192/// Stub for future Email integration (v0.5.3).
193///
194/// Will use SMTP for sending review summaries and IMAP for parsing replies.
195pub 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        // Write a pre-existing response so we don't block.
230        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(&notification).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}