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 crate::jsonx::untagged_title(text)
2265 .to_lowercase()
2266 .chars()
2267 .filter(|c| c.is_ascii_alphanumeric() || c.is_whitespace())
2268 .collect::<String>()
2269 .split_whitespace()
2270 .collect::<Vec<_>>()
2271 .join(" ")
2272}
2273
2274pub(crate) fn same_point(a: &str, b: &str) -> bool {
2276 normalise(a) == normalise(b)
2277}
2278
2279pub(crate) fn same_finding(a: &Finding, b: &Finding) -> bool {
2280 same_finding_parts(&a.title, &a.file, &b.title, &b.file)
2281}
2282
2283pub(crate) fn same_finding_parts(a_title: &str, a_file: &str, b_title: &str, b_file: &str) -> bool {
2284 finding_key(a_title, a_file) == finding_key(b_title, b_file)
2285}
2286
2287fn disposition_matches(finding: &Finding, disposition: &Disposition) -> bool {
2288 same_point(&finding.title, &disposition.title) && finding.file.trim() == disposition.file.trim()
2289}
2290
2291fn stable_disposition_matches(finding: &Finding, disposition: &Disposition) -> bool {
2292 stable_finding_key(&finding.title, &finding.file)
2293 == stable_finding_key(&disposition.title, &disposition.file)
2294}
2295
2296fn sole_disposition_match<'a>(
2297 finding: &Finding,
2298 dispositions: &'a [Disposition],
2299 matches: impl Fn(&Finding, &Disposition) -> bool,
2300) -> std::result::Result<Option<(usize, &'a Disposition)>, &'static str> {
2301 let mut matched = dispositions
2302 .iter()
2303 .enumerate()
2304 .filter(|(_, disposition)| matches(finding, disposition));
2305 let first = matched.next();
2306 if matched.next().is_some() {
2307 return Err("more than one matching disposition");
2308 }
2309 Ok(first)
2310}
2311
2312fn matching_disposition<'a>(
2317 finding: &Finding,
2318 findings: &[Finding],
2319 dispositions: &'a [Disposition],
2320) -> std::result::Result<(usize, &'a Disposition), &'static str> {
2321 if let Some(exact) = sole_disposition_match(finding, dispositions, disposition_matches)? {
2322 return Ok(exact);
2323 }
2324 if unique_stable_finding(findings, finding) {
2325 if let Some(stable) =
2326 sole_disposition_match(finding, dispositions, stable_disposition_matches)?
2327 {
2328 return Ok(stable);
2329 }
2330 }
2331 Err("no matching disposition")
2332}
2333
2334fn fixed_disposition_resolves(committed: bool) -> bool {
2335 committed
2336}
2337
2338#[allow(clippy::too_many_arguments)]
2339fn apply_dispositions(
2340 repo: &Repo,
2341 cfg: &Config,
2342 response: &ResponseDoc,
2343 blocking: &[Finding],
2344 ledger: &mut Ledger,
2345 state: &mut IssueRun,
2346 round: u32,
2347 subject: i64,
2348 pr_number: i64,
2349 author: &str,
2350 committed: bool,
2351) -> Vec<Finding> {
2352 let mut fixed = Vec::new();
2353 let mut refuted = Vec::new();
2354 let mut filed = Vec::new();
2355 let mut unresolved = Vec::new();
2356 let mut used = vec![false; response.dispositions.len()];
2357
2358 for source in blocking {
2359 let (index, d) = match matching_disposition(source, blocking, &response.dispositions) {
2360 Ok((index, disposition)) if !used[index] => (index, disposition),
2361 Ok(_) => {
2362 logwarn!(
2363 "'{}' has more than one matching disposition, so it stays open",
2364 source.title
2365 );
2366 unresolved.push(source.clone());
2367 continue;
2368 }
2369 Err(reason) => {
2370 logwarn!("'{}' has {reason}, so it stays open", source.title);
2371 unresolved.push(source.clone());
2372 continue;
2373 }
2374 };
2375 used[index] = true;
2376 let file = source.file.clone();
2377 let canonical = source.title.as_str();
2381 let title = style::title(canonical, &repo.style);
2382 let located_title = match file.trim() {
2383 "" => title.clone(),
2384 location => format!("{title} ({location})"),
2385 };
2386 let allow_stable_fallback = unique_stable_finding(blocking, source);
2387
2388 match d.action {
2389 Action::Refuted => {
2390 let reasoning = style::summary(&d.reasoning, &repo.style);
2391 settle(
2392 ledger,
2393 canonical,
2394 &file,
2395 allow_stable_fallback,
2396 LedgerEntry {
2397 title: canonical.to_string(),
2398 file: file.clone(),
2399 reasoning: reasoning.clone(),
2400 round,
2401 reraised: 0,
2402 outcome: Settled::Refuted,
2403 },
2404 );
2405 remember_dispute(
2406 state,
2407 Dispute {
2408 title: canonical.to_string(),
2409 file: file.clone(),
2410 reasoning: reasoning.clone(),
2411 },
2412 allow_stable_fallback,
2413 );
2414 forget_noted(state, source, allow_stable_fallback);
2415 refuted.push(format!("{located_title}. {reasoning}"));
2416 }
2417 Action::FiledIssue => {
2418 let new_title = d
2419 .new_issue_title
2420 .clone()
2421 .filter(|t| !t.trim().is_empty())
2422 .unwrap_or_else(|| d.title.clone());
2423 let new_body = d
2424 .new_issue_body
2425 .clone()
2426 .filter(|b| !b.trim().is_empty())
2427 .unwrap_or_else(|| d.reasoning.clone());
2428 let recorded = file_followup(repo, &new_title, &new_body, subject, cfg, state);
2429 if let Some(url) = recorded.url() {
2430 state.filed.push(url.to_string());
2431 filed.push(url.to_string());
2432 }
2433 let Some((outcome, reasoning)) =
2442 filed_entry(&recorded, &style::summary(&d.reasoning, &repo.style))
2443 else {
2444 logwarn!(
2445 "'{title}' was not recorded anywhere, so it stays open for the next round"
2446 );
2447 unresolved.push(source.clone());
2448 continue;
2449 };
2450 settle(
2451 ledger,
2452 canonical,
2453 &file,
2454 allow_stable_fallback,
2455 LedgerEntry {
2456 title: canonical.to_string(),
2457 file: file.clone(),
2458 reasoning,
2459 round,
2460 reraised: 0,
2461 outcome,
2462 },
2463 );
2464 if outcome == Settled::Dropped {
2465 remember_noted(state, source, allow_stable_fallback);
2466 } else {
2467 forget_noted(state, source, allow_stable_fallback);
2468 }
2469 forget_dispute(state, source, allow_stable_fallback);
2470 }
2471 Action::Fixed => {
2472 if fixed_disposition_resolves(committed) {
2487 settle(
2488 ledger,
2489 canonical,
2490 &file,
2491 allow_stable_fallback,
2492 LedgerEntry {
2493 title: canonical.to_string(),
2494 file: file.clone(),
2495 reasoning: style::summary(&d.reasoning, &repo.style),
2496 round,
2497 reraised: 0,
2498 outcome: Settled::Fixed,
2499 },
2500 );
2501 forget_noted(state, source, allow_stable_fallback);
2502 forget_dispute(state, source, allow_stable_fallback);
2503 fixed.push(located_title);
2504 } else {
2505 unresolved.push(source.clone());
2506 }
2507 }
2508 }
2509 }
2510
2511 for (index, disposition) in response.dispositions.iter().enumerate() {
2512 if !used[index] {
2513 logwarn!(
2514 "ignoring an unmatched or duplicate disposition for '{}' ({})",
2515 disposition.title,
2516 disposition.file
2517 );
2518 }
2519 }
2520
2521 if repo.style.pr_comments == PrComments::Rounds {
2522 let comment = disposition_comment(author, response, &fixed, &refuted, &filed, &repo.style);
2523 if let Some(text) = comment {
2524 if let Err(e) = repo.comment_pr(pr_number, &text) {
2525 logdim!("could not post the disposition comment: {e}");
2526 }
2527 }
2528 }
2529 unresolved
2530}
2531
2532fn filed_entry(recorded: &Followup, reasoning: &str) -> Option<(Settled, String)> {
2540 let (outcome, tail) = match recorded {
2541 Followup::Recorded(reference) => (
2542 Settled::Filed,
2543 format!("Tracked in {}.", as_reference(reference)),
2544 ),
2545 Followup::Covered(reference) => (
2546 Settled::Filed,
2547 format!("Already covered by {}.", as_reference(reference)),
2548 ),
2549 Followup::Dropped(why) => (Settled::Dropped, format!("Not filed anywhere: {why}.")),
2550 Followup::Failed => return None,
2551 };
2552 let reasoning = match reasoning.trim() {
2553 "" => tail,
2554 said => format!("{said} {tail}"),
2555 };
2556 Some((outcome, reasoning))
2557}
2558
2559fn external_followup_write_paused(destination: Followups, state: &IssueRun) -> bool {
2566 destination == Followups::Issues && state.followup_writes_uncertain
2567}
2568
2569fn failed_followup(state: &mut IssueRun, error: &SparError) -> Followup {
2570 if error.kind() == ErrorKind::UncertainWrite {
2571 state.followup_writes_uncertain = true;
2572 if !state
2573 .notes
2574 .iter()
2575 .any(|note| note.contains("external follow-up writes were paused"))
2576 {
2577 state.notes.push(
2578 "An external follow-up write could not be verified, so further external \
2579 follow-up writes were paused for this run. Inspect recent issues and comments \
2580 before trying them again."
2581 .to_string(),
2582 );
2583 }
2584 }
2585 Followup::Failed
2586}
2587
2588pub fn file_followup(
2599 repo: &Repo,
2600 title: &str,
2601 body: &str,
2602 source: i64,
2603 cfg: &Config,
2604 state: &mut IssueRun,
2605) -> Followup {
2606 if repo.followups == Followups::None {
2607 return Followup::Dropped("follow-ups are off for this repository");
2608 }
2609 if external_followup_write_paused(repo.followups, state) {
2610 logdim!(
2611 "not attempting another external follow-up write after an earlier result could not \
2612 be verified"
2613 );
2614 return Followup::Failed;
2615 }
2616 if state.filed.len() >= cfg.loop_cfg.max_followups {
2619 logwarn!(
2620 "already recorded {} follow-ups, not recording '{}'. Raise max_followups if you want \
2621 them all.",
2622 state.filed.len(),
2623 style::title(title, &repo.style)
2624 );
2625 return Followup::Dropped("this run had already recorded as many follow-ups as it may");
2626 }
2627 let title = match repo.clean_followup_title(title) {
2634 Ok(title) => title,
2635 Err(e) => {
2636 logdim!("could not clean a follow-up title: {e}");
2637 return Followup::Failed;
2638 }
2639 };
2640 if title.trim().is_empty() {
2641 logdim!("nothing left of a follow-up title after cleaning it");
2642 return Followup::Failed;
2643 }
2644 let body = format!(
2647 "{}\n\nFound while working on #{source}.",
2648 style::issue_body(body, &repo.style)
2649 );
2650
2651 if repo.followups == Followups::Local {
2652 return repo.append_local_followup(&title, &body);
2653 }
2654
2655 match file_as_issue(repo, &title, &body) {
2656 Ok(filed) => filed.into(),
2657 Err(e) => {
2658 logdim!("could not file a follow-up for '{title}': {e}");
2659 failed_followup(state, &e)
2660 }
2661 }
2662}
2663
2664#[derive(Debug, Clone)]
2666pub enum Filed {
2667 Opened(i64, String),
2669 AddedTo(i64, String),
2671 Covered(i64, String),
2673 AlreadyClosed(i64, String),
2675}
2676
2677impl From<Filed> for Followup {
2678 fn from(filed: Filed) -> Self {
2679 match filed {
2680 Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => {
2681 Followup::Recorded(url)
2682 }
2683 Filed::AlreadyClosed(_, url) => Followup::Covered(url),
2687 }
2688 }
2689}
2690
2691impl Filed {
2692 pub fn url(&self) -> Option<&str> {
2693 match self {
2694 Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => Some(url),
2695 Filed::AlreadyClosed(_, _) => None,
2698 }
2699 }
2700
2701 pub fn issue(&self) -> i64 {
2704 match self {
2705 Filed::Opened(n, _)
2706 | Filed::AddedTo(n, _)
2707 | Filed::Covered(n, _)
2708 | Filed::AlreadyClosed(n, _) => *n,
2709 }
2710 }
2711
2712 pub fn number(&self) -> Option<i64> {
2714 match self {
2715 Filed::Opened(n, _) | Filed::AddedTo(n, _) | Filed::Covered(n, _) => Some(*n),
2716 Filed::AlreadyClosed(_, _) => None,
2717 }
2718 }
2719
2720 pub fn note(&self) -> String {
2722 match self {
2723 Filed::Opened(n, _) => format!("#{n}"),
2724 Filed::AddedTo(n, _) => format!("added to #{n}"),
2725 Filed::Covered(n, _) => format!("#{n} already says this"),
2726 Filed::AlreadyClosed(n, _) => format!("#{n} covers it and is closed"),
2727 }
2728 }
2729
2730 pub fn describe(&self, title: &str) -> String {
2731 let title = style::clip(title.trim(), 80);
2732 match self {
2733 Filed::Opened(n, _) => format!("filed #{n}: {title}"),
2734 Filed::AddedTo(n, _) => format!("added to #{n}: {title}"),
2735 Filed::Covered(n, _) => format!("#{n} already says this: {title}"),
2736 Filed::AlreadyClosed(n, _) => format!("#{n} covers it and is closed: {title}"),
2737 }
2738 }
2739}
2740
2741pub fn file_as_issue(repo: &Repo, title: &str, body: &str) -> Result<Filed> {
2753 file_as_issue_apart_from(repo, title, body, None)
2754}
2755
2756pub fn file_as_issue_apart_from(
2762 repo: &Repo,
2763 title: &str,
2764 body: &str,
2765 apart_from: Option<i64>,
2766) -> Result<Filed> {
2767 let title = repo.record_failed_write(repo.clean_title(title))?;
2768 if title.trim().is_empty() {
2769 return repo.record_failed_write(Err(spar_err!(
2770 "nothing left of the title after cleaning it"
2771 )));
2772 }
2773 let issue_body = repo.record_failed_write(repo.clean_issue_body(body))?;
2774 let exact =
2775 repo.record_failed_write(repo.try_exact_issue_apart_from(&title, &issue_body, apart_from))?;
2776 if let Some(existing) = exact {
2777 return Ok(if existing.open {
2778 Filed::Covered(existing.number, existing.url)
2779 } else {
2780 Filed::AlreadyClosed(existing.number, existing.url)
2781 });
2782 }
2783 let similar = repo.record_failed_write(repo.try_find_similar_issue_apart_from(
2784 &title,
2785 &issue_body,
2786 apart_from,
2787 ))?;
2788 if let Some(existing) = similar {
2789 let known = format!("{} {}", existing.title, existing.body);
2790 if !existing.open {
2791 return Ok(Filed::AlreadyClosed(existing.number, existing.url));
2792 }
2793 if crate::textsim::adds_information(&issue_body, &known) {
2794 repo.comment_issue(existing.number, &issue_body)?;
2795 return Ok(Filed::AddedTo(existing.number, existing.url));
2796 }
2797 return Ok(Filed::Covered(existing.number, existing.url));
2798 }
2799 let url = repo.create_issue_apart_from(&title, &issue_body, apart_from)?;
2800 let number = filed_issue_number(&url)
2801 .ok_or_else(|| spar_err!("filed an issue but could not read its number from {url}"))?;
2802 Ok(Filed::Opened(number, url))
2803}
2804
2805fn file_out_of_scope(
2806 repo: &Repo,
2807 findings: &[Finding],
2808 subject: i64,
2809 state: &mut IssueRun,
2810 cfg: &Config,
2811) {
2812 for finding in findings.iter().filter(|f| !f.in_scope) {
2813 let body = issue_report(finding);
2814 let recorded = file_followup(repo, &finding.title, &body, subject, cfg, state);
2815 if finding.severity != Severity::Nit || matches!(recorded, Followup::Recorded(_)) {
2816 record_nonblocking_outcome_with_match(
2817 state,
2818 finding,
2819 Some(&recorded),
2820 unique_stable_finding(findings, finding),
2821 );
2822 }
2823 }
2824}
2825
2826pub fn issue_report(finding: &Finding) -> String {
2833 let sections = finding.report_sections();
2834 if sections.is_empty() {
2835 return finding.detail.clone();
2836 }
2837 let mut out: Vec<String> = sections
2838 .iter()
2839 .map(|(heading, text)| format!("## {heading}\n\n{text}"))
2840 .collect();
2841 if !finding.detail.trim().is_empty()
2844 && !sections
2845 .iter()
2846 .any(|(_, text)| crate::textsim::same_point(text, &finding.detail))
2847 {
2848 out.insert(0, finding.detail.trim().to_string());
2849 }
2850 out.join("\n\n")
2851}
2852
2853fn file_nonblocking(
2860 repo: &Repo,
2861 findings: &[Finding],
2862 subject: i64,
2863 state: &mut IssueRun,
2864 cfg: &Config,
2865) {
2866 for finding in findings {
2867 if !finding.in_scope || finding.severity == Severity::Blocking {
2868 continue;
2869 }
2870 let should_file = match finding.severity {
2871 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking,
2872 Severity::Nit => cfg.loop_cfg.file_nits,
2873 Severity::Blocking => false,
2874 };
2875 if !should_file {
2876 if finding.severity == Severity::NonBlocking {
2877 record_nonblocking_outcome_with_match(
2878 state,
2879 finding,
2880 None,
2881 unique_stable_finding(findings, finding),
2882 );
2883 }
2884 continue;
2885 }
2886 let recorded = file_followup(repo, &finding.title, &finding.detail, subject, cfg, state);
2887 if finding.severity == Severity::NonBlocking {
2888 record_nonblocking_outcome_with_match(
2889 state,
2890 finding,
2891 Some(&recorded),
2892 unique_stable_finding(findings, finding),
2893 );
2894 } else if let Some(url) = recorded.url() {
2895 state.filed.push(url.to_string());
2896 }
2897 }
2898}
2899
2900fn bullets(lines: &[String]) -> String {
2910 lines
2911 .iter()
2912 .map(|l| format!("- {l}"))
2913 .collect::<Vec<_>>()
2914 .join("\n")
2915}
2916
2917fn located(finding: &Finding, style: &Style) -> String {
2918 let title = style::title(&finding.title, style);
2919 match finding.where_at() {
2920 "general" => title,
2921 file => format!("{title} ({file})"),
2922 }
2923}
2924
2925pub enum Ending<'a> {
2927 Approved,
2929 OutOfRounds,
2932 Unchanged,
2935 Unresolved(&'a [Finding]),
2938 Deadlocked(&'a [Finding]),
2941}
2942
2943#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2944enum OutcomeSink {
2945 PullRequest,
2946 Terminal,
2947}
2948
2949fn outcome_sink(mode: PrComments) -> OutcomeSink {
2950 match mode {
2951 PrComments::Outcome | PrComments::Rounds => OutcomeSink::PullRequest,
2952 PrComments::None => OutcomeSink::Terminal,
2953 }
2954}
2955
2956fn emit_outcome(repo: &Repo, pr_number: i64, text: &str) {
2957 if outcome_sink(repo.style.pr_comments) == OutcomeSink::Terminal {
2958 println!("\n{text}\n");
2959 return;
2960 }
2961 if let Err(e) = repo.comment_pr(pr_number, text) {
2962 logdim!("could not post the outcome comment: {e}");
2963 println!("\n{text}\n");
2964 }
2965}
2966
2967pub fn post_outcome(
2979 repo: &Repo,
2980 pr_number: i64,
2981 state: &IssueRun,
2982 ledger: &Ledger,
2983 ending: Ending<'_>,
2984) {
2985 let Some(text) = outcome_comment(state, ledger, &ending, &repo.style) else {
2986 return;
2987 };
2988 emit_outcome(repo, pr_number, &text);
2989}
2990
2991fn post_unread_outcome(
2992 repo: &Repo,
2993 pr_number: i64,
2994 state: &IssueRun,
2995 ledger: &Ledger,
2996 open_findings: &[Finding],
2997) {
2998 let Some(text) = outcome_comment_with_unread(
2999 state,
3000 ledger,
3001 &Ending::OutOfRounds,
3002 open_findings,
3003 &repo.style,
3004 ) else {
3005 return;
3006 };
3007 emit_outcome(repo, pr_number, &text);
3008}
3009
3010fn settled_as(finding: &Finding, state: &IssueRun, ledger: &Ledger) -> Option<(Settled, String)> {
3013 if let Some(d) = state
3014 .disputes
3015 .iter()
3016 .find(|d| same_finding_parts(&d.title, &d.file, &finding.title, &finding.file))
3017 {
3018 if !d.reasoning.trim().is_empty() {
3019 return Some((Settled::Refuted, d.reasoning.clone()));
3020 }
3021 }
3022 matching_ledger_entry(ledger, &finding.title, &finding.file)
3023 .filter(|entry| !entry.reasoning.trim().is_empty())
3024 .map(|entry| (entry.outcome, entry.reasoning.clone()))
3025}
3026
3027pub fn filed_issue_number(filed: &str) -> Option<i64> {
3032 filed
3033 .rsplit('/')
3034 .next()
3035 .and_then(|tail| tail.parse::<i64>().ok())
3036 .filter(|n| *n > 0)
3037}
3038
3039fn as_reference(url: &str) -> String {
3040 match url.rsplit('/').next().and_then(|n| n.parse::<u64>().ok()) {
3041 Some(number) => format!("#{number}"),
3042 None => url.to_string(),
3043 }
3044}
3045
3046pub fn outcome_comment(
3047 state: &IssueRun,
3048 ledger: &Ledger,
3049 ending: &Ending<'_>,
3050 style: &Style,
3051) -> Option<String> {
3052 outcome_comment_with_unread(state, ledger, ending, &[], style)
3053}
3054
3055fn outcome_comment_with_unread(
3056 state: &IssueRun,
3057 ledger: &Ledger,
3058 ending: &Ending<'_>,
3059 unread_open: &[Finding],
3060 style: &Style,
3061) -> Option<String> {
3062 let mut out: Vec<String> = Vec::new();
3063 let mut already: Vec<(String, String)> = Vec::new();
3066
3067 match ending {
3068 Ending::Approved => {
3069 if state.disputes.is_empty() && state.filed.is_empty() && state.noted.is_empty() {
3070 return None;
3076 }
3077 out.push("Reviewed, nothing blocking a merge.".into());
3078 }
3079 Ending::OutOfRounds => {
3080 out.push(
3081 "Not signed off: the last round of fixes was pushed but has not been reviewed."
3082 .into(),
3083 );
3084 if !unread_open.is_empty() {
3085 let lines: Vec<String> = unread_open
3086 .iter()
3087 .map(|finding| {
3088 already.push((finding.title.clone(), finding.file.clone()));
3089 format!(
3090 "{}. {}",
3091 located(finding, style),
3092 style::sentence(&finding.detail, style)
3093 )
3094 })
3095 .collect();
3096 out.push("These points were already open:".into());
3097 out.push(bullets(&lines));
3098 }
3099 }
3100 Ending::Unchanged => out.push(
3104 "Not signed off: the last round changed nothing, so the branch is the one that was \
3105 already reviewed."
3106 .into(),
3107 ),
3108 Ending::Unresolved(points) => {
3109 let lines: Vec<String> = points
3110 .iter()
3111 .map(|f| {
3112 already.push((f.title.clone(), f.file.clone()));
3113 format!(
3114 "{}. {}",
3115 located(f, style),
3116 style::sentence(&f.detail, style)
3117 )
3118 })
3119 .collect();
3120 out.push("Not signed off. These points are still open:".into());
3121 out.push(bullets(&lines));
3122 }
3123 Ending::Deadlocked(points) => {
3124 let lines: Vec<String> = points
3130 .iter()
3131 .map(|f| {
3132 let where_at = match f.where_at() {
3133 "general" => String::new(),
3134 file => format!(" ({file})"),
3135 };
3136 let title = style::title(&f.title, style);
3137 already.push((f.title.clone(), f.file.clone()));
3138 match settled_as(f, state, ledger) {
3139 Some((Settled::Refuted, reason)) => format!(
3140 "{title}{where_at}. Refuted as: {}",
3141 style::summary(&reason, style)
3142 ),
3143 Some((Settled::Filed, reason)) => format!(
3144 "{title}{where_at}. Filed as out of scope: {}",
3145 style::summary(&reason, style)
3146 ),
3147 Some((Settled::Dropped, reason)) => format!(
3150 "{title}{where_at}. Out of scope here, and not filed: {}",
3151 style::summary(&reason, style)
3152 ),
3153 Some((Settled::Fixed, reason)) => format!(
3159 "{title}{where_at}. Fixed and raised again. Recorded answer: {}",
3160 style::summary(&reason, style)
3161 ),
3162 None => format!("{title}{where_at}"),
3163 }
3164 })
3165 .collect();
3166 out.push("Needs your decision. The reviewers could not settle this:".into());
3167 out.push(bullets(&lines));
3168 }
3169 }
3170
3171 let disputes: Vec<&crate::model::Dispute> = state
3172 .disputes
3173 .iter()
3174 .filter(|d| {
3175 !already
3176 .iter()
3177 .any(|(title, file)| same_finding_parts(title, file, &d.title, &d.file))
3178 })
3179 .collect();
3180 if !disputes.is_empty() {
3181 let lines: Vec<String> = disputes
3184 .iter()
3185 .map(|d| {
3186 let title = style::title(&d.title, style);
3187 let title = if d.file.trim().is_empty() {
3188 title
3189 } else {
3190 format!("{title} ({})", d.file.trim())
3191 };
3192 format!("{}. {}", title, style::sentence(&d.reasoning, style))
3193 })
3194 .collect();
3195 out.push(format!("Raised and refuted:\n{}", bullets(&lines)));
3196 }
3197
3198 if !state.noted.is_empty() {
3199 let lines: Vec<String> = state
3203 .noted
3204 .iter()
3205 .filter(|f| {
3206 !already
3207 .iter()
3208 .any(|(title, file)| same_finding_parts(title, file, &f.title, &f.file))
3209 })
3210 .map(|f| located(f, style))
3211 .collect();
3212 if !lines.is_empty() {
3213 out.push(format!("Noted, not blocking:\n{}", bullets(&lines)));
3214 }
3215 }
3216
3217 if !state.filed.is_empty() {
3218 let refs: Vec<String> = state.filed.iter().map(|u| as_reference(u)).collect();
3219 out.push(format!("Filed separately: {}", refs.join(", ")));
3220 }
3221
3222 Some(out.join("\n\n"))
3223}
3224
3225#[allow(clippy::too_many_arguments)]
3234fn close_prompt(
3235 base: &str,
3236 number: i64,
3237 title: &str,
3238 from: &str,
3239 landed: Option<&[String]>,
3240 ledger: &Ledger,
3241 open_findings: &[Finding],
3242 round: u32,
3243) -> String {
3244 let landed = match landed {
3245 Some([]) => "\nNothing landed after the last round of review. What it asked for was \
3246 answered in words rather than in code, so the branch in front of you is the \
3247 branch that was already read.\n"
3248 .to_string(),
3249 Some(lines) => format!(
3250 "\nThis landed after the last round of review, and nobody has read it:\n{}\n\nRead it \
3251 first with `git diff {from}..HEAD`, then inspect the full branch with `git diff \
3252 {base}...HEAD`.\n",
3253 lines
3254 .iter()
3255 .map(|l| format!("- {l}"))
3256 .collect::<Vec<_>>()
3257 .join("\n")
3258 ),
3259 None => format!(
3260 "\nThe commits on this branch were rewritten after the last round of review, so the \
3261 harness cannot say which of them are new. Inspect the full branch with `git diff \
3262 {base}...HEAD`.\n"
3263 ),
3264 };
3265 CLOSE_PROMPT
3266 .replace("{number}", &number.to_string())
3267 .replace("{title}", title)
3268 .replace("{base}", base)
3269 .replace("{landed}", &landed)
3270 .replace("{open}", &open_findings_block(open_findings))
3271 .replace("{answers}", &closing_answers(ledger, round))
3272 .replace("{settled}", &settled_block(ledger))
3273}
3274
3275fn open_findings_block(findings: &[Finding]) -> String {
3276 if findings.is_empty() {
3277 return String::new();
3278 }
3279 format!(
3280 "\nThese blocking findings were left open by an earlier response. Recheck each one:\n{}\n\
3281 \nIf one still blocks, return it under the same title and file. Omission means you checked \
3282 it and found that it no longer blocks.\n",
3283 findings_for_prompt(findings)
3284 )
3285}
3286
3287fn closing_answers(ledger: &Ledger, round: u32) -> String {
3292 let lines = fixed_lines(ledger, round);
3293 if lines.is_empty() {
3294 return String::new();
3295 }
3296 format!(
3297 "\nThese points were raised on this pull request and the author says it fixed them. \
3298 Nobody has checked that:\n{}\n",
3299 lines.join("\n")
3300 )
3301}
3302
3303fn review_prompt(
3308 base: &str,
3309 number: i64,
3310 title: &str,
3311 ledger: &Ledger,
3312 open_findings: &[Finding],
3313 round: u32,
3314 last: u32,
3315) -> String {
3316 REVIEW_PROMPT
3317 .replace("{base}", base)
3318 .replace("{number}", &number.to_string())
3319 .replace("{title}", title)
3320 .replace("{open}", &open_findings_block(open_findings))
3321 .replace("{answers}", &answers_block(ledger, round))
3322 .replace("{settled}", &settled_block(ledger))
3323 .replace("{round}", &round_note(round, last))
3324}
3325
3326fn implement_prompt(number: i64, title: &str, url: &str, body: &str) -> String {
3334 IMPLEMENT_PROMPT
3335 .replace("{number}", &number.to_string())
3336 .replace("{title}", title)
3337 .replace("{url}", url)
3338 .replace("{body}", body)
3339}
3340
3341pub fn pr_body(issue: i64, work: &Implementation, style: &Style) -> String {
3351 let mut parts = vec![format!("Closes #{issue}")];
3352
3353 for lead in [&work.summary, &work.problem] {
3354 let text = style::sentence(lead, style);
3355 if !text.is_empty() {
3356 parts.push(text);
3357 }
3358 }
3359 parts.extend(section("What changed", &work.changes, style));
3360 parts.extend(section("How to test", &work.testing, style));
3361
3362 let notes = style::sentence(work.notes.as_deref().unwrap_or_default(), style);
3363 if !notes.is_empty() {
3364 parts.push(format!("## Notes\n\n{notes}"));
3365 }
3366
3367 style::body(&parts.join("\n\n"), style)
3368}
3369
3370fn section(heading: &str, lines: &[String], style: &Style) -> Option<String> {
3377 let items: Vec<String> = lines
3378 .iter()
3379 .map(|line| style::summary(line, style))
3380 .filter(|line| !line.is_empty())
3381 .collect();
3382 if items.is_empty() {
3383 return None;
3384 }
3385 Some(format!("## {heading}\n\n{}", bullets(&items)))
3386}
3387
3388pub fn from_commits(repo: &Repo, work_dir: &Path, base: &str) -> Implementation {
3396 Implementation {
3397 changes: repo.commit_subjects(work_dir, "HEAD", base),
3398 notes: Some(
3399 "The implement call failed after these commits were made, so this body is assembled \
3400 from their messages rather than written by their author. Read the diff."
3401 .to_string(),
3402 ),
3403 ..Implementation::default()
3404 }
3405}
3406
3407fn no_pr_note(work: &Implementation, style: &Style) -> String {
3414 let reason = style::sentence(&work.reason, style);
3415 if !reason.is_empty() {
3416 return reason;
3417 }
3418 if work.not_worth_doing {
3419 "Left alone after reading the code, with no reason given.".to_string()
3420 } else {
3421 "Nothing was committed, so there is nothing to review.".to_string()
3422 }
3423}
3424
3425pub fn review_comment(holder: &str, round: u32, review: &Review, style: &Style) -> String {
3429 let by = |severity: Severity| -> Vec<&Finding> {
3430 review
3431 .findings
3432 .iter()
3433 .filter(|f| f.severity == severity && f.in_scope)
3434 .collect()
3435 };
3436 let blocking = by(Severity::Blocking);
3437 let non_blocking = by(Severity::NonBlocking);
3438 let nits = by(Severity::Nit);
3439 let out_of_scope: Vec<&Finding> = review.findings.iter().filter(|f| !f.in_scope).collect();
3440
3441 let mut counts = Vec::new();
3442 if !blocking.is_empty() {
3443 counts.push(format!("{} blocking", blocking.len()));
3444 }
3445 if !non_blocking.is_empty() {
3446 counts.push(format!("{} non-blocking", non_blocking.len()));
3447 }
3448 if !nits.is_empty() {
3449 counts.push(format!("{} nit", nits.len()));
3450 }
3451 if !out_of_scope.is_empty() {
3452 counts.push(format!("{} out of scope", out_of_scope.len()));
3453 }
3454 let headline = if counts.is_empty() {
3455 "no findings".to_string()
3456 } else {
3457 counts.join(", ")
3458 };
3459
3460 let _ = (holder, round, headline);
3461 let mut out = Vec::new();
3462 let summary = style::summary(&review.summary, style);
3463 if !summary.is_empty() {
3464 out.push(summary);
3465 }
3466
3467 if !blocking.is_empty() {
3468 let lines: Vec<String> = blocking
3469 .iter()
3470 .map(|f| {
3471 let detail = style::detail(&f.detail, style);
3472 if detail.is_empty() {
3473 located(f, style)
3474 } else {
3475 format!("{}. {detail}", located(f, style))
3476 }
3477 })
3478 .collect();
3479 out.push(format!("blocking\n{}", bullets(&lines)));
3480 }
3481
3482 for (label, group) in [
3485 ("non-blocking", &non_blocking),
3486 ("nits", &nits),
3487 ("out of scope", &out_of_scope),
3488 ] {
3489 if group.is_empty() {
3490 continue;
3491 }
3492 let lines: Vec<String> = group.iter().map(|f| located(f, style)).collect();
3493 out.push(format!("{label}\n{}", bullets(&lines)));
3494 }
3495
3496 out.join("\n\n")
3497}
3498
3499pub fn disposition_comment(
3503 author: &str,
3504 response: &ResponseDoc,
3505 fixed: &[String],
3506 refuted: &[String],
3507 filed: &[String],
3508 style: &Style,
3509) -> Option<String> {
3510 if fixed.is_empty() && refuted.is_empty() && filed.is_empty() {
3511 return None;
3512 }
3513 let mut counts = Vec::new();
3514 if !fixed.is_empty() {
3515 counts.push(format!("{} fixed", fixed.len()));
3516 }
3517 if !refuted.is_empty() {
3518 counts.push(format!("{} refuted", refuted.len()));
3519 }
3520 if !filed.is_empty() {
3521 counts.push(format!("{} filed", filed.len()));
3522 }
3523
3524 let _ = (author, counts);
3525 let mut out = Vec::new();
3526 let summary = style::summary(&response.summary, style);
3527 if !summary.is_empty() {
3528 out.push(summary);
3529 }
3530 if !refuted.is_empty() {
3531 out.push(format!("refuted\n{}", bullets(refuted)));
3532 }
3533 if !fixed.is_empty() {
3534 out.push(format!("fixed\n{}", bullets(fixed)));
3535 }
3536 if !filed.is_empty() {
3537 out.push(format!("filed\n{}", bullets(filed)));
3538 }
3539 Some(out.join("\n\n"))
3540}
3541
3542pub fn skip_comment(item: &SkippedItem, style: &Style) -> String {
3550 let reasons = item
3551 .reasons
3552 .values()
3553 .map(|reason| style::sentence(reason, style));
3554 let lines = crate::textsim::dedupe_by(reasons, crate::textsim::same_reason);
3558 bullets(&lines)
3559}
3560
3561pub(crate) fn findings_for_prompt(findings: &[Finding]) -> String {
3564 if findings.is_empty() {
3565 return "(none)".to_string();
3566 }
3567 findings
3568 .iter()
3569 .map(|f| {
3570 let scope = if f.in_scope { "" } else { " [out of scope]" };
3571 format!(
3572 "- [{}]{scope} {} ({})\n {}",
3573 f.severity,
3574 f.title,
3575 f.where_at(),
3576 f.detail
3577 )
3578 })
3579 .collect::<Vec<_>>()
3580 .join("\n")
3581}
3582
3583#[cfg(test)]
3584mod tests {
3585 use super::*;
3586 use crate::model::Verdict;
3587
3588 fn style() -> Style {
3589 Style::default()
3590 }
3591
3592 fn finding(severity: &str, title: &str, detail: &str, file: &str, in_scope: bool) -> Finding {
3593 Finding {
3594 severity: Severity::parse_lenient(severity).unwrap(),
3595 title: title.into(),
3596 detail: detail.into(),
3597 file: file.into(),
3598 in_scope,
3599 ..Default::default()
3600 }
3601 }
3602
3603 fn review(summary: &str, findings: Vec<Finding>) -> Review {
3604 Review {
3605 verdict: Verdict::Approve,
3606 next_action: NextAction::Merge,
3607 summary: summary.into(),
3608 findings,
3609 }
3610 }
3611
3612 fn disposition(title: &str, file: &str, action: Action) -> Disposition {
3613 Disposition {
3614 title: title.into(),
3615 file: file.into(),
3616 action,
3617 reasoning: "because".into(),
3618 new_issue_title: None,
3619 new_issue_body: None,
3620 }
3621 }
3622
3623 fn cfg_with(worktrees: bool, keep: bool) -> Config {
3626 let text = "[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n";
3627 let mut cfg = crate::config::parse(text).unwrap();
3628 cfg.loop_cfg.worktrees = worktrees;
3629 cfg.loop_cfg.keep_worktrees = keep;
3630 cfg
3631 }
3632
3633 #[test]
3634 fn a_worktree_is_released_on_every_finished_outcome() {
3635 let cfg = cfg_with(true, false);
3636 for status in [Status::Approved, Status::Merged, Status::Abandoned] {
3637 assert!(should_release(&cfg, status), "{status}");
3638 }
3639 }
3640
3641 #[test]
3644 fn a_worktree_is_kept_only_where_a_human_has_to_look() {
3645 let cfg = cfg_with(true, false);
3646 assert!(!should_release(&cfg, Status::Escalated));
3647 assert!(!should_release(&cfg, Status::Error));
3648 }
3649
3650 #[test]
3651 fn an_uncommitted_implementation_names_its_diagnostic_and_recovery_path() {
3652 let path = Path::new("/tmp/issue worktree");
3653 let err =
3654 uncommitted_implementation_error(path, Some("git add could not create index.lock"))
3655 .to_string();
3656 assert!(err.contains("could not create index.lock"), "{err}");
3657 assert!(err.contains("/tmp/issue worktree"), "{err}");
3658 assert!(err.contains("Commit or recover"), "{err}");
3659 assert!(!should_release(&cfg_with(true, false), Status::Error));
3660 }
3661
3662 #[test]
3663 fn the_keep_flag_overrides_everything() {
3664 assert!(!should_release(&cfg_with(true, true), Status::Approved));
3665 }
3666
3667 #[test]
3668 fn nothing_is_released_when_worktrees_are_off() {
3669 assert!(!should_release(&cfg_with(false, false), Status::Approved));
3670 }
3671
3672 #[test]
3677 fn fixing_your_own_findings_hands_the_pr_over() {
3678 let cfg = cfg_with(true, false);
3679 assert_eq!("a", next_reviewer(&cfg, "b", Some("b")));
3680 assert_eq!("b", next_reviewer(&cfg, "a", Some("a")));
3681 }
3682
3683 #[test]
3687 fn handing_back_keeps_the_reviewer_for_the_next_round() {
3688 let cfg = cfg_with(true, false);
3689 assert_eq!("b", next_reviewer(&cfg, "b", Some("a")));
3690 assert_eq!("a", next_reviewer(&cfg, "a", Some("b")));
3691 }
3692
3693 #[test]
3696 fn nobody_reviews_their_own_edit() {
3697 let cfg = cfg_with(true, false);
3698 let round_1 = cfg.other(&cfg.first_implementor);
3699 assert_eq!("b", round_1);
3700 for editor in ["a", "b"] {
3701 assert_ne!(editor, next_reviewer(&cfg, &round_1, Some(editor)));
3702 }
3703 }
3704
3705 #[test]
3710 fn a_fix_that_committed_nothing_leaves_the_pr_where_it_is() {
3711 let cfg = cfg_with(true, false);
3712 assert_eq!("b", next_reviewer(&cfg, "b", None));
3713 assert_eq!("a", next_reviewer(&cfg, "a", None));
3714 }
3715
3716 #[test]
3720 fn a_reviewer_that_wrote_the_head_gives_the_pr_up() {
3721 let cfg = cfg_with(true, false);
3722 assert_eq!("a", next_reviewer(&cfg, "b", Some("b")));
3723 }
3724
3725 #[test]
3729 fn a_review_that_wrote_cannot_approve_what_is_left() {
3730 assert!(!approval_stands(&[], true));
3731 }
3732
3733 #[test]
3734 fn a_clean_review_of_an_untouched_branch_approves() {
3735 assert!(approval_stands(&[], false));
3736 }
3737
3738 #[test]
3739 fn a_blocking_finding_never_approves() {
3740 let blocking = vec![finding("blocking", "Broken", "detail", "src/x.rs", true)];
3741 assert!(!approval_stands(&blocking, false));
3742 }
3743
3744 #[test]
3745 fn approval_refuses_a_head_that_changed_after_review() {
3746 assert!(ensure_reviewed_head(36, "abc123", "abc123").is_ok());
3747 let error = ensure_reviewed_head(36, "abc123", "def456").unwrap_err();
3748 assert!(error.to_string().contains("unread head"));
3749 }
3750
3751 #[test]
3753 fn only_a_moved_head_counts_as_a_commit() {
3754 let before = Snapshot {
3755 head: "abc".into(),
3756 dirty: false,
3757 };
3758 assert!(!Snapshot {
3759 head: "abc".into(),
3760 dirty: true,
3761 }
3762 .landed_over(&before));
3763 assert!(Snapshot {
3764 head: "def".into(),
3765 dirty: false,
3766 }
3767 .landed_over(&before));
3768 assert!(!Snapshot {
3770 head: String::new(),
3771 dirty: false,
3772 }
3773 .landed_over(&before));
3774 }
3775
3776 #[test]
3780 fn a_fresh_run_starts_at_one() {
3781 assert_eq!((1, 3), round_window(1, 3));
3782 assert_eq!((1, 5), round_window(1, 5));
3783 }
3784
3785 #[test]
3789 fn a_resumed_run_gets_a_full_fresh_budget() {
3790 assert_eq!((6, 10), round_window(6, 5));
3791 assert_eq!((11, 13), round_window(11, 3));
3792 }
3793
3794 #[test]
3795 fn a_budget_of_one_is_a_single_round() {
3796 assert_eq!((6, 6), round_window(6, 1));
3797 }
3798
3799 #[test]
3800 fn round_numbers_keep_counting_across_sessions() {
3801 let mut start = 1;
3803 let mut seen = Vec::new();
3804 for _ in 0..3 {
3805 let (first, last) = round_window(start, 3);
3806 seen.push((first, last));
3807 start = last + 1;
3808 }
3809 assert_eq!(vec![(1, 3), (4, 6), (7, 9)], seen);
3810 }
3811
3812 fn ledger_with(title: &str, file: &str) -> Ledger {
3815 let mut ledger = Ledger::new();
3816 ledger.insert(
3817 finding_key(title, file),
3818 LedgerEntry {
3819 title: title.into(),
3820 file: file.into(),
3821 reasoning: "no".into(),
3822 round: 1,
3823 reraised: 0,
3824 outcome: Settled::Refuted,
3825 },
3826 );
3827 ledger
3828 }
3829
3830 #[test]
3831 fn a_point_refuted_and_re_raised_twice_escalates() {
3832 let mut ledger = ledger_with("nit about naming", "a.rs");
3833 let mut state = IssueRun::new(1, "t");
3834 let blocking = vec![finding("blocking", "nit about naming", "d", "a.rs", true)];
3835 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
3836 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
3837 }
3838
3839 #[test]
3843 fn a_point_fixed_twice_and_raised_again_escalates() {
3844 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
3845 for entry in ledger.values_mut() {
3846 entry.outcome = Settled::Fixed;
3847 }
3848 let mut state = IssueRun::new(1, "t");
3849 let blocking = [finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
3850
3851 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
3852 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
3853 }
3854
3855 #[test]
3858 fn a_fix_that_missed_twice_is_not_reported_as_a_refutation() {
3859 assert!(why_escalated(Settled::Fixed).contains("fixed twice"));
3860 for outcome in [Settled::Refuted, Settled::Filed, Settled::Dropped] {
3861 assert!(why_escalated(outcome).contains("settled"), "{outcome}");
3862 }
3863 }
3864
3865 #[test]
3870 fn a_reviewer_that_fixes_its_own_findings_records_them_too() {
3871 let mut ledger = Ledger::new();
3872 let blocking = vec![finding(
3873 "blocking",
3874 "Unbounded loop",
3875 "spins",
3876 "src/x.rs",
3877 true,
3878 )];
3879 let mut state = IssueRun::new(1, "t");
3880
3881 record_own_fixes(&blocking, &mut ledger, &mut state, 1);
3882
3883 let entry = ledger
3884 .get(&finding_key("Unbounded loop", "src/x.rs"))
3885 .expect("keyed where the next round will look");
3886 assert_eq!(Settled::Fixed, entry.outcome);
3887 assert_eq!(
3888 "a committed change was made for this point",
3889 entry.reasoning
3890 );
3891
3892 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
3894 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
3895 }
3896
3897 #[test]
3901 fn a_fixed_point_is_not_in_the_settled_block() {
3902 let mut ledger = ledger_with("refuted point", "a.rs");
3903 ledger.extend(ledger_with("fixed point", "b.rs"));
3904 for entry in ledger.values_mut() {
3905 if entry.title == "fixed point" {
3906 entry.outcome = Settled::Fixed;
3907 }
3908 }
3909 let block = settled_block(&ledger);
3910 assert!(block.contains("refuted point"));
3911 assert!(!block.contains("fixed point"));
3912 }
3913
3914 #[test]
3917 fn a_ledger_of_only_fixes_says_nothing_is_settled() {
3918 let mut ledger = ledger_with("fixed point", "b.rs");
3919 for entry in ledger.values_mut() {
3920 entry.outcome = Settled::Fixed;
3921 }
3922 assert_eq!("", settled_block(&ledger));
3923 }
3924
3925 #[test]
3930 fn one_review_spends_one_re_raise_however_often_it_says_it() {
3931 let mut ledger = ledger_with("Missing error handling", "src/net.rs");
3932 let mut state = IssueRun::new(1, "t");
3933 let twice = vec![
3934 finding(
3935 "blocking",
3936 "Missing error handling",
3937 "d",
3938 "src/net.rs",
3939 true,
3940 ),
3941 finding(
3942 "blocking",
3943 "Missing error handling",
3944 "e",
3945 "src/net.rs",
3946 true,
3947 ),
3948 ];
3949
3950 assert!(!check_relitigation(&mut ledger, &twice, &mut state));
3951 assert_eq!(1, ledger.values().next().unwrap().reraised);
3952 assert!(check_relitigation(&mut ledger, &twice, &mut state));
3953 }
3954
3955 #[test]
3956 fn an_untracked_finding_does_not_escalate() {
3957 let mut state = IssueRun::new(1, "t");
3958 let blocking = vec![finding("blocking", "brand new", "d", "a.rs", true)];
3959 assert!(!check_relitigation(
3960 &mut Ledger::new(),
3961 &blocking,
3962 &mut state
3963 ));
3964 }
3965
3966 #[test]
3967 fn persisted_ledger_entries_are_rekeyed_for_stable_locations() {
3968 let mut ledger = Ledger::new();
3969 ledger.insert(
3970 "legacy-key".into(),
3971 LedgerEntry {
3972 title: "Unbounded loop".into(),
3973 file: "src/x.rs:88".into(),
3974 reasoning: "bounded by the caller".into(),
3975 round: 2,
3976 reraised: 1,
3977 outcome: Settled::Refuted,
3978 },
3979 );
3980 normalise_ledger_keys(&mut ledger);
3981 let key = matching_ledger_key(&ledger, "Unbounded loop", "src/x.rs:91").unwrap();
3982 assert_eq!(1, ledger[&key].reraised);
3983 }
3984
3985 #[test]
3986 fn same_title_at_two_sites_keeps_both_blockers() {
3987 let findings = vec![
3988 finding(
3989 "blocking",
3990 "Unchecked error",
3991 "first site",
3992 "src/net.rs:10",
3993 true,
3994 ),
3995 finding(
3996 "blocking",
3997 "Unchecked error",
3998 "second site",
3999 "src/net.rs:200",
4000 true,
4001 ),
4002 ];
4003
4004 let blocking = blocking_findings(&findings);
4005 assert_eq!(2, blocking.len());
4006 assert_eq!("src/net.rs:10", blocking[0].file);
4007 assert_eq!("src/net.rs:200", blocking[1].file);
4008 }
4009
4010 #[test]
4011 fn moved_location_fallback_refuses_an_ambiguous_ledger() {
4012 let mut ledger = ledger_with("Unchecked error", "src/net.rs:10");
4013 ledger.extend(ledger_with("Unchecked error", "src/net.rs:200"));
4014 assert!(matching_ledger_key(&ledger, "Unchecked error", "src/net.rs:30").is_none());
4015 }
4016
4017 #[test]
4018 fn two_current_sites_do_not_relocate_one_old_ledger_entry() {
4019 let mut ledger = ledger_with("Unchecked error", "src/net.rs:5");
4020 let blocking = vec![
4021 finding(
4022 "blocking",
4023 "Unchecked error",
4024 "first",
4025 "src/net.rs:10",
4026 true,
4027 ),
4028 finding(
4029 "blocking",
4030 "Unchecked error",
4031 "second",
4032 "src/net.rs:200",
4033 true,
4034 ),
4035 ];
4036 let mut state = IssueRun::new(1, "t");
4037 record_own_fixes(&blocking, &mut ledger, &mut state, 2);
4038 assert!(ledger.contains_key(&finding_key("Unchecked error", "src/net.rs:10")));
4039 assert!(ledger.contains_key(&finding_key("Unchecked error", "src/net.rs:200")));
4040 }
4041
4042 #[test]
4043 fn two_current_sites_remain_two_open_findings() {
4044 let current = vec![
4045 finding(
4046 "blocking",
4047 "Unchecked error",
4048 "first",
4049 "src/net.rs:10",
4050 true,
4051 ),
4052 finding(
4053 "blocking",
4054 "Unchecked error",
4055 "second",
4056 "src/net.rs:200",
4057 true,
4058 ),
4059 ];
4060 let mut open = Vec::new();
4061
4062 extend_findings(&mut open, ¤t);
4063
4064 assert_eq!(2, open.len());
4065 assert_eq!("src/net.rs:10", open[0].file);
4066 assert_eq!("src/net.rs:200", open[1].file);
4067 }
4068
4069 #[test]
4070 fn display_limits_do_not_change_persisted_finding_identity() {
4071 let point = finding(
4072 "blocking",
4073 "abcdefghij",
4074 "still wrong",
4075 "src/net.rs:10",
4076 true,
4077 );
4078 let mut ledger = Ledger::new();
4079 let mut state = IssueRun::new(1, "t");
4080 record_own_fixes(std::slice::from_ref(&point), &mut ledger, &mut state, 1);
4081 normalise_ledger_keys(&mut ledger);
4082
4083 let key = matching_ledger_key(&ledger, "abcdefghij", "src/net.rs:12").unwrap();
4084 assert_eq!("abcdefghij", ledger[&key].title);
4085 }
4086
4087 #[test]
4088 fn a_clipped_legacy_entry_keeps_its_original_lookup_key() {
4089 let mut ledger = Ledger::new();
4090 let key = crate::jsonx::finding_key("abcdefghij", "src/net.rs:10");
4091 ledger.insert(
4092 key.clone(),
4093 LedgerEntry {
4094 title: "abcde".into(),
4095 file: "src/net.rs:10".into(),
4096 reasoning: "bounded by the caller".into(),
4097 round: 1,
4098 reraised: 1,
4099 outcome: Settled::Refuted,
4100 },
4101 );
4102 normalise_ledger_keys(&mut ledger);
4103
4104 assert_eq!(
4105 Some(key),
4106 matching_ledger_key(&ledger, "abcdefghij", "src/net.rs:12")
4107 );
4108 }
4109
4110 #[test]
4111 fn a_legacy_key_collision_does_not_merge_case_distinct_paths() {
4112 let mut ledger = Ledger::new();
4113 let key = crate::jsonx::finding_key("Unchecked error", "src/Main.rs:10");
4114 ledger.insert(
4115 key.clone(),
4116 LedgerEntry {
4117 title: "Unchecked error".into(),
4118 file: "src/Main.rs:10".into(),
4119 reasoning: "bounded by the caller".into(),
4120 round: 1,
4121 reraised: 0,
4122 outcome: Settled::Refuted,
4123 },
4124 );
4125
4126 assert_eq!(
4127 Some(key),
4128 matching_ledger_key(&ledger, "Unchecked error", "src/Main.rs:10")
4129 );
4130 assert!(matching_ledger_key(&ledger, "Unchecked error", "src/main.rs:10").is_none());
4131 }
4132
4133 #[test]
4137 fn a_refutation_lands_on_the_key_the_next_round_will_look_up() {
4138 let blocking = [finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
4139 let recorded = finding_key(&blocking[0].title, &blocking[0].file);
4140 let answer = disposition("unbounded loop!", "src/x.rs", Action::Refuted);
4141 assert!(disposition_matches(&blocking[0], &answer));
4142 assert_eq!(recorded, finding_key(&answer.title, &answer.file));
4143 }
4144
4145 #[test]
4148 fn the_ledger_key_ignores_title_punctuation() {
4149 let findings = [finding(
4150 "blocking",
4151 "Panic on multi-byte input",
4152 "d",
4153 "src/style.rs",
4154 true,
4155 )];
4156 let reworded = "Panic on multibyte input";
4157 let source = &findings[0];
4158 assert_eq!(
4159 finding_key(reworded, &source.file),
4160 finding_key(&source.title, &source.file)
4161 );
4162 let recorded = finding_key(&source.title, &source.file);
4163 let looked_up = finding_key(&findings[0].title, &findings[0].file);
4164 assert_eq!(recorded, looked_up);
4165 }
4166
4167 #[test]
4168 fn a_disposition_matches_its_finding_despite_wording_noise() {
4169 let findings = [finding(
4170 "blocking",
4171 "Unbounded loop!",
4172 "d",
4173 "src/x.rs",
4174 true,
4175 )];
4176 assert!(disposition_matches(
4177 &findings[0],
4178 &disposition("unbounded loop", "src/x.rs", Action::Refuted)
4179 ));
4180 assert!(!disposition_matches(
4181 &findings[0],
4182 &disposition("something else", "src/x.rs", Action::Refuted)
4183 ));
4184 assert!(!disposition_matches(
4185 &findings[0],
4186 &disposition("unbounded loop", "src/y.rs", Action::Refuted)
4187 ));
4188 }
4189
4190 #[test]
4191 fn a_disposition_matches_its_finding_despite_a_severity_tag() {
4192 let blocker = finding(
4193 "blocking",
4194 "Refused requests can corrupt the active splice",
4195 "d",
4196 "src/lightning/channel/channel.ts",
4197 true,
4198 );
4199 let answers = [disposition(
4200 "[blocking] Refused requests can corrupt the active splice",
4201 "src/lightning/channel/channel.ts",
4202 Action::Fixed,
4203 )];
4204
4205 assert_eq!(
4206 0,
4207 matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers)
4208 .expect("tagged answer")
4209 .0
4210 );
4211 }
4212
4213 #[test]
4214 fn a_bracketed_subject_still_separates_two_findings() {
4215 let ios = finding("blocking", "[iOS] Startup crash", "d", "src/app.ts", true);
4216 let android = finding(
4217 "blocking",
4218 "[Android] Startup crash",
4219 "d",
4220 "src/app.ts",
4221 true,
4222 );
4223 let findings = [ios.clone(), android];
4224 let answers = [disposition(
4225 "[Android] Startup crash",
4226 "src/app.ts",
4227 Action::Fixed,
4228 )];
4229
4230 assert!(matches!(
4231 matching_disposition(&ios, &findings, &answers),
4232 Err("no matching disposition")
4233 ));
4234 }
4235
4236 #[test]
4237 fn an_omitted_disposition_leaves_the_blocker_unmatched() {
4238 let blocker = finding("blocking", "Unbounded loop", "d", "src/x.rs", true);
4239 assert!(matches!(
4240 matching_disposition(&blocker, std::slice::from_ref(&blocker), &[]),
4241 Err("no matching disposition")
4242 ));
4243 }
4244
4245 #[test]
4246 fn duplicate_dispositions_are_ambiguous() {
4247 let blocker = finding("blocking", "Unbounded loop", "d", "src/x.rs", true);
4248 let answers = vec![
4249 disposition("Unbounded loop", "src/x.rs", Action::Fixed),
4250 disposition("Unbounded loop", "src/x.rs", Action::Refuted),
4251 ];
4252 assert!(matches!(
4253 matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers),
4254 Err("more than one matching disposition")
4255 ));
4256 }
4257
4258 #[test]
4259 fn an_unambiguous_disposition_may_omit_the_line_number() {
4260 let blocker = finding(
4261 "blocking",
4262 "Replayed offers persist the wrong lane key",
4263 "d",
4264 "src/engine.rs:1166",
4265 true,
4266 );
4267 let answers = [disposition(
4268 "replayed offers persist the wrong lane key!",
4269 "src/engine.rs",
4270 Action::Fixed,
4271 )];
4272
4273 assert_eq!(
4274 0,
4275 matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers)
4276 .expect("stable answer")
4277 .0
4278 );
4279 }
4280
4281 #[test]
4282 fn a_line_free_disposition_does_not_choose_between_two_sites() {
4283 let first = finding("blocking", "Unchecked error", "d", "src/engine.rs:10", true);
4284 let second = finding(
4285 "blocking",
4286 "Unchecked error",
4287 "d",
4288 "src/engine.rs:200",
4289 true,
4290 );
4291 let findings = [first.clone(), second];
4292 let answers = [disposition(
4293 "Unchecked error",
4294 "src/engine.rs",
4295 Action::Fixed,
4296 )];
4297
4298 assert!(matches!(
4299 matching_disposition(&first, &findings, &answers),
4300 Err("no matching disposition")
4301 ));
4302 }
4303
4304 #[test]
4305 fn an_exact_location_wins_over_an_ambiguous_fallback() {
4306 let blocker = finding("blocking", "Unchecked error", "d", "src/engine.rs:10", true);
4307 let answers = [
4308 disposition("Unchecked error", "src/engine.rs", Action::Refuted),
4309 disposition("Unchecked error", "src/engine.rs:10", Action::Fixed),
4310 ];
4311
4312 let matched = matching_disposition(&blocker, std::slice::from_ref(&blocker), &answers)
4313 .expect("exact answer");
4314 assert_eq!(1, matched.0);
4315 assert_eq!(Action::Fixed, matched.1.action);
4316 }
4317
4318 #[test]
4319 fn same_titled_findings_in_different_files_need_separate_dispositions() {
4320 let findings = [
4321 finding("blocking", "Unchecked error", "d", "src/a.rs", true),
4322 finding("blocking", "Unchecked error", "d", "src/b.rs", true),
4323 ];
4324 let answers = vec![
4325 disposition("Unchecked error", "src/a.rs", Action::Fixed),
4326 disposition("Unchecked error", "src/b.rs", Action::Refuted),
4327 ];
4328 assert_eq!(
4329 0,
4330 matching_disposition(&findings[0], &findings, &answers)
4331 .expect("left answer")
4332 .0
4333 );
4334 assert_eq!(
4335 1,
4336 matching_disposition(&findings[1], &findings, &answers)
4337 .expect("right answer")
4338 .0
4339 );
4340 }
4341
4342 #[test]
4343 fn a_reported_fix_without_a_commit_stays_open() {
4344 assert!(!fixed_disposition_resolves(false));
4345 assert!(fixed_disposition_resolves(true));
4346 }
4347
4348 #[test]
4349 fn the_settled_block_is_empty_when_nothing_is_settled() {
4350 assert_eq!("", settled_block(&Ledger::new()));
4351 }
4352
4353 #[test]
4354 fn the_settled_block_names_each_refutation() {
4355 let block = settled_block(&ledger_with("a point", "x.rs"));
4356 assert!(block.contains("a point"));
4357 assert!(block.contains("x.rs"));
4358 assert!(block.contains("settled"));
4359 }
4360
4361 #[test]
4362 fn same_title_settlements_name_each_location() {
4363 let mut ledger = ledger_with("Unchecked error", "a.rs:10");
4364 ledger.extend(ledger_with("Unchecked error", "b.rs:20"));
4365
4366 let block = settled_block(&ledger);
4367
4368 assert!(block.contains("Unchecked error (a.rs:10)"), "{block}");
4369 assert!(block.contains("Unchecked error (b.rs:20)"), "{block}");
4370 }
4371
4372 #[test]
4376 fn a_filed_point_is_settled_too() {
4377 let mut ledger = ledger_with("out of scope", "x.rs");
4378 for entry in ledger.values_mut() {
4379 entry.outcome = Settled::Filed;
4380 entry.reasoning = "Tracked in #9.".into();
4381 }
4382 let block = settled_block(&ledger);
4383 assert!(block.contains("out of scope"));
4384 assert!(block.contains("#9"));
4385 }
4386
4387 #[test]
4390 fn answering_a_point_again_keeps_its_re_raise_count() {
4391 let mut ledger = ledger_with("a point", "x.rs");
4392 let entry = ledger.values().next().unwrap().clone();
4393 let mut state = IssueRun::new(1, "t");
4394 let blocking = vec![finding("blocking", "a point", "d", "x.rs", true)];
4395
4396 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
4397 settle(&mut ledger, "a point", "x.rs", true, entry);
4398 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
4399 }
4400
4401 #[test]
4404 fn a_clean_review_is_just_the_verdict() {
4407 let text = review_comment("codex", 1, &review("Looks correct.", vec![]), &style());
4408 assert_eq!("Looks correct.", text);
4409 }
4410
4411 #[test]
4412 fn a_review_leads_with_the_counts() {
4413 let text = review_comment(
4414 "codex",
4415 2,
4416 &review(
4417 "One real problem.",
4418 vec![
4419 finding(
4420 "blocking",
4421 "Loop never terminates",
4422 "Confirmed by running it.",
4423 "src/a.rs",
4424 true,
4425 ),
4426 finding("non-blocking", "Name is vague", "d", "src/b.rs", true),
4427 finding("nit", "Log wording", "d", "", true),
4428 ],
4429 ),
4430 &style(),
4431 );
4432 assert!(text.starts_with("One real problem."), "{text}");
4433 assert!(!text.contains("codex"), "no agent name: {text}");
4434 assert!(!text.contains("round 2"), "no round number: {text}");
4435 }
4436
4437 #[test]
4440 fn only_blocking_findings_carry_their_detail() {
4441 let text = review_comment(
4442 "codex",
4443 1,
4444 &review(
4445 "s",
4446 vec![
4447 finding("blocking", "Loop", "BLOCKING DETAIL", "a.rs", true),
4448 finding("non-blocking", "Name", "NONBLOCKING DETAIL", "b.rs", true),
4449 ],
4450 ),
4451 &style(),
4452 );
4453 assert!(text.contains("BLOCKING DETAIL"), "{text}");
4454 assert!(!text.contains("NONBLOCKING DETAIL"), "{text}");
4455 }
4456
4457 #[test]
4458 fn a_thorough_explanation_reaches_the_author_intact() {
4461 let detail = "Reproduced by running the 429 test with max_attempts unset. ".repeat(8);
4462 let text = review_comment(
4463 "codex",
4464 1,
4465 &review(
4466 "One problem.",
4467 vec![finding("blocking", "T", &detail, "a.rs", true)],
4468 ),
4469 &style(),
4470 );
4471 assert!(
4472 text.contains(detail.trim()),
4473 "the explanation was cut:\n{text}"
4474 );
4475 }
4476
4477 #[test]
4479 fn a_runaway_model_is_still_bounded() {
4480 let long = "filler words. ".repeat(20_000);
4481 let text = review_comment(
4482 "codex",
4483 1,
4484 &review(&long, vec![finding("blocking", "T", &long, "a.rs", true)]),
4485 &style(),
4486 );
4487 assert!(
4488 text.len() < 30_000,
4489 "review comment was {} chars",
4490 text.len()
4491 );
4492 }
4493
4494 #[test]
4495 fn a_general_finding_has_no_empty_parenthesis() {
4496 let text = review_comment(
4497 "codex",
4498 1,
4499 &review("s", vec![finding("blocking", "Something", "d", "", true)]),
4500 &style(),
4501 );
4502 assert!(!text.contains("()"), "{text}");
4503 assert!(!text.contains("(general)"), "{text}");
4504 }
4505
4506 #[test]
4507 fn out_of_scope_findings_are_counted_separately() {
4508 let text = review_comment(
4509 "codex",
4510 1,
4511 &review(
4512 "s",
4513 vec![finding("blocking", "Old bug", "d", "a.rs", false)],
4514 ),
4515 &style(),
4516 );
4517 assert!(text.contains("out of scope"), "{text}");
4518 assert!(text.contains("Old bug"), "{text}");
4519 }
4520
4521 #[test]
4522 fn a_disposition_comment_leads_with_counts_and_keeps_refutations() {
4523 let response = ResponseDoc {
4524 summary: "Two of three were right.".into(),
4525 dispositions: vec![],
4526 };
4527 let text = disposition_comment(
4528 "claude",
4529 &response,
4530 &["Fixed thing".to_string()],
4531 &["Wrong thing. Because the caller already checks.".to_string()],
4532 &[],
4533 &style(),
4534 )
4535 .unwrap();
4536 assert!(text.starts_with("Two of three were right."), "{text}");
4537 assert!(!text.contains("claude"), "no agent name: {text}");
4538 assert!(
4539 text.contains("Because the caller already checks."),
4540 "{text}"
4541 );
4542 }
4543
4544 #[test]
4545 fn an_empty_disposition_comment_is_not_posted() {
4546 let response = ResponseDoc {
4547 summary: "s".into(),
4548 dispositions: vec![],
4549 };
4550 assert!(disposition_comment("claude", &response, &[], &[], &[], &style()).is_none());
4551 }
4552
4553 #[test]
4558 fn a_local_closing_commit_does_not_change_remote_custody() {
4559 for holder in ["a", "b"] {
4560 assert_eq!(holder, closing_next_actor(holder));
4561 }
4562 }
4563
4564 #[test]
4565 fn a_matching_head_keeps_saved_custody() {
4566 assert!(reconcile_saved_head(Some("abc123"), Some(2), true, "abc123", None, 42).unwrap());
4567 assert!(reconcile_saved_head(None, None, false, "abc123", None, 42).unwrap());
4568 }
4569
4570 #[test]
4571 fn a_changed_head_refuses_automatic_custody() {
4572 let error =
4573 reconcile_saved_head(Some("abc123"), Some(2), true, "def456", None, 42).unwrap_err();
4574 let text = error.to_string();
4575 assert!(text.contains("abc123"), "{text}");
4576 assert!(text.contains("def456"), "{text}");
4577 assert!(text.contains("--next <agent>"), "{text}");
4578 }
4579
4580 #[test]
4581 fn legacy_state_keeps_its_saved_custody_for_migration() {
4582 assert!(reconcile_saved_head(Some(""), Some(1), true, "def456", None, 42).unwrap());
4583 }
4584
4585 #[test]
4586 fn an_explicit_holder_resets_state_for_a_changed_or_legacy_head() {
4587 assert!(
4588 !reconcile_saved_head(Some("abc123"), Some(2), true, "def456", Some("b"), 42).unwrap()
4589 );
4590 assert!(!reconcile_saved_head(Some(""), Some(1), true, "def456", Some("b"), 42).unwrap());
4591 }
4592
4593 #[test]
4594 fn a_headless_current_or_future_state_is_not_legacy() {
4595 for version in [2, STATE_VERSION + 1] {
4596 assert!(
4597 reconcile_saved_head(Some(""), Some(version), true, "def456", None, 42).is_err()
4598 );
4599 }
4600 }
4601
4602 #[test]
4603 fn legacy_state_with_an_unknown_actor_refuses_to_guess() {
4604 assert!(reconcile_saved_head(Some(""), Some(1), false, "def456", None, 42).is_err());
4605 }
4606
4607 #[test]
4608 fn an_invalid_review_cannot_clear_a_carried_blocker() {
4609 let mut open = vec![finding(
4610 "blocking",
4611 "Unchecked error",
4612 "still fails",
4613 "src/a.rs:12",
4614 true,
4615 )];
4616 update_open_findings(&mut open, &[], false);
4617 assert_eq!(1, open.len());
4618
4619 update_open_findings(&mut open, &[], true);
4620 assert!(open.is_empty());
4621 }
4622
4623 #[test]
4624 fn a_final_round_with_no_commit_names_open_blockers() {
4625 let open = vec![finding(
4626 "blocking",
4627 "Unchecked error",
4628 "still fails",
4629 "src/a.rs",
4630 true,
4631 )];
4632 assert!(matches!(
4633 ending_without_landing(&open),
4634 Ending::Unresolved(points) if points.len() == 1
4635 ));
4636 assert!(matches!(ending_without_landing(&[]), Ending::Unchanged));
4637 }
4638
4639 #[test]
4640 fn a_closing_pass_uses_the_later_effort_tier() {
4641 assert_eq!(2, closing_effort_round(1));
4642 assert_eq!(8, closing_effort_round(7));
4643 }
4644
4645 #[test]
4646 fn a_ledger_with_no_claimed_fix_has_nothing_to_close_over() {
4647 assert!(!any_fixes(&ledger_with("refuted point", "a.rs"), 1));
4648 let mut fixed = ledger_with("fixed point", "b.rs");
4649 for entry in fixed.values_mut() {
4650 entry.outcome = Settled::Fixed;
4651 }
4652 assert!(any_fixes(&fixed, 1));
4653 }
4654
4655 #[test]
4658 fn the_closing_note_counts_points_rather_than_rounds() {
4659 assert_eq!("one point left after the closing pass", unresolved_note(1));
4660 assert_eq!("3 points left after the closing pass", unresolved_note(3));
4661 assert!(!unresolved_note(2).contains("round"));
4662 }
4663
4664 fn fixed_ledger() -> Ledger {
4665 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
4666 for entry in ledger.values_mut() {
4667 entry.outcome = Settled::Fixed;
4668 entry.reasoning = "bounded it on max_attempts".into();
4669 }
4670 ledger
4671 }
4672
4673 #[test]
4674 fn the_closing_prompt_names_every_fix_it_has_to_check() {
4675 let landed = vec!["abc1234 Bound the retry loop".to_string()];
4676 let prompt = close_prompt(
4677 "main",
4678 42,
4679 "Retry a 429",
4680 "9f8e7d6",
4681 Some(&landed),
4682 &fixed_ledger(),
4683 &[],
4684 1,
4685 );
4686 assert!(prompt.contains("Unbounded loop"), "{prompt}");
4687 assert!(prompt.contains("bounded it on max_attempts"), "{prompt}");
4688 assert!(prompt.contains("abc1234 Bound the retry loop"), "{prompt}");
4689 assert!(prompt.contains("git diff 9f8e7d6..HEAD"), "{prompt}");
4690 assert!(prompt.contains("git diff main...HEAD"), "{prompt}");
4691 assert!(!prompt.contains('{'), "{prompt}");
4692 }
4693
4694 #[test]
4697 fn a_close_with_nothing_landed_says_so_rather_than_leaving_a_hole() {
4698 let prompt = close_prompt(
4699 "main",
4700 42,
4701 "Retry a 429",
4702 "9f8e7d6",
4703 Some(&[]),
4704 &Ledger::new(),
4705 &[],
4706 1,
4707 );
4708 assert!(
4709 prompt.contains("Nothing landed after the last round"),
4710 "{prompt}"
4711 );
4712 assert!(!prompt.contains('{'), "{prompt}");
4713 }
4714
4715 #[test]
4721 fn a_rewritten_branch_admits_it_cannot_say_what_landed() {
4722 let prompt = close_prompt(
4723 "main",
4724 42,
4725 "Retry a 429",
4726 "9f8e7d6",
4727 None,
4728 &fixed_ledger(),
4729 &[],
4730 1,
4731 );
4732 assert!(prompt.contains("were rewritten"), "{prompt}");
4733 assert!(prompt.contains("git diff main...HEAD"), "{prompt}");
4734 assert!(!prompt.contains("nobody has read it"), "{prompt}");
4735 assert!(!prompt.contains('{'), "{prompt}");
4736 }
4737
4738 #[test]
4741 fn the_closing_prompt_forbids_the_writing_the_loop_rolls_back() {
4742 let prompt = close_prompt(
4743 "main",
4744 42,
4745 "t",
4746 "9f8e7d6",
4747 Some(&[]),
4748 &Ledger::new(),
4749 &[],
4750 1,
4751 );
4752 assert!(prompt.contains("do not commit"), "{prompt}");
4753 }
4754
4755 #[test]
4757 fn the_closing_prompt_keeps_confirmed_merge_blockers_blocking() {
4758 let prompt = close_prompt(
4759 "main",
4760 42,
4761 "t",
4762 "9f8e7d6",
4763 Some(&[]),
4764 &Ledger::new(),
4765 &[],
4766 1,
4767 );
4768 assert!(
4769 prompt.contains("serious defect an\nearlier round missed"),
4770 "{prompt}"
4771 );
4772 assert!(prompt.contains("final merge-safety audit"), "{prompt}");
4773 assert!(!prompt.contains("not another\naudit"), "{prompt}");
4774 assert!(!prompt.contains("A\nfinding means"), "{prompt}");
4775 assert!(prompt.contains("A\nblocking finding means"), "{prompt}");
4776 let flat = prompt.split_whitespace().collect::<Vec<_>>().join(" ");
4777 assert!(flat.contains("does not become non-blocking"), "{prompt}");
4778 assert!(flat.contains("Only one of them ships"), "{prompt}");
4779 }
4780
4781 #[test]
4786 fn the_closing_pass_is_offered_a_severity_rather_than_the_field_that_gates() {
4787 let prompt = close_prompt(
4788 "main",
4789 42,
4790 "t",
4791 "9f8e7d6",
4792 Some(&[]),
4793 &Ledger::new(),
4794 &[],
4795 1,
4796 );
4797 assert!(
4798 prompt.contains("Minor defects and improvements are\nnon-blocking"),
4799 "{prompt}"
4800 );
4801 assert!(
4802 prompt.contains("a real defect\nthis pull request did not cause"),
4803 "{prompt}"
4804 );
4805 }
4806
4807 #[test]
4810 fn an_out_of_scope_point_cannot_gate_the_close() {
4811 let out_of_scope = finding("blocking", "Adjacent leak", "d", "o.rs", false);
4812 assert!(!out_of_scope.blocks());
4813 assert!(approval_stands(&[], false));
4814 }
4815
4816 #[test]
4820 fn a_fix_is_shown_to_the_pass_that_has_to_check_it_and_not_after() {
4821 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
4822 for entry in ledger.values_mut() {
4823 entry.outcome = Settled::Fixed;
4824 entry.round = 2;
4825 }
4826 assert!(answers_block(&ledger, 3).contains("Unbounded loop"));
4828 assert_eq!("", answers_block(&ledger, 4));
4830 assert!(any_fixes(&ledger, 2));
4831 assert!(!any_fixes(&ledger, 3));
4832 }
4833
4834 #[test]
4839 fn the_answers_block_asks_the_reviewer_to_check_rather_than_to_trust() {
4840 let mut ledger = ledger_with("Unbounded loop", "src/x.rs");
4841 for entry in ledger.values_mut() {
4842 entry.outcome = Settled::Fixed;
4843 entry.reasoning = "bounded it on max_attempts".into();
4844 }
4845 let block = answers_block(&ledger, 2);
4846 assert!(block.contains("Unbounded loop"), "{block}");
4847 assert!(block.contains("src/x.rs"), "{block}");
4848 assert!(block.contains("bounded it on max_attempts"), "{block}");
4849 assert!(block.contains("Check the answer"), "{block}");
4850 assert!(!block.contains("settled"), "{block}");
4851 }
4852
4853 #[test]
4856 fn a_fix_and_a_refutation_do_not_share_a_heading() {
4857 let mut ledger = ledger_with("refuted point", "a.rs");
4858 ledger.extend(ledger_with("fixed point", "b.rs"));
4859 for entry in ledger.values_mut() {
4860 if entry.title == "fixed point" {
4861 entry.outcome = Settled::Fixed;
4862 }
4863 }
4864 let answers = answers_block(&ledger, 2);
4865 let settled = settled_block(&ledger);
4866 assert!(answers.contains("fixed point") && !answers.contains("refuted point"));
4867 assert!(settled.contains("refuted point") && !settled.contains("fixed point"));
4868 }
4869
4870 #[test]
4871 fn an_empty_ledger_adds_no_answers_block() {
4872 assert_eq!("", answers_block(&Ledger::new(), 2));
4873 }
4874
4875 #[test]
4878 fn the_last_round_that_can_ask_for_anything_says_so() {
4879 assert_eq!("", round_note(1, 3));
4880 assert_eq!("", round_note(2, 3));
4881 assert!(round_note(3, 3).contains("last round"));
4882 assert!(round_note(6, 6).contains("last round"));
4885 }
4886
4887 #[test]
4893 fn saying_when_the_asking_stops_says_nothing_about_severity() {
4894 let note = round_note(3, 3);
4895 for word in ["approve", "blocking", "severity", "nit"] {
4896 assert!(!note.contains(word), "{word} in: {note}");
4897 }
4898 }
4899
4900 #[test]
4901 fn the_review_prompt_leaves_nothing_unsubstituted() {
4902 let empty = review_prompt("main", 42, "Retry a 429", &Ledger::new(), &[], 1, 3);
4903 assert!(!empty.contains('{'), "{empty}");
4904 assert!(empty.contains("main") && empty.contains("#42") && empty.contains("Retry a 429"));
4905
4906 let mut ledger = ledger_with("refuted point", "a.rs");
4907 ledger.extend(ledger_with("fixed point", "b.rs"));
4908 for entry in ledger.values_mut() {
4909 if entry.title == "fixed point" {
4910 entry.outcome = Settled::Fixed;
4911 entry.round = 2;
4912 }
4913 }
4914 let full = review_prompt("main", 42, "Retry a 429", &ledger, &[], 3, 3);
4915 assert!(!full.contains('{'), "{full}");
4916 assert!(full.contains("fixed point") && full.contains("refuted point"));
4917 assert!(full.contains("last round"), "{full}");
4918 }
4919
4920 #[test]
4921 fn a_resumed_open_finding_reaches_review_and_closing_prompts() {
4922 let open = vec![finding(
4923 "blocking",
4924 "Retry bypasses the limit",
4925 "reproduced with max_attempts set to one",
4926 "src/net.rs:88",
4927 true,
4928 )];
4929 let review = review_prompt("main", 42, "Retry a 429", &Ledger::new(), &open, 2, 3);
4930 let close = close_prompt(
4931 "main",
4932 42,
4933 "Retry a 429",
4934 "9f8e7d6",
4935 Some(&[]),
4936 &Ledger::new(),
4937 &open,
4938 2,
4939 );
4940 for prompt in [review, close] {
4941 assert!(prompt.contains("Retry bypasses the limit"), "{prompt}");
4942 assert!(prompt.contains("src/net.rs:88"), "{prompt}");
4943 assert!(
4944 prompt.contains("reproduced with max_attempts set to one"),
4945 "{prompt}"
4946 );
4947 assert!(!prompt.contains('{'), "{prompt}");
4948 }
4949 }
4950
4951 #[test]
4955 fn a_minor_defect_has_a_severity_that_is_not_blocking() {
4956 assert!(
4957 REVIEW_PROMPT.contains("A minor defect belongs\n here as much as an improvement does")
4958 );
4959 for text in [
4964 schema::review().to_string(),
4965 crate::review_only::review_only_prompt().to_string(),
4966 ] {
4967 assert!(text.contains("as much as an improvement does"), "{text}");
4968 assert!(!text.contains("a genuine improvement"), "{text}");
4969 }
4970 }
4971
4972 #[test]
4975 fn doubt_resolves_away_from_the_field_that_gates() {
4976 for text in [REVIEW_PROMPT.to_string(), schema::review().to_string()] {
4977 assert!(text.contains("say your piece in the finding and label it non-blocking"));
4978 assert!(!text.contains("leave in_scope true"));
4979 }
4980 }
4981
4982 #[test]
4985 fn both_edit_prompts_ask_for_the_smallest_change_that_answers_the_point() {
4986 for prompt in [FIX_PROMPT, RESPOND_PROMPT] {
4987 assert!(
4988 prompt.contains("The smallest change that answers it is"),
4989 "{prompt}"
4990 );
4991 }
4992 assert!(RESPOND_PROMPT.contains("bigger than the\n problem it names"));
4993 }
4994
4995 #[test]
4996 fn a_fixed_disposition_must_explain_what_changed() {
4997 let flat = RESPOND_PROMPT
4998 .split_whitespace()
4999 .collect::<Vec<_>>()
5000 .join(" ");
5001 assert!(
5002 flat.contains("For fixed, say what changed and how it answers the point"),
5003 "{RESPOND_PROMPT}"
5004 );
5005 }
5006
5007 #[test]
5008 fn an_empty_fix_reason_still_renders_as_a_claim_to_check() {
5009 let mut ledger = ledger_with("Unchecked error", "src/net.rs");
5010 for entry in ledger.values_mut() {
5011 entry.outcome = Settled::Fixed;
5012 entry.reasoning.clear();
5013 }
5014
5015 let lines = fixed_lines(&ledger, 0);
5016
5017 assert_eq!(1, lines.len());
5018 assert!(lines[0].contains("a committed change claims to address this point"));
5019 assert!(!lines[0].contains("The author said"));
5020 }
5021
5022 #[test]
5027 fn the_implementor_is_given_the_link_and_the_body() {
5028 let prompt = implement_prompt(
5029 42,
5030 "Retry a 429",
5031 "https://github.com/o/r/issues/42",
5032 "A rate limited response was treated as fatal.",
5033 );
5034 assert!(
5035 prompt.contains("https://github.com/o/r/issues/42"),
5036 "{prompt}"
5037 );
5038 assert!(
5039 prompt.contains("A rate limited response was treated as fatal."),
5040 "{prompt}"
5041 );
5042 assert!(prompt.contains("#42"), "{prompt}");
5043 assert!(prompt.contains("Retry a 429"), "{prompt}");
5044 assert!(!prompt.contains('{'), "{prompt}");
5046 }
5047
5048 #[test]
5051 fn the_prompt_says_the_discussion_is_not_included() {
5052 let prompt = implement_prompt(1, "t", "u", "b");
5053 let lower = prompt
5055 .split_whitespace()
5056 .collect::<Vec<_>>()
5057 .join(" ")
5058 .to_lowercase();
5059 assert!(
5060 lower.contains("discussion since is not included"),
5061 "{prompt}"
5062 );
5063 assert!(lower.contains("cannot reach the network"), "{prompt}");
5064 }
5065
5066 fn worked() -> Implementation {
5068 Implementation {
5069 summary: "Retry a 429 instead of failing the run.".into(),
5070 problem: "A rate limited response was treated as fatal, so one throttled call ended \
5071 a run that had hours of work left in it."
5072 .into(),
5073 changes: vec![
5074 "`send` retries a 429 with the delay the header asks for".into(),
5075 "the retry budget is bounded, so a permanent 429 still ends".into(),
5076 ],
5077 testing: vec![
5078 "`cargo test retries_a_429`".into(),
5079 "point it at a throttled endpoint and watch it finish".into(),
5080 ],
5081 ..Implementation::default()
5082 }
5083 }
5084
5085 #[test]
5086 fn a_pr_body_is_what_it_closes_and_what_changed() {
5089 let body = pr_body(42, &worked(), &style());
5090 assert_eq!(
5091 "Closes #42\n\n\
5092 Retry a 429 instead of failing the run.\n\n\
5093 A rate limited response was treated as fatal, so one throttled call \
5094 ended a run that had hours of work left in it.\n\n\
5095 ## What changed\n\n\
5096 - `send` retries a 429 with the delay the header asks for\n\
5097 - the retry budget is bounded, so a permanent 429 still ends\n\n\
5098 ## How to test\n\n\
5099 - `cargo test retries_a_429`\n\
5100 - point it at a throttled endpoint and watch it finish",
5101 body
5102 );
5103 }
5104
5105 #[test]
5108 fn a_body_with_nothing_to_list_carries_no_empty_headings() {
5109 let work = Implementation {
5110 summary: "Retry a 429 instead of failing the run.".into(),
5111 ..Implementation::default()
5112 };
5113 assert_eq!(
5114 "Closes #42\n\nRetry a 429 instead of failing the run.",
5115 pr_body(42, &work, &style())
5116 );
5117 }
5118
5119 #[test]
5120 fn a_pr_body_survives_an_implementor_that_said_nothing() {
5121 assert_eq!(
5122 "Closes #7",
5123 pr_body(7, &Implementation::default(), &style())
5124 );
5125 }
5126
5127 #[test]
5130 fn blank_list_entries_do_not_earn_a_heading() {
5131 let work = Implementation {
5132 summary: "Did a thing.".into(),
5133 changes: vec![String::new(), " ".into()],
5134 ..Implementation::default()
5135 };
5136 let body = pr_body(42, &work, &style());
5137 assert!(!body.contains("What changed"), "{body}");
5138 }
5139
5140 #[test]
5141 fn notes_appear_only_when_there_is_something_to_note() {
5142 let mut work = worked();
5143 assert!(!pr_body(42, &work, &style()).contains("## Notes"));
5144 work.notes = Some("The retry is not applied to streaming calls.".into());
5145 let body = pr_body(42, &work, &style());
5146 assert!(body.contains("## Notes"), "{body}");
5147 assert!(body.contains("streaming calls"), "{body}");
5148 }
5149
5150 #[test]
5153 fn declining_posts_the_reason_and_not_the_summary() {
5154 let work = Implementation {
5155 not_worth_doing: true,
5156 reason: "Already fixed in 1.2, and the report predates it.".into(),
5157 summary: "Nothing to do.".into(),
5158 ..Implementation::default()
5159 };
5160 assert_eq!(
5161 "Already fixed in 1.2, and the report predates it.",
5162 no_pr_note(&work, &style())
5163 );
5164 }
5165
5166 #[test]
5167 fn reporting_work_and_committing_none_says_that_rather_than_the_summary() {
5168 let work = Implementation {
5169 summary: "Retry a 429 instead of failing the run.".into(),
5170 ..Implementation::default()
5171 };
5172 let note = no_pr_note(&work, &style());
5173 assert_eq!(
5174 "Nothing was committed, so there is nothing to review.",
5175 note
5176 );
5177 }
5178
5179 #[test]
5180 fn declining_without_a_reason_still_says_something() {
5181 let work = Implementation {
5182 not_worth_doing: true,
5183 ..Implementation::default()
5184 };
5185 assert!(no_pr_note(&work, &style()).contains("no reason given"));
5186 }
5187
5188 #[test]
5189 fn a_skip_comment_is_only_the_reasoning() {
5190 let item = SkippedItem {
5191 issue: 3,
5192 title: "t".into(),
5193 tracker: false,
5194 reasons: [
5195 ("claude".to_string(), "Already fixed in 1.2.".to_string()),
5196 ("codex".to_string(), "Duplicate of #2.".to_string()),
5197 ]
5198 .into_iter()
5199 .collect(),
5200 };
5201 let text = skip_comment(&item, &style());
5202 assert!(text.contains("Already fixed in 1.2."), "{text}");
5203 assert!(text.contains("Duplicate of #2."), "{text}");
5204 assert!(
5205 !text.contains("claude") && !text.contains("codex"),
5206 "{text}"
5207 );
5208 assert!(!text.to_lowercase().contains("not scheduled"), "{text}");
5209 assert!(text.lines().count() <= 3, "{text}");
5210 }
5211
5212 #[test]
5213 fn findings_for_a_model_keep_full_detail() {
5214 let long = "x".repeat(2000);
5215 let text = findings_for_prompt(&[finding("blocking", "T", &long, "a.rs", true)]);
5216 assert!(
5217 text.contains(&long),
5218 "a model needs the whole finding, only humans need brevity"
5219 );
5220 }
5221
5222 #[test]
5223 fn findings_for_a_model_are_never_empty() {
5224 assert_eq!("(none)", findings_for_prompt(&[]));
5225 }
5226}
5227
5228#[cfg(test)]
5229mod outcome_tests {
5230 use super::*;
5231 use crate::model::{Dispute, Severity};
5232
5233 fn style() -> Style {
5234 Style::default()
5235 }
5236
5237 fn state_with(disputes: Vec<(&str, &str)>, filed: Vec<&str>) -> IssueRun {
5238 let mut s = IssueRun::new(482, "t");
5239 s.disputes = disputes
5240 .into_iter()
5241 .map(|(title, reasoning)| Dispute {
5242 title: title.into(),
5243 file: String::new(),
5244 reasoning: reasoning.into(),
5245 })
5246 .collect();
5247 s.filed = filed.into_iter().map(String::from).collect();
5248 s
5249 }
5250
5251 fn finding(title: &str, file: &str) -> Finding {
5252 Finding {
5253 severity: Severity::Blocking,
5254 title: title.into(),
5255 detail: "d".into(),
5256 file: file.into(),
5257 in_scope: true,
5258 ..Default::default()
5259 }
5260 }
5261
5262 fn graded(severity: Severity, title: &str, file: &str, in_scope: bool) -> Finding {
5263 Finding {
5264 severity,
5265 in_scope,
5266 ..finding(title, file)
5267 }
5268 }
5269
5270 #[test]
5271 fn outcome_mode_routes_final_results_to_the_configured_sink() {
5272 assert_eq!(OutcomeSink::PullRequest, outcome_sink(PrComments::Outcome));
5273 assert_eq!(OutcomeSink::PullRequest, outcome_sink(PrComments::Rounds));
5274 assert_eq!(OutcomeSink::Terminal, outcome_sink(PrComments::None));
5275 }
5276
5277 #[test]
5280 fn a_clean_approval_says_nothing() {
5281 let state = state_with(vec![], vec![]);
5282 assert!(outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).is_none());
5283 }
5284
5285 #[test]
5290 fn a_downgraded_finding_still_reaches_the_pull_request() {
5291 let mut state = state_with(vec![], vec![]);
5292 let kept = graded(
5293 Severity::NonBlocking,
5294 "Timeout is not configurable",
5295 "n.rs",
5296 true,
5297 );
5298 record_nonblocking_outcome(&mut state, &kept, None);
5299
5300 assert_eq!(1, state.noted.len());
5303
5304 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
5305 assert!(text.contains("Noted, not blocking"), "{text}");
5306 assert!(
5307 text.contains("Timeout is not configurable (n.rs)"),
5308 "{text}"
5309 );
5310 }
5311
5312 #[test]
5315 fn a_point_that_was_filed_is_not_also_noted() {
5316 let mut state = state_with(vec![], vec![]);
5317 let finding = graded(Severity::NonBlocking, "Timeout", "n.rs", true);
5318 record_nonblocking_outcome(&mut state, &finding, None);
5319 record_nonblocking_outcome(
5320 &mut state,
5321 &finding,
5322 Some(&Followup::Recorded("https://example.invalid/9".into())),
5323 );
5324 assert!(state.noted.is_empty());
5325 assert_eq!(vec!["https://example.invalid/9"], state.filed);
5326 }
5327
5328 #[test]
5329 fn filing_a_moved_point_removes_its_earlier_note() {
5330 let mut state = state_with(vec![], vec![]);
5331 let earlier = graded(Severity::NonBlocking, "Timeout", "src/net.rs:10", true);
5332 let moved = graded(Severity::NonBlocking, "Timeout", "src/net.rs:12", false);
5333 record_nonblocking_outcome(&mut state, &earlier, None);
5334
5335 record_nonblocking_outcome(
5336 &mut state,
5337 &moved,
5338 Some(&Followup::Recorded("https://example.invalid/10".into())),
5339 );
5340
5341 assert!(state.noted.is_empty());
5342 assert_eq!(vec!["https://example.invalid/10"], state.filed);
5343 }
5344
5345 #[test]
5346 fn an_unrecorded_nonblocking_followup_remains_noted() {
5347 let finding = graded(Severity::NonBlocking, "Timeout", "n.rs", true);
5348 for outcome in [
5349 Followup::Covered("https://example.invalid/closed".into()),
5350 Followup::Dropped("follow-ups are off"),
5351 Followup::Failed,
5352 ] {
5353 let mut state = state_with(vec![], vec![]);
5354 record_nonblocking_outcome(&mut state, &finding, Some(&outcome));
5355 assert_eq!(1, state.noted.len(), "{outcome:?}");
5356 assert!(state.filed.is_empty(), "{outcome:?}");
5357 }
5358 }
5359
5360 #[test]
5362 fn a_point_noted_twice_is_listed_once() {
5363 let mut state = state_with(vec![], vec![]);
5364 let raised = graded(
5365 Severity::NonBlocking,
5366 "Timeout is not configurable",
5367 "n.rs",
5368 true,
5369 );
5370 let reworded = graded(
5371 Severity::NonBlocking,
5372 "timeout is not configurable!",
5373 "n.rs",
5374 true,
5375 );
5376 record_nonblocking_outcome(&mut state, &raised, None);
5377 record_nonblocking_outcome(&mut state, &reworded, None);
5378 assert_eq!(1, state.noted.len());
5379 }
5380
5381 #[test]
5382 fn same_title_notes_in_different_files_are_both_kept() {
5383 let mut state = state_with(vec![], vec![]);
5384 for file in ["src/a.rs", "src/b.rs"] {
5385 let finding = graded(Severity::NonBlocking, "Unchecked error", file, true);
5386 record_nonblocking_outcome(&mut state, &finding, None);
5387 }
5388 assert_eq!(2, state.noted.len());
5389 }
5390
5391 #[test]
5392 fn same_title_notes_at_two_sites_in_one_review_are_both_kept() {
5393 let findings = vec![
5394 graded(
5395 Severity::NonBlocking,
5396 "Unchecked error",
5397 "src/a.rs:10",
5398 true,
5399 ),
5400 graded(
5401 Severity::NonBlocking,
5402 "Unchecked error",
5403 "src/a.rs:200",
5404 true,
5405 ),
5406 ];
5407 let mut state = state_with(vec![], vec![]);
5408
5409 for finding in &findings {
5410 record_nonblocking_outcome_with_match(
5411 &mut state,
5412 finding,
5413 None,
5414 unique_stable_finding(&findings, finding),
5415 );
5416 }
5417
5418 assert_eq!(2, state.noted.len());
5419 }
5420
5421 #[test]
5422 fn settling_one_of_two_same_title_notes_keeps_the_other() {
5423 let first = graded(
5424 Severity::NonBlocking,
5425 "Unchecked error",
5426 "src/a.rs:10",
5427 true,
5428 );
5429 let second = graded(
5430 Severity::NonBlocking,
5431 "Unchecked error",
5432 "src/a.rs:200",
5433 true,
5434 );
5435 let mut state = state_with(vec![], vec![]);
5436 remember_noted(&mut state, &first, false);
5437 remember_noted(&mut state, &second, false);
5438
5439 forget_noted(&mut state, &first, false);
5440
5441 assert_eq!(1, state.noted.len());
5442 assert_eq!("src/a.rs:200", state.noted[0].file);
5443 }
5444
5445 #[test]
5446 fn same_title_disputes_at_two_sites_are_both_kept() {
5447 let mut state = state_with(vec![], vec![]);
5448 for file in ["src/a.rs:10", "src/a.rs:200"] {
5449 remember_dispute(
5450 &mut state,
5451 Dispute {
5452 title: "Unchecked error".into(),
5453 file: file.into(),
5454 reasoning: "the caller handles it".into(),
5455 },
5456 false,
5457 );
5458 }
5459
5460 assert_eq!(2, state.disputes.len());
5461 }
5462
5463 #[test]
5464 fn a_later_nonblocking_verdict_replaces_a_prior_dispute() {
5465 let mut state = state_with(vec![], vec![]);
5466 let finding = graded(Severity::NonBlocking, "Unchecked error", "src/a.rs", true);
5467 remember_dispute(
5468 &mut state,
5469 Dispute {
5470 title: finding.title.clone(),
5471 file: finding.file.clone(),
5472 reasoning: "the caller handles it".into(),
5473 },
5474 true,
5475 );
5476
5477 record_nonblocking_outcome(&mut state, &finding, None);
5478
5479 assert!(state.disputes.is_empty());
5480 assert_eq!(1, state.noted.len());
5481 }
5482
5483 #[test]
5484 fn a_note_moving_lines_in_the_same_file_is_updated() {
5485 let mut state = state_with(vec![], vec![]);
5486 let first = graded(
5487 Severity::NonBlocking,
5488 "Unchecked error",
5489 "src/a.rs:12",
5490 true,
5491 );
5492 let moved = graded(
5493 Severity::NonBlocking,
5494 "Unchecked error",
5495 "src/a.rs:19",
5496 true,
5497 );
5498 record_nonblocking_outcome(&mut state, &first, None);
5499 record_nonblocking_outcome(&mut state, &moved, None);
5500 assert_eq!(1, state.noted.len());
5501 assert_eq!("src/a.rs:19", state.noted[0].file);
5502 }
5503
5504 #[test]
5505 fn a_settled_point_removes_its_stale_note() {
5506 for outcome in [Settled::Fixed, Settled::Refuted, Settled::Filed] {
5507 let noted = graded(Severity::NonBlocking, "Unchecked error", "src/a.rs", true);
5508 let mut state = state_with(vec![], vec![]);
5509 record_nonblocking_outcome(&mut state, ¬ed, None);
5510 forget_noted(&mut state, ¬ed, true);
5511 assert!(state.noted.is_empty(), "{outcome}");
5512 }
5513 }
5514
5515 #[test]
5516 fn settling_a_moved_point_removes_its_old_dispute() {
5517 let old = graded(Severity::Blocking, "Unchecked error", "src/a.rs:10", true);
5518 let moved = graded(Severity::Blocking, "Unchecked error", "src/a.rs:12", true);
5519 let mut state = state_with(vec![], vec![]);
5520 remember_dispute(
5521 &mut state,
5522 Dispute {
5523 title: old.title,
5524 file: old.file,
5525 reasoning: "the caller handles it".into(),
5526 },
5527 true,
5528 );
5529
5530 forget_dispute(&mut state, &moved, true);
5531
5532 assert!(state.disputes.is_empty());
5533 }
5534
5535 #[test]
5538 fn a_deadlocked_point_is_not_also_noted() {
5539 let mut state = state_with(vec![], vec![]);
5540 let noted = graded(Severity::NonBlocking, "Unbounded loop", "x.rs", true);
5541 record_nonblocking_outcome(&mut state, ¬ed, None);
5542 let points = vec![finding("Unbounded loop", "x.rs")];
5543 let text = outcome_comment(
5544 &state,
5545 &Ledger::new(),
5546 &Ending::Deadlocked(&points),
5547 &style(),
5548 )
5549 .unwrap();
5550 assert!(!text.contains("Noted, not blocking"), "{text}");
5551 }
5552
5553 #[test]
5554 fn clipping_a_rendered_title_does_not_break_duplicate_suppression() {
5555 let mut compact = style();
5556 compact.max_title_chars = 5;
5557 let mut state = state_with(
5558 vec![("abcdefghij", "the caller already handles it")],
5559 vec![],
5560 );
5561 state
5562 .noted
5563 .push(graded(Severity::NonBlocking, "abcdefghij", "x.rs", true));
5564 state.disputes[0].file = "x.rs".into();
5565 let points = vec![finding("abcdefghij", "x.rs")];
5566
5567 let text = outcome_comment(
5568 &state,
5569 &Ledger::new(),
5570 &Ending::Unresolved(&points),
5571 &compact,
5572 )
5573 .unwrap();
5574
5575 assert!(!text.contains("Raised and refuted"), "{text}");
5576 assert!(!text.contains("Noted, not blocking"), "{text}");
5577 }
5578
5579 #[test]
5580 fn an_approval_that_filed_follow_ups_links_them() {
5581 let state = state_with(
5582 vec![],
5583 vec![
5584 "https://github.com/you/thing/issues/485",
5585 "https://github.com/you/thing/issues/486",
5586 ],
5587 );
5588 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
5589 assert!(text.contains("Filed separately: #485, #486"), "{text}");
5590 }
5591
5592 #[test]
5596 fn a_run_that_changed_nothing_does_not_claim_there_is_something_to_read() {
5597 let state = state_with(vec![], vec![]);
5598 let text = outcome_comment(&state, &Ledger::new(), &Ending::Unchanged, &style()).unwrap();
5599 assert!(text.contains("changed nothing"), "{text}");
5600 assert!(!text.contains("was pushed"), "{text}");
5601 }
5602
5603 #[test]
5607 fn an_unresolved_close_names_what_is_still_wrong() {
5608 let state = state_with(vec![], vec![]);
5609 let left = vec![Finding {
5610 detail: "The guard sits after the early return.".into(),
5611 ..finding("The retry fix never reaches the 429 path", "src/net.rs:88")
5612 }];
5613 let text =
5614 outcome_comment(&state, &Ledger::new(), &Ending::Unresolved(&left), &style()).unwrap();
5615 assert!(text.contains("These points are still open"), "{text}");
5616 assert!(
5617 text.contains("The retry fix never reaches the 429 path (src/net.rs:88)"),
5618 "{text}"
5619 );
5620 assert!(
5621 text.contains("The guard sits after the early return."),
5622 "{text}"
5623 );
5624 assert!(!text.contains("has not been reviewed"), "{text}");
5627 }
5628
5629 #[test]
5633 fn running_out_of_rounds_says_what_that_means_for_the_reader() {
5634 let state = state_with(vec![], vec![]);
5635 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
5636 assert!(text.contains("has not been reviewed"), "{text}");
5637 assert!(
5638 !text.to_lowercase().contains("round 3"),
5639 "no round numbers: {text}"
5640 );
5641 assert!(!text.to_lowercase().contains("convergence"), "{text}");
5642 }
5643
5644 #[test]
5645 fn a_failed_close_reports_unread_fixes_and_carried_blockers() {
5646 let state = state_with(vec![], vec![]);
5647 let open = vec![Finding {
5648 detail: "the failure is still discarded".into(),
5649 ..finding("Unchecked error", "src/net.rs:88")
5650 }];
5651 let text = outcome_comment_with_unread(
5652 &state,
5653 &Ledger::new(),
5654 &Ending::OutOfRounds,
5655 &open,
5656 &style(),
5657 )
5658 .unwrap();
5659 assert!(text.contains("has not been reviewed"), "{text}");
5660 assert!(text.contains("These points were already open"), "{text}");
5661 assert!(text.contains("Unchecked error (src/net.rs:88)"), "{text}");
5662 }
5663
5664 #[test]
5665 fn a_deadlock_names_the_point_they_could_not_settle() {
5666 let state = state_with(vec![], vec![]);
5667 let points = [finding("Retry loop never terminates", "src/net.rs:88")];
5668 let text = outcome_comment(
5669 &state,
5670 &Ledger::new(),
5671 &Ending::Deadlocked(&points),
5672 &style(),
5673 )
5674 .unwrap();
5675 assert!(
5676 text.contains("Retry loop never terminates (src/net.rs:88)"),
5677 "{text}"
5678 );
5679 assert!(text.contains("could not settle"), "{text}");
5680 }
5681
5682 #[test]
5684 fn refutations_survive_because_nothing_else_carries_them() {
5685 let state = state_with(
5686 vec![(
5687 "Error is swallowed",
5688 "the caller already validates the file",
5689 )],
5690 vec![],
5691 );
5692 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
5693 assert!(text.contains("Raised and refuted:"), "{text}");
5694 assert!(
5695 text.contains("The caller already validates the file"),
5696 "{text}"
5697 );
5698 }
5699
5700 #[test]
5701 fn no_agent_names_counts_or_round_numbers_reach_the_thread() {
5702 let state = state_with(
5703 vec![("A point", "a reason")],
5704 vec!["https://github.com/you/thing/issues/485"],
5705 );
5706 let left = vec![finding("A point", "a.rs")];
5707 for ending in [
5708 Ending::Approved,
5709 Ending::OutOfRounds,
5710 Ending::Unresolved(&left),
5711 ] {
5712 let text = outcome_comment(&state, &Ledger::new(), &ending, &style()).unwrap();
5713 let lower = text.to_lowercase();
5714 for banned in ["claude", "codex", "blocking,", "nit,", " fixed."] {
5715 assert!(
5716 !lower.contains(banned),
5717 "{banned:?} leaked into the thread:\n{text}"
5718 );
5719 }
5720 for n in 1..9 {
5722 assert!(
5723 !lower.contains(&format!("round {n}")),
5724 "a round number leaked into the thread:\n{text}"
5725 );
5726 }
5727 }
5728 }
5729
5730 #[test]
5731 fn a_refutation_is_allowed_to_make_its_case() {
5734 let reasoning = "The caller validates against the schema first. \
5735 The discarded error is therefore unreachable in practice. ";
5736 let state = state_with(
5737 vec![("A point", &reasoning.repeat(6))],
5738 vec!["https://github.com/you/thing/issues/485"],
5739 );
5740 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
5741 assert!(
5742 !text.contains("..."),
5743 "nothing was cut mid thought:\n{text}"
5744 );
5745 assert!(text.len() < 4000, "{} chars", text.len());
5746 }
5747
5748 #[test]
5749 fn a_url_that_is_not_an_issue_link_is_left_alone() {
5750 assert_eq!(
5751 "#485",
5752 as_reference("https://github.com/you/thing/issues/485")
5753 );
5754 assert_eq!("note: something", as_reference("note: something"));
5755 }
5756}
5757
5758#[cfg(test)]
5759mod filed_reference_tests {
5760 use super::*;
5761
5762 #[test]
5763 fn an_issue_url_yields_its_number() {
5764 assert_eq!(
5765 Some(485),
5766 filed_issue_number("https://github.com/you/thing/issues/485")
5767 );
5768 }
5769
5770 #[test]
5773 fn a_local_note_yields_nothing() {
5774 assert_eq!(None, filed_issue_number("note: Retry is unbounded"));
5775 assert_eq!(None, filed_issue_number(""));
5776 assert_eq!(
5777 None,
5778 filed_issue_number("https://github.com/you/thing/issues/")
5779 );
5780 }
5781}
5782
5783#[cfg(test)]
5784mod followup_restraint_tests {
5785 use super::*;
5786 use crate::model::Severity;
5787
5788 fn cfg_with(followups: Followups, non_blocking: bool, nits: bool, cap: usize) -> Config {
5789 let mut cfg =
5790 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
5791 .unwrap();
5792 cfg.loop_cfg.followups = followups;
5793 cfg.loop_cfg.file_non_blocking = non_blocking;
5794 cfg.loop_cfg.file_nits = nits;
5795 cfg.loop_cfg.max_followups = cap;
5796 cfg
5797 }
5798
5799 fn finding(severity: Severity, title: &str, in_scope: bool) -> Finding {
5800 Finding {
5801 severity,
5802 title: title.into(),
5803 detail: "d".into(),
5804 file: "a.rs".into(),
5805 in_scope,
5806 ..Default::default()
5807 }
5808 }
5809
5810 #[test]
5814 fn a_non_blocking_finding_is_not_a_tracker_item_by_default() {
5815 let cfg = cfg_with(Followups::Issues, false, false, 5);
5816 assert!(!cfg.loop_cfg.file_non_blocking);
5817 assert!(!cfg.loop_cfg.file_nits);
5818 }
5819
5820 #[test]
5821 fn follow_ups_stay_off_the_tracker_by_default() {
5822 let cfg =
5823 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
5824 .unwrap();
5825 assert_eq!(
5826 Followups::Local,
5827 cfg.loop_cfg.followups,
5828 "the tracker is somebody's queue; the default must not write to it"
5829 );
5830 assert_eq!(5, cfg.loop_cfg.max_followups);
5831 }
5832
5833 #[test]
5835 fn only_out_of_scope_defects_qualify_at_the_defaults() {
5836 let cfg = cfg_with(Followups::Issues, false, false, 5);
5837 let qualifies = |f: &Finding| match f.severity {
5838 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking && f.in_scope,
5839 Severity::Nit => cfg.loop_cfg.file_nits && f.in_scope,
5840 Severity::Blocking => false,
5841 } || !f.in_scope;
5842
5843 assert!(qualifies(&finding(
5844 Severity::Blocking,
5845 "pre-existing",
5846 false
5847 )));
5848 assert!(!qualifies(&finding(
5849 Severity::NonBlocking,
5850 "improvement",
5851 true
5852 )));
5853 assert!(!qualifies(&finding(Severity::Nit, "taste", true)));
5854 assert!(!qualifies(&finding(
5855 Severity::Blocking,
5856 "fix it here",
5857 true
5858 )));
5859 }
5860
5861 #[test]
5862 fn opening_it_up_lets_non_blocking_findings_through_again() {
5863 let cfg = cfg_with(Followups::Issues, true, false, 5);
5864 assert!(cfg.loop_cfg.file_non_blocking);
5865 }
5866
5867 #[test]
5869 fn the_cap_is_a_real_backstop() {
5870 let cfg = cfg_with(Followups::Issues, false, false, 3);
5871 let mut state = IssueRun::new(1, "t");
5872 state.filed = (0..3).map(|n| format!("url{n}")).collect();
5873 assert!(state.filed.len() >= cfg.loop_cfg.max_followups);
5874 }
5875
5876 #[test]
5880 fn the_cap_bounds_what_one_run_can_spawn() {
5881 let cfg = cfg_with(Followups::Issues, false, false, 5);
5882 assert!(
5883 cfg.loop_cfg.max_followups <= 5,
5884 "a run that can file ten follow-ups is a branching process"
5885 );
5886 }
5887}
5888
5889#[cfg(test)]
5893mod followup_outcome_tests {
5894 use super::*;
5895
5896 const URL: &str = "https://github.com/you/thing/issues/485";
5897
5898 fn entry(recorded: Followup) -> Option<(Settled, String)> {
5899 filed_entry(&recorded, "It predates this branch.")
5900 }
5901
5902 #[test]
5906 fn a_failed_followup_settles_nothing() {
5907 assert_eq!(None, entry(Followup::Failed));
5908 }
5909
5910 #[test]
5911 fn an_uncertain_external_write_blocks_later_issue_followups_for_this_run() {
5912 let mut state = IssueRun::new(1, "review");
5913 let uncertain = SparError::uncertain_write("the result could not be verified");
5914 assert_eq!(Followup::Failed, failed_followup(&mut state, &uncertain));
5915 assert!(external_followup_write_paused(Followups::Issues, &state));
5916 assert!(!external_followup_write_paused(Followups::Local, &state));
5917 assert_eq!(1, state.notes.len());
5918
5919 assert_eq!(Followup::Failed, failed_followup(&mut state, &uncertain));
5920 assert_eq!(1, state.notes.len(), "the recovery note was duplicated");
5921
5922 let mut ordinary = IssueRun::new(2, "review");
5923 let error = SparError::new("permission denied");
5924 assert_eq!(Followup::Failed, failed_followup(&mut ordinary, &error));
5925 assert!(!external_followup_write_paused(
5926 Followups::Issues,
5927 &ordinary
5928 ));
5929 }
5930
5931 #[test]
5932 fn a_recorded_followup_is_filed_and_says_where() {
5933 let (outcome, reasoning) = entry(Followup::Recorded(URL.into())).unwrap();
5934 assert_eq!(Settled::Filed, outcome);
5935 assert!(
5936 reasoning.contains("It predates this branch."),
5937 "{reasoning}"
5938 );
5939 assert!(reasoning.contains("#485"), "{reasoning}");
5940 }
5941
5942 #[test]
5945 fn a_closed_issue_covering_the_point_settles_it_without_offering_work() {
5946 let recorded = Followup::from(Filed::AlreadyClosed(9, URL.into()));
5947 assert_eq!(Followup::Covered(URL.into()), recorded);
5948 assert_eq!(
5949 None,
5950 recorded.url(),
5951 "a closed issue is not work to pick up"
5952 );
5953
5954 let (outcome, reasoning) = entry(recorded).unwrap();
5955 assert_eq!(Settled::Filed, outcome);
5956 assert!(reasoning.contains("#485"), "{reasoning}");
5957 }
5958
5959 #[test]
5962 fn an_open_issue_that_already_covers_the_point_is_still_a_reference() {
5963 for filed in [
5964 Filed::Opened(9, URL.into()),
5965 Filed::AddedTo(9, URL.into()),
5966 Filed::Covered(9, URL.into()),
5967 ] {
5968 assert_eq!(Some(URL), Followup::from(filed).url());
5969 }
5970 }
5971
5972 #[test]
5976 fn a_dropped_followup_is_settled_but_never_reported_as_filed() {
5977 let (outcome, reasoning) = entry(Followup::Dropped("follow-ups are off")).unwrap();
5978 assert_eq!(Settled::Dropped, outcome);
5979 assert!(reasoning.contains("follow-ups are off"), "{reasoning}");
5980 assert!(reasoning.contains("Not filed"), "{reasoning}");
5981 }
5982
5983 fn ledger_of(outcome: Settled, reasoning: &str) -> Ledger {
5984 let mut ledger = Ledger::new();
5985 ledger.insert(
5986 finding_key("A pre-existing leak", "src/x.rs"),
5987 LedgerEntry {
5988 title: "A pre-existing leak".into(),
5989 file: "src/x.rs".into(),
5990 reasoning: reasoning.into(),
5991 round: 1,
5992 reraised: 0,
5993 outcome,
5994 },
5995 );
5996 ledger
5997 }
5998
5999 #[test]
6003 fn the_settled_block_tells_a_filed_point_from_a_dropped_one() {
6004 let filed = settled_block(&ledger_of(Settled::Filed, "Tracked in #9."));
6005 assert!(filed.contains("out of scope here, and filed"), "{filed}");
6006
6007 let dropped = settled_block(&ledger_of(Settled::Dropped, "Not filed anywhere: off."));
6008 assert!(
6009 dropped.contains("out of scope here, and not filed"),
6010 "{dropped}"
6011 );
6012 assert!(dropped.contains("A pre-existing leak"), "{dropped}");
6013 }
6014
6015 #[test]
6018 fn a_deadlocked_point_that_was_never_filed_does_not_claim_to_be() {
6019 let points = [Finding {
6020 severity: Severity::Blocking,
6021 title: "A pre-existing leak".into(),
6022 detail: "d".into(),
6023 file: "src/x.rs".into(),
6024 in_scope: false,
6025 ..Default::default()
6026 }];
6027 let text = outcome_comment(
6028 &IssueRun::new(1, "t"),
6029 &ledger_of(Settled::Dropped, "Not filed anywhere: follow-ups are off."),
6030 &Ending::Deadlocked(&points),
6031 &Style::default(),
6032 )
6033 .unwrap();
6034 assert!(text.contains("not filed"), "{text}");
6035 assert!(!text.contains("Filed as out of scope"), "{text}");
6036 }
6037}
6038
6039#[cfg(test)]
6040mod issue_report_tests {
6041 use super::*;
6042 use crate::model::Severity;
6043
6044 fn reported() -> Finding {
6047 Finding {
6048 severity: Severity::Blocking,
6049 title: "sendPaymentAsync bypasses drain mode and spending limits".into(),
6050 detail: "The async path skips every admission check payInvoice applies.".into(),
6051 file: "src/node.ts:412".into(),
6052 in_scope: false,
6053 problem: Some(
6054 "`BeignetNode.sendPaymentAsync()` submits a payment directly to the Lightning \
6055 engine without applying the safeguards used by `payInvoice()`.\n\nThe async path \
6056 does not:\n\n- call `_checkDraining()`\n- call `_checkSpendLimit()`"
6057 .into(),
6058 ),
6059 reproduction: Some(
6060 "1. Create a `BeignetNode` with `dailySpendLimitSats: 1`.\n2. Enable drain mode.\n\
6061 3. Submit a 1,000 sat invoice.\n\nActual result:\n\n- The engine is called.\n\
6062 - `spentSats` remains 0."
6063 .into(),
6064 ),
6065 impact: Some(
6066 "An authorized client can submit async payments up to the available outbound \
6067 liquidity despite the configured limits."
6068 .into(),
6069 ),
6070 expected: Some(
6071 "- Reject new payments while draining.\n- Enforce the per-payment limit before \
6072 submission.\n- Cover both paths with regression tests.\n\nThis predates the \
6073 current branch."
6074 .into(),
6075 ),
6076 }
6077 }
6078
6079 #[test]
6080 fn a_reported_finding_becomes_a_bug_report() {
6081 let body = issue_report(&reported());
6082 for heading in [
6083 "## Problem",
6084 "## Reproduction",
6085 "## Impact",
6086 "## Expected behavior",
6087 ] {
6088 assert!(body.contains(heading), "missing {heading}:\n{body}");
6089 }
6090 let at = |h: &str| body.find(h).unwrap();
6092 assert!(at("## Problem") < at("## Reproduction"));
6093 assert!(at("## Reproduction") < at("## Impact"));
6094 assert!(at("## Impact") < at("## Expected behavior"));
6095 }
6096
6097 #[test]
6098 fn the_substance_survives_the_outbound_gates() {
6099 let repo_style = Style::default();
6100 let body = crate::style::issue_body(&issue_report(&reported()), &repo_style);
6101 for kept in [
6102 "_checkDraining()",
6103 "Actual result:",
6104 "outbound liquidity",
6105 "regression tests",
6106 "predates the current branch",
6107 ] {
6108 assert!(body.contains(kept), "the gate ate {kept:?}:\n{body}");
6109 }
6110 assert!(!body.contains("..."), "something was cut:\n{body}");
6111 }
6112
6113 #[test]
6116 fn an_ordinary_finding_is_still_just_its_detail() {
6117 let plain = Finding {
6118 severity: Severity::NonBlocking,
6119 title: "Name is vague".into(),
6120 detail: "The variable could say what it holds.".into(),
6121 file: "a.rs".into(),
6122 in_scope: true,
6123 ..Default::default()
6124 };
6125 assert_eq!(
6126 "The variable could say what it holds.",
6127 issue_report(&plain)
6128 );
6129 }
6130
6131 #[test]
6134 fn only_the_sections_that_were_written_appear() {
6135 let partial = Finding {
6136 problem: Some("The guard is inverted.".into()),
6137 expected: Some("It should reject rather than accept.".into()),
6138 ..reported()
6139 };
6140 let partial = Finding {
6141 reproduction: None,
6142 impact: None,
6143 ..partial
6144 };
6145 let body = issue_report(&partial);
6146 assert!(body.contains("## Problem") && body.contains("## Expected behavior"));
6147 assert!(!body.contains("## Reproduction"), "{body}");
6148 assert!(!body.contains("## Impact"), "{body}");
6149 }
6150
6151 #[test]
6154 fn the_summary_line_is_not_printed_twice() {
6155 let echoed = Finding {
6156 detail: "The guard is inverted so it rejects valid input.".into(),
6157 problem: Some("The guard is inverted so it rejects valid input.".into()),
6158 reproduction: None,
6159 impact: None,
6160 expected: None,
6161 ..reported()
6162 };
6163 let body = issue_report(&echoed);
6164 assert_eq!(1, body.matches("The guard is inverted").count(), "{body}");
6165 }
6166}