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