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    fn a_verbose_model_is_clipped_not_forwarded() {
1502        let long_summary = "This is a very thorough summary. ".repeat(40);
1503        let long_detail = "Here is an extremely long explanation. ".repeat(40);
1504        let text = review_comment(
1505            "codex",
1506            1,
1507            &review(
1508                &long_summary,
1509                vec![finding("blocking", "T", &long_detail, "a.rs", true)],
1510            ),
1511            &style(),
1512        );
1513        assert!(
1514            text.len() < 900,
1515            "review comment was {} chars:\n{text}",
1516            text.len()
1517        );
1518    }
1519
1520    #[test]
1521    fn a_general_finding_has_no_empty_parenthesis() {
1522        let text = review_comment(
1523            "codex",
1524            1,
1525            &review("s", vec![finding("blocking", "Something", "d", "", true)]),
1526            &style(),
1527        );
1528        assert!(!text.contains("()"), "{text}");
1529        assert!(!text.contains("(general)"), "{text}");
1530    }
1531
1532    #[test]
1533    fn out_of_scope_findings_are_counted_separately() {
1534        let text = review_comment(
1535            "codex",
1536            1,
1537            &review(
1538                "s",
1539                vec![finding("blocking", "Old bug", "d", "a.rs", false)],
1540            ),
1541            &style(),
1542        );
1543        assert!(text.contains("out of scope"), "{text}");
1544        assert!(text.contains("Old bug"), "{text}");
1545    }
1546
1547    #[test]
1548    fn a_disposition_comment_leads_with_counts_and_keeps_refutations() {
1549        let response = ResponseDoc {
1550            summary: "Two of three were right.".into(),
1551            dispositions: vec![],
1552        };
1553        let text = disposition_comment(
1554            "claude",
1555            &response,
1556            &["Fixed thing".to_string()],
1557            &["Wrong thing. Because the caller already checks.".to_string()],
1558            &[],
1559            &style(),
1560        )
1561        .unwrap();
1562        assert!(text.starts_with("Two of three were right."), "{text}");
1563        assert!(!text.contains("claude"), "no agent name: {text}");
1564        assert!(
1565            text.contains("Because the caller already checks."),
1566            "{text}"
1567        );
1568    }
1569
1570    #[test]
1571    fn an_empty_disposition_comment_is_not_posted() {
1572        let response = ResponseDoc {
1573            summary: "s".into(),
1574            dispositions: vec![],
1575        };
1576        assert!(disposition_comment("claude", &response, &[], &[], &[], &style()).is_none());
1577    }
1578
1579    #[test]
1580    /// Two parts, not three. GitHub renders the file count and the plus and
1581    /// minus figures in the header, immediately above whatever spar writes.
1582    fn a_pr_body_is_what_it_closes_and_what_changed() {
1583        let body = pr_body(42, "Retry on a 429 instead of failing.", &style());
1584        assert_eq!("Closes #42\n\nRetry on a 429 instead of failing.", body);
1585    }
1586
1587    #[test]
1588    fn a_pr_body_survives_a_missing_summary_and_diffstat() {
1589        assert_eq!("Closes #7", pr_body(7, "", &style()));
1590    }
1591
1592    #[test]
1593    fn the_summary_line_is_lifted_out_of_the_final_message() {
1594        let out = "I did some work.\n\nSUMMARY: Retry on a 429 instead of failing.\n";
1595        assert_eq!(
1596            Some("Retry on a 429 instead of failing.".to_string()),
1597            extract_summary(out)
1598        );
1599    }
1600
1601    #[test]
1602    fn a_decorated_summary_line_still_parses() {
1603        assert_eq!(
1604            Some("Did a thing.".to_string()),
1605            extract_summary("**SUMMARY:** Did a thing.")
1606        );
1607    }
1608
1609    #[test]
1610    fn a_missing_summary_line_is_none() {
1611        assert_eq!(None, extract_summary("no marker here"));
1612    }
1613
1614    #[test]
1615    fn the_last_summary_line_wins() {
1616        let out = "SUMMARY: first draft\nmore work\nSUMMARY: final answer";
1617        assert_eq!(Some("final answer".to_string()), extract_summary(out));
1618    }
1619
1620    #[test]
1621    fn a_skip_comment_is_only_the_reasoning() {
1622        let item = SkippedItem {
1623            issue: 3,
1624            title: "t".into(),
1625            reasons: [
1626                ("claude".to_string(), "Already fixed in 1.2.".to_string()),
1627                ("codex".to_string(), "Duplicate of #2.".to_string()),
1628            ]
1629            .into_iter()
1630            .collect(),
1631        };
1632        let text = skip_comment(&item, &style());
1633        assert!(text.contains("Already fixed in 1.2."), "{text}");
1634        assert!(text.contains("Duplicate of #2."), "{text}");
1635        assert!(
1636            !text.contains("claude") && !text.contains("codex"),
1637            "{text}"
1638        );
1639        assert!(!text.to_lowercase().contains("not scheduled"), "{text}");
1640        assert!(text.lines().count() <= 3, "{text}");
1641    }
1642
1643    #[test]
1644    fn findings_for_a_model_keep_full_detail() {
1645        let long = "x".repeat(2000);
1646        let text = findings_for_prompt(&[finding("blocking", "T", &long, "a.rs", true)]);
1647        assert!(
1648            text.contains(&long),
1649            "a model needs the whole finding, only humans need brevity"
1650        );
1651    }
1652
1653    #[test]
1654    fn findings_for_a_model_are_never_empty() {
1655        assert_eq!("(none)", findings_for_prompt(&[]));
1656    }
1657}
1658
1659#[cfg(test)]
1660mod outcome_tests {
1661    use super::*;
1662    use crate::model::{Dispute, Severity};
1663
1664    fn style() -> Style {
1665        Style::default()
1666    }
1667
1668    fn state_with(disputes: Vec<(&str, &str)>, filed: Vec<&str>) -> IssueRun {
1669        let mut s = IssueRun::new(482, "t");
1670        s.disputes = disputes
1671            .into_iter()
1672            .map(|(title, reasoning)| Dispute {
1673                title: title.into(),
1674                reasoning: reasoning.into(),
1675            })
1676            .collect();
1677        s.filed = filed.into_iter().map(String::from).collect();
1678        s
1679    }
1680
1681    fn finding(title: &str, file: &str) -> Finding {
1682        Finding {
1683            severity: Severity::Blocking,
1684            title: title.into(),
1685            detail: "d".into(),
1686            file: file.into(),
1687            in_scope: true,
1688        }
1689    }
1690
1691    /// The absence of objections is the message. A PR that reviewed cleanly and
1692    /// filed nothing should leave no trace in the thread at all.
1693    #[test]
1694    fn a_clean_approval_says_nothing() {
1695        let state = state_with(vec![], vec![]);
1696        assert!(outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).is_none());
1697    }
1698
1699    #[test]
1700    fn an_approval_that_filed_follow_ups_links_them() {
1701        let state = state_with(
1702            vec![],
1703            vec![
1704                "https://github.com/you/thing/issues/485",
1705                "https://github.com/you/thing/issues/486",
1706            ],
1707        );
1708        let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
1709        assert!(text.contains("Filed separately: #485, #486"), "{text}");
1710    }
1711
1712    /// The real PR ended with "5 fixed" followed by "no convergence", which
1713    /// reads as a contradiction. What a maintainer needs is that the fixes went
1714    /// in and nobody checked them.
1715    #[test]
1716    fn running_out_of_rounds_says_what_that_means_for_the_reader() {
1717        let state = state_with(vec![], vec![]);
1718        let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
1719        assert!(text.contains("has not been reviewed"), "{text}");
1720        assert!(
1721            !text.to_lowercase().contains("round 3"),
1722            "no round numbers: {text}"
1723        );
1724        assert!(!text.to_lowercase().contains("convergence"), "{text}");
1725    }
1726
1727    #[test]
1728    fn a_deadlock_names_the_point_they_could_not_settle() {
1729        let state = state_with(vec![], vec![]);
1730        let points = [finding("Retry loop never terminates", "src/net.rs:88")];
1731        let text = outcome_comment(
1732            &state,
1733            &Ledger::new(),
1734            &Ending::Deadlocked(&points),
1735            &style(),
1736        )
1737        .unwrap();
1738        assert!(
1739            text.contains("Retry loop never terminates (src/net.rs:88)"),
1740            "{text}"
1741        );
1742        assert!(text.contains("could not settle"), "{text}");
1743    }
1744
1745    /// The diff records what was fixed. Nothing records what was argued down.
1746    #[test]
1747    fn refutations_survive_because_nothing_else_carries_them() {
1748        let state = state_with(
1749            vec![(
1750                "Error is swallowed",
1751                "the caller already validates the file",
1752            )],
1753            vec![],
1754        );
1755        let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
1756        assert!(text.contains("Raised and refuted:"), "{text}");
1757        assert!(
1758            text.contains("The caller already validates the file"),
1759            "{text}"
1760        );
1761    }
1762
1763    #[test]
1764    fn no_agent_names_counts_or_round_numbers_reach_the_thread() {
1765        let state = state_with(
1766            vec![("A point", "a reason")],
1767            vec!["https://github.com/you/thing/issues/485"],
1768        );
1769        for ending in [Ending::Approved, Ending::OutOfRounds] {
1770            let text = outcome_comment(&state, &Ledger::new(), &ending, &style()).unwrap();
1771            let lower = text.to_lowercase();
1772            for banned in ["claude", "codex", "blocking,", "nit,", " fixed."] {
1773                assert!(
1774                    !lower.contains(banned),
1775                    "{banned:?} leaked into the thread:\n{text}"
1776                );
1777            }
1778            // "the last round of fixes" is prose. "round 3" is narration.
1779            for n in 1..9 {
1780                assert!(
1781                    !lower.contains(&format!("round {n}")),
1782                    "a round number leaked into the thread:\n{text}"
1783                );
1784            }
1785        }
1786    }
1787
1788    #[test]
1789    fn the_whole_comment_stays_short() {
1790        let state = state_with(
1791            vec![("A point", &"long reasoning ".repeat(40))],
1792            vec!["https://github.com/you/thing/issues/485"],
1793        );
1794        let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
1795        assert!(text.len() < 600, "{} chars:\n{text}", text.len());
1796    }
1797
1798    #[test]
1799    fn a_url_that_is_not_an_issue_link_is_left_alone() {
1800        assert_eq!(
1801            "#485",
1802            as_reference("https://github.com/you/thing/issues/485")
1803        );
1804        assert_eq!("note: something", as_reference("note: something"));
1805    }
1806}
1807
1808#[cfg(test)]
1809mod filed_reference_tests {
1810    use super::*;
1811
1812    #[test]
1813    fn an_issue_url_yields_its_number() {
1814        assert_eq!(
1815            Some(485),
1816            filed_issue_number("https://github.com/you/thing/issues/485")
1817        );
1818    }
1819
1820    /// Local mode records a note rather than a URL, and a run with
1821    /// followups = "local" must not try to absorb it as an issue.
1822    #[test]
1823    fn a_local_note_yields_nothing() {
1824        assert_eq!(None, filed_issue_number("note: Retry is unbounded"));
1825        assert_eq!(None, filed_issue_number(""));
1826        assert_eq!(
1827            None,
1828            filed_issue_number("https://github.com/you/thing/issues/")
1829        );
1830    }
1831}