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