1use anyhow::Result;
10
11use crate::backend::{OpenRouterBackend, ReviewBackend};
12use crate::config::Config;
13use crate::providers::{PrMeta, Provider};
14use crate::review::{load_repo_config, run_review_with, RunReviewInput};
15
16const DESC_START: &str = "<!-- prbot:describe:start -->";
21const DESC_END: &str = "<!-- prbot:describe:end -->";
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum Command {
26 Review,
28 Ask(String),
30 Describe,
32 ReviewFile(String),
34}
35
36#[derive(Debug, Clone)]
38pub struct CommandOutcome {
39 pub command: &'static str,
41 pub comment_url: Option<String>,
43}
44
45pub fn parse_command(body: &str) -> Option<Command> {
62 let trimmed = body.trim();
63 let mut lines = trimmed.lines();
64 let first = lines.next().unwrap_or("").trim();
65 let (cmd, rest) = match first.split_once(char::is_whitespace) {
66 Some((c, r)) => (c, r.trim()),
67 None => (first, ""),
68 };
69 match cmd {
70 "/review" => Some(Command::Review),
71 "/describe" => Some(Command::Describe),
72 "/review-file" => {
73 let path = rest.trim();
74 (!path.is_empty()).then(|| Command::ReviewFile(path.to_string()))
75 }
76 "/ask" => {
77 let mut q = rest.to_string();
80 let tail: Vec<&str> = lines.collect();
81 if !tail.is_empty() {
82 if !q.is_empty() {
83 q.push('\n');
84 }
85 q.push_str(&tail.join("\n"));
86 }
87 let q = q.trim().to_string();
88 if q.is_empty() {
89 None
90 } else {
91 Some(Command::Ask(q))
92 }
93 }
94 _ => None,
95 }
96}
97
98pub async fn run_command(
107 cfg: &Config,
108 provider_name: &str,
109 repo: &str,
110 pr: u64,
111 cmd: Command,
112) -> Result<CommandOutcome> {
113 run_command_with(cfg, provider_name, repo, pr, cmd, &OpenRouterBackend).await
114}
115
116pub async fn run_command_with(
123 cfg: &Config,
124 provider_name: &str,
125 repo: &str,
126 pr: u64,
127 cmd: Command,
128 backend: &dyn ReviewBackend,
129) -> Result<CommandOutcome> {
130 match cmd {
131 Command::Review => {
132 let out = run_review_with(
133 cfg,
134 RunReviewInput {
135 provider: provider_name.to_string(),
136 repo: repo.to_string(),
137 pr,
138 dry_run: false,
139 placeholder: true,
140 },
141 backend,
142 )
143 .await?;
144 Ok(CommandOutcome {
145 command: "review",
146 comment_url: out.comment_url,
147 })
148 }
149 Command::Ask(question) => run_ask(cfg, backend, provider_name, repo, pr, &question).await,
150 Command::Describe => run_describe(cfg, backend, provider_name, repo, pr).await,
151 Command::ReviewFile(path) => {
152 run_review_file(cfg, backend, provider_name, repo, pr, &path).await
153 }
154 }
155}
156
157async fn run_review_file(
161 cfg: &Config,
162 backend: &dyn ReviewBackend,
163 provider_name: &str,
164 repo: &str,
165 pr: u64,
166 path: &str,
167) -> Result<CommandOutcome> {
168 let provider = Provider::from_name(provider_name)?;
169 let client = reqwest::Client::new();
170 let meta = provider.get_meta(&client, cfg, repo, pr).await?;
171 let effective = load_repo_config(&provider, &client, cfg, repo, &meta).await;
172 let cfg = &effective;
173
174 if !crate::diff::path_matches_globs(path, &cfg.include_globs, &cfg.exclude_globs) {
178 let url = provider
179 .post_comment(
180 &client,
181 cfg,
182 repo,
183 pr,
184 &format!(
185 "> **/review-file** `{path}`\n\nThat path is excluded by this repo's review file filters, so I won't review it."
186 ),
187 )
188 .await?;
189 return Ok(CommandOutcome {
190 command: "review-file",
191 comment_url: url,
192 });
193 }
194
195 let git_ref = match (meta.head_sha.as_deref(), meta.base_branch.as_deref()) {
197 (Some(s), _) if !s.is_empty() => s,
198 (_, Some(b)) if !b.is_empty() => b,
199 _ => anyhow::bail!("no git ref to fetch `{path}` against"),
200 };
201 let content = match provider
202 .get_file_contents(&client, cfg, repo, git_ref, path)
203 .await?
204 {
205 Some(c) => c,
206 None => {
207 let url = provider
208 .post_comment(
209 &client,
210 cfg,
211 repo,
212 pr,
213 &format!(
214 "> **/review-file** `{path}`\n\nCouldn't find that file at the PR head."
215 ),
216 )
217 .await?;
218 return Ok(CommandOutcome {
219 command: "review-file",
220 comment_url: url,
221 });
222 }
223 };
224
225 let review = crate::llm::review_file(cfg, backend, path, &content).await?;
226 let mut findings = review.findings.clone();
228 findings.retain(|f| f.confidence.unwrap_or(100) >= cfg.min_confidence);
229 findings.sort_by(|a, b| {
230 crate::review::severity_rank(&b.severity)
231 .cmp(&crate::review::severity_rank(&a.severity))
232 .then(b.confidence.unwrap_or(0).cmp(&a.confidence.unwrap_or(0)))
234 });
235 findings.truncate(cfg.max_findings);
236
237 let body = render_file_review(path, &review, &findings);
238 let url = provider.post_comment(&client, cfg, repo, pr, &body).await?;
239 Ok(CommandOutcome {
240 command: "review-file",
241 comment_url: url,
242 })
243}
244
245fn render_file_review(
247 path: &str,
248 review: &crate::llm::Review,
249 findings: &[crate::llm::Finding],
250) -> String {
251 let mut s = format!(
252 "🔍 **File review — `{path}`**\n\n{}\n\n**Recommendation:** {}",
253 review.summary.trim(),
254 review.recommendation.trim()
255 );
256 if findings.is_empty() {
257 s.push_str("\n\nNo issues found.");
258 } else {
259 s.push_str("\n\n## Findings");
260 for f in findings {
261 let loc = f.line.map(|l| format!(" (line {l})")).unwrap_or_default();
262 s.push_str(&format!(
263 "\n- {} **{}** — `{path}`{loc} — {}",
264 crate::review::severity_emoji(&f.severity),
265 f.severity.to_uppercase(),
266 f.body.trim()
267 ));
268 }
269 }
270 s.push_str("\n\n_Automated advisory review — a human still owns the merge decision._");
271 s
272}
273
274async fn prepared_diff(
278 provider: &Provider,
279 client: &reqwest::Client,
280 cfg: &Config,
281 repo: &str,
282 meta: &PrMeta,
283) -> Result<(String, String)> {
284 let raw = provider.get_diff(client, cfg, repo, meta.pr).await?;
285 let (diff, _dropped) =
286 crate::diff::filter_diff_by_globs(&raw, &cfg.include_globs, &cfg.exclude_globs);
287 let (diff, _packed) = crate::diff::pack_diff(&diff, cfg.max_diff_chars);
288 let structural = if cfg.structural_context && !diff.trim().is_empty() {
289 crate::structure::structural_context(provider, client, cfg, repo, meta, &diff).await
290 } else {
291 String::new()
292 };
293 Ok((diff, structural))
294}
295
296async fn run_ask(
298 cfg: &Config,
299 backend: &dyn ReviewBackend,
300 provider_name: &str,
301 repo: &str,
302 pr: u64,
303 question: &str,
304) -> Result<CommandOutcome> {
305 let provider = Provider::from_name(provider_name)?;
306 let client = reqwest::Client::new();
307 let meta = provider.get_meta(&client, cfg, repo, pr).await?;
308 let effective = load_repo_config(&provider, &client, cfg, repo, &meta).await;
309 let cfg = &effective;
310
311 let (diff, structural) = prepared_diff(&provider, &client, cfg, repo, &meta).await?;
312 if diff.trim().is_empty() {
313 let body = format!(
314 "> **/ask** {question}\n\nThere are no reviewable source changes in this PR to answer against."
315 );
316 let url = provider.post_comment(&client, cfg, repo, pr, &body).await?;
317 return Ok(CommandOutcome {
318 command: "ask",
319 comment_url: url,
320 });
321 }
322
323 let structural_opt = (!structural.is_empty()).then_some(structural.as_str());
324 let answer =
325 crate::llm::answer_question(cfg, backend, &meta, &diff, question, structural_opt).await?;
326 let body = format!("> **/ask** {question}\n\n{answer}");
328 let url = provider.post_comment(&client, cfg, repo, pr, &body).await?;
329 Ok(CommandOutcome {
330 command: "ask",
331 comment_url: url,
332 })
333}
334
335async fn run_describe(
338 cfg: &Config,
339 backend: &dyn ReviewBackend,
340 provider_name: &str,
341 repo: &str,
342 pr: u64,
343) -> Result<CommandOutcome> {
344 let provider = Provider::from_name(provider_name)?;
345 let client = reqwest::Client::new();
346 let meta = provider.get_meta(&client, cfg, repo, pr).await?;
347 let effective = load_repo_config(&provider, &client, cfg, repo, &meta).await;
348 let cfg = &effective;
349
350 let (diff, structural) = prepared_diff(&provider, &client, cfg, repo, &meta).await?;
351 if diff.trim().is_empty() {
352 let url = provider
353 .post_comment(
354 &client,
355 cfg,
356 repo,
357 pr,
358 "No reviewable source changes to describe.",
359 )
360 .await?;
361 return Ok(CommandOutcome {
362 command: "describe",
363 comment_url: url,
364 });
365 }
366
367 let structural_opt = (!structural.is_empty()).then_some(structural.as_str());
368 let generated = crate::llm::describe_pr(cfg, backend, &meta, &diff, structural_opt).await?;
369 let merged = merge_description(meta.body.as_deref().unwrap_or(""), &generated);
370 provider
371 .update_pr_description(&client, cfg, &meta, &merged)
372 .await?;
373 let url = provider
374 .post_comment(&client, cfg, repo, pr, "📝 Updated the PR description.")
375 .await?;
376 Ok(CommandOutcome {
377 command: "describe",
378 comment_url: url,
379 })
380}
381
382pub fn merge_description(existing: &str, generated: &str) -> String {
402 let block = format!("{DESC_START}\n{}\n{DESC_END}", generated.trim());
403 if let (Some(s), Some(e)) = (existing.find(DESC_START), existing.find(DESC_END)) {
404 if e > s {
405 let end = e + DESC_END.len();
406 return format!("{}{}{}", &existing[..s], block, &existing[end..]);
407 }
408 }
409 if existing.trim().is_empty() {
410 block
411 } else {
412 format!("{block}\n\n{}", existing.trim())
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 #[test]
421 fn parses_the_core_commands() {
422 assert_eq!(parse_command("/review"), Some(Command::Review));
423 assert_eq!(parse_command("/describe"), Some(Command::Describe));
424 assert_eq!(
425 parse_command("/ask does this leak memory?"),
426 Some(Command::Ask("does this leak memory?".into()))
427 );
428 }
429
430 #[test]
431 fn parses_review_file_with_path() {
432 assert_eq!(
433 parse_command("/review-file src/auth.rs"),
434 Some(Command::ReviewFile("src/auth.rs".into()))
435 );
436 assert_eq!(
438 parse_command(" /review-file src/lib.rs "),
439 Some(Command::ReviewFile("src/lib.rs".into()))
440 );
441 }
442
443 #[test]
444 fn review_file_without_path_is_none() {
445 assert_eq!(parse_command("/review-file"), None);
446 assert_eq!(parse_command("/review-file "), None);
447 }
448
449 #[test]
450 fn ask_captures_multiline_question() {
451 let cmd = parse_command("/ask first line\nsecond line").unwrap();
452 assert_eq!(cmd, Command::Ask("first line\nsecond line".into()));
453 }
454
455 #[test]
456 fn ask_with_no_question_is_none() {
457 assert_eq!(parse_command("/ask"), None);
458 assert_eq!(parse_command("/ask "), None);
459 }
460
461 #[test]
462 fn non_commands_are_ignored() {
463 assert_eq!(parse_command("please /review"), None);
464 assert_eq!(parse_command("/reviews"), None);
465 assert_eq!(parse_command("just a comment"), None);
466 assert_eq!(parse_command(""), None);
467 }
468
469 #[test]
470 fn leading_and_trailing_whitespace_ok() {
471 assert_eq!(parse_command(" /review \n"), Some(Command::Review));
472 }
473
474 #[test]
475 fn merge_into_empty_body() {
476 let out = merge_description("", "generated text");
477 assert_eq!(out, format!("{DESC_START}\ngenerated text\n{DESC_END}"));
478 }
479
480 #[test]
481 fn merge_prepends_to_human_body() {
482 let out = merge_description("Human notes here.", "gen");
483 assert!(out.starts_with(DESC_START));
484 assert!(out.ends_with("Human notes here."));
485 assert!(out.contains("gen"));
486 }
487
488 #[test]
489 fn merge_replaces_prior_generated_section() {
490 let first = merge_description("Keep me.", "old desc");
491 let edited = format!("PREFIX\n{first}\nSUFFIX");
493 let again = merge_description(&edited, "new desc");
494 assert!(again.contains("new desc"));
495 assert!(!again.contains("old desc"));
496 assert!(again.starts_with("PREFIX"));
497 assert!(again.ends_with("SUFFIX"));
498 assert!(again.contains("Keep me."));
499 }
500}