use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReviewVerdict {
pub confidence: u8,
pub p0_count: u32,
pub p1_count: u32,
pub p2_count: u32,
pub all_criteria_met: bool,
pub comment_id: u64,
pub commit_short_hash: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AutoMergeCriteria {
pub min_confidence: u8,
pub max_p0: u32,
pub max_p1: u32,
pub require_all_criteria: bool,
pub max_diff_loc: u32,
pub require_agent_author: bool,
}
impl Default for AutoMergeCriteria {
fn default() -> Self {
Self {
min_confidence: 5,
max_p0: 0,
max_p1: 0,
require_all_criteria: true,
max_diff_loc: 500,
require_agent_author: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrMetadata {
pub pr_number: u64,
pub author_login: String,
pub diff_loc: u32,
pub head_sha: String,
pub base_branch: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AutoMergeDecision {
Merge,
HumanReviewNeeded(String),
}
#[derive(Error, Debug, PartialEq, Eq)]
pub enum VerdictParseError {
#[error("missing confidence score header in review comment")]
MissingConfidence,
#[error("confidence score out of range: got {0}")]
ConfidenceOutOfRange(u8),
#[error("missing inline findings section")]
MissingFindings,
#[error("malformed footer (expected `Last reviewed commit: <short>`)")]
MalformedFooter,
}
pub fn parse_verdict(body: &str, comment_id: u64) -> Result<ReviewVerdict, VerdictParseError> {
let confidence = parse_confidence(body)?;
let normalised = strip_h3_attributes(body);
let has_findings = normalised.contains("<h3>Inline Findings</h3>")
|| normalised.contains("<h3>Findings</h3>")
|| body.contains("### Inline Findings")
|| body.contains("### Findings");
if !has_findings {
return Err(VerdictParseError::MissingFindings);
}
let (p0_count, p1_count, p2_count) = count_findings(body);
let all_criteria_met = all_checkboxes_checked(body);
let commit_short_hash = parse_commit_short_hash(body)?;
Ok(ReviewVerdict {
confidence,
p0_count,
p1_count,
p2_count,
all_criteria_met,
comment_id,
commit_short_hash,
})
}
pub fn evaluate(
verdict: &ReviewVerdict,
pr: &PrMetadata,
criteria: &AutoMergeCriteria,
) -> AutoMergeDecision {
if verdict.confidence < criteria.min_confidence {
return AutoMergeDecision::HumanReviewNeeded(format!(
"confidence {}/5 below auto-merge threshold {}/5",
verdict.confidence, criteria.min_confidence
));
}
if verdict.p0_count > criteria.max_p0 {
return AutoMergeDecision::HumanReviewNeeded(format!(
"{} P0 finding(s) present (max {})",
verdict.p0_count, criteria.max_p0
));
}
if verdict.p1_count > criteria.max_p1 {
return AutoMergeDecision::HumanReviewNeeded(format!(
"{} P1 finding(s) present (max {})",
verdict.p1_count, criteria.max_p1
));
}
if criteria.require_all_criteria && !verdict.all_criteria_met {
return AutoMergeDecision::HumanReviewNeeded(
"unchecked acceptance criteria in review body".to_string(),
);
}
if pr.diff_loc > criteria.max_diff_loc {
return AutoMergeDecision::HumanReviewNeeded(format!(
"diff size {} LoC exceeds cap {} LoC",
pr.diff_loc, criteria.max_diff_loc
));
}
if criteria.require_agent_author && !author_is_agent(&pr.author_login) {
return AutoMergeDecision::HumanReviewNeeded(format!(
"author `{}` is not a recognised agent; human-authored PRs require manual merge",
pr.author_login
));
}
AutoMergeDecision::Merge
}
pub fn author_is_agent(login: &str) -> bool {
matches!(login, "claude-code" | "root") || login.starts_with("adf-")
}
fn strip_h3_attributes(body: &str) -> String {
let mut result = String::with_capacity(body.len());
let mut chars = body.chars().peekable();
while let Some(c) = chars.next() {
result.push(c);
if c == '<' && (chars.peek() == Some(&'h') || chars.peek() == Some(&'H')) {
let mut tag = String::new();
tag.push('<');
if let Some(ch) = chars.next() {
tag.push(ch);
if ch == 'h' || ch == 'H' {
if let Some(ch) = chars.next() {
tag.push(ch);
if ch == '3' {
result.push_str("h3");
loop {
match chars.next() {
Some('>') => {
result.push('>');
break;
}
Some(other) => {
let _ = other;
}
None => break,
}
}
continue;
} else {
result.push_str(&tag[1..]);
}
} else {
result.push_str(&tag[1..]);
}
} else {
result.push_str(&tag[1..]);
}
}
}
}
result
}
fn parse_confidence(body: &str) -> Result<u8, VerdictParseError> {
let needle = "Confidence Score:";
let idx = body
.find(needle)
.ok_or(VerdictParseError::MissingConfidence)?;
let tail = &body[idx + needle.len()..];
let digits: String = tail
.chars()
.skip_while(|c| c.is_whitespace())
.take_while(|c| c.is_ascii_digit())
.collect();
if digits.is_empty() {
return Err(VerdictParseError::MissingConfidence);
}
let score: u8 = digits
.parse()
.map_err(|_| VerdictParseError::MissingConfidence)?;
if !(1..=5).contains(&score) {
return Err(VerdictParseError::ConfidenceOutOfRange(score));
}
Ok(score)
}
fn count_findings(body: &str) -> (u32, u32, u32) {
let mut p0 = 0;
let mut p1 = 0;
let mut p2 = 0;
for line in body.lines() {
let t = line.trim_start();
if t.starts_with("**P0 ") {
p0 += 1;
} else if t.starts_with("**P1 ") {
p1 += 1;
} else if t.starts_with("**P2 ") {
p2 += 1;
}
}
(p0, p1, p2)
}
fn all_checkboxes_checked(body: &str) -> bool {
for line in body.lines() {
let t = line.trim_start();
if t.starts_with("- [ ]") || t.starts_with("* [ ]") {
return false;
}
}
true
}
fn parse_commit_short_hash(body: &str) -> Result<String, VerdictParseError> {
let needle = "Last reviewed commit:";
let idx = body
.find(needle)
.ok_or(VerdictParseError::MalformedFooter)?;
let tail = &body[idx + needle.len()..];
let hash: String = tail
.chars()
.skip_while(|c| c.is_whitespace())
.take_while(|c| c.is_ascii_hexdigit())
.collect();
if hash.is_empty() {
return Err(VerdictParseError::MalformedFooter);
}
Ok(hash)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn author_is_agent_policy() {
assert!(author_is_agent("claude-code"));
assert!(author_is_agent("root"));
assert!(author_is_agent("adf-fleet"));
assert!(author_is_agent("adf-reviewer"));
assert!(!author_is_agent("alex"));
assert!(!author_is_agent("dependabot[bot]"));
assert!(!author_is_agent("renovate[bot]"));
}
#[test]
fn default_criteria_match_policy() {
let c = AutoMergeCriteria::default();
assert_eq!(c.min_confidence, 5);
assert_eq!(c.max_p0, 0);
assert_eq!(c.max_p1, 0);
assert!(c.require_all_criteria);
assert_eq!(c.max_diff_loc, 500);
assert!(c.require_agent_author);
}
#[test]
fn confidence_out_of_range_is_rejected_inline() {
let body = "<h3>Confidence Score: 7/5</h3>\n<h3>Inline Findings</h3>\n<sub>Last reviewed commit: abc123</sub>";
let err = parse_verdict(body, 1).unwrap_err();
assert_eq!(err, VerdictParseError::ConfidenceOutOfRange(7));
}
#[test]
fn strip_h3_attributes_removes_classes_and_ids() {
let input = r#"<h3 class="foo">Confidence Score: 5/5</h3>\n<h3 id="bar" class="baz">Inline Findings</h3>"#;
let got = strip_h3_attributes(input);
assert!(got.contains("<h3>Confidence Score: 5/5</h3>"));
assert!(got.contains("<h3>Inline Findings</h3>"));
assert!(!got.contains("class=\"foo\""));
assert!(!got.contains("id=\"bar\""));
}
#[test]
fn parse_verdict_accepts_h3_with_attributes() {
let body = r#"<h3 class="foo">Confidence Score: 5/5</h3>
<h3 id="bar" class="baz">Inline Findings</h3>
<sub>Last reviewed commit: abc123</sub>"#;
let verdict = parse_verdict(body, 42).expect("should parse with attributes in h3");
assert_eq!(verdict.confidence, 5);
assert_eq!(verdict.p0_count, 0);
assert_eq!(verdict.comment_id, 42);
assert_eq!(verdict.commit_short_hash, "abc123");
}
#[test]
fn strip_h3_attributes_leaves_other_tags_intact() {
let input = r#"<h2 class="x">Summary</h2>\n<h3 class="y">Inline Findings</h3>\n<p class="z">text</p>"#;
let got = strip_h3_attributes(input);
assert!(got.contains("<h2 class=\"x\">Summary</h2>"));
assert!(got.contains("<p class=\"z\">text</p>"));
assert!(got.contains("<h3>Inline Findings</h3>"));
}
#[test]
fn parse_verdict_accepts_findings_without_inline() {
let body = "<h3>Confidence Score: 5/5</h3>\n<h3>Findings</h3>\n<sub>Last reviewed commit: abc123</sub>";
let verdict = parse_verdict(body, 1).expect("should parse with Findings heading");
assert_eq!(verdict.confidence, 5);
assert_eq!(verdict.p0_count, 0);
}
#[test]
fn parse_verdict_accepts_markdown_findings_without_inline() {
let body = "### Confidence Score: 4/5\n\n### Findings\n\n<sub>Last reviewed commit: deadbeef</sub>";
let verdict = parse_verdict(body, 2).expect("should parse with markdown Findings");
assert_eq!(verdict.confidence, 4);
assert_eq!(verdict.commit_short_hash, "deadbeef");
}
#[test]
fn parse_verdict_rejects_missing_findings_entirely() {
let body = "<h3>Confidence Score: 5/5</h3>\n<sub>Last reviewed commit: abc123</sub>";
let err = parse_verdict(body, 1).unwrap_err();
assert_eq!(err, VerdictParseError::MissingFindings);
}
#[test]
fn parse_verdict_accepts_findings_with_attributes() {
let body = r#"<h3 class="section">Confidence Score: 5/5</h3>
<h3 id="findings" class="section">Findings</h3>
<sub>Last reviewed commit: abc123</sub>"#;
let verdict = parse_verdict(body, 1).expect("should parse Findings with h3 attributes");
assert_eq!(verdict.confidence, 5);
assert_eq!(verdict.p0_count, 0);
}
#[test]
fn parse_verdict_counts_p0_in_findings_section() {
let body = "<h3>Confidence Score: 3/5</h3>\n<h3>Findings</h3>\n**P0 SQL injection in login**:\n**P1 Missing CSRF token**:\n<sub>Last reviewed commit: abc123</sub>";
let verdict = parse_verdict(body, 10).unwrap();
assert_eq!(verdict.confidence, 3);
assert_eq!(verdict.p0_count, 1);
assert_eq!(verdict.p1_count, 1);
assert_eq!(verdict.p2_count, 0);
}
}