1use std::collections::BTreeSet;
21use std::path::{Path, PathBuf};
22
23use crate::agent::{self, Agent};
24use crate::comments::{issue_for_prompt, THREAD_RULE};
25use crate::config::{Call, Config, Drafts, Followups, PrComments};
26use crate::error::{ErrorKind, Result, SparError};
27use crate::jsonx::{exact_finding_key as finding_key, finding_file, stable_finding_key};
28use crate::model::{
29 Action, Disposition, Dispute, Finding, Followup, Implementation, Issue, IssueRun, Ledger,
30 LedgerEntry, NextAction, OwnFix, OwnFixDoc, PersistedState, PlanItem, PrView, ResponseDoc,
31 Review, Settled, Severity, SkippedItem, Status, Verdict, STATE_VERSION,
32};
33use crate::repo::Repo;
34use crate::style::{self, Style};
35use crate::{bail, log, logdim, logwarn, schema, spar_err};
36
37const IMPLEMENT_PROMPT: &str = "\
42Implement GitHub issue #{number} in this repository.
43
44Title: {title}
45URL: {url}
46
47{body}
48
49That is the issue as filed, followed by any comments on it since, oldest first.
50{thread_rule}
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 issue_text = issue_for_prompt(
773 issue,
774 cfg.loop_cfg.max_issue_chars,
775 cfg.loop_cfg.checkin_trust,
776 );
777 issue_text.report(&format!("#{number}"));
778 let body = issue_text.text;
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 text = issue_for_prompt(
1091 issue,
1092 cfg.loop_cfg.max_issue_chars,
1093 cfg.loop_cfg.checkin_trust,
1094 );
1095 text.report(&format!("PR #{pr_number}"));
1096 (text.text, issue.url.clone())
1097 }
1098 None => (String::new(), String::new()),
1099 };
1100
1101 let ctx = LoopCtx {
1102 work_dir,
1103 branch,
1104 pr_number,
1105 context: context_block(subject, &issue_url, &issue_body, &pr.body),
1106 label: format!("PR #{pr_number}"),
1107 subject,
1108 title: pr.title.clone(),
1109 start_round,
1110 holder,
1111 release: Release::Pr(pr_number),
1112 };
1113
1114 let outcome = review_loop(
1115 agents,
1116 cfg,
1117 repo,
1118 &ctx,
1119 &mut state,
1120 &mut ledger,
1121 open_findings,
1122 );
1123 if let Err(e) = outcome {
1124 state.status = Status::Error;
1125 state.notes.push(e.to_string());
1126 log!("PR #{pr_number} failed: {e}");
1127 }
1128 if should_release(cfg, state.status) {
1129 repo.release_pr_worktree(pr_number);
1130 }
1131 Ok(state)
1132}
1133
1134#[derive(Debug, Clone, Copy)]
1139enum Release {
1140 Issue(i64),
1141 Pr(i64),
1142}
1143
1144struct LoopCtx {
1145 work_dir: PathBuf,
1146 branch: String,
1147 pr_number: i64,
1148 label: String,
1149 subject: i64,
1150 title: String,
1151 context: String,
1154 start_round: u32,
1155 holder: String,
1156 release: Release,
1157}
1158
1159impl LoopCtx {
1160 fn release(&self, repo: &Repo) -> bool {
1161 match self.release {
1162 Release::Issue(n) => repo.worktree_remove(n),
1163 Release::Pr(n) => repo.release_pr_worktree(n),
1164 }
1165 }
1166}
1167
1168fn review_loop(
1169 agents: &[Agent],
1170 cfg: &Config,
1171 repo: &Repo,
1172 ctx: &LoopCtx,
1173 state: &mut IssueRun,
1174 ledger: &mut Ledger,
1175 mut open_findings: Vec<Finding>,
1176) -> Result<()> {
1177 let base = cfg.base_branch().to_string();
1178 let mut holder = ctx.holder.clone();
1182
1183 let (first, last_allowed) = round_window(ctx.start_round, cfg.loop_cfg.max_rounds);
1189 let mut published_head = checked_head(repo, &ctx.work_dir)?;
1190 persist(
1191 repo,
1192 ctx.pr_number,
1193 state,
1194 ledger,
1195 &open_findings,
1196 &published_head,
1197 first.saturating_sub(1),
1198 &holder,
1199 )?;
1200 let base_ref = repo.base_ref(&ctx.work_dir, &base);
1206
1207 let mut audited_head = String::new();
1211 let mut last_round = first.saturating_sub(1);
1212
1213 for round in first..=last_allowed {
1214 last_round = round;
1215 state.rounds = round;
1216 let reviewer = agent::find(agents, &holder)?;
1217 let effort = reviewer.effort(cfg, Call::Review(round)).about(ctx.subject);
1218 log!(
1219 "{}: round {round}, {holder} reviewing ({})",
1220 ctx.label,
1221 effort.describe()
1222 );
1223
1224 let prompt = review_prompt(
1225 &base_ref,
1226 ctx.subject,
1227 &ctx.title,
1228 &ctx.context,
1229 ledger,
1230 &open_findings,
1231 round,
1232 last_allowed,
1233 );
1234 let before_review = snapshot(repo, &ctx.work_dir);
1235 let review_baseline = repo.worktree_baseline(&ctx.work_dir)?;
1236 audited_head = before_review.head.clone();
1239 let review = reviewer.review::<Review>(
1240 &base_ref,
1241 &prompt,
1242 &schema::review(),
1243 &ctx.work_dir,
1244 &effort,
1245 );
1246 if let Err(error) = &review {
1247 if error.kind() == crate::error::ErrorKind::UncertainWrite {
1248 return Err(error.clone());
1249 }
1250 }
1251 repo.refuse_unrepresented_tracked_changes(&ctx.work_dir, &review_baseline)?;
1252 repo.refuse_read_only_leavings(&ctx.work_dir, &review_baseline)?;
1253 let review = review?;
1254 if repo.has_uncommitted_changes(&ctx.work_dir)? {
1255 bail!(
1256 "{}: {holder} left uncommitted files while reviewing. They were kept at {} and \
1257 the review did not continue.",
1258 ctx.label,
1259 ctx.work_dir.display()
1260 );
1261 }
1262
1263 let mut editor: Option<String> = None;
1267 let review_wrote = snapshot(repo, &ctx.work_dir) != before_review;
1268 if review_wrote {
1269 logwarn!(
1270 "{}: {holder} changed the branch while reviewing it, which the review prompt \
1271 forbids. Rolling it back.",
1272 ctx.label
1273 );
1274 if undo_edits(repo, &ctx.work_dir, &before_review).head != before_review.head {
1275 state
1276 .notes
1277 .push(format!("{holder} committed during its own review"));
1278 editor = Some(holder.clone());
1279 }
1280 }
1281
1282 let blocking = blocking_findings(&review.findings);
1283 if let Some(said) = verdict_disagreement(&review, blocking.len()) {
1284 logwarn!("{}: {holder} {said}", ctx.label);
1285 }
1286 update_open_findings(&mut open_findings, &blocking, !review_wrote);
1287
1288 if repo.style.pr_comments == PrComments::Rounds {
1289 if let Err(e) = repo.comment_pr(
1290 ctx.pr_number,
1291 &review_comment(&holder, round, &review, &repo.style),
1292 ) {
1293 logdim!("could not post the review comment: {e}");
1294 }
1295 }
1296
1297 file_out_of_scope(repo, &review.findings, ctx.subject, state, cfg);
1301 file_nonblocking(repo, &review.findings, ctx.subject, state, cfg);
1302 remove_findings(&mut state.noted, &blocking);
1303
1304 if check_relitigation(ledger, &blocking, state) {
1305 state.status = Status::Escalated;
1306 post_outcome(
1307 repo,
1308 ctx.pr_number,
1309 state,
1310 ledger,
1311 Ending::Deadlocked(&blocking),
1312 );
1313 persist(
1314 repo,
1315 ctx.pr_number,
1316 state,
1317 ledger,
1318 &open_findings,
1319 &published_head,
1320 round,
1321 &holder,
1322 )?;
1323 return Ok(());
1324 }
1325
1326 if approval_stands(&blocking, review_wrote) {
1327 open_findings.clear();
1328 return approve(
1329 cfg,
1330 repo,
1331 ctx,
1332 state,
1333 ledger,
1334 &published_head,
1335 round,
1336 &holder,
1337 );
1338 }
1339
1340 persist(
1343 repo,
1344 ctx.pr_number,
1345 state,
1346 ledger,
1347 &open_findings,
1348 &published_head,
1349 round,
1350 &holder,
1351 )?;
1352
1353 let mut edit_error = None;
1354
1355 if blocking.is_empty() {
1356 logwarn!(
1361 "{}: {holder} found nothing blocking on a branch it had changed itself, so the \
1362 approval does not carry.",
1363 ctx.label
1364 );
1365 state.notes.push(format!(
1366 "{holder} passed the branch in round {round} after editing it; the edit was rolled \
1367 back and the approval did not stand"
1368 ));
1369 } else if review.next_action == NextAction::FixMyself {
1370 log!("{}: {holder} fixing its own findings", ctx.label);
1371 let prompt = FIX_PROMPT.replace("{findings}", &findings_for_prompt(&blocking));
1372 let before_fix = repo.head_oid_checked(&ctx.work_dir)?;
1373 let worktree_baseline = repo.worktree_baseline(&ctx.work_dir)?;
1374 let mut report: Option<OwnFixDoc> = None;
1375 let fix_error = match reviewer.edit_json::<OwnFixDoc>(
1376 &prompt,
1377 &schema::own_fix(),
1378 &ctx.work_dir,
1379 &effort,
1380 ) {
1381 Ok(doc) => {
1382 commit_accepted_changes(
1383 cfg,
1384 repo,
1385 &ctx.work_dir,
1386 &worktree_baseline,
1387 &doc.summary,
1388 "Address blocking review findings",
1389 )?;
1390 report = Some(doc);
1391 None
1392 }
1393 Err(error) => Some(defer_clean_edit_error(
1394 repo,
1395 &ctx.work_dir,
1396 &worktree_baseline,
1397 error,
1398 )?),
1399 };
1400 match editor_after(repo, &ctx.work_dir, &before_fix, &ctx.label, &holder)? {
1401 Some(who) => {
1402 let fixes = report.as_ref().map(|d| d.fixes.as_slice()).unwrap_or(&[]);
1414 let (claimed, left) = split_own_fixes(&blocking, fixes);
1415 record_own_fixes(&claimed, ledger, state, round);
1416 remove_findings(&mut open_findings, &blocking);
1417 extend_findings(&mut open_findings, &left);
1418 if !left.is_empty() {
1419 state.notes.push(format!(
1420 "{holder} fixed {} of {} of its own findings in round {round}; the \
1421 rest stay open",
1422 claimed.len(),
1423 blocking.len()
1424 ));
1425 }
1426 editor = Some(who);
1427 }
1428 None if fix_error.is_none() => {
1429 repo.refuse_new_ignored_files(&ctx.work_dir, &worktree_baseline)?;
1430 logwarn!(
1433 "{}: {holder} said it would fix its own findings and committed nothing, \
1434 so it keeps the pull request.",
1435 ctx.label
1436 );
1437 state.notes.push(format!(
1438 "{holder} chose to fix its own findings in round {round} and committed \
1439 nothing"
1440 ));
1441 }
1442 None => {}
1443 }
1444 edit_error = fix_error;
1445 } else {
1446 let author_name = cfg.other(&holder);
1447 let author = agent::find(agents, &author_name)?;
1448 log!(
1449 "{}: handing {} finding(s) to {author_name}",
1450 ctx.label,
1451 blocking.len()
1452 );
1453 let prompt = RESPOND_PROMPT
1454 .replace("{number}", &ctx.subject.to_string())
1455 .replace("{context}", &ctx.context)
1456 .replace("{findings}", &findings_for_prompt(&blocking))
1457 .replace("{optional}", &optional_block(&review.findings));
1458 let before_response = repo.head_oid_checked(&ctx.work_dir)?;
1459 let worktree_baseline = repo.worktree_baseline(&ctx.work_dir)?;
1460 let response: Result<ResponseDoc> = author.edit_json(
1461 &prompt,
1462 &schema::response(),
1463 &ctx.work_dir,
1464 &author.effort(cfg, Call::Respond(round)).about(ctx.subject),
1465 );
1466 let response = match response {
1467 Ok(response) => {
1468 commit_accepted_changes(
1469 cfg,
1470 repo,
1471 &ctx.work_dir,
1472 &worktree_baseline,
1473 &response.summary,
1474 "Address blocking review findings",
1475 )?;
1476 Some(response)
1477 }
1478 Err(error) => {
1479 edit_error = Some(defer_clean_edit_error(
1480 repo,
1481 &ctx.work_dir,
1482 &worktree_baseline,
1483 error,
1484 )?);
1485 None
1486 }
1487 };
1488 if let Some(who) = editor_after(
1489 repo,
1490 &ctx.work_dir,
1491 &before_response,
1492 &ctx.label,
1493 &author_name,
1494 )? {
1495 editor = Some(who);
1496 } else if let Some(response) = &response {
1497 repo.refuse_new_ignored_files(&ctx.work_dir, &worktree_baseline)?;
1498 if response
1499 .dispositions
1500 .iter()
1501 .any(|d| d.action == Action::Fixed)
1502 {
1503 logwarn!(
1504 "{}: {author_name} reported fixes but committed nothing, so the diff does \
1505 not have them.",
1506 ctx.label
1507 );
1508 }
1509 }
1510 if let Some(response) = response {
1511 let mut used = vec![false; response.dispositions.len()];
1515 let unresolved = apply_dispositions(
1516 repo,
1517 cfg,
1518 &response,
1519 &blocking,
1520 ledger,
1521 state,
1522 round,
1523 ctx.subject,
1524 ctx.pr_number,
1525 &author_name,
1526 editor.is_some(),
1527 &mut used,
1528 );
1529 remove_findings(&mut open_findings, &blocking);
1530 extend_findings(&mut open_findings, &unresolved);
1531 apply_optional_dispositions(
1532 repo,
1533 cfg,
1534 &response,
1535 &review.findings,
1536 state,
1537 ctx.subject,
1538 &mut used,
1539 );
1540 warn_unplaced_dispositions(&response, &used);
1541 }
1542 }
1543
1544 if let Some(who) = &editor {
1545 check_before_push(
1546 cfg,
1547 repo,
1548 agent::find(agents, who)?,
1549 &ctx.work_dir,
1550 &ctx.label,
1551 Call::Respond(round),
1552 )?;
1553 }
1554 if editor.is_some() {
1555 repo.rewrite_commits_if_needed(&ctx.work_dir, &base, Some(&published_head))?;
1556 repo.push(&ctx.work_dir, &ctx.branch, Some(&published_head))?;
1557 published_head = checked_head(repo, &ctx.work_dir)?;
1558 }
1559 holder = next_reviewer(cfg, &holder, editor.as_deref());
1560 persist(
1561 repo,
1562 ctx.pr_number,
1563 state,
1564 ledger,
1565 &open_findings,
1566 &published_head,
1567 round,
1568 &holder,
1569 )?;
1570 if let Some(error) = edit_error {
1571 return Err(error);
1572 }
1573 }
1574
1575 close_out(
1582 agents,
1583 cfg,
1584 repo,
1585 ctx,
1586 state,
1587 ledger,
1588 &mut open_findings,
1589 &holder,
1590 last_round,
1591 &audited_head,
1592 &published_head,
1593 )
1594}
1595
1596#[allow(clippy::too_many_arguments)]
1607fn close_out(
1608 agents: &[Agent],
1609 cfg: &Config,
1610 repo: &Repo,
1611 ctx: &LoopCtx,
1612 state: &mut IssueRun,
1613 ledger: &mut Ledger,
1614 open_findings: &mut Vec<Finding>,
1615 holder: &str,
1616 round: u32,
1617 audited_head: &str,
1618 published_head: &str,
1619) -> Result<()> {
1620 let stop =
1621 |state: &mut IssueRun, ledger: &Ledger, open_findings: &[Finding], ending: Ending<'_>| {
1622 state.status = Status::Escalated;
1623 state.notes.push(exhausted_note(ctx.start_round, round));
1624 post_outcome(repo, ctx.pr_number, state, ledger, ending);
1625 persist(
1626 repo,
1627 ctx.pr_number,
1628 state,
1629 ledger,
1630 open_findings,
1631 published_head,
1632 round,
1633 holder,
1634 )
1635 };
1636
1637 let landed = (!audited_head.is_empty())
1644 .then(|| repo.commits_since(&ctx.work_dir, audited_head, "HEAD"))
1645 .flatten();
1646 if audited_head.is_empty()
1647 || (landed.as_ref().is_some_and(|l| l.is_empty()) && !any_fixes(ledger, round))
1648 {
1649 logdim!(
1650 "{}: nothing landed after the last review, so there is nothing to close over",
1651 ctx.label
1652 );
1653 if !open_findings.is_empty() {
1654 state.notes.push(unresolved_note(open_findings.len()));
1655 }
1656 stop(
1657 state,
1658 ledger,
1659 open_findings,
1660 ending_without_landing(open_findings),
1661 )?;
1662 return Ok(());
1663 }
1664
1665 let closer = agent::find(agents, holder)?;
1666 let effort = closer.effort(cfg, Call::Close).about(ctx.subject);
1667 log!(
1668 "{}: closing, {holder} checking what the last round left ({})",
1669 ctx.label,
1670 effort.describe()
1671 );
1672
1673 let prompt = close_prompt(
1674 &repo.base_ref(&ctx.work_dir, cfg.base_branch()),
1675 ctx.subject,
1676 &ctx.title,
1677 &ctx.context,
1678 audited_head,
1679 landed.as_deref(),
1680 ledger,
1681 open_findings,
1682 round,
1683 );
1684 let before = snapshot(repo, &ctx.work_dir);
1685 let closing_baseline = repo.worktree_baseline(&ctx.work_dir)?;
1686 let pass = closer.ask_json(&prompt, &schema::review(), &ctx.work_dir, &effort);
1690 if let Err(e) = &pass {
1691 if e.kind() == crate::error::ErrorKind::UncertainWrite {
1692 return Err(e.clone());
1693 }
1694 }
1695 repo.refuse_unrepresented_tracked_changes(&ctx.work_dir, &closing_baseline)?;
1696 repo.refuse_read_only_leavings(&ctx.work_dir, &closing_baseline)?;
1697 if repo.has_uncommitted_changes(&ctx.work_dir)? {
1698 bail!(
1699 "{}: {holder} left uncommitted files during the closing pass. They were kept at {} \
1700 and the pass did not continue.",
1701 ctx.label,
1702 ctx.work_dir.display()
1703 );
1704 }
1705
1706 let close_wrote = snapshot(repo, &ctx.work_dir) != before;
1709 let next = closing_next_actor(holder);
1713 if close_wrote {
1714 if let Err(error) = &pass {
1715 logwarn!(
1716 "{}: the closing pass failed after changing the branch: {error}",
1717 ctx.label
1718 );
1719 }
1720 logwarn!(
1721 "{}: {holder} changed the branch while closing, which the prompt forbids. Rolling it \
1722 back.",
1723 ctx.label
1724 );
1725 let after_undo = undo_edits(repo, &ctx.work_dir, &before);
1726 if after_undo != before {
1727 state.notes.push(format!(
1728 "{holder}'s closing-pass changes could not be fully rolled back"
1729 ));
1730 if after_undo.head != before.head {
1731 if let Some(reference) = preserve_closing_commit(repo, ctx) {
1732 state.notes.push(format!(
1733 "the closing pass commit was not pushed and remains at {reference}"
1734 ));
1735 }
1736 }
1737 }
1738 state.status = Status::Escalated;
1739 state.notes.push(format!(
1740 "{holder} edited the branch during the closing pass, so its answer did not stand"
1741 ));
1742 post_unread_outcome(repo, ctx.pr_number, state, ledger, open_findings);
1743 persist(
1744 repo,
1745 ctx.pr_number,
1746 state,
1747 ledger,
1748 open_findings,
1749 published_head,
1750 round,
1751 &next,
1752 )?;
1753 return Ok(());
1754 }
1755
1756 let pass: Review = match pass {
1757 Ok(pass) => pass,
1758 Err(e) => {
1759 logwarn!("{}: the closing pass failed: {e}", ctx.label);
1763 state.status = Status::Escalated;
1764 state.notes.push(exhausted_note(ctx.start_round, round));
1765 post_unread_outcome(repo, ctx.pr_number, state, ledger, open_findings);
1766 persist(
1767 repo,
1768 ctx.pr_number,
1769 state,
1770 ledger,
1771 open_findings,
1772 published_head,
1773 round,
1774 holder,
1775 )?;
1776 return Ok(());
1777 }
1778 };
1779
1780 file_out_of_scope(repo, &pass.findings, ctx.subject, state, cfg);
1781 file_nonblocking(repo, &pass.findings, ctx.subject, state, cfg);
1782
1783 let blocking = blocking_findings(&pass.findings);
1784 if let Some(said) = verdict_disagreement(&pass, blocking.len()) {
1785 logwarn!("{}: the closing pass {said}", ctx.label);
1786 }
1787 remove_findings(&mut state.noted, &blocking);
1788 update_open_findings(open_findings, &blocking, true);
1789
1790 if check_relitigation(ledger, &blocking, state) {
1791 state.status = Status::Escalated;
1792 post_outcome(
1793 repo,
1794 ctx.pr_number,
1795 state,
1796 ledger,
1797 Ending::Deadlocked(&blocking),
1798 );
1799 persist(
1800 repo,
1801 ctx.pr_number,
1802 state,
1803 ledger,
1804 open_findings,
1805 published_head,
1806 round,
1807 &next,
1808 )?;
1809 return Ok(());
1810 }
1811
1812 if approval_stands(&blocking, false) {
1813 open_findings.clear();
1814 return approve(cfg, repo, ctx, state, ledger, published_head, round, &next);
1815 }
1816
1817 state.status = Status::Escalated;
1818 state.notes.push(unresolved_note(open_findings.len()));
1819 post_outcome(
1820 repo,
1821 ctx.pr_number,
1822 state,
1823 ledger,
1824 Ending::Unresolved(open_findings),
1825 );
1826 persist(
1827 repo,
1828 ctx.pr_number,
1829 state,
1830 ledger,
1831 open_findings,
1832 published_head,
1833 round,
1834 &next,
1835 )?;
1836 Ok(())
1837}
1838
1839fn any_fixes(ledger: &Ledger, round: u32) -> bool {
1845 ledger
1846 .values()
1847 .any(|e| e.outcome == Settled::Fixed && e.round >= round)
1848}
1849
1850fn unresolved_note(left: usize) -> String {
1855 match left {
1856 1 => "one point left after the closing pass".to_string(),
1857 n => format!("{n} points left after the closing pass"),
1858 }
1859}
1860
1861fn ensure_reviewed_head(pr_number: i64, reviewed_head: &str, live_head: &str) -> Result<()> {
1867 if live_head == reviewed_head {
1868 return Ok(());
1869 }
1870 Err(spar_err!(
1871 "PR #{pr_number} changed from {reviewed_head} to {live_head} after it was reviewed; refusing to approve or merge an unread head"
1872 ))
1873}
1874
1875#[allow(clippy::too_many_arguments)]
1876fn approve(
1877 cfg: &Config,
1878 repo: &Repo,
1879 ctx: &LoopCtx,
1880 state: &mut IssueRun,
1881 ledger: &Ledger,
1882 published_head: &str,
1883 round: u32,
1884 holder: &str,
1885) -> Result<()> {
1886 let live_head = repo.pr_head_oid(ctx.pr_number)?;
1887 ensure_reviewed_head(ctx.pr_number, published_head, &live_head)?;
1888 state.status = Status::Approved;
1889 persist(
1890 repo,
1891 ctx.pr_number,
1892 state,
1893 ledger,
1894 &[],
1895 published_head,
1896 round,
1897 holder,
1898 )?;
1899 let live_head = repo.pr_head_oid(ctx.pr_number)?;
1900 ensure_reviewed_head(ctx.pr_number, published_head, &live_head)?;
1901 post_outcome(repo, ctx.pr_number, state, ledger, Ending::Approved);
1902 if cfg.loop_cfg.drafts == Drafts::UntilApproved && repo.mark_ready(ctx.pr_number) {
1906 log!("{}: out of draft", ctx.label);
1907 }
1908 if cfg.loop_cfg.auto_merge {
1909 let released = ctx.release(repo);
1913 if !released {
1914 log!(
1915 "{}: kept the worktree at {} and will leave its branch in place",
1916 ctx.label,
1917 ctx.work_dir.display()
1918 );
1919 }
1920 repo.merge_pr_at_head(ctx.pr_number, published_head, released)?;
1921 state.status = Status::Merged;
1922 repo.clear_state(ctx.pr_number); log!("{}: merged", ctx.label);
1924 } else {
1925 log!("{}: approved, awaiting human merge", ctx.label);
1926 }
1927 Ok(())
1928}
1929
1930fn approval_stands(blocking: &[Finding], review_wrote: bool) -> bool {
1938 blocking.is_empty() && !review_wrote
1939}
1940
1941fn next_reviewer(cfg: &Config, reviewer: &str, editor: Option<&str>) -> String {
1954 match editor {
1955 Some(editor) => cfg.other(editor),
1956 None => reviewer.to_string(),
1957 }
1958}
1959
1960fn defer_clean_edit_error(
1961 repo: &Repo,
1962 work_dir: &Path,
1963 baseline: &crate::repo::WorktreeBaseline,
1964 error: SparError,
1965) -> Result<SparError> {
1966 if error.kind() == ErrorKind::UncertainWrite {
1967 return Err(error);
1968 }
1969 repo.refuse_changed_attributes(work_dir, baseline)?;
1970 if repo.has_uncommitted_changes(work_dir)? {
1971 return Err(error);
1972 }
1973 repo.refuse_failed_edit_leavings(work_dir, baseline)?;
1974 repo.refuse_unrepresented_tracked_changes(work_dir, baseline)?;
1975 Ok(error)
1976}
1977
1978fn editor_after(
1986 repo: &Repo,
1987 work_dir: &Path,
1988 before_head: &str,
1989 label: &str,
1990 who: &str,
1991) -> Result<Option<String>> {
1992 if repo.has_uncommitted_changes(work_dir)? {
1993 bail!(
1994 "{label}: {who} left uncommitted files at {}. They were kept and the review did not \
1995 continue.",
1996 work_dir.display()
1997 );
1998 }
1999 let after_head = repo.head_oid_checked(work_dir)?;
2000 if after_head != before_head && !repo.is_ancestor_checked(work_dir, before_head, &after_head)? {
2001 bail!(
2002 "{label}: {who} moved HEAD to {after_head}, but it does not contain the previous \
2003 branch tip {before_head}. The worktree was kept for recovery and nothing was \
2004 published. Restore {before_head} or reapply the intended commits on top of it before \
2005 resuming."
2006 );
2007 }
2008 Ok((after_head != before_head).then(|| who.to_string()))
2009}
2010
2011fn round_window(start_round: u32, budget: u32) -> (u32, u32) {
2017 (start_round, start_round + budget.saturating_sub(1))
2018}
2019
2020fn spent(start_round: u32, last_round: u32) -> (u32, u32) {
2024 (last_round.saturating_sub(start_round) + 1, last_round)
2025}
2026
2027fn exhausted_note(start_round: u32, last_round: u32) -> String {
2028 let (this_run, total) = spent(start_round, last_round);
2029 if this_run == total {
2030 format!("no convergence after {this_run} rounds")
2031 } else {
2032 format!("no convergence after {this_run} more rounds ({total} in total)")
2033 }
2034}
2035
2036#[allow(clippy::too_many_arguments)]
2037fn persist(
2038 repo: &Repo,
2039 pr_number: i64,
2040 state: &IssueRun,
2041 ledger: &Ledger,
2042 open_findings: &[Finding],
2043 published_head: &str,
2044 round: u32,
2045 next_actor: &str,
2046) -> Result<()> {
2047 let payload = PersistedState {
2048 version: STATE_VERSION,
2049 checkpoint: 0,
2050 round,
2051 next_actor: next_actor.to_string(),
2052 status: state.status,
2053 pr_head: published_head.to_string(),
2054 ledger: ledger.clone(),
2055 filed: state.filed.clone(),
2056 open_findings: open_findings.to_vec(),
2057 disputes: state.disputes.clone(),
2058 noted: state.noted.clone(),
2059 };
2060 repo.write_state(pr_number, &payload)
2061}
2062
2063fn settled_block(ledger: &Ledger) -> String {
2068 if ledger.is_empty() {
2069 return String::new();
2070 }
2071 let lines: Vec<String> = ledger
2076 .values()
2077 .filter_map(|e| {
2078 let point = match e.file.trim() {
2079 "" => e.title.clone(),
2080 file => format!("{} ({file})", e.title),
2081 };
2082 match e.outcome {
2083 Settled::Refuted => Some(format!("- {point}: refuted because {}", e.reasoning)),
2084 Settled::Filed => Some(format!(
2085 "- {point}: out of scope here, and filed. {}",
2086 e.reasoning
2087 )),
2088 Settled::Dropped => Some(format!(
2089 "- {point}: out of scope here, and not filed. {}",
2090 e.reasoning
2091 )),
2092 Settled::Fixed => None,
2093 }
2094 })
2095 .collect();
2096 if lines.is_empty() {
2097 return String::new();
2098 }
2099 format!(
2100 "\nThe following points were already raised and settled, by a refutation or by a \
2101 follow-up issue. Treat them as settled. Do not raise them again unless you have new \
2102 evidence:\n{}",
2103 lines.join("\n")
2104 )
2105}
2106
2107fn fixed_lines(ledger: &Ledger, since: u32) -> Vec<String> {
2119 ledger
2120 .values()
2121 .filter(|e| e.outcome == Settled::Fixed && e.round >= since)
2122 .map(|e| {
2123 let claim = match e.reasoning.trim() {
2124 "" => "a committed change claims to address this point",
2125 reasoning => reasoning,
2126 };
2127 match e.file.trim() {
2128 "" => format!("- {}. Recorded answer: {claim}", e.title),
2129 file => format!("- {} ({file}). Recorded answer: {claim}", e.title),
2130 }
2131 })
2132 .collect()
2133}
2134
2135fn answers_block(ledger: &Ledger, round: u32) -> String {
2143 let lines = fixed_lines(ledger, round.saturating_sub(1));
2146 if lines.is_empty() {
2147 return String::new();
2148 }
2149 format!(
2150 "\nThese points were raised on this pull request in earlier rounds and the author says \
2151 it fixed them. The code is on the branch and the claim is the author's:\n{}\n\nCheck the \
2152 answer rather than taking it. If one of them is still not fixed, raise it again under \
2153 the same title, so the run can tell a point that was not answered from a new one.\n",
2154 lines.join("\n")
2155 )
2156}
2157
2158fn round_note(round: u32, last: u32) -> String {
2167 if round < last {
2168 return String::new();
2169 }
2170 "\nThis is the last round in this run that can ask the author for anything. After it, one \
2171 pass reads what landed and the pull request is either signed off or goes to a person with \
2172 what is left. Raise everything you mean to raise now. A point held back for a later round \
2173 does not get one.\n"
2174 .to_string()
2175}
2176
2177#[cfg(test)]
2182fn matching_ledger_key(ledger: &Ledger, title: &str, file: &str) -> Option<String> {
2183 matching_ledger_key_with_fallback(ledger, title, file, true)
2184}
2185
2186fn matching_ledger_key_with_fallback(
2187 ledger: &Ledger,
2188 title: &str,
2189 file: &str,
2190 allow_stable_fallback: bool,
2191) -> Option<String> {
2192 let exact = finding_key(title, file);
2193 if ledger.contains_key(&exact) {
2194 return Some(exact);
2195 }
2196 let legacy = crate::jsonx::finding_key(title, file);
2197 if ledger
2198 .get(&legacy)
2199 .is_some_and(|entry| same_finding_parts(&entry.title, &entry.file, title, file))
2200 {
2201 return Some(legacy);
2202 }
2203 if !allow_stable_fallback {
2204 return None;
2205 }
2206
2207 let stable = stable_finding_key(title, file);
2208 let path = finding_file(file);
2209 let mut matches = ledger
2210 .iter()
2211 .filter(|(saved_key, entry)| {
2212 if stable_finding_key(&entry.title, &entry.file) == stable {
2213 return true;
2214 }
2215 finding_file(&entry.file) == path
2216 && saved_key.as_str() == crate::jsonx::finding_key(title, &entry.file)
2217 })
2218 .map(|(key, _)| key.clone());
2219 let first = matches.next()?;
2220 matches.next().is_none().then_some(first)
2221}
2222
2223fn matching_ledger_entry<'a>(
2224 ledger: &'a Ledger,
2225 title: &str,
2226 file: &str,
2227) -> Option<&'a LedgerEntry> {
2228 matching_ledger_entry_with_fallback(ledger, title, file, true)
2229}
2230
2231fn matching_ledger_entry_with_fallback<'a>(
2232 ledger: &'a Ledger,
2233 title: &str,
2234 file: &str,
2235 allow_stable_fallback: bool,
2236) -> Option<&'a LedgerEntry> {
2237 let key = matching_ledger_key_with_fallback(ledger, title, file, allow_stable_fallback)?;
2238 ledger.get(&key)
2239}
2240
2241fn settle(
2242 ledger: &mut Ledger,
2243 title: &str,
2244 file: &str,
2245 allow_stable_fallback: bool,
2246 entry: LedgerEntry,
2247) {
2248 let old_key = matching_ledger_key_with_fallback(ledger, title, file, allow_stable_fallback);
2249 let reraised = old_key
2250 .as_ref()
2251 .and_then(|key| ledger.get(key))
2252 .map(|entry| entry.reraised)
2253 .unwrap_or(0);
2254 if let Some(old_key) = old_key {
2255 ledger.remove(&old_key);
2256 }
2257 ledger.insert(finding_key(title, file), LedgerEntry { reraised, ..entry });
2258}
2259
2260fn normalise_ledger_keys(ledger: &mut Ledger) {
2262 let mut normalised = Ledger::new();
2263 for (saved_key, mut entry) in std::mem::take(ledger) {
2264 let key = if saved_key.len() == 12
2265 && saved_key
2266 .chars()
2267 .all(|character| character.is_ascii_hexdigit())
2268 {
2269 saved_key
2270 } else {
2271 finding_key(&entry.title, &entry.file)
2272 };
2273 if let Some(previous) = normalised.get_mut(&key) {
2274 let reraised = previous.reraised.max(entry.reraised);
2275 if entry.round >= previous.round {
2276 entry.reraised = reraised;
2277 *previous = entry;
2278 } else {
2279 previous.reraised = reraised;
2280 }
2281 } else {
2282 normalised.insert(key, entry);
2283 }
2284 }
2285 *ledger = normalised;
2286}
2287
2288pub(crate) fn verdict_disagreement(review: &Review, blocking: usize) -> Option<String> {
2298 match (review.verdict, blocking, review.next_action) {
2299 (Verdict::Approve, n, _) if n > 0 => Some(format!(
2300 "the review says approve and raises {n} blocking finding(s); the findings decide"
2301 )),
2302 (Verdict::ChangesRequested, 0, NextAction::Merge) => Some(
2303 "the review says changes_requested, raises nothing blocking, and asks to merge; the \
2304 findings decide"
2305 .to_string(),
2306 ),
2307 _ => None,
2308 }
2309}
2310
2311fn blocking_findings(findings: &[Finding]) -> Vec<Finding> {
2312 let mut kept = Vec::new();
2313 for finding in findings.iter().filter(|finding| finding.blocks()) {
2314 if let Some(existing) = kept
2315 .iter_mut()
2316 .find(|existing| same_finding(existing, finding))
2317 {
2318 *existing = finding.clone();
2319 } else {
2320 kept.push(finding.clone());
2321 }
2322 }
2323 kept
2324}
2325
2326fn matching_finding_index(
2327 findings: &[Finding],
2328 target: &Finding,
2329 allow_stable_fallback: bool,
2330) -> Option<usize> {
2331 if let Some(index) = findings
2332 .iter()
2333 .position(|finding| same_finding(finding, target))
2334 {
2335 return Some(index);
2336 }
2337 if !allow_stable_fallback {
2338 return None;
2339 }
2340
2341 let stable = stable_finding_key(&target.title, &target.file);
2342 let mut matches = findings
2343 .iter()
2344 .enumerate()
2345 .filter(|(_, finding)| stable_finding_key(&finding.title, &finding.file) == stable);
2346 let first = matches.next().map(|(index, _)| index);
2347 first.filter(|_| matches.next().is_none())
2348}
2349
2350fn unique_stable_finding(findings: &[Finding], target: &Finding) -> bool {
2351 let stable = stable_finding_key(&target.title, &target.file);
2352 findings
2353 .iter()
2354 .filter(|finding| stable_finding_key(&finding.title, &finding.file) == stable)
2355 .count()
2356 == 1
2357}
2358
2359fn extend_findings(target: &mut Vec<Finding>, additions: &[Finding]) {
2361 for finding in additions {
2362 if let Some(index) =
2363 matching_finding_index(target, finding, unique_stable_finding(additions, finding))
2364 {
2365 target[index] = finding.clone();
2366 } else {
2367 target.push(finding.clone());
2368 }
2369 }
2370}
2371
2372fn update_open_findings(
2373 open_findings: &mut Vec<Finding>,
2374 current: &[Finding],
2375 answer_stands: bool,
2376) {
2377 if answer_stands {
2378 *open_findings = current.to_vec();
2379 } else {
2380 extend_findings(open_findings, current);
2381 }
2382}
2383
2384fn remove_findings(target: &mut Vec<Finding>, removed: &[Finding]) {
2385 for finding in removed {
2386 if let Some(index) =
2387 matching_finding_index(target, finding, unique_stable_finding(removed, finding))
2388 {
2389 target.remove(index);
2390 }
2391 }
2392}
2393
2394fn ending_without_landing(open_findings: &[Finding]) -> Ending<'_> {
2395 if open_findings.is_empty() {
2396 Ending::Unchanged
2397 } else {
2398 Ending::Unresolved(open_findings)
2399 }
2400}
2401
2402fn closing_next_actor(holder: &str) -> String {
2403 holder.to_string()
2404}
2405
2406fn remember_noted(state: &mut IssueRun, finding: &Finding, allow_stable_fallback: bool) {
2419 if let Some(index) = matching_finding_index(&state.noted, finding, allow_stable_fallback) {
2420 state.noted[index] = finding.clone();
2421 } else {
2422 state.noted.push(finding.clone());
2423 }
2424}
2425
2426fn forget_noted(state: &mut IssueRun, finding: &Finding, allow_stable_fallback: bool) {
2427 if let Some(index) = matching_finding_index(&state.noted, finding, allow_stable_fallback) {
2428 state.noted.remove(index);
2429 }
2430}
2431
2432fn remember_dispute(state: &mut IssueRun, dispute: Dispute, allow_stable_fallback: bool) {
2433 let exact = finding_key(&dispute.title, &dispute.file);
2434 let stable = stable_finding_key(&dispute.title, &dispute.file);
2435 let exact_index = state
2436 .disputes
2437 .iter()
2438 .position(|kept| finding_key(&kept.title, &kept.file) == exact);
2439 let stable_index = if exact_index.is_none() && allow_stable_fallback {
2440 let mut matches = state
2441 .disputes
2442 .iter()
2443 .enumerate()
2444 .filter(|(_, kept)| stable_finding_key(&kept.title, &kept.file) == stable);
2445 let first = matches.next().map(|(index, _)| index);
2446 first.filter(|_| matches.next().is_none())
2447 } else {
2448 None
2449 };
2450 if let Some(index) = exact_index.or(stable_index) {
2451 state.disputes[index] = dispute;
2452 } else {
2453 state.disputes.push(dispute);
2454 }
2455}
2456
2457fn forget_dispute(state: &mut IssueRun, finding: &Finding, allow_stable_fallback: bool) {
2458 let exact = finding_key(&finding.title, &finding.file);
2459 if let Some(index) = state
2460 .disputes
2461 .iter()
2462 .position(|kept| finding_key(&kept.title, &kept.file) == exact)
2463 {
2464 state.disputes.remove(index);
2465 return;
2466 }
2467 if !allow_stable_fallback {
2468 return;
2469 }
2470
2471 let stable = stable_finding_key(&finding.title, &finding.file);
2472 let mut matches = state
2473 .disputes
2474 .iter()
2475 .enumerate()
2476 .filter(|(_, kept)| stable_finding_key(&kept.title, &kept.file) == stable);
2477 let first = matches.next().map(|(index, _)| index);
2478 if let Some(index) = first.filter(|_| matches.next().is_none()) {
2479 state.disputes.remove(index);
2480 }
2481}
2482
2483#[cfg(test)]
2484fn record_nonblocking_outcome(state: &mut IssueRun, finding: &Finding, outcome: Option<&Followup>) {
2485 record_nonblocking_outcome_with_match(state, finding, outcome, true);
2486}
2487
2488fn record_nonblocking_outcome_with_match(
2489 state: &mut IssueRun,
2490 finding: &Finding,
2491 outcome: Option<&Followup>,
2492 allow_stable_fallback: bool,
2493) {
2494 forget_dispute(state, finding, allow_stable_fallback);
2495 if let Some(Followup::Recorded(url)) = outcome {
2496 if !state.filed.iter().any(|filed| filed == url) {
2497 state.filed.push(url.clone());
2498 }
2499 forget_noted(state, finding, allow_stable_fallback);
2500 } else {
2501 remember_noted(state, finding, allow_stable_fallback);
2502 }
2503}
2504
2505fn split_own_fixes(blocking: &[Finding], fixes: &[OwnFix]) -> (Vec<Finding>, Vec<Finding>) {
2512 let mut claimed = Vec::new();
2513 let mut left = Vec::new();
2514 let mut used = vec![false; fixes.len()];
2515 for finding in blocking {
2516 match matching_disposition(finding, blocking, fixes) {
2517 Ok((index, fix)) if !used[index] => {
2518 used[index] = true;
2519 if fix.fixed {
2520 claimed.push(finding.clone());
2521 continue;
2522 }
2523 let mut open = finding.clone();
2524 let reason = fix.reason.trim();
2525 if !reason.is_empty() {
2526 open.detail = format!(
2527 "{}\n\nThe reviewer chose to fix this itself and did not: {reason}",
2528 open.detail.trim()
2529 );
2530 }
2531 logwarn!("'{}' was not fixed: {reason}", finding.title);
2532 left.push(open);
2533 }
2534 Ok(_) => {
2535 logwarn!(
2536 "'{}' has more than one matching entry in the fix report, so it stays open",
2537 finding.title
2538 );
2539 left.push(finding.clone());
2540 }
2541 Err(reason) => {
2542 logwarn!(
2543 "'{}' has {reason} in the fix report, so it stays open",
2544 finding.title
2545 );
2546 left.push(finding.clone());
2547 }
2548 }
2549 }
2550 (claimed, left)
2551}
2552
2553fn record_own_fixes(blocking: &[Finding], ledger: &mut Ledger, state: &mut IssueRun, round: u32) {
2560 for finding in blocking {
2561 let allow_stable_fallback = unique_stable_finding(blocking, finding);
2562 settle(
2563 ledger,
2564 &finding.title,
2565 &finding.file,
2566 allow_stable_fallback,
2567 LedgerEntry {
2568 title: finding.title.clone(),
2569 file: finding.file.clone(),
2570 reasoning: "a committed change was made for this point".to_string(),
2571 round,
2572 reraised: 0,
2573 outcome: Settled::Fixed,
2574 },
2575 );
2576 forget_noted(state, finding, allow_stable_fallback);
2577 forget_dispute(state, finding, allow_stable_fallback);
2578 }
2579}
2580
2581fn check_relitigation(ledger: &mut Ledger, blocking: &[Finding], state: &mut IssueRun) -> bool {
2584 let mut escalate = false;
2585 let mut counted: BTreeSet<String> = BTreeSet::new();
2591 for finding in blocking {
2592 let allow_stable_fallback = unique_stable_finding(blocking, finding);
2593 let Some(key) = matching_ledger_key_with_fallback(
2594 ledger,
2595 &finding.title,
2596 &finding.file,
2597 allow_stable_fallback,
2598 ) else {
2599 continue;
2600 };
2601 if !counted.insert(key.clone()) {
2602 continue;
2603 }
2604 if let Some(entry) = ledger.get_mut(&key) {
2605 entry.reraised += 1;
2606 if entry.outcome == Settled::Refuted {
2607 remember_dispute(
2608 state,
2609 Dispute {
2610 title: finding.title.clone(),
2611 file: finding.file.clone(),
2612 reasoning: entry.reasoning.clone(),
2613 },
2614 allow_stable_fallback,
2615 );
2616 }
2617 if entry.reraised >= 2 {
2618 state.notes.push(format!(
2619 "'{}' {}",
2620 finding.title,
2621 why_escalated(entry.outcome)
2622 ));
2623 escalate = true;
2624 }
2625 }
2626 }
2627 escalate
2628}
2629
2630fn why_escalated(outcome: Settled) -> &'static str {
2637 match outcome {
2638 Settled::Fixed => "was fixed twice and raised again; escalating.",
2639 _ => "was settled and re-raised twice; escalating.",
2640 }
2641}
2642
2643fn normalise(text: &str) -> String {
2644 crate::jsonx::untagged_title(text)
2645 .to_lowercase()
2646 .chars()
2647 .filter(|c| c.is_ascii_alphanumeric() || c.is_whitespace())
2648 .collect::<String>()
2649 .split_whitespace()
2650 .collect::<Vec<_>>()
2651 .join(" ")
2652}
2653
2654pub(crate) fn same_point(a: &str, b: &str) -> bool {
2656 normalise(a) == normalise(b)
2657}
2658
2659pub(crate) fn same_finding(a: &Finding, b: &Finding) -> bool {
2660 same_finding_parts(&a.title, &a.file, &b.title, &b.file)
2661}
2662
2663pub(crate) fn same_finding_parts(a_title: &str, a_file: &str, b_title: &str, b_file: &str) -> bool {
2664 finding_key(a_title, a_file) == finding_key(b_title, b_file)
2665}
2666
2667pub(crate) trait Answer {
2673 fn title(&self) -> &str;
2674 fn file(&self) -> &str;
2675}
2676
2677impl Answer for Disposition {
2678 fn title(&self) -> &str {
2679 &self.title
2680 }
2681 fn file(&self) -> &str {
2682 &self.file
2683 }
2684}
2685
2686impl Answer for OwnFix {
2687 fn title(&self) -> &str {
2688 &self.title
2689 }
2690 fn file(&self) -> &str {
2691 &self.file
2692 }
2693}
2694
2695fn answer_matches<A: Answer>(finding: &Finding, answer: &A) -> bool {
2696 same_point(&finding.title, answer.title()) && finding.file.trim() == answer.file().trim()
2697}
2698
2699fn stable_answer_matches<A: Answer>(finding: &Finding, answer: &A) -> bool {
2700 stable_finding_key(&finding.title, &finding.file)
2701 == stable_finding_key(answer.title(), answer.file())
2702}
2703
2704fn sole_answer_match<'a, A: Answer>(
2705 finding: &Finding,
2706 answers: &'a [A],
2707 matches: impl Fn(&Finding, &A) -> bool,
2708) -> std::result::Result<Option<(usize, &'a A)>, &'static str> {
2709 let mut matched = answers
2710 .iter()
2711 .enumerate()
2712 .filter(|(_, answer)| matches(finding, answer));
2713 let first = matched.next();
2714 if matched.next().is_some() {
2715 return Err("more than one matching disposition");
2716 }
2717 Ok(first)
2718}
2719
2720fn matching_disposition<'a, A: Answer>(
2725 finding: &Finding,
2726 findings: &[Finding],
2727 answers: &'a [A],
2728) -> std::result::Result<(usize, &'a A), &'static str> {
2729 if let Some(exact) = sole_answer_match(finding, answers, answer_matches)? {
2730 return Ok(exact);
2731 }
2732 if unique_stable_finding(findings, finding) {
2733 if let Some(stable) = sole_answer_match(finding, answers, stable_answer_matches)? {
2734 return Ok(stable);
2735 }
2736 }
2737 Err("no matching disposition")
2738}
2739
2740fn fixed_disposition_resolves(committed: bool) -> bool {
2741 committed
2742}
2743
2744#[allow(clippy::too_many_arguments)]
2745fn apply_dispositions(
2746 repo: &Repo,
2747 cfg: &Config,
2748 response: &ResponseDoc,
2749 blocking: &[Finding],
2750 ledger: &mut Ledger,
2751 state: &mut IssueRun,
2752 round: u32,
2753 subject: i64,
2754 pr_number: i64,
2755 author: &str,
2756 committed: bool,
2757 used: &mut [bool],
2758) -> Vec<Finding> {
2759 let mut fixed = Vec::new();
2760 let mut refuted = Vec::new();
2761 let mut filed = Vec::new();
2762 let mut unresolved = Vec::new();
2763
2764 for source in blocking {
2765 let (index, d) = match matching_disposition(source, blocking, &response.dispositions) {
2766 Ok((index, disposition)) if !used[index] => (index, disposition),
2767 Ok(_) => {
2768 logwarn!(
2769 "'{}' has more than one matching disposition, so it stays open",
2770 source.title
2771 );
2772 unresolved.push(source.clone());
2773 continue;
2774 }
2775 Err(reason) => {
2776 logwarn!("'{}' has {reason}, so it stays open", source.title);
2777 unresolved.push(source.clone());
2778 continue;
2779 }
2780 };
2781 used[index] = true;
2782 let file = source.file.clone();
2783 let canonical = source.title.as_str();
2787 let title = style::title(canonical, &repo.style);
2788 let located_title = match file.trim() {
2789 "" => title.clone(),
2790 location => format!("{title} ({location})"),
2791 };
2792 let allow_stable_fallback = unique_stable_finding(blocking, source);
2793
2794 match d.action {
2795 Action::Refuted => {
2796 let reasoning = style::summary(&d.reasoning, &repo.style);
2797 settle(
2798 ledger,
2799 canonical,
2800 &file,
2801 allow_stable_fallback,
2802 LedgerEntry {
2803 title: canonical.to_string(),
2804 file: file.clone(),
2805 reasoning: reasoning.clone(),
2806 round,
2807 reraised: 0,
2808 outcome: Settled::Refuted,
2809 },
2810 );
2811 remember_dispute(
2812 state,
2813 Dispute {
2814 title: canonical.to_string(),
2815 file: file.clone(),
2816 reasoning: reasoning.clone(),
2817 },
2818 allow_stable_fallback,
2819 );
2820 forget_noted(state, source, allow_stable_fallback);
2821 refuted.push(format!("{located_title}. {reasoning}"));
2822 }
2823 Action::FiledIssue => {
2824 let new_title = d
2825 .new_issue_title
2826 .clone()
2827 .filter(|t| !t.trim().is_empty())
2828 .unwrap_or_else(|| d.title.clone());
2829 let new_body = d
2830 .new_issue_body
2831 .clone()
2832 .filter(|b| !b.trim().is_empty())
2833 .unwrap_or_else(|| d.reasoning.clone());
2834 let recorded = file_followup(repo, &new_title, &new_body, subject, cfg, state);
2835 if let Some(url) = recorded.url() {
2836 state.filed.push(url.to_string());
2837 filed.push(url.to_string());
2838 }
2839 let Some((outcome, reasoning)) =
2848 filed_entry(&recorded, &style::summary(&d.reasoning, &repo.style))
2849 else {
2850 logwarn!(
2851 "'{title}' was not recorded anywhere, so it stays open for the next round"
2852 );
2853 unresolved.push(source.clone());
2854 continue;
2855 };
2856 settle(
2857 ledger,
2858 canonical,
2859 &file,
2860 allow_stable_fallback,
2861 LedgerEntry {
2862 title: canonical.to_string(),
2863 file: file.clone(),
2864 reasoning,
2865 round,
2866 reraised: 0,
2867 outcome,
2868 },
2869 );
2870 if outcome == Settled::Dropped {
2871 remember_noted(state, source, allow_stable_fallback);
2872 } else {
2873 forget_noted(state, source, allow_stable_fallback);
2874 }
2875 forget_dispute(state, source, allow_stable_fallback);
2876 }
2877 Action::Fixed => {
2878 if fixed_disposition_resolves(committed) {
2893 settle(
2894 ledger,
2895 canonical,
2896 &file,
2897 allow_stable_fallback,
2898 LedgerEntry {
2899 title: canonical.to_string(),
2900 file: file.clone(),
2901 reasoning: style::summary(&d.reasoning, &repo.style),
2902 round,
2903 reraised: 0,
2904 outcome: Settled::Fixed,
2905 },
2906 );
2907 forget_noted(state, source, allow_stable_fallback);
2908 forget_dispute(state, source, allow_stable_fallback);
2909 fixed.push(located_title);
2910 } else {
2911 unresolved.push(source.clone());
2912 }
2913 }
2914 }
2915 }
2916
2917 if repo.style.pr_comments == PrComments::Rounds {
2918 let comment = disposition_comment(author, response, &fixed, &refuted, &filed, &repo.style);
2919 if let Some(text) = comment {
2920 if let Err(e) = repo.comment_pr(pr_number, &text) {
2921 logdim!("could not post the disposition comment: {e}");
2922 }
2923 }
2924 }
2925 unresolved
2926}
2927
2928fn filed_entry(recorded: &Followup, reasoning: &str) -> Option<(Settled, String)> {
2936 let (outcome, tail) = match recorded {
2937 Followup::Recorded(reference) => (
2938 Settled::Filed,
2939 format!("Tracked in {}.", as_reference(reference)),
2940 ),
2941 Followup::Covered(reference) => (
2942 Settled::Filed,
2943 format!("Already covered by {}.", as_reference(reference)),
2944 ),
2945 Followup::Dropped(why) => (Settled::Dropped, format!("Not filed anywhere: {why}.")),
2946 Followup::Failed => return None,
2947 };
2948 let reasoning = match reasoning.trim() {
2949 "" => tail,
2950 said => format!("{said} {tail}"),
2951 };
2952 Some((outcome, reasoning))
2953}
2954
2955fn external_followup_write_paused(destination: Followups, state: &IssueRun) -> bool {
2962 destination == Followups::Issues && state.followup_writes_uncertain
2963}
2964
2965fn failed_followup(state: &mut IssueRun, error: &SparError) -> Followup {
2966 if error.kind() == ErrorKind::UncertainWrite {
2967 state.followup_writes_uncertain = true;
2968 if !state
2969 .notes
2970 .iter()
2971 .any(|note| note.contains("external follow-up writes were paused"))
2972 {
2973 state.notes.push(
2974 "An external follow-up write could not be verified, so further external \
2975 follow-up writes were paused for this run. Inspect recent issues and comments \
2976 before trying them again."
2977 .to_string(),
2978 );
2979 }
2980 }
2981 Followup::Failed
2982}
2983
2984pub fn file_followup(
2995 repo: &Repo,
2996 title: &str,
2997 body: &str,
2998 source: i64,
2999 cfg: &Config,
3000 state: &mut IssueRun,
3001) -> Followup {
3002 if repo.followups == Followups::None {
3003 return Followup::Dropped("follow-ups are off for this repository");
3004 }
3005 if external_followup_write_paused(repo.followups, state) {
3006 logdim!(
3007 "not attempting another external follow-up write after an earlier result could not \
3008 be verified"
3009 );
3010 return Followup::Failed;
3011 }
3012 if state.filed.len() >= cfg.loop_cfg.max_followups {
3015 logwarn!(
3016 "already recorded {} follow-ups, not recording '{}'. Raise max_followups if you want \
3017 them all.",
3018 state.filed.len(),
3019 style::title(title, &repo.style)
3020 );
3021 return Followup::Dropped("this run had already recorded as many follow-ups as it may");
3022 }
3023 let title = match repo.clean_followup_title(title) {
3030 Ok(title) => title,
3031 Err(e) => {
3032 logdim!("could not clean a follow-up title: {e}");
3033 return Followup::Failed;
3034 }
3035 };
3036 if title.trim().is_empty() {
3037 logdim!("nothing left of a follow-up title after cleaning it");
3038 return Followup::Failed;
3039 }
3040 let body = format!(
3043 "{}\n\nFound while working on #{source}.",
3044 style::issue_body(body, &repo.style)
3045 );
3046
3047 if repo.followups == Followups::Local {
3048 return repo.append_local_followup(&title, &body);
3049 }
3050
3051 match file_as_issue(repo, &title, &body) {
3052 Ok(filed) => filed.into(),
3053 Err(e) => {
3054 logdim!("could not file a follow-up for '{title}': {e}");
3055 failed_followup(state, &e)
3056 }
3057 }
3058}
3059
3060#[derive(Debug, Clone)]
3062pub enum Filed {
3063 Opened(i64, String),
3065 AddedTo(i64, String),
3067 Covered(i64, String),
3069}
3070
3071impl From<Filed> for Followup {
3072 fn from(filed: Filed) -> Self {
3073 match filed {
3074 Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => {
3075 Followup::Recorded(url)
3076 }
3077 }
3078 }
3079}
3080
3081impl Filed {
3082 pub fn url(&self) -> Option<&str> {
3083 match self {
3084 Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => Some(url),
3085 }
3086 }
3087
3088 pub fn issue(&self) -> i64 {
3091 match self {
3092 Filed::Opened(n, _) | Filed::AddedTo(n, _) | Filed::Covered(n, _) => *n,
3093 }
3094 }
3095
3096 pub fn number(&self) -> Option<i64> {
3098 match self {
3099 Filed::Opened(n, _) | Filed::AddedTo(n, _) | Filed::Covered(n, _) => Some(*n),
3100 }
3101 }
3102
3103 pub fn note(&self) -> String {
3105 match self {
3106 Filed::Opened(n, _) => format!("#{n}"),
3107 Filed::AddedTo(n, _) => format!("added to #{n}"),
3108 Filed::Covered(n, _) => format!("#{n} already says this"),
3109 }
3110 }
3111
3112 pub fn describe(&self, title: &str) -> String {
3113 let title = style::clip(title.trim(), 80);
3114 match self {
3115 Filed::Opened(n, _) => format!("filed #{n}: {title}"),
3116 Filed::AddedTo(n, _) => format!("added to #{n}: {title}"),
3117 Filed::Covered(n, _) => format!("#{n} already says this: {title}"),
3118 }
3119 }
3120}
3121
3122pub fn file_as_issue(repo: &Repo, title: &str, body: &str) -> Result<Filed> {
3134 file_as_issue_apart_from(repo, title, body, None)
3135}
3136
3137pub fn file_as_issue_apart_from(
3143 repo: &Repo,
3144 title: &str,
3145 body: &str,
3146 apart_from: Option<i64>,
3147) -> Result<Filed> {
3148 let title = repo.record_failed_write(repo.clean_title(title))?;
3149 if title.trim().is_empty() {
3150 return repo.record_failed_write(Err(spar_err!(
3151 "nothing left of the title after cleaning it"
3152 )));
3153 }
3154 let issue_body = repo.record_failed_write(repo.clean_issue_body(body))?;
3155 let exact =
3156 repo.record_failed_write(repo.try_exact_issue_apart_from(&title, &issue_body, apart_from))?;
3157 let mut body_to_file = issue_body.clone();
3158 if let Some(existing) = exact {
3159 if existing.open {
3160 logdim!("'{title}' is already open as #{}", existing.number);
3161 return Ok(Filed::Covered(existing.number, existing.url));
3162 }
3163 logdim!(
3169 "'{title}' matches #{}, which is closed, so it is being filed again",
3170 existing.number
3171 );
3172 body_to_file = regression_body(&issue_body, existing.number);
3173 } else {
3174 let similar = repo.record_failed_write(repo.try_find_similar_issue_apart_from(
3175 &title,
3176 &issue_body,
3177 apart_from,
3178 ))?;
3179 if let Some(existing) = similar {
3180 let known = format!("{} {}", existing.title, existing.body);
3181 if crate::textsim::adds_information(&issue_body, &known) {
3182 logdim!(
3183 "'{title}' is covered by #{} and adds something, so it goes there as a \
3184 comment",
3185 existing.number
3186 );
3187 repo.comment_issue(existing.number, &issue_body)?;
3188 return Ok(Filed::AddedTo(existing.number, existing.url));
3189 }
3190 logdim!(
3191 "'{title}' is covered by #{} '{}', which already says it",
3192 existing.number,
3193 style::clip(&existing.title, 60)
3194 );
3195 return Ok(Filed::Covered(existing.number, existing.url));
3196 }
3197 }
3198 let url = repo.create_issue_apart_from(&title, &body_to_file, apart_from)?;
3199 let number = filed_issue_number(&url)
3200 .ok_or_else(|| spar_err!("filed an issue but could not read its number from {url}"))?;
3201 Ok(Filed::Opened(number, url))
3202}
3203
3204pub(crate) fn regression_body(body: &str, closed: i64) -> String {
3209 format!(
3210 "{}\n\nThis was filed before as #{closed}, which is closed. The evidence above is from \
3211 a later run, so it looks like it has come back.",
3212 body.trim()
3213 )
3214}
3215
3216fn file_out_of_scope(
3217 repo: &Repo,
3218 findings: &[Finding],
3219 subject: i64,
3220 state: &mut IssueRun,
3221 cfg: &Config,
3222) {
3223 for finding in findings.iter().filter(|f| !f.in_scope) {
3224 let body = issue_report(finding);
3225 let recorded = file_followup(repo, &finding.title, &body, subject, cfg, state);
3226 if finding.severity != Severity::Nit || matches!(recorded, Followup::Recorded(_)) {
3227 record_nonblocking_outcome_with_match(
3228 state,
3229 finding,
3230 Some(&recorded),
3231 unique_stable_finding(findings, finding),
3232 );
3233 }
3234 }
3235}
3236
3237pub fn issue_report(finding: &Finding) -> String {
3244 let sections = finding.report_sections();
3245 if sections.is_empty() {
3246 return finding.detail.clone();
3247 }
3248 let mut out: Vec<String> = sections
3249 .iter()
3250 .map(|(heading, text)| format!("## {heading}\n\n{text}"))
3251 .collect();
3252 if !finding.detail.trim().is_empty()
3255 && !sections
3256 .iter()
3257 .any(|(_, text)| crate::textsim::same_point(text, &finding.detail))
3258 {
3259 out.insert(0, finding.detail.trim().to_string());
3260 }
3261 out.join("\n\n")
3262}
3263
3264fn file_nonblocking(
3271 repo: &Repo,
3272 findings: &[Finding],
3273 subject: i64,
3274 state: &mut IssueRun,
3275 cfg: &Config,
3276) {
3277 for finding in findings {
3278 if !finding.in_scope || finding.severity == Severity::Blocking {
3279 continue;
3280 }
3281 let should_file = match finding.severity {
3282 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking,
3283 Severity::Nit => cfg.loop_cfg.file_nits,
3284 Severity::Blocking => false,
3285 };
3286 if !should_file {
3287 if finding.severity == Severity::NonBlocking {
3288 record_nonblocking_outcome_with_match(
3289 state,
3290 finding,
3291 None,
3292 unique_stable_finding(findings, finding),
3293 );
3294 }
3295 continue;
3296 }
3297 let recorded = file_followup(repo, &finding.title, &finding.detail, subject, cfg, state);
3298 if finding.severity == Severity::NonBlocking {
3299 record_nonblocking_outcome_with_match(
3300 state,
3301 finding,
3302 Some(&recorded),
3303 unique_stable_finding(findings, finding),
3304 );
3305 } else if let Some(url) = recorded.url() {
3306 state.filed.push(url.to_string());
3307 }
3308 }
3309}
3310
3311fn bullets(lines: &[String]) -> String {
3321 lines
3322 .iter()
3323 .map(|l| format!("- {l}"))
3324 .collect::<Vec<_>>()
3325 .join("\n")
3326}
3327
3328fn located(finding: &Finding, style: &Style) -> String {
3329 let title = style::title(&finding.title, style);
3330 match finding.where_at() {
3331 "general" => title,
3332 file => format!("{title} ({file})"),
3333 }
3334}
3335
3336pub enum Ending<'a> {
3338 Approved,
3340 OutOfRounds,
3343 Unchanged,
3346 Unresolved(&'a [Finding]),
3349 Deadlocked(&'a [Finding]),
3352}
3353
3354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3355enum OutcomeSink {
3356 PullRequest,
3357 Terminal,
3358}
3359
3360fn outcome_sink(mode: PrComments) -> OutcomeSink {
3361 match mode {
3362 PrComments::Outcome | PrComments::Rounds => OutcomeSink::PullRequest,
3363 PrComments::None => OutcomeSink::Terminal,
3364 }
3365}
3366
3367fn emit_outcome(repo: &Repo, pr_number: i64, text: &str) {
3368 if outcome_sink(repo.style.pr_comments) == OutcomeSink::Terminal {
3369 println!("\n{text}\n");
3370 return;
3371 }
3372 if let Err(e) = repo.comment_pr(pr_number, text) {
3373 logdim!("could not post the outcome comment: {e}");
3374 println!("\n{text}\n");
3375 }
3376}
3377
3378pub fn post_outcome(
3390 repo: &Repo,
3391 pr_number: i64,
3392 state: &IssueRun,
3393 ledger: &Ledger,
3394 ending: Ending<'_>,
3395) {
3396 let Some(text) = outcome_comment(state, ledger, &ending, &repo.style) else {
3397 return;
3398 };
3399 emit_outcome(repo, pr_number, &text);
3400}
3401
3402fn post_unread_outcome(
3403 repo: &Repo,
3404 pr_number: i64,
3405 state: &IssueRun,
3406 ledger: &Ledger,
3407 open_findings: &[Finding],
3408) {
3409 let Some(text) = outcome_comment_with_unread(
3410 state,
3411 ledger,
3412 &Ending::OutOfRounds,
3413 open_findings,
3414 &repo.style,
3415 ) else {
3416 return;
3417 };
3418 emit_outcome(repo, pr_number, &text);
3419}
3420
3421fn settled_as(finding: &Finding, state: &IssueRun, ledger: &Ledger) -> Option<(Settled, String)> {
3424 if let Some(d) = state
3425 .disputes
3426 .iter()
3427 .find(|d| same_finding_parts(&d.title, &d.file, &finding.title, &finding.file))
3428 {
3429 if !d.reasoning.trim().is_empty() {
3430 return Some((Settled::Refuted, d.reasoning.clone()));
3431 }
3432 }
3433 matching_ledger_entry(ledger, &finding.title, &finding.file)
3434 .filter(|entry| !entry.reasoning.trim().is_empty())
3435 .map(|entry| (entry.outcome, entry.reasoning.clone()))
3436}
3437
3438pub fn filed_issue_number(filed: &str) -> Option<i64> {
3443 filed
3444 .rsplit('/')
3445 .next()
3446 .and_then(|tail| tail.parse::<i64>().ok())
3447 .filter(|n| *n > 0)
3448}
3449
3450fn as_reference(url: &str) -> String {
3451 match url.rsplit('/').next().and_then(|n| n.parse::<u64>().ok()) {
3452 Some(number) => format!("#{number}"),
3453 None => url.to_string(),
3454 }
3455}
3456
3457pub fn outcome_comment(
3458 state: &IssueRun,
3459 ledger: &Ledger,
3460 ending: &Ending<'_>,
3461 style: &Style,
3462) -> Option<String> {
3463 outcome_comment_with_unread(state, ledger, ending, &[], style)
3464}
3465
3466fn outcome_comment_with_unread(
3467 state: &IssueRun,
3468 ledger: &Ledger,
3469 ending: &Ending<'_>,
3470 unread_open: &[Finding],
3471 style: &Style,
3472) -> Option<String> {
3473 let mut out: Vec<String> = Vec::new();
3474 let mut already: Vec<(String, String)> = Vec::new();
3477
3478 match ending {
3479 Ending::Approved => {
3480 if state.disputes.is_empty() && state.filed.is_empty() && state.noted.is_empty() {
3481 return None;
3487 }
3488 out.push("Reviewed, nothing blocking a merge.".into());
3489 }
3490 Ending::OutOfRounds => {
3491 out.push(
3492 "Not signed off: the last round of fixes was pushed but has not been reviewed."
3493 .into(),
3494 );
3495 if !unread_open.is_empty() {
3496 let lines: Vec<String> = unread_open
3497 .iter()
3498 .map(|finding| {
3499 already.push((finding.title.clone(), finding.file.clone()));
3500 format!(
3501 "{}. {}",
3502 located(finding, style),
3503 style::sentence(&finding.detail, style)
3504 )
3505 })
3506 .collect();
3507 out.push("These points were already open:".into());
3508 out.push(bullets(&lines));
3509 }
3510 }
3511 Ending::Unchanged => out.push(
3515 "Not signed off: the last round changed nothing, so the branch is the one that was \
3516 already reviewed."
3517 .into(),
3518 ),
3519 Ending::Unresolved(points) => {
3520 let lines: Vec<String> = points
3521 .iter()
3522 .map(|f| {
3523 already.push((f.title.clone(), f.file.clone()));
3524 format!(
3525 "{}. {}",
3526 located(f, style),
3527 style::sentence(&f.detail, style)
3528 )
3529 })
3530 .collect();
3531 out.push("Not signed off. These points are still open:".into());
3532 out.push(bullets(&lines));
3533 }
3534 Ending::Deadlocked(points) => {
3535 let lines: Vec<String> = points
3541 .iter()
3542 .map(|f| {
3543 let where_at = match f.where_at() {
3544 "general" => String::new(),
3545 file => format!(" ({file})"),
3546 };
3547 let title = style::title(&f.title, style);
3548 already.push((f.title.clone(), f.file.clone()));
3549 match settled_as(f, state, ledger) {
3550 Some((Settled::Refuted, reason)) => format!(
3551 "{title}{where_at}. Refuted as: {}",
3552 style::summary(&reason, style)
3553 ),
3554 Some((Settled::Filed, reason)) => format!(
3555 "{title}{where_at}. Filed as out of scope: {}",
3556 style::summary(&reason, style)
3557 ),
3558 Some((Settled::Dropped, reason)) => format!(
3561 "{title}{where_at}. Out of scope here, and not filed: {}",
3562 style::summary(&reason, style)
3563 ),
3564 Some((Settled::Fixed, reason)) => format!(
3570 "{title}{where_at}. Fixed and raised again. Recorded answer: {}",
3571 style::summary(&reason, style)
3572 ),
3573 None => format!("{title}{where_at}"),
3574 }
3575 })
3576 .collect();
3577 out.push("Needs your decision. The reviewers could not settle this:".into());
3578 out.push(bullets(&lines));
3579 }
3580 }
3581
3582 let disputes: Vec<&crate::model::Dispute> = state
3583 .disputes
3584 .iter()
3585 .filter(|d| {
3586 !already
3587 .iter()
3588 .any(|(title, file)| same_finding_parts(title, file, &d.title, &d.file))
3589 })
3590 .collect();
3591 if !disputes.is_empty() {
3592 let lines: Vec<String> = disputes
3595 .iter()
3596 .map(|d| {
3597 let title = style::title(&d.title, style);
3598 let title = if d.file.trim().is_empty() {
3599 title
3600 } else {
3601 format!("{title} ({})", d.file.trim())
3602 };
3603 format!("{}. {}", title, style::sentence(&d.reasoning, style))
3604 })
3605 .collect();
3606 out.push(format!("Raised and refuted:\n{}", bullets(&lines)));
3607 }
3608
3609 if !state.noted.is_empty() {
3610 let lines: Vec<String> = state
3614 .noted
3615 .iter()
3616 .filter(|f| {
3617 !already
3618 .iter()
3619 .any(|(title, file)| same_finding_parts(title, file, &f.title, &f.file))
3620 })
3621 .map(|f| located(f, style))
3622 .collect();
3623 if !lines.is_empty() {
3624 out.push(format!("Noted, not blocking:\n{}", bullets(&lines)));
3625 }
3626 }
3627
3628 if !state.filed.is_empty() {
3629 let refs: Vec<String> = state.filed.iter().map(|u| as_reference(u)).collect();
3630 out.push(format!("Filed separately: {}", refs.join(", ")));
3631 }
3632
3633 Some(out.join("\n\n"))
3634}
3635
3636#[allow(clippy::too_many_arguments)]
3645fn close_prompt(
3646 base: &str,
3647 number: i64,
3648 title: &str,
3649 context: &str,
3650 from: &str,
3651 landed: Option<&[String]>,
3652 ledger: &Ledger,
3653 open_findings: &[Finding],
3654 round: u32,
3655) -> String {
3656 let landed = match landed {
3657 Some([]) => "\nNothing landed after the last round of review. What it asked for was \
3658 answered in words rather than in code, so the branch in front of you is the \
3659 branch that was already read.\n"
3660 .to_string(),
3661 Some(lines) => format!(
3662 "\nThis landed after the last round of review, and nobody has read it:\n{}\n\nRead it \
3663 first with `git diff {from}..HEAD`, then inspect the full branch with `git diff \
3664 {base}...HEAD`.\n",
3665 lines
3666 .iter()
3667 .map(|l| format!("- {l}"))
3668 .collect::<Vec<_>>()
3669 .join("\n")
3670 ),
3671 None => format!(
3672 "\nThe commits on this branch were rewritten after the last round of review, so the \
3673 harness cannot say which of them are new. Inspect the full branch with `git diff \
3674 {base}...HEAD`.\n"
3675 ),
3676 };
3677 CLOSE_PROMPT
3678 .replace("{number}", &number.to_string())
3679 .replace("{title}", title)
3680 .replace("{context}", context)
3681 .replace("{base}", base)
3682 .replace("{landed}", &landed)
3683 .replace("{open}", &open_findings_block(open_findings))
3684 .replace("{answers}", &closing_answers(ledger, round))
3685 .replace("{settled}", &settled_block(ledger))
3686}
3687
3688fn open_findings_block(findings: &[Finding]) -> String {
3689 if findings.is_empty() {
3690 return String::new();
3691 }
3692 format!(
3693 "\nThese blocking findings were left open by an earlier response. Recheck each one:\n{}\n\
3694 \nIf one still blocks, return it under the same title and file. Omission means you checked \
3695 it and found that it no longer blocks.\n",
3696 findings_for_prompt(findings)
3697 )
3698}
3699
3700fn closing_answers(ledger: &Ledger, round: u32) -> String {
3705 let lines = fixed_lines(ledger, round);
3706 if lines.is_empty() {
3707 return String::new();
3708 }
3709 format!(
3710 "\nThese points were raised on this pull request and the author says it fixed them. \
3711 Nobody has checked that:\n{}\n",
3712 lines.join("\n")
3713 )
3714}
3715
3716#[allow(clippy::too_many_arguments)]
3721fn review_prompt(
3722 base: &str,
3723 number: i64,
3724 title: &str,
3725 context: &str,
3726 ledger: &Ledger,
3727 open_findings: &[Finding],
3728 round: u32,
3729 last: u32,
3730) -> String {
3731 REVIEW_PROMPT
3732 .replace("{base}", base)
3733 .replace("{number}", &number.to_string())
3734 .replace("{title}", title)
3735 .replace("{context}", context)
3736 .replace("{open}", &open_findings_block(open_findings))
3737 .replace("{answers}", &answers_block(ledger, round))
3738 .replace("{settled}", &settled_block(ledger))
3739 .replace("{round}", &round_note(round, last))
3740}
3741
3742fn implement_prompt(number: i64, title: &str, url: &str, body: &str) -> String {
3750 IMPLEMENT_PROMPT
3751 .replace("{thread_rule}", THREAD_RULE)
3752 .replace("{number}", &number.to_string())
3753 .replace("{title}", title)
3754 .replace("{url}", url)
3755 .replace("{body}", body)
3756}
3757
3758pub fn pr_body(issue: i64, work: &Implementation, checked: Option<&str>, style: &Style) -> String {
3768 let mut parts = vec![format!("Closes #{issue}")];
3769
3770 for lead in [&work.summary, &work.problem] {
3771 let text = style::sentence(lead, style);
3772 if !text.is_empty() {
3773 parts.push(text);
3774 }
3775 }
3776 parts.extend(section("What changed", &work.changes, style));
3777 let mut testing = work.testing.clone();
3780 if let Some(command) = checked {
3781 testing.push(format!("`{command}` passed on this branch, run by spar."));
3782 }
3783 parts.extend(section("How to test", &testing, style));
3784
3785 let notes = style::sentence(work.notes.as_deref().unwrap_or_default(), style);
3786 if !notes.is_empty() {
3787 parts.push(format!("## Notes\n\n{notes}"));
3788 }
3789
3790 style::body(&parts.join("\n\n"), style)
3791}
3792
3793fn section(heading: &str, lines: &[String], style: &Style) -> Option<String> {
3800 let items: Vec<String> = lines
3801 .iter()
3802 .map(|line| style::summary(line, style))
3803 .filter(|line| !line.is_empty())
3804 .collect();
3805 if items.is_empty() {
3806 return None;
3807 }
3808 Some(format!("## {heading}\n\n{}", bullets(&items)))
3809}
3810
3811pub fn from_commits(repo: &Repo, work_dir: &Path, base: &str) -> Implementation {
3819 Implementation {
3820 changes: repo.commit_subjects(work_dir, "HEAD", base),
3821 notes: Some(
3822 "The implement call failed after these commits were made, so this body is assembled \
3823 from their messages rather than written by their author. Read the diff."
3824 .to_string(),
3825 ),
3826 ..Implementation::default()
3827 }
3828}
3829
3830fn no_pr_note(work: &Implementation, style: &Style) -> String {
3837 let reason = style::sentence(&work.reason, style);
3838 if !reason.is_empty() {
3839 return reason;
3840 }
3841 if work.not_worth_doing {
3842 "Left alone after reading the code, with no reason given.".to_string()
3843 } else {
3844 "Nothing was committed, so there is nothing to review.".to_string()
3845 }
3846}
3847
3848pub fn review_comment(holder: &str, round: u32, review: &Review, style: &Style) -> String {
3852 let by = |severity: Severity| -> Vec<&Finding> {
3853 review
3854 .findings
3855 .iter()
3856 .filter(|f| f.severity == severity && f.in_scope)
3857 .collect()
3858 };
3859 let blocking = by(Severity::Blocking);
3860 let non_blocking = by(Severity::NonBlocking);
3861 let nits = by(Severity::Nit);
3862 let out_of_scope: Vec<&Finding> = review.findings.iter().filter(|f| !f.in_scope).collect();
3863
3864 let mut counts = Vec::new();
3865 if !blocking.is_empty() {
3866 counts.push(format!("{} blocking", blocking.len()));
3867 }
3868 if !non_blocking.is_empty() {
3869 counts.push(format!("{} non-blocking", non_blocking.len()));
3870 }
3871 if !nits.is_empty() {
3872 counts.push(format!("{} nit", nits.len()));
3873 }
3874 if !out_of_scope.is_empty() {
3875 counts.push(format!("{} out of scope", out_of_scope.len()));
3876 }
3877 let headline = if counts.is_empty() {
3878 "no findings".to_string()
3879 } else {
3880 counts.join(", ")
3881 };
3882
3883 let _ = (holder, round, headline);
3884 let mut out = Vec::new();
3885 let summary = style::summary(&review.summary, style);
3886 if !summary.is_empty() {
3887 out.push(summary);
3888 }
3889
3890 if !blocking.is_empty() {
3891 let lines: Vec<String> = blocking
3892 .iter()
3893 .map(|f| {
3894 let detail = style::detail(&f.detail, style);
3895 if detail.is_empty() {
3896 located(f, style)
3897 } else {
3898 format!("{}. {detail}", located(f, style))
3899 }
3900 })
3901 .collect();
3902 out.push(format!("blocking\n{}", bullets(&lines)));
3903 }
3904
3905 for (label, group) in [
3908 ("non-blocking", &non_blocking),
3909 ("nits", &nits),
3910 ("out of scope", &out_of_scope),
3911 ] {
3912 if group.is_empty() {
3913 continue;
3914 }
3915 let lines: Vec<String> = group.iter().map(|f| located(f, style)).collect();
3916 out.push(format!("{label}\n{}", bullets(&lines)));
3917 }
3918
3919 out.join("\n\n")
3920}
3921
3922pub fn disposition_comment(
3926 author: &str,
3927 response: &ResponseDoc,
3928 fixed: &[String],
3929 refuted: &[String],
3930 filed: &[String],
3931 style: &Style,
3932) -> Option<String> {
3933 if fixed.is_empty() && refuted.is_empty() && filed.is_empty() {
3934 return None;
3935 }
3936 let mut counts = Vec::new();
3937 if !fixed.is_empty() {
3938 counts.push(format!("{} fixed", fixed.len()));
3939 }
3940 if !refuted.is_empty() {
3941 counts.push(format!("{} refuted", refuted.len()));
3942 }
3943 if !filed.is_empty() {
3944 counts.push(format!("{} filed", filed.len()));
3945 }
3946
3947 let _ = (author, counts);
3948 let mut out = Vec::new();
3949 let summary = style::summary(&response.summary, style);
3950 if !summary.is_empty() {
3951 out.push(summary);
3952 }
3953 if !refuted.is_empty() {
3954 out.push(format!("refuted\n{}", bullets(refuted)));
3955 }
3956 if !fixed.is_empty() {
3957 out.push(format!("fixed\n{}", bullets(fixed)));
3958 }
3959 if !filed.is_empty() {
3960 out.push(format!("filed\n{}", bullets(filed)));
3961 }
3962 Some(out.join("\n\n"))
3963}
3964
3965pub fn skip_comment(item: &SkippedItem, style: &Style) -> String {
3973 let reasons = item
3974 .reasons
3975 .values()
3976 .map(|reason| style::sentence(reason, style));
3977 let lines = crate::textsim::dedupe_by(reasons, crate::textsim::same_reason);
3981 bullets(&lines)
3982}
3983
3984pub(crate) fn context_block(number: i64, url: &str, issue_text: &str, pr_body: &str) -> String {
4002 let mut out = String::new();
4003 let body = issue_text.trim();
4004 if !body.is_empty() {
4005 out.push_str(&format!("\nIssue #{number} as filed"));
4006 if !url.trim().is_empty() {
4007 out.push_str(&format!(", at {}", url.trim()));
4008 }
4009 out.push_str(&format!(
4010 ":\n\n{body}\n\nThat is the issue as filed, followed by any comments on it since, \
4011 oldest first. {}\n",
4012 THREAD_RULE.replace('\n', " ")
4013 ));
4014 }
4015 let claim = pr_body.trim();
4016 if !claim.is_empty() {
4017 out.push_str(&format!(
4018 "\nThe pull request description, which is the author's own account of this change. \
4019 It is a claim to check against the code, not a statement of fact:\n\n{claim}\n"
4020 ));
4021 }
4022 out
4023}
4024
4025pub(crate) fn optional_findings(findings: &[Finding]) -> Vec<Finding> {
4027 findings
4028 .iter()
4029 .filter(|f| f.in_scope && f.severity == Severity::NonBlocking)
4030 .cloned()
4031 .collect()
4032}
4033
4034fn optional_block(findings: &[Finding]) -> String {
4035 let optional = optional_findings(findings);
4036 if optional.is_empty() {
4037 return String::new();
4038 }
4039 format!("\n{OPTIONAL_HEADER}{}\n", findings_for_prompt(&optional))
4040}
4041
4042fn warn_unplaced_dispositions(response: &ResponseDoc, used: &[bool]) {
4060 for (index, disposition) in response.dispositions.iter().enumerate() {
4061 if !used[index] {
4062 logwarn!(
4063 "ignoring an unmatched or duplicate disposition for '{}' ({})",
4064 disposition.title,
4065 disposition.file
4066 );
4067 }
4068 }
4069}
4070
4071fn apply_optional_dispositions(
4072 repo: &Repo,
4073 cfg: &Config,
4074 response: &ResponseDoc,
4075 findings: &[Finding],
4076 state: &mut IssueRun,
4077 subject: i64,
4078 used: &mut [bool],
4079) {
4080 let optional = optional_findings(findings);
4081 if optional.is_empty() {
4082 return;
4083 }
4084 let mut fixed = 0usize;
4085 for finding in &optional {
4086 let Ok((index, disposition)) =
4087 matching_disposition(finding, &optional, &response.dispositions)
4088 else {
4089 continue;
4090 };
4091 if used[index] {
4094 continue;
4095 }
4096 used[index] = true;
4097 let unique = unique_stable_finding(&optional, finding);
4098 match disposition.action {
4099 Action::Fixed => {
4100 fixed += 1;
4101 forget_noted(state, finding, unique);
4102 }
4103 Action::FiledIssue => {
4104 let title = disposition
4105 .new_issue_title
4106 .clone()
4107 .filter(|t| !t.trim().is_empty())
4108 .unwrap_or_else(|| finding.title.clone());
4109 let body = disposition
4110 .new_issue_body
4111 .clone()
4112 .filter(|b| !b.trim().is_empty())
4113 .unwrap_or_else(|| finding.detail.clone());
4114 let recorded = file_followup(repo, &title, &body, subject, cfg, state);
4115 record_nonblocking_outcome_with_match(state, finding, Some(&recorded), unique);
4116 }
4117 Action::Refuted => {}
4118 }
4119 }
4120 if fixed > 0 {
4121 logdim!("the author also fixed {fixed} non-blocking finding(s)");
4122 }
4123}
4124
4125pub(crate) fn findings_for_prompt(findings: &[Finding]) -> String {
4128 if findings.is_empty() {
4129 return "(none)".to_string();
4130 }
4131 findings
4132 .iter()
4133 .map(|f| {
4134 let scope = if f.in_scope { "" } else { " [out of scope]" };
4135 format!(
4136 "- [{}]{scope} {} ({})\n {}",
4137 f.severity,
4138 f.title,
4139 f.where_at(),
4140 f.detail
4141 )
4142 })
4143 .collect::<Vec<_>>()
4144 .join("\n")
4145}
4146
4147#[cfg(test)]
4148mod tests {
4149 use super::*;
4150 use crate::model::Verdict;
4151
4152 fn style() -> Style {
4153 Style::default()
4154 }
4155
4156 fn finding(severity: &str, title: &str, detail: &str, file: &str, in_scope: bool) -> Finding {
4157 Finding {
4158 severity: Severity::parse_lenient(severity).unwrap(),
4159 title: title.into(),
4160 detail: detail.into(),
4161 file: file.into(),
4162 in_scope,
4163 ..Default::default()
4164 }
4165 }
4166
4167 fn review(summary: &str, findings: Vec<Finding>) -> Review {
4168 Review {
4169 verdict: Verdict::Approve,
4170 next_action: NextAction::Merge,
4171 summary: summary.into(),
4172 findings,
4173 }
4174 }
4175
4176 fn disposition(title: &str, file: &str, action: Action) -> Disposition {
4177 Disposition {
4178 title: title.into(),
4179 file: file.into(),
4180 action,
4181 reasoning: "because".into(),
4182 new_issue_title: None,
4183 new_issue_body: None,
4184 }
4185 }
4186
4187 fn cfg_with(worktrees: bool, keep: bool) -> Config {
4190 let text = "[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n";
4191 let mut cfg = crate::config::parse(text).unwrap();
4192 cfg.loop_cfg.worktrees = worktrees;
4193 cfg.loop_cfg.keep_worktrees = keep;
4194 cfg
4195 }
4196
4197 #[test]
4198 fn a_worktree_is_released_on_every_finished_outcome() {
4199 let cfg = cfg_with(true, false);
4200 for status in [Status::Approved, Status::Merged, Status::Abandoned] {
4201 assert!(should_release(&cfg, status), "{status}");
4202 }
4203 }
4204
4205 #[test]
4208 fn a_worktree_is_kept_only_where_a_human_has_to_look() {
4209 let cfg = cfg_with(true, false);
4210 assert!(!should_release(&cfg, Status::Escalated));
4211 assert!(!should_release(&cfg, Status::Error));
4212 }
4213
4214 #[test]
4215 fn an_uncommitted_implementation_names_its_diagnostic_and_recovery_path() {
4216 let path = Path::new("/tmp/issue worktree");
4217 let err =
4218 uncommitted_implementation_error(path, Some("git add could not create index.lock"))
4219 .to_string();
4220 assert!(err.contains("could not create index.lock"), "{err}");
4221 assert!(err.contains("/tmp/issue worktree"), "{err}");
4222 assert!(err.contains("Commit or recover"), "{err}");
4223 assert!(!should_release(&cfg_with(true, false), Status::Error));
4224 }
4225
4226 #[test]
4227 fn the_keep_flag_overrides_everything() {
4228 assert!(!should_release(&cfg_with(true, true), Status::Approved));
4229 }
4230
4231 #[test]
4232 fn nothing_is_released_when_worktrees_are_off() {
4233 assert!(!should_release(&cfg_with(false, false), Status::Approved));
4234 }
4235
4236 #[test]
4241 fn fixing_your_own_findings_hands_the_pr_over() {
4242 let cfg = cfg_with(true, false);
4243 assert_eq!("a", next_reviewer(&cfg, "b", Some("b")));
4244 assert_eq!("b", next_reviewer(&cfg, "a", Some("a")));
4245 }
4246
4247 #[test]
4251 fn handing_back_keeps_the_reviewer_for_the_next_round() {
4252 let cfg = cfg_with(true, false);
4253 assert_eq!("b", next_reviewer(&cfg, "b", Some("a")));
4254 assert_eq!("a", next_reviewer(&cfg, "a", Some("b")));
4255 }
4256
4257 #[test]
4260 fn nobody_reviews_their_own_edit() {
4261 let cfg = cfg_with(true, false);
4262 let round_1 = cfg.other(&cfg.first_implementor);
4263 assert_eq!("b", round_1);
4264 for editor in ["a", "b"] {
4265 assert_ne!(editor, next_reviewer(&cfg, &round_1, Some(editor)));
4266 }
4267 }
4268
4269 #[test]
4274 fn a_fix_that_committed_nothing_leaves_the_pr_where_it_is() {
4275 let cfg = cfg_with(true, false);
4276 assert_eq!("b", next_reviewer(&cfg, "b", None));
4277 assert_eq!("a", next_reviewer(&cfg, "a", None));
4278 }
4279
4280 #[test]
4284 fn a_reviewer_that_wrote_the_head_gives_the_pr_up() {
4285 let cfg = cfg_with(true, false);
4286 assert_eq!("a", next_reviewer(&cfg, "b", Some("b")));
4287 }
4288
4289 #[test]
4293 fn a_review_that_wrote_cannot_approve_what_is_left() {
4294 assert!(!approval_stands(&[], true));
4295 }
4296
4297 #[test]
4298 fn a_clean_review_of_an_untouched_branch_approves() {
4299 assert!(approval_stands(&[], false));
4300 }
4301
4302 #[test]
4303 fn a_blocking_finding_never_approves() {
4304 let blocking = vec![finding("blocking", "Broken", "detail", "src/x.rs", true)];
4305 assert!(!approval_stands(&blocking, false));
4306 }
4307
4308 #[test]
4309 fn approval_refuses_a_head_that_changed_after_review() {
4310 assert!(ensure_reviewed_head(36, "abc123", "abc123").is_ok());
4311 let error = ensure_reviewed_head(36, "abc123", "def456").unwrap_err();
4312 assert!(error.to_string().contains("unread head"));
4313 }
4314
4315 #[test]
4317 fn only_a_moved_head_counts_as_a_commit() {
4318 let before = Snapshot {
4319 head: "abc".into(),
4320 dirty: false,
4321 };
4322 assert!(!Snapshot {
4323 head: "abc".into(),
4324 dirty: true,
4325 }
4326 .landed_over(&before));
4327 assert!(Snapshot {
4328 head: "def".into(),
4329 dirty: false,
4330 }
4331 .landed_over(&before));
4332 assert!(!Snapshot {
4334 head: String::new(),
4335 dirty: false,
4336 }
4337 .landed_over(&before));
4338 }
4339
4340 #[test]
4344 fn a_fresh_run_starts_at_one() {
4345 assert_eq!((1, 3), round_window(1, 3));
4346 assert_eq!((1, 5), round_window(1, 5));
4347 }
4348
4349 #[test]
4353 fn a_resumed_run_gets_a_full_fresh_budget() {
4354 assert_eq!((6, 10), round_window(6, 5));
4355 assert_eq!((11, 13), round_window(11, 3));
4356 }
4357
4358 #[test]
4359 fn a_budget_of_one_is_a_single_round() {
4360 assert_eq!((6, 6), round_window(6, 1));
4361 }
4362
4363 #[test]
4364 fn round_numbers_keep_counting_across_sessions() {
4365 let mut start = 1;
4367 let mut seen = Vec::new();
4368 for _ in 0..3 {
4369 let (first, last) = round_window(start, 3);
4370 seen.push((first, last));
4371 start = last + 1;
4372 }
4373 assert_eq!(vec![(1, 3), (4, 6), (7, 9)], seen);
4374 }
4375
4376 fn ledger_with(title: &str, file: &str) -> Ledger {
4379 let mut ledger = Ledger::new();
4380 ledger.insert(
4381 finding_key(title, file),
4382 LedgerEntry {
4383 title: title.into(),
4384 file: file.into(),
4385 reasoning: "no".into(),
4386 round: 1,
4387 reraised: 0,
4388 outcome: Settled::Refuted,
4389 },
4390 );
4391 ledger
4392 }
4393
4394 #[test]
4395 fn a_point_refuted_and_re_raised_twice_escalates() {
4396 let mut ledger = ledger_with("nit about naming", "a.rs");
4397 let mut state = IssueRun::new(1, "t");
4398 let blocking = vec![finding("blocking", "nit about naming", "d", "a.rs", true)];
4399 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
4400 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
4401 }
4402
4403 #[test]
4407 fn a_point_fixed_twice_and_raised_again_escalates() {
4408 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
4409 for entry in ledger.values_mut() {
4410 entry.outcome = Settled::Fixed;
4411 }
4412 let mut state = IssueRun::new(1, "t");
4413 let blocking = [finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
4414
4415 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
4416 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
4417 }
4418
4419 #[test]
4422 fn a_fix_that_missed_twice_is_not_reported_as_a_refutation() {
4423 assert!(why_escalated(Settled::Fixed).contains("fixed twice"));
4424 for outcome in [Settled::Refuted, Settled::Filed, Settled::Dropped] {
4425 assert!(why_escalated(outcome).contains("settled"), "{outcome}");
4426 }
4427 }
4428
4429 #[test]
4434 fn a_reviewer_that_fixes_its_own_findings_records_them_too() {
4435 let mut ledger = Ledger::new();
4436 let blocking = vec![finding(
4437 "blocking",
4438 "Unbounded loop",
4439 "spins",
4440 "src/x.rs",
4441 true,
4442 )];
4443 let mut state = IssueRun::new(1, "t");
4444
4445 record_own_fixes(&blocking, &mut ledger, &mut state, 1);
4446
4447 let entry = ledger
4448 .get(&finding_key("Unbounded loop", "src/x.rs"))
4449 .expect("keyed where the next round will look");
4450 assert_eq!(Settled::Fixed, entry.outcome);
4451 assert_eq!(
4452 "a committed change was made for this point",
4453 entry.reasoning
4454 );
4455
4456 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
4458 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
4459 }
4460
4461 #[test]
4470 fn a_review_that_contradicts_itself_says_so_and_the_findings_win() {
4471 let blocker = finding("blocking", "Retry loop spins", "spins", "src/net.rs", true);
4472 let approve_with_blocker = Review {
4473 verdict: Verdict::Approve,
4474 next_action: NextAction::HandBack,
4475 summary: "one blocker".into(),
4476 findings: vec![blocker],
4477 };
4478 let said = verdict_disagreement(&approve_with_blocker, 1).expect("noticed");
4479 assert!(said.contains("approve"), "{said}");
4480 assert!(said.contains("findings decide"), "{said}");
4481
4482 let changes_but_merge = Review {
4483 verdict: Verdict::ChangesRequested,
4484 next_action: NextAction::Merge,
4485 summary: "nothing blocking".into(),
4486 findings: vec![],
4487 };
4488 assert!(verdict_disagreement(&changes_but_merge, 0).is_some());
4489
4490 let clean = Review {
4492 verdict: Verdict::Approve,
4493 next_action: NextAction::Merge,
4494 summary: "good".into(),
4495 findings: vec![],
4496 };
4497 assert!(verdict_disagreement(&clean, 0).is_none());
4498 let blocked = Review {
4499 verdict: Verdict::ChangesRequested,
4500 next_action: NextAction::HandBack,
4501 summary: "one blocker".into(),
4502 findings: vec![],
4503 };
4504 assert!(verdict_disagreement(&blocked, 1).is_none());
4505 }
4506
4507 #[test]
4515 fn a_finding_the_fix_call_declined_stays_open() {
4516 let blocking = vec![
4517 finding("blocking", "Unbounded loop", "spins", "src/x.rs:88", true),
4518 finding("blocking", "Missing guard", "panics", "src/y.rs:12", true),
4519 ];
4520 let fixes = vec![
4521 OwnFix {
4522 title: "Unbounded loop".into(),
4523 file: "src/x.rs:88".into(),
4524 fixed: true,
4525 reason: "bounded it on max_attempts".into(),
4526 },
4527 OwnFix {
4528 title: "Missing guard".into(),
4529 file: "src/y.rs:12".into(),
4530 fixed: false,
4531 reason: "the guard belongs in the caller, which this branch does not touch".into(),
4532 },
4533 ];
4534
4535 let (claimed, left) = split_own_fixes(&blocking, &fixes);
4536
4537 assert_eq!(1, claimed.len());
4538 assert_eq!("Unbounded loop", claimed[0].title);
4539 assert_eq!(1, left.len());
4540 assert_eq!("Missing guard", left[0].title);
4541 assert!(
4542 left[0].detail.contains("belongs in the caller"),
4543 "the author is not told why it was left: {}",
4544 left[0].detail
4545 );
4546
4547 let mut ledger = Ledger::new();
4549 let mut state = IssueRun::new(1, "t");
4550 record_own_fixes(&claimed, &mut ledger, &mut state, 1);
4551 assert!(ledger.contains_key(&finding_key("Unbounded loop", "src/x.rs:88")));
4552 assert!(
4553 !ledger.contains_key(&finding_key("Missing guard", "src/y.rs:12")),
4554 "a point nobody fixed was recorded as fixed"
4555 );
4556 }
4557
4558 #[test]
4560 fn a_finding_the_fix_report_never_mentions_stays_open() {
4561 let blocking = vec![finding(
4562 "blocking",
4563 "Unbounded loop",
4564 "spins",
4565 "src/x.rs:88",
4566 true,
4567 )];
4568 let (claimed, left) = split_own_fixes(&blocking, &[]);
4569 assert!(claimed.is_empty());
4570 assert_eq!(1, left.len());
4571 }
4572
4573 #[test]
4577 fn a_fixed_point_is_not_in_the_settled_block() {
4578 let mut ledger = ledger_with("refuted point", "a.rs");
4579 ledger.extend(ledger_with("fixed point", "b.rs"));
4580 for entry in ledger.values_mut() {
4581 if entry.title == "fixed point" {
4582 entry.outcome = Settled::Fixed;
4583 }
4584 }
4585 let block = settled_block(&ledger);
4586 assert!(block.contains("refuted point"));
4587 assert!(!block.contains("fixed point"));
4588 }
4589
4590 #[test]
4593 fn a_ledger_of_only_fixes_says_nothing_is_settled() {
4594 let mut ledger = ledger_with("fixed point", "b.rs");
4595 for entry in ledger.values_mut() {
4596 entry.outcome = Settled::Fixed;
4597 }
4598 assert_eq!("", settled_block(&ledger));
4599 }
4600
4601 #[test]
4606 fn one_review_spends_one_re_raise_however_often_it_says_it() {
4607 let mut ledger = ledger_with("Missing error handling", "src/net.rs");
4608 let mut state = IssueRun::new(1, "t");
4609 let twice = vec![
4610 finding(
4611 "blocking",
4612 "Missing error handling",
4613 "d",
4614 "src/net.rs",
4615 true,
4616 ),
4617 finding(
4618 "blocking",
4619 "Missing error handling",
4620 "e",
4621 "src/net.rs",
4622 true,
4623 ),
4624 ];
4625
4626 assert!(!check_relitigation(&mut ledger, &twice, &mut state));
4627 assert_eq!(1, ledger.values().next().unwrap().reraised);
4628 assert!(check_relitigation(&mut ledger, &twice, &mut state));
4629 }
4630
4631 #[test]
4632 fn an_untracked_finding_does_not_escalate() {
4633 let mut state = IssueRun::new(1, "t");
4634 let blocking = vec![finding("blocking", "brand new", "d", "a.rs", true)];
4635 assert!(!check_relitigation(
4636 &mut Ledger::new(),
4637 &blocking,
4638 &mut state
4639 ));
4640 }
4641
4642 #[test]
4643 fn persisted_ledger_entries_are_rekeyed_for_stable_locations() {
4644 let mut ledger = Ledger::new();
4645 ledger.insert(
4646 "legacy-key".into(),
4647 LedgerEntry {
4648 title: "Unbounded loop".into(),
4649 file: "src/x.rs:88".into(),
4650 reasoning: "bounded by the caller".into(),
4651 round: 2,
4652 reraised: 1,
4653 outcome: Settled::Refuted,
4654 },
4655 );
4656 normalise_ledger_keys(&mut ledger);
4657 let key = matching_ledger_key(&ledger, "Unbounded loop", "src/x.rs:91").unwrap();
4658 assert_eq!(1, ledger[&key].reraised);
4659 }
4660
4661 #[test]
4662 fn same_title_at_two_sites_keeps_both_blockers() {
4663 let findings = vec![
4664 finding(
4665 "blocking",
4666 "Unchecked error",
4667 "first site",
4668 "src/net.rs:10",
4669 true,
4670 ),
4671 finding(
4672 "blocking",
4673 "Unchecked error",
4674 "second site",
4675 "src/net.rs:200",
4676 true,
4677 ),
4678 ];
4679
4680 let blocking = blocking_findings(&findings);
4681 assert_eq!(2, blocking.len());
4682 assert_eq!("src/net.rs:10", blocking[0].file);
4683 assert_eq!("src/net.rs:200", blocking[1].file);
4684 }
4685
4686 #[test]
4687 fn moved_location_fallback_refuses_an_ambiguous_ledger() {
4688 let mut ledger = ledger_with("Unchecked error", "src/net.rs:10");
4689 ledger.extend(ledger_with("Unchecked error", "src/net.rs:200"));
4690 assert!(matching_ledger_key(&ledger, "Unchecked error", "src/net.rs:30").is_none());
4691 }
4692
4693 #[test]
4694 fn two_current_sites_do_not_relocate_one_old_ledger_entry() {
4695 let mut ledger = ledger_with("Unchecked error", "src/net.rs:5");
4696 let blocking = vec![
4697 finding(
4698 "blocking",
4699 "Unchecked error",
4700 "first",
4701 "src/net.rs:10",
4702 true,
4703 ),
4704 finding(
4705 "blocking",
4706 "Unchecked error",
4707 "second",
4708 "src/net.rs:200",
4709 true,
4710 ),
4711 ];
4712 let mut state = IssueRun::new(1, "t");
4713 record_own_fixes(&blocking, &mut ledger, &mut state, 2);
4714 assert!(ledger.contains_key(&finding_key("Unchecked error", "src/net.rs:10")));
4715 assert!(ledger.contains_key(&finding_key("Unchecked error", "src/net.rs:200")));
4716 }
4717
4718 #[test]
4719 fn two_current_sites_remain_two_open_findings() {
4720 let current = vec![
4721 finding(
4722 "blocking",
4723 "Unchecked error",
4724 "first",
4725 "src/net.rs:10",
4726 true,
4727 ),
4728 finding(
4729 "blocking",
4730 "Unchecked error",
4731 "second",
4732 "src/net.rs:200",
4733 true,
4734 ),
4735 ];
4736 let mut open = Vec::new();
4737
4738 extend_findings(&mut open, ¤t);
4739
4740 assert_eq!(2, open.len());
4741 assert_eq!("src/net.rs:10", open[0].file);
4742 assert_eq!("src/net.rs:200", open[1].file);
4743 }
4744
4745 #[test]
4746 fn display_limits_do_not_change_persisted_finding_identity() {
4747 let point = finding(
4748 "blocking",
4749 "abcdefghij",
4750 "still wrong",
4751 "src/net.rs:10",
4752 true,
4753 );
4754 let mut ledger = Ledger::new();
4755 let mut state = IssueRun::new(1, "t");
4756 record_own_fixes(std::slice::from_ref(&point), &mut ledger, &mut state, 1);
4757 normalise_ledger_keys(&mut ledger);
4758
4759 let key = matching_ledger_key(&ledger, "abcdefghij", "src/net.rs:12").unwrap();
4760 assert_eq!("abcdefghij", ledger[&key].title);
4761 }
4762
4763 #[test]
4764 fn a_clipped_legacy_entry_keeps_its_original_lookup_key() {
4765 let mut ledger = Ledger::new();
4766 let key = crate::jsonx::finding_key("abcdefghij", "src/net.rs:10");
4767 ledger.insert(
4768 key.clone(),
4769 LedgerEntry {
4770 title: "abcde".into(),
4771 file: "src/net.rs:10".into(),
4772 reasoning: "bounded by the caller".into(),
4773 round: 1,
4774 reraised: 1,
4775 outcome: Settled::Refuted,
4776 },
4777 );
4778 normalise_ledger_keys(&mut ledger);
4779
4780 assert_eq!(
4781 Some(key),
4782 matching_ledger_key(&ledger, "abcdefghij", "src/net.rs:12")
4783 );
4784 }
4785
4786 #[test]
4787 fn a_legacy_key_collision_does_not_merge_case_distinct_paths() {
4788 let mut ledger = Ledger::new();
4789 let key = crate::jsonx::finding_key("Unchecked error", "src/Main.rs:10");
4790 ledger.insert(
4791 key.clone(),
4792 LedgerEntry {
4793 title: "Unchecked error".into(),
4794 file: "src/Main.rs:10".into(),
4795 reasoning: "bounded by the caller".into(),
4796 round: 1,
4797 reraised: 0,
4798 outcome: Settled::Refuted,
4799 },
4800 );
4801
4802 assert_eq!(
4803 Some(key),
4804 matching_ledger_key(&ledger, "Unchecked error", "src/Main.rs:10")
4805 );
4806 assert!(matching_ledger_key(&ledger, "Unchecked error", "src/main.rs:10").is_none());
4807 }
4808
4809 #[test]
4813 fn a_refutation_lands_on_the_key_the_next_round_will_look_up() {
4814 let blocking = [finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
4815 let recorded = finding_key(&blocking[0].title, &blocking[0].file);
4816 let answer = disposition("unbounded loop!", "src/x.rs", Action::Refuted);
4817 assert!(answer_matches(&blocking[0], &answer));
4818 assert_eq!(recorded, finding_key(&answer.title, &answer.file));
4819 }
4820
4821 #[test]
4824 fn the_ledger_key_ignores_title_punctuation() {
4825 let findings = [finding(
4826 "blocking",
4827 "Panic on multi-byte input",
4828 "d",
4829 "src/style.rs",
4830 true,
4831 )];
4832 let reworded = "Panic on multibyte input";
4833 let source = &findings[0];
4834 assert_eq!(
4835 finding_key(reworded, &source.file),
4836 finding_key(&source.title, &source.file)
4837 );
4838 let recorded = finding_key(&source.title, &source.file);
4839 let looked_up = finding_key(&findings[0].title, &findings[0].file);
4840 assert_eq!(recorded, looked_up);
4841 }
4842
4843 #[test]
4844 fn a_disposition_matches_its_finding_despite_wording_noise() {
4845 let findings = [finding(
4846 "blocking",
4847 "Unbounded loop!",
4848 "d",
4849 "src/x.rs",
4850 true,
4851 )];
4852 assert!(answer_matches(
4853 &findings[0],
4854 &disposition("unbounded loop", "src/x.rs", Action::Refuted)
4855 ));
4856 assert!(!answer_matches(
4857 &findings[0],
4858 &disposition("something else", "src/x.rs", Action::Refuted)
4859 ));
4860 assert!(!answer_matches(
4861 &findings[0],
4862 &disposition("unbounded loop", "src/y.rs", Action::Refuted)
4863 ));
4864 }
4865
4866 #[test]
4867 fn a_disposition_matches_its_finding_despite_a_severity_tag() {
4868 let blocker = finding(
4869 "blocking",
4870 "Refused requests can corrupt the active splice",
4871 "d",
4872 "src/lightning/channel/channel.ts",
4873 true,
4874 );
4875 let answers = [disposition(
4876 "[blocking] Refused requests can corrupt the active splice",
4877 "src/lightning/channel/channel.ts",
4878 Action::Fixed,
4879 )];
4880
4881 assert_eq!(
4882 0,
4883 matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers)
4884 .expect("tagged answer")
4885 .0
4886 );
4887 }
4888
4889 #[test]
4890 fn a_bracketed_subject_still_separates_two_findings() {
4891 let ios = finding("blocking", "[iOS] Startup crash", "d", "src/app.ts", true);
4892 let android = finding(
4893 "blocking",
4894 "[Android] Startup crash",
4895 "d",
4896 "src/app.ts",
4897 true,
4898 );
4899 let findings = [ios.clone(), android];
4900 let answers = [disposition(
4901 "[Android] Startup crash",
4902 "src/app.ts",
4903 Action::Fixed,
4904 )];
4905
4906 assert!(matches!(
4907 matching_disposition(&ios, &findings, &answers),
4908 Err("no matching disposition")
4909 ));
4910 }
4911
4912 #[test]
4918 fn an_answer_to_an_optional_finding_is_placed_by_the_optional_pass() {
4919 let blocker = finding("blocking", "Unbounded loop", "d", "src/x.rs", true);
4920 let optional = finding(
4921 "non-blocking",
4922 "Storage failures are reported as a successful state update",
4923 "d",
4924 "src/wallet/index.ts",
4925 true,
4926 );
4927 let answers = [
4928 disposition("Unbounded loop", "src/x.rs", Action::Fixed),
4929 disposition(
4930 "Storage failures are reported as a successful state update",
4931 "src/wallet/index.ts",
4932 Action::Refuted,
4933 ),
4934 ];
4935 let mut used = vec![false; answers.len()];
4936
4937 let blocking = [blocker];
4938 let (index, _) = matching_disposition(&blocking[0], &blocking, &answers).unwrap();
4939 used[index] = true;
4940
4941 let optionals = [optional];
4942 let (index, _) = matching_disposition(&optionals[0], &optionals, &answers).unwrap();
4943 assert!(!used[index], "the blocking pass must not have taken it");
4944 used[index] = true;
4945
4946 assert!(used.iter().all(|placed| *placed), "both were placed");
4947 }
4948
4949 #[test]
4952 fn an_answer_to_a_point_nobody_raised_stays_unplaced() {
4953 let blocking = [finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
4954 let answers = [
4955 disposition("Unbounded loop", "src/x.rs", Action::Fixed),
4956 disposition("Something nobody said", "src/z.rs", Action::Refuted),
4957 ];
4958 let mut used = vec![false; answers.len()];
4959
4960 let (index, _) = matching_disposition(&blocking[0], &blocking, &answers).unwrap();
4961 used[index] = true;
4962
4963 let optionals = optional_findings(&blocking);
4964 assert!(optionals.is_empty());
4965
4966 assert_eq!(vec![false], vec![used[1]], "the stray answer is unplaced");
4967 }
4968
4969 #[test]
4970 fn an_omitted_disposition_leaves_the_blocker_unmatched() {
4971 let blocker = finding("blocking", "Unbounded loop", "d", "src/x.rs", true);
4972 assert!(matches!(
4973 matching_disposition(
4974 &blocker,
4975 std::slice::from_ref(&blocker),
4976 &[] as &[Disposition]
4977 ),
4978 Err("no matching disposition")
4979 ));
4980 }
4981
4982 #[test]
4983 fn duplicate_dispositions_are_ambiguous() {
4984 let blocker = finding("blocking", "Unbounded loop", "d", "src/x.rs", true);
4985 let answers = vec![
4986 disposition("Unbounded loop", "src/x.rs", Action::Fixed),
4987 disposition("Unbounded loop", "src/x.rs", Action::Refuted),
4988 ];
4989 assert!(matches!(
4990 matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers),
4991 Err("more than one matching disposition")
4992 ));
4993 }
4994
4995 #[test]
4996 fn an_unambiguous_disposition_may_omit_the_line_number() {
4997 let blocker = finding(
4998 "blocking",
4999 "Replayed offers persist the wrong lane key",
5000 "d",
5001 "src/engine.rs:1166",
5002 true,
5003 );
5004 let answers = [disposition(
5005 "replayed offers persist the wrong lane key!",
5006 "src/engine.rs",
5007 Action::Fixed,
5008 )];
5009
5010 assert_eq!(
5011 0,
5012 matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers)
5013 .expect("stable answer")
5014 .0
5015 );
5016 }
5017
5018 #[test]
5019 fn a_line_free_disposition_does_not_choose_between_two_sites() {
5020 let first = finding("blocking", "Unchecked error", "d", "src/engine.rs:10", true);
5021 let second = finding(
5022 "blocking",
5023 "Unchecked error",
5024 "d",
5025 "src/engine.rs:200",
5026 true,
5027 );
5028 let findings = [first.clone(), second];
5029 let answers = [disposition(
5030 "Unchecked error",
5031 "src/engine.rs",
5032 Action::Fixed,
5033 )];
5034
5035 assert!(matches!(
5036 matching_disposition(&first, &findings, &answers),
5037 Err("no matching disposition")
5038 ));
5039 }
5040
5041 #[test]
5042 fn an_exact_location_wins_over_an_ambiguous_fallback() {
5043 let blocker = finding("blocking", "Unchecked error", "d", "src/engine.rs:10", true);
5044 let answers = [
5045 disposition("Unchecked error", "src/engine.rs", Action::Refuted),
5046 disposition("Unchecked error", "src/engine.rs:10", Action::Fixed),
5047 ];
5048
5049 let matched = matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers)
5050 .expect("exact answer");
5051 assert_eq!(1, matched.0);
5052 assert_eq!(Action::Fixed, matched.1.action);
5053 }
5054
5055 #[test]
5056 fn same_titled_findings_in_different_files_need_separate_dispositions() {
5057 let findings = [
5058 finding("blocking", "Unchecked error", "d", "src/a.rs", true),
5059 finding("blocking", "Unchecked error", "d", "src/b.rs", true),
5060 ];
5061 let answers = vec![
5062 disposition("Unchecked error", "src/a.rs", Action::Fixed),
5063 disposition("Unchecked error", "src/b.rs", Action::Refuted),
5064 ];
5065 assert_eq!(
5066 0,
5067 matching_disposition(&findings[0], &findings, &answers)
5068 .expect("left answer")
5069 .0
5070 );
5071 assert_eq!(
5072 1,
5073 matching_disposition(&findings[1], &findings, &answers)
5074 .expect("right answer")
5075 .0
5076 );
5077 }
5078
5079 #[test]
5080 fn a_reported_fix_without_a_commit_stays_open() {
5081 assert!(!fixed_disposition_resolves(false));
5082 assert!(fixed_disposition_resolves(true));
5083 }
5084
5085 #[test]
5086 fn the_settled_block_is_empty_when_nothing_is_settled() {
5087 assert_eq!("", settled_block(&Ledger::new()));
5088 }
5089
5090 #[test]
5091 fn the_settled_block_names_each_refutation() {
5092 let block = settled_block(&ledger_with("a point", "x.rs"));
5093 assert!(block.contains("a point"));
5094 assert!(block.contains("x.rs"));
5095 assert!(block.contains("settled"));
5096 }
5097
5098 #[test]
5099 fn same_title_settlements_name_each_location() {
5100 let mut ledger = ledger_with("Unchecked error", "a.rs:10");
5101 ledger.extend(ledger_with("Unchecked error", "b.rs:20"));
5102
5103 let block = settled_block(&ledger);
5104
5105 assert!(block.contains("Unchecked error (a.rs:10)"), "{block}");
5106 assert!(block.contains("Unchecked error (b.rs:20)"), "{block}");
5107 }
5108
5109 #[test]
5113 fn a_filed_point_is_settled_too() {
5114 let mut ledger = ledger_with("out of scope", "x.rs");
5115 for entry in ledger.values_mut() {
5116 entry.outcome = Settled::Filed;
5117 entry.reasoning = "Tracked in #9.".into();
5118 }
5119 let block = settled_block(&ledger);
5120 assert!(block.contains("out of scope"));
5121 assert!(block.contains("#9"));
5122 }
5123
5124 #[test]
5127 fn answering_a_point_again_keeps_its_re_raise_count() {
5128 let mut ledger = ledger_with("a point", "x.rs");
5129 let entry = ledger.values().next().unwrap().clone();
5130 let mut state = IssueRun::new(1, "t");
5131 let blocking = vec![finding("blocking", "a point", "d", "x.rs", true)];
5132
5133 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
5134 settle(&mut ledger, "a point", "x.rs", true, entry);
5135 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
5136 }
5137
5138 #[test]
5141 fn a_clean_review_is_just_the_verdict() {
5144 let text = review_comment("codex", 1, &review("Looks correct.", vec![]), &style());
5145 assert_eq!("Looks correct.", text);
5146 }
5147
5148 #[test]
5149 fn a_review_leads_with_the_counts() {
5150 let text = review_comment(
5151 "codex",
5152 2,
5153 &review(
5154 "One real problem.",
5155 vec![
5156 finding(
5157 "blocking",
5158 "Loop never terminates",
5159 "Confirmed by running it.",
5160 "src/a.rs",
5161 true,
5162 ),
5163 finding("non-blocking", "Name is vague", "d", "src/b.rs", true),
5164 finding("nit", "Log wording", "d", "", true),
5165 ],
5166 ),
5167 &style(),
5168 );
5169 assert!(text.starts_with("One real problem."), "{text}");
5170 assert!(!text.contains("codex"), "no agent name: {text}");
5171 assert!(!text.contains("round 2"), "no round number: {text}");
5172 }
5173
5174 #[test]
5177 fn only_blocking_findings_carry_their_detail() {
5178 let text = review_comment(
5179 "codex",
5180 1,
5181 &review(
5182 "s",
5183 vec![
5184 finding("blocking", "Loop", "BLOCKING DETAIL", "a.rs", true),
5185 finding("non-blocking", "Name", "NONBLOCKING DETAIL", "b.rs", true),
5186 ],
5187 ),
5188 &style(),
5189 );
5190 assert!(text.contains("BLOCKING DETAIL"), "{text}");
5191 assert!(!text.contains("NONBLOCKING DETAIL"), "{text}");
5192 }
5193
5194 #[test]
5195 fn a_thorough_explanation_reaches_the_author_intact() {
5198 let detail = "Reproduced by running the 429 test with max_attempts unset. ".repeat(8);
5199 let text = review_comment(
5200 "codex",
5201 1,
5202 &review(
5203 "One problem.",
5204 vec![finding("blocking", "T", &detail, "a.rs", true)],
5205 ),
5206 &style(),
5207 );
5208 assert!(
5209 text.contains(detail.trim()),
5210 "the explanation was cut:\n{text}"
5211 );
5212 }
5213
5214 #[test]
5216 fn a_runaway_model_is_still_bounded() {
5217 let long = "filler words. ".repeat(20_000);
5218 let text = review_comment(
5219 "codex",
5220 1,
5221 &review(&long, vec![finding("blocking", "T", &long, "a.rs", true)]),
5222 &style(),
5223 );
5224 assert!(
5225 text.len() < 30_000,
5226 "review comment was {} chars",
5227 text.len()
5228 );
5229 }
5230
5231 #[test]
5232 fn a_general_finding_has_no_empty_parenthesis() {
5233 let text = review_comment(
5234 "codex",
5235 1,
5236 &review("s", vec![finding("blocking", "Something", "d", "", true)]),
5237 &style(),
5238 );
5239 assert!(!text.contains("()"), "{text}");
5240 assert!(!text.contains("(general)"), "{text}");
5241 }
5242
5243 #[test]
5244 fn out_of_scope_findings_are_counted_separately() {
5245 let text = review_comment(
5246 "codex",
5247 1,
5248 &review(
5249 "s",
5250 vec![finding("blocking", "Old bug", "d", "a.rs", false)],
5251 ),
5252 &style(),
5253 );
5254 assert!(text.contains("out of scope"), "{text}");
5255 assert!(text.contains("Old bug"), "{text}");
5256 }
5257
5258 #[test]
5259 fn a_disposition_comment_leads_with_counts_and_keeps_refutations() {
5260 let response = ResponseDoc {
5261 summary: "Two of three were right.".into(),
5262 dispositions: vec![],
5263 };
5264 let text = disposition_comment(
5265 "claude",
5266 &response,
5267 &["Fixed thing".to_string()],
5268 &["Wrong thing. Because the caller already checks.".to_string()],
5269 &[],
5270 &style(),
5271 )
5272 .unwrap();
5273 assert!(text.starts_with("Two of three were right."), "{text}");
5274 assert!(!text.contains("claude"), "no agent name: {text}");
5275 assert!(
5276 text.contains("Because the caller already checks."),
5277 "{text}"
5278 );
5279 }
5280
5281 #[test]
5282 fn an_empty_disposition_comment_is_not_posted() {
5283 let response = ResponseDoc {
5284 summary: "s".into(),
5285 dispositions: vec![],
5286 };
5287 assert!(disposition_comment("claude", &response, &[], &[], &[], &style()).is_none());
5288 }
5289
5290 #[test]
5295 fn a_local_closing_commit_does_not_change_remote_custody() {
5296 for holder in ["a", "b"] {
5297 assert_eq!(holder, closing_next_actor(holder));
5298 }
5299 }
5300
5301 #[test]
5302 fn a_matching_head_keeps_saved_custody() {
5303 assert!(reconcile_saved_head(Some("abc123"), Some(2), true, "abc123", None, 42).unwrap());
5304 assert!(reconcile_saved_head(None, None, false, "abc123", None, 42).unwrap());
5305 }
5306
5307 #[test]
5308 fn a_changed_head_refuses_automatic_custody() {
5309 let error =
5310 reconcile_saved_head(Some("abc123"), Some(2), true, "def456", None, 42).unwrap_err();
5311 let text = error.to_string();
5312 assert!(text.contains("abc123"), "{text}");
5313 assert!(text.contains("def456"), "{text}");
5314 assert!(text.contains("--next <agent>"), "{text}");
5315 }
5316
5317 #[test]
5318 fn legacy_state_keeps_its_saved_custody_for_migration() {
5319 assert!(reconcile_saved_head(Some(""), Some(1), true, "def456", None, 42).unwrap());
5320 }
5321
5322 #[test]
5323 fn an_explicit_holder_resets_state_for_a_changed_or_legacy_head() {
5324 assert!(
5325 !reconcile_saved_head(Some("abc123"), Some(2), true, "def456", Some("b"), 42).unwrap()
5326 );
5327 assert!(!reconcile_saved_head(Some(""), Some(1), true, "def456", Some("b"), 42).unwrap());
5328 }
5329
5330 #[test]
5331 fn a_headless_current_or_future_state_is_not_legacy() {
5332 for version in [2, STATE_VERSION + 1] {
5333 assert!(
5334 reconcile_saved_head(Some(""), Some(version), true, "def456", None, 42).is_err()
5335 );
5336 }
5337 }
5338
5339 #[test]
5340 fn legacy_state_with_an_unknown_actor_refuses_to_guess() {
5341 assert!(reconcile_saved_head(Some(""), Some(1), false, "def456", None, 42).is_err());
5342 }
5343
5344 #[test]
5345 fn an_invalid_review_cannot_clear_a_carried_blocker() {
5346 let mut open = vec![finding(
5347 "blocking",
5348 "Unchecked error",
5349 "still fails",
5350 "src/a.rs:12",
5351 true,
5352 )];
5353 update_open_findings(&mut open, &[], false);
5354 assert_eq!(1, open.len());
5355
5356 update_open_findings(&mut open, &[], true);
5357 assert!(open.is_empty());
5358 }
5359
5360 #[test]
5361 fn a_final_round_with_no_commit_names_open_blockers() {
5362 let open = vec![finding(
5363 "blocking",
5364 "Unchecked error",
5365 "still fails",
5366 "src/a.rs",
5367 true,
5368 )];
5369 assert!(matches!(
5370 ending_without_landing(&open),
5371 Ending::Unresolved(points) if points.len() == 1
5372 ));
5373 assert!(matches!(ending_without_landing(&[]), Ending::Unchanged));
5374 }
5375
5376 #[test]
5380 fn a_closing_pass_uses_the_later_effort_tier() {
5381 use crate::config::{Call, EffortSchedule};
5382 let schedule = EffortSchedule {
5383 round_1: Some("ultra".into()),
5384 rest: Some("low".into()),
5385 ..EffortSchedule::default()
5386 };
5387 assert_eq!(None, schedule.get(Call::Close));
5388 let mut with_key = schedule.clone();
5389 with_key.close = Some("medium".into());
5390 assert_eq!(Some("medium".to_string()), with_key.get(Call::Close));
5391 }
5392
5393 #[test]
5394 fn a_ledger_with_no_claimed_fix_has_nothing_to_close_over() {
5395 assert!(!any_fixes(&ledger_with("refuted point", "a.rs"), 1));
5396 let mut fixed = ledger_with("fixed point", "b.rs");
5397 for entry in fixed.values_mut() {
5398 entry.outcome = Settled::Fixed;
5399 }
5400 assert!(any_fixes(&fixed, 1));
5401 }
5402
5403 #[test]
5406 fn the_closing_note_counts_points_rather_than_rounds() {
5407 assert_eq!("one point left after the closing pass", unresolved_note(1));
5408 assert_eq!("3 points left after the closing pass", unresolved_note(3));
5409 assert!(!unresolved_note(2).contains("round"));
5410 }
5411
5412 fn fixed_ledger() -> Ledger {
5413 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
5414 for entry in ledger.values_mut() {
5415 entry.outcome = Settled::Fixed;
5416 entry.reasoning = "bounded it on max_attempts".into();
5417 }
5418 ledger
5419 }
5420
5421 #[test]
5422 fn the_closing_prompt_names_every_fix_it_has_to_check() {
5423 let landed = vec!["abc1234 Bound the retry loop".to_string()];
5424 let prompt = close_prompt(
5425 "origin/main",
5426 42,
5427 "Retry a 429",
5428 "",
5429 "9f8e7d6",
5430 Some(&landed),
5431 &fixed_ledger(),
5432 &[],
5433 1,
5434 );
5435 assert!(prompt.contains("Unbounded loop"), "{prompt}");
5436 assert!(prompt.contains("bounded it on max_attempts"), "{prompt}");
5437 assert!(prompt.contains("abc1234 Bound the retry loop"), "{prompt}");
5438 assert!(prompt.contains("git diff 9f8e7d6..HEAD"), "{prompt}");
5439 assert!(prompt.contains("git diff origin/main...HEAD"), "{prompt}");
5440 assert!(!prompt.contains('{'), "{prompt}");
5441 }
5442
5443 #[test]
5446 fn a_close_with_nothing_landed_says_so_rather_than_leaving_a_hole() {
5447 let prompt = close_prompt(
5448 "origin/main",
5449 42,
5450 "Retry a 429",
5451 "",
5452 "9f8e7d6",
5453 Some(&[]),
5454 &Ledger::new(),
5455 &[],
5456 1,
5457 );
5458 assert!(
5459 prompt.contains("Nothing landed after the last round"),
5460 "{prompt}"
5461 );
5462 assert!(!prompt.contains('{'), "{prompt}");
5463 }
5464
5465 #[test]
5471 fn a_rewritten_branch_admits_it_cannot_say_what_landed() {
5472 let prompt = close_prompt(
5473 "origin/main",
5474 42,
5475 "Retry a 429",
5476 "",
5477 "9f8e7d6",
5478 None,
5479 &fixed_ledger(),
5480 &[],
5481 1,
5482 );
5483 assert!(prompt.contains("were rewritten"), "{prompt}");
5484 assert!(prompt.contains("git diff origin/main...HEAD"), "{prompt}");
5485 assert!(!prompt.contains("nobody has read it"), "{prompt}");
5486 assert!(!prompt.contains('{'), "{prompt}");
5487 }
5488
5489 #[test]
5492 fn the_closing_prompt_forbids_the_writing_the_loop_rolls_back() {
5493 let prompt = close_prompt(
5494 "origin/main",
5495 42,
5496 "t",
5497 "",
5498 "9f8e7d6",
5499 Some(&[]),
5500 &Ledger::new(),
5501 &[],
5502 1,
5503 );
5504 assert!(prompt.contains("do not commit"), "{prompt}");
5505 }
5506
5507 #[test]
5509 fn the_closing_prompt_keeps_confirmed_merge_blockers_blocking() {
5510 let prompt = close_prompt(
5511 "origin/main",
5512 42,
5513 "t",
5514 "",
5515 "9f8e7d6",
5516 Some(&[]),
5517 &Ledger::new(),
5518 &[],
5519 1,
5520 );
5521 assert!(
5522 prompt.contains("serious defect an\nearlier round missed"),
5523 "{prompt}"
5524 );
5525 assert!(prompt.contains("final merge-safety audit"), "{prompt}");
5526 assert!(!prompt.contains("not another\naudit"), "{prompt}");
5527 assert!(!prompt.contains("A\nfinding means"), "{prompt}");
5528 assert!(prompt.contains("A\nblocking finding means"), "{prompt}");
5529 let flat = prompt.split_whitespace().collect::<Vec<_>>().join(" ");
5530 assert!(flat.contains("does not become non-blocking"), "{prompt}");
5531 assert!(flat.contains("Only one of them ships"), "{prompt}");
5532 }
5533
5534 #[test]
5539 fn the_closing_pass_is_offered_a_severity_rather_than_the_field_that_gates() {
5540 let prompt = close_prompt(
5541 "origin/main",
5542 42,
5543 "t",
5544 "",
5545 "9f8e7d6",
5546 Some(&[]),
5547 &Ledger::new(),
5548 &[],
5549 1,
5550 );
5551 assert!(
5552 prompt.contains("Minor defects and improvements are\nnon-blocking"),
5553 "{prompt}"
5554 );
5555 assert!(
5556 prompt.contains("a real defect\nthis pull request did not cause"),
5557 "{prompt}"
5558 );
5559 }
5560
5561 #[test]
5564 fn an_out_of_scope_point_cannot_gate_the_close() {
5565 let out_of_scope = finding("blocking", "Adjacent leak", "d", "o.rs", false);
5566 assert!(!out_of_scope.blocks());
5567 assert!(approval_stands(&[], false));
5568 }
5569
5570 #[test]
5574 fn a_fix_is_shown_to_the_pass_that_has_to_check_it_and_not_after() {
5575 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
5576 for entry in ledger.values_mut() {
5577 entry.outcome = Settled::Fixed;
5578 entry.round = 2;
5579 }
5580 assert!(answers_block(&ledger, 3).contains("Unbounded loop"));
5582 assert_eq!("", answers_block(&ledger, 4));
5584 assert!(any_fixes(&ledger, 2));
5585 assert!(!any_fixes(&ledger, 3));
5586 }
5587
5588 #[test]
5593 fn the_answers_block_asks_the_reviewer_to_check_rather_than_to_trust() {
5594 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
5595 for entry in ledger.values_mut() {
5596 entry.outcome = Settled::Fixed;
5597 entry.reasoning = "bounded it on max_attempts".into();
5598 }
5599 let block = answers_block(&ledger, 2);
5600 assert!(block.contains("Unbounded loop"), "{block}");
5601 assert!(block.contains("src/x.rs"), "{block}");
5602 assert!(block.contains("bounded it on max_attempts"), "{block}");
5603 assert!(block.contains("Check the answer"), "{block}");
5604 assert!(!block.contains("settled"), "{block}");
5605 }
5606
5607 #[test]
5610 fn a_fix_and_a_refutation_do_not_share_a_heading() {
5611 let mut ledger = ledger_with("refuted point", "a.rs");
5612 ledger.extend(ledger_with("fixed point", "b.rs"));
5613 for entry in ledger.values_mut() {
5614 if entry.title == "fixed point" {
5615 entry.outcome = Settled::Fixed;
5616 }
5617 }
5618 let answers = answers_block(&ledger, 2);
5619 let settled = settled_block(&ledger);
5620 assert!(answers.contains("fixed point") && !answers.contains("refuted point"));
5621 assert!(settled.contains("refuted point") && !settled.contains("fixed point"));
5622 }
5623
5624 #[test]
5625 fn an_empty_ledger_adds_no_answers_block() {
5626 assert_eq!("", answers_block(&Ledger::new(), 2));
5627 }
5628
5629 #[test]
5632 fn the_last_round_that_can_ask_for_anything_says_so() {
5633 assert_eq!("", round_note(1, 3));
5634 assert_eq!("", round_note(2, 3));
5635 assert!(round_note(3, 3).contains("last round"));
5636 assert!(round_note(6, 6).contains("last round"));
5639 }
5640
5641 #[test]
5647 fn saying_when_the_asking_stops_says_nothing_about_severity() {
5648 let note = round_note(3, 3);
5649 for word in ["approve", "blocking", "severity", "nit"] {
5650 assert!(!note.contains(word), "{word} in: {note}");
5651 }
5652 }
5653
5654 #[test]
5655 fn the_review_prompt_leaves_nothing_unsubstituted() {
5656 let empty = review_prompt(
5660 "origin/main",
5661 42,
5662 "Retry a 429",
5663 "",
5664 &Ledger::new(),
5665 &[],
5666 1,
5667 3,
5668 );
5669 assert!(!empty.contains('{'), "{empty}");
5670 assert!(
5671 empty.contains("`origin/main`"),
5672 "the reviewer was told to read a different ref than spar compares:\n{empty}"
5673 );
5674 assert!(empty.contains("#42") && empty.contains("Retry a 429"));
5675
5676 let mut ledger = ledger_with("refuted point", "a.rs");
5677 ledger.extend(ledger_with("fixed point", "b.rs"));
5678 for entry in ledger.values_mut() {
5679 if entry.title == "fixed point" {
5680 entry.outcome = Settled::Fixed;
5681 entry.round = 2;
5682 }
5683 }
5684 let full = review_prompt("origin/main", 42, "Retry a 429", "", &ledger, &[], 3, 3);
5685 assert!(!full.contains('{'), "{full}");
5686 assert!(full.contains("fixed point") && full.contains("refuted point"));
5687 assert!(full.contains("last round"), "{full}");
5688 }
5689
5690 #[test]
5699 fn every_pass_that_judges_the_branch_is_given_the_issue_and_the_description() {
5700 let context = context_block(
5701 42,
5702 "https://github.com/example/project/issues/42",
5703 "Honour Retry-After and cap at five attempts.",
5704 "## What changed\n\nBounded the loop. Ran the 429 test.",
5705 );
5706 assert!(context.contains("Honour Retry-After"), "{context}");
5707 assert!(context.contains("issues/42"), "{context}");
5708 assert!(
5709 context.contains("followed by any comments on it since"),
5710 "{context}"
5711 );
5712 assert!(context.contains("the later one wins"), "{context}");
5713 assert!(context.contains("Ran the 429 test"), "{context}");
5714 assert!(
5715 context.contains("claim to check"),
5716 "the description has to be labelled as the author's own account:\n{context}"
5717 );
5718
5719 let review = review_prompt(
5720 "origin/main",
5721 42,
5722 "Retry on 429",
5723 &context,
5724 &Ledger::new(),
5725 &[],
5726 1,
5727 3,
5728 );
5729 let close = close_prompt(
5730 "origin/main",
5731 42,
5732 "Retry on 429",
5733 &context,
5734 "9f8e7d6",
5735 Some(&[]),
5736 &Ledger::new(),
5737 &[],
5738 1,
5739 );
5740 let respond = RESPOND_PROMPT
5741 .replace("{number}", "42")
5742 .replace("{context}", &context)
5743 .replace("{findings}", "(none)")
5744 .replace("{optional}", "");
5745 for prompt in [review, close, respond] {
5746 assert!(prompt.contains("Honour Retry-After"), "{prompt}");
5747 assert!(prompt.contains("Ran the 429 test"), "{prompt}");
5748 assert!(!prompt.contains('{'), "{prompt}");
5749 }
5750 }
5751
5752 #[test]
5759 fn the_author_is_offered_the_non_blocking_findings_it_never_saw() {
5760 let findings = vec![
5761 finding(
5762 "blocking",
5763 "Retry loop spins",
5764 "confirmed",
5765 "src/net.rs:88",
5766 true,
5767 ),
5768 finding(
5769 "non-blocking",
5770 "Timeout is hard coded",
5771 "thirty seconds, in one place",
5772 "src/net.rs:44",
5773 true,
5774 ),
5775 finding(
5776 "nit",
5777 "Log line says retrying",
5778 "wording",
5779 "src/net.rs:102",
5780 true,
5781 ),
5782 finding(
5783 "non-blocking",
5784 "Out of scope thing",
5785 "predates the branch",
5786 "src/cache.rs",
5787 false,
5788 ),
5789 ];
5790
5791 let optional = optional_findings(&findings);
5792 assert_eq!(1, optional.len(), "{optional:?}");
5793 assert_eq!("Timeout is hard coded", optional[0].title);
5794
5795 let prompt = RESPOND_PROMPT
5796 .replace("{number}", "42")
5797 .replace("{context}", "")
5798 .replace("{findings}", &findings_for_prompt(&[findings[0].clone()]))
5799 .replace("{optional}", &optional_block(&findings));
5800 assert!(prompt.contains("Timeout is hard coded"), "{prompt}");
5801 assert!(prompt.contains("will not hold the merge"), "{prompt}");
5802 assert!(
5803 !prompt.contains("Log line says retrying"),
5804 "a nit is dropped, not offered:\n{prompt}"
5805 );
5806 assert!(
5807 !prompt.contains("Out of scope thing"),
5808 "an out of scope point is filed, not offered:\n{prompt}"
5809 );
5810 assert!(!prompt.contains('{'), "{prompt}");
5811
5812 let none = optional_block(&[findings[0].clone()]);
5814 assert_eq!("", none);
5815 }
5816
5817 #[test]
5819 fn the_review_prompt_says_what_happens_to_each_severity() {
5820 assert!(
5821 REVIEW_PROMPT.contains("offered to the author"),
5822 "{REVIEW_PROMPT}"
5823 );
5824 assert!(REVIEW_PROMPT.contains("nit: dropped"), "{REVIEW_PROMPT}");
5825 }
5826
5827 #[test]
5830 fn a_pull_request_with_no_issue_behind_it_carries_no_empty_headings() {
5831 assert_eq!("", context_block(7, "", "", ""));
5832 let only_description = context_block(7, "", "", "What it does.");
5833 assert!(!only_description.contains("as filed"), "{only_description}");
5834 assert!(only_description.contains("What it does."));
5835 }
5836
5837 #[test]
5838 fn a_resumed_open_finding_reaches_review_and_closing_prompts() {
5839 let open = vec![finding(
5840 "blocking",
5841 "Retry bypasses the limit",
5842 "reproduced with max_attempts set to one",
5843 "src/net.rs:88",
5844 true,
5845 )];
5846 let review = review_prompt(
5847 "origin/main",
5848 42,
5849 "Retry a 429",
5850 "",
5851 &Ledger::new(),
5852 &open,
5853 2,
5854 3,
5855 );
5856 let close = close_prompt(
5857 "origin/main",
5858 42,
5859 "Retry a 429",
5860 "",
5861 "9f8e7d6",
5862 Some(&[]),
5863 &Ledger::new(),
5864 &open,
5865 2,
5866 );
5867 for prompt in [review, close] {
5868 assert!(prompt.contains("Retry bypasses the limit"), "{prompt}");
5869 assert!(prompt.contains("src/net.rs:88"), "{prompt}");
5870 assert!(
5871 prompt.contains("reproduced with max_attempts set to one"),
5872 "{prompt}"
5873 );
5874 assert!(!prompt.contains('{'), "{prompt}");
5875 }
5876 }
5877
5878 #[test]
5882 fn a_minor_defect_has_a_severity_that_is_not_blocking() {
5883 assert!(
5884 REVIEW_PROMPT.contains("A minor defect belongs\n here as much as an improvement does")
5885 );
5886 for text in [
5891 schema::review().to_string(),
5892 crate::review_only::review_only_prompt().to_string(),
5893 ] {
5894 assert!(text.contains("as much as an improvement does"), "{text}");
5895 assert!(!text.contains("a genuine improvement"), "{text}");
5896 }
5897 }
5898
5899 #[test]
5902 fn doubt_resolves_away_from_the_field_that_gates() {
5903 for text in [REVIEW_PROMPT.to_string(), schema::review().to_string()] {
5904 assert!(text.contains("say your piece in the finding and label it non-blocking"));
5905 assert!(!text.contains("leave in_scope true"));
5906 }
5907 }
5908
5909 #[test]
5912 fn both_edit_prompts_ask_for_the_smallest_change_that_answers_the_point() {
5913 for prompt in [FIX_PROMPT, RESPOND_PROMPT] {
5914 assert!(
5915 prompt.contains("The smallest change that answers it is"),
5916 "{prompt}"
5917 );
5918 }
5919 assert!(RESPOND_PROMPT.contains("bigger than the\n problem it names"));
5920 }
5921
5922 #[test]
5923 fn a_fixed_disposition_must_explain_what_changed() {
5924 let flat = RESPOND_PROMPT
5925 .split_whitespace()
5926 .collect::<Vec<_>>()
5927 .join(" ");
5928 assert!(
5929 flat.contains("For fixed, say what changed and how it answers the point"),
5930 "{RESPOND_PROMPT}"
5931 );
5932 }
5933
5934 #[test]
5935 fn an_empty_fix_reason_still_renders_as_a_claim_to_check() {
5936 let mut ledger = ledger_with("Unchecked error", "src/net.rs");
5937 for entry in ledger.values_mut() {
5938 entry.outcome = Settled::Fixed;
5939 entry.reasoning.clear();
5940 }
5941
5942 let lines = fixed_lines(&ledger, 0);
5943
5944 assert_eq!(1, lines.len());
5945 assert!(lines[0].contains("a committed change claims to address this point"));
5946 assert!(!lines[0].contains("The author said"));
5947 }
5948
5949 #[test]
5954 fn the_implementor_is_given_the_link_and_the_body() {
5955 let prompt = implement_prompt(
5956 42,
5957 "Retry a 429",
5958 "https://github.com/o/r/issues/42",
5959 "A rate limited response was treated as fatal.",
5960 );
5961 assert!(
5962 prompt.contains("https://github.com/o/r/issues/42"),
5963 "{prompt}"
5964 );
5965 assert!(
5966 prompt.contains("A rate limited response was treated as fatal."),
5967 "{prompt}"
5968 );
5969 assert!(prompt.contains("#42"), "{prompt}");
5970 assert!(prompt.contains("Retry a 429"), "{prompt}");
5971 assert!(!prompt.contains('{'), "{prompt}");
5973 }
5974
5975 #[test]
5981 fn the_prompt_says_a_later_comment_can_change_what_was_asked() {
5982 let prompt = implement_prompt(1, "t", "u", "b");
5983 let lower = prompt
5985 .split_whitespace()
5986 .collect::<Vec<_>>()
5987 .join(" ")
5988 .to_lowercase();
5989 assert!(lower.contains("followed by any comments"), "{prompt}");
5990 assert!(lower.contains("the later one wins"), "{prompt}");
5991 assert!(lower.contains("a comment from spar"), "{prompt}");
5992 assert!(!prompt.contains('{'), "{prompt}");
5993 }
5994
5995 fn worked() -> Implementation {
5997 Implementation {
5998 summary: "Retry a 429 instead of failing the run.".into(),
5999 problem: "A rate limited response was treated as fatal, so one throttled call ended \
6000 a run that had hours of work left in it."
6001 .into(),
6002 changes: vec![
6003 "`send` retries a 429 with the delay the header asks for".into(),
6004 "the retry budget is bounded, so a permanent 429 still ends".into(),
6005 ],
6006 testing: vec![
6007 "`cargo test retries_a_429`".into(),
6008 "point it at a throttled endpoint and watch it finish".into(),
6009 ],
6010 ..Implementation::default()
6011 }
6012 }
6013
6014 #[test]
6015 fn a_pr_body_is_what_it_closes_and_what_changed() {
6018 let body = pr_body(42, &worked(), None, &style());
6019 assert_eq!(
6020 "Closes #42\n\n\
6021 Retry a 429 instead of failing the run.\n\n\
6022 A rate limited response was treated as fatal, so one throttled call \
6023 ended a run that had hours of work left in it.\n\n\
6024 ## What changed\n\n\
6025 - `send` retries a 429 with the delay the header asks for\n\
6026 - the retry budget is bounded, so a permanent 429 still ends\n\n\
6027 ## How to test\n\n\
6028 - `cargo test retries_a_429`\n\
6029 - point it at a throttled endpoint and watch it finish",
6030 body
6031 );
6032 }
6033
6034 #[test]
6037 fn a_body_with_nothing_to_list_carries_no_empty_headings() {
6038 let work = Implementation {
6039 summary: "Retry a 429 instead of failing the run.".into(),
6040 ..Implementation::default()
6041 };
6042 assert_eq!(
6043 "Closes #42\n\nRetry a 429 instead of failing the run.",
6044 pr_body(42, &work, None, &style())
6045 );
6046 }
6047
6048 #[test]
6049 fn a_pr_body_survives_an_implementor_that_said_nothing() {
6050 assert_eq!(
6051 "Closes #7",
6052 pr_body(7, &Implementation::default(), None, &style())
6053 );
6054 }
6055
6056 #[test]
6059 fn blank_list_entries_do_not_earn_a_heading() {
6060 let work = Implementation {
6061 summary: "Did a thing.".into(),
6062 changes: vec![String::new(), " ".into()],
6063 ..Implementation::default()
6064 };
6065 let body = pr_body(42, &work, None, &style());
6066 assert!(!body.contains("What changed"), "{body}");
6067 }
6068
6069 #[test]
6070 fn notes_appear_only_when_there_is_something_to_note() {
6071 let mut work = worked();
6072 assert!(!pr_body(42, &work, None, &style()).contains("## Notes"));
6073 work.notes = Some("The retry is not applied to streaming calls.".into());
6074 let body = pr_body(42, &work, None, &style());
6075 assert!(body.contains("## Notes"), "{body}");
6076 assert!(body.contains("streaming calls"), "{body}");
6077 }
6078
6079 #[test]
6082 fn declining_posts_the_reason_and_not_the_summary() {
6083 let work = Implementation {
6084 not_worth_doing: true,
6085 reason: "Already fixed in 1.2, and the report predates it.".into(),
6086 summary: "Nothing to do.".into(),
6087 ..Implementation::default()
6088 };
6089 assert_eq!(
6090 "Already fixed in 1.2, and the report predates it.",
6091 no_pr_note(&work, &style())
6092 );
6093 }
6094
6095 #[test]
6096 fn reporting_work_and_committing_none_says_that_rather_than_the_summary() {
6097 let work = Implementation {
6098 summary: "Retry a 429 instead of failing the run.".into(),
6099 ..Implementation::default()
6100 };
6101 let note = no_pr_note(&work, &style());
6102 assert_eq!(
6103 "Nothing was committed, so there is nothing to review.",
6104 note
6105 );
6106 }
6107
6108 #[test]
6109 fn declining_without_a_reason_still_says_something() {
6110 let work = Implementation {
6111 not_worth_doing: true,
6112 ..Implementation::default()
6113 };
6114 assert!(no_pr_note(&work, &style()).contains("no reason given"));
6115 }
6116
6117 #[test]
6118 fn a_skip_comment_is_only_the_reasoning() {
6119 let item = SkippedItem {
6120 issue: 3,
6121 title: "t".into(),
6122 tracker: false,
6123 reasons: [
6124 ("claude".to_string(), "Already fixed in 1.2.".to_string()),
6125 ("codex".to_string(), "Duplicate of #2.".to_string()),
6126 ]
6127 .into_iter()
6128 .collect(),
6129 };
6130 let text = skip_comment(&item, &style());
6131 assert!(text.contains("Already fixed in 1.2."), "{text}");
6132 assert!(text.contains("Duplicate of #2."), "{text}");
6133 assert!(
6134 !text.contains("claude") && !text.contains("codex"),
6135 "{text}"
6136 );
6137 assert!(!text.to_lowercase().contains("not scheduled"), "{text}");
6138 assert!(text.lines().count() <= 3, "{text}");
6139 }
6140
6141 #[test]
6142 fn findings_for_a_model_keep_full_detail() {
6143 let long = "x".repeat(2000);
6144 let text = findings_for_prompt(&[finding("blocking", "T", &long, "a.rs", true)]);
6145 assert!(
6146 text.contains(&long),
6147 "a model needs the whole finding, only humans need brevity"
6148 );
6149 }
6150
6151 #[test]
6152 fn findings_for_a_model_are_never_empty() {
6153 assert_eq!("(none)", findings_for_prompt(&[]));
6154 }
6155}
6156
6157#[cfg(test)]
6158mod outcome_tests {
6159 use super::*;
6160 use crate::model::{Dispute, Severity};
6161
6162 fn style() -> Style {
6163 Style::default()
6164 }
6165
6166 fn state_with(disputes: Vec<(&str, &str)>, filed: Vec<&str>) -> IssueRun {
6167 let mut s = IssueRun::new(482, "t");
6168 s.disputes = disputes
6169 .into_iter()
6170 .map(|(title, reasoning)| Dispute {
6171 title: title.into(),
6172 file: String::new(),
6173 reasoning: reasoning.into(),
6174 })
6175 .collect();
6176 s.filed = filed.into_iter().map(String::from).collect();
6177 s
6178 }
6179
6180 fn finding(title: &str, file: &str) -> Finding {
6181 Finding {
6182 severity: Severity::Blocking,
6183 title: title.into(),
6184 detail: "d".into(),
6185 file: file.into(),
6186 in_scope: true,
6187 ..Default::default()
6188 }
6189 }
6190
6191 fn graded(severity: Severity, title: &str, file: &str, in_scope: bool) -> Finding {
6192 Finding {
6193 severity,
6194 in_scope,
6195 ..finding(title, file)
6196 }
6197 }
6198
6199 #[test]
6200 fn outcome_mode_routes_final_results_to_the_configured_sink() {
6201 assert_eq!(OutcomeSink::PullRequest, outcome_sink(PrComments::Outcome));
6202 assert_eq!(OutcomeSink::PullRequest, outcome_sink(PrComments::Rounds));
6203 assert_eq!(OutcomeSink::Terminal, outcome_sink(PrComments::None));
6204 }
6205
6206 #[test]
6209 fn a_clean_approval_says_nothing() {
6210 let state = state_with(vec![], vec![]);
6211 assert!(outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).is_none());
6212 }
6213
6214 #[test]
6219 fn a_downgraded_finding_still_reaches_the_pull_request() {
6220 let mut state = state_with(vec![], vec![]);
6221 let kept = graded(
6222 Severity::NonBlocking,
6223 "Timeout is not configurable",
6224 "n.rs",
6225 true,
6226 );
6227 record_nonblocking_outcome(&mut state, &kept, None);
6228
6229 assert_eq!(1, state.noted.len());
6232
6233 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
6234 assert!(text.contains("Noted, not blocking"), "{text}");
6235 assert!(
6236 text.contains("Timeout is not configurable (n.rs)"),
6237 "{text}"
6238 );
6239 }
6240
6241 #[test]
6244 fn a_point_that_was_filed_is_not_also_noted() {
6245 let mut state = state_with(vec![], vec![]);
6246 let finding = graded(Severity::NonBlocking, "Timeout", "n.rs", true);
6247 record_nonblocking_outcome(&mut state, &finding, None);
6248 record_nonblocking_outcome(
6249 &mut state,
6250 &finding,
6251 Some(&Followup::Recorded("https://example.invalid/9".into())),
6252 );
6253 assert!(state.noted.is_empty());
6254 assert_eq!(vec!["https://example.invalid/9"], state.filed);
6255 }
6256
6257 #[test]
6258 fn filing_a_moved_point_removes_its_earlier_note() {
6259 let mut state = state_with(vec![], vec![]);
6260 let earlier = graded(Severity::NonBlocking, "Timeout", "src/net.rs:10", true);
6261 let moved = graded(Severity::NonBlocking, "Timeout", "src/net.rs:12", false);
6262 record_nonblocking_outcome(&mut state, &earlier, None);
6263
6264 record_nonblocking_outcome(
6265 &mut state,
6266 &moved,
6267 Some(&Followup::Recorded("https://example.invalid/10".into())),
6268 );
6269
6270 assert!(state.noted.is_empty());
6271 assert_eq!(vec!["https://example.invalid/10"], state.filed);
6272 }
6273
6274 #[test]
6275 fn an_unrecorded_nonblocking_followup_remains_noted() {
6276 let finding = graded(Severity::NonBlocking, "Timeout", "n.rs", true);
6277 for outcome in [
6278 Followup::Covered("https://example.invalid/closed".into()),
6279 Followup::Dropped("follow-ups are off"),
6280 Followup::Failed,
6281 ] {
6282 let mut state = state_with(vec![], vec![]);
6283 record_nonblocking_outcome(&mut state, &finding, Some(&outcome));
6284 assert_eq!(1, state.noted.len(), "{outcome:?}");
6285 assert!(state.filed.is_empty(), "{outcome:?}");
6286 }
6287 }
6288
6289 #[test]
6291 fn a_point_noted_twice_is_listed_once() {
6292 let mut state = state_with(vec![], vec![]);
6293 let raised = graded(
6294 Severity::NonBlocking,
6295 "Timeout is not configurable",
6296 "n.rs",
6297 true,
6298 );
6299 let reworded = graded(
6300 Severity::NonBlocking,
6301 "timeout is not configurable!",
6302 "n.rs",
6303 true,
6304 );
6305 record_nonblocking_outcome(&mut state, &raised, None);
6306 record_nonblocking_outcome(&mut state, &reworded, None);
6307 assert_eq!(1, state.noted.len());
6308 }
6309
6310 #[test]
6311 fn same_title_notes_in_different_files_are_both_kept() {
6312 let mut state = state_with(vec![], vec![]);
6313 for file in ["src/a.rs", "src/b.rs"] {
6314 let finding = graded(Severity::NonBlocking, "Unchecked error", file, true);
6315 record_nonblocking_outcome(&mut state, &finding, None);
6316 }
6317 assert_eq!(2, state.noted.len());
6318 }
6319
6320 #[test]
6321 fn same_title_notes_at_two_sites_in_one_review_are_both_kept() {
6322 let findings = vec![
6323 graded(
6324 Severity::NonBlocking,
6325 "Unchecked error",
6326 "src/a.rs:10",
6327 true,
6328 ),
6329 graded(
6330 Severity::NonBlocking,
6331 "Unchecked error",
6332 "src/a.rs:200",
6333 true,
6334 ),
6335 ];
6336 let mut state = state_with(vec![], vec![]);
6337
6338 for finding in &findings {
6339 record_nonblocking_outcome_with_match(
6340 &mut state,
6341 finding,
6342 None,
6343 unique_stable_finding(&findings, finding),
6344 );
6345 }
6346
6347 assert_eq!(2, state.noted.len());
6348 }
6349
6350 #[test]
6351 fn settling_one_of_two_same_title_notes_keeps_the_other() {
6352 let first = graded(
6353 Severity::NonBlocking,
6354 "Unchecked error",
6355 "src/a.rs:10",
6356 true,
6357 );
6358 let second = graded(
6359 Severity::NonBlocking,
6360 "Unchecked error",
6361 "src/a.rs:200",
6362 true,
6363 );
6364 let mut state = state_with(vec![], vec![]);
6365 remember_noted(&mut state, &first, false);
6366 remember_noted(&mut state, &second, false);
6367
6368 forget_noted(&mut state, &first, false);
6369
6370 assert_eq!(1, state.noted.len());
6371 assert_eq!("src/a.rs:200", state.noted[0].file);
6372 }
6373
6374 #[test]
6375 fn same_title_disputes_at_two_sites_are_both_kept() {
6376 let mut state = state_with(vec![], vec![]);
6377 for file in ["src/a.rs:10", "src/a.rs:200"] {
6378 remember_dispute(
6379 &mut state,
6380 Dispute {
6381 title: "Unchecked error".into(),
6382 file: file.into(),
6383 reasoning: "the caller handles it".into(),
6384 },
6385 false,
6386 );
6387 }
6388
6389 assert_eq!(2, state.disputes.len());
6390 }
6391
6392 #[test]
6393 fn a_later_nonblocking_verdict_replaces_a_prior_dispute() {
6394 let mut state = state_with(vec![], vec![]);
6395 let finding = graded(Severity::NonBlocking, "Unchecked error", "src/a.rs", true);
6396 remember_dispute(
6397 &mut state,
6398 Dispute {
6399 title: finding.title.clone(),
6400 file: finding.file.clone(),
6401 reasoning: "the caller handles it".into(),
6402 },
6403 true,
6404 );
6405
6406 record_nonblocking_outcome(&mut state, &finding, None);
6407
6408 assert!(state.disputes.is_empty());
6409 assert_eq!(1, state.noted.len());
6410 }
6411
6412 #[test]
6413 fn a_note_moving_lines_in_the_same_file_is_updated() {
6414 let mut state = state_with(vec![], vec![]);
6415 let first = graded(
6416 Severity::NonBlocking,
6417 "Unchecked error",
6418 "src/a.rs:12",
6419 true,
6420 );
6421 let moved = graded(
6422 Severity::NonBlocking,
6423 "Unchecked error",
6424 "src/a.rs:19",
6425 true,
6426 );
6427 record_nonblocking_outcome(&mut state, &first, None);
6428 record_nonblocking_outcome(&mut state, &moved, None);
6429 assert_eq!(1, state.noted.len());
6430 assert_eq!("src/a.rs:19", state.noted[0].file);
6431 }
6432
6433 #[test]
6434 fn a_settled_point_removes_its_stale_note() {
6435 for outcome in [Settled::Fixed, Settled::Refuted, Settled::Filed] {
6436 let noted = graded(Severity::NonBlocking, "Unchecked error", "src/a.rs", true);
6437 let mut state = state_with(vec![], vec![]);
6438 record_nonblocking_outcome(&mut state, ¬ed, None);
6439 forget_noted(&mut state, ¬ed, true);
6440 assert!(state.noted.is_empty(), "{outcome}");
6441 }
6442 }
6443
6444 #[test]
6445 fn settling_a_moved_point_removes_its_old_dispute() {
6446 let old = graded(Severity::Blocking, "Unchecked error", "src/a.rs:10", true);
6447 let moved = graded(Severity::Blocking, "Unchecked error", "src/a.rs:12", true);
6448 let mut state = state_with(vec![], vec![]);
6449 remember_dispute(
6450 &mut state,
6451 Dispute {
6452 title: old.title,
6453 file: old.file,
6454 reasoning: "the caller handles it".into(),
6455 },
6456 true,
6457 );
6458
6459 forget_dispute(&mut state, &moved, true);
6460
6461 assert!(state.disputes.is_empty());
6462 }
6463
6464 #[test]
6467 fn a_deadlocked_point_is_not_also_noted() {
6468 let mut state = state_with(vec![], vec![]);
6469 let noted = graded(Severity::NonBlocking, "Unbounded loop", "x.rs", true);
6470 record_nonblocking_outcome(&mut state, ¬ed, None);
6471 let points = vec![finding("Unbounded loop", "x.rs")];
6472 let text = outcome_comment(
6473 &state,
6474 &Ledger::new(),
6475 &Ending::Deadlocked(&points),
6476 &style(),
6477 )
6478 .unwrap();
6479 assert!(!text.contains("Noted, not blocking"), "{text}");
6480 }
6481
6482 #[test]
6483 fn clipping_a_rendered_title_does_not_break_duplicate_suppression() {
6484 let mut compact = style();
6485 compact.max_title_chars = 5;
6486 let mut state = state_with(
6487 vec![("abcdefghij", "the caller already handles it")],
6488 vec![],
6489 );
6490 state
6491 .noted
6492 .push(graded(Severity::NonBlocking, "abcdefghij", "x.rs", true));
6493 state.disputes[0].file = "x.rs".into();
6494 let points = vec![finding("abcdefghij", "x.rs")];
6495
6496 let text = outcome_comment(
6497 &state,
6498 &Ledger::new(),
6499 &Ending::Unresolved(&points),
6500 &compact,
6501 )
6502 .unwrap();
6503
6504 assert!(!text.contains("Raised and refuted"), "{text}");
6505 assert!(!text.contains("Noted, not blocking"), "{text}");
6506 }
6507
6508 #[test]
6509 fn an_approval_that_filed_follow_ups_links_them() {
6510 let state = state_with(
6511 vec![],
6512 vec![
6513 "https://github.com/you/thing/issues/485",
6514 "https://github.com/you/thing/issues/486",
6515 ],
6516 );
6517 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
6518 assert!(text.contains("Filed separately: #485, #486"), "{text}");
6519 }
6520
6521 #[test]
6525 fn a_run_that_changed_nothing_does_not_claim_there_is_something_to_read() {
6526 let state = state_with(vec![], vec![]);
6527 let text = outcome_comment(&state, &Ledger::new(), &Ending::Unchanged, &style()).unwrap();
6528 assert!(text.contains("changed nothing"), "{text}");
6529 assert!(!text.contains("was pushed"), "{text}");
6530 }
6531
6532 #[test]
6536 fn an_unresolved_close_names_what_is_still_wrong() {
6537 let state = state_with(vec![], vec![]);
6538 let left = vec![Finding {
6539 detail: "The guard sits after the early return.".into(),
6540 ..finding("The retry fix never reaches the 429 path", "src/net.rs:88")
6541 }];
6542 let text =
6543 outcome_comment(&state, &Ledger::new(), &Ending::Unresolved(&left), &style()).unwrap();
6544 assert!(text.contains("These points are still open"), "{text}");
6545 assert!(
6546 text.contains("The retry fix never reaches the 429 path (src/net.rs:88)"),
6547 "{text}"
6548 );
6549 assert!(
6550 text.contains("The guard sits after the early return."),
6551 "{text}"
6552 );
6553 assert!(!text.contains("has not been reviewed"), "{text}");
6556 }
6557
6558 #[test]
6562 fn running_out_of_rounds_says_what_that_means_for_the_reader() {
6563 let state = state_with(vec![], vec![]);
6564 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
6565 assert!(text.contains("has not been reviewed"), "{text}");
6566 assert!(
6567 !text.to_lowercase().contains("round 3"),
6568 "no round numbers: {text}"
6569 );
6570 assert!(!text.to_lowercase().contains("convergence"), "{text}");
6571 }
6572
6573 #[test]
6574 fn a_failed_close_reports_unread_fixes_and_carried_blockers() {
6575 let state = state_with(vec![], vec![]);
6576 let open = vec![Finding {
6577 detail: "the failure is still discarded".into(),
6578 ..finding("Unchecked error", "src/net.rs:88")
6579 }];
6580 let text = outcome_comment_with_unread(
6581 &state,
6582 &Ledger::new(),
6583 &Ending::OutOfRounds,
6584 &open,
6585 &style(),
6586 )
6587 .unwrap();
6588 assert!(text.contains("has not been reviewed"), "{text}");
6589 assert!(text.contains("These points were already open"), "{text}");
6590 assert!(text.contains("Unchecked error (src/net.rs:88)"), "{text}");
6591 }
6592
6593 #[test]
6594 fn a_deadlock_names_the_point_they_could_not_settle() {
6595 let state = state_with(vec![], vec![]);
6596 let points = [finding("Retry loop never terminates", "src/net.rs:88")];
6597 let text = outcome_comment(
6598 &state,
6599 &Ledger::new(),
6600 &Ending::Deadlocked(&points),
6601 &style(),
6602 )
6603 .unwrap();
6604 assert!(
6605 text.contains("Retry loop never terminates (src/net.rs:88)"),
6606 "{text}"
6607 );
6608 assert!(text.contains("could not settle"), "{text}");
6609 }
6610
6611 #[test]
6613 fn refutations_survive_because_nothing_else_carries_them() {
6614 let state = state_with(
6615 vec![(
6616 "Error is swallowed",
6617 "the caller already validates the file",
6618 )],
6619 vec![],
6620 );
6621 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
6622 assert!(text.contains("Raised and refuted:"), "{text}");
6623 assert!(
6624 text.contains("The caller already validates the file"),
6625 "{text}"
6626 );
6627 }
6628
6629 #[test]
6630 fn no_agent_names_counts_or_round_numbers_reach_the_thread() {
6631 let state = state_with(
6632 vec![("A point", "a reason")],
6633 vec!["https://github.com/you/thing/issues/485"],
6634 );
6635 let left = vec![finding("A point", "a.rs")];
6636 for ending in [
6637 Ending::Approved,
6638 Ending::OutOfRounds,
6639 Ending::Unresolved(&left),
6640 ] {
6641 let text = outcome_comment(&state, &Ledger::new(), &ending, &style()).unwrap();
6642 let lower = text.to_lowercase();
6643 for banned in ["claude", "codex", "blocking,", "nit,", " fixed."] {
6644 assert!(
6645 !lower.contains(banned),
6646 "{banned:?} leaked into the thread:\n{text}"
6647 );
6648 }
6649 for n in 1..9 {
6651 assert!(
6652 !lower.contains(&format!("round {n}")),
6653 "a round number leaked into the thread:\n{text}"
6654 );
6655 }
6656 }
6657 }
6658
6659 #[test]
6660 fn a_refutation_is_allowed_to_make_its_case() {
6663 let reasoning = "The caller validates against the schema first. \
6664 The discarded error is therefore unreachable in practice. ";
6665 let state = state_with(
6666 vec![("A point", &reasoning.repeat(6))],
6667 vec!["https://github.com/you/thing/issues/485"],
6668 );
6669 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
6670 assert!(
6671 !text.contains("..."),
6672 "nothing was cut mid thought:\n{text}"
6673 );
6674 assert!(text.len() < 4000, "{} chars", text.len());
6675 }
6676
6677 #[test]
6678 fn a_url_that_is_not_an_issue_link_is_left_alone() {
6679 assert_eq!(
6680 "#485",
6681 as_reference("https://github.com/you/thing/issues/485")
6682 );
6683 assert_eq!("note: something", as_reference("note: something"));
6684 }
6685}
6686
6687#[cfg(test)]
6688mod filed_reference_tests {
6689 use super::*;
6690
6691 #[test]
6692 fn an_issue_url_yields_its_number() {
6693 assert_eq!(
6694 Some(485),
6695 filed_issue_number("https://github.com/you/thing/issues/485")
6696 );
6697 }
6698
6699 #[test]
6702 fn a_local_note_yields_nothing() {
6703 assert_eq!(None, filed_issue_number("note: Retry is unbounded"));
6704 assert_eq!(None, filed_issue_number(""));
6705 assert_eq!(
6706 None,
6707 filed_issue_number("https://github.com/you/thing/issues/")
6708 );
6709 }
6710}
6711
6712#[cfg(test)]
6713mod followup_restraint_tests {
6714 use super::*;
6715 use crate::model::Severity;
6716
6717 fn cfg_with(followups: Followups, non_blocking: bool, nits: bool, cap: usize) -> Config {
6718 let mut cfg =
6719 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
6720 .unwrap();
6721 cfg.loop_cfg.followups = followups;
6722 cfg.loop_cfg.file_non_blocking = non_blocking;
6723 cfg.loop_cfg.file_nits = nits;
6724 cfg.loop_cfg.max_followups = cap;
6725 cfg
6726 }
6727
6728 fn finding(severity: Severity, title: &str, in_scope: bool) -> Finding {
6729 Finding {
6730 severity,
6731 title: title.into(),
6732 detail: "d".into(),
6733 file: "a.rs".into(),
6734 in_scope,
6735 ..Default::default()
6736 }
6737 }
6738
6739 #[test]
6743 fn a_non_blocking_finding_is_not_a_tracker_item_by_default() {
6744 let cfg = cfg_with(Followups::Issues, false, false, 5);
6745 assert!(!cfg.loop_cfg.file_non_blocking);
6746 assert!(!cfg.loop_cfg.file_nits);
6747 }
6748
6749 #[test]
6750 fn follow_ups_stay_off_the_tracker_by_default() {
6751 let cfg =
6752 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
6753 .unwrap();
6754 assert_eq!(
6755 Followups::Local,
6756 cfg.loop_cfg.followups,
6757 "the tracker is somebody's queue; the default must not write to it"
6758 );
6759 assert_eq!(5, cfg.loop_cfg.max_followups);
6760 }
6761
6762 #[test]
6764 fn only_out_of_scope_defects_qualify_at_the_defaults() {
6765 let cfg = cfg_with(Followups::Issues, false, false, 5);
6766 let qualifies = |f: &Finding| match f.severity {
6767 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking && f.in_scope,
6768 Severity::Nit => cfg.loop_cfg.file_nits && f.in_scope,
6769 Severity::Blocking => false,
6770 } || !f.in_scope;
6771
6772 assert!(qualifies(&finding(
6773 Severity::Blocking,
6774 "pre-existing",
6775 false
6776 )));
6777 assert!(!qualifies(&finding(
6778 Severity::NonBlocking,
6779 "improvement",
6780 true
6781 )));
6782 assert!(!qualifies(&finding(Severity::Nit, "taste", true)));
6783 assert!(!qualifies(&finding(
6784 Severity::Blocking,
6785 "fix it here",
6786 true
6787 )));
6788 }
6789
6790 #[test]
6791 fn opening_it_up_lets_non_blocking_findings_through_again() {
6792 let cfg = cfg_with(Followups::Issues, true, false, 5);
6793 assert!(cfg.loop_cfg.file_non_blocking);
6794 }
6795
6796 #[test]
6798 fn the_cap_is_a_real_backstop() {
6799 let cfg = cfg_with(Followups::Issues, false, false, 3);
6800 let mut state = IssueRun::new(1, "t");
6801 state.filed = (0..3).map(|n| format!("url{n}")).collect();
6802 assert!(state.filed.len() >= cfg.loop_cfg.max_followups);
6803 }
6804
6805 #[test]
6809 fn the_cap_bounds_what_one_run_can_spawn() {
6810 let cfg = cfg_with(Followups::Issues, false, false, 5);
6811 assert!(
6812 cfg.loop_cfg.max_followups <= 5,
6813 "a run that can file ten follow-ups is a branching process"
6814 );
6815 }
6816}
6817
6818#[cfg(test)]
6822mod followup_outcome_tests {
6823 use super::*;
6824
6825 const URL: &str = "https://github.com/you/thing/issues/485";
6826
6827 fn entry(recorded: Followup) -> Option<(Settled, String)> {
6828 filed_entry(&recorded, "It predates this branch.")
6829 }
6830
6831 #[test]
6835 fn a_failed_followup_settles_nothing() {
6836 assert_eq!(None, entry(Followup::Failed));
6837 }
6838
6839 #[test]
6840 fn an_uncertain_external_write_blocks_later_issue_followups_for_this_run() {
6841 let mut state = IssueRun::new(1, "review");
6842 let uncertain = SparError::uncertain_write("the result could not be verified");
6843 assert_eq!(Followup::Failed, failed_followup(&mut state, &uncertain));
6844 assert!(external_followup_write_paused(Followups::Issues, &state));
6845 assert!(!external_followup_write_paused(Followups::Local, &state));
6846 assert_eq!(1, state.notes.len());
6847
6848 assert_eq!(Followup::Failed, failed_followup(&mut state, &uncertain));
6849 assert_eq!(1, state.notes.len(), "the recovery note was duplicated");
6850
6851 let mut ordinary = IssueRun::new(2, "review");
6852 let error = SparError::new("permission denied");
6853 assert_eq!(Followup::Failed, failed_followup(&mut ordinary, &error));
6854 assert!(!external_followup_write_paused(
6855 Followups::Issues,
6856 &ordinary
6857 ));
6858 }
6859
6860 #[test]
6861 fn a_recorded_followup_is_filed_and_says_where() {
6862 let (outcome, reasoning) = entry(Followup::Recorded(URL.into())).unwrap();
6863 assert_eq!(Settled::Filed, outcome);
6864 assert!(
6865 reasoning.contains("It predates this branch."),
6866 "{reasoning}"
6867 );
6868 assert!(reasoning.contains("#485"), "{reasoning}");
6869 }
6870
6871 #[test]
6880 fn a_finding_that_matches_a_closed_issue_is_filed_again_with_the_reference() {
6881 let body = regression_body("The retry loop spins on 429.", 485);
6882 assert!(body.contains("The retry loop spins on 429."), "{body}");
6883 assert!(body.contains("#485"), "{body}");
6884 assert!(body.contains("closed"), "{body}");
6885
6886 let recorded = Followup::from(Filed::Opened(9, URL.into()));
6888 assert_eq!(Followup::Recorded(URL.into()), recorded);
6889 assert_eq!(Some(URL), recorded.url());
6890 }
6891
6892 #[test]
6895 fn an_open_issue_that_already_covers_the_point_is_still_a_reference() {
6896 for filed in [
6897 Filed::Opened(9, URL.into()),
6898 Filed::AddedTo(9, URL.into()),
6899 Filed::Covered(9, URL.into()),
6900 ] {
6901 assert_eq!(Some(URL), Followup::from(filed).url());
6902 }
6903 }
6904
6905 #[test]
6909 fn a_dropped_followup_is_settled_but_never_reported_as_filed() {
6910 let (outcome, reasoning) = entry(Followup::Dropped("follow-ups are off")).unwrap();
6911 assert_eq!(Settled::Dropped, outcome);
6912 assert!(reasoning.contains("follow-ups are off"), "{reasoning}");
6913 assert!(reasoning.contains("Not filed"), "{reasoning}");
6914 }
6915
6916 fn ledger_of(outcome: Settled, reasoning: &str) -> Ledger {
6917 let mut ledger = Ledger::new();
6918 ledger.insert(
6919 finding_key("A pre-existing leak", "src/x.rs"),
6920 LedgerEntry {
6921 title: "A pre-existing leak".into(),
6922 file: "src/x.rs".into(),
6923 reasoning: reasoning.into(),
6924 round: 1,
6925 reraised: 0,
6926 outcome,
6927 },
6928 );
6929 ledger
6930 }
6931
6932 #[test]
6936 fn the_settled_block_tells_a_filed_point_from_a_dropped_one() {
6937 let filed = settled_block(&ledger_of(Settled::Filed, "Tracked in #9."));
6938 assert!(filed.contains("out of scope here, and filed"), "{filed}");
6939
6940 let dropped = settled_block(&ledger_of(Settled::Dropped, "Not filed anywhere: off."));
6941 assert!(
6942 dropped.contains("out of scope here, and not filed"),
6943 "{dropped}"
6944 );
6945 assert!(dropped.contains("A pre-existing leak"), "{dropped}");
6946 }
6947
6948 #[test]
6951 fn a_deadlocked_point_that_was_never_filed_does_not_claim_to_be() {
6952 let points = [Finding {
6953 severity: Severity::Blocking,
6954 title: "A pre-existing leak".into(),
6955 detail: "d".into(),
6956 file: "src/x.rs".into(),
6957 in_scope: false,
6958 ..Default::default()
6959 }];
6960 let text = outcome_comment(
6961 &IssueRun::new(1, "t"),
6962 &ledger_of(Settled::Dropped, "Not filed anywhere: follow-ups are off."),
6963 &Ending::Deadlocked(&points),
6964 &Style::default(),
6965 )
6966 .unwrap();
6967 assert!(text.contains("not filed"), "{text}");
6968 assert!(!text.contains("Filed as out of scope"), "{text}");
6969 }
6970}
6971
6972#[cfg(test)]
6973mod issue_report_tests {
6974 use super::*;
6975 use crate::model::Severity;
6976
6977 fn reported() -> Finding {
6980 Finding {
6981 severity: Severity::Blocking,
6982 title: "sendPaymentAsync bypasses drain mode and spending limits".into(),
6983 detail: "The async path skips every admission check payInvoice applies.".into(),
6984 file: "src/node.ts:412".into(),
6985 in_scope: false,
6986 problem: Some(
6987 "`BeignetNode.sendPaymentAsync()` submits a payment directly to the Lightning \
6988 engine without applying the safeguards used by `payInvoice()`.\n\nThe async path \
6989 does not:\n\n- call `_checkDraining()`\n- call `_checkSpendLimit()`"
6990 .into(),
6991 ),
6992 reproduction: Some(
6993 "1. Create a `BeignetNode` with `dailySpendLimitSats: 1`.\n2. Enable drain mode.\n\
6994 3. Submit a 1,000 sat invoice.\n\nActual result:\n\n- The engine is called.\n\
6995 - `spentSats` remains 0."
6996 .into(),
6997 ),
6998 impact: Some(
6999 "An authorized client can submit async payments up to the available outbound \
7000 liquidity despite the configured limits."
7001 .into(),
7002 ),
7003 expected: Some(
7004 "- Reject new payments while draining.\n- Enforce the per-payment limit before \
7005 submission.\n- Cover both paths with regression tests.\n\nThis predates the \
7006 current branch."
7007 .into(),
7008 ),
7009 }
7010 }
7011
7012 #[test]
7013 fn a_reported_finding_becomes_a_bug_report() {
7014 let body = issue_report(&reported());
7015 for heading in [
7016 "## Problem",
7017 "## Reproduction",
7018 "## Impact",
7019 "## Expected behavior",
7020 ] {
7021 assert!(body.contains(heading), "missing {heading}:\n{body}");
7022 }
7023 let at = |h: &str| body.find(h).unwrap();
7025 assert!(at("## Problem") < at("## Reproduction"));
7026 assert!(at("## Reproduction") < at("## Impact"));
7027 assert!(at("## Impact") < at("## Expected behavior"));
7028 }
7029
7030 #[test]
7031 fn the_substance_survives_the_outbound_gates() {
7032 let repo_style = Style::default();
7033 let body = crate::style::issue_body(&issue_report(&reported()), &repo_style);
7034 for kept in [
7035 "_checkDraining()",
7036 "Actual result:",
7037 "outbound liquidity",
7038 "regression tests",
7039 "predates the current branch",
7040 ] {
7041 assert!(body.contains(kept), "the gate ate {kept:?}:\n{body}");
7042 }
7043 assert!(!body.contains("..."), "something was cut:\n{body}");
7044 }
7045
7046 #[test]
7049 fn an_ordinary_finding_is_still_just_its_detail() {
7050 let plain = Finding {
7051 severity: Severity::NonBlocking,
7052 title: "Name is vague".into(),
7053 detail: "The variable could say what it holds.".into(),
7054 file: "a.rs".into(),
7055 in_scope: true,
7056 ..Default::default()
7057 };
7058 assert_eq!(
7059 "The variable could say what it holds.",
7060 issue_report(&plain)
7061 );
7062 }
7063
7064 #[test]
7067 fn only_the_sections_that_were_written_appear() {
7068 let partial = Finding {
7069 problem: Some("The guard is inverted.".into()),
7070 expected: Some("It should reject rather than accept.".into()),
7071 ..reported()
7072 };
7073 let partial = Finding {
7074 reproduction: None,
7075 impact: None,
7076 ..partial
7077 };
7078 let body = issue_report(&partial);
7079 assert!(body.contains("## Problem") && body.contains("## Expected behavior"));
7080 assert!(!body.contains("## Reproduction"), "{body}");
7081 assert!(!body.contains("## Impact"), "{body}");
7082 }
7083
7084 #[test]
7087 fn the_summary_line_is_not_printed_twice() {
7088 let echoed = Finding {
7089 detail: "The guard is inverted so it rejects valid input.".into(),
7090 problem: Some("The guard is inverted so it rejects valid input.".into()),
7091 reproduction: None,
7092 impact: None,
7093 expected: None,
7094 ..reported()
7095 };
7096 let body = issue_report(&echoed);
7097 assert_eq!(1, body.matches("The guard is inverted").count(), "{body}");
7098 }
7099}