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