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