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    match file_as_issue(repo, &title, &body) {
853        Ok(filed) => filed.url().map(str::to_string),
854        Err(e) => {
855            logdim!("could not file a follow-up for '{title}': {e}");
856            None
857        }
858    }
859}
860
861/// What happened to one finding on the way to the tracker.
862#[derive(Debug, Clone)]
863pub enum Filed {
864    /// A new issue.
865    Opened(i64, String),
866    /// An open issue already covered it, and this pass had something to add.
867    AddedTo(i64, String),
868    /// An open issue already covered it, and this pass added nothing.
869    Covered(i64, String),
870    /// A closed issue already covered it. Nothing was written.
871    AlreadyClosed(i64, String),
872}
873
874impl Filed {
875    pub fn url(&self) -> Option<&str> {
876        match self {
877            Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => Some(url),
878            // The work is done and closed. Reporting it as filed would put it
879            // back into a wave to be implemented again.
880            Filed::AlreadyClosed(_, _) => None,
881        }
882    }
883
884    /// The issue to work, when there is one to work.
885    pub fn number(&self) -> Option<i64> {
886        match self {
887            Filed::Opened(n, _) | Filed::AddedTo(n, _) | Filed::Covered(n, _) => Some(*n),
888            Filed::AlreadyClosed(_, _) => None,
889        }
890    }
891
892    /// One clause saying where it went, for a log line or an archive entry.
893    pub fn note(&self) -> String {
894        match self {
895            Filed::Opened(n, _) => format!("#{n}"),
896            Filed::AddedTo(n, _) => format!("added to #{n}"),
897            Filed::Covered(n, _) => format!("#{n} already says this"),
898            Filed::AlreadyClosed(n, _) => format!("#{n} covers it and is closed"),
899        }
900    }
901
902    pub fn describe(&self, title: &str) -> String {
903        let title = style::clip(title.trim(), 80);
904        match self {
905            Filed::Opened(n, _) => format!("filed #{n}: {title}"),
906            Filed::AddedTo(n, _) => format!("added to #{n}: {title}"),
907            Filed::Covered(n, _) => format!("#{n} already says this: {title}"),
908            Filed::AlreadyClosed(n, _) => format!("#{n} covers it and is closed: {title}"),
909        }
910    }
911}
912
913/// File an issue, or add to the one that already covers it.
914///
915/// Exact title matching let duplicates through: two agents, or two runs a week
916/// apart, never word one defect identically, and a real run filed two that had
917/// to be closed by hand. Filing a second copy is the complaint; silently
918/// dropping the new wording is not much better, because a later pass often
919/// carries evidence the first did not.
920///
921/// The title arrives cleaned by the caller, and it has to: searching for
922/// anything but the exact string that will land on GitHub means the duplicate
923/// check can never hit.
924pub fn file_as_issue(repo: &Repo, title: &str, body: &str) -> Result<Filed> {
925    let title = repo.clean_title(title)?;
926    if title.trim().is_empty() {
927        return Err(spar_err!("nothing left of the title after cleaning it"));
928    }
929    if let Some(existing) = repo.find_similar_issue(&title, body) {
930        let known = format!("{} {}", existing.title, existing.body);
931        if !existing.open {
932            return Ok(Filed::AlreadyClosed(existing.number, existing.url));
933        }
934        if crate::textsim::adds_information(body, &known) {
935            repo.comment_issue(existing.number, body)?;
936            return Ok(Filed::AddedTo(existing.number, existing.url));
937        }
938        return Ok(Filed::Covered(existing.number, existing.url));
939    }
940    let url = repo.create_issue(&title, body)?;
941    let number = filed_issue_number(&url)
942        .ok_or_else(|| spar_err!("filed an issue but could not read its number from {url}"))?;
943    Ok(Filed::Opened(number, url))
944}
945
946fn file_out_of_scope(
947    repo: &Repo,
948    findings: &[Finding],
949    subject: i64,
950    state: &mut IssueRun,
951    cfg: &Config,
952) {
953    for finding in findings.iter().filter(|f| !f.in_scope) {
954        let body = issue_report(finding);
955        if let Some(url) = file_followup(repo, &finding.title, &body, subject, cfg, state) {
956            state.filed.push(url);
957        }
958    }
959}
960
961/// A finding written as a bug report, when it carries the parts of one.
962///
963/// The thread gets one line; an issue gets the whole thing under headings, in
964/// the order somebody reads a bug report: what is wrong, how to see it, what it
965/// costs, what it should do instead. A finding with none of those falls back to
966/// its detail, which is every finding that was never going to be filed.
967pub fn issue_report(finding: &Finding) -> String {
968    let sections = finding.report_sections();
969    if sections.is_empty() {
970        return finding.detail.clone();
971    }
972    let mut out: Vec<String> = sections
973        .iter()
974        .map(|(heading, text)| format!("## {heading}\n\n{text}"))
975        .collect();
976    // Keep the one line summary when it says something the sections do not,
977    // rather than dropping it or repeating it.
978    if !finding.detail.trim().is_empty()
979        && !sections
980            .iter()
981            .any(|(_, text)| crate::textsim::same_point(text, &finding.detail))
982    {
983        out.insert(0, finding.detail.trim().to_string());
984    }
985    out.join("\n\n")
986}
987
988/// Non-blocking findings become follow-ups so they do not gate the merge.
989///
990/// Nits are excluded by default. On a shared repository a filed nit is somebody
991/// else's notification and somebody else's triage queue: an early run on a
992/// production codebase opened an issue titled "Log wording". Worth saying in
993/// the PR thread, not worth an issue.
994fn file_nonblocking(
995    repo: &Repo,
996    findings: &[Finding],
997    subject: i64,
998    state: &mut IssueRun,
999    cfg: &Config,
1000) {
1001    for finding in findings {
1002        let keep = match finding.severity {
1003            Severity::NonBlocking => cfg.loop_cfg.file_non_blocking,
1004            Severity::Nit => cfg.loop_cfg.file_nits,
1005            Severity::Blocking => false,
1006        };
1007        if !keep || !finding.in_scope {
1008            continue;
1009        }
1010        if let Some(url) = file_followup(repo, &finding.title, &finding.detail, subject, cfg, state)
1011        {
1012            state.filed.push(url);
1013        }
1014    }
1015}
1016
1017// ---------------------------------------------------------------------------
1018// What a human actually reads
1019// ---------------------------------------------------------------------------
1020//
1021// spar composes every comment itself from structured fields, rather than
1022// forwarding whatever prose a model produced. That is the only reliable way to
1023// keep a PR thread readable: the model supplies facts, the harness supplies the
1024// shape, and each field is held to a budget on the way out.
1025
1026fn bullets(lines: &[String]) -> String {
1027    lines
1028        .iter()
1029        .map(|l| format!("- {l}"))
1030        .collect::<Vec<_>>()
1031        .join("\n")
1032}
1033
1034fn located(finding: &Finding, style: &Style) -> String {
1035    let title = style::title(&finding.title, style);
1036    match finding.where_at() {
1037        "general" => title,
1038        file => format!("{title} ({file})"),
1039    }
1040}
1041
1042/// How the run ended, which is the only thing about the run a reader needs.
1043pub enum Ending<'a> {
1044    /// Nothing blocks a merge.
1045    Approved,
1046    /// The round budget ran out. The last round's fixes were pushed but never
1047    /// reviewed, which is the part a maintainer has to know.
1048    OutOfRounds,
1049    /// A point was refuted and raised again anyway. Nobody is going to break
1050    /// the tie but a person.
1051    Deadlocked(&'a [Finding]),
1052}
1053
1054/// Post the one comment a run leaves behind, if it has anything to say.
1055///
1056/// Everything spar used to write here was an account of its own working: which
1057/// agent spoke, which round it was, how many findings of each severity, that it
1058/// had stopped. None of that is about the code. Worse, the running commentary
1059/// could contradict itself, ending a thread with "5 fixed" immediately followed
1060/// by "no convergence", which reads as a failure rather than as fixes nobody
1061/// has checked yet.
1062///
1063/// So the loop is silent and this says what is left: what is unresolved, what
1064/// was argued down, and where the follow-ups went.
1065pub fn post_outcome(
1066    repo: &Repo,
1067    pr_number: i64,
1068    state: &IssueRun,
1069    ledger: &Ledger,
1070    ending: Ending<'_>,
1071) {
1072    if repo.style.pr_comments != PrComments::Outcome {
1073        return;
1074    }
1075    let Some(text) = outcome_comment(state, ledger, &ending, &repo.style) else {
1076        return;
1077    };
1078    if let Err(e) = repo.comment_pr(pr_number, &text) {
1079        logdim!("could not post the outcome comment: {e}");
1080    }
1081}
1082
1083/// Why a point was refuted: this run's disputes first, then the ledger, which
1084/// is what survives across a resume.
1085fn refutation_of(finding: &Finding, state: &IssueRun, ledger: &Ledger) -> Option<String> {
1086    if let Some(d) = state
1087        .disputes
1088        .iter()
1089        .find(|d| same_point(&d.title, &finding.title))
1090    {
1091        if !d.reasoning.trim().is_empty() {
1092            return Some(d.reasoning.clone());
1093        }
1094    }
1095    ledger
1096        .get(&finding_key(&finding.title, &finding.file))
1097        .map(|entry| entry.reasoning.clone())
1098        .filter(|r| !r.trim().is_empty())
1099}
1100
1101/// `#123` from a filed issue URL, falling back to the URL when it does not look
1102/// like one. Shorter, and GitHub renders it as a link either way.
1103/// The issue number a filed follow-up URL points at, when it is one. Local
1104/// notes and anything unparseable yield nothing.
1105pub fn filed_issue_number(filed: &str) -> Option<i64> {
1106    filed
1107        .rsplit('/')
1108        .next()
1109        .and_then(|tail| tail.parse::<i64>().ok())
1110        .filter(|n| *n > 0)
1111}
1112
1113fn as_reference(url: &str) -> String {
1114    match url.rsplit('/').next().and_then(|n| n.parse::<u64>().ok()) {
1115        Some(number) => format!("#{number}"),
1116        None => url.to_string(),
1117    }
1118}
1119
1120pub fn outcome_comment(
1121    state: &IssueRun,
1122    ledger: &Ledger,
1123    ending: &Ending<'_>,
1124    style: &Style,
1125) -> Option<String> {
1126    let mut out: Vec<String> = Vec::new();
1127    // Points rendered in the deadlock block, so the refutation list below does
1128    // not print the same title a second time.
1129    let mut already: Vec<String> = Vec::new();
1130
1131    match ending {
1132        Ending::Approved => {
1133            if state.disputes.is_empty() && state.filed.is_empty() {
1134                // A clean approval with nothing outstanding needs no comment.
1135                // The absence of objections is the message.
1136                return None;
1137            }
1138            out.push("Reviewed, nothing blocking a merge.".into());
1139        }
1140        Ending::OutOfRounds => out.push(
1141            "Not signed off: the last round of fixes was pushed but has not been reviewed.".into(),
1142        ),
1143        Ending::Deadlocked(points) => {
1144            // Rendered once, with the argument attached. A deadlocked point is
1145            // by definition one that was refuted earlier, so the reasoning is
1146            // the whole reason a person is being asked to look. On a resumed
1147            // run `state.disputes` is empty (only `filed` is restored), so the
1148            // ledger is the only place that argument survives.
1149            let lines: Vec<String> = points
1150                .iter()
1151                .map(|f| {
1152                    let where_at = match f.where_at() {
1153                        "general" => String::new(),
1154                        file => format!(" ({file})"),
1155                    };
1156                    let title = style::title(&f.title, style);
1157                    already.push(title.clone());
1158                    match refutation_of(f, state, ledger) {
1159                        Some(reason) => format!(
1160                            "{title}{where_at}. Refuted as: {}",
1161                            style::summary(&reason, style)
1162                        ),
1163                        None => format!("{title}{where_at}"),
1164                    }
1165                })
1166                .collect();
1167            out.push("Needs your decision. The reviewers could not settle this:".into());
1168            out.push(bullets(&lines));
1169        }
1170    }
1171
1172    let disputes: Vec<&crate::model::Dispute> = state
1173        .disputes
1174        .iter()
1175        .filter(|d| !already.iter().any(|t| same_point(t, &d.title)))
1176        .collect();
1177    if !disputes.is_empty() {
1178        // The one thing invisible anywhere else. The diff shows what was fixed;
1179        // nothing shows what was argued down, or why.
1180        let lines: Vec<String> = disputes
1181            .iter()
1182            .map(|d| {
1183                format!(
1184                    "{}. {}",
1185                    style::title(&d.title, style),
1186                    style::sentence(&d.reasoning, style)
1187                )
1188            })
1189            .collect();
1190        out.push(format!("Raised and refuted:\n{}", bullets(&lines)));
1191    }
1192
1193    if !state.filed.is_empty() {
1194        let refs: Vec<String> = state.filed.iter().map(|u| as_reference(u)).collect();
1195        out.push(format!("Filed separately: {}", refs.join(", ")));
1196    }
1197
1198    Some(out.join("\n\n"))
1199}
1200
1201/// What the implementor is asked, with the issue in front of it.
1202///
1203/// The body is passed rather than only the link, because one of the two agents
1204/// cannot follow a link: codex runs under `-s workspace-write`, which has no
1205/// network at all, so a URL alone would leave it judging the title. The link is
1206/// there for the agent that can follow it, and for the comments spar does not
1207/// fetch.
1208fn implement_prompt(number: i64, title: &str, url: &str, body: &str) -> String {
1209    IMPLEMENT_PROMPT
1210        .replace("{number}", &number.to_string())
1211        .replace("{title}", title)
1212        .replace("{url}", url)
1213        .replace("{body}", body)
1214}
1215
1216/// The pull request body.
1217///
1218/// What it closes, then the change in one sentence, then what was wrong, then
1219/// only the sections that have something in them. The lead is two paragraphs
1220/// rather than two headings: a heading over a single sentence is a label on a
1221/// label, and those two parts are the ones every body has.
1222///
1223/// GitHub renders the file count and the plus and minus figures immediately
1224/// above this, so neither appears here.
1225pub fn pr_body(issue: i64, work: &Implementation, style: &Style) -> String {
1226    let mut parts = vec![format!("Closes #{issue}")];
1227
1228    for lead in [&work.summary, &work.problem] {
1229        let text = style::sentence(lead, style);
1230        if !text.is_empty() {
1231            parts.push(text);
1232        }
1233    }
1234    parts.extend(section("What changed", &work.changes, style));
1235    parts.extend(section("How to test", &work.testing, style));
1236
1237    let notes = style::sentence(work.notes.as_deref().unwrap_or_default(), style);
1238    if !notes.is_empty() {
1239        parts.push(format!("## Notes\n\n{notes}"));
1240    }
1241
1242    style::body(&parts.join("\n\n"), style)
1243}
1244
1245/// A headed list, or nothing at all when there is nothing to list.
1246///
1247/// Nothing at all on purpose. A heading with an empty body under it reads as a
1248/// section somebody forgot to write, which is worse than the absence, and a
1249/// small change that needs no change list should not be made to look like one
1250/// that is missing its.
1251fn section(heading: &str, lines: &[String], style: &Style) -> Option<String> {
1252    let items: Vec<String> = lines
1253        .iter()
1254        .map(|line| style::summary(line, style))
1255        .filter(|line| !line.is_empty())
1256        .collect();
1257    if items.is_empty() {
1258        return None;
1259    }
1260    Some(format!("## {heading}\n\n{}", bullets(&items)))
1261}
1262
1263/// What gets posted on an issue that produced no pull request.
1264///
1265/// The agent's own reason when it gave one, since that is the part written for
1266/// the person who opened the issue. Never the summary: an issue that produced
1267/// no commits has no change for a summary to describe, and one that claims
1268/// otherwise is worse than a flat sentence saying nothing happened.
1269fn no_pr_note(work: &Implementation, style: &Style) -> String {
1270    let reason = style::sentence(&work.reason, style);
1271    if !reason.is_empty() {
1272        return reason;
1273    }
1274    if work.not_worth_doing {
1275        "Left alone after reading the code, with no reason given.".to_string()
1276    } else {
1277        "Nothing was committed, so there is nothing to review.".to_string()
1278    }
1279}
1280
1281/// One review, as a reviewer would write it if they were in a hurry: a count
1282/// line, a sentence, and one bullet per finding. Only blocking findings carry
1283/// their detail, because only those are something the author has to act on now.
1284pub fn review_comment(holder: &str, round: u32, review: &Review, style: &Style) -> String {
1285    let by = |severity: Severity| -> Vec<&Finding> {
1286        review
1287            .findings
1288            .iter()
1289            .filter(|f| f.severity == severity && f.in_scope)
1290            .collect()
1291    };
1292    let blocking = by(Severity::Blocking);
1293    let non_blocking = by(Severity::NonBlocking);
1294    let nits = by(Severity::Nit);
1295    let out_of_scope: Vec<&Finding> = review.findings.iter().filter(|f| !f.in_scope).collect();
1296
1297    let mut counts = Vec::new();
1298    if !blocking.is_empty() {
1299        counts.push(format!("{} blocking", blocking.len()));
1300    }
1301    if !non_blocking.is_empty() {
1302        counts.push(format!("{} non-blocking", non_blocking.len()));
1303    }
1304    if !nits.is_empty() {
1305        counts.push(format!("{} nit", nits.len()));
1306    }
1307    if !out_of_scope.is_empty() {
1308        counts.push(format!("{} out of scope", out_of_scope.len()));
1309    }
1310    let headline = if counts.is_empty() {
1311        "no findings".to_string()
1312    } else {
1313        counts.join(", ")
1314    };
1315
1316    let _ = (holder, round, headline);
1317    let mut out = Vec::new();
1318    let summary = style::summary(&review.summary, style);
1319    if !summary.is_empty() {
1320        out.push(summary);
1321    }
1322
1323    if !blocking.is_empty() {
1324        let lines: Vec<String> = blocking
1325            .iter()
1326            .map(|f| {
1327                let detail = style::detail(&f.detail, style);
1328                if detail.is_empty() {
1329                    located(f, style)
1330                } else {
1331                    format!("{}. {detail}", located(f, style))
1332                }
1333            })
1334            .collect();
1335        out.push(format!("blocking\n{}", bullets(&lines)));
1336    }
1337
1338    // Everything below is filed as a follow-up, so the thread only needs the
1339    // title: the detail lives on the issue where it can be acted on.
1340    for (label, group) in [
1341        ("non-blocking", &non_blocking),
1342        ("nits", &nits),
1343        ("out of scope", &out_of_scope),
1344    ] {
1345        if group.is_empty() {
1346            continue;
1347        }
1348        let lines: Vec<String> = group.iter().map(|f| located(f, style)).collect();
1349        out.push(format!("{label}\n{}", bullets(&lines)));
1350    }
1351
1352    out.join("\n\n")
1353}
1354
1355/// One response to a review. Refutations carry their reasoning because that is
1356/// the whole argument; fixes are a list of titles because the diff says the
1357/// rest.
1358pub fn disposition_comment(
1359    author: &str,
1360    response: &ResponseDoc,
1361    fixed: &[String],
1362    refuted: &[String],
1363    filed: &[String],
1364    style: &Style,
1365) -> Option<String> {
1366    if fixed.is_empty() && refuted.is_empty() && filed.is_empty() {
1367        return None;
1368    }
1369    let mut counts = Vec::new();
1370    if !fixed.is_empty() {
1371        counts.push(format!("{} fixed", fixed.len()));
1372    }
1373    if !refuted.is_empty() {
1374        counts.push(format!("{} refuted", refuted.len()));
1375    }
1376    if !filed.is_empty() {
1377        counts.push(format!("{} filed", filed.len()));
1378    }
1379
1380    let _ = (author, counts);
1381    let mut out = Vec::new();
1382    let summary = style::summary(&response.summary, style);
1383    if !summary.is_empty() {
1384        out.push(summary);
1385    }
1386    if !refuted.is_empty() {
1387        out.push(format!("refuted\n{}", bullets(refuted)));
1388    }
1389    if !fixed.is_empty() {
1390        out.push(format!("fixed\n{}", bullets(fixed)));
1391    }
1392    if !filed.is_empty() {
1393        out.push(format!("filed\n{}", bullets(filed)));
1394    }
1395    Some(out.join("\n\n"))
1396}
1397
1398/// What is posted on an issue both agents declined.
1399/// What is posted on an issue both reviewers declined.
1400///
1401/// Just the reasons. GitHub already shows that it was closed as not planned,
1402/// and which model held which opinion is a fact about the run rather than about
1403/// the issue. Duplicates are collapsed, since two reviewers reaching the same
1404/// conclusion often reach it in the same words.
1405pub fn skip_comment(item: &SkippedItem, style: &Style) -> String {
1406    let reasons = item
1407        .reasons
1408        .values()
1409        .map(|reason| style::sentence(reason, style));
1410    // Two reviewers declining one issue almost always decline it for the same
1411    // reason, worded differently. On the run that prompted this, both cited the
1412    // issue it duplicated and the reader saw the point twice.
1413    let lines = crate::textsim::dedupe_by(reasons, crate::textsim::same_reason);
1414    bullets(&lines)
1415}
1416
1417/// Findings as a model should see them: full detail, since this one is not for
1418/// a human to read.
1419pub(crate) fn findings_for_prompt(findings: &[Finding]) -> String {
1420    if findings.is_empty() {
1421        return "(none)".to_string();
1422    }
1423    findings
1424        .iter()
1425        .map(|f| {
1426            let scope = if f.in_scope { "" } else { " [out of scope]" };
1427            format!(
1428                "- [{}]{scope} {} ({})\n  {}",
1429                f.severity,
1430                f.title,
1431                f.where_at(),
1432                f.detail
1433            )
1434        })
1435        .collect::<Vec<_>>()
1436        .join("\n")
1437}
1438
1439#[cfg(test)]
1440mod tests {
1441    use super::*;
1442    use crate::model::Verdict;
1443
1444    fn style() -> Style {
1445        Style::default()
1446    }
1447
1448    fn finding(severity: &str, title: &str, detail: &str, file: &str, in_scope: bool) -> Finding {
1449        Finding {
1450            severity: Severity::parse_lenient(severity).unwrap(),
1451            title: title.into(),
1452            detail: detail.into(),
1453            file: file.into(),
1454            in_scope,
1455            ..Default::default()
1456        }
1457    }
1458
1459    fn review(summary: &str, findings: Vec<Finding>) -> Review {
1460        Review {
1461            verdict: Verdict::Approve,
1462            next_action: NextAction::Merge,
1463            summary: summary.into(),
1464            findings,
1465        }
1466    }
1467
1468    // -- worktree release ------------------------------------------------
1469
1470    fn cfg_with(worktrees: bool, keep: bool) -> Config {
1471        let text = "[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n";
1472        let mut cfg = crate::config::parse(text).unwrap();
1473        cfg.loop_cfg.worktrees = worktrees;
1474        cfg.loop_cfg.keep_worktrees = keep;
1475        cfg
1476    }
1477
1478    #[test]
1479    fn a_worktree_is_released_on_every_finished_outcome() {
1480        let cfg = cfg_with(true, false);
1481        for status in [Status::Approved, Status::Merged, Status::Abandoned] {
1482            assert!(should_release(&cfg, status), "{status}");
1483        }
1484    }
1485
1486    /// Releasing only on "merged" leaked one worktree per run, because
1487    /// auto_merge is off by default and runs end at "approved".
1488    #[test]
1489    fn a_worktree_is_kept_only_where_a_human_has_to_look() {
1490        let cfg = cfg_with(true, false);
1491        assert!(!should_release(&cfg, Status::Escalated));
1492        assert!(!should_release(&cfg, Status::Error));
1493    }
1494
1495    #[test]
1496    fn the_keep_flag_overrides_everything() {
1497        assert!(!should_release(&cfg_with(true, true), Status::Approved));
1498    }
1499
1500    #[test]
1501    fn nothing_is_released_when_worktrees_are_off() {
1502        assert!(!should_release(&cfg_with(false, false), Status::Approved));
1503    }
1504
1505    // -- round budget ----------------------------------------------------
1506
1507    /// A fresh PR gets rounds 1 through max_rounds.
1508    #[test]
1509    fn a_fresh_run_starts_at_one() {
1510        assert_eq!((1, 3), round_window(1, 3));
1511        assert_eq!((1, 5), round_window(1, 5));
1512    }
1513
1514    /// The budget is per invocation, not a lifetime cap. Running spar again on
1515    /// a PR that already spent five rounds gives it five more, because a person
1516    /// looked at it and chose to.
1517    #[test]
1518    fn a_resumed_run_gets_a_full_fresh_budget() {
1519        assert_eq!((6, 10), round_window(6, 5));
1520        assert_eq!((11, 13), round_window(11, 3));
1521    }
1522
1523    #[test]
1524    fn a_budget_of_one_is_a_single_round() {
1525        assert_eq!((6, 6), round_window(6, 1));
1526    }
1527
1528    #[test]
1529    fn round_numbers_keep_counting_across_sessions() {
1530        // Three sessions of three rounds each land on 1..3, 4..6, 7..9.
1531        let mut start = 1;
1532        let mut seen = Vec::new();
1533        for _ in 0..3 {
1534            let (first, last) = round_window(start, 3);
1535            seen.push((first, last));
1536            start = last + 1;
1537        }
1538        assert_eq!(vec![(1, 3), (4, 6), (7, 9)], seen);
1539    }
1540
1541    // -- the ledger ------------------------------------------------------
1542
1543    fn ledger_with(title: &str, file: &str) -> Ledger {
1544        let mut ledger = Ledger::new();
1545        ledger.insert(
1546            finding_key(title, file),
1547            LedgerEntry {
1548                title: title.into(),
1549                file: file.into(),
1550                reasoning: "no".into(),
1551                round: 1,
1552                reraised: 0,
1553            },
1554        );
1555        ledger
1556    }
1557
1558    #[test]
1559    fn a_point_refuted_and_re_raised_twice_escalates() {
1560        let mut ledger = ledger_with("nit about naming", "a.rs");
1561        let mut state = IssueRun::new(1, "t");
1562        let blocking = vec![finding("blocking", "nit about naming", "d", "a.rs", true)];
1563        assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
1564        assert!(check_relitigation(&mut ledger, &blocking, &mut state));
1565    }
1566
1567    #[test]
1568    fn an_untracked_finding_does_not_escalate() {
1569        let mut state = IssueRun::new(1, "t");
1570        let blocking = vec![finding("blocking", "brand new", "d", "a.rs", true)];
1571        assert!(!check_relitigation(
1572            &mut Ledger::new(),
1573            &blocking,
1574            &mut state
1575        ));
1576    }
1577
1578    /// The key a refutation records has to be the key the next round's finding
1579    /// hashes to. Recording it without the file made the guard dead code for
1580    /// every finding that named one, which is nearly all of them.
1581    #[test]
1582    fn a_refutation_lands_on_the_key_the_next_round_will_look_up() {
1583        let blocking = vec![finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
1584        let recorded = finding_key(&blocking[0].title, &blocking[0].file);
1585
1586        let matched = matching_finding(&blocking, "unbounded loop!").expect("should match");
1587        assert_eq!(recorded, finding_key("unbounded loop!", &matched.file));
1588    }
1589
1590    /// `matching_finding` ignores hyphens, dots, slashes, and underscores;
1591    /// `finding_key` keeps them. A disposition that differs only in those
1592    /// characters therefore matches its finding while hashing to a different
1593    /// key, so recording the author's wording made the guard track nothing.
1594    #[test]
1595    fn the_ledger_key_uses_the_reviewers_wording_not_the_authors() {
1596        let findings = vec![finding(
1597            "blocking",
1598            "Panic on multi-byte input",
1599            "d",
1600            "src/style.rs",
1601            true,
1602        )];
1603        let reworded = "Panic on multibyte input";
1604
1605        let source = matching_finding(&findings, reworded).expect("still matches");
1606        assert_ne!(
1607            finding_key(reworded, &source.file),
1608            finding_key(&source.title, &source.file),
1609            "the two spellings must genuinely hash apart, or this test proves nothing"
1610        );
1611
1612        // What apply_dispositions records, and what the next round looks up.
1613        let recorded = finding_key(&source.title, &source.file);
1614        let looked_up = finding_key(&findings[0].title, &findings[0].file);
1615        assert_eq!(recorded, looked_up);
1616    }
1617
1618    #[test]
1619    fn a_disposition_matches_its_finding_despite_wording_noise() {
1620        let findings = vec![finding(
1621            "blocking",
1622            "Unbounded loop!",
1623            "d",
1624            "src/x.rs",
1625            true,
1626        )];
1627        assert!(matching_finding(&findings, "unbounded loop").is_some());
1628        assert!(matching_finding(&findings, "something else").is_none());
1629    }
1630
1631    #[test]
1632    fn the_settled_block_is_empty_when_nothing_is_settled() {
1633        assert_eq!("", settled_block(&Ledger::new()));
1634    }
1635
1636    #[test]
1637    fn the_settled_block_names_each_refutation() {
1638        let block = settled_block(&ledger_with("a point", "x.rs"));
1639        assert!(block.contains("a point"));
1640        assert!(block.contains("settled"));
1641    }
1642
1643    // -- brevity ---------------------------------------------------------
1644
1645    #[test]
1646    /// No agent name, no round number, and no count of things listed below.
1647    /// The reader wants the review, not an account of who produced it.
1648    fn a_clean_review_is_just_the_verdict() {
1649        let text = review_comment("codex", 1, &review("Looks correct.", vec![]), &style());
1650        assert_eq!("Looks correct.", text);
1651    }
1652
1653    #[test]
1654    fn a_review_leads_with_the_counts() {
1655        let text = review_comment(
1656            "codex",
1657            2,
1658            &review(
1659                "One real problem.",
1660                vec![
1661                    finding(
1662                        "blocking",
1663                        "Loop never terminates",
1664                        "Confirmed by running it.",
1665                        "src/a.rs",
1666                        true,
1667                    ),
1668                    finding("non-blocking", "Name is vague", "d", "src/b.rs", true),
1669                    finding("nit", "Log wording", "d", "", true),
1670                ],
1671            ),
1672            &style(),
1673        );
1674        assert!(text.starts_with("One real problem."), "{text}");
1675        assert!(!text.contains("codex"), "no agent name: {text}");
1676        assert!(!text.contains("round 2"), "no round number: {text}");
1677    }
1678
1679    /// Only blocking findings carry their detail into the thread. Everything
1680    /// else is filed, and the detail belongs on the issue.
1681    #[test]
1682    fn only_blocking_findings_carry_their_detail() {
1683        let text = review_comment(
1684            "codex",
1685            1,
1686            &review(
1687                "s",
1688                vec![
1689                    finding("blocking", "Loop", "BLOCKING DETAIL", "a.rs", true),
1690                    finding("non-blocking", "Name", "NONBLOCKING DETAIL", "b.rs", true),
1691                ],
1692            ),
1693            &style(),
1694        );
1695        assert!(text.contains("BLOCKING DETAIL"), "{text}");
1696        assert!(!text.contains("NONBLOCKING DETAIL"), "{text}");
1697    }
1698
1699    #[test]
1700    /// A finding's explanation is what the author acts on. Cutting it to save
1701    /// characters leaves them nothing to act on and saves nothing worth having.
1702    fn a_thorough_explanation_reaches_the_author_intact() {
1703        let detail = "Reproduced by running the 429 test with max_attempts unset. ".repeat(8);
1704        let text = review_comment(
1705            "codex",
1706            1,
1707            &review(
1708                "One problem.",
1709                vec![finding("blocking", "T", &detail, "a.rs", true)],
1710            ),
1711            &style(),
1712        );
1713        assert!(
1714            text.contains(detail.trim()),
1715            "the explanation was cut:\n{text}"
1716        );
1717    }
1718
1719    /// A runaway is still bounded, just nowhere near tightly.
1720    #[test]
1721    fn a_runaway_model_is_still_bounded() {
1722        let long = "filler words. ".repeat(20_000);
1723        let text = review_comment(
1724            "codex",
1725            1,
1726            &review(&long, vec![finding("blocking", "T", &long, "a.rs", true)]),
1727            &style(),
1728        );
1729        assert!(
1730            text.len() < 30_000,
1731            "review comment was {} chars",
1732            text.len()
1733        );
1734    }
1735
1736    #[test]
1737    fn a_general_finding_has_no_empty_parenthesis() {
1738        let text = review_comment(
1739            "codex",
1740            1,
1741            &review("s", vec![finding("blocking", "Something", "d", "", true)]),
1742            &style(),
1743        );
1744        assert!(!text.contains("()"), "{text}");
1745        assert!(!text.contains("(general)"), "{text}");
1746    }
1747
1748    #[test]
1749    fn out_of_scope_findings_are_counted_separately() {
1750        let text = review_comment(
1751            "codex",
1752            1,
1753            &review(
1754                "s",
1755                vec![finding("blocking", "Old bug", "d", "a.rs", false)],
1756            ),
1757            &style(),
1758        );
1759        assert!(text.contains("out of scope"), "{text}");
1760        assert!(text.contains("Old bug"), "{text}");
1761    }
1762
1763    #[test]
1764    fn a_disposition_comment_leads_with_counts_and_keeps_refutations() {
1765        let response = ResponseDoc {
1766            summary: "Two of three were right.".into(),
1767            dispositions: vec![],
1768        };
1769        let text = disposition_comment(
1770            "claude",
1771            &response,
1772            &["Fixed thing".to_string()],
1773            &["Wrong thing. Because the caller already checks.".to_string()],
1774            &[],
1775            &style(),
1776        )
1777        .unwrap();
1778        assert!(text.starts_with("Two of three were right."), "{text}");
1779        assert!(!text.contains("claude"), "no agent name: {text}");
1780        assert!(
1781            text.contains("Because the caller already checks."),
1782            "{text}"
1783        );
1784    }
1785
1786    #[test]
1787    fn an_empty_disposition_comment_is_not_posted() {
1788        let response = ResponseDoc {
1789            summary: "s".into(),
1790            dispositions: vec![],
1791        };
1792        assert!(disposition_comment("claude", &response, &[], &[], &[], &style()).is_none());
1793    }
1794
1795    /// Both, not either. The link is how an agent that can reach the network
1796    /// reads the discussion spar does not fetch, and the body is what the one
1797    /// that cannot works from: codex runs with no network, so a link alone
1798    /// would leave it building from the title.
1799    #[test]
1800    fn the_implementor_is_given_the_link_and_the_body() {
1801        let prompt = implement_prompt(
1802            42,
1803            "Retry a 429",
1804            "https://github.com/o/r/issues/42",
1805            "A rate limited response was treated as fatal.",
1806        );
1807        assert!(
1808            prompt.contains("https://github.com/o/r/issues/42"),
1809            "{prompt}"
1810        );
1811        assert!(
1812            prompt.contains("A rate limited response was treated as fatal."),
1813            "{prompt}"
1814        );
1815        assert!(prompt.contains("#42"), "{prompt}");
1816        assert!(prompt.contains("Retry a 429"), "{prompt}");
1817        // Nothing left unsubstituted.
1818        assert!(!prompt.contains('{'), "{prompt}");
1819    }
1820
1821    /// An agent that cannot reach the link is told what it is missing, so it
1822    /// works from the body rather than assuming the body is everything.
1823    #[test]
1824    fn the_prompt_says_the_discussion_is_not_included() {
1825        let prompt = implement_prompt(1, "t", "u", "b");
1826        // Flattened, so the assertion does not turn on where the prompt wraps.
1827        let lower = prompt
1828            .split_whitespace()
1829            .collect::<Vec<_>>()
1830            .join(" ")
1831            .to_lowercase();
1832        assert!(
1833            lower.contains("discussion since is not included"),
1834            "{prompt}"
1835        );
1836        assert!(lower.contains("cannot reach the network"), "{prompt}");
1837    }
1838
1839    /// A fully reported implementation, for the body tests.
1840    fn worked() -> Implementation {
1841        Implementation {
1842            summary: "Retry a 429 instead of failing the run.".into(),
1843            problem: "A rate limited response was treated as fatal, so one throttled call ended \
1844                      a run that had hours of work left in it."
1845                .into(),
1846            changes: vec![
1847                "`send` retries a 429 with the delay the header asks for".into(),
1848                "the retry budget is bounded, so a permanent 429 still ends".into(),
1849            ],
1850            testing: vec![
1851                "`cargo test retries_a_429`".into(),
1852                "point it at a throttled endpoint and watch it finish".into(),
1853            ],
1854            ..Implementation::default()
1855        }
1856    }
1857
1858    #[test]
1859    /// GitHub renders the file count and the plus and minus figures in the
1860    /// header, immediately above whatever spar writes, so neither is here.
1861    fn a_pr_body_is_what_it_closes_and_what_changed() {
1862        let body = pr_body(42, &worked(), &style());
1863        assert_eq!(
1864            "Closes #42\n\n\
1865             Retry a 429 instead of failing the run.\n\n\
1866             A rate limited response was treated as fatal, so one throttled call \
1867             ended a run that had hours of work left in it.\n\n\
1868             ## What changed\n\n\
1869             - `send` retries a 429 with the delay the header asks for\n\
1870             - the retry budget is bounded, so a permanent 429 still ends\n\n\
1871             ## How to test\n\n\
1872             - `cargo test retries_a_429`\n\
1873             - point it at a throttled endpoint and watch it finish",
1874            body
1875        );
1876    }
1877
1878    /// The sections are optional and the lead is not. A one line fix should
1879    /// read as one, not as a form with most of it left blank.
1880    #[test]
1881    fn a_body_with_nothing_to_list_carries_no_empty_headings() {
1882        let work = Implementation {
1883            summary: "Retry a 429 instead of failing the run.".into(),
1884            ..Implementation::default()
1885        };
1886        assert_eq!(
1887            "Closes #42\n\nRetry a 429 instead of failing the run.",
1888            pr_body(42, &work, &style())
1889        );
1890    }
1891
1892    #[test]
1893    fn a_pr_body_survives_an_implementor_that_said_nothing() {
1894        assert_eq!(
1895            "Closes #7",
1896            pr_body(7, &Implementation::default(), &style())
1897        );
1898    }
1899
1900    /// Blank entries are the model's, not the reader's problem. A heading whose
1901    /// only bullet was an empty string used to be possible.
1902    #[test]
1903    fn blank_list_entries_do_not_earn_a_heading() {
1904        let work = Implementation {
1905            summary: "Did a thing.".into(),
1906            changes: vec![String::new(), "   ".into()],
1907            ..Implementation::default()
1908        };
1909        let body = pr_body(42, &work, &style());
1910        assert!(!body.contains("What changed"), "{body}");
1911    }
1912
1913    #[test]
1914    fn notes_appear_only_when_there_is_something_to_note() {
1915        let mut work = worked();
1916        assert!(!pr_body(42, &work, &style()).contains("## Notes"));
1917        work.notes = Some("The retry is not applied to streaming calls.".into());
1918        let body = pr_body(42, &work, &style());
1919        assert!(body.contains("## Notes"), "{body}");
1920        assert!(body.contains("streaming calls"), "{body}");
1921    }
1922
1923    /// An issue that produced no commits is told so. Never the summary, which
1924    /// describes a change that is not in the branch.
1925    #[test]
1926    fn declining_posts_the_reason_and_not_the_summary() {
1927        let work = Implementation {
1928            not_worth_doing: true,
1929            reason: "Already fixed in 1.2, and the report predates it.".into(),
1930            summary: "Nothing to do.".into(),
1931            ..Implementation::default()
1932        };
1933        assert_eq!(
1934            "Already fixed in 1.2, and the report predates it.",
1935            no_pr_note(&work, &style())
1936        );
1937    }
1938
1939    #[test]
1940    fn reporting_work_and_committing_none_says_that_rather_than_the_summary() {
1941        let work = Implementation {
1942            summary: "Retry a 429 instead of failing the run.".into(),
1943            ..Implementation::default()
1944        };
1945        let note = no_pr_note(&work, &style());
1946        assert_eq!(
1947            "Nothing was committed, so there is nothing to review.",
1948            note
1949        );
1950    }
1951
1952    #[test]
1953    fn declining_without_a_reason_still_says_something() {
1954        let work = Implementation {
1955            not_worth_doing: true,
1956            ..Implementation::default()
1957        };
1958        assert!(no_pr_note(&work, &style()).contains("no reason given"));
1959    }
1960
1961    #[test]
1962    fn a_skip_comment_is_only_the_reasoning() {
1963        let item = SkippedItem {
1964            issue: 3,
1965            title: "t".into(),
1966            tracker: false,
1967            reasons: [
1968                ("claude".to_string(), "Already fixed in 1.2.".to_string()),
1969                ("codex".to_string(), "Duplicate of #2.".to_string()),
1970            ]
1971            .into_iter()
1972            .collect(),
1973        };
1974        let text = skip_comment(&item, &style());
1975        assert!(text.contains("Already fixed in 1.2."), "{text}");
1976        assert!(text.contains("Duplicate of #2."), "{text}");
1977        assert!(
1978            !text.contains("claude") && !text.contains("codex"),
1979            "{text}"
1980        );
1981        assert!(!text.to_lowercase().contains("not scheduled"), "{text}");
1982        assert!(text.lines().count() <= 3, "{text}");
1983    }
1984
1985    #[test]
1986    fn findings_for_a_model_keep_full_detail() {
1987        let long = "x".repeat(2000);
1988        let text = findings_for_prompt(&[finding("blocking", "T", &long, "a.rs", true)]);
1989        assert!(
1990            text.contains(&long),
1991            "a model needs the whole finding, only humans need brevity"
1992        );
1993    }
1994
1995    #[test]
1996    fn findings_for_a_model_are_never_empty() {
1997        assert_eq!("(none)", findings_for_prompt(&[]));
1998    }
1999}
2000
2001#[cfg(test)]
2002mod outcome_tests {
2003    use super::*;
2004    use crate::model::{Dispute, Severity};
2005
2006    fn style() -> Style {
2007        Style::default()
2008    }
2009
2010    fn state_with(disputes: Vec<(&str, &str)>, filed: Vec<&str>) -> IssueRun {
2011        let mut s = IssueRun::new(482, "t");
2012        s.disputes = disputes
2013            .into_iter()
2014            .map(|(title, reasoning)| Dispute {
2015                title: title.into(),
2016                reasoning: reasoning.into(),
2017            })
2018            .collect();
2019        s.filed = filed.into_iter().map(String::from).collect();
2020        s
2021    }
2022
2023    fn finding(title: &str, file: &str) -> Finding {
2024        Finding {
2025            severity: Severity::Blocking,
2026            title: title.into(),
2027            detail: "d".into(),
2028            file: file.into(),
2029            in_scope: true,
2030            ..Default::default()
2031        }
2032    }
2033
2034    /// The absence of objections is the message. A PR that reviewed cleanly and
2035    /// filed nothing should leave no trace in the thread at all.
2036    #[test]
2037    fn a_clean_approval_says_nothing() {
2038        let state = state_with(vec![], vec![]);
2039        assert!(outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).is_none());
2040    }
2041
2042    #[test]
2043    fn an_approval_that_filed_follow_ups_links_them() {
2044        let state = state_with(
2045            vec![],
2046            vec![
2047                "https://github.com/you/thing/issues/485",
2048                "https://github.com/you/thing/issues/486",
2049            ],
2050        );
2051        let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
2052        assert!(text.contains("Filed separately: #485, #486"), "{text}");
2053    }
2054
2055    /// The real PR ended with "5 fixed" followed by "no convergence", which
2056    /// reads as a contradiction. What a maintainer needs is that the fixes went
2057    /// in and nobody checked them.
2058    #[test]
2059    fn running_out_of_rounds_says_what_that_means_for_the_reader() {
2060        let state = state_with(vec![], vec![]);
2061        let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
2062        assert!(text.contains("has not been reviewed"), "{text}");
2063        assert!(
2064            !text.to_lowercase().contains("round 3"),
2065            "no round numbers: {text}"
2066        );
2067        assert!(!text.to_lowercase().contains("convergence"), "{text}");
2068    }
2069
2070    #[test]
2071    fn a_deadlock_names_the_point_they_could_not_settle() {
2072        let state = state_with(vec![], vec![]);
2073        let points = [finding("Retry loop never terminates", "src/net.rs:88")];
2074        let text = outcome_comment(
2075            &state,
2076            &Ledger::new(),
2077            &Ending::Deadlocked(&points),
2078            &style(),
2079        )
2080        .unwrap();
2081        assert!(
2082            text.contains("Retry loop never terminates (src/net.rs:88)"),
2083            "{text}"
2084        );
2085        assert!(text.contains("could not settle"), "{text}");
2086    }
2087
2088    /// The diff records what was fixed. Nothing records what was argued down.
2089    #[test]
2090    fn refutations_survive_because_nothing_else_carries_them() {
2091        let state = state_with(
2092            vec![(
2093                "Error is swallowed",
2094                "the caller already validates the file",
2095            )],
2096            vec![],
2097        );
2098        let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
2099        assert!(text.contains("Raised and refuted:"), "{text}");
2100        assert!(
2101            text.contains("The caller already validates the file"),
2102            "{text}"
2103        );
2104    }
2105
2106    #[test]
2107    fn no_agent_names_counts_or_round_numbers_reach_the_thread() {
2108        let state = state_with(
2109            vec![("A point", "a reason")],
2110            vec!["https://github.com/you/thing/issues/485"],
2111        );
2112        for ending in [Ending::Approved, Ending::OutOfRounds] {
2113            let text = outcome_comment(&state, &Ledger::new(), &ending, &style()).unwrap();
2114            let lower = text.to_lowercase();
2115            for banned in ["claude", "codex", "blocking,", "nit,", " fixed."] {
2116                assert!(
2117                    !lower.contains(banned),
2118                    "{banned:?} leaked into the thread:\n{text}"
2119                );
2120            }
2121            // "the last round of fixes" is prose. "round 3" is narration.
2122            for n in 1..9 {
2123                assert!(
2124                    !lower.contains(&format!("round {n}")),
2125                    "a round number leaked into the thread:\n{text}"
2126                );
2127            }
2128        }
2129    }
2130
2131    #[test]
2132    /// A refutation is an argument, and an argument that stops mid clause is
2133    /// not one. Bounded, but with room to make the case.
2134    fn a_refutation_is_allowed_to_make_its_case() {
2135        let reasoning = "The caller validates against the schema first. \
2136                         The discarded error is therefore unreachable in practice. ";
2137        let state = state_with(
2138            vec![("A point", &reasoning.repeat(6))],
2139            vec!["https://github.com/you/thing/issues/485"],
2140        );
2141        let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
2142        assert!(
2143            !text.contains("..."),
2144            "nothing was cut mid thought:\n{text}"
2145        );
2146        assert!(text.len() < 4000, "{} chars", text.len());
2147    }
2148
2149    #[test]
2150    fn a_url_that_is_not_an_issue_link_is_left_alone() {
2151        assert_eq!(
2152            "#485",
2153            as_reference("https://github.com/you/thing/issues/485")
2154        );
2155        assert_eq!("note: something", as_reference("note: something"));
2156    }
2157}
2158
2159#[cfg(test)]
2160mod filed_reference_tests {
2161    use super::*;
2162
2163    #[test]
2164    fn an_issue_url_yields_its_number() {
2165        assert_eq!(
2166            Some(485),
2167            filed_issue_number("https://github.com/you/thing/issues/485")
2168        );
2169    }
2170
2171    /// Local mode records a note rather than a URL, and a run with
2172    /// followups = "local" must not try to absorb it as an issue.
2173    #[test]
2174    fn a_local_note_yields_nothing() {
2175        assert_eq!(None, filed_issue_number("note: Retry is unbounded"));
2176        assert_eq!(None, filed_issue_number(""));
2177        assert_eq!(
2178            None,
2179            filed_issue_number("https://github.com/you/thing/issues/")
2180        );
2181    }
2182}
2183
2184#[cfg(test)]
2185mod followup_restraint_tests {
2186    use super::*;
2187    use crate::model::Severity;
2188
2189    fn cfg_with(followups: Followups, non_blocking: bool, nits: bool, cap: usize) -> Config {
2190        let mut cfg =
2191            crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
2192                .unwrap();
2193        cfg.loop_cfg.followups = followups;
2194        cfg.loop_cfg.file_non_blocking = non_blocking;
2195        cfg.loop_cfg.file_nits = nits;
2196        cfg.loop_cfg.max_followups = cap;
2197        cfg
2198    }
2199
2200    fn finding(severity: Severity, title: &str, in_scope: bool) -> Finding {
2201        Finding {
2202            severity,
2203            title: title.into(),
2204            detail: "d".into(),
2205            file: "a.rs".into(),
2206            in_scope,
2207            ..Default::default()
2208        }
2209    }
2210
2211    /// The defaults are what let one issue spawn ten, which spawned more. A
2212    /// thorough reviewer always finds improvements; not gating a merge is not
2213    /// the same as deserving somebody's triage queue.
2214    #[test]
2215    fn a_non_blocking_finding_is_not_a_tracker_item_by_default() {
2216        let cfg = cfg_with(Followups::Issues, false, false, 5);
2217        assert!(!cfg.loop_cfg.file_non_blocking);
2218        assert!(!cfg.loop_cfg.file_nits);
2219    }
2220
2221    #[test]
2222    fn follow_ups_stay_off_the_tracker_by_default() {
2223        let cfg =
2224            crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
2225                .unwrap();
2226        assert_eq!(
2227            Followups::Local,
2228            cfg.loop_cfg.followups,
2229            "the tracker is somebody's queue; the default must not write to it"
2230        );
2231        assert_eq!(5, cfg.loop_cfg.max_followups);
2232    }
2233
2234    /// Which severities survive the filter, at the defaults and when opened up.
2235    #[test]
2236    fn only_out_of_scope_defects_qualify_at_the_defaults() {
2237        let cfg = cfg_with(Followups::Issues, false, false, 5);
2238        let qualifies = |f: &Finding| match f.severity {
2239            Severity::NonBlocking => cfg.loop_cfg.file_non_blocking && f.in_scope,
2240            Severity::Nit => cfg.loop_cfg.file_nits && f.in_scope,
2241            Severity::Blocking => false,
2242        } || !f.in_scope;
2243
2244        assert!(qualifies(&finding(
2245            Severity::Blocking,
2246            "pre-existing",
2247            false
2248        )));
2249        assert!(!qualifies(&finding(
2250            Severity::NonBlocking,
2251            "improvement",
2252            true
2253        )));
2254        assert!(!qualifies(&finding(Severity::Nit, "taste", true)));
2255        assert!(!qualifies(&finding(
2256            Severity::Blocking,
2257            "fix it here",
2258            true
2259        )));
2260    }
2261
2262    #[test]
2263    fn opening_it_up_lets_non_blocking_findings_through_again() {
2264        let cfg = cfg_with(Followups::Issues, true, false, 5);
2265        assert!(cfg.loop_cfg.file_non_blocking);
2266    }
2267
2268    /// A run that will not stop finding things is stopped, and says so.
2269    #[test]
2270    fn the_cap_is_a_real_backstop() {
2271        let cfg = cfg_with(Followups::Issues, false, false, 3);
2272        let mut state = IssueRun::new(1, "t");
2273        state.filed = (0..3).map(|n| format!("url{n}")).collect();
2274        assert!(state.filed.len() >= cfg.loop_cfg.max_followups);
2275    }
2276
2277    /// The number that matters. Reviewing one issue produced ten follow-ups on
2278    /// a real repository, each of which could be run in turn: mean offspring
2279    /// above one never terminates.
2280    #[test]
2281    fn the_cap_bounds_what_one_run_can_spawn() {
2282        let cfg = cfg_with(Followups::Issues, false, false, 5);
2283        assert!(
2284            cfg.loop_cfg.max_followups <= 5,
2285            "a run that can file ten follow-ups is a branching process"
2286        );
2287    }
2288}
2289
2290#[cfg(test)]
2291mod issue_report_tests {
2292    use super::*;
2293    use crate::model::Severity;
2294
2295    /// Shaped after a bug report written by hand that reads the way one should:
2296    /// what is wrong, how to see it, what it costs, what it should do instead.
2297    fn reported() -> Finding {
2298        Finding {
2299            severity: Severity::Blocking,
2300            title: "sendPaymentAsync bypasses drain mode and spending limits".into(),
2301            detail: "The async path skips every admission check payInvoice applies.".into(),
2302            file: "src/node.ts:412".into(),
2303            in_scope: false,
2304            problem: Some(
2305                "`BeignetNode.sendPaymentAsync()` submits a payment directly to the Lightning \
2306                 engine without applying the safeguards used by `payInvoice()`.\n\nThe async path \
2307                 does not:\n\n- call `_checkDraining()`\n- call `_checkSpendLimit()`"
2308                    .into(),
2309            ),
2310            reproduction: Some(
2311                "1. Create a `BeignetNode` with `dailySpendLimitSats: 1`.\n2. Enable drain mode.\n\
2312                 3. Submit a 1,000 sat invoice.\n\nActual result:\n\n- The engine is called.\n\
2313                 - `spentSats` remains 0."
2314                    .into(),
2315            ),
2316            impact: Some(
2317                "An authorized client can submit async payments up to the available outbound \
2318                 liquidity despite the configured limits."
2319                    .into(),
2320            ),
2321            expected: Some(
2322                "- Reject new payments while draining.\n- Enforce the per-payment limit before \
2323                 submission.\n- Cover both paths with regression tests.\n\nThis predates the \
2324                 current branch."
2325                    .into(),
2326            ),
2327        }
2328    }
2329
2330    #[test]
2331    fn a_reported_finding_becomes_a_bug_report() {
2332        let body = issue_report(&reported());
2333        for heading in [
2334            "## Problem",
2335            "## Reproduction",
2336            "## Impact",
2337            "## Expected behavior",
2338        ] {
2339            assert!(body.contains(heading), "missing {heading}:\n{body}");
2340        }
2341        // In the order somebody reads a bug report.
2342        let at = |h: &str| body.find(h).unwrap();
2343        assert!(at("## Problem") < at("## Reproduction"));
2344        assert!(at("## Reproduction") < at("## Impact"));
2345        assert!(at("## Impact") < at("## Expected behavior"));
2346    }
2347
2348    #[test]
2349    fn the_substance_survives_the_outbound_gates() {
2350        let repo_style = Style::default();
2351        let body = crate::style::issue_body(&issue_report(&reported()), &repo_style);
2352        for kept in [
2353            "_checkDraining()",
2354            "Actual result:",
2355            "outbound liquidity",
2356            "regression tests",
2357            "predates the current branch",
2358        ] {
2359            assert!(body.contains(kept), "the gate ate {kept:?}:\n{body}");
2360        }
2361        assert!(!body.contains("..."), "something was cut:\n{body}");
2362    }
2363
2364    /// A finding that was never going to be filed carries none of this, and
2365    /// must not gain empty headings for the sake of a format.
2366    #[test]
2367    fn an_ordinary_finding_is_still_just_its_detail() {
2368        let plain = Finding {
2369            severity: Severity::NonBlocking,
2370            title: "Name is vague".into(),
2371            detail: "The variable could say what it holds.".into(),
2372            file: "a.rs".into(),
2373            in_scope: true,
2374            ..Default::default()
2375        };
2376        assert_eq!(
2377            "The variable could say what it holds.",
2378            issue_report(&plain)
2379        );
2380    }
2381
2382    /// Partial reports are normal: a defect with no useful reproduction should
2383    /// not sprout an empty Reproduction heading.
2384    #[test]
2385    fn only_the_sections_that_were_written_appear() {
2386        let partial = Finding {
2387            problem: Some("The guard is inverted.".into()),
2388            expected: Some("It should reject rather than accept.".into()),
2389            ..reported()
2390        };
2391        let partial = Finding {
2392            reproduction: None,
2393            impact: None,
2394            ..partial
2395        };
2396        let body = issue_report(&partial);
2397        assert!(body.contains("## Problem") && body.contains("## Expected behavior"));
2398        assert!(!body.contains("## Reproduction"), "{body}");
2399        assert!(!body.contains("## Impact"), "{body}");
2400    }
2401
2402    /// The one line the thread shows is not repeated when a section already
2403    /// says it.
2404    #[test]
2405    fn the_summary_line_is_not_printed_twice() {
2406        let echoed = Finding {
2407            detail: "The guard is inverted so it rejects valid input.".into(),
2408            problem: Some("The guard is inverted so it rejects valid input.".into()),
2409            reproduction: None,
2410            impact: None,
2411            expected: None,
2412            ..reported()
2413        };
2414        let body = issue_report(&echoed);
2415        assert_eq!(1, body.matches("The guard is inverted").count(), "{body}");
2416    }
2417}