1use std::path::Path;
26
27use crate::agent::Agent;
28use crate::config::Config;
29use crate::error::Result;
30use crate::jsonx::finding_key;
31use crate::model::{
32 AdjudicationDoc, Finding, IssueRun, Judged, PrView, Review, Severity, Standing, Status,
33};
34use crate::repo::Repo;
35use crate::style::{self, Style};
36use crate::{log, logdim, schema, spar_err};
37
38const REVIEW_ONLY_PROMPT: &str = "\
39Review pull request #{number} against `{base}`: {title}
40
41You are reviewing somebody else's work. Your checkout is detached and read only.
42Do not modify, commit, or push anything. The only thing you produce is findings.
43
44Review thoroughly: correctness, edge cases, error handling, security, and
45whether the change actually does what it claims. Read the surrounding code, do
46not only read the diff.
47
48Label every finding by severity, and be honest about which is which:
49- blocking: this should not merge as is. Real defects only.
50- non-blocking: a genuine improvement that need not gate the merge.
51- nit: style or taste.
52
53Confirm anything you label blocking before you label it. Run the code, reproduce
54the failure, or point at the exact line that breaks, and say in the detail what
55you did to confirm it. Someone else's contribution is on the other end of this.
56An unverified blocking finding costs them a round trip and costs the maintainer
57their credibility, so if you suspect a problem but could not confirm it, say so
58and label it non-blocking.
59
60Set in_scope=false for a real problem that exists but is not caused by this pull
61request. next_action is not used in this mode; set it to hand_back.";
62
63const ADJUDICATE_PROMPT: &str = "\
64Another reviewer examined this same pull request and raised the findings below.
65You have already reviewed it yourself.
66
67For each one, go to the code at the location given and rule on it.
68
69Agree only if you read the code and confirmed the defect is real. Do not defer
70to the other reviewer, and do not agree in order to be agreeable. A finding you
71cannot confirm wastes the contributor's time and the maintainer's, which is the
72thing this whole exercise exists to protect. Disagreeing with a reason is the
73most useful thing you can do here.
74
75Give your own severity even where you agree the defect is real: the other
76reviewer calling something blocking does not make it so.
77
78Findings:
79{findings}";
80
81const REBUT_PROMPT: &str = "\
82You raised the findings below. The other reviewer went to the code and rejected
83each one, for the reason given under it.
84
85For each, set agrees=true only if you stand by the finding, and then give the
86specific evidence that settles it: the line, the input, the failing case. Set
87agrees=false to withdraw it, which is the right answer when they are correct.
88
89Withdrawing costs nothing. Defending a point you cannot substantiate puts it in
90front of a maintainer with two reviewers' names on it, which is worse than never
91having raised it.
92
93Findings, with the objection to each:
94{findings}";
95
96pub fn review_pr(
98 agents: &[Agent],
99 cfg: &Config,
100 repo: &Repo,
101 pr_number: i64,
102 dry_run: bool,
103) -> IssueRun {
104 match review_inner(agents, cfg, repo, pr_number, dry_run) {
105 Ok(state) => state,
106 Err(e) => {
107 log!("PR #{pr_number} review failed: {e}");
108 let mut state = IssueRun::new(pr_number, format!("PR #{pr_number}"));
109 state.status = Status::Error;
110 state.notes.push(e.to_string());
111 state
112 }
113 }
114}
115
116fn review_inner(
117 agents: &[Agent],
118 cfg: &Config,
119 repo: &Repo,
120 pr_number: i64,
121 dry_run: bool,
122) -> Result<IssueRun> {
123 let pr: PrView = repo.pr_view(pr_number)?;
124 if !pr.is_open() {
125 return Err(spar_err!("PR #{pr_number} is {}", pr.state.to_lowercase()));
126 }
127 let base = if pr.base_ref_name.trim().is_empty() {
128 cfg.base_branch().to_string()
129 } else {
130 pr.base_ref_name.clone()
131 };
132
133 let mut state = IssueRun::new(pr_number, pr.title.clone());
134 state.pr = Some(pr.url.clone());
135
136 let work_dir = repo.worktree_for_pr_head(pr_number)?;
137 let outcome = run_phases(
138 agents, cfg, repo, &pr, &base, &work_dir, &mut state, dry_run,
139 );
140 repo.release_review_worktree(pr_number);
141 outcome?;
142 Ok(state)
143}
144
145#[allow(clippy::too_many_arguments)]
146fn run_phases(
147 agents: &[Agent],
148 cfg: &Config,
149 repo: &Repo,
150 pr: &PrView,
151 base: &str,
152 work_dir: &Path,
153 state: &mut IssueRun,
154 dry_run: bool,
155) -> Result<()> {
156 let budget = cfg.loop_cfg.max_rounds;
157
158 log!(
160 "PR #{}: {} reviewing independently",
161 pr.number,
162 agents
163 .iter()
164 .map(Agent::name)
165 .collect::<Vec<_>>()
166 .join(" and ")
167 );
168 let prompt = REVIEW_ONLY_PROMPT
169 .replace("{number}", &pr.number.to_string())
170 .replace("{base}", base)
171 .replace("{title}", &pr.title);
172
173 let reviews = concurrently(agents, |a| {
174 let effort = cfg.effort_for_round(&a.spec, 1);
175 a.review::<Review>(
176 base,
177 &prompt,
178 &schema::review(),
179 work_dir,
180 effort.as_deref(),
181 )
182 });
183
184 let mut by_agent: Vec<(String, Vec<Finding>)> = Vec::new();
185 for (name, result) in reviews {
186 match result {
187 Ok(review) => by_agent.push((name, review.findings)),
188 Err(e) => {
189 logdim!("{name} could not review PR #{}: {e}", pr.number);
193 state
194 .notes
195 .push(format!("{name} did not return a review: {e}"));
196 }
197 }
198 }
199 if by_agent.is_empty() {
200 return Err(spar_err!("neither reviewer returned a usable review"));
201 }
202 if by_agent.len() == 1 {
203 state
204 .notes
205 .push("only one reviewer answered, so nothing was cross-checked".into());
206 }
207
208 let mut judged = corroborate(&by_agent);
209
210 if budget >= 2 && by_agent.len() == 2 {
212 adjudicate(agents, cfg, repo, work_dir, &mut judged, 2)?;
213 } else if budget < 2 {
214 for j in judged.iter_mut() {
215 if j.standing == Standing::Unverified {
216 j.counterpoint = Some("not cross-checked, max_rounds was 1".into());
217 }
218 }
219 }
220
221 if budget >= 3 && judged.iter().any(|j| j.standing == Standing::Disputed) {
223 rebut(agents, cfg, repo, work_dir, &mut judged, 3)?;
224 }
225
226 state.rounds = budget.min(3);
227 finish(repo, pr, state, &judged, dry_run)
228}
229
230fn corroborate(by_agent: &[(String, Vec<Finding>)]) -> Vec<Judged> {
232 let mut judged: Vec<Judged> = Vec::new();
233
234 for (name, findings) in by_agent {
235 for finding in findings {
236 let key = finding_key(&finding.title, &finding.file);
237 match judged
238 .iter_mut()
239 .find(|j| finding_key(&j.finding.title, &j.finding.file) == key)
240 {
241 Some(existing) => {
242 existing.finding.severity = existing.finding.severity.graver(finding.severity);
245 existing.standing = Standing::Corroborated;
246 existing.raised_by = format!("{} and {name}", existing.raised_by);
247 }
248 None => judged.push(Judged {
249 finding: finding.clone(),
250 raised_by: name.clone(),
251 standing: Standing::Unverified,
252 counterpoint: None,
253 }),
254 }
255 }
256 }
257 judged
258}
259
260fn adjudicate(
261 agents: &[Agent],
262 cfg: &Config,
263 repo: &Repo,
264 work_dir: &Path,
265 judged: &mut [Judged],
266 round: u32,
267) -> Result<()> {
268 let pending: Vec<usize> = judged
269 .iter()
270 .enumerate()
271 .filter(|(_, j)| j.standing == Standing::Unverified)
272 .map(|(i, _)| i)
273 .collect();
274 if pending.is_empty() {
275 return Ok(());
276 }
277 log!(
278 "cross-checking {} finding{} raised by one reviewer",
279 pending.len(),
280 plural(pending.len())
281 );
282
283 let answers = concurrently(agents, |adjudicator| {
284 let theirs: Vec<&Judged> = pending
286 .iter()
287 .map(|i| &judged[*i])
288 .filter(|j| j.raised_by != adjudicator.name())
289 .collect();
290 if theirs.is_empty() {
291 return Ok(AdjudicationDoc { verdicts: vec![] });
292 }
293 let listed: Vec<Finding> = theirs.iter().map(|j| j.finding.clone()).collect();
294 let prompt =
295 ADJUDICATE_PROMPT.replace("{findings}", &crate::review::findings_for_prompt(&listed));
296 adjudicator.ask_json::<AdjudicationDoc>(
297 &prompt,
298 &schema::adjudication(),
299 work_dir,
300 cfg.effort_for_round(&adjudicator.spec, round).as_deref(),
301 )
302 });
303
304 for (name, result) in answers {
305 let doc = match result {
306 Ok(doc) => doc,
307 Err(e) => {
308 logdim!("{name} could not adjudicate: {e}");
309 continue;
310 }
311 };
312 for verdict in doc.verdicts {
313 let key = finding_key(&verdict.title, &verdict.file);
314 let Some(target) = judged.iter_mut().find(|j| {
315 j.raised_by != name
316 && (finding_key(&j.finding.title, &j.finding.file) == key
317 || crate::review::same_point(&j.finding.title, &verdict.title))
318 }) else {
319 continue;
320 };
321 if target.standing != Standing::Unverified {
322 continue;
323 }
324 target.counterpoint = Some(style::summary(&verdict.reasoning, &repo.style));
325 if verdict.agrees {
326 target.standing = Standing::Confirmed;
327 target.finding.severity = target.finding.severity.graver(verdict.severity);
331 } else {
332 target.standing = Standing::Disputed;
333 }
334 }
335 }
336 Ok(())
337}
338
339fn rebut(
340 agents: &[Agent],
341 cfg: &Config,
342 repo: &Repo,
343 work_dir: &Path,
344 judged: &mut [Judged],
345 round: u32,
346) -> Result<()> {
347 let disputed: Vec<usize> = judged
348 .iter()
349 .enumerate()
350 .filter(|(_, j)| j.standing == Standing::Disputed)
351 .map(|(i, _)| i)
352 .collect();
353 log!(
354 "{} disputed finding{} going back to whoever raised them",
355 disputed.len(),
356 plural(disputed.len())
357 );
358
359 let answers = concurrently(agents, |author| {
360 let mine: Vec<&Judged> = disputed
361 .iter()
362 .map(|i| &judged[*i])
363 .filter(|j| j.raised_by == author.name())
364 .collect();
365 if mine.is_empty() {
366 return Ok(AdjudicationDoc { verdicts: vec![] });
367 }
368 let listed = mine
369 .iter()
370 .map(|j| {
371 format!(
372 "- [{}] {} ({})\n {}\n OBJECTION: {}",
373 j.finding.severity,
374 j.finding.title,
375 j.finding.where_at(),
376 j.finding.detail,
377 j.counterpoint.as_deref().unwrap_or("(none given)")
378 )
379 })
380 .collect::<Vec<_>>()
381 .join("\n");
382 let prompt = REBUT_PROMPT.replace("{findings}", &listed);
383 author.ask_json::<AdjudicationDoc>(
384 &prompt,
385 &schema::adjudication(),
386 work_dir,
387 cfg.effort_for_round(&author.spec, round).as_deref(),
388 )
389 });
390
391 for (name, result) in answers {
392 let doc = match result {
393 Ok(doc) => doc,
394 Err(e) => {
395 logdim!("{name} could not answer the objections: {e}");
396 continue;
397 }
398 };
399 for verdict in doc.verdicts {
400 let key = finding_key(&verdict.title, &verdict.file);
401 let Some(target) = judged.iter_mut().find(|j| {
402 j.raised_by == name
403 && j.standing == Standing::Disputed
404 && (finding_key(&j.finding.title, &j.finding.file) == key
405 || crate::review::same_point(&j.finding.title, &verdict.title))
406 }) else {
407 continue;
408 };
409 if verdict.agrees {
410 let defence = style::summary(&verdict.reasoning, &repo.style);
412 target.counterpoint = Some(match target.counterpoint.take() {
413 Some(objection) => format!("{objection} | answered: {defence}"),
414 None => defence,
415 });
416 } else {
417 target.standing = Standing::Withdrawn;
418 }
419 }
420 }
421 Ok(())
422}
423
424fn finish(
425 repo: &Repo,
426 pr: &PrView,
427 state: &mut IssueRun,
428 judged: &[Judged],
429 dry_run: bool,
430) -> Result<()> {
431 let blocking = judged
432 .iter()
433 .filter(|j| j.finding.blocks() && j.standing.counts())
434 .count();
435
436 state.status = if blocking == 0 {
437 Status::Clean
438 } else {
439 Status::Reviewed
440 };
441 for j in judged.iter().filter(|j| j.standing == Standing::Disputed) {
442 state.disputes.push(crate::model::Dispute {
443 title: style::title(&j.finding.title, &repo.style),
444 reasoning: j.counterpoint.clone().unwrap_or_default(),
445 });
446 }
447
448 let comment = verdict_comment(judged, &repo.style);
449 if dry_run {
450 println!("\n{comment}\n");
451 log!("dry run, nothing posted to PR #{}", pr.number);
452 return Ok(());
453 }
454 match repo.comment_pr(pr.number, &comment) {
455 Ok(()) => log!(
456 "PR #{}: {}",
457 pr.number,
458 if blocking == 0 {
459 "no blocking findings, review posted".to_string()
460 } else {
461 format!(
462 "{blocking} blocking finding{}, review posted",
463 plural(blocking)
464 )
465 }
466 ),
467 Err(e) => {
468 state.notes.push(format!("could not post the review: {e}"));
469 println!("\n{comment}\n");
470 }
471 }
472 Ok(())
473}
474
475impl Standing {
476 pub fn counts(self) -> bool {
478 matches!(
479 self,
480 Standing::Corroborated | Standing::Confirmed | Standing::Unverified
481 )
482 }
483
484 pub fn label(self) -> &'static str {
485 match self {
486 Standing::Corroborated => "both reviewers raised this independently",
487 Standing::Confirmed => "raised by one reviewer, confirmed by the other",
488 Standing::Disputed => "the reviewers disagree",
489 Standing::Withdrawn => "withdrawn",
490 Standing::Unverified => "raised by one reviewer, not cross-checked",
491 }
492 }
493}
494
495pub fn verdict_comment(judged: &[Judged], style: &Style) -> String {
497 let live: Vec<&Judged> = judged.iter().filter(|j| j.standing.counts()).collect();
498 let pick = |severity: Severity| -> Vec<&Judged> {
499 live.iter()
500 .copied()
501 .filter(|j| j.finding.severity == severity && j.finding.in_scope)
502 .collect()
503 };
504 let blocking = pick(Severity::Blocking);
505 let non_blocking = pick(Severity::NonBlocking);
506 let nits = pick(Severity::Nit);
507 let disputed: Vec<&Judged> = judged
508 .iter()
509 .filter(|j| j.standing == Standing::Disputed)
510 .collect();
511 let withdrawn = judged
512 .iter()
513 .filter(|j| j.standing == Standing::Withdrawn)
514 .count();
515
516 let headline = if blocking.is_empty() {
517 "nothing blocking a merge".to_string()
518 } else {
519 format!("{} blocking", blocking.len())
520 };
521 let mut counts = vec![headline];
522 if !non_blocking.is_empty() {
523 counts.push(format!("{} non-blocking", non_blocking.len()));
524 }
525 if !nits.is_empty() {
526 counts.push(format!("{} nit", nits.len()));
527 }
528 if !disputed.is_empty() {
529 counts.push(format!("{} disputed", disputed.len()));
530 }
531
532 let mut out = vec![format!("Two independent reviews: {}.", counts.join(", "))];
533 if withdrawn == 1 {
534 out.push(
535 "A further point was raised and withdrawn on a second look, and is not listed."
536 .to_string(),
537 );
538 } else if withdrawn > 1 {
539 out.push(format!(
540 "{withdrawn} further points were raised and withdrawn on a second look, and are \
541 not listed."
542 ));
543 }
544
545 let line = |j: &Judged| -> String {
546 let where_at = match j.finding.where_at() {
547 "general" => String::new(),
548 file => format!(" ({file})"),
549 };
550 let detail = style::detail(&j.finding.detail, style);
551 let attested = if j.standing == Standing::Corroborated {
552 " [both]"
553 } else if j.standing == Standing::Unverified {
554 " [one reviewer only]"
555 } else {
556 ""
557 };
558 if detail.is_empty() {
559 format!(
560 "- {}{where_at}{attested}",
561 style::title(&j.finding.title, style)
562 )
563 } else {
564 format!(
565 "- {}{where_at}{attested}. {detail}",
566 style::title(&j.finding.title, style)
567 )
568 }
569 };
570
571 if !blocking.is_empty() {
572 out.push(format!(
573 "needs changing before merge\n{}",
574 blocking
575 .iter()
576 .copied()
577 .map(line)
578 .collect::<Vec<_>>()
579 .join("\n")
580 ));
581 }
582 if !non_blocking.is_empty() {
583 out.push(format!(
584 "worth doing, does not block\n{}",
585 non_blocking
586 .iter()
587 .copied()
588 .map(line)
589 .collect::<Vec<_>>()
590 .join("\n")
591 ));
592 }
593 if !nits.is_empty() {
594 out.push(format!(
595 "nits\n{}",
596 nits.iter()
597 .copied()
598 .map(line)
599 .collect::<Vec<_>>()
600 .join("\n")
601 ));
602 }
603 if !disputed.is_empty() {
604 let lines: Vec<String> = disputed
605 .iter()
606 .map(|j| {
607 format!(
608 "- {} ({}). {} says yes; the other says {}",
609 style::title(&j.finding.title, style),
610 j.finding.where_at(),
611 j.raised_by,
612 style::summary(j.counterpoint.as_deref().unwrap_or("no"), style)
613 )
614 })
615 .collect();
616 out.push(format!(
617 "the reviewers disagree, your call\n{}",
618 lines.join("\n")
619 ));
620 }
621 if blocking.is_empty() && disputed.is_empty() {
622 out.push("Both reviewers are happy for this to merge.".to_string());
623 }
624 out.join("\n\n")
625}
626
627fn plural(n: usize) -> &'static str {
629 if n == 1 {
630 ""
631 } else {
632 "s"
633 }
634}
635
636fn concurrently<T, F>(agents: &[Agent], work: F) -> Vec<(String, Result<T>)>
638where
639 T: Send,
640 F: Fn(&Agent) -> Result<T> + Sync,
641{
642 std::thread::scope(|scope| {
643 let handles: Vec<_> = agents
644 .iter()
645 .map(|agent| scope.spawn(|| (agent.name().to_string(), work(agent))))
646 .collect();
647 handles
648 .into_iter()
649 .zip(agents)
650 .map(|(handle, agent)| {
651 handle.join().unwrap_or_else(|_| {
652 (
653 agent.name().to_string(),
654 Err(spar_err!("thread for '{}' panicked", agent.name())),
655 )
656 })
657 })
658 .collect()
659 })
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665
666 fn finding(severity: &str, title: &str, file: &str) -> Finding {
667 Finding {
668 severity: Severity::parse_lenient(severity).unwrap(),
669 title: title.into(),
670 detail: "why it matters".into(),
671 file: file.into(),
672 in_scope: true,
673 }
674 }
675
676 fn from(name: &str, findings: Vec<Finding>) -> (String, Vec<Finding>) {
677 (name.to_string(), findings)
678 }
679
680 #[test]
686 fn a_finding_both_reviewers_reached_alone_is_corroborated() {
687 let judged = corroborate(&[
688 from(
689 "claude",
690 vec![finding("blocking", "Retry loop spins", "src/net.rs")],
691 ),
692 from(
693 "codex",
694 vec![finding("blocking", "retry loop spins!", "src/net.rs")],
695 ),
696 ]);
697 assert_eq!(1, judged.len(), "the same point must not be listed twice");
698 assert_eq!(Standing::Corroborated, judged[0].standing);
699 assert!(judged[0].raised_by.contains("claude"));
700 assert!(judged[0].raised_by.contains("codex"));
701 }
702
703 #[test]
704 fn a_finding_only_one_reviewer_raised_starts_unverified() {
705 let judged = corroborate(&[
706 from(
707 "claude",
708 vec![finding("blocking", "Only claude saw this", "a.rs")],
709 ),
710 from("codex", vec![]),
711 ]);
712 assert_eq!(Standing::Unverified, judged[0].standing);
713 assert_eq!("claude", judged[0].raised_by);
714 }
715
716 #[test]
717 fn the_same_title_in_a_different_file_is_two_findings() {
718 let judged = corroborate(&[
719 from("claude", vec![finding("nit", "Naming", "a.rs")]),
720 from("codex", vec![finding("nit", "Naming", "b.rs")]),
721 ]);
722 assert_eq!(2, judged.len());
723 }
724
725 #[test]
728 fn disagreement_about_severity_keeps_the_graver_one() {
729 let judged = corroborate(&[
730 from("claude", vec![finding("nit", "Unbounded loop", "a.rs")]),
731 from("codex", vec![finding("blocking", "unbounded loop", "a.rs")]),
732 ]);
733 assert_eq!(Severity::Blocking, judged[0].finding.severity);
734
735 let judged = corroborate(&[
737 from(
738 "claude",
739 vec![finding("blocking", "Unbounded loop", "a.rs")],
740 ),
741 from("codex", vec![finding("nit", "unbounded loop", "a.rs")]),
742 ]);
743 assert_eq!(Severity::Blocking, judged[0].finding.severity);
744 }
745
746 #[test]
747 fn severity_ordering_does_not_depend_on_declaration_order() {
748 assert_eq!(Severity::Blocking, Severity::Blocking.graver(Severity::Nit));
749 assert_eq!(Severity::Blocking, Severity::Nit.graver(Severity::Blocking));
750 assert_eq!(
751 Severity::NonBlocking,
752 Severity::Nit.graver(Severity::NonBlocking)
753 );
754 assert!(Severity::Blocking.rank() > Severity::NonBlocking.rank());
755 assert!(Severity::NonBlocking.rank() > Severity::Nit.rank());
756 }
757
758 #[test]
759 fn a_single_reviewer_still_produces_a_list() {
760 let judged = corroborate(&[from("claude", vec![finding("blocking", "A", "a.rs")])]);
761 assert_eq!(1, judged.len());
762 assert_eq!(Standing::Unverified, judged[0].standing);
763 }
764
765 #[test]
768 fn only_surviving_standings_count() {
769 assert!(Standing::Corroborated.counts());
770 assert!(Standing::Confirmed.counts());
771 assert!(Standing::Unverified.counts());
772 assert!(
773 !Standing::Disputed.counts(),
774 "a disputed point is listed separately"
775 );
776 assert!(
777 !Standing::Withdrawn.counts(),
778 "a withdrawn point is not a finding"
779 );
780 }
781
782 fn judged(standing: Standing, severity: &str, title: &str) -> Judged {
783 Judged {
784 finding: finding(severity, title, "src/net.rs"),
785 raised_by: "claude".into(),
786 standing,
787 counterpoint: None,
788 }
789 }
790
791 #[test]
792 fn a_clean_pr_says_so_in_one_breath() {
793 let text = verdict_comment(&[], &Style::default());
794 assert!(
795 text.starts_with("Two independent reviews: nothing blocking a merge."),
796 "{text}"
797 );
798 assert!(text.contains("happy for this to merge"), "{text}");
799 }
800
801 #[test]
802 fn a_corroborated_blocker_is_marked_as_such() {
803 let text = verdict_comment(
804 &[judged(
805 Standing::Corroborated,
806 "blocking",
807 "Retry loop spins",
808 )],
809 &Style::default(),
810 );
811 assert!(text.contains("needs changing before merge"), "{text}");
812 assert!(text.contains("[both]"), "{text}");
813 assert!(!text.contains("happy for this to merge"), "{text}");
814 }
815
816 #[test]
817 fn an_uncrosschecked_finding_is_flagged_as_one_reviewers_opinion() {
818 let text = verdict_comment(
819 &[judged(
820 Standing::Unverified,
821 "blocking",
822 "Only one saw this",
823 )],
824 &Style::default(),
825 );
826 assert!(text.contains("[one reviewer only]"), "{text}");
827 }
828
829 #[test]
830 fn a_confirmed_finding_carries_no_qualifier() {
831 let text = verdict_comment(
832 &[judged(Standing::Confirmed, "blocking", "Checked and real")],
833 &Style::default(),
834 );
835 assert!(
836 !text.contains("[both]") && !text.contains("[one reviewer only]"),
837 "{text}"
838 );
839 }
840
841 #[test]
844 fn a_withdrawn_finding_never_reaches_the_maintainer() {
845 let text = verdict_comment(
846 &[judged(
847 Standing::Withdrawn,
848 "blocking",
849 "Wrong on a second look",
850 )],
851 &Style::default(),
852 );
853 assert!(!text.contains("Wrong on a second look"), "{text}");
854 assert!(
855 text.contains("raised and withdrawn"),
856 "the count is still owned up to: {text}"
857 );
858 assert!(text.contains("nothing blocking a merge"), "{text}");
859 }
860
861 #[test]
862 fn one_withdrawn_point_reads_as_english() {
863 let text = verdict_comment(
864 &[judged(Standing::Withdrawn, "blocking", "Wrong")],
865 &Style::default(),
866 );
867 assert!(
868 text.contains("A further point was raised and withdrawn"),
869 "{text}"
870 );
871 assert!(!text.contains("point(s)"), "{text}");
872 }
873
874 #[test]
875 fn several_withdrawn_points_read_as_english_too() {
876 let text = verdict_comment(
877 &[
878 judged(Standing::Withdrawn, "blocking", "Wrong one"),
879 judged(Standing::Withdrawn, "nit", "Wrong two"),
880 ],
881 &Style::default(),
882 );
883 assert!(text.contains("2 further points were raised"), "{text}");
884 }
885
886 #[test]
887 fn a_disputed_finding_goes_to_a_person_with_both_sides() {
888 let mut j = judged(Standing::Disputed, "blocking", "Error is swallowed");
889 j.counterpoint = Some("the caller already validates the file".into());
890 let text = verdict_comment(&[j], &Style::default());
891 assert!(text.contains("the reviewers disagree, your call"), "{text}");
892 assert!(text.contains("claude says yes"), "{text}");
893 assert!(text.contains("the caller already validates"), "{text}");
894 assert!(
895 !text.contains("needs changing before merge"),
896 "disputed does not block: {text}"
897 );
898 }
899
900 #[test]
901 fn the_three_severities_are_kept_apart() {
902 let text = verdict_comment(
903 &[
904 judged(Standing::Corroborated, "blocking", "Must fix"),
905 judged(Standing::Confirmed, "non-blocking", "Could improve"),
906 judged(Standing::Confirmed, "nit", "Taste"),
907 ],
908 &Style::default(),
909 );
910 assert!(text.contains("1 blocking, 1 non-blocking, 1 nit"), "{text}");
911 assert!(text.contains("needs changing before merge"), "{text}");
912 assert!(text.contains("worth doing, does not block"), "{text}");
913 assert!(text.contains("nits"), "{text}");
914 }
915
916 #[test]
917 fn a_verbose_reviewer_is_still_clipped() {
918 let mut j = judged(Standing::Corroborated, "blocking", "A real problem");
919 j.finding.detail = "Extremely long explanation. ".repeat(60);
920 let text = verdict_comment(&[j], &Style::default());
921 assert!(text.len() < 700, "{} chars:\n{text}", text.len());
922 }
923
924 #[test]
925 fn an_out_of_scope_finding_does_not_ask_the_contributor_to_fix_it() {
926 let mut j = judged(Standing::Corroborated, "blocking", "Pre-existing bug");
927 j.finding.in_scope = false;
928 let text = verdict_comment(&[j], &Style::default());
929 assert!(!text.contains("needs changing before merge"), "{text}");
930 assert!(text.contains("nothing blocking a merge"), "{text}");
931 }
932}