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