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