use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::integrations::github::{GithubClient, GithubError};
use crate::models::{Finding, ReviewResult, Verdict};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerdictBlock {
#[serde(skip_serializing_if = "Option::is_none")]
pub grade: Option<String>,
pub verdict: Verdict,
pub model: String,
pub review_version: String,
pub findings: Vec<Finding>,
}
impl VerdictBlock {
fn from_result(result: &ReviewResult) -> Self {
Self {
grade: result.grade.clone(),
verdict: result.verdict.clone(),
model: result.model.clone(),
review_version: result.review_version.clone(),
findings: result.findings.clone(),
}
}
}
pub const REVIEW_SIGNATURE: &str = "<!-- trusty-review -->";
pub fn build_review_comment_body(result: &ReviewResult) -> String {
let mut md = String::with_capacity(1024);
md.push_str(REVIEW_SIGNATURE);
md.push('\n');
let grade_prefix = result
.grade
.as_deref()
.map(|g| format!("Grade: {g} | "))
.unwrap_or_default();
md.push_str(&format!(
"## trusty-review: {}`{}`\n\n",
grade_prefix, result.verdict
));
if result.review_body.trim().is_empty() {
md.push_str("_No narrative summary was produced for this review._\n\n");
} else {
md.push_str(result.review_body.trim());
md.push_str("\n\n");
}
if result.findings.is_empty() {
md.push_str("**Findings:** none\n\n");
} else {
md.push_str(&format!("**Findings ({}):**\n\n", result.findings.len()));
for (i, f) in result.findings.iter().enumerate() {
let loc = match f.line {
Some(l) => format!("{}:{l}", f.file),
None => f.file.clone(),
};
md.push_str(&format!(
"{}. **{}** (`{}`, {}, confidence {:.0}%)\n - {}\n - _Fix:_ {}\n",
i + 1,
f.kind,
loc,
f.effort,
f.confidence * 100.0,
f.description,
f.suggestion,
));
}
md.push('\n');
}
let block = VerdictBlock::from_result(result);
match serde_json::to_string_pretty(&block) {
Ok(json) => {
md.push_str("```json\n");
md.push_str(&json);
md.push_str("\n```\n");
}
Err(e) => {
tracing::warn!("failed to serialise verdict block for comment: {e}");
}
}
md
}
#[derive(Debug, Clone, Deserialize)]
pub struct PostedReview {
pub id: u64,
#[serde(default)]
pub html_url: String,
}
fn review_event(_verdict: &Verdict) -> &'static str {
"COMMENT"
}
pub async fn post_pr_review(
client: &GithubClient,
owner: &str,
repo: &str,
pr: u64,
token: &str,
result: &ReviewResult,
) -> Result<PostedReview, GithubError> {
let body = build_review_comment_body(result);
let event = review_event(&result.verdict);
let url = format!("https://api.github.com/repos/{owner}/{repo}/pulls/{pr}/reviews");
let payload = json!({
"body": body,
"event": event,
});
let resp = client
.http
.post(&url)
.header("Accept", "application/vnd.github+json")
.header("Authorization", format!("Bearer {token}"))
.header("X-GitHub-Api-Version", "2022-11-28")
.header("User-Agent", &client.user_agent)
.json(&payload)
.send()
.await
.map_err(|e| GithubError::Transport(format!("POST {url}: {e}")))?;
let status = resp.status();
let resp_body = resp
.text()
.await
.map_err(|e| GithubError::Transport(format!("read body of {url}: {e}")))?;
if !status.is_success() {
return Err(GithubError::Api {
status: status.as_u16(),
body: resp_body,
});
}
serde_json::from_str(&resp_body)
.map_err(|e| GithubError::Transport(format!("parse review-post response from {url}: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::Effort;
fn sample_result() -> ReviewResult {
let mut r = ReviewResult::new(
"acme",
"backend",
42,
"Add feature X",
"https://github.com/acme/backend/pull/42",
);
r.verdict = Verdict::RequestChanges;
r.model = "us.anthropic.claude-sonnet-4-6".to_string();
r.review_body = "This change has a SQL injection risk on the user path.".to_string();
let mut f = Finding::new(
"src/db.rs",
"security",
"SQL injection via string interpolation",
"Use a parameterised query",
0.92,
Effort::Medium,
);
f.line = Some(42);
r.findings.push(f);
r
}
#[test]
fn body_contains_signature() {
let body = build_review_comment_body(&sample_result());
assert!(body.contains(REVIEW_SIGNATURE), "must carry the signature");
}
#[test]
fn body_contains_prose_and_json_block() {
let body = build_review_comment_body(&sample_result());
assert!(body.contains("SQL injection risk"), "prose must appear");
assert!(body.contains("```json"), "fenced JSON block must appear");
assert!(body.contains("REQUEST_CHANGES"), "verdict must appear");
assert!(
body.contains("src/db.rs:42"),
"finding location must appear"
);
}
#[test]
fn body_json_block_roundtrips() {
let result = sample_result();
let body = build_review_comment_body(&result);
let start = body.find("```json\n").expect("json fence start") + "```json\n".len();
let rest = &body[start..];
let end = rest.find("\n```").expect("json fence end");
let json = &rest[..end];
let block: VerdictBlock = serde_json::from_str(json).expect("block must parse");
assert_eq!(block.verdict, Verdict::RequestChanges);
assert_eq!(block.findings.len(), 1);
assert_eq!(block.model, "us.anthropic.claude-sonnet-4-6");
}
#[test]
fn body_no_findings_notes_absence() {
let mut result = sample_result();
result.findings.clear();
result.verdict = Verdict::Approve;
let body = build_review_comment_body(&result);
assert!(body.contains("**Findings:** none"));
}
#[test]
fn body_empty_summary_uses_fallback() {
let mut result = sample_result();
result.review_body = String::new();
let body = build_review_comment_body(&result);
assert!(body.contains("No narrative summary"));
}
#[test]
fn verdict_event_is_comment() {
assert_eq!(review_event(&Verdict::Block), "COMMENT");
assert_eq!(review_event(&Verdict::Approve), "COMMENT");
}
#[test]
fn posted_review_deserialises() {
let json = r#"{"id": 555, "html_url": "https://github.com/acme/backend/pull/42#pullrequestreview-555"}"#;
let posted: PostedReview = serde_json::from_str(json).expect("deserialise");
assert_eq!(posted.id, 555);
assert!(posted.html_url.contains("pullrequestreview-555"));
}
#[tokio::test]
async fn post_pr_review_transport_error_on_unreachable() {
let client = GithubClient::with_timeout(std::time::Duration::from_millis(200));
let result = sample_result();
let resp = client
.http
.post("http://127.0.0.1:1/repos/acme/backend/pulls/42/reviews")
.header("User-Agent", &client.user_agent)
.json(&serde_json::json!({"body": build_review_comment_body(&result), "event": "COMMENT"}))
.send()
.await;
assert!(resp.is_err(), "connection to port 1 must fail");
}
#[test]
fn body_footer_contains_grade() {
use crate::pipeline::post::format_review_footer;
let mut result = sample_result();
result.grade = Some("B+".to_string());
result.model = "us.anthropic.claude-sonnet-4-6".to_string();
result.input_tokens = 13499;
result.output_tokens = 1718;
result.cost_estimate_usd = 0.066_267;
let footer = format_review_footer(
result.grade.as_deref(),
&result.model,
result.input_tokens,
result.output_tokens,
result.cost_estimate_usd,
);
result.review_body.push_str(&footer);
let expected_footer = "Grade: B+ · 🤖 Reviewed by `us.anthropic.claude-sonnet-4-6` · tokens ↑13,499 ↓1,718 · est. $0.066";
assert!(
result.review_body.contains(expected_footer),
"review_body must contain the exact consolidated footer: {expected_footer}\nActual review_body:\n{}",
result.review_body
);
let body = build_review_comment_body(&result);
assert!(
body.contains(expected_footer),
"comment body must contain the consolidated footer: {expected_footer}\nActual body:\n{body}"
);
assert!(
!body.contains("0.066267"),
"comment must not contain full-precision cost: {body}"
);
assert!(
body.contains("↑13,499"),
"comment must contain thousands-separated input tokens: {body}"
);
assert!(
body.contains("↓1,718"),
"comment must contain thousands-separated output tokens: {body}"
);
}
#[test]
fn body_comment_shows_grade_in_heading() {
let mut result = sample_result();
result.grade = Some("B+".to_string());
let body = build_review_comment_body(&result);
assert!(
body.contains("Grade: B+"),
"review body heading must include grade: {body}"
);
}
#[test]
fn body_comment_no_grade_omits_grade_prefix() {
let mut result = sample_result();
result.grade = None;
let body = build_review_comment_body(&result);
assert!(
body.contains("## trusty-review: `REQUEST_CHANGES`"),
"heading without grade must show bare verdict"
);
}
}