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