use anyhow::Result;
use crate::backend::{OpenRouterBackend, ReviewBackend};
use crate::config::Config;
use crate::providers::{PrMeta, Provider};
use crate::review::{load_repo_config, run_review_with, RunReviewInput};
const DESC_START: &str = "<!-- prbot:describe:start -->";
const DESC_END: &str = "<!-- prbot:describe:end -->";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
Review,
Ask(String),
Describe,
ReviewFile(String),
}
#[derive(Debug, Clone)]
pub struct CommandOutcome {
pub command: &'static str,
pub comment_url: Option<String>,
}
pub fn parse_command(body: &str) -> Option<Command> {
let trimmed = body.trim();
let mut lines = trimmed.lines();
let first = lines.next().unwrap_or("").trim();
let (cmd, rest) = match first.split_once(char::is_whitespace) {
Some((c, r)) => (c, r.trim()),
None => (first, ""),
};
match cmd {
"/review" => Some(Command::Review),
"/describe" => Some(Command::Describe),
"/review-file" => {
let path = rest.trim();
(!path.is_empty()).then(|| Command::ReviewFile(path.to_string()))
}
"/ask" => {
let mut q = rest.to_string();
let tail: Vec<&str> = lines.collect();
if !tail.is_empty() {
if !q.is_empty() {
q.push('\n');
}
q.push_str(&tail.join("\n"));
}
let q = q.trim().to_string();
if q.is_empty() {
None
} else {
Some(Command::Ask(q))
}
}
_ => None,
}
}
pub async fn run_command(
cfg: &Config,
provider_name: &str,
repo: &str,
pr: u64,
cmd: Command,
) -> Result<CommandOutcome> {
run_command_with(cfg, provider_name, repo, pr, cmd, &OpenRouterBackend).await
}
pub async fn run_command_with(
cfg: &Config,
provider_name: &str,
repo: &str,
pr: u64,
cmd: Command,
backend: &dyn ReviewBackend,
) -> Result<CommandOutcome> {
match cmd {
Command::Review => {
let out = run_review_with(
cfg,
RunReviewInput {
provider: provider_name.to_string(),
repo: repo.to_string(),
pr,
dry_run: false,
placeholder: true,
},
backend,
)
.await?;
Ok(CommandOutcome {
command: "review",
comment_url: out.comment_url,
})
}
Command::Ask(question) => run_ask(cfg, backend, provider_name, repo, pr, &question).await,
Command::Describe => run_describe(cfg, backend, provider_name, repo, pr).await,
Command::ReviewFile(path) => {
run_review_file(cfg, backend, provider_name, repo, pr, &path).await
}
}
}
async fn run_review_file(
cfg: &Config,
backend: &dyn ReviewBackend,
provider_name: &str,
repo: &str,
pr: u64,
path: &str,
) -> Result<CommandOutcome> {
let provider = Provider::from_name(provider_name)?;
let client = reqwest::Client::new();
let meta = provider.get_meta(&client, cfg, repo, pr).await?;
let effective = load_repo_config(&provider, &client, cfg, repo, &meta).await;
let cfg = &effective;
if !crate::diff::path_matches_globs(path, &cfg.include_globs, &cfg.exclude_globs) {
let url = provider
.post_comment(
&client,
cfg,
repo,
pr,
&format!(
"> **/review-file** `{path}`\n\nThat path is excluded by this repo's review file filters, so I won't review it."
),
)
.await?;
return Ok(CommandOutcome {
command: "review-file",
comment_url: url,
});
}
let git_ref = match (meta.head_sha.as_deref(), meta.base_branch.as_deref()) {
(Some(s), _) if !s.is_empty() => s,
(_, Some(b)) if !b.is_empty() => b,
_ => anyhow::bail!("no git ref to fetch `{path}` against"),
};
let content = match provider
.get_file_contents(&client, cfg, repo, git_ref, path)
.await?
{
Some(c) => c,
None => {
let url = provider
.post_comment(
&client,
cfg,
repo,
pr,
&format!(
"> **/review-file** `{path}`\n\nCouldn't find that file at the PR head."
),
)
.await?;
return Ok(CommandOutcome {
command: "review-file",
comment_url: url,
});
}
};
let review = crate::llm::review_file(cfg, backend, path, &content).await?;
let mut findings = review.findings.clone();
findings.retain(|f| f.confidence.unwrap_or(100) >= cfg.min_confidence);
findings.sort_by(|a, b| {
crate::review::severity_rank(&b.severity)
.cmp(&crate::review::severity_rank(&a.severity))
.then(b.confidence.unwrap_or(0).cmp(&a.confidence.unwrap_or(0)))
});
findings.truncate(cfg.max_findings);
let body = render_file_review(path, &review, &findings);
let url = provider.post_comment(&client, cfg, repo, pr, &body).await?;
Ok(CommandOutcome {
command: "review-file",
comment_url: url,
})
}
fn render_file_review(
path: &str,
review: &crate::llm::Review,
findings: &[crate::llm::Finding],
) -> String {
let mut s = format!(
"🔍 **File review — `{path}`**\n\n{}\n\n**Recommendation:** {}",
review.summary.trim(),
review.recommendation.trim()
);
if findings.is_empty() {
s.push_str("\n\nNo issues found.");
} else {
s.push_str("\n\n## Findings");
for f in findings {
let loc = f.line.map(|l| format!(" (line {l})")).unwrap_or_default();
s.push_str(&format!(
"\n- {} **{}** — `{path}`{loc} — {}",
crate::review::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
}
async fn prepared_diff(
provider: &Provider,
client: &reqwest::Client,
cfg: &Config,
repo: &str,
meta: &PrMeta,
) -> Result<(String, String)> {
let raw = provider.get_diff(client, cfg, repo, meta.pr).await?;
let (diff, _dropped) =
crate::diff::filter_diff_by_globs(&raw, &cfg.include_globs, &cfg.exclude_globs);
let (diff, _packed) = crate::diff::pack_diff(&diff, cfg.max_diff_chars);
let structural = if cfg.structural_context && !diff.trim().is_empty() {
crate::structure::structural_context(provider, client, cfg, repo, meta, &diff).await
} else {
String::new()
};
Ok((diff, structural))
}
async fn run_ask(
cfg: &Config,
backend: &dyn ReviewBackend,
provider_name: &str,
repo: &str,
pr: u64,
question: &str,
) -> Result<CommandOutcome> {
let provider = Provider::from_name(provider_name)?;
let client = reqwest::Client::new();
let meta = provider.get_meta(&client, cfg, repo, pr).await?;
let effective = load_repo_config(&provider, &client, cfg, repo, &meta).await;
let cfg = &effective;
let (diff, structural) = prepared_diff(&provider, &client, cfg, repo, &meta).await?;
if diff.trim().is_empty() {
let body = format!(
"> **/ask** {question}\n\nThere are no reviewable source changes in this PR to answer against."
);
let url = provider.post_comment(&client, cfg, repo, pr, &body).await?;
return Ok(CommandOutcome {
command: "ask",
comment_url: url,
});
}
let structural_opt = (!structural.is_empty()).then_some(structural.as_str());
let answer =
crate::llm::answer_question(cfg, backend, &meta, &diff, question, structural_opt).await?;
let body = format!("> **/ask** {question}\n\n{answer}");
let url = provider.post_comment(&client, cfg, repo, pr, &body).await?;
Ok(CommandOutcome {
command: "ask",
comment_url: url,
})
}
async fn run_describe(
cfg: &Config,
backend: &dyn ReviewBackend,
provider_name: &str,
repo: &str,
pr: u64,
) -> Result<CommandOutcome> {
let provider = Provider::from_name(provider_name)?;
let client = reqwest::Client::new();
let meta = provider.get_meta(&client, cfg, repo, pr).await?;
let effective = load_repo_config(&provider, &client, cfg, repo, &meta).await;
let cfg = &effective;
let (diff, structural) = prepared_diff(&provider, &client, cfg, repo, &meta).await?;
if diff.trim().is_empty() {
let url = provider
.post_comment(
&client,
cfg,
repo,
pr,
"No reviewable source changes to describe.",
)
.await?;
return Ok(CommandOutcome {
command: "describe",
comment_url: url,
});
}
let structural_opt = (!structural.is_empty()).then_some(structural.as_str());
let generated = crate::llm::describe_pr(cfg, backend, &meta, &diff, structural_opt).await?;
let merged = merge_description(meta.body.as_deref().unwrap_or(""), &generated);
provider
.update_pr_description(&client, cfg, &meta, &merged)
.await?;
let url = provider
.post_comment(&client, cfg, repo, pr, "📝 Updated the PR description.")
.await?;
Ok(CommandOutcome {
command: "describe",
comment_url: url,
})
}
pub fn merge_description(existing: &str, generated: &str) -> String {
let block = format!("{DESC_START}\n{}\n{DESC_END}", generated.trim());
if let (Some(s), Some(e)) = (existing.find(DESC_START), existing.find(DESC_END)) {
if e > s {
let end = e + DESC_END.len();
return format!("{}{}{}", &existing[..s], block, &existing[end..]);
}
}
if existing.trim().is_empty() {
block
} else {
format!("{block}\n\n{}", existing.trim())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_the_core_commands() {
assert_eq!(parse_command("/review"), Some(Command::Review));
assert_eq!(parse_command("/describe"), Some(Command::Describe));
assert_eq!(
parse_command("/ask does this leak memory?"),
Some(Command::Ask("does this leak memory?".into()))
);
}
#[test]
fn parses_review_file_with_path() {
assert_eq!(
parse_command("/review-file src/auth.rs"),
Some(Command::ReviewFile("src/auth.rs".into()))
);
assert_eq!(
parse_command(" /review-file src/lib.rs "),
Some(Command::ReviewFile("src/lib.rs".into()))
);
}
#[test]
fn review_file_without_path_is_none() {
assert_eq!(parse_command("/review-file"), None);
assert_eq!(parse_command("/review-file "), None);
}
#[test]
fn ask_captures_multiline_question() {
let cmd = parse_command("/ask first line\nsecond line").unwrap();
assert_eq!(cmd, Command::Ask("first line\nsecond line".into()));
}
#[test]
fn ask_with_no_question_is_none() {
assert_eq!(parse_command("/ask"), None);
assert_eq!(parse_command("/ask "), None);
}
#[test]
fn non_commands_are_ignored() {
assert_eq!(parse_command("please /review"), None);
assert_eq!(parse_command("/reviews"), None);
assert_eq!(parse_command("just a comment"), None);
assert_eq!(parse_command(""), None);
}
#[test]
fn leading_and_trailing_whitespace_ok() {
assert_eq!(parse_command(" /review \n"), Some(Command::Review));
}
#[test]
fn merge_into_empty_body() {
let out = merge_description("", "generated text");
assert_eq!(out, format!("{DESC_START}\ngenerated text\n{DESC_END}"));
}
#[test]
fn merge_prepends_to_human_body() {
let out = merge_description("Human notes here.", "gen");
assert!(out.starts_with(DESC_START));
assert!(out.ends_with("Human notes here."));
assert!(out.contains("gen"));
}
#[test]
fn merge_replaces_prior_generated_section() {
let first = merge_description("Keep me.", "old desc");
let edited = format!("PREFIX\n{first}\nSUFFIX");
let again = merge_description(&edited, "new desc");
assert!(again.contains("new desc"));
assert!(!again.contains("old desc"));
assert!(again.starts_with("PREFIX"));
assert!(again.ends_with("SUFFIX"));
assert!(again.contains("Keep me."));
}
}