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