Skip to main content

pr_review_core/
command.rs

1//! PR comment commands (T3.9): `/review`, `/ask <question>`, and `/describe`.
2//!
3//! A single entry point — [`run_command`] — lets a bot binary route any
4//! recognized comment command through the core without wiring each one itself.
5//! [`parse_command`] turns a raw comment body into a [`Command`]; the caller is
6//! responsible for the gating that's cheap to do provider-side (the event is a
7//! newly-created comment on a PR).
8
9use 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
16/// HTML-comment delimiters wrapping the AI-generated section of a PR description
17/// so `/describe` can regenerate idempotently while preserving human-written
18/// content around it. (GitHub/GitLab hide these; Bitbucket renders them literally
19/// — a minor cosmetic quirk on that provider.)
20const DESC_START: &str = "<!-- prbot:describe:start -->";
21const DESC_END: &str = "<!-- prbot:describe:end -->";
22
23/// A recognized PR comment command.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum Command {
26    /// `/review` — (re)run the full review.
27    Review,
28    /// `/ask <question>` — answer a question about the PR.
29    Ask(String),
30    /// `/describe` — (re)generate the PR description from the diff.
31    Describe,
32    /// `/review-file <path>` — deep-review an entire file at the PR head.
33    ReviewFile(String),
34}
35
36/// What a command run did, for the caller to log.
37#[derive(Debug, Clone)]
38pub struct CommandOutcome {
39    /// `"review"`, `"ask"`, `"describe"`, or `"review-file"`.
40    pub command: &'static str,
41    /// URL of the comment posted (or the review summary), when available.
42    pub comment_url: Option<String>,
43}
44
45/// Parse a comment body into a [`Command`], or `None` if it isn't one.
46///
47/// The command must be the first token of the comment. `/ask` takes the rest of
48/// the comment (which may span multiple lines) as its question; an empty question
49/// yields `None`. Surrounding whitespace is ignored.
50///
51/// # Examples
52/// ```
53/// # use pr_review_core::command::{parse_command, Command};
54/// assert_eq!(parse_command("/review"), Some(Command::Review));
55/// assert_eq!(parse_command("  /describe \n"), Some(Command::Describe));
56/// assert_eq!(parse_command("/ask why is this safe?"), Some(Command::Ask("why is this safe?".into())));
57/// assert_eq!(parse_command("/ask"), None);            // no question
58/// assert_eq!(parse_command("please /review"), None);  // not the first token
59/// assert_eq!(parse_command("/reviews"), None);        // no fuzzy match
60/// ```
61pub 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            // The question is the remainder of the first line plus any following
78            // lines, so a multi-line question survives intact.
79            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
98/// Execute a parsed command end-to-end.
99///
100/// `/review` delegates to [`run_review`] (with an instant "Reviewing…"
101/// placeholder). `/ask` and `/describe` fetch the diff, respect a per-repo
102/// `.prbot.toml`, and post their result.
103///
104/// # Errors
105/// On unknown provider, or any provider/LLM API failure.
106pub 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
116/// Like [`run_command`] but with a caller-supplied [`ReviewBackend`], so `/review`,
117/// `/ask`, and `/describe` all run on the same backend (e.g. an agent CLI) instead
118/// of always OpenRouter.
119///
120/// # Errors
121/// On unknown provider, or any provider/backend failure.
122pub 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
157/// `/review-file <path>`: deep-review an entire file at the PR head and post the
158/// findings as a summary comment. Findings can anchor to any line in the file, so
159/// they're reported as text (a PR only accepts inline comments on diff lines).
160async 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    // Honour the repo's include/exclude globs so `/review-file` can't be used to
175    // pull in a file the diff-based review would have filtered out (e.g. secrets
176    // or vendored paths excluded via `.prbot.toml`).
177    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    // Fetch the file at the PR head (fall back to the base branch if no head SHA).
196    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    // Same post-processing as a diff review: confidence floor, severity sort, cap.
227    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            // Secondary key: higher confidence first — matches the `/review` path.
233            .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
245/// Render a `/review-file` result as a summary comment.
246fn 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
274/// Fetch the PR diff and prepare it exactly as the review path does — glob
275/// filter, size packing, and (optionally) structural context — so `/ask` and
276/// `/describe` reason over the same trimmed, budgeted diff the reviewer sees.
277async 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
296/// `/ask`: answer a question about the PR and post it as a reply comment.
297async 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    // Echo the question so the thread reads as a Q&A exchange.
327    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
335/// `/describe`: generate a PR description, merge it into the existing body
336/// (preserving human-written content), update the PR, and confirm in a comment.
337async 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
382/// Merge a freshly-generated description into an existing PR body.
383///
384/// The generated text is wrapped between [`DESC_START`]/[`DESC_END`] markers. If
385/// those markers already exist (a prior `/describe`), only the section between
386/// them is replaced, preserving anything the author wrote around it. Otherwise
387/// the marked block is prepended to the existing body (or becomes the whole body
388/// when it was empty).
389///
390/// # Examples
391/// ```
392/// # use pr_review_core::command::merge_description;
393/// // First run on an empty body: just the generated block.
394/// let out = merge_description("", "## Summary\nDoes a thing.");
395/// assert!(out.contains("Does a thing."));
396/// // Re-run replaces only the generated section, keeping human notes.
397/// let again = merge_description(&out, "## Summary\nUpdated.");
398/// assert!(again.contains("Updated."));
399/// assert!(!again.contains("Does a thing."));
400/// ```
401pub 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        // Extra surrounding whitespace is trimmed off the path.
437        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        // Human edits above and below the block are preserved on re-run.
492        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}