use std::collections::{HashMap, HashSet};
use std::time::{Duration, Instant};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::pr_review::{
self, AutoMergeCriteria, AutoMergeDecision, PrMetadata, ReviewVerdict, VerdictParseError,
};
pub const PR_REVIEWER_LOGIN: &str = "pr-reviewer";
pub const PR_POLL_MIN_INTERVAL: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrSummary {
pub number: u64,
pub author_login: String,
pub head_sha: String,
pub base_ref: String,
pub diff_loc: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrComment {
pub id: u64,
pub user_login: String,
pub body: String,
pub updated_at: String,
}
#[async_trait]
pub trait PrTracker: Send + Sync {
async fn list_open_prs(&self) -> Result<Vec<PrSummary>, String>;
async fn fetch_pr_comments(&self, pr_number: u64) -> Result<Vec<PrComment>, String>;
async fn list_head_commit_statuses(
&self,
_pr_number: u64,
_head_sha: &str,
) -> Result<Vec<crate::pr_gate::CommitStatusSummary>, String> {
Ok(Vec::new())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MergeOutcome {
pub pr_number: u64,
pub merge_commit_sha: String,
pub title: String,
}
#[async_trait]
pub trait AutoMergeExecutor: PrTracker {
async fn merge_pr(&self, pr_number: u64) -> Result<MergeOutcome, String>;
async fn open_failure_issue(
&self,
title: &str,
body: &str,
labels: &[&str],
) -> Result<u64, String>;
}
pub struct GiteaPrTracker {
inner: terraphim_tracker::GiteaTracker,
}
impl GiteaPrTracker {
pub fn new(inner: terraphim_tracker::GiteaTracker) -> Self {
Self { inner }
}
}
#[async_trait]
impl PrTracker for GiteaPrTracker {
async fn list_open_prs(&self) -> Result<Vec<PrSummary>, String> {
self.inner
.list_open_prs()
.await
.map(|v| {
v.into_iter()
.map(|p| PrSummary {
number: p.number,
author_login: p.author_login,
head_sha: p.head_sha,
base_ref: p.base_ref,
diff_loc: p.diff_loc,
})
.collect()
})
.map_err(|e| e.to_string())
}
async fn fetch_pr_comments(&self, pr_number: u64) -> Result<Vec<PrComment>, String> {
self.inner
.fetch_comments(pr_number, None)
.await
.map(|v| {
v.into_iter()
.map(|c| PrComment {
id: c.id,
user_login: c.user.login,
body: c.body,
updated_at: c.updated_at,
})
.collect()
})
.map_err(|e| e.to_string())
}
async fn list_head_commit_statuses(
&self,
_pr_number: u64,
head_sha: &str,
) -> Result<Vec<crate::pr_gate::CommitStatusSummary>, String> {
let owner = self.inner.owner().to_string();
let repo = self.inner.repo().to_string();
self.inner
.list_commit_statuses(&owner, &repo, head_sha)
.await
.map(|v| {
v.into_iter()
.map(|s| crate::pr_gate::CommitStatusSummary {
context: s.context,
state: crate::pr_gate::CommitStatusState::from_api_str(&s.state),
created_at_unix: parse_rfc3339_to_unix(s.created_at.as_deref()),
})
.collect()
})
.map_err(|e| e.to_string())
}
}
#[async_trait]
impl AutoMergeExecutor for GiteaPrTracker {
async fn merge_pr(&self, pr_number: u64) -> Result<MergeOutcome, String> {
self.inner
.merge_pull(pr_number, terraphim_tracker::MergeStyle::Merge, true)
.await
.map(|r| MergeOutcome {
pr_number: r.pr_number,
merge_commit_sha: r.merge_commit_sha,
title: r.title,
})
.map_err(|e| e.to_string())
}
async fn open_failure_issue(
&self,
title: &str,
body: &str,
labels: &[&str],
) -> Result<u64, String> {
self.inner
.create_issue(title, body, labels)
.await
.map(|i| i.number)
.map_err(|e| e.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EvaluationOutcome {
Merge { head_sha: String },
HumanReviewNeeded { reason: String },
Remediate {
head_sha: String,
reason: String,
verdict_excerpt: String,
confidence: u8,
},
NoReviewerComment,
ParseError { reason: String },
StaleReview { reviewed: String, head: String },
}
pub fn is_pr_reviewer_comment(comment: &PrComment) -> bool {
if comment.user_login == PR_REVIEWER_LOGIN {
return true;
}
comment.body.contains("Last reviewed commit:") && !is_non_reviewer_agent_comment(&comment.body)
}
const NON_REVIEWER_HEADING_PREFIXES: &[&str] = &[
"security_checklist Summary",
"Security Audit Summary",
"Requirements Traceability Summary",
"Quality Gate Report",
];
fn is_non_reviewer_agent_comment(body: &str) -> bool {
let trimmed = body.trim();
for prefix in NON_REVIEWER_HEADING_PREFIXES {
if trimmed.starts_with(prefix) {
return true;
}
}
false
}
pub fn latest_reviewer_comment(comments: &[PrComment]) -> Option<&PrComment> {
comments
.iter()
.filter(|c| is_pr_reviewer_comment(c))
.max_by(|a, b| {
a.updated_at
.cmp(&b.updated_at)
.then_with(|| a.id.cmp(&b.id))
})
}
pub fn evaluate_pr_verdict(
pr: &PrSummary,
comments: &[PrComment],
head_statuses: &[crate::pr_gate::CommitStatusSummary],
criteria: &AutoMergeCriteria,
required_contexts: &[String],
) -> EvaluationOutcome {
use crate::pr_gate::{self, PrGateDecision, PrGateSnapshot};
let snapshot = PrGateSnapshot {
pr_number: pr.number,
head_sha: pr.head_sha.clone(),
base_branch: pr.base_ref.clone(),
required_contexts: required_contexts.to_vec(),
head_statuses: head_statuses.to_vec(),
now_unix: 0,
};
match pr_gate::reconcile_pr_gate(&snapshot) {
PrGateDecision::ReadyForPolicy => { }
PrGateDecision::EnqueueMissingChecks { missing } => {
return EvaluationOutcome::HumanReviewNeeded {
reason: format!(
"required status(es) not posted on head: {}",
missing.join(", ")
),
};
}
PrGateDecision::AwaitingChecks { pending } => {
return EvaluationOutcome::HumanReviewNeeded {
reason: format!(
"required status(es) still pending on head: {}",
pending.join(", ")
),
};
}
PrGateDecision::BlockedByFailedChecks { failed } => {
return EvaluationOutcome::HumanReviewNeeded {
reason: format!(
"required status(es) failed on head: {}",
failed
.iter()
.map(|(c, s)| format!("{c}={s}"))
.collect::<Vec<_>>()
.join(", ")
),
};
}
PrGateDecision::FactoryFault { error } => {
return EvaluationOutcome::HumanReviewNeeded {
reason: format!("status-gate fault: {error}"),
};
}
}
let Some(latest) = latest_reviewer_comment(comments) else {
return EvaluationOutcome::NoReviewerComment;
};
let verdict: ReviewVerdict = match pr_review::parse_verdict(&latest.body, latest.id) {
Ok(v) => v,
Err(e) => {
return EvaluationOutcome::ParseError {
reason: describe_parse_error(e),
};
}
};
if !head_matches_footer(&pr.head_sha, &verdict.commit_short_hash) {
return EvaluationOutcome::StaleReview {
reviewed: verdict.commit_short_hash.clone(),
head: pr.head_sha.clone(),
};
}
let metadata = PrMetadata {
pr_number: pr.number,
author_login: pr.author_login.clone(),
diff_loc: pr.diff_loc,
head_sha: pr.head_sha.clone(),
base_branch: pr.base_ref.clone(),
};
match pr_review::evaluate(&verdict, &metadata, criteria) {
AutoMergeDecision::Merge => EvaluationOutcome::Merge {
head_sha: pr.head_sha.clone(),
},
AutoMergeDecision::HumanReviewNeeded(reason) => {
let remediable = verdict.confidence < criteria.min_confidence && verdict.p0_count == 0;
if remediable {
EvaluationOutcome::Remediate {
head_sha: pr.head_sha.clone(),
reason,
verdict_excerpt: findings_excerpt(&latest.body),
confidence: verdict.confidence,
}
} else {
EvaluationOutcome::HumanReviewNeeded { reason }
}
}
}
}
const VERDICT_EXCERPT_CAP: usize = 16 * 1024;
fn findings_excerpt(body: &str) -> String {
const HEADINGS: &[&str] = &[
"<h3>Inline Findings</h3>",
"<h3>Findings</h3>",
"### Inline Findings",
"### Findings",
];
let start = HEADINGS
.iter()
.filter_map(|h| body.find(h))
.min()
.unwrap_or(0);
let mut slice = &body[start..];
if let Some(footer_at) = slice.find("Last reviewed commit:") {
let cut = slice[..footer_at].rfind("<sub>").unwrap_or(footer_at);
slice = slice[..cut].trim_end();
}
if slice.len() <= VERDICT_EXCERPT_CAP {
return slice.to_string();
}
let mut end = VERDICT_EXCERPT_CAP;
while end > 0 && !slice.is_char_boundary(end) {
end -= 1;
}
slice[..end].to_string()
}
pub fn head_matches_footer(head: &str, footer: &str) -> bool {
let f = footer.trim();
!f.is_empty()
&& head
.to_ascii_lowercase()
.starts_with(&f.to_ascii_lowercase())
}
pub fn sha_prefix_matches(footer_short: &str, head: &str) -> bool {
let a = footer_short.trim().to_lowercase();
let b = head.trim().to_lowercase();
if a.len() < 7 || b.len() < 7 {
return false;
}
let (short, long) = if a.len() <= b.len() {
(&a, &b)
} else {
(&b, &a)
};
long.starts_with(short.as_str())
}
pub fn parse_rfc3339_to_unix(created_at: Option<&str>) -> Option<i64> {
let raw = created_at?;
chrono::DateTime::parse_from_rfc3339(raw)
.ok()
.map(|dt| dt.timestamp())
}
fn describe_parse_error(err: VerdictParseError) -> String {
match err {
VerdictParseError::MissingConfidence => "missing confidence score header".to_string(),
VerdictParseError::ConfidenceOutOfRange(n) => {
format!("confidence {n}/5 out of range (expected 1..=5)")
}
VerdictParseError::MissingFindings => "missing Inline Findings section".to_string(),
VerdictParseError::MalformedFooter => {
"malformed `Last reviewed commit:` footer".to_string()
}
}
}
#[derive(Debug, Default)]
pub struct PrPollRateLimiter {
last_poll: HashMap<(String, u64), Instant>,
min_interval: Duration,
}
impl PrPollRateLimiter {
pub fn new(min_interval: Duration) -> Self {
Self {
last_poll: HashMap::new(),
min_interval,
}
}
pub fn allow(&mut self, project: &str, pr_number: u64, now: Instant) -> bool {
let key = (project.to_string(), pr_number);
if let Some(prev) = self.last_poll.get(&key)
&& now.duration_since(*prev) < self.min_interval
{
return false;
}
self.last_poll.insert(key, now);
true
}
}
#[derive(Debug, Default)]
pub struct AutoMergeDedupeSet {
by_project: HashMap<String, HashSet<(u64, String)>>,
}
impl AutoMergeDedupeSet {
pub fn new() -> Self {
Self::default()
}
pub fn record_if_new(&mut self, project: &str, pr_number: u64, head_sha: &str) -> bool {
self.by_project
.entry(project.to_string())
.or_default()
.insert((pr_number, head_sha.to_string()))
}
pub fn contains(&self, project: &str, pr_number: u64, head_sha: &str) -> bool {
self.by_project
.get(project)
.is_some_and(|s| s.contains(&(pr_number, head_sha.to_string())))
}
pub fn snapshot_for(&self, project: &str) -> HashSet<(u64, String)> {
self.by_project.get(project).cloned().unwrap_or_default()
}
pub fn restore_for(&mut self, project: &str, set: HashSet<(u64, String)>) {
if set.is_empty() {
return;
}
self.by_project
.entry(project.to_string())
.or_default()
.extend(set);
}
}
#[derive(Debug, Default)]
pub struct RemediationAttempts {
by_project: HashMap<String, HashMap<u64, AttemptState>>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
struct AttemptState {
count: u32,
last_confidence: u8,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct PersistedAttempt {
pub count: u32,
pub last_confidence: u8,
}
impl RemediationAttempts {
pub fn new() -> Self {
Self::default()
}
pub fn count(&self, project: &str, pr: u64) -> u32 {
self.by_project
.get(project)
.and_then(|m| m.get(&pr))
.map_or(0, |s| s.count)
}
pub fn last_confidence(&self, project: &str, pr: u64) -> Option<u8> {
self.by_project
.get(project)
.and_then(|m| m.get(&pr))
.map(|s| s.last_confidence)
}
pub fn record(&mut self, project: &str, pr: u64, confidence: u8) {
let entry = self
.by_project
.entry(project.to_string())
.or_default()
.entry(pr)
.or_insert(AttemptState {
count: 0,
last_confidence: confidence,
});
entry.count += 1;
entry.last_confidence = confidence;
}
pub fn snapshot_for(&self, project: &str) -> HashMap<u64, PersistedAttempt> {
self.by_project
.get(project)
.map(|m| {
m.iter()
.map(|(pr, s)| {
(
*pr,
PersistedAttempt {
count: s.count,
last_confidence: s.last_confidence,
},
)
})
.collect()
})
.unwrap_or_default()
}
pub fn restore_for(&mut self, project: &str, map: HashMap<u64, PersistedAttempt>) {
if map.is_empty() {
return;
}
let entry = self.by_project.entry(project.to_string()).or_default();
for (pr, persisted) in map {
entry.insert(
pr,
AttemptState {
count: persisted.count,
last_confidence: persisted.last_confidence,
},
);
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RemediationState {
#[serde(default)]
pub dispatched: HashSet<(u64, String)>,
#[serde(default)]
pub attempts: HashMap<u64, PersistedAttempt>,
}
impl RemediationState {
const DISPATCHED_CAP: usize = 10_000;
async fn sqlite_op() -> Option<opendal::Operator> {
let storage = terraphim_persistence::DeviceStorage::instance()
.await
.ok()?;
let (op, _) = storage.ops.get("sqlite")?;
Some(op.clone())
}
fn state_key(project_id: &str) -> String {
format!("adf/remediation_state/{project_id}")
}
pub async fn load(project_id: &str) -> Self {
let key = Self::state_key(project_id);
if let Some(op) = Self::sqlite_op().await {
match op.read(&key).await {
Ok(bs) => match serde_json::from_slice::<Self>(&bs.to_vec()) {
Ok(state) => {
tracing::info!(
project = project_id,
dispatched = state.dispatched.len(),
attempts = state.attempts.len(),
"loaded RemediationState from persistence"
);
return state;
}
Err(e) => {
tracing::warn!(
project = project_id,
?e,
"failed to deserialise RemediationState; deleting and starting fresh"
);
let _ = op.delete(&key).await;
}
},
Err(_) => {
tracing::info!(
project = project_id,
"no persisted RemediationState found, starting fresh"
);
}
}
} else {
tracing::warn!(
project = project_id,
"DeviceStorage sqlite not available; remediation state not persisted"
);
}
Self::default()
}
pub async fn save(&self, project_id: &str) {
let key = Self::state_key(project_id);
if let Some(op) = Self::sqlite_op().await {
match serde_json::to_string(self) {
Ok(json) => {
if let Err(e) = op.write(&key, json).await {
tracing::warn!(project = project_id, ?e, "failed to save RemediationState");
} else {
tracing::debug!(
project = project_id,
dispatched = self.dispatched.len(),
attempts = self.attempts.len(),
"saved RemediationState"
);
}
}
Err(e) => {
tracing::warn!(
project = project_id,
?e,
"failed to serialise RemediationState"
);
}
}
} else {
tracing::warn!(
project = project_id,
"DeviceStorage sqlite not available; remediation state not persisted"
);
}
}
pub fn cap(&mut self) {
if self.dispatched.len() > Self::DISPATCHED_CAP {
let to_remove: Vec<(u64, String)> = self
.dispatched
.iter()
.take(Self::DISPATCHED_CAP / 2)
.cloned()
.collect();
for k in to_remove {
self.dispatched.remove(&k);
}
}
}
}
#[derive(Debug)]
pub struct AutoMergeFailureDedupe {
entries: HashMap<(String, u64, String), Instant>,
ttl: Duration,
}
impl AutoMergeFailureDedupe {
pub fn new(ttl: Duration) -> Self {
Self {
entries: HashMap::new(),
ttl,
}
}
pub fn is_recent(&mut self, project: &str, pr_number: u64, head_sha: &str) -> bool {
self.purge_expired();
let key = (project.to_string(), pr_number, head_sha.to_string());
self.entries.contains_key(&key)
}
pub fn record(&mut self, project: &str, pr_number: u64, head_sha: &str) {
let key = (project.to_string(), pr_number, head_sha.to_string());
self.entries.insert(key, Instant::now());
}
fn purge_expired(&mut self) {
let now = Instant::now();
self.entries
.retain(|_, created| now.duration_since(*created) < self.ttl);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn comment(id: u64, user: &str, body: &str, updated_at: &str) -> PrComment {
PrComment {
id,
user_login: user.to_string(),
body: body.to_string(),
updated_at: updated_at.to_string(),
}
}
fn pr(number: u64, author: &str, head: &str, diff_loc: u32) -> PrSummary {
PrSummary {
number,
author_login: author.to_string(),
head_sha: head.to_string(),
base_ref: "main".to_string(),
diff_loc,
}
}
#[test]
fn is_pr_reviewer_comment_matches_login() {
let c = comment(1, PR_REVIEWER_LOGIN, "hello", "2026-01-01T00:00:00Z");
assert!(is_pr_reviewer_comment(&c));
}
#[test]
fn is_pr_reviewer_comment_matches_footer_fallback() {
let c = comment(
1,
"random-user",
"body\n<sub>Last reviewed commit: abc123</sub>",
"2026-01-01T00:00:00Z",
);
assert!(is_pr_reviewer_comment(&c));
}
#[test]
fn is_pr_reviewer_comment_rejects_security_checklist() {
let c = comment(
1,
"random-user",
"security_checklist Summary\n<sub>Last reviewed commit: abc123</sub>",
"2026-01-01T00:00:00Z",
);
assert!(!is_pr_reviewer_comment(&c));
}
#[test]
fn is_pr_reviewer_comment_rejects_security_audit() {
let c = comment(
1,
"random-user",
"Security Audit Summary\n<sub>Last reviewed commit: abc123</sub>",
"2026-01-01T00:00:00Z",
);
assert!(!is_pr_reviewer_comment(&c));
}
#[test]
fn is_pr_reviewer_comment_rejects_requirements_traceability() {
let c = comment(
1,
"random-user",
"Requirements Traceability Summary\n<sub>Last reviewed commit: abc123</sub>",
"2026-01-01T00:00:00Z",
);
assert!(!is_pr_reviewer_comment(&c));
}
#[test]
fn is_pr_reviewer_comment_rejects_quality_gate_report() {
let c = comment(
1,
"random-user",
"Quality Gate Report\n<sub>Last reviewed commit: abc123</sub>",
"2026-01-01T00:00:00Z",
);
assert!(!is_pr_reviewer_comment(&c));
}
#[test]
fn pr_reviewer_login_overrides_non_reviewer_heading() {
let c = comment(
1,
PR_REVIEWER_LOGIN,
"security_checklist Summary\n<sub>Last reviewed commit: abc123</sub>",
"2026-01-01T00:00:00Z",
);
assert!(is_pr_reviewer_comment(&c));
}
#[test]
fn latest_reviewer_comment_picks_max_updated_at() {
let comments = vec![
comment(1, PR_REVIEWER_LOGIN, "first", "2026-01-01T00:00:00Z"),
comment(2, PR_REVIEWER_LOGIN, "second", "2026-01-02T00:00:00Z"),
comment(3, "human", "noise", "2026-01-03T00:00:00Z"),
];
let latest = latest_reviewer_comment(&comments).unwrap();
assert_eq!(latest.id, 2);
}
#[test]
fn evaluate_verdict_returns_no_reviewer_comment_when_empty() {
let p = pr(1, "claude-code", "abc", 10);
let out = evaluate_pr_verdict(&p, &[], &[], &AutoMergeCriteria::default(), &[]);
assert_eq!(out, EvaluationOutcome::NoReviewerComment);
}
#[test]
fn evaluate_verdict_returns_parse_error_on_malformed_body() {
let p = pr(1, "claude-code", "abc", 10);
let c = comment(7, PR_REVIEWER_LOGIN, "garbage", "2026-01-01T00:00:00Z");
let out = evaluate_pr_verdict(&p, &[c], &[], &AutoMergeCriteria::default(), &[]);
assert!(matches!(out, EvaluationOutcome::ParseError { .. }));
}
#[test]
fn evaluate_pr_verdict_status_layer_blocks_before_parse() {
let p = pr(1, "claude-code", "2ef451d8aabbcc", 10);
let required = vec!["adf/test".to_string()];
let out = evaluate_pr_verdict(&p, &[], &[], &AutoMergeCriteria::default(), &required);
match out {
EvaluationOutcome::HumanReviewNeeded { reason } => {
assert!(reason.contains("adf/test"), "reason was: {reason}");
assert!(reason.contains("not posted"), "reason was: {reason}");
}
other => panic!("expected HumanReviewNeeded, got {other:?}"),
}
}
#[test]
fn sha_prefix_matches_short_is_prefix_of_long() {
assert!(sha_prefix_matches(
"2ef451d8",
"2ef451d8aabbccddeeff00112233445566778899"
));
}
#[test]
fn sha_prefix_matches_long_is_anchor_for_short() {
assert!(sha_prefix_matches(
"2ef451d8aabbccddeeff00112233445566778899",
"2ef451d8"
));
}
#[test]
fn sha_prefix_matches_equal_short_shas() {
assert!(sha_prefix_matches("2ef451d8", "2ef451d8"));
}
#[test]
fn sha_prefix_matches_case_insensitive() {
assert!(sha_prefix_matches("2EF451D8", "2ef451d8aabbcc"));
}
#[test]
fn sha_prefix_matches_rejects_mismatch() {
assert!(!sha_prefix_matches("2ef451d8", "62672e38aabbcc"));
}
#[test]
fn sha_prefix_matches_rejects_too_short() {
assert!(!sha_prefix_matches("2ef451", "2ef451d8aabbcc"));
assert!(!sha_prefix_matches("2ef451d8", "2ef451"));
}
#[test]
fn sha_prefix_matches_rejects_empty() {
assert!(!sha_prefix_matches("", "2ef451d8aabbcc"));
assert!(!sha_prefix_matches("2ef451d8", ""));
}
#[test]
fn head_matches_footer_prefix_match() {
assert!(head_matches_footer(
"abc123def4567890aabbccddeeff00112233445566",
"abc123"
));
}
#[test]
fn head_matches_footer_case_insensitive() {
assert!(head_matches_footer(
"ABC123def4567890aabbccddeeff00112233445566",
"abc123"
));
}
#[test]
fn head_matches_footer_empty_footer_never_matches() {
assert!(!head_matches_footer("abc123def4567890", ""));
assert!(!head_matches_footer("abc123def4567890", " "));
}
#[test]
fn head_matches_footer_rejects_mismatch() {
assert!(!head_matches_footer("abc123def4567890", "def999"));
}
#[test]
fn parse_rfc3339_to_unix_parses_valid() {
assert_eq!(
parse_rfc3339_to_unix(Some("2026-03-31T12:00:00Z")),
Some(1_774_958_400)
);
}
#[test]
fn parse_rfc3339_to_unix_handles_offset() {
assert_eq!(
parse_rfc3339_to_unix(Some("2026-03-31T14:00:00+02:00")),
Some(1_774_958_400)
);
}
#[test]
fn parse_rfc3339_to_unix_none_and_garbage() {
assert_eq!(parse_rfc3339_to_unix(None), None);
assert_eq!(parse_rfc3339_to_unix(Some("not-a-timestamp")), None);
}
#[test]
fn rate_limiter_blocks_inside_window() {
let mut rl = PrPollRateLimiter::new(Duration::from_secs(60));
let now = Instant::now();
assert!(rl.allow("p", 1, now));
assert!(!rl.allow("p", 1, now + Duration::from_secs(30)));
assert!(rl.allow("p", 1, now + Duration::from_secs(61)));
}
#[test]
fn rate_limiter_scopes_by_project_and_pr() {
let mut rl = PrPollRateLimiter::new(Duration::from_secs(60));
let now = Instant::now();
assert!(rl.allow("a", 1, now));
assert!(rl.allow("a", 2, now));
assert!(rl.allow("b", 1, now));
}
#[test]
fn dedupe_records_new_and_rejects_duplicates() {
let mut set = AutoMergeDedupeSet::new();
assert!(set.record_if_new("p", 1, "sha"));
assert!(!set.record_if_new("p", 1, "sha"));
assert!(set.record_if_new("p", 1, "sha2"));
assert!(set.record_if_new("q", 1, "sha"));
}
#[test]
fn failure_dedupe_blocks_duplicate_within_ttl() {
let mut cache = AutoMergeFailureDedupe::new(Duration::from_secs(300));
assert!(!cache.is_recent("p", 1, "sha"));
cache.record("p", 1, "sha");
assert!(cache.is_recent("p", 1, "sha"));
assert!(!cache.is_recent("p", 1, "sha2"));
assert!(!cache.is_recent("p", 2, "sha"));
assert!(!cache.is_recent("q", 1, "sha"));
}
#[test]
fn failure_dedupe_allows_recreate_after_ttl() {
let mut cache = AutoMergeFailureDedupe::new(Duration::from_millis(50));
cache.record("p", 1, "sha");
assert!(cache.is_recent("p", 1, "sha"));
std::thread::sleep(Duration::from_millis(60));
assert!(!cache.is_recent("p", 1, "sha"));
}
fn verdict_body(conf: u8, p0: usize, p1: usize) -> String {
let mut s = format!("<h3>Confidence Score: {conf}/5</h3>\n<h3>Inline Findings</h3>\n");
for i in 0..p0 {
s.push_str(&format!("**P0 finding {i} in src/x.rs, line 1**: detail\n"));
}
for i in 0..p1 {
s.push_str(&format!("**P1 finding {i} in src/x.rs, line 2**: detail\n"));
}
s.push_str("<sub>Last reviewed commit: 2ef451d8 | Reviews (1)</sub>");
s
}
#[test]
fn evaluate_verdict_conditional_below_threshold_no_p0_remediates() {
let p = pr(1, "claude-code", "2ef451d8aabbcc", 42);
let body = verdict_body(3, 0, 1);
let c = comment(1, PR_REVIEWER_LOGIN, &body, "2026-01-02T00:00:00Z");
let out = evaluate_pr_verdict(&p, &[c], &[], &AutoMergeCriteria::default(), &[]);
match out {
EvaluationOutcome::Remediate {
head_sha,
confidence,
verdict_excerpt,
..
} => {
assert_eq!(head_sha, "2ef451d8aabbcc");
assert_eq!(confidence, 3);
assert!(
verdict_excerpt.contains("Inline Findings"),
"excerpt must carry the verbatim findings section: {verdict_excerpt}"
);
assert!(verdict_excerpt.contains("**P1 finding 0"));
}
other => panic!("expected Remediate, got {other:?}"),
}
}
#[test]
fn evaluate_verdict_p0_present_below_threshold_is_human_review() {
let p = pr(2, "claude-code", "2ef451d8aabbcc", 42);
let body = verdict_body(2, 1, 0);
let c = comment(2, PR_REVIEWER_LOGIN, &body, "2026-01-02T00:00:00Z");
let out = evaluate_pr_verdict(&p, &[c], &[], &AutoMergeCriteria::default(), &[]);
assert!(
matches!(out, EvaluationOutcome::HumanReviewNeeded { .. }),
"P0-bearing verdict must escalate, not remediate: {out:?}"
);
}
#[test]
fn evaluate_verdict_at_threshold_still_merges_when_clean() {
let p = pr(3, "claude-code", "2ef451d8aabbcc", 42);
let body = verdict_body(5, 0, 0);
let c = comment(3, PR_REVIEWER_LOGIN, &body, "2026-01-02T00:00:00Z");
let out = evaluate_pr_verdict(&p, &[c], &[], &AutoMergeCriteria::default(), &[]);
assert!(
matches!(out, EvaluationOutcome::Merge { .. }),
"got {out:?}"
);
}
#[test]
fn evaluate_verdict_diff_over_cap_below_threshold_is_human_review() {
let p = pr(4, "claude-code", "2ef451d8aabbcc", 999);
let body = verdict_body(5, 0, 0);
let c = comment(4, PR_REVIEWER_LOGIN, &body, "2026-01-02T00:00:00Z");
let out = evaluate_pr_verdict(&p, &[c], &[], &AutoMergeCriteria::default(), &[]);
match out {
EvaluationOutcome::HumanReviewNeeded { reason } => {
assert!(reason.contains("diff size"), "got {reason}");
}
other => panic!("expected HumanReviewNeeded (diff cap), got {other:?}"),
}
}
#[test]
fn evaluate_verdict_non_agent_author_below_threshold_remediates_only_on_confidence() {
let p = pr(5, "alice-human", "2ef451d8aabbcc", 42);
let body = verdict_body(3, 0, 1);
let c = comment(5, PR_REVIEWER_LOGIN, &body, "2026-01-02T00:00:00Z");
let out = evaluate_pr_verdict(&p, &[c], &[], &AutoMergeCriteria::default(), &[]);
assert!(
matches!(out, EvaluationOutcome::Remediate { .. }),
"got {out:?}"
);
let body_clean = verdict_body(5, 0, 0);
let c2 = comment(6, PR_REVIEWER_LOGIN, &body_clean, "2026-01-02T00:00:00Z");
let out2 = evaluate_pr_verdict(&p, &[c2], &[], &AutoMergeCriteria::default(), &[]);
match out2 {
EvaluationOutcome::HumanReviewNeeded { reason } => {
assert!(reason.contains("not a recognised agent"), "got {reason}");
}
other => panic!("expected HumanReviewNeeded (author), got {other:?}"),
}
}
#[test]
fn evaluate_verdict_failed_status_below_threshold_is_human_review() {
use crate::pr_gate::{CommitStatusState, CommitStatusSummary};
let p = pr(7, "claude-code", "2ef451d8aabbcc", 42);
let body = verdict_body(3, 0, 1);
let c = comment(7, PR_REVIEWER_LOGIN, &body, "2026-01-02T00:00:00Z");
let statuses = vec![CommitStatusSummary {
context: "adf/test".to_string(),
state: CommitStatusState::Failure,
created_at_unix: None,
}];
let required = vec!["adf/test".to_string()];
let out = evaluate_pr_verdict(
&p,
&[c],
&statuses,
&AutoMergeCriteria::default(),
&required,
);
match out {
EvaluationOutcome::HumanReviewNeeded { reason } => {
assert!(reason.contains("failed"), "got {reason}");
}
other => panic!("expected HumanReviewNeeded (status), got {other:?}"),
}
}
#[test]
fn evaluate_verdict_stale_sha_below_threshold_is_stale_review() {
let p = pr(8, "claude-code", "deadbeef99aa11", 42);
let body = verdict_body(3, 0, 1); let c = comment(8, PR_REVIEWER_LOGIN, &body, "2026-01-02T00:00:00Z");
let out = evaluate_pr_verdict(&p, &[c], &[], &AutoMergeCriteria::default(), &[]);
match out {
EvaluationOutcome::StaleReview { reviewed, head } => {
assert_eq!(reviewed, "2ef451d8");
assert_eq!(head, "deadbeef99aa11");
}
other => panic!("expected StaleReview, got {other:?}"),
}
}
#[test]
fn findings_excerpt_slices_inline_findings_section() {
let body = "<h3>Summary</h3>\nblah\n<h3>Inline Findings</h3>\n**P1 thing**: detail\n<sub>Last reviewed commit: abc</sub>";
let ex = findings_excerpt(body);
assert!(ex.starts_with("<h3>Inline Findings</h3>"), "got: {ex}");
assert!(ex.contains("**P1 thing**"));
assert!(!ex.contains("<h3>Summary</h3>"));
}
#[test]
fn findings_excerpt_falls_back_to_whole_body_without_heading() {
let body = "no headings here, just text";
assert_eq!(findings_excerpt(body), body);
}
#[test]
fn findings_excerpt_caps_long_body_on_char_boundary() {
let body = "x".repeat(VERDICT_EXCERPT_CAP + 100);
let ex = findings_excerpt(&body);
assert_eq!(ex.len(), VERDICT_EXCERPT_CAP);
}
#[test]
fn remediation_attempts_count_starts_zero() {
let a = RemediationAttempts::new();
assert_eq!(a.count("p", 1), 0);
assert_eq!(a.last_confidence("p", 1), None);
}
#[test]
fn remediation_attempts_record_bumps_and_stores_confidence() {
let mut a = RemediationAttempts::new();
a.record("p", 1, 3);
assert_eq!(a.count("p", 1), 1);
assert_eq!(a.last_confidence("p", 1), Some(3));
a.record("p", 1, 4);
assert_eq!(a.count("p", 1), 2);
assert_eq!(a.last_confidence("p", 1), Some(4));
assert_eq!(a.count("p", 2), 0);
assert_eq!(a.count("q", 1), 0);
}
}