Skip to main content

spar/
review_only.rs

1//! Reviewing a pull request without touching it.
2//!
3//! The custody loop in [`crate::review`] converges because the diff changes
4//! between rounds: a reviewer objects, an author fixes, the next review sees
5//! something new. Here nothing changes. The pull request belongs to somebody
6//! else, spar has no write access to it, and the product is the finding list
7//! rather than a commit.
8//!
9//! So the loop is not "review until it converges", which would just re-litigate
10//! the same unchanged code until the budget ran out. It is three phases:
11//!
12//! 1. **Independent review.** Both agents review at the same time, neither
13//!    seeing the other. A finding both reach on their own is the strongest
14//!    signal available, and it costs nothing extra to look for.
15//! 2. **Cross-adjudication.** Each reads the other's remaining findings, goes
16//!    to the code, and rules on them. A finding one model raised and the other
17//!    examined and rejected is usually pattern matching, and saying so is more
18//!    useful to a maintainer than forwarding both.
19//! 3. **Rebuttal.** Anything rejected goes back to whoever raised it, to
20//!    withdraw or to substantiate with the line, the input, the failing case.
21//!
22//! What survives is sorted by how well it is attested, and anything the two
23//! still disagree about is handed to a person rather than resolved by fiat.
24
25use 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/// The independent review prompt, for the test that holds it and
39/// `schema::review()` to one definition of each severity.
40#[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
104/// Review a pull request without changing it.
105pub 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    // -- phase 1: two independent reviews, at the same time ---------------
177    log!(
178        "PR #{}: {} reviewing independently",
179        pr.number,
180        agents
181            .iter()
182            .map(Agent::name)
183            .collect::<Vec<_>>()
184            .join(" and ")
185    );
186    // The ref spar compares against, not the bare branch name: in a linked
187    // worktree the local branch is whatever the primary checkout last had.
188    let base_ref = repo.base_ref(work_dir, base);
189    // The issue it says it closes, and the author's own account of the change.
190    // The reviewer is asked whether the change does what it claims, and both
191    // halves of that question were missing from the prompt.
192    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                // One reviewer failing is a degraded review, not a dead one,
234                // but the report has to say so rather than quietly halving the
235                // coverage the whole design rests on.
236                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        // The whole design is one model checking another. A single reviewer is
253        // a materially weaker result, not a footnote, so it is said loudly and
254        // marked on every finding in the comment.
255        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    // -- phase 2: each rules on what only the other raised ----------------
268    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    // -- phase 3: whoever raised a rejected point defends it or drops it --
279    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
292/// Findings both reviewers reached on their own, matched by finding key.
293fn 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                    // Both reached it independently. Keep the graver severity:
309                    // one reviewer calling it blocking is a reason to look.
310                    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
327/// Collapse repeated output from one reviewer before looking for independent
328/// corroboration. Repeating a point is not a second opinion, and leaving both
329/// copies in the pool can prevent the other reviewer's matching point from
330/// corroborating either one.
331fn 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
349/// The finding a verdict rules on, or `None` with the reason.
350///
351/// Exact title and location first, then the line-tolerant key when it
352/// identifies a single eligible finding, which is the fallback the custody loop
353/// learned to use. Neither tolerated a paraphrased title before, and a miss did
354/// nothing at all: a rejection was discarded and the finding went to a
355/// maintainer tagged as unexamined, with the argument against it nowhere.
356fn 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    // Last, the place alone. An adjudicator reads a finding and writes the
392    // point back in its own words, which no hash of the title can follow, and
393    // "Retry loop never terminates" against "Retry loop does not terminate" is
394    // the ordinary shape of that. It is safe here only because the adjudicator
395    // was handed exactly the eligible findings and exactly one of them is at
396    // this path: two findings in one file fall through to the miss below rather
397    // than guess between them.
398    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        // Each agent rules on what the *other* raised, never on its own.
441        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                // A second reader who agrees it is real but calls it a nit is
488                // exactly the signal that stops a nitpick reaching a
489                // maintainer as a blocker.
490                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                // Stands by it. Kept separate from the objection so a person
579                // can weigh the two arguments rather than read them spliced.
580                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    // A blocking finding the two reviewers disagree about is not a clean pull
603    // request. The comment says "the reviewers disagree, your call", and the
604    // terminal used to say `clean` beside it.
605    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    // `pr_comments = "none"` promises spar will not comment on a pull request.
631    // Review mode used to post regardless, which made the promise false and left
632    // --dry-run as the only way to keep it.
633    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    /// Whether a finding should be put in front of a maintainer as real.
680    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
698/// The one thing a maintainer reads.
699pub fn verdict_comment(judged: &[Judged], style: &Style) -> String {
700    verdict_comment_from(judged, style, 2)
701}
702
703/// The same, told how many reviewers actually answered.
704///
705/// "Two independent reviews." is what makes [both] and [one reviewer only] mean
706/// anything, and it was printed whether two answered or one did.
707pub 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    // "Two independent reviews" stays: it is the only thing that makes [both],
728    // [one reviewer only] and the disagreement heading below mean anything. The
729    // counts go, because everything they count is listed immediately after.
730    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
830/// "s" unless there is exactly one of the thing.
831fn plural(n: usize) -> &'static str {
832    if n == 1 {
833        ""
834    } else {
835        "s"
836    }
837}
838
839/// Run the same closure on every agent at once.
840fn 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    // -- matching a verdict back to its finding ---------------------------
885
886    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    /// An adjudicator rewords the title it is ruling on, and its ruling was
897    /// dropped without a word.
898    ///
899    /// This is the mode the README recommends as the cheapest way to adopt
900    /// spar, and its whole value is the attestation. A dropped rejection puts a
901    /// finding a reviewer went to the code and rejected in front of a
902    /// maintainer tagged as unexamined, with the argument against it nowhere.
903    #[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        // Punctuation and case noise.
913        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        // A dropped line number, which #48 taught the custody loop to tolerate.
923        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    /// The fallback is only safe where it is unambiguous.
935    #[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        // The exact one still lands.
949        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    /// A pull request whose only blocking finding is disputed is not clean.
961    ///
962    /// `Standing::Disputed` does not count toward the blocking total, so the
963    /// terminal said `clean` while the posted comment said "the reviewers
964    /// disagree, your call" about a merge blocker.
965    #[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        // Which is what the comment says about it.
991        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    /// The header is what makes [both] and [one reviewer only] mean anything.
1000    #[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    /// A verdict on somebody's own finding is not eligible: each agent rules on
1020    /// what the other raised.
1021    #[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    // -- corroboration ---------------------------------------------------
1032
1033    /// The whole thesis of the tool: a defect two models reach independently,
1034    /// with different training and different blind spots, is worth more than
1035    /// either one saying it twice.
1036    #[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    /// Resolved upward on purpose. Nothing here gates a merge, so advice that
1146    /// under-reports a real defect is worse than advice that over-reports.
1147    #[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        // And the other way round, so it is not an artefact of ordering.
1156        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    // -- what reaches a maintainer ---------------------------------------
1186
1187    #[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    /// The point of the whole exercise: a claim one model made and the other
1261    /// read the code and rejected does not go to a maintainer as fact.
1262    #[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    /// A reviewer with a lot to say is not the problem, and clipping the
1316    /// explanation of a defect helps nobody. Only a runaway is bounded.
1317    #[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}