Skip to main content

bn/commands/
review.rs

1//! `bn review` — Adversarial post-close review of bean implementations.
2//!
3//! After an agent closes a bean, a review agent checks the implementation against
4//! the bean's spec in a fresh context, providing semantic correctness checking
5//! beyond what verify gates can catch.
6//!
7//! ## Flow
8//! 1. Load bean description + acceptance criteria
9//! 2. Collect git diff (changes since HEAD)
10//! 3. Build a review prompt with spec + diff + verdict instructions
11//! 4. Spawn review agent (using config.review.run or config.run template)
12//! 5. Parse VERDICT from agent output: approve / request-changes / flag
13//! 6. Apply verdict: update labels, optionally reopen bean with notes
14//!
15//! ## Verdicts
16//! - `approve` — implementation correct; adds `reviewed` label
17//! - `request-changes` — issues found; reopens bean with notes, adds `review-failed`
18//! - `flag` — needs human attention; adds `needs-human-review` label, stays closed
19
20use std::path::{Path, PathBuf};
21use std::process::{Command, Stdio};
22
23use anyhow::{Context, Result};
24use chrono::Utc;
25
26use crate::bean::{Bean, Status};
27use crate::config::Config;
28use crate::discovery::find_bean_file;
29use crate::index::Index;
30
31// ---------------------------------------------------------------------------
32// Types
33// ---------------------------------------------------------------------------
34
35/// Verdict returned (or inferred) from the review agent's output.
36#[derive(Debug, Clone, PartialEq)]
37pub enum ReviewVerdict {
38    Approve,
39    RequestChanges(String),
40    Flag(String),
41}
42
43/// Arguments for `cmd_review`.
44pub struct ReviewArgs {
45    /// Bean ID to review.
46    pub id: String,
47    /// Override model (passed as BEAN_REVIEW_MODEL env var to the agent).
48    pub model: Option<String>,
49    /// Include only the git diff, not the full bean description.
50    pub diff_only: bool,
51}
52
53// ---------------------------------------------------------------------------
54// Entry point
55// ---------------------------------------------------------------------------
56
57/// Execute `bn review <id>`.
58///
59/// Spawns a review agent with bean context + git diff, parses its verdict,
60/// and updates the bean (labels, notes, status) accordingly.
61pub fn cmd_review(beans_dir: &Path, args: ReviewArgs) -> Result<()> {
62    let config = Config::load_with_extends(beans_dir)?;
63
64    let bean_path = find_bean_file(beans_dir, &args.id)
65        .with_context(|| format!("Bean not found: {}", args.id))?;
66    let bean =
67        Bean::from_file(&bean_path).with_context(|| format!("Failed to load bean: {}", args.id))?;
68
69    // Enforce max_reopens to prevent infinite review loops.
70    // Count how many times review has previously reopened this bean by
71    // counting "Review failed" markers injected into notes.
72    let max_reopens = config.review.as_ref().map(|r| r.max_reopens).unwrap_or(2);
73
74    let reopen_count = bean
75        .notes
76        .as_deref()
77        .unwrap_or("")
78        .matches("**Review failed**")
79        .count() as u32;
80
81    if reopen_count >= max_reopens {
82        eprintln!(
83            "Review: bean {} has been reopened by review {} time(s) (max {}). Skipping.",
84            args.id, reopen_count, max_reopens
85        );
86        return Ok(());
87    }
88
89    // Build review context (spec + diff)
90    let context = build_review_context(beans_dir, &bean, args.diff_only)?;
91
92    // Resolve review command template (prefer review.run, fall back to run)
93    let run_template = config
94        .review
95        .as_ref()
96        .and_then(|r| r.run.as_ref())
97        .or(config.run.as_ref());
98
99    let Some(template) = run_template else {
100        eprintln!(
101            "Review: no review command configured.\n\
102             Set one with: bn config set review.run \"<command>\"\n\
103             Or configure a default agent: bn init --setup"
104        );
105        return Ok(());
106    };
107
108    let cmd_str = template.replace("{id}", &args.id);
109
110    eprintln!("Review: spawning review agent for bean {}...", args.id);
111
112    let mut child_cmd = Command::new("sh");
113    child_cmd
114        .args(["-c", &cmd_str])
115        // Pass full context via env so agent can read it from $BEAN_REVIEW_CONTEXT
116        .env("BEAN_REVIEW_CONTEXT", &context)
117        .env("BEAN_REVIEW_ID", &args.id)
118        .env("BEAN_REVIEW_MODE", "1")
119        .stdout(Stdio::piped())
120        .stderr(Stdio::inherit());
121
122    if let Some(ref model) = args.model {
123        child_cmd.env("BEAN_REVIEW_MODEL", model);
124    }
125
126    let output = child_cmd
127        .output()
128        .with_context(|| format!("Failed to spawn review agent: {}", cmd_str))?;
129
130    let stdout = String::from_utf8_lossy(&output.stdout);
131    let verdict = parse_verdict(&stdout);
132
133    apply_verdict(beans_dir, &args.id, &bean_path, verdict)?;
134
135    Ok(())
136}
137
138// ---------------------------------------------------------------------------
139// Context builder
140// ---------------------------------------------------------------------------
141
142/// Build the review prompt: bean spec + git diff + verdict instructions.
143fn build_review_context(beans_dir: &Path, bean: &Bean, diff_only: bool) -> Result<String> {
144    let mut ctx = String::new();
145
146    if !diff_only {
147        ctx.push_str("# Bean Spec\n\n");
148        ctx.push_str(&format!("**ID**: {}\n", bean.id));
149        ctx.push_str(&format!("**Title**: {}\n\n", bean.title));
150
151        if let Some(ref desc) = bean.description {
152            ctx.push_str("## Description\n\n");
153            ctx.push_str(desc);
154            ctx.push_str("\n\n");
155        }
156
157        if let Some(ref acceptance) = bean.acceptance {
158            ctx.push_str("## Acceptance Criteria\n\n");
159            ctx.push_str(acceptance);
160            ctx.push_str("\n\n");
161        }
162    }
163
164    let git_diff = get_git_diff(beans_dir)?;
165    if git_diff.is_empty() {
166        ctx.push_str("# Git Diff\n\n(no uncommitted changes detected)\n\n");
167    } else {
168        ctx.push_str("# Git Diff\n\n```diff\n");
169        ctx.push_str(&git_diff);
170        ctx.push_str("\n```\n\n");
171    }
172
173    ctx.push_str("# Review Instructions\n\n");
174    ctx.push_str(
175        "Review the implementation above against the spec. Output your verdict as one of:\n\
176         - `VERDICT: approve` — implementation is correct and complete\n\
177         - `VERDICT: request-changes` — implementation has issues that must be fixed\n\
178         - `VERDICT: flag` — implementation needs human attention (unusual issues)\n\n\
179         Follow the verdict line with your reasoning and specific notes.\n",
180    );
181
182    Ok(ctx)
183}
184
185/// Get the current git diff (uncommitted changes in the working tree).
186fn get_git_diff(beans_dir: &Path) -> Result<String> {
187    let project_root = beans_dir.parent().unwrap_or(beans_dir);
188
189    // Try staged + unstaged diff against HEAD
190    let output = Command::new("git")
191        .args(["diff", "HEAD"])
192        .current_dir(project_root)
193        .output();
194
195    match output {
196        Ok(out) if out.status.success() => {
197            let diff = String::from_utf8_lossy(&out.stdout).into_owned();
198            if !diff.is_empty() {
199                return Ok(diff);
200            }
201            // HEAD diff empty — maybe there are staged changes only
202            let staged = Command::new("git")
203                .args(["diff", "--cached"])
204                .current_dir(project_root)
205                .output();
206            if let Ok(s) = staged {
207                return Ok(String::from_utf8_lossy(&s.stdout).into_owned());
208            }
209            Ok(String::new())
210        }
211        _ => {
212            // Fallback: plain diff (no commits yet)
213            let out2 = Command::new("git")
214                .args(["diff"])
215                .current_dir(project_root)
216                .output();
217            match out2 {
218                Ok(o) => Ok(String::from_utf8_lossy(&o.stdout).into_owned()),
219                Err(_) => Ok(String::new()),
220            }
221        }
222    }
223}
224
225// ---------------------------------------------------------------------------
226// Verdict parsing + application
227// ---------------------------------------------------------------------------
228
229/// Parse the review agent's output for a VERDICT keyword.
230///
231/// Looks for `VERDICT: approve`, `VERDICT: request-changes`, or `VERDICT: flag`
232/// (case-insensitive). Everything after the verdict line is treated as notes.
233/// Defaults to Approve if no verdict keyword is found.
234pub fn parse_verdict(output: &str) -> ReviewVerdict {
235    let lower = output.to_lowercase();
236
237    if let Some(pos) = lower.find("verdict: request-changes") {
238        let after = &output[pos..];
239        let notes: String = after.lines().skip(1).collect::<Vec<_>>().join("\n");
240        return ReviewVerdict::RequestChanges(notes.trim().to_string());
241    }
242
243    if let Some(pos) = lower.find("verdict: flag") {
244        let after = &output[pos..];
245        let notes: String = after.lines().skip(1).collect::<Vec<_>>().join("\n");
246        return ReviewVerdict::Flag(notes.trim().to_string());
247    }
248
249    if lower.contains("verdict: approve") {
250        return ReviewVerdict::Approve;
251    }
252
253    // No explicit verdict — default to approve to avoid blocking progress
254    ReviewVerdict::Approve
255}
256
257/// Apply the parsed verdict to the bean: update labels, notes, and status.
258pub fn apply_verdict(
259    beans_dir: &Path,
260    id: &str,
261    bean_path: &PathBuf,
262    verdict: ReviewVerdict,
263) -> Result<()> {
264    let mut bean =
265        Bean::from_file(bean_path).with_context(|| format!("Failed to reload bean: {}", id))?;
266
267    match verdict {
268        ReviewVerdict::Approve => {
269            eprintln!("Review: ✓ APPROVED  bean {}", id);
270            if !bean.labels.contains(&"reviewed".to_string()) {
271                bean.labels.push("reviewed".to_string());
272            }
273            // Remove review-failed if it was set from a previous review cycle
274            bean.labels.retain(|l| l != "review-failed");
275            bean.updated_at = Utc::now();
276            bean.to_file(bean_path)
277                .with_context(|| format!("Failed to save bean: {}", id))?;
278        }
279
280        ReviewVerdict::RequestChanges(ref notes) => {
281            eprintln!(
282                "Review: ✗ REQUEST-CHANGES  bean {} — reopening for revision",
283                id
284            );
285            if !bean.labels.contains(&"review-failed".to_string()) {
286                bean.labels.push("review-failed".to_string());
287            }
288            bean.labels.retain(|l| l != "reviewed");
289
290            // Append review notes so the next agent sees them
291            let review_note = format!(
292                "\n---\n**Review failed** ({})\n\n{}\n",
293                Utc::now().format("%Y-%m-%d %H:%M UTC"),
294                notes
295            );
296            match bean.notes {
297                Some(ref mut existing) => existing.push_str(&review_note),
298                None => bean.notes = Some(review_note),
299            }
300
301            // Reopen the bean
302            bean.status = Status::Open;
303            bean.closed_at = None;
304            bean.close_reason = None;
305            bean.updated_at = Utc::now();
306            bean.to_file(bean_path)
307                .with_context(|| format!("Failed to save bean: {}", id))?;
308        }
309
310        ReviewVerdict::Flag(ref notes) => {
311            eprintln!("Review: ⚑ FLAGGED  bean {} — needs human review", id);
312            if !bean.labels.contains(&"needs-human-review".to_string()) {
313                bean.labels.push("needs-human-review".to_string());
314            }
315            let review_note = format!(
316                "\n---\n**Flagged for human review** ({})\n\n{}\n",
317                Utc::now().format("%Y-%m-%d %H:%M UTC"),
318                notes
319            );
320            match bean.notes {
321                Some(ref mut existing) => existing.push_str(&review_note),
322                None => bean.notes = Some(review_note),
323            }
324            bean.updated_at = Utc::now();
325            bean.to_file(bean_path)
326                .with_context(|| format!("Failed to save bean: {}", id))?;
327        }
328    }
329
330    // Rebuild index so status/labels are reflected immediately
331    let index = Index::build(beans_dir).context("Failed to rebuild index after review")?;
332    index
333        .save(beans_dir)
334        .context("Failed to save index after review")?;
335
336    Ok(())
337}
338
339// ---------------------------------------------------------------------------
340// Tests
341// ---------------------------------------------------------------------------
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use crate::bean::Bean;
347    use crate::util::title_to_slug;
348    use std::fs;
349    use tempfile::TempDir;
350
351    fn setup() -> (TempDir, PathBuf) {
352        let dir = TempDir::new().unwrap();
353        let beans_dir = dir.path().join(".beans");
354        fs::create_dir_all(&beans_dir).unwrap();
355        (dir, beans_dir)
356    }
357
358    // --- parse_verdict ---
359
360    #[test]
361    fn parse_verdict_approve() {
362        let output = "The code looks good.\nVERDICT: approve\nWell done.";
363        assert_eq!(parse_verdict(output), ReviewVerdict::Approve);
364    }
365
366    #[test]
367    fn parse_verdict_approve_case_insensitive() {
368        let output = "verdict: APPROVE";
369        assert_eq!(parse_verdict(output), ReviewVerdict::Approve);
370    }
371
372    #[test]
373    fn parse_verdict_request_changes_captures_notes() {
374        let output =
375            "Issues found.\nVERDICT: request-changes\nMissing error handling.\nAlso add tests.";
376        let verdict = parse_verdict(output);
377        assert!(matches!(verdict, ReviewVerdict::RequestChanges(_)));
378        if let ReviewVerdict::RequestChanges(notes) = verdict {
379            assert!(notes.contains("Missing error handling"));
380        }
381    }
382
383    #[test]
384    fn parse_verdict_flag_captures_notes() {
385        let output = "Unusual.\nVERDICT: flag\nPlease check manually.";
386        let verdict = parse_verdict(output);
387        assert!(matches!(verdict, ReviewVerdict::Flag(_)));
388        if let ReviewVerdict::Flag(notes) = verdict {
389            assert!(notes.contains("Please check manually"));
390        }
391    }
392
393    #[test]
394    fn parse_verdict_defaults_to_approve_when_no_keyword() {
395        let output = "No verdict keyword present in this output.";
396        assert_eq!(parse_verdict(output), ReviewVerdict::Approve);
397    }
398
399    #[test]
400    fn parse_verdict_request_changes_takes_priority_over_approve() {
401        // If both appear, request-changes wins (searched first)
402        let output = "VERDICT: request-changes\nsome issue\nVERDICT: approve";
403        let verdict = parse_verdict(output);
404        assert!(matches!(verdict, ReviewVerdict::RequestChanges(_)));
405    }
406
407    // --- apply_verdict ---
408
409    #[test]
410    fn apply_verdict_approve_adds_reviewed_label() {
411        let (_dir, beans_dir) = setup();
412        let bean = Bean::new("1", "Test bean");
413        let slug = title_to_slug(&bean.title);
414        let path = beans_dir.join(format!("1-{}.md", slug));
415        bean.to_file(&path).unwrap();
416
417        apply_verdict(&beans_dir, "1", &path, ReviewVerdict::Approve).unwrap();
418
419        let updated = Bean::from_file(&path).unwrap();
420        assert!(updated.labels.contains(&"reviewed".to_string()));
421    }
422
423    #[test]
424    fn apply_verdict_approve_removes_review_failed_label() {
425        let (_dir, beans_dir) = setup();
426        let mut bean = Bean::new("1", "Test bean");
427        bean.labels.push("review-failed".to_string());
428        let slug = title_to_slug(&bean.title);
429        let path = beans_dir.join(format!("1-{}.md", slug));
430        bean.to_file(&path).unwrap();
431
432        apply_verdict(&beans_dir, "1", &path, ReviewVerdict::Approve).unwrap();
433
434        let updated = Bean::from_file(&path).unwrap();
435        assert!(!updated.labels.contains(&"review-failed".to_string()));
436        assert!(updated.labels.contains(&"reviewed".to_string()));
437    }
438
439    #[test]
440    fn apply_verdict_request_changes_reopens_bean() {
441        let (_dir, beans_dir) = setup();
442        let mut bean = Bean::new("1", "Test bean");
443        bean.status = Status::Closed;
444        bean.closed_at = Some(Utc::now());
445        let slug = title_to_slug(&bean.title);
446        let path = beans_dir.join(format!("1-{}.md", slug));
447        bean.to_file(&path).unwrap();
448
449        apply_verdict(
450            &beans_dir,
451            "1",
452            &path,
453            ReviewVerdict::RequestChanges("Fix error handling".to_string()),
454        )
455        .unwrap();
456
457        let updated = Bean::from_file(&path).unwrap();
458        assert_eq!(updated.status, Status::Open);
459        assert!(updated.closed_at.is_none());
460        assert!(updated.labels.contains(&"review-failed".to_string()));
461        assert!(!updated.labels.contains(&"reviewed".to_string()));
462        assert!(updated.notes.unwrap().contains("Review failed"));
463    }
464
465    #[test]
466    fn apply_verdict_request_changes_injects_notes() {
467        let (_dir, beans_dir) = setup();
468        let bean = Bean::new("1", "Test bean");
469        let slug = title_to_slug(&bean.title);
470        let path = beans_dir.join(format!("1-{}.md", slug));
471        bean.to_file(&path).unwrap();
472
473        apply_verdict(
474            &beans_dir,
475            "1",
476            &path,
477            ReviewVerdict::RequestChanges("You forgot to handle EOF".to_string()),
478        )
479        .unwrap();
480
481        let updated = Bean::from_file(&path).unwrap();
482        let notes = updated.notes.unwrap();
483        assert!(notes.contains("Review failed"));
484        assert!(notes.contains("You forgot to handle EOF"));
485    }
486
487    #[test]
488    fn apply_verdict_flag_adds_needs_human_review_label() {
489        let (_dir, beans_dir) = setup();
490        let mut bean = Bean::new("1", "Test bean");
491        // Flag keeps bean in its current state — test with Closed to show it stays closed
492        bean.status = Status::Closed;
493        bean.closed_at = Some(Utc::now());
494        let slug = title_to_slug(&bean.title);
495        let path = beans_dir.join(format!("1-{}.md", slug));
496        bean.to_file(&path).unwrap();
497
498        apply_verdict(
499            &beans_dir,
500            "1",
501            &path,
502            ReviewVerdict::Flag("Security concern".to_string()),
503        )
504        .unwrap();
505
506        let updated = Bean::from_file(&path).unwrap();
507        assert!(updated.labels.contains(&"needs-human-review".to_string()));
508        assert_eq!(updated.status, Status::Closed); // not reopened — stays as-is
509    }
510
511    #[test]
512    fn apply_verdict_flag_injects_notes() {
513        let (_dir, beans_dir) = setup();
514        let bean = Bean::new("1", "Test bean");
515        let slug = title_to_slug(&bean.title);
516        let path = beans_dir.join(format!("1-{}.md", slug));
517        bean.to_file(&path).unwrap();
518
519        apply_verdict(
520            &beans_dir,
521            "1",
522            &path,
523            ReviewVerdict::Flag("Potential race condition".to_string()),
524        )
525        .unwrap();
526
527        let updated = Bean::from_file(&path).unwrap();
528        let notes = updated.notes.unwrap();
529        assert!(notes.contains("Flagged for human review"));
530        assert!(notes.contains("Potential race condition"));
531    }
532
533    #[test]
534    fn apply_verdict_appends_to_existing_notes() {
535        let (_dir, beans_dir) = setup();
536        let mut bean = Bean::new("1", "Test bean");
537        bean.notes = Some("Existing notes here.".to_string());
538        let slug = title_to_slug(&bean.title);
539        let path = beans_dir.join(format!("1-{}.md", slug));
540        bean.to_file(&path).unwrap();
541
542        apply_verdict(
543            &beans_dir,
544            "1",
545            &path,
546            ReviewVerdict::Flag("New flag note".to_string()),
547        )
548        .unwrap();
549
550        let updated = Bean::from_file(&path).unwrap();
551        let notes = updated.notes.unwrap();
552        assert!(notes.contains("Existing notes here."));
553        assert!(notes.contains("Flagged for human review"));
554    }
555
556    #[test]
557    fn max_reopens_check_prevents_infinite_loops() {
558        // Simulate that after max_reopens, review is skipped.
559        // The count is based on "**Review failed**" markers in notes.
560        let notes = "**Review failed** (2026-01-01)\n\nFix X\n\n---\n**Review failed** (2026-01-02)\n\nFix Y\n";
561        let count = notes.matches("**Review failed**").count() as u32;
562        let max: u32 = 2;
563        assert!(count >= max, "Should skip review when max_reopens reached");
564    }
565}