Skip to main content

spar/
review.rs

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