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