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