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    // The verdict was posted by `finish`, several statements ago. A checkout
159    // that cannot be tidied afterwards is a worktree to go and look at, not a
160    // reason to report an hour of review as `rounds=0` and an error.
161    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    // -- phase 1: two independent reviews, at the same time ---------------
185    log!(
186        "PR #{}: {} reviewing independently",
187        pr.number,
188        agents
189            .iter()
190            .map(Agent::name)
191            .collect::<Vec<_>>()
192            .join(" and ")
193    );
194    // The ref spar compares against, not the bare branch name: in a linked
195    // worktree the local branch is whatever the primary checkout last had.
196    let base_ref = repo.base_ref(work_dir, base);
197    // The issue it says it closes, and the author's own account of the change.
198    // The reviewer is asked whether the change does what it claims, and both
199    // halves of that question were missing from the prompt.
200    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                // One reviewer failing is a degraded review, not a dead one,
247                // but the report has to say so rather than quietly halving the
248                // coverage the whole design rests on.
249                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        // The whole design is one model checking another. A single reviewer is
266        // a materially weaker result, not a footnote, so it is said loudly and
267        // marked on every finding in the comment.
268        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    // -- phase 2: each rules on what only the other raised ----------------
281    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    // -- phase 3: whoever raised a rejected point defends it or drops it --
292    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
305/// Findings both reviewers reached on their own, matched by finding key.
306fn 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                    // Both reached it independently. Keep the graver severity:
322                    // one reviewer calling it blocking is a reason to look.
323                    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
340/// Collapse repeated output from one reviewer before looking for independent
341/// corroboration. Repeating a point is not a second opinion, and leaving both
342/// copies in the pool can prevent the other reviewer's matching point from
343/// corroborating either one.
344fn 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
362/// The finding a verdict rules on, or `None` with the reason.
363///
364/// Exact title and location first, then the line-tolerant key when it
365/// identifies a single eligible finding, which is the fallback the custody loop
366/// learned to use. Neither tolerated a paraphrased title before, and a miss did
367/// nothing at all: a rejection was discarded and the finding went to a
368/// maintainer tagged as unexamined, with the argument against it nowhere.
369fn 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    // Last, the place alone. An adjudicator reads a finding and writes the
405    // point back in its own words, which no hash of the title can follow, and
406    // "Retry loop never terminates" against "Retry loop does not terminate" is
407    // the ordinary shape of that. It is safe here only because the adjudicator
408    // was handed exactly the eligible findings and exactly one of them is at
409    // this path: two findings in one file fall through to the miss below rather
410    // than guess between them.
411    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        // Each agent rules on what the *other* raised, never on its own.
454        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                // A second reader who agrees it is real but calls it a nit is
501                // exactly the signal that stops a nitpick reaching a
502                // maintainer as a blocker.
503                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                // Stands by it. Kept separate from the objection so a person
592                // can weigh the two arguments rather than read them spliced.
593                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    // A blocking finding the two reviewers disagree about is not a clean pull
616    // request. The comment says "the reviewers disagree, your call", and the
617    // terminal used to say `clean` beside it.
618    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    // `pr_comments = "none"` promises spar will not comment on a pull request.
644    // Review mode used to post regardless, which made the promise false and left
645    // --dry-run as the only way to keep it.
646    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    /// Whether a finding should be put in front of a maintainer as real.
693    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
711/// The one thing a maintainer reads.
712pub fn verdict_comment(judged: &[Judged], style: &Style) -> String {
713    verdict_comment_from(judged, style, 2)
714}
715
716/// The same, told how many reviewers actually answered.
717///
718/// "Two independent reviews." is what makes [both] and [one reviewer only] mean
719/// anything, and it was printed whether two answered or one did.
720pub 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    // "Two independent reviews" stays: it is the only thing that makes [both],
741    // [one reviewer only] and the disagreement heading below mean anything. The
742    // counts go, because everything they count is listed immediately after.
743    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
843/// "s" unless there is exactly one of the thing.
844fn plural(n: usize) -> &'static str {
845    if n == 1 {
846        ""
847    } else {
848        "s"
849    }
850}
851
852/// Run the same closure on every agent at once.
853pub(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    // -- matching a verdict back to its finding ---------------------------
898
899    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    /// An adjudicator rewords the title it is ruling on, and its ruling was
910    /// dropped without a word.
911    ///
912    /// This is the mode the README recommends as the cheapest way to adopt
913    /// spar, and its whole value is the attestation. A dropped rejection puts a
914    /// finding a reviewer went to the code and rejected in front of a
915    /// maintainer tagged as unexamined, with the argument against it nowhere.
916    #[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        // Punctuation and case noise.
926        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        // A dropped line number, which #48 taught the custody loop to tolerate.
936        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    /// The fallback is only safe where it is unambiguous.
948    #[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        // The exact one still lands.
962        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    /// A pull request whose only blocking finding is disputed is not clean.
974    ///
975    /// `Standing::Disputed` does not count toward the blocking total, so the
976    /// terminal said `clean` while the posted comment said "the reviewers
977    /// disagree, your call" about a merge blocker.
978    #[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        // Which is what the comment says about it.
1004        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    /// The header is what makes [both] and [one reviewer only] mean anything.
1013    #[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    /// A verdict on somebody's own finding is not eligible: each agent rules on
1033    /// what the other raised.
1034    #[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    // -- corroboration ---------------------------------------------------
1045
1046    /// The whole thesis of the tool: a defect two models reach independently,
1047    /// with different training and different blind spots, is worth more than
1048    /// either one saying it twice.
1049    #[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    /// Resolved upward on purpose. Nothing here gates a merge, so advice that
1159    /// under-reports a real defect is worse than advice that over-reports.
1160    #[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        // And the other way round, so it is not an artefact of ordering.
1169        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    // -- what reaches a maintainer ---------------------------------------
1199
1200    #[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    /// The point of the whole exercise: a claim one model made and the other
1274    /// read the code and rejected does not go to a maintainer as fact.
1275    #[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    /// A reviewer with a lot to say is not the problem, and clipping the
1329    /// explanation of a defect helps nobody. Only a runaway is bounded.
1330    #[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}