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