use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum LoopLevel {
L1Report,
L2Assisted,
L3Unattended,
}
impl LoopLevel {
pub fn maker_may_write(self) -> bool {
!matches!(self, LoopLevel::L1Report)
}
pub fn label(self) -> &'static str {
match self {
LoopLevel::L1Report => "L1-report",
LoopLevel::L2Assisted => "L2-assisted",
LoopLevel::L3Unattended => "L3-unattended",
}
}
}
#[derive(Debug, Clone)]
pub struct ProposedAction {
pub kind: String,
pub summary: String,
pub verified: bool,
}
impl ProposedAction {
pub fn new(kind: impl Into<String>, summary: impl Into<String>, verified: bool) -> Self {
Self {
kind: kind.into(),
summary: summary.into(),
verified,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GateDecision {
AutoProceed,
Escalate { reason: String },
}
impl GateDecision {
pub fn escalate(reason: impl Into<String>) -> Self {
GateDecision::Escalate {
reason: reason.into(),
}
}
pub fn is_auto(&self) -> bool {
matches!(self, GateDecision::AutoProceed)
}
}
pub trait HumanGate: Send + Sync {
fn decide(&self, level: LoopLevel, action: &ProposedAction) -> GateDecision;
}
pub struct AlwaysEscalate;
impl HumanGate for AlwaysEscalate {
fn decide(&self, _level: LoopLevel, _action: &ProposedAction) -> GateDecision {
GateDecision::escalate("report-only / human review required")
}
}
pub struct AllowlistGate {
allow_kinds: Vec<String>,
}
impl AllowlistGate {
pub fn new<I, S>(kinds: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
allow_kinds: kinds.into_iter().map(Into::into).collect(),
}
}
}
impl HumanGate for AllowlistGate {
fn decide(&self, level: LoopLevel, action: &ProposedAction) -> GateDecision {
if level != LoopLevel::L3Unattended {
return GateDecision::escalate("allowlist gate only auto-proceeds at L3");
}
if !action.verified {
return GateDecision::escalate("checker did not verify the work");
}
if self.allow_kinds.iter().any(|k| k == &action.kind) {
GateDecision::AutoProceed
} else {
GateDecision::escalate(format!("action kind `{}` is not allowlisted", action.kind))
}
}
}
pub struct CallbackGate<F>(pub F)
where
F: Fn(LoopLevel, &ProposedAction) -> GateDecision + Send + Sync;
impl<F> HumanGate for CallbackGate<F>
where
F: Fn(LoopLevel, &ProposedAction) -> GateDecision + Send + Sync,
{
fn decide(&self, level: LoopLevel, action: &ProposedAction) -> GateDecision {
(self.0)(level, action)
}
}
pub fn default_gate_for(_level: LoopLevel) -> Arc<dyn HumanGate> {
Arc::new(AlwaysEscalate)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn l1_is_read_only() {
assert!(!LoopLevel::L1Report.maker_may_write());
assert!(LoopLevel::L2Assisted.maker_may_write());
assert!(LoopLevel::L3Unattended.maker_may_write());
}
#[test]
fn always_escalate_never_proceeds() {
let g = AlwaysEscalate;
let a = ProposedAction::new("commit", "x", true);
assert!(!g.decide(LoopLevel::L3Unattended, &a).is_auto());
}
#[test]
fn allowlist_gate_only_auto_at_l3_for_verified_allowlisted() {
let g = AllowlistGate::new(["comment", "commit"]);
assert!(
g.decide(
LoopLevel::L3Unattended,
&ProposedAction::new("commit", "s", true)
)
.is_auto()
);
assert!(
!g.decide(
LoopLevel::L3Unattended,
&ProposedAction::new("force-push", "s", true)
)
.is_auto()
);
assert!(
!g.decide(
LoopLevel::L3Unattended,
&ProposedAction::new("commit", "s", false)
)
.is_auto()
);
assert!(
!g.decide(
LoopLevel::L2Assisted,
&ProposedAction::new("commit", "s", true)
)
.is_auto()
);
}
#[test]
fn callback_gate_runs_closure() {
let g = CallbackGate(|_lvl, a: &ProposedAction| {
if a.kind == "ok" {
GateDecision::AutoProceed
} else {
GateDecision::escalate("nope")
}
});
assert!(
g.decide(
LoopLevel::L3Unattended,
&ProposedAction::new("ok", "", true)
)
.is_auto()
);
}
}