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/// Whether two titles name the same point, ignoring wording noise.
2274pub(crate) fn same_point(a: &str, b: &str) -> bool {
2275    normalise(a) == normalise(b)
2276}
2277
2278pub(crate) fn same_finding(a: &Finding, b: &Finding) -> bool {
2279    same_finding_parts(&a.title, &a.file, &b.title, &b.file)
2280}
2281
2282pub(crate) fn same_finding_parts(a_title: &str, a_file: &str, b_title: &str, b_file: &str) -> bool {
2283    finding_key(a_title, a_file) == finding_key(b_title, b_file)
2284}
2285
2286fn disposition_matches(finding: &Finding, disposition: &Disposition) -> bool {
2287    same_point(&finding.title, &disposition.title) && finding.file.trim() == disposition.file.trim()
2288}
2289
2290fn stable_disposition_matches(finding: &Finding, disposition: &Disposition) -> bool {
2291    stable_finding_key(&finding.title, &finding.file)
2292        == stable_finding_key(&disposition.title, &disposition.file)
2293}
2294
2295fn sole_disposition_match<'a>(
2296    finding: &Finding,
2297    dispositions: &'a [Disposition],
2298    matches: impl Fn(&Finding, &Disposition) -> bool,
2299) -> std::result::Result<Option<(usize, &'a Disposition)>, &'static str> {
2300    let mut matched = dispositions
2301        .iter()
2302        .enumerate()
2303        .filter(|(_, disposition)| matches(finding, disposition));
2304    let first = matched.next();
2305    if matched.next().is_some() {
2306        return Err("more than one matching disposition");
2307    }
2308    Ok(first)
2309}
2310
2311/// Match a disposition back to the finding it answers, so the ledger key it
2312/// records is the same key the next round's finding will hash to. Exact
2313/// locations lead. A line-tolerant match is safe only when the finding and its
2314/// disposition are each unique at that stable repository path.
2315fn matching_disposition<'a>(
2316    finding: &Finding,
2317    findings: &[Finding],
2318    dispositions: &'a [Disposition],
2319) -> std::result::Result<(usize, &'a Disposition), &'static str> {
2320    if let Some(exact) = sole_disposition_match(finding, dispositions, disposition_matches)? {
2321        return Ok(exact);
2322    }
2323    if unique_stable_finding(findings, finding) {
2324        if let Some(stable) =
2325            sole_disposition_match(finding, dispositions, stable_disposition_matches)?
2326        {
2327            return Ok(stable);
2328        }
2329    }
2330    Err("no matching disposition")
2331}
2332
2333fn fixed_disposition_resolves(committed: bool) -> bool {
2334    committed
2335}
2336
2337#[allow(clippy::too_many_arguments)]
2338fn apply_dispositions(
2339    repo: &Repo,
2340    cfg: &Config,
2341    response: &ResponseDoc,
2342    blocking: &[Finding],
2343    ledger: &mut Ledger,
2344    state: &mut IssueRun,
2345    round: u32,
2346    subject: i64,
2347    pr_number: i64,
2348    author: &str,
2349    committed: bool,
2350) -> Vec<Finding> {
2351    let mut fixed = Vec::new();
2352    let mut refuted = Vec::new();
2353    let mut filed = Vec::new();
2354    let mut unresolved = Vec::new();
2355    let mut used = vec![false; response.dispositions.len()];
2356
2357    for source in blocking {
2358        let (index, d) = match matching_disposition(source, blocking, &response.dispositions) {
2359            Ok((index, disposition)) if !used[index] => (index, disposition),
2360            Ok(_) => {
2361                logwarn!(
2362                    "'{}' has more than one matching disposition, so it stays open",
2363                    source.title
2364                );
2365                unresolved.push(source.clone());
2366                continue;
2367            }
2368            Err(reason) => {
2369                logwarn!("'{}' has {reason}, so it stays open", source.title);
2370                unresolved.push(source.clone());
2371                continue;
2372            }
2373        };
2374        used[index] = true;
2375        let file = source.file.clone();
2376        // Hash the reviewer's wording, not the author's. The response may vary
2377        // punctuation while still matching the point, and the next round must
2378        // look up the same identity the review created.
2379        let canonical = source.title.as_str();
2380        let title = style::title(canonical, &repo.style);
2381        let located_title = match file.trim() {
2382            "" => title.clone(),
2383            location => format!("{title} ({location})"),
2384        };
2385        let allow_stable_fallback = unique_stable_finding(blocking, source);
2386
2387        match d.action {
2388            Action::Refuted => {
2389                let reasoning = style::summary(&d.reasoning, &repo.style);
2390                settle(
2391                    ledger,
2392                    canonical,
2393                    &file,
2394                    allow_stable_fallback,
2395                    LedgerEntry {
2396                        title: canonical.to_string(),
2397                        file: file.clone(),
2398                        reasoning: reasoning.clone(),
2399                        round,
2400                        reraised: 0,
2401                        outcome: Settled::Refuted,
2402                    },
2403                );
2404                remember_dispute(
2405                    state,
2406                    Dispute {
2407                        title: canonical.to_string(),
2408                        file: file.clone(),
2409                        reasoning: reasoning.clone(),
2410                    },
2411                    allow_stable_fallback,
2412                );
2413                forget_noted(state, source, allow_stable_fallback);
2414                refuted.push(format!("{located_title}. {reasoning}"));
2415            }
2416            Action::FiledIssue => {
2417                let new_title = d
2418                    .new_issue_title
2419                    .clone()
2420                    .filter(|t| !t.trim().is_empty())
2421                    .unwrap_or_else(|| d.title.clone());
2422                let new_body = d
2423                    .new_issue_body
2424                    .clone()
2425                    .filter(|b| !b.trim().is_empty())
2426                    .unwrap_or_else(|| d.reasoning.clone());
2427                let recorded = file_followup(repo, &new_title, &new_body, subject, cfg, state);
2428                if let Some(url) = recorded.url() {
2429                    state.filed.push(url.to_string());
2430                    filed.push(url.to_string());
2431                }
2432                // Settled like a refutation, because it ends the same way: the
2433                // code will not change for this point on this branch. Without
2434                // the entry the reviewer keeping the PR raises it again next
2435                // round, the author files a duplicate, and the round budget
2436                // goes on one point nobody disagrees about.
2437                //
2438                // Unless nothing holds the point, in which case there is no
2439                // entry to write: see `filed_entry`.
2440                let Some((outcome, reasoning)) =
2441                    filed_entry(&recorded, &style::summary(&d.reasoning, &repo.style))
2442                else {
2443                    logwarn!(
2444                        "'{title}' was not recorded anywhere, so it stays open for the next round"
2445                    );
2446                    unresolved.push(source.clone());
2447                    continue;
2448                };
2449                settle(
2450                    ledger,
2451                    canonical,
2452                    &file,
2453                    allow_stable_fallback,
2454                    LedgerEntry {
2455                        title: canonical.to_string(),
2456                        file: file.clone(),
2457                        reasoning,
2458                        round,
2459                        reraised: 0,
2460                        outcome,
2461                    },
2462                );
2463                if outcome == Settled::Dropped {
2464                    remember_noted(state, source, allow_stable_fallback);
2465                } else {
2466                    forget_noted(state, source, allow_stable_fallback);
2467                }
2468                forget_dispute(state, source, allow_stable_fallback);
2469            }
2470            Action::Fixed => {
2471                // Recorded like every other disposition, on the reviewer's own
2472                // wording, so a re-raise next round hashes to this entry.
2473                //
2474                // Fixing is what most dispositions are, and it was the one that
2475                // left nothing behind. The next round met the fix as ordinary
2476                // code with no sign anybody had asked for it, and the guard that
2477                // ends an argument had only refutations to match, so across six
2478                // fix rounds on two pull requests it never fired once.
2479                //
2480                // Only when something was actually committed, on the same rule
2481                // `filed_entry` keeps for a follow-up that failed: an entry says
2482                // the point was dealt with and it outlives the run, so writing
2483                // one for a fix that does not exist tells every later pass to
2484                // check code nobody wrote.
2485                if fixed_disposition_resolves(committed) {
2486                    settle(
2487                        ledger,
2488                        canonical,
2489                        &file,
2490                        allow_stable_fallback,
2491                        LedgerEntry {
2492                            title: canonical.to_string(),
2493                            file: file.clone(),
2494                            reasoning: style::summary(&d.reasoning, &repo.style),
2495                            round,
2496                            reraised: 0,
2497                            outcome: Settled::Fixed,
2498                        },
2499                    );
2500                    forget_noted(state, source, allow_stable_fallback);
2501                    forget_dispute(state, source, allow_stable_fallback);
2502                    fixed.push(located_title);
2503                } else {
2504                    unresolved.push(source.clone());
2505                }
2506            }
2507        }
2508    }
2509
2510    for (index, disposition) in response.dispositions.iter().enumerate() {
2511        if !used[index] {
2512            logwarn!(
2513                "ignoring an unmatched or duplicate disposition for '{}' ({})",
2514                disposition.title,
2515                disposition.file
2516            );
2517        }
2518    }
2519
2520    if repo.style.pr_comments == PrComments::Rounds {
2521        let comment = disposition_comment(author, response, &fixed, &refuted, &filed, &repo.style);
2522        if let Some(text) = comment {
2523            if let Err(e) = repo.comment_pr(pr_number, &text) {
2524                logdim!("could not post the disposition comment: {e}");
2525            }
2526        }
2527    }
2528    unresolved
2529}
2530
2531/// What the ledger should say about a point the author moved out of this pull
2532/// request, and whether it should say anything at all.
2533///
2534/// Nothing, for a follow-up that failed. An entry tells every later round the
2535/// point was dealt with, and it outlives the run: recording one for a write
2536/// that never happened suppresses a real defect for good, on the strength of a
2537/// transient error.
2538fn filed_entry(recorded: &Followup, reasoning: &str) -> Option<(Settled, String)> {
2539    let (outcome, tail) = match recorded {
2540        Followup::Recorded(reference) => (
2541            Settled::Filed,
2542            format!("Tracked in {}.", as_reference(reference)),
2543        ),
2544        Followup::Covered(reference) => (
2545            Settled::Filed,
2546            format!("Already covered by {}.", as_reference(reference)),
2547        ),
2548        Followup::Dropped(why) => (Settled::Dropped, format!("Not filed anywhere: {why}.")),
2549        Followup::Failed => return None,
2550    };
2551    let reasoning = match reasoning.trim() {
2552        "" => tail,
2553        said => format!("{said} {tail}"),
2554    };
2555    Some((outcome, reasoning))
2556}
2557
2558// ---------------------------------------------------------------------------
2559// Follow-ups
2560// ---------------------------------------------------------------------------
2561
2562// One uncertain external write stops the rest for this process. A later run
2563// performs exact and similarity prechecks before it writes again.
2564fn external_followup_write_paused(destination: Followups, state: &IssueRun) -> bool {
2565    destination == Followups::Issues && state.followup_writes_uncertain
2566}
2567
2568fn failed_followup(state: &mut IssueRun, error: &SparError) -> Followup {
2569    if error.kind() == ErrorKind::UncertainWrite {
2570        state.followup_writes_uncertain = true;
2571        if !state
2572            .notes
2573            .iter()
2574            .any(|note| note.contains("external follow-up writes were paused"))
2575        {
2576            state.notes.push(
2577                "An external follow-up write could not be verified, so further external \
2578                 follow-up writes were paused for this run. Inspect recent issues and comments \
2579                 before trying them again."
2580                    .to_string(),
2581            );
2582        }
2583    }
2584    Followup::Failed
2585}
2586
2587/// Record a finding that is real but out of scope for this PR.
2588///
2589/// On your own repository an issue is the right home. On a large repository
2590/// that is not yours it is somebody else's notification and somebody else's
2591/// triage queue, so `local` keeps the same information in `.spar/followups.md`
2592/// and `none` drops it.
2593///
2594/// The answer says which of those happened, because the caller settles the
2595/// point on it. A failure and a deliberate drop look identical from the outside
2596/// and mean opposite things to the next round.
2597pub fn file_followup(
2598    repo: &Repo,
2599    title: &str,
2600    body: &str,
2601    source: i64,
2602    cfg: &Config,
2603    state: &mut IssueRun,
2604) -> Followup {
2605    if repo.followups == Followups::None {
2606        return Followup::Dropped("follow-ups are off for this repository");
2607    }
2608    if external_followup_write_paused(repo.followups, state) {
2609        logdim!(
2610            "not attempting another external follow-up write after an earlier result could not \
2611             be verified"
2612        );
2613        return Followup::Failed;
2614    }
2615    // A backstop against a run that will not stop finding things. Silent
2616    // truncation is not on offer: what was dropped is said out loud.
2617    if state.filed.len() >= cfg.loop_cfg.max_followups {
2618        logwarn!(
2619            "already recorded {} follow-ups, not recording '{}'. Raise max_followups if you want \
2620             them all.",
2621            state.filed.len(),
2622            style::title(title, &repo.style)
2623        );
2624        return Followup::Dropped("this run had already recorded as many follow-ups as it may");
2625    }
2626    // The exact string that will land on GitHub. Searching for anything else
2627    // means the duplicate check can never hit, and every round files another
2628    // copy of the same follow-up.
2629    //
2630    // A title the style gate cannot clean is a failure rather than a drop: the
2631    // next round words the point differently, and that wording may pass.
2632    let title = match repo.clean_followup_title(title) {
2633        Ok(title) => title,
2634        Err(e) => {
2635            logdim!("could not clean a follow-up title: {e}");
2636            return Followup::Failed;
2637        }
2638    };
2639    if title.trim().is_empty() {
2640        logdim!("nothing left of a follow-up title after cleaning it");
2641        return Followup::Failed;
2642    }
2643    // Not style::body: that is the budget for a pull request comment, read with
2644    // the diff in front of you. This is a work item somebody picks up cold.
2645    let body = format!(
2646        "{}\n\nFound while working on #{source}.",
2647        style::issue_body(body, &repo.style)
2648    );
2649
2650    if repo.followups == Followups::Local {
2651        return repo.append_local_followup(&title, &body);
2652    }
2653
2654    match file_as_issue(repo, &title, &body) {
2655        Ok(filed) => filed.into(),
2656        Err(e) => {
2657            logdim!("could not file a follow-up for '{title}': {e}");
2658            failed_followup(state, &e)
2659        }
2660    }
2661}
2662
2663/// What happened to one finding on the way to the tracker.
2664#[derive(Debug, Clone)]
2665pub enum Filed {
2666    /// A new issue.
2667    Opened(i64, String),
2668    /// An open issue already covered it, and this pass had something to add.
2669    AddedTo(i64, String),
2670    /// An open issue already covered it, and this pass added nothing.
2671    Covered(i64, String),
2672    /// A closed issue already covered it. Nothing was written.
2673    AlreadyClosed(i64, String),
2674}
2675
2676impl From<Filed> for Followup {
2677    fn from(filed: Filed) -> Self {
2678        match filed {
2679            Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => {
2680                Followup::Recorded(url)
2681            }
2682            // Covered rather than recorded: the point is genuinely tracked, so
2683            // raising it again is waste, but the issue holding it is closed and
2684            // must not be handed out as work.
2685            Filed::AlreadyClosed(_, url) => Followup::Covered(url),
2686        }
2687    }
2688}
2689
2690impl Filed {
2691    pub fn url(&self) -> Option<&str> {
2692        match self {
2693            Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => Some(url),
2694            // The work is done and closed. Reporting it as filed would put it
2695            // back into a wave to be implemented again.
2696            Filed::AlreadyClosed(_, _) => None,
2697        }
2698    }
2699
2700    /// The issue this went to, whatever state it is in. `number` answers the
2701    /// narrower question of what there is to work.
2702    pub fn issue(&self) -> i64 {
2703        match self {
2704            Filed::Opened(n, _)
2705            | Filed::AddedTo(n, _)
2706            | Filed::Covered(n, _)
2707            | Filed::AlreadyClosed(n, _) => *n,
2708        }
2709    }
2710
2711    /// The issue to work, when there is one to work.
2712    pub fn number(&self) -> Option<i64> {
2713        match self {
2714            Filed::Opened(n, _) | Filed::AddedTo(n, _) | Filed::Covered(n, _) => Some(*n),
2715            Filed::AlreadyClosed(_, _) => None,
2716        }
2717    }
2718
2719    /// One clause saying where it went, for a log line or an archive entry.
2720    pub fn note(&self) -> String {
2721        match self {
2722            Filed::Opened(n, _) => format!("#{n}"),
2723            Filed::AddedTo(n, _) => format!("added to #{n}"),
2724            Filed::Covered(n, _) => format!("#{n} already says this"),
2725            Filed::AlreadyClosed(n, _) => format!("#{n} covers it and is closed"),
2726        }
2727    }
2728
2729    pub fn describe(&self, title: &str) -> String {
2730        let title = style::clip(title.trim(), 80);
2731        match self {
2732            Filed::Opened(n, _) => format!("filed #{n}: {title}"),
2733            Filed::AddedTo(n, _) => format!("added to #{n}: {title}"),
2734            Filed::Covered(n, _) => format!("#{n} already says this: {title}"),
2735            Filed::AlreadyClosed(n, _) => format!("#{n} covers it and is closed: {title}"),
2736        }
2737    }
2738}
2739
2740/// File an issue, or add to the one that already covers it.
2741///
2742/// Exact title matching let duplicates through: two agents, or two runs a week
2743/// apart, never word one defect identically, and a real run filed two that had
2744/// to be closed by hand. Filing a second copy is the complaint; silently
2745/// dropping the new wording is not much better, because a later pass often
2746/// carries evidence the first did not.
2747///
2748/// The title arrives cleaned by the caller, and it has to: searching for
2749/// anything but the exact string that will land on GitHub means the duplicate
2750/// check can never hit.
2751pub fn file_as_issue(repo: &Repo, title: &str, body: &str) -> Result<Filed> {
2752    file_as_issue_apart_from(repo, title, body, None)
2753}
2754
2755/// The same, with one issue this cannot be a duplicate of.
2756///
2757/// A checklist item is quoted in the tracker it was read from, so the tracker
2758/// is the closest match for every item in it. Without this the run would
2759/// comment an item onto its own tracker and call it covered.
2760pub fn file_as_issue_apart_from(
2761    repo: &Repo,
2762    title: &str,
2763    body: &str,
2764    apart_from: Option<i64>,
2765) -> Result<Filed> {
2766    let title = repo.record_failed_write(repo.clean_title(title))?;
2767    if title.trim().is_empty() {
2768        return repo.record_failed_write(Err(spar_err!(
2769            "nothing left of the title after cleaning it"
2770        )));
2771    }
2772    let issue_body = repo.record_failed_write(repo.clean_issue_body(body))?;
2773    let exact =
2774        repo.record_failed_write(repo.try_exact_issue_apart_from(&title, &issue_body, apart_from))?;
2775    if let Some(existing) = exact {
2776        return Ok(if existing.open {
2777            Filed::Covered(existing.number, existing.url)
2778        } else {
2779            Filed::AlreadyClosed(existing.number, existing.url)
2780        });
2781    }
2782    let similar = repo.record_failed_write(repo.try_find_similar_issue_apart_from(
2783        &title,
2784        &issue_body,
2785        apart_from,
2786    ))?;
2787    if let Some(existing) = similar {
2788        let known = format!("{} {}", existing.title, existing.body);
2789        if !existing.open {
2790            return Ok(Filed::AlreadyClosed(existing.number, existing.url));
2791        }
2792        if crate::textsim::adds_information(&issue_body, &known) {
2793            repo.comment_issue(existing.number, &issue_body)?;
2794            return Ok(Filed::AddedTo(existing.number, existing.url));
2795        }
2796        return Ok(Filed::Covered(existing.number, existing.url));
2797    }
2798    let url = repo.create_issue_apart_from(&title, &issue_body, apart_from)?;
2799    let number = filed_issue_number(&url)
2800        .ok_or_else(|| spar_err!("filed an issue but could not read its number from {url}"))?;
2801    Ok(Filed::Opened(number, url))
2802}
2803
2804fn file_out_of_scope(
2805    repo: &Repo,
2806    findings: &[Finding],
2807    subject: i64,
2808    state: &mut IssueRun,
2809    cfg: &Config,
2810) {
2811    for finding in findings.iter().filter(|f| !f.in_scope) {
2812        let body = issue_report(finding);
2813        let recorded = file_followup(repo, &finding.title, &body, subject, cfg, state);
2814        if finding.severity != Severity::Nit || matches!(recorded, Followup::Recorded(_)) {
2815            record_nonblocking_outcome_with_match(
2816                state,
2817                finding,
2818                Some(&recorded),
2819                unique_stable_finding(findings, finding),
2820            );
2821        }
2822    }
2823}
2824
2825/// A finding written as a bug report, when it carries the parts of one.
2826///
2827/// The thread gets one line; an issue gets the whole thing under headings, in
2828/// the order somebody reads a bug report: what is wrong, how to see it, what it
2829/// costs, what it should do instead. A finding with none of those falls back to
2830/// its detail, which is every finding that was never going to be filed.
2831pub fn issue_report(finding: &Finding) -> String {
2832    let sections = finding.report_sections();
2833    if sections.is_empty() {
2834        return finding.detail.clone();
2835    }
2836    let mut out: Vec<String> = sections
2837        .iter()
2838        .map(|(heading, text)| format!("## {heading}\n\n{text}"))
2839        .collect();
2840    // Keep the one line summary when it says something the sections do not,
2841    // rather than dropping it or repeating it.
2842    if !finding.detail.trim().is_empty()
2843        && !sections
2844            .iter()
2845            .any(|(_, text)| crate::textsim::same_point(text, &finding.detail))
2846    {
2847        out.insert(0, finding.detail.trim().to_string());
2848    }
2849    out.join("\n\n")
2850}
2851
2852/// Non-blocking findings become follow-ups so they do not gate the merge.
2853///
2854/// Nits are excluded by default. On a shared repository a filed nit is somebody
2855/// else's notification and somebody else's triage queue: an early run on a
2856/// production codebase opened an issue titled "Log wording". Worth saying in
2857/// the PR thread, not worth an issue.
2858fn file_nonblocking(
2859    repo: &Repo,
2860    findings: &[Finding],
2861    subject: i64,
2862    state: &mut IssueRun,
2863    cfg: &Config,
2864) {
2865    for finding in findings {
2866        if !finding.in_scope || finding.severity == Severity::Blocking {
2867            continue;
2868        }
2869        let should_file = match finding.severity {
2870            Severity::NonBlocking => cfg.loop_cfg.file_non_blocking,
2871            Severity::Nit => cfg.loop_cfg.file_nits,
2872            Severity::Blocking => false,
2873        };
2874        if !should_file {
2875            if finding.severity == Severity::NonBlocking {
2876                record_nonblocking_outcome_with_match(
2877                    state,
2878                    finding,
2879                    None,
2880                    unique_stable_finding(findings, finding),
2881                );
2882            }
2883            continue;
2884        }
2885        let recorded = file_followup(repo, &finding.title, &finding.detail, subject, cfg, state);
2886        if finding.severity == Severity::NonBlocking {
2887            record_nonblocking_outcome_with_match(
2888                state,
2889                finding,
2890                Some(&recorded),
2891                unique_stable_finding(findings, finding),
2892            );
2893        } else if let Some(url) = recorded.url() {
2894            state.filed.push(url.to_string());
2895        }
2896    }
2897}
2898
2899// ---------------------------------------------------------------------------
2900// What a human actually reads
2901// ---------------------------------------------------------------------------
2902//
2903// spar composes every comment itself from structured fields, rather than
2904// forwarding whatever prose a model produced. That is the only reliable way to
2905// keep a PR thread readable: the model supplies facts, the harness supplies the
2906// shape, and each field is held to a budget on the way out.
2907
2908fn bullets(lines: &[String]) -> String {
2909    lines
2910        .iter()
2911        .map(|l| format!("- {l}"))
2912        .collect::<Vec<_>>()
2913        .join("\n")
2914}
2915
2916fn located(finding: &Finding, style: &Style) -> String {
2917    let title = style::title(&finding.title, style);
2918    match finding.where_at() {
2919        "general" => title,
2920        file => format!("{title} ({file})"),
2921    }
2922}
2923
2924/// How the run ended, which is the only thing about the run a reader needs.
2925pub enum Ending<'a> {
2926    /// Nothing blocks a merge.
2927    Approved,
2928    /// The closing pass could not run, so the last round's fixes were pushed and
2929    /// nothing has read them, which is the part a maintainer has to know.
2930    OutOfRounds,
2931    /// The budget ran out on a branch the last round did not change. Nothing is
2932    /// unread, and nothing cleared the points that were raised either.
2933    Unchanged,
2934    /// The closing pass read what the last round left and did not sign it off.
2935    /// Nothing more will be fixed here, so what is left is a person's to weigh.
2936    Unresolved(&'a [Finding]),
2937    /// A point that ran out of tries: refuted and raised again anyway, or fixed
2938    /// twice and raised again. Nobody is going to break the tie but a person.
2939    Deadlocked(&'a [Finding]),
2940}
2941
2942#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2943enum OutcomeSink {
2944    PullRequest,
2945    Terminal,
2946}
2947
2948fn outcome_sink(mode: PrComments) -> OutcomeSink {
2949    match mode {
2950        PrComments::Outcome | PrComments::Rounds => OutcomeSink::PullRequest,
2951        PrComments::None => OutcomeSink::Terminal,
2952    }
2953}
2954
2955fn emit_outcome(repo: &Repo, pr_number: i64, text: &str) {
2956    if outcome_sink(repo.style.pr_comments) == OutcomeSink::Terminal {
2957        println!("\n{text}\n");
2958        return;
2959    }
2960    if let Err(e) = repo.comment_pr(pr_number, text) {
2961        logdim!("could not post the outcome comment: {e}");
2962        println!("\n{text}\n");
2963    }
2964}
2965
2966/// Post the one comment a run leaves behind, if it has anything to say.
2967///
2968/// Everything spar used to write here was an account of its own working: which
2969/// agent spoke, which round it was, how many findings of each severity, that it
2970/// had stopped. None of that is about the code. Worse, the running commentary
2971/// could contradict itself, ending a thread with "5 fixed" immediately followed
2972/// by "no convergence", which reads as a failure rather than as fixes nobody
2973/// has checked yet.
2974///
2975/// So the loop is silent and this says what is left: what is unresolved, what
2976/// was argued down, and where the follow-ups went.
2977pub fn post_outcome(
2978    repo: &Repo,
2979    pr_number: i64,
2980    state: &IssueRun,
2981    ledger: &Ledger,
2982    ending: Ending<'_>,
2983) {
2984    let Some(text) = outcome_comment(state, ledger, &ending, &repo.style) else {
2985        return;
2986    };
2987    emit_outcome(repo, pr_number, &text);
2988}
2989
2990fn post_unread_outcome(
2991    repo: &Repo,
2992    pr_number: i64,
2993    state: &IssueRun,
2994    ledger: &Ledger,
2995    open_findings: &[Finding],
2996) {
2997    let Some(text) = outcome_comment_with_unread(
2998        state,
2999        ledger,
3000        &Ending::OutOfRounds,
3001        open_findings,
3002        &repo.style,
3003    ) else {
3004        return;
3005    };
3006    emit_outcome(repo, pr_number, &text);
3007}
3008
3009/// How a point was settled and why: this run's disputes first, then the ledger,
3010/// which is what survives across a resume.
3011fn settled_as(finding: &Finding, state: &IssueRun, ledger: &Ledger) -> Option<(Settled, String)> {
3012    if let Some(d) = state
3013        .disputes
3014        .iter()
3015        .find(|d| same_finding_parts(&d.title, &d.file, &finding.title, &finding.file))
3016    {
3017        if !d.reasoning.trim().is_empty() {
3018            return Some((Settled::Refuted, d.reasoning.clone()));
3019        }
3020    }
3021    matching_ledger_entry(ledger, &finding.title, &finding.file)
3022        .filter(|entry| !entry.reasoning.trim().is_empty())
3023        .map(|entry| (entry.outcome, entry.reasoning.clone()))
3024}
3025
3026/// `#123` from a filed issue URL, falling back to the URL when it does not look
3027/// like one. Shorter, and GitHub renders it as a link either way.
3028/// The issue number a filed follow-up URL points at, when it is one. Local
3029/// notes and anything unparseable yield nothing.
3030pub fn filed_issue_number(filed: &str) -> Option<i64> {
3031    filed
3032        .rsplit('/')
3033        .next()
3034        .and_then(|tail| tail.parse::<i64>().ok())
3035        .filter(|n| *n > 0)
3036}
3037
3038fn as_reference(url: &str) -> String {
3039    match url.rsplit('/').next().and_then(|n| n.parse::<u64>().ok()) {
3040        Some(number) => format!("#{number}"),
3041        None => url.to_string(),
3042    }
3043}
3044
3045pub fn outcome_comment(
3046    state: &IssueRun,
3047    ledger: &Ledger,
3048    ending: &Ending<'_>,
3049    style: &Style,
3050) -> Option<String> {
3051    outcome_comment_with_unread(state, ledger, ending, &[], style)
3052}
3053
3054fn outcome_comment_with_unread(
3055    state: &IssueRun,
3056    ledger: &Ledger,
3057    ending: &Ending<'_>,
3058    unread_open: &[Finding],
3059    style: &Style,
3060) -> Option<String> {
3061    let mut out: Vec<String> = Vec::new();
3062    // Points rendered in the deadlock block, so the refutation list below does
3063    // not print the same title a second time.
3064    let mut already: Vec<(String, String)> = Vec::new();
3065
3066    match ending {
3067        Ending::Approved => {
3068            if state.disputes.is_empty() && state.filed.is_empty() && state.noted.is_empty() {
3069                // A clean approval with nothing outstanding needs no comment.
3070                // The absence of objections is the message. `noted` is in that
3071                // condition because the message has to be true: a reviewer that
3072                // found six real problems and gated on none of them did not find
3073                // nothing, and silence would say it did.
3074                return None;
3075            }
3076            out.push("Reviewed, nothing blocking a merge.".into());
3077        }
3078        Ending::OutOfRounds => {
3079            out.push(
3080                "Not signed off: the last round of fixes was pushed but has not been reviewed."
3081                    .into(),
3082            );
3083            if !unread_open.is_empty() {
3084                let lines: Vec<String> = unread_open
3085                    .iter()
3086                    .map(|finding| {
3087                        already.push((finding.title.clone(), finding.file.clone()));
3088                        format!(
3089                            "{}. {}",
3090                            located(finding, style),
3091                            style::sentence(&finding.detail, style)
3092                        )
3093                    })
3094                    .collect();
3095                out.push("These points were already open:".into());
3096                out.push(bullets(&lines));
3097            }
3098        }
3099        // Deliberately not the sentence above. Nothing was pushed on this path,
3100        // and telling a maintainer to go and read a commit that does not exist
3101        // is worse than saying nothing.
3102        Ending::Unchanged => out.push(
3103            "Not signed off: the last round changed nothing, so the branch is the one that was \
3104             already reviewed."
3105                .into(),
3106        ),
3107        Ending::Unresolved(points) => {
3108            let lines: Vec<String> = points
3109                .iter()
3110                .map(|f| {
3111                    already.push((f.title.clone(), f.file.clone()));
3112                    format!(
3113                        "{}. {}",
3114                        located(f, style),
3115                        style::sentence(&f.detail, style)
3116                    )
3117                })
3118                .collect();
3119            out.push("Not signed off. These points are still open:".into());
3120            out.push(bullets(&lines));
3121        }
3122        Ending::Deadlocked(points) => {
3123            // Rendered once, with the argument attached. A deadlocked point is
3124            // by definition one that was settled earlier, so the reasoning is
3125            // the whole reason a person is being asked to look. On a resumed
3126            // run `state.disputes` is empty (only `filed` is restored), so the
3127            // ledger is the only place that argument survives.
3128            let lines: Vec<String> = points
3129                .iter()
3130                .map(|f| {
3131                    let where_at = match f.where_at() {
3132                        "general" => String::new(),
3133                        file => format!(" ({file})"),
3134                    };
3135                    let title = style::title(&f.title, style);
3136                    already.push((f.title.clone(), f.file.clone()));
3137                    match settled_as(f, state, ledger) {
3138                        Some((Settled::Refuted, reason)) => format!(
3139                            "{title}{where_at}. Refuted as: {}",
3140                            style::summary(&reason, style)
3141                        ),
3142                        Some((Settled::Filed, reason)) => format!(
3143                            "{title}{where_at}. Filed as out of scope: {}",
3144                            style::summary(&reason, style)
3145                        ),
3146                        // Never "filed": nothing holds this point but the
3147                        // comment you are reading.
3148                        Some((Settled::Dropped, reason)) => format!(
3149                            "{title}{where_at}. Out of scope here, and not filed: {}",
3150                            style::summary(&reason, style)
3151                        ),
3152                        // Not a refutation, so it must not read as one. Nobody
3153                        // argued this point down: it was fixed, raised again,
3154                        // fixed again, and raised again, and what a person has
3155                        // to weigh is a fix that keeps missing rather than an
3156                        // argument neither agent would give up.
3157                        Some((Settled::Fixed, reason)) => format!(
3158                            "{title}{where_at}. Fixed and raised again. Recorded answer: {}",
3159                            style::summary(&reason, style)
3160                        ),
3161                        None => format!("{title}{where_at}"),
3162                    }
3163                })
3164                .collect();
3165            out.push("Needs your decision. The reviewers could not settle this:".into());
3166            out.push(bullets(&lines));
3167        }
3168    }
3169
3170    let disputes: Vec<&crate::model::Dispute> = state
3171        .disputes
3172        .iter()
3173        .filter(|d| {
3174            !already
3175                .iter()
3176                .any(|(title, file)| same_finding_parts(title, file, &d.title, &d.file))
3177        })
3178        .collect();
3179    if !disputes.is_empty() {
3180        // The one thing invisible anywhere else. The diff shows what was fixed;
3181        // nothing shows what was argued down, or why.
3182        let lines: Vec<String> = disputes
3183            .iter()
3184            .map(|d| {
3185                let title = style::title(&d.title, style);
3186                let title = if d.file.trim().is_empty() {
3187                    title
3188                } else {
3189                    format!("{title} ({})", d.file.trim())
3190                };
3191                format!("{}. {}", title, style::sentence(&d.reasoning, style))
3192            })
3193            .collect();
3194        out.push(format!("Raised and refuted:\n{}", bullets(&lines)));
3195    }
3196
3197    if !state.noted.is_empty() {
3198        // The only place a downgraded point survives. The diff shows what was
3199        // fixed and the refutation list shows what was argued down; a finding
3200        // the reviewer judged real and chose not to gate on had nothing.
3201        let lines: Vec<String> = state
3202            .noted
3203            .iter()
3204            .filter(|f| {
3205                !already
3206                    .iter()
3207                    .any(|(title, file)| same_finding_parts(title, file, &f.title, &f.file))
3208            })
3209            .map(|f| located(f, style))
3210            .collect();
3211        if !lines.is_empty() {
3212            out.push(format!("Noted, not blocking:\n{}", bullets(&lines)));
3213        }
3214    }
3215
3216    if !state.filed.is_empty() {
3217        let refs: Vec<String> = state.filed.iter().map(|u| as_reference(u)).collect();
3218        out.push(format!("Filed separately: {}", refs.join(", ")));
3219    }
3220
3221    Some(out.join("\n\n"))
3222}
3223
3224/// What the closing pass is asked, with the last delta called out inside the
3225/// full merge-safety audit.
3226///
3227/// `landed` is `None` when the harness cannot say what is new. Commit messages
3228/// are rewritten when they break the style rules, which moves every hash from
3229/// the first offender onward, so a head recorded before a round can stop being
3230/// on the branch. Saying that plainly is the only honest option: the alternative
3231/// is `git log` reporting the whole branch as newly landed.
3232#[allow(clippy::too_many_arguments)]
3233fn close_prompt(
3234    base: &str,
3235    number: i64,
3236    title: &str,
3237    from: &str,
3238    landed: Option<&[String]>,
3239    ledger: &Ledger,
3240    open_findings: &[Finding],
3241    round: u32,
3242) -> String {
3243    let landed = match landed {
3244        Some([]) => "\nNothing landed after the last round of review. What it asked for was \
3245                     answered in words rather than in code, so the branch in front of you is the \
3246                     branch that was already read.\n"
3247            .to_string(),
3248        Some(lines) => format!(
3249            "\nThis landed after the last round of review, and nobody has read it:\n{}\n\nRead it \
3250             first with `git diff {from}..HEAD`, then inspect the full branch with `git diff \
3251             {base}...HEAD`.\n",
3252            lines
3253                .iter()
3254                .map(|l| format!("- {l}"))
3255                .collect::<Vec<_>>()
3256                .join("\n")
3257        ),
3258        None => format!(
3259            "\nThe commits on this branch were rewritten after the last round of review, so the \
3260             harness cannot say which of them are new. Inspect the full branch with `git diff \
3261             {base}...HEAD`.\n"
3262        ),
3263    };
3264    CLOSE_PROMPT
3265        .replace("{number}", &number.to_string())
3266        .replace("{title}", title)
3267        .replace("{base}", base)
3268        .replace("{landed}", &landed)
3269        .replace("{open}", &open_findings_block(open_findings))
3270        .replace("{answers}", &closing_answers(ledger, round))
3271        .replace("{settled}", &settled_block(ledger))
3272}
3273
3274fn open_findings_block(findings: &[Finding]) -> String {
3275    if findings.is_empty() {
3276        return String::new();
3277    }
3278    format!(
3279        "\nThese blocking findings were left open by an earlier response. Recheck each one:\n{}\n\
3280         \nIf one still blocks, return it under the same title and file. Omission means you checked \
3281         it and found that it no longer blocks.\n",
3282        findings_for_prompt(findings)
3283    )
3284}
3285
3286/// The claimed fixes, as the closing pass is told about them.
3287///
3288/// The same points `answers_block` gives a round, asked as the thing this pass
3289/// is for rather than as context for a wider read.
3290fn closing_answers(ledger: &Ledger, round: u32) -> String {
3291    let lines = fixed_lines(ledger, round);
3292    if lines.is_empty() {
3293        return String::new();
3294    }
3295    format!(
3296        "\nThese points were raised on this pull request and the author says it fixed them. \
3297         Nobody has checked that:\n{}\n",
3298        lines.join("\n")
3299    )
3300}
3301
3302/// What the reviewer is asked, with what it already answered behind it.
3303///
3304/// Built here rather than inline in the loop, because a prompt built inline is a
3305/// prompt with no test.
3306fn review_prompt(
3307    base: &str,
3308    number: i64,
3309    title: &str,
3310    ledger: &Ledger,
3311    open_findings: &[Finding],
3312    round: u32,
3313    last: u32,
3314) -> String {
3315    REVIEW_PROMPT
3316        .replace("{base}", base)
3317        .replace("{number}", &number.to_string())
3318        .replace("{title}", title)
3319        .replace("{open}", &open_findings_block(open_findings))
3320        .replace("{answers}", &answers_block(ledger, round))
3321        .replace("{settled}", &settled_block(ledger))
3322        .replace("{round}", &round_note(round, last))
3323}
3324
3325/// What the implementor is asked, with the issue in front of it.
3326///
3327/// The body is passed rather than only the link, because one of the two agents
3328/// cannot follow a link: codex runs under `-s workspace-write`, which has no
3329/// network at all, so a URL alone would leave it judging the title. The link is
3330/// there for the agent that can follow it, and for the comments spar does not
3331/// fetch.
3332fn implement_prompt(number: i64, title: &str, url: &str, body: &str) -> String {
3333    IMPLEMENT_PROMPT
3334        .replace("{number}", &number.to_string())
3335        .replace("{title}", title)
3336        .replace("{url}", url)
3337        .replace("{body}", body)
3338}
3339
3340/// The pull request body.
3341///
3342/// What it closes, then the change in one sentence, then what was wrong, then
3343/// only the sections that have something in them. The lead is two paragraphs
3344/// rather than two headings: a heading over a single sentence is a label on a
3345/// label, and those two parts are the ones every body has.
3346///
3347/// GitHub renders the file count and the plus and minus figures immediately
3348/// above this, so neither appears here.
3349pub fn pr_body(issue: i64, work: &Implementation, style: &Style) -> String {
3350    let mut parts = vec![format!("Closes #{issue}")];
3351
3352    for lead in [&work.summary, &work.problem] {
3353        let text = style::sentence(lead, style);
3354        if !text.is_empty() {
3355            parts.push(text);
3356        }
3357    }
3358    parts.extend(section("What changed", &work.changes, style));
3359    parts.extend(section("How to test", &work.testing, style));
3360
3361    let notes = style::sentence(work.notes.as_deref().unwrap_or_default(), style);
3362    if !notes.is_empty() {
3363        parts.push(format!("## Notes\n\n{notes}"));
3364    }
3365
3366    style::body(&parts.join("\n\n"), style)
3367}
3368
3369/// A headed list, or nothing at all when there is nothing to list.
3370///
3371/// Nothing at all on purpose. A heading with an empty body under it reads as a
3372/// section somebody forgot to write, which is worse than the absence, and a
3373/// small change that needs no change list should not be made to look like one
3374/// that is missing its.
3375fn section(heading: &str, lines: &[String], style: &Style) -> Option<String> {
3376    let items: Vec<String> = lines
3377        .iter()
3378        .map(|line| style::summary(line, style))
3379        .filter(|line| !line.is_empty())
3380        .collect();
3381    if items.is_empty() {
3382        return None;
3383    }
3384    Some(format!("## {heading}\n\n{}", bullets(&items)))
3385}
3386
3387/// A pull request body for work whose author never got to describe it.
3388///
3389/// The implement call failed after the commits were made, so what those commits
3390/// say about themselves is the only account of them there is. It is a poor one,
3391/// and better than an empty body over work nobody would otherwise know was
3392/// there; the note says as much, so a reviewer does not read the list as the
3393/// author's own summary.
3394pub fn from_commits(repo: &Repo, work_dir: &Path, base: &str) -> Implementation {
3395    Implementation {
3396        changes: repo.commit_subjects(work_dir, "HEAD", base),
3397        notes: Some(
3398            "The implement call failed after these commits were made, so this body is assembled \
3399             from their messages rather than written by their author. Read the diff."
3400                .to_string(),
3401        ),
3402        ..Implementation::default()
3403    }
3404}
3405
3406/// What gets posted on an issue that produced no pull request.
3407///
3408/// The agent's own reason when it gave one, since that is the part written for
3409/// the person who opened the issue. Never the summary: an issue that produced
3410/// no commits has no change for a summary to describe, and one that claims
3411/// otherwise is worse than a flat sentence saying nothing happened.
3412fn no_pr_note(work: &Implementation, style: &Style) -> String {
3413    let reason = style::sentence(&work.reason, style);
3414    if !reason.is_empty() {
3415        return reason;
3416    }
3417    if work.not_worth_doing {
3418        "Left alone after reading the code, with no reason given.".to_string()
3419    } else {
3420        "Nothing was committed, so there is nothing to review.".to_string()
3421    }
3422}
3423
3424/// One review, as a reviewer would write it if they were in a hurry: a count
3425/// line, a sentence, and one bullet per finding. Only blocking findings carry
3426/// their detail, because only those are something the author has to act on now.
3427pub fn review_comment(holder: &str, round: u32, review: &Review, style: &Style) -> String {
3428    let by = |severity: Severity| -> Vec<&Finding> {
3429        review
3430            .findings
3431            .iter()
3432            .filter(|f| f.severity == severity && f.in_scope)
3433            .collect()
3434    };
3435    let blocking = by(Severity::Blocking);
3436    let non_blocking = by(Severity::NonBlocking);
3437    let nits = by(Severity::Nit);
3438    let out_of_scope: Vec<&Finding> = review.findings.iter().filter(|f| !f.in_scope).collect();
3439
3440    let mut counts = Vec::new();
3441    if !blocking.is_empty() {
3442        counts.push(format!("{} blocking", blocking.len()));
3443    }
3444    if !non_blocking.is_empty() {
3445        counts.push(format!("{} non-blocking", non_blocking.len()));
3446    }
3447    if !nits.is_empty() {
3448        counts.push(format!("{} nit", nits.len()));
3449    }
3450    if !out_of_scope.is_empty() {
3451        counts.push(format!("{} out of scope", out_of_scope.len()));
3452    }
3453    let headline = if counts.is_empty() {
3454        "no findings".to_string()
3455    } else {
3456        counts.join(", ")
3457    };
3458
3459    let _ = (holder, round, headline);
3460    let mut out = Vec::new();
3461    let summary = style::summary(&review.summary, style);
3462    if !summary.is_empty() {
3463        out.push(summary);
3464    }
3465
3466    if !blocking.is_empty() {
3467        let lines: Vec<String> = blocking
3468            .iter()
3469            .map(|f| {
3470                let detail = style::detail(&f.detail, style);
3471                if detail.is_empty() {
3472                    located(f, style)
3473                } else {
3474                    format!("{}. {detail}", located(f, style))
3475                }
3476            })
3477            .collect();
3478        out.push(format!("blocking\n{}", bullets(&lines)));
3479    }
3480
3481    // Everything below is filed as a follow-up, so the thread only needs the
3482    // title: the detail lives on the issue where it can be acted on.
3483    for (label, group) in [
3484        ("non-blocking", &non_blocking),
3485        ("nits", &nits),
3486        ("out of scope", &out_of_scope),
3487    ] {
3488        if group.is_empty() {
3489            continue;
3490        }
3491        let lines: Vec<String> = group.iter().map(|f| located(f, style)).collect();
3492        out.push(format!("{label}\n{}", bullets(&lines)));
3493    }
3494
3495    out.join("\n\n")
3496}
3497
3498/// One response to a review. Refutations carry their reasoning because that is
3499/// the whole argument; fixes are a list of titles because the diff says the
3500/// rest.
3501pub fn disposition_comment(
3502    author: &str,
3503    response: &ResponseDoc,
3504    fixed: &[String],
3505    refuted: &[String],
3506    filed: &[String],
3507    style: &Style,
3508) -> Option<String> {
3509    if fixed.is_empty() && refuted.is_empty() && filed.is_empty() {
3510        return None;
3511    }
3512    let mut counts = Vec::new();
3513    if !fixed.is_empty() {
3514        counts.push(format!("{} fixed", fixed.len()));
3515    }
3516    if !refuted.is_empty() {
3517        counts.push(format!("{} refuted", refuted.len()));
3518    }
3519    if !filed.is_empty() {
3520        counts.push(format!("{} filed", filed.len()));
3521    }
3522
3523    let _ = (author, counts);
3524    let mut out = Vec::new();
3525    let summary = style::summary(&response.summary, style);
3526    if !summary.is_empty() {
3527        out.push(summary);
3528    }
3529    if !refuted.is_empty() {
3530        out.push(format!("refuted\n{}", bullets(refuted)));
3531    }
3532    if !fixed.is_empty() {
3533        out.push(format!("fixed\n{}", bullets(fixed)));
3534    }
3535    if !filed.is_empty() {
3536        out.push(format!("filed\n{}", bullets(filed)));
3537    }
3538    Some(out.join("\n\n"))
3539}
3540
3541/// What is posted on an issue both agents declined.
3542/// What is posted on an issue both reviewers declined.
3543///
3544/// Just the reasons. GitHub already shows that it was closed as not planned,
3545/// and which model held which opinion is a fact about the run rather than about
3546/// the issue. Duplicates are collapsed, since two reviewers reaching the same
3547/// conclusion often reach it in the same words.
3548pub fn skip_comment(item: &SkippedItem, style: &Style) -> String {
3549    let reasons = item
3550        .reasons
3551        .values()
3552        .map(|reason| style::sentence(reason, style));
3553    // Two reviewers declining one issue almost always decline it for the same
3554    // reason, worded differently. On the run that prompted this, both cited the
3555    // issue it duplicated and the reader saw the point twice.
3556    let lines = crate::textsim::dedupe_by(reasons, crate::textsim::same_reason);
3557    bullets(&lines)
3558}
3559
3560/// Findings as a model should see them: full detail, since this one is not for
3561/// a human to read.
3562pub(crate) fn findings_for_prompt(findings: &[Finding]) -> String {
3563    if findings.is_empty() {
3564        return "(none)".to_string();
3565    }
3566    findings
3567        .iter()
3568        .map(|f| {
3569            let scope = if f.in_scope { "" } else { " [out of scope]" };
3570            format!(
3571                "- [{}]{scope} {} ({})\n  {}",
3572                f.severity,
3573                f.title,
3574                f.where_at(),
3575                f.detail
3576            )
3577        })
3578        .collect::<Vec<_>>()
3579        .join("\n")
3580}
3581
3582#[cfg(test)]
3583mod tests {
3584    use super::*;
3585    use crate::model::Verdict;
3586
3587    fn style() -> Style {
3588        Style::default()
3589    }
3590
3591    fn finding(severity: &str, title: &str, detail: &str, file: &str, in_scope: bool) -> Finding {
3592        Finding {
3593            severity: Severity::parse_lenient(severity).unwrap(),
3594            title: title.into(),
3595            detail: detail.into(),
3596            file: file.into(),
3597            in_scope,
3598            ..Default::default()
3599        }
3600    }
3601
3602    fn review(summary: &str, findings: Vec<Finding>) -> Review {
3603        Review {
3604            verdict: Verdict::Approve,
3605            next_action: NextAction::Merge,
3606            summary: summary.into(),
3607            findings,
3608        }
3609    }
3610
3611    fn disposition(title: &str, file: &str, action: Action) -> Disposition {
3612        Disposition {
3613            title: title.into(),
3614            file: file.into(),
3615            action,
3616            reasoning: "because".into(),
3617            new_issue_title: None,
3618            new_issue_body: None,
3619        }
3620    }
3621
3622    // -- worktree release ------------------------------------------------
3623
3624    fn cfg_with(worktrees: bool, keep: bool) -> Config {
3625        let text = "[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n";
3626        let mut cfg = crate::config::parse(text).unwrap();
3627        cfg.loop_cfg.worktrees = worktrees;
3628        cfg.loop_cfg.keep_worktrees = keep;
3629        cfg
3630    }
3631
3632    #[test]
3633    fn a_worktree_is_released_on_every_finished_outcome() {
3634        let cfg = cfg_with(true, false);
3635        for status in [Status::Approved, Status::Merged, Status::Abandoned] {
3636            assert!(should_release(&cfg, status), "{status}");
3637        }
3638    }
3639
3640    /// Releasing only on "merged" leaked one worktree per run, because
3641    /// auto_merge is off by default and runs end at "approved".
3642    #[test]
3643    fn a_worktree_is_kept_only_where_a_human_has_to_look() {
3644        let cfg = cfg_with(true, false);
3645        assert!(!should_release(&cfg, Status::Escalated));
3646        assert!(!should_release(&cfg, Status::Error));
3647    }
3648
3649    #[test]
3650    fn an_uncommitted_implementation_names_its_diagnostic_and_recovery_path() {
3651        let path = Path::new("/tmp/issue worktree");
3652        let err =
3653            uncommitted_implementation_error(path, Some("git add could not create index.lock"))
3654                .to_string();
3655        assert!(err.contains("could not create index.lock"), "{err}");
3656        assert!(err.contains("/tmp/issue worktree"), "{err}");
3657        assert!(err.contains("Commit or recover"), "{err}");
3658        assert!(!should_release(&cfg_with(true, false), Status::Error));
3659    }
3660
3661    #[test]
3662    fn the_keep_flag_overrides_everything() {
3663        assert!(!should_release(&cfg_with(true, true), Status::Approved));
3664    }
3665
3666    #[test]
3667    fn nothing_is_released_when_worktrees_are_off() {
3668        assert!(!should_release(&cfg_with(false, false), Status::Approved));
3669    }
3670
3671    // -- custody ---------------------------------------------------------
3672
3673    /// The reviewer fixed the findings itself, so it wrote the head and the
3674    /// other agent takes round 2.
3675    #[test]
3676    fn fixing_your_own_findings_hands_the_pr_over() {
3677        let cfg = cfg_with(true, false);
3678        assert_eq!("a", next_reviewer(&cfg, "b", Some("b")));
3679        assert_eq!("b", next_reviewer(&cfg, "a", Some("a")));
3680    }
3681
3682    /// The author wrote the head, so the reviewer keeps the PR. Flipping here
3683    /// gave the author its own fix to review in round 2, and an approval of it
3684    /// ended the loop.
3685    #[test]
3686    fn handing_back_keeps_the_reviewer_for_the_next_round() {
3687        let cfg = cfg_with(true, false);
3688        assert_eq!("b", next_reviewer(&cfg, "b", Some("a")));
3689        assert_eq!("a", next_reviewer(&cfg, "a", Some("b")));
3690    }
3691
3692    /// Whoever holds round 2 did not write what it is reading, whoever wrote
3693    /// it. `a` implements, so `b` reviews round 1.
3694    #[test]
3695    fn nobody_reviews_their_own_edit() {
3696        let cfg = cfg_with(true, false);
3697        let round_1 = cfg.other(&cfg.first_implementor);
3698        assert_eq!("b", round_1);
3699        for editor in ["a", "b"] {
3700            assert_ne!(editor, next_reviewer(&cfg, &round_1, Some(editor)));
3701        }
3702    }
3703
3704    /// The `fix_myself` half of the bug. The reviewer said it would fix its own
3705    /// findings and the call returned without committing, so the head is still
3706    /// the author's and handing over would put the author in front of its own
3707    /// work.
3708    #[test]
3709    fn a_fix_that_committed_nothing_leaves_the_pr_where_it_is() {
3710        let cfg = cfg_with(true, false);
3711        assert_eq!("b", next_reviewer(&cfg, "b", None));
3712        assert_eq!("a", next_reviewer(&cfg, "a", None));
3713    }
3714
3715    /// The `hand_back` half. The reviewer committed while reviewing and the
3716    /// author answered without committing, so the head is the reviewer's and
3717    /// keeping it would have it read its own commit.
3718    #[test]
3719    fn a_reviewer_that_wrote_the_head_gives_the_pr_up() {
3720        let cfg = cfg_with(true, false);
3721        assert_eq!("a", next_reviewer(&cfg, "b", Some("b")));
3722    }
3723
3724    /// A reviewer that fixes what it finds and then reports nothing blocking
3725    /// approved its own fix, and the rollback takes that fix out again. The
3726    /// head that would merge is not the head that passed.
3727    #[test]
3728    fn a_review_that_wrote_cannot_approve_what_is_left() {
3729        assert!(!approval_stands(&[], true));
3730    }
3731
3732    #[test]
3733    fn a_clean_review_of_an_untouched_branch_approves() {
3734        assert!(approval_stands(&[], false));
3735    }
3736
3737    #[test]
3738    fn a_blocking_finding_never_approves() {
3739        let blocking = vec![finding("blocking", "Broken", "detail", "src/x.rs", true)];
3740        assert!(!approval_stands(&blocking, false));
3741    }
3742
3743    #[test]
3744    fn approval_refuses_a_head_that_changed_after_review() {
3745        assert!(ensure_reviewed_head(36, "abc123", "abc123").is_ok());
3746        let error = ensure_reviewed_head(36, "abc123", "def456").unwrap_err();
3747        assert!(error.to_string().contains("unread head"));
3748    }
3749
3750    /// Custody is decided on what git says, not on the call returning.
3751    #[test]
3752    fn only_a_moved_head_counts_as_a_commit() {
3753        let before = Snapshot {
3754            head: "abc".into(),
3755            dirty: false,
3756        };
3757        assert!(!Snapshot {
3758            head: "abc".into(),
3759            dirty: true,
3760        }
3761        .landed_over(&before));
3762        assert!(Snapshot {
3763            head: "def".into(),
3764            dirty: false,
3765        }
3766        .landed_over(&before));
3767        // git could not be read, which is not evidence that anything landed.
3768        assert!(!Snapshot {
3769            head: String::new(),
3770            dirty: false,
3771        }
3772        .landed_over(&before));
3773    }
3774
3775    // -- round budget ----------------------------------------------------
3776
3777    /// A fresh PR gets rounds 1 through max_rounds.
3778    #[test]
3779    fn a_fresh_run_starts_at_one() {
3780        assert_eq!((1, 3), round_window(1, 3));
3781        assert_eq!((1, 5), round_window(1, 5));
3782    }
3783
3784    /// The budget is per invocation, not a lifetime cap. Running spar again on
3785    /// a PR that already spent five rounds gives it five more, because a person
3786    /// looked at it and chose to.
3787    #[test]
3788    fn a_resumed_run_gets_a_full_fresh_budget() {
3789        assert_eq!((6, 10), round_window(6, 5));
3790        assert_eq!((11, 13), round_window(11, 3));
3791    }
3792
3793    #[test]
3794    fn a_budget_of_one_is_a_single_round() {
3795        assert_eq!((6, 6), round_window(6, 1));
3796    }
3797
3798    #[test]
3799    fn round_numbers_keep_counting_across_sessions() {
3800        // Three sessions of three rounds each land on 1..3, 4..6, 7..9.
3801        let mut start = 1;
3802        let mut seen = Vec::new();
3803        for _ in 0..3 {
3804            let (first, last) = round_window(start, 3);
3805            seen.push((first, last));
3806            start = last + 1;
3807        }
3808        assert_eq!(vec![(1, 3), (4, 6), (7, 9)], seen);
3809    }
3810
3811    // -- the ledger ------------------------------------------------------
3812
3813    fn ledger_with(title: &str, file: &str) -> Ledger {
3814        let mut ledger = Ledger::new();
3815        ledger.insert(
3816            finding_key(title, file),
3817            LedgerEntry {
3818                title: title.into(),
3819                file: file.into(),
3820                reasoning: "no".into(),
3821                round: 1,
3822                reraised: 0,
3823                outcome: Settled::Refuted,
3824            },
3825        );
3826        ledger
3827    }
3828
3829    #[test]
3830    fn a_point_refuted_and_re_raised_twice_escalates() {
3831        let mut ledger = ledger_with("nit about naming", "a.rs");
3832        let mut state = IssueRun::new(1, "t");
3833        let blocking = vec![finding("blocking", "nit about naming", "d", "a.rs", true)];
3834        assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
3835        assert!(check_relitigation(&mut ledger, &blocking, &mut state));
3836    }
3837
3838    /// Fixing is what most dispositions are, and it recorded nothing, so the
3839    /// guard had only refutations to match and never fired on a real run. Three
3840    /// tries at one point is a person's problem, not another round's.
3841    #[test]
3842    fn a_point_fixed_twice_and_raised_again_escalates() {
3843        let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
3844        for entry in ledger.values_mut() {
3845            entry.outcome = Settled::Fixed;
3846        }
3847        let mut state = IssueRun::new(1, "t");
3848        let blocking = [finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
3849
3850        assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
3851        assert!(check_relitigation(&mut ledger, &blocking, &mut state));
3852    }
3853
3854    /// A maintainer reading "settled and re-raised" about a fix that genuinely
3855    /// did not work sides with the author, and is wrong.
3856    #[test]
3857    fn a_fix_that_missed_twice_is_not_reported_as_a_refutation() {
3858        assert!(why_escalated(Settled::Fixed).contains("fixed twice"));
3859        for outcome in [Settled::Refuted, Settled::Filed, Settled::Dropped] {
3860            assert!(why_escalated(outcome).contains("settled"), "{outcome}");
3861        }
3862    }
3863
3864    /// A reviewer that fixes its own findings answers them in code too. Leaving
3865    /// them out left that path with the hole the other one had: the next pass
3866    /// reads a fix with nothing saying it was asked for, and the guard cannot
3867    /// count it.
3868    #[test]
3869    fn a_reviewer_that_fixes_its_own_findings_records_them_too() {
3870        let mut ledger = Ledger::new();
3871        let blocking = vec![finding(
3872            "blocking",
3873            "Unbounded loop",
3874            "spins",
3875            "src/x.rs",
3876            true,
3877        )];
3878        let mut state = IssueRun::new(1, "t");
3879
3880        record_own_fixes(&blocking, &mut ledger, &mut state, 1);
3881
3882        let entry = ledger
3883            .get(&finding_key("Unbounded loop", "src/x.rs"))
3884            .expect("keyed where the next round will look");
3885        assert_eq!(Settled::Fixed, entry.outcome);
3886        assert_eq!(
3887            "a committed change was made for this point",
3888            entry.reasoning
3889        );
3890
3891        // And the guard can now count it, which it could not before.
3892        assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
3893        assert!(check_relitigation(&mut ledger, &blocking, &mut state));
3894    }
3895
3896    /// The settled block tells a reviewer the code will not change for a point.
3897    /// That is the opposite of what happened to a fix, and a fixed point printed
3898    /// there reads as an argument already won.
3899    #[test]
3900    fn a_fixed_point_is_not_in_the_settled_block() {
3901        let mut ledger = ledger_with("refuted point", "a.rs");
3902        ledger.extend(ledger_with("fixed point", "b.rs"));
3903        for entry in ledger.values_mut() {
3904            if entry.title == "fixed point" {
3905                entry.outcome = Settled::Fixed;
3906            }
3907        }
3908        let block = settled_block(&ledger);
3909        assert!(block.contains("refuted point"));
3910        assert!(!block.contains("fixed point"));
3911    }
3912
3913    /// And a ledger holding nothing but fixes has no settled block at all,
3914    /// rather than a heading with no points under it.
3915    #[test]
3916    fn a_ledger_of_only_fixes_says_nothing_is_settled() {
3917        let mut ledger = ledger_with("fixed point", "b.rs");
3918        for entry in ledger.values_mut() {
3919            entry.outcome = Settled::Fixed;
3920        }
3921        assert_eq!("", settled_block(&ledger));
3922    }
3923
3924    /// A review that lists one point twice used to take its entry from nothing
3925    /// to escalated in a single pass, without the author ever being asked. Rare
3926    /// while only refutations were recorded, and not rare now that every fix
3927    /// leaves an entry.
3928    #[test]
3929    fn one_review_spends_one_re_raise_however_often_it_says_it() {
3930        let mut ledger = ledger_with("Missing error handling", "src/net.rs");
3931        let mut state = IssueRun::new(1, "t");
3932        let twice = vec![
3933            finding(
3934                "blocking",
3935                "Missing error handling",
3936                "d",
3937                "src/net.rs",
3938                true,
3939            ),
3940            finding(
3941                "blocking",
3942                "Missing error handling",
3943                "e",
3944                "src/net.rs",
3945                true,
3946            ),
3947        ];
3948
3949        assert!(!check_relitigation(&mut ledger, &twice, &mut state));
3950        assert_eq!(1, ledger.values().next().unwrap().reraised);
3951        assert!(check_relitigation(&mut ledger, &twice, &mut state));
3952    }
3953
3954    #[test]
3955    fn an_untracked_finding_does_not_escalate() {
3956        let mut state = IssueRun::new(1, "t");
3957        let blocking = vec![finding("blocking", "brand new", "d", "a.rs", true)];
3958        assert!(!check_relitigation(
3959            &mut Ledger::new(),
3960            &blocking,
3961            &mut state
3962        ));
3963    }
3964
3965    #[test]
3966    fn persisted_ledger_entries_are_rekeyed_for_stable_locations() {
3967        let mut ledger = Ledger::new();
3968        ledger.insert(
3969            "legacy-key".into(),
3970            LedgerEntry {
3971                title: "Unbounded loop".into(),
3972                file: "src/x.rs:88".into(),
3973                reasoning: "bounded by the caller".into(),
3974                round: 2,
3975                reraised: 1,
3976                outcome: Settled::Refuted,
3977            },
3978        );
3979        normalise_ledger_keys(&mut ledger);
3980        let key = matching_ledger_key(&ledger, "Unbounded loop", "src/x.rs:91").unwrap();
3981        assert_eq!(1, ledger[&key].reraised);
3982    }
3983
3984    #[test]
3985    fn same_title_at_two_sites_keeps_both_blockers() {
3986        let findings = vec![
3987            finding(
3988                "blocking",
3989                "Unchecked error",
3990                "first site",
3991                "src/net.rs:10",
3992                true,
3993            ),
3994            finding(
3995                "blocking",
3996                "Unchecked error",
3997                "second site",
3998                "src/net.rs:200",
3999                true,
4000            ),
4001        ];
4002
4003        let blocking = blocking_findings(&findings);
4004        assert_eq!(2, blocking.len());
4005        assert_eq!("src/net.rs:10", blocking[0].file);
4006        assert_eq!("src/net.rs:200", blocking[1].file);
4007    }
4008
4009    #[test]
4010    fn moved_location_fallback_refuses_an_ambiguous_ledger() {
4011        let mut ledger = ledger_with("Unchecked error", "src/net.rs:10");
4012        ledger.extend(ledger_with("Unchecked error", "src/net.rs:200"));
4013        assert!(matching_ledger_key(&ledger, "Unchecked error", "src/net.rs:30").is_none());
4014    }
4015
4016    #[test]
4017    fn two_current_sites_do_not_relocate_one_old_ledger_entry() {
4018        let mut ledger = ledger_with("Unchecked error", "src/net.rs:5");
4019        let blocking = vec![
4020            finding(
4021                "blocking",
4022                "Unchecked error",
4023                "first",
4024                "src/net.rs:10",
4025                true,
4026            ),
4027            finding(
4028                "blocking",
4029                "Unchecked error",
4030                "second",
4031                "src/net.rs:200",
4032                true,
4033            ),
4034        ];
4035        let mut state = IssueRun::new(1, "t");
4036        record_own_fixes(&blocking, &mut ledger, &mut state, 2);
4037        assert!(ledger.contains_key(&finding_key("Unchecked error", "src/net.rs:10")));
4038        assert!(ledger.contains_key(&finding_key("Unchecked error", "src/net.rs:200")));
4039    }
4040
4041    #[test]
4042    fn two_current_sites_remain_two_open_findings() {
4043        let current = vec![
4044            finding(
4045                "blocking",
4046                "Unchecked error",
4047                "first",
4048                "src/net.rs:10",
4049                true,
4050            ),
4051            finding(
4052                "blocking",
4053                "Unchecked error",
4054                "second",
4055                "src/net.rs:200",
4056                true,
4057            ),
4058        ];
4059        let mut open = Vec::new();
4060
4061        extend_findings(&mut open, &current);
4062
4063        assert_eq!(2, open.len());
4064        assert_eq!("src/net.rs:10", open[0].file);
4065        assert_eq!("src/net.rs:200", open[1].file);
4066    }
4067
4068    #[test]
4069    fn display_limits_do_not_change_persisted_finding_identity() {
4070        let point = finding(
4071            "blocking",
4072            "abcdefghij",
4073            "still wrong",
4074            "src/net.rs:10",
4075            true,
4076        );
4077        let mut ledger = Ledger::new();
4078        let mut state = IssueRun::new(1, "t");
4079        record_own_fixes(std::slice::from_ref(&point), &mut ledger, &mut state, 1);
4080        normalise_ledger_keys(&mut ledger);
4081
4082        let key = matching_ledger_key(&ledger, "abcdefghij", "src/net.rs:12").unwrap();
4083        assert_eq!("abcdefghij", ledger[&key].title);
4084    }
4085
4086    #[test]
4087    fn a_clipped_legacy_entry_keeps_its_original_lookup_key() {
4088        let mut ledger = Ledger::new();
4089        let key = crate::jsonx::finding_key("abcdefghij", "src/net.rs:10");
4090        ledger.insert(
4091            key.clone(),
4092            LedgerEntry {
4093                title: "abcde".into(),
4094                file: "src/net.rs:10".into(),
4095                reasoning: "bounded by the caller".into(),
4096                round: 1,
4097                reraised: 1,
4098                outcome: Settled::Refuted,
4099            },
4100        );
4101        normalise_ledger_keys(&mut ledger);
4102
4103        assert_eq!(
4104            Some(key),
4105            matching_ledger_key(&ledger, "abcdefghij", "src/net.rs:12")
4106        );
4107    }
4108
4109    #[test]
4110    fn a_legacy_key_collision_does_not_merge_case_distinct_paths() {
4111        let mut ledger = Ledger::new();
4112        let key = crate::jsonx::finding_key("Unchecked error", "src/Main.rs:10");
4113        ledger.insert(
4114            key.clone(),
4115            LedgerEntry {
4116                title: "Unchecked error".into(),
4117                file: "src/Main.rs:10".into(),
4118                reasoning: "bounded by the caller".into(),
4119                round: 1,
4120                reraised: 0,
4121                outcome: Settled::Refuted,
4122            },
4123        );
4124
4125        assert_eq!(
4126            Some(key),
4127            matching_ledger_key(&ledger, "Unchecked error", "src/Main.rs:10")
4128        );
4129        assert!(matching_ledger_key(&ledger, "Unchecked error", "src/main.rs:10").is_none());
4130    }
4131
4132    /// The key a refutation records has to be the key the next round's finding
4133    /// hashes to. Recording it without the file made the guard dead code for
4134    /// every finding that named one, which is nearly all of them.
4135    #[test]
4136    fn a_refutation_lands_on_the_key_the_next_round_will_look_up() {
4137        let blocking = [finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
4138        let recorded = finding_key(&blocking[0].title, &blocking[0].file);
4139        let answer = disposition("unbounded loop!", "src/x.rs", Action::Refuted);
4140        assert!(disposition_matches(&blocking[0], &answer));
4141        assert_eq!(recorded, finding_key(&answer.title, &answer.file));
4142    }
4143
4144    /// Title punctuation is wording noise, while the path remains part of the
4145    /// identity. A response can vary punctuation without losing the point.
4146    #[test]
4147    fn the_ledger_key_ignores_title_punctuation() {
4148        let findings = [finding(
4149            "blocking",
4150            "Panic on multi-byte input",
4151            "d",
4152            "src/style.rs",
4153            true,
4154        )];
4155        let reworded = "Panic on multibyte input";
4156        let source = &findings[0];
4157        assert_eq!(
4158            finding_key(reworded, &source.file),
4159            finding_key(&source.title, &source.file)
4160        );
4161        let recorded = finding_key(&source.title, &source.file);
4162        let looked_up = finding_key(&findings[0].title, &findings[0].file);
4163        assert_eq!(recorded, looked_up);
4164    }
4165
4166    #[test]
4167    fn a_disposition_matches_its_finding_despite_wording_noise() {
4168        let findings = [finding(
4169            "blocking",
4170            "Unbounded loop!",
4171            "d",
4172            "src/x.rs",
4173            true,
4174        )];
4175        assert!(disposition_matches(
4176            &findings[0],
4177            &disposition("unbounded loop", "src/x.rs", Action::Refuted)
4178        ));
4179        assert!(!disposition_matches(
4180            &findings[0],
4181            &disposition("something else", "src/x.rs", Action::Refuted)
4182        ));
4183        assert!(!disposition_matches(
4184            &findings[0],
4185            &disposition("unbounded loop", "src/y.rs", Action::Refuted)
4186        ));
4187    }
4188
4189    #[test]
4190    fn an_omitted_disposition_leaves_the_blocker_unmatched() {
4191        let blocker = finding("blocking", "Unbounded loop", "d", "src/x.rs", true);
4192        assert!(matches!(
4193            matching_disposition(&blocker, std::slice::from_ref(&blocker), &[]),
4194            Err("no matching disposition")
4195        ));
4196    }
4197
4198    #[test]
4199    fn duplicate_dispositions_are_ambiguous() {
4200        let blocker = finding("blocking", "Unbounded loop", "d", "src/x.rs", true);
4201        let answers = vec![
4202            disposition("Unbounded loop", "src/x.rs", Action::Fixed),
4203            disposition("Unbounded loop", "src/x.rs", Action::Refuted),
4204        ];
4205        assert!(matches!(
4206            matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers),
4207            Err("more than one matching disposition")
4208        ));
4209    }
4210
4211    #[test]
4212    fn an_unambiguous_disposition_may_omit_the_line_number() {
4213        let blocker = finding(
4214            "blocking",
4215            "Replayed offers persist the wrong lane key",
4216            "d",
4217            "src/engine.rs:1166",
4218            true,
4219        );
4220        let answers = [disposition(
4221            "replayed offers persist the wrong lane key!",
4222            "src/engine.rs",
4223            Action::Fixed,
4224        )];
4225
4226        assert_eq!(
4227            0,
4228            matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers)
4229                .expect("stable answer")
4230                .0
4231        );
4232    }
4233
4234    #[test]
4235    fn a_line_free_disposition_does_not_choose_between_two_sites() {
4236        let first = finding("blocking", "Unchecked error", "d", "src/engine.rs:10", true);
4237        let second = finding(
4238            "blocking",
4239            "Unchecked error",
4240            "d",
4241            "src/engine.rs:200",
4242            true,
4243        );
4244        let findings = [first.clone(), second];
4245        let answers = [disposition(
4246            "Unchecked error",
4247            "src/engine.rs",
4248            Action::Fixed,
4249        )];
4250
4251        assert!(matches!(
4252            matching_disposition(&first, &findings, &answers),
4253            Err("no matching disposition")
4254        ));
4255    }
4256
4257    #[test]
4258    fn an_exact_location_wins_over_an_ambiguous_fallback() {
4259        let blocker = finding("blocking", "Unchecked error", "d", "src/engine.rs:10", true);
4260        let answers = [
4261            disposition("Unchecked error", "src/engine.rs", Action::Refuted),
4262            disposition("Unchecked error", "src/engine.rs:10", Action::Fixed),
4263        ];
4264
4265        let matched = matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers)
4266            .expect("exact answer");
4267        assert_eq!(1, matched.0);
4268        assert_eq!(Action::Fixed, matched.1.action);
4269    }
4270
4271    #[test]
4272    fn same_titled_findings_in_different_files_need_separate_dispositions() {
4273        let findings = [
4274            finding("blocking", "Unchecked error", "d", "src/a.rs", true),
4275            finding("blocking", "Unchecked error", "d", "src/b.rs", true),
4276        ];
4277        let answers = vec![
4278            disposition("Unchecked error", "src/a.rs", Action::Fixed),
4279            disposition("Unchecked error", "src/b.rs", Action::Refuted),
4280        ];
4281        assert_eq!(
4282            0,
4283            matching_disposition(&findings[0], &findings, &answers)
4284                .expect("left answer")
4285                .0
4286        );
4287        assert_eq!(
4288            1,
4289            matching_disposition(&findings[1], &findings, &answers)
4290                .expect("right answer")
4291                .0
4292        );
4293    }
4294
4295    #[test]
4296    fn a_reported_fix_without_a_commit_stays_open() {
4297        assert!(!fixed_disposition_resolves(false));
4298        assert!(fixed_disposition_resolves(true));
4299    }
4300
4301    #[test]
4302    fn the_settled_block_is_empty_when_nothing_is_settled() {
4303        assert_eq!("", settled_block(&Ledger::new()));
4304    }
4305
4306    #[test]
4307    fn the_settled_block_names_each_refutation() {
4308        let block = settled_block(&ledger_with("a point", "x.rs"));
4309        assert!(block.contains("a point"));
4310        assert!(block.contains("x.rs"));
4311        assert!(block.contains("settled"));
4312    }
4313
4314    #[test]
4315    fn same_title_settlements_name_each_location() {
4316        let mut ledger = ledger_with("Unchecked error", "a.rs:10");
4317        ledger.extend(ledger_with("Unchecked error", "b.rs:20"));
4318
4319        let block = settled_block(&ledger);
4320
4321        assert!(block.contains("Unchecked error (a.rs:10)"), "{block}");
4322        assert!(block.contains("Unchecked error (b.rs:20)"), "{block}");
4323    }
4324
4325    /// A point the author moved to its own issue is done with on this branch.
4326    /// Leaving it out of the block let the reviewer that keeps the PR raise it
4327    /// again every round until the budget ran out.
4328    #[test]
4329    fn a_filed_point_is_settled_too() {
4330        let mut ledger = ledger_with("out of scope", "x.rs");
4331        for entry in ledger.values_mut() {
4332            entry.outcome = Settled::Filed;
4333            entry.reasoning = "Tracked in #9.".into();
4334        }
4335        let block = settled_block(&ledger);
4336        assert!(block.contains("out of scope"));
4337        assert!(block.contains("#9"));
4338    }
4339
4340    /// The author answers the point again every round it is re-raised, so
4341    /// recording the answer must not wipe the count that ends the argument.
4342    #[test]
4343    fn answering_a_point_again_keeps_its_re_raise_count() {
4344        let mut ledger = ledger_with("a point", "x.rs");
4345        let entry = ledger.values().next().unwrap().clone();
4346        let mut state = IssueRun::new(1, "t");
4347        let blocking = vec![finding("blocking", "a point", "d", "x.rs", true)];
4348
4349        assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
4350        settle(&mut ledger, "a point", "x.rs", true, entry);
4351        assert!(check_relitigation(&mut ledger, &blocking, &mut state));
4352    }
4353
4354    // -- brevity ---------------------------------------------------------
4355
4356    #[test]
4357    /// No agent name, no round number, and no count of things listed below.
4358    /// The reader wants the review, not an account of who produced it.
4359    fn a_clean_review_is_just_the_verdict() {
4360        let text = review_comment("codex", 1, &review("Looks correct.", vec![]), &style());
4361        assert_eq!("Looks correct.", text);
4362    }
4363
4364    #[test]
4365    fn a_review_leads_with_the_counts() {
4366        let text = review_comment(
4367            "codex",
4368            2,
4369            &review(
4370                "One real problem.",
4371                vec![
4372                    finding(
4373                        "blocking",
4374                        "Loop never terminates",
4375                        "Confirmed by running it.",
4376                        "src/a.rs",
4377                        true,
4378                    ),
4379                    finding("non-blocking", "Name is vague", "d", "src/b.rs", true),
4380                    finding("nit", "Log wording", "d", "", true),
4381                ],
4382            ),
4383            &style(),
4384        );
4385        assert!(text.starts_with("One real problem."), "{text}");
4386        assert!(!text.contains("codex"), "no agent name: {text}");
4387        assert!(!text.contains("round 2"), "no round number: {text}");
4388    }
4389
4390    /// Only blocking findings carry their detail into the thread. Everything
4391    /// else is filed, and the detail belongs on the issue.
4392    #[test]
4393    fn only_blocking_findings_carry_their_detail() {
4394        let text = review_comment(
4395            "codex",
4396            1,
4397            &review(
4398                "s",
4399                vec![
4400                    finding("blocking", "Loop", "BLOCKING DETAIL", "a.rs", true),
4401                    finding("non-blocking", "Name", "NONBLOCKING DETAIL", "b.rs", true),
4402                ],
4403            ),
4404            &style(),
4405        );
4406        assert!(text.contains("BLOCKING DETAIL"), "{text}");
4407        assert!(!text.contains("NONBLOCKING DETAIL"), "{text}");
4408    }
4409
4410    #[test]
4411    /// A finding's explanation is what the author acts on. Cutting it to save
4412    /// characters leaves them nothing to act on and saves nothing worth having.
4413    fn a_thorough_explanation_reaches_the_author_intact() {
4414        let detail = "Reproduced by running the 429 test with max_attempts unset. ".repeat(8);
4415        let text = review_comment(
4416            "codex",
4417            1,
4418            &review(
4419                "One problem.",
4420                vec![finding("blocking", "T", &detail, "a.rs", true)],
4421            ),
4422            &style(),
4423        );
4424        assert!(
4425            text.contains(detail.trim()),
4426            "the explanation was cut:\n{text}"
4427        );
4428    }
4429
4430    /// A runaway is still bounded, just nowhere near tightly.
4431    #[test]
4432    fn a_runaway_model_is_still_bounded() {
4433        let long = "filler words. ".repeat(20_000);
4434        let text = review_comment(
4435            "codex",
4436            1,
4437            &review(&long, vec![finding("blocking", "T", &long, "a.rs", true)]),
4438            &style(),
4439        );
4440        assert!(
4441            text.len() < 30_000,
4442            "review comment was {} chars",
4443            text.len()
4444        );
4445    }
4446
4447    #[test]
4448    fn a_general_finding_has_no_empty_parenthesis() {
4449        let text = review_comment(
4450            "codex",
4451            1,
4452            &review("s", vec![finding("blocking", "Something", "d", "", true)]),
4453            &style(),
4454        );
4455        assert!(!text.contains("()"), "{text}");
4456        assert!(!text.contains("(general)"), "{text}");
4457    }
4458
4459    #[test]
4460    fn out_of_scope_findings_are_counted_separately() {
4461        let text = review_comment(
4462            "codex",
4463            1,
4464            &review(
4465                "s",
4466                vec![finding("blocking", "Old bug", "d", "a.rs", false)],
4467            ),
4468            &style(),
4469        );
4470        assert!(text.contains("out of scope"), "{text}");
4471        assert!(text.contains("Old bug"), "{text}");
4472    }
4473
4474    #[test]
4475    fn a_disposition_comment_leads_with_counts_and_keeps_refutations() {
4476        let response = ResponseDoc {
4477            summary: "Two of three were right.".into(),
4478            dispositions: vec![],
4479        };
4480        let text = disposition_comment(
4481            "claude",
4482            &response,
4483            &["Fixed thing".to_string()],
4484            &["Wrong thing. Because the caller already checks.".to_string()],
4485            &[],
4486            &style(),
4487        )
4488        .unwrap();
4489        assert!(text.starts_with("Two of three were right."), "{text}");
4490        assert!(!text.contains("claude"), "no agent name: {text}");
4491        assert!(
4492            text.contains("Because the caller already checks."),
4493            "{text}"
4494        );
4495    }
4496
4497    #[test]
4498    fn an_empty_disposition_comment_is_not_posted() {
4499        let response = ResponseDoc {
4500            summary: "s".into(),
4501            dispositions: vec![],
4502        };
4503        assert!(disposition_comment("claude", &response, &[], &[], &[], &style()).is_none());
4504    }
4505
4506    // -- the closing pass -------------------------------------------------
4507
4508    /// A closing pass is not allowed to publish its own commit. The remote head
4509    /// therefore keeps the same eligible reviewer on a later run.
4510    #[test]
4511    fn a_local_closing_commit_does_not_change_remote_custody() {
4512        for holder in ["a", "b"] {
4513            assert_eq!(holder, closing_next_actor(holder));
4514        }
4515    }
4516
4517    #[test]
4518    fn a_matching_head_keeps_saved_custody() {
4519        assert!(reconcile_saved_head(Some("abc123"), Some(2), true, "abc123", None, 42).unwrap());
4520        assert!(reconcile_saved_head(None, None, false, "abc123", None, 42).unwrap());
4521    }
4522
4523    #[test]
4524    fn a_changed_head_refuses_automatic_custody() {
4525        let error =
4526            reconcile_saved_head(Some("abc123"), Some(2), true, "def456", None, 42).unwrap_err();
4527        let text = error.to_string();
4528        assert!(text.contains("abc123"), "{text}");
4529        assert!(text.contains("def456"), "{text}");
4530        assert!(text.contains("--next <agent>"), "{text}");
4531    }
4532
4533    #[test]
4534    fn legacy_state_keeps_its_saved_custody_for_migration() {
4535        assert!(reconcile_saved_head(Some(""), Some(1), true, "def456", None, 42).unwrap());
4536    }
4537
4538    #[test]
4539    fn an_explicit_holder_resets_state_for_a_changed_or_legacy_head() {
4540        assert!(
4541            !reconcile_saved_head(Some("abc123"), Some(2), true, "def456", Some("b"), 42).unwrap()
4542        );
4543        assert!(!reconcile_saved_head(Some(""), Some(1), true, "def456", Some("b"), 42).unwrap());
4544    }
4545
4546    #[test]
4547    fn a_headless_current_or_future_state_is_not_legacy() {
4548        for version in [2, STATE_VERSION + 1] {
4549            assert!(
4550                reconcile_saved_head(Some(""), Some(version), true, "def456", None, 42).is_err()
4551            );
4552        }
4553    }
4554
4555    #[test]
4556    fn legacy_state_with_an_unknown_actor_refuses_to_guess() {
4557        assert!(reconcile_saved_head(Some(""), Some(1), false, "def456", None, 42).is_err());
4558    }
4559
4560    #[test]
4561    fn an_invalid_review_cannot_clear_a_carried_blocker() {
4562        let mut open = vec![finding(
4563            "blocking",
4564            "Unchecked error",
4565            "still fails",
4566            "src/a.rs:12",
4567            true,
4568        )];
4569        update_open_findings(&mut open, &[], false);
4570        assert_eq!(1, open.len());
4571
4572        update_open_findings(&mut open, &[], true);
4573        assert!(open.is_empty());
4574    }
4575
4576    #[test]
4577    fn a_final_round_with_no_commit_names_open_blockers() {
4578        let open = vec![finding(
4579            "blocking",
4580            "Unchecked error",
4581            "still fails",
4582            "src/a.rs",
4583            true,
4584        )];
4585        assert!(matches!(
4586            ending_without_landing(&open),
4587            Ending::Unresolved(points) if points.len() == 1
4588        ));
4589        assert!(matches!(ending_without_landing(&[]), Ending::Unchanged));
4590    }
4591
4592    #[test]
4593    fn a_closing_pass_uses_the_later_effort_tier() {
4594        assert_eq!(2, closing_effort_round(1));
4595        assert_eq!(8, closing_effort_round(7));
4596    }
4597
4598    #[test]
4599    fn a_ledger_with_no_claimed_fix_has_nothing_to_close_over() {
4600        assert!(!any_fixes(&ledger_with("refuted point", "a.rs"), 1));
4601        let mut fixed = ledger_with("fixed point", "b.rs");
4602        for entry in fixed.values_mut() {
4603            entry.outcome = Settled::Fixed;
4604        }
4605        assert!(any_fixes(&fixed, 1));
4606    }
4607
4608    /// A count of rounds is a fact about spar, and what is left is a fact about
4609    /// the branch.
4610    #[test]
4611    fn the_closing_note_counts_points_rather_than_rounds() {
4612        assert_eq!("one point left after the closing pass", unresolved_note(1));
4613        assert_eq!("3 points left after the closing pass", unresolved_note(3));
4614        assert!(!unresolved_note(2).contains("round"));
4615    }
4616
4617    fn fixed_ledger() -> Ledger {
4618        let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
4619        for entry in ledger.values_mut() {
4620            entry.outcome = Settled::Fixed;
4621            entry.reasoning = "bounded it on max_attempts".into();
4622        }
4623        ledger
4624    }
4625
4626    #[test]
4627    fn the_closing_prompt_names_every_fix_it_has_to_check() {
4628        let landed = vec!["abc1234 Bound the retry loop".to_string()];
4629        let prompt = close_prompt(
4630            "main",
4631            42,
4632            "Retry a 429",
4633            "9f8e7d6",
4634            Some(&landed),
4635            &fixed_ledger(),
4636            &[],
4637            1,
4638        );
4639        assert!(prompt.contains("Unbounded loop"), "{prompt}");
4640        assert!(prompt.contains("bounded it on max_attempts"), "{prompt}");
4641        assert!(prompt.contains("abc1234 Bound the retry loop"), "{prompt}");
4642        assert!(prompt.contains("git diff 9f8e7d6..HEAD"), "{prompt}");
4643        assert!(prompt.contains("git diff main...HEAD"), "{prompt}");
4644        assert!(!prompt.contains('{'), "{prompt}");
4645    }
4646
4647    /// Nothing landed is a real answer and a different one from "the harness
4648    /// cannot tell", and neither may leave a heading with nothing under it.
4649    #[test]
4650    fn a_close_with_nothing_landed_says_so_rather_than_leaving_a_hole() {
4651        let prompt = close_prompt(
4652            "main",
4653            42,
4654            "Retry a 429",
4655            "9f8e7d6",
4656            Some(&[]),
4657            &Ledger::new(),
4658            &[],
4659            1,
4660        );
4661        assert!(
4662            prompt.contains("Nothing landed after the last round"),
4663            "{prompt}"
4664        );
4665        assert!(!prompt.contains('{'), "{prompt}");
4666    }
4667
4668    /// A commit message that breaks the style rules is rewritten, which moves
4669    /// every hash after it, so the head a round recorded can stop being on the
4670    /// branch. `git log` answers that with the whole branch, and reporting all
4671    /// of it as newly landed would be false. The full branch remains the audit
4672    /// scope either way.
4673    #[test]
4674    fn a_rewritten_branch_admits_it_cannot_say_what_landed() {
4675        let prompt = close_prompt(
4676            "main",
4677            42,
4678            "Retry a 429",
4679            "9f8e7d6",
4680            None,
4681            &fixed_ledger(),
4682            &[],
4683            1,
4684        );
4685        assert!(prompt.contains("were rewritten"), "{prompt}");
4686        assert!(prompt.contains("git diff main...HEAD"), "{prompt}");
4687        assert!(!prompt.contains("nobody has read it"), "{prompt}");
4688        assert!(!prompt.contains('{'), "{prompt}");
4689    }
4690
4691    /// The pass may not write, and the loop rolls back and says the prompt
4692    /// forbids it, so the prompt has to actually forbid it.
4693    #[test]
4694    fn the_closing_prompt_forbids_the_writing_the_loop_rolls_back() {
4695        let prompt = close_prompt(
4696            "main",
4697            42,
4698            "t",
4699            "9f8e7d6",
4700            Some(&[]),
4701            &Ledger::new(),
4702            &[],
4703            1,
4704        );
4705        assert!(prompt.contains("do not commit"), "{prompt}");
4706    }
4707
4708    /// Missing a serious defect in an earlier round does not make it safe.
4709    #[test]
4710    fn the_closing_prompt_keeps_confirmed_merge_blockers_blocking() {
4711        let prompt = close_prompt(
4712            "main",
4713            42,
4714            "t",
4715            "9f8e7d6",
4716            Some(&[]),
4717            &Ledger::new(),
4718            &[],
4719            1,
4720        );
4721        assert!(
4722            prompt.contains("serious defect an\nearlier round missed"),
4723            "{prompt}"
4724        );
4725        assert!(prompt.contains("final merge-safety audit"), "{prompt}");
4726        assert!(!prompt.contains("not another\naudit"), "{prompt}");
4727        assert!(!prompt.contains("A\nfinding means"), "{prompt}");
4728        assert!(prompt.contains("A\nblocking finding means"), "{prompt}");
4729        let flat = prompt.split_whitespace().collect::<Vec<_>>().join(" ");
4730        assert!(flat.contains("does not become non-blocking"), "{prompt}");
4731        assert!(flat.contains("Only one of them ships"), "{prompt}");
4732    }
4733
4734    /// The closing pass had two routes for a real point, block or in_scope=false,
4735    /// and `blocks()` is `severity == Blocking && in_scope`, so the second one
4736    /// silently opens the merge gate. A closer taking it filed an issue saying
4737    /// the branch must not merge and merged the branch.
4738    #[test]
4739    fn the_closing_pass_is_offered_a_severity_rather_than_the_field_that_gates() {
4740        let prompt = close_prompt(
4741            "main",
4742            42,
4743            "t",
4744            "9f8e7d6",
4745            Some(&[]),
4746            &Ledger::new(),
4747            &[],
4748            1,
4749        );
4750        assert!(
4751            prompt.contains("Minor defects and improvements are\nnon-blocking"),
4752            "{prompt}"
4753        );
4754        assert!(
4755            prompt.contains("a real defect\nthis pull request did not cause"),
4756            "{prompt}"
4757        );
4758    }
4759
4760    /// A point that only ever reached `in_scope = false` never reaches
4761    /// `blocking`, whatever severity it carries, so the run merges.
4762    #[test]
4763    fn an_out_of_scope_point_cannot_gate_the_close() {
4764        let out_of_scope = finding("blocking", "Adjacent leak", "d", "o.rs", false);
4765        assert!(!out_of_scope.blocks());
4766        assert!(approval_stands(&[], false));
4767    }
4768
4769    /// The closing pass reads what the last round left. Carrying every fix a
4770    /// pull request ever saw would hand a resumed run's close nine rounds of
4771    /// answered points, which is the unbounded surface this replaces.
4772    #[test]
4773    fn a_fix_is_shown_to_the_pass_that_has_to_check_it_and_not_after() {
4774        let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
4775        for entry in ledger.values_mut() {
4776            entry.outcome = Settled::Fixed;
4777            entry.round = 2;
4778        }
4779        // Round 3 follows the round that claimed it.
4780        assert!(answers_block(&ledger, 3).contains("Unbounded loop"));
4781        // Round 4 does not: round 3 read it and did not raise it again.
4782        assert_eq!("", answers_block(&ledger, 4));
4783        assert!(any_fixes(&ledger, 2));
4784        assert!(!any_fixes(&ledger, 3));
4785    }
4786
4787    // -- the review prompt ----------------------------------------------
4788
4789    /// A round that fixed nine findings left nothing behind, so the next round
4790    /// met the fix as ordinary code with no sign anybody had asked for it.
4791    #[test]
4792    fn the_answers_block_asks_the_reviewer_to_check_rather_than_to_trust() {
4793        let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
4794        for entry in ledger.values_mut() {
4795            entry.outcome = Settled::Fixed;
4796            entry.reasoning = "bounded it on max_attempts".into();
4797        }
4798        let block = answers_block(&ledger, 2);
4799        assert!(block.contains("Unbounded loop"), "{block}");
4800        assert!(block.contains("src/x.rs"), "{block}");
4801        assert!(block.contains("bounded it on max_attempts"), "{block}");
4802        assert!(block.contains("Check the answer"), "{block}");
4803        assert!(!block.contains("settled"), "{block}");
4804    }
4805
4806    /// A refutation is an argument to weigh and a fix is a claim to check, and
4807    /// the two blocks say opposite things. Neither may carry the other's points.
4808    #[test]
4809    fn a_fix_and_a_refutation_do_not_share_a_heading() {
4810        let mut ledger = ledger_with("refuted point", "a.rs");
4811        ledger.extend(ledger_with("fixed point", "b.rs"));
4812        for entry in ledger.values_mut() {
4813            if entry.title == "fixed point" {
4814                entry.outcome = Settled::Fixed;
4815            }
4816        }
4817        let answers = answers_block(&ledger, 2);
4818        let settled = settled_block(&ledger);
4819        assert!(answers.contains("fixed point") && !answers.contains("refuted point"));
4820        assert!(settled.contains("refuted point") && !settled.contains("fixed point"));
4821    }
4822
4823    #[test]
4824    fn an_empty_ledger_adds_no_answers_block() {
4825        assert_eq!("", answers_block(&Ledger::new(), 2));
4826    }
4827
4828    /// A point held back for a later round does not get one, so the reviewer is
4829    /// told which round is the last that can ask for anything.
4830    #[test]
4831    fn the_last_round_that_can_ask_for_anything_says_so() {
4832        assert_eq!("", round_note(1, 3));
4833        assert_eq!("", round_note(2, 3));
4834        assert!(round_note(3, 3).contains("last round"));
4835        // Round numbers keep counting up across a resume, so the last round of
4836        // an invocation is not round `max_rounds`.
4837        assert!(round_note(6, 6).contains("last round"));
4838    }
4839
4840    /// Telling a reviewer when the asking stops must never tell it to want less.
4841    /// A reviewer that lowers its bar to finish is the failure this loop was
4842    /// built against, so the note carries no severity vocabulary at all. That
4843    /// the pull request may merge afterwards is a fact about the harness, and
4844    /// saying it is not the same as asking for an approval.
4845    #[test]
4846    fn saying_when_the_asking_stops_says_nothing_about_severity() {
4847        let note = round_note(3, 3);
4848        for word in ["approve", "blocking", "severity", "nit"] {
4849            assert!(!note.contains(word), "{word} in: {note}");
4850        }
4851    }
4852
4853    #[test]
4854    fn the_review_prompt_leaves_nothing_unsubstituted() {
4855        let empty = review_prompt("main", 42, "Retry a 429", &Ledger::new(), &[], 1, 3);
4856        assert!(!empty.contains('{'), "{empty}");
4857        assert!(empty.contains("main") && empty.contains("#42") && empty.contains("Retry a 429"));
4858
4859        let mut ledger = ledger_with("refuted point", "a.rs");
4860        ledger.extend(ledger_with("fixed point", "b.rs"));
4861        for entry in ledger.values_mut() {
4862            if entry.title == "fixed point" {
4863                entry.outcome = Settled::Fixed;
4864                entry.round = 2;
4865            }
4866        }
4867        let full = review_prompt("main", 42, "Retry a 429", &ledger, &[], 3, 3);
4868        assert!(!full.contains('{'), "{full}");
4869        assert!(full.contains("fixed point") && full.contains("refuted point"));
4870        assert!(full.contains("last round"), "{full}");
4871    }
4872
4873    #[test]
4874    fn a_resumed_open_finding_reaches_review_and_closing_prompts() {
4875        let open = vec![finding(
4876            "blocking",
4877            "Retry bypasses the limit",
4878            "reproduced with max_attempts set to one",
4879            "src/net.rs:88",
4880            true,
4881        )];
4882        let review = review_prompt("main", 42, "Retry a 429", &Ledger::new(), &open, 2, 3);
4883        let close = close_prompt(
4884            "main",
4885            42,
4886            "Retry a 429",
4887            "9f8e7d6",
4888            Some(&[]),
4889            &Ledger::new(),
4890            &open,
4891            2,
4892        );
4893        for prompt in [review, close] {
4894            assert!(prompt.contains("Retry bypasses the limit"), "{prompt}");
4895            assert!(prompt.contains("src/net.rs:88"), "{prompt}");
4896            assert!(
4897                prompt.contains("reproduced with max_attempts set to one"),
4898                "{prompt}"
4899            );
4900            assert!(!prompt.contains('{'), "{prompt}");
4901        }
4902    }
4903
4904    /// A confirmed defect that is minor had no label but blocking: non-blocking
4905    /// was defined as an improvement, and nit as taste. Severity gating is the
4906    /// whole defence against the nitpick spiral, and it had a hole in it.
4907    #[test]
4908    fn a_minor_defect_has_a_severity_that_is_not_blocking() {
4909        assert!(
4910            REVIEW_PROMPT.contains("A minor defect belongs\n  here as much as an improvement does")
4911        );
4912        // The schema is shared with `spar review`, which has no rounds, so it
4913        // and that prompt carry the same ladder without the round neither can
4914        // spend. Two definitions of one enum value in one request is how a
4915        // reviewer ends up applying a cost model that does not exist.
4916        for text in [
4917            schema::review().to_string(),
4918            crate::review_only::review_only_prompt().to_string(),
4919        ] {
4920            assert!(text.contains("as much as an improvement does"), "{text}");
4921            assert!(!text.contains("a genuine improvement"), "{text}");
4922        }
4923    }
4924
4925    /// Doubt used to resolve onto `in_scope = true`, which is half of what gates
4926    /// a merge. It resolves onto the severity instead, which is not.
4927    #[test]
4928    fn doubt_resolves_away_from_the_field_that_gates() {
4929        for text in [REVIEW_PROMPT.to_string(), schema::review().to_string()] {
4930            assert!(text.contains("say your piece in the finding and label it non-blocking"));
4931            assert!(!text.contains("leave in_scope true"));
4932        }
4933    }
4934
4935    /// Every line a fix adds is what the next pass reviews, so a fix that grows
4936    /// the branch buys another round of findings about the fix.
4937    #[test]
4938    fn both_edit_prompts_ask_for_the_smallest_change_that_answers_the_point() {
4939        for prompt in [FIX_PROMPT, RESPOND_PROMPT] {
4940            assert!(
4941                prompt.contains("The smallest change that answers it is"),
4942                "{prompt}"
4943            );
4944        }
4945        assert!(RESPOND_PROMPT.contains("bigger than the\n  problem it names"));
4946    }
4947
4948    #[test]
4949    fn a_fixed_disposition_must_explain_what_changed() {
4950        let flat = RESPOND_PROMPT
4951            .split_whitespace()
4952            .collect::<Vec<_>>()
4953            .join(" ");
4954        assert!(
4955            flat.contains("For fixed, say what changed and how it answers the point"),
4956            "{RESPOND_PROMPT}"
4957        );
4958    }
4959
4960    #[test]
4961    fn an_empty_fix_reason_still_renders_as_a_claim_to_check() {
4962        let mut ledger = ledger_with("Unchecked error", "src/net.rs");
4963        for entry in ledger.values_mut() {
4964            entry.outcome = Settled::Fixed;
4965            entry.reasoning.clear();
4966        }
4967
4968        let lines = fixed_lines(&ledger, 0);
4969
4970        assert_eq!(1, lines.len());
4971        assert!(lines[0].contains("a committed change claims to address this point"));
4972        assert!(!lines[0].contains("The author said"));
4973    }
4974
4975    /// Both, not either. The link is how an agent that can reach the network
4976    /// reads the discussion spar does not fetch, and the body is what the one
4977    /// that cannot works from: codex runs with no network, so a link alone
4978    /// would leave it building from the title.
4979    #[test]
4980    fn the_implementor_is_given_the_link_and_the_body() {
4981        let prompt = implement_prompt(
4982            42,
4983            "Retry a 429",
4984            "https://github.com/o/r/issues/42",
4985            "A rate limited response was treated as fatal.",
4986        );
4987        assert!(
4988            prompt.contains("https://github.com/o/r/issues/42"),
4989            "{prompt}"
4990        );
4991        assert!(
4992            prompt.contains("A rate limited response was treated as fatal."),
4993            "{prompt}"
4994        );
4995        assert!(prompt.contains("#42"), "{prompt}");
4996        assert!(prompt.contains("Retry a 429"), "{prompt}");
4997        // Nothing left unsubstituted.
4998        assert!(!prompt.contains('{'), "{prompt}");
4999    }
5000
5001    /// An agent that cannot reach the link is told what it is missing, so it
5002    /// works from the body rather than assuming the body is everything.
5003    #[test]
5004    fn the_prompt_says_the_discussion_is_not_included() {
5005        let prompt = implement_prompt(1, "t", "u", "b");
5006        // Flattened, so the assertion does not turn on where the prompt wraps.
5007        let lower = prompt
5008            .split_whitespace()
5009            .collect::<Vec<_>>()
5010            .join(" ")
5011            .to_lowercase();
5012        assert!(
5013            lower.contains("discussion since is not included"),
5014            "{prompt}"
5015        );
5016        assert!(lower.contains("cannot reach the network"), "{prompt}");
5017    }
5018
5019    /// A fully reported implementation, for the body tests.
5020    fn worked() -> Implementation {
5021        Implementation {
5022            summary: "Retry a 429 instead of failing the run.".into(),
5023            problem: "A rate limited response was treated as fatal, so one throttled call ended \
5024                      a run that had hours of work left in it."
5025                .into(),
5026            changes: vec![
5027                "`send` retries a 429 with the delay the header asks for".into(),
5028                "the retry budget is bounded, so a permanent 429 still ends".into(),
5029            ],
5030            testing: vec![
5031                "`cargo test retries_a_429`".into(),
5032                "point it at a throttled endpoint and watch it finish".into(),
5033            ],
5034            ..Implementation::default()
5035        }
5036    }
5037
5038    #[test]
5039    /// GitHub renders the file count and the plus and minus figures in the
5040    /// header, immediately above whatever spar writes, so neither is here.
5041    fn a_pr_body_is_what_it_closes_and_what_changed() {
5042        let body = pr_body(42, &worked(), &style());
5043        assert_eq!(
5044            "Closes #42\n\n\
5045             Retry a 429 instead of failing the run.\n\n\
5046             A rate limited response was treated as fatal, so one throttled call \
5047             ended a run that had hours of work left in it.\n\n\
5048             ## What changed\n\n\
5049             - `send` retries a 429 with the delay the header asks for\n\
5050             - the retry budget is bounded, so a permanent 429 still ends\n\n\
5051             ## How to test\n\n\
5052             - `cargo test retries_a_429`\n\
5053             - point it at a throttled endpoint and watch it finish",
5054            body
5055        );
5056    }
5057
5058    /// The sections are optional and the lead is not. A one line fix should
5059    /// read as one, not as a form with most of it left blank.
5060    #[test]
5061    fn a_body_with_nothing_to_list_carries_no_empty_headings() {
5062        let work = Implementation {
5063            summary: "Retry a 429 instead of failing the run.".into(),
5064            ..Implementation::default()
5065        };
5066        assert_eq!(
5067            "Closes #42\n\nRetry a 429 instead of failing the run.",
5068            pr_body(42, &work, &style())
5069        );
5070    }
5071
5072    #[test]
5073    fn a_pr_body_survives_an_implementor_that_said_nothing() {
5074        assert_eq!(
5075            "Closes #7",
5076            pr_body(7, &Implementation::default(), &style())
5077        );
5078    }
5079
5080    /// Blank entries are the model's, not the reader's problem. A heading whose
5081    /// only bullet was an empty string used to be possible.
5082    #[test]
5083    fn blank_list_entries_do_not_earn_a_heading() {
5084        let work = Implementation {
5085            summary: "Did a thing.".into(),
5086            changes: vec![String::new(), "   ".into()],
5087            ..Implementation::default()
5088        };
5089        let body = pr_body(42, &work, &style());
5090        assert!(!body.contains("What changed"), "{body}");
5091    }
5092
5093    #[test]
5094    fn notes_appear_only_when_there_is_something_to_note() {
5095        let mut work = worked();
5096        assert!(!pr_body(42, &work, &style()).contains("## Notes"));
5097        work.notes = Some("The retry is not applied to streaming calls.".into());
5098        let body = pr_body(42, &work, &style());
5099        assert!(body.contains("## Notes"), "{body}");
5100        assert!(body.contains("streaming calls"), "{body}");
5101    }
5102
5103    /// An issue that produced no commits is told so. Never the summary, which
5104    /// describes a change that is not in the branch.
5105    #[test]
5106    fn declining_posts_the_reason_and_not_the_summary() {
5107        let work = Implementation {
5108            not_worth_doing: true,
5109            reason: "Already fixed in 1.2, and the report predates it.".into(),
5110            summary: "Nothing to do.".into(),
5111            ..Implementation::default()
5112        };
5113        assert_eq!(
5114            "Already fixed in 1.2, and the report predates it.",
5115            no_pr_note(&work, &style())
5116        );
5117    }
5118
5119    #[test]
5120    fn reporting_work_and_committing_none_says_that_rather_than_the_summary() {
5121        let work = Implementation {
5122            summary: "Retry a 429 instead of failing the run.".into(),
5123            ..Implementation::default()
5124        };
5125        let note = no_pr_note(&work, &style());
5126        assert_eq!(
5127            "Nothing was committed, so there is nothing to review.",
5128            note
5129        );
5130    }
5131
5132    #[test]
5133    fn declining_without_a_reason_still_says_something() {
5134        let work = Implementation {
5135            not_worth_doing: true,
5136            ..Implementation::default()
5137        };
5138        assert!(no_pr_note(&work, &style()).contains("no reason given"));
5139    }
5140
5141    #[test]
5142    fn a_skip_comment_is_only_the_reasoning() {
5143        let item = SkippedItem {
5144            issue: 3,
5145            title: "t".into(),
5146            tracker: false,
5147            reasons: [
5148                ("claude".to_string(), "Already fixed in 1.2.".to_string()),
5149                ("codex".to_string(), "Duplicate of #2.".to_string()),
5150            ]
5151            .into_iter()
5152            .collect(),
5153        };
5154        let text = skip_comment(&item, &style());
5155        assert!(text.contains("Already fixed in 1.2."), "{text}");
5156        assert!(text.contains("Duplicate of #2."), "{text}");
5157        assert!(
5158            !text.contains("claude") && !text.contains("codex"),
5159            "{text}"
5160        );
5161        assert!(!text.to_lowercase().contains("not scheduled"), "{text}");
5162        assert!(text.lines().count() <= 3, "{text}");
5163    }
5164
5165    #[test]
5166    fn findings_for_a_model_keep_full_detail() {
5167        let long = "x".repeat(2000);
5168        let text = findings_for_prompt(&[finding("blocking", "T", &long, "a.rs", true)]);
5169        assert!(
5170            text.contains(&long),
5171            "a model needs the whole finding, only humans need brevity"
5172        );
5173    }
5174
5175    #[test]
5176    fn findings_for_a_model_are_never_empty() {
5177        assert_eq!("(none)", findings_for_prompt(&[]));
5178    }
5179}
5180
5181#[cfg(test)]
5182mod outcome_tests {
5183    use super::*;
5184    use crate::model::{Dispute, Severity};
5185
5186    fn style() -> Style {
5187        Style::default()
5188    }
5189
5190    fn state_with(disputes: Vec<(&str, &str)>, filed: Vec<&str>) -> IssueRun {
5191        let mut s = IssueRun::new(482, "t");
5192        s.disputes = disputes
5193            .into_iter()
5194            .map(|(title, reasoning)| Dispute {
5195                title: title.into(),
5196                file: String::new(),
5197                reasoning: reasoning.into(),
5198            })
5199            .collect();
5200        s.filed = filed.into_iter().map(String::from).collect();
5201        s
5202    }
5203
5204    fn finding(title: &str, file: &str) -> Finding {
5205        Finding {
5206            severity: Severity::Blocking,
5207            title: title.into(),
5208            detail: "d".into(),
5209            file: file.into(),
5210            in_scope: true,
5211            ..Default::default()
5212        }
5213    }
5214
5215    fn graded(severity: Severity, title: &str, file: &str, in_scope: bool) -> Finding {
5216        Finding {
5217            severity,
5218            in_scope,
5219            ..finding(title, file)
5220        }
5221    }
5222
5223    #[test]
5224    fn outcome_mode_routes_final_results_to_the_configured_sink() {
5225        assert_eq!(OutcomeSink::PullRequest, outcome_sink(PrComments::Outcome));
5226        assert_eq!(OutcomeSink::PullRequest, outcome_sink(PrComments::Rounds));
5227        assert_eq!(OutcomeSink::Terminal, outcome_sink(PrComments::None));
5228    }
5229
5230    /// The absence of objections is the message. A PR that reviewed cleanly and
5231    /// filed nothing should leave no trace in the thread at all.
5232    #[test]
5233    fn a_clean_approval_says_nothing() {
5234        let state = state_with(vec![], vec![]);
5235        assert!(outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).is_none());
5236    }
5237
5238    /// Widening the ladder makes downgrading the easy answer, and under the
5239    /// defaults a non-blocking finding is filed nowhere and commented nowhere.
5240    /// Without this, a reviewer could make a real defect disappear by relabelling
5241    /// it, and the pull request would look exactly like a clean one.
5242    #[test]
5243    fn a_downgraded_finding_still_reaches_the_pull_request() {
5244        let mut state = state_with(vec![], vec![]);
5245        let kept = graded(
5246            Severity::NonBlocking,
5247            "Timeout is not configurable",
5248            "n.rs",
5249            true,
5250        );
5251        record_nonblocking_outcome(&mut state, &kept, None);
5252
5253        // A nit is taste, and an out of scope point is filed rather than noted.
5254        // Neither belongs in a list a person reads for what was let through.
5255        assert_eq!(1, state.noted.len());
5256
5257        let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
5258        assert!(text.contains("Noted, not blocking"), "{text}");
5259        assert!(
5260            text.contains("Timeout is not configurable (n.rs)"),
5261            "{text}"
5262        );
5263    }
5264
5265    /// With follow-ups on, every one of these is already an issue and already
5266    /// named under "Filed separately". Two headings for one point reads as two.
5267    #[test]
5268    fn a_point_that_was_filed_is_not_also_noted() {
5269        let mut state = state_with(vec![], vec![]);
5270        let finding = graded(Severity::NonBlocking, "Timeout", "n.rs", true);
5271        record_nonblocking_outcome(&mut state, &finding, None);
5272        record_nonblocking_outcome(
5273            &mut state,
5274            &finding,
5275            Some(&Followup::Recorded("https://example.invalid/9".into())),
5276        );
5277        assert!(state.noted.is_empty());
5278        assert_eq!(vec!["https://example.invalid/9"], state.filed);
5279    }
5280
5281    #[test]
5282    fn filing_a_moved_point_removes_its_earlier_note() {
5283        let mut state = state_with(vec![], vec![]);
5284        let earlier = graded(Severity::NonBlocking, "Timeout", "src/net.rs:10", true);
5285        let moved = graded(Severity::NonBlocking, "Timeout", "src/net.rs:12", false);
5286        record_nonblocking_outcome(&mut state, &earlier, None);
5287
5288        record_nonblocking_outcome(
5289            &mut state,
5290            &moved,
5291            Some(&Followup::Recorded("https://example.invalid/10".into())),
5292        );
5293
5294        assert!(state.noted.is_empty());
5295        assert_eq!(vec!["https://example.invalid/10"], state.filed);
5296    }
5297
5298    #[test]
5299    fn an_unrecorded_nonblocking_followup_remains_noted() {
5300        let finding = graded(Severity::NonBlocking, "Timeout", "n.rs", true);
5301        for outcome in [
5302            Followup::Covered("https://example.invalid/closed".into()),
5303            Followup::Dropped("follow-ups are off"),
5304            Followup::Failed,
5305        ] {
5306            let mut state = state_with(vec![], vec![]);
5307            record_nonblocking_outcome(&mut state, &finding, Some(&outcome));
5308            assert_eq!(1, state.noted.len(), "{outcome:?}");
5309            assert!(state.filed.is_empty(), "{outcome:?}");
5310        }
5311    }
5312
5313    /// The same point raised again in a later round is one point, not three.
5314    #[test]
5315    fn a_point_noted_twice_is_listed_once() {
5316        let mut state = state_with(vec![], vec![]);
5317        let raised = graded(
5318            Severity::NonBlocking,
5319            "Timeout is not configurable",
5320            "n.rs",
5321            true,
5322        );
5323        let reworded = graded(
5324            Severity::NonBlocking,
5325            "timeout is not configurable!",
5326            "n.rs",
5327            true,
5328        );
5329        record_nonblocking_outcome(&mut state, &raised, None);
5330        record_nonblocking_outcome(&mut state, &reworded, None);
5331        assert_eq!(1, state.noted.len());
5332    }
5333
5334    #[test]
5335    fn same_title_notes_in_different_files_are_both_kept() {
5336        let mut state = state_with(vec![], vec![]);
5337        for file in ["src/a.rs", "src/b.rs"] {
5338            let finding = graded(Severity::NonBlocking, "Unchecked error", file, true);
5339            record_nonblocking_outcome(&mut state, &finding, None);
5340        }
5341        assert_eq!(2, state.noted.len());
5342    }
5343
5344    #[test]
5345    fn same_title_notes_at_two_sites_in_one_review_are_both_kept() {
5346        let findings = vec![
5347            graded(
5348                Severity::NonBlocking,
5349                "Unchecked error",
5350                "src/a.rs:10",
5351                true,
5352            ),
5353            graded(
5354                Severity::NonBlocking,
5355                "Unchecked error",
5356                "src/a.rs:200",
5357                true,
5358            ),
5359        ];
5360        let mut state = state_with(vec![], vec![]);
5361
5362        for finding in &findings {
5363            record_nonblocking_outcome_with_match(
5364                &mut state,
5365                finding,
5366                None,
5367                unique_stable_finding(&findings, finding),
5368            );
5369        }
5370
5371        assert_eq!(2, state.noted.len());
5372    }
5373
5374    #[test]
5375    fn settling_one_of_two_same_title_notes_keeps_the_other() {
5376        let first = graded(
5377            Severity::NonBlocking,
5378            "Unchecked error",
5379            "src/a.rs:10",
5380            true,
5381        );
5382        let second = graded(
5383            Severity::NonBlocking,
5384            "Unchecked error",
5385            "src/a.rs:200",
5386            true,
5387        );
5388        let mut state = state_with(vec![], vec![]);
5389        remember_noted(&mut state, &first, false);
5390        remember_noted(&mut state, &second, false);
5391
5392        forget_noted(&mut state, &first, false);
5393
5394        assert_eq!(1, state.noted.len());
5395        assert_eq!("src/a.rs:200", state.noted[0].file);
5396    }
5397
5398    #[test]
5399    fn same_title_disputes_at_two_sites_are_both_kept() {
5400        let mut state = state_with(vec![], vec![]);
5401        for file in ["src/a.rs:10", "src/a.rs:200"] {
5402            remember_dispute(
5403                &mut state,
5404                Dispute {
5405                    title: "Unchecked error".into(),
5406                    file: file.into(),
5407                    reasoning: "the caller handles it".into(),
5408                },
5409                false,
5410            );
5411        }
5412
5413        assert_eq!(2, state.disputes.len());
5414    }
5415
5416    #[test]
5417    fn a_later_nonblocking_verdict_replaces_a_prior_dispute() {
5418        let mut state = state_with(vec![], vec![]);
5419        let finding = graded(Severity::NonBlocking, "Unchecked error", "src/a.rs", true);
5420        remember_dispute(
5421            &mut state,
5422            Dispute {
5423                title: finding.title.clone(),
5424                file: finding.file.clone(),
5425                reasoning: "the caller handles it".into(),
5426            },
5427            true,
5428        );
5429
5430        record_nonblocking_outcome(&mut state, &finding, None);
5431
5432        assert!(state.disputes.is_empty());
5433        assert_eq!(1, state.noted.len());
5434    }
5435
5436    #[test]
5437    fn a_note_moving_lines_in_the_same_file_is_updated() {
5438        let mut state = state_with(vec![], vec![]);
5439        let first = graded(
5440            Severity::NonBlocking,
5441            "Unchecked error",
5442            "src/a.rs:12",
5443            true,
5444        );
5445        let moved = graded(
5446            Severity::NonBlocking,
5447            "Unchecked error",
5448            "src/a.rs:19",
5449            true,
5450        );
5451        record_nonblocking_outcome(&mut state, &first, None);
5452        record_nonblocking_outcome(&mut state, &moved, None);
5453        assert_eq!(1, state.noted.len());
5454        assert_eq!("src/a.rs:19", state.noted[0].file);
5455    }
5456
5457    #[test]
5458    fn a_settled_point_removes_its_stale_note() {
5459        for outcome in [Settled::Fixed, Settled::Refuted, Settled::Filed] {
5460            let noted = graded(Severity::NonBlocking, "Unchecked error", "src/a.rs", true);
5461            let mut state = state_with(vec![], vec![]);
5462            record_nonblocking_outcome(&mut state, &noted, None);
5463            forget_noted(&mut state, &noted, true);
5464            assert!(state.noted.is_empty(), "{outcome}");
5465        }
5466    }
5467
5468    #[test]
5469    fn settling_a_moved_point_removes_its_old_dispute() {
5470        let old = graded(Severity::Blocking, "Unchecked error", "src/a.rs:10", true);
5471        let moved = graded(Severity::Blocking, "Unchecked error", "src/a.rs:12", true);
5472        let mut state = state_with(vec![], vec![]);
5473        remember_dispute(
5474            &mut state,
5475            Dispute {
5476                title: old.title,
5477                file: old.file,
5478                reasoning: "the caller handles it".into(),
5479            },
5480            true,
5481        );
5482
5483        forget_dispute(&mut state, &moved, true);
5484
5485        assert!(state.disputes.is_empty());
5486    }
5487
5488    /// A point already printed with its argument attached is not printed again
5489    /// under a second heading.
5490    #[test]
5491    fn a_deadlocked_point_is_not_also_noted() {
5492        let mut state = state_with(vec![], vec![]);
5493        let noted = graded(Severity::NonBlocking, "Unbounded loop", "x.rs", true);
5494        record_nonblocking_outcome(&mut state, &noted, None);
5495        let points = vec![finding("Unbounded loop", "x.rs")];
5496        let text = outcome_comment(
5497            &state,
5498            &Ledger::new(),
5499            &Ending::Deadlocked(&points),
5500            &style(),
5501        )
5502        .unwrap();
5503        assert!(!text.contains("Noted, not blocking"), "{text}");
5504    }
5505
5506    #[test]
5507    fn clipping_a_rendered_title_does_not_break_duplicate_suppression() {
5508        let mut compact = style();
5509        compact.max_title_chars = 5;
5510        let mut state = state_with(
5511            vec![("abcdefghij", "the caller already handles it")],
5512            vec![],
5513        );
5514        state
5515            .noted
5516            .push(graded(Severity::NonBlocking, "abcdefghij", "x.rs", true));
5517        state.disputes[0].file = "x.rs".into();
5518        let points = vec![finding("abcdefghij", "x.rs")];
5519
5520        let text = outcome_comment(
5521            &state,
5522            &Ledger::new(),
5523            &Ending::Unresolved(&points),
5524            &compact,
5525        )
5526        .unwrap();
5527
5528        assert!(!text.contains("Raised and refuted"), "{text}");
5529        assert!(!text.contains("Noted, not blocking"), "{text}");
5530    }
5531
5532    #[test]
5533    fn an_approval_that_filed_follow_ups_links_them() {
5534        let state = state_with(
5535            vec![],
5536            vec![
5537                "https://github.com/you/thing/issues/485",
5538                "https://github.com/you/thing/issues/486",
5539            ],
5540        );
5541        let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
5542        assert!(text.contains("Filed separately: #485, #486"), "{text}");
5543    }
5544
5545    /// The skip path is taken when nothing landed, so the sentence about fixes
5546    /// that were pushed and not read is false there. Sending a maintainer to
5547    /// read a commit that does not exist is worse than saying nothing.
5548    #[test]
5549    fn a_run_that_changed_nothing_does_not_claim_there_is_something_to_read() {
5550        let state = state_with(vec![], vec![]);
5551        let text = outcome_comment(&state, &Ledger::new(), &Ending::Unchanged, &style()).unwrap();
5552        assert!(text.contains("changed nothing"), "{text}");
5553        assert!(!text.contains("was pushed"), "{text}");
5554    }
5555
5556    /// Telling a maintainer that the last round was pushed and nobody read it
5557    /// gives them nothing they can act on. What is left, with where it is, is
5558    /// three lines and a decision.
5559    #[test]
5560    fn an_unresolved_close_names_what_is_still_wrong() {
5561        let state = state_with(vec![], vec![]);
5562        let left = vec![Finding {
5563            detail: "The guard sits after the early return.".into(),
5564            ..finding("The retry fix never reaches the 429 path", "src/net.rs:88")
5565        }];
5566        let text =
5567            outcome_comment(&state, &Ledger::new(), &Ending::Unresolved(&left), &style()).unwrap();
5568        assert!(text.contains("These points are still open"), "{text}");
5569        assert!(
5570            text.contains("The retry fix never reaches the 429 path (src/net.rs:88)"),
5571            "{text}"
5572        );
5573        assert!(
5574            text.contains("The guard sits after the early return."),
5575            "{text}"
5576        );
5577        // The sentence the budget used to end on, which is now only true when
5578        // the closing pass could not run at all.
5579        assert!(!text.contains("has not been reviewed"), "{text}");
5580    }
5581
5582    /// The real PR ended with "5 fixed" followed by "no convergence", which
5583    /// reads as a contradiction. What a maintainer needs is that the fixes went
5584    /// in and nobody checked them.
5585    #[test]
5586    fn running_out_of_rounds_says_what_that_means_for_the_reader() {
5587        let state = state_with(vec![], vec![]);
5588        let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
5589        assert!(text.contains("has not been reviewed"), "{text}");
5590        assert!(
5591            !text.to_lowercase().contains("round 3"),
5592            "no round numbers: {text}"
5593        );
5594        assert!(!text.to_lowercase().contains("convergence"), "{text}");
5595    }
5596
5597    #[test]
5598    fn a_failed_close_reports_unread_fixes_and_carried_blockers() {
5599        let state = state_with(vec![], vec![]);
5600        let open = vec![Finding {
5601            detail: "the failure is still discarded".into(),
5602            ..finding("Unchecked error", "src/net.rs:88")
5603        }];
5604        let text = outcome_comment_with_unread(
5605            &state,
5606            &Ledger::new(),
5607            &Ending::OutOfRounds,
5608            &open,
5609            &style(),
5610        )
5611        .unwrap();
5612        assert!(text.contains("has not been reviewed"), "{text}");
5613        assert!(text.contains("These points were already open"), "{text}");
5614        assert!(text.contains("Unchecked error (src/net.rs:88)"), "{text}");
5615    }
5616
5617    #[test]
5618    fn a_deadlock_names_the_point_they_could_not_settle() {
5619        let state = state_with(vec![], vec![]);
5620        let points = [finding("Retry loop never terminates", "src/net.rs:88")];
5621        let text = outcome_comment(
5622            &state,
5623            &Ledger::new(),
5624            &Ending::Deadlocked(&points),
5625            &style(),
5626        )
5627        .unwrap();
5628        assert!(
5629            text.contains("Retry loop never terminates (src/net.rs:88)"),
5630            "{text}"
5631        );
5632        assert!(text.contains("could not settle"), "{text}");
5633    }
5634
5635    /// The diff records what was fixed. Nothing records what was argued down.
5636    #[test]
5637    fn refutations_survive_because_nothing_else_carries_them() {
5638        let state = state_with(
5639            vec![(
5640                "Error is swallowed",
5641                "the caller already validates the file",
5642            )],
5643            vec![],
5644        );
5645        let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
5646        assert!(text.contains("Raised and refuted:"), "{text}");
5647        assert!(
5648            text.contains("The caller already validates the file"),
5649            "{text}"
5650        );
5651    }
5652
5653    #[test]
5654    fn no_agent_names_counts_or_round_numbers_reach_the_thread() {
5655        let state = state_with(
5656            vec![("A point", "a reason")],
5657            vec!["https://github.com/you/thing/issues/485"],
5658        );
5659        let left = vec![finding("A point", "a.rs")];
5660        for ending in [
5661            Ending::Approved,
5662            Ending::OutOfRounds,
5663            Ending::Unresolved(&left),
5664        ] {
5665            let text = outcome_comment(&state, &Ledger::new(), &ending, &style()).unwrap();
5666            let lower = text.to_lowercase();
5667            for banned in ["claude", "codex", "blocking,", "nit,", " fixed."] {
5668                assert!(
5669                    !lower.contains(banned),
5670                    "{banned:?} leaked into the thread:\n{text}"
5671                );
5672            }
5673            // "the last round of fixes" is prose. "round 3" is narration.
5674            for n in 1..9 {
5675                assert!(
5676                    !lower.contains(&format!("round {n}")),
5677                    "a round number leaked into the thread:\n{text}"
5678                );
5679            }
5680        }
5681    }
5682
5683    #[test]
5684    /// A refutation is an argument, and an argument that stops mid clause is
5685    /// not one. Bounded, but with room to make the case.
5686    fn a_refutation_is_allowed_to_make_its_case() {
5687        let reasoning = "The caller validates against the schema first. \
5688                         The discarded error is therefore unreachable in practice. ";
5689        let state = state_with(
5690            vec![("A point", &reasoning.repeat(6))],
5691            vec!["https://github.com/you/thing/issues/485"],
5692        );
5693        let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
5694        assert!(
5695            !text.contains("..."),
5696            "nothing was cut mid thought:\n{text}"
5697        );
5698        assert!(text.len() < 4000, "{} chars", text.len());
5699    }
5700
5701    #[test]
5702    fn a_url_that_is_not_an_issue_link_is_left_alone() {
5703        assert_eq!(
5704            "#485",
5705            as_reference("https://github.com/you/thing/issues/485")
5706        );
5707        assert_eq!("note: something", as_reference("note: something"));
5708    }
5709}
5710
5711#[cfg(test)]
5712mod filed_reference_tests {
5713    use super::*;
5714
5715    #[test]
5716    fn an_issue_url_yields_its_number() {
5717        assert_eq!(
5718            Some(485),
5719            filed_issue_number("https://github.com/you/thing/issues/485")
5720        );
5721    }
5722
5723    /// Local mode records a note rather than a URL, and a run with
5724    /// followups = "local" must not try to absorb it as an issue.
5725    #[test]
5726    fn a_local_note_yields_nothing() {
5727        assert_eq!(None, filed_issue_number("note: Retry is unbounded"));
5728        assert_eq!(None, filed_issue_number(""));
5729        assert_eq!(
5730            None,
5731            filed_issue_number("https://github.com/you/thing/issues/")
5732        );
5733    }
5734}
5735
5736#[cfg(test)]
5737mod followup_restraint_tests {
5738    use super::*;
5739    use crate::model::Severity;
5740
5741    fn cfg_with(followups: Followups, non_blocking: bool, nits: bool, cap: usize) -> Config {
5742        let mut cfg =
5743            crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
5744                .unwrap();
5745        cfg.loop_cfg.followups = followups;
5746        cfg.loop_cfg.file_non_blocking = non_blocking;
5747        cfg.loop_cfg.file_nits = nits;
5748        cfg.loop_cfg.max_followups = cap;
5749        cfg
5750    }
5751
5752    fn finding(severity: Severity, title: &str, in_scope: bool) -> Finding {
5753        Finding {
5754            severity,
5755            title: title.into(),
5756            detail: "d".into(),
5757            file: "a.rs".into(),
5758            in_scope,
5759            ..Default::default()
5760        }
5761    }
5762
5763    /// The defaults are what let one issue spawn ten, which spawned more. A
5764    /// thorough reviewer always finds improvements; not gating a merge is not
5765    /// the same as deserving somebody's triage queue.
5766    #[test]
5767    fn a_non_blocking_finding_is_not_a_tracker_item_by_default() {
5768        let cfg = cfg_with(Followups::Issues, false, false, 5);
5769        assert!(!cfg.loop_cfg.file_non_blocking);
5770        assert!(!cfg.loop_cfg.file_nits);
5771    }
5772
5773    #[test]
5774    fn follow_ups_stay_off_the_tracker_by_default() {
5775        let cfg =
5776            crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
5777                .unwrap();
5778        assert_eq!(
5779            Followups::Local,
5780            cfg.loop_cfg.followups,
5781            "the tracker is somebody's queue; the default must not write to it"
5782        );
5783        assert_eq!(5, cfg.loop_cfg.max_followups);
5784    }
5785
5786    /// Which severities survive the filter, at the defaults and when opened up.
5787    #[test]
5788    fn only_out_of_scope_defects_qualify_at_the_defaults() {
5789        let cfg = cfg_with(Followups::Issues, false, false, 5);
5790        let qualifies = |f: &Finding| match f.severity {
5791            Severity::NonBlocking => cfg.loop_cfg.file_non_blocking && f.in_scope,
5792            Severity::Nit => cfg.loop_cfg.file_nits && f.in_scope,
5793            Severity::Blocking => false,
5794        } || !f.in_scope;
5795
5796        assert!(qualifies(&finding(
5797            Severity::Blocking,
5798            "pre-existing",
5799            false
5800        )));
5801        assert!(!qualifies(&finding(
5802            Severity::NonBlocking,
5803            "improvement",
5804            true
5805        )));
5806        assert!(!qualifies(&finding(Severity::Nit, "taste", true)));
5807        assert!(!qualifies(&finding(
5808            Severity::Blocking,
5809            "fix it here",
5810            true
5811        )));
5812    }
5813
5814    #[test]
5815    fn opening_it_up_lets_non_blocking_findings_through_again() {
5816        let cfg = cfg_with(Followups::Issues, true, false, 5);
5817        assert!(cfg.loop_cfg.file_non_blocking);
5818    }
5819
5820    /// A run that will not stop finding things is stopped, and says so.
5821    #[test]
5822    fn the_cap_is_a_real_backstop() {
5823        let cfg = cfg_with(Followups::Issues, false, false, 3);
5824        let mut state = IssueRun::new(1, "t");
5825        state.filed = (0..3).map(|n| format!("url{n}")).collect();
5826        assert!(state.filed.len() >= cfg.loop_cfg.max_followups);
5827    }
5828
5829    /// The number that matters. Reviewing one issue produced ten follow-ups on
5830    /// a real repository, each of which could be run in turn: mean offspring
5831    /// above one never terminates.
5832    #[test]
5833    fn the_cap_bounds_what_one_run_can_spawn() {
5834        let cfg = cfg_with(Followups::Issues, false, false, 5);
5835        assert!(
5836            cfg.loop_cfg.max_followups <= 5,
5837            "a run that can file ten follow-ups is a branching process"
5838        );
5839    }
5840}
5841
5842/// What the ledger is told about a point the author moved out of the pull
5843/// request. Every case here used to record "filed", including the ones where
5844/// nothing was written anywhere.
5845#[cfg(test)]
5846mod followup_outcome_tests {
5847    use super::*;
5848
5849    const URL: &str = "https://github.com/you/thing/issues/485";
5850
5851    fn entry(recorded: Followup) -> Option<(Settled, String)> {
5852        filed_entry(&recorded, "It predates this branch.")
5853    }
5854
5855    /// The bug. A tracker request or a local write that failed left no
5856    /// follow-up, and the ledger said it had been filed, which is a claim that
5857    /// survives every later round and every resume.
5858    #[test]
5859    fn a_failed_followup_settles_nothing() {
5860        assert_eq!(None, entry(Followup::Failed));
5861    }
5862
5863    #[test]
5864    fn an_uncertain_external_write_blocks_later_issue_followups_for_this_run() {
5865        let mut state = IssueRun::new(1, "review");
5866        let uncertain = SparError::uncertain_write("the result could not be verified");
5867        assert_eq!(Followup::Failed, failed_followup(&mut state, &uncertain));
5868        assert!(external_followup_write_paused(Followups::Issues, &state));
5869        assert!(!external_followup_write_paused(Followups::Local, &state));
5870        assert_eq!(1, state.notes.len());
5871
5872        assert_eq!(Followup::Failed, failed_followup(&mut state, &uncertain));
5873        assert_eq!(1, state.notes.len(), "the recovery note was duplicated");
5874
5875        let mut ordinary = IssueRun::new(2, "review");
5876        let error = SparError::new("permission denied");
5877        assert_eq!(Followup::Failed, failed_followup(&mut ordinary, &error));
5878        assert!(!external_followup_write_paused(
5879            Followups::Issues,
5880            &ordinary
5881        ));
5882    }
5883
5884    #[test]
5885    fn a_recorded_followup_is_filed_and_says_where() {
5886        let (outcome, reasoning) = entry(Followup::Recorded(URL.into())).unwrap();
5887        assert_eq!(Settled::Filed, outcome);
5888        assert!(
5889            reasoning.contains("It predates this branch."),
5890            "{reasoning}"
5891        );
5892        assert!(reasoning.contains("#485"), "{reasoning}");
5893    }
5894
5895    /// A closed issue already carries the point, so raising it again is waste.
5896    /// It is still not something to hand anybody as work.
5897    #[test]
5898    fn a_closed_issue_covering_the_point_settles_it_without_offering_work() {
5899        let recorded = Followup::from(Filed::AlreadyClosed(9, URL.into()));
5900        assert_eq!(Followup::Covered(URL.into()), recorded);
5901        assert_eq!(
5902            None,
5903            recorded.url(),
5904            "a closed issue is not work to pick up"
5905        );
5906
5907        let (outcome, reasoning) = entry(recorded).unwrap();
5908        assert_eq!(Settled::Filed, outcome);
5909        assert!(reasoning.contains("#485"), "{reasoning}");
5910    }
5911
5912    /// An open issue that already covers the point is worth linking from the
5913    /// pull request, and worth counting against the cap.
5914    #[test]
5915    fn an_open_issue_that_already_covers_the_point_is_still_a_reference() {
5916        for filed in [
5917            Filed::Opened(9, URL.into()),
5918            Filed::AddedTo(9, URL.into()),
5919            Filed::Covered(9, URL.into()),
5920        ] {
5921            assert_eq!(Some(URL), Followup::from(filed).url());
5922        }
5923    }
5924
5925    /// Configuration, not failure: retrying it every round would spend the
5926    /// budget on a write that is never going to happen. The entry has to be
5927    /// honest about it, because nothing else holds the point.
5928    #[test]
5929    fn a_dropped_followup_is_settled_but_never_reported_as_filed() {
5930        let (outcome, reasoning) = entry(Followup::Dropped("follow-ups are off")).unwrap();
5931        assert_eq!(Settled::Dropped, outcome);
5932        assert!(reasoning.contains("follow-ups are off"), "{reasoning}");
5933        assert!(reasoning.contains("Not filed"), "{reasoning}");
5934    }
5935
5936    fn ledger_of(outcome: Settled, reasoning: &str) -> Ledger {
5937        let mut ledger = Ledger::new();
5938        ledger.insert(
5939            finding_key("A pre-existing leak", "src/x.rs"),
5940            LedgerEntry {
5941                title: "A pre-existing leak".into(),
5942                file: "src/x.rs".into(),
5943                reasoning: reasoning.into(),
5944                round: 1,
5945                reraised: 0,
5946                outcome,
5947            },
5948        );
5949        ledger
5950    }
5951
5952    /// The next reviewer is told to leave settled points alone either way, so
5953    /// the wording is all that separates them. Saying "filed" of a point
5954    /// nothing holds is the lie that loses it.
5955    #[test]
5956    fn the_settled_block_tells_a_filed_point_from_a_dropped_one() {
5957        let filed = settled_block(&ledger_of(Settled::Filed, "Tracked in #9."));
5958        assert!(filed.contains("out of scope here, and filed"), "{filed}");
5959
5960        let dropped = settled_block(&ledger_of(Settled::Dropped, "Not filed anywhere: off."));
5961        assert!(
5962            dropped.contains("out of scope here, and not filed"),
5963            "{dropped}"
5964        );
5965        assert!(dropped.contains("A pre-existing leak"), "{dropped}");
5966    }
5967
5968    /// A deadlock goes to a person, and the first thing they do is look for the
5969    /// issue the comment says exists.
5970    #[test]
5971    fn a_deadlocked_point_that_was_never_filed_does_not_claim_to_be() {
5972        let points = [Finding {
5973            severity: Severity::Blocking,
5974            title: "A pre-existing leak".into(),
5975            detail: "d".into(),
5976            file: "src/x.rs".into(),
5977            in_scope: false,
5978            ..Default::default()
5979        }];
5980        let text = outcome_comment(
5981            &IssueRun::new(1, "t"),
5982            &ledger_of(Settled::Dropped, "Not filed anywhere: follow-ups are off."),
5983            &Ending::Deadlocked(&points),
5984            &Style::default(),
5985        )
5986        .unwrap();
5987        assert!(text.contains("not filed"), "{text}");
5988        assert!(!text.contains("Filed as out of scope"), "{text}");
5989    }
5990}
5991
5992#[cfg(test)]
5993mod issue_report_tests {
5994    use super::*;
5995    use crate::model::Severity;
5996
5997    /// Shaped after a bug report written by hand that reads the way one should:
5998    /// what is wrong, how to see it, what it costs, what it should do instead.
5999    fn reported() -> Finding {
6000        Finding {
6001            severity: Severity::Blocking,
6002            title: "sendPaymentAsync bypasses drain mode and spending limits".into(),
6003            detail: "The async path skips every admission check payInvoice applies.".into(),
6004            file: "src/node.ts:412".into(),
6005            in_scope: false,
6006            problem: Some(
6007                "`BeignetNode.sendPaymentAsync()` submits a payment directly to the Lightning \
6008                 engine without applying the safeguards used by `payInvoice()`.\n\nThe async path \
6009                 does not:\n\n- call `_checkDraining()`\n- call `_checkSpendLimit()`"
6010                    .into(),
6011            ),
6012            reproduction: Some(
6013                "1. Create a `BeignetNode` with `dailySpendLimitSats: 1`.\n2. Enable drain mode.\n\
6014                 3. Submit a 1,000 sat invoice.\n\nActual result:\n\n- The engine is called.\n\
6015                 - `spentSats` remains 0."
6016                    .into(),
6017            ),
6018            impact: Some(
6019                "An authorized client can submit async payments up to the available outbound \
6020                 liquidity despite the configured limits."
6021                    .into(),
6022            ),
6023            expected: Some(
6024                "- Reject new payments while draining.\n- Enforce the per-payment limit before \
6025                 submission.\n- Cover both paths with regression tests.\n\nThis predates the \
6026                 current branch."
6027                    .into(),
6028            ),
6029        }
6030    }
6031
6032    #[test]
6033    fn a_reported_finding_becomes_a_bug_report() {
6034        let body = issue_report(&reported());
6035        for heading in [
6036            "## Problem",
6037            "## Reproduction",
6038            "## Impact",
6039            "## Expected behavior",
6040        ] {
6041            assert!(body.contains(heading), "missing {heading}:\n{body}");
6042        }
6043        // In the order somebody reads a bug report.
6044        let at = |h: &str| body.find(h).unwrap();
6045        assert!(at("## Problem") < at("## Reproduction"));
6046        assert!(at("## Reproduction") < at("## Impact"));
6047        assert!(at("## Impact") < at("## Expected behavior"));
6048    }
6049
6050    #[test]
6051    fn the_substance_survives_the_outbound_gates() {
6052        let repo_style = Style::default();
6053        let body = crate::style::issue_body(&issue_report(&reported()), &repo_style);
6054        for kept in [
6055            "_checkDraining()",
6056            "Actual result:",
6057            "outbound liquidity",
6058            "regression tests",
6059            "predates the current branch",
6060        ] {
6061            assert!(body.contains(kept), "the gate ate {kept:?}:\n{body}");
6062        }
6063        assert!(!body.contains("..."), "something was cut:\n{body}");
6064    }
6065
6066    /// A finding that was never going to be filed carries none of this, and
6067    /// must not gain empty headings for the sake of a format.
6068    #[test]
6069    fn an_ordinary_finding_is_still_just_its_detail() {
6070        let plain = Finding {
6071            severity: Severity::NonBlocking,
6072            title: "Name is vague".into(),
6073            detail: "The variable could say what it holds.".into(),
6074            file: "a.rs".into(),
6075            in_scope: true,
6076            ..Default::default()
6077        };
6078        assert_eq!(
6079            "The variable could say what it holds.",
6080            issue_report(&plain)
6081        );
6082    }
6083
6084    /// Partial reports are normal: a defect with no useful reproduction should
6085    /// not sprout an empty Reproduction heading.
6086    #[test]
6087    fn only_the_sections_that_were_written_appear() {
6088        let partial = Finding {
6089            problem: Some("The guard is inverted.".into()),
6090            expected: Some("It should reject rather than accept.".into()),
6091            ..reported()
6092        };
6093        let partial = Finding {
6094            reproduction: None,
6095            impact: None,
6096            ..partial
6097        };
6098        let body = issue_report(&partial);
6099        assert!(body.contains("## Problem") && body.contains("## Expected behavior"));
6100        assert!(!body.contains("## Reproduction"), "{body}");
6101        assert!(!body.contains("## Impact"), "{body}");
6102    }
6103
6104    /// The one line the thread shows is not repeated when a section already
6105    /// says it.
6106    #[test]
6107    fn the_summary_line_is_not_printed_twice() {
6108        let echoed = Finding {
6109            detail: "The guard is inverted so it rejects valid input.".into(),
6110            problem: Some("The guard is inverted so it rejects valid input.".into()),
6111            reproduction: None,
6112            impact: None,
6113            expected: None,
6114            ..reported()
6115        };
6116        let body = issue_report(&echoed);
6117        assert_eq!(1, body.matches("The guard is inverted").count(), "{body}");
6118    }
6119}