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