1use 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
26pub const DEFAULT_FIX_LABEL: &str = "kranz:fix";
32pub const DEFAULT_QUEUE_LABEL: &str = "kranz:fix-and-queue";
35
36#[derive(Clone, PartialEq, Deserialize)]
44#[serde(rename_all = "camelCase", default)]
45pub struct HooksConfig {
46 pub secret: Option<String>,
48 pub fix_label: String,
50 pub queue_label: String,
53 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
81pub 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
121pub fn hmac_sha256_hex(key: &[u8], message: &[u8]) -> String {
129 use sha2::{Digest, Sha256};
130 const BLOCK: usize = 64; 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
161pub 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
173pub const ACCEPTED_EVENTS: [&str; 3] = [
180 "workflow_run",
181 "pull_request_review_comment",
182 "issue_comment",
183];
184
185pub const TRIGGER_ACTIONS: [&str; 2] = ["draft", "queue"];
190
191const MISSION_BRANCH_PREFIX: &str = "kranz/mission-";
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub enum TriggerKind {
198 CiFailure,
200 PrComment,
202}
203
204impl TriggerKind {
205 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum Consent {
217 DraftOnly,
219 PreConsentedQueue,
222}
223
224impl Consent {
225 pub fn then_enqueue(self) -> bool {
228 matches!(self, Consent::PreConsentedQueue)
229 }
230
231 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#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct Trigger {
244 pub kind: TriggerKind,
245 pub dedup_id: String,
247 pub title: String,
248 pub source_url: String,
250 pub actor: String,
251 pub excerpt: String,
253 pub consent: Consent,
254}
255
256const MAX_RAW_CHARS: usize = 8 * 1024;
259const MAX_EXCERPT_CHARS: usize = 1000;
261const MAX_LINE_CHARS: usize = 100;
263
264fn 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
273fn bound_line(raw: &str) -> String {
275 let one_line = raw.replace(['\n', '\r'], " ");
276 bound_scrubbed(&one_line, MAX_LINE_CHARS)
277}
278
279pub 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
292fn 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 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
358fn 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 "pull_request_review_comment" => payload.pointer("/pull_request/number")?.as_u64()?,
388 _ => {
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
411pub 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
423pub 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#[derive(Debug, Clone, PartialEq, Eq)]
450pub enum TriggerDraft {
451 Drafted { slug: String },
453 Duplicate { slug: String },
456}
457
458pub 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
474pub 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 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
498pub 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}