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