1use async_trait::async_trait;
12use serde_json::Value;
13use uuid::Uuid;
14
15use khive_runtime::{KhiveRuntime, KindHook, RuntimeError};
16
17fn is_40_hex(s: &str) -> bool {
19 s.len() == 40 && s.chars().all(|c| c.is_ascii_hexdigit())
20}
21
22fn properties_obj(args: &Value) -> Result<&serde_json::Map<String, Value>, RuntimeError> {
23 args.get("properties")
24 .and_then(Value::as_object)
25 .ok_or_else(|| {
26 RuntimeError::InvalidInput(
27 "kind=commit|issue|pull_request requires a `properties` object".into(),
28 )
29 })
30}
31
32#[derive(Debug, Default)]
38pub struct CommitHook;
39
40#[async_trait]
41impl KindHook for CommitHook {
42 async fn prepare_create(
43 &self,
44 _runtime: &KhiveRuntime,
45 args: &mut Value,
46 ) -> Result<(), RuntimeError> {
47 let props = properties_obj(args)?;
48
49 let sha = props
50 .get("sha")
51 .and_then(Value::as_str)
52 .ok_or_else(|| RuntimeError::InvalidInput("commit requires properties.sha".into()))?;
53 if !is_40_hex(sha) {
54 return Err(RuntimeError::InvalidInput(format!(
55 "commit properties.sha {sha:?} must be a 40-character hex string"
56 )));
57 }
58
59 if let Some(parents) = props.get("parents") {
60 let arr = parents.as_array().ok_or_else(|| {
61 RuntimeError::InvalidInput("commit properties.parents must be an array".into())
62 })?;
63 for (idx, p) in arr.iter().enumerate() {
64 let s = p.as_str().ok_or_else(|| {
65 RuntimeError::InvalidInput(format!(
66 "commit properties.parents[{idx}] must be a string"
67 ))
68 })?;
69 if !is_40_hex(s) {
70 return Err(RuntimeError::InvalidInput(format!(
71 "commit properties.parents[{idx}] {s:?} must be a 40-character hex string"
72 )));
73 }
74 }
75 }
76
77 if let Some(short) = props.get("short_sha").and_then(Value::as_str) {
78 if short.is_empty() || !sha.starts_with(short) {
79 return Err(RuntimeError::InvalidInput(format!(
80 "commit properties.short_sha {short:?} must be a non-empty prefix of sha {sha:?}"
81 )));
82 }
83 }
84
85 Ok(())
86 }
87
88 async fn after_create(
89 &self,
90 _runtime: &KhiveRuntime,
91 _id: Uuid,
92 _args: &Value,
93 ) -> Result<(), RuntimeError> {
94 Ok(())
95 }
96}
97
98pub(crate) const ISSUE_STATE_REASONS: &[&str] =
103 &["completed", "not_planned", "reopened", "duplicate"];
104
105#[derive(Debug)]
118pub struct IssueLikeHook {
119 pub kind: &'static str,
121}
122
123#[async_trait]
124impl KindHook for IssueLikeHook {
125 async fn prepare_create(
126 &self,
127 _runtime: &KhiveRuntime,
128 args: &mut Value,
129 ) -> Result<(), RuntimeError> {
130 let props = properties_obj(args)?;
131
132 let number = props.get("number").ok_or_else(|| {
133 RuntimeError::InvalidInput(format!("{} requires properties.number", self.kind))
134 })?;
135 if !number.is_u64() && !number.is_i64() {
136 return Err(RuntimeError::InvalidInput(format!(
137 "{} properties.number must be an integer",
138 self.kind
139 )));
140 }
141
142 let project_id = props
143 .get("project_id")
144 .and_then(Value::as_str)
145 .ok_or_else(|| {
146 RuntimeError::InvalidInput(format!("{} requires properties.project_id", self.kind))
147 })?;
148 if Uuid::parse_str(project_id).is_err() {
149 return Err(RuntimeError::InvalidInput(format!(
150 "{} properties.project_id {project_id:?} must be a UUID",
151 self.kind
152 )));
153 }
154
155 if let Some(reason) = props.get("state_reason").and_then(Value::as_str) {
156 if self.kind == "issue" && !ISSUE_STATE_REASONS.contains(&reason) {
160 return Err(RuntimeError::InvalidInput(format!(
161 "issue properties.state_reason is not one of the governed values — valid: {}",
162 ISSUE_STATE_REASONS.join(", ")
163 )));
164 }
165 if reason.trim().is_empty() {
166 return Err(RuntimeError::InvalidInput(format!(
167 "{} properties.state_reason must not be empty when present",
168 self.kind
169 )));
170 }
171 }
172
173 Ok(())
174 }
175
176 async fn after_create(
177 &self,
178 _runtime: &KhiveRuntime,
179 _id: Uuid,
180 _args: &Value,
181 ) -> Result<(), RuntimeError> {
182 Ok(())
183 }
184}