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.
6//!
7//! Three failure modes are handled explicitly here, because each one breaks a
8//! naive loop:
9//!
10//! - **The nitpick spiral.** Round 6 findings are worse than round 1 findings
11//!   and a loop that counts objections cannot tell. Only `blocking` gates.
12//! - **Re-litigation.** A refuted point re-raised forever never terminates.
13//!   Refutations are hashed into a ledger carried across rounds.
14//! - **Approval drift.** Optimising for "get approved" pressures the author
15//!   into accepting wrong review comments, so refutation is blessed and the
16//!   merge gate is blocking-findings-empty, not reviewer-satisfied.
17
18use std::path::{Path, PathBuf};
19
20use crate::agent::{self, Agent};
21use crate::config::{Config, Followups, PrComments};
22use crate::error::{Result, SparError};
23use crate::jsonx::finding_key;
24use crate::model::{
25    Action, Dispute, Finding, Implementation, Issue, IssueRun, Ledger, LedgerEntry, NextAction,
26    PersistedState, PlanItem, PrView, ResponseDoc, Review, Severity, SkippedItem, Status,
27    STATE_VERSION,
28};
29use crate::repo::Repo;
30use crate::style::{self, Style};
31use crate::{log, logdim, logwarn, schema, spar_err};
32
33// ---------------------------------------------------------------------------
34// Prompts
35// ---------------------------------------------------------------------------
36
37const IMPLEMENT_PROMPT: &str = "\
38Implement GitHub issue #{number} in this repository.
39
40Title: {title}
41
42{body}
43
44Do the work, then commit it on the current branch. Make focused commits with
45clear messages. Do not push, do not open a PR, and do not merge; the harness
46handles that.
47
48Then report it. Your answer becomes the pull request description, and the
49reviewer reads that cold, with nothing but the diff and a link to the issue:
50say what you found wrong, what the change does about it, and how they confirm
51it for themselves. Say what you actually ran, not what could be run.
52
53If after reading the code you conclude this issue should not be implemented,
54make no commits and set not_worth_doing, with the reason.";
55
56const REVIEW_PROMPT: &str = "\
57Review the changes on this branch against `{base}`. They implement issue
58#{number}: {title}
59
60Review thoroughly: correctness, edge cases, error handling, security, and
61whether the change actually resolves the issue. Read surrounding code, do not
62only read the diff.
63
64Label every finding by severity, and be honest about which is which:
65- blocking: the PR should not merge as is. Real defects only.
66- non-blocking: a genuine improvement that need not gate this PR.
67- nit: style or taste.
68
69Confirm anything you label blocking before you label it. Run the code,
70reproduce the failure, or point at the exact line that breaks, and say in the
71detail what you did to confirm it. When you need to run something to check a
72claim, write a scratch file and run that, rather than passing a long program on
73the command line: it is easier to read back, easier to rerun, and less likely to
74be refused by a sandbox or a safety filter part way through your work. An unverified blocking finding is worse than
75one you never raised: it stalls a good PR and teaches the author to stop
76believing you. If you suspect a problem but could not confirm it, say so and
77label it non-blocking.
78
79Set in_scope=false for a real defect that exists, that this PR did not cause, and
80that is worth somebody stopping to fix. Each one becomes a tracked item a
81maintainer has to read and triage, so the bar is a defect and not an observation.
82A thorough reviewer can always find something adjacent to what it is reading;
83that is not a reason to file it. If you are not sure it is worth a maintainer's
84time, leave in_scope true and say your piece in the finding.
85
86Reviewing one issue should not manufacture ten more. If you find yourself with
87several out of scope findings, keep the ones that would bite somebody and drop
88the rest.
89
90Then choose next_action:
91- merge: no blocking findings, the PR is good.
92- fix_myself: there are blocking findings and you will fix them directly.
93- hand_back: there are blocking findings the author should address.
94{settled}";
95
96const FIX_PROMPT: &str = "\
97You reviewed this branch and chose to fix the blocking findings yourself.
98Implement those fixes now and commit them.
99
100Your findings:
101{findings}
102
103Commit your changes. Do not push, do not merge.";
104
105const RESPOND_PROMPT: &str = "\
106Here is a review of your PR for issue #{number}.
107
108{findings}
109
110For each point, choose exactly one disposition:
111- fixed: the point is valid and in scope. Fix it and commit.
112- refuted: the point is wrong, or not worth acting on. Explain why. Refuting is
113  a legitimate outcome; do not accept a review comment you believe is incorrect
114  just to get the PR approved.
115- filed_issue: the point is valid but unrelated to this PR. Supply
116  new_issue_title and new_issue_body; the harness files it and skips duplicates.
117
118Copy each finding's title and file across exactly as given, so your answer can
119be matched back to the review.
120
121Commit any fixes. Do not push, do not merge.";
122
123/// A worktree is only worth keeping when a person has to look at it locally.
124/// Anything else strands a checked-out branch that blocks
125/// `gh pr merge --delete-branch`, and since auto_merge is off by default,
126/// keeping it on anything but "merged" leaks one per run.
127fn should_release(cfg: &Config, status: Status) -> bool {
128    if !cfg.loop_cfg.worktrees || cfg.loop_cfg.keep_worktrees {
129        return false;
130    }
131    !matches!(status, Status::Escalated | Status::Error)
132}
133
134// ---------------------------------------------------------------------------
135// One issue, start to finish
136// ---------------------------------------------------------------------------
137
138pub fn run_issue(
139    agents: &[Agent],
140    cfg: &Config,
141    repo: &Repo,
142    item: &PlanItem,
143    issue: &Issue,
144    ledger: &mut Ledger,
145) -> IssueRun {
146    // Continue an existing PR rather than implementing over the top of it.
147    //
148    // Without this, a second `spar run 42` deletes the local branch, rebuilds
149    // it from the base, implements from scratch, and force pushes. The lease
150    // holds because the remote tracking ref survives the local branch being
151    // deleted, so the push succeeds and the previous round's work is gone from
152    // the PR with nothing to say it ever existed.
153    if let Some(existing) = repo.open_pr_for_issue(item.issue) {
154        log!(
155            "#{}: {} is already open, continuing it instead of implementing again",
156            item.issue,
157            existing.url
158        );
159        return resume_pr(agents, cfg, repo, existing.number, None);
160    }
161
162    let mut state = IssueRun::new(item.issue, item.title.clone());
163    let base = cfg.base_branch().to_string();
164
165    let prepared = if cfg.loop_cfg.worktrees {
166        repo.worktree_add(item.issue, &base)
167    } else {
168        let branch = repo.branch_for_issue(item.issue);
169        let start = format!("origin/{base}");
170        repo.git(&["checkout", "-B", &branch, &start])
171            .map(|_| (repo.root().to_path_buf(), branch))
172    };
173
174    let (work_dir, branch) = match prepared {
175        Ok(pair) => pair,
176        Err(e) => {
177            state.status = Status::Error;
178            state.notes.push(e.to_string());
179            log!("#{} failed: {e}", item.issue);
180            return state;
181        }
182    };
183
184    let outcome = implement_and_review(
185        agents, cfg, repo, item, issue, ledger, &mut state, &work_dir, &branch,
186    );
187    if let Err(e) = outcome {
188        state.status = Status::Error;
189        state.notes.push(e.to_string());
190        log!("#{} failed: {e}", item.issue);
191    }
192
193    if should_release(cfg, state.status) {
194        repo.worktree_remove(item.issue);
195    }
196    state
197}
198
199#[allow(clippy::too_many_arguments)]
200fn implement_and_review(
201    agents: &[Agent],
202    cfg: &Config,
203    repo: &Repo,
204    item: &PlanItem,
205    issue: &Issue,
206    ledger: &mut Ledger,
207    state: &mut IssueRun,
208    work_dir: &Path,
209    branch: &str,
210) -> Result<()> {
211    let number = item.issue;
212    let holder = cfg.first_implementor.clone();
213    let implementor = agent::find(agents, &holder)?;
214    let base = cfg.base_branch().to_string();
215
216    log!("#{number}: {holder} implementing");
217    let body: String = issue.body_text().trim().chars().take(6000).collect();
218    let prompt = IMPLEMENT_PROMPT
219        .replace("{number}", &number.to_string())
220        .replace("{title}", &item.title)
221        .replace("{body}", &body);
222    let mut work: Implementation = implementor.ask_json(
223        &prompt,
224        &schema::implementation(),
225        work_dir,
226        cfg.effort_for_round(&implementor.spec, 1).as_deref(),
227    )?;
228
229    if work.not_worth_doing || !repo.has_changes(work_dir, &base) {
230        state.status = Status::Abandoned;
231        let reason = no_pr_note(&work, &repo.style);
232        state.notes.push(reason.clone());
233        if let Err(e) = repo.comment_issue(number, &reason) {
234            logdim!("could not comment on #{number}: {e}");
235        }
236        return Ok(());
237    }
238
239    // A body that leads with nothing is a body nobody reads past. The issue
240    // title is a poor substitute for a sentence about the change, and a better
241    // one than a blank first line.
242    if work.summary.trim().is_empty() {
243        work.summary = item.title.clone();
244    }
245
246    repo.rewrite_commits_if_needed(work_dir, &base)?;
247    repo.push(work_dir, branch)?;
248
249    let pr = match repo.pr_for_branch(branch) {
250        Some(existing) => existing,
251        None => {
252            let body = pr_body(number, &work, &repo.style);
253            repo.create_pr(
254                work_dir,
255                branch,
256                &base,
257                &format!("{} (#{number})", item.title),
258                &body,
259            )?
260        }
261    };
262    state.pr = Some(pr.url.clone());
263    log!("#{number}: PR {}", pr.url);
264
265    let ctx = LoopCtx {
266        work_dir: work_dir.to_path_buf(),
267        branch: branch.to_string(),
268        pr_number: pr.number,
269        label: format!("#{number}"),
270        subject: number,
271        title: item.title.clone(),
272        start_round: 1,
273        holder: cfg.other(&holder),
274        release: Release::Issue(number),
275    };
276    review_loop(agents, cfg, repo, &ctx, state, ledger)
277}
278
279// ---------------------------------------------------------------------------
280// Resuming an existing PR
281// ---------------------------------------------------------------------------
282
283/// Pick up an existing PR and continue the loop.
284///
285/// The PR need not have been created by spar. Anything with a branch and a diff
286/// can be reviewed, including work a person or a different tool started, which
287/// is also the cheapest way to adopt spar: no agent writes a feature from
288/// scratch, it only reviews what already exists.
289pub fn resume_pr(
290    agents: &[Agent],
291    cfg: &Config,
292    repo: &Repo,
293    pr_number: i64,
294    holder_override: Option<&str>,
295) -> IssueRun {
296    let failed = |e: SparError| {
297        log!("PR #{pr_number} failed: {e}");
298        let mut state = IssueRun::new(pr_number, format!("PR #{pr_number}"));
299        state.status = Status::Error;
300        state.notes.push(e.to_string());
301        state
302    };
303
304    let pr = match repo.pr_view(pr_number) {
305        Ok(pr) => pr,
306        Err(e) => return failed(e),
307    };
308
309    // A pull request from a fork cannot be pushed to, so the loop that fixes
310    // things cannot run on it. Reviewing it is still the useful thing, and it
311    // is what a maintainer wants from an outside contribution anyway, so do
312    // that rather than refusing.
313    if pr.is_cross_repository {
314        log!("PR #{pr_number} comes from a fork, reviewing it without changing it");
315        return crate::review_only::review_pr(agents, cfg, repo, pr_number, false);
316    }
317
318    match resume_inner(agents, cfg, repo, pr, holder_override) {
319        Ok(state) => state,
320        Err(e) => failed(e),
321    }
322}
323
324fn resume_inner(
325    agents: &[Agent],
326    cfg: &Config,
327    repo: &Repo,
328    pr: PrView,
329    holder_override: Option<&str>,
330) -> Result<IssueRun> {
331    let pr_number = pr.number;
332    if !pr.is_open() {
333        return Err(spar_err!("PR #{pr_number} is {}", pr.state.to_lowercase()));
334    }
335
336    let subject = pr
337        .closing_issues_references
338        .first()
339        .map(|r| r.number)
340        .unwrap_or(pr_number);
341
342    let saved = repo.read_state(&pr);
343    let mut ledger: Ledger = saved.as_ref().map(|s| s.ledger.clone()).unwrap_or_default();
344    let start_round = saved.as_ref().map(|s| s.round + 1).unwrap_or(1);
345
346    let default_holder = cfg.other(&cfg.first_implementor);
347    let mut holder = holder_override
348        .map(str::to_string)
349        .or_else(|| saved.as_ref().map(|s| s.next_actor.clone()))
350        .unwrap_or_else(|| default_holder.clone());
351    if !cfg.has_agent(&holder) {
352        log!("state named unknown agent '{holder}', using {default_holder}");
353        holder = default_holder;
354    }
355
356    match &saved {
357        Some(_) => log!(
358            "PR #{pr_number}: resuming at round {start_round}, {} settled point(s), next up {holder}",
359            ledger.len()
360        ),
361        None => log!("PR #{pr_number}: no prior spar state, starting fresh with {holder}"),
362    }
363
364    let mut state = IssueRun::new(subject, pr.title.clone());
365    state.pr = Some(pr.url.clone());
366    if let Some(s) = &saved {
367        state.filed = s.filed.clone();
368    }
369
370    let (work_dir, branch) = repo.worktree_for_pr(&pr)?;
371    let ctx = LoopCtx {
372        work_dir,
373        branch,
374        pr_number,
375        label: format!("PR #{pr_number}"),
376        subject,
377        title: pr.title.clone(),
378        start_round,
379        holder,
380        release: Release::Pr(pr_number),
381    };
382
383    let outcome = review_loop(agents, cfg, repo, &ctx, &mut state, &mut ledger);
384    if let Err(e) = outcome {
385        state.status = Status::Error;
386        state.notes.push(e.to_string());
387        log!("PR #{pr_number} failed: {e}");
388    }
389    if should_release(cfg, state.status) {
390        repo.release_pr_worktree(pr_number);
391    }
392    Ok(state)
393}
394
395// ---------------------------------------------------------------------------
396// The loop
397// ---------------------------------------------------------------------------
398
399#[derive(Debug, Clone, Copy)]
400enum Release {
401    Issue(i64),
402    Pr(i64),
403}
404
405struct LoopCtx {
406    work_dir: PathBuf,
407    branch: String,
408    pr_number: i64,
409    label: String,
410    subject: i64,
411    title: String,
412    start_round: u32,
413    holder: String,
414    release: Release,
415}
416
417impl LoopCtx {
418    fn release(&self, repo: &Repo) {
419        match self.release {
420            Release::Issue(n) => repo.worktree_remove(n),
421            Release::Pr(n) => repo.release_pr_worktree(n),
422        }
423    }
424}
425
426fn review_loop(
427    agents: &[Agent],
428    cfg: &Config,
429    repo: &Repo,
430    ctx: &LoopCtx,
431    state: &mut IssueRun,
432    ledger: &mut Ledger,
433) -> Result<()> {
434    let base = cfg.base_branch().to_string();
435    let mut holder = ctx.holder.clone();
436
437    // `max_rounds` is a budget for this invocation, not a lifetime cap on the
438    // pull request. Running spar again on a PR that already spent its rounds is
439    // a deliberate act by a person who has looked at it, so it gets a fresh
440    // budget rather than an error telling them to raise a number they cannot
441    // see from the outside.
442    let (first, last_allowed) = round_window(ctx.start_round, cfg.loop_cfg.max_rounds);
443    let mut last_round = first.saturating_sub(1);
444
445    for round in first..=last_allowed {
446        last_round = round;
447        state.rounds = round;
448        let reviewer = agent::find(agents, &holder)?;
449        let effort = cfg.effort_for_round(&reviewer.spec, round);
450        log!(
451            "{}: round {round}, {holder} reviewing ({})",
452            ctx.label,
453            effort.as_deref().unwrap_or("default effort")
454        );
455
456        let prompt = REVIEW_PROMPT
457            .replace("{base}", &base)
458            .replace("{number}", &ctx.subject.to_string())
459            .replace("{title}", &ctx.title)
460            .replace("{settled}", &settled_block(ledger));
461        let review: Review = reviewer.review(
462            &base,
463            &prompt,
464            &schema::review(),
465            &ctx.work_dir,
466            effort.as_deref(),
467        )?;
468
469        let blocking: Vec<Finding> = review
470            .findings
471            .iter()
472            .filter(|f| f.blocks())
473            .cloned()
474            .collect();
475
476        if repo.style.pr_comments == PrComments::Rounds {
477            if let Err(e) = repo.comment_pr(
478                ctx.pr_number,
479                &review_comment(&holder, round, &review, &repo.style),
480            ) {
481                logdim!("could not post the review comment: {e}");
482            }
483        }
484
485        // Filed every round, not only on approval: a run that escalates or runs
486        // out of rounds would otherwise drop these on the floor. Filing
487        // deduplicates by title, so repeats across rounds are free.
488        file_out_of_scope(repo, &review.findings, ctx.subject, state, cfg);
489        file_nonblocking(repo, &review.findings, ctx.subject, state, cfg);
490
491        if check_relitigation(ledger, &blocking, state) {
492            state.status = Status::Escalated;
493            post_outcome(
494                repo,
495                ctx.pr_number,
496                state,
497                ledger,
498                Ending::Deadlocked(&blocking),
499            );
500            persist(
501                repo,
502                ctx.pr_number,
503                state,
504                ledger,
505                round,
506                &cfg.other(&holder),
507            );
508            return Ok(());
509        }
510
511        if blocking.is_empty() {
512            state.status = Status::Approved;
513            post_outcome(repo, ctx.pr_number, state, ledger, Ending::Approved);
514            persist(
515                repo,
516                ctx.pr_number,
517                state,
518                ledger,
519                round,
520                &cfg.other(&holder),
521            );
522            if cfg.loop_cfg.auto_merge {
523                // Release the worktree first. `gh pr merge --delete-branch`
524                // fails if anything still has the branch checked out, and it
525                // fails *after* merging, so the merge lands while the command
526                // reports failure.
527                ctx.release(repo);
528                repo.merge_pr(ctx.pr_number)?;
529                state.status = Status::Merged;
530                repo.clear_state(ctx.pr_number); // nothing left to resume
531                log!("{}: merged", ctx.label);
532            } else {
533                log!("{}: approved, awaiting human merge", ctx.label);
534            }
535            return Ok(());
536        }
537
538        if review.next_action == NextAction::FixMyself {
539            log!("{}: {holder} fixing its own findings", ctx.label);
540            let prompt = FIX_PROMPT.replace("{findings}", &findings_for_prompt(&blocking));
541            reviewer.ask(&prompt, &ctx.work_dir, effort.as_deref())?;
542        } else {
543            let author_name = cfg.other(&holder);
544            let author = agent::find(agents, &author_name)?;
545            log!(
546                "{}: handing {} finding(s) to {author_name}",
547                ctx.label,
548                blocking.len()
549            );
550            let prompt = RESPOND_PROMPT
551                .replace("{number}", &ctx.subject.to_string())
552                .replace("{findings}", &findings_for_prompt(&blocking));
553            let response: ResponseDoc = author.ask_json(
554                &prompt,
555                &schema::response(),
556                &ctx.work_dir,
557                cfg.effort_for_round(&author.spec, round).as_deref(),
558            )?;
559            apply_dispositions(
560                repo,
561                cfg,
562                &response,
563                &blocking,
564                ledger,
565                state,
566                round,
567                ctx.subject,
568                ctx.pr_number,
569                &author_name,
570            );
571        }
572
573        repo.rewrite_commits_if_needed(&ctx.work_dir, &base)?;
574        repo.push(&ctx.work_dir, &ctx.branch)?;
575        holder = cfg.other(&holder);
576        persist(repo, ctx.pr_number, state, ledger, round, &holder);
577    }
578
579    state.status = Status::Escalated;
580    state
581        .notes
582        .push(exhausted_note(ctx.start_round, last_round));
583    post_outcome(repo, ctx.pr_number, state, ledger, Ending::OutOfRounds);
584    persist(repo, ctx.pr_number, state, ledger, last_round, &holder);
585    Ok(())
586}
587
588/// The inclusive range of round numbers this invocation will work through.
589///
590/// Round numbers keep counting up across sessions so the ledger and the PR
591/// history stay coherent, while the budget resets each time a person chooses to
592/// run spar again.
593fn round_window(start_round: u32, budget: u32) -> (u32, u32) {
594    (start_round, start_round + budget.saturating_sub(1))
595}
596
597/// How many rounds this invocation spent, and how many the PR has seen in
598/// total. A resumed PR that stops at round 8 did not have 8 rounds of budget,
599/// and saying so would misreport both the cost and the history.
600fn spent(start_round: u32, last_round: u32) -> (u32, u32) {
601    (last_round.saturating_sub(start_round) + 1, last_round)
602}
603
604fn exhausted_note(start_round: u32, last_round: u32) -> String {
605    let (this_run, total) = spent(start_round, last_round);
606    if this_run == total {
607        format!("no convergence after {this_run} rounds")
608    } else {
609        format!("no convergence after {this_run} more rounds ({total} in total)")
610    }
611}
612
613fn persist(
614    repo: &Repo,
615    pr_number: i64,
616    state: &IssueRun,
617    ledger: &Ledger,
618    round: u32,
619    next_actor: &str,
620) {
621    let payload = PersistedState {
622        version: STATE_VERSION,
623        round,
624        next_actor: next_actor.to_string(),
625        status: state.status,
626        ledger: ledger.clone(),
627        filed: state.filed.clone(),
628    };
629    if let Err(e) = repo.write_state(pr_number, &payload) {
630        logdim!("could not persist state for PR #{pr_number}: {e}");
631    }
632}
633
634// ---------------------------------------------------------------------------
635// The ledger
636// ---------------------------------------------------------------------------
637
638fn settled_block(ledger: &Ledger) -> String {
639    if ledger.is_empty() {
640        return String::new();
641    }
642    let lines: Vec<String> = ledger
643        .values()
644        .map(|e| format!("- {}: refuted because {}", e.title, e.reasoning))
645        .collect();
646    format!(
647        "\nThe following points were already raised and refuted. Treat them as settled. Do not \
648         raise them again unless you have new evidence:\n{}",
649        lines.join("\n")
650    )
651}
652
653/// A point refuted and then raised twice more goes to a person rather than
654/// looping forever.
655fn check_relitigation(ledger: &mut Ledger, blocking: &[Finding], state: &mut IssueRun) -> bool {
656    let mut escalate = false;
657    for finding in blocking {
658        let key = finding_key(&finding.title, &finding.file);
659        if let Some(entry) = ledger.get_mut(&key) {
660            entry.reraised += 1;
661            if entry.reraised >= 2 {
662                state.notes.push(format!(
663                    "'{}' was refuted and re-raised twice; escalating.",
664                    finding.title
665                ));
666                escalate = true;
667            }
668        }
669    }
670    escalate
671}
672
673fn normalise(text: &str) -> String {
674    text.to_lowercase()
675        .chars()
676        .filter(|c| c.is_ascii_alphanumeric() || c.is_whitespace())
677        .collect::<String>()
678        .split_whitespace()
679        .collect::<Vec<_>>()
680        .join(" ")
681}
682
683/// Match a disposition back to the finding it answers, so the ledger key it
684/// records is the same key the next round's finding will hash to. Without this
685/// the re-litigation guard is dead code for any finding that names a file.
686/// Whether two titles name the same point, ignoring wording noise.
687pub(crate) fn same_point(a: &str, b: &str) -> bool {
688    normalise(a) == normalise(b)
689}
690
691fn matching_finding<'a>(findings: &'a [Finding], title: &str) -> Option<&'a Finding> {
692    let wanted = normalise(title);
693    findings.iter().find(|f| normalise(&f.title) == wanted)
694}
695
696#[allow(clippy::too_many_arguments)]
697fn apply_dispositions(
698    repo: &Repo,
699    cfg: &Config,
700    response: &ResponseDoc,
701    blocking: &[Finding],
702    ledger: &mut Ledger,
703    state: &mut IssueRun,
704    round: u32,
705    subject: i64,
706    pr_number: i64,
707    author: &str,
708) {
709    let mut fixed = Vec::new();
710    let mut refuted = Vec::new();
711    let mut filed = Vec::new();
712
713    for d in &response.dispositions {
714        let source = matching_finding(blocking, &d.title);
715        let file = source
716            .map(|f| f.file.clone())
717            .filter(|f| !f.trim().is_empty())
718            .unwrap_or_else(|| d.file.clone());
719        // Hash the *reviewer's* wording, not the author's. `matching_finding`
720        // is deliberately looser than `finding_key` (it ignores hyphens, dots,
721        // slashes, and underscores), so an author who writes "multibyte" where
722        // the reviewer wrote "multi-byte" matches here and yet hashes to a
723        // different key. Recording that key means next round's lookup misses
724        // and the re-litigation guard tracks nothing at all.
725        let canonical = source.map(|f| f.title.as_str()).unwrap_or(d.title.as_str());
726        let title = style::title(canonical, &repo.style);
727
728        match d.action {
729            Action::Refuted => {
730                let reasoning = style::summary(&d.reasoning, &repo.style);
731                ledger.insert(
732                    finding_key(canonical, &file),
733                    LedgerEntry {
734                        title: title.clone(),
735                        file: file.clone(),
736                        reasoning: reasoning.clone(),
737                        round,
738                        reraised: 0,
739                    },
740                );
741                state.disputes.push(Dispute {
742                    title: title.clone(),
743                    reasoning: reasoning.clone(),
744                });
745                refuted.push(format!("{title}. {reasoning}"));
746            }
747            Action::FiledIssue => {
748                let new_title = d
749                    .new_issue_title
750                    .clone()
751                    .filter(|t| !t.trim().is_empty())
752                    .unwrap_or_else(|| d.title.clone());
753                let new_body = d
754                    .new_issue_body
755                    .clone()
756                    .filter(|b| !b.trim().is_empty())
757                    .unwrap_or_else(|| d.reasoning.clone());
758                let recorded = file_followup(repo, &new_title, &new_body, subject, cfg, state);
759                if let Some(url) = recorded {
760                    state.filed.push(url.clone());
761                    filed.push(url);
762                }
763            }
764            Action::Fixed => fixed.push(title),
765        }
766    }
767
768    if repo.style.pr_comments == PrComments::Rounds {
769        let comment = disposition_comment(author, response, &fixed, &refuted, &filed, &repo.style);
770        if let Some(text) = comment {
771            if let Err(e) = repo.comment_pr(pr_number, &text) {
772                logdim!("could not post the disposition comment: {e}");
773            }
774        }
775    }
776}
777
778// ---------------------------------------------------------------------------
779// Follow-ups
780// ---------------------------------------------------------------------------
781
782/// Record a finding that is real but out of scope for this PR.
783///
784/// On your own repository an issue is the right home. On a large repository
785/// that is not yours it is somebody else's notification and somebody else's
786/// triage queue, so `local` keeps the same information in `.spar/followups.md`
787/// and `none` drops it.
788fn file_followup(
789    repo: &Repo,
790    title: &str,
791    body: &str,
792    source: i64,
793    cfg: &Config,
794    state: &IssueRun,
795) -> Option<String> {
796    if repo.followups == Followups::None {
797        return None;
798    }
799    // A backstop against a run that will not stop finding things. Silent
800    // truncation is not on offer: what was dropped is said out loud.
801    if state.filed.len() >= cfg.loop_cfg.max_followups {
802        logwarn!(
803            "already recorded {} follow-ups, not recording '{}'. Raise max_followups if you want \
804             them all.",
805            state.filed.len(),
806            style::title(title, &repo.style)
807        );
808        return None;
809    }
810    // The exact string that will land on GitHub. Searching for anything else
811    // means the duplicate check can never hit, and every round files another
812    // copy of the same follow-up.
813    let title = match repo.clean_title(title) {
814        Ok(title) => title,
815        Err(e) => {
816            logdim!("could not clean a follow-up title: {e}");
817            return None;
818        }
819    };
820    if title.trim().is_empty() {
821        return None;
822    }
823    // Not style::body: that is the budget for a pull request comment, read with
824    // the diff in front of you. This is a work item somebody picks up cold.
825    let body = format!(
826        "{}\n\nFound while working on #{source}.",
827        style::issue_body(body, &repo.style)
828    );
829
830    if repo.followups == Followups::Local {
831        return repo.append_local_followup(&title, &body);
832    }
833
834    // An issue that already covers this, however it was worded. Filing a second
835    // one is the complaint; silently dropping the new wording is not much
836    // better, because a later run often carries evidence the first did not.
837    if let Some(existing) = repo.find_similar_issue(&title, &body) {
838        let known = format!("{} {}", existing.title, existing.body);
839        if !existing.open {
840            logdim!(
841                "#{} already covers '{title}' and is closed, leaving it alone",
842                existing.number
843            );
844            return None;
845        }
846        if crate::textsim::adds_information(&body, &known) {
847            match repo.comment_issue(existing.number, &body) {
848                Ok(()) => log!("added to #{}: {title}", existing.number),
849                Err(e) => logdim!("could not add to #{}: {e}", existing.number),
850            }
851        } else {
852            logdim!("#{} already says this, nothing added", existing.number);
853        }
854        return Some(existing.url);
855    }
856
857    match repo.create_issue(&title, &body) {
858        Ok(url) => Some(url),
859        Err(e) => {
860            logdim!("could not file a follow-up for '{title}': {e}");
861            None
862        }
863    }
864}
865
866fn file_out_of_scope(
867    repo: &Repo,
868    findings: &[Finding],
869    subject: i64,
870    state: &mut IssueRun,
871    cfg: &Config,
872) {
873    for finding in findings.iter().filter(|f| !f.in_scope) {
874        let body = issue_report(finding);
875        if let Some(url) = file_followup(repo, &finding.title, &body, subject, cfg, state) {
876            state.filed.push(url);
877        }
878    }
879}
880
881/// A finding written as a bug report, when it carries the parts of one.
882///
883/// The thread gets one line; an issue gets the whole thing under headings, in
884/// the order somebody reads a bug report: what is wrong, how to see it, what it
885/// costs, what it should do instead. A finding with none of those falls back to
886/// its detail, which is every finding that was never going to be filed.
887pub fn issue_report(finding: &Finding) -> String {
888    let sections = finding.report_sections();
889    if sections.is_empty() {
890        return finding.detail.clone();
891    }
892    let mut out: Vec<String> = sections
893        .iter()
894        .map(|(heading, text)| format!("## {heading}\n\n{text}"))
895        .collect();
896    // Keep the one line summary when it says something the sections do not,
897    // rather than dropping it or repeating it.
898    if !finding.detail.trim().is_empty()
899        && !sections
900            .iter()
901            .any(|(_, text)| crate::textsim::same_point(text, &finding.detail))
902    {
903        out.insert(0, finding.detail.trim().to_string());
904    }
905    out.join("\n\n")
906}
907
908/// Non-blocking findings become follow-ups so they do not gate the merge.
909///
910/// Nits are excluded by default. On a shared repository a filed nit is somebody
911/// else's notification and somebody else's triage queue: an early run on a
912/// production codebase opened an issue titled "Log wording". Worth saying in
913/// the PR thread, not worth an issue.
914fn file_nonblocking(
915    repo: &Repo,
916    findings: &[Finding],
917    subject: i64,
918    state: &mut IssueRun,
919    cfg: &Config,
920) {
921    for finding in findings {
922        let keep = match finding.severity {
923            Severity::NonBlocking => cfg.loop_cfg.file_non_blocking,
924            Severity::Nit => cfg.loop_cfg.file_nits,
925            Severity::Blocking => false,
926        };
927        if !keep || !finding.in_scope {
928            continue;
929        }
930        if let Some(url) = file_followup(repo, &finding.title, &finding.detail, subject, cfg, state)
931        {
932            state.filed.push(url);
933        }
934    }
935}
936
937// ---------------------------------------------------------------------------
938// What a human actually reads
939// ---------------------------------------------------------------------------
940//
941// spar composes every comment itself from structured fields, rather than
942// forwarding whatever prose a model produced. That is the only reliable way to
943// keep a PR thread readable: the model supplies facts, the harness supplies the
944// shape, and each field is held to a budget on the way out.
945
946fn bullets(lines: &[String]) -> String {
947    lines
948        .iter()
949        .map(|l| format!("- {l}"))
950        .collect::<Vec<_>>()
951        .join("\n")
952}
953
954fn located(finding: &Finding, style: &Style) -> String {
955    let title = style::title(&finding.title, style);
956    match finding.where_at() {
957        "general" => title,
958        file => format!("{title} ({file})"),
959    }
960}
961
962/// How the run ended, which is the only thing about the run a reader needs.
963pub enum Ending<'a> {
964    /// Nothing blocks a merge.
965    Approved,
966    /// The round budget ran out. The last round's fixes were pushed but never
967    /// reviewed, which is the part a maintainer has to know.
968    OutOfRounds,
969    /// A point was refuted and raised again anyway. Nobody is going to break
970    /// the tie but a person.
971    Deadlocked(&'a [Finding]),
972}
973
974/// Post the one comment a run leaves behind, if it has anything to say.
975///
976/// Everything spar used to write here was an account of its own working: which
977/// agent spoke, which round it was, how many findings of each severity, that it
978/// had stopped. None of that is about the code. Worse, the running commentary
979/// could contradict itself, ending a thread with "5 fixed" immediately followed
980/// by "no convergence", which reads as a failure rather than as fixes nobody
981/// has checked yet.
982///
983/// So the loop is silent and this says what is left: what is unresolved, what
984/// was argued down, and where the follow-ups went.
985pub fn post_outcome(
986    repo: &Repo,
987    pr_number: i64,
988    state: &IssueRun,
989    ledger: &Ledger,
990    ending: Ending<'_>,
991) {
992    if repo.style.pr_comments != PrComments::Outcome {
993        return;
994    }
995    let Some(text) = outcome_comment(state, ledger, &ending, &repo.style) else {
996        return;
997    };
998    if let Err(e) = repo.comment_pr(pr_number, &text) {
999        logdim!("could not post the outcome comment: {e}");
1000    }
1001}
1002
1003/// Why a point was refuted: this run's disputes first, then the ledger, which
1004/// is what survives across a resume.
1005fn refutation_of(finding: &Finding, state: &IssueRun, ledger: &Ledger) -> Option<String> {
1006    if let Some(d) = state
1007        .disputes
1008        .iter()
1009        .find(|d| same_point(&d.title, &finding.title))
1010    {
1011        if !d.reasoning.trim().is_empty() {
1012            return Some(d.reasoning.clone());
1013        }
1014    }
1015    ledger
1016        .get(&finding_key(&finding.title, &finding.file))
1017        .map(|entry| entry.reasoning.clone())
1018        .filter(|r| !r.trim().is_empty())
1019}
1020
1021/// `#123` from a filed issue URL, falling back to the URL when it does not look
1022/// like one. Shorter, and GitHub renders it as a link either way.
1023/// The issue number a filed follow-up URL points at, when it is one. Local
1024/// notes and anything unparseable yield nothing.
1025pub fn filed_issue_number(filed: &str) -> Option<i64> {
1026    filed
1027        .rsplit('/')
1028        .next()
1029        .and_then(|tail| tail.parse::<i64>().ok())
1030        .filter(|n| *n > 0)
1031}
1032
1033fn as_reference(url: &str) -> String {
1034    match url.rsplit('/').next().and_then(|n| n.parse::<u64>().ok()) {
1035        Some(number) => format!("#{number}"),
1036        None => url.to_string(),
1037    }
1038}
1039
1040pub fn outcome_comment(
1041    state: &IssueRun,
1042    ledger: &Ledger,
1043    ending: &Ending<'_>,
1044    style: &Style,
1045) -> Option<String> {
1046    let mut out: Vec<String> = Vec::new();
1047    // Points rendered in the deadlock block, so the refutation list below does
1048    // not print the same title a second time.
1049    let mut already: Vec<String> = Vec::new();
1050
1051    match ending {
1052        Ending::Approved => {
1053            if state.disputes.is_empty() && state.filed.is_empty() {
1054                // A clean approval with nothing outstanding needs no comment.
1055                // The absence of objections is the message.
1056                return None;
1057            }
1058            out.push("Reviewed, nothing blocking a merge.".into());
1059        }
1060        Ending::OutOfRounds => out.push(
1061            "Not signed off: the last round of fixes was pushed but has not been reviewed.".into(),
1062        ),
1063        Ending::Deadlocked(points) => {
1064            // Rendered once, with the argument attached. A deadlocked point is
1065            // by definition one that was refuted earlier, so the reasoning is
1066            // the whole reason a person is being asked to look. On a resumed
1067            // run `state.disputes` is empty (only `filed` is restored), so the
1068            // ledger is the only place that argument survives.
1069            let lines: Vec<String> = points
1070                .iter()
1071                .map(|f| {
1072                    let where_at = match f.where_at() {
1073                        "general" => String::new(),
1074                        file => format!(" ({file})"),
1075                    };
1076                    let title = style::title(&f.title, style);
1077                    already.push(title.clone());
1078                    match refutation_of(f, state, ledger) {
1079                        Some(reason) => format!(
1080                            "{title}{where_at}. Refuted as: {}",
1081                            style::summary(&reason, style)
1082                        ),
1083                        None => format!("{title}{where_at}"),
1084                    }
1085                })
1086                .collect();
1087            out.push("Needs your decision. The reviewers could not settle this:".into());
1088            out.push(bullets(&lines));
1089        }
1090    }
1091
1092    let disputes: Vec<&crate::model::Dispute> = state
1093        .disputes
1094        .iter()
1095        .filter(|d| !already.iter().any(|t| same_point(t, &d.title)))
1096        .collect();
1097    if !disputes.is_empty() {
1098        // The one thing invisible anywhere else. The diff shows what was fixed;
1099        // nothing shows what was argued down, or why.
1100        let lines: Vec<String> = disputes
1101            .iter()
1102            .map(|d| {
1103                format!(
1104                    "{}. {}",
1105                    style::title(&d.title, style),
1106                    style::sentence(&d.reasoning, style)
1107                )
1108            })
1109            .collect();
1110        out.push(format!("Raised and refuted:\n{}", bullets(&lines)));
1111    }
1112
1113    if !state.filed.is_empty() {
1114        let refs: Vec<String> = state.filed.iter().map(|u| as_reference(u)).collect();
1115        out.push(format!("Filed separately: {}", refs.join(", ")));
1116    }
1117
1118    Some(out.join("\n\n"))
1119}
1120
1121/// The pull request body.
1122///
1123/// What it closes, then the change in one sentence, then what was wrong, then
1124/// only the sections that have something in them. The lead is two paragraphs
1125/// rather than two headings: a heading over a single sentence is a label on a
1126/// label, and those two parts are the ones every body has.
1127///
1128/// GitHub renders the file count and the plus and minus figures immediately
1129/// above this, so neither appears here.
1130pub fn pr_body(issue: i64, work: &Implementation, style: &Style) -> String {
1131    let mut parts = vec![format!("Closes #{issue}")];
1132
1133    for lead in [&work.summary, &work.problem] {
1134        let text = style::sentence(lead, style);
1135        if !text.is_empty() {
1136            parts.push(text);
1137        }
1138    }
1139    parts.extend(section("What changed", &work.changes, style));
1140    parts.extend(section("How to test", &work.testing, style));
1141
1142    let notes = style::sentence(work.notes.as_deref().unwrap_or_default(), style);
1143    if !notes.is_empty() {
1144        parts.push(format!("## Notes\n\n{notes}"));
1145    }
1146
1147    style::body(&parts.join("\n\n"), style)
1148}
1149
1150/// A headed list, or nothing at all when there is nothing to list.
1151///
1152/// Nothing at all on purpose. A heading with an empty body under it reads as a
1153/// section somebody forgot to write, which is worse than the absence, and a
1154/// small change that needs no change list should not be made to look like one
1155/// that is missing its.
1156fn section(heading: &str, lines: &[String], style: &Style) -> Option<String> {
1157    let items: Vec<String> = lines
1158        .iter()
1159        .map(|line| style::summary(line, style))
1160        .filter(|line| !line.is_empty())
1161        .collect();
1162    if items.is_empty() {
1163        return None;
1164    }
1165    Some(format!("## {heading}\n\n{}", bullets(&items)))
1166}
1167
1168/// What gets posted on an issue that produced no pull request.
1169///
1170/// The agent's own reason when it gave one, since that is the part written for
1171/// the person who opened the issue. Never the summary: an issue that produced
1172/// no commits has no change for a summary to describe, and one that claims
1173/// otherwise is worse than a flat sentence saying nothing happened.
1174fn no_pr_note(work: &Implementation, style: &Style) -> String {
1175    let reason = style::sentence(&work.reason, style);
1176    if !reason.is_empty() {
1177        return reason;
1178    }
1179    if work.not_worth_doing {
1180        "Left alone after reading the code, with no reason given.".to_string()
1181    } else {
1182        "Nothing was committed, so there is nothing to review.".to_string()
1183    }
1184}
1185
1186/// One review, as a reviewer would write it if they were in a hurry: a count
1187/// line, a sentence, and one bullet per finding. Only blocking findings carry
1188/// their detail, because only those are something the author has to act on now.
1189pub fn review_comment(holder: &str, round: u32, review: &Review, style: &Style) -> String {
1190    let by = |severity: Severity| -> Vec<&Finding> {
1191        review
1192            .findings
1193            .iter()
1194            .filter(|f| f.severity == severity && f.in_scope)
1195            .collect()
1196    };
1197    let blocking = by(Severity::Blocking);
1198    let non_blocking = by(Severity::NonBlocking);
1199    let nits = by(Severity::Nit);
1200    let out_of_scope: Vec<&Finding> = review.findings.iter().filter(|f| !f.in_scope).collect();
1201
1202    let mut counts = Vec::new();
1203    if !blocking.is_empty() {
1204        counts.push(format!("{} blocking", blocking.len()));
1205    }
1206    if !non_blocking.is_empty() {
1207        counts.push(format!("{} non-blocking", non_blocking.len()));
1208    }
1209    if !nits.is_empty() {
1210        counts.push(format!("{} nit", nits.len()));
1211    }
1212    if !out_of_scope.is_empty() {
1213        counts.push(format!("{} out of scope", out_of_scope.len()));
1214    }
1215    let headline = if counts.is_empty() {
1216        "no findings".to_string()
1217    } else {
1218        counts.join(", ")
1219    };
1220
1221    let _ = (holder, round, headline);
1222    let mut out = Vec::new();
1223    let summary = style::summary(&review.summary, style);
1224    if !summary.is_empty() {
1225        out.push(summary);
1226    }
1227
1228    if !blocking.is_empty() {
1229        let lines: Vec<String> = blocking
1230            .iter()
1231            .map(|f| {
1232                let detail = style::detail(&f.detail, style);
1233                if detail.is_empty() {
1234                    located(f, style)
1235                } else {
1236                    format!("{}. {detail}", located(f, style))
1237                }
1238            })
1239            .collect();
1240        out.push(format!("blocking\n{}", bullets(&lines)));
1241    }
1242
1243    // Everything below is filed as a follow-up, so the thread only needs the
1244    // title: the detail lives on the issue where it can be acted on.
1245    for (label, group) in [
1246        ("non-blocking", &non_blocking),
1247        ("nits", &nits),
1248        ("out of scope", &out_of_scope),
1249    ] {
1250        if group.is_empty() {
1251            continue;
1252        }
1253        let lines: Vec<String> = group.iter().map(|f| located(f, style)).collect();
1254        out.push(format!("{label}\n{}", bullets(&lines)));
1255    }
1256
1257    out.join("\n\n")
1258}
1259
1260/// One response to a review. Refutations carry their reasoning because that is
1261/// the whole argument; fixes are a list of titles because the diff says the
1262/// rest.
1263pub fn disposition_comment(
1264    author: &str,
1265    response: &ResponseDoc,
1266    fixed: &[String],
1267    refuted: &[String],
1268    filed: &[String],
1269    style: &Style,
1270) -> Option<String> {
1271    if fixed.is_empty() && refuted.is_empty() && filed.is_empty() {
1272        return None;
1273    }
1274    let mut counts = Vec::new();
1275    if !fixed.is_empty() {
1276        counts.push(format!("{} fixed", fixed.len()));
1277    }
1278    if !refuted.is_empty() {
1279        counts.push(format!("{} refuted", refuted.len()));
1280    }
1281    if !filed.is_empty() {
1282        counts.push(format!("{} filed", filed.len()));
1283    }
1284
1285    let _ = (author, counts);
1286    let mut out = Vec::new();
1287    let summary = style::summary(&response.summary, style);
1288    if !summary.is_empty() {
1289        out.push(summary);
1290    }
1291    if !refuted.is_empty() {
1292        out.push(format!("refuted\n{}", bullets(refuted)));
1293    }
1294    if !fixed.is_empty() {
1295        out.push(format!("fixed\n{}", bullets(fixed)));
1296    }
1297    if !filed.is_empty() {
1298        out.push(format!("filed\n{}", bullets(filed)));
1299    }
1300    Some(out.join("\n\n"))
1301}
1302
1303/// What is posted on an issue both agents declined.
1304/// What is posted on an issue both reviewers declined.
1305///
1306/// Just the reasons. GitHub already shows that it was closed as not planned,
1307/// and which model held which opinion is a fact about the run rather than about
1308/// the issue. Duplicates are collapsed, since two reviewers reaching the same
1309/// conclusion often reach it in the same words.
1310pub fn skip_comment(item: &SkippedItem, style: &Style) -> String {
1311    let reasons = item
1312        .reasons
1313        .values()
1314        .map(|reason| style::sentence(reason, style));
1315    // Two reviewers declining one issue almost always decline it for the same
1316    // reason, worded differently. On the run that prompted this, both cited the
1317    // issue it duplicated and the reader saw the point twice.
1318    let lines = crate::textsim::dedupe_by(reasons, crate::textsim::same_reason);
1319    bullets(&lines)
1320}
1321
1322/// Findings as a model should see them: full detail, since this one is not for
1323/// a human to read.
1324pub(crate) fn findings_for_prompt(findings: &[Finding]) -> String {
1325    if findings.is_empty() {
1326        return "(none)".to_string();
1327    }
1328    findings
1329        .iter()
1330        .map(|f| {
1331            let scope = if f.in_scope { "" } else { " [out of scope]" };
1332            format!(
1333                "- [{}]{scope} {} ({})\n  {}",
1334                f.severity,
1335                f.title,
1336                f.where_at(),
1337                f.detail
1338            )
1339        })
1340        .collect::<Vec<_>>()
1341        .join("\n")
1342}
1343
1344#[cfg(test)]
1345mod tests {
1346    use super::*;
1347    use crate::model::Verdict;
1348
1349    fn style() -> Style {
1350        Style::default()
1351    }
1352
1353    fn finding(severity: &str, title: &str, detail: &str, file: &str, in_scope: bool) -> Finding {
1354        Finding {
1355            severity: Severity::parse_lenient(severity).unwrap(),
1356            title: title.into(),
1357            detail: detail.into(),
1358            file: file.into(),
1359            in_scope,
1360            ..Default::default()
1361        }
1362    }
1363
1364    fn review(summary: &str, findings: Vec<Finding>) -> Review {
1365        Review {
1366            verdict: Verdict::Approve,
1367            next_action: NextAction::Merge,
1368            summary: summary.into(),
1369            findings,
1370        }
1371    }
1372
1373    // -- worktree release ------------------------------------------------
1374
1375    fn cfg_with(worktrees: bool, keep: bool) -> Config {
1376        let text = "[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n";
1377        let mut cfg = crate::config::parse(text).unwrap();
1378        cfg.loop_cfg.worktrees = worktrees;
1379        cfg.loop_cfg.keep_worktrees = keep;
1380        cfg
1381    }
1382
1383    #[test]
1384    fn a_worktree_is_released_on_every_finished_outcome() {
1385        let cfg = cfg_with(true, false);
1386        for status in [Status::Approved, Status::Merged, Status::Abandoned] {
1387            assert!(should_release(&cfg, status), "{status}");
1388        }
1389    }
1390
1391    /// Releasing only on "merged" leaked one worktree per run, because
1392    /// auto_merge is off by default and runs end at "approved".
1393    #[test]
1394    fn a_worktree_is_kept_only_where_a_human_has_to_look() {
1395        let cfg = cfg_with(true, false);
1396        assert!(!should_release(&cfg, Status::Escalated));
1397        assert!(!should_release(&cfg, Status::Error));
1398    }
1399
1400    #[test]
1401    fn the_keep_flag_overrides_everything() {
1402        assert!(!should_release(&cfg_with(true, true), Status::Approved));
1403    }
1404
1405    #[test]
1406    fn nothing_is_released_when_worktrees_are_off() {
1407        assert!(!should_release(&cfg_with(false, false), Status::Approved));
1408    }
1409
1410    // -- round budget ----------------------------------------------------
1411
1412    /// A fresh PR gets rounds 1 through max_rounds.
1413    #[test]
1414    fn a_fresh_run_starts_at_one() {
1415        assert_eq!((1, 3), round_window(1, 3));
1416        assert_eq!((1, 5), round_window(1, 5));
1417    }
1418
1419    /// The budget is per invocation, not a lifetime cap. Running spar again on
1420    /// a PR that already spent five rounds gives it five more, because a person
1421    /// looked at it and chose to.
1422    #[test]
1423    fn a_resumed_run_gets_a_full_fresh_budget() {
1424        assert_eq!((6, 10), round_window(6, 5));
1425        assert_eq!((11, 13), round_window(11, 3));
1426    }
1427
1428    #[test]
1429    fn a_budget_of_one_is_a_single_round() {
1430        assert_eq!((6, 6), round_window(6, 1));
1431    }
1432
1433    #[test]
1434    fn round_numbers_keep_counting_across_sessions() {
1435        // Three sessions of three rounds each land on 1..3, 4..6, 7..9.
1436        let mut start = 1;
1437        let mut seen = Vec::new();
1438        for _ in 0..3 {
1439            let (first, last) = round_window(start, 3);
1440            seen.push((first, last));
1441            start = last + 1;
1442        }
1443        assert_eq!(vec![(1, 3), (4, 6), (7, 9)], seen);
1444    }
1445
1446    // -- the ledger ------------------------------------------------------
1447
1448    fn ledger_with(title: &str, file: &str) -> Ledger {
1449        let mut ledger = Ledger::new();
1450        ledger.insert(
1451            finding_key(title, file),
1452            LedgerEntry {
1453                title: title.into(),
1454                file: file.into(),
1455                reasoning: "no".into(),
1456                round: 1,
1457                reraised: 0,
1458            },
1459        );
1460        ledger
1461    }
1462
1463    #[test]
1464    fn a_point_refuted_and_re_raised_twice_escalates() {
1465        let mut ledger = ledger_with("nit about naming", "a.rs");
1466        let mut state = IssueRun::new(1, "t");
1467        let blocking = vec![finding("blocking", "nit about naming", "d", "a.rs", true)];
1468        assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
1469        assert!(check_relitigation(&mut ledger, &blocking, &mut state));
1470    }
1471
1472    #[test]
1473    fn an_untracked_finding_does_not_escalate() {
1474        let mut state = IssueRun::new(1, "t");
1475        let blocking = vec![finding("blocking", "brand new", "d", "a.rs", true)];
1476        assert!(!check_relitigation(
1477            &mut Ledger::new(),
1478            &blocking,
1479            &mut state
1480        ));
1481    }
1482
1483    /// The key a refutation records has to be the key the next round's finding
1484    /// hashes to. Recording it without the file made the guard dead code for
1485    /// every finding that named one, which is nearly all of them.
1486    #[test]
1487    fn a_refutation_lands_on_the_key_the_next_round_will_look_up() {
1488        let blocking = vec![finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
1489        let recorded = finding_key(&blocking[0].title, &blocking[0].file);
1490
1491        let matched = matching_finding(&blocking, "unbounded loop!").expect("should match");
1492        assert_eq!(recorded, finding_key("unbounded loop!", &matched.file));
1493    }
1494
1495    /// `matching_finding` ignores hyphens, dots, slashes, and underscores;
1496    /// `finding_key` keeps them. A disposition that differs only in those
1497    /// characters therefore matches its finding while hashing to a different
1498    /// key, so recording the author's wording made the guard track nothing.
1499    #[test]
1500    fn the_ledger_key_uses_the_reviewers_wording_not_the_authors() {
1501        let findings = vec![finding(
1502            "blocking",
1503            "Panic on multi-byte input",
1504            "d",
1505            "src/style.rs",
1506            true,
1507        )];
1508        let reworded = "Panic on multibyte input";
1509
1510        let source = matching_finding(&findings, reworded).expect("still matches");
1511        assert_ne!(
1512            finding_key(reworded, &source.file),
1513            finding_key(&source.title, &source.file),
1514            "the two spellings must genuinely hash apart, or this test proves nothing"
1515        );
1516
1517        // What apply_dispositions records, and what the next round looks up.
1518        let recorded = finding_key(&source.title, &source.file);
1519        let looked_up = finding_key(&findings[0].title, &findings[0].file);
1520        assert_eq!(recorded, looked_up);
1521    }
1522
1523    #[test]
1524    fn a_disposition_matches_its_finding_despite_wording_noise() {
1525        let findings = vec![finding(
1526            "blocking",
1527            "Unbounded loop!",
1528            "d",
1529            "src/x.rs",
1530            true,
1531        )];
1532        assert!(matching_finding(&findings, "unbounded loop").is_some());
1533        assert!(matching_finding(&findings, "something else").is_none());
1534    }
1535
1536    #[test]
1537    fn the_settled_block_is_empty_when_nothing_is_settled() {
1538        assert_eq!("", settled_block(&Ledger::new()));
1539    }
1540
1541    #[test]
1542    fn the_settled_block_names_each_refutation() {
1543        let block = settled_block(&ledger_with("a point", "x.rs"));
1544        assert!(block.contains("a point"));
1545        assert!(block.contains("settled"));
1546    }
1547
1548    // -- brevity ---------------------------------------------------------
1549
1550    #[test]
1551    /// No agent name, no round number, and no count of things listed below.
1552    /// The reader wants the review, not an account of who produced it.
1553    fn a_clean_review_is_just_the_verdict() {
1554        let text = review_comment("codex", 1, &review("Looks correct.", vec![]), &style());
1555        assert_eq!("Looks correct.", text);
1556    }
1557
1558    #[test]
1559    fn a_review_leads_with_the_counts() {
1560        let text = review_comment(
1561            "codex",
1562            2,
1563            &review(
1564                "One real problem.",
1565                vec![
1566                    finding(
1567                        "blocking",
1568                        "Loop never terminates",
1569                        "Confirmed by running it.",
1570                        "src/a.rs",
1571                        true,
1572                    ),
1573                    finding("non-blocking", "Name is vague", "d", "src/b.rs", true),
1574                    finding("nit", "Log wording", "d", "", true),
1575                ],
1576            ),
1577            &style(),
1578        );
1579        assert!(text.starts_with("One real problem."), "{text}");
1580        assert!(!text.contains("codex"), "no agent name: {text}");
1581        assert!(!text.contains("round 2"), "no round number: {text}");
1582    }
1583
1584    /// Only blocking findings carry their detail into the thread. Everything
1585    /// else is filed, and the detail belongs on the issue.
1586    #[test]
1587    fn only_blocking_findings_carry_their_detail() {
1588        let text = review_comment(
1589            "codex",
1590            1,
1591            &review(
1592                "s",
1593                vec![
1594                    finding("blocking", "Loop", "BLOCKING DETAIL", "a.rs", true),
1595                    finding("non-blocking", "Name", "NONBLOCKING DETAIL", "b.rs", true),
1596                ],
1597            ),
1598            &style(),
1599        );
1600        assert!(text.contains("BLOCKING DETAIL"), "{text}");
1601        assert!(!text.contains("NONBLOCKING DETAIL"), "{text}");
1602    }
1603
1604    #[test]
1605    /// A finding's explanation is what the author acts on. Cutting it to save
1606    /// characters leaves them nothing to act on and saves nothing worth having.
1607    fn a_thorough_explanation_reaches_the_author_intact() {
1608        let detail = "Reproduced by running the 429 test with max_attempts unset. ".repeat(8);
1609        let text = review_comment(
1610            "codex",
1611            1,
1612            &review(
1613                "One problem.",
1614                vec![finding("blocking", "T", &detail, "a.rs", true)],
1615            ),
1616            &style(),
1617        );
1618        assert!(
1619            text.contains(detail.trim()),
1620            "the explanation was cut:\n{text}"
1621        );
1622    }
1623
1624    /// A runaway is still bounded, just nowhere near tightly.
1625    #[test]
1626    fn a_runaway_model_is_still_bounded() {
1627        let long = "filler words. ".repeat(20_000);
1628        let text = review_comment(
1629            "codex",
1630            1,
1631            &review(&long, vec![finding("blocking", "T", &long, "a.rs", true)]),
1632            &style(),
1633        );
1634        assert!(
1635            text.len() < 30_000,
1636            "review comment was {} chars",
1637            text.len()
1638        );
1639    }
1640
1641    #[test]
1642    fn a_general_finding_has_no_empty_parenthesis() {
1643        let text = review_comment(
1644            "codex",
1645            1,
1646            &review("s", vec![finding("blocking", "Something", "d", "", true)]),
1647            &style(),
1648        );
1649        assert!(!text.contains("()"), "{text}");
1650        assert!(!text.contains("(general)"), "{text}");
1651    }
1652
1653    #[test]
1654    fn out_of_scope_findings_are_counted_separately() {
1655        let text = review_comment(
1656            "codex",
1657            1,
1658            &review(
1659                "s",
1660                vec![finding("blocking", "Old bug", "d", "a.rs", false)],
1661            ),
1662            &style(),
1663        );
1664        assert!(text.contains("out of scope"), "{text}");
1665        assert!(text.contains("Old bug"), "{text}");
1666    }
1667
1668    #[test]
1669    fn a_disposition_comment_leads_with_counts_and_keeps_refutations() {
1670        let response = ResponseDoc {
1671            summary: "Two of three were right.".into(),
1672            dispositions: vec![],
1673        };
1674        let text = disposition_comment(
1675            "claude",
1676            &response,
1677            &["Fixed thing".to_string()],
1678            &["Wrong thing. Because the caller already checks.".to_string()],
1679            &[],
1680            &style(),
1681        )
1682        .unwrap();
1683        assert!(text.starts_with("Two of three were right."), "{text}");
1684        assert!(!text.contains("claude"), "no agent name: {text}");
1685        assert!(
1686            text.contains("Because the caller already checks."),
1687            "{text}"
1688        );
1689    }
1690
1691    #[test]
1692    fn an_empty_disposition_comment_is_not_posted() {
1693        let response = ResponseDoc {
1694            summary: "s".into(),
1695            dispositions: vec![],
1696        };
1697        assert!(disposition_comment("claude", &response, &[], &[], &[], &style()).is_none());
1698    }
1699
1700    /// A fully reported implementation, for the body tests.
1701    fn worked() -> Implementation {
1702        Implementation {
1703            summary: "Retry a 429 instead of failing the run.".into(),
1704            problem: "A rate limited response was treated as fatal, so one throttled call ended \
1705                      a run that had hours of work left in it."
1706                .into(),
1707            changes: vec![
1708                "`send` retries a 429 with the delay the header asks for".into(),
1709                "the retry budget is bounded, so a permanent 429 still ends".into(),
1710            ],
1711            testing: vec![
1712                "`cargo test retries_a_429`".into(),
1713                "point it at a throttled endpoint and watch it finish".into(),
1714            ],
1715            ..Implementation::default()
1716        }
1717    }
1718
1719    #[test]
1720    /// GitHub renders the file count and the plus and minus figures in the
1721    /// header, immediately above whatever spar writes, so neither is here.
1722    fn a_pr_body_is_what_it_closes_and_what_changed() {
1723        let body = pr_body(42, &worked(), &style());
1724        assert_eq!(
1725            "Closes #42\n\n\
1726             Retry a 429 instead of failing the run.\n\n\
1727             A rate limited response was treated as fatal, so one throttled call \
1728             ended a run that had hours of work left in it.\n\n\
1729             ## What changed\n\n\
1730             - `send` retries a 429 with the delay the header asks for\n\
1731             - the retry budget is bounded, so a permanent 429 still ends\n\n\
1732             ## How to test\n\n\
1733             - `cargo test retries_a_429`\n\
1734             - point it at a throttled endpoint and watch it finish",
1735            body
1736        );
1737    }
1738
1739    /// The sections are optional and the lead is not. A one line fix should
1740    /// read as one, not as a form with most of it left blank.
1741    #[test]
1742    fn a_body_with_nothing_to_list_carries_no_empty_headings() {
1743        let work = Implementation {
1744            summary: "Retry a 429 instead of failing the run.".into(),
1745            ..Implementation::default()
1746        };
1747        assert_eq!(
1748            "Closes #42\n\nRetry a 429 instead of failing the run.",
1749            pr_body(42, &work, &style())
1750        );
1751    }
1752
1753    #[test]
1754    fn a_pr_body_survives_an_implementor_that_said_nothing() {
1755        assert_eq!(
1756            "Closes #7",
1757            pr_body(7, &Implementation::default(), &style())
1758        );
1759    }
1760
1761    /// Blank entries are the model's, not the reader's problem. A heading whose
1762    /// only bullet was an empty string used to be possible.
1763    #[test]
1764    fn blank_list_entries_do_not_earn_a_heading() {
1765        let work = Implementation {
1766            summary: "Did a thing.".into(),
1767            changes: vec![String::new(), "   ".into()],
1768            ..Implementation::default()
1769        };
1770        let body = pr_body(42, &work, &style());
1771        assert!(!body.contains("What changed"), "{body}");
1772    }
1773
1774    #[test]
1775    fn notes_appear_only_when_there_is_something_to_note() {
1776        let mut work = worked();
1777        assert!(!pr_body(42, &work, &style()).contains("## Notes"));
1778        work.notes = Some("The retry is not applied to streaming calls.".into());
1779        let body = pr_body(42, &work, &style());
1780        assert!(body.contains("## Notes"), "{body}");
1781        assert!(body.contains("streaming calls"), "{body}");
1782    }
1783
1784    /// An issue that produced no commits is told so. Never the summary, which
1785    /// describes a change that is not in the branch.
1786    #[test]
1787    fn declining_posts_the_reason_and_not_the_summary() {
1788        let work = Implementation {
1789            not_worth_doing: true,
1790            reason: "Already fixed in 1.2, and the report predates it.".into(),
1791            summary: "Nothing to do.".into(),
1792            ..Implementation::default()
1793        };
1794        assert_eq!(
1795            "Already fixed in 1.2, and the report predates it.",
1796            no_pr_note(&work, &style())
1797        );
1798    }
1799
1800    #[test]
1801    fn reporting_work_and_committing_none_says_that_rather_than_the_summary() {
1802        let work = Implementation {
1803            summary: "Retry a 429 instead of failing the run.".into(),
1804            ..Implementation::default()
1805        };
1806        let note = no_pr_note(&work, &style());
1807        assert_eq!(
1808            "Nothing was committed, so there is nothing to review.",
1809            note
1810        );
1811    }
1812
1813    #[test]
1814    fn declining_without_a_reason_still_says_something() {
1815        let work = Implementation {
1816            not_worth_doing: true,
1817            ..Implementation::default()
1818        };
1819        assert!(no_pr_note(&work, &style()).contains("no reason given"));
1820    }
1821
1822    #[test]
1823    fn a_skip_comment_is_only_the_reasoning() {
1824        let item = SkippedItem {
1825            issue: 3,
1826            title: "t".into(),
1827            reasons: [
1828                ("claude".to_string(), "Already fixed in 1.2.".to_string()),
1829                ("codex".to_string(), "Duplicate of #2.".to_string()),
1830            ]
1831            .into_iter()
1832            .collect(),
1833        };
1834        let text = skip_comment(&item, &style());
1835        assert!(text.contains("Already fixed in 1.2."), "{text}");
1836        assert!(text.contains("Duplicate of #2."), "{text}");
1837        assert!(
1838            !text.contains("claude") && !text.contains("codex"),
1839            "{text}"
1840        );
1841        assert!(!text.to_lowercase().contains("not scheduled"), "{text}");
1842        assert!(text.lines().count() <= 3, "{text}");
1843    }
1844
1845    #[test]
1846    fn findings_for_a_model_keep_full_detail() {
1847        let long = "x".repeat(2000);
1848        let text = findings_for_prompt(&[finding("blocking", "T", &long, "a.rs", true)]);
1849        assert!(
1850            text.contains(&long),
1851            "a model needs the whole finding, only humans need brevity"
1852        );
1853    }
1854
1855    #[test]
1856    fn findings_for_a_model_are_never_empty() {
1857        assert_eq!("(none)", findings_for_prompt(&[]));
1858    }
1859}
1860
1861#[cfg(test)]
1862mod outcome_tests {
1863    use super::*;
1864    use crate::model::{Dispute, Severity};
1865
1866    fn style() -> Style {
1867        Style::default()
1868    }
1869
1870    fn state_with(disputes: Vec<(&str, &str)>, filed: Vec<&str>) -> IssueRun {
1871        let mut s = IssueRun::new(482, "t");
1872        s.disputes = disputes
1873            .into_iter()
1874            .map(|(title, reasoning)| Dispute {
1875                title: title.into(),
1876                reasoning: reasoning.into(),
1877            })
1878            .collect();
1879        s.filed = filed.into_iter().map(String::from).collect();
1880        s
1881    }
1882
1883    fn finding(title: &str, file: &str) -> Finding {
1884        Finding {
1885            severity: Severity::Blocking,
1886            title: title.into(),
1887            detail: "d".into(),
1888            file: file.into(),
1889            in_scope: true,
1890            ..Default::default()
1891        }
1892    }
1893
1894    /// The absence of objections is the message. A PR that reviewed cleanly and
1895    /// filed nothing should leave no trace in the thread at all.
1896    #[test]
1897    fn a_clean_approval_says_nothing() {
1898        let state = state_with(vec![], vec![]);
1899        assert!(outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).is_none());
1900    }
1901
1902    #[test]
1903    fn an_approval_that_filed_follow_ups_links_them() {
1904        let state = state_with(
1905            vec![],
1906            vec![
1907                "https://github.com/you/thing/issues/485",
1908                "https://github.com/you/thing/issues/486",
1909            ],
1910        );
1911        let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
1912        assert!(text.contains("Filed separately: #485, #486"), "{text}");
1913    }
1914
1915    /// The real PR ended with "5 fixed" followed by "no convergence", which
1916    /// reads as a contradiction. What a maintainer needs is that the fixes went
1917    /// in and nobody checked them.
1918    #[test]
1919    fn running_out_of_rounds_says_what_that_means_for_the_reader() {
1920        let state = state_with(vec![], vec![]);
1921        let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
1922        assert!(text.contains("has not been reviewed"), "{text}");
1923        assert!(
1924            !text.to_lowercase().contains("round 3"),
1925            "no round numbers: {text}"
1926        );
1927        assert!(!text.to_lowercase().contains("convergence"), "{text}");
1928    }
1929
1930    #[test]
1931    fn a_deadlock_names_the_point_they_could_not_settle() {
1932        let state = state_with(vec![], vec![]);
1933        let points = [finding("Retry loop never terminates", "src/net.rs:88")];
1934        let text = outcome_comment(
1935            &state,
1936            &Ledger::new(),
1937            &Ending::Deadlocked(&points),
1938            &style(),
1939        )
1940        .unwrap();
1941        assert!(
1942            text.contains("Retry loop never terminates (src/net.rs:88)"),
1943            "{text}"
1944        );
1945        assert!(text.contains("could not settle"), "{text}");
1946    }
1947
1948    /// The diff records what was fixed. Nothing records what was argued down.
1949    #[test]
1950    fn refutations_survive_because_nothing_else_carries_them() {
1951        let state = state_with(
1952            vec![(
1953                "Error is swallowed",
1954                "the caller already validates the file",
1955            )],
1956            vec![],
1957        );
1958        let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
1959        assert!(text.contains("Raised and refuted:"), "{text}");
1960        assert!(
1961            text.contains("The caller already validates the file"),
1962            "{text}"
1963        );
1964    }
1965
1966    #[test]
1967    fn no_agent_names_counts_or_round_numbers_reach_the_thread() {
1968        let state = state_with(
1969            vec![("A point", "a reason")],
1970            vec!["https://github.com/you/thing/issues/485"],
1971        );
1972        for ending in [Ending::Approved, Ending::OutOfRounds] {
1973            let text = outcome_comment(&state, &Ledger::new(), &ending, &style()).unwrap();
1974            let lower = text.to_lowercase();
1975            for banned in ["claude", "codex", "blocking,", "nit,", " fixed."] {
1976                assert!(
1977                    !lower.contains(banned),
1978                    "{banned:?} leaked into the thread:\n{text}"
1979                );
1980            }
1981            // "the last round of fixes" is prose. "round 3" is narration.
1982            for n in 1..9 {
1983                assert!(
1984                    !lower.contains(&format!("round {n}")),
1985                    "a round number leaked into the thread:\n{text}"
1986                );
1987            }
1988        }
1989    }
1990
1991    #[test]
1992    /// A refutation is an argument, and an argument that stops mid clause is
1993    /// not one. Bounded, but with room to make the case.
1994    fn a_refutation_is_allowed_to_make_its_case() {
1995        let reasoning = "The caller validates against the schema first. \
1996                         The discarded error is therefore unreachable in practice. ";
1997        let state = state_with(
1998            vec![("A point", &reasoning.repeat(6))],
1999            vec!["https://github.com/you/thing/issues/485"],
2000        );
2001        let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
2002        assert!(
2003            !text.contains("..."),
2004            "nothing was cut mid thought:\n{text}"
2005        );
2006        assert!(text.len() < 4000, "{} chars", text.len());
2007    }
2008
2009    #[test]
2010    fn a_url_that_is_not_an_issue_link_is_left_alone() {
2011        assert_eq!(
2012            "#485",
2013            as_reference("https://github.com/you/thing/issues/485")
2014        );
2015        assert_eq!("note: something", as_reference("note: something"));
2016    }
2017}
2018
2019#[cfg(test)]
2020mod filed_reference_tests {
2021    use super::*;
2022
2023    #[test]
2024    fn an_issue_url_yields_its_number() {
2025        assert_eq!(
2026            Some(485),
2027            filed_issue_number("https://github.com/you/thing/issues/485")
2028        );
2029    }
2030
2031    /// Local mode records a note rather than a URL, and a run with
2032    /// followups = "local" must not try to absorb it as an issue.
2033    #[test]
2034    fn a_local_note_yields_nothing() {
2035        assert_eq!(None, filed_issue_number("note: Retry is unbounded"));
2036        assert_eq!(None, filed_issue_number(""));
2037        assert_eq!(
2038            None,
2039            filed_issue_number("https://github.com/you/thing/issues/")
2040        );
2041    }
2042}
2043
2044#[cfg(test)]
2045mod followup_restraint_tests {
2046    use super::*;
2047    use crate::model::Severity;
2048
2049    fn cfg_with(followups: Followups, non_blocking: bool, nits: bool, cap: usize) -> Config {
2050        let mut cfg =
2051            crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
2052                .unwrap();
2053        cfg.loop_cfg.followups = followups;
2054        cfg.loop_cfg.file_non_blocking = non_blocking;
2055        cfg.loop_cfg.file_nits = nits;
2056        cfg.loop_cfg.max_followups = cap;
2057        cfg
2058    }
2059
2060    fn finding(severity: Severity, title: &str, in_scope: bool) -> Finding {
2061        Finding {
2062            severity,
2063            title: title.into(),
2064            detail: "d".into(),
2065            file: "a.rs".into(),
2066            in_scope,
2067            ..Default::default()
2068        }
2069    }
2070
2071    /// The defaults are what let one issue spawn ten, which spawned more. A
2072    /// thorough reviewer always finds improvements; not gating a merge is not
2073    /// the same as deserving somebody's triage queue.
2074    #[test]
2075    fn a_non_blocking_finding_is_not_a_tracker_item_by_default() {
2076        let cfg = cfg_with(Followups::Issues, false, false, 5);
2077        assert!(!cfg.loop_cfg.file_non_blocking);
2078        assert!(!cfg.loop_cfg.file_nits);
2079    }
2080
2081    #[test]
2082    fn follow_ups_stay_off_the_tracker_by_default() {
2083        let cfg =
2084            crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
2085                .unwrap();
2086        assert_eq!(
2087            Followups::Local,
2088            cfg.loop_cfg.followups,
2089            "the tracker is somebody's queue; the default must not write to it"
2090        );
2091        assert_eq!(5, cfg.loop_cfg.max_followups);
2092    }
2093
2094    /// Which severities survive the filter, at the defaults and when opened up.
2095    #[test]
2096    fn only_out_of_scope_defects_qualify_at_the_defaults() {
2097        let cfg = cfg_with(Followups::Issues, false, false, 5);
2098        let qualifies = |f: &Finding| match f.severity {
2099            Severity::NonBlocking => cfg.loop_cfg.file_non_blocking && f.in_scope,
2100            Severity::Nit => cfg.loop_cfg.file_nits && f.in_scope,
2101            Severity::Blocking => false,
2102        } || !f.in_scope;
2103
2104        assert!(qualifies(&finding(
2105            Severity::Blocking,
2106            "pre-existing",
2107            false
2108        )));
2109        assert!(!qualifies(&finding(
2110            Severity::NonBlocking,
2111            "improvement",
2112            true
2113        )));
2114        assert!(!qualifies(&finding(Severity::Nit, "taste", true)));
2115        assert!(!qualifies(&finding(
2116            Severity::Blocking,
2117            "fix it here",
2118            true
2119        )));
2120    }
2121
2122    #[test]
2123    fn opening_it_up_lets_non_blocking_findings_through_again() {
2124        let cfg = cfg_with(Followups::Issues, true, false, 5);
2125        assert!(cfg.loop_cfg.file_non_blocking);
2126    }
2127
2128    /// A run that will not stop finding things is stopped, and says so.
2129    #[test]
2130    fn the_cap_is_a_real_backstop() {
2131        let cfg = cfg_with(Followups::Issues, false, false, 3);
2132        let mut state = IssueRun::new(1, "t");
2133        state.filed = (0..3).map(|n| format!("url{n}")).collect();
2134        assert!(state.filed.len() >= cfg.loop_cfg.max_followups);
2135    }
2136
2137    /// The number that matters. Reviewing one issue produced ten follow-ups on
2138    /// a real repository, each of which could be run in turn: mean offspring
2139    /// above one never terminates.
2140    #[test]
2141    fn the_cap_bounds_what_one_run_can_spawn() {
2142        let cfg = cfg_with(Followups::Issues, false, false, 5);
2143        assert!(
2144            cfg.loop_cfg.max_followups <= 5,
2145            "a run that can file ten follow-ups is a branching process"
2146        );
2147    }
2148}
2149
2150#[cfg(test)]
2151mod issue_report_tests {
2152    use super::*;
2153    use crate::model::Severity;
2154
2155    /// Shaped after a bug report written by hand that reads the way one should:
2156    /// what is wrong, how to see it, what it costs, what it should do instead.
2157    fn reported() -> Finding {
2158        Finding {
2159            severity: Severity::Blocking,
2160            title: "sendPaymentAsync bypasses drain mode and spending limits".into(),
2161            detail: "The async path skips every admission check payInvoice applies.".into(),
2162            file: "src/node.ts:412".into(),
2163            in_scope: false,
2164            problem: Some(
2165                "`BeignetNode.sendPaymentAsync()` submits a payment directly to the Lightning \
2166                 engine without applying the safeguards used by `payInvoice()`.\n\nThe async path \
2167                 does not:\n\n- call `_checkDraining()`\n- call `_checkSpendLimit()`"
2168                    .into(),
2169            ),
2170            reproduction: Some(
2171                "1. Create a `BeignetNode` with `dailySpendLimitSats: 1`.\n2. Enable drain mode.\n\
2172                 3. Submit a 1,000 sat invoice.\n\nActual result:\n\n- The engine is called.\n\
2173                 - `spentSats` remains 0."
2174                    .into(),
2175            ),
2176            impact: Some(
2177                "An authorized client can submit async payments up to the available outbound \
2178                 liquidity despite the configured limits."
2179                    .into(),
2180            ),
2181            expected: Some(
2182                "- Reject new payments while draining.\n- Enforce the per-payment limit before \
2183                 submission.\n- Cover both paths with regression tests.\n\nThis predates the \
2184                 current branch."
2185                    .into(),
2186            ),
2187        }
2188    }
2189
2190    #[test]
2191    fn a_reported_finding_becomes_a_bug_report() {
2192        let body = issue_report(&reported());
2193        for heading in [
2194            "## Problem",
2195            "## Reproduction",
2196            "## Impact",
2197            "## Expected behavior",
2198        ] {
2199            assert!(body.contains(heading), "missing {heading}:\n{body}");
2200        }
2201        // In the order somebody reads a bug report.
2202        let at = |h: &str| body.find(h).unwrap();
2203        assert!(at("## Problem") < at("## Reproduction"));
2204        assert!(at("## Reproduction") < at("## Impact"));
2205        assert!(at("## Impact") < at("## Expected behavior"));
2206    }
2207
2208    #[test]
2209    fn the_substance_survives_the_outbound_gates() {
2210        let repo_style = Style::default();
2211        let body = crate::style::issue_body(&issue_report(&reported()), &repo_style);
2212        for kept in [
2213            "_checkDraining()",
2214            "Actual result:",
2215            "outbound liquidity",
2216            "regression tests",
2217            "predates the current branch",
2218        ] {
2219            assert!(body.contains(kept), "the gate ate {kept:?}:\n{body}");
2220        }
2221        assert!(!body.contains("..."), "something was cut:\n{body}");
2222    }
2223
2224    /// A finding that was never going to be filed carries none of this, and
2225    /// must not gain empty headings for the sake of a format.
2226    #[test]
2227    fn an_ordinary_finding_is_still_just_its_detail() {
2228        let plain = Finding {
2229            severity: Severity::NonBlocking,
2230            title: "Name is vague".into(),
2231            detail: "The variable could say what it holds.".into(),
2232            file: "a.rs".into(),
2233            in_scope: true,
2234            ..Default::default()
2235        };
2236        assert_eq!(
2237            "The variable could say what it holds.",
2238            issue_report(&plain)
2239        );
2240    }
2241
2242    /// Partial reports are normal: a defect with no useful reproduction should
2243    /// not sprout an empty Reproduction heading.
2244    #[test]
2245    fn only_the_sections_that_were_written_appear() {
2246        let partial = Finding {
2247            problem: Some("The guard is inverted.".into()),
2248            expected: Some("It should reject rather than accept.".into()),
2249            ..reported()
2250        };
2251        let partial = Finding {
2252            reproduction: None,
2253            impact: None,
2254            ..partial
2255        };
2256        let body = issue_report(&partial);
2257        assert!(body.contains("## Problem") && body.contains("## Expected behavior"));
2258        assert!(!body.contains("## Reproduction"), "{body}");
2259        assert!(!body.contains("## Impact"), "{body}");
2260    }
2261
2262    /// The one line the thread shows is not repeated when a section already
2263    /// says it.
2264    #[test]
2265    fn the_summary_line_is_not_printed_twice() {
2266        let echoed = Finding {
2267            detail: "The guard is inverted so it rejects valid input.".into(),
2268            problem: Some("The guard is inverted so it rejects valid input.".into()),
2269            reproduction: None,
2270            impact: None,
2271            expected: None,
2272            ..reported()
2273        };
2274        let body = issue_report(&echoed);
2275        assert_eq!(1, body.matches("The guard is inverted").count(), "{body}");
2276    }
2277}