use std::collections::{HashMap, HashSet};
use std::time::{Duration, Instant};
use async_trait::async_trait;
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>;
}
#[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_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 },
NoReviewerComment,
ParseError { reason: 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],
criteria: &AutoMergeCriteria,
) -> EvaluationOutcome {
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),
}
}
};
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) => {
EvaluationOutcome::HumanReviewNeeded { reason }
}
}
}
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) {
if 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())))
}
}
#[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 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"));
}
}