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 crate::logging::warn(format!(
207 "only {} answered on PR #{}. Nothing was cross-checked, so these findings carry one \
208 model's judgement rather than two.",
209 by_agent[0].0, pr.number
210 ));
211 state
212 .notes
213 .push("only one reviewer answered, so nothing was cross-checked".into());
214 }
215
216 let mut judged = corroborate(&by_agent);
217
218 if budget >= 2 && by_agent.len() == 2 {
220 adjudicate(agents, cfg, repo, work_dir, &mut judged, 2)?;
221 } else if budget < 2 {
222 for j in judged.iter_mut() {
223 if j.standing == Standing::Unverified {
224 j.counterpoint = Some("not cross-checked, max_rounds was 1".into());
225 }
226 }
227 }
228
229 if budget >= 3 && judged.iter().any(|j| j.standing == Standing::Disputed) {
231 rebut(agents, cfg, repo, work_dir, &mut judged, 3)?;
232 }
233
234 state.rounds = budget.min(3);
235 finish(repo, pr, state, &judged, dry_run)
236}
237
238fn corroborate(by_agent: &[(String, Vec<Finding>)]) -> Vec<Judged> {
240 let mut judged: Vec<Judged> = Vec::new();
241
242 for (name, findings) in by_agent {
243 for finding in findings {
244 let key = finding_key(&finding.title, &finding.file);
245 match judged
246 .iter_mut()
247 .find(|j| finding_key(&j.finding.title, &j.finding.file) == key)
248 {
249 Some(existing) => {
250 existing.finding.severity = existing.finding.severity.graver(finding.severity);
253 existing.standing = Standing::Corroborated;
254 existing.raised_by = format!("{} and {name}", existing.raised_by);
255 }
256 None => judged.push(Judged {
257 finding: finding.clone(),
258 raised_by: name.clone(),
259 standing: Standing::Unverified,
260 counterpoint: None,
261 defence: None,
262 }),
263 }
264 }
265 }
266 judged
267}
268
269fn adjudicate(
270 agents: &[Agent],
271 cfg: &Config,
272 repo: &Repo,
273 work_dir: &Path,
274 judged: &mut [Judged],
275 round: u32,
276) -> Result<()> {
277 let pending: Vec<usize> = judged
278 .iter()
279 .enumerate()
280 .filter(|(_, j)| j.standing == Standing::Unverified)
281 .map(|(i, _)| i)
282 .collect();
283 if pending.is_empty() {
284 return Ok(());
285 }
286 log!(
287 "cross-checking {} finding{} raised by one reviewer",
288 pending.len(),
289 plural(pending.len())
290 );
291
292 let answers = concurrently(agents, |adjudicator| {
293 let theirs: Vec<&Judged> = pending
295 .iter()
296 .map(|i| &judged[*i])
297 .filter(|j| j.raised_by != adjudicator.name())
298 .collect();
299 if theirs.is_empty() {
300 return Ok(AdjudicationDoc { verdicts: vec![] });
301 }
302 let listed: Vec<Finding> = theirs.iter().map(|j| j.finding.clone()).collect();
303 let prompt =
304 ADJUDICATE_PROMPT.replace("{findings}", &crate::review::findings_for_prompt(&listed));
305 adjudicator.ask_json::<AdjudicationDoc>(
306 &prompt,
307 &schema::adjudication(),
308 work_dir,
309 cfg.effort_for_round(&adjudicator.spec, round).as_deref(),
310 )
311 });
312
313 for (name, result) in answers {
314 let doc = match result {
315 Ok(doc) => doc,
316 Err(e) => {
317 logdim!("{name} could not adjudicate: {e}");
318 continue;
319 }
320 };
321 for verdict in doc.verdicts {
322 let key = finding_key(&verdict.title, &verdict.file);
323 let Some(target) = judged.iter_mut().find(|j| {
324 j.raised_by != name
325 && (finding_key(&j.finding.title, &j.finding.file) == key
326 || crate::review::same_point(&j.finding.title, &verdict.title))
327 }) else {
328 continue;
329 };
330 if target.standing != Standing::Unverified {
331 continue;
332 }
333 target.counterpoint = Some(style::summary(&verdict.reasoning, &repo.style));
334 if verdict.agrees {
335 target.standing = Standing::Confirmed;
336 target.finding.severity = target.finding.severity.graver(verdict.severity);
340 } else {
341 target.standing = Standing::Disputed;
342 }
343 }
344 }
345 Ok(())
346}
347
348fn rebut(
349 agents: &[Agent],
350 cfg: &Config,
351 repo: &Repo,
352 work_dir: &Path,
353 judged: &mut [Judged],
354 round: u32,
355) -> Result<()> {
356 let disputed: Vec<usize> = judged
357 .iter()
358 .enumerate()
359 .filter(|(_, j)| j.standing == Standing::Disputed)
360 .map(|(i, _)| i)
361 .collect();
362 log!(
363 "{} disputed finding{} going back to whoever raised them",
364 disputed.len(),
365 plural(disputed.len())
366 );
367
368 let answers = concurrently(agents, |author| {
369 let mine: Vec<&Judged> = disputed
370 .iter()
371 .map(|i| &judged[*i])
372 .filter(|j| j.raised_by == author.name())
373 .collect();
374 if mine.is_empty() {
375 return Ok(AdjudicationDoc { verdicts: vec![] });
376 }
377 let listed = mine
378 .iter()
379 .map(|j| {
380 format!(
381 "- [{}] {} ({})\n {}\n OBJECTION: {}",
382 j.finding.severity,
383 j.finding.title,
384 j.finding.where_at(),
385 j.finding.detail,
386 j.counterpoint.as_deref().unwrap_or("(none given)")
387 )
388 })
389 .collect::<Vec<_>>()
390 .join("\n");
391 let prompt = REBUT_PROMPT.replace("{findings}", &listed);
392 author.ask_json::<AdjudicationDoc>(
393 &prompt,
394 &schema::adjudication(),
395 work_dir,
396 cfg.effort_for_round(&author.spec, round).as_deref(),
397 )
398 });
399
400 for (name, result) in answers {
401 let doc = match result {
402 Ok(doc) => doc,
403 Err(e) => {
404 logdim!("{name} could not answer the objections: {e}");
405 continue;
406 }
407 };
408 for verdict in doc.verdicts {
409 let key = finding_key(&verdict.title, &verdict.file);
410 let Some(target) = judged.iter_mut().find(|j| {
411 j.raised_by == name
412 && j.standing == Standing::Disputed
413 && (finding_key(&j.finding.title, &j.finding.file) == key
414 || crate::review::same_point(&j.finding.title, &verdict.title))
415 }) else {
416 continue;
417 };
418 if verdict.agrees {
419 target.defence = Some(style::sentence(&verdict.reasoning, &repo.style));
422 } else {
423 target.standing = Standing::Withdrawn;
424 }
425 }
426 }
427 Ok(())
428}
429
430fn finish(
431 repo: &Repo,
432 pr: &PrView,
433 state: &mut IssueRun,
434 judged: &[Judged],
435 dry_run: bool,
436) -> Result<()> {
437 let blocking = judged
438 .iter()
439 .filter(|j| j.finding.blocks() && j.standing.counts())
440 .count();
441
442 state.status = if blocking == 0 {
443 Status::Clean
444 } else {
445 Status::Reviewed
446 };
447 for j in judged.iter().filter(|j| j.standing == Standing::Disputed) {
448 state.disputes.push(crate::model::Dispute {
449 title: style::title(&j.finding.title, &repo.style),
450 reasoning: j.counterpoint.clone().unwrap_or_default(),
451 });
452 }
453
454 let comment = verdict_comment(judged, &repo.style);
455 let silent = dry_run || repo.style.pr_comments == crate::config::PrComments::None;
459 if silent {
460 println!("\n{comment}\n");
461 let why = if dry_run {
462 "dry run"
463 } else {
464 "pr_comments is none"
465 };
466 match repo.save_pending_comment(pr.number, &comment) {
467 Ok(path) => log!(
468 "{why}, nothing posted. Saved to {}. Post it with `spar post {}`, or edit that \
469 file first.",
470 path.display(),
471 pr.number
472 ),
473 Err(e) => logdim!("{why}, nothing posted, and could not save it: {e}"),
474 }
475 return Ok(());
476 }
477 match repo.comment_pr(pr.number, &comment) {
478 Ok(()) => log!(
479 "PR #{}: {}",
480 pr.number,
481 if blocking == 0 {
482 "no blocking findings, review posted".to_string()
483 } else {
484 format!(
485 "{blocking} blocking finding{}, review posted",
486 plural(blocking)
487 )
488 }
489 ),
490 Err(e) => {
491 state.notes.push(format!("could not post the review: {e}"));
492 println!("\n{comment}\n");
493 }
494 }
495 Ok(())
496}
497
498impl Standing {
499 pub fn counts(self) -> bool {
501 matches!(
502 self,
503 Standing::Corroborated | Standing::Confirmed | Standing::Unverified
504 )
505 }
506
507 pub fn label(self) -> &'static str {
508 match self {
509 Standing::Corroborated => "both reviewers raised this independently",
510 Standing::Confirmed => "raised by one reviewer, confirmed by the other",
511 Standing::Disputed => "the reviewers disagree",
512 Standing::Withdrawn => "withdrawn",
513 Standing::Unverified => "raised by one reviewer, not cross-checked",
514 }
515 }
516}
517
518pub fn verdict_comment(judged: &[Judged], style: &Style) -> String {
520 let live: Vec<&Judged> = judged.iter().filter(|j| j.standing.counts()).collect();
521 let pick = |severity: Severity| -> Vec<&Judged> {
522 live.iter()
523 .copied()
524 .filter(|j| j.finding.severity == severity && j.finding.in_scope)
525 .collect()
526 };
527 let blocking = pick(Severity::Blocking);
528 let non_blocking = pick(Severity::NonBlocking);
529 let nits = pick(Severity::Nit);
530 let disputed: Vec<&Judged> = judged
531 .iter()
532 .filter(|j| j.standing == Standing::Disputed)
533 .collect();
534 let withdrawn = judged
535 .iter()
536 .filter(|j| j.standing == Standing::Withdrawn)
537 .count();
538
539 let mut out = vec![if blocking.is_empty() && disputed.is_empty() {
543 "Two independent reviews, nothing blocking a merge.".to_string()
544 } else {
545 "Two independent reviews.".to_string()
546 }];
547 let _ = withdrawn;
548
549 let line = |j: &Judged| -> String {
550 let where_at = match j.finding.where_at() {
551 "general" => String::new(),
552 file => format!(" ({file})"),
553 };
554 let detail = style::detail(&j.finding.detail, style);
555 let attested = if j.standing == Standing::Corroborated {
556 " [both]"
557 } else if j.standing == Standing::Unverified {
558 " [one reviewer only]"
559 } else {
560 ""
561 };
562 if detail.is_empty() {
563 format!(
564 "- {}{where_at}{attested}",
565 style::title(&j.finding.title, style)
566 )
567 } else {
568 format!(
569 "- {}{where_at}{attested}. {detail}",
570 style::title(&j.finding.title, style)
571 )
572 }
573 };
574
575 if !blocking.is_empty() {
576 out.push(format!(
577 "needs changing before merge\n{}",
578 blocking
579 .iter()
580 .copied()
581 .map(line)
582 .collect::<Vec<_>>()
583 .join("\n")
584 ));
585 }
586 if !non_blocking.is_empty() {
587 out.push(format!(
588 "worth doing, does not block\n{}",
589 non_blocking
590 .iter()
591 .copied()
592 .map(line)
593 .collect::<Vec<_>>()
594 .join("\n")
595 ));
596 }
597 if !nits.is_empty() {
598 out.push(format!(
599 "nits\n{}",
600 nits.iter()
601 .copied()
602 .map(line)
603 .collect::<Vec<_>>()
604 .join("\n")
605 ));
606 }
607 if !disputed.is_empty() {
608 let lines: Vec<String> = disputed
609 .iter()
610 .map(|j| {
611 let mut line = format!(
612 "- {} ({})",
613 style::title(&j.finding.title, style),
614 j.finding.where_at()
615 );
616 if let Some(objection) = &j.counterpoint {
617 line.push_str(&format!(
618 ". Objection: {}",
619 style::sentence(objection, style)
620 ));
621 }
622 if let Some(defence) = &j.defence {
623 line.push_str(&format!(" Answer: {}", style::sentence(defence, style)));
624 }
625 line
626 })
627 .collect();
628 out.push(format!(
629 "the reviewers disagree, your call\n{}",
630 lines.join("\n")
631 ));
632 }
633
634 out.join("\n\n")
635}
636
637fn plural(n: usize) -> &'static str {
639 if n == 1 {
640 ""
641 } else {
642 "s"
643 }
644}
645
646fn concurrently<T, F>(agents: &[Agent], work: F) -> Vec<(String, Result<T>)>
648where
649 T: Send,
650 F: Fn(&Agent) -> Result<T> + Sync,
651{
652 std::thread::scope(|scope| {
653 let handles: Vec<_> = agents
654 .iter()
655 .map(|agent| scope.spawn(|| (agent.name().to_string(), work(agent))))
656 .collect();
657 handles
658 .into_iter()
659 .zip(agents)
660 .map(|(handle, agent)| {
661 handle.join().unwrap_or_else(|_| {
662 (
663 agent.name().to_string(),
664 Err(spar_err!("thread for '{}' panicked", agent.name())),
665 )
666 })
667 })
668 .collect()
669 })
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675
676 fn finding(severity: &str, title: &str, file: &str) -> Finding {
677 Finding {
678 severity: Severity::parse_lenient(severity).unwrap(),
679 title: title.into(),
680 detail: "why it matters".into(),
681 file: file.into(),
682 in_scope: true,
683 ..Default::default()
684 }
685 }
686
687 fn from(name: &str, findings: Vec<Finding>) -> (String, Vec<Finding>) {
688 (name.to_string(), findings)
689 }
690
691 #[test]
697 fn a_finding_both_reviewers_reached_alone_is_corroborated() {
698 let judged = corroborate(&[
699 from(
700 "claude",
701 vec![finding("blocking", "Retry loop spins", "src/net.rs")],
702 ),
703 from(
704 "codex",
705 vec![finding("blocking", "retry loop spins!", "src/net.rs")],
706 ),
707 ]);
708 assert_eq!(1, judged.len(), "the same point must not be listed twice");
709 assert_eq!(Standing::Corroborated, judged[0].standing);
710 assert!(judged[0].raised_by.contains("claude"));
711 assert!(judged[0].raised_by.contains("codex"));
712 }
713
714 #[test]
715 fn a_finding_only_one_reviewer_raised_starts_unverified() {
716 let judged = corroborate(&[
717 from(
718 "claude",
719 vec![finding("blocking", "Only claude saw this", "a.rs")],
720 ),
721 from("codex", vec![]),
722 ]);
723 assert_eq!(Standing::Unverified, judged[0].standing);
724 assert_eq!("claude", judged[0].raised_by);
725 }
726
727 #[test]
728 fn the_same_title_in_a_different_file_is_two_findings() {
729 let judged = corroborate(&[
730 from("claude", vec![finding("nit", "Naming", "a.rs")]),
731 from("codex", vec![finding("nit", "Naming", "b.rs")]),
732 ]);
733 assert_eq!(2, judged.len());
734 }
735
736 #[test]
739 fn disagreement_about_severity_keeps_the_graver_one() {
740 let judged = corroborate(&[
741 from("claude", vec![finding("nit", "Unbounded loop", "a.rs")]),
742 from("codex", vec![finding("blocking", "unbounded loop", "a.rs")]),
743 ]);
744 assert_eq!(Severity::Blocking, judged[0].finding.severity);
745
746 let judged = corroborate(&[
748 from(
749 "claude",
750 vec![finding("blocking", "Unbounded loop", "a.rs")],
751 ),
752 from("codex", vec![finding("nit", "unbounded loop", "a.rs")]),
753 ]);
754 assert_eq!(Severity::Blocking, judged[0].finding.severity);
755 }
756
757 #[test]
758 fn severity_ordering_does_not_depend_on_declaration_order() {
759 assert_eq!(Severity::Blocking, Severity::Blocking.graver(Severity::Nit));
760 assert_eq!(Severity::Blocking, Severity::Nit.graver(Severity::Blocking));
761 assert_eq!(
762 Severity::NonBlocking,
763 Severity::Nit.graver(Severity::NonBlocking)
764 );
765 assert!(Severity::Blocking.rank() > Severity::NonBlocking.rank());
766 assert!(Severity::NonBlocking.rank() > Severity::Nit.rank());
767 }
768
769 #[test]
770 fn a_single_reviewer_still_produces_a_list() {
771 let judged = corroborate(&[from("claude", vec![finding("blocking", "A", "a.rs")])]);
772 assert_eq!(1, judged.len());
773 assert_eq!(Standing::Unverified, judged[0].standing);
774 }
775
776 #[test]
779 fn only_surviving_standings_count() {
780 assert!(Standing::Corroborated.counts());
781 assert!(Standing::Confirmed.counts());
782 assert!(Standing::Unverified.counts());
783 assert!(
784 !Standing::Disputed.counts(),
785 "a disputed point is listed separately"
786 );
787 assert!(
788 !Standing::Withdrawn.counts(),
789 "a withdrawn point is not a finding"
790 );
791 }
792
793 fn judged(standing: Standing, severity: &str, title: &str) -> Judged {
794 Judged {
795 finding: finding(severity, title, "src/net.rs"),
796 raised_by: "claude".into(),
797 standing,
798 counterpoint: None,
799 defence: None,
800 }
801 }
802
803 #[test]
804 fn a_clean_pr_says_so_in_one_breath() {
805 let text = verdict_comment(&[], &Style::default());
806 assert!(
807 text.starts_with("Two independent reviews, nothing blocking a merge."),
808 "{text}"
809 );
810 }
811
812 #[test]
813 fn a_corroborated_blocker_is_marked_as_such() {
814 let text = verdict_comment(
815 &[judged(
816 Standing::Corroborated,
817 "blocking",
818 "Retry loop spins",
819 )],
820 &Style::default(),
821 );
822 assert!(text.contains("needs changing before merge"), "{text}");
823 assert!(text.contains("[both]"), "{text}");
824 }
825
826 #[test]
827 fn an_uncrosschecked_finding_is_flagged_as_one_reviewers_opinion() {
828 let text = verdict_comment(
829 &[judged(
830 Standing::Unverified,
831 "blocking",
832 "Only one saw this",
833 )],
834 &Style::default(),
835 );
836 assert!(text.contains("[one reviewer only]"), "{text}");
837 }
838
839 #[test]
840 fn a_confirmed_finding_carries_no_qualifier() {
841 let text = verdict_comment(
842 &[judged(Standing::Confirmed, "blocking", "Checked and real")],
843 &Style::default(),
844 );
845 assert!(
846 !text.contains("[both]") && !text.contains("[one reviewer only]"),
847 "{text}"
848 );
849 }
850
851 #[test]
854 fn a_withdrawn_finding_never_reaches_the_maintainer() {
855 let text = verdict_comment(
856 &[judged(
857 Standing::Withdrawn,
858 "blocking",
859 "Wrong on a second look",
860 )],
861 &Style::default(),
862 );
863 assert!(!text.contains("Wrong on a second look"), "{text}");
864 assert!(
865 !text.to_lowercase().contains("withdrawn"),
866 "a point nobody can see or act on is not worth a sentence: {text}"
867 );
868 assert!(text.contains("nothing blocking a merge"), "{text}");
869 }
870
871 #[test]
872 fn a_disputed_finding_goes_to_a_person_with_both_sides() {
873 let mut j = judged(Standing::Disputed, "blocking", "Error is swallowed");
874 j.counterpoint = Some("the caller already validates the file".into());
875 let text = verdict_comment(&[j], &Style::default());
876 assert!(text.contains("the reviewers disagree, your call"), "{text}");
877 assert!(
878 text.contains("Objection: The caller already validates"),
879 "{text}"
880 );
881 assert!(
882 !text.contains("needs changing before merge"),
883 "disputed does not block: {text}"
884 );
885 }
886
887 #[test]
888 fn the_three_severities_are_kept_apart() {
889 let text = verdict_comment(
890 &[
891 judged(Standing::Corroborated, "blocking", "Must fix"),
892 judged(Standing::Confirmed, "non-blocking", "Could improve"),
893 judged(Standing::Confirmed, "nit", "Taste"),
894 ],
895 &Style::default(),
896 );
897 assert!(
898 !text.contains("1 blocking"),
899 "counts are listed below, not above: {text}"
900 );
901 assert!(text.contains("needs changing before merge"), "{text}");
902 assert!(text.contains("worth doing, does not block"), "{text}");
903 assert!(text.contains("nits"), "{text}");
904 }
905
906 #[test]
909 fn a_thorough_reviewer_is_not_cut_short() {
910 let mut j = judged(Standing::Corroborated, "blocking", "A real problem");
911 j.finding.detail = "Here is a step of the reproduction. ".repeat(20);
912 let text = verdict_comment(&[j], &Style::default());
913 assert!(
914 text.contains(
915 &"Here is a step of the reproduction. "
916 .repeat(20)
917 .trim()
918 .to_string()
919 ) || text.len() > 600,
920 "the explanation survived: {} chars",
921 text.len()
922 );
923 }
924
925 #[test]
926 fn a_runaway_reviewer_is_still_bounded() {
927 let mut j = judged(Standing::Corroborated, "blocking", "A real problem");
928 j.finding.detail = "filler ".repeat(20_000);
929 let text = verdict_comment(&[j], &Style::default());
930 assert!(text.len() < 20_000, "{} chars", text.len());
931 }
932
933 #[test]
934 fn an_out_of_scope_finding_does_not_ask_the_contributor_to_fix_it() {
935 let mut j = judged(Standing::Corroborated, "blocking", "Pre-existing bug");
936 j.finding.in_scope = false;
937 let text = verdict_comment(&[j], &Style::default());
938 assert!(!text.contains("needs changing before merge"), "{text}");
939 assert!(text.contains("nothing blocking a merge"), "{text}");
940 }
941}