use std::path::Path;
use std::time::{Duration, Instant};
use serde::Serialize;
use thiserror::Error;
use tokio::process::Command;
use crate::context::{IssueRun, IssueStage};
use crate::shell::{CommandExecError, CommandExt};
use crate::template::{JinjaRenderer, TemplateError};
const HOOK_TIMEOUT: Duration = Duration::from_secs(30);
const STDERR_TAIL_BYTES: usize = 2048;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HookKind {
AfterIssueWorkdirCreate,
BeforeIssueStageRun,
AfterIssueStageRun,
}
impl HookKind {
pub fn as_str(&self) -> &'static str {
match self {
HookKind::AfterIssueWorkdirCreate => "after_issue_workdir_create",
HookKind::BeforeIssueStageRun => "before_issue_stage_run",
HookKind::AfterIssueStageRun => "after_issue_stage_run",
}
}
}
#[derive(Debug, Error)]
pub enum HookError {
#[error("hook `{hook}` template render failed: {source}")]
Render {
hook: &'static str,
#[source]
source: TemplateError,
},
#[error("hook `{hook}` shell execution failed: {source}")]
Exec {
hook: &'static str,
#[source]
source: CommandExecError,
},
#[error("hook `{hook}` exited with non-zero status {code}: {stderr_tail}")]
NonZeroExit {
hook: &'static str,
code: i32,
stderr_tail: String,
},
}
#[derive(Debug, Clone)]
pub struct HookRunner {
renderer: JinjaRenderer,
timeout: Duration,
}
impl Default for HookRunner {
fn default() -> Self {
Self::new()
}
}
impl HookRunner {
pub fn new() -> Self {
Self {
renderer: JinjaRenderer::new(),
timeout: HOOK_TIMEOUT,
}
}
#[allow(dead_code)]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[inline]
pub async fn after_issue_workdir_created(&self, issue: &IssueRun, hook: &Option<String>) -> Result<(), HookError> {
self
.schedule_inner(HookKind::AfterIssueWorkdirCreate, issue.workdir(), hook, issue)
.await
}
#[inline]
pub async fn before_issue_stage_run(&self, stage: &IssueStage, hook: &Option<String>) -> Result<(), HookError> {
self
.schedule_inner(HookKind::BeforeIssueStageRun, stage.workdir(), hook, stage)
.await
}
#[inline]
pub async fn after_issue_stage_run(&self, stage: &IssueStage, hook: &Option<String>) -> Result<(), HookError> {
self
.schedule_inner(HookKind::AfterIssueStageRun, stage.workdir(), hook, stage)
.await
}
async fn schedule_inner<Context: Serialize>(
&self,
kind: HookKind,
cwd: &Path,
hook: &Option<String>,
context: Context,
) -> Result<(), HookError> {
let hook_name = kind.as_str();
let span = tracing::info_span!(
"hook",
hook = %hook_name,
);
let command = match hook {
Some(body) => body,
None => {
span.in_scope(|| {
tracing::debug!("hook not configured; skipping execution");
});
return Ok(());
},
};
let command = self.render_hook_command(kind, command, context)?;
self.run_command(kind, cwd, command, &span).await
}
fn render_hook_command<Context: Serialize>(
&self,
kind: HookKind,
command: &str,
context: Context,
) -> Result<String, HookError> {
self.renderer.render(command, context).map_err(|e| HookError::Render {
hook: kind.as_str(),
source: e,
})
}
async fn run_command(
&self,
kind: HookKind,
cwd: &Path,
command: String,
span: &tracing::Span,
) -> Result<(), HookError> {
let started = Instant::now();
span.in_scope(|| {
tracing::debug!(cwd = %cwd.display(), "hook shell starting");
});
let output = match shell_command(&command).current_dir(cwd).timeout(self.timeout).output().await {
Ok(output) => output,
Err(source) => {
let duration = started.elapsed().as_millis();
span.in_scope(|| {
tracing::error!(duration, error = %source, "hook shell exec errored");
});
return Err(HookError::Exec {
hook: kind.as_str(),
source,
});
},
};
let duration = started.elapsed().as_millis();
if output.status.success() {
span.in_scope(|| {
tracing::info!(duration, "hook completed");
});
return Ok(());
}
let code = output.status.code().unwrap_or(-1);
let stderr_tail = tail_utf8(&output.stderr, STDERR_TAIL_BYTES);
let error = HookError::NonZeroExit {
hook: kind.as_str(),
code,
stderr_tail,
};
span.in_scope(|| {
tracing::error!(duration, error = %error, "hook exited non-zero");
});
Err(error)
}
}
fn tail_utf8(bytes: &[u8], limit: usize) -> String {
if bytes.len() <= limit {
return String::from_utf8_lossy(bytes).into_owned();
}
let start = bytes.len() - limit;
String::from_utf8_lossy(&bytes[start..]).into_owned()
}
#[cfg(windows)]
fn shell_command(body: &str) -> Command {
let mut cmd = Command::new("cmd");
cmd.args(["/C", body]);
cmd
}
#[cfg(not(windows))]
fn shell_command(body: &str) -> Command {
let mut cmd = Command::new("sh");
cmd.args(["-c", body]);
cmd
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hook_kind_names_match_workflow_keys() {
assert_eq!(HookKind::AfterIssueWorkdirCreate.as_str(), "after_issue_workdir_create");
assert_eq!(HookKind::BeforeIssueStageRun.as_str(), "before_issue_stage_run");
assert_eq!(HookKind::AfterIssueStageRun.as_str(), "after_issue_stage_run");
}
#[tokio::test]
async fn unconfigured_hook_skips_without_requiring_cwd() {
let temp = tempfile::tempdir().expect("tempdir");
let missing_cwd = temp.path().join("missing");
HookRunner::new()
.schedule_inner(
HookKind::BeforeIssueStageRun,
&missing_cwd,
&None,
serde_json::json!({}),
)
.await
.expect("unconfigured hook skips");
assert!(!missing_cwd.exists());
}
#[tokio::test]
async fn configured_hook_renders_template_and_executes_in_cwd() {
let temp = tempfile::tempdir().expect("tempdir");
let hook = Some("echo {{ issue.id }}:{{ issue.stage }}>hook-output.txt".to_string());
HookRunner::new()
.schedule_inner(
HookKind::BeforeIssueStageRun,
temp.path(),
&hook,
serde_json::json!({
"issue": {
"id": "ISS-7",
"stage": "plan"
},
}),
)
.await
.expect("configured hook runs");
let output = std::fs::read_to_string(temp.path().join("hook-output.txt")).expect("hook output");
assert_eq!(output.lines().next(), Some("ISS-7:plan"));
}
#[cfg(not(windows))]
#[test]
fn unconfigured_hook_log_omits_phase_field() {
use crate::logging::tests::CaptureLayer;
use tracing_subscriber::{Registry, layer::SubscriberExt};
let (layer, events) = CaptureLayer::new();
let subscriber = Registry::default().with(layer);
let _default = tracing::subscriber::set_default(subscriber);
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime");
runtime.block_on(async {
let temp = tempfile::tempdir().expect("tempdir");
let hook = None;
HookRunner::new()
.schedule_inner(HookKind::BeforeIssueStageRun, temp.path(), &hook, serde_json::json!({}))
.await
.expect("configured hook runs");
});
let events = events.lock().expect("events mutex");
let skipped = events
.iter()
.find(|event| event["message"] == "hook not configured; skipping execution")
.expect("hook skip log");
assert_eq!(skipped["hook"], "before_issue_stage_run");
assert!(skipped.get("phase").is_none());
}
#[cfg(not(windows))]
#[tokio::test]
async fn nonzero_hook_reports_bounded_stderr_tail() {
let temp = tempfile::tempdir().expect("tempdir");
let stderr = format!("{}TAIL", "x".repeat(STDERR_TAIL_BYTES + 10));
let hook = Some(format!("printf '%s' '{stderr}' >&2; exit 7"));
let err = HookRunner::new()
.schedule_inner(HookKind::AfterIssueStageRun, temp.path(), &hook, serde_json::json!({}))
.await
.expect_err("nonzero hook fails");
match err {
HookError::NonZeroExit {
hook,
code,
stderr_tail,
} => {
assert_eq!(hook, "after_issue_stage_run");
assert_eq!(code, 7);
assert_eq!(stderr_tail, format!("{}TAIL", "x".repeat(STDERR_TAIL_BYTES - 4)));
},
other => panic!("expected nonzero exit, got {other:?}"),
}
}
}