1use 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#[derive(Debug, Clone, PartialEq)]
37pub enum ReviewVerdict {
38 Approve,
39 RequestChanges(String),
40 Flag(String),
41}
42
43pub struct ReviewArgs {
45 pub id: String,
47 pub model: Option<String>,
49 pub diff_only: bool,
51}
52
53pub 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 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 let context = build_review_context(beans_dir, &bean, args.diff_only)?;
91
92 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 .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
138fn 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
185fn get_git_diff(beans_dir: &Path) -> Result<String> {
187 let project_root = beans_dir.parent().unwrap_or(beans_dir);
188
189 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 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 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
225pub 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 ReviewVerdict::Approve
255}
256
257pub 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 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 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 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 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#[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 #[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 let output = "VERDICT: request-changes\nsome issue\nVERDICT: approve";
403 let verdict = parse_verdict(output);
404 assert!(matches!(verdict, ReviewVerdict::RequestChanges(_)));
405 }
406
407 #[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 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); }
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 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}