use std::path::Path;
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::output_poster::OutputPoster;
use crate::pr_review::extractor;
use crate::pr_review::parse_verdict;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrVerdictMeta {
pub project: String,
pub owner: String,
pub repo: String,
pub pr_number: u64,
pub head_sha: String,
pub head_short: String,
pub author_login: String,
pub title: String,
pub diff_loc: u64,
pub cli_tool: String,
pub agent_name: String,
pub min_confidence: u8,
}
#[derive(Debug, PartialEq, Eq)]
pub enum VerdictPostOutcome {
Posted {
comment_id: u64,
confidence: u8,
},
AlreadyPresent { existing_comment_id: u64 },
NoParseableVerdict { reason: &'static str },
NoDrain { reason: String },
}
pub async fn post_verdict_from_drain(
log_path: &Path,
pr_meta: &PrVerdictMeta,
output_poster: &OutputPoster,
) -> VerdictPostOutcome {
let lines: Vec<String> = match std::fs::read_to_string(log_path) {
Ok(content) => content.lines().map(|s| s.to_string()).collect(),
Err(e) => {
return VerdictPostOutcome::NoDrain {
reason: format!("read_to_string({}): {e}", log_path.display()),
};
}
};
if lines.is_empty() {
return VerdictPostOutcome::NoDrain {
reason: "drain log empty".to_string(),
};
}
let Some(text) = extractor::extract_final_assistant_text(&lines, &pr_meta.cli_tool) else {
return VerdictPostOutcome::NoParseableVerdict {
reason: "no recognisable assistant text in stream",
};
};
let trimmed = text.trim();
if trimmed.is_empty() {
return VerdictPostOutcome::NoParseableVerdict {
reason: "extracted text was empty after trim",
};
}
let tracker = output_poster.tracker_for(&pr_meta.project, "pr-reviewer");
let footer_marker = format!("Last reviewed commit: {}", pr_meta.head_short);
let comments = match tracker {
Some(t) => match t.fetch_comments(pr_meta.pr_number, None).await {
Ok(c) => c,
Err(e) => {
warn!(
pr = pr_meta.pr_number,
project = %pr_meta.project,
error = %e,
"idempotency list failed; treating as fresh post (caller may re-run)"
);
Vec::new()
}
},
None => Vec::new(),
};
if let Some(existing) = comments.iter().find(|c| c.body.contains(&footer_marker)) {
return VerdictPostOutcome::AlreadyPresent {
existing_comment_id: existing.id,
};
}
let reviews_n = comments
.iter()
.filter(|c| c.body.contains(&footer_marker))
.count() as u32
+ 1;
let body = render_verdict_body(
trimmed,
&pr_meta.head_short,
reviews_n,
&pr_meta.title,
&pr_meta.author_login,
pr_meta.diff_loc,
);
let confidence = match parse_verdict(&body, 0) {
Ok(v) => v.confidence,
Err(_) => {
return VerdictPostOutcome::NoParseableVerdict {
reason: "rendered body failed parse_verdict",
};
}
};
match output_poster
.post_raw_as_agent_for_project(&pr_meta.project, "pr-reviewer", pr_meta.pr_number, &body)
.await
{
Ok(comment_id) => VerdictPostOutcome::Posted {
comment_id,
confidence,
},
Err(e) => VerdictPostOutcome::NoDrain {
reason: format!("post_raw_as_agent_for_project failed: {e}"),
},
}
}
fn render_verdict_body(
extracted: &str,
head_short: &str,
reviews_n: u32,
title: &str,
author_login: &str,
diff_loc: u64,
) -> String {
let canonical_footer = format!(
"<sub>Last reviewed commit: {head_short} | Reviews ({n})</sub>",
n = reviews_n
);
let has_summary = extracted.contains("### Summary") || extracted.contains("## Summary");
let has_confidence =
extracted.contains("### Confidence Score:") || extracted.contains("## Confidence Score:");
let has_findings = extracted.contains("### Inline Findings")
|| extracted.contains("## Inline Findings")
|| extracted.contains("### Findings")
|| extracted.contains("## Findings");
if has_summary && has_confidence && has_findings {
return strip_and_reattach_footer(extracted, &canonical_footer);
}
let confidence_line = extract_confidence_line(extracted)
.unwrap_or_else(|| "### Confidence Score: 3/5".to_string());
let context_line = format!("_PR_: {title} _(by {author_login}, {diff_loc} LOC)_");
format!(
"### Summary\n\n{context_line}\n\n{extracted}\n\n{confidence_line}\n\n### Inline Findings\n\n{footer}",
context_line = context_line,
confidence_line = confidence_line,
footer = canonical_footer
)
}
fn strip_and_reattach_footer(extracted: &str, canonical_footer: &str) -> String {
let mut out = String::with_capacity(extracted.len() + canonical_footer.len() + 8);
for line in extracted.lines() {
if line.trim_start().starts_with("<sub>Last reviewed commit:") {
continue;
}
if !out.is_empty() {
out.push('\n');
}
out.push_str(line);
}
if !out.ends_with('\n') {
out.push('\n');
}
out.push('\n');
out.push_str(canonical_footer);
out
}
fn extract_confidence_line(extracted: &str) -> Option<String> {
for line in extracted.lines() {
let l = line.trim_start();
if let Some(idx) = l.find("Confidence Score:") {
let after = &l[idx + "Confidence Score:".len()..];
let token = after.split_whitespace().next().unwrap_or("").trim();
let token = token.trim_end_matches(|c: char| !c.is_ascii_digit());
if !token.is_empty() {
return Some(format!("### Confidence Score: {token}/5"));
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_verdict_body_wraps_free_form_text_under_summary() {
let body = render_verdict_body(
"A short review.",
"abc1234",
1,
"Fix #1 dead-code",
"root",
500,
);
assert!(body.contains("### Summary"));
assert!(body.contains("A short review."));
assert!(body.contains("### Confidence Score: 3/5"));
assert!(body.contains("### Inline Findings"));
assert!(body.contains("<sub>Last reviewed commit: abc1234 | Reviews (1)</sub>"));
assert!(body.contains("Fix #1 dead-code"));
assert!(body.contains("root"));
assert!(body.contains("500 LOC"));
}
#[test]
fn render_verdict_body_keeps_well_formed_body_and_replaces_footer() {
let extracted = "\
### Summary
A short review.
### Confidence Score: 5/5
### Inline Findings
**P2 file.rs, line 1**: hygiene
<sub>Last reviewed commit: abc1234 | Reviews (2)</sub>
";
let body = render_verdict_body(extracted, "abc1234", 3, "title", "root", 42);
assert!(body.contains("| Reviews (3)</sub>"));
assert!(!body.contains("| Reviews (2)</sub>"));
assert!(body.contains("### Summary"));
assert!(body.contains("### Confidence Score: 5/5"));
assert!(body.contains("### Inline Findings"));
}
#[test]
fn render_verdict_body_extracts_confidence_from_inline_text() {
let extracted = "I read the diff. Confidence Score: 4/5. Looks fine.";
let body = render_verdict_body(extracted, "deadbee", 1, "title", "root", 10);
assert!(body.contains("### Confidence Score: 4/5"));
}
#[test]
fn render_verdict_body_falls_back_to_default_confidence_when_unreadable() {
let body = render_verdict_body("Just a review.", "abc1234", 1, "title", "root", 10);
assert!(body.contains("### Confidence Score: 3/5"));
}
}