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::path::{Path, PathBuf};
21
22use crate::agent::{self, Agent};
23use crate::config::{Config, Drafts, Followups, PrComments};
24use crate::error::{Result, SparError};
25use crate::jsonx::finding_key;
26use crate::model::{
27    Action, Dispute, Finding, Followup, Implementation, Issue, IssueRun, Ledger, LedgerEntry,
28    NextAction, PersistedState, PlanItem, PrView, ResponseDoc, Review, Settled, Severity,
29    SkippedItem, Status, STATE_VERSION,
30};
31use crate::repo::Repo;
32use crate::style::{self, Style};
33use crate::{log, logdim, logwarn, schema, spar_err};
34
35// ---------------------------------------------------------------------------
36// Prompts
37// ---------------------------------------------------------------------------
38
39const IMPLEMENT_PROMPT: &str = "\
40Implement GitHub issue #{number} in this repository.
41
42Title: {title}
43URL: {url}
44
45{body}
46
47That is the issue body as filed. The discussion since is not included, so read
48the thread at the URL above if the body leaves anything open. If you cannot
49reach the network, work from what is here.
50
51Do the work, then commit it on the current branch. Make focused commits with
52clear messages. Do not push, do not open a PR, and do not merge; the harness
53handles that.
54
55Then report it. Your answer becomes the pull request description, and the
56reviewer reads that cold, with nothing but the diff and a link to the issue:
57say what you found wrong, what the change does about it, and how they confirm
58it for themselves. Say what you actually ran, not what could be run.
59
60If after reading the code you conclude this issue should not be implemented,
61make no commits and set not_worth_doing, with the reason.";
62
63const REVIEW_PROMPT: &str = "\
64Review the changes on this branch against `{base}`. They implement issue
65#{number}: {title}
66
67Review thoroughly: correctness, edge cases, error handling, security, and
68whether the change actually resolves the issue. Read surrounding code, do not
69only read the diff.
70
71Label every finding by severity, and be honest about which is which:
72- blocking: the PR should not merge as is. Real defects only.
73- non-blocking: a genuine improvement that need not gate this PR.
74- nit: style or taste.
75
76Confirm anything you label blocking before you label it. Run the code,
77reproduce the failure, or point at the exact line that breaks, and say in the
78detail what you did to confirm it. When you need to run something to check a
79claim, write a scratch file and run that, rather than passing a long program on
80the command line: it is easier to read back, easier to rerun, and less likely to
81be refused by a sandbox or a safety filter part way through your work. An unverified blocking finding is worse than
82one you never raised: it stalls a good PR and teaches the author to stop
83believing you. If you suspect a problem but could not confirm it, say so and
84label it non-blocking.
85
86Set in_scope=false for a real defect that exists, that this PR did not cause, and
87that is worth somebody stopping to fix. Each one becomes a tracked item a
88maintainer has to read and triage, so the bar is a defect and not an observation.
89A thorough reviewer can always find something adjacent to what it is reading;
90that is not a reason to file it. If you are not sure it is worth a maintainer's
91time, leave in_scope true and say your piece in the finding.
92
93Reviewing one issue should not manufacture ten more. If you find yourself with
94several out of scope findings, keep the ones that would bite somebody and drop
95the rest.
96
97Then choose next_action:
98- merge: no blocking findings, the PR is good.
99- fix_myself: there are blocking findings and you will fix them directly.
100- hand_back: there are blocking findings the author should address.
101{settled}";
102
103const FIX_PROMPT: &str = "\
104You reviewed this branch and chose to fix the blocking findings yourself.
105Implement those fixes now and commit them.
106
107Your findings:
108{findings}
109
110Commit your changes. Do not push, do not merge.";
111
112const RESPOND_PROMPT: &str = "\
113Here is a review of your PR for issue #{number}.
114
115{findings}
116
117For each point, choose exactly one disposition:
118- fixed: the point is valid and in scope. Fix it and commit.
119- refuted: the point is wrong, or not worth acting on. Explain why. Refuting is
120  a legitimate outcome; do not accept a review comment you believe is incorrect
121  just to get the PR approved.
122- filed_issue: the point is valid but unrelated to this PR. Supply
123  new_issue_title and new_issue_body; the harness files it and skips duplicates.
124
125Copy each finding's title and file across exactly as given, so your answer can
126be matched back to the review.
127
128Commit any fixes. Do not push, do not merge.";
129
130// ---------------------------------------------------------------------------
131// Evidence
132// ---------------------------------------------------------------------------
133
134/// What the branch looked like at one point in a round.
135///
136/// Untracked files are deliberately not dirt. The review prompt asks for a
137/// scratch file when a claim needs running to check it, so counting one as a
138/// mutation would reject every review that did as it was told.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct Snapshot {
141    pub head: String,
142    /// Tracked files differing from the index or the head.
143    pub dirty: bool,
144}
145
146impl Snapshot {
147    /// Whether a commit landed between the two. An empty head means git could
148    /// not be read, which is not evidence that anything was written.
149    pub fn landed_over(&self, before: &Snapshot) -> bool {
150        !self.head.is_empty() && self.head != before.head
151    }
152}
153
154pub fn snapshot(repo: &Repo, work_dir: &Path) -> Snapshot {
155    Snapshot {
156        head: repo
157            .git_try_at(Some(work_dir), &["rev-parse", "HEAD"])
158            .trim()
159            .to_string(),
160        dirty: !repo
161            .git_try_at(
162                Some(work_dir),
163                &["status", "--porcelain", "--untracked-files=no"],
164            )
165            .trim()
166            .is_empty(),
167    }
168}
169
170/// Copy the tracked edits in the tree to somewhere they can be got back from.
171///
172/// `git stash create` writes them as a dangling commit and, unlike `git stash
173/// push`, leaves the stash stack alone: the stack belongs to whoever is working
174/// in the repository, and every worktree of it shares the same one. `None` when
175/// there was nothing to save.
176pub fn park(repo: &Repo, work_dir: &Path) -> Option<String> {
177    let saved = repo
178        .git_try_at(Some(work_dir), &["stash", "create"])
179        .trim()
180        .to_string();
181    (!saved.is_empty()).then_some(saved)
182}
183
184/// Reset the tree to `target`, saving what that throws away.
185///
186/// With `--no-worktrees` the checkout is the user's own, and nothing here can
187/// tell an edit an agent left behind from one a person made while a call was
188/// running. So the discard is never silent and never final: the changes are
189/// parked first and the log says how to put them back.
190fn reset_saving(repo: &Repo, work_dir: &Path, target: &str) {
191    let parked = park(repo, work_dir);
192    if let Err(e) = repo.git_at(Some(work_dir), &["reset", "--hard", target]) {
193        logdim!("could not roll the working tree back: {e}");
194        return;
195    }
196    if let Some(saved) = parked {
197        logdim!("`git stash apply {saved}` puts the discarded changes back");
198    }
199}
200
201/// Put the branch back where the review found it.
202///
203/// Nothing here was ever pushed: the loop pushes at the end of a round, so the
204/// head a review starts from is the head the pull request already has. What is
205/// discarded is therefore only what the review wrote after being told not to,
206/// and keeping it would hand the reviewer its own commit to review next round.
207///
208/// Returns the state afterwards, which equals `before` when the rollback took.
209/// The caller compares, because a rollback that did not take means the reviewer
210/// wrote the head and custody has to follow it there.
211pub fn undo_edits(repo: &Repo, work_dir: &Path, before: &Snapshot) -> Snapshot {
212    let current = snapshot(repo, work_dir);
213    if before.head.is_empty() {
214        return current;
215    }
216    if current.landed_over(before) {
217        logdim!(
218            "the commits being rolled back are still at {}",
219            current.head
220        );
221    }
222    reset_saving(repo, work_dir, &before.head);
223    snapshot(repo, work_dir)
224}
225
226/// Drop what a call left uncommitted, keeping whatever it committed.
227///
228/// Only commits reach the pull request, but the next review reads the working
229/// tree, so an edit left behind is code the reviewer judges and the diff does
230/// not have. That is how an agent comes to approve a fix of its own that
231/// nobody else can see.
232pub fn drop_uncommitted(repo: &Repo, work_dir: &Path) -> Snapshot {
233    let current = snapshot(repo, work_dir);
234    if !current.dirty || current.head.is_empty() {
235        return current;
236    }
237    reset_saving(repo, work_dir, &current.head);
238    snapshot(repo, work_dir)
239}
240
241/// A worktree is only worth keeping when a person has to look at it locally.
242/// Anything else strands a checked-out branch that blocks
243/// `gh pr merge --delete-branch`, and since auto_merge is off by default,
244/// keeping it on anything but "merged" leaks one per run.
245fn should_release(cfg: &Config, status: Status) -> bool {
246    if !cfg.loop_cfg.worktrees || cfg.loop_cfg.keep_worktrees {
247        return false;
248    }
249    !matches!(status, Status::Escalated | Status::Error)
250}
251
252// ---------------------------------------------------------------------------
253// One issue, start to finish
254// ---------------------------------------------------------------------------
255
256pub fn run_issue(
257    agents: &[Agent],
258    cfg: &Config,
259    repo: &Repo,
260    item: &PlanItem,
261    issue: &Issue,
262    ledger: &mut Ledger,
263) -> IssueRun {
264    // Continue an existing PR rather than implementing over the top of it.
265    //
266    // Without this, a second `spar run 42` deletes the local branch, rebuilds
267    // it from the base, implements from scratch, and force pushes. The lease
268    // holds because the remote tracking ref survives the local branch being
269    // deleted, so the push succeeds and the previous round's work is gone from
270    // the PR with nothing to say it ever existed.
271    if let Some(existing) = repo.open_pr_for_issue(item.issue) {
272        log!(
273            "#{}: {} is already open, continuing it instead of implementing again",
274            item.issue,
275            existing.url
276        );
277        return resume_pr(agents, cfg, repo, existing.number, None);
278    }
279
280    let mut state = IssueRun::new(item.issue, item.title.clone());
281    let base = cfg.base_branch().to_string();
282
283    let prepared = if cfg.loop_cfg.worktrees {
284        repo.worktree_add(item.issue, &base)
285    } else {
286        let branch = repo.branch_for_issue(item.issue);
287        let start = format!("origin/{base}");
288        repo.git(&["checkout", "-B", &branch, &start])
289            .map(|_| (repo.root().to_path_buf(), branch))
290    };
291
292    let (work_dir, branch) = match prepared {
293        Ok(pair) => pair,
294        Err(e) => {
295            state.status = Status::Error;
296            state.notes.push(e.to_string());
297            log!("#{} failed: {e}", item.issue);
298            return state;
299        }
300    };
301
302    let outcome = implement_and_review(
303        agents, cfg, repo, item, issue, ledger, &mut state, &work_dir, &branch,
304    );
305    if let Err(e) = outcome {
306        state.status = Status::Error;
307        state.notes.push(e.to_string());
308        log!("#{} failed: {e}", item.issue);
309    }
310
311    if should_release(cfg, state.status) {
312        repo.worktree_remove(item.issue);
313    }
314    state
315}
316
317#[allow(clippy::too_many_arguments)]
318fn implement_and_review(
319    agents: &[Agent],
320    cfg: &Config,
321    repo: &Repo,
322    item: &PlanItem,
323    issue: &Issue,
324    ledger: &mut Ledger,
325    state: &mut IssueRun,
326    work_dir: &Path,
327    branch: &str,
328) -> Result<()> {
329    let number = item.issue;
330    let holder = cfg.first_implementor.clone();
331    let implementor = agent::find(agents, &holder)?;
332    let base = cfg.base_branch().to_string();
333
334    log!("#{number}: {holder} implementing");
335    // Fixing triage alone would have been worse than fixing neither: an issue
336    // correctly judged worth doing on its whole text, then built from the first
337    // few thousand characters of it, raises confidence without raising
338    // fidelity.
339    let (body, shortened) = issue.body_for_prompt(cfg.loop_cfg.max_issue_chars);
340    if shortened {
341        logwarn!(
342            "#{number}: the issue body was shortened to fit the prompt. Raise max_issue_chars if \
343             the rest matters."
344        );
345    }
346    let prompt = implement_prompt(number, &item.title, &issue.url, &body);
347    let answer: Result<Implementation> = implementor.ask_json(
348        &prompt,
349        &schema::implementation(),
350        work_dir,
351        cfg.effort_for_round(&implementor.spec, 1).as_deref(),
352    );
353
354    // A call that fails with commits on the branch is not the same as one that
355    // fails with nothing to show. The agent commits as it goes and reports at
356    // the end, so the usual failure here is the report, not the work, and
357    // returning the error would leave the commits unpushed on a local branch
358    // that the next `spar run` deletes. The review loop is what the round is
359    // for and it needs the diff, not the summary.
360    let mut work = match answer {
361        Ok(work) => work,
362        Err(e) if repo.has_changes(work_dir, &base) => {
363            logwarn!(
364                "#{number}: {holder} failed after committing: {e}\nContinuing from the commits, \
365                 with a pull request body written from their messages."
366            );
367            state
368                .notes
369                .push(format!("{holder} failed after committing: {e}"));
370            from_commits(repo, work_dir, &base)
371        }
372        Err(e) => return Err(e),
373    };
374
375    if work.not_worth_doing || !repo.has_changes(work_dir, &base) {
376        state.status = Status::Abandoned;
377        let reason = no_pr_note(&work, &repo.style);
378        state.notes.push(reason.clone());
379        if let Err(e) = repo.comment_issue(number, &reason) {
380            logdim!("could not comment on #{number}: {e}");
381        }
382        return Ok(());
383    }
384
385    // A body that leads with nothing is a body nobody reads past. The issue
386    // title is a poor substitute for a sentence about the change, and a better
387    // one than a blank first line.
388    if work.summary.trim().is_empty() {
389        work.summary = item.title.clone();
390    }
391
392    repo.rewrite_commits_if_needed(work_dir, &base)?;
393    repo.push(work_dir, branch)?;
394
395    let pr = match repo.pr_for_branch(branch) {
396        Some(existing) => existing,
397        None => {
398            let body = pr_body(number, &work, &repo.style);
399            repo.create_pr(
400                work_dir,
401                branch,
402                &base,
403                &format!("{} (#{number})", item.title),
404                &body,
405            )?
406        }
407    };
408    state.pr = Some(pr.url.clone());
409    log!("#{number}: PR {}", pr.url);
410
411    let ctx = LoopCtx {
412        work_dir: work_dir.to_path_buf(),
413        branch: branch.to_string(),
414        pr_number: pr.number,
415        label: format!("#{number}"),
416        subject: number,
417        title: item.title.clone(),
418        start_round: 1,
419        holder: cfg.other(&holder),
420        release: Release::Issue(number),
421    };
422    review_loop(agents, cfg, repo, &ctx, state, ledger)
423}
424
425// ---------------------------------------------------------------------------
426// Resuming an existing PR
427// ---------------------------------------------------------------------------
428
429/// Pick up an existing PR and continue the loop.
430///
431/// The PR need not have been created by spar. Anything with a branch and a diff
432/// can be reviewed, including work a person or a different tool started, which
433/// is also the cheapest way to adopt spar: no agent writes a feature from
434/// scratch, it only reviews what already exists.
435pub fn resume_pr(
436    agents: &[Agent],
437    cfg: &Config,
438    repo: &Repo,
439    pr_number: i64,
440    holder_override: Option<&str>,
441) -> IssueRun {
442    let failed = |e: SparError| {
443        log!("PR #{pr_number} failed: {e}");
444        let mut state = IssueRun::new(pr_number, format!("PR #{pr_number}"));
445        state.status = Status::Error;
446        state.notes.push(e.to_string());
447        state
448    };
449
450    let pr = match repo.pr_view(pr_number) {
451        Ok(pr) => pr,
452        Err(e) => return failed(e),
453    };
454
455    // A pull request from a fork cannot be pushed to, so the loop that fixes
456    // things cannot run on it. Reviewing it is still the useful thing, and it
457    // is what a maintainer wants from an outside contribution anyway, so do
458    // that rather than refusing.
459    if pr.is_cross_repository {
460        log!("PR #{pr_number} comes from a fork, reviewing it without changing it");
461        return crate::review_only::review_pr(agents, cfg, repo, pr_number, false);
462    }
463
464    match resume_inner(agents, cfg, repo, pr, holder_override) {
465        Ok(state) => state,
466        Err(e) => failed(e),
467    }
468}
469
470fn resume_inner(
471    agents: &[Agent],
472    cfg: &Config,
473    repo: &Repo,
474    pr: PrView,
475    holder_override: Option<&str>,
476) -> Result<IssueRun> {
477    let pr_number = pr.number;
478    if !pr.is_open() {
479        return Err(spar_err!("PR #{pr_number} is {}", pr.state.to_lowercase()));
480    }
481
482    let subject = pr
483        .closing_issues_references
484        .first()
485        .map(|r| r.number)
486        .unwrap_or(pr_number);
487
488    let saved = repo.read_state(&pr);
489    let mut ledger: Ledger = saved.as_ref().map(|s| s.ledger.clone()).unwrap_or_default();
490    let start_round = saved.as_ref().map(|s| s.round + 1).unwrap_or(1);
491
492    let default_holder = cfg.other(&cfg.first_implementor);
493    let mut holder = holder_override
494        .map(str::to_string)
495        .or_else(|| saved.as_ref().map(|s| s.next_actor.clone()))
496        .unwrap_or_else(|| default_holder.clone());
497    if !cfg.has_agent(&holder) {
498        log!("state named unknown agent '{holder}', using {default_holder}");
499        holder = default_holder;
500    }
501
502    match &saved {
503        Some(_) => log!(
504            "PR #{pr_number}: resuming at round {start_round}, {} settled point(s), next up {holder}",
505            ledger.len()
506        ),
507        None => log!("PR #{pr_number}: no prior spar state, starting fresh with {holder}"),
508    }
509
510    let mut state = IssueRun::new(subject, pr.title.clone());
511    state.pr = Some(pr.url.clone());
512    if let Some(s) = &saved {
513        state.filed = s.filed.clone();
514    }
515
516    let (work_dir, branch) = repo.worktree_for_pr(&pr)?;
517    let ctx = LoopCtx {
518        work_dir,
519        branch,
520        pr_number,
521        label: format!("PR #{pr_number}"),
522        subject,
523        title: pr.title.clone(),
524        start_round,
525        holder,
526        release: Release::Pr(pr_number),
527    };
528
529    let outcome = review_loop(agents, cfg, repo, &ctx, &mut state, &mut ledger);
530    if let Err(e) = outcome {
531        state.status = Status::Error;
532        state.notes.push(e.to_string());
533        log!("PR #{pr_number} failed: {e}");
534    }
535    if should_release(cfg, state.status) {
536        repo.release_pr_worktree(pr_number);
537    }
538    Ok(state)
539}
540
541// ---------------------------------------------------------------------------
542// The loop
543// ---------------------------------------------------------------------------
544
545#[derive(Debug, Clone, Copy)]
546enum Release {
547    Issue(i64),
548    Pr(i64),
549}
550
551struct LoopCtx {
552    work_dir: PathBuf,
553    branch: String,
554    pr_number: i64,
555    label: String,
556    subject: i64,
557    title: String,
558    start_round: u32,
559    holder: String,
560    release: Release,
561}
562
563impl LoopCtx {
564    fn release(&self, repo: &Repo) {
565        match self.release {
566            Release::Issue(n) => repo.worktree_remove(n),
567            Release::Pr(n) => repo.release_pr_worktree(n),
568        }
569    }
570}
571
572fn review_loop(
573    agents: &[Agent],
574    cfg: &Config,
575    repo: &Repo,
576    ctx: &LoopCtx,
577    state: &mut IssueRun,
578    ledger: &mut Ledger,
579) -> Result<()> {
580    let base = cfg.base_branch().to_string();
581    // Never the agent that made the last commit, on entry and after every
582    // round. An approval or a deadlock ends the round with nothing edited, so
583    // those paths persist it unchanged.
584    let mut holder = ctx.holder.clone();
585
586    // `max_rounds` is a budget for this invocation, not a lifetime cap on the
587    // pull request. Running spar again on a PR that already spent its rounds is
588    // a deliberate act by a person who has looked at it, so it gets a fresh
589    // budget rather than an error telling them to raise a number they cannot
590    // see from the outside.
591    let (first, last_allowed) = round_window(ctx.start_round, cfg.loop_cfg.max_rounds);
592    let mut last_round = first.saturating_sub(1);
593
594    for round in first..=last_allowed {
595        last_round = round;
596        state.rounds = round;
597        let reviewer = agent::find(agents, &holder)?;
598        let effort = cfg.effort_for_round(&reviewer.spec, round);
599        log!(
600            "{}: round {round}, {holder} reviewing ({})",
601            ctx.label,
602            effort.as_deref().unwrap_or("default effort")
603        );
604
605        let prompt = REVIEW_PROMPT
606            .replace("{base}", &base)
607            .replace("{number}", &ctx.subject.to_string())
608            .replace("{title}", &ctx.title)
609            .replace("{settled}", &settled_block(ledger));
610        let before_review = snapshot(repo, &ctx.work_dir);
611        let review: Review = reviewer.review(
612            &base,
613            &prompt,
614            &schema::review(),
615            &ctx.work_dir,
616            effort.as_deref(),
617        )?;
618
619        // Who actually wrote the head this round, which is the only thing that
620        // decides who reviews it next. None so far: a review is not supposed to
621        // write anything.
622        let mut editor: Option<String> = None;
623        let review_wrote = snapshot(repo, &ctx.work_dir) != before_review;
624        if review_wrote {
625            logwarn!(
626                "{}: {holder} changed the branch while reviewing it, which the review prompt \
627                 forbids. Rolling it back.",
628                ctx.label
629            );
630            if undo_edits(repo, &ctx.work_dir, &before_review).head != before_review.head {
631                state
632                    .notes
633                    .push(format!("{holder} committed during its own review"));
634                editor = Some(holder.clone());
635            }
636        }
637
638        let blocking: Vec<Finding> = review
639            .findings
640            .iter()
641            .filter(|f| f.blocks())
642            .cloned()
643            .collect();
644
645        if repo.style.pr_comments == PrComments::Rounds {
646            if let Err(e) = repo.comment_pr(
647                ctx.pr_number,
648                &review_comment(&holder, round, &review, &repo.style),
649            ) {
650                logdim!("could not post the review comment: {e}");
651            }
652        }
653
654        // Filed every round, not only on approval: a run that escalates or runs
655        // out of rounds would otherwise drop these on the floor. Filing
656        // deduplicates by title, so repeats across rounds are free.
657        file_out_of_scope(repo, &review.findings, ctx.subject, state, cfg);
658        file_nonblocking(repo, &review.findings, ctx.subject, state, cfg);
659
660        if check_relitigation(ledger, &blocking, state) {
661            state.status = Status::Escalated;
662            post_outcome(
663                repo,
664                ctx.pr_number,
665                state,
666                ledger,
667                Ending::Deadlocked(&blocking),
668            );
669            persist(repo, ctx.pr_number, state, ledger, round, &holder);
670            return Ok(());
671        }
672
673        if approval_stands(&blocking, review_wrote) {
674            state.status = Status::Approved;
675            post_outcome(repo, ctx.pr_number, state, ledger, Ending::Approved);
676            persist(repo, ctx.pr_number, state, ledger, round, &holder);
677            // Before the merge, not after: a draft cannot be merged, and the
678            // state the draft was signalling, that two agents were still
679            // arguing about it, has just stopped being true.
680            if cfg.loop_cfg.drafts == Drafts::UntilApproved && repo.mark_ready(ctx.pr_number) {
681                log!("{}: out of draft", ctx.label);
682            }
683            if cfg.loop_cfg.auto_merge {
684                // Release the worktree first. `gh pr merge --delete-branch`
685                // fails if anything still has the branch checked out, and it
686                // fails *after* merging, so the merge lands while the command
687                // reports failure.
688                ctx.release(repo);
689                repo.merge_pr(ctx.pr_number)?;
690                state.status = Status::Merged;
691                repo.clear_state(ctx.pr_number); // nothing left to resume
692                log!("{}: merged", ctx.label);
693            } else {
694                log!("{}: approved, awaiting human merge", ctx.label);
695            }
696            return Ok(());
697        }
698
699        if blocking.is_empty() {
700            // Nothing blocking, but the branch it said that about is not the
701            // branch that is there now. Falling through gives the next round
702            // whatever the rollback left: the same reviewer when it took, the
703            // other agent when the review's commit survived it.
704            logwarn!(
705                "{}: {holder} found nothing blocking on a branch it had changed itself, so the \
706                 approval does not carry.",
707                ctx.label
708            );
709            state.notes.push(format!(
710                "{holder} passed the branch in round {round} after editing it; the edit was rolled \
711                 back and the approval did not stand"
712            ));
713        } else if review.next_action == NextAction::FixMyself {
714            log!("{}: {holder} fixing its own findings", ctx.label);
715            let prompt = FIX_PROMPT.replace("{findings}", &findings_for_prompt(&blocking));
716            let before_fix = snapshot(repo, &ctx.work_dir);
717            reviewer.ask(&prompt, &ctx.work_dir, effort.as_deref())?;
718            match editor_after(repo, &ctx.work_dir, &before_fix, &ctx.label, &holder) {
719                Some(who) => editor = Some(who),
720                None => {
721                    // Handing over here is what the bug was: the head is still
722                    // the author's, so the author would be reading its own work.
723                    logwarn!(
724                        "{}: {holder} said it would fix its own findings and committed nothing, \
725                         so it keeps the pull request.",
726                        ctx.label
727                    );
728                    state.notes.push(format!(
729                        "{holder} chose to fix its own findings in round {round} and committed \
730                         nothing"
731                    ));
732                }
733            }
734        } else {
735            let author_name = cfg.other(&holder);
736            let author = agent::find(agents, &author_name)?;
737            log!(
738                "{}: handing {} finding(s) to {author_name}",
739                ctx.label,
740                blocking.len()
741            );
742            let prompt = RESPOND_PROMPT
743                .replace("{number}", &ctx.subject.to_string())
744                .replace("{findings}", &findings_for_prompt(&blocking));
745            let before_response = snapshot(repo, &ctx.work_dir);
746            let response: ResponseDoc = author.ask_json(
747                &prompt,
748                &schema::response(),
749                &ctx.work_dir,
750                cfg.effort_for_round(&author.spec, round).as_deref(),
751            )?;
752            if let Some(who) = editor_after(
753                repo,
754                &ctx.work_dir,
755                &before_response,
756                &ctx.label,
757                &author_name,
758            ) {
759                editor = Some(who);
760            } else if response
761                .dispositions
762                .iter()
763                .any(|d| d.action == Action::Fixed)
764            {
765                logwarn!(
766                    "{}: {author_name} reported fixes but committed nothing, so the diff does not \
767                     have them.",
768                    ctx.label
769                );
770            }
771            apply_dispositions(
772                repo,
773                cfg,
774                &response,
775                &blocking,
776                ledger,
777                state,
778                round,
779                ctx.subject,
780                ctx.pr_number,
781                &author_name,
782            );
783        }
784
785        if editor.is_some() {
786            repo.rewrite_commits_if_needed(&ctx.work_dir, &base)?;
787            repo.push(&ctx.work_dir, &ctx.branch)?;
788        }
789        holder = next_reviewer(cfg, &holder, editor.as_deref());
790        persist(repo, ctx.pr_number, state, ledger, round, &holder);
791    }
792
793    state.status = Status::Escalated;
794    state
795        .notes
796        .push(exhausted_note(ctx.start_round, last_round));
797    post_outcome(repo, ctx.pr_number, state, ledger, Ending::OutOfRounds);
798    persist(repo, ctx.pr_number, state, ledger, last_round, &holder);
799    Ok(())
800}
801
802/// Whether a review with nothing blocking can end the run.
803///
804/// A review that wrote to the branch judged a tree the rollback then takes
805/// away, so "nothing blocking" was said about code that is not there any more:
806/// a reviewer that quietly fixes what it finds and reports clean would merge
807/// the defect it fixed. Another round on the restored branch is cheaper than
808/// that.
809fn approval_stands(blocking: &[Finding], review_wrote: bool) -> bool {
810    blocking.is_empty() && !review_wrote
811}
812
813/// Who reviews the next round: never the agent that wrote the head it will
814/// read.
815///
816/// `editor` is whoever moved HEAD this round, observed rather than inferred
817/// from `next_action`. The two came apart in both directions: a `fix_myself`
818/// call that returned without committing handed the author its own commit back,
819/// and a reviewer that committed during `hand_back` kept a PR whose head it had
820/// written.
821///
822/// Nothing landing at all leaves the head with the author, which by this rule's
823/// own invariant is not the reviewer, so the reviewer keeps the pull request and
824/// reads the same commit again.
825fn next_reviewer(cfg: &Config, reviewer: &str, editor: Option<&str>) -> String {
826    match editor {
827        Some(editor) => cfg.other(editor),
828        None => reviewer.to_string(),
829    }
830}
831
832/// Who wrote the head after a call that was asked to commit, if anybody did.
833///
834/// A call that returns successfully is not evidence of a commit, and custody is
835/// decided on this answer, so it is read from git rather than taken from the
836/// agent's word for it. Anything it left uncommitted goes the same way as a
837/// review's edits, and for the same reason: the round it hands over is the diff
838/// on the branch, not the state of somebody's checkout.
839fn editor_after(
840    repo: &Repo,
841    work_dir: &Path,
842    before: &Snapshot,
843    label: &str,
844    who: &str,
845) -> Option<String> {
846    let after = snapshot(repo, work_dir);
847    if after.dirty {
848        logwarn!(
849            "{label}: {who} left tracked files uncommitted. Only commits are pushed and the next \
850             review reads the tree, so they are discarded."
851        );
852        drop_uncommitted(repo, work_dir);
853    }
854    after.landed_over(before).then(|| who.to_string())
855}
856
857/// The inclusive range of round numbers this invocation will work through.
858///
859/// Round numbers keep counting up across sessions so the ledger and the PR
860/// history stay coherent, while the budget resets each time a person chooses to
861/// run spar again.
862fn round_window(start_round: u32, budget: u32) -> (u32, u32) {
863    (start_round, start_round + budget.saturating_sub(1))
864}
865
866/// How many rounds this invocation spent, and how many the PR has seen in
867/// total. A resumed PR that stops at round 8 did not have 8 rounds of budget,
868/// and saying so would misreport both the cost and the history.
869fn spent(start_round: u32, last_round: u32) -> (u32, u32) {
870    (last_round.saturating_sub(start_round) + 1, last_round)
871}
872
873fn exhausted_note(start_round: u32, last_round: u32) -> String {
874    let (this_run, total) = spent(start_round, last_round);
875    if this_run == total {
876        format!("no convergence after {this_run} rounds")
877    } else {
878        format!("no convergence after {this_run} more rounds ({total} in total)")
879    }
880}
881
882fn persist(
883    repo: &Repo,
884    pr_number: i64,
885    state: &IssueRun,
886    ledger: &Ledger,
887    round: u32,
888    next_actor: &str,
889) {
890    let payload = PersistedState {
891        version: STATE_VERSION,
892        round,
893        next_actor: next_actor.to_string(),
894        status: state.status,
895        ledger: ledger.clone(),
896        filed: state.filed.clone(),
897    };
898    if let Err(e) = repo.write_state(pr_number, &payload) {
899        logdim!("could not persist state for PR #{pr_number}: {e}");
900    }
901}
902
903// ---------------------------------------------------------------------------
904// The ledger
905// ---------------------------------------------------------------------------
906
907fn settled_block(ledger: &Ledger) -> String {
908    if ledger.is_empty() {
909        return String::new();
910    }
911    let lines: Vec<String> = ledger
912        .values()
913        .map(|e| match e.outcome {
914            Settled::Refuted => format!("- {}: refuted because {}", e.title, e.reasoning),
915            Settled::Filed => format!(
916                "- {}: out of scope here, and filed. {}",
917                e.title, e.reasoning
918            ),
919            Settled::Dropped => format!(
920                "- {}: out of scope here, and not filed. {}",
921                e.title, e.reasoning
922            ),
923        })
924        .collect();
925    format!(
926        "\nThe following points were already raised and settled, by a refutation or by a \
927         follow-up issue. Treat them as settled. Do not raise them again unless you have new \
928         evidence:\n{}",
929        lines.join("\n")
930    )
931}
932
933/// Record a point as settled, keeping any re-raise count it already carries.
934/// Answering the same point a second time does not reset the argument, and
935/// zeroing the count here would put the escalation guard out of reach: the
936/// count is spent every round and rebuilt from nothing every round.
937fn settle(ledger: &mut Ledger, key: String, entry: LedgerEntry) {
938    let reraised = ledger.get(&key).map(|e| e.reraised).unwrap_or(0);
939    ledger.insert(key, LedgerEntry { reraised, ..entry });
940}
941
942/// A settled point raised twice more goes to a person rather than looping
943/// forever.
944fn check_relitigation(ledger: &mut Ledger, blocking: &[Finding], state: &mut IssueRun) -> bool {
945    let mut escalate = false;
946    for finding in blocking {
947        let key = finding_key(&finding.title, &finding.file);
948        if let Some(entry) = ledger.get_mut(&key) {
949            entry.reraised += 1;
950            if entry.reraised >= 2 {
951                state.notes.push(format!(
952                    "'{}' was settled and re-raised twice; escalating.",
953                    finding.title
954                ));
955                escalate = true;
956            }
957        }
958    }
959    escalate
960}
961
962fn normalise(text: &str) -> String {
963    text.to_lowercase()
964        .chars()
965        .filter(|c| c.is_ascii_alphanumeric() || c.is_whitespace())
966        .collect::<String>()
967        .split_whitespace()
968        .collect::<Vec<_>>()
969        .join(" ")
970}
971
972/// Match a disposition back to the finding it answers, so the ledger key it
973/// records is the same key the next round's finding will hash to. Without this
974/// the re-litigation guard is dead code for any finding that names a file.
975/// Whether two titles name the same point, ignoring wording noise.
976pub(crate) fn same_point(a: &str, b: &str) -> bool {
977    normalise(a) == normalise(b)
978}
979
980fn matching_finding<'a>(findings: &'a [Finding], title: &str) -> Option<&'a Finding> {
981    let wanted = normalise(title);
982    findings.iter().find(|f| normalise(&f.title) == wanted)
983}
984
985#[allow(clippy::too_many_arguments)]
986fn apply_dispositions(
987    repo: &Repo,
988    cfg: &Config,
989    response: &ResponseDoc,
990    blocking: &[Finding],
991    ledger: &mut Ledger,
992    state: &mut IssueRun,
993    round: u32,
994    subject: i64,
995    pr_number: i64,
996    author: &str,
997) {
998    let mut fixed = Vec::new();
999    let mut refuted = Vec::new();
1000    let mut filed = Vec::new();
1001
1002    for d in &response.dispositions {
1003        let source = matching_finding(blocking, &d.title);
1004        let file = source
1005            .map(|f| f.file.clone())
1006            .filter(|f| !f.trim().is_empty())
1007            .unwrap_or_else(|| d.file.clone());
1008        // Hash the *reviewer's* wording, not the author's. `matching_finding`
1009        // is deliberately looser than `finding_key` (it ignores hyphens, dots,
1010        // slashes, and underscores), so an author who writes "multibyte" where
1011        // the reviewer wrote "multi-byte" matches here and yet hashes to a
1012        // different key. Recording that key means next round's lookup misses
1013        // and the re-litigation guard tracks nothing at all.
1014        let canonical = source.map(|f| f.title.as_str()).unwrap_or(d.title.as_str());
1015        let title = style::title(canonical, &repo.style);
1016
1017        match d.action {
1018            Action::Refuted => {
1019                let reasoning = style::summary(&d.reasoning, &repo.style);
1020                settle(
1021                    ledger,
1022                    finding_key(canonical, &file),
1023                    LedgerEntry {
1024                        title: title.clone(),
1025                        file: file.clone(),
1026                        reasoning: reasoning.clone(),
1027                        round,
1028                        reraised: 0,
1029                        outcome: Settled::Refuted,
1030                    },
1031                );
1032                state.disputes.push(Dispute {
1033                    title: title.clone(),
1034                    reasoning: reasoning.clone(),
1035                });
1036                refuted.push(format!("{title}. {reasoning}"));
1037            }
1038            Action::FiledIssue => {
1039                let new_title = d
1040                    .new_issue_title
1041                    .clone()
1042                    .filter(|t| !t.trim().is_empty())
1043                    .unwrap_or_else(|| d.title.clone());
1044                let new_body = d
1045                    .new_issue_body
1046                    .clone()
1047                    .filter(|b| !b.trim().is_empty())
1048                    .unwrap_or_else(|| d.reasoning.clone());
1049                let recorded = file_followup(repo, &new_title, &new_body, subject, cfg, state);
1050                if let Some(url) = recorded.url() {
1051                    state.filed.push(url.to_string());
1052                    filed.push(url.to_string());
1053                }
1054                // Settled like a refutation, because it ends the same way: the
1055                // code will not change for this point on this branch. Without
1056                // the entry the reviewer keeping the PR raises it again next
1057                // round, the author files a duplicate, and the round budget
1058                // goes on one point nobody disagrees about.
1059                //
1060                // Unless nothing holds the point, in which case there is no
1061                // entry to write: see `filed_entry`.
1062                let Some((outcome, reasoning)) =
1063                    filed_entry(&recorded, &style::summary(&d.reasoning, &repo.style))
1064                else {
1065                    logwarn!(
1066                        "'{title}' was not recorded anywhere, so it stays open for the next round"
1067                    );
1068                    continue;
1069                };
1070                settle(
1071                    ledger,
1072                    finding_key(canonical, &file),
1073                    LedgerEntry {
1074                        title: title.clone(),
1075                        file: file.clone(),
1076                        reasoning,
1077                        round,
1078                        reraised: 0,
1079                        outcome,
1080                    },
1081                );
1082            }
1083            Action::Fixed => fixed.push(title),
1084        }
1085    }
1086
1087    if repo.style.pr_comments == PrComments::Rounds {
1088        let comment = disposition_comment(author, response, &fixed, &refuted, &filed, &repo.style);
1089        if let Some(text) = comment {
1090            if let Err(e) = repo.comment_pr(pr_number, &text) {
1091                logdim!("could not post the disposition comment: {e}");
1092            }
1093        }
1094    }
1095}
1096
1097/// What the ledger should say about a point the author moved out of this pull
1098/// request, and whether it should say anything at all.
1099///
1100/// Nothing, for a follow-up that failed. An entry tells every later round the
1101/// point was dealt with, and it outlives the run: recording one for a write
1102/// that never happened suppresses a real defect for good, on the strength of a
1103/// transient error.
1104fn filed_entry(recorded: &Followup, reasoning: &str) -> Option<(Settled, String)> {
1105    let (outcome, tail) = match recorded {
1106        Followup::Recorded(reference) => (
1107            Settled::Filed,
1108            format!("Tracked in {}.", as_reference(reference)),
1109        ),
1110        Followup::Covered(reference) => (
1111            Settled::Filed,
1112            format!("Already covered by {}.", as_reference(reference)),
1113        ),
1114        Followup::Dropped(why) => (Settled::Dropped, format!("Not filed anywhere: {why}.")),
1115        Followup::Failed => return None,
1116    };
1117    let reasoning = match reasoning.trim() {
1118        "" => tail,
1119        said => format!("{said} {tail}"),
1120    };
1121    Some((outcome, reasoning))
1122}
1123
1124// ---------------------------------------------------------------------------
1125// Follow-ups
1126// ---------------------------------------------------------------------------
1127
1128/// Record a finding that is real but out of scope for this PR.
1129///
1130/// On your own repository an issue is the right home. On a large repository
1131/// that is not yours it is somebody else's notification and somebody else's
1132/// triage queue, so `local` keeps the same information in `.spar/followups.md`
1133/// and `none` drops it.
1134///
1135/// The answer says which of those happened, because the caller settles the
1136/// point on it. A failure and a deliberate drop look identical from the outside
1137/// and mean opposite things to the next round.
1138pub fn file_followup(
1139    repo: &Repo,
1140    title: &str,
1141    body: &str,
1142    source: i64,
1143    cfg: &Config,
1144    state: &IssueRun,
1145) -> Followup {
1146    if repo.followups == Followups::None {
1147        return Followup::Dropped("follow-ups are off for this repository");
1148    }
1149    // A backstop against a run that will not stop finding things. Silent
1150    // truncation is not on offer: what was dropped is said out loud.
1151    if state.filed.len() >= cfg.loop_cfg.max_followups {
1152        logwarn!(
1153            "already recorded {} follow-ups, not recording '{}'. Raise max_followups if you want \
1154             them all.",
1155            state.filed.len(),
1156            style::title(title, &repo.style)
1157        );
1158        return Followup::Dropped("this run had already recorded as many follow-ups as it may");
1159    }
1160    // The exact string that will land on GitHub. Searching for anything else
1161    // means the duplicate check can never hit, and every round files another
1162    // copy of the same follow-up.
1163    //
1164    // A title the style gate cannot clean is a failure rather than a drop: the
1165    // next round words the point differently, and that wording may pass.
1166    let title = match repo.clean_title(title) {
1167        Ok(title) => title,
1168        Err(e) => {
1169            logdim!("could not clean a follow-up title: {e}");
1170            return Followup::Failed;
1171        }
1172    };
1173    if title.trim().is_empty() {
1174        logdim!("nothing left of a follow-up title after cleaning it");
1175        return Followup::Failed;
1176    }
1177    // Not style::body: that is the budget for a pull request comment, read with
1178    // the diff in front of you. This is a work item somebody picks up cold.
1179    let body = format!(
1180        "{}\n\nFound while working on #{source}.",
1181        style::issue_body(body, &repo.style)
1182    );
1183
1184    if repo.followups == Followups::Local {
1185        return repo.append_local_followup(&title, &body);
1186    }
1187
1188    match file_as_issue(repo, &title, &body) {
1189        Ok(filed) => filed.into(),
1190        Err(e) => {
1191            logdim!("could not file a follow-up for '{title}': {e}");
1192            Followup::Failed
1193        }
1194    }
1195}
1196
1197/// What happened to one finding on the way to the tracker.
1198#[derive(Debug, Clone)]
1199pub enum Filed {
1200    /// A new issue.
1201    Opened(i64, String),
1202    /// An open issue already covered it, and this pass had something to add.
1203    AddedTo(i64, String),
1204    /// An open issue already covered it, and this pass added nothing.
1205    Covered(i64, String),
1206    /// A closed issue already covered it. Nothing was written.
1207    AlreadyClosed(i64, String),
1208}
1209
1210impl From<Filed> for Followup {
1211    fn from(filed: Filed) -> Self {
1212        match filed {
1213            Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => {
1214                Followup::Recorded(url)
1215            }
1216            // Covered rather than recorded: the point is genuinely tracked, so
1217            // raising it again is waste, but the issue holding it is closed and
1218            // must not be handed out as work.
1219            Filed::AlreadyClosed(_, url) => Followup::Covered(url),
1220        }
1221    }
1222}
1223
1224impl Filed {
1225    pub fn url(&self) -> Option<&str> {
1226        match self {
1227            Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => Some(url),
1228            // The work is done and closed. Reporting it as filed would put it
1229            // back into a wave to be implemented again.
1230            Filed::AlreadyClosed(_, _) => None,
1231        }
1232    }
1233
1234    /// The issue to work, when there is one to work.
1235    pub fn number(&self) -> Option<i64> {
1236        match self {
1237            Filed::Opened(n, _) | Filed::AddedTo(n, _) | Filed::Covered(n, _) => Some(*n),
1238            Filed::AlreadyClosed(_, _) => None,
1239        }
1240    }
1241
1242    /// One clause saying where it went, for a log line or an archive entry.
1243    pub fn note(&self) -> String {
1244        match self {
1245            Filed::Opened(n, _) => format!("#{n}"),
1246            Filed::AddedTo(n, _) => format!("added to #{n}"),
1247            Filed::Covered(n, _) => format!("#{n} already says this"),
1248            Filed::AlreadyClosed(n, _) => format!("#{n} covers it and is closed"),
1249        }
1250    }
1251
1252    pub fn describe(&self, title: &str) -> String {
1253        let title = style::clip(title.trim(), 80);
1254        match self {
1255            Filed::Opened(n, _) => format!("filed #{n}: {title}"),
1256            Filed::AddedTo(n, _) => format!("added to #{n}: {title}"),
1257            Filed::Covered(n, _) => format!("#{n} already says this: {title}"),
1258            Filed::AlreadyClosed(n, _) => format!("#{n} covers it and is closed: {title}"),
1259        }
1260    }
1261}
1262
1263/// File an issue, or add to the one that already covers it.
1264///
1265/// Exact title matching let duplicates through: two agents, or two runs a week
1266/// apart, never word one defect identically, and a real run filed two that had
1267/// to be closed by hand. Filing a second copy is the complaint; silently
1268/// dropping the new wording is not much better, because a later pass often
1269/// carries evidence the first did not.
1270///
1271/// The title arrives cleaned by the caller, and it has to: searching for
1272/// anything but the exact string that will land on GitHub means the duplicate
1273/// check can never hit.
1274pub fn file_as_issue(repo: &Repo, title: &str, body: &str) -> Result<Filed> {
1275    let title = repo.clean_title(title)?;
1276    if title.trim().is_empty() {
1277        return Err(spar_err!("nothing left of the title after cleaning it"));
1278    }
1279    if let Some(existing) = repo.find_similar_issue(&title, body) {
1280        let known = format!("{} {}", existing.title, existing.body);
1281        if !existing.open {
1282            return Ok(Filed::AlreadyClosed(existing.number, existing.url));
1283        }
1284        if crate::textsim::adds_information(body, &known) {
1285            repo.comment_issue(existing.number, body)?;
1286            return Ok(Filed::AddedTo(existing.number, existing.url));
1287        }
1288        return Ok(Filed::Covered(existing.number, existing.url));
1289    }
1290    let url = repo.create_issue(&title, body)?;
1291    let number = filed_issue_number(&url)
1292        .ok_or_else(|| spar_err!("filed an issue but could not read its number from {url}"))?;
1293    Ok(Filed::Opened(number, url))
1294}
1295
1296fn file_out_of_scope(
1297    repo: &Repo,
1298    findings: &[Finding],
1299    subject: i64,
1300    state: &mut IssueRun,
1301    cfg: &Config,
1302) {
1303    for finding in findings.iter().filter(|f| !f.in_scope) {
1304        let body = issue_report(finding);
1305        let recorded = file_followup(repo, &finding.title, &body, subject, cfg, state);
1306        if let Some(url) = recorded.url() {
1307            state.filed.push(url.to_string());
1308        }
1309    }
1310}
1311
1312/// A finding written as a bug report, when it carries the parts of one.
1313///
1314/// The thread gets one line; an issue gets the whole thing under headings, in
1315/// the order somebody reads a bug report: what is wrong, how to see it, what it
1316/// costs, what it should do instead. A finding with none of those falls back to
1317/// its detail, which is every finding that was never going to be filed.
1318pub fn issue_report(finding: &Finding) -> String {
1319    let sections = finding.report_sections();
1320    if sections.is_empty() {
1321        return finding.detail.clone();
1322    }
1323    let mut out: Vec<String> = sections
1324        .iter()
1325        .map(|(heading, text)| format!("## {heading}\n\n{text}"))
1326        .collect();
1327    // Keep the one line summary when it says something the sections do not,
1328    // rather than dropping it or repeating it.
1329    if !finding.detail.trim().is_empty()
1330        && !sections
1331            .iter()
1332            .any(|(_, text)| crate::textsim::same_point(text, &finding.detail))
1333    {
1334        out.insert(0, finding.detail.trim().to_string());
1335    }
1336    out.join("\n\n")
1337}
1338
1339/// Non-blocking findings become follow-ups so they do not gate the merge.
1340///
1341/// Nits are excluded by default. On a shared repository a filed nit is somebody
1342/// else's notification and somebody else's triage queue: an early run on a
1343/// production codebase opened an issue titled "Log wording". Worth saying in
1344/// the PR thread, not worth an issue.
1345fn file_nonblocking(
1346    repo: &Repo,
1347    findings: &[Finding],
1348    subject: i64,
1349    state: &mut IssueRun,
1350    cfg: &Config,
1351) {
1352    for finding in findings {
1353        let keep = match finding.severity {
1354            Severity::NonBlocking => cfg.loop_cfg.file_non_blocking,
1355            Severity::Nit => cfg.loop_cfg.file_nits,
1356            Severity::Blocking => false,
1357        };
1358        if !keep || !finding.in_scope {
1359            continue;
1360        }
1361        let recorded = file_followup(repo, &finding.title, &finding.detail, subject, cfg, state);
1362        if let Some(url) = recorded.url() {
1363            state.filed.push(url.to_string());
1364        }
1365    }
1366}
1367
1368// ---------------------------------------------------------------------------
1369// What a human actually reads
1370// ---------------------------------------------------------------------------
1371//
1372// spar composes every comment itself from structured fields, rather than
1373// forwarding whatever prose a model produced. That is the only reliable way to
1374// keep a PR thread readable: the model supplies facts, the harness supplies the
1375// shape, and each field is held to a budget on the way out.
1376
1377fn bullets(lines: &[String]) -> String {
1378    lines
1379        .iter()
1380        .map(|l| format!("- {l}"))
1381        .collect::<Vec<_>>()
1382        .join("\n")
1383}
1384
1385fn located(finding: &Finding, style: &Style) -> String {
1386    let title = style::title(&finding.title, style);
1387    match finding.where_at() {
1388        "general" => title,
1389        file => format!("{title} ({file})"),
1390    }
1391}
1392
1393/// How the run ended, which is the only thing about the run a reader needs.
1394pub enum Ending<'a> {
1395    /// Nothing blocks a merge.
1396    Approved,
1397    /// The round budget ran out. The last round's fixes were pushed but never
1398    /// reviewed, which is the part a maintainer has to know.
1399    OutOfRounds,
1400    /// A point was refuted and raised again anyway. Nobody is going to break
1401    /// the tie but a person.
1402    Deadlocked(&'a [Finding]),
1403}
1404
1405/// Post the one comment a run leaves behind, if it has anything to say.
1406///
1407/// Everything spar used to write here was an account of its own working: which
1408/// agent spoke, which round it was, how many findings of each severity, that it
1409/// had stopped. None of that is about the code. Worse, the running commentary
1410/// could contradict itself, ending a thread with "5 fixed" immediately followed
1411/// by "no convergence", which reads as a failure rather than as fixes nobody
1412/// has checked yet.
1413///
1414/// So the loop is silent and this says what is left: what is unresolved, what
1415/// was argued down, and where the follow-ups went.
1416pub fn post_outcome(
1417    repo: &Repo,
1418    pr_number: i64,
1419    state: &IssueRun,
1420    ledger: &Ledger,
1421    ending: Ending<'_>,
1422) {
1423    if repo.style.pr_comments != PrComments::Outcome {
1424        return;
1425    }
1426    let Some(text) = outcome_comment(state, ledger, &ending, &repo.style) else {
1427        return;
1428    };
1429    if let Err(e) = repo.comment_pr(pr_number, &text) {
1430        logdim!("could not post the outcome comment: {e}");
1431    }
1432}
1433
1434/// How a point was settled and why: this run's disputes first, then the ledger,
1435/// which is what survives across a resume.
1436fn settled_as(finding: &Finding, state: &IssueRun, ledger: &Ledger) -> Option<(Settled, String)> {
1437    if let Some(d) = state
1438        .disputes
1439        .iter()
1440        .find(|d| same_point(&d.title, &finding.title))
1441    {
1442        if !d.reasoning.trim().is_empty() {
1443            return Some((Settled::Refuted, d.reasoning.clone()));
1444        }
1445    }
1446    ledger
1447        .get(&finding_key(&finding.title, &finding.file))
1448        .filter(|entry| !entry.reasoning.trim().is_empty())
1449        .map(|entry| (entry.outcome, entry.reasoning.clone()))
1450}
1451
1452/// `#123` from a filed issue URL, falling back to the URL when it does not look
1453/// like one. Shorter, and GitHub renders it as a link either way.
1454/// The issue number a filed follow-up URL points at, when it is one. Local
1455/// notes and anything unparseable yield nothing.
1456pub fn filed_issue_number(filed: &str) -> Option<i64> {
1457    filed
1458        .rsplit('/')
1459        .next()
1460        .and_then(|tail| tail.parse::<i64>().ok())
1461        .filter(|n| *n > 0)
1462}
1463
1464fn as_reference(url: &str) -> String {
1465    match url.rsplit('/').next().and_then(|n| n.parse::<u64>().ok()) {
1466        Some(number) => format!("#{number}"),
1467        None => url.to_string(),
1468    }
1469}
1470
1471pub fn outcome_comment(
1472    state: &IssueRun,
1473    ledger: &Ledger,
1474    ending: &Ending<'_>,
1475    style: &Style,
1476) -> Option<String> {
1477    let mut out: Vec<String> = Vec::new();
1478    // Points rendered in the deadlock block, so the refutation list below does
1479    // not print the same title a second time.
1480    let mut already: Vec<String> = Vec::new();
1481
1482    match ending {
1483        Ending::Approved => {
1484            if state.disputes.is_empty() && state.filed.is_empty() {
1485                // A clean approval with nothing outstanding needs no comment.
1486                // The absence of objections is the message.
1487                return None;
1488            }
1489            out.push("Reviewed, nothing blocking a merge.".into());
1490        }
1491        Ending::OutOfRounds => out.push(
1492            "Not signed off: the last round of fixes was pushed but has not been reviewed.".into(),
1493        ),
1494        Ending::Deadlocked(points) => {
1495            // Rendered once, with the argument attached. A deadlocked point is
1496            // by definition one that was settled earlier, so the reasoning is
1497            // the whole reason a person is being asked to look. On a resumed
1498            // run `state.disputes` is empty (only `filed` is restored), so the
1499            // ledger is the only place that argument survives.
1500            let lines: Vec<String> = points
1501                .iter()
1502                .map(|f| {
1503                    let where_at = match f.where_at() {
1504                        "general" => String::new(),
1505                        file => format!(" ({file})"),
1506                    };
1507                    let title = style::title(&f.title, style);
1508                    already.push(title.clone());
1509                    match settled_as(f, state, ledger) {
1510                        Some((Settled::Refuted, reason)) => format!(
1511                            "{title}{where_at}. Refuted as: {}",
1512                            style::summary(&reason, style)
1513                        ),
1514                        Some((Settled::Filed, reason)) => format!(
1515                            "{title}{where_at}. Filed as out of scope: {}",
1516                            style::summary(&reason, style)
1517                        ),
1518                        // Never "filed": nothing holds this point but the
1519                        // comment you are reading.
1520                        Some((Settled::Dropped, reason)) => format!(
1521                            "{title}{where_at}. Out of scope here, and not filed: {}",
1522                            style::summary(&reason, style)
1523                        ),
1524                        None => format!("{title}{where_at}"),
1525                    }
1526                })
1527                .collect();
1528            out.push("Needs your decision. The reviewers could not settle this:".into());
1529            out.push(bullets(&lines));
1530        }
1531    }
1532
1533    let disputes: Vec<&crate::model::Dispute> = state
1534        .disputes
1535        .iter()
1536        .filter(|d| !already.iter().any(|t| same_point(t, &d.title)))
1537        .collect();
1538    if !disputes.is_empty() {
1539        // The one thing invisible anywhere else. The diff shows what was fixed;
1540        // nothing shows what was argued down, or why.
1541        let lines: Vec<String> = disputes
1542            .iter()
1543            .map(|d| {
1544                format!(
1545                    "{}. {}",
1546                    style::title(&d.title, style),
1547                    style::sentence(&d.reasoning, style)
1548                )
1549            })
1550            .collect();
1551        out.push(format!("Raised and refuted:\n{}", bullets(&lines)));
1552    }
1553
1554    if !state.filed.is_empty() {
1555        let refs: Vec<String> = state.filed.iter().map(|u| as_reference(u)).collect();
1556        out.push(format!("Filed separately: {}", refs.join(", ")));
1557    }
1558
1559    Some(out.join("\n\n"))
1560}
1561
1562/// What the implementor is asked, with the issue in front of it.
1563///
1564/// The body is passed rather than only the link, because one of the two agents
1565/// cannot follow a link: codex runs under `-s workspace-write`, which has no
1566/// network at all, so a URL alone would leave it judging the title. The link is
1567/// there for the agent that can follow it, and for the comments spar does not
1568/// fetch.
1569fn implement_prompt(number: i64, title: &str, url: &str, body: &str) -> String {
1570    IMPLEMENT_PROMPT
1571        .replace("{number}", &number.to_string())
1572        .replace("{title}", title)
1573        .replace("{url}", url)
1574        .replace("{body}", body)
1575}
1576
1577/// The pull request body.
1578///
1579/// What it closes, then the change in one sentence, then what was wrong, then
1580/// only the sections that have something in them. The lead is two paragraphs
1581/// rather than two headings: a heading over a single sentence is a label on a
1582/// label, and those two parts are the ones every body has.
1583///
1584/// GitHub renders the file count and the plus and minus figures immediately
1585/// above this, so neither appears here.
1586pub fn pr_body(issue: i64, work: &Implementation, style: &Style) -> String {
1587    let mut parts = vec![format!("Closes #{issue}")];
1588
1589    for lead in [&work.summary, &work.problem] {
1590        let text = style::sentence(lead, style);
1591        if !text.is_empty() {
1592            parts.push(text);
1593        }
1594    }
1595    parts.extend(section("What changed", &work.changes, style));
1596    parts.extend(section("How to test", &work.testing, style));
1597
1598    let notes = style::sentence(work.notes.as_deref().unwrap_or_default(), style);
1599    if !notes.is_empty() {
1600        parts.push(format!("## Notes\n\n{notes}"));
1601    }
1602
1603    style::body(&parts.join("\n\n"), style)
1604}
1605
1606/// A headed list, or nothing at all when there is nothing to list.
1607///
1608/// Nothing at all on purpose. A heading with an empty body under it reads as a
1609/// section somebody forgot to write, which is worse than the absence, and a
1610/// small change that needs no change list should not be made to look like one
1611/// that is missing its.
1612fn section(heading: &str, lines: &[String], style: &Style) -> Option<String> {
1613    let items: Vec<String> = lines
1614        .iter()
1615        .map(|line| style::summary(line, style))
1616        .filter(|line| !line.is_empty())
1617        .collect();
1618    if items.is_empty() {
1619        return None;
1620    }
1621    Some(format!("## {heading}\n\n{}", bullets(&items)))
1622}
1623
1624/// A pull request body for work whose author never got to describe it.
1625///
1626/// The implement call failed after the commits were made, so what those commits
1627/// say about themselves is the only account of them there is. It is a poor one,
1628/// and better than an empty body over work nobody would otherwise know was
1629/// there; the note says as much, so a reviewer does not read the list as the
1630/// author's own summary.
1631pub fn from_commits(repo: &Repo, work_dir: &Path, base: &str) -> Implementation {
1632    Implementation {
1633        changes: repo.commit_subjects(work_dir, "HEAD", base),
1634        notes: Some(
1635            "The implement call failed after these commits were made, so this body is assembled \
1636             from their messages rather than written by their author. Read the diff."
1637                .to_string(),
1638        ),
1639        ..Implementation::default()
1640    }
1641}
1642
1643/// What gets posted on an issue that produced no pull request.
1644///
1645/// The agent's own reason when it gave one, since that is the part written for
1646/// the person who opened the issue. Never the summary: an issue that produced
1647/// no commits has no change for a summary to describe, and one that claims
1648/// otherwise is worse than a flat sentence saying nothing happened.
1649fn no_pr_note(work: &Implementation, style: &Style) -> String {
1650    let reason = style::sentence(&work.reason, style);
1651    if !reason.is_empty() {
1652        return reason;
1653    }
1654    if work.not_worth_doing {
1655        "Left alone after reading the code, with no reason given.".to_string()
1656    } else {
1657        "Nothing was committed, so there is nothing to review.".to_string()
1658    }
1659}
1660
1661/// One review, as a reviewer would write it if they were in a hurry: a count
1662/// line, a sentence, and one bullet per finding. Only blocking findings carry
1663/// their detail, because only those are something the author has to act on now.
1664pub fn review_comment(holder: &str, round: u32, review: &Review, style: &Style) -> String {
1665    let by = |severity: Severity| -> Vec<&Finding> {
1666        review
1667            .findings
1668            .iter()
1669            .filter(|f| f.severity == severity && f.in_scope)
1670            .collect()
1671    };
1672    let blocking = by(Severity::Blocking);
1673    let non_blocking = by(Severity::NonBlocking);
1674    let nits = by(Severity::Nit);
1675    let out_of_scope: Vec<&Finding> = review.findings.iter().filter(|f| !f.in_scope).collect();
1676
1677    let mut counts = Vec::new();
1678    if !blocking.is_empty() {
1679        counts.push(format!("{} blocking", blocking.len()));
1680    }
1681    if !non_blocking.is_empty() {
1682        counts.push(format!("{} non-blocking", non_blocking.len()));
1683    }
1684    if !nits.is_empty() {
1685        counts.push(format!("{} nit", nits.len()));
1686    }
1687    if !out_of_scope.is_empty() {
1688        counts.push(format!("{} out of scope", out_of_scope.len()));
1689    }
1690    let headline = if counts.is_empty() {
1691        "no findings".to_string()
1692    } else {
1693        counts.join(", ")
1694    };
1695
1696    let _ = (holder, round, headline);
1697    let mut out = Vec::new();
1698    let summary = style::summary(&review.summary, style);
1699    if !summary.is_empty() {
1700        out.push(summary);
1701    }
1702
1703    if !blocking.is_empty() {
1704        let lines: Vec<String> = blocking
1705            .iter()
1706            .map(|f| {
1707                let detail = style::detail(&f.detail, style);
1708                if detail.is_empty() {
1709                    located(f, style)
1710                } else {
1711                    format!("{}. {detail}", located(f, style))
1712                }
1713            })
1714            .collect();
1715        out.push(format!("blocking\n{}", bullets(&lines)));
1716    }
1717
1718    // Everything below is filed as a follow-up, so the thread only needs the
1719    // title: the detail lives on the issue where it can be acted on.
1720    for (label, group) in [
1721        ("non-blocking", &non_blocking),
1722        ("nits", &nits),
1723        ("out of scope", &out_of_scope),
1724    ] {
1725        if group.is_empty() {
1726            continue;
1727        }
1728        let lines: Vec<String> = group.iter().map(|f| located(f, style)).collect();
1729        out.push(format!("{label}\n{}", bullets(&lines)));
1730    }
1731
1732    out.join("\n\n")
1733}
1734
1735/// One response to a review. Refutations carry their reasoning because that is
1736/// the whole argument; fixes are a list of titles because the diff says the
1737/// rest.
1738pub fn disposition_comment(
1739    author: &str,
1740    response: &ResponseDoc,
1741    fixed: &[String],
1742    refuted: &[String],
1743    filed: &[String],
1744    style: &Style,
1745) -> Option<String> {
1746    if fixed.is_empty() && refuted.is_empty() && filed.is_empty() {
1747        return None;
1748    }
1749    let mut counts = Vec::new();
1750    if !fixed.is_empty() {
1751        counts.push(format!("{} fixed", fixed.len()));
1752    }
1753    if !refuted.is_empty() {
1754        counts.push(format!("{} refuted", refuted.len()));
1755    }
1756    if !filed.is_empty() {
1757        counts.push(format!("{} filed", filed.len()));
1758    }
1759
1760    let _ = (author, counts);
1761    let mut out = Vec::new();
1762    let summary = style::summary(&response.summary, style);
1763    if !summary.is_empty() {
1764        out.push(summary);
1765    }
1766    if !refuted.is_empty() {
1767        out.push(format!("refuted\n{}", bullets(refuted)));
1768    }
1769    if !fixed.is_empty() {
1770        out.push(format!("fixed\n{}", bullets(fixed)));
1771    }
1772    if !filed.is_empty() {
1773        out.push(format!("filed\n{}", bullets(filed)));
1774    }
1775    Some(out.join("\n\n"))
1776}
1777
1778/// What is posted on an issue both agents declined.
1779/// What is posted on an issue both reviewers declined.
1780///
1781/// Just the reasons. GitHub already shows that it was closed as not planned,
1782/// and which model held which opinion is a fact about the run rather than about
1783/// the issue. Duplicates are collapsed, since two reviewers reaching the same
1784/// conclusion often reach it in the same words.
1785pub fn skip_comment(item: &SkippedItem, style: &Style) -> String {
1786    let reasons = item
1787        .reasons
1788        .values()
1789        .map(|reason| style::sentence(reason, style));
1790    // Two reviewers declining one issue almost always decline it for the same
1791    // reason, worded differently. On the run that prompted this, both cited the
1792    // issue it duplicated and the reader saw the point twice.
1793    let lines = crate::textsim::dedupe_by(reasons, crate::textsim::same_reason);
1794    bullets(&lines)
1795}
1796
1797/// Findings as a model should see them: full detail, since this one is not for
1798/// a human to read.
1799pub(crate) fn findings_for_prompt(findings: &[Finding]) -> String {
1800    if findings.is_empty() {
1801        return "(none)".to_string();
1802    }
1803    findings
1804        .iter()
1805        .map(|f| {
1806            let scope = if f.in_scope { "" } else { " [out of scope]" };
1807            format!(
1808                "- [{}]{scope} {} ({})\n  {}",
1809                f.severity,
1810                f.title,
1811                f.where_at(),
1812                f.detail
1813            )
1814        })
1815        .collect::<Vec<_>>()
1816        .join("\n")
1817}
1818
1819#[cfg(test)]
1820mod tests {
1821    use super::*;
1822    use crate::model::Verdict;
1823
1824    fn style() -> Style {
1825        Style::default()
1826    }
1827
1828    fn finding(severity: &str, title: &str, detail: &str, file: &str, in_scope: bool) -> Finding {
1829        Finding {
1830            severity: Severity::parse_lenient(severity).unwrap(),
1831            title: title.into(),
1832            detail: detail.into(),
1833            file: file.into(),
1834            in_scope,
1835            ..Default::default()
1836        }
1837    }
1838
1839    fn review(summary: &str, findings: Vec<Finding>) -> Review {
1840        Review {
1841            verdict: Verdict::Approve,
1842            next_action: NextAction::Merge,
1843            summary: summary.into(),
1844            findings,
1845        }
1846    }
1847
1848    // -- worktree release ------------------------------------------------
1849
1850    fn cfg_with(worktrees: bool, keep: bool) -> Config {
1851        let text = "[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n";
1852        let mut cfg = crate::config::parse(text).unwrap();
1853        cfg.loop_cfg.worktrees = worktrees;
1854        cfg.loop_cfg.keep_worktrees = keep;
1855        cfg
1856    }
1857
1858    #[test]
1859    fn a_worktree_is_released_on_every_finished_outcome() {
1860        let cfg = cfg_with(true, false);
1861        for status in [Status::Approved, Status::Merged, Status::Abandoned] {
1862            assert!(should_release(&cfg, status), "{status}");
1863        }
1864    }
1865
1866    /// Releasing only on "merged" leaked one worktree per run, because
1867    /// auto_merge is off by default and runs end at "approved".
1868    #[test]
1869    fn a_worktree_is_kept_only_where_a_human_has_to_look() {
1870        let cfg = cfg_with(true, false);
1871        assert!(!should_release(&cfg, Status::Escalated));
1872        assert!(!should_release(&cfg, Status::Error));
1873    }
1874
1875    #[test]
1876    fn the_keep_flag_overrides_everything() {
1877        assert!(!should_release(&cfg_with(true, true), Status::Approved));
1878    }
1879
1880    #[test]
1881    fn nothing_is_released_when_worktrees_are_off() {
1882        assert!(!should_release(&cfg_with(false, false), Status::Approved));
1883    }
1884
1885    // -- custody ---------------------------------------------------------
1886
1887    /// The reviewer fixed the findings itself, so it wrote the head and the
1888    /// other agent takes round 2.
1889    #[test]
1890    fn fixing_your_own_findings_hands_the_pr_over() {
1891        let cfg = cfg_with(true, false);
1892        assert_eq!("a", next_reviewer(&cfg, "b", Some("b")));
1893        assert_eq!("b", next_reviewer(&cfg, "a", Some("a")));
1894    }
1895
1896    /// The author wrote the head, so the reviewer keeps the PR. Flipping here
1897    /// gave the author its own fix to review in round 2, and an approval of it
1898    /// ended the loop.
1899    #[test]
1900    fn handing_back_keeps_the_reviewer_for_the_next_round() {
1901        let cfg = cfg_with(true, false);
1902        assert_eq!("b", next_reviewer(&cfg, "b", Some("a")));
1903        assert_eq!("a", next_reviewer(&cfg, "a", Some("b")));
1904    }
1905
1906    /// Whoever holds round 2 did not write what it is reading, whoever wrote
1907    /// it. `a` implements, so `b` reviews round 1.
1908    #[test]
1909    fn nobody_reviews_their_own_edit() {
1910        let cfg = cfg_with(true, false);
1911        let round_1 = cfg.other(&cfg.first_implementor);
1912        assert_eq!("b", round_1);
1913        for editor in ["a", "b"] {
1914            assert_ne!(editor, next_reviewer(&cfg, &round_1, Some(editor)));
1915        }
1916    }
1917
1918    /// The `fix_myself` half of the bug. The reviewer said it would fix its own
1919    /// findings and the call returned without committing, so the head is still
1920    /// the author's and handing over would put the author in front of its own
1921    /// work.
1922    #[test]
1923    fn a_fix_that_committed_nothing_leaves_the_pr_where_it_is() {
1924        let cfg = cfg_with(true, false);
1925        assert_eq!("b", next_reviewer(&cfg, "b", None));
1926        assert_eq!("a", next_reviewer(&cfg, "a", None));
1927    }
1928
1929    /// The `hand_back` half. The reviewer committed while reviewing and the
1930    /// author answered without committing, so the head is the reviewer's and
1931    /// keeping it would have it read its own commit.
1932    #[test]
1933    fn a_reviewer_that_wrote_the_head_gives_the_pr_up() {
1934        let cfg = cfg_with(true, false);
1935        assert_eq!("a", next_reviewer(&cfg, "b", Some("b")));
1936    }
1937
1938    /// A reviewer that fixes what it finds and then reports nothing blocking
1939    /// approved its own fix, and the rollback takes that fix out again. The
1940    /// head that would merge is not the head that passed.
1941    #[test]
1942    fn a_review_that_wrote_cannot_approve_what_is_left() {
1943        assert!(!approval_stands(&[], true));
1944    }
1945
1946    #[test]
1947    fn a_clean_review_of_an_untouched_branch_approves() {
1948        assert!(approval_stands(&[], false));
1949    }
1950
1951    #[test]
1952    fn a_blocking_finding_never_approves() {
1953        let blocking = vec![finding("blocking", "Broken", "detail", "src/x.rs", true)];
1954        assert!(!approval_stands(&blocking, false));
1955    }
1956
1957    /// Custody is decided on what git says, not on the call returning.
1958    #[test]
1959    fn only_a_moved_head_counts_as_a_commit() {
1960        let before = Snapshot {
1961            head: "abc".into(),
1962            dirty: false,
1963        };
1964        assert!(!Snapshot {
1965            head: "abc".into(),
1966            dirty: true,
1967        }
1968        .landed_over(&before));
1969        assert!(Snapshot {
1970            head: "def".into(),
1971            dirty: false,
1972        }
1973        .landed_over(&before));
1974        // git could not be read, which is not evidence that anything landed.
1975        assert!(!Snapshot {
1976            head: String::new(),
1977            dirty: false,
1978        }
1979        .landed_over(&before));
1980    }
1981
1982    // -- round budget ----------------------------------------------------
1983
1984    /// A fresh PR gets rounds 1 through max_rounds.
1985    #[test]
1986    fn a_fresh_run_starts_at_one() {
1987        assert_eq!((1, 3), round_window(1, 3));
1988        assert_eq!((1, 5), round_window(1, 5));
1989    }
1990
1991    /// The budget is per invocation, not a lifetime cap. Running spar again on
1992    /// a PR that already spent five rounds gives it five more, because a person
1993    /// looked at it and chose to.
1994    #[test]
1995    fn a_resumed_run_gets_a_full_fresh_budget() {
1996        assert_eq!((6, 10), round_window(6, 5));
1997        assert_eq!((11, 13), round_window(11, 3));
1998    }
1999
2000    #[test]
2001    fn a_budget_of_one_is_a_single_round() {
2002        assert_eq!((6, 6), round_window(6, 1));
2003    }
2004
2005    #[test]
2006    fn round_numbers_keep_counting_across_sessions() {
2007        // Three sessions of three rounds each land on 1..3, 4..6, 7..9.
2008        let mut start = 1;
2009        let mut seen = Vec::new();
2010        for _ in 0..3 {
2011            let (first, last) = round_window(start, 3);
2012            seen.push((first, last));
2013            start = last + 1;
2014        }
2015        assert_eq!(vec![(1, 3), (4, 6), (7, 9)], seen);
2016    }
2017
2018    // -- the ledger ------------------------------------------------------
2019
2020    fn ledger_with(title: &str, file: &str) -> Ledger {
2021        let mut ledger = Ledger::new();
2022        ledger.insert(
2023            finding_key(title, file),
2024            LedgerEntry {
2025                title: title.into(),
2026                file: file.into(),
2027                reasoning: "no".into(),
2028                round: 1,
2029                reraised: 0,
2030                outcome: Settled::Refuted,
2031            },
2032        );
2033        ledger
2034    }
2035
2036    #[test]
2037    fn a_point_refuted_and_re_raised_twice_escalates() {
2038        let mut ledger = ledger_with("nit about naming", "a.rs");
2039        let mut state = IssueRun::new(1, "t");
2040        let blocking = vec![finding("blocking", "nit about naming", "d", "a.rs", true)];
2041        assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
2042        assert!(check_relitigation(&mut ledger, &blocking, &mut state));
2043    }
2044
2045    #[test]
2046    fn an_untracked_finding_does_not_escalate() {
2047        let mut state = IssueRun::new(1, "t");
2048        let blocking = vec![finding("blocking", "brand new", "d", "a.rs", true)];
2049        assert!(!check_relitigation(
2050            &mut Ledger::new(),
2051            &blocking,
2052            &mut state
2053        ));
2054    }
2055
2056    /// The key a refutation records has to be the key the next round's finding
2057    /// hashes to. Recording it without the file made the guard dead code for
2058    /// every finding that named one, which is nearly all of them.
2059    #[test]
2060    fn a_refutation_lands_on_the_key_the_next_round_will_look_up() {
2061        let blocking = vec![finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
2062        let recorded = finding_key(&blocking[0].title, &blocking[0].file);
2063
2064        let matched = matching_finding(&blocking, "unbounded loop!").expect("should match");
2065        assert_eq!(recorded, finding_key("unbounded loop!", &matched.file));
2066    }
2067
2068    /// `matching_finding` ignores hyphens, dots, slashes, and underscores;
2069    /// `finding_key` keeps them. A disposition that differs only in those
2070    /// characters therefore matches its finding while hashing to a different
2071    /// key, so recording the author's wording made the guard track nothing.
2072    #[test]
2073    fn the_ledger_key_uses_the_reviewers_wording_not_the_authors() {
2074        let findings = vec![finding(
2075            "blocking",
2076            "Panic on multi-byte input",
2077            "d",
2078            "src/style.rs",
2079            true,
2080        )];
2081        let reworded = "Panic on multibyte input";
2082
2083        let source = matching_finding(&findings, reworded).expect("still matches");
2084        assert_ne!(
2085            finding_key(reworded, &source.file),
2086            finding_key(&source.title, &source.file),
2087            "the two spellings must genuinely hash apart, or this test proves nothing"
2088        );
2089
2090        // What apply_dispositions records, and what the next round looks up.
2091        let recorded = finding_key(&source.title, &source.file);
2092        let looked_up = finding_key(&findings[0].title, &findings[0].file);
2093        assert_eq!(recorded, looked_up);
2094    }
2095
2096    #[test]
2097    fn a_disposition_matches_its_finding_despite_wording_noise() {
2098        let findings = vec![finding(
2099            "blocking",
2100            "Unbounded loop!",
2101            "d",
2102            "src/x.rs",
2103            true,
2104        )];
2105        assert!(matching_finding(&findings, "unbounded loop").is_some());
2106        assert!(matching_finding(&findings, "something else").is_none());
2107    }
2108
2109    #[test]
2110    fn the_settled_block_is_empty_when_nothing_is_settled() {
2111        assert_eq!("", settled_block(&Ledger::new()));
2112    }
2113
2114    #[test]
2115    fn the_settled_block_names_each_refutation() {
2116        let block = settled_block(&ledger_with("a point", "x.rs"));
2117        assert!(block.contains("a point"));
2118        assert!(block.contains("settled"));
2119    }
2120
2121    /// A point the author moved to its own issue is done with on this branch.
2122    /// Leaving it out of the block let the reviewer that keeps the PR raise it
2123    /// again every round until the budget ran out.
2124    #[test]
2125    fn a_filed_point_is_settled_too() {
2126        let mut ledger = ledger_with("out of scope", "x.rs");
2127        for entry in ledger.values_mut() {
2128            entry.outcome = Settled::Filed;
2129            entry.reasoning = "Tracked in #9.".into();
2130        }
2131        let block = settled_block(&ledger);
2132        assert!(block.contains("out of scope"));
2133        assert!(block.contains("#9"));
2134    }
2135
2136    /// The author answers the point again every round it is re-raised, so
2137    /// recording the answer must not wipe the count that ends the argument.
2138    #[test]
2139    fn answering_a_point_again_keeps_its_re_raise_count() {
2140        let mut ledger = ledger_with("a point", "x.rs");
2141        let entry = ledger.values().next().unwrap().clone();
2142        let key = finding_key("a point", "x.rs");
2143        let mut state = IssueRun::new(1, "t");
2144        let blocking = vec![finding("blocking", "a point", "d", "x.rs", true)];
2145
2146        assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
2147        settle(&mut ledger, key, entry);
2148        assert!(check_relitigation(&mut ledger, &blocking, &mut state));
2149    }
2150
2151    // -- brevity ---------------------------------------------------------
2152
2153    #[test]
2154    /// No agent name, no round number, and no count of things listed below.
2155    /// The reader wants the review, not an account of who produced it.
2156    fn a_clean_review_is_just_the_verdict() {
2157        let text = review_comment("codex", 1, &review("Looks correct.", vec![]), &style());
2158        assert_eq!("Looks correct.", text);
2159    }
2160
2161    #[test]
2162    fn a_review_leads_with_the_counts() {
2163        let text = review_comment(
2164            "codex",
2165            2,
2166            &review(
2167                "One real problem.",
2168                vec![
2169                    finding(
2170                        "blocking",
2171                        "Loop never terminates",
2172                        "Confirmed by running it.",
2173                        "src/a.rs",
2174                        true,
2175                    ),
2176                    finding("non-blocking", "Name is vague", "d", "src/b.rs", true),
2177                    finding("nit", "Log wording", "d", "", true),
2178                ],
2179            ),
2180            &style(),
2181        );
2182        assert!(text.starts_with("One real problem."), "{text}");
2183        assert!(!text.contains("codex"), "no agent name: {text}");
2184        assert!(!text.contains("round 2"), "no round number: {text}");
2185    }
2186
2187    /// Only blocking findings carry their detail into the thread. Everything
2188    /// else is filed, and the detail belongs on the issue.
2189    #[test]
2190    fn only_blocking_findings_carry_their_detail() {
2191        let text = review_comment(
2192            "codex",
2193            1,
2194            &review(
2195                "s",
2196                vec![
2197                    finding("blocking", "Loop", "BLOCKING DETAIL", "a.rs", true),
2198                    finding("non-blocking", "Name", "NONBLOCKING DETAIL", "b.rs", true),
2199                ],
2200            ),
2201            &style(),
2202        );
2203        assert!(text.contains("BLOCKING DETAIL"), "{text}");
2204        assert!(!text.contains("NONBLOCKING DETAIL"), "{text}");
2205    }
2206
2207    #[test]
2208    /// A finding's explanation is what the author acts on. Cutting it to save
2209    /// characters leaves them nothing to act on and saves nothing worth having.
2210    fn a_thorough_explanation_reaches_the_author_intact() {
2211        let detail = "Reproduced by running the 429 test with max_attempts unset. ".repeat(8);
2212        let text = review_comment(
2213            "codex",
2214            1,
2215            &review(
2216                "One problem.",
2217                vec![finding("blocking", "T", &detail, "a.rs", true)],
2218            ),
2219            &style(),
2220        );
2221        assert!(
2222            text.contains(detail.trim()),
2223            "the explanation was cut:\n{text}"
2224        );
2225    }
2226
2227    /// A runaway is still bounded, just nowhere near tightly.
2228    #[test]
2229    fn a_runaway_model_is_still_bounded() {
2230        let long = "filler words. ".repeat(20_000);
2231        let text = review_comment(
2232            "codex",
2233            1,
2234            &review(&long, vec![finding("blocking", "T", &long, "a.rs", true)]),
2235            &style(),
2236        );
2237        assert!(
2238            text.len() < 30_000,
2239            "review comment was {} chars",
2240            text.len()
2241        );
2242    }
2243
2244    #[test]
2245    fn a_general_finding_has_no_empty_parenthesis() {
2246        let text = review_comment(
2247            "codex",
2248            1,
2249            &review("s", vec![finding("blocking", "Something", "d", "", true)]),
2250            &style(),
2251        );
2252        assert!(!text.contains("()"), "{text}");
2253        assert!(!text.contains("(general)"), "{text}");
2254    }
2255
2256    #[test]
2257    fn out_of_scope_findings_are_counted_separately() {
2258        let text = review_comment(
2259            "codex",
2260            1,
2261            &review(
2262                "s",
2263                vec![finding("blocking", "Old bug", "d", "a.rs", false)],
2264            ),
2265            &style(),
2266        );
2267        assert!(text.contains("out of scope"), "{text}");
2268        assert!(text.contains("Old bug"), "{text}");
2269    }
2270
2271    #[test]
2272    fn a_disposition_comment_leads_with_counts_and_keeps_refutations() {
2273        let response = ResponseDoc {
2274            summary: "Two of three were right.".into(),
2275            dispositions: vec![],
2276        };
2277        let text = disposition_comment(
2278            "claude",
2279            &response,
2280            &["Fixed thing".to_string()],
2281            &["Wrong thing. Because the caller already checks.".to_string()],
2282            &[],
2283            &style(),
2284        )
2285        .unwrap();
2286        assert!(text.starts_with("Two of three were right."), "{text}");
2287        assert!(!text.contains("claude"), "no agent name: {text}");
2288        assert!(
2289            text.contains("Because the caller already checks."),
2290            "{text}"
2291        );
2292    }
2293
2294    #[test]
2295    fn an_empty_disposition_comment_is_not_posted() {
2296        let response = ResponseDoc {
2297            summary: "s".into(),
2298            dispositions: vec![],
2299        };
2300        assert!(disposition_comment("claude", &response, &[], &[], &[], &style()).is_none());
2301    }
2302
2303    /// Both, not either. The link is how an agent that can reach the network
2304    /// reads the discussion spar does not fetch, and the body is what the one
2305    /// that cannot works from: codex runs with no network, so a link alone
2306    /// would leave it building from the title.
2307    #[test]
2308    fn the_implementor_is_given_the_link_and_the_body() {
2309        let prompt = implement_prompt(
2310            42,
2311            "Retry a 429",
2312            "https://github.com/o/r/issues/42",
2313            "A rate limited response was treated as fatal.",
2314        );
2315        assert!(
2316            prompt.contains("https://github.com/o/r/issues/42"),
2317            "{prompt}"
2318        );
2319        assert!(
2320            prompt.contains("A rate limited response was treated as fatal."),
2321            "{prompt}"
2322        );
2323        assert!(prompt.contains("#42"), "{prompt}");
2324        assert!(prompt.contains("Retry a 429"), "{prompt}");
2325        // Nothing left unsubstituted.
2326        assert!(!prompt.contains('{'), "{prompt}");
2327    }
2328
2329    /// An agent that cannot reach the link is told what it is missing, so it
2330    /// works from the body rather than assuming the body is everything.
2331    #[test]
2332    fn the_prompt_says_the_discussion_is_not_included() {
2333        let prompt = implement_prompt(1, "t", "u", "b");
2334        // Flattened, so the assertion does not turn on where the prompt wraps.
2335        let lower = prompt
2336            .split_whitespace()
2337            .collect::<Vec<_>>()
2338            .join(" ")
2339            .to_lowercase();
2340        assert!(
2341            lower.contains("discussion since is not included"),
2342            "{prompt}"
2343        );
2344        assert!(lower.contains("cannot reach the network"), "{prompt}");
2345    }
2346
2347    /// A fully reported implementation, for the body tests.
2348    fn worked() -> Implementation {
2349        Implementation {
2350            summary: "Retry a 429 instead of failing the run.".into(),
2351            problem: "A rate limited response was treated as fatal, so one throttled call ended \
2352                      a run that had hours of work left in it."
2353                .into(),
2354            changes: vec![
2355                "`send` retries a 429 with the delay the header asks for".into(),
2356                "the retry budget is bounded, so a permanent 429 still ends".into(),
2357            ],
2358            testing: vec![
2359                "`cargo test retries_a_429`".into(),
2360                "point it at a throttled endpoint and watch it finish".into(),
2361            ],
2362            ..Implementation::default()
2363        }
2364    }
2365
2366    #[test]
2367    /// GitHub renders the file count and the plus and minus figures in the
2368    /// header, immediately above whatever spar writes, so neither is here.
2369    fn a_pr_body_is_what_it_closes_and_what_changed() {
2370        let body = pr_body(42, &worked(), &style());
2371        assert_eq!(
2372            "Closes #42\n\n\
2373             Retry a 429 instead of failing the run.\n\n\
2374             A rate limited response was treated as fatal, so one throttled call \
2375             ended a run that had hours of work left in it.\n\n\
2376             ## What changed\n\n\
2377             - `send` retries a 429 with the delay the header asks for\n\
2378             - the retry budget is bounded, so a permanent 429 still ends\n\n\
2379             ## How to test\n\n\
2380             - `cargo test retries_a_429`\n\
2381             - point it at a throttled endpoint and watch it finish",
2382            body
2383        );
2384    }
2385
2386    /// The sections are optional and the lead is not. A one line fix should
2387    /// read as one, not as a form with most of it left blank.
2388    #[test]
2389    fn a_body_with_nothing_to_list_carries_no_empty_headings() {
2390        let work = Implementation {
2391            summary: "Retry a 429 instead of failing the run.".into(),
2392            ..Implementation::default()
2393        };
2394        assert_eq!(
2395            "Closes #42\n\nRetry a 429 instead of failing the run.",
2396            pr_body(42, &work, &style())
2397        );
2398    }
2399
2400    #[test]
2401    fn a_pr_body_survives_an_implementor_that_said_nothing() {
2402        assert_eq!(
2403            "Closes #7",
2404            pr_body(7, &Implementation::default(), &style())
2405        );
2406    }
2407
2408    /// Blank entries are the model's, not the reader's problem. A heading whose
2409    /// only bullet was an empty string used to be possible.
2410    #[test]
2411    fn blank_list_entries_do_not_earn_a_heading() {
2412        let work = Implementation {
2413            summary: "Did a thing.".into(),
2414            changes: vec![String::new(), "   ".into()],
2415            ..Implementation::default()
2416        };
2417        let body = pr_body(42, &work, &style());
2418        assert!(!body.contains("What changed"), "{body}");
2419    }
2420
2421    #[test]
2422    fn notes_appear_only_when_there_is_something_to_note() {
2423        let mut work = worked();
2424        assert!(!pr_body(42, &work, &style()).contains("## Notes"));
2425        work.notes = Some("The retry is not applied to streaming calls.".into());
2426        let body = pr_body(42, &work, &style());
2427        assert!(body.contains("## Notes"), "{body}");
2428        assert!(body.contains("streaming calls"), "{body}");
2429    }
2430
2431    /// An issue that produced no commits is told so. Never the summary, which
2432    /// describes a change that is not in the branch.
2433    #[test]
2434    fn declining_posts_the_reason_and_not_the_summary() {
2435        let work = Implementation {
2436            not_worth_doing: true,
2437            reason: "Already fixed in 1.2, and the report predates it.".into(),
2438            summary: "Nothing to do.".into(),
2439            ..Implementation::default()
2440        };
2441        assert_eq!(
2442            "Already fixed in 1.2, and the report predates it.",
2443            no_pr_note(&work, &style())
2444        );
2445    }
2446
2447    #[test]
2448    fn reporting_work_and_committing_none_says_that_rather_than_the_summary() {
2449        let work = Implementation {
2450            summary: "Retry a 429 instead of failing the run.".into(),
2451            ..Implementation::default()
2452        };
2453        let note = no_pr_note(&work, &style());
2454        assert_eq!(
2455            "Nothing was committed, so there is nothing to review.",
2456            note
2457        );
2458    }
2459
2460    #[test]
2461    fn declining_without_a_reason_still_says_something() {
2462        let work = Implementation {
2463            not_worth_doing: true,
2464            ..Implementation::default()
2465        };
2466        assert!(no_pr_note(&work, &style()).contains("no reason given"));
2467    }
2468
2469    #[test]
2470    fn a_skip_comment_is_only_the_reasoning() {
2471        let item = SkippedItem {
2472            issue: 3,
2473            title: "t".into(),
2474            tracker: false,
2475            reasons: [
2476                ("claude".to_string(), "Already fixed in 1.2.".to_string()),
2477                ("codex".to_string(), "Duplicate of #2.".to_string()),
2478            ]
2479            .into_iter()
2480            .collect(),
2481        };
2482        let text = skip_comment(&item, &style());
2483        assert!(text.contains("Already fixed in 1.2."), "{text}");
2484        assert!(text.contains("Duplicate of #2."), "{text}");
2485        assert!(
2486            !text.contains("claude") && !text.contains("codex"),
2487            "{text}"
2488        );
2489        assert!(!text.to_lowercase().contains("not scheduled"), "{text}");
2490        assert!(text.lines().count() <= 3, "{text}");
2491    }
2492
2493    #[test]
2494    fn findings_for_a_model_keep_full_detail() {
2495        let long = "x".repeat(2000);
2496        let text = findings_for_prompt(&[finding("blocking", "T", &long, "a.rs", true)]);
2497        assert!(
2498            text.contains(&long),
2499            "a model needs the whole finding, only humans need brevity"
2500        );
2501    }
2502
2503    #[test]
2504    fn findings_for_a_model_are_never_empty() {
2505        assert_eq!("(none)", findings_for_prompt(&[]));
2506    }
2507}
2508
2509#[cfg(test)]
2510mod outcome_tests {
2511    use super::*;
2512    use crate::model::{Dispute, Severity};
2513
2514    fn style() -> Style {
2515        Style::default()
2516    }
2517
2518    fn state_with(disputes: Vec<(&str, &str)>, filed: Vec<&str>) -> IssueRun {
2519        let mut s = IssueRun::new(482, "t");
2520        s.disputes = disputes
2521            .into_iter()
2522            .map(|(title, reasoning)| Dispute {
2523                title: title.into(),
2524                reasoning: reasoning.into(),
2525            })
2526            .collect();
2527        s.filed = filed.into_iter().map(String::from).collect();
2528        s
2529    }
2530
2531    fn finding(title: &str, file: &str) -> Finding {
2532        Finding {
2533            severity: Severity::Blocking,
2534            title: title.into(),
2535            detail: "d".into(),
2536            file: file.into(),
2537            in_scope: true,
2538            ..Default::default()
2539        }
2540    }
2541
2542    /// The absence of objections is the message. A PR that reviewed cleanly and
2543    /// filed nothing should leave no trace in the thread at all.
2544    #[test]
2545    fn a_clean_approval_says_nothing() {
2546        let state = state_with(vec![], vec![]);
2547        assert!(outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).is_none());
2548    }
2549
2550    #[test]
2551    fn an_approval_that_filed_follow_ups_links_them() {
2552        let state = state_with(
2553            vec![],
2554            vec![
2555                "https://github.com/you/thing/issues/485",
2556                "https://github.com/you/thing/issues/486",
2557            ],
2558        );
2559        let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
2560        assert!(text.contains("Filed separately: #485, #486"), "{text}");
2561    }
2562
2563    /// The real PR ended with "5 fixed" followed by "no convergence", which
2564    /// reads as a contradiction. What a maintainer needs is that the fixes went
2565    /// in and nobody checked them.
2566    #[test]
2567    fn running_out_of_rounds_says_what_that_means_for_the_reader() {
2568        let state = state_with(vec![], vec![]);
2569        let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
2570        assert!(text.contains("has not been reviewed"), "{text}");
2571        assert!(
2572            !text.to_lowercase().contains("round 3"),
2573            "no round numbers: {text}"
2574        );
2575        assert!(!text.to_lowercase().contains("convergence"), "{text}");
2576    }
2577
2578    #[test]
2579    fn a_deadlock_names_the_point_they_could_not_settle() {
2580        let state = state_with(vec![], vec![]);
2581        let points = [finding("Retry loop never terminates", "src/net.rs:88")];
2582        let text = outcome_comment(
2583            &state,
2584            &Ledger::new(),
2585            &Ending::Deadlocked(&points),
2586            &style(),
2587        )
2588        .unwrap();
2589        assert!(
2590            text.contains("Retry loop never terminates (src/net.rs:88)"),
2591            "{text}"
2592        );
2593        assert!(text.contains("could not settle"), "{text}");
2594    }
2595
2596    /// The diff records what was fixed. Nothing records what was argued down.
2597    #[test]
2598    fn refutations_survive_because_nothing_else_carries_them() {
2599        let state = state_with(
2600            vec![(
2601                "Error is swallowed",
2602                "the caller already validates the file",
2603            )],
2604            vec![],
2605        );
2606        let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
2607        assert!(text.contains("Raised and refuted:"), "{text}");
2608        assert!(
2609            text.contains("The caller already validates the file"),
2610            "{text}"
2611        );
2612    }
2613
2614    #[test]
2615    fn no_agent_names_counts_or_round_numbers_reach_the_thread() {
2616        let state = state_with(
2617            vec![("A point", "a reason")],
2618            vec!["https://github.com/you/thing/issues/485"],
2619        );
2620        for ending in [Ending::Approved, Ending::OutOfRounds] {
2621            let text = outcome_comment(&state, &Ledger::new(), &ending, &style()).unwrap();
2622            let lower = text.to_lowercase();
2623            for banned in ["claude", "codex", "blocking,", "nit,", " fixed."] {
2624                assert!(
2625                    !lower.contains(banned),
2626                    "{banned:?} leaked into the thread:\n{text}"
2627                );
2628            }
2629            // "the last round of fixes" is prose. "round 3" is narration.
2630            for n in 1..9 {
2631                assert!(
2632                    !lower.contains(&format!("round {n}")),
2633                    "a round number leaked into the thread:\n{text}"
2634                );
2635            }
2636        }
2637    }
2638
2639    #[test]
2640    /// A refutation is an argument, and an argument that stops mid clause is
2641    /// not one. Bounded, but with room to make the case.
2642    fn a_refutation_is_allowed_to_make_its_case() {
2643        let reasoning = "The caller validates against the schema first. \
2644                         The discarded error is therefore unreachable in practice. ";
2645        let state = state_with(
2646            vec![("A point", &reasoning.repeat(6))],
2647            vec!["https://github.com/you/thing/issues/485"],
2648        );
2649        let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
2650        assert!(
2651            !text.contains("..."),
2652            "nothing was cut mid thought:\n{text}"
2653        );
2654        assert!(text.len() < 4000, "{} chars", text.len());
2655    }
2656
2657    #[test]
2658    fn a_url_that_is_not_an_issue_link_is_left_alone() {
2659        assert_eq!(
2660            "#485",
2661            as_reference("https://github.com/you/thing/issues/485")
2662        );
2663        assert_eq!("note: something", as_reference("note: something"));
2664    }
2665}
2666
2667#[cfg(test)]
2668mod filed_reference_tests {
2669    use super::*;
2670
2671    #[test]
2672    fn an_issue_url_yields_its_number() {
2673        assert_eq!(
2674            Some(485),
2675            filed_issue_number("https://github.com/you/thing/issues/485")
2676        );
2677    }
2678
2679    /// Local mode records a note rather than a URL, and a run with
2680    /// followups = "local" must not try to absorb it as an issue.
2681    #[test]
2682    fn a_local_note_yields_nothing() {
2683        assert_eq!(None, filed_issue_number("note: Retry is unbounded"));
2684        assert_eq!(None, filed_issue_number(""));
2685        assert_eq!(
2686            None,
2687            filed_issue_number("https://github.com/you/thing/issues/")
2688        );
2689    }
2690}
2691
2692#[cfg(test)]
2693mod followup_restraint_tests {
2694    use super::*;
2695    use crate::model::Severity;
2696
2697    fn cfg_with(followups: Followups, non_blocking: bool, nits: bool, cap: usize) -> Config {
2698        let mut cfg =
2699            crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
2700                .unwrap();
2701        cfg.loop_cfg.followups = followups;
2702        cfg.loop_cfg.file_non_blocking = non_blocking;
2703        cfg.loop_cfg.file_nits = nits;
2704        cfg.loop_cfg.max_followups = cap;
2705        cfg
2706    }
2707
2708    fn finding(severity: Severity, title: &str, in_scope: bool) -> Finding {
2709        Finding {
2710            severity,
2711            title: title.into(),
2712            detail: "d".into(),
2713            file: "a.rs".into(),
2714            in_scope,
2715            ..Default::default()
2716        }
2717    }
2718
2719    /// The defaults are what let one issue spawn ten, which spawned more. A
2720    /// thorough reviewer always finds improvements; not gating a merge is not
2721    /// the same as deserving somebody's triage queue.
2722    #[test]
2723    fn a_non_blocking_finding_is_not_a_tracker_item_by_default() {
2724        let cfg = cfg_with(Followups::Issues, false, false, 5);
2725        assert!(!cfg.loop_cfg.file_non_blocking);
2726        assert!(!cfg.loop_cfg.file_nits);
2727    }
2728
2729    #[test]
2730    fn follow_ups_stay_off_the_tracker_by_default() {
2731        let cfg =
2732            crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
2733                .unwrap();
2734        assert_eq!(
2735            Followups::Local,
2736            cfg.loop_cfg.followups,
2737            "the tracker is somebody's queue; the default must not write to it"
2738        );
2739        assert_eq!(5, cfg.loop_cfg.max_followups);
2740    }
2741
2742    /// Which severities survive the filter, at the defaults and when opened up.
2743    #[test]
2744    fn only_out_of_scope_defects_qualify_at_the_defaults() {
2745        let cfg = cfg_with(Followups::Issues, false, false, 5);
2746        let qualifies = |f: &Finding| match f.severity {
2747            Severity::NonBlocking => cfg.loop_cfg.file_non_blocking && f.in_scope,
2748            Severity::Nit => cfg.loop_cfg.file_nits && f.in_scope,
2749            Severity::Blocking => false,
2750        } || !f.in_scope;
2751
2752        assert!(qualifies(&finding(
2753            Severity::Blocking,
2754            "pre-existing",
2755            false
2756        )));
2757        assert!(!qualifies(&finding(
2758            Severity::NonBlocking,
2759            "improvement",
2760            true
2761        )));
2762        assert!(!qualifies(&finding(Severity::Nit, "taste", true)));
2763        assert!(!qualifies(&finding(
2764            Severity::Blocking,
2765            "fix it here",
2766            true
2767        )));
2768    }
2769
2770    #[test]
2771    fn opening_it_up_lets_non_blocking_findings_through_again() {
2772        let cfg = cfg_with(Followups::Issues, true, false, 5);
2773        assert!(cfg.loop_cfg.file_non_blocking);
2774    }
2775
2776    /// A run that will not stop finding things is stopped, and says so.
2777    #[test]
2778    fn the_cap_is_a_real_backstop() {
2779        let cfg = cfg_with(Followups::Issues, false, false, 3);
2780        let mut state = IssueRun::new(1, "t");
2781        state.filed = (0..3).map(|n| format!("url{n}")).collect();
2782        assert!(state.filed.len() >= cfg.loop_cfg.max_followups);
2783    }
2784
2785    /// The number that matters. Reviewing one issue produced ten follow-ups on
2786    /// a real repository, each of which could be run in turn: mean offspring
2787    /// above one never terminates.
2788    #[test]
2789    fn the_cap_bounds_what_one_run_can_spawn() {
2790        let cfg = cfg_with(Followups::Issues, false, false, 5);
2791        assert!(
2792            cfg.loop_cfg.max_followups <= 5,
2793            "a run that can file ten follow-ups is a branching process"
2794        );
2795    }
2796}
2797
2798/// What the ledger is told about a point the author moved out of the pull
2799/// request. Every case here used to record "filed", including the ones where
2800/// nothing was written anywhere.
2801#[cfg(test)]
2802mod followup_outcome_tests {
2803    use super::*;
2804
2805    const URL: &str = "https://github.com/you/thing/issues/485";
2806
2807    fn entry(recorded: Followup) -> Option<(Settled, String)> {
2808        filed_entry(&recorded, "It predates this branch.")
2809    }
2810
2811    /// The bug. A tracker request or a local write that failed left no
2812    /// follow-up, and the ledger said it had been filed, which is a claim that
2813    /// survives every later round and every resume.
2814    #[test]
2815    fn a_failed_followup_settles_nothing() {
2816        assert_eq!(None, entry(Followup::Failed));
2817    }
2818
2819    #[test]
2820    fn a_recorded_followup_is_filed_and_says_where() {
2821        let (outcome, reasoning) = entry(Followup::Recorded(URL.into())).unwrap();
2822        assert_eq!(Settled::Filed, outcome);
2823        assert!(
2824            reasoning.contains("It predates this branch."),
2825            "{reasoning}"
2826        );
2827        assert!(reasoning.contains("#485"), "{reasoning}");
2828    }
2829
2830    /// A closed issue already carries the point, so raising it again is waste.
2831    /// It is still not something to hand anybody as work.
2832    #[test]
2833    fn a_closed_issue_covering_the_point_settles_it_without_offering_work() {
2834        let recorded = Followup::from(Filed::AlreadyClosed(9, URL.into()));
2835        assert_eq!(Followup::Covered(URL.into()), recorded);
2836        assert_eq!(
2837            None,
2838            recorded.url(),
2839            "a closed issue is not work to pick up"
2840        );
2841
2842        let (outcome, reasoning) = entry(recorded).unwrap();
2843        assert_eq!(Settled::Filed, outcome);
2844        assert!(reasoning.contains("#485"), "{reasoning}");
2845    }
2846
2847    /// An open issue that already covers the point is worth linking from the
2848    /// pull request, and worth counting against the cap.
2849    #[test]
2850    fn an_open_issue_that_already_covers_the_point_is_still_a_reference() {
2851        for filed in [
2852            Filed::Opened(9, URL.into()),
2853            Filed::AddedTo(9, URL.into()),
2854            Filed::Covered(9, URL.into()),
2855        ] {
2856            assert_eq!(Some(URL), Followup::from(filed).url());
2857        }
2858    }
2859
2860    /// Configuration, not failure: retrying it every round would spend the
2861    /// budget on a write that is never going to happen. The entry has to be
2862    /// honest about it, because nothing else holds the point.
2863    #[test]
2864    fn a_dropped_followup_is_settled_but_never_reported_as_filed() {
2865        let (outcome, reasoning) = entry(Followup::Dropped("follow-ups are off")).unwrap();
2866        assert_eq!(Settled::Dropped, outcome);
2867        assert!(reasoning.contains("follow-ups are off"), "{reasoning}");
2868        assert!(reasoning.contains("Not filed"), "{reasoning}");
2869    }
2870
2871    fn ledger_of(outcome: Settled, reasoning: &str) -> Ledger {
2872        let mut ledger = Ledger::new();
2873        ledger.insert(
2874            finding_key("A pre-existing leak", "src/x.rs"),
2875            LedgerEntry {
2876                title: "A pre-existing leak".into(),
2877                file: "src/x.rs".into(),
2878                reasoning: reasoning.into(),
2879                round: 1,
2880                reraised: 0,
2881                outcome,
2882            },
2883        );
2884        ledger
2885    }
2886
2887    /// The next reviewer is told to leave settled points alone either way, so
2888    /// the wording is all that separates them. Saying "filed" of a point
2889    /// nothing holds is the lie that loses it.
2890    #[test]
2891    fn the_settled_block_tells_a_filed_point_from_a_dropped_one() {
2892        let filed = settled_block(&ledger_of(Settled::Filed, "Tracked in #9."));
2893        assert!(filed.contains("out of scope here, and filed"), "{filed}");
2894
2895        let dropped = settled_block(&ledger_of(Settled::Dropped, "Not filed anywhere: off."));
2896        assert!(
2897            dropped.contains("out of scope here, and not filed"),
2898            "{dropped}"
2899        );
2900        assert!(dropped.contains("A pre-existing leak"), "{dropped}");
2901    }
2902
2903    /// A deadlock goes to a person, and the first thing they do is look for the
2904    /// issue the comment says exists.
2905    #[test]
2906    fn a_deadlocked_point_that_was_never_filed_does_not_claim_to_be() {
2907        let points = [Finding {
2908            severity: Severity::Blocking,
2909            title: "A pre-existing leak".into(),
2910            detail: "d".into(),
2911            file: "src/x.rs".into(),
2912            in_scope: false,
2913            ..Default::default()
2914        }];
2915        let text = outcome_comment(
2916            &IssueRun::new(1, "t"),
2917            &ledger_of(Settled::Dropped, "Not filed anywhere: follow-ups are off."),
2918            &Ending::Deadlocked(&points),
2919            &Style::default(),
2920        )
2921        .unwrap();
2922        assert!(text.contains("not filed"), "{text}");
2923        assert!(!text.contains("Filed as out of scope"), "{text}");
2924    }
2925}
2926
2927#[cfg(test)]
2928mod issue_report_tests {
2929    use super::*;
2930    use crate::model::Severity;
2931
2932    /// Shaped after a bug report written by hand that reads the way one should:
2933    /// what is wrong, how to see it, what it costs, what it should do instead.
2934    fn reported() -> Finding {
2935        Finding {
2936            severity: Severity::Blocking,
2937            title: "sendPaymentAsync bypasses drain mode and spending limits".into(),
2938            detail: "The async path skips every admission check payInvoice applies.".into(),
2939            file: "src/node.ts:412".into(),
2940            in_scope: false,
2941            problem: Some(
2942                "`BeignetNode.sendPaymentAsync()` submits a payment directly to the Lightning \
2943                 engine without applying the safeguards used by `payInvoice()`.\n\nThe async path \
2944                 does not:\n\n- call `_checkDraining()`\n- call `_checkSpendLimit()`"
2945                    .into(),
2946            ),
2947            reproduction: Some(
2948                "1. Create a `BeignetNode` with `dailySpendLimitSats: 1`.\n2. Enable drain mode.\n\
2949                 3. Submit a 1,000 sat invoice.\n\nActual result:\n\n- The engine is called.\n\
2950                 - `spentSats` remains 0."
2951                    .into(),
2952            ),
2953            impact: Some(
2954                "An authorized client can submit async payments up to the available outbound \
2955                 liquidity despite the configured limits."
2956                    .into(),
2957            ),
2958            expected: Some(
2959                "- Reject new payments while draining.\n- Enforce the per-payment limit before \
2960                 submission.\n- Cover both paths with regression tests.\n\nThis predates the \
2961                 current branch."
2962                    .into(),
2963            ),
2964        }
2965    }
2966
2967    #[test]
2968    fn a_reported_finding_becomes_a_bug_report() {
2969        let body = issue_report(&reported());
2970        for heading in [
2971            "## Problem",
2972            "## Reproduction",
2973            "## Impact",
2974            "## Expected behavior",
2975        ] {
2976            assert!(body.contains(heading), "missing {heading}:\n{body}");
2977        }
2978        // In the order somebody reads a bug report.
2979        let at = |h: &str| body.find(h).unwrap();
2980        assert!(at("## Problem") < at("## Reproduction"));
2981        assert!(at("## Reproduction") < at("## Impact"));
2982        assert!(at("## Impact") < at("## Expected behavior"));
2983    }
2984
2985    #[test]
2986    fn the_substance_survives_the_outbound_gates() {
2987        let repo_style = Style::default();
2988        let body = crate::style::issue_body(&issue_report(&reported()), &repo_style);
2989        for kept in [
2990            "_checkDraining()",
2991            "Actual result:",
2992            "outbound liquidity",
2993            "regression tests",
2994            "predates the current branch",
2995        ] {
2996            assert!(body.contains(kept), "the gate ate {kept:?}:\n{body}");
2997        }
2998        assert!(!body.contains("..."), "something was cut:\n{body}");
2999    }
3000
3001    /// A finding that was never going to be filed carries none of this, and
3002    /// must not gain empty headings for the sake of a format.
3003    #[test]
3004    fn an_ordinary_finding_is_still_just_its_detail() {
3005        let plain = Finding {
3006            severity: Severity::NonBlocking,
3007            title: "Name is vague".into(),
3008            detail: "The variable could say what it holds.".into(),
3009            file: "a.rs".into(),
3010            in_scope: true,
3011            ..Default::default()
3012        };
3013        assert_eq!(
3014            "The variable could say what it holds.",
3015            issue_report(&plain)
3016        );
3017    }
3018
3019    /// Partial reports are normal: a defect with no useful reproduction should
3020    /// not sprout an empty Reproduction heading.
3021    #[test]
3022    fn only_the_sections_that_were_written_appear() {
3023        let partial = Finding {
3024            problem: Some("The guard is inverted.".into()),
3025            expected: Some("It should reject rather than accept.".into()),
3026            ..reported()
3027        };
3028        let partial = Finding {
3029            reproduction: None,
3030            impact: None,
3031            ..partial
3032        };
3033        let body = issue_report(&partial);
3034        assert!(body.contains("## Problem") && body.contains("## Expected behavior"));
3035        assert!(!body.contains("## Reproduction"), "{body}");
3036        assert!(!body.contains("## Impact"), "{body}");
3037    }
3038
3039    /// The one line the thread shows is not repeated when a section already
3040    /// says it.
3041    #[test]
3042    fn the_summary_line_is_not_printed_twice() {
3043        let echoed = Finding {
3044            detail: "The guard is inverted so it rejects valid input.".into(),
3045            problem: Some("The guard is inverted so it rejects valid input.".into()),
3046            reproduction: None,
3047            impact: None,
3048            expected: None,
3049            ..reported()
3050        };
3051        let body = issue_report(&echoed);
3052        assert_eq!(1, body.matches("The guard is inverted").count(), "{body}");
3053    }
3054}