Skip to main content

khive_pack_git/
hook.rs

1//! `KindHook` implementations for the three note kinds this pack contributes.
2//!
3//! Validation only — this pack introduces no new edges at `after_create` time.
4//! Provenance edges (`annotates` -> project / document / merging PR) are
5//! supplied by the caller (the ingester, see `src/ingest.rs`) as part of the
6//! generic `create(kind=..., annotates=[...])` call; the runtime's own
7//! `create_note` path validates and links them atomically, so no
8//! `after_create` edge-creation logic is needed here (unlike gtd's
9//! `TaskHook::after_create`).
10
11use async_trait::async_trait;
12use serde_json::Value;
13use uuid::Uuid;
14
15use khive_runtime::{KhiveRuntime, KindHook, RuntimeError};
16
17/// A 40-character lowercase-hex string, the shape of a full git commit SHA-1.
18fn 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/// `KindHook` for the immutable `commit` note kind.
33///
34/// Validates `properties.sha` (required, 40-hex) and, when present,
35/// `properties.parents` (array of 40-hex strings). Commits have no lifecycle
36/// and no `after_create` edge work.
37#[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
98/// The governed `state_reason` value set for `issue` (ADR-088 §3). `pub(crate)`
99/// so `ingest::MaskedIssueFields` can classify against the same set at the
100/// masking boundary, before an ungoverned (possibly credential-shaped) raw
101/// value can reach this hook's error path.
102pub(crate) const ISSUE_STATE_REASONS: &[&str] =
103    &["completed", "not_planned", "reopened", "duplicate"];
104
105/// `KindHook` shared by `issue` and `pull_request` — both require
106/// `properties.number` and `properties.project_id`, and, when present,
107/// validate `properties.state_reason`. `issue`'s `state_reason` is governed
108/// to a fixed set (ADR-088 §3); v0 does not document a fixed set for
109/// `pull_request`'s `state_reason`, so it is only checked for non-emptiness
110/// there.
111///
112/// GitHub issue/PR numbers are repository-scoped, not globally unique — two
113/// different `project` entities in the same namespace can each have a `#1`.
114/// `properties.project_id` is the natural-key scoping field the ingester's
115/// `find_by_number` lookup filters on, so it is required and validated as a
116/// UUID here rather than left to the caller's discipline.
117#[derive(Debug)]
118pub struct IssueLikeHook {
119    /// The note kind this instance validates: `"issue"` or `"pull_request"`.
120    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            // The raw value is never interpolated into this error: it is
157            // caller-controlled (for `issue`, sourced from GitHub) and may be
158            // credential-shaped. Only the static governed set is echoed.
159            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}