1use std::path::Path;
26
27use crate::agent::Agent;
28use crate::config::Config;
29use crate::error::{ErrorKind, 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, WorktreeCheckpoint};
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 checkpoint = repo.worktree_checkpoint(&work_dir)?;
146 let outcome = run_phases(
147 agents,
148 cfg,
149 repo,
150 &pr,
151 &base,
152 &work_dir,
153 &checkpoint,
154 &mut state,
155 dry_run,
156 );
157 outcome?;
158 repo.release_review_worktree_checked(pr_number, &checkpoint)?;
159 Ok(state)
160}
161
162#[allow(clippy::too_many_arguments)]
163fn run_phases(
164 agents: &[Agent],
165 cfg: &Config,
166 repo: &Repo,
167 pr: &PrView,
168 base: &str,
169 work_dir: &Path,
170 checkpoint: &WorktreeCheckpoint,
171 state: &mut IssueRun,
172 dry_run: bool,
173) -> Result<()> {
174 let budget = cfg.loop_cfg.max_rounds;
175
176 log!(
178 "PR #{}: {} reviewing independently",
179 pr.number,
180 agents
181 .iter()
182 .map(Agent::name)
183 .collect::<Vec<_>>()
184 .join(" and ")
185 );
186 let prompt = REVIEW_ONLY_PROMPT
187 .replace("{number}", &pr.number.to_string())
188 .replace("{base}", base)
189 .replace("{title}", &pr.title);
190
191 let reviews = concurrently(agents, |a| {
192 let effort = cfg.effort_for_round(&a.spec, 1);
193 a.review::<Review>(
194 base,
195 &prompt,
196 &schema::review(),
197 work_dir,
198 effort.as_deref(),
199 )
200 });
201
202 let mut by_agent: Vec<(String, Vec<Finding>)> = Vec::new();
203 for (name, result) in reviews {
204 match result {
205 Ok(review) => by_agent.push((name, review.findings)),
206 Err(e) if e.kind() == ErrorKind::UncertainWrite => return Err(e),
207 Err(e) => {
208 logdim!("{name} could not review PR #{}: {e}", pr.number);
212 state
213 .notes
214 .push(format!("{name} did not return a review: {e}"));
215 }
216 }
217 }
218 repo.require_unchanged_worktree(
219 work_dir,
220 checkpoint,
221 &format!("review worktree for PR #{}", pr.number),
222 )?;
223 if by_agent.is_empty() {
224 return Err(spar_err!("neither reviewer returned a usable review"));
225 }
226 if by_agent.len() == 1 {
227 crate::logging::warn(format!(
231 "only {} answered on PR #{}. Nothing was cross-checked, so these findings carry one \
232 model's judgement rather than two.",
233 by_agent[0].0, pr.number
234 ));
235 state
236 .notes
237 .push("only one reviewer answered, so nothing was cross-checked".into());
238 }
239
240 let mut judged = corroborate(&by_agent);
241
242 if budget >= 2 && by_agent.len() == 2 {
244 adjudicate(agents, cfg, repo, work_dir, checkpoint, &mut judged, 2)?;
245 } else if budget < 2 {
246 for j in judged.iter_mut() {
247 if j.standing == Standing::Unverified {
248 j.counterpoint = Some("not cross-checked, max_rounds was 1".into());
249 }
250 }
251 }
252
253 if budget >= 3 && judged.iter().any(|j| j.standing == Standing::Disputed) {
255 rebut(agents, cfg, repo, work_dir, checkpoint, &mut judged, 3)?;
256 }
257
258 state.rounds = budget.min(3);
259 repo.require_unchanged_worktree(
260 work_dir,
261 checkpoint,
262 &format!("review worktree for PR #{}", pr.number),
263 )?;
264 finish(repo, pr, state, &judged, dry_run)
265}
266
267fn corroborate(by_agent: &[(String, Vec<Finding>)]) -> Vec<Judged> {
269 let mut judged: Vec<Judged> = Vec::new();
270
271 for (name, findings) in by_agent {
272 for finding in dedupe_exact_findings(findings) {
273 let exact = finding_key(&finding.title, &finding.file);
274 let mut exact_matches = judged.iter().enumerate().filter(|(_, existing)| {
275 existing.standing == Standing::Unverified
276 && existing.raised_by != *name
277 && finding_key(&existing.finding.title, &existing.finding.file) == exact
278 });
279 let exact_match = exact_matches.next().map(|(index, _)| index);
280 let exact_match = exact_match.filter(|_| exact_matches.next().is_none());
281 match exact_match.map(|index| &mut judged[index]) {
282 Some(existing) => {
283 existing.finding.severity = existing.finding.severity.graver(finding.severity);
286 existing.standing = Standing::Corroborated;
287 existing.raised_by = format!("{} and {name}", existing.raised_by);
288 }
289 None => judged.push(Judged {
290 finding,
291 raised_by: name.clone(),
292 standing: Standing::Unverified,
293 counterpoint: None,
294 defence: None,
295 }),
296 }
297 }
298 }
299 judged
300}
301
302fn dedupe_exact_findings(findings: &[Finding]) -> Vec<Finding> {
307 let mut unique: Vec<Finding> = Vec::new();
308 for finding in findings {
309 let key = finding_key(&finding.title, &finding.file);
310 if let Some(existing) = unique
311 .iter_mut()
312 .find(|existing| finding_key(&existing.title, &existing.file) == key)
313 {
314 let severity = existing.severity.graver(finding.severity);
315 *existing = finding.clone();
316 existing.severity = severity;
317 } else {
318 unique.push(finding.clone());
319 }
320 }
321 unique
322}
323
324fn adjudicate(
325 agents: &[Agent],
326 cfg: &Config,
327 repo: &Repo,
328 work_dir: &Path,
329 checkpoint: &WorktreeCheckpoint,
330 judged: &mut [Judged],
331 round: u32,
332) -> Result<()> {
333 let pending: Vec<usize> = judged
334 .iter()
335 .enumerate()
336 .filter(|(_, j)| j.standing == Standing::Unverified)
337 .map(|(i, _)| i)
338 .collect();
339 if pending.is_empty() {
340 return Ok(());
341 }
342 log!(
343 "cross-checking {} finding{} raised by one reviewer",
344 pending.len(),
345 plural(pending.len())
346 );
347
348 let answers = concurrently(agents, |adjudicator| {
349 let theirs: Vec<&Judged> = pending
351 .iter()
352 .map(|i| &judged[*i])
353 .filter(|j| j.raised_by != adjudicator.name())
354 .collect();
355 if theirs.is_empty() {
356 return Ok(AdjudicationDoc { verdicts: vec![] });
357 }
358 let listed: Vec<Finding> = theirs.iter().map(|j| j.finding.clone()).collect();
359 let prompt =
360 ADJUDICATE_PROMPT.replace("{findings}", &crate::review::findings_for_prompt(&listed));
361 adjudicator.ask_json::<AdjudicationDoc>(
362 &prompt,
363 &schema::adjudication(),
364 work_dir,
365 cfg.effort_for_round(&adjudicator.spec, round).as_deref(),
366 )
367 });
368
369 for (name, result) in answers {
370 let doc = match result {
371 Ok(doc) => doc,
372 Err(e) if e.kind() == ErrorKind::UncertainWrite => return Err(e),
373 Err(e) => {
374 logdim!("{name} could not adjudicate: {e}");
375 continue;
376 }
377 };
378 for verdict in doc.verdicts {
379 let key = finding_key(&verdict.title, &verdict.file);
380 let Some(target) = judged.iter_mut().find(|j| {
381 j.raised_by != name
382 && (finding_key(&j.finding.title, &j.finding.file) == key
383 || crate::review::same_finding_parts(
384 &j.finding.title,
385 &j.finding.file,
386 &verdict.title,
387 &verdict.file,
388 ))
389 }) else {
390 continue;
391 };
392 if target.standing != Standing::Unverified {
393 continue;
394 }
395 target.counterpoint = Some(style::summary(&verdict.reasoning, &repo.style));
396 if verdict.agrees {
397 target.standing = Standing::Confirmed;
398 target.finding.severity = target.finding.severity.graver(verdict.severity);
402 } else {
403 target.standing = Standing::Disputed;
404 }
405 }
406 }
407 repo.require_unchanged_worktree(work_dir, checkpoint, "read-only review worktree")?;
408 Ok(())
409}
410
411fn rebut(
412 agents: &[Agent],
413 cfg: &Config,
414 repo: &Repo,
415 work_dir: &Path,
416 checkpoint: &WorktreeCheckpoint,
417 judged: &mut [Judged],
418 round: u32,
419) -> Result<()> {
420 let disputed: Vec<usize> = judged
421 .iter()
422 .enumerate()
423 .filter(|(_, j)| j.standing == Standing::Disputed)
424 .map(|(i, _)| i)
425 .collect();
426 log!(
427 "{} disputed finding{} going back to whoever raised them",
428 disputed.len(),
429 plural(disputed.len())
430 );
431
432 let answers = concurrently(agents, |author| {
433 let mine: Vec<&Judged> = disputed
434 .iter()
435 .map(|i| &judged[*i])
436 .filter(|j| j.raised_by == author.name())
437 .collect();
438 if mine.is_empty() {
439 return Ok(AdjudicationDoc { verdicts: vec![] });
440 }
441 let listed = mine
442 .iter()
443 .map(|j| {
444 format!(
445 "- [{}] {} ({})\n {}\n OBJECTION: {}",
446 j.finding.severity,
447 j.finding.title,
448 j.finding.where_at(),
449 j.finding.detail,
450 j.counterpoint.as_deref().unwrap_or("(none given)")
451 )
452 })
453 .collect::<Vec<_>>()
454 .join("\n");
455 let prompt = REBUT_PROMPT.replace("{findings}", &listed);
456 author.ask_json::<AdjudicationDoc>(
457 &prompt,
458 &schema::adjudication(),
459 work_dir,
460 cfg.effort_for_round(&author.spec, round).as_deref(),
461 )
462 });
463
464 for (name, result) in answers {
465 let doc = match result {
466 Ok(doc) => doc,
467 Err(e) if e.kind() == ErrorKind::UncertainWrite => return Err(e),
468 Err(e) => {
469 logdim!("{name} could not answer the objections: {e}");
470 continue;
471 }
472 };
473 for verdict in doc.verdicts {
474 let key = finding_key(&verdict.title, &verdict.file);
475 let Some(target) = judged.iter_mut().find(|j| {
476 j.raised_by == name
477 && j.standing == Standing::Disputed
478 && (finding_key(&j.finding.title, &j.finding.file) == key
479 || crate::review::same_finding_parts(
480 &j.finding.title,
481 &j.finding.file,
482 &verdict.title,
483 &verdict.file,
484 ))
485 }) else {
486 continue;
487 };
488 if verdict.agrees {
489 target.defence = Some(style::sentence(&verdict.reasoning, &repo.style));
492 } else {
493 target.standing = Standing::Withdrawn;
494 }
495 }
496 }
497 repo.require_unchanged_worktree(work_dir, checkpoint, "read-only review worktree")?;
498 Ok(())
499}
500
501fn finish(
502 repo: &Repo,
503 pr: &PrView,
504 state: &mut IssueRun,
505 judged: &[Judged],
506 dry_run: bool,
507) -> Result<()> {
508 let blocking = judged
509 .iter()
510 .filter(|j| j.finding.blocks() && j.standing.counts())
511 .count();
512
513 state.status = if blocking == 0 {
514 Status::Clean
515 } else {
516 Status::Reviewed
517 };
518 for j in judged.iter().filter(|j| j.standing == Standing::Disputed) {
519 state.disputes.push(crate::model::Dispute {
520 title: style::title(&j.finding.title, &repo.style),
521 file: j.finding.file.clone(),
522 reasoning: j.counterpoint.clone().unwrap_or_default(),
523 });
524 }
525
526 let comment = verdict_comment(judged, &repo.style);
527 let silent = dry_run || repo.style.pr_comments == crate::config::PrComments::None;
531 if silent {
532 println!("\n{comment}\n");
533 let why = if dry_run {
534 "dry run"
535 } else {
536 "pr_comments is none"
537 };
538 match repo.save_pending_comment(pr.number, &comment) {
539 Ok(path) => log!(
540 "{why}, nothing posted. Saved to {}. Post it with `spar post {}`, or edit that \
541 file first.",
542 path.display(),
543 pr.number
544 ),
545 Err(e) => logdim!("{why}, nothing posted, and could not save it: {e}"),
546 }
547 return Ok(());
548 }
549 match repo.comment_pr(pr.number, &comment) {
550 Ok(()) => log!(
551 "PR #{}: {}",
552 pr.number,
553 if blocking == 0 {
554 "no blocking findings, review posted".to_string()
555 } else {
556 format!(
557 "{blocking} blocking finding{}, review posted",
558 plural(blocking)
559 )
560 }
561 ),
562 Err(e) => {
563 state.notes.push(format!("could not post the review: {e}"));
564 println!("\n{comment}\n");
565 }
566 }
567 Ok(())
568}
569
570impl Standing {
571 pub fn counts(self) -> bool {
573 matches!(
574 self,
575 Standing::Corroborated | Standing::Confirmed | Standing::Unverified
576 )
577 }
578
579 pub fn label(self) -> &'static str {
580 match self {
581 Standing::Corroborated => "both reviewers raised this independently",
582 Standing::Confirmed => "raised by one reviewer, confirmed by the other",
583 Standing::Disputed => "the reviewers disagree",
584 Standing::Withdrawn => "withdrawn",
585 Standing::Unverified => "raised by one reviewer, not cross-checked",
586 }
587 }
588}
589
590pub fn verdict_comment(judged: &[Judged], style: &Style) -> String {
592 let live: Vec<&Judged> = judged.iter().filter(|j| j.standing.counts()).collect();
593 let pick = |severity: Severity| -> Vec<&Judged> {
594 live.iter()
595 .copied()
596 .filter(|j| j.finding.severity == severity && j.finding.in_scope)
597 .collect()
598 };
599 let blocking = pick(Severity::Blocking);
600 let non_blocking = pick(Severity::NonBlocking);
601 let nits = pick(Severity::Nit);
602 let disputed: Vec<&Judged> = judged
603 .iter()
604 .filter(|j| j.standing == Standing::Disputed)
605 .collect();
606 let withdrawn = judged
607 .iter()
608 .filter(|j| j.standing == Standing::Withdrawn)
609 .count();
610
611 let mut out = vec![if blocking.is_empty() && disputed.is_empty() {
615 "Two independent reviews, nothing blocking a merge.".to_string()
616 } else {
617 "Two independent reviews.".to_string()
618 }];
619 let _ = withdrawn;
620
621 let line = |j: &Judged| -> String {
622 let where_at = match j.finding.where_at() {
623 "general" => String::new(),
624 file => format!(" ({file})"),
625 };
626 let detail = style::detail(&j.finding.detail, style);
627 let attested = if j.standing == Standing::Corroborated {
628 " [both]"
629 } else if j.standing == Standing::Unverified {
630 " [one reviewer only]"
631 } else {
632 ""
633 };
634 if detail.is_empty() {
635 format!(
636 "- {}{where_at}{attested}",
637 style::title(&j.finding.title, style)
638 )
639 } else {
640 format!(
641 "- {}{where_at}{attested}. {detail}",
642 style::title(&j.finding.title, style)
643 )
644 }
645 };
646
647 if !blocking.is_empty() {
648 out.push(format!(
649 "needs changing before merge\n{}",
650 blocking
651 .iter()
652 .copied()
653 .map(line)
654 .collect::<Vec<_>>()
655 .join("\n")
656 ));
657 }
658 if !non_blocking.is_empty() {
659 out.push(format!(
660 "worth doing, does not block\n{}",
661 non_blocking
662 .iter()
663 .copied()
664 .map(line)
665 .collect::<Vec<_>>()
666 .join("\n")
667 ));
668 }
669 if !nits.is_empty() {
670 out.push(format!(
671 "nits\n{}",
672 nits.iter()
673 .copied()
674 .map(line)
675 .collect::<Vec<_>>()
676 .join("\n")
677 ));
678 }
679 if !disputed.is_empty() {
680 let lines: Vec<String> = disputed
681 .iter()
682 .map(|j| {
683 let mut line = format!(
684 "- {} ({})",
685 style::title(&j.finding.title, style),
686 j.finding.where_at()
687 );
688 if let Some(objection) = &j.counterpoint {
689 line.push_str(&format!(
690 ". Objection: {}",
691 style::sentence(objection, style)
692 ));
693 }
694 if let Some(defence) = &j.defence {
695 line.push_str(&format!(" Answer: {}", style::sentence(defence, style)));
696 }
697 line
698 })
699 .collect();
700 out.push(format!(
701 "the reviewers disagree, your call\n{}",
702 lines.join("\n")
703 ));
704 }
705
706 out.join("\n\n")
707}
708
709fn plural(n: usize) -> &'static str {
711 if n == 1 {
712 ""
713 } else {
714 "s"
715 }
716}
717
718fn concurrently<T, F>(agents: &[Agent], work: F) -> Vec<(String, Result<T>)>
720where
721 T: Send,
722 F: Fn(&Agent) -> Result<T> + Sync,
723{
724 std::thread::scope(|scope| {
725 let handles: Vec<_> = agents
726 .iter()
727 .map(|agent| scope.spawn(|| (agent.name().to_string(), work(agent))))
728 .collect();
729 handles
730 .into_iter()
731 .zip(agents)
732 .map(|(handle, agent)| {
733 handle.join().unwrap_or_else(|_| {
734 (
735 agent.name().to_string(),
736 Err(spar_err!("thread for '{}' panicked", agent.name())),
737 )
738 })
739 })
740 .collect()
741 })
742}
743
744#[cfg(test)]
745mod tests {
746 use super::*;
747
748 fn finding(severity: &str, title: &str, file: &str) -> Finding {
749 Finding {
750 severity: Severity::parse_lenient(severity).unwrap(),
751 title: title.into(),
752 detail: "why it matters".into(),
753 file: file.into(),
754 in_scope: true,
755 ..Default::default()
756 }
757 }
758
759 fn from(name: &str, findings: Vec<Finding>) -> (String, Vec<Finding>) {
760 (name.to_string(), findings)
761 }
762
763 #[test]
769 fn a_finding_both_reviewers_reached_alone_is_corroborated() {
770 let judged = corroborate(&[
771 from(
772 "claude",
773 vec![finding("blocking", "Retry loop spins", "src/net.rs")],
774 ),
775 from(
776 "codex",
777 vec![finding("blocking", "retry loop spins!", "src/net.rs")],
778 ),
779 ]);
780 assert_eq!(1, judged.len(), "the same point must not be listed twice");
781 assert_eq!(Standing::Corroborated, judged[0].standing);
782 assert!(judged[0].raised_by.contains("claude"));
783 assert!(judged[0].raised_by.contains("codex"));
784 }
785
786 #[test]
787 fn a_finding_only_one_reviewer_raised_starts_unverified() {
788 let judged = corroborate(&[
789 from(
790 "claude",
791 vec![finding("blocking", "Only claude saw this", "a.rs")],
792 ),
793 from("codex", vec![]),
794 ]);
795 assert_eq!(Standing::Unverified, judged[0].standing);
796 assert_eq!("claude", judged[0].raised_by);
797 }
798
799 #[test]
800 fn repeating_a_finding_is_not_an_independent_opinion() {
801 let repeated = finding("non-blocking", "Unchecked error", "src/net.rs:10");
802 let mut graver = repeated.clone();
803 graver.severity = Severity::Blocking;
804 graver.detail = "confirmed by the failing path".into();
805
806 let judged = corroborate(&[
807 from("first", vec![repeated, graver]),
808 from("second", vec![]),
809 ]);
810
811 assert_eq!(1, judged.len());
812 assert_eq!(Standing::Unverified, judged[0].standing);
813 assert_eq!(Severity::Blocking, judged[0].finding.severity);
814 assert_eq!("confirmed by the failing path", judged[0].finding.detail);
815 }
816
817 #[test]
818 fn one_repeated_finding_and_one_independent_match_corroborate_once() {
819 let repeated = finding("blocking", "Unchecked error", "src/net.rs:10");
820 let judged = corroborate(&[
821 from("first", vec![repeated.clone(), repeated]),
822 from(
823 "second",
824 vec![finding("blocking", "unchecked error!", "src/net.rs:10")],
825 ),
826 ]);
827
828 assert_eq!(1, judged.len());
829 assert_eq!(Standing::Corroborated, judged[0].standing);
830 }
831
832 #[test]
833 fn the_same_title_in_a_different_file_is_two_findings() {
834 let judged = corroborate(&[
835 from("claude", vec![finding("nit", "Naming", "a.rs")]),
836 from("codex", vec![finding("nit", "Naming", "b.rs")]),
837 ]);
838 assert_eq!(2, judged.len());
839 }
840
841 #[test]
842 fn one_reviewer_cannot_corroborate_itself_at_two_sites() {
843 let judged = corroborate(&[
844 from(
845 "first",
846 vec![
847 finding("blocking", "Unchecked error", "src/net.rs:10"),
848 finding("blocking", "Unchecked error", "src/net.rs:200"),
849 ],
850 ),
851 from("second", vec![]),
852 ]);
853 assert_eq!(2, judged.len());
854 assert!(judged
855 .iter()
856 .all(|finding| finding.standing == Standing::Unverified));
857 }
858
859 #[test]
860 fn different_sites_are_not_corroborated() {
861 let judged = corroborate(&[
862 from(
863 "first",
864 vec![finding("blocking", "Unchecked error", "src/net.rs:10")],
865 ),
866 from(
867 "second",
868 vec![finding("blocking", "Unchecked error", "src/net.rs:12")],
869 ),
870 ]);
871 assert_eq!(2, judged.len());
872 assert!(judged
873 .iter()
874 .all(|finding| finding.standing == Standing::Unverified));
875 }
876
877 #[test]
880 fn disagreement_about_severity_keeps_the_graver_one() {
881 let judged = corroborate(&[
882 from("claude", vec![finding("nit", "Unbounded loop", "a.rs")]),
883 from("codex", vec![finding("blocking", "unbounded loop", "a.rs")]),
884 ]);
885 assert_eq!(Severity::Blocking, judged[0].finding.severity);
886
887 let judged = corroborate(&[
889 from(
890 "claude",
891 vec![finding("blocking", "Unbounded loop", "a.rs")],
892 ),
893 from("codex", vec![finding("nit", "unbounded loop", "a.rs")]),
894 ]);
895 assert_eq!(Severity::Blocking, judged[0].finding.severity);
896 }
897
898 #[test]
899 fn severity_ordering_does_not_depend_on_declaration_order() {
900 assert_eq!(Severity::Blocking, Severity::Blocking.graver(Severity::Nit));
901 assert_eq!(Severity::Blocking, Severity::Nit.graver(Severity::Blocking));
902 assert_eq!(
903 Severity::NonBlocking,
904 Severity::Nit.graver(Severity::NonBlocking)
905 );
906 assert!(Severity::Blocking.rank() > Severity::NonBlocking.rank());
907 assert!(Severity::NonBlocking.rank() > Severity::Nit.rank());
908 }
909
910 #[test]
911 fn a_single_reviewer_still_produces_a_list() {
912 let judged = corroborate(&[from("claude", vec![finding("blocking", "A", "a.rs")])]);
913 assert_eq!(1, judged.len());
914 assert_eq!(Standing::Unverified, judged[0].standing);
915 }
916
917 #[test]
920 fn only_surviving_standings_count() {
921 assert!(Standing::Corroborated.counts());
922 assert!(Standing::Confirmed.counts());
923 assert!(Standing::Unverified.counts());
924 assert!(
925 !Standing::Disputed.counts(),
926 "a disputed point is listed separately"
927 );
928 assert!(
929 !Standing::Withdrawn.counts(),
930 "a withdrawn point is not a finding"
931 );
932 }
933
934 fn judged(standing: Standing, severity: &str, title: &str) -> Judged {
935 Judged {
936 finding: finding(severity, title, "src/net.rs"),
937 raised_by: "claude".into(),
938 standing,
939 counterpoint: None,
940 defence: None,
941 }
942 }
943
944 #[test]
945 fn a_clean_pr_says_so_in_one_breath() {
946 let text = verdict_comment(&[], &Style::default());
947 assert!(
948 text.starts_with("Two independent reviews, nothing blocking a merge."),
949 "{text}"
950 );
951 }
952
953 #[test]
954 fn a_corroborated_blocker_is_marked_as_such() {
955 let text = verdict_comment(
956 &[judged(
957 Standing::Corroborated,
958 "blocking",
959 "Retry loop spins",
960 )],
961 &Style::default(),
962 );
963 assert!(text.contains("needs changing before merge"), "{text}");
964 assert!(text.contains("[both]"), "{text}");
965 }
966
967 #[test]
968 fn an_uncrosschecked_finding_is_flagged_as_one_reviewers_opinion() {
969 let text = verdict_comment(
970 &[judged(
971 Standing::Unverified,
972 "blocking",
973 "Only one saw this",
974 )],
975 &Style::default(),
976 );
977 assert!(text.contains("[one reviewer only]"), "{text}");
978 }
979
980 #[test]
981 fn a_confirmed_finding_carries_no_qualifier() {
982 let text = verdict_comment(
983 &[judged(Standing::Confirmed, "blocking", "Checked and real")],
984 &Style::default(),
985 );
986 assert!(
987 !text.contains("[both]") && !text.contains("[one reviewer only]"),
988 "{text}"
989 );
990 }
991
992 #[test]
995 fn a_withdrawn_finding_never_reaches_the_maintainer() {
996 let text = verdict_comment(
997 &[judged(
998 Standing::Withdrawn,
999 "blocking",
1000 "Wrong on a second look",
1001 )],
1002 &Style::default(),
1003 );
1004 assert!(!text.contains("Wrong on a second look"), "{text}");
1005 assert!(
1006 !text.to_lowercase().contains("withdrawn"),
1007 "a point nobody can see or act on is not worth a sentence: {text}"
1008 );
1009 assert!(text.contains("nothing blocking a merge"), "{text}");
1010 }
1011
1012 #[test]
1013 fn a_disputed_finding_goes_to_a_person_with_both_sides() {
1014 let mut j = judged(Standing::Disputed, "blocking", "Error is swallowed");
1015 j.counterpoint = Some("the caller already validates the file".into());
1016 let text = verdict_comment(&[j], &Style::default());
1017 assert!(text.contains("the reviewers disagree, your call"), "{text}");
1018 assert!(
1019 text.contains("Objection: The caller already validates"),
1020 "{text}"
1021 );
1022 assert!(
1023 !text.contains("needs changing before merge"),
1024 "disputed does not block: {text}"
1025 );
1026 }
1027
1028 #[test]
1029 fn the_three_severities_are_kept_apart() {
1030 let text = verdict_comment(
1031 &[
1032 judged(Standing::Corroborated, "blocking", "Must fix"),
1033 judged(Standing::Confirmed, "non-blocking", "Could improve"),
1034 judged(Standing::Confirmed, "nit", "Taste"),
1035 ],
1036 &Style::default(),
1037 );
1038 assert!(
1039 !text.contains("1 blocking"),
1040 "counts are listed below, not above: {text}"
1041 );
1042 assert!(text.contains("needs changing before merge"), "{text}");
1043 assert!(text.contains("worth doing, does not block"), "{text}");
1044 assert!(text.contains("nits"), "{text}");
1045 }
1046
1047 #[test]
1050 fn a_thorough_reviewer_is_not_cut_short() {
1051 let mut j = judged(Standing::Corroborated, "blocking", "A real problem");
1052 j.finding.detail = "Here is a step of the reproduction. ".repeat(20);
1053 let text = verdict_comment(&[j], &Style::default());
1054 assert!(
1055 text.contains(
1056 &"Here is a step of the reproduction. "
1057 .repeat(20)
1058 .trim()
1059 .to_string()
1060 ) || text.len() > 600,
1061 "the explanation survived: {} chars",
1062 text.len()
1063 );
1064 }
1065
1066 #[test]
1067 fn a_runaway_reviewer_is_still_bounded() {
1068 let mut j = judged(Standing::Corroborated, "blocking", "A real problem");
1069 j.finding.detail = "filler ".repeat(20_000);
1070 let text = verdict_comment(&[j], &Style::default());
1071 assert!(text.len() < 20_000, "{} chars", text.len());
1072 }
1073
1074 #[test]
1075 fn an_out_of_scope_finding_does_not_ask_the_contributor_to_fix_it() {
1076 let mut j = judged(Standing::Corroborated, "blocking", "Pre-existing bug");
1077 j.finding.in_scope = false;
1078 let text = verdict_comment(&[j], &Style::default());
1079 assert!(!text.contains("needs changing before merge"), "{text}");
1080 assert!(text.contains("nothing blocking a merge"), "{text}");
1081 }
1082}