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