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, CriterionStatus, Disposition, Dispute, Finding, Followup, Implementation, Issue,
30 IssueRun, Ledger, LedgerEntry, Mark, NextAction, OwnFix, OwnFixDoc, PersistedState, PlanItem,
31 PrView, ResponseDoc, Review, Ruled, Settled, Severity, SkippedItem, Status, Verdict,
32 STATE_VERSION,
33};
34use crate::repo::{PushTarget, Repo};
35use crate::style::{self, Style};
36use crate::template::fill;
37use crate::tracker::{self, Item};
38use crate::{bail, log, logdim, logwarn, schema, spar_err};
39
40const IMPLEMENT_PROMPT: &str = "\
45Implement GitHub issue #{number} in this repository.
46
47Title: {title}
48URL: {url}
49
50{body}
51
52That is the issue as filed, followed by any comments on it since, oldest first.
53{thread_rule}{criteria}
54
55Do the work and run the relevant tests. Leave the changes uncommitted. Do not
56commit, push, open a PR, or merge; the harness validates and commits the working
57tree after your report is accepted.
58
59Then report it. Your answer becomes the pull request description, and the
60reviewer reads that cold, with nothing but the diff and a link to the issue:
61say what you found wrong, what the change does about it, and how they confirm
62it for themselves. Say what you actually ran, not what could be run.
63
64If after reading the code you conclude this issue should not be implemented,
65make no changes and set not_worth_doing, with the reason.";
66
67const REVIEW_PROMPT: &str = "\
68Review the changes on this branch against `{base}`. They implement issue
69#{number}: {title}
70{context}{criteria}
71Review thoroughly: correctness, edge cases, error handling, security, and
72whether the change actually resolves the issue. Read surrounding code, do not
73only read the diff.
74
75Label every finding by severity, and be honest about which is which:
76- blocking: the PR should not merge as is. Real defects only.
77- non-blocking: real, and smaller than another round. A minor defect belongs
78 here as much as an improvement does.
79- nit: style or taste.
80
81Blocking is the only severity that costs a round, and a round is another commit
82somebody has to read before this can merge. Being right that something is wrong
83is not enough to block. It has to be wrong in a way that would cost somebody.
84
85Confirm anything you label blocking before you label it. Run the code,
86reproduce the failure, or point at the exact line that breaks, and say in the
87detail what you did to confirm it. When you need to run something to check a
88claim, write a scratch file under the system temporary directory and run that,
89rather than passing a long program on the command line: it is easier to read
90back, easier to rerun, and less likely to be refused by a sandbox or a safety
91filter part way through your work. An unverified blocking finding is worse than
92one you never raised: it stalls a good PR and teaches the author to stop
93believing you. If you suspect a problem but could not confirm it, say so and
94label it non-blocking.
95
96Set in_scope=false for a real defect that exists, that this PR did not cause, and
97that is worth somebody stopping to fix. Each one becomes a tracked item a
98maintainer has to read and triage, so the bar is a defect and not an observation.
99A thorough reviewer can always find something adjacent to what it is reading;
100that is not a reason to file it. If you are not sure it is worth a maintainer's
101time, say your piece in the finding and label it non-blocking.
102
103Reviewing one issue should not manufacture ten more. If you find yourself with
104several out of scope findings, keep the ones that would bite somebody and drop
105the rest.
106
107What each label means for what happens next, so you can choose it knowing:
108- blocking: the author answers it this round, or you fix it yourself, and the
109 pull request does not merge until it is settled.
110- non-blocking: offered to the author in the same round as an optional fix, and
111 reported for a person either way. Nothing holds the merge for it.
112- nit: dropped. Nobody acts on it.
113
114Then choose next_action:
115- merge: no blocking findings, the PR is good.
116- fix_myself: there are blocking findings and you will fix them directly.
117- hand_back: there are blocking findings the author should address.
118
119Choose fix_myself for a mechanical fix nobody would argue about: the missing
120guard, the wrong comparison, the case the code plainly meant to handle. Choose
121hand_back when the author might reasonably disagree, or when the fix needs
122context you do not have. Fixing it yourself costs the author its chance to
123refute the point, and a refutation is a legitimate outcome here: an author that
124accepts every review comment to get approved produces worse code, not better.
125{open}{answers}{settled}{round}";
126
127const CLOSE_PROMPT: &str = "\
128This closes the review of issue #{number}: {title}
129{context}{criteria}
130This is the final merge-safety audit. Read the full branch against `{base}` and
131answer one question: does the branch still contain anything that must not
132merge. Do not spend this pass on optional improvements or style.
133{landed}{open}{answers}{settled}
134Something blocks here when the branch still contains a confirmed defect that
135means it should not merge. That includes an open point above that the code does
136not answer, a defect in what landed since the last round, or a serious defect an
137earlier round missed. Go and look before you raise it. Run the test that covers
138it, or read the relevant lines and follow them to the caller, and say in the
139detail what you did.
140
141Keep this pass focused on merge safety. Minor defects and improvements are
142non-blocking. Keep in_scope=false for what it has always meant, a real defect
143this pull request did not cause, which the harness handles according to the
144configured follow-up policy. A confirmed in-scope defect does not become
145non-blocking merely because an earlier round missed it.
146
147Nothing you raise here will be fixed, because there is no round after this. A
148blocking finding means the pull request stays open and the finding is reported
149for a person to weigh. Non-blocking findings are reported without holding it
150open. No blocking findings means the branch is signed off on your word, so do
151not omit one because the list was long. Both mistakes cost somebody. Only one
152of them ships.
153
154Set next_action to merge when you raise nothing blocking, and hand_back when you
155do. Nothing acts on it here, and the findings are what decide.
156
157This call reads and nothing else. Do not edit the code, do not commit, and do
158not push. A pass that writes has judged a branch the rollback then takes away,
159so anything you leave behind is rolled back and the sign off does not stand.
160Put any scratch file under the system temporary directory, not in the working
161tree.";
162
163const FIX_PROMPT: &str = "\
164You reviewed this branch and chose to fix the blocking findings yourself.
165Implement those fixes now. Leave the changes uncommitted so the harness can
166validate and commit them.
167
168Your findings:
169{findings}
170
171Fix what the point says and nothing else. The smallest change that answers it is
172the right one: no refactor alongside it, no capability nobody asked for, no
173handling for cases nobody raised. Every line you add is what the next pass
174reviews, so a fix that grows the branch buys another round of findings about the
175fix. If a point cannot be answered without a change bigger than the point, set
176fixed=false for it and say why, rather than making the change.
177
178Report on every point above: fixed=true only where a change in the working tree
179answers it, and fixed=false with the reason where you left it. A point you
180report as left stays open and goes to the author, with your reason, so write
181that reason for them. Copy each title and file across exactly as given, so your
182answer can be matched back to the finding. The summary becomes the commit
183subject, so write one sentence naming what changed.
184
185Do not commit, push, or merge.";
186
187const RESPOND_PROMPT: &str = "\
188Here is a review of your PR for issue #{number}.
189{context}
190{findings}
191
192For each point, choose exactly one disposition:
193- fixed: the point is valid and in scope. Fix it and leave the change
194 uncommitted for the harness.
195- refuted: the point is wrong, or the change it asks for is bigger than the
196 problem it names. Explain why. Refuting is a legitimate outcome; do not accept
197 a review comment you believe is incorrect just to get the PR approved.
198- filed_issue: the point is valid but unrelated to this PR. Supply
199 new_issue_title and new_issue_body; the harness files it and skips duplicates.
200
201Copy each finding's title and file across exactly as given, so your answer can
202be matched back to the review. Give a reason for every disposition. For fixed,
203say what changed and how it answers the point. For refuted, say why the point
204does not stand. For filed_issue, say why it belongs outside this pull request.
205
206Fix what the point says and nothing else. The smallest change that answers it is
207the right one: no refactor alongside it, no capability nobody asked for, no
208handling for cases nobody raised. Every line you add is what the next pass
209reviews, so a fix that grows the branch buys another round of findings about the
210fix. If a point cannot be answered without a change bigger than the point, say so
211rather than making the change.
212{optional}
213Leave any fixes uncommitted. Do not commit, push, or merge.";
214
215const OPTIONAL_HEADER: &str = "\
222These are not blocking and will not hold the merge. You are making a commit for
223the points above anyway, so fix the ones that are a line or two, and decline the
224rest with a reason. Declining costs nothing: what you decline is reported for a
225person to weigh, exactly as it would have been if you had not been asked.
226
227Optional:
228";
229
230const CHECK_PROMPT: &str = "\
231The branch does not pass. spar ran `{command}` in this worktree after your
232change and it failed. This is the harness running it, not a claim by anybody.
233
234{output}
235
236Fix what it reports and leave the changes uncommitted, as before. If the failure
237is not caused by your change and the branch was already failing, say so in the
238summary and change nothing: the run stops either way, and saying so is worth
239more than a guess at somebody else's bug.
240
241Do not commit, push, or merge.";
242
243const CHECK_TIMEOUT_SECS: u64 = 1800;
249
250pub struct Checked {
252 pub command: String,
253 pub output: String,
254}
255
256fn run_check(cfg: &Config, work_dir: &Path) -> Option<std::result::Result<String, Checked>> {
258 let argv = cfg.loop_cfg.check.argv();
259 if argv.is_empty() {
260 return None;
261 }
262 let command = cfg.loop_cfg.check.describe();
263 logdim!("running the check: {command}");
264 let out = crate::proc::exec(
265 &argv,
266 &crate::proc::ExecOpts::new()
267 .cwd(work_dir)
268 .check(false)
269 .timeout_secs(CHECK_TIMEOUT_SECS)
270 .stop_descendants(true),
271 );
272 Some(match out {
273 Ok(out) if out.ok() => Ok(command),
274 Ok(out) => {
275 let text = format!("{}\n{}", out.stdout.trim_end(), out.stderr.trim_end());
276 Err(Checked {
277 command,
278 output: style::clip(text.trim(), 4000),
279 })
280 }
281 Err(e) => Err(Checked {
284 command,
285 output: format!("the check could not be run: {e}"),
286 }),
287 })
288}
289
290#[allow(clippy::too_many_arguments)]
298fn check_before_push(
299 cfg: &Config,
300 repo: &Repo,
301 agent: &Agent,
302 work_dir: &Path,
303 label: &str,
304 call: crate::config::Call,
305) -> Result<Option<String>> {
306 let Some(first) = run_check(cfg, work_dir) else {
307 return Ok(None);
308 };
309 let failed = match first {
310 Ok(command) => {
311 log!("{label}: `{command}` passed");
312 return Ok(Some(command));
313 }
314 Err(failed) => failed,
315 };
316 logwarn!(
317 "{label}: `{}` failed, handing it back to {} once. Nothing is pushed until it passes.",
318 failed.command,
319 agent.name()
320 );
321
322 let prompt = fill(
323 CHECK_PROMPT,
324 &[("{command}", &failed.command), ("{output}", &failed.output)],
325 );
326 let baseline = repo.worktree_baseline(work_dir)?;
327 let before = repo.head_oid_checked(work_dir)?;
328 let answer = agent.edit(&prompt, work_dir, &agent.effort(cfg, call));
329 match answer {
330 Ok(summary) => {
331 commit_accepted_changes(
332 cfg,
333 repo,
334 work_dir,
335 &baseline,
336 &summary,
337 "Make the branch pass its check",
338 )?;
339 }
340 Err(error) => {
341 let error = defer_clean_edit_error(repo, work_dir, &baseline, error)?;
342 bail!(
343 "{label}: `{}` failed and the fix call failed too, so nothing was pushed.\n{}\n\n{error}",
344 failed.command,
345 failed.output
346 );
347 }
348 }
349 let moved = repo.head_oid_checked(work_dir)? != before;
350 match run_check(cfg, work_dir) {
351 Some(Ok(command)) => {
352 log!("{label}: `{command}` passed after one fix");
353 Ok(Some(command))
354 }
355 Some(Err(again)) => {
356 bail!(
357 "{label}: `{}` still fails after one fix{}, so nothing was pushed and the worktree \
358 was kept.\n{}",
359 again.command,
360 if moved { "" } else { " and nothing was committed" },
361 again.output
362 )
363 }
364 None => Ok(None),
365 }
366}
367
368#[derive(Debug, Clone, PartialEq, Eq)]
378pub struct Snapshot {
379 pub head: String,
380 pub dirty: bool,
382}
383
384impl Snapshot {
385 pub fn landed_over(&self, before: &Snapshot) -> bool {
388 !self.head.is_empty() && self.head != before.head
389 }
390}
391
392pub fn snapshot(repo: &Repo, work_dir: &Path) -> Snapshot {
393 Snapshot {
394 head: repo
395 .git_try_at(Some(work_dir), &["rev-parse", "HEAD"])
396 .trim()
397 .to_string(),
398 dirty: !repo
399 .git_try_at(
400 Some(work_dir),
401 &["status", "--porcelain", "--untracked-files=no"],
402 )
403 .trim()
404 .is_empty(),
405 }
406}
407
408fn checked_head(repo: &Repo, work_dir: &Path) -> Result<String> {
409 let head = repo.git_at(Some(work_dir), &["rev-parse", "HEAD"])?;
410 let head = head.trim().to_string();
411 if head.is_empty() {
412 return Err(spar_err!("could not read the pull request head"));
413 }
414 Ok(head)
415}
416
417fn reconcile_saved_head(
426 recorded_head: Option<&str>,
427 state_version: Option<u32>,
428 saved_actor_known: bool,
429 actual_head: &str,
430 holder_override: Option<&str>,
431 pr_number: i64,
432) -> Result<bool> {
433 let Some(recorded_head) = recorded_head else {
434 return Ok(true);
435 };
436 if !recorded_head.is_empty() && recorded_head == actual_head {
437 return Ok(true);
438 }
439 if holder_override.is_some() {
440 return Ok(false);
441 }
442 if recorded_head.is_empty() && state_version == Some(1) && saved_actor_known {
443 return Ok(true);
444 }
445 let recorded = if recorded_head.is_empty() {
446 format!(
447 "state version {} without a recorded head",
448 state_version
449 .map(|version| version.to_string())
450 .unwrap_or_else(|| "unknown".to_string())
451 )
452 } else {
453 format!("head {recorded_head}")
454 };
455 Err(spar_err!(
456 "saved review state applies to {recorded}, but PR #{pr_number} is at {actual_head}; \
457 resume with --next <agent> to choose who reviews this head"
458 ))
459}
460
461pub fn park(repo: &Repo, work_dir: &Path) -> Option<String> {
468 let saved = repo
469 .git_try_at(Some(work_dir), &["stash", "create"])
470 .trim()
471 .to_string();
472 (!saved.is_empty()).then_some(saved)
473}
474
475fn reset_saving(repo: &Repo, work_dir: &Path, target: &str) {
482 let parked = park(repo, work_dir);
483 if let Err(e) = repo.git_at(Some(work_dir), &["reset", "--hard", target]) {
484 logdim!("could not roll the working tree back: {e}");
485 return;
486 }
487 if let Some(saved) = parked {
488 logdim!("`git stash apply {saved}` puts the discarded changes back");
489 }
490}
491
492pub fn undo_edits(repo: &Repo, work_dir: &Path, before: &Snapshot) -> Snapshot {
503 let current = snapshot(repo, work_dir);
504 if before.head.is_empty() {
505 return current;
506 }
507 if current.landed_over(before) {
508 logdim!(
509 "the commits being rolled back are still at {}",
510 current.head
511 );
512 }
513 reset_saving(repo, work_dir, &before.head);
514 snapshot(repo, work_dir)
515}
516
517fn preserve_closing_commit(repo: &Repo, ctx: &LoopCtx) -> Option<String> {
523 let current = snapshot(repo, &ctx.work_dir);
524 if current.head.is_empty() {
525 return None;
526 }
527 let reference = format!(
528 "refs/spar/recovery/pr-{}/closing-{}",
529 ctx.pr_number, current.head
530 );
531 match repo.git_at(
532 Some(&ctx.work_dir),
533 &["update-ref", &reference, ¤t.head],
534 ) {
535 Ok(_) => Some(reference),
536 Err(e) => {
537 logdim!("could not preserve the closing pass commit: {e}");
538 None
539 }
540 }
541}
542
543pub fn drop_uncommitted(repo: &Repo, work_dir: &Path) -> Snapshot {
550 let current = snapshot(repo, work_dir);
551 if !current.dirty || current.head.is_empty() {
552 return current;
553 }
554 reset_saving(repo, work_dir, ¤t.head);
555 snapshot(repo, work_dir)
556}
557
558fn should_release(cfg: &Config, status: Status) -> bool {
563 if !cfg.loop_cfg.worktrees || cfg.loop_cfg.keep_worktrees {
564 return false;
565 }
566 !matches!(status, Status::Escalated | Status::Error)
567}
568
569fn uncommitted_implementation_error(work_dir: &Path, detail: Option<&str>) -> SparError {
570 let detail = detail.unwrap_or_default().trim();
571 let prefix = if detail.is_empty() {
572 String::new()
573 } else {
574 format!("{detail}\n")
575 };
576 spar_err!(
577 "{prefix}The implementation left uncommitted changes in {}. Commit or recover them \
578 before running this issue again.",
579 work_dir.display()
580 )
581}
582
583fn commit_accepted_changes(
584 cfg: &Config,
585 repo: &Repo,
586 work_dir: &Path,
587 baseline: &crate::repo::WorktreeBaseline,
588 preferred_subject: &str,
589 fallback_subject: &str,
590) -> Result<bool> {
591 repo.refuse_changed_attributes(work_dir, baseline)?;
592 if !cfg.loop_cfg.worktrees && repo.has_uncommitted_changes(work_dir)? {
593 bail!(
594 "the implementation changed the shared checkout at {}, but spar cannot distinguish \
595 those files from edits made concurrently by its owner. The files were kept. Commit \
596 or recover them, then use the default worktree mode for managed commits.",
597 work_dir.display()
598 );
599 }
600 let committed =
601 repo.commit_pending_changes(work_dir, baseline, preferred_subject, fallback_subject)?;
602 repo.refuse_unrepresented_tracked_changes(work_dir, baseline)?;
603 Ok(committed)
604}
605
606pub fn run_issue(
611 agents: &[Agent],
612 cfg: &Config,
613 repo: &Repo,
614 item: &PlanItem,
615 issue: &Issue,
616) -> IssueRun {
617 run_nth_issue(agents, cfg, repo, item, issue, 0)
618}
619
620pub fn run_nth_issue(
625 agents: &[Agent],
626 cfg: &Config,
627 repo: &Repo,
628 item: &PlanItem,
629 issue: &Issue,
630 nth: usize,
631) -> IssueRun {
632 match repo.try_open_pr_for_issue(item.issue, cfg.base_branch()) {
642 Ok(Some(existing)) => {
643 log!(
644 "#{}: {} is already open, continuing it instead of implementing again",
645 item.issue,
646 existing.url
647 );
648 return resume_pr(agents, cfg, repo, existing.number, None);
649 }
650 Ok(None) => {}
651 Err(e) => {
652 let mut state = IssueRun::new(item.issue, item.title.clone());
653 state.status = Status::Error;
654 state.notes.push(e.to_string());
655 log!("#{} failed: {e}", item.issue);
656 return state;
657 }
658 }
659
660 let mut state = IssueRun::new(item.issue, item.title.clone());
661 let mut ledger = Ledger::new();
668 let base = cfg.base_branch().to_string();
669
670 let prepared = if cfg.loop_cfg.worktrees {
671 repo.worktree_add(item.issue, &base)
672 } else {
673 (|| {
674 if repo.has_uncommitted_changes(repo.root())? {
675 bail!(
676 "the shared checkout at {} has uncommitted changes. Refusing to reset it for \
677 issue #{}.",
678 repo.root().display(),
679 item.issue
680 );
681 }
682 if repo.has_changes_checked(repo.root(), &base)? {
683 let preserved = repo.current_branch_is_preserved(repo.root()).map_err(|e| {
684 spar_err!(
685 "could not verify whether a pull request preserves the shared checkout \
686 at {}: {}. Refusing to reset it for issue #{}.",
687 repo.root().display(),
688 e.last_line(),
689 item.issue
690 )
691 })?;
692 if !preserved {
693 bail!(
694 "the shared checkout at {} has commits that are not on {base}. Refusing \
695 to reset them for issue #{}.",
696 repo.root().display(),
697 item.issue
698 );
699 }
700 }
701 let branch = repo.branch_for_issue(item.issue);
702 let start = format!("origin/{base}");
703 repo.refuse_issue_branch_rebuild(item.issue, &base)?;
704 if repo.has_uncommitted_changes(repo.root())? {
705 bail!(
706 "the shared checkout at {} changed while issue #{} was being prepared. \
707 Refusing to reset it.",
708 repo.root().display(),
709 item.issue
710 );
711 }
712 repo.git(&["checkout", "-B", &branch, &start])?;
713 repo.record_branch(&branch, "issue", item.issue);
714 Ok((repo.root().to_path_buf(), branch))
715 })()
716 };
717
718 let (work_dir, branch) = match prepared {
719 Ok(pair) => pair,
720 Err(e) => {
721 state.status = Status::Error;
722 state.notes.push(e.to_string());
723 log!("#{} failed: {e}", item.issue);
724 return state;
725 }
726 };
727
728 let outcome = implement_and_review(
729 agents,
730 cfg,
731 repo,
732 item,
733 issue,
734 nth,
735 &mut ledger,
736 &mut state,
737 &work_dir,
738 &branch,
739 );
740 if let Err(e) = outcome {
741 state.status = Status::Error;
742 state.notes.push(e.to_string());
743 log!("#{} failed: {e}", item.issue);
744 }
745
746 if should_release(cfg, state.status) {
747 repo.worktree_remove(item.issue);
748 }
749 state
750}
751
752#[allow(clippy::too_many_arguments)]
753fn implement_and_review(
754 agents: &[Agent],
755 cfg: &Config,
756 repo: &Repo,
757 item: &PlanItem,
758 issue: &Issue,
759 nth: usize,
760 ledger: &mut Ledger,
761 state: &mut IssueRun,
762 work_dir: &Path,
763 branch: &str,
764) -> Result<()> {
765 let number = item.issue;
766 let holder = cfg.first_implementor_for(nth);
767 let implementor = agent::find(agents, &holder)?;
768 let base = cfg.base_branch().to_string();
769
770 log!("#{number}: {holder} implementing");
771 repo.remember_implementor(number, &holder);
772 let issue_text = issue_for_prompt(
777 issue,
778 cfg.loop_cfg.max_issue_chars,
779 cfg.loop_cfg.checkin_trust,
780 );
781 issue_text.report(&format!("#{number}"));
782 let body = issue_text.text;
783 let criteria = tracker::criteria(issue.body_text());
784 let prompt = implement_prompt(number, &item.title, &issue.url, &body, &criteria);
785 let implementation_schema = if criteria.is_empty() {
786 schema::implementation()
787 } else {
788 schema::with_criteria(schema::implementation(), schema::CriteriaVoice::Author)
789 };
790 let worktree_baseline = repo.worktree_baseline(work_dir)?;
791 let answer: Result<Implementation> = implementor.edit_json(
792 &prompt,
793 &implementation_schema,
794 work_dir,
795 &implementor.effort(cfg, Call::Implement).about(number),
796 );
797
798 if answer.is_ok() {
799 repo.refuse_changed_attributes(work_dir, &worktree_baseline)?;
800 }
801
802 if let Err(e) = &answer {
803 if e.kind() == crate::error::ErrorKind::UncertainWrite {
804 return Err(e.clone());
805 }
806 }
807
808 let has_commits = repo.has_changes_checked(work_dir, &base)?;
813 let has_uncommitted = repo.has_uncommitted_changes(work_dir)?;
814 let mut work = match answer {
815 Err(e) if has_uncommitted => {
816 return Err(uncommitted_implementation_error(
817 work_dir,
818 Some(e.message()),
819 ));
820 }
821 Ok(work) => work,
822 Err(e) if has_commits => {
823 logwarn!(
824 "#{number}: {holder} failed after committing: {e}\nContinuing from the commits, \
825 with a pull request body written from their messages."
826 );
827 state
828 .notes
829 .push(format!("{holder} failed after committing: {e}"));
830 from_commits(repo, work_dir, &base)
831 }
832 Err(e) => return Err(e),
833 };
834
835 if work.not_worth_doing {
836 repo.refuse_unrepresented_tracked_changes(work_dir, &worktree_baseline)?;
837 repo.refuse_changed_existing_untracked(work_dir, &worktree_baseline)?;
838 if has_uncommitted || has_commits {
839 bail!(
840 "{holder} declined issue #{number} after changing {}. The worktree was kept for \
841 recovery.",
842 work_dir.display()
843 );
844 }
845 repo.refuse_new_ignored_files(work_dir, &worktree_baseline)?;
846 state.status = Status::Abandoned;
847 let reason = no_pr_note(&work, &repo.style);
848 state.notes.push(reason.clone());
849 if let Err(e) = repo.comment_issue(number, &reason) {
850 logdim!("could not comment on #{number}: {e}");
851 }
852 return Ok(());
853 }
854
855 commit_accepted_changes(
856 cfg,
857 repo,
858 work_dir,
859 &worktree_baseline,
860 &work.summary,
861 &item.title,
862 )?;
863 if repo.has_uncommitted_changes(work_dir)? {
864 return Err(uncommitted_implementation_error(
865 work_dir,
866 work.notes.as_deref(),
867 ));
868 }
869 if !repo.has_changes_checked(work_dir, &base)? {
870 repo.refuse_new_ignored_files(work_dir, &worktree_baseline)?;
871 state.status = Status::Abandoned;
872 let reason = no_pr_note(&work, &repo.style);
873 state.notes.push(reason.clone());
874 if let Err(e) = repo.comment_issue(number, &reason) {
875 logdim!("could not comment on #{number}: {e}");
876 }
877 return Ok(());
878 }
879
880 if work.summary.trim().is_empty() {
884 work.summary = item.title.clone();
885 }
886
887 let checked = check_before_push(
892 cfg,
893 repo,
894 implementor,
895 work_dir,
896 &format!("#{number}"),
897 Call::Implement,
898 )?;
899
900 repo.rewrite_commits_if_needed(work_dir, &base, None)?;
904 let target = repo.publish_new_branch(work_dir, branch, &base)?;
909
910 let description = pr_body(number, &work, checked.as_deref(), &criteria, &repo.style);
911 let pr = match repo.pr_for_branch(branch) {
912 Some(existing) => existing,
913 None => {
914 let body = description.clone();
915 repo.create_pr(
916 work_dir,
917 branch,
918 &target,
919 &base,
920 &format!("{} (#{number})", item.title),
921 &body,
922 )?
923 }
924 };
925 repo.record_branch(branch, "pr", pr.number);
926 state.pr = Some(pr.url.clone());
927 log!("#{number}: PR {}", pr.url);
928
929 let ctx = LoopCtx {
930 work_dir: work_dir.to_path_buf(),
931 branch: branch.to_string(),
932 target,
933 pr_number: pr.number,
934 context: context_block(number, &issue.url, &body, &description),
935 label: format!("#{number}"),
936 subject: number,
937 title: item.title.clone(),
938 start_round: 1,
939 holder: cfg.other(&holder),
940 release: Release::Issue(number),
941 criteria,
942 };
943 review_loop(agents, cfg, repo, &ctx, state, ledger, Vec::new())
944}
945
946pub fn resume_pr(
957 agents: &[Agent],
958 cfg: &Config,
959 repo: &Repo,
960 pr_number: i64,
961 holder_override: Option<&str>,
962) -> IssueRun {
963 let failed = |e: SparError| {
964 log!("PR #{pr_number} failed: {e}");
965 let mut state = IssueRun::new(pr_number, format!("PR #{pr_number}"));
966 state.status = Status::Error;
967 state.notes.push(e.to_string());
968 state
969 };
970
971 let pr = match repo.pr_view(pr_number) {
972 Ok(pr) => pr,
973 Err(e) => return failed(e),
974 };
975
976 let Some(target) = repo.pr_push_target(&pr) else {
983 log!("PR #{pr_number} comes from a fork, reviewing it without changing it");
984 return crate::review_only::review_pr(
985 agents,
986 cfg,
987 repo,
988 pr_number,
989 crate::review_only::ReviewOpts::default(),
990 );
991 };
992
993 match resume_inner(agents, cfg, repo, pr, target, holder_override) {
994 Ok(state) => state,
995 Err(e) => failed(e),
996 }
997}
998
999fn resume_inner(
1000 agents: &[Agent],
1001 cfg: &Config,
1002 repo: &Repo,
1003 pr: PrView,
1004 target: PushTarget,
1005 holder_override: Option<&str>,
1006) -> Result<IssueRun> {
1007 let pr_number = pr.number;
1008 if !pr.is_open() {
1009 return Err(spar_err!("PR #{pr_number} is {}", pr.state.to_lowercase()));
1010 }
1011
1012 let subject = pr
1013 .closing_issues_references
1014 .first()
1015 .map(|r| r.number)
1016 .unwrap_or(pr_number);
1017 let closing_url = pr.closing_issues_references.first().map(|r| &r.url);
1018
1019 if let Some(holder) = holder_override {
1020 if !cfg.has_agent(holder) {
1021 return Err(spar_err!(
1022 "--next must name one of: {}",
1023 cfg.agent_names().join(", ")
1024 ));
1025 }
1026 }
1027 let (work_dir, branch) = repo.worktree_for_pr(&pr, &target)?;
1028 let actual_head = checked_head(repo, &work_dir)?;
1029 let saved = repo.read_state_for_head(&pr, &actual_head);
1030 let saved_actor_known = saved
1031 .as_ref()
1032 .map(|state| cfg.has_agent(&state.next_actor))
1033 .unwrap_or(false);
1034 let state_matches_head = reconcile_saved_head(
1035 saved.as_ref().map(|state| state.pr_head.as_str()),
1036 saved.as_ref().map(|state| state.version),
1037 saved_actor_known,
1038 &actual_head,
1039 holder_override,
1040 pr_number,
1041 )?;
1042 let migrates_legacy_state = saved.as_ref().is_some_and(|state| {
1043 state.version == 1
1044 && state.pr_head.is_empty()
1045 && saved_actor_known
1046 && holder_override.is_none()
1047 });
1048 if migrates_legacy_state {
1049 log!("PR #{pr_number}: migrating legacy review state to head {actual_head}");
1050 }
1051
1052 let mut ledger: Ledger = saved
1053 .as_ref()
1054 .filter(|_| state_matches_head)
1055 .map(|s| s.ledger.clone())
1056 .unwrap_or_default();
1057 normalise_ledger_keys(&mut ledger);
1058 let open_findings = saved
1059 .as_ref()
1060 .filter(|_| state_matches_head)
1061 .map(|s| blocking_findings(&s.open_findings))
1062 .unwrap_or_default();
1063 let start_round = saved
1064 .as_ref()
1065 .filter(|_| state_matches_head)
1066 .map(|s| s.round + 1)
1067 .unwrap_or(1);
1068
1069 let default_holder = cfg.other(&cfg.first_implementor);
1070 let mut holder = holder_override
1071 .map(str::to_string)
1072 .or_else(|| saved.as_ref().map(|s| s.next_actor.clone()))
1073 .unwrap_or_else(|| default_holder.clone());
1074 if !cfg.has_agent(&holder) {
1075 log!("state named unknown agent '{holder}', using {default_holder}");
1076 holder = default_holder;
1077 }
1078
1079 match &saved {
1080 Some(_) => log!(
1081 "PR #{pr_number}: resuming at round {start_round}, {} point(s) on record, next up \
1082 {holder}",
1083 ledger.len()
1084 ),
1085 None => log!("PR #{pr_number}: no prior spar state, starting fresh with {holder}"),
1086 }
1087
1088 let mut state = IssueRun::new(subject, pr.title.clone());
1089 state.pr = Some(pr.url.clone());
1090 if let Some(s) = &saved {
1091 state.filed = s.filed.clone();
1092 if state_matches_head {
1093 state.disputes = s.disputes.clone();
1094 state.noted = s.noted.clone();
1095 } else {
1096 log!(
1097 "PR #{pr_number}: saved state does not match {actual_head}; branch-dependent \
1098 review state was cleared"
1099 );
1100 }
1101 }
1102
1103 let issue = (subject != pr_number)
1106 .then(|| repo.read_issue(subject))
1107 .transpose()
1108 .unwrap_or_else(|e| {
1109 logdim!("could not read issue #{subject} for context: {e}");
1110 None
1111 });
1112 let (issue_body, issue_url) = match &issue {
1113 Some(issue) => {
1114 let text = issue_for_prompt(
1115 issue,
1116 cfg.loop_cfg.max_issue_chars,
1117 cfg.loop_cfg.checkin_trust,
1118 );
1119 text.report(&format!("PR #{pr_number}"));
1120 (text.text, issue.url.clone())
1121 }
1122 None => (String::new(), String::new()),
1123 };
1124
1125 let ctx = LoopCtx {
1126 work_dir,
1127 branch,
1128 target,
1129 pr_number,
1130 context: context_block(subject, &issue_url, &issue_body, &pr.body),
1131 label: format!("PR #{pr_number}"),
1132 subject,
1133 title: pr.title.clone(),
1134 start_round,
1135 holder,
1136 release: Release::Pr(pr_number),
1137 criteria: issue
1140 .as_ref()
1141 .filter(|issue| Some(&issue.url) == closing_url)
1142 .map(|issue| tracker::criteria(issue.body_text()))
1143 .unwrap_or_default(),
1144 };
1145
1146 let outcome = review_loop(
1147 agents,
1148 cfg,
1149 repo,
1150 &ctx,
1151 &mut state,
1152 &mut ledger,
1153 open_findings,
1154 );
1155 if let Err(e) = outcome {
1156 state.status = Status::Error;
1157 state.notes.push(e.to_string());
1158 log!("PR #{pr_number} failed: {e}");
1159 }
1160 if should_release(cfg, state.status) {
1161 repo.release_pr_worktree(pr_number);
1162 }
1163 Ok(state)
1164}
1165
1166#[derive(Debug, Clone, Copy)]
1171enum Release {
1172 Issue(i64),
1173 Pr(i64),
1174}
1175
1176struct LoopCtx {
1177 work_dir: PathBuf,
1178 branch: String,
1179 target: PushTarget,
1182 pr_number: i64,
1183 label: String,
1184 subject: i64,
1185 title: String,
1186 context: String,
1189 start_round: u32,
1190 holder: String,
1191 release: Release,
1192 criteria: Vec<Item>,
1194}
1195
1196impl LoopCtx {
1197 fn release(&self, repo: &Repo) -> bool {
1198 match self.release {
1199 Release::Issue(n) => repo.worktree_remove(n),
1200 Release::Pr(n) => repo.release_pr_worktree(n),
1201 }
1202 }
1203}
1204
1205fn review_loop(
1206 agents: &[Agent],
1207 cfg: &Config,
1208 repo: &Repo,
1209 ctx: &LoopCtx,
1210 state: &mut IssueRun,
1211 ledger: &mut Ledger,
1212 mut open_findings: Vec<Finding>,
1213) -> Result<()> {
1214 let base = cfg.base_branch().to_string();
1215 let mut holder = ctx.holder.clone();
1219
1220 let (first, last_allowed) = round_window(ctx.start_round, cfg.loop_cfg.max_rounds);
1226 let mut published_head = checked_head(repo, &ctx.work_dir)?;
1227 persist(
1228 repo,
1229 ctx.pr_number,
1230 state,
1231 ledger,
1232 &open_findings,
1233 &published_head,
1234 first.saturating_sub(1),
1235 &holder,
1236 )?;
1237 let base_ref = repo.base_ref(&ctx.work_dir, &base);
1243
1244 let mut audited_head = String::new();
1248 let mut last_round = first.saturating_sub(1);
1249
1250 for round in first..=last_allowed {
1251 last_round = round;
1252 state.rounds = round;
1253 let reviewer = agent::find(agents, &holder)?;
1254 let effort = reviewer.effort(cfg, Call::Review(round)).about(ctx.subject);
1255 log!(
1256 "{}: round {round}, {holder} reviewing ({})",
1257 ctx.label,
1258 effort.describe()
1259 );
1260
1261 let prompt = review_prompt(
1262 &base_ref,
1263 ctx.subject,
1264 &ctx.title,
1265 &ctx.context,
1266 ledger,
1267 &open_findings,
1268 round,
1269 last_allowed,
1270 &ctx.criteria,
1271 );
1272 let before_review = snapshot(repo, &ctx.work_dir);
1273 let review_baseline = repo.worktree_baseline(&ctx.work_dir)?;
1274 audited_head = before_review.head.clone();
1277 let review = reviewer.review::<Review>(
1278 &base_ref,
1279 &prompt,
1280 &review_schema(&ctx.criteria),
1281 &ctx.work_dir,
1282 &effort,
1283 );
1284 if let Err(error) = &review {
1285 if error.kind() == crate::error::ErrorKind::UncertainWrite {
1286 return Err(error.clone());
1287 }
1288 }
1289 repo.refuse_unrepresented_tracked_changes(&ctx.work_dir, &review_baseline)?;
1290 repo.refuse_read_only_leavings(&ctx.work_dir, &review_baseline)?;
1291 let review = review?;
1292 if repo.has_uncommitted_changes(&ctx.work_dir)? {
1293 bail!(
1294 "{}: {holder} left uncommitted files while reviewing. They were kept at {} and \
1295 the review did not continue.",
1296 ctx.label,
1297 ctx.work_dir.display()
1298 );
1299 }
1300
1301 let mut editor: Option<String> = None;
1305 let review_wrote = snapshot(repo, &ctx.work_dir) != before_review;
1306 if review_wrote {
1307 logwarn!(
1308 "{}: {holder} changed the branch while reviewing it, which the review prompt \
1309 forbids. Rolling it back.",
1310 ctx.label
1311 );
1312 if undo_edits(repo, &ctx.work_dir, &before_review).head != before_review.head {
1313 state
1314 .notes
1315 .push(format!("{holder} committed during its own review"));
1316 editor = Some(holder.clone());
1317 }
1318 }
1319
1320 let blocking = blocking_findings(&review.findings);
1321 if let Some(said) = verdict_disagreement(&review, blocking.len()) {
1322 logwarn!("{}: {holder} {said}", ctx.label);
1323 }
1324 update_open_findings(&mut open_findings, &blocking, !review_wrote);
1325
1326 if repo.style.pr_comments == PrComments::Rounds {
1327 if let Err(e) = repo.comment_pr(
1328 ctx.pr_number,
1329 &review_comment(&holder, round, &review, &repo.style),
1330 ) {
1331 logdim!("could not post the review comment: {e}");
1332 }
1333 }
1334
1335 file_out_of_scope(repo, &review.findings, ctx.subject, state, cfg);
1339 file_nonblocking(repo, &review.findings, ctx.subject, state, cfg);
1340 remove_findings(&mut state.noted, &blocking);
1341
1342 if check_relitigation(ledger, &blocking, state) {
1343 state.status = Status::Escalated;
1344 post_outcome(
1345 repo,
1346 ctx.pr_number,
1347 state,
1348 ledger,
1349 Ending::Deadlocked(&blocking),
1350 );
1351 persist(
1352 repo,
1353 ctx.pr_number,
1354 state,
1355 ledger,
1356 &open_findings,
1357 &published_head,
1358 round,
1359 &holder,
1360 )?;
1361 return Ok(());
1362 }
1363
1364 if approval_stands(&blocking, review_wrote) {
1365 open_findings.clear();
1366 state.criteria = ruled(&ctx.criteria, &review.criteria);
1367 return approve(
1368 cfg,
1369 repo,
1370 ctx,
1371 state,
1372 ledger,
1373 &published_head,
1374 round,
1375 &holder,
1376 );
1377 }
1378
1379 persist(
1382 repo,
1383 ctx.pr_number,
1384 state,
1385 ledger,
1386 &open_findings,
1387 &published_head,
1388 round,
1389 &holder,
1390 )?;
1391
1392 let mut edit_error = None;
1393
1394 if blocking.is_empty() {
1395 logwarn!(
1400 "{}: {holder} found nothing blocking on a branch it had changed itself, so the \
1401 approval does not carry.",
1402 ctx.label
1403 );
1404 state.notes.push(format!(
1405 "{holder} passed the branch in round {round} after editing it; the edit was rolled \
1406 back and the approval did not stand"
1407 ));
1408 } else if review.next_action == NextAction::FixMyself {
1409 log!("{}: {holder} fixing its own findings", ctx.label);
1410 let prompt = FIX_PROMPT.replace("{findings}", &findings_for_prompt(&blocking));
1411 let before_fix = repo.head_oid_checked(&ctx.work_dir)?;
1412 let worktree_baseline = repo.worktree_baseline(&ctx.work_dir)?;
1413 let mut report: Option<OwnFixDoc> = None;
1414 let fix_error = match reviewer.edit_json::<OwnFixDoc>(
1415 &prompt,
1416 &schema::own_fix(),
1417 &ctx.work_dir,
1418 &effort,
1419 ) {
1420 Ok(doc) => {
1421 commit_accepted_changes(
1422 cfg,
1423 repo,
1424 &ctx.work_dir,
1425 &worktree_baseline,
1426 &doc.summary,
1427 "Address blocking review findings",
1428 )?;
1429 report = Some(doc);
1430 None
1431 }
1432 Err(error) => Some(defer_clean_edit_error(
1433 repo,
1434 &ctx.work_dir,
1435 &worktree_baseline,
1436 error,
1437 )?),
1438 };
1439 match editor_after(repo, &ctx.work_dir, &before_fix, &ctx.label, &holder)? {
1440 Some(who) => {
1441 let fixes = report.as_ref().map(|d| d.fixes.as_slice()).unwrap_or(&[]);
1453 let (claimed, left) = split_own_fixes(&blocking, fixes);
1454 record_own_fixes(&claimed, ledger, state, round);
1455 remove_findings(&mut open_findings, &blocking);
1456 extend_findings(&mut open_findings, &left);
1457 if !left.is_empty() {
1458 state.notes.push(format!(
1459 "{holder} fixed {} of {} of its own findings in round {round}; the \
1460 rest stay open",
1461 claimed.len(),
1462 blocking.len()
1463 ));
1464 }
1465 editor = Some(who);
1466 }
1467 None if fix_error.is_none() => {
1468 repo.refuse_new_ignored_files(&ctx.work_dir, &worktree_baseline)?;
1469 logwarn!(
1472 "{}: {holder} said it would fix its own findings and committed nothing, \
1473 so it keeps the pull request.",
1474 ctx.label
1475 );
1476 state.notes.push(format!(
1477 "{holder} chose to fix its own findings in round {round} and committed \
1478 nothing"
1479 ));
1480 }
1481 None => {}
1482 }
1483 edit_error = fix_error;
1484 } else {
1485 let author_name = cfg.other(&holder);
1486 let author = agent::find(agents, &author_name)?;
1487 log!(
1488 "{}: handing {} finding(s) to {author_name}",
1489 ctx.label,
1490 blocking.len()
1491 );
1492 let prompt = respond_prompt(ctx.subject, &ctx.context, &blocking, &review.findings);
1493 let before_response = repo.head_oid_checked(&ctx.work_dir)?;
1494 let worktree_baseline = repo.worktree_baseline(&ctx.work_dir)?;
1495 let response: Result<ResponseDoc> = author.edit_json(
1496 &prompt,
1497 &schema::response(),
1498 &ctx.work_dir,
1499 &author.effort(cfg, Call::Respond(round)).about(ctx.subject),
1500 );
1501 let response = match response {
1502 Ok(response) => {
1503 commit_accepted_changes(
1504 cfg,
1505 repo,
1506 &ctx.work_dir,
1507 &worktree_baseline,
1508 &response.summary,
1509 "Address blocking review findings",
1510 )?;
1511 Some(response)
1512 }
1513 Err(error) => {
1514 edit_error = Some(defer_clean_edit_error(
1515 repo,
1516 &ctx.work_dir,
1517 &worktree_baseline,
1518 error,
1519 )?);
1520 None
1521 }
1522 };
1523 if let Some(who) = editor_after(
1524 repo,
1525 &ctx.work_dir,
1526 &before_response,
1527 &ctx.label,
1528 &author_name,
1529 )? {
1530 editor = Some(who);
1531 } else if let Some(response) = &response {
1532 repo.refuse_new_ignored_files(&ctx.work_dir, &worktree_baseline)?;
1533 if response
1534 .dispositions
1535 .iter()
1536 .any(|d| d.action == Action::Fixed)
1537 {
1538 logwarn!(
1539 "{}: {author_name} reported fixes but committed nothing, so the diff does \
1540 not have them.",
1541 ctx.label
1542 );
1543 }
1544 }
1545 if let Some(response) = response {
1546 let mut used = vec![false; response.dispositions.len()];
1550 let unresolved = apply_dispositions(
1551 repo,
1552 cfg,
1553 &response,
1554 &blocking,
1555 ledger,
1556 state,
1557 round,
1558 ctx.subject,
1559 ctx.pr_number,
1560 &author_name,
1561 editor.is_some(),
1562 &mut used,
1563 );
1564 remove_findings(&mut open_findings, &blocking);
1565 extend_findings(&mut open_findings, &unresolved);
1566 apply_optional_dispositions(
1567 repo,
1568 cfg,
1569 &response,
1570 &review.findings,
1571 state,
1572 ctx.subject,
1573 &mut used,
1574 );
1575 warn_unplaced_dispositions(&response, &used);
1576 }
1577 }
1578
1579 if let Some(who) = &editor {
1580 check_before_push(
1581 cfg,
1582 repo,
1583 agent::find(agents, who)?,
1584 &ctx.work_dir,
1585 &ctx.label,
1586 Call::Respond(round),
1587 )?;
1588 }
1589 if editor.is_some() {
1590 repo.rewrite_commits_if_needed(&ctx.work_dir, &base, Some(&published_head))?;
1591 repo.push_to(
1592 &ctx.work_dir,
1593 &ctx.target,
1594 &ctx.branch,
1595 Some(&published_head),
1596 )?;
1597 published_head = checked_head(repo, &ctx.work_dir)?;
1598 }
1599 holder = next_reviewer(cfg, &holder, editor.as_deref());
1600 persist(
1601 repo,
1602 ctx.pr_number,
1603 state,
1604 ledger,
1605 &open_findings,
1606 &published_head,
1607 round,
1608 &holder,
1609 )?;
1610 if let Some(error) = edit_error {
1611 return Err(error);
1612 }
1613 }
1614
1615 close_out(
1622 agents,
1623 cfg,
1624 repo,
1625 ctx,
1626 state,
1627 ledger,
1628 &mut open_findings,
1629 &holder,
1630 last_round,
1631 &audited_head,
1632 &published_head,
1633 )
1634}
1635
1636#[allow(clippy::too_many_arguments)]
1647fn close_out(
1648 agents: &[Agent],
1649 cfg: &Config,
1650 repo: &Repo,
1651 ctx: &LoopCtx,
1652 state: &mut IssueRun,
1653 ledger: &mut Ledger,
1654 open_findings: &mut Vec<Finding>,
1655 holder: &str,
1656 round: u32,
1657 audited_head: &str,
1658 published_head: &str,
1659) -> Result<()> {
1660 let stop =
1661 |state: &mut IssueRun, ledger: &Ledger, open_findings: &[Finding], ending: Ending<'_>| {
1662 state.status = Status::Escalated;
1663 state.notes.push(exhausted_note(ctx.start_round, round));
1664 post_outcome(repo, ctx.pr_number, state, ledger, ending);
1665 persist(
1666 repo,
1667 ctx.pr_number,
1668 state,
1669 ledger,
1670 open_findings,
1671 published_head,
1672 round,
1673 holder,
1674 )
1675 };
1676
1677 let landed = (!audited_head.is_empty())
1684 .then(|| repo.commits_since(&ctx.work_dir, audited_head, "HEAD"))
1685 .flatten();
1686 if audited_head.is_empty()
1687 || (landed.as_ref().is_some_and(|l| l.is_empty()) && !any_fixes(ledger, round))
1688 {
1689 logdim!(
1690 "{}: nothing landed after the last review, so there is nothing to close over",
1691 ctx.label
1692 );
1693 if !open_findings.is_empty() {
1694 state.notes.push(unresolved_note(open_findings.len()));
1695 }
1696 stop(
1697 state,
1698 ledger,
1699 open_findings,
1700 ending_without_landing(open_findings),
1701 )?;
1702 return Ok(());
1703 }
1704
1705 let closer = agent::find(agents, holder)?;
1706 let effort = closer.effort(cfg, Call::Close).about(ctx.subject);
1707 log!(
1708 "{}: closing, {holder} checking what the last round left ({})",
1709 ctx.label,
1710 effort.describe()
1711 );
1712
1713 let prompt = close_prompt(
1714 &repo.base_ref(&ctx.work_dir, cfg.base_branch()),
1715 ctx.subject,
1716 &ctx.title,
1717 &ctx.context,
1718 audited_head,
1719 landed.as_deref(),
1720 ledger,
1721 open_findings,
1722 round,
1723 &ctx.criteria,
1724 );
1725 let before = snapshot(repo, &ctx.work_dir);
1726 let closing_baseline = repo.worktree_baseline(&ctx.work_dir)?;
1727 let pass = closer.ask_json(
1731 &prompt,
1732 &review_schema(&ctx.criteria),
1733 &ctx.work_dir,
1734 &effort,
1735 );
1736 if let Err(e) = &pass {
1737 if e.kind() == crate::error::ErrorKind::UncertainWrite {
1738 return Err(e.clone());
1739 }
1740 }
1741 repo.refuse_unrepresented_tracked_changes(&ctx.work_dir, &closing_baseline)?;
1742 repo.refuse_read_only_leavings(&ctx.work_dir, &closing_baseline)?;
1743 if repo.has_uncommitted_changes(&ctx.work_dir)? {
1744 bail!(
1745 "{}: {holder} left uncommitted files during the closing pass. They were kept at {} \
1746 and the pass did not continue.",
1747 ctx.label,
1748 ctx.work_dir.display()
1749 );
1750 }
1751
1752 let close_wrote = snapshot(repo, &ctx.work_dir) != before;
1755 let next = closing_next_actor(holder);
1759 if close_wrote {
1760 if let Err(error) = &pass {
1761 logwarn!(
1762 "{}: the closing pass failed after changing the branch: {error}",
1763 ctx.label
1764 );
1765 }
1766 logwarn!(
1767 "{}: {holder} changed the branch while closing, which the prompt forbids. Rolling it \
1768 back.",
1769 ctx.label
1770 );
1771 let after_undo = undo_edits(repo, &ctx.work_dir, &before);
1772 if after_undo != before {
1773 state.notes.push(format!(
1774 "{holder}'s closing-pass changes could not be fully rolled back"
1775 ));
1776 if after_undo.head != before.head {
1777 if let Some(reference) = preserve_closing_commit(repo, ctx) {
1778 state.notes.push(format!(
1779 "the closing pass commit was not pushed and remains at {reference}"
1780 ));
1781 }
1782 }
1783 }
1784 state.status = Status::Escalated;
1785 state.notes.push(format!(
1786 "{holder} edited the branch during the closing pass, so its answer did not stand"
1787 ));
1788 post_unread_outcome(repo, ctx.pr_number, state, ledger, open_findings);
1789 persist(
1790 repo,
1791 ctx.pr_number,
1792 state,
1793 ledger,
1794 open_findings,
1795 published_head,
1796 round,
1797 &next,
1798 )?;
1799 return Ok(());
1800 }
1801
1802 let pass: Review = match pass {
1803 Ok(pass) => pass,
1804 Err(e) => {
1805 logwarn!("{}: the closing pass failed: {e}", ctx.label);
1809 state.status = Status::Escalated;
1810 state.notes.push(exhausted_note(ctx.start_round, round));
1811 post_unread_outcome(repo, ctx.pr_number, state, ledger, open_findings);
1812 persist(
1813 repo,
1814 ctx.pr_number,
1815 state,
1816 ledger,
1817 open_findings,
1818 published_head,
1819 round,
1820 holder,
1821 )?;
1822 return Ok(());
1823 }
1824 };
1825
1826 file_out_of_scope(repo, &pass.findings, ctx.subject, state, cfg);
1827 file_nonblocking(repo, &pass.findings, ctx.subject, state, cfg);
1828
1829 let blocking = blocking_findings(&pass.findings);
1830 if let Some(said) = verdict_disagreement(&pass, blocking.len()) {
1831 logwarn!("{}: the closing pass {said}", ctx.label);
1832 }
1833 remove_findings(&mut state.noted, &blocking);
1834 update_open_findings(open_findings, &blocking, true);
1835
1836 if check_relitigation(ledger, &blocking, state) {
1837 state.status = Status::Escalated;
1838 post_outcome(
1839 repo,
1840 ctx.pr_number,
1841 state,
1842 ledger,
1843 Ending::Deadlocked(&blocking),
1844 );
1845 persist(
1846 repo,
1847 ctx.pr_number,
1848 state,
1849 ledger,
1850 open_findings,
1851 published_head,
1852 round,
1853 &next,
1854 )?;
1855 return Ok(());
1856 }
1857
1858 if approval_stands(&blocking, false) {
1859 open_findings.clear();
1860 state.criteria = ruled(&ctx.criteria, &pass.criteria);
1861 return approve(cfg, repo, ctx, state, ledger, published_head, round, &next);
1862 }
1863
1864 state.status = Status::Escalated;
1865 state.notes.push(unresolved_note(open_findings.len()));
1866 post_outcome(
1867 repo,
1868 ctx.pr_number,
1869 state,
1870 ledger,
1871 Ending::Unresolved(open_findings),
1872 );
1873 persist(
1874 repo,
1875 ctx.pr_number,
1876 state,
1877 ledger,
1878 open_findings,
1879 published_head,
1880 round,
1881 &next,
1882 )?;
1883 Ok(())
1884}
1885
1886fn any_fixes(ledger: &Ledger, round: u32) -> bool {
1892 ledger
1893 .values()
1894 .any(|e| e.outcome == Settled::Fixed && e.round >= round)
1895}
1896
1897fn unresolved_note(left: usize) -> String {
1902 match left {
1903 1 => "one point left after the closing pass".to_string(),
1904 n => format!("{n} points left after the closing pass"),
1905 }
1906}
1907
1908fn ensure_reviewed_head(pr_number: i64, reviewed_head: &str, live_head: &str) -> Result<()> {
1914 if live_head == reviewed_head {
1915 return Ok(());
1916 }
1917 Err(spar_err!(
1918 "PR #{pr_number} changed from {reviewed_head} to {live_head} after it was reviewed; refusing to approve or merge an unread head"
1919 ))
1920}
1921
1922#[allow(clippy::too_many_arguments)]
1923fn approve(
1924 cfg: &Config,
1925 repo: &Repo,
1926 ctx: &LoopCtx,
1927 state: &mut IssueRun,
1928 ledger: &Ledger,
1929 published_head: &str,
1930 round: u32,
1931 holder: &str,
1932) -> Result<()> {
1933 let live_head = repo.pr_head_oid(ctx.pr_number)?;
1934 ensure_reviewed_head(ctx.pr_number, published_head, &live_head)?;
1935 state.status = Status::Approved;
1936 persist(
1937 repo,
1938 ctx.pr_number,
1939 state,
1940 ledger,
1941 &[],
1942 published_head,
1943 round,
1944 holder,
1945 )?;
1946 let live_head = repo.pr_head_oid(ctx.pr_number)?;
1947 ensure_reviewed_head(ctx.pr_number, published_head, &live_head)?;
1948 state.ticks_criteria = cfg.loop_cfg.auto_merge && cfg.loop_cfg.tick_criteria;
1950 post_outcome(repo, ctx.pr_number, state, ledger, Ending::Approved);
1951 if cfg.loop_cfg.drafts == Drafts::UntilApproved && repo.mark_ready(ctx.pr_number) {
1955 log!("{}: out of draft", ctx.label);
1956 }
1957 if cfg.loop_cfg.auto_merge {
1958 let released = ctx.release(repo);
1962 if !released {
1963 log!(
1964 "{}: kept the worktree at {} and will leave its branch in place",
1965 ctx.label,
1966 ctx.work_dir.display()
1967 );
1968 }
1969 repo.merge_pr_at_head(ctx.pr_number, published_head, released)?;
1970 state.status = Status::Merged;
1971 repo.clear_state(ctx.pr_number); log!("{}: merged", ctx.label);
1973 let some_met = state.criteria.iter().any(|c| c.status == Some(Mark::Met));
1974 if state.ticks_criteria && ctx.subject != ctx.pr_number && some_met {
1975 let landed = repo.pr_state(ctx.pr_number) == "MERGED"
1979 && repo.pr_head_oid(ctx.pr_number).ok().as_deref() == Some(published_head);
1980 if !landed {
1981 log!(
1982 "{}: not merged yet at the reviewed head, so no acceptance criteria were ticked on #{}",
1983 ctx.label,
1984 ctx.subject
1985 );
1986 return Ok(());
1987 }
1988 let ticked = tick_met(repo, ctx.subject, &state.criteria);
1989 if ticked > 0 {
1990 let noun = if ticked == 1 { "criterion" } else { "criteria" };
1991 log!(
1992 "{}: ticked {ticked} acceptance {noun} on #{}",
1993 ctx.label,
1994 ctx.subject
1995 );
1996 }
1997 }
1998 } else {
1999 log!("{}: approved, awaiting human merge", ctx.label);
2000 }
2001 Ok(())
2002}
2003
2004fn approval_stands(blocking: &[Finding], review_wrote: bool) -> bool {
2012 blocking.is_empty() && !review_wrote
2013}
2014
2015fn next_reviewer(cfg: &Config, reviewer: &str, editor: Option<&str>) -> String {
2028 match editor {
2029 Some(editor) => cfg.other(editor),
2030 None => reviewer.to_string(),
2031 }
2032}
2033
2034fn defer_clean_edit_error(
2035 repo: &Repo,
2036 work_dir: &Path,
2037 baseline: &crate::repo::WorktreeBaseline,
2038 error: SparError,
2039) -> Result<SparError> {
2040 if error.kind() == ErrorKind::UncertainWrite {
2041 return Err(error);
2042 }
2043 repo.refuse_changed_attributes(work_dir, baseline)?;
2044 if repo.has_uncommitted_changes(work_dir)? {
2045 return Err(error);
2046 }
2047 repo.refuse_failed_edit_leavings(work_dir, baseline)?;
2048 repo.refuse_unrepresented_tracked_changes(work_dir, baseline)?;
2049 Ok(error)
2050}
2051
2052fn editor_after(
2060 repo: &Repo,
2061 work_dir: &Path,
2062 before_head: &str,
2063 label: &str,
2064 who: &str,
2065) -> Result<Option<String>> {
2066 if repo.has_uncommitted_changes(work_dir)? {
2067 bail!(
2068 "{label}: {who} left uncommitted files at {}. They were kept and the review did not \
2069 continue.",
2070 work_dir.display()
2071 );
2072 }
2073 let after_head = repo.head_oid_checked(work_dir)?;
2074 if after_head != before_head && !repo.is_ancestor_checked(work_dir, before_head, &after_head)? {
2075 bail!(
2076 "{label}: {who} moved HEAD to {after_head}, but it does not contain the previous \
2077 branch tip {before_head}. The worktree was kept for recovery and nothing was \
2078 published. Restore {before_head} or reapply the intended commits on top of it before \
2079 resuming."
2080 );
2081 }
2082 Ok((after_head != before_head).then(|| who.to_string()))
2083}
2084
2085fn round_window(start_round: u32, budget: u32) -> (u32, u32) {
2091 (start_round, start_round + budget.saturating_sub(1))
2092}
2093
2094fn spent(start_round: u32, last_round: u32) -> (u32, u32) {
2098 (last_round.saturating_sub(start_round) + 1, last_round)
2099}
2100
2101fn exhausted_note(start_round: u32, last_round: u32) -> String {
2102 let (this_run, total) = spent(start_round, last_round);
2103 if this_run == total {
2104 format!("no convergence after {this_run} rounds")
2105 } else {
2106 format!("no convergence after {this_run} more rounds ({total} in total)")
2107 }
2108}
2109
2110#[allow(clippy::too_many_arguments)]
2111fn persist(
2112 repo: &Repo,
2113 pr_number: i64,
2114 state: &IssueRun,
2115 ledger: &Ledger,
2116 open_findings: &[Finding],
2117 published_head: &str,
2118 round: u32,
2119 next_actor: &str,
2120) -> Result<()> {
2121 let payload = PersistedState {
2122 version: STATE_VERSION,
2123 checkpoint: 0,
2124 round,
2125 next_actor: next_actor.to_string(),
2126 status: state.status,
2127 pr_head: published_head.to_string(),
2128 ledger: ledger.clone(),
2129 filed: state.filed.clone(),
2130 open_findings: open_findings.to_vec(),
2131 disputes: state.disputes.clone(),
2132 noted: state.noted.clone(),
2133 };
2134 repo.write_state(pr_number, &payload)
2135}
2136
2137fn settled_block(ledger: &Ledger) -> String {
2142 if ledger.is_empty() {
2143 return String::new();
2144 }
2145 let lines: Vec<String> = ledger
2150 .values()
2151 .filter_map(|e| {
2152 let point = match e.file.trim() {
2153 "" => e.title.clone(),
2154 file => format!("{} ({file})", e.title),
2155 };
2156 match e.outcome {
2157 Settled::Refuted => Some(format!("- {point}: refuted because {}", e.reasoning)),
2158 Settled::Filed => Some(format!(
2159 "- {point}: out of scope here, and filed. {}",
2160 e.reasoning
2161 )),
2162 Settled::Dropped => Some(format!(
2163 "- {point}: out of scope here, and not filed. {}",
2164 e.reasoning
2165 )),
2166 Settled::Fixed => None,
2167 }
2168 })
2169 .collect();
2170 if lines.is_empty() {
2171 return String::new();
2172 }
2173 format!(
2174 "\nThe following points were already raised and settled, by a refutation or by a \
2175 follow-up issue. Treat them as settled. Do not raise them again unless you have new \
2176 evidence:\n{}",
2177 lines.join("\n")
2178 )
2179}
2180
2181fn fixed_lines(ledger: &Ledger, since: u32) -> Vec<String> {
2193 ledger
2194 .values()
2195 .filter(|e| e.outcome == Settled::Fixed && e.round >= since)
2196 .map(|e| {
2197 let claim = match e.reasoning.trim() {
2198 "" => "a committed change claims to address this point",
2199 reasoning => reasoning,
2200 };
2201 match e.file.trim() {
2202 "" => format!("- {}. Recorded answer: {claim}", e.title),
2203 file => format!("- {} ({file}). Recorded answer: {claim}", e.title),
2204 }
2205 })
2206 .collect()
2207}
2208
2209fn answers_block(ledger: &Ledger, round: u32) -> String {
2217 let lines = fixed_lines(ledger, round.saturating_sub(1));
2220 if lines.is_empty() {
2221 return String::new();
2222 }
2223 format!(
2224 "\nThese points were raised on this pull request in earlier rounds and the author says \
2225 it fixed them. The code is on the branch and the claim is the author's:\n{}\n\nCheck the \
2226 answer rather than taking it. If one of them is still not fixed, raise it again under \
2227 the same title, so the run can tell a point that was not answered from a new one.\n",
2228 lines.join("\n")
2229 )
2230}
2231
2232fn round_note(round: u32, last: u32) -> String {
2241 if round < last {
2242 return String::new();
2243 }
2244 "\nThis is the last round in this run that can ask the author for anything. After it, one \
2245 pass reads what landed and the pull request is either signed off or goes to a person with \
2246 what is left. Raise everything you mean to raise now. A point held back for a later round \
2247 does not get one.\n"
2248 .to_string()
2249}
2250
2251#[cfg(test)]
2256fn matching_ledger_key(ledger: &Ledger, title: &str, file: &str) -> Option<String> {
2257 matching_ledger_key_with_fallback(ledger, title, file, true)
2258}
2259
2260fn matching_ledger_key_with_fallback(
2261 ledger: &Ledger,
2262 title: &str,
2263 file: &str,
2264 allow_stable_fallback: bool,
2265) -> Option<String> {
2266 let exact = finding_key(title, file);
2267 if ledger.contains_key(&exact) {
2268 return Some(exact);
2269 }
2270 let legacy = crate::jsonx::finding_key(title, file);
2271 if ledger
2272 .get(&legacy)
2273 .is_some_and(|entry| same_finding_parts(&entry.title, &entry.file, title, file))
2274 {
2275 return Some(legacy);
2276 }
2277 if !allow_stable_fallback {
2278 return None;
2279 }
2280
2281 let stable = stable_finding_key(title, file);
2282 let path = finding_file(file);
2283 let mut matches = ledger
2284 .iter()
2285 .filter(|(saved_key, entry)| {
2286 if stable_finding_key(&entry.title, &entry.file) == stable {
2287 return true;
2288 }
2289 finding_file(&entry.file) == path
2290 && saved_key.as_str() == crate::jsonx::finding_key(title, &entry.file)
2291 })
2292 .map(|(key, _)| key.clone());
2293 let first = matches.next()?;
2294 matches.next().is_none().then_some(first)
2295}
2296
2297fn matching_ledger_entry<'a>(
2298 ledger: &'a Ledger,
2299 title: &str,
2300 file: &str,
2301) -> Option<&'a LedgerEntry> {
2302 matching_ledger_entry_with_fallback(ledger, title, file, true)
2303}
2304
2305fn matching_ledger_entry_with_fallback<'a>(
2306 ledger: &'a Ledger,
2307 title: &str,
2308 file: &str,
2309 allow_stable_fallback: bool,
2310) -> Option<&'a LedgerEntry> {
2311 let key = matching_ledger_key_with_fallback(ledger, title, file, allow_stable_fallback)?;
2312 ledger.get(&key)
2313}
2314
2315fn settle(
2316 ledger: &mut Ledger,
2317 title: &str,
2318 file: &str,
2319 allow_stable_fallback: bool,
2320 entry: LedgerEntry,
2321) {
2322 let old_key = matching_ledger_key_with_fallback(ledger, title, file, allow_stable_fallback);
2323 let reraised = old_key
2324 .as_ref()
2325 .and_then(|key| ledger.get(key))
2326 .map(|entry| entry.reraised)
2327 .unwrap_or(0);
2328 if let Some(old_key) = old_key {
2329 ledger.remove(&old_key);
2330 }
2331 ledger.insert(finding_key(title, file), LedgerEntry { reraised, ..entry });
2332}
2333
2334fn normalise_ledger_keys(ledger: &mut Ledger) {
2336 let mut normalised = Ledger::new();
2337 for (saved_key, mut entry) in std::mem::take(ledger) {
2338 let key = if saved_key.len() == 12
2339 && saved_key
2340 .chars()
2341 .all(|character| character.is_ascii_hexdigit())
2342 {
2343 saved_key
2344 } else {
2345 finding_key(&entry.title, &entry.file)
2346 };
2347 if let Some(previous) = normalised.get_mut(&key) {
2348 let reraised = previous.reraised.max(entry.reraised);
2349 if entry.round >= previous.round {
2350 entry.reraised = reraised;
2351 *previous = entry;
2352 } else {
2353 previous.reraised = reraised;
2354 }
2355 } else {
2356 normalised.insert(key, entry);
2357 }
2358 }
2359 *ledger = normalised;
2360}
2361
2362pub(crate) fn verdict_disagreement(review: &Review, blocking: usize) -> Option<String> {
2372 match (review.verdict, blocking, review.next_action) {
2373 (Verdict::Approve, n, _) if n > 0 => Some(format!(
2374 "the review says approve and raises {n} blocking finding(s); the findings decide"
2375 )),
2376 (Verdict::ChangesRequested, 0, NextAction::Merge) => Some(
2377 "the review says changes_requested, raises nothing blocking, and asks to merge; the \
2378 findings decide"
2379 .to_string(),
2380 ),
2381 _ => None,
2382 }
2383}
2384
2385fn blocking_findings(findings: &[Finding]) -> Vec<Finding> {
2386 let mut kept = Vec::new();
2387 for finding in findings.iter().filter(|finding| finding.blocks()) {
2388 if let Some(existing) = kept
2389 .iter_mut()
2390 .find(|existing| same_finding(existing, finding))
2391 {
2392 *existing = finding.clone();
2393 } else {
2394 kept.push(finding.clone());
2395 }
2396 }
2397 kept
2398}
2399
2400fn matching_finding_index(
2401 findings: &[Finding],
2402 target: &Finding,
2403 allow_stable_fallback: bool,
2404) -> Option<usize> {
2405 if let Some(index) = findings
2406 .iter()
2407 .position(|finding| same_finding(finding, target))
2408 {
2409 return Some(index);
2410 }
2411 if !allow_stable_fallback {
2412 return None;
2413 }
2414
2415 let stable = stable_finding_key(&target.title, &target.file);
2416 let mut matches = findings
2417 .iter()
2418 .enumerate()
2419 .filter(|(_, finding)| stable_finding_key(&finding.title, &finding.file) == stable);
2420 let first = matches.next().map(|(index, _)| index);
2421 first.filter(|_| matches.next().is_none())
2422}
2423
2424fn unique_stable_finding(findings: &[Finding], target: &Finding) -> bool {
2425 let stable = stable_finding_key(&target.title, &target.file);
2426 findings
2427 .iter()
2428 .filter(|finding| stable_finding_key(&finding.title, &finding.file) == stable)
2429 .count()
2430 == 1
2431}
2432
2433fn extend_findings(target: &mut Vec<Finding>, additions: &[Finding]) {
2435 for finding in additions {
2436 if let Some(index) =
2437 matching_finding_index(target, finding, unique_stable_finding(additions, finding))
2438 {
2439 target[index] = finding.clone();
2440 } else {
2441 target.push(finding.clone());
2442 }
2443 }
2444}
2445
2446fn update_open_findings(
2447 open_findings: &mut Vec<Finding>,
2448 current: &[Finding],
2449 answer_stands: bool,
2450) {
2451 if answer_stands {
2452 *open_findings = current.to_vec();
2453 } else {
2454 extend_findings(open_findings, current);
2455 }
2456}
2457
2458fn remove_findings(target: &mut Vec<Finding>, removed: &[Finding]) {
2459 for finding in removed {
2460 if let Some(index) =
2461 matching_finding_index(target, finding, unique_stable_finding(removed, finding))
2462 {
2463 target.remove(index);
2464 }
2465 }
2466}
2467
2468fn ending_without_landing(open_findings: &[Finding]) -> Ending<'_> {
2469 if open_findings.is_empty() {
2470 Ending::Unchanged
2471 } else {
2472 Ending::Unresolved(open_findings)
2473 }
2474}
2475
2476fn closing_next_actor(holder: &str) -> String {
2477 holder.to_string()
2478}
2479
2480fn remember_noted(state: &mut IssueRun, finding: &Finding, allow_stable_fallback: bool) {
2493 if let Some(index) = matching_finding_index(&state.noted, finding, allow_stable_fallback) {
2494 state.noted[index] = finding.clone();
2495 } else {
2496 state.noted.push(finding.clone());
2497 }
2498}
2499
2500fn forget_noted(state: &mut IssueRun, finding: &Finding, allow_stable_fallback: bool) {
2501 if let Some(index) = matching_finding_index(&state.noted, finding, allow_stable_fallback) {
2502 state.noted.remove(index);
2503 }
2504}
2505
2506fn remember_dispute(state: &mut IssueRun, dispute: Dispute, allow_stable_fallback: bool) {
2507 let exact = finding_key(&dispute.title, &dispute.file);
2508 let stable = stable_finding_key(&dispute.title, &dispute.file);
2509 let exact_index = state
2510 .disputes
2511 .iter()
2512 .position(|kept| finding_key(&kept.title, &kept.file) == exact);
2513 let stable_index = if exact_index.is_none() && allow_stable_fallback {
2514 let mut matches = state
2515 .disputes
2516 .iter()
2517 .enumerate()
2518 .filter(|(_, kept)| stable_finding_key(&kept.title, &kept.file) == stable);
2519 let first = matches.next().map(|(index, _)| index);
2520 first.filter(|_| matches.next().is_none())
2521 } else {
2522 None
2523 };
2524 if let Some(index) = exact_index.or(stable_index) {
2525 state.disputes[index] = dispute;
2526 } else {
2527 state.disputes.push(dispute);
2528 }
2529}
2530
2531fn forget_dispute(state: &mut IssueRun, finding: &Finding, allow_stable_fallback: bool) {
2532 let exact = finding_key(&finding.title, &finding.file);
2533 if let Some(index) = state
2534 .disputes
2535 .iter()
2536 .position(|kept| finding_key(&kept.title, &kept.file) == exact)
2537 {
2538 state.disputes.remove(index);
2539 return;
2540 }
2541 if !allow_stable_fallback {
2542 return;
2543 }
2544
2545 let stable = stable_finding_key(&finding.title, &finding.file);
2546 let mut matches = state
2547 .disputes
2548 .iter()
2549 .enumerate()
2550 .filter(|(_, kept)| stable_finding_key(&kept.title, &kept.file) == stable);
2551 let first = matches.next().map(|(index, _)| index);
2552 if let Some(index) = first.filter(|_| matches.next().is_none()) {
2553 state.disputes.remove(index);
2554 }
2555}
2556
2557#[cfg(test)]
2558fn record_nonblocking_outcome(state: &mut IssueRun, finding: &Finding, outcome: Option<&Followup>) {
2559 record_nonblocking_outcome_with_match(state, finding, outcome, true);
2560}
2561
2562fn record_nonblocking_outcome_with_match(
2563 state: &mut IssueRun,
2564 finding: &Finding,
2565 outcome: Option<&Followup>,
2566 allow_stable_fallback: bool,
2567) {
2568 forget_dispute(state, finding, allow_stable_fallback);
2569 if let Some(Followup::Recorded(url)) = outcome {
2570 if !state.filed.iter().any(|filed| filed == url) {
2571 state.filed.push(url.clone());
2572 }
2573 forget_noted(state, finding, allow_stable_fallback);
2574 } else {
2575 remember_noted(state, finding, allow_stable_fallback);
2576 }
2577}
2578
2579fn split_own_fixes(blocking: &[Finding], fixes: &[OwnFix]) -> (Vec<Finding>, Vec<Finding>) {
2586 let mut claimed = Vec::new();
2587 let mut left = Vec::new();
2588 let mut used = vec![false; fixes.len()];
2589 for finding in blocking {
2590 match matching_disposition(finding, blocking, fixes) {
2591 Ok((index, fix)) if !used[index] => {
2592 used[index] = true;
2593 if fix.fixed {
2594 claimed.push(finding.clone());
2595 continue;
2596 }
2597 let mut open = finding.clone();
2598 let reason = fix.reason.trim();
2599 if !reason.is_empty() {
2600 open.detail = format!(
2601 "{}\n\nThe reviewer chose to fix this itself and did not: {reason}",
2602 open.detail.trim()
2603 );
2604 }
2605 logwarn!("'{}' was not fixed: {reason}", finding.title);
2606 left.push(open);
2607 }
2608 Ok(_) => {
2609 logwarn!(
2610 "'{}' has more than one matching entry in the fix report, so it stays open",
2611 finding.title
2612 );
2613 left.push(finding.clone());
2614 }
2615 Err(reason) => {
2616 logwarn!(
2617 "'{}' has {reason} in the fix report, so it stays open",
2618 finding.title
2619 );
2620 left.push(finding.clone());
2621 }
2622 }
2623 }
2624 (claimed, left)
2625}
2626
2627fn record_own_fixes(blocking: &[Finding], ledger: &mut Ledger, state: &mut IssueRun, round: u32) {
2634 for finding in blocking {
2635 let allow_stable_fallback = unique_stable_finding(blocking, finding);
2636 settle(
2637 ledger,
2638 &finding.title,
2639 &finding.file,
2640 allow_stable_fallback,
2641 LedgerEntry {
2642 title: finding.title.clone(),
2643 file: finding.file.clone(),
2644 reasoning: "a committed change was made for this point".to_string(),
2645 round,
2646 reraised: 0,
2647 outcome: Settled::Fixed,
2648 },
2649 );
2650 forget_noted(state, finding, allow_stable_fallback);
2651 forget_dispute(state, finding, allow_stable_fallback);
2652 }
2653}
2654
2655fn check_relitigation(ledger: &mut Ledger, blocking: &[Finding], state: &mut IssueRun) -> bool {
2658 let mut escalate = false;
2659 let mut counted: BTreeSet<String> = BTreeSet::new();
2665 for finding in blocking {
2666 let allow_stable_fallback = unique_stable_finding(blocking, finding);
2667 let Some(key) = matching_ledger_key_with_fallback(
2668 ledger,
2669 &finding.title,
2670 &finding.file,
2671 allow_stable_fallback,
2672 ) else {
2673 continue;
2674 };
2675 if !counted.insert(key.clone()) {
2676 continue;
2677 }
2678 if let Some(entry) = ledger.get_mut(&key) {
2679 entry.reraised += 1;
2680 if entry.outcome == Settled::Refuted {
2681 remember_dispute(
2682 state,
2683 Dispute {
2684 title: finding.title.clone(),
2685 file: finding.file.clone(),
2686 reasoning: entry.reasoning.clone(),
2687 },
2688 allow_stable_fallback,
2689 );
2690 }
2691 if entry.reraised >= 2 {
2692 state.notes.push(format!(
2693 "'{}' {}",
2694 finding.title,
2695 why_escalated(entry.outcome)
2696 ));
2697 escalate = true;
2698 }
2699 }
2700 }
2701 escalate
2702}
2703
2704fn why_escalated(outcome: Settled) -> &'static str {
2711 match outcome {
2712 Settled::Fixed => "was fixed twice and raised again; escalating.",
2713 _ => "was settled and re-raised twice; escalating.",
2714 }
2715}
2716
2717fn normalise(text: &str) -> String {
2718 crate::jsonx::untagged_title(text)
2719 .to_lowercase()
2720 .chars()
2721 .filter(|c| c.is_ascii_alphanumeric() || c.is_whitespace())
2722 .collect::<String>()
2723 .split_whitespace()
2724 .collect::<Vec<_>>()
2725 .join(" ")
2726}
2727
2728pub(crate) fn same_point(a: &str, b: &str) -> bool {
2730 normalise(a) == normalise(b)
2731}
2732
2733pub(crate) fn same_finding(a: &Finding, b: &Finding) -> bool {
2734 same_finding_parts(&a.title, &a.file, &b.title, &b.file)
2735}
2736
2737pub(crate) fn same_finding_parts(a_title: &str, a_file: &str, b_title: &str, b_file: &str) -> bool {
2738 finding_key(a_title, a_file) == finding_key(b_title, b_file)
2739}
2740
2741pub(crate) trait Answer {
2747 fn title(&self) -> &str;
2748 fn file(&self) -> &str;
2749}
2750
2751impl Answer for Disposition {
2752 fn title(&self) -> &str {
2753 &self.title
2754 }
2755 fn file(&self) -> &str {
2756 &self.file
2757 }
2758}
2759
2760impl Answer for OwnFix {
2761 fn title(&self) -> &str {
2762 &self.title
2763 }
2764 fn file(&self) -> &str {
2765 &self.file
2766 }
2767}
2768
2769fn answer_matches<A: Answer>(finding: &Finding, answer: &A) -> bool {
2770 same_point(&finding.title, answer.title()) && finding.file.trim() == answer.file().trim()
2771}
2772
2773fn stable_answer_matches<A: Answer>(finding: &Finding, answer: &A) -> bool {
2774 stable_finding_key(&finding.title, &finding.file)
2775 == stable_finding_key(answer.title(), answer.file())
2776}
2777
2778fn sole_answer_match<'a, A: Answer>(
2779 finding: &Finding,
2780 answers: &'a [A],
2781 matches: impl Fn(&Finding, &A) -> bool,
2782) -> std::result::Result<Option<(usize, &'a A)>, &'static str> {
2783 let mut matched = answers
2784 .iter()
2785 .enumerate()
2786 .filter(|(_, answer)| matches(finding, answer));
2787 let first = matched.next();
2788 if matched.next().is_some() {
2789 return Err("more than one matching disposition");
2790 }
2791 Ok(first)
2792}
2793
2794fn matching_disposition<'a, A: Answer>(
2799 finding: &Finding,
2800 findings: &[Finding],
2801 answers: &'a [A],
2802) -> std::result::Result<(usize, &'a A), &'static str> {
2803 if let Some(exact) = sole_answer_match(finding, answers, answer_matches)? {
2804 return Ok(exact);
2805 }
2806 if unique_stable_finding(findings, finding) {
2807 if let Some(stable) = sole_answer_match(finding, answers, stable_answer_matches)? {
2808 return Ok(stable);
2809 }
2810 }
2811 Err("no matching disposition")
2812}
2813
2814fn fixed_disposition_resolves(committed: bool) -> bool {
2815 committed
2816}
2817
2818#[allow(clippy::too_many_arguments)]
2819fn apply_dispositions(
2820 repo: &Repo,
2821 cfg: &Config,
2822 response: &ResponseDoc,
2823 blocking: &[Finding],
2824 ledger: &mut Ledger,
2825 state: &mut IssueRun,
2826 round: u32,
2827 subject: i64,
2828 pr_number: i64,
2829 author: &str,
2830 committed: bool,
2831 used: &mut [bool],
2832) -> Vec<Finding> {
2833 let mut fixed = Vec::new();
2834 let mut refuted = Vec::new();
2835 let mut filed = Vec::new();
2836 let mut unresolved = Vec::new();
2837
2838 for source in blocking {
2839 let (index, d) = match matching_disposition(source, blocking, &response.dispositions) {
2840 Ok((index, disposition)) if !used[index] => (index, disposition),
2841 Ok(_) => {
2842 logwarn!(
2843 "'{}' has more than one matching disposition, so it stays open",
2844 source.title
2845 );
2846 unresolved.push(source.clone());
2847 continue;
2848 }
2849 Err(reason) => {
2850 logwarn!("'{}' has {reason}, so it stays open", source.title);
2851 unresolved.push(source.clone());
2852 continue;
2853 }
2854 };
2855 used[index] = true;
2856 let file = source.file.clone();
2857 let canonical = source.title.as_str();
2861 let title = style::title(canonical, &repo.style);
2862 let located_title = match file.trim() {
2863 "" => title.clone(),
2864 location => format!("{title} ({location})"),
2865 };
2866 let allow_stable_fallback = unique_stable_finding(blocking, source);
2867
2868 match d.action {
2869 Action::Refuted => {
2870 let reasoning = style::summary(&d.reasoning, &repo.style);
2871 settle(
2872 ledger,
2873 canonical,
2874 &file,
2875 allow_stable_fallback,
2876 LedgerEntry {
2877 title: canonical.to_string(),
2878 file: file.clone(),
2879 reasoning: reasoning.clone(),
2880 round,
2881 reraised: 0,
2882 outcome: Settled::Refuted,
2883 },
2884 );
2885 remember_dispute(
2886 state,
2887 Dispute {
2888 title: canonical.to_string(),
2889 file: file.clone(),
2890 reasoning: reasoning.clone(),
2891 },
2892 allow_stable_fallback,
2893 );
2894 forget_noted(state, source, allow_stable_fallback);
2895 refuted.push(format!("{located_title}. {reasoning}"));
2896 }
2897 Action::FiledIssue => {
2898 let new_title = d
2899 .new_issue_title
2900 .clone()
2901 .filter(|t| !t.trim().is_empty())
2902 .unwrap_or_else(|| d.title.clone());
2903 let new_body = d
2904 .new_issue_body
2905 .clone()
2906 .filter(|b| !b.trim().is_empty())
2907 .unwrap_or_else(|| d.reasoning.clone());
2908 let recorded = file_followup(repo, &new_title, &new_body, subject, cfg, state);
2909 if let Some(url) = recorded.url() {
2910 state.filed.push(url.to_string());
2911 filed.push(url.to_string());
2912 }
2913 let Some((outcome, reasoning)) =
2922 filed_entry(&recorded, &style::summary(&d.reasoning, &repo.style))
2923 else {
2924 logwarn!(
2925 "'{title}' was not recorded anywhere, so it stays open for the next round"
2926 );
2927 unresolved.push(source.clone());
2928 continue;
2929 };
2930 settle(
2931 ledger,
2932 canonical,
2933 &file,
2934 allow_stable_fallback,
2935 LedgerEntry {
2936 title: canonical.to_string(),
2937 file: file.clone(),
2938 reasoning,
2939 round,
2940 reraised: 0,
2941 outcome,
2942 },
2943 );
2944 if outcome == Settled::Dropped {
2945 remember_noted(state, source, allow_stable_fallback);
2946 } else {
2947 forget_noted(state, source, allow_stable_fallback);
2948 }
2949 forget_dispute(state, source, allow_stable_fallback);
2950 }
2951 Action::Fixed => {
2952 if fixed_disposition_resolves(committed) {
2967 settle(
2968 ledger,
2969 canonical,
2970 &file,
2971 allow_stable_fallback,
2972 LedgerEntry {
2973 title: canonical.to_string(),
2974 file: file.clone(),
2975 reasoning: style::summary(&d.reasoning, &repo.style),
2976 round,
2977 reraised: 0,
2978 outcome: Settled::Fixed,
2979 },
2980 );
2981 forget_noted(state, source, allow_stable_fallback);
2982 forget_dispute(state, source, allow_stable_fallback);
2983 fixed.push(located_title);
2984 } else {
2985 unresolved.push(source.clone());
2986 }
2987 }
2988 }
2989 }
2990
2991 if repo.style.pr_comments == PrComments::Rounds {
2992 let comment = disposition_comment(author, response, &fixed, &refuted, &filed, &repo.style);
2993 if let Some(text) = comment {
2994 if let Err(e) = repo.comment_pr(pr_number, &text) {
2995 logdim!("could not post the disposition comment: {e}");
2996 }
2997 }
2998 }
2999 unresolved
3000}
3001
3002fn filed_entry(recorded: &Followup, reasoning: &str) -> Option<(Settled, String)> {
3010 let (outcome, tail) = match recorded {
3011 Followup::Recorded(reference) => (
3012 Settled::Filed,
3013 format!("Tracked in {}.", as_reference(reference)),
3014 ),
3015 Followup::Covered(reference) => (
3016 Settled::Filed,
3017 format!("Already covered by {}.", as_reference(reference)),
3018 ),
3019 Followup::Dropped(why) => (Settled::Dropped, format!("Not filed anywhere: {why}.")),
3020 Followup::Failed => return None,
3021 };
3022 let reasoning = match reasoning.trim() {
3023 "" => tail,
3024 said => format!("{said} {tail}"),
3025 };
3026 Some((outcome, reasoning))
3027}
3028
3029fn external_followup_write_paused(destination: Followups, state: &IssueRun) -> bool {
3036 destination == Followups::Issues && state.followup_writes_uncertain
3037}
3038
3039fn failed_followup(state: &mut IssueRun, error: &SparError) -> Followup {
3040 if error.kind() == ErrorKind::UncertainWrite {
3041 state.followup_writes_uncertain = true;
3042 if !state
3043 .notes
3044 .iter()
3045 .any(|note| note.contains("external follow-up writes were paused"))
3046 {
3047 state.notes.push(
3048 "An external follow-up write could not be verified, so further external \
3049 follow-up writes were paused for this run. Inspect recent issues and comments \
3050 before trying them again."
3051 .to_string(),
3052 );
3053 }
3054 }
3055 Followup::Failed
3056}
3057
3058pub fn file_followup(
3069 repo: &Repo,
3070 title: &str,
3071 body: &str,
3072 source: i64,
3073 cfg: &Config,
3074 state: &mut IssueRun,
3075) -> Followup {
3076 if repo.followups == Followups::None {
3077 return Followup::Dropped("follow-ups are off for this repository");
3078 }
3079 if external_followup_write_paused(repo.followups, state) {
3080 logdim!(
3081 "not attempting another external follow-up write after an earlier result could not \
3082 be verified"
3083 );
3084 return Followup::Failed;
3085 }
3086 if state.filed.len() >= cfg.loop_cfg.max_followups {
3089 logwarn!(
3090 "already recorded {} follow-ups, not recording '{}'. Raise max_followups if you want \
3091 them all.",
3092 state.filed.len(),
3093 style::title(title, &repo.style)
3094 );
3095 return Followup::Dropped("this run had already recorded as many follow-ups as it may");
3096 }
3097 let title = match repo.clean_followup_title(title) {
3104 Ok(title) => title,
3105 Err(e) => {
3106 logdim!("could not clean a follow-up title: {e}");
3107 return Followup::Failed;
3108 }
3109 };
3110 if title.trim().is_empty() {
3111 logdim!("nothing left of a follow-up title after cleaning it");
3112 return Followup::Failed;
3113 }
3114 let body = format!(
3117 "{}\n\nFound while working on #{source}.",
3118 style::issue_body(body, &repo.style)
3119 );
3120
3121 if repo.followups == Followups::Local {
3122 return repo.append_local_followup(&title, &body);
3123 }
3124
3125 match file_as_issue(repo, &title, &body) {
3126 Ok(filed) => filed.into(),
3127 Err(e) => {
3128 logdim!("could not file a follow-up for '{title}': {e}");
3129 failed_followup(state, &e)
3130 }
3131 }
3132}
3133
3134#[derive(Debug, Clone)]
3136pub enum Filed {
3137 Opened(i64, String),
3139 AddedTo(i64, String),
3141 Covered(i64, String),
3143}
3144
3145impl From<Filed> for Followup {
3146 fn from(filed: Filed) -> Self {
3147 match filed {
3148 Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => {
3149 Followup::Recorded(url)
3150 }
3151 }
3152 }
3153}
3154
3155impl Filed {
3156 pub fn url(&self) -> Option<&str> {
3157 match self {
3158 Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => Some(url),
3159 }
3160 }
3161
3162 pub fn issue(&self) -> i64 {
3165 match self {
3166 Filed::Opened(n, _) | Filed::AddedTo(n, _) | Filed::Covered(n, _) => *n,
3167 }
3168 }
3169
3170 pub fn number(&self) -> Option<i64> {
3172 match self {
3173 Filed::Opened(n, _) | Filed::AddedTo(n, _) | Filed::Covered(n, _) => Some(*n),
3174 }
3175 }
3176
3177 pub fn note(&self) -> String {
3179 match self {
3180 Filed::Opened(n, _) => format!("#{n}"),
3181 Filed::AddedTo(n, _) => format!("added to #{n}"),
3182 Filed::Covered(n, _) => format!("#{n} already says this"),
3183 }
3184 }
3185
3186 pub fn describe(&self, title: &str) -> String {
3187 let title = style::clip(title.trim(), 80);
3188 match self {
3189 Filed::Opened(n, _) => format!("filed #{n}: {title}"),
3190 Filed::AddedTo(n, _) => format!("added to #{n}: {title}"),
3191 Filed::Covered(n, _) => format!("#{n} already says this: {title}"),
3192 }
3193 }
3194}
3195
3196pub fn file_as_issue(repo: &Repo, title: &str, body: &str) -> Result<Filed> {
3208 file_as_issue_apart_from(repo, title, body, None)
3209}
3210
3211pub fn file_as_issue_apart_from(
3217 repo: &Repo,
3218 title: &str,
3219 body: &str,
3220 apart_from: Option<i64>,
3221) -> Result<Filed> {
3222 let title = repo.record_failed_write(repo.clean_title(title))?;
3223 if title.trim().is_empty() {
3224 return repo.record_failed_write(Err(spar_err!(
3225 "nothing left of the title after cleaning it"
3226 )));
3227 }
3228 let issue_body = repo.record_failed_write(repo.clean_issue_body(body))?;
3229 let exact =
3230 repo.record_failed_write(repo.try_exact_issue_apart_from(&title, &issue_body, apart_from))?;
3231 let mut body_to_file = issue_body.clone();
3232 if let Some(existing) = exact {
3233 if existing.open {
3234 logdim!("'{title}' is already open as #{}", existing.number);
3235 return Ok(Filed::Covered(existing.number, existing.url));
3236 }
3237 logdim!(
3243 "'{title}' matches #{}, which is closed, so it is being filed again",
3244 existing.number
3245 );
3246 body_to_file = regression_body(&issue_body, existing.number);
3247 } else {
3248 let similar = repo.record_failed_write(repo.try_find_similar_issue_apart_from(
3249 &title,
3250 &issue_body,
3251 apart_from,
3252 ))?;
3253 if let Some(existing) = similar {
3254 let known = format!("{} {}", existing.title, existing.body);
3255 if crate::textsim::adds_information(&issue_body, &known) {
3256 logdim!(
3257 "'{title}' is covered by #{} and adds something, so it goes there as a \
3258 comment",
3259 existing.number
3260 );
3261 repo.comment_issue(existing.number, &issue_body)?;
3262 return Ok(Filed::AddedTo(existing.number, existing.url));
3263 }
3264 logdim!(
3265 "'{title}' is covered by #{} '{}', which already says it",
3266 existing.number,
3267 style::clip(&existing.title, 60)
3268 );
3269 return Ok(Filed::Covered(existing.number, existing.url));
3270 }
3271 }
3272 let url = repo.create_issue_apart_from(&title, &body_to_file, apart_from)?;
3273 let number = filed_issue_number(&url)
3274 .ok_or_else(|| spar_err!("filed an issue but could not read its number from {url}"))?;
3275 Ok(Filed::Opened(number, url))
3276}
3277
3278pub(crate) fn regression_body(body: &str, closed: i64) -> String {
3283 format!(
3284 "{}\n\nThis was filed before as #{closed}, which is closed. The evidence above is from \
3285 a later run, so it looks like it has come back.",
3286 body.trim()
3287 )
3288}
3289
3290fn file_out_of_scope(
3291 repo: &Repo,
3292 findings: &[Finding],
3293 subject: i64,
3294 state: &mut IssueRun,
3295 cfg: &Config,
3296) {
3297 for finding in findings.iter().filter(|f| !f.in_scope) {
3298 let body = issue_report(finding);
3299 let recorded = file_followup(repo, &finding.title, &body, subject, cfg, state);
3300 if finding.severity != Severity::Nit || matches!(recorded, Followup::Recorded(_)) {
3301 record_nonblocking_outcome_with_match(
3302 state,
3303 finding,
3304 Some(&recorded),
3305 unique_stable_finding(findings, finding),
3306 );
3307 }
3308 }
3309}
3310
3311pub fn issue_report(finding: &Finding) -> String {
3318 let sections = finding.report_sections();
3319 if sections.is_empty() {
3320 return finding.detail.clone();
3321 }
3322 let mut out: Vec<String> = sections
3323 .iter()
3324 .map(|(heading, text)| format!("## {heading}\n\n{text}"))
3325 .collect();
3326 if !finding.detail.trim().is_empty()
3329 && !sections
3330 .iter()
3331 .any(|(_, text)| crate::textsim::same_point(text, &finding.detail))
3332 {
3333 out.insert(0, finding.detail.trim().to_string());
3334 }
3335 out.join("\n\n")
3336}
3337
3338fn file_nonblocking(
3345 repo: &Repo,
3346 findings: &[Finding],
3347 subject: i64,
3348 state: &mut IssueRun,
3349 cfg: &Config,
3350) {
3351 for finding in findings {
3352 if !finding.in_scope || finding.severity == Severity::Blocking {
3353 continue;
3354 }
3355 let should_file = match finding.severity {
3356 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking,
3357 Severity::Nit => cfg.loop_cfg.file_nits,
3358 Severity::Blocking => false,
3359 };
3360 if !should_file {
3361 if finding.severity == Severity::NonBlocking {
3362 record_nonblocking_outcome_with_match(
3363 state,
3364 finding,
3365 None,
3366 unique_stable_finding(findings, finding),
3367 );
3368 }
3369 continue;
3370 }
3371 let recorded = file_followup(repo, &finding.title, &finding.detail, subject, cfg, state);
3372 if finding.severity == Severity::NonBlocking {
3373 record_nonblocking_outcome_with_match(
3374 state,
3375 finding,
3376 Some(&recorded),
3377 unique_stable_finding(findings, finding),
3378 );
3379 } else if let Some(url) = recorded.url() {
3380 state.filed.push(url.to_string());
3381 }
3382 }
3383}
3384
3385fn bullets(lines: &[String]) -> String {
3395 lines
3396 .iter()
3397 .map(|l| format!("- {l}"))
3398 .collect::<Vec<_>>()
3399 .join("\n")
3400}
3401
3402fn located(finding: &Finding, style: &Style) -> String {
3403 let title = style::title(&finding.title, style);
3404 match finding.where_at() {
3405 "general" => title,
3406 file => format!("{title} ({file})"),
3407 }
3408}
3409
3410pub enum Ending<'a> {
3412 Approved,
3414 OutOfRounds,
3417 Unchanged,
3420 Unresolved(&'a [Finding]),
3423 Deadlocked(&'a [Finding]),
3426}
3427
3428#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3429enum OutcomeSink {
3430 PullRequest,
3431 Terminal,
3432}
3433
3434fn outcome_sink(mode: PrComments) -> OutcomeSink {
3435 match mode {
3436 PrComments::Outcome | PrComments::Rounds => OutcomeSink::PullRequest,
3437 PrComments::None => OutcomeSink::Terminal,
3438 }
3439}
3440
3441fn emit_outcome(repo: &Repo, pr_number: i64, text: &str) {
3442 if outcome_sink(repo.style.pr_comments) == OutcomeSink::Terminal {
3443 println!("\n{text}\n");
3444 return;
3445 }
3446 if let Err(e) = repo.comment_pr(pr_number, text) {
3447 logdim!("could not post the outcome comment: {e}");
3448 println!("\n{text}\n");
3449 }
3450}
3451
3452pub fn post_outcome(
3464 repo: &Repo,
3465 pr_number: i64,
3466 state: &IssueRun,
3467 ledger: &Ledger,
3468 ending: Ending<'_>,
3469) {
3470 let Some(text) = outcome_comment(state, ledger, &ending, &repo.style) else {
3471 return;
3472 };
3473 emit_outcome(repo, pr_number, &text);
3474}
3475
3476fn post_unread_outcome(
3477 repo: &Repo,
3478 pr_number: i64,
3479 state: &IssueRun,
3480 ledger: &Ledger,
3481 open_findings: &[Finding],
3482) {
3483 let Some(text) = outcome_comment_with_unread(
3484 state,
3485 ledger,
3486 &Ending::OutOfRounds,
3487 open_findings,
3488 &repo.style,
3489 ) else {
3490 return;
3491 };
3492 emit_outcome(repo, pr_number, &text);
3493}
3494
3495fn settled_as(finding: &Finding, state: &IssueRun, ledger: &Ledger) -> Option<(Settled, String)> {
3498 if let Some(d) = state
3499 .disputes
3500 .iter()
3501 .find(|d| same_finding_parts(&d.title, &d.file, &finding.title, &finding.file))
3502 {
3503 if !d.reasoning.trim().is_empty() {
3504 return Some((Settled::Refuted, d.reasoning.clone()));
3505 }
3506 }
3507 matching_ledger_entry(ledger, &finding.title, &finding.file)
3508 .filter(|entry| !entry.reasoning.trim().is_empty())
3509 .map(|entry| (entry.outcome, entry.reasoning.clone()))
3510}
3511
3512pub fn filed_issue_number(filed: &str) -> Option<i64> {
3517 filed
3518 .rsplit('/')
3519 .next()
3520 .and_then(|tail| tail.parse::<i64>().ok())
3521 .filter(|n| *n > 0)
3522}
3523
3524fn as_reference(url: &str) -> String {
3525 match url.rsplit('/').next().and_then(|n| n.parse::<u64>().ok()) {
3526 Some(number) => format!("#{number}"),
3527 None => url.to_string(),
3528 }
3529}
3530
3531pub fn outcome_comment(
3532 state: &IssueRun,
3533 ledger: &Ledger,
3534 ending: &Ending<'_>,
3535 style: &Style,
3536) -> Option<String> {
3537 outcome_comment_with_unread(state, ledger, ending, &[], style)
3538}
3539
3540fn outcome_comment_with_unread(
3541 state: &IssueRun,
3542 ledger: &Ledger,
3543 ending: &Ending<'_>,
3544 unread_open: &[Finding],
3545 style: &Style,
3546) -> Option<String> {
3547 let mut out: Vec<String> = Vec::new();
3548 let mut already: Vec<(String, String)> = Vec::new();
3551
3552 match ending {
3553 Ending::Approved => {
3554 let criteria = criteria_rulings(state, style);
3555 if state.disputes.is_empty()
3556 && state.filed.is_empty()
3557 && state.noted.is_empty()
3558 && criteria.is_none()
3559 {
3560 return None;
3567 }
3568 out.push("Reviewed, nothing blocking a merge.".into());
3569 out.extend(criteria);
3570 }
3571 Ending::OutOfRounds => {
3572 out.push(
3573 "Not signed off: the last round of fixes was pushed but has not been reviewed."
3574 .into(),
3575 );
3576 if !unread_open.is_empty() {
3577 let lines: Vec<String> = unread_open
3578 .iter()
3579 .map(|finding| {
3580 already.push((finding.title.clone(), finding.file.clone()));
3581 format!(
3582 "{}. {}",
3583 located(finding, style),
3584 style::sentence(&finding.detail, style)
3585 )
3586 })
3587 .collect();
3588 out.push("These points were already open:".into());
3589 out.push(bullets(&lines));
3590 }
3591 }
3592 Ending::Unchanged => out.push(
3596 "Not signed off: the last round changed nothing, so the branch is the one that was \
3597 already reviewed."
3598 .into(),
3599 ),
3600 Ending::Unresolved(points) => {
3601 let lines: Vec<String> = points
3602 .iter()
3603 .map(|f| {
3604 already.push((f.title.clone(), f.file.clone()));
3605 format!(
3606 "{}. {}",
3607 located(f, style),
3608 style::sentence(&f.detail, style)
3609 )
3610 })
3611 .collect();
3612 out.push("Not signed off. These points are still open:".into());
3613 out.push(bullets(&lines));
3614 }
3615 Ending::Deadlocked(points) => {
3616 let lines: Vec<String> = points
3622 .iter()
3623 .map(|f| {
3624 let where_at = match f.where_at() {
3625 "general" => String::new(),
3626 file => format!(" ({file})"),
3627 };
3628 let title = style::title(&f.title, style);
3629 already.push((f.title.clone(), f.file.clone()));
3630 match settled_as(f, state, ledger) {
3631 Some((Settled::Refuted, reason)) => format!(
3632 "{title}{where_at}. Refuted as: {}",
3633 style::summary(&reason, style)
3634 ),
3635 Some((Settled::Filed, reason)) => format!(
3636 "{title}{where_at}. Filed as out of scope: {}",
3637 style::summary(&reason, style)
3638 ),
3639 Some((Settled::Dropped, reason)) => format!(
3642 "{title}{where_at}. Out of scope here, and not filed: {}",
3643 style::summary(&reason, style)
3644 ),
3645 Some((Settled::Fixed, reason)) => format!(
3651 "{title}{where_at}. Fixed and raised again. Recorded answer: {}",
3652 style::summary(&reason, style)
3653 ),
3654 None => format!("{title}{where_at}"),
3655 }
3656 })
3657 .collect();
3658 out.push("Needs your decision. The reviewers could not settle this:".into());
3659 out.push(bullets(&lines));
3660 }
3661 }
3662
3663 let disputes: Vec<&crate::model::Dispute> = state
3664 .disputes
3665 .iter()
3666 .filter(|d| {
3667 !already
3668 .iter()
3669 .any(|(title, file)| same_finding_parts(title, file, &d.title, &d.file))
3670 })
3671 .collect();
3672 if !disputes.is_empty() {
3673 let lines: Vec<String> = disputes
3676 .iter()
3677 .map(|d| {
3678 let title = style::title(&d.title, style);
3679 let title = if d.file.trim().is_empty() {
3680 title
3681 } else {
3682 format!("{title} ({})", d.file.trim())
3683 };
3684 format!("{}. {}", title, style::sentence(&d.reasoning, style))
3685 })
3686 .collect();
3687 out.push(format!("Raised and refuted:\n{}", bullets(&lines)));
3688 }
3689
3690 if !state.noted.is_empty() {
3691 let lines: Vec<String> = state
3695 .noted
3696 .iter()
3697 .filter(|f| {
3698 !already
3699 .iter()
3700 .any(|(title, file)| same_finding_parts(title, file, &f.title, &f.file))
3701 })
3702 .map(|f| located(f, style))
3703 .collect();
3704 if !lines.is_empty() {
3705 out.push(format!("Noted, not blocking:\n{}", bullets(&lines)));
3706 }
3707 }
3708
3709 if !state.filed.is_empty() {
3710 let refs: Vec<String> = state.filed.iter().map(|u| as_reference(u)).collect();
3711 out.push(format!("Filed separately: {}", refs.join(", ")));
3712 }
3713
3714 Some(out.join("\n\n"))
3715}
3716
3717fn criteria_rulings(state: &IssueRun, style: &Style) -> Option<String> {
3724 let shown: Vec<String> = state
3725 .criteria
3726 .iter()
3727 .filter(|c| !state.ticks_criteria || c.status != Some(Mark::Met))
3728 .map(|c| {
3729 let label = match c.status {
3730 Some(Mark::Met) => "Met",
3731 Some(Mark::NotMet) => "Not met",
3732 Some(Mark::NeedsPerson) => "Needs a person",
3733 None => "Not ruled on",
3734 };
3735 criterion_line(label, &c.text, Some(&c.reason), style)
3736 })
3737 .collect();
3738 if shown.is_empty() {
3739 return None;
3740 }
3741 let lead = if state.ticks_criteria {
3742 "Acceptance criteria not ruled met, left unchecked on the issue:"
3743 } else {
3744 "Acceptance criteria as the review ruled them. Nothing was ticked on the issue:"
3745 };
3746 Some(format!("{lead}\n{}", bullets(&shown)))
3747}
3748
3749#[allow(clippy::too_many_arguments)]
3758fn close_prompt(
3759 base: &str,
3760 number: i64,
3761 title: &str,
3762 context: &str,
3763 from: &str,
3764 landed: Option<&[String]>,
3765 ledger: &Ledger,
3766 open_findings: &[Finding],
3767 round: u32,
3768 criteria: &[Item],
3769) -> String {
3770 let landed = match landed {
3771 Some([]) => "\nNothing landed after the last round of review. What it asked for was \
3772 answered in words rather than in code, so the branch in front of you is the \
3773 branch that was already read.\n"
3774 .to_string(),
3775 Some(lines) => format!(
3776 "\nThis landed after the last round of review, and nobody has read it:\n{}\n\nRead it \
3777 first with `git diff {from}..HEAD`, then inspect the full branch with `git diff \
3778 {base}...HEAD`.\n",
3779 lines
3780 .iter()
3781 .map(|l| format!("- {l}"))
3782 .collect::<Vec<_>>()
3783 .join("\n")
3784 ),
3785 None => format!(
3786 "\nThe commits on this branch were rewritten after the last round of review, so the \
3787 harness cannot say which of them are new. Inspect the full branch with `git diff \
3788 {base}...HEAD`.\n"
3789 ),
3790 };
3791 fill(
3792 CLOSE_PROMPT,
3793 &[
3794 ("{number}", &number.to_string()),
3795 ("{title}", title),
3796 ("{context}", context),
3797 ("{criteria}", &criteria_to_rule(criteria)),
3798 ("{base}", base),
3799 ("{landed}", &landed),
3800 ("{open}", &open_findings_block(open_findings)),
3801 ("{answers}", &closing_answers(ledger, round)),
3802 ("{settled}", &settled_block(ledger)),
3803 ],
3804 )
3805}
3806
3807fn open_findings_block(findings: &[Finding]) -> String {
3808 if findings.is_empty() {
3809 return String::new();
3810 }
3811 format!(
3812 "\nThese blocking findings were left open by an earlier response. Recheck each one:\n{}\n\
3813 \nIf one still blocks, return it under the same title and file. Omission means you checked \
3814 it and found that it no longer blocks.\n",
3815 findings_for_prompt(findings)
3816 )
3817}
3818
3819fn closing_answers(ledger: &Ledger, round: u32) -> String {
3824 let lines = fixed_lines(ledger, round);
3825 if lines.is_empty() {
3826 return String::new();
3827 }
3828 format!(
3829 "\nThese points were raised on this pull request and the author says it fixed them. \
3830 Nobody has checked that:\n{}\n",
3831 lines.join("\n")
3832 )
3833}
3834
3835#[allow(clippy::too_many_arguments)]
3840fn review_prompt(
3841 base: &str,
3842 number: i64,
3843 title: &str,
3844 context: &str,
3845 ledger: &Ledger,
3846 open_findings: &[Finding],
3847 round: u32,
3848 last: u32,
3849 criteria: &[Item],
3850) -> String {
3851 fill(
3852 REVIEW_PROMPT,
3853 &[
3854 ("{base}", base),
3855 ("{number}", &number.to_string()),
3856 ("{title}", title),
3857 ("{context}", context),
3858 ("{criteria}", &criteria_to_rule(criteria)),
3859 ("{open}", &open_findings_block(open_findings)),
3860 ("{answers}", &answers_block(ledger, round)),
3861 ("{settled}", &settled_block(ledger)),
3862 ("{round}", &round_note(round, last)),
3863 ],
3864 )
3865}
3866
3867fn respond_prompt(
3870 number: i64,
3871 context: &str,
3872 blocking: &[Finding],
3873 findings: &[Finding],
3874) -> String {
3875 fill(
3876 RESPOND_PROMPT,
3877 &[
3878 ("{number}", &number.to_string()),
3879 ("{context}", context),
3880 ("{findings}", &findings_for_prompt(blocking)),
3881 ("{optional}", &optional_block(findings)),
3882 ],
3883 )
3884}
3885
3886fn implement_prompt(number: i64, title: &str, url: &str, body: &str, criteria: &[Item]) -> String {
3894 let criteria = if criteria.is_empty() {
3895 String::new()
3896 } else {
3897 format!(
3898 "\n\nThe issue's acceptance criteria, numbered:\n{}\n\nReport a status on each one \
3899 in criteria, by its number: done, not_done, or needs_person for anything the \
3900 repository cannot show, such as a run on a device. Give one line of reason. This is \
3901 your account and goes in the pull request body as yours. Only the reviewer's ruling \
3902 ticks a box on the issue.",
3903 numbered(criteria)
3904 )
3905 };
3906 fill(
3907 IMPLEMENT_PROMPT,
3908 &[
3909 ("{thread_rule}", THREAD_RULE),
3910 ("{number}", &number.to_string()),
3911 ("{title}", title),
3912 ("{url}", url),
3913 ("{criteria}", &criteria),
3914 ("{body}", body),
3915 ],
3916 )
3917}
3918
3919pub fn pr_body(
3929 issue: i64,
3930 work: &Implementation,
3931 checked: Option<&str>,
3932 criteria: &[Item],
3933 style: &Style,
3934) -> String {
3935 let mut parts = vec![format!("Closes #{issue}")];
3936
3937 for lead in [&work.summary, &work.problem] {
3938 let text = style::sentence(lead, style);
3939 if !text.is_empty() {
3940 parts.push(text);
3941 }
3942 }
3943 parts.extend(section("What changed", &work.changes, style));
3944 let mut testing = work.testing.clone();
3947 if let Some(command) = checked {
3948 testing.push(format!("`{command}` passed on this branch, run by spar."));
3949 }
3950 parts.extend(section("How to test", &testing, style));
3951 parts.extend(claimed_criteria(criteria, &work.criteria, style));
3952
3953 let notes = style::sentence(work.notes.as_deref().unwrap_or_default(), style);
3954 if !notes.is_empty() {
3955 parts.push(format!("## Notes\n\n{notes}"));
3956 }
3957
3958 style::body(&parts.join("\n\n"), style)
3959}
3960
3961fn claimed_criteria(
3966 criteria: &[Item],
3967 claims: &[CriterionStatus],
3968 style: &Style,
3969) -> Option<String> {
3970 if criteria.is_empty() {
3971 return None;
3972 }
3973 let lines: Vec<String> = criteria
3974 .iter()
3975 .enumerate()
3976 .map(|(at, item)| {
3977 let status = status_for(claims, at + 1);
3978 let label = match status.map(|s| s.status) {
3979 Some(Mark::Met) => "Done",
3980 Some(Mark::NotMet) => "Not done",
3981 Some(Mark::NeedsPerson) => "Needs a person",
3982 None => "No status given",
3983 };
3984 criterion_line(label, &item.text, status.map(|s| s.reason.as_str()), style)
3985 })
3986 .collect();
3987 Some(format!(
3988 "## Acceptance criteria\n\nThe author's account. A box on the issue is ticked only on \
3989 the review's ruling, after spar merges.\n\n{}",
3990 bullets(&lines)
3991 ))
3992}
3993
3994fn criterion_line(label: &str, text: &str, reason: Option<&str>, style: &Style) -> String {
3996 let stop = |text: String| {
3997 if text.is_empty() || text.ends_with(['.', '!', '?']) {
3998 text
3999 } else {
4000 text + "."
4001 }
4002 };
4003 let mut line = format!("{label}: {}", stop(style::one_line(text.trim())));
4004 let reason = stop(style::sentence(reason.unwrap_or_default(), style));
4005 if !reason.is_empty() {
4006 line.push(' ');
4007 line.push_str(&reason);
4008 }
4009 line
4010}
4011
4012fn numbered(criteria: &[Item]) -> String {
4014 criteria
4015 .iter()
4016 .enumerate()
4017 .map(|(at, item)| format!("{}. {}", at + 1, item.text.trim()))
4018 .collect::<Vec<_>>()
4019 .join("\n")
4020}
4021
4022fn criteria_to_rule(criteria: &[Item]) -> String {
4025 if criteria.is_empty() {
4026 return String::new();
4027 }
4028 format!(
4029 "\nThe issue's acceptance criteria, numbered:\n{}\n\nRule on each one in criteria, by its \
4030 number: met when you checked that the branch does it, not_met when it does not or you \
4031 could not confirm it, needs_person when the repository cannot show it, such as a run on \
4032 a device. Say what you checked. A criterion ruled met is ticked on the issue when spar \
4033 merges this pull request, so do not rule met on the author's word. A ruling is not a \
4034 finding: an unmet criterion holds nothing unless it is also a defect you would block \
4035 for.\n",
4036 numbered(criteria)
4037 )
4038}
4039
4040fn review_schema(criteria: &[Item]) -> serde_json::Value {
4043 if criteria.is_empty() {
4044 schema::review()
4045 } else {
4046 schema::with_criteria(schema::review(), schema::CriteriaVoice::Reviewer)
4047 }
4048}
4049
4050fn status_for(statuses: &[CriterionStatus], number: usize) -> Option<&CriterionStatus> {
4054 let answers: Vec<&CriterionStatus> = statuses
4055 .iter()
4056 .filter(|s| s.number as usize == number)
4057 .collect();
4058 answers
4059 .iter()
4060 .find(|s| s.status != Mark::Met)
4061 .or_else(|| answers.first())
4062 .copied()
4063}
4064
4065fn ruled(criteria: &[Item], rulings: &[CriterionStatus]) -> Vec<Ruled> {
4067 criteria
4068 .iter()
4069 .enumerate()
4070 .map(|(at, item)| {
4071 let status = status_for(rulings, at + 1);
4072 Ruled {
4073 raw: item.raw.clone(),
4074 line: item.line,
4075 text: item.text.trim().to_string(),
4076 status: status.map(|s| s.status),
4077 reason: status.map(|s| s.reason.clone()).unwrap_or_default(),
4078 }
4079 })
4080 .collect()
4081}
4082
4083fn tick_met(repo: &Repo, issue: i64, criteria: &[Ruled]) -> usize {
4089 let mut ticked = 0;
4090 for criterion in criteria.iter().filter(|c| c.status == Some(Mark::Met)) {
4091 let at = Some(criterion.line);
4092 if tracker::write(repo, issue, &criterion.raw, &tracker::Change::Tick, at) {
4093 ticked += 1;
4094 }
4095 }
4096 ticked
4097}
4098
4099fn section(heading: &str, lines: &[String], style: &Style) -> Option<String> {
4106 let items: Vec<String> = lines
4107 .iter()
4108 .map(|line| style::summary(line, style))
4109 .filter(|line| !line.is_empty())
4110 .collect();
4111 if items.is_empty() {
4112 return None;
4113 }
4114 Some(format!("## {heading}\n\n{}", bullets(&items)))
4115}
4116
4117pub fn from_commits(repo: &Repo, work_dir: &Path, base: &str) -> Implementation {
4125 Implementation {
4126 changes: repo.commit_subjects(work_dir, "HEAD", base),
4127 notes: Some(
4128 "The implement call failed after these commits were made, so this body is assembled \
4129 from their messages rather than written by their author. Read the diff."
4130 .to_string(),
4131 ),
4132 ..Implementation::default()
4133 }
4134}
4135
4136fn no_pr_note(work: &Implementation, style: &Style) -> String {
4143 let reason = style::sentence(&work.reason, style);
4144 if !reason.is_empty() {
4145 return reason;
4146 }
4147 if work.not_worth_doing {
4148 "Left alone after reading the code, with no reason given.".to_string()
4149 } else {
4150 "Nothing was committed, so there is nothing to review.".to_string()
4151 }
4152}
4153
4154pub fn review_comment(holder: &str, round: u32, review: &Review, style: &Style) -> String {
4158 let by = |severity: Severity| -> Vec<&Finding> {
4159 review
4160 .findings
4161 .iter()
4162 .filter(|f| f.severity == severity && f.in_scope)
4163 .collect()
4164 };
4165 let blocking = by(Severity::Blocking);
4166 let non_blocking = by(Severity::NonBlocking);
4167 let nits = by(Severity::Nit);
4168 let out_of_scope: Vec<&Finding> = review.findings.iter().filter(|f| !f.in_scope).collect();
4169
4170 let mut counts = Vec::new();
4171 if !blocking.is_empty() {
4172 counts.push(format!("{} blocking", blocking.len()));
4173 }
4174 if !non_blocking.is_empty() {
4175 counts.push(format!("{} non-blocking", non_blocking.len()));
4176 }
4177 if !nits.is_empty() {
4178 counts.push(format!("{} nit", nits.len()));
4179 }
4180 if !out_of_scope.is_empty() {
4181 counts.push(format!("{} out of scope", out_of_scope.len()));
4182 }
4183 let headline = if counts.is_empty() {
4184 "no findings".to_string()
4185 } else {
4186 counts.join(", ")
4187 };
4188
4189 let _ = (holder, round, headline);
4190 let mut out = Vec::new();
4191 let summary = style::summary(&review.summary, style);
4192 if !summary.is_empty() {
4193 out.push(summary);
4194 }
4195
4196 if !blocking.is_empty() {
4197 let lines: Vec<String> = blocking
4198 .iter()
4199 .map(|f| {
4200 let detail = style::detail(&f.detail, style);
4201 if detail.is_empty() {
4202 located(f, style)
4203 } else {
4204 format!("{}. {detail}", located(f, style))
4205 }
4206 })
4207 .collect();
4208 out.push(format!("blocking\n{}", bullets(&lines)));
4209 }
4210
4211 for (label, group) in [
4214 ("non-blocking", &non_blocking),
4215 ("nits", &nits),
4216 ("out of scope", &out_of_scope),
4217 ] {
4218 if group.is_empty() {
4219 continue;
4220 }
4221 let lines: Vec<String> = group.iter().map(|f| located(f, style)).collect();
4222 out.push(format!("{label}\n{}", bullets(&lines)));
4223 }
4224
4225 out.join("\n\n")
4226}
4227
4228pub fn disposition_comment(
4232 author: &str,
4233 response: &ResponseDoc,
4234 fixed: &[String],
4235 refuted: &[String],
4236 filed: &[String],
4237 style: &Style,
4238) -> Option<String> {
4239 if fixed.is_empty() && refuted.is_empty() && filed.is_empty() {
4240 return None;
4241 }
4242 let mut counts = Vec::new();
4243 if !fixed.is_empty() {
4244 counts.push(format!("{} fixed", fixed.len()));
4245 }
4246 if !refuted.is_empty() {
4247 counts.push(format!("{} refuted", refuted.len()));
4248 }
4249 if !filed.is_empty() {
4250 counts.push(format!("{} filed", filed.len()));
4251 }
4252
4253 let _ = (author, counts);
4254 let mut out = Vec::new();
4255 let summary = style::summary(&response.summary, style);
4256 if !summary.is_empty() {
4257 out.push(summary);
4258 }
4259 if !refuted.is_empty() {
4260 out.push(format!("refuted\n{}", bullets(refuted)));
4261 }
4262 if !fixed.is_empty() {
4263 out.push(format!("fixed\n{}", bullets(fixed)));
4264 }
4265 if !filed.is_empty() {
4266 out.push(format!("filed\n{}", bullets(filed)));
4267 }
4268 Some(out.join("\n\n"))
4269}
4270
4271pub fn skip_comment(item: &SkippedItem, style: &Style) -> String {
4279 let reasons = item
4280 .reasons
4281 .values()
4282 .map(|reason| style::sentence(reason, style));
4283 let lines = crate::textsim::dedupe_by(reasons, crate::textsim::same_reason);
4287 bullets(&lines)
4288}
4289
4290pub(crate) fn context_block(number: i64, url: &str, issue_text: &str, pr_body: &str) -> String {
4308 let mut out = String::new();
4309 let body = issue_text.trim();
4310 if !body.is_empty() {
4311 out.push_str(&format!("\nIssue #{number} as filed"));
4312 if !url.trim().is_empty() {
4313 out.push_str(&format!(", at {}", url.trim()));
4314 }
4315 out.push_str(&format!(
4316 ":\n\n{body}\n\nThat is the issue as filed, followed by any comments on it since, \
4317 oldest first. {}\n",
4318 THREAD_RULE.replace('\n', " ")
4319 ));
4320 }
4321 let claim = pr_body.trim();
4322 if !claim.is_empty() {
4323 out.push_str(&format!(
4324 "\nThe pull request description, which is the author's own account of this change. \
4325 It is a claim to check against the code, not a statement of fact:\n\n{claim}\n"
4326 ));
4327 }
4328 out
4329}
4330
4331pub(crate) fn optional_findings(findings: &[Finding]) -> Vec<Finding> {
4333 findings
4334 .iter()
4335 .filter(|f| f.in_scope && f.severity == Severity::NonBlocking)
4336 .cloned()
4337 .collect()
4338}
4339
4340fn optional_block(findings: &[Finding]) -> String {
4341 let optional = optional_findings(findings);
4342 if optional.is_empty() {
4343 return String::new();
4344 }
4345 format!("\n{OPTIONAL_HEADER}{}\n", findings_for_prompt(&optional))
4346}
4347
4348fn warn_unplaced_dispositions(response: &ResponseDoc, used: &[bool]) {
4366 for (index, disposition) in response.dispositions.iter().enumerate() {
4367 if !used[index] {
4368 logwarn!(
4369 "ignoring an unmatched or duplicate disposition for '{}' ({})",
4370 disposition.title,
4371 disposition.file
4372 );
4373 }
4374 }
4375}
4376
4377fn apply_optional_dispositions(
4378 repo: &Repo,
4379 cfg: &Config,
4380 response: &ResponseDoc,
4381 findings: &[Finding],
4382 state: &mut IssueRun,
4383 subject: i64,
4384 used: &mut [bool],
4385) {
4386 let optional = optional_findings(findings);
4387 if optional.is_empty() {
4388 return;
4389 }
4390 let mut fixed = 0usize;
4391 for finding in &optional {
4392 let Ok((index, disposition)) =
4393 matching_disposition(finding, &optional, &response.dispositions)
4394 else {
4395 continue;
4396 };
4397 if used[index] {
4400 continue;
4401 }
4402 used[index] = true;
4403 let unique = unique_stable_finding(&optional, finding);
4404 match disposition.action {
4405 Action::Fixed => {
4406 fixed += 1;
4407 forget_noted(state, finding, unique);
4408 }
4409 Action::FiledIssue => {
4410 let title = disposition
4411 .new_issue_title
4412 .clone()
4413 .filter(|t| !t.trim().is_empty())
4414 .unwrap_or_else(|| finding.title.clone());
4415 let body = disposition
4416 .new_issue_body
4417 .clone()
4418 .filter(|b| !b.trim().is_empty())
4419 .unwrap_or_else(|| finding.detail.clone());
4420 let recorded = file_followup(repo, &title, &body, subject, cfg, state);
4421 record_nonblocking_outcome_with_match(state, finding, Some(&recorded), unique);
4422 }
4423 Action::Refuted => {}
4424 }
4425 }
4426 if fixed > 0 {
4427 logdim!("the author also fixed {fixed} non-blocking finding(s)");
4428 }
4429}
4430
4431pub(crate) fn findings_for_prompt(findings: &[Finding]) -> String {
4434 if findings.is_empty() {
4435 return "(none)".to_string();
4436 }
4437 findings
4438 .iter()
4439 .map(|f| {
4440 let scope = if f.in_scope { "" } else { " [out of scope]" };
4441 format!(
4442 "- [{}]{scope} {} ({})\n {}",
4443 f.severity,
4444 f.title,
4445 f.where_at(),
4446 f.detail
4447 )
4448 })
4449 .collect::<Vec<_>>()
4450 .join("\n")
4451}
4452
4453#[cfg(test)]
4454mod tests {
4455 use super::*;
4456 use crate::model::Verdict;
4457
4458 fn style() -> Style {
4459 Style::default()
4460 }
4461
4462 fn finding(severity: &str, title: &str, detail: &str, file: &str, in_scope: bool) -> Finding {
4463 Finding {
4464 severity: Severity::parse_lenient(severity).unwrap(),
4465 title: title.into(),
4466 detail: detail.into(),
4467 file: file.into(),
4468 in_scope,
4469 ..Default::default()
4470 }
4471 }
4472
4473 fn review(summary: &str, findings: Vec<Finding>) -> Review {
4474 Review {
4475 verdict: Verdict::Approve,
4476 next_action: NextAction::Merge,
4477 summary: summary.into(),
4478 findings,
4479 criteria: Vec::new(),
4480 }
4481 }
4482
4483 fn disposition(title: &str, file: &str, action: Action) -> Disposition {
4484 Disposition {
4485 title: title.into(),
4486 file: file.into(),
4487 action,
4488 reasoning: "because".into(),
4489 new_issue_title: None,
4490 new_issue_body: None,
4491 }
4492 }
4493
4494 fn cfg_with(worktrees: bool, keep: bool) -> Config {
4497 let text = "[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n";
4498 let mut cfg = crate::config::parse(text).unwrap();
4499 cfg.loop_cfg.worktrees = worktrees;
4500 cfg.loop_cfg.keep_worktrees = keep;
4501 cfg
4502 }
4503
4504 #[test]
4505 fn a_worktree_is_released_on_every_finished_outcome() {
4506 let cfg = cfg_with(true, false);
4507 for status in [Status::Approved, Status::Merged, Status::Abandoned] {
4508 assert!(should_release(&cfg, status), "{status}");
4509 }
4510 }
4511
4512 #[test]
4515 fn a_worktree_is_kept_only_where_a_human_has_to_look() {
4516 let cfg = cfg_with(true, false);
4517 assert!(!should_release(&cfg, Status::Escalated));
4518 assert!(!should_release(&cfg, Status::Error));
4519 }
4520
4521 #[test]
4522 fn an_uncommitted_implementation_names_its_diagnostic_and_recovery_path() {
4523 let path = Path::new("/tmp/issue worktree");
4524 let err =
4525 uncommitted_implementation_error(path, Some("git add could not create index.lock"))
4526 .to_string();
4527 assert!(err.contains("could not create index.lock"), "{err}");
4528 assert!(err.contains("/tmp/issue worktree"), "{err}");
4529 assert!(err.contains("Commit or recover"), "{err}");
4530 assert!(!should_release(&cfg_with(true, false), Status::Error));
4531 }
4532
4533 #[test]
4534 fn the_keep_flag_overrides_everything() {
4535 assert!(!should_release(&cfg_with(true, true), Status::Approved));
4536 }
4537
4538 #[test]
4539 fn nothing_is_released_when_worktrees_are_off() {
4540 assert!(!should_release(&cfg_with(false, false), Status::Approved));
4541 }
4542
4543 #[test]
4548 fn fixing_your_own_findings_hands_the_pr_over() {
4549 let cfg = cfg_with(true, false);
4550 assert_eq!("a", next_reviewer(&cfg, "b", Some("b")));
4551 assert_eq!("b", next_reviewer(&cfg, "a", Some("a")));
4552 }
4553
4554 #[test]
4558 fn handing_back_keeps_the_reviewer_for_the_next_round() {
4559 let cfg = cfg_with(true, false);
4560 assert_eq!("b", next_reviewer(&cfg, "b", Some("a")));
4561 assert_eq!("a", next_reviewer(&cfg, "a", Some("b")));
4562 }
4563
4564 #[test]
4567 fn nobody_reviews_their_own_edit() {
4568 let cfg = cfg_with(true, false);
4569 let round_1 = cfg.other(&cfg.first_implementor);
4570 assert_eq!("b", round_1);
4571 for editor in ["a", "b"] {
4572 assert_ne!(editor, next_reviewer(&cfg, &round_1, Some(editor)));
4573 }
4574 }
4575
4576 #[test]
4581 fn a_fix_that_committed_nothing_leaves_the_pr_where_it_is() {
4582 let cfg = cfg_with(true, false);
4583 assert_eq!("b", next_reviewer(&cfg, "b", None));
4584 assert_eq!("a", next_reviewer(&cfg, "a", None));
4585 }
4586
4587 #[test]
4591 fn a_reviewer_that_wrote_the_head_gives_the_pr_up() {
4592 let cfg = cfg_with(true, false);
4593 assert_eq!("a", next_reviewer(&cfg, "b", Some("b")));
4594 }
4595
4596 #[test]
4600 fn a_review_that_wrote_cannot_approve_what_is_left() {
4601 assert!(!approval_stands(&[], true));
4602 }
4603
4604 #[test]
4605 fn a_clean_review_of_an_untouched_branch_approves() {
4606 assert!(approval_stands(&[], false));
4607 }
4608
4609 #[test]
4610 fn a_blocking_finding_never_approves() {
4611 let blocking = vec![finding("blocking", "Broken", "detail", "src/x.rs", true)];
4612 assert!(!approval_stands(&blocking, false));
4613 }
4614
4615 #[test]
4616 fn approval_refuses_a_head_that_changed_after_review() {
4617 assert!(ensure_reviewed_head(36, "abc123", "abc123").is_ok());
4618 let error = ensure_reviewed_head(36, "abc123", "def456").unwrap_err();
4619 assert!(error.to_string().contains("unread head"));
4620 }
4621
4622 #[test]
4624 fn only_a_moved_head_counts_as_a_commit() {
4625 let before = Snapshot {
4626 head: "abc".into(),
4627 dirty: false,
4628 };
4629 assert!(!Snapshot {
4630 head: "abc".into(),
4631 dirty: true,
4632 }
4633 .landed_over(&before));
4634 assert!(Snapshot {
4635 head: "def".into(),
4636 dirty: false,
4637 }
4638 .landed_over(&before));
4639 assert!(!Snapshot {
4641 head: String::new(),
4642 dirty: false,
4643 }
4644 .landed_over(&before));
4645 }
4646
4647 #[test]
4651 fn a_fresh_run_starts_at_one() {
4652 assert_eq!((1, 3), round_window(1, 3));
4653 assert_eq!((1, 5), round_window(1, 5));
4654 }
4655
4656 #[test]
4660 fn a_resumed_run_gets_a_full_fresh_budget() {
4661 assert_eq!((6, 10), round_window(6, 5));
4662 assert_eq!((11, 13), round_window(11, 3));
4663 }
4664
4665 #[test]
4666 fn a_budget_of_one_is_a_single_round() {
4667 assert_eq!((6, 6), round_window(6, 1));
4668 }
4669
4670 #[test]
4671 fn round_numbers_keep_counting_across_sessions() {
4672 let mut start = 1;
4674 let mut seen = Vec::new();
4675 for _ in 0..3 {
4676 let (first, last) = round_window(start, 3);
4677 seen.push((first, last));
4678 start = last + 1;
4679 }
4680 assert_eq!(vec![(1, 3), (4, 6), (7, 9)], seen);
4681 }
4682
4683 fn ledger_with(title: &str, file: &str) -> Ledger {
4686 let mut ledger = Ledger::new();
4687 ledger.insert(
4688 finding_key(title, file),
4689 LedgerEntry {
4690 title: title.into(),
4691 file: file.into(),
4692 reasoning: "no".into(),
4693 round: 1,
4694 reraised: 0,
4695 outcome: Settled::Refuted,
4696 },
4697 );
4698 ledger
4699 }
4700
4701 #[test]
4702 fn a_point_refuted_and_re_raised_twice_escalates() {
4703 let mut ledger = ledger_with("nit about naming", "a.rs");
4704 let mut state = IssueRun::new(1, "t");
4705 let blocking = vec![finding("blocking", "nit about naming", "d", "a.rs", true)];
4706 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
4707 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
4708 }
4709
4710 #[test]
4714 fn a_point_fixed_twice_and_raised_again_escalates() {
4715 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
4716 for entry in ledger.values_mut() {
4717 entry.outcome = Settled::Fixed;
4718 }
4719 let mut state = IssueRun::new(1, "t");
4720 let blocking = [finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
4721
4722 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
4723 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
4724 }
4725
4726 #[test]
4729 fn a_fix_that_missed_twice_is_not_reported_as_a_refutation() {
4730 assert!(why_escalated(Settled::Fixed).contains("fixed twice"));
4731 for outcome in [Settled::Refuted, Settled::Filed, Settled::Dropped] {
4732 assert!(why_escalated(outcome).contains("settled"), "{outcome}");
4733 }
4734 }
4735
4736 #[test]
4741 fn a_reviewer_that_fixes_its_own_findings_records_them_too() {
4742 let mut ledger = Ledger::new();
4743 let blocking = vec![finding(
4744 "blocking",
4745 "Unbounded loop",
4746 "spins",
4747 "src/x.rs",
4748 true,
4749 )];
4750 let mut state = IssueRun::new(1, "t");
4751
4752 record_own_fixes(&blocking, &mut ledger, &mut state, 1);
4753
4754 let entry = ledger
4755 .get(&finding_key("Unbounded loop", "src/x.rs"))
4756 .expect("keyed where the next round will look");
4757 assert_eq!(Settled::Fixed, entry.outcome);
4758 assert_eq!(
4759 "a committed change was made for this point",
4760 entry.reasoning
4761 );
4762
4763 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
4765 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
4766 }
4767
4768 #[test]
4777 fn a_review_that_contradicts_itself_says_so_and_the_findings_win() {
4778 let blocker = finding("blocking", "Retry loop spins", "spins", "src/net.rs", true);
4779 let approve_with_blocker = Review {
4780 verdict: Verdict::Approve,
4781 next_action: NextAction::HandBack,
4782 summary: "one blocker".into(),
4783 findings: vec![blocker],
4784 criteria: Vec::new(),
4785 };
4786 let said = verdict_disagreement(&approve_with_blocker, 1).expect("noticed");
4787 assert!(said.contains("approve"), "{said}");
4788 assert!(said.contains("findings decide"), "{said}");
4789
4790 let changes_but_merge = Review {
4791 verdict: Verdict::ChangesRequested,
4792 next_action: NextAction::Merge,
4793 summary: "nothing blocking".into(),
4794 findings: vec![],
4795 criteria: Vec::new(),
4796 };
4797 assert!(verdict_disagreement(&changes_but_merge, 0).is_some());
4798
4799 let clean = Review {
4801 verdict: Verdict::Approve,
4802 next_action: NextAction::Merge,
4803 summary: "good".into(),
4804 findings: vec![],
4805 criteria: Vec::new(),
4806 };
4807 assert!(verdict_disagreement(&clean, 0).is_none());
4808 let blocked = Review {
4809 verdict: Verdict::ChangesRequested,
4810 next_action: NextAction::HandBack,
4811 summary: "one blocker".into(),
4812 findings: vec![],
4813 criteria: Vec::new(),
4814 };
4815 assert!(verdict_disagreement(&blocked, 1).is_none());
4816 }
4817
4818 #[test]
4826 fn a_finding_the_fix_call_declined_stays_open() {
4827 let blocking = vec![
4828 finding("blocking", "Unbounded loop", "spins", "src/x.rs:88", true),
4829 finding("blocking", "Missing guard", "panics", "src/y.rs:12", true),
4830 ];
4831 let fixes = vec![
4832 OwnFix {
4833 title: "Unbounded loop".into(),
4834 file: "src/x.rs:88".into(),
4835 fixed: true,
4836 reason: "bounded it on max_attempts".into(),
4837 },
4838 OwnFix {
4839 title: "Missing guard".into(),
4840 file: "src/y.rs:12".into(),
4841 fixed: false,
4842 reason: "the guard belongs in the caller, which this branch does not touch".into(),
4843 },
4844 ];
4845
4846 let (claimed, left) = split_own_fixes(&blocking, &fixes);
4847
4848 assert_eq!(1, claimed.len());
4849 assert_eq!("Unbounded loop", claimed[0].title);
4850 assert_eq!(1, left.len());
4851 assert_eq!("Missing guard", left[0].title);
4852 assert!(
4853 left[0].detail.contains("belongs in the caller"),
4854 "the author is not told why it was left: {}",
4855 left[0].detail
4856 );
4857
4858 let mut ledger = Ledger::new();
4860 let mut state = IssueRun::new(1, "t");
4861 record_own_fixes(&claimed, &mut ledger, &mut state, 1);
4862 assert!(ledger.contains_key(&finding_key("Unbounded loop", "src/x.rs:88")));
4863 assert!(
4864 !ledger.contains_key(&finding_key("Missing guard", "src/y.rs:12")),
4865 "a point nobody fixed was recorded as fixed"
4866 );
4867 }
4868
4869 #[test]
4871 fn a_finding_the_fix_report_never_mentions_stays_open() {
4872 let blocking = vec![finding(
4873 "blocking",
4874 "Unbounded loop",
4875 "spins",
4876 "src/x.rs:88",
4877 true,
4878 )];
4879 let (claimed, left) = split_own_fixes(&blocking, &[]);
4880 assert!(claimed.is_empty());
4881 assert_eq!(1, left.len());
4882 }
4883
4884 #[test]
4888 fn a_fixed_point_is_not_in_the_settled_block() {
4889 let mut ledger = ledger_with("refuted point", "a.rs");
4890 ledger.extend(ledger_with("fixed point", "b.rs"));
4891 for entry in ledger.values_mut() {
4892 if entry.title == "fixed point" {
4893 entry.outcome = Settled::Fixed;
4894 }
4895 }
4896 let block = settled_block(&ledger);
4897 assert!(block.contains("refuted point"));
4898 assert!(!block.contains("fixed point"));
4899 }
4900
4901 #[test]
4904 fn a_ledger_of_only_fixes_says_nothing_is_settled() {
4905 let mut ledger = ledger_with("fixed point", "b.rs");
4906 for entry in ledger.values_mut() {
4907 entry.outcome = Settled::Fixed;
4908 }
4909 assert_eq!("", settled_block(&ledger));
4910 }
4911
4912 #[test]
4917 fn one_review_spends_one_re_raise_however_often_it_says_it() {
4918 let mut ledger = ledger_with("Missing error handling", "src/net.rs");
4919 let mut state = IssueRun::new(1, "t");
4920 let twice = vec![
4921 finding(
4922 "blocking",
4923 "Missing error handling",
4924 "d",
4925 "src/net.rs",
4926 true,
4927 ),
4928 finding(
4929 "blocking",
4930 "Missing error handling",
4931 "e",
4932 "src/net.rs",
4933 true,
4934 ),
4935 ];
4936
4937 assert!(!check_relitigation(&mut ledger, &twice, &mut state));
4938 assert_eq!(1, ledger.values().next().unwrap().reraised);
4939 assert!(check_relitigation(&mut ledger, &twice, &mut state));
4940 }
4941
4942 #[test]
4943 fn an_untracked_finding_does_not_escalate() {
4944 let mut state = IssueRun::new(1, "t");
4945 let blocking = vec![finding("blocking", "brand new", "d", "a.rs", true)];
4946 assert!(!check_relitigation(
4947 &mut Ledger::new(),
4948 &blocking,
4949 &mut state
4950 ));
4951 }
4952
4953 #[test]
4954 fn persisted_ledger_entries_are_rekeyed_for_stable_locations() {
4955 let mut ledger = Ledger::new();
4956 ledger.insert(
4957 "legacy-key".into(),
4958 LedgerEntry {
4959 title: "Unbounded loop".into(),
4960 file: "src/x.rs:88".into(),
4961 reasoning: "bounded by the caller".into(),
4962 round: 2,
4963 reraised: 1,
4964 outcome: Settled::Refuted,
4965 },
4966 );
4967 normalise_ledger_keys(&mut ledger);
4968 let key = matching_ledger_key(&ledger, "Unbounded loop", "src/x.rs:91").unwrap();
4969 assert_eq!(1, ledger[&key].reraised);
4970 }
4971
4972 #[test]
4973 fn same_title_at_two_sites_keeps_both_blockers() {
4974 let findings = vec![
4975 finding(
4976 "blocking",
4977 "Unchecked error",
4978 "first site",
4979 "src/net.rs:10",
4980 true,
4981 ),
4982 finding(
4983 "blocking",
4984 "Unchecked error",
4985 "second site",
4986 "src/net.rs:200",
4987 true,
4988 ),
4989 ];
4990
4991 let blocking = blocking_findings(&findings);
4992 assert_eq!(2, blocking.len());
4993 assert_eq!("src/net.rs:10", blocking[0].file);
4994 assert_eq!("src/net.rs:200", blocking[1].file);
4995 }
4996
4997 #[test]
4998 fn moved_location_fallback_refuses_an_ambiguous_ledger() {
4999 let mut ledger = ledger_with("Unchecked error", "src/net.rs:10");
5000 ledger.extend(ledger_with("Unchecked error", "src/net.rs:200"));
5001 assert!(matching_ledger_key(&ledger, "Unchecked error", "src/net.rs:30").is_none());
5002 }
5003
5004 #[test]
5005 fn two_current_sites_do_not_relocate_one_old_ledger_entry() {
5006 let mut ledger = ledger_with("Unchecked error", "src/net.rs:5");
5007 let blocking = vec![
5008 finding(
5009 "blocking",
5010 "Unchecked error",
5011 "first",
5012 "src/net.rs:10",
5013 true,
5014 ),
5015 finding(
5016 "blocking",
5017 "Unchecked error",
5018 "second",
5019 "src/net.rs:200",
5020 true,
5021 ),
5022 ];
5023 let mut state = IssueRun::new(1, "t");
5024 record_own_fixes(&blocking, &mut ledger, &mut state, 2);
5025 assert!(ledger.contains_key(&finding_key("Unchecked error", "src/net.rs:10")));
5026 assert!(ledger.contains_key(&finding_key("Unchecked error", "src/net.rs:200")));
5027 }
5028
5029 #[test]
5030 fn two_current_sites_remain_two_open_findings() {
5031 let current = vec![
5032 finding(
5033 "blocking",
5034 "Unchecked error",
5035 "first",
5036 "src/net.rs:10",
5037 true,
5038 ),
5039 finding(
5040 "blocking",
5041 "Unchecked error",
5042 "second",
5043 "src/net.rs:200",
5044 true,
5045 ),
5046 ];
5047 let mut open = Vec::new();
5048
5049 extend_findings(&mut open, ¤t);
5050
5051 assert_eq!(2, open.len());
5052 assert_eq!("src/net.rs:10", open[0].file);
5053 assert_eq!("src/net.rs:200", open[1].file);
5054 }
5055
5056 #[test]
5057 fn display_limits_do_not_change_persisted_finding_identity() {
5058 let point = finding(
5059 "blocking",
5060 "abcdefghij",
5061 "still wrong",
5062 "src/net.rs:10",
5063 true,
5064 );
5065 let mut ledger = Ledger::new();
5066 let mut state = IssueRun::new(1, "t");
5067 record_own_fixes(std::slice::from_ref(&point), &mut ledger, &mut state, 1);
5068 normalise_ledger_keys(&mut ledger);
5069
5070 let key = matching_ledger_key(&ledger, "abcdefghij", "src/net.rs:12").unwrap();
5071 assert_eq!("abcdefghij", ledger[&key].title);
5072 }
5073
5074 #[test]
5075 fn a_clipped_legacy_entry_keeps_its_original_lookup_key() {
5076 let mut ledger = Ledger::new();
5077 let key = crate::jsonx::finding_key("abcdefghij", "src/net.rs:10");
5078 ledger.insert(
5079 key.clone(),
5080 LedgerEntry {
5081 title: "abcde".into(),
5082 file: "src/net.rs:10".into(),
5083 reasoning: "bounded by the caller".into(),
5084 round: 1,
5085 reraised: 1,
5086 outcome: Settled::Refuted,
5087 },
5088 );
5089 normalise_ledger_keys(&mut ledger);
5090
5091 assert_eq!(
5092 Some(key),
5093 matching_ledger_key(&ledger, "abcdefghij", "src/net.rs:12")
5094 );
5095 }
5096
5097 #[test]
5098 fn a_legacy_key_collision_does_not_merge_case_distinct_paths() {
5099 let mut ledger = Ledger::new();
5100 let key = crate::jsonx::finding_key("Unchecked error", "src/Main.rs:10");
5101 ledger.insert(
5102 key.clone(),
5103 LedgerEntry {
5104 title: "Unchecked error".into(),
5105 file: "src/Main.rs:10".into(),
5106 reasoning: "bounded by the caller".into(),
5107 round: 1,
5108 reraised: 0,
5109 outcome: Settled::Refuted,
5110 },
5111 );
5112
5113 assert_eq!(
5114 Some(key),
5115 matching_ledger_key(&ledger, "Unchecked error", "src/Main.rs:10")
5116 );
5117 assert!(matching_ledger_key(&ledger, "Unchecked error", "src/main.rs:10").is_none());
5118 }
5119
5120 #[test]
5124 fn a_refutation_lands_on_the_key_the_next_round_will_look_up() {
5125 let blocking = [finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
5126 let recorded = finding_key(&blocking[0].title, &blocking[0].file);
5127 let answer = disposition("unbounded loop!", "src/x.rs", Action::Refuted);
5128 assert!(answer_matches(&blocking[0], &answer));
5129 assert_eq!(recorded, finding_key(&answer.title, &answer.file));
5130 }
5131
5132 #[test]
5135 fn the_ledger_key_ignores_title_punctuation() {
5136 let findings = [finding(
5137 "blocking",
5138 "Panic on multi-byte input",
5139 "d",
5140 "src/style.rs",
5141 true,
5142 )];
5143 let reworded = "Panic on multibyte input";
5144 let source = &findings[0];
5145 assert_eq!(
5146 finding_key(reworded, &source.file),
5147 finding_key(&source.title, &source.file)
5148 );
5149 let recorded = finding_key(&source.title, &source.file);
5150 let looked_up = finding_key(&findings[0].title, &findings[0].file);
5151 assert_eq!(recorded, looked_up);
5152 }
5153
5154 #[test]
5155 fn a_disposition_matches_its_finding_despite_wording_noise() {
5156 let findings = [finding(
5157 "blocking",
5158 "Unbounded loop!",
5159 "d",
5160 "src/x.rs",
5161 true,
5162 )];
5163 assert!(answer_matches(
5164 &findings[0],
5165 &disposition("unbounded loop", "src/x.rs", Action::Refuted)
5166 ));
5167 assert!(!answer_matches(
5168 &findings[0],
5169 &disposition("something else", "src/x.rs", Action::Refuted)
5170 ));
5171 assert!(!answer_matches(
5172 &findings[0],
5173 &disposition("unbounded loop", "src/y.rs", Action::Refuted)
5174 ));
5175 }
5176
5177 #[test]
5178 fn a_disposition_matches_its_finding_despite_a_severity_tag() {
5179 let blocker = finding(
5180 "blocking",
5181 "Refused requests can corrupt the active splice",
5182 "d",
5183 "src/lightning/channel/channel.ts",
5184 true,
5185 );
5186 let answers = [disposition(
5187 "[blocking] Refused requests can corrupt the active splice",
5188 "src/lightning/channel/channel.ts",
5189 Action::Fixed,
5190 )];
5191
5192 assert_eq!(
5193 0,
5194 matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers)
5195 .expect("tagged answer")
5196 .0
5197 );
5198 }
5199
5200 #[test]
5201 fn a_bracketed_subject_still_separates_two_findings() {
5202 let ios = finding("blocking", "[iOS] Startup crash", "d", "src/app.ts", true);
5203 let android = finding(
5204 "blocking",
5205 "[Android] Startup crash",
5206 "d",
5207 "src/app.ts",
5208 true,
5209 );
5210 let findings = [ios.clone(), android];
5211 let answers = [disposition(
5212 "[Android] Startup crash",
5213 "src/app.ts",
5214 Action::Fixed,
5215 )];
5216
5217 assert!(matches!(
5218 matching_disposition(&ios, &findings, &answers),
5219 Err("no matching disposition")
5220 ));
5221 }
5222
5223 #[test]
5229 fn an_answer_to_an_optional_finding_is_placed_by_the_optional_pass() {
5230 let blocker = finding("blocking", "Unbounded loop", "d", "src/x.rs", true);
5231 let optional = finding(
5232 "non-blocking",
5233 "Storage failures are reported as a successful state update",
5234 "d",
5235 "src/wallet/index.ts",
5236 true,
5237 );
5238 let answers = [
5239 disposition("Unbounded loop", "src/x.rs", Action::Fixed),
5240 disposition(
5241 "Storage failures are reported as a successful state update",
5242 "src/wallet/index.ts",
5243 Action::Refuted,
5244 ),
5245 ];
5246 let mut used = vec![false; answers.len()];
5247
5248 let blocking = [blocker];
5249 let (index, _) = matching_disposition(&blocking[0], &blocking, &answers).unwrap();
5250 used[index] = true;
5251
5252 let optionals = [optional];
5253 let (index, _) = matching_disposition(&optionals[0], &optionals, &answers).unwrap();
5254 assert!(!used[index], "the blocking pass must not have taken it");
5255 used[index] = true;
5256
5257 assert!(used.iter().all(|placed| *placed), "both were placed");
5258 }
5259
5260 #[test]
5263 fn an_answer_to_a_point_nobody_raised_stays_unplaced() {
5264 let blocking = [finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
5265 let answers = [
5266 disposition("Unbounded loop", "src/x.rs", Action::Fixed),
5267 disposition("Something nobody said", "src/z.rs", Action::Refuted),
5268 ];
5269 let mut used = vec![false; answers.len()];
5270
5271 let (index, _) = matching_disposition(&blocking[0], &blocking, &answers).unwrap();
5272 used[index] = true;
5273
5274 let optionals = optional_findings(&blocking);
5275 assert!(optionals.is_empty());
5276
5277 assert_eq!(vec![false], vec![used[1]], "the stray answer is unplaced");
5278 }
5279
5280 #[test]
5281 fn an_omitted_disposition_leaves_the_blocker_unmatched() {
5282 let blocker = finding("blocking", "Unbounded loop", "d", "src/x.rs", true);
5283 assert!(matches!(
5284 matching_disposition(
5285 &blocker,
5286 std::slice::from_ref(&blocker),
5287 &[] as &[Disposition]
5288 ),
5289 Err("no matching disposition")
5290 ));
5291 }
5292
5293 #[test]
5294 fn duplicate_dispositions_are_ambiguous() {
5295 let blocker = finding("blocking", "Unbounded loop", "d", "src/x.rs", true);
5296 let answers = vec![
5297 disposition("Unbounded loop", "src/x.rs", Action::Fixed),
5298 disposition("Unbounded loop", "src/x.rs", Action::Refuted),
5299 ];
5300 assert!(matches!(
5301 matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers),
5302 Err("more than one matching disposition")
5303 ));
5304 }
5305
5306 #[test]
5307 fn an_unambiguous_disposition_may_omit_the_line_number() {
5308 let blocker = finding(
5309 "blocking",
5310 "Replayed offers persist the wrong lane key",
5311 "d",
5312 "src/engine.rs:1166",
5313 true,
5314 );
5315 let answers = [disposition(
5316 "replayed offers persist the wrong lane key!",
5317 "src/engine.rs",
5318 Action::Fixed,
5319 )];
5320
5321 assert_eq!(
5322 0,
5323 matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers)
5324 .expect("stable answer")
5325 .0
5326 );
5327 }
5328
5329 #[test]
5330 fn a_line_free_disposition_does_not_choose_between_two_sites() {
5331 let first = finding("blocking", "Unchecked error", "d", "src/engine.rs:10", true);
5332 let second = finding(
5333 "blocking",
5334 "Unchecked error",
5335 "d",
5336 "src/engine.rs:200",
5337 true,
5338 );
5339 let findings = [first.clone(), second];
5340 let answers = [disposition(
5341 "Unchecked error",
5342 "src/engine.rs",
5343 Action::Fixed,
5344 )];
5345
5346 assert!(matches!(
5347 matching_disposition(&first, &findings, &answers),
5348 Err("no matching disposition")
5349 ));
5350 }
5351
5352 #[test]
5353 fn an_exact_location_wins_over_an_ambiguous_fallback() {
5354 let blocker = finding("blocking", "Unchecked error", "d", "src/engine.rs:10", true);
5355 let answers = [
5356 disposition("Unchecked error", "src/engine.rs", Action::Refuted),
5357 disposition("Unchecked error", "src/engine.rs:10", Action::Fixed),
5358 ];
5359
5360 let matched = matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers)
5361 .expect("exact answer");
5362 assert_eq!(1, matched.0);
5363 assert_eq!(Action::Fixed, matched.1.action);
5364 }
5365
5366 #[test]
5367 fn same_titled_findings_in_different_files_need_separate_dispositions() {
5368 let findings = [
5369 finding("blocking", "Unchecked error", "d", "src/a.rs", true),
5370 finding("blocking", "Unchecked error", "d", "src/b.rs", true),
5371 ];
5372 let answers = vec![
5373 disposition("Unchecked error", "src/a.rs", Action::Fixed),
5374 disposition("Unchecked error", "src/b.rs", Action::Refuted),
5375 ];
5376 assert_eq!(
5377 0,
5378 matching_disposition(&findings[0], &findings, &answers)
5379 .expect("left answer")
5380 .0
5381 );
5382 assert_eq!(
5383 1,
5384 matching_disposition(&findings[1], &findings, &answers)
5385 .expect("right answer")
5386 .0
5387 );
5388 }
5389
5390 #[test]
5391 fn a_reported_fix_without_a_commit_stays_open() {
5392 assert!(!fixed_disposition_resolves(false));
5393 assert!(fixed_disposition_resolves(true));
5394 }
5395
5396 #[test]
5397 fn the_settled_block_is_empty_when_nothing_is_settled() {
5398 assert_eq!("", settled_block(&Ledger::new()));
5399 }
5400
5401 #[test]
5402 fn the_settled_block_names_each_refutation() {
5403 let block = settled_block(&ledger_with("a point", "x.rs"));
5404 assert!(block.contains("a point"));
5405 assert!(block.contains("x.rs"));
5406 assert!(block.contains("settled"));
5407 }
5408
5409 #[test]
5410 fn same_title_settlements_name_each_location() {
5411 let mut ledger = ledger_with("Unchecked error", "a.rs:10");
5412 ledger.extend(ledger_with("Unchecked error", "b.rs:20"));
5413
5414 let block = settled_block(&ledger);
5415
5416 assert!(block.contains("Unchecked error (a.rs:10)"), "{block}");
5417 assert!(block.contains("Unchecked error (b.rs:20)"), "{block}");
5418 }
5419
5420 #[test]
5424 fn a_filed_point_is_settled_too() {
5425 let mut ledger = ledger_with("out of scope", "x.rs");
5426 for entry in ledger.values_mut() {
5427 entry.outcome = Settled::Filed;
5428 entry.reasoning = "Tracked in #9.".into();
5429 }
5430 let block = settled_block(&ledger);
5431 assert!(block.contains("out of scope"));
5432 assert!(block.contains("#9"));
5433 }
5434
5435 #[test]
5438 fn answering_a_point_again_keeps_its_re_raise_count() {
5439 let mut ledger = ledger_with("a point", "x.rs");
5440 let entry = ledger.values().next().unwrap().clone();
5441 let mut state = IssueRun::new(1, "t");
5442 let blocking = vec![finding("blocking", "a point", "d", "x.rs", true)];
5443
5444 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
5445 settle(&mut ledger, "a point", "x.rs", true, entry);
5446 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
5447 }
5448
5449 #[test]
5452 fn a_clean_review_is_just_the_verdict() {
5455 let text = review_comment("codex", 1, &review("Looks correct.", vec![]), &style());
5456 assert_eq!("Looks correct.", text);
5457 }
5458
5459 #[test]
5460 fn a_review_leads_with_the_counts() {
5461 let text = review_comment(
5462 "codex",
5463 2,
5464 &review(
5465 "One real problem.",
5466 vec![
5467 finding(
5468 "blocking",
5469 "Loop never terminates",
5470 "Confirmed by running it.",
5471 "src/a.rs",
5472 true,
5473 ),
5474 finding("non-blocking", "Name is vague", "d", "src/b.rs", true),
5475 finding("nit", "Log wording", "d", "", true),
5476 ],
5477 ),
5478 &style(),
5479 );
5480 assert!(text.starts_with("One real problem."), "{text}");
5481 assert!(!text.contains("codex"), "no agent name: {text}");
5482 assert!(!text.contains("round 2"), "no round number: {text}");
5483 }
5484
5485 #[test]
5488 fn only_blocking_findings_carry_their_detail() {
5489 let text = review_comment(
5490 "codex",
5491 1,
5492 &review(
5493 "s",
5494 vec![
5495 finding("blocking", "Loop", "BLOCKING DETAIL", "a.rs", true),
5496 finding("non-blocking", "Name", "NONBLOCKING DETAIL", "b.rs", true),
5497 ],
5498 ),
5499 &style(),
5500 );
5501 assert!(text.contains("BLOCKING DETAIL"), "{text}");
5502 assert!(!text.contains("NONBLOCKING DETAIL"), "{text}");
5503 }
5504
5505 #[test]
5506 fn a_thorough_explanation_reaches_the_author_intact() {
5509 let detail = "Reproduced by running the 429 test with max_attempts unset. ".repeat(8);
5510 let text = review_comment(
5511 "codex",
5512 1,
5513 &review(
5514 "One problem.",
5515 vec![finding("blocking", "T", &detail, "a.rs", true)],
5516 ),
5517 &style(),
5518 );
5519 assert!(
5520 text.contains(detail.trim()),
5521 "the explanation was cut:\n{text}"
5522 );
5523 }
5524
5525 #[test]
5527 fn a_runaway_model_is_still_bounded() {
5528 let long = "filler words. ".repeat(20_000);
5529 let text = review_comment(
5530 "codex",
5531 1,
5532 &review(&long, vec![finding("blocking", "T", &long, "a.rs", true)]),
5533 &style(),
5534 );
5535 assert!(
5536 text.len() < 30_000,
5537 "review comment was {} chars",
5538 text.len()
5539 );
5540 }
5541
5542 #[test]
5543 fn a_general_finding_has_no_empty_parenthesis() {
5544 let text = review_comment(
5545 "codex",
5546 1,
5547 &review("s", vec![finding("blocking", "Something", "d", "", true)]),
5548 &style(),
5549 );
5550 assert!(!text.contains("()"), "{text}");
5551 assert!(!text.contains("(general)"), "{text}");
5552 }
5553
5554 #[test]
5555 fn out_of_scope_findings_are_counted_separately() {
5556 let text = review_comment(
5557 "codex",
5558 1,
5559 &review(
5560 "s",
5561 vec![finding("blocking", "Old bug", "d", "a.rs", false)],
5562 ),
5563 &style(),
5564 );
5565 assert!(text.contains("out of scope"), "{text}");
5566 assert!(text.contains("Old bug"), "{text}");
5567 }
5568
5569 #[test]
5570 fn a_disposition_comment_leads_with_counts_and_keeps_refutations() {
5571 let response = ResponseDoc {
5572 summary: "Two of three were right.".into(),
5573 dispositions: vec![],
5574 };
5575 let text = disposition_comment(
5576 "claude",
5577 &response,
5578 &["Fixed thing".to_string()],
5579 &["Wrong thing. Because the caller already checks.".to_string()],
5580 &[],
5581 &style(),
5582 )
5583 .unwrap();
5584 assert!(text.starts_with("Two of three were right."), "{text}");
5585 assert!(!text.contains("claude"), "no agent name: {text}");
5586 assert!(
5587 text.contains("Because the caller already checks."),
5588 "{text}"
5589 );
5590 }
5591
5592 #[test]
5593 fn an_empty_disposition_comment_is_not_posted() {
5594 let response = ResponseDoc {
5595 summary: "s".into(),
5596 dispositions: vec![],
5597 };
5598 assert!(disposition_comment("claude", &response, &[], &[], &[], &style()).is_none());
5599 }
5600
5601 #[test]
5606 fn a_local_closing_commit_does_not_change_remote_custody() {
5607 for holder in ["a", "b"] {
5608 assert_eq!(holder, closing_next_actor(holder));
5609 }
5610 }
5611
5612 #[test]
5613 fn a_matching_head_keeps_saved_custody() {
5614 assert!(reconcile_saved_head(Some("abc123"), Some(2), true, "abc123", None, 42).unwrap());
5615 assert!(reconcile_saved_head(None, None, false, "abc123", None, 42).unwrap());
5616 }
5617
5618 #[test]
5619 fn a_changed_head_refuses_automatic_custody() {
5620 let error =
5621 reconcile_saved_head(Some("abc123"), Some(2), true, "def456", None, 42).unwrap_err();
5622 let text = error.to_string();
5623 assert!(text.contains("abc123"), "{text}");
5624 assert!(text.contains("def456"), "{text}");
5625 assert!(text.contains("--next <agent>"), "{text}");
5626 }
5627
5628 #[test]
5629 fn legacy_state_keeps_its_saved_custody_for_migration() {
5630 assert!(reconcile_saved_head(Some(""), Some(1), true, "def456", None, 42).unwrap());
5631 }
5632
5633 #[test]
5634 fn an_explicit_holder_resets_state_for_a_changed_or_legacy_head() {
5635 assert!(
5636 !reconcile_saved_head(Some("abc123"), Some(2), true, "def456", Some("b"), 42).unwrap()
5637 );
5638 assert!(!reconcile_saved_head(Some(""), Some(1), true, "def456", Some("b"), 42).unwrap());
5639 }
5640
5641 #[test]
5642 fn a_headless_current_or_future_state_is_not_legacy() {
5643 for version in [2, STATE_VERSION + 1] {
5644 assert!(
5645 reconcile_saved_head(Some(""), Some(version), true, "def456", None, 42).is_err()
5646 );
5647 }
5648 }
5649
5650 #[test]
5651 fn legacy_state_with_an_unknown_actor_refuses_to_guess() {
5652 assert!(reconcile_saved_head(Some(""), Some(1), false, "def456", None, 42).is_err());
5653 }
5654
5655 #[test]
5656 fn an_invalid_review_cannot_clear_a_carried_blocker() {
5657 let mut open = vec![finding(
5658 "blocking",
5659 "Unchecked error",
5660 "still fails",
5661 "src/a.rs:12",
5662 true,
5663 )];
5664 update_open_findings(&mut open, &[], false);
5665 assert_eq!(1, open.len());
5666
5667 update_open_findings(&mut open, &[], true);
5668 assert!(open.is_empty());
5669 }
5670
5671 #[test]
5672 fn a_final_round_with_no_commit_names_open_blockers() {
5673 let open = vec![finding(
5674 "blocking",
5675 "Unchecked error",
5676 "still fails",
5677 "src/a.rs",
5678 true,
5679 )];
5680 assert!(matches!(
5681 ending_without_landing(&open),
5682 Ending::Unresolved(points) if points.len() == 1
5683 ));
5684 assert!(matches!(ending_without_landing(&[]), Ending::Unchanged));
5685 }
5686
5687 #[test]
5691 fn a_closing_pass_uses_the_later_effort_tier() {
5692 use crate::config::{Call, EffortSchedule};
5693 let schedule = EffortSchedule {
5694 round_1: Some("ultra".into()),
5695 rest: Some("low".into()),
5696 ..EffortSchedule::default()
5697 };
5698 assert_eq!(None, schedule.get(Call::Close));
5699 let mut with_key = schedule.clone();
5700 with_key.close = Some("medium".into());
5701 assert_eq!(Some("medium".to_string()), with_key.get(Call::Close));
5702 }
5703
5704 #[test]
5705 fn a_ledger_with_no_claimed_fix_has_nothing_to_close_over() {
5706 assert!(!any_fixes(&ledger_with("refuted point", "a.rs"), 1));
5707 let mut fixed = ledger_with("fixed point", "b.rs");
5708 for entry in fixed.values_mut() {
5709 entry.outcome = Settled::Fixed;
5710 }
5711 assert!(any_fixes(&fixed, 1));
5712 }
5713
5714 #[test]
5717 fn the_closing_note_counts_points_rather_than_rounds() {
5718 assert_eq!("one point left after the closing pass", unresolved_note(1));
5719 assert_eq!("3 points left after the closing pass", unresolved_note(3));
5720 assert!(!unresolved_note(2).contains("round"));
5721 }
5722
5723 fn fixed_ledger() -> Ledger {
5724 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
5725 for entry in ledger.values_mut() {
5726 entry.outcome = Settled::Fixed;
5727 entry.reasoning = "bounded it on max_attempts".into();
5728 }
5729 ledger
5730 }
5731
5732 #[test]
5733 fn the_closing_prompt_names_every_fix_it_has_to_check() {
5734 let landed = vec!["abc1234 Bound the retry loop".to_string()];
5735 let prompt = close_prompt(
5736 "origin/main",
5737 42,
5738 "Retry a 429",
5739 "",
5740 "9f8e7d6",
5741 Some(&landed),
5742 &fixed_ledger(),
5743 &[],
5744 1,
5745 &[],
5746 );
5747 assert!(prompt.contains("Unbounded loop"), "{prompt}");
5748 assert!(prompt.contains("bounded it on max_attempts"), "{prompt}");
5749 assert!(prompt.contains("abc1234 Bound the retry loop"), "{prompt}");
5750 assert!(prompt.contains("git diff 9f8e7d6..HEAD"), "{prompt}");
5751 assert!(prompt.contains("git diff origin/main...HEAD"), "{prompt}");
5752 assert!(!prompt.contains('{'), "{prompt}");
5753 }
5754
5755 #[test]
5758 fn a_close_with_nothing_landed_says_so_rather_than_leaving_a_hole() {
5759 let prompt = close_prompt(
5760 "origin/main",
5761 42,
5762 "Retry a 429",
5763 "",
5764 "9f8e7d6",
5765 Some(&[]),
5766 &Ledger::new(),
5767 &[],
5768 1,
5769 &[],
5770 );
5771 assert!(
5772 prompt.contains("Nothing landed after the last round"),
5773 "{prompt}"
5774 );
5775 assert!(!prompt.contains('{'), "{prompt}");
5776 }
5777
5778 #[test]
5784 fn a_rewritten_branch_admits_it_cannot_say_what_landed() {
5785 let prompt = close_prompt(
5786 "origin/main",
5787 42,
5788 "Retry a 429",
5789 "",
5790 "9f8e7d6",
5791 None,
5792 &fixed_ledger(),
5793 &[],
5794 1,
5795 &[],
5796 );
5797 assert!(prompt.contains("were rewritten"), "{prompt}");
5798 assert!(prompt.contains("git diff origin/main...HEAD"), "{prompt}");
5799 assert!(!prompt.contains("nobody has read it"), "{prompt}");
5800 assert!(!prompt.contains('{'), "{prompt}");
5801 }
5802
5803 #[test]
5806 fn the_closing_prompt_forbids_the_writing_the_loop_rolls_back() {
5807 let prompt = close_prompt(
5808 "origin/main",
5809 42,
5810 "t",
5811 "",
5812 "9f8e7d6",
5813 Some(&[]),
5814 &Ledger::new(),
5815 &[],
5816 1,
5817 &[],
5818 );
5819 assert!(prompt.contains("do not commit"), "{prompt}");
5820 }
5821
5822 #[test]
5824 fn the_closing_prompt_keeps_confirmed_merge_blockers_blocking() {
5825 let prompt = close_prompt(
5826 "origin/main",
5827 42,
5828 "t",
5829 "",
5830 "9f8e7d6",
5831 Some(&[]),
5832 &Ledger::new(),
5833 &[],
5834 1,
5835 &[],
5836 );
5837 assert!(
5838 prompt.contains("serious defect an\nearlier round missed"),
5839 "{prompt}"
5840 );
5841 assert!(prompt.contains("final merge-safety audit"), "{prompt}");
5842 assert!(!prompt.contains("not another\naudit"), "{prompt}");
5843 assert!(!prompt.contains("A\nfinding means"), "{prompt}");
5844 assert!(prompt.contains("A\nblocking finding means"), "{prompt}");
5845 let flat = prompt.split_whitespace().collect::<Vec<_>>().join(" ");
5846 assert!(flat.contains("does not become non-blocking"), "{prompt}");
5847 assert!(flat.contains("Only one of them ships"), "{prompt}");
5848 }
5849
5850 #[test]
5855 fn the_closing_pass_is_offered_a_severity_rather_than_the_field_that_gates() {
5856 let prompt = close_prompt(
5857 "origin/main",
5858 42,
5859 "t",
5860 "",
5861 "9f8e7d6",
5862 Some(&[]),
5863 &Ledger::new(),
5864 &[],
5865 1,
5866 &[],
5867 );
5868 assert!(
5869 prompt.contains("Minor defects and improvements are\nnon-blocking"),
5870 "{prompt}"
5871 );
5872 assert!(
5873 prompt.contains("a real defect\nthis pull request did not cause"),
5874 "{prompt}"
5875 );
5876 }
5877
5878 #[test]
5881 fn an_out_of_scope_point_cannot_gate_the_close() {
5882 let out_of_scope = finding("blocking", "Adjacent leak", "d", "o.rs", false);
5883 assert!(!out_of_scope.blocks());
5884 assert!(approval_stands(&[], false));
5885 }
5886
5887 #[test]
5891 fn a_fix_is_shown_to_the_pass_that_has_to_check_it_and_not_after() {
5892 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
5893 for entry in ledger.values_mut() {
5894 entry.outcome = Settled::Fixed;
5895 entry.round = 2;
5896 }
5897 assert!(answers_block(&ledger, 3).contains("Unbounded loop"));
5899 assert_eq!("", answers_block(&ledger, 4));
5901 assert!(any_fixes(&ledger, 2));
5902 assert!(!any_fixes(&ledger, 3));
5903 }
5904
5905 #[test]
5910 fn the_answers_block_asks_the_reviewer_to_check_rather_than_to_trust() {
5911 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
5912 for entry in ledger.values_mut() {
5913 entry.outcome = Settled::Fixed;
5914 entry.reasoning = "bounded it on max_attempts".into();
5915 }
5916 let block = answers_block(&ledger, 2);
5917 assert!(block.contains("Unbounded loop"), "{block}");
5918 assert!(block.contains("src/x.rs"), "{block}");
5919 assert!(block.contains("bounded it on max_attempts"), "{block}");
5920 assert!(block.contains("Check the answer"), "{block}");
5921 assert!(!block.contains("settled"), "{block}");
5922 }
5923
5924 #[test]
5927 fn a_fix_and_a_refutation_do_not_share_a_heading() {
5928 let mut ledger = ledger_with("refuted point", "a.rs");
5929 ledger.extend(ledger_with("fixed point", "b.rs"));
5930 for entry in ledger.values_mut() {
5931 if entry.title == "fixed point" {
5932 entry.outcome = Settled::Fixed;
5933 }
5934 }
5935 let answers = answers_block(&ledger, 2);
5936 let settled = settled_block(&ledger);
5937 assert!(answers.contains("fixed point") && !answers.contains("refuted point"));
5938 assert!(settled.contains("refuted point") && !settled.contains("fixed point"));
5939 }
5940
5941 #[test]
5942 fn an_empty_ledger_adds_no_answers_block() {
5943 assert_eq!("", answers_block(&Ledger::new(), 2));
5944 }
5945
5946 #[test]
5949 fn the_last_round_that_can_ask_for_anything_says_so() {
5950 assert_eq!("", round_note(1, 3));
5951 assert_eq!("", round_note(2, 3));
5952 assert!(round_note(3, 3).contains("last round"));
5953 assert!(round_note(6, 6).contains("last round"));
5956 }
5957
5958 #[test]
5964 fn saying_when_the_asking_stops_says_nothing_about_severity() {
5965 let note = round_note(3, 3);
5966 for word in ["approve", "blocking", "severity", "nit"] {
5967 assert!(!note.contains(word), "{word} in: {note}");
5968 }
5969 }
5970
5971 #[test]
5972 fn the_review_prompt_leaves_nothing_unsubstituted() {
5973 let empty = review_prompt(
5977 "origin/main",
5978 42,
5979 "Retry a 429",
5980 "",
5981 &Ledger::new(),
5982 &[],
5983 1,
5984 3,
5985 &[],
5986 );
5987 assert!(!empty.contains('{'), "{empty}");
5988 assert!(
5989 empty.contains("`origin/main`"),
5990 "the reviewer was told to read a different ref than spar compares:\n{empty}"
5991 );
5992 assert!(empty.contains("#42") && empty.contains("Retry a 429"));
5993
5994 let mut ledger = ledger_with("refuted point", "a.rs");
5995 ledger.extend(ledger_with("fixed point", "b.rs"));
5996 for entry in ledger.values_mut() {
5997 if entry.title == "fixed point" {
5998 entry.outcome = Settled::Fixed;
5999 entry.round = 2;
6000 }
6001 }
6002 let full = review_prompt(
6003 "origin/main",
6004 42,
6005 "Retry a 429",
6006 "",
6007 &ledger,
6008 &[],
6009 3,
6010 3,
6011 &[],
6012 );
6013 assert!(!full.contains('{'), "{full}");
6014 assert!(full.contains("fixed point") && full.contains("refuted point"));
6015 assert!(full.contains("last round"), "{full}");
6016 }
6017
6018 #[test]
6027 fn every_pass_that_judges_the_branch_is_given_the_issue_and_the_description() {
6028 let context = context_block(
6029 42,
6030 "https://github.com/example/project/issues/42",
6031 "Honour Retry-After and cap at five attempts.",
6032 "## What changed\n\nBounded the loop. Ran the 429 test.",
6033 );
6034 assert!(context.contains("Honour Retry-After"), "{context}");
6035 assert!(context.contains("issues/42"), "{context}");
6036 assert!(
6037 context.contains("followed by any comments on it since"),
6038 "{context}"
6039 );
6040 assert!(context.contains("the later one wins"), "{context}");
6041 assert!(context.contains("Ran the 429 test"), "{context}");
6042 assert!(
6043 context.contains("claim to check"),
6044 "the description has to be labelled as the author's own account:\n{context}"
6045 );
6046
6047 let review = review_prompt(
6048 "origin/main",
6049 42,
6050 "Retry on 429",
6051 &context,
6052 &Ledger::new(),
6053 &[],
6054 1,
6055 3,
6056 &[],
6057 );
6058 let close = close_prompt(
6059 "origin/main",
6060 42,
6061 "Retry on 429",
6062 &context,
6063 "9f8e7d6",
6064 Some(&[]),
6065 &Ledger::new(),
6066 &[],
6067 1,
6068 &[],
6069 );
6070 let respond = respond_prompt(42, &context, &[], &[]);
6071 for prompt in [review, close, respond] {
6072 assert!(prompt.contains("Honour Retry-After"), "{prompt}");
6073 assert!(prompt.contains("Ran the 429 test"), "{prompt}");
6074 assert!(!prompt.contains('{'), "{prompt}");
6075 }
6076 }
6077
6078 #[test]
6085 fn the_author_is_offered_the_non_blocking_findings_it_never_saw() {
6086 let findings = vec![
6087 finding(
6088 "blocking",
6089 "Retry loop spins",
6090 "confirmed",
6091 "src/net.rs:88",
6092 true,
6093 ),
6094 finding(
6095 "non-blocking",
6096 "Timeout is hard coded",
6097 "thirty seconds, in one place",
6098 "src/net.rs:44",
6099 true,
6100 ),
6101 finding(
6102 "nit",
6103 "Log line says retrying",
6104 "wording",
6105 "src/net.rs:102",
6106 true,
6107 ),
6108 finding(
6109 "non-blocking",
6110 "Out of scope thing",
6111 "predates the branch",
6112 "src/cache.rs",
6113 false,
6114 ),
6115 ];
6116
6117 let optional = optional_findings(&findings);
6118 assert_eq!(1, optional.len(), "{optional:?}");
6119 assert_eq!("Timeout is hard coded", optional[0].title);
6120
6121 let prompt = respond_prompt(42, "", &[findings[0].clone()], &findings);
6122 assert!(prompt.contains("Timeout is hard coded"), "{prompt}");
6123 assert!(prompt.contains("will not hold the merge"), "{prompt}");
6124 assert!(
6125 !prompt.contains("Log line says retrying"),
6126 "a nit is dropped, not offered:\n{prompt}"
6127 );
6128 assert!(
6129 !prompt.contains("Out of scope thing"),
6130 "an out of scope point is filed, not offered:\n{prompt}"
6131 );
6132 assert!(!prompt.contains('{'), "{prompt}");
6133
6134 let none = optional_block(&[findings[0].clone()]);
6136 assert_eq!("", none);
6137 }
6138
6139 #[test]
6141 fn the_review_prompt_says_what_happens_to_each_severity() {
6142 assert!(
6143 REVIEW_PROMPT.contains("offered to the author"),
6144 "{REVIEW_PROMPT}"
6145 );
6146 assert!(REVIEW_PROMPT.contains("nit: dropped"), "{REVIEW_PROMPT}");
6147 }
6148
6149 #[test]
6152 fn a_pull_request_with_no_issue_behind_it_carries_no_empty_headings() {
6153 assert_eq!("", context_block(7, "", "", ""));
6154 let only_description = context_block(7, "", "", "What it does.");
6155 assert!(!only_description.contains("as filed"), "{only_description}");
6156 assert!(only_description.contains("What it does."));
6157 }
6158
6159 #[test]
6160 fn a_resumed_open_finding_reaches_review_and_closing_prompts() {
6161 let open = vec![finding(
6162 "blocking",
6163 "Retry bypasses the limit",
6164 "reproduced with max_attempts set to one",
6165 "src/net.rs:88",
6166 true,
6167 )];
6168 let review = review_prompt(
6169 "origin/main",
6170 42,
6171 "Retry a 429",
6172 "",
6173 &Ledger::new(),
6174 &open,
6175 2,
6176 3,
6177 &[],
6178 );
6179 let close = close_prompt(
6180 "origin/main",
6181 42,
6182 "Retry a 429",
6183 "",
6184 "9f8e7d6",
6185 Some(&[]),
6186 &Ledger::new(),
6187 &open,
6188 2,
6189 &[],
6190 );
6191 for prompt in [review, close] {
6192 assert!(prompt.contains("Retry bypasses the limit"), "{prompt}");
6193 assert!(prompt.contains("src/net.rs:88"), "{prompt}");
6194 assert!(
6195 prompt.contains("reproduced with max_attempts set to one"),
6196 "{prompt}"
6197 );
6198 assert!(!prompt.contains('{'), "{prompt}");
6199 }
6200 }
6201
6202 #[test]
6206 fn a_minor_defect_has_a_severity_that_is_not_blocking() {
6207 assert!(
6208 REVIEW_PROMPT.contains("A minor defect belongs\n here as much as an improvement does")
6209 );
6210 for text in [
6215 schema::review().to_string(),
6216 crate::review_only::review_only_prompt().to_string(),
6217 ] {
6218 assert!(text.contains("as much as an improvement does"), "{text}");
6219 assert!(!text.contains("a genuine improvement"), "{text}");
6220 }
6221 }
6222
6223 #[test]
6226 fn doubt_resolves_away_from_the_field_that_gates() {
6227 for text in [REVIEW_PROMPT.to_string(), schema::review().to_string()] {
6228 assert!(text.contains("say your piece in the finding and label it non-blocking"));
6229 assert!(!text.contains("leave in_scope true"));
6230 }
6231 }
6232
6233 #[test]
6236 fn both_edit_prompts_ask_for_the_smallest_change_that_answers_the_point() {
6237 for prompt in [FIX_PROMPT, RESPOND_PROMPT] {
6238 assert!(
6239 prompt.contains("The smallest change that answers it is"),
6240 "{prompt}"
6241 );
6242 }
6243 assert!(RESPOND_PROMPT.contains("bigger than the\n problem it names"));
6244 }
6245
6246 #[test]
6247 fn a_fixed_disposition_must_explain_what_changed() {
6248 let flat = RESPOND_PROMPT
6249 .split_whitespace()
6250 .collect::<Vec<_>>()
6251 .join(" ");
6252 assert!(
6253 flat.contains("For fixed, say what changed and how it answers the point"),
6254 "{RESPOND_PROMPT}"
6255 );
6256 }
6257
6258 #[test]
6259 fn an_empty_fix_reason_still_renders_as_a_claim_to_check() {
6260 let mut ledger = ledger_with("Unchecked error", "src/net.rs");
6261 for entry in ledger.values_mut() {
6262 entry.outcome = Settled::Fixed;
6263 entry.reasoning.clear();
6264 }
6265
6266 let lines = fixed_lines(&ledger, 0);
6267
6268 assert_eq!(1, lines.len());
6269 assert!(lines[0].contains("a committed change claims to address this point"));
6270 assert!(!lines[0].contains("The author said"));
6271 }
6272
6273 #[test]
6278 fn the_implementor_is_given_the_link_and_the_body() {
6279 let prompt = implement_prompt(
6280 42,
6281 "Retry a 429",
6282 "https://github.com/o/r/issues/42",
6283 "A rate limited response was treated as fatal.",
6284 &[],
6285 );
6286 assert!(
6287 prompt.contains("https://github.com/o/r/issues/42"),
6288 "{prompt}"
6289 );
6290 assert!(
6291 prompt.contains("A rate limited response was treated as fatal."),
6292 "{prompt}"
6293 );
6294 assert!(prompt.contains("#42"), "{prompt}");
6295 assert!(prompt.contains("Retry a 429"), "{prompt}");
6296 assert!(!prompt.contains('{'), "{prompt}");
6298 }
6299
6300 #[test]
6306 fn the_prompt_says_a_later_comment_can_change_what_was_asked() {
6307 let prompt = implement_prompt(1, "t", "u", "b", &[]);
6308 let lower = prompt
6310 .split_whitespace()
6311 .collect::<Vec<_>>()
6312 .join(" ")
6313 .to_lowercase();
6314 assert!(lower.contains("followed by any comments"), "{prompt}");
6315 assert!(lower.contains("the later one wins"), "{prompt}");
6316 assert!(lower.contains("a comment from spar"), "{prompt}");
6317 assert!(!prompt.contains('{'), "{prompt}");
6318 }
6319
6320 fn worked() -> Implementation {
6322 Implementation {
6323 summary: "Retry a 429 instead of failing the run.".into(),
6324 problem: "A rate limited response was treated as fatal, so one throttled call ended \
6325 a run that had hours of work left in it."
6326 .into(),
6327 changes: vec![
6328 "`send` retries a 429 with the delay the header asks for".into(),
6329 "the retry budget is bounded, so a permanent 429 still ends".into(),
6330 ],
6331 testing: vec![
6332 "`cargo test retries_a_429`".into(),
6333 "point it at a throttled endpoint and watch it finish".into(),
6334 ],
6335 ..Implementation::default()
6336 }
6337 }
6338
6339 #[test]
6340 fn a_pr_body_is_what_it_closes_and_what_changed() {
6343 let body = pr_body(42, &worked(), None, &[], &style());
6344 assert_eq!(
6345 "Closes #42\n\n\
6346 Retry a 429 instead of failing the run.\n\n\
6347 A rate limited response was treated as fatal, so one throttled call \
6348 ended a run that had hours of work left in it.\n\n\
6349 ## What changed\n\n\
6350 - `send` retries a 429 with the delay the header asks for\n\
6351 - the retry budget is bounded, so a permanent 429 still ends\n\n\
6352 ## How to test\n\n\
6353 - `cargo test retries_a_429`\n\
6354 - point it at a throttled endpoint and watch it finish",
6355 body
6356 );
6357 }
6358
6359 #[test]
6362 fn a_body_with_nothing_to_list_carries_no_empty_headings() {
6363 let work = Implementation {
6364 summary: "Retry a 429 instead of failing the run.".into(),
6365 ..Implementation::default()
6366 };
6367 assert_eq!(
6368 "Closes #42\n\nRetry a 429 instead of failing the run.",
6369 pr_body(42, &work, None, &[], &style())
6370 );
6371 }
6372
6373 #[test]
6374 fn a_pr_body_survives_an_implementor_that_said_nothing() {
6375 assert_eq!(
6376 "Closes #7",
6377 pr_body(7, &Implementation::default(), None, &[], &style())
6378 );
6379 }
6380
6381 #[test]
6384 fn blank_list_entries_do_not_earn_a_heading() {
6385 let work = Implementation {
6386 summary: "Did a thing.".into(),
6387 changes: vec![String::new(), " ".into()],
6388 ..Implementation::default()
6389 };
6390 let body = pr_body(42, &work, None, &[], &style());
6391 assert!(!body.contains("What changed"), "{body}");
6392 }
6393
6394 #[test]
6395 fn notes_appear_only_when_there_is_something_to_note() {
6396 let mut work = worked();
6397 assert!(!pr_body(42, &work, None, &[], &style()).contains("## Notes"));
6398 work.notes = Some("The retry is not applied to streaming calls.".into());
6399 let body = pr_body(42, &work, None, &[], &style());
6400 assert!(body.contains("## Notes"), "{body}");
6401 assert!(body.contains("streaming calls"), "{body}");
6402 }
6403
6404 #[test]
6407 fn declining_posts_the_reason_and_not_the_summary() {
6408 let work = Implementation {
6409 not_worth_doing: true,
6410 reason: "Already fixed in 1.2, and the report predates it.".into(),
6411 summary: "Nothing to do.".into(),
6412 ..Implementation::default()
6413 };
6414 assert_eq!(
6415 "Already fixed in 1.2, and the report predates it.",
6416 no_pr_note(&work, &style())
6417 );
6418 }
6419
6420 #[test]
6421 fn reporting_work_and_committing_none_says_that_rather_than_the_summary() {
6422 let work = Implementation {
6423 summary: "Retry a 429 instead of failing the run.".into(),
6424 ..Implementation::default()
6425 };
6426 let note = no_pr_note(&work, &style());
6427 assert_eq!(
6428 "Nothing was committed, so there is nothing to review.",
6429 note
6430 );
6431 }
6432
6433 #[test]
6434 fn declining_without_a_reason_still_says_something() {
6435 let work = Implementation {
6436 not_worth_doing: true,
6437 ..Implementation::default()
6438 };
6439 assert!(no_pr_note(&work, &style()).contains("no reason given"));
6440 }
6441
6442 #[test]
6443 fn a_skip_comment_is_only_the_reasoning() {
6444 let item = SkippedItem {
6445 issue: 3,
6446 title: "t".into(),
6447 tracker: false,
6448 reasons: [
6449 ("claude".to_string(), "Already fixed in 1.2.".to_string()),
6450 ("codex".to_string(), "Duplicate of #2.".to_string()),
6451 ]
6452 .into_iter()
6453 .collect(),
6454 };
6455 let text = skip_comment(&item, &style());
6456 assert!(text.contains("Already fixed in 1.2."), "{text}");
6457 assert!(text.contains("Duplicate of #2."), "{text}");
6458 assert!(
6459 !text.contains("claude") && !text.contains("codex"),
6460 "{text}"
6461 );
6462 assert!(!text.to_lowercase().contains("not scheduled"), "{text}");
6463 assert!(text.lines().count() <= 3, "{text}");
6464 }
6465
6466 #[test]
6467 fn findings_for_a_model_keep_full_detail() {
6468 let long = "x".repeat(2000);
6469 let text = findings_for_prompt(&[finding("blocking", "T", &long, "a.rs", true)]);
6470 assert!(
6471 text.contains(&long),
6472 "a model needs the whole finding, only humans need brevity"
6473 );
6474 }
6475
6476 #[test]
6477 fn findings_for_a_model_are_never_empty() {
6478 assert_eq!("(none)", findings_for_prompt(&[]));
6479 }
6480
6481 const CRITERIA_BODY: &str = "## Acceptance criteria\n- [ ] Preserve column order.\n- [x] Already done.\n- [ ] Connects on a device\n";
6482
6483 fn status(number: u32, mark: Mark, reason: &str) -> CriterionStatus {
6484 CriterionStatus {
6485 number,
6486 status: mark,
6487 reason: reason.into(),
6488 }
6489 }
6490
6491 #[test]
6492 fn criteria_are_the_unchecked_items_only() {
6493 let criteria = tracker::criteria(CRITERIA_BODY);
6494 let texts: Vec<&str> = criteria.iter().map(|c| c.text.as_str()).collect();
6495 assert_eq!(
6496 vec!["Preserve column order.", "Connects on a device"],
6497 texts
6498 );
6499 }
6500
6501 #[test]
6502 fn prompts_without_criteria_are_unchanged() {
6503 assert!(!implement_prompt(1, "t", "u", "b", &[]).contains("{criteria}"));
6504 assert!(implement_prompt(1, "t", "u", "b", &[])
6505 .contains("oldest first.\n".to_string().as_str()));
6506 let review = review_prompt("main", 1, "t", "\nctx\n", &Ledger::new(), &[], 1, 3, &[]);
6507 assert!(
6508 review.contains("#1: t\n\nctx\n\nReview thoroughly"),
6509 "{review}"
6510 );
6511 let close = close_prompt(
6512 "main",
6513 1,
6514 "t",
6515 "\nctx\n",
6516 "",
6517 None,
6518 &Ledger::new(),
6519 &[],
6520 1,
6521 &[],
6522 );
6523 assert!(
6524 close.contains("#1: t\n\nctx\n\nThis is the final"),
6525 "{close}"
6526 );
6527 let body = pr_body(1, &Implementation::default(), None, &[], &style());
6528 assert!(!body.contains("Acceptance criteria"), "{body}");
6529 }
6530
6531 #[test]
6532 fn a_criterion_is_not_read_as_template_syntax() {
6533 let criteria = tracker::criteria("- [ ] Keep the literal {number} and {url}.\n");
6534 let implement = implement_prompt(42, "t", "u", "b", &criteria);
6535 assert!(
6536 implement.contains("1. Keep the literal {number} and {url}."),
6537 "{implement}"
6538 );
6539 }
6540
6541 const EVERY_PLACEHOLDER: &str = "{thread_rule} {number} {title} {url} {criteria} {body} \
6543 {base} {context} {landed} {open} {answers} {settled} {round}";
6544
6545 #[test]
6546 fn issue_text_naming_a_placeholder_reaches_every_prompt_as_written() {
6547 let title = format!("Title {EVERY_PLACEHOLDER}");
6548 let body = format!("Body {EVERY_PLACEHOLDER}");
6549 let criterion = format!("1. Criterion {EVERY_PLACEHOLDER}\n");
6550 let context = context_block(42, "u", &body, "");
6551 let ledger = Ledger::new();
6552 let with = tracker::criteria(&format!("- [ ] Criterion {EVERY_PLACEHOLDER}\n"));
6553 assert_eq!(1, with.len(), "{with:?}");
6554
6555 for criteria in [with, Vec::new()] {
6556 let implement = implement_prompt(42, &title, "u", &body, &criteria);
6557 let review = review_prompt("main", 42, &title, &context, &ledger, &[], 3, 3, &criteria);
6560 let close = close_prompt(
6561 "main",
6562 42,
6563 &title,
6564 &context,
6565 "abc1234",
6566 Some(&[]),
6567 &ledger,
6568 &[],
6569 3,
6570 &criteria,
6571 );
6572 for prompt in [&implement, &review, &close] {
6573 assert!(prompt.contains(&title), "{prompt}");
6574 assert!(prompt.contains(&body), "{prompt}");
6575 assert_eq!(
6576 !criteria.is_empty(),
6577 prompt.contains(&criterion),
6578 "{prompt}"
6579 );
6580 let expected = if criteria.is_empty() { 2 } else { 3 };
6581 assert_eq!(
6582 expected,
6583 prompt.matches(EVERY_PLACEHOLDER).count(),
6584 "{prompt}"
6585 );
6586 }
6587 }
6588 }
6589
6590 #[test]
6591 fn findings_and_context_naming_a_placeholder_reach_the_author_as_written() {
6592 let every = "{number} {context} {findings} {optional}";
6593 let context = format!("\nContext {every}\n");
6594 let findings = vec![
6595 finding(
6596 "blocking",
6597 &format!("Blocker {every}"),
6598 every,
6599 "src/a.rs",
6600 true,
6601 ),
6602 finding(
6603 "non-blocking",
6604 &format!("Optional {every}"),
6605 every,
6606 "src/b.rs",
6607 true,
6608 ),
6609 ];
6610 let prompt = respond_prompt(42, &context, &[findings[0].clone()], &findings);
6611 assert!(prompt.contains(&context), "{prompt}");
6612 assert!(prompt.contains(&format!("Blocker {every}")), "{prompt}");
6613 assert!(prompt.contains(&format!("Optional {every}")), "{prompt}");
6614 assert_eq!(5, prompt.matches(every).count(), "{prompt}");
6615 }
6616
6617 #[test]
6618 fn prompts_number_each_criterion_verbatim() {
6619 let criteria = tracker::criteria(CRITERIA_BODY);
6620 let implement = implement_prompt(1, "t", "u", "b", &criteria);
6621 let review = review_prompt("main", 1, "t", "", &Ledger::new(), &[], 1, 3, &criteria);
6622 let close = close_prompt(
6623 "main",
6624 1,
6625 "t",
6626 "",
6627 "",
6628 None,
6629 &Ledger::new(),
6630 &[],
6631 1,
6632 &criteria,
6633 );
6634 for prompt in [&implement, &review, &close] {
6635 assert!(
6636 prompt.contains("1. Preserve column order.\n2. Connects on a device\n"),
6637 "{prompt}"
6638 );
6639 assert!(!prompt.contains("Already done"), "{prompt}");
6640 }
6641 assert!(implement.contains("done, not_done, or needs_person"));
6642 assert!(review.contains("met when you checked"));
6643 assert!(close.contains("met when you checked"));
6644 }
6645
6646 #[test]
6647 fn the_pr_body_gives_the_authors_account_of_each_criterion() {
6648 let criteria = tracker::criteria(CRITERIA_BODY);
6649 let work = Implementation {
6650 summary: "Add CSV export.".into(),
6651 criteria: vec![status(1, Mark::Met, "the writer keeps the header order")],
6652 ..Implementation::default()
6653 };
6654 let body = pr_body(7, &work, None, &criteria, &style());
6655 assert!(
6656 body.contains("## Acceptance criteria\n\nThe author's account."),
6657 "{body}"
6658 );
6659 assert!(
6660 body.contains("- Done: Preserve column order. The writer keeps the header order."),
6661 "{body}"
6662 );
6663 assert!(
6664 body.contains("- No status given: Connects on a device."),
6665 "{body}"
6666 );
6667 }
6668
6669 #[test]
6670 fn only_a_criterion_ruled_met_by_every_answer_is_met() {
6671 let criteria = tracker::criteria(CRITERIA_BODY);
6672 let rulings = vec![
6673 status(1, Mark::Met, "checked"),
6674 status(2, Mark::Met, "looks right"),
6675 status(2, Mark::NeedsPerson, "needs a device"),
6676 status(9, Mark::Met, "no such criterion"),
6677 ];
6678 let ruled = ruled(&criteria, &rulings);
6679 assert_eq!(2, ruled.len());
6680 assert_eq!(Some(Mark::Met), ruled[0].status);
6681 assert_eq!("- [ ] Preserve column order.", ruled[0].raw);
6682 assert_eq!(Some(Mark::NeedsPerson), ruled[1].status);
6683 assert_eq!(None, super::ruled(&criteria, &[])[0].status);
6684 }
6685
6686 #[test]
6687 fn the_authors_claim_never_reaches_a_ruling() {
6688 let criteria = tracker::criteria(CRITERIA_BODY);
6689 let ruled = ruled(&criteria, &[]);
6691 assert!(ruled.iter().all(|c| c.status != Some(Mark::Met)));
6692 }
6693
6694 fn approved_with(criteria: Vec<Ruled>, ticks: bool) -> Option<String> {
6695 let mut state = IssueRun::new(1, "t");
6696 state.criteria = criteria;
6697 state.ticks_criteria = ticks;
6698 outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style())
6699 }
6700
6701 fn ruled_one(text: &str, status: Option<Mark>, reason: &str) -> Ruled {
6702 Ruled {
6703 raw: format!("- [ ] {text}"),
6704 line: 1,
6705 text: text.into(),
6706 status,
6707 reason: reason.into(),
6708 }
6709 }
6710
6711 #[test]
6712 fn a_clean_approval_names_a_criterion_not_ruled_met() {
6713 let text = approved_with(
6714 vec![
6715 ruled_one("Preserve column order.", Some(Mark::Met), "checked"),
6716 ruled_one(
6717 "Connects on a device",
6718 Some(Mark::NeedsPerson),
6719 "needs a phone",
6720 ),
6721 ],
6722 true,
6723 )
6724 .expect("a comment");
6725 assert!(
6726 text.contains("Needs a person: Connects on a device. Needs a phone."),
6727 "{text}"
6728 );
6729 assert!(!text.contains("Preserve column order"), "{text}");
6730 assert!(text.contains("left unchecked"), "{text}");
6731 }
6732
6733 #[test]
6734 fn a_clean_approval_with_every_criterion_met_stays_silent() {
6735 let text = approved_with(
6736 vec![ruled_one(
6737 "Preserve column order.",
6738 Some(Mark::Met),
6739 "checked",
6740 )],
6741 true,
6742 );
6743 assert_eq!(None, text);
6744 }
6745
6746 #[test]
6747 fn without_ticking_the_comment_lists_every_ruling() {
6748 let text = approved_with(
6749 vec![
6750 ruled_one("Preserve column order.", Some(Mark::Met), "checked"),
6751 ruled_one("Connects on a device", None, ""),
6752 ],
6753 false,
6754 )
6755 .expect("a comment");
6756 assert!(
6757 text.contains("- Met: Preserve column order. Checked."),
6758 "{text}"
6759 );
6760 assert!(
6761 text.contains("- Not ruled on: Connects on a device."),
6762 "{text}"
6763 );
6764 assert!(text.contains("Nothing was ticked"), "{text}");
6765 }
6766}
6767
6768#[cfg(test)]
6769mod outcome_tests {
6770 use super::*;
6771 use crate::model::{Dispute, Severity};
6772
6773 fn style() -> Style {
6774 Style::default()
6775 }
6776
6777 fn state_with(disputes: Vec<(&str, &str)>, filed: Vec<&str>) -> IssueRun {
6778 let mut s = IssueRun::new(482, "t");
6779 s.disputes = disputes
6780 .into_iter()
6781 .map(|(title, reasoning)| Dispute {
6782 title: title.into(),
6783 file: String::new(),
6784 reasoning: reasoning.into(),
6785 })
6786 .collect();
6787 s.filed = filed.into_iter().map(String::from).collect();
6788 s
6789 }
6790
6791 fn finding(title: &str, file: &str) -> Finding {
6792 Finding {
6793 severity: Severity::Blocking,
6794 title: title.into(),
6795 detail: "d".into(),
6796 file: file.into(),
6797 in_scope: true,
6798 ..Default::default()
6799 }
6800 }
6801
6802 fn graded(severity: Severity, title: &str, file: &str, in_scope: bool) -> Finding {
6803 Finding {
6804 severity,
6805 in_scope,
6806 ..finding(title, file)
6807 }
6808 }
6809
6810 #[test]
6811 fn outcome_mode_routes_final_results_to_the_configured_sink() {
6812 assert_eq!(OutcomeSink::PullRequest, outcome_sink(PrComments::Outcome));
6813 assert_eq!(OutcomeSink::PullRequest, outcome_sink(PrComments::Rounds));
6814 assert_eq!(OutcomeSink::Terminal, outcome_sink(PrComments::None));
6815 }
6816
6817 #[test]
6820 fn a_clean_approval_says_nothing() {
6821 let state = state_with(vec![], vec![]);
6822 assert!(outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).is_none());
6823 }
6824
6825 #[test]
6830 fn a_downgraded_finding_still_reaches_the_pull_request() {
6831 let mut state = state_with(vec![], vec![]);
6832 let kept = graded(
6833 Severity::NonBlocking,
6834 "Timeout is not configurable",
6835 "n.rs",
6836 true,
6837 );
6838 record_nonblocking_outcome(&mut state, &kept, None);
6839
6840 assert_eq!(1, state.noted.len());
6843
6844 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
6845 assert!(text.contains("Noted, not blocking"), "{text}");
6846 assert!(
6847 text.contains("Timeout is not configurable (n.rs)"),
6848 "{text}"
6849 );
6850 }
6851
6852 #[test]
6855 fn a_point_that_was_filed_is_not_also_noted() {
6856 let mut state = state_with(vec![], vec![]);
6857 let finding = graded(Severity::NonBlocking, "Timeout", "n.rs", true);
6858 record_nonblocking_outcome(&mut state, &finding, None);
6859 record_nonblocking_outcome(
6860 &mut state,
6861 &finding,
6862 Some(&Followup::Recorded("https://example.invalid/9".into())),
6863 );
6864 assert!(state.noted.is_empty());
6865 assert_eq!(vec!["https://example.invalid/9"], state.filed);
6866 }
6867
6868 #[test]
6869 fn filing_a_moved_point_removes_its_earlier_note() {
6870 let mut state = state_with(vec![], vec![]);
6871 let earlier = graded(Severity::NonBlocking, "Timeout", "src/net.rs:10", true);
6872 let moved = graded(Severity::NonBlocking, "Timeout", "src/net.rs:12", false);
6873 record_nonblocking_outcome(&mut state, &earlier, None);
6874
6875 record_nonblocking_outcome(
6876 &mut state,
6877 &moved,
6878 Some(&Followup::Recorded("https://example.invalid/10".into())),
6879 );
6880
6881 assert!(state.noted.is_empty());
6882 assert_eq!(vec!["https://example.invalid/10"], state.filed);
6883 }
6884
6885 #[test]
6886 fn an_unrecorded_nonblocking_followup_remains_noted() {
6887 let finding = graded(Severity::NonBlocking, "Timeout", "n.rs", true);
6888 for outcome in [
6889 Followup::Covered("https://example.invalid/closed".into()),
6890 Followup::Dropped("follow-ups are off"),
6891 Followup::Failed,
6892 ] {
6893 let mut state = state_with(vec![], vec![]);
6894 record_nonblocking_outcome(&mut state, &finding, Some(&outcome));
6895 assert_eq!(1, state.noted.len(), "{outcome:?}");
6896 assert!(state.filed.is_empty(), "{outcome:?}");
6897 }
6898 }
6899
6900 #[test]
6902 fn a_point_noted_twice_is_listed_once() {
6903 let mut state = state_with(vec![], vec![]);
6904 let raised = graded(
6905 Severity::NonBlocking,
6906 "Timeout is not configurable",
6907 "n.rs",
6908 true,
6909 );
6910 let reworded = graded(
6911 Severity::NonBlocking,
6912 "timeout is not configurable!",
6913 "n.rs",
6914 true,
6915 );
6916 record_nonblocking_outcome(&mut state, &raised, None);
6917 record_nonblocking_outcome(&mut state, &reworded, None);
6918 assert_eq!(1, state.noted.len());
6919 }
6920
6921 #[test]
6922 fn same_title_notes_in_different_files_are_both_kept() {
6923 let mut state = state_with(vec![], vec![]);
6924 for file in ["src/a.rs", "src/b.rs"] {
6925 let finding = graded(Severity::NonBlocking, "Unchecked error", file, true);
6926 record_nonblocking_outcome(&mut state, &finding, None);
6927 }
6928 assert_eq!(2, state.noted.len());
6929 }
6930
6931 #[test]
6932 fn same_title_notes_at_two_sites_in_one_review_are_both_kept() {
6933 let findings = vec![
6934 graded(
6935 Severity::NonBlocking,
6936 "Unchecked error",
6937 "src/a.rs:10",
6938 true,
6939 ),
6940 graded(
6941 Severity::NonBlocking,
6942 "Unchecked error",
6943 "src/a.rs:200",
6944 true,
6945 ),
6946 ];
6947 let mut state = state_with(vec![], vec![]);
6948
6949 for finding in &findings {
6950 record_nonblocking_outcome_with_match(
6951 &mut state,
6952 finding,
6953 None,
6954 unique_stable_finding(&findings, finding),
6955 );
6956 }
6957
6958 assert_eq!(2, state.noted.len());
6959 }
6960
6961 #[test]
6962 fn settling_one_of_two_same_title_notes_keeps_the_other() {
6963 let first = graded(
6964 Severity::NonBlocking,
6965 "Unchecked error",
6966 "src/a.rs:10",
6967 true,
6968 );
6969 let second = graded(
6970 Severity::NonBlocking,
6971 "Unchecked error",
6972 "src/a.rs:200",
6973 true,
6974 );
6975 let mut state = state_with(vec![], vec![]);
6976 remember_noted(&mut state, &first, false);
6977 remember_noted(&mut state, &second, false);
6978
6979 forget_noted(&mut state, &first, false);
6980
6981 assert_eq!(1, state.noted.len());
6982 assert_eq!("src/a.rs:200", state.noted[0].file);
6983 }
6984
6985 #[test]
6986 fn same_title_disputes_at_two_sites_are_both_kept() {
6987 let mut state = state_with(vec![], vec![]);
6988 for file in ["src/a.rs:10", "src/a.rs:200"] {
6989 remember_dispute(
6990 &mut state,
6991 Dispute {
6992 title: "Unchecked error".into(),
6993 file: file.into(),
6994 reasoning: "the caller handles it".into(),
6995 },
6996 false,
6997 );
6998 }
6999
7000 assert_eq!(2, state.disputes.len());
7001 }
7002
7003 #[test]
7004 fn a_later_nonblocking_verdict_replaces_a_prior_dispute() {
7005 let mut state = state_with(vec![], vec![]);
7006 let finding = graded(Severity::NonBlocking, "Unchecked error", "src/a.rs", true);
7007 remember_dispute(
7008 &mut state,
7009 Dispute {
7010 title: finding.title.clone(),
7011 file: finding.file.clone(),
7012 reasoning: "the caller handles it".into(),
7013 },
7014 true,
7015 );
7016
7017 record_nonblocking_outcome(&mut state, &finding, None);
7018
7019 assert!(state.disputes.is_empty());
7020 assert_eq!(1, state.noted.len());
7021 }
7022
7023 #[test]
7024 fn a_note_moving_lines_in_the_same_file_is_updated() {
7025 let mut state = state_with(vec![], vec![]);
7026 let first = graded(
7027 Severity::NonBlocking,
7028 "Unchecked error",
7029 "src/a.rs:12",
7030 true,
7031 );
7032 let moved = graded(
7033 Severity::NonBlocking,
7034 "Unchecked error",
7035 "src/a.rs:19",
7036 true,
7037 );
7038 record_nonblocking_outcome(&mut state, &first, None);
7039 record_nonblocking_outcome(&mut state, &moved, None);
7040 assert_eq!(1, state.noted.len());
7041 assert_eq!("src/a.rs:19", state.noted[0].file);
7042 }
7043
7044 #[test]
7045 fn a_settled_point_removes_its_stale_note() {
7046 for outcome in [Settled::Fixed, Settled::Refuted, Settled::Filed] {
7047 let noted = graded(Severity::NonBlocking, "Unchecked error", "src/a.rs", true);
7048 let mut state = state_with(vec![], vec![]);
7049 record_nonblocking_outcome(&mut state, ¬ed, None);
7050 forget_noted(&mut state, ¬ed, true);
7051 assert!(state.noted.is_empty(), "{outcome}");
7052 }
7053 }
7054
7055 #[test]
7056 fn settling_a_moved_point_removes_its_old_dispute() {
7057 let old = graded(Severity::Blocking, "Unchecked error", "src/a.rs:10", true);
7058 let moved = graded(Severity::Blocking, "Unchecked error", "src/a.rs:12", true);
7059 let mut state = state_with(vec![], vec![]);
7060 remember_dispute(
7061 &mut state,
7062 Dispute {
7063 title: old.title,
7064 file: old.file,
7065 reasoning: "the caller handles it".into(),
7066 },
7067 true,
7068 );
7069
7070 forget_dispute(&mut state, &moved, true);
7071
7072 assert!(state.disputes.is_empty());
7073 }
7074
7075 #[test]
7078 fn a_deadlocked_point_is_not_also_noted() {
7079 let mut state = state_with(vec![], vec![]);
7080 let noted = graded(Severity::NonBlocking, "Unbounded loop", "x.rs", true);
7081 record_nonblocking_outcome(&mut state, ¬ed, None);
7082 let points = vec![finding("Unbounded loop", "x.rs")];
7083 let text = outcome_comment(
7084 &state,
7085 &Ledger::new(),
7086 &Ending::Deadlocked(&points),
7087 &style(),
7088 )
7089 .unwrap();
7090 assert!(!text.contains("Noted, not blocking"), "{text}");
7091 }
7092
7093 #[test]
7094 fn clipping_a_rendered_title_does_not_break_duplicate_suppression() {
7095 let mut compact = style();
7096 compact.max_title_chars = 5;
7097 let mut state = state_with(
7098 vec![("abcdefghij", "the caller already handles it")],
7099 vec![],
7100 );
7101 state
7102 .noted
7103 .push(graded(Severity::NonBlocking, "abcdefghij", "x.rs", true));
7104 state.disputes[0].file = "x.rs".into();
7105 let points = vec![finding("abcdefghij", "x.rs")];
7106
7107 let text = outcome_comment(
7108 &state,
7109 &Ledger::new(),
7110 &Ending::Unresolved(&points),
7111 &compact,
7112 )
7113 .unwrap();
7114
7115 assert!(!text.contains("Raised and refuted"), "{text}");
7116 assert!(!text.contains("Noted, not blocking"), "{text}");
7117 }
7118
7119 #[test]
7120 fn an_approval_that_filed_follow_ups_links_them() {
7121 let state = state_with(
7122 vec![],
7123 vec![
7124 "https://github.com/you/thing/issues/485",
7125 "https://github.com/you/thing/issues/486",
7126 ],
7127 );
7128 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
7129 assert!(text.contains("Filed separately: #485, #486"), "{text}");
7130 }
7131
7132 #[test]
7136 fn a_run_that_changed_nothing_does_not_claim_there_is_something_to_read() {
7137 let state = state_with(vec![], vec![]);
7138 let text = outcome_comment(&state, &Ledger::new(), &Ending::Unchanged, &style()).unwrap();
7139 assert!(text.contains("changed nothing"), "{text}");
7140 assert!(!text.contains("was pushed"), "{text}");
7141 }
7142
7143 #[test]
7147 fn an_unresolved_close_names_what_is_still_wrong() {
7148 let state = state_with(vec![], vec![]);
7149 let left = vec![Finding {
7150 detail: "The guard sits after the early return.".into(),
7151 ..finding("The retry fix never reaches the 429 path", "src/net.rs:88")
7152 }];
7153 let text =
7154 outcome_comment(&state, &Ledger::new(), &Ending::Unresolved(&left), &style()).unwrap();
7155 assert!(text.contains("These points are still open"), "{text}");
7156 assert!(
7157 text.contains("The retry fix never reaches the 429 path (src/net.rs:88)"),
7158 "{text}"
7159 );
7160 assert!(
7161 text.contains("The guard sits after the early return."),
7162 "{text}"
7163 );
7164 assert!(!text.contains("has not been reviewed"), "{text}");
7167 }
7168
7169 #[test]
7173 fn running_out_of_rounds_says_what_that_means_for_the_reader() {
7174 let state = state_with(vec![], vec![]);
7175 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
7176 assert!(text.contains("has not been reviewed"), "{text}");
7177 assert!(
7178 !text.to_lowercase().contains("round 3"),
7179 "no round numbers: {text}"
7180 );
7181 assert!(!text.to_lowercase().contains("convergence"), "{text}");
7182 }
7183
7184 #[test]
7185 fn a_failed_close_reports_unread_fixes_and_carried_blockers() {
7186 let state = state_with(vec![], vec![]);
7187 let open = vec![Finding {
7188 detail: "the failure is still discarded".into(),
7189 ..finding("Unchecked error", "src/net.rs:88")
7190 }];
7191 let text = outcome_comment_with_unread(
7192 &state,
7193 &Ledger::new(),
7194 &Ending::OutOfRounds,
7195 &open,
7196 &style(),
7197 )
7198 .unwrap();
7199 assert!(text.contains("has not been reviewed"), "{text}");
7200 assert!(text.contains("These points were already open"), "{text}");
7201 assert!(text.contains("Unchecked error (src/net.rs:88)"), "{text}");
7202 }
7203
7204 #[test]
7205 fn a_deadlock_names_the_point_they_could_not_settle() {
7206 let state = state_with(vec![], vec![]);
7207 let points = [finding("Retry loop never terminates", "src/net.rs:88")];
7208 let text = outcome_comment(
7209 &state,
7210 &Ledger::new(),
7211 &Ending::Deadlocked(&points),
7212 &style(),
7213 )
7214 .unwrap();
7215 assert!(
7216 text.contains("Retry loop never terminates (src/net.rs:88)"),
7217 "{text}"
7218 );
7219 assert!(text.contains("could not settle"), "{text}");
7220 }
7221
7222 #[test]
7224 fn refutations_survive_because_nothing_else_carries_them() {
7225 let state = state_with(
7226 vec![(
7227 "Error is swallowed",
7228 "the caller already validates the file",
7229 )],
7230 vec![],
7231 );
7232 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
7233 assert!(text.contains("Raised and refuted:"), "{text}");
7234 assert!(
7235 text.contains("The caller already validates the file"),
7236 "{text}"
7237 );
7238 }
7239
7240 #[test]
7241 fn no_agent_names_counts_or_round_numbers_reach_the_thread() {
7242 let state = state_with(
7243 vec![("A point", "a reason")],
7244 vec!["https://github.com/you/thing/issues/485"],
7245 );
7246 let left = vec![finding("A point", "a.rs")];
7247 for ending in [
7248 Ending::Approved,
7249 Ending::OutOfRounds,
7250 Ending::Unresolved(&left),
7251 ] {
7252 let text = outcome_comment(&state, &Ledger::new(), &ending, &style()).unwrap();
7253 let lower = text.to_lowercase();
7254 for banned in ["claude", "codex", "blocking,", "nit,", " fixed."] {
7255 assert!(
7256 !lower.contains(banned),
7257 "{banned:?} leaked into the thread:\n{text}"
7258 );
7259 }
7260 for n in 1..9 {
7262 assert!(
7263 !lower.contains(&format!("round {n}")),
7264 "a round number leaked into the thread:\n{text}"
7265 );
7266 }
7267 }
7268 }
7269
7270 #[test]
7271 fn a_refutation_is_allowed_to_make_its_case() {
7274 let reasoning = "The caller validates against the schema first. \
7275 The discarded error is therefore unreachable in practice. ";
7276 let state = state_with(
7277 vec![("A point", &reasoning.repeat(6))],
7278 vec!["https://github.com/you/thing/issues/485"],
7279 );
7280 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
7281 assert!(
7282 !text.contains("..."),
7283 "nothing was cut mid thought:\n{text}"
7284 );
7285 assert!(text.len() < 4000, "{} chars", text.len());
7286 }
7287
7288 #[test]
7289 fn a_url_that_is_not_an_issue_link_is_left_alone() {
7290 assert_eq!(
7291 "#485",
7292 as_reference("https://github.com/you/thing/issues/485")
7293 );
7294 assert_eq!("note: something", as_reference("note: something"));
7295 }
7296}
7297
7298#[cfg(test)]
7299mod filed_reference_tests {
7300 use super::*;
7301
7302 #[test]
7303 fn an_issue_url_yields_its_number() {
7304 assert_eq!(
7305 Some(485),
7306 filed_issue_number("https://github.com/you/thing/issues/485")
7307 );
7308 }
7309
7310 #[test]
7313 fn a_local_note_yields_nothing() {
7314 assert_eq!(None, filed_issue_number("note: Retry is unbounded"));
7315 assert_eq!(None, filed_issue_number(""));
7316 assert_eq!(
7317 None,
7318 filed_issue_number("https://github.com/you/thing/issues/")
7319 );
7320 }
7321}
7322
7323#[cfg(test)]
7324mod followup_restraint_tests {
7325 use super::*;
7326 use crate::model::Severity;
7327
7328 fn cfg_with(followups: Followups, non_blocking: bool, nits: bool, cap: usize) -> Config {
7329 let mut cfg =
7330 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
7331 .unwrap();
7332 cfg.loop_cfg.followups = followups;
7333 cfg.loop_cfg.file_non_blocking = non_blocking;
7334 cfg.loop_cfg.file_nits = nits;
7335 cfg.loop_cfg.max_followups = cap;
7336 cfg
7337 }
7338
7339 fn finding(severity: Severity, title: &str, in_scope: bool) -> Finding {
7340 Finding {
7341 severity,
7342 title: title.into(),
7343 detail: "d".into(),
7344 file: "a.rs".into(),
7345 in_scope,
7346 ..Default::default()
7347 }
7348 }
7349
7350 #[test]
7354 fn a_non_blocking_finding_is_not_a_tracker_item_by_default() {
7355 let cfg = cfg_with(Followups::Issues, false, false, 5);
7356 assert!(!cfg.loop_cfg.file_non_blocking);
7357 assert!(!cfg.loop_cfg.file_nits);
7358 }
7359
7360 #[test]
7361 fn follow_ups_stay_off_the_tracker_by_default() {
7362 let cfg =
7363 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
7364 .unwrap();
7365 assert_eq!(
7366 Followups::Local,
7367 cfg.loop_cfg.followups,
7368 "the tracker is somebody's queue; the default must not write to it"
7369 );
7370 assert_eq!(5, cfg.loop_cfg.max_followups);
7371 }
7372
7373 #[test]
7375 fn only_out_of_scope_defects_qualify_at_the_defaults() {
7376 let cfg = cfg_with(Followups::Issues, false, false, 5);
7377 let qualifies = |f: &Finding| match f.severity {
7378 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking && f.in_scope,
7379 Severity::Nit => cfg.loop_cfg.file_nits && f.in_scope,
7380 Severity::Blocking => false,
7381 } || !f.in_scope;
7382
7383 assert!(qualifies(&finding(
7384 Severity::Blocking,
7385 "pre-existing",
7386 false
7387 )));
7388 assert!(!qualifies(&finding(
7389 Severity::NonBlocking,
7390 "improvement",
7391 true
7392 )));
7393 assert!(!qualifies(&finding(Severity::Nit, "taste", true)));
7394 assert!(!qualifies(&finding(
7395 Severity::Blocking,
7396 "fix it here",
7397 true
7398 )));
7399 }
7400
7401 #[test]
7402 fn opening_it_up_lets_non_blocking_findings_through_again() {
7403 let cfg = cfg_with(Followups::Issues, true, false, 5);
7404 assert!(cfg.loop_cfg.file_non_blocking);
7405 }
7406
7407 #[test]
7409 fn the_cap_is_a_real_backstop() {
7410 let cfg = cfg_with(Followups::Issues, false, false, 3);
7411 let mut state = IssueRun::new(1, "t");
7412 state.filed = (0..3).map(|n| format!("url{n}")).collect();
7413 assert!(state.filed.len() >= cfg.loop_cfg.max_followups);
7414 }
7415
7416 #[test]
7420 fn the_cap_bounds_what_one_run_can_spawn() {
7421 let cfg = cfg_with(Followups::Issues, false, false, 5);
7422 assert!(
7423 cfg.loop_cfg.max_followups <= 5,
7424 "a run that can file ten follow-ups is a branching process"
7425 );
7426 }
7427}
7428
7429#[cfg(test)]
7433mod followup_outcome_tests {
7434 use super::*;
7435
7436 const URL: &str = "https://github.com/you/thing/issues/485";
7437
7438 fn entry(recorded: Followup) -> Option<(Settled, String)> {
7439 filed_entry(&recorded, "It predates this branch.")
7440 }
7441
7442 #[test]
7446 fn a_failed_followup_settles_nothing() {
7447 assert_eq!(None, entry(Followup::Failed));
7448 }
7449
7450 #[test]
7451 fn an_uncertain_external_write_blocks_later_issue_followups_for_this_run() {
7452 let mut state = IssueRun::new(1, "review");
7453 let uncertain = SparError::uncertain_write("the result could not be verified");
7454 assert_eq!(Followup::Failed, failed_followup(&mut state, &uncertain));
7455 assert!(external_followup_write_paused(Followups::Issues, &state));
7456 assert!(!external_followup_write_paused(Followups::Local, &state));
7457 assert_eq!(1, state.notes.len());
7458
7459 assert_eq!(Followup::Failed, failed_followup(&mut state, &uncertain));
7460 assert_eq!(1, state.notes.len(), "the recovery note was duplicated");
7461
7462 let mut ordinary = IssueRun::new(2, "review");
7463 let error = SparError::new("permission denied");
7464 assert_eq!(Followup::Failed, failed_followup(&mut ordinary, &error));
7465 assert!(!external_followup_write_paused(
7466 Followups::Issues,
7467 &ordinary
7468 ));
7469 }
7470
7471 #[test]
7472 fn a_recorded_followup_is_filed_and_says_where() {
7473 let (outcome, reasoning) = entry(Followup::Recorded(URL.into())).unwrap();
7474 assert_eq!(Settled::Filed, outcome);
7475 assert!(
7476 reasoning.contains("It predates this branch."),
7477 "{reasoning}"
7478 );
7479 assert!(reasoning.contains("#485"), "{reasoning}");
7480 }
7481
7482 #[test]
7491 fn a_finding_that_matches_a_closed_issue_is_filed_again_with_the_reference() {
7492 let body = regression_body("The retry loop spins on 429.", 485);
7493 assert!(body.contains("The retry loop spins on 429."), "{body}");
7494 assert!(body.contains("#485"), "{body}");
7495 assert!(body.contains("closed"), "{body}");
7496
7497 let recorded = Followup::from(Filed::Opened(9, URL.into()));
7499 assert_eq!(Followup::Recorded(URL.into()), recorded);
7500 assert_eq!(Some(URL), recorded.url());
7501 }
7502
7503 #[test]
7506 fn an_open_issue_that_already_covers_the_point_is_still_a_reference() {
7507 for filed in [
7508 Filed::Opened(9, URL.into()),
7509 Filed::AddedTo(9, URL.into()),
7510 Filed::Covered(9, URL.into()),
7511 ] {
7512 assert_eq!(Some(URL), Followup::from(filed).url());
7513 }
7514 }
7515
7516 #[test]
7520 fn a_dropped_followup_is_settled_but_never_reported_as_filed() {
7521 let (outcome, reasoning) = entry(Followup::Dropped("follow-ups are off")).unwrap();
7522 assert_eq!(Settled::Dropped, outcome);
7523 assert!(reasoning.contains("follow-ups are off"), "{reasoning}");
7524 assert!(reasoning.contains("Not filed"), "{reasoning}");
7525 }
7526
7527 fn ledger_of(outcome: Settled, reasoning: &str) -> Ledger {
7528 let mut ledger = Ledger::new();
7529 ledger.insert(
7530 finding_key("A pre-existing leak", "src/x.rs"),
7531 LedgerEntry {
7532 title: "A pre-existing leak".into(),
7533 file: "src/x.rs".into(),
7534 reasoning: reasoning.into(),
7535 round: 1,
7536 reraised: 0,
7537 outcome,
7538 },
7539 );
7540 ledger
7541 }
7542
7543 #[test]
7547 fn the_settled_block_tells_a_filed_point_from_a_dropped_one() {
7548 let filed = settled_block(&ledger_of(Settled::Filed, "Tracked in #9."));
7549 assert!(filed.contains("out of scope here, and filed"), "{filed}");
7550
7551 let dropped = settled_block(&ledger_of(Settled::Dropped, "Not filed anywhere: off."));
7552 assert!(
7553 dropped.contains("out of scope here, and not filed"),
7554 "{dropped}"
7555 );
7556 assert!(dropped.contains("A pre-existing leak"), "{dropped}");
7557 }
7558
7559 #[test]
7562 fn a_deadlocked_point_that_was_never_filed_does_not_claim_to_be() {
7563 let points = [Finding {
7564 severity: Severity::Blocking,
7565 title: "A pre-existing leak".into(),
7566 detail: "d".into(),
7567 file: "src/x.rs".into(),
7568 in_scope: false,
7569 ..Default::default()
7570 }];
7571 let text = outcome_comment(
7572 &IssueRun::new(1, "t"),
7573 &ledger_of(Settled::Dropped, "Not filed anywhere: follow-ups are off."),
7574 &Ending::Deadlocked(&points),
7575 &Style::default(),
7576 )
7577 .unwrap();
7578 assert!(text.contains("not filed"), "{text}");
7579 assert!(!text.contains("Filed as out of scope"), "{text}");
7580 }
7581}
7582
7583#[cfg(test)]
7584mod issue_report_tests {
7585 use super::*;
7586 use crate::model::Severity;
7587
7588 fn reported() -> Finding {
7591 Finding {
7592 severity: Severity::Blocking,
7593 title: "sendPaymentAsync bypasses drain mode and spending limits".into(),
7594 detail: "The async path skips every admission check payInvoice applies.".into(),
7595 file: "src/node.ts:412".into(),
7596 in_scope: false,
7597 problem: Some(
7598 "`BeignetNode.sendPaymentAsync()` submits a payment directly to the Lightning \
7599 engine without applying the safeguards used by `payInvoice()`.\n\nThe async path \
7600 does not:\n\n- call `_checkDraining()`\n- call `_checkSpendLimit()`"
7601 .into(),
7602 ),
7603 reproduction: Some(
7604 "1. Create a `BeignetNode` with `dailySpendLimitSats: 1`.\n2. Enable drain mode.\n\
7605 3. Submit a 1,000 sat invoice.\n\nActual result:\n\n- The engine is called.\n\
7606 - `spentSats` remains 0."
7607 .into(),
7608 ),
7609 impact: Some(
7610 "An authorized client can submit async payments up to the available outbound \
7611 liquidity despite the configured limits."
7612 .into(),
7613 ),
7614 expected: Some(
7615 "- Reject new payments while draining.\n- Enforce the per-payment limit before \
7616 submission.\n- Cover both paths with regression tests.\n\nThis predates the \
7617 current branch."
7618 .into(),
7619 ),
7620 }
7621 }
7622
7623 #[test]
7624 fn a_reported_finding_becomes_a_bug_report() {
7625 let body = issue_report(&reported());
7626 for heading in [
7627 "## Problem",
7628 "## Reproduction",
7629 "## Impact",
7630 "## Expected behavior",
7631 ] {
7632 assert!(body.contains(heading), "missing {heading}:\n{body}");
7633 }
7634 let at = |h: &str| body.find(h).unwrap();
7636 assert!(at("## Problem") < at("## Reproduction"));
7637 assert!(at("## Reproduction") < at("## Impact"));
7638 assert!(at("## Impact") < at("## Expected behavior"));
7639 }
7640
7641 #[test]
7642 fn the_substance_survives_the_outbound_gates() {
7643 let repo_style = Style::default();
7644 let body = crate::style::issue_body(&issue_report(&reported()), &repo_style);
7645 for kept in [
7646 "_checkDraining()",
7647 "Actual result:",
7648 "outbound liquidity",
7649 "regression tests",
7650 "predates the current branch",
7651 ] {
7652 assert!(body.contains(kept), "the gate ate {kept:?}:\n{body}");
7653 }
7654 assert!(!body.contains("..."), "something was cut:\n{body}");
7655 }
7656
7657 #[test]
7660 fn an_ordinary_finding_is_still_just_its_detail() {
7661 let plain = Finding {
7662 severity: Severity::NonBlocking,
7663 title: "Name is vague".into(),
7664 detail: "The variable could say what it holds.".into(),
7665 file: "a.rs".into(),
7666 in_scope: true,
7667 ..Default::default()
7668 };
7669 assert_eq!(
7670 "The variable could say what it holds.",
7671 issue_report(&plain)
7672 );
7673 }
7674
7675 #[test]
7678 fn only_the_sections_that_were_written_appear() {
7679 let partial = Finding {
7680 problem: Some("The guard is inverted.".into()),
7681 expected: Some("It should reject rather than accept.".into()),
7682 ..reported()
7683 };
7684 let partial = Finding {
7685 reproduction: None,
7686 impact: None,
7687 ..partial
7688 };
7689 let body = issue_report(&partial);
7690 assert!(body.contains("## Problem") && body.contains("## Expected behavior"));
7691 assert!(!body.contains("## Reproduction"), "{body}");
7692 assert!(!body.contains("## Impact"), "{body}");
7693 }
7694
7695 #[test]
7698 fn the_summary_line_is_not_printed_twice() {
7699 let echoed = Finding {
7700 detail: "The guard is inverted so it rejects valid input.".into(),
7701 problem: Some("The guard is inverted so it rejects valid input.".into()),
7702 reproduction: None,
7703 impact: None,
7704 expected: None,
7705 ..reported()
7706 };
7707 let body = issue_report(&echoed);
7708 assert_eq!(1, body.matches("The guard is inverted").count(), "{body}");
7709 }
7710}