1use std::collections::BTreeSet;
21use std::path::{Path, PathBuf};
22
23use crate::agent::{self, Agent};
24use crate::config::{Config, Drafts, Followups, PrComments};
25use crate::error::{ErrorKind, Result, SparError};
26use crate::jsonx::{exact_finding_key as finding_key, finding_file, stable_finding_key};
27use crate::model::{
28 Action, Disposition, Dispute, Finding, Followup, Implementation, Issue, IssueRun, Ledger,
29 LedgerEntry, NextAction, PersistedState, PlanItem, PrView, ResponseDoc, Review, Settled,
30 Severity, SkippedItem, Status, STATE_VERSION,
31};
32use crate::repo::Repo;
33use crate::style::{self, Style};
34use crate::{bail, log, logdim, logwarn, schema, spar_err};
35
36const IMPLEMENT_PROMPT: &str = "\
41Implement GitHub issue #{number} in this repository.
42
43Title: {title}
44URL: {url}
45
46{body}
47
48That is the issue body as filed. The discussion since is not included, so read
49the thread at the URL above if the body leaves anything open. If you cannot
50reach the network, work from what is here.
51
52Do the work and run the relevant tests. Leave the changes uncommitted. Do not
53commit, push, open a PR, or merge; the harness validates and commits the working
54tree after your report is accepted.
55
56Then report it. Your answer becomes the pull request description, and the
57reviewer reads that cold, with nothing but the diff and a link to the issue:
58say what you found wrong, what the change does about it, and how they confirm
59it for themselves. Say what you actually ran, not what could be run.
60
61If after reading the code you conclude this issue should not be implemented,
62make no changes and set not_worth_doing, with the reason.";
63
64const REVIEW_PROMPT: &str = "\
65Review the changes on this branch against `{base}`. They implement issue
66#{number}: {title}
67
68Review thoroughly: correctness, edge cases, error handling, security, and
69whether the change actually resolves the issue. Read surrounding code, do not
70only read the diff.
71
72Label every finding by severity, and be honest about which is which:
73- blocking: the PR should not merge as is. Real defects only.
74- non-blocking: real, and smaller than another round. A minor defect belongs
75 here as much as an improvement does.
76- nit: style or taste.
77
78Blocking is the only severity that costs a round, and a round is another commit
79somebody has to read before this can merge. Being right that something is wrong
80is not enough to block. It has to be wrong in a way that would cost somebody.
81
82Confirm anything you label blocking before you label it. Run the code,
83reproduce the failure, or point at the exact line that breaks, and say in the
84detail what you did to confirm it. When you need to run something to check a
85claim, write a scratch file under the system temporary directory and run that,
86rather than passing a long program on the command line: it is easier to read
87back, easier to rerun, and less likely to be refused by a sandbox or a safety
88filter part way through your work. An unverified blocking finding is worse than
89one you never raised: it stalls a good PR and teaches the author to stop
90believing you. If you suspect a problem but could not confirm it, say so and
91label it non-blocking.
92
93Set in_scope=false for a real defect that exists, that this PR did not cause, and
94that is worth somebody stopping to fix. Each one becomes a tracked item a
95maintainer has to read and triage, so the bar is a defect and not an observation.
96A thorough reviewer can always find something adjacent to what it is reading;
97that is not a reason to file it. If you are not sure it is worth a maintainer's
98time, say your piece in the finding and label it non-blocking.
99
100Reviewing one issue should not manufacture ten more. If you find yourself with
101several out of scope findings, keep the ones that would bite somebody and drop
102the rest.
103
104Then choose next_action:
105- merge: no blocking findings, the PR is good.
106- fix_myself: there are blocking findings and you will fix them directly.
107- hand_back: there are blocking findings the author should address.
108{open}{answers}{settled}{round}";
109
110const CLOSE_PROMPT: &str = "\
111This closes the review of issue #{number}: {title}
112
113This is the final merge-safety audit. Read the full branch against `{base}` and
114answer one question: does the branch still contain anything that must not
115merge. Do not spend this pass on optional improvements or style.
116{landed}{open}{answers}{settled}
117Something blocks here when the branch still contains a confirmed defect that
118means it should not merge. That includes an open point above that the code does
119not answer, a defect in what landed since the last round, or a serious defect an
120earlier round missed. Go and look before you raise it. Run the test that covers
121it, or read the relevant lines and follow them to the caller, and say in the
122detail what you did.
123
124Keep this pass focused on merge safety. Minor defects and improvements are
125non-blocking. Keep in_scope=false for what it has always meant, a real defect
126this pull request did not cause, which the harness handles according to the
127configured follow-up policy. A confirmed in-scope defect does not become
128non-blocking merely because an earlier round missed it.
129
130Nothing you raise here will be fixed, because there is no round after this. A
131blocking finding means the pull request stays open and the finding is reported
132for a person to weigh. Non-blocking findings are reported without holding it
133open. No blocking findings means the branch is signed off on your word, so do
134not omit one because the list was long. Both mistakes cost somebody. Only one
135of them ships.
136
137Set next_action to merge when you raise nothing blocking, and hand_back when you
138do. Nothing acts on it here, and the findings are what decide.
139
140This call reads and nothing else. Do not edit the code, do not commit, and do
141not push. A pass that writes has judged a branch the rollback then takes away,
142so anything you leave behind is rolled back and the sign off does not stand.
143Put any scratch file under the system temporary directory, not in the working
144tree.";
145
146const FIX_PROMPT: &str = "\
147You reviewed this branch and chose to fix the blocking findings yourself.
148Implement those fixes now. Leave the changes uncommitted so the harness can
149validate and commit them.
150
151Your findings:
152{findings}
153
154Fix what the point says and nothing else. The smallest change that answers it is
155the right one: no refactor alongside it, no capability nobody asked for, no
156handling for cases nobody raised. Every line you add is what the next pass
157reviews, so a fix that grows the branch buys another round of findings about the
158fix. If a point cannot be answered without a change bigger than the point, say so
159rather than making the change.
160
161Do not commit, push, or merge.";
162
163const RESPOND_PROMPT: &str = "\
164Here is a review of your PR for issue #{number}.
165
166{findings}
167
168For each point, choose exactly one disposition:
169- fixed: the point is valid and in scope. Fix it and leave the change
170 uncommitted for the harness.
171- refuted: the point is wrong, or the change it asks for is bigger than the
172 problem it names. Explain why. Refuting is a legitimate outcome; do not accept
173 a review comment you believe is incorrect just to get the PR approved.
174- filed_issue: the point is valid but unrelated to this PR. Supply
175 new_issue_title and new_issue_body; the harness files it and skips duplicates.
176
177Copy each finding's title and file across exactly as given, so your answer can
178be matched back to the review. Give a reason for every disposition. For fixed,
179say what changed and how it answers the point. For refuted, say why the point
180does not stand. For filed_issue, say why it belongs outside this pull request.
181
182Fix what the point says and nothing else. The smallest change that answers it is
183the right one: no refactor alongside it, no capability nobody asked for, no
184handling for cases nobody raised. Every line you add is what the next pass
185reviews, so a fix that grows the branch buys another round of findings about the
186fix. If a point cannot be answered without a change bigger than the point, say so
187rather than making the change.
188
189Leave any fixes uncommitted. Do not commit, push, or merge.";
190
191#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct Snapshot {
202 pub head: String,
203 pub dirty: bool,
205}
206
207impl Snapshot {
208 pub fn landed_over(&self, before: &Snapshot) -> bool {
211 !self.head.is_empty() && self.head != before.head
212 }
213}
214
215pub fn snapshot(repo: &Repo, work_dir: &Path) -> Snapshot {
216 Snapshot {
217 head: repo
218 .git_try_at(Some(work_dir), &["rev-parse", "HEAD"])
219 .trim()
220 .to_string(),
221 dirty: !repo
222 .git_try_at(
223 Some(work_dir),
224 &["status", "--porcelain", "--untracked-files=no"],
225 )
226 .trim()
227 .is_empty(),
228 }
229}
230
231fn checked_head(repo: &Repo, work_dir: &Path) -> Result<String> {
232 let head = repo.git_at(Some(work_dir), &["rev-parse", "HEAD"])?;
233 let head = head.trim().to_string();
234 if head.is_empty() {
235 return Err(spar_err!("could not read the pull request head"));
236 }
237 Ok(head)
238}
239
240fn reconcile_saved_head(
249 recorded_head: Option<&str>,
250 state_version: Option<u32>,
251 saved_actor_known: bool,
252 actual_head: &str,
253 holder_override: Option<&str>,
254 pr_number: i64,
255) -> Result<bool> {
256 let Some(recorded_head) = recorded_head else {
257 return Ok(true);
258 };
259 if !recorded_head.is_empty() && recorded_head == actual_head {
260 return Ok(true);
261 }
262 if holder_override.is_some() {
263 return Ok(false);
264 }
265 if recorded_head.is_empty() && state_version == Some(1) && saved_actor_known {
266 return Ok(true);
267 }
268 let recorded = if recorded_head.is_empty() {
269 format!(
270 "state version {} without a recorded head",
271 state_version
272 .map(|version| version.to_string())
273 .unwrap_or_else(|| "unknown".to_string())
274 )
275 } else {
276 format!("head {recorded_head}")
277 };
278 Err(spar_err!(
279 "saved review state applies to {recorded}, but PR #{pr_number} is at {actual_head}; \
280 resume with --next <agent> to choose who reviews this head"
281 ))
282}
283
284pub fn park(repo: &Repo, work_dir: &Path) -> Option<String> {
291 let saved = repo
292 .git_try_at(Some(work_dir), &["stash", "create"])
293 .trim()
294 .to_string();
295 (!saved.is_empty()).then_some(saved)
296}
297
298fn reset_saving(repo: &Repo, work_dir: &Path, target: &str) {
305 let parked = park(repo, work_dir);
306 if let Err(e) = repo.git_at(Some(work_dir), &["reset", "--hard", target]) {
307 logdim!("could not roll the working tree back: {e}");
308 return;
309 }
310 if let Some(saved) = parked {
311 logdim!("`git stash apply {saved}` puts the discarded changes back");
312 }
313}
314
315pub fn undo_edits(repo: &Repo, work_dir: &Path, before: &Snapshot) -> Snapshot {
326 let current = snapshot(repo, work_dir);
327 if before.head.is_empty() {
328 return current;
329 }
330 if current.landed_over(before) {
331 logdim!(
332 "the commits being rolled back are still at {}",
333 current.head
334 );
335 }
336 reset_saving(repo, work_dir, &before.head);
337 snapshot(repo, work_dir)
338}
339
340fn preserve_closing_commit(repo: &Repo, ctx: &LoopCtx) -> Option<String> {
346 let current = snapshot(repo, &ctx.work_dir);
347 if current.head.is_empty() {
348 return None;
349 }
350 let reference = format!(
351 "refs/spar/recovery/pr-{}/closing-{}",
352 ctx.pr_number, current.head
353 );
354 match repo.git_at(
355 Some(&ctx.work_dir),
356 &["update-ref", &reference, ¤t.head],
357 ) {
358 Ok(_) => Some(reference),
359 Err(e) => {
360 logdim!("could not preserve the closing pass commit: {e}");
361 None
362 }
363 }
364}
365
366pub fn drop_uncommitted(repo: &Repo, work_dir: &Path) -> Snapshot {
373 let current = snapshot(repo, work_dir);
374 if !current.dirty || current.head.is_empty() {
375 return current;
376 }
377 reset_saving(repo, work_dir, ¤t.head);
378 snapshot(repo, work_dir)
379}
380
381fn should_release(cfg: &Config, status: Status) -> bool {
386 if !cfg.loop_cfg.worktrees || cfg.loop_cfg.keep_worktrees {
387 return false;
388 }
389 !matches!(status, Status::Escalated | Status::Error)
390}
391
392fn uncommitted_implementation_error(work_dir: &Path, detail: Option<&str>) -> SparError {
393 let detail = detail.unwrap_or_default().trim();
394 let prefix = if detail.is_empty() {
395 String::new()
396 } else {
397 format!("{detail}\n")
398 };
399 spar_err!(
400 "{prefix}The implementation left uncommitted changes in {}. Commit or recover them \
401 before running this issue again.",
402 work_dir.display()
403 )
404}
405
406fn commit_accepted_changes(
407 cfg: &Config,
408 repo: &Repo,
409 work_dir: &Path,
410 baseline: &crate::repo::WorktreeBaseline,
411 preferred_subject: &str,
412 fallback_subject: &str,
413) -> Result<bool> {
414 repo.refuse_changed_attributes(work_dir, baseline)?;
415 if !cfg.loop_cfg.worktrees && repo.has_uncommitted_changes(work_dir)? {
416 bail!(
417 "the implementation changed the shared checkout at {}, but spar cannot distinguish \
418 those files from edits made concurrently by its owner. The files were kept. Commit \
419 or recover them, then use the default worktree mode for managed commits.",
420 work_dir.display()
421 );
422 }
423 let committed =
424 repo.commit_pending_changes(work_dir, baseline, preferred_subject, fallback_subject)?;
425 repo.refuse_unrepresented_tracked_changes(work_dir, baseline)?;
426 Ok(committed)
427}
428
429pub fn run_issue(
434 agents: &[Agent],
435 cfg: &Config,
436 repo: &Repo,
437 item: &PlanItem,
438 issue: &Issue,
439) -> IssueRun {
440 if let Some(existing) = repo.open_pr_for_issue(item.issue) {
448 log!(
449 "#{}: {} is already open, continuing it instead of implementing again",
450 item.issue,
451 existing.url
452 );
453 return resume_pr(agents, cfg, repo, existing.number, None);
454 }
455
456 let mut state = IssueRun::new(item.issue, item.title.clone());
457 let mut ledger = Ledger::new();
464 let base = cfg.base_branch().to_string();
465
466 let prepared = if cfg.loop_cfg.worktrees {
467 repo.worktree_add(item.issue, &base)
468 } else {
469 (|| {
470 if repo.has_uncommitted_changes(repo.root())? {
471 bail!(
472 "the shared checkout at {} has uncommitted changes. Refusing to reset it for \
473 issue #{}.",
474 repo.root().display(),
475 item.issue
476 );
477 }
478 if repo.has_changes_checked(repo.root(), &base)? {
479 let preserved = repo.current_branch_is_preserved(repo.root()).map_err(|e| {
480 spar_err!(
481 "could not verify whether a pull request preserves the shared checkout \
482 at {}: {}. Refusing to reset it for issue #{}.",
483 repo.root().display(),
484 e.last_line(),
485 item.issue
486 )
487 })?;
488 if !preserved {
489 bail!(
490 "the shared checkout at {} has commits that are not on {base}. Refusing \
491 to reset them for issue #{}.",
492 repo.root().display(),
493 item.issue
494 );
495 }
496 }
497 let branch = repo.branch_for_issue(item.issue);
498 let start = format!("origin/{base}");
499 repo.refuse_issue_branch_rebuild(item.issue, &base)?;
500 if repo.has_uncommitted_changes(repo.root())? {
501 bail!(
502 "the shared checkout at {} changed while issue #{} was being prepared. \
503 Refusing to reset it.",
504 repo.root().display(),
505 item.issue
506 );
507 }
508 repo.git(&["checkout", "-B", &branch, &start])?;
509 repo.record_branch(&branch, "issue", item.issue);
510 Ok((repo.root().to_path_buf(), branch))
511 })()
512 };
513
514 let (work_dir, branch) = match prepared {
515 Ok(pair) => pair,
516 Err(e) => {
517 state.status = Status::Error;
518 state.notes.push(e.to_string());
519 log!("#{} failed: {e}", item.issue);
520 return state;
521 }
522 };
523
524 let outcome = implement_and_review(
525 agents,
526 cfg,
527 repo,
528 item,
529 issue,
530 &mut ledger,
531 &mut state,
532 &work_dir,
533 &branch,
534 );
535 if let Err(e) = outcome {
536 state.status = Status::Error;
537 state.notes.push(e.to_string());
538 log!("#{} failed: {e}", item.issue);
539 }
540
541 if should_release(cfg, state.status) {
542 repo.worktree_remove(item.issue);
543 }
544 state
545}
546
547#[allow(clippy::too_many_arguments)]
548fn implement_and_review(
549 agents: &[Agent],
550 cfg: &Config,
551 repo: &Repo,
552 item: &PlanItem,
553 issue: &Issue,
554 ledger: &mut Ledger,
555 state: &mut IssueRun,
556 work_dir: &Path,
557 branch: &str,
558) -> Result<()> {
559 let number = item.issue;
560 let holder = cfg.first_implementor.clone();
561 let implementor = agent::find(agents, &holder)?;
562 let base = cfg.base_branch().to_string();
563
564 log!("#{number}: {holder} implementing");
565 let (body, shortened) = issue.body_for_prompt(cfg.loop_cfg.max_issue_chars);
570 if shortened {
571 logwarn!(
572 "#{number}: the issue body was shortened to fit the prompt. Raise max_issue_chars if \
573 the rest matters."
574 );
575 }
576 let prompt = implement_prompt(number, &item.title, &issue.url, &body);
577 let worktree_baseline = repo.worktree_baseline(work_dir)?;
578 let answer: Result<Implementation> = implementor.edit_json(
579 &prompt,
580 &schema::implementation(),
581 work_dir,
582 cfg.effort_for_round(&implementor.spec, 1).as_deref(),
583 );
584
585 if answer.is_ok() {
586 repo.refuse_changed_attributes(work_dir, &worktree_baseline)?;
587 }
588
589 if let Err(e) = &answer {
590 if e.kind() == crate::error::ErrorKind::UncertainWrite {
591 return Err(e.clone());
592 }
593 }
594
595 let has_commits = repo.has_changes_checked(work_dir, &base)?;
600 let has_uncommitted = repo.has_uncommitted_changes(work_dir)?;
601 let mut work = match answer {
602 Err(e) if has_uncommitted => {
603 return Err(uncommitted_implementation_error(
604 work_dir,
605 Some(e.message()),
606 ));
607 }
608 Ok(work) => work,
609 Err(e) if has_commits => {
610 logwarn!(
611 "#{number}: {holder} failed after committing: {e}\nContinuing from the commits, \
612 with a pull request body written from their messages."
613 );
614 state
615 .notes
616 .push(format!("{holder} failed after committing: {e}"));
617 from_commits(repo, work_dir, &base)
618 }
619 Err(e) => return Err(e),
620 };
621
622 if work.not_worth_doing {
623 repo.refuse_unrepresented_tracked_changes(work_dir, &worktree_baseline)?;
624 repo.refuse_changed_existing_untracked(work_dir, &worktree_baseline)?;
625 if has_uncommitted || has_commits {
626 bail!(
627 "{holder} declined issue #{number} after changing {}. The worktree was kept for \
628 recovery.",
629 work_dir.display()
630 );
631 }
632 repo.refuse_new_ignored_files(work_dir, &worktree_baseline)?;
633 state.status = Status::Abandoned;
634 let reason = no_pr_note(&work, &repo.style);
635 state.notes.push(reason.clone());
636 if let Err(e) = repo.comment_issue(number, &reason) {
637 logdim!("could not comment on #{number}: {e}");
638 }
639 return Ok(());
640 }
641
642 commit_accepted_changes(
643 cfg,
644 repo,
645 work_dir,
646 &worktree_baseline,
647 &work.summary,
648 &item.title,
649 )?;
650 if repo.has_uncommitted_changes(work_dir)? {
651 return Err(uncommitted_implementation_error(
652 work_dir,
653 work.notes.as_deref(),
654 ));
655 }
656 if !repo.has_changes_checked(work_dir, &base)? {
657 repo.refuse_new_ignored_files(work_dir, &worktree_baseline)?;
658 state.status = Status::Abandoned;
659 let reason = no_pr_note(&work, &repo.style);
660 state.notes.push(reason.clone());
661 if let Err(e) = repo.comment_issue(number, &reason) {
662 logdim!("could not comment on #{number}: {e}");
663 }
664 return Ok(());
665 }
666
667 if work.summary.trim().is_empty() {
671 work.summary = item.title.clone();
672 }
673
674 repo.rewrite_commits_if_needed(work_dir, &base)?;
675 repo.push(work_dir, branch)?;
676
677 let pr = match repo.pr_for_branch(branch) {
678 Some(existing) => existing,
679 None => {
680 let body = pr_body(number, &work, &repo.style);
681 repo.create_pr(
682 work_dir,
683 branch,
684 &base,
685 &format!("{} (#{number})", item.title),
686 &body,
687 )?
688 }
689 };
690 repo.record_branch(branch, "pr", pr.number);
691 state.pr = Some(pr.url.clone());
692 log!("#{number}: PR {}", pr.url);
693
694 let ctx = LoopCtx {
695 work_dir: work_dir.to_path_buf(),
696 branch: branch.to_string(),
697 pr_number: pr.number,
698 label: format!("#{number}"),
699 subject: number,
700 title: item.title.clone(),
701 start_round: 1,
702 holder: cfg.other(&holder),
703 release: Release::Issue(number),
704 };
705 review_loop(agents, cfg, repo, &ctx, state, ledger, Vec::new())
706}
707
708pub fn resume_pr(
719 agents: &[Agent],
720 cfg: &Config,
721 repo: &Repo,
722 pr_number: i64,
723 holder_override: Option<&str>,
724) -> IssueRun {
725 let failed = |e: SparError| {
726 log!("PR #{pr_number} failed: {e}");
727 let mut state = IssueRun::new(pr_number, format!("PR #{pr_number}"));
728 state.status = Status::Error;
729 state.notes.push(e.to_string());
730 state
731 };
732
733 let pr = match repo.pr_view(pr_number) {
734 Ok(pr) => pr,
735 Err(e) => return failed(e),
736 };
737
738 if pr.is_cross_repository {
743 log!("PR #{pr_number} comes from a fork, reviewing it without changing it");
744 return crate::review_only::review_pr(agents, cfg, repo, pr_number, false);
745 }
746
747 match resume_inner(agents, cfg, repo, pr, holder_override) {
748 Ok(state) => state,
749 Err(e) => failed(e),
750 }
751}
752
753fn resume_inner(
754 agents: &[Agent],
755 cfg: &Config,
756 repo: &Repo,
757 pr: PrView,
758 holder_override: Option<&str>,
759) -> Result<IssueRun> {
760 let pr_number = pr.number;
761 if !pr.is_open() {
762 return Err(spar_err!("PR #{pr_number} is {}", pr.state.to_lowercase()));
763 }
764
765 let subject = pr
766 .closing_issues_references
767 .first()
768 .map(|r| r.number)
769 .unwrap_or(pr_number);
770
771 if let Some(holder) = holder_override {
772 if !cfg.has_agent(holder) {
773 return Err(spar_err!(
774 "--next must name one of: {}",
775 cfg.agent_names().join(", ")
776 ));
777 }
778 }
779 let (work_dir, branch) = repo.worktree_for_pr(&pr)?;
780 let actual_head = checked_head(repo, &work_dir)?;
781 let saved = repo.read_state_for_head(&pr, &actual_head);
782 let saved_actor_known = saved
783 .as_ref()
784 .map(|state| cfg.has_agent(&state.next_actor))
785 .unwrap_or(false);
786 let state_matches_head = reconcile_saved_head(
787 saved.as_ref().map(|state| state.pr_head.as_str()),
788 saved.as_ref().map(|state| state.version),
789 saved_actor_known,
790 &actual_head,
791 holder_override,
792 pr_number,
793 )?;
794 let migrates_legacy_state = saved.as_ref().is_some_and(|state| {
795 state.version == 1
796 && state.pr_head.is_empty()
797 && saved_actor_known
798 && holder_override.is_none()
799 });
800 if migrates_legacy_state {
801 log!("PR #{pr_number}: migrating legacy review state to head {actual_head}");
802 }
803
804 let mut ledger: Ledger = saved
805 .as_ref()
806 .filter(|_| state_matches_head)
807 .map(|s| s.ledger.clone())
808 .unwrap_or_default();
809 normalise_ledger_keys(&mut ledger);
810 let open_findings = saved
811 .as_ref()
812 .filter(|_| state_matches_head)
813 .map(|s| blocking_findings(&s.open_findings))
814 .unwrap_or_default();
815 let start_round = saved
816 .as_ref()
817 .filter(|_| state_matches_head)
818 .map(|s| s.round + 1)
819 .unwrap_or(1);
820
821 let default_holder = cfg.other(&cfg.first_implementor);
822 let mut holder = holder_override
823 .map(str::to_string)
824 .or_else(|| saved.as_ref().map(|s| s.next_actor.clone()))
825 .unwrap_or_else(|| default_holder.clone());
826 if !cfg.has_agent(&holder) {
827 log!("state named unknown agent '{holder}', using {default_holder}");
828 holder = default_holder;
829 }
830
831 match &saved {
832 Some(_) => log!(
833 "PR #{pr_number}: resuming at round {start_round}, {} point(s) on record, next up \
834 {holder}",
835 ledger.len()
836 ),
837 None => log!("PR #{pr_number}: no prior spar state, starting fresh with {holder}"),
838 }
839
840 let mut state = IssueRun::new(subject, pr.title.clone());
841 state.pr = Some(pr.url.clone());
842 if let Some(s) = &saved {
843 state.filed = s.filed.clone();
844 if state_matches_head {
845 state.disputes = s.disputes.clone();
846 state.noted = s.noted.clone();
847 } else {
848 log!(
849 "PR #{pr_number}: saved state does not match {actual_head}; branch-dependent \
850 review state was cleared"
851 );
852 }
853 }
854
855 let ctx = LoopCtx {
856 work_dir,
857 branch,
858 pr_number,
859 label: format!("PR #{pr_number}"),
860 subject,
861 title: pr.title.clone(),
862 start_round,
863 holder,
864 release: Release::Pr(pr_number),
865 };
866
867 let outcome = review_loop(
868 agents,
869 cfg,
870 repo,
871 &ctx,
872 &mut state,
873 &mut ledger,
874 open_findings,
875 );
876 if let Err(e) = outcome {
877 state.status = Status::Error;
878 state.notes.push(e.to_string());
879 log!("PR #{pr_number} failed: {e}");
880 }
881 if should_release(cfg, state.status) {
882 repo.release_pr_worktree(pr_number);
883 }
884 Ok(state)
885}
886
887#[derive(Debug, Clone, Copy)]
892enum Release {
893 Issue(i64),
894 Pr(i64),
895}
896
897struct LoopCtx {
898 work_dir: PathBuf,
899 branch: String,
900 pr_number: i64,
901 label: String,
902 subject: i64,
903 title: String,
904 start_round: u32,
905 holder: String,
906 release: Release,
907}
908
909impl LoopCtx {
910 fn release(&self, repo: &Repo) -> bool {
911 match self.release {
912 Release::Issue(n) => repo.worktree_remove(n),
913 Release::Pr(n) => repo.release_pr_worktree(n),
914 }
915 }
916}
917
918fn review_loop(
919 agents: &[Agent],
920 cfg: &Config,
921 repo: &Repo,
922 ctx: &LoopCtx,
923 state: &mut IssueRun,
924 ledger: &mut Ledger,
925 mut open_findings: Vec<Finding>,
926) -> Result<()> {
927 let base = cfg.base_branch().to_string();
928 let mut holder = ctx.holder.clone();
932
933 let (first, last_allowed) = round_window(ctx.start_round, cfg.loop_cfg.max_rounds);
939 let mut published_head = checked_head(repo, &ctx.work_dir)?;
940 persist(
941 repo,
942 ctx.pr_number,
943 state,
944 ledger,
945 &open_findings,
946 &published_head,
947 first.saturating_sub(1),
948 &holder,
949 )?;
950 let mut audited_head = String::new();
954 let mut last_round = first.saturating_sub(1);
955
956 for round in first..=last_allowed {
957 last_round = round;
958 state.rounds = round;
959 let reviewer = agent::find(agents, &holder)?;
960 let effort = cfg.effort_for_round(&reviewer.spec, round);
961 log!(
962 "{}: round {round}, {holder} reviewing ({})",
963 ctx.label,
964 effort.as_deref().unwrap_or("default effort")
965 );
966
967 let prompt = review_prompt(
968 &base,
969 ctx.subject,
970 &ctx.title,
971 ledger,
972 &open_findings,
973 round,
974 last_allowed,
975 );
976 let before_review = snapshot(repo, &ctx.work_dir);
977 let review_baseline = repo.worktree_baseline(&ctx.work_dir)?;
978 audited_head = before_review.head.clone();
981 let review = reviewer.review::<Review>(
982 &base,
983 &prompt,
984 &schema::review(),
985 &ctx.work_dir,
986 effort.as_deref(),
987 );
988 if let Err(error) = &review {
989 if error.kind() == crate::error::ErrorKind::UncertainWrite {
990 return Err(error.clone());
991 }
992 }
993 repo.refuse_unrepresented_tracked_changes(&ctx.work_dir, &review_baseline)?;
994 repo.refuse_new_ignored_files(&ctx.work_dir, &review_baseline)?;
995 let review = review?;
996 if repo.has_uncommitted_changes(&ctx.work_dir)? {
997 bail!(
998 "{}: {holder} left uncommitted files while reviewing. They were kept at {} and \
999 the review did not continue.",
1000 ctx.label,
1001 ctx.work_dir.display()
1002 );
1003 }
1004
1005 let mut editor: Option<String> = None;
1009 let review_wrote = snapshot(repo, &ctx.work_dir) != before_review;
1010 if review_wrote {
1011 logwarn!(
1012 "{}: {holder} changed the branch while reviewing it, which the review prompt \
1013 forbids. Rolling it back.",
1014 ctx.label
1015 );
1016 if undo_edits(repo, &ctx.work_dir, &before_review).head != before_review.head {
1017 state
1018 .notes
1019 .push(format!("{holder} committed during its own review"));
1020 editor = Some(holder.clone());
1021 }
1022 }
1023
1024 let blocking = blocking_findings(&review.findings);
1025 update_open_findings(&mut open_findings, &blocking, !review_wrote);
1026
1027 if repo.style.pr_comments == PrComments::Rounds {
1028 if let Err(e) = repo.comment_pr(
1029 ctx.pr_number,
1030 &review_comment(&holder, round, &review, &repo.style),
1031 ) {
1032 logdim!("could not post the review comment: {e}");
1033 }
1034 }
1035
1036 file_out_of_scope(repo, &review.findings, ctx.subject, state, cfg);
1040 file_nonblocking(repo, &review.findings, ctx.subject, state, cfg);
1041 remove_findings(&mut state.noted, &blocking);
1042
1043 if check_relitigation(ledger, &blocking, state) {
1044 state.status = Status::Escalated;
1045 post_outcome(
1046 repo,
1047 ctx.pr_number,
1048 state,
1049 ledger,
1050 Ending::Deadlocked(&blocking),
1051 );
1052 persist(
1053 repo,
1054 ctx.pr_number,
1055 state,
1056 ledger,
1057 &open_findings,
1058 &published_head,
1059 round,
1060 &holder,
1061 )?;
1062 return Ok(());
1063 }
1064
1065 if approval_stands(&blocking, review_wrote) {
1066 open_findings.clear();
1067 return approve(
1068 cfg,
1069 repo,
1070 ctx,
1071 state,
1072 ledger,
1073 &published_head,
1074 round,
1075 &holder,
1076 );
1077 }
1078
1079 persist(
1082 repo,
1083 ctx.pr_number,
1084 state,
1085 ledger,
1086 &open_findings,
1087 &published_head,
1088 round,
1089 &holder,
1090 )?;
1091
1092 let mut edit_error = None;
1093
1094 if blocking.is_empty() {
1095 logwarn!(
1100 "{}: {holder} found nothing blocking on a branch it had changed itself, so the \
1101 approval does not carry.",
1102 ctx.label
1103 );
1104 state.notes.push(format!(
1105 "{holder} passed the branch in round {round} after editing it; the edit was rolled \
1106 back and the approval did not stand"
1107 ));
1108 } else if review.next_action == NextAction::FixMyself {
1109 log!("{}: {holder} fixing its own findings", ctx.label);
1110 let prompt = FIX_PROMPT.replace("{findings}", &findings_for_prompt(&blocking));
1111 let before_fix = repo.head_oid_checked(&ctx.work_dir)?;
1112 let worktree_baseline = repo.worktree_baseline(&ctx.work_dir)?;
1113 let fix_error = match reviewer.edit(&prompt, &ctx.work_dir, effort.as_deref()) {
1114 Ok(summary) => {
1115 commit_accepted_changes(
1116 cfg,
1117 repo,
1118 &ctx.work_dir,
1119 &worktree_baseline,
1120 &summary,
1121 "Address blocking review findings",
1122 )?;
1123 None
1124 }
1125 Err(error) => Some(defer_clean_edit_error(
1126 repo,
1127 &ctx.work_dir,
1128 &worktree_baseline,
1129 error,
1130 )?),
1131 };
1132 match editor_after(repo, &ctx.work_dir, &before_fix, &ctx.label, &holder)? {
1133 Some(who) => {
1134 record_own_fixes(&blocking, ledger, state, round);
1142 remove_findings(&mut open_findings, &blocking);
1143 editor = Some(who);
1144 }
1145 None if fix_error.is_none() => {
1146 repo.refuse_new_ignored_files(&ctx.work_dir, &worktree_baseline)?;
1147 logwarn!(
1150 "{}: {holder} said it would fix its own findings and committed nothing, \
1151 so it keeps the pull request.",
1152 ctx.label
1153 );
1154 state.notes.push(format!(
1155 "{holder} chose to fix its own findings in round {round} and committed \
1156 nothing"
1157 ));
1158 }
1159 None => {}
1160 }
1161 edit_error = fix_error;
1162 } else {
1163 let author_name = cfg.other(&holder);
1164 let author = agent::find(agents, &author_name)?;
1165 log!(
1166 "{}: handing {} finding(s) to {author_name}",
1167 ctx.label,
1168 blocking.len()
1169 );
1170 let prompt = RESPOND_PROMPT
1171 .replace("{number}", &ctx.subject.to_string())
1172 .replace("{findings}", &findings_for_prompt(&blocking));
1173 let before_response = repo.head_oid_checked(&ctx.work_dir)?;
1174 let worktree_baseline = repo.worktree_baseline(&ctx.work_dir)?;
1175 let response: Result<ResponseDoc> = author.edit_json(
1176 &prompt,
1177 &schema::response(),
1178 &ctx.work_dir,
1179 cfg.effort_for_round(&author.spec, round).as_deref(),
1180 );
1181 let response = match response {
1182 Ok(response) => {
1183 commit_accepted_changes(
1184 cfg,
1185 repo,
1186 &ctx.work_dir,
1187 &worktree_baseline,
1188 &response.summary,
1189 "Address blocking review findings",
1190 )?;
1191 Some(response)
1192 }
1193 Err(error) => {
1194 edit_error = Some(defer_clean_edit_error(
1195 repo,
1196 &ctx.work_dir,
1197 &worktree_baseline,
1198 error,
1199 )?);
1200 None
1201 }
1202 };
1203 if let Some(who) = editor_after(
1204 repo,
1205 &ctx.work_dir,
1206 &before_response,
1207 &ctx.label,
1208 &author_name,
1209 )? {
1210 editor = Some(who);
1211 } else if let Some(response) = &response {
1212 repo.refuse_new_ignored_files(&ctx.work_dir, &worktree_baseline)?;
1213 if response
1214 .dispositions
1215 .iter()
1216 .any(|d| d.action == Action::Fixed)
1217 {
1218 logwarn!(
1219 "{}: {author_name} reported fixes but committed nothing, so the diff does \
1220 not have them.",
1221 ctx.label
1222 );
1223 }
1224 }
1225 if let Some(response) = response {
1226 let unresolved = apply_dispositions(
1227 repo,
1228 cfg,
1229 &response,
1230 &blocking,
1231 ledger,
1232 state,
1233 round,
1234 ctx.subject,
1235 ctx.pr_number,
1236 &author_name,
1237 editor.is_some(),
1238 );
1239 remove_findings(&mut open_findings, &blocking);
1240 extend_findings(&mut open_findings, &unresolved);
1241 }
1242 }
1243
1244 if editor.is_some() {
1245 repo.rewrite_commits_if_needed(&ctx.work_dir, &base)?;
1246 repo.push(&ctx.work_dir, &ctx.branch)?;
1247 published_head = checked_head(repo, &ctx.work_dir)?;
1248 }
1249 holder = next_reviewer(cfg, &holder, editor.as_deref());
1250 persist(
1251 repo,
1252 ctx.pr_number,
1253 state,
1254 ledger,
1255 &open_findings,
1256 &published_head,
1257 round,
1258 &holder,
1259 )?;
1260 if let Some(error) = edit_error {
1261 return Err(error);
1262 }
1263 }
1264
1265 close_out(
1272 agents,
1273 cfg,
1274 repo,
1275 ctx,
1276 state,
1277 ledger,
1278 &mut open_findings,
1279 &holder,
1280 last_round,
1281 &audited_head,
1282 &published_head,
1283 )
1284}
1285
1286#[allow(clippy::too_many_arguments)]
1297fn close_out(
1298 agents: &[Agent],
1299 cfg: &Config,
1300 repo: &Repo,
1301 ctx: &LoopCtx,
1302 state: &mut IssueRun,
1303 ledger: &mut Ledger,
1304 open_findings: &mut Vec<Finding>,
1305 holder: &str,
1306 round: u32,
1307 audited_head: &str,
1308 published_head: &str,
1309) -> Result<()> {
1310 let stop =
1311 |state: &mut IssueRun, ledger: &Ledger, open_findings: &[Finding], ending: Ending<'_>| {
1312 state.status = Status::Escalated;
1313 state.notes.push(exhausted_note(ctx.start_round, round));
1314 post_outcome(repo, ctx.pr_number, state, ledger, ending);
1315 persist(
1316 repo,
1317 ctx.pr_number,
1318 state,
1319 ledger,
1320 open_findings,
1321 published_head,
1322 round,
1323 holder,
1324 )
1325 };
1326
1327 let landed = (!audited_head.is_empty())
1334 .then(|| repo.commits_since(&ctx.work_dir, audited_head, "HEAD"))
1335 .flatten();
1336 if audited_head.is_empty()
1337 || (landed.as_ref().is_some_and(|l| l.is_empty()) && !any_fixes(ledger, round))
1338 {
1339 logdim!(
1340 "{}: nothing landed after the last review, so there is nothing to close over",
1341 ctx.label
1342 );
1343 if !open_findings.is_empty() {
1344 state.notes.push(unresolved_note(open_findings.len()));
1345 }
1346 stop(
1347 state,
1348 ledger,
1349 open_findings,
1350 ending_without_landing(open_findings),
1351 )?;
1352 return Ok(());
1353 }
1354
1355 let closer = agent::find(agents, holder)?;
1356 let effort = cfg.effort_for_round(&closer.spec, closing_effort_round(round));
1357 log!(
1358 "{}: closing, {holder} checking what the last round left ({})",
1359 ctx.label,
1360 effort.as_deref().unwrap_or("default effort")
1361 );
1362
1363 let prompt = close_prompt(
1364 cfg.base_branch(),
1365 ctx.subject,
1366 &ctx.title,
1367 audited_head,
1368 landed.as_deref(),
1369 ledger,
1370 open_findings,
1371 round,
1372 );
1373 let before = snapshot(repo, &ctx.work_dir);
1374 let closing_baseline = repo.worktree_baseline(&ctx.work_dir)?;
1375 let pass = closer.ask_json(&prompt, &schema::review(), &ctx.work_dir, effort.as_deref());
1379 if let Err(e) = &pass {
1380 if e.kind() == crate::error::ErrorKind::UncertainWrite {
1381 return Err(e.clone());
1382 }
1383 }
1384 repo.refuse_unrepresented_tracked_changes(&ctx.work_dir, &closing_baseline)?;
1385 repo.refuse_new_ignored_files(&ctx.work_dir, &closing_baseline)?;
1386 if repo.has_uncommitted_changes(&ctx.work_dir)? {
1387 bail!(
1388 "{}: {holder} left uncommitted files during the closing pass. They were kept at {} \
1389 and the pass did not continue.",
1390 ctx.label,
1391 ctx.work_dir.display()
1392 );
1393 }
1394
1395 let close_wrote = snapshot(repo, &ctx.work_dir) != before;
1398 let next = closing_next_actor(holder);
1402 if close_wrote {
1403 if let Err(error) = &pass {
1404 logwarn!(
1405 "{}: the closing pass failed after changing the branch: {error}",
1406 ctx.label
1407 );
1408 }
1409 logwarn!(
1410 "{}: {holder} changed the branch while closing, which the prompt forbids. Rolling it \
1411 back.",
1412 ctx.label
1413 );
1414 let after_undo = undo_edits(repo, &ctx.work_dir, &before);
1415 if after_undo != before {
1416 state.notes.push(format!(
1417 "{holder}'s closing-pass changes could not be fully rolled back"
1418 ));
1419 if after_undo.head != before.head {
1420 if let Some(reference) = preserve_closing_commit(repo, ctx) {
1421 state.notes.push(format!(
1422 "the closing pass commit was not pushed and remains at {reference}"
1423 ));
1424 }
1425 }
1426 }
1427 state.status = Status::Escalated;
1428 state.notes.push(format!(
1429 "{holder} edited the branch during the closing pass, so its answer did not stand"
1430 ));
1431 post_unread_outcome(repo, ctx.pr_number, state, ledger, open_findings);
1432 persist(
1433 repo,
1434 ctx.pr_number,
1435 state,
1436 ledger,
1437 open_findings,
1438 published_head,
1439 round,
1440 &next,
1441 )?;
1442 return Ok(());
1443 }
1444
1445 let pass: Review = match pass {
1446 Ok(pass) => pass,
1447 Err(e) => {
1448 logwarn!("{}: the closing pass failed: {e}", ctx.label);
1452 state.status = Status::Escalated;
1453 state.notes.push(exhausted_note(ctx.start_round, round));
1454 post_unread_outcome(repo, ctx.pr_number, state, ledger, open_findings);
1455 persist(
1456 repo,
1457 ctx.pr_number,
1458 state,
1459 ledger,
1460 open_findings,
1461 published_head,
1462 round,
1463 holder,
1464 )?;
1465 return Ok(());
1466 }
1467 };
1468
1469 file_out_of_scope(repo, &pass.findings, ctx.subject, state, cfg);
1470 file_nonblocking(repo, &pass.findings, ctx.subject, state, cfg);
1471
1472 let blocking = blocking_findings(&pass.findings);
1473 remove_findings(&mut state.noted, &blocking);
1474 update_open_findings(open_findings, &blocking, true);
1475
1476 if check_relitigation(ledger, &blocking, state) {
1477 state.status = Status::Escalated;
1478 post_outcome(
1479 repo,
1480 ctx.pr_number,
1481 state,
1482 ledger,
1483 Ending::Deadlocked(&blocking),
1484 );
1485 persist(
1486 repo,
1487 ctx.pr_number,
1488 state,
1489 ledger,
1490 open_findings,
1491 published_head,
1492 round,
1493 &next,
1494 )?;
1495 return Ok(());
1496 }
1497
1498 if approval_stands(&blocking, false) {
1499 open_findings.clear();
1500 return approve(cfg, repo, ctx, state, ledger, published_head, round, &next);
1501 }
1502
1503 state.status = Status::Escalated;
1504 state.notes.push(unresolved_note(open_findings.len()));
1505 post_outcome(
1506 repo,
1507 ctx.pr_number,
1508 state,
1509 ledger,
1510 Ending::Unresolved(open_findings),
1511 );
1512 persist(
1513 repo,
1514 ctx.pr_number,
1515 state,
1516 ledger,
1517 open_findings,
1518 published_head,
1519 round,
1520 &next,
1521 )?;
1522 Ok(())
1523}
1524
1525fn any_fixes(ledger: &Ledger, round: u32) -> bool {
1531 ledger
1532 .values()
1533 .any(|e| e.outcome == Settled::Fixed && e.round >= round)
1534}
1535
1536fn unresolved_note(left: usize) -> String {
1541 match left {
1542 1 => "one point left after the closing pass".to_string(),
1543 n => format!("{n} points left after the closing pass"),
1544 }
1545}
1546
1547fn ensure_reviewed_head(pr_number: i64, reviewed_head: &str, live_head: &str) -> Result<()> {
1553 if live_head == reviewed_head {
1554 return Ok(());
1555 }
1556 Err(spar_err!(
1557 "PR #{pr_number} changed from {reviewed_head} to {live_head} after it was reviewed; refusing to approve or merge an unread head"
1558 ))
1559}
1560
1561#[allow(clippy::too_many_arguments)]
1562fn approve(
1563 cfg: &Config,
1564 repo: &Repo,
1565 ctx: &LoopCtx,
1566 state: &mut IssueRun,
1567 ledger: &Ledger,
1568 published_head: &str,
1569 round: u32,
1570 holder: &str,
1571) -> Result<()> {
1572 let live_head = repo.pr_head_oid(ctx.pr_number)?;
1573 ensure_reviewed_head(ctx.pr_number, published_head, &live_head)?;
1574 state.status = Status::Approved;
1575 persist(
1576 repo,
1577 ctx.pr_number,
1578 state,
1579 ledger,
1580 &[],
1581 published_head,
1582 round,
1583 holder,
1584 )?;
1585 let live_head = repo.pr_head_oid(ctx.pr_number)?;
1586 ensure_reviewed_head(ctx.pr_number, published_head, &live_head)?;
1587 post_outcome(repo, ctx.pr_number, state, ledger, Ending::Approved);
1588 if cfg.loop_cfg.drafts == Drafts::UntilApproved && repo.mark_ready(ctx.pr_number) {
1592 log!("{}: out of draft", ctx.label);
1593 }
1594 if cfg.loop_cfg.auto_merge {
1595 let released = ctx.release(repo);
1599 if !released {
1600 log!(
1601 "{}: kept the worktree at {} and will leave its branch in place",
1602 ctx.label,
1603 ctx.work_dir.display()
1604 );
1605 }
1606 repo.merge_pr_at_head(ctx.pr_number, published_head, released)?;
1607 state.status = Status::Merged;
1608 repo.clear_state(ctx.pr_number); log!("{}: merged", ctx.label);
1610 } else {
1611 log!("{}: approved, awaiting human merge", ctx.label);
1612 }
1613 Ok(())
1614}
1615
1616fn approval_stands(blocking: &[Finding], review_wrote: bool) -> bool {
1624 blocking.is_empty() && !review_wrote
1625}
1626
1627fn next_reviewer(cfg: &Config, reviewer: &str, editor: Option<&str>) -> String {
1640 match editor {
1641 Some(editor) => cfg.other(editor),
1642 None => reviewer.to_string(),
1643 }
1644}
1645
1646fn defer_clean_edit_error(
1647 repo: &Repo,
1648 work_dir: &Path,
1649 baseline: &crate::repo::WorktreeBaseline,
1650 error: SparError,
1651) -> Result<SparError> {
1652 if error.kind() == ErrorKind::UncertainWrite {
1653 return Err(error);
1654 }
1655 repo.refuse_changed_attributes(work_dir, baseline)?;
1656 if repo.has_uncommitted_changes(work_dir)? {
1657 return Err(error);
1658 }
1659 repo.refuse_new_ignored_files(work_dir, baseline)?;
1660 repo.refuse_unrepresented_tracked_changes(work_dir, baseline)?;
1661 Ok(error)
1662}
1663
1664fn editor_after(
1672 repo: &Repo,
1673 work_dir: &Path,
1674 before_head: &str,
1675 label: &str,
1676 who: &str,
1677) -> Result<Option<String>> {
1678 if repo.has_uncommitted_changes(work_dir)? {
1679 bail!(
1680 "{label}: {who} left uncommitted files at {}. They were kept and the review did not \
1681 continue.",
1682 work_dir.display()
1683 );
1684 }
1685 let after_head = repo.head_oid_checked(work_dir)?;
1686 if after_head != before_head && !repo.is_ancestor_checked(work_dir, before_head, &after_head)? {
1687 bail!(
1688 "{label}: {who} moved HEAD to {after_head}, but it does not contain the previous \
1689 branch tip {before_head}. The worktree was kept for recovery and nothing was \
1690 published. Restore {before_head} or reapply the intended commits on top of it before \
1691 resuming."
1692 );
1693 }
1694 Ok((after_head != before_head).then(|| who.to_string()))
1695}
1696
1697fn round_window(start_round: u32, budget: u32) -> (u32, u32) {
1703 (start_round, start_round + budget.saturating_sub(1))
1704}
1705
1706fn spent(start_round: u32, last_round: u32) -> (u32, u32) {
1710 (last_round.saturating_sub(start_round) + 1, last_round)
1711}
1712
1713fn exhausted_note(start_round: u32, last_round: u32) -> String {
1714 let (this_run, total) = spent(start_round, last_round);
1715 if this_run == total {
1716 format!("no convergence after {this_run} rounds")
1717 } else {
1718 format!("no convergence after {this_run} more rounds ({total} in total)")
1719 }
1720}
1721
1722#[allow(clippy::too_many_arguments)]
1723fn persist(
1724 repo: &Repo,
1725 pr_number: i64,
1726 state: &IssueRun,
1727 ledger: &Ledger,
1728 open_findings: &[Finding],
1729 published_head: &str,
1730 round: u32,
1731 next_actor: &str,
1732) -> Result<()> {
1733 let payload = PersistedState {
1734 version: STATE_VERSION,
1735 checkpoint: 0,
1736 round,
1737 next_actor: next_actor.to_string(),
1738 status: state.status,
1739 pr_head: published_head.to_string(),
1740 ledger: ledger.clone(),
1741 filed: state.filed.clone(),
1742 open_findings: open_findings.to_vec(),
1743 disputes: state.disputes.clone(),
1744 noted: state.noted.clone(),
1745 };
1746 repo.write_state(pr_number, &payload)
1747}
1748
1749fn settled_block(ledger: &Ledger) -> String {
1754 if ledger.is_empty() {
1755 return String::new();
1756 }
1757 let lines: Vec<String> = ledger
1762 .values()
1763 .filter_map(|e| {
1764 let point = match e.file.trim() {
1765 "" => e.title.clone(),
1766 file => format!("{} ({file})", e.title),
1767 };
1768 match e.outcome {
1769 Settled::Refuted => Some(format!("- {point}: refuted because {}", e.reasoning)),
1770 Settled::Filed => Some(format!(
1771 "- {point}: out of scope here, and filed. {}",
1772 e.reasoning
1773 )),
1774 Settled::Dropped => Some(format!(
1775 "- {point}: out of scope here, and not filed. {}",
1776 e.reasoning
1777 )),
1778 Settled::Fixed => None,
1779 }
1780 })
1781 .collect();
1782 if lines.is_empty() {
1783 return String::new();
1784 }
1785 format!(
1786 "\nThe following points were already raised and settled, by a refutation or by a \
1787 follow-up issue. Treat them as settled. Do not raise them again unless you have new \
1788 evidence:\n{}",
1789 lines.join("\n")
1790 )
1791}
1792
1793fn fixed_lines(ledger: &Ledger, since: u32) -> Vec<String> {
1805 ledger
1806 .values()
1807 .filter(|e| e.outcome == Settled::Fixed && e.round >= since)
1808 .map(|e| {
1809 let claim = match e.reasoning.trim() {
1810 "" => "a committed change claims to address this point",
1811 reasoning => reasoning,
1812 };
1813 match e.file.trim() {
1814 "" => format!("- {}. Recorded answer: {claim}", e.title),
1815 file => format!("- {} ({file}). Recorded answer: {claim}", e.title),
1816 }
1817 })
1818 .collect()
1819}
1820
1821fn answers_block(ledger: &Ledger, round: u32) -> String {
1829 let lines = fixed_lines(ledger, round.saturating_sub(1));
1832 if lines.is_empty() {
1833 return String::new();
1834 }
1835 format!(
1836 "\nThese points were raised on this pull request in earlier rounds and the author says \
1837 it fixed them. The code is on the branch and the claim is the author's:\n{}\n\nCheck the \
1838 answer rather than taking it. If one of them is still not fixed, raise it again under \
1839 the same title, so the run can tell a point that was not answered from a new one.\n",
1840 lines.join("\n")
1841 )
1842}
1843
1844fn round_note(round: u32, last: u32) -> String {
1853 if round < last {
1854 return String::new();
1855 }
1856 "\nThis is the last round in this run that can ask the author for anything. After it, one \
1857 pass reads what landed and the pull request is either signed off or goes to a person with \
1858 what is left. Raise everything you mean to raise now. A point held back for a later round \
1859 does not get one.\n"
1860 .to_string()
1861}
1862
1863#[cfg(test)]
1868fn matching_ledger_key(ledger: &Ledger, title: &str, file: &str) -> Option<String> {
1869 matching_ledger_key_with_fallback(ledger, title, file, true)
1870}
1871
1872fn matching_ledger_key_with_fallback(
1873 ledger: &Ledger,
1874 title: &str,
1875 file: &str,
1876 allow_stable_fallback: bool,
1877) -> Option<String> {
1878 let exact = finding_key(title, file);
1879 if ledger.contains_key(&exact) {
1880 return Some(exact);
1881 }
1882 let legacy = crate::jsonx::finding_key(title, file);
1883 if ledger
1884 .get(&legacy)
1885 .is_some_and(|entry| same_finding_parts(&entry.title, &entry.file, title, file))
1886 {
1887 return Some(legacy);
1888 }
1889 if !allow_stable_fallback {
1890 return None;
1891 }
1892
1893 let stable = stable_finding_key(title, file);
1894 let path = finding_file(file);
1895 let mut matches = ledger
1896 .iter()
1897 .filter(|(saved_key, entry)| {
1898 if stable_finding_key(&entry.title, &entry.file) == stable {
1899 return true;
1900 }
1901 finding_file(&entry.file) == path
1902 && saved_key.as_str() == crate::jsonx::finding_key(title, &entry.file)
1903 })
1904 .map(|(key, _)| key.clone());
1905 let first = matches.next()?;
1906 matches.next().is_none().then_some(first)
1907}
1908
1909fn matching_ledger_entry<'a>(
1910 ledger: &'a Ledger,
1911 title: &str,
1912 file: &str,
1913) -> Option<&'a LedgerEntry> {
1914 matching_ledger_entry_with_fallback(ledger, title, file, true)
1915}
1916
1917fn matching_ledger_entry_with_fallback<'a>(
1918 ledger: &'a Ledger,
1919 title: &str,
1920 file: &str,
1921 allow_stable_fallback: bool,
1922) -> Option<&'a LedgerEntry> {
1923 let key = matching_ledger_key_with_fallback(ledger, title, file, allow_stable_fallback)?;
1924 ledger.get(&key)
1925}
1926
1927fn settle(
1928 ledger: &mut Ledger,
1929 title: &str,
1930 file: &str,
1931 allow_stable_fallback: bool,
1932 entry: LedgerEntry,
1933) {
1934 let old_key = matching_ledger_key_with_fallback(ledger, title, file, allow_stable_fallback);
1935 let reraised = old_key
1936 .as_ref()
1937 .and_then(|key| ledger.get(key))
1938 .map(|entry| entry.reraised)
1939 .unwrap_or(0);
1940 if let Some(old_key) = old_key {
1941 ledger.remove(&old_key);
1942 }
1943 ledger.insert(finding_key(title, file), LedgerEntry { reraised, ..entry });
1944}
1945
1946fn normalise_ledger_keys(ledger: &mut Ledger) {
1948 let mut normalised = Ledger::new();
1949 for (saved_key, mut entry) in std::mem::take(ledger) {
1950 let key = if saved_key.len() == 12
1951 && saved_key
1952 .chars()
1953 .all(|character| character.is_ascii_hexdigit())
1954 {
1955 saved_key
1956 } else {
1957 finding_key(&entry.title, &entry.file)
1958 };
1959 if let Some(previous) = normalised.get_mut(&key) {
1960 let reraised = previous.reraised.max(entry.reraised);
1961 if entry.round >= previous.round {
1962 entry.reraised = reraised;
1963 *previous = entry;
1964 } else {
1965 previous.reraised = reraised;
1966 }
1967 } else {
1968 normalised.insert(key, entry);
1969 }
1970 }
1971 *ledger = normalised;
1972}
1973
1974fn blocking_findings(findings: &[Finding]) -> Vec<Finding> {
1976 let mut kept = Vec::new();
1977 for finding in findings.iter().filter(|finding| finding.blocks()) {
1978 if let Some(existing) = kept
1979 .iter_mut()
1980 .find(|existing| same_finding(existing, finding))
1981 {
1982 *existing = finding.clone();
1983 } else {
1984 kept.push(finding.clone());
1985 }
1986 }
1987 kept
1988}
1989
1990fn matching_finding_index(
1991 findings: &[Finding],
1992 target: &Finding,
1993 allow_stable_fallback: bool,
1994) -> Option<usize> {
1995 if let Some(index) = findings
1996 .iter()
1997 .position(|finding| same_finding(finding, target))
1998 {
1999 return Some(index);
2000 }
2001 if !allow_stable_fallback {
2002 return None;
2003 }
2004
2005 let stable = stable_finding_key(&target.title, &target.file);
2006 let mut matches = findings
2007 .iter()
2008 .enumerate()
2009 .filter(|(_, finding)| stable_finding_key(&finding.title, &finding.file) == stable);
2010 let first = matches.next().map(|(index, _)| index);
2011 first.filter(|_| matches.next().is_none())
2012}
2013
2014fn unique_stable_finding(findings: &[Finding], target: &Finding) -> bool {
2015 let stable = stable_finding_key(&target.title, &target.file);
2016 findings
2017 .iter()
2018 .filter(|finding| stable_finding_key(&finding.title, &finding.file) == stable)
2019 .count()
2020 == 1
2021}
2022
2023fn extend_findings(target: &mut Vec<Finding>, additions: &[Finding]) {
2025 for finding in additions {
2026 if let Some(index) =
2027 matching_finding_index(target, finding, unique_stable_finding(additions, finding))
2028 {
2029 target[index] = finding.clone();
2030 } else {
2031 target.push(finding.clone());
2032 }
2033 }
2034}
2035
2036fn update_open_findings(
2037 open_findings: &mut Vec<Finding>,
2038 current: &[Finding],
2039 answer_stands: bool,
2040) {
2041 if answer_stands {
2042 *open_findings = current.to_vec();
2043 } else {
2044 extend_findings(open_findings, current);
2045 }
2046}
2047
2048fn remove_findings(target: &mut Vec<Finding>, removed: &[Finding]) {
2049 for finding in removed {
2050 if let Some(index) =
2051 matching_finding_index(target, finding, unique_stable_finding(removed, finding))
2052 {
2053 target.remove(index);
2054 }
2055 }
2056}
2057
2058fn ending_without_landing(open_findings: &[Finding]) -> Ending<'_> {
2059 if open_findings.is_empty() {
2060 Ending::Unchanged
2061 } else {
2062 Ending::Unresolved(open_findings)
2063 }
2064}
2065
2066fn closing_effort_round(round: u32) -> u32 {
2067 round.saturating_add(1)
2068}
2069
2070fn closing_next_actor(holder: &str) -> String {
2071 holder.to_string()
2072}
2073
2074fn remember_noted(state: &mut IssueRun, finding: &Finding, allow_stable_fallback: bool) {
2087 if let Some(index) = matching_finding_index(&state.noted, finding, allow_stable_fallback) {
2088 state.noted[index] = finding.clone();
2089 } else {
2090 state.noted.push(finding.clone());
2091 }
2092}
2093
2094fn forget_noted(state: &mut IssueRun, finding: &Finding, allow_stable_fallback: bool) {
2095 if let Some(index) = matching_finding_index(&state.noted, finding, allow_stable_fallback) {
2096 state.noted.remove(index);
2097 }
2098}
2099
2100fn remember_dispute(state: &mut IssueRun, dispute: Dispute, allow_stable_fallback: bool) {
2101 let exact = finding_key(&dispute.title, &dispute.file);
2102 let stable = stable_finding_key(&dispute.title, &dispute.file);
2103 let exact_index = state
2104 .disputes
2105 .iter()
2106 .position(|kept| finding_key(&kept.title, &kept.file) == exact);
2107 let stable_index = if exact_index.is_none() && allow_stable_fallback {
2108 let mut matches = state
2109 .disputes
2110 .iter()
2111 .enumerate()
2112 .filter(|(_, kept)| stable_finding_key(&kept.title, &kept.file) == stable);
2113 let first = matches.next().map(|(index, _)| index);
2114 first.filter(|_| matches.next().is_none())
2115 } else {
2116 None
2117 };
2118 if let Some(index) = exact_index.or(stable_index) {
2119 state.disputes[index] = dispute;
2120 } else {
2121 state.disputes.push(dispute);
2122 }
2123}
2124
2125fn forget_dispute(state: &mut IssueRun, finding: &Finding, allow_stable_fallback: bool) {
2126 let exact = finding_key(&finding.title, &finding.file);
2127 if let Some(index) = state
2128 .disputes
2129 .iter()
2130 .position(|kept| finding_key(&kept.title, &kept.file) == exact)
2131 {
2132 state.disputes.remove(index);
2133 return;
2134 }
2135 if !allow_stable_fallback {
2136 return;
2137 }
2138
2139 let stable = stable_finding_key(&finding.title, &finding.file);
2140 let mut matches = state
2141 .disputes
2142 .iter()
2143 .enumerate()
2144 .filter(|(_, kept)| stable_finding_key(&kept.title, &kept.file) == stable);
2145 let first = matches.next().map(|(index, _)| index);
2146 if let Some(index) = first.filter(|_| matches.next().is_none()) {
2147 state.disputes.remove(index);
2148 }
2149}
2150
2151#[cfg(test)]
2152fn record_nonblocking_outcome(state: &mut IssueRun, finding: &Finding, outcome: Option<&Followup>) {
2153 record_nonblocking_outcome_with_match(state, finding, outcome, true);
2154}
2155
2156fn record_nonblocking_outcome_with_match(
2157 state: &mut IssueRun,
2158 finding: &Finding,
2159 outcome: Option<&Followup>,
2160 allow_stable_fallback: bool,
2161) {
2162 forget_dispute(state, finding, allow_stable_fallback);
2163 if let Some(Followup::Recorded(url)) = outcome {
2164 if !state.filed.iter().any(|filed| filed == url) {
2165 state.filed.push(url.clone());
2166 }
2167 forget_noted(state, finding, allow_stable_fallback);
2168 } else {
2169 remember_noted(state, finding, allow_stable_fallback);
2170 }
2171}
2172
2173fn record_own_fixes(blocking: &[Finding], ledger: &mut Ledger, state: &mut IssueRun, round: u32) {
2180 for finding in blocking {
2181 let allow_stable_fallback = unique_stable_finding(blocking, finding);
2182 settle(
2183 ledger,
2184 &finding.title,
2185 &finding.file,
2186 allow_stable_fallback,
2187 LedgerEntry {
2188 title: finding.title.clone(),
2189 file: finding.file.clone(),
2190 reasoning: "a committed change was made for this point".to_string(),
2191 round,
2192 reraised: 0,
2193 outcome: Settled::Fixed,
2194 },
2195 );
2196 forget_noted(state, finding, allow_stable_fallback);
2197 forget_dispute(state, finding, allow_stable_fallback);
2198 }
2199}
2200
2201fn check_relitigation(ledger: &mut Ledger, blocking: &[Finding], state: &mut IssueRun) -> bool {
2204 let mut escalate = false;
2205 let mut counted: BTreeSet<String> = BTreeSet::new();
2211 for finding in blocking {
2212 let allow_stable_fallback = unique_stable_finding(blocking, finding);
2213 let Some(key) = matching_ledger_key_with_fallback(
2214 ledger,
2215 &finding.title,
2216 &finding.file,
2217 allow_stable_fallback,
2218 ) else {
2219 continue;
2220 };
2221 if !counted.insert(key.clone()) {
2222 continue;
2223 }
2224 if let Some(entry) = ledger.get_mut(&key) {
2225 entry.reraised += 1;
2226 if entry.outcome == Settled::Refuted {
2227 remember_dispute(
2228 state,
2229 Dispute {
2230 title: finding.title.clone(),
2231 file: finding.file.clone(),
2232 reasoning: entry.reasoning.clone(),
2233 },
2234 allow_stable_fallback,
2235 );
2236 }
2237 if entry.reraised >= 2 {
2238 state.notes.push(format!(
2239 "'{}' {}",
2240 finding.title,
2241 why_escalated(entry.outcome)
2242 ));
2243 escalate = true;
2244 }
2245 }
2246 }
2247 escalate
2248}
2249
2250fn why_escalated(outcome: Settled) -> &'static str {
2257 match outcome {
2258 Settled::Fixed => "was fixed twice and raised again; escalating.",
2259 _ => "was settled and re-raised twice; escalating.",
2260 }
2261}
2262
2263fn normalise(text: &str) -> String {
2264 text.to_lowercase()
2265 .chars()
2266 .filter(|c| c.is_ascii_alphanumeric() || c.is_whitespace())
2267 .collect::<String>()
2268 .split_whitespace()
2269 .collect::<Vec<_>>()
2270 .join(" ")
2271}
2272
2273pub(crate) fn same_point(a: &str, b: &str) -> bool {
2275 normalise(a) == normalise(b)
2276}
2277
2278pub(crate) fn same_finding(a: &Finding, b: &Finding) -> bool {
2279 same_finding_parts(&a.title, &a.file, &b.title, &b.file)
2280}
2281
2282pub(crate) fn same_finding_parts(a_title: &str, a_file: &str, b_title: &str, b_file: &str) -> bool {
2283 finding_key(a_title, a_file) == finding_key(b_title, b_file)
2284}
2285
2286fn disposition_matches(finding: &Finding, disposition: &Disposition) -> bool {
2287 same_point(&finding.title, &disposition.title) && finding.file.trim() == disposition.file.trim()
2288}
2289
2290fn stable_disposition_matches(finding: &Finding, disposition: &Disposition) -> bool {
2291 stable_finding_key(&finding.title, &finding.file)
2292 == stable_finding_key(&disposition.title, &disposition.file)
2293}
2294
2295fn sole_disposition_match<'a>(
2296 finding: &Finding,
2297 dispositions: &'a [Disposition],
2298 matches: impl Fn(&Finding, &Disposition) -> bool,
2299) -> std::result::Result<Option<(usize, &'a Disposition)>, &'static str> {
2300 let mut matched = dispositions
2301 .iter()
2302 .enumerate()
2303 .filter(|(_, disposition)| matches(finding, disposition));
2304 let first = matched.next();
2305 if matched.next().is_some() {
2306 return Err("more than one matching disposition");
2307 }
2308 Ok(first)
2309}
2310
2311fn matching_disposition<'a>(
2316 finding: &Finding,
2317 findings: &[Finding],
2318 dispositions: &'a [Disposition],
2319) -> std::result::Result<(usize, &'a Disposition), &'static str> {
2320 if let Some(exact) = sole_disposition_match(finding, dispositions, disposition_matches)? {
2321 return Ok(exact);
2322 }
2323 if unique_stable_finding(findings, finding) {
2324 if let Some(stable) =
2325 sole_disposition_match(finding, dispositions, stable_disposition_matches)?
2326 {
2327 return Ok(stable);
2328 }
2329 }
2330 Err("no matching disposition")
2331}
2332
2333fn fixed_disposition_resolves(committed: bool) -> bool {
2334 committed
2335}
2336
2337#[allow(clippy::too_many_arguments)]
2338fn apply_dispositions(
2339 repo: &Repo,
2340 cfg: &Config,
2341 response: &ResponseDoc,
2342 blocking: &[Finding],
2343 ledger: &mut Ledger,
2344 state: &mut IssueRun,
2345 round: u32,
2346 subject: i64,
2347 pr_number: i64,
2348 author: &str,
2349 committed: bool,
2350) -> Vec<Finding> {
2351 let mut fixed = Vec::new();
2352 let mut refuted = Vec::new();
2353 let mut filed = Vec::new();
2354 let mut unresolved = Vec::new();
2355 let mut used = vec![false; response.dispositions.len()];
2356
2357 for source in blocking {
2358 let (index, d) = match matching_disposition(source, blocking, &response.dispositions) {
2359 Ok((index, disposition)) if !used[index] => (index, disposition),
2360 Ok(_) => {
2361 logwarn!(
2362 "'{}' has more than one matching disposition, so it stays open",
2363 source.title
2364 );
2365 unresolved.push(source.clone());
2366 continue;
2367 }
2368 Err(reason) => {
2369 logwarn!("'{}' has {reason}, so it stays open", source.title);
2370 unresolved.push(source.clone());
2371 continue;
2372 }
2373 };
2374 used[index] = true;
2375 let file = source.file.clone();
2376 let canonical = source.title.as_str();
2380 let title = style::title(canonical, &repo.style);
2381 let located_title = match file.trim() {
2382 "" => title.clone(),
2383 location => format!("{title} ({location})"),
2384 };
2385 let allow_stable_fallback = unique_stable_finding(blocking, source);
2386
2387 match d.action {
2388 Action::Refuted => {
2389 let reasoning = style::summary(&d.reasoning, &repo.style);
2390 settle(
2391 ledger,
2392 canonical,
2393 &file,
2394 allow_stable_fallback,
2395 LedgerEntry {
2396 title: canonical.to_string(),
2397 file: file.clone(),
2398 reasoning: reasoning.clone(),
2399 round,
2400 reraised: 0,
2401 outcome: Settled::Refuted,
2402 },
2403 );
2404 remember_dispute(
2405 state,
2406 Dispute {
2407 title: canonical.to_string(),
2408 file: file.clone(),
2409 reasoning: reasoning.clone(),
2410 },
2411 allow_stable_fallback,
2412 );
2413 forget_noted(state, source, allow_stable_fallback);
2414 refuted.push(format!("{located_title}. {reasoning}"));
2415 }
2416 Action::FiledIssue => {
2417 let new_title = d
2418 .new_issue_title
2419 .clone()
2420 .filter(|t| !t.trim().is_empty())
2421 .unwrap_or_else(|| d.title.clone());
2422 let new_body = d
2423 .new_issue_body
2424 .clone()
2425 .filter(|b| !b.trim().is_empty())
2426 .unwrap_or_else(|| d.reasoning.clone());
2427 let recorded = file_followup(repo, &new_title, &new_body, subject, cfg, state);
2428 if let Some(url) = recorded.url() {
2429 state.filed.push(url.to_string());
2430 filed.push(url.to_string());
2431 }
2432 let Some((outcome, reasoning)) =
2441 filed_entry(&recorded, &style::summary(&d.reasoning, &repo.style))
2442 else {
2443 logwarn!(
2444 "'{title}' was not recorded anywhere, so it stays open for the next round"
2445 );
2446 unresolved.push(source.clone());
2447 continue;
2448 };
2449 settle(
2450 ledger,
2451 canonical,
2452 &file,
2453 allow_stable_fallback,
2454 LedgerEntry {
2455 title: canonical.to_string(),
2456 file: file.clone(),
2457 reasoning,
2458 round,
2459 reraised: 0,
2460 outcome,
2461 },
2462 );
2463 if outcome == Settled::Dropped {
2464 remember_noted(state, source, allow_stable_fallback);
2465 } else {
2466 forget_noted(state, source, allow_stable_fallback);
2467 }
2468 forget_dispute(state, source, allow_stable_fallback);
2469 }
2470 Action::Fixed => {
2471 if fixed_disposition_resolves(committed) {
2486 settle(
2487 ledger,
2488 canonical,
2489 &file,
2490 allow_stable_fallback,
2491 LedgerEntry {
2492 title: canonical.to_string(),
2493 file: file.clone(),
2494 reasoning: style::summary(&d.reasoning, &repo.style),
2495 round,
2496 reraised: 0,
2497 outcome: Settled::Fixed,
2498 },
2499 );
2500 forget_noted(state, source, allow_stable_fallback);
2501 forget_dispute(state, source, allow_stable_fallback);
2502 fixed.push(located_title);
2503 } else {
2504 unresolved.push(source.clone());
2505 }
2506 }
2507 }
2508 }
2509
2510 for (index, disposition) in response.dispositions.iter().enumerate() {
2511 if !used[index] {
2512 logwarn!(
2513 "ignoring an unmatched or duplicate disposition for '{}' ({})",
2514 disposition.title,
2515 disposition.file
2516 );
2517 }
2518 }
2519
2520 if repo.style.pr_comments == PrComments::Rounds {
2521 let comment = disposition_comment(author, response, &fixed, &refuted, &filed, &repo.style);
2522 if let Some(text) = comment {
2523 if let Err(e) = repo.comment_pr(pr_number, &text) {
2524 logdim!("could not post the disposition comment: {e}");
2525 }
2526 }
2527 }
2528 unresolved
2529}
2530
2531fn filed_entry(recorded: &Followup, reasoning: &str) -> Option<(Settled, String)> {
2539 let (outcome, tail) = match recorded {
2540 Followup::Recorded(reference) => (
2541 Settled::Filed,
2542 format!("Tracked in {}.", as_reference(reference)),
2543 ),
2544 Followup::Covered(reference) => (
2545 Settled::Filed,
2546 format!("Already covered by {}.", as_reference(reference)),
2547 ),
2548 Followup::Dropped(why) => (Settled::Dropped, format!("Not filed anywhere: {why}.")),
2549 Followup::Failed => return None,
2550 };
2551 let reasoning = match reasoning.trim() {
2552 "" => tail,
2553 said => format!("{said} {tail}"),
2554 };
2555 Some((outcome, reasoning))
2556}
2557
2558fn external_followup_write_paused(destination: Followups, state: &IssueRun) -> bool {
2565 destination == Followups::Issues && state.followup_writes_uncertain
2566}
2567
2568fn failed_followup(state: &mut IssueRun, error: &SparError) -> Followup {
2569 if error.kind() == ErrorKind::UncertainWrite {
2570 state.followup_writes_uncertain = true;
2571 if !state
2572 .notes
2573 .iter()
2574 .any(|note| note.contains("external follow-up writes were paused"))
2575 {
2576 state.notes.push(
2577 "An external follow-up write could not be verified, so further external \
2578 follow-up writes were paused for this run. Inspect recent issues and comments \
2579 before trying them again."
2580 .to_string(),
2581 );
2582 }
2583 }
2584 Followup::Failed
2585}
2586
2587pub fn file_followup(
2598 repo: &Repo,
2599 title: &str,
2600 body: &str,
2601 source: i64,
2602 cfg: &Config,
2603 state: &mut IssueRun,
2604) -> Followup {
2605 if repo.followups == Followups::None {
2606 return Followup::Dropped("follow-ups are off for this repository");
2607 }
2608 if external_followup_write_paused(repo.followups, state) {
2609 logdim!(
2610 "not attempting another external follow-up write after an earlier result could not \
2611 be verified"
2612 );
2613 return Followup::Failed;
2614 }
2615 if state.filed.len() >= cfg.loop_cfg.max_followups {
2618 logwarn!(
2619 "already recorded {} follow-ups, not recording '{}'. Raise max_followups if you want \
2620 them all.",
2621 state.filed.len(),
2622 style::title(title, &repo.style)
2623 );
2624 return Followup::Dropped("this run had already recorded as many follow-ups as it may");
2625 }
2626 let title = match repo.clean_followup_title(title) {
2633 Ok(title) => title,
2634 Err(e) => {
2635 logdim!("could not clean a follow-up title: {e}");
2636 return Followup::Failed;
2637 }
2638 };
2639 if title.trim().is_empty() {
2640 logdim!("nothing left of a follow-up title after cleaning it");
2641 return Followup::Failed;
2642 }
2643 let body = format!(
2646 "{}\n\nFound while working on #{source}.",
2647 style::issue_body(body, &repo.style)
2648 );
2649
2650 if repo.followups == Followups::Local {
2651 return repo.append_local_followup(&title, &body);
2652 }
2653
2654 match file_as_issue(repo, &title, &body) {
2655 Ok(filed) => filed.into(),
2656 Err(e) => {
2657 logdim!("could not file a follow-up for '{title}': {e}");
2658 failed_followup(state, &e)
2659 }
2660 }
2661}
2662
2663#[derive(Debug, Clone)]
2665pub enum Filed {
2666 Opened(i64, String),
2668 AddedTo(i64, String),
2670 Covered(i64, String),
2672 AlreadyClosed(i64, String),
2674}
2675
2676impl From<Filed> for Followup {
2677 fn from(filed: Filed) -> Self {
2678 match filed {
2679 Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => {
2680 Followup::Recorded(url)
2681 }
2682 Filed::AlreadyClosed(_, url) => Followup::Covered(url),
2686 }
2687 }
2688}
2689
2690impl Filed {
2691 pub fn url(&self) -> Option<&str> {
2692 match self {
2693 Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => Some(url),
2694 Filed::AlreadyClosed(_, _) => None,
2697 }
2698 }
2699
2700 pub fn issue(&self) -> i64 {
2703 match self {
2704 Filed::Opened(n, _)
2705 | Filed::AddedTo(n, _)
2706 | Filed::Covered(n, _)
2707 | Filed::AlreadyClosed(n, _) => *n,
2708 }
2709 }
2710
2711 pub fn number(&self) -> Option<i64> {
2713 match self {
2714 Filed::Opened(n, _) | Filed::AddedTo(n, _) | Filed::Covered(n, _) => Some(*n),
2715 Filed::AlreadyClosed(_, _) => None,
2716 }
2717 }
2718
2719 pub fn note(&self) -> String {
2721 match self {
2722 Filed::Opened(n, _) => format!("#{n}"),
2723 Filed::AddedTo(n, _) => format!("added to #{n}"),
2724 Filed::Covered(n, _) => format!("#{n} already says this"),
2725 Filed::AlreadyClosed(n, _) => format!("#{n} covers it and is closed"),
2726 }
2727 }
2728
2729 pub fn describe(&self, title: &str) -> String {
2730 let title = style::clip(title.trim(), 80);
2731 match self {
2732 Filed::Opened(n, _) => format!("filed #{n}: {title}"),
2733 Filed::AddedTo(n, _) => format!("added to #{n}: {title}"),
2734 Filed::Covered(n, _) => format!("#{n} already says this: {title}"),
2735 Filed::AlreadyClosed(n, _) => format!("#{n} covers it and is closed: {title}"),
2736 }
2737 }
2738}
2739
2740pub fn file_as_issue(repo: &Repo, title: &str, body: &str) -> Result<Filed> {
2752 file_as_issue_apart_from(repo, title, body, None)
2753}
2754
2755pub fn file_as_issue_apart_from(
2761 repo: &Repo,
2762 title: &str,
2763 body: &str,
2764 apart_from: Option<i64>,
2765) -> Result<Filed> {
2766 let title = repo.record_failed_write(repo.clean_title(title))?;
2767 if title.trim().is_empty() {
2768 return repo.record_failed_write(Err(spar_err!(
2769 "nothing left of the title after cleaning it"
2770 )));
2771 }
2772 let issue_body = repo.record_failed_write(repo.clean_issue_body(body))?;
2773 let exact =
2774 repo.record_failed_write(repo.try_exact_issue_apart_from(&title, &issue_body, apart_from))?;
2775 if let Some(existing) = exact {
2776 return Ok(if existing.open {
2777 Filed::Covered(existing.number, existing.url)
2778 } else {
2779 Filed::AlreadyClosed(existing.number, existing.url)
2780 });
2781 }
2782 let similar = repo.record_failed_write(repo.try_find_similar_issue_apart_from(
2783 &title,
2784 &issue_body,
2785 apart_from,
2786 ))?;
2787 if let Some(existing) = similar {
2788 let known = format!("{} {}", existing.title, existing.body);
2789 if !existing.open {
2790 return Ok(Filed::AlreadyClosed(existing.number, existing.url));
2791 }
2792 if crate::textsim::adds_information(&issue_body, &known) {
2793 repo.comment_issue(existing.number, &issue_body)?;
2794 return Ok(Filed::AddedTo(existing.number, existing.url));
2795 }
2796 return Ok(Filed::Covered(existing.number, existing.url));
2797 }
2798 let url = repo.create_issue_apart_from(&title, &issue_body, apart_from)?;
2799 let number = filed_issue_number(&url)
2800 .ok_or_else(|| spar_err!("filed an issue but could not read its number from {url}"))?;
2801 Ok(Filed::Opened(number, url))
2802}
2803
2804fn file_out_of_scope(
2805 repo: &Repo,
2806 findings: &[Finding],
2807 subject: i64,
2808 state: &mut IssueRun,
2809 cfg: &Config,
2810) {
2811 for finding in findings.iter().filter(|f| !f.in_scope) {
2812 let body = issue_report(finding);
2813 let recorded = file_followup(repo, &finding.title, &body, subject, cfg, state);
2814 if finding.severity != Severity::Nit || matches!(recorded, Followup::Recorded(_)) {
2815 record_nonblocking_outcome_with_match(
2816 state,
2817 finding,
2818 Some(&recorded),
2819 unique_stable_finding(findings, finding),
2820 );
2821 }
2822 }
2823}
2824
2825pub fn issue_report(finding: &Finding) -> String {
2832 let sections = finding.report_sections();
2833 if sections.is_empty() {
2834 return finding.detail.clone();
2835 }
2836 let mut out: Vec<String> = sections
2837 .iter()
2838 .map(|(heading, text)| format!("## {heading}\n\n{text}"))
2839 .collect();
2840 if !finding.detail.trim().is_empty()
2843 && !sections
2844 .iter()
2845 .any(|(_, text)| crate::textsim::same_point(text, &finding.detail))
2846 {
2847 out.insert(0, finding.detail.trim().to_string());
2848 }
2849 out.join("\n\n")
2850}
2851
2852fn file_nonblocking(
2859 repo: &Repo,
2860 findings: &[Finding],
2861 subject: i64,
2862 state: &mut IssueRun,
2863 cfg: &Config,
2864) {
2865 for finding in findings {
2866 if !finding.in_scope || finding.severity == Severity::Blocking {
2867 continue;
2868 }
2869 let should_file = match finding.severity {
2870 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking,
2871 Severity::Nit => cfg.loop_cfg.file_nits,
2872 Severity::Blocking => false,
2873 };
2874 if !should_file {
2875 if finding.severity == Severity::NonBlocking {
2876 record_nonblocking_outcome_with_match(
2877 state,
2878 finding,
2879 None,
2880 unique_stable_finding(findings, finding),
2881 );
2882 }
2883 continue;
2884 }
2885 let recorded = file_followup(repo, &finding.title, &finding.detail, subject, cfg, state);
2886 if finding.severity == Severity::NonBlocking {
2887 record_nonblocking_outcome_with_match(
2888 state,
2889 finding,
2890 Some(&recorded),
2891 unique_stable_finding(findings, finding),
2892 );
2893 } else if let Some(url) = recorded.url() {
2894 state.filed.push(url.to_string());
2895 }
2896 }
2897}
2898
2899fn bullets(lines: &[String]) -> String {
2909 lines
2910 .iter()
2911 .map(|l| format!("- {l}"))
2912 .collect::<Vec<_>>()
2913 .join("\n")
2914}
2915
2916fn located(finding: &Finding, style: &Style) -> String {
2917 let title = style::title(&finding.title, style);
2918 match finding.where_at() {
2919 "general" => title,
2920 file => format!("{title} ({file})"),
2921 }
2922}
2923
2924pub enum Ending<'a> {
2926 Approved,
2928 OutOfRounds,
2931 Unchanged,
2934 Unresolved(&'a [Finding]),
2937 Deadlocked(&'a [Finding]),
2940}
2941
2942#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2943enum OutcomeSink {
2944 PullRequest,
2945 Terminal,
2946}
2947
2948fn outcome_sink(mode: PrComments) -> OutcomeSink {
2949 match mode {
2950 PrComments::Outcome | PrComments::Rounds => OutcomeSink::PullRequest,
2951 PrComments::None => OutcomeSink::Terminal,
2952 }
2953}
2954
2955fn emit_outcome(repo: &Repo, pr_number: i64, text: &str) {
2956 if outcome_sink(repo.style.pr_comments) == OutcomeSink::Terminal {
2957 println!("\n{text}\n");
2958 return;
2959 }
2960 if let Err(e) = repo.comment_pr(pr_number, text) {
2961 logdim!("could not post the outcome comment: {e}");
2962 println!("\n{text}\n");
2963 }
2964}
2965
2966pub fn post_outcome(
2978 repo: &Repo,
2979 pr_number: i64,
2980 state: &IssueRun,
2981 ledger: &Ledger,
2982 ending: Ending<'_>,
2983) {
2984 let Some(text) = outcome_comment(state, ledger, &ending, &repo.style) else {
2985 return;
2986 };
2987 emit_outcome(repo, pr_number, &text);
2988}
2989
2990fn post_unread_outcome(
2991 repo: &Repo,
2992 pr_number: i64,
2993 state: &IssueRun,
2994 ledger: &Ledger,
2995 open_findings: &[Finding],
2996) {
2997 let Some(text) = outcome_comment_with_unread(
2998 state,
2999 ledger,
3000 &Ending::OutOfRounds,
3001 open_findings,
3002 &repo.style,
3003 ) else {
3004 return;
3005 };
3006 emit_outcome(repo, pr_number, &text);
3007}
3008
3009fn settled_as(finding: &Finding, state: &IssueRun, ledger: &Ledger) -> Option<(Settled, String)> {
3012 if let Some(d) = state
3013 .disputes
3014 .iter()
3015 .find(|d| same_finding_parts(&d.title, &d.file, &finding.title, &finding.file))
3016 {
3017 if !d.reasoning.trim().is_empty() {
3018 return Some((Settled::Refuted, d.reasoning.clone()));
3019 }
3020 }
3021 matching_ledger_entry(ledger, &finding.title, &finding.file)
3022 .filter(|entry| !entry.reasoning.trim().is_empty())
3023 .map(|entry| (entry.outcome, entry.reasoning.clone()))
3024}
3025
3026pub fn filed_issue_number(filed: &str) -> Option<i64> {
3031 filed
3032 .rsplit('/')
3033 .next()
3034 .and_then(|tail| tail.parse::<i64>().ok())
3035 .filter(|n| *n > 0)
3036}
3037
3038fn as_reference(url: &str) -> String {
3039 match url.rsplit('/').next().and_then(|n| n.parse::<u64>().ok()) {
3040 Some(number) => format!("#{number}"),
3041 None => url.to_string(),
3042 }
3043}
3044
3045pub fn outcome_comment(
3046 state: &IssueRun,
3047 ledger: &Ledger,
3048 ending: &Ending<'_>,
3049 style: &Style,
3050) -> Option<String> {
3051 outcome_comment_with_unread(state, ledger, ending, &[], style)
3052}
3053
3054fn outcome_comment_with_unread(
3055 state: &IssueRun,
3056 ledger: &Ledger,
3057 ending: &Ending<'_>,
3058 unread_open: &[Finding],
3059 style: &Style,
3060) -> Option<String> {
3061 let mut out: Vec<String> = Vec::new();
3062 let mut already: Vec<(String, String)> = Vec::new();
3065
3066 match ending {
3067 Ending::Approved => {
3068 if state.disputes.is_empty() && state.filed.is_empty() && state.noted.is_empty() {
3069 return None;
3075 }
3076 out.push("Reviewed, nothing blocking a merge.".into());
3077 }
3078 Ending::OutOfRounds => {
3079 out.push(
3080 "Not signed off: the last round of fixes was pushed but has not been reviewed."
3081 .into(),
3082 );
3083 if !unread_open.is_empty() {
3084 let lines: Vec<String> = unread_open
3085 .iter()
3086 .map(|finding| {
3087 already.push((finding.title.clone(), finding.file.clone()));
3088 format!(
3089 "{}. {}",
3090 located(finding, style),
3091 style::sentence(&finding.detail, style)
3092 )
3093 })
3094 .collect();
3095 out.push("These points were already open:".into());
3096 out.push(bullets(&lines));
3097 }
3098 }
3099 Ending::Unchanged => out.push(
3103 "Not signed off: the last round changed nothing, so the branch is the one that was \
3104 already reviewed."
3105 .into(),
3106 ),
3107 Ending::Unresolved(points) => {
3108 let lines: Vec<String> = points
3109 .iter()
3110 .map(|f| {
3111 already.push((f.title.clone(), f.file.clone()));
3112 format!(
3113 "{}. {}",
3114 located(f, style),
3115 style::sentence(&f.detail, style)
3116 )
3117 })
3118 .collect();
3119 out.push("Not signed off. These points are still open:".into());
3120 out.push(bullets(&lines));
3121 }
3122 Ending::Deadlocked(points) => {
3123 let lines: Vec<String> = points
3129 .iter()
3130 .map(|f| {
3131 let where_at = match f.where_at() {
3132 "general" => String::new(),
3133 file => format!(" ({file})"),
3134 };
3135 let title = style::title(&f.title, style);
3136 already.push((f.title.clone(), f.file.clone()));
3137 match settled_as(f, state, ledger) {
3138 Some((Settled::Refuted, reason)) => format!(
3139 "{title}{where_at}. Refuted as: {}",
3140 style::summary(&reason, style)
3141 ),
3142 Some((Settled::Filed, reason)) => format!(
3143 "{title}{where_at}. Filed as out of scope: {}",
3144 style::summary(&reason, style)
3145 ),
3146 Some((Settled::Dropped, reason)) => format!(
3149 "{title}{where_at}. Out of scope here, and not filed: {}",
3150 style::summary(&reason, style)
3151 ),
3152 Some((Settled::Fixed, reason)) => format!(
3158 "{title}{where_at}. Fixed and raised again. Recorded answer: {}",
3159 style::summary(&reason, style)
3160 ),
3161 None => format!("{title}{where_at}"),
3162 }
3163 })
3164 .collect();
3165 out.push("Needs your decision. The reviewers could not settle this:".into());
3166 out.push(bullets(&lines));
3167 }
3168 }
3169
3170 let disputes: Vec<&crate::model::Dispute> = state
3171 .disputes
3172 .iter()
3173 .filter(|d| {
3174 !already
3175 .iter()
3176 .any(|(title, file)| same_finding_parts(title, file, &d.title, &d.file))
3177 })
3178 .collect();
3179 if !disputes.is_empty() {
3180 let lines: Vec<String> = disputes
3183 .iter()
3184 .map(|d| {
3185 let title = style::title(&d.title, style);
3186 let title = if d.file.trim().is_empty() {
3187 title
3188 } else {
3189 format!("{title} ({})", d.file.trim())
3190 };
3191 format!("{}. {}", title, style::sentence(&d.reasoning, style))
3192 })
3193 .collect();
3194 out.push(format!("Raised and refuted:\n{}", bullets(&lines)));
3195 }
3196
3197 if !state.noted.is_empty() {
3198 let lines: Vec<String> = state
3202 .noted
3203 .iter()
3204 .filter(|f| {
3205 !already
3206 .iter()
3207 .any(|(title, file)| same_finding_parts(title, file, &f.title, &f.file))
3208 })
3209 .map(|f| located(f, style))
3210 .collect();
3211 if !lines.is_empty() {
3212 out.push(format!("Noted, not blocking:\n{}", bullets(&lines)));
3213 }
3214 }
3215
3216 if !state.filed.is_empty() {
3217 let refs: Vec<String> = state.filed.iter().map(|u| as_reference(u)).collect();
3218 out.push(format!("Filed separately: {}", refs.join(", ")));
3219 }
3220
3221 Some(out.join("\n\n"))
3222}
3223
3224#[allow(clippy::too_many_arguments)]
3233fn close_prompt(
3234 base: &str,
3235 number: i64,
3236 title: &str,
3237 from: &str,
3238 landed: Option<&[String]>,
3239 ledger: &Ledger,
3240 open_findings: &[Finding],
3241 round: u32,
3242) -> String {
3243 let landed = match landed {
3244 Some([]) => "\nNothing landed after the last round of review. What it asked for was \
3245 answered in words rather than in code, so the branch in front of you is the \
3246 branch that was already read.\n"
3247 .to_string(),
3248 Some(lines) => format!(
3249 "\nThis landed after the last round of review, and nobody has read it:\n{}\n\nRead it \
3250 first with `git diff {from}..HEAD`, then inspect the full branch with `git diff \
3251 {base}...HEAD`.\n",
3252 lines
3253 .iter()
3254 .map(|l| format!("- {l}"))
3255 .collect::<Vec<_>>()
3256 .join("\n")
3257 ),
3258 None => format!(
3259 "\nThe commits on this branch were rewritten after the last round of review, so the \
3260 harness cannot say which of them are new. Inspect the full branch with `git diff \
3261 {base}...HEAD`.\n"
3262 ),
3263 };
3264 CLOSE_PROMPT
3265 .replace("{number}", &number.to_string())
3266 .replace("{title}", title)
3267 .replace("{base}", base)
3268 .replace("{landed}", &landed)
3269 .replace("{open}", &open_findings_block(open_findings))
3270 .replace("{answers}", &closing_answers(ledger, round))
3271 .replace("{settled}", &settled_block(ledger))
3272}
3273
3274fn open_findings_block(findings: &[Finding]) -> String {
3275 if findings.is_empty() {
3276 return String::new();
3277 }
3278 format!(
3279 "\nThese blocking findings were left open by an earlier response. Recheck each one:\n{}\n\
3280 \nIf one still blocks, return it under the same title and file. Omission means you checked \
3281 it and found that it no longer blocks.\n",
3282 findings_for_prompt(findings)
3283 )
3284}
3285
3286fn closing_answers(ledger: &Ledger, round: u32) -> String {
3291 let lines = fixed_lines(ledger, round);
3292 if lines.is_empty() {
3293 return String::new();
3294 }
3295 format!(
3296 "\nThese points were raised on this pull request and the author says it fixed them. \
3297 Nobody has checked that:\n{}\n",
3298 lines.join("\n")
3299 )
3300}
3301
3302fn review_prompt(
3307 base: &str,
3308 number: i64,
3309 title: &str,
3310 ledger: &Ledger,
3311 open_findings: &[Finding],
3312 round: u32,
3313 last: u32,
3314) -> String {
3315 REVIEW_PROMPT
3316 .replace("{base}", base)
3317 .replace("{number}", &number.to_string())
3318 .replace("{title}", title)
3319 .replace("{open}", &open_findings_block(open_findings))
3320 .replace("{answers}", &answers_block(ledger, round))
3321 .replace("{settled}", &settled_block(ledger))
3322 .replace("{round}", &round_note(round, last))
3323}
3324
3325fn implement_prompt(number: i64, title: &str, url: &str, body: &str) -> String {
3333 IMPLEMENT_PROMPT
3334 .replace("{number}", &number.to_string())
3335 .replace("{title}", title)
3336 .replace("{url}", url)
3337 .replace("{body}", body)
3338}
3339
3340pub fn pr_body(issue: i64, work: &Implementation, style: &Style) -> String {
3350 let mut parts = vec![format!("Closes #{issue}")];
3351
3352 for lead in [&work.summary, &work.problem] {
3353 let text = style::sentence(lead, style);
3354 if !text.is_empty() {
3355 parts.push(text);
3356 }
3357 }
3358 parts.extend(section("What changed", &work.changes, style));
3359 parts.extend(section("How to test", &work.testing, style));
3360
3361 let notes = style::sentence(work.notes.as_deref().unwrap_or_default(), style);
3362 if !notes.is_empty() {
3363 parts.push(format!("## Notes\n\n{notes}"));
3364 }
3365
3366 style::body(&parts.join("\n\n"), style)
3367}
3368
3369fn section(heading: &str, lines: &[String], style: &Style) -> Option<String> {
3376 let items: Vec<String> = lines
3377 .iter()
3378 .map(|line| style::summary(line, style))
3379 .filter(|line| !line.is_empty())
3380 .collect();
3381 if items.is_empty() {
3382 return None;
3383 }
3384 Some(format!("## {heading}\n\n{}", bullets(&items)))
3385}
3386
3387pub fn from_commits(repo: &Repo, work_dir: &Path, base: &str) -> Implementation {
3395 Implementation {
3396 changes: repo.commit_subjects(work_dir, "HEAD", base),
3397 notes: Some(
3398 "The implement call failed after these commits were made, so this body is assembled \
3399 from their messages rather than written by their author. Read the diff."
3400 .to_string(),
3401 ),
3402 ..Implementation::default()
3403 }
3404}
3405
3406fn no_pr_note(work: &Implementation, style: &Style) -> String {
3413 let reason = style::sentence(&work.reason, style);
3414 if !reason.is_empty() {
3415 return reason;
3416 }
3417 if work.not_worth_doing {
3418 "Left alone after reading the code, with no reason given.".to_string()
3419 } else {
3420 "Nothing was committed, so there is nothing to review.".to_string()
3421 }
3422}
3423
3424pub fn review_comment(holder: &str, round: u32, review: &Review, style: &Style) -> String {
3428 let by = |severity: Severity| -> Vec<&Finding> {
3429 review
3430 .findings
3431 .iter()
3432 .filter(|f| f.severity == severity && f.in_scope)
3433 .collect()
3434 };
3435 let blocking = by(Severity::Blocking);
3436 let non_blocking = by(Severity::NonBlocking);
3437 let nits = by(Severity::Nit);
3438 let out_of_scope: Vec<&Finding> = review.findings.iter().filter(|f| !f.in_scope).collect();
3439
3440 let mut counts = Vec::new();
3441 if !blocking.is_empty() {
3442 counts.push(format!("{} blocking", blocking.len()));
3443 }
3444 if !non_blocking.is_empty() {
3445 counts.push(format!("{} non-blocking", non_blocking.len()));
3446 }
3447 if !nits.is_empty() {
3448 counts.push(format!("{} nit", nits.len()));
3449 }
3450 if !out_of_scope.is_empty() {
3451 counts.push(format!("{} out of scope", out_of_scope.len()));
3452 }
3453 let headline = if counts.is_empty() {
3454 "no findings".to_string()
3455 } else {
3456 counts.join(", ")
3457 };
3458
3459 let _ = (holder, round, headline);
3460 let mut out = Vec::new();
3461 let summary = style::summary(&review.summary, style);
3462 if !summary.is_empty() {
3463 out.push(summary);
3464 }
3465
3466 if !blocking.is_empty() {
3467 let lines: Vec<String> = blocking
3468 .iter()
3469 .map(|f| {
3470 let detail = style::detail(&f.detail, style);
3471 if detail.is_empty() {
3472 located(f, style)
3473 } else {
3474 format!("{}. {detail}", located(f, style))
3475 }
3476 })
3477 .collect();
3478 out.push(format!("blocking\n{}", bullets(&lines)));
3479 }
3480
3481 for (label, group) in [
3484 ("non-blocking", &non_blocking),
3485 ("nits", &nits),
3486 ("out of scope", &out_of_scope),
3487 ] {
3488 if group.is_empty() {
3489 continue;
3490 }
3491 let lines: Vec<String> = group.iter().map(|f| located(f, style)).collect();
3492 out.push(format!("{label}\n{}", bullets(&lines)));
3493 }
3494
3495 out.join("\n\n")
3496}
3497
3498pub fn disposition_comment(
3502 author: &str,
3503 response: &ResponseDoc,
3504 fixed: &[String],
3505 refuted: &[String],
3506 filed: &[String],
3507 style: &Style,
3508) -> Option<String> {
3509 if fixed.is_empty() && refuted.is_empty() && filed.is_empty() {
3510 return None;
3511 }
3512 let mut counts = Vec::new();
3513 if !fixed.is_empty() {
3514 counts.push(format!("{} fixed", fixed.len()));
3515 }
3516 if !refuted.is_empty() {
3517 counts.push(format!("{} refuted", refuted.len()));
3518 }
3519 if !filed.is_empty() {
3520 counts.push(format!("{} filed", filed.len()));
3521 }
3522
3523 let _ = (author, counts);
3524 let mut out = Vec::new();
3525 let summary = style::summary(&response.summary, style);
3526 if !summary.is_empty() {
3527 out.push(summary);
3528 }
3529 if !refuted.is_empty() {
3530 out.push(format!("refuted\n{}", bullets(refuted)));
3531 }
3532 if !fixed.is_empty() {
3533 out.push(format!("fixed\n{}", bullets(fixed)));
3534 }
3535 if !filed.is_empty() {
3536 out.push(format!("filed\n{}", bullets(filed)));
3537 }
3538 Some(out.join("\n\n"))
3539}
3540
3541pub fn skip_comment(item: &SkippedItem, style: &Style) -> String {
3549 let reasons = item
3550 .reasons
3551 .values()
3552 .map(|reason| style::sentence(reason, style));
3553 let lines = crate::textsim::dedupe_by(reasons, crate::textsim::same_reason);
3557 bullets(&lines)
3558}
3559
3560pub(crate) fn findings_for_prompt(findings: &[Finding]) -> String {
3563 if findings.is_empty() {
3564 return "(none)".to_string();
3565 }
3566 findings
3567 .iter()
3568 .map(|f| {
3569 let scope = if f.in_scope { "" } else { " [out of scope]" };
3570 format!(
3571 "- [{}]{scope} {} ({})\n {}",
3572 f.severity,
3573 f.title,
3574 f.where_at(),
3575 f.detail
3576 )
3577 })
3578 .collect::<Vec<_>>()
3579 .join("\n")
3580}
3581
3582#[cfg(test)]
3583mod tests {
3584 use super::*;
3585 use crate::model::Verdict;
3586
3587 fn style() -> Style {
3588 Style::default()
3589 }
3590
3591 fn finding(severity: &str, title: &str, detail: &str, file: &str, in_scope: bool) -> Finding {
3592 Finding {
3593 severity: Severity::parse_lenient(severity).unwrap(),
3594 title: title.into(),
3595 detail: detail.into(),
3596 file: file.into(),
3597 in_scope,
3598 ..Default::default()
3599 }
3600 }
3601
3602 fn review(summary: &str, findings: Vec<Finding>) -> Review {
3603 Review {
3604 verdict: Verdict::Approve,
3605 next_action: NextAction::Merge,
3606 summary: summary.into(),
3607 findings,
3608 }
3609 }
3610
3611 fn disposition(title: &str, file: &str, action: Action) -> Disposition {
3612 Disposition {
3613 title: title.into(),
3614 file: file.into(),
3615 action,
3616 reasoning: "because".into(),
3617 new_issue_title: None,
3618 new_issue_body: None,
3619 }
3620 }
3621
3622 fn cfg_with(worktrees: bool, keep: bool) -> Config {
3625 let text = "[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n";
3626 let mut cfg = crate::config::parse(text).unwrap();
3627 cfg.loop_cfg.worktrees = worktrees;
3628 cfg.loop_cfg.keep_worktrees = keep;
3629 cfg
3630 }
3631
3632 #[test]
3633 fn a_worktree_is_released_on_every_finished_outcome() {
3634 let cfg = cfg_with(true, false);
3635 for status in [Status::Approved, Status::Merged, Status::Abandoned] {
3636 assert!(should_release(&cfg, status), "{status}");
3637 }
3638 }
3639
3640 #[test]
3643 fn a_worktree_is_kept_only_where_a_human_has_to_look() {
3644 let cfg = cfg_with(true, false);
3645 assert!(!should_release(&cfg, Status::Escalated));
3646 assert!(!should_release(&cfg, Status::Error));
3647 }
3648
3649 #[test]
3650 fn an_uncommitted_implementation_names_its_diagnostic_and_recovery_path() {
3651 let path = Path::new("/tmp/issue worktree");
3652 let err =
3653 uncommitted_implementation_error(path, Some("git add could not create index.lock"))
3654 .to_string();
3655 assert!(err.contains("could not create index.lock"), "{err}");
3656 assert!(err.contains("/tmp/issue worktree"), "{err}");
3657 assert!(err.contains("Commit or recover"), "{err}");
3658 assert!(!should_release(&cfg_with(true, false), Status::Error));
3659 }
3660
3661 #[test]
3662 fn the_keep_flag_overrides_everything() {
3663 assert!(!should_release(&cfg_with(true, true), Status::Approved));
3664 }
3665
3666 #[test]
3667 fn nothing_is_released_when_worktrees_are_off() {
3668 assert!(!should_release(&cfg_with(false, false), Status::Approved));
3669 }
3670
3671 #[test]
3676 fn fixing_your_own_findings_hands_the_pr_over() {
3677 let cfg = cfg_with(true, false);
3678 assert_eq!("a", next_reviewer(&cfg, "b", Some("b")));
3679 assert_eq!("b", next_reviewer(&cfg, "a", Some("a")));
3680 }
3681
3682 #[test]
3686 fn handing_back_keeps_the_reviewer_for_the_next_round() {
3687 let cfg = cfg_with(true, false);
3688 assert_eq!("b", next_reviewer(&cfg, "b", Some("a")));
3689 assert_eq!("a", next_reviewer(&cfg, "a", Some("b")));
3690 }
3691
3692 #[test]
3695 fn nobody_reviews_their_own_edit() {
3696 let cfg = cfg_with(true, false);
3697 let round_1 = cfg.other(&cfg.first_implementor);
3698 assert_eq!("b", round_1);
3699 for editor in ["a", "b"] {
3700 assert_ne!(editor, next_reviewer(&cfg, &round_1, Some(editor)));
3701 }
3702 }
3703
3704 #[test]
3709 fn a_fix_that_committed_nothing_leaves_the_pr_where_it_is() {
3710 let cfg = cfg_with(true, false);
3711 assert_eq!("b", next_reviewer(&cfg, "b", None));
3712 assert_eq!("a", next_reviewer(&cfg, "a", None));
3713 }
3714
3715 #[test]
3719 fn a_reviewer_that_wrote_the_head_gives_the_pr_up() {
3720 let cfg = cfg_with(true, false);
3721 assert_eq!("a", next_reviewer(&cfg, "b", Some("b")));
3722 }
3723
3724 #[test]
3728 fn a_review_that_wrote_cannot_approve_what_is_left() {
3729 assert!(!approval_stands(&[], true));
3730 }
3731
3732 #[test]
3733 fn a_clean_review_of_an_untouched_branch_approves() {
3734 assert!(approval_stands(&[], false));
3735 }
3736
3737 #[test]
3738 fn a_blocking_finding_never_approves() {
3739 let blocking = vec![finding("blocking", "Broken", "detail", "src/x.rs", true)];
3740 assert!(!approval_stands(&blocking, false));
3741 }
3742
3743 #[test]
3744 fn approval_refuses_a_head_that_changed_after_review() {
3745 assert!(ensure_reviewed_head(36, "abc123", "abc123").is_ok());
3746 let error = ensure_reviewed_head(36, "abc123", "def456").unwrap_err();
3747 assert!(error.to_string().contains("unread head"));
3748 }
3749
3750 #[test]
3752 fn only_a_moved_head_counts_as_a_commit() {
3753 let before = Snapshot {
3754 head: "abc".into(),
3755 dirty: false,
3756 };
3757 assert!(!Snapshot {
3758 head: "abc".into(),
3759 dirty: true,
3760 }
3761 .landed_over(&before));
3762 assert!(Snapshot {
3763 head: "def".into(),
3764 dirty: false,
3765 }
3766 .landed_over(&before));
3767 assert!(!Snapshot {
3769 head: String::new(),
3770 dirty: false,
3771 }
3772 .landed_over(&before));
3773 }
3774
3775 #[test]
3779 fn a_fresh_run_starts_at_one() {
3780 assert_eq!((1, 3), round_window(1, 3));
3781 assert_eq!((1, 5), round_window(1, 5));
3782 }
3783
3784 #[test]
3788 fn a_resumed_run_gets_a_full_fresh_budget() {
3789 assert_eq!((6, 10), round_window(6, 5));
3790 assert_eq!((11, 13), round_window(11, 3));
3791 }
3792
3793 #[test]
3794 fn a_budget_of_one_is_a_single_round() {
3795 assert_eq!((6, 6), round_window(6, 1));
3796 }
3797
3798 #[test]
3799 fn round_numbers_keep_counting_across_sessions() {
3800 let mut start = 1;
3802 let mut seen = Vec::new();
3803 for _ in 0..3 {
3804 let (first, last) = round_window(start, 3);
3805 seen.push((first, last));
3806 start = last + 1;
3807 }
3808 assert_eq!(vec![(1, 3), (4, 6), (7, 9)], seen);
3809 }
3810
3811 fn ledger_with(title: &str, file: &str) -> Ledger {
3814 let mut ledger = Ledger::new();
3815 ledger.insert(
3816 finding_key(title, file),
3817 LedgerEntry {
3818 title: title.into(),
3819 file: file.into(),
3820 reasoning: "no".into(),
3821 round: 1,
3822 reraised: 0,
3823 outcome: Settled::Refuted,
3824 },
3825 );
3826 ledger
3827 }
3828
3829 #[test]
3830 fn a_point_refuted_and_re_raised_twice_escalates() {
3831 let mut ledger = ledger_with("nit about naming", "a.rs");
3832 let mut state = IssueRun::new(1, "t");
3833 let blocking = vec![finding("blocking", "nit about naming", "d", "a.rs", true)];
3834 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
3835 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
3836 }
3837
3838 #[test]
3842 fn a_point_fixed_twice_and_raised_again_escalates() {
3843 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
3844 for entry in ledger.values_mut() {
3845 entry.outcome = Settled::Fixed;
3846 }
3847 let mut state = IssueRun::new(1, "t");
3848 let blocking = [finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
3849
3850 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
3851 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
3852 }
3853
3854 #[test]
3857 fn a_fix_that_missed_twice_is_not_reported_as_a_refutation() {
3858 assert!(why_escalated(Settled::Fixed).contains("fixed twice"));
3859 for outcome in [Settled::Refuted, Settled::Filed, Settled::Dropped] {
3860 assert!(why_escalated(outcome).contains("settled"), "{outcome}");
3861 }
3862 }
3863
3864 #[test]
3869 fn a_reviewer_that_fixes_its_own_findings_records_them_too() {
3870 let mut ledger = Ledger::new();
3871 let blocking = vec![finding(
3872 "blocking",
3873 "Unbounded loop",
3874 "spins",
3875 "src/x.rs",
3876 true,
3877 )];
3878 let mut state = IssueRun::new(1, "t");
3879
3880 record_own_fixes(&blocking, &mut ledger, &mut state, 1);
3881
3882 let entry = ledger
3883 .get(&finding_key("Unbounded loop", "src/x.rs"))
3884 .expect("keyed where the next round will look");
3885 assert_eq!(Settled::Fixed, entry.outcome);
3886 assert_eq!(
3887 "a committed change was made for this point",
3888 entry.reasoning
3889 );
3890
3891 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
3893 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
3894 }
3895
3896 #[test]
3900 fn a_fixed_point_is_not_in_the_settled_block() {
3901 let mut ledger = ledger_with("refuted point", "a.rs");
3902 ledger.extend(ledger_with("fixed point", "b.rs"));
3903 for entry in ledger.values_mut() {
3904 if entry.title == "fixed point" {
3905 entry.outcome = Settled::Fixed;
3906 }
3907 }
3908 let block = settled_block(&ledger);
3909 assert!(block.contains("refuted point"));
3910 assert!(!block.contains("fixed point"));
3911 }
3912
3913 #[test]
3916 fn a_ledger_of_only_fixes_says_nothing_is_settled() {
3917 let mut ledger = ledger_with("fixed point", "b.rs");
3918 for entry in ledger.values_mut() {
3919 entry.outcome = Settled::Fixed;
3920 }
3921 assert_eq!("", settled_block(&ledger));
3922 }
3923
3924 #[test]
3929 fn one_review_spends_one_re_raise_however_often_it_says_it() {
3930 let mut ledger = ledger_with("Missing error handling", "src/net.rs");
3931 let mut state = IssueRun::new(1, "t");
3932 let twice = vec![
3933 finding(
3934 "blocking",
3935 "Missing error handling",
3936 "d",
3937 "src/net.rs",
3938 true,
3939 ),
3940 finding(
3941 "blocking",
3942 "Missing error handling",
3943 "e",
3944 "src/net.rs",
3945 true,
3946 ),
3947 ];
3948
3949 assert!(!check_relitigation(&mut ledger, &twice, &mut state));
3950 assert_eq!(1, ledger.values().next().unwrap().reraised);
3951 assert!(check_relitigation(&mut ledger, &twice, &mut state));
3952 }
3953
3954 #[test]
3955 fn an_untracked_finding_does_not_escalate() {
3956 let mut state = IssueRun::new(1, "t");
3957 let blocking = vec![finding("blocking", "brand new", "d", "a.rs", true)];
3958 assert!(!check_relitigation(
3959 &mut Ledger::new(),
3960 &blocking,
3961 &mut state
3962 ));
3963 }
3964
3965 #[test]
3966 fn persisted_ledger_entries_are_rekeyed_for_stable_locations() {
3967 let mut ledger = Ledger::new();
3968 ledger.insert(
3969 "legacy-key".into(),
3970 LedgerEntry {
3971 title: "Unbounded loop".into(),
3972 file: "src/x.rs:88".into(),
3973 reasoning: "bounded by the caller".into(),
3974 round: 2,
3975 reraised: 1,
3976 outcome: Settled::Refuted,
3977 },
3978 );
3979 normalise_ledger_keys(&mut ledger);
3980 let key = matching_ledger_key(&ledger, "Unbounded loop", "src/x.rs:91").unwrap();
3981 assert_eq!(1, ledger[&key].reraised);
3982 }
3983
3984 #[test]
3985 fn same_title_at_two_sites_keeps_both_blockers() {
3986 let findings = vec![
3987 finding(
3988 "blocking",
3989 "Unchecked error",
3990 "first site",
3991 "src/net.rs:10",
3992 true,
3993 ),
3994 finding(
3995 "blocking",
3996 "Unchecked error",
3997 "second site",
3998 "src/net.rs:200",
3999 true,
4000 ),
4001 ];
4002
4003 let blocking = blocking_findings(&findings);
4004 assert_eq!(2, blocking.len());
4005 assert_eq!("src/net.rs:10", blocking[0].file);
4006 assert_eq!("src/net.rs:200", blocking[1].file);
4007 }
4008
4009 #[test]
4010 fn moved_location_fallback_refuses_an_ambiguous_ledger() {
4011 let mut ledger = ledger_with("Unchecked error", "src/net.rs:10");
4012 ledger.extend(ledger_with("Unchecked error", "src/net.rs:200"));
4013 assert!(matching_ledger_key(&ledger, "Unchecked error", "src/net.rs:30").is_none());
4014 }
4015
4016 #[test]
4017 fn two_current_sites_do_not_relocate_one_old_ledger_entry() {
4018 let mut ledger = ledger_with("Unchecked error", "src/net.rs:5");
4019 let blocking = vec![
4020 finding(
4021 "blocking",
4022 "Unchecked error",
4023 "first",
4024 "src/net.rs:10",
4025 true,
4026 ),
4027 finding(
4028 "blocking",
4029 "Unchecked error",
4030 "second",
4031 "src/net.rs:200",
4032 true,
4033 ),
4034 ];
4035 let mut state = IssueRun::new(1, "t");
4036 record_own_fixes(&blocking, &mut ledger, &mut state, 2);
4037 assert!(ledger.contains_key(&finding_key("Unchecked error", "src/net.rs:10")));
4038 assert!(ledger.contains_key(&finding_key("Unchecked error", "src/net.rs:200")));
4039 }
4040
4041 #[test]
4042 fn two_current_sites_remain_two_open_findings() {
4043 let current = vec![
4044 finding(
4045 "blocking",
4046 "Unchecked error",
4047 "first",
4048 "src/net.rs:10",
4049 true,
4050 ),
4051 finding(
4052 "blocking",
4053 "Unchecked error",
4054 "second",
4055 "src/net.rs:200",
4056 true,
4057 ),
4058 ];
4059 let mut open = Vec::new();
4060
4061 extend_findings(&mut open, ¤t);
4062
4063 assert_eq!(2, open.len());
4064 assert_eq!("src/net.rs:10", open[0].file);
4065 assert_eq!("src/net.rs:200", open[1].file);
4066 }
4067
4068 #[test]
4069 fn display_limits_do_not_change_persisted_finding_identity() {
4070 let point = finding(
4071 "blocking",
4072 "abcdefghij",
4073 "still wrong",
4074 "src/net.rs:10",
4075 true,
4076 );
4077 let mut ledger = Ledger::new();
4078 let mut state = IssueRun::new(1, "t");
4079 record_own_fixes(std::slice::from_ref(&point), &mut ledger, &mut state, 1);
4080 normalise_ledger_keys(&mut ledger);
4081
4082 let key = matching_ledger_key(&ledger, "abcdefghij", "src/net.rs:12").unwrap();
4083 assert_eq!("abcdefghij", ledger[&key].title);
4084 }
4085
4086 #[test]
4087 fn a_clipped_legacy_entry_keeps_its_original_lookup_key() {
4088 let mut ledger = Ledger::new();
4089 let key = crate::jsonx::finding_key("abcdefghij", "src/net.rs:10");
4090 ledger.insert(
4091 key.clone(),
4092 LedgerEntry {
4093 title: "abcde".into(),
4094 file: "src/net.rs:10".into(),
4095 reasoning: "bounded by the caller".into(),
4096 round: 1,
4097 reraised: 1,
4098 outcome: Settled::Refuted,
4099 },
4100 );
4101 normalise_ledger_keys(&mut ledger);
4102
4103 assert_eq!(
4104 Some(key),
4105 matching_ledger_key(&ledger, "abcdefghij", "src/net.rs:12")
4106 );
4107 }
4108
4109 #[test]
4110 fn a_legacy_key_collision_does_not_merge_case_distinct_paths() {
4111 let mut ledger = Ledger::new();
4112 let key = crate::jsonx::finding_key("Unchecked error", "src/Main.rs:10");
4113 ledger.insert(
4114 key.clone(),
4115 LedgerEntry {
4116 title: "Unchecked error".into(),
4117 file: "src/Main.rs:10".into(),
4118 reasoning: "bounded by the caller".into(),
4119 round: 1,
4120 reraised: 0,
4121 outcome: Settled::Refuted,
4122 },
4123 );
4124
4125 assert_eq!(
4126 Some(key),
4127 matching_ledger_key(&ledger, "Unchecked error", "src/Main.rs:10")
4128 );
4129 assert!(matching_ledger_key(&ledger, "Unchecked error", "src/main.rs:10").is_none());
4130 }
4131
4132 #[test]
4136 fn a_refutation_lands_on_the_key_the_next_round_will_look_up() {
4137 let blocking = [finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
4138 let recorded = finding_key(&blocking[0].title, &blocking[0].file);
4139 let answer = disposition("unbounded loop!", "src/x.rs", Action::Refuted);
4140 assert!(disposition_matches(&blocking[0], &answer));
4141 assert_eq!(recorded, finding_key(&answer.title, &answer.file));
4142 }
4143
4144 #[test]
4147 fn the_ledger_key_ignores_title_punctuation() {
4148 let findings = [finding(
4149 "blocking",
4150 "Panic on multi-byte input",
4151 "d",
4152 "src/style.rs",
4153 true,
4154 )];
4155 let reworded = "Panic on multibyte input";
4156 let source = &findings[0];
4157 assert_eq!(
4158 finding_key(reworded, &source.file),
4159 finding_key(&source.title, &source.file)
4160 );
4161 let recorded = finding_key(&source.title, &source.file);
4162 let looked_up = finding_key(&findings[0].title, &findings[0].file);
4163 assert_eq!(recorded, looked_up);
4164 }
4165
4166 #[test]
4167 fn a_disposition_matches_its_finding_despite_wording_noise() {
4168 let findings = [finding(
4169 "blocking",
4170 "Unbounded loop!",
4171 "d",
4172 "src/x.rs",
4173 true,
4174 )];
4175 assert!(disposition_matches(
4176 &findings[0],
4177 &disposition("unbounded loop", "src/x.rs", Action::Refuted)
4178 ));
4179 assert!(!disposition_matches(
4180 &findings[0],
4181 &disposition("something else", "src/x.rs", Action::Refuted)
4182 ));
4183 assert!(!disposition_matches(
4184 &findings[0],
4185 &disposition("unbounded loop", "src/y.rs", Action::Refuted)
4186 ));
4187 }
4188
4189 #[test]
4190 fn an_omitted_disposition_leaves_the_blocker_unmatched() {
4191 let blocker = finding("blocking", "Unbounded loop", "d", "src/x.rs", true);
4192 assert!(matches!(
4193 matching_disposition(&blocker, std::slice::from_ref(&blocker), &[]),
4194 Err("no matching disposition")
4195 ));
4196 }
4197
4198 #[test]
4199 fn duplicate_dispositions_are_ambiguous() {
4200 let blocker = finding("blocking", "Unbounded loop", "d", "src/x.rs", true);
4201 let answers = vec![
4202 disposition("Unbounded loop", "src/x.rs", Action::Fixed),
4203 disposition("Unbounded loop", "src/x.rs", Action::Refuted),
4204 ];
4205 assert!(matches!(
4206 matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers),
4207 Err("more than one matching disposition")
4208 ));
4209 }
4210
4211 #[test]
4212 fn an_unambiguous_disposition_may_omit_the_line_number() {
4213 let blocker = finding(
4214 "blocking",
4215 "Replayed offers persist the wrong lane key",
4216 "d",
4217 "src/engine.rs:1166",
4218 true,
4219 );
4220 let answers = [disposition(
4221 "replayed offers persist the wrong lane key!",
4222 "src/engine.rs",
4223 Action::Fixed,
4224 )];
4225
4226 assert_eq!(
4227 0,
4228 matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers)
4229 .expect("stable answer")
4230 .0
4231 );
4232 }
4233
4234 #[test]
4235 fn a_line_free_disposition_does_not_choose_between_two_sites() {
4236 let first = finding("blocking", "Unchecked error", "d", "src/engine.rs:10", true);
4237 let second = finding(
4238 "blocking",
4239 "Unchecked error",
4240 "d",
4241 "src/engine.rs:200",
4242 true,
4243 );
4244 let findings = [first.clone(), second];
4245 let answers = [disposition(
4246 "Unchecked error",
4247 "src/engine.rs",
4248 Action::Fixed,
4249 )];
4250
4251 assert!(matches!(
4252 matching_disposition(&first, &findings, &answers),
4253 Err("no matching disposition")
4254 ));
4255 }
4256
4257 #[test]
4258 fn an_exact_location_wins_over_an_ambiguous_fallback() {
4259 let blocker = finding("blocking", "Unchecked error", "d", "src/engine.rs:10", true);
4260 let answers = [
4261 disposition("Unchecked error", "src/engine.rs", Action::Refuted),
4262 disposition("Unchecked error", "src/engine.rs:10", Action::Fixed),
4263 ];
4264
4265 let matched = matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers)
4266 .expect("exact answer");
4267 assert_eq!(1, matched.0);
4268 assert_eq!(Action::Fixed, matched.1.action);
4269 }
4270
4271 #[test]
4272 fn same_titled_findings_in_different_files_need_separate_dispositions() {
4273 let findings = [
4274 finding("blocking", "Unchecked error", "d", "src/a.rs", true),
4275 finding("blocking", "Unchecked error", "d", "src/b.rs", true),
4276 ];
4277 let answers = vec![
4278 disposition("Unchecked error", "src/a.rs", Action::Fixed),
4279 disposition("Unchecked error", "src/b.rs", Action::Refuted),
4280 ];
4281 assert_eq!(
4282 0,
4283 matching_disposition(&findings[0], &findings, &answers)
4284 .expect("left answer")
4285 .0
4286 );
4287 assert_eq!(
4288 1,
4289 matching_disposition(&findings[1], &findings, &answers)
4290 .expect("right answer")
4291 .0
4292 );
4293 }
4294
4295 #[test]
4296 fn a_reported_fix_without_a_commit_stays_open() {
4297 assert!(!fixed_disposition_resolves(false));
4298 assert!(fixed_disposition_resolves(true));
4299 }
4300
4301 #[test]
4302 fn the_settled_block_is_empty_when_nothing_is_settled() {
4303 assert_eq!("", settled_block(&Ledger::new()));
4304 }
4305
4306 #[test]
4307 fn the_settled_block_names_each_refutation() {
4308 let block = settled_block(&ledger_with("a point", "x.rs"));
4309 assert!(block.contains("a point"));
4310 assert!(block.contains("x.rs"));
4311 assert!(block.contains("settled"));
4312 }
4313
4314 #[test]
4315 fn same_title_settlements_name_each_location() {
4316 let mut ledger = ledger_with("Unchecked error", "a.rs:10");
4317 ledger.extend(ledger_with("Unchecked error", "b.rs:20"));
4318
4319 let block = settled_block(&ledger);
4320
4321 assert!(block.contains("Unchecked error (a.rs:10)"), "{block}");
4322 assert!(block.contains("Unchecked error (b.rs:20)"), "{block}");
4323 }
4324
4325 #[test]
4329 fn a_filed_point_is_settled_too() {
4330 let mut ledger = ledger_with("out of scope", "x.rs");
4331 for entry in ledger.values_mut() {
4332 entry.outcome = Settled::Filed;
4333 entry.reasoning = "Tracked in #9.".into();
4334 }
4335 let block = settled_block(&ledger);
4336 assert!(block.contains("out of scope"));
4337 assert!(block.contains("#9"));
4338 }
4339
4340 #[test]
4343 fn answering_a_point_again_keeps_its_re_raise_count() {
4344 let mut ledger = ledger_with("a point", "x.rs");
4345 let entry = ledger.values().next().unwrap().clone();
4346 let mut state = IssueRun::new(1, "t");
4347 let blocking = vec![finding("blocking", "a point", "d", "x.rs", true)];
4348
4349 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
4350 settle(&mut ledger, "a point", "x.rs", true, entry);
4351 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
4352 }
4353
4354 #[test]
4357 fn a_clean_review_is_just_the_verdict() {
4360 let text = review_comment("codex", 1, &review("Looks correct.", vec![]), &style());
4361 assert_eq!("Looks correct.", text);
4362 }
4363
4364 #[test]
4365 fn a_review_leads_with_the_counts() {
4366 let text = review_comment(
4367 "codex",
4368 2,
4369 &review(
4370 "One real problem.",
4371 vec![
4372 finding(
4373 "blocking",
4374 "Loop never terminates",
4375 "Confirmed by running it.",
4376 "src/a.rs",
4377 true,
4378 ),
4379 finding("non-blocking", "Name is vague", "d", "src/b.rs", true),
4380 finding("nit", "Log wording", "d", "", true),
4381 ],
4382 ),
4383 &style(),
4384 );
4385 assert!(text.starts_with("One real problem."), "{text}");
4386 assert!(!text.contains("codex"), "no agent name: {text}");
4387 assert!(!text.contains("round 2"), "no round number: {text}");
4388 }
4389
4390 #[test]
4393 fn only_blocking_findings_carry_their_detail() {
4394 let text = review_comment(
4395 "codex",
4396 1,
4397 &review(
4398 "s",
4399 vec![
4400 finding("blocking", "Loop", "BLOCKING DETAIL", "a.rs", true),
4401 finding("non-blocking", "Name", "NONBLOCKING DETAIL", "b.rs", true),
4402 ],
4403 ),
4404 &style(),
4405 );
4406 assert!(text.contains("BLOCKING DETAIL"), "{text}");
4407 assert!(!text.contains("NONBLOCKING DETAIL"), "{text}");
4408 }
4409
4410 #[test]
4411 fn a_thorough_explanation_reaches_the_author_intact() {
4414 let detail = "Reproduced by running the 429 test with max_attempts unset. ".repeat(8);
4415 let text = review_comment(
4416 "codex",
4417 1,
4418 &review(
4419 "One problem.",
4420 vec![finding("blocking", "T", &detail, "a.rs", true)],
4421 ),
4422 &style(),
4423 );
4424 assert!(
4425 text.contains(detail.trim()),
4426 "the explanation was cut:\n{text}"
4427 );
4428 }
4429
4430 #[test]
4432 fn a_runaway_model_is_still_bounded() {
4433 let long = "filler words. ".repeat(20_000);
4434 let text = review_comment(
4435 "codex",
4436 1,
4437 &review(&long, vec![finding("blocking", "T", &long, "a.rs", true)]),
4438 &style(),
4439 );
4440 assert!(
4441 text.len() < 30_000,
4442 "review comment was {} chars",
4443 text.len()
4444 );
4445 }
4446
4447 #[test]
4448 fn a_general_finding_has_no_empty_parenthesis() {
4449 let text = review_comment(
4450 "codex",
4451 1,
4452 &review("s", vec![finding("blocking", "Something", "d", "", true)]),
4453 &style(),
4454 );
4455 assert!(!text.contains("()"), "{text}");
4456 assert!(!text.contains("(general)"), "{text}");
4457 }
4458
4459 #[test]
4460 fn out_of_scope_findings_are_counted_separately() {
4461 let text = review_comment(
4462 "codex",
4463 1,
4464 &review(
4465 "s",
4466 vec![finding("blocking", "Old bug", "d", "a.rs", false)],
4467 ),
4468 &style(),
4469 );
4470 assert!(text.contains("out of scope"), "{text}");
4471 assert!(text.contains("Old bug"), "{text}");
4472 }
4473
4474 #[test]
4475 fn a_disposition_comment_leads_with_counts_and_keeps_refutations() {
4476 let response = ResponseDoc {
4477 summary: "Two of three were right.".into(),
4478 dispositions: vec![],
4479 };
4480 let text = disposition_comment(
4481 "claude",
4482 &response,
4483 &["Fixed thing".to_string()],
4484 &["Wrong thing. Because the caller already checks.".to_string()],
4485 &[],
4486 &style(),
4487 )
4488 .unwrap();
4489 assert!(text.starts_with("Two of three were right."), "{text}");
4490 assert!(!text.contains("claude"), "no agent name: {text}");
4491 assert!(
4492 text.contains("Because the caller already checks."),
4493 "{text}"
4494 );
4495 }
4496
4497 #[test]
4498 fn an_empty_disposition_comment_is_not_posted() {
4499 let response = ResponseDoc {
4500 summary: "s".into(),
4501 dispositions: vec![],
4502 };
4503 assert!(disposition_comment("claude", &response, &[], &[], &[], &style()).is_none());
4504 }
4505
4506 #[test]
4511 fn a_local_closing_commit_does_not_change_remote_custody() {
4512 for holder in ["a", "b"] {
4513 assert_eq!(holder, closing_next_actor(holder));
4514 }
4515 }
4516
4517 #[test]
4518 fn a_matching_head_keeps_saved_custody() {
4519 assert!(reconcile_saved_head(Some("abc123"), Some(2), true, "abc123", None, 42).unwrap());
4520 assert!(reconcile_saved_head(None, None, false, "abc123", None, 42).unwrap());
4521 }
4522
4523 #[test]
4524 fn a_changed_head_refuses_automatic_custody() {
4525 let error =
4526 reconcile_saved_head(Some("abc123"), Some(2), true, "def456", None, 42).unwrap_err();
4527 let text = error.to_string();
4528 assert!(text.contains("abc123"), "{text}");
4529 assert!(text.contains("def456"), "{text}");
4530 assert!(text.contains("--next <agent>"), "{text}");
4531 }
4532
4533 #[test]
4534 fn legacy_state_keeps_its_saved_custody_for_migration() {
4535 assert!(reconcile_saved_head(Some(""), Some(1), true, "def456", None, 42).unwrap());
4536 }
4537
4538 #[test]
4539 fn an_explicit_holder_resets_state_for_a_changed_or_legacy_head() {
4540 assert!(
4541 !reconcile_saved_head(Some("abc123"), Some(2), true, "def456", Some("b"), 42).unwrap()
4542 );
4543 assert!(!reconcile_saved_head(Some(""), Some(1), true, "def456", Some("b"), 42).unwrap());
4544 }
4545
4546 #[test]
4547 fn a_headless_current_or_future_state_is_not_legacy() {
4548 for version in [2, STATE_VERSION + 1] {
4549 assert!(
4550 reconcile_saved_head(Some(""), Some(version), true, "def456", None, 42).is_err()
4551 );
4552 }
4553 }
4554
4555 #[test]
4556 fn legacy_state_with_an_unknown_actor_refuses_to_guess() {
4557 assert!(reconcile_saved_head(Some(""), Some(1), false, "def456", None, 42).is_err());
4558 }
4559
4560 #[test]
4561 fn an_invalid_review_cannot_clear_a_carried_blocker() {
4562 let mut open = vec![finding(
4563 "blocking",
4564 "Unchecked error",
4565 "still fails",
4566 "src/a.rs:12",
4567 true,
4568 )];
4569 update_open_findings(&mut open, &[], false);
4570 assert_eq!(1, open.len());
4571
4572 update_open_findings(&mut open, &[], true);
4573 assert!(open.is_empty());
4574 }
4575
4576 #[test]
4577 fn a_final_round_with_no_commit_names_open_blockers() {
4578 let open = vec![finding(
4579 "blocking",
4580 "Unchecked error",
4581 "still fails",
4582 "src/a.rs",
4583 true,
4584 )];
4585 assert!(matches!(
4586 ending_without_landing(&open),
4587 Ending::Unresolved(points) if points.len() == 1
4588 ));
4589 assert!(matches!(ending_without_landing(&[]), Ending::Unchanged));
4590 }
4591
4592 #[test]
4593 fn a_closing_pass_uses_the_later_effort_tier() {
4594 assert_eq!(2, closing_effort_round(1));
4595 assert_eq!(8, closing_effort_round(7));
4596 }
4597
4598 #[test]
4599 fn a_ledger_with_no_claimed_fix_has_nothing_to_close_over() {
4600 assert!(!any_fixes(&ledger_with("refuted point", "a.rs"), 1));
4601 let mut fixed = ledger_with("fixed point", "b.rs");
4602 for entry in fixed.values_mut() {
4603 entry.outcome = Settled::Fixed;
4604 }
4605 assert!(any_fixes(&fixed, 1));
4606 }
4607
4608 #[test]
4611 fn the_closing_note_counts_points_rather_than_rounds() {
4612 assert_eq!("one point left after the closing pass", unresolved_note(1));
4613 assert_eq!("3 points left after the closing pass", unresolved_note(3));
4614 assert!(!unresolved_note(2).contains("round"));
4615 }
4616
4617 fn fixed_ledger() -> Ledger {
4618 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
4619 for entry in ledger.values_mut() {
4620 entry.outcome = Settled::Fixed;
4621 entry.reasoning = "bounded it on max_attempts".into();
4622 }
4623 ledger
4624 }
4625
4626 #[test]
4627 fn the_closing_prompt_names_every_fix_it_has_to_check() {
4628 let landed = vec!["abc1234 Bound the retry loop".to_string()];
4629 let prompt = close_prompt(
4630 "main",
4631 42,
4632 "Retry a 429",
4633 "9f8e7d6",
4634 Some(&landed),
4635 &fixed_ledger(),
4636 &[],
4637 1,
4638 );
4639 assert!(prompt.contains("Unbounded loop"), "{prompt}");
4640 assert!(prompt.contains("bounded it on max_attempts"), "{prompt}");
4641 assert!(prompt.contains("abc1234 Bound the retry loop"), "{prompt}");
4642 assert!(prompt.contains("git diff 9f8e7d6..HEAD"), "{prompt}");
4643 assert!(prompt.contains("git diff main...HEAD"), "{prompt}");
4644 assert!(!prompt.contains('{'), "{prompt}");
4645 }
4646
4647 #[test]
4650 fn a_close_with_nothing_landed_says_so_rather_than_leaving_a_hole() {
4651 let prompt = close_prompt(
4652 "main",
4653 42,
4654 "Retry a 429",
4655 "9f8e7d6",
4656 Some(&[]),
4657 &Ledger::new(),
4658 &[],
4659 1,
4660 );
4661 assert!(
4662 prompt.contains("Nothing landed after the last round"),
4663 "{prompt}"
4664 );
4665 assert!(!prompt.contains('{'), "{prompt}");
4666 }
4667
4668 #[test]
4674 fn a_rewritten_branch_admits_it_cannot_say_what_landed() {
4675 let prompt = close_prompt(
4676 "main",
4677 42,
4678 "Retry a 429",
4679 "9f8e7d6",
4680 None,
4681 &fixed_ledger(),
4682 &[],
4683 1,
4684 );
4685 assert!(prompt.contains("were rewritten"), "{prompt}");
4686 assert!(prompt.contains("git diff main...HEAD"), "{prompt}");
4687 assert!(!prompt.contains("nobody has read it"), "{prompt}");
4688 assert!(!prompt.contains('{'), "{prompt}");
4689 }
4690
4691 #[test]
4694 fn the_closing_prompt_forbids_the_writing_the_loop_rolls_back() {
4695 let prompt = close_prompt(
4696 "main",
4697 42,
4698 "t",
4699 "9f8e7d6",
4700 Some(&[]),
4701 &Ledger::new(),
4702 &[],
4703 1,
4704 );
4705 assert!(prompt.contains("do not commit"), "{prompt}");
4706 }
4707
4708 #[test]
4710 fn the_closing_prompt_keeps_confirmed_merge_blockers_blocking() {
4711 let prompt = close_prompt(
4712 "main",
4713 42,
4714 "t",
4715 "9f8e7d6",
4716 Some(&[]),
4717 &Ledger::new(),
4718 &[],
4719 1,
4720 );
4721 assert!(
4722 prompt.contains("serious defect an\nearlier round missed"),
4723 "{prompt}"
4724 );
4725 assert!(prompt.contains("final merge-safety audit"), "{prompt}");
4726 assert!(!prompt.contains("not another\naudit"), "{prompt}");
4727 assert!(!prompt.contains("A\nfinding means"), "{prompt}");
4728 assert!(prompt.contains("A\nblocking finding means"), "{prompt}");
4729 let flat = prompt.split_whitespace().collect::<Vec<_>>().join(" ");
4730 assert!(flat.contains("does not become non-blocking"), "{prompt}");
4731 assert!(flat.contains("Only one of them ships"), "{prompt}");
4732 }
4733
4734 #[test]
4739 fn the_closing_pass_is_offered_a_severity_rather_than_the_field_that_gates() {
4740 let prompt = close_prompt(
4741 "main",
4742 42,
4743 "t",
4744 "9f8e7d6",
4745 Some(&[]),
4746 &Ledger::new(),
4747 &[],
4748 1,
4749 );
4750 assert!(
4751 prompt.contains("Minor defects and improvements are\nnon-blocking"),
4752 "{prompt}"
4753 );
4754 assert!(
4755 prompt.contains("a real defect\nthis pull request did not cause"),
4756 "{prompt}"
4757 );
4758 }
4759
4760 #[test]
4763 fn an_out_of_scope_point_cannot_gate_the_close() {
4764 let out_of_scope = finding("blocking", "Adjacent leak", "d", "o.rs", false);
4765 assert!(!out_of_scope.blocks());
4766 assert!(approval_stands(&[], false));
4767 }
4768
4769 #[test]
4773 fn a_fix_is_shown_to_the_pass_that_has_to_check_it_and_not_after() {
4774 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
4775 for entry in ledger.values_mut() {
4776 entry.outcome = Settled::Fixed;
4777 entry.round = 2;
4778 }
4779 assert!(answers_block(&ledger, 3).contains("Unbounded loop"));
4781 assert_eq!("", answers_block(&ledger, 4));
4783 assert!(any_fixes(&ledger, 2));
4784 assert!(!any_fixes(&ledger, 3));
4785 }
4786
4787 #[test]
4792 fn the_answers_block_asks_the_reviewer_to_check_rather_than_to_trust() {
4793 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
4794 for entry in ledger.values_mut() {
4795 entry.outcome = Settled::Fixed;
4796 entry.reasoning = "bounded it on max_attempts".into();
4797 }
4798 let block = answers_block(&ledger, 2);
4799 assert!(block.contains("Unbounded loop"), "{block}");
4800 assert!(block.contains("src/x.rs"), "{block}");
4801 assert!(block.contains("bounded it on max_attempts"), "{block}");
4802 assert!(block.contains("Check the answer"), "{block}");
4803 assert!(!block.contains("settled"), "{block}");
4804 }
4805
4806 #[test]
4809 fn a_fix_and_a_refutation_do_not_share_a_heading() {
4810 let mut ledger = ledger_with("refuted point", "a.rs");
4811 ledger.extend(ledger_with("fixed point", "b.rs"));
4812 for entry in ledger.values_mut() {
4813 if entry.title == "fixed point" {
4814 entry.outcome = Settled::Fixed;
4815 }
4816 }
4817 let answers = answers_block(&ledger, 2);
4818 let settled = settled_block(&ledger);
4819 assert!(answers.contains("fixed point") && !answers.contains("refuted point"));
4820 assert!(settled.contains("refuted point") && !settled.contains("fixed point"));
4821 }
4822
4823 #[test]
4824 fn an_empty_ledger_adds_no_answers_block() {
4825 assert_eq!("", answers_block(&Ledger::new(), 2));
4826 }
4827
4828 #[test]
4831 fn the_last_round_that_can_ask_for_anything_says_so() {
4832 assert_eq!("", round_note(1, 3));
4833 assert_eq!("", round_note(2, 3));
4834 assert!(round_note(3, 3).contains("last round"));
4835 assert!(round_note(6, 6).contains("last round"));
4838 }
4839
4840 #[test]
4846 fn saying_when_the_asking_stops_says_nothing_about_severity() {
4847 let note = round_note(3, 3);
4848 for word in ["approve", "blocking", "severity", "nit"] {
4849 assert!(!note.contains(word), "{word} in: {note}");
4850 }
4851 }
4852
4853 #[test]
4854 fn the_review_prompt_leaves_nothing_unsubstituted() {
4855 let empty = review_prompt("main", 42, "Retry a 429", &Ledger::new(), &[], 1, 3);
4856 assert!(!empty.contains('{'), "{empty}");
4857 assert!(empty.contains("main") && empty.contains("#42") && empty.contains("Retry a 429"));
4858
4859 let mut ledger = ledger_with("refuted point", "a.rs");
4860 ledger.extend(ledger_with("fixed point", "b.rs"));
4861 for entry in ledger.values_mut() {
4862 if entry.title == "fixed point" {
4863 entry.outcome = Settled::Fixed;
4864 entry.round = 2;
4865 }
4866 }
4867 let full = review_prompt("main", 42, "Retry a 429", &ledger, &[], 3, 3);
4868 assert!(!full.contains('{'), "{full}");
4869 assert!(full.contains("fixed point") && full.contains("refuted point"));
4870 assert!(full.contains("last round"), "{full}");
4871 }
4872
4873 #[test]
4874 fn a_resumed_open_finding_reaches_review_and_closing_prompts() {
4875 let open = vec![finding(
4876 "blocking",
4877 "Retry bypasses the limit",
4878 "reproduced with max_attempts set to one",
4879 "src/net.rs:88",
4880 true,
4881 )];
4882 let review = review_prompt("main", 42, "Retry a 429", &Ledger::new(), &open, 2, 3);
4883 let close = close_prompt(
4884 "main",
4885 42,
4886 "Retry a 429",
4887 "9f8e7d6",
4888 Some(&[]),
4889 &Ledger::new(),
4890 &open,
4891 2,
4892 );
4893 for prompt in [review, close] {
4894 assert!(prompt.contains("Retry bypasses the limit"), "{prompt}");
4895 assert!(prompt.contains("src/net.rs:88"), "{prompt}");
4896 assert!(
4897 prompt.contains("reproduced with max_attempts set to one"),
4898 "{prompt}"
4899 );
4900 assert!(!prompt.contains('{'), "{prompt}");
4901 }
4902 }
4903
4904 #[test]
4908 fn a_minor_defect_has_a_severity_that_is_not_blocking() {
4909 assert!(
4910 REVIEW_PROMPT.contains("A minor defect belongs\n here as much as an improvement does")
4911 );
4912 for text in [
4917 schema::review().to_string(),
4918 crate::review_only::review_only_prompt().to_string(),
4919 ] {
4920 assert!(text.contains("as much as an improvement does"), "{text}");
4921 assert!(!text.contains("a genuine improvement"), "{text}");
4922 }
4923 }
4924
4925 #[test]
4928 fn doubt_resolves_away_from_the_field_that_gates() {
4929 for text in [REVIEW_PROMPT.to_string(), schema::review().to_string()] {
4930 assert!(text.contains("say your piece in the finding and label it non-blocking"));
4931 assert!(!text.contains("leave in_scope true"));
4932 }
4933 }
4934
4935 #[test]
4938 fn both_edit_prompts_ask_for_the_smallest_change_that_answers_the_point() {
4939 for prompt in [FIX_PROMPT, RESPOND_PROMPT] {
4940 assert!(
4941 prompt.contains("The smallest change that answers it is"),
4942 "{prompt}"
4943 );
4944 }
4945 assert!(RESPOND_PROMPT.contains("bigger than the\n problem it names"));
4946 }
4947
4948 #[test]
4949 fn a_fixed_disposition_must_explain_what_changed() {
4950 let flat = RESPOND_PROMPT
4951 .split_whitespace()
4952 .collect::<Vec<_>>()
4953 .join(" ");
4954 assert!(
4955 flat.contains("For fixed, say what changed and how it answers the point"),
4956 "{RESPOND_PROMPT}"
4957 );
4958 }
4959
4960 #[test]
4961 fn an_empty_fix_reason_still_renders_as_a_claim_to_check() {
4962 let mut ledger = ledger_with("Unchecked error", "src/net.rs");
4963 for entry in ledger.values_mut() {
4964 entry.outcome = Settled::Fixed;
4965 entry.reasoning.clear();
4966 }
4967
4968 let lines = fixed_lines(&ledger, 0);
4969
4970 assert_eq!(1, lines.len());
4971 assert!(lines[0].contains("a committed change claims to address this point"));
4972 assert!(!lines[0].contains("The author said"));
4973 }
4974
4975 #[test]
4980 fn the_implementor_is_given_the_link_and_the_body() {
4981 let prompt = implement_prompt(
4982 42,
4983 "Retry a 429",
4984 "https://github.com/o/r/issues/42",
4985 "A rate limited response was treated as fatal.",
4986 );
4987 assert!(
4988 prompt.contains("https://github.com/o/r/issues/42"),
4989 "{prompt}"
4990 );
4991 assert!(
4992 prompt.contains("A rate limited response was treated as fatal."),
4993 "{prompt}"
4994 );
4995 assert!(prompt.contains("#42"), "{prompt}");
4996 assert!(prompt.contains("Retry a 429"), "{prompt}");
4997 assert!(!prompt.contains('{'), "{prompt}");
4999 }
5000
5001 #[test]
5004 fn the_prompt_says_the_discussion_is_not_included() {
5005 let prompt = implement_prompt(1, "t", "u", "b");
5006 let lower = prompt
5008 .split_whitespace()
5009 .collect::<Vec<_>>()
5010 .join(" ")
5011 .to_lowercase();
5012 assert!(
5013 lower.contains("discussion since is not included"),
5014 "{prompt}"
5015 );
5016 assert!(lower.contains("cannot reach the network"), "{prompt}");
5017 }
5018
5019 fn worked() -> Implementation {
5021 Implementation {
5022 summary: "Retry a 429 instead of failing the run.".into(),
5023 problem: "A rate limited response was treated as fatal, so one throttled call ended \
5024 a run that had hours of work left in it."
5025 .into(),
5026 changes: vec![
5027 "`send` retries a 429 with the delay the header asks for".into(),
5028 "the retry budget is bounded, so a permanent 429 still ends".into(),
5029 ],
5030 testing: vec![
5031 "`cargo test retries_a_429`".into(),
5032 "point it at a throttled endpoint and watch it finish".into(),
5033 ],
5034 ..Implementation::default()
5035 }
5036 }
5037
5038 #[test]
5039 fn a_pr_body_is_what_it_closes_and_what_changed() {
5042 let body = pr_body(42, &worked(), &style());
5043 assert_eq!(
5044 "Closes #42\n\n\
5045 Retry a 429 instead of failing the run.\n\n\
5046 A rate limited response was treated as fatal, so one throttled call \
5047 ended a run that had hours of work left in it.\n\n\
5048 ## What changed\n\n\
5049 - `send` retries a 429 with the delay the header asks for\n\
5050 - the retry budget is bounded, so a permanent 429 still ends\n\n\
5051 ## How to test\n\n\
5052 - `cargo test retries_a_429`\n\
5053 - point it at a throttled endpoint and watch it finish",
5054 body
5055 );
5056 }
5057
5058 #[test]
5061 fn a_body_with_nothing_to_list_carries_no_empty_headings() {
5062 let work = Implementation {
5063 summary: "Retry a 429 instead of failing the run.".into(),
5064 ..Implementation::default()
5065 };
5066 assert_eq!(
5067 "Closes #42\n\nRetry a 429 instead of failing the run.",
5068 pr_body(42, &work, &style())
5069 );
5070 }
5071
5072 #[test]
5073 fn a_pr_body_survives_an_implementor_that_said_nothing() {
5074 assert_eq!(
5075 "Closes #7",
5076 pr_body(7, &Implementation::default(), &style())
5077 );
5078 }
5079
5080 #[test]
5083 fn blank_list_entries_do_not_earn_a_heading() {
5084 let work = Implementation {
5085 summary: "Did a thing.".into(),
5086 changes: vec![String::new(), " ".into()],
5087 ..Implementation::default()
5088 };
5089 let body = pr_body(42, &work, &style());
5090 assert!(!body.contains("What changed"), "{body}");
5091 }
5092
5093 #[test]
5094 fn notes_appear_only_when_there_is_something_to_note() {
5095 let mut work = worked();
5096 assert!(!pr_body(42, &work, &style()).contains("## Notes"));
5097 work.notes = Some("The retry is not applied to streaming calls.".into());
5098 let body = pr_body(42, &work, &style());
5099 assert!(body.contains("## Notes"), "{body}");
5100 assert!(body.contains("streaming calls"), "{body}");
5101 }
5102
5103 #[test]
5106 fn declining_posts_the_reason_and_not_the_summary() {
5107 let work = Implementation {
5108 not_worth_doing: true,
5109 reason: "Already fixed in 1.2, and the report predates it.".into(),
5110 summary: "Nothing to do.".into(),
5111 ..Implementation::default()
5112 };
5113 assert_eq!(
5114 "Already fixed in 1.2, and the report predates it.",
5115 no_pr_note(&work, &style())
5116 );
5117 }
5118
5119 #[test]
5120 fn reporting_work_and_committing_none_says_that_rather_than_the_summary() {
5121 let work = Implementation {
5122 summary: "Retry a 429 instead of failing the run.".into(),
5123 ..Implementation::default()
5124 };
5125 let note = no_pr_note(&work, &style());
5126 assert_eq!(
5127 "Nothing was committed, so there is nothing to review.",
5128 note
5129 );
5130 }
5131
5132 #[test]
5133 fn declining_without_a_reason_still_says_something() {
5134 let work = Implementation {
5135 not_worth_doing: true,
5136 ..Implementation::default()
5137 };
5138 assert!(no_pr_note(&work, &style()).contains("no reason given"));
5139 }
5140
5141 #[test]
5142 fn a_skip_comment_is_only_the_reasoning() {
5143 let item = SkippedItem {
5144 issue: 3,
5145 title: "t".into(),
5146 tracker: false,
5147 reasons: [
5148 ("claude".to_string(), "Already fixed in 1.2.".to_string()),
5149 ("codex".to_string(), "Duplicate of #2.".to_string()),
5150 ]
5151 .into_iter()
5152 .collect(),
5153 };
5154 let text = skip_comment(&item, &style());
5155 assert!(text.contains("Already fixed in 1.2."), "{text}");
5156 assert!(text.contains("Duplicate of #2."), "{text}");
5157 assert!(
5158 !text.contains("claude") && !text.contains("codex"),
5159 "{text}"
5160 );
5161 assert!(!text.to_lowercase().contains("not scheduled"), "{text}");
5162 assert!(text.lines().count() <= 3, "{text}");
5163 }
5164
5165 #[test]
5166 fn findings_for_a_model_keep_full_detail() {
5167 let long = "x".repeat(2000);
5168 let text = findings_for_prompt(&[finding("blocking", "T", &long, "a.rs", true)]);
5169 assert!(
5170 text.contains(&long),
5171 "a model needs the whole finding, only humans need brevity"
5172 );
5173 }
5174
5175 #[test]
5176 fn findings_for_a_model_are_never_empty() {
5177 assert_eq!("(none)", findings_for_prompt(&[]));
5178 }
5179}
5180
5181#[cfg(test)]
5182mod outcome_tests {
5183 use super::*;
5184 use crate::model::{Dispute, Severity};
5185
5186 fn style() -> Style {
5187 Style::default()
5188 }
5189
5190 fn state_with(disputes: Vec<(&str, &str)>, filed: Vec<&str>) -> IssueRun {
5191 let mut s = IssueRun::new(482, "t");
5192 s.disputes = disputes
5193 .into_iter()
5194 .map(|(title, reasoning)| Dispute {
5195 title: title.into(),
5196 file: String::new(),
5197 reasoning: reasoning.into(),
5198 })
5199 .collect();
5200 s.filed = filed.into_iter().map(String::from).collect();
5201 s
5202 }
5203
5204 fn finding(title: &str, file: &str) -> Finding {
5205 Finding {
5206 severity: Severity::Blocking,
5207 title: title.into(),
5208 detail: "d".into(),
5209 file: file.into(),
5210 in_scope: true,
5211 ..Default::default()
5212 }
5213 }
5214
5215 fn graded(severity: Severity, title: &str, file: &str, in_scope: bool) -> Finding {
5216 Finding {
5217 severity,
5218 in_scope,
5219 ..finding(title, file)
5220 }
5221 }
5222
5223 #[test]
5224 fn outcome_mode_routes_final_results_to_the_configured_sink() {
5225 assert_eq!(OutcomeSink::PullRequest, outcome_sink(PrComments::Outcome));
5226 assert_eq!(OutcomeSink::PullRequest, outcome_sink(PrComments::Rounds));
5227 assert_eq!(OutcomeSink::Terminal, outcome_sink(PrComments::None));
5228 }
5229
5230 #[test]
5233 fn a_clean_approval_says_nothing() {
5234 let state = state_with(vec![], vec![]);
5235 assert!(outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).is_none());
5236 }
5237
5238 #[test]
5243 fn a_downgraded_finding_still_reaches_the_pull_request() {
5244 let mut state = state_with(vec![], vec![]);
5245 let kept = graded(
5246 Severity::NonBlocking,
5247 "Timeout is not configurable",
5248 "n.rs",
5249 true,
5250 );
5251 record_nonblocking_outcome(&mut state, &kept, None);
5252
5253 assert_eq!(1, state.noted.len());
5256
5257 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
5258 assert!(text.contains("Noted, not blocking"), "{text}");
5259 assert!(
5260 text.contains("Timeout is not configurable (n.rs)"),
5261 "{text}"
5262 );
5263 }
5264
5265 #[test]
5268 fn a_point_that_was_filed_is_not_also_noted() {
5269 let mut state = state_with(vec![], vec![]);
5270 let finding = graded(Severity::NonBlocking, "Timeout", "n.rs", true);
5271 record_nonblocking_outcome(&mut state, &finding, None);
5272 record_nonblocking_outcome(
5273 &mut state,
5274 &finding,
5275 Some(&Followup::Recorded("https://example.invalid/9".into())),
5276 );
5277 assert!(state.noted.is_empty());
5278 assert_eq!(vec!["https://example.invalid/9"], state.filed);
5279 }
5280
5281 #[test]
5282 fn filing_a_moved_point_removes_its_earlier_note() {
5283 let mut state = state_with(vec![], vec![]);
5284 let earlier = graded(Severity::NonBlocking, "Timeout", "src/net.rs:10", true);
5285 let moved = graded(Severity::NonBlocking, "Timeout", "src/net.rs:12", false);
5286 record_nonblocking_outcome(&mut state, &earlier, None);
5287
5288 record_nonblocking_outcome(
5289 &mut state,
5290 &moved,
5291 Some(&Followup::Recorded("https://example.invalid/10".into())),
5292 );
5293
5294 assert!(state.noted.is_empty());
5295 assert_eq!(vec!["https://example.invalid/10"], state.filed);
5296 }
5297
5298 #[test]
5299 fn an_unrecorded_nonblocking_followup_remains_noted() {
5300 let finding = graded(Severity::NonBlocking, "Timeout", "n.rs", true);
5301 for outcome in [
5302 Followup::Covered("https://example.invalid/closed".into()),
5303 Followup::Dropped("follow-ups are off"),
5304 Followup::Failed,
5305 ] {
5306 let mut state = state_with(vec![], vec![]);
5307 record_nonblocking_outcome(&mut state, &finding, Some(&outcome));
5308 assert_eq!(1, state.noted.len(), "{outcome:?}");
5309 assert!(state.filed.is_empty(), "{outcome:?}");
5310 }
5311 }
5312
5313 #[test]
5315 fn a_point_noted_twice_is_listed_once() {
5316 let mut state = state_with(vec![], vec![]);
5317 let raised = graded(
5318 Severity::NonBlocking,
5319 "Timeout is not configurable",
5320 "n.rs",
5321 true,
5322 );
5323 let reworded = graded(
5324 Severity::NonBlocking,
5325 "timeout is not configurable!",
5326 "n.rs",
5327 true,
5328 );
5329 record_nonblocking_outcome(&mut state, &raised, None);
5330 record_nonblocking_outcome(&mut state, &reworded, None);
5331 assert_eq!(1, state.noted.len());
5332 }
5333
5334 #[test]
5335 fn same_title_notes_in_different_files_are_both_kept() {
5336 let mut state = state_with(vec![], vec![]);
5337 for file in ["src/a.rs", "src/b.rs"] {
5338 let finding = graded(Severity::NonBlocking, "Unchecked error", file, true);
5339 record_nonblocking_outcome(&mut state, &finding, None);
5340 }
5341 assert_eq!(2, state.noted.len());
5342 }
5343
5344 #[test]
5345 fn same_title_notes_at_two_sites_in_one_review_are_both_kept() {
5346 let findings = vec![
5347 graded(
5348 Severity::NonBlocking,
5349 "Unchecked error",
5350 "src/a.rs:10",
5351 true,
5352 ),
5353 graded(
5354 Severity::NonBlocking,
5355 "Unchecked error",
5356 "src/a.rs:200",
5357 true,
5358 ),
5359 ];
5360 let mut state = state_with(vec![], vec![]);
5361
5362 for finding in &findings {
5363 record_nonblocking_outcome_with_match(
5364 &mut state,
5365 finding,
5366 None,
5367 unique_stable_finding(&findings, finding),
5368 );
5369 }
5370
5371 assert_eq!(2, state.noted.len());
5372 }
5373
5374 #[test]
5375 fn settling_one_of_two_same_title_notes_keeps_the_other() {
5376 let first = graded(
5377 Severity::NonBlocking,
5378 "Unchecked error",
5379 "src/a.rs:10",
5380 true,
5381 );
5382 let second = graded(
5383 Severity::NonBlocking,
5384 "Unchecked error",
5385 "src/a.rs:200",
5386 true,
5387 );
5388 let mut state = state_with(vec![], vec![]);
5389 remember_noted(&mut state, &first, false);
5390 remember_noted(&mut state, &second, false);
5391
5392 forget_noted(&mut state, &first, false);
5393
5394 assert_eq!(1, state.noted.len());
5395 assert_eq!("src/a.rs:200", state.noted[0].file);
5396 }
5397
5398 #[test]
5399 fn same_title_disputes_at_two_sites_are_both_kept() {
5400 let mut state = state_with(vec![], vec![]);
5401 for file in ["src/a.rs:10", "src/a.rs:200"] {
5402 remember_dispute(
5403 &mut state,
5404 Dispute {
5405 title: "Unchecked error".into(),
5406 file: file.into(),
5407 reasoning: "the caller handles it".into(),
5408 },
5409 false,
5410 );
5411 }
5412
5413 assert_eq!(2, state.disputes.len());
5414 }
5415
5416 #[test]
5417 fn a_later_nonblocking_verdict_replaces_a_prior_dispute() {
5418 let mut state = state_with(vec![], vec![]);
5419 let finding = graded(Severity::NonBlocking, "Unchecked error", "src/a.rs", true);
5420 remember_dispute(
5421 &mut state,
5422 Dispute {
5423 title: finding.title.clone(),
5424 file: finding.file.clone(),
5425 reasoning: "the caller handles it".into(),
5426 },
5427 true,
5428 );
5429
5430 record_nonblocking_outcome(&mut state, &finding, None);
5431
5432 assert!(state.disputes.is_empty());
5433 assert_eq!(1, state.noted.len());
5434 }
5435
5436 #[test]
5437 fn a_note_moving_lines_in_the_same_file_is_updated() {
5438 let mut state = state_with(vec![], vec![]);
5439 let first = graded(
5440 Severity::NonBlocking,
5441 "Unchecked error",
5442 "src/a.rs:12",
5443 true,
5444 );
5445 let moved = graded(
5446 Severity::NonBlocking,
5447 "Unchecked error",
5448 "src/a.rs:19",
5449 true,
5450 );
5451 record_nonblocking_outcome(&mut state, &first, None);
5452 record_nonblocking_outcome(&mut state, &moved, None);
5453 assert_eq!(1, state.noted.len());
5454 assert_eq!("src/a.rs:19", state.noted[0].file);
5455 }
5456
5457 #[test]
5458 fn a_settled_point_removes_its_stale_note() {
5459 for outcome in [Settled::Fixed, Settled::Refuted, Settled::Filed] {
5460 let noted = graded(Severity::NonBlocking, "Unchecked error", "src/a.rs", true);
5461 let mut state = state_with(vec![], vec![]);
5462 record_nonblocking_outcome(&mut state, ¬ed, None);
5463 forget_noted(&mut state, ¬ed, true);
5464 assert!(state.noted.is_empty(), "{outcome}");
5465 }
5466 }
5467
5468 #[test]
5469 fn settling_a_moved_point_removes_its_old_dispute() {
5470 let old = graded(Severity::Blocking, "Unchecked error", "src/a.rs:10", true);
5471 let moved = graded(Severity::Blocking, "Unchecked error", "src/a.rs:12", true);
5472 let mut state = state_with(vec![], vec![]);
5473 remember_dispute(
5474 &mut state,
5475 Dispute {
5476 title: old.title,
5477 file: old.file,
5478 reasoning: "the caller handles it".into(),
5479 },
5480 true,
5481 );
5482
5483 forget_dispute(&mut state, &moved, true);
5484
5485 assert!(state.disputes.is_empty());
5486 }
5487
5488 #[test]
5491 fn a_deadlocked_point_is_not_also_noted() {
5492 let mut state = state_with(vec![], vec![]);
5493 let noted = graded(Severity::NonBlocking, "Unbounded loop", "x.rs", true);
5494 record_nonblocking_outcome(&mut state, ¬ed, None);
5495 let points = vec![finding("Unbounded loop", "x.rs")];
5496 let text = outcome_comment(
5497 &state,
5498 &Ledger::new(),
5499 &Ending::Deadlocked(&points),
5500 &style(),
5501 )
5502 .unwrap();
5503 assert!(!text.contains("Noted, not blocking"), "{text}");
5504 }
5505
5506 #[test]
5507 fn clipping_a_rendered_title_does_not_break_duplicate_suppression() {
5508 let mut compact = style();
5509 compact.max_title_chars = 5;
5510 let mut state = state_with(
5511 vec![("abcdefghij", "the caller already handles it")],
5512 vec![],
5513 );
5514 state
5515 .noted
5516 .push(graded(Severity::NonBlocking, "abcdefghij", "x.rs", true));
5517 state.disputes[0].file = "x.rs".into();
5518 let points = vec![finding("abcdefghij", "x.rs")];
5519
5520 let text = outcome_comment(
5521 &state,
5522 &Ledger::new(),
5523 &Ending::Unresolved(&points),
5524 &compact,
5525 )
5526 .unwrap();
5527
5528 assert!(!text.contains("Raised and refuted"), "{text}");
5529 assert!(!text.contains("Noted, not blocking"), "{text}");
5530 }
5531
5532 #[test]
5533 fn an_approval_that_filed_follow_ups_links_them() {
5534 let state = state_with(
5535 vec![],
5536 vec![
5537 "https://github.com/you/thing/issues/485",
5538 "https://github.com/you/thing/issues/486",
5539 ],
5540 );
5541 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
5542 assert!(text.contains("Filed separately: #485, #486"), "{text}");
5543 }
5544
5545 #[test]
5549 fn a_run_that_changed_nothing_does_not_claim_there_is_something_to_read() {
5550 let state = state_with(vec![], vec![]);
5551 let text = outcome_comment(&state, &Ledger::new(), &Ending::Unchanged, &style()).unwrap();
5552 assert!(text.contains("changed nothing"), "{text}");
5553 assert!(!text.contains("was pushed"), "{text}");
5554 }
5555
5556 #[test]
5560 fn an_unresolved_close_names_what_is_still_wrong() {
5561 let state = state_with(vec![], vec![]);
5562 let left = vec![Finding {
5563 detail: "The guard sits after the early return.".into(),
5564 ..finding("The retry fix never reaches the 429 path", "src/net.rs:88")
5565 }];
5566 let text =
5567 outcome_comment(&state, &Ledger::new(), &Ending::Unresolved(&left), &style()).unwrap();
5568 assert!(text.contains("These points are still open"), "{text}");
5569 assert!(
5570 text.contains("The retry fix never reaches the 429 path (src/net.rs:88)"),
5571 "{text}"
5572 );
5573 assert!(
5574 text.contains("The guard sits after the early return."),
5575 "{text}"
5576 );
5577 assert!(!text.contains("has not been reviewed"), "{text}");
5580 }
5581
5582 #[test]
5586 fn running_out_of_rounds_says_what_that_means_for_the_reader() {
5587 let state = state_with(vec![], vec![]);
5588 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
5589 assert!(text.contains("has not been reviewed"), "{text}");
5590 assert!(
5591 !text.to_lowercase().contains("round 3"),
5592 "no round numbers: {text}"
5593 );
5594 assert!(!text.to_lowercase().contains("convergence"), "{text}");
5595 }
5596
5597 #[test]
5598 fn a_failed_close_reports_unread_fixes_and_carried_blockers() {
5599 let state = state_with(vec![], vec![]);
5600 let open = vec![Finding {
5601 detail: "the failure is still discarded".into(),
5602 ..finding("Unchecked error", "src/net.rs:88")
5603 }];
5604 let text = outcome_comment_with_unread(
5605 &state,
5606 &Ledger::new(),
5607 &Ending::OutOfRounds,
5608 &open,
5609 &style(),
5610 )
5611 .unwrap();
5612 assert!(text.contains("has not been reviewed"), "{text}");
5613 assert!(text.contains("These points were already open"), "{text}");
5614 assert!(text.contains("Unchecked error (src/net.rs:88)"), "{text}");
5615 }
5616
5617 #[test]
5618 fn a_deadlock_names_the_point_they_could_not_settle() {
5619 let state = state_with(vec![], vec![]);
5620 let points = [finding("Retry loop never terminates", "src/net.rs:88")];
5621 let text = outcome_comment(
5622 &state,
5623 &Ledger::new(),
5624 &Ending::Deadlocked(&points),
5625 &style(),
5626 )
5627 .unwrap();
5628 assert!(
5629 text.contains("Retry loop never terminates (src/net.rs:88)"),
5630 "{text}"
5631 );
5632 assert!(text.contains("could not settle"), "{text}");
5633 }
5634
5635 #[test]
5637 fn refutations_survive_because_nothing_else_carries_them() {
5638 let state = state_with(
5639 vec![(
5640 "Error is swallowed",
5641 "the caller already validates the file",
5642 )],
5643 vec![],
5644 );
5645 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
5646 assert!(text.contains("Raised and refuted:"), "{text}");
5647 assert!(
5648 text.contains("The caller already validates the file"),
5649 "{text}"
5650 );
5651 }
5652
5653 #[test]
5654 fn no_agent_names_counts_or_round_numbers_reach_the_thread() {
5655 let state = state_with(
5656 vec![("A point", "a reason")],
5657 vec!["https://github.com/you/thing/issues/485"],
5658 );
5659 let left = vec![finding("A point", "a.rs")];
5660 for ending in [
5661 Ending::Approved,
5662 Ending::OutOfRounds,
5663 Ending::Unresolved(&left),
5664 ] {
5665 let text = outcome_comment(&state, &Ledger::new(), &ending, &style()).unwrap();
5666 let lower = text.to_lowercase();
5667 for banned in ["claude", "codex", "blocking,", "nit,", " fixed."] {
5668 assert!(
5669 !lower.contains(banned),
5670 "{banned:?} leaked into the thread:\n{text}"
5671 );
5672 }
5673 for n in 1..9 {
5675 assert!(
5676 !lower.contains(&format!("round {n}")),
5677 "a round number leaked into the thread:\n{text}"
5678 );
5679 }
5680 }
5681 }
5682
5683 #[test]
5684 fn a_refutation_is_allowed_to_make_its_case() {
5687 let reasoning = "The caller validates against the schema first. \
5688 The discarded error is therefore unreachable in practice. ";
5689 let state = state_with(
5690 vec![("A point", &reasoning.repeat(6))],
5691 vec!["https://github.com/you/thing/issues/485"],
5692 );
5693 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
5694 assert!(
5695 !text.contains("..."),
5696 "nothing was cut mid thought:\n{text}"
5697 );
5698 assert!(text.len() < 4000, "{} chars", text.len());
5699 }
5700
5701 #[test]
5702 fn a_url_that_is_not_an_issue_link_is_left_alone() {
5703 assert_eq!(
5704 "#485",
5705 as_reference("https://github.com/you/thing/issues/485")
5706 );
5707 assert_eq!("note: something", as_reference("note: something"));
5708 }
5709}
5710
5711#[cfg(test)]
5712mod filed_reference_tests {
5713 use super::*;
5714
5715 #[test]
5716 fn an_issue_url_yields_its_number() {
5717 assert_eq!(
5718 Some(485),
5719 filed_issue_number("https://github.com/you/thing/issues/485")
5720 );
5721 }
5722
5723 #[test]
5726 fn a_local_note_yields_nothing() {
5727 assert_eq!(None, filed_issue_number("note: Retry is unbounded"));
5728 assert_eq!(None, filed_issue_number(""));
5729 assert_eq!(
5730 None,
5731 filed_issue_number("https://github.com/you/thing/issues/")
5732 );
5733 }
5734}
5735
5736#[cfg(test)]
5737mod followup_restraint_tests {
5738 use super::*;
5739 use crate::model::Severity;
5740
5741 fn cfg_with(followups: Followups, non_blocking: bool, nits: bool, cap: usize) -> Config {
5742 let mut cfg =
5743 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
5744 .unwrap();
5745 cfg.loop_cfg.followups = followups;
5746 cfg.loop_cfg.file_non_blocking = non_blocking;
5747 cfg.loop_cfg.file_nits = nits;
5748 cfg.loop_cfg.max_followups = cap;
5749 cfg
5750 }
5751
5752 fn finding(severity: Severity, title: &str, in_scope: bool) -> Finding {
5753 Finding {
5754 severity,
5755 title: title.into(),
5756 detail: "d".into(),
5757 file: "a.rs".into(),
5758 in_scope,
5759 ..Default::default()
5760 }
5761 }
5762
5763 #[test]
5767 fn a_non_blocking_finding_is_not_a_tracker_item_by_default() {
5768 let cfg = cfg_with(Followups::Issues, false, false, 5);
5769 assert!(!cfg.loop_cfg.file_non_blocking);
5770 assert!(!cfg.loop_cfg.file_nits);
5771 }
5772
5773 #[test]
5774 fn follow_ups_stay_off_the_tracker_by_default() {
5775 let cfg =
5776 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
5777 .unwrap();
5778 assert_eq!(
5779 Followups::Local,
5780 cfg.loop_cfg.followups,
5781 "the tracker is somebody's queue; the default must not write to it"
5782 );
5783 assert_eq!(5, cfg.loop_cfg.max_followups);
5784 }
5785
5786 #[test]
5788 fn only_out_of_scope_defects_qualify_at_the_defaults() {
5789 let cfg = cfg_with(Followups::Issues, false, false, 5);
5790 let qualifies = |f: &Finding| match f.severity {
5791 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking && f.in_scope,
5792 Severity::Nit => cfg.loop_cfg.file_nits && f.in_scope,
5793 Severity::Blocking => false,
5794 } || !f.in_scope;
5795
5796 assert!(qualifies(&finding(
5797 Severity::Blocking,
5798 "pre-existing",
5799 false
5800 )));
5801 assert!(!qualifies(&finding(
5802 Severity::NonBlocking,
5803 "improvement",
5804 true
5805 )));
5806 assert!(!qualifies(&finding(Severity::Nit, "taste", true)));
5807 assert!(!qualifies(&finding(
5808 Severity::Blocking,
5809 "fix it here",
5810 true
5811 )));
5812 }
5813
5814 #[test]
5815 fn opening_it_up_lets_non_blocking_findings_through_again() {
5816 let cfg = cfg_with(Followups::Issues, true, false, 5);
5817 assert!(cfg.loop_cfg.file_non_blocking);
5818 }
5819
5820 #[test]
5822 fn the_cap_is_a_real_backstop() {
5823 let cfg = cfg_with(Followups::Issues, false, false, 3);
5824 let mut state = IssueRun::new(1, "t");
5825 state.filed = (0..3).map(|n| format!("url{n}")).collect();
5826 assert!(state.filed.len() >= cfg.loop_cfg.max_followups);
5827 }
5828
5829 #[test]
5833 fn the_cap_bounds_what_one_run_can_spawn() {
5834 let cfg = cfg_with(Followups::Issues, false, false, 5);
5835 assert!(
5836 cfg.loop_cfg.max_followups <= 5,
5837 "a run that can file ten follow-ups is a branching process"
5838 );
5839 }
5840}
5841
5842#[cfg(test)]
5846mod followup_outcome_tests {
5847 use super::*;
5848
5849 const URL: &str = "https://github.com/you/thing/issues/485";
5850
5851 fn entry(recorded: Followup) -> Option<(Settled, String)> {
5852 filed_entry(&recorded, "It predates this branch.")
5853 }
5854
5855 #[test]
5859 fn a_failed_followup_settles_nothing() {
5860 assert_eq!(None, entry(Followup::Failed));
5861 }
5862
5863 #[test]
5864 fn an_uncertain_external_write_blocks_later_issue_followups_for_this_run() {
5865 let mut state = IssueRun::new(1, "review");
5866 let uncertain = SparError::uncertain_write("the result could not be verified");
5867 assert_eq!(Followup::Failed, failed_followup(&mut state, &uncertain));
5868 assert!(external_followup_write_paused(Followups::Issues, &state));
5869 assert!(!external_followup_write_paused(Followups::Local, &state));
5870 assert_eq!(1, state.notes.len());
5871
5872 assert_eq!(Followup::Failed, failed_followup(&mut state, &uncertain));
5873 assert_eq!(1, state.notes.len(), "the recovery note was duplicated");
5874
5875 let mut ordinary = IssueRun::new(2, "review");
5876 let error = SparError::new("permission denied");
5877 assert_eq!(Followup::Failed, failed_followup(&mut ordinary, &error));
5878 assert!(!external_followup_write_paused(
5879 Followups::Issues,
5880 &ordinary
5881 ));
5882 }
5883
5884 #[test]
5885 fn a_recorded_followup_is_filed_and_says_where() {
5886 let (outcome, reasoning) = entry(Followup::Recorded(URL.into())).unwrap();
5887 assert_eq!(Settled::Filed, outcome);
5888 assert!(
5889 reasoning.contains("It predates this branch."),
5890 "{reasoning}"
5891 );
5892 assert!(reasoning.contains("#485"), "{reasoning}");
5893 }
5894
5895 #[test]
5898 fn a_closed_issue_covering_the_point_settles_it_without_offering_work() {
5899 let recorded = Followup::from(Filed::AlreadyClosed(9, URL.into()));
5900 assert_eq!(Followup::Covered(URL.into()), recorded);
5901 assert_eq!(
5902 None,
5903 recorded.url(),
5904 "a closed issue is not work to pick up"
5905 );
5906
5907 let (outcome, reasoning) = entry(recorded).unwrap();
5908 assert_eq!(Settled::Filed, outcome);
5909 assert!(reasoning.contains("#485"), "{reasoning}");
5910 }
5911
5912 #[test]
5915 fn an_open_issue_that_already_covers_the_point_is_still_a_reference() {
5916 for filed in [
5917 Filed::Opened(9, URL.into()),
5918 Filed::AddedTo(9, URL.into()),
5919 Filed::Covered(9, URL.into()),
5920 ] {
5921 assert_eq!(Some(URL), Followup::from(filed).url());
5922 }
5923 }
5924
5925 #[test]
5929 fn a_dropped_followup_is_settled_but_never_reported_as_filed() {
5930 let (outcome, reasoning) = entry(Followup::Dropped("follow-ups are off")).unwrap();
5931 assert_eq!(Settled::Dropped, outcome);
5932 assert!(reasoning.contains("follow-ups are off"), "{reasoning}");
5933 assert!(reasoning.contains("Not filed"), "{reasoning}");
5934 }
5935
5936 fn ledger_of(outcome: Settled, reasoning: &str) -> Ledger {
5937 let mut ledger = Ledger::new();
5938 ledger.insert(
5939 finding_key("A pre-existing leak", "src/x.rs"),
5940 LedgerEntry {
5941 title: "A pre-existing leak".into(),
5942 file: "src/x.rs".into(),
5943 reasoning: reasoning.into(),
5944 round: 1,
5945 reraised: 0,
5946 outcome,
5947 },
5948 );
5949 ledger
5950 }
5951
5952 #[test]
5956 fn the_settled_block_tells_a_filed_point_from_a_dropped_one() {
5957 let filed = settled_block(&ledger_of(Settled::Filed, "Tracked in #9."));
5958 assert!(filed.contains("out of scope here, and filed"), "{filed}");
5959
5960 let dropped = settled_block(&ledger_of(Settled::Dropped, "Not filed anywhere: off."));
5961 assert!(
5962 dropped.contains("out of scope here, and not filed"),
5963 "{dropped}"
5964 );
5965 assert!(dropped.contains("A pre-existing leak"), "{dropped}");
5966 }
5967
5968 #[test]
5971 fn a_deadlocked_point_that_was_never_filed_does_not_claim_to_be() {
5972 let points = [Finding {
5973 severity: Severity::Blocking,
5974 title: "A pre-existing leak".into(),
5975 detail: "d".into(),
5976 file: "src/x.rs".into(),
5977 in_scope: false,
5978 ..Default::default()
5979 }];
5980 let text = outcome_comment(
5981 &IssueRun::new(1, "t"),
5982 &ledger_of(Settled::Dropped, "Not filed anywhere: follow-ups are off."),
5983 &Ending::Deadlocked(&points),
5984 &Style::default(),
5985 )
5986 .unwrap();
5987 assert!(text.contains("not filed"), "{text}");
5988 assert!(!text.contains("Filed as out of scope"), "{text}");
5989 }
5990}
5991
5992#[cfg(test)]
5993mod issue_report_tests {
5994 use super::*;
5995 use crate::model::Severity;
5996
5997 fn reported() -> Finding {
6000 Finding {
6001 severity: Severity::Blocking,
6002 title: "sendPaymentAsync bypasses drain mode and spending limits".into(),
6003 detail: "The async path skips every admission check payInvoice applies.".into(),
6004 file: "src/node.ts:412".into(),
6005 in_scope: false,
6006 problem: Some(
6007 "`BeignetNode.sendPaymentAsync()` submits a payment directly to the Lightning \
6008 engine without applying the safeguards used by `payInvoice()`.\n\nThe async path \
6009 does not:\n\n- call `_checkDraining()`\n- call `_checkSpendLimit()`"
6010 .into(),
6011 ),
6012 reproduction: Some(
6013 "1. Create a `BeignetNode` with `dailySpendLimitSats: 1`.\n2. Enable drain mode.\n\
6014 3. Submit a 1,000 sat invoice.\n\nActual result:\n\n- The engine is called.\n\
6015 - `spentSats` remains 0."
6016 .into(),
6017 ),
6018 impact: Some(
6019 "An authorized client can submit async payments up to the available outbound \
6020 liquidity despite the configured limits."
6021 .into(),
6022 ),
6023 expected: Some(
6024 "- Reject new payments while draining.\n- Enforce the per-payment limit before \
6025 submission.\n- Cover both paths with regression tests.\n\nThis predates the \
6026 current branch."
6027 .into(),
6028 ),
6029 }
6030 }
6031
6032 #[test]
6033 fn a_reported_finding_becomes_a_bug_report() {
6034 let body = issue_report(&reported());
6035 for heading in [
6036 "## Problem",
6037 "## Reproduction",
6038 "## Impact",
6039 "## Expected behavior",
6040 ] {
6041 assert!(body.contains(heading), "missing {heading}:\n{body}");
6042 }
6043 let at = |h: &str| body.find(h).unwrap();
6045 assert!(at("## Problem") < at("## Reproduction"));
6046 assert!(at("## Reproduction") < at("## Impact"));
6047 assert!(at("## Impact") < at("## Expected behavior"));
6048 }
6049
6050 #[test]
6051 fn the_substance_survives_the_outbound_gates() {
6052 let repo_style = Style::default();
6053 let body = crate::style::issue_body(&issue_report(&reported()), &repo_style);
6054 for kept in [
6055 "_checkDraining()",
6056 "Actual result:",
6057 "outbound liquidity",
6058 "regression tests",
6059 "predates the current branch",
6060 ] {
6061 assert!(body.contains(kept), "the gate ate {kept:?}:\n{body}");
6062 }
6063 assert!(!body.contains("..."), "something was cut:\n{body}");
6064 }
6065
6066 #[test]
6069 fn an_ordinary_finding_is_still_just_its_detail() {
6070 let plain = Finding {
6071 severity: Severity::NonBlocking,
6072 title: "Name is vague".into(),
6073 detail: "The variable could say what it holds.".into(),
6074 file: "a.rs".into(),
6075 in_scope: true,
6076 ..Default::default()
6077 };
6078 assert_eq!(
6079 "The variable could say what it holds.",
6080 issue_report(&plain)
6081 );
6082 }
6083
6084 #[test]
6087 fn only_the_sections_that_were_written_appear() {
6088 let partial = Finding {
6089 problem: Some("The guard is inverted.".into()),
6090 expected: Some("It should reject rather than accept.".into()),
6091 ..reported()
6092 };
6093 let partial = Finding {
6094 reproduction: None,
6095 impact: None,
6096 ..partial
6097 };
6098 let body = issue_report(&partial);
6099 assert!(body.contains("## Problem") && body.contains("## Expected behavior"));
6100 assert!(!body.contains("## Reproduction"), "{body}");
6101 assert!(!body.contains("## Impact"), "{body}");
6102 }
6103
6104 #[test]
6107 fn the_summary_line_is_not_printed_twice() {
6108 let echoed = Finding {
6109 detail: "The guard is inverted so it rejects valid input.".into(),
6110 problem: Some("The guard is inverted so it rejects valid input.".into()),
6111 reproduction: None,
6112 impact: None,
6113 expected: None,
6114 ..reported()
6115 };
6116 let body = issue_report(&echoed);
6117 assert_eq!(1, body.matches("The guard is inverted").count(), "{body}");
6118 }
6119}