use anyhow::Result;
use crate::config::Config;
use crate::providers::{PrMeta, Provider};
use crate::review::{load_repo_config, run_review, 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,
}
#[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),
"/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> {
match cmd {
Command::Review => {
let out = run_review(
cfg,
RunReviewInput {
provider: provider_name.to_string(),
repo: repo.to_string(),
pr,
dry_run: false,
placeholder: true,
},
)
.await?;
Ok(CommandOutcome {
command: "review",
comment_url: out.comment_url,
})
}
Command::Ask(question) => run_ask(cfg, provider_name, repo, pr, &question).await,
Command::Describe => run_describe(cfg, provider_name, repo, pr).await,
}
}
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,
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(&client, cfg, &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,
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(&client, cfg, &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_three_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 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."));
}
}