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