opendev_hooks/
executor.rs1use crate::models::HookCommand;
12use serde_json::Value;
13use std::collections::HashMap;
14use std::time::Duration;
15use tokio::io::AsyncWriteExt;
16use tokio::process::Command;
17use tracing::{error, warn};
18
19#[derive(Debug, Clone, Default)]
21pub struct HookResult {
22 pub exit_code: i32,
24 pub stdout: String,
26 pub stderr: String,
28 pub timed_out: bool,
30 pub error: Option<String>,
32}
33
34impl HookResult {
35 pub fn success(&self) -> bool {
37 self.exit_code == 0 && !self.timed_out && self.error.is_none()
38 }
39
40 pub fn should_block(&self) -> bool {
42 self.exit_code == 2
43 }
44
45 pub fn parse_json_output(&self) -> HashMap<String, Value> {
49 let trimmed = self.stdout.trim();
50 if trimmed.is_empty() {
51 return HashMap::new();
52 }
53 serde_json::from_str(trimmed).unwrap_or_default()
54 }
55}
56
57#[derive(Debug, Clone)]
62pub struct HookExecutor;
63
64impl HookExecutor {
65 pub fn new() -> Self {
66 Self
67 }
68
69 pub async fn execute(&self, command: &HookCommand, stdin_data: &Value) -> HookResult {
74 let stdin_json = match serde_json::to_string(stdin_data) {
75 Ok(s) => s,
76 Err(e) => {
77 return HookResult {
78 exit_code: 1,
79 error: Some(format!("Failed to serialize stdin data: {e}")),
80 ..Default::default()
81 };
82 }
83 };
84
85 let timeout = Duration::from_secs(command.effective_timeout() as u64);
86
87 let (shell, flag) = if cfg!(target_os = "windows") {
89 ("cmd", "/C")
90 } else {
91 ("sh", "-c")
92 };
93
94 let mut child = match Command::new(shell)
95 .arg(flag)
96 .arg(&command.command)
97 .stdin(std::process::Stdio::piped())
98 .stdout(std::process::Stdio::piped())
99 .stderr(std::process::Stdio::piped())
100 .spawn()
101 {
102 Ok(child) => child,
103 Err(e) => {
104 error!(
105 command = %command.command,
106 error = %e,
107 "Hook command failed to execute"
108 );
109 return HookResult {
110 exit_code: 1,
111 error: Some(format!("Failed to execute hook: {e}")),
112 ..Default::default()
113 };
114 }
115 };
116
117 if let Some(mut stdin) = child.stdin.take() {
119 if let Err(e) = stdin.write_all(stdin_json.as_bytes()).await {
120 warn!(error = %e, "Failed to write stdin to hook command");
121 }
122 drop(stdin);
124 }
125
126 let stdout_handle = child.stdout.take();
129 let stderr_handle = child.stderr.take();
130
131 match tokio::time::timeout(timeout, child.wait()).await {
133 Ok(Ok(status)) => {
134 let exit_code = status.code().unwrap_or(1);
135
136 let stdout = if let Some(mut out) = stdout_handle {
138 use tokio::io::AsyncReadExt;
139 let mut buf = Vec::new();
140 let _ = out.read_to_end(&mut buf).await;
141 String::from_utf8_lossy(&buf).to_string()
142 } else {
143 String::new()
144 };
145
146 let stderr = if let Some(mut err) = stderr_handle {
147 use tokio::io::AsyncReadExt;
148 let mut buf = Vec::new();
149 let _ = err.read_to_end(&mut buf).await;
150 String::from_utf8_lossy(&buf).to_string()
151 } else {
152 String::new()
153 };
154
155 HookResult {
156 exit_code,
157 stdout,
158 stderr,
159 timed_out: false,
160 error: None,
161 }
162 }
163 Ok(Err(e)) => {
164 error!(
165 command = %command.command,
166 error = %e,
167 "Hook command I/O error"
168 );
169 HookResult {
170 exit_code: 1,
171 error: Some(format!("Hook I/O error: {e}")),
172 ..Default::default()
173 }
174 }
175 Err(_elapsed) => {
176 warn!(
177 command = %command.command,
178 timeout_secs = command.effective_timeout(),
179 "Hook command timed out"
180 );
181 let _ = child.kill().await;
183 HookResult {
184 exit_code: 1,
185 timed_out: true,
186 error: Some(format!(
187 "Hook timed out after {}s",
188 command.effective_timeout()
189 )),
190 ..Default::default()
191 }
192 }
193 }
194 }
195}
196
197impl Default for HookExecutor {
198 fn default() -> Self {
199 Self::new()
200 }
201}
202
203#[cfg(test)]
204#[path = "executor_tests.rs"]
205mod tests;