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        if let Some(url) = file_followup(repo, &finding.title, &finding.detail, subject, cfg, state)
865        {
866            state.filed.push(url);
867        }
868    }
869}
870
871/// Non-blocking findings become follow-ups so they do not gate the merge.
872///
873/// Nits are excluded by default. On a shared repository a filed nit is somebody
874/// else's notification and somebody else's triage queue: an early run on a
875/// production codebase opened an issue titled "Log wording". Worth saying in
876/// the PR thread, not worth an issue.
877fn file_nonblocking(
878    repo: &Repo,
879    findings: &[Finding],
880    subject: i64,
881    state: &mut IssueRun,
882    cfg: &Config,
883) {
884    for finding in findings {
885        let keep = match finding.severity {
886            Severity::NonBlocking => cfg.loop_cfg.file_non_blocking,
887            Severity::Nit => cfg.loop_cfg.file_nits,
888            Severity::Blocking => false,
889        };
890        if !keep || !finding.in_scope {
891            continue;
892        }
893        if let Some(url) = file_followup(repo, &finding.title, &finding.detail, subject, cfg, state)
894        {
895            state.filed.push(url);
896        }
897    }
898}
899
900// ---------------------------------------------------------------------------
901// What a human actually reads
902// ---------------------------------------------------------------------------
903//
904// spar composes every comment itself from structured fields, rather than
905// forwarding whatever prose a model produced. That is the only reliable way to
906// keep a PR thread readable: the model supplies facts, the harness supplies the
907// shape, and each field is held to a budget on the way out.
908
909fn bullets(lines: &[String]) -> String {
910    lines
911        .iter()
912        .map(|l| format!("- {l}"))
913        .collect::<Vec<_>>()
914        .join("\n")
915}
916
917fn located(finding: &Finding, style: &Style) -> String {
918    let title = style::title(&finding.title, style);
919    match finding.where_at() {
920        "general" => title,
921        file => format!("{title} ({file})"),
922    }
923}
924
925/// How the run ended, which is the only thing about the run a reader needs.
926pub enum Ending<'a> {
927    /// Nothing blocks a merge.
928    Approved,
929    /// The round budget ran out. The last round's fixes were pushed but never
930    /// reviewed, which is the part a maintainer has to know.
931    OutOfRounds,
932    /// A point was refuted and raised again anyway. Nobody is going to break
933    /// the tie but a person.
934    Deadlocked(&'a [Finding]),
935}
936
937/// Post the one comment a run leaves behind, if it has anything to say.
938///
939/// Everything spar used to write here was an account of its own working: which
940/// agent spoke, which round it was, how many findings of each severity, that it
941/// had stopped. None of that is about the code. Worse, the running commentary
942/// could contradict itself, ending a thread with "5 fixed" immediately followed
943/// by "no convergence", which reads as a failure rather than as fixes nobody
944/// has checked yet.
945///
946/// So the loop is silent and this says what is left: what is unresolved, what
947/// was argued down, and where the follow-ups went.
948pub fn post_outcome(
949    repo: &Repo,
950    pr_number: i64,
951    state: &IssueRun,
952    ledger: &Ledger,
953    ending: Ending<'_>,
954) {
955    if repo.style.pr_comments != PrComments::Outcome {
956        return;
957    }
958    let Some(text) = outcome_comment(state, ledger, &ending, &repo.style) else {
959        return;
960    };
961    if let Err(e) = repo.comment_pr(pr_number, &text) {
962        logdim!("could not post the outcome comment: {e}");
963    }
964}
965
966/// Why a point was refuted: this run's disputes first, then the ledger, which
967/// is what survives across a resume.
968fn refutation_of(finding: &Finding, state: &IssueRun, ledger: &Ledger) -> Option<String> {
969    if let Some(d) = state
970        .disputes
971        .iter()
972        .find(|d| same_point(&d.title, &finding.title))
973    {
974        if !d.reasoning.trim().is_empty() {
975            return Some(d.reasoning.clone());
976        }
977    }
978    ledger
979        .get(&finding_key(&finding.title, &finding.file))
980        .map(|entry| entry.reasoning.clone())
981        .filter(|r| !r.trim().is_empty())
982}
983
984/// `#123` from a filed issue URL, falling back to the URL when it does not look
985/// like one. Shorter, and GitHub renders it as a link either way.
986/// The issue number a filed follow-up URL points at, when it is one. Local
987/// notes and anything unparseable yield nothing.
988pub fn filed_issue_number(filed: &str) -> Option<i64> {
989    filed
990        .rsplit('/')
991        .next()
992        .and_then(|tail| tail.parse::<i64>().ok())
993        .filter(|n| *n > 0)
994}
995
996fn as_reference(url: &str) -> String {
997    match url.rsplit('/').next().and_then(|n| n.parse::<u64>().ok()) {
998        Some(number) => format!("#{number}"),
999        None => url.to_string(),
1000    }
1001}
1002
1003pub fn outcome_comment(
1004    state: &IssueRun,
1005    ledger: &Ledger,
1006    ending: &Ending<'_>,
1007    style: &Style,
1008) -> Option<String> {
1009    let mut out: Vec<String> = Vec::new();
1010    // Points rendered in the deadlock block, so the refutation list below does
1011    // not print the same title a second time.
1012    let mut already: Vec<String> = Vec::new();
1013
1014    match ending {
1015        Ending::Approved => {
1016            if state.disputes.is_empty() && state.filed.is_empty() {
1017                // A clean approval with nothing outstanding needs no comment.
1018                // The absence of objections is the message.
1019                return None;
1020            }
1021            out.push("Reviewed, nothing blocking a merge.".into());
1022        }
1023        Ending::OutOfRounds => out.push(
1024            "Not signed off: the last round of fixes was pushed but has not been reviewed.".into(),
1025        ),
1026        Ending::Deadlocked(points) => {
1027            // Rendered once, with the argument attached. A deadlocked point is
1028            // by definition one that was refuted earlier, so the reasoning is
1029            // the whole reason a person is being asked to look. On a resumed
1030            // run `state.disputes` is empty (only `filed` is restored), so the
1031            // ledger is the only place that argument survives.
1032            let lines: Vec<String> = points
1033                .iter()
1034                .map(|f| {
1035                    let where_at = match f.where_at() {
1036                        "general" => String::new(),
1037                        file => format!(" ({file})"),
1038                    };
1039                    let title = style::title(&f.title, style);
1040                    already.push(title.clone());
1041                    match refutation_of(f, state, ledger) {
1042                        Some(reason) => format!(
1043                            "{title}{where_at}. Refuted as: {}",
1044                            style::summary(&reason, style)
1045                        ),
1046                        None => format!("{title}{where_at}"),
1047                    }
1048                })
1049                .collect();
1050            out.push("Needs your decision. The reviewers could not settle this:".into());
1051            out.push(bullets(&lines));
1052        }
1053    }
1054
1055    let disputes: Vec<&crate::model::Dispute> = state
1056        .disputes
1057        .iter()
1058        .filter(|d| !already.iter().any(|t| same_point(t, &d.title)))
1059        .collect();
1060    if !disputes.is_empty() {
1061        // The one thing invisible anywhere else. The diff shows what was fixed;
1062        // nothing shows what was argued down, or why.
1063        let lines: Vec<String> = disputes
1064            .iter()
1065            .map(|d| {
1066                format!(
1067                    "{}. {}",
1068                    style::title(&d.title, style),
1069                    style::sentence(&d.reasoning, style)
1070                )
1071            })
1072            .collect();
1073        out.push(format!("Raised and refuted:\n{}", bullets(&lines)));
1074    }
1075
1076    if !state.filed.is_empty() {
1077        let refs: Vec<String> = state.filed.iter().map(|u| as_reference(u)).collect();
1078        out.push(format!("Filed separately: {}", refs.join(", ")));
1079    }
1080
1081    Some(out.join("\n\n"))
1082}
1083
1084/// The PR body: what it closes, one sentence of what changed, and the diffstat.
1085/// GitHub already shows the file list, so repeating it is noise.
1086pub fn pr_body(issue: i64, summary: &str, style: &Style) -> String {
1087    let mut parts = vec![format!("Closes #{issue}")];
1088    let summary = style::summary(summary, style);
1089    if !summary.is_empty() {
1090        parts.push(summary);
1091    }
1092    parts.join("\n\n")
1093}
1094
1095/// The last `SUMMARY:` line an implementor emitted, if it left one.
1096pub fn extract_summary(text: &str) -> Option<String> {
1097    text.lines()
1098        .rev()
1099        .find_map(|line| {
1100            let trimmed = line.trim().trim_start_matches(['*', '#', '-', ' ']);
1101            trimmed
1102                .strip_prefix("SUMMARY:")
1103                .or_else(|| trimmed.strip_prefix("Summary:"))
1104        })
1105        .map(|s| {
1106            s.trim()
1107                .trim_start_matches(['*', '_', ':', ' '])
1108                .trim()
1109                .to_string()
1110        })
1111        .filter(|s| !s.is_empty())
1112}
1113
1114/// One review, as a reviewer would write it if they were in a hurry: a count
1115/// line, a sentence, and one bullet per finding. Only blocking findings carry
1116/// their detail, because only those are something the author has to act on now.
1117pub fn review_comment(holder: &str, round: u32, review: &Review, style: &Style) -> String {
1118    let by = |severity: Severity| -> Vec<&Finding> {
1119        review
1120            .findings
1121            .iter()
1122            .filter(|f| f.severity == severity && f.in_scope)
1123            .collect()
1124    };
1125    let blocking = by(Severity::Blocking);
1126    let non_blocking = by(Severity::NonBlocking);
1127    let nits = by(Severity::Nit);
1128    let out_of_scope: Vec<&Finding> = review.findings.iter().filter(|f| !f.in_scope).collect();
1129
1130    let mut counts = Vec::new();
1131    if !blocking.is_empty() {
1132        counts.push(format!("{} blocking", blocking.len()));
1133    }
1134    if !non_blocking.is_empty() {
1135        counts.push(format!("{} non-blocking", non_blocking.len()));
1136    }
1137    if !nits.is_empty() {
1138        counts.push(format!("{} nit", nits.len()));
1139    }
1140    if !out_of_scope.is_empty() {
1141        counts.push(format!("{} out of scope", out_of_scope.len()));
1142    }
1143    let headline = if counts.is_empty() {
1144        "no findings".to_string()
1145    } else {
1146        counts.join(", ")
1147    };
1148
1149    let _ = (holder, round, headline);
1150    let mut out = Vec::new();
1151    let summary = style::summary(&review.summary, style);
1152    if !summary.is_empty() {
1153        out.push(summary);
1154    }
1155
1156    if !blocking.is_empty() {
1157        let lines: Vec<String> = blocking
1158            .iter()
1159            .map(|f| {
1160                let detail = style::detail(&f.detail, style);
1161                if detail.is_empty() {
1162                    located(f, style)
1163                } else {
1164                    format!("{}. {detail}", located(f, style))
1165                }
1166            })
1167            .collect();
1168        out.push(format!("blocking\n{}", bullets(&lines)));
1169    }
1170
1171    // Everything below is filed as a follow-up, so the thread only needs the
1172    // title: the detail lives on the issue where it can be acted on.
1173    for (label, group) in [
1174        ("non-blocking", &non_blocking),
1175        ("nits", &nits),
1176        ("out of scope", &out_of_scope),
1177    ] {
1178        if group.is_empty() {
1179            continue;
1180        }
1181        let lines: Vec<String> = group.iter().map(|f| located(f, style)).collect();
1182        out.push(format!("{label}\n{}", bullets(&lines)));
1183    }
1184
1185    out.join("\n\n")
1186}
1187
1188/// One response to a review. Refutations carry their reasoning because that is
1189/// the whole argument; fixes are a list of titles because the diff says the
1190/// rest.
1191pub fn disposition_comment(
1192    author: &str,
1193    response: &ResponseDoc,
1194    fixed: &[String],
1195    refuted: &[String],
1196    filed: &[String],
1197    style: &Style,
1198) -> Option<String> {
1199    if fixed.is_empty() && refuted.is_empty() && filed.is_empty() {
1200        return None;
1201    }
1202    let mut counts = Vec::new();
1203    if !fixed.is_empty() {
1204        counts.push(format!("{} fixed", fixed.len()));
1205    }
1206    if !refuted.is_empty() {
1207        counts.push(format!("{} refuted", refuted.len()));
1208    }
1209    if !filed.is_empty() {
1210        counts.push(format!("{} filed", filed.len()));
1211    }
1212
1213    let _ = (author, counts);
1214    let mut out = Vec::new();
1215    let summary = style::summary(&response.summary, style);
1216    if !summary.is_empty() {
1217        out.push(summary);
1218    }
1219    if !refuted.is_empty() {
1220        out.push(format!("refuted\n{}", bullets(refuted)));
1221    }
1222    if !fixed.is_empty() {
1223        out.push(format!("fixed\n{}", bullets(fixed)));
1224    }
1225    if !filed.is_empty() {
1226        out.push(format!("filed\n{}", bullets(filed)));
1227    }
1228    Some(out.join("\n\n"))
1229}
1230
1231/// What is posted on an issue both agents declined.
1232/// What is posted on an issue both reviewers declined.
1233///
1234/// Just the reasons. GitHub already shows that it was closed as not planned,
1235/// and which model held which opinion is a fact about the run rather than about
1236/// the issue. Duplicates are collapsed, since two reviewers reaching the same
1237/// conclusion often reach it in the same words.
1238pub fn skip_comment(item: &SkippedItem, style: &Style) -> String {
1239    let reasons = item
1240        .reasons
1241        .values()
1242        .map(|reason| style::sentence(reason, style));
1243    // Two reviewers declining one issue almost always decline it for the same
1244    // reason, worded differently. On the run that prompted this, both cited the
1245    // issue it duplicated and the reader saw the point twice.
1246    let lines = crate::textsim::dedupe_by(reasons, crate::textsim::same_reason);
1247    bullets(&lines)
1248}
1249
1250/// Findings as a model should see them: full detail, since this one is not for
1251/// a human to read.
1252pub(crate) fn findings_for_prompt(findings: &[Finding]) -> String {
1253    if findings.is_empty() {
1254        return "(none)".to_string();
1255    }
1256    findings
1257        .iter()
1258        .map(|f| {
1259            let scope = if f.in_scope { "" } else { " [out of scope]" };
1260            format!(
1261                "- [{}]{scope} {} ({})\n  {}",
1262                f.severity,
1263                f.title,
1264                f.where_at(),
1265                f.detail
1266            )
1267        })
1268        .collect::<Vec<_>>()
1269        .join("\n")
1270}
1271
1272#[cfg(test)]
1273mod tests {
1274    use super::*;
1275    use crate::model::Verdict;
1276
1277    fn style() -> Style {
1278        Style::default()
1279    }
1280
1281    fn finding(severity: &str, title: &str, detail: &str, file: &str, in_scope: bool) -> Finding {
1282        Finding {
1283            severity: Severity::parse_lenient(severity).unwrap(),
1284            title: title.into(),
1285            detail: detail.into(),
1286            file: file.into(),
1287            in_scope,
1288        }
1289    }
1290
1291    fn review(summary: &str, findings: Vec<Finding>) -> Review {
1292        Review {
1293            verdict: Verdict::Approve,
1294            next_action: NextAction::Merge,
1295            summary: summary.into(),
1296            findings,
1297        }
1298    }
1299
1300    // -- worktree release ------------------------------------------------
1301
1302    fn cfg_with(worktrees: bool, keep: bool) -> Config {
1303        let text = "[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n";
1304        let mut cfg = crate::config::parse(text).unwrap();
1305        cfg.loop_cfg.worktrees = worktrees;
1306        cfg.loop_cfg.keep_worktrees = keep;
1307        cfg
1308    }
1309
1310    #[test]
1311    fn a_worktree_is_released_on_every_finished_outcome() {
1312        let cfg = cfg_with(true, false);
1313        for status in [Status::Approved, Status::Merged, Status::Abandoned] {
1314            assert!(should_release(&cfg, status), "{status}");
1315        }
1316    }
1317
1318    /// Releasing only on "merged" leaked one worktree per run, because
1319    /// auto_merge is off by default and runs end at "approved".
1320    #[test]
1321    fn a_worktree_is_kept_only_where_a_human_has_to_look() {
1322        let cfg = cfg_with(true, false);
1323        assert!(!should_release(&cfg, Status::Escalated));
1324        assert!(!should_release(&cfg, Status::Error));
1325    }
1326
1327    #[test]
1328    fn the_keep_flag_overrides_everything() {
1329        assert!(!should_release(&cfg_with(true, true), Status::Approved));
1330    }
1331
1332    #[test]
1333    fn nothing_is_released_when_worktrees_are_off() {
1334        assert!(!should_release(&cfg_with(false, false), Status::Approved));
1335    }
1336
1337    // -- round budget ----------------------------------------------------
1338
1339    /// A fresh PR gets rounds 1 through max_rounds.
1340    #[test]
1341    fn a_fresh_run_starts_at_one() {
1342        assert_eq!((1, 3), round_window(1, 3));
1343        assert_eq!((1, 5), round_window(1, 5));
1344    }
1345
1346    /// The budget is per invocation, not a lifetime cap. Running spar again on
1347    /// a PR that already spent five rounds gives it five more, because a person
1348    /// looked at it and chose to.
1349    #[test]
1350    fn a_resumed_run_gets_a_full_fresh_budget() {
1351        assert_eq!((6, 10), round_window(6, 5));
1352        assert_eq!((11, 13), round_window(11, 3));
1353    }
1354
1355    #[test]
1356    fn a_budget_of_one_is_a_single_round() {
1357        assert_eq!((6, 6), round_window(6, 1));
1358    }
1359
1360    #[test]
1361    fn round_numbers_keep_counting_across_sessions() {
1362        // Three sessions of three rounds each land on 1..3, 4..6, 7..9.
1363        let mut start = 1;
1364        let mut seen = Vec::new();
1365        for _ in 0..3 {
1366            let (first, last) = round_window(start, 3);
1367            seen.push((first, last));
1368            start = last + 1;
1369        }
1370        assert_eq!(vec![(1, 3), (4, 6), (7, 9)], seen);
1371    }
1372
1373    // -- the ledger ------------------------------------------------------
1374
1375    fn ledger_with(title: &str, file: &str) -> Ledger {
1376        let mut ledger = Ledger::new();
1377        ledger.insert(
1378            finding_key(title, file),
1379            LedgerEntry {
1380                title: title.into(),
1381                file: file.into(),
1382                reasoning: "no".into(),
1383                round: 1,
1384                reraised: 0,
1385            },
1386        );
1387        ledger
1388    }
1389
1390    #[test]
1391    fn a_point_refuted_and_re_raised_twice_escalates() {
1392        let mut ledger = ledger_with("nit about naming", "a.rs");
1393        let mut state = IssueRun::new(1, "t");
1394        let blocking = vec![finding("blocking", "nit about naming", "d", "a.rs", true)];
1395        assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
1396        assert!(check_relitigation(&mut ledger, &blocking, &mut state));
1397    }
1398
1399    #[test]
1400    fn an_untracked_finding_does_not_escalate() {
1401        let mut state = IssueRun::new(1, "t");
1402        let blocking = vec![finding("blocking", "brand new", "d", "a.rs", true)];
1403        assert!(!check_relitigation(
1404            &mut Ledger::new(),
1405            &blocking,
1406            &mut state
1407        ));
1408    }
1409
1410    /// The key a refutation records has to be the key the next round's finding
1411    /// hashes to. Recording it without the file made the guard dead code for
1412    /// every finding that named one, which is nearly all of them.
1413    #[test]
1414    fn a_refutation_lands_on_the_key_the_next_round_will_look_up() {
1415        let blocking = vec![finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
1416        let recorded = finding_key(&blocking[0].title, &blocking[0].file);
1417
1418        let matched = matching_finding(&blocking, "unbounded loop!").expect("should match");
1419        assert_eq!(recorded, finding_key("unbounded loop!", &matched.file));
1420    }
1421
1422    /// `matching_finding` ignores hyphens, dots, slashes, and underscores;
1423    /// `finding_key` keeps them. A disposition that differs only in those
1424    /// characters therefore matches its finding while hashing to a different
1425    /// key, so recording the author's wording made the guard track nothing.
1426    #[test]
1427    fn the_ledger_key_uses_the_reviewers_wording_not_the_authors() {
1428        let findings = vec![finding(
1429            "blocking",
1430            "Panic on multi-byte input",
1431            "d",
1432            "src/style.rs",
1433            true,
1434        )];
1435        let reworded = "Panic on multibyte input";
1436
1437        let source = matching_finding(&findings, reworded).expect("still matches");
1438        assert_ne!(
1439            finding_key(reworded, &source.file),
1440            finding_key(&source.title, &source.file),
1441            "the two spellings must genuinely hash apart, or this test proves nothing"
1442        );
1443
1444        // What apply_dispositions records, and what the next round looks up.
1445        let recorded = finding_key(&source.title, &source.file);
1446        let looked_up = finding_key(&findings[0].title, &findings[0].file);
1447        assert_eq!(recorded, looked_up);
1448    }
1449
1450    #[test]
1451    fn a_disposition_matches_its_finding_despite_wording_noise() {
1452        let findings = vec![finding(
1453            "blocking",
1454            "Unbounded loop!",
1455            "d",
1456            "src/x.rs",
1457            true,
1458        )];
1459        assert!(matching_finding(&findings, "unbounded loop").is_some());
1460        assert!(matching_finding(&findings, "something else").is_none());
1461    }
1462
1463    #[test]
1464    fn the_settled_block_is_empty_when_nothing_is_settled() {
1465        assert_eq!("", settled_block(&Ledger::new()));
1466    }
1467
1468    #[test]
1469    fn the_settled_block_names_each_refutation() {
1470        let block = settled_block(&ledger_with("a point", "x.rs"));
1471        assert!(block.contains("a point"));
1472        assert!(block.contains("settled"));
1473    }
1474
1475    // -- brevity ---------------------------------------------------------
1476
1477    #[test]
1478    /// No agent name, no round number, and no count of things listed below.
1479    /// The reader wants the review, not an account of who produced it.
1480    fn a_clean_review_is_just_the_verdict() {
1481        let text = review_comment("codex", 1, &review("Looks correct.", vec![]), &style());
1482        assert_eq!("Looks correct.", text);
1483    }
1484
1485    #[test]
1486    fn a_review_leads_with_the_counts() {
1487        let text = review_comment(
1488            "codex",
1489            2,
1490            &review(
1491                "One real problem.",
1492                vec![
1493                    finding(
1494                        "blocking",
1495                        "Loop never terminates",
1496                        "Confirmed by running it.",
1497                        "src/a.rs",
1498                        true,
1499                    ),
1500                    finding("non-blocking", "Name is vague", "d", "src/b.rs", true),
1501                    finding("nit", "Log wording", "d", "", true),
1502                ],
1503            ),
1504            &style(),
1505        );
1506        assert!(text.starts_with("One real problem."), "{text}");
1507        assert!(!text.contains("codex"), "no agent name: {text}");
1508        assert!(!text.contains("round 2"), "no round number: {text}");
1509    }
1510
1511    /// Only blocking findings carry their detail into the thread. Everything
1512    /// else is filed, and the detail belongs on the issue.
1513    #[test]
1514    fn only_blocking_findings_carry_their_detail() {
1515        let text = review_comment(
1516            "codex",
1517            1,
1518            &review(
1519                "s",
1520                vec![
1521                    finding("blocking", "Loop", "BLOCKING DETAIL", "a.rs", true),
1522                    finding("non-blocking", "Name", "NONBLOCKING DETAIL", "b.rs", true),
1523                ],
1524            ),
1525            &style(),
1526        );
1527        assert!(text.contains("BLOCKING DETAIL"), "{text}");
1528        assert!(!text.contains("NONBLOCKING DETAIL"), "{text}");
1529    }
1530
1531    #[test]
1532    /// A finding's explanation is what the author acts on. Cutting it to save
1533    /// characters leaves them nothing to act on and saves nothing worth having.
1534    fn a_thorough_explanation_reaches_the_author_intact() {
1535        let detail = "Reproduced by running the 429 test with max_attempts unset. ".repeat(8);
1536        let text = review_comment(
1537            "codex",
1538            1,
1539            &review(
1540                "One problem.",
1541                vec![finding("blocking", "T", &detail, "a.rs", true)],
1542            ),
1543            &style(),
1544        );
1545        assert!(
1546            text.contains(detail.trim()),
1547            "the explanation was cut:\n{text}"
1548        );
1549    }
1550
1551    /// A runaway is still bounded, just nowhere near tightly.
1552    #[test]
1553    fn a_runaway_model_is_still_bounded() {
1554        let long = "filler words. ".repeat(20_000);
1555        let text = review_comment(
1556            "codex",
1557            1,
1558            &review(&long, vec![finding("blocking", "T", &long, "a.rs", true)]),
1559            &style(),
1560        );
1561        assert!(text.len() < 8000, "review comment was {} chars", text.len());
1562    }
1563
1564    #[test]
1565    fn a_general_finding_has_no_empty_parenthesis() {
1566        let text = review_comment(
1567            "codex",
1568            1,
1569            &review("s", vec![finding("blocking", "Something", "d", "", true)]),
1570            &style(),
1571        );
1572        assert!(!text.contains("()"), "{text}");
1573        assert!(!text.contains("(general)"), "{text}");
1574    }
1575
1576    #[test]
1577    fn out_of_scope_findings_are_counted_separately() {
1578        let text = review_comment(
1579            "codex",
1580            1,
1581            &review(
1582                "s",
1583                vec![finding("blocking", "Old bug", "d", "a.rs", false)],
1584            ),
1585            &style(),
1586        );
1587        assert!(text.contains("out of scope"), "{text}");
1588        assert!(text.contains("Old bug"), "{text}");
1589    }
1590
1591    #[test]
1592    fn a_disposition_comment_leads_with_counts_and_keeps_refutations() {
1593        let response = ResponseDoc {
1594            summary: "Two of three were right.".into(),
1595            dispositions: vec![],
1596        };
1597        let text = disposition_comment(
1598            "claude",
1599            &response,
1600            &["Fixed thing".to_string()],
1601            &["Wrong thing. Because the caller already checks.".to_string()],
1602            &[],
1603            &style(),
1604        )
1605        .unwrap();
1606        assert!(text.starts_with("Two of three were right."), "{text}");
1607        assert!(!text.contains("claude"), "no agent name: {text}");
1608        assert!(
1609            text.contains("Because the caller already checks."),
1610            "{text}"
1611        );
1612    }
1613
1614    #[test]
1615    fn an_empty_disposition_comment_is_not_posted() {
1616        let response = ResponseDoc {
1617            summary: "s".into(),
1618            dispositions: vec![],
1619        };
1620        assert!(disposition_comment("claude", &response, &[], &[], &[], &style()).is_none());
1621    }
1622
1623    #[test]
1624    /// Two parts, not three. GitHub renders the file count and the plus and
1625    /// minus figures in the header, immediately above whatever spar writes.
1626    fn a_pr_body_is_what_it_closes_and_what_changed() {
1627        let body = pr_body(42, "Retry on a 429 instead of failing.", &style());
1628        assert_eq!("Closes #42\n\nRetry on a 429 instead of failing.", body);
1629    }
1630
1631    #[test]
1632    fn a_pr_body_survives_a_missing_summary_and_diffstat() {
1633        assert_eq!("Closes #7", pr_body(7, "", &style()));
1634    }
1635
1636    #[test]
1637    fn the_summary_line_is_lifted_out_of_the_final_message() {
1638        let out = "I did some work.\n\nSUMMARY: Retry on a 429 instead of failing.\n";
1639        assert_eq!(
1640            Some("Retry on a 429 instead of failing.".to_string()),
1641            extract_summary(out)
1642        );
1643    }
1644
1645    #[test]
1646    fn a_decorated_summary_line_still_parses() {
1647        assert_eq!(
1648            Some("Did a thing.".to_string()),
1649            extract_summary("**SUMMARY:** Did a thing.")
1650        );
1651    }
1652
1653    #[test]
1654    fn a_missing_summary_line_is_none() {
1655        assert_eq!(None, extract_summary("no marker here"));
1656    }
1657
1658    #[test]
1659    fn the_last_summary_line_wins() {
1660        let out = "SUMMARY: first draft\nmore work\nSUMMARY: final answer";
1661        assert_eq!(Some("final answer".to_string()), extract_summary(out));
1662    }
1663
1664    #[test]
1665    fn a_skip_comment_is_only_the_reasoning() {
1666        let item = SkippedItem {
1667            issue: 3,
1668            title: "t".into(),
1669            reasons: [
1670                ("claude".to_string(), "Already fixed in 1.2.".to_string()),
1671                ("codex".to_string(), "Duplicate of #2.".to_string()),
1672            ]
1673            .into_iter()
1674            .collect(),
1675        };
1676        let text = skip_comment(&item, &style());
1677        assert!(text.contains("Already fixed in 1.2."), "{text}");
1678        assert!(text.contains("Duplicate of #2."), "{text}");
1679        assert!(
1680            !text.contains("claude") && !text.contains("codex"),
1681            "{text}"
1682        );
1683        assert!(!text.to_lowercase().contains("not scheduled"), "{text}");
1684        assert!(text.lines().count() <= 3, "{text}");
1685    }
1686
1687    #[test]
1688    fn findings_for_a_model_keep_full_detail() {
1689        let long = "x".repeat(2000);
1690        let text = findings_for_prompt(&[finding("blocking", "T", &long, "a.rs", true)]);
1691        assert!(
1692            text.contains(&long),
1693            "a model needs the whole finding, only humans need brevity"
1694        );
1695    }
1696
1697    #[test]
1698    fn findings_for_a_model_are_never_empty() {
1699        assert_eq!("(none)", findings_for_prompt(&[]));
1700    }
1701}
1702
1703#[cfg(test)]
1704mod outcome_tests {
1705    use super::*;
1706    use crate::model::{Dispute, Severity};
1707
1708    fn style() -> Style {
1709        Style::default()
1710    }
1711
1712    fn state_with(disputes: Vec<(&str, &str)>, filed: Vec<&str>) -> IssueRun {
1713        let mut s = IssueRun::new(482, "t");
1714        s.disputes = disputes
1715            .into_iter()
1716            .map(|(title, reasoning)| Dispute {
1717                title: title.into(),
1718                reasoning: reasoning.into(),
1719            })
1720            .collect();
1721        s.filed = filed.into_iter().map(String::from).collect();
1722        s
1723    }
1724
1725    fn finding(title: &str, file: &str) -> Finding {
1726        Finding {
1727            severity: Severity::Blocking,
1728            title: title.into(),
1729            detail: "d".into(),
1730            file: file.into(),
1731            in_scope: true,
1732        }
1733    }
1734
1735    /// The absence of objections is the message. A PR that reviewed cleanly and
1736    /// filed nothing should leave no trace in the thread at all.
1737    #[test]
1738    fn a_clean_approval_says_nothing() {
1739        let state = state_with(vec![], vec![]);
1740        assert!(outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).is_none());
1741    }
1742
1743    #[test]
1744    fn an_approval_that_filed_follow_ups_links_them() {
1745        let state = state_with(
1746            vec![],
1747            vec![
1748                "https://github.com/you/thing/issues/485",
1749                "https://github.com/you/thing/issues/486",
1750            ],
1751        );
1752        let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
1753        assert!(text.contains("Filed separately: #485, #486"), "{text}");
1754    }
1755
1756    /// The real PR ended with "5 fixed" followed by "no convergence", which
1757    /// reads as a contradiction. What a maintainer needs is that the fixes went
1758    /// in and nobody checked them.
1759    #[test]
1760    fn running_out_of_rounds_says_what_that_means_for_the_reader() {
1761        let state = state_with(vec![], vec![]);
1762        let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
1763        assert!(text.contains("has not been reviewed"), "{text}");
1764        assert!(
1765            !text.to_lowercase().contains("round 3"),
1766            "no round numbers: {text}"
1767        );
1768        assert!(!text.to_lowercase().contains("convergence"), "{text}");
1769    }
1770
1771    #[test]
1772    fn a_deadlock_names_the_point_they_could_not_settle() {
1773        let state = state_with(vec![], vec![]);
1774        let points = [finding("Retry loop never terminates", "src/net.rs:88")];
1775        let text = outcome_comment(
1776            &state,
1777            &Ledger::new(),
1778            &Ending::Deadlocked(&points),
1779            &style(),
1780        )
1781        .unwrap();
1782        assert!(
1783            text.contains("Retry loop never terminates (src/net.rs:88)"),
1784            "{text}"
1785        );
1786        assert!(text.contains("could not settle"), "{text}");
1787    }
1788
1789    /// The diff records what was fixed. Nothing records what was argued down.
1790    #[test]
1791    fn refutations_survive_because_nothing_else_carries_them() {
1792        let state = state_with(
1793            vec![(
1794                "Error is swallowed",
1795                "the caller already validates the file",
1796            )],
1797            vec![],
1798        );
1799        let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
1800        assert!(text.contains("Raised and refuted:"), "{text}");
1801        assert!(
1802            text.contains("The caller already validates the file"),
1803            "{text}"
1804        );
1805    }
1806
1807    #[test]
1808    fn no_agent_names_counts_or_round_numbers_reach_the_thread() {
1809        let state = state_with(
1810            vec![("A point", "a reason")],
1811            vec!["https://github.com/you/thing/issues/485"],
1812        );
1813        for ending in [Ending::Approved, Ending::OutOfRounds] {
1814            let text = outcome_comment(&state, &Ledger::new(), &ending, &style()).unwrap();
1815            let lower = text.to_lowercase();
1816            for banned in ["claude", "codex", "blocking,", "nit,", " fixed."] {
1817                assert!(
1818                    !lower.contains(banned),
1819                    "{banned:?} leaked into the thread:\n{text}"
1820                );
1821            }
1822            // "the last round of fixes" is prose. "round 3" is narration.
1823            for n in 1..9 {
1824                assert!(
1825                    !lower.contains(&format!("round {n}")),
1826                    "a round number leaked into the thread:\n{text}"
1827                );
1828            }
1829        }
1830    }
1831
1832    #[test]
1833    /// A refutation is an argument, and an argument that stops mid clause is
1834    /// not one. Bounded, but with room to make the case.
1835    fn a_refutation_is_allowed_to_make_its_case() {
1836        let reasoning = "The caller validates against the schema first. \
1837                         The discarded error is therefore unreachable in practice. ";
1838        let state = state_with(
1839            vec![("A point", &reasoning.repeat(6))],
1840            vec!["https://github.com/you/thing/issues/485"],
1841        );
1842        let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
1843        assert!(
1844            !text.contains("..."),
1845            "nothing was cut mid thought:\n{text}"
1846        );
1847        assert!(text.len() < 4000, "{} chars", text.len());
1848    }
1849
1850    #[test]
1851    fn a_url_that_is_not_an_issue_link_is_left_alone() {
1852        assert_eq!(
1853            "#485",
1854            as_reference("https://github.com/you/thing/issues/485")
1855        );
1856        assert_eq!("note: something", as_reference("note: something"));
1857    }
1858}
1859
1860#[cfg(test)]
1861mod filed_reference_tests {
1862    use super::*;
1863
1864    #[test]
1865    fn an_issue_url_yields_its_number() {
1866        assert_eq!(
1867            Some(485),
1868            filed_issue_number("https://github.com/you/thing/issues/485")
1869        );
1870    }
1871
1872    /// Local mode records a note rather than a URL, and a run with
1873    /// followups = "local" must not try to absorb it as an issue.
1874    #[test]
1875    fn a_local_note_yields_nothing() {
1876        assert_eq!(None, filed_issue_number("note: Retry is unbounded"));
1877        assert_eq!(None, filed_issue_number(""));
1878        assert_eq!(
1879            None,
1880            filed_issue_number("https://github.com/you/thing/issues/")
1881        );
1882    }
1883}
1884
1885#[cfg(test)]
1886mod followup_restraint_tests {
1887    use super::*;
1888    use crate::model::Severity;
1889
1890    fn cfg_with(followups: Followups, non_blocking: bool, nits: bool, cap: usize) -> Config {
1891        let mut cfg =
1892            crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
1893                .unwrap();
1894        cfg.loop_cfg.followups = followups;
1895        cfg.loop_cfg.file_non_blocking = non_blocking;
1896        cfg.loop_cfg.file_nits = nits;
1897        cfg.loop_cfg.max_followups = cap;
1898        cfg
1899    }
1900
1901    fn finding(severity: Severity, title: &str, in_scope: bool) -> Finding {
1902        Finding {
1903            severity,
1904            title: title.into(),
1905            detail: "d".into(),
1906            file: "a.rs".into(),
1907            in_scope,
1908        }
1909    }
1910
1911    /// The defaults are what let one issue spawn ten, which spawned more. A
1912    /// thorough reviewer always finds improvements; not gating a merge is not
1913    /// the same as deserving somebody's triage queue.
1914    #[test]
1915    fn a_non_blocking_finding_is_not_a_tracker_item_by_default() {
1916        let cfg = cfg_with(Followups::Issues, false, false, 5);
1917        assert!(!cfg.loop_cfg.file_non_blocking);
1918        assert!(!cfg.loop_cfg.file_nits);
1919    }
1920
1921    #[test]
1922    fn follow_ups_stay_off_the_tracker_by_default() {
1923        let cfg =
1924            crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
1925                .unwrap();
1926        assert_eq!(
1927            Followups::Local,
1928            cfg.loop_cfg.followups,
1929            "the tracker is somebody's queue; the default must not write to it"
1930        );
1931        assert_eq!(5, cfg.loop_cfg.max_followups);
1932    }
1933
1934    /// Which severities survive the filter, at the defaults and when opened up.
1935    #[test]
1936    fn only_out_of_scope_defects_qualify_at_the_defaults() {
1937        let cfg = cfg_with(Followups::Issues, false, false, 5);
1938        let qualifies = |f: &Finding| match f.severity {
1939            Severity::NonBlocking => cfg.loop_cfg.file_non_blocking && f.in_scope,
1940            Severity::Nit => cfg.loop_cfg.file_nits && f.in_scope,
1941            Severity::Blocking => false,
1942        } || !f.in_scope;
1943
1944        assert!(qualifies(&finding(
1945            Severity::Blocking,
1946            "pre-existing",
1947            false
1948        )));
1949        assert!(!qualifies(&finding(
1950            Severity::NonBlocking,
1951            "improvement",
1952            true
1953        )));
1954        assert!(!qualifies(&finding(Severity::Nit, "taste", true)));
1955        assert!(!qualifies(&finding(
1956            Severity::Blocking,
1957            "fix it here",
1958            true
1959        )));
1960    }
1961
1962    #[test]
1963    fn opening_it_up_lets_non_blocking_findings_through_again() {
1964        let cfg = cfg_with(Followups::Issues, true, false, 5);
1965        assert!(cfg.loop_cfg.file_non_blocking);
1966    }
1967
1968    /// A run that will not stop finding things is stopped, and says so.
1969    #[test]
1970    fn the_cap_is_a_real_backstop() {
1971        let cfg = cfg_with(Followups::Issues, false, false, 3);
1972        let mut state = IssueRun::new(1, "t");
1973        state.filed = (0..3).map(|n| format!("url{n}")).collect();
1974        assert!(state.filed.len() >= cfg.loop_cfg.max_followups);
1975    }
1976
1977    /// The number that matters. Reviewing one issue produced ten follow-ups on
1978    /// a real repository, each of which could be run in turn: mean offspring
1979    /// above one never terminates.
1980    #[test]
1981    fn the_cap_bounds_what_one_run_can_spawn() {
1982        let cfg = cfg_with(Followups::Issues, false, false, 5);
1983        assert!(
1984            cfg.loop_cfg.max_followups <= 5,
1985            "a run that can file ten follow-ups is a branching process"
1986        );
1987    }
1988}