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