Skip to main content

spar/
review.rs

1//! Alternating custody until a PR converges.
2//!
3//! Roles are not fixed. Whoever holds the PR may implement, review, fix, or
4//! file follow-ups, and then hands custody to the other. An agent never
5//! reviews its own most recent edit, and custody follows the commit that
6//! landed rather than the action a reviewer asked for: a call that returns is
7//! not a call that wrote anything.
8//!
9//! Three failure modes are handled explicitly here, because each one breaks a
10//! naive loop:
11//!
12//! - **The nitpick spiral.** Round 6 findings are worse than round 1 findings
13//!   and a loop that counts objections cannot tell. Only `blocking` gates.
14//! - **Re-litigation.** A refuted point re-raised forever never terminates.
15//!   Refutations are hashed into a ledger carried across rounds.
16//! - **Approval drift.** Optimising for "get approved" pressures the author
17//!   into accepting wrong review comments, so refutation is blessed and the
18//!   merge gate is blocking-findings-empty, not reviewer-satisfied.
19
20use std::collections::BTreeSet;
21use std::path::{Path, PathBuf};
22
23use crate::agent::{self, Agent};
24use crate::comments::{issue_for_prompt, THREAD_RULE};
25use crate::config::{Call, Config, Drafts, Followups, PrComments};
26use crate::error::{ErrorKind, Result, SparError};
27use crate::jsonx::{exact_finding_key as finding_key, finding_file, stable_finding_key};
28use crate::model::{
29    Action, CriterionStatus, Disposition, Dispute, Finding, Followup, Implementation, Issue,
30    IssueRun, Ledger, LedgerEntry, Mark, NextAction, OwnFix, OwnFixDoc, PersistedState, PlanItem,
31    PrView, ResponseDoc, Review, Ruled, Settled, Severity, SkippedItem, Status, Verdict,
32    STATE_VERSION,
33};
34use crate::repo::{PushTarget, Repo};
35use crate::style::{self, Style};
36use crate::template::fill;
37use crate::tracker::{self, Item};
38use crate::{bail, log, logdim, logwarn, schema, spar_err};
39
40// ---------------------------------------------------------------------------
41// Prompts
42// ---------------------------------------------------------------------------
43
44const IMPLEMENT_PROMPT: &str = "\
45Implement GitHub issue #{number} in this repository.
46
47Title: {title}
48URL: {url}
49
50{body}
51
52That is the issue as filed, followed by any comments on it since, oldest first.
53{thread_rule}{criteria}
54
55Do the work and run the relevant tests. Leave the changes uncommitted. Do not
56commit, push, open a PR, or merge; the harness validates and commits the working
57tree after your report is accepted.
58
59Then report it. Your answer becomes the pull request description, and the
60reviewer reads that cold, with nothing but the diff and a link to the issue:
61say what you found wrong, what the change does about it, and how they confirm
62it for themselves. Say what you actually ran, not what could be run.
63
64If after reading the code you conclude this issue should not be implemented,
65make no changes and set not_worth_doing, with the reason.";
66
67const REVIEW_PROMPT: &str = "\
68Review the changes on this branch against `{base}`. They implement issue
69#{number}: {title}
70{context}{criteria}
71Review thoroughly: correctness, edge cases, error handling, security, and
72whether the change actually resolves the issue. Read surrounding code, do not
73only read the diff.
74
75Label every finding by severity, and be honest about which is which:
76- blocking: the PR should not merge as is. Real defects only.
77- non-blocking: real, and smaller than another round. A minor defect belongs
78  here as much as an improvement does.
79- nit: style or taste.
80
81Blocking is the only severity that costs a round, and a round is another commit
82somebody has to read before this can merge. Being right that something is wrong
83is not enough to block. It has to be wrong in a way that would cost somebody.
84
85Confirm anything you label blocking before you label it. Run the code,
86reproduce the failure, or point at the exact line that breaks, and say in the
87detail what you did to confirm it. When you need to run something to check a
88claim, write a scratch file under the system temporary directory and run that,
89rather than passing a long program on the command line: it is easier to read
90back, easier to rerun, and less likely to be refused by a sandbox or a safety
91filter part way through your work. An unverified blocking finding is worse than
92one you never raised: it stalls a good PR and teaches the author to stop
93believing you. If you suspect a problem but could not confirm it, say so and
94label it non-blocking.
95
96Set in_scope=false for a real defect that exists, that this PR did not cause, and
97that is worth somebody stopping to fix. Each one becomes a tracked item a
98maintainer has to read and triage, so the bar is a defect and not an observation.
99A thorough reviewer can always find something adjacent to what it is reading;
100that is not a reason to file it. If you are not sure it is worth a maintainer's
101time, say your piece in the finding and label it non-blocking.
102
103Reviewing one issue should not manufacture ten more. If you find yourself with
104several out of scope findings, keep the ones that would bite somebody and drop
105the rest.
106
107What each label means for what happens next, so you can choose it knowing:
108- blocking: the author answers it this round, or you fix it yourself, and the
109  pull request does not merge until it is settled.
110- non-blocking: offered to the author in the same round as an optional fix, and
111  reported for a person either way. Nothing holds the merge for it.
112- nit: dropped. Nobody acts on it.
113
114Then choose next_action:
115- merge: no blocking findings, the PR is good.
116- fix_myself: there are blocking findings and you will fix them directly.
117- hand_back: there are blocking findings the author should address.
118
119Choose fix_myself for a mechanical fix nobody would argue about: the missing
120guard, the wrong comparison, the case the code plainly meant to handle. Choose
121hand_back when the author might reasonably disagree, or when the fix needs
122context you do not have. Fixing it yourself costs the author its chance to
123refute the point, and a refutation is a legitimate outcome here: an author that
124accepts every review comment to get approved produces worse code, not better.
125{open}{answers}{settled}{round}";
126
127const CLOSE_PROMPT: &str = "\
128This closes the review of issue #{number}: {title}
129{context}{criteria}
130This is the final merge-safety audit. Read the full branch against `{base}` and
131answer one question: does the branch still contain anything that must not
132merge. Do not spend this pass on optional improvements or style.
133{landed}{open}{answers}{settled}
134Something blocks here when the branch still contains a confirmed defect that
135means it should not merge. That includes an open point above that the code does
136not answer, a defect in what landed since the last round, or a serious defect an
137earlier round missed. Go and look before you raise it. Run the test that covers
138it, or read the relevant lines and follow them to the caller, and say in the
139detail what you did.
140
141Keep this pass focused on merge safety. Minor defects and improvements are
142non-blocking. Keep in_scope=false for what it has always meant, a real defect
143this pull request did not cause, which the harness handles according to the
144configured follow-up policy. A confirmed in-scope defect does not become
145non-blocking merely because an earlier round missed it.
146
147Nothing you raise here will be fixed, because there is no round after this. A
148blocking finding means the pull request stays open and the finding is reported
149for a person to weigh. Non-blocking findings are reported without holding it
150open. No blocking findings means the branch is signed off on your word, so do
151not omit one because the list was long. Both mistakes cost somebody. Only one
152of them ships.
153
154Set next_action to merge when you raise nothing blocking, and hand_back when you
155do. Nothing acts on it here, and the findings are what decide.
156
157This call reads and nothing else. Do not edit the code, do not commit, and do
158not push. A pass that writes has judged a branch the rollback then takes away,
159so anything you leave behind is rolled back and the sign off does not stand.
160Put any scratch file under the system temporary directory, not in the working
161tree.";
162
163const FIX_PROMPT: &str = "\
164You reviewed this branch and chose to fix the blocking findings yourself.
165Implement those fixes now. Leave the changes uncommitted so the harness can
166validate and commit them.
167
168Your findings:
169{findings}
170
171Fix what the point says and nothing else. The smallest change that answers it is
172the right one: no refactor alongside it, no capability nobody asked for, no
173handling for cases nobody raised. Every line you add is what the next pass
174reviews, so a fix that grows the branch buys another round of findings about the
175fix. If a point cannot be answered without a change bigger than the point, set
176fixed=false for it and say why, rather than making the change.
177
178Report on every point above: fixed=true only where a change in the working tree
179answers it, and fixed=false with the reason where you left it. A point you
180report as left stays open and goes to the author, with your reason, so write
181that reason for them. Copy each title and file across exactly as given, so your
182answer can be matched back to the finding. The summary becomes the commit
183subject, so write one sentence naming what changed.
184
185Do not commit, push, or merge.";
186
187const RESPOND_PROMPT: &str = "\
188Here is a review of your PR for issue #{number}.
189{context}
190{findings}
191
192For each point, choose exactly one disposition:
193- fixed: the point is valid and in scope. Fix it and leave the change
194  uncommitted for the harness.
195- refuted: the point is wrong, or the change it asks for is bigger than the
196  problem it names. Explain why. Refuting is a legitimate outcome; do not accept
197  a review comment you believe is incorrect just to get the PR approved.
198- filed_issue: the point is valid but unrelated to this PR. Supply
199  new_issue_title and new_issue_body; the harness files it and skips duplicates.
200
201Copy each finding's title and file across exactly as given, so your answer can
202be matched back to the review. Give a reason for every disposition. For fixed,
203say what changed and how it answers the point. For refuted, say why the point
204does not stand. For filed_issue, say why it belongs outside this pull request.
205
206Fix what the point says and nothing else. The smallest change that answers it is
207the right one: no refactor alongside it, no capability nobody asked for, no
208handling for cases nobody raised. Every line you add is what the next pass
209reviews, so a fix that grows the branch buys another round of findings about the
210fix. If a point cannot be answered without a change bigger than the point, say so
211rather than making the change.
212{optional}
213Leave any fixes uncommitted. Do not commit, push, or merge.";
214
215/// The non-blocking findings, offered rather than required.
216///
217/// The author is making a commit anyway for the blocking points, and these are
218/// real defects the reviewer judged smaller than another round. Nobody acted on
219/// them before: they were listed for a person at the end and the merged pull
220/// request kept them.
221const OPTIONAL_HEADER: &str = "\
222These are not blocking and will not hold the merge. You are making a commit for
223the points above anyway, so fix the ones that are a line or two, and decline the
224rest with a reason. Declining costs nothing: what you decline is reported for a
225person to weigh, exactly as it would have been if you had not been asked.
226
227Optional:
228";
229
230const CHECK_PROMPT: &str = "\
231The branch does not pass. spar ran `{command}` in this worktree after your
232change and it failed. This is the harness running it, not a claim by anybody.
233
234{output}
235
236Fix what it reports and leave the changes uncommitted, as before. If the failure
237is not caused by your change and the branch was already failing, say so in the
238summary and change nothing: the run stops either way, and saying so is worth
239more than a guess at somebody else's bug.
240
241Do not commit, push, or merge.";
242
243/// How long one check may run.
244///
245/// Long, because a real test suite is long, and a check that times out is
246/// reported as a failure like any other. Shorter than an agent call, because
247/// nothing here is waiting on a model.
248const CHECK_TIMEOUT_SECS: u64 = 1800;
249
250/// What a check run left behind.
251pub struct Checked {
252    pub command: String,
253    pub output: String,
254}
255
256/// Run the configured check in the worktree, once, and say what happened.
257fn run_check(cfg: &Config, work_dir: &Path) -> Option<std::result::Result<String, Checked>> {
258    let argv = cfg.loop_cfg.check.argv();
259    if argv.is_empty() {
260        return None;
261    }
262    let command = cfg.loop_cfg.check.describe();
263    logdim!("running the check: {command}");
264    let out = crate::proc::exec(
265        &argv,
266        &crate::proc::ExecOpts::new()
267            .cwd(work_dir)
268            .check(false)
269            .timeout_secs(CHECK_TIMEOUT_SECS)
270            .stop_descendants(true),
271    );
272    Some(match out {
273        Ok(out) if out.ok() => Ok(command),
274        Ok(out) => {
275            let text = format!("{}\n{}", out.stdout.trim_end(), out.stderr.trim_end());
276            Err(Checked {
277                command,
278                output: style::clip(text.trim(), 4000),
279            })
280        }
281        // A check that could not be run at all is a failure of the check rather
282        // than of the branch, and saying which is the whole of the message.
283        Err(e) => Err(Checked {
284            command,
285            output: format!("the check could not be run: {e}"),
286        }),
287    })
288}
289
290/// Run the check, and give the agent one go at what it reports.
291///
292/// One retry, the same shape as the JSON retry: a failing suite is usually a
293/// small thing the editing call did not run, and asking twice about the same
294/// failure is how a round is spent on nothing. A second failure ends the round
295/// with the output in the report and the worktree kept, which is what an
296/// uncommitted edit already does.
297#[allow(clippy::too_many_arguments)]
298fn check_before_push(
299    cfg: &Config,
300    repo: &Repo,
301    agent: &Agent,
302    work_dir: &Path,
303    label: &str,
304    call: crate::config::Call,
305) -> Result<Option<String>> {
306    let Some(first) = run_check(cfg, work_dir) else {
307        return Ok(None);
308    };
309    let failed = match first {
310        Ok(command) => {
311            log!("{label}: `{command}` passed");
312            return Ok(Some(command));
313        }
314        Err(failed) => failed,
315    };
316    logwarn!(
317        "{label}: `{}` failed, handing it back to {} once. Nothing is pushed until it passes.",
318        failed.command,
319        agent.name()
320    );
321
322    let prompt = fill(
323        CHECK_PROMPT,
324        &[("{command}", &failed.command), ("{output}", &failed.output)],
325    );
326    let baseline = repo.worktree_baseline(work_dir)?;
327    let before = repo.head_oid_checked(work_dir)?;
328    let answer = agent.edit(&prompt, work_dir, &agent.effort(cfg, call));
329    match answer {
330        Ok(summary) => {
331            commit_accepted_changes(
332                cfg,
333                repo,
334                work_dir,
335                &baseline,
336                &summary,
337                "Make the branch pass its check",
338            )?;
339        }
340        Err(error) => {
341            let error = defer_clean_edit_error(repo, work_dir, &baseline, error)?;
342            bail!(
343                "{label}: `{}` failed and the fix call failed too, so nothing was pushed.\n{}\n\n{error}",
344                failed.command,
345                failed.output
346            );
347        }
348    }
349    let moved = repo.head_oid_checked(work_dir)? != before;
350    match run_check(cfg, work_dir) {
351        Some(Ok(command)) => {
352            log!("{label}: `{command}` passed after one fix");
353            Ok(Some(command))
354        }
355        Some(Err(again)) => {
356            bail!(
357            "{label}: `{}` still fails after one fix{}, so nothing was pushed and the worktree \
358             was kept.\n{}",
359            again.command,
360            if moved { "" } else { " and nothing was committed" },
361            again.output
362        )
363        }
364        None => Ok(None),
365    }
366}
367
368// ---------------------------------------------------------------------------
369// Evidence
370// ---------------------------------------------------------------------------
371
372/// What the branch looked like at one point in a round.
373///
374/// Untracked files are deliberately not dirt. The review prompt asks for a
375/// scratch file when a claim needs running to check it, so counting one as a
376/// mutation would reject every review that did as it was told.
377#[derive(Debug, Clone, PartialEq, Eq)]
378pub struct Snapshot {
379    pub head: String,
380    /// Tracked files differing from the index or the head.
381    pub dirty: bool,
382}
383
384impl Snapshot {
385    /// Whether a commit landed between the two. An empty head means git could
386    /// not be read, which is not evidence that anything was written.
387    pub fn landed_over(&self, before: &Snapshot) -> bool {
388        !self.head.is_empty() && self.head != before.head
389    }
390}
391
392pub fn snapshot(repo: &Repo, work_dir: &Path) -> Snapshot {
393    Snapshot {
394        head: repo
395            .git_try_at(Some(work_dir), &["rev-parse", "HEAD"])
396            .trim()
397            .to_string(),
398        dirty: !repo
399            .git_try_at(
400                Some(work_dir),
401                &["status", "--porcelain", "--untracked-files=no"],
402            )
403            .trim()
404            .is_empty(),
405    }
406}
407
408fn checked_head(repo: &Repo, work_dir: &Path) -> Result<String> {
409    let head = repo.git_at(Some(work_dir), &["rev-parse", "HEAD"])?;
410    let head = head.trim().to_string();
411    if head.is_empty() {
412        return Err(spar_err!("could not read the pull request head"));
413    }
414    Ok(head)
415}
416
417/// Whether branch-dependent review state can be applied to the checked-out PR.
418///
419/// A version 1 checkpoint has no head field, but it already records the
420/// intended next reviewer. Its first resume preserves that handoff and binds it
421/// to the checked-out head. A current checkpoint must name this exact published
422/// head; otherwise automatic custody would let the previous reviewer read a
423/// commit it may have written. An explicit override supplies the human decision
424/// and starts branch-dependent state fresh.
425fn reconcile_saved_head(
426    recorded_head: Option<&str>,
427    state_version: Option<u32>,
428    saved_actor_known: bool,
429    actual_head: &str,
430    holder_override: Option<&str>,
431    pr_number: i64,
432) -> Result<bool> {
433    let Some(recorded_head) = recorded_head else {
434        return Ok(true);
435    };
436    if !recorded_head.is_empty() && recorded_head == actual_head {
437        return Ok(true);
438    }
439    if holder_override.is_some() {
440        return Ok(false);
441    }
442    if recorded_head.is_empty() && state_version == Some(1) && saved_actor_known {
443        return Ok(true);
444    }
445    let recorded = if recorded_head.is_empty() {
446        format!(
447            "state version {} without a recorded head",
448            state_version
449                .map(|version| version.to_string())
450                .unwrap_or_else(|| "unknown".to_string())
451        )
452    } else {
453        format!("head {recorded_head}")
454    };
455    Err(spar_err!(
456        "saved review state applies to {recorded}, but PR #{pr_number} is at {actual_head}; \
457         resume with --next <agent> to choose who reviews this head"
458    ))
459}
460
461/// Copy the tracked edits in the tree to somewhere they can be got back from.
462///
463/// `git stash create` writes them as a dangling commit and, unlike `git stash
464/// push`, leaves the stash stack alone: the stack belongs to whoever is working
465/// in the repository, and every worktree of it shares the same one. `None` when
466/// there was nothing to save.
467pub fn park(repo: &Repo, work_dir: &Path) -> Option<String> {
468    let saved = repo
469        .git_try_at(Some(work_dir), &["stash", "create"])
470        .trim()
471        .to_string();
472    (!saved.is_empty()).then_some(saved)
473}
474
475/// Reset the tree to `target`, saving what that throws away.
476///
477/// With `--no-worktrees` the checkout is the user's own, and nothing here can
478/// tell an edit an agent left behind from one a person made while a call was
479/// running. So the discard is never silent and never final: the changes are
480/// parked first and the log says how to put them back.
481fn reset_saving(repo: &Repo, work_dir: &Path, target: &str) {
482    let parked = park(repo, work_dir);
483    if let Err(e) = repo.git_at(Some(work_dir), &["reset", "--hard", target]) {
484        logdim!("could not roll the working tree back: {e}");
485        return;
486    }
487    if let Some(saved) = parked {
488        logdim!("`git stash apply {saved}` puts the discarded changes back");
489    }
490}
491
492/// Put the branch back where the review found it.
493///
494/// Nothing here was ever pushed: the loop pushes at the end of a round, so the
495/// head a review starts from is the head the pull request already has. What is
496/// discarded is therefore only what the review wrote after being told not to,
497/// and keeping it would hand the reviewer its own commit to review next round.
498///
499/// Returns the state afterwards, which equals `before` when the rollback took.
500/// The caller compares, because a rollback that did not take means the reviewer
501/// wrote the head and custody has to follow it there.
502pub fn undo_edits(repo: &Repo, work_dir: &Path, before: &Snapshot) -> Snapshot {
503    let current = snapshot(repo, work_dir);
504    if before.head.is_empty() {
505        return current;
506    }
507    if current.landed_over(before) {
508        logdim!(
509            "the commits being rolled back are still at {}",
510            current.head
511        );
512    }
513    reset_saving(repo, work_dir, &before.head);
514    snapshot(repo, work_dir)
515}
516
517/// Keep a prohibited closing commit reachable without publishing it.
518///
519/// A later resume rebuilds the worktree from the pull request branch. The ref
520/// preserves the local commit for inspection while keeping custody based on
521/// the unchanged remote head.
522fn preserve_closing_commit(repo: &Repo, ctx: &LoopCtx) -> Option<String> {
523    let current = snapshot(repo, &ctx.work_dir);
524    if current.head.is_empty() {
525        return None;
526    }
527    let reference = format!(
528        "refs/spar/recovery/pr-{}/closing-{}",
529        ctx.pr_number, current.head
530    );
531    match repo.git_at(
532        Some(&ctx.work_dir),
533        &["update-ref", &reference, &current.head],
534    ) {
535        Ok(_) => Some(reference),
536        Err(e) => {
537            logdim!("could not preserve the closing pass commit: {e}");
538            None
539        }
540    }
541}
542
543/// Drop what a call left uncommitted, keeping whatever it committed.
544///
545/// Only commits reach the pull request, but the next review reads the working
546/// tree, so an edit left behind is code the reviewer judges and the diff does
547/// not have. That is how an agent comes to approve a fix of its own that
548/// nobody else can see.
549pub fn drop_uncommitted(repo: &Repo, work_dir: &Path) -> Snapshot {
550    let current = snapshot(repo, work_dir);
551    if !current.dirty || current.head.is_empty() {
552        return current;
553    }
554    reset_saving(repo, work_dir, &current.head);
555    snapshot(repo, work_dir)
556}
557
558/// A worktree is only worth keeping when a person has to look at it locally.
559/// Anything else strands a checked-out branch that blocks
560/// `gh pr merge --delete-branch`, and since auto_merge is off by default,
561/// keeping it on anything but "merged" leaks one per run.
562fn should_release(cfg: &Config, status: Status) -> bool {
563    if !cfg.loop_cfg.worktrees || cfg.loop_cfg.keep_worktrees {
564        return false;
565    }
566    !matches!(status, Status::Escalated | Status::Error)
567}
568
569fn uncommitted_implementation_error(work_dir: &Path, detail: Option<&str>) -> SparError {
570    let detail = detail.unwrap_or_default().trim();
571    let prefix = if detail.is_empty() {
572        String::new()
573    } else {
574        format!("{detail}\n")
575    };
576    spar_err!(
577        "{prefix}The implementation left uncommitted changes in {}. Commit or recover them \
578         before running this issue again.",
579        work_dir.display()
580    )
581}
582
583fn commit_accepted_changes(
584    cfg: &Config,
585    repo: &Repo,
586    work_dir: &Path,
587    baseline: &crate::repo::WorktreeBaseline,
588    preferred_subject: &str,
589    fallback_subject: &str,
590) -> Result<bool> {
591    repo.refuse_changed_attributes(work_dir, baseline)?;
592    if !cfg.loop_cfg.worktrees && repo.has_uncommitted_changes(work_dir)? {
593        bail!(
594            "the implementation changed the shared checkout at {}, but spar cannot distinguish \
595             those files from edits made concurrently by its owner. The files were kept. Commit \
596             or recover them, then use the default worktree mode for managed commits.",
597            work_dir.display()
598        );
599    }
600    let committed =
601        repo.commit_pending_changes(work_dir, baseline, preferred_subject, fallback_subject)?;
602    repo.refuse_unrepresented_tracked_changes(work_dir, baseline)?;
603    Ok(committed)
604}
605
606// ---------------------------------------------------------------------------
607// One issue, start to finish
608// ---------------------------------------------------------------------------
609
610pub fn run_issue(
611    agents: &[Agent],
612    cfg: &Config,
613    repo: &Repo,
614    item: &PlanItem,
615    issue: &Issue,
616) -> IssueRun {
617    run_nth_issue(agents, cfg, repo, item, issue, 0)
618}
619
620/// The same, told where in the plan this issue sits.
621///
622/// The position is what the alternating first implementor policy reads: it
623/// swaps every issue, so both models write first drafts and both review them.
624pub fn run_nth_issue(
625    agents: &[Agent],
626    cfg: &Config,
627    repo: &Repo,
628    item: &PlanItem,
629    issue: &Issue,
630    nth: usize,
631) -> IssueRun {
632    // Continue an existing PR rather than implementing over the top of it.
633    //
634    // Without this, a second `spar run 42` deletes the local branch, rebuilds
635    // it from the base, implements from scratch, and force pushes. The lease
636    // holds because the remote tracking ref survives the local branch being
637    // deleted, so the push succeeds and the previous round's work is gone from
638    // the PR with nothing to say it ever existed.
639    // A failed lookup is not an answer. "No pull request exists" is what makes
640    // spar implement from scratch, so it has to be something GitHub said.
641    match repo.try_open_pr_for_issue(item.issue, cfg.base_branch()) {
642        Ok(Some(existing)) => {
643            log!(
644                "#{}: {} is already open, continuing it instead of implementing again",
645                item.issue,
646                existing.url
647            );
648            return resume_pr(agents, cfg, repo, existing.number, None);
649        }
650        Ok(None) => {}
651        Err(e) => {
652            let mut state = IssueRun::new(item.issue, item.title.clone());
653            state.status = Status::Error;
654            state.notes.push(e.to_string());
655            log!("#{} failed: {e}", item.issue);
656            return state;
657        }
658    }
659
660    let mut state = IssueRun::new(item.issue, item.title.clone());
661    // One ledger per pull request. It was one per invocation, held by
662    // `work_issues` and lent to every issue in the run, so the second issue was
663    // handed the first one's points and told to treat as settled a defect in a
664    // file its own branch does not touch. Both state files on this repository
665    // record it: `pr-34.json` carries `pr-33.json`'s two `src/tracker.rs`
666    // entries, on a branch with no tracker in it.
667    let mut ledger = Ledger::new();
668    let base = cfg.base_branch().to_string();
669
670    let prepared = if cfg.loop_cfg.worktrees {
671        repo.worktree_add(item.issue, &base)
672    } else {
673        (|| {
674            if repo.has_uncommitted_changes(repo.root())? {
675                bail!(
676                    "the shared checkout at {} has uncommitted changes. Refusing to reset it for \
677                     issue #{}.",
678                    repo.root().display(),
679                    item.issue
680                );
681            }
682            if repo.has_changes_checked(repo.root(), &base)? {
683                let preserved = repo.current_branch_is_preserved(repo.root()).map_err(|e| {
684                    spar_err!(
685                        "could not verify whether a pull request preserves the shared checkout \
686                             at {}: {}. Refusing to reset it for issue #{}.",
687                        repo.root().display(),
688                        e.last_line(),
689                        item.issue
690                    )
691                })?;
692                if !preserved {
693                    bail!(
694                        "the shared checkout at {} has commits that are not on {base}. Refusing \
695                         to reset them for issue #{}.",
696                        repo.root().display(),
697                        item.issue
698                    );
699                }
700            }
701            let branch = repo.branch_for_issue(item.issue);
702            let start = format!("origin/{base}");
703            repo.refuse_issue_branch_rebuild(item.issue, &base)?;
704            if repo.has_uncommitted_changes(repo.root())? {
705                bail!(
706                    "the shared checkout at {} changed while issue #{} was being prepared. \
707                     Refusing to reset it.",
708                    repo.root().display(),
709                    item.issue
710                );
711            }
712            repo.git(&["checkout", "-B", &branch, &start])?;
713            repo.record_branch(&branch, "issue", item.issue);
714            Ok((repo.root().to_path_buf(), branch))
715        })()
716    };
717
718    let (work_dir, branch) = match prepared {
719        Ok(pair) => pair,
720        Err(e) => {
721            state.status = Status::Error;
722            state.notes.push(e.to_string());
723            log!("#{} failed: {e}", item.issue);
724            return state;
725        }
726    };
727
728    let outcome = implement_and_review(
729        agents,
730        cfg,
731        repo,
732        item,
733        issue,
734        nth,
735        &mut ledger,
736        &mut state,
737        &work_dir,
738        &branch,
739    );
740    if let Err(e) = outcome {
741        state.status = Status::Error;
742        state.notes.push(e.to_string());
743        log!("#{} failed: {e}", item.issue);
744    }
745
746    if should_release(cfg, state.status) {
747        repo.worktree_remove(item.issue);
748    }
749    state
750}
751
752#[allow(clippy::too_many_arguments)]
753fn implement_and_review(
754    agents: &[Agent],
755    cfg: &Config,
756    repo: &Repo,
757    item: &PlanItem,
758    issue: &Issue,
759    nth: usize,
760    ledger: &mut Ledger,
761    state: &mut IssueRun,
762    work_dir: &Path,
763    branch: &str,
764) -> Result<()> {
765    let number = item.issue;
766    let holder = cfg.first_implementor_for(nth);
767    let implementor = agent::find(agents, &holder)?;
768    let base = cfg.base_branch().to_string();
769
770    log!("#{number}: {holder} implementing");
771    repo.remember_implementor(number, &holder);
772    // Fixing triage alone would have been worse than fixing neither: an issue
773    // correctly judged worth doing on its whole text, then built from the first
774    // few thousand characters of it, raises confidence without raising
775    // fidelity.
776    let issue_text = issue_for_prompt(
777        issue,
778        cfg.loop_cfg.max_issue_chars,
779        cfg.loop_cfg.checkin_trust,
780    );
781    issue_text.report(&format!("#{number}"));
782    let body = issue_text.text;
783    let criteria = tracker::criteria(issue.body_text());
784    let prompt = implement_prompt(number, &item.title, &issue.url, &body, &criteria);
785    let implementation_schema = if criteria.is_empty() {
786        schema::implementation()
787    } else {
788        schema::with_criteria(schema::implementation(), schema::CriteriaVoice::Author)
789    };
790    let worktree_baseline = repo.worktree_baseline(work_dir)?;
791    let answer: Result<Implementation> = implementor.edit_json(
792        &prompt,
793        &implementation_schema,
794        work_dir,
795        &implementor.effort(cfg, Call::Implement).about(number),
796    );
797
798    if answer.is_ok() {
799        repo.refuse_changed_attributes(work_dir, &worktree_baseline)?;
800    }
801
802    if let Err(e) = &answer {
803        if e.kind() == crate::error::ErrorKind::UncertainWrite {
804            return Err(e.clone());
805        }
806    }
807
808    // A call that fails with commits on the branch is not the same as one that
809    // fails with nothing to show. Custom editing commands can still commit
810    // directly before their report fails. The review loop needs that retained
811    // diff, not the missing summary.
812    let has_commits = repo.has_changes_checked(work_dir, &base)?;
813    let has_uncommitted = repo.has_uncommitted_changes(work_dir)?;
814    let mut work = match answer {
815        Err(e) if has_uncommitted => {
816            return Err(uncommitted_implementation_error(
817                work_dir,
818                Some(e.message()),
819            ));
820        }
821        Ok(work) => work,
822        Err(e) if has_commits => {
823            logwarn!(
824                "#{number}: {holder} failed after committing: {e}\nContinuing from the commits, \
825                 with a pull request body written from their messages."
826            );
827            state
828                .notes
829                .push(format!("{holder} failed after committing: {e}"));
830            from_commits(repo, work_dir, &base)
831        }
832        Err(e) => return Err(e),
833    };
834
835    if work.not_worth_doing {
836        repo.refuse_unrepresented_tracked_changes(work_dir, &worktree_baseline)?;
837        repo.refuse_changed_existing_untracked(work_dir, &worktree_baseline)?;
838        if has_uncommitted || has_commits {
839            bail!(
840                "{holder} declined issue #{number} after changing {}. The worktree was kept for \
841                 recovery.",
842                work_dir.display()
843            );
844        }
845        repo.refuse_new_ignored_files(work_dir, &worktree_baseline)?;
846        state.status = Status::Abandoned;
847        let reason = no_pr_note(&work, &repo.style);
848        state.notes.push(reason.clone());
849        if let Err(e) = repo.comment_issue(number, &reason) {
850            logdim!("could not comment on #{number}: {e}");
851        }
852        return Ok(());
853    }
854
855    commit_accepted_changes(
856        cfg,
857        repo,
858        work_dir,
859        &worktree_baseline,
860        &work.summary,
861        &item.title,
862    )?;
863    if repo.has_uncommitted_changes(work_dir)? {
864        return Err(uncommitted_implementation_error(
865            work_dir,
866            work.notes.as_deref(),
867        ));
868    }
869    if !repo.has_changes_checked(work_dir, &base)? {
870        repo.refuse_new_ignored_files(work_dir, &worktree_baseline)?;
871        state.status = Status::Abandoned;
872        let reason = no_pr_note(&work, &repo.style);
873        state.notes.push(reason.clone());
874        if let Err(e) = repo.comment_issue(number, &reason) {
875            logdim!("could not comment on #{number}: {e}");
876        }
877        return Ok(());
878    }
879
880    // A body that leads with nothing is a body nobody reads past. The issue
881    // title is a poor substitute for a sentence about the change, and a better
882    // one than a blank first line.
883    if work.summary.trim().is_empty() {
884        work.summary = item.title.clone();
885    }
886
887    // Before the push, not after. Every round after a broken push is a round
888    // spent on a branch that should not have been pushed, and a reviewer told
889    // to confirm findings by running the code starts from a tree that does not
890    // build.
891    let checked = check_before_push(
892        cfg,
893        repo,
894        implementor,
895        work_dir,
896        &format!("#{number}"),
897        Call::Implement,
898    )?;
899
900    // Nothing on this branch predates spar: the worktree was created from
901    // `origin/<base>` in this invocation, and `refuse_issue_branch_rebuild`
902    // has already refused to build over a branch that carried commits.
903    repo.rewrite_commits_if_needed(work_dir, &base, None)?;
904    // A branch created in this invocation: there is no earlier remote head
905    // to lease against, and `refuse_issue_branch_rebuild` already refused to
906    // build over one that existed. Being new is also what lets it go to a
907    // fork when origin refuses it, since no pull request is on origin yet.
908    //
909    // A failure here comes after the most expensive call of the run, so it
910    // says where that work is and how to carry on from it. `spar run` cannot:
911    // it refuses to rebuild a branch that holds commits nothing preserves.
912    let target = repo
913        .publish_new_branch(work_dir, branch, &base)
914        .map_err(|e| {
915            e.with_message(format!(
916                "{e}\nThe work is kept on the local branch {branch} in {}. To continue it \
917                 without implementing again, push that branch, open a pull request for it and \
918                 run `spar resume <pr>`.",
919                work_dir.display()
920            ))
921        })?;
922
923    let description = pr_body(number, &work, checked.as_deref(), &criteria, &repo.style);
924    let pr = match repo.pr_for_branch(branch) {
925        Some(existing) => existing,
926        None => {
927            let body = description.clone();
928            repo.create_pr(
929                work_dir,
930                branch,
931                &target,
932                &base,
933                &format!("{} (#{number})", item.title),
934                &body,
935            )?
936        }
937    };
938    repo.record_branch(branch, "pr", pr.number);
939    state.pr = Some(pr.url.clone());
940    log!("#{number}: PR {}", pr.url);
941
942    let ctx = LoopCtx {
943        work_dir: work_dir.to_path_buf(),
944        branch: branch.to_string(),
945        target,
946        pr_number: pr.number,
947        context: context_block(number, &issue.url, &body, &description),
948        label: format!("#{number}"),
949        subject: number,
950        title: item.title.clone(),
951        start_round: 1,
952        holder: cfg.other(&holder),
953        release: Release::Issue(number),
954        criteria,
955    };
956    review_loop(agents, cfg, repo, &ctx, state, ledger, Vec::new())
957}
958
959// ---------------------------------------------------------------------------
960// Resuming an existing PR
961// ---------------------------------------------------------------------------
962
963/// Pick up an existing PR and continue the loop.
964///
965/// The PR need not have been created by spar. Anything with a branch and a diff
966/// can be reviewed, including work a person or a different tool started, which
967/// is also the cheapest way to adopt spar: no agent writes a feature from
968/// scratch, it only reviews what already exists.
969pub fn resume_pr(
970    agents: &[Agent],
971    cfg: &Config,
972    repo: &Repo,
973    pr_number: i64,
974    holder_override: Option<&str>,
975) -> IssueRun {
976    let failed = |e: SparError| {
977        log!("PR #{pr_number} failed: {e}");
978        let mut state = IssueRun::new(pr_number, format!("PR #{pr_number}"));
979        state.status = Status::Error;
980        state.notes.push(e.to_string());
981        state
982    };
983
984    let pr = match repo.pr_view(pr_number) {
985        Ok(pr) => pr,
986        Err(e) => return failed(e),
987    };
988
989    // A pull request from somebody else's fork cannot be pushed to, so the
990    // loop that fixes things cannot run on it. Reviewing it is still the
991    // useful thing, and it is what a maintainer wants from an outside
992    // contribution anyway, so do that rather than refusing. One from the
993    // authenticated user's own fork, such as one spar opened after origin
994    // refused it, carries on like any other.
995    let Some(target) = repo.pr_push_target(&pr) else {
996        log!("PR #{pr_number} comes from a fork, reviewing it without changing it");
997        return crate::review_only::review_pr(
998            agents,
999            cfg,
1000            repo,
1001            pr_number,
1002            crate::review_only::ReviewOpts::default(),
1003        );
1004    };
1005
1006    match resume_inner(agents, cfg, repo, pr, target, holder_override) {
1007        Ok(state) => state,
1008        Err(e) => failed(e),
1009    }
1010}
1011
1012fn resume_inner(
1013    agents: &[Agent],
1014    cfg: &Config,
1015    repo: &Repo,
1016    pr: PrView,
1017    target: PushTarget,
1018    holder_override: Option<&str>,
1019) -> Result<IssueRun> {
1020    let pr_number = pr.number;
1021    if !pr.is_open() {
1022        return Err(spar_err!("PR #{pr_number} is {}", pr.state.to_lowercase()));
1023    }
1024
1025    let subject = pr
1026        .closing_issues_references
1027        .first()
1028        .map(|r| r.number)
1029        .unwrap_or(pr_number);
1030    let closing_url = pr.closing_issues_references.first().map(|r| &r.url);
1031
1032    if let Some(holder) = holder_override {
1033        if !cfg.has_agent(holder) {
1034            return Err(spar_err!(
1035                "--next must name one of: {}",
1036                cfg.agent_names().join(", ")
1037            ));
1038        }
1039    }
1040    let (work_dir, branch) = repo.worktree_for_pr(&pr, &target)?;
1041    let actual_head = checked_head(repo, &work_dir)?;
1042    let saved = repo.read_state_for_head(&pr, &actual_head);
1043    let saved_actor_known = saved
1044        .as_ref()
1045        .map(|state| cfg.has_agent(&state.next_actor))
1046        .unwrap_or(false);
1047    let state_matches_head = reconcile_saved_head(
1048        saved.as_ref().map(|state| state.pr_head.as_str()),
1049        saved.as_ref().map(|state| state.version),
1050        saved_actor_known,
1051        &actual_head,
1052        holder_override,
1053        pr_number,
1054    )?;
1055    let migrates_legacy_state = saved.as_ref().is_some_and(|state| {
1056        state.version == 1
1057            && state.pr_head.is_empty()
1058            && saved_actor_known
1059            && holder_override.is_none()
1060    });
1061    if migrates_legacy_state {
1062        log!("PR #{pr_number}: migrating legacy review state to head {actual_head}");
1063    }
1064
1065    let mut ledger: Ledger = saved
1066        .as_ref()
1067        .filter(|_| state_matches_head)
1068        .map(|s| s.ledger.clone())
1069        .unwrap_or_default();
1070    normalise_ledger_keys(&mut ledger);
1071    let open_findings = saved
1072        .as_ref()
1073        .filter(|_| state_matches_head)
1074        .map(|s| blocking_findings(&s.open_findings))
1075        .unwrap_or_default();
1076    let start_round = saved
1077        .as_ref()
1078        .filter(|_| state_matches_head)
1079        .map(|s| s.round + 1)
1080        .unwrap_or(1);
1081
1082    let default_holder = cfg.other(&cfg.first_implementor);
1083    let mut holder = holder_override
1084        .map(str::to_string)
1085        .or_else(|| saved.as_ref().map(|s| s.next_actor.clone()))
1086        .unwrap_or_else(|| default_holder.clone());
1087    if !cfg.has_agent(&holder) {
1088        log!("state named unknown agent '{holder}', using {default_holder}");
1089        holder = default_holder;
1090    }
1091
1092    match &saved {
1093        Some(_) => log!(
1094            "PR #{pr_number}: resuming at round {start_round}, {} point(s) on record, next up \
1095             {holder}",
1096            ledger.len()
1097        ),
1098        None => log!("PR #{pr_number}: no prior spar state, starting fresh with {holder}"),
1099    }
1100
1101    let mut state = IssueRun::new(subject, pr.title.clone());
1102    state.pr = Some(pr.url.clone());
1103    if let Some(s) = &saved {
1104        state.filed = s.filed.clone();
1105        if state_matches_head {
1106            state.disputes = s.disputes.clone();
1107            state.noted = s.noted.clone();
1108        } else {
1109            log!(
1110                "PR #{pr_number}: saved state does not match {actual_head}; branch-dependent \
1111                 review state was cleared"
1112            );
1113        }
1114    }
1115
1116    // The issue the pull request closes, when it names one. A failure to read
1117    // it costs the prompts their context and nothing else, so it is a warning.
1118    let issue = (subject != pr_number)
1119        .then(|| repo.read_issue(subject))
1120        .transpose()
1121        .unwrap_or_else(|e| {
1122            logdim!("could not read issue #{subject} for context: {e}");
1123            None
1124        });
1125    let (issue_body, issue_url) = match &issue {
1126        Some(issue) => {
1127            let text = issue_for_prompt(
1128                issue,
1129                cfg.loop_cfg.max_issue_chars,
1130                cfg.loop_cfg.checkin_trust,
1131            );
1132            text.report(&format!("PR #{pr_number}"));
1133            (text.text, issue.url.clone())
1134        }
1135        None => (String::new(), String::new()),
1136    };
1137
1138    let ctx = LoopCtx {
1139        work_dir,
1140        branch,
1141        target,
1142        pr_number,
1143        context: context_block(subject, &issue_url, &issue_body, &pr.body),
1144        label: format!("PR #{pr_number}"),
1145        subject,
1146        title: pr.title.clone(),
1147        start_round,
1148        holder,
1149        release: Release::Pr(pr_number),
1150        // A closing link can name another repository's issue, and the lookup
1151        // above found this one's namesake, whose boxes are not this PR's.
1152        criteria: issue
1153            .as_ref()
1154            .filter(|issue| Some(&issue.url) == closing_url)
1155            .map(|issue| tracker::criteria(issue.body_text()))
1156            .unwrap_or_default(),
1157    };
1158
1159    let outcome = review_loop(
1160        agents,
1161        cfg,
1162        repo,
1163        &ctx,
1164        &mut state,
1165        &mut ledger,
1166        open_findings,
1167    );
1168    if let Err(e) = outcome {
1169        state.status = Status::Error;
1170        state.notes.push(e.to_string());
1171        log!("PR #{pr_number} failed: {e}");
1172    }
1173    if should_release(cfg, state.status) {
1174        repo.release_pr_worktree(pr_number);
1175    }
1176    Ok(state)
1177}
1178
1179// ---------------------------------------------------------------------------
1180// The loop
1181// ---------------------------------------------------------------------------
1182
1183#[derive(Debug, Clone, Copy)]
1184enum Release {
1185    Issue(i64),
1186    Pr(i64),
1187}
1188
1189struct LoopCtx {
1190    work_dir: PathBuf,
1191    branch: String,
1192    /// Where the branch is published: origin, or the authenticated user's own
1193    /// fork, which is where spar opens a pull request origin refused.
1194    target: PushTarget,
1195    pr_number: i64,
1196    label: String,
1197    subject: i64,
1198    title: String,
1199    /// The issue as filed and the author's account of the change, for every
1200    /// prompt that has to judge whether the branch answers the issue.
1201    context: String,
1202    start_round: u32,
1203    holder: String,
1204    release: Release,
1205    /// The issue's unchecked task list items, read from its whole body.
1206    criteria: Vec<Item>,
1207}
1208
1209impl LoopCtx {
1210    fn release(&self, repo: &Repo) -> bool {
1211        match self.release {
1212            Release::Issue(n) => repo.worktree_remove(n),
1213            Release::Pr(n) => repo.release_pr_worktree(n),
1214        }
1215    }
1216}
1217
1218fn review_loop(
1219    agents: &[Agent],
1220    cfg: &Config,
1221    repo: &Repo,
1222    ctx: &LoopCtx,
1223    state: &mut IssueRun,
1224    ledger: &mut Ledger,
1225    mut open_findings: Vec<Finding>,
1226) -> Result<()> {
1227    let base = cfg.base_branch().to_string();
1228    // Never the agent that made the last commit, on entry and after every
1229    // round. An approval or a deadlock ends the round with nothing edited, so
1230    // those paths persist it unchanged.
1231    let mut holder = ctx.holder.clone();
1232
1233    // `max_rounds` is a budget for this invocation, not a lifetime cap on the
1234    // pull request. Running spar again on a PR that already spent its rounds is
1235    // a deliberate act by a person who has looked at it, so it gets a fresh
1236    // budget rather than an error telling them to raise a number they cannot
1237    // see from the outside.
1238    let (first, last_allowed) = round_window(ctx.start_round, cfg.loop_cfg.max_rounds);
1239    let mut published_head = checked_head(repo, &ctx.work_dir)?;
1240    persist(
1241        repo,
1242        ctx.pr_number,
1243        state,
1244        ledger,
1245        &open_findings,
1246        &published_head,
1247        first.saturating_sub(1),
1248        &holder,
1249    )?;
1250    // The ref spar itself compares against, which is what the reviewer has to
1251    // be told to compare against. In a linked worktree the local branch is
1252    // whatever the primary checkout last had: often behind origin, sometimes
1253    // ahead of it with unpushed commits, and either way not the base this
1254    // branch was built from.
1255    let base_ref = repo.base_ref(&ctx.work_dir, &base);
1256
1257    // The head the last review in this invocation read. Empty until one has
1258    // run, and in-invocation on purpose: a resumed run's closing pass reads what
1259    // this invocation's rounds produced, not what some earlier one did.
1260    let mut audited_head = String::new();
1261    let mut last_round = first.saturating_sub(1);
1262
1263    for round in first..=last_allowed {
1264        last_round = round;
1265        state.rounds = round;
1266        let reviewer = agent::find(agents, &holder)?;
1267        let effort = reviewer.effort(cfg, Call::Review(round)).about(ctx.subject);
1268        log!(
1269            "{}: round {round}, {holder} reviewing ({})",
1270            ctx.label,
1271            effort.describe()
1272        );
1273
1274        let prompt = review_prompt(
1275            &base_ref,
1276            ctx.subject,
1277            &ctx.title,
1278            &ctx.context,
1279            ledger,
1280            &open_findings,
1281            round,
1282            last_allowed,
1283            &ctx.criteria,
1284        );
1285        let before_review = snapshot(repo, &ctx.work_dir);
1286        let review_baseline = repo.worktree_baseline(&ctx.work_dir)?;
1287        // The commit this round is judging, kept for the closing pass, which
1288        // reads what landed after the last one of these.
1289        audited_head = before_review.head.clone();
1290        let review = reviewer.review::<Review>(
1291            &base_ref,
1292            &prompt,
1293            &review_schema(&ctx.criteria),
1294            &ctx.work_dir,
1295            &effort,
1296        );
1297        if let Err(error) = &review {
1298            if error.kind() == crate::error::ErrorKind::UncertainWrite {
1299                return Err(error.clone());
1300            }
1301        }
1302        repo.refuse_unrepresented_tracked_changes(&ctx.work_dir, &review_baseline)?;
1303        repo.refuse_read_only_leavings(&ctx.work_dir, &review_baseline)?;
1304        let review = review?;
1305        if repo.has_uncommitted_changes(&ctx.work_dir)? {
1306            bail!(
1307                "{}: {holder} left uncommitted files while reviewing. They were kept at {} and \
1308                 the review did not continue.",
1309                ctx.label,
1310                ctx.work_dir.display()
1311            );
1312        }
1313
1314        // Who actually wrote the head this round, which is the only thing that
1315        // decides who reviews it next. None so far: a review is not supposed to
1316        // write anything.
1317        let mut editor: Option<String> = None;
1318        let review_wrote = snapshot(repo, &ctx.work_dir) != before_review;
1319        if review_wrote {
1320            logwarn!(
1321                "{}: {holder} changed the branch while reviewing it, which the review prompt \
1322                 forbids. Rolling it back.",
1323                ctx.label
1324            );
1325            if undo_edits(repo, &ctx.work_dir, &before_review).head != before_review.head {
1326                state
1327                    .notes
1328                    .push(format!("{holder} committed during its own review"));
1329                editor = Some(holder.clone());
1330            }
1331        }
1332
1333        let blocking = blocking_findings(&review.findings);
1334        if let Some(said) = verdict_disagreement(&review, blocking.len()) {
1335            logwarn!("{}: {holder} {said}", ctx.label);
1336        }
1337        update_open_findings(&mut open_findings, &blocking, !review_wrote);
1338
1339        if repo.style.pr_comments == PrComments::Rounds {
1340            if let Err(e) = repo.comment_pr(
1341                ctx.pr_number,
1342                &review_comment(&holder, round, &review, &repo.style),
1343            ) {
1344                logdim!("could not post the review comment: {e}");
1345            }
1346        }
1347
1348        // Filed every round, not only on approval: a run that escalates or runs
1349        // out of rounds would otherwise drop these on the floor. Filing
1350        // deduplicates by title, so repeats across rounds are free.
1351        file_out_of_scope(repo, &review.findings, ctx.subject, state, cfg);
1352        file_nonblocking(repo, &review.findings, ctx.subject, state, cfg);
1353        remove_findings(&mut state.noted, &blocking);
1354
1355        if check_relitigation(ledger, &blocking, state) {
1356            state.status = Status::Escalated;
1357            post_outcome(
1358                repo,
1359                ctx.pr_number,
1360                state,
1361                ledger,
1362                Ending::Deadlocked(&blocking),
1363            );
1364            persist(
1365                repo,
1366                ctx.pr_number,
1367                state,
1368                ledger,
1369                &open_findings,
1370                &published_head,
1371                round,
1372                &holder,
1373            )?;
1374            return Ok(());
1375        }
1376
1377        if approval_stands(&blocking, review_wrote) {
1378            open_findings.clear();
1379            state.criteria = ruled(&ctx.criteria, &review.criteria);
1380            return approve(
1381                cfg,
1382                repo,
1383                ctx,
1384                state,
1385                ledger,
1386                &published_head,
1387                round,
1388                &holder,
1389            );
1390        }
1391
1392        // Checkpoint the review before any fixer, responder, rewrite, or push
1393        // can fail. A confirmed blocker must survive those failures.
1394        persist(
1395            repo,
1396            ctx.pr_number,
1397            state,
1398            ledger,
1399            &open_findings,
1400            &published_head,
1401            round,
1402            &holder,
1403        )?;
1404
1405        let mut edit_error = None;
1406
1407        if blocking.is_empty() {
1408            // Nothing blocking, but the branch it said that about is not the
1409            // branch that is there now. Falling through gives the next round
1410            // whatever the rollback left: the same reviewer when it took, the
1411            // other agent when the review's commit survived it.
1412            logwarn!(
1413                "{}: {holder} found nothing blocking on a branch it had changed itself, so the \
1414                 approval does not carry.",
1415                ctx.label
1416            );
1417            state.notes.push(format!(
1418                "{holder} passed the branch in round {round} after editing it; the edit was rolled \
1419                 back and the approval did not stand"
1420            ));
1421        } else if review.next_action == NextAction::FixMyself {
1422            log!("{}: {holder} fixing its own findings", ctx.label);
1423            let prompt = FIX_PROMPT.replace("{findings}", &findings_for_prompt(&blocking));
1424            let before_fix = repo.head_oid_checked(&ctx.work_dir)?;
1425            let worktree_baseline = repo.worktree_baseline(&ctx.work_dir)?;
1426            let mut report: Option<OwnFixDoc> = None;
1427            let fix_error = match reviewer.edit_json::<OwnFixDoc>(
1428                &prompt,
1429                &schema::own_fix(),
1430                &ctx.work_dir,
1431                &effort,
1432            ) {
1433                Ok(doc) => {
1434                    commit_accepted_changes(
1435                        cfg,
1436                        repo,
1437                        &ctx.work_dir,
1438                        &worktree_baseline,
1439                        &doc.summary,
1440                        "Address blocking review findings",
1441                    )?;
1442                    report = Some(doc);
1443                    None
1444                }
1445                Err(error) => Some(defer_clean_edit_error(
1446                    repo,
1447                    &ctx.work_dir,
1448                    &worktree_baseline,
1449                    error,
1450                )?),
1451            };
1452            match editor_after(repo, &ctx.work_dir, &before_fix, &ctx.label, &holder)? {
1453                Some(who) => {
1454                    // Recorded like an author's fix, and for the same reason.
1455                    // These points were answered in code too, and leaving them
1456                    // out left this path with the hole the other one had: the
1457                    // next pass reads a fix with nothing saying it was asked
1458                    // for, and the guard that ends an argument cannot count it.
1459                    //
1460                    // Only the ones the report claims. A commit landing says
1461                    // one point was answered, not that all of them were, and a
1462                    // false Fixed entry either sends the closing pass to check
1463                    // code that does not exist or lets the point through on the
1464                    // claim alone.
1465                    let fixes = report.as_ref().map(|d| d.fixes.as_slice()).unwrap_or(&[]);
1466                    let (claimed, left) = split_own_fixes(&blocking, fixes);
1467                    record_own_fixes(&claimed, ledger, state, round);
1468                    remove_findings(&mut open_findings, &blocking);
1469                    extend_findings(&mut open_findings, &left);
1470                    if !left.is_empty() {
1471                        state.notes.push(format!(
1472                            "{holder} fixed {} of {} of its own findings in round {round}; the \
1473                             rest stay open",
1474                            claimed.len(),
1475                            blocking.len()
1476                        ));
1477                    }
1478                    editor = Some(who);
1479                }
1480                None if fix_error.is_none() => {
1481                    repo.refuse_new_ignored_files(&ctx.work_dir, &worktree_baseline)?;
1482                    // Handing over here is what the bug was: the head is still
1483                    // the author's, so the author would be reading its own work.
1484                    logwarn!(
1485                        "{}: {holder} said it would fix its own findings and committed nothing, \
1486                         so it keeps the pull request.",
1487                        ctx.label
1488                    );
1489                    state.notes.push(format!(
1490                        "{holder} chose to fix its own findings in round {round} and committed \
1491                         nothing"
1492                    ));
1493                }
1494                None => {}
1495            }
1496            edit_error = fix_error;
1497        } else {
1498            let author_name = cfg.other(&holder);
1499            let author = agent::find(agents, &author_name)?;
1500            log!(
1501                "{}: handing {} finding(s) to {author_name}",
1502                ctx.label,
1503                blocking.len()
1504            );
1505            let prompt = respond_prompt(ctx.subject, &ctx.context, &blocking, &review.findings);
1506            let before_response = repo.head_oid_checked(&ctx.work_dir)?;
1507            let worktree_baseline = repo.worktree_baseline(&ctx.work_dir)?;
1508            let response: Result<ResponseDoc> = author.edit_json(
1509                &prompt,
1510                &schema::response(),
1511                &ctx.work_dir,
1512                &author.effort(cfg, Call::Respond(round)).about(ctx.subject),
1513            );
1514            let response = match response {
1515                Ok(response) => {
1516                    commit_accepted_changes(
1517                        cfg,
1518                        repo,
1519                        &ctx.work_dir,
1520                        &worktree_baseline,
1521                        &response.summary,
1522                        "Address blocking review findings",
1523                    )?;
1524                    Some(response)
1525                }
1526                Err(error) => {
1527                    edit_error = Some(defer_clean_edit_error(
1528                        repo,
1529                        &ctx.work_dir,
1530                        &worktree_baseline,
1531                        error,
1532                    )?);
1533                    None
1534                }
1535            };
1536            if let Some(who) = editor_after(
1537                repo,
1538                &ctx.work_dir,
1539                &before_response,
1540                &ctx.label,
1541                &author_name,
1542            )? {
1543                editor = Some(who);
1544            } else if let Some(response) = &response {
1545                repo.refuse_new_ignored_files(&ctx.work_dir, &worktree_baseline)?;
1546                if response
1547                    .dispositions
1548                    .iter()
1549                    .any(|d| d.action == Action::Fixed)
1550                {
1551                    logwarn!(
1552                        "{}: {author_name} reported fixes but committed nothing, so the diff does \
1553                         not have them.",
1554                        ctx.label
1555                    );
1556                }
1557            }
1558            if let Some(response) = response {
1559                // One set of marks across both passes, so a disposition the
1560                // optional pass is about to handle is not reported as unplaced
1561                // by the blocking one.
1562                let mut used = vec![false; response.dispositions.len()];
1563                let unresolved = apply_dispositions(
1564                    repo,
1565                    cfg,
1566                    &response,
1567                    &blocking,
1568                    ledger,
1569                    state,
1570                    round,
1571                    ctx.subject,
1572                    ctx.pr_number,
1573                    &author_name,
1574                    editor.is_some(),
1575                    &mut used,
1576                );
1577                remove_findings(&mut open_findings, &blocking);
1578                extend_findings(&mut open_findings, &unresolved);
1579                apply_optional_dispositions(
1580                    repo,
1581                    cfg,
1582                    &response,
1583                    &review.findings,
1584                    state,
1585                    ctx.subject,
1586                    &mut used,
1587                );
1588                warn_unplaced_dispositions(&response, &used);
1589            }
1590        }
1591
1592        if let Some(who) = &editor {
1593            check_before_push(
1594                cfg,
1595                repo,
1596                agent::find(agents, who)?,
1597                &ctx.work_dir,
1598                &ctx.label,
1599                Call::Respond(round),
1600            )?;
1601        }
1602        if editor.is_some() {
1603            repo.rewrite_commits_if_needed(&ctx.work_dir, &base, Some(&published_head))?;
1604            repo.push_to(
1605                &ctx.work_dir,
1606                &ctx.target,
1607                &ctx.branch,
1608                Some(&published_head),
1609            )?;
1610            published_head = checked_head(repo, &ctx.work_dir)?;
1611        }
1612        holder = next_reviewer(cfg, &holder, editor.as_deref());
1613        persist(
1614            repo,
1615            ctx.pr_number,
1616            state,
1617            ledger,
1618            &open_findings,
1619            &published_head,
1620            round,
1621            &holder,
1622        )?;
1623        if let Some(error) = edit_error {
1624            return Err(error);
1625        }
1626    }
1627
1628    // Falling out of the budget is not an outcome. Every path above returns
1629    // with the head already read by somebody who did not write it; this is the
1630    // one that does not, because a round is review and then fix and the fix
1631    // comes last. Leaving it as the ending is what made every long run finish
1632    // on a commit nobody had seen, and made "we stopped" the only thing spar
1633    // could say about a pull request it had spent an hour on.
1634    close_out(
1635        agents,
1636        cfg,
1637        repo,
1638        ctx,
1639        state,
1640        ledger,
1641        &mut open_findings,
1642        &holder,
1643        last_round,
1644        &audited_head,
1645        &published_head,
1646    )
1647}
1648
1649/// The closing pass: one look at what the last round left, and the verdict.
1650///
1651/// Not a round. It cannot ask for a fix, there is nothing after it, and it is
1652/// the only way a run that spends its whole budget ends in an approval. Kept out
1653/// of the `for` so every round keeps one shape, and the call that behaves
1654/// differently is the one with a different name.
1655///
1656/// It inherits PR #24's invariant from the same place the rounds do, and not
1657/// from a rule of its own: the closer is `holder`, which `next_reviewer` has
1658/// already moved off whoever wrote the head.
1659#[allow(clippy::too_many_arguments)]
1660fn close_out(
1661    agents: &[Agent],
1662    cfg: &Config,
1663    repo: &Repo,
1664    ctx: &LoopCtx,
1665    state: &mut IssueRun,
1666    ledger: &mut Ledger,
1667    open_findings: &mut Vec<Finding>,
1668    holder: &str,
1669    round: u32,
1670    audited_head: &str,
1671    published_head: &str,
1672) -> Result<()> {
1673    let stop =
1674        |state: &mut IssueRun, ledger: &Ledger, open_findings: &[Finding], ending: Ending<'_>| {
1675            state.status = Status::Escalated;
1676            state.notes.push(exhausted_note(ctx.start_round, round));
1677            post_outcome(repo, ctx.pr_number, state, ledger, ending);
1678            persist(
1679                repo,
1680                ctx.pr_number,
1681                state,
1682                ledger,
1683                open_findings,
1684                published_head,
1685                round,
1686                holder,
1687            )
1688        };
1689
1690    // Nothing to close over. The last round changed no code and claimed no fix,
1691    // so the branch in front of the closer is the branch a round already read at
1692    // full breadth, and one more call over it buys nothing. An empty head is the
1693    // same answer for a different reason: git could not be read, so there is no
1694    // range to hand the pass. Either way it ends on its own sentence rather than
1695    // the one about unread fixes, because on this path there are none.
1696    let landed = (!audited_head.is_empty())
1697        .then(|| repo.commits_since(&ctx.work_dir, audited_head, "HEAD"))
1698        .flatten();
1699    if audited_head.is_empty()
1700        || (landed.as_ref().is_some_and(|l| l.is_empty()) && !any_fixes(ledger, round))
1701    {
1702        logdim!(
1703            "{}: nothing landed after the last review, so there is nothing to close over",
1704            ctx.label
1705        );
1706        if !open_findings.is_empty() {
1707            state.notes.push(unresolved_note(open_findings.len()));
1708        }
1709        stop(
1710            state,
1711            ledger,
1712            open_findings,
1713            ending_without_landing(open_findings),
1714        )?;
1715        return Ok(());
1716    }
1717
1718    let closer = agent::find(agents, holder)?;
1719    let effort = closer.effort(cfg, Call::Close).about(ctx.subject);
1720    log!(
1721        "{}: closing, {holder} checking what the last round left ({})",
1722        ctx.label,
1723        effort.describe()
1724    );
1725
1726    let prompt = close_prompt(
1727        &repo.base_ref(&ctx.work_dir, cfg.base_branch()),
1728        ctx.subject,
1729        &ctx.title,
1730        &ctx.context,
1731        audited_head,
1732        landed.as_deref(),
1733        ledger,
1734        open_findings,
1735        round,
1736        &ctx.criteria,
1737    );
1738    let before = snapshot(repo, &ctx.work_dir);
1739    let closing_baseline = repo.worktree_baseline(&ctx.work_dir)?;
1740    // `ask_json` rather than `Agent::review`: this prompt already defines the
1741    // full merge-safety scope and calls out the unread delta and carried points.
1742    // Appending a second scope would make the closing instructions compete.
1743    let pass = closer.ask_json(
1744        &prompt,
1745        &review_schema(&ctx.criteria),
1746        &ctx.work_dir,
1747        &effort,
1748    );
1749    if let Err(e) = &pass {
1750        if e.kind() == crate::error::ErrorKind::UncertainWrite {
1751            return Err(e.clone());
1752        }
1753    }
1754    repo.refuse_unrepresented_tracked_changes(&ctx.work_dir, &closing_baseline)?;
1755    repo.refuse_read_only_leavings(&ctx.work_dir, &closing_baseline)?;
1756    if repo.has_uncommitted_changes(&ctx.work_dir)? {
1757        bail!(
1758            "{}: {holder} left uncommitted files during the closing pass. They were kept at {} \
1759             and the pass did not continue.",
1760            ctx.label,
1761            ctx.work_dir.display()
1762        );
1763    }
1764
1765    // Held to the same rule as a review, for the same reason: a pass that judged
1766    // a tree the rollback then takes away judged code that is not there.
1767    let close_wrote = snapshot(repo, &ctx.work_dir) != before;
1768    // The closing pass never publishes code. If rollback fails, its prohibited
1769    // commit is kept under a recovery ref and the remote branch remains on the
1770    // head that `holder` is allowed to review on a later run.
1771    let next = closing_next_actor(holder);
1772    if close_wrote {
1773        if let Err(error) = &pass {
1774            logwarn!(
1775                "{}: the closing pass failed after changing the branch: {error}",
1776                ctx.label
1777            );
1778        }
1779        logwarn!(
1780            "{}: {holder} changed the branch while closing, which the prompt forbids. Rolling it \
1781             back.",
1782            ctx.label
1783        );
1784        let after_undo = undo_edits(repo, &ctx.work_dir, &before);
1785        if after_undo != before {
1786            state.notes.push(format!(
1787                "{holder}'s closing-pass changes could not be fully rolled back"
1788            ));
1789            if after_undo.head != before.head {
1790                if let Some(reference) = preserve_closing_commit(repo, ctx) {
1791                    state.notes.push(format!(
1792                        "the closing pass commit was not pushed and remains at {reference}"
1793                    ));
1794                }
1795            }
1796        }
1797        state.status = Status::Escalated;
1798        state.notes.push(format!(
1799            "{holder} edited the branch during the closing pass, so its answer did not stand"
1800        ));
1801        post_unread_outcome(repo, ctx.pr_number, state, ledger, open_findings);
1802        persist(
1803            repo,
1804            ctx.pr_number,
1805            state,
1806            ledger,
1807            open_findings,
1808            published_head,
1809            round,
1810            &next,
1811        )?;
1812        return Ok(());
1813    }
1814
1815    let pass: Review = match pass {
1816        Ok(pass) => pass,
1817        Err(e) => {
1818            // Never propagated. The run has an account of itself by now, and
1819            // losing all of it to an unreachable model on the last call is worse
1820            // than ending where it would have ended before this existed.
1821            logwarn!("{}: the closing pass failed: {e}", ctx.label);
1822            state.status = Status::Escalated;
1823            state.notes.push(exhausted_note(ctx.start_round, round));
1824            post_unread_outcome(repo, ctx.pr_number, state, ledger, open_findings);
1825            persist(
1826                repo,
1827                ctx.pr_number,
1828                state,
1829                ledger,
1830                open_findings,
1831                published_head,
1832                round,
1833                holder,
1834            )?;
1835            return Ok(());
1836        }
1837    };
1838
1839    file_out_of_scope(repo, &pass.findings, ctx.subject, state, cfg);
1840    file_nonblocking(repo, &pass.findings, ctx.subject, state, cfg);
1841
1842    let blocking = blocking_findings(&pass.findings);
1843    if let Some(said) = verdict_disagreement(&pass, blocking.len()) {
1844        logwarn!("{}: the closing pass {said}", ctx.label);
1845    }
1846    remove_findings(&mut state.noted, &blocking);
1847    update_open_findings(open_findings, &blocking, true);
1848
1849    if check_relitigation(ledger, &blocking, state) {
1850        state.status = Status::Escalated;
1851        post_outcome(
1852            repo,
1853            ctx.pr_number,
1854            state,
1855            ledger,
1856            Ending::Deadlocked(&blocking),
1857        );
1858        persist(
1859            repo,
1860            ctx.pr_number,
1861            state,
1862            ledger,
1863            open_findings,
1864            published_head,
1865            round,
1866            &next,
1867        )?;
1868        return Ok(());
1869    }
1870
1871    if approval_stands(&blocking, false) {
1872        open_findings.clear();
1873        state.criteria = ruled(&ctx.criteria, &pass.criteria);
1874        return approve(cfg, repo, ctx, state, ledger, published_head, round, &next);
1875    }
1876
1877    state.status = Status::Escalated;
1878    state.notes.push(unresolved_note(open_findings.len()));
1879    post_outcome(
1880        repo,
1881        ctx.pr_number,
1882        state,
1883        ledger,
1884        Ending::Unresolved(open_findings),
1885    );
1886    persist(
1887        repo,
1888        ctx.pr_number,
1889        state,
1890        ledger,
1891        open_findings,
1892        published_head,
1893        round,
1894        &next,
1895    )?;
1896    Ok(())
1897}
1898
1899/// Whether the last round left a claimed fix for the closing pass to ask about.
1900///
1901/// Scoped to the round rather than to the pull request. Asked over the whole
1902/// ledger, a resumed run with an old fix in it would never take the skip, and
1903/// the pass would be handed a branch nothing had changed.
1904fn any_fixes(ledger: &Ledger, round: u32) -> bool {
1905    ledger
1906        .values()
1907        .any(|e| e.outcome == Settled::Fixed && e.round >= round)
1908}
1909
1910/// What the run says about itself when the closing pass did not sign off.
1911///
1912/// Not "no convergence after three rounds". A count of rounds is a fact about
1913/// spar, and what is left is a fact about the branch.
1914fn unresolved_note(left: usize) -> String {
1915    match left {
1916        1 => "one point left after the closing pass".to_string(),
1917        n => format!("{n} points left after the closing pass"),
1918    }
1919}
1920
1921/// End the run on a pass: post, persist, leave draft, and merge if asked.
1922///
1923/// Extracted because the closing pass ends the same way a round does. Written
1924/// twice, the two would drift, and the half that drifted would be the one that
1925/// merges.
1926fn ensure_reviewed_head(pr_number: i64, reviewed_head: &str, live_head: &str) -> Result<()> {
1927    if live_head == reviewed_head {
1928        return Ok(());
1929    }
1930    Err(spar_err!(
1931        "PR #{pr_number} changed from {reviewed_head} to {live_head} after it was reviewed; refusing to approve or merge an unread head"
1932    ))
1933}
1934
1935#[allow(clippy::too_many_arguments)]
1936fn approve(
1937    cfg: &Config,
1938    repo: &Repo,
1939    ctx: &LoopCtx,
1940    state: &mut IssueRun,
1941    ledger: &Ledger,
1942    published_head: &str,
1943    round: u32,
1944    holder: &str,
1945) -> Result<()> {
1946    let live_head = repo.pr_head_oid(ctx.pr_number)?;
1947    ensure_reviewed_head(ctx.pr_number, published_head, &live_head)?;
1948    state.status = Status::Approved;
1949    persist(
1950        repo,
1951        ctx.pr_number,
1952        state,
1953        ledger,
1954        &[],
1955        published_head,
1956        round,
1957        holder,
1958    )?;
1959    let live_head = repo.pr_head_oid(ctx.pr_number)?;
1960    ensure_reviewed_head(ctx.pr_number, published_head, &live_head)?;
1961    // A tick says the work landed, so only a merge spar performs earns one.
1962    state.ticks_criteria = cfg.loop_cfg.auto_merge && cfg.loop_cfg.tick_criteria;
1963    post_outcome(repo, ctx.pr_number, state, ledger, Ending::Approved);
1964    // Before the merge, not after: a draft cannot be merged, and the state the
1965    // draft was signalling, that two agents were still arguing about it, has
1966    // just stopped being true.
1967    if cfg.loop_cfg.drafts == Drafts::UntilApproved && repo.mark_ready(ctx.pr_number) {
1968        log!("{}: out of draft", ctx.label);
1969    }
1970    if cfg.loop_cfg.auto_merge {
1971        // Release the worktree first. `gh pr merge --delete-branch` fails if
1972        // anything still has the branch checked out, and it fails *after*
1973        // merging, so the merge lands while the command reports failure.
1974        let released = ctx.release(repo);
1975        if !released {
1976            log!(
1977                "{}: kept the worktree at {} and will leave its branch in place",
1978                ctx.label,
1979                ctx.work_dir.display()
1980            );
1981        }
1982        repo.merge_pr_at_head(ctx.pr_number, published_head, released)?;
1983        state.status = Status::Merged;
1984        repo.clear_state(ctx.pr_number); // nothing left to resume
1985        log!("{}: merged", ctx.label);
1986        let some_met = state.criteria.iter().any(|c| c.status == Some(Mark::Met));
1987        if state.ticks_criteria && ctx.subject != ctx.pr_number && some_met {
1988            // A merge queue takes the command and merges later, or not at all.
1989            // And a merge that reported failure counts as done once the PR
1990            // reads MERGED, which someone else may have done at a later head.
1991            let landed = repo.pr_state(ctx.pr_number) == "MERGED"
1992                && repo.pr_head_oid(ctx.pr_number).ok().as_deref() == Some(published_head);
1993            if !landed {
1994                log!(
1995                    "{}: not merged yet at the reviewed head, so no acceptance criteria were ticked on #{}",
1996                    ctx.label,
1997                    ctx.subject
1998                );
1999                return Ok(());
2000            }
2001            let ticked = tick_met(repo, ctx.subject, &state.criteria);
2002            if ticked > 0 {
2003                let noun = if ticked == 1 { "criterion" } else { "criteria" };
2004                log!(
2005                    "{}: ticked {ticked} acceptance {noun} on #{}",
2006                    ctx.label,
2007                    ctx.subject
2008                );
2009            }
2010        }
2011    } else {
2012        log!("{}: approved, awaiting human merge", ctx.label);
2013    }
2014    Ok(())
2015}
2016
2017/// Whether a review with nothing blocking can end the run.
2018///
2019/// A review that wrote to the branch judged a tree the rollback then takes
2020/// away, so "nothing blocking" was said about code that is not there any more:
2021/// a reviewer that quietly fixes what it finds and reports clean would merge
2022/// the defect it fixed. Another round on the restored branch is cheaper than
2023/// that.
2024fn approval_stands(blocking: &[Finding], review_wrote: bool) -> bool {
2025    blocking.is_empty() && !review_wrote
2026}
2027
2028/// Who reviews the next round: never the agent that wrote the head it will
2029/// read.
2030///
2031/// `editor` is whoever moved HEAD this round, observed rather than inferred
2032/// from `next_action`. The two came apart in both directions: a `fix_myself`
2033/// call that returned without committing handed the author its own commit back,
2034/// and a reviewer that committed during `hand_back` kept a PR whose head it had
2035/// written.
2036///
2037/// Nothing landing at all leaves the head with the author, which by this rule's
2038/// own invariant is not the reviewer, so the reviewer keeps the pull request and
2039/// reads the same commit again.
2040fn next_reviewer(cfg: &Config, reviewer: &str, editor: Option<&str>) -> String {
2041    match editor {
2042        Some(editor) => cfg.other(editor),
2043        None => reviewer.to_string(),
2044    }
2045}
2046
2047fn defer_clean_edit_error(
2048    repo: &Repo,
2049    work_dir: &Path,
2050    baseline: &crate::repo::WorktreeBaseline,
2051    error: SparError,
2052) -> Result<SparError> {
2053    if error.kind() == ErrorKind::UncertainWrite {
2054        return Err(error);
2055    }
2056    repo.refuse_changed_attributes(work_dir, baseline)?;
2057    if repo.has_uncommitted_changes(work_dir)? {
2058        return Err(error);
2059    }
2060    repo.refuse_failed_edit_leavings(work_dir, baseline)?;
2061    repo.refuse_unrepresented_tracked_changes(work_dir, baseline)?;
2062    Ok(error)
2063}
2064
2065/// Who wrote the head after a call that was asked to commit, if anybody did.
2066///
2067/// A call that returns successfully is not evidence of a commit, and custody is
2068/// decided on this answer, so it is read from git rather than taken from the
2069/// agent's word for it. Anything it left uncommitted goes the same way as a
2070/// review's edits, and for the same reason: the round it hands over is the diff
2071/// on the branch, not the state of somebody's checkout.
2072fn editor_after(
2073    repo: &Repo,
2074    work_dir: &Path,
2075    before_head: &str,
2076    label: &str,
2077    who: &str,
2078) -> Result<Option<String>> {
2079    if repo.has_uncommitted_changes(work_dir)? {
2080        bail!(
2081            "{label}: {who} left uncommitted files at {}. They were kept and the review did not \
2082             continue.",
2083            work_dir.display()
2084        );
2085    }
2086    let after_head = repo.head_oid_checked(work_dir)?;
2087    if after_head != before_head && !repo.is_ancestor_checked(work_dir, before_head, &after_head)? {
2088        bail!(
2089            "{label}: {who} moved HEAD to {after_head}, but it does not contain the previous \
2090             branch tip {before_head}. The worktree was kept for recovery and nothing was \
2091             published. Restore {before_head} or reapply the intended commits on top of it before \
2092             resuming."
2093        );
2094    }
2095    Ok((after_head != before_head).then(|| who.to_string()))
2096}
2097
2098/// The inclusive range of round numbers this invocation will work through.
2099///
2100/// Round numbers keep counting up across sessions so the ledger and the PR
2101/// history stay coherent, while the budget resets each time a person chooses to
2102/// run spar again.
2103fn round_window(start_round: u32, budget: u32) -> (u32, u32) {
2104    (start_round, start_round + budget.saturating_sub(1))
2105}
2106
2107/// How many rounds this invocation spent, and how many the PR has seen in
2108/// total. A resumed PR that stops at round 8 did not have 8 rounds of budget,
2109/// and saying so would misreport both the cost and the history.
2110fn spent(start_round: u32, last_round: u32) -> (u32, u32) {
2111    (last_round.saturating_sub(start_round) + 1, last_round)
2112}
2113
2114fn exhausted_note(start_round: u32, last_round: u32) -> String {
2115    let (this_run, total) = spent(start_round, last_round);
2116    if this_run == total {
2117        format!("no convergence after {this_run} rounds")
2118    } else {
2119        format!("no convergence after {this_run} more rounds ({total} in total)")
2120    }
2121}
2122
2123#[allow(clippy::too_many_arguments)]
2124fn persist(
2125    repo: &Repo,
2126    pr_number: i64,
2127    state: &IssueRun,
2128    ledger: &Ledger,
2129    open_findings: &[Finding],
2130    published_head: &str,
2131    round: u32,
2132    next_actor: &str,
2133) -> Result<()> {
2134    let payload = PersistedState {
2135        version: STATE_VERSION,
2136        checkpoint: 0,
2137        round,
2138        next_actor: next_actor.to_string(),
2139        status: state.status,
2140        pr_head: published_head.to_string(),
2141        ledger: ledger.clone(),
2142        filed: state.filed.clone(),
2143        open_findings: open_findings.to_vec(),
2144        disputes: state.disputes.clone(),
2145        noted: state.noted.clone(),
2146    };
2147    repo.write_state(pr_number, &payload)
2148}
2149
2150// ---------------------------------------------------------------------------
2151// The ledger
2152// ---------------------------------------------------------------------------
2153
2154fn settled_block(ledger: &Ledger) -> String {
2155    if ledger.is_empty() {
2156        return String::new();
2157    }
2158    // A fixed point has no line here. The code changed for it, which is the
2159    // opposite of what this block says, and it goes in the answers block
2160    // instead, where it reads as a claim to check rather than an argument
2161    // already won.
2162    let lines: Vec<String> = ledger
2163        .values()
2164        .filter_map(|e| {
2165            let point = match e.file.trim() {
2166                "" => e.title.clone(),
2167                file => format!("{} ({file})", e.title),
2168            };
2169            match e.outcome {
2170                Settled::Refuted => Some(format!("- {point}: refuted because {}", e.reasoning)),
2171                Settled::Filed => Some(format!(
2172                    "- {point}: out of scope here, and filed. {}",
2173                    e.reasoning
2174                )),
2175                Settled::Dropped => Some(format!(
2176                    "- {point}: out of scope here, and not filed. {}",
2177                    e.reasoning
2178                )),
2179                Settled::Fixed => None,
2180            }
2181        })
2182        .collect();
2183    if lines.is_empty() {
2184        return String::new();
2185    }
2186    format!(
2187        "\nThe following points were already raised and settled, by a refutation or by a \
2188         follow-up issue. Treat them as settled. Do not raise them again unless you have new \
2189         evidence:\n{}",
2190        lines.join("\n")
2191    )
2192}
2193
2194/// The points the author says it fixed, one line each, with the claim attached.
2195///
2196/// One formatter for the two blocks that print them, because two copies of a
2197/// list are two copies to drift.
2198///
2199/// `since` is what keeps the list from growing without end. A fix is a claim for
2200/// whoever reads the branch next, and once that pass has read it and not raised
2201/// it again, it has been checked. Carrying every fix a pull request ever saw
2202/// would put a resumed run's tenth round in front of nine rounds of answered
2203/// points, which is the same unbounded surface this whole change exists to
2204/// bound.
2205fn fixed_lines(ledger: &Ledger, since: u32) -> Vec<String> {
2206    ledger
2207        .values()
2208        .filter(|e| e.outcome == Settled::Fixed && e.round >= since)
2209        .map(|e| {
2210            let claim = match e.reasoning.trim() {
2211                "" => "a committed change claims to address this point",
2212                reasoning => reasoning,
2213            };
2214            match e.file.trim() {
2215                "" => format!("- {}. Recorded answer: {claim}", e.title),
2216                file => format!("- {} ({file}). Recorded answer: {claim}", e.title),
2217            }
2218        })
2219        .collect()
2220}
2221
2222/// What a later round is told about the fixes it asked for.
2223///
2224/// The ledger used to hold only the points the reviewer lost, so a round that
2225/// fixed nine findings left nothing behind and the next round met the fix as
2226/// ordinary code. Rendered apart from the settled block on purpose: a settled
2227/// point is an argument to weigh, a fixed point is a claim to check, and printed
2228/// under one heading a claim to check reads as an argument already won.
2229fn answers_block(ledger: &Ledger, round: u32) -> String {
2230    // The round before this one: what the pass this reviewer is following up on
2231    // asked for, and got.
2232    let lines = fixed_lines(ledger, round.saturating_sub(1));
2233    if lines.is_empty() {
2234        return String::new();
2235    }
2236    format!(
2237        "\nThese points were raised on this pull request in earlier rounds and the author says \
2238         it fixed them. The code is on the branch and the claim is the author's:\n{}\n\nCheck the \
2239         answer rather than taking it. If one of them is still not fixed, raise it again under \
2240         the same title, so the run can tell a point that was not answered from a new one.\n",
2241        lines.join("\n")
2242    )
2243}
2244
2245/// What the reviewer is told about where in the run it is.
2246///
2247/// Empty until the last round that can ask for anything, where it says one thing
2248/// the prompt could not say before: when the asking stops. The deadline holds
2249/// whether or not the reviewer is told, so saying it out loud only lets the
2250/// reviewer spend the round it has. It says nothing about severity, because a
2251/// reviewer that lowers its bar to finish is the failure this loop was built
2252/// against.
2253fn round_note(round: u32, last: u32) -> String {
2254    if round < last {
2255        return String::new();
2256    }
2257    "\nThis is the last round in this run that can ask the author for anything. After it, one \
2258     pass reads what landed and the pull request is either signed off or goes to a person with \
2259     what is left. Raise everything you mean to raise now. A point held back for a later round \
2260     does not get one.\n"
2261        .to_string()
2262}
2263
2264/// Record a point as settled, keeping any re-raise count it already carries.
2265/// Answering the same point a second time does not reset the argument, and
2266/// zeroing the count here would put the escalation guard out of reach: the
2267/// count is spent every round and rebuilt from nothing every round.
2268#[cfg(test)]
2269fn matching_ledger_key(ledger: &Ledger, title: &str, file: &str) -> Option<String> {
2270    matching_ledger_key_with_fallback(ledger, title, file, true)
2271}
2272
2273fn matching_ledger_key_with_fallback(
2274    ledger: &Ledger,
2275    title: &str,
2276    file: &str,
2277    allow_stable_fallback: bool,
2278) -> Option<String> {
2279    let exact = finding_key(title, file);
2280    if ledger.contains_key(&exact) {
2281        return Some(exact);
2282    }
2283    let legacy = crate::jsonx::finding_key(title, file);
2284    if ledger
2285        .get(&legacy)
2286        .is_some_and(|entry| same_finding_parts(&entry.title, &entry.file, title, file))
2287    {
2288        return Some(legacy);
2289    }
2290    if !allow_stable_fallback {
2291        return None;
2292    }
2293
2294    let stable = stable_finding_key(title, file);
2295    let path = finding_file(file);
2296    let mut matches = ledger
2297        .iter()
2298        .filter(|(saved_key, entry)| {
2299            if stable_finding_key(&entry.title, &entry.file) == stable {
2300                return true;
2301            }
2302            finding_file(&entry.file) == path
2303                && saved_key.as_str() == crate::jsonx::finding_key(title, &entry.file)
2304        })
2305        .map(|(key, _)| key.clone());
2306    let first = matches.next()?;
2307    matches.next().is_none().then_some(first)
2308}
2309
2310fn matching_ledger_entry<'a>(
2311    ledger: &'a Ledger,
2312    title: &str,
2313    file: &str,
2314) -> Option<&'a LedgerEntry> {
2315    matching_ledger_entry_with_fallback(ledger, title, file, true)
2316}
2317
2318fn matching_ledger_entry_with_fallback<'a>(
2319    ledger: &'a Ledger,
2320    title: &str,
2321    file: &str,
2322    allow_stable_fallback: bool,
2323) -> Option<&'a LedgerEntry> {
2324    let key = matching_ledger_key_with_fallback(ledger, title, file, allow_stable_fallback)?;
2325    ledger.get(&key)
2326}
2327
2328fn settle(
2329    ledger: &mut Ledger,
2330    title: &str,
2331    file: &str,
2332    allow_stable_fallback: bool,
2333    entry: LedgerEntry,
2334) {
2335    let old_key = matching_ledger_key_with_fallback(ledger, title, file, allow_stable_fallback);
2336    let reraised = old_key
2337        .as_ref()
2338        .and_then(|key| ledger.get(key))
2339        .map(|entry| entry.reraised)
2340        .unwrap_or(0);
2341    if let Some(old_key) = old_key {
2342        ledger.remove(&old_key);
2343    }
2344    ledger.insert(finding_key(title, file), LedgerEntry { reraised, ..entry });
2345}
2346
2347/// Re-key state from the raw title and full location stored in each entry.
2348fn normalise_ledger_keys(ledger: &mut Ledger) {
2349    let mut normalised = Ledger::new();
2350    for (saved_key, mut entry) in std::mem::take(ledger) {
2351        let key = if saved_key.len() == 12
2352            && saved_key
2353                .chars()
2354                .all(|character| character.is_ascii_hexdigit())
2355        {
2356            saved_key
2357        } else {
2358            finding_key(&entry.title, &entry.file)
2359        };
2360        if let Some(previous) = normalised.get_mut(&key) {
2361            let reraised = previous.reraised.max(entry.reraised);
2362            if entry.round >= previous.round {
2363                entry.reraised = reraised;
2364                *previous = entry;
2365            } else {
2366                previous.reraised = reraised;
2367            }
2368        } else {
2369            normalised.insert(key, entry);
2370        }
2371    }
2372    *ledger = normalised;
2373}
2374
2375/// Blocking findings, once each, in review order.
2376/// Where a review's own two answers contradict each other, in words.
2377///
2378/// `verdict` is required by the schema and decides nothing: every path here
2379/// reads `next_action` and the findings' severities. Keeping it and checking it
2380/// is worth more than dropping it, because the disagreement is a signal about
2381/// the answer: a review that says approve while raising a blocker has not
2382/// decided what it thinks, and the reader of the pull request should know that
2383/// the findings were what counted.
2384pub(crate) fn verdict_disagreement(review: &Review, blocking: usize) -> Option<String> {
2385    match (review.verdict, blocking, review.next_action) {
2386        (Verdict::Approve, n, _) if n > 0 => Some(format!(
2387            "the review says approve and raises {n} blocking finding(s); the findings decide"
2388        )),
2389        (Verdict::ChangesRequested, 0, NextAction::Merge) => Some(
2390            "the review says changes_requested, raises nothing blocking, and asks to merge; the \
2391             findings decide"
2392                .to_string(),
2393        ),
2394        _ => None,
2395    }
2396}
2397
2398fn blocking_findings(findings: &[Finding]) -> Vec<Finding> {
2399    let mut kept = Vec::new();
2400    for finding in findings.iter().filter(|finding| finding.blocks()) {
2401        if let Some(existing) = kept
2402            .iter_mut()
2403            .find(|existing| same_finding(existing, finding))
2404        {
2405            *existing = finding.clone();
2406        } else {
2407            kept.push(finding.clone());
2408        }
2409    }
2410    kept
2411}
2412
2413fn matching_finding_index(
2414    findings: &[Finding],
2415    target: &Finding,
2416    allow_stable_fallback: bool,
2417) -> Option<usize> {
2418    if let Some(index) = findings
2419        .iter()
2420        .position(|finding| same_finding(finding, target))
2421    {
2422        return Some(index);
2423    }
2424    if !allow_stable_fallback {
2425        return None;
2426    }
2427
2428    let stable = stable_finding_key(&target.title, &target.file);
2429    let mut matches = findings
2430        .iter()
2431        .enumerate()
2432        .filter(|(_, finding)| stable_finding_key(&finding.title, &finding.file) == stable);
2433    let first = matches.next().map(|(index, _)| index);
2434    first.filter(|_| matches.next().is_none())
2435}
2436
2437fn unique_stable_finding(findings: &[Finding], target: &Finding) -> bool {
2438    let stable = stable_finding_key(&target.title, &target.file);
2439    findings
2440        .iter()
2441        .filter(|finding| stable_finding_key(&finding.title, &finding.file) == stable)
2442        .count()
2443        == 1
2444}
2445
2446/// Add findings without losing their newest location or explanation.
2447fn extend_findings(target: &mut Vec<Finding>, additions: &[Finding]) {
2448    for finding in additions {
2449        if let Some(index) =
2450            matching_finding_index(target, finding, unique_stable_finding(additions, finding))
2451        {
2452            target[index] = finding.clone();
2453        } else {
2454            target.push(finding.clone());
2455        }
2456    }
2457}
2458
2459fn update_open_findings(
2460    open_findings: &mut Vec<Finding>,
2461    current: &[Finding],
2462    answer_stands: bool,
2463) {
2464    if answer_stands {
2465        *open_findings = current.to_vec();
2466    } else {
2467        extend_findings(open_findings, current);
2468    }
2469}
2470
2471fn remove_findings(target: &mut Vec<Finding>, removed: &[Finding]) {
2472    for finding in removed {
2473        if let Some(index) =
2474            matching_finding_index(target, finding, unique_stable_finding(removed, finding))
2475        {
2476            target.remove(index);
2477        }
2478    }
2479}
2480
2481fn ending_without_landing(open_findings: &[Finding]) -> Ending<'_> {
2482    if open_findings.is_empty() {
2483        Ending::Unchanged
2484    } else {
2485        Ending::Unresolved(open_findings)
2486    }
2487}
2488
2489fn closing_next_actor(holder: &str) -> String {
2490    holder.to_string()
2491}
2492
2493/// Keep the real points a reviewer chose not to gate on.
2494///
2495/// The severity ladder is the whole defence against the nitpick spiral, and it
2496/// only works if a reviewer can put a real defect somewhere other than blocking.
2497/// Somewhere has to be a place, though: under the defaults a non-blocking
2498/// finding is filed nowhere and commented nowhere, so downgrading one deleted
2499/// it. Now downgrading costs the reviewer a line on the pull request in its own
2500/// words, and a run that merges with fourteen of them says so where a person
2501/// will see it.
2502///
2503/// Nits are not kept. They are taste, and a list of them is the noise the
2504/// outcome comment exists to avoid.
2505fn remember_noted(state: &mut IssueRun, finding: &Finding, allow_stable_fallback: bool) {
2506    if let Some(index) = matching_finding_index(&state.noted, finding, allow_stable_fallback) {
2507        state.noted[index] = finding.clone();
2508    } else {
2509        state.noted.push(finding.clone());
2510    }
2511}
2512
2513fn forget_noted(state: &mut IssueRun, finding: &Finding, allow_stable_fallback: bool) {
2514    if let Some(index) = matching_finding_index(&state.noted, finding, allow_stable_fallback) {
2515        state.noted.remove(index);
2516    }
2517}
2518
2519fn remember_dispute(state: &mut IssueRun, dispute: Dispute, allow_stable_fallback: bool) {
2520    let exact = finding_key(&dispute.title, &dispute.file);
2521    let stable = stable_finding_key(&dispute.title, &dispute.file);
2522    let exact_index = state
2523        .disputes
2524        .iter()
2525        .position(|kept| finding_key(&kept.title, &kept.file) == exact);
2526    let stable_index = if exact_index.is_none() && allow_stable_fallback {
2527        let mut matches = state
2528            .disputes
2529            .iter()
2530            .enumerate()
2531            .filter(|(_, kept)| stable_finding_key(&kept.title, &kept.file) == stable);
2532        let first = matches.next().map(|(index, _)| index);
2533        first.filter(|_| matches.next().is_none())
2534    } else {
2535        None
2536    };
2537    if let Some(index) = exact_index.or(stable_index) {
2538        state.disputes[index] = dispute;
2539    } else {
2540        state.disputes.push(dispute);
2541    }
2542}
2543
2544fn forget_dispute(state: &mut IssueRun, finding: &Finding, allow_stable_fallback: bool) {
2545    let exact = finding_key(&finding.title, &finding.file);
2546    if let Some(index) = state
2547        .disputes
2548        .iter()
2549        .position(|kept| finding_key(&kept.title, &kept.file) == exact)
2550    {
2551        state.disputes.remove(index);
2552        return;
2553    }
2554    if !allow_stable_fallback {
2555        return;
2556    }
2557
2558    let stable = stable_finding_key(&finding.title, &finding.file);
2559    let mut matches = state
2560        .disputes
2561        .iter()
2562        .enumerate()
2563        .filter(|(_, kept)| stable_finding_key(&kept.title, &kept.file) == stable);
2564    let first = matches.next().map(|(index, _)| index);
2565    if let Some(index) = first.filter(|_| matches.next().is_none()) {
2566        state.disputes.remove(index);
2567    }
2568}
2569
2570#[cfg(test)]
2571fn record_nonblocking_outcome(state: &mut IssueRun, finding: &Finding, outcome: Option<&Followup>) {
2572    record_nonblocking_outcome_with_match(state, finding, outcome, true);
2573}
2574
2575fn record_nonblocking_outcome_with_match(
2576    state: &mut IssueRun,
2577    finding: &Finding,
2578    outcome: Option<&Followup>,
2579    allow_stable_fallback: bool,
2580) {
2581    forget_dispute(state, finding, allow_stable_fallback);
2582    if let Some(Followup::Recorded(url)) = outcome {
2583        if !state.filed.iter().any(|filed| filed == url) {
2584            state.filed.push(url.clone());
2585        }
2586        forget_noted(state, finding, allow_stable_fallback);
2587    } else {
2588        remember_noted(state, finding, allow_stable_fallback);
2589    }
2590}
2591
2592/// Split the reviewer's own findings by what its fix call says it did.
2593///
2594/// Returns the ones it reports fixed, and the ones it reports leaving with the
2595/// reason attached to the detail, because the author reads that next round and
2596/// "the reviewer tried this and stopped" is the useful half of it. A finding
2597/// with no entry stays open too: an unanswered point is not a fixed one.
2598fn split_own_fixes(blocking: &[Finding], fixes: &[OwnFix]) -> (Vec<Finding>, Vec<Finding>) {
2599    let mut claimed = Vec::new();
2600    let mut left = Vec::new();
2601    let mut used = vec![false; fixes.len()];
2602    for finding in blocking {
2603        match matching_disposition(finding, blocking, fixes) {
2604            Ok((index, fix)) if !used[index] => {
2605                used[index] = true;
2606                if fix.fixed {
2607                    claimed.push(finding.clone());
2608                    continue;
2609                }
2610                let mut open = finding.clone();
2611                let reason = fix.reason.trim();
2612                if !reason.is_empty() {
2613                    open.detail = format!(
2614                        "{}\n\nThe reviewer chose to fix this itself and did not: {reason}",
2615                        open.detail.trim()
2616                    );
2617                }
2618                logwarn!("'{}' was not fixed: {reason}", finding.title);
2619                left.push(open);
2620            }
2621            Ok(_) => {
2622                logwarn!(
2623                    "'{}' has more than one matching entry in the fix report, so it stays open",
2624                    finding.title
2625                );
2626                left.push(finding.clone());
2627            }
2628            Err(reason) => {
2629                logwarn!(
2630                    "'{}' has {reason} in the fix report, so it stays open",
2631                    finding.title
2632                );
2633                left.push(finding.clone());
2634            }
2635        }
2636    }
2637    (claimed, left)
2638}
2639
2640/// Put the findings a reviewer fixed itself in the ledger.
2641///
2642/// The other path has an author's disposition to record, naming which points it
2643/// answered. Here the reviewer both raised and fixed them, so there is no
2644/// disposition and the findings themselves are the record. Only reached when a
2645/// commit landed, which the caller has just observed.
2646fn record_own_fixes(blocking: &[Finding], ledger: &mut Ledger, state: &mut IssueRun, round: u32) {
2647    for finding in blocking {
2648        let allow_stable_fallback = unique_stable_finding(blocking, finding);
2649        settle(
2650            ledger,
2651            &finding.title,
2652            &finding.file,
2653            allow_stable_fallback,
2654            LedgerEntry {
2655                title: finding.title.clone(),
2656                file: finding.file.clone(),
2657                reasoning: "a committed change was made for this point".to_string(),
2658                round,
2659                reraised: 0,
2660                outcome: Settled::Fixed,
2661            },
2662        );
2663        forget_noted(state, finding, allow_stable_fallback);
2664        forget_dispute(state, finding, allow_stable_fallback);
2665    }
2666}
2667
2668/// A settled point raised twice more goes to a person rather than looping
2669/// forever.
2670fn check_relitigation(ledger: &mut Ledger, blocking: &[Finding], state: &mut IssueRun) -> bool {
2671    let mut escalate = false;
2672    // One re-raise per round, however many times a review says it. Counting
2673    // each finding separately let a review that listed one title twice take an
2674    // entry from nothing to escalated in a single pass, without the author ever
2675    // being asked. Rare while only refutations were recorded, and not rare now
2676    // that every fix leaves an entry.
2677    let mut counted: BTreeSet<String> = BTreeSet::new();
2678    for finding in blocking {
2679        let allow_stable_fallback = unique_stable_finding(blocking, finding);
2680        let Some(key) = matching_ledger_key_with_fallback(
2681            ledger,
2682            &finding.title,
2683            &finding.file,
2684            allow_stable_fallback,
2685        ) else {
2686            continue;
2687        };
2688        if !counted.insert(key.clone()) {
2689            continue;
2690        }
2691        if let Some(entry) = ledger.get_mut(&key) {
2692            entry.reraised += 1;
2693            if entry.outcome == Settled::Refuted {
2694                remember_dispute(
2695                    state,
2696                    Dispute {
2697                        title: finding.title.clone(),
2698                        file: finding.file.clone(),
2699                        reasoning: entry.reasoning.clone(),
2700                    },
2701                    allow_stable_fallback,
2702                );
2703            }
2704            if entry.reraised >= 2 {
2705                state.notes.push(format!(
2706                    "'{}' {}",
2707                    finding.title,
2708                    why_escalated(entry.outcome)
2709                ));
2710                escalate = true;
2711            }
2712        }
2713    }
2714    escalate
2715}
2716
2717/// What a person is told about a point that ran out of tries.
2718///
2719/// A fix that missed twice is not an argument nobody would give up, and calling
2720/// it one sends a maintainer to the wrong side of it. The code changed twice for
2721/// this point and the reviewer still says it is wrong, which is a different
2722/// thing to look at and a more likely one to be right about.
2723fn why_escalated(outcome: Settled) -> &'static str {
2724    match outcome {
2725        Settled::Fixed => "was fixed twice and raised again; escalating.",
2726        _ => "was settled and re-raised twice; escalating.",
2727    }
2728}
2729
2730fn normalise(text: &str) -> String {
2731    crate::jsonx::untagged_title(text)
2732        .to_lowercase()
2733        .chars()
2734        .filter(|c| c.is_ascii_alphanumeric() || c.is_whitespace())
2735        .collect::<String>()
2736        .split_whitespace()
2737        .collect::<Vec<_>>()
2738        .join(" ")
2739}
2740
2741/// Whether two titles name the same point, ignoring wording noise.
2742pub(crate) fn same_point(a: &str, b: &str) -> bool {
2743    normalise(a) == normalise(b)
2744}
2745
2746pub(crate) fn same_finding(a: &Finding, b: &Finding) -> bool {
2747    same_finding_parts(&a.title, &a.file, &b.title, &b.file)
2748}
2749
2750pub(crate) fn same_finding_parts(a_title: &str, a_file: &str, b_title: &str, b_file: &str) -> bool {
2751    finding_key(a_title, a_file) == finding_key(b_title, b_file)
2752}
2753
2754/// An answer to a finding, from whichever side of the round wrote it.
2755///
2756/// One matching rule for both, because the ledger key it produces has to be the
2757/// same key the next round's finding hashes to whether the author answered the
2758/// point or the reviewer answered its own.
2759pub(crate) trait Answer {
2760    fn title(&self) -> &str;
2761    fn file(&self) -> &str;
2762}
2763
2764impl Answer for Disposition {
2765    fn title(&self) -> &str {
2766        &self.title
2767    }
2768    fn file(&self) -> &str {
2769        &self.file
2770    }
2771}
2772
2773impl Answer for OwnFix {
2774    fn title(&self) -> &str {
2775        &self.title
2776    }
2777    fn file(&self) -> &str {
2778        &self.file
2779    }
2780}
2781
2782fn answer_matches<A: Answer>(finding: &Finding, answer: &A) -> bool {
2783    same_point(&finding.title, answer.title()) && finding.file.trim() == answer.file().trim()
2784}
2785
2786fn stable_answer_matches<A: Answer>(finding: &Finding, answer: &A) -> bool {
2787    stable_finding_key(&finding.title, &finding.file)
2788        == stable_finding_key(answer.title(), answer.file())
2789}
2790
2791fn sole_answer_match<'a, A: Answer>(
2792    finding: &Finding,
2793    answers: &'a [A],
2794    matches: impl Fn(&Finding, &A) -> bool,
2795) -> std::result::Result<Option<(usize, &'a A)>, &'static str> {
2796    let mut matched = answers
2797        .iter()
2798        .enumerate()
2799        .filter(|(_, answer)| matches(finding, answer));
2800    let first = matched.next();
2801    if matched.next().is_some() {
2802        return Err("more than one matching disposition");
2803    }
2804    Ok(first)
2805}
2806
2807/// Match an answer back to the finding it answers, so the ledger key it
2808/// records is the same key the next round's finding will hash to. Exact
2809/// locations lead. A line-tolerant match is safe only when the finding and its
2810/// answer are each unique at that stable repository path.
2811fn matching_disposition<'a, A: Answer>(
2812    finding: &Finding,
2813    findings: &[Finding],
2814    answers: &'a [A],
2815) -> std::result::Result<(usize, &'a A), &'static str> {
2816    if let Some(exact) = sole_answer_match(finding, answers, answer_matches)? {
2817        return Ok(exact);
2818    }
2819    if unique_stable_finding(findings, finding) {
2820        if let Some(stable) = sole_answer_match(finding, answers, stable_answer_matches)? {
2821            return Ok(stable);
2822        }
2823    }
2824    Err("no matching disposition")
2825}
2826
2827fn fixed_disposition_resolves(committed: bool) -> bool {
2828    committed
2829}
2830
2831#[allow(clippy::too_many_arguments)]
2832fn apply_dispositions(
2833    repo: &Repo,
2834    cfg: &Config,
2835    response: &ResponseDoc,
2836    blocking: &[Finding],
2837    ledger: &mut Ledger,
2838    state: &mut IssueRun,
2839    round: u32,
2840    subject: i64,
2841    pr_number: i64,
2842    author: &str,
2843    committed: bool,
2844    used: &mut [bool],
2845) -> Vec<Finding> {
2846    let mut fixed = Vec::new();
2847    let mut refuted = Vec::new();
2848    let mut filed = Vec::new();
2849    let mut unresolved = Vec::new();
2850
2851    for source in blocking {
2852        let (index, d) = match matching_disposition(source, blocking, &response.dispositions) {
2853            Ok((index, disposition)) if !used[index] => (index, disposition),
2854            Ok(_) => {
2855                logwarn!(
2856                    "'{}' has more than one matching disposition, so it stays open",
2857                    source.title
2858                );
2859                unresolved.push(source.clone());
2860                continue;
2861            }
2862            Err(reason) => {
2863                logwarn!("'{}' has {reason}, so it stays open", source.title);
2864                unresolved.push(source.clone());
2865                continue;
2866            }
2867        };
2868        used[index] = true;
2869        let file = source.file.clone();
2870        // Hash the reviewer's wording, not the author's. The response may vary
2871        // punctuation while still matching the point, and the next round must
2872        // look up the same identity the review created.
2873        let canonical = source.title.as_str();
2874        let title = style::title(canonical, &repo.style);
2875        let located_title = match file.trim() {
2876            "" => title.clone(),
2877            location => format!("{title} ({location})"),
2878        };
2879        let allow_stable_fallback = unique_stable_finding(blocking, source);
2880
2881        match d.action {
2882            Action::Refuted => {
2883                let reasoning = style::summary(&d.reasoning, &repo.style);
2884                settle(
2885                    ledger,
2886                    canonical,
2887                    &file,
2888                    allow_stable_fallback,
2889                    LedgerEntry {
2890                        title: canonical.to_string(),
2891                        file: file.clone(),
2892                        reasoning: reasoning.clone(),
2893                        round,
2894                        reraised: 0,
2895                        outcome: Settled::Refuted,
2896                    },
2897                );
2898                remember_dispute(
2899                    state,
2900                    Dispute {
2901                        title: canonical.to_string(),
2902                        file: file.clone(),
2903                        reasoning: reasoning.clone(),
2904                    },
2905                    allow_stable_fallback,
2906                );
2907                forget_noted(state, source, allow_stable_fallback);
2908                refuted.push(format!("{located_title}. {reasoning}"));
2909            }
2910            Action::FiledIssue => {
2911                let new_title = d
2912                    .new_issue_title
2913                    .clone()
2914                    .filter(|t| !t.trim().is_empty())
2915                    .unwrap_or_else(|| d.title.clone());
2916                let new_body = d
2917                    .new_issue_body
2918                    .clone()
2919                    .filter(|b| !b.trim().is_empty())
2920                    .unwrap_or_else(|| d.reasoning.clone());
2921                let recorded = file_followup(repo, &new_title, &new_body, subject, cfg, state);
2922                if let Some(url) = recorded.url() {
2923                    state.filed.push(url.to_string());
2924                    filed.push(url.to_string());
2925                }
2926                // Settled like a refutation, because it ends the same way: the
2927                // code will not change for this point on this branch. Without
2928                // the entry the reviewer keeping the PR raises it again next
2929                // round, the author files a duplicate, and the round budget
2930                // goes on one point nobody disagrees about.
2931                //
2932                // Unless nothing holds the point, in which case there is no
2933                // entry to write: see `filed_entry`.
2934                let Some((outcome, reasoning)) =
2935                    filed_entry(&recorded, &style::summary(&d.reasoning, &repo.style))
2936                else {
2937                    logwarn!(
2938                        "'{title}' was not recorded anywhere, so it stays open for the next round"
2939                    );
2940                    unresolved.push(source.clone());
2941                    continue;
2942                };
2943                settle(
2944                    ledger,
2945                    canonical,
2946                    &file,
2947                    allow_stable_fallback,
2948                    LedgerEntry {
2949                        title: canonical.to_string(),
2950                        file: file.clone(),
2951                        reasoning,
2952                        round,
2953                        reraised: 0,
2954                        outcome,
2955                    },
2956                );
2957                if outcome == Settled::Dropped {
2958                    remember_noted(state, source, allow_stable_fallback);
2959                } else {
2960                    forget_noted(state, source, allow_stable_fallback);
2961                }
2962                forget_dispute(state, source, allow_stable_fallback);
2963            }
2964            Action::Fixed => {
2965                // Recorded like every other disposition, on the reviewer's own
2966                // wording, so a re-raise next round hashes to this entry.
2967                //
2968                // Fixing is what most dispositions are, and it was the one that
2969                // left nothing behind. The next round met the fix as ordinary
2970                // code with no sign anybody had asked for it, and the guard that
2971                // ends an argument had only refutations to match, so across six
2972                // fix rounds on two pull requests it never fired once.
2973                //
2974                // Only when something was actually committed, on the same rule
2975                // `filed_entry` keeps for a follow-up that failed: an entry says
2976                // the point was dealt with and it outlives the run, so writing
2977                // one for a fix that does not exist tells every later pass to
2978                // check code nobody wrote.
2979                if fixed_disposition_resolves(committed) {
2980                    settle(
2981                        ledger,
2982                        canonical,
2983                        &file,
2984                        allow_stable_fallback,
2985                        LedgerEntry {
2986                            title: canonical.to_string(),
2987                            file: file.clone(),
2988                            reasoning: style::summary(&d.reasoning, &repo.style),
2989                            round,
2990                            reraised: 0,
2991                            outcome: Settled::Fixed,
2992                        },
2993                    );
2994                    forget_noted(state, source, allow_stable_fallback);
2995                    forget_dispute(state, source, allow_stable_fallback);
2996                    fixed.push(located_title);
2997                } else {
2998                    unresolved.push(source.clone());
2999                }
3000            }
3001        }
3002    }
3003
3004    if repo.style.pr_comments == PrComments::Rounds {
3005        let comment = disposition_comment(author, response, &fixed, &refuted, &filed, &repo.style);
3006        if let Some(text) = comment {
3007            if let Err(e) = repo.comment_pr(pr_number, &text) {
3008                logdim!("could not post the disposition comment: {e}");
3009            }
3010        }
3011    }
3012    unresolved
3013}
3014
3015/// What the ledger should say about a point the author moved out of this pull
3016/// request, and whether it should say anything at all.
3017///
3018/// Nothing, for a follow-up that failed. An entry tells every later round the
3019/// point was dealt with, and it outlives the run: recording one for a write
3020/// that never happened suppresses a real defect for good, on the strength of a
3021/// transient error.
3022fn filed_entry(recorded: &Followup, reasoning: &str) -> Option<(Settled, String)> {
3023    let (outcome, tail) = match recorded {
3024        Followup::Recorded(reference) => (
3025            Settled::Filed,
3026            format!("Tracked in {}.", as_reference(reference)),
3027        ),
3028        Followup::Covered(reference) => (
3029            Settled::Filed,
3030            format!("Already covered by {}.", as_reference(reference)),
3031        ),
3032        Followup::Dropped(why) => (Settled::Dropped, format!("Not filed anywhere: {why}.")),
3033        Followup::Failed => return None,
3034    };
3035    let reasoning = match reasoning.trim() {
3036        "" => tail,
3037        said => format!("{said} {tail}"),
3038    };
3039    Some((outcome, reasoning))
3040}
3041
3042// ---------------------------------------------------------------------------
3043// Follow-ups
3044// ---------------------------------------------------------------------------
3045
3046// One uncertain external write stops the rest for this process. A later run
3047// performs exact and similarity prechecks before it writes again.
3048fn external_followup_write_paused(destination: Followups, state: &IssueRun) -> bool {
3049    destination == Followups::Issues && state.followup_writes_uncertain
3050}
3051
3052fn failed_followup(state: &mut IssueRun, error: &SparError) -> Followup {
3053    if error.kind() == ErrorKind::UncertainWrite {
3054        state.followup_writes_uncertain = true;
3055        if !state
3056            .notes
3057            .iter()
3058            .any(|note| note.contains("external follow-up writes were paused"))
3059        {
3060            state.notes.push(
3061                "An external follow-up write could not be verified, so further external \
3062                 follow-up writes were paused for this run. Inspect recent issues and comments \
3063                 before trying them again."
3064                    .to_string(),
3065            );
3066        }
3067    }
3068    Followup::Failed
3069}
3070
3071/// Record a finding that is real but out of scope for this PR.
3072///
3073/// On your own repository an issue is the right home. On a large repository
3074/// that is not yours it is somebody else's notification and somebody else's
3075/// triage queue, so `local` keeps the same information in `.spar/followups.md`
3076/// and `none` drops it.
3077///
3078/// The answer says which of those happened, because the caller settles the
3079/// point on it. A failure and a deliberate drop look identical from the outside
3080/// and mean opposite things to the next round.
3081pub fn file_followup(
3082    repo: &Repo,
3083    title: &str,
3084    body: &str,
3085    source: i64,
3086    cfg: &Config,
3087    state: &mut IssueRun,
3088) -> Followup {
3089    if repo.followups == Followups::None {
3090        return Followup::Dropped("follow-ups are off for this repository");
3091    }
3092    if external_followup_write_paused(repo.followups, state) {
3093        logdim!(
3094            "not attempting another external follow-up write after an earlier result could not \
3095             be verified"
3096        );
3097        return Followup::Failed;
3098    }
3099    // A backstop against a run that will not stop finding things. Silent
3100    // truncation is not on offer: what was dropped is said out loud.
3101    if state.filed.len() >= cfg.loop_cfg.max_followups {
3102        logwarn!(
3103            "already recorded {} follow-ups, not recording '{}'. Raise max_followups if you want \
3104             them all.",
3105            state.filed.len(),
3106            style::title(title, &repo.style)
3107        );
3108        return Followup::Dropped("this run had already recorded as many follow-ups as it may");
3109    }
3110    // The exact string that will land on GitHub. Searching for anything else
3111    // means the duplicate check can never hit, and every round files another
3112    // copy of the same follow-up.
3113    //
3114    // A title the style gate cannot clean is a failure rather than a drop: the
3115    // next round words the point differently, and that wording may pass.
3116    let title = match repo.clean_followup_title(title) {
3117        Ok(title) => title,
3118        Err(e) => {
3119            logdim!("could not clean a follow-up title: {e}");
3120            return Followup::Failed;
3121        }
3122    };
3123    if title.trim().is_empty() {
3124        logdim!("nothing left of a follow-up title after cleaning it");
3125        return Followup::Failed;
3126    }
3127    // Not style::body: that is the budget for a pull request comment, read with
3128    // the diff in front of you. This is a work item somebody picks up cold.
3129    let body = format!(
3130        "{}\n\nFound while working on #{source}.",
3131        style::issue_body(body, &repo.style)
3132    );
3133
3134    if repo.followups == Followups::Local {
3135        return repo.append_local_followup(&title, &body);
3136    }
3137
3138    match file_as_issue(repo, &title, &body) {
3139        Ok(filed) => filed.into(),
3140        Err(e) => {
3141            logdim!("could not file a follow-up for '{title}': {e}");
3142            failed_followup(state, &e)
3143        }
3144    }
3145}
3146
3147/// What happened to one finding on the way to the tracker.
3148#[derive(Debug, Clone)]
3149pub enum Filed {
3150    /// A new issue.
3151    Opened(i64, String),
3152    /// An open issue already covered it, and this pass had something to add.
3153    AddedTo(i64, String),
3154    /// An open issue already covered it, and this pass added nothing.
3155    Covered(i64, String),
3156}
3157
3158impl From<Filed> for Followup {
3159    fn from(filed: Filed) -> Self {
3160        match filed {
3161            Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => {
3162                Followup::Recorded(url)
3163            }
3164        }
3165    }
3166}
3167
3168impl Filed {
3169    pub fn url(&self) -> Option<&str> {
3170        match self {
3171            Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => Some(url),
3172        }
3173    }
3174
3175    /// The issue this went to, whatever state it is in. `number` answers the
3176    /// narrower question of what there is to work.
3177    pub fn issue(&self) -> i64 {
3178        match self {
3179            Filed::Opened(n, _) | Filed::AddedTo(n, _) | Filed::Covered(n, _) => *n,
3180        }
3181    }
3182
3183    /// The issue to work, when there is one to work.
3184    pub fn number(&self) -> Option<i64> {
3185        match self {
3186            Filed::Opened(n, _) | Filed::AddedTo(n, _) | Filed::Covered(n, _) => Some(*n),
3187        }
3188    }
3189
3190    /// One clause saying where it went, for a log line or an archive entry.
3191    pub fn note(&self) -> String {
3192        match self {
3193            Filed::Opened(n, _) => format!("#{n}"),
3194            Filed::AddedTo(n, _) => format!("added to #{n}"),
3195            Filed::Covered(n, _) => format!("#{n} already says this"),
3196        }
3197    }
3198
3199    pub fn describe(&self, title: &str) -> String {
3200        let title = style::clip(title.trim(), 80);
3201        match self {
3202            Filed::Opened(n, _) => format!("filed #{n}: {title}"),
3203            Filed::AddedTo(n, _) => format!("added to #{n}: {title}"),
3204            Filed::Covered(n, _) => format!("#{n} already says this: {title}"),
3205        }
3206    }
3207}
3208
3209/// File an issue, or add to the one that already covers it.
3210///
3211/// Exact title matching let duplicates through: two agents, or two runs a week
3212/// apart, never word one defect identically, and a real run filed two that had
3213/// to be closed by hand. Filing a second copy is the complaint; silently
3214/// dropping the new wording is not much better, because a later pass often
3215/// carries evidence the first did not.
3216///
3217/// The title arrives cleaned by the caller, and it has to: searching for
3218/// anything but the exact string that will land on GitHub means the duplicate
3219/// check can never hit.
3220pub fn file_as_issue(repo: &Repo, title: &str, body: &str) -> Result<Filed> {
3221    file_as_issue_apart_from(repo, title, body, None)
3222}
3223
3224/// The same, with one issue this cannot be a duplicate of.
3225///
3226/// A checklist item is quoted in the tracker it was read from, so the tracker
3227/// is the closest match for every item in it. Without this the run would
3228/// comment an item onto its own tracker and call it covered.
3229pub fn file_as_issue_apart_from(
3230    repo: &Repo,
3231    title: &str,
3232    body: &str,
3233    apart_from: Option<i64>,
3234) -> Result<Filed> {
3235    let title = repo.record_failed_write(repo.clean_title(title))?;
3236    if title.trim().is_empty() {
3237        return repo.record_failed_write(Err(spar_err!(
3238            "nothing left of the title after cleaning it"
3239        )));
3240    }
3241    let issue_body = repo.record_failed_write(repo.clean_issue_body(body))?;
3242    let exact =
3243        repo.record_failed_write(repo.try_exact_issue_apart_from(&title, &issue_body, apart_from))?;
3244    let mut body_to_file = issue_body.clone();
3245    if let Some(existing) = exact {
3246        if existing.open {
3247            logdim!("'{title}' is already open as #{}", existing.number);
3248            return Ok(Filed::Covered(existing.number, existing.url));
3249        }
3250        // A closed issue with this exact text is a defect that was fixed and
3251        // has come back, which is the finding most worth keeping. It used to
3252        // leave the run with no record anywhere: no issue, no queue entry, no
3253        // wave, and the ledger recorded the point as filed, so the reviewer
3254        // keeping the pull request was told not to raise it again.
3255        logdim!(
3256            "'{title}' matches #{}, which is closed, so it is being filed again",
3257            existing.number
3258        );
3259        body_to_file = regression_body(&issue_body, existing.number);
3260    } else {
3261        let similar = repo.record_failed_write(repo.try_find_similar_issue_apart_from(
3262            &title,
3263            &issue_body,
3264            apart_from,
3265        ))?;
3266        if let Some(existing) = similar {
3267            let known = format!("{} {}", existing.title, existing.body);
3268            if crate::textsim::adds_information(&issue_body, &known) {
3269                logdim!(
3270                    "'{title}' is covered by #{} and adds something, so it goes there as a \
3271                     comment",
3272                    existing.number
3273                );
3274                repo.comment_issue(existing.number, &issue_body)?;
3275                return Ok(Filed::AddedTo(existing.number, existing.url));
3276            }
3277            logdim!(
3278                "'{title}' is covered by #{} '{}', which already says it",
3279                existing.number,
3280                style::clip(&existing.title, 60)
3281            );
3282            return Ok(Filed::Covered(existing.number, existing.url));
3283        }
3284    }
3285    let url = repo.create_issue_apart_from(&title, &body_to_file, apart_from)?;
3286    let number = filed_issue_number(&url)
3287        .ok_or_else(|| spar_err!("filed an issue but could not read its number from {url}"))?;
3288    Ok(Filed::Opened(number, url))
3289}
3290
3291/// The body for a finding that matches an issue somebody already closed.
3292///
3293/// Filing again rather than reopening: reopening writes on somebody's decision,
3294/// and the reference costs a reader one click to find the history.
3295pub(crate) fn regression_body(body: &str, closed: i64) -> String {
3296    format!(
3297        "{}\n\nThis was filed before as #{closed}, which is closed. The evidence above is from \
3298         a later run, so it looks like it has come back.",
3299        body.trim()
3300    )
3301}
3302
3303fn file_out_of_scope(
3304    repo: &Repo,
3305    findings: &[Finding],
3306    subject: i64,
3307    state: &mut IssueRun,
3308    cfg: &Config,
3309) {
3310    for finding in findings.iter().filter(|f| !f.in_scope) {
3311        let body = issue_report(finding);
3312        let recorded = file_followup(repo, &finding.title, &body, subject, cfg, state);
3313        if finding.severity != Severity::Nit || matches!(recorded, Followup::Recorded(_)) {
3314            record_nonblocking_outcome_with_match(
3315                state,
3316                finding,
3317                Some(&recorded),
3318                unique_stable_finding(findings, finding),
3319            );
3320        }
3321    }
3322}
3323
3324/// A finding written as a bug report, when it carries the parts of one.
3325///
3326/// The thread gets one line; an issue gets the whole thing under headings, in
3327/// the order somebody reads a bug report: what is wrong, how to see it, what it
3328/// costs, what it should do instead. A finding with none of those falls back to
3329/// its detail, which is every finding that was never going to be filed.
3330pub fn issue_report(finding: &Finding) -> String {
3331    let sections = finding.report_sections();
3332    if sections.is_empty() {
3333        return finding.detail.clone();
3334    }
3335    let mut out: Vec<String> = sections
3336        .iter()
3337        .map(|(heading, text)| format!("## {heading}\n\n{text}"))
3338        .collect();
3339    // Keep the one line summary when it says something the sections do not,
3340    // rather than dropping it or repeating it.
3341    if !finding.detail.trim().is_empty()
3342        && !sections
3343            .iter()
3344            .any(|(_, text)| crate::textsim::same_point(text, &finding.detail))
3345    {
3346        out.insert(0, finding.detail.trim().to_string());
3347    }
3348    out.join("\n\n")
3349}
3350
3351/// Non-blocking findings become follow-ups so they do not gate the merge.
3352///
3353/// Nits are excluded by default. On a shared repository a filed nit is somebody
3354/// else's notification and somebody else's triage queue: an early run on a
3355/// production codebase opened an issue titled "Log wording". Worth saying in
3356/// the PR thread, not worth an issue.
3357fn file_nonblocking(
3358    repo: &Repo,
3359    findings: &[Finding],
3360    subject: i64,
3361    state: &mut IssueRun,
3362    cfg: &Config,
3363) {
3364    for finding in findings {
3365        if !finding.in_scope || finding.severity == Severity::Blocking {
3366            continue;
3367        }
3368        let should_file = match finding.severity {
3369            Severity::NonBlocking => cfg.loop_cfg.file_non_blocking,
3370            Severity::Nit => cfg.loop_cfg.file_nits,
3371            Severity::Blocking => false,
3372        };
3373        if !should_file {
3374            if finding.severity == Severity::NonBlocking {
3375                record_nonblocking_outcome_with_match(
3376                    state,
3377                    finding,
3378                    None,
3379                    unique_stable_finding(findings, finding),
3380                );
3381            }
3382            continue;
3383        }
3384        let recorded = file_followup(repo, &finding.title, &finding.detail, subject, cfg, state);
3385        if finding.severity == Severity::NonBlocking {
3386            record_nonblocking_outcome_with_match(
3387                state,
3388                finding,
3389                Some(&recorded),
3390                unique_stable_finding(findings, finding),
3391            );
3392        } else if let Some(url) = recorded.url() {
3393            state.filed.push(url.to_string());
3394        }
3395    }
3396}
3397
3398// ---------------------------------------------------------------------------
3399// What a human actually reads
3400// ---------------------------------------------------------------------------
3401//
3402// spar composes every comment itself from structured fields, rather than
3403// forwarding whatever prose a model produced. That is the only reliable way to
3404// keep a PR thread readable: the model supplies facts, the harness supplies the
3405// shape, and each field is held to a budget on the way out.
3406
3407fn bullets(lines: &[String]) -> String {
3408    lines
3409        .iter()
3410        .map(|l| format!("- {l}"))
3411        .collect::<Vec<_>>()
3412        .join("\n")
3413}
3414
3415fn located(finding: &Finding, style: &Style) -> String {
3416    let title = style::title(&finding.title, style);
3417    match finding.where_at() {
3418        "general" => title,
3419        file => format!("{title} ({file})"),
3420    }
3421}
3422
3423/// How the run ended, which is the only thing about the run a reader needs.
3424pub enum Ending<'a> {
3425    /// Nothing blocks a merge.
3426    Approved,
3427    /// The closing pass could not run, so the last round's fixes were pushed and
3428    /// nothing has read them, which is the part a maintainer has to know.
3429    OutOfRounds,
3430    /// The budget ran out on a branch the last round did not change. Nothing is
3431    /// unread, and nothing cleared the points that were raised either.
3432    Unchanged,
3433    /// The closing pass read what the last round left and did not sign it off.
3434    /// Nothing more will be fixed here, so what is left is a person's to weigh.
3435    Unresolved(&'a [Finding]),
3436    /// A point that ran out of tries: refuted and raised again anyway, or fixed
3437    /// twice and raised again. Nobody is going to break the tie but a person.
3438    Deadlocked(&'a [Finding]),
3439}
3440
3441#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3442enum OutcomeSink {
3443    PullRequest,
3444    Terminal,
3445}
3446
3447fn outcome_sink(mode: PrComments) -> OutcomeSink {
3448    match mode {
3449        PrComments::Outcome | PrComments::Rounds => OutcomeSink::PullRequest,
3450        PrComments::None => OutcomeSink::Terminal,
3451    }
3452}
3453
3454fn emit_outcome(repo: &Repo, pr_number: i64, text: &str) {
3455    if outcome_sink(repo.style.pr_comments) == OutcomeSink::Terminal {
3456        println!("\n{text}\n");
3457        return;
3458    }
3459    if let Err(e) = repo.comment_pr(pr_number, text) {
3460        logdim!("could not post the outcome comment: {e}");
3461        println!("\n{text}\n");
3462    }
3463}
3464
3465/// Post the one comment a run leaves behind, if it has anything to say.
3466///
3467/// Everything spar used to write here was an account of its own working: which
3468/// agent spoke, which round it was, how many findings of each severity, that it
3469/// had stopped. None of that is about the code. Worse, the running commentary
3470/// could contradict itself, ending a thread with "5 fixed" immediately followed
3471/// by "no convergence", which reads as a failure rather than as fixes nobody
3472/// has checked yet.
3473///
3474/// So the loop is silent and this says what is left: what is unresolved, what
3475/// was argued down, and where the follow-ups went.
3476pub fn post_outcome(
3477    repo: &Repo,
3478    pr_number: i64,
3479    state: &IssueRun,
3480    ledger: &Ledger,
3481    ending: Ending<'_>,
3482) {
3483    let Some(text) = outcome_comment(state, ledger, &ending, &repo.style) else {
3484        return;
3485    };
3486    emit_outcome(repo, pr_number, &text);
3487}
3488
3489fn post_unread_outcome(
3490    repo: &Repo,
3491    pr_number: i64,
3492    state: &IssueRun,
3493    ledger: &Ledger,
3494    open_findings: &[Finding],
3495) {
3496    let Some(text) = outcome_comment_with_unread(
3497        state,
3498        ledger,
3499        &Ending::OutOfRounds,
3500        open_findings,
3501        &repo.style,
3502    ) else {
3503        return;
3504    };
3505    emit_outcome(repo, pr_number, &text);
3506}
3507
3508/// How a point was settled and why: this run's disputes first, then the ledger,
3509/// which is what survives across a resume.
3510fn settled_as(finding: &Finding, state: &IssueRun, ledger: &Ledger) -> Option<(Settled, String)> {
3511    if let Some(d) = state
3512        .disputes
3513        .iter()
3514        .find(|d| same_finding_parts(&d.title, &d.file, &finding.title, &finding.file))
3515    {
3516        if !d.reasoning.trim().is_empty() {
3517            return Some((Settled::Refuted, d.reasoning.clone()));
3518        }
3519    }
3520    matching_ledger_entry(ledger, &finding.title, &finding.file)
3521        .filter(|entry| !entry.reasoning.trim().is_empty())
3522        .map(|entry| (entry.outcome, entry.reasoning.clone()))
3523}
3524
3525/// `#123` from a filed issue URL, falling back to the URL when it does not look
3526/// like one. Shorter, and GitHub renders it as a link either way.
3527/// The issue number a filed follow-up URL points at, when it is one. Local
3528/// notes and anything unparseable yield nothing.
3529pub fn filed_issue_number(filed: &str) -> Option<i64> {
3530    filed
3531        .rsplit('/')
3532        .next()
3533        .and_then(|tail| tail.parse::<i64>().ok())
3534        .filter(|n| *n > 0)
3535}
3536
3537fn as_reference(url: &str) -> String {
3538    match url.rsplit('/').next().and_then(|n| n.parse::<u64>().ok()) {
3539        Some(number) => format!("#{number}"),
3540        None => url.to_string(),
3541    }
3542}
3543
3544pub fn outcome_comment(
3545    state: &IssueRun,
3546    ledger: &Ledger,
3547    ending: &Ending<'_>,
3548    style: &Style,
3549) -> Option<String> {
3550    outcome_comment_with_unread(state, ledger, ending, &[], style)
3551}
3552
3553fn outcome_comment_with_unread(
3554    state: &IssueRun,
3555    ledger: &Ledger,
3556    ending: &Ending<'_>,
3557    unread_open: &[Finding],
3558    style: &Style,
3559) -> Option<String> {
3560    let mut out: Vec<String> = Vec::new();
3561    // Points rendered in the deadlock block, so the refutation list below does
3562    // not print the same title a second time.
3563    let mut already: Vec<(String, String)> = Vec::new();
3564
3565    match ending {
3566        Ending::Approved => {
3567            let criteria = criteria_rulings(state, style);
3568            if state.disputes.is_empty()
3569                && state.filed.is_empty()
3570                && state.noted.is_empty()
3571                && criteria.is_none()
3572            {
3573                // A clean approval with nothing outstanding needs no comment.
3574                // The absence of objections is the message. `noted` is in that
3575                // condition because the message has to be true: a reviewer that
3576                // found six real problems and gated on none of them did not find
3577                // nothing, and silence would say it did. So is a criterion not
3578                // ruled met, for the same reason.
3579                return None;
3580            }
3581            out.push("Reviewed, nothing blocking a merge.".into());
3582            out.extend(criteria);
3583        }
3584        Ending::OutOfRounds => {
3585            out.push(
3586                "Not signed off: the last round of fixes was pushed but has not been reviewed."
3587                    .into(),
3588            );
3589            if !unread_open.is_empty() {
3590                let lines: Vec<String> = unread_open
3591                    .iter()
3592                    .map(|finding| {
3593                        already.push((finding.title.clone(), finding.file.clone()));
3594                        format!(
3595                            "{}. {}",
3596                            located(finding, style),
3597                            style::sentence(&finding.detail, style)
3598                        )
3599                    })
3600                    .collect();
3601                out.push("These points were already open:".into());
3602                out.push(bullets(&lines));
3603            }
3604        }
3605        // Deliberately not the sentence above. Nothing was pushed on this path,
3606        // and telling a maintainer to go and read a commit that does not exist
3607        // is worse than saying nothing.
3608        Ending::Unchanged => out.push(
3609            "Not signed off: the last round changed nothing, so the branch is the one that was \
3610             already reviewed."
3611                .into(),
3612        ),
3613        Ending::Unresolved(points) => {
3614            let lines: Vec<String> = points
3615                .iter()
3616                .map(|f| {
3617                    already.push((f.title.clone(), f.file.clone()));
3618                    format!(
3619                        "{}. {}",
3620                        located(f, style),
3621                        style::sentence(&f.detail, style)
3622                    )
3623                })
3624                .collect();
3625            out.push("Not signed off. These points are still open:".into());
3626            out.push(bullets(&lines));
3627        }
3628        Ending::Deadlocked(points) => {
3629            // Rendered once, with the argument attached. A deadlocked point is
3630            // by definition one that was settled earlier, so the reasoning is
3631            // the whole reason a person is being asked to look. On a resumed
3632            // run `state.disputes` is empty (only `filed` is restored), so the
3633            // ledger is the only place that argument survives.
3634            let lines: Vec<String> = points
3635                .iter()
3636                .map(|f| {
3637                    let where_at = match f.where_at() {
3638                        "general" => String::new(),
3639                        file => format!(" ({file})"),
3640                    };
3641                    let title = style::title(&f.title, style);
3642                    already.push((f.title.clone(), f.file.clone()));
3643                    match settled_as(f, state, ledger) {
3644                        Some((Settled::Refuted, reason)) => format!(
3645                            "{title}{where_at}. Refuted as: {}",
3646                            style::summary(&reason, style)
3647                        ),
3648                        Some((Settled::Filed, reason)) => format!(
3649                            "{title}{where_at}. Filed as out of scope: {}",
3650                            style::summary(&reason, style)
3651                        ),
3652                        // Never "filed": nothing holds this point but the
3653                        // comment you are reading.
3654                        Some((Settled::Dropped, reason)) => format!(
3655                            "{title}{where_at}. Out of scope here, and not filed: {}",
3656                            style::summary(&reason, style)
3657                        ),
3658                        // Not a refutation, so it must not read as one. Nobody
3659                        // argued this point down: it was fixed, raised again,
3660                        // fixed again, and raised again, and what a person has
3661                        // to weigh is a fix that keeps missing rather than an
3662                        // argument neither agent would give up.
3663                        Some((Settled::Fixed, reason)) => format!(
3664                            "{title}{where_at}. Fixed and raised again. Recorded answer: {}",
3665                            style::summary(&reason, style)
3666                        ),
3667                        None => format!("{title}{where_at}"),
3668                    }
3669                })
3670                .collect();
3671            out.push("Needs your decision. The reviewers could not settle this:".into());
3672            out.push(bullets(&lines));
3673        }
3674    }
3675
3676    let disputes: Vec<&crate::model::Dispute> = state
3677        .disputes
3678        .iter()
3679        .filter(|d| {
3680            !already
3681                .iter()
3682                .any(|(title, file)| same_finding_parts(title, file, &d.title, &d.file))
3683        })
3684        .collect();
3685    if !disputes.is_empty() {
3686        // The one thing invisible anywhere else. The diff shows what was fixed;
3687        // nothing shows what was argued down, or why.
3688        let lines: Vec<String> = disputes
3689            .iter()
3690            .map(|d| {
3691                let title = style::title(&d.title, style);
3692                let title = if d.file.trim().is_empty() {
3693                    title
3694                } else {
3695                    format!("{title} ({})", d.file.trim())
3696                };
3697                format!("{}. {}", title, style::sentence(&d.reasoning, style))
3698            })
3699            .collect();
3700        out.push(format!("Raised and refuted:\n{}", bullets(&lines)));
3701    }
3702
3703    if !state.noted.is_empty() {
3704        // The only place a downgraded point survives. The diff shows what was
3705        // fixed and the refutation list shows what was argued down; a finding
3706        // the reviewer judged real and chose not to gate on had nothing.
3707        let lines: Vec<String> = state
3708            .noted
3709            .iter()
3710            .filter(|f| {
3711                !already
3712                    .iter()
3713                    .any(|(title, file)| same_finding_parts(title, file, &f.title, &f.file))
3714            })
3715            .map(|f| located(f, style))
3716            .collect();
3717        if !lines.is_empty() {
3718            out.push(format!("Noted, not blocking:\n{}", bullets(&lines)));
3719        }
3720    }
3721
3722    if !state.filed.is_empty() {
3723        let refs: Vec<String> = state.filed.iter().map(|u| as_reference(u)).collect();
3724        out.push(format!("Filed separately: {}", refs.join(", ")));
3725    }
3726
3727    Some(out.join("\n\n"))
3728}
3729
3730/// The signing review's rulings on the acceptance criteria, for the outcome
3731/// comment.
3732///
3733/// When met criteria are about to be ticked, the issue records those, so only
3734/// the rest are named. When nothing will be ticked, this comment is the only
3735/// record of any ruling, so every one is.
3736fn criteria_rulings(state: &IssueRun, style: &Style) -> Option<String> {
3737    let shown: Vec<String> = state
3738        .criteria
3739        .iter()
3740        .filter(|c| !state.ticks_criteria || c.status != Some(Mark::Met))
3741        .map(|c| {
3742            let label = match c.status {
3743                Some(Mark::Met) => "Met",
3744                Some(Mark::NotMet) => "Not met",
3745                Some(Mark::NeedsPerson) => "Needs a person",
3746                None => "Not ruled on",
3747            };
3748            criterion_line(label, &c.text, Some(&c.reason), style)
3749        })
3750        .collect();
3751    if shown.is_empty() {
3752        return None;
3753    }
3754    let lead = if state.ticks_criteria {
3755        "Acceptance criteria not ruled met, left unchecked on the issue:"
3756    } else {
3757        "Acceptance criteria as the review ruled them. Nothing was ticked on the issue:"
3758    };
3759    Some(format!("{lead}\n{}", bullets(&shown)))
3760}
3761
3762/// What the closing pass is asked, with the last delta called out inside the
3763/// full merge-safety audit.
3764///
3765/// `landed` is `None` when the harness cannot say what is new. Commit messages
3766/// are rewritten when they break the style rules, which moves every hash from
3767/// the first offender onward, so a head recorded before a round can stop being
3768/// on the branch. Saying that plainly is the only honest option: the alternative
3769/// is `git log` reporting the whole branch as newly landed.
3770#[allow(clippy::too_many_arguments)]
3771fn close_prompt(
3772    base: &str,
3773    number: i64,
3774    title: &str,
3775    context: &str,
3776    from: &str,
3777    landed: Option<&[String]>,
3778    ledger: &Ledger,
3779    open_findings: &[Finding],
3780    round: u32,
3781    criteria: &[Item],
3782) -> String {
3783    let landed = match landed {
3784        Some([]) => "\nNothing landed after the last round of review. What it asked for was \
3785                     answered in words rather than in code, so the branch in front of you is the \
3786                     branch that was already read.\n"
3787            .to_string(),
3788        Some(lines) => format!(
3789            "\nThis landed after the last round of review, and nobody has read it:\n{}\n\nRead it \
3790             first with `git diff {from}..HEAD`, then inspect the full branch with `git diff \
3791             {base}...HEAD`.\n",
3792            lines
3793                .iter()
3794                .map(|l| format!("- {l}"))
3795                .collect::<Vec<_>>()
3796                .join("\n")
3797        ),
3798        None => format!(
3799            "\nThe commits on this branch were rewritten after the last round of review, so the \
3800             harness cannot say which of them are new. Inspect the full branch with `git diff \
3801             {base}...HEAD`.\n"
3802        ),
3803    };
3804    fill(
3805        CLOSE_PROMPT,
3806        &[
3807            ("{number}", &number.to_string()),
3808            ("{title}", title),
3809            ("{context}", context),
3810            ("{criteria}", &criteria_to_rule(criteria)),
3811            ("{base}", base),
3812            ("{landed}", &landed),
3813            ("{open}", &open_findings_block(open_findings)),
3814            ("{answers}", &closing_answers(ledger, round)),
3815            ("{settled}", &settled_block(ledger)),
3816        ],
3817    )
3818}
3819
3820fn open_findings_block(findings: &[Finding]) -> String {
3821    if findings.is_empty() {
3822        return String::new();
3823    }
3824    format!(
3825        "\nThese blocking findings were left open by an earlier response. Recheck each one:\n{}\n\
3826         \nIf one still blocks, return it under the same title and file. Omission means you checked \
3827         it and found that it no longer blocks.\n",
3828        findings_for_prompt(findings)
3829    )
3830}
3831
3832/// The claimed fixes, as the closing pass is told about them.
3833///
3834/// The same points `answers_block` gives a round, asked as the thing this pass
3835/// is for rather than as context for a wider read.
3836fn closing_answers(ledger: &Ledger, round: u32) -> String {
3837    let lines = fixed_lines(ledger, round);
3838    if lines.is_empty() {
3839        return String::new();
3840    }
3841    format!(
3842        "\nThese points were raised on this pull request and the author says it fixed them. \
3843         Nobody has checked that:\n{}\n",
3844        lines.join("\n")
3845    )
3846}
3847
3848/// What the reviewer is asked, with what it already answered behind it.
3849///
3850/// Built here rather than inline in the loop, because a prompt built inline is a
3851/// prompt with no test.
3852#[allow(clippy::too_many_arguments)]
3853fn review_prompt(
3854    base: &str,
3855    number: i64,
3856    title: &str,
3857    context: &str,
3858    ledger: &Ledger,
3859    open_findings: &[Finding],
3860    round: u32,
3861    last: u32,
3862    criteria: &[Item],
3863) -> String {
3864    fill(
3865        REVIEW_PROMPT,
3866        &[
3867            ("{base}", base),
3868            ("{number}", &number.to_string()),
3869            ("{title}", title),
3870            ("{context}", context),
3871            ("{criteria}", &criteria_to_rule(criteria)),
3872            ("{open}", &open_findings_block(open_findings)),
3873            ("{answers}", &answers_block(ledger, round)),
3874            ("{settled}", &settled_block(ledger)),
3875            ("{round}", &round_note(round, last)),
3876        ],
3877    )
3878}
3879
3880/// What the author is asked about the blocking findings, with the non-blocking
3881/// ones offered beside them.
3882fn respond_prompt(
3883    number: i64,
3884    context: &str,
3885    blocking: &[Finding],
3886    findings: &[Finding],
3887) -> String {
3888    fill(
3889        RESPOND_PROMPT,
3890        &[
3891            ("{number}", &number.to_string()),
3892            ("{context}", context),
3893            ("{findings}", &findings_for_prompt(blocking)),
3894            ("{optional}", &optional_block(findings)),
3895        ],
3896    )
3897}
3898
3899/// What the implementor is asked, with the issue in front of it.
3900///
3901/// The body and its comments are passed rather than only the link, because one
3902/// of the two agents cannot follow a link: codex runs under `-s workspace-write`,
3903/// which has no network at all, so a URL alone would leave it judging the title.
3904/// And an agent that can follow it has no reason to when the body reads as
3905/// complete, which is exactly when a comment that superseded it matters most.
3906fn implement_prompt(number: i64, title: &str, url: &str, body: &str, criteria: &[Item]) -> String {
3907    let criteria = if criteria.is_empty() {
3908        String::new()
3909    } else {
3910        format!(
3911            "\n\nThe issue's acceptance criteria, numbered:\n{}\n\nReport a status on each one \
3912             in criteria, by its number: done, not_done, or needs_person for anything the \
3913             repository cannot show, such as a run on a device. Give one line of reason. This is \
3914             your account and goes in the pull request body as yours. Only the reviewer's ruling \
3915             ticks a box on the issue.",
3916            numbered(criteria)
3917        )
3918    };
3919    fill(
3920        IMPLEMENT_PROMPT,
3921        &[
3922            ("{thread_rule}", THREAD_RULE),
3923            ("{number}", &number.to_string()),
3924            ("{title}", title),
3925            ("{url}", url),
3926            ("{criteria}", &criteria),
3927            ("{body}", body),
3928        ],
3929    )
3930}
3931
3932/// The pull request body.
3933///
3934/// What it closes, then the change in one sentence, then what was wrong, then
3935/// only the sections that have something in them. The lead is two paragraphs
3936/// rather than two headings: a heading over a single sentence is a label on a
3937/// label, and those two parts are the ones every body has.
3938///
3939/// GitHub renders the file count and the plus and minus figures immediately
3940/// above this, so neither appears here.
3941pub fn pr_body(
3942    issue: i64,
3943    work: &Implementation,
3944    checked: Option<&str>,
3945    criteria: &[Item],
3946    style: &Style,
3947) -> String {
3948    let mut parts = vec![format!("Closes #{issue}")];
3949
3950    for lead in [&work.summary, &work.problem] {
3951        let text = style::sentence(lead, style);
3952        if !text.is_empty() {
3953            parts.push(text);
3954        }
3955    }
3956    parts.extend(section("What changed", &work.changes, style));
3957    // The testing list is the author's claim. This line is spar's, and it is
3958    // the only one in the body that anybody else ran.
3959    let mut testing = work.testing.clone();
3960    if let Some(command) = checked {
3961        testing.push(format!("`{command}` passed on this branch, run by spar."));
3962    }
3963    parts.extend(section("How to test", &testing, style));
3964    parts.extend(claimed_criteria(criteria, &work.criteria, style));
3965
3966    let notes = style::sentence(work.notes.as_deref().unwrap_or_default(), style);
3967    if !notes.is_empty() {
3968        parts.push(format!("## Notes\n\n{notes}"));
3969    }
3970
3971    style::body(&parts.join("\n\n"), style)
3972}
3973
3974/// The author's status on each acceptance criterion, said to be the author's.
3975///
3976/// A reviewer reads the body as a claim already, and a person reading the pull
3977/// request has to be told this is not what ticks a box.
3978fn claimed_criteria(
3979    criteria: &[Item],
3980    claims: &[CriterionStatus],
3981    style: &Style,
3982) -> Option<String> {
3983    if criteria.is_empty() {
3984        return None;
3985    }
3986    let lines: Vec<String> = criteria
3987        .iter()
3988        .enumerate()
3989        .map(|(at, item)| {
3990            let status = status_for(claims, at + 1);
3991            let label = match status.map(|s| s.status) {
3992                Some(Mark::Met) => "Done",
3993                Some(Mark::NotMet) => "Not done",
3994                Some(Mark::NeedsPerson) => "Needs a person",
3995                None => "No status given",
3996            };
3997            criterion_line(label, &item.text, status.map(|s| s.reason.as_str()), style)
3998        })
3999        .collect();
4000    Some(format!(
4001        "## Acceptance criteria\n\nThe author's account. A box on the issue is ticked only on \
4002         the review's ruling, after spar merges.\n\n{}",
4003        bullets(&lines)
4004    ))
4005}
4006
4007/// `Label: criterion. Reason.` on one line.
4008fn criterion_line(label: &str, text: &str, reason: Option<&str>, style: &Style) -> String {
4009    let stop = |text: String| {
4010        if text.is_empty() || text.ends_with(['.', '!', '?']) {
4011            text
4012        } else {
4013            text + "."
4014        }
4015    };
4016    let mut line = format!("{label}: {}", stop(style::one_line(text.trim())));
4017    let reason = stop(style::sentence(reason.unwrap_or_default(), style));
4018    if !reason.is_empty() {
4019        line.push(' ');
4020        line.push_str(&reason);
4021    }
4022    line
4023}
4024
4025/// The criteria, numbered from one, each as the issue words it.
4026fn numbered(criteria: &[Item]) -> String {
4027    criteria
4028        .iter()
4029        .enumerate()
4030        .map(|(at, item)| format!("{}. {}", at + 1, item.text.trim()))
4031        .collect::<Vec<_>>()
4032        .join("\n")
4033}
4034
4035/// What a review or the closing pass is asked about the criteria, or nothing
4036/// when the issue has none.
4037fn criteria_to_rule(criteria: &[Item]) -> String {
4038    if criteria.is_empty() {
4039        return String::new();
4040    }
4041    format!(
4042        "\nThe issue's acceptance criteria, numbered:\n{}\n\nRule on each one in criteria, by its \
4043         number: met when you checked that the branch does it, not_met when it does not or you \
4044         could not confirm it, needs_person when the repository cannot show it, such as a run on \
4045         a device. Say what you checked. A criterion ruled met is ticked on the issue when spar \
4046         merges this pull request, so do not rule met on the author's word. A ruling is not a \
4047         finding: an unmet criterion holds nothing unless it is also a defect you would block \
4048         for.\n",
4049        numbered(criteria)
4050    )
4051}
4052
4053/// The schema a review or the closing pass answers, asking for rulings only
4054/// when there is something to rule on.
4055fn review_schema(criteria: &[Item]) -> serde_json::Value {
4056    if criteria.is_empty() {
4057        schema::review()
4058    } else {
4059        schema::with_criteria(schema::review(), schema::CriteriaVoice::Reviewer)
4060    }
4061}
4062
4063/// One agent's word on criterion `number`, matched by number. A criterion
4064/// answered twice counts as met only when every answer says so, since a tick
4065/// is the one outcome here that is hard to take back.
4066fn status_for(statuses: &[CriterionStatus], number: usize) -> Option<&CriterionStatus> {
4067    let answers: Vec<&CriterionStatus> = statuses
4068        .iter()
4069        .filter(|s| s.number as usize == number)
4070        .collect();
4071    answers
4072        .iter()
4073        .find(|s| s.status != Mark::Met)
4074        .or_else(|| answers.first())
4075        .copied()
4076}
4077
4078/// The signing review's ruling on each criterion, with the line it is about.
4079fn ruled(criteria: &[Item], rulings: &[CriterionStatus]) -> Vec<Ruled> {
4080    criteria
4081        .iter()
4082        .enumerate()
4083        .map(|(at, item)| {
4084            let status = status_for(rulings, at + 1);
4085            Ruled {
4086                raw: item.raw.clone(),
4087                line: item.line,
4088                text: item.text.trim().to_string(),
4089                status: status.map(|s| s.status),
4090                reason: status.map(|s| s.reason.clone()).unwrap_or_default(),
4091            }
4092        })
4093        .collect()
4094}
4095
4096/// Tick each criterion the signing review ruled met, once the work has landed.
4097///
4098/// Each write re-reads the body and goes through the tracker's line surgery,
4099/// so a line that changed or now appears twice is skipped with a log line, a
4100/// box is never unchecked, and every other byte of the body comes through.
4101fn tick_met(repo: &Repo, issue: i64, criteria: &[Ruled]) -> usize {
4102    let mut ticked = 0;
4103    for criterion in criteria.iter().filter(|c| c.status == Some(Mark::Met)) {
4104        let at = Some(criterion.line);
4105        if tracker::write(repo, issue, &criterion.raw, &tracker::Change::Tick, at) {
4106            ticked += 1;
4107        }
4108    }
4109    ticked
4110}
4111
4112/// A headed list, or nothing at all when there is nothing to list.
4113///
4114/// Nothing at all on purpose. A heading with an empty body under it reads as a
4115/// section somebody forgot to write, which is worse than the absence, and a
4116/// small change that needs no change list should not be made to look like one
4117/// that is missing its.
4118fn section(heading: &str, lines: &[String], style: &Style) -> Option<String> {
4119    let items: Vec<String> = lines
4120        .iter()
4121        .map(|line| style::summary(line, style))
4122        .filter(|line| !line.is_empty())
4123        .collect();
4124    if items.is_empty() {
4125        return None;
4126    }
4127    Some(format!("## {heading}\n\n{}", bullets(&items)))
4128}
4129
4130/// A pull request body for work whose author never got to describe it.
4131///
4132/// The implement call failed after the commits were made, so what those commits
4133/// say about themselves is the only account of them there is. It is a poor one,
4134/// and better than an empty body over work nobody would otherwise know was
4135/// there; the note says as much, so a reviewer does not read the list as the
4136/// author's own summary.
4137pub fn from_commits(repo: &Repo, work_dir: &Path, base: &str) -> Implementation {
4138    Implementation {
4139        changes: repo.commit_subjects(work_dir, "HEAD", base),
4140        notes: Some(
4141            "The implement call failed after these commits were made, so this body is assembled \
4142             from their messages rather than written by their author. Read the diff."
4143                .to_string(),
4144        ),
4145        ..Implementation::default()
4146    }
4147}
4148
4149/// What gets posted on an issue that produced no pull request.
4150///
4151/// The agent's own reason when it gave one, since that is the part written for
4152/// the person who opened the issue. Never the summary: an issue that produced
4153/// no commits has no change for a summary to describe, and one that claims
4154/// otherwise is worse than a flat sentence saying nothing happened.
4155fn no_pr_note(work: &Implementation, style: &Style) -> String {
4156    let reason = style::sentence(&work.reason, style);
4157    if !reason.is_empty() {
4158        return reason;
4159    }
4160    if work.not_worth_doing {
4161        "Left alone after reading the code, with no reason given.".to_string()
4162    } else {
4163        "Nothing was committed, so there is nothing to review.".to_string()
4164    }
4165}
4166
4167/// One review, as a reviewer would write it if they were in a hurry: a count
4168/// line, a sentence, and one bullet per finding. Only blocking findings carry
4169/// their detail, because only those are something the author has to act on now.
4170pub fn review_comment(holder: &str, round: u32, review: &Review, style: &Style) -> String {
4171    let by = |severity: Severity| -> Vec<&Finding> {
4172        review
4173            .findings
4174            .iter()
4175            .filter(|f| f.severity == severity && f.in_scope)
4176            .collect()
4177    };
4178    let blocking = by(Severity::Blocking);
4179    let non_blocking = by(Severity::NonBlocking);
4180    let nits = by(Severity::Nit);
4181    let out_of_scope: Vec<&Finding> = review.findings.iter().filter(|f| !f.in_scope).collect();
4182
4183    let mut counts = Vec::new();
4184    if !blocking.is_empty() {
4185        counts.push(format!("{} blocking", blocking.len()));
4186    }
4187    if !non_blocking.is_empty() {
4188        counts.push(format!("{} non-blocking", non_blocking.len()));
4189    }
4190    if !nits.is_empty() {
4191        counts.push(format!("{} nit", nits.len()));
4192    }
4193    if !out_of_scope.is_empty() {
4194        counts.push(format!("{} out of scope", out_of_scope.len()));
4195    }
4196    let headline = if counts.is_empty() {
4197        "no findings".to_string()
4198    } else {
4199        counts.join(", ")
4200    };
4201
4202    let _ = (holder, round, headline);
4203    let mut out = Vec::new();
4204    let summary = style::summary(&review.summary, style);
4205    if !summary.is_empty() {
4206        out.push(summary);
4207    }
4208
4209    if !blocking.is_empty() {
4210        let lines: Vec<String> = blocking
4211            .iter()
4212            .map(|f| {
4213                let detail = style::detail(&f.detail, style);
4214                if detail.is_empty() {
4215                    located(f, style)
4216                } else {
4217                    format!("{}. {detail}", located(f, style))
4218                }
4219            })
4220            .collect();
4221        out.push(format!("blocking\n{}", bullets(&lines)));
4222    }
4223
4224    // Everything below is filed as a follow-up, so the thread only needs the
4225    // title: the detail lives on the issue where it can be acted on.
4226    for (label, group) in [
4227        ("non-blocking", &non_blocking),
4228        ("nits", &nits),
4229        ("out of scope", &out_of_scope),
4230    ] {
4231        if group.is_empty() {
4232            continue;
4233        }
4234        let lines: Vec<String> = group.iter().map(|f| located(f, style)).collect();
4235        out.push(format!("{label}\n{}", bullets(&lines)));
4236    }
4237
4238    out.join("\n\n")
4239}
4240
4241/// One response to a review. Refutations carry their reasoning because that is
4242/// the whole argument; fixes are a list of titles because the diff says the
4243/// rest.
4244pub fn disposition_comment(
4245    author: &str,
4246    response: &ResponseDoc,
4247    fixed: &[String],
4248    refuted: &[String],
4249    filed: &[String],
4250    style: &Style,
4251) -> Option<String> {
4252    if fixed.is_empty() && refuted.is_empty() && filed.is_empty() {
4253        return None;
4254    }
4255    let mut counts = Vec::new();
4256    if !fixed.is_empty() {
4257        counts.push(format!("{} fixed", fixed.len()));
4258    }
4259    if !refuted.is_empty() {
4260        counts.push(format!("{} refuted", refuted.len()));
4261    }
4262    if !filed.is_empty() {
4263        counts.push(format!("{} filed", filed.len()));
4264    }
4265
4266    let _ = (author, counts);
4267    let mut out = Vec::new();
4268    let summary = style::summary(&response.summary, style);
4269    if !summary.is_empty() {
4270        out.push(summary);
4271    }
4272    if !refuted.is_empty() {
4273        out.push(format!("refuted\n{}", bullets(refuted)));
4274    }
4275    if !fixed.is_empty() {
4276        out.push(format!("fixed\n{}", bullets(fixed)));
4277    }
4278    if !filed.is_empty() {
4279        out.push(format!("filed\n{}", bullets(filed)));
4280    }
4281    Some(out.join("\n\n"))
4282}
4283
4284/// What is posted on an issue both agents declined.
4285/// What is posted on an issue both reviewers declined.
4286///
4287/// Just the reasons. GitHub already shows that it was closed as not planned,
4288/// and which model held which opinion is a fact about the run rather than about
4289/// the issue. Duplicates are collapsed, since two reviewers reaching the same
4290/// conclusion often reach it in the same words.
4291pub fn skip_comment(item: &SkippedItem, style: &Style) -> String {
4292    let reasons = item
4293        .reasons
4294        .values()
4295        .map(|reason| style::sentence(reason, style));
4296    // Two reviewers declining one issue almost always decline it for the same
4297    // reason, worded differently. On the run that prompted this, both cited the
4298    // issue it duplicated and the reader saw the point twice.
4299    let lines = crate::textsim::dedupe_by(reasons, crate::textsim::same_reason);
4300    bullets(&lines)
4301}
4302
4303/// What the branch was for, and what its author says it does.
4304///
4305/// Every call is a fresh process and nothing carries between them but the
4306/// branch and the prompt, so a reviewer asked to judge "whether the change
4307/// actually resolves the issue" against a title alone cannot answer the
4308/// question. The most valuable kind of blocking finding is "the issue asked for
4309/// X and the branch does not do X", and it needs the issue. The author's
4310/// testing notes are the cheapest way for a reviewer to check a fix, and they
4311/// are in the description.
4312///
4313/// A link is not a substitute: codex runs under `-s workspace-write`, which has
4314/// no network, which is the reason the implement prompt carries the body rather
4315/// than a link. The same reason applies here, and to the comments under the
4316/// issue: a reviewer judging against a body that a later comment superseded
4317/// passes a change that does what was first asked and not what is asked now.
4318///
4319/// `issue_text` is the body and its comments as `issue_for_prompt` renders them.
4320pub(crate) fn context_block(number: i64, url: &str, issue_text: &str, pr_body: &str) -> String {
4321    let mut out = String::new();
4322    let body = issue_text.trim();
4323    if !body.is_empty() {
4324        out.push_str(&format!("\nIssue #{number} as filed"));
4325        if !url.trim().is_empty() {
4326            out.push_str(&format!(", at {}", url.trim()));
4327        }
4328        out.push_str(&format!(
4329            ":\n\n{body}\n\nThat is the issue as filed, followed by any comments on it since, \
4330             oldest first. {}\n",
4331            THREAD_RULE.replace('\n', " ")
4332        ));
4333    }
4334    let claim = pr_body.trim();
4335    if !claim.is_empty() {
4336        out.push_str(&format!(
4337            "\nThe pull request description, which is the author's own account of this change. \
4338             It is a claim to check against the code, not a statement of fact:\n\n{claim}\n"
4339        ));
4340    }
4341    out
4342}
4343
4344/// The non-blocking findings a round can offer the author, if there are any.
4345pub(crate) fn optional_findings(findings: &[Finding]) -> Vec<Finding> {
4346    findings
4347        .iter()
4348        .filter(|f| f.in_scope && f.severity == Severity::NonBlocking)
4349        .cloned()
4350        .collect()
4351}
4352
4353fn optional_block(findings: &[Finding]) -> String {
4354    let optional = optional_findings(findings);
4355    if optional.is_empty() {
4356        return String::new();
4357    }
4358    format!("\n{OPTIONAL_HEADER}{}\n", findings_for_prompt(&optional))
4359}
4360
4361/// Act on what the author said about the optional findings.
4362///
4363/// Nothing here touches the ledger, the round budget, or the merge gate: these
4364/// points never gated anything and they still do not. A fix takes the point off
4365/// the list a person is handed at the end, since it is answered; a decline or a
4366/// refutation leaves it exactly where it was.
4367/// Say what neither pass could place, once both have had their turn.
4368///
4369/// This used to be the tail of `apply_dispositions`, which matches only the
4370/// blocking findings. The author is handed the non-blocking ones too, under a
4371/// header that asks it to fix the small ones and decline the rest with a
4372/// reason, so every optional answer it gave as instructed was announced as
4373/// unmatched and then quietly handled by `apply_optional_dispositions` on the
4374/// next line. The warning was reporting spar's own reading order.
4375///
4376/// What is left after both passes is worth saying: an answer to a point nobody
4377/// raised, or a second answer to one already settled.
4378fn warn_unplaced_dispositions(response: &ResponseDoc, used: &[bool]) {
4379    for (index, disposition) in response.dispositions.iter().enumerate() {
4380        if !used[index] {
4381            logwarn!(
4382                "ignoring an unmatched or duplicate disposition for '{}' ({})",
4383                disposition.title,
4384                disposition.file
4385            );
4386        }
4387    }
4388}
4389
4390fn apply_optional_dispositions(
4391    repo: &Repo,
4392    cfg: &Config,
4393    response: &ResponseDoc,
4394    findings: &[Finding],
4395    state: &mut IssueRun,
4396    subject: i64,
4397    used: &mut [bool],
4398) {
4399    let optional = optional_findings(findings);
4400    if optional.is_empty() {
4401        return;
4402    }
4403    let mut fixed = 0usize;
4404    for finding in &optional {
4405        let Ok((index, disposition)) =
4406            matching_disposition(finding, &optional, &response.dispositions)
4407        else {
4408            continue;
4409        };
4410        // A disposition a blocking finding already took is not also this
4411        // finding's answer, however alike the two are worded.
4412        if used[index] {
4413            continue;
4414        }
4415        used[index] = true;
4416        let unique = unique_stable_finding(&optional, finding);
4417        match disposition.action {
4418            Action::Fixed => {
4419                fixed += 1;
4420                forget_noted(state, finding, unique);
4421            }
4422            Action::FiledIssue => {
4423                let title = disposition
4424                    .new_issue_title
4425                    .clone()
4426                    .filter(|t| !t.trim().is_empty())
4427                    .unwrap_or_else(|| finding.title.clone());
4428                let body = disposition
4429                    .new_issue_body
4430                    .clone()
4431                    .filter(|b| !b.trim().is_empty())
4432                    .unwrap_or_else(|| finding.detail.clone());
4433                let recorded = file_followup(repo, &title, &body, subject, cfg, state);
4434                record_nonblocking_outcome_with_match(state, finding, Some(&recorded), unique);
4435            }
4436            Action::Refuted => {}
4437        }
4438    }
4439    if fixed > 0 {
4440        logdim!("the author also fixed {fixed} non-blocking finding(s)");
4441    }
4442}
4443
4444/// Findings as a model should see them: full detail, since this one is not for
4445/// a human to read.
4446pub(crate) fn findings_for_prompt(findings: &[Finding]) -> String {
4447    if findings.is_empty() {
4448        return "(none)".to_string();
4449    }
4450    findings
4451        .iter()
4452        .map(|f| {
4453            let scope = if f.in_scope { "" } else { " [out of scope]" };
4454            format!(
4455                "- [{}]{scope} {} ({})\n  {}",
4456                f.severity,
4457                f.title,
4458                f.where_at(),
4459                f.detail
4460            )
4461        })
4462        .collect::<Vec<_>>()
4463        .join("\n")
4464}
4465
4466#[cfg(test)]
4467mod tests {
4468    use super::*;
4469    use crate::model::Verdict;
4470
4471    fn style() -> Style {
4472        Style::default()
4473    }
4474
4475    fn finding(severity: &str, title: &str, detail: &str, file: &str, in_scope: bool) -> Finding {
4476        Finding {
4477            severity: Severity::parse_lenient(severity).unwrap(),
4478            title: title.into(),
4479            detail: detail.into(),
4480            file: file.into(),
4481            in_scope,
4482            ..Default::default()
4483        }
4484    }
4485
4486    fn review(summary: &str, findings: Vec<Finding>) -> Review {
4487        Review {
4488            verdict: Verdict::Approve,
4489            next_action: NextAction::Merge,
4490            summary: summary.into(),
4491            findings,
4492            criteria: Vec::new(),
4493        }
4494    }
4495
4496    fn disposition(title: &str, file: &str, action: Action) -> Disposition {
4497        Disposition {
4498            title: title.into(),
4499            file: file.into(),
4500            action,
4501            reasoning: "because".into(),
4502            new_issue_title: None,
4503            new_issue_body: None,
4504        }
4505    }
4506
4507    // -- worktree release ------------------------------------------------
4508
4509    fn cfg_with(worktrees: bool, keep: bool) -> Config {
4510        let text = "[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n";
4511        let mut cfg = crate::config::parse(text).unwrap();
4512        cfg.loop_cfg.worktrees = worktrees;
4513        cfg.loop_cfg.keep_worktrees = keep;
4514        cfg
4515    }
4516
4517    #[test]
4518    fn a_worktree_is_released_on_every_finished_outcome() {
4519        let cfg = cfg_with(true, false);
4520        for status in [Status::Approved, Status::Merged, Status::Abandoned] {
4521            assert!(should_release(&cfg, status), "{status}");
4522        }
4523    }
4524
4525    /// Releasing only on "merged" leaked one worktree per run, because
4526    /// auto_merge is off by default and runs end at "approved".
4527    #[test]
4528    fn a_worktree_is_kept_only_where_a_human_has_to_look() {
4529        let cfg = cfg_with(true, false);
4530        assert!(!should_release(&cfg, Status::Escalated));
4531        assert!(!should_release(&cfg, Status::Error));
4532    }
4533
4534    #[test]
4535    fn an_uncommitted_implementation_names_its_diagnostic_and_recovery_path() {
4536        let path = Path::new("/tmp/issue worktree");
4537        let err =
4538            uncommitted_implementation_error(path, Some("git add could not create index.lock"))
4539                .to_string();
4540        assert!(err.contains("could not create index.lock"), "{err}");
4541        assert!(err.contains("/tmp/issue worktree"), "{err}");
4542        assert!(err.contains("Commit or recover"), "{err}");
4543        assert!(!should_release(&cfg_with(true, false), Status::Error));
4544    }
4545
4546    #[test]
4547    fn the_keep_flag_overrides_everything() {
4548        assert!(!should_release(&cfg_with(true, true), Status::Approved));
4549    }
4550
4551    #[test]
4552    fn nothing_is_released_when_worktrees_are_off() {
4553        assert!(!should_release(&cfg_with(false, false), Status::Approved));
4554    }
4555
4556    // -- custody ---------------------------------------------------------
4557
4558    /// The reviewer fixed the findings itself, so it wrote the head and the
4559    /// other agent takes round 2.
4560    #[test]
4561    fn fixing_your_own_findings_hands_the_pr_over() {
4562        let cfg = cfg_with(true, false);
4563        assert_eq!("a", next_reviewer(&cfg, "b", Some("b")));
4564        assert_eq!("b", next_reviewer(&cfg, "a", Some("a")));
4565    }
4566
4567    /// The author wrote the head, so the reviewer keeps the PR. Flipping here
4568    /// gave the author its own fix to review in round 2, and an approval of it
4569    /// ended the loop.
4570    #[test]
4571    fn handing_back_keeps_the_reviewer_for_the_next_round() {
4572        let cfg = cfg_with(true, false);
4573        assert_eq!("b", next_reviewer(&cfg, "b", Some("a")));
4574        assert_eq!("a", next_reviewer(&cfg, "a", Some("b")));
4575    }
4576
4577    /// Whoever holds round 2 did not write what it is reading, whoever wrote
4578    /// it. `a` implements, so `b` reviews round 1.
4579    #[test]
4580    fn nobody_reviews_their_own_edit() {
4581        let cfg = cfg_with(true, false);
4582        let round_1 = cfg.other(&cfg.first_implementor);
4583        assert_eq!("b", round_1);
4584        for editor in ["a", "b"] {
4585            assert_ne!(editor, next_reviewer(&cfg, &round_1, Some(editor)));
4586        }
4587    }
4588
4589    /// The `fix_myself` half of the bug. The reviewer said it would fix its own
4590    /// findings and the call returned without committing, so the head is still
4591    /// the author's and handing over would put the author in front of its own
4592    /// work.
4593    #[test]
4594    fn a_fix_that_committed_nothing_leaves_the_pr_where_it_is() {
4595        let cfg = cfg_with(true, false);
4596        assert_eq!("b", next_reviewer(&cfg, "b", None));
4597        assert_eq!("a", next_reviewer(&cfg, "a", None));
4598    }
4599
4600    /// The `hand_back` half. The reviewer committed while reviewing and the
4601    /// author answered without committing, so the head is the reviewer's and
4602    /// keeping it would have it read its own commit.
4603    #[test]
4604    fn a_reviewer_that_wrote_the_head_gives_the_pr_up() {
4605        let cfg = cfg_with(true, false);
4606        assert_eq!("a", next_reviewer(&cfg, "b", Some("b")));
4607    }
4608
4609    /// A reviewer that fixes what it finds and then reports nothing blocking
4610    /// approved its own fix, and the rollback takes that fix out again. The
4611    /// head that would merge is not the head that passed.
4612    #[test]
4613    fn a_review_that_wrote_cannot_approve_what_is_left() {
4614        assert!(!approval_stands(&[], true));
4615    }
4616
4617    #[test]
4618    fn a_clean_review_of_an_untouched_branch_approves() {
4619        assert!(approval_stands(&[], false));
4620    }
4621
4622    #[test]
4623    fn a_blocking_finding_never_approves() {
4624        let blocking = vec![finding("blocking", "Broken", "detail", "src/x.rs", true)];
4625        assert!(!approval_stands(&blocking, false));
4626    }
4627
4628    #[test]
4629    fn approval_refuses_a_head_that_changed_after_review() {
4630        assert!(ensure_reviewed_head(36, "abc123", "abc123").is_ok());
4631        let error = ensure_reviewed_head(36, "abc123", "def456").unwrap_err();
4632        assert!(error.to_string().contains("unread head"));
4633    }
4634
4635    /// Custody is decided on what git says, not on the call returning.
4636    #[test]
4637    fn only_a_moved_head_counts_as_a_commit() {
4638        let before = Snapshot {
4639            head: "abc".into(),
4640            dirty: false,
4641        };
4642        assert!(!Snapshot {
4643            head: "abc".into(),
4644            dirty: true,
4645        }
4646        .landed_over(&before));
4647        assert!(Snapshot {
4648            head: "def".into(),
4649            dirty: false,
4650        }
4651        .landed_over(&before));
4652        // git could not be read, which is not evidence that anything landed.
4653        assert!(!Snapshot {
4654            head: String::new(),
4655            dirty: false,
4656        }
4657        .landed_over(&before));
4658    }
4659
4660    // -- round budget ----------------------------------------------------
4661
4662    /// A fresh PR gets rounds 1 through max_rounds.
4663    #[test]
4664    fn a_fresh_run_starts_at_one() {
4665        assert_eq!((1, 3), round_window(1, 3));
4666        assert_eq!((1, 5), round_window(1, 5));
4667    }
4668
4669    /// The budget is per invocation, not a lifetime cap. Running spar again on
4670    /// a PR that already spent five rounds gives it five more, because a person
4671    /// looked at it and chose to.
4672    #[test]
4673    fn a_resumed_run_gets_a_full_fresh_budget() {
4674        assert_eq!((6, 10), round_window(6, 5));
4675        assert_eq!((11, 13), round_window(11, 3));
4676    }
4677
4678    #[test]
4679    fn a_budget_of_one_is_a_single_round() {
4680        assert_eq!((6, 6), round_window(6, 1));
4681    }
4682
4683    #[test]
4684    fn round_numbers_keep_counting_across_sessions() {
4685        // Three sessions of three rounds each land on 1..3, 4..6, 7..9.
4686        let mut start = 1;
4687        let mut seen = Vec::new();
4688        for _ in 0..3 {
4689            let (first, last) = round_window(start, 3);
4690            seen.push((first, last));
4691            start = last + 1;
4692        }
4693        assert_eq!(vec![(1, 3), (4, 6), (7, 9)], seen);
4694    }
4695
4696    // -- the ledger ------------------------------------------------------
4697
4698    fn ledger_with(title: &str, file: &str) -> Ledger {
4699        let mut ledger = Ledger::new();
4700        ledger.insert(
4701            finding_key(title, file),
4702            LedgerEntry {
4703                title: title.into(),
4704                file: file.into(),
4705                reasoning: "no".into(),
4706                round: 1,
4707                reraised: 0,
4708                outcome: Settled::Refuted,
4709            },
4710        );
4711        ledger
4712    }
4713
4714    #[test]
4715    fn a_point_refuted_and_re_raised_twice_escalates() {
4716        let mut ledger = ledger_with("nit about naming", "a.rs");
4717        let mut state = IssueRun::new(1, "t");
4718        let blocking = vec![finding("blocking", "nit about naming", "d", "a.rs", true)];
4719        assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
4720        assert!(check_relitigation(&mut ledger, &blocking, &mut state));
4721    }
4722
4723    /// Fixing is what most dispositions are, and it recorded nothing, so the
4724    /// guard had only refutations to match and never fired on a real run. Three
4725    /// tries at one point is a person's problem, not another round's.
4726    #[test]
4727    fn a_point_fixed_twice_and_raised_again_escalates() {
4728        let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
4729        for entry in ledger.values_mut() {
4730            entry.outcome = Settled::Fixed;
4731        }
4732        let mut state = IssueRun::new(1, "t");
4733        let blocking = [finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
4734
4735        assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
4736        assert!(check_relitigation(&mut ledger, &blocking, &mut state));
4737    }
4738
4739    /// A maintainer reading "settled and re-raised" about a fix that genuinely
4740    /// did not work sides with the author, and is wrong.
4741    #[test]
4742    fn a_fix_that_missed_twice_is_not_reported_as_a_refutation() {
4743        assert!(why_escalated(Settled::Fixed).contains("fixed twice"));
4744        for outcome in [Settled::Refuted, Settled::Filed, Settled::Dropped] {
4745            assert!(why_escalated(outcome).contains("settled"), "{outcome}");
4746        }
4747    }
4748
4749    /// A reviewer that fixes its own findings answers them in code too. Leaving
4750    /// them out left that path with the hole the other one had: the next pass
4751    /// reads a fix with nothing saying it was asked for, and the guard cannot
4752    /// count it.
4753    #[test]
4754    fn a_reviewer_that_fixes_its_own_findings_records_them_too() {
4755        let mut ledger = Ledger::new();
4756        let blocking = vec![finding(
4757            "blocking",
4758            "Unbounded loop",
4759            "spins",
4760            "src/x.rs",
4761            true,
4762        )];
4763        let mut state = IssueRun::new(1, "t");
4764
4765        record_own_fixes(&blocking, &mut ledger, &mut state, 1);
4766
4767        let entry = ledger
4768            .get(&finding_key("Unbounded loop", "src/x.rs"))
4769            .expect("keyed where the next round will look");
4770        assert_eq!(Settled::Fixed, entry.outcome);
4771        assert_eq!(
4772            "a committed change was made for this point",
4773            entry.reasoning
4774        );
4775
4776        // And the guard can now count it, which it could not before.
4777        assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
4778        assert!(check_relitigation(&mut ledger, &blocking, &mut state));
4779    }
4780
4781    /// `verdict` is required by the schema and decides nothing, so a review can
4782    /// contradict itself and nothing notices.
4783    ///
4784    /// The schema module says the fields are "what make convergence machine
4785    /// checkable", so a field that checks nothing is noise in the one place
4786    /// that is meant to be exact. It is kept and checked rather than dropped:
4787    /// the contradiction says something about the answer, and the reader should
4788    /// know the findings were what counted.
4789    #[test]
4790    fn a_review_that_contradicts_itself_says_so_and_the_findings_win() {
4791        let blocker = finding("blocking", "Retry loop spins", "spins", "src/net.rs", true);
4792        let approve_with_blocker = Review {
4793            verdict: Verdict::Approve,
4794            next_action: NextAction::HandBack,
4795            summary: "one blocker".into(),
4796            findings: vec![blocker],
4797            criteria: Vec::new(),
4798        };
4799        let said = verdict_disagreement(&approve_with_blocker, 1).expect("noticed");
4800        assert!(said.contains("approve"), "{said}");
4801        assert!(said.contains("findings decide"), "{said}");
4802
4803        let changes_but_merge = Review {
4804            verdict: Verdict::ChangesRequested,
4805            next_action: NextAction::Merge,
4806            summary: "nothing blocking".into(),
4807            findings: vec![],
4808            criteria: Vec::new(),
4809        };
4810        assert!(verdict_disagreement(&changes_but_merge, 0).is_some());
4811
4812        // The two ordinary shapes say nothing.
4813        let clean = Review {
4814            verdict: Verdict::Approve,
4815            next_action: NextAction::Merge,
4816            summary: "good".into(),
4817            findings: vec![],
4818            criteria: Vec::new(),
4819        };
4820        assert!(verdict_disagreement(&clean, 0).is_none());
4821        let blocked = Review {
4822            verdict: Verdict::ChangesRequested,
4823            next_action: NextAction::HandBack,
4824            summary: "one blocker".into(),
4825            findings: vec![],
4826            criteria: Vec::new(),
4827        };
4828        assert!(verdict_disagreement(&blocked, 1).is_none());
4829    }
4830
4831    /// A commit landing says one point was answered, not that all of them were.
4832    ///
4833    /// The fix call used to return free text, and every blocking finding was
4834    /// recorded Fixed as soon as anything committed. A reviewer that fixed two
4835    /// of three and said so in prose had all three settled, and the next
4836    /// reviewer was told "the author says it fixed them" for one nobody had
4837    /// touched.
4838    #[test]
4839    fn a_finding_the_fix_call_declined_stays_open() {
4840        let blocking = vec![
4841            finding("blocking", "Unbounded loop", "spins", "src/x.rs:88", true),
4842            finding("blocking", "Missing guard", "panics", "src/y.rs:12", true),
4843        ];
4844        let fixes = vec![
4845            OwnFix {
4846                title: "Unbounded loop".into(),
4847                file: "src/x.rs:88".into(),
4848                fixed: true,
4849                reason: "bounded it on max_attempts".into(),
4850            },
4851            OwnFix {
4852                title: "Missing guard".into(),
4853                file: "src/y.rs:12".into(),
4854                fixed: false,
4855                reason: "the guard belongs in the caller, which this branch does not touch".into(),
4856            },
4857        ];
4858
4859        let (claimed, left) = split_own_fixes(&blocking, &fixes);
4860
4861        assert_eq!(1, claimed.len());
4862        assert_eq!("Unbounded loop", claimed[0].title);
4863        assert_eq!(1, left.len());
4864        assert_eq!("Missing guard", left[0].title);
4865        assert!(
4866            left[0].detail.contains("belongs in the caller"),
4867            "the author is not told why it was left: {}",
4868            left[0].detail
4869        );
4870
4871        // And only the claimed one settles.
4872        let mut ledger = Ledger::new();
4873        let mut state = IssueRun::new(1, "t");
4874        record_own_fixes(&claimed, &mut ledger, &mut state, 1);
4875        assert!(ledger.contains_key(&finding_key("Unbounded loop", "src/x.rs:88")));
4876        assert!(
4877            !ledger.contains_key(&finding_key("Missing guard", "src/y.rs:12")),
4878            "a point nobody fixed was recorded as fixed"
4879        );
4880    }
4881
4882    /// A point the report says nothing about is not a fixed point either.
4883    #[test]
4884    fn a_finding_the_fix_report_never_mentions_stays_open() {
4885        let blocking = vec![finding(
4886            "blocking",
4887            "Unbounded loop",
4888            "spins",
4889            "src/x.rs:88",
4890            true,
4891        )];
4892        let (claimed, left) = split_own_fixes(&blocking, &[]);
4893        assert!(claimed.is_empty());
4894        assert_eq!(1, left.len());
4895    }
4896
4897    /// The settled block tells a reviewer the code will not change for a point.
4898    /// That is the opposite of what happened to a fix, and a fixed point printed
4899    /// there reads as an argument already won.
4900    #[test]
4901    fn a_fixed_point_is_not_in_the_settled_block() {
4902        let mut ledger = ledger_with("refuted point", "a.rs");
4903        ledger.extend(ledger_with("fixed point", "b.rs"));
4904        for entry in ledger.values_mut() {
4905            if entry.title == "fixed point" {
4906                entry.outcome = Settled::Fixed;
4907            }
4908        }
4909        let block = settled_block(&ledger);
4910        assert!(block.contains("refuted point"));
4911        assert!(!block.contains("fixed point"));
4912    }
4913
4914    /// And a ledger holding nothing but fixes has no settled block at all,
4915    /// rather than a heading with no points under it.
4916    #[test]
4917    fn a_ledger_of_only_fixes_says_nothing_is_settled() {
4918        let mut ledger = ledger_with("fixed point", "b.rs");
4919        for entry in ledger.values_mut() {
4920            entry.outcome = Settled::Fixed;
4921        }
4922        assert_eq!("", settled_block(&ledger));
4923    }
4924
4925    /// A review that lists one point twice used to take its entry from nothing
4926    /// to escalated in a single pass, without the author ever being asked. Rare
4927    /// while only refutations were recorded, and not rare now that every fix
4928    /// leaves an entry.
4929    #[test]
4930    fn one_review_spends_one_re_raise_however_often_it_says_it() {
4931        let mut ledger = ledger_with("Missing error handling", "src/net.rs");
4932        let mut state = IssueRun::new(1, "t");
4933        let twice = vec![
4934            finding(
4935                "blocking",
4936                "Missing error handling",
4937                "d",
4938                "src/net.rs",
4939                true,
4940            ),
4941            finding(
4942                "blocking",
4943                "Missing error handling",
4944                "e",
4945                "src/net.rs",
4946                true,
4947            ),
4948        ];
4949
4950        assert!(!check_relitigation(&mut ledger, &twice, &mut state));
4951        assert_eq!(1, ledger.values().next().unwrap().reraised);
4952        assert!(check_relitigation(&mut ledger, &twice, &mut state));
4953    }
4954
4955    #[test]
4956    fn an_untracked_finding_does_not_escalate() {
4957        let mut state = IssueRun::new(1, "t");
4958        let blocking = vec![finding("blocking", "brand new", "d", "a.rs", true)];
4959        assert!(!check_relitigation(
4960            &mut Ledger::new(),
4961            &blocking,
4962            &mut state
4963        ));
4964    }
4965
4966    #[test]
4967    fn persisted_ledger_entries_are_rekeyed_for_stable_locations() {
4968        let mut ledger = Ledger::new();
4969        ledger.insert(
4970            "legacy-key".into(),
4971            LedgerEntry {
4972                title: "Unbounded loop".into(),
4973                file: "src/x.rs:88".into(),
4974                reasoning: "bounded by the caller".into(),
4975                round: 2,
4976                reraised: 1,
4977                outcome: Settled::Refuted,
4978            },
4979        );
4980        normalise_ledger_keys(&mut ledger);
4981        let key = matching_ledger_key(&ledger, "Unbounded loop", "src/x.rs:91").unwrap();
4982        assert_eq!(1, ledger[&key].reraised);
4983    }
4984
4985    #[test]
4986    fn same_title_at_two_sites_keeps_both_blockers() {
4987        let findings = vec![
4988            finding(
4989                "blocking",
4990                "Unchecked error",
4991                "first site",
4992                "src/net.rs:10",
4993                true,
4994            ),
4995            finding(
4996                "blocking",
4997                "Unchecked error",
4998                "second site",
4999                "src/net.rs:200",
5000                true,
5001            ),
5002        ];
5003
5004        let blocking = blocking_findings(&findings);
5005        assert_eq!(2, blocking.len());
5006        assert_eq!("src/net.rs:10", blocking[0].file);
5007        assert_eq!("src/net.rs:200", blocking[1].file);
5008    }
5009
5010    #[test]
5011    fn moved_location_fallback_refuses_an_ambiguous_ledger() {
5012        let mut ledger = ledger_with("Unchecked error", "src/net.rs:10");
5013        ledger.extend(ledger_with("Unchecked error", "src/net.rs:200"));
5014        assert!(matching_ledger_key(&ledger, "Unchecked error", "src/net.rs:30").is_none());
5015    }
5016
5017    #[test]
5018    fn two_current_sites_do_not_relocate_one_old_ledger_entry() {
5019        let mut ledger = ledger_with("Unchecked error", "src/net.rs:5");
5020        let blocking = vec![
5021            finding(
5022                "blocking",
5023                "Unchecked error",
5024                "first",
5025                "src/net.rs:10",
5026                true,
5027            ),
5028            finding(
5029                "blocking",
5030                "Unchecked error",
5031                "second",
5032                "src/net.rs:200",
5033                true,
5034            ),
5035        ];
5036        let mut state = IssueRun::new(1, "t");
5037        record_own_fixes(&blocking, &mut ledger, &mut state, 2);
5038        assert!(ledger.contains_key(&finding_key("Unchecked error", "src/net.rs:10")));
5039        assert!(ledger.contains_key(&finding_key("Unchecked error", "src/net.rs:200")));
5040    }
5041
5042    #[test]
5043    fn two_current_sites_remain_two_open_findings() {
5044        let current = vec![
5045            finding(
5046                "blocking",
5047                "Unchecked error",
5048                "first",
5049                "src/net.rs:10",
5050                true,
5051            ),
5052            finding(
5053                "blocking",
5054                "Unchecked error",
5055                "second",
5056                "src/net.rs:200",
5057                true,
5058            ),
5059        ];
5060        let mut open = Vec::new();
5061
5062        extend_findings(&mut open, &current);
5063
5064        assert_eq!(2, open.len());
5065        assert_eq!("src/net.rs:10", open[0].file);
5066        assert_eq!("src/net.rs:200", open[1].file);
5067    }
5068
5069    #[test]
5070    fn display_limits_do_not_change_persisted_finding_identity() {
5071        let point = finding(
5072            "blocking",
5073            "abcdefghij",
5074            "still wrong",
5075            "src/net.rs:10",
5076            true,
5077        );
5078        let mut ledger = Ledger::new();
5079        let mut state = IssueRun::new(1, "t");
5080        record_own_fixes(std::slice::from_ref(&point), &mut ledger, &mut state, 1);
5081        normalise_ledger_keys(&mut ledger);
5082
5083        let key = matching_ledger_key(&ledger, "abcdefghij", "src/net.rs:12").unwrap();
5084        assert_eq!("abcdefghij", ledger[&key].title);
5085    }
5086
5087    #[test]
5088    fn a_clipped_legacy_entry_keeps_its_original_lookup_key() {
5089        let mut ledger = Ledger::new();
5090        let key = crate::jsonx::finding_key("abcdefghij", "src/net.rs:10");
5091        ledger.insert(
5092            key.clone(),
5093            LedgerEntry {
5094                title: "abcde".into(),
5095                file: "src/net.rs:10".into(),
5096                reasoning: "bounded by the caller".into(),
5097                round: 1,
5098                reraised: 1,
5099                outcome: Settled::Refuted,
5100            },
5101        );
5102        normalise_ledger_keys(&mut ledger);
5103
5104        assert_eq!(
5105            Some(key),
5106            matching_ledger_key(&ledger, "abcdefghij", "src/net.rs:12")
5107        );
5108    }
5109
5110    #[test]
5111    fn a_legacy_key_collision_does_not_merge_case_distinct_paths() {
5112        let mut ledger = Ledger::new();
5113        let key = crate::jsonx::finding_key("Unchecked error", "src/Main.rs:10");
5114        ledger.insert(
5115            key.clone(),
5116            LedgerEntry {
5117                title: "Unchecked error".into(),
5118                file: "src/Main.rs:10".into(),
5119                reasoning: "bounded by the caller".into(),
5120                round: 1,
5121                reraised: 0,
5122                outcome: Settled::Refuted,
5123            },
5124        );
5125
5126        assert_eq!(
5127            Some(key),
5128            matching_ledger_key(&ledger, "Unchecked error", "src/Main.rs:10")
5129        );
5130        assert!(matching_ledger_key(&ledger, "Unchecked error", "src/main.rs:10").is_none());
5131    }
5132
5133    /// The key a refutation records has to be the key the next round's finding
5134    /// hashes to. Recording it without the file made the guard dead code for
5135    /// every finding that named one, which is nearly all of them.
5136    #[test]
5137    fn a_refutation_lands_on_the_key_the_next_round_will_look_up() {
5138        let blocking = [finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
5139        let recorded = finding_key(&blocking[0].title, &blocking[0].file);
5140        let answer = disposition("unbounded loop!", "src/x.rs", Action::Refuted);
5141        assert!(answer_matches(&blocking[0], &answer));
5142        assert_eq!(recorded, finding_key(&answer.title, &answer.file));
5143    }
5144
5145    /// Title punctuation is wording noise, while the path remains part of the
5146    /// identity. A response can vary punctuation without losing the point.
5147    #[test]
5148    fn the_ledger_key_ignores_title_punctuation() {
5149        let findings = [finding(
5150            "blocking",
5151            "Panic on multi-byte input",
5152            "d",
5153            "src/style.rs",
5154            true,
5155        )];
5156        let reworded = "Panic on multibyte input";
5157        let source = &findings[0];
5158        assert_eq!(
5159            finding_key(reworded, &source.file),
5160            finding_key(&source.title, &source.file)
5161        );
5162        let recorded = finding_key(&source.title, &source.file);
5163        let looked_up = finding_key(&findings[0].title, &findings[0].file);
5164        assert_eq!(recorded, looked_up);
5165    }
5166
5167    #[test]
5168    fn a_disposition_matches_its_finding_despite_wording_noise() {
5169        let findings = [finding(
5170            "blocking",
5171            "Unbounded loop!",
5172            "d",
5173            "src/x.rs",
5174            true,
5175        )];
5176        assert!(answer_matches(
5177            &findings[0],
5178            &disposition("unbounded loop", "src/x.rs", Action::Refuted)
5179        ));
5180        assert!(!answer_matches(
5181            &findings[0],
5182            &disposition("something else", "src/x.rs", Action::Refuted)
5183        ));
5184        assert!(!answer_matches(
5185            &findings[0],
5186            &disposition("unbounded loop", "src/y.rs", Action::Refuted)
5187        ));
5188    }
5189
5190    #[test]
5191    fn a_disposition_matches_its_finding_despite_a_severity_tag() {
5192        let blocker = finding(
5193            "blocking",
5194            "Refused requests can corrupt the active splice",
5195            "d",
5196            "src/lightning/channel/channel.ts",
5197            true,
5198        );
5199        let answers = [disposition(
5200            "[blocking] Refused requests can corrupt the active splice",
5201            "src/lightning/channel/channel.ts",
5202            Action::Fixed,
5203        )];
5204
5205        assert_eq!(
5206            0,
5207            matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers)
5208                .expect("tagged answer")
5209                .0
5210        );
5211    }
5212
5213    #[test]
5214    fn a_bracketed_subject_still_separates_two_findings() {
5215        let ios = finding("blocking", "[iOS] Startup crash", "d", "src/app.ts", true);
5216        let android = finding(
5217            "blocking",
5218            "[Android] Startup crash",
5219            "d",
5220            "src/app.ts",
5221            true,
5222        );
5223        let findings = [ios.clone(), android];
5224        let answers = [disposition(
5225            "[Android] Startup crash",
5226            "src/app.ts",
5227            Action::Fixed,
5228        )];
5229
5230        assert!(matches!(
5231            matching_disposition(&ios, &findings, &answers),
5232            Err("no matching disposition")
5233        ));
5234    }
5235
5236    /// The warning this removes: the author is handed the non-blocking findings
5237    /// under a header asking it to fix the small ones and decline the rest with
5238    /// a reason, so an answer to one is the author doing as it was told. The
5239    /// blocking pass cannot place it, and used to say so, one line before the
5240    /// optional pass placed it.
5241    #[test]
5242    fn an_answer_to_an_optional_finding_is_placed_by_the_optional_pass() {
5243        let blocker = finding("blocking", "Unbounded loop", "d", "src/x.rs", true);
5244        let optional = finding(
5245            "non-blocking",
5246            "Storage failures are reported as a successful state update",
5247            "d",
5248            "src/wallet/index.ts",
5249            true,
5250        );
5251        let answers = [
5252            disposition("Unbounded loop", "src/x.rs", Action::Fixed),
5253            disposition(
5254                "Storage failures are reported as a successful state update",
5255                "src/wallet/index.ts",
5256                Action::Refuted,
5257            ),
5258        ];
5259        let mut used = vec![false; answers.len()];
5260
5261        let blocking = [blocker];
5262        let (index, _) = matching_disposition(&blocking[0], &blocking, &answers).unwrap();
5263        used[index] = true;
5264
5265        let optionals = [optional];
5266        let (index, _) = matching_disposition(&optionals[0], &optionals, &answers).unwrap();
5267        assert!(!used[index], "the blocking pass must not have taken it");
5268        used[index] = true;
5269
5270        assert!(used.iter().all(|placed| *placed), "both were placed");
5271    }
5272
5273    /// And the warning still means something. An answer to a point nobody
5274    /// raised is placed by neither pass.
5275    #[test]
5276    fn an_answer_to_a_point_nobody_raised_stays_unplaced() {
5277        let blocking = [finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
5278        let answers = [
5279            disposition("Unbounded loop", "src/x.rs", Action::Fixed),
5280            disposition("Something nobody said", "src/z.rs", Action::Refuted),
5281        ];
5282        let mut used = vec![false; answers.len()];
5283
5284        let (index, _) = matching_disposition(&blocking[0], &blocking, &answers).unwrap();
5285        used[index] = true;
5286
5287        let optionals = optional_findings(&blocking);
5288        assert!(optionals.is_empty());
5289
5290        assert_eq!(vec![false], vec![used[1]], "the stray answer is unplaced");
5291    }
5292
5293    #[test]
5294    fn an_omitted_disposition_leaves_the_blocker_unmatched() {
5295        let blocker = finding("blocking", "Unbounded loop", "d", "src/x.rs", true);
5296        assert!(matches!(
5297            matching_disposition(
5298                &blocker,
5299                std::slice::from_ref(&blocker),
5300                &[] as &[Disposition]
5301            ),
5302            Err("no matching disposition")
5303        ));
5304    }
5305
5306    #[test]
5307    fn duplicate_dispositions_are_ambiguous() {
5308        let blocker = finding("blocking", "Unbounded loop", "d", "src/x.rs", true);
5309        let answers = vec![
5310            disposition("Unbounded loop", "src/x.rs", Action::Fixed),
5311            disposition("Unbounded loop", "src/x.rs", Action::Refuted),
5312        ];
5313        assert!(matches!(
5314            matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers),
5315            Err("more than one matching disposition")
5316        ));
5317    }
5318
5319    #[test]
5320    fn an_unambiguous_disposition_may_omit_the_line_number() {
5321        let blocker = finding(
5322            "blocking",
5323            "Replayed offers persist the wrong lane key",
5324            "d",
5325            "src/engine.rs:1166",
5326            true,
5327        );
5328        let answers = [disposition(
5329            "replayed offers persist the wrong lane key!",
5330            "src/engine.rs",
5331            Action::Fixed,
5332        )];
5333
5334        assert_eq!(
5335            0,
5336            matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers)
5337                .expect("stable answer")
5338                .0
5339        );
5340    }
5341
5342    #[test]
5343    fn a_line_free_disposition_does_not_choose_between_two_sites() {
5344        let first = finding("blocking", "Unchecked error", "d", "src/engine.rs:10", true);
5345        let second = finding(
5346            "blocking",
5347            "Unchecked error",
5348            "d",
5349            "src/engine.rs:200",
5350            true,
5351        );
5352        let findings = [first.clone(), second];
5353        let answers = [disposition(
5354            "Unchecked error",
5355            "src/engine.rs",
5356            Action::Fixed,
5357        )];
5358
5359        assert!(matches!(
5360            matching_disposition(&first, &findings, &answers),
5361            Err("no matching disposition")
5362        ));
5363    }
5364
5365    #[test]
5366    fn an_exact_location_wins_over_an_ambiguous_fallback() {
5367        let blocker = finding("blocking", "Unchecked error", "d", "src/engine.rs:10", true);
5368        let answers = [
5369            disposition("Unchecked error", "src/engine.rs", Action::Refuted),
5370            disposition("Unchecked error", "src/engine.rs:10", Action::Fixed),
5371        ];
5372
5373        let matched = matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers)
5374            .expect("exact answer");
5375        assert_eq!(1, matched.0);
5376        assert_eq!(Action::Fixed, matched.1.action);
5377    }
5378
5379    #[test]
5380    fn same_titled_findings_in_different_files_need_separate_dispositions() {
5381        let findings = [
5382            finding("blocking", "Unchecked error", "d", "src/a.rs", true),
5383            finding("blocking", "Unchecked error", "d", "src/b.rs", true),
5384        ];
5385        let answers = vec![
5386            disposition("Unchecked error", "src/a.rs", Action::Fixed),
5387            disposition("Unchecked error", "src/b.rs", Action::Refuted),
5388        ];
5389        assert_eq!(
5390            0,
5391            matching_disposition(&findings[0], &findings, &answers)
5392                .expect("left answer")
5393                .0
5394        );
5395        assert_eq!(
5396            1,
5397            matching_disposition(&findings[1], &findings, &answers)
5398                .expect("right answer")
5399                .0
5400        );
5401    }
5402
5403    #[test]
5404    fn a_reported_fix_without_a_commit_stays_open() {
5405        assert!(!fixed_disposition_resolves(false));
5406        assert!(fixed_disposition_resolves(true));
5407    }
5408
5409    #[test]
5410    fn the_settled_block_is_empty_when_nothing_is_settled() {
5411        assert_eq!("", settled_block(&Ledger::new()));
5412    }
5413
5414    #[test]
5415    fn the_settled_block_names_each_refutation() {
5416        let block = settled_block(&ledger_with("a point", "x.rs"));
5417        assert!(block.contains("a point"));
5418        assert!(block.contains("x.rs"));
5419        assert!(block.contains("settled"));
5420    }
5421
5422    #[test]
5423    fn same_title_settlements_name_each_location() {
5424        let mut ledger = ledger_with("Unchecked error", "a.rs:10");
5425        ledger.extend(ledger_with("Unchecked error", "b.rs:20"));
5426
5427        let block = settled_block(&ledger);
5428
5429        assert!(block.contains("Unchecked error (a.rs:10)"), "{block}");
5430        assert!(block.contains("Unchecked error (b.rs:20)"), "{block}");
5431    }
5432
5433    /// A point the author moved to its own issue is done with on this branch.
5434    /// Leaving it out of the block let the reviewer that keeps the PR raise it
5435    /// again every round until the budget ran out.
5436    #[test]
5437    fn a_filed_point_is_settled_too() {
5438        let mut ledger = ledger_with("out of scope", "x.rs");
5439        for entry in ledger.values_mut() {
5440            entry.outcome = Settled::Filed;
5441            entry.reasoning = "Tracked in #9.".into();
5442        }
5443        let block = settled_block(&ledger);
5444        assert!(block.contains("out of scope"));
5445        assert!(block.contains("#9"));
5446    }
5447
5448    /// The author answers the point again every round it is re-raised, so
5449    /// recording the answer must not wipe the count that ends the argument.
5450    #[test]
5451    fn answering_a_point_again_keeps_its_re_raise_count() {
5452        let mut ledger = ledger_with("a point", "x.rs");
5453        let entry = ledger.values().next().unwrap().clone();
5454        let mut state = IssueRun::new(1, "t");
5455        let blocking = vec![finding("blocking", "a point", "d", "x.rs", true)];
5456
5457        assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
5458        settle(&mut ledger, "a point", "x.rs", true, entry);
5459        assert!(check_relitigation(&mut ledger, &blocking, &mut state));
5460    }
5461
5462    // -- brevity ---------------------------------------------------------
5463
5464    #[test]
5465    /// No agent name, no round number, and no count of things listed below.
5466    /// The reader wants the review, not an account of who produced it.
5467    fn a_clean_review_is_just_the_verdict() {
5468        let text = review_comment("codex", 1, &review("Looks correct.", vec![]), &style());
5469        assert_eq!("Looks correct.", text);
5470    }
5471
5472    #[test]
5473    fn a_review_leads_with_the_counts() {
5474        let text = review_comment(
5475            "codex",
5476            2,
5477            &review(
5478                "One real problem.",
5479                vec![
5480                    finding(
5481                        "blocking",
5482                        "Loop never terminates",
5483                        "Confirmed by running it.",
5484                        "src/a.rs",
5485                        true,
5486                    ),
5487                    finding("non-blocking", "Name is vague", "d", "src/b.rs", true),
5488                    finding("nit", "Log wording", "d", "", true),
5489                ],
5490            ),
5491            &style(),
5492        );
5493        assert!(text.starts_with("One real problem."), "{text}");
5494        assert!(!text.contains("codex"), "no agent name: {text}");
5495        assert!(!text.contains("round 2"), "no round number: {text}");
5496    }
5497
5498    /// Only blocking findings carry their detail into the thread. Everything
5499    /// else is filed, and the detail belongs on the issue.
5500    #[test]
5501    fn only_blocking_findings_carry_their_detail() {
5502        let text = review_comment(
5503            "codex",
5504            1,
5505            &review(
5506                "s",
5507                vec![
5508                    finding("blocking", "Loop", "BLOCKING DETAIL", "a.rs", true),
5509                    finding("non-blocking", "Name", "NONBLOCKING DETAIL", "b.rs", true),
5510                ],
5511            ),
5512            &style(),
5513        );
5514        assert!(text.contains("BLOCKING DETAIL"), "{text}");
5515        assert!(!text.contains("NONBLOCKING DETAIL"), "{text}");
5516    }
5517
5518    #[test]
5519    /// A finding's explanation is what the author acts on. Cutting it to save
5520    /// characters leaves them nothing to act on and saves nothing worth having.
5521    fn a_thorough_explanation_reaches_the_author_intact() {
5522        let detail = "Reproduced by running the 429 test with max_attempts unset. ".repeat(8);
5523        let text = review_comment(
5524            "codex",
5525            1,
5526            &review(
5527                "One problem.",
5528                vec![finding("blocking", "T", &detail, "a.rs", true)],
5529            ),
5530            &style(),
5531        );
5532        assert!(
5533            text.contains(detail.trim()),
5534            "the explanation was cut:\n{text}"
5535        );
5536    }
5537
5538    /// A runaway is still bounded, just nowhere near tightly.
5539    #[test]
5540    fn a_runaway_model_is_still_bounded() {
5541        let long = "filler words. ".repeat(20_000);
5542        let text = review_comment(
5543            "codex",
5544            1,
5545            &review(&long, vec![finding("blocking", "T", &long, "a.rs", true)]),
5546            &style(),
5547        );
5548        assert!(
5549            text.len() < 30_000,
5550            "review comment was {} chars",
5551            text.len()
5552        );
5553    }
5554
5555    #[test]
5556    fn a_general_finding_has_no_empty_parenthesis() {
5557        let text = review_comment(
5558            "codex",
5559            1,
5560            &review("s", vec![finding("blocking", "Something", "d", "", true)]),
5561            &style(),
5562        );
5563        assert!(!text.contains("()"), "{text}");
5564        assert!(!text.contains("(general)"), "{text}");
5565    }
5566
5567    #[test]
5568    fn out_of_scope_findings_are_counted_separately() {
5569        let text = review_comment(
5570            "codex",
5571            1,
5572            &review(
5573                "s",
5574                vec![finding("blocking", "Old bug", "d", "a.rs", false)],
5575            ),
5576            &style(),
5577        );
5578        assert!(text.contains("out of scope"), "{text}");
5579        assert!(text.contains("Old bug"), "{text}");
5580    }
5581
5582    #[test]
5583    fn a_disposition_comment_leads_with_counts_and_keeps_refutations() {
5584        let response = ResponseDoc {
5585            summary: "Two of three were right.".into(),
5586            dispositions: vec![],
5587        };
5588        let text = disposition_comment(
5589            "claude",
5590            &response,
5591            &["Fixed thing".to_string()],
5592            &["Wrong thing. Because the caller already checks.".to_string()],
5593            &[],
5594            &style(),
5595        )
5596        .unwrap();
5597        assert!(text.starts_with("Two of three were right."), "{text}");
5598        assert!(!text.contains("claude"), "no agent name: {text}");
5599        assert!(
5600            text.contains("Because the caller already checks."),
5601            "{text}"
5602        );
5603    }
5604
5605    #[test]
5606    fn an_empty_disposition_comment_is_not_posted() {
5607        let response = ResponseDoc {
5608            summary: "s".into(),
5609            dispositions: vec![],
5610        };
5611        assert!(disposition_comment("claude", &response, &[], &[], &[], &style()).is_none());
5612    }
5613
5614    // -- the closing pass -------------------------------------------------
5615
5616    /// A closing pass is not allowed to publish its own commit. The remote head
5617    /// therefore keeps the same eligible reviewer on a later run.
5618    #[test]
5619    fn a_local_closing_commit_does_not_change_remote_custody() {
5620        for holder in ["a", "b"] {
5621            assert_eq!(holder, closing_next_actor(holder));
5622        }
5623    }
5624
5625    #[test]
5626    fn a_matching_head_keeps_saved_custody() {
5627        assert!(reconcile_saved_head(Some("abc123"), Some(2), true, "abc123", None, 42).unwrap());
5628        assert!(reconcile_saved_head(None, None, false, "abc123", None, 42).unwrap());
5629    }
5630
5631    #[test]
5632    fn a_changed_head_refuses_automatic_custody() {
5633        let error =
5634            reconcile_saved_head(Some("abc123"), Some(2), true, "def456", None, 42).unwrap_err();
5635        let text = error.to_string();
5636        assert!(text.contains("abc123"), "{text}");
5637        assert!(text.contains("def456"), "{text}");
5638        assert!(text.contains("--next <agent>"), "{text}");
5639    }
5640
5641    #[test]
5642    fn legacy_state_keeps_its_saved_custody_for_migration() {
5643        assert!(reconcile_saved_head(Some(""), Some(1), true, "def456", None, 42).unwrap());
5644    }
5645
5646    #[test]
5647    fn an_explicit_holder_resets_state_for_a_changed_or_legacy_head() {
5648        assert!(
5649            !reconcile_saved_head(Some("abc123"), Some(2), true, "def456", Some("b"), 42).unwrap()
5650        );
5651        assert!(!reconcile_saved_head(Some(""), Some(1), true, "def456", Some("b"), 42).unwrap());
5652    }
5653
5654    #[test]
5655    fn a_headless_current_or_future_state_is_not_legacy() {
5656        for version in [2, STATE_VERSION + 1] {
5657            assert!(
5658                reconcile_saved_head(Some(""), Some(version), true, "def456", None, 42).is_err()
5659            );
5660        }
5661    }
5662
5663    #[test]
5664    fn legacy_state_with_an_unknown_actor_refuses_to_guess() {
5665        assert!(reconcile_saved_head(Some(""), Some(1), false, "def456", None, 42).is_err());
5666    }
5667
5668    #[test]
5669    fn an_invalid_review_cannot_clear_a_carried_blocker() {
5670        let mut open = vec![finding(
5671            "blocking",
5672            "Unchecked error",
5673            "still fails",
5674            "src/a.rs:12",
5675            true,
5676        )];
5677        update_open_findings(&mut open, &[], false);
5678        assert_eq!(1, open.len());
5679
5680        update_open_findings(&mut open, &[], true);
5681        assert!(open.is_empty());
5682    }
5683
5684    #[test]
5685    fn a_final_round_with_no_commit_names_open_blockers() {
5686        let open = vec![finding(
5687            "blocking",
5688            "Unchecked error",
5689            "still fails",
5690            "src/a.rs",
5691            true,
5692        )];
5693        assert!(matches!(
5694            ending_without_landing(&open),
5695            Ending::Unresolved(points) if points.len() == 1
5696        ));
5697        assert!(matches!(ending_without_landing(&[]), Ending::Unchanged));
5698    }
5699
5700    /// The closing pass reads a small delta and answers one question, so it
5701    /// never buys the first review's depth again. It has its own key now, and
5702    /// falls back to `rest` rather than to `round_1`.
5703    #[test]
5704    fn a_closing_pass_uses_the_later_effort_tier() {
5705        use crate::config::{Call, EffortSchedule};
5706        let schedule = EffortSchedule {
5707            round_1: Some("ultra".into()),
5708            rest: Some("low".into()),
5709            ..EffortSchedule::default()
5710        };
5711        assert_eq!(None, schedule.get(Call::Close));
5712        let mut with_key = schedule.clone();
5713        with_key.close = Some("medium".into());
5714        assert_eq!(Some("medium".to_string()), with_key.get(Call::Close));
5715    }
5716
5717    #[test]
5718    fn a_ledger_with_no_claimed_fix_has_nothing_to_close_over() {
5719        assert!(!any_fixes(&ledger_with("refuted point", "a.rs"), 1));
5720        let mut fixed = ledger_with("fixed point", "b.rs");
5721        for entry in fixed.values_mut() {
5722            entry.outcome = Settled::Fixed;
5723        }
5724        assert!(any_fixes(&fixed, 1));
5725    }
5726
5727    /// A count of rounds is a fact about spar, and what is left is a fact about
5728    /// the branch.
5729    #[test]
5730    fn the_closing_note_counts_points_rather_than_rounds() {
5731        assert_eq!("one point left after the closing pass", unresolved_note(1));
5732        assert_eq!("3 points left after the closing pass", unresolved_note(3));
5733        assert!(!unresolved_note(2).contains("round"));
5734    }
5735
5736    fn fixed_ledger() -> Ledger {
5737        let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
5738        for entry in ledger.values_mut() {
5739            entry.outcome = Settled::Fixed;
5740            entry.reasoning = "bounded it on max_attempts".into();
5741        }
5742        ledger
5743    }
5744
5745    #[test]
5746    fn the_closing_prompt_names_every_fix_it_has_to_check() {
5747        let landed = vec!["abc1234 Bound the retry loop".to_string()];
5748        let prompt = close_prompt(
5749            "origin/main",
5750            42,
5751            "Retry a 429",
5752            "",
5753            "9f8e7d6",
5754            Some(&landed),
5755            &fixed_ledger(),
5756            &[],
5757            1,
5758            &[],
5759        );
5760        assert!(prompt.contains("Unbounded loop"), "{prompt}");
5761        assert!(prompt.contains("bounded it on max_attempts"), "{prompt}");
5762        assert!(prompt.contains("abc1234 Bound the retry loop"), "{prompt}");
5763        assert!(prompt.contains("git diff 9f8e7d6..HEAD"), "{prompt}");
5764        assert!(prompt.contains("git diff origin/main...HEAD"), "{prompt}");
5765        assert!(!prompt.contains('{'), "{prompt}");
5766    }
5767
5768    /// Nothing landed is a real answer and a different one from "the harness
5769    /// cannot tell", and neither may leave a heading with nothing under it.
5770    #[test]
5771    fn a_close_with_nothing_landed_says_so_rather_than_leaving_a_hole() {
5772        let prompt = close_prompt(
5773            "origin/main",
5774            42,
5775            "Retry a 429",
5776            "",
5777            "9f8e7d6",
5778            Some(&[]),
5779            &Ledger::new(),
5780            &[],
5781            1,
5782            &[],
5783        );
5784        assert!(
5785            prompt.contains("Nothing landed after the last round"),
5786            "{prompt}"
5787        );
5788        assert!(!prompt.contains('{'), "{prompt}");
5789    }
5790
5791    /// A commit message that breaks the style rules is rewritten, which moves
5792    /// every hash after it, so the head a round recorded can stop being on the
5793    /// branch. `git log` answers that with the whole branch, and reporting all
5794    /// of it as newly landed would be false. The full branch remains the audit
5795    /// scope either way.
5796    #[test]
5797    fn a_rewritten_branch_admits_it_cannot_say_what_landed() {
5798        let prompt = close_prompt(
5799            "origin/main",
5800            42,
5801            "Retry a 429",
5802            "",
5803            "9f8e7d6",
5804            None,
5805            &fixed_ledger(),
5806            &[],
5807            1,
5808            &[],
5809        );
5810        assert!(prompt.contains("were rewritten"), "{prompt}");
5811        assert!(prompt.contains("git diff origin/main...HEAD"), "{prompt}");
5812        assert!(!prompt.contains("nobody has read it"), "{prompt}");
5813        assert!(!prompt.contains('{'), "{prompt}");
5814    }
5815
5816    /// The pass may not write, and the loop rolls back and says the prompt
5817    /// forbids it, so the prompt has to actually forbid it.
5818    #[test]
5819    fn the_closing_prompt_forbids_the_writing_the_loop_rolls_back() {
5820        let prompt = close_prompt(
5821            "origin/main",
5822            42,
5823            "t",
5824            "",
5825            "9f8e7d6",
5826            Some(&[]),
5827            &Ledger::new(),
5828            &[],
5829            1,
5830            &[],
5831        );
5832        assert!(prompt.contains("do not commit"), "{prompt}");
5833    }
5834
5835    /// Missing a serious defect in an earlier round does not make it safe.
5836    #[test]
5837    fn the_closing_prompt_keeps_confirmed_merge_blockers_blocking() {
5838        let prompt = close_prompt(
5839            "origin/main",
5840            42,
5841            "t",
5842            "",
5843            "9f8e7d6",
5844            Some(&[]),
5845            &Ledger::new(),
5846            &[],
5847            1,
5848            &[],
5849        );
5850        assert!(
5851            prompt.contains("serious defect an\nearlier round missed"),
5852            "{prompt}"
5853        );
5854        assert!(prompt.contains("final merge-safety audit"), "{prompt}");
5855        assert!(!prompt.contains("not another\naudit"), "{prompt}");
5856        assert!(!prompt.contains("A\nfinding means"), "{prompt}");
5857        assert!(prompt.contains("A\nblocking finding means"), "{prompt}");
5858        let flat = prompt.split_whitespace().collect::<Vec<_>>().join(" ");
5859        assert!(flat.contains("does not become non-blocking"), "{prompt}");
5860        assert!(flat.contains("Only one of them ships"), "{prompt}");
5861    }
5862
5863    /// The closing pass had two routes for a real point, block or in_scope=false,
5864    /// and `blocks()` is `severity == Blocking && in_scope`, so the second one
5865    /// silently opens the merge gate. A closer taking it filed an issue saying
5866    /// the branch must not merge and merged the branch.
5867    #[test]
5868    fn the_closing_pass_is_offered_a_severity_rather_than_the_field_that_gates() {
5869        let prompt = close_prompt(
5870            "origin/main",
5871            42,
5872            "t",
5873            "",
5874            "9f8e7d6",
5875            Some(&[]),
5876            &Ledger::new(),
5877            &[],
5878            1,
5879            &[],
5880        );
5881        assert!(
5882            prompt.contains("Minor defects and improvements are\nnon-blocking"),
5883            "{prompt}"
5884        );
5885        assert!(
5886            prompt.contains("a real defect\nthis pull request did not cause"),
5887            "{prompt}"
5888        );
5889    }
5890
5891    /// A point that only ever reached `in_scope = false` never reaches
5892    /// `blocking`, whatever severity it carries, so the run merges.
5893    #[test]
5894    fn an_out_of_scope_point_cannot_gate_the_close() {
5895        let out_of_scope = finding("blocking", "Adjacent leak", "d", "o.rs", false);
5896        assert!(!out_of_scope.blocks());
5897        assert!(approval_stands(&[], false));
5898    }
5899
5900    /// The closing pass reads what the last round left. Carrying every fix a
5901    /// pull request ever saw would hand a resumed run's close nine rounds of
5902    /// answered points, which is the unbounded surface this replaces.
5903    #[test]
5904    fn a_fix_is_shown_to_the_pass_that_has_to_check_it_and_not_after() {
5905        let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
5906        for entry in ledger.values_mut() {
5907            entry.outcome = Settled::Fixed;
5908            entry.round = 2;
5909        }
5910        // Round 3 follows the round that claimed it.
5911        assert!(answers_block(&ledger, 3).contains("Unbounded loop"));
5912        // Round 4 does not: round 3 read it and did not raise it again.
5913        assert_eq!("", answers_block(&ledger, 4));
5914        assert!(any_fixes(&ledger, 2));
5915        assert!(!any_fixes(&ledger, 3));
5916    }
5917
5918    // -- the review prompt ----------------------------------------------
5919
5920    /// A round that fixed nine findings left nothing behind, so the next round
5921    /// met the fix as ordinary code with no sign anybody had asked for it.
5922    #[test]
5923    fn the_answers_block_asks_the_reviewer_to_check_rather_than_to_trust() {
5924        let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
5925        for entry in ledger.values_mut() {
5926            entry.outcome = Settled::Fixed;
5927            entry.reasoning = "bounded it on max_attempts".into();
5928        }
5929        let block = answers_block(&ledger, 2);
5930        assert!(block.contains("Unbounded loop"), "{block}");
5931        assert!(block.contains("src/x.rs"), "{block}");
5932        assert!(block.contains("bounded it on max_attempts"), "{block}");
5933        assert!(block.contains("Check the answer"), "{block}");
5934        assert!(!block.contains("settled"), "{block}");
5935    }
5936
5937    /// A refutation is an argument to weigh and a fix is a claim to check, and
5938    /// the two blocks say opposite things. Neither may carry the other's points.
5939    #[test]
5940    fn a_fix_and_a_refutation_do_not_share_a_heading() {
5941        let mut ledger = ledger_with("refuted point", "a.rs");
5942        ledger.extend(ledger_with("fixed point", "b.rs"));
5943        for entry in ledger.values_mut() {
5944            if entry.title == "fixed point" {
5945                entry.outcome = Settled::Fixed;
5946            }
5947        }
5948        let answers = answers_block(&ledger, 2);
5949        let settled = settled_block(&ledger);
5950        assert!(answers.contains("fixed point") && !answers.contains("refuted point"));
5951        assert!(settled.contains("refuted point") && !settled.contains("fixed point"));
5952    }
5953
5954    #[test]
5955    fn an_empty_ledger_adds_no_answers_block() {
5956        assert_eq!("", answers_block(&Ledger::new(), 2));
5957    }
5958
5959    /// A point held back for a later round does not get one, so the reviewer is
5960    /// told which round is the last that can ask for anything.
5961    #[test]
5962    fn the_last_round_that_can_ask_for_anything_says_so() {
5963        assert_eq!("", round_note(1, 3));
5964        assert_eq!("", round_note(2, 3));
5965        assert!(round_note(3, 3).contains("last round"));
5966        // Round numbers keep counting up across a resume, so the last round of
5967        // an invocation is not round `max_rounds`.
5968        assert!(round_note(6, 6).contains("last round"));
5969    }
5970
5971    /// Telling a reviewer when the asking stops must never tell it to want less.
5972    /// A reviewer that lowers its bar to finish is the failure this loop was
5973    /// built against, so the note carries no severity vocabulary at all. That
5974    /// the pull request may merge afterwards is a fact about the harness, and
5975    /// saying it is not the same as asking for an approval.
5976    #[test]
5977    fn saying_when_the_asking_stops_says_nothing_about_severity() {
5978        let note = round_note(3, 3);
5979        for word in ["approve", "blocking", "severity", "nit"] {
5980            assert!(!note.contains(word), "{word} in: {note}");
5981        }
5982    }
5983
5984    #[test]
5985    fn the_review_prompt_leaves_nothing_unsubstituted() {
5986        // The resolved ref, because that is what the loop passes and what spar
5987        // itself compares against. In a linked worktree the local branch is
5988        // whatever the primary checkout last had.
5989        let empty = review_prompt(
5990            "origin/main",
5991            42,
5992            "Retry a 429",
5993            "",
5994            &Ledger::new(),
5995            &[],
5996            1,
5997            3,
5998            &[],
5999        );
6000        assert!(!empty.contains('{'), "{empty}");
6001        assert!(
6002            empty.contains("`origin/main`"),
6003            "the reviewer was told to read a different ref than spar compares:\n{empty}"
6004        );
6005        assert!(empty.contains("#42") && empty.contains("Retry a 429"));
6006
6007        let mut ledger = ledger_with("refuted point", "a.rs");
6008        ledger.extend(ledger_with("fixed point", "b.rs"));
6009        for entry in ledger.values_mut() {
6010            if entry.title == "fixed point" {
6011                entry.outcome = Settled::Fixed;
6012                entry.round = 2;
6013            }
6014        }
6015        let full = review_prompt(
6016            "origin/main",
6017            42,
6018            "Retry a 429",
6019            "",
6020            &ledger,
6021            &[],
6022            3,
6023            3,
6024            &[],
6025        );
6026        assert!(!full.contains('{'), "{full}");
6027        assert!(full.contains("fixed point") && full.contains("refuted point"));
6028        assert!(full.contains("last round"), "{full}");
6029    }
6030
6031    /// A reviewer asked whether the change resolves the issue was given the
6032    /// title and nothing else.
6033    ///
6034    /// Every call is a fresh process, so what the prompt does not carry does
6035    /// not exist. An issue titled "Retry on 429" whose body specifies honouring
6036    /// Retry-After and a five attempt cap was reviewed against those four
6037    /// words, and "the issue asked for X and the branch does not do X" is the
6038    /// most valuable blocking finding there is.
6039    #[test]
6040    fn every_pass_that_judges_the_branch_is_given_the_issue_and_the_description() {
6041        let context = context_block(
6042            42,
6043            "https://github.com/example/project/issues/42",
6044            "Honour Retry-After and cap at five attempts.",
6045            "## What changed\n\nBounded the loop. Ran the 429 test.",
6046        );
6047        assert!(context.contains("Honour Retry-After"), "{context}");
6048        assert!(context.contains("issues/42"), "{context}");
6049        assert!(
6050            context.contains("followed by any comments on it since"),
6051            "{context}"
6052        );
6053        assert!(context.contains("the later one wins"), "{context}");
6054        assert!(context.contains("Ran the 429 test"), "{context}");
6055        assert!(
6056            context.contains("claim to check"),
6057            "the description has to be labelled as the author's own account:\n{context}"
6058        );
6059
6060        let review = review_prompt(
6061            "origin/main",
6062            42,
6063            "Retry on 429",
6064            &context,
6065            &Ledger::new(),
6066            &[],
6067            1,
6068            3,
6069            &[],
6070        );
6071        let close = close_prompt(
6072            "origin/main",
6073            42,
6074            "Retry on 429",
6075            &context,
6076            "9f8e7d6",
6077            Some(&[]),
6078            &Ledger::new(),
6079            &[],
6080            1,
6081            &[],
6082        );
6083        let respond = respond_prompt(42, &context, &[], &[]);
6084        for prompt in [review, close, respond] {
6085            assert!(prompt.contains("Honour Retry-After"), "{prompt}");
6086            assert!(prompt.contains("Ran the 429 test"), "{prompt}");
6087            assert!(!prompt.contains('{'), "{prompt}");
6088        }
6089    }
6090
6091    /// The author is making a commit anyway, and nobody was acting on these.
6092    ///
6093    /// Under the defaults a non-blocking finding is kept in state.noted and
6094    /// listed for a person at the end, so a reviewer being properly restrained
6095    /// about what costs a round was deciding which real defects reach the
6096    /// merged code. Handing them to the author as optional costs no extra call.
6097    #[test]
6098    fn the_author_is_offered_the_non_blocking_findings_it_never_saw() {
6099        let findings = vec![
6100            finding(
6101                "blocking",
6102                "Retry loop spins",
6103                "confirmed",
6104                "src/net.rs:88",
6105                true,
6106            ),
6107            finding(
6108                "non-blocking",
6109                "Timeout is hard coded",
6110                "thirty seconds, in one place",
6111                "src/net.rs:44",
6112                true,
6113            ),
6114            finding(
6115                "nit",
6116                "Log line says retrying",
6117                "wording",
6118                "src/net.rs:102",
6119                true,
6120            ),
6121            finding(
6122                "non-blocking",
6123                "Out of scope thing",
6124                "predates the branch",
6125                "src/cache.rs",
6126                false,
6127            ),
6128        ];
6129
6130        let optional = optional_findings(&findings);
6131        assert_eq!(1, optional.len(), "{optional:?}");
6132        assert_eq!("Timeout is hard coded", optional[0].title);
6133
6134        let prompt = respond_prompt(42, "", &[findings[0].clone()], &findings);
6135        assert!(prompt.contains("Timeout is hard coded"), "{prompt}");
6136        assert!(prompt.contains("will not hold the merge"), "{prompt}");
6137        assert!(
6138            !prompt.contains("Log line says retrying"),
6139            "a nit is dropped, not offered:\n{prompt}"
6140        );
6141        assert!(
6142            !prompt.contains("Out of scope thing"),
6143            "an out of scope point is filed, not offered:\n{prompt}"
6144        );
6145        assert!(!prompt.contains('{'), "{prompt}");
6146
6147        // Nothing optional means nothing said about it.
6148        let none = optional_block(&[findings[0].clone()]);
6149        assert_eq!("", none);
6150    }
6151
6152    /// The reviewer chooses the label, so it has to know what each one costs.
6153    #[test]
6154    fn the_review_prompt_says_what_happens_to_each_severity() {
6155        assert!(
6156            REVIEW_PROMPT.contains("offered to the author"),
6157            "{REVIEW_PROMPT}"
6158        );
6159        assert!(REVIEW_PROMPT.contains("nit: dropped"), "{REVIEW_PROMPT}");
6160    }
6161
6162    /// Nothing to say is said as nothing, not as an empty heading. A resumed
6163    /// pull request that closes no issue has no body to carry.
6164    #[test]
6165    fn a_pull_request_with_no_issue_behind_it_carries_no_empty_headings() {
6166        assert_eq!("", context_block(7, "", "", ""));
6167        let only_description = context_block(7, "", "", "What it does.");
6168        assert!(!only_description.contains("as filed"), "{only_description}");
6169        assert!(only_description.contains("What it does."));
6170    }
6171
6172    #[test]
6173    fn a_resumed_open_finding_reaches_review_and_closing_prompts() {
6174        let open = vec![finding(
6175            "blocking",
6176            "Retry bypasses the limit",
6177            "reproduced with max_attempts set to one",
6178            "src/net.rs:88",
6179            true,
6180        )];
6181        let review = review_prompt(
6182            "origin/main",
6183            42,
6184            "Retry a 429",
6185            "",
6186            &Ledger::new(),
6187            &open,
6188            2,
6189            3,
6190            &[],
6191        );
6192        let close = close_prompt(
6193            "origin/main",
6194            42,
6195            "Retry a 429",
6196            "",
6197            "9f8e7d6",
6198            Some(&[]),
6199            &Ledger::new(),
6200            &open,
6201            2,
6202            &[],
6203        );
6204        for prompt in [review, close] {
6205            assert!(prompt.contains("Retry bypasses the limit"), "{prompt}");
6206            assert!(prompt.contains("src/net.rs:88"), "{prompt}");
6207            assert!(
6208                prompt.contains("reproduced with max_attempts set to one"),
6209                "{prompt}"
6210            );
6211            assert!(!prompt.contains('{'), "{prompt}");
6212        }
6213    }
6214
6215    /// A confirmed defect that is minor had no label but blocking: non-blocking
6216    /// was defined as an improvement, and nit as taste. Severity gating is the
6217    /// whole defence against the nitpick spiral, and it had a hole in it.
6218    #[test]
6219    fn a_minor_defect_has_a_severity_that_is_not_blocking() {
6220        assert!(
6221            REVIEW_PROMPT.contains("A minor defect belongs\n  here as much as an improvement does")
6222        );
6223        // The schema is shared with `spar review`, which has no rounds, so it
6224        // and that prompt carry the same ladder without the round neither can
6225        // spend. Two definitions of one enum value in one request is how a
6226        // reviewer ends up applying a cost model that does not exist.
6227        for text in [
6228            schema::review().to_string(),
6229            crate::review_only::review_only_prompt().to_string(),
6230        ] {
6231            assert!(text.contains("as much as an improvement does"), "{text}");
6232            assert!(!text.contains("a genuine improvement"), "{text}");
6233        }
6234    }
6235
6236    /// Doubt used to resolve onto `in_scope = true`, which is half of what gates
6237    /// a merge. It resolves onto the severity instead, which is not.
6238    #[test]
6239    fn doubt_resolves_away_from_the_field_that_gates() {
6240        for text in [REVIEW_PROMPT.to_string(), schema::review().to_string()] {
6241            assert!(text.contains("say your piece in the finding and label it non-blocking"));
6242            assert!(!text.contains("leave in_scope true"));
6243        }
6244    }
6245
6246    /// Every line a fix adds is what the next pass reviews, so a fix that grows
6247    /// the branch buys another round of findings about the fix.
6248    #[test]
6249    fn both_edit_prompts_ask_for_the_smallest_change_that_answers_the_point() {
6250        for prompt in [FIX_PROMPT, RESPOND_PROMPT] {
6251            assert!(
6252                prompt.contains("The smallest change that answers it is"),
6253                "{prompt}"
6254            );
6255        }
6256        assert!(RESPOND_PROMPT.contains("bigger than the\n  problem it names"));
6257    }
6258
6259    #[test]
6260    fn a_fixed_disposition_must_explain_what_changed() {
6261        let flat = RESPOND_PROMPT
6262            .split_whitespace()
6263            .collect::<Vec<_>>()
6264            .join(" ");
6265        assert!(
6266            flat.contains("For fixed, say what changed and how it answers the point"),
6267            "{RESPOND_PROMPT}"
6268        );
6269    }
6270
6271    #[test]
6272    fn an_empty_fix_reason_still_renders_as_a_claim_to_check() {
6273        let mut ledger = ledger_with("Unchecked error", "src/net.rs");
6274        for entry in ledger.values_mut() {
6275            entry.outcome = Settled::Fixed;
6276            entry.reasoning.clear();
6277        }
6278
6279        let lines = fixed_lines(&ledger, 0);
6280
6281        assert_eq!(1, lines.len());
6282        assert!(lines[0].contains("a committed change claims to address this point"));
6283        assert!(!lines[0].contains("The author said"));
6284    }
6285
6286    /// Both, not either. The link is how an agent that can reach the network
6287    /// reads the discussion spar does not fetch, and the body is what the one
6288    /// that cannot works from: codex runs with no network, so a link alone
6289    /// would leave it building from the title.
6290    #[test]
6291    fn the_implementor_is_given_the_link_and_the_body() {
6292        let prompt = implement_prompt(
6293            42,
6294            "Retry a 429",
6295            "https://github.com/o/r/issues/42",
6296            "A rate limited response was treated as fatal.",
6297            &[],
6298        );
6299        assert!(
6300            prompt.contains("https://github.com/o/r/issues/42"),
6301            "{prompt}"
6302        );
6303        assert!(
6304            prompt.contains("A rate limited response was treated as fatal."),
6305            "{prompt}"
6306        );
6307        assert!(prompt.contains("#42"), "{prompt}");
6308        assert!(prompt.contains("Retry a 429"), "{prompt}");
6309        // Nothing left unsubstituted.
6310        assert!(!prompt.contains('{'), "{prompt}");
6311    }
6312
6313    /// A body that reads as complete gives no hint that a comment superseded
6314    /// it, so the prompt says the comments follow it and that a later one wins.
6315    /// beignet#862 was filed with a cause and a suggested fix, and its comments
6316    /// then found the wallet could never persist a channel again and retitled
6317    /// it. Built from the body alone, the change fixes the first half.
6318    #[test]
6319    fn the_prompt_says_a_later_comment_can_change_what_was_asked() {
6320        let prompt = implement_prompt(1, "t", "u", "b", &[]);
6321        // Flattened, so the assertion does not turn on where the prompt wraps.
6322        let lower = prompt
6323            .split_whitespace()
6324            .collect::<Vec<_>>()
6325            .join(" ")
6326            .to_lowercase();
6327        assert!(lower.contains("followed by any comments"), "{prompt}");
6328        assert!(lower.contains("the later one wins"), "{prompt}");
6329        assert!(lower.contains("a comment from spar"), "{prompt}");
6330        assert!(!prompt.contains('{'), "{prompt}");
6331    }
6332
6333    /// A fully reported implementation, for the body tests.
6334    fn worked() -> Implementation {
6335        Implementation {
6336            summary: "Retry a 429 instead of failing the run.".into(),
6337            problem: "A rate limited response was treated as fatal, so one throttled call ended \
6338                      a run that had hours of work left in it."
6339                .into(),
6340            changes: vec![
6341                "`send` retries a 429 with the delay the header asks for".into(),
6342                "the retry budget is bounded, so a permanent 429 still ends".into(),
6343            ],
6344            testing: vec![
6345                "`cargo test retries_a_429`".into(),
6346                "point it at a throttled endpoint and watch it finish".into(),
6347            ],
6348            ..Implementation::default()
6349        }
6350    }
6351
6352    #[test]
6353    /// GitHub renders the file count and the plus and minus figures in the
6354    /// header, immediately above whatever spar writes, so neither is here.
6355    fn a_pr_body_is_what_it_closes_and_what_changed() {
6356        let body = pr_body(42, &worked(), None, &[], &style());
6357        assert_eq!(
6358            "Closes #42\n\n\
6359             Retry a 429 instead of failing the run.\n\n\
6360             A rate limited response was treated as fatal, so one throttled call \
6361             ended a run that had hours of work left in it.\n\n\
6362             ## What changed\n\n\
6363             - `send` retries a 429 with the delay the header asks for\n\
6364             - the retry budget is bounded, so a permanent 429 still ends\n\n\
6365             ## How to test\n\n\
6366             - `cargo test retries_a_429`\n\
6367             - point it at a throttled endpoint and watch it finish",
6368            body
6369        );
6370    }
6371
6372    /// The sections are optional and the lead is not. A one line fix should
6373    /// read as one, not as a form with most of it left blank.
6374    #[test]
6375    fn a_body_with_nothing_to_list_carries_no_empty_headings() {
6376        let work = Implementation {
6377            summary: "Retry a 429 instead of failing the run.".into(),
6378            ..Implementation::default()
6379        };
6380        assert_eq!(
6381            "Closes #42\n\nRetry a 429 instead of failing the run.",
6382            pr_body(42, &work, None, &[], &style())
6383        );
6384    }
6385
6386    #[test]
6387    fn a_pr_body_survives_an_implementor_that_said_nothing() {
6388        assert_eq!(
6389            "Closes #7",
6390            pr_body(7, &Implementation::default(), None, &[], &style())
6391        );
6392    }
6393
6394    /// Blank entries are the model's, not the reader's problem. A heading whose
6395    /// only bullet was an empty string used to be possible.
6396    #[test]
6397    fn blank_list_entries_do_not_earn_a_heading() {
6398        let work = Implementation {
6399            summary: "Did a thing.".into(),
6400            changes: vec![String::new(), "   ".into()],
6401            ..Implementation::default()
6402        };
6403        let body = pr_body(42, &work, None, &[], &style());
6404        assert!(!body.contains("What changed"), "{body}");
6405    }
6406
6407    #[test]
6408    fn notes_appear_only_when_there_is_something_to_note() {
6409        let mut work = worked();
6410        assert!(!pr_body(42, &work, None, &[], &style()).contains("## Notes"));
6411        work.notes = Some("The retry is not applied to streaming calls.".into());
6412        let body = pr_body(42, &work, None, &[], &style());
6413        assert!(body.contains("## Notes"), "{body}");
6414        assert!(body.contains("streaming calls"), "{body}");
6415    }
6416
6417    /// An issue that produced no commits is told so. Never the summary, which
6418    /// describes a change that is not in the branch.
6419    #[test]
6420    fn declining_posts_the_reason_and_not_the_summary() {
6421        let work = Implementation {
6422            not_worth_doing: true,
6423            reason: "Already fixed in 1.2, and the report predates it.".into(),
6424            summary: "Nothing to do.".into(),
6425            ..Implementation::default()
6426        };
6427        assert_eq!(
6428            "Already fixed in 1.2, and the report predates it.",
6429            no_pr_note(&work, &style())
6430        );
6431    }
6432
6433    #[test]
6434    fn reporting_work_and_committing_none_says_that_rather_than_the_summary() {
6435        let work = Implementation {
6436            summary: "Retry a 429 instead of failing the run.".into(),
6437            ..Implementation::default()
6438        };
6439        let note = no_pr_note(&work, &style());
6440        assert_eq!(
6441            "Nothing was committed, so there is nothing to review.",
6442            note
6443        );
6444    }
6445
6446    #[test]
6447    fn declining_without_a_reason_still_says_something() {
6448        let work = Implementation {
6449            not_worth_doing: true,
6450            ..Implementation::default()
6451        };
6452        assert!(no_pr_note(&work, &style()).contains("no reason given"));
6453    }
6454
6455    #[test]
6456    fn a_skip_comment_is_only_the_reasoning() {
6457        let item = SkippedItem {
6458            issue: 3,
6459            title: "t".into(),
6460            tracker: false,
6461            reasons: [
6462                ("claude".to_string(), "Already fixed in 1.2.".to_string()),
6463                ("codex".to_string(), "Duplicate of #2.".to_string()),
6464            ]
6465            .into_iter()
6466            .collect(),
6467        };
6468        let text = skip_comment(&item, &style());
6469        assert!(text.contains("Already fixed in 1.2."), "{text}");
6470        assert!(text.contains("Duplicate of #2."), "{text}");
6471        assert!(
6472            !text.contains("claude") && !text.contains("codex"),
6473            "{text}"
6474        );
6475        assert!(!text.to_lowercase().contains("not scheduled"), "{text}");
6476        assert!(text.lines().count() <= 3, "{text}");
6477    }
6478
6479    #[test]
6480    fn findings_for_a_model_keep_full_detail() {
6481        let long = "x".repeat(2000);
6482        let text = findings_for_prompt(&[finding("blocking", "T", &long, "a.rs", true)]);
6483        assert!(
6484            text.contains(&long),
6485            "a model needs the whole finding, only humans need brevity"
6486        );
6487    }
6488
6489    #[test]
6490    fn findings_for_a_model_are_never_empty() {
6491        assert_eq!("(none)", findings_for_prompt(&[]));
6492    }
6493
6494    const CRITERIA_BODY: &str = "## Acceptance criteria\n- [ ] Preserve column order.\n- [x] Already done.\n- [ ] Connects on a device\n";
6495
6496    fn status(number: u32, mark: Mark, reason: &str) -> CriterionStatus {
6497        CriterionStatus {
6498            number,
6499            status: mark,
6500            reason: reason.into(),
6501        }
6502    }
6503
6504    #[test]
6505    fn criteria_are_the_unchecked_items_only() {
6506        let criteria = tracker::criteria(CRITERIA_BODY);
6507        let texts: Vec<&str> = criteria.iter().map(|c| c.text.as_str()).collect();
6508        assert_eq!(
6509            vec!["Preserve column order.", "Connects on a device"],
6510            texts
6511        );
6512    }
6513
6514    #[test]
6515    fn prompts_without_criteria_are_unchanged() {
6516        assert!(!implement_prompt(1, "t", "u", "b", &[]).contains("{criteria}"));
6517        assert!(implement_prompt(1, "t", "u", "b", &[])
6518            .contains("oldest first.\n".to_string().as_str()));
6519        let review = review_prompt("main", 1, "t", "\nctx\n", &Ledger::new(), &[], 1, 3, &[]);
6520        assert!(
6521            review.contains("#1: t\n\nctx\n\nReview thoroughly"),
6522            "{review}"
6523        );
6524        let close = close_prompt(
6525            "main",
6526            1,
6527            "t",
6528            "\nctx\n",
6529            "",
6530            None,
6531            &Ledger::new(),
6532            &[],
6533            1,
6534            &[],
6535        );
6536        assert!(
6537            close.contains("#1: t\n\nctx\n\nThis is the final"),
6538            "{close}"
6539        );
6540        let body = pr_body(1, &Implementation::default(), None, &[], &style());
6541        assert!(!body.contains("Acceptance criteria"), "{body}");
6542    }
6543
6544    #[test]
6545    fn a_criterion_is_not_read_as_template_syntax() {
6546        let criteria = tracker::criteria("- [ ] Keep the literal {number} and {url}.\n");
6547        let implement = implement_prompt(42, "t", "u", "b", &criteria);
6548        assert!(
6549            implement.contains("1. Keep the literal {number} and {url}."),
6550            "{implement}"
6551        );
6552    }
6553
6554    /// Every placeholder the implement, review and closing templates use.
6555    const EVERY_PLACEHOLDER: &str = "{thread_rule} {number} {title} {url} {criteria} {body} \
6556                                     {base} {context} {landed} {open} {answers} {settled} {round}";
6557
6558    #[test]
6559    fn issue_text_naming_a_placeholder_reaches_every_prompt_as_written() {
6560        let title = format!("Title {EVERY_PLACEHOLDER}");
6561        let body = format!("Body {EVERY_PLACEHOLDER}");
6562        let criterion = format!("1. Criterion {EVERY_PLACEHOLDER}\n");
6563        let context = context_block(42, "u", &body, "");
6564        let ledger = Ledger::new();
6565        let with = tracker::criteria(&format!("- [ ] Criterion {EVERY_PLACEHOLDER}\n"));
6566        assert_eq!(1, with.len(), "{with:?}");
6567
6568        for criteria in [with, Vec::new()] {
6569            let implement = implement_prompt(42, &title, "u", &body, &criteria);
6570            // The last round and something landed, so the round note and the
6571            // landed block are both filled with text rather than nothing.
6572            let review = review_prompt("main", 42, &title, &context, &ledger, &[], 3, 3, &criteria);
6573            let close = close_prompt(
6574                "main",
6575                42,
6576                &title,
6577                &context,
6578                "abc1234",
6579                Some(&[]),
6580                &ledger,
6581                &[],
6582                3,
6583                &criteria,
6584            );
6585            for prompt in [&implement, &review, &close] {
6586                assert!(prompt.contains(&title), "{prompt}");
6587                assert!(prompt.contains(&body), "{prompt}");
6588                assert_eq!(
6589                    !criteria.is_empty(),
6590                    prompt.contains(&criterion),
6591                    "{prompt}"
6592                );
6593                let expected = if criteria.is_empty() { 2 } else { 3 };
6594                assert_eq!(
6595                    expected,
6596                    prompt.matches(EVERY_PLACEHOLDER).count(),
6597                    "{prompt}"
6598                );
6599            }
6600        }
6601    }
6602
6603    #[test]
6604    fn findings_and_context_naming_a_placeholder_reach_the_author_as_written() {
6605        let every = "{number} {context} {findings} {optional}";
6606        let context = format!("\nContext {every}\n");
6607        let findings = vec![
6608            finding(
6609                "blocking",
6610                &format!("Blocker {every}"),
6611                every,
6612                "src/a.rs",
6613                true,
6614            ),
6615            finding(
6616                "non-blocking",
6617                &format!("Optional {every}"),
6618                every,
6619                "src/b.rs",
6620                true,
6621            ),
6622        ];
6623        let prompt = respond_prompt(42, &context, &[findings[0].clone()], &findings);
6624        assert!(prompt.contains(&context), "{prompt}");
6625        assert!(prompt.contains(&format!("Blocker {every}")), "{prompt}");
6626        assert!(prompt.contains(&format!("Optional {every}")), "{prompt}");
6627        assert_eq!(5, prompt.matches(every).count(), "{prompt}");
6628    }
6629
6630    #[test]
6631    fn prompts_number_each_criterion_verbatim() {
6632        let criteria = tracker::criteria(CRITERIA_BODY);
6633        let implement = implement_prompt(1, "t", "u", "b", &criteria);
6634        let review = review_prompt("main", 1, "t", "", &Ledger::new(), &[], 1, 3, &criteria);
6635        let close = close_prompt(
6636            "main",
6637            1,
6638            "t",
6639            "",
6640            "",
6641            None,
6642            &Ledger::new(),
6643            &[],
6644            1,
6645            &criteria,
6646        );
6647        for prompt in [&implement, &review, &close] {
6648            assert!(
6649                prompt.contains("1. Preserve column order.\n2. Connects on a device\n"),
6650                "{prompt}"
6651            );
6652            assert!(!prompt.contains("Already done"), "{prompt}");
6653        }
6654        assert!(implement.contains("done, not_done, or needs_person"));
6655        assert!(review.contains("met when you checked"));
6656        assert!(close.contains("met when you checked"));
6657    }
6658
6659    #[test]
6660    fn the_pr_body_gives_the_authors_account_of_each_criterion() {
6661        let criteria = tracker::criteria(CRITERIA_BODY);
6662        let work = Implementation {
6663            summary: "Add CSV export.".into(),
6664            criteria: vec![status(1, Mark::Met, "the writer keeps the header order")],
6665            ..Implementation::default()
6666        };
6667        let body = pr_body(7, &work, None, &criteria, &style());
6668        assert!(
6669            body.contains("## Acceptance criteria\n\nThe author's account."),
6670            "{body}"
6671        );
6672        assert!(
6673            body.contains("- Done: Preserve column order. The writer keeps the header order."),
6674            "{body}"
6675        );
6676        assert!(
6677            body.contains("- No status given: Connects on a device."),
6678            "{body}"
6679        );
6680    }
6681
6682    #[test]
6683    fn only_a_criterion_ruled_met_by_every_answer_is_met() {
6684        let criteria = tracker::criteria(CRITERIA_BODY);
6685        let rulings = vec![
6686            status(1, Mark::Met, "checked"),
6687            status(2, Mark::Met, "looks right"),
6688            status(2, Mark::NeedsPerson, "needs a device"),
6689            status(9, Mark::Met, "no such criterion"),
6690        ];
6691        let ruled = ruled(&criteria, &rulings);
6692        assert_eq!(2, ruled.len());
6693        assert_eq!(Some(Mark::Met), ruled[0].status);
6694        assert_eq!("- [ ] Preserve column order.", ruled[0].raw);
6695        assert_eq!(Some(Mark::NeedsPerson), ruled[1].status);
6696        assert_eq!(None, super::ruled(&criteria, &[])[0].status);
6697    }
6698
6699    #[test]
6700    fn the_authors_claim_never_reaches_a_ruling() {
6701        let criteria = tracker::criteria(CRITERIA_BODY);
6702        // The implementor said done; the reviewer did not rule. Nothing is met.
6703        let ruled = ruled(&criteria, &[]);
6704        assert!(ruled.iter().all(|c| c.status != Some(Mark::Met)));
6705    }
6706
6707    fn approved_with(criteria: Vec<Ruled>, ticks: bool) -> Option<String> {
6708        let mut state = IssueRun::new(1, "t");
6709        state.criteria = criteria;
6710        state.ticks_criteria = ticks;
6711        outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style())
6712    }
6713
6714    fn ruled_one(text: &str, status: Option<Mark>, reason: &str) -> Ruled {
6715        Ruled {
6716            raw: format!("- [ ] {text}"),
6717            line: 1,
6718            text: text.into(),
6719            status,
6720            reason: reason.into(),
6721        }
6722    }
6723
6724    #[test]
6725    fn a_clean_approval_names_a_criterion_not_ruled_met() {
6726        let text = approved_with(
6727            vec![
6728                ruled_one("Preserve column order.", Some(Mark::Met), "checked"),
6729                ruled_one(
6730                    "Connects on a device",
6731                    Some(Mark::NeedsPerson),
6732                    "needs a phone",
6733                ),
6734            ],
6735            true,
6736        )
6737        .expect("a comment");
6738        assert!(
6739            text.contains("Needs a person: Connects on a device. Needs a phone."),
6740            "{text}"
6741        );
6742        assert!(!text.contains("Preserve column order"), "{text}");
6743        assert!(text.contains("left unchecked"), "{text}");
6744    }
6745
6746    #[test]
6747    fn a_clean_approval_with_every_criterion_met_stays_silent() {
6748        let text = approved_with(
6749            vec![ruled_one(
6750                "Preserve column order.",
6751                Some(Mark::Met),
6752                "checked",
6753            )],
6754            true,
6755        );
6756        assert_eq!(None, text);
6757    }
6758
6759    #[test]
6760    fn without_ticking_the_comment_lists_every_ruling() {
6761        let text = approved_with(
6762            vec![
6763                ruled_one("Preserve column order.", Some(Mark::Met), "checked"),
6764                ruled_one("Connects on a device", None, ""),
6765            ],
6766            false,
6767        )
6768        .expect("a comment");
6769        assert!(
6770            text.contains("- Met: Preserve column order. Checked."),
6771            "{text}"
6772        );
6773        assert!(
6774            text.contains("- Not ruled on: Connects on a device."),
6775            "{text}"
6776        );
6777        assert!(text.contains("Nothing was ticked"), "{text}");
6778    }
6779}
6780
6781#[cfg(test)]
6782mod outcome_tests {
6783    use super::*;
6784    use crate::model::{Dispute, Severity};
6785
6786    fn style() -> Style {
6787        Style::default()
6788    }
6789
6790    fn state_with(disputes: Vec<(&str, &str)>, filed: Vec<&str>) -> IssueRun {
6791        let mut s = IssueRun::new(482, "t");
6792        s.disputes = disputes
6793            .into_iter()
6794            .map(|(title, reasoning)| Dispute {
6795                title: title.into(),
6796                file: String::new(),
6797                reasoning: reasoning.into(),
6798            })
6799            .collect();
6800        s.filed = filed.into_iter().map(String::from).collect();
6801        s
6802    }
6803
6804    fn finding(title: &str, file: &str) -> Finding {
6805        Finding {
6806            severity: Severity::Blocking,
6807            title: title.into(),
6808            detail: "d".into(),
6809            file: file.into(),
6810            in_scope: true,
6811            ..Default::default()
6812        }
6813    }
6814
6815    fn graded(severity: Severity, title: &str, file: &str, in_scope: bool) -> Finding {
6816        Finding {
6817            severity,
6818            in_scope,
6819            ..finding(title, file)
6820        }
6821    }
6822
6823    #[test]
6824    fn outcome_mode_routes_final_results_to_the_configured_sink() {
6825        assert_eq!(OutcomeSink::PullRequest, outcome_sink(PrComments::Outcome));
6826        assert_eq!(OutcomeSink::PullRequest, outcome_sink(PrComments::Rounds));
6827        assert_eq!(OutcomeSink::Terminal, outcome_sink(PrComments::None));
6828    }
6829
6830    /// The absence of objections is the message. A PR that reviewed cleanly and
6831    /// filed nothing should leave no trace in the thread at all.
6832    #[test]
6833    fn a_clean_approval_says_nothing() {
6834        let state = state_with(vec![], vec![]);
6835        assert!(outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).is_none());
6836    }
6837
6838    /// Widening the ladder makes downgrading the easy answer, and under the
6839    /// defaults a non-blocking finding is filed nowhere and commented nowhere.
6840    /// Without this, a reviewer could make a real defect disappear by relabelling
6841    /// it, and the pull request would look exactly like a clean one.
6842    #[test]
6843    fn a_downgraded_finding_still_reaches_the_pull_request() {
6844        let mut state = state_with(vec![], vec![]);
6845        let kept = graded(
6846            Severity::NonBlocking,
6847            "Timeout is not configurable",
6848            "n.rs",
6849            true,
6850        );
6851        record_nonblocking_outcome(&mut state, &kept, None);
6852
6853        // A nit is taste, and an out of scope point is filed rather than noted.
6854        // Neither belongs in a list a person reads for what was let through.
6855        assert_eq!(1, state.noted.len());
6856
6857        let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
6858        assert!(text.contains("Noted, not blocking"), "{text}");
6859        assert!(
6860            text.contains("Timeout is not configurable (n.rs)"),
6861            "{text}"
6862        );
6863    }
6864
6865    /// With follow-ups on, every one of these is already an issue and already
6866    /// named under "Filed separately". Two headings for one point reads as two.
6867    #[test]
6868    fn a_point_that_was_filed_is_not_also_noted() {
6869        let mut state = state_with(vec![], vec![]);
6870        let finding = graded(Severity::NonBlocking, "Timeout", "n.rs", true);
6871        record_nonblocking_outcome(&mut state, &finding, None);
6872        record_nonblocking_outcome(
6873            &mut state,
6874            &finding,
6875            Some(&Followup::Recorded("https://example.invalid/9".into())),
6876        );
6877        assert!(state.noted.is_empty());
6878        assert_eq!(vec!["https://example.invalid/9"], state.filed);
6879    }
6880
6881    #[test]
6882    fn filing_a_moved_point_removes_its_earlier_note() {
6883        let mut state = state_with(vec![], vec![]);
6884        let earlier = graded(Severity::NonBlocking, "Timeout", "src/net.rs:10", true);
6885        let moved = graded(Severity::NonBlocking, "Timeout", "src/net.rs:12", false);
6886        record_nonblocking_outcome(&mut state, &earlier, None);
6887
6888        record_nonblocking_outcome(
6889            &mut state,
6890            &moved,
6891            Some(&Followup::Recorded("https://example.invalid/10".into())),
6892        );
6893
6894        assert!(state.noted.is_empty());
6895        assert_eq!(vec!["https://example.invalid/10"], state.filed);
6896    }
6897
6898    #[test]
6899    fn an_unrecorded_nonblocking_followup_remains_noted() {
6900        let finding = graded(Severity::NonBlocking, "Timeout", "n.rs", true);
6901        for outcome in [
6902            Followup::Covered("https://example.invalid/closed".into()),
6903            Followup::Dropped("follow-ups are off"),
6904            Followup::Failed,
6905        ] {
6906            let mut state = state_with(vec![], vec![]);
6907            record_nonblocking_outcome(&mut state, &finding, Some(&outcome));
6908            assert_eq!(1, state.noted.len(), "{outcome:?}");
6909            assert!(state.filed.is_empty(), "{outcome:?}");
6910        }
6911    }
6912
6913    /// The same point raised again in a later round is one point, not three.
6914    #[test]
6915    fn a_point_noted_twice_is_listed_once() {
6916        let mut state = state_with(vec![], vec![]);
6917        let raised = graded(
6918            Severity::NonBlocking,
6919            "Timeout is not configurable",
6920            "n.rs",
6921            true,
6922        );
6923        let reworded = graded(
6924            Severity::NonBlocking,
6925            "timeout is not configurable!",
6926            "n.rs",
6927            true,
6928        );
6929        record_nonblocking_outcome(&mut state, &raised, None);
6930        record_nonblocking_outcome(&mut state, &reworded, None);
6931        assert_eq!(1, state.noted.len());
6932    }
6933
6934    #[test]
6935    fn same_title_notes_in_different_files_are_both_kept() {
6936        let mut state = state_with(vec![], vec![]);
6937        for file in ["src/a.rs", "src/b.rs"] {
6938            let finding = graded(Severity::NonBlocking, "Unchecked error", file, true);
6939            record_nonblocking_outcome(&mut state, &finding, None);
6940        }
6941        assert_eq!(2, state.noted.len());
6942    }
6943
6944    #[test]
6945    fn same_title_notes_at_two_sites_in_one_review_are_both_kept() {
6946        let findings = vec![
6947            graded(
6948                Severity::NonBlocking,
6949                "Unchecked error",
6950                "src/a.rs:10",
6951                true,
6952            ),
6953            graded(
6954                Severity::NonBlocking,
6955                "Unchecked error",
6956                "src/a.rs:200",
6957                true,
6958            ),
6959        ];
6960        let mut state = state_with(vec![], vec![]);
6961
6962        for finding in &findings {
6963            record_nonblocking_outcome_with_match(
6964                &mut state,
6965                finding,
6966                None,
6967                unique_stable_finding(&findings, finding),
6968            );
6969        }
6970
6971        assert_eq!(2, state.noted.len());
6972    }
6973
6974    #[test]
6975    fn settling_one_of_two_same_title_notes_keeps_the_other() {
6976        let first = graded(
6977            Severity::NonBlocking,
6978            "Unchecked error",
6979            "src/a.rs:10",
6980            true,
6981        );
6982        let second = graded(
6983            Severity::NonBlocking,
6984            "Unchecked error",
6985            "src/a.rs:200",
6986            true,
6987        );
6988        let mut state = state_with(vec![], vec![]);
6989        remember_noted(&mut state, &first, false);
6990        remember_noted(&mut state, &second, false);
6991
6992        forget_noted(&mut state, &first, false);
6993
6994        assert_eq!(1, state.noted.len());
6995        assert_eq!("src/a.rs:200", state.noted[0].file);
6996    }
6997
6998    #[test]
6999    fn same_title_disputes_at_two_sites_are_both_kept() {
7000        let mut state = state_with(vec![], vec![]);
7001        for file in ["src/a.rs:10", "src/a.rs:200"] {
7002            remember_dispute(
7003                &mut state,
7004                Dispute {
7005                    title: "Unchecked error".into(),
7006                    file: file.into(),
7007                    reasoning: "the caller handles it".into(),
7008                },
7009                false,
7010            );
7011        }
7012
7013        assert_eq!(2, state.disputes.len());
7014    }
7015
7016    #[test]
7017    fn a_later_nonblocking_verdict_replaces_a_prior_dispute() {
7018        let mut state = state_with(vec![], vec![]);
7019        let finding = graded(Severity::NonBlocking, "Unchecked error", "src/a.rs", true);
7020        remember_dispute(
7021            &mut state,
7022            Dispute {
7023                title: finding.title.clone(),
7024                file: finding.file.clone(),
7025                reasoning: "the caller handles it".into(),
7026            },
7027            true,
7028        );
7029
7030        record_nonblocking_outcome(&mut state, &finding, None);
7031
7032        assert!(state.disputes.is_empty());
7033        assert_eq!(1, state.noted.len());
7034    }
7035
7036    #[test]
7037    fn a_note_moving_lines_in_the_same_file_is_updated() {
7038        let mut state = state_with(vec![], vec![]);
7039        let first = graded(
7040            Severity::NonBlocking,
7041            "Unchecked error",
7042            "src/a.rs:12",
7043            true,
7044        );
7045        let moved = graded(
7046            Severity::NonBlocking,
7047            "Unchecked error",
7048            "src/a.rs:19",
7049            true,
7050        );
7051        record_nonblocking_outcome(&mut state, &first, None);
7052        record_nonblocking_outcome(&mut state, &moved, None);
7053        assert_eq!(1, state.noted.len());
7054        assert_eq!("src/a.rs:19", state.noted[0].file);
7055    }
7056
7057    #[test]
7058    fn a_settled_point_removes_its_stale_note() {
7059        for outcome in [Settled::Fixed, Settled::Refuted, Settled::Filed] {
7060            let noted = graded(Severity::NonBlocking, "Unchecked error", "src/a.rs", true);
7061            let mut state = state_with(vec![], vec![]);
7062            record_nonblocking_outcome(&mut state, &noted, None);
7063            forget_noted(&mut state, &noted, true);
7064            assert!(state.noted.is_empty(), "{outcome}");
7065        }
7066    }
7067
7068    #[test]
7069    fn settling_a_moved_point_removes_its_old_dispute() {
7070        let old = graded(Severity::Blocking, "Unchecked error", "src/a.rs:10", true);
7071        let moved = graded(Severity::Blocking, "Unchecked error", "src/a.rs:12", true);
7072        let mut state = state_with(vec![], vec![]);
7073        remember_dispute(
7074            &mut state,
7075            Dispute {
7076                title: old.title,
7077                file: old.file,
7078                reasoning: "the caller handles it".into(),
7079            },
7080            true,
7081        );
7082
7083        forget_dispute(&mut state, &moved, true);
7084
7085        assert!(state.disputes.is_empty());
7086    }
7087
7088    /// A point already printed with its argument attached is not printed again
7089    /// under a second heading.
7090    #[test]
7091    fn a_deadlocked_point_is_not_also_noted() {
7092        let mut state = state_with(vec![], vec![]);
7093        let noted = graded(Severity::NonBlocking, "Unbounded loop", "x.rs", true);
7094        record_nonblocking_outcome(&mut state, &noted, None);
7095        let points = vec![finding("Unbounded loop", "x.rs")];
7096        let text = outcome_comment(
7097            &state,
7098            &Ledger::new(),
7099            &Ending::Deadlocked(&points),
7100            &style(),
7101        )
7102        .unwrap();
7103        assert!(!text.contains("Noted, not blocking"), "{text}");
7104    }
7105
7106    #[test]
7107    fn clipping_a_rendered_title_does_not_break_duplicate_suppression() {
7108        let mut compact = style();
7109        compact.max_title_chars = 5;
7110        let mut state = state_with(
7111            vec![("abcdefghij", "the caller already handles it")],
7112            vec![],
7113        );
7114        state
7115            .noted
7116            .push(graded(Severity::NonBlocking, "abcdefghij", "x.rs", true));
7117        state.disputes[0].file = "x.rs".into();
7118        let points = vec![finding("abcdefghij", "x.rs")];
7119
7120        let text = outcome_comment(
7121            &state,
7122            &Ledger::new(),
7123            &Ending::Unresolved(&points),
7124            &compact,
7125        )
7126        .unwrap();
7127
7128        assert!(!text.contains("Raised and refuted"), "{text}");
7129        assert!(!text.contains("Noted, not blocking"), "{text}");
7130    }
7131
7132    #[test]
7133    fn an_approval_that_filed_follow_ups_links_them() {
7134        let state = state_with(
7135            vec![],
7136            vec![
7137                "https://github.com/you/thing/issues/485",
7138                "https://github.com/you/thing/issues/486",
7139            ],
7140        );
7141        let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
7142        assert!(text.contains("Filed separately: #485, #486"), "{text}");
7143    }
7144
7145    /// The skip path is taken when nothing landed, so the sentence about fixes
7146    /// that were pushed and not read is false there. Sending a maintainer to
7147    /// read a commit that does not exist is worse than saying nothing.
7148    #[test]
7149    fn a_run_that_changed_nothing_does_not_claim_there_is_something_to_read() {
7150        let state = state_with(vec![], vec![]);
7151        let text = outcome_comment(&state, &Ledger::new(), &Ending::Unchanged, &style()).unwrap();
7152        assert!(text.contains("changed nothing"), "{text}");
7153        assert!(!text.contains("was pushed"), "{text}");
7154    }
7155
7156    /// Telling a maintainer that the last round was pushed and nobody read it
7157    /// gives them nothing they can act on. What is left, with where it is, is
7158    /// three lines and a decision.
7159    #[test]
7160    fn an_unresolved_close_names_what_is_still_wrong() {
7161        let state = state_with(vec![], vec![]);
7162        let left = vec![Finding {
7163            detail: "The guard sits after the early return.".into(),
7164            ..finding("The retry fix never reaches the 429 path", "src/net.rs:88")
7165        }];
7166        let text =
7167            outcome_comment(&state, &Ledger::new(), &Ending::Unresolved(&left), &style()).unwrap();
7168        assert!(text.contains("These points are still open"), "{text}");
7169        assert!(
7170            text.contains("The retry fix never reaches the 429 path (src/net.rs:88)"),
7171            "{text}"
7172        );
7173        assert!(
7174            text.contains("The guard sits after the early return."),
7175            "{text}"
7176        );
7177        // The sentence the budget used to end on, which is now only true when
7178        // the closing pass could not run at all.
7179        assert!(!text.contains("has not been reviewed"), "{text}");
7180    }
7181
7182    /// The real PR ended with "5 fixed" followed by "no convergence", which
7183    /// reads as a contradiction. What a maintainer needs is that the fixes went
7184    /// in and nobody checked them.
7185    #[test]
7186    fn running_out_of_rounds_says_what_that_means_for_the_reader() {
7187        let state = state_with(vec![], vec![]);
7188        let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
7189        assert!(text.contains("has not been reviewed"), "{text}");
7190        assert!(
7191            !text.to_lowercase().contains("round 3"),
7192            "no round numbers: {text}"
7193        );
7194        assert!(!text.to_lowercase().contains("convergence"), "{text}");
7195    }
7196
7197    #[test]
7198    fn a_failed_close_reports_unread_fixes_and_carried_blockers() {
7199        let state = state_with(vec![], vec![]);
7200        let open = vec![Finding {
7201            detail: "the failure is still discarded".into(),
7202            ..finding("Unchecked error", "src/net.rs:88")
7203        }];
7204        let text = outcome_comment_with_unread(
7205            &state,
7206            &Ledger::new(),
7207            &Ending::OutOfRounds,
7208            &open,
7209            &style(),
7210        )
7211        .unwrap();
7212        assert!(text.contains("has not been reviewed"), "{text}");
7213        assert!(text.contains("These points were already open"), "{text}");
7214        assert!(text.contains("Unchecked error (src/net.rs:88)"), "{text}");
7215    }
7216
7217    #[test]
7218    fn a_deadlock_names_the_point_they_could_not_settle() {
7219        let state = state_with(vec![], vec![]);
7220        let points = [finding("Retry loop never terminates", "src/net.rs:88")];
7221        let text = outcome_comment(
7222            &state,
7223            &Ledger::new(),
7224            &Ending::Deadlocked(&points),
7225            &style(),
7226        )
7227        .unwrap();
7228        assert!(
7229            text.contains("Retry loop never terminates (src/net.rs:88)"),
7230            "{text}"
7231        );
7232        assert!(text.contains("could not settle"), "{text}");
7233    }
7234
7235    /// The diff records what was fixed. Nothing records what was argued down.
7236    #[test]
7237    fn refutations_survive_because_nothing_else_carries_them() {
7238        let state = state_with(
7239            vec![(
7240                "Error is swallowed",
7241                "the caller already validates the file",
7242            )],
7243            vec![],
7244        );
7245        let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
7246        assert!(text.contains("Raised and refuted:"), "{text}");
7247        assert!(
7248            text.contains("The caller already validates the file"),
7249            "{text}"
7250        );
7251    }
7252
7253    #[test]
7254    fn no_agent_names_counts_or_round_numbers_reach_the_thread() {
7255        let state = state_with(
7256            vec![("A point", "a reason")],
7257            vec!["https://github.com/you/thing/issues/485"],
7258        );
7259        let left = vec![finding("A point", "a.rs")];
7260        for ending in [
7261            Ending::Approved,
7262            Ending::OutOfRounds,
7263            Ending::Unresolved(&left),
7264        ] {
7265            let text = outcome_comment(&state, &Ledger::new(), &ending, &style()).unwrap();
7266            let lower = text.to_lowercase();
7267            for banned in ["claude", "codex", "blocking,", "nit,", " fixed."] {
7268                assert!(
7269                    !lower.contains(banned),
7270                    "{banned:?} leaked into the thread:\n{text}"
7271                );
7272            }
7273            // "the last round of fixes" is prose. "round 3" is narration.
7274            for n in 1..9 {
7275                assert!(
7276                    !lower.contains(&format!("round {n}")),
7277                    "a round number leaked into the thread:\n{text}"
7278                );
7279            }
7280        }
7281    }
7282
7283    #[test]
7284    /// A refutation is an argument, and an argument that stops mid clause is
7285    /// not one. Bounded, but with room to make the case.
7286    fn a_refutation_is_allowed_to_make_its_case() {
7287        let reasoning = "The caller validates against the schema first. \
7288                         The discarded error is therefore unreachable in practice. ";
7289        let state = state_with(
7290            vec![("A point", &reasoning.repeat(6))],
7291            vec!["https://github.com/you/thing/issues/485"],
7292        );
7293        let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
7294        assert!(
7295            !text.contains("..."),
7296            "nothing was cut mid thought:\n{text}"
7297        );
7298        assert!(text.len() < 4000, "{} chars", text.len());
7299    }
7300
7301    #[test]
7302    fn a_url_that_is_not_an_issue_link_is_left_alone() {
7303        assert_eq!(
7304            "#485",
7305            as_reference("https://github.com/you/thing/issues/485")
7306        );
7307        assert_eq!("note: something", as_reference("note: something"));
7308    }
7309}
7310
7311#[cfg(test)]
7312mod filed_reference_tests {
7313    use super::*;
7314
7315    #[test]
7316    fn an_issue_url_yields_its_number() {
7317        assert_eq!(
7318            Some(485),
7319            filed_issue_number("https://github.com/you/thing/issues/485")
7320        );
7321    }
7322
7323    /// Local mode records a note rather than a URL, and a run with
7324    /// followups = "local" must not try to absorb it as an issue.
7325    #[test]
7326    fn a_local_note_yields_nothing() {
7327        assert_eq!(None, filed_issue_number("note: Retry is unbounded"));
7328        assert_eq!(None, filed_issue_number(""));
7329        assert_eq!(
7330            None,
7331            filed_issue_number("https://github.com/you/thing/issues/")
7332        );
7333    }
7334}
7335
7336#[cfg(test)]
7337mod followup_restraint_tests {
7338    use super::*;
7339    use crate::model::Severity;
7340
7341    fn cfg_with(followups: Followups, non_blocking: bool, nits: bool, cap: usize) -> Config {
7342        let mut cfg =
7343            crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
7344                .unwrap();
7345        cfg.loop_cfg.followups = followups;
7346        cfg.loop_cfg.file_non_blocking = non_blocking;
7347        cfg.loop_cfg.file_nits = nits;
7348        cfg.loop_cfg.max_followups = cap;
7349        cfg
7350    }
7351
7352    fn finding(severity: Severity, title: &str, in_scope: bool) -> Finding {
7353        Finding {
7354            severity,
7355            title: title.into(),
7356            detail: "d".into(),
7357            file: "a.rs".into(),
7358            in_scope,
7359            ..Default::default()
7360        }
7361    }
7362
7363    /// The defaults are what let one issue spawn ten, which spawned more. A
7364    /// thorough reviewer always finds improvements; not gating a merge is not
7365    /// the same as deserving somebody's triage queue.
7366    #[test]
7367    fn a_non_blocking_finding_is_not_a_tracker_item_by_default() {
7368        let cfg = cfg_with(Followups::Issues, false, false, 5);
7369        assert!(!cfg.loop_cfg.file_non_blocking);
7370        assert!(!cfg.loop_cfg.file_nits);
7371    }
7372
7373    #[test]
7374    fn follow_ups_stay_off_the_tracker_by_default() {
7375        let cfg =
7376            crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
7377                .unwrap();
7378        assert_eq!(
7379            Followups::Local,
7380            cfg.loop_cfg.followups,
7381            "the tracker is somebody's queue; the default must not write to it"
7382        );
7383        assert_eq!(5, cfg.loop_cfg.max_followups);
7384    }
7385
7386    /// Which severities survive the filter, at the defaults and when opened up.
7387    #[test]
7388    fn only_out_of_scope_defects_qualify_at_the_defaults() {
7389        let cfg = cfg_with(Followups::Issues, false, false, 5);
7390        let qualifies = |f: &Finding| match f.severity {
7391            Severity::NonBlocking => cfg.loop_cfg.file_non_blocking && f.in_scope,
7392            Severity::Nit => cfg.loop_cfg.file_nits && f.in_scope,
7393            Severity::Blocking => false,
7394        } || !f.in_scope;
7395
7396        assert!(qualifies(&finding(
7397            Severity::Blocking,
7398            "pre-existing",
7399            false
7400        )));
7401        assert!(!qualifies(&finding(
7402            Severity::NonBlocking,
7403            "improvement",
7404            true
7405        )));
7406        assert!(!qualifies(&finding(Severity::Nit, "taste", true)));
7407        assert!(!qualifies(&finding(
7408            Severity::Blocking,
7409            "fix it here",
7410            true
7411        )));
7412    }
7413
7414    #[test]
7415    fn opening_it_up_lets_non_blocking_findings_through_again() {
7416        let cfg = cfg_with(Followups::Issues, true, false, 5);
7417        assert!(cfg.loop_cfg.file_non_blocking);
7418    }
7419
7420    /// A run that will not stop finding things is stopped, and says so.
7421    #[test]
7422    fn the_cap_is_a_real_backstop() {
7423        let cfg = cfg_with(Followups::Issues, false, false, 3);
7424        let mut state = IssueRun::new(1, "t");
7425        state.filed = (0..3).map(|n| format!("url{n}")).collect();
7426        assert!(state.filed.len() >= cfg.loop_cfg.max_followups);
7427    }
7428
7429    /// The number that matters. Reviewing one issue produced ten follow-ups on
7430    /// a real repository, each of which could be run in turn: mean offspring
7431    /// above one never terminates.
7432    #[test]
7433    fn the_cap_bounds_what_one_run_can_spawn() {
7434        let cfg = cfg_with(Followups::Issues, false, false, 5);
7435        assert!(
7436            cfg.loop_cfg.max_followups <= 5,
7437            "a run that can file ten follow-ups is a branching process"
7438        );
7439    }
7440}
7441
7442/// What the ledger is told about a point the author moved out of the pull
7443/// request. Every case here used to record "filed", including the ones where
7444/// nothing was written anywhere.
7445#[cfg(test)]
7446mod followup_outcome_tests {
7447    use super::*;
7448
7449    const URL: &str = "https://github.com/you/thing/issues/485";
7450
7451    fn entry(recorded: Followup) -> Option<(Settled, String)> {
7452        filed_entry(&recorded, "It predates this branch.")
7453    }
7454
7455    /// The bug. A tracker request or a local write that failed left no
7456    /// follow-up, and the ledger said it had been filed, which is a claim that
7457    /// survives every later round and every resume.
7458    #[test]
7459    fn a_failed_followup_settles_nothing() {
7460        assert_eq!(None, entry(Followup::Failed));
7461    }
7462
7463    #[test]
7464    fn an_uncertain_external_write_blocks_later_issue_followups_for_this_run() {
7465        let mut state = IssueRun::new(1, "review");
7466        let uncertain = SparError::uncertain_write("the result could not be verified");
7467        assert_eq!(Followup::Failed, failed_followup(&mut state, &uncertain));
7468        assert!(external_followup_write_paused(Followups::Issues, &state));
7469        assert!(!external_followup_write_paused(Followups::Local, &state));
7470        assert_eq!(1, state.notes.len());
7471
7472        assert_eq!(Followup::Failed, failed_followup(&mut state, &uncertain));
7473        assert_eq!(1, state.notes.len(), "the recovery note was duplicated");
7474
7475        let mut ordinary = IssueRun::new(2, "review");
7476        let error = SparError::new("permission denied");
7477        assert_eq!(Followup::Failed, failed_followup(&mut ordinary, &error));
7478        assert!(!external_followup_write_paused(
7479            Followups::Issues,
7480            &ordinary
7481        ));
7482    }
7483
7484    #[test]
7485    fn a_recorded_followup_is_filed_and_says_where() {
7486        let (outcome, reasoning) = entry(Followup::Recorded(URL.into())).unwrap();
7487        assert_eq!(Settled::Filed, outcome);
7488        assert!(
7489            reasoning.contains("It predates this branch."),
7490            "{reasoning}"
7491        );
7492        assert!(reasoning.contains("#485"), "{reasoning}");
7493    }
7494
7495    /// A finding that matches a closed issue is a defect that came back, and
7496    /// it used to leave the run with no record anywhere.
7497    ///
7498    /// `Filed::AlreadyClosed` wrote nothing and reported no url, so the finding
7499    /// appeared in no issue, no queue and no wave, while the ledger recorded
7500    /// the point as filed and told the next reviewer not to raise it again. The
7501    /// similarity search reached every issue ever filed, so a fixed and closed
7502    /// defect that regressed was the case that hit it.
7503    #[test]
7504    fn a_finding_that_matches_a_closed_issue_is_filed_again_with_the_reference() {
7505        let body = regression_body("The retry loop spins on 429.", 485);
7506        assert!(body.contains("The retry loop spins on 429."), "{body}");
7507        assert!(body.contains("#485"), "{body}");
7508        assert!(body.contains("closed"), "{body}");
7509
7510        // And what it produces is ordinary work, with a url to pick up.
7511        let recorded = Followup::from(Filed::Opened(9, URL.into()));
7512        assert_eq!(Followup::Recorded(URL.into()), recorded);
7513        assert_eq!(Some(URL), recorded.url());
7514    }
7515
7516    /// An open issue that already covers the point is worth linking from the
7517    /// pull request, and worth counting against the cap.
7518    #[test]
7519    fn an_open_issue_that_already_covers_the_point_is_still_a_reference() {
7520        for filed in [
7521            Filed::Opened(9, URL.into()),
7522            Filed::AddedTo(9, URL.into()),
7523            Filed::Covered(9, URL.into()),
7524        ] {
7525            assert_eq!(Some(URL), Followup::from(filed).url());
7526        }
7527    }
7528
7529    /// Configuration, not failure: retrying it every round would spend the
7530    /// budget on a write that is never going to happen. The entry has to be
7531    /// honest about it, because nothing else holds the point.
7532    #[test]
7533    fn a_dropped_followup_is_settled_but_never_reported_as_filed() {
7534        let (outcome, reasoning) = entry(Followup::Dropped("follow-ups are off")).unwrap();
7535        assert_eq!(Settled::Dropped, outcome);
7536        assert!(reasoning.contains("follow-ups are off"), "{reasoning}");
7537        assert!(reasoning.contains("Not filed"), "{reasoning}");
7538    }
7539
7540    fn ledger_of(outcome: Settled, reasoning: &str) -> Ledger {
7541        let mut ledger = Ledger::new();
7542        ledger.insert(
7543            finding_key("A pre-existing leak", "src/x.rs"),
7544            LedgerEntry {
7545                title: "A pre-existing leak".into(),
7546                file: "src/x.rs".into(),
7547                reasoning: reasoning.into(),
7548                round: 1,
7549                reraised: 0,
7550                outcome,
7551            },
7552        );
7553        ledger
7554    }
7555
7556    /// The next reviewer is told to leave settled points alone either way, so
7557    /// the wording is all that separates them. Saying "filed" of a point
7558    /// nothing holds is the lie that loses it.
7559    #[test]
7560    fn the_settled_block_tells_a_filed_point_from_a_dropped_one() {
7561        let filed = settled_block(&ledger_of(Settled::Filed, "Tracked in #9."));
7562        assert!(filed.contains("out of scope here, and filed"), "{filed}");
7563
7564        let dropped = settled_block(&ledger_of(Settled::Dropped, "Not filed anywhere: off."));
7565        assert!(
7566            dropped.contains("out of scope here, and not filed"),
7567            "{dropped}"
7568        );
7569        assert!(dropped.contains("A pre-existing leak"), "{dropped}");
7570    }
7571
7572    /// A deadlock goes to a person, and the first thing they do is look for the
7573    /// issue the comment says exists.
7574    #[test]
7575    fn a_deadlocked_point_that_was_never_filed_does_not_claim_to_be() {
7576        let points = [Finding {
7577            severity: Severity::Blocking,
7578            title: "A pre-existing leak".into(),
7579            detail: "d".into(),
7580            file: "src/x.rs".into(),
7581            in_scope: false,
7582            ..Default::default()
7583        }];
7584        let text = outcome_comment(
7585            &IssueRun::new(1, "t"),
7586            &ledger_of(Settled::Dropped, "Not filed anywhere: follow-ups are off."),
7587            &Ending::Deadlocked(&points),
7588            &Style::default(),
7589        )
7590        .unwrap();
7591        assert!(text.contains("not filed"), "{text}");
7592        assert!(!text.contains("Filed as out of scope"), "{text}");
7593    }
7594}
7595
7596#[cfg(test)]
7597mod issue_report_tests {
7598    use super::*;
7599    use crate::model::Severity;
7600
7601    /// Shaped after a bug report written by hand that reads the way one should:
7602    /// what is wrong, how to see it, what it costs, what it should do instead.
7603    fn reported() -> Finding {
7604        Finding {
7605            severity: Severity::Blocking,
7606            title: "sendPaymentAsync bypasses drain mode and spending limits".into(),
7607            detail: "The async path skips every admission check payInvoice applies.".into(),
7608            file: "src/node.ts:412".into(),
7609            in_scope: false,
7610            problem: Some(
7611                "`BeignetNode.sendPaymentAsync()` submits a payment directly to the Lightning \
7612                 engine without applying the safeguards used by `payInvoice()`.\n\nThe async path \
7613                 does not:\n\n- call `_checkDraining()`\n- call `_checkSpendLimit()`"
7614                    .into(),
7615            ),
7616            reproduction: Some(
7617                "1. Create a `BeignetNode` with `dailySpendLimitSats: 1`.\n2. Enable drain mode.\n\
7618                 3. Submit a 1,000 sat invoice.\n\nActual result:\n\n- The engine is called.\n\
7619                 - `spentSats` remains 0."
7620                    .into(),
7621            ),
7622            impact: Some(
7623                "An authorized client can submit async payments up to the available outbound \
7624                 liquidity despite the configured limits."
7625                    .into(),
7626            ),
7627            expected: Some(
7628                "- Reject new payments while draining.\n- Enforce the per-payment limit before \
7629                 submission.\n- Cover both paths with regression tests.\n\nThis predates the \
7630                 current branch."
7631                    .into(),
7632            ),
7633        }
7634    }
7635
7636    #[test]
7637    fn a_reported_finding_becomes_a_bug_report() {
7638        let body = issue_report(&reported());
7639        for heading in [
7640            "## Problem",
7641            "## Reproduction",
7642            "## Impact",
7643            "## Expected behavior",
7644        ] {
7645            assert!(body.contains(heading), "missing {heading}:\n{body}");
7646        }
7647        // In the order somebody reads a bug report.
7648        let at = |h: &str| body.find(h).unwrap();
7649        assert!(at("## Problem") < at("## Reproduction"));
7650        assert!(at("## Reproduction") < at("## Impact"));
7651        assert!(at("## Impact") < at("## Expected behavior"));
7652    }
7653
7654    #[test]
7655    fn the_substance_survives_the_outbound_gates() {
7656        let repo_style = Style::default();
7657        let body = crate::style::issue_body(&issue_report(&reported()), &repo_style);
7658        for kept in [
7659            "_checkDraining()",
7660            "Actual result:",
7661            "outbound liquidity",
7662            "regression tests",
7663            "predates the current branch",
7664        ] {
7665            assert!(body.contains(kept), "the gate ate {kept:?}:\n{body}");
7666        }
7667        assert!(!body.contains("..."), "something was cut:\n{body}");
7668    }
7669
7670    /// A finding that was never going to be filed carries none of this, and
7671    /// must not gain empty headings for the sake of a format.
7672    #[test]
7673    fn an_ordinary_finding_is_still_just_its_detail() {
7674        let plain = Finding {
7675            severity: Severity::NonBlocking,
7676            title: "Name is vague".into(),
7677            detail: "The variable could say what it holds.".into(),
7678            file: "a.rs".into(),
7679            in_scope: true,
7680            ..Default::default()
7681        };
7682        assert_eq!(
7683            "The variable could say what it holds.",
7684            issue_report(&plain)
7685        );
7686    }
7687
7688    /// Partial reports are normal: a defect with no useful reproduction should
7689    /// not sprout an empty Reproduction heading.
7690    #[test]
7691    fn only_the_sections_that_were_written_appear() {
7692        let partial = Finding {
7693            problem: Some("The guard is inverted.".into()),
7694            expected: Some("It should reject rather than accept.".into()),
7695            ..reported()
7696        };
7697        let partial = Finding {
7698            reproduction: None,
7699            impact: None,
7700            ..partial
7701        };
7702        let body = issue_report(&partial);
7703        assert!(body.contains("## Problem") && body.contains("## Expected behavior"));
7704        assert!(!body.contains("## Reproduction"), "{body}");
7705        assert!(!body.contains("## Impact"), "{body}");
7706    }
7707
7708    /// The one line the thread shows is not repeated when a section already
7709    /// says it.
7710    #[test]
7711    fn the_summary_line_is_not_printed_twice() {
7712        let echoed = Finding {
7713            detail: "The guard is inverted so it rejects valid input.".into(),
7714            problem: Some("The guard is inverted so it rejects valid input.".into()),
7715            reproduction: None,
7716            impact: None,
7717            expected: None,
7718            ..reported()
7719        };
7720        let body = issue_report(&echoed);
7721        assert_eq!(1, body.matches("The guard is inverted").count(), "{body}");
7722    }
7723}