use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use crate::agent::{self, Agent};
use crate::config::{Config, Drafts, Followups, PrComments};
use crate::error::{ErrorKind, Result, SparError};
use crate::jsonx::{exact_finding_key as finding_key, finding_file, stable_finding_key};
use crate::model::{
Action, Disposition, Dispute, Finding, Followup, Implementation, Issue, IssueRun, Ledger,
LedgerEntry, NextAction, PersistedState, PlanItem, PrView, ResponseDoc, Review, Settled,
Severity, SkippedItem, Status, STATE_VERSION,
};
use crate::repo::Repo;
use crate::style::{self, Style};
use crate::{log, logdim, logwarn, schema, spar_err};
const IMPLEMENT_PROMPT: &str = "\
Implement GitHub issue #{number} in this repository.
Title: {title}
URL: {url}
{body}
That is the issue body as filed. The discussion since is not included, so read
the thread at the URL above if the body leaves anything open. If you cannot
reach the network, work from what is here.
Do the work, then commit it on the current branch. Make focused commits with
clear messages. Do not push, do not open a PR, and do not merge; the harness
handles that.
Then report it. Your answer becomes the pull request description, and the
reviewer reads that cold, with nothing but the diff and a link to the issue:
say what you found wrong, what the change does about it, and how they confirm
it for themselves. Say what you actually ran, not what could be run.
If after reading the code you conclude this issue should not be implemented,
make no commits and set not_worth_doing, with the reason.";
const REVIEW_PROMPT: &str = "\
Review the changes on this branch against `{base}`. They implement issue
#{number}: {title}
Review thoroughly: correctness, edge cases, error handling, security, and
whether the change actually resolves the issue. Read surrounding code, do not
only read the diff.
Label every finding by severity, and be honest about which is which:
- blocking: the PR should not merge as is. Real defects only.
- non-blocking: real, and smaller than another round. A minor defect belongs
here as much as an improvement does.
- nit: style or taste.
Blocking is the only severity that costs a round, and a round is another commit
somebody has to read before this can merge. Being right that something is wrong
is not enough to block. It has to be wrong in a way that would cost somebody.
Confirm anything you label blocking before you label it. Run the code,
reproduce the failure, or point at the exact line that breaks, and say in the
detail what you did to confirm it. When you need to run something to check a
claim, write a scratch file and run that, rather than passing a long program on
the command line: it is easier to read back, easier to rerun, and less likely to
be refused by a sandbox or a safety filter part way through your work. An unverified blocking finding is worse than
one you never raised: it stalls a good PR and teaches the author to stop
believing you. If you suspect a problem but could not confirm it, say so and
label it non-blocking.
Set in_scope=false for a real defect that exists, that this PR did not cause, and
that is worth somebody stopping to fix. Each one becomes a tracked item a
maintainer has to read and triage, so the bar is a defect and not an observation.
A thorough reviewer can always find something adjacent to what it is reading;
that is not a reason to file it. If you are not sure it is worth a maintainer's
time, say your piece in the finding and label it non-blocking.
Reviewing one issue should not manufacture ten more. If you find yourself with
several out of scope findings, keep the ones that would bite somebody and drop
the rest.
Then choose next_action:
- merge: no blocking findings, the PR is good.
- fix_myself: there are blocking findings and you will fix them directly.
- hand_back: there are blocking findings the author should address.
{open}{answers}{settled}{round}";
const CLOSE_PROMPT: &str = "\
This closes the review of issue #{number}: {title}
This is the final merge-safety audit. Read the full branch against `{base}` and
answer one question: does the branch still contain anything that must not
merge. Do not spend this pass on optional improvements or style.
{landed}{open}{answers}{settled}
Something blocks here when the branch still contains a confirmed defect that
means it should not merge. That includes an open point above that the code does
not answer, a defect in what landed since the last round, or a serious defect an
earlier round missed. Go and look before you raise it. Run the test that covers
it, or read the relevant lines and follow them to the caller, and say in the
detail what you did.
Keep this pass focused on merge safety. Minor defects and improvements are
non-blocking. Keep in_scope=false for what it has always meant, a real defect
this pull request did not cause, which the harness handles according to the
configured follow-up policy. A confirmed in-scope defect does not become
non-blocking merely because an earlier round missed it.
Nothing you raise here will be fixed, because there is no round after this. A
blocking finding means the pull request stays open and the finding is reported
for a person to weigh. Non-blocking findings are reported without holding it
open. No blocking findings means the branch is signed off on your word, so do
not omit one because the list was long. Both mistakes cost somebody. Only one
of them ships.
Set next_action to merge when you raise nothing blocking, and hand_back when you
do. Nothing acts on it here, and the findings are what decide.
This call reads and nothing else. Do not edit the code, do not commit, and do
not push. A pass that writes has judged a branch the rollback then takes away,
so anything you leave behind is rolled back and the sign off does not stand.
Writing a scratch file to check a claim is fine.";
const FIX_PROMPT: &str = "\
You reviewed this branch and chose to fix the blocking findings yourself.
Implement those fixes now and commit them.
Your findings:
{findings}
Fix what the point says and nothing else. The smallest change that answers it is
the right one: no refactor alongside it, no capability nobody asked for, no
handling for cases nobody raised. Every line you add is what the next pass
reviews, so a fix that grows the branch buys another round of findings about the
fix. If a point cannot be answered without a change bigger than the point, say so
rather than making the change.
Commit your changes. Do not push, do not merge.";
const RESPOND_PROMPT: &str = "\
Here is a review of your PR for issue #{number}.
{findings}
For each point, choose exactly one disposition:
- fixed: the point is valid and in scope. Fix it and commit.
- refuted: the point is wrong, or the change it asks for is bigger than the
problem it names. Explain why. Refuting is a legitimate outcome; do not accept
a review comment you believe is incorrect just to get the PR approved.
- filed_issue: the point is valid but unrelated to this PR. Supply
new_issue_title and new_issue_body; the harness files it and skips duplicates.
Copy each finding's title and file across exactly as given, so your answer can
be matched back to the review. Give a reason for every disposition. For fixed,
say what changed and how it answers the point. For refuted, say why the point
does not stand. For filed_issue, say why it belongs outside this pull request.
Fix what the point says and nothing else. The smallest change that answers it is
the right one: no refactor alongside it, no capability nobody asked for, no
handling for cases nobody raised. Every line you add is what the next pass
reviews, so a fix that grows the branch buys another round of findings about the
fix. If a point cannot be answered without a change bigger than the point, say so
rather than making the change.
Commit any fixes. Do not push, do not merge.";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Snapshot {
pub head: String,
pub dirty: bool,
}
impl Snapshot {
pub fn landed_over(&self, before: &Snapshot) -> bool {
!self.head.is_empty() && self.head != before.head
}
}
pub fn snapshot(repo: &Repo, work_dir: &Path) -> Snapshot {
Snapshot {
head: repo
.git_try_at(Some(work_dir), &["rev-parse", "HEAD"])
.trim()
.to_string(),
dirty: !repo
.git_try_at(
Some(work_dir),
&["status", "--porcelain", "--untracked-files=no"],
)
.trim()
.is_empty(),
}
}
fn checked_head(repo: &Repo, work_dir: &Path) -> Result<String> {
let head = repo.git_at(Some(work_dir), &["rev-parse", "HEAD"])?;
let head = head.trim().to_string();
if head.is_empty() {
return Err(spar_err!("could not read the pull request head"));
}
Ok(head)
}
fn reconcile_saved_head(
recorded_head: Option<&str>,
actual_head: &str,
holder_override: Option<&str>,
pr_number: i64,
) -> Result<bool> {
let Some(recorded_head) = recorded_head else {
return Ok(true);
};
if !recorded_head.is_empty() && recorded_head == actual_head {
return Ok(true);
}
if holder_override.is_some() {
return Ok(false);
}
let recorded = if recorded_head.is_empty() {
"legacy or unknown"
} else {
recorded_head
};
Err(spar_err!(
"saved review state applies to head {recorded}, but PR #{pr_number} is at {actual_head}; \
resume with --next <agent> to choose who reviews this head"
))
}
pub fn park(repo: &Repo, work_dir: &Path) -> Option<String> {
let saved = repo
.git_try_at(Some(work_dir), &["stash", "create"])
.trim()
.to_string();
(!saved.is_empty()).then_some(saved)
}
fn reset_saving(repo: &Repo, work_dir: &Path, target: &str) {
let parked = park(repo, work_dir);
if let Err(e) = repo.git_at(Some(work_dir), &["reset", "--hard", target]) {
logdim!("could not roll the working tree back: {e}");
return;
}
if let Some(saved) = parked {
logdim!("`git stash apply {saved}` puts the discarded changes back");
}
}
pub fn undo_edits(repo: &Repo, work_dir: &Path, before: &Snapshot) -> Snapshot {
let current = snapshot(repo, work_dir);
if before.head.is_empty() {
return current;
}
if current.landed_over(before) {
logdim!(
"the commits being rolled back are still at {}",
current.head
);
}
reset_saving(repo, work_dir, &before.head);
snapshot(repo, work_dir)
}
fn preserve_closing_commit(repo: &Repo, ctx: &LoopCtx) -> Option<String> {
let current = snapshot(repo, &ctx.work_dir);
if current.head.is_empty() {
return None;
}
let reference = format!(
"refs/spar/recovery/pr-{}/closing-{}",
ctx.pr_number, current.head
);
match repo.git_at(
Some(&ctx.work_dir),
&["update-ref", &reference, ¤t.head],
) {
Ok(_) => Some(reference),
Err(e) => {
logdim!("could not preserve the closing pass commit: {e}");
None
}
}
}
pub fn drop_uncommitted(repo: &Repo, work_dir: &Path) -> Snapshot {
let current = snapshot(repo, work_dir);
if !current.dirty || current.head.is_empty() {
return current;
}
reset_saving(repo, work_dir, ¤t.head);
snapshot(repo, work_dir)
}
fn should_release(cfg: &Config, status: Status) -> bool {
if !cfg.loop_cfg.worktrees || cfg.loop_cfg.keep_worktrees {
return false;
}
!matches!(status, Status::Escalated | Status::Error)
}
pub fn run_issue(
agents: &[Agent],
cfg: &Config,
repo: &Repo,
item: &PlanItem,
issue: &Issue,
) -> IssueRun {
if let Some(existing) = repo.open_pr_for_issue(item.issue) {
log!(
"#{}: {} is already open, continuing it instead of implementing again",
item.issue,
existing.url
);
return resume_pr(agents, cfg, repo, existing.number, None);
}
let mut state = IssueRun::new(item.issue, item.title.clone());
let mut ledger = Ledger::new();
let base = cfg.base_branch().to_string();
let prepared = if cfg.loop_cfg.worktrees {
repo.worktree_add(item.issue, &base)
} else {
let branch = repo.branch_for_issue(item.issue);
let start = format!("origin/{base}");
repo.git(&["checkout", "-B", &branch, &start])
.map(|_| (repo.root().to_path_buf(), branch))
};
let (work_dir, branch) = match prepared {
Ok(pair) => pair,
Err(e) => {
state.status = Status::Error;
state.notes.push(e.to_string());
log!("#{} failed: {e}", item.issue);
return state;
}
};
let outcome = implement_and_review(
agents,
cfg,
repo,
item,
issue,
&mut ledger,
&mut state,
&work_dir,
&branch,
);
if let Err(e) = outcome {
state.status = Status::Error;
state.notes.push(e.to_string());
log!("#{} failed: {e}", item.issue);
}
if should_release(cfg, state.status) {
repo.worktree_remove(item.issue);
}
state
}
#[allow(clippy::too_many_arguments)]
fn implement_and_review(
agents: &[Agent],
cfg: &Config,
repo: &Repo,
item: &PlanItem,
issue: &Issue,
ledger: &mut Ledger,
state: &mut IssueRun,
work_dir: &Path,
branch: &str,
) -> Result<()> {
let number = item.issue;
let holder = cfg.first_implementor.clone();
let implementor = agent::find(agents, &holder)?;
let base = cfg.base_branch().to_string();
log!("#{number}: {holder} implementing");
let (body, shortened) = issue.body_for_prompt(cfg.loop_cfg.max_issue_chars);
if shortened {
logwarn!(
"#{number}: the issue body was shortened to fit the prompt. Raise max_issue_chars if \
the rest matters."
);
}
let prompt = implement_prompt(number, &item.title, &issue.url, &body);
let answer: Result<Implementation> = implementor.ask_json(
&prompt,
&schema::implementation(),
work_dir,
cfg.effort_for_round(&implementor.spec, 1).as_deref(),
);
let mut work = match answer {
Ok(work) => work,
Err(e) if repo.has_changes(work_dir, &base) => {
logwarn!(
"#{number}: {holder} failed after committing: {e}\nContinuing from the commits, \
with a pull request body written from their messages."
);
state
.notes
.push(format!("{holder} failed after committing: {e}"));
from_commits(repo, work_dir, &base)
}
Err(e) => return Err(e),
};
if work.not_worth_doing || !repo.has_changes(work_dir, &base) {
state.status = Status::Abandoned;
let reason = no_pr_note(&work, &repo.style);
state.notes.push(reason.clone());
if let Err(e) = repo.comment_issue(number, &reason) {
logdim!("could not comment on #{number}: {e}");
}
return Ok(());
}
if work.summary.trim().is_empty() {
work.summary = item.title.clone();
}
repo.rewrite_commits_if_needed(work_dir, &base)?;
repo.push(work_dir, branch)?;
let pr = match repo.pr_for_branch(branch) {
Some(existing) => existing,
None => {
let body = pr_body(number, &work, &repo.style);
repo.create_pr(
work_dir,
branch,
&base,
&format!("{} (#{number})", item.title),
&body,
)?
}
};
state.pr = Some(pr.url.clone());
log!("#{number}: PR {}", pr.url);
let ctx = LoopCtx {
work_dir: work_dir.to_path_buf(),
branch: branch.to_string(),
pr_number: pr.number,
label: format!("#{number}"),
subject: number,
title: item.title.clone(),
start_round: 1,
holder: cfg.other(&holder),
release: Release::Issue(number),
};
review_loop(agents, cfg, repo, &ctx, state, ledger, Vec::new())
}
pub fn resume_pr(
agents: &[Agent],
cfg: &Config,
repo: &Repo,
pr_number: i64,
holder_override: Option<&str>,
) -> IssueRun {
let failed = |e: SparError| {
log!("PR #{pr_number} failed: {e}");
let mut state = IssueRun::new(pr_number, format!("PR #{pr_number}"));
state.status = Status::Error;
state.notes.push(e.to_string());
state
};
let pr = match repo.pr_view(pr_number) {
Ok(pr) => pr,
Err(e) => return failed(e),
};
if pr.is_cross_repository {
log!("PR #{pr_number} comes from a fork, reviewing it without changing it");
return crate::review_only::review_pr(agents, cfg, repo, pr_number, false);
}
match resume_inner(agents, cfg, repo, pr, holder_override) {
Ok(state) => state,
Err(e) => failed(e),
}
}
fn resume_inner(
agents: &[Agent],
cfg: &Config,
repo: &Repo,
pr: PrView,
holder_override: Option<&str>,
) -> Result<IssueRun> {
let pr_number = pr.number;
if !pr.is_open() {
return Err(spar_err!("PR #{pr_number} is {}", pr.state.to_lowercase()));
}
let subject = pr
.closing_issues_references
.first()
.map(|r| r.number)
.unwrap_or(pr_number);
if let Some(holder) = holder_override {
if !cfg.has_agent(holder) {
return Err(spar_err!(
"--next must name one of: {}",
cfg.agent_names().join(", ")
));
}
}
let (work_dir, branch) = repo.worktree_for_pr(&pr)?;
let actual_head = checked_head(repo, &work_dir)?;
let saved = repo.read_state_for_head(&pr, &actual_head);
let state_matches_head = reconcile_saved_head(
saved.as_ref().map(|state| state.pr_head.as_str()),
&actual_head,
holder_override,
pr_number,
)?;
let mut ledger: Ledger = saved
.as_ref()
.filter(|_| state_matches_head)
.map(|s| s.ledger.clone())
.unwrap_or_default();
normalise_ledger_keys(&mut ledger);
let open_findings = saved
.as_ref()
.filter(|_| state_matches_head)
.map(|s| blocking_findings(&s.open_findings))
.unwrap_or_default();
let start_round = saved
.as_ref()
.filter(|_| state_matches_head)
.map(|s| s.round + 1)
.unwrap_or(1);
let default_holder = cfg.other(&cfg.first_implementor);
let mut holder = holder_override
.map(str::to_string)
.or_else(|| saved.as_ref().map(|s| s.next_actor.clone()))
.unwrap_or_else(|| default_holder.clone());
if !cfg.has_agent(&holder) {
log!("state named unknown agent '{holder}', using {default_holder}");
holder = default_holder;
}
match &saved {
Some(_) => log!(
"PR #{pr_number}: resuming at round {start_round}, {} point(s) on record, next up \
{holder}",
ledger.len()
),
None => log!("PR #{pr_number}: no prior spar state, starting fresh with {holder}"),
}
let mut state = IssueRun::new(subject, pr.title.clone());
state.pr = Some(pr.url.clone());
if let Some(s) = &saved {
state.filed = s.filed.clone();
if state_matches_head {
state.disputes = s.disputes.clone();
state.noted = s.noted.clone();
} else {
log!(
"PR #{pr_number}: saved state does not match {actual_head}; branch-dependent \
review state was cleared"
);
}
}
let ctx = LoopCtx {
work_dir,
branch,
pr_number,
label: format!("PR #{pr_number}"),
subject,
title: pr.title.clone(),
start_round,
holder,
release: Release::Pr(pr_number),
};
let outcome = review_loop(
agents,
cfg,
repo,
&ctx,
&mut state,
&mut ledger,
open_findings,
);
if let Err(e) = outcome {
state.status = Status::Error;
state.notes.push(e.to_string());
log!("PR #{pr_number} failed: {e}");
}
if should_release(cfg, state.status) {
repo.release_pr_worktree(pr_number);
}
Ok(state)
}
#[derive(Debug, Clone, Copy)]
enum Release {
Issue(i64),
Pr(i64),
}
struct LoopCtx {
work_dir: PathBuf,
branch: String,
pr_number: i64,
label: String,
subject: i64,
title: String,
start_round: u32,
holder: String,
release: Release,
}
impl LoopCtx {
fn release(&self, repo: &Repo) {
match self.release {
Release::Issue(n) => repo.worktree_remove(n),
Release::Pr(n) => repo.release_pr_worktree(n),
}
}
}
fn review_loop(
agents: &[Agent],
cfg: &Config,
repo: &Repo,
ctx: &LoopCtx,
state: &mut IssueRun,
ledger: &mut Ledger,
mut open_findings: Vec<Finding>,
) -> Result<()> {
let base = cfg.base_branch().to_string();
let mut holder = ctx.holder.clone();
let (first, last_allowed) = round_window(ctx.start_round, cfg.loop_cfg.max_rounds);
let mut published_head = checked_head(repo, &ctx.work_dir)?;
persist(
repo,
ctx.pr_number,
state,
ledger,
&open_findings,
&published_head,
first.saturating_sub(1),
&holder,
)?;
let mut audited_head = String::new();
let mut last_round = first.saturating_sub(1);
for round in first..=last_allowed {
last_round = round;
state.rounds = round;
let reviewer = agent::find(agents, &holder)?;
let effort = cfg.effort_for_round(&reviewer.spec, round);
log!(
"{}: round {round}, {holder} reviewing ({})",
ctx.label,
effort.as_deref().unwrap_or("default effort")
);
let prompt = review_prompt(
&base,
ctx.subject,
&ctx.title,
ledger,
&open_findings,
round,
last_allowed,
);
let before_review = snapshot(repo, &ctx.work_dir);
audited_head = before_review.head.clone();
let review: Review = reviewer.review(
&base,
&prompt,
&schema::review(),
&ctx.work_dir,
effort.as_deref(),
)?;
let mut editor: Option<String> = None;
let review_wrote = snapshot(repo, &ctx.work_dir) != before_review;
if review_wrote {
logwarn!(
"{}: {holder} changed the branch while reviewing it, which the review prompt \
forbids. Rolling it back.",
ctx.label
);
if undo_edits(repo, &ctx.work_dir, &before_review).head != before_review.head {
state
.notes
.push(format!("{holder} committed during its own review"));
editor = Some(holder.clone());
}
}
let blocking = blocking_findings(&review.findings);
update_open_findings(&mut open_findings, &blocking, !review_wrote);
if repo.style.pr_comments == PrComments::Rounds {
if let Err(e) = repo.comment_pr(
ctx.pr_number,
&review_comment(&holder, round, &review, &repo.style),
) {
logdim!("could not post the review comment: {e}");
}
}
file_out_of_scope(repo, &review.findings, ctx.subject, state, cfg);
file_nonblocking(repo, &review.findings, ctx.subject, state, cfg);
remove_findings(&mut state.noted, &blocking);
if check_relitigation(ledger, &blocking, state) {
state.status = Status::Escalated;
post_outcome(
repo,
ctx.pr_number,
state,
ledger,
Ending::Deadlocked(&blocking),
);
persist(
repo,
ctx.pr_number,
state,
ledger,
&open_findings,
&published_head,
round,
&holder,
)?;
return Ok(());
}
if approval_stands(&blocking, review_wrote) {
open_findings.clear();
return approve(
cfg,
repo,
ctx,
state,
ledger,
&published_head,
round,
&holder,
);
}
persist(
repo,
ctx.pr_number,
state,
ledger,
&open_findings,
&published_head,
round,
&holder,
)?;
if blocking.is_empty() {
logwarn!(
"{}: {holder} found nothing blocking on a branch it had changed itself, so the \
approval does not carry.",
ctx.label
);
state.notes.push(format!(
"{holder} passed the branch in round {round} after editing it; the edit was rolled \
back and the approval did not stand"
));
} else if review.next_action == NextAction::FixMyself {
log!("{}: {holder} fixing its own findings", ctx.label);
let prompt = FIX_PROMPT.replace("{findings}", &findings_for_prompt(&blocking));
let before_fix = snapshot(repo, &ctx.work_dir);
reviewer.ask(&prompt, &ctx.work_dir, effort.as_deref())?;
match editor_after(repo, &ctx.work_dir, &before_fix, &ctx.label, &holder) {
Some(who) => {
record_own_fixes(&blocking, ledger, state, round);
remove_findings(&mut open_findings, &blocking);
editor = Some(who);
}
None => {
logwarn!(
"{}: {holder} said it would fix its own findings and committed nothing, \
so it keeps the pull request.",
ctx.label
);
state.notes.push(format!(
"{holder} chose to fix its own findings in round {round} and committed \
nothing"
));
}
}
} else {
let author_name = cfg.other(&holder);
let author = agent::find(agents, &author_name)?;
log!(
"{}: handing {} finding(s) to {author_name}",
ctx.label,
blocking.len()
);
let prompt = RESPOND_PROMPT
.replace("{number}", &ctx.subject.to_string())
.replace("{findings}", &findings_for_prompt(&blocking));
let before_response = snapshot(repo, &ctx.work_dir);
let response: ResponseDoc = author.ask_json(
&prompt,
&schema::response(),
&ctx.work_dir,
cfg.effort_for_round(&author.spec, round).as_deref(),
)?;
if let Some(who) = editor_after(
repo,
&ctx.work_dir,
&before_response,
&ctx.label,
&author_name,
) {
editor = Some(who);
} else if response
.dispositions
.iter()
.any(|d| d.action == Action::Fixed)
{
logwarn!(
"{}: {author_name} reported fixes but committed nothing, so the diff does not \
have them.",
ctx.label
);
}
let unresolved = apply_dispositions(
repo,
cfg,
&response,
&blocking,
ledger,
state,
round,
ctx.subject,
ctx.pr_number,
&author_name,
editor.is_some(),
);
remove_findings(&mut open_findings, &blocking);
extend_findings(&mut open_findings, &unresolved);
}
if editor.is_some() {
repo.rewrite_commits_if_needed(&ctx.work_dir, &base)?;
repo.push(&ctx.work_dir, &ctx.branch)?;
published_head = checked_head(repo, &ctx.work_dir)?;
}
holder = next_reviewer(cfg, &holder, editor.as_deref());
persist(
repo,
ctx.pr_number,
state,
ledger,
&open_findings,
&published_head,
round,
&holder,
)?;
}
close_out(
agents,
cfg,
repo,
ctx,
state,
ledger,
&mut open_findings,
&holder,
last_round,
&audited_head,
&published_head,
)
}
#[allow(clippy::too_many_arguments)]
fn close_out(
agents: &[Agent],
cfg: &Config,
repo: &Repo,
ctx: &LoopCtx,
state: &mut IssueRun,
ledger: &mut Ledger,
open_findings: &mut Vec<Finding>,
holder: &str,
round: u32,
audited_head: &str,
published_head: &str,
) -> Result<()> {
let stop =
|state: &mut IssueRun, ledger: &Ledger, open_findings: &[Finding], ending: Ending<'_>| {
state.status = Status::Escalated;
state.notes.push(exhausted_note(ctx.start_round, round));
post_outcome(repo, ctx.pr_number, state, ledger, ending);
persist(
repo,
ctx.pr_number,
state,
ledger,
open_findings,
published_head,
round,
holder,
)
};
let landed = (!audited_head.is_empty())
.then(|| repo.commits_since(&ctx.work_dir, audited_head, "HEAD"))
.flatten();
if audited_head.is_empty()
|| (landed.as_ref().is_some_and(|l| l.is_empty()) && !any_fixes(ledger, round))
{
logdim!(
"{}: nothing landed after the last review, so there is nothing to close over",
ctx.label
);
if !open_findings.is_empty() {
state.notes.push(unresolved_note(open_findings.len()));
}
stop(
state,
ledger,
open_findings,
ending_without_landing(open_findings),
)?;
return Ok(());
}
let closer = agent::find(agents, holder)?;
let effort = cfg.effort_for_round(&closer.spec, closing_effort_round(round));
log!(
"{}: closing, {holder} checking what the last round left ({})",
ctx.label,
effort.as_deref().unwrap_or("default effort")
);
let prompt = close_prompt(
cfg.base_branch(),
ctx.subject,
&ctx.title,
audited_head,
landed.as_deref(),
ledger,
open_findings,
round,
);
let before = snapshot(repo, &ctx.work_dir);
let pass = closer.ask_json(&prompt, &schema::review(), &ctx.work_dir, effort.as_deref());
let close_wrote = snapshot(repo, &ctx.work_dir) != before;
let next = closing_next_actor(holder);
if close_wrote {
if let Err(error) = &pass {
logwarn!(
"{}: the closing pass failed after changing the branch: {error}",
ctx.label
);
}
logwarn!(
"{}: {holder} changed the branch while closing, which the prompt forbids. Rolling it \
back.",
ctx.label
);
let after_undo = undo_edits(repo, &ctx.work_dir, &before);
if after_undo != before {
state.notes.push(format!(
"{holder}'s closing-pass changes could not be fully rolled back"
));
if after_undo.head != before.head {
if let Some(reference) = preserve_closing_commit(repo, ctx) {
state.notes.push(format!(
"the closing pass commit was not pushed and remains at {reference}"
));
}
}
}
state.status = Status::Escalated;
state.notes.push(format!(
"{holder} edited the branch during the closing pass, so its answer did not stand"
));
post_unread_outcome(repo, ctx.pr_number, state, ledger, open_findings);
persist(
repo,
ctx.pr_number,
state,
ledger,
open_findings,
published_head,
round,
&next,
)?;
return Ok(());
}
let pass: Review = match pass {
Ok(pass) => pass,
Err(e) => {
logwarn!("{}: the closing pass failed: {e}", ctx.label);
state.status = Status::Escalated;
state.notes.push(exhausted_note(ctx.start_round, round));
post_unread_outcome(repo, ctx.pr_number, state, ledger, open_findings);
persist(
repo,
ctx.pr_number,
state,
ledger,
open_findings,
published_head,
round,
holder,
)?;
return Ok(());
}
};
file_out_of_scope(repo, &pass.findings, ctx.subject, state, cfg);
file_nonblocking(repo, &pass.findings, ctx.subject, state, cfg);
let blocking = blocking_findings(&pass.findings);
remove_findings(&mut state.noted, &blocking);
update_open_findings(open_findings, &blocking, true);
if check_relitigation(ledger, &blocking, state) {
state.status = Status::Escalated;
post_outcome(
repo,
ctx.pr_number,
state,
ledger,
Ending::Deadlocked(&blocking),
);
persist(
repo,
ctx.pr_number,
state,
ledger,
open_findings,
published_head,
round,
&next,
)?;
return Ok(());
}
if approval_stands(&blocking, false) {
open_findings.clear();
return approve(cfg, repo, ctx, state, ledger, published_head, round, &next);
}
state.status = Status::Escalated;
state.notes.push(unresolved_note(open_findings.len()));
post_outcome(
repo,
ctx.pr_number,
state,
ledger,
Ending::Unresolved(open_findings),
);
persist(
repo,
ctx.pr_number,
state,
ledger,
open_findings,
published_head,
round,
&next,
)?;
Ok(())
}
fn any_fixes(ledger: &Ledger, round: u32) -> bool {
ledger
.values()
.any(|e| e.outcome == Settled::Fixed && e.round >= round)
}
fn unresolved_note(left: usize) -> String {
match left {
1 => "one point left after the closing pass".to_string(),
n => format!("{n} points left after the closing pass"),
}
}
fn ensure_reviewed_head(pr_number: i64, reviewed_head: &str, live_head: &str) -> Result<()> {
if live_head == reviewed_head {
return Ok(());
}
Err(spar_err!(
"PR #{pr_number} changed from {reviewed_head} to {live_head} after it was reviewed; refusing to approve or merge an unread head"
))
}
#[allow(clippy::too_many_arguments)]
fn approve(
cfg: &Config,
repo: &Repo,
ctx: &LoopCtx,
state: &mut IssueRun,
ledger: &Ledger,
published_head: &str,
round: u32,
holder: &str,
) -> Result<()> {
let live_head = repo.pr_head_oid(ctx.pr_number)?;
ensure_reviewed_head(ctx.pr_number, published_head, &live_head)?;
state.status = Status::Approved;
persist(
repo,
ctx.pr_number,
state,
ledger,
&[],
published_head,
round,
holder,
)?;
let live_head = repo.pr_head_oid(ctx.pr_number)?;
ensure_reviewed_head(ctx.pr_number, published_head, &live_head)?;
post_outcome(repo, ctx.pr_number, state, ledger, Ending::Approved);
if cfg.loop_cfg.drafts == Drafts::UntilApproved && repo.mark_ready(ctx.pr_number) {
log!("{}: out of draft", ctx.label);
}
if cfg.loop_cfg.auto_merge {
ctx.release(repo);
repo.merge_pr_at_head(ctx.pr_number, published_head)?;
state.status = Status::Merged;
repo.clear_state(ctx.pr_number); log!("{}: merged", ctx.label);
} else {
log!("{}: approved, awaiting human merge", ctx.label);
}
Ok(())
}
fn approval_stands(blocking: &[Finding], review_wrote: bool) -> bool {
blocking.is_empty() && !review_wrote
}
fn next_reviewer(cfg: &Config, reviewer: &str, editor: Option<&str>) -> String {
match editor {
Some(editor) => cfg.other(editor),
None => reviewer.to_string(),
}
}
fn editor_after(
repo: &Repo,
work_dir: &Path,
before: &Snapshot,
label: &str,
who: &str,
) -> Option<String> {
let after = snapshot(repo, work_dir);
if after.dirty {
logwarn!(
"{label}: {who} left tracked files uncommitted. Only commits are pushed and the next \
review reads the tree, so they are discarded."
);
drop_uncommitted(repo, work_dir);
}
after.landed_over(before).then(|| who.to_string())
}
fn round_window(start_round: u32, budget: u32) -> (u32, u32) {
(start_round, start_round + budget.saturating_sub(1))
}
fn spent(start_round: u32, last_round: u32) -> (u32, u32) {
(last_round.saturating_sub(start_round) + 1, last_round)
}
fn exhausted_note(start_round: u32, last_round: u32) -> String {
let (this_run, total) = spent(start_round, last_round);
if this_run == total {
format!("no convergence after {this_run} rounds")
} else {
format!("no convergence after {this_run} more rounds ({total} in total)")
}
}
#[allow(clippy::too_many_arguments)]
fn persist(
repo: &Repo,
pr_number: i64,
state: &IssueRun,
ledger: &Ledger,
open_findings: &[Finding],
published_head: &str,
round: u32,
next_actor: &str,
) -> Result<()> {
let payload = PersistedState {
version: STATE_VERSION,
checkpoint: 0,
round,
next_actor: next_actor.to_string(),
status: state.status,
pr_head: published_head.to_string(),
ledger: ledger.clone(),
filed: state.filed.clone(),
open_findings: open_findings.to_vec(),
disputes: state.disputes.clone(),
noted: state.noted.clone(),
};
repo.write_state(pr_number, &payload)
}
fn settled_block(ledger: &Ledger) -> String {
if ledger.is_empty() {
return String::new();
}
let lines: Vec<String> = ledger
.values()
.filter_map(|e| {
let point = match e.file.trim() {
"" => e.title.clone(),
file => format!("{} ({file})", e.title),
};
match e.outcome {
Settled::Refuted => Some(format!("- {point}: refuted because {}", e.reasoning)),
Settled::Filed => Some(format!(
"- {point}: out of scope here, and filed. {}",
e.reasoning
)),
Settled::Dropped => Some(format!(
"- {point}: out of scope here, and not filed. {}",
e.reasoning
)),
Settled::Fixed => None,
}
})
.collect();
if lines.is_empty() {
return String::new();
}
format!(
"\nThe following points were already raised and settled, by a refutation or by a \
follow-up issue. Treat them as settled. Do not raise them again unless you have new \
evidence:\n{}",
lines.join("\n")
)
}
fn fixed_lines(ledger: &Ledger, since: u32) -> Vec<String> {
ledger
.values()
.filter(|e| e.outcome == Settled::Fixed && e.round >= since)
.map(|e| {
let claim = match e.reasoning.trim() {
"" => "a committed change claims to address this point",
reasoning => reasoning,
};
match e.file.trim() {
"" => format!("- {}. Recorded answer: {claim}", e.title),
file => format!("- {} ({file}). Recorded answer: {claim}", e.title),
}
})
.collect()
}
fn answers_block(ledger: &Ledger, round: u32) -> String {
let lines = fixed_lines(ledger, round.saturating_sub(1));
if lines.is_empty() {
return String::new();
}
format!(
"\nThese points were raised on this pull request in earlier rounds and the author says \
it fixed them. The code is on the branch and the claim is the author's:\n{}\n\nCheck the \
answer rather than taking it. If one of them is still not fixed, raise it again under \
the same title, so the run can tell a point that was not answered from a new one.\n",
lines.join("\n")
)
}
fn round_note(round: u32, last: u32) -> String {
if round < last {
return String::new();
}
"\nThis is the last round in this run that can ask the author for anything. After it, one \
pass reads what landed and the pull request is either signed off or goes to a person with \
what is left. Raise everything you mean to raise now. A point held back for a later round \
does not get one.\n"
.to_string()
}
#[cfg(test)]
fn matching_ledger_key(ledger: &Ledger, title: &str, file: &str) -> Option<String> {
matching_ledger_key_with_fallback(ledger, title, file, true)
}
fn matching_ledger_key_with_fallback(
ledger: &Ledger,
title: &str,
file: &str,
allow_stable_fallback: bool,
) -> Option<String> {
let exact = finding_key(title, file);
if ledger.contains_key(&exact) {
return Some(exact);
}
let legacy = crate::jsonx::finding_key(title, file);
if ledger
.get(&legacy)
.is_some_and(|entry| same_finding_parts(&entry.title, &entry.file, title, file))
{
return Some(legacy);
}
if !allow_stable_fallback {
return None;
}
let stable = stable_finding_key(title, file);
let path = finding_file(file);
let mut matches = ledger
.iter()
.filter(|(saved_key, entry)| {
if stable_finding_key(&entry.title, &entry.file) == stable {
return true;
}
finding_file(&entry.file) == path
&& saved_key.as_str() == crate::jsonx::finding_key(title, &entry.file)
})
.map(|(key, _)| key.clone());
let first = matches.next()?;
matches.next().is_none().then_some(first)
}
fn matching_ledger_entry<'a>(
ledger: &'a Ledger,
title: &str,
file: &str,
) -> Option<&'a LedgerEntry> {
matching_ledger_entry_with_fallback(ledger, title, file, true)
}
fn matching_ledger_entry_with_fallback<'a>(
ledger: &'a Ledger,
title: &str,
file: &str,
allow_stable_fallback: bool,
) -> Option<&'a LedgerEntry> {
let key = matching_ledger_key_with_fallback(ledger, title, file, allow_stable_fallback)?;
ledger.get(&key)
}
fn settle(
ledger: &mut Ledger,
title: &str,
file: &str,
allow_stable_fallback: bool,
entry: LedgerEntry,
) {
let old_key = matching_ledger_key_with_fallback(ledger, title, file, allow_stable_fallback);
let reraised = old_key
.as_ref()
.and_then(|key| ledger.get(key))
.map(|entry| entry.reraised)
.unwrap_or(0);
if let Some(old_key) = old_key {
ledger.remove(&old_key);
}
ledger.insert(finding_key(title, file), LedgerEntry { reraised, ..entry });
}
fn normalise_ledger_keys(ledger: &mut Ledger) {
let mut normalised = Ledger::new();
for (saved_key, mut entry) in std::mem::take(ledger) {
let key = if saved_key.len() == 12
&& saved_key
.chars()
.all(|character| character.is_ascii_hexdigit())
{
saved_key
} else {
finding_key(&entry.title, &entry.file)
};
if let Some(previous) = normalised.get_mut(&key) {
let reraised = previous.reraised.max(entry.reraised);
if entry.round >= previous.round {
entry.reraised = reraised;
*previous = entry;
} else {
previous.reraised = reraised;
}
} else {
normalised.insert(key, entry);
}
}
*ledger = normalised;
}
fn blocking_findings(findings: &[Finding]) -> Vec<Finding> {
let mut kept = Vec::new();
for finding in findings.iter().filter(|finding| finding.blocks()) {
if let Some(existing) = kept
.iter_mut()
.find(|existing| same_finding(existing, finding))
{
*existing = finding.clone();
} else {
kept.push(finding.clone());
}
}
kept
}
fn matching_finding_index(
findings: &[Finding],
target: &Finding,
allow_stable_fallback: bool,
) -> Option<usize> {
if let Some(index) = findings
.iter()
.position(|finding| same_finding(finding, target))
{
return Some(index);
}
if !allow_stable_fallback {
return None;
}
let stable = stable_finding_key(&target.title, &target.file);
let mut matches = findings
.iter()
.enumerate()
.filter(|(_, finding)| stable_finding_key(&finding.title, &finding.file) == stable);
let first = matches.next().map(|(index, _)| index);
first.filter(|_| matches.next().is_none())
}
fn unique_stable_finding(findings: &[Finding], target: &Finding) -> bool {
let stable = stable_finding_key(&target.title, &target.file);
findings
.iter()
.filter(|finding| stable_finding_key(&finding.title, &finding.file) == stable)
.count()
== 1
}
fn extend_findings(target: &mut Vec<Finding>, additions: &[Finding]) {
for finding in additions {
if let Some(index) =
matching_finding_index(target, finding, unique_stable_finding(additions, finding))
{
target[index] = finding.clone();
} else {
target.push(finding.clone());
}
}
}
fn update_open_findings(
open_findings: &mut Vec<Finding>,
current: &[Finding],
answer_stands: bool,
) {
if answer_stands {
*open_findings = current.to_vec();
} else {
extend_findings(open_findings, current);
}
}
fn remove_findings(target: &mut Vec<Finding>, removed: &[Finding]) {
for finding in removed {
if let Some(index) =
matching_finding_index(target, finding, unique_stable_finding(removed, finding))
{
target.remove(index);
}
}
}
fn ending_without_landing(open_findings: &[Finding]) -> Ending<'_> {
if open_findings.is_empty() {
Ending::Unchanged
} else {
Ending::Unresolved(open_findings)
}
}
fn closing_effort_round(round: u32) -> u32 {
round.saturating_add(1)
}
fn closing_next_actor(holder: &str) -> String {
holder.to_string()
}
fn remember_noted(state: &mut IssueRun, finding: &Finding, allow_stable_fallback: bool) {
if let Some(index) = matching_finding_index(&state.noted, finding, allow_stable_fallback) {
state.noted[index] = finding.clone();
} else {
state.noted.push(finding.clone());
}
}
fn forget_noted(state: &mut IssueRun, finding: &Finding, allow_stable_fallback: bool) {
if let Some(index) = matching_finding_index(&state.noted, finding, allow_stable_fallback) {
state.noted.remove(index);
}
}
fn remember_dispute(state: &mut IssueRun, dispute: Dispute, allow_stable_fallback: bool) {
let exact = finding_key(&dispute.title, &dispute.file);
let stable = stable_finding_key(&dispute.title, &dispute.file);
let exact_index = state
.disputes
.iter()
.position(|kept| finding_key(&kept.title, &kept.file) == exact);
let stable_index = if exact_index.is_none() && allow_stable_fallback {
let mut matches = state
.disputes
.iter()
.enumerate()
.filter(|(_, kept)| stable_finding_key(&kept.title, &kept.file) == stable);
let first = matches.next().map(|(index, _)| index);
first.filter(|_| matches.next().is_none())
} else {
None
};
if let Some(index) = exact_index.or(stable_index) {
state.disputes[index] = dispute;
} else {
state.disputes.push(dispute);
}
}
fn forget_dispute(state: &mut IssueRun, finding: &Finding, allow_stable_fallback: bool) {
let exact = finding_key(&finding.title, &finding.file);
if let Some(index) = state
.disputes
.iter()
.position(|kept| finding_key(&kept.title, &kept.file) == exact)
{
state.disputes.remove(index);
return;
}
if !allow_stable_fallback {
return;
}
let stable = stable_finding_key(&finding.title, &finding.file);
let mut matches = state
.disputes
.iter()
.enumerate()
.filter(|(_, kept)| stable_finding_key(&kept.title, &kept.file) == stable);
let first = matches.next().map(|(index, _)| index);
if let Some(index) = first.filter(|_| matches.next().is_none()) {
state.disputes.remove(index);
}
}
#[cfg(test)]
fn record_nonblocking_outcome(state: &mut IssueRun, finding: &Finding, outcome: Option<&Followup>) {
record_nonblocking_outcome_with_match(state, finding, outcome, true);
}
fn record_nonblocking_outcome_with_match(
state: &mut IssueRun,
finding: &Finding,
outcome: Option<&Followup>,
allow_stable_fallback: bool,
) {
forget_dispute(state, finding, allow_stable_fallback);
if let Some(Followup::Recorded(url)) = outcome {
if !state.filed.iter().any(|filed| filed == url) {
state.filed.push(url.clone());
}
forget_noted(state, finding, allow_stable_fallback);
} else {
remember_noted(state, finding, allow_stable_fallback);
}
}
fn record_own_fixes(blocking: &[Finding], ledger: &mut Ledger, state: &mut IssueRun, round: u32) {
for finding in blocking {
let allow_stable_fallback = unique_stable_finding(blocking, finding);
settle(
ledger,
&finding.title,
&finding.file,
allow_stable_fallback,
LedgerEntry {
title: finding.title.clone(),
file: finding.file.clone(),
reasoning: "a committed change was made for this point".to_string(),
round,
reraised: 0,
outcome: Settled::Fixed,
},
);
forget_noted(state, finding, allow_stable_fallback);
forget_dispute(state, finding, allow_stable_fallback);
}
}
fn check_relitigation(ledger: &mut Ledger, blocking: &[Finding], state: &mut IssueRun) -> bool {
let mut escalate = false;
let mut counted: BTreeSet<String> = BTreeSet::new();
for finding in blocking {
let allow_stable_fallback = unique_stable_finding(blocking, finding);
let Some(key) = matching_ledger_key_with_fallback(
ledger,
&finding.title,
&finding.file,
allow_stable_fallback,
) else {
continue;
};
if !counted.insert(key.clone()) {
continue;
}
if let Some(entry) = ledger.get_mut(&key) {
entry.reraised += 1;
if entry.outcome == Settled::Refuted {
remember_dispute(
state,
Dispute {
title: finding.title.clone(),
file: finding.file.clone(),
reasoning: entry.reasoning.clone(),
},
allow_stable_fallback,
);
}
if entry.reraised >= 2 {
state.notes.push(format!(
"'{}' {}",
finding.title,
why_escalated(entry.outcome)
));
escalate = true;
}
}
}
escalate
}
fn why_escalated(outcome: Settled) -> &'static str {
match outcome {
Settled::Fixed => "was fixed twice and raised again; escalating.",
_ => "was settled and re-raised twice; escalating.",
}
}
fn normalise(text: &str) -> String {
text.to_lowercase()
.chars()
.filter(|c| c.is_ascii_alphanumeric() || c.is_whitespace())
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
pub(crate) fn same_point(a: &str, b: &str) -> bool {
normalise(a) == normalise(b)
}
pub(crate) fn same_finding(a: &Finding, b: &Finding) -> bool {
same_finding_parts(&a.title, &a.file, &b.title, &b.file)
}
pub(crate) fn same_finding_parts(a_title: &str, a_file: &str, b_title: &str, b_file: &str) -> bool {
finding_key(a_title, a_file) == finding_key(b_title, b_file)
}
fn disposition_matches(finding: &Finding, disposition: &Disposition) -> bool {
same_point(&finding.title, &disposition.title) && finding.file.trim() == disposition.file.trim()
}
fn matching_disposition<'a>(
finding: &Finding,
dispositions: &'a [Disposition],
) -> std::result::Result<(usize, &'a Disposition), &'static str> {
let mut matches = dispositions
.iter()
.enumerate()
.filter(|(_, disposition)| disposition_matches(finding, disposition));
let first = matches.next().ok_or("no matching disposition")?;
if matches.next().is_some() {
return Err("more than one matching disposition");
}
Ok(first)
}
fn fixed_disposition_resolves(committed: bool) -> bool {
committed
}
#[allow(clippy::too_many_arguments)]
fn apply_dispositions(
repo: &Repo,
cfg: &Config,
response: &ResponseDoc,
blocking: &[Finding],
ledger: &mut Ledger,
state: &mut IssueRun,
round: u32,
subject: i64,
pr_number: i64,
author: &str,
committed: bool,
) -> Vec<Finding> {
let mut fixed = Vec::new();
let mut refuted = Vec::new();
let mut filed = Vec::new();
let mut unresolved = Vec::new();
let mut used = vec![false; response.dispositions.len()];
for source in blocking {
let (index, d) = match matching_disposition(source, &response.dispositions) {
Ok((index, disposition)) if !used[index] => (index, disposition),
Ok(_) => {
logwarn!(
"'{}' has more than one matching disposition, so it stays open",
source.title
);
unresolved.push(source.clone());
continue;
}
Err(reason) => {
logwarn!("'{}' has {reason}, so it stays open", source.title);
unresolved.push(source.clone());
continue;
}
};
used[index] = true;
let file = source.file.clone();
let canonical = source.title.as_str();
let title = style::title(canonical, &repo.style);
let located_title = match file.trim() {
"" => title.clone(),
location => format!("{title} ({location})"),
};
let allow_stable_fallback = unique_stable_finding(blocking, source);
match d.action {
Action::Refuted => {
let reasoning = style::summary(&d.reasoning, &repo.style);
settle(
ledger,
canonical,
&file,
allow_stable_fallback,
LedgerEntry {
title: canonical.to_string(),
file: file.clone(),
reasoning: reasoning.clone(),
round,
reraised: 0,
outcome: Settled::Refuted,
},
);
remember_dispute(
state,
Dispute {
title: canonical.to_string(),
file: file.clone(),
reasoning: reasoning.clone(),
},
allow_stable_fallback,
);
forget_noted(state, source, allow_stable_fallback);
refuted.push(format!("{located_title}. {reasoning}"));
}
Action::FiledIssue => {
let new_title = d
.new_issue_title
.clone()
.filter(|t| !t.trim().is_empty())
.unwrap_or_else(|| d.title.clone());
let new_body = d
.new_issue_body
.clone()
.filter(|b| !b.trim().is_empty())
.unwrap_or_else(|| d.reasoning.clone());
let recorded = file_followup(repo, &new_title, &new_body, subject, cfg, state);
if let Some(url) = recorded.url() {
state.filed.push(url.to_string());
filed.push(url.to_string());
}
let Some((outcome, reasoning)) =
filed_entry(&recorded, &style::summary(&d.reasoning, &repo.style))
else {
logwarn!(
"'{title}' was not recorded anywhere, so it stays open for the next round"
);
unresolved.push(source.clone());
continue;
};
settle(
ledger,
canonical,
&file,
allow_stable_fallback,
LedgerEntry {
title: canonical.to_string(),
file: file.clone(),
reasoning,
round,
reraised: 0,
outcome,
},
);
if outcome == Settled::Dropped {
remember_noted(state, source, allow_stable_fallback);
} else {
forget_noted(state, source, allow_stable_fallback);
}
forget_dispute(state, source, allow_stable_fallback);
}
Action::Fixed => {
if fixed_disposition_resolves(committed) {
settle(
ledger,
canonical,
&file,
allow_stable_fallback,
LedgerEntry {
title: canonical.to_string(),
file: file.clone(),
reasoning: style::summary(&d.reasoning, &repo.style),
round,
reraised: 0,
outcome: Settled::Fixed,
},
);
forget_noted(state, source, allow_stable_fallback);
forget_dispute(state, source, allow_stable_fallback);
fixed.push(located_title);
} else {
unresolved.push(source.clone());
}
}
}
}
for (index, disposition) in response.dispositions.iter().enumerate() {
if !used[index] {
logwarn!(
"ignoring an unmatched or duplicate disposition for '{}' ({})",
disposition.title,
disposition.file
);
}
}
if repo.style.pr_comments == PrComments::Rounds {
let comment = disposition_comment(author, response, &fixed, &refuted, &filed, &repo.style);
if let Some(text) = comment {
if let Err(e) = repo.comment_pr(pr_number, &text) {
logdim!("could not post the disposition comment: {e}");
}
}
}
unresolved
}
fn filed_entry(recorded: &Followup, reasoning: &str) -> Option<(Settled, String)> {
let (outcome, tail) = match recorded {
Followup::Recorded(reference) => (
Settled::Filed,
format!("Tracked in {}.", as_reference(reference)),
),
Followup::Covered(reference) => (
Settled::Filed,
format!("Already covered by {}.", as_reference(reference)),
),
Followup::Dropped(why) => (Settled::Dropped, format!("Not filed anywhere: {why}.")),
Followup::Failed => return None,
};
let reasoning = match reasoning.trim() {
"" => tail,
said => format!("{said} {tail}"),
};
Some((outcome, reasoning))
}
fn external_followup_write_paused(destination: Followups, state: &IssueRun) -> bool {
destination == Followups::Issues && state.followup_writes_uncertain
}
fn failed_followup(state: &mut IssueRun, error: &SparError) -> Followup {
if error.kind() == ErrorKind::UncertainWrite {
state.followup_writes_uncertain = true;
if !state
.notes
.iter()
.any(|note| note.contains("external follow-up writes were paused"))
{
state.notes.push(
"An external follow-up write could not be verified, so further external \
follow-up writes were paused for this run. Inspect recent issues and comments \
before trying them again."
.to_string(),
);
}
}
Followup::Failed
}
pub fn file_followup(
repo: &Repo,
title: &str,
body: &str,
source: i64,
cfg: &Config,
state: &mut IssueRun,
) -> Followup {
if repo.followups == Followups::None {
return Followup::Dropped("follow-ups are off for this repository");
}
if external_followup_write_paused(repo.followups, state) {
logdim!(
"not attempting another external follow-up write after an earlier result could not \
be verified"
);
return Followup::Failed;
}
if state.filed.len() >= cfg.loop_cfg.max_followups {
logwarn!(
"already recorded {} follow-ups, not recording '{}'. Raise max_followups if you want \
them all.",
state.filed.len(),
style::title(title, &repo.style)
);
return Followup::Dropped("this run had already recorded as many follow-ups as it may");
}
let title = match repo.clean_title(title) {
Ok(title) => title,
Err(e) => {
logdim!("could not clean a follow-up title: {e}");
return Followup::Failed;
}
};
if title.trim().is_empty() {
logdim!("nothing left of a follow-up title after cleaning it");
return Followup::Failed;
}
let body = format!(
"{}\n\nFound while working on #{source}.",
style::issue_body(body, &repo.style)
);
if repo.followups == Followups::Local {
return repo.append_local_followup(&title, &body);
}
match file_as_issue(repo, &title, &body) {
Ok(filed) => filed.into(),
Err(e) => {
logdim!("could not file a follow-up for '{title}': {e}");
failed_followup(state, &e)
}
}
}
#[derive(Debug, Clone)]
pub enum Filed {
Opened(i64, String),
AddedTo(i64, String),
Covered(i64, String),
AlreadyClosed(i64, String),
}
impl From<Filed> for Followup {
fn from(filed: Filed) -> Self {
match filed {
Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => {
Followup::Recorded(url)
}
Filed::AlreadyClosed(_, url) => Followup::Covered(url),
}
}
}
impl Filed {
pub fn url(&self) -> Option<&str> {
match self {
Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => Some(url),
Filed::AlreadyClosed(_, _) => None,
}
}
pub fn issue(&self) -> i64 {
match self {
Filed::Opened(n, _)
| Filed::AddedTo(n, _)
| Filed::Covered(n, _)
| Filed::AlreadyClosed(n, _) => *n,
}
}
pub fn number(&self) -> Option<i64> {
match self {
Filed::Opened(n, _) | Filed::AddedTo(n, _) | Filed::Covered(n, _) => Some(*n),
Filed::AlreadyClosed(_, _) => None,
}
}
pub fn note(&self) -> String {
match self {
Filed::Opened(n, _) => format!("#{n}"),
Filed::AddedTo(n, _) => format!("added to #{n}"),
Filed::Covered(n, _) => format!("#{n} already says this"),
Filed::AlreadyClosed(n, _) => format!("#{n} covers it and is closed"),
}
}
pub fn describe(&self, title: &str) -> String {
let title = style::clip(title.trim(), 80);
match self {
Filed::Opened(n, _) => format!("filed #{n}: {title}"),
Filed::AddedTo(n, _) => format!("added to #{n}: {title}"),
Filed::Covered(n, _) => format!("#{n} already says this: {title}"),
Filed::AlreadyClosed(n, _) => format!("#{n} covers it and is closed: {title}"),
}
}
}
pub fn file_as_issue(repo: &Repo, title: &str, body: &str) -> Result<Filed> {
file_as_issue_apart_from(repo, title, body, None)
}
pub fn file_as_issue_apart_from(
repo: &Repo,
title: &str,
body: &str,
apart_from: Option<i64>,
) -> Result<Filed> {
let title = repo.clean_title(title)?;
if title.trim().is_empty() {
return Err(spar_err!("nothing left of the title after cleaning it"));
}
let issue_body = repo.clean_issue_body(body)?;
if let Some(existing) = repo.try_exact_issue_apart_from(&title, &issue_body, apart_from)? {
return Ok(if existing.open {
Filed::Covered(existing.number, existing.url)
} else {
Filed::AlreadyClosed(existing.number, existing.url)
});
}
if let Some(existing) =
repo.try_find_similar_issue_apart_from(&title, &issue_body, apart_from)?
{
let known = format!("{} {}", existing.title, existing.body);
if !existing.open {
return Ok(Filed::AlreadyClosed(existing.number, existing.url));
}
if crate::textsim::adds_information(&issue_body, &known) {
repo.comment_issue(existing.number, &issue_body)?;
return Ok(Filed::AddedTo(existing.number, existing.url));
}
return Ok(Filed::Covered(existing.number, existing.url));
}
let url = repo.create_issue_apart_from(&title, &issue_body, apart_from)?;
let number = filed_issue_number(&url)
.ok_or_else(|| spar_err!("filed an issue but could not read its number from {url}"))?;
Ok(Filed::Opened(number, url))
}
fn file_out_of_scope(
repo: &Repo,
findings: &[Finding],
subject: i64,
state: &mut IssueRun,
cfg: &Config,
) {
for finding in findings.iter().filter(|f| !f.in_scope) {
let body = issue_report(finding);
let recorded = file_followup(repo, &finding.title, &body, subject, cfg, state);
if finding.severity != Severity::Nit || matches!(recorded, Followup::Recorded(_)) {
record_nonblocking_outcome_with_match(
state,
finding,
Some(&recorded),
unique_stable_finding(findings, finding),
);
}
}
}
pub fn issue_report(finding: &Finding) -> String {
let sections = finding.report_sections();
if sections.is_empty() {
return finding.detail.clone();
}
let mut out: Vec<String> = sections
.iter()
.map(|(heading, text)| format!("## {heading}\n\n{text}"))
.collect();
if !finding.detail.trim().is_empty()
&& !sections
.iter()
.any(|(_, text)| crate::textsim::same_point(text, &finding.detail))
{
out.insert(0, finding.detail.trim().to_string());
}
out.join("\n\n")
}
fn file_nonblocking(
repo: &Repo,
findings: &[Finding],
subject: i64,
state: &mut IssueRun,
cfg: &Config,
) {
for finding in findings {
if !finding.in_scope || finding.severity == Severity::Blocking {
continue;
}
let should_file = match finding.severity {
Severity::NonBlocking => cfg.loop_cfg.file_non_blocking,
Severity::Nit => cfg.loop_cfg.file_nits,
Severity::Blocking => false,
};
if !should_file {
if finding.severity == Severity::NonBlocking {
record_nonblocking_outcome_with_match(
state,
finding,
None,
unique_stable_finding(findings, finding),
);
}
continue;
}
let recorded = file_followup(repo, &finding.title, &finding.detail, subject, cfg, state);
if finding.severity == Severity::NonBlocking {
record_nonblocking_outcome_with_match(
state,
finding,
Some(&recorded),
unique_stable_finding(findings, finding),
);
} else if let Some(url) = recorded.url() {
state.filed.push(url.to_string());
}
}
}
fn bullets(lines: &[String]) -> String {
lines
.iter()
.map(|l| format!("- {l}"))
.collect::<Vec<_>>()
.join("\n")
}
fn located(finding: &Finding, style: &Style) -> String {
let title = style::title(&finding.title, style);
match finding.where_at() {
"general" => title,
file => format!("{title} ({file})"),
}
}
pub enum Ending<'a> {
Approved,
OutOfRounds,
Unchanged,
Unresolved(&'a [Finding]),
Deadlocked(&'a [Finding]),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OutcomeSink {
PullRequest,
Terminal,
}
fn outcome_sink(mode: PrComments) -> OutcomeSink {
match mode {
PrComments::Outcome | PrComments::Rounds => OutcomeSink::PullRequest,
PrComments::None => OutcomeSink::Terminal,
}
}
fn emit_outcome(repo: &Repo, pr_number: i64, text: &str) {
if outcome_sink(repo.style.pr_comments) == OutcomeSink::Terminal {
println!("\n{text}\n");
return;
}
if let Err(e) = repo.comment_pr(pr_number, text) {
logdim!("could not post the outcome comment: {e}");
println!("\n{text}\n");
}
}
pub fn post_outcome(
repo: &Repo,
pr_number: i64,
state: &IssueRun,
ledger: &Ledger,
ending: Ending<'_>,
) {
let Some(text) = outcome_comment(state, ledger, &ending, &repo.style) else {
return;
};
emit_outcome(repo, pr_number, &text);
}
fn post_unread_outcome(
repo: &Repo,
pr_number: i64,
state: &IssueRun,
ledger: &Ledger,
open_findings: &[Finding],
) {
let Some(text) = outcome_comment_with_unread(
state,
ledger,
&Ending::OutOfRounds,
open_findings,
&repo.style,
) else {
return;
};
emit_outcome(repo, pr_number, &text);
}
fn settled_as(finding: &Finding, state: &IssueRun, ledger: &Ledger) -> Option<(Settled, String)> {
if let Some(d) = state
.disputes
.iter()
.find(|d| same_finding_parts(&d.title, &d.file, &finding.title, &finding.file))
{
if !d.reasoning.trim().is_empty() {
return Some((Settled::Refuted, d.reasoning.clone()));
}
}
matching_ledger_entry(ledger, &finding.title, &finding.file)
.filter(|entry| !entry.reasoning.trim().is_empty())
.map(|entry| (entry.outcome, entry.reasoning.clone()))
}
pub fn filed_issue_number(filed: &str) -> Option<i64> {
filed
.rsplit('/')
.next()
.and_then(|tail| tail.parse::<i64>().ok())
.filter(|n| *n > 0)
}
fn as_reference(url: &str) -> String {
match url.rsplit('/').next().and_then(|n| n.parse::<u64>().ok()) {
Some(number) => format!("#{number}"),
None => url.to_string(),
}
}
pub fn outcome_comment(
state: &IssueRun,
ledger: &Ledger,
ending: &Ending<'_>,
style: &Style,
) -> Option<String> {
outcome_comment_with_unread(state, ledger, ending, &[], style)
}
fn outcome_comment_with_unread(
state: &IssueRun,
ledger: &Ledger,
ending: &Ending<'_>,
unread_open: &[Finding],
style: &Style,
) -> Option<String> {
let mut out: Vec<String> = Vec::new();
let mut already: Vec<(String, String)> = Vec::new();
match ending {
Ending::Approved => {
if state.disputes.is_empty() && state.filed.is_empty() && state.noted.is_empty() {
return None;
}
out.push("Reviewed, nothing blocking a merge.".into());
}
Ending::OutOfRounds => {
out.push(
"Not signed off: the last round of fixes was pushed but has not been reviewed."
.into(),
);
if !unread_open.is_empty() {
let lines: Vec<String> = unread_open
.iter()
.map(|finding| {
already.push((finding.title.clone(), finding.file.clone()));
format!(
"{}. {}",
located(finding, style),
style::sentence(&finding.detail, style)
)
})
.collect();
out.push("These points were already open:".into());
out.push(bullets(&lines));
}
}
Ending::Unchanged => out.push(
"Not signed off: the last round changed nothing, so the branch is the one that was \
already reviewed."
.into(),
),
Ending::Unresolved(points) => {
let lines: Vec<String> = points
.iter()
.map(|f| {
already.push((f.title.clone(), f.file.clone()));
format!(
"{}. {}",
located(f, style),
style::sentence(&f.detail, style)
)
})
.collect();
out.push("Not signed off. These points are still open:".into());
out.push(bullets(&lines));
}
Ending::Deadlocked(points) => {
let lines: Vec<String> = points
.iter()
.map(|f| {
let where_at = match f.where_at() {
"general" => String::new(),
file => format!(" ({file})"),
};
let title = style::title(&f.title, style);
already.push((f.title.clone(), f.file.clone()));
match settled_as(f, state, ledger) {
Some((Settled::Refuted, reason)) => format!(
"{title}{where_at}. Refuted as: {}",
style::summary(&reason, style)
),
Some((Settled::Filed, reason)) => format!(
"{title}{where_at}. Filed as out of scope: {}",
style::summary(&reason, style)
),
Some((Settled::Dropped, reason)) => format!(
"{title}{where_at}. Out of scope here, and not filed: {}",
style::summary(&reason, style)
),
Some((Settled::Fixed, reason)) => format!(
"{title}{where_at}. Fixed and raised again. Recorded answer: {}",
style::summary(&reason, style)
),
None => format!("{title}{where_at}"),
}
})
.collect();
out.push("Needs your decision. The reviewers could not settle this:".into());
out.push(bullets(&lines));
}
}
let disputes: Vec<&crate::model::Dispute> = state
.disputes
.iter()
.filter(|d| {
!already
.iter()
.any(|(title, file)| same_finding_parts(title, file, &d.title, &d.file))
})
.collect();
if !disputes.is_empty() {
let lines: Vec<String> = disputes
.iter()
.map(|d| {
let title = style::title(&d.title, style);
let title = if d.file.trim().is_empty() {
title
} else {
format!("{title} ({})", d.file.trim())
};
format!("{}. {}", title, style::sentence(&d.reasoning, style))
})
.collect();
out.push(format!("Raised and refuted:\n{}", bullets(&lines)));
}
if !state.noted.is_empty() {
let lines: Vec<String> = state
.noted
.iter()
.filter(|f| {
!already
.iter()
.any(|(title, file)| same_finding_parts(title, file, &f.title, &f.file))
})
.map(|f| located(f, style))
.collect();
if !lines.is_empty() {
out.push(format!("Noted, not blocking:\n{}", bullets(&lines)));
}
}
if !state.filed.is_empty() {
let refs: Vec<String> = state.filed.iter().map(|u| as_reference(u)).collect();
out.push(format!("Filed separately: {}", refs.join(", ")));
}
Some(out.join("\n\n"))
}
#[allow(clippy::too_many_arguments)]
fn close_prompt(
base: &str,
number: i64,
title: &str,
from: &str,
landed: Option<&[String]>,
ledger: &Ledger,
open_findings: &[Finding],
round: u32,
) -> String {
let landed = match landed {
Some([]) => "\nNothing landed after the last round of review. What it asked for was \
answered in words rather than in code, so the branch in front of you is the \
branch that was already read.\n"
.to_string(),
Some(lines) => format!(
"\nThis landed after the last round of review, and nobody has read it:\n{}\n\nRead it \
first with `git diff {from}..HEAD`, then inspect the full branch with `git diff \
{base}...HEAD`.\n",
lines
.iter()
.map(|l| format!("- {l}"))
.collect::<Vec<_>>()
.join("\n")
),
None => format!(
"\nThe commits on this branch were rewritten after the last round of review, so the \
harness cannot say which of them are new. Inspect the full branch with `git diff \
{base}...HEAD`.\n"
),
};
CLOSE_PROMPT
.replace("{number}", &number.to_string())
.replace("{title}", title)
.replace("{base}", base)
.replace("{landed}", &landed)
.replace("{open}", &open_findings_block(open_findings))
.replace("{answers}", &closing_answers(ledger, round))
.replace("{settled}", &settled_block(ledger))
}
fn open_findings_block(findings: &[Finding]) -> String {
if findings.is_empty() {
return String::new();
}
format!(
"\nThese blocking findings were left open by an earlier response. Recheck each one:\n{}\n\
\nIf one still blocks, return it under the same title and file. Omission means you checked \
it and found that it no longer blocks.\n",
findings_for_prompt(findings)
)
}
fn closing_answers(ledger: &Ledger, round: u32) -> String {
let lines = fixed_lines(ledger, round);
if lines.is_empty() {
return String::new();
}
format!(
"\nThese points were raised on this pull request and the author says it fixed them. \
Nobody has checked that:\n{}\n",
lines.join("\n")
)
}
fn review_prompt(
base: &str,
number: i64,
title: &str,
ledger: &Ledger,
open_findings: &[Finding],
round: u32,
last: u32,
) -> String {
REVIEW_PROMPT
.replace("{base}", base)
.replace("{number}", &number.to_string())
.replace("{title}", title)
.replace("{open}", &open_findings_block(open_findings))
.replace("{answers}", &answers_block(ledger, round))
.replace("{settled}", &settled_block(ledger))
.replace("{round}", &round_note(round, last))
}
fn implement_prompt(number: i64, title: &str, url: &str, body: &str) -> String {
IMPLEMENT_PROMPT
.replace("{number}", &number.to_string())
.replace("{title}", title)
.replace("{url}", url)
.replace("{body}", body)
}
pub fn pr_body(issue: i64, work: &Implementation, style: &Style) -> String {
let mut parts = vec![format!("Closes #{issue}")];
for lead in [&work.summary, &work.problem] {
let text = style::sentence(lead, style);
if !text.is_empty() {
parts.push(text);
}
}
parts.extend(section("What changed", &work.changes, style));
parts.extend(section("How to test", &work.testing, style));
let notes = style::sentence(work.notes.as_deref().unwrap_or_default(), style);
if !notes.is_empty() {
parts.push(format!("## Notes\n\n{notes}"));
}
style::body(&parts.join("\n\n"), style)
}
fn section(heading: &str, lines: &[String], style: &Style) -> Option<String> {
let items: Vec<String> = lines
.iter()
.map(|line| style::summary(line, style))
.filter(|line| !line.is_empty())
.collect();
if items.is_empty() {
return None;
}
Some(format!("## {heading}\n\n{}", bullets(&items)))
}
pub fn from_commits(repo: &Repo, work_dir: &Path, base: &str) -> Implementation {
Implementation {
changes: repo.commit_subjects(work_dir, "HEAD", base),
notes: Some(
"The implement call failed after these commits were made, so this body is assembled \
from their messages rather than written by their author. Read the diff."
.to_string(),
),
..Implementation::default()
}
}
fn no_pr_note(work: &Implementation, style: &Style) -> String {
let reason = style::sentence(&work.reason, style);
if !reason.is_empty() {
return reason;
}
if work.not_worth_doing {
"Left alone after reading the code, with no reason given.".to_string()
} else {
"Nothing was committed, so there is nothing to review.".to_string()
}
}
pub fn review_comment(holder: &str, round: u32, review: &Review, style: &Style) -> String {
let by = |severity: Severity| -> Vec<&Finding> {
review
.findings
.iter()
.filter(|f| f.severity == severity && f.in_scope)
.collect()
};
let blocking = by(Severity::Blocking);
let non_blocking = by(Severity::NonBlocking);
let nits = by(Severity::Nit);
let out_of_scope: Vec<&Finding> = review.findings.iter().filter(|f| !f.in_scope).collect();
let mut counts = Vec::new();
if !blocking.is_empty() {
counts.push(format!("{} blocking", blocking.len()));
}
if !non_blocking.is_empty() {
counts.push(format!("{} non-blocking", non_blocking.len()));
}
if !nits.is_empty() {
counts.push(format!("{} nit", nits.len()));
}
if !out_of_scope.is_empty() {
counts.push(format!("{} out of scope", out_of_scope.len()));
}
let headline = if counts.is_empty() {
"no findings".to_string()
} else {
counts.join(", ")
};
let _ = (holder, round, headline);
let mut out = Vec::new();
let summary = style::summary(&review.summary, style);
if !summary.is_empty() {
out.push(summary);
}
if !blocking.is_empty() {
let lines: Vec<String> = blocking
.iter()
.map(|f| {
let detail = style::detail(&f.detail, style);
if detail.is_empty() {
located(f, style)
} else {
format!("{}. {detail}", located(f, style))
}
})
.collect();
out.push(format!("blocking\n{}", bullets(&lines)));
}
for (label, group) in [
("non-blocking", &non_blocking),
("nits", &nits),
("out of scope", &out_of_scope),
] {
if group.is_empty() {
continue;
}
let lines: Vec<String> = group.iter().map(|f| located(f, style)).collect();
out.push(format!("{label}\n{}", bullets(&lines)));
}
out.join("\n\n")
}
pub fn disposition_comment(
author: &str,
response: &ResponseDoc,
fixed: &[String],
refuted: &[String],
filed: &[String],
style: &Style,
) -> Option<String> {
if fixed.is_empty() && refuted.is_empty() && filed.is_empty() {
return None;
}
let mut counts = Vec::new();
if !fixed.is_empty() {
counts.push(format!("{} fixed", fixed.len()));
}
if !refuted.is_empty() {
counts.push(format!("{} refuted", refuted.len()));
}
if !filed.is_empty() {
counts.push(format!("{} filed", filed.len()));
}
let _ = (author, counts);
let mut out = Vec::new();
let summary = style::summary(&response.summary, style);
if !summary.is_empty() {
out.push(summary);
}
if !refuted.is_empty() {
out.push(format!("refuted\n{}", bullets(refuted)));
}
if !fixed.is_empty() {
out.push(format!("fixed\n{}", bullets(fixed)));
}
if !filed.is_empty() {
out.push(format!("filed\n{}", bullets(filed)));
}
Some(out.join("\n\n"))
}
pub fn skip_comment(item: &SkippedItem, style: &Style) -> String {
let reasons = item
.reasons
.values()
.map(|reason| style::sentence(reason, style));
let lines = crate::textsim::dedupe_by(reasons, crate::textsim::same_reason);
bullets(&lines)
}
pub(crate) fn findings_for_prompt(findings: &[Finding]) -> String {
if findings.is_empty() {
return "(none)".to_string();
}
findings
.iter()
.map(|f| {
let scope = if f.in_scope { "" } else { " [out of scope]" };
format!(
"- [{}]{scope} {} ({})\n {}",
f.severity,
f.title,
f.where_at(),
f.detail
)
})
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Verdict;
fn style() -> Style {
Style::default()
}
fn finding(severity: &str, title: &str, detail: &str, file: &str, in_scope: bool) -> Finding {
Finding {
severity: Severity::parse_lenient(severity).unwrap(),
title: title.into(),
detail: detail.into(),
file: file.into(),
in_scope,
..Default::default()
}
}
fn review(summary: &str, findings: Vec<Finding>) -> Review {
Review {
verdict: Verdict::Approve,
next_action: NextAction::Merge,
summary: summary.into(),
findings,
}
}
fn disposition(title: &str, file: &str, action: Action) -> Disposition {
Disposition {
title: title.into(),
file: file.into(),
action,
reasoning: "because".into(),
new_issue_title: None,
new_issue_body: None,
}
}
fn cfg_with(worktrees: bool, keep: bool) -> Config {
let text = "[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n";
let mut cfg = crate::config::parse(text).unwrap();
cfg.loop_cfg.worktrees = worktrees;
cfg.loop_cfg.keep_worktrees = keep;
cfg
}
#[test]
fn a_worktree_is_released_on_every_finished_outcome() {
let cfg = cfg_with(true, false);
for status in [Status::Approved, Status::Merged, Status::Abandoned] {
assert!(should_release(&cfg, status), "{status}");
}
}
#[test]
fn a_worktree_is_kept_only_where_a_human_has_to_look() {
let cfg = cfg_with(true, false);
assert!(!should_release(&cfg, Status::Escalated));
assert!(!should_release(&cfg, Status::Error));
}
#[test]
fn the_keep_flag_overrides_everything() {
assert!(!should_release(&cfg_with(true, true), Status::Approved));
}
#[test]
fn nothing_is_released_when_worktrees_are_off() {
assert!(!should_release(&cfg_with(false, false), Status::Approved));
}
#[test]
fn fixing_your_own_findings_hands_the_pr_over() {
let cfg = cfg_with(true, false);
assert_eq!("a", next_reviewer(&cfg, "b", Some("b")));
assert_eq!("b", next_reviewer(&cfg, "a", Some("a")));
}
#[test]
fn handing_back_keeps_the_reviewer_for_the_next_round() {
let cfg = cfg_with(true, false);
assert_eq!("b", next_reviewer(&cfg, "b", Some("a")));
assert_eq!("a", next_reviewer(&cfg, "a", Some("b")));
}
#[test]
fn nobody_reviews_their_own_edit() {
let cfg = cfg_with(true, false);
let round_1 = cfg.other(&cfg.first_implementor);
assert_eq!("b", round_1);
for editor in ["a", "b"] {
assert_ne!(editor, next_reviewer(&cfg, &round_1, Some(editor)));
}
}
#[test]
fn a_fix_that_committed_nothing_leaves_the_pr_where_it_is() {
let cfg = cfg_with(true, false);
assert_eq!("b", next_reviewer(&cfg, "b", None));
assert_eq!("a", next_reviewer(&cfg, "a", None));
}
#[test]
fn a_reviewer_that_wrote_the_head_gives_the_pr_up() {
let cfg = cfg_with(true, false);
assert_eq!("a", next_reviewer(&cfg, "b", Some("b")));
}
#[test]
fn a_review_that_wrote_cannot_approve_what_is_left() {
assert!(!approval_stands(&[], true));
}
#[test]
fn a_clean_review_of_an_untouched_branch_approves() {
assert!(approval_stands(&[], false));
}
#[test]
fn a_blocking_finding_never_approves() {
let blocking = vec![finding("blocking", "Broken", "detail", "src/x.rs", true)];
assert!(!approval_stands(&blocking, false));
}
#[test]
fn approval_refuses_a_head_that_changed_after_review() {
assert!(ensure_reviewed_head(36, "abc123", "abc123").is_ok());
let error = ensure_reviewed_head(36, "abc123", "def456").unwrap_err();
assert!(error.to_string().contains("unread head"));
}
#[test]
fn only_a_moved_head_counts_as_a_commit() {
let before = Snapshot {
head: "abc".into(),
dirty: false,
};
assert!(!Snapshot {
head: "abc".into(),
dirty: true,
}
.landed_over(&before));
assert!(Snapshot {
head: "def".into(),
dirty: false,
}
.landed_over(&before));
assert!(!Snapshot {
head: String::new(),
dirty: false,
}
.landed_over(&before));
}
#[test]
fn a_fresh_run_starts_at_one() {
assert_eq!((1, 3), round_window(1, 3));
assert_eq!((1, 5), round_window(1, 5));
}
#[test]
fn a_resumed_run_gets_a_full_fresh_budget() {
assert_eq!((6, 10), round_window(6, 5));
assert_eq!((11, 13), round_window(11, 3));
}
#[test]
fn a_budget_of_one_is_a_single_round() {
assert_eq!((6, 6), round_window(6, 1));
}
#[test]
fn round_numbers_keep_counting_across_sessions() {
let mut start = 1;
let mut seen = Vec::new();
for _ in 0..3 {
let (first, last) = round_window(start, 3);
seen.push((first, last));
start = last + 1;
}
assert_eq!(vec![(1, 3), (4, 6), (7, 9)], seen);
}
fn ledger_with(title: &str, file: &str) -> Ledger {
let mut ledger = Ledger::new();
ledger.insert(
finding_key(title, file),
LedgerEntry {
title: title.into(),
file: file.into(),
reasoning: "no".into(),
round: 1,
reraised: 0,
outcome: Settled::Refuted,
},
);
ledger
}
#[test]
fn a_point_refuted_and_re_raised_twice_escalates() {
let mut ledger = ledger_with("nit about naming", "a.rs");
let mut state = IssueRun::new(1, "t");
let blocking = vec![finding("blocking", "nit about naming", "d", "a.rs", true)];
assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
assert!(check_relitigation(&mut ledger, &blocking, &mut state));
}
#[test]
fn a_point_fixed_twice_and_raised_again_escalates() {
let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
for entry in ledger.values_mut() {
entry.outcome = Settled::Fixed;
}
let mut state = IssueRun::new(1, "t");
let blocking = [finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
assert!(check_relitigation(&mut ledger, &blocking, &mut state));
}
#[test]
fn a_fix_that_missed_twice_is_not_reported_as_a_refutation() {
assert!(why_escalated(Settled::Fixed).contains("fixed twice"));
for outcome in [Settled::Refuted, Settled::Filed, Settled::Dropped] {
assert!(why_escalated(outcome).contains("settled"), "{outcome}");
}
}
#[test]
fn a_reviewer_that_fixes_its_own_findings_records_them_too() {
let mut ledger = Ledger::new();
let blocking = vec![finding(
"blocking",
"Unbounded loop",
"spins",
"src/x.rs",
true,
)];
let mut state = IssueRun::new(1, "t");
record_own_fixes(&blocking, &mut ledger, &mut state, 1);
let entry = ledger
.get(&finding_key("Unbounded loop", "src/x.rs"))
.expect("keyed where the next round will look");
assert_eq!(Settled::Fixed, entry.outcome);
assert_eq!(
"a committed change was made for this point",
entry.reasoning
);
assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
assert!(check_relitigation(&mut ledger, &blocking, &mut state));
}
#[test]
fn a_fixed_point_is_not_in_the_settled_block() {
let mut ledger = ledger_with("refuted point", "a.rs");
ledger.extend(ledger_with("fixed point", "b.rs"));
for entry in ledger.values_mut() {
if entry.title == "fixed point" {
entry.outcome = Settled::Fixed;
}
}
let block = settled_block(&ledger);
assert!(block.contains("refuted point"));
assert!(!block.contains("fixed point"));
}
#[test]
fn a_ledger_of_only_fixes_says_nothing_is_settled() {
let mut ledger = ledger_with("fixed point", "b.rs");
for entry in ledger.values_mut() {
entry.outcome = Settled::Fixed;
}
assert_eq!("", settled_block(&ledger));
}
#[test]
fn one_review_spends_one_re_raise_however_often_it_says_it() {
let mut ledger = ledger_with("Missing error handling", "src/net.rs");
let mut state = IssueRun::new(1, "t");
let twice = vec![
finding(
"blocking",
"Missing error handling",
"d",
"src/net.rs",
true,
),
finding(
"blocking",
"Missing error handling",
"e",
"src/net.rs",
true,
),
];
assert!(!check_relitigation(&mut ledger, &twice, &mut state));
assert_eq!(1, ledger.values().next().unwrap().reraised);
assert!(check_relitigation(&mut ledger, &twice, &mut state));
}
#[test]
fn an_untracked_finding_does_not_escalate() {
let mut state = IssueRun::new(1, "t");
let blocking = vec![finding("blocking", "brand new", "d", "a.rs", true)];
assert!(!check_relitigation(
&mut Ledger::new(),
&blocking,
&mut state
));
}
#[test]
fn persisted_ledger_entries_are_rekeyed_for_stable_locations() {
let mut ledger = Ledger::new();
ledger.insert(
"legacy-key".into(),
LedgerEntry {
title: "Unbounded loop".into(),
file: "src/x.rs:88".into(),
reasoning: "bounded by the caller".into(),
round: 2,
reraised: 1,
outcome: Settled::Refuted,
},
);
normalise_ledger_keys(&mut ledger);
let key = matching_ledger_key(&ledger, "Unbounded loop", "src/x.rs:91").unwrap();
assert_eq!(1, ledger[&key].reraised);
}
#[test]
fn same_title_at_two_sites_keeps_both_blockers() {
let findings = vec![
finding(
"blocking",
"Unchecked error",
"first site",
"src/net.rs:10",
true,
),
finding(
"blocking",
"Unchecked error",
"second site",
"src/net.rs:200",
true,
),
];
let blocking = blocking_findings(&findings);
assert_eq!(2, blocking.len());
assert_eq!("src/net.rs:10", blocking[0].file);
assert_eq!("src/net.rs:200", blocking[1].file);
}
#[test]
fn moved_location_fallback_refuses_an_ambiguous_ledger() {
let mut ledger = ledger_with("Unchecked error", "src/net.rs:10");
ledger.extend(ledger_with("Unchecked error", "src/net.rs:200"));
assert!(matching_ledger_key(&ledger, "Unchecked error", "src/net.rs:30").is_none());
}
#[test]
fn two_current_sites_do_not_relocate_one_old_ledger_entry() {
let mut ledger = ledger_with("Unchecked error", "src/net.rs:5");
let blocking = vec![
finding(
"blocking",
"Unchecked error",
"first",
"src/net.rs:10",
true,
),
finding(
"blocking",
"Unchecked error",
"second",
"src/net.rs:200",
true,
),
];
let mut state = IssueRun::new(1, "t");
record_own_fixes(&blocking, &mut ledger, &mut state, 2);
assert!(ledger.contains_key(&finding_key("Unchecked error", "src/net.rs:10")));
assert!(ledger.contains_key(&finding_key("Unchecked error", "src/net.rs:200")));
}
#[test]
fn two_current_sites_remain_two_open_findings() {
let current = vec![
finding(
"blocking",
"Unchecked error",
"first",
"src/net.rs:10",
true,
),
finding(
"blocking",
"Unchecked error",
"second",
"src/net.rs:200",
true,
),
];
let mut open = Vec::new();
extend_findings(&mut open, ¤t);
assert_eq!(2, open.len());
assert_eq!("src/net.rs:10", open[0].file);
assert_eq!("src/net.rs:200", open[1].file);
}
#[test]
fn display_limits_do_not_change_persisted_finding_identity() {
let point = finding(
"blocking",
"abcdefghij",
"still wrong",
"src/net.rs:10",
true,
);
let mut ledger = Ledger::new();
let mut state = IssueRun::new(1, "t");
record_own_fixes(std::slice::from_ref(&point), &mut ledger, &mut state, 1);
normalise_ledger_keys(&mut ledger);
let key = matching_ledger_key(&ledger, "abcdefghij", "src/net.rs:12").unwrap();
assert_eq!("abcdefghij", ledger[&key].title);
}
#[test]
fn a_clipped_legacy_entry_keeps_its_original_lookup_key() {
let mut ledger = Ledger::new();
let key = crate::jsonx::finding_key("abcdefghij", "src/net.rs:10");
ledger.insert(
key.clone(),
LedgerEntry {
title: "abcde".into(),
file: "src/net.rs:10".into(),
reasoning: "bounded by the caller".into(),
round: 1,
reraised: 1,
outcome: Settled::Refuted,
},
);
normalise_ledger_keys(&mut ledger);
assert_eq!(
Some(key),
matching_ledger_key(&ledger, "abcdefghij", "src/net.rs:12")
);
}
#[test]
fn a_legacy_key_collision_does_not_merge_case_distinct_paths() {
let mut ledger = Ledger::new();
let key = crate::jsonx::finding_key("Unchecked error", "src/Main.rs:10");
ledger.insert(
key.clone(),
LedgerEntry {
title: "Unchecked error".into(),
file: "src/Main.rs:10".into(),
reasoning: "bounded by the caller".into(),
round: 1,
reraised: 0,
outcome: Settled::Refuted,
},
);
assert_eq!(
Some(key),
matching_ledger_key(&ledger, "Unchecked error", "src/Main.rs:10")
);
assert!(matching_ledger_key(&ledger, "Unchecked error", "src/main.rs:10").is_none());
}
#[test]
fn a_refutation_lands_on_the_key_the_next_round_will_look_up() {
let blocking = [finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
let recorded = finding_key(&blocking[0].title, &blocking[0].file);
let answer = disposition("unbounded loop!", "src/x.rs", Action::Refuted);
assert!(disposition_matches(&blocking[0], &answer));
assert_eq!(recorded, finding_key(&answer.title, &answer.file));
}
#[test]
fn the_ledger_key_ignores_title_punctuation() {
let findings = [finding(
"blocking",
"Panic on multi-byte input",
"d",
"src/style.rs",
true,
)];
let reworded = "Panic on multibyte input";
let source = &findings[0];
assert_eq!(
finding_key(reworded, &source.file),
finding_key(&source.title, &source.file)
);
let recorded = finding_key(&source.title, &source.file);
let looked_up = finding_key(&findings[0].title, &findings[0].file);
assert_eq!(recorded, looked_up);
}
#[test]
fn a_disposition_matches_its_finding_despite_wording_noise() {
let findings = [finding(
"blocking",
"Unbounded loop!",
"d",
"src/x.rs",
true,
)];
assert!(disposition_matches(
&findings[0],
&disposition("unbounded loop", "src/x.rs", Action::Refuted)
));
assert!(!disposition_matches(
&findings[0],
&disposition("something else", "src/x.rs", Action::Refuted)
));
assert!(!disposition_matches(
&findings[0],
&disposition("unbounded loop", "src/y.rs", Action::Refuted)
));
}
#[test]
fn an_omitted_disposition_leaves_the_blocker_unmatched() {
let blocker = finding("blocking", "Unbounded loop", "d", "src/x.rs", true);
assert!(matches!(
matching_disposition(&blocker, &[]),
Err("no matching disposition")
));
}
#[test]
fn duplicate_dispositions_are_ambiguous() {
let blocker = finding("blocking", "Unbounded loop", "d", "src/x.rs", true);
let answers = vec![
disposition("Unbounded loop", "src/x.rs", Action::Fixed),
disposition("Unbounded loop", "src/x.rs", Action::Refuted),
];
assert!(matches!(
matching_disposition(&blocker, &answers),
Err("more than one matching disposition")
));
}
#[test]
fn same_titled_findings_in_different_files_need_separate_dispositions() {
let left = finding("blocking", "Unchecked error", "d", "src/a.rs", true);
let right = finding("blocking", "Unchecked error", "d", "src/b.rs", true);
let answers = vec![
disposition("Unchecked error", "src/a.rs", Action::Fixed),
disposition("Unchecked error", "src/b.rs", Action::Refuted),
];
assert_eq!(
0,
matching_disposition(&left, &answers)
.expect("left answer")
.0
);
assert_eq!(
1,
matching_disposition(&right, &answers)
.expect("right answer")
.0
);
}
#[test]
fn a_reported_fix_without_a_commit_stays_open() {
assert!(!fixed_disposition_resolves(false));
assert!(fixed_disposition_resolves(true));
}
#[test]
fn the_settled_block_is_empty_when_nothing_is_settled() {
assert_eq!("", settled_block(&Ledger::new()));
}
#[test]
fn the_settled_block_names_each_refutation() {
let block = settled_block(&ledger_with("a point", "x.rs"));
assert!(block.contains("a point"));
assert!(block.contains("x.rs"));
assert!(block.contains("settled"));
}
#[test]
fn same_title_settlements_name_each_location() {
let mut ledger = ledger_with("Unchecked error", "a.rs:10");
ledger.extend(ledger_with("Unchecked error", "b.rs:20"));
let block = settled_block(&ledger);
assert!(block.contains("Unchecked error (a.rs:10)"), "{block}");
assert!(block.contains("Unchecked error (b.rs:20)"), "{block}");
}
#[test]
fn a_filed_point_is_settled_too() {
let mut ledger = ledger_with("out of scope", "x.rs");
for entry in ledger.values_mut() {
entry.outcome = Settled::Filed;
entry.reasoning = "Tracked in #9.".into();
}
let block = settled_block(&ledger);
assert!(block.contains("out of scope"));
assert!(block.contains("#9"));
}
#[test]
fn answering_a_point_again_keeps_its_re_raise_count() {
let mut ledger = ledger_with("a point", "x.rs");
let entry = ledger.values().next().unwrap().clone();
let mut state = IssueRun::new(1, "t");
let blocking = vec![finding("blocking", "a point", "d", "x.rs", true)];
assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
settle(&mut ledger, "a point", "x.rs", true, entry);
assert!(check_relitigation(&mut ledger, &blocking, &mut state));
}
#[test]
fn a_clean_review_is_just_the_verdict() {
let text = review_comment("codex", 1, &review("Looks correct.", vec![]), &style());
assert_eq!("Looks correct.", text);
}
#[test]
fn a_review_leads_with_the_counts() {
let text = review_comment(
"codex",
2,
&review(
"One real problem.",
vec![
finding(
"blocking",
"Loop never terminates",
"Confirmed by running it.",
"src/a.rs",
true,
),
finding("non-blocking", "Name is vague", "d", "src/b.rs", true),
finding("nit", "Log wording", "d", "", true),
],
),
&style(),
);
assert!(text.starts_with("One real problem."), "{text}");
assert!(!text.contains("codex"), "no agent name: {text}");
assert!(!text.contains("round 2"), "no round number: {text}");
}
#[test]
fn only_blocking_findings_carry_their_detail() {
let text = review_comment(
"codex",
1,
&review(
"s",
vec![
finding("blocking", "Loop", "BLOCKING DETAIL", "a.rs", true),
finding("non-blocking", "Name", "NONBLOCKING DETAIL", "b.rs", true),
],
),
&style(),
);
assert!(text.contains("BLOCKING DETAIL"), "{text}");
assert!(!text.contains("NONBLOCKING DETAIL"), "{text}");
}
#[test]
fn a_thorough_explanation_reaches_the_author_intact() {
let detail = "Reproduced by running the 429 test with max_attempts unset. ".repeat(8);
let text = review_comment(
"codex",
1,
&review(
"One problem.",
vec![finding("blocking", "T", &detail, "a.rs", true)],
),
&style(),
);
assert!(
text.contains(detail.trim()),
"the explanation was cut:\n{text}"
);
}
#[test]
fn a_runaway_model_is_still_bounded() {
let long = "filler words. ".repeat(20_000);
let text = review_comment(
"codex",
1,
&review(&long, vec![finding("blocking", "T", &long, "a.rs", true)]),
&style(),
);
assert!(
text.len() < 30_000,
"review comment was {} chars",
text.len()
);
}
#[test]
fn a_general_finding_has_no_empty_parenthesis() {
let text = review_comment(
"codex",
1,
&review("s", vec![finding("blocking", "Something", "d", "", true)]),
&style(),
);
assert!(!text.contains("()"), "{text}");
assert!(!text.contains("(general)"), "{text}");
}
#[test]
fn out_of_scope_findings_are_counted_separately() {
let text = review_comment(
"codex",
1,
&review(
"s",
vec![finding("blocking", "Old bug", "d", "a.rs", false)],
),
&style(),
);
assert!(text.contains("out of scope"), "{text}");
assert!(text.contains("Old bug"), "{text}");
}
#[test]
fn a_disposition_comment_leads_with_counts_and_keeps_refutations() {
let response = ResponseDoc {
summary: "Two of three were right.".into(),
dispositions: vec![],
};
let text = disposition_comment(
"claude",
&response,
&["Fixed thing".to_string()],
&["Wrong thing. Because the caller already checks.".to_string()],
&[],
&style(),
)
.unwrap();
assert!(text.starts_with("Two of three were right."), "{text}");
assert!(!text.contains("claude"), "no agent name: {text}");
assert!(
text.contains("Because the caller already checks."),
"{text}"
);
}
#[test]
fn an_empty_disposition_comment_is_not_posted() {
let response = ResponseDoc {
summary: "s".into(),
dispositions: vec![],
};
assert!(disposition_comment("claude", &response, &[], &[], &[], &style()).is_none());
}
#[test]
fn a_local_closing_commit_does_not_change_remote_custody() {
for holder in ["a", "b"] {
assert_eq!(holder, closing_next_actor(holder));
}
}
#[test]
fn a_matching_head_keeps_saved_custody() {
assert!(reconcile_saved_head(Some("abc123"), "abc123", None, 42).unwrap());
assert!(reconcile_saved_head(None, "abc123", None, 42).unwrap());
}
#[test]
fn a_changed_head_refuses_automatic_custody() {
let error = reconcile_saved_head(Some("abc123"), "def456", None, 42).unwrap_err();
let text = error.to_string();
assert!(text.contains("abc123"), "{text}");
assert!(text.contains("def456"), "{text}");
assert!(text.contains("--next <agent>"), "{text}");
}
#[test]
fn an_explicit_holder_resets_state_for_a_changed_or_legacy_head() {
assert!(!reconcile_saved_head(Some("abc123"), "def456", Some("b"), 42).unwrap());
assert!(!reconcile_saved_head(Some(""), "def456", Some("b"), 42).unwrap());
assert!(reconcile_saved_head(Some(""), "def456", None, 42).is_err());
}
#[test]
fn an_invalid_review_cannot_clear_a_carried_blocker() {
let mut open = vec![finding(
"blocking",
"Unchecked error",
"still fails",
"src/a.rs:12",
true,
)];
update_open_findings(&mut open, &[], false);
assert_eq!(1, open.len());
update_open_findings(&mut open, &[], true);
assert!(open.is_empty());
}
#[test]
fn a_final_round_with_no_commit_names_open_blockers() {
let open = vec![finding(
"blocking",
"Unchecked error",
"still fails",
"src/a.rs",
true,
)];
assert!(matches!(
ending_without_landing(&open),
Ending::Unresolved(points) if points.len() == 1
));
assert!(matches!(ending_without_landing(&[]), Ending::Unchanged));
}
#[test]
fn a_closing_pass_uses_the_later_effort_tier() {
assert_eq!(2, closing_effort_round(1));
assert_eq!(8, closing_effort_round(7));
}
#[test]
fn a_ledger_with_no_claimed_fix_has_nothing_to_close_over() {
assert!(!any_fixes(&ledger_with("refuted point", "a.rs"), 1));
let mut fixed = ledger_with("fixed point", "b.rs");
for entry in fixed.values_mut() {
entry.outcome = Settled::Fixed;
}
assert!(any_fixes(&fixed, 1));
}
#[test]
fn the_closing_note_counts_points_rather_than_rounds() {
assert_eq!("one point left after the closing pass", unresolved_note(1));
assert_eq!("3 points left after the closing pass", unresolved_note(3));
assert!(!unresolved_note(2).contains("round"));
}
fn fixed_ledger() -> Ledger {
let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
for entry in ledger.values_mut() {
entry.outcome = Settled::Fixed;
entry.reasoning = "bounded it on max_attempts".into();
}
ledger
}
#[test]
fn the_closing_prompt_names_every_fix_it_has_to_check() {
let landed = vec!["abc1234 Bound the retry loop".to_string()];
let prompt = close_prompt(
"main",
42,
"Retry a 429",
"9f8e7d6",
Some(&landed),
&fixed_ledger(),
&[],
1,
);
assert!(prompt.contains("Unbounded loop"), "{prompt}");
assert!(prompt.contains("bounded it on max_attempts"), "{prompt}");
assert!(prompt.contains("abc1234 Bound the retry loop"), "{prompt}");
assert!(prompt.contains("git diff 9f8e7d6..HEAD"), "{prompt}");
assert!(prompt.contains("git diff main...HEAD"), "{prompt}");
assert!(!prompt.contains('{'), "{prompt}");
}
#[test]
fn a_close_with_nothing_landed_says_so_rather_than_leaving_a_hole() {
let prompt = close_prompt(
"main",
42,
"Retry a 429",
"9f8e7d6",
Some(&[]),
&Ledger::new(),
&[],
1,
);
assert!(
prompt.contains("Nothing landed after the last round"),
"{prompt}"
);
assert!(!prompt.contains('{'), "{prompt}");
}
#[test]
fn a_rewritten_branch_admits_it_cannot_say_what_landed() {
let prompt = close_prompt(
"main",
42,
"Retry a 429",
"9f8e7d6",
None,
&fixed_ledger(),
&[],
1,
);
assert!(prompt.contains("were rewritten"), "{prompt}");
assert!(prompt.contains("git diff main...HEAD"), "{prompt}");
assert!(!prompt.contains("nobody has read it"), "{prompt}");
assert!(!prompt.contains('{'), "{prompt}");
}
#[test]
fn the_closing_prompt_forbids_the_writing_the_loop_rolls_back() {
let prompt = close_prompt(
"main",
42,
"t",
"9f8e7d6",
Some(&[]),
&Ledger::new(),
&[],
1,
);
assert!(prompt.contains("do not commit"), "{prompt}");
}
#[test]
fn the_closing_prompt_keeps_confirmed_merge_blockers_blocking() {
let prompt = close_prompt(
"main",
42,
"t",
"9f8e7d6",
Some(&[]),
&Ledger::new(),
&[],
1,
);
assert!(
prompt.contains("serious defect an\nearlier round missed"),
"{prompt}"
);
assert!(prompt.contains("final merge-safety audit"), "{prompt}");
assert!(!prompt.contains("not another\naudit"), "{prompt}");
assert!(!prompt.contains("A\nfinding means"), "{prompt}");
assert!(prompt.contains("A\nblocking finding means"), "{prompt}");
let flat = prompt.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(flat.contains("does not become non-blocking"), "{prompt}");
assert!(flat.contains("Only one of them ships"), "{prompt}");
}
#[test]
fn the_closing_pass_is_offered_a_severity_rather_than_the_field_that_gates() {
let prompt = close_prompt(
"main",
42,
"t",
"9f8e7d6",
Some(&[]),
&Ledger::new(),
&[],
1,
);
assert!(
prompt.contains("Minor defects and improvements are\nnon-blocking"),
"{prompt}"
);
assert!(
prompt.contains("a real defect\nthis pull request did not cause"),
"{prompt}"
);
}
#[test]
fn an_out_of_scope_point_cannot_gate_the_close() {
let out_of_scope = finding("blocking", "Adjacent leak", "d", "o.rs", false);
assert!(!out_of_scope.blocks());
assert!(approval_stands(&[], false));
}
#[test]
fn a_fix_is_shown_to_the_pass_that_has_to_check_it_and_not_after() {
let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
for entry in ledger.values_mut() {
entry.outcome = Settled::Fixed;
entry.round = 2;
}
assert!(answers_block(&ledger, 3).contains("Unbounded loop"));
assert_eq!("", answers_block(&ledger, 4));
assert!(any_fixes(&ledger, 2));
assert!(!any_fixes(&ledger, 3));
}
#[test]
fn the_answers_block_asks_the_reviewer_to_check_rather_than_to_trust() {
let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
for entry in ledger.values_mut() {
entry.outcome = Settled::Fixed;
entry.reasoning = "bounded it on max_attempts".into();
}
let block = answers_block(&ledger, 2);
assert!(block.contains("Unbounded loop"), "{block}");
assert!(block.contains("src/x.rs"), "{block}");
assert!(block.contains("bounded it on max_attempts"), "{block}");
assert!(block.contains("Check the answer"), "{block}");
assert!(!block.contains("settled"), "{block}");
}
#[test]
fn a_fix_and_a_refutation_do_not_share_a_heading() {
let mut ledger = ledger_with("refuted point", "a.rs");
ledger.extend(ledger_with("fixed point", "b.rs"));
for entry in ledger.values_mut() {
if entry.title == "fixed point" {
entry.outcome = Settled::Fixed;
}
}
let answers = answers_block(&ledger, 2);
let settled = settled_block(&ledger);
assert!(answers.contains("fixed point") && !answers.contains("refuted point"));
assert!(settled.contains("refuted point") && !settled.contains("fixed point"));
}
#[test]
fn an_empty_ledger_adds_no_answers_block() {
assert_eq!("", answers_block(&Ledger::new(), 2));
}
#[test]
fn the_last_round_that_can_ask_for_anything_says_so() {
assert_eq!("", round_note(1, 3));
assert_eq!("", round_note(2, 3));
assert!(round_note(3, 3).contains("last round"));
assert!(round_note(6, 6).contains("last round"));
}
#[test]
fn saying_when_the_asking_stops_says_nothing_about_severity() {
let note = round_note(3, 3);
for word in ["approve", "blocking", "severity", "nit"] {
assert!(!note.contains(word), "{word} in: {note}");
}
}
#[test]
fn the_review_prompt_leaves_nothing_unsubstituted() {
let empty = review_prompt("main", 42, "Retry a 429", &Ledger::new(), &[], 1, 3);
assert!(!empty.contains('{'), "{empty}");
assert!(empty.contains("main") && empty.contains("#42") && empty.contains("Retry a 429"));
let mut ledger = ledger_with("refuted point", "a.rs");
ledger.extend(ledger_with("fixed point", "b.rs"));
for entry in ledger.values_mut() {
if entry.title == "fixed point" {
entry.outcome = Settled::Fixed;
entry.round = 2;
}
}
let full = review_prompt("main", 42, "Retry a 429", &ledger, &[], 3, 3);
assert!(!full.contains('{'), "{full}");
assert!(full.contains("fixed point") && full.contains("refuted point"));
assert!(full.contains("last round"), "{full}");
}
#[test]
fn a_resumed_open_finding_reaches_review_and_closing_prompts() {
let open = vec![finding(
"blocking",
"Retry bypasses the limit",
"reproduced with max_attempts set to one",
"src/net.rs:88",
true,
)];
let review = review_prompt("main", 42, "Retry a 429", &Ledger::new(), &open, 2, 3);
let close = close_prompt(
"main",
42,
"Retry a 429",
"9f8e7d6",
Some(&[]),
&Ledger::new(),
&open,
2,
);
for prompt in [review, close] {
assert!(prompt.contains("Retry bypasses the limit"), "{prompt}");
assert!(prompt.contains("src/net.rs:88"), "{prompt}");
assert!(
prompt.contains("reproduced with max_attempts set to one"),
"{prompt}"
);
assert!(!prompt.contains('{'), "{prompt}");
}
}
#[test]
fn a_minor_defect_has_a_severity_that_is_not_blocking() {
assert!(
REVIEW_PROMPT.contains("A minor defect belongs\n here as much as an improvement does")
);
for text in [
schema::review().to_string(),
crate::review_only::review_only_prompt().to_string(),
] {
assert!(text.contains("as much as an improvement does"), "{text}");
assert!(!text.contains("a genuine improvement"), "{text}");
}
}
#[test]
fn doubt_resolves_away_from_the_field_that_gates() {
for text in [REVIEW_PROMPT.to_string(), schema::review().to_string()] {
assert!(text.contains("say your piece in the finding and label it non-blocking"));
assert!(!text.contains("leave in_scope true"));
}
}
#[test]
fn both_edit_prompts_ask_for_the_smallest_change_that_answers_the_point() {
for prompt in [FIX_PROMPT, RESPOND_PROMPT] {
assert!(
prompt.contains("The smallest change that answers it is"),
"{prompt}"
);
}
assert!(RESPOND_PROMPT.contains("bigger than the\n problem it names"));
}
#[test]
fn a_fixed_disposition_must_explain_what_changed() {
let flat = RESPOND_PROMPT
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
assert!(
flat.contains("For fixed, say what changed and how it answers the point"),
"{RESPOND_PROMPT}"
);
}
#[test]
fn an_empty_fix_reason_still_renders_as_a_claim_to_check() {
let mut ledger = ledger_with("Unchecked error", "src/net.rs");
for entry in ledger.values_mut() {
entry.outcome = Settled::Fixed;
entry.reasoning.clear();
}
let lines = fixed_lines(&ledger, 0);
assert_eq!(1, lines.len());
assert!(lines[0].contains("a committed change claims to address this point"));
assert!(!lines[0].contains("The author said"));
}
#[test]
fn the_implementor_is_given_the_link_and_the_body() {
let prompt = implement_prompt(
42,
"Retry a 429",
"https://github.com/o/r/issues/42",
"A rate limited response was treated as fatal.",
);
assert!(
prompt.contains("https://github.com/o/r/issues/42"),
"{prompt}"
);
assert!(
prompt.contains("A rate limited response was treated as fatal."),
"{prompt}"
);
assert!(prompt.contains("#42"), "{prompt}");
assert!(prompt.contains("Retry a 429"), "{prompt}");
assert!(!prompt.contains('{'), "{prompt}");
}
#[test]
fn the_prompt_says_the_discussion_is_not_included() {
let prompt = implement_prompt(1, "t", "u", "b");
let lower = prompt
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase();
assert!(
lower.contains("discussion since is not included"),
"{prompt}"
);
assert!(lower.contains("cannot reach the network"), "{prompt}");
}
fn worked() -> Implementation {
Implementation {
summary: "Retry a 429 instead of failing the run.".into(),
problem: "A rate limited response was treated as fatal, so one throttled call ended \
a run that had hours of work left in it."
.into(),
changes: vec![
"`send` retries a 429 with the delay the header asks for".into(),
"the retry budget is bounded, so a permanent 429 still ends".into(),
],
testing: vec![
"`cargo test retries_a_429`".into(),
"point it at a throttled endpoint and watch it finish".into(),
],
..Implementation::default()
}
}
#[test]
fn a_pr_body_is_what_it_closes_and_what_changed() {
let body = pr_body(42, &worked(), &style());
assert_eq!(
"Closes #42\n\n\
Retry a 429 instead of failing the run.\n\n\
A rate limited response was treated as fatal, so one throttled call \
ended a run that had hours of work left in it.\n\n\
## What changed\n\n\
- `send` retries a 429 with the delay the header asks for\n\
- the retry budget is bounded, so a permanent 429 still ends\n\n\
## How to test\n\n\
- `cargo test retries_a_429`\n\
- point it at a throttled endpoint and watch it finish",
body
);
}
#[test]
fn a_body_with_nothing_to_list_carries_no_empty_headings() {
let work = Implementation {
summary: "Retry a 429 instead of failing the run.".into(),
..Implementation::default()
};
assert_eq!(
"Closes #42\n\nRetry a 429 instead of failing the run.",
pr_body(42, &work, &style())
);
}
#[test]
fn a_pr_body_survives_an_implementor_that_said_nothing() {
assert_eq!(
"Closes #7",
pr_body(7, &Implementation::default(), &style())
);
}
#[test]
fn blank_list_entries_do_not_earn_a_heading() {
let work = Implementation {
summary: "Did a thing.".into(),
changes: vec![String::new(), " ".into()],
..Implementation::default()
};
let body = pr_body(42, &work, &style());
assert!(!body.contains("What changed"), "{body}");
}
#[test]
fn notes_appear_only_when_there_is_something_to_note() {
let mut work = worked();
assert!(!pr_body(42, &work, &style()).contains("## Notes"));
work.notes = Some("The retry is not applied to streaming calls.".into());
let body = pr_body(42, &work, &style());
assert!(body.contains("## Notes"), "{body}");
assert!(body.contains("streaming calls"), "{body}");
}
#[test]
fn declining_posts_the_reason_and_not_the_summary() {
let work = Implementation {
not_worth_doing: true,
reason: "Already fixed in 1.2, and the report predates it.".into(),
summary: "Nothing to do.".into(),
..Implementation::default()
};
assert_eq!(
"Already fixed in 1.2, and the report predates it.",
no_pr_note(&work, &style())
);
}
#[test]
fn reporting_work_and_committing_none_says_that_rather_than_the_summary() {
let work = Implementation {
summary: "Retry a 429 instead of failing the run.".into(),
..Implementation::default()
};
let note = no_pr_note(&work, &style());
assert_eq!(
"Nothing was committed, so there is nothing to review.",
note
);
}
#[test]
fn declining_without_a_reason_still_says_something() {
let work = Implementation {
not_worth_doing: true,
..Implementation::default()
};
assert!(no_pr_note(&work, &style()).contains("no reason given"));
}
#[test]
fn a_skip_comment_is_only_the_reasoning() {
let item = SkippedItem {
issue: 3,
title: "t".into(),
tracker: false,
reasons: [
("claude".to_string(), "Already fixed in 1.2.".to_string()),
("codex".to_string(), "Duplicate of #2.".to_string()),
]
.into_iter()
.collect(),
};
let text = skip_comment(&item, &style());
assert!(text.contains("Already fixed in 1.2."), "{text}");
assert!(text.contains("Duplicate of #2."), "{text}");
assert!(
!text.contains("claude") && !text.contains("codex"),
"{text}"
);
assert!(!text.to_lowercase().contains("not scheduled"), "{text}");
assert!(text.lines().count() <= 3, "{text}");
}
#[test]
fn findings_for_a_model_keep_full_detail() {
let long = "x".repeat(2000);
let text = findings_for_prompt(&[finding("blocking", "T", &long, "a.rs", true)]);
assert!(
text.contains(&long),
"a model needs the whole finding, only humans need brevity"
);
}
#[test]
fn findings_for_a_model_are_never_empty() {
assert_eq!("(none)", findings_for_prompt(&[]));
}
}
#[cfg(test)]
mod outcome_tests {
use super::*;
use crate::model::{Dispute, Severity};
fn style() -> Style {
Style::default()
}
fn state_with(disputes: Vec<(&str, &str)>, filed: Vec<&str>) -> IssueRun {
let mut s = IssueRun::new(482, "t");
s.disputes = disputes
.into_iter()
.map(|(title, reasoning)| Dispute {
title: title.into(),
file: String::new(),
reasoning: reasoning.into(),
})
.collect();
s.filed = filed.into_iter().map(String::from).collect();
s
}
fn finding(title: &str, file: &str) -> Finding {
Finding {
severity: Severity::Blocking,
title: title.into(),
detail: "d".into(),
file: file.into(),
in_scope: true,
..Default::default()
}
}
fn graded(severity: Severity, title: &str, file: &str, in_scope: bool) -> Finding {
Finding {
severity,
in_scope,
..finding(title, file)
}
}
#[test]
fn outcome_mode_routes_final_results_to_the_configured_sink() {
assert_eq!(OutcomeSink::PullRequest, outcome_sink(PrComments::Outcome));
assert_eq!(OutcomeSink::PullRequest, outcome_sink(PrComments::Rounds));
assert_eq!(OutcomeSink::Terminal, outcome_sink(PrComments::None));
}
#[test]
fn a_clean_approval_says_nothing() {
let state = state_with(vec![], vec![]);
assert!(outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).is_none());
}
#[test]
fn a_downgraded_finding_still_reaches_the_pull_request() {
let mut state = state_with(vec![], vec![]);
let kept = graded(
Severity::NonBlocking,
"Timeout is not configurable",
"n.rs",
true,
);
record_nonblocking_outcome(&mut state, &kept, None);
assert_eq!(1, state.noted.len());
let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
assert!(text.contains("Noted, not blocking"), "{text}");
assert!(
text.contains("Timeout is not configurable (n.rs)"),
"{text}"
);
}
#[test]
fn a_point_that_was_filed_is_not_also_noted() {
let mut state = state_with(vec![], vec![]);
let finding = graded(Severity::NonBlocking, "Timeout", "n.rs", true);
record_nonblocking_outcome(&mut state, &finding, None);
record_nonblocking_outcome(
&mut state,
&finding,
Some(&Followup::Recorded("https://example.invalid/9".into())),
);
assert!(state.noted.is_empty());
assert_eq!(vec!["https://example.invalid/9"], state.filed);
}
#[test]
fn filing_a_moved_point_removes_its_earlier_note() {
let mut state = state_with(vec![], vec![]);
let earlier = graded(Severity::NonBlocking, "Timeout", "src/net.rs:10", true);
let moved = graded(Severity::NonBlocking, "Timeout", "src/net.rs:12", false);
record_nonblocking_outcome(&mut state, &earlier, None);
record_nonblocking_outcome(
&mut state,
&moved,
Some(&Followup::Recorded("https://example.invalid/10".into())),
);
assert!(state.noted.is_empty());
assert_eq!(vec!["https://example.invalid/10"], state.filed);
}
#[test]
fn an_unrecorded_nonblocking_followup_remains_noted() {
let finding = graded(Severity::NonBlocking, "Timeout", "n.rs", true);
for outcome in [
Followup::Covered("https://example.invalid/closed".into()),
Followup::Dropped("follow-ups are off"),
Followup::Failed,
] {
let mut state = state_with(vec![], vec![]);
record_nonblocking_outcome(&mut state, &finding, Some(&outcome));
assert_eq!(1, state.noted.len(), "{outcome:?}");
assert!(state.filed.is_empty(), "{outcome:?}");
}
}
#[test]
fn a_point_noted_twice_is_listed_once() {
let mut state = state_with(vec![], vec![]);
let raised = graded(
Severity::NonBlocking,
"Timeout is not configurable",
"n.rs",
true,
);
let reworded = graded(
Severity::NonBlocking,
"timeout is not configurable!",
"n.rs",
true,
);
record_nonblocking_outcome(&mut state, &raised, None);
record_nonblocking_outcome(&mut state, &reworded, None);
assert_eq!(1, state.noted.len());
}
#[test]
fn same_title_notes_in_different_files_are_both_kept() {
let mut state = state_with(vec![], vec![]);
for file in ["src/a.rs", "src/b.rs"] {
let finding = graded(Severity::NonBlocking, "Unchecked error", file, true);
record_nonblocking_outcome(&mut state, &finding, None);
}
assert_eq!(2, state.noted.len());
}
#[test]
fn same_title_notes_at_two_sites_in_one_review_are_both_kept() {
let findings = vec![
graded(
Severity::NonBlocking,
"Unchecked error",
"src/a.rs:10",
true,
),
graded(
Severity::NonBlocking,
"Unchecked error",
"src/a.rs:200",
true,
),
];
let mut state = state_with(vec![], vec![]);
for finding in &findings {
record_nonblocking_outcome_with_match(
&mut state,
finding,
None,
unique_stable_finding(&findings, finding),
);
}
assert_eq!(2, state.noted.len());
}
#[test]
fn settling_one_of_two_same_title_notes_keeps_the_other() {
let first = graded(
Severity::NonBlocking,
"Unchecked error",
"src/a.rs:10",
true,
);
let second = graded(
Severity::NonBlocking,
"Unchecked error",
"src/a.rs:200",
true,
);
let mut state = state_with(vec![], vec![]);
remember_noted(&mut state, &first, false);
remember_noted(&mut state, &second, false);
forget_noted(&mut state, &first, false);
assert_eq!(1, state.noted.len());
assert_eq!("src/a.rs:200", state.noted[0].file);
}
#[test]
fn same_title_disputes_at_two_sites_are_both_kept() {
let mut state = state_with(vec![], vec![]);
for file in ["src/a.rs:10", "src/a.rs:200"] {
remember_dispute(
&mut state,
Dispute {
title: "Unchecked error".into(),
file: file.into(),
reasoning: "the caller handles it".into(),
},
false,
);
}
assert_eq!(2, state.disputes.len());
}
#[test]
fn a_later_nonblocking_verdict_replaces_a_prior_dispute() {
let mut state = state_with(vec![], vec![]);
let finding = graded(Severity::NonBlocking, "Unchecked error", "src/a.rs", true);
remember_dispute(
&mut state,
Dispute {
title: finding.title.clone(),
file: finding.file.clone(),
reasoning: "the caller handles it".into(),
},
true,
);
record_nonblocking_outcome(&mut state, &finding, None);
assert!(state.disputes.is_empty());
assert_eq!(1, state.noted.len());
}
#[test]
fn a_note_moving_lines_in_the_same_file_is_updated() {
let mut state = state_with(vec![], vec![]);
let first = graded(
Severity::NonBlocking,
"Unchecked error",
"src/a.rs:12",
true,
);
let moved = graded(
Severity::NonBlocking,
"Unchecked error",
"src/a.rs:19",
true,
);
record_nonblocking_outcome(&mut state, &first, None);
record_nonblocking_outcome(&mut state, &moved, None);
assert_eq!(1, state.noted.len());
assert_eq!("src/a.rs:19", state.noted[0].file);
}
#[test]
fn a_settled_point_removes_its_stale_note() {
for outcome in [Settled::Fixed, Settled::Refuted, Settled::Filed] {
let noted = graded(Severity::NonBlocking, "Unchecked error", "src/a.rs", true);
let mut state = state_with(vec![], vec![]);
record_nonblocking_outcome(&mut state, ¬ed, None);
forget_noted(&mut state, ¬ed, true);
assert!(state.noted.is_empty(), "{outcome}");
}
}
#[test]
fn settling_a_moved_point_removes_its_old_dispute() {
let old = graded(Severity::Blocking, "Unchecked error", "src/a.rs:10", true);
let moved = graded(Severity::Blocking, "Unchecked error", "src/a.rs:12", true);
let mut state = state_with(vec![], vec![]);
remember_dispute(
&mut state,
Dispute {
title: old.title,
file: old.file,
reasoning: "the caller handles it".into(),
},
true,
);
forget_dispute(&mut state, &moved, true);
assert!(state.disputes.is_empty());
}
#[test]
fn a_deadlocked_point_is_not_also_noted() {
let mut state = state_with(vec![], vec![]);
let noted = graded(Severity::NonBlocking, "Unbounded loop", "x.rs", true);
record_nonblocking_outcome(&mut state, ¬ed, None);
let points = vec![finding("Unbounded loop", "x.rs")];
let text = outcome_comment(
&state,
&Ledger::new(),
&Ending::Deadlocked(&points),
&style(),
)
.unwrap();
assert!(!text.contains("Noted, not blocking"), "{text}");
}
#[test]
fn clipping_a_rendered_title_does_not_break_duplicate_suppression() {
let mut compact = style();
compact.max_title_chars = 5;
let mut state = state_with(
vec![("abcdefghij", "the caller already handles it")],
vec![],
);
state
.noted
.push(graded(Severity::NonBlocking, "abcdefghij", "x.rs", true));
state.disputes[0].file = "x.rs".into();
let points = vec![finding("abcdefghij", "x.rs")];
let text = outcome_comment(
&state,
&Ledger::new(),
&Ending::Unresolved(&points),
&compact,
)
.unwrap();
assert!(!text.contains("Raised and refuted"), "{text}");
assert!(!text.contains("Noted, not blocking"), "{text}");
}
#[test]
fn an_approval_that_filed_follow_ups_links_them() {
let state = state_with(
vec![],
vec![
"https://github.com/you/thing/issues/485",
"https://github.com/you/thing/issues/486",
],
);
let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
assert!(text.contains("Filed separately: #485, #486"), "{text}");
}
#[test]
fn a_run_that_changed_nothing_does_not_claim_there_is_something_to_read() {
let state = state_with(vec![], vec![]);
let text = outcome_comment(&state, &Ledger::new(), &Ending::Unchanged, &style()).unwrap();
assert!(text.contains("changed nothing"), "{text}");
assert!(!text.contains("was pushed"), "{text}");
}
#[test]
fn an_unresolved_close_names_what_is_still_wrong() {
let state = state_with(vec![], vec![]);
let left = vec![Finding {
detail: "The guard sits after the early return.".into(),
..finding("The retry fix never reaches the 429 path", "src/net.rs:88")
}];
let text =
outcome_comment(&state, &Ledger::new(), &Ending::Unresolved(&left), &style()).unwrap();
assert!(text.contains("These points are still open"), "{text}");
assert!(
text.contains("The retry fix never reaches the 429 path (src/net.rs:88)"),
"{text}"
);
assert!(
text.contains("The guard sits after the early return."),
"{text}"
);
assert!(!text.contains("has not been reviewed"), "{text}");
}
#[test]
fn running_out_of_rounds_says_what_that_means_for_the_reader() {
let state = state_with(vec![], vec![]);
let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
assert!(text.contains("has not been reviewed"), "{text}");
assert!(
!text.to_lowercase().contains("round 3"),
"no round numbers: {text}"
);
assert!(!text.to_lowercase().contains("convergence"), "{text}");
}
#[test]
fn a_failed_close_reports_unread_fixes_and_carried_blockers() {
let state = state_with(vec![], vec![]);
let open = vec![Finding {
detail: "the failure is still discarded".into(),
..finding("Unchecked error", "src/net.rs:88")
}];
let text = outcome_comment_with_unread(
&state,
&Ledger::new(),
&Ending::OutOfRounds,
&open,
&style(),
)
.unwrap();
assert!(text.contains("has not been reviewed"), "{text}");
assert!(text.contains("These points were already open"), "{text}");
assert!(text.contains("Unchecked error (src/net.rs:88)"), "{text}");
}
#[test]
fn a_deadlock_names_the_point_they_could_not_settle() {
let state = state_with(vec![], vec![]);
let points = [finding("Retry loop never terminates", "src/net.rs:88")];
let text = outcome_comment(
&state,
&Ledger::new(),
&Ending::Deadlocked(&points),
&style(),
)
.unwrap();
assert!(
text.contains("Retry loop never terminates (src/net.rs:88)"),
"{text}"
);
assert!(text.contains("could not settle"), "{text}");
}
#[test]
fn refutations_survive_because_nothing_else_carries_them() {
let state = state_with(
vec![(
"Error is swallowed",
"the caller already validates the file",
)],
vec![],
);
let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
assert!(text.contains("Raised and refuted:"), "{text}");
assert!(
text.contains("The caller already validates the file"),
"{text}"
);
}
#[test]
fn no_agent_names_counts_or_round_numbers_reach_the_thread() {
let state = state_with(
vec![("A point", "a reason")],
vec!["https://github.com/you/thing/issues/485"],
);
let left = vec![finding("A point", "a.rs")];
for ending in [
Ending::Approved,
Ending::OutOfRounds,
Ending::Unresolved(&left),
] {
let text = outcome_comment(&state, &Ledger::new(), &ending, &style()).unwrap();
let lower = text.to_lowercase();
for banned in ["claude", "codex", "blocking,", "nit,", " fixed."] {
assert!(
!lower.contains(banned),
"{banned:?} leaked into the thread:\n{text}"
);
}
for n in 1..9 {
assert!(
!lower.contains(&format!("round {n}")),
"a round number leaked into the thread:\n{text}"
);
}
}
}
#[test]
fn a_refutation_is_allowed_to_make_its_case() {
let reasoning = "The caller validates against the schema first. \
The discarded error is therefore unreachable in practice. ";
let state = state_with(
vec![("A point", &reasoning.repeat(6))],
vec!["https://github.com/you/thing/issues/485"],
);
let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
assert!(
!text.contains("..."),
"nothing was cut mid thought:\n{text}"
);
assert!(text.len() < 4000, "{} chars", text.len());
}
#[test]
fn a_url_that_is_not_an_issue_link_is_left_alone() {
assert_eq!(
"#485",
as_reference("https://github.com/you/thing/issues/485")
);
assert_eq!("note: something", as_reference("note: something"));
}
}
#[cfg(test)]
mod filed_reference_tests {
use super::*;
#[test]
fn an_issue_url_yields_its_number() {
assert_eq!(
Some(485),
filed_issue_number("https://github.com/you/thing/issues/485")
);
}
#[test]
fn a_local_note_yields_nothing() {
assert_eq!(None, filed_issue_number("note: Retry is unbounded"));
assert_eq!(None, filed_issue_number(""));
assert_eq!(
None,
filed_issue_number("https://github.com/you/thing/issues/")
);
}
}
#[cfg(test)]
mod followup_restraint_tests {
use super::*;
use crate::model::Severity;
fn cfg_with(followups: Followups, non_blocking: bool, nits: bool, cap: usize) -> Config {
let mut cfg =
crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
.unwrap();
cfg.loop_cfg.followups = followups;
cfg.loop_cfg.file_non_blocking = non_blocking;
cfg.loop_cfg.file_nits = nits;
cfg.loop_cfg.max_followups = cap;
cfg
}
fn finding(severity: Severity, title: &str, in_scope: bool) -> Finding {
Finding {
severity,
title: title.into(),
detail: "d".into(),
file: "a.rs".into(),
in_scope,
..Default::default()
}
}
#[test]
fn a_non_blocking_finding_is_not_a_tracker_item_by_default() {
let cfg = cfg_with(Followups::Issues, false, false, 5);
assert!(!cfg.loop_cfg.file_non_blocking);
assert!(!cfg.loop_cfg.file_nits);
}
#[test]
fn follow_ups_stay_off_the_tracker_by_default() {
let cfg =
crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
.unwrap();
assert_eq!(
Followups::Local,
cfg.loop_cfg.followups,
"the tracker is somebody's queue; the default must not write to it"
);
assert_eq!(5, cfg.loop_cfg.max_followups);
}
#[test]
fn only_out_of_scope_defects_qualify_at_the_defaults() {
let cfg = cfg_with(Followups::Issues, false, false, 5);
let qualifies = |f: &Finding| match f.severity {
Severity::NonBlocking => cfg.loop_cfg.file_non_blocking && f.in_scope,
Severity::Nit => cfg.loop_cfg.file_nits && f.in_scope,
Severity::Blocking => false,
} || !f.in_scope;
assert!(qualifies(&finding(
Severity::Blocking,
"pre-existing",
false
)));
assert!(!qualifies(&finding(
Severity::NonBlocking,
"improvement",
true
)));
assert!(!qualifies(&finding(Severity::Nit, "taste", true)));
assert!(!qualifies(&finding(
Severity::Blocking,
"fix it here",
true
)));
}
#[test]
fn opening_it_up_lets_non_blocking_findings_through_again() {
let cfg = cfg_with(Followups::Issues, true, false, 5);
assert!(cfg.loop_cfg.file_non_blocking);
}
#[test]
fn the_cap_is_a_real_backstop() {
let cfg = cfg_with(Followups::Issues, false, false, 3);
let mut state = IssueRun::new(1, "t");
state.filed = (0..3).map(|n| format!("url{n}")).collect();
assert!(state.filed.len() >= cfg.loop_cfg.max_followups);
}
#[test]
fn the_cap_bounds_what_one_run_can_spawn() {
let cfg = cfg_with(Followups::Issues, false, false, 5);
assert!(
cfg.loop_cfg.max_followups <= 5,
"a run that can file ten follow-ups is a branching process"
);
}
}
#[cfg(test)]
mod followup_outcome_tests {
use super::*;
const URL: &str = "https://github.com/you/thing/issues/485";
fn entry(recorded: Followup) -> Option<(Settled, String)> {
filed_entry(&recorded, "It predates this branch.")
}
#[test]
fn a_failed_followup_settles_nothing() {
assert_eq!(None, entry(Followup::Failed));
}
#[test]
fn an_uncertain_external_write_blocks_later_issue_followups_for_this_run() {
let mut state = IssueRun::new(1, "review");
let uncertain = SparError::uncertain_write("the result could not be verified");
assert_eq!(Followup::Failed, failed_followup(&mut state, &uncertain));
assert!(external_followup_write_paused(Followups::Issues, &state));
assert!(!external_followup_write_paused(Followups::Local, &state));
assert_eq!(1, state.notes.len());
assert_eq!(Followup::Failed, failed_followup(&mut state, &uncertain));
assert_eq!(1, state.notes.len(), "the recovery note was duplicated");
let mut ordinary = IssueRun::new(2, "review");
let error = SparError::new("permission denied");
assert_eq!(Followup::Failed, failed_followup(&mut ordinary, &error));
assert!(!external_followup_write_paused(
Followups::Issues,
&ordinary
));
}
#[test]
fn a_recorded_followup_is_filed_and_says_where() {
let (outcome, reasoning) = entry(Followup::Recorded(URL.into())).unwrap();
assert_eq!(Settled::Filed, outcome);
assert!(
reasoning.contains("It predates this branch."),
"{reasoning}"
);
assert!(reasoning.contains("#485"), "{reasoning}");
}
#[test]
fn a_closed_issue_covering_the_point_settles_it_without_offering_work() {
let recorded = Followup::from(Filed::AlreadyClosed(9, URL.into()));
assert_eq!(Followup::Covered(URL.into()), recorded);
assert_eq!(
None,
recorded.url(),
"a closed issue is not work to pick up"
);
let (outcome, reasoning) = entry(recorded).unwrap();
assert_eq!(Settled::Filed, outcome);
assert!(reasoning.contains("#485"), "{reasoning}");
}
#[test]
fn an_open_issue_that_already_covers_the_point_is_still_a_reference() {
for filed in [
Filed::Opened(9, URL.into()),
Filed::AddedTo(9, URL.into()),
Filed::Covered(9, URL.into()),
] {
assert_eq!(Some(URL), Followup::from(filed).url());
}
}
#[test]
fn a_dropped_followup_is_settled_but_never_reported_as_filed() {
let (outcome, reasoning) = entry(Followup::Dropped("follow-ups are off")).unwrap();
assert_eq!(Settled::Dropped, outcome);
assert!(reasoning.contains("follow-ups are off"), "{reasoning}");
assert!(reasoning.contains("Not filed"), "{reasoning}");
}
fn ledger_of(outcome: Settled, reasoning: &str) -> Ledger {
let mut ledger = Ledger::new();
ledger.insert(
finding_key("A pre-existing leak", "src/x.rs"),
LedgerEntry {
title: "A pre-existing leak".into(),
file: "src/x.rs".into(),
reasoning: reasoning.into(),
round: 1,
reraised: 0,
outcome,
},
);
ledger
}
#[test]
fn the_settled_block_tells_a_filed_point_from_a_dropped_one() {
let filed = settled_block(&ledger_of(Settled::Filed, "Tracked in #9."));
assert!(filed.contains("out of scope here, and filed"), "{filed}");
let dropped = settled_block(&ledger_of(Settled::Dropped, "Not filed anywhere: off."));
assert!(
dropped.contains("out of scope here, and not filed"),
"{dropped}"
);
assert!(dropped.contains("A pre-existing leak"), "{dropped}");
}
#[test]
fn a_deadlocked_point_that_was_never_filed_does_not_claim_to_be() {
let points = [Finding {
severity: Severity::Blocking,
title: "A pre-existing leak".into(),
detail: "d".into(),
file: "src/x.rs".into(),
in_scope: false,
..Default::default()
}];
let text = outcome_comment(
&IssueRun::new(1, "t"),
&ledger_of(Settled::Dropped, "Not filed anywhere: follow-ups are off."),
&Ending::Deadlocked(&points),
&Style::default(),
)
.unwrap();
assert!(text.contains("not filed"), "{text}");
assert!(!text.contains("Filed as out of scope"), "{text}");
}
}
#[cfg(test)]
mod issue_report_tests {
use super::*;
use crate::model::Severity;
fn reported() -> Finding {
Finding {
severity: Severity::Blocking,
title: "sendPaymentAsync bypasses drain mode and spending limits".into(),
detail: "The async path skips every admission check payInvoice applies.".into(),
file: "src/node.ts:412".into(),
in_scope: false,
problem: Some(
"`BeignetNode.sendPaymentAsync()` submits a payment directly to the Lightning \
engine without applying the safeguards used by `payInvoice()`.\n\nThe async path \
does not:\n\n- call `_checkDraining()`\n- call `_checkSpendLimit()`"
.into(),
),
reproduction: Some(
"1. Create a `BeignetNode` with `dailySpendLimitSats: 1`.\n2. Enable drain mode.\n\
3. Submit a 1,000 sat invoice.\n\nActual result:\n\n- The engine is called.\n\
- `spentSats` remains 0."
.into(),
),
impact: Some(
"An authorized client can submit async payments up to the available outbound \
liquidity despite the configured limits."
.into(),
),
expected: Some(
"- Reject new payments while draining.\n- Enforce the per-payment limit before \
submission.\n- Cover both paths with regression tests.\n\nThis predates the \
current branch."
.into(),
),
}
}
#[test]
fn a_reported_finding_becomes_a_bug_report() {
let body = issue_report(&reported());
for heading in [
"## Problem",
"## Reproduction",
"## Impact",
"## Expected behavior",
] {
assert!(body.contains(heading), "missing {heading}:\n{body}");
}
let at = |h: &str| body.find(h).unwrap();
assert!(at("## Problem") < at("## Reproduction"));
assert!(at("## Reproduction") < at("## Impact"));
assert!(at("## Impact") < at("## Expected behavior"));
}
#[test]
fn the_substance_survives_the_outbound_gates() {
let repo_style = Style::default();
let body = crate::style::issue_body(&issue_report(&reported()), &repo_style);
for kept in [
"_checkDraining()",
"Actual result:",
"outbound liquidity",
"regression tests",
"predates the current branch",
] {
assert!(body.contains(kept), "the gate ate {kept:?}:\n{body}");
}
assert!(!body.contains("..."), "something was cut:\n{body}");
}
#[test]
fn an_ordinary_finding_is_still_just_its_detail() {
let plain = Finding {
severity: Severity::NonBlocking,
title: "Name is vague".into(),
detail: "The variable could say what it holds.".into(),
file: "a.rs".into(),
in_scope: true,
..Default::default()
};
assert_eq!(
"The variable could say what it holds.",
issue_report(&plain)
);
}
#[test]
fn only_the_sections_that_were_written_appear() {
let partial = Finding {
problem: Some("The guard is inverted.".into()),
expected: Some("It should reject rather than accept.".into()),
..reported()
};
let partial = Finding {
reproduction: None,
impact: None,
..partial
};
let body = issue_report(&partial);
assert!(body.contains("## Problem") && body.contains("## Expected behavior"));
assert!(!body.contains("## Reproduction"), "{body}");
assert!(!body.contains("## Impact"), "{body}");
}
#[test]
fn the_summary_line_is_not_printed_twice() {
let echoed = Finding {
detail: "The guard is inverted so it rejects valid input.".into(),
problem: Some("The guard is inverted so it rejects valid input.".into()),
reproduction: None,
impact: None,
expected: None,
..reported()
};
let body = issue_report(&echoed);
assert_eq!(1, body.matches("The guard is inverted").count(), "{body}");
}
}