use anyhow::Result;
use serde::Serialize;
use crate::agent::agentic_review;
use crate::backend::{OpenRouterBackend, ReviewBackend, ReviewContext};
use crate::config::Config;
use crate::diff::parse_valid_lines;
use crate::llm::{Finding, Review, ReviewResult, Usage};
use crate::providers::{InlineComment, PrMeta, Provider, ReviewPost};
use crate::repo::Workspace;
use crate::repo_config;
pub struct RunReviewInput {
pub provider: String,
pub repo: String,
pub pr: u64,
pub dry_run: bool,
pub placeholder: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RunReviewOutput {
pub provider: String,
pub repo: String,
pub pr: u64,
pub model: String,
pub recommendation: String,
pub findings: usize,
#[serde(default)]
pub findings_detail: Vec<Finding>,
pub inline_posted: usize,
pub posted: bool,
pub comment_url: Option<String>,
pub summary_markdown: String,
pub usage: Option<Usage>,
}
pub(crate) fn severity_rank(sev: &str) -> u8 {
match sev.to_uppercase().as_str() {
"BLOCKING" => 3,
"HIGH" => 2,
"MEDIUM" => 1,
"LOW" => 0,
_ => 0,
}
}
pub(crate) fn severity_emoji(sev: &str) -> &'static str {
match sev.to_uppercase().as_str() {
"BLOCKING" => "🚨",
"HIGH" => "⚠️",
"MEDIUM" => "ℹ️",
"LOW" => "💡",
_ => "•",
}
}
fn recommendation_rank(rec: &str) -> u8 {
let r = rec.to_uppercase();
if r.contains("BLOCK") {
2
} else if r.contains("CHANGES") {
1
} else {
0
}
}
fn effective_recommendation(model_rec: &str, findings: &[Finding]) -> String {
let max_sev = findings
.iter()
.map(|f| severity_rank(&f.severity))
.max()
.unwrap_or(0);
let floor = match max_sev {
3 => "BLOCK", 2 | 1 => "APPROVE WITH CHANGES", _ => "APPROVE", };
if recommendation_rank(model_rec) >= recommendation_rank(floor) {
model_rec.trim().to_string()
} else {
floor.to_string()
}
}
fn inline_body(f: &Finding) -> String {
format!(
"{} **{}** — {}",
severity_emoji(&f.severity),
f.severity.to_uppercase(),
f.body.trim()
)
}
const REANCHOR_WINDOW: i64 = 3;
const MIN_ANCHOR_SYMBOL_LEN: usize = 4;
fn idents(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
for ch in s.chars() {
if ch.is_alphanumeric() || ch == '_' {
cur.push(ch);
} else if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
}
if !cur.is_empty() {
out.push(cur);
}
out
}
fn line_symbols(text: &str) -> Vec<String> {
const KW: &[&str] = &[
"const", "let", "var", "function", "return", "import", "export", "from", "class",
"interface", "type", "public", "private", "protected", "static", "async", "await", "for",
"while", "new", "void", "null", "true", "false", "this", "self", "def", "func", "pub",
"use", "mod", "struct", "enum", "impl", "package",
];
idents(text)
.into_iter()
.filter(|w| w.len() >= 3 && !KW.contains(&w.as_str()))
.collect()
}
fn reanchor(
line: u64,
valid: &std::collections::HashSet<u64>,
texts: &std::collections::HashMap<u64, String>,
body: &str,
) -> Option<u64> {
let body_words: std::collections::HashSet<String> = line_symbols(body).into_iter().collect();
if body_words.is_empty() {
return None;
}
let mut cands: Vec<u64> = valid
.iter()
.copied()
.filter(|&c| c != line && (c as i64 - line as i64).abs() <= REANCHOR_WINDOW)
.collect();
cands.sort_by_key(|&c| ((c as i64 - line as i64).abs(), c));
for c in cands {
if let Some(text) = texts.get(&c) {
if line_symbols(text)
.iter()
.any(|s| s.len() >= MIN_ANCHOR_SYMBOL_LEN && body_words.contains(s))
{
return Some(c);
}
}
}
None
}
fn render_summary(
review: &Review,
recommendation: &str,
unanchored: &[&Finding],
inline_count: usize,
) -> String {
let mut s = format!(
"🤖 **Automated review**\n\n{}\n\n**Recommendation:** {}",
review.summary.trim(),
recommendation.trim()
);
if inline_count > 0 {
s.push_str(&format!("\n\n_{inline_count} inline comment(s) below._"));
}
if unanchored.is_empty() {
if inline_count == 0 {
s.push_str("\n\nNo blocking issues found.");
}
} else {
s.push_str("\n\n## Findings");
for f in unanchored {
let loc = match f.line {
Some(l) => format!("`{}` (~{l})", f.file),
None => format!("`{}`", f.file),
};
s.push_str(&format!(
"\n- {} **{}** — {loc} — {}",
severity_emoji(&f.severity),
f.severity.to_uppercase(),
f.body.trim()
));
}
}
s.push_str("\n\n_Automated advisory review — a human still owns the merge decision._");
s
}
fn render_pending() -> String {
"🤖 **Automated review**\n\n⏳ _Reviewing this PR… (this comment will update shortly)_"
.to_string()
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn run_agentic(
provider: &Provider,
client: &reqwest::Client,
cfg: &Config,
meta: &PrMeta,
diff: &str,
omitted_note: Option<&str>,
structural_context: Option<&str>,
repo: &str,
) -> Result<ReviewResult> {
let url = provider.clone_url(cfg, repo)?;
let sha = meta.head_sha.clone();
let ws = tokio::task::spawn_blocking(move || Workspace::clone(&url, sha.as_deref())).await??;
agentic_review(
client,
cfg,
meta,
diff,
omitted_note,
structural_context,
&ws,
)
.await
}
pub(crate) async fn load_repo_config(
provider: &Provider,
client: &reqwest::Client,
base: &Config,
repo: &str,
meta: &PrMeta,
) -> Config {
let git_ref = match (meta.head_sha.as_deref(), meta.base_branch.as_deref()) {
(Some(sha), _) if !sha.is_empty() => sha,
(_, Some(branch)) if !branch.is_empty() => branch,
_ => return base.clone(),
};
match provider
.get_file_contents(client, base, repo, git_ref, ".prbot.toml")
.await
{
Ok(Some(text)) => match repo_config::parse(&text) {
Ok(rc) => {
tracing::info!("applied .prbot.toml overrides for {repo}");
base.with_repo_overrides(&rc)
}
Err(e) => {
tracing::warn!("ignoring invalid .prbot.toml for {repo}: {e:#}");
base.clone()
}
},
Ok(None) => base.clone(),
Err(e) => {
tracing::warn!("could not fetch .prbot.toml for {repo}: {e:#}");
base.clone()
}
}
}
fn render_no_review_summary(advisories: &[crate::deps::DepAdvisory], hygiene: &[Finding]) -> String {
let mut s = String::from(
"🤖 **Automated review**\n\nNo reviewable source changes (all files excluded by filters).",
);
if !advisories.is_empty() {
s.push_str("\n\n");
s.push_str(&crate::deps::render_advisories(advisories));
}
if !hygiene.is_empty() {
s.push_str("\n\n## Findings");
for f in hygiene {
s.push_str(&format!(
"\n- {} **{}** — `{}` — {}",
severity_emoji(&f.severity),
f.severity.to_uppercase(),
f.file,
f.body.trim()
));
}
}
s.push_str("\n\n_Automated advisory review — a human still owns the merge decision._");
s
}
async fn post_advisory_only(
provider: &Provider,
client: &reqwest::Client,
cfg: &Config,
meta: &PrMeta,
input: &RunReviewInput,
advisories: Vec<crate::deps::DepAdvisory>,
hygiene: Vec<Finding>,
) -> Result<RunReviewOutput> {
let summary = render_no_review_summary(&advisories, &hygiene);
let baseline = if advisories.is_empty() {
"APPROVE"
} else {
"APPROVE WITH CHANGES"
};
let recommendation = effective_recommendation(baseline, &hygiene);
let post = ReviewPost {
summary: summary.clone(),
inline: Vec::new(),
};
let mut out = RunReviewOutput {
provider: provider.name().to_string(),
repo: input.repo.clone(),
pr: input.pr,
model: cfg.openrouter_model.clone(),
recommendation,
findings: hygiene.len(),
findings_detail: hygiene,
inline_posted: 0,
posted: false,
comment_url: None,
summary_markdown: summary,
usage: None,
};
if !input.dry_run {
out.comment_url = provider.post_review(client, cfg, meta, &post).await?;
out.posted = true;
}
Ok(out)
}
pub async fn run_review(cfg: &Config, input: RunReviewInput) -> Result<RunReviewOutput> {
run_review_with(cfg, input, &OpenRouterBackend).await
}
pub async fn run_review_with(
cfg: &Config,
input: RunReviewInput,
backend: &dyn ReviewBackend,
) -> Result<RunReviewOutput> {
let provider = Provider::from_name(&input.provider)?;
let client = reqwest::Client::new();
let meta = provider
.get_meta(&client, cfg, &input.repo, input.pr)
.await?;
let effective = load_repo_config(&provider, &client, cfg, &input.repo, &meta).await;
let cfg = &effective;
if input.placeholder && !input.dry_run {
let pending = ReviewPost {
summary: render_pending(),
inline: Vec::new(),
};
if let Err(e) = provider.post_review(&client, cfg, &meta, &pending).await {
tracing::warn!(
"placeholder comment failed for {}#{}: {e:#}",
input.repo,
input.pr
);
}
}
let raw_diff = provider
.get_diff(&client, cfg, &input.repo, input.pr)
.await?;
let advisories = crate::deps::scan(&client, cfg, &raw_diff).await;
if !advisories.is_empty() {
tracing::info!(
"OSV: {} dependency advisor(y/ies) for {}#{}",
advisories.len(),
input.repo,
input.pr
);
}
let (diff, dropped) =
crate::diff::filter_diff_by_globs(&raw_diff, &cfg.include_globs, &cfg.exclude_globs);
if !dropped.is_empty() {
tracing::info!("skipped {} file(s) by glob: {:?}", dropped.len(), dropped);
}
let hygiene: Vec<Finding> = crate::diff::diff_hygiene(&raw_diff)
.into_iter()
.map(|h| Finding {
severity: h.severity.to_string(),
file: h.file,
line: None,
body: h.body,
confidence: Some(100),
})
.collect();
if diff.trim().is_empty() {
if !advisories.is_empty() || !hygiene.is_empty() {
return post_advisory_only(&provider, &client, cfg, &meta, &input, advisories, hygiene)
.await;
}
anyhow::bail!(
"PR diff is empty (all files excluded by globs, or no changes) — nothing to review."
);
}
let (diff, packed_dropped) = if cfg.file_bundling {
crate::diff::pack_diff_bundled(&diff, cfg.max_diff_chars)
} else {
crate::diff::pack_diff(&diff, cfg.max_diff_chars)
};
if !packed_dropped.is_empty() {
tracing::info!(
"packed diff: omitted {} lower-priority file(s) to fit budget: {:?}",
packed_dropped.len(),
packed_dropped
);
}
let omitted_note = (!packed_dropped.is_empty()).then(|| {
format!(
"{} file(s) were omitted to fit the size limit and were NOT reviewed: {}",
packed_dropped.len(),
packed_dropped.join(", ")
)
});
let structural = if cfg.structural_context {
crate::structure::structural_context(&provider, &client, cfg, &input.repo, &meta, &diff)
.await
} else {
String::new()
};
if !structural.is_empty() {
tracing::info!(
"structural context for {}#{}: {} line(s)",
input.repo,
input.pr,
structural.lines().count()
);
}
let structural_opt = (!structural.is_empty()).then_some(structural.as_str());
let ctx = ReviewContext {
client: &client,
cfg,
provider: &provider,
repo: &input.repo,
meta: &meta,
diff: &diff,
omitted_note: omitted_note.as_deref(),
structural_context: structural_opt,
};
let result = backend.review(&ctx).await?;
let mut findings = result.review.findings.clone();
if cfg.self_critique && !findings.is_empty() {
findings = match crate::llm::critique_findings(&client, cfg, &meta, &diff, &findings).await
{
Ok(f) => f,
Err(e) => {
tracing::warn!("self-critique failed ({e:#}); keeping original findings");
findings
}
};
}
findings.retain(|f| f.confidence.unwrap_or(100) >= cfg.min_confidence);
findings.extend(hygiene);
findings.sort_by(|a, b| {
severity_rank(&b.severity)
.cmp(&severity_rank(&a.severity))
.then(b.confidence.unwrap_or(0).cmp(&a.confidence.unwrap_or(0)))
});
let recommendation = effective_recommendation(&result.review.recommendation, &findings);
findings.truncate(cfg.max_findings);
let valid = parse_valid_lines(&diff);
let line_texts = if cfg.reanchor_findings {
crate::diff::diff_line_texts(&diff)
} else {
std::collections::HashMap::new()
};
let mut inline: Vec<InlineComment> = Vec::new();
let mut unanchored: Vec<&Finding> = Vec::new();
for f in &findings {
let mut anchor = f
.line
.filter(|l| valid.get(&f.file).is_some_and(|s| s.contains(l)));
if anchor.is_none() && cfg.reanchor_findings {
if let (Some(l), Some(v), Some(t)) =
(f.line, valid.get(&f.file), line_texts.get(&f.file))
{
anchor = reanchor(l, v, t, &f.body);
}
}
match anchor {
Some(line) => inline.push(InlineComment {
path: f.file.clone(),
line,
body: inline_body(f),
}),
None => unanchored.push(f),
}
}
let mut summary = render_summary(&result.review, &recommendation, &unanchored, inline.len());
if !advisories.is_empty() {
summary.push_str("\n\n");
summary.push_str(&crate::deps::render_advisories(&advisories));
}
let inline_count = inline.len();
let post = ReviewPost {
summary: summary.clone(),
inline,
};
let mut out = RunReviewOutput {
provider: provider.name().to_string(),
repo: input.repo.clone(),
pr: input.pr,
model: result.model,
recommendation: recommendation.clone(),
findings: findings.len(),
findings_detail: findings.clone(),
inline_posted: inline_count,
posted: false,
comment_url: None,
summary_markdown: summary,
usage: result.usage,
};
if !input.dry_run {
out.comment_url = provider.post_review(&client, cfg, &meta, &post).await?;
out.posted = true;
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::{
effective_recommendation, idents, line_symbols, reanchor, render_no_review_summary,
};
use crate::llm::Finding;
use std::collections::{HashMap, HashSet};
fn finding(severity: &str) -> Finding {
Finding {
severity: severity.to_string(),
file: "assets/ime.zip".to_string(),
line: None,
body: "A binary file `assets/ime.zip` was added. Fix: drop it.".to_string(),
confidence: Some(100),
}
}
#[test]
fn recommendation_upgrades_but_never_downgrades() {
assert_eq!(
effective_recommendation("APPROVE", &[finding("MEDIUM")]),
"APPROVE WITH CHANGES"
);
assert_eq!(effective_recommendation("APPROVE", &[finding("BLOCKING")]), "BLOCK");
assert_eq!(effective_recommendation("APPROVE", &[finding("LOW")]), "APPROVE");
assert_eq!(effective_recommendation("BLOCK", &[finding("LOW")]), "BLOCK");
assert_eq!(effective_recommendation("APPROVE", &[]), "APPROVE");
}
#[test]
fn no_review_summary_still_names_a_swept_in_binary() {
let s = render_no_review_summary(&[], &[finding("MEDIUM")]);
assert!(s.contains("assets/ime.zip"));
assert!(s.contains("MEDIUM"));
assert!(s.contains("## Findings"));
}
#[test]
fn idents_extracts_tokens() {
assert_eq!(idents("foo.bar(baz_qux)"), vec!["foo", "bar", "baz_qux"]);
}
#[test]
fn line_symbols_drops_keywords_and_short_tokens() {
let s = line_symbols("export function calcTotal(o) {");
assert!(s.contains(&"calcTotal".to_string()));
assert!(!s.iter().any(|w| w == "export" || w == "function" || w == "o"));
}
#[test]
fn reanchor_snaps_to_the_matching_diff_line() {
let valid: HashSet<u64> = [8, 10, 12].into_iter().collect();
let mut texts = HashMap::new();
texts.insert(8, " const subtotal = sum(items);".to_string());
texts.insert(10, " return calcTotal(order, tax);".to_string());
texts.insert(12, "}".to_string());
let got = reanchor(9, &valid, &texts, "`calcTotal` now needs a tax arg. Fix: pass it.");
assert_eq!(got, Some(10));
}
#[test]
fn reanchor_declines_without_a_content_match() {
let valid: HashSet<u64> = [8, 10].into_iter().collect();
let mut texts = HashMap::new();
texts.insert(8, " const x = 1;".to_string());
texts.insert(10, " const y = 2;".to_string());
assert_eq!(reanchor(9, &valid, &texts, "Missing null check on user.roles"), None);
}
#[test]
fn reanchor_declines_on_a_short_shared_token() {
let valid: HashSet<u64> = [10].into_iter().collect();
let mut texts = HashMap::new();
texts.insert(10, " const total = sum(items);".to_string());
assert_eq!(reanchor(9, &valid, &texts, "sum is off by one"), None);
}
#[test]
fn reanchor_ties_break_on_lower_line_number() {
let valid: HashSet<u64> = [8, 10].into_iter().collect();
let mut texts = HashMap::new();
texts.insert(8, " calcTotal(order);".to_string());
texts.insert(10, " calcTotal(basket);".to_string());
assert_eq!(reanchor(9, &valid, &texts, "calcTotal needs a tax arg"), Some(8));
}
#[test]
fn reanchor_ignores_lines_outside_the_window() {
let valid: HashSet<u64> = [20].into_iter().collect();
let mut texts = HashMap::new();
texts.insert(20, " calcTotal();".to_string());
assert_eq!(reanchor(9, &valid, &texts, "calcTotal issue"), None);
}
}