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