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::{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, then commit it on the current branch. Make focused commits with
53clear messages. Do not push, do not open a PR, and do not merge; the harness
54handles that.
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 commits 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 and run that, rather than passing a long program on
86the command line: it is easier to read back, easier to rerun, and less likely to
87be refused by a sandbox or a safety filter part way through your work. An unverified blocking finding is worse than
88one you never raised: it stalls a good PR and teaches the author to stop
89believing you. If you suspect a problem but could not confirm it, say so and
90label it non-blocking.
91
92Set in_scope=false for a real defect that exists, that this PR did not cause, and
93that is worth somebody stopping to fix. Each one becomes a tracked item a
94maintainer has to read and triage, so the bar is a defect and not an observation.
95A thorough reviewer can always find something adjacent to what it is reading;
96that is not a reason to file it. If you are not sure it is worth a maintainer's
97time, say your piece in the finding and label it non-blocking.
98
99Reviewing one issue should not manufacture ten more. If you find yourself with
100several out of scope findings, keep the ones that would bite somebody and drop
101the rest.
102
103Then choose next_action:
104- merge: no blocking findings, the PR is good.
105- fix_myself: there are blocking findings and you will fix them directly.
106- hand_back: there are blocking findings the author should address.
107{open}{answers}{settled}{round}";
108
109const CLOSE_PROMPT: &str = "\
110This closes the review of issue #{number}: {title}
111
112This is the final merge-safety audit. Read the full branch against `{base}` and
113answer one question: does the branch still contain anything that must not
114merge. Do not spend this pass on optional improvements or style.
115{landed}{open}{answers}{settled}
116Something blocks here when the branch still contains a confirmed defect that
117means it should not merge. That includes an open point above that the code does
118not answer, a defect in what landed since the last round, or a serious defect an
119earlier round missed. Go and look before you raise it. Run the test that covers
120it, or read the relevant lines and follow them to the caller, and say in the
121detail what you did.
122
123Keep this pass focused on merge safety. Minor defects and improvements are
124non-blocking. Keep in_scope=false for what it has always meant, a real defect
125this pull request did not cause, which the harness handles according to the
126configured follow-up policy. A confirmed in-scope defect does not become
127non-blocking merely because an earlier round missed it.
128
129Nothing you raise here will be fixed, because there is no round after this. A
130blocking finding means the pull request stays open and the finding is reported
131for a person to weigh. Non-blocking findings are reported without holding it
132open. No blocking findings means the branch is signed off on your word, so do
133not omit one because the list was long. Both mistakes cost somebody. Only one
134of them ships.
135
136Set next_action to merge when you raise nothing blocking, and hand_back when you
137do. Nothing acts on it here, and the findings are what decide.
138
139This call reads and nothing else. Do not edit the code, do not commit, and do
140not push. A pass that writes has judged a branch the rollback then takes away,
141so anything you leave behind is rolled back and the sign off does not stand.
142Writing a scratch file to check a claim is fine.";
143
144const FIX_PROMPT: &str = "\
145You reviewed this branch and chose to fix the blocking findings yourself.
146Implement those fixes now and commit them.
147
148Your findings:
149{findings}
150
151Fix what the point says and nothing else. The smallest change that answers it is
152the right one: no refactor alongside it, no capability nobody asked for, no
153handling for cases nobody raised. Every line you add is what the next pass
154reviews, so a fix that grows the branch buys another round of findings about the
155fix. If a point cannot be answered without a change bigger than the point, say so
156rather than making the change.
157
158Commit your changes. Do not push, do not merge.";
159
160const RESPOND_PROMPT: &str = "\
161Here is a review of your PR for issue #{number}.
162
163{findings}
164
165For each point, choose exactly one disposition:
166- fixed: the point is valid and in scope. Fix it and commit.
167- refuted: the point is wrong, or the change it asks for is bigger than the
168 problem it names. Explain why. Refuting is a legitimate outcome; do not accept
169 a review comment you believe is incorrect just to get the PR approved.
170- filed_issue: the point is valid but unrelated to this PR. Supply
171 new_issue_title and new_issue_body; the harness files it and skips duplicates.
172
173Copy each finding's title and file across exactly as given, so your answer can
174be matched back to the review. Give a reason for every disposition. For fixed,
175say what changed and how it answers the point. For refuted, say why the point
176does not stand. For filed_issue, say why it belongs outside this pull request.
177
178Fix what the point says and nothing else. The smallest change that answers it is
179the right one: no refactor alongside it, no capability nobody asked for, no
180handling for cases nobody raised. Every line you add is what the next pass
181reviews, so a fix that grows the branch buys another round of findings about the
182fix. If a point cannot be answered without a change bigger than the point, say so
183rather than making the change.
184
185Commit any fixes. Do not push, do not merge.";
186
187#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct Snapshot {
198 pub head: String,
199 pub dirty: bool,
201}
202
203impl Snapshot {
204 pub fn landed_over(&self, before: &Snapshot) -> bool {
207 !self.head.is_empty() && self.head != before.head
208 }
209}
210
211pub fn snapshot(repo: &Repo, work_dir: &Path) -> Snapshot {
212 Snapshot {
213 head: repo
214 .git_try_at(Some(work_dir), &["rev-parse", "HEAD"])
215 .trim()
216 .to_string(),
217 dirty: !repo
218 .git_try_at(
219 Some(work_dir),
220 &["status", "--porcelain", "--untracked-files=no"],
221 )
222 .trim()
223 .is_empty(),
224 }
225}
226
227fn checked_head(repo: &Repo, work_dir: &Path) -> Result<String> {
228 let head = repo.git_at(Some(work_dir), &["rev-parse", "HEAD"])?;
229 let head = head.trim().to_string();
230 if head.is_empty() {
231 return Err(spar_err!("could not read the pull request head"));
232 }
233 Ok(head)
234}
235
236fn reconcile_saved_head(
243 recorded_head: Option<&str>,
244 actual_head: &str,
245 holder_override: Option<&str>,
246 pr_number: i64,
247) -> Result<bool> {
248 let Some(recorded_head) = recorded_head else {
249 return Ok(true);
250 };
251 if !recorded_head.is_empty() && recorded_head == actual_head {
252 return Ok(true);
253 }
254 if holder_override.is_some() {
255 return Ok(false);
256 }
257 let recorded = if recorded_head.is_empty() {
258 "legacy or unknown"
259 } else {
260 recorded_head
261 };
262 Err(spar_err!(
263 "saved review state applies to head {recorded}, but PR #{pr_number} is at {actual_head}; \
264 resume with --next <agent> to choose who reviews this head"
265 ))
266}
267
268pub fn park(repo: &Repo, work_dir: &Path) -> Option<String> {
275 let saved = repo
276 .git_try_at(Some(work_dir), &["stash", "create"])
277 .trim()
278 .to_string();
279 (!saved.is_empty()).then_some(saved)
280}
281
282fn reset_saving(repo: &Repo, work_dir: &Path, target: &str) {
289 let parked = park(repo, work_dir);
290 if let Err(e) = repo.git_at(Some(work_dir), &["reset", "--hard", target]) {
291 logdim!("could not roll the working tree back: {e}");
292 return;
293 }
294 if let Some(saved) = parked {
295 logdim!("`git stash apply {saved}` puts the discarded changes back");
296 }
297}
298
299pub fn undo_edits(repo: &Repo, work_dir: &Path, before: &Snapshot) -> Snapshot {
310 let current = snapshot(repo, work_dir);
311 if before.head.is_empty() {
312 return current;
313 }
314 if current.landed_over(before) {
315 logdim!(
316 "the commits being rolled back are still at {}",
317 current.head
318 );
319 }
320 reset_saving(repo, work_dir, &before.head);
321 snapshot(repo, work_dir)
322}
323
324fn preserve_closing_commit(repo: &Repo, ctx: &LoopCtx) -> Option<String> {
330 let current = snapshot(repo, &ctx.work_dir);
331 if current.head.is_empty() {
332 return None;
333 }
334 let reference = format!(
335 "refs/spar/recovery/pr-{}/closing-{}",
336 ctx.pr_number, current.head
337 );
338 match repo.git_at(
339 Some(&ctx.work_dir),
340 &["update-ref", &reference, ¤t.head],
341 ) {
342 Ok(_) => Some(reference),
343 Err(e) => {
344 logdim!("could not preserve the closing pass commit: {e}");
345 None
346 }
347 }
348}
349
350pub fn drop_uncommitted(repo: &Repo, work_dir: &Path) -> Snapshot {
357 let current = snapshot(repo, work_dir);
358 if !current.dirty || current.head.is_empty() {
359 return current;
360 }
361 reset_saving(repo, work_dir, ¤t.head);
362 snapshot(repo, work_dir)
363}
364
365fn should_release(cfg: &Config, status: Status) -> bool {
370 if !cfg.loop_cfg.worktrees || cfg.loop_cfg.keep_worktrees {
371 return false;
372 }
373 !matches!(status, Status::Escalated | Status::Error)
374}
375
376pub fn run_issue(
381 agents: &[Agent],
382 cfg: &Config,
383 repo: &Repo,
384 item: &PlanItem,
385 issue: &Issue,
386) -> IssueRun {
387 if let Some(existing) = repo.open_pr_for_issue(item.issue) {
395 log!(
396 "#{}: {} is already open, continuing it instead of implementing again",
397 item.issue,
398 existing.url
399 );
400 return resume_pr(agents, cfg, repo, existing.number, None);
401 }
402
403 let mut state = IssueRun::new(item.issue, item.title.clone());
404 let mut ledger = Ledger::new();
411 let base = cfg.base_branch().to_string();
412
413 let prepared = if cfg.loop_cfg.worktrees {
414 repo.worktree_add(item.issue, &base)
415 } else {
416 let branch = repo.branch_for_issue(item.issue);
417 let start = format!("origin/{base}");
418 repo.git(&["checkout", "-B", &branch, &start])
419 .map(|_| (repo.root().to_path_buf(), branch))
420 };
421
422 let (work_dir, branch) = match prepared {
423 Ok(pair) => pair,
424 Err(e) => {
425 state.status = Status::Error;
426 state.notes.push(e.to_string());
427 log!("#{} failed: {e}", item.issue);
428 return state;
429 }
430 };
431
432 let outcome = implement_and_review(
433 agents,
434 cfg,
435 repo,
436 item,
437 issue,
438 &mut ledger,
439 &mut state,
440 &work_dir,
441 &branch,
442 );
443 if let Err(e) = outcome {
444 state.status = Status::Error;
445 state.notes.push(e.to_string());
446 log!("#{} failed: {e}", item.issue);
447 }
448
449 if should_release(cfg, state.status) {
450 repo.worktree_remove(item.issue);
451 }
452 state
453}
454
455#[allow(clippy::too_many_arguments)]
456fn implement_and_review(
457 agents: &[Agent],
458 cfg: &Config,
459 repo: &Repo,
460 item: &PlanItem,
461 issue: &Issue,
462 ledger: &mut Ledger,
463 state: &mut IssueRun,
464 work_dir: &Path,
465 branch: &str,
466) -> Result<()> {
467 let number = item.issue;
468 let holder = cfg.first_implementor.clone();
469 let implementor = agent::find(agents, &holder)?;
470 let base = cfg.base_branch().to_string();
471
472 log!("#{number}: {holder} implementing");
473 let (body, shortened) = issue.body_for_prompt(cfg.loop_cfg.max_issue_chars);
478 if shortened {
479 logwarn!(
480 "#{number}: the issue body was shortened to fit the prompt. Raise max_issue_chars if \
481 the rest matters."
482 );
483 }
484 let prompt = implement_prompt(number, &item.title, &issue.url, &body);
485 let answer: Result<Implementation> = implementor.ask_json(
486 &prompt,
487 &schema::implementation(),
488 work_dir,
489 cfg.effort_for_round(&implementor.spec, 1).as_deref(),
490 );
491
492 let mut work = match answer {
499 Ok(work) => work,
500 Err(e) if repo.has_changes(work_dir, &base) => {
501 logwarn!(
502 "#{number}: {holder} failed after committing: {e}\nContinuing from the commits, \
503 with a pull request body written from their messages."
504 );
505 state
506 .notes
507 .push(format!("{holder} failed after committing: {e}"));
508 from_commits(repo, work_dir, &base)
509 }
510 Err(e) => return Err(e),
511 };
512
513 if work.not_worth_doing || !repo.has_changes(work_dir, &base) {
514 state.status = Status::Abandoned;
515 let reason = no_pr_note(&work, &repo.style);
516 state.notes.push(reason.clone());
517 if let Err(e) = repo.comment_issue(number, &reason) {
518 logdim!("could not comment on #{number}: {e}");
519 }
520 return Ok(());
521 }
522
523 if work.summary.trim().is_empty() {
527 work.summary = item.title.clone();
528 }
529
530 repo.rewrite_commits_if_needed(work_dir, &base)?;
531 repo.push(work_dir, branch)?;
532
533 let pr = match repo.pr_for_branch(branch) {
534 Some(existing) => existing,
535 None => {
536 let body = pr_body(number, &work, &repo.style);
537 repo.create_pr(
538 work_dir,
539 branch,
540 &base,
541 &format!("{} (#{number})", item.title),
542 &body,
543 )?
544 }
545 };
546 state.pr = Some(pr.url.clone());
547 log!("#{number}: PR {}", pr.url);
548
549 let ctx = LoopCtx {
550 work_dir: work_dir.to_path_buf(),
551 branch: branch.to_string(),
552 pr_number: pr.number,
553 label: format!("#{number}"),
554 subject: number,
555 title: item.title.clone(),
556 start_round: 1,
557 holder: cfg.other(&holder),
558 release: Release::Issue(number),
559 };
560 review_loop(agents, cfg, repo, &ctx, state, ledger, Vec::new())
561}
562
563pub fn resume_pr(
574 agents: &[Agent],
575 cfg: &Config,
576 repo: &Repo,
577 pr_number: i64,
578 holder_override: Option<&str>,
579) -> IssueRun {
580 let failed = |e: SparError| {
581 log!("PR #{pr_number} failed: {e}");
582 let mut state = IssueRun::new(pr_number, format!("PR #{pr_number}"));
583 state.status = Status::Error;
584 state.notes.push(e.to_string());
585 state
586 };
587
588 let pr = match repo.pr_view(pr_number) {
589 Ok(pr) => pr,
590 Err(e) => return failed(e),
591 };
592
593 if pr.is_cross_repository {
598 log!("PR #{pr_number} comes from a fork, reviewing it without changing it");
599 return crate::review_only::review_pr(agents, cfg, repo, pr_number, false);
600 }
601
602 match resume_inner(agents, cfg, repo, pr, holder_override) {
603 Ok(state) => state,
604 Err(e) => failed(e),
605 }
606}
607
608fn resume_inner(
609 agents: &[Agent],
610 cfg: &Config,
611 repo: &Repo,
612 pr: PrView,
613 holder_override: Option<&str>,
614) -> Result<IssueRun> {
615 let pr_number = pr.number;
616 if !pr.is_open() {
617 return Err(spar_err!("PR #{pr_number} is {}", pr.state.to_lowercase()));
618 }
619
620 let subject = pr
621 .closing_issues_references
622 .first()
623 .map(|r| r.number)
624 .unwrap_or(pr_number);
625
626 if let Some(holder) = holder_override {
627 if !cfg.has_agent(holder) {
628 return Err(spar_err!(
629 "--next must name one of: {}",
630 cfg.agent_names().join(", ")
631 ));
632 }
633 }
634 let (work_dir, branch) = repo.worktree_for_pr(&pr)?;
635 let actual_head = checked_head(repo, &work_dir)?;
636 let saved = repo.read_state_for_head(&pr, &actual_head);
637 let state_matches_head = reconcile_saved_head(
638 saved.as_ref().map(|state| state.pr_head.as_str()),
639 &actual_head,
640 holder_override,
641 pr_number,
642 )?;
643
644 let mut ledger: Ledger = saved
645 .as_ref()
646 .filter(|_| state_matches_head)
647 .map(|s| s.ledger.clone())
648 .unwrap_or_default();
649 normalise_ledger_keys(&mut ledger);
650 let open_findings = saved
651 .as_ref()
652 .filter(|_| state_matches_head)
653 .map(|s| blocking_findings(&s.open_findings))
654 .unwrap_or_default();
655 let start_round = saved
656 .as_ref()
657 .filter(|_| state_matches_head)
658 .map(|s| s.round + 1)
659 .unwrap_or(1);
660
661 let default_holder = cfg.other(&cfg.first_implementor);
662 let mut holder = holder_override
663 .map(str::to_string)
664 .or_else(|| saved.as_ref().map(|s| s.next_actor.clone()))
665 .unwrap_or_else(|| default_holder.clone());
666 if !cfg.has_agent(&holder) {
667 log!("state named unknown agent '{holder}', using {default_holder}");
668 holder = default_holder;
669 }
670
671 match &saved {
672 Some(_) => log!(
673 "PR #{pr_number}: resuming at round {start_round}, {} point(s) on record, next up \
674 {holder}",
675 ledger.len()
676 ),
677 None => log!("PR #{pr_number}: no prior spar state, starting fresh with {holder}"),
678 }
679
680 let mut state = IssueRun::new(subject, pr.title.clone());
681 state.pr = Some(pr.url.clone());
682 if let Some(s) = &saved {
683 state.filed = s.filed.clone();
684 if state_matches_head {
685 state.disputes = s.disputes.clone();
686 state.noted = s.noted.clone();
687 } else {
688 log!(
689 "PR #{pr_number}: saved state does not match {actual_head}; branch-dependent \
690 review state was cleared"
691 );
692 }
693 }
694
695 let ctx = LoopCtx {
696 work_dir,
697 branch,
698 pr_number,
699 label: format!("PR #{pr_number}"),
700 subject,
701 title: pr.title.clone(),
702 start_round,
703 holder,
704 release: Release::Pr(pr_number),
705 };
706
707 let outcome = review_loop(
708 agents,
709 cfg,
710 repo,
711 &ctx,
712 &mut state,
713 &mut ledger,
714 open_findings,
715 );
716 if let Err(e) = outcome {
717 state.status = Status::Error;
718 state.notes.push(e.to_string());
719 log!("PR #{pr_number} failed: {e}");
720 }
721 if should_release(cfg, state.status) {
722 repo.release_pr_worktree(pr_number);
723 }
724 Ok(state)
725}
726
727#[derive(Debug, Clone, Copy)]
732enum Release {
733 Issue(i64),
734 Pr(i64),
735}
736
737struct LoopCtx {
738 work_dir: PathBuf,
739 branch: String,
740 pr_number: i64,
741 label: String,
742 subject: i64,
743 title: String,
744 start_round: u32,
745 holder: String,
746 release: Release,
747}
748
749impl LoopCtx {
750 fn release(&self, repo: &Repo) {
751 match self.release {
752 Release::Issue(n) => repo.worktree_remove(n),
753 Release::Pr(n) => repo.release_pr_worktree(n),
754 }
755 }
756}
757
758fn review_loop(
759 agents: &[Agent],
760 cfg: &Config,
761 repo: &Repo,
762 ctx: &LoopCtx,
763 state: &mut IssueRun,
764 ledger: &mut Ledger,
765 mut open_findings: Vec<Finding>,
766) -> Result<()> {
767 let base = cfg.base_branch().to_string();
768 let mut holder = ctx.holder.clone();
772
773 let (first, last_allowed) = round_window(ctx.start_round, cfg.loop_cfg.max_rounds);
779 let mut published_head = checked_head(repo, &ctx.work_dir)?;
780 persist(
781 repo,
782 ctx.pr_number,
783 state,
784 ledger,
785 &open_findings,
786 &published_head,
787 first.saturating_sub(1),
788 &holder,
789 )?;
790 let mut audited_head = String::new();
794 let mut last_round = first.saturating_sub(1);
795
796 for round in first..=last_allowed {
797 last_round = round;
798 state.rounds = round;
799 let reviewer = agent::find(agents, &holder)?;
800 let effort = cfg.effort_for_round(&reviewer.spec, round);
801 log!(
802 "{}: round {round}, {holder} reviewing ({})",
803 ctx.label,
804 effort.as_deref().unwrap_or("default effort")
805 );
806
807 let prompt = review_prompt(
808 &base,
809 ctx.subject,
810 &ctx.title,
811 ledger,
812 &open_findings,
813 round,
814 last_allowed,
815 );
816 let before_review = snapshot(repo, &ctx.work_dir);
817 audited_head = before_review.head.clone();
820 let review: Review = reviewer.review(
821 &base,
822 &prompt,
823 &schema::review(),
824 &ctx.work_dir,
825 effort.as_deref(),
826 )?;
827
828 let mut editor: Option<String> = None;
832 let review_wrote = snapshot(repo, &ctx.work_dir) != before_review;
833 if review_wrote {
834 logwarn!(
835 "{}: {holder} changed the branch while reviewing it, which the review prompt \
836 forbids. Rolling it back.",
837 ctx.label
838 );
839 if undo_edits(repo, &ctx.work_dir, &before_review).head != before_review.head {
840 state
841 .notes
842 .push(format!("{holder} committed during its own review"));
843 editor = Some(holder.clone());
844 }
845 }
846
847 let blocking = blocking_findings(&review.findings);
848 update_open_findings(&mut open_findings, &blocking, !review_wrote);
849
850 if repo.style.pr_comments == PrComments::Rounds {
851 if let Err(e) = repo.comment_pr(
852 ctx.pr_number,
853 &review_comment(&holder, round, &review, &repo.style),
854 ) {
855 logdim!("could not post the review comment: {e}");
856 }
857 }
858
859 file_out_of_scope(repo, &review.findings, ctx.subject, state, cfg);
863 file_nonblocking(repo, &review.findings, ctx.subject, state, cfg);
864 remove_findings(&mut state.noted, &blocking);
865
866 if check_relitigation(ledger, &blocking, state) {
867 state.status = Status::Escalated;
868 post_outcome(
869 repo,
870 ctx.pr_number,
871 state,
872 ledger,
873 Ending::Deadlocked(&blocking),
874 );
875 persist(
876 repo,
877 ctx.pr_number,
878 state,
879 ledger,
880 &open_findings,
881 &published_head,
882 round,
883 &holder,
884 )?;
885 return Ok(());
886 }
887
888 if approval_stands(&blocking, review_wrote) {
889 open_findings.clear();
890 return approve(
891 cfg,
892 repo,
893 ctx,
894 state,
895 ledger,
896 &published_head,
897 round,
898 &holder,
899 );
900 }
901
902 persist(
905 repo,
906 ctx.pr_number,
907 state,
908 ledger,
909 &open_findings,
910 &published_head,
911 round,
912 &holder,
913 )?;
914
915 if blocking.is_empty() {
916 logwarn!(
921 "{}: {holder} found nothing blocking on a branch it had changed itself, so the \
922 approval does not carry.",
923 ctx.label
924 );
925 state.notes.push(format!(
926 "{holder} passed the branch in round {round} after editing it; the edit was rolled \
927 back and the approval did not stand"
928 ));
929 } else if review.next_action == NextAction::FixMyself {
930 log!("{}: {holder} fixing its own findings", ctx.label);
931 let prompt = FIX_PROMPT.replace("{findings}", &findings_for_prompt(&blocking));
932 let before_fix = snapshot(repo, &ctx.work_dir);
933 reviewer.ask(&prompt, &ctx.work_dir, effort.as_deref())?;
934 match editor_after(repo, &ctx.work_dir, &before_fix, &ctx.label, &holder) {
935 Some(who) => {
936 record_own_fixes(&blocking, ledger, state, round);
944 remove_findings(&mut open_findings, &blocking);
945 editor = Some(who);
946 }
947 None => {
948 logwarn!(
951 "{}: {holder} said it would fix its own findings and committed nothing, \
952 so it keeps the pull request.",
953 ctx.label
954 );
955 state.notes.push(format!(
956 "{holder} chose to fix its own findings in round {round} and committed \
957 nothing"
958 ));
959 }
960 }
961 } else {
962 let author_name = cfg.other(&holder);
963 let author = agent::find(agents, &author_name)?;
964 log!(
965 "{}: handing {} finding(s) to {author_name}",
966 ctx.label,
967 blocking.len()
968 );
969 let prompt = RESPOND_PROMPT
970 .replace("{number}", &ctx.subject.to_string())
971 .replace("{findings}", &findings_for_prompt(&blocking));
972 let before_response = snapshot(repo, &ctx.work_dir);
973 let response: ResponseDoc = author.ask_json(
974 &prompt,
975 &schema::response(),
976 &ctx.work_dir,
977 cfg.effort_for_round(&author.spec, round).as_deref(),
978 )?;
979 if let Some(who) = editor_after(
980 repo,
981 &ctx.work_dir,
982 &before_response,
983 &ctx.label,
984 &author_name,
985 ) {
986 editor = Some(who);
987 } else if response
988 .dispositions
989 .iter()
990 .any(|d| d.action == Action::Fixed)
991 {
992 logwarn!(
993 "{}: {author_name} reported fixes but committed nothing, so the diff does not \
994 have them.",
995 ctx.label
996 );
997 }
998 let unresolved = apply_dispositions(
999 repo,
1000 cfg,
1001 &response,
1002 &blocking,
1003 ledger,
1004 state,
1005 round,
1006 ctx.subject,
1007 ctx.pr_number,
1008 &author_name,
1009 editor.is_some(),
1010 );
1011 remove_findings(&mut open_findings, &blocking);
1012 extend_findings(&mut open_findings, &unresolved);
1013 }
1014
1015 if editor.is_some() {
1016 repo.rewrite_commits_if_needed(&ctx.work_dir, &base)?;
1017 repo.push(&ctx.work_dir, &ctx.branch)?;
1018 published_head = checked_head(repo, &ctx.work_dir)?;
1019 }
1020 holder = next_reviewer(cfg, &holder, editor.as_deref());
1021 persist(
1022 repo,
1023 ctx.pr_number,
1024 state,
1025 ledger,
1026 &open_findings,
1027 &published_head,
1028 round,
1029 &holder,
1030 )?;
1031 }
1032
1033 close_out(
1040 agents,
1041 cfg,
1042 repo,
1043 ctx,
1044 state,
1045 ledger,
1046 &mut open_findings,
1047 &holder,
1048 last_round,
1049 &audited_head,
1050 &published_head,
1051 )
1052}
1053
1054#[allow(clippy::too_many_arguments)]
1065fn close_out(
1066 agents: &[Agent],
1067 cfg: &Config,
1068 repo: &Repo,
1069 ctx: &LoopCtx,
1070 state: &mut IssueRun,
1071 ledger: &mut Ledger,
1072 open_findings: &mut Vec<Finding>,
1073 holder: &str,
1074 round: u32,
1075 audited_head: &str,
1076 published_head: &str,
1077) -> Result<()> {
1078 let stop =
1079 |state: &mut IssueRun, ledger: &Ledger, open_findings: &[Finding], ending: Ending<'_>| {
1080 state.status = Status::Escalated;
1081 state.notes.push(exhausted_note(ctx.start_round, round));
1082 post_outcome(repo, ctx.pr_number, state, ledger, ending);
1083 persist(
1084 repo,
1085 ctx.pr_number,
1086 state,
1087 ledger,
1088 open_findings,
1089 published_head,
1090 round,
1091 holder,
1092 )
1093 };
1094
1095 let landed = (!audited_head.is_empty())
1102 .then(|| repo.commits_since(&ctx.work_dir, audited_head, "HEAD"))
1103 .flatten();
1104 if audited_head.is_empty()
1105 || (landed.as_ref().is_some_and(|l| l.is_empty()) && !any_fixes(ledger, round))
1106 {
1107 logdim!(
1108 "{}: nothing landed after the last review, so there is nothing to close over",
1109 ctx.label
1110 );
1111 if !open_findings.is_empty() {
1112 state.notes.push(unresolved_note(open_findings.len()));
1113 }
1114 stop(
1115 state,
1116 ledger,
1117 open_findings,
1118 ending_without_landing(open_findings),
1119 )?;
1120 return Ok(());
1121 }
1122
1123 let closer = agent::find(agents, holder)?;
1124 let effort = cfg.effort_for_round(&closer.spec, closing_effort_round(round));
1125 log!(
1126 "{}: closing, {holder} checking what the last round left ({})",
1127 ctx.label,
1128 effort.as_deref().unwrap_or("default effort")
1129 );
1130
1131 let prompt = close_prompt(
1132 cfg.base_branch(),
1133 ctx.subject,
1134 &ctx.title,
1135 audited_head,
1136 landed.as_deref(),
1137 ledger,
1138 open_findings,
1139 round,
1140 );
1141 let before = snapshot(repo, &ctx.work_dir);
1142 let pass = closer.ask_json(&prompt, &schema::review(), &ctx.work_dir, effort.as_deref());
1146
1147 let close_wrote = snapshot(repo, &ctx.work_dir) != before;
1150 let next = closing_next_actor(holder);
1154 if close_wrote {
1155 if let Err(error) = &pass {
1156 logwarn!(
1157 "{}: the closing pass failed after changing the branch: {error}",
1158 ctx.label
1159 );
1160 }
1161 logwarn!(
1162 "{}: {holder} changed the branch while closing, which the prompt forbids. Rolling it \
1163 back.",
1164 ctx.label
1165 );
1166 let after_undo = undo_edits(repo, &ctx.work_dir, &before);
1167 if after_undo != before {
1168 state.notes.push(format!(
1169 "{holder}'s closing-pass changes could not be fully rolled back"
1170 ));
1171 if after_undo.head != before.head {
1172 if let Some(reference) = preserve_closing_commit(repo, ctx) {
1173 state.notes.push(format!(
1174 "the closing pass commit was not pushed and remains at {reference}"
1175 ));
1176 }
1177 }
1178 }
1179 state.status = Status::Escalated;
1180 state.notes.push(format!(
1181 "{holder} edited the branch during the closing pass, so its answer did not stand"
1182 ));
1183 post_unread_outcome(repo, ctx.pr_number, state, ledger, open_findings);
1184 persist(
1185 repo,
1186 ctx.pr_number,
1187 state,
1188 ledger,
1189 open_findings,
1190 published_head,
1191 round,
1192 &next,
1193 )?;
1194 return Ok(());
1195 }
1196
1197 let pass: Review = match pass {
1198 Ok(pass) => pass,
1199 Err(e) => {
1200 logwarn!("{}: the closing pass failed: {e}", ctx.label);
1204 state.status = Status::Escalated;
1205 state.notes.push(exhausted_note(ctx.start_round, round));
1206 post_unread_outcome(repo, ctx.pr_number, state, ledger, open_findings);
1207 persist(
1208 repo,
1209 ctx.pr_number,
1210 state,
1211 ledger,
1212 open_findings,
1213 published_head,
1214 round,
1215 holder,
1216 )?;
1217 return Ok(());
1218 }
1219 };
1220
1221 file_out_of_scope(repo, &pass.findings, ctx.subject, state, cfg);
1222 file_nonblocking(repo, &pass.findings, ctx.subject, state, cfg);
1223
1224 let blocking = blocking_findings(&pass.findings);
1225 remove_findings(&mut state.noted, &blocking);
1226 update_open_findings(open_findings, &blocking, true);
1227
1228 if check_relitigation(ledger, &blocking, state) {
1229 state.status = Status::Escalated;
1230 post_outcome(
1231 repo,
1232 ctx.pr_number,
1233 state,
1234 ledger,
1235 Ending::Deadlocked(&blocking),
1236 );
1237 persist(
1238 repo,
1239 ctx.pr_number,
1240 state,
1241 ledger,
1242 open_findings,
1243 published_head,
1244 round,
1245 &next,
1246 )?;
1247 return Ok(());
1248 }
1249
1250 if approval_stands(&blocking, false) {
1251 open_findings.clear();
1252 return approve(cfg, repo, ctx, state, ledger, published_head, round, &next);
1253 }
1254
1255 state.status = Status::Escalated;
1256 state.notes.push(unresolved_note(open_findings.len()));
1257 post_outcome(
1258 repo,
1259 ctx.pr_number,
1260 state,
1261 ledger,
1262 Ending::Unresolved(open_findings),
1263 );
1264 persist(
1265 repo,
1266 ctx.pr_number,
1267 state,
1268 ledger,
1269 open_findings,
1270 published_head,
1271 round,
1272 &next,
1273 )?;
1274 Ok(())
1275}
1276
1277fn any_fixes(ledger: &Ledger, round: u32) -> bool {
1283 ledger
1284 .values()
1285 .any(|e| e.outcome == Settled::Fixed && e.round >= round)
1286}
1287
1288fn unresolved_note(left: usize) -> String {
1293 match left {
1294 1 => "one point left after the closing pass".to_string(),
1295 n => format!("{n} points left after the closing pass"),
1296 }
1297}
1298
1299fn ensure_reviewed_head(pr_number: i64, reviewed_head: &str, live_head: &str) -> Result<()> {
1305 if live_head == reviewed_head {
1306 return Ok(());
1307 }
1308 Err(spar_err!(
1309 "PR #{pr_number} changed from {reviewed_head} to {live_head} after it was reviewed; refusing to approve or merge an unread head"
1310 ))
1311}
1312
1313#[allow(clippy::too_many_arguments)]
1314fn approve(
1315 cfg: &Config,
1316 repo: &Repo,
1317 ctx: &LoopCtx,
1318 state: &mut IssueRun,
1319 ledger: &Ledger,
1320 published_head: &str,
1321 round: u32,
1322 holder: &str,
1323) -> Result<()> {
1324 let live_head = repo.pr_head_oid(ctx.pr_number)?;
1325 ensure_reviewed_head(ctx.pr_number, published_head, &live_head)?;
1326 state.status = Status::Approved;
1327 persist(
1328 repo,
1329 ctx.pr_number,
1330 state,
1331 ledger,
1332 &[],
1333 published_head,
1334 round,
1335 holder,
1336 )?;
1337 let live_head = repo.pr_head_oid(ctx.pr_number)?;
1338 ensure_reviewed_head(ctx.pr_number, published_head, &live_head)?;
1339 post_outcome(repo, ctx.pr_number, state, ledger, Ending::Approved);
1340 if cfg.loop_cfg.drafts == Drafts::UntilApproved && repo.mark_ready(ctx.pr_number) {
1344 log!("{}: out of draft", ctx.label);
1345 }
1346 if cfg.loop_cfg.auto_merge {
1347 ctx.release(repo);
1351 repo.merge_pr_at_head(ctx.pr_number, published_head)?;
1352 state.status = Status::Merged;
1353 repo.clear_state(ctx.pr_number); log!("{}: merged", ctx.label);
1355 } else {
1356 log!("{}: approved, awaiting human merge", ctx.label);
1357 }
1358 Ok(())
1359}
1360
1361fn approval_stands(blocking: &[Finding], review_wrote: bool) -> bool {
1369 blocking.is_empty() && !review_wrote
1370}
1371
1372fn next_reviewer(cfg: &Config, reviewer: &str, editor: Option<&str>) -> String {
1385 match editor {
1386 Some(editor) => cfg.other(editor),
1387 None => reviewer.to_string(),
1388 }
1389}
1390
1391fn editor_after(
1399 repo: &Repo,
1400 work_dir: &Path,
1401 before: &Snapshot,
1402 label: &str,
1403 who: &str,
1404) -> Option<String> {
1405 let after = snapshot(repo, work_dir);
1406 if after.dirty {
1407 logwarn!(
1408 "{label}: {who} left tracked files uncommitted. Only commits are pushed and the next \
1409 review reads the tree, so they are discarded."
1410 );
1411 drop_uncommitted(repo, work_dir);
1412 }
1413 after.landed_over(before).then(|| who.to_string())
1414}
1415
1416fn round_window(start_round: u32, budget: u32) -> (u32, u32) {
1422 (start_round, start_round + budget.saturating_sub(1))
1423}
1424
1425fn spent(start_round: u32, last_round: u32) -> (u32, u32) {
1429 (last_round.saturating_sub(start_round) + 1, last_round)
1430}
1431
1432fn exhausted_note(start_round: u32, last_round: u32) -> String {
1433 let (this_run, total) = spent(start_round, last_round);
1434 if this_run == total {
1435 format!("no convergence after {this_run} rounds")
1436 } else {
1437 format!("no convergence after {this_run} more rounds ({total} in total)")
1438 }
1439}
1440
1441#[allow(clippy::too_many_arguments)]
1442fn persist(
1443 repo: &Repo,
1444 pr_number: i64,
1445 state: &IssueRun,
1446 ledger: &Ledger,
1447 open_findings: &[Finding],
1448 published_head: &str,
1449 round: u32,
1450 next_actor: &str,
1451) -> Result<()> {
1452 let payload = PersistedState {
1453 version: STATE_VERSION,
1454 checkpoint: 0,
1455 round,
1456 next_actor: next_actor.to_string(),
1457 status: state.status,
1458 pr_head: published_head.to_string(),
1459 ledger: ledger.clone(),
1460 filed: state.filed.clone(),
1461 open_findings: open_findings.to_vec(),
1462 disputes: state.disputes.clone(),
1463 noted: state.noted.clone(),
1464 };
1465 repo.write_state(pr_number, &payload)
1466}
1467
1468fn settled_block(ledger: &Ledger) -> String {
1473 if ledger.is_empty() {
1474 return String::new();
1475 }
1476 let lines: Vec<String> = ledger
1481 .values()
1482 .filter_map(|e| {
1483 let point = match e.file.trim() {
1484 "" => e.title.clone(),
1485 file => format!("{} ({file})", e.title),
1486 };
1487 match e.outcome {
1488 Settled::Refuted => Some(format!("- {point}: refuted because {}", e.reasoning)),
1489 Settled::Filed => Some(format!(
1490 "- {point}: out of scope here, and filed. {}",
1491 e.reasoning
1492 )),
1493 Settled::Dropped => Some(format!(
1494 "- {point}: out of scope here, and not filed. {}",
1495 e.reasoning
1496 )),
1497 Settled::Fixed => None,
1498 }
1499 })
1500 .collect();
1501 if lines.is_empty() {
1502 return String::new();
1503 }
1504 format!(
1505 "\nThe following points were already raised and settled, by a refutation or by a \
1506 follow-up issue. Treat them as settled. Do not raise them again unless you have new \
1507 evidence:\n{}",
1508 lines.join("\n")
1509 )
1510}
1511
1512fn fixed_lines(ledger: &Ledger, since: u32) -> Vec<String> {
1524 ledger
1525 .values()
1526 .filter(|e| e.outcome == Settled::Fixed && e.round >= since)
1527 .map(|e| {
1528 let claim = match e.reasoning.trim() {
1529 "" => "a committed change claims to address this point",
1530 reasoning => reasoning,
1531 };
1532 match e.file.trim() {
1533 "" => format!("- {}. Recorded answer: {claim}", e.title),
1534 file => format!("- {} ({file}). Recorded answer: {claim}", e.title),
1535 }
1536 })
1537 .collect()
1538}
1539
1540fn answers_block(ledger: &Ledger, round: u32) -> String {
1548 let lines = fixed_lines(ledger, round.saturating_sub(1));
1551 if lines.is_empty() {
1552 return String::new();
1553 }
1554 format!(
1555 "\nThese points were raised on this pull request in earlier rounds and the author says \
1556 it fixed them. The code is on the branch and the claim is the author's:\n{}\n\nCheck the \
1557 answer rather than taking it. If one of them is still not fixed, raise it again under \
1558 the same title, so the run can tell a point that was not answered from a new one.\n",
1559 lines.join("\n")
1560 )
1561}
1562
1563fn round_note(round: u32, last: u32) -> String {
1572 if round < last {
1573 return String::new();
1574 }
1575 "\nThis is the last round in this run that can ask the author for anything. After it, one \
1576 pass reads what landed and the pull request is either signed off or goes to a person with \
1577 what is left. Raise everything you mean to raise now. A point held back for a later round \
1578 does not get one.\n"
1579 .to_string()
1580}
1581
1582#[cfg(test)]
1587fn matching_ledger_key(ledger: &Ledger, title: &str, file: &str) -> Option<String> {
1588 matching_ledger_key_with_fallback(ledger, title, file, true)
1589}
1590
1591fn matching_ledger_key_with_fallback(
1592 ledger: &Ledger,
1593 title: &str,
1594 file: &str,
1595 allow_stable_fallback: bool,
1596) -> Option<String> {
1597 let exact = finding_key(title, file);
1598 if ledger.contains_key(&exact) {
1599 return Some(exact);
1600 }
1601 let legacy = crate::jsonx::finding_key(title, file);
1602 if ledger
1603 .get(&legacy)
1604 .is_some_and(|entry| same_finding_parts(&entry.title, &entry.file, title, file))
1605 {
1606 return Some(legacy);
1607 }
1608 if !allow_stable_fallback {
1609 return None;
1610 }
1611
1612 let stable = stable_finding_key(title, file);
1613 let path = finding_file(file);
1614 let mut matches = ledger
1615 .iter()
1616 .filter(|(saved_key, entry)| {
1617 if stable_finding_key(&entry.title, &entry.file) == stable {
1618 return true;
1619 }
1620 finding_file(&entry.file) == path
1621 && saved_key.as_str() == crate::jsonx::finding_key(title, &entry.file)
1622 })
1623 .map(|(key, _)| key.clone());
1624 let first = matches.next()?;
1625 matches.next().is_none().then_some(first)
1626}
1627
1628fn matching_ledger_entry<'a>(
1629 ledger: &'a Ledger,
1630 title: &str,
1631 file: &str,
1632) -> Option<&'a LedgerEntry> {
1633 matching_ledger_entry_with_fallback(ledger, title, file, true)
1634}
1635
1636fn matching_ledger_entry_with_fallback<'a>(
1637 ledger: &'a Ledger,
1638 title: &str,
1639 file: &str,
1640 allow_stable_fallback: bool,
1641) -> Option<&'a LedgerEntry> {
1642 let key = matching_ledger_key_with_fallback(ledger, title, file, allow_stable_fallback)?;
1643 ledger.get(&key)
1644}
1645
1646fn settle(
1647 ledger: &mut Ledger,
1648 title: &str,
1649 file: &str,
1650 allow_stable_fallback: bool,
1651 entry: LedgerEntry,
1652) {
1653 let old_key = matching_ledger_key_with_fallback(ledger, title, file, allow_stable_fallback);
1654 let reraised = old_key
1655 .as_ref()
1656 .and_then(|key| ledger.get(key))
1657 .map(|entry| entry.reraised)
1658 .unwrap_or(0);
1659 if let Some(old_key) = old_key {
1660 ledger.remove(&old_key);
1661 }
1662 ledger.insert(finding_key(title, file), LedgerEntry { reraised, ..entry });
1663}
1664
1665fn normalise_ledger_keys(ledger: &mut Ledger) {
1667 let mut normalised = Ledger::new();
1668 for (saved_key, mut entry) in std::mem::take(ledger) {
1669 let key = if saved_key.len() == 12
1670 && saved_key
1671 .chars()
1672 .all(|character| character.is_ascii_hexdigit())
1673 {
1674 saved_key
1675 } else {
1676 finding_key(&entry.title, &entry.file)
1677 };
1678 if let Some(previous) = normalised.get_mut(&key) {
1679 let reraised = previous.reraised.max(entry.reraised);
1680 if entry.round >= previous.round {
1681 entry.reraised = reraised;
1682 *previous = entry;
1683 } else {
1684 previous.reraised = reraised;
1685 }
1686 } else {
1687 normalised.insert(key, entry);
1688 }
1689 }
1690 *ledger = normalised;
1691}
1692
1693fn blocking_findings(findings: &[Finding]) -> Vec<Finding> {
1695 let mut kept = Vec::new();
1696 for finding in findings.iter().filter(|finding| finding.blocks()) {
1697 if let Some(existing) = kept
1698 .iter_mut()
1699 .find(|existing| same_finding(existing, finding))
1700 {
1701 *existing = finding.clone();
1702 } else {
1703 kept.push(finding.clone());
1704 }
1705 }
1706 kept
1707}
1708
1709fn matching_finding_index(
1710 findings: &[Finding],
1711 target: &Finding,
1712 allow_stable_fallback: bool,
1713) -> Option<usize> {
1714 if let Some(index) = findings
1715 .iter()
1716 .position(|finding| same_finding(finding, target))
1717 {
1718 return Some(index);
1719 }
1720 if !allow_stable_fallback {
1721 return None;
1722 }
1723
1724 let stable = stable_finding_key(&target.title, &target.file);
1725 let mut matches = findings
1726 .iter()
1727 .enumerate()
1728 .filter(|(_, finding)| stable_finding_key(&finding.title, &finding.file) == stable);
1729 let first = matches.next().map(|(index, _)| index);
1730 first.filter(|_| matches.next().is_none())
1731}
1732
1733fn unique_stable_finding(findings: &[Finding], target: &Finding) -> bool {
1734 let stable = stable_finding_key(&target.title, &target.file);
1735 findings
1736 .iter()
1737 .filter(|finding| stable_finding_key(&finding.title, &finding.file) == stable)
1738 .count()
1739 == 1
1740}
1741
1742fn extend_findings(target: &mut Vec<Finding>, additions: &[Finding]) {
1744 for finding in additions {
1745 if let Some(index) =
1746 matching_finding_index(target, finding, unique_stable_finding(additions, finding))
1747 {
1748 target[index] = finding.clone();
1749 } else {
1750 target.push(finding.clone());
1751 }
1752 }
1753}
1754
1755fn update_open_findings(
1756 open_findings: &mut Vec<Finding>,
1757 current: &[Finding],
1758 answer_stands: bool,
1759) {
1760 if answer_stands {
1761 *open_findings = current.to_vec();
1762 } else {
1763 extend_findings(open_findings, current);
1764 }
1765}
1766
1767fn remove_findings(target: &mut Vec<Finding>, removed: &[Finding]) {
1768 for finding in removed {
1769 if let Some(index) =
1770 matching_finding_index(target, finding, unique_stable_finding(removed, finding))
1771 {
1772 target.remove(index);
1773 }
1774 }
1775}
1776
1777fn ending_without_landing(open_findings: &[Finding]) -> Ending<'_> {
1778 if open_findings.is_empty() {
1779 Ending::Unchanged
1780 } else {
1781 Ending::Unresolved(open_findings)
1782 }
1783}
1784
1785fn closing_effort_round(round: u32) -> u32 {
1786 round.saturating_add(1)
1787}
1788
1789fn closing_next_actor(holder: &str) -> String {
1790 holder.to_string()
1791}
1792
1793fn remember_noted(state: &mut IssueRun, finding: &Finding, allow_stable_fallback: bool) {
1806 if let Some(index) = matching_finding_index(&state.noted, finding, allow_stable_fallback) {
1807 state.noted[index] = finding.clone();
1808 } else {
1809 state.noted.push(finding.clone());
1810 }
1811}
1812
1813fn forget_noted(state: &mut IssueRun, finding: &Finding, allow_stable_fallback: bool) {
1814 if let Some(index) = matching_finding_index(&state.noted, finding, allow_stable_fallback) {
1815 state.noted.remove(index);
1816 }
1817}
1818
1819fn remember_dispute(state: &mut IssueRun, dispute: Dispute, allow_stable_fallback: bool) {
1820 let exact = finding_key(&dispute.title, &dispute.file);
1821 let stable = stable_finding_key(&dispute.title, &dispute.file);
1822 let exact_index = state
1823 .disputes
1824 .iter()
1825 .position(|kept| finding_key(&kept.title, &kept.file) == exact);
1826 let stable_index = if exact_index.is_none() && allow_stable_fallback {
1827 let mut matches = state
1828 .disputes
1829 .iter()
1830 .enumerate()
1831 .filter(|(_, kept)| stable_finding_key(&kept.title, &kept.file) == stable);
1832 let first = matches.next().map(|(index, _)| index);
1833 first.filter(|_| matches.next().is_none())
1834 } else {
1835 None
1836 };
1837 if let Some(index) = exact_index.or(stable_index) {
1838 state.disputes[index] = dispute;
1839 } else {
1840 state.disputes.push(dispute);
1841 }
1842}
1843
1844fn forget_dispute(state: &mut IssueRun, finding: &Finding, allow_stable_fallback: bool) {
1845 let exact = finding_key(&finding.title, &finding.file);
1846 if let Some(index) = state
1847 .disputes
1848 .iter()
1849 .position(|kept| finding_key(&kept.title, &kept.file) == exact)
1850 {
1851 state.disputes.remove(index);
1852 return;
1853 }
1854 if !allow_stable_fallback {
1855 return;
1856 }
1857
1858 let stable = stable_finding_key(&finding.title, &finding.file);
1859 let mut matches = state
1860 .disputes
1861 .iter()
1862 .enumerate()
1863 .filter(|(_, kept)| stable_finding_key(&kept.title, &kept.file) == stable);
1864 let first = matches.next().map(|(index, _)| index);
1865 if let Some(index) = first.filter(|_| matches.next().is_none()) {
1866 state.disputes.remove(index);
1867 }
1868}
1869
1870#[cfg(test)]
1871fn record_nonblocking_outcome(state: &mut IssueRun, finding: &Finding, outcome: Option<&Followup>) {
1872 record_nonblocking_outcome_with_match(state, finding, outcome, true);
1873}
1874
1875fn record_nonblocking_outcome_with_match(
1876 state: &mut IssueRun,
1877 finding: &Finding,
1878 outcome: Option<&Followup>,
1879 allow_stable_fallback: bool,
1880) {
1881 forget_dispute(state, finding, allow_stable_fallback);
1882 if let Some(Followup::Recorded(url)) = outcome {
1883 if !state.filed.iter().any(|filed| filed == url) {
1884 state.filed.push(url.clone());
1885 }
1886 forget_noted(state, finding, allow_stable_fallback);
1887 } else {
1888 remember_noted(state, finding, allow_stable_fallback);
1889 }
1890}
1891
1892fn record_own_fixes(blocking: &[Finding], ledger: &mut Ledger, state: &mut IssueRun, round: u32) {
1899 for finding in blocking {
1900 let allow_stable_fallback = unique_stable_finding(blocking, finding);
1901 settle(
1902 ledger,
1903 &finding.title,
1904 &finding.file,
1905 allow_stable_fallback,
1906 LedgerEntry {
1907 title: finding.title.clone(),
1908 file: finding.file.clone(),
1909 reasoning: "a committed change was made for this point".to_string(),
1910 round,
1911 reraised: 0,
1912 outcome: Settled::Fixed,
1913 },
1914 );
1915 forget_noted(state, finding, allow_stable_fallback);
1916 forget_dispute(state, finding, allow_stable_fallback);
1917 }
1918}
1919
1920fn check_relitigation(ledger: &mut Ledger, blocking: &[Finding], state: &mut IssueRun) -> bool {
1923 let mut escalate = false;
1924 let mut counted: BTreeSet<String> = BTreeSet::new();
1930 for finding in blocking {
1931 let allow_stable_fallback = unique_stable_finding(blocking, finding);
1932 let Some(key) = matching_ledger_key_with_fallback(
1933 ledger,
1934 &finding.title,
1935 &finding.file,
1936 allow_stable_fallback,
1937 ) else {
1938 continue;
1939 };
1940 if !counted.insert(key.clone()) {
1941 continue;
1942 }
1943 if let Some(entry) = ledger.get_mut(&key) {
1944 entry.reraised += 1;
1945 if entry.outcome == Settled::Refuted {
1946 remember_dispute(
1947 state,
1948 Dispute {
1949 title: finding.title.clone(),
1950 file: finding.file.clone(),
1951 reasoning: entry.reasoning.clone(),
1952 },
1953 allow_stable_fallback,
1954 );
1955 }
1956 if entry.reraised >= 2 {
1957 state.notes.push(format!(
1958 "'{}' {}",
1959 finding.title,
1960 why_escalated(entry.outcome)
1961 ));
1962 escalate = true;
1963 }
1964 }
1965 }
1966 escalate
1967}
1968
1969fn why_escalated(outcome: Settled) -> &'static str {
1976 match outcome {
1977 Settled::Fixed => "was fixed twice and raised again; escalating.",
1978 _ => "was settled and re-raised twice; escalating.",
1979 }
1980}
1981
1982fn normalise(text: &str) -> String {
1983 text.to_lowercase()
1984 .chars()
1985 .filter(|c| c.is_ascii_alphanumeric() || c.is_whitespace())
1986 .collect::<String>()
1987 .split_whitespace()
1988 .collect::<Vec<_>>()
1989 .join(" ")
1990}
1991
1992pub(crate) fn same_point(a: &str, b: &str) -> bool {
1997 normalise(a) == normalise(b)
1998}
1999
2000pub(crate) fn same_finding(a: &Finding, b: &Finding) -> bool {
2001 same_finding_parts(&a.title, &a.file, &b.title, &b.file)
2002}
2003
2004pub(crate) fn same_finding_parts(a_title: &str, a_file: &str, b_title: &str, b_file: &str) -> bool {
2005 finding_key(a_title, a_file) == finding_key(b_title, b_file)
2006}
2007
2008fn disposition_matches(finding: &Finding, disposition: &Disposition) -> bool {
2009 same_point(&finding.title, &disposition.title) && finding.file.trim() == disposition.file.trim()
2010}
2011
2012fn matching_disposition<'a>(
2013 finding: &Finding,
2014 dispositions: &'a [Disposition],
2015) -> std::result::Result<(usize, &'a Disposition), &'static str> {
2016 let mut matches = dispositions
2017 .iter()
2018 .enumerate()
2019 .filter(|(_, disposition)| disposition_matches(finding, disposition));
2020 let first = matches.next().ok_or("no matching disposition")?;
2021 if matches.next().is_some() {
2022 return Err("more than one matching disposition");
2023 }
2024 Ok(first)
2025}
2026
2027fn fixed_disposition_resolves(committed: bool) -> bool {
2028 committed
2029}
2030
2031#[allow(clippy::too_many_arguments)]
2032fn apply_dispositions(
2033 repo: &Repo,
2034 cfg: &Config,
2035 response: &ResponseDoc,
2036 blocking: &[Finding],
2037 ledger: &mut Ledger,
2038 state: &mut IssueRun,
2039 round: u32,
2040 subject: i64,
2041 pr_number: i64,
2042 author: &str,
2043 committed: bool,
2044) -> Vec<Finding> {
2045 let mut fixed = Vec::new();
2046 let mut refuted = Vec::new();
2047 let mut filed = Vec::new();
2048 let mut unresolved = Vec::new();
2049 let mut used = vec![false; response.dispositions.len()];
2050
2051 for source in blocking {
2052 let (index, d) = match matching_disposition(source, &response.dispositions) {
2053 Ok((index, disposition)) if !used[index] => (index, disposition),
2054 Ok(_) => {
2055 logwarn!(
2056 "'{}' has more than one matching disposition, so it stays open",
2057 source.title
2058 );
2059 unresolved.push(source.clone());
2060 continue;
2061 }
2062 Err(reason) => {
2063 logwarn!("'{}' has {reason}, so it stays open", source.title);
2064 unresolved.push(source.clone());
2065 continue;
2066 }
2067 };
2068 used[index] = true;
2069 let file = source.file.clone();
2070 let canonical = source.title.as_str();
2074 let title = style::title(canonical, &repo.style);
2075 let located_title = match file.trim() {
2076 "" => title.clone(),
2077 location => format!("{title} ({location})"),
2078 };
2079 let allow_stable_fallback = unique_stable_finding(blocking, source);
2080
2081 match d.action {
2082 Action::Refuted => {
2083 let reasoning = style::summary(&d.reasoning, &repo.style);
2084 settle(
2085 ledger,
2086 canonical,
2087 &file,
2088 allow_stable_fallback,
2089 LedgerEntry {
2090 title: canonical.to_string(),
2091 file: file.clone(),
2092 reasoning: reasoning.clone(),
2093 round,
2094 reraised: 0,
2095 outcome: Settled::Refuted,
2096 },
2097 );
2098 remember_dispute(
2099 state,
2100 Dispute {
2101 title: canonical.to_string(),
2102 file: file.clone(),
2103 reasoning: reasoning.clone(),
2104 },
2105 allow_stable_fallback,
2106 );
2107 forget_noted(state, source, allow_stable_fallback);
2108 refuted.push(format!("{located_title}. {reasoning}"));
2109 }
2110 Action::FiledIssue => {
2111 let new_title = d
2112 .new_issue_title
2113 .clone()
2114 .filter(|t| !t.trim().is_empty())
2115 .unwrap_or_else(|| d.title.clone());
2116 let new_body = d
2117 .new_issue_body
2118 .clone()
2119 .filter(|b| !b.trim().is_empty())
2120 .unwrap_or_else(|| d.reasoning.clone());
2121 let recorded = file_followup(repo, &new_title, &new_body, subject, cfg, state);
2122 if let Some(url) = recorded.url() {
2123 state.filed.push(url.to_string());
2124 filed.push(url.to_string());
2125 }
2126 let Some((outcome, reasoning)) =
2135 filed_entry(&recorded, &style::summary(&d.reasoning, &repo.style))
2136 else {
2137 logwarn!(
2138 "'{title}' was not recorded anywhere, so it stays open for the next round"
2139 );
2140 unresolved.push(source.clone());
2141 continue;
2142 };
2143 settle(
2144 ledger,
2145 canonical,
2146 &file,
2147 allow_stable_fallback,
2148 LedgerEntry {
2149 title: canonical.to_string(),
2150 file: file.clone(),
2151 reasoning,
2152 round,
2153 reraised: 0,
2154 outcome,
2155 },
2156 );
2157 if outcome == Settled::Dropped {
2158 remember_noted(state, source, allow_stable_fallback);
2159 } else {
2160 forget_noted(state, source, allow_stable_fallback);
2161 }
2162 forget_dispute(state, source, allow_stable_fallback);
2163 }
2164 Action::Fixed => {
2165 if fixed_disposition_resolves(committed) {
2180 settle(
2181 ledger,
2182 canonical,
2183 &file,
2184 allow_stable_fallback,
2185 LedgerEntry {
2186 title: canonical.to_string(),
2187 file: file.clone(),
2188 reasoning: style::summary(&d.reasoning, &repo.style),
2189 round,
2190 reraised: 0,
2191 outcome: Settled::Fixed,
2192 },
2193 );
2194 forget_noted(state, source, allow_stable_fallback);
2195 forget_dispute(state, source, allow_stable_fallback);
2196 fixed.push(located_title);
2197 } else {
2198 unresolved.push(source.clone());
2199 }
2200 }
2201 }
2202 }
2203
2204 for (index, disposition) in response.dispositions.iter().enumerate() {
2205 if !used[index] {
2206 logwarn!(
2207 "ignoring an unmatched or duplicate disposition for '{}' ({})",
2208 disposition.title,
2209 disposition.file
2210 );
2211 }
2212 }
2213
2214 if repo.style.pr_comments == PrComments::Rounds {
2215 let comment = disposition_comment(author, response, &fixed, &refuted, &filed, &repo.style);
2216 if let Some(text) = comment {
2217 if let Err(e) = repo.comment_pr(pr_number, &text) {
2218 logdim!("could not post the disposition comment: {e}");
2219 }
2220 }
2221 }
2222 unresolved
2223}
2224
2225fn filed_entry(recorded: &Followup, reasoning: &str) -> Option<(Settled, String)> {
2233 let (outcome, tail) = match recorded {
2234 Followup::Recorded(reference) => (
2235 Settled::Filed,
2236 format!("Tracked in {}.", as_reference(reference)),
2237 ),
2238 Followup::Covered(reference) => (
2239 Settled::Filed,
2240 format!("Already covered by {}.", as_reference(reference)),
2241 ),
2242 Followup::Dropped(why) => (Settled::Dropped, format!("Not filed anywhere: {why}.")),
2243 Followup::Failed => return None,
2244 };
2245 let reasoning = match reasoning.trim() {
2246 "" => tail,
2247 said => format!("{said} {tail}"),
2248 };
2249 Some((outcome, reasoning))
2250}
2251
2252fn external_followup_write_paused(destination: Followups, state: &IssueRun) -> bool {
2259 destination == Followups::Issues && state.followup_writes_uncertain
2260}
2261
2262fn failed_followup(state: &mut IssueRun, error: &SparError) -> Followup {
2263 if error.kind() == ErrorKind::UncertainWrite {
2264 state.followup_writes_uncertain = true;
2265 if !state
2266 .notes
2267 .iter()
2268 .any(|note| note.contains("external follow-up writes were paused"))
2269 {
2270 state.notes.push(
2271 "An external follow-up write could not be verified, so further external \
2272 follow-up writes were paused for this run. Inspect recent issues and comments \
2273 before trying them again."
2274 .to_string(),
2275 );
2276 }
2277 }
2278 Followup::Failed
2279}
2280
2281pub fn file_followup(
2292 repo: &Repo,
2293 title: &str,
2294 body: &str,
2295 source: i64,
2296 cfg: &Config,
2297 state: &mut IssueRun,
2298) -> Followup {
2299 if repo.followups == Followups::None {
2300 return Followup::Dropped("follow-ups are off for this repository");
2301 }
2302 if external_followup_write_paused(repo.followups, state) {
2303 logdim!(
2304 "not attempting another external follow-up write after an earlier result could not \
2305 be verified"
2306 );
2307 return Followup::Failed;
2308 }
2309 if state.filed.len() >= cfg.loop_cfg.max_followups {
2312 logwarn!(
2313 "already recorded {} follow-ups, not recording '{}'. Raise max_followups if you want \
2314 them all.",
2315 state.filed.len(),
2316 style::title(title, &repo.style)
2317 );
2318 return Followup::Dropped("this run had already recorded as many follow-ups as it may");
2319 }
2320 let title = match repo.clean_title(title) {
2327 Ok(title) => title,
2328 Err(e) => {
2329 logdim!("could not clean a follow-up title: {e}");
2330 return Followup::Failed;
2331 }
2332 };
2333 if title.trim().is_empty() {
2334 logdim!("nothing left of a follow-up title after cleaning it");
2335 return Followup::Failed;
2336 }
2337 let body = format!(
2340 "{}\n\nFound while working on #{source}.",
2341 style::issue_body(body, &repo.style)
2342 );
2343
2344 if repo.followups == Followups::Local {
2345 return repo.append_local_followup(&title, &body);
2346 }
2347
2348 match file_as_issue(repo, &title, &body) {
2349 Ok(filed) => filed.into(),
2350 Err(e) => {
2351 logdim!("could not file a follow-up for '{title}': {e}");
2352 failed_followup(state, &e)
2353 }
2354 }
2355}
2356
2357#[derive(Debug, Clone)]
2359pub enum Filed {
2360 Opened(i64, String),
2362 AddedTo(i64, String),
2364 Covered(i64, String),
2366 AlreadyClosed(i64, String),
2368}
2369
2370impl From<Filed> for Followup {
2371 fn from(filed: Filed) -> Self {
2372 match filed {
2373 Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => {
2374 Followup::Recorded(url)
2375 }
2376 Filed::AlreadyClosed(_, url) => Followup::Covered(url),
2380 }
2381 }
2382}
2383
2384impl Filed {
2385 pub fn url(&self) -> Option<&str> {
2386 match self {
2387 Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => Some(url),
2388 Filed::AlreadyClosed(_, _) => None,
2391 }
2392 }
2393
2394 pub fn issue(&self) -> i64 {
2397 match self {
2398 Filed::Opened(n, _)
2399 | Filed::AddedTo(n, _)
2400 | Filed::Covered(n, _)
2401 | Filed::AlreadyClosed(n, _) => *n,
2402 }
2403 }
2404
2405 pub fn number(&self) -> Option<i64> {
2407 match self {
2408 Filed::Opened(n, _) | Filed::AddedTo(n, _) | Filed::Covered(n, _) => Some(*n),
2409 Filed::AlreadyClosed(_, _) => None,
2410 }
2411 }
2412
2413 pub fn note(&self) -> String {
2415 match self {
2416 Filed::Opened(n, _) => format!("#{n}"),
2417 Filed::AddedTo(n, _) => format!("added to #{n}"),
2418 Filed::Covered(n, _) => format!("#{n} already says this"),
2419 Filed::AlreadyClosed(n, _) => format!("#{n} covers it and is closed"),
2420 }
2421 }
2422
2423 pub fn describe(&self, title: &str) -> String {
2424 let title = style::clip(title.trim(), 80);
2425 match self {
2426 Filed::Opened(n, _) => format!("filed #{n}: {title}"),
2427 Filed::AddedTo(n, _) => format!("added to #{n}: {title}"),
2428 Filed::Covered(n, _) => format!("#{n} already says this: {title}"),
2429 Filed::AlreadyClosed(n, _) => format!("#{n} covers it and is closed: {title}"),
2430 }
2431 }
2432}
2433
2434pub fn file_as_issue(repo: &Repo, title: &str, body: &str) -> Result<Filed> {
2446 file_as_issue_apart_from(repo, title, body, None)
2447}
2448
2449pub fn file_as_issue_apart_from(
2455 repo: &Repo,
2456 title: &str,
2457 body: &str,
2458 apart_from: Option<i64>,
2459) -> Result<Filed> {
2460 let title = repo.clean_title(title)?;
2461 if title.trim().is_empty() {
2462 return Err(spar_err!("nothing left of the title after cleaning it"));
2463 }
2464 let issue_body = repo.clean_issue_body(body)?;
2465 if let Some(existing) = repo.try_exact_issue_apart_from(&title, &issue_body, apart_from)? {
2466 return Ok(if existing.open {
2467 Filed::Covered(existing.number, existing.url)
2468 } else {
2469 Filed::AlreadyClosed(existing.number, existing.url)
2470 });
2471 }
2472 if let Some(existing) =
2473 repo.try_find_similar_issue_apart_from(&title, &issue_body, apart_from)?
2474 {
2475 let known = format!("{} {}", existing.title, existing.body);
2476 if !existing.open {
2477 return Ok(Filed::AlreadyClosed(existing.number, existing.url));
2478 }
2479 if crate::textsim::adds_information(&issue_body, &known) {
2480 repo.comment_issue(existing.number, &issue_body)?;
2481 return Ok(Filed::AddedTo(existing.number, existing.url));
2482 }
2483 return Ok(Filed::Covered(existing.number, existing.url));
2484 }
2485 let url = repo.create_issue_apart_from(&title, &issue_body, apart_from)?;
2486 let number = filed_issue_number(&url)
2487 .ok_or_else(|| spar_err!("filed an issue but could not read its number from {url}"))?;
2488 Ok(Filed::Opened(number, url))
2489}
2490
2491fn file_out_of_scope(
2492 repo: &Repo,
2493 findings: &[Finding],
2494 subject: i64,
2495 state: &mut IssueRun,
2496 cfg: &Config,
2497) {
2498 for finding in findings.iter().filter(|f| !f.in_scope) {
2499 let body = issue_report(finding);
2500 let recorded = file_followup(repo, &finding.title, &body, subject, cfg, state);
2501 if finding.severity != Severity::Nit || matches!(recorded, Followup::Recorded(_)) {
2502 record_nonblocking_outcome_with_match(
2503 state,
2504 finding,
2505 Some(&recorded),
2506 unique_stable_finding(findings, finding),
2507 );
2508 }
2509 }
2510}
2511
2512pub fn issue_report(finding: &Finding) -> String {
2519 let sections = finding.report_sections();
2520 if sections.is_empty() {
2521 return finding.detail.clone();
2522 }
2523 let mut out: Vec<String> = sections
2524 .iter()
2525 .map(|(heading, text)| format!("## {heading}\n\n{text}"))
2526 .collect();
2527 if !finding.detail.trim().is_empty()
2530 && !sections
2531 .iter()
2532 .any(|(_, text)| crate::textsim::same_point(text, &finding.detail))
2533 {
2534 out.insert(0, finding.detail.trim().to_string());
2535 }
2536 out.join("\n\n")
2537}
2538
2539fn file_nonblocking(
2546 repo: &Repo,
2547 findings: &[Finding],
2548 subject: i64,
2549 state: &mut IssueRun,
2550 cfg: &Config,
2551) {
2552 for finding in findings {
2553 if !finding.in_scope || finding.severity == Severity::Blocking {
2554 continue;
2555 }
2556 let should_file = match finding.severity {
2557 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking,
2558 Severity::Nit => cfg.loop_cfg.file_nits,
2559 Severity::Blocking => false,
2560 };
2561 if !should_file {
2562 if finding.severity == Severity::NonBlocking {
2563 record_nonblocking_outcome_with_match(
2564 state,
2565 finding,
2566 None,
2567 unique_stable_finding(findings, finding),
2568 );
2569 }
2570 continue;
2571 }
2572 let recorded = file_followup(repo, &finding.title, &finding.detail, subject, cfg, state);
2573 if finding.severity == Severity::NonBlocking {
2574 record_nonblocking_outcome_with_match(
2575 state,
2576 finding,
2577 Some(&recorded),
2578 unique_stable_finding(findings, finding),
2579 );
2580 } else if let Some(url) = recorded.url() {
2581 state.filed.push(url.to_string());
2582 }
2583 }
2584}
2585
2586fn bullets(lines: &[String]) -> String {
2596 lines
2597 .iter()
2598 .map(|l| format!("- {l}"))
2599 .collect::<Vec<_>>()
2600 .join("\n")
2601}
2602
2603fn located(finding: &Finding, style: &Style) -> String {
2604 let title = style::title(&finding.title, style);
2605 match finding.where_at() {
2606 "general" => title,
2607 file => format!("{title} ({file})"),
2608 }
2609}
2610
2611pub enum Ending<'a> {
2613 Approved,
2615 OutOfRounds,
2618 Unchanged,
2621 Unresolved(&'a [Finding]),
2624 Deadlocked(&'a [Finding]),
2627}
2628
2629#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2630enum OutcomeSink {
2631 PullRequest,
2632 Terminal,
2633}
2634
2635fn outcome_sink(mode: PrComments) -> OutcomeSink {
2636 match mode {
2637 PrComments::Outcome | PrComments::Rounds => OutcomeSink::PullRequest,
2638 PrComments::None => OutcomeSink::Terminal,
2639 }
2640}
2641
2642fn emit_outcome(repo: &Repo, pr_number: i64, text: &str) {
2643 if outcome_sink(repo.style.pr_comments) == OutcomeSink::Terminal {
2644 println!("\n{text}\n");
2645 return;
2646 }
2647 if let Err(e) = repo.comment_pr(pr_number, text) {
2648 logdim!("could not post the outcome comment: {e}");
2649 println!("\n{text}\n");
2650 }
2651}
2652
2653pub fn post_outcome(
2665 repo: &Repo,
2666 pr_number: i64,
2667 state: &IssueRun,
2668 ledger: &Ledger,
2669 ending: Ending<'_>,
2670) {
2671 let Some(text) = outcome_comment(state, ledger, &ending, &repo.style) else {
2672 return;
2673 };
2674 emit_outcome(repo, pr_number, &text);
2675}
2676
2677fn post_unread_outcome(
2678 repo: &Repo,
2679 pr_number: i64,
2680 state: &IssueRun,
2681 ledger: &Ledger,
2682 open_findings: &[Finding],
2683) {
2684 let Some(text) = outcome_comment_with_unread(
2685 state,
2686 ledger,
2687 &Ending::OutOfRounds,
2688 open_findings,
2689 &repo.style,
2690 ) else {
2691 return;
2692 };
2693 emit_outcome(repo, pr_number, &text);
2694}
2695
2696fn settled_as(finding: &Finding, state: &IssueRun, ledger: &Ledger) -> Option<(Settled, String)> {
2699 if let Some(d) = state
2700 .disputes
2701 .iter()
2702 .find(|d| same_finding_parts(&d.title, &d.file, &finding.title, &finding.file))
2703 {
2704 if !d.reasoning.trim().is_empty() {
2705 return Some((Settled::Refuted, d.reasoning.clone()));
2706 }
2707 }
2708 matching_ledger_entry(ledger, &finding.title, &finding.file)
2709 .filter(|entry| !entry.reasoning.trim().is_empty())
2710 .map(|entry| (entry.outcome, entry.reasoning.clone()))
2711}
2712
2713pub fn filed_issue_number(filed: &str) -> Option<i64> {
2718 filed
2719 .rsplit('/')
2720 .next()
2721 .and_then(|tail| tail.parse::<i64>().ok())
2722 .filter(|n| *n > 0)
2723}
2724
2725fn as_reference(url: &str) -> String {
2726 match url.rsplit('/').next().and_then(|n| n.parse::<u64>().ok()) {
2727 Some(number) => format!("#{number}"),
2728 None => url.to_string(),
2729 }
2730}
2731
2732pub fn outcome_comment(
2733 state: &IssueRun,
2734 ledger: &Ledger,
2735 ending: &Ending<'_>,
2736 style: &Style,
2737) -> Option<String> {
2738 outcome_comment_with_unread(state, ledger, ending, &[], style)
2739}
2740
2741fn outcome_comment_with_unread(
2742 state: &IssueRun,
2743 ledger: &Ledger,
2744 ending: &Ending<'_>,
2745 unread_open: &[Finding],
2746 style: &Style,
2747) -> Option<String> {
2748 let mut out: Vec<String> = Vec::new();
2749 let mut already: Vec<(String, String)> = Vec::new();
2752
2753 match ending {
2754 Ending::Approved => {
2755 if state.disputes.is_empty() && state.filed.is_empty() && state.noted.is_empty() {
2756 return None;
2762 }
2763 out.push("Reviewed, nothing blocking a merge.".into());
2764 }
2765 Ending::OutOfRounds => {
2766 out.push(
2767 "Not signed off: the last round of fixes was pushed but has not been reviewed."
2768 .into(),
2769 );
2770 if !unread_open.is_empty() {
2771 let lines: Vec<String> = unread_open
2772 .iter()
2773 .map(|finding| {
2774 already.push((finding.title.clone(), finding.file.clone()));
2775 format!(
2776 "{}. {}",
2777 located(finding, style),
2778 style::sentence(&finding.detail, style)
2779 )
2780 })
2781 .collect();
2782 out.push("These points were already open:".into());
2783 out.push(bullets(&lines));
2784 }
2785 }
2786 Ending::Unchanged => out.push(
2790 "Not signed off: the last round changed nothing, so the branch is the one that was \
2791 already reviewed."
2792 .into(),
2793 ),
2794 Ending::Unresolved(points) => {
2795 let lines: Vec<String> = points
2796 .iter()
2797 .map(|f| {
2798 already.push((f.title.clone(), f.file.clone()));
2799 format!(
2800 "{}. {}",
2801 located(f, style),
2802 style::sentence(&f.detail, style)
2803 )
2804 })
2805 .collect();
2806 out.push("Not signed off. These points are still open:".into());
2807 out.push(bullets(&lines));
2808 }
2809 Ending::Deadlocked(points) => {
2810 let lines: Vec<String> = points
2816 .iter()
2817 .map(|f| {
2818 let where_at = match f.where_at() {
2819 "general" => String::new(),
2820 file => format!(" ({file})"),
2821 };
2822 let title = style::title(&f.title, style);
2823 already.push((f.title.clone(), f.file.clone()));
2824 match settled_as(f, state, ledger) {
2825 Some((Settled::Refuted, reason)) => format!(
2826 "{title}{where_at}. Refuted as: {}",
2827 style::summary(&reason, style)
2828 ),
2829 Some((Settled::Filed, reason)) => format!(
2830 "{title}{where_at}. Filed as out of scope: {}",
2831 style::summary(&reason, style)
2832 ),
2833 Some((Settled::Dropped, reason)) => format!(
2836 "{title}{where_at}. Out of scope here, and not filed: {}",
2837 style::summary(&reason, style)
2838 ),
2839 Some((Settled::Fixed, reason)) => format!(
2845 "{title}{where_at}. Fixed and raised again. Recorded answer: {}",
2846 style::summary(&reason, style)
2847 ),
2848 None => format!("{title}{where_at}"),
2849 }
2850 })
2851 .collect();
2852 out.push("Needs your decision. The reviewers could not settle this:".into());
2853 out.push(bullets(&lines));
2854 }
2855 }
2856
2857 let disputes: Vec<&crate::model::Dispute> = state
2858 .disputes
2859 .iter()
2860 .filter(|d| {
2861 !already
2862 .iter()
2863 .any(|(title, file)| same_finding_parts(title, file, &d.title, &d.file))
2864 })
2865 .collect();
2866 if !disputes.is_empty() {
2867 let lines: Vec<String> = disputes
2870 .iter()
2871 .map(|d| {
2872 let title = style::title(&d.title, style);
2873 let title = if d.file.trim().is_empty() {
2874 title
2875 } else {
2876 format!("{title} ({})", d.file.trim())
2877 };
2878 format!("{}. {}", title, style::sentence(&d.reasoning, style))
2879 })
2880 .collect();
2881 out.push(format!("Raised and refuted:\n{}", bullets(&lines)));
2882 }
2883
2884 if !state.noted.is_empty() {
2885 let lines: Vec<String> = state
2889 .noted
2890 .iter()
2891 .filter(|f| {
2892 !already
2893 .iter()
2894 .any(|(title, file)| same_finding_parts(title, file, &f.title, &f.file))
2895 })
2896 .map(|f| located(f, style))
2897 .collect();
2898 if !lines.is_empty() {
2899 out.push(format!("Noted, not blocking:\n{}", bullets(&lines)));
2900 }
2901 }
2902
2903 if !state.filed.is_empty() {
2904 let refs: Vec<String> = state.filed.iter().map(|u| as_reference(u)).collect();
2905 out.push(format!("Filed separately: {}", refs.join(", ")));
2906 }
2907
2908 Some(out.join("\n\n"))
2909}
2910
2911#[allow(clippy::too_many_arguments)]
2920fn close_prompt(
2921 base: &str,
2922 number: i64,
2923 title: &str,
2924 from: &str,
2925 landed: Option<&[String]>,
2926 ledger: &Ledger,
2927 open_findings: &[Finding],
2928 round: u32,
2929) -> String {
2930 let landed = match landed {
2931 Some([]) => "\nNothing landed after the last round of review. What it asked for was \
2932 answered in words rather than in code, so the branch in front of you is the \
2933 branch that was already read.\n"
2934 .to_string(),
2935 Some(lines) => format!(
2936 "\nThis landed after the last round of review, and nobody has read it:\n{}\n\nRead it \
2937 first with `git diff {from}..HEAD`, then inspect the full branch with `git diff \
2938 {base}...HEAD`.\n",
2939 lines
2940 .iter()
2941 .map(|l| format!("- {l}"))
2942 .collect::<Vec<_>>()
2943 .join("\n")
2944 ),
2945 None => format!(
2946 "\nThe commits on this branch were rewritten after the last round of review, so the \
2947 harness cannot say which of them are new. Inspect the full branch with `git diff \
2948 {base}...HEAD`.\n"
2949 ),
2950 };
2951 CLOSE_PROMPT
2952 .replace("{number}", &number.to_string())
2953 .replace("{title}", title)
2954 .replace("{base}", base)
2955 .replace("{landed}", &landed)
2956 .replace("{open}", &open_findings_block(open_findings))
2957 .replace("{answers}", &closing_answers(ledger, round))
2958 .replace("{settled}", &settled_block(ledger))
2959}
2960
2961fn open_findings_block(findings: &[Finding]) -> String {
2962 if findings.is_empty() {
2963 return String::new();
2964 }
2965 format!(
2966 "\nThese blocking findings were left open by an earlier response. Recheck each one:\n{}\n\
2967 \nIf one still blocks, return it under the same title and file. Omission means you checked \
2968 it and found that it no longer blocks.\n",
2969 findings_for_prompt(findings)
2970 )
2971}
2972
2973fn closing_answers(ledger: &Ledger, round: u32) -> String {
2978 let lines = fixed_lines(ledger, round);
2979 if lines.is_empty() {
2980 return String::new();
2981 }
2982 format!(
2983 "\nThese points were raised on this pull request and the author says it fixed them. \
2984 Nobody has checked that:\n{}\n",
2985 lines.join("\n")
2986 )
2987}
2988
2989fn review_prompt(
2994 base: &str,
2995 number: i64,
2996 title: &str,
2997 ledger: &Ledger,
2998 open_findings: &[Finding],
2999 round: u32,
3000 last: u32,
3001) -> String {
3002 REVIEW_PROMPT
3003 .replace("{base}", base)
3004 .replace("{number}", &number.to_string())
3005 .replace("{title}", title)
3006 .replace("{open}", &open_findings_block(open_findings))
3007 .replace("{answers}", &answers_block(ledger, round))
3008 .replace("{settled}", &settled_block(ledger))
3009 .replace("{round}", &round_note(round, last))
3010}
3011
3012fn implement_prompt(number: i64, title: &str, url: &str, body: &str) -> String {
3020 IMPLEMENT_PROMPT
3021 .replace("{number}", &number.to_string())
3022 .replace("{title}", title)
3023 .replace("{url}", url)
3024 .replace("{body}", body)
3025}
3026
3027pub fn pr_body(issue: i64, work: &Implementation, style: &Style) -> String {
3037 let mut parts = vec![format!("Closes #{issue}")];
3038
3039 for lead in [&work.summary, &work.problem] {
3040 let text = style::sentence(lead, style);
3041 if !text.is_empty() {
3042 parts.push(text);
3043 }
3044 }
3045 parts.extend(section("What changed", &work.changes, style));
3046 parts.extend(section("How to test", &work.testing, style));
3047
3048 let notes = style::sentence(work.notes.as_deref().unwrap_or_default(), style);
3049 if !notes.is_empty() {
3050 parts.push(format!("## Notes\n\n{notes}"));
3051 }
3052
3053 style::body(&parts.join("\n\n"), style)
3054}
3055
3056fn section(heading: &str, lines: &[String], style: &Style) -> Option<String> {
3063 let items: Vec<String> = lines
3064 .iter()
3065 .map(|line| style::summary(line, style))
3066 .filter(|line| !line.is_empty())
3067 .collect();
3068 if items.is_empty() {
3069 return None;
3070 }
3071 Some(format!("## {heading}\n\n{}", bullets(&items)))
3072}
3073
3074pub fn from_commits(repo: &Repo, work_dir: &Path, base: &str) -> Implementation {
3082 Implementation {
3083 changes: repo.commit_subjects(work_dir, "HEAD", base),
3084 notes: Some(
3085 "The implement call failed after these commits were made, so this body is assembled \
3086 from their messages rather than written by their author. Read the diff."
3087 .to_string(),
3088 ),
3089 ..Implementation::default()
3090 }
3091}
3092
3093fn no_pr_note(work: &Implementation, style: &Style) -> String {
3100 let reason = style::sentence(&work.reason, style);
3101 if !reason.is_empty() {
3102 return reason;
3103 }
3104 if work.not_worth_doing {
3105 "Left alone after reading the code, with no reason given.".to_string()
3106 } else {
3107 "Nothing was committed, so there is nothing to review.".to_string()
3108 }
3109}
3110
3111pub fn review_comment(holder: &str, round: u32, review: &Review, style: &Style) -> String {
3115 let by = |severity: Severity| -> Vec<&Finding> {
3116 review
3117 .findings
3118 .iter()
3119 .filter(|f| f.severity == severity && f.in_scope)
3120 .collect()
3121 };
3122 let blocking = by(Severity::Blocking);
3123 let non_blocking = by(Severity::NonBlocking);
3124 let nits = by(Severity::Nit);
3125 let out_of_scope: Vec<&Finding> = review.findings.iter().filter(|f| !f.in_scope).collect();
3126
3127 let mut counts = Vec::new();
3128 if !blocking.is_empty() {
3129 counts.push(format!("{} blocking", blocking.len()));
3130 }
3131 if !non_blocking.is_empty() {
3132 counts.push(format!("{} non-blocking", non_blocking.len()));
3133 }
3134 if !nits.is_empty() {
3135 counts.push(format!("{} nit", nits.len()));
3136 }
3137 if !out_of_scope.is_empty() {
3138 counts.push(format!("{} out of scope", out_of_scope.len()));
3139 }
3140 let headline = if counts.is_empty() {
3141 "no findings".to_string()
3142 } else {
3143 counts.join(", ")
3144 };
3145
3146 let _ = (holder, round, headline);
3147 let mut out = Vec::new();
3148 let summary = style::summary(&review.summary, style);
3149 if !summary.is_empty() {
3150 out.push(summary);
3151 }
3152
3153 if !blocking.is_empty() {
3154 let lines: Vec<String> = blocking
3155 .iter()
3156 .map(|f| {
3157 let detail = style::detail(&f.detail, style);
3158 if detail.is_empty() {
3159 located(f, style)
3160 } else {
3161 format!("{}. {detail}", located(f, style))
3162 }
3163 })
3164 .collect();
3165 out.push(format!("blocking\n{}", bullets(&lines)));
3166 }
3167
3168 for (label, group) in [
3171 ("non-blocking", &non_blocking),
3172 ("nits", &nits),
3173 ("out of scope", &out_of_scope),
3174 ] {
3175 if group.is_empty() {
3176 continue;
3177 }
3178 let lines: Vec<String> = group.iter().map(|f| located(f, style)).collect();
3179 out.push(format!("{label}\n{}", bullets(&lines)));
3180 }
3181
3182 out.join("\n\n")
3183}
3184
3185pub fn disposition_comment(
3189 author: &str,
3190 response: &ResponseDoc,
3191 fixed: &[String],
3192 refuted: &[String],
3193 filed: &[String],
3194 style: &Style,
3195) -> Option<String> {
3196 if fixed.is_empty() && refuted.is_empty() && filed.is_empty() {
3197 return None;
3198 }
3199 let mut counts = Vec::new();
3200 if !fixed.is_empty() {
3201 counts.push(format!("{} fixed", fixed.len()));
3202 }
3203 if !refuted.is_empty() {
3204 counts.push(format!("{} refuted", refuted.len()));
3205 }
3206 if !filed.is_empty() {
3207 counts.push(format!("{} filed", filed.len()));
3208 }
3209
3210 let _ = (author, counts);
3211 let mut out = Vec::new();
3212 let summary = style::summary(&response.summary, style);
3213 if !summary.is_empty() {
3214 out.push(summary);
3215 }
3216 if !refuted.is_empty() {
3217 out.push(format!("refuted\n{}", bullets(refuted)));
3218 }
3219 if !fixed.is_empty() {
3220 out.push(format!("fixed\n{}", bullets(fixed)));
3221 }
3222 if !filed.is_empty() {
3223 out.push(format!("filed\n{}", bullets(filed)));
3224 }
3225 Some(out.join("\n\n"))
3226}
3227
3228pub fn skip_comment(item: &SkippedItem, style: &Style) -> String {
3236 let reasons = item
3237 .reasons
3238 .values()
3239 .map(|reason| style::sentence(reason, style));
3240 let lines = crate::textsim::dedupe_by(reasons, crate::textsim::same_reason);
3244 bullets(&lines)
3245}
3246
3247pub(crate) fn findings_for_prompt(findings: &[Finding]) -> String {
3250 if findings.is_empty() {
3251 return "(none)".to_string();
3252 }
3253 findings
3254 .iter()
3255 .map(|f| {
3256 let scope = if f.in_scope { "" } else { " [out of scope]" };
3257 format!(
3258 "- [{}]{scope} {} ({})\n {}",
3259 f.severity,
3260 f.title,
3261 f.where_at(),
3262 f.detail
3263 )
3264 })
3265 .collect::<Vec<_>>()
3266 .join("\n")
3267}
3268
3269#[cfg(test)]
3270mod tests {
3271 use super::*;
3272 use crate::model::Verdict;
3273
3274 fn style() -> Style {
3275 Style::default()
3276 }
3277
3278 fn finding(severity: &str, title: &str, detail: &str, file: &str, in_scope: bool) -> Finding {
3279 Finding {
3280 severity: Severity::parse_lenient(severity).unwrap(),
3281 title: title.into(),
3282 detail: detail.into(),
3283 file: file.into(),
3284 in_scope,
3285 ..Default::default()
3286 }
3287 }
3288
3289 fn review(summary: &str, findings: Vec<Finding>) -> Review {
3290 Review {
3291 verdict: Verdict::Approve,
3292 next_action: NextAction::Merge,
3293 summary: summary.into(),
3294 findings,
3295 }
3296 }
3297
3298 fn disposition(title: &str, file: &str, action: Action) -> Disposition {
3299 Disposition {
3300 title: title.into(),
3301 file: file.into(),
3302 action,
3303 reasoning: "because".into(),
3304 new_issue_title: None,
3305 new_issue_body: None,
3306 }
3307 }
3308
3309 fn cfg_with(worktrees: bool, keep: bool) -> Config {
3312 let text = "[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n";
3313 let mut cfg = crate::config::parse(text).unwrap();
3314 cfg.loop_cfg.worktrees = worktrees;
3315 cfg.loop_cfg.keep_worktrees = keep;
3316 cfg
3317 }
3318
3319 #[test]
3320 fn a_worktree_is_released_on_every_finished_outcome() {
3321 let cfg = cfg_with(true, false);
3322 for status in [Status::Approved, Status::Merged, Status::Abandoned] {
3323 assert!(should_release(&cfg, status), "{status}");
3324 }
3325 }
3326
3327 #[test]
3330 fn a_worktree_is_kept_only_where_a_human_has_to_look() {
3331 let cfg = cfg_with(true, false);
3332 assert!(!should_release(&cfg, Status::Escalated));
3333 assert!(!should_release(&cfg, Status::Error));
3334 }
3335
3336 #[test]
3337 fn the_keep_flag_overrides_everything() {
3338 assert!(!should_release(&cfg_with(true, true), Status::Approved));
3339 }
3340
3341 #[test]
3342 fn nothing_is_released_when_worktrees_are_off() {
3343 assert!(!should_release(&cfg_with(false, false), Status::Approved));
3344 }
3345
3346 #[test]
3351 fn fixing_your_own_findings_hands_the_pr_over() {
3352 let cfg = cfg_with(true, false);
3353 assert_eq!("a", next_reviewer(&cfg, "b", Some("b")));
3354 assert_eq!("b", next_reviewer(&cfg, "a", Some("a")));
3355 }
3356
3357 #[test]
3361 fn handing_back_keeps_the_reviewer_for_the_next_round() {
3362 let cfg = cfg_with(true, false);
3363 assert_eq!("b", next_reviewer(&cfg, "b", Some("a")));
3364 assert_eq!("a", next_reviewer(&cfg, "a", Some("b")));
3365 }
3366
3367 #[test]
3370 fn nobody_reviews_their_own_edit() {
3371 let cfg = cfg_with(true, false);
3372 let round_1 = cfg.other(&cfg.first_implementor);
3373 assert_eq!("b", round_1);
3374 for editor in ["a", "b"] {
3375 assert_ne!(editor, next_reviewer(&cfg, &round_1, Some(editor)));
3376 }
3377 }
3378
3379 #[test]
3384 fn a_fix_that_committed_nothing_leaves_the_pr_where_it_is() {
3385 let cfg = cfg_with(true, false);
3386 assert_eq!("b", next_reviewer(&cfg, "b", None));
3387 assert_eq!("a", next_reviewer(&cfg, "a", None));
3388 }
3389
3390 #[test]
3394 fn a_reviewer_that_wrote_the_head_gives_the_pr_up() {
3395 let cfg = cfg_with(true, false);
3396 assert_eq!("a", next_reviewer(&cfg, "b", Some("b")));
3397 }
3398
3399 #[test]
3403 fn a_review_that_wrote_cannot_approve_what_is_left() {
3404 assert!(!approval_stands(&[], true));
3405 }
3406
3407 #[test]
3408 fn a_clean_review_of_an_untouched_branch_approves() {
3409 assert!(approval_stands(&[], false));
3410 }
3411
3412 #[test]
3413 fn a_blocking_finding_never_approves() {
3414 let blocking = vec![finding("blocking", "Broken", "detail", "src/x.rs", true)];
3415 assert!(!approval_stands(&blocking, false));
3416 }
3417
3418 #[test]
3419 fn approval_refuses_a_head_that_changed_after_review() {
3420 assert!(ensure_reviewed_head(36, "abc123", "abc123").is_ok());
3421 let error = ensure_reviewed_head(36, "abc123", "def456").unwrap_err();
3422 assert!(error.to_string().contains("unread head"));
3423 }
3424
3425 #[test]
3427 fn only_a_moved_head_counts_as_a_commit() {
3428 let before = Snapshot {
3429 head: "abc".into(),
3430 dirty: false,
3431 };
3432 assert!(!Snapshot {
3433 head: "abc".into(),
3434 dirty: true,
3435 }
3436 .landed_over(&before));
3437 assert!(Snapshot {
3438 head: "def".into(),
3439 dirty: false,
3440 }
3441 .landed_over(&before));
3442 assert!(!Snapshot {
3444 head: String::new(),
3445 dirty: false,
3446 }
3447 .landed_over(&before));
3448 }
3449
3450 #[test]
3454 fn a_fresh_run_starts_at_one() {
3455 assert_eq!((1, 3), round_window(1, 3));
3456 assert_eq!((1, 5), round_window(1, 5));
3457 }
3458
3459 #[test]
3463 fn a_resumed_run_gets_a_full_fresh_budget() {
3464 assert_eq!((6, 10), round_window(6, 5));
3465 assert_eq!((11, 13), round_window(11, 3));
3466 }
3467
3468 #[test]
3469 fn a_budget_of_one_is_a_single_round() {
3470 assert_eq!((6, 6), round_window(6, 1));
3471 }
3472
3473 #[test]
3474 fn round_numbers_keep_counting_across_sessions() {
3475 let mut start = 1;
3477 let mut seen = Vec::new();
3478 for _ in 0..3 {
3479 let (first, last) = round_window(start, 3);
3480 seen.push((first, last));
3481 start = last + 1;
3482 }
3483 assert_eq!(vec![(1, 3), (4, 6), (7, 9)], seen);
3484 }
3485
3486 fn ledger_with(title: &str, file: &str) -> Ledger {
3489 let mut ledger = Ledger::new();
3490 ledger.insert(
3491 finding_key(title, file),
3492 LedgerEntry {
3493 title: title.into(),
3494 file: file.into(),
3495 reasoning: "no".into(),
3496 round: 1,
3497 reraised: 0,
3498 outcome: Settled::Refuted,
3499 },
3500 );
3501 ledger
3502 }
3503
3504 #[test]
3505 fn a_point_refuted_and_re_raised_twice_escalates() {
3506 let mut ledger = ledger_with("nit about naming", "a.rs");
3507 let mut state = IssueRun::new(1, "t");
3508 let blocking = vec![finding("blocking", "nit about naming", "d", "a.rs", true)];
3509 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
3510 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
3511 }
3512
3513 #[test]
3517 fn a_point_fixed_twice_and_raised_again_escalates() {
3518 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
3519 for entry in ledger.values_mut() {
3520 entry.outcome = Settled::Fixed;
3521 }
3522 let mut state = IssueRun::new(1, "t");
3523 let blocking = [finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
3524
3525 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
3526 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
3527 }
3528
3529 #[test]
3532 fn a_fix_that_missed_twice_is_not_reported_as_a_refutation() {
3533 assert!(why_escalated(Settled::Fixed).contains("fixed twice"));
3534 for outcome in [Settled::Refuted, Settled::Filed, Settled::Dropped] {
3535 assert!(why_escalated(outcome).contains("settled"), "{outcome}");
3536 }
3537 }
3538
3539 #[test]
3544 fn a_reviewer_that_fixes_its_own_findings_records_them_too() {
3545 let mut ledger = Ledger::new();
3546 let blocking = vec![finding(
3547 "blocking",
3548 "Unbounded loop",
3549 "spins",
3550 "src/x.rs",
3551 true,
3552 )];
3553 let mut state = IssueRun::new(1, "t");
3554
3555 record_own_fixes(&blocking, &mut ledger, &mut state, 1);
3556
3557 let entry = ledger
3558 .get(&finding_key("Unbounded loop", "src/x.rs"))
3559 .expect("keyed where the next round will look");
3560 assert_eq!(Settled::Fixed, entry.outcome);
3561 assert_eq!(
3562 "a committed change was made for this point",
3563 entry.reasoning
3564 );
3565
3566 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
3568 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
3569 }
3570
3571 #[test]
3575 fn a_fixed_point_is_not_in_the_settled_block() {
3576 let mut ledger = ledger_with("refuted point", "a.rs");
3577 ledger.extend(ledger_with("fixed point", "b.rs"));
3578 for entry in ledger.values_mut() {
3579 if entry.title == "fixed point" {
3580 entry.outcome = Settled::Fixed;
3581 }
3582 }
3583 let block = settled_block(&ledger);
3584 assert!(block.contains("refuted point"));
3585 assert!(!block.contains("fixed point"));
3586 }
3587
3588 #[test]
3591 fn a_ledger_of_only_fixes_says_nothing_is_settled() {
3592 let mut ledger = ledger_with("fixed point", "b.rs");
3593 for entry in ledger.values_mut() {
3594 entry.outcome = Settled::Fixed;
3595 }
3596 assert_eq!("", settled_block(&ledger));
3597 }
3598
3599 #[test]
3604 fn one_review_spends_one_re_raise_however_often_it_says_it() {
3605 let mut ledger = ledger_with("Missing error handling", "src/net.rs");
3606 let mut state = IssueRun::new(1, "t");
3607 let twice = vec![
3608 finding(
3609 "blocking",
3610 "Missing error handling",
3611 "d",
3612 "src/net.rs",
3613 true,
3614 ),
3615 finding(
3616 "blocking",
3617 "Missing error handling",
3618 "e",
3619 "src/net.rs",
3620 true,
3621 ),
3622 ];
3623
3624 assert!(!check_relitigation(&mut ledger, &twice, &mut state));
3625 assert_eq!(1, ledger.values().next().unwrap().reraised);
3626 assert!(check_relitigation(&mut ledger, &twice, &mut state));
3627 }
3628
3629 #[test]
3630 fn an_untracked_finding_does_not_escalate() {
3631 let mut state = IssueRun::new(1, "t");
3632 let blocking = vec![finding("blocking", "brand new", "d", "a.rs", true)];
3633 assert!(!check_relitigation(
3634 &mut Ledger::new(),
3635 &blocking,
3636 &mut state
3637 ));
3638 }
3639
3640 #[test]
3641 fn persisted_ledger_entries_are_rekeyed_for_stable_locations() {
3642 let mut ledger = Ledger::new();
3643 ledger.insert(
3644 "legacy-key".into(),
3645 LedgerEntry {
3646 title: "Unbounded loop".into(),
3647 file: "src/x.rs:88".into(),
3648 reasoning: "bounded by the caller".into(),
3649 round: 2,
3650 reraised: 1,
3651 outcome: Settled::Refuted,
3652 },
3653 );
3654 normalise_ledger_keys(&mut ledger);
3655 let key = matching_ledger_key(&ledger, "Unbounded loop", "src/x.rs:91").unwrap();
3656 assert_eq!(1, ledger[&key].reraised);
3657 }
3658
3659 #[test]
3660 fn same_title_at_two_sites_keeps_both_blockers() {
3661 let findings = vec![
3662 finding(
3663 "blocking",
3664 "Unchecked error",
3665 "first site",
3666 "src/net.rs:10",
3667 true,
3668 ),
3669 finding(
3670 "blocking",
3671 "Unchecked error",
3672 "second site",
3673 "src/net.rs:200",
3674 true,
3675 ),
3676 ];
3677
3678 let blocking = blocking_findings(&findings);
3679 assert_eq!(2, blocking.len());
3680 assert_eq!("src/net.rs:10", blocking[0].file);
3681 assert_eq!("src/net.rs:200", blocking[1].file);
3682 }
3683
3684 #[test]
3685 fn moved_location_fallback_refuses_an_ambiguous_ledger() {
3686 let mut ledger = ledger_with("Unchecked error", "src/net.rs:10");
3687 ledger.extend(ledger_with("Unchecked error", "src/net.rs:200"));
3688 assert!(matching_ledger_key(&ledger, "Unchecked error", "src/net.rs:30").is_none());
3689 }
3690
3691 #[test]
3692 fn two_current_sites_do_not_relocate_one_old_ledger_entry() {
3693 let mut ledger = ledger_with("Unchecked error", "src/net.rs:5");
3694 let blocking = vec![
3695 finding(
3696 "blocking",
3697 "Unchecked error",
3698 "first",
3699 "src/net.rs:10",
3700 true,
3701 ),
3702 finding(
3703 "blocking",
3704 "Unchecked error",
3705 "second",
3706 "src/net.rs:200",
3707 true,
3708 ),
3709 ];
3710 let mut state = IssueRun::new(1, "t");
3711 record_own_fixes(&blocking, &mut ledger, &mut state, 2);
3712 assert!(ledger.contains_key(&finding_key("Unchecked error", "src/net.rs:10")));
3713 assert!(ledger.contains_key(&finding_key("Unchecked error", "src/net.rs:200")));
3714 }
3715
3716 #[test]
3717 fn two_current_sites_remain_two_open_findings() {
3718 let current = vec![
3719 finding(
3720 "blocking",
3721 "Unchecked error",
3722 "first",
3723 "src/net.rs:10",
3724 true,
3725 ),
3726 finding(
3727 "blocking",
3728 "Unchecked error",
3729 "second",
3730 "src/net.rs:200",
3731 true,
3732 ),
3733 ];
3734 let mut open = Vec::new();
3735
3736 extend_findings(&mut open, ¤t);
3737
3738 assert_eq!(2, open.len());
3739 assert_eq!("src/net.rs:10", open[0].file);
3740 assert_eq!("src/net.rs:200", open[1].file);
3741 }
3742
3743 #[test]
3744 fn display_limits_do_not_change_persisted_finding_identity() {
3745 let point = finding(
3746 "blocking",
3747 "abcdefghij",
3748 "still wrong",
3749 "src/net.rs:10",
3750 true,
3751 );
3752 let mut ledger = Ledger::new();
3753 let mut state = IssueRun::new(1, "t");
3754 record_own_fixes(std::slice::from_ref(&point), &mut ledger, &mut state, 1);
3755 normalise_ledger_keys(&mut ledger);
3756
3757 let key = matching_ledger_key(&ledger, "abcdefghij", "src/net.rs:12").unwrap();
3758 assert_eq!("abcdefghij", ledger[&key].title);
3759 }
3760
3761 #[test]
3762 fn a_clipped_legacy_entry_keeps_its_original_lookup_key() {
3763 let mut ledger = Ledger::new();
3764 let key = crate::jsonx::finding_key("abcdefghij", "src/net.rs:10");
3765 ledger.insert(
3766 key.clone(),
3767 LedgerEntry {
3768 title: "abcde".into(),
3769 file: "src/net.rs:10".into(),
3770 reasoning: "bounded by the caller".into(),
3771 round: 1,
3772 reraised: 1,
3773 outcome: Settled::Refuted,
3774 },
3775 );
3776 normalise_ledger_keys(&mut ledger);
3777
3778 assert_eq!(
3779 Some(key),
3780 matching_ledger_key(&ledger, "abcdefghij", "src/net.rs:12")
3781 );
3782 }
3783
3784 #[test]
3785 fn a_legacy_key_collision_does_not_merge_case_distinct_paths() {
3786 let mut ledger = Ledger::new();
3787 let key = crate::jsonx::finding_key("Unchecked error", "src/Main.rs:10");
3788 ledger.insert(
3789 key.clone(),
3790 LedgerEntry {
3791 title: "Unchecked error".into(),
3792 file: "src/Main.rs:10".into(),
3793 reasoning: "bounded by the caller".into(),
3794 round: 1,
3795 reraised: 0,
3796 outcome: Settled::Refuted,
3797 },
3798 );
3799
3800 assert_eq!(
3801 Some(key),
3802 matching_ledger_key(&ledger, "Unchecked error", "src/Main.rs:10")
3803 );
3804 assert!(matching_ledger_key(&ledger, "Unchecked error", "src/main.rs:10").is_none());
3805 }
3806
3807 #[test]
3811 fn a_refutation_lands_on_the_key_the_next_round_will_look_up() {
3812 let blocking = [finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
3813 let recorded = finding_key(&blocking[0].title, &blocking[0].file);
3814 let answer = disposition("unbounded loop!", "src/x.rs", Action::Refuted);
3815 assert!(disposition_matches(&blocking[0], &answer));
3816 assert_eq!(recorded, finding_key(&answer.title, &answer.file));
3817 }
3818
3819 #[test]
3822 fn the_ledger_key_ignores_title_punctuation() {
3823 let findings = [finding(
3824 "blocking",
3825 "Panic on multi-byte input",
3826 "d",
3827 "src/style.rs",
3828 true,
3829 )];
3830 let reworded = "Panic on multibyte input";
3831 let source = &findings[0];
3832 assert_eq!(
3833 finding_key(reworded, &source.file),
3834 finding_key(&source.title, &source.file)
3835 );
3836 let recorded = finding_key(&source.title, &source.file);
3837 let looked_up = finding_key(&findings[0].title, &findings[0].file);
3838 assert_eq!(recorded, looked_up);
3839 }
3840
3841 #[test]
3842 fn a_disposition_matches_its_finding_despite_wording_noise() {
3843 let findings = [finding(
3844 "blocking",
3845 "Unbounded loop!",
3846 "d",
3847 "src/x.rs",
3848 true,
3849 )];
3850 assert!(disposition_matches(
3851 &findings[0],
3852 &disposition("unbounded loop", "src/x.rs", Action::Refuted)
3853 ));
3854 assert!(!disposition_matches(
3855 &findings[0],
3856 &disposition("something else", "src/x.rs", Action::Refuted)
3857 ));
3858 assert!(!disposition_matches(
3859 &findings[0],
3860 &disposition("unbounded loop", "src/y.rs", Action::Refuted)
3861 ));
3862 }
3863
3864 #[test]
3865 fn an_omitted_disposition_leaves_the_blocker_unmatched() {
3866 let blocker = finding("blocking", "Unbounded loop", "d", "src/x.rs", true);
3867 assert!(matches!(
3868 matching_disposition(&blocker, &[]),
3869 Err("no matching disposition")
3870 ));
3871 }
3872
3873 #[test]
3874 fn duplicate_dispositions_are_ambiguous() {
3875 let blocker = finding("blocking", "Unbounded loop", "d", "src/x.rs", true);
3876 let answers = vec![
3877 disposition("Unbounded loop", "src/x.rs", Action::Fixed),
3878 disposition("Unbounded loop", "src/x.rs", Action::Refuted),
3879 ];
3880 assert!(matches!(
3881 matching_disposition(&blocker, &answers),
3882 Err("more than one matching disposition")
3883 ));
3884 }
3885
3886 #[test]
3887 fn same_titled_findings_in_different_files_need_separate_dispositions() {
3888 let left = finding("blocking", "Unchecked error", "d", "src/a.rs", true);
3889 let right = finding("blocking", "Unchecked error", "d", "src/b.rs", true);
3890 let answers = vec![
3891 disposition("Unchecked error", "src/a.rs", Action::Fixed),
3892 disposition("Unchecked error", "src/b.rs", Action::Refuted),
3893 ];
3894 assert_eq!(
3895 0,
3896 matching_disposition(&left, &answers)
3897 .expect("left answer")
3898 .0
3899 );
3900 assert_eq!(
3901 1,
3902 matching_disposition(&right, &answers)
3903 .expect("right answer")
3904 .0
3905 );
3906 }
3907
3908 #[test]
3909 fn a_reported_fix_without_a_commit_stays_open() {
3910 assert!(!fixed_disposition_resolves(false));
3911 assert!(fixed_disposition_resolves(true));
3912 }
3913
3914 #[test]
3915 fn the_settled_block_is_empty_when_nothing_is_settled() {
3916 assert_eq!("", settled_block(&Ledger::new()));
3917 }
3918
3919 #[test]
3920 fn the_settled_block_names_each_refutation() {
3921 let block = settled_block(&ledger_with("a point", "x.rs"));
3922 assert!(block.contains("a point"));
3923 assert!(block.contains("x.rs"));
3924 assert!(block.contains("settled"));
3925 }
3926
3927 #[test]
3928 fn same_title_settlements_name_each_location() {
3929 let mut ledger = ledger_with("Unchecked error", "a.rs:10");
3930 ledger.extend(ledger_with("Unchecked error", "b.rs:20"));
3931
3932 let block = settled_block(&ledger);
3933
3934 assert!(block.contains("Unchecked error (a.rs:10)"), "{block}");
3935 assert!(block.contains("Unchecked error (b.rs:20)"), "{block}");
3936 }
3937
3938 #[test]
3942 fn a_filed_point_is_settled_too() {
3943 let mut ledger = ledger_with("out of scope", "x.rs");
3944 for entry in ledger.values_mut() {
3945 entry.outcome = Settled::Filed;
3946 entry.reasoning = "Tracked in #9.".into();
3947 }
3948 let block = settled_block(&ledger);
3949 assert!(block.contains("out of scope"));
3950 assert!(block.contains("#9"));
3951 }
3952
3953 #[test]
3956 fn answering_a_point_again_keeps_its_re_raise_count() {
3957 let mut ledger = ledger_with("a point", "x.rs");
3958 let entry = ledger.values().next().unwrap().clone();
3959 let mut state = IssueRun::new(1, "t");
3960 let blocking = vec![finding("blocking", "a point", "d", "x.rs", true)];
3961
3962 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
3963 settle(&mut ledger, "a point", "x.rs", true, entry);
3964 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
3965 }
3966
3967 #[test]
3970 fn a_clean_review_is_just_the_verdict() {
3973 let text = review_comment("codex", 1, &review("Looks correct.", vec![]), &style());
3974 assert_eq!("Looks correct.", text);
3975 }
3976
3977 #[test]
3978 fn a_review_leads_with_the_counts() {
3979 let text = review_comment(
3980 "codex",
3981 2,
3982 &review(
3983 "One real problem.",
3984 vec![
3985 finding(
3986 "blocking",
3987 "Loop never terminates",
3988 "Confirmed by running it.",
3989 "src/a.rs",
3990 true,
3991 ),
3992 finding("non-blocking", "Name is vague", "d", "src/b.rs", true),
3993 finding("nit", "Log wording", "d", "", true),
3994 ],
3995 ),
3996 &style(),
3997 );
3998 assert!(text.starts_with("One real problem."), "{text}");
3999 assert!(!text.contains("codex"), "no agent name: {text}");
4000 assert!(!text.contains("round 2"), "no round number: {text}");
4001 }
4002
4003 #[test]
4006 fn only_blocking_findings_carry_their_detail() {
4007 let text = review_comment(
4008 "codex",
4009 1,
4010 &review(
4011 "s",
4012 vec![
4013 finding("blocking", "Loop", "BLOCKING DETAIL", "a.rs", true),
4014 finding("non-blocking", "Name", "NONBLOCKING DETAIL", "b.rs", true),
4015 ],
4016 ),
4017 &style(),
4018 );
4019 assert!(text.contains("BLOCKING DETAIL"), "{text}");
4020 assert!(!text.contains("NONBLOCKING DETAIL"), "{text}");
4021 }
4022
4023 #[test]
4024 fn a_thorough_explanation_reaches_the_author_intact() {
4027 let detail = "Reproduced by running the 429 test with max_attempts unset. ".repeat(8);
4028 let text = review_comment(
4029 "codex",
4030 1,
4031 &review(
4032 "One problem.",
4033 vec![finding("blocking", "T", &detail, "a.rs", true)],
4034 ),
4035 &style(),
4036 );
4037 assert!(
4038 text.contains(detail.trim()),
4039 "the explanation was cut:\n{text}"
4040 );
4041 }
4042
4043 #[test]
4045 fn a_runaway_model_is_still_bounded() {
4046 let long = "filler words. ".repeat(20_000);
4047 let text = review_comment(
4048 "codex",
4049 1,
4050 &review(&long, vec![finding("blocking", "T", &long, "a.rs", true)]),
4051 &style(),
4052 );
4053 assert!(
4054 text.len() < 30_000,
4055 "review comment was {} chars",
4056 text.len()
4057 );
4058 }
4059
4060 #[test]
4061 fn a_general_finding_has_no_empty_parenthesis() {
4062 let text = review_comment(
4063 "codex",
4064 1,
4065 &review("s", vec![finding("blocking", "Something", "d", "", true)]),
4066 &style(),
4067 );
4068 assert!(!text.contains("()"), "{text}");
4069 assert!(!text.contains("(general)"), "{text}");
4070 }
4071
4072 #[test]
4073 fn out_of_scope_findings_are_counted_separately() {
4074 let text = review_comment(
4075 "codex",
4076 1,
4077 &review(
4078 "s",
4079 vec![finding("blocking", "Old bug", "d", "a.rs", false)],
4080 ),
4081 &style(),
4082 );
4083 assert!(text.contains("out of scope"), "{text}");
4084 assert!(text.contains("Old bug"), "{text}");
4085 }
4086
4087 #[test]
4088 fn a_disposition_comment_leads_with_counts_and_keeps_refutations() {
4089 let response = ResponseDoc {
4090 summary: "Two of three were right.".into(),
4091 dispositions: vec![],
4092 };
4093 let text = disposition_comment(
4094 "claude",
4095 &response,
4096 &["Fixed thing".to_string()],
4097 &["Wrong thing. Because the caller already checks.".to_string()],
4098 &[],
4099 &style(),
4100 )
4101 .unwrap();
4102 assert!(text.starts_with("Two of three were right."), "{text}");
4103 assert!(!text.contains("claude"), "no agent name: {text}");
4104 assert!(
4105 text.contains("Because the caller already checks."),
4106 "{text}"
4107 );
4108 }
4109
4110 #[test]
4111 fn an_empty_disposition_comment_is_not_posted() {
4112 let response = ResponseDoc {
4113 summary: "s".into(),
4114 dispositions: vec![],
4115 };
4116 assert!(disposition_comment("claude", &response, &[], &[], &[], &style()).is_none());
4117 }
4118
4119 #[test]
4124 fn a_local_closing_commit_does_not_change_remote_custody() {
4125 for holder in ["a", "b"] {
4126 assert_eq!(holder, closing_next_actor(holder));
4127 }
4128 }
4129
4130 #[test]
4131 fn a_matching_head_keeps_saved_custody() {
4132 assert!(reconcile_saved_head(Some("abc123"), "abc123", None, 42).unwrap());
4133 assert!(reconcile_saved_head(None, "abc123", None, 42).unwrap());
4134 }
4135
4136 #[test]
4137 fn a_changed_head_refuses_automatic_custody() {
4138 let error = reconcile_saved_head(Some("abc123"), "def456", None, 42).unwrap_err();
4139 let text = error.to_string();
4140 assert!(text.contains("abc123"), "{text}");
4141 assert!(text.contains("def456"), "{text}");
4142 assert!(text.contains("--next <agent>"), "{text}");
4143 }
4144
4145 #[test]
4146 fn an_explicit_holder_resets_state_for_a_changed_or_legacy_head() {
4147 assert!(!reconcile_saved_head(Some("abc123"), "def456", Some("b"), 42).unwrap());
4148 assert!(!reconcile_saved_head(Some(""), "def456", Some("b"), 42).unwrap());
4149 assert!(reconcile_saved_head(Some(""), "def456", None, 42).is_err());
4150 }
4151
4152 #[test]
4153 fn an_invalid_review_cannot_clear_a_carried_blocker() {
4154 let mut open = vec![finding(
4155 "blocking",
4156 "Unchecked error",
4157 "still fails",
4158 "src/a.rs:12",
4159 true,
4160 )];
4161 update_open_findings(&mut open, &[], false);
4162 assert_eq!(1, open.len());
4163
4164 update_open_findings(&mut open, &[], true);
4165 assert!(open.is_empty());
4166 }
4167
4168 #[test]
4169 fn a_final_round_with_no_commit_names_open_blockers() {
4170 let open = vec![finding(
4171 "blocking",
4172 "Unchecked error",
4173 "still fails",
4174 "src/a.rs",
4175 true,
4176 )];
4177 assert!(matches!(
4178 ending_without_landing(&open),
4179 Ending::Unresolved(points) if points.len() == 1
4180 ));
4181 assert!(matches!(ending_without_landing(&[]), Ending::Unchanged));
4182 }
4183
4184 #[test]
4185 fn a_closing_pass_uses_the_later_effort_tier() {
4186 assert_eq!(2, closing_effort_round(1));
4187 assert_eq!(8, closing_effort_round(7));
4188 }
4189
4190 #[test]
4191 fn a_ledger_with_no_claimed_fix_has_nothing_to_close_over() {
4192 assert!(!any_fixes(&ledger_with("refuted point", "a.rs"), 1));
4193 let mut fixed = ledger_with("fixed point", "b.rs");
4194 for entry in fixed.values_mut() {
4195 entry.outcome = Settled::Fixed;
4196 }
4197 assert!(any_fixes(&fixed, 1));
4198 }
4199
4200 #[test]
4203 fn the_closing_note_counts_points_rather_than_rounds() {
4204 assert_eq!("one point left after the closing pass", unresolved_note(1));
4205 assert_eq!("3 points left after the closing pass", unresolved_note(3));
4206 assert!(!unresolved_note(2).contains("round"));
4207 }
4208
4209 fn fixed_ledger() -> Ledger {
4210 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
4211 for entry in ledger.values_mut() {
4212 entry.outcome = Settled::Fixed;
4213 entry.reasoning = "bounded it on max_attempts".into();
4214 }
4215 ledger
4216 }
4217
4218 #[test]
4219 fn the_closing_prompt_names_every_fix_it_has_to_check() {
4220 let landed = vec!["abc1234 Bound the retry loop".to_string()];
4221 let prompt = close_prompt(
4222 "main",
4223 42,
4224 "Retry a 429",
4225 "9f8e7d6",
4226 Some(&landed),
4227 &fixed_ledger(),
4228 &[],
4229 1,
4230 );
4231 assert!(prompt.contains("Unbounded loop"), "{prompt}");
4232 assert!(prompt.contains("bounded it on max_attempts"), "{prompt}");
4233 assert!(prompt.contains("abc1234 Bound the retry loop"), "{prompt}");
4234 assert!(prompt.contains("git diff 9f8e7d6..HEAD"), "{prompt}");
4235 assert!(prompt.contains("git diff main...HEAD"), "{prompt}");
4236 assert!(!prompt.contains('{'), "{prompt}");
4237 }
4238
4239 #[test]
4242 fn a_close_with_nothing_landed_says_so_rather_than_leaving_a_hole() {
4243 let prompt = close_prompt(
4244 "main",
4245 42,
4246 "Retry a 429",
4247 "9f8e7d6",
4248 Some(&[]),
4249 &Ledger::new(),
4250 &[],
4251 1,
4252 );
4253 assert!(
4254 prompt.contains("Nothing landed after the last round"),
4255 "{prompt}"
4256 );
4257 assert!(!prompt.contains('{'), "{prompt}");
4258 }
4259
4260 #[test]
4266 fn a_rewritten_branch_admits_it_cannot_say_what_landed() {
4267 let prompt = close_prompt(
4268 "main",
4269 42,
4270 "Retry a 429",
4271 "9f8e7d6",
4272 None,
4273 &fixed_ledger(),
4274 &[],
4275 1,
4276 );
4277 assert!(prompt.contains("were rewritten"), "{prompt}");
4278 assert!(prompt.contains("git diff main...HEAD"), "{prompt}");
4279 assert!(!prompt.contains("nobody has read it"), "{prompt}");
4280 assert!(!prompt.contains('{'), "{prompt}");
4281 }
4282
4283 #[test]
4286 fn the_closing_prompt_forbids_the_writing_the_loop_rolls_back() {
4287 let prompt = close_prompt(
4288 "main",
4289 42,
4290 "t",
4291 "9f8e7d6",
4292 Some(&[]),
4293 &Ledger::new(),
4294 &[],
4295 1,
4296 );
4297 assert!(prompt.contains("do not commit"), "{prompt}");
4298 }
4299
4300 #[test]
4302 fn the_closing_prompt_keeps_confirmed_merge_blockers_blocking() {
4303 let prompt = close_prompt(
4304 "main",
4305 42,
4306 "t",
4307 "9f8e7d6",
4308 Some(&[]),
4309 &Ledger::new(),
4310 &[],
4311 1,
4312 );
4313 assert!(
4314 prompt.contains("serious defect an\nearlier round missed"),
4315 "{prompt}"
4316 );
4317 assert!(prompt.contains("final merge-safety audit"), "{prompt}");
4318 assert!(!prompt.contains("not another\naudit"), "{prompt}");
4319 assert!(!prompt.contains("A\nfinding means"), "{prompt}");
4320 assert!(prompt.contains("A\nblocking finding means"), "{prompt}");
4321 let flat = prompt.split_whitespace().collect::<Vec<_>>().join(" ");
4322 assert!(flat.contains("does not become non-blocking"), "{prompt}");
4323 assert!(flat.contains("Only one of them ships"), "{prompt}");
4324 }
4325
4326 #[test]
4331 fn the_closing_pass_is_offered_a_severity_rather_than_the_field_that_gates() {
4332 let prompt = close_prompt(
4333 "main",
4334 42,
4335 "t",
4336 "9f8e7d6",
4337 Some(&[]),
4338 &Ledger::new(),
4339 &[],
4340 1,
4341 );
4342 assert!(
4343 prompt.contains("Minor defects and improvements are\nnon-blocking"),
4344 "{prompt}"
4345 );
4346 assert!(
4347 prompt.contains("a real defect\nthis pull request did not cause"),
4348 "{prompt}"
4349 );
4350 }
4351
4352 #[test]
4355 fn an_out_of_scope_point_cannot_gate_the_close() {
4356 let out_of_scope = finding("blocking", "Adjacent leak", "d", "o.rs", false);
4357 assert!(!out_of_scope.blocks());
4358 assert!(approval_stands(&[], false));
4359 }
4360
4361 #[test]
4365 fn a_fix_is_shown_to_the_pass_that_has_to_check_it_and_not_after() {
4366 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
4367 for entry in ledger.values_mut() {
4368 entry.outcome = Settled::Fixed;
4369 entry.round = 2;
4370 }
4371 assert!(answers_block(&ledger, 3).contains("Unbounded loop"));
4373 assert_eq!("", answers_block(&ledger, 4));
4375 assert!(any_fixes(&ledger, 2));
4376 assert!(!any_fixes(&ledger, 3));
4377 }
4378
4379 #[test]
4384 fn the_answers_block_asks_the_reviewer_to_check_rather_than_to_trust() {
4385 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
4386 for entry in ledger.values_mut() {
4387 entry.outcome = Settled::Fixed;
4388 entry.reasoning = "bounded it on max_attempts".into();
4389 }
4390 let block = answers_block(&ledger, 2);
4391 assert!(block.contains("Unbounded loop"), "{block}");
4392 assert!(block.contains("src/x.rs"), "{block}");
4393 assert!(block.contains("bounded it on max_attempts"), "{block}");
4394 assert!(block.contains("Check the answer"), "{block}");
4395 assert!(!block.contains("settled"), "{block}");
4396 }
4397
4398 #[test]
4401 fn a_fix_and_a_refutation_do_not_share_a_heading() {
4402 let mut ledger = ledger_with("refuted point", "a.rs");
4403 ledger.extend(ledger_with("fixed point", "b.rs"));
4404 for entry in ledger.values_mut() {
4405 if entry.title == "fixed point" {
4406 entry.outcome = Settled::Fixed;
4407 }
4408 }
4409 let answers = answers_block(&ledger, 2);
4410 let settled = settled_block(&ledger);
4411 assert!(answers.contains("fixed point") && !answers.contains("refuted point"));
4412 assert!(settled.contains("refuted point") && !settled.contains("fixed point"));
4413 }
4414
4415 #[test]
4416 fn an_empty_ledger_adds_no_answers_block() {
4417 assert_eq!("", answers_block(&Ledger::new(), 2));
4418 }
4419
4420 #[test]
4423 fn the_last_round_that_can_ask_for_anything_says_so() {
4424 assert_eq!("", round_note(1, 3));
4425 assert_eq!("", round_note(2, 3));
4426 assert!(round_note(3, 3).contains("last round"));
4427 assert!(round_note(6, 6).contains("last round"));
4430 }
4431
4432 #[test]
4438 fn saying_when_the_asking_stops_says_nothing_about_severity() {
4439 let note = round_note(3, 3);
4440 for word in ["approve", "blocking", "severity", "nit"] {
4441 assert!(!note.contains(word), "{word} in: {note}");
4442 }
4443 }
4444
4445 #[test]
4446 fn the_review_prompt_leaves_nothing_unsubstituted() {
4447 let empty = review_prompt("main", 42, "Retry a 429", &Ledger::new(), &[], 1, 3);
4448 assert!(!empty.contains('{'), "{empty}");
4449 assert!(empty.contains("main") && empty.contains("#42") && empty.contains("Retry a 429"));
4450
4451 let mut ledger = ledger_with("refuted point", "a.rs");
4452 ledger.extend(ledger_with("fixed point", "b.rs"));
4453 for entry in ledger.values_mut() {
4454 if entry.title == "fixed point" {
4455 entry.outcome = Settled::Fixed;
4456 entry.round = 2;
4457 }
4458 }
4459 let full = review_prompt("main", 42, "Retry a 429", &ledger, &[], 3, 3);
4460 assert!(!full.contains('{'), "{full}");
4461 assert!(full.contains("fixed point") && full.contains("refuted point"));
4462 assert!(full.contains("last round"), "{full}");
4463 }
4464
4465 #[test]
4466 fn a_resumed_open_finding_reaches_review_and_closing_prompts() {
4467 let open = vec![finding(
4468 "blocking",
4469 "Retry bypasses the limit",
4470 "reproduced with max_attempts set to one",
4471 "src/net.rs:88",
4472 true,
4473 )];
4474 let review = review_prompt("main", 42, "Retry a 429", &Ledger::new(), &open, 2, 3);
4475 let close = close_prompt(
4476 "main",
4477 42,
4478 "Retry a 429",
4479 "9f8e7d6",
4480 Some(&[]),
4481 &Ledger::new(),
4482 &open,
4483 2,
4484 );
4485 for prompt in [review, close] {
4486 assert!(prompt.contains("Retry bypasses the limit"), "{prompt}");
4487 assert!(prompt.contains("src/net.rs:88"), "{prompt}");
4488 assert!(
4489 prompt.contains("reproduced with max_attempts set to one"),
4490 "{prompt}"
4491 );
4492 assert!(!prompt.contains('{'), "{prompt}");
4493 }
4494 }
4495
4496 #[test]
4500 fn a_minor_defect_has_a_severity_that_is_not_blocking() {
4501 assert!(
4502 REVIEW_PROMPT.contains("A minor defect belongs\n here as much as an improvement does")
4503 );
4504 for text in [
4509 schema::review().to_string(),
4510 crate::review_only::review_only_prompt().to_string(),
4511 ] {
4512 assert!(text.contains("as much as an improvement does"), "{text}");
4513 assert!(!text.contains("a genuine improvement"), "{text}");
4514 }
4515 }
4516
4517 #[test]
4520 fn doubt_resolves_away_from_the_field_that_gates() {
4521 for text in [REVIEW_PROMPT.to_string(), schema::review().to_string()] {
4522 assert!(text.contains("say your piece in the finding and label it non-blocking"));
4523 assert!(!text.contains("leave in_scope true"));
4524 }
4525 }
4526
4527 #[test]
4530 fn both_edit_prompts_ask_for_the_smallest_change_that_answers_the_point() {
4531 for prompt in [FIX_PROMPT, RESPOND_PROMPT] {
4532 assert!(
4533 prompt.contains("The smallest change that answers it is"),
4534 "{prompt}"
4535 );
4536 }
4537 assert!(RESPOND_PROMPT.contains("bigger than the\n problem it names"));
4538 }
4539
4540 #[test]
4541 fn a_fixed_disposition_must_explain_what_changed() {
4542 let flat = RESPOND_PROMPT
4543 .split_whitespace()
4544 .collect::<Vec<_>>()
4545 .join(" ");
4546 assert!(
4547 flat.contains("For fixed, say what changed and how it answers the point"),
4548 "{RESPOND_PROMPT}"
4549 );
4550 }
4551
4552 #[test]
4553 fn an_empty_fix_reason_still_renders_as_a_claim_to_check() {
4554 let mut ledger = ledger_with("Unchecked error", "src/net.rs");
4555 for entry in ledger.values_mut() {
4556 entry.outcome = Settled::Fixed;
4557 entry.reasoning.clear();
4558 }
4559
4560 let lines = fixed_lines(&ledger, 0);
4561
4562 assert_eq!(1, lines.len());
4563 assert!(lines[0].contains("a committed change claims to address this point"));
4564 assert!(!lines[0].contains("The author said"));
4565 }
4566
4567 #[test]
4572 fn the_implementor_is_given_the_link_and_the_body() {
4573 let prompt = implement_prompt(
4574 42,
4575 "Retry a 429",
4576 "https://github.com/o/r/issues/42",
4577 "A rate limited response was treated as fatal.",
4578 );
4579 assert!(
4580 prompt.contains("https://github.com/o/r/issues/42"),
4581 "{prompt}"
4582 );
4583 assert!(
4584 prompt.contains("A rate limited response was treated as fatal."),
4585 "{prompt}"
4586 );
4587 assert!(prompt.contains("#42"), "{prompt}");
4588 assert!(prompt.contains("Retry a 429"), "{prompt}");
4589 assert!(!prompt.contains('{'), "{prompt}");
4591 }
4592
4593 #[test]
4596 fn the_prompt_says_the_discussion_is_not_included() {
4597 let prompt = implement_prompt(1, "t", "u", "b");
4598 let lower = prompt
4600 .split_whitespace()
4601 .collect::<Vec<_>>()
4602 .join(" ")
4603 .to_lowercase();
4604 assert!(
4605 lower.contains("discussion since is not included"),
4606 "{prompt}"
4607 );
4608 assert!(lower.contains("cannot reach the network"), "{prompt}");
4609 }
4610
4611 fn worked() -> Implementation {
4613 Implementation {
4614 summary: "Retry a 429 instead of failing the run.".into(),
4615 problem: "A rate limited response was treated as fatal, so one throttled call ended \
4616 a run that had hours of work left in it."
4617 .into(),
4618 changes: vec![
4619 "`send` retries a 429 with the delay the header asks for".into(),
4620 "the retry budget is bounded, so a permanent 429 still ends".into(),
4621 ],
4622 testing: vec![
4623 "`cargo test retries_a_429`".into(),
4624 "point it at a throttled endpoint and watch it finish".into(),
4625 ],
4626 ..Implementation::default()
4627 }
4628 }
4629
4630 #[test]
4631 fn a_pr_body_is_what_it_closes_and_what_changed() {
4634 let body = pr_body(42, &worked(), &style());
4635 assert_eq!(
4636 "Closes #42\n\n\
4637 Retry a 429 instead of failing the run.\n\n\
4638 A rate limited response was treated as fatal, so one throttled call \
4639 ended a run that had hours of work left in it.\n\n\
4640 ## What changed\n\n\
4641 - `send` retries a 429 with the delay the header asks for\n\
4642 - the retry budget is bounded, so a permanent 429 still ends\n\n\
4643 ## How to test\n\n\
4644 - `cargo test retries_a_429`\n\
4645 - point it at a throttled endpoint and watch it finish",
4646 body
4647 );
4648 }
4649
4650 #[test]
4653 fn a_body_with_nothing_to_list_carries_no_empty_headings() {
4654 let work = Implementation {
4655 summary: "Retry a 429 instead of failing the run.".into(),
4656 ..Implementation::default()
4657 };
4658 assert_eq!(
4659 "Closes #42\n\nRetry a 429 instead of failing the run.",
4660 pr_body(42, &work, &style())
4661 );
4662 }
4663
4664 #[test]
4665 fn a_pr_body_survives_an_implementor_that_said_nothing() {
4666 assert_eq!(
4667 "Closes #7",
4668 pr_body(7, &Implementation::default(), &style())
4669 );
4670 }
4671
4672 #[test]
4675 fn blank_list_entries_do_not_earn_a_heading() {
4676 let work = Implementation {
4677 summary: "Did a thing.".into(),
4678 changes: vec![String::new(), " ".into()],
4679 ..Implementation::default()
4680 };
4681 let body = pr_body(42, &work, &style());
4682 assert!(!body.contains("What changed"), "{body}");
4683 }
4684
4685 #[test]
4686 fn notes_appear_only_when_there_is_something_to_note() {
4687 let mut work = worked();
4688 assert!(!pr_body(42, &work, &style()).contains("## Notes"));
4689 work.notes = Some("The retry is not applied to streaming calls.".into());
4690 let body = pr_body(42, &work, &style());
4691 assert!(body.contains("## Notes"), "{body}");
4692 assert!(body.contains("streaming calls"), "{body}");
4693 }
4694
4695 #[test]
4698 fn declining_posts_the_reason_and_not_the_summary() {
4699 let work = Implementation {
4700 not_worth_doing: true,
4701 reason: "Already fixed in 1.2, and the report predates it.".into(),
4702 summary: "Nothing to do.".into(),
4703 ..Implementation::default()
4704 };
4705 assert_eq!(
4706 "Already fixed in 1.2, and the report predates it.",
4707 no_pr_note(&work, &style())
4708 );
4709 }
4710
4711 #[test]
4712 fn reporting_work_and_committing_none_says_that_rather_than_the_summary() {
4713 let work = Implementation {
4714 summary: "Retry a 429 instead of failing the run.".into(),
4715 ..Implementation::default()
4716 };
4717 let note = no_pr_note(&work, &style());
4718 assert_eq!(
4719 "Nothing was committed, so there is nothing to review.",
4720 note
4721 );
4722 }
4723
4724 #[test]
4725 fn declining_without_a_reason_still_says_something() {
4726 let work = Implementation {
4727 not_worth_doing: true,
4728 ..Implementation::default()
4729 };
4730 assert!(no_pr_note(&work, &style()).contains("no reason given"));
4731 }
4732
4733 #[test]
4734 fn a_skip_comment_is_only_the_reasoning() {
4735 let item = SkippedItem {
4736 issue: 3,
4737 title: "t".into(),
4738 tracker: false,
4739 reasons: [
4740 ("claude".to_string(), "Already fixed in 1.2.".to_string()),
4741 ("codex".to_string(), "Duplicate of #2.".to_string()),
4742 ]
4743 .into_iter()
4744 .collect(),
4745 };
4746 let text = skip_comment(&item, &style());
4747 assert!(text.contains("Already fixed in 1.2."), "{text}");
4748 assert!(text.contains("Duplicate of #2."), "{text}");
4749 assert!(
4750 !text.contains("claude") && !text.contains("codex"),
4751 "{text}"
4752 );
4753 assert!(!text.to_lowercase().contains("not scheduled"), "{text}");
4754 assert!(text.lines().count() <= 3, "{text}");
4755 }
4756
4757 #[test]
4758 fn findings_for_a_model_keep_full_detail() {
4759 let long = "x".repeat(2000);
4760 let text = findings_for_prompt(&[finding("blocking", "T", &long, "a.rs", true)]);
4761 assert!(
4762 text.contains(&long),
4763 "a model needs the whole finding, only humans need brevity"
4764 );
4765 }
4766
4767 #[test]
4768 fn findings_for_a_model_are_never_empty() {
4769 assert_eq!("(none)", findings_for_prompt(&[]));
4770 }
4771}
4772
4773#[cfg(test)]
4774mod outcome_tests {
4775 use super::*;
4776 use crate::model::{Dispute, Severity};
4777
4778 fn style() -> Style {
4779 Style::default()
4780 }
4781
4782 fn state_with(disputes: Vec<(&str, &str)>, filed: Vec<&str>) -> IssueRun {
4783 let mut s = IssueRun::new(482, "t");
4784 s.disputes = disputes
4785 .into_iter()
4786 .map(|(title, reasoning)| Dispute {
4787 title: title.into(),
4788 file: String::new(),
4789 reasoning: reasoning.into(),
4790 })
4791 .collect();
4792 s.filed = filed.into_iter().map(String::from).collect();
4793 s
4794 }
4795
4796 fn finding(title: &str, file: &str) -> Finding {
4797 Finding {
4798 severity: Severity::Blocking,
4799 title: title.into(),
4800 detail: "d".into(),
4801 file: file.into(),
4802 in_scope: true,
4803 ..Default::default()
4804 }
4805 }
4806
4807 fn graded(severity: Severity, title: &str, file: &str, in_scope: bool) -> Finding {
4808 Finding {
4809 severity,
4810 in_scope,
4811 ..finding(title, file)
4812 }
4813 }
4814
4815 #[test]
4816 fn outcome_mode_routes_final_results_to_the_configured_sink() {
4817 assert_eq!(OutcomeSink::PullRequest, outcome_sink(PrComments::Outcome));
4818 assert_eq!(OutcomeSink::PullRequest, outcome_sink(PrComments::Rounds));
4819 assert_eq!(OutcomeSink::Terminal, outcome_sink(PrComments::None));
4820 }
4821
4822 #[test]
4825 fn a_clean_approval_says_nothing() {
4826 let state = state_with(vec![], vec![]);
4827 assert!(outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).is_none());
4828 }
4829
4830 #[test]
4835 fn a_downgraded_finding_still_reaches_the_pull_request() {
4836 let mut state = state_with(vec![], vec![]);
4837 let kept = graded(
4838 Severity::NonBlocking,
4839 "Timeout is not configurable",
4840 "n.rs",
4841 true,
4842 );
4843 record_nonblocking_outcome(&mut state, &kept, None);
4844
4845 assert_eq!(1, state.noted.len());
4848
4849 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
4850 assert!(text.contains("Noted, not blocking"), "{text}");
4851 assert!(
4852 text.contains("Timeout is not configurable (n.rs)"),
4853 "{text}"
4854 );
4855 }
4856
4857 #[test]
4860 fn a_point_that_was_filed_is_not_also_noted() {
4861 let mut state = state_with(vec![], vec![]);
4862 let finding = graded(Severity::NonBlocking, "Timeout", "n.rs", true);
4863 record_nonblocking_outcome(&mut state, &finding, None);
4864 record_nonblocking_outcome(
4865 &mut state,
4866 &finding,
4867 Some(&Followup::Recorded("https://example.invalid/9".into())),
4868 );
4869 assert!(state.noted.is_empty());
4870 assert_eq!(vec!["https://example.invalid/9"], state.filed);
4871 }
4872
4873 #[test]
4874 fn filing_a_moved_point_removes_its_earlier_note() {
4875 let mut state = state_with(vec![], vec![]);
4876 let earlier = graded(Severity::NonBlocking, "Timeout", "src/net.rs:10", true);
4877 let moved = graded(Severity::NonBlocking, "Timeout", "src/net.rs:12", false);
4878 record_nonblocking_outcome(&mut state, &earlier, None);
4879
4880 record_nonblocking_outcome(
4881 &mut state,
4882 &moved,
4883 Some(&Followup::Recorded("https://example.invalid/10".into())),
4884 );
4885
4886 assert!(state.noted.is_empty());
4887 assert_eq!(vec!["https://example.invalid/10"], state.filed);
4888 }
4889
4890 #[test]
4891 fn an_unrecorded_nonblocking_followup_remains_noted() {
4892 let finding = graded(Severity::NonBlocking, "Timeout", "n.rs", true);
4893 for outcome in [
4894 Followup::Covered("https://example.invalid/closed".into()),
4895 Followup::Dropped("follow-ups are off"),
4896 Followup::Failed,
4897 ] {
4898 let mut state = state_with(vec![], vec![]);
4899 record_nonblocking_outcome(&mut state, &finding, Some(&outcome));
4900 assert_eq!(1, state.noted.len(), "{outcome:?}");
4901 assert!(state.filed.is_empty(), "{outcome:?}");
4902 }
4903 }
4904
4905 #[test]
4907 fn a_point_noted_twice_is_listed_once() {
4908 let mut state = state_with(vec![], vec![]);
4909 let raised = graded(
4910 Severity::NonBlocking,
4911 "Timeout is not configurable",
4912 "n.rs",
4913 true,
4914 );
4915 let reworded = graded(
4916 Severity::NonBlocking,
4917 "timeout is not configurable!",
4918 "n.rs",
4919 true,
4920 );
4921 record_nonblocking_outcome(&mut state, &raised, None);
4922 record_nonblocking_outcome(&mut state, &reworded, None);
4923 assert_eq!(1, state.noted.len());
4924 }
4925
4926 #[test]
4927 fn same_title_notes_in_different_files_are_both_kept() {
4928 let mut state = state_with(vec![], vec![]);
4929 for file in ["src/a.rs", "src/b.rs"] {
4930 let finding = graded(Severity::NonBlocking, "Unchecked error", file, true);
4931 record_nonblocking_outcome(&mut state, &finding, None);
4932 }
4933 assert_eq!(2, state.noted.len());
4934 }
4935
4936 #[test]
4937 fn same_title_notes_at_two_sites_in_one_review_are_both_kept() {
4938 let findings = vec![
4939 graded(
4940 Severity::NonBlocking,
4941 "Unchecked error",
4942 "src/a.rs:10",
4943 true,
4944 ),
4945 graded(
4946 Severity::NonBlocking,
4947 "Unchecked error",
4948 "src/a.rs:200",
4949 true,
4950 ),
4951 ];
4952 let mut state = state_with(vec![], vec![]);
4953
4954 for finding in &findings {
4955 record_nonblocking_outcome_with_match(
4956 &mut state,
4957 finding,
4958 None,
4959 unique_stable_finding(&findings, finding),
4960 );
4961 }
4962
4963 assert_eq!(2, state.noted.len());
4964 }
4965
4966 #[test]
4967 fn settling_one_of_two_same_title_notes_keeps_the_other() {
4968 let first = graded(
4969 Severity::NonBlocking,
4970 "Unchecked error",
4971 "src/a.rs:10",
4972 true,
4973 );
4974 let second = graded(
4975 Severity::NonBlocking,
4976 "Unchecked error",
4977 "src/a.rs:200",
4978 true,
4979 );
4980 let mut state = state_with(vec![], vec![]);
4981 remember_noted(&mut state, &first, false);
4982 remember_noted(&mut state, &second, false);
4983
4984 forget_noted(&mut state, &first, false);
4985
4986 assert_eq!(1, state.noted.len());
4987 assert_eq!("src/a.rs:200", state.noted[0].file);
4988 }
4989
4990 #[test]
4991 fn same_title_disputes_at_two_sites_are_both_kept() {
4992 let mut state = state_with(vec![], vec![]);
4993 for file in ["src/a.rs:10", "src/a.rs:200"] {
4994 remember_dispute(
4995 &mut state,
4996 Dispute {
4997 title: "Unchecked error".into(),
4998 file: file.into(),
4999 reasoning: "the caller handles it".into(),
5000 },
5001 false,
5002 );
5003 }
5004
5005 assert_eq!(2, state.disputes.len());
5006 }
5007
5008 #[test]
5009 fn a_later_nonblocking_verdict_replaces_a_prior_dispute() {
5010 let mut state = state_with(vec![], vec![]);
5011 let finding = graded(Severity::NonBlocking, "Unchecked error", "src/a.rs", true);
5012 remember_dispute(
5013 &mut state,
5014 Dispute {
5015 title: finding.title.clone(),
5016 file: finding.file.clone(),
5017 reasoning: "the caller handles it".into(),
5018 },
5019 true,
5020 );
5021
5022 record_nonblocking_outcome(&mut state, &finding, None);
5023
5024 assert!(state.disputes.is_empty());
5025 assert_eq!(1, state.noted.len());
5026 }
5027
5028 #[test]
5029 fn a_note_moving_lines_in_the_same_file_is_updated() {
5030 let mut state = state_with(vec![], vec![]);
5031 let first = graded(
5032 Severity::NonBlocking,
5033 "Unchecked error",
5034 "src/a.rs:12",
5035 true,
5036 );
5037 let moved = graded(
5038 Severity::NonBlocking,
5039 "Unchecked error",
5040 "src/a.rs:19",
5041 true,
5042 );
5043 record_nonblocking_outcome(&mut state, &first, None);
5044 record_nonblocking_outcome(&mut state, &moved, None);
5045 assert_eq!(1, state.noted.len());
5046 assert_eq!("src/a.rs:19", state.noted[0].file);
5047 }
5048
5049 #[test]
5050 fn a_settled_point_removes_its_stale_note() {
5051 for outcome in [Settled::Fixed, Settled::Refuted, Settled::Filed] {
5052 let noted = graded(Severity::NonBlocking, "Unchecked error", "src/a.rs", true);
5053 let mut state = state_with(vec![], vec![]);
5054 record_nonblocking_outcome(&mut state, ¬ed, None);
5055 forget_noted(&mut state, ¬ed, true);
5056 assert!(state.noted.is_empty(), "{outcome}");
5057 }
5058 }
5059
5060 #[test]
5061 fn settling_a_moved_point_removes_its_old_dispute() {
5062 let old = graded(Severity::Blocking, "Unchecked error", "src/a.rs:10", true);
5063 let moved = graded(Severity::Blocking, "Unchecked error", "src/a.rs:12", true);
5064 let mut state = state_with(vec![], vec![]);
5065 remember_dispute(
5066 &mut state,
5067 Dispute {
5068 title: old.title,
5069 file: old.file,
5070 reasoning: "the caller handles it".into(),
5071 },
5072 true,
5073 );
5074
5075 forget_dispute(&mut state, &moved, true);
5076
5077 assert!(state.disputes.is_empty());
5078 }
5079
5080 #[test]
5083 fn a_deadlocked_point_is_not_also_noted() {
5084 let mut state = state_with(vec![], vec![]);
5085 let noted = graded(Severity::NonBlocking, "Unbounded loop", "x.rs", true);
5086 record_nonblocking_outcome(&mut state, ¬ed, None);
5087 let points = vec![finding("Unbounded loop", "x.rs")];
5088 let text = outcome_comment(
5089 &state,
5090 &Ledger::new(),
5091 &Ending::Deadlocked(&points),
5092 &style(),
5093 )
5094 .unwrap();
5095 assert!(!text.contains("Noted, not blocking"), "{text}");
5096 }
5097
5098 #[test]
5099 fn clipping_a_rendered_title_does_not_break_duplicate_suppression() {
5100 let mut compact = style();
5101 compact.max_title_chars = 5;
5102 let mut state = state_with(
5103 vec![("abcdefghij", "the caller already handles it")],
5104 vec![],
5105 );
5106 state
5107 .noted
5108 .push(graded(Severity::NonBlocking, "abcdefghij", "x.rs", true));
5109 state.disputes[0].file = "x.rs".into();
5110 let points = vec![finding("abcdefghij", "x.rs")];
5111
5112 let text = outcome_comment(
5113 &state,
5114 &Ledger::new(),
5115 &Ending::Unresolved(&points),
5116 &compact,
5117 )
5118 .unwrap();
5119
5120 assert!(!text.contains("Raised and refuted"), "{text}");
5121 assert!(!text.contains("Noted, not blocking"), "{text}");
5122 }
5123
5124 #[test]
5125 fn an_approval_that_filed_follow_ups_links_them() {
5126 let state = state_with(
5127 vec![],
5128 vec![
5129 "https://github.com/you/thing/issues/485",
5130 "https://github.com/you/thing/issues/486",
5131 ],
5132 );
5133 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
5134 assert!(text.contains("Filed separately: #485, #486"), "{text}");
5135 }
5136
5137 #[test]
5141 fn a_run_that_changed_nothing_does_not_claim_there_is_something_to_read() {
5142 let state = state_with(vec![], vec![]);
5143 let text = outcome_comment(&state, &Ledger::new(), &Ending::Unchanged, &style()).unwrap();
5144 assert!(text.contains("changed nothing"), "{text}");
5145 assert!(!text.contains("was pushed"), "{text}");
5146 }
5147
5148 #[test]
5152 fn an_unresolved_close_names_what_is_still_wrong() {
5153 let state = state_with(vec![], vec![]);
5154 let left = vec![Finding {
5155 detail: "The guard sits after the early return.".into(),
5156 ..finding("The retry fix never reaches the 429 path", "src/net.rs:88")
5157 }];
5158 let text =
5159 outcome_comment(&state, &Ledger::new(), &Ending::Unresolved(&left), &style()).unwrap();
5160 assert!(text.contains("These points are still open"), "{text}");
5161 assert!(
5162 text.contains("The retry fix never reaches the 429 path (src/net.rs:88)"),
5163 "{text}"
5164 );
5165 assert!(
5166 text.contains("The guard sits after the early return."),
5167 "{text}"
5168 );
5169 assert!(!text.contains("has not been reviewed"), "{text}");
5172 }
5173
5174 #[test]
5178 fn running_out_of_rounds_says_what_that_means_for_the_reader() {
5179 let state = state_with(vec![], vec![]);
5180 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
5181 assert!(text.contains("has not been reviewed"), "{text}");
5182 assert!(
5183 !text.to_lowercase().contains("round 3"),
5184 "no round numbers: {text}"
5185 );
5186 assert!(!text.to_lowercase().contains("convergence"), "{text}");
5187 }
5188
5189 #[test]
5190 fn a_failed_close_reports_unread_fixes_and_carried_blockers() {
5191 let state = state_with(vec![], vec![]);
5192 let open = vec![Finding {
5193 detail: "the failure is still discarded".into(),
5194 ..finding("Unchecked error", "src/net.rs:88")
5195 }];
5196 let text = outcome_comment_with_unread(
5197 &state,
5198 &Ledger::new(),
5199 &Ending::OutOfRounds,
5200 &open,
5201 &style(),
5202 )
5203 .unwrap();
5204 assert!(text.contains("has not been reviewed"), "{text}");
5205 assert!(text.contains("These points were already open"), "{text}");
5206 assert!(text.contains("Unchecked error (src/net.rs:88)"), "{text}");
5207 }
5208
5209 #[test]
5210 fn a_deadlock_names_the_point_they_could_not_settle() {
5211 let state = state_with(vec![], vec![]);
5212 let points = [finding("Retry loop never terminates", "src/net.rs:88")];
5213 let text = outcome_comment(
5214 &state,
5215 &Ledger::new(),
5216 &Ending::Deadlocked(&points),
5217 &style(),
5218 )
5219 .unwrap();
5220 assert!(
5221 text.contains("Retry loop never terminates (src/net.rs:88)"),
5222 "{text}"
5223 );
5224 assert!(text.contains("could not settle"), "{text}");
5225 }
5226
5227 #[test]
5229 fn refutations_survive_because_nothing_else_carries_them() {
5230 let state = state_with(
5231 vec![(
5232 "Error is swallowed",
5233 "the caller already validates the file",
5234 )],
5235 vec![],
5236 );
5237 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
5238 assert!(text.contains("Raised and refuted:"), "{text}");
5239 assert!(
5240 text.contains("The caller already validates the file"),
5241 "{text}"
5242 );
5243 }
5244
5245 #[test]
5246 fn no_agent_names_counts_or_round_numbers_reach_the_thread() {
5247 let state = state_with(
5248 vec![("A point", "a reason")],
5249 vec!["https://github.com/you/thing/issues/485"],
5250 );
5251 let left = vec![finding("A point", "a.rs")];
5252 for ending in [
5253 Ending::Approved,
5254 Ending::OutOfRounds,
5255 Ending::Unresolved(&left),
5256 ] {
5257 let text = outcome_comment(&state, &Ledger::new(), &ending, &style()).unwrap();
5258 let lower = text.to_lowercase();
5259 for banned in ["claude", "codex", "blocking,", "nit,", " fixed."] {
5260 assert!(
5261 !lower.contains(banned),
5262 "{banned:?} leaked into the thread:\n{text}"
5263 );
5264 }
5265 for n in 1..9 {
5267 assert!(
5268 !lower.contains(&format!("round {n}")),
5269 "a round number leaked into the thread:\n{text}"
5270 );
5271 }
5272 }
5273 }
5274
5275 #[test]
5276 fn a_refutation_is_allowed_to_make_its_case() {
5279 let reasoning = "The caller validates against the schema first. \
5280 The discarded error is therefore unreachable in practice. ";
5281 let state = state_with(
5282 vec![("A point", &reasoning.repeat(6))],
5283 vec!["https://github.com/you/thing/issues/485"],
5284 );
5285 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
5286 assert!(
5287 !text.contains("..."),
5288 "nothing was cut mid thought:\n{text}"
5289 );
5290 assert!(text.len() < 4000, "{} chars", text.len());
5291 }
5292
5293 #[test]
5294 fn a_url_that_is_not_an_issue_link_is_left_alone() {
5295 assert_eq!(
5296 "#485",
5297 as_reference("https://github.com/you/thing/issues/485")
5298 );
5299 assert_eq!("note: something", as_reference("note: something"));
5300 }
5301}
5302
5303#[cfg(test)]
5304mod filed_reference_tests {
5305 use super::*;
5306
5307 #[test]
5308 fn an_issue_url_yields_its_number() {
5309 assert_eq!(
5310 Some(485),
5311 filed_issue_number("https://github.com/you/thing/issues/485")
5312 );
5313 }
5314
5315 #[test]
5318 fn a_local_note_yields_nothing() {
5319 assert_eq!(None, filed_issue_number("note: Retry is unbounded"));
5320 assert_eq!(None, filed_issue_number(""));
5321 assert_eq!(
5322 None,
5323 filed_issue_number("https://github.com/you/thing/issues/")
5324 );
5325 }
5326}
5327
5328#[cfg(test)]
5329mod followup_restraint_tests {
5330 use super::*;
5331 use crate::model::Severity;
5332
5333 fn cfg_with(followups: Followups, non_blocking: bool, nits: bool, cap: usize) -> Config {
5334 let mut cfg =
5335 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
5336 .unwrap();
5337 cfg.loop_cfg.followups = followups;
5338 cfg.loop_cfg.file_non_blocking = non_blocking;
5339 cfg.loop_cfg.file_nits = nits;
5340 cfg.loop_cfg.max_followups = cap;
5341 cfg
5342 }
5343
5344 fn finding(severity: Severity, title: &str, in_scope: bool) -> Finding {
5345 Finding {
5346 severity,
5347 title: title.into(),
5348 detail: "d".into(),
5349 file: "a.rs".into(),
5350 in_scope,
5351 ..Default::default()
5352 }
5353 }
5354
5355 #[test]
5359 fn a_non_blocking_finding_is_not_a_tracker_item_by_default() {
5360 let cfg = cfg_with(Followups::Issues, false, false, 5);
5361 assert!(!cfg.loop_cfg.file_non_blocking);
5362 assert!(!cfg.loop_cfg.file_nits);
5363 }
5364
5365 #[test]
5366 fn follow_ups_stay_off_the_tracker_by_default() {
5367 let cfg =
5368 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
5369 .unwrap();
5370 assert_eq!(
5371 Followups::Local,
5372 cfg.loop_cfg.followups,
5373 "the tracker is somebody's queue; the default must not write to it"
5374 );
5375 assert_eq!(5, cfg.loop_cfg.max_followups);
5376 }
5377
5378 #[test]
5380 fn only_out_of_scope_defects_qualify_at_the_defaults() {
5381 let cfg = cfg_with(Followups::Issues, false, false, 5);
5382 let qualifies = |f: &Finding| match f.severity {
5383 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking && f.in_scope,
5384 Severity::Nit => cfg.loop_cfg.file_nits && f.in_scope,
5385 Severity::Blocking => false,
5386 } || !f.in_scope;
5387
5388 assert!(qualifies(&finding(
5389 Severity::Blocking,
5390 "pre-existing",
5391 false
5392 )));
5393 assert!(!qualifies(&finding(
5394 Severity::NonBlocking,
5395 "improvement",
5396 true
5397 )));
5398 assert!(!qualifies(&finding(Severity::Nit, "taste", true)));
5399 assert!(!qualifies(&finding(
5400 Severity::Blocking,
5401 "fix it here",
5402 true
5403 )));
5404 }
5405
5406 #[test]
5407 fn opening_it_up_lets_non_blocking_findings_through_again() {
5408 let cfg = cfg_with(Followups::Issues, true, false, 5);
5409 assert!(cfg.loop_cfg.file_non_blocking);
5410 }
5411
5412 #[test]
5414 fn the_cap_is_a_real_backstop() {
5415 let cfg = cfg_with(Followups::Issues, false, false, 3);
5416 let mut state = IssueRun::new(1, "t");
5417 state.filed = (0..3).map(|n| format!("url{n}")).collect();
5418 assert!(state.filed.len() >= cfg.loop_cfg.max_followups);
5419 }
5420
5421 #[test]
5425 fn the_cap_bounds_what_one_run_can_spawn() {
5426 let cfg = cfg_with(Followups::Issues, false, false, 5);
5427 assert!(
5428 cfg.loop_cfg.max_followups <= 5,
5429 "a run that can file ten follow-ups is a branching process"
5430 );
5431 }
5432}
5433
5434#[cfg(test)]
5438mod followup_outcome_tests {
5439 use super::*;
5440
5441 const URL: &str = "https://github.com/you/thing/issues/485";
5442
5443 fn entry(recorded: Followup) -> Option<(Settled, String)> {
5444 filed_entry(&recorded, "It predates this branch.")
5445 }
5446
5447 #[test]
5451 fn a_failed_followup_settles_nothing() {
5452 assert_eq!(None, entry(Followup::Failed));
5453 }
5454
5455 #[test]
5456 fn an_uncertain_external_write_blocks_later_issue_followups_for_this_run() {
5457 let mut state = IssueRun::new(1, "review");
5458 let uncertain = SparError::uncertain_write("the result could not be verified");
5459 assert_eq!(Followup::Failed, failed_followup(&mut state, &uncertain));
5460 assert!(external_followup_write_paused(Followups::Issues, &state));
5461 assert!(!external_followup_write_paused(Followups::Local, &state));
5462 assert_eq!(1, state.notes.len());
5463
5464 assert_eq!(Followup::Failed, failed_followup(&mut state, &uncertain));
5465 assert_eq!(1, state.notes.len(), "the recovery note was duplicated");
5466
5467 let mut ordinary = IssueRun::new(2, "review");
5468 let error = SparError::new("permission denied");
5469 assert_eq!(Followup::Failed, failed_followup(&mut ordinary, &error));
5470 assert!(!external_followup_write_paused(
5471 Followups::Issues,
5472 &ordinary
5473 ));
5474 }
5475
5476 #[test]
5477 fn a_recorded_followup_is_filed_and_says_where() {
5478 let (outcome, reasoning) = entry(Followup::Recorded(URL.into())).unwrap();
5479 assert_eq!(Settled::Filed, outcome);
5480 assert!(
5481 reasoning.contains("It predates this branch."),
5482 "{reasoning}"
5483 );
5484 assert!(reasoning.contains("#485"), "{reasoning}");
5485 }
5486
5487 #[test]
5490 fn a_closed_issue_covering_the_point_settles_it_without_offering_work() {
5491 let recorded = Followup::from(Filed::AlreadyClosed(9, URL.into()));
5492 assert_eq!(Followup::Covered(URL.into()), recorded);
5493 assert_eq!(
5494 None,
5495 recorded.url(),
5496 "a closed issue is not work to pick up"
5497 );
5498
5499 let (outcome, reasoning) = entry(recorded).unwrap();
5500 assert_eq!(Settled::Filed, outcome);
5501 assert!(reasoning.contains("#485"), "{reasoning}");
5502 }
5503
5504 #[test]
5507 fn an_open_issue_that_already_covers_the_point_is_still_a_reference() {
5508 for filed in [
5509 Filed::Opened(9, URL.into()),
5510 Filed::AddedTo(9, URL.into()),
5511 Filed::Covered(9, URL.into()),
5512 ] {
5513 assert_eq!(Some(URL), Followup::from(filed).url());
5514 }
5515 }
5516
5517 #[test]
5521 fn a_dropped_followup_is_settled_but_never_reported_as_filed() {
5522 let (outcome, reasoning) = entry(Followup::Dropped("follow-ups are off")).unwrap();
5523 assert_eq!(Settled::Dropped, outcome);
5524 assert!(reasoning.contains("follow-ups are off"), "{reasoning}");
5525 assert!(reasoning.contains("Not filed"), "{reasoning}");
5526 }
5527
5528 fn ledger_of(outcome: Settled, reasoning: &str) -> Ledger {
5529 let mut ledger = Ledger::new();
5530 ledger.insert(
5531 finding_key("A pre-existing leak", "src/x.rs"),
5532 LedgerEntry {
5533 title: "A pre-existing leak".into(),
5534 file: "src/x.rs".into(),
5535 reasoning: reasoning.into(),
5536 round: 1,
5537 reraised: 0,
5538 outcome,
5539 },
5540 );
5541 ledger
5542 }
5543
5544 #[test]
5548 fn the_settled_block_tells_a_filed_point_from_a_dropped_one() {
5549 let filed = settled_block(&ledger_of(Settled::Filed, "Tracked in #9."));
5550 assert!(filed.contains("out of scope here, and filed"), "{filed}");
5551
5552 let dropped = settled_block(&ledger_of(Settled::Dropped, "Not filed anywhere: off."));
5553 assert!(
5554 dropped.contains("out of scope here, and not filed"),
5555 "{dropped}"
5556 );
5557 assert!(dropped.contains("A pre-existing leak"), "{dropped}");
5558 }
5559
5560 #[test]
5563 fn a_deadlocked_point_that_was_never_filed_does_not_claim_to_be() {
5564 let points = [Finding {
5565 severity: Severity::Blocking,
5566 title: "A pre-existing leak".into(),
5567 detail: "d".into(),
5568 file: "src/x.rs".into(),
5569 in_scope: false,
5570 ..Default::default()
5571 }];
5572 let text = outcome_comment(
5573 &IssueRun::new(1, "t"),
5574 &ledger_of(Settled::Dropped, "Not filed anywhere: follow-ups are off."),
5575 &Ending::Deadlocked(&points),
5576 &Style::default(),
5577 )
5578 .unwrap();
5579 assert!(text.contains("not filed"), "{text}");
5580 assert!(!text.contains("Filed as out of scope"), "{text}");
5581 }
5582}
5583
5584#[cfg(test)]
5585mod issue_report_tests {
5586 use super::*;
5587 use crate::model::Severity;
5588
5589 fn reported() -> Finding {
5592 Finding {
5593 severity: Severity::Blocking,
5594 title: "sendPaymentAsync bypasses drain mode and spending limits".into(),
5595 detail: "The async path skips every admission check payInvoice applies.".into(),
5596 file: "src/node.ts:412".into(),
5597 in_scope: false,
5598 problem: Some(
5599 "`BeignetNode.sendPaymentAsync()` submits a payment directly to the Lightning \
5600 engine without applying the safeguards used by `payInvoice()`.\n\nThe async path \
5601 does not:\n\n- call `_checkDraining()`\n- call `_checkSpendLimit()`"
5602 .into(),
5603 ),
5604 reproduction: Some(
5605 "1. Create a `BeignetNode` with `dailySpendLimitSats: 1`.\n2. Enable drain mode.\n\
5606 3. Submit a 1,000 sat invoice.\n\nActual result:\n\n- The engine is called.\n\
5607 - `spentSats` remains 0."
5608 .into(),
5609 ),
5610 impact: Some(
5611 "An authorized client can submit async payments up to the available outbound \
5612 liquidity despite the configured limits."
5613 .into(),
5614 ),
5615 expected: Some(
5616 "- Reject new payments while draining.\n- Enforce the per-payment limit before \
5617 submission.\n- Cover both paths with regression tests.\n\nThis predates the \
5618 current branch."
5619 .into(),
5620 ),
5621 }
5622 }
5623
5624 #[test]
5625 fn a_reported_finding_becomes_a_bug_report() {
5626 let body = issue_report(&reported());
5627 for heading in [
5628 "## Problem",
5629 "## Reproduction",
5630 "## Impact",
5631 "## Expected behavior",
5632 ] {
5633 assert!(body.contains(heading), "missing {heading}:\n{body}");
5634 }
5635 let at = |h: &str| body.find(h).unwrap();
5637 assert!(at("## Problem") < at("## Reproduction"));
5638 assert!(at("## Reproduction") < at("## Impact"));
5639 assert!(at("## Impact") < at("## Expected behavior"));
5640 }
5641
5642 #[test]
5643 fn the_substance_survives_the_outbound_gates() {
5644 let repo_style = Style::default();
5645 let body = crate::style::issue_body(&issue_report(&reported()), &repo_style);
5646 for kept in [
5647 "_checkDraining()",
5648 "Actual result:",
5649 "outbound liquidity",
5650 "regression tests",
5651 "predates the current branch",
5652 ] {
5653 assert!(body.contains(kept), "the gate ate {kept:?}:\n{body}");
5654 }
5655 assert!(!body.contains("..."), "something was cut:\n{body}");
5656 }
5657
5658 #[test]
5661 fn an_ordinary_finding_is_still_just_its_detail() {
5662 let plain = Finding {
5663 severity: Severity::NonBlocking,
5664 title: "Name is vague".into(),
5665 detail: "The variable could say what it holds.".into(),
5666 file: "a.rs".into(),
5667 in_scope: true,
5668 ..Default::default()
5669 };
5670 assert_eq!(
5671 "The variable could say what it holds.",
5672 issue_report(&plain)
5673 );
5674 }
5675
5676 #[test]
5679 fn only_the_sections_that_were_written_appear() {
5680 let partial = Finding {
5681 problem: Some("The guard is inverted.".into()),
5682 expected: Some("It should reject rather than accept.".into()),
5683 ..reported()
5684 };
5685 let partial = Finding {
5686 reproduction: None,
5687 impact: None,
5688 ..partial
5689 };
5690 let body = issue_report(&partial);
5691 assert!(body.contains("## Problem") && body.contains("## Expected behavior"));
5692 assert!(!body.contains("## Reproduction"), "{body}");
5693 assert!(!body.contains("## Impact"), "{body}");
5694 }
5695
5696 #[test]
5699 fn the_summary_line_is_not_printed_twice() {
5700 let echoed = Finding {
5701 detail: "The guard is inverted so it rejects valid input.".into(),
5702 problem: Some("The guard is inverted so it rejects valid input.".into()),
5703 reproduction: None,
5704 impact: None,
5705 expected: None,
5706 ..reported()
5707 };
5708 let body = issue_report(&echoed);
5709 assert_eq!(1, body.matches("The guard is inverted").count(), "{body}");
5710 }
5711}