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