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
913 .publish_new_branch(work_dir, branch, &base)
914 .map_err(|e| {
915 e.with_message(format!(
916 "{e}\nThe work is kept on the local branch {branch} in {}. To continue it \
917 without implementing again, push that branch, open a pull request for it and \
918 run `spar resume <pr>`.",
919 work_dir.display()
920 ))
921 })?;
922
923 let description = pr_body(number, &work, checked.as_deref(), &criteria, &repo.style);
924 let pr = match repo.pr_for_branch(branch) {
925 Some(existing) => existing,
926 None => {
927 let body = description.clone();
928 repo.create_pr(
929 work_dir,
930 branch,
931 &target,
932 &base,
933 &format!("{} (#{number})", item.title),
934 &body,
935 )?
936 }
937 };
938 repo.record_branch(branch, "pr", pr.number);
939 state.pr = Some(pr.url.clone());
940 log!("#{number}: PR {}", pr.url);
941
942 let ctx = LoopCtx {
943 work_dir: work_dir.to_path_buf(),
944 branch: branch.to_string(),
945 target,
946 pr_number: pr.number,
947 context: context_block(number, &issue.url, &body, &description),
948 label: format!("#{number}"),
949 subject: number,
950 title: item.title.clone(),
951 start_round: 1,
952 holder: cfg.other(&holder),
953 release: Release::Issue(number),
954 criteria,
955 };
956 review_loop(agents, cfg, repo, &ctx, state, ledger, Vec::new())
957}
958
959pub fn resume_pr(
970 agents: &[Agent],
971 cfg: &Config,
972 repo: &Repo,
973 pr_number: i64,
974 holder_override: Option<&str>,
975) -> IssueRun {
976 let failed = |e: SparError| {
977 log!("PR #{pr_number} failed: {e}");
978 let mut state = IssueRun::new(pr_number, format!("PR #{pr_number}"));
979 state.status = Status::Error;
980 state.notes.push(e.to_string());
981 state
982 };
983
984 let pr = match repo.pr_view(pr_number) {
985 Ok(pr) => pr,
986 Err(e) => return failed(e),
987 };
988
989 let Some(target) = repo.pr_push_target(&pr) else {
996 log!("PR #{pr_number} comes from a fork, reviewing it without changing it");
997 return crate::review_only::review_pr(
998 agents,
999 cfg,
1000 repo,
1001 pr_number,
1002 crate::review_only::ReviewOpts::default(),
1003 );
1004 };
1005
1006 match resume_inner(agents, cfg, repo, pr, target, holder_override) {
1007 Ok(state) => state,
1008 Err(e) => failed(e),
1009 }
1010}
1011
1012fn resume_inner(
1013 agents: &[Agent],
1014 cfg: &Config,
1015 repo: &Repo,
1016 pr: PrView,
1017 target: PushTarget,
1018 holder_override: Option<&str>,
1019) -> Result<IssueRun> {
1020 let pr_number = pr.number;
1021 if !pr.is_open() {
1022 return Err(spar_err!("PR #{pr_number} is {}", pr.state.to_lowercase()));
1023 }
1024
1025 let subject = pr
1026 .closing_issues_references
1027 .first()
1028 .map(|r| r.number)
1029 .unwrap_or(pr_number);
1030 let closing_url = pr.closing_issues_references.first().map(|r| &r.url);
1031
1032 if let Some(holder) = holder_override {
1033 if !cfg.has_agent(holder) {
1034 return Err(spar_err!(
1035 "--next must name one of: {}",
1036 cfg.agent_names().join(", ")
1037 ));
1038 }
1039 }
1040 let (work_dir, branch) = repo.worktree_for_pr(&pr, &target)?;
1041 let actual_head = checked_head(repo, &work_dir)?;
1042 let saved = repo.read_state_for_head(&pr, &actual_head);
1043 let saved_actor_known = saved
1044 .as_ref()
1045 .map(|state| cfg.has_agent(&state.next_actor))
1046 .unwrap_or(false);
1047 let state_matches_head = reconcile_saved_head(
1048 saved.as_ref().map(|state| state.pr_head.as_str()),
1049 saved.as_ref().map(|state| state.version),
1050 saved_actor_known,
1051 &actual_head,
1052 holder_override,
1053 pr_number,
1054 )?;
1055 let migrates_legacy_state = saved.as_ref().is_some_and(|state| {
1056 state.version == 1
1057 && state.pr_head.is_empty()
1058 && saved_actor_known
1059 && holder_override.is_none()
1060 });
1061 if migrates_legacy_state {
1062 log!("PR #{pr_number}: migrating legacy review state to head {actual_head}");
1063 }
1064
1065 let mut ledger: Ledger = saved
1066 .as_ref()
1067 .filter(|_| state_matches_head)
1068 .map(|s| s.ledger.clone())
1069 .unwrap_or_default();
1070 normalise_ledger_keys(&mut ledger);
1071 let open_findings = saved
1072 .as_ref()
1073 .filter(|_| state_matches_head)
1074 .map(|s| blocking_findings(&s.open_findings))
1075 .unwrap_or_default();
1076 let start_round = saved
1077 .as_ref()
1078 .filter(|_| state_matches_head)
1079 .map(|s| s.round + 1)
1080 .unwrap_or(1);
1081
1082 let default_holder = cfg.other(&cfg.first_implementor);
1083 let mut holder = holder_override
1084 .map(str::to_string)
1085 .or_else(|| saved.as_ref().map(|s| s.next_actor.clone()))
1086 .unwrap_or_else(|| default_holder.clone());
1087 if !cfg.has_agent(&holder) {
1088 log!("state named unknown agent '{holder}', using {default_holder}");
1089 holder = default_holder;
1090 }
1091
1092 match &saved {
1093 Some(_) => log!(
1094 "PR #{pr_number}: resuming at round {start_round}, {} point(s) on record, next up \
1095 {holder}",
1096 ledger.len()
1097 ),
1098 None => log!("PR #{pr_number}: no prior spar state, starting fresh with {holder}"),
1099 }
1100
1101 let mut state = IssueRun::new(subject, pr.title.clone());
1102 state.pr = Some(pr.url.clone());
1103 if let Some(s) = &saved {
1104 state.filed = s.filed.clone();
1105 if state_matches_head {
1106 state.disputes = s.disputes.clone();
1107 state.noted = s.noted.clone();
1108 } else {
1109 log!(
1110 "PR #{pr_number}: saved state does not match {actual_head}; branch-dependent \
1111 review state was cleared"
1112 );
1113 }
1114 }
1115
1116 let issue = (subject != pr_number)
1119 .then(|| repo.read_issue(subject))
1120 .transpose()
1121 .unwrap_or_else(|e| {
1122 logdim!("could not read issue #{subject} for context: {e}");
1123 None
1124 });
1125 let (issue_body, issue_url) = match &issue {
1126 Some(issue) => {
1127 let text = issue_for_prompt(
1128 issue,
1129 cfg.loop_cfg.max_issue_chars,
1130 cfg.loop_cfg.checkin_trust,
1131 );
1132 text.report(&format!("PR #{pr_number}"));
1133 (text.text, issue.url.clone())
1134 }
1135 None => (String::new(), String::new()),
1136 };
1137
1138 let ctx = LoopCtx {
1139 work_dir,
1140 branch,
1141 target,
1142 pr_number,
1143 context: context_block(subject, &issue_url, &issue_body, &pr.body),
1144 label: format!("PR #{pr_number}"),
1145 subject,
1146 title: pr.title.clone(),
1147 start_round,
1148 holder,
1149 release: Release::Pr(pr_number),
1150 criteria: issue
1153 .as_ref()
1154 .filter(|issue| Some(&issue.url) == closing_url)
1155 .map(|issue| tracker::criteria(issue.body_text()))
1156 .unwrap_or_default(),
1157 };
1158
1159 let outcome = review_loop(
1160 agents,
1161 cfg,
1162 repo,
1163 &ctx,
1164 &mut state,
1165 &mut ledger,
1166 open_findings,
1167 );
1168 if let Err(e) = outcome {
1169 state.status = Status::Error;
1170 state.notes.push(e.to_string());
1171 log!("PR #{pr_number} failed: {e}");
1172 }
1173 if should_release(cfg, state.status) {
1174 repo.release_pr_worktree(pr_number);
1175 }
1176 Ok(state)
1177}
1178
1179#[derive(Debug, Clone, Copy)]
1184enum Release {
1185 Issue(i64),
1186 Pr(i64),
1187}
1188
1189struct LoopCtx {
1190 work_dir: PathBuf,
1191 branch: String,
1192 target: PushTarget,
1195 pr_number: i64,
1196 label: String,
1197 subject: i64,
1198 title: String,
1199 context: String,
1202 start_round: u32,
1203 holder: String,
1204 release: Release,
1205 criteria: Vec<Item>,
1207}
1208
1209impl LoopCtx {
1210 fn release(&self, repo: &Repo) -> bool {
1211 match self.release {
1212 Release::Issue(n) => repo.worktree_remove(n),
1213 Release::Pr(n) => repo.release_pr_worktree(n),
1214 }
1215 }
1216}
1217
1218fn review_loop(
1219 agents: &[Agent],
1220 cfg: &Config,
1221 repo: &Repo,
1222 ctx: &LoopCtx,
1223 state: &mut IssueRun,
1224 ledger: &mut Ledger,
1225 mut open_findings: Vec<Finding>,
1226) -> Result<()> {
1227 let base = cfg.base_branch().to_string();
1228 let mut holder = ctx.holder.clone();
1232
1233 let (first, last_allowed) = round_window(ctx.start_round, cfg.loop_cfg.max_rounds);
1239 let mut published_head = checked_head(repo, &ctx.work_dir)?;
1240 persist(
1241 repo,
1242 ctx.pr_number,
1243 state,
1244 ledger,
1245 &open_findings,
1246 &published_head,
1247 first.saturating_sub(1),
1248 &holder,
1249 )?;
1250 let base_ref = repo.base_ref(&ctx.work_dir, &base);
1256
1257 let mut audited_head = String::new();
1261 let mut last_round = first.saturating_sub(1);
1262
1263 for round in first..=last_allowed {
1264 last_round = round;
1265 state.rounds = round;
1266 let reviewer = agent::find(agents, &holder)?;
1267 let effort = reviewer.effort(cfg, Call::Review(round)).about(ctx.subject);
1268 log!(
1269 "{}: round {round}, {holder} reviewing ({})",
1270 ctx.label,
1271 effort.describe()
1272 );
1273
1274 let prompt = review_prompt(
1275 &base_ref,
1276 ctx.subject,
1277 &ctx.title,
1278 &ctx.context,
1279 ledger,
1280 &open_findings,
1281 round,
1282 last_allowed,
1283 &ctx.criteria,
1284 );
1285 let before_review = snapshot(repo, &ctx.work_dir);
1286 let review_baseline = repo.worktree_baseline(&ctx.work_dir)?;
1287 audited_head = before_review.head.clone();
1290 let review = reviewer.review::<Review>(
1291 &base_ref,
1292 &prompt,
1293 &review_schema(&ctx.criteria),
1294 &ctx.work_dir,
1295 &effort,
1296 );
1297 if let Err(error) = &review {
1298 if error.kind() == crate::error::ErrorKind::UncertainWrite {
1299 return Err(error.clone());
1300 }
1301 }
1302 repo.refuse_unrepresented_tracked_changes(&ctx.work_dir, &review_baseline)?;
1303 repo.refuse_read_only_leavings(&ctx.work_dir, &review_baseline)?;
1304 let review = review?;
1305 if repo.has_uncommitted_changes(&ctx.work_dir)? {
1306 bail!(
1307 "{}: {holder} left uncommitted files while reviewing. They were kept at {} and \
1308 the review did not continue.",
1309 ctx.label,
1310 ctx.work_dir.display()
1311 );
1312 }
1313
1314 let mut editor: Option<String> = None;
1318 let review_wrote = snapshot(repo, &ctx.work_dir) != before_review;
1319 if review_wrote {
1320 logwarn!(
1321 "{}: {holder} changed the branch while reviewing it, which the review prompt \
1322 forbids. Rolling it back.",
1323 ctx.label
1324 );
1325 if undo_edits(repo, &ctx.work_dir, &before_review).head != before_review.head {
1326 state
1327 .notes
1328 .push(format!("{holder} committed during its own review"));
1329 editor = Some(holder.clone());
1330 }
1331 }
1332
1333 let blocking = blocking_findings(&review.findings);
1334 if let Some(said) = verdict_disagreement(&review, blocking.len()) {
1335 logwarn!("{}: {holder} {said}", ctx.label);
1336 }
1337 update_open_findings(&mut open_findings, &blocking, !review_wrote);
1338
1339 if repo.style.pr_comments == PrComments::Rounds {
1340 if let Err(e) = repo.comment_pr(
1341 ctx.pr_number,
1342 &review_comment(&holder, round, &review, &repo.style),
1343 ) {
1344 logdim!("could not post the review comment: {e}");
1345 }
1346 }
1347
1348 file_out_of_scope(repo, &review.findings, ctx.subject, state, cfg);
1352 file_nonblocking(repo, &review.findings, ctx.subject, state, cfg);
1353 remove_findings(&mut state.noted, &blocking);
1354
1355 if check_relitigation(ledger, &blocking, state) {
1356 state.status = Status::Escalated;
1357 post_outcome(
1358 repo,
1359 ctx.pr_number,
1360 state,
1361 ledger,
1362 Ending::Deadlocked(&blocking),
1363 );
1364 persist(
1365 repo,
1366 ctx.pr_number,
1367 state,
1368 ledger,
1369 &open_findings,
1370 &published_head,
1371 round,
1372 &holder,
1373 )?;
1374 return Ok(());
1375 }
1376
1377 if approval_stands(&blocking, review_wrote) {
1378 open_findings.clear();
1379 state.criteria = ruled(&ctx.criteria, &review.criteria);
1380 return approve(
1381 cfg,
1382 repo,
1383 ctx,
1384 state,
1385 ledger,
1386 &published_head,
1387 round,
1388 &holder,
1389 );
1390 }
1391
1392 persist(
1395 repo,
1396 ctx.pr_number,
1397 state,
1398 ledger,
1399 &open_findings,
1400 &published_head,
1401 round,
1402 &holder,
1403 )?;
1404
1405 let mut edit_error = None;
1406
1407 if blocking.is_empty() {
1408 logwarn!(
1413 "{}: {holder} found nothing blocking on a branch it had changed itself, so the \
1414 approval does not carry.",
1415 ctx.label
1416 );
1417 state.notes.push(format!(
1418 "{holder} passed the branch in round {round} after editing it; the edit was rolled \
1419 back and the approval did not stand"
1420 ));
1421 } else if review.next_action == NextAction::FixMyself {
1422 log!("{}: {holder} fixing its own findings", ctx.label);
1423 let prompt = FIX_PROMPT.replace("{findings}", &findings_for_prompt(&blocking));
1424 let before_fix = repo.head_oid_checked(&ctx.work_dir)?;
1425 let worktree_baseline = repo.worktree_baseline(&ctx.work_dir)?;
1426 let mut report: Option<OwnFixDoc> = None;
1427 let fix_error = match reviewer.edit_json::<OwnFixDoc>(
1428 &prompt,
1429 &schema::own_fix(),
1430 &ctx.work_dir,
1431 &effort,
1432 ) {
1433 Ok(doc) => {
1434 commit_accepted_changes(
1435 cfg,
1436 repo,
1437 &ctx.work_dir,
1438 &worktree_baseline,
1439 &doc.summary,
1440 "Address blocking review findings",
1441 )?;
1442 report = Some(doc);
1443 None
1444 }
1445 Err(error) => Some(defer_clean_edit_error(
1446 repo,
1447 &ctx.work_dir,
1448 &worktree_baseline,
1449 error,
1450 )?),
1451 };
1452 match editor_after(repo, &ctx.work_dir, &before_fix, &ctx.label, &holder)? {
1453 Some(who) => {
1454 let fixes = report.as_ref().map(|d| d.fixes.as_slice()).unwrap_or(&[]);
1466 let (claimed, left) = split_own_fixes(&blocking, fixes);
1467 record_own_fixes(&claimed, ledger, state, round);
1468 remove_findings(&mut open_findings, &blocking);
1469 extend_findings(&mut open_findings, &left);
1470 if !left.is_empty() {
1471 state.notes.push(format!(
1472 "{holder} fixed {} of {} of its own findings in round {round}; the \
1473 rest stay open",
1474 claimed.len(),
1475 blocking.len()
1476 ));
1477 }
1478 editor = Some(who);
1479 }
1480 None if fix_error.is_none() => {
1481 repo.refuse_new_ignored_files(&ctx.work_dir, &worktree_baseline)?;
1482 logwarn!(
1485 "{}: {holder} said it would fix its own findings and committed nothing, \
1486 so it keeps the pull request.",
1487 ctx.label
1488 );
1489 state.notes.push(format!(
1490 "{holder} chose to fix its own findings in round {round} and committed \
1491 nothing"
1492 ));
1493 }
1494 None => {}
1495 }
1496 edit_error = fix_error;
1497 } else {
1498 let author_name = cfg.other(&holder);
1499 let author = agent::find(agents, &author_name)?;
1500 log!(
1501 "{}: handing {} finding(s) to {author_name}",
1502 ctx.label,
1503 blocking.len()
1504 );
1505 let prompt = respond_prompt(ctx.subject, &ctx.context, &blocking, &review.findings);
1506 let before_response = repo.head_oid_checked(&ctx.work_dir)?;
1507 let worktree_baseline = repo.worktree_baseline(&ctx.work_dir)?;
1508 let response: Result<ResponseDoc> = author.edit_json(
1509 &prompt,
1510 &schema::response(),
1511 &ctx.work_dir,
1512 &author.effort(cfg, Call::Respond(round)).about(ctx.subject),
1513 );
1514 let response = match response {
1515 Ok(response) => {
1516 commit_accepted_changes(
1517 cfg,
1518 repo,
1519 &ctx.work_dir,
1520 &worktree_baseline,
1521 &response.summary,
1522 "Address blocking review findings",
1523 )?;
1524 Some(response)
1525 }
1526 Err(error) => {
1527 edit_error = Some(defer_clean_edit_error(
1528 repo,
1529 &ctx.work_dir,
1530 &worktree_baseline,
1531 error,
1532 )?);
1533 None
1534 }
1535 };
1536 if let Some(who) = editor_after(
1537 repo,
1538 &ctx.work_dir,
1539 &before_response,
1540 &ctx.label,
1541 &author_name,
1542 )? {
1543 editor = Some(who);
1544 } else if let Some(response) = &response {
1545 repo.refuse_new_ignored_files(&ctx.work_dir, &worktree_baseline)?;
1546 if response
1547 .dispositions
1548 .iter()
1549 .any(|d| d.action == Action::Fixed)
1550 {
1551 logwarn!(
1552 "{}: {author_name} reported fixes but committed nothing, so the diff does \
1553 not have them.",
1554 ctx.label
1555 );
1556 }
1557 }
1558 if let Some(response) = response {
1559 let mut used = vec![false; response.dispositions.len()];
1563 let unresolved = apply_dispositions(
1564 repo,
1565 cfg,
1566 &response,
1567 &blocking,
1568 ledger,
1569 state,
1570 round,
1571 ctx.subject,
1572 ctx.pr_number,
1573 &author_name,
1574 editor.is_some(),
1575 &mut used,
1576 );
1577 remove_findings(&mut open_findings, &blocking);
1578 extend_findings(&mut open_findings, &unresolved);
1579 apply_optional_dispositions(
1580 repo,
1581 cfg,
1582 &response,
1583 &review.findings,
1584 state,
1585 ctx.subject,
1586 &mut used,
1587 );
1588 warn_unplaced_dispositions(&response, &used);
1589 }
1590 }
1591
1592 if let Some(who) = &editor {
1593 check_before_push(
1594 cfg,
1595 repo,
1596 agent::find(agents, who)?,
1597 &ctx.work_dir,
1598 &ctx.label,
1599 Call::Respond(round),
1600 )?;
1601 }
1602 if editor.is_some() {
1603 repo.rewrite_commits_if_needed(&ctx.work_dir, &base, Some(&published_head))?;
1604 repo.push_to(
1605 &ctx.work_dir,
1606 &ctx.target,
1607 &ctx.branch,
1608 Some(&published_head),
1609 )?;
1610 published_head = checked_head(repo, &ctx.work_dir)?;
1611 }
1612 holder = next_reviewer(cfg, &holder, editor.as_deref());
1613 persist(
1614 repo,
1615 ctx.pr_number,
1616 state,
1617 ledger,
1618 &open_findings,
1619 &published_head,
1620 round,
1621 &holder,
1622 )?;
1623 if let Some(error) = edit_error {
1624 return Err(error);
1625 }
1626 }
1627
1628 close_out(
1635 agents,
1636 cfg,
1637 repo,
1638 ctx,
1639 state,
1640 ledger,
1641 &mut open_findings,
1642 &holder,
1643 last_round,
1644 &audited_head,
1645 &published_head,
1646 )
1647}
1648
1649#[allow(clippy::too_many_arguments)]
1660fn close_out(
1661 agents: &[Agent],
1662 cfg: &Config,
1663 repo: &Repo,
1664 ctx: &LoopCtx,
1665 state: &mut IssueRun,
1666 ledger: &mut Ledger,
1667 open_findings: &mut Vec<Finding>,
1668 holder: &str,
1669 round: u32,
1670 audited_head: &str,
1671 published_head: &str,
1672) -> Result<()> {
1673 let stop =
1674 |state: &mut IssueRun, ledger: &Ledger, open_findings: &[Finding], ending: Ending<'_>| {
1675 state.status = Status::Escalated;
1676 state.notes.push(exhausted_note(ctx.start_round, round));
1677 post_outcome(repo, ctx.pr_number, state, ledger, ending);
1678 persist(
1679 repo,
1680 ctx.pr_number,
1681 state,
1682 ledger,
1683 open_findings,
1684 published_head,
1685 round,
1686 holder,
1687 )
1688 };
1689
1690 let landed = (!audited_head.is_empty())
1697 .then(|| repo.commits_since(&ctx.work_dir, audited_head, "HEAD"))
1698 .flatten();
1699 if audited_head.is_empty()
1700 || (landed.as_ref().is_some_and(|l| l.is_empty()) && !any_fixes(ledger, round))
1701 {
1702 logdim!(
1703 "{}: nothing landed after the last review, so there is nothing to close over",
1704 ctx.label
1705 );
1706 if !open_findings.is_empty() {
1707 state.notes.push(unresolved_note(open_findings.len()));
1708 }
1709 stop(
1710 state,
1711 ledger,
1712 open_findings,
1713 ending_without_landing(open_findings),
1714 )?;
1715 return Ok(());
1716 }
1717
1718 let closer = agent::find(agents, holder)?;
1719 let effort = closer.effort(cfg, Call::Close).about(ctx.subject);
1720 log!(
1721 "{}: closing, {holder} checking what the last round left ({})",
1722 ctx.label,
1723 effort.describe()
1724 );
1725
1726 let prompt = close_prompt(
1727 &repo.base_ref(&ctx.work_dir, cfg.base_branch()),
1728 ctx.subject,
1729 &ctx.title,
1730 &ctx.context,
1731 audited_head,
1732 landed.as_deref(),
1733 ledger,
1734 open_findings,
1735 round,
1736 &ctx.criteria,
1737 );
1738 let before = snapshot(repo, &ctx.work_dir);
1739 let closing_baseline = repo.worktree_baseline(&ctx.work_dir)?;
1740 let pass = closer.ask_json(
1744 &prompt,
1745 &review_schema(&ctx.criteria),
1746 &ctx.work_dir,
1747 &effort,
1748 );
1749 if let Err(e) = &pass {
1750 if e.kind() == crate::error::ErrorKind::UncertainWrite {
1751 return Err(e.clone());
1752 }
1753 }
1754 repo.refuse_unrepresented_tracked_changes(&ctx.work_dir, &closing_baseline)?;
1755 repo.refuse_read_only_leavings(&ctx.work_dir, &closing_baseline)?;
1756 if repo.has_uncommitted_changes(&ctx.work_dir)? {
1757 bail!(
1758 "{}: {holder} left uncommitted files during the closing pass. They were kept at {} \
1759 and the pass did not continue.",
1760 ctx.label,
1761 ctx.work_dir.display()
1762 );
1763 }
1764
1765 let close_wrote = snapshot(repo, &ctx.work_dir) != before;
1768 let next = closing_next_actor(holder);
1772 if close_wrote {
1773 if let Err(error) = &pass {
1774 logwarn!(
1775 "{}: the closing pass failed after changing the branch: {error}",
1776 ctx.label
1777 );
1778 }
1779 logwarn!(
1780 "{}: {holder} changed the branch while closing, which the prompt forbids. Rolling it \
1781 back.",
1782 ctx.label
1783 );
1784 let after_undo = undo_edits(repo, &ctx.work_dir, &before);
1785 if after_undo != before {
1786 state.notes.push(format!(
1787 "{holder}'s closing-pass changes could not be fully rolled back"
1788 ));
1789 if after_undo.head != before.head {
1790 if let Some(reference) = preserve_closing_commit(repo, ctx) {
1791 state.notes.push(format!(
1792 "the closing pass commit was not pushed and remains at {reference}"
1793 ));
1794 }
1795 }
1796 }
1797 state.status = Status::Escalated;
1798 state.notes.push(format!(
1799 "{holder} edited the branch during the closing pass, so its answer did not stand"
1800 ));
1801 post_unread_outcome(repo, ctx.pr_number, state, ledger, open_findings);
1802 persist(
1803 repo,
1804 ctx.pr_number,
1805 state,
1806 ledger,
1807 open_findings,
1808 published_head,
1809 round,
1810 &next,
1811 )?;
1812 return Ok(());
1813 }
1814
1815 let pass: Review = match pass {
1816 Ok(pass) => pass,
1817 Err(e) => {
1818 logwarn!("{}: the closing pass failed: {e}", ctx.label);
1822 state.status = Status::Escalated;
1823 state.notes.push(exhausted_note(ctx.start_round, round));
1824 post_unread_outcome(repo, ctx.pr_number, state, ledger, open_findings);
1825 persist(
1826 repo,
1827 ctx.pr_number,
1828 state,
1829 ledger,
1830 open_findings,
1831 published_head,
1832 round,
1833 holder,
1834 )?;
1835 return Ok(());
1836 }
1837 };
1838
1839 file_out_of_scope(repo, &pass.findings, ctx.subject, state, cfg);
1840 file_nonblocking(repo, &pass.findings, ctx.subject, state, cfg);
1841
1842 let blocking = blocking_findings(&pass.findings);
1843 if let Some(said) = verdict_disagreement(&pass, blocking.len()) {
1844 logwarn!("{}: the closing pass {said}", ctx.label);
1845 }
1846 remove_findings(&mut state.noted, &blocking);
1847 update_open_findings(open_findings, &blocking, true);
1848
1849 if check_relitigation(ledger, &blocking, state) {
1850 state.status = Status::Escalated;
1851 post_outcome(
1852 repo,
1853 ctx.pr_number,
1854 state,
1855 ledger,
1856 Ending::Deadlocked(&blocking),
1857 );
1858 persist(
1859 repo,
1860 ctx.pr_number,
1861 state,
1862 ledger,
1863 open_findings,
1864 published_head,
1865 round,
1866 &next,
1867 )?;
1868 return Ok(());
1869 }
1870
1871 if approval_stands(&blocking, false) {
1872 open_findings.clear();
1873 state.criteria = ruled(&ctx.criteria, &pass.criteria);
1874 return approve(cfg, repo, ctx, state, ledger, published_head, round, &next);
1875 }
1876
1877 state.status = Status::Escalated;
1878 state.notes.push(unresolved_note(open_findings.len()));
1879 post_outcome(
1880 repo,
1881 ctx.pr_number,
1882 state,
1883 ledger,
1884 Ending::Unresolved(open_findings),
1885 );
1886 persist(
1887 repo,
1888 ctx.pr_number,
1889 state,
1890 ledger,
1891 open_findings,
1892 published_head,
1893 round,
1894 &next,
1895 )?;
1896 Ok(())
1897}
1898
1899fn any_fixes(ledger: &Ledger, round: u32) -> bool {
1905 ledger
1906 .values()
1907 .any(|e| e.outcome == Settled::Fixed && e.round >= round)
1908}
1909
1910fn unresolved_note(left: usize) -> String {
1915 match left {
1916 1 => "one point left after the closing pass".to_string(),
1917 n => format!("{n} points left after the closing pass"),
1918 }
1919}
1920
1921fn ensure_reviewed_head(pr_number: i64, reviewed_head: &str, live_head: &str) -> Result<()> {
1927 if live_head == reviewed_head {
1928 return Ok(());
1929 }
1930 Err(spar_err!(
1931 "PR #{pr_number} changed from {reviewed_head} to {live_head} after it was reviewed; refusing to approve or merge an unread head"
1932 ))
1933}
1934
1935#[allow(clippy::too_many_arguments)]
1936fn approve(
1937 cfg: &Config,
1938 repo: &Repo,
1939 ctx: &LoopCtx,
1940 state: &mut IssueRun,
1941 ledger: &Ledger,
1942 published_head: &str,
1943 round: u32,
1944 holder: &str,
1945) -> Result<()> {
1946 let live_head = repo.pr_head_oid(ctx.pr_number)?;
1947 ensure_reviewed_head(ctx.pr_number, published_head, &live_head)?;
1948 state.status = Status::Approved;
1949 persist(
1950 repo,
1951 ctx.pr_number,
1952 state,
1953 ledger,
1954 &[],
1955 published_head,
1956 round,
1957 holder,
1958 )?;
1959 let live_head = repo.pr_head_oid(ctx.pr_number)?;
1960 ensure_reviewed_head(ctx.pr_number, published_head, &live_head)?;
1961 state.ticks_criteria = cfg.loop_cfg.auto_merge && cfg.loop_cfg.tick_criteria;
1963 post_outcome(repo, ctx.pr_number, state, ledger, Ending::Approved);
1964 if cfg.loop_cfg.drafts == Drafts::UntilApproved && repo.mark_ready(ctx.pr_number) {
1968 log!("{}: out of draft", ctx.label);
1969 }
1970 if cfg.loop_cfg.auto_merge {
1971 let released = ctx.release(repo);
1975 if !released {
1976 log!(
1977 "{}: kept the worktree at {} and will leave its branch in place",
1978 ctx.label,
1979 ctx.work_dir.display()
1980 );
1981 }
1982 repo.merge_pr_at_head(ctx.pr_number, published_head, released)?;
1983 state.status = Status::Merged;
1984 repo.clear_state(ctx.pr_number); log!("{}: merged", ctx.label);
1986 let some_met = state.criteria.iter().any(|c| c.status == Some(Mark::Met));
1987 if state.ticks_criteria && ctx.subject != ctx.pr_number && some_met {
1988 let landed = repo.pr_state(ctx.pr_number) == "MERGED"
1992 && repo.pr_head_oid(ctx.pr_number).ok().as_deref() == Some(published_head);
1993 if !landed {
1994 log!(
1995 "{}: not merged yet at the reviewed head, so no acceptance criteria were ticked on #{}",
1996 ctx.label,
1997 ctx.subject
1998 );
1999 return Ok(());
2000 }
2001 let ticked = tick_met(repo, ctx.subject, &state.criteria);
2002 if ticked > 0 {
2003 let noun = if ticked == 1 { "criterion" } else { "criteria" };
2004 log!(
2005 "{}: ticked {ticked} acceptance {noun} on #{}",
2006 ctx.label,
2007 ctx.subject
2008 );
2009 }
2010 }
2011 } else {
2012 log!("{}: approved, awaiting human merge", ctx.label);
2013 }
2014 Ok(())
2015}
2016
2017fn approval_stands(blocking: &[Finding], review_wrote: bool) -> bool {
2025 blocking.is_empty() && !review_wrote
2026}
2027
2028fn next_reviewer(cfg: &Config, reviewer: &str, editor: Option<&str>) -> String {
2041 match editor {
2042 Some(editor) => cfg.other(editor),
2043 None => reviewer.to_string(),
2044 }
2045}
2046
2047fn defer_clean_edit_error(
2048 repo: &Repo,
2049 work_dir: &Path,
2050 baseline: &crate::repo::WorktreeBaseline,
2051 error: SparError,
2052) -> Result<SparError> {
2053 if error.kind() == ErrorKind::UncertainWrite {
2054 return Err(error);
2055 }
2056 repo.refuse_changed_attributes(work_dir, baseline)?;
2057 if repo.has_uncommitted_changes(work_dir)? {
2058 return Err(error);
2059 }
2060 repo.refuse_failed_edit_leavings(work_dir, baseline)?;
2061 repo.refuse_unrepresented_tracked_changes(work_dir, baseline)?;
2062 Ok(error)
2063}
2064
2065fn editor_after(
2073 repo: &Repo,
2074 work_dir: &Path,
2075 before_head: &str,
2076 label: &str,
2077 who: &str,
2078) -> Result<Option<String>> {
2079 if repo.has_uncommitted_changes(work_dir)? {
2080 bail!(
2081 "{label}: {who} left uncommitted files at {}. They were kept and the review did not \
2082 continue.",
2083 work_dir.display()
2084 );
2085 }
2086 let after_head = repo.head_oid_checked(work_dir)?;
2087 if after_head != before_head && !repo.is_ancestor_checked(work_dir, before_head, &after_head)? {
2088 bail!(
2089 "{label}: {who} moved HEAD to {after_head}, but it does not contain the previous \
2090 branch tip {before_head}. The worktree was kept for recovery and nothing was \
2091 published. Restore {before_head} or reapply the intended commits on top of it before \
2092 resuming."
2093 );
2094 }
2095 Ok((after_head != before_head).then(|| who.to_string()))
2096}
2097
2098fn round_window(start_round: u32, budget: u32) -> (u32, u32) {
2104 (start_round, start_round + budget.saturating_sub(1))
2105}
2106
2107fn spent(start_round: u32, last_round: u32) -> (u32, u32) {
2111 (last_round.saturating_sub(start_round) + 1, last_round)
2112}
2113
2114fn exhausted_note(start_round: u32, last_round: u32) -> String {
2115 let (this_run, total) = spent(start_round, last_round);
2116 if this_run == total {
2117 format!("no convergence after {this_run} rounds")
2118 } else {
2119 format!("no convergence after {this_run} more rounds ({total} in total)")
2120 }
2121}
2122
2123#[allow(clippy::too_many_arguments)]
2124fn persist(
2125 repo: &Repo,
2126 pr_number: i64,
2127 state: &IssueRun,
2128 ledger: &Ledger,
2129 open_findings: &[Finding],
2130 published_head: &str,
2131 round: u32,
2132 next_actor: &str,
2133) -> Result<()> {
2134 let payload = PersistedState {
2135 version: STATE_VERSION,
2136 checkpoint: 0,
2137 round,
2138 next_actor: next_actor.to_string(),
2139 status: state.status,
2140 pr_head: published_head.to_string(),
2141 ledger: ledger.clone(),
2142 filed: state.filed.clone(),
2143 open_findings: open_findings.to_vec(),
2144 disputes: state.disputes.clone(),
2145 noted: state.noted.clone(),
2146 };
2147 repo.write_state(pr_number, &payload)
2148}
2149
2150fn settled_block(ledger: &Ledger) -> String {
2155 if ledger.is_empty() {
2156 return String::new();
2157 }
2158 let lines: Vec<String> = ledger
2163 .values()
2164 .filter_map(|e| {
2165 let point = match e.file.trim() {
2166 "" => e.title.clone(),
2167 file => format!("{} ({file})", e.title),
2168 };
2169 match e.outcome {
2170 Settled::Refuted => Some(format!("- {point}: refuted because {}", e.reasoning)),
2171 Settled::Filed => Some(format!(
2172 "- {point}: out of scope here, and filed. {}",
2173 e.reasoning
2174 )),
2175 Settled::Dropped => Some(format!(
2176 "- {point}: out of scope here, and not filed. {}",
2177 e.reasoning
2178 )),
2179 Settled::Fixed => None,
2180 }
2181 })
2182 .collect();
2183 if lines.is_empty() {
2184 return String::new();
2185 }
2186 format!(
2187 "\nThe following points were already raised and settled, by a refutation or by a \
2188 follow-up issue. Treat them as settled. Do not raise them again unless you have new \
2189 evidence:\n{}",
2190 lines.join("\n")
2191 )
2192}
2193
2194fn fixed_lines(ledger: &Ledger, since: u32) -> Vec<String> {
2206 ledger
2207 .values()
2208 .filter(|e| e.outcome == Settled::Fixed && e.round >= since)
2209 .map(|e| {
2210 let claim = match e.reasoning.trim() {
2211 "" => "a committed change claims to address this point",
2212 reasoning => reasoning,
2213 };
2214 match e.file.trim() {
2215 "" => format!("- {}. Recorded answer: {claim}", e.title),
2216 file => format!("- {} ({file}). Recorded answer: {claim}", e.title),
2217 }
2218 })
2219 .collect()
2220}
2221
2222fn answers_block(ledger: &Ledger, round: u32) -> String {
2230 let lines = fixed_lines(ledger, round.saturating_sub(1));
2233 if lines.is_empty() {
2234 return String::new();
2235 }
2236 format!(
2237 "\nThese points were raised on this pull request in earlier rounds and the author says \
2238 it fixed them. The code is on the branch and the claim is the author's:\n{}\n\nCheck the \
2239 answer rather than taking it. If one of them is still not fixed, raise it again under \
2240 the same title, so the run can tell a point that was not answered from a new one.\n",
2241 lines.join("\n")
2242 )
2243}
2244
2245fn round_note(round: u32, last: u32) -> String {
2254 if round < last {
2255 return String::new();
2256 }
2257 "\nThis is the last round in this run that can ask the author for anything. After it, one \
2258 pass reads what landed and the pull request is either signed off or goes to a person with \
2259 what is left. Raise everything you mean to raise now. A point held back for a later round \
2260 does not get one.\n"
2261 .to_string()
2262}
2263
2264#[cfg(test)]
2269fn matching_ledger_key(ledger: &Ledger, title: &str, file: &str) -> Option<String> {
2270 matching_ledger_key_with_fallback(ledger, title, file, true)
2271}
2272
2273fn matching_ledger_key_with_fallback(
2274 ledger: &Ledger,
2275 title: &str,
2276 file: &str,
2277 allow_stable_fallback: bool,
2278) -> Option<String> {
2279 let exact = finding_key(title, file);
2280 if ledger.contains_key(&exact) {
2281 return Some(exact);
2282 }
2283 let legacy = crate::jsonx::finding_key(title, file);
2284 if ledger
2285 .get(&legacy)
2286 .is_some_and(|entry| same_finding_parts(&entry.title, &entry.file, title, file))
2287 {
2288 return Some(legacy);
2289 }
2290 if !allow_stable_fallback {
2291 return None;
2292 }
2293
2294 let stable = stable_finding_key(title, file);
2295 let path = finding_file(file);
2296 let mut matches = ledger
2297 .iter()
2298 .filter(|(saved_key, entry)| {
2299 if stable_finding_key(&entry.title, &entry.file) == stable {
2300 return true;
2301 }
2302 finding_file(&entry.file) == path
2303 && saved_key.as_str() == crate::jsonx::finding_key(title, &entry.file)
2304 })
2305 .map(|(key, _)| key.clone());
2306 let first = matches.next()?;
2307 matches.next().is_none().then_some(first)
2308}
2309
2310fn matching_ledger_entry<'a>(
2311 ledger: &'a Ledger,
2312 title: &str,
2313 file: &str,
2314) -> Option<&'a LedgerEntry> {
2315 matching_ledger_entry_with_fallback(ledger, title, file, true)
2316}
2317
2318fn matching_ledger_entry_with_fallback<'a>(
2319 ledger: &'a Ledger,
2320 title: &str,
2321 file: &str,
2322 allow_stable_fallback: bool,
2323) -> Option<&'a LedgerEntry> {
2324 let key = matching_ledger_key_with_fallback(ledger, title, file, allow_stable_fallback)?;
2325 ledger.get(&key)
2326}
2327
2328fn settle(
2329 ledger: &mut Ledger,
2330 title: &str,
2331 file: &str,
2332 allow_stable_fallback: bool,
2333 entry: LedgerEntry,
2334) {
2335 let old_key = matching_ledger_key_with_fallback(ledger, title, file, allow_stable_fallback);
2336 let reraised = old_key
2337 .as_ref()
2338 .and_then(|key| ledger.get(key))
2339 .map(|entry| entry.reraised)
2340 .unwrap_or(0);
2341 if let Some(old_key) = old_key {
2342 ledger.remove(&old_key);
2343 }
2344 ledger.insert(finding_key(title, file), LedgerEntry { reraised, ..entry });
2345}
2346
2347fn normalise_ledger_keys(ledger: &mut Ledger) {
2349 let mut normalised = Ledger::new();
2350 for (saved_key, mut entry) in std::mem::take(ledger) {
2351 let key = if saved_key.len() == 12
2352 && saved_key
2353 .chars()
2354 .all(|character| character.is_ascii_hexdigit())
2355 {
2356 saved_key
2357 } else {
2358 finding_key(&entry.title, &entry.file)
2359 };
2360 if let Some(previous) = normalised.get_mut(&key) {
2361 let reraised = previous.reraised.max(entry.reraised);
2362 if entry.round >= previous.round {
2363 entry.reraised = reraised;
2364 *previous = entry;
2365 } else {
2366 previous.reraised = reraised;
2367 }
2368 } else {
2369 normalised.insert(key, entry);
2370 }
2371 }
2372 *ledger = normalised;
2373}
2374
2375pub(crate) fn verdict_disagreement(review: &Review, blocking: usize) -> Option<String> {
2385 match (review.verdict, blocking, review.next_action) {
2386 (Verdict::Approve, n, _) if n > 0 => Some(format!(
2387 "the review says approve and raises {n} blocking finding(s); the findings decide"
2388 )),
2389 (Verdict::ChangesRequested, 0, NextAction::Merge) => Some(
2390 "the review says changes_requested, raises nothing blocking, and asks to merge; the \
2391 findings decide"
2392 .to_string(),
2393 ),
2394 _ => None,
2395 }
2396}
2397
2398fn blocking_findings(findings: &[Finding]) -> Vec<Finding> {
2399 let mut kept = Vec::new();
2400 for finding in findings.iter().filter(|finding| finding.blocks()) {
2401 if let Some(existing) = kept
2402 .iter_mut()
2403 .find(|existing| same_finding(existing, finding))
2404 {
2405 *existing = finding.clone();
2406 } else {
2407 kept.push(finding.clone());
2408 }
2409 }
2410 kept
2411}
2412
2413fn matching_finding_index(
2414 findings: &[Finding],
2415 target: &Finding,
2416 allow_stable_fallback: bool,
2417) -> Option<usize> {
2418 if let Some(index) = findings
2419 .iter()
2420 .position(|finding| same_finding(finding, target))
2421 {
2422 return Some(index);
2423 }
2424 if !allow_stable_fallback {
2425 return None;
2426 }
2427
2428 let stable = stable_finding_key(&target.title, &target.file);
2429 let mut matches = findings
2430 .iter()
2431 .enumerate()
2432 .filter(|(_, finding)| stable_finding_key(&finding.title, &finding.file) == stable);
2433 let first = matches.next().map(|(index, _)| index);
2434 first.filter(|_| matches.next().is_none())
2435}
2436
2437fn unique_stable_finding(findings: &[Finding], target: &Finding) -> bool {
2438 let stable = stable_finding_key(&target.title, &target.file);
2439 findings
2440 .iter()
2441 .filter(|finding| stable_finding_key(&finding.title, &finding.file) == stable)
2442 .count()
2443 == 1
2444}
2445
2446fn extend_findings(target: &mut Vec<Finding>, additions: &[Finding]) {
2448 for finding in additions {
2449 if let Some(index) =
2450 matching_finding_index(target, finding, unique_stable_finding(additions, finding))
2451 {
2452 target[index] = finding.clone();
2453 } else {
2454 target.push(finding.clone());
2455 }
2456 }
2457}
2458
2459fn update_open_findings(
2460 open_findings: &mut Vec<Finding>,
2461 current: &[Finding],
2462 answer_stands: bool,
2463) {
2464 if answer_stands {
2465 *open_findings = current.to_vec();
2466 } else {
2467 extend_findings(open_findings, current);
2468 }
2469}
2470
2471fn remove_findings(target: &mut Vec<Finding>, removed: &[Finding]) {
2472 for finding in removed {
2473 if let Some(index) =
2474 matching_finding_index(target, finding, unique_stable_finding(removed, finding))
2475 {
2476 target.remove(index);
2477 }
2478 }
2479}
2480
2481fn ending_without_landing(open_findings: &[Finding]) -> Ending<'_> {
2482 if open_findings.is_empty() {
2483 Ending::Unchanged
2484 } else {
2485 Ending::Unresolved(open_findings)
2486 }
2487}
2488
2489fn closing_next_actor(holder: &str) -> String {
2490 holder.to_string()
2491}
2492
2493fn remember_noted(state: &mut IssueRun, finding: &Finding, allow_stable_fallback: bool) {
2506 if let Some(index) = matching_finding_index(&state.noted, finding, allow_stable_fallback) {
2507 state.noted[index] = finding.clone();
2508 } else {
2509 state.noted.push(finding.clone());
2510 }
2511}
2512
2513fn forget_noted(state: &mut IssueRun, finding: &Finding, allow_stable_fallback: bool) {
2514 if let Some(index) = matching_finding_index(&state.noted, finding, allow_stable_fallback) {
2515 state.noted.remove(index);
2516 }
2517}
2518
2519fn remember_dispute(state: &mut IssueRun, dispute: Dispute, allow_stable_fallback: bool) {
2520 let exact = finding_key(&dispute.title, &dispute.file);
2521 let stable = stable_finding_key(&dispute.title, &dispute.file);
2522 let exact_index = state
2523 .disputes
2524 .iter()
2525 .position(|kept| finding_key(&kept.title, &kept.file) == exact);
2526 let stable_index = if exact_index.is_none() && allow_stable_fallback {
2527 let mut matches = state
2528 .disputes
2529 .iter()
2530 .enumerate()
2531 .filter(|(_, kept)| stable_finding_key(&kept.title, &kept.file) == stable);
2532 let first = matches.next().map(|(index, _)| index);
2533 first.filter(|_| matches.next().is_none())
2534 } else {
2535 None
2536 };
2537 if let Some(index) = exact_index.or(stable_index) {
2538 state.disputes[index] = dispute;
2539 } else {
2540 state.disputes.push(dispute);
2541 }
2542}
2543
2544fn forget_dispute(state: &mut IssueRun, finding: &Finding, allow_stable_fallback: bool) {
2545 let exact = finding_key(&finding.title, &finding.file);
2546 if let Some(index) = state
2547 .disputes
2548 .iter()
2549 .position(|kept| finding_key(&kept.title, &kept.file) == exact)
2550 {
2551 state.disputes.remove(index);
2552 return;
2553 }
2554 if !allow_stable_fallback {
2555 return;
2556 }
2557
2558 let stable = stable_finding_key(&finding.title, &finding.file);
2559 let mut matches = state
2560 .disputes
2561 .iter()
2562 .enumerate()
2563 .filter(|(_, kept)| stable_finding_key(&kept.title, &kept.file) == stable);
2564 let first = matches.next().map(|(index, _)| index);
2565 if let Some(index) = first.filter(|_| matches.next().is_none()) {
2566 state.disputes.remove(index);
2567 }
2568}
2569
2570#[cfg(test)]
2571fn record_nonblocking_outcome(state: &mut IssueRun, finding: &Finding, outcome: Option<&Followup>) {
2572 record_nonblocking_outcome_with_match(state, finding, outcome, true);
2573}
2574
2575fn record_nonblocking_outcome_with_match(
2576 state: &mut IssueRun,
2577 finding: &Finding,
2578 outcome: Option<&Followup>,
2579 allow_stable_fallback: bool,
2580) {
2581 forget_dispute(state, finding, allow_stable_fallback);
2582 if let Some(Followup::Recorded(url)) = outcome {
2583 if !state.filed.iter().any(|filed| filed == url) {
2584 state.filed.push(url.clone());
2585 }
2586 forget_noted(state, finding, allow_stable_fallback);
2587 } else {
2588 remember_noted(state, finding, allow_stable_fallback);
2589 }
2590}
2591
2592fn split_own_fixes(blocking: &[Finding], fixes: &[OwnFix]) -> (Vec<Finding>, Vec<Finding>) {
2599 let mut claimed = Vec::new();
2600 let mut left = Vec::new();
2601 let mut used = vec![false; fixes.len()];
2602 for finding in blocking {
2603 match matching_disposition(finding, blocking, fixes) {
2604 Ok((index, fix)) if !used[index] => {
2605 used[index] = true;
2606 if fix.fixed {
2607 claimed.push(finding.clone());
2608 continue;
2609 }
2610 let mut open = finding.clone();
2611 let reason = fix.reason.trim();
2612 if !reason.is_empty() {
2613 open.detail = format!(
2614 "{}\n\nThe reviewer chose to fix this itself and did not: {reason}",
2615 open.detail.trim()
2616 );
2617 }
2618 logwarn!("'{}' was not fixed: {reason}", finding.title);
2619 left.push(open);
2620 }
2621 Ok(_) => {
2622 logwarn!(
2623 "'{}' has more than one matching entry in the fix report, so it stays open",
2624 finding.title
2625 );
2626 left.push(finding.clone());
2627 }
2628 Err(reason) => {
2629 logwarn!(
2630 "'{}' has {reason} in the fix report, so it stays open",
2631 finding.title
2632 );
2633 left.push(finding.clone());
2634 }
2635 }
2636 }
2637 (claimed, left)
2638}
2639
2640fn record_own_fixes(blocking: &[Finding], ledger: &mut Ledger, state: &mut IssueRun, round: u32) {
2647 for finding in blocking {
2648 let allow_stable_fallback = unique_stable_finding(blocking, finding);
2649 settle(
2650 ledger,
2651 &finding.title,
2652 &finding.file,
2653 allow_stable_fallback,
2654 LedgerEntry {
2655 title: finding.title.clone(),
2656 file: finding.file.clone(),
2657 reasoning: "a committed change was made for this point".to_string(),
2658 round,
2659 reraised: 0,
2660 outcome: Settled::Fixed,
2661 },
2662 );
2663 forget_noted(state, finding, allow_stable_fallback);
2664 forget_dispute(state, finding, allow_stable_fallback);
2665 }
2666}
2667
2668fn check_relitigation(ledger: &mut Ledger, blocking: &[Finding], state: &mut IssueRun) -> bool {
2671 let mut escalate = false;
2672 let mut counted: BTreeSet<String> = BTreeSet::new();
2678 for finding in blocking {
2679 let allow_stable_fallback = unique_stable_finding(blocking, finding);
2680 let Some(key) = matching_ledger_key_with_fallback(
2681 ledger,
2682 &finding.title,
2683 &finding.file,
2684 allow_stable_fallback,
2685 ) else {
2686 continue;
2687 };
2688 if !counted.insert(key.clone()) {
2689 continue;
2690 }
2691 if let Some(entry) = ledger.get_mut(&key) {
2692 entry.reraised += 1;
2693 if entry.outcome == Settled::Refuted {
2694 remember_dispute(
2695 state,
2696 Dispute {
2697 title: finding.title.clone(),
2698 file: finding.file.clone(),
2699 reasoning: entry.reasoning.clone(),
2700 },
2701 allow_stable_fallback,
2702 );
2703 }
2704 if entry.reraised >= 2 {
2705 state.notes.push(format!(
2706 "'{}' {}",
2707 finding.title,
2708 why_escalated(entry.outcome)
2709 ));
2710 escalate = true;
2711 }
2712 }
2713 }
2714 escalate
2715}
2716
2717fn why_escalated(outcome: Settled) -> &'static str {
2724 match outcome {
2725 Settled::Fixed => "was fixed twice and raised again; escalating.",
2726 _ => "was settled and re-raised twice; escalating.",
2727 }
2728}
2729
2730fn normalise(text: &str) -> String {
2731 crate::jsonx::untagged_title(text)
2732 .to_lowercase()
2733 .chars()
2734 .filter(|c| c.is_ascii_alphanumeric() || c.is_whitespace())
2735 .collect::<String>()
2736 .split_whitespace()
2737 .collect::<Vec<_>>()
2738 .join(" ")
2739}
2740
2741pub(crate) fn same_point(a: &str, b: &str) -> bool {
2743 normalise(a) == normalise(b)
2744}
2745
2746pub(crate) fn same_finding(a: &Finding, b: &Finding) -> bool {
2747 same_finding_parts(&a.title, &a.file, &b.title, &b.file)
2748}
2749
2750pub(crate) fn same_finding_parts(a_title: &str, a_file: &str, b_title: &str, b_file: &str) -> bool {
2751 finding_key(a_title, a_file) == finding_key(b_title, b_file)
2752}
2753
2754pub(crate) trait Answer {
2760 fn title(&self) -> &str;
2761 fn file(&self) -> &str;
2762}
2763
2764impl Answer for Disposition {
2765 fn title(&self) -> &str {
2766 &self.title
2767 }
2768 fn file(&self) -> &str {
2769 &self.file
2770 }
2771}
2772
2773impl Answer for OwnFix {
2774 fn title(&self) -> &str {
2775 &self.title
2776 }
2777 fn file(&self) -> &str {
2778 &self.file
2779 }
2780}
2781
2782fn answer_matches<A: Answer>(finding: &Finding, answer: &A) -> bool {
2783 same_point(&finding.title, answer.title()) && finding.file.trim() == answer.file().trim()
2784}
2785
2786fn stable_answer_matches<A: Answer>(finding: &Finding, answer: &A) -> bool {
2787 stable_finding_key(&finding.title, &finding.file)
2788 == stable_finding_key(answer.title(), answer.file())
2789}
2790
2791fn sole_answer_match<'a, A: Answer>(
2792 finding: &Finding,
2793 answers: &'a [A],
2794 matches: impl Fn(&Finding, &A) -> bool,
2795) -> std::result::Result<Option<(usize, &'a A)>, &'static str> {
2796 let mut matched = answers
2797 .iter()
2798 .enumerate()
2799 .filter(|(_, answer)| matches(finding, answer));
2800 let first = matched.next();
2801 if matched.next().is_some() {
2802 return Err("more than one matching disposition");
2803 }
2804 Ok(first)
2805}
2806
2807fn matching_disposition<'a, A: Answer>(
2812 finding: &Finding,
2813 findings: &[Finding],
2814 answers: &'a [A],
2815) -> std::result::Result<(usize, &'a A), &'static str> {
2816 if let Some(exact) = sole_answer_match(finding, answers, answer_matches)? {
2817 return Ok(exact);
2818 }
2819 if unique_stable_finding(findings, finding) {
2820 if let Some(stable) = sole_answer_match(finding, answers, stable_answer_matches)? {
2821 return Ok(stable);
2822 }
2823 }
2824 Err("no matching disposition")
2825}
2826
2827fn fixed_disposition_resolves(committed: bool) -> bool {
2828 committed
2829}
2830
2831#[allow(clippy::too_many_arguments)]
2832fn apply_dispositions(
2833 repo: &Repo,
2834 cfg: &Config,
2835 response: &ResponseDoc,
2836 blocking: &[Finding],
2837 ledger: &mut Ledger,
2838 state: &mut IssueRun,
2839 round: u32,
2840 subject: i64,
2841 pr_number: i64,
2842 author: &str,
2843 committed: bool,
2844 used: &mut [bool],
2845) -> Vec<Finding> {
2846 let mut fixed = Vec::new();
2847 let mut refuted = Vec::new();
2848 let mut filed = Vec::new();
2849 let mut unresolved = Vec::new();
2850
2851 for source in blocking {
2852 let (index, d) = match matching_disposition(source, blocking, &response.dispositions) {
2853 Ok((index, disposition)) if !used[index] => (index, disposition),
2854 Ok(_) => {
2855 logwarn!(
2856 "'{}' has more than one matching disposition, so it stays open",
2857 source.title
2858 );
2859 unresolved.push(source.clone());
2860 continue;
2861 }
2862 Err(reason) => {
2863 logwarn!("'{}' has {reason}, so it stays open", source.title);
2864 unresolved.push(source.clone());
2865 continue;
2866 }
2867 };
2868 used[index] = true;
2869 let file = source.file.clone();
2870 let canonical = source.title.as_str();
2874 let title = style::title(canonical, &repo.style);
2875 let located_title = match file.trim() {
2876 "" => title.clone(),
2877 location => format!("{title} ({location})"),
2878 };
2879 let allow_stable_fallback = unique_stable_finding(blocking, source);
2880
2881 match d.action {
2882 Action::Refuted => {
2883 let reasoning = style::summary(&d.reasoning, &repo.style);
2884 settle(
2885 ledger,
2886 canonical,
2887 &file,
2888 allow_stable_fallback,
2889 LedgerEntry {
2890 title: canonical.to_string(),
2891 file: file.clone(),
2892 reasoning: reasoning.clone(),
2893 round,
2894 reraised: 0,
2895 outcome: Settled::Refuted,
2896 },
2897 );
2898 remember_dispute(
2899 state,
2900 Dispute {
2901 title: canonical.to_string(),
2902 file: file.clone(),
2903 reasoning: reasoning.clone(),
2904 },
2905 allow_stable_fallback,
2906 );
2907 forget_noted(state, source, allow_stable_fallback);
2908 refuted.push(format!("{located_title}. {reasoning}"));
2909 }
2910 Action::FiledIssue => {
2911 let new_title = d
2912 .new_issue_title
2913 .clone()
2914 .filter(|t| !t.trim().is_empty())
2915 .unwrap_or_else(|| d.title.clone());
2916 let new_body = d
2917 .new_issue_body
2918 .clone()
2919 .filter(|b| !b.trim().is_empty())
2920 .unwrap_or_else(|| d.reasoning.clone());
2921 let recorded = file_followup(repo, &new_title, &new_body, subject, cfg, state);
2922 if let Some(url) = recorded.url() {
2923 state.filed.push(url.to_string());
2924 filed.push(url.to_string());
2925 }
2926 let Some((outcome, reasoning)) =
2935 filed_entry(&recorded, &style::summary(&d.reasoning, &repo.style))
2936 else {
2937 logwarn!(
2938 "'{title}' was not recorded anywhere, so it stays open for the next round"
2939 );
2940 unresolved.push(source.clone());
2941 continue;
2942 };
2943 settle(
2944 ledger,
2945 canonical,
2946 &file,
2947 allow_stable_fallback,
2948 LedgerEntry {
2949 title: canonical.to_string(),
2950 file: file.clone(),
2951 reasoning,
2952 round,
2953 reraised: 0,
2954 outcome,
2955 },
2956 );
2957 if outcome == Settled::Dropped {
2958 remember_noted(state, source, allow_stable_fallback);
2959 } else {
2960 forget_noted(state, source, allow_stable_fallback);
2961 }
2962 forget_dispute(state, source, allow_stable_fallback);
2963 }
2964 Action::Fixed => {
2965 if fixed_disposition_resolves(committed) {
2980 settle(
2981 ledger,
2982 canonical,
2983 &file,
2984 allow_stable_fallback,
2985 LedgerEntry {
2986 title: canonical.to_string(),
2987 file: file.clone(),
2988 reasoning: style::summary(&d.reasoning, &repo.style),
2989 round,
2990 reraised: 0,
2991 outcome: Settled::Fixed,
2992 },
2993 );
2994 forget_noted(state, source, allow_stable_fallback);
2995 forget_dispute(state, source, allow_stable_fallback);
2996 fixed.push(located_title);
2997 } else {
2998 unresolved.push(source.clone());
2999 }
3000 }
3001 }
3002 }
3003
3004 if repo.style.pr_comments == PrComments::Rounds {
3005 let comment = disposition_comment(author, response, &fixed, &refuted, &filed, &repo.style);
3006 if let Some(text) = comment {
3007 if let Err(e) = repo.comment_pr(pr_number, &text) {
3008 logdim!("could not post the disposition comment: {e}");
3009 }
3010 }
3011 }
3012 unresolved
3013}
3014
3015fn filed_entry(recorded: &Followup, reasoning: &str) -> Option<(Settled, String)> {
3023 let (outcome, tail) = match recorded {
3024 Followup::Recorded(reference) => (
3025 Settled::Filed,
3026 format!("Tracked in {}.", as_reference(reference)),
3027 ),
3028 Followup::Covered(reference) => (
3029 Settled::Filed,
3030 format!("Already covered by {}.", as_reference(reference)),
3031 ),
3032 Followup::Dropped(why) => (Settled::Dropped, format!("Not filed anywhere: {why}.")),
3033 Followup::Failed => return None,
3034 };
3035 let reasoning = match reasoning.trim() {
3036 "" => tail,
3037 said => format!("{said} {tail}"),
3038 };
3039 Some((outcome, reasoning))
3040}
3041
3042fn external_followup_write_paused(destination: Followups, state: &IssueRun) -> bool {
3049 destination == Followups::Issues && state.followup_writes_uncertain
3050}
3051
3052fn failed_followup(state: &mut IssueRun, error: &SparError) -> Followup {
3053 if error.kind() == ErrorKind::UncertainWrite {
3054 state.followup_writes_uncertain = true;
3055 if !state
3056 .notes
3057 .iter()
3058 .any(|note| note.contains("external follow-up writes were paused"))
3059 {
3060 state.notes.push(
3061 "An external follow-up write could not be verified, so further external \
3062 follow-up writes were paused for this run. Inspect recent issues and comments \
3063 before trying them again."
3064 .to_string(),
3065 );
3066 }
3067 }
3068 Followup::Failed
3069}
3070
3071pub fn file_followup(
3082 repo: &Repo,
3083 title: &str,
3084 body: &str,
3085 source: i64,
3086 cfg: &Config,
3087 state: &mut IssueRun,
3088) -> Followup {
3089 if repo.followups == Followups::None {
3090 return Followup::Dropped("follow-ups are off for this repository");
3091 }
3092 if external_followup_write_paused(repo.followups, state) {
3093 logdim!(
3094 "not attempting another external follow-up write after an earlier result could not \
3095 be verified"
3096 );
3097 return Followup::Failed;
3098 }
3099 if state.filed.len() >= cfg.loop_cfg.max_followups {
3102 logwarn!(
3103 "already recorded {} follow-ups, not recording '{}'. Raise max_followups if you want \
3104 them all.",
3105 state.filed.len(),
3106 style::title(title, &repo.style)
3107 );
3108 return Followup::Dropped("this run had already recorded as many follow-ups as it may");
3109 }
3110 let title = match repo.clean_followup_title(title) {
3117 Ok(title) => title,
3118 Err(e) => {
3119 logdim!("could not clean a follow-up title: {e}");
3120 return Followup::Failed;
3121 }
3122 };
3123 if title.trim().is_empty() {
3124 logdim!("nothing left of a follow-up title after cleaning it");
3125 return Followup::Failed;
3126 }
3127 let body = format!(
3130 "{}\n\nFound while working on #{source}.",
3131 style::issue_body(body, &repo.style)
3132 );
3133
3134 if repo.followups == Followups::Local {
3135 return repo.append_local_followup(&title, &body);
3136 }
3137
3138 match file_as_issue(repo, &title, &body) {
3139 Ok(filed) => filed.into(),
3140 Err(e) => {
3141 logdim!("could not file a follow-up for '{title}': {e}");
3142 failed_followup(state, &e)
3143 }
3144 }
3145}
3146
3147#[derive(Debug, Clone)]
3149pub enum Filed {
3150 Opened(i64, String),
3152 AddedTo(i64, String),
3154 Covered(i64, String),
3156}
3157
3158impl From<Filed> for Followup {
3159 fn from(filed: Filed) -> Self {
3160 match filed {
3161 Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => {
3162 Followup::Recorded(url)
3163 }
3164 }
3165 }
3166}
3167
3168impl Filed {
3169 pub fn url(&self) -> Option<&str> {
3170 match self {
3171 Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => Some(url),
3172 }
3173 }
3174
3175 pub fn issue(&self) -> i64 {
3178 match self {
3179 Filed::Opened(n, _) | Filed::AddedTo(n, _) | Filed::Covered(n, _) => *n,
3180 }
3181 }
3182
3183 pub fn number(&self) -> Option<i64> {
3185 match self {
3186 Filed::Opened(n, _) | Filed::AddedTo(n, _) | Filed::Covered(n, _) => Some(*n),
3187 }
3188 }
3189
3190 pub fn note(&self) -> String {
3192 match self {
3193 Filed::Opened(n, _) => format!("#{n}"),
3194 Filed::AddedTo(n, _) => format!("added to #{n}"),
3195 Filed::Covered(n, _) => format!("#{n} already says this"),
3196 }
3197 }
3198
3199 pub fn describe(&self, title: &str) -> String {
3200 let title = style::clip(title.trim(), 80);
3201 match self {
3202 Filed::Opened(n, _) => format!("filed #{n}: {title}"),
3203 Filed::AddedTo(n, _) => format!("added to #{n}: {title}"),
3204 Filed::Covered(n, _) => format!("#{n} already says this: {title}"),
3205 }
3206 }
3207}
3208
3209pub fn file_as_issue(repo: &Repo, title: &str, body: &str) -> Result<Filed> {
3221 file_as_issue_apart_from(repo, title, body, None)
3222}
3223
3224pub fn file_as_issue_apart_from(
3230 repo: &Repo,
3231 title: &str,
3232 body: &str,
3233 apart_from: Option<i64>,
3234) -> Result<Filed> {
3235 let title = repo.record_failed_write(repo.clean_title(title))?;
3236 if title.trim().is_empty() {
3237 return repo.record_failed_write(Err(spar_err!(
3238 "nothing left of the title after cleaning it"
3239 )));
3240 }
3241 let issue_body = repo.record_failed_write(repo.clean_issue_body(body))?;
3242 let exact =
3243 repo.record_failed_write(repo.try_exact_issue_apart_from(&title, &issue_body, apart_from))?;
3244 let mut body_to_file = issue_body.clone();
3245 if let Some(existing) = exact {
3246 if existing.open {
3247 logdim!("'{title}' is already open as #{}", existing.number);
3248 return Ok(Filed::Covered(existing.number, existing.url));
3249 }
3250 logdim!(
3256 "'{title}' matches #{}, which is closed, so it is being filed again",
3257 existing.number
3258 );
3259 body_to_file = regression_body(&issue_body, existing.number);
3260 } else {
3261 let similar = repo.record_failed_write(repo.try_find_similar_issue_apart_from(
3262 &title,
3263 &issue_body,
3264 apart_from,
3265 ))?;
3266 if let Some(existing) = similar {
3267 let known = format!("{} {}", existing.title, existing.body);
3268 if crate::textsim::adds_information(&issue_body, &known) {
3269 logdim!(
3270 "'{title}' is covered by #{} and adds something, so it goes there as a \
3271 comment",
3272 existing.number
3273 );
3274 repo.comment_issue(existing.number, &issue_body)?;
3275 return Ok(Filed::AddedTo(existing.number, existing.url));
3276 }
3277 logdim!(
3278 "'{title}' is covered by #{} '{}', which already says it",
3279 existing.number,
3280 style::clip(&existing.title, 60)
3281 );
3282 return Ok(Filed::Covered(existing.number, existing.url));
3283 }
3284 }
3285 let url = repo.create_issue_apart_from(&title, &body_to_file, apart_from)?;
3286 let number = filed_issue_number(&url)
3287 .ok_or_else(|| spar_err!("filed an issue but could not read its number from {url}"))?;
3288 Ok(Filed::Opened(number, url))
3289}
3290
3291pub(crate) fn regression_body(body: &str, closed: i64) -> String {
3296 format!(
3297 "{}\n\nThis was filed before as #{closed}, which is closed. The evidence above is from \
3298 a later run, so it looks like it has come back.",
3299 body.trim()
3300 )
3301}
3302
3303fn file_out_of_scope(
3304 repo: &Repo,
3305 findings: &[Finding],
3306 subject: i64,
3307 state: &mut IssueRun,
3308 cfg: &Config,
3309) {
3310 for finding in findings.iter().filter(|f| !f.in_scope) {
3311 let body = issue_report(finding);
3312 let recorded = file_followup(repo, &finding.title, &body, subject, cfg, state);
3313 if finding.severity != Severity::Nit || matches!(recorded, Followup::Recorded(_)) {
3314 record_nonblocking_outcome_with_match(
3315 state,
3316 finding,
3317 Some(&recorded),
3318 unique_stable_finding(findings, finding),
3319 );
3320 }
3321 }
3322}
3323
3324pub fn issue_report(finding: &Finding) -> String {
3331 let sections = finding.report_sections();
3332 if sections.is_empty() {
3333 return finding.detail.clone();
3334 }
3335 let mut out: Vec<String> = sections
3336 .iter()
3337 .map(|(heading, text)| format!("## {heading}\n\n{text}"))
3338 .collect();
3339 if !finding.detail.trim().is_empty()
3342 && !sections
3343 .iter()
3344 .any(|(_, text)| crate::textsim::same_point(text, &finding.detail))
3345 {
3346 out.insert(0, finding.detail.trim().to_string());
3347 }
3348 out.join("\n\n")
3349}
3350
3351fn file_nonblocking(
3358 repo: &Repo,
3359 findings: &[Finding],
3360 subject: i64,
3361 state: &mut IssueRun,
3362 cfg: &Config,
3363) {
3364 for finding in findings {
3365 if !finding.in_scope || finding.severity == Severity::Blocking {
3366 continue;
3367 }
3368 let should_file = match finding.severity {
3369 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking,
3370 Severity::Nit => cfg.loop_cfg.file_nits,
3371 Severity::Blocking => false,
3372 };
3373 if !should_file {
3374 if finding.severity == Severity::NonBlocking {
3375 record_nonblocking_outcome_with_match(
3376 state,
3377 finding,
3378 None,
3379 unique_stable_finding(findings, finding),
3380 );
3381 }
3382 continue;
3383 }
3384 let recorded = file_followup(repo, &finding.title, &finding.detail, subject, cfg, state);
3385 if finding.severity == Severity::NonBlocking {
3386 record_nonblocking_outcome_with_match(
3387 state,
3388 finding,
3389 Some(&recorded),
3390 unique_stable_finding(findings, finding),
3391 );
3392 } else if let Some(url) = recorded.url() {
3393 state.filed.push(url.to_string());
3394 }
3395 }
3396}
3397
3398fn bullets(lines: &[String]) -> String {
3408 lines
3409 .iter()
3410 .map(|l| format!("- {l}"))
3411 .collect::<Vec<_>>()
3412 .join("\n")
3413}
3414
3415fn located(finding: &Finding, style: &Style) -> String {
3416 let title = style::title(&finding.title, style);
3417 match finding.where_at() {
3418 "general" => title,
3419 file => format!("{title} ({file})"),
3420 }
3421}
3422
3423pub enum Ending<'a> {
3425 Approved,
3427 OutOfRounds,
3430 Unchanged,
3433 Unresolved(&'a [Finding]),
3436 Deadlocked(&'a [Finding]),
3439}
3440
3441#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3442enum OutcomeSink {
3443 PullRequest,
3444 Terminal,
3445}
3446
3447fn outcome_sink(mode: PrComments) -> OutcomeSink {
3448 match mode {
3449 PrComments::Outcome | PrComments::Rounds => OutcomeSink::PullRequest,
3450 PrComments::None => OutcomeSink::Terminal,
3451 }
3452}
3453
3454fn emit_outcome(repo: &Repo, pr_number: i64, text: &str) {
3455 if outcome_sink(repo.style.pr_comments) == OutcomeSink::Terminal {
3456 println!("\n{text}\n");
3457 return;
3458 }
3459 if let Err(e) = repo.comment_pr(pr_number, text) {
3460 logdim!("could not post the outcome comment: {e}");
3461 println!("\n{text}\n");
3462 }
3463}
3464
3465pub fn post_outcome(
3477 repo: &Repo,
3478 pr_number: i64,
3479 state: &IssueRun,
3480 ledger: &Ledger,
3481 ending: Ending<'_>,
3482) {
3483 let Some(text) = outcome_comment(state, ledger, &ending, &repo.style) else {
3484 return;
3485 };
3486 emit_outcome(repo, pr_number, &text);
3487}
3488
3489fn post_unread_outcome(
3490 repo: &Repo,
3491 pr_number: i64,
3492 state: &IssueRun,
3493 ledger: &Ledger,
3494 open_findings: &[Finding],
3495) {
3496 let Some(text) = outcome_comment_with_unread(
3497 state,
3498 ledger,
3499 &Ending::OutOfRounds,
3500 open_findings,
3501 &repo.style,
3502 ) else {
3503 return;
3504 };
3505 emit_outcome(repo, pr_number, &text);
3506}
3507
3508fn settled_as(finding: &Finding, state: &IssueRun, ledger: &Ledger) -> Option<(Settled, String)> {
3511 if let Some(d) = state
3512 .disputes
3513 .iter()
3514 .find(|d| same_finding_parts(&d.title, &d.file, &finding.title, &finding.file))
3515 {
3516 if !d.reasoning.trim().is_empty() {
3517 return Some((Settled::Refuted, d.reasoning.clone()));
3518 }
3519 }
3520 matching_ledger_entry(ledger, &finding.title, &finding.file)
3521 .filter(|entry| !entry.reasoning.trim().is_empty())
3522 .map(|entry| (entry.outcome, entry.reasoning.clone()))
3523}
3524
3525pub fn filed_issue_number(filed: &str) -> Option<i64> {
3530 filed
3531 .rsplit('/')
3532 .next()
3533 .and_then(|tail| tail.parse::<i64>().ok())
3534 .filter(|n| *n > 0)
3535}
3536
3537fn as_reference(url: &str) -> String {
3538 match url.rsplit('/').next().and_then(|n| n.parse::<u64>().ok()) {
3539 Some(number) => format!("#{number}"),
3540 None => url.to_string(),
3541 }
3542}
3543
3544pub fn outcome_comment(
3545 state: &IssueRun,
3546 ledger: &Ledger,
3547 ending: &Ending<'_>,
3548 style: &Style,
3549) -> Option<String> {
3550 outcome_comment_with_unread(state, ledger, ending, &[], style)
3551}
3552
3553fn outcome_comment_with_unread(
3554 state: &IssueRun,
3555 ledger: &Ledger,
3556 ending: &Ending<'_>,
3557 unread_open: &[Finding],
3558 style: &Style,
3559) -> Option<String> {
3560 let mut out: Vec<String> = Vec::new();
3561 let mut already: Vec<(String, String)> = Vec::new();
3564
3565 match ending {
3566 Ending::Approved => {
3567 let criteria = criteria_rulings(state, style);
3568 if state.disputes.is_empty()
3569 && state.filed.is_empty()
3570 && state.noted.is_empty()
3571 && criteria.is_none()
3572 {
3573 return None;
3580 }
3581 out.push("Reviewed, nothing blocking a merge.".into());
3582 out.extend(criteria);
3583 }
3584 Ending::OutOfRounds => {
3585 out.push(
3586 "Not signed off: the last round of fixes was pushed but has not been reviewed."
3587 .into(),
3588 );
3589 if !unread_open.is_empty() {
3590 let lines: Vec<String> = unread_open
3591 .iter()
3592 .map(|finding| {
3593 already.push((finding.title.clone(), finding.file.clone()));
3594 format!(
3595 "{}. {}",
3596 located(finding, style),
3597 style::sentence(&finding.detail, style)
3598 )
3599 })
3600 .collect();
3601 out.push("These points were already open:".into());
3602 out.push(bullets(&lines));
3603 }
3604 }
3605 Ending::Unchanged => out.push(
3609 "Not signed off: the last round changed nothing, so the branch is the one that was \
3610 already reviewed."
3611 .into(),
3612 ),
3613 Ending::Unresolved(points) => {
3614 let lines: Vec<String> = points
3615 .iter()
3616 .map(|f| {
3617 already.push((f.title.clone(), f.file.clone()));
3618 format!(
3619 "{}. {}",
3620 located(f, style),
3621 style::sentence(&f.detail, style)
3622 )
3623 })
3624 .collect();
3625 out.push("Not signed off. These points are still open:".into());
3626 out.push(bullets(&lines));
3627 }
3628 Ending::Deadlocked(points) => {
3629 let lines: Vec<String> = points
3635 .iter()
3636 .map(|f| {
3637 let where_at = match f.where_at() {
3638 "general" => String::new(),
3639 file => format!(" ({file})"),
3640 };
3641 let title = style::title(&f.title, style);
3642 already.push((f.title.clone(), f.file.clone()));
3643 match settled_as(f, state, ledger) {
3644 Some((Settled::Refuted, reason)) => format!(
3645 "{title}{where_at}. Refuted as: {}",
3646 style::summary(&reason, style)
3647 ),
3648 Some((Settled::Filed, reason)) => format!(
3649 "{title}{where_at}. Filed as out of scope: {}",
3650 style::summary(&reason, style)
3651 ),
3652 Some((Settled::Dropped, reason)) => format!(
3655 "{title}{where_at}. Out of scope here, and not filed: {}",
3656 style::summary(&reason, style)
3657 ),
3658 Some((Settled::Fixed, reason)) => format!(
3664 "{title}{where_at}. Fixed and raised again. Recorded answer: {}",
3665 style::summary(&reason, style)
3666 ),
3667 None => format!("{title}{where_at}"),
3668 }
3669 })
3670 .collect();
3671 out.push("Needs your decision. The reviewers could not settle this:".into());
3672 out.push(bullets(&lines));
3673 }
3674 }
3675
3676 let disputes: Vec<&crate::model::Dispute> = state
3677 .disputes
3678 .iter()
3679 .filter(|d| {
3680 !already
3681 .iter()
3682 .any(|(title, file)| same_finding_parts(title, file, &d.title, &d.file))
3683 })
3684 .collect();
3685 if !disputes.is_empty() {
3686 let lines: Vec<String> = disputes
3689 .iter()
3690 .map(|d| {
3691 let title = style::title(&d.title, style);
3692 let title = if d.file.trim().is_empty() {
3693 title
3694 } else {
3695 format!("{title} ({})", d.file.trim())
3696 };
3697 format!("{}. {}", title, style::sentence(&d.reasoning, style))
3698 })
3699 .collect();
3700 out.push(format!("Raised and refuted:\n{}", bullets(&lines)));
3701 }
3702
3703 if !state.noted.is_empty() {
3704 let lines: Vec<String> = state
3708 .noted
3709 .iter()
3710 .filter(|f| {
3711 !already
3712 .iter()
3713 .any(|(title, file)| same_finding_parts(title, file, &f.title, &f.file))
3714 })
3715 .map(|f| located(f, style))
3716 .collect();
3717 if !lines.is_empty() {
3718 out.push(format!("Noted, not blocking:\n{}", bullets(&lines)));
3719 }
3720 }
3721
3722 if !state.filed.is_empty() {
3723 let refs: Vec<String> = state.filed.iter().map(|u| as_reference(u)).collect();
3724 out.push(format!("Filed separately: {}", refs.join(", ")));
3725 }
3726
3727 Some(out.join("\n\n"))
3728}
3729
3730fn criteria_rulings(state: &IssueRun, style: &Style) -> Option<String> {
3737 let shown: Vec<String> = state
3738 .criteria
3739 .iter()
3740 .filter(|c| !state.ticks_criteria || c.status != Some(Mark::Met))
3741 .map(|c| {
3742 let label = match c.status {
3743 Some(Mark::Met) => "Met",
3744 Some(Mark::NotMet) => "Not met",
3745 Some(Mark::NeedsPerson) => "Needs a person",
3746 None => "Not ruled on",
3747 };
3748 criterion_line(label, &c.text, Some(&c.reason), style)
3749 })
3750 .collect();
3751 if shown.is_empty() {
3752 return None;
3753 }
3754 let lead = if state.ticks_criteria {
3755 "Acceptance criteria not ruled met, left unchecked on the issue:"
3756 } else {
3757 "Acceptance criteria as the review ruled them. Nothing was ticked on the issue:"
3758 };
3759 Some(format!("{lead}\n{}", bullets(&shown)))
3760}
3761
3762#[allow(clippy::too_many_arguments)]
3771fn close_prompt(
3772 base: &str,
3773 number: i64,
3774 title: &str,
3775 context: &str,
3776 from: &str,
3777 landed: Option<&[String]>,
3778 ledger: &Ledger,
3779 open_findings: &[Finding],
3780 round: u32,
3781 criteria: &[Item],
3782) -> String {
3783 let landed = match landed {
3784 Some([]) => "\nNothing landed after the last round of review. What it asked for was \
3785 answered in words rather than in code, so the branch in front of you is the \
3786 branch that was already read.\n"
3787 .to_string(),
3788 Some(lines) => format!(
3789 "\nThis landed after the last round of review, and nobody has read it:\n{}\n\nRead it \
3790 first with `git diff {from}..HEAD`, then inspect the full branch with `git diff \
3791 {base}...HEAD`.\n",
3792 lines
3793 .iter()
3794 .map(|l| format!("- {l}"))
3795 .collect::<Vec<_>>()
3796 .join("\n")
3797 ),
3798 None => format!(
3799 "\nThe commits on this branch were rewritten after the last round of review, so the \
3800 harness cannot say which of them are new. Inspect the full branch with `git diff \
3801 {base}...HEAD`.\n"
3802 ),
3803 };
3804 fill(
3805 CLOSE_PROMPT,
3806 &[
3807 ("{number}", &number.to_string()),
3808 ("{title}", title),
3809 ("{context}", context),
3810 ("{criteria}", &criteria_to_rule(criteria)),
3811 ("{base}", base),
3812 ("{landed}", &landed),
3813 ("{open}", &open_findings_block(open_findings)),
3814 ("{answers}", &closing_answers(ledger, round)),
3815 ("{settled}", &settled_block(ledger)),
3816 ],
3817 )
3818}
3819
3820fn open_findings_block(findings: &[Finding]) -> String {
3821 if findings.is_empty() {
3822 return String::new();
3823 }
3824 format!(
3825 "\nThese blocking findings were left open by an earlier response. Recheck each one:\n{}\n\
3826 \nIf one still blocks, return it under the same title and file. Omission means you checked \
3827 it and found that it no longer blocks.\n",
3828 findings_for_prompt(findings)
3829 )
3830}
3831
3832fn closing_answers(ledger: &Ledger, round: u32) -> String {
3837 let lines = fixed_lines(ledger, round);
3838 if lines.is_empty() {
3839 return String::new();
3840 }
3841 format!(
3842 "\nThese points were raised on this pull request and the author says it fixed them. \
3843 Nobody has checked that:\n{}\n",
3844 lines.join("\n")
3845 )
3846}
3847
3848#[allow(clippy::too_many_arguments)]
3853fn review_prompt(
3854 base: &str,
3855 number: i64,
3856 title: &str,
3857 context: &str,
3858 ledger: &Ledger,
3859 open_findings: &[Finding],
3860 round: u32,
3861 last: u32,
3862 criteria: &[Item],
3863) -> String {
3864 fill(
3865 REVIEW_PROMPT,
3866 &[
3867 ("{base}", base),
3868 ("{number}", &number.to_string()),
3869 ("{title}", title),
3870 ("{context}", context),
3871 ("{criteria}", &criteria_to_rule(criteria)),
3872 ("{open}", &open_findings_block(open_findings)),
3873 ("{answers}", &answers_block(ledger, round)),
3874 ("{settled}", &settled_block(ledger)),
3875 ("{round}", &round_note(round, last)),
3876 ],
3877 )
3878}
3879
3880fn respond_prompt(
3883 number: i64,
3884 context: &str,
3885 blocking: &[Finding],
3886 findings: &[Finding],
3887) -> String {
3888 fill(
3889 RESPOND_PROMPT,
3890 &[
3891 ("{number}", &number.to_string()),
3892 ("{context}", context),
3893 ("{findings}", &findings_for_prompt(blocking)),
3894 ("{optional}", &optional_block(findings)),
3895 ],
3896 )
3897}
3898
3899fn implement_prompt(number: i64, title: &str, url: &str, body: &str, criteria: &[Item]) -> String {
3907 let criteria = if criteria.is_empty() {
3908 String::new()
3909 } else {
3910 format!(
3911 "\n\nThe issue's acceptance criteria, numbered:\n{}\n\nReport a status on each one \
3912 in criteria, by its number: done, not_done, or needs_person for anything the \
3913 repository cannot show, such as a run on a device. Give one line of reason. This is \
3914 your account and goes in the pull request body as yours. Only the reviewer's ruling \
3915 ticks a box on the issue.",
3916 numbered(criteria)
3917 )
3918 };
3919 fill(
3920 IMPLEMENT_PROMPT,
3921 &[
3922 ("{thread_rule}", THREAD_RULE),
3923 ("{number}", &number.to_string()),
3924 ("{title}", title),
3925 ("{url}", url),
3926 ("{criteria}", &criteria),
3927 ("{body}", body),
3928 ],
3929 )
3930}
3931
3932pub fn pr_body(
3942 issue: i64,
3943 work: &Implementation,
3944 checked: Option<&str>,
3945 criteria: &[Item],
3946 style: &Style,
3947) -> String {
3948 let mut parts = vec![format!("Closes #{issue}")];
3949
3950 for lead in [&work.summary, &work.problem] {
3951 let text = style::sentence(lead, style);
3952 if !text.is_empty() {
3953 parts.push(text);
3954 }
3955 }
3956 parts.extend(section("What changed", &work.changes, style));
3957 let mut testing = work.testing.clone();
3960 if let Some(command) = checked {
3961 testing.push(format!("`{command}` passed on this branch, run by spar."));
3962 }
3963 parts.extend(section("How to test", &testing, style));
3964 parts.extend(claimed_criteria(criteria, &work.criteria, style));
3965
3966 let notes = style::sentence(work.notes.as_deref().unwrap_or_default(), style);
3967 if !notes.is_empty() {
3968 parts.push(format!("## Notes\n\n{notes}"));
3969 }
3970
3971 style::body(&parts.join("\n\n"), style)
3972}
3973
3974fn claimed_criteria(
3979 criteria: &[Item],
3980 claims: &[CriterionStatus],
3981 style: &Style,
3982) -> Option<String> {
3983 if criteria.is_empty() {
3984 return None;
3985 }
3986 let lines: Vec<String> = criteria
3987 .iter()
3988 .enumerate()
3989 .map(|(at, item)| {
3990 let status = status_for(claims, at + 1);
3991 let label = match status.map(|s| s.status) {
3992 Some(Mark::Met) => "Done",
3993 Some(Mark::NotMet) => "Not done",
3994 Some(Mark::NeedsPerson) => "Needs a person",
3995 None => "No status given",
3996 };
3997 criterion_line(label, &item.text, status.map(|s| s.reason.as_str()), style)
3998 })
3999 .collect();
4000 Some(format!(
4001 "## Acceptance criteria\n\nThe author's account. A box on the issue is ticked only on \
4002 the review's ruling, after spar merges.\n\n{}",
4003 bullets(&lines)
4004 ))
4005}
4006
4007fn criterion_line(label: &str, text: &str, reason: Option<&str>, style: &Style) -> String {
4009 let stop = |text: String| {
4010 if text.is_empty() || text.ends_with(['.', '!', '?']) {
4011 text
4012 } else {
4013 text + "."
4014 }
4015 };
4016 let mut line = format!("{label}: {}", stop(style::one_line(text.trim())));
4017 let reason = stop(style::sentence(reason.unwrap_or_default(), style));
4018 if !reason.is_empty() {
4019 line.push(' ');
4020 line.push_str(&reason);
4021 }
4022 line
4023}
4024
4025fn numbered(criteria: &[Item]) -> String {
4027 criteria
4028 .iter()
4029 .enumerate()
4030 .map(|(at, item)| format!("{}. {}", at + 1, item.text.trim()))
4031 .collect::<Vec<_>>()
4032 .join("\n")
4033}
4034
4035fn criteria_to_rule(criteria: &[Item]) -> String {
4038 if criteria.is_empty() {
4039 return String::new();
4040 }
4041 format!(
4042 "\nThe issue's acceptance criteria, numbered:\n{}\n\nRule on each one in criteria, by its \
4043 number: met when you checked that the branch does it, not_met when it does not or you \
4044 could not confirm it, needs_person when the repository cannot show it, such as a run on \
4045 a device. Say what you checked. A criterion ruled met is ticked on the issue when spar \
4046 merges this pull request, so do not rule met on the author's word. A ruling is not a \
4047 finding: an unmet criterion holds nothing unless it is also a defect you would block \
4048 for.\n",
4049 numbered(criteria)
4050 )
4051}
4052
4053fn review_schema(criteria: &[Item]) -> serde_json::Value {
4056 if criteria.is_empty() {
4057 schema::review()
4058 } else {
4059 schema::with_criteria(schema::review(), schema::CriteriaVoice::Reviewer)
4060 }
4061}
4062
4063fn status_for(statuses: &[CriterionStatus], number: usize) -> Option<&CriterionStatus> {
4067 let answers: Vec<&CriterionStatus> = statuses
4068 .iter()
4069 .filter(|s| s.number as usize == number)
4070 .collect();
4071 answers
4072 .iter()
4073 .find(|s| s.status != Mark::Met)
4074 .or_else(|| answers.first())
4075 .copied()
4076}
4077
4078fn ruled(criteria: &[Item], rulings: &[CriterionStatus]) -> Vec<Ruled> {
4080 criteria
4081 .iter()
4082 .enumerate()
4083 .map(|(at, item)| {
4084 let status = status_for(rulings, at + 1);
4085 Ruled {
4086 raw: item.raw.clone(),
4087 line: item.line,
4088 text: item.text.trim().to_string(),
4089 status: status.map(|s| s.status),
4090 reason: status.map(|s| s.reason.clone()).unwrap_or_default(),
4091 }
4092 })
4093 .collect()
4094}
4095
4096fn tick_met(repo: &Repo, issue: i64, criteria: &[Ruled]) -> usize {
4102 let mut ticked = 0;
4103 for criterion in criteria.iter().filter(|c| c.status == Some(Mark::Met)) {
4104 let at = Some(criterion.line);
4105 if tracker::write(repo, issue, &criterion.raw, &tracker::Change::Tick, at) {
4106 ticked += 1;
4107 }
4108 }
4109 ticked
4110}
4111
4112fn section(heading: &str, lines: &[String], style: &Style) -> Option<String> {
4119 let items: Vec<String> = lines
4120 .iter()
4121 .map(|line| style::summary(line, style))
4122 .filter(|line| !line.is_empty())
4123 .collect();
4124 if items.is_empty() {
4125 return None;
4126 }
4127 Some(format!("## {heading}\n\n{}", bullets(&items)))
4128}
4129
4130pub fn from_commits(repo: &Repo, work_dir: &Path, base: &str) -> Implementation {
4138 Implementation {
4139 changes: repo.commit_subjects(work_dir, "HEAD", base),
4140 notes: Some(
4141 "The implement call failed after these commits were made, so this body is assembled \
4142 from their messages rather than written by their author. Read the diff."
4143 .to_string(),
4144 ),
4145 ..Implementation::default()
4146 }
4147}
4148
4149fn no_pr_note(work: &Implementation, style: &Style) -> String {
4156 let reason = style::sentence(&work.reason, style);
4157 if !reason.is_empty() {
4158 return reason;
4159 }
4160 if work.not_worth_doing {
4161 "Left alone after reading the code, with no reason given.".to_string()
4162 } else {
4163 "Nothing was committed, so there is nothing to review.".to_string()
4164 }
4165}
4166
4167pub fn review_comment(holder: &str, round: u32, review: &Review, style: &Style) -> String {
4171 let by = |severity: Severity| -> Vec<&Finding> {
4172 review
4173 .findings
4174 .iter()
4175 .filter(|f| f.severity == severity && f.in_scope)
4176 .collect()
4177 };
4178 let blocking = by(Severity::Blocking);
4179 let non_blocking = by(Severity::NonBlocking);
4180 let nits = by(Severity::Nit);
4181 let out_of_scope: Vec<&Finding> = review.findings.iter().filter(|f| !f.in_scope).collect();
4182
4183 let mut counts = Vec::new();
4184 if !blocking.is_empty() {
4185 counts.push(format!("{} blocking", blocking.len()));
4186 }
4187 if !non_blocking.is_empty() {
4188 counts.push(format!("{} non-blocking", non_blocking.len()));
4189 }
4190 if !nits.is_empty() {
4191 counts.push(format!("{} nit", nits.len()));
4192 }
4193 if !out_of_scope.is_empty() {
4194 counts.push(format!("{} out of scope", out_of_scope.len()));
4195 }
4196 let headline = if counts.is_empty() {
4197 "no findings".to_string()
4198 } else {
4199 counts.join(", ")
4200 };
4201
4202 let _ = (holder, round, headline);
4203 let mut out = Vec::new();
4204 let summary = style::summary(&review.summary, style);
4205 if !summary.is_empty() {
4206 out.push(summary);
4207 }
4208
4209 if !blocking.is_empty() {
4210 let lines: Vec<String> = blocking
4211 .iter()
4212 .map(|f| {
4213 let detail = style::detail(&f.detail, style);
4214 if detail.is_empty() {
4215 located(f, style)
4216 } else {
4217 format!("{}. {detail}", located(f, style))
4218 }
4219 })
4220 .collect();
4221 out.push(format!("blocking\n{}", bullets(&lines)));
4222 }
4223
4224 for (label, group) in [
4227 ("non-blocking", &non_blocking),
4228 ("nits", &nits),
4229 ("out of scope", &out_of_scope),
4230 ] {
4231 if group.is_empty() {
4232 continue;
4233 }
4234 let lines: Vec<String> = group.iter().map(|f| located(f, style)).collect();
4235 out.push(format!("{label}\n{}", bullets(&lines)));
4236 }
4237
4238 out.join("\n\n")
4239}
4240
4241pub fn disposition_comment(
4245 author: &str,
4246 response: &ResponseDoc,
4247 fixed: &[String],
4248 refuted: &[String],
4249 filed: &[String],
4250 style: &Style,
4251) -> Option<String> {
4252 if fixed.is_empty() && refuted.is_empty() && filed.is_empty() {
4253 return None;
4254 }
4255 let mut counts = Vec::new();
4256 if !fixed.is_empty() {
4257 counts.push(format!("{} fixed", fixed.len()));
4258 }
4259 if !refuted.is_empty() {
4260 counts.push(format!("{} refuted", refuted.len()));
4261 }
4262 if !filed.is_empty() {
4263 counts.push(format!("{} filed", filed.len()));
4264 }
4265
4266 let _ = (author, counts);
4267 let mut out = Vec::new();
4268 let summary = style::summary(&response.summary, style);
4269 if !summary.is_empty() {
4270 out.push(summary);
4271 }
4272 if !refuted.is_empty() {
4273 out.push(format!("refuted\n{}", bullets(refuted)));
4274 }
4275 if !fixed.is_empty() {
4276 out.push(format!("fixed\n{}", bullets(fixed)));
4277 }
4278 if !filed.is_empty() {
4279 out.push(format!("filed\n{}", bullets(filed)));
4280 }
4281 Some(out.join("\n\n"))
4282}
4283
4284pub fn skip_comment(item: &SkippedItem, style: &Style) -> String {
4292 let reasons = item
4293 .reasons
4294 .values()
4295 .map(|reason| style::sentence(reason, style));
4296 let lines = crate::textsim::dedupe_by(reasons, crate::textsim::same_reason);
4300 bullets(&lines)
4301}
4302
4303pub(crate) fn context_block(number: i64, url: &str, issue_text: &str, pr_body: &str) -> String {
4321 let mut out = String::new();
4322 let body = issue_text.trim();
4323 if !body.is_empty() {
4324 out.push_str(&format!("\nIssue #{number} as filed"));
4325 if !url.trim().is_empty() {
4326 out.push_str(&format!(", at {}", url.trim()));
4327 }
4328 out.push_str(&format!(
4329 ":\n\n{body}\n\nThat is the issue as filed, followed by any comments on it since, \
4330 oldest first. {}\n",
4331 THREAD_RULE.replace('\n', " ")
4332 ));
4333 }
4334 let claim = pr_body.trim();
4335 if !claim.is_empty() {
4336 out.push_str(&format!(
4337 "\nThe pull request description, which is the author's own account of this change. \
4338 It is a claim to check against the code, not a statement of fact:\n\n{claim}\n"
4339 ));
4340 }
4341 out
4342}
4343
4344pub(crate) fn optional_findings(findings: &[Finding]) -> Vec<Finding> {
4346 findings
4347 .iter()
4348 .filter(|f| f.in_scope && f.severity == Severity::NonBlocking)
4349 .cloned()
4350 .collect()
4351}
4352
4353fn optional_block(findings: &[Finding]) -> String {
4354 let optional = optional_findings(findings);
4355 if optional.is_empty() {
4356 return String::new();
4357 }
4358 format!("\n{OPTIONAL_HEADER}{}\n", findings_for_prompt(&optional))
4359}
4360
4361fn warn_unplaced_dispositions(response: &ResponseDoc, used: &[bool]) {
4379 for (index, disposition) in response.dispositions.iter().enumerate() {
4380 if !used[index] {
4381 logwarn!(
4382 "ignoring an unmatched or duplicate disposition for '{}' ({})",
4383 disposition.title,
4384 disposition.file
4385 );
4386 }
4387 }
4388}
4389
4390fn apply_optional_dispositions(
4391 repo: &Repo,
4392 cfg: &Config,
4393 response: &ResponseDoc,
4394 findings: &[Finding],
4395 state: &mut IssueRun,
4396 subject: i64,
4397 used: &mut [bool],
4398) {
4399 let optional = optional_findings(findings);
4400 if optional.is_empty() {
4401 return;
4402 }
4403 let mut fixed = 0usize;
4404 for finding in &optional {
4405 let Ok((index, disposition)) =
4406 matching_disposition(finding, &optional, &response.dispositions)
4407 else {
4408 continue;
4409 };
4410 if used[index] {
4413 continue;
4414 }
4415 used[index] = true;
4416 let unique = unique_stable_finding(&optional, finding);
4417 match disposition.action {
4418 Action::Fixed => {
4419 fixed += 1;
4420 forget_noted(state, finding, unique);
4421 }
4422 Action::FiledIssue => {
4423 let title = disposition
4424 .new_issue_title
4425 .clone()
4426 .filter(|t| !t.trim().is_empty())
4427 .unwrap_or_else(|| finding.title.clone());
4428 let body = disposition
4429 .new_issue_body
4430 .clone()
4431 .filter(|b| !b.trim().is_empty())
4432 .unwrap_or_else(|| finding.detail.clone());
4433 let recorded = file_followup(repo, &title, &body, subject, cfg, state);
4434 record_nonblocking_outcome_with_match(state, finding, Some(&recorded), unique);
4435 }
4436 Action::Refuted => {}
4437 }
4438 }
4439 if fixed > 0 {
4440 logdim!("the author also fixed {fixed} non-blocking finding(s)");
4441 }
4442}
4443
4444pub(crate) fn findings_for_prompt(findings: &[Finding]) -> String {
4447 if findings.is_empty() {
4448 return "(none)".to_string();
4449 }
4450 findings
4451 .iter()
4452 .map(|f| {
4453 let scope = if f.in_scope { "" } else { " [out of scope]" };
4454 format!(
4455 "- [{}]{scope} {} ({})\n {}",
4456 f.severity,
4457 f.title,
4458 f.where_at(),
4459 f.detail
4460 )
4461 })
4462 .collect::<Vec<_>>()
4463 .join("\n")
4464}
4465
4466#[cfg(test)]
4467mod tests {
4468 use super::*;
4469 use crate::model::Verdict;
4470
4471 fn style() -> Style {
4472 Style::default()
4473 }
4474
4475 fn finding(severity: &str, title: &str, detail: &str, file: &str, in_scope: bool) -> Finding {
4476 Finding {
4477 severity: Severity::parse_lenient(severity).unwrap(),
4478 title: title.into(),
4479 detail: detail.into(),
4480 file: file.into(),
4481 in_scope,
4482 ..Default::default()
4483 }
4484 }
4485
4486 fn review(summary: &str, findings: Vec<Finding>) -> Review {
4487 Review {
4488 verdict: Verdict::Approve,
4489 next_action: NextAction::Merge,
4490 summary: summary.into(),
4491 findings,
4492 criteria: Vec::new(),
4493 }
4494 }
4495
4496 fn disposition(title: &str, file: &str, action: Action) -> Disposition {
4497 Disposition {
4498 title: title.into(),
4499 file: file.into(),
4500 action,
4501 reasoning: "because".into(),
4502 new_issue_title: None,
4503 new_issue_body: None,
4504 }
4505 }
4506
4507 fn cfg_with(worktrees: bool, keep: bool) -> Config {
4510 let text = "[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n";
4511 let mut cfg = crate::config::parse(text).unwrap();
4512 cfg.loop_cfg.worktrees = worktrees;
4513 cfg.loop_cfg.keep_worktrees = keep;
4514 cfg
4515 }
4516
4517 #[test]
4518 fn a_worktree_is_released_on_every_finished_outcome() {
4519 let cfg = cfg_with(true, false);
4520 for status in [Status::Approved, Status::Merged, Status::Abandoned] {
4521 assert!(should_release(&cfg, status), "{status}");
4522 }
4523 }
4524
4525 #[test]
4528 fn a_worktree_is_kept_only_where_a_human_has_to_look() {
4529 let cfg = cfg_with(true, false);
4530 assert!(!should_release(&cfg, Status::Escalated));
4531 assert!(!should_release(&cfg, Status::Error));
4532 }
4533
4534 #[test]
4535 fn an_uncommitted_implementation_names_its_diagnostic_and_recovery_path() {
4536 let path = Path::new("/tmp/issue worktree");
4537 let err =
4538 uncommitted_implementation_error(path, Some("git add could not create index.lock"))
4539 .to_string();
4540 assert!(err.contains("could not create index.lock"), "{err}");
4541 assert!(err.contains("/tmp/issue worktree"), "{err}");
4542 assert!(err.contains("Commit or recover"), "{err}");
4543 assert!(!should_release(&cfg_with(true, false), Status::Error));
4544 }
4545
4546 #[test]
4547 fn the_keep_flag_overrides_everything() {
4548 assert!(!should_release(&cfg_with(true, true), Status::Approved));
4549 }
4550
4551 #[test]
4552 fn nothing_is_released_when_worktrees_are_off() {
4553 assert!(!should_release(&cfg_with(false, false), Status::Approved));
4554 }
4555
4556 #[test]
4561 fn fixing_your_own_findings_hands_the_pr_over() {
4562 let cfg = cfg_with(true, false);
4563 assert_eq!("a", next_reviewer(&cfg, "b", Some("b")));
4564 assert_eq!("b", next_reviewer(&cfg, "a", Some("a")));
4565 }
4566
4567 #[test]
4571 fn handing_back_keeps_the_reviewer_for_the_next_round() {
4572 let cfg = cfg_with(true, false);
4573 assert_eq!("b", next_reviewer(&cfg, "b", Some("a")));
4574 assert_eq!("a", next_reviewer(&cfg, "a", Some("b")));
4575 }
4576
4577 #[test]
4580 fn nobody_reviews_their_own_edit() {
4581 let cfg = cfg_with(true, false);
4582 let round_1 = cfg.other(&cfg.first_implementor);
4583 assert_eq!("b", round_1);
4584 for editor in ["a", "b"] {
4585 assert_ne!(editor, next_reviewer(&cfg, &round_1, Some(editor)));
4586 }
4587 }
4588
4589 #[test]
4594 fn a_fix_that_committed_nothing_leaves_the_pr_where_it_is() {
4595 let cfg = cfg_with(true, false);
4596 assert_eq!("b", next_reviewer(&cfg, "b", None));
4597 assert_eq!("a", next_reviewer(&cfg, "a", None));
4598 }
4599
4600 #[test]
4604 fn a_reviewer_that_wrote_the_head_gives_the_pr_up() {
4605 let cfg = cfg_with(true, false);
4606 assert_eq!("a", next_reviewer(&cfg, "b", Some("b")));
4607 }
4608
4609 #[test]
4613 fn a_review_that_wrote_cannot_approve_what_is_left() {
4614 assert!(!approval_stands(&[], true));
4615 }
4616
4617 #[test]
4618 fn a_clean_review_of_an_untouched_branch_approves() {
4619 assert!(approval_stands(&[], false));
4620 }
4621
4622 #[test]
4623 fn a_blocking_finding_never_approves() {
4624 let blocking = vec![finding("blocking", "Broken", "detail", "src/x.rs", true)];
4625 assert!(!approval_stands(&blocking, false));
4626 }
4627
4628 #[test]
4629 fn approval_refuses_a_head_that_changed_after_review() {
4630 assert!(ensure_reviewed_head(36, "abc123", "abc123").is_ok());
4631 let error = ensure_reviewed_head(36, "abc123", "def456").unwrap_err();
4632 assert!(error.to_string().contains("unread head"));
4633 }
4634
4635 #[test]
4637 fn only_a_moved_head_counts_as_a_commit() {
4638 let before = Snapshot {
4639 head: "abc".into(),
4640 dirty: false,
4641 };
4642 assert!(!Snapshot {
4643 head: "abc".into(),
4644 dirty: true,
4645 }
4646 .landed_over(&before));
4647 assert!(Snapshot {
4648 head: "def".into(),
4649 dirty: false,
4650 }
4651 .landed_over(&before));
4652 assert!(!Snapshot {
4654 head: String::new(),
4655 dirty: false,
4656 }
4657 .landed_over(&before));
4658 }
4659
4660 #[test]
4664 fn a_fresh_run_starts_at_one() {
4665 assert_eq!((1, 3), round_window(1, 3));
4666 assert_eq!((1, 5), round_window(1, 5));
4667 }
4668
4669 #[test]
4673 fn a_resumed_run_gets_a_full_fresh_budget() {
4674 assert_eq!((6, 10), round_window(6, 5));
4675 assert_eq!((11, 13), round_window(11, 3));
4676 }
4677
4678 #[test]
4679 fn a_budget_of_one_is_a_single_round() {
4680 assert_eq!((6, 6), round_window(6, 1));
4681 }
4682
4683 #[test]
4684 fn round_numbers_keep_counting_across_sessions() {
4685 let mut start = 1;
4687 let mut seen = Vec::new();
4688 for _ in 0..3 {
4689 let (first, last) = round_window(start, 3);
4690 seen.push((first, last));
4691 start = last + 1;
4692 }
4693 assert_eq!(vec![(1, 3), (4, 6), (7, 9)], seen);
4694 }
4695
4696 fn ledger_with(title: &str, file: &str) -> Ledger {
4699 let mut ledger = Ledger::new();
4700 ledger.insert(
4701 finding_key(title, file),
4702 LedgerEntry {
4703 title: title.into(),
4704 file: file.into(),
4705 reasoning: "no".into(),
4706 round: 1,
4707 reraised: 0,
4708 outcome: Settled::Refuted,
4709 },
4710 );
4711 ledger
4712 }
4713
4714 #[test]
4715 fn a_point_refuted_and_re_raised_twice_escalates() {
4716 let mut ledger = ledger_with("nit about naming", "a.rs");
4717 let mut state = IssueRun::new(1, "t");
4718 let blocking = vec![finding("blocking", "nit about naming", "d", "a.rs", true)];
4719 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
4720 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
4721 }
4722
4723 #[test]
4727 fn a_point_fixed_twice_and_raised_again_escalates() {
4728 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
4729 for entry in ledger.values_mut() {
4730 entry.outcome = Settled::Fixed;
4731 }
4732 let mut state = IssueRun::new(1, "t");
4733 let blocking = [finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
4734
4735 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
4736 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
4737 }
4738
4739 #[test]
4742 fn a_fix_that_missed_twice_is_not_reported_as_a_refutation() {
4743 assert!(why_escalated(Settled::Fixed).contains("fixed twice"));
4744 for outcome in [Settled::Refuted, Settled::Filed, Settled::Dropped] {
4745 assert!(why_escalated(outcome).contains("settled"), "{outcome}");
4746 }
4747 }
4748
4749 #[test]
4754 fn a_reviewer_that_fixes_its_own_findings_records_them_too() {
4755 let mut ledger = Ledger::new();
4756 let blocking = vec![finding(
4757 "blocking",
4758 "Unbounded loop",
4759 "spins",
4760 "src/x.rs",
4761 true,
4762 )];
4763 let mut state = IssueRun::new(1, "t");
4764
4765 record_own_fixes(&blocking, &mut ledger, &mut state, 1);
4766
4767 let entry = ledger
4768 .get(&finding_key("Unbounded loop", "src/x.rs"))
4769 .expect("keyed where the next round will look");
4770 assert_eq!(Settled::Fixed, entry.outcome);
4771 assert_eq!(
4772 "a committed change was made for this point",
4773 entry.reasoning
4774 );
4775
4776 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
4778 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
4779 }
4780
4781 #[test]
4790 fn a_review_that_contradicts_itself_says_so_and_the_findings_win() {
4791 let blocker = finding("blocking", "Retry loop spins", "spins", "src/net.rs", true);
4792 let approve_with_blocker = Review {
4793 verdict: Verdict::Approve,
4794 next_action: NextAction::HandBack,
4795 summary: "one blocker".into(),
4796 findings: vec![blocker],
4797 criteria: Vec::new(),
4798 };
4799 let said = verdict_disagreement(&approve_with_blocker, 1).expect("noticed");
4800 assert!(said.contains("approve"), "{said}");
4801 assert!(said.contains("findings decide"), "{said}");
4802
4803 let changes_but_merge = Review {
4804 verdict: Verdict::ChangesRequested,
4805 next_action: NextAction::Merge,
4806 summary: "nothing blocking".into(),
4807 findings: vec![],
4808 criteria: Vec::new(),
4809 };
4810 assert!(verdict_disagreement(&changes_but_merge, 0).is_some());
4811
4812 let clean = Review {
4814 verdict: Verdict::Approve,
4815 next_action: NextAction::Merge,
4816 summary: "good".into(),
4817 findings: vec![],
4818 criteria: Vec::new(),
4819 };
4820 assert!(verdict_disagreement(&clean, 0).is_none());
4821 let blocked = Review {
4822 verdict: Verdict::ChangesRequested,
4823 next_action: NextAction::HandBack,
4824 summary: "one blocker".into(),
4825 findings: vec![],
4826 criteria: Vec::new(),
4827 };
4828 assert!(verdict_disagreement(&blocked, 1).is_none());
4829 }
4830
4831 #[test]
4839 fn a_finding_the_fix_call_declined_stays_open() {
4840 let blocking = vec![
4841 finding("blocking", "Unbounded loop", "spins", "src/x.rs:88", true),
4842 finding("blocking", "Missing guard", "panics", "src/y.rs:12", true),
4843 ];
4844 let fixes = vec![
4845 OwnFix {
4846 title: "Unbounded loop".into(),
4847 file: "src/x.rs:88".into(),
4848 fixed: true,
4849 reason: "bounded it on max_attempts".into(),
4850 },
4851 OwnFix {
4852 title: "Missing guard".into(),
4853 file: "src/y.rs:12".into(),
4854 fixed: false,
4855 reason: "the guard belongs in the caller, which this branch does not touch".into(),
4856 },
4857 ];
4858
4859 let (claimed, left) = split_own_fixes(&blocking, &fixes);
4860
4861 assert_eq!(1, claimed.len());
4862 assert_eq!("Unbounded loop", claimed[0].title);
4863 assert_eq!(1, left.len());
4864 assert_eq!("Missing guard", left[0].title);
4865 assert!(
4866 left[0].detail.contains("belongs in the caller"),
4867 "the author is not told why it was left: {}",
4868 left[0].detail
4869 );
4870
4871 let mut ledger = Ledger::new();
4873 let mut state = IssueRun::new(1, "t");
4874 record_own_fixes(&claimed, &mut ledger, &mut state, 1);
4875 assert!(ledger.contains_key(&finding_key("Unbounded loop", "src/x.rs:88")));
4876 assert!(
4877 !ledger.contains_key(&finding_key("Missing guard", "src/y.rs:12")),
4878 "a point nobody fixed was recorded as fixed"
4879 );
4880 }
4881
4882 #[test]
4884 fn a_finding_the_fix_report_never_mentions_stays_open() {
4885 let blocking = vec![finding(
4886 "blocking",
4887 "Unbounded loop",
4888 "spins",
4889 "src/x.rs:88",
4890 true,
4891 )];
4892 let (claimed, left) = split_own_fixes(&blocking, &[]);
4893 assert!(claimed.is_empty());
4894 assert_eq!(1, left.len());
4895 }
4896
4897 #[test]
4901 fn a_fixed_point_is_not_in_the_settled_block() {
4902 let mut ledger = ledger_with("refuted point", "a.rs");
4903 ledger.extend(ledger_with("fixed point", "b.rs"));
4904 for entry in ledger.values_mut() {
4905 if entry.title == "fixed point" {
4906 entry.outcome = Settled::Fixed;
4907 }
4908 }
4909 let block = settled_block(&ledger);
4910 assert!(block.contains("refuted point"));
4911 assert!(!block.contains("fixed point"));
4912 }
4913
4914 #[test]
4917 fn a_ledger_of_only_fixes_says_nothing_is_settled() {
4918 let mut ledger = ledger_with("fixed point", "b.rs");
4919 for entry in ledger.values_mut() {
4920 entry.outcome = Settled::Fixed;
4921 }
4922 assert_eq!("", settled_block(&ledger));
4923 }
4924
4925 #[test]
4930 fn one_review_spends_one_re_raise_however_often_it_says_it() {
4931 let mut ledger = ledger_with("Missing error handling", "src/net.rs");
4932 let mut state = IssueRun::new(1, "t");
4933 let twice = vec![
4934 finding(
4935 "blocking",
4936 "Missing error handling",
4937 "d",
4938 "src/net.rs",
4939 true,
4940 ),
4941 finding(
4942 "blocking",
4943 "Missing error handling",
4944 "e",
4945 "src/net.rs",
4946 true,
4947 ),
4948 ];
4949
4950 assert!(!check_relitigation(&mut ledger, &twice, &mut state));
4951 assert_eq!(1, ledger.values().next().unwrap().reraised);
4952 assert!(check_relitigation(&mut ledger, &twice, &mut state));
4953 }
4954
4955 #[test]
4956 fn an_untracked_finding_does_not_escalate() {
4957 let mut state = IssueRun::new(1, "t");
4958 let blocking = vec![finding("blocking", "brand new", "d", "a.rs", true)];
4959 assert!(!check_relitigation(
4960 &mut Ledger::new(),
4961 &blocking,
4962 &mut state
4963 ));
4964 }
4965
4966 #[test]
4967 fn persisted_ledger_entries_are_rekeyed_for_stable_locations() {
4968 let mut ledger = Ledger::new();
4969 ledger.insert(
4970 "legacy-key".into(),
4971 LedgerEntry {
4972 title: "Unbounded loop".into(),
4973 file: "src/x.rs:88".into(),
4974 reasoning: "bounded by the caller".into(),
4975 round: 2,
4976 reraised: 1,
4977 outcome: Settled::Refuted,
4978 },
4979 );
4980 normalise_ledger_keys(&mut ledger);
4981 let key = matching_ledger_key(&ledger, "Unbounded loop", "src/x.rs:91").unwrap();
4982 assert_eq!(1, ledger[&key].reraised);
4983 }
4984
4985 #[test]
4986 fn same_title_at_two_sites_keeps_both_blockers() {
4987 let findings = vec![
4988 finding(
4989 "blocking",
4990 "Unchecked error",
4991 "first site",
4992 "src/net.rs:10",
4993 true,
4994 ),
4995 finding(
4996 "blocking",
4997 "Unchecked error",
4998 "second site",
4999 "src/net.rs:200",
5000 true,
5001 ),
5002 ];
5003
5004 let blocking = blocking_findings(&findings);
5005 assert_eq!(2, blocking.len());
5006 assert_eq!("src/net.rs:10", blocking[0].file);
5007 assert_eq!("src/net.rs:200", blocking[1].file);
5008 }
5009
5010 #[test]
5011 fn moved_location_fallback_refuses_an_ambiguous_ledger() {
5012 let mut ledger = ledger_with("Unchecked error", "src/net.rs:10");
5013 ledger.extend(ledger_with("Unchecked error", "src/net.rs:200"));
5014 assert!(matching_ledger_key(&ledger, "Unchecked error", "src/net.rs:30").is_none());
5015 }
5016
5017 #[test]
5018 fn two_current_sites_do_not_relocate_one_old_ledger_entry() {
5019 let mut ledger = ledger_with("Unchecked error", "src/net.rs:5");
5020 let blocking = vec![
5021 finding(
5022 "blocking",
5023 "Unchecked error",
5024 "first",
5025 "src/net.rs:10",
5026 true,
5027 ),
5028 finding(
5029 "blocking",
5030 "Unchecked error",
5031 "second",
5032 "src/net.rs:200",
5033 true,
5034 ),
5035 ];
5036 let mut state = IssueRun::new(1, "t");
5037 record_own_fixes(&blocking, &mut ledger, &mut state, 2);
5038 assert!(ledger.contains_key(&finding_key("Unchecked error", "src/net.rs:10")));
5039 assert!(ledger.contains_key(&finding_key("Unchecked error", "src/net.rs:200")));
5040 }
5041
5042 #[test]
5043 fn two_current_sites_remain_two_open_findings() {
5044 let current = vec![
5045 finding(
5046 "blocking",
5047 "Unchecked error",
5048 "first",
5049 "src/net.rs:10",
5050 true,
5051 ),
5052 finding(
5053 "blocking",
5054 "Unchecked error",
5055 "second",
5056 "src/net.rs:200",
5057 true,
5058 ),
5059 ];
5060 let mut open = Vec::new();
5061
5062 extend_findings(&mut open, ¤t);
5063
5064 assert_eq!(2, open.len());
5065 assert_eq!("src/net.rs:10", open[0].file);
5066 assert_eq!("src/net.rs:200", open[1].file);
5067 }
5068
5069 #[test]
5070 fn display_limits_do_not_change_persisted_finding_identity() {
5071 let point = finding(
5072 "blocking",
5073 "abcdefghij",
5074 "still wrong",
5075 "src/net.rs:10",
5076 true,
5077 );
5078 let mut ledger = Ledger::new();
5079 let mut state = IssueRun::new(1, "t");
5080 record_own_fixes(std::slice::from_ref(&point), &mut ledger, &mut state, 1);
5081 normalise_ledger_keys(&mut ledger);
5082
5083 let key = matching_ledger_key(&ledger, "abcdefghij", "src/net.rs:12").unwrap();
5084 assert_eq!("abcdefghij", ledger[&key].title);
5085 }
5086
5087 #[test]
5088 fn a_clipped_legacy_entry_keeps_its_original_lookup_key() {
5089 let mut ledger = Ledger::new();
5090 let key = crate::jsonx::finding_key("abcdefghij", "src/net.rs:10");
5091 ledger.insert(
5092 key.clone(),
5093 LedgerEntry {
5094 title: "abcde".into(),
5095 file: "src/net.rs:10".into(),
5096 reasoning: "bounded by the caller".into(),
5097 round: 1,
5098 reraised: 1,
5099 outcome: Settled::Refuted,
5100 },
5101 );
5102 normalise_ledger_keys(&mut ledger);
5103
5104 assert_eq!(
5105 Some(key),
5106 matching_ledger_key(&ledger, "abcdefghij", "src/net.rs:12")
5107 );
5108 }
5109
5110 #[test]
5111 fn a_legacy_key_collision_does_not_merge_case_distinct_paths() {
5112 let mut ledger = Ledger::new();
5113 let key = crate::jsonx::finding_key("Unchecked error", "src/Main.rs:10");
5114 ledger.insert(
5115 key.clone(),
5116 LedgerEntry {
5117 title: "Unchecked error".into(),
5118 file: "src/Main.rs:10".into(),
5119 reasoning: "bounded by the caller".into(),
5120 round: 1,
5121 reraised: 0,
5122 outcome: Settled::Refuted,
5123 },
5124 );
5125
5126 assert_eq!(
5127 Some(key),
5128 matching_ledger_key(&ledger, "Unchecked error", "src/Main.rs:10")
5129 );
5130 assert!(matching_ledger_key(&ledger, "Unchecked error", "src/main.rs:10").is_none());
5131 }
5132
5133 #[test]
5137 fn a_refutation_lands_on_the_key_the_next_round_will_look_up() {
5138 let blocking = [finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
5139 let recorded = finding_key(&blocking[0].title, &blocking[0].file);
5140 let answer = disposition("unbounded loop!", "src/x.rs", Action::Refuted);
5141 assert!(answer_matches(&blocking[0], &answer));
5142 assert_eq!(recorded, finding_key(&answer.title, &answer.file));
5143 }
5144
5145 #[test]
5148 fn the_ledger_key_ignores_title_punctuation() {
5149 let findings = [finding(
5150 "blocking",
5151 "Panic on multi-byte input",
5152 "d",
5153 "src/style.rs",
5154 true,
5155 )];
5156 let reworded = "Panic on multibyte input";
5157 let source = &findings[0];
5158 assert_eq!(
5159 finding_key(reworded, &source.file),
5160 finding_key(&source.title, &source.file)
5161 );
5162 let recorded = finding_key(&source.title, &source.file);
5163 let looked_up = finding_key(&findings[0].title, &findings[0].file);
5164 assert_eq!(recorded, looked_up);
5165 }
5166
5167 #[test]
5168 fn a_disposition_matches_its_finding_despite_wording_noise() {
5169 let findings = [finding(
5170 "blocking",
5171 "Unbounded loop!",
5172 "d",
5173 "src/x.rs",
5174 true,
5175 )];
5176 assert!(answer_matches(
5177 &findings[0],
5178 &disposition("unbounded loop", "src/x.rs", Action::Refuted)
5179 ));
5180 assert!(!answer_matches(
5181 &findings[0],
5182 &disposition("something else", "src/x.rs", Action::Refuted)
5183 ));
5184 assert!(!answer_matches(
5185 &findings[0],
5186 &disposition("unbounded loop", "src/y.rs", Action::Refuted)
5187 ));
5188 }
5189
5190 #[test]
5191 fn a_disposition_matches_its_finding_despite_a_severity_tag() {
5192 let blocker = finding(
5193 "blocking",
5194 "Refused requests can corrupt the active splice",
5195 "d",
5196 "src/lightning/channel/channel.ts",
5197 true,
5198 );
5199 let answers = [disposition(
5200 "[blocking] Refused requests can corrupt the active splice",
5201 "src/lightning/channel/channel.ts",
5202 Action::Fixed,
5203 )];
5204
5205 assert_eq!(
5206 0,
5207 matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers)
5208 .expect("tagged answer")
5209 .0
5210 );
5211 }
5212
5213 #[test]
5214 fn a_bracketed_subject_still_separates_two_findings() {
5215 let ios = finding("blocking", "[iOS] Startup crash", "d", "src/app.ts", true);
5216 let android = finding(
5217 "blocking",
5218 "[Android] Startup crash",
5219 "d",
5220 "src/app.ts",
5221 true,
5222 );
5223 let findings = [ios.clone(), android];
5224 let answers = [disposition(
5225 "[Android] Startup crash",
5226 "src/app.ts",
5227 Action::Fixed,
5228 )];
5229
5230 assert!(matches!(
5231 matching_disposition(&ios, &findings, &answers),
5232 Err("no matching disposition")
5233 ));
5234 }
5235
5236 #[test]
5242 fn an_answer_to_an_optional_finding_is_placed_by_the_optional_pass() {
5243 let blocker = finding("blocking", "Unbounded loop", "d", "src/x.rs", true);
5244 let optional = finding(
5245 "non-blocking",
5246 "Storage failures are reported as a successful state update",
5247 "d",
5248 "src/wallet/index.ts",
5249 true,
5250 );
5251 let answers = [
5252 disposition("Unbounded loop", "src/x.rs", Action::Fixed),
5253 disposition(
5254 "Storage failures are reported as a successful state update",
5255 "src/wallet/index.ts",
5256 Action::Refuted,
5257 ),
5258 ];
5259 let mut used = vec![false; answers.len()];
5260
5261 let blocking = [blocker];
5262 let (index, _) = matching_disposition(&blocking[0], &blocking, &answers).unwrap();
5263 used[index] = true;
5264
5265 let optionals = [optional];
5266 let (index, _) = matching_disposition(&optionals[0], &optionals, &answers).unwrap();
5267 assert!(!used[index], "the blocking pass must not have taken it");
5268 used[index] = true;
5269
5270 assert!(used.iter().all(|placed| *placed), "both were placed");
5271 }
5272
5273 #[test]
5276 fn an_answer_to_a_point_nobody_raised_stays_unplaced() {
5277 let blocking = [finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
5278 let answers = [
5279 disposition("Unbounded loop", "src/x.rs", Action::Fixed),
5280 disposition("Something nobody said", "src/z.rs", Action::Refuted),
5281 ];
5282 let mut used = vec![false; answers.len()];
5283
5284 let (index, _) = matching_disposition(&blocking[0], &blocking, &answers).unwrap();
5285 used[index] = true;
5286
5287 let optionals = optional_findings(&blocking);
5288 assert!(optionals.is_empty());
5289
5290 assert_eq!(vec![false], vec![used[1]], "the stray answer is unplaced");
5291 }
5292
5293 #[test]
5294 fn an_omitted_disposition_leaves_the_blocker_unmatched() {
5295 let blocker = finding("blocking", "Unbounded loop", "d", "src/x.rs", true);
5296 assert!(matches!(
5297 matching_disposition(
5298 &blocker,
5299 std::slice::from_ref(&blocker),
5300 &[] as &[Disposition]
5301 ),
5302 Err("no matching disposition")
5303 ));
5304 }
5305
5306 #[test]
5307 fn duplicate_dispositions_are_ambiguous() {
5308 let blocker = finding("blocking", "Unbounded loop", "d", "src/x.rs", true);
5309 let answers = vec![
5310 disposition("Unbounded loop", "src/x.rs", Action::Fixed),
5311 disposition("Unbounded loop", "src/x.rs", Action::Refuted),
5312 ];
5313 assert!(matches!(
5314 matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers),
5315 Err("more than one matching disposition")
5316 ));
5317 }
5318
5319 #[test]
5320 fn an_unambiguous_disposition_may_omit_the_line_number() {
5321 let blocker = finding(
5322 "blocking",
5323 "Replayed offers persist the wrong lane key",
5324 "d",
5325 "src/engine.rs:1166",
5326 true,
5327 );
5328 let answers = [disposition(
5329 "replayed offers persist the wrong lane key!",
5330 "src/engine.rs",
5331 Action::Fixed,
5332 )];
5333
5334 assert_eq!(
5335 0,
5336 matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers)
5337 .expect("stable answer")
5338 .0
5339 );
5340 }
5341
5342 #[test]
5343 fn a_line_free_disposition_does_not_choose_between_two_sites() {
5344 let first = finding("blocking", "Unchecked error", "d", "src/engine.rs:10", true);
5345 let second = finding(
5346 "blocking",
5347 "Unchecked error",
5348 "d",
5349 "src/engine.rs:200",
5350 true,
5351 );
5352 let findings = [first.clone(), second];
5353 let answers = [disposition(
5354 "Unchecked error",
5355 "src/engine.rs",
5356 Action::Fixed,
5357 )];
5358
5359 assert!(matches!(
5360 matching_disposition(&first, &findings, &answers),
5361 Err("no matching disposition")
5362 ));
5363 }
5364
5365 #[test]
5366 fn an_exact_location_wins_over_an_ambiguous_fallback() {
5367 let blocker = finding("blocking", "Unchecked error", "d", "src/engine.rs:10", true);
5368 let answers = [
5369 disposition("Unchecked error", "src/engine.rs", Action::Refuted),
5370 disposition("Unchecked error", "src/engine.rs:10", Action::Fixed),
5371 ];
5372
5373 let matched = matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers)
5374 .expect("exact answer");
5375 assert_eq!(1, matched.0);
5376 assert_eq!(Action::Fixed, matched.1.action);
5377 }
5378
5379 #[test]
5380 fn same_titled_findings_in_different_files_need_separate_dispositions() {
5381 let findings = [
5382 finding("blocking", "Unchecked error", "d", "src/a.rs", true),
5383 finding("blocking", "Unchecked error", "d", "src/b.rs", true),
5384 ];
5385 let answers = vec![
5386 disposition("Unchecked error", "src/a.rs", Action::Fixed),
5387 disposition("Unchecked error", "src/b.rs", Action::Refuted),
5388 ];
5389 assert_eq!(
5390 0,
5391 matching_disposition(&findings[0], &findings, &answers)
5392 .expect("left answer")
5393 .0
5394 );
5395 assert_eq!(
5396 1,
5397 matching_disposition(&findings[1], &findings, &answers)
5398 .expect("right answer")
5399 .0
5400 );
5401 }
5402
5403 #[test]
5404 fn a_reported_fix_without_a_commit_stays_open() {
5405 assert!(!fixed_disposition_resolves(false));
5406 assert!(fixed_disposition_resolves(true));
5407 }
5408
5409 #[test]
5410 fn the_settled_block_is_empty_when_nothing_is_settled() {
5411 assert_eq!("", settled_block(&Ledger::new()));
5412 }
5413
5414 #[test]
5415 fn the_settled_block_names_each_refutation() {
5416 let block = settled_block(&ledger_with("a point", "x.rs"));
5417 assert!(block.contains("a point"));
5418 assert!(block.contains("x.rs"));
5419 assert!(block.contains("settled"));
5420 }
5421
5422 #[test]
5423 fn same_title_settlements_name_each_location() {
5424 let mut ledger = ledger_with("Unchecked error", "a.rs:10");
5425 ledger.extend(ledger_with("Unchecked error", "b.rs:20"));
5426
5427 let block = settled_block(&ledger);
5428
5429 assert!(block.contains("Unchecked error (a.rs:10)"), "{block}");
5430 assert!(block.contains("Unchecked error (b.rs:20)"), "{block}");
5431 }
5432
5433 #[test]
5437 fn a_filed_point_is_settled_too() {
5438 let mut ledger = ledger_with("out of scope", "x.rs");
5439 for entry in ledger.values_mut() {
5440 entry.outcome = Settled::Filed;
5441 entry.reasoning = "Tracked in #9.".into();
5442 }
5443 let block = settled_block(&ledger);
5444 assert!(block.contains("out of scope"));
5445 assert!(block.contains("#9"));
5446 }
5447
5448 #[test]
5451 fn answering_a_point_again_keeps_its_re_raise_count() {
5452 let mut ledger = ledger_with("a point", "x.rs");
5453 let entry = ledger.values().next().unwrap().clone();
5454 let mut state = IssueRun::new(1, "t");
5455 let blocking = vec![finding("blocking", "a point", "d", "x.rs", true)];
5456
5457 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
5458 settle(&mut ledger, "a point", "x.rs", true, entry);
5459 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
5460 }
5461
5462 #[test]
5465 fn a_clean_review_is_just_the_verdict() {
5468 let text = review_comment("codex", 1, &review("Looks correct.", vec![]), &style());
5469 assert_eq!("Looks correct.", text);
5470 }
5471
5472 #[test]
5473 fn a_review_leads_with_the_counts() {
5474 let text = review_comment(
5475 "codex",
5476 2,
5477 &review(
5478 "One real problem.",
5479 vec![
5480 finding(
5481 "blocking",
5482 "Loop never terminates",
5483 "Confirmed by running it.",
5484 "src/a.rs",
5485 true,
5486 ),
5487 finding("non-blocking", "Name is vague", "d", "src/b.rs", true),
5488 finding("nit", "Log wording", "d", "", true),
5489 ],
5490 ),
5491 &style(),
5492 );
5493 assert!(text.starts_with("One real problem."), "{text}");
5494 assert!(!text.contains("codex"), "no agent name: {text}");
5495 assert!(!text.contains("round 2"), "no round number: {text}");
5496 }
5497
5498 #[test]
5501 fn only_blocking_findings_carry_their_detail() {
5502 let text = review_comment(
5503 "codex",
5504 1,
5505 &review(
5506 "s",
5507 vec![
5508 finding("blocking", "Loop", "BLOCKING DETAIL", "a.rs", true),
5509 finding("non-blocking", "Name", "NONBLOCKING DETAIL", "b.rs", true),
5510 ],
5511 ),
5512 &style(),
5513 );
5514 assert!(text.contains("BLOCKING DETAIL"), "{text}");
5515 assert!(!text.contains("NONBLOCKING DETAIL"), "{text}");
5516 }
5517
5518 #[test]
5519 fn a_thorough_explanation_reaches_the_author_intact() {
5522 let detail = "Reproduced by running the 429 test with max_attempts unset. ".repeat(8);
5523 let text = review_comment(
5524 "codex",
5525 1,
5526 &review(
5527 "One problem.",
5528 vec![finding("blocking", "T", &detail, "a.rs", true)],
5529 ),
5530 &style(),
5531 );
5532 assert!(
5533 text.contains(detail.trim()),
5534 "the explanation was cut:\n{text}"
5535 );
5536 }
5537
5538 #[test]
5540 fn a_runaway_model_is_still_bounded() {
5541 let long = "filler words. ".repeat(20_000);
5542 let text = review_comment(
5543 "codex",
5544 1,
5545 &review(&long, vec![finding("blocking", "T", &long, "a.rs", true)]),
5546 &style(),
5547 );
5548 assert!(
5549 text.len() < 30_000,
5550 "review comment was {} chars",
5551 text.len()
5552 );
5553 }
5554
5555 #[test]
5556 fn a_general_finding_has_no_empty_parenthesis() {
5557 let text = review_comment(
5558 "codex",
5559 1,
5560 &review("s", vec![finding("blocking", "Something", "d", "", true)]),
5561 &style(),
5562 );
5563 assert!(!text.contains("()"), "{text}");
5564 assert!(!text.contains("(general)"), "{text}");
5565 }
5566
5567 #[test]
5568 fn out_of_scope_findings_are_counted_separately() {
5569 let text = review_comment(
5570 "codex",
5571 1,
5572 &review(
5573 "s",
5574 vec![finding("blocking", "Old bug", "d", "a.rs", false)],
5575 ),
5576 &style(),
5577 );
5578 assert!(text.contains("out of scope"), "{text}");
5579 assert!(text.contains("Old bug"), "{text}");
5580 }
5581
5582 #[test]
5583 fn a_disposition_comment_leads_with_counts_and_keeps_refutations() {
5584 let response = ResponseDoc {
5585 summary: "Two of three were right.".into(),
5586 dispositions: vec![],
5587 };
5588 let text = disposition_comment(
5589 "claude",
5590 &response,
5591 &["Fixed thing".to_string()],
5592 &["Wrong thing. Because the caller already checks.".to_string()],
5593 &[],
5594 &style(),
5595 )
5596 .unwrap();
5597 assert!(text.starts_with("Two of three were right."), "{text}");
5598 assert!(!text.contains("claude"), "no agent name: {text}");
5599 assert!(
5600 text.contains("Because the caller already checks."),
5601 "{text}"
5602 );
5603 }
5604
5605 #[test]
5606 fn an_empty_disposition_comment_is_not_posted() {
5607 let response = ResponseDoc {
5608 summary: "s".into(),
5609 dispositions: vec![],
5610 };
5611 assert!(disposition_comment("claude", &response, &[], &[], &[], &style()).is_none());
5612 }
5613
5614 #[test]
5619 fn a_local_closing_commit_does_not_change_remote_custody() {
5620 for holder in ["a", "b"] {
5621 assert_eq!(holder, closing_next_actor(holder));
5622 }
5623 }
5624
5625 #[test]
5626 fn a_matching_head_keeps_saved_custody() {
5627 assert!(reconcile_saved_head(Some("abc123"), Some(2), true, "abc123", None, 42).unwrap());
5628 assert!(reconcile_saved_head(None, None, false, "abc123", None, 42).unwrap());
5629 }
5630
5631 #[test]
5632 fn a_changed_head_refuses_automatic_custody() {
5633 let error =
5634 reconcile_saved_head(Some("abc123"), Some(2), true, "def456", None, 42).unwrap_err();
5635 let text = error.to_string();
5636 assert!(text.contains("abc123"), "{text}");
5637 assert!(text.contains("def456"), "{text}");
5638 assert!(text.contains("--next <agent>"), "{text}");
5639 }
5640
5641 #[test]
5642 fn legacy_state_keeps_its_saved_custody_for_migration() {
5643 assert!(reconcile_saved_head(Some(""), Some(1), true, "def456", None, 42).unwrap());
5644 }
5645
5646 #[test]
5647 fn an_explicit_holder_resets_state_for_a_changed_or_legacy_head() {
5648 assert!(
5649 !reconcile_saved_head(Some("abc123"), Some(2), true, "def456", Some("b"), 42).unwrap()
5650 );
5651 assert!(!reconcile_saved_head(Some(""), Some(1), true, "def456", Some("b"), 42).unwrap());
5652 }
5653
5654 #[test]
5655 fn a_headless_current_or_future_state_is_not_legacy() {
5656 for version in [2, STATE_VERSION + 1] {
5657 assert!(
5658 reconcile_saved_head(Some(""), Some(version), true, "def456", None, 42).is_err()
5659 );
5660 }
5661 }
5662
5663 #[test]
5664 fn legacy_state_with_an_unknown_actor_refuses_to_guess() {
5665 assert!(reconcile_saved_head(Some(""), Some(1), false, "def456", None, 42).is_err());
5666 }
5667
5668 #[test]
5669 fn an_invalid_review_cannot_clear_a_carried_blocker() {
5670 let mut open = vec![finding(
5671 "blocking",
5672 "Unchecked error",
5673 "still fails",
5674 "src/a.rs:12",
5675 true,
5676 )];
5677 update_open_findings(&mut open, &[], false);
5678 assert_eq!(1, open.len());
5679
5680 update_open_findings(&mut open, &[], true);
5681 assert!(open.is_empty());
5682 }
5683
5684 #[test]
5685 fn a_final_round_with_no_commit_names_open_blockers() {
5686 let open = vec![finding(
5687 "blocking",
5688 "Unchecked error",
5689 "still fails",
5690 "src/a.rs",
5691 true,
5692 )];
5693 assert!(matches!(
5694 ending_without_landing(&open),
5695 Ending::Unresolved(points) if points.len() == 1
5696 ));
5697 assert!(matches!(ending_without_landing(&[]), Ending::Unchanged));
5698 }
5699
5700 #[test]
5704 fn a_closing_pass_uses_the_later_effort_tier() {
5705 use crate::config::{Call, EffortSchedule};
5706 let schedule = EffortSchedule {
5707 round_1: Some("ultra".into()),
5708 rest: Some("low".into()),
5709 ..EffortSchedule::default()
5710 };
5711 assert_eq!(None, schedule.get(Call::Close));
5712 let mut with_key = schedule.clone();
5713 with_key.close = Some("medium".into());
5714 assert_eq!(Some("medium".to_string()), with_key.get(Call::Close));
5715 }
5716
5717 #[test]
5718 fn a_ledger_with_no_claimed_fix_has_nothing_to_close_over() {
5719 assert!(!any_fixes(&ledger_with("refuted point", "a.rs"), 1));
5720 let mut fixed = ledger_with("fixed point", "b.rs");
5721 for entry in fixed.values_mut() {
5722 entry.outcome = Settled::Fixed;
5723 }
5724 assert!(any_fixes(&fixed, 1));
5725 }
5726
5727 #[test]
5730 fn the_closing_note_counts_points_rather_than_rounds() {
5731 assert_eq!("one point left after the closing pass", unresolved_note(1));
5732 assert_eq!("3 points left after the closing pass", unresolved_note(3));
5733 assert!(!unresolved_note(2).contains("round"));
5734 }
5735
5736 fn fixed_ledger() -> Ledger {
5737 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
5738 for entry in ledger.values_mut() {
5739 entry.outcome = Settled::Fixed;
5740 entry.reasoning = "bounded it on max_attempts".into();
5741 }
5742 ledger
5743 }
5744
5745 #[test]
5746 fn the_closing_prompt_names_every_fix_it_has_to_check() {
5747 let landed = vec!["abc1234 Bound the retry loop".to_string()];
5748 let prompt = close_prompt(
5749 "origin/main",
5750 42,
5751 "Retry a 429",
5752 "",
5753 "9f8e7d6",
5754 Some(&landed),
5755 &fixed_ledger(),
5756 &[],
5757 1,
5758 &[],
5759 );
5760 assert!(prompt.contains("Unbounded loop"), "{prompt}");
5761 assert!(prompt.contains("bounded it on max_attempts"), "{prompt}");
5762 assert!(prompt.contains("abc1234 Bound the retry loop"), "{prompt}");
5763 assert!(prompt.contains("git diff 9f8e7d6..HEAD"), "{prompt}");
5764 assert!(prompt.contains("git diff origin/main...HEAD"), "{prompt}");
5765 assert!(!prompt.contains('{'), "{prompt}");
5766 }
5767
5768 #[test]
5771 fn a_close_with_nothing_landed_says_so_rather_than_leaving_a_hole() {
5772 let prompt = close_prompt(
5773 "origin/main",
5774 42,
5775 "Retry a 429",
5776 "",
5777 "9f8e7d6",
5778 Some(&[]),
5779 &Ledger::new(),
5780 &[],
5781 1,
5782 &[],
5783 );
5784 assert!(
5785 prompt.contains("Nothing landed after the last round"),
5786 "{prompt}"
5787 );
5788 assert!(!prompt.contains('{'), "{prompt}");
5789 }
5790
5791 #[test]
5797 fn a_rewritten_branch_admits_it_cannot_say_what_landed() {
5798 let prompt = close_prompt(
5799 "origin/main",
5800 42,
5801 "Retry a 429",
5802 "",
5803 "9f8e7d6",
5804 None,
5805 &fixed_ledger(),
5806 &[],
5807 1,
5808 &[],
5809 );
5810 assert!(prompt.contains("were rewritten"), "{prompt}");
5811 assert!(prompt.contains("git diff origin/main...HEAD"), "{prompt}");
5812 assert!(!prompt.contains("nobody has read it"), "{prompt}");
5813 assert!(!prompt.contains('{'), "{prompt}");
5814 }
5815
5816 #[test]
5819 fn the_closing_prompt_forbids_the_writing_the_loop_rolls_back() {
5820 let prompt = close_prompt(
5821 "origin/main",
5822 42,
5823 "t",
5824 "",
5825 "9f8e7d6",
5826 Some(&[]),
5827 &Ledger::new(),
5828 &[],
5829 1,
5830 &[],
5831 );
5832 assert!(prompt.contains("do not commit"), "{prompt}");
5833 }
5834
5835 #[test]
5837 fn the_closing_prompt_keeps_confirmed_merge_blockers_blocking() {
5838 let prompt = close_prompt(
5839 "origin/main",
5840 42,
5841 "t",
5842 "",
5843 "9f8e7d6",
5844 Some(&[]),
5845 &Ledger::new(),
5846 &[],
5847 1,
5848 &[],
5849 );
5850 assert!(
5851 prompt.contains("serious defect an\nearlier round missed"),
5852 "{prompt}"
5853 );
5854 assert!(prompt.contains("final merge-safety audit"), "{prompt}");
5855 assert!(!prompt.contains("not another\naudit"), "{prompt}");
5856 assert!(!prompt.contains("A\nfinding means"), "{prompt}");
5857 assert!(prompt.contains("A\nblocking finding means"), "{prompt}");
5858 let flat = prompt.split_whitespace().collect::<Vec<_>>().join(" ");
5859 assert!(flat.contains("does not become non-blocking"), "{prompt}");
5860 assert!(flat.contains("Only one of them ships"), "{prompt}");
5861 }
5862
5863 #[test]
5868 fn the_closing_pass_is_offered_a_severity_rather_than_the_field_that_gates() {
5869 let prompt = close_prompt(
5870 "origin/main",
5871 42,
5872 "t",
5873 "",
5874 "9f8e7d6",
5875 Some(&[]),
5876 &Ledger::new(),
5877 &[],
5878 1,
5879 &[],
5880 );
5881 assert!(
5882 prompt.contains("Minor defects and improvements are\nnon-blocking"),
5883 "{prompt}"
5884 );
5885 assert!(
5886 prompt.contains("a real defect\nthis pull request did not cause"),
5887 "{prompt}"
5888 );
5889 }
5890
5891 #[test]
5894 fn an_out_of_scope_point_cannot_gate_the_close() {
5895 let out_of_scope = finding("blocking", "Adjacent leak", "d", "o.rs", false);
5896 assert!(!out_of_scope.blocks());
5897 assert!(approval_stands(&[], false));
5898 }
5899
5900 #[test]
5904 fn a_fix_is_shown_to_the_pass_that_has_to_check_it_and_not_after() {
5905 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
5906 for entry in ledger.values_mut() {
5907 entry.outcome = Settled::Fixed;
5908 entry.round = 2;
5909 }
5910 assert!(answers_block(&ledger, 3).contains("Unbounded loop"));
5912 assert_eq!("", answers_block(&ledger, 4));
5914 assert!(any_fixes(&ledger, 2));
5915 assert!(!any_fixes(&ledger, 3));
5916 }
5917
5918 #[test]
5923 fn the_answers_block_asks_the_reviewer_to_check_rather_than_to_trust() {
5924 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
5925 for entry in ledger.values_mut() {
5926 entry.outcome = Settled::Fixed;
5927 entry.reasoning = "bounded it on max_attempts".into();
5928 }
5929 let block = answers_block(&ledger, 2);
5930 assert!(block.contains("Unbounded loop"), "{block}");
5931 assert!(block.contains("src/x.rs"), "{block}");
5932 assert!(block.contains("bounded it on max_attempts"), "{block}");
5933 assert!(block.contains("Check the answer"), "{block}");
5934 assert!(!block.contains("settled"), "{block}");
5935 }
5936
5937 #[test]
5940 fn a_fix_and_a_refutation_do_not_share_a_heading() {
5941 let mut ledger = ledger_with("refuted point", "a.rs");
5942 ledger.extend(ledger_with("fixed point", "b.rs"));
5943 for entry in ledger.values_mut() {
5944 if entry.title == "fixed point" {
5945 entry.outcome = Settled::Fixed;
5946 }
5947 }
5948 let answers = answers_block(&ledger, 2);
5949 let settled = settled_block(&ledger);
5950 assert!(answers.contains("fixed point") && !answers.contains("refuted point"));
5951 assert!(settled.contains("refuted point") && !settled.contains("fixed point"));
5952 }
5953
5954 #[test]
5955 fn an_empty_ledger_adds_no_answers_block() {
5956 assert_eq!("", answers_block(&Ledger::new(), 2));
5957 }
5958
5959 #[test]
5962 fn the_last_round_that_can_ask_for_anything_says_so() {
5963 assert_eq!("", round_note(1, 3));
5964 assert_eq!("", round_note(2, 3));
5965 assert!(round_note(3, 3).contains("last round"));
5966 assert!(round_note(6, 6).contains("last round"));
5969 }
5970
5971 #[test]
5977 fn saying_when_the_asking_stops_says_nothing_about_severity() {
5978 let note = round_note(3, 3);
5979 for word in ["approve", "blocking", "severity", "nit"] {
5980 assert!(!note.contains(word), "{word} in: {note}");
5981 }
5982 }
5983
5984 #[test]
5985 fn the_review_prompt_leaves_nothing_unsubstituted() {
5986 let empty = review_prompt(
5990 "origin/main",
5991 42,
5992 "Retry a 429",
5993 "",
5994 &Ledger::new(),
5995 &[],
5996 1,
5997 3,
5998 &[],
5999 );
6000 assert!(!empty.contains('{'), "{empty}");
6001 assert!(
6002 empty.contains("`origin/main`"),
6003 "the reviewer was told to read a different ref than spar compares:\n{empty}"
6004 );
6005 assert!(empty.contains("#42") && empty.contains("Retry a 429"));
6006
6007 let mut ledger = ledger_with("refuted point", "a.rs");
6008 ledger.extend(ledger_with("fixed point", "b.rs"));
6009 for entry in ledger.values_mut() {
6010 if entry.title == "fixed point" {
6011 entry.outcome = Settled::Fixed;
6012 entry.round = 2;
6013 }
6014 }
6015 let full = review_prompt(
6016 "origin/main",
6017 42,
6018 "Retry a 429",
6019 "",
6020 &ledger,
6021 &[],
6022 3,
6023 3,
6024 &[],
6025 );
6026 assert!(!full.contains('{'), "{full}");
6027 assert!(full.contains("fixed point") && full.contains("refuted point"));
6028 assert!(full.contains("last round"), "{full}");
6029 }
6030
6031 #[test]
6040 fn every_pass_that_judges_the_branch_is_given_the_issue_and_the_description() {
6041 let context = context_block(
6042 42,
6043 "https://github.com/example/project/issues/42",
6044 "Honour Retry-After and cap at five attempts.",
6045 "## What changed\n\nBounded the loop. Ran the 429 test.",
6046 );
6047 assert!(context.contains("Honour Retry-After"), "{context}");
6048 assert!(context.contains("issues/42"), "{context}");
6049 assert!(
6050 context.contains("followed by any comments on it since"),
6051 "{context}"
6052 );
6053 assert!(context.contains("the later one wins"), "{context}");
6054 assert!(context.contains("Ran the 429 test"), "{context}");
6055 assert!(
6056 context.contains("claim to check"),
6057 "the description has to be labelled as the author's own account:\n{context}"
6058 );
6059
6060 let review = review_prompt(
6061 "origin/main",
6062 42,
6063 "Retry on 429",
6064 &context,
6065 &Ledger::new(),
6066 &[],
6067 1,
6068 3,
6069 &[],
6070 );
6071 let close = close_prompt(
6072 "origin/main",
6073 42,
6074 "Retry on 429",
6075 &context,
6076 "9f8e7d6",
6077 Some(&[]),
6078 &Ledger::new(),
6079 &[],
6080 1,
6081 &[],
6082 );
6083 let respond = respond_prompt(42, &context, &[], &[]);
6084 for prompt in [review, close, respond] {
6085 assert!(prompt.contains("Honour Retry-After"), "{prompt}");
6086 assert!(prompt.contains("Ran the 429 test"), "{prompt}");
6087 assert!(!prompt.contains('{'), "{prompt}");
6088 }
6089 }
6090
6091 #[test]
6098 fn the_author_is_offered_the_non_blocking_findings_it_never_saw() {
6099 let findings = vec![
6100 finding(
6101 "blocking",
6102 "Retry loop spins",
6103 "confirmed",
6104 "src/net.rs:88",
6105 true,
6106 ),
6107 finding(
6108 "non-blocking",
6109 "Timeout is hard coded",
6110 "thirty seconds, in one place",
6111 "src/net.rs:44",
6112 true,
6113 ),
6114 finding(
6115 "nit",
6116 "Log line says retrying",
6117 "wording",
6118 "src/net.rs:102",
6119 true,
6120 ),
6121 finding(
6122 "non-blocking",
6123 "Out of scope thing",
6124 "predates the branch",
6125 "src/cache.rs",
6126 false,
6127 ),
6128 ];
6129
6130 let optional = optional_findings(&findings);
6131 assert_eq!(1, optional.len(), "{optional:?}");
6132 assert_eq!("Timeout is hard coded", optional[0].title);
6133
6134 let prompt = respond_prompt(42, "", &[findings[0].clone()], &findings);
6135 assert!(prompt.contains("Timeout is hard coded"), "{prompt}");
6136 assert!(prompt.contains("will not hold the merge"), "{prompt}");
6137 assert!(
6138 !prompt.contains("Log line says retrying"),
6139 "a nit is dropped, not offered:\n{prompt}"
6140 );
6141 assert!(
6142 !prompt.contains("Out of scope thing"),
6143 "an out of scope point is filed, not offered:\n{prompt}"
6144 );
6145 assert!(!prompt.contains('{'), "{prompt}");
6146
6147 let none = optional_block(&[findings[0].clone()]);
6149 assert_eq!("", none);
6150 }
6151
6152 #[test]
6154 fn the_review_prompt_says_what_happens_to_each_severity() {
6155 assert!(
6156 REVIEW_PROMPT.contains("offered to the author"),
6157 "{REVIEW_PROMPT}"
6158 );
6159 assert!(REVIEW_PROMPT.contains("nit: dropped"), "{REVIEW_PROMPT}");
6160 }
6161
6162 #[test]
6165 fn a_pull_request_with_no_issue_behind_it_carries_no_empty_headings() {
6166 assert_eq!("", context_block(7, "", "", ""));
6167 let only_description = context_block(7, "", "", "What it does.");
6168 assert!(!only_description.contains("as filed"), "{only_description}");
6169 assert!(only_description.contains("What it does."));
6170 }
6171
6172 #[test]
6173 fn a_resumed_open_finding_reaches_review_and_closing_prompts() {
6174 let open = vec![finding(
6175 "blocking",
6176 "Retry bypasses the limit",
6177 "reproduced with max_attempts set to one",
6178 "src/net.rs:88",
6179 true,
6180 )];
6181 let review = review_prompt(
6182 "origin/main",
6183 42,
6184 "Retry a 429",
6185 "",
6186 &Ledger::new(),
6187 &open,
6188 2,
6189 3,
6190 &[],
6191 );
6192 let close = close_prompt(
6193 "origin/main",
6194 42,
6195 "Retry a 429",
6196 "",
6197 "9f8e7d6",
6198 Some(&[]),
6199 &Ledger::new(),
6200 &open,
6201 2,
6202 &[],
6203 );
6204 for prompt in [review, close] {
6205 assert!(prompt.contains("Retry bypasses the limit"), "{prompt}");
6206 assert!(prompt.contains("src/net.rs:88"), "{prompt}");
6207 assert!(
6208 prompt.contains("reproduced with max_attempts set to one"),
6209 "{prompt}"
6210 );
6211 assert!(!prompt.contains('{'), "{prompt}");
6212 }
6213 }
6214
6215 #[test]
6219 fn a_minor_defect_has_a_severity_that_is_not_blocking() {
6220 assert!(
6221 REVIEW_PROMPT.contains("A minor defect belongs\n here as much as an improvement does")
6222 );
6223 for text in [
6228 schema::review().to_string(),
6229 crate::review_only::review_only_prompt().to_string(),
6230 ] {
6231 assert!(text.contains("as much as an improvement does"), "{text}");
6232 assert!(!text.contains("a genuine improvement"), "{text}");
6233 }
6234 }
6235
6236 #[test]
6239 fn doubt_resolves_away_from_the_field_that_gates() {
6240 for text in [REVIEW_PROMPT.to_string(), schema::review().to_string()] {
6241 assert!(text.contains("say your piece in the finding and label it non-blocking"));
6242 assert!(!text.contains("leave in_scope true"));
6243 }
6244 }
6245
6246 #[test]
6249 fn both_edit_prompts_ask_for_the_smallest_change_that_answers_the_point() {
6250 for prompt in [FIX_PROMPT, RESPOND_PROMPT] {
6251 assert!(
6252 prompt.contains("The smallest change that answers it is"),
6253 "{prompt}"
6254 );
6255 }
6256 assert!(RESPOND_PROMPT.contains("bigger than the\n problem it names"));
6257 }
6258
6259 #[test]
6260 fn a_fixed_disposition_must_explain_what_changed() {
6261 let flat = RESPOND_PROMPT
6262 .split_whitespace()
6263 .collect::<Vec<_>>()
6264 .join(" ");
6265 assert!(
6266 flat.contains("For fixed, say what changed and how it answers the point"),
6267 "{RESPOND_PROMPT}"
6268 );
6269 }
6270
6271 #[test]
6272 fn an_empty_fix_reason_still_renders_as_a_claim_to_check() {
6273 let mut ledger = ledger_with("Unchecked error", "src/net.rs");
6274 for entry in ledger.values_mut() {
6275 entry.outcome = Settled::Fixed;
6276 entry.reasoning.clear();
6277 }
6278
6279 let lines = fixed_lines(&ledger, 0);
6280
6281 assert_eq!(1, lines.len());
6282 assert!(lines[0].contains("a committed change claims to address this point"));
6283 assert!(!lines[0].contains("The author said"));
6284 }
6285
6286 #[test]
6291 fn the_implementor_is_given_the_link_and_the_body() {
6292 let prompt = implement_prompt(
6293 42,
6294 "Retry a 429",
6295 "https://github.com/o/r/issues/42",
6296 "A rate limited response was treated as fatal.",
6297 &[],
6298 );
6299 assert!(
6300 prompt.contains("https://github.com/o/r/issues/42"),
6301 "{prompt}"
6302 );
6303 assert!(
6304 prompt.contains("A rate limited response was treated as fatal."),
6305 "{prompt}"
6306 );
6307 assert!(prompt.contains("#42"), "{prompt}");
6308 assert!(prompt.contains("Retry a 429"), "{prompt}");
6309 assert!(!prompt.contains('{'), "{prompt}");
6311 }
6312
6313 #[test]
6319 fn the_prompt_says_a_later_comment_can_change_what_was_asked() {
6320 let prompt = implement_prompt(1, "t", "u", "b", &[]);
6321 let lower = prompt
6323 .split_whitespace()
6324 .collect::<Vec<_>>()
6325 .join(" ")
6326 .to_lowercase();
6327 assert!(lower.contains("followed by any comments"), "{prompt}");
6328 assert!(lower.contains("the later one wins"), "{prompt}");
6329 assert!(lower.contains("a comment from spar"), "{prompt}");
6330 assert!(!prompt.contains('{'), "{prompt}");
6331 }
6332
6333 fn worked() -> Implementation {
6335 Implementation {
6336 summary: "Retry a 429 instead of failing the run.".into(),
6337 problem: "A rate limited response was treated as fatal, so one throttled call ended \
6338 a run that had hours of work left in it."
6339 .into(),
6340 changes: vec![
6341 "`send` retries a 429 with the delay the header asks for".into(),
6342 "the retry budget is bounded, so a permanent 429 still ends".into(),
6343 ],
6344 testing: vec![
6345 "`cargo test retries_a_429`".into(),
6346 "point it at a throttled endpoint and watch it finish".into(),
6347 ],
6348 ..Implementation::default()
6349 }
6350 }
6351
6352 #[test]
6353 fn a_pr_body_is_what_it_closes_and_what_changed() {
6356 let body = pr_body(42, &worked(), None, &[], &style());
6357 assert_eq!(
6358 "Closes #42\n\n\
6359 Retry a 429 instead of failing the run.\n\n\
6360 A rate limited response was treated as fatal, so one throttled call \
6361 ended a run that had hours of work left in it.\n\n\
6362 ## What changed\n\n\
6363 - `send` retries a 429 with the delay the header asks for\n\
6364 - the retry budget is bounded, so a permanent 429 still ends\n\n\
6365 ## How to test\n\n\
6366 - `cargo test retries_a_429`\n\
6367 - point it at a throttled endpoint and watch it finish",
6368 body
6369 );
6370 }
6371
6372 #[test]
6375 fn a_body_with_nothing_to_list_carries_no_empty_headings() {
6376 let work = Implementation {
6377 summary: "Retry a 429 instead of failing the run.".into(),
6378 ..Implementation::default()
6379 };
6380 assert_eq!(
6381 "Closes #42\n\nRetry a 429 instead of failing the run.",
6382 pr_body(42, &work, None, &[], &style())
6383 );
6384 }
6385
6386 #[test]
6387 fn a_pr_body_survives_an_implementor_that_said_nothing() {
6388 assert_eq!(
6389 "Closes #7",
6390 pr_body(7, &Implementation::default(), None, &[], &style())
6391 );
6392 }
6393
6394 #[test]
6397 fn blank_list_entries_do_not_earn_a_heading() {
6398 let work = Implementation {
6399 summary: "Did a thing.".into(),
6400 changes: vec![String::new(), " ".into()],
6401 ..Implementation::default()
6402 };
6403 let body = pr_body(42, &work, None, &[], &style());
6404 assert!(!body.contains("What changed"), "{body}");
6405 }
6406
6407 #[test]
6408 fn notes_appear_only_when_there_is_something_to_note() {
6409 let mut work = worked();
6410 assert!(!pr_body(42, &work, None, &[], &style()).contains("## Notes"));
6411 work.notes = Some("The retry is not applied to streaming calls.".into());
6412 let body = pr_body(42, &work, None, &[], &style());
6413 assert!(body.contains("## Notes"), "{body}");
6414 assert!(body.contains("streaming calls"), "{body}");
6415 }
6416
6417 #[test]
6420 fn declining_posts_the_reason_and_not_the_summary() {
6421 let work = Implementation {
6422 not_worth_doing: true,
6423 reason: "Already fixed in 1.2, and the report predates it.".into(),
6424 summary: "Nothing to do.".into(),
6425 ..Implementation::default()
6426 };
6427 assert_eq!(
6428 "Already fixed in 1.2, and the report predates it.",
6429 no_pr_note(&work, &style())
6430 );
6431 }
6432
6433 #[test]
6434 fn reporting_work_and_committing_none_says_that_rather_than_the_summary() {
6435 let work = Implementation {
6436 summary: "Retry a 429 instead of failing the run.".into(),
6437 ..Implementation::default()
6438 };
6439 let note = no_pr_note(&work, &style());
6440 assert_eq!(
6441 "Nothing was committed, so there is nothing to review.",
6442 note
6443 );
6444 }
6445
6446 #[test]
6447 fn declining_without_a_reason_still_says_something() {
6448 let work = Implementation {
6449 not_worth_doing: true,
6450 ..Implementation::default()
6451 };
6452 assert!(no_pr_note(&work, &style()).contains("no reason given"));
6453 }
6454
6455 #[test]
6456 fn a_skip_comment_is_only_the_reasoning() {
6457 let item = SkippedItem {
6458 issue: 3,
6459 title: "t".into(),
6460 tracker: false,
6461 reasons: [
6462 ("claude".to_string(), "Already fixed in 1.2.".to_string()),
6463 ("codex".to_string(), "Duplicate of #2.".to_string()),
6464 ]
6465 .into_iter()
6466 .collect(),
6467 };
6468 let text = skip_comment(&item, &style());
6469 assert!(text.contains("Already fixed in 1.2."), "{text}");
6470 assert!(text.contains("Duplicate of #2."), "{text}");
6471 assert!(
6472 !text.contains("claude") && !text.contains("codex"),
6473 "{text}"
6474 );
6475 assert!(!text.to_lowercase().contains("not scheduled"), "{text}");
6476 assert!(text.lines().count() <= 3, "{text}");
6477 }
6478
6479 #[test]
6480 fn findings_for_a_model_keep_full_detail() {
6481 let long = "x".repeat(2000);
6482 let text = findings_for_prompt(&[finding("blocking", "T", &long, "a.rs", true)]);
6483 assert!(
6484 text.contains(&long),
6485 "a model needs the whole finding, only humans need brevity"
6486 );
6487 }
6488
6489 #[test]
6490 fn findings_for_a_model_are_never_empty() {
6491 assert_eq!("(none)", findings_for_prompt(&[]));
6492 }
6493
6494 const CRITERIA_BODY: &str = "## Acceptance criteria\n- [ ] Preserve column order.\n- [x] Already done.\n- [ ] Connects on a device\n";
6495
6496 fn status(number: u32, mark: Mark, reason: &str) -> CriterionStatus {
6497 CriterionStatus {
6498 number,
6499 status: mark,
6500 reason: reason.into(),
6501 }
6502 }
6503
6504 #[test]
6505 fn criteria_are_the_unchecked_items_only() {
6506 let criteria = tracker::criteria(CRITERIA_BODY);
6507 let texts: Vec<&str> = criteria.iter().map(|c| c.text.as_str()).collect();
6508 assert_eq!(
6509 vec!["Preserve column order.", "Connects on a device"],
6510 texts
6511 );
6512 }
6513
6514 #[test]
6515 fn prompts_without_criteria_are_unchanged() {
6516 assert!(!implement_prompt(1, "t", "u", "b", &[]).contains("{criteria}"));
6517 assert!(implement_prompt(1, "t", "u", "b", &[])
6518 .contains("oldest first.\n".to_string().as_str()));
6519 let review = review_prompt("main", 1, "t", "\nctx\n", &Ledger::new(), &[], 1, 3, &[]);
6520 assert!(
6521 review.contains("#1: t\n\nctx\n\nReview thoroughly"),
6522 "{review}"
6523 );
6524 let close = close_prompt(
6525 "main",
6526 1,
6527 "t",
6528 "\nctx\n",
6529 "",
6530 None,
6531 &Ledger::new(),
6532 &[],
6533 1,
6534 &[],
6535 );
6536 assert!(
6537 close.contains("#1: t\n\nctx\n\nThis is the final"),
6538 "{close}"
6539 );
6540 let body = pr_body(1, &Implementation::default(), None, &[], &style());
6541 assert!(!body.contains("Acceptance criteria"), "{body}");
6542 }
6543
6544 #[test]
6545 fn a_criterion_is_not_read_as_template_syntax() {
6546 let criteria = tracker::criteria("- [ ] Keep the literal {number} and {url}.\n");
6547 let implement = implement_prompt(42, "t", "u", "b", &criteria);
6548 assert!(
6549 implement.contains("1. Keep the literal {number} and {url}."),
6550 "{implement}"
6551 );
6552 }
6553
6554 const EVERY_PLACEHOLDER: &str = "{thread_rule} {number} {title} {url} {criteria} {body} \
6556 {base} {context} {landed} {open} {answers} {settled} {round}";
6557
6558 #[test]
6559 fn issue_text_naming_a_placeholder_reaches_every_prompt_as_written() {
6560 let title = format!("Title {EVERY_PLACEHOLDER}");
6561 let body = format!("Body {EVERY_PLACEHOLDER}");
6562 let criterion = format!("1. Criterion {EVERY_PLACEHOLDER}\n");
6563 let context = context_block(42, "u", &body, "");
6564 let ledger = Ledger::new();
6565 let with = tracker::criteria(&format!("- [ ] Criterion {EVERY_PLACEHOLDER}\n"));
6566 assert_eq!(1, with.len(), "{with:?}");
6567
6568 for criteria in [with, Vec::new()] {
6569 let implement = implement_prompt(42, &title, "u", &body, &criteria);
6570 let review = review_prompt("main", 42, &title, &context, &ledger, &[], 3, 3, &criteria);
6573 let close = close_prompt(
6574 "main",
6575 42,
6576 &title,
6577 &context,
6578 "abc1234",
6579 Some(&[]),
6580 &ledger,
6581 &[],
6582 3,
6583 &criteria,
6584 );
6585 for prompt in [&implement, &review, &close] {
6586 assert!(prompt.contains(&title), "{prompt}");
6587 assert!(prompt.contains(&body), "{prompt}");
6588 assert_eq!(
6589 !criteria.is_empty(),
6590 prompt.contains(&criterion),
6591 "{prompt}"
6592 );
6593 let expected = if criteria.is_empty() { 2 } else { 3 };
6594 assert_eq!(
6595 expected,
6596 prompt.matches(EVERY_PLACEHOLDER).count(),
6597 "{prompt}"
6598 );
6599 }
6600 }
6601 }
6602
6603 #[test]
6604 fn findings_and_context_naming_a_placeholder_reach_the_author_as_written() {
6605 let every = "{number} {context} {findings} {optional}";
6606 let context = format!("\nContext {every}\n");
6607 let findings = vec![
6608 finding(
6609 "blocking",
6610 &format!("Blocker {every}"),
6611 every,
6612 "src/a.rs",
6613 true,
6614 ),
6615 finding(
6616 "non-blocking",
6617 &format!("Optional {every}"),
6618 every,
6619 "src/b.rs",
6620 true,
6621 ),
6622 ];
6623 let prompt = respond_prompt(42, &context, &[findings[0].clone()], &findings);
6624 assert!(prompt.contains(&context), "{prompt}");
6625 assert!(prompt.contains(&format!("Blocker {every}")), "{prompt}");
6626 assert!(prompt.contains(&format!("Optional {every}")), "{prompt}");
6627 assert_eq!(5, prompt.matches(every).count(), "{prompt}");
6628 }
6629
6630 #[test]
6631 fn prompts_number_each_criterion_verbatim() {
6632 let criteria = tracker::criteria(CRITERIA_BODY);
6633 let implement = implement_prompt(1, "t", "u", "b", &criteria);
6634 let review = review_prompt("main", 1, "t", "", &Ledger::new(), &[], 1, 3, &criteria);
6635 let close = close_prompt(
6636 "main",
6637 1,
6638 "t",
6639 "",
6640 "",
6641 None,
6642 &Ledger::new(),
6643 &[],
6644 1,
6645 &criteria,
6646 );
6647 for prompt in [&implement, &review, &close] {
6648 assert!(
6649 prompt.contains("1. Preserve column order.\n2. Connects on a device\n"),
6650 "{prompt}"
6651 );
6652 assert!(!prompt.contains("Already done"), "{prompt}");
6653 }
6654 assert!(implement.contains("done, not_done, or needs_person"));
6655 assert!(review.contains("met when you checked"));
6656 assert!(close.contains("met when you checked"));
6657 }
6658
6659 #[test]
6660 fn the_pr_body_gives_the_authors_account_of_each_criterion() {
6661 let criteria = tracker::criteria(CRITERIA_BODY);
6662 let work = Implementation {
6663 summary: "Add CSV export.".into(),
6664 criteria: vec![status(1, Mark::Met, "the writer keeps the header order")],
6665 ..Implementation::default()
6666 };
6667 let body = pr_body(7, &work, None, &criteria, &style());
6668 assert!(
6669 body.contains("## Acceptance criteria\n\nThe author's account."),
6670 "{body}"
6671 );
6672 assert!(
6673 body.contains("- Done: Preserve column order. The writer keeps the header order."),
6674 "{body}"
6675 );
6676 assert!(
6677 body.contains("- No status given: Connects on a device."),
6678 "{body}"
6679 );
6680 }
6681
6682 #[test]
6683 fn only_a_criterion_ruled_met_by_every_answer_is_met() {
6684 let criteria = tracker::criteria(CRITERIA_BODY);
6685 let rulings = vec![
6686 status(1, Mark::Met, "checked"),
6687 status(2, Mark::Met, "looks right"),
6688 status(2, Mark::NeedsPerson, "needs a device"),
6689 status(9, Mark::Met, "no such criterion"),
6690 ];
6691 let ruled = ruled(&criteria, &rulings);
6692 assert_eq!(2, ruled.len());
6693 assert_eq!(Some(Mark::Met), ruled[0].status);
6694 assert_eq!("- [ ] Preserve column order.", ruled[0].raw);
6695 assert_eq!(Some(Mark::NeedsPerson), ruled[1].status);
6696 assert_eq!(None, super::ruled(&criteria, &[])[0].status);
6697 }
6698
6699 #[test]
6700 fn the_authors_claim_never_reaches_a_ruling() {
6701 let criteria = tracker::criteria(CRITERIA_BODY);
6702 let ruled = ruled(&criteria, &[]);
6704 assert!(ruled.iter().all(|c| c.status != Some(Mark::Met)));
6705 }
6706
6707 fn approved_with(criteria: Vec<Ruled>, ticks: bool) -> Option<String> {
6708 let mut state = IssueRun::new(1, "t");
6709 state.criteria = criteria;
6710 state.ticks_criteria = ticks;
6711 outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style())
6712 }
6713
6714 fn ruled_one(text: &str, status: Option<Mark>, reason: &str) -> Ruled {
6715 Ruled {
6716 raw: format!("- [ ] {text}"),
6717 line: 1,
6718 text: text.into(),
6719 status,
6720 reason: reason.into(),
6721 }
6722 }
6723
6724 #[test]
6725 fn a_clean_approval_names_a_criterion_not_ruled_met() {
6726 let text = approved_with(
6727 vec![
6728 ruled_one("Preserve column order.", Some(Mark::Met), "checked"),
6729 ruled_one(
6730 "Connects on a device",
6731 Some(Mark::NeedsPerson),
6732 "needs a phone",
6733 ),
6734 ],
6735 true,
6736 )
6737 .expect("a comment");
6738 assert!(
6739 text.contains("Needs a person: Connects on a device. Needs a phone."),
6740 "{text}"
6741 );
6742 assert!(!text.contains("Preserve column order"), "{text}");
6743 assert!(text.contains("left unchecked"), "{text}");
6744 }
6745
6746 #[test]
6747 fn a_clean_approval_with_every_criterion_met_stays_silent() {
6748 let text = approved_with(
6749 vec![ruled_one(
6750 "Preserve column order.",
6751 Some(Mark::Met),
6752 "checked",
6753 )],
6754 true,
6755 );
6756 assert_eq!(None, text);
6757 }
6758
6759 #[test]
6760 fn without_ticking_the_comment_lists_every_ruling() {
6761 let text = approved_with(
6762 vec![
6763 ruled_one("Preserve column order.", Some(Mark::Met), "checked"),
6764 ruled_one("Connects on a device", None, ""),
6765 ],
6766 false,
6767 )
6768 .expect("a comment");
6769 assert!(
6770 text.contains("- Met: Preserve column order. Checked."),
6771 "{text}"
6772 );
6773 assert!(
6774 text.contains("- Not ruled on: Connects on a device."),
6775 "{text}"
6776 );
6777 assert!(text.contains("Nothing was ticked"), "{text}");
6778 }
6779}
6780
6781#[cfg(test)]
6782mod outcome_tests {
6783 use super::*;
6784 use crate::model::{Dispute, Severity};
6785
6786 fn style() -> Style {
6787 Style::default()
6788 }
6789
6790 fn state_with(disputes: Vec<(&str, &str)>, filed: Vec<&str>) -> IssueRun {
6791 let mut s = IssueRun::new(482, "t");
6792 s.disputes = disputes
6793 .into_iter()
6794 .map(|(title, reasoning)| Dispute {
6795 title: title.into(),
6796 file: String::new(),
6797 reasoning: reasoning.into(),
6798 })
6799 .collect();
6800 s.filed = filed.into_iter().map(String::from).collect();
6801 s
6802 }
6803
6804 fn finding(title: &str, file: &str) -> Finding {
6805 Finding {
6806 severity: Severity::Blocking,
6807 title: title.into(),
6808 detail: "d".into(),
6809 file: file.into(),
6810 in_scope: true,
6811 ..Default::default()
6812 }
6813 }
6814
6815 fn graded(severity: Severity, title: &str, file: &str, in_scope: bool) -> Finding {
6816 Finding {
6817 severity,
6818 in_scope,
6819 ..finding(title, file)
6820 }
6821 }
6822
6823 #[test]
6824 fn outcome_mode_routes_final_results_to_the_configured_sink() {
6825 assert_eq!(OutcomeSink::PullRequest, outcome_sink(PrComments::Outcome));
6826 assert_eq!(OutcomeSink::PullRequest, outcome_sink(PrComments::Rounds));
6827 assert_eq!(OutcomeSink::Terminal, outcome_sink(PrComments::None));
6828 }
6829
6830 #[test]
6833 fn a_clean_approval_says_nothing() {
6834 let state = state_with(vec![], vec![]);
6835 assert!(outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).is_none());
6836 }
6837
6838 #[test]
6843 fn a_downgraded_finding_still_reaches_the_pull_request() {
6844 let mut state = state_with(vec![], vec![]);
6845 let kept = graded(
6846 Severity::NonBlocking,
6847 "Timeout is not configurable",
6848 "n.rs",
6849 true,
6850 );
6851 record_nonblocking_outcome(&mut state, &kept, None);
6852
6853 assert_eq!(1, state.noted.len());
6856
6857 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
6858 assert!(text.contains("Noted, not blocking"), "{text}");
6859 assert!(
6860 text.contains("Timeout is not configurable (n.rs)"),
6861 "{text}"
6862 );
6863 }
6864
6865 #[test]
6868 fn a_point_that_was_filed_is_not_also_noted() {
6869 let mut state = state_with(vec![], vec![]);
6870 let finding = graded(Severity::NonBlocking, "Timeout", "n.rs", true);
6871 record_nonblocking_outcome(&mut state, &finding, None);
6872 record_nonblocking_outcome(
6873 &mut state,
6874 &finding,
6875 Some(&Followup::Recorded("https://example.invalid/9".into())),
6876 );
6877 assert!(state.noted.is_empty());
6878 assert_eq!(vec!["https://example.invalid/9"], state.filed);
6879 }
6880
6881 #[test]
6882 fn filing_a_moved_point_removes_its_earlier_note() {
6883 let mut state = state_with(vec![], vec![]);
6884 let earlier = graded(Severity::NonBlocking, "Timeout", "src/net.rs:10", true);
6885 let moved = graded(Severity::NonBlocking, "Timeout", "src/net.rs:12", false);
6886 record_nonblocking_outcome(&mut state, &earlier, None);
6887
6888 record_nonblocking_outcome(
6889 &mut state,
6890 &moved,
6891 Some(&Followup::Recorded("https://example.invalid/10".into())),
6892 );
6893
6894 assert!(state.noted.is_empty());
6895 assert_eq!(vec!["https://example.invalid/10"], state.filed);
6896 }
6897
6898 #[test]
6899 fn an_unrecorded_nonblocking_followup_remains_noted() {
6900 let finding = graded(Severity::NonBlocking, "Timeout", "n.rs", true);
6901 for outcome in [
6902 Followup::Covered("https://example.invalid/closed".into()),
6903 Followup::Dropped("follow-ups are off"),
6904 Followup::Failed,
6905 ] {
6906 let mut state = state_with(vec![], vec![]);
6907 record_nonblocking_outcome(&mut state, &finding, Some(&outcome));
6908 assert_eq!(1, state.noted.len(), "{outcome:?}");
6909 assert!(state.filed.is_empty(), "{outcome:?}");
6910 }
6911 }
6912
6913 #[test]
6915 fn a_point_noted_twice_is_listed_once() {
6916 let mut state = state_with(vec![], vec![]);
6917 let raised = graded(
6918 Severity::NonBlocking,
6919 "Timeout is not configurable",
6920 "n.rs",
6921 true,
6922 );
6923 let reworded = graded(
6924 Severity::NonBlocking,
6925 "timeout is not configurable!",
6926 "n.rs",
6927 true,
6928 );
6929 record_nonblocking_outcome(&mut state, &raised, None);
6930 record_nonblocking_outcome(&mut state, &reworded, None);
6931 assert_eq!(1, state.noted.len());
6932 }
6933
6934 #[test]
6935 fn same_title_notes_in_different_files_are_both_kept() {
6936 let mut state = state_with(vec![], vec![]);
6937 for file in ["src/a.rs", "src/b.rs"] {
6938 let finding = graded(Severity::NonBlocking, "Unchecked error", file, true);
6939 record_nonblocking_outcome(&mut state, &finding, None);
6940 }
6941 assert_eq!(2, state.noted.len());
6942 }
6943
6944 #[test]
6945 fn same_title_notes_at_two_sites_in_one_review_are_both_kept() {
6946 let findings = vec![
6947 graded(
6948 Severity::NonBlocking,
6949 "Unchecked error",
6950 "src/a.rs:10",
6951 true,
6952 ),
6953 graded(
6954 Severity::NonBlocking,
6955 "Unchecked error",
6956 "src/a.rs:200",
6957 true,
6958 ),
6959 ];
6960 let mut state = state_with(vec![], vec![]);
6961
6962 for finding in &findings {
6963 record_nonblocking_outcome_with_match(
6964 &mut state,
6965 finding,
6966 None,
6967 unique_stable_finding(&findings, finding),
6968 );
6969 }
6970
6971 assert_eq!(2, state.noted.len());
6972 }
6973
6974 #[test]
6975 fn settling_one_of_two_same_title_notes_keeps_the_other() {
6976 let first = graded(
6977 Severity::NonBlocking,
6978 "Unchecked error",
6979 "src/a.rs:10",
6980 true,
6981 );
6982 let second = graded(
6983 Severity::NonBlocking,
6984 "Unchecked error",
6985 "src/a.rs:200",
6986 true,
6987 );
6988 let mut state = state_with(vec![], vec![]);
6989 remember_noted(&mut state, &first, false);
6990 remember_noted(&mut state, &second, false);
6991
6992 forget_noted(&mut state, &first, false);
6993
6994 assert_eq!(1, state.noted.len());
6995 assert_eq!("src/a.rs:200", state.noted[0].file);
6996 }
6997
6998 #[test]
6999 fn same_title_disputes_at_two_sites_are_both_kept() {
7000 let mut state = state_with(vec![], vec![]);
7001 for file in ["src/a.rs:10", "src/a.rs:200"] {
7002 remember_dispute(
7003 &mut state,
7004 Dispute {
7005 title: "Unchecked error".into(),
7006 file: file.into(),
7007 reasoning: "the caller handles it".into(),
7008 },
7009 false,
7010 );
7011 }
7012
7013 assert_eq!(2, state.disputes.len());
7014 }
7015
7016 #[test]
7017 fn a_later_nonblocking_verdict_replaces_a_prior_dispute() {
7018 let mut state = state_with(vec![], vec![]);
7019 let finding = graded(Severity::NonBlocking, "Unchecked error", "src/a.rs", true);
7020 remember_dispute(
7021 &mut state,
7022 Dispute {
7023 title: finding.title.clone(),
7024 file: finding.file.clone(),
7025 reasoning: "the caller handles it".into(),
7026 },
7027 true,
7028 );
7029
7030 record_nonblocking_outcome(&mut state, &finding, None);
7031
7032 assert!(state.disputes.is_empty());
7033 assert_eq!(1, state.noted.len());
7034 }
7035
7036 #[test]
7037 fn a_note_moving_lines_in_the_same_file_is_updated() {
7038 let mut state = state_with(vec![], vec![]);
7039 let first = graded(
7040 Severity::NonBlocking,
7041 "Unchecked error",
7042 "src/a.rs:12",
7043 true,
7044 );
7045 let moved = graded(
7046 Severity::NonBlocking,
7047 "Unchecked error",
7048 "src/a.rs:19",
7049 true,
7050 );
7051 record_nonblocking_outcome(&mut state, &first, None);
7052 record_nonblocking_outcome(&mut state, &moved, None);
7053 assert_eq!(1, state.noted.len());
7054 assert_eq!("src/a.rs:19", state.noted[0].file);
7055 }
7056
7057 #[test]
7058 fn a_settled_point_removes_its_stale_note() {
7059 for outcome in [Settled::Fixed, Settled::Refuted, Settled::Filed] {
7060 let noted = graded(Severity::NonBlocking, "Unchecked error", "src/a.rs", true);
7061 let mut state = state_with(vec![], vec![]);
7062 record_nonblocking_outcome(&mut state, ¬ed, None);
7063 forget_noted(&mut state, ¬ed, true);
7064 assert!(state.noted.is_empty(), "{outcome}");
7065 }
7066 }
7067
7068 #[test]
7069 fn settling_a_moved_point_removes_its_old_dispute() {
7070 let old = graded(Severity::Blocking, "Unchecked error", "src/a.rs:10", true);
7071 let moved = graded(Severity::Blocking, "Unchecked error", "src/a.rs:12", true);
7072 let mut state = state_with(vec![], vec![]);
7073 remember_dispute(
7074 &mut state,
7075 Dispute {
7076 title: old.title,
7077 file: old.file,
7078 reasoning: "the caller handles it".into(),
7079 },
7080 true,
7081 );
7082
7083 forget_dispute(&mut state, &moved, true);
7084
7085 assert!(state.disputes.is_empty());
7086 }
7087
7088 #[test]
7091 fn a_deadlocked_point_is_not_also_noted() {
7092 let mut state = state_with(vec![], vec![]);
7093 let noted = graded(Severity::NonBlocking, "Unbounded loop", "x.rs", true);
7094 record_nonblocking_outcome(&mut state, ¬ed, None);
7095 let points = vec![finding("Unbounded loop", "x.rs")];
7096 let text = outcome_comment(
7097 &state,
7098 &Ledger::new(),
7099 &Ending::Deadlocked(&points),
7100 &style(),
7101 )
7102 .unwrap();
7103 assert!(!text.contains("Noted, not blocking"), "{text}");
7104 }
7105
7106 #[test]
7107 fn clipping_a_rendered_title_does_not_break_duplicate_suppression() {
7108 let mut compact = style();
7109 compact.max_title_chars = 5;
7110 let mut state = state_with(
7111 vec![("abcdefghij", "the caller already handles it")],
7112 vec![],
7113 );
7114 state
7115 .noted
7116 .push(graded(Severity::NonBlocking, "abcdefghij", "x.rs", true));
7117 state.disputes[0].file = "x.rs".into();
7118 let points = vec![finding("abcdefghij", "x.rs")];
7119
7120 let text = outcome_comment(
7121 &state,
7122 &Ledger::new(),
7123 &Ending::Unresolved(&points),
7124 &compact,
7125 )
7126 .unwrap();
7127
7128 assert!(!text.contains("Raised and refuted"), "{text}");
7129 assert!(!text.contains("Noted, not blocking"), "{text}");
7130 }
7131
7132 #[test]
7133 fn an_approval_that_filed_follow_ups_links_them() {
7134 let state = state_with(
7135 vec![],
7136 vec![
7137 "https://github.com/you/thing/issues/485",
7138 "https://github.com/you/thing/issues/486",
7139 ],
7140 );
7141 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
7142 assert!(text.contains("Filed separately: #485, #486"), "{text}");
7143 }
7144
7145 #[test]
7149 fn a_run_that_changed_nothing_does_not_claim_there_is_something_to_read() {
7150 let state = state_with(vec![], vec![]);
7151 let text = outcome_comment(&state, &Ledger::new(), &Ending::Unchanged, &style()).unwrap();
7152 assert!(text.contains("changed nothing"), "{text}");
7153 assert!(!text.contains("was pushed"), "{text}");
7154 }
7155
7156 #[test]
7160 fn an_unresolved_close_names_what_is_still_wrong() {
7161 let state = state_with(vec![], vec![]);
7162 let left = vec![Finding {
7163 detail: "The guard sits after the early return.".into(),
7164 ..finding("The retry fix never reaches the 429 path", "src/net.rs:88")
7165 }];
7166 let text =
7167 outcome_comment(&state, &Ledger::new(), &Ending::Unresolved(&left), &style()).unwrap();
7168 assert!(text.contains("These points are still open"), "{text}");
7169 assert!(
7170 text.contains("The retry fix never reaches the 429 path (src/net.rs:88)"),
7171 "{text}"
7172 );
7173 assert!(
7174 text.contains("The guard sits after the early return."),
7175 "{text}"
7176 );
7177 assert!(!text.contains("has not been reviewed"), "{text}");
7180 }
7181
7182 #[test]
7186 fn running_out_of_rounds_says_what_that_means_for_the_reader() {
7187 let state = state_with(vec![], vec![]);
7188 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
7189 assert!(text.contains("has not been reviewed"), "{text}");
7190 assert!(
7191 !text.to_lowercase().contains("round 3"),
7192 "no round numbers: {text}"
7193 );
7194 assert!(!text.to_lowercase().contains("convergence"), "{text}");
7195 }
7196
7197 #[test]
7198 fn a_failed_close_reports_unread_fixes_and_carried_blockers() {
7199 let state = state_with(vec![], vec![]);
7200 let open = vec![Finding {
7201 detail: "the failure is still discarded".into(),
7202 ..finding("Unchecked error", "src/net.rs:88")
7203 }];
7204 let text = outcome_comment_with_unread(
7205 &state,
7206 &Ledger::new(),
7207 &Ending::OutOfRounds,
7208 &open,
7209 &style(),
7210 )
7211 .unwrap();
7212 assert!(text.contains("has not been reviewed"), "{text}");
7213 assert!(text.contains("These points were already open"), "{text}");
7214 assert!(text.contains("Unchecked error (src/net.rs:88)"), "{text}");
7215 }
7216
7217 #[test]
7218 fn a_deadlock_names_the_point_they_could_not_settle() {
7219 let state = state_with(vec![], vec![]);
7220 let points = [finding("Retry loop never terminates", "src/net.rs:88")];
7221 let text = outcome_comment(
7222 &state,
7223 &Ledger::new(),
7224 &Ending::Deadlocked(&points),
7225 &style(),
7226 )
7227 .unwrap();
7228 assert!(
7229 text.contains("Retry loop never terminates (src/net.rs:88)"),
7230 "{text}"
7231 );
7232 assert!(text.contains("could not settle"), "{text}");
7233 }
7234
7235 #[test]
7237 fn refutations_survive_because_nothing_else_carries_them() {
7238 let state = state_with(
7239 vec![(
7240 "Error is swallowed",
7241 "the caller already validates the file",
7242 )],
7243 vec![],
7244 );
7245 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
7246 assert!(text.contains("Raised and refuted:"), "{text}");
7247 assert!(
7248 text.contains("The caller already validates the file"),
7249 "{text}"
7250 );
7251 }
7252
7253 #[test]
7254 fn no_agent_names_counts_or_round_numbers_reach_the_thread() {
7255 let state = state_with(
7256 vec![("A point", "a reason")],
7257 vec!["https://github.com/you/thing/issues/485"],
7258 );
7259 let left = vec![finding("A point", "a.rs")];
7260 for ending in [
7261 Ending::Approved,
7262 Ending::OutOfRounds,
7263 Ending::Unresolved(&left),
7264 ] {
7265 let text = outcome_comment(&state, &Ledger::new(), &ending, &style()).unwrap();
7266 let lower = text.to_lowercase();
7267 for banned in ["claude", "codex", "blocking,", "nit,", " fixed."] {
7268 assert!(
7269 !lower.contains(banned),
7270 "{banned:?} leaked into the thread:\n{text}"
7271 );
7272 }
7273 for n in 1..9 {
7275 assert!(
7276 !lower.contains(&format!("round {n}")),
7277 "a round number leaked into the thread:\n{text}"
7278 );
7279 }
7280 }
7281 }
7282
7283 #[test]
7284 fn a_refutation_is_allowed_to_make_its_case() {
7287 let reasoning = "The caller validates against the schema first. \
7288 The discarded error is therefore unreachable in practice. ";
7289 let state = state_with(
7290 vec![("A point", &reasoning.repeat(6))],
7291 vec!["https://github.com/you/thing/issues/485"],
7292 );
7293 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
7294 assert!(
7295 !text.contains("..."),
7296 "nothing was cut mid thought:\n{text}"
7297 );
7298 assert!(text.len() < 4000, "{} chars", text.len());
7299 }
7300
7301 #[test]
7302 fn a_url_that_is_not_an_issue_link_is_left_alone() {
7303 assert_eq!(
7304 "#485",
7305 as_reference("https://github.com/you/thing/issues/485")
7306 );
7307 assert_eq!("note: something", as_reference("note: something"));
7308 }
7309}
7310
7311#[cfg(test)]
7312mod filed_reference_tests {
7313 use super::*;
7314
7315 #[test]
7316 fn an_issue_url_yields_its_number() {
7317 assert_eq!(
7318 Some(485),
7319 filed_issue_number("https://github.com/you/thing/issues/485")
7320 );
7321 }
7322
7323 #[test]
7326 fn a_local_note_yields_nothing() {
7327 assert_eq!(None, filed_issue_number("note: Retry is unbounded"));
7328 assert_eq!(None, filed_issue_number(""));
7329 assert_eq!(
7330 None,
7331 filed_issue_number("https://github.com/you/thing/issues/")
7332 );
7333 }
7334}
7335
7336#[cfg(test)]
7337mod followup_restraint_tests {
7338 use super::*;
7339 use crate::model::Severity;
7340
7341 fn cfg_with(followups: Followups, non_blocking: bool, nits: bool, cap: usize) -> Config {
7342 let mut cfg =
7343 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
7344 .unwrap();
7345 cfg.loop_cfg.followups = followups;
7346 cfg.loop_cfg.file_non_blocking = non_blocking;
7347 cfg.loop_cfg.file_nits = nits;
7348 cfg.loop_cfg.max_followups = cap;
7349 cfg
7350 }
7351
7352 fn finding(severity: Severity, title: &str, in_scope: bool) -> Finding {
7353 Finding {
7354 severity,
7355 title: title.into(),
7356 detail: "d".into(),
7357 file: "a.rs".into(),
7358 in_scope,
7359 ..Default::default()
7360 }
7361 }
7362
7363 #[test]
7367 fn a_non_blocking_finding_is_not_a_tracker_item_by_default() {
7368 let cfg = cfg_with(Followups::Issues, false, false, 5);
7369 assert!(!cfg.loop_cfg.file_non_blocking);
7370 assert!(!cfg.loop_cfg.file_nits);
7371 }
7372
7373 #[test]
7374 fn follow_ups_stay_off_the_tracker_by_default() {
7375 let cfg =
7376 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
7377 .unwrap();
7378 assert_eq!(
7379 Followups::Local,
7380 cfg.loop_cfg.followups,
7381 "the tracker is somebody's queue; the default must not write to it"
7382 );
7383 assert_eq!(5, cfg.loop_cfg.max_followups);
7384 }
7385
7386 #[test]
7388 fn only_out_of_scope_defects_qualify_at_the_defaults() {
7389 let cfg = cfg_with(Followups::Issues, false, false, 5);
7390 let qualifies = |f: &Finding| match f.severity {
7391 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking && f.in_scope,
7392 Severity::Nit => cfg.loop_cfg.file_nits && f.in_scope,
7393 Severity::Blocking => false,
7394 } || !f.in_scope;
7395
7396 assert!(qualifies(&finding(
7397 Severity::Blocking,
7398 "pre-existing",
7399 false
7400 )));
7401 assert!(!qualifies(&finding(
7402 Severity::NonBlocking,
7403 "improvement",
7404 true
7405 )));
7406 assert!(!qualifies(&finding(Severity::Nit, "taste", true)));
7407 assert!(!qualifies(&finding(
7408 Severity::Blocking,
7409 "fix it here",
7410 true
7411 )));
7412 }
7413
7414 #[test]
7415 fn opening_it_up_lets_non_blocking_findings_through_again() {
7416 let cfg = cfg_with(Followups::Issues, true, false, 5);
7417 assert!(cfg.loop_cfg.file_non_blocking);
7418 }
7419
7420 #[test]
7422 fn the_cap_is_a_real_backstop() {
7423 let cfg = cfg_with(Followups::Issues, false, false, 3);
7424 let mut state = IssueRun::new(1, "t");
7425 state.filed = (0..3).map(|n| format!("url{n}")).collect();
7426 assert!(state.filed.len() >= cfg.loop_cfg.max_followups);
7427 }
7428
7429 #[test]
7433 fn the_cap_bounds_what_one_run_can_spawn() {
7434 let cfg = cfg_with(Followups::Issues, false, false, 5);
7435 assert!(
7436 cfg.loop_cfg.max_followups <= 5,
7437 "a run that can file ten follow-ups is a branching process"
7438 );
7439 }
7440}
7441
7442#[cfg(test)]
7446mod followup_outcome_tests {
7447 use super::*;
7448
7449 const URL: &str = "https://github.com/you/thing/issues/485";
7450
7451 fn entry(recorded: Followup) -> Option<(Settled, String)> {
7452 filed_entry(&recorded, "It predates this branch.")
7453 }
7454
7455 #[test]
7459 fn a_failed_followup_settles_nothing() {
7460 assert_eq!(None, entry(Followup::Failed));
7461 }
7462
7463 #[test]
7464 fn an_uncertain_external_write_blocks_later_issue_followups_for_this_run() {
7465 let mut state = IssueRun::new(1, "review");
7466 let uncertain = SparError::uncertain_write("the result could not be verified");
7467 assert_eq!(Followup::Failed, failed_followup(&mut state, &uncertain));
7468 assert!(external_followup_write_paused(Followups::Issues, &state));
7469 assert!(!external_followup_write_paused(Followups::Local, &state));
7470 assert_eq!(1, state.notes.len());
7471
7472 assert_eq!(Followup::Failed, failed_followup(&mut state, &uncertain));
7473 assert_eq!(1, state.notes.len(), "the recovery note was duplicated");
7474
7475 let mut ordinary = IssueRun::new(2, "review");
7476 let error = SparError::new("permission denied");
7477 assert_eq!(Followup::Failed, failed_followup(&mut ordinary, &error));
7478 assert!(!external_followup_write_paused(
7479 Followups::Issues,
7480 &ordinary
7481 ));
7482 }
7483
7484 #[test]
7485 fn a_recorded_followup_is_filed_and_says_where() {
7486 let (outcome, reasoning) = entry(Followup::Recorded(URL.into())).unwrap();
7487 assert_eq!(Settled::Filed, outcome);
7488 assert!(
7489 reasoning.contains("It predates this branch."),
7490 "{reasoning}"
7491 );
7492 assert!(reasoning.contains("#485"), "{reasoning}");
7493 }
7494
7495 #[test]
7504 fn a_finding_that_matches_a_closed_issue_is_filed_again_with_the_reference() {
7505 let body = regression_body("The retry loop spins on 429.", 485);
7506 assert!(body.contains("The retry loop spins on 429."), "{body}");
7507 assert!(body.contains("#485"), "{body}");
7508 assert!(body.contains("closed"), "{body}");
7509
7510 let recorded = Followup::from(Filed::Opened(9, URL.into()));
7512 assert_eq!(Followup::Recorded(URL.into()), recorded);
7513 assert_eq!(Some(URL), recorded.url());
7514 }
7515
7516 #[test]
7519 fn an_open_issue_that_already_covers_the_point_is_still_a_reference() {
7520 for filed in [
7521 Filed::Opened(9, URL.into()),
7522 Filed::AddedTo(9, URL.into()),
7523 Filed::Covered(9, URL.into()),
7524 ] {
7525 assert_eq!(Some(URL), Followup::from(filed).url());
7526 }
7527 }
7528
7529 #[test]
7533 fn a_dropped_followup_is_settled_but_never_reported_as_filed() {
7534 let (outcome, reasoning) = entry(Followup::Dropped("follow-ups are off")).unwrap();
7535 assert_eq!(Settled::Dropped, outcome);
7536 assert!(reasoning.contains("follow-ups are off"), "{reasoning}");
7537 assert!(reasoning.contains("Not filed"), "{reasoning}");
7538 }
7539
7540 fn ledger_of(outcome: Settled, reasoning: &str) -> Ledger {
7541 let mut ledger = Ledger::new();
7542 ledger.insert(
7543 finding_key("A pre-existing leak", "src/x.rs"),
7544 LedgerEntry {
7545 title: "A pre-existing leak".into(),
7546 file: "src/x.rs".into(),
7547 reasoning: reasoning.into(),
7548 round: 1,
7549 reraised: 0,
7550 outcome,
7551 },
7552 );
7553 ledger
7554 }
7555
7556 #[test]
7560 fn the_settled_block_tells_a_filed_point_from_a_dropped_one() {
7561 let filed = settled_block(&ledger_of(Settled::Filed, "Tracked in #9."));
7562 assert!(filed.contains("out of scope here, and filed"), "{filed}");
7563
7564 let dropped = settled_block(&ledger_of(Settled::Dropped, "Not filed anywhere: off."));
7565 assert!(
7566 dropped.contains("out of scope here, and not filed"),
7567 "{dropped}"
7568 );
7569 assert!(dropped.contains("A pre-existing leak"), "{dropped}");
7570 }
7571
7572 #[test]
7575 fn a_deadlocked_point_that_was_never_filed_does_not_claim_to_be() {
7576 let points = [Finding {
7577 severity: Severity::Blocking,
7578 title: "A pre-existing leak".into(),
7579 detail: "d".into(),
7580 file: "src/x.rs".into(),
7581 in_scope: false,
7582 ..Default::default()
7583 }];
7584 let text = outcome_comment(
7585 &IssueRun::new(1, "t"),
7586 &ledger_of(Settled::Dropped, "Not filed anywhere: follow-ups are off."),
7587 &Ending::Deadlocked(&points),
7588 &Style::default(),
7589 )
7590 .unwrap();
7591 assert!(text.contains("not filed"), "{text}");
7592 assert!(!text.contains("Filed as out of scope"), "{text}");
7593 }
7594}
7595
7596#[cfg(test)]
7597mod issue_report_tests {
7598 use super::*;
7599 use crate::model::Severity;
7600
7601 fn reported() -> Finding {
7604 Finding {
7605 severity: Severity::Blocking,
7606 title: "sendPaymentAsync bypasses drain mode and spending limits".into(),
7607 detail: "The async path skips every admission check payInvoice applies.".into(),
7608 file: "src/node.ts:412".into(),
7609 in_scope: false,
7610 problem: Some(
7611 "`BeignetNode.sendPaymentAsync()` submits a payment directly to the Lightning \
7612 engine without applying the safeguards used by `payInvoice()`.\n\nThe async path \
7613 does not:\n\n- call `_checkDraining()`\n- call `_checkSpendLimit()`"
7614 .into(),
7615 ),
7616 reproduction: Some(
7617 "1. Create a `BeignetNode` with `dailySpendLimitSats: 1`.\n2. Enable drain mode.\n\
7618 3. Submit a 1,000 sat invoice.\n\nActual result:\n\n- The engine is called.\n\
7619 - `spentSats` remains 0."
7620 .into(),
7621 ),
7622 impact: Some(
7623 "An authorized client can submit async payments up to the available outbound \
7624 liquidity despite the configured limits."
7625 .into(),
7626 ),
7627 expected: Some(
7628 "- Reject new payments while draining.\n- Enforce the per-payment limit before \
7629 submission.\n- Cover both paths with regression tests.\n\nThis predates the \
7630 current branch."
7631 .into(),
7632 ),
7633 }
7634 }
7635
7636 #[test]
7637 fn a_reported_finding_becomes_a_bug_report() {
7638 let body = issue_report(&reported());
7639 for heading in [
7640 "## Problem",
7641 "## Reproduction",
7642 "## Impact",
7643 "## Expected behavior",
7644 ] {
7645 assert!(body.contains(heading), "missing {heading}:\n{body}");
7646 }
7647 let at = |h: &str| body.find(h).unwrap();
7649 assert!(at("## Problem") < at("## Reproduction"));
7650 assert!(at("## Reproduction") < at("## Impact"));
7651 assert!(at("## Impact") < at("## Expected behavior"));
7652 }
7653
7654 #[test]
7655 fn the_substance_survives_the_outbound_gates() {
7656 let repo_style = Style::default();
7657 let body = crate::style::issue_body(&issue_report(&reported()), &repo_style);
7658 for kept in [
7659 "_checkDraining()",
7660 "Actual result:",
7661 "outbound liquidity",
7662 "regression tests",
7663 "predates the current branch",
7664 ] {
7665 assert!(body.contains(kept), "the gate ate {kept:?}:\n{body}");
7666 }
7667 assert!(!body.contains("..."), "something was cut:\n{body}");
7668 }
7669
7670 #[test]
7673 fn an_ordinary_finding_is_still_just_its_detail() {
7674 let plain = Finding {
7675 severity: Severity::NonBlocking,
7676 title: "Name is vague".into(),
7677 detail: "The variable could say what it holds.".into(),
7678 file: "a.rs".into(),
7679 in_scope: true,
7680 ..Default::default()
7681 };
7682 assert_eq!(
7683 "The variable could say what it holds.",
7684 issue_report(&plain)
7685 );
7686 }
7687
7688 #[test]
7691 fn only_the_sections_that_were_written_appear() {
7692 let partial = Finding {
7693 problem: Some("The guard is inverted.".into()),
7694 expected: Some("It should reject rather than accept.".into()),
7695 ..reported()
7696 };
7697 let partial = Finding {
7698 reproduction: None,
7699 impact: None,
7700 ..partial
7701 };
7702 let body = issue_report(&partial);
7703 assert!(body.contains("## Problem") && body.contains("## Expected behavior"));
7704 assert!(!body.contains("## Reproduction"), "{body}");
7705 assert!(!body.contains("## Impact"), "{body}");
7706 }
7707
7708 #[test]
7711 fn the_summary_line_is_not_printed_twice() {
7712 let echoed = Finding {
7713 detail: "The guard is inverted so it rejects valid input.".into(),
7714 problem: Some("The guard is inverted so it rejects valid input.".into()),
7715 reproduction: None,
7716 impact: None,
7717 expected: None,
7718 ..reported()
7719 };
7720 let body = issue_report(&echoed);
7721 assert_eq!(1, body.matches("The guard is inverted").count(), "{body}");
7722 }
7723}