use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use super::forge::{CheckState, GitForge, MergeMethod, PullRequest, RepoRef};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum MergeDecision {
Approve {
method: MergeMethod,
},
Wait {
reason: String,
},
Reject {
reason: String,
},
}
#[derive(Debug, Clone)]
pub struct MergeContext {
pub confidence: f64,
pub diff_lines: u32,
pub files_modified: usize,
}
#[async_trait]
pub trait MergePolicy: Send + Sync {
async fn evaluate(&self, pr: &PullRequest, ctx: &MergeContext) -> MergeDecision;
}
pub struct RequireApprovalPolicy;
#[async_trait]
impl MergePolicy for RequireApprovalPolicy {
async fn evaluate(&self, _pr: &PullRequest, _ctx: &MergeContext) -> MergeDecision {
MergeDecision::Wait {
reason: "Requires human approval".to_string(),
}
}
}
pub struct CiPassPolicy {
forge: std::sync::Arc<dyn GitForge>,
merge_method: MergeMethod,
}
impl CiPassPolicy {
pub fn new(forge: std::sync::Arc<dyn GitForge>, merge_method: MergeMethod) -> Self {
Self {
forge,
merge_method,
}
}
}
#[async_trait]
impl MergePolicy for CiPassPolicy {
async fn evaluate(&self, pr: &PullRequest, _ctx: &MergeContext) -> MergeDecision {
let repo = RepoRef {
owner: String::new(), name: String::new(),
};
match self.forge.get_check_status(&repo, pr.number).await {
Ok(status) => match status.state {
CheckState::Success => MergeDecision::Approve {
method: self.merge_method,
},
CheckState::Pending => MergeDecision::Wait {
reason: "CI checks still running".to_string(),
},
CheckState::Failure => MergeDecision::Reject {
reason: "CI checks failed".to_string(),
},
CheckState::Error => MergeDecision::Reject {
reason: "CI checks errored".to_string(),
},
},
Err(e) => MergeDecision::Wait {
reason: format!("Failed to fetch check status: {e}"),
},
}
}
}
pub struct ConfidenceBasedPolicy {
min_confidence: f64,
merge_method: MergeMethod,
}
impl ConfidenceBasedPolicy {
pub fn new(min_confidence: f64, merge_method: MergeMethod) -> Self {
Self {
min_confidence,
merge_method,
}
}
}
#[async_trait]
impl MergePolicy for ConfidenceBasedPolicy {
async fn evaluate(&self, _pr: &PullRequest, ctx: &MergeContext) -> MergeDecision {
if ctx.confidence < self.min_confidence {
return MergeDecision::Wait {
reason: format!(
"Confidence {:.1}% below threshold {:.1}%",
ctx.confidence * 100.0,
self.min_confidence * 100.0
),
};
}
MergeDecision::Approve {
method: self.merge_method,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn dummy_pr() -> PullRequest {
PullRequest {
id: "pr-1".to_string(),
number: 1,
title: "test PR".to_string(),
body: String::new(),
head_branch: "fix/test".to_string(),
base_branch: "main".to_string(),
url: String::new(),
state: super::super::forge::PrState::Open,
}
}
fn ctx(confidence: f64) -> MergeContext {
MergeContext {
confidence,
diff_lines: 10,
files_modified: 1,
}
}
#[tokio::test]
async fn require_approval_always_waits() {
let policy = RequireApprovalPolicy;
let decision = policy.evaluate(&dummy_pr(), &ctx(1.0)).await;
assert!(matches!(decision, MergeDecision::Wait { .. }));
}
#[tokio::test]
async fn require_approval_wait_message_is_human_approval() {
let policy = RequireApprovalPolicy;
let decision = policy.evaluate(&dummy_pr(), &ctx(0.0)).await;
match decision {
MergeDecision::Wait { reason } => {
assert!(reason.contains("human approval") || !reason.is_empty());
}
_ => panic!("expected Wait"),
}
}
#[tokio::test]
async fn confidence_policy_approves_above_threshold() {
let policy = ConfidenceBasedPolicy::new(0.8, MergeMethod::Squash);
let decision = policy.evaluate(&dummy_pr(), &ctx(0.9)).await;
assert!(matches!(
decision,
MergeDecision::Approve {
method: MergeMethod::Squash
}
));
}
#[tokio::test]
async fn confidence_policy_waits_below_threshold() {
let policy = ConfidenceBasedPolicy::new(0.8, MergeMethod::Merge);
let decision = policy.evaluate(&dummy_pr(), &ctx(0.7)).await;
assert!(matches!(decision, MergeDecision::Wait { .. }));
}
#[tokio::test]
async fn confidence_policy_approves_at_exact_threshold() {
let policy = ConfidenceBasedPolicy::new(0.8, MergeMethod::Rebase);
let decision = policy.evaluate(&dummy_pr(), &ctx(0.8)).await;
assert!(matches!(decision, MergeDecision::Approve { .. }));
}
#[tokio::test]
async fn confidence_policy_wait_message_contains_percentages() {
let policy = ConfidenceBasedPolicy::new(0.8, MergeMethod::Merge);
let decision = policy.evaluate(&dummy_pr(), &ctx(0.5)).await;
match decision {
MergeDecision::Wait { reason } => {
assert!(reason.contains('%'), "reason should contain %: {reason}");
}
_ => panic!("expected Wait"),
}
}
#[test]
fn merge_decision_approve_serde_roundtrip() {
let d = MergeDecision::Approve {
method: MergeMethod::Squash,
};
let json = serde_json::to_string(&d).unwrap();
let back: MergeDecision = serde_json::from_str(&json).unwrap();
assert!(matches!(
back,
MergeDecision::Approve {
method: MergeMethod::Squash
}
));
}
#[test]
fn merge_decision_wait_serde_roundtrip() {
let d = MergeDecision::Wait {
reason: "needs review".to_string(),
};
let json = serde_json::to_string(&d).unwrap();
let back: MergeDecision = serde_json::from_str(&json).unwrap();
match back {
MergeDecision::Wait { reason } => assert_eq!(reason, "needs review"),
_ => panic!("wrong variant"),
}
}
#[test]
fn merge_context_fields_accessible() {
let ctx = MergeContext {
confidence: 0.95,
diff_lines: 42,
files_modified: 3,
};
assert_eq!(ctx.confidence, 0.95);
assert_eq!(ctx.diff_lines, 42);
assert_eq!(ctx.files_modified, 3);
}
}