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