use crate::scm::MergeMethod;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ReactionAction {
SendToAgent,
Notify,
AutoMerge,
}
impl ReactionAction {
pub const fn as_str(self) -> &'static str {
match self {
Self::SendToAgent => "send-to-agent",
Self::Notify => "notify",
Self::AutoMerge => "auto-merge",
}
}
}
impl std::fmt::Display for ReactionAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventPriority {
Urgent,
Action,
Warning,
Info,
}
impl EventPriority {
pub const fn as_str(self) -> &'static str {
match self {
Self::Urgent => "urgent",
Self::Action => "action",
Self::Warning => "warning",
Self::Info => "info",
}
}
}
pub fn default_priority_for_reaction_key(reaction_key: &str) -> EventPriority {
match reaction_key {
"ci-failed" | "merge-conflicts" => EventPriority::Warning,
"changes-requested" => EventPriority::Info,
"approved-and-green" => EventPriority::Action,
"agent-idle" | "all-complete" => EventPriority::Info,
"agent-stuck" | "agent-needs-input" | "agent-exited" => EventPriority::Urgent,
_ => EventPriority::Warning,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum EscalateAfter {
Attempts(u32),
Duration(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReactionConfig {
#[serde(default = "default_auto", skip_serializing_if = "is_true")]
pub auto: bool,
pub action: ReactionAction,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub priority: Option<EventPriority>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retries: Option<u32>,
#[serde(
default,
rename = "escalate_after",
alias = "escalate-after",
skip_serializing_if = "Option::is_none"
)]
pub escalate_after: Option<EscalateAfter>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub threshold: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub include_summary: bool,
#[serde(
default,
rename = "merge_method",
alias = "merge-method",
skip_serializing_if = "Option::is_none"
)]
pub merge_method: Option<MergeMethod>,
}
impl ReactionConfig {
pub fn new(action: ReactionAction) -> Self {
Self {
auto: true,
action,
message: None,
priority: None,
retries: None,
escalate_after: None,
threshold: None,
include_summary: false,
merge_method: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReactionOutcome {
pub reaction_type: String,
pub success: bool,
pub action: ReactionAction,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
pub escalated: bool,
}
fn default_auto() -> bool {
true
}
fn is_true(b: &bool) -> bool {
*b
}
fn is_false(b: &bool) -> bool {
!*b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reaction_action_uses_kebab_case() {
assert_eq!(
serde_yaml::to_string(&ReactionAction::SendToAgent)
.unwrap()
.trim(),
"send-to-agent"
);
assert_eq!(
serde_yaml::to_string(&ReactionAction::AutoMerge)
.unwrap()
.trim(),
"auto-merge"
);
let parsed: ReactionAction = serde_yaml::from_str("notify").unwrap();
assert_eq!(parsed, ReactionAction::Notify);
}
#[test]
fn event_priority_uses_snake_case() {
let yaml = serde_yaml::to_string(&EventPriority::Urgent).unwrap();
assert_eq!(yaml.trim(), "urgent");
let parsed: EventPriority = serde_yaml::from_str("warning").unwrap();
assert_eq!(parsed, EventPriority::Warning);
}
#[test]
fn default_priority_for_reaction_key_matches_supported_keys() {
assert_eq!(
default_priority_for_reaction_key("ci-failed"),
EventPriority::Warning
);
assert_eq!(
default_priority_for_reaction_key("changes-requested"),
EventPriority::Info
);
assert_eq!(
default_priority_for_reaction_key("merge-conflicts"),
EventPriority::Warning
);
assert_eq!(
default_priority_for_reaction_key("approved-and-green"),
EventPriority::Action
);
assert_eq!(
default_priority_for_reaction_key("agent-idle"),
EventPriority::Info
);
assert_eq!(
default_priority_for_reaction_key("agent-stuck"),
EventPriority::Urgent
);
assert_eq!(
default_priority_for_reaction_key("agent-needs-input"),
EventPriority::Urgent
);
assert_eq!(
default_priority_for_reaction_key("agent-exited"),
EventPriority::Urgent
);
assert_eq!(
default_priority_for_reaction_key("all-complete"),
EventPriority::Info
);
assert_eq!(
default_priority_for_reaction_key("not-a-real-key"),
EventPriority::Warning
);
}
#[test]
fn escalate_after_number_parses_as_attempts() {
let parsed: EscalateAfter = serde_yaml::from_str("3").unwrap();
assert_eq!(parsed, EscalateAfter::Attempts(3));
}
#[test]
fn escalate_after_string_parses_as_duration() {
let parsed: EscalateAfter = serde_yaml::from_str("10m").unwrap();
assert_eq!(parsed, EscalateAfter::Duration("10m".into()));
}
#[test]
fn escalate_after_attempts_roundtrips() {
let e = EscalateAfter::Attempts(5);
let yaml = serde_yaml::to_string(&e).unwrap();
assert_eq!(yaml.trim(), "5");
let back: EscalateAfter = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(e, back);
}
#[test]
fn reaction_config_minimal_config_deserializes() {
let yaml = "action: notify\n";
let cfg: ReactionConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(cfg.action, ReactionAction::Notify);
assert!(cfg.auto); assert_eq!(cfg.retries, None);
assert!(!cfg.include_summary);
}
#[test]
fn reaction_config_full_config_roundtrips() {
let yaml = r#"
auto: true
action: send-to-agent
message: "CI broke — logs attached, please fix."
priority: action
retries: 3
escalate_after: 3
threshold: 5m
include_summary: true
"#;
let cfg: ReactionConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(cfg.action, ReactionAction::SendToAgent);
assert_eq!(cfg.priority, Some(EventPriority::Action));
assert_eq!(cfg.retries, Some(3));
assert_eq!(cfg.escalate_after, Some(EscalateAfter::Attempts(3)));
assert_eq!(cfg.threshold.as_deref(), Some("5m"));
assert!(cfg.include_summary);
let back: ReactionConfig =
serde_yaml::from_str(&serde_yaml::to_string(&cfg).unwrap()).unwrap();
assert_eq!(cfg, back);
}
#[test]
fn reaction_config_accepts_hyphenated_escalate_after_key() {
let yaml = "action: notify\nescalate-after: 10m\n";
let cfg: ReactionConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(
cfg.escalate_after,
Some(EscalateAfter::Duration("10m".into()))
);
}
#[test]
fn reaction_config_canonicalizes_escalate_after_on_write() {
let yaml_in = "action: notify\nescalate-after: 10m\n";
let cfg: ReactionConfig = serde_yaml::from_str(yaml_in).unwrap();
let yaml_out = serde_yaml::to_string(&cfg).unwrap();
assert!(
yaml_out.contains("escalate_after:"),
"expected canonical snake_case key in output, got:\n{yaml_out}"
);
assert!(
!yaml_out.contains("escalate-after:"),
"expected no kebab-case key in output, got:\n{yaml_out}"
);
}
#[test]
fn reaction_config_auto_true_is_omitted_on_write() {
let cfg = ReactionConfig::new(ReactionAction::Notify);
let yaml = serde_yaml::to_string(&cfg).unwrap();
assert!(
!yaml.contains("auto:"),
"auto:true should be omitted, got:\n{yaml}"
);
assert!(
!yaml.contains("include_summary"),
"include_summary:false should be omitted, got:\n{yaml}"
);
let mut off = cfg;
off.auto = false;
let yaml = serde_yaml::to_string(&off).unwrap();
assert!(
yaml.contains("auto: false"),
"auto:false must survive, got:\n{yaml}"
);
}
#[test]
fn escalate_after_duration_preserves_whitespace_verbatim() {
let parsed: EscalateAfter = serde_yaml::from_str(r#""3 ""#).unwrap();
assert_eq!(parsed, EscalateAfter::Duration("3 ".into()));
}
#[test]
fn reaction_config_new_is_minimal() {
let c = ReactionConfig::new(ReactionAction::AutoMerge);
assert!(c.auto);
assert_eq!(c.action, ReactionAction::AutoMerge);
assert!(c.message.is_none());
assert!(c.retries.is_none());
}
#[test]
fn reaction_outcome_escalated_roundtrips() {
let o = ReactionOutcome {
reaction_type: "ci-failed".into(),
success: true,
action: ReactionAction::Notify,
message: Some("escalated after 3 attempts".into()),
escalated: true,
};
let back: ReactionOutcome =
serde_yaml::from_str(&serde_yaml::to_string(&o).unwrap()).unwrap();
assert_eq!(o, back);
}
}