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