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