1use std::collections::BTreeSet;
21use std::path::{Path, PathBuf};
22
23use crate::agent::{self, Agent};
24use crate::config::{Call, Config, Drafts, Followups, PrComments};
25use crate::error::{ErrorKind, Result, SparError};
26use crate::jsonx::{exact_finding_key as finding_key, finding_file, stable_finding_key};
27use crate::model::{
28 Action, Disposition, Dispute, Finding, Followup, Implementation, Issue, IssueRun, Ledger,
29 LedgerEntry, NextAction, OwnFix, OwnFixDoc, PersistedState, PlanItem, PrView, ResponseDoc,
30 Review, Settled, Severity, SkippedItem, Status, Verdict, STATE_VERSION,
31};
32use crate::repo::Repo;
33use crate::style::{self, Style};
34use crate::{bail, log, logdim, logwarn, schema, spar_err};
35
36const IMPLEMENT_PROMPT: &str = "\
41Implement GitHub issue #{number} in this repository.
42
43Title: {title}
44URL: {url}
45
46{body}
47
48That is the issue body as filed. The discussion since is not included, so read
49the thread at the URL above if the body leaves anything open. If you cannot
50reach the network, work from what is here.
51
52Do the work and run the relevant tests. Leave the changes uncommitted. Do not
53commit, push, open a PR, or merge; the harness validates and commits the working
54tree after your report is accepted.
55
56Then report it. Your answer becomes the pull request description, and the
57reviewer reads that cold, with nothing but the diff and a link to the issue:
58say what you found wrong, what the change does about it, and how they confirm
59it for themselves. Say what you actually ran, not what could be run.
60
61If after reading the code you conclude this issue should not be implemented,
62make no changes and set not_worth_doing, with the reason.";
63
64const REVIEW_PROMPT: &str = "\
65Review the changes on this branch against `{base}`. They implement issue
66#{number}: {title}
67{context}
68Review thoroughly: correctness, edge cases, error handling, security, and
69whether the change actually resolves the issue. Read surrounding code, do not
70only read the diff.
71
72Label every finding by severity, and be honest about which is which:
73- blocking: the PR should not merge as is. Real defects only.
74- non-blocking: real, and smaller than another round. A minor defect belongs
75 here as much as an improvement does.
76- nit: style or taste.
77
78Blocking is the only severity that costs a round, and a round is another commit
79somebody has to read before this can merge. Being right that something is wrong
80is not enough to block. It has to be wrong in a way that would cost somebody.
81
82Confirm anything you label blocking before you label it. Run the code,
83reproduce the failure, or point at the exact line that breaks, and say in the
84detail what you did to confirm it. When you need to run something to check a
85claim, write a scratch file under the system temporary directory and run that,
86rather than passing a long program on the command line: it is easier to read
87back, easier to rerun, and less likely to be refused by a sandbox or a safety
88filter part way through your work. An unverified blocking finding is worse than
89one you never raised: it stalls a good PR and teaches the author to stop
90believing you. If you suspect a problem but could not confirm it, say so and
91label it non-blocking.
92
93Set in_scope=false for a real defect that exists, that this PR did not cause, and
94that is worth somebody stopping to fix. Each one becomes a tracked item a
95maintainer has to read and triage, so the bar is a defect and not an observation.
96A thorough reviewer can always find something adjacent to what it is reading;
97that is not a reason to file it. If you are not sure it is worth a maintainer's
98time, say your piece in the finding and label it non-blocking.
99
100Reviewing one issue should not manufacture ten more. If you find yourself with
101several out of scope findings, keep the ones that would bite somebody and drop
102the rest.
103
104What each label means for what happens next, so you can choose it knowing:
105- blocking: the author answers it this round, or you fix it yourself, and the
106 pull request does not merge until it is settled.
107- non-blocking: offered to the author in the same round as an optional fix, and
108 reported for a person either way. Nothing holds the merge for it.
109- nit: dropped. Nobody acts on it.
110
111Then choose next_action:
112- merge: no blocking findings, the PR is good.
113- fix_myself: there are blocking findings and you will fix them directly.
114- hand_back: there are blocking findings the author should address.
115
116Choose fix_myself for a mechanical fix nobody would argue about: the missing
117guard, the wrong comparison, the case the code plainly meant to handle. Choose
118hand_back when the author might reasonably disagree, or when the fix needs
119context you do not have. Fixing it yourself costs the author its chance to
120refute the point, and a refutation is a legitimate outcome here: an author that
121accepts every review comment to get approved produces worse code, not better.
122{open}{answers}{settled}{round}";
123
124const CLOSE_PROMPT: &str = "\
125This closes the review of issue #{number}: {title}
126{context}
127This is the final merge-safety audit. Read the full branch against `{base}` and
128answer one question: does the branch still contain anything that must not
129merge. Do not spend this pass on optional improvements or style.
130{landed}{open}{answers}{settled}
131Something blocks here when the branch still contains a confirmed defect that
132means it should not merge. That includes an open point above that the code does
133not answer, a defect in what landed since the last round, or a serious defect an
134earlier round missed. Go and look before you raise it. Run the test that covers
135it, or read the relevant lines and follow them to the caller, and say in the
136detail what you did.
137
138Keep this pass focused on merge safety. Minor defects and improvements are
139non-blocking. Keep in_scope=false for what it has always meant, a real defect
140this pull request did not cause, which the harness handles according to the
141configured follow-up policy. A confirmed in-scope defect does not become
142non-blocking merely because an earlier round missed it.
143
144Nothing you raise here will be fixed, because there is no round after this. A
145blocking finding means the pull request stays open and the finding is reported
146for a person to weigh. Non-blocking findings are reported without holding it
147open. No blocking findings means the branch is signed off on your word, so do
148not omit one because the list was long. Both mistakes cost somebody. Only one
149of them ships.
150
151Set next_action to merge when you raise nothing blocking, and hand_back when you
152do. Nothing acts on it here, and the findings are what decide.
153
154This call reads and nothing else. Do not edit the code, do not commit, and do
155not push. A pass that writes has judged a branch the rollback then takes away,
156so anything you leave behind is rolled back and the sign off does not stand.
157Put any scratch file under the system temporary directory, not in the working
158tree.";
159
160const FIX_PROMPT: &str = "\
161You reviewed this branch and chose to fix the blocking findings yourself.
162Implement those fixes now. Leave the changes uncommitted so the harness can
163validate and commit them.
164
165Your findings:
166{findings}
167
168Fix what the point says and nothing else. The smallest change that answers it is
169the right one: no refactor alongside it, no capability nobody asked for, no
170handling for cases nobody raised. Every line you add is what the next pass
171reviews, so a fix that grows the branch buys another round of findings about the
172fix. If a point cannot be answered without a change bigger than the point, set
173fixed=false for it and say why, rather than making the change.
174
175Report on every point above: fixed=true only where a change in the working tree
176answers it, and fixed=false with the reason where you left it. A point you
177report as left stays open and goes to the author, with your reason, so write
178that reason for them. Copy each title and file across exactly as given, so your
179answer can be matched back to the finding. The summary becomes the commit
180subject, so write one sentence naming what changed.
181
182Do not commit, push, or merge.";
183
184const RESPOND_PROMPT: &str = "\
185Here is a review of your PR for issue #{number}.
186{context}
187{findings}
188
189For each point, choose exactly one disposition:
190- fixed: the point is valid and in scope. Fix it and leave the change
191 uncommitted for the harness.
192- refuted: the point is wrong, or the change it asks for is bigger than the
193 problem it names. Explain why. Refuting is a legitimate outcome; do not accept
194 a review comment you believe is incorrect just to get the PR approved.
195- filed_issue: the point is valid but unrelated to this PR. Supply
196 new_issue_title and new_issue_body; the harness files it and skips duplicates.
197
198Copy each finding's title and file across exactly as given, so your answer can
199be matched back to the review. Give a reason for every disposition. For fixed,
200say what changed and how it answers the point. For refuted, say why the point
201does not stand. For filed_issue, say why it belongs outside this pull request.
202
203Fix what the point says and nothing else. The smallest change that answers it is
204the right one: no refactor alongside it, no capability nobody asked for, no
205handling for cases nobody raised. Every line you add is what the next pass
206reviews, so a fix that grows the branch buys another round of findings about the
207fix. If a point cannot be answered without a change bigger than the point, say so
208rather than making the change.
209{optional}
210Leave any fixes uncommitted. Do not commit, push, or merge.";
211
212const OPTIONAL_HEADER: &str = "\
219These are not blocking and will not hold the merge. You are making a commit for
220the points above anyway, so fix the ones that are a line or two, and decline the
221rest with a reason. Declining costs nothing: what you decline is reported for a
222person to weigh, exactly as it would have been if you had not been asked.
223
224Optional:
225";
226
227const CHECK_PROMPT: &str = "\
228The branch does not pass. spar ran `{command}` in this worktree after your
229change and it failed. This is the harness running it, not a claim by anybody.
230
231{output}
232
233Fix what it reports and leave the changes uncommitted, as before. If the failure
234is not caused by your change and the branch was already failing, say so in the
235summary and change nothing: the run stops either way, and saying so is worth
236more than a guess at somebody else's bug.
237
238Do not commit, push, or merge.";
239
240const CHECK_TIMEOUT_SECS: u64 = 1800;
246
247pub struct Checked {
249 pub command: String,
250 pub output: String,
251}
252
253fn run_check(cfg: &Config, work_dir: &Path) -> Option<std::result::Result<String, Checked>> {
255 let argv = cfg.loop_cfg.check.argv();
256 if argv.is_empty() {
257 return None;
258 }
259 let command = cfg.loop_cfg.check.describe();
260 logdim!("running the check: {command}");
261 let out = crate::proc::exec(
262 &argv,
263 &crate::proc::ExecOpts::new()
264 .cwd(work_dir)
265 .check(false)
266 .timeout_secs(CHECK_TIMEOUT_SECS)
267 .stop_descendants(true),
268 );
269 Some(match out {
270 Ok(out) if out.ok() => Ok(command),
271 Ok(out) => {
272 let text = format!("{}\n{}", out.stdout.trim_end(), out.stderr.trim_end());
273 Err(Checked {
274 command,
275 output: style::clip(text.trim(), 4000),
276 })
277 }
278 Err(e) => Err(Checked {
281 command,
282 output: format!("the check could not be run: {e}"),
283 }),
284 })
285}
286
287#[allow(clippy::too_many_arguments)]
295fn check_before_push(
296 cfg: &Config,
297 repo: &Repo,
298 agent: &Agent,
299 work_dir: &Path,
300 label: &str,
301 call: crate::config::Call,
302) -> Result<Option<String>> {
303 let Some(first) = run_check(cfg, work_dir) else {
304 return Ok(None);
305 };
306 let failed = match first {
307 Ok(command) => {
308 log!("{label}: `{command}` passed");
309 return Ok(Some(command));
310 }
311 Err(failed) => failed,
312 };
313 logwarn!(
314 "{label}: `{}` failed, handing it back to {} once. Nothing is pushed until it passes.",
315 failed.command,
316 agent.name()
317 );
318
319 let prompt = CHECK_PROMPT
320 .replace("{command}", &failed.command)
321 .replace("{output}", &failed.output);
322 let baseline = repo.worktree_baseline(work_dir)?;
323 let before = repo.head_oid_checked(work_dir)?;
324 let answer = agent.edit(&prompt, work_dir, &agent.effort(cfg, call));
325 match answer {
326 Ok(summary) => {
327 commit_accepted_changes(
328 cfg,
329 repo,
330 work_dir,
331 &baseline,
332 &summary,
333 "Make the branch pass its check",
334 )?;
335 }
336 Err(error) => {
337 let error = defer_clean_edit_error(repo, work_dir, &baseline, error)?;
338 bail!(
339 "{label}: `{}` failed and the fix call failed too, so nothing was pushed.\n{}\n\n{error}",
340 failed.command,
341 failed.output
342 );
343 }
344 }
345 let moved = repo.head_oid_checked(work_dir)? != before;
346 match run_check(cfg, work_dir) {
347 Some(Ok(command)) => {
348 log!("{label}: `{command}` passed after one fix");
349 Ok(Some(command))
350 }
351 Some(Err(again)) => {
352 bail!(
353 "{label}: `{}` still fails after one fix{}, so nothing was pushed and the worktree \
354 was kept.\n{}",
355 again.command,
356 if moved { "" } else { " and nothing was committed" },
357 again.output
358 )
359 }
360 None => Ok(None),
361 }
362}
363
364#[derive(Debug, Clone, PartialEq, Eq)]
374pub struct Snapshot {
375 pub head: String,
376 pub dirty: bool,
378}
379
380impl Snapshot {
381 pub fn landed_over(&self, before: &Snapshot) -> bool {
384 !self.head.is_empty() && self.head != before.head
385 }
386}
387
388pub fn snapshot(repo: &Repo, work_dir: &Path) -> Snapshot {
389 Snapshot {
390 head: repo
391 .git_try_at(Some(work_dir), &["rev-parse", "HEAD"])
392 .trim()
393 .to_string(),
394 dirty: !repo
395 .git_try_at(
396 Some(work_dir),
397 &["status", "--porcelain", "--untracked-files=no"],
398 )
399 .trim()
400 .is_empty(),
401 }
402}
403
404fn checked_head(repo: &Repo, work_dir: &Path) -> Result<String> {
405 let head = repo.git_at(Some(work_dir), &["rev-parse", "HEAD"])?;
406 let head = head.trim().to_string();
407 if head.is_empty() {
408 return Err(spar_err!("could not read the pull request head"));
409 }
410 Ok(head)
411}
412
413fn reconcile_saved_head(
422 recorded_head: Option<&str>,
423 state_version: Option<u32>,
424 saved_actor_known: bool,
425 actual_head: &str,
426 holder_override: Option<&str>,
427 pr_number: i64,
428) -> Result<bool> {
429 let Some(recorded_head) = recorded_head else {
430 return Ok(true);
431 };
432 if !recorded_head.is_empty() && recorded_head == actual_head {
433 return Ok(true);
434 }
435 if holder_override.is_some() {
436 return Ok(false);
437 }
438 if recorded_head.is_empty() && state_version == Some(1) && saved_actor_known {
439 return Ok(true);
440 }
441 let recorded = if recorded_head.is_empty() {
442 format!(
443 "state version {} without a recorded head",
444 state_version
445 .map(|version| version.to_string())
446 .unwrap_or_else(|| "unknown".to_string())
447 )
448 } else {
449 format!("head {recorded_head}")
450 };
451 Err(spar_err!(
452 "saved review state applies to {recorded}, but PR #{pr_number} is at {actual_head}; \
453 resume with --next <agent> to choose who reviews this head"
454 ))
455}
456
457pub fn park(repo: &Repo, work_dir: &Path) -> Option<String> {
464 let saved = repo
465 .git_try_at(Some(work_dir), &["stash", "create"])
466 .trim()
467 .to_string();
468 (!saved.is_empty()).then_some(saved)
469}
470
471fn reset_saving(repo: &Repo, work_dir: &Path, target: &str) {
478 let parked = park(repo, work_dir);
479 if let Err(e) = repo.git_at(Some(work_dir), &["reset", "--hard", target]) {
480 logdim!("could not roll the working tree back: {e}");
481 return;
482 }
483 if let Some(saved) = parked {
484 logdim!("`git stash apply {saved}` puts the discarded changes back");
485 }
486}
487
488pub fn undo_edits(repo: &Repo, work_dir: &Path, before: &Snapshot) -> Snapshot {
499 let current = snapshot(repo, work_dir);
500 if before.head.is_empty() {
501 return current;
502 }
503 if current.landed_over(before) {
504 logdim!(
505 "the commits being rolled back are still at {}",
506 current.head
507 );
508 }
509 reset_saving(repo, work_dir, &before.head);
510 snapshot(repo, work_dir)
511}
512
513fn preserve_closing_commit(repo: &Repo, ctx: &LoopCtx) -> Option<String> {
519 let current = snapshot(repo, &ctx.work_dir);
520 if current.head.is_empty() {
521 return None;
522 }
523 let reference = format!(
524 "refs/spar/recovery/pr-{}/closing-{}",
525 ctx.pr_number, current.head
526 );
527 match repo.git_at(
528 Some(&ctx.work_dir),
529 &["update-ref", &reference, ¤t.head],
530 ) {
531 Ok(_) => Some(reference),
532 Err(e) => {
533 logdim!("could not preserve the closing pass commit: {e}");
534 None
535 }
536 }
537}
538
539pub fn drop_uncommitted(repo: &Repo, work_dir: &Path) -> Snapshot {
546 let current = snapshot(repo, work_dir);
547 if !current.dirty || current.head.is_empty() {
548 return current;
549 }
550 reset_saving(repo, work_dir, ¤t.head);
551 snapshot(repo, work_dir)
552}
553
554fn should_release(cfg: &Config, status: Status) -> bool {
559 if !cfg.loop_cfg.worktrees || cfg.loop_cfg.keep_worktrees {
560 return false;
561 }
562 !matches!(status, Status::Escalated | Status::Error)
563}
564
565fn uncommitted_implementation_error(work_dir: &Path, detail: Option<&str>) -> SparError {
566 let detail = detail.unwrap_or_default().trim();
567 let prefix = if detail.is_empty() {
568 String::new()
569 } else {
570 format!("{detail}\n")
571 };
572 spar_err!(
573 "{prefix}The implementation left uncommitted changes in {}. Commit or recover them \
574 before running this issue again.",
575 work_dir.display()
576 )
577}
578
579fn commit_accepted_changes(
580 cfg: &Config,
581 repo: &Repo,
582 work_dir: &Path,
583 baseline: &crate::repo::WorktreeBaseline,
584 preferred_subject: &str,
585 fallback_subject: &str,
586) -> Result<bool> {
587 repo.refuse_changed_attributes(work_dir, baseline)?;
588 if !cfg.loop_cfg.worktrees && repo.has_uncommitted_changes(work_dir)? {
589 bail!(
590 "the implementation changed the shared checkout at {}, but spar cannot distinguish \
591 those files from edits made concurrently by its owner. The files were kept. Commit \
592 or recover them, then use the default worktree mode for managed commits.",
593 work_dir.display()
594 );
595 }
596 let committed =
597 repo.commit_pending_changes(work_dir, baseline, preferred_subject, fallback_subject)?;
598 repo.refuse_unrepresented_tracked_changes(work_dir, baseline)?;
599 Ok(committed)
600}
601
602pub fn run_issue(
607 agents: &[Agent],
608 cfg: &Config,
609 repo: &Repo,
610 item: &PlanItem,
611 issue: &Issue,
612) -> IssueRun {
613 run_nth_issue(agents, cfg, repo, item, issue, 0)
614}
615
616pub fn run_nth_issue(
621 agents: &[Agent],
622 cfg: &Config,
623 repo: &Repo,
624 item: &PlanItem,
625 issue: &Issue,
626 nth: usize,
627) -> IssueRun {
628 match repo.try_open_pr_for_issue(item.issue, cfg.base_branch()) {
638 Ok(Some(existing)) => {
639 log!(
640 "#{}: {} is already open, continuing it instead of implementing again",
641 item.issue,
642 existing.url
643 );
644 return resume_pr(agents, cfg, repo, existing.number, None);
645 }
646 Ok(None) => {}
647 Err(e) => {
648 let mut state = IssueRun::new(item.issue, item.title.clone());
649 state.status = Status::Error;
650 state.notes.push(e.to_string());
651 log!("#{} failed: {e}", item.issue);
652 return state;
653 }
654 }
655
656 let mut state = IssueRun::new(item.issue, item.title.clone());
657 let mut ledger = Ledger::new();
664 let base = cfg.base_branch().to_string();
665
666 let prepared = if cfg.loop_cfg.worktrees {
667 repo.worktree_add(item.issue, &base)
668 } else {
669 (|| {
670 if repo.has_uncommitted_changes(repo.root())? {
671 bail!(
672 "the shared checkout at {} has uncommitted changes. Refusing to reset it for \
673 issue #{}.",
674 repo.root().display(),
675 item.issue
676 );
677 }
678 if repo.has_changes_checked(repo.root(), &base)? {
679 let preserved = repo.current_branch_is_preserved(repo.root()).map_err(|e| {
680 spar_err!(
681 "could not verify whether a pull request preserves the shared checkout \
682 at {}: {}. Refusing to reset it for issue #{}.",
683 repo.root().display(),
684 e.last_line(),
685 item.issue
686 )
687 })?;
688 if !preserved {
689 bail!(
690 "the shared checkout at {} has commits that are not on {base}. Refusing \
691 to reset them for issue #{}.",
692 repo.root().display(),
693 item.issue
694 );
695 }
696 }
697 let branch = repo.branch_for_issue(item.issue);
698 let start = format!("origin/{base}");
699 repo.refuse_issue_branch_rebuild(item.issue, &base)?;
700 if repo.has_uncommitted_changes(repo.root())? {
701 bail!(
702 "the shared checkout at {} changed while issue #{} was being prepared. \
703 Refusing to reset it.",
704 repo.root().display(),
705 item.issue
706 );
707 }
708 repo.git(&["checkout", "-B", &branch, &start])?;
709 repo.record_branch(&branch, "issue", item.issue);
710 Ok((repo.root().to_path_buf(), branch))
711 })()
712 };
713
714 let (work_dir, branch) = match prepared {
715 Ok(pair) => pair,
716 Err(e) => {
717 state.status = Status::Error;
718 state.notes.push(e.to_string());
719 log!("#{} failed: {e}", item.issue);
720 return state;
721 }
722 };
723
724 let outcome = implement_and_review(
725 agents,
726 cfg,
727 repo,
728 item,
729 issue,
730 nth,
731 &mut ledger,
732 &mut state,
733 &work_dir,
734 &branch,
735 );
736 if let Err(e) = outcome {
737 state.status = Status::Error;
738 state.notes.push(e.to_string());
739 log!("#{} failed: {e}", item.issue);
740 }
741
742 if should_release(cfg, state.status) {
743 repo.worktree_remove(item.issue);
744 }
745 state
746}
747
748#[allow(clippy::too_many_arguments)]
749fn implement_and_review(
750 agents: &[Agent],
751 cfg: &Config,
752 repo: &Repo,
753 item: &PlanItem,
754 issue: &Issue,
755 nth: usize,
756 ledger: &mut Ledger,
757 state: &mut IssueRun,
758 work_dir: &Path,
759 branch: &str,
760) -> Result<()> {
761 let number = item.issue;
762 let holder = cfg.first_implementor_for(nth);
763 let implementor = agent::find(agents, &holder)?;
764 let base = cfg.base_branch().to_string();
765
766 log!("#{number}: {holder} implementing");
767 repo.remember_implementor(number, &holder);
768 let (body, shortened) = issue.body_for_prompt(cfg.loop_cfg.max_issue_chars);
773 if shortened {
774 logwarn!(
775 "#{number}: the issue body was shortened to fit the prompt. Raise max_issue_chars if \
776 the rest matters."
777 );
778 }
779 let prompt = implement_prompt(number, &item.title, &issue.url, &body);
780 let worktree_baseline = repo.worktree_baseline(work_dir)?;
781 let answer: Result<Implementation> = implementor.edit_json(
782 &prompt,
783 &schema::implementation(),
784 work_dir,
785 &implementor.effort(cfg, Call::Implement).about(number),
786 );
787
788 if answer.is_ok() {
789 repo.refuse_changed_attributes(work_dir, &worktree_baseline)?;
790 }
791
792 if let Err(e) = &answer {
793 if e.kind() == crate::error::ErrorKind::UncertainWrite {
794 return Err(e.clone());
795 }
796 }
797
798 let has_commits = repo.has_changes_checked(work_dir, &base)?;
803 let has_uncommitted = repo.has_uncommitted_changes(work_dir)?;
804 let mut work = match answer {
805 Err(e) if has_uncommitted => {
806 return Err(uncommitted_implementation_error(
807 work_dir,
808 Some(e.message()),
809 ));
810 }
811 Ok(work) => work,
812 Err(e) if has_commits => {
813 logwarn!(
814 "#{number}: {holder} failed after committing: {e}\nContinuing from the commits, \
815 with a pull request body written from their messages."
816 );
817 state
818 .notes
819 .push(format!("{holder} failed after committing: {e}"));
820 from_commits(repo, work_dir, &base)
821 }
822 Err(e) => return Err(e),
823 };
824
825 if work.not_worth_doing {
826 repo.refuse_unrepresented_tracked_changes(work_dir, &worktree_baseline)?;
827 repo.refuse_changed_existing_untracked(work_dir, &worktree_baseline)?;
828 if has_uncommitted || has_commits {
829 bail!(
830 "{holder} declined issue #{number} after changing {}. The worktree was kept for \
831 recovery.",
832 work_dir.display()
833 );
834 }
835 repo.refuse_new_ignored_files(work_dir, &worktree_baseline)?;
836 state.status = Status::Abandoned;
837 let reason = no_pr_note(&work, &repo.style);
838 state.notes.push(reason.clone());
839 if let Err(e) = repo.comment_issue(number, &reason) {
840 logdim!("could not comment on #{number}: {e}");
841 }
842 return Ok(());
843 }
844
845 commit_accepted_changes(
846 cfg,
847 repo,
848 work_dir,
849 &worktree_baseline,
850 &work.summary,
851 &item.title,
852 )?;
853 if repo.has_uncommitted_changes(work_dir)? {
854 return Err(uncommitted_implementation_error(
855 work_dir,
856 work.notes.as_deref(),
857 ));
858 }
859 if !repo.has_changes_checked(work_dir, &base)? {
860 repo.refuse_new_ignored_files(work_dir, &worktree_baseline)?;
861 state.status = Status::Abandoned;
862 let reason = no_pr_note(&work, &repo.style);
863 state.notes.push(reason.clone());
864 if let Err(e) = repo.comment_issue(number, &reason) {
865 logdim!("could not comment on #{number}: {e}");
866 }
867 return Ok(());
868 }
869
870 if work.summary.trim().is_empty() {
874 work.summary = item.title.clone();
875 }
876
877 let checked = check_before_push(
882 cfg,
883 repo,
884 implementor,
885 work_dir,
886 &format!("#{number}"),
887 Call::Implement,
888 )?;
889
890 repo.rewrite_commits_if_needed(work_dir, &base, None)?;
894 repo.push(work_dir, branch, None)?;
898
899 let description = pr_body(number, &work, checked.as_deref(), &repo.style);
900 let pr = match repo.pr_for_branch(branch) {
901 Some(existing) => existing,
902 None => {
903 let body = description.clone();
904 repo.create_pr(
905 work_dir,
906 branch,
907 &base,
908 &format!("{} (#{number})", item.title),
909 &body,
910 )?
911 }
912 };
913 repo.record_branch(branch, "pr", pr.number);
914 state.pr = Some(pr.url.clone());
915 log!("#{number}: PR {}", pr.url);
916
917 let ctx = LoopCtx {
918 work_dir: work_dir.to_path_buf(),
919 branch: branch.to_string(),
920 pr_number: pr.number,
921 context: context_block(number, &issue.url, &body, &description),
922 label: format!("#{number}"),
923 subject: number,
924 title: item.title.clone(),
925 start_round: 1,
926 holder: cfg.other(&holder),
927 release: Release::Issue(number),
928 };
929 review_loop(agents, cfg, repo, &ctx, state, ledger, Vec::new())
930}
931
932pub fn resume_pr(
943 agents: &[Agent],
944 cfg: &Config,
945 repo: &Repo,
946 pr_number: i64,
947 holder_override: Option<&str>,
948) -> IssueRun {
949 let failed = |e: SparError| {
950 log!("PR #{pr_number} failed: {e}");
951 let mut state = IssueRun::new(pr_number, format!("PR #{pr_number}"));
952 state.status = Status::Error;
953 state.notes.push(e.to_string());
954 state
955 };
956
957 let pr = match repo.pr_view(pr_number) {
958 Ok(pr) => pr,
959 Err(e) => return failed(e),
960 };
961
962 if pr.is_cross_repository {
967 log!("PR #{pr_number} comes from a fork, reviewing it without changing it");
968 return crate::review_only::review_pr(agents, cfg, repo, pr_number, false);
969 }
970
971 match resume_inner(agents, cfg, repo, pr, holder_override) {
972 Ok(state) => state,
973 Err(e) => failed(e),
974 }
975}
976
977fn resume_inner(
978 agents: &[Agent],
979 cfg: &Config,
980 repo: &Repo,
981 pr: PrView,
982 holder_override: Option<&str>,
983) -> Result<IssueRun> {
984 let pr_number = pr.number;
985 if !pr.is_open() {
986 return Err(spar_err!("PR #{pr_number} is {}", pr.state.to_lowercase()));
987 }
988
989 let subject = pr
990 .closing_issues_references
991 .first()
992 .map(|r| r.number)
993 .unwrap_or(pr_number);
994
995 if let Some(holder) = holder_override {
996 if !cfg.has_agent(holder) {
997 return Err(spar_err!(
998 "--next must name one of: {}",
999 cfg.agent_names().join(", ")
1000 ));
1001 }
1002 }
1003 let (work_dir, branch) = repo.worktree_for_pr(&pr)?;
1004 let actual_head = checked_head(repo, &work_dir)?;
1005 let saved = repo.read_state_for_head(&pr, &actual_head);
1006 let saved_actor_known = saved
1007 .as_ref()
1008 .map(|state| cfg.has_agent(&state.next_actor))
1009 .unwrap_or(false);
1010 let state_matches_head = reconcile_saved_head(
1011 saved.as_ref().map(|state| state.pr_head.as_str()),
1012 saved.as_ref().map(|state| state.version),
1013 saved_actor_known,
1014 &actual_head,
1015 holder_override,
1016 pr_number,
1017 )?;
1018 let migrates_legacy_state = saved.as_ref().is_some_and(|state| {
1019 state.version == 1
1020 && state.pr_head.is_empty()
1021 && saved_actor_known
1022 && holder_override.is_none()
1023 });
1024 if migrates_legacy_state {
1025 log!("PR #{pr_number}: migrating legacy review state to head {actual_head}");
1026 }
1027
1028 let mut ledger: Ledger = saved
1029 .as_ref()
1030 .filter(|_| state_matches_head)
1031 .map(|s| s.ledger.clone())
1032 .unwrap_or_default();
1033 normalise_ledger_keys(&mut ledger);
1034 let open_findings = saved
1035 .as_ref()
1036 .filter(|_| state_matches_head)
1037 .map(|s| blocking_findings(&s.open_findings))
1038 .unwrap_or_default();
1039 let start_round = saved
1040 .as_ref()
1041 .filter(|_| state_matches_head)
1042 .map(|s| s.round + 1)
1043 .unwrap_or(1);
1044
1045 let default_holder = cfg.other(&cfg.first_implementor);
1046 let mut holder = holder_override
1047 .map(str::to_string)
1048 .or_else(|| saved.as_ref().map(|s| s.next_actor.clone()))
1049 .unwrap_or_else(|| default_holder.clone());
1050 if !cfg.has_agent(&holder) {
1051 log!("state named unknown agent '{holder}', using {default_holder}");
1052 holder = default_holder;
1053 }
1054
1055 match &saved {
1056 Some(_) => log!(
1057 "PR #{pr_number}: resuming at round {start_round}, {} point(s) on record, next up \
1058 {holder}",
1059 ledger.len()
1060 ),
1061 None => log!("PR #{pr_number}: no prior spar state, starting fresh with {holder}"),
1062 }
1063
1064 let mut state = IssueRun::new(subject, pr.title.clone());
1065 state.pr = Some(pr.url.clone());
1066 if let Some(s) = &saved {
1067 state.filed = s.filed.clone();
1068 if state_matches_head {
1069 state.disputes = s.disputes.clone();
1070 state.noted = s.noted.clone();
1071 } else {
1072 log!(
1073 "PR #{pr_number}: saved state does not match {actual_head}; branch-dependent \
1074 review state was cleared"
1075 );
1076 }
1077 }
1078
1079 let issue = (subject != pr_number)
1082 .then(|| repo.read_issue(subject))
1083 .transpose()
1084 .unwrap_or_else(|e| {
1085 logdim!("could not read issue #{subject} for context: {e}");
1086 None
1087 });
1088 let (issue_body, issue_url) = match &issue {
1089 Some(issue) => {
1090 let (body, shortened) = issue.body_for_prompt(cfg.loop_cfg.max_issue_chars);
1091 if shortened {
1092 logwarn!(
1093 "PR #{pr_number}: the issue body was shortened to fit the prompt. Raise \
1094 max_issue_chars if the rest matters."
1095 );
1096 }
1097 (body, issue.url.clone())
1098 }
1099 None => (String::new(), String::new()),
1100 };
1101
1102 let ctx = LoopCtx {
1103 work_dir,
1104 branch,
1105 pr_number,
1106 context: context_block(subject, &issue_url, &issue_body, &pr.body),
1107 label: format!("PR #{pr_number}"),
1108 subject,
1109 title: pr.title.clone(),
1110 start_round,
1111 holder,
1112 release: Release::Pr(pr_number),
1113 };
1114
1115 let outcome = review_loop(
1116 agents,
1117 cfg,
1118 repo,
1119 &ctx,
1120 &mut state,
1121 &mut ledger,
1122 open_findings,
1123 );
1124 if let Err(e) = outcome {
1125 state.status = Status::Error;
1126 state.notes.push(e.to_string());
1127 log!("PR #{pr_number} failed: {e}");
1128 }
1129 if should_release(cfg, state.status) {
1130 repo.release_pr_worktree(pr_number);
1131 }
1132 Ok(state)
1133}
1134
1135#[derive(Debug, Clone, Copy)]
1140enum Release {
1141 Issue(i64),
1142 Pr(i64),
1143}
1144
1145struct LoopCtx {
1146 work_dir: PathBuf,
1147 branch: String,
1148 pr_number: i64,
1149 label: String,
1150 subject: i64,
1151 title: String,
1152 context: String,
1155 start_round: u32,
1156 holder: String,
1157 release: Release,
1158}
1159
1160impl LoopCtx {
1161 fn release(&self, repo: &Repo) -> bool {
1162 match self.release {
1163 Release::Issue(n) => repo.worktree_remove(n),
1164 Release::Pr(n) => repo.release_pr_worktree(n),
1165 }
1166 }
1167}
1168
1169fn review_loop(
1170 agents: &[Agent],
1171 cfg: &Config,
1172 repo: &Repo,
1173 ctx: &LoopCtx,
1174 state: &mut IssueRun,
1175 ledger: &mut Ledger,
1176 mut open_findings: Vec<Finding>,
1177) -> Result<()> {
1178 let base = cfg.base_branch().to_string();
1179 let mut holder = ctx.holder.clone();
1183
1184 let (first, last_allowed) = round_window(ctx.start_round, cfg.loop_cfg.max_rounds);
1190 let mut published_head = checked_head(repo, &ctx.work_dir)?;
1191 persist(
1192 repo,
1193 ctx.pr_number,
1194 state,
1195 ledger,
1196 &open_findings,
1197 &published_head,
1198 first.saturating_sub(1),
1199 &holder,
1200 )?;
1201 let base_ref = repo.base_ref(&ctx.work_dir, &base);
1207
1208 let mut audited_head = String::new();
1212 let mut last_round = first.saturating_sub(1);
1213
1214 for round in first..=last_allowed {
1215 last_round = round;
1216 state.rounds = round;
1217 let reviewer = agent::find(agents, &holder)?;
1218 let effort = reviewer.effort(cfg, Call::Review(round)).about(ctx.subject);
1219 log!(
1220 "{}: round {round}, {holder} reviewing ({})",
1221 ctx.label,
1222 effort.describe()
1223 );
1224
1225 let prompt = review_prompt(
1226 &base_ref,
1227 ctx.subject,
1228 &ctx.title,
1229 &ctx.context,
1230 ledger,
1231 &open_findings,
1232 round,
1233 last_allowed,
1234 );
1235 let before_review = snapshot(repo, &ctx.work_dir);
1236 let review_baseline = repo.worktree_baseline(&ctx.work_dir)?;
1237 audited_head = before_review.head.clone();
1240 let review = reviewer.review::<Review>(
1241 &base_ref,
1242 &prompt,
1243 &schema::review(),
1244 &ctx.work_dir,
1245 &effort,
1246 );
1247 if let Err(error) = &review {
1248 if error.kind() == crate::error::ErrorKind::UncertainWrite {
1249 return Err(error.clone());
1250 }
1251 }
1252 repo.refuse_unrepresented_tracked_changes(&ctx.work_dir, &review_baseline)?;
1253 repo.refuse_new_ignored_files(&ctx.work_dir, &review_baseline)?;
1254 let review = review?;
1255 if repo.has_uncommitted_changes(&ctx.work_dir)? {
1256 bail!(
1257 "{}: {holder} left uncommitted files while reviewing. They were kept at {} and \
1258 the review did not continue.",
1259 ctx.label,
1260 ctx.work_dir.display()
1261 );
1262 }
1263
1264 let mut editor: Option<String> = None;
1268 let review_wrote = snapshot(repo, &ctx.work_dir) != before_review;
1269 if review_wrote {
1270 logwarn!(
1271 "{}: {holder} changed the branch while reviewing it, which the review prompt \
1272 forbids. Rolling it back.",
1273 ctx.label
1274 );
1275 if undo_edits(repo, &ctx.work_dir, &before_review).head != before_review.head {
1276 state
1277 .notes
1278 .push(format!("{holder} committed during its own review"));
1279 editor = Some(holder.clone());
1280 }
1281 }
1282
1283 let blocking = blocking_findings(&review.findings);
1284 if let Some(said) = verdict_disagreement(&review, blocking.len()) {
1285 logwarn!("{}: {holder} {said}", ctx.label);
1286 }
1287 update_open_findings(&mut open_findings, &blocking, !review_wrote);
1288
1289 if repo.style.pr_comments == PrComments::Rounds {
1290 if let Err(e) = repo.comment_pr(
1291 ctx.pr_number,
1292 &review_comment(&holder, round, &review, &repo.style),
1293 ) {
1294 logdim!("could not post the review comment: {e}");
1295 }
1296 }
1297
1298 file_out_of_scope(repo, &review.findings, ctx.subject, state, cfg);
1302 file_nonblocking(repo, &review.findings, ctx.subject, state, cfg);
1303 remove_findings(&mut state.noted, &blocking);
1304
1305 if check_relitigation(ledger, &blocking, state) {
1306 state.status = Status::Escalated;
1307 post_outcome(
1308 repo,
1309 ctx.pr_number,
1310 state,
1311 ledger,
1312 Ending::Deadlocked(&blocking),
1313 );
1314 persist(
1315 repo,
1316 ctx.pr_number,
1317 state,
1318 ledger,
1319 &open_findings,
1320 &published_head,
1321 round,
1322 &holder,
1323 )?;
1324 return Ok(());
1325 }
1326
1327 if approval_stands(&blocking, review_wrote) {
1328 open_findings.clear();
1329 return approve(
1330 cfg,
1331 repo,
1332 ctx,
1333 state,
1334 ledger,
1335 &published_head,
1336 round,
1337 &holder,
1338 );
1339 }
1340
1341 persist(
1344 repo,
1345 ctx.pr_number,
1346 state,
1347 ledger,
1348 &open_findings,
1349 &published_head,
1350 round,
1351 &holder,
1352 )?;
1353
1354 let mut edit_error = None;
1355
1356 if blocking.is_empty() {
1357 logwarn!(
1362 "{}: {holder} found nothing blocking on a branch it had changed itself, so the \
1363 approval does not carry.",
1364 ctx.label
1365 );
1366 state.notes.push(format!(
1367 "{holder} passed the branch in round {round} after editing it; the edit was rolled \
1368 back and the approval did not stand"
1369 ));
1370 } else if review.next_action == NextAction::FixMyself {
1371 log!("{}: {holder} fixing its own findings", ctx.label);
1372 let prompt = FIX_PROMPT.replace("{findings}", &findings_for_prompt(&blocking));
1373 let before_fix = repo.head_oid_checked(&ctx.work_dir)?;
1374 let worktree_baseline = repo.worktree_baseline(&ctx.work_dir)?;
1375 let mut report: Option<OwnFixDoc> = None;
1376 let fix_error = match reviewer.edit_json::<OwnFixDoc>(
1377 &prompt,
1378 &schema::own_fix(),
1379 &ctx.work_dir,
1380 &effort,
1381 ) {
1382 Ok(doc) => {
1383 commit_accepted_changes(
1384 cfg,
1385 repo,
1386 &ctx.work_dir,
1387 &worktree_baseline,
1388 &doc.summary,
1389 "Address blocking review findings",
1390 )?;
1391 report = Some(doc);
1392 None
1393 }
1394 Err(error) => Some(defer_clean_edit_error(
1395 repo,
1396 &ctx.work_dir,
1397 &worktree_baseline,
1398 error,
1399 )?),
1400 };
1401 match editor_after(repo, &ctx.work_dir, &before_fix, &ctx.label, &holder)? {
1402 Some(who) => {
1403 let fixes = report.as_ref().map(|d| d.fixes.as_slice()).unwrap_or(&[]);
1415 let (claimed, left) = split_own_fixes(&blocking, fixes);
1416 record_own_fixes(&claimed, ledger, state, round);
1417 remove_findings(&mut open_findings, &blocking);
1418 extend_findings(&mut open_findings, &left);
1419 if !left.is_empty() {
1420 state.notes.push(format!(
1421 "{holder} fixed {} of {} of its own findings in round {round}; the \
1422 rest stay open",
1423 claimed.len(),
1424 blocking.len()
1425 ));
1426 }
1427 editor = Some(who);
1428 }
1429 None if fix_error.is_none() => {
1430 repo.refuse_new_ignored_files(&ctx.work_dir, &worktree_baseline)?;
1431 logwarn!(
1434 "{}: {holder} said it would fix its own findings and committed nothing, \
1435 so it keeps the pull request.",
1436 ctx.label
1437 );
1438 state.notes.push(format!(
1439 "{holder} chose to fix its own findings in round {round} and committed \
1440 nothing"
1441 ));
1442 }
1443 None => {}
1444 }
1445 edit_error = fix_error;
1446 } else {
1447 let author_name = cfg.other(&holder);
1448 let author = agent::find(agents, &author_name)?;
1449 log!(
1450 "{}: handing {} finding(s) to {author_name}",
1451 ctx.label,
1452 blocking.len()
1453 );
1454 let prompt = RESPOND_PROMPT
1455 .replace("{number}", &ctx.subject.to_string())
1456 .replace("{context}", &ctx.context)
1457 .replace("{findings}", &findings_for_prompt(&blocking))
1458 .replace("{optional}", &optional_block(&review.findings));
1459 let before_response = repo.head_oid_checked(&ctx.work_dir)?;
1460 let worktree_baseline = repo.worktree_baseline(&ctx.work_dir)?;
1461 let response: Result<ResponseDoc> = author.edit_json(
1462 &prompt,
1463 &schema::response(),
1464 &ctx.work_dir,
1465 &author.effort(cfg, Call::Respond(round)).about(ctx.subject),
1466 );
1467 let response = match response {
1468 Ok(response) => {
1469 commit_accepted_changes(
1470 cfg,
1471 repo,
1472 &ctx.work_dir,
1473 &worktree_baseline,
1474 &response.summary,
1475 "Address blocking review findings",
1476 )?;
1477 Some(response)
1478 }
1479 Err(error) => {
1480 edit_error = Some(defer_clean_edit_error(
1481 repo,
1482 &ctx.work_dir,
1483 &worktree_baseline,
1484 error,
1485 )?);
1486 None
1487 }
1488 };
1489 if let Some(who) = editor_after(
1490 repo,
1491 &ctx.work_dir,
1492 &before_response,
1493 &ctx.label,
1494 &author_name,
1495 )? {
1496 editor = Some(who);
1497 } else if let Some(response) = &response {
1498 repo.refuse_new_ignored_files(&ctx.work_dir, &worktree_baseline)?;
1499 if response
1500 .dispositions
1501 .iter()
1502 .any(|d| d.action == Action::Fixed)
1503 {
1504 logwarn!(
1505 "{}: {author_name} reported fixes but committed nothing, so the diff does \
1506 not have them.",
1507 ctx.label
1508 );
1509 }
1510 }
1511 if let Some(response) = response {
1512 let unresolved = apply_dispositions(
1513 repo,
1514 cfg,
1515 &response,
1516 &blocking,
1517 ledger,
1518 state,
1519 round,
1520 ctx.subject,
1521 ctx.pr_number,
1522 &author_name,
1523 editor.is_some(),
1524 );
1525 remove_findings(&mut open_findings, &blocking);
1526 extend_findings(&mut open_findings, &unresolved);
1527 apply_optional_dispositions(
1528 repo,
1529 cfg,
1530 &response,
1531 &review.findings,
1532 state,
1533 ctx.subject,
1534 );
1535 }
1536 }
1537
1538 if let Some(who) = &editor {
1539 check_before_push(
1540 cfg,
1541 repo,
1542 agent::find(agents, who)?,
1543 &ctx.work_dir,
1544 &ctx.label,
1545 Call::Respond(round),
1546 )?;
1547 }
1548 if editor.is_some() {
1549 repo.rewrite_commits_if_needed(&ctx.work_dir, &base, Some(&published_head))?;
1550 repo.push(&ctx.work_dir, &ctx.branch, Some(&published_head))?;
1551 published_head = checked_head(repo, &ctx.work_dir)?;
1552 }
1553 holder = next_reviewer(cfg, &holder, editor.as_deref());
1554 persist(
1555 repo,
1556 ctx.pr_number,
1557 state,
1558 ledger,
1559 &open_findings,
1560 &published_head,
1561 round,
1562 &holder,
1563 )?;
1564 if let Some(error) = edit_error {
1565 return Err(error);
1566 }
1567 }
1568
1569 close_out(
1576 agents,
1577 cfg,
1578 repo,
1579 ctx,
1580 state,
1581 ledger,
1582 &mut open_findings,
1583 &holder,
1584 last_round,
1585 &audited_head,
1586 &published_head,
1587 )
1588}
1589
1590#[allow(clippy::too_many_arguments)]
1601fn close_out(
1602 agents: &[Agent],
1603 cfg: &Config,
1604 repo: &Repo,
1605 ctx: &LoopCtx,
1606 state: &mut IssueRun,
1607 ledger: &mut Ledger,
1608 open_findings: &mut Vec<Finding>,
1609 holder: &str,
1610 round: u32,
1611 audited_head: &str,
1612 published_head: &str,
1613) -> Result<()> {
1614 let stop =
1615 |state: &mut IssueRun, ledger: &Ledger, open_findings: &[Finding], ending: Ending<'_>| {
1616 state.status = Status::Escalated;
1617 state.notes.push(exhausted_note(ctx.start_round, round));
1618 post_outcome(repo, ctx.pr_number, state, ledger, ending);
1619 persist(
1620 repo,
1621 ctx.pr_number,
1622 state,
1623 ledger,
1624 open_findings,
1625 published_head,
1626 round,
1627 holder,
1628 )
1629 };
1630
1631 let landed = (!audited_head.is_empty())
1638 .then(|| repo.commits_since(&ctx.work_dir, audited_head, "HEAD"))
1639 .flatten();
1640 if audited_head.is_empty()
1641 || (landed.as_ref().is_some_and(|l| l.is_empty()) && !any_fixes(ledger, round))
1642 {
1643 logdim!(
1644 "{}: nothing landed after the last review, so there is nothing to close over",
1645 ctx.label
1646 );
1647 if !open_findings.is_empty() {
1648 state.notes.push(unresolved_note(open_findings.len()));
1649 }
1650 stop(
1651 state,
1652 ledger,
1653 open_findings,
1654 ending_without_landing(open_findings),
1655 )?;
1656 return Ok(());
1657 }
1658
1659 let closer = agent::find(agents, holder)?;
1660 let effort = closer.effort(cfg, Call::Close).about(ctx.subject);
1661 log!(
1662 "{}: closing, {holder} checking what the last round left ({})",
1663 ctx.label,
1664 effort.describe()
1665 );
1666
1667 let prompt = close_prompt(
1668 &repo.base_ref(&ctx.work_dir, cfg.base_branch()),
1669 ctx.subject,
1670 &ctx.title,
1671 &ctx.context,
1672 audited_head,
1673 landed.as_deref(),
1674 ledger,
1675 open_findings,
1676 round,
1677 );
1678 let before = snapshot(repo, &ctx.work_dir);
1679 let closing_baseline = repo.worktree_baseline(&ctx.work_dir)?;
1680 let pass = closer.ask_json(&prompt, &schema::review(), &ctx.work_dir, &effort);
1684 if let Err(e) = &pass {
1685 if e.kind() == crate::error::ErrorKind::UncertainWrite {
1686 return Err(e.clone());
1687 }
1688 }
1689 repo.refuse_unrepresented_tracked_changes(&ctx.work_dir, &closing_baseline)?;
1690 repo.refuse_new_ignored_files(&ctx.work_dir, &closing_baseline)?;
1691 if repo.has_uncommitted_changes(&ctx.work_dir)? {
1692 bail!(
1693 "{}: {holder} left uncommitted files during the closing pass. They were kept at {} \
1694 and the pass did not continue.",
1695 ctx.label,
1696 ctx.work_dir.display()
1697 );
1698 }
1699
1700 let close_wrote = snapshot(repo, &ctx.work_dir) != before;
1703 let next = closing_next_actor(holder);
1707 if close_wrote {
1708 if let Err(error) = &pass {
1709 logwarn!(
1710 "{}: the closing pass failed after changing the branch: {error}",
1711 ctx.label
1712 );
1713 }
1714 logwarn!(
1715 "{}: {holder} changed the branch while closing, which the prompt forbids. Rolling it \
1716 back.",
1717 ctx.label
1718 );
1719 let after_undo = undo_edits(repo, &ctx.work_dir, &before);
1720 if after_undo != before {
1721 state.notes.push(format!(
1722 "{holder}'s closing-pass changes could not be fully rolled back"
1723 ));
1724 if after_undo.head != before.head {
1725 if let Some(reference) = preserve_closing_commit(repo, ctx) {
1726 state.notes.push(format!(
1727 "the closing pass commit was not pushed and remains at {reference}"
1728 ));
1729 }
1730 }
1731 }
1732 state.status = Status::Escalated;
1733 state.notes.push(format!(
1734 "{holder} edited the branch during the closing pass, so its answer did not stand"
1735 ));
1736 post_unread_outcome(repo, ctx.pr_number, state, ledger, open_findings);
1737 persist(
1738 repo,
1739 ctx.pr_number,
1740 state,
1741 ledger,
1742 open_findings,
1743 published_head,
1744 round,
1745 &next,
1746 )?;
1747 return Ok(());
1748 }
1749
1750 let pass: Review = match pass {
1751 Ok(pass) => pass,
1752 Err(e) => {
1753 logwarn!("{}: the closing pass failed: {e}", ctx.label);
1757 state.status = Status::Escalated;
1758 state.notes.push(exhausted_note(ctx.start_round, round));
1759 post_unread_outcome(repo, ctx.pr_number, state, ledger, open_findings);
1760 persist(
1761 repo,
1762 ctx.pr_number,
1763 state,
1764 ledger,
1765 open_findings,
1766 published_head,
1767 round,
1768 holder,
1769 )?;
1770 return Ok(());
1771 }
1772 };
1773
1774 file_out_of_scope(repo, &pass.findings, ctx.subject, state, cfg);
1775 file_nonblocking(repo, &pass.findings, ctx.subject, state, cfg);
1776
1777 let blocking = blocking_findings(&pass.findings);
1778 if let Some(said) = verdict_disagreement(&pass, blocking.len()) {
1779 logwarn!("{}: the closing pass {said}", ctx.label);
1780 }
1781 remove_findings(&mut state.noted, &blocking);
1782 update_open_findings(open_findings, &blocking, true);
1783
1784 if check_relitigation(ledger, &blocking, state) {
1785 state.status = Status::Escalated;
1786 post_outcome(
1787 repo,
1788 ctx.pr_number,
1789 state,
1790 ledger,
1791 Ending::Deadlocked(&blocking),
1792 );
1793 persist(
1794 repo,
1795 ctx.pr_number,
1796 state,
1797 ledger,
1798 open_findings,
1799 published_head,
1800 round,
1801 &next,
1802 )?;
1803 return Ok(());
1804 }
1805
1806 if approval_stands(&blocking, false) {
1807 open_findings.clear();
1808 return approve(cfg, repo, ctx, state, ledger, published_head, round, &next);
1809 }
1810
1811 state.status = Status::Escalated;
1812 state.notes.push(unresolved_note(open_findings.len()));
1813 post_outcome(
1814 repo,
1815 ctx.pr_number,
1816 state,
1817 ledger,
1818 Ending::Unresolved(open_findings),
1819 );
1820 persist(
1821 repo,
1822 ctx.pr_number,
1823 state,
1824 ledger,
1825 open_findings,
1826 published_head,
1827 round,
1828 &next,
1829 )?;
1830 Ok(())
1831}
1832
1833fn any_fixes(ledger: &Ledger, round: u32) -> bool {
1839 ledger
1840 .values()
1841 .any(|e| e.outcome == Settled::Fixed && e.round >= round)
1842}
1843
1844fn unresolved_note(left: usize) -> String {
1849 match left {
1850 1 => "one point left after the closing pass".to_string(),
1851 n => format!("{n} points left after the closing pass"),
1852 }
1853}
1854
1855fn ensure_reviewed_head(pr_number: i64, reviewed_head: &str, live_head: &str) -> Result<()> {
1861 if live_head == reviewed_head {
1862 return Ok(());
1863 }
1864 Err(spar_err!(
1865 "PR #{pr_number} changed from {reviewed_head} to {live_head} after it was reviewed; refusing to approve or merge an unread head"
1866 ))
1867}
1868
1869#[allow(clippy::too_many_arguments)]
1870fn approve(
1871 cfg: &Config,
1872 repo: &Repo,
1873 ctx: &LoopCtx,
1874 state: &mut IssueRun,
1875 ledger: &Ledger,
1876 published_head: &str,
1877 round: u32,
1878 holder: &str,
1879) -> Result<()> {
1880 let live_head = repo.pr_head_oid(ctx.pr_number)?;
1881 ensure_reviewed_head(ctx.pr_number, published_head, &live_head)?;
1882 state.status = Status::Approved;
1883 persist(
1884 repo,
1885 ctx.pr_number,
1886 state,
1887 ledger,
1888 &[],
1889 published_head,
1890 round,
1891 holder,
1892 )?;
1893 let live_head = repo.pr_head_oid(ctx.pr_number)?;
1894 ensure_reviewed_head(ctx.pr_number, published_head, &live_head)?;
1895 post_outcome(repo, ctx.pr_number, state, ledger, Ending::Approved);
1896 if cfg.loop_cfg.drafts == Drafts::UntilApproved && repo.mark_ready(ctx.pr_number) {
1900 log!("{}: out of draft", ctx.label);
1901 }
1902 if cfg.loop_cfg.auto_merge {
1903 let released = ctx.release(repo);
1907 if !released {
1908 log!(
1909 "{}: kept the worktree at {} and will leave its branch in place",
1910 ctx.label,
1911 ctx.work_dir.display()
1912 );
1913 }
1914 repo.merge_pr_at_head(ctx.pr_number, published_head, released)?;
1915 state.status = Status::Merged;
1916 repo.clear_state(ctx.pr_number); log!("{}: merged", ctx.label);
1918 } else {
1919 log!("{}: approved, awaiting human merge", ctx.label);
1920 }
1921 Ok(())
1922}
1923
1924fn approval_stands(blocking: &[Finding], review_wrote: bool) -> bool {
1932 blocking.is_empty() && !review_wrote
1933}
1934
1935fn next_reviewer(cfg: &Config, reviewer: &str, editor: Option<&str>) -> String {
1948 match editor {
1949 Some(editor) => cfg.other(editor),
1950 None => reviewer.to_string(),
1951 }
1952}
1953
1954fn defer_clean_edit_error(
1955 repo: &Repo,
1956 work_dir: &Path,
1957 baseline: &crate::repo::WorktreeBaseline,
1958 error: SparError,
1959) -> Result<SparError> {
1960 if error.kind() == ErrorKind::UncertainWrite {
1961 return Err(error);
1962 }
1963 repo.refuse_changed_attributes(work_dir, baseline)?;
1964 if repo.has_uncommitted_changes(work_dir)? {
1965 return Err(error);
1966 }
1967 repo.refuse_new_ignored_files(work_dir, baseline)?;
1968 repo.refuse_unrepresented_tracked_changes(work_dir, baseline)?;
1969 Ok(error)
1970}
1971
1972fn editor_after(
1980 repo: &Repo,
1981 work_dir: &Path,
1982 before_head: &str,
1983 label: &str,
1984 who: &str,
1985) -> Result<Option<String>> {
1986 if repo.has_uncommitted_changes(work_dir)? {
1987 bail!(
1988 "{label}: {who} left uncommitted files at {}. They were kept and the review did not \
1989 continue.",
1990 work_dir.display()
1991 );
1992 }
1993 let after_head = repo.head_oid_checked(work_dir)?;
1994 if after_head != before_head && !repo.is_ancestor_checked(work_dir, before_head, &after_head)? {
1995 bail!(
1996 "{label}: {who} moved HEAD to {after_head}, but it does not contain the previous \
1997 branch tip {before_head}. The worktree was kept for recovery and nothing was \
1998 published. Restore {before_head} or reapply the intended commits on top of it before \
1999 resuming."
2000 );
2001 }
2002 Ok((after_head != before_head).then(|| who.to_string()))
2003}
2004
2005fn round_window(start_round: u32, budget: u32) -> (u32, u32) {
2011 (start_round, start_round + budget.saturating_sub(1))
2012}
2013
2014fn spent(start_round: u32, last_round: u32) -> (u32, u32) {
2018 (last_round.saturating_sub(start_round) + 1, last_round)
2019}
2020
2021fn exhausted_note(start_round: u32, last_round: u32) -> String {
2022 let (this_run, total) = spent(start_round, last_round);
2023 if this_run == total {
2024 format!("no convergence after {this_run} rounds")
2025 } else {
2026 format!("no convergence after {this_run} more rounds ({total} in total)")
2027 }
2028}
2029
2030#[allow(clippy::too_many_arguments)]
2031fn persist(
2032 repo: &Repo,
2033 pr_number: i64,
2034 state: &IssueRun,
2035 ledger: &Ledger,
2036 open_findings: &[Finding],
2037 published_head: &str,
2038 round: u32,
2039 next_actor: &str,
2040) -> Result<()> {
2041 let payload = PersistedState {
2042 version: STATE_VERSION,
2043 checkpoint: 0,
2044 round,
2045 next_actor: next_actor.to_string(),
2046 status: state.status,
2047 pr_head: published_head.to_string(),
2048 ledger: ledger.clone(),
2049 filed: state.filed.clone(),
2050 open_findings: open_findings.to_vec(),
2051 disputes: state.disputes.clone(),
2052 noted: state.noted.clone(),
2053 };
2054 repo.write_state(pr_number, &payload)
2055}
2056
2057fn settled_block(ledger: &Ledger) -> String {
2062 if ledger.is_empty() {
2063 return String::new();
2064 }
2065 let lines: Vec<String> = ledger
2070 .values()
2071 .filter_map(|e| {
2072 let point = match e.file.trim() {
2073 "" => e.title.clone(),
2074 file => format!("{} ({file})", e.title),
2075 };
2076 match e.outcome {
2077 Settled::Refuted => Some(format!("- {point}: refuted because {}", e.reasoning)),
2078 Settled::Filed => Some(format!(
2079 "- {point}: out of scope here, and filed. {}",
2080 e.reasoning
2081 )),
2082 Settled::Dropped => Some(format!(
2083 "- {point}: out of scope here, and not filed. {}",
2084 e.reasoning
2085 )),
2086 Settled::Fixed => None,
2087 }
2088 })
2089 .collect();
2090 if lines.is_empty() {
2091 return String::new();
2092 }
2093 format!(
2094 "\nThe following points were already raised and settled, by a refutation or by a \
2095 follow-up issue. Treat them as settled. Do not raise them again unless you have new \
2096 evidence:\n{}",
2097 lines.join("\n")
2098 )
2099}
2100
2101fn fixed_lines(ledger: &Ledger, since: u32) -> Vec<String> {
2113 ledger
2114 .values()
2115 .filter(|e| e.outcome == Settled::Fixed && e.round >= since)
2116 .map(|e| {
2117 let claim = match e.reasoning.trim() {
2118 "" => "a committed change claims to address this point",
2119 reasoning => reasoning,
2120 };
2121 match e.file.trim() {
2122 "" => format!("- {}. Recorded answer: {claim}", e.title),
2123 file => format!("- {} ({file}). Recorded answer: {claim}", e.title),
2124 }
2125 })
2126 .collect()
2127}
2128
2129fn answers_block(ledger: &Ledger, round: u32) -> String {
2137 let lines = fixed_lines(ledger, round.saturating_sub(1));
2140 if lines.is_empty() {
2141 return String::new();
2142 }
2143 format!(
2144 "\nThese points were raised on this pull request in earlier rounds and the author says \
2145 it fixed them. The code is on the branch and the claim is the author's:\n{}\n\nCheck the \
2146 answer rather than taking it. If one of them is still not fixed, raise it again under \
2147 the same title, so the run can tell a point that was not answered from a new one.\n",
2148 lines.join("\n")
2149 )
2150}
2151
2152fn round_note(round: u32, last: u32) -> String {
2161 if round < last {
2162 return String::new();
2163 }
2164 "\nThis is the last round in this run that can ask the author for anything. After it, one \
2165 pass reads what landed and the pull request is either signed off or goes to a person with \
2166 what is left. Raise everything you mean to raise now. A point held back for a later round \
2167 does not get one.\n"
2168 .to_string()
2169}
2170
2171#[cfg(test)]
2176fn matching_ledger_key(ledger: &Ledger, title: &str, file: &str) -> Option<String> {
2177 matching_ledger_key_with_fallback(ledger, title, file, true)
2178}
2179
2180fn matching_ledger_key_with_fallback(
2181 ledger: &Ledger,
2182 title: &str,
2183 file: &str,
2184 allow_stable_fallback: bool,
2185) -> Option<String> {
2186 let exact = finding_key(title, file);
2187 if ledger.contains_key(&exact) {
2188 return Some(exact);
2189 }
2190 let legacy = crate::jsonx::finding_key(title, file);
2191 if ledger
2192 .get(&legacy)
2193 .is_some_and(|entry| same_finding_parts(&entry.title, &entry.file, title, file))
2194 {
2195 return Some(legacy);
2196 }
2197 if !allow_stable_fallback {
2198 return None;
2199 }
2200
2201 let stable = stable_finding_key(title, file);
2202 let path = finding_file(file);
2203 let mut matches = ledger
2204 .iter()
2205 .filter(|(saved_key, entry)| {
2206 if stable_finding_key(&entry.title, &entry.file) == stable {
2207 return true;
2208 }
2209 finding_file(&entry.file) == path
2210 && saved_key.as_str() == crate::jsonx::finding_key(title, &entry.file)
2211 })
2212 .map(|(key, _)| key.clone());
2213 let first = matches.next()?;
2214 matches.next().is_none().then_some(first)
2215}
2216
2217fn matching_ledger_entry<'a>(
2218 ledger: &'a Ledger,
2219 title: &str,
2220 file: &str,
2221) -> Option<&'a LedgerEntry> {
2222 matching_ledger_entry_with_fallback(ledger, title, file, true)
2223}
2224
2225fn matching_ledger_entry_with_fallback<'a>(
2226 ledger: &'a Ledger,
2227 title: &str,
2228 file: &str,
2229 allow_stable_fallback: bool,
2230) -> Option<&'a LedgerEntry> {
2231 let key = matching_ledger_key_with_fallback(ledger, title, file, allow_stable_fallback)?;
2232 ledger.get(&key)
2233}
2234
2235fn settle(
2236 ledger: &mut Ledger,
2237 title: &str,
2238 file: &str,
2239 allow_stable_fallback: bool,
2240 entry: LedgerEntry,
2241) {
2242 let old_key = matching_ledger_key_with_fallback(ledger, title, file, allow_stable_fallback);
2243 let reraised = old_key
2244 .as_ref()
2245 .and_then(|key| ledger.get(key))
2246 .map(|entry| entry.reraised)
2247 .unwrap_or(0);
2248 if let Some(old_key) = old_key {
2249 ledger.remove(&old_key);
2250 }
2251 ledger.insert(finding_key(title, file), LedgerEntry { reraised, ..entry });
2252}
2253
2254fn normalise_ledger_keys(ledger: &mut Ledger) {
2256 let mut normalised = Ledger::new();
2257 for (saved_key, mut entry) in std::mem::take(ledger) {
2258 let key = if saved_key.len() == 12
2259 && saved_key
2260 .chars()
2261 .all(|character| character.is_ascii_hexdigit())
2262 {
2263 saved_key
2264 } else {
2265 finding_key(&entry.title, &entry.file)
2266 };
2267 if let Some(previous) = normalised.get_mut(&key) {
2268 let reraised = previous.reraised.max(entry.reraised);
2269 if entry.round >= previous.round {
2270 entry.reraised = reraised;
2271 *previous = entry;
2272 } else {
2273 previous.reraised = reraised;
2274 }
2275 } else {
2276 normalised.insert(key, entry);
2277 }
2278 }
2279 *ledger = normalised;
2280}
2281
2282pub(crate) fn verdict_disagreement(review: &Review, blocking: usize) -> Option<String> {
2292 match (review.verdict, blocking, review.next_action) {
2293 (Verdict::Approve, n, _) if n > 0 => Some(format!(
2294 "the review says approve and raises {n} blocking finding(s); the findings decide"
2295 )),
2296 (Verdict::ChangesRequested, 0, NextAction::Merge) => Some(
2297 "the review says changes_requested, raises nothing blocking, and asks to merge; the \
2298 findings decide"
2299 .to_string(),
2300 ),
2301 _ => None,
2302 }
2303}
2304
2305fn blocking_findings(findings: &[Finding]) -> Vec<Finding> {
2306 let mut kept = Vec::new();
2307 for finding in findings.iter().filter(|finding| finding.blocks()) {
2308 if let Some(existing) = kept
2309 .iter_mut()
2310 .find(|existing| same_finding(existing, finding))
2311 {
2312 *existing = finding.clone();
2313 } else {
2314 kept.push(finding.clone());
2315 }
2316 }
2317 kept
2318}
2319
2320fn matching_finding_index(
2321 findings: &[Finding],
2322 target: &Finding,
2323 allow_stable_fallback: bool,
2324) -> Option<usize> {
2325 if let Some(index) = findings
2326 .iter()
2327 .position(|finding| same_finding(finding, target))
2328 {
2329 return Some(index);
2330 }
2331 if !allow_stable_fallback {
2332 return None;
2333 }
2334
2335 let stable = stable_finding_key(&target.title, &target.file);
2336 let mut matches = findings
2337 .iter()
2338 .enumerate()
2339 .filter(|(_, finding)| stable_finding_key(&finding.title, &finding.file) == stable);
2340 let first = matches.next().map(|(index, _)| index);
2341 first.filter(|_| matches.next().is_none())
2342}
2343
2344fn unique_stable_finding(findings: &[Finding], target: &Finding) -> bool {
2345 let stable = stable_finding_key(&target.title, &target.file);
2346 findings
2347 .iter()
2348 .filter(|finding| stable_finding_key(&finding.title, &finding.file) == stable)
2349 .count()
2350 == 1
2351}
2352
2353fn extend_findings(target: &mut Vec<Finding>, additions: &[Finding]) {
2355 for finding in additions {
2356 if let Some(index) =
2357 matching_finding_index(target, finding, unique_stable_finding(additions, finding))
2358 {
2359 target[index] = finding.clone();
2360 } else {
2361 target.push(finding.clone());
2362 }
2363 }
2364}
2365
2366fn update_open_findings(
2367 open_findings: &mut Vec<Finding>,
2368 current: &[Finding],
2369 answer_stands: bool,
2370) {
2371 if answer_stands {
2372 *open_findings = current.to_vec();
2373 } else {
2374 extend_findings(open_findings, current);
2375 }
2376}
2377
2378fn remove_findings(target: &mut Vec<Finding>, removed: &[Finding]) {
2379 for finding in removed {
2380 if let Some(index) =
2381 matching_finding_index(target, finding, unique_stable_finding(removed, finding))
2382 {
2383 target.remove(index);
2384 }
2385 }
2386}
2387
2388fn ending_without_landing(open_findings: &[Finding]) -> Ending<'_> {
2389 if open_findings.is_empty() {
2390 Ending::Unchanged
2391 } else {
2392 Ending::Unresolved(open_findings)
2393 }
2394}
2395
2396fn closing_next_actor(holder: &str) -> String {
2397 holder.to_string()
2398}
2399
2400fn remember_noted(state: &mut IssueRun, finding: &Finding, allow_stable_fallback: bool) {
2413 if let Some(index) = matching_finding_index(&state.noted, finding, allow_stable_fallback) {
2414 state.noted[index] = finding.clone();
2415 } else {
2416 state.noted.push(finding.clone());
2417 }
2418}
2419
2420fn forget_noted(state: &mut IssueRun, finding: &Finding, allow_stable_fallback: bool) {
2421 if let Some(index) = matching_finding_index(&state.noted, finding, allow_stable_fallback) {
2422 state.noted.remove(index);
2423 }
2424}
2425
2426fn remember_dispute(state: &mut IssueRun, dispute: Dispute, allow_stable_fallback: bool) {
2427 let exact = finding_key(&dispute.title, &dispute.file);
2428 let stable = stable_finding_key(&dispute.title, &dispute.file);
2429 let exact_index = state
2430 .disputes
2431 .iter()
2432 .position(|kept| finding_key(&kept.title, &kept.file) == exact);
2433 let stable_index = if exact_index.is_none() && allow_stable_fallback {
2434 let mut matches = state
2435 .disputes
2436 .iter()
2437 .enumerate()
2438 .filter(|(_, kept)| stable_finding_key(&kept.title, &kept.file) == stable);
2439 let first = matches.next().map(|(index, _)| index);
2440 first.filter(|_| matches.next().is_none())
2441 } else {
2442 None
2443 };
2444 if let Some(index) = exact_index.or(stable_index) {
2445 state.disputes[index] = dispute;
2446 } else {
2447 state.disputes.push(dispute);
2448 }
2449}
2450
2451fn forget_dispute(state: &mut IssueRun, finding: &Finding, allow_stable_fallback: bool) {
2452 let exact = finding_key(&finding.title, &finding.file);
2453 if let Some(index) = state
2454 .disputes
2455 .iter()
2456 .position(|kept| finding_key(&kept.title, &kept.file) == exact)
2457 {
2458 state.disputes.remove(index);
2459 return;
2460 }
2461 if !allow_stable_fallback {
2462 return;
2463 }
2464
2465 let stable = stable_finding_key(&finding.title, &finding.file);
2466 let mut matches = state
2467 .disputes
2468 .iter()
2469 .enumerate()
2470 .filter(|(_, kept)| stable_finding_key(&kept.title, &kept.file) == stable);
2471 let first = matches.next().map(|(index, _)| index);
2472 if let Some(index) = first.filter(|_| matches.next().is_none()) {
2473 state.disputes.remove(index);
2474 }
2475}
2476
2477#[cfg(test)]
2478fn record_nonblocking_outcome(state: &mut IssueRun, finding: &Finding, outcome: Option<&Followup>) {
2479 record_nonblocking_outcome_with_match(state, finding, outcome, true);
2480}
2481
2482fn record_nonblocking_outcome_with_match(
2483 state: &mut IssueRun,
2484 finding: &Finding,
2485 outcome: Option<&Followup>,
2486 allow_stable_fallback: bool,
2487) {
2488 forget_dispute(state, finding, allow_stable_fallback);
2489 if let Some(Followup::Recorded(url)) = outcome {
2490 if !state.filed.iter().any(|filed| filed == url) {
2491 state.filed.push(url.clone());
2492 }
2493 forget_noted(state, finding, allow_stable_fallback);
2494 } else {
2495 remember_noted(state, finding, allow_stable_fallback);
2496 }
2497}
2498
2499fn split_own_fixes(blocking: &[Finding], fixes: &[OwnFix]) -> (Vec<Finding>, Vec<Finding>) {
2506 let mut claimed = Vec::new();
2507 let mut left = Vec::new();
2508 let mut used = vec![false; fixes.len()];
2509 for finding in blocking {
2510 match matching_disposition(finding, blocking, fixes) {
2511 Ok((index, fix)) if !used[index] => {
2512 used[index] = true;
2513 if fix.fixed {
2514 claimed.push(finding.clone());
2515 continue;
2516 }
2517 let mut open = finding.clone();
2518 let reason = fix.reason.trim();
2519 if !reason.is_empty() {
2520 open.detail = format!(
2521 "{}\n\nThe reviewer chose to fix this itself and did not: {reason}",
2522 open.detail.trim()
2523 );
2524 }
2525 logwarn!("'{}' was not fixed: {reason}", finding.title);
2526 left.push(open);
2527 }
2528 Ok(_) => {
2529 logwarn!(
2530 "'{}' has more than one matching entry in the fix report, so it stays open",
2531 finding.title
2532 );
2533 left.push(finding.clone());
2534 }
2535 Err(reason) => {
2536 logwarn!(
2537 "'{}' has {reason} in the fix report, so it stays open",
2538 finding.title
2539 );
2540 left.push(finding.clone());
2541 }
2542 }
2543 }
2544 (claimed, left)
2545}
2546
2547fn record_own_fixes(blocking: &[Finding], ledger: &mut Ledger, state: &mut IssueRun, round: u32) {
2554 for finding in blocking {
2555 let allow_stable_fallback = unique_stable_finding(blocking, finding);
2556 settle(
2557 ledger,
2558 &finding.title,
2559 &finding.file,
2560 allow_stable_fallback,
2561 LedgerEntry {
2562 title: finding.title.clone(),
2563 file: finding.file.clone(),
2564 reasoning: "a committed change was made for this point".to_string(),
2565 round,
2566 reraised: 0,
2567 outcome: Settled::Fixed,
2568 },
2569 );
2570 forget_noted(state, finding, allow_stable_fallback);
2571 forget_dispute(state, finding, allow_stable_fallback);
2572 }
2573}
2574
2575fn check_relitigation(ledger: &mut Ledger, blocking: &[Finding], state: &mut IssueRun) -> bool {
2578 let mut escalate = false;
2579 let mut counted: BTreeSet<String> = BTreeSet::new();
2585 for finding in blocking {
2586 let allow_stable_fallback = unique_stable_finding(blocking, finding);
2587 let Some(key) = matching_ledger_key_with_fallback(
2588 ledger,
2589 &finding.title,
2590 &finding.file,
2591 allow_stable_fallback,
2592 ) else {
2593 continue;
2594 };
2595 if !counted.insert(key.clone()) {
2596 continue;
2597 }
2598 if let Some(entry) = ledger.get_mut(&key) {
2599 entry.reraised += 1;
2600 if entry.outcome == Settled::Refuted {
2601 remember_dispute(
2602 state,
2603 Dispute {
2604 title: finding.title.clone(),
2605 file: finding.file.clone(),
2606 reasoning: entry.reasoning.clone(),
2607 },
2608 allow_stable_fallback,
2609 );
2610 }
2611 if entry.reraised >= 2 {
2612 state.notes.push(format!(
2613 "'{}' {}",
2614 finding.title,
2615 why_escalated(entry.outcome)
2616 ));
2617 escalate = true;
2618 }
2619 }
2620 }
2621 escalate
2622}
2623
2624fn why_escalated(outcome: Settled) -> &'static str {
2631 match outcome {
2632 Settled::Fixed => "was fixed twice and raised again; escalating.",
2633 _ => "was settled and re-raised twice; escalating.",
2634 }
2635}
2636
2637fn normalise(text: &str) -> String {
2638 crate::jsonx::untagged_title(text)
2639 .to_lowercase()
2640 .chars()
2641 .filter(|c| c.is_ascii_alphanumeric() || c.is_whitespace())
2642 .collect::<String>()
2643 .split_whitespace()
2644 .collect::<Vec<_>>()
2645 .join(" ")
2646}
2647
2648pub(crate) fn same_point(a: &str, b: &str) -> bool {
2650 normalise(a) == normalise(b)
2651}
2652
2653pub(crate) fn same_finding(a: &Finding, b: &Finding) -> bool {
2654 same_finding_parts(&a.title, &a.file, &b.title, &b.file)
2655}
2656
2657pub(crate) fn same_finding_parts(a_title: &str, a_file: &str, b_title: &str, b_file: &str) -> bool {
2658 finding_key(a_title, a_file) == finding_key(b_title, b_file)
2659}
2660
2661pub(crate) trait Answer {
2667 fn title(&self) -> &str;
2668 fn file(&self) -> &str;
2669}
2670
2671impl Answer for Disposition {
2672 fn title(&self) -> &str {
2673 &self.title
2674 }
2675 fn file(&self) -> &str {
2676 &self.file
2677 }
2678}
2679
2680impl Answer for OwnFix {
2681 fn title(&self) -> &str {
2682 &self.title
2683 }
2684 fn file(&self) -> &str {
2685 &self.file
2686 }
2687}
2688
2689fn answer_matches<A: Answer>(finding: &Finding, answer: &A) -> bool {
2690 same_point(&finding.title, answer.title()) && finding.file.trim() == answer.file().trim()
2691}
2692
2693fn stable_answer_matches<A: Answer>(finding: &Finding, answer: &A) -> bool {
2694 stable_finding_key(&finding.title, &finding.file)
2695 == stable_finding_key(answer.title(), answer.file())
2696}
2697
2698fn sole_answer_match<'a, A: Answer>(
2699 finding: &Finding,
2700 answers: &'a [A],
2701 matches: impl Fn(&Finding, &A) -> bool,
2702) -> std::result::Result<Option<(usize, &'a A)>, &'static str> {
2703 let mut matched = answers
2704 .iter()
2705 .enumerate()
2706 .filter(|(_, answer)| matches(finding, answer));
2707 let first = matched.next();
2708 if matched.next().is_some() {
2709 return Err("more than one matching disposition");
2710 }
2711 Ok(first)
2712}
2713
2714fn matching_disposition<'a, A: Answer>(
2719 finding: &Finding,
2720 findings: &[Finding],
2721 answers: &'a [A],
2722) -> std::result::Result<(usize, &'a A), &'static str> {
2723 if let Some(exact) = sole_answer_match(finding, answers, answer_matches)? {
2724 return Ok(exact);
2725 }
2726 if unique_stable_finding(findings, finding) {
2727 if let Some(stable) = sole_answer_match(finding, answers, stable_answer_matches)? {
2728 return Ok(stable);
2729 }
2730 }
2731 Err("no matching disposition")
2732}
2733
2734fn fixed_disposition_resolves(committed: bool) -> bool {
2735 committed
2736}
2737
2738#[allow(clippy::too_many_arguments)]
2739fn apply_dispositions(
2740 repo: &Repo,
2741 cfg: &Config,
2742 response: &ResponseDoc,
2743 blocking: &[Finding],
2744 ledger: &mut Ledger,
2745 state: &mut IssueRun,
2746 round: u32,
2747 subject: i64,
2748 pr_number: i64,
2749 author: &str,
2750 committed: bool,
2751) -> Vec<Finding> {
2752 let mut fixed = Vec::new();
2753 let mut refuted = Vec::new();
2754 let mut filed = Vec::new();
2755 let mut unresolved = Vec::new();
2756 let mut used = vec![false; response.dispositions.len()];
2757
2758 for source in blocking {
2759 let (index, d) = match matching_disposition(source, blocking, &response.dispositions) {
2760 Ok((index, disposition)) if !used[index] => (index, disposition),
2761 Ok(_) => {
2762 logwarn!(
2763 "'{}' has more than one matching disposition, so it stays open",
2764 source.title
2765 );
2766 unresolved.push(source.clone());
2767 continue;
2768 }
2769 Err(reason) => {
2770 logwarn!("'{}' has {reason}, so it stays open", source.title);
2771 unresolved.push(source.clone());
2772 continue;
2773 }
2774 };
2775 used[index] = true;
2776 let file = source.file.clone();
2777 let canonical = source.title.as_str();
2781 let title = style::title(canonical, &repo.style);
2782 let located_title = match file.trim() {
2783 "" => title.clone(),
2784 location => format!("{title} ({location})"),
2785 };
2786 let allow_stable_fallback = unique_stable_finding(blocking, source);
2787
2788 match d.action {
2789 Action::Refuted => {
2790 let reasoning = style::summary(&d.reasoning, &repo.style);
2791 settle(
2792 ledger,
2793 canonical,
2794 &file,
2795 allow_stable_fallback,
2796 LedgerEntry {
2797 title: canonical.to_string(),
2798 file: file.clone(),
2799 reasoning: reasoning.clone(),
2800 round,
2801 reraised: 0,
2802 outcome: Settled::Refuted,
2803 },
2804 );
2805 remember_dispute(
2806 state,
2807 Dispute {
2808 title: canonical.to_string(),
2809 file: file.clone(),
2810 reasoning: reasoning.clone(),
2811 },
2812 allow_stable_fallback,
2813 );
2814 forget_noted(state, source, allow_stable_fallback);
2815 refuted.push(format!("{located_title}. {reasoning}"));
2816 }
2817 Action::FiledIssue => {
2818 let new_title = d
2819 .new_issue_title
2820 .clone()
2821 .filter(|t| !t.trim().is_empty())
2822 .unwrap_or_else(|| d.title.clone());
2823 let new_body = d
2824 .new_issue_body
2825 .clone()
2826 .filter(|b| !b.trim().is_empty())
2827 .unwrap_or_else(|| d.reasoning.clone());
2828 let recorded = file_followup(repo, &new_title, &new_body, subject, cfg, state);
2829 if let Some(url) = recorded.url() {
2830 state.filed.push(url.to_string());
2831 filed.push(url.to_string());
2832 }
2833 let Some((outcome, reasoning)) =
2842 filed_entry(&recorded, &style::summary(&d.reasoning, &repo.style))
2843 else {
2844 logwarn!(
2845 "'{title}' was not recorded anywhere, so it stays open for the next round"
2846 );
2847 unresolved.push(source.clone());
2848 continue;
2849 };
2850 settle(
2851 ledger,
2852 canonical,
2853 &file,
2854 allow_stable_fallback,
2855 LedgerEntry {
2856 title: canonical.to_string(),
2857 file: file.clone(),
2858 reasoning,
2859 round,
2860 reraised: 0,
2861 outcome,
2862 },
2863 );
2864 if outcome == Settled::Dropped {
2865 remember_noted(state, source, allow_stable_fallback);
2866 } else {
2867 forget_noted(state, source, allow_stable_fallback);
2868 }
2869 forget_dispute(state, source, allow_stable_fallback);
2870 }
2871 Action::Fixed => {
2872 if fixed_disposition_resolves(committed) {
2887 settle(
2888 ledger,
2889 canonical,
2890 &file,
2891 allow_stable_fallback,
2892 LedgerEntry {
2893 title: canonical.to_string(),
2894 file: file.clone(),
2895 reasoning: style::summary(&d.reasoning, &repo.style),
2896 round,
2897 reraised: 0,
2898 outcome: Settled::Fixed,
2899 },
2900 );
2901 forget_noted(state, source, allow_stable_fallback);
2902 forget_dispute(state, source, allow_stable_fallback);
2903 fixed.push(located_title);
2904 } else {
2905 unresolved.push(source.clone());
2906 }
2907 }
2908 }
2909 }
2910
2911 for (index, disposition) in response.dispositions.iter().enumerate() {
2912 if !used[index] {
2913 logwarn!(
2914 "ignoring an unmatched or duplicate disposition for '{}' ({})",
2915 disposition.title,
2916 disposition.file
2917 );
2918 }
2919 }
2920
2921 if repo.style.pr_comments == PrComments::Rounds {
2922 let comment = disposition_comment(author, response, &fixed, &refuted, &filed, &repo.style);
2923 if let Some(text) = comment {
2924 if let Err(e) = repo.comment_pr(pr_number, &text) {
2925 logdim!("could not post the disposition comment: {e}");
2926 }
2927 }
2928 }
2929 unresolved
2930}
2931
2932fn filed_entry(recorded: &Followup, reasoning: &str) -> Option<(Settled, String)> {
2940 let (outcome, tail) = match recorded {
2941 Followup::Recorded(reference) => (
2942 Settled::Filed,
2943 format!("Tracked in {}.", as_reference(reference)),
2944 ),
2945 Followup::Covered(reference) => (
2946 Settled::Filed,
2947 format!("Already covered by {}.", as_reference(reference)),
2948 ),
2949 Followup::Dropped(why) => (Settled::Dropped, format!("Not filed anywhere: {why}.")),
2950 Followup::Failed => return None,
2951 };
2952 let reasoning = match reasoning.trim() {
2953 "" => tail,
2954 said => format!("{said} {tail}"),
2955 };
2956 Some((outcome, reasoning))
2957}
2958
2959fn external_followup_write_paused(destination: Followups, state: &IssueRun) -> bool {
2966 destination == Followups::Issues && state.followup_writes_uncertain
2967}
2968
2969fn failed_followup(state: &mut IssueRun, error: &SparError) -> Followup {
2970 if error.kind() == ErrorKind::UncertainWrite {
2971 state.followup_writes_uncertain = true;
2972 if !state
2973 .notes
2974 .iter()
2975 .any(|note| note.contains("external follow-up writes were paused"))
2976 {
2977 state.notes.push(
2978 "An external follow-up write could not be verified, so further external \
2979 follow-up writes were paused for this run. Inspect recent issues and comments \
2980 before trying them again."
2981 .to_string(),
2982 );
2983 }
2984 }
2985 Followup::Failed
2986}
2987
2988pub fn file_followup(
2999 repo: &Repo,
3000 title: &str,
3001 body: &str,
3002 source: i64,
3003 cfg: &Config,
3004 state: &mut IssueRun,
3005) -> Followup {
3006 if repo.followups == Followups::None {
3007 return Followup::Dropped("follow-ups are off for this repository");
3008 }
3009 if external_followup_write_paused(repo.followups, state) {
3010 logdim!(
3011 "not attempting another external follow-up write after an earlier result could not \
3012 be verified"
3013 );
3014 return Followup::Failed;
3015 }
3016 if state.filed.len() >= cfg.loop_cfg.max_followups {
3019 logwarn!(
3020 "already recorded {} follow-ups, not recording '{}'. Raise max_followups if you want \
3021 them all.",
3022 state.filed.len(),
3023 style::title(title, &repo.style)
3024 );
3025 return Followup::Dropped("this run had already recorded as many follow-ups as it may");
3026 }
3027 let title = match repo.clean_followup_title(title) {
3034 Ok(title) => title,
3035 Err(e) => {
3036 logdim!("could not clean a follow-up title: {e}");
3037 return Followup::Failed;
3038 }
3039 };
3040 if title.trim().is_empty() {
3041 logdim!("nothing left of a follow-up title after cleaning it");
3042 return Followup::Failed;
3043 }
3044 let body = format!(
3047 "{}\n\nFound while working on #{source}.",
3048 style::issue_body(body, &repo.style)
3049 );
3050
3051 if repo.followups == Followups::Local {
3052 return repo.append_local_followup(&title, &body);
3053 }
3054
3055 match file_as_issue(repo, &title, &body) {
3056 Ok(filed) => filed.into(),
3057 Err(e) => {
3058 logdim!("could not file a follow-up for '{title}': {e}");
3059 failed_followup(state, &e)
3060 }
3061 }
3062}
3063
3064#[derive(Debug, Clone)]
3066pub enum Filed {
3067 Opened(i64, String),
3069 AddedTo(i64, String),
3071 Covered(i64, String),
3073}
3074
3075impl From<Filed> for Followup {
3076 fn from(filed: Filed) -> Self {
3077 match filed {
3078 Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => {
3079 Followup::Recorded(url)
3080 }
3081 }
3082 }
3083}
3084
3085impl Filed {
3086 pub fn url(&self) -> Option<&str> {
3087 match self {
3088 Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => Some(url),
3089 }
3090 }
3091
3092 pub fn issue(&self) -> i64 {
3095 match self {
3096 Filed::Opened(n, _) | Filed::AddedTo(n, _) | Filed::Covered(n, _) => *n,
3097 }
3098 }
3099
3100 pub fn number(&self) -> Option<i64> {
3102 match self {
3103 Filed::Opened(n, _) | Filed::AddedTo(n, _) | Filed::Covered(n, _) => Some(*n),
3104 }
3105 }
3106
3107 pub fn note(&self) -> String {
3109 match self {
3110 Filed::Opened(n, _) => format!("#{n}"),
3111 Filed::AddedTo(n, _) => format!("added to #{n}"),
3112 Filed::Covered(n, _) => format!("#{n} already says this"),
3113 }
3114 }
3115
3116 pub fn describe(&self, title: &str) -> String {
3117 let title = style::clip(title.trim(), 80);
3118 match self {
3119 Filed::Opened(n, _) => format!("filed #{n}: {title}"),
3120 Filed::AddedTo(n, _) => format!("added to #{n}: {title}"),
3121 Filed::Covered(n, _) => format!("#{n} already says this: {title}"),
3122 }
3123 }
3124}
3125
3126pub fn file_as_issue(repo: &Repo, title: &str, body: &str) -> Result<Filed> {
3138 file_as_issue_apart_from(repo, title, body, None)
3139}
3140
3141pub fn file_as_issue_apart_from(
3147 repo: &Repo,
3148 title: &str,
3149 body: &str,
3150 apart_from: Option<i64>,
3151) -> Result<Filed> {
3152 let title = repo.record_failed_write(repo.clean_title(title))?;
3153 if title.trim().is_empty() {
3154 return repo.record_failed_write(Err(spar_err!(
3155 "nothing left of the title after cleaning it"
3156 )));
3157 }
3158 let issue_body = repo.record_failed_write(repo.clean_issue_body(body))?;
3159 let exact =
3160 repo.record_failed_write(repo.try_exact_issue_apart_from(&title, &issue_body, apart_from))?;
3161 let mut body_to_file = issue_body.clone();
3162 if let Some(existing) = exact {
3163 if existing.open {
3164 logdim!("'{title}' is already open as #{}", existing.number);
3165 return Ok(Filed::Covered(existing.number, existing.url));
3166 }
3167 logdim!(
3173 "'{title}' matches #{}, which is closed, so it is being filed again",
3174 existing.number
3175 );
3176 body_to_file = regression_body(&issue_body, existing.number);
3177 } else {
3178 let similar = repo.record_failed_write(repo.try_find_similar_issue_apart_from(
3179 &title,
3180 &issue_body,
3181 apart_from,
3182 ))?;
3183 if let Some(existing) = similar {
3184 let known = format!("{} {}", existing.title, existing.body);
3185 if crate::textsim::adds_information(&issue_body, &known) {
3186 logdim!(
3187 "'{title}' is covered by #{} and adds something, so it goes there as a \
3188 comment",
3189 existing.number
3190 );
3191 repo.comment_issue(existing.number, &issue_body)?;
3192 return Ok(Filed::AddedTo(existing.number, existing.url));
3193 }
3194 logdim!(
3195 "'{title}' is covered by #{} '{}', which already says it",
3196 existing.number,
3197 style::clip(&existing.title, 60)
3198 );
3199 return Ok(Filed::Covered(existing.number, existing.url));
3200 }
3201 }
3202 let url = repo.create_issue_apart_from(&title, &body_to_file, apart_from)?;
3203 let number = filed_issue_number(&url)
3204 .ok_or_else(|| spar_err!("filed an issue but could not read its number from {url}"))?;
3205 Ok(Filed::Opened(number, url))
3206}
3207
3208pub(crate) fn regression_body(body: &str, closed: i64) -> String {
3213 format!(
3214 "{}\n\nThis was filed before as #{closed}, which is closed. The evidence above is from \
3215 a later run, so it looks like it has come back.",
3216 body.trim()
3217 )
3218}
3219
3220fn file_out_of_scope(
3221 repo: &Repo,
3222 findings: &[Finding],
3223 subject: i64,
3224 state: &mut IssueRun,
3225 cfg: &Config,
3226) {
3227 for finding in findings.iter().filter(|f| !f.in_scope) {
3228 let body = issue_report(finding);
3229 let recorded = file_followup(repo, &finding.title, &body, subject, cfg, state);
3230 if finding.severity != Severity::Nit || matches!(recorded, Followup::Recorded(_)) {
3231 record_nonblocking_outcome_with_match(
3232 state,
3233 finding,
3234 Some(&recorded),
3235 unique_stable_finding(findings, finding),
3236 );
3237 }
3238 }
3239}
3240
3241pub fn issue_report(finding: &Finding) -> String {
3248 let sections = finding.report_sections();
3249 if sections.is_empty() {
3250 return finding.detail.clone();
3251 }
3252 let mut out: Vec<String> = sections
3253 .iter()
3254 .map(|(heading, text)| format!("## {heading}\n\n{text}"))
3255 .collect();
3256 if !finding.detail.trim().is_empty()
3259 && !sections
3260 .iter()
3261 .any(|(_, text)| crate::textsim::same_point(text, &finding.detail))
3262 {
3263 out.insert(0, finding.detail.trim().to_string());
3264 }
3265 out.join("\n\n")
3266}
3267
3268fn file_nonblocking(
3275 repo: &Repo,
3276 findings: &[Finding],
3277 subject: i64,
3278 state: &mut IssueRun,
3279 cfg: &Config,
3280) {
3281 for finding in findings {
3282 if !finding.in_scope || finding.severity == Severity::Blocking {
3283 continue;
3284 }
3285 let should_file = match finding.severity {
3286 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking,
3287 Severity::Nit => cfg.loop_cfg.file_nits,
3288 Severity::Blocking => false,
3289 };
3290 if !should_file {
3291 if finding.severity == Severity::NonBlocking {
3292 record_nonblocking_outcome_with_match(
3293 state,
3294 finding,
3295 None,
3296 unique_stable_finding(findings, finding),
3297 );
3298 }
3299 continue;
3300 }
3301 let recorded = file_followup(repo, &finding.title, &finding.detail, subject, cfg, state);
3302 if finding.severity == Severity::NonBlocking {
3303 record_nonblocking_outcome_with_match(
3304 state,
3305 finding,
3306 Some(&recorded),
3307 unique_stable_finding(findings, finding),
3308 );
3309 } else if let Some(url) = recorded.url() {
3310 state.filed.push(url.to_string());
3311 }
3312 }
3313}
3314
3315fn bullets(lines: &[String]) -> String {
3325 lines
3326 .iter()
3327 .map(|l| format!("- {l}"))
3328 .collect::<Vec<_>>()
3329 .join("\n")
3330}
3331
3332fn located(finding: &Finding, style: &Style) -> String {
3333 let title = style::title(&finding.title, style);
3334 match finding.where_at() {
3335 "general" => title,
3336 file => format!("{title} ({file})"),
3337 }
3338}
3339
3340pub enum Ending<'a> {
3342 Approved,
3344 OutOfRounds,
3347 Unchanged,
3350 Unresolved(&'a [Finding]),
3353 Deadlocked(&'a [Finding]),
3356}
3357
3358#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3359enum OutcomeSink {
3360 PullRequest,
3361 Terminal,
3362}
3363
3364fn outcome_sink(mode: PrComments) -> OutcomeSink {
3365 match mode {
3366 PrComments::Outcome | PrComments::Rounds => OutcomeSink::PullRequest,
3367 PrComments::None => OutcomeSink::Terminal,
3368 }
3369}
3370
3371fn emit_outcome(repo: &Repo, pr_number: i64, text: &str) {
3372 if outcome_sink(repo.style.pr_comments) == OutcomeSink::Terminal {
3373 println!("\n{text}\n");
3374 return;
3375 }
3376 if let Err(e) = repo.comment_pr(pr_number, text) {
3377 logdim!("could not post the outcome comment: {e}");
3378 println!("\n{text}\n");
3379 }
3380}
3381
3382pub fn post_outcome(
3394 repo: &Repo,
3395 pr_number: i64,
3396 state: &IssueRun,
3397 ledger: &Ledger,
3398 ending: Ending<'_>,
3399) {
3400 let Some(text) = outcome_comment(state, ledger, &ending, &repo.style) else {
3401 return;
3402 };
3403 emit_outcome(repo, pr_number, &text);
3404}
3405
3406fn post_unread_outcome(
3407 repo: &Repo,
3408 pr_number: i64,
3409 state: &IssueRun,
3410 ledger: &Ledger,
3411 open_findings: &[Finding],
3412) {
3413 let Some(text) = outcome_comment_with_unread(
3414 state,
3415 ledger,
3416 &Ending::OutOfRounds,
3417 open_findings,
3418 &repo.style,
3419 ) else {
3420 return;
3421 };
3422 emit_outcome(repo, pr_number, &text);
3423}
3424
3425fn settled_as(finding: &Finding, state: &IssueRun, ledger: &Ledger) -> Option<(Settled, String)> {
3428 if let Some(d) = state
3429 .disputes
3430 .iter()
3431 .find(|d| same_finding_parts(&d.title, &d.file, &finding.title, &finding.file))
3432 {
3433 if !d.reasoning.trim().is_empty() {
3434 return Some((Settled::Refuted, d.reasoning.clone()));
3435 }
3436 }
3437 matching_ledger_entry(ledger, &finding.title, &finding.file)
3438 .filter(|entry| !entry.reasoning.trim().is_empty())
3439 .map(|entry| (entry.outcome, entry.reasoning.clone()))
3440}
3441
3442pub fn filed_issue_number(filed: &str) -> Option<i64> {
3447 filed
3448 .rsplit('/')
3449 .next()
3450 .and_then(|tail| tail.parse::<i64>().ok())
3451 .filter(|n| *n > 0)
3452}
3453
3454fn as_reference(url: &str) -> String {
3455 match url.rsplit('/').next().and_then(|n| n.parse::<u64>().ok()) {
3456 Some(number) => format!("#{number}"),
3457 None => url.to_string(),
3458 }
3459}
3460
3461pub fn outcome_comment(
3462 state: &IssueRun,
3463 ledger: &Ledger,
3464 ending: &Ending<'_>,
3465 style: &Style,
3466) -> Option<String> {
3467 outcome_comment_with_unread(state, ledger, ending, &[], style)
3468}
3469
3470fn outcome_comment_with_unread(
3471 state: &IssueRun,
3472 ledger: &Ledger,
3473 ending: &Ending<'_>,
3474 unread_open: &[Finding],
3475 style: &Style,
3476) -> Option<String> {
3477 let mut out: Vec<String> = Vec::new();
3478 let mut already: Vec<(String, String)> = Vec::new();
3481
3482 match ending {
3483 Ending::Approved => {
3484 if state.disputes.is_empty() && state.filed.is_empty() && state.noted.is_empty() {
3485 return None;
3491 }
3492 out.push("Reviewed, nothing blocking a merge.".into());
3493 }
3494 Ending::OutOfRounds => {
3495 out.push(
3496 "Not signed off: the last round of fixes was pushed but has not been reviewed."
3497 .into(),
3498 );
3499 if !unread_open.is_empty() {
3500 let lines: Vec<String> = unread_open
3501 .iter()
3502 .map(|finding| {
3503 already.push((finding.title.clone(), finding.file.clone()));
3504 format!(
3505 "{}. {}",
3506 located(finding, style),
3507 style::sentence(&finding.detail, style)
3508 )
3509 })
3510 .collect();
3511 out.push("These points were already open:".into());
3512 out.push(bullets(&lines));
3513 }
3514 }
3515 Ending::Unchanged => out.push(
3519 "Not signed off: the last round changed nothing, so the branch is the one that was \
3520 already reviewed."
3521 .into(),
3522 ),
3523 Ending::Unresolved(points) => {
3524 let lines: Vec<String> = points
3525 .iter()
3526 .map(|f| {
3527 already.push((f.title.clone(), f.file.clone()));
3528 format!(
3529 "{}. {}",
3530 located(f, style),
3531 style::sentence(&f.detail, style)
3532 )
3533 })
3534 .collect();
3535 out.push("Not signed off. These points are still open:".into());
3536 out.push(bullets(&lines));
3537 }
3538 Ending::Deadlocked(points) => {
3539 let lines: Vec<String> = points
3545 .iter()
3546 .map(|f| {
3547 let where_at = match f.where_at() {
3548 "general" => String::new(),
3549 file => format!(" ({file})"),
3550 };
3551 let title = style::title(&f.title, style);
3552 already.push((f.title.clone(), f.file.clone()));
3553 match settled_as(f, state, ledger) {
3554 Some((Settled::Refuted, reason)) => format!(
3555 "{title}{where_at}. Refuted as: {}",
3556 style::summary(&reason, style)
3557 ),
3558 Some((Settled::Filed, reason)) => format!(
3559 "{title}{where_at}. Filed as out of scope: {}",
3560 style::summary(&reason, style)
3561 ),
3562 Some((Settled::Dropped, reason)) => format!(
3565 "{title}{where_at}. Out of scope here, and not filed: {}",
3566 style::summary(&reason, style)
3567 ),
3568 Some((Settled::Fixed, reason)) => format!(
3574 "{title}{where_at}. Fixed and raised again. Recorded answer: {}",
3575 style::summary(&reason, style)
3576 ),
3577 None => format!("{title}{where_at}"),
3578 }
3579 })
3580 .collect();
3581 out.push("Needs your decision. The reviewers could not settle this:".into());
3582 out.push(bullets(&lines));
3583 }
3584 }
3585
3586 let disputes: Vec<&crate::model::Dispute> = state
3587 .disputes
3588 .iter()
3589 .filter(|d| {
3590 !already
3591 .iter()
3592 .any(|(title, file)| same_finding_parts(title, file, &d.title, &d.file))
3593 })
3594 .collect();
3595 if !disputes.is_empty() {
3596 let lines: Vec<String> = disputes
3599 .iter()
3600 .map(|d| {
3601 let title = style::title(&d.title, style);
3602 let title = if d.file.trim().is_empty() {
3603 title
3604 } else {
3605 format!("{title} ({})", d.file.trim())
3606 };
3607 format!("{}. {}", title, style::sentence(&d.reasoning, style))
3608 })
3609 .collect();
3610 out.push(format!("Raised and refuted:\n{}", bullets(&lines)));
3611 }
3612
3613 if !state.noted.is_empty() {
3614 let lines: Vec<String> = state
3618 .noted
3619 .iter()
3620 .filter(|f| {
3621 !already
3622 .iter()
3623 .any(|(title, file)| same_finding_parts(title, file, &f.title, &f.file))
3624 })
3625 .map(|f| located(f, style))
3626 .collect();
3627 if !lines.is_empty() {
3628 out.push(format!("Noted, not blocking:\n{}", bullets(&lines)));
3629 }
3630 }
3631
3632 if !state.filed.is_empty() {
3633 let refs: Vec<String> = state.filed.iter().map(|u| as_reference(u)).collect();
3634 out.push(format!("Filed separately: {}", refs.join(", ")));
3635 }
3636
3637 Some(out.join("\n\n"))
3638}
3639
3640#[allow(clippy::too_many_arguments)]
3649fn close_prompt(
3650 base: &str,
3651 number: i64,
3652 title: &str,
3653 context: &str,
3654 from: &str,
3655 landed: Option<&[String]>,
3656 ledger: &Ledger,
3657 open_findings: &[Finding],
3658 round: u32,
3659) -> String {
3660 let landed = match landed {
3661 Some([]) => "\nNothing landed after the last round of review. What it asked for was \
3662 answered in words rather than in code, so the branch in front of you is the \
3663 branch that was already read.\n"
3664 .to_string(),
3665 Some(lines) => format!(
3666 "\nThis landed after the last round of review, and nobody has read it:\n{}\n\nRead it \
3667 first with `git diff {from}..HEAD`, then inspect the full branch with `git diff \
3668 {base}...HEAD`.\n",
3669 lines
3670 .iter()
3671 .map(|l| format!("- {l}"))
3672 .collect::<Vec<_>>()
3673 .join("\n")
3674 ),
3675 None => format!(
3676 "\nThe commits on this branch were rewritten after the last round of review, so the \
3677 harness cannot say which of them are new. Inspect the full branch with `git diff \
3678 {base}...HEAD`.\n"
3679 ),
3680 };
3681 CLOSE_PROMPT
3682 .replace("{number}", &number.to_string())
3683 .replace("{title}", title)
3684 .replace("{context}", context)
3685 .replace("{base}", base)
3686 .replace("{landed}", &landed)
3687 .replace("{open}", &open_findings_block(open_findings))
3688 .replace("{answers}", &closing_answers(ledger, round))
3689 .replace("{settled}", &settled_block(ledger))
3690}
3691
3692fn open_findings_block(findings: &[Finding]) -> String {
3693 if findings.is_empty() {
3694 return String::new();
3695 }
3696 format!(
3697 "\nThese blocking findings were left open by an earlier response. Recheck each one:\n{}\n\
3698 \nIf one still blocks, return it under the same title and file. Omission means you checked \
3699 it and found that it no longer blocks.\n",
3700 findings_for_prompt(findings)
3701 )
3702}
3703
3704fn closing_answers(ledger: &Ledger, round: u32) -> String {
3709 let lines = fixed_lines(ledger, round);
3710 if lines.is_empty() {
3711 return String::new();
3712 }
3713 format!(
3714 "\nThese points were raised on this pull request and the author says it fixed them. \
3715 Nobody has checked that:\n{}\n",
3716 lines.join("\n")
3717 )
3718}
3719
3720#[allow(clippy::too_many_arguments)]
3725fn review_prompt(
3726 base: &str,
3727 number: i64,
3728 title: &str,
3729 context: &str,
3730 ledger: &Ledger,
3731 open_findings: &[Finding],
3732 round: u32,
3733 last: u32,
3734) -> String {
3735 REVIEW_PROMPT
3736 .replace("{base}", base)
3737 .replace("{number}", &number.to_string())
3738 .replace("{title}", title)
3739 .replace("{context}", context)
3740 .replace("{open}", &open_findings_block(open_findings))
3741 .replace("{answers}", &answers_block(ledger, round))
3742 .replace("{settled}", &settled_block(ledger))
3743 .replace("{round}", &round_note(round, last))
3744}
3745
3746fn implement_prompt(number: i64, title: &str, url: &str, body: &str) -> String {
3754 IMPLEMENT_PROMPT
3755 .replace("{number}", &number.to_string())
3756 .replace("{title}", title)
3757 .replace("{url}", url)
3758 .replace("{body}", body)
3759}
3760
3761pub fn pr_body(issue: i64, work: &Implementation, checked: Option<&str>, style: &Style) -> String {
3771 let mut parts = vec![format!("Closes #{issue}")];
3772
3773 for lead in [&work.summary, &work.problem] {
3774 let text = style::sentence(lead, style);
3775 if !text.is_empty() {
3776 parts.push(text);
3777 }
3778 }
3779 parts.extend(section("What changed", &work.changes, style));
3780 let mut testing = work.testing.clone();
3783 if let Some(command) = checked {
3784 testing.push(format!("`{command}` passed on this branch, run by spar."));
3785 }
3786 parts.extend(section("How to test", &testing, style));
3787
3788 let notes = style::sentence(work.notes.as_deref().unwrap_or_default(), style);
3789 if !notes.is_empty() {
3790 parts.push(format!("## Notes\n\n{notes}"));
3791 }
3792
3793 style::body(&parts.join("\n\n"), style)
3794}
3795
3796fn section(heading: &str, lines: &[String], style: &Style) -> Option<String> {
3803 let items: Vec<String> = lines
3804 .iter()
3805 .map(|line| style::summary(line, style))
3806 .filter(|line| !line.is_empty())
3807 .collect();
3808 if items.is_empty() {
3809 return None;
3810 }
3811 Some(format!("## {heading}\n\n{}", bullets(&items)))
3812}
3813
3814pub fn from_commits(repo: &Repo, work_dir: &Path, base: &str) -> Implementation {
3822 Implementation {
3823 changes: repo.commit_subjects(work_dir, "HEAD", base),
3824 notes: Some(
3825 "The implement call failed after these commits were made, so this body is assembled \
3826 from their messages rather than written by their author. Read the diff."
3827 .to_string(),
3828 ),
3829 ..Implementation::default()
3830 }
3831}
3832
3833fn no_pr_note(work: &Implementation, style: &Style) -> String {
3840 let reason = style::sentence(&work.reason, style);
3841 if !reason.is_empty() {
3842 return reason;
3843 }
3844 if work.not_worth_doing {
3845 "Left alone after reading the code, with no reason given.".to_string()
3846 } else {
3847 "Nothing was committed, so there is nothing to review.".to_string()
3848 }
3849}
3850
3851pub fn review_comment(holder: &str, round: u32, review: &Review, style: &Style) -> String {
3855 let by = |severity: Severity| -> Vec<&Finding> {
3856 review
3857 .findings
3858 .iter()
3859 .filter(|f| f.severity == severity && f.in_scope)
3860 .collect()
3861 };
3862 let blocking = by(Severity::Blocking);
3863 let non_blocking = by(Severity::NonBlocking);
3864 let nits = by(Severity::Nit);
3865 let out_of_scope: Vec<&Finding> = review.findings.iter().filter(|f| !f.in_scope).collect();
3866
3867 let mut counts = Vec::new();
3868 if !blocking.is_empty() {
3869 counts.push(format!("{} blocking", blocking.len()));
3870 }
3871 if !non_blocking.is_empty() {
3872 counts.push(format!("{} non-blocking", non_blocking.len()));
3873 }
3874 if !nits.is_empty() {
3875 counts.push(format!("{} nit", nits.len()));
3876 }
3877 if !out_of_scope.is_empty() {
3878 counts.push(format!("{} out of scope", out_of_scope.len()));
3879 }
3880 let headline = if counts.is_empty() {
3881 "no findings".to_string()
3882 } else {
3883 counts.join(", ")
3884 };
3885
3886 let _ = (holder, round, headline);
3887 let mut out = Vec::new();
3888 let summary = style::summary(&review.summary, style);
3889 if !summary.is_empty() {
3890 out.push(summary);
3891 }
3892
3893 if !blocking.is_empty() {
3894 let lines: Vec<String> = blocking
3895 .iter()
3896 .map(|f| {
3897 let detail = style::detail(&f.detail, style);
3898 if detail.is_empty() {
3899 located(f, style)
3900 } else {
3901 format!("{}. {detail}", located(f, style))
3902 }
3903 })
3904 .collect();
3905 out.push(format!("blocking\n{}", bullets(&lines)));
3906 }
3907
3908 for (label, group) in [
3911 ("non-blocking", &non_blocking),
3912 ("nits", &nits),
3913 ("out of scope", &out_of_scope),
3914 ] {
3915 if group.is_empty() {
3916 continue;
3917 }
3918 let lines: Vec<String> = group.iter().map(|f| located(f, style)).collect();
3919 out.push(format!("{label}\n{}", bullets(&lines)));
3920 }
3921
3922 out.join("\n\n")
3923}
3924
3925pub fn disposition_comment(
3929 author: &str,
3930 response: &ResponseDoc,
3931 fixed: &[String],
3932 refuted: &[String],
3933 filed: &[String],
3934 style: &Style,
3935) -> Option<String> {
3936 if fixed.is_empty() && refuted.is_empty() && filed.is_empty() {
3937 return None;
3938 }
3939 let mut counts = Vec::new();
3940 if !fixed.is_empty() {
3941 counts.push(format!("{} fixed", fixed.len()));
3942 }
3943 if !refuted.is_empty() {
3944 counts.push(format!("{} refuted", refuted.len()));
3945 }
3946 if !filed.is_empty() {
3947 counts.push(format!("{} filed", filed.len()));
3948 }
3949
3950 let _ = (author, counts);
3951 let mut out = Vec::new();
3952 let summary = style::summary(&response.summary, style);
3953 if !summary.is_empty() {
3954 out.push(summary);
3955 }
3956 if !refuted.is_empty() {
3957 out.push(format!("refuted\n{}", bullets(refuted)));
3958 }
3959 if !fixed.is_empty() {
3960 out.push(format!("fixed\n{}", bullets(fixed)));
3961 }
3962 if !filed.is_empty() {
3963 out.push(format!("filed\n{}", bullets(filed)));
3964 }
3965 Some(out.join("\n\n"))
3966}
3967
3968pub fn skip_comment(item: &SkippedItem, style: &Style) -> String {
3976 let reasons = item
3977 .reasons
3978 .values()
3979 .map(|reason| style::sentence(reason, style));
3980 let lines = crate::textsim::dedupe_by(reasons, crate::textsim::same_reason);
3984 bullets(&lines)
3985}
3986
3987pub(crate) fn context_block(number: i64, url: &str, issue_body: &str, pr_body: &str) -> String {
4001 let mut out = String::new();
4002 let body = issue_body.trim();
4003 if !body.is_empty() {
4004 out.push_str(&format!("\nIssue #{number} as filed"));
4005 if !url.trim().is_empty() {
4006 out.push_str(&format!(", at {}", url.trim()));
4007 }
4008 out.push_str(&format!(
4009 ":\n\n{body}\n\nThat is the issue body as filed. The discussion since is not \
4010 included.\n"
4011 ));
4012 }
4013 let claim = pr_body.trim();
4014 if !claim.is_empty() {
4015 out.push_str(&format!(
4016 "\nThe pull request description, which is the author's own account of this change. \
4017 It is a claim to check against the code, not a statement of fact:\n\n{claim}\n"
4018 ));
4019 }
4020 out
4021}
4022
4023pub(crate) fn optional_findings(findings: &[Finding]) -> Vec<Finding> {
4025 findings
4026 .iter()
4027 .filter(|f| f.in_scope && f.severity == Severity::NonBlocking)
4028 .cloned()
4029 .collect()
4030}
4031
4032fn optional_block(findings: &[Finding]) -> String {
4033 let optional = optional_findings(findings);
4034 if optional.is_empty() {
4035 return String::new();
4036 }
4037 format!("\n{OPTIONAL_HEADER}{}\n", findings_for_prompt(&optional))
4038}
4039
4040fn apply_optional_dispositions(
4047 repo: &Repo,
4048 cfg: &Config,
4049 response: &ResponseDoc,
4050 findings: &[Finding],
4051 state: &mut IssueRun,
4052 subject: i64,
4053) {
4054 let optional = optional_findings(findings);
4055 if optional.is_empty() {
4056 return;
4057 }
4058 let mut fixed = 0usize;
4059 for finding in &optional {
4060 let Ok((_, disposition)) = matching_disposition(finding, &optional, &response.dispositions)
4061 else {
4062 continue;
4063 };
4064 let unique = unique_stable_finding(&optional, finding);
4065 match disposition.action {
4066 Action::Fixed => {
4067 fixed += 1;
4068 forget_noted(state, finding, unique);
4069 }
4070 Action::FiledIssue => {
4071 let title = disposition
4072 .new_issue_title
4073 .clone()
4074 .filter(|t| !t.trim().is_empty())
4075 .unwrap_or_else(|| finding.title.clone());
4076 let body = disposition
4077 .new_issue_body
4078 .clone()
4079 .filter(|b| !b.trim().is_empty())
4080 .unwrap_or_else(|| finding.detail.clone());
4081 let recorded = file_followup(repo, &title, &body, subject, cfg, state);
4082 record_nonblocking_outcome_with_match(state, finding, Some(&recorded), unique);
4083 }
4084 Action::Refuted => {}
4085 }
4086 }
4087 if fixed > 0 {
4088 logdim!("the author also fixed {fixed} non-blocking finding(s)");
4089 }
4090}
4091
4092pub(crate) fn findings_for_prompt(findings: &[Finding]) -> String {
4095 if findings.is_empty() {
4096 return "(none)".to_string();
4097 }
4098 findings
4099 .iter()
4100 .map(|f| {
4101 let scope = if f.in_scope { "" } else { " [out of scope]" };
4102 format!(
4103 "- [{}]{scope} {} ({})\n {}",
4104 f.severity,
4105 f.title,
4106 f.where_at(),
4107 f.detail
4108 )
4109 })
4110 .collect::<Vec<_>>()
4111 .join("\n")
4112}
4113
4114#[cfg(test)]
4115mod tests {
4116 use super::*;
4117 use crate::model::Verdict;
4118
4119 fn style() -> Style {
4120 Style::default()
4121 }
4122
4123 fn finding(severity: &str, title: &str, detail: &str, file: &str, in_scope: bool) -> Finding {
4124 Finding {
4125 severity: Severity::parse_lenient(severity).unwrap(),
4126 title: title.into(),
4127 detail: detail.into(),
4128 file: file.into(),
4129 in_scope,
4130 ..Default::default()
4131 }
4132 }
4133
4134 fn review(summary: &str, findings: Vec<Finding>) -> Review {
4135 Review {
4136 verdict: Verdict::Approve,
4137 next_action: NextAction::Merge,
4138 summary: summary.into(),
4139 findings,
4140 }
4141 }
4142
4143 fn disposition(title: &str, file: &str, action: Action) -> Disposition {
4144 Disposition {
4145 title: title.into(),
4146 file: file.into(),
4147 action,
4148 reasoning: "because".into(),
4149 new_issue_title: None,
4150 new_issue_body: None,
4151 }
4152 }
4153
4154 fn cfg_with(worktrees: bool, keep: bool) -> Config {
4157 let text = "[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n";
4158 let mut cfg = crate::config::parse(text).unwrap();
4159 cfg.loop_cfg.worktrees = worktrees;
4160 cfg.loop_cfg.keep_worktrees = keep;
4161 cfg
4162 }
4163
4164 #[test]
4165 fn a_worktree_is_released_on_every_finished_outcome() {
4166 let cfg = cfg_with(true, false);
4167 for status in [Status::Approved, Status::Merged, Status::Abandoned] {
4168 assert!(should_release(&cfg, status), "{status}");
4169 }
4170 }
4171
4172 #[test]
4175 fn a_worktree_is_kept_only_where_a_human_has_to_look() {
4176 let cfg = cfg_with(true, false);
4177 assert!(!should_release(&cfg, Status::Escalated));
4178 assert!(!should_release(&cfg, Status::Error));
4179 }
4180
4181 #[test]
4182 fn an_uncommitted_implementation_names_its_diagnostic_and_recovery_path() {
4183 let path = Path::new("/tmp/issue worktree");
4184 let err =
4185 uncommitted_implementation_error(path, Some("git add could not create index.lock"))
4186 .to_string();
4187 assert!(err.contains("could not create index.lock"), "{err}");
4188 assert!(err.contains("/tmp/issue worktree"), "{err}");
4189 assert!(err.contains("Commit or recover"), "{err}");
4190 assert!(!should_release(&cfg_with(true, false), Status::Error));
4191 }
4192
4193 #[test]
4194 fn the_keep_flag_overrides_everything() {
4195 assert!(!should_release(&cfg_with(true, true), Status::Approved));
4196 }
4197
4198 #[test]
4199 fn nothing_is_released_when_worktrees_are_off() {
4200 assert!(!should_release(&cfg_with(false, false), Status::Approved));
4201 }
4202
4203 #[test]
4208 fn fixing_your_own_findings_hands_the_pr_over() {
4209 let cfg = cfg_with(true, false);
4210 assert_eq!("a", next_reviewer(&cfg, "b", Some("b")));
4211 assert_eq!("b", next_reviewer(&cfg, "a", Some("a")));
4212 }
4213
4214 #[test]
4218 fn handing_back_keeps_the_reviewer_for_the_next_round() {
4219 let cfg = cfg_with(true, false);
4220 assert_eq!("b", next_reviewer(&cfg, "b", Some("a")));
4221 assert_eq!("a", next_reviewer(&cfg, "a", Some("b")));
4222 }
4223
4224 #[test]
4227 fn nobody_reviews_their_own_edit() {
4228 let cfg = cfg_with(true, false);
4229 let round_1 = cfg.other(&cfg.first_implementor);
4230 assert_eq!("b", round_1);
4231 for editor in ["a", "b"] {
4232 assert_ne!(editor, next_reviewer(&cfg, &round_1, Some(editor)));
4233 }
4234 }
4235
4236 #[test]
4241 fn a_fix_that_committed_nothing_leaves_the_pr_where_it_is() {
4242 let cfg = cfg_with(true, false);
4243 assert_eq!("b", next_reviewer(&cfg, "b", None));
4244 assert_eq!("a", next_reviewer(&cfg, "a", None));
4245 }
4246
4247 #[test]
4251 fn a_reviewer_that_wrote_the_head_gives_the_pr_up() {
4252 let cfg = cfg_with(true, false);
4253 assert_eq!("a", next_reviewer(&cfg, "b", Some("b")));
4254 }
4255
4256 #[test]
4260 fn a_review_that_wrote_cannot_approve_what_is_left() {
4261 assert!(!approval_stands(&[], true));
4262 }
4263
4264 #[test]
4265 fn a_clean_review_of_an_untouched_branch_approves() {
4266 assert!(approval_stands(&[], false));
4267 }
4268
4269 #[test]
4270 fn a_blocking_finding_never_approves() {
4271 let blocking = vec![finding("blocking", "Broken", "detail", "src/x.rs", true)];
4272 assert!(!approval_stands(&blocking, false));
4273 }
4274
4275 #[test]
4276 fn approval_refuses_a_head_that_changed_after_review() {
4277 assert!(ensure_reviewed_head(36, "abc123", "abc123").is_ok());
4278 let error = ensure_reviewed_head(36, "abc123", "def456").unwrap_err();
4279 assert!(error.to_string().contains("unread head"));
4280 }
4281
4282 #[test]
4284 fn only_a_moved_head_counts_as_a_commit() {
4285 let before = Snapshot {
4286 head: "abc".into(),
4287 dirty: false,
4288 };
4289 assert!(!Snapshot {
4290 head: "abc".into(),
4291 dirty: true,
4292 }
4293 .landed_over(&before));
4294 assert!(Snapshot {
4295 head: "def".into(),
4296 dirty: false,
4297 }
4298 .landed_over(&before));
4299 assert!(!Snapshot {
4301 head: String::new(),
4302 dirty: false,
4303 }
4304 .landed_over(&before));
4305 }
4306
4307 #[test]
4311 fn a_fresh_run_starts_at_one() {
4312 assert_eq!((1, 3), round_window(1, 3));
4313 assert_eq!((1, 5), round_window(1, 5));
4314 }
4315
4316 #[test]
4320 fn a_resumed_run_gets_a_full_fresh_budget() {
4321 assert_eq!((6, 10), round_window(6, 5));
4322 assert_eq!((11, 13), round_window(11, 3));
4323 }
4324
4325 #[test]
4326 fn a_budget_of_one_is_a_single_round() {
4327 assert_eq!((6, 6), round_window(6, 1));
4328 }
4329
4330 #[test]
4331 fn round_numbers_keep_counting_across_sessions() {
4332 let mut start = 1;
4334 let mut seen = Vec::new();
4335 for _ in 0..3 {
4336 let (first, last) = round_window(start, 3);
4337 seen.push((first, last));
4338 start = last + 1;
4339 }
4340 assert_eq!(vec![(1, 3), (4, 6), (7, 9)], seen);
4341 }
4342
4343 fn ledger_with(title: &str, file: &str) -> Ledger {
4346 let mut ledger = Ledger::new();
4347 ledger.insert(
4348 finding_key(title, file),
4349 LedgerEntry {
4350 title: title.into(),
4351 file: file.into(),
4352 reasoning: "no".into(),
4353 round: 1,
4354 reraised: 0,
4355 outcome: Settled::Refuted,
4356 },
4357 );
4358 ledger
4359 }
4360
4361 #[test]
4362 fn a_point_refuted_and_re_raised_twice_escalates() {
4363 let mut ledger = ledger_with("nit about naming", "a.rs");
4364 let mut state = IssueRun::new(1, "t");
4365 let blocking = vec![finding("blocking", "nit about naming", "d", "a.rs", true)];
4366 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
4367 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
4368 }
4369
4370 #[test]
4374 fn a_point_fixed_twice_and_raised_again_escalates() {
4375 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
4376 for entry in ledger.values_mut() {
4377 entry.outcome = Settled::Fixed;
4378 }
4379 let mut state = IssueRun::new(1, "t");
4380 let blocking = [finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
4381
4382 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
4383 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
4384 }
4385
4386 #[test]
4389 fn a_fix_that_missed_twice_is_not_reported_as_a_refutation() {
4390 assert!(why_escalated(Settled::Fixed).contains("fixed twice"));
4391 for outcome in [Settled::Refuted, Settled::Filed, Settled::Dropped] {
4392 assert!(why_escalated(outcome).contains("settled"), "{outcome}");
4393 }
4394 }
4395
4396 #[test]
4401 fn a_reviewer_that_fixes_its_own_findings_records_them_too() {
4402 let mut ledger = Ledger::new();
4403 let blocking = vec![finding(
4404 "blocking",
4405 "Unbounded loop",
4406 "spins",
4407 "src/x.rs",
4408 true,
4409 )];
4410 let mut state = IssueRun::new(1, "t");
4411
4412 record_own_fixes(&blocking, &mut ledger, &mut state, 1);
4413
4414 let entry = ledger
4415 .get(&finding_key("Unbounded loop", "src/x.rs"))
4416 .expect("keyed where the next round will look");
4417 assert_eq!(Settled::Fixed, entry.outcome);
4418 assert_eq!(
4419 "a committed change was made for this point",
4420 entry.reasoning
4421 );
4422
4423 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
4425 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
4426 }
4427
4428 #[test]
4437 fn a_review_that_contradicts_itself_says_so_and_the_findings_win() {
4438 let blocker = finding("blocking", "Retry loop spins", "spins", "src/net.rs", true);
4439 let approve_with_blocker = Review {
4440 verdict: Verdict::Approve,
4441 next_action: NextAction::HandBack,
4442 summary: "one blocker".into(),
4443 findings: vec![blocker],
4444 };
4445 let said = verdict_disagreement(&approve_with_blocker, 1).expect("noticed");
4446 assert!(said.contains("approve"), "{said}");
4447 assert!(said.contains("findings decide"), "{said}");
4448
4449 let changes_but_merge = Review {
4450 verdict: Verdict::ChangesRequested,
4451 next_action: NextAction::Merge,
4452 summary: "nothing blocking".into(),
4453 findings: vec![],
4454 };
4455 assert!(verdict_disagreement(&changes_but_merge, 0).is_some());
4456
4457 let clean = Review {
4459 verdict: Verdict::Approve,
4460 next_action: NextAction::Merge,
4461 summary: "good".into(),
4462 findings: vec![],
4463 };
4464 assert!(verdict_disagreement(&clean, 0).is_none());
4465 let blocked = Review {
4466 verdict: Verdict::ChangesRequested,
4467 next_action: NextAction::HandBack,
4468 summary: "one blocker".into(),
4469 findings: vec![],
4470 };
4471 assert!(verdict_disagreement(&blocked, 1).is_none());
4472 }
4473
4474 #[test]
4482 fn a_finding_the_fix_call_declined_stays_open() {
4483 let blocking = vec![
4484 finding("blocking", "Unbounded loop", "spins", "src/x.rs:88", true),
4485 finding("blocking", "Missing guard", "panics", "src/y.rs:12", true),
4486 ];
4487 let fixes = vec![
4488 OwnFix {
4489 title: "Unbounded loop".into(),
4490 file: "src/x.rs:88".into(),
4491 fixed: true,
4492 reason: "bounded it on max_attempts".into(),
4493 },
4494 OwnFix {
4495 title: "Missing guard".into(),
4496 file: "src/y.rs:12".into(),
4497 fixed: false,
4498 reason: "the guard belongs in the caller, which this branch does not touch".into(),
4499 },
4500 ];
4501
4502 let (claimed, left) = split_own_fixes(&blocking, &fixes);
4503
4504 assert_eq!(1, claimed.len());
4505 assert_eq!("Unbounded loop", claimed[0].title);
4506 assert_eq!(1, left.len());
4507 assert_eq!("Missing guard", left[0].title);
4508 assert!(
4509 left[0].detail.contains("belongs in the caller"),
4510 "the author is not told why it was left: {}",
4511 left[0].detail
4512 );
4513
4514 let mut ledger = Ledger::new();
4516 let mut state = IssueRun::new(1, "t");
4517 record_own_fixes(&claimed, &mut ledger, &mut state, 1);
4518 assert!(ledger.contains_key(&finding_key("Unbounded loop", "src/x.rs:88")));
4519 assert!(
4520 !ledger.contains_key(&finding_key("Missing guard", "src/y.rs:12")),
4521 "a point nobody fixed was recorded as fixed"
4522 );
4523 }
4524
4525 #[test]
4527 fn a_finding_the_fix_report_never_mentions_stays_open() {
4528 let blocking = vec![finding(
4529 "blocking",
4530 "Unbounded loop",
4531 "spins",
4532 "src/x.rs:88",
4533 true,
4534 )];
4535 let (claimed, left) = split_own_fixes(&blocking, &[]);
4536 assert!(claimed.is_empty());
4537 assert_eq!(1, left.len());
4538 }
4539
4540 #[test]
4544 fn a_fixed_point_is_not_in_the_settled_block() {
4545 let mut ledger = ledger_with("refuted point", "a.rs");
4546 ledger.extend(ledger_with("fixed point", "b.rs"));
4547 for entry in ledger.values_mut() {
4548 if entry.title == "fixed point" {
4549 entry.outcome = Settled::Fixed;
4550 }
4551 }
4552 let block = settled_block(&ledger);
4553 assert!(block.contains("refuted point"));
4554 assert!(!block.contains("fixed point"));
4555 }
4556
4557 #[test]
4560 fn a_ledger_of_only_fixes_says_nothing_is_settled() {
4561 let mut ledger = ledger_with("fixed point", "b.rs");
4562 for entry in ledger.values_mut() {
4563 entry.outcome = Settled::Fixed;
4564 }
4565 assert_eq!("", settled_block(&ledger));
4566 }
4567
4568 #[test]
4573 fn one_review_spends_one_re_raise_however_often_it_says_it() {
4574 let mut ledger = ledger_with("Missing error handling", "src/net.rs");
4575 let mut state = IssueRun::new(1, "t");
4576 let twice = vec![
4577 finding(
4578 "blocking",
4579 "Missing error handling",
4580 "d",
4581 "src/net.rs",
4582 true,
4583 ),
4584 finding(
4585 "blocking",
4586 "Missing error handling",
4587 "e",
4588 "src/net.rs",
4589 true,
4590 ),
4591 ];
4592
4593 assert!(!check_relitigation(&mut ledger, &twice, &mut state));
4594 assert_eq!(1, ledger.values().next().unwrap().reraised);
4595 assert!(check_relitigation(&mut ledger, &twice, &mut state));
4596 }
4597
4598 #[test]
4599 fn an_untracked_finding_does_not_escalate() {
4600 let mut state = IssueRun::new(1, "t");
4601 let blocking = vec![finding("blocking", "brand new", "d", "a.rs", true)];
4602 assert!(!check_relitigation(
4603 &mut Ledger::new(),
4604 &blocking,
4605 &mut state
4606 ));
4607 }
4608
4609 #[test]
4610 fn persisted_ledger_entries_are_rekeyed_for_stable_locations() {
4611 let mut ledger = Ledger::new();
4612 ledger.insert(
4613 "legacy-key".into(),
4614 LedgerEntry {
4615 title: "Unbounded loop".into(),
4616 file: "src/x.rs:88".into(),
4617 reasoning: "bounded by the caller".into(),
4618 round: 2,
4619 reraised: 1,
4620 outcome: Settled::Refuted,
4621 },
4622 );
4623 normalise_ledger_keys(&mut ledger);
4624 let key = matching_ledger_key(&ledger, "Unbounded loop", "src/x.rs:91").unwrap();
4625 assert_eq!(1, ledger[&key].reraised);
4626 }
4627
4628 #[test]
4629 fn same_title_at_two_sites_keeps_both_blockers() {
4630 let findings = vec![
4631 finding(
4632 "blocking",
4633 "Unchecked error",
4634 "first site",
4635 "src/net.rs:10",
4636 true,
4637 ),
4638 finding(
4639 "blocking",
4640 "Unchecked error",
4641 "second site",
4642 "src/net.rs:200",
4643 true,
4644 ),
4645 ];
4646
4647 let blocking = blocking_findings(&findings);
4648 assert_eq!(2, blocking.len());
4649 assert_eq!("src/net.rs:10", blocking[0].file);
4650 assert_eq!("src/net.rs:200", blocking[1].file);
4651 }
4652
4653 #[test]
4654 fn moved_location_fallback_refuses_an_ambiguous_ledger() {
4655 let mut ledger = ledger_with("Unchecked error", "src/net.rs:10");
4656 ledger.extend(ledger_with("Unchecked error", "src/net.rs:200"));
4657 assert!(matching_ledger_key(&ledger, "Unchecked error", "src/net.rs:30").is_none());
4658 }
4659
4660 #[test]
4661 fn two_current_sites_do_not_relocate_one_old_ledger_entry() {
4662 let mut ledger = ledger_with("Unchecked error", "src/net.rs:5");
4663 let blocking = vec![
4664 finding(
4665 "blocking",
4666 "Unchecked error",
4667 "first",
4668 "src/net.rs:10",
4669 true,
4670 ),
4671 finding(
4672 "blocking",
4673 "Unchecked error",
4674 "second",
4675 "src/net.rs:200",
4676 true,
4677 ),
4678 ];
4679 let mut state = IssueRun::new(1, "t");
4680 record_own_fixes(&blocking, &mut ledger, &mut state, 2);
4681 assert!(ledger.contains_key(&finding_key("Unchecked error", "src/net.rs:10")));
4682 assert!(ledger.contains_key(&finding_key("Unchecked error", "src/net.rs:200")));
4683 }
4684
4685 #[test]
4686 fn two_current_sites_remain_two_open_findings() {
4687 let current = vec![
4688 finding(
4689 "blocking",
4690 "Unchecked error",
4691 "first",
4692 "src/net.rs:10",
4693 true,
4694 ),
4695 finding(
4696 "blocking",
4697 "Unchecked error",
4698 "second",
4699 "src/net.rs:200",
4700 true,
4701 ),
4702 ];
4703 let mut open = Vec::new();
4704
4705 extend_findings(&mut open, ¤t);
4706
4707 assert_eq!(2, open.len());
4708 assert_eq!("src/net.rs:10", open[0].file);
4709 assert_eq!("src/net.rs:200", open[1].file);
4710 }
4711
4712 #[test]
4713 fn display_limits_do_not_change_persisted_finding_identity() {
4714 let point = finding(
4715 "blocking",
4716 "abcdefghij",
4717 "still wrong",
4718 "src/net.rs:10",
4719 true,
4720 );
4721 let mut ledger = Ledger::new();
4722 let mut state = IssueRun::new(1, "t");
4723 record_own_fixes(std::slice::from_ref(&point), &mut ledger, &mut state, 1);
4724 normalise_ledger_keys(&mut ledger);
4725
4726 let key = matching_ledger_key(&ledger, "abcdefghij", "src/net.rs:12").unwrap();
4727 assert_eq!("abcdefghij", ledger[&key].title);
4728 }
4729
4730 #[test]
4731 fn a_clipped_legacy_entry_keeps_its_original_lookup_key() {
4732 let mut ledger = Ledger::new();
4733 let key = crate::jsonx::finding_key("abcdefghij", "src/net.rs:10");
4734 ledger.insert(
4735 key.clone(),
4736 LedgerEntry {
4737 title: "abcde".into(),
4738 file: "src/net.rs:10".into(),
4739 reasoning: "bounded by the caller".into(),
4740 round: 1,
4741 reraised: 1,
4742 outcome: Settled::Refuted,
4743 },
4744 );
4745 normalise_ledger_keys(&mut ledger);
4746
4747 assert_eq!(
4748 Some(key),
4749 matching_ledger_key(&ledger, "abcdefghij", "src/net.rs:12")
4750 );
4751 }
4752
4753 #[test]
4754 fn a_legacy_key_collision_does_not_merge_case_distinct_paths() {
4755 let mut ledger = Ledger::new();
4756 let key = crate::jsonx::finding_key("Unchecked error", "src/Main.rs:10");
4757 ledger.insert(
4758 key.clone(),
4759 LedgerEntry {
4760 title: "Unchecked error".into(),
4761 file: "src/Main.rs:10".into(),
4762 reasoning: "bounded by the caller".into(),
4763 round: 1,
4764 reraised: 0,
4765 outcome: Settled::Refuted,
4766 },
4767 );
4768
4769 assert_eq!(
4770 Some(key),
4771 matching_ledger_key(&ledger, "Unchecked error", "src/Main.rs:10")
4772 );
4773 assert!(matching_ledger_key(&ledger, "Unchecked error", "src/main.rs:10").is_none());
4774 }
4775
4776 #[test]
4780 fn a_refutation_lands_on_the_key_the_next_round_will_look_up() {
4781 let blocking = [finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
4782 let recorded = finding_key(&blocking[0].title, &blocking[0].file);
4783 let answer = disposition("unbounded loop!", "src/x.rs", Action::Refuted);
4784 assert!(answer_matches(&blocking[0], &answer));
4785 assert_eq!(recorded, finding_key(&answer.title, &answer.file));
4786 }
4787
4788 #[test]
4791 fn the_ledger_key_ignores_title_punctuation() {
4792 let findings = [finding(
4793 "blocking",
4794 "Panic on multi-byte input",
4795 "d",
4796 "src/style.rs",
4797 true,
4798 )];
4799 let reworded = "Panic on multibyte input";
4800 let source = &findings[0];
4801 assert_eq!(
4802 finding_key(reworded, &source.file),
4803 finding_key(&source.title, &source.file)
4804 );
4805 let recorded = finding_key(&source.title, &source.file);
4806 let looked_up = finding_key(&findings[0].title, &findings[0].file);
4807 assert_eq!(recorded, looked_up);
4808 }
4809
4810 #[test]
4811 fn a_disposition_matches_its_finding_despite_wording_noise() {
4812 let findings = [finding(
4813 "blocking",
4814 "Unbounded loop!",
4815 "d",
4816 "src/x.rs",
4817 true,
4818 )];
4819 assert!(answer_matches(
4820 &findings[0],
4821 &disposition("unbounded loop", "src/x.rs", Action::Refuted)
4822 ));
4823 assert!(!answer_matches(
4824 &findings[0],
4825 &disposition("something else", "src/x.rs", Action::Refuted)
4826 ));
4827 assert!(!answer_matches(
4828 &findings[0],
4829 &disposition("unbounded loop", "src/y.rs", Action::Refuted)
4830 ));
4831 }
4832
4833 #[test]
4834 fn a_disposition_matches_its_finding_despite_a_severity_tag() {
4835 let blocker = finding(
4836 "blocking",
4837 "Refused requests can corrupt the active splice",
4838 "d",
4839 "src/lightning/channel/channel.ts",
4840 true,
4841 );
4842 let answers = [disposition(
4843 "[blocking] Refused requests can corrupt the active splice",
4844 "src/lightning/channel/channel.ts",
4845 Action::Fixed,
4846 )];
4847
4848 assert_eq!(
4849 0,
4850 matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers)
4851 .expect("tagged answer")
4852 .0
4853 );
4854 }
4855
4856 #[test]
4857 fn a_bracketed_subject_still_separates_two_findings() {
4858 let ios = finding("blocking", "[iOS] Startup crash", "d", "src/app.ts", true);
4859 let android = finding(
4860 "blocking",
4861 "[Android] Startup crash",
4862 "d",
4863 "src/app.ts",
4864 true,
4865 );
4866 let findings = [ios.clone(), android];
4867 let answers = [disposition(
4868 "[Android] Startup crash",
4869 "src/app.ts",
4870 Action::Fixed,
4871 )];
4872
4873 assert!(matches!(
4874 matching_disposition(&ios, &findings, &answers),
4875 Err("no matching disposition")
4876 ));
4877 }
4878
4879 #[test]
4880 fn an_omitted_disposition_leaves_the_blocker_unmatched() {
4881 let blocker = finding("blocking", "Unbounded loop", "d", "src/x.rs", true);
4882 assert!(matches!(
4883 matching_disposition(
4884 &blocker,
4885 std::slice::from_ref(&blocker),
4886 &[] as &[Disposition]
4887 ),
4888 Err("no matching disposition")
4889 ));
4890 }
4891
4892 #[test]
4893 fn duplicate_dispositions_are_ambiguous() {
4894 let blocker = finding("blocking", "Unbounded loop", "d", "src/x.rs", true);
4895 let answers = vec![
4896 disposition("Unbounded loop", "src/x.rs", Action::Fixed),
4897 disposition("Unbounded loop", "src/x.rs", Action::Refuted),
4898 ];
4899 assert!(matches!(
4900 matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers),
4901 Err("more than one matching disposition")
4902 ));
4903 }
4904
4905 #[test]
4906 fn an_unambiguous_disposition_may_omit_the_line_number() {
4907 let blocker = finding(
4908 "blocking",
4909 "Replayed offers persist the wrong lane key",
4910 "d",
4911 "src/engine.rs:1166",
4912 true,
4913 );
4914 let answers = [disposition(
4915 "replayed offers persist the wrong lane key!",
4916 "src/engine.rs",
4917 Action::Fixed,
4918 )];
4919
4920 assert_eq!(
4921 0,
4922 matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers)
4923 .expect("stable answer")
4924 .0
4925 );
4926 }
4927
4928 #[test]
4929 fn a_line_free_disposition_does_not_choose_between_two_sites() {
4930 let first = finding("blocking", "Unchecked error", "d", "src/engine.rs:10", true);
4931 let second = finding(
4932 "blocking",
4933 "Unchecked error",
4934 "d",
4935 "src/engine.rs:200",
4936 true,
4937 );
4938 let findings = [first.clone(), second];
4939 let answers = [disposition(
4940 "Unchecked error",
4941 "src/engine.rs",
4942 Action::Fixed,
4943 )];
4944
4945 assert!(matches!(
4946 matching_disposition(&first, &findings, &answers),
4947 Err("no matching disposition")
4948 ));
4949 }
4950
4951 #[test]
4952 fn an_exact_location_wins_over_an_ambiguous_fallback() {
4953 let blocker = finding("blocking", "Unchecked error", "d", "src/engine.rs:10", true);
4954 let answers = [
4955 disposition("Unchecked error", "src/engine.rs", Action::Refuted),
4956 disposition("Unchecked error", "src/engine.rs:10", Action::Fixed),
4957 ];
4958
4959 let matched = matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers)
4960 .expect("exact answer");
4961 assert_eq!(1, matched.0);
4962 assert_eq!(Action::Fixed, matched.1.action);
4963 }
4964
4965 #[test]
4966 fn same_titled_findings_in_different_files_need_separate_dispositions() {
4967 let findings = [
4968 finding("blocking", "Unchecked error", "d", "src/a.rs", true),
4969 finding("blocking", "Unchecked error", "d", "src/b.rs", true),
4970 ];
4971 let answers = vec![
4972 disposition("Unchecked error", "src/a.rs", Action::Fixed),
4973 disposition("Unchecked error", "src/b.rs", Action::Refuted),
4974 ];
4975 assert_eq!(
4976 0,
4977 matching_disposition(&findings[0], &findings, &answers)
4978 .expect("left answer")
4979 .0
4980 );
4981 assert_eq!(
4982 1,
4983 matching_disposition(&findings[1], &findings, &answers)
4984 .expect("right answer")
4985 .0
4986 );
4987 }
4988
4989 #[test]
4990 fn a_reported_fix_without_a_commit_stays_open() {
4991 assert!(!fixed_disposition_resolves(false));
4992 assert!(fixed_disposition_resolves(true));
4993 }
4994
4995 #[test]
4996 fn the_settled_block_is_empty_when_nothing_is_settled() {
4997 assert_eq!("", settled_block(&Ledger::new()));
4998 }
4999
5000 #[test]
5001 fn the_settled_block_names_each_refutation() {
5002 let block = settled_block(&ledger_with("a point", "x.rs"));
5003 assert!(block.contains("a point"));
5004 assert!(block.contains("x.rs"));
5005 assert!(block.contains("settled"));
5006 }
5007
5008 #[test]
5009 fn same_title_settlements_name_each_location() {
5010 let mut ledger = ledger_with("Unchecked error", "a.rs:10");
5011 ledger.extend(ledger_with("Unchecked error", "b.rs:20"));
5012
5013 let block = settled_block(&ledger);
5014
5015 assert!(block.contains("Unchecked error (a.rs:10)"), "{block}");
5016 assert!(block.contains("Unchecked error (b.rs:20)"), "{block}");
5017 }
5018
5019 #[test]
5023 fn a_filed_point_is_settled_too() {
5024 let mut ledger = ledger_with("out of scope", "x.rs");
5025 for entry in ledger.values_mut() {
5026 entry.outcome = Settled::Filed;
5027 entry.reasoning = "Tracked in #9.".into();
5028 }
5029 let block = settled_block(&ledger);
5030 assert!(block.contains("out of scope"));
5031 assert!(block.contains("#9"));
5032 }
5033
5034 #[test]
5037 fn answering_a_point_again_keeps_its_re_raise_count() {
5038 let mut ledger = ledger_with("a point", "x.rs");
5039 let entry = ledger.values().next().unwrap().clone();
5040 let mut state = IssueRun::new(1, "t");
5041 let blocking = vec![finding("blocking", "a point", "d", "x.rs", true)];
5042
5043 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
5044 settle(&mut ledger, "a point", "x.rs", true, entry);
5045 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
5046 }
5047
5048 #[test]
5051 fn a_clean_review_is_just_the_verdict() {
5054 let text = review_comment("codex", 1, &review("Looks correct.", vec![]), &style());
5055 assert_eq!("Looks correct.", text);
5056 }
5057
5058 #[test]
5059 fn a_review_leads_with_the_counts() {
5060 let text = review_comment(
5061 "codex",
5062 2,
5063 &review(
5064 "One real problem.",
5065 vec![
5066 finding(
5067 "blocking",
5068 "Loop never terminates",
5069 "Confirmed by running it.",
5070 "src/a.rs",
5071 true,
5072 ),
5073 finding("non-blocking", "Name is vague", "d", "src/b.rs", true),
5074 finding("nit", "Log wording", "d", "", true),
5075 ],
5076 ),
5077 &style(),
5078 );
5079 assert!(text.starts_with("One real problem."), "{text}");
5080 assert!(!text.contains("codex"), "no agent name: {text}");
5081 assert!(!text.contains("round 2"), "no round number: {text}");
5082 }
5083
5084 #[test]
5087 fn only_blocking_findings_carry_their_detail() {
5088 let text = review_comment(
5089 "codex",
5090 1,
5091 &review(
5092 "s",
5093 vec![
5094 finding("blocking", "Loop", "BLOCKING DETAIL", "a.rs", true),
5095 finding("non-blocking", "Name", "NONBLOCKING DETAIL", "b.rs", true),
5096 ],
5097 ),
5098 &style(),
5099 );
5100 assert!(text.contains("BLOCKING DETAIL"), "{text}");
5101 assert!(!text.contains("NONBLOCKING DETAIL"), "{text}");
5102 }
5103
5104 #[test]
5105 fn a_thorough_explanation_reaches_the_author_intact() {
5108 let detail = "Reproduced by running the 429 test with max_attempts unset. ".repeat(8);
5109 let text = review_comment(
5110 "codex",
5111 1,
5112 &review(
5113 "One problem.",
5114 vec![finding("blocking", "T", &detail, "a.rs", true)],
5115 ),
5116 &style(),
5117 );
5118 assert!(
5119 text.contains(detail.trim()),
5120 "the explanation was cut:\n{text}"
5121 );
5122 }
5123
5124 #[test]
5126 fn a_runaway_model_is_still_bounded() {
5127 let long = "filler words. ".repeat(20_000);
5128 let text = review_comment(
5129 "codex",
5130 1,
5131 &review(&long, vec![finding("blocking", "T", &long, "a.rs", true)]),
5132 &style(),
5133 );
5134 assert!(
5135 text.len() < 30_000,
5136 "review comment was {} chars",
5137 text.len()
5138 );
5139 }
5140
5141 #[test]
5142 fn a_general_finding_has_no_empty_parenthesis() {
5143 let text = review_comment(
5144 "codex",
5145 1,
5146 &review("s", vec![finding("blocking", "Something", "d", "", true)]),
5147 &style(),
5148 );
5149 assert!(!text.contains("()"), "{text}");
5150 assert!(!text.contains("(general)"), "{text}");
5151 }
5152
5153 #[test]
5154 fn out_of_scope_findings_are_counted_separately() {
5155 let text = review_comment(
5156 "codex",
5157 1,
5158 &review(
5159 "s",
5160 vec![finding("blocking", "Old bug", "d", "a.rs", false)],
5161 ),
5162 &style(),
5163 );
5164 assert!(text.contains("out of scope"), "{text}");
5165 assert!(text.contains("Old bug"), "{text}");
5166 }
5167
5168 #[test]
5169 fn a_disposition_comment_leads_with_counts_and_keeps_refutations() {
5170 let response = ResponseDoc {
5171 summary: "Two of three were right.".into(),
5172 dispositions: vec![],
5173 };
5174 let text = disposition_comment(
5175 "claude",
5176 &response,
5177 &["Fixed thing".to_string()],
5178 &["Wrong thing. Because the caller already checks.".to_string()],
5179 &[],
5180 &style(),
5181 )
5182 .unwrap();
5183 assert!(text.starts_with("Two of three were right."), "{text}");
5184 assert!(!text.contains("claude"), "no agent name: {text}");
5185 assert!(
5186 text.contains("Because the caller already checks."),
5187 "{text}"
5188 );
5189 }
5190
5191 #[test]
5192 fn an_empty_disposition_comment_is_not_posted() {
5193 let response = ResponseDoc {
5194 summary: "s".into(),
5195 dispositions: vec![],
5196 };
5197 assert!(disposition_comment("claude", &response, &[], &[], &[], &style()).is_none());
5198 }
5199
5200 #[test]
5205 fn a_local_closing_commit_does_not_change_remote_custody() {
5206 for holder in ["a", "b"] {
5207 assert_eq!(holder, closing_next_actor(holder));
5208 }
5209 }
5210
5211 #[test]
5212 fn a_matching_head_keeps_saved_custody() {
5213 assert!(reconcile_saved_head(Some("abc123"), Some(2), true, "abc123", None, 42).unwrap());
5214 assert!(reconcile_saved_head(None, None, false, "abc123", None, 42).unwrap());
5215 }
5216
5217 #[test]
5218 fn a_changed_head_refuses_automatic_custody() {
5219 let error =
5220 reconcile_saved_head(Some("abc123"), Some(2), true, "def456", None, 42).unwrap_err();
5221 let text = error.to_string();
5222 assert!(text.contains("abc123"), "{text}");
5223 assert!(text.contains("def456"), "{text}");
5224 assert!(text.contains("--next <agent>"), "{text}");
5225 }
5226
5227 #[test]
5228 fn legacy_state_keeps_its_saved_custody_for_migration() {
5229 assert!(reconcile_saved_head(Some(""), Some(1), true, "def456", None, 42).unwrap());
5230 }
5231
5232 #[test]
5233 fn an_explicit_holder_resets_state_for_a_changed_or_legacy_head() {
5234 assert!(
5235 !reconcile_saved_head(Some("abc123"), Some(2), true, "def456", Some("b"), 42).unwrap()
5236 );
5237 assert!(!reconcile_saved_head(Some(""), Some(1), true, "def456", Some("b"), 42).unwrap());
5238 }
5239
5240 #[test]
5241 fn a_headless_current_or_future_state_is_not_legacy() {
5242 for version in [2, STATE_VERSION + 1] {
5243 assert!(
5244 reconcile_saved_head(Some(""), Some(version), true, "def456", None, 42).is_err()
5245 );
5246 }
5247 }
5248
5249 #[test]
5250 fn legacy_state_with_an_unknown_actor_refuses_to_guess() {
5251 assert!(reconcile_saved_head(Some(""), Some(1), false, "def456", None, 42).is_err());
5252 }
5253
5254 #[test]
5255 fn an_invalid_review_cannot_clear_a_carried_blocker() {
5256 let mut open = vec![finding(
5257 "blocking",
5258 "Unchecked error",
5259 "still fails",
5260 "src/a.rs:12",
5261 true,
5262 )];
5263 update_open_findings(&mut open, &[], false);
5264 assert_eq!(1, open.len());
5265
5266 update_open_findings(&mut open, &[], true);
5267 assert!(open.is_empty());
5268 }
5269
5270 #[test]
5271 fn a_final_round_with_no_commit_names_open_blockers() {
5272 let open = vec![finding(
5273 "blocking",
5274 "Unchecked error",
5275 "still fails",
5276 "src/a.rs",
5277 true,
5278 )];
5279 assert!(matches!(
5280 ending_without_landing(&open),
5281 Ending::Unresolved(points) if points.len() == 1
5282 ));
5283 assert!(matches!(ending_without_landing(&[]), Ending::Unchanged));
5284 }
5285
5286 #[test]
5290 fn a_closing_pass_uses_the_later_effort_tier() {
5291 use crate::config::{Call, EffortSchedule};
5292 let schedule = EffortSchedule {
5293 round_1: Some("ultra".into()),
5294 rest: Some("low".into()),
5295 ..EffortSchedule::default()
5296 };
5297 assert_eq!(None, schedule.get(Call::Close));
5298 let mut with_key = schedule.clone();
5299 with_key.close = Some("medium".into());
5300 assert_eq!(Some("medium".to_string()), with_key.get(Call::Close));
5301 }
5302
5303 #[test]
5304 fn a_ledger_with_no_claimed_fix_has_nothing_to_close_over() {
5305 assert!(!any_fixes(&ledger_with("refuted point", "a.rs"), 1));
5306 let mut fixed = ledger_with("fixed point", "b.rs");
5307 for entry in fixed.values_mut() {
5308 entry.outcome = Settled::Fixed;
5309 }
5310 assert!(any_fixes(&fixed, 1));
5311 }
5312
5313 #[test]
5316 fn the_closing_note_counts_points_rather_than_rounds() {
5317 assert_eq!("one point left after the closing pass", unresolved_note(1));
5318 assert_eq!("3 points left after the closing pass", unresolved_note(3));
5319 assert!(!unresolved_note(2).contains("round"));
5320 }
5321
5322 fn fixed_ledger() -> Ledger {
5323 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
5324 for entry in ledger.values_mut() {
5325 entry.outcome = Settled::Fixed;
5326 entry.reasoning = "bounded it on max_attempts".into();
5327 }
5328 ledger
5329 }
5330
5331 #[test]
5332 fn the_closing_prompt_names_every_fix_it_has_to_check() {
5333 let landed = vec!["abc1234 Bound the retry loop".to_string()];
5334 let prompt = close_prompt(
5335 "origin/main",
5336 42,
5337 "Retry a 429",
5338 "",
5339 "9f8e7d6",
5340 Some(&landed),
5341 &fixed_ledger(),
5342 &[],
5343 1,
5344 );
5345 assert!(prompt.contains("Unbounded loop"), "{prompt}");
5346 assert!(prompt.contains("bounded it on max_attempts"), "{prompt}");
5347 assert!(prompt.contains("abc1234 Bound the retry loop"), "{prompt}");
5348 assert!(prompt.contains("git diff 9f8e7d6..HEAD"), "{prompt}");
5349 assert!(prompt.contains("git diff origin/main...HEAD"), "{prompt}");
5350 assert!(!prompt.contains('{'), "{prompt}");
5351 }
5352
5353 #[test]
5356 fn a_close_with_nothing_landed_says_so_rather_than_leaving_a_hole() {
5357 let prompt = close_prompt(
5358 "origin/main",
5359 42,
5360 "Retry a 429",
5361 "",
5362 "9f8e7d6",
5363 Some(&[]),
5364 &Ledger::new(),
5365 &[],
5366 1,
5367 );
5368 assert!(
5369 prompt.contains("Nothing landed after the last round"),
5370 "{prompt}"
5371 );
5372 assert!(!prompt.contains('{'), "{prompt}");
5373 }
5374
5375 #[test]
5381 fn a_rewritten_branch_admits_it_cannot_say_what_landed() {
5382 let prompt = close_prompt(
5383 "origin/main",
5384 42,
5385 "Retry a 429",
5386 "",
5387 "9f8e7d6",
5388 None,
5389 &fixed_ledger(),
5390 &[],
5391 1,
5392 );
5393 assert!(prompt.contains("were rewritten"), "{prompt}");
5394 assert!(prompt.contains("git diff origin/main...HEAD"), "{prompt}");
5395 assert!(!prompt.contains("nobody has read it"), "{prompt}");
5396 assert!(!prompt.contains('{'), "{prompt}");
5397 }
5398
5399 #[test]
5402 fn the_closing_prompt_forbids_the_writing_the_loop_rolls_back() {
5403 let prompt = close_prompt(
5404 "origin/main",
5405 42,
5406 "t",
5407 "",
5408 "9f8e7d6",
5409 Some(&[]),
5410 &Ledger::new(),
5411 &[],
5412 1,
5413 );
5414 assert!(prompt.contains("do not commit"), "{prompt}");
5415 }
5416
5417 #[test]
5419 fn the_closing_prompt_keeps_confirmed_merge_blockers_blocking() {
5420 let prompt = close_prompt(
5421 "origin/main",
5422 42,
5423 "t",
5424 "",
5425 "9f8e7d6",
5426 Some(&[]),
5427 &Ledger::new(),
5428 &[],
5429 1,
5430 );
5431 assert!(
5432 prompt.contains("serious defect an\nearlier round missed"),
5433 "{prompt}"
5434 );
5435 assert!(prompt.contains("final merge-safety audit"), "{prompt}");
5436 assert!(!prompt.contains("not another\naudit"), "{prompt}");
5437 assert!(!prompt.contains("A\nfinding means"), "{prompt}");
5438 assert!(prompt.contains("A\nblocking finding means"), "{prompt}");
5439 let flat = prompt.split_whitespace().collect::<Vec<_>>().join(" ");
5440 assert!(flat.contains("does not become non-blocking"), "{prompt}");
5441 assert!(flat.contains("Only one of them ships"), "{prompt}");
5442 }
5443
5444 #[test]
5449 fn the_closing_pass_is_offered_a_severity_rather_than_the_field_that_gates() {
5450 let prompt = close_prompt(
5451 "origin/main",
5452 42,
5453 "t",
5454 "",
5455 "9f8e7d6",
5456 Some(&[]),
5457 &Ledger::new(),
5458 &[],
5459 1,
5460 );
5461 assert!(
5462 prompt.contains("Minor defects and improvements are\nnon-blocking"),
5463 "{prompt}"
5464 );
5465 assert!(
5466 prompt.contains("a real defect\nthis pull request did not cause"),
5467 "{prompt}"
5468 );
5469 }
5470
5471 #[test]
5474 fn an_out_of_scope_point_cannot_gate_the_close() {
5475 let out_of_scope = finding("blocking", "Adjacent leak", "d", "o.rs", false);
5476 assert!(!out_of_scope.blocks());
5477 assert!(approval_stands(&[], false));
5478 }
5479
5480 #[test]
5484 fn a_fix_is_shown_to_the_pass_that_has_to_check_it_and_not_after() {
5485 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
5486 for entry in ledger.values_mut() {
5487 entry.outcome = Settled::Fixed;
5488 entry.round = 2;
5489 }
5490 assert!(answers_block(&ledger, 3).contains("Unbounded loop"));
5492 assert_eq!("", answers_block(&ledger, 4));
5494 assert!(any_fixes(&ledger, 2));
5495 assert!(!any_fixes(&ledger, 3));
5496 }
5497
5498 #[test]
5503 fn the_answers_block_asks_the_reviewer_to_check_rather_than_to_trust() {
5504 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
5505 for entry in ledger.values_mut() {
5506 entry.outcome = Settled::Fixed;
5507 entry.reasoning = "bounded it on max_attempts".into();
5508 }
5509 let block = answers_block(&ledger, 2);
5510 assert!(block.contains("Unbounded loop"), "{block}");
5511 assert!(block.contains("src/x.rs"), "{block}");
5512 assert!(block.contains("bounded it on max_attempts"), "{block}");
5513 assert!(block.contains("Check the answer"), "{block}");
5514 assert!(!block.contains("settled"), "{block}");
5515 }
5516
5517 #[test]
5520 fn a_fix_and_a_refutation_do_not_share_a_heading() {
5521 let mut ledger = ledger_with("refuted point", "a.rs");
5522 ledger.extend(ledger_with("fixed point", "b.rs"));
5523 for entry in ledger.values_mut() {
5524 if entry.title == "fixed point" {
5525 entry.outcome = Settled::Fixed;
5526 }
5527 }
5528 let answers = answers_block(&ledger, 2);
5529 let settled = settled_block(&ledger);
5530 assert!(answers.contains("fixed point") && !answers.contains("refuted point"));
5531 assert!(settled.contains("refuted point") && !settled.contains("fixed point"));
5532 }
5533
5534 #[test]
5535 fn an_empty_ledger_adds_no_answers_block() {
5536 assert_eq!("", answers_block(&Ledger::new(), 2));
5537 }
5538
5539 #[test]
5542 fn the_last_round_that_can_ask_for_anything_says_so() {
5543 assert_eq!("", round_note(1, 3));
5544 assert_eq!("", round_note(2, 3));
5545 assert!(round_note(3, 3).contains("last round"));
5546 assert!(round_note(6, 6).contains("last round"));
5549 }
5550
5551 #[test]
5557 fn saying_when_the_asking_stops_says_nothing_about_severity() {
5558 let note = round_note(3, 3);
5559 for word in ["approve", "blocking", "severity", "nit"] {
5560 assert!(!note.contains(word), "{word} in: {note}");
5561 }
5562 }
5563
5564 #[test]
5565 fn the_review_prompt_leaves_nothing_unsubstituted() {
5566 let empty = review_prompt(
5570 "origin/main",
5571 42,
5572 "Retry a 429",
5573 "",
5574 &Ledger::new(),
5575 &[],
5576 1,
5577 3,
5578 );
5579 assert!(!empty.contains('{'), "{empty}");
5580 assert!(
5581 empty.contains("`origin/main`"),
5582 "the reviewer was told to read a different ref than spar compares:\n{empty}"
5583 );
5584 assert!(empty.contains("#42") && empty.contains("Retry a 429"));
5585
5586 let mut ledger = ledger_with("refuted point", "a.rs");
5587 ledger.extend(ledger_with("fixed point", "b.rs"));
5588 for entry in ledger.values_mut() {
5589 if entry.title == "fixed point" {
5590 entry.outcome = Settled::Fixed;
5591 entry.round = 2;
5592 }
5593 }
5594 let full = review_prompt("origin/main", 42, "Retry a 429", "", &ledger, &[], 3, 3);
5595 assert!(!full.contains('{'), "{full}");
5596 assert!(full.contains("fixed point") && full.contains("refuted point"));
5597 assert!(full.contains("last round"), "{full}");
5598 }
5599
5600 #[test]
5609 fn every_pass_that_judges_the_branch_is_given_the_issue_and_the_description() {
5610 let context = context_block(
5611 42,
5612 "https://github.com/example/project/issues/42",
5613 "Honour Retry-After and cap at five attempts.",
5614 "## What changed\n\nBounded the loop. Ran the 429 test.",
5615 );
5616 assert!(context.contains("Honour Retry-After"), "{context}");
5617 assert!(context.contains("issues/42"), "{context}");
5618 assert!(
5619 context.contains("discussion since is not included"),
5620 "{context}"
5621 );
5622 assert!(context.contains("Ran the 429 test"), "{context}");
5623 assert!(
5624 context.contains("claim to check"),
5625 "the description has to be labelled as the author's own account:\n{context}"
5626 );
5627
5628 let review = review_prompt(
5629 "origin/main",
5630 42,
5631 "Retry on 429",
5632 &context,
5633 &Ledger::new(),
5634 &[],
5635 1,
5636 3,
5637 );
5638 let close = close_prompt(
5639 "origin/main",
5640 42,
5641 "Retry on 429",
5642 &context,
5643 "9f8e7d6",
5644 Some(&[]),
5645 &Ledger::new(),
5646 &[],
5647 1,
5648 );
5649 let respond = RESPOND_PROMPT
5650 .replace("{number}", "42")
5651 .replace("{context}", &context)
5652 .replace("{findings}", "(none)")
5653 .replace("{optional}", "");
5654 for prompt in [review, close, respond] {
5655 assert!(prompt.contains("Honour Retry-After"), "{prompt}");
5656 assert!(prompt.contains("Ran the 429 test"), "{prompt}");
5657 assert!(!prompt.contains('{'), "{prompt}");
5658 }
5659 }
5660
5661 #[test]
5668 fn the_author_is_offered_the_non_blocking_findings_it_never_saw() {
5669 let findings = vec![
5670 finding(
5671 "blocking",
5672 "Retry loop spins",
5673 "confirmed",
5674 "src/net.rs:88",
5675 true,
5676 ),
5677 finding(
5678 "non-blocking",
5679 "Timeout is hard coded",
5680 "thirty seconds, in one place",
5681 "src/net.rs:44",
5682 true,
5683 ),
5684 finding(
5685 "nit",
5686 "Log line says retrying",
5687 "wording",
5688 "src/net.rs:102",
5689 true,
5690 ),
5691 finding(
5692 "non-blocking",
5693 "Out of scope thing",
5694 "predates the branch",
5695 "src/cache.rs",
5696 false,
5697 ),
5698 ];
5699
5700 let optional = optional_findings(&findings);
5701 assert_eq!(1, optional.len(), "{optional:?}");
5702 assert_eq!("Timeout is hard coded", optional[0].title);
5703
5704 let prompt = RESPOND_PROMPT
5705 .replace("{number}", "42")
5706 .replace("{context}", "")
5707 .replace("{findings}", &findings_for_prompt(&[findings[0].clone()]))
5708 .replace("{optional}", &optional_block(&findings));
5709 assert!(prompt.contains("Timeout is hard coded"), "{prompt}");
5710 assert!(prompt.contains("will not hold the merge"), "{prompt}");
5711 assert!(
5712 !prompt.contains("Log line says retrying"),
5713 "a nit is dropped, not offered:\n{prompt}"
5714 );
5715 assert!(
5716 !prompt.contains("Out of scope thing"),
5717 "an out of scope point is filed, not offered:\n{prompt}"
5718 );
5719 assert!(!prompt.contains('{'), "{prompt}");
5720
5721 let none = optional_block(&[findings[0].clone()]);
5723 assert_eq!("", none);
5724 }
5725
5726 #[test]
5728 fn the_review_prompt_says_what_happens_to_each_severity() {
5729 assert!(
5730 REVIEW_PROMPT.contains("offered to the author"),
5731 "{REVIEW_PROMPT}"
5732 );
5733 assert!(REVIEW_PROMPT.contains("nit: dropped"), "{REVIEW_PROMPT}");
5734 }
5735
5736 #[test]
5739 fn a_pull_request_with_no_issue_behind_it_carries_no_empty_headings() {
5740 assert_eq!("", context_block(7, "", "", ""));
5741 let only_description = context_block(7, "", "", "What it does.");
5742 assert!(!only_description.contains("as filed"), "{only_description}");
5743 assert!(only_description.contains("What it does."));
5744 }
5745
5746 #[test]
5747 fn a_resumed_open_finding_reaches_review_and_closing_prompts() {
5748 let open = vec![finding(
5749 "blocking",
5750 "Retry bypasses the limit",
5751 "reproduced with max_attempts set to one",
5752 "src/net.rs:88",
5753 true,
5754 )];
5755 let review = review_prompt(
5756 "origin/main",
5757 42,
5758 "Retry a 429",
5759 "",
5760 &Ledger::new(),
5761 &open,
5762 2,
5763 3,
5764 );
5765 let close = close_prompt(
5766 "origin/main",
5767 42,
5768 "Retry a 429",
5769 "",
5770 "9f8e7d6",
5771 Some(&[]),
5772 &Ledger::new(),
5773 &open,
5774 2,
5775 );
5776 for prompt in [review, close] {
5777 assert!(prompt.contains("Retry bypasses the limit"), "{prompt}");
5778 assert!(prompt.contains("src/net.rs:88"), "{prompt}");
5779 assert!(
5780 prompt.contains("reproduced with max_attempts set to one"),
5781 "{prompt}"
5782 );
5783 assert!(!prompt.contains('{'), "{prompt}");
5784 }
5785 }
5786
5787 #[test]
5791 fn a_minor_defect_has_a_severity_that_is_not_blocking() {
5792 assert!(
5793 REVIEW_PROMPT.contains("A minor defect belongs\n here as much as an improvement does")
5794 );
5795 for text in [
5800 schema::review().to_string(),
5801 crate::review_only::review_only_prompt().to_string(),
5802 ] {
5803 assert!(text.contains("as much as an improvement does"), "{text}");
5804 assert!(!text.contains("a genuine improvement"), "{text}");
5805 }
5806 }
5807
5808 #[test]
5811 fn doubt_resolves_away_from_the_field_that_gates() {
5812 for text in [REVIEW_PROMPT.to_string(), schema::review().to_string()] {
5813 assert!(text.contains("say your piece in the finding and label it non-blocking"));
5814 assert!(!text.contains("leave in_scope true"));
5815 }
5816 }
5817
5818 #[test]
5821 fn both_edit_prompts_ask_for_the_smallest_change_that_answers_the_point() {
5822 for prompt in [FIX_PROMPT, RESPOND_PROMPT] {
5823 assert!(
5824 prompt.contains("The smallest change that answers it is"),
5825 "{prompt}"
5826 );
5827 }
5828 assert!(RESPOND_PROMPT.contains("bigger than the\n problem it names"));
5829 }
5830
5831 #[test]
5832 fn a_fixed_disposition_must_explain_what_changed() {
5833 let flat = RESPOND_PROMPT
5834 .split_whitespace()
5835 .collect::<Vec<_>>()
5836 .join(" ");
5837 assert!(
5838 flat.contains("For fixed, say what changed and how it answers the point"),
5839 "{RESPOND_PROMPT}"
5840 );
5841 }
5842
5843 #[test]
5844 fn an_empty_fix_reason_still_renders_as_a_claim_to_check() {
5845 let mut ledger = ledger_with("Unchecked error", "src/net.rs");
5846 for entry in ledger.values_mut() {
5847 entry.outcome = Settled::Fixed;
5848 entry.reasoning.clear();
5849 }
5850
5851 let lines = fixed_lines(&ledger, 0);
5852
5853 assert_eq!(1, lines.len());
5854 assert!(lines[0].contains("a committed change claims to address this point"));
5855 assert!(!lines[0].contains("The author said"));
5856 }
5857
5858 #[test]
5863 fn the_implementor_is_given_the_link_and_the_body() {
5864 let prompt = implement_prompt(
5865 42,
5866 "Retry a 429",
5867 "https://github.com/o/r/issues/42",
5868 "A rate limited response was treated as fatal.",
5869 );
5870 assert!(
5871 prompt.contains("https://github.com/o/r/issues/42"),
5872 "{prompt}"
5873 );
5874 assert!(
5875 prompt.contains("A rate limited response was treated as fatal."),
5876 "{prompt}"
5877 );
5878 assert!(prompt.contains("#42"), "{prompt}");
5879 assert!(prompt.contains("Retry a 429"), "{prompt}");
5880 assert!(!prompt.contains('{'), "{prompt}");
5882 }
5883
5884 #[test]
5887 fn the_prompt_says_the_discussion_is_not_included() {
5888 let prompt = implement_prompt(1, "t", "u", "b");
5889 let lower = prompt
5891 .split_whitespace()
5892 .collect::<Vec<_>>()
5893 .join(" ")
5894 .to_lowercase();
5895 assert!(
5896 lower.contains("discussion since is not included"),
5897 "{prompt}"
5898 );
5899 assert!(lower.contains("cannot reach the network"), "{prompt}");
5900 }
5901
5902 fn worked() -> Implementation {
5904 Implementation {
5905 summary: "Retry a 429 instead of failing the run.".into(),
5906 problem: "A rate limited response was treated as fatal, so one throttled call ended \
5907 a run that had hours of work left in it."
5908 .into(),
5909 changes: vec![
5910 "`send` retries a 429 with the delay the header asks for".into(),
5911 "the retry budget is bounded, so a permanent 429 still ends".into(),
5912 ],
5913 testing: vec![
5914 "`cargo test retries_a_429`".into(),
5915 "point it at a throttled endpoint and watch it finish".into(),
5916 ],
5917 ..Implementation::default()
5918 }
5919 }
5920
5921 #[test]
5922 fn a_pr_body_is_what_it_closes_and_what_changed() {
5925 let body = pr_body(42, &worked(), None, &style());
5926 assert_eq!(
5927 "Closes #42\n\n\
5928 Retry a 429 instead of failing the run.\n\n\
5929 A rate limited response was treated as fatal, so one throttled call \
5930 ended a run that had hours of work left in it.\n\n\
5931 ## What changed\n\n\
5932 - `send` retries a 429 with the delay the header asks for\n\
5933 - the retry budget is bounded, so a permanent 429 still ends\n\n\
5934 ## How to test\n\n\
5935 - `cargo test retries_a_429`\n\
5936 - point it at a throttled endpoint and watch it finish",
5937 body
5938 );
5939 }
5940
5941 #[test]
5944 fn a_body_with_nothing_to_list_carries_no_empty_headings() {
5945 let work = Implementation {
5946 summary: "Retry a 429 instead of failing the run.".into(),
5947 ..Implementation::default()
5948 };
5949 assert_eq!(
5950 "Closes #42\n\nRetry a 429 instead of failing the run.",
5951 pr_body(42, &work, None, &style())
5952 );
5953 }
5954
5955 #[test]
5956 fn a_pr_body_survives_an_implementor_that_said_nothing() {
5957 assert_eq!(
5958 "Closes #7",
5959 pr_body(7, &Implementation::default(), None, &style())
5960 );
5961 }
5962
5963 #[test]
5966 fn blank_list_entries_do_not_earn_a_heading() {
5967 let work = Implementation {
5968 summary: "Did a thing.".into(),
5969 changes: vec![String::new(), " ".into()],
5970 ..Implementation::default()
5971 };
5972 let body = pr_body(42, &work, None, &style());
5973 assert!(!body.contains("What changed"), "{body}");
5974 }
5975
5976 #[test]
5977 fn notes_appear_only_when_there_is_something_to_note() {
5978 let mut work = worked();
5979 assert!(!pr_body(42, &work, None, &style()).contains("## Notes"));
5980 work.notes = Some("The retry is not applied to streaming calls.".into());
5981 let body = pr_body(42, &work, None, &style());
5982 assert!(body.contains("## Notes"), "{body}");
5983 assert!(body.contains("streaming calls"), "{body}");
5984 }
5985
5986 #[test]
5989 fn declining_posts_the_reason_and_not_the_summary() {
5990 let work = Implementation {
5991 not_worth_doing: true,
5992 reason: "Already fixed in 1.2, and the report predates it.".into(),
5993 summary: "Nothing to do.".into(),
5994 ..Implementation::default()
5995 };
5996 assert_eq!(
5997 "Already fixed in 1.2, and the report predates it.",
5998 no_pr_note(&work, &style())
5999 );
6000 }
6001
6002 #[test]
6003 fn reporting_work_and_committing_none_says_that_rather_than_the_summary() {
6004 let work = Implementation {
6005 summary: "Retry a 429 instead of failing the run.".into(),
6006 ..Implementation::default()
6007 };
6008 let note = no_pr_note(&work, &style());
6009 assert_eq!(
6010 "Nothing was committed, so there is nothing to review.",
6011 note
6012 );
6013 }
6014
6015 #[test]
6016 fn declining_without_a_reason_still_says_something() {
6017 let work = Implementation {
6018 not_worth_doing: true,
6019 ..Implementation::default()
6020 };
6021 assert!(no_pr_note(&work, &style()).contains("no reason given"));
6022 }
6023
6024 #[test]
6025 fn a_skip_comment_is_only_the_reasoning() {
6026 let item = SkippedItem {
6027 issue: 3,
6028 title: "t".into(),
6029 tracker: false,
6030 reasons: [
6031 ("claude".to_string(), "Already fixed in 1.2.".to_string()),
6032 ("codex".to_string(), "Duplicate of #2.".to_string()),
6033 ]
6034 .into_iter()
6035 .collect(),
6036 };
6037 let text = skip_comment(&item, &style());
6038 assert!(text.contains("Already fixed in 1.2."), "{text}");
6039 assert!(text.contains("Duplicate of #2."), "{text}");
6040 assert!(
6041 !text.contains("claude") && !text.contains("codex"),
6042 "{text}"
6043 );
6044 assert!(!text.to_lowercase().contains("not scheduled"), "{text}");
6045 assert!(text.lines().count() <= 3, "{text}");
6046 }
6047
6048 #[test]
6049 fn findings_for_a_model_keep_full_detail() {
6050 let long = "x".repeat(2000);
6051 let text = findings_for_prompt(&[finding("blocking", "T", &long, "a.rs", true)]);
6052 assert!(
6053 text.contains(&long),
6054 "a model needs the whole finding, only humans need brevity"
6055 );
6056 }
6057
6058 #[test]
6059 fn findings_for_a_model_are_never_empty() {
6060 assert_eq!("(none)", findings_for_prompt(&[]));
6061 }
6062}
6063
6064#[cfg(test)]
6065mod outcome_tests {
6066 use super::*;
6067 use crate::model::{Dispute, Severity};
6068
6069 fn style() -> Style {
6070 Style::default()
6071 }
6072
6073 fn state_with(disputes: Vec<(&str, &str)>, filed: Vec<&str>) -> IssueRun {
6074 let mut s = IssueRun::new(482, "t");
6075 s.disputes = disputes
6076 .into_iter()
6077 .map(|(title, reasoning)| Dispute {
6078 title: title.into(),
6079 file: String::new(),
6080 reasoning: reasoning.into(),
6081 })
6082 .collect();
6083 s.filed = filed.into_iter().map(String::from).collect();
6084 s
6085 }
6086
6087 fn finding(title: &str, file: &str) -> Finding {
6088 Finding {
6089 severity: Severity::Blocking,
6090 title: title.into(),
6091 detail: "d".into(),
6092 file: file.into(),
6093 in_scope: true,
6094 ..Default::default()
6095 }
6096 }
6097
6098 fn graded(severity: Severity, title: &str, file: &str, in_scope: bool) -> Finding {
6099 Finding {
6100 severity,
6101 in_scope,
6102 ..finding(title, file)
6103 }
6104 }
6105
6106 #[test]
6107 fn outcome_mode_routes_final_results_to_the_configured_sink() {
6108 assert_eq!(OutcomeSink::PullRequest, outcome_sink(PrComments::Outcome));
6109 assert_eq!(OutcomeSink::PullRequest, outcome_sink(PrComments::Rounds));
6110 assert_eq!(OutcomeSink::Terminal, outcome_sink(PrComments::None));
6111 }
6112
6113 #[test]
6116 fn a_clean_approval_says_nothing() {
6117 let state = state_with(vec![], vec![]);
6118 assert!(outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).is_none());
6119 }
6120
6121 #[test]
6126 fn a_downgraded_finding_still_reaches_the_pull_request() {
6127 let mut state = state_with(vec![], vec![]);
6128 let kept = graded(
6129 Severity::NonBlocking,
6130 "Timeout is not configurable",
6131 "n.rs",
6132 true,
6133 );
6134 record_nonblocking_outcome(&mut state, &kept, None);
6135
6136 assert_eq!(1, state.noted.len());
6139
6140 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
6141 assert!(text.contains("Noted, not blocking"), "{text}");
6142 assert!(
6143 text.contains("Timeout is not configurable (n.rs)"),
6144 "{text}"
6145 );
6146 }
6147
6148 #[test]
6151 fn a_point_that_was_filed_is_not_also_noted() {
6152 let mut state = state_with(vec![], vec![]);
6153 let finding = graded(Severity::NonBlocking, "Timeout", "n.rs", true);
6154 record_nonblocking_outcome(&mut state, &finding, None);
6155 record_nonblocking_outcome(
6156 &mut state,
6157 &finding,
6158 Some(&Followup::Recorded("https://example.invalid/9".into())),
6159 );
6160 assert!(state.noted.is_empty());
6161 assert_eq!(vec!["https://example.invalid/9"], state.filed);
6162 }
6163
6164 #[test]
6165 fn filing_a_moved_point_removes_its_earlier_note() {
6166 let mut state = state_with(vec![], vec![]);
6167 let earlier = graded(Severity::NonBlocking, "Timeout", "src/net.rs:10", true);
6168 let moved = graded(Severity::NonBlocking, "Timeout", "src/net.rs:12", false);
6169 record_nonblocking_outcome(&mut state, &earlier, None);
6170
6171 record_nonblocking_outcome(
6172 &mut state,
6173 &moved,
6174 Some(&Followup::Recorded("https://example.invalid/10".into())),
6175 );
6176
6177 assert!(state.noted.is_empty());
6178 assert_eq!(vec!["https://example.invalid/10"], state.filed);
6179 }
6180
6181 #[test]
6182 fn an_unrecorded_nonblocking_followup_remains_noted() {
6183 let finding = graded(Severity::NonBlocking, "Timeout", "n.rs", true);
6184 for outcome in [
6185 Followup::Covered("https://example.invalid/closed".into()),
6186 Followup::Dropped("follow-ups are off"),
6187 Followup::Failed,
6188 ] {
6189 let mut state = state_with(vec![], vec![]);
6190 record_nonblocking_outcome(&mut state, &finding, Some(&outcome));
6191 assert_eq!(1, state.noted.len(), "{outcome:?}");
6192 assert!(state.filed.is_empty(), "{outcome:?}");
6193 }
6194 }
6195
6196 #[test]
6198 fn a_point_noted_twice_is_listed_once() {
6199 let mut state = state_with(vec![], vec![]);
6200 let raised = graded(
6201 Severity::NonBlocking,
6202 "Timeout is not configurable",
6203 "n.rs",
6204 true,
6205 );
6206 let reworded = graded(
6207 Severity::NonBlocking,
6208 "timeout is not configurable!",
6209 "n.rs",
6210 true,
6211 );
6212 record_nonblocking_outcome(&mut state, &raised, None);
6213 record_nonblocking_outcome(&mut state, &reworded, None);
6214 assert_eq!(1, state.noted.len());
6215 }
6216
6217 #[test]
6218 fn same_title_notes_in_different_files_are_both_kept() {
6219 let mut state = state_with(vec![], vec![]);
6220 for file in ["src/a.rs", "src/b.rs"] {
6221 let finding = graded(Severity::NonBlocking, "Unchecked error", file, true);
6222 record_nonblocking_outcome(&mut state, &finding, None);
6223 }
6224 assert_eq!(2, state.noted.len());
6225 }
6226
6227 #[test]
6228 fn same_title_notes_at_two_sites_in_one_review_are_both_kept() {
6229 let findings = vec![
6230 graded(
6231 Severity::NonBlocking,
6232 "Unchecked error",
6233 "src/a.rs:10",
6234 true,
6235 ),
6236 graded(
6237 Severity::NonBlocking,
6238 "Unchecked error",
6239 "src/a.rs:200",
6240 true,
6241 ),
6242 ];
6243 let mut state = state_with(vec![], vec![]);
6244
6245 for finding in &findings {
6246 record_nonblocking_outcome_with_match(
6247 &mut state,
6248 finding,
6249 None,
6250 unique_stable_finding(&findings, finding),
6251 );
6252 }
6253
6254 assert_eq!(2, state.noted.len());
6255 }
6256
6257 #[test]
6258 fn settling_one_of_two_same_title_notes_keeps_the_other() {
6259 let first = graded(
6260 Severity::NonBlocking,
6261 "Unchecked error",
6262 "src/a.rs:10",
6263 true,
6264 );
6265 let second = graded(
6266 Severity::NonBlocking,
6267 "Unchecked error",
6268 "src/a.rs:200",
6269 true,
6270 );
6271 let mut state = state_with(vec![], vec![]);
6272 remember_noted(&mut state, &first, false);
6273 remember_noted(&mut state, &second, false);
6274
6275 forget_noted(&mut state, &first, false);
6276
6277 assert_eq!(1, state.noted.len());
6278 assert_eq!("src/a.rs:200", state.noted[0].file);
6279 }
6280
6281 #[test]
6282 fn same_title_disputes_at_two_sites_are_both_kept() {
6283 let mut state = state_with(vec![], vec![]);
6284 for file in ["src/a.rs:10", "src/a.rs:200"] {
6285 remember_dispute(
6286 &mut state,
6287 Dispute {
6288 title: "Unchecked error".into(),
6289 file: file.into(),
6290 reasoning: "the caller handles it".into(),
6291 },
6292 false,
6293 );
6294 }
6295
6296 assert_eq!(2, state.disputes.len());
6297 }
6298
6299 #[test]
6300 fn a_later_nonblocking_verdict_replaces_a_prior_dispute() {
6301 let mut state = state_with(vec![], vec![]);
6302 let finding = graded(Severity::NonBlocking, "Unchecked error", "src/a.rs", true);
6303 remember_dispute(
6304 &mut state,
6305 Dispute {
6306 title: finding.title.clone(),
6307 file: finding.file.clone(),
6308 reasoning: "the caller handles it".into(),
6309 },
6310 true,
6311 );
6312
6313 record_nonblocking_outcome(&mut state, &finding, None);
6314
6315 assert!(state.disputes.is_empty());
6316 assert_eq!(1, state.noted.len());
6317 }
6318
6319 #[test]
6320 fn a_note_moving_lines_in_the_same_file_is_updated() {
6321 let mut state = state_with(vec![], vec![]);
6322 let first = graded(
6323 Severity::NonBlocking,
6324 "Unchecked error",
6325 "src/a.rs:12",
6326 true,
6327 );
6328 let moved = graded(
6329 Severity::NonBlocking,
6330 "Unchecked error",
6331 "src/a.rs:19",
6332 true,
6333 );
6334 record_nonblocking_outcome(&mut state, &first, None);
6335 record_nonblocking_outcome(&mut state, &moved, None);
6336 assert_eq!(1, state.noted.len());
6337 assert_eq!("src/a.rs:19", state.noted[0].file);
6338 }
6339
6340 #[test]
6341 fn a_settled_point_removes_its_stale_note() {
6342 for outcome in [Settled::Fixed, Settled::Refuted, Settled::Filed] {
6343 let noted = graded(Severity::NonBlocking, "Unchecked error", "src/a.rs", true);
6344 let mut state = state_with(vec![], vec![]);
6345 record_nonblocking_outcome(&mut state, ¬ed, None);
6346 forget_noted(&mut state, ¬ed, true);
6347 assert!(state.noted.is_empty(), "{outcome}");
6348 }
6349 }
6350
6351 #[test]
6352 fn settling_a_moved_point_removes_its_old_dispute() {
6353 let old = graded(Severity::Blocking, "Unchecked error", "src/a.rs:10", true);
6354 let moved = graded(Severity::Blocking, "Unchecked error", "src/a.rs:12", true);
6355 let mut state = state_with(vec![], vec![]);
6356 remember_dispute(
6357 &mut state,
6358 Dispute {
6359 title: old.title,
6360 file: old.file,
6361 reasoning: "the caller handles it".into(),
6362 },
6363 true,
6364 );
6365
6366 forget_dispute(&mut state, &moved, true);
6367
6368 assert!(state.disputes.is_empty());
6369 }
6370
6371 #[test]
6374 fn a_deadlocked_point_is_not_also_noted() {
6375 let mut state = state_with(vec![], vec![]);
6376 let noted = graded(Severity::NonBlocking, "Unbounded loop", "x.rs", true);
6377 record_nonblocking_outcome(&mut state, ¬ed, None);
6378 let points = vec![finding("Unbounded loop", "x.rs")];
6379 let text = outcome_comment(
6380 &state,
6381 &Ledger::new(),
6382 &Ending::Deadlocked(&points),
6383 &style(),
6384 )
6385 .unwrap();
6386 assert!(!text.contains("Noted, not blocking"), "{text}");
6387 }
6388
6389 #[test]
6390 fn clipping_a_rendered_title_does_not_break_duplicate_suppression() {
6391 let mut compact = style();
6392 compact.max_title_chars = 5;
6393 let mut state = state_with(
6394 vec![("abcdefghij", "the caller already handles it")],
6395 vec![],
6396 );
6397 state
6398 .noted
6399 .push(graded(Severity::NonBlocking, "abcdefghij", "x.rs", true));
6400 state.disputes[0].file = "x.rs".into();
6401 let points = vec![finding("abcdefghij", "x.rs")];
6402
6403 let text = outcome_comment(
6404 &state,
6405 &Ledger::new(),
6406 &Ending::Unresolved(&points),
6407 &compact,
6408 )
6409 .unwrap();
6410
6411 assert!(!text.contains("Raised and refuted"), "{text}");
6412 assert!(!text.contains("Noted, not blocking"), "{text}");
6413 }
6414
6415 #[test]
6416 fn an_approval_that_filed_follow_ups_links_them() {
6417 let state = state_with(
6418 vec![],
6419 vec![
6420 "https://github.com/you/thing/issues/485",
6421 "https://github.com/you/thing/issues/486",
6422 ],
6423 );
6424 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
6425 assert!(text.contains("Filed separately: #485, #486"), "{text}");
6426 }
6427
6428 #[test]
6432 fn a_run_that_changed_nothing_does_not_claim_there_is_something_to_read() {
6433 let state = state_with(vec![], vec![]);
6434 let text = outcome_comment(&state, &Ledger::new(), &Ending::Unchanged, &style()).unwrap();
6435 assert!(text.contains("changed nothing"), "{text}");
6436 assert!(!text.contains("was pushed"), "{text}");
6437 }
6438
6439 #[test]
6443 fn an_unresolved_close_names_what_is_still_wrong() {
6444 let state = state_with(vec![], vec![]);
6445 let left = vec![Finding {
6446 detail: "The guard sits after the early return.".into(),
6447 ..finding("The retry fix never reaches the 429 path", "src/net.rs:88")
6448 }];
6449 let text =
6450 outcome_comment(&state, &Ledger::new(), &Ending::Unresolved(&left), &style()).unwrap();
6451 assert!(text.contains("These points are still open"), "{text}");
6452 assert!(
6453 text.contains("The retry fix never reaches the 429 path (src/net.rs:88)"),
6454 "{text}"
6455 );
6456 assert!(
6457 text.contains("The guard sits after the early return."),
6458 "{text}"
6459 );
6460 assert!(!text.contains("has not been reviewed"), "{text}");
6463 }
6464
6465 #[test]
6469 fn running_out_of_rounds_says_what_that_means_for_the_reader() {
6470 let state = state_with(vec![], vec![]);
6471 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
6472 assert!(text.contains("has not been reviewed"), "{text}");
6473 assert!(
6474 !text.to_lowercase().contains("round 3"),
6475 "no round numbers: {text}"
6476 );
6477 assert!(!text.to_lowercase().contains("convergence"), "{text}");
6478 }
6479
6480 #[test]
6481 fn a_failed_close_reports_unread_fixes_and_carried_blockers() {
6482 let state = state_with(vec![], vec![]);
6483 let open = vec![Finding {
6484 detail: "the failure is still discarded".into(),
6485 ..finding("Unchecked error", "src/net.rs:88")
6486 }];
6487 let text = outcome_comment_with_unread(
6488 &state,
6489 &Ledger::new(),
6490 &Ending::OutOfRounds,
6491 &open,
6492 &style(),
6493 )
6494 .unwrap();
6495 assert!(text.contains("has not been reviewed"), "{text}");
6496 assert!(text.contains("These points were already open"), "{text}");
6497 assert!(text.contains("Unchecked error (src/net.rs:88)"), "{text}");
6498 }
6499
6500 #[test]
6501 fn a_deadlock_names_the_point_they_could_not_settle() {
6502 let state = state_with(vec![], vec![]);
6503 let points = [finding("Retry loop never terminates", "src/net.rs:88")];
6504 let text = outcome_comment(
6505 &state,
6506 &Ledger::new(),
6507 &Ending::Deadlocked(&points),
6508 &style(),
6509 )
6510 .unwrap();
6511 assert!(
6512 text.contains("Retry loop never terminates (src/net.rs:88)"),
6513 "{text}"
6514 );
6515 assert!(text.contains("could not settle"), "{text}");
6516 }
6517
6518 #[test]
6520 fn refutations_survive_because_nothing_else_carries_them() {
6521 let state = state_with(
6522 vec![(
6523 "Error is swallowed",
6524 "the caller already validates the file",
6525 )],
6526 vec![],
6527 );
6528 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
6529 assert!(text.contains("Raised and refuted:"), "{text}");
6530 assert!(
6531 text.contains("The caller already validates the file"),
6532 "{text}"
6533 );
6534 }
6535
6536 #[test]
6537 fn no_agent_names_counts_or_round_numbers_reach_the_thread() {
6538 let state = state_with(
6539 vec![("A point", "a reason")],
6540 vec!["https://github.com/you/thing/issues/485"],
6541 );
6542 let left = vec![finding("A point", "a.rs")];
6543 for ending in [
6544 Ending::Approved,
6545 Ending::OutOfRounds,
6546 Ending::Unresolved(&left),
6547 ] {
6548 let text = outcome_comment(&state, &Ledger::new(), &ending, &style()).unwrap();
6549 let lower = text.to_lowercase();
6550 for banned in ["claude", "codex", "blocking,", "nit,", " fixed."] {
6551 assert!(
6552 !lower.contains(banned),
6553 "{banned:?} leaked into the thread:\n{text}"
6554 );
6555 }
6556 for n in 1..9 {
6558 assert!(
6559 !lower.contains(&format!("round {n}")),
6560 "a round number leaked into the thread:\n{text}"
6561 );
6562 }
6563 }
6564 }
6565
6566 #[test]
6567 fn a_refutation_is_allowed_to_make_its_case() {
6570 let reasoning = "The caller validates against the schema first. \
6571 The discarded error is therefore unreachable in practice. ";
6572 let state = state_with(
6573 vec![("A point", &reasoning.repeat(6))],
6574 vec!["https://github.com/you/thing/issues/485"],
6575 );
6576 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
6577 assert!(
6578 !text.contains("..."),
6579 "nothing was cut mid thought:\n{text}"
6580 );
6581 assert!(text.len() < 4000, "{} chars", text.len());
6582 }
6583
6584 #[test]
6585 fn a_url_that_is_not_an_issue_link_is_left_alone() {
6586 assert_eq!(
6587 "#485",
6588 as_reference("https://github.com/you/thing/issues/485")
6589 );
6590 assert_eq!("note: something", as_reference("note: something"));
6591 }
6592}
6593
6594#[cfg(test)]
6595mod filed_reference_tests {
6596 use super::*;
6597
6598 #[test]
6599 fn an_issue_url_yields_its_number() {
6600 assert_eq!(
6601 Some(485),
6602 filed_issue_number("https://github.com/you/thing/issues/485")
6603 );
6604 }
6605
6606 #[test]
6609 fn a_local_note_yields_nothing() {
6610 assert_eq!(None, filed_issue_number("note: Retry is unbounded"));
6611 assert_eq!(None, filed_issue_number(""));
6612 assert_eq!(
6613 None,
6614 filed_issue_number("https://github.com/you/thing/issues/")
6615 );
6616 }
6617}
6618
6619#[cfg(test)]
6620mod followup_restraint_tests {
6621 use super::*;
6622 use crate::model::Severity;
6623
6624 fn cfg_with(followups: Followups, non_blocking: bool, nits: bool, cap: usize) -> Config {
6625 let mut cfg =
6626 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
6627 .unwrap();
6628 cfg.loop_cfg.followups = followups;
6629 cfg.loop_cfg.file_non_blocking = non_blocking;
6630 cfg.loop_cfg.file_nits = nits;
6631 cfg.loop_cfg.max_followups = cap;
6632 cfg
6633 }
6634
6635 fn finding(severity: Severity, title: &str, in_scope: bool) -> Finding {
6636 Finding {
6637 severity,
6638 title: title.into(),
6639 detail: "d".into(),
6640 file: "a.rs".into(),
6641 in_scope,
6642 ..Default::default()
6643 }
6644 }
6645
6646 #[test]
6650 fn a_non_blocking_finding_is_not_a_tracker_item_by_default() {
6651 let cfg = cfg_with(Followups::Issues, false, false, 5);
6652 assert!(!cfg.loop_cfg.file_non_blocking);
6653 assert!(!cfg.loop_cfg.file_nits);
6654 }
6655
6656 #[test]
6657 fn follow_ups_stay_off_the_tracker_by_default() {
6658 let cfg =
6659 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
6660 .unwrap();
6661 assert_eq!(
6662 Followups::Local,
6663 cfg.loop_cfg.followups,
6664 "the tracker is somebody's queue; the default must not write to it"
6665 );
6666 assert_eq!(5, cfg.loop_cfg.max_followups);
6667 }
6668
6669 #[test]
6671 fn only_out_of_scope_defects_qualify_at_the_defaults() {
6672 let cfg = cfg_with(Followups::Issues, false, false, 5);
6673 let qualifies = |f: &Finding| match f.severity {
6674 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking && f.in_scope,
6675 Severity::Nit => cfg.loop_cfg.file_nits && f.in_scope,
6676 Severity::Blocking => false,
6677 } || !f.in_scope;
6678
6679 assert!(qualifies(&finding(
6680 Severity::Blocking,
6681 "pre-existing",
6682 false
6683 )));
6684 assert!(!qualifies(&finding(
6685 Severity::NonBlocking,
6686 "improvement",
6687 true
6688 )));
6689 assert!(!qualifies(&finding(Severity::Nit, "taste", true)));
6690 assert!(!qualifies(&finding(
6691 Severity::Blocking,
6692 "fix it here",
6693 true
6694 )));
6695 }
6696
6697 #[test]
6698 fn opening_it_up_lets_non_blocking_findings_through_again() {
6699 let cfg = cfg_with(Followups::Issues, true, false, 5);
6700 assert!(cfg.loop_cfg.file_non_blocking);
6701 }
6702
6703 #[test]
6705 fn the_cap_is_a_real_backstop() {
6706 let cfg = cfg_with(Followups::Issues, false, false, 3);
6707 let mut state = IssueRun::new(1, "t");
6708 state.filed = (0..3).map(|n| format!("url{n}")).collect();
6709 assert!(state.filed.len() >= cfg.loop_cfg.max_followups);
6710 }
6711
6712 #[test]
6716 fn the_cap_bounds_what_one_run_can_spawn() {
6717 let cfg = cfg_with(Followups::Issues, false, false, 5);
6718 assert!(
6719 cfg.loop_cfg.max_followups <= 5,
6720 "a run that can file ten follow-ups is a branching process"
6721 );
6722 }
6723}
6724
6725#[cfg(test)]
6729mod followup_outcome_tests {
6730 use super::*;
6731
6732 const URL: &str = "https://github.com/you/thing/issues/485";
6733
6734 fn entry(recorded: Followup) -> Option<(Settled, String)> {
6735 filed_entry(&recorded, "It predates this branch.")
6736 }
6737
6738 #[test]
6742 fn a_failed_followup_settles_nothing() {
6743 assert_eq!(None, entry(Followup::Failed));
6744 }
6745
6746 #[test]
6747 fn an_uncertain_external_write_blocks_later_issue_followups_for_this_run() {
6748 let mut state = IssueRun::new(1, "review");
6749 let uncertain = SparError::uncertain_write("the result could not be verified");
6750 assert_eq!(Followup::Failed, failed_followup(&mut state, &uncertain));
6751 assert!(external_followup_write_paused(Followups::Issues, &state));
6752 assert!(!external_followup_write_paused(Followups::Local, &state));
6753 assert_eq!(1, state.notes.len());
6754
6755 assert_eq!(Followup::Failed, failed_followup(&mut state, &uncertain));
6756 assert_eq!(1, state.notes.len(), "the recovery note was duplicated");
6757
6758 let mut ordinary = IssueRun::new(2, "review");
6759 let error = SparError::new("permission denied");
6760 assert_eq!(Followup::Failed, failed_followup(&mut ordinary, &error));
6761 assert!(!external_followup_write_paused(
6762 Followups::Issues,
6763 &ordinary
6764 ));
6765 }
6766
6767 #[test]
6768 fn a_recorded_followup_is_filed_and_says_where() {
6769 let (outcome, reasoning) = entry(Followup::Recorded(URL.into())).unwrap();
6770 assert_eq!(Settled::Filed, outcome);
6771 assert!(
6772 reasoning.contains("It predates this branch."),
6773 "{reasoning}"
6774 );
6775 assert!(reasoning.contains("#485"), "{reasoning}");
6776 }
6777
6778 #[test]
6787 fn a_finding_that_matches_a_closed_issue_is_filed_again_with_the_reference() {
6788 let body = regression_body("The retry loop spins on 429.", 485);
6789 assert!(body.contains("The retry loop spins on 429."), "{body}");
6790 assert!(body.contains("#485"), "{body}");
6791 assert!(body.contains("closed"), "{body}");
6792
6793 let recorded = Followup::from(Filed::Opened(9, URL.into()));
6795 assert_eq!(Followup::Recorded(URL.into()), recorded);
6796 assert_eq!(Some(URL), recorded.url());
6797 }
6798
6799 #[test]
6802 fn an_open_issue_that_already_covers_the_point_is_still_a_reference() {
6803 for filed in [
6804 Filed::Opened(9, URL.into()),
6805 Filed::AddedTo(9, URL.into()),
6806 Filed::Covered(9, URL.into()),
6807 ] {
6808 assert_eq!(Some(URL), Followup::from(filed).url());
6809 }
6810 }
6811
6812 #[test]
6816 fn a_dropped_followup_is_settled_but_never_reported_as_filed() {
6817 let (outcome, reasoning) = entry(Followup::Dropped("follow-ups are off")).unwrap();
6818 assert_eq!(Settled::Dropped, outcome);
6819 assert!(reasoning.contains("follow-ups are off"), "{reasoning}");
6820 assert!(reasoning.contains("Not filed"), "{reasoning}");
6821 }
6822
6823 fn ledger_of(outcome: Settled, reasoning: &str) -> Ledger {
6824 let mut ledger = Ledger::new();
6825 ledger.insert(
6826 finding_key("A pre-existing leak", "src/x.rs"),
6827 LedgerEntry {
6828 title: "A pre-existing leak".into(),
6829 file: "src/x.rs".into(),
6830 reasoning: reasoning.into(),
6831 round: 1,
6832 reraised: 0,
6833 outcome,
6834 },
6835 );
6836 ledger
6837 }
6838
6839 #[test]
6843 fn the_settled_block_tells_a_filed_point_from_a_dropped_one() {
6844 let filed = settled_block(&ledger_of(Settled::Filed, "Tracked in #9."));
6845 assert!(filed.contains("out of scope here, and filed"), "{filed}");
6846
6847 let dropped = settled_block(&ledger_of(Settled::Dropped, "Not filed anywhere: off."));
6848 assert!(
6849 dropped.contains("out of scope here, and not filed"),
6850 "{dropped}"
6851 );
6852 assert!(dropped.contains("A pre-existing leak"), "{dropped}");
6853 }
6854
6855 #[test]
6858 fn a_deadlocked_point_that_was_never_filed_does_not_claim_to_be() {
6859 let points = [Finding {
6860 severity: Severity::Blocking,
6861 title: "A pre-existing leak".into(),
6862 detail: "d".into(),
6863 file: "src/x.rs".into(),
6864 in_scope: false,
6865 ..Default::default()
6866 }];
6867 let text = outcome_comment(
6868 &IssueRun::new(1, "t"),
6869 &ledger_of(Settled::Dropped, "Not filed anywhere: follow-ups are off."),
6870 &Ending::Deadlocked(&points),
6871 &Style::default(),
6872 )
6873 .unwrap();
6874 assert!(text.contains("not filed"), "{text}");
6875 assert!(!text.contains("Filed as out of scope"), "{text}");
6876 }
6877}
6878
6879#[cfg(test)]
6880mod issue_report_tests {
6881 use super::*;
6882 use crate::model::Severity;
6883
6884 fn reported() -> Finding {
6887 Finding {
6888 severity: Severity::Blocking,
6889 title: "sendPaymentAsync bypasses drain mode and spending limits".into(),
6890 detail: "The async path skips every admission check payInvoice applies.".into(),
6891 file: "src/node.ts:412".into(),
6892 in_scope: false,
6893 problem: Some(
6894 "`BeignetNode.sendPaymentAsync()` submits a payment directly to the Lightning \
6895 engine without applying the safeguards used by `payInvoice()`.\n\nThe async path \
6896 does not:\n\n- call `_checkDraining()`\n- call `_checkSpendLimit()`"
6897 .into(),
6898 ),
6899 reproduction: Some(
6900 "1. Create a `BeignetNode` with `dailySpendLimitSats: 1`.\n2. Enable drain mode.\n\
6901 3. Submit a 1,000 sat invoice.\n\nActual result:\n\n- The engine is called.\n\
6902 - `spentSats` remains 0."
6903 .into(),
6904 ),
6905 impact: Some(
6906 "An authorized client can submit async payments up to the available outbound \
6907 liquidity despite the configured limits."
6908 .into(),
6909 ),
6910 expected: Some(
6911 "- Reject new payments while draining.\n- Enforce the per-payment limit before \
6912 submission.\n- Cover both paths with regression tests.\n\nThis predates the \
6913 current branch."
6914 .into(),
6915 ),
6916 }
6917 }
6918
6919 #[test]
6920 fn a_reported_finding_becomes_a_bug_report() {
6921 let body = issue_report(&reported());
6922 for heading in [
6923 "## Problem",
6924 "## Reproduction",
6925 "## Impact",
6926 "## Expected behavior",
6927 ] {
6928 assert!(body.contains(heading), "missing {heading}:\n{body}");
6929 }
6930 let at = |h: &str| body.find(h).unwrap();
6932 assert!(at("## Problem") < at("## Reproduction"));
6933 assert!(at("## Reproduction") < at("## Impact"));
6934 assert!(at("## Impact") < at("## Expected behavior"));
6935 }
6936
6937 #[test]
6938 fn the_substance_survives_the_outbound_gates() {
6939 let repo_style = Style::default();
6940 let body = crate::style::issue_body(&issue_report(&reported()), &repo_style);
6941 for kept in [
6942 "_checkDraining()",
6943 "Actual result:",
6944 "outbound liquidity",
6945 "regression tests",
6946 "predates the current branch",
6947 ] {
6948 assert!(body.contains(kept), "the gate ate {kept:?}:\n{body}");
6949 }
6950 assert!(!body.contains("..."), "something was cut:\n{body}");
6951 }
6952
6953 #[test]
6956 fn an_ordinary_finding_is_still_just_its_detail() {
6957 let plain = Finding {
6958 severity: Severity::NonBlocking,
6959 title: "Name is vague".into(),
6960 detail: "The variable could say what it holds.".into(),
6961 file: "a.rs".into(),
6962 in_scope: true,
6963 ..Default::default()
6964 };
6965 assert_eq!(
6966 "The variable could say what it holds.",
6967 issue_report(&plain)
6968 );
6969 }
6970
6971 #[test]
6974 fn only_the_sections_that_were_written_appear() {
6975 let partial = Finding {
6976 problem: Some("The guard is inverted.".into()),
6977 expected: Some("It should reject rather than accept.".into()),
6978 ..reported()
6979 };
6980 let partial = Finding {
6981 reproduction: None,
6982 impact: None,
6983 ..partial
6984 };
6985 let body = issue_report(&partial);
6986 assert!(body.contains("## Problem") && body.contains("## Expected behavior"));
6987 assert!(!body.contains("## Reproduction"), "{body}");
6988 assert!(!body.contains("## Impact"), "{body}");
6989 }
6990
6991 #[test]
6994 fn the_summary_line_is_not_printed_twice() {
6995 let echoed = Finding {
6996 detail: "The guard is inverted so it rejects valid input.".into(),
6997 problem: Some("The guard is inverted so it rejects valid input.".into()),
6998 reproduction: None,
6999 impact: None,
7000 expected: None,
7001 ..reported()
7002 };
7003 let body = issue_report(&echoed);
7004 assert_eq!(1, body.matches("The guard is inverted").count(), "{body}");
7005 }
7006}