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