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::{PushTarget, 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 let target = repo.publish_new_branch(work_dir, branch, &base)?;
899
900 let description = pr_body(number, &work, checked.as_deref(), &repo.style);
901 let pr = match repo.pr_for_branch(branch) {
902 Some(existing) => existing,
903 None => {
904 let body = description.clone();
905 repo.create_pr(
906 work_dir,
907 branch,
908 &target,
909 &base,
910 &format!("{} (#{number})", item.title),
911 &body,
912 )?
913 }
914 };
915 repo.record_branch(branch, "pr", pr.number);
916 state.pr = Some(pr.url.clone());
917 log!("#{number}: PR {}", pr.url);
918
919 let ctx = LoopCtx {
920 work_dir: work_dir.to_path_buf(),
921 branch: branch.to_string(),
922 target,
923 pr_number: pr.number,
924 context: context_block(number, &issue.url, &body, &description),
925 label: format!("#{number}"),
926 subject: number,
927 title: item.title.clone(),
928 start_round: 1,
929 holder: cfg.other(&holder),
930 release: Release::Issue(number),
931 };
932 review_loop(agents, cfg, repo, &ctx, state, ledger, Vec::new())
933}
934
935pub fn resume_pr(
946 agents: &[Agent],
947 cfg: &Config,
948 repo: &Repo,
949 pr_number: i64,
950 holder_override: Option<&str>,
951) -> IssueRun {
952 let failed = |e: SparError| {
953 log!("PR #{pr_number} failed: {e}");
954 let mut state = IssueRun::new(pr_number, format!("PR #{pr_number}"));
955 state.status = Status::Error;
956 state.notes.push(e.to_string());
957 state
958 };
959
960 let pr = match repo.pr_view(pr_number) {
961 Ok(pr) => pr,
962 Err(e) => return failed(e),
963 };
964
965 let Some(target) = repo.pr_push_target(&pr) else {
972 log!("PR #{pr_number} comes from a fork, reviewing it without changing it");
973 return crate::review_only::review_pr(
974 agents,
975 cfg,
976 repo,
977 pr_number,
978 crate::review_only::ReviewOpts::default(),
979 );
980 };
981
982 match resume_inner(agents, cfg, repo, pr, target, holder_override) {
983 Ok(state) => state,
984 Err(e) => failed(e),
985 }
986}
987
988fn resume_inner(
989 agents: &[Agent],
990 cfg: &Config,
991 repo: &Repo,
992 pr: PrView,
993 target: PushTarget,
994 holder_override: Option<&str>,
995) -> Result<IssueRun> {
996 let pr_number = pr.number;
997 if !pr.is_open() {
998 return Err(spar_err!("PR #{pr_number} is {}", pr.state.to_lowercase()));
999 }
1000
1001 let subject = pr
1002 .closing_issues_references
1003 .first()
1004 .map(|r| r.number)
1005 .unwrap_or(pr_number);
1006
1007 if let Some(holder) = holder_override {
1008 if !cfg.has_agent(holder) {
1009 return Err(spar_err!(
1010 "--next must name one of: {}",
1011 cfg.agent_names().join(", ")
1012 ));
1013 }
1014 }
1015 let (work_dir, branch) = repo.worktree_for_pr(&pr, &target)?;
1016 let actual_head = checked_head(repo, &work_dir)?;
1017 let saved = repo.read_state_for_head(&pr, &actual_head);
1018 let saved_actor_known = saved
1019 .as_ref()
1020 .map(|state| cfg.has_agent(&state.next_actor))
1021 .unwrap_or(false);
1022 let state_matches_head = reconcile_saved_head(
1023 saved.as_ref().map(|state| state.pr_head.as_str()),
1024 saved.as_ref().map(|state| state.version),
1025 saved_actor_known,
1026 &actual_head,
1027 holder_override,
1028 pr_number,
1029 )?;
1030 let migrates_legacy_state = saved.as_ref().is_some_and(|state| {
1031 state.version == 1
1032 && state.pr_head.is_empty()
1033 && saved_actor_known
1034 && holder_override.is_none()
1035 });
1036 if migrates_legacy_state {
1037 log!("PR #{pr_number}: migrating legacy review state to head {actual_head}");
1038 }
1039
1040 let mut ledger: Ledger = saved
1041 .as_ref()
1042 .filter(|_| state_matches_head)
1043 .map(|s| s.ledger.clone())
1044 .unwrap_or_default();
1045 normalise_ledger_keys(&mut ledger);
1046 let open_findings = saved
1047 .as_ref()
1048 .filter(|_| state_matches_head)
1049 .map(|s| blocking_findings(&s.open_findings))
1050 .unwrap_or_default();
1051 let start_round = saved
1052 .as_ref()
1053 .filter(|_| state_matches_head)
1054 .map(|s| s.round + 1)
1055 .unwrap_or(1);
1056
1057 let default_holder = cfg.other(&cfg.first_implementor);
1058 let mut holder = holder_override
1059 .map(str::to_string)
1060 .or_else(|| saved.as_ref().map(|s| s.next_actor.clone()))
1061 .unwrap_or_else(|| default_holder.clone());
1062 if !cfg.has_agent(&holder) {
1063 log!("state named unknown agent '{holder}', using {default_holder}");
1064 holder = default_holder;
1065 }
1066
1067 match &saved {
1068 Some(_) => log!(
1069 "PR #{pr_number}: resuming at round {start_round}, {} point(s) on record, next up \
1070 {holder}",
1071 ledger.len()
1072 ),
1073 None => log!("PR #{pr_number}: no prior spar state, starting fresh with {holder}"),
1074 }
1075
1076 let mut state = IssueRun::new(subject, pr.title.clone());
1077 state.pr = Some(pr.url.clone());
1078 if let Some(s) = &saved {
1079 state.filed = s.filed.clone();
1080 if state_matches_head {
1081 state.disputes = s.disputes.clone();
1082 state.noted = s.noted.clone();
1083 } else {
1084 log!(
1085 "PR #{pr_number}: saved state does not match {actual_head}; branch-dependent \
1086 review state was cleared"
1087 );
1088 }
1089 }
1090
1091 let issue = (subject != pr_number)
1094 .then(|| repo.read_issue(subject))
1095 .transpose()
1096 .unwrap_or_else(|e| {
1097 logdim!("could not read issue #{subject} for context: {e}");
1098 None
1099 });
1100 let (issue_body, issue_url) = match &issue {
1101 Some(issue) => {
1102 let text = issue_for_prompt(
1103 issue,
1104 cfg.loop_cfg.max_issue_chars,
1105 cfg.loop_cfg.checkin_trust,
1106 );
1107 text.report(&format!("PR #{pr_number}"));
1108 (text.text, issue.url.clone())
1109 }
1110 None => (String::new(), String::new()),
1111 };
1112
1113 let ctx = LoopCtx {
1114 work_dir,
1115 branch,
1116 target,
1117 pr_number,
1118 context: context_block(subject, &issue_url, &issue_body, &pr.body),
1119 label: format!("PR #{pr_number}"),
1120 subject,
1121 title: pr.title.clone(),
1122 start_round,
1123 holder,
1124 release: Release::Pr(pr_number),
1125 };
1126
1127 let outcome = review_loop(
1128 agents,
1129 cfg,
1130 repo,
1131 &ctx,
1132 &mut state,
1133 &mut ledger,
1134 open_findings,
1135 );
1136 if let Err(e) = outcome {
1137 state.status = Status::Error;
1138 state.notes.push(e.to_string());
1139 log!("PR #{pr_number} failed: {e}");
1140 }
1141 if should_release(cfg, state.status) {
1142 repo.release_pr_worktree(pr_number);
1143 }
1144 Ok(state)
1145}
1146
1147#[derive(Debug, Clone, Copy)]
1152enum Release {
1153 Issue(i64),
1154 Pr(i64),
1155}
1156
1157struct LoopCtx {
1158 work_dir: PathBuf,
1159 branch: String,
1160 target: PushTarget,
1163 pr_number: i64,
1164 label: String,
1165 subject: i64,
1166 title: String,
1167 context: String,
1170 start_round: u32,
1171 holder: String,
1172 release: Release,
1173}
1174
1175impl LoopCtx {
1176 fn release(&self, repo: &Repo) -> bool {
1177 match self.release {
1178 Release::Issue(n) => repo.worktree_remove(n),
1179 Release::Pr(n) => repo.release_pr_worktree(n),
1180 }
1181 }
1182}
1183
1184fn review_loop(
1185 agents: &[Agent],
1186 cfg: &Config,
1187 repo: &Repo,
1188 ctx: &LoopCtx,
1189 state: &mut IssueRun,
1190 ledger: &mut Ledger,
1191 mut open_findings: Vec<Finding>,
1192) -> Result<()> {
1193 let base = cfg.base_branch().to_string();
1194 let mut holder = ctx.holder.clone();
1198
1199 let (first, last_allowed) = round_window(ctx.start_round, cfg.loop_cfg.max_rounds);
1205 let mut published_head = checked_head(repo, &ctx.work_dir)?;
1206 persist(
1207 repo,
1208 ctx.pr_number,
1209 state,
1210 ledger,
1211 &open_findings,
1212 &published_head,
1213 first.saturating_sub(1),
1214 &holder,
1215 )?;
1216 let base_ref = repo.base_ref(&ctx.work_dir, &base);
1222
1223 let mut audited_head = String::new();
1227 let mut last_round = first.saturating_sub(1);
1228
1229 for round in first..=last_allowed {
1230 last_round = round;
1231 state.rounds = round;
1232 let reviewer = agent::find(agents, &holder)?;
1233 let effort = reviewer.effort(cfg, Call::Review(round)).about(ctx.subject);
1234 log!(
1235 "{}: round {round}, {holder} reviewing ({})",
1236 ctx.label,
1237 effort.describe()
1238 );
1239
1240 let prompt = review_prompt(
1241 &base_ref,
1242 ctx.subject,
1243 &ctx.title,
1244 &ctx.context,
1245 ledger,
1246 &open_findings,
1247 round,
1248 last_allowed,
1249 );
1250 let before_review = snapshot(repo, &ctx.work_dir);
1251 let review_baseline = repo.worktree_baseline(&ctx.work_dir)?;
1252 audited_head = before_review.head.clone();
1255 let review = reviewer.review::<Review>(
1256 &base_ref,
1257 &prompt,
1258 &schema::review(),
1259 &ctx.work_dir,
1260 &effort,
1261 );
1262 if let Err(error) = &review {
1263 if error.kind() == crate::error::ErrorKind::UncertainWrite {
1264 return Err(error.clone());
1265 }
1266 }
1267 repo.refuse_unrepresented_tracked_changes(&ctx.work_dir, &review_baseline)?;
1268 repo.refuse_read_only_leavings(&ctx.work_dir, &review_baseline)?;
1269 let review = review?;
1270 if repo.has_uncommitted_changes(&ctx.work_dir)? {
1271 bail!(
1272 "{}: {holder} left uncommitted files while reviewing. They were kept at {} and \
1273 the review did not continue.",
1274 ctx.label,
1275 ctx.work_dir.display()
1276 );
1277 }
1278
1279 let mut editor: Option<String> = None;
1283 let review_wrote = snapshot(repo, &ctx.work_dir) != before_review;
1284 if review_wrote {
1285 logwarn!(
1286 "{}: {holder} changed the branch while reviewing it, which the review prompt \
1287 forbids. Rolling it back.",
1288 ctx.label
1289 );
1290 if undo_edits(repo, &ctx.work_dir, &before_review).head != before_review.head {
1291 state
1292 .notes
1293 .push(format!("{holder} committed during its own review"));
1294 editor = Some(holder.clone());
1295 }
1296 }
1297
1298 let blocking = blocking_findings(&review.findings);
1299 if let Some(said) = verdict_disagreement(&review, blocking.len()) {
1300 logwarn!("{}: {holder} {said}", ctx.label);
1301 }
1302 update_open_findings(&mut open_findings, &blocking, !review_wrote);
1303
1304 if repo.style.pr_comments == PrComments::Rounds {
1305 if let Err(e) = repo.comment_pr(
1306 ctx.pr_number,
1307 &review_comment(&holder, round, &review, &repo.style),
1308 ) {
1309 logdim!("could not post the review comment: {e}");
1310 }
1311 }
1312
1313 file_out_of_scope(repo, &review.findings, ctx.subject, state, cfg);
1317 file_nonblocking(repo, &review.findings, ctx.subject, state, cfg);
1318 remove_findings(&mut state.noted, &blocking);
1319
1320 if check_relitigation(ledger, &blocking, state) {
1321 state.status = Status::Escalated;
1322 post_outcome(
1323 repo,
1324 ctx.pr_number,
1325 state,
1326 ledger,
1327 Ending::Deadlocked(&blocking),
1328 );
1329 persist(
1330 repo,
1331 ctx.pr_number,
1332 state,
1333 ledger,
1334 &open_findings,
1335 &published_head,
1336 round,
1337 &holder,
1338 )?;
1339 return Ok(());
1340 }
1341
1342 if approval_stands(&blocking, review_wrote) {
1343 open_findings.clear();
1344 return approve(
1345 cfg,
1346 repo,
1347 ctx,
1348 state,
1349 ledger,
1350 &published_head,
1351 round,
1352 &holder,
1353 );
1354 }
1355
1356 persist(
1359 repo,
1360 ctx.pr_number,
1361 state,
1362 ledger,
1363 &open_findings,
1364 &published_head,
1365 round,
1366 &holder,
1367 )?;
1368
1369 let mut edit_error = None;
1370
1371 if blocking.is_empty() {
1372 logwarn!(
1377 "{}: {holder} found nothing blocking on a branch it had changed itself, so the \
1378 approval does not carry.",
1379 ctx.label
1380 );
1381 state.notes.push(format!(
1382 "{holder} passed the branch in round {round} after editing it; the edit was rolled \
1383 back and the approval did not stand"
1384 ));
1385 } else if review.next_action == NextAction::FixMyself {
1386 log!("{}: {holder} fixing its own findings", ctx.label);
1387 let prompt = FIX_PROMPT.replace("{findings}", &findings_for_prompt(&blocking));
1388 let before_fix = repo.head_oid_checked(&ctx.work_dir)?;
1389 let worktree_baseline = repo.worktree_baseline(&ctx.work_dir)?;
1390 let mut report: Option<OwnFixDoc> = None;
1391 let fix_error = match reviewer.edit_json::<OwnFixDoc>(
1392 &prompt,
1393 &schema::own_fix(),
1394 &ctx.work_dir,
1395 &effort,
1396 ) {
1397 Ok(doc) => {
1398 commit_accepted_changes(
1399 cfg,
1400 repo,
1401 &ctx.work_dir,
1402 &worktree_baseline,
1403 &doc.summary,
1404 "Address blocking review findings",
1405 )?;
1406 report = Some(doc);
1407 None
1408 }
1409 Err(error) => Some(defer_clean_edit_error(
1410 repo,
1411 &ctx.work_dir,
1412 &worktree_baseline,
1413 error,
1414 )?),
1415 };
1416 match editor_after(repo, &ctx.work_dir, &before_fix, &ctx.label, &holder)? {
1417 Some(who) => {
1418 let fixes = report.as_ref().map(|d| d.fixes.as_slice()).unwrap_or(&[]);
1430 let (claimed, left) = split_own_fixes(&blocking, fixes);
1431 record_own_fixes(&claimed, ledger, state, round);
1432 remove_findings(&mut open_findings, &blocking);
1433 extend_findings(&mut open_findings, &left);
1434 if !left.is_empty() {
1435 state.notes.push(format!(
1436 "{holder} fixed {} of {} of its own findings in round {round}; the \
1437 rest stay open",
1438 claimed.len(),
1439 blocking.len()
1440 ));
1441 }
1442 editor = Some(who);
1443 }
1444 None if fix_error.is_none() => {
1445 repo.refuse_new_ignored_files(&ctx.work_dir, &worktree_baseline)?;
1446 logwarn!(
1449 "{}: {holder} said it would fix its own findings and committed nothing, \
1450 so it keeps the pull request.",
1451 ctx.label
1452 );
1453 state.notes.push(format!(
1454 "{holder} chose to fix its own findings in round {round} and committed \
1455 nothing"
1456 ));
1457 }
1458 None => {}
1459 }
1460 edit_error = fix_error;
1461 } else {
1462 let author_name = cfg.other(&holder);
1463 let author = agent::find(agents, &author_name)?;
1464 log!(
1465 "{}: handing {} finding(s) to {author_name}",
1466 ctx.label,
1467 blocking.len()
1468 );
1469 let prompt = RESPOND_PROMPT
1470 .replace("{number}", &ctx.subject.to_string())
1471 .replace("{context}", &ctx.context)
1472 .replace("{findings}", &findings_for_prompt(&blocking))
1473 .replace("{optional}", &optional_block(&review.findings));
1474 let before_response = repo.head_oid_checked(&ctx.work_dir)?;
1475 let worktree_baseline = repo.worktree_baseline(&ctx.work_dir)?;
1476 let response: Result<ResponseDoc> = author.edit_json(
1477 &prompt,
1478 &schema::response(),
1479 &ctx.work_dir,
1480 &author.effort(cfg, Call::Respond(round)).about(ctx.subject),
1481 );
1482 let response = match response {
1483 Ok(response) => {
1484 commit_accepted_changes(
1485 cfg,
1486 repo,
1487 &ctx.work_dir,
1488 &worktree_baseline,
1489 &response.summary,
1490 "Address blocking review findings",
1491 )?;
1492 Some(response)
1493 }
1494 Err(error) => {
1495 edit_error = Some(defer_clean_edit_error(
1496 repo,
1497 &ctx.work_dir,
1498 &worktree_baseline,
1499 error,
1500 )?);
1501 None
1502 }
1503 };
1504 if let Some(who) = editor_after(
1505 repo,
1506 &ctx.work_dir,
1507 &before_response,
1508 &ctx.label,
1509 &author_name,
1510 )? {
1511 editor = Some(who);
1512 } else if let Some(response) = &response {
1513 repo.refuse_new_ignored_files(&ctx.work_dir, &worktree_baseline)?;
1514 if response
1515 .dispositions
1516 .iter()
1517 .any(|d| d.action == Action::Fixed)
1518 {
1519 logwarn!(
1520 "{}: {author_name} reported fixes but committed nothing, so the diff does \
1521 not have them.",
1522 ctx.label
1523 );
1524 }
1525 }
1526 if let Some(response) = response {
1527 let mut used = vec![false; response.dispositions.len()];
1531 let unresolved = apply_dispositions(
1532 repo,
1533 cfg,
1534 &response,
1535 &blocking,
1536 ledger,
1537 state,
1538 round,
1539 ctx.subject,
1540 ctx.pr_number,
1541 &author_name,
1542 editor.is_some(),
1543 &mut used,
1544 );
1545 remove_findings(&mut open_findings, &blocking);
1546 extend_findings(&mut open_findings, &unresolved);
1547 apply_optional_dispositions(
1548 repo,
1549 cfg,
1550 &response,
1551 &review.findings,
1552 state,
1553 ctx.subject,
1554 &mut used,
1555 );
1556 warn_unplaced_dispositions(&response, &used);
1557 }
1558 }
1559
1560 if let Some(who) = &editor {
1561 check_before_push(
1562 cfg,
1563 repo,
1564 agent::find(agents, who)?,
1565 &ctx.work_dir,
1566 &ctx.label,
1567 Call::Respond(round),
1568 )?;
1569 }
1570 if editor.is_some() {
1571 repo.rewrite_commits_if_needed(&ctx.work_dir, &base, Some(&published_head))?;
1572 repo.push_to(
1573 &ctx.work_dir,
1574 &ctx.target,
1575 &ctx.branch,
1576 Some(&published_head),
1577 )?;
1578 published_head = checked_head(repo, &ctx.work_dir)?;
1579 }
1580 holder = next_reviewer(cfg, &holder, editor.as_deref());
1581 persist(
1582 repo,
1583 ctx.pr_number,
1584 state,
1585 ledger,
1586 &open_findings,
1587 &published_head,
1588 round,
1589 &holder,
1590 )?;
1591 if let Some(error) = edit_error {
1592 return Err(error);
1593 }
1594 }
1595
1596 close_out(
1603 agents,
1604 cfg,
1605 repo,
1606 ctx,
1607 state,
1608 ledger,
1609 &mut open_findings,
1610 &holder,
1611 last_round,
1612 &audited_head,
1613 &published_head,
1614 )
1615}
1616
1617#[allow(clippy::too_many_arguments)]
1628fn close_out(
1629 agents: &[Agent],
1630 cfg: &Config,
1631 repo: &Repo,
1632 ctx: &LoopCtx,
1633 state: &mut IssueRun,
1634 ledger: &mut Ledger,
1635 open_findings: &mut Vec<Finding>,
1636 holder: &str,
1637 round: u32,
1638 audited_head: &str,
1639 published_head: &str,
1640) -> Result<()> {
1641 let stop =
1642 |state: &mut IssueRun, ledger: &Ledger, open_findings: &[Finding], ending: Ending<'_>| {
1643 state.status = Status::Escalated;
1644 state.notes.push(exhausted_note(ctx.start_round, round));
1645 post_outcome(repo, ctx.pr_number, state, ledger, ending);
1646 persist(
1647 repo,
1648 ctx.pr_number,
1649 state,
1650 ledger,
1651 open_findings,
1652 published_head,
1653 round,
1654 holder,
1655 )
1656 };
1657
1658 let landed = (!audited_head.is_empty())
1665 .then(|| repo.commits_since(&ctx.work_dir, audited_head, "HEAD"))
1666 .flatten();
1667 if audited_head.is_empty()
1668 || (landed.as_ref().is_some_and(|l| l.is_empty()) && !any_fixes(ledger, round))
1669 {
1670 logdim!(
1671 "{}: nothing landed after the last review, so there is nothing to close over",
1672 ctx.label
1673 );
1674 if !open_findings.is_empty() {
1675 state.notes.push(unresolved_note(open_findings.len()));
1676 }
1677 stop(
1678 state,
1679 ledger,
1680 open_findings,
1681 ending_without_landing(open_findings),
1682 )?;
1683 return Ok(());
1684 }
1685
1686 let closer = agent::find(agents, holder)?;
1687 let effort = closer.effort(cfg, Call::Close).about(ctx.subject);
1688 log!(
1689 "{}: closing, {holder} checking what the last round left ({})",
1690 ctx.label,
1691 effort.describe()
1692 );
1693
1694 let prompt = close_prompt(
1695 &repo.base_ref(&ctx.work_dir, cfg.base_branch()),
1696 ctx.subject,
1697 &ctx.title,
1698 &ctx.context,
1699 audited_head,
1700 landed.as_deref(),
1701 ledger,
1702 open_findings,
1703 round,
1704 );
1705 let before = snapshot(repo, &ctx.work_dir);
1706 let closing_baseline = repo.worktree_baseline(&ctx.work_dir)?;
1707 let pass = closer.ask_json(&prompt, &schema::review(), &ctx.work_dir, &effort);
1711 if let Err(e) = &pass {
1712 if e.kind() == crate::error::ErrorKind::UncertainWrite {
1713 return Err(e.clone());
1714 }
1715 }
1716 repo.refuse_unrepresented_tracked_changes(&ctx.work_dir, &closing_baseline)?;
1717 repo.refuse_read_only_leavings(&ctx.work_dir, &closing_baseline)?;
1718 if repo.has_uncommitted_changes(&ctx.work_dir)? {
1719 bail!(
1720 "{}: {holder} left uncommitted files during the closing pass. They were kept at {} \
1721 and the pass did not continue.",
1722 ctx.label,
1723 ctx.work_dir.display()
1724 );
1725 }
1726
1727 let close_wrote = snapshot(repo, &ctx.work_dir) != before;
1730 let next = closing_next_actor(holder);
1734 if close_wrote {
1735 if let Err(error) = &pass {
1736 logwarn!(
1737 "{}: the closing pass failed after changing the branch: {error}",
1738 ctx.label
1739 );
1740 }
1741 logwarn!(
1742 "{}: {holder} changed the branch while closing, which the prompt forbids. Rolling it \
1743 back.",
1744 ctx.label
1745 );
1746 let after_undo = undo_edits(repo, &ctx.work_dir, &before);
1747 if after_undo != before {
1748 state.notes.push(format!(
1749 "{holder}'s closing-pass changes could not be fully rolled back"
1750 ));
1751 if after_undo.head != before.head {
1752 if let Some(reference) = preserve_closing_commit(repo, ctx) {
1753 state.notes.push(format!(
1754 "the closing pass commit was not pushed and remains at {reference}"
1755 ));
1756 }
1757 }
1758 }
1759 state.status = Status::Escalated;
1760 state.notes.push(format!(
1761 "{holder} edited the branch during the closing pass, so its answer did not stand"
1762 ));
1763 post_unread_outcome(repo, ctx.pr_number, state, ledger, open_findings);
1764 persist(
1765 repo,
1766 ctx.pr_number,
1767 state,
1768 ledger,
1769 open_findings,
1770 published_head,
1771 round,
1772 &next,
1773 )?;
1774 return Ok(());
1775 }
1776
1777 let pass: Review = match pass {
1778 Ok(pass) => pass,
1779 Err(e) => {
1780 logwarn!("{}: the closing pass failed: {e}", ctx.label);
1784 state.status = Status::Escalated;
1785 state.notes.push(exhausted_note(ctx.start_round, round));
1786 post_unread_outcome(repo, ctx.pr_number, state, ledger, open_findings);
1787 persist(
1788 repo,
1789 ctx.pr_number,
1790 state,
1791 ledger,
1792 open_findings,
1793 published_head,
1794 round,
1795 holder,
1796 )?;
1797 return Ok(());
1798 }
1799 };
1800
1801 file_out_of_scope(repo, &pass.findings, ctx.subject, state, cfg);
1802 file_nonblocking(repo, &pass.findings, ctx.subject, state, cfg);
1803
1804 let blocking = blocking_findings(&pass.findings);
1805 if let Some(said) = verdict_disagreement(&pass, blocking.len()) {
1806 logwarn!("{}: the closing pass {said}", ctx.label);
1807 }
1808 remove_findings(&mut state.noted, &blocking);
1809 update_open_findings(open_findings, &blocking, true);
1810
1811 if check_relitigation(ledger, &blocking, state) {
1812 state.status = Status::Escalated;
1813 post_outcome(
1814 repo,
1815 ctx.pr_number,
1816 state,
1817 ledger,
1818 Ending::Deadlocked(&blocking),
1819 );
1820 persist(
1821 repo,
1822 ctx.pr_number,
1823 state,
1824 ledger,
1825 open_findings,
1826 published_head,
1827 round,
1828 &next,
1829 )?;
1830 return Ok(());
1831 }
1832
1833 if approval_stands(&blocking, false) {
1834 open_findings.clear();
1835 return approve(cfg, repo, ctx, state, ledger, published_head, round, &next);
1836 }
1837
1838 state.status = Status::Escalated;
1839 state.notes.push(unresolved_note(open_findings.len()));
1840 post_outcome(
1841 repo,
1842 ctx.pr_number,
1843 state,
1844 ledger,
1845 Ending::Unresolved(open_findings),
1846 );
1847 persist(
1848 repo,
1849 ctx.pr_number,
1850 state,
1851 ledger,
1852 open_findings,
1853 published_head,
1854 round,
1855 &next,
1856 )?;
1857 Ok(())
1858}
1859
1860fn any_fixes(ledger: &Ledger, round: u32) -> bool {
1866 ledger
1867 .values()
1868 .any(|e| e.outcome == Settled::Fixed && e.round >= round)
1869}
1870
1871fn unresolved_note(left: usize) -> String {
1876 match left {
1877 1 => "one point left after the closing pass".to_string(),
1878 n => format!("{n} points left after the closing pass"),
1879 }
1880}
1881
1882fn ensure_reviewed_head(pr_number: i64, reviewed_head: &str, live_head: &str) -> Result<()> {
1888 if live_head == reviewed_head {
1889 return Ok(());
1890 }
1891 Err(spar_err!(
1892 "PR #{pr_number} changed from {reviewed_head} to {live_head} after it was reviewed; refusing to approve or merge an unread head"
1893 ))
1894}
1895
1896#[allow(clippy::too_many_arguments)]
1897fn approve(
1898 cfg: &Config,
1899 repo: &Repo,
1900 ctx: &LoopCtx,
1901 state: &mut IssueRun,
1902 ledger: &Ledger,
1903 published_head: &str,
1904 round: u32,
1905 holder: &str,
1906) -> Result<()> {
1907 let live_head = repo.pr_head_oid(ctx.pr_number)?;
1908 ensure_reviewed_head(ctx.pr_number, published_head, &live_head)?;
1909 state.status = Status::Approved;
1910 persist(
1911 repo,
1912 ctx.pr_number,
1913 state,
1914 ledger,
1915 &[],
1916 published_head,
1917 round,
1918 holder,
1919 )?;
1920 let live_head = repo.pr_head_oid(ctx.pr_number)?;
1921 ensure_reviewed_head(ctx.pr_number, published_head, &live_head)?;
1922 post_outcome(repo, ctx.pr_number, state, ledger, Ending::Approved);
1923 if cfg.loop_cfg.drafts == Drafts::UntilApproved && repo.mark_ready(ctx.pr_number) {
1927 log!("{}: out of draft", ctx.label);
1928 }
1929 if cfg.loop_cfg.auto_merge {
1930 let released = ctx.release(repo);
1934 if !released {
1935 log!(
1936 "{}: kept the worktree at {} and will leave its branch in place",
1937 ctx.label,
1938 ctx.work_dir.display()
1939 );
1940 }
1941 repo.merge_pr_at_head(ctx.pr_number, published_head, released)?;
1942 state.status = Status::Merged;
1943 repo.clear_state(ctx.pr_number); log!("{}: merged", ctx.label);
1945 } else {
1946 log!("{}: approved, awaiting human merge", ctx.label);
1947 }
1948 Ok(())
1949}
1950
1951fn approval_stands(blocking: &[Finding], review_wrote: bool) -> bool {
1959 blocking.is_empty() && !review_wrote
1960}
1961
1962fn next_reviewer(cfg: &Config, reviewer: &str, editor: Option<&str>) -> String {
1975 match editor {
1976 Some(editor) => cfg.other(editor),
1977 None => reviewer.to_string(),
1978 }
1979}
1980
1981fn defer_clean_edit_error(
1982 repo: &Repo,
1983 work_dir: &Path,
1984 baseline: &crate::repo::WorktreeBaseline,
1985 error: SparError,
1986) -> Result<SparError> {
1987 if error.kind() == ErrorKind::UncertainWrite {
1988 return Err(error);
1989 }
1990 repo.refuse_changed_attributes(work_dir, baseline)?;
1991 if repo.has_uncommitted_changes(work_dir)? {
1992 return Err(error);
1993 }
1994 repo.refuse_failed_edit_leavings(work_dir, baseline)?;
1995 repo.refuse_unrepresented_tracked_changes(work_dir, baseline)?;
1996 Ok(error)
1997}
1998
1999fn editor_after(
2007 repo: &Repo,
2008 work_dir: &Path,
2009 before_head: &str,
2010 label: &str,
2011 who: &str,
2012) -> Result<Option<String>> {
2013 if repo.has_uncommitted_changes(work_dir)? {
2014 bail!(
2015 "{label}: {who} left uncommitted files at {}. They were kept and the review did not \
2016 continue.",
2017 work_dir.display()
2018 );
2019 }
2020 let after_head = repo.head_oid_checked(work_dir)?;
2021 if after_head != before_head && !repo.is_ancestor_checked(work_dir, before_head, &after_head)? {
2022 bail!(
2023 "{label}: {who} moved HEAD to {after_head}, but it does not contain the previous \
2024 branch tip {before_head}. The worktree was kept for recovery and nothing was \
2025 published. Restore {before_head} or reapply the intended commits on top of it before \
2026 resuming."
2027 );
2028 }
2029 Ok((after_head != before_head).then(|| who.to_string()))
2030}
2031
2032fn round_window(start_round: u32, budget: u32) -> (u32, u32) {
2038 (start_round, start_round + budget.saturating_sub(1))
2039}
2040
2041fn spent(start_round: u32, last_round: u32) -> (u32, u32) {
2045 (last_round.saturating_sub(start_round) + 1, last_round)
2046}
2047
2048fn exhausted_note(start_round: u32, last_round: u32) -> String {
2049 let (this_run, total) = spent(start_round, last_round);
2050 if this_run == total {
2051 format!("no convergence after {this_run} rounds")
2052 } else {
2053 format!("no convergence after {this_run} more rounds ({total} in total)")
2054 }
2055}
2056
2057#[allow(clippy::too_many_arguments)]
2058fn persist(
2059 repo: &Repo,
2060 pr_number: i64,
2061 state: &IssueRun,
2062 ledger: &Ledger,
2063 open_findings: &[Finding],
2064 published_head: &str,
2065 round: u32,
2066 next_actor: &str,
2067) -> Result<()> {
2068 let payload = PersistedState {
2069 version: STATE_VERSION,
2070 checkpoint: 0,
2071 round,
2072 next_actor: next_actor.to_string(),
2073 status: state.status,
2074 pr_head: published_head.to_string(),
2075 ledger: ledger.clone(),
2076 filed: state.filed.clone(),
2077 open_findings: open_findings.to_vec(),
2078 disputes: state.disputes.clone(),
2079 noted: state.noted.clone(),
2080 };
2081 repo.write_state(pr_number, &payload)
2082}
2083
2084fn settled_block(ledger: &Ledger) -> String {
2089 if ledger.is_empty() {
2090 return String::new();
2091 }
2092 let lines: Vec<String> = ledger
2097 .values()
2098 .filter_map(|e| {
2099 let point = match e.file.trim() {
2100 "" => e.title.clone(),
2101 file => format!("{} ({file})", e.title),
2102 };
2103 match e.outcome {
2104 Settled::Refuted => Some(format!("- {point}: refuted because {}", e.reasoning)),
2105 Settled::Filed => Some(format!(
2106 "- {point}: out of scope here, and filed. {}",
2107 e.reasoning
2108 )),
2109 Settled::Dropped => Some(format!(
2110 "- {point}: out of scope here, and not filed. {}",
2111 e.reasoning
2112 )),
2113 Settled::Fixed => None,
2114 }
2115 })
2116 .collect();
2117 if lines.is_empty() {
2118 return String::new();
2119 }
2120 format!(
2121 "\nThe following points were already raised and settled, by a refutation or by a \
2122 follow-up issue. Treat them as settled. Do not raise them again unless you have new \
2123 evidence:\n{}",
2124 lines.join("\n")
2125 )
2126}
2127
2128fn fixed_lines(ledger: &Ledger, since: u32) -> Vec<String> {
2140 ledger
2141 .values()
2142 .filter(|e| e.outcome == Settled::Fixed && e.round >= since)
2143 .map(|e| {
2144 let claim = match e.reasoning.trim() {
2145 "" => "a committed change claims to address this point",
2146 reasoning => reasoning,
2147 };
2148 match e.file.trim() {
2149 "" => format!("- {}. Recorded answer: {claim}", e.title),
2150 file => format!("- {} ({file}). Recorded answer: {claim}", e.title),
2151 }
2152 })
2153 .collect()
2154}
2155
2156fn answers_block(ledger: &Ledger, round: u32) -> String {
2164 let lines = fixed_lines(ledger, round.saturating_sub(1));
2167 if lines.is_empty() {
2168 return String::new();
2169 }
2170 format!(
2171 "\nThese points were raised on this pull request in earlier rounds and the author says \
2172 it fixed them. The code is on the branch and the claim is the author's:\n{}\n\nCheck the \
2173 answer rather than taking it. If one of them is still not fixed, raise it again under \
2174 the same title, so the run can tell a point that was not answered from a new one.\n",
2175 lines.join("\n")
2176 )
2177}
2178
2179fn round_note(round: u32, last: u32) -> String {
2188 if round < last {
2189 return String::new();
2190 }
2191 "\nThis is the last round in this run that can ask the author for anything. After it, one \
2192 pass reads what landed and the pull request is either signed off or goes to a person with \
2193 what is left. Raise everything you mean to raise now. A point held back for a later round \
2194 does not get one.\n"
2195 .to_string()
2196}
2197
2198#[cfg(test)]
2203fn matching_ledger_key(ledger: &Ledger, title: &str, file: &str) -> Option<String> {
2204 matching_ledger_key_with_fallback(ledger, title, file, true)
2205}
2206
2207fn matching_ledger_key_with_fallback(
2208 ledger: &Ledger,
2209 title: &str,
2210 file: &str,
2211 allow_stable_fallback: bool,
2212) -> Option<String> {
2213 let exact = finding_key(title, file);
2214 if ledger.contains_key(&exact) {
2215 return Some(exact);
2216 }
2217 let legacy = crate::jsonx::finding_key(title, file);
2218 if ledger
2219 .get(&legacy)
2220 .is_some_and(|entry| same_finding_parts(&entry.title, &entry.file, title, file))
2221 {
2222 return Some(legacy);
2223 }
2224 if !allow_stable_fallback {
2225 return None;
2226 }
2227
2228 let stable = stable_finding_key(title, file);
2229 let path = finding_file(file);
2230 let mut matches = ledger
2231 .iter()
2232 .filter(|(saved_key, entry)| {
2233 if stable_finding_key(&entry.title, &entry.file) == stable {
2234 return true;
2235 }
2236 finding_file(&entry.file) == path
2237 && saved_key.as_str() == crate::jsonx::finding_key(title, &entry.file)
2238 })
2239 .map(|(key, _)| key.clone());
2240 let first = matches.next()?;
2241 matches.next().is_none().then_some(first)
2242}
2243
2244fn matching_ledger_entry<'a>(
2245 ledger: &'a Ledger,
2246 title: &str,
2247 file: &str,
2248) -> Option<&'a LedgerEntry> {
2249 matching_ledger_entry_with_fallback(ledger, title, file, true)
2250}
2251
2252fn matching_ledger_entry_with_fallback<'a>(
2253 ledger: &'a Ledger,
2254 title: &str,
2255 file: &str,
2256 allow_stable_fallback: bool,
2257) -> Option<&'a LedgerEntry> {
2258 let key = matching_ledger_key_with_fallback(ledger, title, file, allow_stable_fallback)?;
2259 ledger.get(&key)
2260}
2261
2262fn settle(
2263 ledger: &mut Ledger,
2264 title: &str,
2265 file: &str,
2266 allow_stable_fallback: bool,
2267 entry: LedgerEntry,
2268) {
2269 let old_key = matching_ledger_key_with_fallback(ledger, title, file, allow_stable_fallback);
2270 let reraised = old_key
2271 .as_ref()
2272 .and_then(|key| ledger.get(key))
2273 .map(|entry| entry.reraised)
2274 .unwrap_or(0);
2275 if let Some(old_key) = old_key {
2276 ledger.remove(&old_key);
2277 }
2278 ledger.insert(finding_key(title, file), LedgerEntry { reraised, ..entry });
2279}
2280
2281fn normalise_ledger_keys(ledger: &mut Ledger) {
2283 let mut normalised = Ledger::new();
2284 for (saved_key, mut entry) in std::mem::take(ledger) {
2285 let key = if saved_key.len() == 12
2286 && saved_key
2287 .chars()
2288 .all(|character| character.is_ascii_hexdigit())
2289 {
2290 saved_key
2291 } else {
2292 finding_key(&entry.title, &entry.file)
2293 };
2294 if let Some(previous) = normalised.get_mut(&key) {
2295 let reraised = previous.reraised.max(entry.reraised);
2296 if entry.round >= previous.round {
2297 entry.reraised = reraised;
2298 *previous = entry;
2299 } else {
2300 previous.reraised = reraised;
2301 }
2302 } else {
2303 normalised.insert(key, entry);
2304 }
2305 }
2306 *ledger = normalised;
2307}
2308
2309pub(crate) fn verdict_disagreement(review: &Review, blocking: usize) -> Option<String> {
2319 match (review.verdict, blocking, review.next_action) {
2320 (Verdict::Approve, n, _) if n > 0 => Some(format!(
2321 "the review says approve and raises {n} blocking finding(s); the findings decide"
2322 )),
2323 (Verdict::ChangesRequested, 0, NextAction::Merge) => Some(
2324 "the review says changes_requested, raises nothing blocking, and asks to merge; the \
2325 findings decide"
2326 .to_string(),
2327 ),
2328 _ => None,
2329 }
2330}
2331
2332fn blocking_findings(findings: &[Finding]) -> Vec<Finding> {
2333 let mut kept = Vec::new();
2334 for finding in findings.iter().filter(|finding| finding.blocks()) {
2335 if let Some(existing) = kept
2336 .iter_mut()
2337 .find(|existing| same_finding(existing, finding))
2338 {
2339 *existing = finding.clone();
2340 } else {
2341 kept.push(finding.clone());
2342 }
2343 }
2344 kept
2345}
2346
2347fn matching_finding_index(
2348 findings: &[Finding],
2349 target: &Finding,
2350 allow_stable_fallback: bool,
2351) -> Option<usize> {
2352 if let Some(index) = findings
2353 .iter()
2354 .position(|finding| same_finding(finding, target))
2355 {
2356 return Some(index);
2357 }
2358 if !allow_stable_fallback {
2359 return None;
2360 }
2361
2362 let stable = stable_finding_key(&target.title, &target.file);
2363 let mut matches = findings
2364 .iter()
2365 .enumerate()
2366 .filter(|(_, finding)| stable_finding_key(&finding.title, &finding.file) == stable);
2367 let first = matches.next().map(|(index, _)| index);
2368 first.filter(|_| matches.next().is_none())
2369}
2370
2371fn unique_stable_finding(findings: &[Finding], target: &Finding) -> bool {
2372 let stable = stable_finding_key(&target.title, &target.file);
2373 findings
2374 .iter()
2375 .filter(|finding| stable_finding_key(&finding.title, &finding.file) == stable)
2376 .count()
2377 == 1
2378}
2379
2380fn extend_findings(target: &mut Vec<Finding>, additions: &[Finding]) {
2382 for finding in additions {
2383 if let Some(index) =
2384 matching_finding_index(target, finding, unique_stable_finding(additions, finding))
2385 {
2386 target[index] = finding.clone();
2387 } else {
2388 target.push(finding.clone());
2389 }
2390 }
2391}
2392
2393fn update_open_findings(
2394 open_findings: &mut Vec<Finding>,
2395 current: &[Finding],
2396 answer_stands: bool,
2397) {
2398 if answer_stands {
2399 *open_findings = current.to_vec();
2400 } else {
2401 extend_findings(open_findings, current);
2402 }
2403}
2404
2405fn remove_findings(target: &mut Vec<Finding>, removed: &[Finding]) {
2406 for finding in removed {
2407 if let Some(index) =
2408 matching_finding_index(target, finding, unique_stable_finding(removed, finding))
2409 {
2410 target.remove(index);
2411 }
2412 }
2413}
2414
2415fn ending_without_landing(open_findings: &[Finding]) -> Ending<'_> {
2416 if open_findings.is_empty() {
2417 Ending::Unchanged
2418 } else {
2419 Ending::Unresolved(open_findings)
2420 }
2421}
2422
2423fn closing_next_actor(holder: &str) -> String {
2424 holder.to_string()
2425}
2426
2427fn remember_noted(state: &mut IssueRun, finding: &Finding, allow_stable_fallback: bool) {
2440 if let Some(index) = matching_finding_index(&state.noted, finding, allow_stable_fallback) {
2441 state.noted[index] = finding.clone();
2442 } else {
2443 state.noted.push(finding.clone());
2444 }
2445}
2446
2447fn forget_noted(state: &mut IssueRun, finding: &Finding, allow_stable_fallback: bool) {
2448 if let Some(index) = matching_finding_index(&state.noted, finding, allow_stable_fallback) {
2449 state.noted.remove(index);
2450 }
2451}
2452
2453fn remember_dispute(state: &mut IssueRun, dispute: Dispute, allow_stable_fallback: bool) {
2454 let exact = finding_key(&dispute.title, &dispute.file);
2455 let stable = stable_finding_key(&dispute.title, &dispute.file);
2456 let exact_index = state
2457 .disputes
2458 .iter()
2459 .position(|kept| finding_key(&kept.title, &kept.file) == exact);
2460 let stable_index = if exact_index.is_none() && allow_stable_fallback {
2461 let mut matches = state
2462 .disputes
2463 .iter()
2464 .enumerate()
2465 .filter(|(_, kept)| stable_finding_key(&kept.title, &kept.file) == stable);
2466 let first = matches.next().map(|(index, _)| index);
2467 first.filter(|_| matches.next().is_none())
2468 } else {
2469 None
2470 };
2471 if let Some(index) = exact_index.or(stable_index) {
2472 state.disputes[index] = dispute;
2473 } else {
2474 state.disputes.push(dispute);
2475 }
2476}
2477
2478fn forget_dispute(state: &mut IssueRun, finding: &Finding, allow_stable_fallback: bool) {
2479 let exact = finding_key(&finding.title, &finding.file);
2480 if let Some(index) = state
2481 .disputes
2482 .iter()
2483 .position(|kept| finding_key(&kept.title, &kept.file) == exact)
2484 {
2485 state.disputes.remove(index);
2486 return;
2487 }
2488 if !allow_stable_fallback {
2489 return;
2490 }
2491
2492 let stable = stable_finding_key(&finding.title, &finding.file);
2493 let mut matches = state
2494 .disputes
2495 .iter()
2496 .enumerate()
2497 .filter(|(_, kept)| stable_finding_key(&kept.title, &kept.file) == stable);
2498 let first = matches.next().map(|(index, _)| index);
2499 if let Some(index) = first.filter(|_| matches.next().is_none()) {
2500 state.disputes.remove(index);
2501 }
2502}
2503
2504#[cfg(test)]
2505fn record_nonblocking_outcome(state: &mut IssueRun, finding: &Finding, outcome: Option<&Followup>) {
2506 record_nonblocking_outcome_with_match(state, finding, outcome, true);
2507}
2508
2509fn record_nonblocking_outcome_with_match(
2510 state: &mut IssueRun,
2511 finding: &Finding,
2512 outcome: Option<&Followup>,
2513 allow_stable_fallback: bool,
2514) {
2515 forget_dispute(state, finding, allow_stable_fallback);
2516 if let Some(Followup::Recorded(url)) = outcome {
2517 if !state.filed.iter().any(|filed| filed == url) {
2518 state.filed.push(url.clone());
2519 }
2520 forget_noted(state, finding, allow_stable_fallback);
2521 } else {
2522 remember_noted(state, finding, allow_stable_fallback);
2523 }
2524}
2525
2526fn split_own_fixes(blocking: &[Finding], fixes: &[OwnFix]) -> (Vec<Finding>, Vec<Finding>) {
2533 let mut claimed = Vec::new();
2534 let mut left = Vec::new();
2535 let mut used = vec![false; fixes.len()];
2536 for finding in blocking {
2537 match matching_disposition(finding, blocking, fixes) {
2538 Ok((index, fix)) if !used[index] => {
2539 used[index] = true;
2540 if fix.fixed {
2541 claimed.push(finding.clone());
2542 continue;
2543 }
2544 let mut open = finding.clone();
2545 let reason = fix.reason.trim();
2546 if !reason.is_empty() {
2547 open.detail = format!(
2548 "{}\n\nThe reviewer chose to fix this itself and did not: {reason}",
2549 open.detail.trim()
2550 );
2551 }
2552 logwarn!("'{}' was not fixed: {reason}", finding.title);
2553 left.push(open);
2554 }
2555 Ok(_) => {
2556 logwarn!(
2557 "'{}' has more than one matching entry in the fix report, so it stays open",
2558 finding.title
2559 );
2560 left.push(finding.clone());
2561 }
2562 Err(reason) => {
2563 logwarn!(
2564 "'{}' has {reason} in the fix report, so it stays open",
2565 finding.title
2566 );
2567 left.push(finding.clone());
2568 }
2569 }
2570 }
2571 (claimed, left)
2572}
2573
2574fn record_own_fixes(blocking: &[Finding], ledger: &mut Ledger, state: &mut IssueRun, round: u32) {
2581 for finding in blocking {
2582 let allow_stable_fallback = unique_stable_finding(blocking, finding);
2583 settle(
2584 ledger,
2585 &finding.title,
2586 &finding.file,
2587 allow_stable_fallback,
2588 LedgerEntry {
2589 title: finding.title.clone(),
2590 file: finding.file.clone(),
2591 reasoning: "a committed change was made for this point".to_string(),
2592 round,
2593 reraised: 0,
2594 outcome: Settled::Fixed,
2595 },
2596 );
2597 forget_noted(state, finding, allow_stable_fallback);
2598 forget_dispute(state, finding, allow_stable_fallback);
2599 }
2600}
2601
2602fn check_relitigation(ledger: &mut Ledger, blocking: &[Finding], state: &mut IssueRun) -> bool {
2605 let mut escalate = false;
2606 let mut counted: BTreeSet<String> = BTreeSet::new();
2612 for finding in blocking {
2613 let allow_stable_fallback = unique_stable_finding(blocking, finding);
2614 let Some(key) = matching_ledger_key_with_fallback(
2615 ledger,
2616 &finding.title,
2617 &finding.file,
2618 allow_stable_fallback,
2619 ) else {
2620 continue;
2621 };
2622 if !counted.insert(key.clone()) {
2623 continue;
2624 }
2625 if let Some(entry) = ledger.get_mut(&key) {
2626 entry.reraised += 1;
2627 if entry.outcome == Settled::Refuted {
2628 remember_dispute(
2629 state,
2630 Dispute {
2631 title: finding.title.clone(),
2632 file: finding.file.clone(),
2633 reasoning: entry.reasoning.clone(),
2634 },
2635 allow_stable_fallback,
2636 );
2637 }
2638 if entry.reraised >= 2 {
2639 state.notes.push(format!(
2640 "'{}' {}",
2641 finding.title,
2642 why_escalated(entry.outcome)
2643 ));
2644 escalate = true;
2645 }
2646 }
2647 }
2648 escalate
2649}
2650
2651fn why_escalated(outcome: Settled) -> &'static str {
2658 match outcome {
2659 Settled::Fixed => "was fixed twice and raised again; escalating.",
2660 _ => "was settled and re-raised twice; escalating.",
2661 }
2662}
2663
2664fn normalise(text: &str) -> String {
2665 crate::jsonx::untagged_title(text)
2666 .to_lowercase()
2667 .chars()
2668 .filter(|c| c.is_ascii_alphanumeric() || c.is_whitespace())
2669 .collect::<String>()
2670 .split_whitespace()
2671 .collect::<Vec<_>>()
2672 .join(" ")
2673}
2674
2675pub(crate) fn same_point(a: &str, b: &str) -> bool {
2677 normalise(a) == normalise(b)
2678}
2679
2680pub(crate) fn same_finding(a: &Finding, b: &Finding) -> bool {
2681 same_finding_parts(&a.title, &a.file, &b.title, &b.file)
2682}
2683
2684pub(crate) fn same_finding_parts(a_title: &str, a_file: &str, b_title: &str, b_file: &str) -> bool {
2685 finding_key(a_title, a_file) == finding_key(b_title, b_file)
2686}
2687
2688pub(crate) trait Answer {
2694 fn title(&self) -> &str;
2695 fn file(&self) -> &str;
2696}
2697
2698impl Answer for Disposition {
2699 fn title(&self) -> &str {
2700 &self.title
2701 }
2702 fn file(&self) -> &str {
2703 &self.file
2704 }
2705}
2706
2707impl Answer for OwnFix {
2708 fn title(&self) -> &str {
2709 &self.title
2710 }
2711 fn file(&self) -> &str {
2712 &self.file
2713 }
2714}
2715
2716fn answer_matches<A: Answer>(finding: &Finding, answer: &A) -> bool {
2717 same_point(&finding.title, answer.title()) && finding.file.trim() == answer.file().trim()
2718}
2719
2720fn stable_answer_matches<A: Answer>(finding: &Finding, answer: &A) -> bool {
2721 stable_finding_key(&finding.title, &finding.file)
2722 == stable_finding_key(answer.title(), answer.file())
2723}
2724
2725fn sole_answer_match<'a, A: Answer>(
2726 finding: &Finding,
2727 answers: &'a [A],
2728 matches: impl Fn(&Finding, &A) -> bool,
2729) -> std::result::Result<Option<(usize, &'a A)>, &'static str> {
2730 let mut matched = answers
2731 .iter()
2732 .enumerate()
2733 .filter(|(_, answer)| matches(finding, answer));
2734 let first = matched.next();
2735 if matched.next().is_some() {
2736 return Err("more than one matching disposition");
2737 }
2738 Ok(first)
2739}
2740
2741fn matching_disposition<'a, A: Answer>(
2746 finding: &Finding,
2747 findings: &[Finding],
2748 answers: &'a [A],
2749) -> std::result::Result<(usize, &'a A), &'static str> {
2750 if let Some(exact) = sole_answer_match(finding, answers, answer_matches)? {
2751 return Ok(exact);
2752 }
2753 if unique_stable_finding(findings, finding) {
2754 if let Some(stable) = sole_answer_match(finding, answers, stable_answer_matches)? {
2755 return Ok(stable);
2756 }
2757 }
2758 Err("no matching disposition")
2759}
2760
2761fn fixed_disposition_resolves(committed: bool) -> bool {
2762 committed
2763}
2764
2765#[allow(clippy::too_many_arguments)]
2766fn apply_dispositions(
2767 repo: &Repo,
2768 cfg: &Config,
2769 response: &ResponseDoc,
2770 blocking: &[Finding],
2771 ledger: &mut Ledger,
2772 state: &mut IssueRun,
2773 round: u32,
2774 subject: i64,
2775 pr_number: i64,
2776 author: &str,
2777 committed: bool,
2778 used: &mut [bool],
2779) -> Vec<Finding> {
2780 let mut fixed = Vec::new();
2781 let mut refuted = Vec::new();
2782 let mut filed = Vec::new();
2783 let mut unresolved = Vec::new();
2784
2785 for source in blocking {
2786 let (index, d) = match matching_disposition(source, blocking, &response.dispositions) {
2787 Ok((index, disposition)) if !used[index] => (index, disposition),
2788 Ok(_) => {
2789 logwarn!(
2790 "'{}' has more than one matching disposition, so it stays open",
2791 source.title
2792 );
2793 unresolved.push(source.clone());
2794 continue;
2795 }
2796 Err(reason) => {
2797 logwarn!("'{}' has {reason}, so it stays open", source.title);
2798 unresolved.push(source.clone());
2799 continue;
2800 }
2801 };
2802 used[index] = true;
2803 let file = source.file.clone();
2804 let canonical = source.title.as_str();
2808 let title = style::title(canonical, &repo.style);
2809 let located_title = match file.trim() {
2810 "" => title.clone(),
2811 location => format!("{title} ({location})"),
2812 };
2813 let allow_stable_fallback = unique_stable_finding(blocking, source);
2814
2815 match d.action {
2816 Action::Refuted => {
2817 let reasoning = style::summary(&d.reasoning, &repo.style);
2818 settle(
2819 ledger,
2820 canonical,
2821 &file,
2822 allow_stable_fallback,
2823 LedgerEntry {
2824 title: canonical.to_string(),
2825 file: file.clone(),
2826 reasoning: reasoning.clone(),
2827 round,
2828 reraised: 0,
2829 outcome: Settled::Refuted,
2830 },
2831 );
2832 remember_dispute(
2833 state,
2834 Dispute {
2835 title: canonical.to_string(),
2836 file: file.clone(),
2837 reasoning: reasoning.clone(),
2838 },
2839 allow_stable_fallback,
2840 );
2841 forget_noted(state, source, allow_stable_fallback);
2842 refuted.push(format!("{located_title}. {reasoning}"));
2843 }
2844 Action::FiledIssue => {
2845 let new_title = d
2846 .new_issue_title
2847 .clone()
2848 .filter(|t| !t.trim().is_empty())
2849 .unwrap_or_else(|| d.title.clone());
2850 let new_body = d
2851 .new_issue_body
2852 .clone()
2853 .filter(|b| !b.trim().is_empty())
2854 .unwrap_or_else(|| d.reasoning.clone());
2855 let recorded = file_followup(repo, &new_title, &new_body, subject, cfg, state);
2856 if let Some(url) = recorded.url() {
2857 state.filed.push(url.to_string());
2858 filed.push(url.to_string());
2859 }
2860 let Some((outcome, reasoning)) =
2869 filed_entry(&recorded, &style::summary(&d.reasoning, &repo.style))
2870 else {
2871 logwarn!(
2872 "'{title}' was not recorded anywhere, so it stays open for the next round"
2873 );
2874 unresolved.push(source.clone());
2875 continue;
2876 };
2877 settle(
2878 ledger,
2879 canonical,
2880 &file,
2881 allow_stable_fallback,
2882 LedgerEntry {
2883 title: canonical.to_string(),
2884 file: file.clone(),
2885 reasoning,
2886 round,
2887 reraised: 0,
2888 outcome,
2889 },
2890 );
2891 if outcome == Settled::Dropped {
2892 remember_noted(state, source, allow_stable_fallback);
2893 } else {
2894 forget_noted(state, source, allow_stable_fallback);
2895 }
2896 forget_dispute(state, source, allow_stable_fallback);
2897 }
2898 Action::Fixed => {
2899 if fixed_disposition_resolves(committed) {
2914 settle(
2915 ledger,
2916 canonical,
2917 &file,
2918 allow_stable_fallback,
2919 LedgerEntry {
2920 title: canonical.to_string(),
2921 file: file.clone(),
2922 reasoning: style::summary(&d.reasoning, &repo.style),
2923 round,
2924 reraised: 0,
2925 outcome: Settled::Fixed,
2926 },
2927 );
2928 forget_noted(state, source, allow_stable_fallback);
2929 forget_dispute(state, source, allow_stable_fallback);
2930 fixed.push(located_title);
2931 } else {
2932 unresolved.push(source.clone());
2933 }
2934 }
2935 }
2936 }
2937
2938 if repo.style.pr_comments == PrComments::Rounds {
2939 let comment = disposition_comment(author, response, &fixed, &refuted, &filed, &repo.style);
2940 if let Some(text) = comment {
2941 if let Err(e) = repo.comment_pr(pr_number, &text) {
2942 logdim!("could not post the disposition comment: {e}");
2943 }
2944 }
2945 }
2946 unresolved
2947}
2948
2949fn filed_entry(recorded: &Followup, reasoning: &str) -> Option<(Settled, String)> {
2957 let (outcome, tail) = match recorded {
2958 Followup::Recorded(reference) => (
2959 Settled::Filed,
2960 format!("Tracked in {}.", as_reference(reference)),
2961 ),
2962 Followup::Covered(reference) => (
2963 Settled::Filed,
2964 format!("Already covered by {}.", as_reference(reference)),
2965 ),
2966 Followup::Dropped(why) => (Settled::Dropped, format!("Not filed anywhere: {why}.")),
2967 Followup::Failed => return None,
2968 };
2969 let reasoning = match reasoning.trim() {
2970 "" => tail,
2971 said => format!("{said} {tail}"),
2972 };
2973 Some((outcome, reasoning))
2974}
2975
2976fn external_followup_write_paused(destination: Followups, state: &IssueRun) -> bool {
2983 destination == Followups::Issues && state.followup_writes_uncertain
2984}
2985
2986fn failed_followup(state: &mut IssueRun, error: &SparError) -> Followup {
2987 if error.kind() == ErrorKind::UncertainWrite {
2988 state.followup_writes_uncertain = true;
2989 if !state
2990 .notes
2991 .iter()
2992 .any(|note| note.contains("external follow-up writes were paused"))
2993 {
2994 state.notes.push(
2995 "An external follow-up write could not be verified, so further external \
2996 follow-up writes were paused for this run. Inspect recent issues and comments \
2997 before trying them again."
2998 .to_string(),
2999 );
3000 }
3001 }
3002 Followup::Failed
3003}
3004
3005pub fn file_followup(
3016 repo: &Repo,
3017 title: &str,
3018 body: &str,
3019 source: i64,
3020 cfg: &Config,
3021 state: &mut IssueRun,
3022) -> Followup {
3023 if repo.followups == Followups::None {
3024 return Followup::Dropped("follow-ups are off for this repository");
3025 }
3026 if external_followup_write_paused(repo.followups, state) {
3027 logdim!(
3028 "not attempting another external follow-up write after an earlier result could not \
3029 be verified"
3030 );
3031 return Followup::Failed;
3032 }
3033 if state.filed.len() >= cfg.loop_cfg.max_followups {
3036 logwarn!(
3037 "already recorded {} follow-ups, not recording '{}'. Raise max_followups if you want \
3038 them all.",
3039 state.filed.len(),
3040 style::title(title, &repo.style)
3041 );
3042 return Followup::Dropped("this run had already recorded as many follow-ups as it may");
3043 }
3044 let title = match repo.clean_followup_title(title) {
3051 Ok(title) => title,
3052 Err(e) => {
3053 logdim!("could not clean a follow-up title: {e}");
3054 return Followup::Failed;
3055 }
3056 };
3057 if title.trim().is_empty() {
3058 logdim!("nothing left of a follow-up title after cleaning it");
3059 return Followup::Failed;
3060 }
3061 let body = format!(
3064 "{}\n\nFound while working on #{source}.",
3065 style::issue_body(body, &repo.style)
3066 );
3067
3068 if repo.followups == Followups::Local {
3069 return repo.append_local_followup(&title, &body);
3070 }
3071
3072 match file_as_issue(repo, &title, &body) {
3073 Ok(filed) => filed.into(),
3074 Err(e) => {
3075 logdim!("could not file a follow-up for '{title}': {e}");
3076 failed_followup(state, &e)
3077 }
3078 }
3079}
3080
3081#[derive(Debug, Clone)]
3083pub enum Filed {
3084 Opened(i64, String),
3086 AddedTo(i64, String),
3088 Covered(i64, String),
3090}
3091
3092impl From<Filed> for Followup {
3093 fn from(filed: Filed) -> Self {
3094 match filed {
3095 Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => {
3096 Followup::Recorded(url)
3097 }
3098 }
3099 }
3100}
3101
3102impl Filed {
3103 pub fn url(&self) -> Option<&str> {
3104 match self {
3105 Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => Some(url),
3106 }
3107 }
3108
3109 pub fn issue(&self) -> i64 {
3112 match self {
3113 Filed::Opened(n, _) | Filed::AddedTo(n, _) | Filed::Covered(n, _) => *n,
3114 }
3115 }
3116
3117 pub fn number(&self) -> Option<i64> {
3119 match self {
3120 Filed::Opened(n, _) | Filed::AddedTo(n, _) | Filed::Covered(n, _) => Some(*n),
3121 }
3122 }
3123
3124 pub fn note(&self) -> String {
3126 match self {
3127 Filed::Opened(n, _) => format!("#{n}"),
3128 Filed::AddedTo(n, _) => format!("added to #{n}"),
3129 Filed::Covered(n, _) => format!("#{n} already says this"),
3130 }
3131 }
3132
3133 pub fn describe(&self, title: &str) -> String {
3134 let title = style::clip(title.trim(), 80);
3135 match self {
3136 Filed::Opened(n, _) => format!("filed #{n}: {title}"),
3137 Filed::AddedTo(n, _) => format!("added to #{n}: {title}"),
3138 Filed::Covered(n, _) => format!("#{n} already says this: {title}"),
3139 }
3140 }
3141}
3142
3143pub fn file_as_issue(repo: &Repo, title: &str, body: &str) -> Result<Filed> {
3155 file_as_issue_apart_from(repo, title, body, None)
3156}
3157
3158pub fn file_as_issue_apart_from(
3164 repo: &Repo,
3165 title: &str,
3166 body: &str,
3167 apart_from: Option<i64>,
3168) -> Result<Filed> {
3169 let title = repo.record_failed_write(repo.clean_title(title))?;
3170 if title.trim().is_empty() {
3171 return repo.record_failed_write(Err(spar_err!(
3172 "nothing left of the title after cleaning it"
3173 )));
3174 }
3175 let issue_body = repo.record_failed_write(repo.clean_issue_body(body))?;
3176 let exact =
3177 repo.record_failed_write(repo.try_exact_issue_apart_from(&title, &issue_body, apart_from))?;
3178 let mut body_to_file = issue_body.clone();
3179 if let Some(existing) = exact {
3180 if existing.open {
3181 logdim!("'{title}' is already open as #{}", existing.number);
3182 return Ok(Filed::Covered(existing.number, existing.url));
3183 }
3184 logdim!(
3190 "'{title}' matches #{}, which is closed, so it is being filed again",
3191 existing.number
3192 );
3193 body_to_file = regression_body(&issue_body, existing.number);
3194 } else {
3195 let similar = repo.record_failed_write(repo.try_find_similar_issue_apart_from(
3196 &title,
3197 &issue_body,
3198 apart_from,
3199 ))?;
3200 if let Some(existing) = similar {
3201 let known = format!("{} {}", existing.title, existing.body);
3202 if crate::textsim::adds_information(&issue_body, &known) {
3203 logdim!(
3204 "'{title}' is covered by #{} and adds something, so it goes there as a \
3205 comment",
3206 existing.number
3207 );
3208 repo.comment_issue(existing.number, &issue_body)?;
3209 return Ok(Filed::AddedTo(existing.number, existing.url));
3210 }
3211 logdim!(
3212 "'{title}' is covered by #{} '{}', which already says it",
3213 existing.number,
3214 style::clip(&existing.title, 60)
3215 );
3216 return Ok(Filed::Covered(existing.number, existing.url));
3217 }
3218 }
3219 let url = repo.create_issue_apart_from(&title, &body_to_file, apart_from)?;
3220 let number = filed_issue_number(&url)
3221 .ok_or_else(|| spar_err!("filed an issue but could not read its number from {url}"))?;
3222 Ok(Filed::Opened(number, url))
3223}
3224
3225pub(crate) fn regression_body(body: &str, closed: i64) -> String {
3230 format!(
3231 "{}\n\nThis was filed before as #{closed}, which is closed. The evidence above is from \
3232 a later run, so it looks like it has come back.",
3233 body.trim()
3234 )
3235}
3236
3237fn file_out_of_scope(
3238 repo: &Repo,
3239 findings: &[Finding],
3240 subject: i64,
3241 state: &mut IssueRun,
3242 cfg: &Config,
3243) {
3244 for finding in findings.iter().filter(|f| !f.in_scope) {
3245 let body = issue_report(finding);
3246 let recorded = file_followup(repo, &finding.title, &body, subject, cfg, state);
3247 if finding.severity != Severity::Nit || matches!(recorded, Followup::Recorded(_)) {
3248 record_nonblocking_outcome_with_match(
3249 state,
3250 finding,
3251 Some(&recorded),
3252 unique_stable_finding(findings, finding),
3253 );
3254 }
3255 }
3256}
3257
3258pub fn issue_report(finding: &Finding) -> String {
3265 let sections = finding.report_sections();
3266 if sections.is_empty() {
3267 return finding.detail.clone();
3268 }
3269 let mut out: Vec<String> = sections
3270 .iter()
3271 .map(|(heading, text)| format!("## {heading}\n\n{text}"))
3272 .collect();
3273 if !finding.detail.trim().is_empty()
3276 && !sections
3277 .iter()
3278 .any(|(_, text)| crate::textsim::same_point(text, &finding.detail))
3279 {
3280 out.insert(0, finding.detail.trim().to_string());
3281 }
3282 out.join("\n\n")
3283}
3284
3285fn file_nonblocking(
3292 repo: &Repo,
3293 findings: &[Finding],
3294 subject: i64,
3295 state: &mut IssueRun,
3296 cfg: &Config,
3297) {
3298 for finding in findings {
3299 if !finding.in_scope || finding.severity == Severity::Blocking {
3300 continue;
3301 }
3302 let should_file = match finding.severity {
3303 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking,
3304 Severity::Nit => cfg.loop_cfg.file_nits,
3305 Severity::Blocking => false,
3306 };
3307 if !should_file {
3308 if finding.severity == Severity::NonBlocking {
3309 record_nonblocking_outcome_with_match(
3310 state,
3311 finding,
3312 None,
3313 unique_stable_finding(findings, finding),
3314 );
3315 }
3316 continue;
3317 }
3318 let recorded = file_followup(repo, &finding.title, &finding.detail, subject, cfg, state);
3319 if finding.severity == Severity::NonBlocking {
3320 record_nonblocking_outcome_with_match(
3321 state,
3322 finding,
3323 Some(&recorded),
3324 unique_stable_finding(findings, finding),
3325 );
3326 } else if let Some(url) = recorded.url() {
3327 state.filed.push(url.to_string());
3328 }
3329 }
3330}
3331
3332fn bullets(lines: &[String]) -> String {
3342 lines
3343 .iter()
3344 .map(|l| format!("- {l}"))
3345 .collect::<Vec<_>>()
3346 .join("\n")
3347}
3348
3349fn located(finding: &Finding, style: &Style) -> String {
3350 let title = style::title(&finding.title, style);
3351 match finding.where_at() {
3352 "general" => title,
3353 file => format!("{title} ({file})"),
3354 }
3355}
3356
3357pub enum Ending<'a> {
3359 Approved,
3361 OutOfRounds,
3364 Unchanged,
3367 Unresolved(&'a [Finding]),
3370 Deadlocked(&'a [Finding]),
3373}
3374
3375#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3376enum OutcomeSink {
3377 PullRequest,
3378 Terminal,
3379}
3380
3381fn outcome_sink(mode: PrComments) -> OutcomeSink {
3382 match mode {
3383 PrComments::Outcome | PrComments::Rounds => OutcomeSink::PullRequest,
3384 PrComments::None => OutcomeSink::Terminal,
3385 }
3386}
3387
3388fn emit_outcome(repo: &Repo, pr_number: i64, text: &str) {
3389 if outcome_sink(repo.style.pr_comments) == OutcomeSink::Terminal {
3390 println!("\n{text}\n");
3391 return;
3392 }
3393 if let Err(e) = repo.comment_pr(pr_number, text) {
3394 logdim!("could not post the outcome comment: {e}");
3395 println!("\n{text}\n");
3396 }
3397}
3398
3399pub fn post_outcome(
3411 repo: &Repo,
3412 pr_number: i64,
3413 state: &IssueRun,
3414 ledger: &Ledger,
3415 ending: Ending<'_>,
3416) {
3417 let Some(text) = outcome_comment(state, ledger, &ending, &repo.style) else {
3418 return;
3419 };
3420 emit_outcome(repo, pr_number, &text);
3421}
3422
3423fn post_unread_outcome(
3424 repo: &Repo,
3425 pr_number: i64,
3426 state: &IssueRun,
3427 ledger: &Ledger,
3428 open_findings: &[Finding],
3429) {
3430 let Some(text) = outcome_comment_with_unread(
3431 state,
3432 ledger,
3433 &Ending::OutOfRounds,
3434 open_findings,
3435 &repo.style,
3436 ) else {
3437 return;
3438 };
3439 emit_outcome(repo, pr_number, &text);
3440}
3441
3442fn settled_as(finding: &Finding, state: &IssueRun, ledger: &Ledger) -> Option<(Settled, String)> {
3445 if let Some(d) = state
3446 .disputes
3447 .iter()
3448 .find(|d| same_finding_parts(&d.title, &d.file, &finding.title, &finding.file))
3449 {
3450 if !d.reasoning.trim().is_empty() {
3451 return Some((Settled::Refuted, d.reasoning.clone()));
3452 }
3453 }
3454 matching_ledger_entry(ledger, &finding.title, &finding.file)
3455 .filter(|entry| !entry.reasoning.trim().is_empty())
3456 .map(|entry| (entry.outcome, entry.reasoning.clone()))
3457}
3458
3459pub fn filed_issue_number(filed: &str) -> Option<i64> {
3464 filed
3465 .rsplit('/')
3466 .next()
3467 .and_then(|tail| tail.parse::<i64>().ok())
3468 .filter(|n| *n > 0)
3469}
3470
3471fn as_reference(url: &str) -> String {
3472 match url.rsplit('/').next().and_then(|n| n.parse::<u64>().ok()) {
3473 Some(number) => format!("#{number}"),
3474 None => url.to_string(),
3475 }
3476}
3477
3478pub fn outcome_comment(
3479 state: &IssueRun,
3480 ledger: &Ledger,
3481 ending: &Ending<'_>,
3482 style: &Style,
3483) -> Option<String> {
3484 outcome_comment_with_unread(state, ledger, ending, &[], style)
3485}
3486
3487fn outcome_comment_with_unread(
3488 state: &IssueRun,
3489 ledger: &Ledger,
3490 ending: &Ending<'_>,
3491 unread_open: &[Finding],
3492 style: &Style,
3493) -> Option<String> {
3494 let mut out: Vec<String> = Vec::new();
3495 let mut already: Vec<(String, String)> = Vec::new();
3498
3499 match ending {
3500 Ending::Approved => {
3501 if state.disputes.is_empty() && state.filed.is_empty() && state.noted.is_empty() {
3502 return None;
3508 }
3509 out.push("Reviewed, nothing blocking a merge.".into());
3510 }
3511 Ending::OutOfRounds => {
3512 out.push(
3513 "Not signed off: the last round of fixes was pushed but has not been reviewed."
3514 .into(),
3515 );
3516 if !unread_open.is_empty() {
3517 let lines: Vec<String> = unread_open
3518 .iter()
3519 .map(|finding| {
3520 already.push((finding.title.clone(), finding.file.clone()));
3521 format!(
3522 "{}. {}",
3523 located(finding, style),
3524 style::sentence(&finding.detail, style)
3525 )
3526 })
3527 .collect();
3528 out.push("These points were already open:".into());
3529 out.push(bullets(&lines));
3530 }
3531 }
3532 Ending::Unchanged => out.push(
3536 "Not signed off: the last round changed nothing, so the branch is the one that was \
3537 already reviewed."
3538 .into(),
3539 ),
3540 Ending::Unresolved(points) => {
3541 let lines: Vec<String> = points
3542 .iter()
3543 .map(|f| {
3544 already.push((f.title.clone(), f.file.clone()));
3545 format!(
3546 "{}. {}",
3547 located(f, style),
3548 style::sentence(&f.detail, style)
3549 )
3550 })
3551 .collect();
3552 out.push("Not signed off. These points are still open:".into());
3553 out.push(bullets(&lines));
3554 }
3555 Ending::Deadlocked(points) => {
3556 let lines: Vec<String> = points
3562 .iter()
3563 .map(|f| {
3564 let where_at = match f.where_at() {
3565 "general" => String::new(),
3566 file => format!(" ({file})"),
3567 };
3568 let title = style::title(&f.title, style);
3569 already.push((f.title.clone(), f.file.clone()));
3570 match settled_as(f, state, ledger) {
3571 Some((Settled::Refuted, reason)) => format!(
3572 "{title}{where_at}. Refuted as: {}",
3573 style::summary(&reason, style)
3574 ),
3575 Some((Settled::Filed, reason)) => format!(
3576 "{title}{where_at}. Filed as out of scope: {}",
3577 style::summary(&reason, style)
3578 ),
3579 Some((Settled::Dropped, reason)) => format!(
3582 "{title}{where_at}. Out of scope here, and not filed: {}",
3583 style::summary(&reason, style)
3584 ),
3585 Some((Settled::Fixed, reason)) => format!(
3591 "{title}{where_at}. Fixed and raised again. Recorded answer: {}",
3592 style::summary(&reason, style)
3593 ),
3594 None => format!("{title}{where_at}"),
3595 }
3596 })
3597 .collect();
3598 out.push("Needs your decision. The reviewers could not settle this:".into());
3599 out.push(bullets(&lines));
3600 }
3601 }
3602
3603 let disputes: Vec<&crate::model::Dispute> = state
3604 .disputes
3605 .iter()
3606 .filter(|d| {
3607 !already
3608 .iter()
3609 .any(|(title, file)| same_finding_parts(title, file, &d.title, &d.file))
3610 })
3611 .collect();
3612 if !disputes.is_empty() {
3613 let lines: Vec<String> = disputes
3616 .iter()
3617 .map(|d| {
3618 let title = style::title(&d.title, style);
3619 let title = if d.file.trim().is_empty() {
3620 title
3621 } else {
3622 format!("{title} ({})", d.file.trim())
3623 };
3624 format!("{}. {}", title, style::sentence(&d.reasoning, style))
3625 })
3626 .collect();
3627 out.push(format!("Raised and refuted:\n{}", bullets(&lines)));
3628 }
3629
3630 if !state.noted.is_empty() {
3631 let lines: Vec<String> = state
3635 .noted
3636 .iter()
3637 .filter(|f| {
3638 !already
3639 .iter()
3640 .any(|(title, file)| same_finding_parts(title, file, &f.title, &f.file))
3641 })
3642 .map(|f| located(f, style))
3643 .collect();
3644 if !lines.is_empty() {
3645 out.push(format!("Noted, not blocking:\n{}", bullets(&lines)));
3646 }
3647 }
3648
3649 if !state.filed.is_empty() {
3650 let refs: Vec<String> = state.filed.iter().map(|u| as_reference(u)).collect();
3651 out.push(format!("Filed separately: {}", refs.join(", ")));
3652 }
3653
3654 Some(out.join("\n\n"))
3655}
3656
3657#[allow(clippy::too_many_arguments)]
3666fn close_prompt(
3667 base: &str,
3668 number: i64,
3669 title: &str,
3670 context: &str,
3671 from: &str,
3672 landed: Option<&[String]>,
3673 ledger: &Ledger,
3674 open_findings: &[Finding],
3675 round: u32,
3676) -> String {
3677 let landed = match landed {
3678 Some([]) => "\nNothing landed after the last round of review. What it asked for was \
3679 answered in words rather than in code, so the branch in front of you is the \
3680 branch that was already read.\n"
3681 .to_string(),
3682 Some(lines) => format!(
3683 "\nThis landed after the last round of review, and nobody has read it:\n{}\n\nRead it \
3684 first with `git diff {from}..HEAD`, then inspect the full branch with `git diff \
3685 {base}...HEAD`.\n",
3686 lines
3687 .iter()
3688 .map(|l| format!("- {l}"))
3689 .collect::<Vec<_>>()
3690 .join("\n")
3691 ),
3692 None => format!(
3693 "\nThe commits on this branch were rewritten after the last round of review, so the \
3694 harness cannot say which of them are new. Inspect the full branch with `git diff \
3695 {base}...HEAD`.\n"
3696 ),
3697 };
3698 CLOSE_PROMPT
3699 .replace("{number}", &number.to_string())
3700 .replace("{title}", title)
3701 .replace("{context}", context)
3702 .replace("{base}", base)
3703 .replace("{landed}", &landed)
3704 .replace("{open}", &open_findings_block(open_findings))
3705 .replace("{answers}", &closing_answers(ledger, round))
3706 .replace("{settled}", &settled_block(ledger))
3707}
3708
3709fn open_findings_block(findings: &[Finding]) -> String {
3710 if findings.is_empty() {
3711 return String::new();
3712 }
3713 format!(
3714 "\nThese blocking findings were left open by an earlier response. Recheck each one:\n{}\n\
3715 \nIf one still blocks, return it under the same title and file. Omission means you checked \
3716 it and found that it no longer blocks.\n",
3717 findings_for_prompt(findings)
3718 )
3719}
3720
3721fn closing_answers(ledger: &Ledger, round: u32) -> String {
3726 let lines = fixed_lines(ledger, round);
3727 if lines.is_empty() {
3728 return String::new();
3729 }
3730 format!(
3731 "\nThese points were raised on this pull request and the author says it fixed them. \
3732 Nobody has checked that:\n{}\n",
3733 lines.join("\n")
3734 )
3735}
3736
3737#[allow(clippy::too_many_arguments)]
3742fn review_prompt(
3743 base: &str,
3744 number: i64,
3745 title: &str,
3746 context: &str,
3747 ledger: &Ledger,
3748 open_findings: &[Finding],
3749 round: u32,
3750 last: u32,
3751) -> String {
3752 REVIEW_PROMPT
3753 .replace("{base}", base)
3754 .replace("{number}", &number.to_string())
3755 .replace("{title}", title)
3756 .replace("{context}", context)
3757 .replace("{open}", &open_findings_block(open_findings))
3758 .replace("{answers}", &answers_block(ledger, round))
3759 .replace("{settled}", &settled_block(ledger))
3760 .replace("{round}", &round_note(round, last))
3761}
3762
3763fn implement_prompt(number: i64, title: &str, url: &str, body: &str) -> String {
3771 IMPLEMENT_PROMPT
3772 .replace("{thread_rule}", THREAD_RULE)
3773 .replace("{number}", &number.to_string())
3774 .replace("{title}", title)
3775 .replace("{url}", url)
3776 .replace("{body}", body)
3777}
3778
3779pub fn pr_body(issue: i64, work: &Implementation, checked: Option<&str>, style: &Style) -> String {
3789 let mut parts = vec![format!("Closes #{issue}")];
3790
3791 for lead in [&work.summary, &work.problem] {
3792 let text = style::sentence(lead, style);
3793 if !text.is_empty() {
3794 parts.push(text);
3795 }
3796 }
3797 parts.extend(section("What changed", &work.changes, style));
3798 let mut testing = work.testing.clone();
3801 if let Some(command) = checked {
3802 testing.push(format!("`{command}` passed on this branch, run by spar."));
3803 }
3804 parts.extend(section("How to test", &testing, style));
3805
3806 let notes = style::sentence(work.notes.as_deref().unwrap_or_default(), style);
3807 if !notes.is_empty() {
3808 parts.push(format!("## Notes\n\n{notes}"));
3809 }
3810
3811 style::body(&parts.join("\n\n"), style)
3812}
3813
3814fn section(heading: &str, lines: &[String], style: &Style) -> Option<String> {
3821 let items: Vec<String> = lines
3822 .iter()
3823 .map(|line| style::summary(line, style))
3824 .filter(|line| !line.is_empty())
3825 .collect();
3826 if items.is_empty() {
3827 return None;
3828 }
3829 Some(format!("## {heading}\n\n{}", bullets(&items)))
3830}
3831
3832pub fn from_commits(repo: &Repo, work_dir: &Path, base: &str) -> Implementation {
3840 Implementation {
3841 changes: repo.commit_subjects(work_dir, "HEAD", base),
3842 notes: Some(
3843 "The implement call failed after these commits were made, so this body is assembled \
3844 from their messages rather than written by their author. Read the diff."
3845 .to_string(),
3846 ),
3847 ..Implementation::default()
3848 }
3849}
3850
3851fn no_pr_note(work: &Implementation, style: &Style) -> String {
3858 let reason = style::sentence(&work.reason, style);
3859 if !reason.is_empty() {
3860 return reason;
3861 }
3862 if work.not_worth_doing {
3863 "Left alone after reading the code, with no reason given.".to_string()
3864 } else {
3865 "Nothing was committed, so there is nothing to review.".to_string()
3866 }
3867}
3868
3869pub fn review_comment(holder: &str, round: u32, review: &Review, style: &Style) -> String {
3873 let by = |severity: Severity| -> Vec<&Finding> {
3874 review
3875 .findings
3876 .iter()
3877 .filter(|f| f.severity == severity && f.in_scope)
3878 .collect()
3879 };
3880 let blocking = by(Severity::Blocking);
3881 let non_blocking = by(Severity::NonBlocking);
3882 let nits = by(Severity::Nit);
3883 let out_of_scope: Vec<&Finding> = review.findings.iter().filter(|f| !f.in_scope).collect();
3884
3885 let mut counts = Vec::new();
3886 if !blocking.is_empty() {
3887 counts.push(format!("{} blocking", blocking.len()));
3888 }
3889 if !non_blocking.is_empty() {
3890 counts.push(format!("{} non-blocking", non_blocking.len()));
3891 }
3892 if !nits.is_empty() {
3893 counts.push(format!("{} nit", nits.len()));
3894 }
3895 if !out_of_scope.is_empty() {
3896 counts.push(format!("{} out of scope", out_of_scope.len()));
3897 }
3898 let headline = if counts.is_empty() {
3899 "no findings".to_string()
3900 } else {
3901 counts.join(", ")
3902 };
3903
3904 let _ = (holder, round, headline);
3905 let mut out = Vec::new();
3906 let summary = style::summary(&review.summary, style);
3907 if !summary.is_empty() {
3908 out.push(summary);
3909 }
3910
3911 if !blocking.is_empty() {
3912 let lines: Vec<String> = blocking
3913 .iter()
3914 .map(|f| {
3915 let detail = style::detail(&f.detail, style);
3916 if detail.is_empty() {
3917 located(f, style)
3918 } else {
3919 format!("{}. {detail}", located(f, style))
3920 }
3921 })
3922 .collect();
3923 out.push(format!("blocking\n{}", bullets(&lines)));
3924 }
3925
3926 for (label, group) in [
3929 ("non-blocking", &non_blocking),
3930 ("nits", &nits),
3931 ("out of scope", &out_of_scope),
3932 ] {
3933 if group.is_empty() {
3934 continue;
3935 }
3936 let lines: Vec<String> = group.iter().map(|f| located(f, style)).collect();
3937 out.push(format!("{label}\n{}", bullets(&lines)));
3938 }
3939
3940 out.join("\n\n")
3941}
3942
3943pub fn disposition_comment(
3947 author: &str,
3948 response: &ResponseDoc,
3949 fixed: &[String],
3950 refuted: &[String],
3951 filed: &[String],
3952 style: &Style,
3953) -> Option<String> {
3954 if fixed.is_empty() && refuted.is_empty() && filed.is_empty() {
3955 return None;
3956 }
3957 let mut counts = Vec::new();
3958 if !fixed.is_empty() {
3959 counts.push(format!("{} fixed", fixed.len()));
3960 }
3961 if !refuted.is_empty() {
3962 counts.push(format!("{} refuted", refuted.len()));
3963 }
3964 if !filed.is_empty() {
3965 counts.push(format!("{} filed", filed.len()));
3966 }
3967
3968 let _ = (author, counts);
3969 let mut out = Vec::new();
3970 let summary = style::summary(&response.summary, style);
3971 if !summary.is_empty() {
3972 out.push(summary);
3973 }
3974 if !refuted.is_empty() {
3975 out.push(format!("refuted\n{}", bullets(refuted)));
3976 }
3977 if !fixed.is_empty() {
3978 out.push(format!("fixed\n{}", bullets(fixed)));
3979 }
3980 if !filed.is_empty() {
3981 out.push(format!("filed\n{}", bullets(filed)));
3982 }
3983 Some(out.join("\n\n"))
3984}
3985
3986pub fn skip_comment(item: &SkippedItem, style: &Style) -> String {
3994 let reasons = item
3995 .reasons
3996 .values()
3997 .map(|reason| style::sentence(reason, style));
3998 let lines = crate::textsim::dedupe_by(reasons, crate::textsim::same_reason);
4002 bullets(&lines)
4003}
4004
4005pub(crate) fn context_block(number: i64, url: &str, issue_text: &str, pr_body: &str) -> String {
4023 let mut out = String::new();
4024 let body = issue_text.trim();
4025 if !body.is_empty() {
4026 out.push_str(&format!("\nIssue #{number} as filed"));
4027 if !url.trim().is_empty() {
4028 out.push_str(&format!(", at {}", url.trim()));
4029 }
4030 out.push_str(&format!(
4031 ":\n\n{body}\n\nThat is the issue as filed, followed by any comments on it since, \
4032 oldest first. {}\n",
4033 THREAD_RULE.replace('\n', " ")
4034 ));
4035 }
4036 let claim = pr_body.trim();
4037 if !claim.is_empty() {
4038 out.push_str(&format!(
4039 "\nThe pull request description, which is the author's own account of this change. \
4040 It is a claim to check against the code, not a statement of fact:\n\n{claim}\n"
4041 ));
4042 }
4043 out
4044}
4045
4046pub(crate) fn optional_findings(findings: &[Finding]) -> Vec<Finding> {
4048 findings
4049 .iter()
4050 .filter(|f| f.in_scope && f.severity == Severity::NonBlocking)
4051 .cloned()
4052 .collect()
4053}
4054
4055fn optional_block(findings: &[Finding]) -> String {
4056 let optional = optional_findings(findings);
4057 if optional.is_empty() {
4058 return String::new();
4059 }
4060 format!("\n{OPTIONAL_HEADER}{}\n", findings_for_prompt(&optional))
4061}
4062
4063fn warn_unplaced_dispositions(response: &ResponseDoc, used: &[bool]) {
4081 for (index, disposition) in response.dispositions.iter().enumerate() {
4082 if !used[index] {
4083 logwarn!(
4084 "ignoring an unmatched or duplicate disposition for '{}' ({})",
4085 disposition.title,
4086 disposition.file
4087 );
4088 }
4089 }
4090}
4091
4092fn apply_optional_dispositions(
4093 repo: &Repo,
4094 cfg: &Config,
4095 response: &ResponseDoc,
4096 findings: &[Finding],
4097 state: &mut IssueRun,
4098 subject: i64,
4099 used: &mut [bool],
4100) {
4101 let optional = optional_findings(findings);
4102 if optional.is_empty() {
4103 return;
4104 }
4105 let mut fixed = 0usize;
4106 for finding in &optional {
4107 let Ok((index, disposition)) =
4108 matching_disposition(finding, &optional, &response.dispositions)
4109 else {
4110 continue;
4111 };
4112 if used[index] {
4115 continue;
4116 }
4117 used[index] = true;
4118 let unique = unique_stable_finding(&optional, finding);
4119 match disposition.action {
4120 Action::Fixed => {
4121 fixed += 1;
4122 forget_noted(state, finding, unique);
4123 }
4124 Action::FiledIssue => {
4125 let title = disposition
4126 .new_issue_title
4127 .clone()
4128 .filter(|t| !t.trim().is_empty())
4129 .unwrap_or_else(|| finding.title.clone());
4130 let body = disposition
4131 .new_issue_body
4132 .clone()
4133 .filter(|b| !b.trim().is_empty())
4134 .unwrap_or_else(|| finding.detail.clone());
4135 let recorded = file_followup(repo, &title, &body, subject, cfg, state);
4136 record_nonblocking_outcome_with_match(state, finding, Some(&recorded), unique);
4137 }
4138 Action::Refuted => {}
4139 }
4140 }
4141 if fixed > 0 {
4142 logdim!("the author also fixed {fixed} non-blocking finding(s)");
4143 }
4144}
4145
4146pub(crate) fn findings_for_prompt(findings: &[Finding]) -> String {
4149 if findings.is_empty() {
4150 return "(none)".to_string();
4151 }
4152 findings
4153 .iter()
4154 .map(|f| {
4155 let scope = if f.in_scope { "" } else { " [out of scope]" };
4156 format!(
4157 "- [{}]{scope} {} ({})\n {}",
4158 f.severity,
4159 f.title,
4160 f.where_at(),
4161 f.detail
4162 )
4163 })
4164 .collect::<Vec<_>>()
4165 .join("\n")
4166}
4167
4168#[cfg(test)]
4169mod tests {
4170 use super::*;
4171 use crate::model::Verdict;
4172
4173 fn style() -> Style {
4174 Style::default()
4175 }
4176
4177 fn finding(severity: &str, title: &str, detail: &str, file: &str, in_scope: bool) -> Finding {
4178 Finding {
4179 severity: Severity::parse_lenient(severity).unwrap(),
4180 title: title.into(),
4181 detail: detail.into(),
4182 file: file.into(),
4183 in_scope,
4184 ..Default::default()
4185 }
4186 }
4187
4188 fn review(summary: &str, findings: Vec<Finding>) -> Review {
4189 Review {
4190 verdict: Verdict::Approve,
4191 next_action: NextAction::Merge,
4192 summary: summary.into(),
4193 findings,
4194 }
4195 }
4196
4197 fn disposition(title: &str, file: &str, action: Action) -> Disposition {
4198 Disposition {
4199 title: title.into(),
4200 file: file.into(),
4201 action,
4202 reasoning: "because".into(),
4203 new_issue_title: None,
4204 new_issue_body: None,
4205 }
4206 }
4207
4208 fn cfg_with(worktrees: bool, keep: bool) -> Config {
4211 let text = "[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n";
4212 let mut cfg = crate::config::parse(text).unwrap();
4213 cfg.loop_cfg.worktrees = worktrees;
4214 cfg.loop_cfg.keep_worktrees = keep;
4215 cfg
4216 }
4217
4218 #[test]
4219 fn a_worktree_is_released_on_every_finished_outcome() {
4220 let cfg = cfg_with(true, false);
4221 for status in [Status::Approved, Status::Merged, Status::Abandoned] {
4222 assert!(should_release(&cfg, status), "{status}");
4223 }
4224 }
4225
4226 #[test]
4229 fn a_worktree_is_kept_only_where_a_human_has_to_look() {
4230 let cfg = cfg_with(true, false);
4231 assert!(!should_release(&cfg, Status::Escalated));
4232 assert!(!should_release(&cfg, Status::Error));
4233 }
4234
4235 #[test]
4236 fn an_uncommitted_implementation_names_its_diagnostic_and_recovery_path() {
4237 let path = Path::new("/tmp/issue worktree");
4238 let err =
4239 uncommitted_implementation_error(path, Some("git add could not create index.lock"))
4240 .to_string();
4241 assert!(err.contains("could not create index.lock"), "{err}");
4242 assert!(err.contains("/tmp/issue worktree"), "{err}");
4243 assert!(err.contains("Commit or recover"), "{err}");
4244 assert!(!should_release(&cfg_with(true, false), Status::Error));
4245 }
4246
4247 #[test]
4248 fn the_keep_flag_overrides_everything() {
4249 assert!(!should_release(&cfg_with(true, true), Status::Approved));
4250 }
4251
4252 #[test]
4253 fn nothing_is_released_when_worktrees_are_off() {
4254 assert!(!should_release(&cfg_with(false, false), Status::Approved));
4255 }
4256
4257 #[test]
4262 fn fixing_your_own_findings_hands_the_pr_over() {
4263 let cfg = cfg_with(true, false);
4264 assert_eq!("a", next_reviewer(&cfg, "b", Some("b")));
4265 assert_eq!("b", next_reviewer(&cfg, "a", Some("a")));
4266 }
4267
4268 #[test]
4272 fn handing_back_keeps_the_reviewer_for_the_next_round() {
4273 let cfg = cfg_with(true, false);
4274 assert_eq!("b", next_reviewer(&cfg, "b", Some("a")));
4275 assert_eq!("a", next_reviewer(&cfg, "a", Some("b")));
4276 }
4277
4278 #[test]
4281 fn nobody_reviews_their_own_edit() {
4282 let cfg = cfg_with(true, false);
4283 let round_1 = cfg.other(&cfg.first_implementor);
4284 assert_eq!("b", round_1);
4285 for editor in ["a", "b"] {
4286 assert_ne!(editor, next_reviewer(&cfg, &round_1, Some(editor)));
4287 }
4288 }
4289
4290 #[test]
4295 fn a_fix_that_committed_nothing_leaves_the_pr_where_it_is() {
4296 let cfg = cfg_with(true, false);
4297 assert_eq!("b", next_reviewer(&cfg, "b", None));
4298 assert_eq!("a", next_reviewer(&cfg, "a", None));
4299 }
4300
4301 #[test]
4305 fn a_reviewer_that_wrote_the_head_gives_the_pr_up() {
4306 let cfg = cfg_with(true, false);
4307 assert_eq!("a", next_reviewer(&cfg, "b", Some("b")));
4308 }
4309
4310 #[test]
4314 fn a_review_that_wrote_cannot_approve_what_is_left() {
4315 assert!(!approval_stands(&[], true));
4316 }
4317
4318 #[test]
4319 fn a_clean_review_of_an_untouched_branch_approves() {
4320 assert!(approval_stands(&[], false));
4321 }
4322
4323 #[test]
4324 fn a_blocking_finding_never_approves() {
4325 let blocking = vec![finding("blocking", "Broken", "detail", "src/x.rs", true)];
4326 assert!(!approval_stands(&blocking, false));
4327 }
4328
4329 #[test]
4330 fn approval_refuses_a_head_that_changed_after_review() {
4331 assert!(ensure_reviewed_head(36, "abc123", "abc123").is_ok());
4332 let error = ensure_reviewed_head(36, "abc123", "def456").unwrap_err();
4333 assert!(error.to_string().contains("unread head"));
4334 }
4335
4336 #[test]
4338 fn only_a_moved_head_counts_as_a_commit() {
4339 let before = Snapshot {
4340 head: "abc".into(),
4341 dirty: false,
4342 };
4343 assert!(!Snapshot {
4344 head: "abc".into(),
4345 dirty: true,
4346 }
4347 .landed_over(&before));
4348 assert!(Snapshot {
4349 head: "def".into(),
4350 dirty: false,
4351 }
4352 .landed_over(&before));
4353 assert!(!Snapshot {
4355 head: String::new(),
4356 dirty: false,
4357 }
4358 .landed_over(&before));
4359 }
4360
4361 #[test]
4365 fn a_fresh_run_starts_at_one() {
4366 assert_eq!((1, 3), round_window(1, 3));
4367 assert_eq!((1, 5), round_window(1, 5));
4368 }
4369
4370 #[test]
4374 fn a_resumed_run_gets_a_full_fresh_budget() {
4375 assert_eq!((6, 10), round_window(6, 5));
4376 assert_eq!((11, 13), round_window(11, 3));
4377 }
4378
4379 #[test]
4380 fn a_budget_of_one_is_a_single_round() {
4381 assert_eq!((6, 6), round_window(6, 1));
4382 }
4383
4384 #[test]
4385 fn round_numbers_keep_counting_across_sessions() {
4386 let mut start = 1;
4388 let mut seen = Vec::new();
4389 for _ in 0..3 {
4390 let (first, last) = round_window(start, 3);
4391 seen.push((first, last));
4392 start = last + 1;
4393 }
4394 assert_eq!(vec![(1, 3), (4, 6), (7, 9)], seen);
4395 }
4396
4397 fn ledger_with(title: &str, file: &str) -> Ledger {
4400 let mut ledger = Ledger::new();
4401 ledger.insert(
4402 finding_key(title, file),
4403 LedgerEntry {
4404 title: title.into(),
4405 file: file.into(),
4406 reasoning: "no".into(),
4407 round: 1,
4408 reraised: 0,
4409 outcome: Settled::Refuted,
4410 },
4411 );
4412 ledger
4413 }
4414
4415 #[test]
4416 fn a_point_refuted_and_re_raised_twice_escalates() {
4417 let mut ledger = ledger_with("nit about naming", "a.rs");
4418 let mut state = IssueRun::new(1, "t");
4419 let blocking = vec![finding("blocking", "nit about naming", "d", "a.rs", true)];
4420 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
4421 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
4422 }
4423
4424 #[test]
4428 fn a_point_fixed_twice_and_raised_again_escalates() {
4429 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
4430 for entry in ledger.values_mut() {
4431 entry.outcome = Settled::Fixed;
4432 }
4433 let mut state = IssueRun::new(1, "t");
4434 let blocking = [finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
4435
4436 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
4437 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
4438 }
4439
4440 #[test]
4443 fn a_fix_that_missed_twice_is_not_reported_as_a_refutation() {
4444 assert!(why_escalated(Settled::Fixed).contains("fixed twice"));
4445 for outcome in [Settled::Refuted, Settled::Filed, Settled::Dropped] {
4446 assert!(why_escalated(outcome).contains("settled"), "{outcome}");
4447 }
4448 }
4449
4450 #[test]
4455 fn a_reviewer_that_fixes_its_own_findings_records_them_too() {
4456 let mut ledger = Ledger::new();
4457 let blocking = vec![finding(
4458 "blocking",
4459 "Unbounded loop",
4460 "spins",
4461 "src/x.rs",
4462 true,
4463 )];
4464 let mut state = IssueRun::new(1, "t");
4465
4466 record_own_fixes(&blocking, &mut ledger, &mut state, 1);
4467
4468 let entry = ledger
4469 .get(&finding_key("Unbounded loop", "src/x.rs"))
4470 .expect("keyed where the next round will look");
4471 assert_eq!(Settled::Fixed, entry.outcome);
4472 assert_eq!(
4473 "a committed change was made for this point",
4474 entry.reasoning
4475 );
4476
4477 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
4479 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
4480 }
4481
4482 #[test]
4491 fn a_review_that_contradicts_itself_says_so_and_the_findings_win() {
4492 let blocker = finding("blocking", "Retry loop spins", "spins", "src/net.rs", true);
4493 let approve_with_blocker = Review {
4494 verdict: Verdict::Approve,
4495 next_action: NextAction::HandBack,
4496 summary: "one blocker".into(),
4497 findings: vec![blocker],
4498 };
4499 let said = verdict_disagreement(&approve_with_blocker, 1).expect("noticed");
4500 assert!(said.contains("approve"), "{said}");
4501 assert!(said.contains("findings decide"), "{said}");
4502
4503 let changes_but_merge = Review {
4504 verdict: Verdict::ChangesRequested,
4505 next_action: NextAction::Merge,
4506 summary: "nothing blocking".into(),
4507 findings: vec![],
4508 };
4509 assert!(verdict_disagreement(&changes_but_merge, 0).is_some());
4510
4511 let clean = Review {
4513 verdict: Verdict::Approve,
4514 next_action: NextAction::Merge,
4515 summary: "good".into(),
4516 findings: vec![],
4517 };
4518 assert!(verdict_disagreement(&clean, 0).is_none());
4519 let blocked = Review {
4520 verdict: Verdict::ChangesRequested,
4521 next_action: NextAction::HandBack,
4522 summary: "one blocker".into(),
4523 findings: vec![],
4524 };
4525 assert!(verdict_disagreement(&blocked, 1).is_none());
4526 }
4527
4528 #[test]
4536 fn a_finding_the_fix_call_declined_stays_open() {
4537 let blocking = vec![
4538 finding("blocking", "Unbounded loop", "spins", "src/x.rs:88", true),
4539 finding("blocking", "Missing guard", "panics", "src/y.rs:12", true),
4540 ];
4541 let fixes = vec![
4542 OwnFix {
4543 title: "Unbounded loop".into(),
4544 file: "src/x.rs:88".into(),
4545 fixed: true,
4546 reason: "bounded it on max_attempts".into(),
4547 },
4548 OwnFix {
4549 title: "Missing guard".into(),
4550 file: "src/y.rs:12".into(),
4551 fixed: false,
4552 reason: "the guard belongs in the caller, which this branch does not touch".into(),
4553 },
4554 ];
4555
4556 let (claimed, left) = split_own_fixes(&blocking, &fixes);
4557
4558 assert_eq!(1, claimed.len());
4559 assert_eq!("Unbounded loop", claimed[0].title);
4560 assert_eq!(1, left.len());
4561 assert_eq!("Missing guard", left[0].title);
4562 assert!(
4563 left[0].detail.contains("belongs in the caller"),
4564 "the author is not told why it was left: {}",
4565 left[0].detail
4566 );
4567
4568 let mut ledger = Ledger::new();
4570 let mut state = IssueRun::new(1, "t");
4571 record_own_fixes(&claimed, &mut ledger, &mut state, 1);
4572 assert!(ledger.contains_key(&finding_key("Unbounded loop", "src/x.rs:88")));
4573 assert!(
4574 !ledger.contains_key(&finding_key("Missing guard", "src/y.rs:12")),
4575 "a point nobody fixed was recorded as fixed"
4576 );
4577 }
4578
4579 #[test]
4581 fn a_finding_the_fix_report_never_mentions_stays_open() {
4582 let blocking = vec![finding(
4583 "blocking",
4584 "Unbounded loop",
4585 "spins",
4586 "src/x.rs:88",
4587 true,
4588 )];
4589 let (claimed, left) = split_own_fixes(&blocking, &[]);
4590 assert!(claimed.is_empty());
4591 assert_eq!(1, left.len());
4592 }
4593
4594 #[test]
4598 fn a_fixed_point_is_not_in_the_settled_block() {
4599 let mut ledger = ledger_with("refuted point", "a.rs");
4600 ledger.extend(ledger_with("fixed point", "b.rs"));
4601 for entry in ledger.values_mut() {
4602 if entry.title == "fixed point" {
4603 entry.outcome = Settled::Fixed;
4604 }
4605 }
4606 let block = settled_block(&ledger);
4607 assert!(block.contains("refuted point"));
4608 assert!(!block.contains("fixed point"));
4609 }
4610
4611 #[test]
4614 fn a_ledger_of_only_fixes_says_nothing_is_settled() {
4615 let mut ledger = ledger_with("fixed point", "b.rs");
4616 for entry in ledger.values_mut() {
4617 entry.outcome = Settled::Fixed;
4618 }
4619 assert_eq!("", settled_block(&ledger));
4620 }
4621
4622 #[test]
4627 fn one_review_spends_one_re_raise_however_often_it_says_it() {
4628 let mut ledger = ledger_with("Missing error handling", "src/net.rs");
4629 let mut state = IssueRun::new(1, "t");
4630 let twice = vec![
4631 finding(
4632 "blocking",
4633 "Missing error handling",
4634 "d",
4635 "src/net.rs",
4636 true,
4637 ),
4638 finding(
4639 "blocking",
4640 "Missing error handling",
4641 "e",
4642 "src/net.rs",
4643 true,
4644 ),
4645 ];
4646
4647 assert!(!check_relitigation(&mut ledger, &twice, &mut state));
4648 assert_eq!(1, ledger.values().next().unwrap().reraised);
4649 assert!(check_relitigation(&mut ledger, &twice, &mut state));
4650 }
4651
4652 #[test]
4653 fn an_untracked_finding_does_not_escalate() {
4654 let mut state = IssueRun::new(1, "t");
4655 let blocking = vec![finding("blocking", "brand new", "d", "a.rs", true)];
4656 assert!(!check_relitigation(
4657 &mut Ledger::new(),
4658 &blocking,
4659 &mut state
4660 ));
4661 }
4662
4663 #[test]
4664 fn persisted_ledger_entries_are_rekeyed_for_stable_locations() {
4665 let mut ledger = Ledger::new();
4666 ledger.insert(
4667 "legacy-key".into(),
4668 LedgerEntry {
4669 title: "Unbounded loop".into(),
4670 file: "src/x.rs:88".into(),
4671 reasoning: "bounded by the caller".into(),
4672 round: 2,
4673 reraised: 1,
4674 outcome: Settled::Refuted,
4675 },
4676 );
4677 normalise_ledger_keys(&mut ledger);
4678 let key = matching_ledger_key(&ledger, "Unbounded loop", "src/x.rs:91").unwrap();
4679 assert_eq!(1, ledger[&key].reraised);
4680 }
4681
4682 #[test]
4683 fn same_title_at_two_sites_keeps_both_blockers() {
4684 let findings = vec![
4685 finding(
4686 "blocking",
4687 "Unchecked error",
4688 "first site",
4689 "src/net.rs:10",
4690 true,
4691 ),
4692 finding(
4693 "blocking",
4694 "Unchecked error",
4695 "second site",
4696 "src/net.rs:200",
4697 true,
4698 ),
4699 ];
4700
4701 let blocking = blocking_findings(&findings);
4702 assert_eq!(2, blocking.len());
4703 assert_eq!("src/net.rs:10", blocking[0].file);
4704 assert_eq!("src/net.rs:200", blocking[1].file);
4705 }
4706
4707 #[test]
4708 fn moved_location_fallback_refuses_an_ambiguous_ledger() {
4709 let mut ledger = ledger_with("Unchecked error", "src/net.rs:10");
4710 ledger.extend(ledger_with("Unchecked error", "src/net.rs:200"));
4711 assert!(matching_ledger_key(&ledger, "Unchecked error", "src/net.rs:30").is_none());
4712 }
4713
4714 #[test]
4715 fn two_current_sites_do_not_relocate_one_old_ledger_entry() {
4716 let mut ledger = ledger_with("Unchecked error", "src/net.rs:5");
4717 let blocking = vec![
4718 finding(
4719 "blocking",
4720 "Unchecked error",
4721 "first",
4722 "src/net.rs:10",
4723 true,
4724 ),
4725 finding(
4726 "blocking",
4727 "Unchecked error",
4728 "second",
4729 "src/net.rs:200",
4730 true,
4731 ),
4732 ];
4733 let mut state = IssueRun::new(1, "t");
4734 record_own_fixes(&blocking, &mut ledger, &mut state, 2);
4735 assert!(ledger.contains_key(&finding_key("Unchecked error", "src/net.rs:10")));
4736 assert!(ledger.contains_key(&finding_key("Unchecked error", "src/net.rs:200")));
4737 }
4738
4739 #[test]
4740 fn two_current_sites_remain_two_open_findings() {
4741 let current = vec![
4742 finding(
4743 "blocking",
4744 "Unchecked error",
4745 "first",
4746 "src/net.rs:10",
4747 true,
4748 ),
4749 finding(
4750 "blocking",
4751 "Unchecked error",
4752 "second",
4753 "src/net.rs:200",
4754 true,
4755 ),
4756 ];
4757 let mut open = Vec::new();
4758
4759 extend_findings(&mut open, ¤t);
4760
4761 assert_eq!(2, open.len());
4762 assert_eq!("src/net.rs:10", open[0].file);
4763 assert_eq!("src/net.rs:200", open[1].file);
4764 }
4765
4766 #[test]
4767 fn display_limits_do_not_change_persisted_finding_identity() {
4768 let point = finding(
4769 "blocking",
4770 "abcdefghij",
4771 "still wrong",
4772 "src/net.rs:10",
4773 true,
4774 );
4775 let mut ledger = Ledger::new();
4776 let mut state = IssueRun::new(1, "t");
4777 record_own_fixes(std::slice::from_ref(&point), &mut ledger, &mut state, 1);
4778 normalise_ledger_keys(&mut ledger);
4779
4780 let key = matching_ledger_key(&ledger, "abcdefghij", "src/net.rs:12").unwrap();
4781 assert_eq!("abcdefghij", ledger[&key].title);
4782 }
4783
4784 #[test]
4785 fn a_clipped_legacy_entry_keeps_its_original_lookup_key() {
4786 let mut ledger = Ledger::new();
4787 let key = crate::jsonx::finding_key("abcdefghij", "src/net.rs:10");
4788 ledger.insert(
4789 key.clone(),
4790 LedgerEntry {
4791 title: "abcde".into(),
4792 file: "src/net.rs:10".into(),
4793 reasoning: "bounded by the caller".into(),
4794 round: 1,
4795 reraised: 1,
4796 outcome: Settled::Refuted,
4797 },
4798 );
4799 normalise_ledger_keys(&mut ledger);
4800
4801 assert_eq!(
4802 Some(key),
4803 matching_ledger_key(&ledger, "abcdefghij", "src/net.rs:12")
4804 );
4805 }
4806
4807 #[test]
4808 fn a_legacy_key_collision_does_not_merge_case_distinct_paths() {
4809 let mut ledger = Ledger::new();
4810 let key = crate::jsonx::finding_key("Unchecked error", "src/Main.rs:10");
4811 ledger.insert(
4812 key.clone(),
4813 LedgerEntry {
4814 title: "Unchecked error".into(),
4815 file: "src/Main.rs:10".into(),
4816 reasoning: "bounded by the caller".into(),
4817 round: 1,
4818 reraised: 0,
4819 outcome: Settled::Refuted,
4820 },
4821 );
4822
4823 assert_eq!(
4824 Some(key),
4825 matching_ledger_key(&ledger, "Unchecked error", "src/Main.rs:10")
4826 );
4827 assert!(matching_ledger_key(&ledger, "Unchecked error", "src/main.rs:10").is_none());
4828 }
4829
4830 #[test]
4834 fn a_refutation_lands_on_the_key_the_next_round_will_look_up() {
4835 let blocking = [finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
4836 let recorded = finding_key(&blocking[0].title, &blocking[0].file);
4837 let answer = disposition("unbounded loop!", "src/x.rs", Action::Refuted);
4838 assert!(answer_matches(&blocking[0], &answer));
4839 assert_eq!(recorded, finding_key(&answer.title, &answer.file));
4840 }
4841
4842 #[test]
4845 fn the_ledger_key_ignores_title_punctuation() {
4846 let findings = [finding(
4847 "blocking",
4848 "Panic on multi-byte input",
4849 "d",
4850 "src/style.rs",
4851 true,
4852 )];
4853 let reworded = "Panic on multibyte input";
4854 let source = &findings[0];
4855 assert_eq!(
4856 finding_key(reworded, &source.file),
4857 finding_key(&source.title, &source.file)
4858 );
4859 let recorded = finding_key(&source.title, &source.file);
4860 let looked_up = finding_key(&findings[0].title, &findings[0].file);
4861 assert_eq!(recorded, looked_up);
4862 }
4863
4864 #[test]
4865 fn a_disposition_matches_its_finding_despite_wording_noise() {
4866 let findings = [finding(
4867 "blocking",
4868 "Unbounded loop!",
4869 "d",
4870 "src/x.rs",
4871 true,
4872 )];
4873 assert!(answer_matches(
4874 &findings[0],
4875 &disposition("unbounded loop", "src/x.rs", Action::Refuted)
4876 ));
4877 assert!(!answer_matches(
4878 &findings[0],
4879 &disposition("something else", "src/x.rs", Action::Refuted)
4880 ));
4881 assert!(!answer_matches(
4882 &findings[0],
4883 &disposition("unbounded loop", "src/y.rs", Action::Refuted)
4884 ));
4885 }
4886
4887 #[test]
4888 fn a_disposition_matches_its_finding_despite_a_severity_tag() {
4889 let blocker = finding(
4890 "blocking",
4891 "Refused requests can corrupt the active splice",
4892 "d",
4893 "src/lightning/channel/channel.ts",
4894 true,
4895 );
4896 let answers = [disposition(
4897 "[blocking] Refused requests can corrupt the active splice",
4898 "src/lightning/channel/channel.ts",
4899 Action::Fixed,
4900 )];
4901
4902 assert_eq!(
4903 0,
4904 matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers)
4905 .expect("tagged answer")
4906 .0
4907 );
4908 }
4909
4910 #[test]
4911 fn a_bracketed_subject_still_separates_two_findings() {
4912 let ios = finding("blocking", "[iOS] Startup crash", "d", "src/app.ts", true);
4913 let android = finding(
4914 "blocking",
4915 "[Android] Startup crash",
4916 "d",
4917 "src/app.ts",
4918 true,
4919 );
4920 let findings = [ios.clone(), android];
4921 let answers = [disposition(
4922 "[Android] Startup crash",
4923 "src/app.ts",
4924 Action::Fixed,
4925 )];
4926
4927 assert!(matches!(
4928 matching_disposition(&ios, &findings, &answers),
4929 Err("no matching disposition")
4930 ));
4931 }
4932
4933 #[test]
4939 fn an_answer_to_an_optional_finding_is_placed_by_the_optional_pass() {
4940 let blocker = finding("blocking", "Unbounded loop", "d", "src/x.rs", true);
4941 let optional = finding(
4942 "non-blocking",
4943 "Storage failures are reported as a successful state update",
4944 "d",
4945 "src/wallet/index.ts",
4946 true,
4947 );
4948 let answers = [
4949 disposition("Unbounded loop", "src/x.rs", Action::Fixed),
4950 disposition(
4951 "Storage failures are reported as a successful state update",
4952 "src/wallet/index.ts",
4953 Action::Refuted,
4954 ),
4955 ];
4956 let mut used = vec![false; answers.len()];
4957
4958 let blocking = [blocker];
4959 let (index, _) = matching_disposition(&blocking[0], &blocking, &answers).unwrap();
4960 used[index] = true;
4961
4962 let optionals = [optional];
4963 let (index, _) = matching_disposition(&optionals[0], &optionals, &answers).unwrap();
4964 assert!(!used[index], "the blocking pass must not have taken it");
4965 used[index] = true;
4966
4967 assert!(used.iter().all(|placed| *placed), "both were placed");
4968 }
4969
4970 #[test]
4973 fn an_answer_to_a_point_nobody_raised_stays_unplaced() {
4974 let blocking = [finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
4975 let answers = [
4976 disposition("Unbounded loop", "src/x.rs", Action::Fixed),
4977 disposition("Something nobody said", "src/z.rs", Action::Refuted),
4978 ];
4979 let mut used = vec![false; answers.len()];
4980
4981 let (index, _) = matching_disposition(&blocking[0], &blocking, &answers).unwrap();
4982 used[index] = true;
4983
4984 let optionals = optional_findings(&blocking);
4985 assert!(optionals.is_empty());
4986
4987 assert_eq!(vec![false], vec![used[1]], "the stray answer is unplaced");
4988 }
4989
4990 #[test]
4991 fn an_omitted_disposition_leaves_the_blocker_unmatched() {
4992 let blocker = finding("blocking", "Unbounded loop", "d", "src/x.rs", true);
4993 assert!(matches!(
4994 matching_disposition(
4995 &blocker,
4996 std::slice::from_ref(&blocker),
4997 &[] as &[Disposition]
4998 ),
4999 Err("no matching disposition")
5000 ));
5001 }
5002
5003 #[test]
5004 fn duplicate_dispositions_are_ambiguous() {
5005 let blocker = finding("blocking", "Unbounded loop", "d", "src/x.rs", true);
5006 let answers = vec![
5007 disposition("Unbounded loop", "src/x.rs", Action::Fixed),
5008 disposition("Unbounded loop", "src/x.rs", Action::Refuted),
5009 ];
5010 assert!(matches!(
5011 matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers),
5012 Err("more than one matching disposition")
5013 ));
5014 }
5015
5016 #[test]
5017 fn an_unambiguous_disposition_may_omit_the_line_number() {
5018 let blocker = finding(
5019 "blocking",
5020 "Replayed offers persist the wrong lane key",
5021 "d",
5022 "src/engine.rs:1166",
5023 true,
5024 );
5025 let answers = [disposition(
5026 "replayed offers persist the wrong lane key!",
5027 "src/engine.rs",
5028 Action::Fixed,
5029 )];
5030
5031 assert_eq!(
5032 0,
5033 matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers)
5034 .expect("stable answer")
5035 .0
5036 );
5037 }
5038
5039 #[test]
5040 fn a_line_free_disposition_does_not_choose_between_two_sites() {
5041 let first = finding("blocking", "Unchecked error", "d", "src/engine.rs:10", true);
5042 let second = finding(
5043 "blocking",
5044 "Unchecked error",
5045 "d",
5046 "src/engine.rs:200",
5047 true,
5048 );
5049 let findings = [first.clone(), second];
5050 let answers = [disposition(
5051 "Unchecked error",
5052 "src/engine.rs",
5053 Action::Fixed,
5054 )];
5055
5056 assert!(matches!(
5057 matching_disposition(&first, &findings, &answers),
5058 Err("no matching disposition")
5059 ));
5060 }
5061
5062 #[test]
5063 fn an_exact_location_wins_over_an_ambiguous_fallback() {
5064 let blocker = finding("blocking", "Unchecked error", "d", "src/engine.rs:10", true);
5065 let answers = [
5066 disposition("Unchecked error", "src/engine.rs", Action::Refuted),
5067 disposition("Unchecked error", "src/engine.rs:10", Action::Fixed),
5068 ];
5069
5070 let matched = matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers)
5071 .expect("exact answer");
5072 assert_eq!(1, matched.0);
5073 assert_eq!(Action::Fixed, matched.1.action);
5074 }
5075
5076 #[test]
5077 fn same_titled_findings_in_different_files_need_separate_dispositions() {
5078 let findings = [
5079 finding("blocking", "Unchecked error", "d", "src/a.rs", true),
5080 finding("blocking", "Unchecked error", "d", "src/b.rs", true),
5081 ];
5082 let answers = vec![
5083 disposition("Unchecked error", "src/a.rs", Action::Fixed),
5084 disposition("Unchecked error", "src/b.rs", Action::Refuted),
5085 ];
5086 assert_eq!(
5087 0,
5088 matching_disposition(&findings[0], &findings, &answers)
5089 .expect("left answer")
5090 .0
5091 );
5092 assert_eq!(
5093 1,
5094 matching_disposition(&findings[1], &findings, &answers)
5095 .expect("right answer")
5096 .0
5097 );
5098 }
5099
5100 #[test]
5101 fn a_reported_fix_without_a_commit_stays_open() {
5102 assert!(!fixed_disposition_resolves(false));
5103 assert!(fixed_disposition_resolves(true));
5104 }
5105
5106 #[test]
5107 fn the_settled_block_is_empty_when_nothing_is_settled() {
5108 assert_eq!("", settled_block(&Ledger::new()));
5109 }
5110
5111 #[test]
5112 fn the_settled_block_names_each_refutation() {
5113 let block = settled_block(&ledger_with("a point", "x.rs"));
5114 assert!(block.contains("a point"));
5115 assert!(block.contains("x.rs"));
5116 assert!(block.contains("settled"));
5117 }
5118
5119 #[test]
5120 fn same_title_settlements_name_each_location() {
5121 let mut ledger = ledger_with("Unchecked error", "a.rs:10");
5122 ledger.extend(ledger_with("Unchecked error", "b.rs:20"));
5123
5124 let block = settled_block(&ledger);
5125
5126 assert!(block.contains("Unchecked error (a.rs:10)"), "{block}");
5127 assert!(block.contains("Unchecked error (b.rs:20)"), "{block}");
5128 }
5129
5130 #[test]
5134 fn a_filed_point_is_settled_too() {
5135 let mut ledger = ledger_with("out of scope", "x.rs");
5136 for entry in ledger.values_mut() {
5137 entry.outcome = Settled::Filed;
5138 entry.reasoning = "Tracked in #9.".into();
5139 }
5140 let block = settled_block(&ledger);
5141 assert!(block.contains("out of scope"));
5142 assert!(block.contains("#9"));
5143 }
5144
5145 #[test]
5148 fn answering_a_point_again_keeps_its_re_raise_count() {
5149 let mut ledger = ledger_with("a point", "x.rs");
5150 let entry = ledger.values().next().unwrap().clone();
5151 let mut state = IssueRun::new(1, "t");
5152 let blocking = vec![finding("blocking", "a point", "d", "x.rs", true)];
5153
5154 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
5155 settle(&mut ledger, "a point", "x.rs", true, entry);
5156 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
5157 }
5158
5159 #[test]
5162 fn a_clean_review_is_just_the_verdict() {
5165 let text = review_comment("codex", 1, &review("Looks correct.", vec![]), &style());
5166 assert_eq!("Looks correct.", text);
5167 }
5168
5169 #[test]
5170 fn a_review_leads_with_the_counts() {
5171 let text = review_comment(
5172 "codex",
5173 2,
5174 &review(
5175 "One real problem.",
5176 vec![
5177 finding(
5178 "blocking",
5179 "Loop never terminates",
5180 "Confirmed by running it.",
5181 "src/a.rs",
5182 true,
5183 ),
5184 finding("non-blocking", "Name is vague", "d", "src/b.rs", true),
5185 finding("nit", "Log wording", "d", "", true),
5186 ],
5187 ),
5188 &style(),
5189 );
5190 assert!(text.starts_with("One real problem."), "{text}");
5191 assert!(!text.contains("codex"), "no agent name: {text}");
5192 assert!(!text.contains("round 2"), "no round number: {text}");
5193 }
5194
5195 #[test]
5198 fn only_blocking_findings_carry_their_detail() {
5199 let text = review_comment(
5200 "codex",
5201 1,
5202 &review(
5203 "s",
5204 vec![
5205 finding("blocking", "Loop", "BLOCKING DETAIL", "a.rs", true),
5206 finding("non-blocking", "Name", "NONBLOCKING DETAIL", "b.rs", true),
5207 ],
5208 ),
5209 &style(),
5210 );
5211 assert!(text.contains("BLOCKING DETAIL"), "{text}");
5212 assert!(!text.contains("NONBLOCKING DETAIL"), "{text}");
5213 }
5214
5215 #[test]
5216 fn a_thorough_explanation_reaches_the_author_intact() {
5219 let detail = "Reproduced by running the 429 test with max_attempts unset. ".repeat(8);
5220 let text = review_comment(
5221 "codex",
5222 1,
5223 &review(
5224 "One problem.",
5225 vec![finding("blocking", "T", &detail, "a.rs", true)],
5226 ),
5227 &style(),
5228 );
5229 assert!(
5230 text.contains(detail.trim()),
5231 "the explanation was cut:\n{text}"
5232 );
5233 }
5234
5235 #[test]
5237 fn a_runaway_model_is_still_bounded() {
5238 let long = "filler words. ".repeat(20_000);
5239 let text = review_comment(
5240 "codex",
5241 1,
5242 &review(&long, vec![finding("blocking", "T", &long, "a.rs", true)]),
5243 &style(),
5244 );
5245 assert!(
5246 text.len() < 30_000,
5247 "review comment was {} chars",
5248 text.len()
5249 );
5250 }
5251
5252 #[test]
5253 fn a_general_finding_has_no_empty_parenthesis() {
5254 let text = review_comment(
5255 "codex",
5256 1,
5257 &review("s", vec![finding("blocking", "Something", "d", "", true)]),
5258 &style(),
5259 );
5260 assert!(!text.contains("()"), "{text}");
5261 assert!(!text.contains("(general)"), "{text}");
5262 }
5263
5264 #[test]
5265 fn out_of_scope_findings_are_counted_separately() {
5266 let text = review_comment(
5267 "codex",
5268 1,
5269 &review(
5270 "s",
5271 vec![finding("blocking", "Old bug", "d", "a.rs", false)],
5272 ),
5273 &style(),
5274 );
5275 assert!(text.contains("out of scope"), "{text}");
5276 assert!(text.contains("Old bug"), "{text}");
5277 }
5278
5279 #[test]
5280 fn a_disposition_comment_leads_with_counts_and_keeps_refutations() {
5281 let response = ResponseDoc {
5282 summary: "Two of three were right.".into(),
5283 dispositions: vec![],
5284 };
5285 let text = disposition_comment(
5286 "claude",
5287 &response,
5288 &["Fixed thing".to_string()],
5289 &["Wrong thing. Because the caller already checks.".to_string()],
5290 &[],
5291 &style(),
5292 )
5293 .unwrap();
5294 assert!(text.starts_with("Two of three were right."), "{text}");
5295 assert!(!text.contains("claude"), "no agent name: {text}");
5296 assert!(
5297 text.contains("Because the caller already checks."),
5298 "{text}"
5299 );
5300 }
5301
5302 #[test]
5303 fn an_empty_disposition_comment_is_not_posted() {
5304 let response = ResponseDoc {
5305 summary: "s".into(),
5306 dispositions: vec![],
5307 };
5308 assert!(disposition_comment("claude", &response, &[], &[], &[], &style()).is_none());
5309 }
5310
5311 #[test]
5316 fn a_local_closing_commit_does_not_change_remote_custody() {
5317 for holder in ["a", "b"] {
5318 assert_eq!(holder, closing_next_actor(holder));
5319 }
5320 }
5321
5322 #[test]
5323 fn a_matching_head_keeps_saved_custody() {
5324 assert!(reconcile_saved_head(Some("abc123"), Some(2), true, "abc123", None, 42).unwrap());
5325 assert!(reconcile_saved_head(None, None, false, "abc123", None, 42).unwrap());
5326 }
5327
5328 #[test]
5329 fn a_changed_head_refuses_automatic_custody() {
5330 let error =
5331 reconcile_saved_head(Some("abc123"), Some(2), true, "def456", None, 42).unwrap_err();
5332 let text = error.to_string();
5333 assert!(text.contains("abc123"), "{text}");
5334 assert!(text.contains("def456"), "{text}");
5335 assert!(text.contains("--next <agent>"), "{text}");
5336 }
5337
5338 #[test]
5339 fn legacy_state_keeps_its_saved_custody_for_migration() {
5340 assert!(reconcile_saved_head(Some(""), Some(1), true, "def456", None, 42).unwrap());
5341 }
5342
5343 #[test]
5344 fn an_explicit_holder_resets_state_for_a_changed_or_legacy_head() {
5345 assert!(
5346 !reconcile_saved_head(Some("abc123"), Some(2), true, "def456", Some("b"), 42).unwrap()
5347 );
5348 assert!(!reconcile_saved_head(Some(""), Some(1), true, "def456", Some("b"), 42).unwrap());
5349 }
5350
5351 #[test]
5352 fn a_headless_current_or_future_state_is_not_legacy() {
5353 for version in [2, STATE_VERSION + 1] {
5354 assert!(
5355 reconcile_saved_head(Some(""), Some(version), true, "def456", None, 42).is_err()
5356 );
5357 }
5358 }
5359
5360 #[test]
5361 fn legacy_state_with_an_unknown_actor_refuses_to_guess() {
5362 assert!(reconcile_saved_head(Some(""), Some(1), false, "def456", None, 42).is_err());
5363 }
5364
5365 #[test]
5366 fn an_invalid_review_cannot_clear_a_carried_blocker() {
5367 let mut open = vec![finding(
5368 "blocking",
5369 "Unchecked error",
5370 "still fails",
5371 "src/a.rs:12",
5372 true,
5373 )];
5374 update_open_findings(&mut open, &[], false);
5375 assert_eq!(1, open.len());
5376
5377 update_open_findings(&mut open, &[], true);
5378 assert!(open.is_empty());
5379 }
5380
5381 #[test]
5382 fn a_final_round_with_no_commit_names_open_blockers() {
5383 let open = vec![finding(
5384 "blocking",
5385 "Unchecked error",
5386 "still fails",
5387 "src/a.rs",
5388 true,
5389 )];
5390 assert!(matches!(
5391 ending_without_landing(&open),
5392 Ending::Unresolved(points) if points.len() == 1
5393 ));
5394 assert!(matches!(ending_without_landing(&[]), Ending::Unchanged));
5395 }
5396
5397 #[test]
5401 fn a_closing_pass_uses_the_later_effort_tier() {
5402 use crate::config::{Call, EffortSchedule};
5403 let schedule = EffortSchedule {
5404 round_1: Some("ultra".into()),
5405 rest: Some("low".into()),
5406 ..EffortSchedule::default()
5407 };
5408 assert_eq!(None, schedule.get(Call::Close));
5409 let mut with_key = schedule.clone();
5410 with_key.close = Some("medium".into());
5411 assert_eq!(Some("medium".to_string()), with_key.get(Call::Close));
5412 }
5413
5414 #[test]
5415 fn a_ledger_with_no_claimed_fix_has_nothing_to_close_over() {
5416 assert!(!any_fixes(&ledger_with("refuted point", "a.rs"), 1));
5417 let mut fixed = ledger_with("fixed point", "b.rs");
5418 for entry in fixed.values_mut() {
5419 entry.outcome = Settled::Fixed;
5420 }
5421 assert!(any_fixes(&fixed, 1));
5422 }
5423
5424 #[test]
5427 fn the_closing_note_counts_points_rather_than_rounds() {
5428 assert_eq!("one point left after the closing pass", unresolved_note(1));
5429 assert_eq!("3 points left after the closing pass", unresolved_note(3));
5430 assert!(!unresolved_note(2).contains("round"));
5431 }
5432
5433 fn fixed_ledger() -> Ledger {
5434 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
5435 for entry in ledger.values_mut() {
5436 entry.outcome = Settled::Fixed;
5437 entry.reasoning = "bounded it on max_attempts".into();
5438 }
5439 ledger
5440 }
5441
5442 #[test]
5443 fn the_closing_prompt_names_every_fix_it_has_to_check() {
5444 let landed = vec!["abc1234 Bound the retry loop".to_string()];
5445 let prompt = close_prompt(
5446 "origin/main",
5447 42,
5448 "Retry a 429",
5449 "",
5450 "9f8e7d6",
5451 Some(&landed),
5452 &fixed_ledger(),
5453 &[],
5454 1,
5455 );
5456 assert!(prompt.contains("Unbounded loop"), "{prompt}");
5457 assert!(prompt.contains("bounded it on max_attempts"), "{prompt}");
5458 assert!(prompt.contains("abc1234 Bound the retry loop"), "{prompt}");
5459 assert!(prompt.contains("git diff 9f8e7d6..HEAD"), "{prompt}");
5460 assert!(prompt.contains("git diff origin/main...HEAD"), "{prompt}");
5461 assert!(!prompt.contains('{'), "{prompt}");
5462 }
5463
5464 #[test]
5467 fn a_close_with_nothing_landed_says_so_rather_than_leaving_a_hole() {
5468 let prompt = close_prompt(
5469 "origin/main",
5470 42,
5471 "Retry a 429",
5472 "",
5473 "9f8e7d6",
5474 Some(&[]),
5475 &Ledger::new(),
5476 &[],
5477 1,
5478 );
5479 assert!(
5480 prompt.contains("Nothing landed after the last round"),
5481 "{prompt}"
5482 );
5483 assert!(!prompt.contains('{'), "{prompt}");
5484 }
5485
5486 #[test]
5492 fn a_rewritten_branch_admits_it_cannot_say_what_landed() {
5493 let prompt = close_prompt(
5494 "origin/main",
5495 42,
5496 "Retry a 429",
5497 "",
5498 "9f8e7d6",
5499 None,
5500 &fixed_ledger(),
5501 &[],
5502 1,
5503 );
5504 assert!(prompt.contains("were rewritten"), "{prompt}");
5505 assert!(prompt.contains("git diff origin/main...HEAD"), "{prompt}");
5506 assert!(!prompt.contains("nobody has read it"), "{prompt}");
5507 assert!(!prompt.contains('{'), "{prompt}");
5508 }
5509
5510 #[test]
5513 fn the_closing_prompt_forbids_the_writing_the_loop_rolls_back() {
5514 let prompt = close_prompt(
5515 "origin/main",
5516 42,
5517 "t",
5518 "",
5519 "9f8e7d6",
5520 Some(&[]),
5521 &Ledger::new(),
5522 &[],
5523 1,
5524 );
5525 assert!(prompt.contains("do not commit"), "{prompt}");
5526 }
5527
5528 #[test]
5530 fn the_closing_prompt_keeps_confirmed_merge_blockers_blocking() {
5531 let prompt = close_prompt(
5532 "origin/main",
5533 42,
5534 "t",
5535 "",
5536 "9f8e7d6",
5537 Some(&[]),
5538 &Ledger::new(),
5539 &[],
5540 1,
5541 );
5542 assert!(
5543 prompt.contains("serious defect an\nearlier round missed"),
5544 "{prompt}"
5545 );
5546 assert!(prompt.contains("final merge-safety audit"), "{prompt}");
5547 assert!(!prompt.contains("not another\naudit"), "{prompt}");
5548 assert!(!prompt.contains("A\nfinding means"), "{prompt}");
5549 assert!(prompt.contains("A\nblocking finding means"), "{prompt}");
5550 let flat = prompt.split_whitespace().collect::<Vec<_>>().join(" ");
5551 assert!(flat.contains("does not become non-blocking"), "{prompt}");
5552 assert!(flat.contains("Only one of them ships"), "{prompt}");
5553 }
5554
5555 #[test]
5560 fn the_closing_pass_is_offered_a_severity_rather_than_the_field_that_gates() {
5561 let prompt = close_prompt(
5562 "origin/main",
5563 42,
5564 "t",
5565 "",
5566 "9f8e7d6",
5567 Some(&[]),
5568 &Ledger::new(),
5569 &[],
5570 1,
5571 );
5572 assert!(
5573 prompt.contains("Minor defects and improvements are\nnon-blocking"),
5574 "{prompt}"
5575 );
5576 assert!(
5577 prompt.contains("a real defect\nthis pull request did not cause"),
5578 "{prompt}"
5579 );
5580 }
5581
5582 #[test]
5585 fn an_out_of_scope_point_cannot_gate_the_close() {
5586 let out_of_scope = finding("blocking", "Adjacent leak", "d", "o.rs", false);
5587 assert!(!out_of_scope.blocks());
5588 assert!(approval_stands(&[], false));
5589 }
5590
5591 #[test]
5595 fn a_fix_is_shown_to_the_pass_that_has_to_check_it_and_not_after() {
5596 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
5597 for entry in ledger.values_mut() {
5598 entry.outcome = Settled::Fixed;
5599 entry.round = 2;
5600 }
5601 assert!(answers_block(&ledger, 3).contains("Unbounded loop"));
5603 assert_eq!("", answers_block(&ledger, 4));
5605 assert!(any_fixes(&ledger, 2));
5606 assert!(!any_fixes(&ledger, 3));
5607 }
5608
5609 #[test]
5614 fn the_answers_block_asks_the_reviewer_to_check_rather_than_to_trust() {
5615 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
5616 for entry in ledger.values_mut() {
5617 entry.outcome = Settled::Fixed;
5618 entry.reasoning = "bounded it on max_attempts".into();
5619 }
5620 let block = answers_block(&ledger, 2);
5621 assert!(block.contains("Unbounded loop"), "{block}");
5622 assert!(block.contains("src/x.rs"), "{block}");
5623 assert!(block.contains("bounded it on max_attempts"), "{block}");
5624 assert!(block.contains("Check the answer"), "{block}");
5625 assert!(!block.contains("settled"), "{block}");
5626 }
5627
5628 #[test]
5631 fn a_fix_and_a_refutation_do_not_share_a_heading() {
5632 let mut ledger = ledger_with("refuted point", "a.rs");
5633 ledger.extend(ledger_with("fixed point", "b.rs"));
5634 for entry in ledger.values_mut() {
5635 if entry.title == "fixed point" {
5636 entry.outcome = Settled::Fixed;
5637 }
5638 }
5639 let answers = answers_block(&ledger, 2);
5640 let settled = settled_block(&ledger);
5641 assert!(answers.contains("fixed point") && !answers.contains("refuted point"));
5642 assert!(settled.contains("refuted point") && !settled.contains("fixed point"));
5643 }
5644
5645 #[test]
5646 fn an_empty_ledger_adds_no_answers_block() {
5647 assert_eq!("", answers_block(&Ledger::new(), 2));
5648 }
5649
5650 #[test]
5653 fn the_last_round_that_can_ask_for_anything_says_so() {
5654 assert_eq!("", round_note(1, 3));
5655 assert_eq!("", round_note(2, 3));
5656 assert!(round_note(3, 3).contains("last round"));
5657 assert!(round_note(6, 6).contains("last round"));
5660 }
5661
5662 #[test]
5668 fn saying_when_the_asking_stops_says_nothing_about_severity() {
5669 let note = round_note(3, 3);
5670 for word in ["approve", "blocking", "severity", "nit"] {
5671 assert!(!note.contains(word), "{word} in: {note}");
5672 }
5673 }
5674
5675 #[test]
5676 fn the_review_prompt_leaves_nothing_unsubstituted() {
5677 let empty = review_prompt(
5681 "origin/main",
5682 42,
5683 "Retry a 429",
5684 "",
5685 &Ledger::new(),
5686 &[],
5687 1,
5688 3,
5689 );
5690 assert!(!empty.contains('{'), "{empty}");
5691 assert!(
5692 empty.contains("`origin/main`"),
5693 "the reviewer was told to read a different ref than spar compares:\n{empty}"
5694 );
5695 assert!(empty.contains("#42") && empty.contains("Retry a 429"));
5696
5697 let mut ledger = ledger_with("refuted point", "a.rs");
5698 ledger.extend(ledger_with("fixed point", "b.rs"));
5699 for entry in ledger.values_mut() {
5700 if entry.title == "fixed point" {
5701 entry.outcome = Settled::Fixed;
5702 entry.round = 2;
5703 }
5704 }
5705 let full = review_prompt("origin/main", 42, "Retry a 429", "", &ledger, &[], 3, 3);
5706 assert!(!full.contains('{'), "{full}");
5707 assert!(full.contains("fixed point") && full.contains("refuted point"));
5708 assert!(full.contains("last round"), "{full}");
5709 }
5710
5711 #[test]
5720 fn every_pass_that_judges_the_branch_is_given_the_issue_and_the_description() {
5721 let context = context_block(
5722 42,
5723 "https://github.com/example/project/issues/42",
5724 "Honour Retry-After and cap at five attempts.",
5725 "## What changed\n\nBounded the loop. Ran the 429 test.",
5726 );
5727 assert!(context.contains("Honour Retry-After"), "{context}");
5728 assert!(context.contains("issues/42"), "{context}");
5729 assert!(
5730 context.contains("followed by any comments on it since"),
5731 "{context}"
5732 );
5733 assert!(context.contains("the later one wins"), "{context}");
5734 assert!(context.contains("Ran the 429 test"), "{context}");
5735 assert!(
5736 context.contains("claim to check"),
5737 "the description has to be labelled as the author's own account:\n{context}"
5738 );
5739
5740 let review = review_prompt(
5741 "origin/main",
5742 42,
5743 "Retry on 429",
5744 &context,
5745 &Ledger::new(),
5746 &[],
5747 1,
5748 3,
5749 );
5750 let close = close_prompt(
5751 "origin/main",
5752 42,
5753 "Retry on 429",
5754 &context,
5755 "9f8e7d6",
5756 Some(&[]),
5757 &Ledger::new(),
5758 &[],
5759 1,
5760 );
5761 let respond = RESPOND_PROMPT
5762 .replace("{number}", "42")
5763 .replace("{context}", &context)
5764 .replace("{findings}", "(none)")
5765 .replace("{optional}", "");
5766 for prompt in [review, close, respond] {
5767 assert!(prompt.contains("Honour Retry-After"), "{prompt}");
5768 assert!(prompt.contains("Ran the 429 test"), "{prompt}");
5769 assert!(!prompt.contains('{'), "{prompt}");
5770 }
5771 }
5772
5773 #[test]
5780 fn the_author_is_offered_the_non_blocking_findings_it_never_saw() {
5781 let findings = vec![
5782 finding(
5783 "blocking",
5784 "Retry loop spins",
5785 "confirmed",
5786 "src/net.rs:88",
5787 true,
5788 ),
5789 finding(
5790 "non-blocking",
5791 "Timeout is hard coded",
5792 "thirty seconds, in one place",
5793 "src/net.rs:44",
5794 true,
5795 ),
5796 finding(
5797 "nit",
5798 "Log line says retrying",
5799 "wording",
5800 "src/net.rs:102",
5801 true,
5802 ),
5803 finding(
5804 "non-blocking",
5805 "Out of scope thing",
5806 "predates the branch",
5807 "src/cache.rs",
5808 false,
5809 ),
5810 ];
5811
5812 let optional = optional_findings(&findings);
5813 assert_eq!(1, optional.len(), "{optional:?}");
5814 assert_eq!("Timeout is hard coded", optional[0].title);
5815
5816 let prompt = RESPOND_PROMPT
5817 .replace("{number}", "42")
5818 .replace("{context}", "")
5819 .replace("{findings}", &findings_for_prompt(&[findings[0].clone()]))
5820 .replace("{optional}", &optional_block(&findings));
5821 assert!(prompt.contains("Timeout is hard coded"), "{prompt}");
5822 assert!(prompt.contains("will not hold the merge"), "{prompt}");
5823 assert!(
5824 !prompt.contains("Log line says retrying"),
5825 "a nit is dropped, not offered:\n{prompt}"
5826 );
5827 assert!(
5828 !prompt.contains("Out of scope thing"),
5829 "an out of scope point is filed, not offered:\n{prompt}"
5830 );
5831 assert!(!prompt.contains('{'), "{prompt}");
5832
5833 let none = optional_block(&[findings[0].clone()]);
5835 assert_eq!("", none);
5836 }
5837
5838 #[test]
5840 fn the_review_prompt_says_what_happens_to_each_severity() {
5841 assert!(
5842 REVIEW_PROMPT.contains("offered to the author"),
5843 "{REVIEW_PROMPT}"
5844 );
5845 assert!(REVIEW_PROMPT.contains("nit: dropped"), "{REVIEW_PROMPT}");
5846 }
5847
5848 #[test]
5851 fn a_pull_request_with_no_issue_behind_it_carries_no_empty_headings() {
5852 assert_eq!("", context_block(7, "", "", ""));
5853 let only_description = context_block(7, "", "", "What it does.");
5854 assert!(!only_description.contains("as filed"), "{only_description}");
5855 assert!(only_description.contains("What it does."));
5856 }
5857
5858 #[test]
5859 fn a_resumed_open_finding_reaches_review_and_closing_prompts() {
5860 let open = vec![finding(
5861 "blocking",
5862 "Retry bypasses the limit",
5863 "reproduced with max_attempts set to one",
5864 "src/net.rs:88",
5865 true,
5866 )];
5867 let review = review_prompt(
5868 "origin/main",
5869 42,
5870 "Retry a 429",
5871 "",
5872 &Ledger::new(),
5873 &open,
5874 2,
5875 3,
5876 );
5877 let close = close_prompt(
5878 "origin/main",
5879 42,
5880 "Retry a 429",
5881 "",
5882 "9f8e7d6",
5883 Some(&[]),
5884 &Ledger::new(),
5885 &open,
5886 2,
5887 );
5888 for prompt in [review, close] {
5889 assert!(prompt.contains("Retry bypasses the limit"), "{prompt}");
5890 assert!(prompt.contains("src/net.rs:88"), "{prompt}");
5891 assert!(
5892 prompt.contains("reproduced with max_attempts set to one"),
5893 "{prompt}"
5894 );
5895 assert!(!prompt.contains('{'), "{prompt}");
5896 }
5897 }
5898
5899 #[test]
5903 fn a_minor_defect_has_a_severity_that_is_not_blocking() {
5904 assert!(
5905 REVIEW_PROMPT.contains("A minor defect belongs\n here as much as an improvement does")
5906 );
5907 for text in [
5912 schema::review().to_string(),
5913 crate::review_only::review_only_prompt().to_string(),
5914 ] {
5915 assert!(text.contains("as much as an improvement does"), "{text}");
5916 assert!(!text.contains("a genuine improvement"), "{text}");
5917 }
5918 }
5919
5920 #[test]
5923 fn doubt_resolves_away_from_the_field_that_gates() {
5924 for text in [REVIEW_PROMPT.to_string(), schema::review().to_string()] {
5925 assert!(text.contains("say your piece in the finding and label it non-blocking"));
5926 assert!(!text.contains("leave in_scope true"));
5927 }
5928 }
5929
5930 #[test]
5933 fn both_edit_prompts_ask_for_the_smallest_change_that_answers_the_point() {
5934 for prompt in [FIX_PROMPT, RESPOND_PROMPT] {
5935 assert!(
5936 prompt.contains("The smallest change that answers it is"),
5937 "{prompt}"
5938 );
5939 }
5940 assert!(RESPOND_PROMPT.contains("bigger than the\n problem it names"));
5941 }
5942
5943 #[test]
5944 fn a_fixed_disposition_must_explain_what_changed() {
5945 let flat = RESPOND_PROMPT
5946 .split_whitespace()
5947 .collect::<Vec<_>>()
5948 .join(" ");
5949 assert!(
5950 flat.contains("For fixed, say what changed and how it answers the point"),
5951 "{RESPOND_PROMPT}"
5952 );
5953 }
5954
5955 #[test]
5956 fn an_empty_fix_reason_still_renders_as_a_claim_to_check() {
5957 let mut ledger = ledger_with("Unchecked error", "src/net.rs");
5958 for entry in ledger.values_mut() {
5959 entry.outcome = Settled::Fixed;
5960 entry.reasoning.clear();
5961 }
5962
5963 let lines = fixed_lines(&ledger, 0);
5964
5965 assert_eq!(1, lines.len());
5966 assert!(lines[0].contains("a committed change claims to address this point"));
5967 assert!(!lines[0].contains("The author said"));
5968 }
5969
5970 #[test]
5975 fn the_implementor_is_given_the_link_and_the_body() {
5976 let prompt = implement_prompt(
5977 42,
5978 "Retry a 429",
5979 "https://github.com/o/r/issues/42",
5980 "A rate limited response was treated as fatal.",
5981 );
5982 assert!(
5983 prompt.contains("https://github.com/o/r/issues/42"),
5984 "{prompt}"
5985 );
5986 assert!(
5987 prompt.contains("A rate limited response was treated as fatal."),
5988 "{prompt}"
5989 );
5990 assert!(prompt.contains("#42"), "{prompt}");
5991 assert!(prompt.contains("Retry a 429"), "{prompt}");
5992 assert!(!prompt.contains('{'), "{prompt}");
5994 }
5995
5996 #[test]
6002 fn the_prompt_says_a_later_comment_can_change_what_was_asked() {
6003 let prompt = implement_prompt(1, "t", "u", "b");
6004 let lower = prompt
6006 .split_whitespace()
6007 .collect::<Vec<_>>()
6008 .join(" ")
6009 .to_lowercase();
6010 assert!(lower.contains("followed by any comments"), "{prompt}");
6011 assert!(lower.contains("the later one wins"), "{prompt}");
6012 assert!(lower.contains("a comment from spar"), "{prompt}");
6013 assert!(!prompt.contains('{'), "{prompt}");
6014 }
6015
6016 fn worked() -> Implementation {
6018 Implementation {
6019 summary: "Retry a 429 instead of failing the run.".into(),
6020 problem: "A rate limited response was treated as fatal, so one throttled call ended \
6021 a run that had hours of work left in it."
6022 .into(),
6023 changes: vec![
6024 "`send` retries a 429 with the delay the header asks for".into(),
6025 "the retry budget is bounded, so a permanent 429 still ends".into(),
6026 ],
6027 testing: vec![
6028 "`cargo test retries_a_429`".into(),
6029 "point it at a throttled endpoint and watch it finish".into(),
6030 ],
6031 ..Implementation::default()
6032 }
6033 }
6034
6035 #[test]
6036 fn a_pr_body_is_what_it_closes_and_what_changed() {
6039 let body = pr_body(42, &worked(), None, &style());
6040 assert_eq!(
6041 "Closes #42\n\n\
6042 Retry a 429 instead of failing the run.\n\n\
6043 A rate limited response was treated as fatal, so one throttled call \
6044 ended a run that had hours of work left in it.\n\n\
6045 ## What changed\n\n\
6046 - `send` retries a 429 with the delay the header asks for\n\
6047 - the retry budget is bounded, so a permanent 429 still ends\n\n\
6048 ## How to test\n\n\
6049 - `cargo test retries_a_429`\n\
6050 - point it at a throttled endpoint and watch it finish",
6051 body
6052 );
6053 }
6054
6055 #[test]
6058 fn a_body_with_nothing_to_list_carries_no_empty_headings() {
6059 let work = Implementation {
6060 summary: "Retry a 429 instead of failing the run.".into(),
6061 ..Implementation::default()
6062 };
6063 assert_eq!(
6064 "Closes #42\n\nRetry a 429 instead of failing the run.",
6065 pr_body(42, &work, None, &style())
6066 );
6067 }
6068
6069 #[test]
6070 fn a_pr_body_survives_an_implementor_that_said_nothing() {
6071 assert_eq!(
6072 "Closes #7",
6073 pr_body(7, &Implementation::default(), None, &style())
6074 );
6075 }
6076
6077 #[test]
6080 fn blank_list_entries_do_not_earn_a_heading() {
6081 let work = Implementation {
6082 summary: "Did a thing.".into(),
6083 changes: vec![String::new(), " ".into()],
6084 ..Implementation::default()
6085 };
6086 let body = pr_body(42, &work, None, &style());
6087 assert!(!body.contains("What changed"), "{body}");
6088 }
6089
6090 #[test]
6091 fn notes_appear_only_when_there_is_something_to_note() {
6092 let mut work = worked();
6093 assert!(!pr_body(42, &work, None, &style()).contains("## Notes"));
6094 work.notes = Some("The retry is not applied to streaming calls.".into());
6095 let body = pr_body(42, &work, None, &style());
6096 assert!(body.contains("## Notes"), "{body}");
6097 assert!(body.contains("streaming calls"), "{body}");
6098 }
6099
6100 #[test]
6103 fn declining_posts_the_reason_and_not_the_summary() {
6104 let work = Implementation {
6105 not_worth_doing: true,
6106 reason: "Already fixed in 1.2, and the report predates it.".into(),
6107 summary: "Nothing to do.".into(),
6108 ..Implementation::default()
6109 };
6110 assert_eq!(
6111 "Already fixed in 1.2, and the report predates it.",
6112 no_pr_note(&work, &style())
6113 );
6114 }
6115
6116 #[test]
6117 fn reporting_work_and_committing_none_says_that_rather_than_the_summary() {
6118 let work = Implementation {
6119 summary: "Retry a 429 instead of failing the run.".into(),
6120 ..Implementation::default()
6121 };
6122 let note = no_pr_note(&work, &style());
6123 assert_eq!(
6124 "Nothing was committed, so there is nothing to review.",
6125 note
6126 );
6127 }
6128
6129 #[test]
6130 fn declining_without_a_reason_still_says_something() {
6131 let work = Implementation {
6132 not_worth_doing: true,
6133 ..Implementation::default()
6134 };
6135 assert!(no_pr_note(&work, &style()).contains("no reason given"));
6136 }
6137
6138 #[test]
6139 fn a_skip_comment_is_only_the_reasoning() {
6140 let item = SkippedItem {
6141 issue: 3,
6142 title: "t".into(),
6143 tracker: false,
6144 reasons: [
6145 ("claude".to_string(), "Already fixed in 1.2.".to_string()),
6146 ("codex".to_string(), "Duplicate of #2.".to_string()),
6147 ]
6148 .into_iter()
6149 .collect(),
6150 };
6151 let text = skip_comment(&item, &style());
6152 assert!(text.contains("Already fixed in 1.2."), "{text}");
6153 assert!(text.contains("Duplicate of #2."), "{text}");
6154 assert!(
6155 !text.contains("claude") && !text.contains("codex"),
6156 "{text}"
6157 );
6158 assert!(!text.to_lowercase().contains("not scheduled"), "{text}");
6159 assert!(text.lines().count() <= 3, "{text}");
6160 }
6161
6162 #[test]
6163 fn findings_for_a_model_keep_full_detail() {
6164 let long = "x".repeat(2000);
6165 let text = findings_for_prompt(&[finding("blocking", "T", &long, "a.rs", true)]);
6166 assert!(
6167 text.contains(&long),
6168 "a model needs the whole finding, only humans need brevity"
6169 );
6170 }
6171
6172 #[test]
6173 fn findings_for_a_model_are_never_empty() {
6174 assert_eq!("(none)", findings_for_prompt(&[]));
6175 }
6176}
6177
6178#[cfg(test)]
6179mod outcome_tests {
6180 use super::*;
6181 use crate::model::{Dispute, Severity};
6182
6183 fn style() -> Style {
6184 Style::default()
6185 }
6186
6187 fn state_with(disputes: Vec<(&str, &str)>, filed: Vec<&str>) -> IssueRun {
6188 let mut s = IssueRun::new(482, "t");
6189 s.disputes = disputes
6190 .into_iter()
6191 .map(|(title, reasoning)| Dispute {
6192 title: title.into(),
6193 file: String::new(),
6194 reasoning: reasoning.into(),
6195 })
6196 .collect();
6197 s.filed = filed.into_iter().map(String::from).collect();
6198 s
6199 }
6200
6201 fn finding(title: &str, file: &str) -> Finding {
6202 Finding {
6203 severity: Severity::Blocking,
6204 title: title.into(),
6205 detail: "d".into(),
6206 file: file.into(),
6207 in_scope: true,
6208 ..Default::default()
6209 }
6210 }
6211
6212 fn graded(severity: Severity, title: &str, file: &str, in_scope: bool) -> Finding {
6213 Finding {
6214 severity,
6215 in_scope,
6216 ..finding(title, file)
6217 }
6218 }
6219
6220 #[test]
6221 fn outcome_mode_routes_final_results_to_the_configured_sink() {
6222 assert_eq!(OutcomeSink::PullRequest, outcome_sink(PrComments::Outcome));
6223 assert_eq!(OutcomeSink::PullRequest, outcome_sink(PrComments::Rounds));
6224 assert_eq!(OutcomeSink::Terminal, outcome_sink(PrComments::None));
6225 }
6226
6227 #[test]
6230 fn a_clean_approval_says_nothing() {
6231 let state = state_with(vec![], vec![]);
6232 assert!(outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).is_none());
6233 }
6234
6235 #[test]
6240 fn a_downgraded_finding_still_reaches_the_pull_request() {
6241 let mut state = state_with(vec![], vec![]);
6242 let kept = graded(
6243 Severity::NonBlocking,
6244 "Timeout is not configurable",
6245 "n.rs",
6246 true,
6247 );
6248 record_nonblocking_outcome(&mut state, &kept, None);
6249
6250 assert_eq!(1, state.noted.len());
6253
6254 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
6255 assert!(text.contains("Noted, not blocking"), "{text}");
6256 assert!(
6257 text.contains("Timeout is not configurable (n.rs)"),
6258 "{text}"
6259 );
6260 }
6261
6262 #[test]
6265 fn a_point_that_was_filed_is_not_also_noted() {
6266 let mut state = state_with(vec![], vec![]);
6267 let finding = graded(Severity::NonBlocking, "Timeout", "n.rs", true);
6268 record_nonblocking_outcome(&mut state, &finding, None);
6269 record_nonblocking_outcome(
6270 &mut state,
6271 &finding,
6272 Some(&Followup::Recorded("https://example.invalid/9".into())),
6273 );
6274 assert!(state.noted.is_empty());
6275 assert_eq!(vec!["https://example.invalid/9"], state.filed);
6276 }
6277
6278 #[test]
6279 fn filing_a_moved_point_removes_its_earlier_note() {
6280 let mut state = state_with(vec![], vec![]);
6281 let earlier = graded(Severity::NonBlocking, "Timeout", "src/net.rs:10", true);
6282 let moved = graded(Severity::NonBlocking, "Timeout", "src/net.rs:12", false);
6283 record_nonblocking_outcome(&mut state, &earlier, None);
6284
6285 record_nonblocking_outcome(
6286 &mut state,
6287 &moved,
6288 Some(&Followup::Recorded("https://example.invalid/10".into())),
6289 );
6290
6291 assert!(state.noted.is_empty());
6292 assert_eq!(vec!["https://example.invalid/10"], state.filed);
6293 }
6294
6295 #[test]
6296 fn an_unrecorded_nonblocking_followup_remains_noted() {
6297 let finding = graded(Severity::NonBlocking, "Timeout", "n.rs", true);
6298 for outcome in [
6299 Followup::Covered("https://example.invalid/closed".into()),
6300 Followup::Dropped("follow-ups are off"),
6301 Followup::Failed,
6302 ] {
6303 let mut state = state_with(vec![], vec![]);
6304 record_nonblocking_outcome(&mut state, &finding, Some(&outcome));
6305 assert_eq!(1, state.noted.len(), "{outcome:?}");
6306 assert!(state.filed.is_empty(), "{outcome:?}");
6307 }
6308 }
6309
6310 #[test]
6312 fn a_point_noted_twice_is_listed_once() {
6313 let mut state = state_with(vec![], vec![]);
6314 let raised = graded(
6315 Severity::NonBlocking,
6316 "Timeout is not configurable",
6317 "n.rs",
6318 true,
6319 );
6320 let reworded = graded(
6321 Severity::NonBlocking,
6322 "timeout is not configurable!",
6323 "n.rs",
6324 true,
6325 );
6326 record_nonblocking_outcome(&mut state, &raised, None);
6327 record_nonblocking_outcome(&mut state, &reworded, None);
6328 assert_eq!(1, state.noted.len());
6329 }
6330
6331 #[test]
6332 fn same_title_notes_in_different_files_are_both_kept() {
6333 let mut state = state_with(vec![], vec![]);
6334 for file in ["src/a.rs", "src/b.rs"] {
6335 let finding = graded(Severity::NonBlocking, "Unchecked error", file, true);
6336 record_nonblocking_outcome(&mut state, &finding, None);
6337 }
6338 assert_eq!(2, state.noted.len());
6339 }
6340
6341 #[test]
6342 fn same_title_notes_at_two_sites_in_one_review_are_both_kept() {
6343 let findings = vec![
6344 graded(
6345 Severity::NonBlocking,
6346 "Unchecked error",
6347 "src/a.rs:10",
6348 true,
6349 ),
6350 graded(
6351 Severity::NonBlocking,
6352 "Unchecked error",
6353 "src/a.rs:200",
6354 true,
6355 ),
6356 ];
6357 let mut state = state_with(vec![], vec![]);
6358
6359 for finding in &findings {
6360 record_nonblocking_outcome_with_match(
6361 &mut state,
6362 finding,
6363 None,
6364 unique_stable_finding(&findings, finding),
6365 );
6366 }
6367
6368 assert_eq!(2, state.noted.len());
6369 }
6370
6371 #[test]
6372 fn settling_one_of_two_same_title_notes_keeps_the_other() {
6373 let first = graded(
6374 Severity::NonBlocking,
6375 "Unchecked error",
6376 "src/a.rs:10",
6377 true,
6378 );
6379 let second = graded(
6380 Severity::NonBlocking,
6381 "Unchecked error",
6382 "src/a.rs:200",
6383 true,
6384 );
6385 let mut state = state_with(vec![], vec![]);
6386 remember_noted(&mut state, &first, false);
6387 remember_noted(&mut state, &second, false);
6388
6389 forget_noted(&mut state, &first, false);
6390
6391 assert_eq!(1, state.noted.len());
6392 assert_eq!("src/a.rs:200", state.noted[0].file);
6393 }
6394
6395 #[test]
6396 fn same_title_disputes_at_two_sites_are_both_kept() {
6397 let mut state = state_with(vec![], vec![]);
6398 for file in ["src/a.rs:10", "src/a.rs:200"] {
6399 remember_dispute(
6400 &mut state,
6401 Dispute {
6402 title: "Unchecked error".into(),
6403 file: file.into(),
6404 reasoning: "the caller handles it".into(),
6405 },
6406 false,
6407 );
6408 }
6409
6410 assert_eq!(2, state.disputes.len());
6411 }
6412
6413 #[test]
6414 fn a_later_nonblocking_verdict_replaces_a_prior_dispute() {
6415 let mut state = state_with(vec![], vec![]);
6416 let finding = graded(Severity::NonBlocking, "Unchecked error", "src/a.rs", true);
6417 remember_dispute(
6418 &mut state,
6419 Dispute {
6420 title: finding.title.clone(),
6421 file: finding.file.clone(),
6422 reasoning: "the caller handles it".into(),
6423 },
6424 true,
6425 );
6426
6427 record_nonblocking_outcome(&mut state, &finding, None);
6428
6429 assert!(state.disputes.is_empty());
6430 assert_eq!(1, state.noted.len());
6431 }
6432
6433 #[test]
6434 fn a_note_moving_lines_in_the_same_file_is_updated() {
6435 let mut state = state_with(vec![], vec![]);
6436 let first = graded(
6437 Severity::NonBlocking,
6438 "Unchecked error",
6439 "src/a.rs:12",
6440 true,
6441 );
6442 let moved = graded(
6443 Severity::NonBlocking,
6444 "Unchecked error",
6445 "src/a.rs:19",
6446 true,
6447 );
6448 record_nonblocking_outcome(&mut state, &first, None);
6449 record_nonblocking_outcome(&mut state, &moved, None);
6450 assert_eq!(1, state.noted.len());
6451 assert_eq!("src/a.rs:19", state.noted[0].file);
6452 }
6453
6454 #[test]
6455 fn a_settled_point_removes_its_stale_note() {
6456 for outcome in [Settled::Fixed, Settled::Refuted, Settled::Filed] {
6457 let noted = graded(Severity::NonBlocking, "Unchecked error", "src/a.rs", true);
6458 let mut state = state_with(vec![], vec![]);
6459 record_nonblocking_outcome(&mut state, ¬ed, None);
6460 forget_noted(&mut state, ¬ed, true);
6461 assert!(state.noted.is_empty(), "{outcome}");
6462 }
6463 }
6464
6465 #[test]
6466 fn settling_a_moved_point_removes_its_old_dispute() {
6467 let old = graded(Severity::Blocking, "Unchecked error", "src/a.rs:10", true);
6468 let moved = graded(Severity::Blocking, "Unchecked error", "src/a.rs:12", true);
6469 let mut state = state_with(vec![], vec![]);
6470 remember_dispute(
6471 &mut state,
6472 Dispute {
6473 title: old.title,
6474 file: old.file,
6475 reasoning: "the caller handles it".into(),
6476 },
6477 true,
6478 );
6479
6480 forget_dispute(&mut state, &moved, true);
6481
6482 assert!(state.disputes.is_empty());
6483 }
6484
6485 #[test]
6488 fn a_deadlocked_point_is_not_also_noted() {
6489 let mut state = state_with(vec![], vec![]);
6490 let noted = graded(Severity::NonBlocking, "Unbounded loop", "x.rs", true);
6491 record_nonblocking_outcome(&mut state, ¬ed, None);
6492 let points = vec![finding("Unbounded loop", "x.rs")];
6493 let text = outcome_comment(
6494 &state,
6495 &Ledger::new(),
6496 &Ending::Deadlocked(&points),
6497 &style(),
6498 )
6499 .unwrap();
6500 assert!(!text.contains("Noted, not blocking"), "{text}");
6501 }
6502
6503 #[test]
6504 fn clipping_a_rendered_title_does_not_break_duplicate_suppression() {
6505 let mut compact = style();
6506 compact.max_title_chars = 5;
6507 let mut state = state_with(
6508 vec![("abcdefghij", "the caller already handles it")],
6509 vec![],
6510 );
6511 state
6512 .noted
6513 .push(graded(Severity::NonBlocking, "abcdefghij", "x.rs", true));
6514 state.disputes[0].file = "x.rs".into();
6515 let points = vec![finding("abcdefghij", "x.rs")];
6516
6517 let text = outcome_comment(
6518 &state,
6519 &Ledger::new(),
6520 &Ending::Unresolved(&points),
6521 &compact,
6522 )
6523 .unwrap();
6524
6525 assert!(!text.contains("Raised and refuted"), "{text}");
6526 assert!(!text.contains("Noted, not blocking"), "{text}");
6527 }
6528
6529 #[test]
6530 fn an_approval_that_filed_follow_ups_links_them() {
6531 let state = state_with(
6532 vec![],
6533 vec![
6534 "https://github.com/you/thing/issues/485",
6535 "https://github.com/you/thing/issues/486",
6536 ],
6537 );
6538 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
6539 assert!(text.contains("Filed separately: #485, #486"), "{text}");
6540 }
6541
6542 #[test]
6546 fn a_run_that_changed_nothing_does_not_claim_there_is_something_to_read() {
6547 let state = state_with(vec![], vec![]);
6548 let text = outcome_comment(&state, &Ledger::new(), &Ending::Unchanged, &style()).unwrap();
6549 assert!(text.contains("changed nothing"), "{text}");
6550 assert!(!text.contains("was pushed"), "{text}");
6551 }
6552
6553 #[test]
6557 fn an_unresolved_close_names_what_is_still_wrong() {
6558 let state = state_with(vec![], vec![]);
6559 let left = vec![Finding {
6560 detail: "The guard sits after the early return.".into(),
6561 ..finding("The retry fix never reaches the 429 path", "src/net.rs:88")
6562 }];
6563 let text =
6564 outcome_comment(&state, &Ledger::new(), &Ending::Unresolved(&left), &style()).unwrap();
6565 assert!(text.contains("These points are still open"), "{text}");
6566 assert!(
6567 text.contains("The retry fix never reaches the 429 path (src/net.rs:88)"),
6568 "{text}"
6569 );
6570 assert!(
6571 text.contains("The guard sits after the early return."),
6572 "{text}"
6573 );
6574 assert!(!text.contains("has not been reviewed"), "{text}");
6577 }
6578
6579 #[test]
6583 fn running_out_of_rounds_says_what_that_means_for_the_reader() {
6584 let state = state_with(vec![], vec![]);
6585 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
6586 assert!(text.contains("has not been reviewed"), "{text}");
6587 assert!(
6588 !text.to_lowercase().contains("round 3"),
6589 "no round numbers: {text}"
6590 );
6591 assert!(!text.to_lowercase().contains("convergence"), "{text}");
6592 }
6593
6594 #[test]
6595 fn a_failed_close_reports_unread_fixes_and_carried_blockers() {
6596 let state = state_with(vec![], vec![]);
6597 let open = vec![Finding {
6598 detail: "the failure is still discarded".into(),
6599 ..finding("Unchecked error", "src/net.rs:88")
6600 }];
6601 let text = outcome_comment_with_unread(
6602 &state,
6603 &Ledger::new(),
6604 &Ending::OutOfRounds,
6605 &open,
6606 &style(),
6607 )
6608 .unwrap();
6609 assert!(text.contains("has not been reviewed"), "{text}");
6610 assert!(text.contains("These points were already open"), "{text}");
6611 assert!(text.contains("Unchecked error (src/net.rs:88)"), "{text}");
6612 }
6613
6614 #[test]
6615 fn a_deadlock_names_the_point_they_could_not_settle() {
6616 let state = state_with(vec![], vec![]);
6617 let points = [finding("Retry loop never terminates", "src/net.rs:88")];
6618 let text = outcome_comment(
6619 &state,
6620 &Ledger::new(),
6621 &Ending::Deadlocked(&points),
6622 &style(),
6623 )
6624 .unwrap();
6625 assert!(
6626 text.contains("Retry loop never terminates (src/net.rs:88)"),
6627 "{text}"
6628 );
6629 assert!(text.contains("could not settle"), "{text}");
6630 }
6631
6632 #[test]
6634 fn refutations_survive_because_nothing_else_carries_them() {
6635 let state = state_with(
6636 vec![(
6637 "Error is swallowed",
6638 "the caller already validates the file",
6639 )],
6640 vec![],
6641 );
6642 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
6643 assert!(text.contains("Raised and refuted:"), "{text}");
6644 assert!(
6645 text.contains("The caller already validates the file"),
6646 "{text}"
6647 );
6648 }
6649
6650 #[test]
6651 fn no_agent_names_counts_or_round_numbers_reach_the_thread() {
6652 let state = state_with(
6653 vec![("A point", "a reason")],
6654 vec!["https://github.com/you/thing/issues/485"],
6655 );
6656 let left = vec![finding("A point", "a.rs")];
6657 for ending in [
6658 Ending::Approved,
6659 Ending::OutOfRounds,
6660 Ending::Unresolved(&left),
6661 ] {
6662 let text = outcome_comment(&state, &Ledger::new(), &ending, &style()).unwrap();
6663 let lower = text.to_lowercase();
6664 for banned in ["claude", "codex", "blocking,", "nit,", " fixed."] {
6665 assert!(
6666 !lower.contains(banned),
6667 "{banned:?} leaked into the thread:\n{text}"
6668 );
6669 }
6670 for n in 1..9 {
6672 assert!(
6673 !lower.contains(&format!("round {n}")),
6674 "a round number leaked into the thread:\n{text}"
6675 );
6676 }
6677 }
6678 }
6679
6680 #[test]
6681 fn a_refutation_is_allowed_to_make_its_case() {
6684 let reasoning = "The caller validates against the schema first. \
6685 The discarded error is therefore unreachable in practice. ";
6686 let state = state_with(
6687 vec![("A point", &reasoning.repeat(6))],
6688 vec!["https://github.com/you/thing/issues/485"],
6689 );
6690 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
6691 assert!(
6692 !text.contains("..."),
6693 "nothing was cut mid thought:\n{text}"
6694 );
6695 assert!(text.len() < 4000, "{} chars", text.len());
6696 }
6697
6698 #[test]
6699 fn a_url_that_is_not_an_issue_link_is_left_alone() {
6700 assert_eq!(
6701 "#485",
6702 as_reference("https://github.com/you/thing/issues/485")
6703 );
6704 assert_eq!("note: something", as_reference("note: something"));
6705 }
6706}
6707
6708#[cfg(test)]
6709mod filed_reference_tests {
6710 use super::*;
6711
6712 #[test]
6713 fn an_issue_url_yields_its_number() {
6714 assert_eq!(
6715 Some(485),
6716 filed_issue_number("https://github.com/you/thing/issues/485")
6717 );
6718 }
6719
6720 #[test]
6723 fn a_local_note_yields_nothing() {
6724 assert_eq!(None, filed_issue_number("note: Retry is unbounded"));
6725 assert_eq!(None, filed_issue_number(""));
6726 assert_eq!(
6727 None,
6728 filed_issue_number("https://github.com/you/thing/issues/")
6729 );
6730 }
6731}
6732
6733#[cfg(test)]
6734mod followup_restraint_tests {
6735 use super::*;
6736 use crate::model::Severity;
6737
6738 fn cfg_with(followups: Followups, non_blocking: bool, nits: bool, cap: usize) -> Config {
6739 let mut cfg =
6740 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
6741 .unwrap();
6742 cfg.loop_cfg.followups = followups;
6743 cfg.loop_cfg.file_non_blocking = non_blocking;
6744 cfg.loop_cfg.file_nits = nits;
6745 cfg.loop_cfg.max_followups = cap;
6746 cfg
6747 }
6748
6749 fn finding(severity: Severity, title: &str, in_scope: bool) -> Finding {
6750 Finding {
6751 severity,
6752 title: title.into(),
6753 detail: "d".into(),
6754 file: "a.rs".into(),
6755 in_scope,
6756 ..Default::default()
6757 }
6758 }
6759
6760 #[test]
6764 fn a_non_blocking_finding_is_not_a_tracker_item_by_default() {
6765 let cfg = cfg_with(Followups::Issues, false, false, 5);
6766 assert!(!cfg.loop_cfg.file_non_blocking);
6767 assert!(!cfg.loop_cfg.file_nits);
6768 }
6769
6770 #[test]
6771 fn follow_ups_stay_off_the_tracker_by_default() {
6772 let cfg =
6773 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
6774 .unwrap();
6775 assert_eq!(
6776 Followups::Local,
6777 cfg.loop_cfg.followups,
6778 "the tracker is somebody's queue; the default must not write to it"
6779 );
6780 assert_eq!(5, cfg.loop_cfg.max_followups);
6781 }
6782
6783 #[test]
6785 fn only_out_of_scope_defects_qualify_at_the_defaults() {
6786 let cfg = cfg_with(Followups::Issues, false, false, 5);
6787 let qualifies = |f: &Finding| match f.severity {
6788 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking && f.in_scope,
6789 Severity::Nit => cfg.loop_cfg.file_nits && f.in_scope,
6790 Severity::Blocking => false,
6791 } || !f.in_scope;
6792
6793 assert!(qualifies(&finding(
6794 Severity::Blocking,
6795 "pre-existing",
6796 false
6797 )));
6798 assert!(!qualifies(&finding(
6799 Severity::NonBlocking,
6800 "improvement",
6801 true
6802 )));
6803 assert!(!qualifies(&finding(Severity::Nit, "taste", true)));
6804 assert!(!qualifies(&finding(
6805 Severity::Blocking,
6806 "fix it here",
6807 true
6808 )));
6809 }
6810
6811 #[test]
6812 fn opening_it_up_lets_non_blocking_findings_through_again() {
6813 let cfg = cfg_with(Followups::Issues, true, false, 5);
6814 assert!(cfg.loop_cfg.file_non_blocking);
6815 }
6816
6817 #[test]
6819 fn the_cap_is_a_real_backstop() {
6820 let cfg = cfg_with(Followups::Issues, false, false, 3);
6821 let mut state = IssueRun::new(1, "t");
6822 state.filed = (0..3).map(|n| format!("url{n}")).collect();
6823 assert!(state.filed.len() >= cfg.loop_cfg.max_followups);
6824 }
6825
6826 #[test]
6830 fn the_cap_bounds_what_one_run_can_spawn() {
6831 let cfg = cfg_with(Followups::Issues, false, false, 5);
6832 assert!(
6833 cfg.loop_cfg.max_followups <= 5,
6834 "a run that can file ten follow-ups is a branching process"
6835 );
6836 }
6837}
6838
6839#[cfg(test)]
6843mod followup_outcome_tests {
6844 use super::*;
6845
6846 const URL: &str = "https://github.com/you/thing/issues/485";
6847
6848 fn entry(recorded: Followup) -> Option<(Settled, String)> {
6849 filed_entry(&recorded, "It predates this branch.")
6850 }
6851
6852 #[test]
6856 fn a_failed_followup_settles_nothing() {
6857 assert_eq!(None, entry(Followup::Failed));
6858 }
6859
6860 #[test]
6861 fn an_uncertain_external_write_blocks_later_issue_followups_for_this_run() {
6862 let mut state = IssueRun::new(1, "review");
6863 let uncertain = SparError::uncertain_write("the result could not be verified");
6864 assert_eq!(Followup::Failed, failed_followup(&mut state, &uncertain));
6865 assert!(external_followup_write_paused(Followups::Issues, &state));
6866 assert!(!external_followup_write_paused(Followups::Local, &state));
6867 assert_eq!(1, state.notes.len());
6868
6869 assert_eq!(Followup::Failed, failed_followup(&mut state, &uncertain));
6870 assert_eq!(1, state.notes.len(), "the recovery note was duplicated");
6871
6872 let mut ordinary = IssueRun::new(2, "review");
6873 let error = SparError::new("permission denied");
6874 assert_eq!(Followup::Failed, failed_followup(&mut ordinary, &error));
6875 assert!(!external_followup_write_paused(
6876 Followups::Issues,
6877 &ordinary
6878 ));
6879 }
6880
6881 #[test]
6882 fn a_recorded_followup_is_filed_and_says_where() {
6883 let (outcome, reasoning) = entry(Followup::Recorded(URL.into())).unwrap();
6884 assert_eq!(Settled::Filed, outcome);
6885 assert!(
6886 reasoning.contains("It predates this branch."),
6887 "{reasoning}"
6888 );
6889 assert!(reasoning.contains("#485"), "{reasoning}");
6890 }
6891
6892 #[test]
6901 fn a_finding_that_matches_a_closed_issue_is_filed_again_with_the_reference() {
6902 let body = regression_body("The retry loop spins on 429.", 485);
6903 assert!(body.contains("The retry loop spins on 429."), "{body}");
6904 assert!(body.contains("#485"), "{body}");
6905 assert!(body.contains("closed"), "{body}");
6906
6907 let recorded = Followup::from(Filed::Opened(9, URL.into()));
6909 assert_eq!(Followup::Recorded(URL.into()), recorded);
6910 assert_eq!(Some(URL), recorded.url());
6911 }
6912
6913 #[test]
6916 fn an_open_issue_that_already_covers_the_point_is_still_a_reference() {
6917 for filed in [
6918 Filed::Opened(9, URL.into()),
6919 Filed::AddedTo(9, URL.into()),
6920 Filed::Covered(9, URL.into()),
6921 ] {
6922 assert_eq!(Some(URL), Followup::from(filed).url());
6923 }
6924 }
6925
6926 #[test]
6930 fn a_dropped_followup_is_settled_but_never_reported_as_filed() {
6931 let (outcome, reasoning) = entry(Followup::Dropped("follow-ups are off")).unwrap();
6932 assert_eq!(Settled::Dropped, outcome);
6933 assert!(reasoning.contains("follow-ups are off"), "{reasoning}");
6934 assert!(reasoning.contains("Not filed"), "{reasoning}");
6935 }
6936
6937 fn ledger_of(outcome: Settled, reasoning: &str) -> Ledger {
6938 let mut ledger = Ledger::new();
6939 ledger.insert(
6940 finding_key("A pre-existing leak", "src/x.rs"),
6941 LedgerEntry {
6942 title: "A pre-existing leak".into(),
6943 file: "src/x.rs".into(),
6944 reasoning: reasoning.into(),
6945 round: 1,
6946 reraised: 0,
6947 outcome,
6948 },
6949 );
6950 ledger
6951 }
6952
6953 #[test]
6957 fn the_settled_block_tells_a_filed_point_from_a_dropped_one() {
6958 let filed = settled_block(&ledger_of(Settled::Filed, "Tracked in #9."));
6959 assert!(filed.contains("out of scope here, and filed"), "{filed}");
6960
6961 let dropped = settled_block(&ledger_of(Settled::Dropped, "Not filed anywhere: off."));
6962 assert!(
6963 dropped.contains("out of scope here, and not filed"),
6964 "{dropped}"
6965 );
6966 assert!(dropped.contains("A pre-existing leak"), "{dropped}");
6967 }
6968
6969 #[test]
6972 fn a_deadlocked_point_that_was_never_filed_does_not_claim_to_be() {
6973 let points = [Finding {
6974 severity: Severity::Blocking,
6975 title: "A pre-existing leak".into(),
6976 detail: "d".into(),
6977 file: "src/x.rs".into(),
6978 in_scope: false,
6979 ..Default::default()
6980 }];
6981 let text = outcome_comment(
6982 &IssueRun::new(1, "t"),
6983 &ledger_of(Settled::Dropped, "Not filed anywhere: follow-ups are off."),
6984 &Ending::Deadlocked(&points),
6985 &Style::default(),
6986 )
6987 .unwrap();
6988 assert!(text.contains("not filed"), "{text}");
6989 assert!(!text.contains("Filed as out of scope"), "{text}");
6990 }
6991}
6992
6993#[cfg(test)]
6994mod issue_report_tests {
6995 use super::*;
6996 use crate::model::Severity;
6997
6998 fn reported() -> Finding {
7001 Finding {
7002 severity: Severity::Blocking,
7003 title: "sendPaymentAsync bypasses drain mode and spending limits".into(),
7004 detail: "The async path skips every admission check payInvoice applies.".into(),
7005 file: "src/node.ts:412".into(),
7006 in_scope: false,
7007 problem: Some(
7008 "`BeignetNode.sendPaymentAsync()` submits a payment directly to the Lightning \
7009 engine without applying the safeguards used by `payInvoice()`.\n\nThe async path \
7010 does not:\n\n- call `_checkDraining()`\n- call `_checkSpendLimit()`"
7011 .into(),
7012 ),
7013 reproduction: Some(
7014 "1. Create a `BeignetNode` with `dailySpendLimitSats: 1`.\n2. Enable drain mode.\n\
7015 3. Submit a 1,000 sat invoice.\n\nActual result:\n\n- The engine is called.\n\
7016 - `spentSats` remains 0."
7017 .into(),
7018 ),
7019 impact: Some(
7020 "An authorized client can submit async payments up to the available outbound \
7021 liquidity despite the configured limits."
7022 .into(),
7023 ),
7024 expected: Some(
7025 "- Reject new payments while draining.\n- Enforce the per-payment limit before \
7026 submission.\n- Cover both paths with regression tests.\n\nThis predates the \
7027 current branch."
7028 .into(),
7029 ),
7030 }
7031 }
7032
7033 #[test]
7034 fn a_reported_finding_becomes_a_bug_report() {
7035 let body = issue_report(&reported());
7036 for heading in [
7037 "## Problem",
7038 "## Reproduction",
7039 "## Impact",
7040 "## Expected behavior",
7041 ] {
7042 assert!(body.contains(heading), "missing {heading}:\n{body}");
7043 }
7044 let at = |h: &str| body.find(h).unwrap();
7046 assert!(at("## Problem") < at("## Reproduction"));
7047 assert!(at("## Reproduction") < at("## Impact"));
7048 assert!(at("## Impact") < at("## Expected behavior"));
7049 }
7050
7051 #[test]
7052 fn the_substance_survives_the_outbound_gates() {
7053 let repo_style = Style::default();
7054 let body = crate::style::issue_body(&issue_report(&reported()), &repo_style);
7055 for kept in [
7056 "_checkDraining()",
7057 "Actual result:",
7058 "outbound liquidity",
7059 "regression tests",
7060 "predates the current branch",
7061 ] {
7062 assert!(body.contains(kept), "the gate ate {kept:?}:\n{body}");
7063 }
7064 assert!(!body.contains("..."), "something was cut:\n{body}");
7065 }
7066
7067 #[test]
7070 fn an_ordinary_finding_is_still_just_its_detail() {
7071 let plain = Finding {
7072 severity: Severity::NonBlocking,
7073 title: "Name is vague".into(),
7074 detail: "The variable could say what it holds.".into(),
7075 file: "a.rs".into(),
7076 in_scope: true,
7077 ..Default::default()
7078 };
7079 assert_eq!(
7080 "The variable could say what it holds.",
7081 issue_report(&plain)
7082 );
7083 }
7084
7085 #[test]
7088 fn only_the_sections_that_were_written_appear() {
7089 let partial = Finding {
7090 problem: Some("The guard is inverted.".into()),
7091 expected: Some("It should reject rather than accept.".into()),
7092 ..reported()
7093 };
7094 let partial = Finding {
7095 reproduction: None,
7096 impact: None,
7097 ..partial
7098 };
7099 let body = issue_report(&partial);
7100 assert!(body.contains("## Problem") && body.contains("## Expected behavior"));
7101 assert!(!body.contains("## Reproduction"), "{body}");
7102 assert!(!body.contains("## Impact"), "{body}");
7103 }
7104
7105 #[test]
7108 fn the_summary_line_is_not_printed_twice() {
7109 let echoed = Finding {
7110 detail: "The guard is inverted so it rejects valid input.".into(),
7111 problem: Some("The guard is inverted so it rejects valid input.".into()),
7112 reproduction: None,
7113 impact: None,
7114 expected: None,
7115 ..reported()
7116 };
7117 let body = issue_report(&echoed);
7118 assert_eq!(1, body.matches("The guard is inverted").count(), "{body}");
7119 }
7120}