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