Skip to main content

khive_pack_git/
hook.rs

1//! `KindHook` implementations for the three note kinds this pack contributes.
2//! Validation only — provenance edges are supplied by the caller and linked
3//! by the runtime's `create_note` path itself. See
4//! crates/khive-pack-git/docs/api/hooks.md for why no `after_create` edge work
5//! is needed here.
6
7use async_trait::async_trait;
8use serde_json::Value;
9use uuid::Uuid;
10
11use khive_runtime::{KhiveRuntime, KindHook, RuntimeError};
12
13/// A 40-character lowercase-hex string, the shape of a full git commit SHA-1.
14fn 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/// `KindHook` for the immutable `commit` note kind.
29///
30/// Validates `properties.sha` (required, 40-hex) and, when present,
31/// `properties.parents` (array of 40-hex strings). Commits have no lifecycle
32/// and no `after_create` edge work.
33#[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
94/// The governed `state_reason` value set for `issue` (ADR-088 §3). See
95/// crates/khive-pack-git/docs/api/hooks.md#issuelikehook for why this is
96/// `pub(crate)`.
97pub(crate) const ISSUE_STATE_REASONS: &[&str] =
98    &["completed", "not_planned", "reopened", "duplicate"];
99
100/// `KindHook` shared by `issue` and `pull_request` — both require
101/// `properties.number` (integer) and `properties.project_id` (UUID), and,
102/// when present, validate `properties.state_reason` (governed to a fixed
103/// set for `issue` per ADR-088 §3; only checked for non-emptiness for
104/// `pull_request`). See crates/khive-pack-git/docs/api/hooks.md#issuelikehook
105/// for why `project_id` is required here rather than left to caller
106/// discipline.
107#[derive(Debug)]
108pub struct IssueLikeHook {
109    /// The note kind this instance validates: `"issue"` or `"pull_request"`.
110    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            // The raw value is never interpolated into this error: it is
147            // caller-controlled (for `issue`, sourced from GitHub) and may be
148            // credential-shaped. Only the static governed set is echoed.
149            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}