Skip to main content

spar/
review.rs

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