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