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