Skip to main content

kranz_engine/
hooks.rs

1//! External trigger core (design D-F, ticket `trigger-ci-pr-fix-mission`):
2//! parse an authenticated GitHub webhook into a ticket-draft decision, dedupe
3//! by trigger identity, and write the provenance-carrying ticket.
4//!
5//! D-F's rule is that external triggers create AUDITED work, never prompt
6//! loops. Everything this module produces enters the normal ticket pipeline —
7//! draft → plan approval → queue → `kranz work` — so spend gates and plan
8//! approval stay exactly where they are. The module performs no git
9//! operations at all: its only action set is [`TRIGGER_ACTIONS`] (draft, or
10//! draft+queue when the operator pre-consented via the queue label), and a
11//! regression test scans this file to keep it free of any git-mutation path.
12//!
13//! The webhook HTTP surface lives in `kranz-server` (`POST
14//! /api/hooks/github`); this module is the pure, surface-agnostic core so any
15//! surface can drive the same decisions.
16
17use crate::error::{EngineError, Result};
18use crate::ticket::Ticket;
19use crate::{config, paths, scrub};
20use serde::Deserialize;
21use serde_json::Value;
22use std::fmt;
23use std::io::ErrorKind;
24use std::path::Path;
25
26// ---------------------------------------------------------------------------
27// Configuration (`hooks` key of the layered config files)
28// ---------------------------------------------------------------------------
29
30/// Default comment label that drafts a fix ticket.
31pub const DEFAULT_FIX_LABEL: &str = "kranz:fix";
32/// Default comment label that additionally pre-consents to queueing the
33/// drafted plan (plan approval itself is never skipped).
34pub const DEFAULT_QUEUE_LABEL: &str = "kranz:fix-and-queue";
35
36/// Webhook trigger configuration — the `hooks` key of `.kranz/config.json`
37/// (additive; absent ⇒ the route refuses closed, never open-accepts).
38///
39/// Loaded separately from [`crate::types::MissionConfig`] on purpose: the
40/// secret must never ride into a `mission.created` event's serialized config
41/// or any other log, so this type has NO `Serialize` impl and a redacting
42/// `Debug`. `Debug`/`tracing` output shows `[REDACTED]` for the secret.
43#[derive(Clone, PartialEq, Deserialize)]
44#[serde(rename_all = "camelCase", default)]
45pub struct HooksConfig {
46    /// Per-repo HMAC secret for `X-Hub-Signature-256` verification.
47    pub secret: Option<String>,
48    /// Comment label that drafts a fix ticket (default `kranz:fix`).
49    pub fix_label: String,
50    /// Comment label that additionally pre-consents to queueing the drafted
51    /// plan (default `kranz:fix-and-queue`).
52    pub queue_label: String,
53    /// GitHub logins allowed to request work through PR comments. Empty
54    /// disables comment triggers; a valid webhook signature authenticates
55    /// GitHub, not the commenter's authority to spend or approve work.
56    pub allow_users: Vec<String>,
57}
58
59impl Default for HooksConfig {
60    fn default() -> Self {
61        HooksConfig {
62            secret: None,
63            fix_label: DEFAULT_FIX_LABEL.to_string(),
64            queue_label: DEFAULT_QUEUE_LABEL.to_string(),
65            allow_users: Vec::new(),
66        }
67    }
68}
69
70impl fmt::Debug for HooksConfig {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        f.debug_struct("HooksConfig")
73            .field("secret", &self.secret.as_ref().map(|_| "[REDACTED]"))
74            .field("fix_label", &self.fix_label)
75            .field("queue_label", &self.queue_label)
76            .field("allow_users", &self.allow_users)
77            .finish()
78    }
79}
80
81/// Read the `hooks` key out of the same layered config files
82/// ([`config::load`] reads): defaults, then the global file, then the project
83/// file, later layers winning key-wise. Files without a `hooks` key are
84/// skipped; a present-but-invalid `hooks` value is a config error naming the
85/// layer. Absent everywhere ⇒ [`HooksConfig::default`] (no secret ⇒ the
86/// caller refuses closed).
87pub fn load_hooks(repo_root: &Path) -> Result<HooksConfig> {
88    let mut layers: Vec<std::path::PathBuf> = Vec::new();
89    if let Some(global) = paths::global_config() {
90        layers.push(global);
91    }
92    layers.push(paths::project_config(repo_root));
93
94    let mut merged = serde_json::json!({});
95    for path in layers {
96        let text = match std::fs::read_to_string(&path) {
97            Ok(text) => text,
98            Err(e) if e.kind() == ErrorKind::NotFound => continue,
99            Err(e) => {
100                return Err(EngineError::Config(format!(
101                    "cannot read config file {}: {e}",
102                    path.display()
103                )))
104            }
105        };
106        let value: Value = serde_json::from_str(&text).map_err(|e| {
107            EngineError::Config(format!(
108                "invalid JSON in config file {}: {e}",
109                path.display()
110            ))
111        })?;
112        if let Some(hooks) = value.get("hooks") {
113            config::deep_merge(&mut merged, hooks);
114        }
115    }
116
117    serde_json::from_value(merged)
118        .map_err(|e| EngineError::Config(format!("hooks configuration does not deserialize: {e}")))
119}
120
121// ---------------------------------------------------------------------------
122// Signature verification (HMAC-SHA256, RFC 2104)
123// ---------------------------------------------------------------------------
124
125/// Hex-encoded HMAC-SHA256 (RFC 2104) of `message` under `key`. Implemented
126/// over the workspace's existing `sha2` dependency — no crypto crate is
127/// added. Tested against the RFC 4231 vectors.
128pub fn hmac_sha256_hex(key: &[u8], message: &[u8]) -> String {
129    use sha2::{Digest, Sha256};
130    const BLOCK: usize = 64; // SHA-256 block size
131
132    let mut key_block = [0u8; BLOCK];
133    if key.len() > BLOCK {
134        let hashed = Sha256::digest(key);
135        key_block[..hashed.len()].copy_from_slice(&hashed);
136    } else {
137        key_block[..key.len()].copy_from_slice(key);
138    }
139
140    let mut inner = Sha256::new();
141    for byte in key_block {
142        inner.update([byte ^ 0x36]);
143    }
144    inner.update(message);
145    let inner_hash = inner.finalize();
146
147    let mut outer = Sha256::new();
148    for byte in key_block {
149        outer.update([byte ^ 0x5c]);
150    }
151    outer.update(inner_hash);
152    let digest = outer.finalize();
153
154    let mut hex = String::with_capacity(digest.len() * 2);
155    for byte in digest {
156        hex.push_str(&format!("{byte:02x}"));
157    }
158    hex
159}
160
161/// Verify a GitHub `X-Hub-Signature-256` header value (`sha256=<hex>`)
162/// against the raw request body. Constant-time on the digest bytes, and the
163/// secret is never logged. A missing or malformed header never verifies.
164pub fn verify_signature(secret: &str, body: &[u8], header: Option<&str>) -> bool {
165    use subtle::ConstantTimeEq;
166    let Some(presented) = header.and_then(|value| value.strip_prefix("sha256=")) else {
167        return false;
168    };
169    let expected = hmac_sha256_hex(secret.as_bytes(), body);
170    expected.as_bytes().ct_eq(presented.as_bytes()).into()
171}
172
173// ---------------------------------------------------------------------------
174// Trigger parsing (the allowlist + payload rules)
175// ---------------------------------------------------------------------------
176
177/// `X-GitHub-Event` values the hook route acts on. Everything else is
178/// 202-ignored with a decision log line.
179pub const ACCEPTED_EVENTS: [&str; 3] = [
180    "workflow_run",
181    "pull_request_review_comment",
182    "issue_comment",
183];
184
185/// The only actions a trigger may drive (D-F: draft, or draft+queue on
186/// recorded pre-consent). There is deliberately no run/land action: a
187/// trigger ticket reaches execution only through the existing approval path.
188/// A regression test pins this set.
189pub const TRIGGER_ACTIONS: [&str; 2] = ["draft", "queue"];
190
191/// Mission branch prefix — a CI failure on one of these is kranz's own work.
192const MISSION_BRANCH_PREFIX: &str = "kranz/mission-";
193
194/// Which external trigger opened the ticket (the `trigger:` frontmatter
195/// provenance field).
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub enum TriggerKind {
198    /// `workflow_run` with `conclusion: failure`.
199    CiFailure,
200    /// A PR comment carrying the configured trigger label.
201    PrComment,
202}
203
204impl TriggerKind {
205    /// The `trigger:` frontmatter value.
206    pub fn as_str(self) -> &'static str {
207        match self {
208            TriggerKind::CiFailure => "ci-failure",
209            TriggerKind::PrComment => "pr-comment",
210        }
211    }
212}
213
214/// The consent state recorded into the ticket body (D-F: never bypassed).
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum Consent {
217    /// Draft only: a human runs the normal draft → approve → queue path.
218    DraftOnly,
219    /// The trigger comment carried the queue label: the draft may auto-queue
220    /// on an approved plan (plan approval itself is never skipped).
221    PreConsentedQueue,
222}
223
224impl Consent {
225    /// Whether the draft may enqueue on an approved plan — the `then_enqueue`
226    /// flag of the existing draft pipeline.
227    pub fn then_enqueue(self) -> bool {
228        matches!(self, Consent::PreConsentedQueue)
229    }
230
231    /// The consent label written into the ticket's provenance block.
232    pub fn as_str(self) -> &'static str {
233        match self {
234            Consent::DraftOnly => "draft-only",
235            Consent::PreConsentedQueue => "fix-and-queue",
236        }
237    }
238}
239
240/// One accepted trigger, normalized out of the webhook payload. Every field
241/// that lands in the ticket is already bounded and scrubbed.
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct Trigger {
244    pub kind: TriggerKind,
245    /// Dedup identity: the workflow run id or the PR number.
246    pub dedup_id: String,
247    pub title: String,
248    /// Trigger source url (workflow run / comment permalink).
249    pub source_url: String,
250    pub actor: String,
251    /// Bounded, scrubbed failure/comment excerpt for the ticket body.
252    pub excerpt: String,
253    pub consent: Consent,
254}
255
256/// Cap on the raw text pulled from a payload BEFORE scrubbing, so scrub work
257/// is bounded; the ticket excerpt is then capped to [`MAX_EXCERPT_CHARS`].
258const MAX_RAW_CHARS: usize = 8 * 1024;
259/// Cap on the excerpt written into the ticket body.
260const MAX_EXCERPT_CHARS: usize = 1000;
261/// Cap on a one-line field (title, actor).
262const MAX_LINE_CHARS: usize = 100;
263
264/// Bound + scrub free text from a payload: the raw text is cut to
265/// [`MAX_RAW_CHARS`] first (bounding scrub work while keeping a redaction
266/// pass over everything that could survive), scrubbed, then cut to `max`.
267fn bound_scrubbed(raw: &str, max: usize) -> String {
268    let bounded = scrub::truncate_chars(raw, MAX_RAW_CHARS);
269    let scrubbed = scrub::scrub(&bounded);
270    scrub::truncate_chars(scrubbed.trim(), max)
271}
272
273/// One-line version of [`bound_scrubbed`] for titles and logins.
274fn bound_line(raw: &str) -> String {
275    let one_line = raw.replace(['\n', '\r'], " ");
276    bound_scrubbed(&one_line, MAX_LINE_CHARS)
277}
278
279/// Parse a webhook payload into a [`Trigger`], or `None` when the event does
280/// not match a trigger rule (the caller 202-ignores it with a decision log).
281/// `event` is the `X-GitHub-Event` header value; anything outside
282/// [`ACCEPTED_EVENTS`] is `None` here too, keeping the allowlist meaningful
283/// at the core as well as at the route.
284pub fn parse_trigger(event: &str, payload: &Value, cfg: &HooksConfig) -> Option<Trigger> {
285    match event {
286        "workflow_run" => parse_workflow_run(payload),
287        "pull_request_review_comment" | "issue_comment" => parse_comment(event, payload, cfg),
288        _ => None,
289    }
290}
291
292/// `workflow_run`: only `action: completed` with `conclusion: failure` on the
293/// repository's default branch or a mission branch.
294fn parse_workflow_run(payload: &Value) -> Option<Trigger> {
295    if payload.get("action")?.as_str()? != "completed" {
296        return None;
297    }
298    let run = payload.get("workflow_run")?;
299    if run.get("conclusion")?.as_str()? != "failure" {
300        return None;
301    }
302    // A fork can name its branch `main` or `kranz/mission-*` too. The
303    // workflow's source repository must be the repository receiving the
304    // webhook before a branch name can authorize an automatic draft.
305    let repository = payload.pointer("/repository/full_name")?.as_str()?;
306    let head_repository = run.pointer("/head_repository/full_name")?.as_str()?;
307    if repository.is_empty() || !repository.eq_ignore_ascii_case(head_repository) {
308        return None;
309    }
310    let head_branch = run.get("head_branch")?.as_str()?;
311    let default_branch = payload
312        .pointer("/repository/default_branch")
313        .and_then(Value::as_str)
314        .unwrap_or("");
315    let on_default = !default_branch.is_empty() && head_branch == default_branch;
316    if !on_default && !head_branch.starts_with(MISSION_BRANCH_PREFIX) {
317        return None;
318    }
319
320    let run_id = run.get("id")?.as_u64()?.to_string();
321    let workflow = run.get("name").and_then(Value::as_str).unwrap_or("CI");
322    let url = run
323        .get("html_url")
324        .and_then(Value::as_str)
325        .unwrap_or("")
326        .to_string();
327    let actor = run
328        .pointer("/actor/login")
329        .and_then(Value::as_str)
330        .or_else(|| payload.pointer("/sender/login").and_then(Value::as_str))
331        .unwrap_or("unknown");
332    let head_sha = run
333        .get("head_sha")
334        .and_then(Value::as_str)
335        .map(|sha| sha.chars().take(12).collect::<String>())
336        .unwrap_or_default();
337    let excerpt = format!(
338        "workflow: {}\nbranch: {}\nhead_sha: {}\nconclusion: failure\nrun id: {}",
339        bound_line(workflow),
340        bound_line(head_branch),
341        head_sha,
342        run_id,
343    );
344
345    Some(Trigger {
346        kind: TriggerKind::CiFailure,
347        dedup_id: run_id.clone(),
348        title: bound_line(&format!(
349            "CI failure: {workflow} on {head_branch} (run {run_id})"
350        )),
351        source_url: url,
352        actor: bound_line(actor),
353        excerpt,
354        consent: Consent::DraftOnly,
355    })
356}
357
358/// `issue_comment` / `pull_request_review_comment`: only `action: created`
359/// where the comment body carries the configured fix (or queue) label and
360/// the target is a pull request. The queue label is checked FIRST: its
361/// default (`kranz:fix-and-queue`) starts with the fix label, so checking
362/// the fix label first would misread pre-consent as draft-only.
363fn parse_comment(event: &str, payload: &Value, cfg: &HooksConfig) -> Option<Trigger> {
364    if payload.get("action")?.as_str()? != "created" {
365        return None;
366    }
367    let actor = payload.pointer("/comment/user/login")?.as_str()?;
368    if actor.is_empty()
369        || !cfg
370            .allow_users
371            .iter()
372            .any(|allowed| allowed.eq_ignore_ascii_case(actor))
373    {
374        return None;
375    }
376    let body = payload.pointer("/comment/body")?.as_str()?;
377    let consent = if body.contains(&cfg.queue_label) {
378        Consent::PreConsentedQueue
379    } else if body.contains(&cfg.fix_label) {
380        Consent::DraftOnly
381    } else {
382        return None;
383    };
384
385    let pr_number = match event {
386        // A review comment is always on a PR.
387        "pull_request_review_comment" => payload.pointer("/pull_request/number")?.as_u64()?,
388        // An issue comment only triggers when the issue IS a pull request.
389        _ => {
390            payload.pointer("/issue/pull_request")?;
391            payload.pointer("/issue/number")?.as_u64()?
392        }
393    };
394
395    let url = payload
396        .pointer("/comment/html_url")
397        .and_then(Value::as_str)
398        .unwrap_or("")
399        .to_string();
400    Some(Trigger {
401        kind: TriggerKind::PrComment,
402        dedup_id: pr_number.to_string(),
403        title: bound_line(&format!("PR #{pr_number} fix requested by {actor}")),
404        source_url: url,
405        actor: bound_line(actor),
406        excerpt: bound_scrubbed(body, MAX_EXCERPT_CHARS),
407        consent,
408    })
409}
410
411// ---------------------------------------------------------------------------
412// Repository identity (no cross-repo triggers)
413// ---------------------------------------------------------------------------
414
415/// The `repository.full_name` (`owner/repo`) a GitHub webhook payload claims.
416pub fn repo_full_name_from_payload(payload: &Value) -> Option<String> {
417    payload
418        .pointer("/repository/full_name")
419        .and_then(Value::as_str)
420        .map(str::to_string)
421}
422
423/// Derive `owner/repo` from a GitHub remote URL (`git@github.com:o/r.git`,
424/// `ssh://git@github.com/o/r.git`, `https://github.com/o/r[.git]`). Any other
425/// host or shape is `None` — the caller refuses closed when it cannot
426/// establish the served repository's identity.
427pub fn github_full_name_from_remote(url: &str) -> Option<String> {
428    let path = if let Some(scp) = url.strip_prefix("git@github.com:") {
429        scp
430    } else {
431        url.strip_prefix("https://github.com/")
432            .or_else(|| url.strip_prefix("ssh://git@github.com/"))?
433    };
434    let path = path.trim_end_matches('/');
435    let path = path.strip_suffix(".git").unwrap_or(path);
436    let mut parts = path.split('/');
437    let (owner, repo) = (parts.next()?, parts.next()?);
438    if parts.next().is_some() || owner.is_empty() || repo.is_empty() {
439        return None;
440    }
441    Some(format!("{owner}/{repo}"))
442}
443
444// ---------------------------------------------------------------------------
445// Ticket drafting (dedup + provenance)
446// ---------------------------------------------------------------------------
447
448/// Outcome of drafting a trigger ticket.
449#[derive(Debug, Clone, PartialEq, Eq)]
450pub enum TriggerDraft {
451    /// The ticket was written (state New, ready for the draft pipeline).
452    Drafted { slug: String },
453    /// A ticket for this trigger already exists — a second event for the
454    /// same workflow run / PR is a no-op, never a duplicate ticket.
455    Duplicate { slug: String },
456}
457
458/// Deterministic ticket slug for a trigger: `trigger-ci-<run id>` /
459/// `trigger-pr-<number>`. The dedup identity is numeric in both accepted
460/// payloads; the filter keeps the slug valid even if a future payload shape
461/// changes that.
462pub fn trigger_slug(kind: TriggerKind, dedup_id: &str) -> String {
463    let clean: String = dedup_id
464        .chars()
465        .filter(|c| c.is_ascii_alphanumeric())
466        .collect();
467    let prefix = match kind {
468        TriggerKind::CiFailure => "trigger-ci",
469        TriggerKind::PrComment => "trigger-pr",
470    };
471    format!("{prefix}-{clean}")
472}
473
474/// Write the trigger's ticket, deduped by slug: an existing ticket file for
475/// the same workflow run / PR is a no-op ([`TriggerDraft::Duplicate`]).
476/// Performs no git operations; the ticket enters the normal pipeline at
477/// state New, exactly like a human-authored one.
478pub fn draft_trigger_ticket(repo_root: &Path, trigger: &Trigger) -> Result<TriggerDraft> {
479    let slug = trigger_slug(trigger.kind, &trigger.dedup_id);
480    Ticket::ensure_valid_slug(&slug)?;
481    let path = Ticket::md_path(repo_root, &slug);
482    if path.exists() {
483        return Ok(TriggerDraft::Duplicate { slug });
484    }
485    let body = trigger_ticket_template(trigger);
486    // Self-check, mirroring Ticket::scaffold: a template that does not parse
487    // back is a bug, not a ticket.
488    Ticket::parse(&slug, &body).map_err(|e| {
489        EngineError::Other(format!(
490            "internal error: trigger ticket template does not parse: {e}"
491        ))
492    })?;
493    std::fs::create_dir_all(Ticket::tickets_dir(repo_root))?;
494    std::fs::write(&path, body)?;
495    Ok(TriggerDraft::Drafted { slug })
496}
497
498/// The trigger ticket's markdown: the standard four sections plus the
499/// additive `trigger:` frontmatter field and a provenance block in
500/// `## Context` (source url, actor, consent state, bounded scrubbed
501/// excerpt). Parses back cleanly through [`Ticket::parse`].
502pub fn trigger_ticket_template(trigger: &Trigger) -> String {
503    let goal = match trigger.kind {
504        TriggerKind::CiFailure => format!(
505            "Investigate and fix the CI failure recorded at {} (details in Context). \
506             Reproduce the failing gate locally, land the minimal fix, and keep the \
507             repo's gate suite green.",
508            trigger.source_url
509        ),
510        TriggerKind::PrComment => format!(
511            "Address the review feedback from {} (PR #{}, excerpt in Context). \
512             Land the minimal change that resolves it.",
513            trigger.source_url, trigger.dedup_id
514        ),
515    };
516    let consent_note = match trigger.consent {
517        Consent::DraftOnly => {
518            "a human drives the normal draft → approve → queue path; \
519             the trigger itself queues nothing"
520        }
521        Consent::PreConsentedQueue => {
522            "operator pre-consented via the queue label: the draft \
523             auto-queues on an approved plan (plan approval itself is never skipped)"
524        }
525    };
526    let excerpt_heading = match trigger.kind {
527        TriggerKind::CiFailure => "Failure excerpt (bounded, scrubbed)",
528        TriggerKind::PrComment => "Comment excerpt (bounded, scrubbed)",
529    };
530    format!(
531        "---\n\
532         title: {title}\n\
533         priority: 2\n\
534         schedule: once\n\
535         trigger: {kind}\n\
536         ---\n\
537         \n\
538         ## Goal\n\
539         {goal}\n\
540         \n\
541         ## Context\n\
542         Trigger: {kind} (GitHub webhook)\n\
543         Source: {source}\n\
544         Actor: {actor}\n\
545         Consent: {consent} — {consent_note}\n\
546         \n\
547         {excerpt_heading}:\n\
548         {excerpt}\n\
549         \n\
550         ## Scoping answers\n\
551         \n\
552         ## Acceptance hints\n\
553         - Reproduce the failure or concern before changing code.\n\
554         - The fix mission starts only through the normal approval path.\n",
555        title = bound_line(&trigger.title),
556        kind = trigger.kind.as_str(),
557        goal = bound_scrubbed(&goal, MAX_EXCERPT_CHARS),
558        source = bound_line(&trigger.source_url),
559        actor = bound_line(&trigger.actor),
560        consent = trigger.consent.as_str(),
561        excerpt = trigger.excerpt,
562    )
563}