1use std::path::Path;
26
27use crate::agent::Agent;
28use crate::config::{Call, 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, logwarn, 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{context}
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 base_ref = repo.base_ref(work_dir, base);
189 let linked = pr.closing_issues_references.first().map(|r| r.number);
193 let (issue_body, issue_url) = match linked.map(|n| repo.read_issue(n)) {
194 Some(Ok(issue)) => {
195 let (body, _) = issue.body_for_prompt(cfg.loop_cfg.max_issue_chars);
196 (body, issue.url)
197 }
198 Some(Err(e)) => {
199 logdim!("could not read the linked issue for PR #{}: {e}", pr.number);
200 (String::new(), String::new())
201 }
202 None => (String::new(), String::new()),
203 };
204 let context = crate::review::context_block(
205 linked.unwrap_or(pr.number),
206 &issue_url,
207 &issue_body,
208 &pr.body,
209 );
210 let prompt = REVIEW_ONLY_PROMPT
211 .replace("{number}", &pr.number.to_string())
212 .replace("{base}", &base_ref)
213 .replace("{title}", &pr.title)
214 .replace("{context}", &context);
215
216 let reviews = concurrently(agents, |a| {
217 let effort = a.effort(cfg, Call::Review(1)).about(pr.number);
218 a.review::<Review>(&base_ref, &prompt, &schema::review(), work_dir, &effort)
219 });
220
221 let mut by_agent: Vec<(String, Vec<Finding>)> = Vec::new();
222 for (name, result) in reviews {
223 match result {
224 Ok(review) => {
225 let blocking = review.findings.iter().filter(|f| f.blocks()).count();
226 if let Some(said) = crate::review::verdict_disagreement(&review, blocking) {
227 logwarn!("{name} {said}");
228 }
229 by_agent.push((name, review.findings));
230 }
231 Err(e) if e.kind() == ErrorKind::UncertainWrite => return Err(e),
232 Err(e) => {
233 logdim!("{name} could not review PR #{}: {e}", pr.number);
237 state
238 .notes
239 .push(format!("{name} did not return a review: {e}"));
240 }
241 }
242 }
243 repo.require_unchanged_worktree(
244 work_dir,
245 checkpoint,
246 &format!("review worktree for PR #{}", pr.number),
247 )?;
248 if by_agent.is_empty() {
249 return Err(spar_err!("neither reviewer returned a usable review"));
250 }
251 if by_agent.len() == 1 {
252 crate::logging::warn(format!(
256 "only {} answered on PR #{}. Nothing was cross-checked, so these findings carry one \
257 model's judgement rather than two.",
258 by_agent[0].0, pr.number
259 ));
260 state
261 .notes
262 .push("only one reviewer answered, so nothing was cross-checked".into());
263 }
264
265 let mut judged = corroborate(&by_agent);
266
267 if budget >= 2 && by_agent.len() == 2 {
269 adjudicate(agents, cfg, repo, work_dir, checkpoint, &mut judged, 2)?;
270 } else if budget < 2 {
271 for j in judged.iter_mut() {
272 if j.standing == Standing::Unverified {
273 j.counterpoint = Some("not cross-checked, max_rounds was 1".into());
274 }
275 }
276 }
277
278 if budget >= 3 && judged.iter().any(|j| j.standing == Standing::Disputed) {
280 rebut(agents, cfg, repo, work_dir, checkpoint, &mut judged, 3)?;
281 }
282
283 state.rounds = budget.min(3);
284 repo.require_unchanged_worktree(
285 work_dir,
286 checkpoint,
287 &format!("review worktree for PR #{}", pr.number),
288 )?;
289 finish(repo, pr, state, &judged, by_agent.len(), dry_run)
290}
291
292fn corroborate(by_agent: &[(String, Vec<Finding>)]) -> Vec<Judged> {
294 let mut judged: Vec<Judged> = Vec::new();
295
296 for (name, findings) in by_agent {
297 for finding in dedupe_exact_findings(findings) {
298 let exact = finding_key(&finding.title, &finding.file);
299 let mut exact_matches = judged.iter().enumerate().filter(|(_, existing)| {
300 existing.standing == Standing::Unverified
301 && existing.raised_by != *name
302 && finding_key(&existing.finding.title, &existing.finding.file) == exact
303 });
304 let exact_match = exact_matches.next().map(|(index, _)| index);
305 let exact_match = exact_match.filter(|_| exact_matches.next().is_none());
306 match exact_match.map(|index| &mut judged[index]) {
307 Some(existing) => {
308 existing.finding.severity = existing.finding.severity.graver(finding.severity);
311 existing.standing = Standing::Corroborated;
312 existing.raised_by = format!("{} and {name}", existing.raised_by);
313 }
314 None => judged.push(Judged {
315 finding,
316 raised_by: name.clone(),
317 standing: Standing::Unverified,
318 counterpoint: None,
319 defence: None,
320 }),
321 }
322 }
323 }
324 judged
325}
326
327fn dedupe_exact_findings(findings: &[Finding]) -> Vec<Finding> {
332 let mut unique: Vec<Finding> = Vec::new();
333 for finding in findings {
334 let key = finding_key(&finding.title, &finding.file);
335 if let Some(existing) = unique
336 .iter_mut()
337 .find(|existing| finding_key(&existing.title, &existing.file) == key)
338 {
339 let severity = existing.severity.graver(finding.severity);
340 *existing = finding.clone();
341 existing.severity = severity;
342 } else {
343 unique.push(finding.clone());
344 }
345 }
346 unique
347}
348
349fn matching_finding(
357 judged: &[Judged],
358 title: &str,
359 file: &str,
360 eligible: impl Fn(&Judged) -> bool,
361) -> std::result::Result<usize, &'static str> {
362 let key = finding_key(title, file);
363 let exact: Vec<usize> = judged
364 .iter()
365 .enumerate()
366 .filter(|(_, j)| eligible(j) && finding_key(&j.finding.title, &j.finding.file) == key)
367 .map(|(i, _)| i)
368 .collect();
369 if exact.len() == 1 {
370 return Ok(exact[0]);
371 }
372 if exact.len() > 1 {
373 return Err("matched more than one finding");
374 }
375 let stable = crate::jsonx::stable_finding_key(title, file);
376 let loose: Vec<usize> = judged
377 .iter()
378 .enumerate()
379 .filter(|(_, j)| {
380 eligible(j)
381 && crate::jsonx::stable_finding_key(&j.finding.title, &j.finding.file) == stable
382 })
383 .map(|(i, _)| i)
384 .collect();
385 if loose.len() == 1 {
386 return Ok(loose[0]);
387 }
388 if loose.len() > 1 {
389 return Err("matched more than one finding");
390 }
391 let here: Vec<usize> = judged
399 .iter()
400 .enumerate()
401 .filter(|(_, j)| {
402 eligible(j)
403 && !file.trim().is_empty()
404 && crate::jsonx::finding_file(&j.finding.file) == crate::jsonx::finding_file(file)
405 })
406 .map(|(i, _)| i)
407 .collect();
408 match here.len() {
409 1 => Ok(here[0]),
410 0 => Err("matched no finding"),
411 _ => Err("matched more than one finding"),
412 }
413}
414
415fn adjudicate(
416 agents: &[Agent],
417 cfg: &Config,
418 repo: &Repo,
419 work_dir: &Path,
420 checkpoint: &WorktreeCheckpoint,
421 judged: &mut [Judged],
422 round: u32,
423) -> Result<()> {
424 let pending: Vec<usize> = judged
425 .iter()
426 .enumerate()
427 .filter(|(_, j)| j.standing == Standing::Unverified)
428 .map(|(i, _)| i)
429 .collect();
430 if pending.is_empty() {
431 return Ok(());
432 }
433 log!(
434 "cross-checking {} finding{} raised by one reviewer",
435 pending.len(),
436 plural(pending.len())
437 );
438
439 let answers = concurrently(agents, |adjudicator| {
440 let theirs: Vec<&Judged> = pending
442 .iter()
443 .map(|i| &judged[*i])
444 .filter(|j| j.raised_by != adjudicator.name())
445 .collect();
446 if theirs.is_empty() {
447 return Ok(AdjudicationDoc { verdicts: vec![] });
448 }
449 let listed: Vec<Finding> = theirs.iter().map(|j| j.finding.clone()).collect();
450 let prompt =
451 ADJUDICATE_PROMPT.replace("{findings}", &crate::review::findings_for_prompt(&listed));
452 adjudicator.ask_json::<AdjudicationDoc>(
453 &prompt,
454 &schema::adjudication(),
455 work_dir,
456 &adjudicator.effort(cfg, Call::Review(round)),
457 )
458 });
459
460 for (name, result) in answers {
461 let doc = match result {
462 Ok(doc) => doc,
463 Err(e) if e.kind() == ErrorKind::UncertainWrite => return Err(e),
464 Err(e) => {
465 logdim!("{name} could not adjudicate: {e}");
466 continue;
467 }
468 };
469 for verdict in doc.verdicts {
470 let found = matching_finding(judged, &verdict.title, &verdict.file, |j| {
471 j.raised_by != name && j.standing == Standing::Unverified
472 });
473 let target = match found {
474 Ok(index) => &mut judged[index],
475 Err(why) => {
476 logwarn!(
477 "{name}'s ruling on '{}' {why}, so it was dropped and that finding goes \
478 to a maintainer unexamined",
479 verdict.title
480 );
481 continue;
482 }
483 };
484 target.counterpoint = Some(style::summary(&verdict.reasoning, &repo.style));
485 if verdict.agrees {
486 target.standing = Standing::Confirmed;
487 target.finding.severity = target.finding.severity.graver(verdict.severity);
491 } else {
492 target.standing = Standing::Disputed;
493 }
494 }
495 }
496 repo.require_unchanged_worktree(work_dir, checkpoint, "read-only review worktree")?;
497 Ok(())
498}
499
500fn rebut(
501 agents: &[Agent],
502 cfg: &Config,
503 repo: &Repo,
504 work_dir: &Path,
505 checkpoint: &WorktreeCheckpoint,
506 judged: &mut [Judged],
507 round: u32,
508) -> Result<()> {
509 let disputed: Vec<usize> = judged
510 .iter()
511 .enumerate()
512 .filter(|(_, j)| j.standing == Standing::Disputed)
513 .map(|(i, _)| i)
514 .collect();
515 log!(
516 "{} disputed finding{} going back to whoever raised them",
517 disputed.len(),
518 plural(disputed.len())
519 );
520
521 let answers = concurrently(agents, |author| {
522 let mine: Vec<&Judged> = disputed
523 .iter()
524 .map(|i| &judged[*i])
525 .filter(|j| j.raised_by == author.name())
526 .collect();
527 if mine.is_empty() {
528 return Ok(AdjudicationDoc { verdicts: vec![] });
529 }
530 let listed = mine
531 .iter()
532 .map(|j| {
533 format!(
534 "- [{}] {} ({})\n {}\n OBJECTION: {}",
535 j.finding.severity,
536 j.finding.title,
537 j.finding.where_at(),
538 j.finding.detail,
539 j.counterpoint.as_deref().unwrap_or("(none given)")
540 )
541 })
542 .collect::<Vec<_>>()
543 .join("\n");
544 let prompt = REBUT_PROMPT.replace("{findings}", &listed);
545 author.ask_json::<AdjudicationDoc>(
546 &prompt,
547 &schema::adjudication(),
548 work_dir,
549 &author.effort(cfg, Call::Review(round)),
550 )
551 });
552
553 for (name, result) in answers {
554 let doc = match result {
555 Ok(doc) => doc,
556 Err(e) if e.kind() == ErrorKind::UncertainWrite => return Err(e),
557 Err(e) => {
558 logdim!("{name} could not answer the objections: {e}");
559 continue;
560 }
561 };
562 for verdict in doc.verdicts {
563 let found = matching_finding(judged, &verdict.title, &verdict.file, |j| {
564 j.raised_by == name && j.standing == Standing::Disputed
565 });
566 let target = match found {
567 Ok(index) => &mut judged[index],
568 Err(why) => {
569 logwarn!(
570 "{name}'s answer to the objection on '{}' {why}, so it was dropped and \
571 the point stays disputed with no defence",
572 verdict.title
573 );
574 continue;
575 }
576 };
577 if verdict.agrees {
578 target.defence = Some(style::sentence(&verdict.reasoning, &repo.style));
581 } else {
582 target.standing = Standing::Withdrawn;
583 }
584 }
585 }
586 repo.require_unchanged_worktree(work_dir, checkpoint, "read-only review worktree")?;
587 Ok(())
588}
589
590fn finish(
591 repo: &Repo,
592 pr: &PrView,
593 state: &mut IssueRun,
594 judged: &[Judged],
595 reviewers: usize,
596 dry_run: bool,
597) -> Result<()> {
598 let blocking = judged
599 .iter()
600 .filter(|j| j.finding.blocks() && j.standing.counts())
601 .count();
602 let contested = judged
606 .iter()
607 .filter(|j| j.finding.blocks() && j.standing == Standing::Disputed)
608 .count();
609
610 state.status = if blocking == 0 && contested == 0 {
611 Status::Clean
612 } else {
613 Status::Reviewed
614 };
615 if contested > 0 {
616 state.notes.push(format!(
617 "{contested} blocking finding{} the reviewers disagree about, which is your call",
618 plural(contested)
619 ));
620 }
621 for j in judged.iter().filter(|j| j.standing == Standing::Disputed) {
622 state.disputes.push(crate::model::Dispute {
623 title: style::title(&j.finding.title, &repo.style),
624 file: j.finding.file.clone(),
625 reasoning: j.counterpoint.clone().unwrap_or_default(),
626 });
627 }
628
629 let comment = verdict_comment_from(judged, &repo.style, reviewers);
630 let silent = dry_run || repo.style.pr_comments == crate::config::PrComments::None;
634 if silent {
635 println!("\n{comment}\n");
636 let why = if dry_run {
637 "dry run"
638 } else {
639 "pr_comments is none"
640 };
641 match repo.save_pending_comment(pr.number, &comment) {
642 Ok(path) => log!(
643 "{why}, nothing posted. Saved to {}. Post it with `spar post {}`, or edit that \
644 file first.",
645 path.display(),
646 pr.number
647 ),
648 Err(e) => logdim!("{why}, nothing posted, and could not save it: {e}"),
649 }
650 return Ok(());
651 }
652 match repo.comment_pr(pr.number, &comment) {
653 Ok(()) => log!(
654 "PR #{}: {}",
655 pr.number,
656 if blocking == 0 && contested == 0 {
657 "no blocking findings, review posted".to_string()
658 } else if blocking == 0 {
659 format!(
660 "{contested} disputed blocking finding{}, review posted",
661 plural(contested)
662 )
663 } else {
664 format!(
665 "{blocking} blocking finding{}, review posted",
666 plural(blocking)
667 )
668 }
669 ),
670 Err(e) => {
671 state.notes.push(format!("could not post the review: {e}"));
672 println!("\n{comment}\n");
673 }
674 }
675 Ok(())
676}
677
678impl Standing {
679 pub fn counts(self) -> bool {
681 matches!(
682 self,
683 Standing::Corroborated | Standing::Confirmed | Standing::Unverified
684 )
685 }
686
687 pub fn label(self) -> &'static str {
688 match self {
689 Standing::Corroborated => "both reviewers raised this independently",
690 Standing::Confirmed => "raised by one reviewer, confirmed by the other",
691 Standing::Disputed => "the reviewers disagree",
692 Standing::Withdrawn => "withdrawn",
693 Standing::Unverified => "raised by one reviewer, not cross-checked",
694 }
695 }
696}
697
698pub fn verdict_comment(judged: &[Judged], style: &Style) -> String {
700 verdict_comment_from(judged, style, 2)
701}
702
703pub fn verdict_comment_from(judged: &[Judged], style: &Style, reviewers: usize) -> String {
708 let live: Vec<&Judged> = judged.iter().filter(|j| j.standing.counts()).collect();
709 let pick = |severity: Severity| -> Vec<&Judged> {
710 live.iter()
711 .copied()
712 .filter(|j| j.finding.severity == severity && j.finding.in_scope)
713 .collect()
714 };
715 let blocking = pick(Severity::Blocking);
716 let non_blocking = pick(Severity::NonBlocking);
717 let nits = pick(Severity::Nit);
718 let disputed: Vec<&Judged> = judged
719 .iter()
720 .filter(|j| j.standing == Standing::Disputed)
721 .collect();
722 let withdrawn = judged
723 .iter()
724 .filter(|j| j.standing == Standing::Withdrawn)
725 .count();
726
727 let heading = if reviewers >= 2 {
731 "Two independent reviews"
732 } else {
733 "One review, nothing cross-checked"
734 };
735 let mut out = vec![if blocking.is_empty() && disputed.is_empty() {
736 format!("{heading}, nothing blocking a merge.")
737 } else {
738 format!("{heading}.")
739 }];
740 let _ = withdrawn;
741
742 let line = |j: &Judged| -> String {
743 let where_at = match j.finding.where_at() {
744 "general" => String::new(),
745 file => format!(" ({file})"),
746 };
747 let detail = style::detail(&j.finding.detail, style);
748 let attested = if j.standing == Standing::Corroborated {
749 " [both]"
750 } else if j.standing == Standing::Unverified {
751 " [one reviewer only]"
752 } else {
753 ""
754 };
755 if detail.is_empty() {
756 format!(
757 "- {}{where_at}{attested}",
758 style::title(&j.finding.title, style)
759 )
760 } else {
761 format!(
762 "- {}{where_at}{attested}. {detail}",
763 style::title(&j.finding.title, style)
764 )
765 }
766 };
767
768 if !blocking.is_empty() {
769 out.push(format!(
770 "needs changing before merge\n{}",
771 blocking
772 .iter()
773 .copied()
774 .map(line)
775 .collect::<Vec<_>>()
776 .join("\n")
777 ));
778 }
779 if !non_blocking.is_empty() {
780 out.push(format!(
781 "worth doing, does not block\n{}",
782 non_blocking
783 .iter()
784 .copied()
785 .map(line)
786 .collect::<Vec<_>>()
787 .join("\n")
788 ));
789 }
790 if !nits.is_empty() {
791 out.push(format!(
792 "nits\n{}",
793 nits.iter()
794 .copied()
795 .map(line)
796 .collect::<Vec<_>>()
797 .join("\n")
798 ));
799 }
800 if !disputed.is_empty() {
801 let lines: Vec<String> = disputed
802 .iter()
803 .map(|j| {
804 let mut line = format!(
805 "- {} ({})",
806 style::title(&j.finding.title, style),
807 j.finding.where_at()
808 );
809 if let Some(objection) = &j.counterpoint {
810 line.push_str(&format!(
811 ". Objection: {}",
812 style::sentence(objection, style)
813 ));
814 }
815 if let Some(defence) = &j.defence {
816 line.push_str(&format!(" Answer: {}", style::sentence(defence, style)));
817 }
818 line
819 })
820 .collect();
821 out.push(format!(
822 "the reviewers disagree, your call\n{}",
823 lines.join("\n")
824 ));
825 }
826
827 out.join("\n\n")
828}
829
830fn plural(n: usize) -> &'static str {
832 if n == 1 {
833 ""
834 } else {
835 "s"
836 }
837}
838
839fn concurrently<T, F>(agents: &[Agent], work: F) -> Vec<(String, Result<T>)>
841where
842 T: Send,
843 F: Fn(&Agent) -> Result<T> + Sync,
844{
845 std::thread::scope(|scope| {
846 let handles: Vec<_> = agents
847 .iter()
848 .map(|agent| scope.spawn(|| (agent.name().to_string(), work(agent))))
849 .collect();
850 handles
851 .into_iter()
852 .zip(agents)
853 .map(|(handle, agent)| {
854 handle.join().unwrap_or_else(|_| {
855 (
856 agent.name().to_string(),
857 Err(spar_err!("thread for '{}' panicked", agent.name())),
858 )
859 })
860 })
861 .collect()
862 })
863}
864
865#[cfg(test)]
866mod tests {
867 use super::*;
868
869 fn finding(severity: &str, title: &str, file: &str) -> Finding {
870 Finding {
871 severity: Severity::parse_lenient(severity).unwrap(),
872 title: title.into(),
873 detail: "why it matters".into(),
874 file: file.into(),
875 in_scope: true,
876 ..Default::default()
877 }
878 }
879
880 fn from(name: &str, findings: Vec<Finding>) -> (String, Vec<Finding>) {
881 (name.to_string(), findings)
882 }
883
884 fn unverified(title: &str, file: &str, raised_by: &str) -> Judged {
887 Judged {
888 finding: finding("blocking", title, file),
889 raised_by: raised_by.to_string(),
890 standing: Standing::Unverified,
891 counterpoint: None,
892 defence: None,
893 }
894 }
895
896 #[test]
904 fn a_paraphrased_verdict_still_finds_its_finding() {
905 let judged = vec![unverified(
906 "Retry loop never terminates",
907 "src/net.rs:88",
908 "claude",
909 )];
910 let eligible = |j: &Judged| j.raised_by != "codex" && j.standing == Standing::Unverified;
911
912 assert_eq!(
914 Ok(0),
915 matching_finding(
916 &judged,
917 "retry loop never terminates!",
918 "src/net.rs:88",
919 eligible
920 )
921 );
922 assert_eq!(
924 Ok(0),
925 matching_finding(
926 &judged,
927 "Retry loop does not terminate",
928 "src/net.rs",
929 eligible
930 )
931 );
932 }
933
934 #[test]
936 fn a_line_free_verdict_does_not_choose_between_two_findings_in_one_file() {
937 let judged = vec![
938 unverified("Retry loop never terminates", "src/net.rs:88", "claude"),
939 unverified("Retry loop leaks a socket", "src/net.rs:120", "claude"),
940 ];
941 let eligible = |j: &Judged| j.raised_by != "codex" && j.standing == Standing::Unverified;
942
943 assert_eq!(
944 Err("matched more than one finding"),
945 matching_finding(&judged, "Something about retries", "src/net.rs", eligible),
946 "the fallback guessed between two findings in one file"
947 );
948 assert_eq!(
950 Ok(1),
951 matching_finding(
952 &judged,
953 "Retry loop leaks a socket",
954 "src/net.rs:120",
955 eligible
956 )
957 );
958 }
959
960 #[test]
966 fn a_disputed_blocker_is_not_a_clean_pull_request() {
967 let disputed = |title: &str| Judged {
968 finding: finding("blocking", title, "src/net.rs"),
969 raised_by: "claude".into(),
970 standing: Standing::Disputed,
971 counterpoint: Some("I read the code and it terminates".into()),
972 defence: None,
973 };
974 let judged = vec![disputed("Retry loop never terminates")];
975
976 let contested = judged
977 .iter()
978 .filter(|j| j.finding.blocks() && j.standing == Standing::Disputed)
979 .count();
980 let counted = judged
981 .iter()
982 .filter(|j| j.finding.blocks() && j.standing.counts())
983 .count();
984 assert_eq!(0, counted, "a disputed finding is not attested");
985 assert_eq!(
986 1, contested,
987 "and it is still a blocker somebody has to weigh"
988 );
989
990 let text = verdict_comment(&judged, &Style::default());
992 assert!(text.contains("the reviewers disagree, your call"), "{text}");
993 assert!(
994 !text.contains("nothing blocking a merge"),
995 "the header contradicted the section below it:\n{text}"
996 );
997 }
998
999 #[test]
1001 fn the_header_counts_the_reviewers_that_answered() {
1002 let judged = vec![Judged {
1003 finding: finding("non-blocking", "A small thing", "a.rs"),
1004 raised_by: "claude".into(),
1005 standing: Standing::Unverified,
1006 counterpoint: None,
1007 defence: None,
1008 }];
1009 let two = verdict_comment_from(&judged, &Style::default(), 2);
1010 assert!(two.starts_with("Two independent reviews"), "{two}");
1011
1012 let one = verdict_comment_from(&judged, &Style::default(), 1);
1013 assert!(
1014 one.starts_with("One review, nothing cross-checked"),
1015 "{one}"
1016 );
1017 }
1018
1019 #[test]
1022 fn a_verdict_never_matches_the_agent_that_raised_the_finding() {
1023 let judged = vec![unverified("Retry loop spins", "src/net.rs", "codex")];
1024 assert_eq!(
1025 Err("matched no finding"),
1026 matching_finding(&judged, "Retry loop spins", "src/net.rs", |j| j.raised_by
1027 != "codex")
1028 );
1029 }
1030
1031 #[test]
1037 fn a_finding_both_reviewers_reached_alone_is_corroborated() {
1038 let judged = corroborate(&[
1039 from(
1040 "claude",
1041 vec![finding("blocking", "Retry loop spins", "src/net.rs")],
1042 ),
1043 from(
1044 "codex",
1045 vec![finding("blocking", "retry loop spins!", "src/net.rs")],
1046 ),
1047 ]);
1048 assert_eq!(1, judged.len(), "the same point must not be listed twice");
1049 assert_eq!(Standing::Corroborated, judged[0].standing);
1050 assert!(judged[0].raised_by.contains("claude"));
1051 assert!(judged[0].raised_by.contains("codex"));
1052 }
1053
1054 #[test]
1055 fn a_finding_only_one_reviewer_raised_starts_unverified() {
1056 let judged = corroborate(&[
1057 from(
1058 "claude",
1059 vec![finding("blocking", "Only claude saw this", "a.rs")],
1060 ),
1061 from("codex", vec![]),
1062 ]);
1063 assert_eq!(Standing::Unverified, judged[0].standing);
1064 assert_eq!("claude", judged[0].raised_by);
1065 }
1066
1067 #[test]
1068 fn repeating_a_finding_is_not_an_independent_opinion() {
1069 let repeated = finding("non-blocking", "Unchecked error", "src/net.rs:10");
1070 let mut graver = repeated.clone();
1071 graver.severity = Severity::Blocking;
1072 graver.detail = "confirmed by the failing path".into();
1073
1074 let judged = corroborate(&[
1075 from("first", vec![repeated, graver]),
1076 from("second", vec![]),
1077 ]);
1078
1079 assert_eq!(1, judged.len());
1080 assert_eq!(Standing::Unverified, judged[0].standing);
1081 assert_eq!(Severity::Blocking, judged[0].finding.severity);
1082 assert_eq!("confirmed by the failing path", judged[0].finding.detail);
1083 }
1084
1085 #[test]
1086 fn one_repeated_finding_and_one_independent_match_corroborate_once() {
1087 let repeated = finding("blocking", "Unchecked error", "src/net.rs:10");
1088 let judged = corroborate(&[
1089 from("first", vec![repeated.clone(), repeated]),
1090 from(
1091 "second",
1092 vec![finding("blocking", "unchecked error!", "src/net.rs:10")],
1093 ),
1094 ]);
1095
1096 assert_eq!(1, judged.len());
1097 assert_eq!(Standing::Corroborated, judged[0].standing);
1098 }
1099
1100 #[test]
1101 fn the_same_title_in_a_different_file_is_two_findings() {
1102 let judged = corroborate(&[
1103 from("claude", vec![finding("nit", "Naming", "a.rs")]),
1104 from("codex", vec![finding("nit", "Naming", "b.rs")]),
1105 ]);
1106 assert_eq!(2, judged.len());
1107 }
1108
1109 #[test]
1110 fn one_reviewer_cannot_corroborate_itself_at_two_sites() {
1111 let judged = corroborate(&[
1112 from(
1113 "first",
1114 vec![
1115 finding("blocking", "Unchecked error", "src/net.rs:10"),
1116 finding("blocking", "Unchecked error", "src/net.rs:200"),
1117 ],
1118 ),
1119 from("second", vec![]),
1120 ]);
1121 assert_eq!(2, judged.len());
1122 assert!(judged
1123 .iter()
1124 .all(|finding| finding.standing == Standing::Unverified));
1125 }
1126
1127 #[test]
1128 fn different_sites_are_not_corroborated() {
1129 let judged = corroborate(&[
1130 from(
1131 "first",
1132 vec![finding("blocking", "Unchecked error", "src/net.rs:10")],
1133 ),
1134 from(
1135 "second",
1136 vec![finding("blocking", "Unchecked error", "src/net.rs:12")],
1137 ),
1138 ]);
1139 assert_eq!(2, judged.len());
1140 assert!(judged
1141 .iter()
1142 .all(|finding| finding.standing == Standing::Unverified));
1143 }
1144
1145 #[test]
1148 fn disagreement_about_severity_keeps_the_graver_one() {
1149 let judged = corroborate(&[
1150 from("claude", vec![finding("nit", "Unbounded loop", "a.rs")]),
1151 from("codex", vec![finding("blocking", "unbounded loop", "a.rs")]),
1152 ]);
1153 assert_eq!(Severity::Blocking, judged[0].finding.severity);
1154
1155 let judged = corroborate(&[
1157 from(
1158 "claude",
1159 vec![finding("blocking", "Unbounded loop", "a.rs")],
1160 ),
1161 from("codex", vec![finding("nit", "unbounded loop", "a.rs")]),
1162 ]);
1163 assert_eq!(Severity::Blocking, judged[0].finding.severity);
1164 }
1165
1166 #[test]
1167 fn severity_ordering_does_not_depend_on_declaration_order() {
1168 assert_eq!(Severity::Blocking, Severity::Blocking.graver(Severity::Nit));
1169 assert_eq!(Severity::Blocking, Severity::Nit.graver(Severity::Blocking));
1170 assert_eq!(
1171 Severity::NonBlocking,
1172 Severity::Nit.graver(Severity::NonBlocking)
1173 );
1174 assert!(Severity::Blocking.rank() > Severity::NonBlocking.rank());
1175 assert!(Severity::NonBlocking.rank() > Severity::Nit.rank());
1176 }
1177
1178 #[test]
1179 fn a_single_reviewer_still_produces_a_list() {
1180 let judged = corroborate(&[from("claude", vec![finding("blocking", "A", "a.rs")])]);
1181 assert_eq!(1, judged.len());
1182 assert_eq!(Standing::Unverified, judged[0].standing);
1183 }
1184
1185 #[test]
1188 fn only_surviving_standings_count() {
1189 assert!(Standing::Corroborated.counts());
1190 assert!(Standing::Confirmed.counts());
1191 assert!(Standing::Unverified.counts());
1192 assert!(
1193 !Standing::Disputed.counts(),
1194 "a disputed point is listed separately"
1195 );
1196 assert!(
1197 !Standing::Withdrawn.counts(),
1198 "a withdrawn point is not a finding"
1199 );
1200 }
1201
1202 fn judged(standing: Standing, severity: &str, title: &str) -> Judged {
1203 Judged {
1204 finding: finding(severity, title, "src/net.rs"),
1205 raised_by: "claude".into(),
1206 standing,
1207 counterpoint: None,
1208 defence: None,
1209 }
1210 }
1211
1212 #[test]
1213 fn a_clean_pr_says_so_in_one_breath() {
1214 let text = verdict_comment(&[], &Style::default());
1215 assert!(
1216 text.starts_with("Two independent reviews, nothing blocking a merge."),
1217 "{text}"
1218 );
1219 }
1220
1221 #[test]
1222 fn a_corroborated_blocker_is_marked_as_such() {
1223 let text = verdict_comment(
1224 &[judged(
1225 Standing::Corroborated,
1226 "blocking",
1227 "Retry loop spins",
1228 )],
1229 &Style::default(),
1230 );
1231 assert!(text.contains("needs changing before merge"), "{text}");
1232 assert!(text.contains("[both]"), "{text}");
1233 }
1234
1235 #[test]
1236 fn an_uncrosschecked_finding_is_flagged_as_one_reviewers_opinion() {
1237 let text = verdict_comment(
1238 &[judged(
1239 Standing::Unverified,
1240 "blocking",
1241 "Only one saw this",
1242 )],
1243 &Style::default(),
1244 );
1245 assert!(text.contains("[one reviewer only]"), "{text}");
1246 }
1247
1248 #[test]
1249 fn a_confirmed_finding_carries_no_qualifier() {
1250 let text = verdict_comment(
1251 &[judged(Standing::Confirmed, "blocking", "Checked and real")],
1252 &Style::default(),
1253 );
1254 assert!(
1255 !text.contains("[both]") && !text.contains("[one reviewer only]"),
1256 "{text}"
1257 );
1258 }
1259
1260 #[test]
1263 fn a_withdrawn_finding_never_reaches_the_maintainer() {
1264 let text = verdict_comment(
1265 &[judged(
1266 Standing::Withdrawn,
1267 "blocking",
1268 "Wrong on a second look",
1269 )],
1270 &Style::default(),
1271 );
1272 assert!(!text.contains("Wrong on a second look"), "{text}");
1273 assert!(
1274 !text.to_lowercase().contains("withdrawn"),
1275 "a point nobody can see or act on is not worth a sentence: {text}"
1276 );
1277 assert!(text.contains("nothing blocking a merge"), "{text}");
1278 }
1279
1280 #[test]
1281 fn a_disputed_finding_goes_to_a_person_with_both_sides() {
1282 let mut j = judged(Standing::Disputed, "blocking", "Error is swallowed");
1283 j.counterpoint = Some("the caller already validates the file".into());
1284 let text = verdict_comment(&[j], &Style::default());
1285 assert!(text.contains("the reviewers disagree, your call"), "{text}");
1286 assert!(
1287 text.contains("Objection: The caller already validates"),
1288 "{text}"
1289 );
1290 assert!(
1291 !text.contains("needs changing before merge"),
1292 "disputed does not block: {text}"
1293 );
1294 }
1295
1296 #[test]
1297 fn the_three_severities_are_kept_apart() {
1298 let text = verdict_comment(
1299 &[
1300 judged(Standing::Corroborated, "blocking", "Must fix"),
1301 judged(Standing::Confirmed, "non-blocking", "Could improve"),
1302 judged(Standing::Confirmed, "nit", "Taste"),
1303 ],
1304 &Style::default(),
1305 );
1306 assert!(
1307 !text.contains("1 blocking"),
1308 "counts are listed below, not above: {text}"
1309 );
1310 assert!(text.contains("needs changing before merge"), "{text}");
1311 assert!(text.contains("worth doing, does not block"), "{text}");
1312 assert!(text.contains("nits"), "{text}");
1313 }
1314
1315 #[test]
1318 fn a_thorough_reviewer_is_not_cut_short() {
1319 let mut j = judged(Standing::Corroborated, "blocking", "A real problem");
1320 j.finding.detail = "Here is a step of the reproduction. ".repeat(20);
1321 let text = verdict_comment(&[j], &Style::default());
1322 assert!(
1323 text.contains(
1324 &"Here is a step of the reproduction. "
1325 .repeat(20)
1326 .trim()
1327 .to_string()
1328 ) || text.len() > 600,
1329 "the explanation survived: {} chars",
1330 text.len()
1331 );
1332 }
1333
1334 #[test]
1335 fn a_runaway_reviewer_is_still_bounded() {
1336 let mut j = judged(Standing::Corroborated, "blocking", "A real problem");
1337 j.finding.detail = "filler ".repeat(20_000);
1338 let text = verdict_comment(&[j], &Style::default());
1339 assert!(text.len() < 20_000, "{} chars", text.len());
1340 }
1341
1342 #[test]
1343 fn an_out_of_scope_finding_does_not_ask_the_contributor_to_fix_it() {
1344 let mut j = judged(Standing::Corroborated, "blocking", "Pre-existing bug");
1345 j.finding.in_scope = false;
1346 let text = verdict_comment(&[j], &Style::default());
1347 assert!(!text.contains("needs changing before merge"), "{text}");
1348 assert!(text.contains("nothing blocking a merge"), "{text}");
1349 }
1350}