1use std::path::{Path, PathBuf};
21
22use crate::agent::{self, Agent};
23use crate::config::{Config, Drafts, Followups, PrComments};
24use crate::error::{Result, SparError};
25use crate::jsonx::finding_key;
26use crate::model::{
27 Action, Dispute, Finding, Followup, Implementation, Issue, IssueRun, Ledger, LedgerEntry,
28 NextAction, PersistedState, PlanItem, PrView, ResponseDoc, Review, Settled, Severity,
29 SkippedItem, Status, STATE_VERSION,
30};
31use crate::repo::Repo;
32use crate::style::{self, Style};
33use crate::{log, logdim, logwarn, schema, spar_err};
34
35const IMPLEMENT_PROMPT: &str = "\
40Implement GitHub issue #{number} in this repository.
41
42Title: {title}
43URL: {url}
44
45{body}
46
47That is the issue body as filed. The discussion since is not included, so read
48the thread at the URL above if the body leaves anything open. If you cannot
49reach the network, work from what is here.
50
51Do the work, then commit it on the current branch. Make focused commits with
52clear messages. Do not push, do not open a PR, and do not merge; the harness
53handles that.
54
55Then report it. Your answer becomes the pull request description, and the
56reviewer reads that cold, with nothing but the diff and a link to the issue:
57say what you found wrong, what the change does about it, and how they confirm
58it for themselves. Say what you actually ran, not what could be run.
59
60If after reading the code you conclude this issue should not be implemented,
61make no commits and set not_worth_doing, with the reason.";
62
63const REVIEW_PROMPT: &str = "\
64Review the changes on this branch against `{base}`. They implement issue
65#{number}: {title}
66
67Review thoroughly: correctness, edge cases, error handling, security, and
68whether the change actually resolves the issue. Read surrounding code, do not
69only read the diff.
70
71Label every finding by severity, and be honest about which is which:
72- blocking: the PR should not merge as is. Real defects only.
73- non-blocking: a genuine improvement that need not gate this PR.
74- nit: style or taste.
75
76Confirm anything you label blocking before you label it. Run the code,
77reproduce the failure, or point at the exact line that breaks, and say in the
78detail what you did to confirm it. When you need to run something to check a
79claim, write a scratch file and run that, rather than passing a long program on
80the command line: it is easier to read back, easier to rerun, and less likely to
81be refused by a sandbox or a safety filter part way through your work. An unverified blocking finding is worse than
82one you never raised: it stalls a good PR and teaches the author to stop
83believing you. If you suspect a problem but could not confirm it, say so and
84label it non-blocking.
85
86Set in_scope=false for a real defect that exists, that this PR did not cause, and
87that is worth somebody stopping to fix. Each one becomes a tracked item a
88maintainer has to read and triage, so the bar is a defect and not an observation.
89A thorough reviewer can always find something adjacent to what it is reading;
90that is not a reason to file it. If you are not sure it is worth a maintainer's
91time, leave in_scope true and say your piece in the finding.
92
93Reviewing one issue should not manufacture ten more. If you find yourself with
94several out of scope findings, keep the ones that would bite somebody and drop
95the rest.
96
97Then choose next_action:
98- merge: no blocking findings, the PR is good.
99- fix_myself: there are blocking findings and you will fix them directly.
100- hand_back: there are blocking findings the author should address.
101{settled}";
102
103const FIX_PROMPT: &str = "\
104You reviewed this branch and chose to fix the blocking findings yourself.
105Implement those fixes now and commit them.
106
107Your findings:
108{findings}
109
110Commit your changes. Do not push, do not merge.";
111
112const RESPOND_PROMPT: &str = "\
113Here is a review of your PR for issue #{number}.
114
115{findings}
116
117For each point, choose exactly one disposition:
118- fixed: the point is valid and in scope. Fix it and commit.
119- refuted: the point is wrong, or not worth acting on. Explain why. Refuting is
120 a legitimate outcome; do not accept a review comment you believe is incorrect
121 just to get the PR approved.
122- filed_issue: the point is valid but unrelated to this PR. Supply
123 new_issue_title and new_issue_body; the harness files it and skips duplicates.
124
125Copy each finding's title and file across exactly as given, so your answer can
126be matched back to the review.
127
128Commit any fixes. Do not push, do not merge.";
129
130#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct Snapshot {
141 pub head: String,
142 pub dirty: bool,
144}
145
146impl Snapshot {
147 pub fn landed_over(&self, before: &Snapshot) -> bool {
150 !self.head.is_empty() && self.head != before.head
151 }
152}
153
154pub fn snapshot(repo: &Repo, work_dir: &Path) -> Snapshot {
155 Snapshot {
156 head: repo
157 .git_try_at(Some(work_dir), &["rev-parse", "HEAD"])
158 .trim()
159 .to_string(),
160 dirty: !repo
161 .git_try_at(
162 Some(work_dir),
163 &["status", "--porcelain", "--untracked-files=no"],
164 )
165 .trim()
166 .is_empty(),
167 }
168}
169
170pub fn park(repo: &Repo, work_dir: &Path) -> Option<String> {
177 let saved = repo
178 .git_try_at(Some(work_dir), &["stash", "create"])
179 .trim()
180 .to_string();
181 (!saved.is_empty()).then_some(saved)
182}
183
184fn reset_saving(repo: &Repo, work_dir: &Path, target: &str) {
191 let parked = park(repo, work_dir);
192 if let Err(e) = repo.git_at(Some(work_dir), &["reset", "--hard", target]) {
193 logdim!("could not roll the working tree back: {e}");
194 return;
195 }
196 if let Some(saved) = parked {
197 logdim!("`git stash apply {saved}` puts the discarded changes back");
198 }
199}
200
201pub fn undo_edits(repo: &Repo, work_dir: &Path, before: &Snapshot) -> Snapshot {
212 let current = snapshot(repo, work_dir);
213 if before.head.is_empty() {
214 return current;
215 }
216 if current.landed_over(before) {
217 logdim!(
218 "the commits being rolled back are still at {}",
219 current.head
220 );
221 }
222 reset_saving(repo, work_dir, &before.head);
223 snapshot(repo, work_dir)
224}
225
226pub fn drop_uncommitted(repo: &Repo, work_dir: &Path) -> Snapshot {
233 let current = snapshot(repo, work_dir);
234 if !current.dirty || current.head.is_empty() {
235 return current;
236 }
237 reset_saving(repo, work_dir, ¤t.head);
238 snapshot(repo, work_dir)
239}
240
241fn should_release(cfg: &Config, status: Status) -> bool {
246 if !cfg.loop_cfg.worktrees || cfg.loop_cfg.keep_worktrees {
247 return false;
248 }
249 !matches!(status, Status::Escalated | Status::Error)
250}
251
252pub fn run_issue(
257 agents: &[Agent],
258 cfg: &Config,
259 repo: &Repo,
260 item: &PlanItem,
261 issue: &Issue,
262 ledger: &mut Ledger,
263) -> IssueRun {
264 if let Some(existing) = repo.open_pr_for_issue(item.issue) {
272 log!(
273 "#{}: {} is already open, continuing it instead of implementing again",
274 item.issue,
275 existing.url
276 );
277 return resume_pr(agents, cfg, repo, existing.number, None);
278 }
279
280 let mut state = IssueRun::new(item.issue, item.title.clone());
281 let base = cfg.base_branch().to_string();
282
283 let prepared = if cfg.loop_cfg.worktrees {
284 repo.worktree_add(item.issue, &base)
285 } else {
286 let branch = repo.branch_for_issue(item.issue);
287 let start = format!("origin/{base}");
288 repo.git(&["checkout", "-B", &branch, &start])
289 .map(|_| (repo.root().to_path_buf(), branch))
290 };
291
292 let (work_dir, branch) = match prepared {
293 Ok(pair) => pair,
294 Err(e) => {
295 state.status = Status::Error;
296 state.notes.push(e.to_string());
297 log!("#{} failed: {e}", item.issue);
298 return state;
299 }
300 };
301
302 let outcome = implement_and_review(
303 agents, cfg, repo, item, issue, ledger, &mut state, &work_dir, &branch,
304 );
305 if let Err(e) = outcome {
306 state.status = Status::Error;
307 state.notes.push(e.to_string());
308 log!("#{} failed: {e}", item.issue);
309 }
310
311 if should_release(cfg, state.status) {
312 repo.worktree_remove(item.issue);
313 }
314 state
315}
316
317#[allow(clippy::too_many_arguments)]
318fn implement_and_review(
319 agents: &[Agent],
320 cfg: &Config,
321 repo: &Repo,
322 item: &PlanItem,
323 issue: &Issue,
324 ledger: &mut Ledger,
325 state: &mut IssueRun,
326 work_dir: &Path,
327 branch: &str,
328) -> Result<()> {
329 let number = item.issue;
330 let holder = cfg.first_implementor.clone();
331 let implementor = agent::find(agents, &holder)?;
332 let base = cfg.base_branch().to_string();
333
334 log!("#{number}: {holder} implementing");
335 let (body, shortened) = issue.body_for_prompt(cfg.loop_cfg.max_issue_chars);
340 if shortened {
341 logwarn!(
342 "#{number}: the issue body was shortened to fit the prompt. Raise max_issue_chars if \
343 the rest matters."
344 );
345 }
346 let prompt = implement_prompt(number, &item.title, &issue.url, &body);
347 let answer: Result<Implementation> = implementor.ask_json(
348 &prompt,
349 &schema::implementation(),
350 work_dir,
351 cfg.effort_for_round(&implementor.spec, 1).as_deref(),
352 );
353
354 let mut work = match answer {
361 Ok(work) => work,
362 Err(e) if repo.has_changes(work_dir, &base) => {
363 logwarn!(
364 "#{number}: {holder} failed after committing: {e}\nContinuing from the commits, \
365 with a pull request body written from their messages."
366 );
367 state
368 .notes
369 .push(format!("{holder} failed after committing: {e}"));
370 from_commits(repo, work_dir, &base)
371 }
372 Err(e) => return Err(e),
373 };
374
375 if work.not_worth_doing || !repo.has_changes(work_dir, &base) {
376 state.status = Status::Abandoned;
377 let reason = no_pr_note(&work, &repo.style);
378 state.notes.push(reason.clone());
379 if let Err(e) = repo.comment_issue(number, &reason) {
380 logdim!("could not comment on #{number}: {e}");
381 }
382 return Ok(());
383 }
384
385 if work.summary.trim().is_empty() {
389 work.summary = item.title.clone();
390 }
391
392 repo.rewrite_commits_if_needed(work_dir, &base)?;
393 repo.push(work_dir, branch)?;
394
395 let pr = match repo.pr_for_branch(branch) {
396 Some(existing) => existing,
397 None => {
398 let body = pr_body(number, &work, &repo.style);
399 repo.create_pr(
400 work_dir,
401 branch,
402 &base,
403 &format!("{} (#{number})", item.title),
404 &body,
405 )?
406 }
407 };
408 state.pr = Some(pr.url.clone());
409 log!("#{number}: PR {}", pr.url);
410
411 let ctx = LoopCtx {
412 work_dir: work_dir.to_path_buf(),
413 branch: branch.to_string(),
414 pr_number: pr.number,
415 label: format!("#{number}"),
416 subject: number,
417 title: item.title.clone(),
418 start_round: 1,
419 holder: cfg.other(&holder),
420 release: Release::Issue(number),
421 };
422 review_loop(agents, cfg, repo, &ctx, state, ledger)
423}
424
425pub fn resume_pr(
436 agents: &[Agent],
437 cfg: &Config,
438 repo: &Repo,
439 pr_number: i64,
440 holder_override: Option<&str>,
441) -> IssueRun {
442 let failed = |e: SparError| {
443 log!("PR #{pr_number} failed: {e}");
444 let mut state = IssueRun::new(pr_number, format!("PR #{pr_number}"));
445 state.status = Status::Error;
446 state.notes.push(e.to_string());
447 state
448 };
449
450 let pr = match repo.pr_view(pr_number) {
451 Ok(pr) => pr,
452 Err(e) => return failed(e),
453 };
454
455 if pr.is_cross_repository {
460 log!("PR #{pr_number} comes from a fork, reviewing it without changing it");
461 return crate::review_only::review_pr(agents, cfg, repo, pr_number, false);
462 }
463
464 match resume_inner(agents, cfg, repo, pr, holder_override) {
465 Ok(state) => state,
466 Err(e) => failed(e),
467 }
468}
469
470fn resume_inner(
471 agents: &[Agent],
472 cfg: &Config,
473 repo: &Repo,
474 pr: PrView,
475 holder_override: Option<&str>,
476) -> Result<IssueRun> {
477 let pr_number = pr.number;
478 if !pr.is_open() {
479 return Err(spar_err!("PR #{pr_number} is {}", pr.state.to_lowercase()));
480 }
481
482 let subject = pr
483 .closing_issues_references
484 .first()
485 .map(|r| r.number)
486 .unwrap_or(pr_number);
487
488 let saved = repo.read_state(&pr);
489 let mut ledger: Ledger = saved.as_ref().map(|s| s.ledger.clone()).unwrap_or_default();
490 let start_round = saved.as_ref().map(|s| s.round + 1).unwrap_or(1);
491
492 let default_holder = cfg.other(&cfg.first_implementor);
493 let mut holder = holder_override
494 .map(str::to_string)
495 .or_else(|| saved.as_ref().map(|s| s.next_actor.clone()))
496 .unwrap_or_else(|| default_holder.clone());
497 if !cfg.has_agent(&holder) {
498 log!("state named unknown agent '{holder}', using {default_holder}");
499 holder = default_holder;
500 }
501
502 match &saved {
503 Some(_) => log!(
504 "PR #{pr_number}: resuming at round {start_round}, {} settled point(s), next up {holder}",
505 ledger.len()
506 ),
507 None => log!("PR #{pr_number}: no prior spar state, starting fresh with {holder}"),
508 }
509
510 let mut state = IssueRun::new(subject, pr.title.clone());
511 state.pr = Some(pr.url.clone());
512 if let Some(s) = &saved {
513 state.filed = s.filed.clone();
514 }
515
516 let (work_dir, branch) = repo.worktree_for_pr(&pr)?;
517 let ctx = LoopCtx {
518 work_dir,
519 branch,
520 pr_number,
521 label: format!("PR #{pr_number}"),
522 subject,
523 title: pr.title.clone(),
524 start_round,
525 holder,
526 release: Release::Pr(pr_number),
527 };
528
529 let outcome = review_loop(agents, cfg, repo, &ctx, &mut state, &mut ledger);
530 if let Err(e) = outcome {
531 state.status = Status::Error;
532 state.notes.push(e.to_string());
533 log!("PR #{pr_number} failed: {e}");
534 }
535 if should_release(cfg, state.status) {
536 repo.release_pr_worktree(pr_number);
537 }
538 Ok(state)
539}
540
541#[derive(Debug, Clone, Copy)]
546enum Release {
547 Issue(i64),
548 Pr(i64),
549}
550
551struct LoopCtx {
552 work_dir: PathBuf,
553 branch: String,
554 pr_number: i64,
555 label: String,
556 subject: i64,
557 title: String,
558 start_round: u32,
559 holder: String,
560 release: Release,
561}
562
563impl LoopCtx {
564 fn release(&self, repo: &Repo) {
565 match self.release {
566 Release::Issue(n) => repo.worktree_remove(n),
567 Release::Pr(n) => repo.release_pr_worktree(n),
568 }
569 }
570}
571
572fn review_loop(
573 agents: &[Agent],
574 cfg: &Config,
575 repo: &Repo,
576 ctx: &LoopCtx,
577 state: &mut IssueRun,
578 ledger: &mut Ledger,
579) -> Result<()> {
580 let base = cfg.base_branch().to_string();
581 let mut holder = ctx.holder.clone();
585
586 let (first, last_allowed) = round_window(ctx.start_round, cfg.loop_cfg.max_rounds);
592 let mut last_round = first.saturating_sub(1);
593
594 for round in first..=last_allowed {
595 last_round = round;
596 state.rounds = round;
597 let reviewer = agent::find(agents, &holder)?;
598 let effort = cfg.effort_for_round(&reviewer.spec, round);
599 log!(
600 "{}: round {round}, {holder} reviewing ({})",
601 ctx.label,
602 effort.as_deref().unwrap_or("default effort")
603 );
604
605 let prompt = REVIEW_PROMPT
606 .replace("{base}", &base)
607 .replace("{number}", &ctx.subject.to_string())
608 .replace("{title}", &ctx.title)
609 .replace("{settled}", &settled_block(ledger));
610 let before_review = snapshot(repo, &ctx.work_dir);
611 let review: Review = reviewer.review(
612 &base,
613 &prompt,
614 &schema::review(),
615 &ctx.work_dir,
616 effort.as_deref(),
617 )?;
618
619 let mut editor: Option<String> = None;
623 let review_wrote = snapshot(repo, &ctx.work_dir) != before_review;
624 if review_wrote {
625 logwarn!(
626 "{}: {holder} changed the branch while reviewing it, which the review prompt \
627 forbids. Rolling it back.",
628 ctx.label
629 );
630 if undo_edits(repo, &ctx.work_dir, &before_review).head != before_review.head {
631 state
632 .notes
633 .push(format!("{holder} committed during its own review"));
634 editor = Some(holder.clone());
635 }
636 }
637
638 let blocking: Vec<Finding> = review
639 .findings
640 .iter()
641 .filter(|f| f.blocks())
642 .cloned()
643 .collect();
644
645 if repo.style.pr_comments == PrComments::Rounds {
646 if let Err(e) = repo.comment_pr(
647 ctx.pr_number,
648 &review_comment(&holder, round, &review, &repo.style),
649 ) {
650 logdim!("could not post the review comment: {e}");
651 }
652 }
653
654 file_out_of_scope(repo, &review.findings, ctx.subject, state, cfg);
658 file_nonblocking(repo, &review.findings, ctx.subject, state, cfg);
659
660 if check_relitigation(ledger, &blocking, state) {
661 state.status = Status::Escalated;
662 post_outcome(
663 repo,
664 ctx.pr_number,
665 state,
666 ledger,
667 Ending::Deadlocked(&blocking),
668 );
669 persist(repo, ctx.pr_number, state, ledger, round, &holder);
670 return Ok(());
671 }
672
673 if approval_stands(&blocking, review_wrote) {
674 state.status = Status::Approved;
675 post_outcome(repo, ctx.pr_number, state, ledger, Ending::Approved);
676 persist(repo, ctx.pr_number, state, ledger, round, &holder);
677 if cfg.loop_cfg.drafts == Drafts::UntilApproved && repo.mark_ready(ctx.pr_number) {
681 log!("{}: out of draft", ctx.label);
682 }
683 if cfg.loop_cfg.auto_merge {
684 ctx.release(repo);
689 repo.merge_pr(ctx.pr_number)?;
690 state.status = Status::Merged;
691 repo.clear_state(ctx.pr_number); log!("{}: merged", ctx.label);
693 } else {
694 log!("{}: approved, awaiting human merge", ctx.label);
695 }
696 return Ok(());
697 }
698
699 if blocking.is_empty() {
700 logwarn!(
705 "{}: {holder} found nothing blocking on a branch it had changed itself, so the \
706 approval does not carry.",
707 ctx.label
708 );
709 state.notes.push(format!(
710 "{holder} passed the branch in round {round} after editing it; the edit was rolled \
711 back and the approval did not stand"
712 ));
713 } else if review.next_action == NextAction::FixMyself {
714 log!("{}: {holder} fixing its own findings", ctx.label);
715 let prompt = FIX_PROMPT.replace("{findings}", &findings_for_prompt(&blocking));
716 let before_fix = snapshot(repo, &ctx.work_dir);
717 reviewer.ask(&prompt, &ctx.work_dir, effort.as_deref())?;
718 match editor_after(repo, &ctx.work_dir, &before_fix, &ctx.label, &holder) {
719 Some(who) => editor = Some(who),
720 None => {
721 logwarn!(
724 "{}: {holder} said it would fix its own findings and committed nothing, \
725 so it keeps the pull request.",
726 ctx.label
727 );
728 state.notes.push(format!(
729 "{holder} chose to fix its own findings in round {round} and committed \
730 nothing"
731 ));
732 }
733 }
734 } else {
735 let author_name = cfg.other(&holder);
736 let author = agent::find(agents, &author_name)?;
737 log!(
738 "{}: handing {} finding(s) to {author_name}",
739 ctx.label,
740 blocking.len()
741 );
742 let prompt = RESPOND_PROMPT
743 .replace("{number}", &ctx.subject.to_string())
744 .replace("{findings}", &findings_for_prompt(&blocking));
745 let before_response = snapshot(repo, &ctx.work_dir);
746 let response: ResponseDoc = author.ask_json(
747 &prompt,
748 &schema::response(),
749 &ctx.work_dir,
750 cfg.effort_for_round(&author.spec, round).as_deref(),
751 )?;
752 if let Some(who) = editor_after(
753 repo,
754 &ctx.work_dir,
755 &before_response,
756 &ctx.label,
757 &author_name,
758 ) {
759 editor = Some(who);
760 } else if response
761 .dispositions
762 .iter()
763 .any(|d| d.action == Action::Fixed)
764 {
765 logwarn!(
766 "{}: {author_name} reported fixes but committed nothing, so the diff does not \
767 have them.",
768 ctx.label
769 );
770 }
771 apply_dispositions(
772 repo,
773 cfg,
774 &response,
775 &blocking,
776 ledger,
777 state,
778 round,
779 ctx.subject,
780 ctx.pr_number,
781 &author_name,
782 );
783 }
784
785 if editor.is_some() {
786 repo.rewrite_commits_if_needed(&ctx.work_dir, &base)?;
787 repo.push(&ctx.work_dir, &ctx.branch)?;
788 }
789 holder = next_reviewer(cfg, &holder, editor.as_deref());
790 persist(repo, ctx.pr_number, state, ledger, round, &holder);
791 }
792
793 state.status = Status::Escalated;
794 state
795 .notes
796 .push(exhausted_note(ctx.start_round, last_round));
797 post_outcome(repo, ctx.pr_number, state, ledger, Ending::OutOfRounds);
798 persist(repo, ctx.pr_number, state, ledger, last_round, &holder);
799 Ok(())
800}
801
802fn approval_stands(blocking: &[Finding], review_wrote: bool) -> bool {
810 blocking.is_empty() && !review_wrote
811}
812
813fn next_reviewer(cfg: &Config, reviewer: &str, editor: Option<&str>) -> String {
826 match editor {
827 Some(editor) => cfg.other(editor),
828 None => reviewer.to_string(),
829 }
830}
831
832fn editor_after(
840 repo: &Repo,
841 work_dir: &Path,
842 before: &Snapshot,
843 label: &str,
844 who: &str,
845) -> Option<String> {
846 let after = snapshot(repo, work_dir);
847 if after.dirty {
848 logwarn!(
849 "{label}: {who} left tracked files uncommitted. Only commits are pushed and the next \
850 review reads the tree, so they are discarded."
851 );
852 drop_uncommitted(repo, work_dir);
853 }
854 after.landed_over(before).then(|| who.to_string())
855}
856
857fn round_window(start_round: u32, budget: u32) -> (u32, u32) {
863 (start_round, start_round + budget.saturating_sub(1))
864}
865
866fn spent(start_round: u32, last_round: u32) -> (u32, u32) {
870 (last_round.saturating_sub(start_round) + 1, last_round)
871}
872
873fn exhausted_note(start_round: u32, last_round: u32) -> String {
874 let (this_run, total) = spent(start_round, last_round);
875 if this_run == total {
876 format!("no convergence after {this_run} rounds")
877 } else {
878 format!("no convergence after {this_run} more rounds ({total} in total)")
879 }
880}
881
882fn persist(
883 repo: &Repo,
884 pr_number: i64,
885 state: &IssueRun,
886 ledger: &Ledger,
887 round: u32,
888 next_actor: &str,
889) {
890 let payload = PersistedState {
891 version: STATE_VERSION,
892 round,
893 next_actor: next_actor.to_string(),
894 status: state.status,
895 ledger: ledger.clone(),
896 filed: state.filed.clone(),
897 };
898 if let Err(e) = repo.write_state(pr_number, &payload) {
899 logdim!("could not persist state for PR #{pr_number}: {e}");
900 }
901}
902
903fn settled_block(ledger: &Ledger) -> String {
908 if ledger.is_empty() {
909 return String::new();
910 }
911 let lines: Vec<String> = ledger
912 .values()
913 .map(|e| match e.outcome {
914 Settled::Refuted => format!("- {}: refuted because {}", e.title, e.reasoning),
915 Settled::Filed => format!(
916 "- {}: out of scope here, and filed. {}",
917 e.title, e.reasoning
918 ),
919 Settled::Dropped => format!(
920 "- {}: out of scope here, and not filed. {}",
921 e.title, e.reasoning
922 ),
923 })
924 .collect();
925 format!(
926 "\nThe following points were already raised and settled, by a refutation or by a \
927 follow-up issue. Treat them as settled. Do not raise them again unless you have new \
928 evidence:\n{}",
929 lines.join("\n")
930 )
931}
932
933fn settle(ledger: &mut Ledger, key: String, entry: LedgerEntry) {
938 let reraised = ledger.get(&key).map(|e| e.reraised).unwrap_or(0);
939 ledger.insert(key, LedgerEntry { reraised, ..entry });
940}
941
942fn check_relitigation(ledger: &mut Ledger, blocking: &[Finding], state: &mut IssueRun) -> bool {
945 let mut escalate = false;
946 for finding in blocking {
947 let key = finding_key(&finding.title, &finding.file);
948 if let Some(entry) = ledger.get_mut(&key) {
949 entry.reraised += 1;
950 if entry.reraised >= 2 {
951 state.notes.push(format!(
952 "'{}' was settled and re-raised twice; escalating.",
953 finding.title
954 ));
955 escalate = true;
956 }
957 }
958 }
959 escalate
960}
961
962fn normalise(text: &str) -> String {
963 text.to_lowercase()
964 .chars()
965 .filter(|c| c.is_ascii_alphanumeric() || c.is_whitespace())
966 .collect::<String>()
967 .split_whitespace()
968 .collect::<Vec<_>>()
969 .join(" ")
970}
971
972pub(crate) fn same_point(a: &str, b: &str) -> bool {
977 normalise(a) == normalise(b)
978}
979
980fn matching_finding<'a>(findings: &'a [Finding], title: &str) -> Option<&'a Finding> {
981 let wanted = normalise(title);
982 findings.iter().find(|f| normalise(&f.title) == wanted)
983}
984
985#[allow(clippy::too_many_arguments)]
986fn apply_dispositions(
987 repo: &Repo,
988 cfg: &Config,
989 response: &ResponseDoc,
990 blocking: &[Finding],
991 ledger: &mut Ledger,
992 state: &mut IssueRun,
993 round: u32,
994 subject: i64,
995 pr_number: i64,
996 author: &str,
997) {
998 let mut fixed = Vec::new();
999 let mut refuted = Vec::new();
1000 let mut filed = Vec::new();
1001
1002 for d in &response.dispositions {
1003 let source = matching_finding(blocking, &d.title);
1004 let file = source
1005 .map(|f| f.file.clone())
1006 .filter(|f| !f.trim().is_empty())
1007 .unwrap_or_else(|| d.file.clone());
1008 let canonical = source.map(|f| f.title.as_str()).unwrap_or(d.title.as_str());
1015 let title = style::title(canonical, &repo.style);
1016
1017 match d.action {
1018 Action::Refuted => {
1019 let reasoning = style::summary(&d.reasoning, &repo.style);
1020 settle(
1021 ledger,
1022 finding_key(canonical, &file),
1023 LedgerEntry {
1024 title: title.clone(),
1025 file: file.clone(),
1026 reasoning: reasoning.clone(),
1027 round,
1028 reraised: 0,
1029 outcome: Settled::Refuted,
1030 },
1031 );
1032 state.disputes.push(Dispute {
1033 title: title.clone(),
1034 reasoning: reasoning.clone(),
1035 });
1036 refuted.push(format!("{title}. {reasoning}"));
1037 }
1038 Action::FiledIssue => {
1039 let new_title = d
1040 .new_issue_title
1041 .clone()
1042 .filter(|t| !t.trim().is_empty())
1043 .unwrap_or_else(|| d.title.clone());
1044 let new_body = d
1045 .new_issue_body
1046 .clone()
1047 .filter(|b| !b.trim().is_empty())
1048 .unwrap_or_else(|| d.reasoning.clone());
1049 let recorded = file_followup(repo, &new_title, &new_body, subject, cfg, state);
1050 if let Some(url) = recorded.url() {
1051 state.filed.push(url.to_string());
1052 filed.push(url.to_string());
1053 }
1054 let Some((outcome, reasoning)) =
1063 filed_entry(&recorded, &style::summary(&d.reasoning, &repo.style))
1064 else {
1065 logwarn!(
1066 "'{title}' was not recorded anywhere, so it stays open for the next round"
1067 );
1068 continue;
1069 };
1070 settle(
1071 ledger,
1072 finding_key(canonical, &file),
1073 LedgerEntry {
1074 title: title.clone(),
1075 file: file.clone(),
1076 reasoning,
1077 round,
1078 reraised: 0,
1079 outcome,
1080 },
1081 );
1082 }
1083 Action::Fixed => fixed.push(title),
1084 }
1085 }
1086
1087 if repo.style.pr_comments == PrComments::Rounds {
1088 let comment = disposition_comment(author, response, &fixed, &refuted, &filed, &repo.style);
1089 if let Some(text) = comment {
1090 if let Err(e) = repo.comment_pr(pr_number, &text) {
1091 logdim!("could not post the disposition comment: {e}");
1092 }
1093 }
1094 }
1095}
1096
1097fn filed_entry(recorded: &Followup, reasoning: &str) -> Option<(Settled, String)> {
1105 let (outcome, tail) = match recorded {
1106 Followup::Recorded(reference) => (
1107 Settled::Filed,
1108 format!("Tracked in {}.", as_reference(reference)),
1109 ),
1110 Followup::Covered(reference) => (
1111 Settled::Filed,
1112 format!("Already covered by {}.", as_reference(reference)),
1113 ),
1114 Followup::Dropped(why) => (Settled::Dropped, format!("Not filed anywhere: {why}.")),
1115 Followup::Failed => return None,
1116 };
1117 let reasoning = match reasoning.trim() {
1118 "" => tail,
1119 said => format!("{said} {tail}"),
1120 };
1121 Some((outcome, reasoning))
1122}
1123
1124pub fn file_followup(
1139 repo: &Repo,
1140 title: &str,
1141 body: &str,
1142 source: i64,
1143 cfg: &Config,
1144 state: &IssueRun,
1145) -> Followup {
1146 if repo.followups == Followups::None {
1147 return Followup::Dropped("follow-ups are off for this repository");
1148 }
1149 if state.filed.len() >= cfg.loop_cfg.max_followups {
1152 logwarn!(
1153 "already recorded {} follow-ups, not recording '{}'. Raise max_followups if you want \
1154 them all.",
1155 state.filed.len(),
1156 style::title(title, &repo.style)
1157 );
1158 return Followup::Dropped("this run had already recorded as many follow-ups as it may");
1159 }
1160 let title = match repo.clean_title(title) {
1167 Ok(title) => title,
1168 Err(e) => {
1169 logdim!("could not clean a follow-up title: {e}");
1170 return Followup::Failed;
1171 }
1172 };
1173 if title.trim().is_empty() {
1174 logdim!("nothing left of a follow-up title after cleaning it");
1175 return Followup::Failed;
1176 }
1177 let body = format!(
1180 "{}\n\nFound while working on #{source}.",
1181 style::issue_body(body, &repo.style)
1182 );
1183
1184 if repo.followups == Followups::Local {
1185 return repo.append_local_followup(&title, &body);
1186 }
1187
1188 match file_as_issue(repo, &title, &body) {
1189 Ok(filed) => filed.into(),
1190 Err(e) => {
1191 logdim!("could not file a follow-up for '{title}': {e}");
1192 Followup::Failed
1193 }
1194 }
1195}
1196
1197#[derive(Debug, Clone)]
1199pub enum Filed {
1200 Opened(i64, String),
1202 AddedTo(i64, String),
1204 Covered(i64, String),
1206 AlreadyClosed(i64, String),
1208}
1209
1210impl From<Filed> for Followup {
1211 fn from(filed: Filed) -> Self {
1212 match filed {
1213 Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => {
1214 Followup::Recorded(url)
1215 }
1216 Filed::AlreadyClosed(_, url) => Followup::Covered(url),
1220 }
1221 }
1222}
1223
1224impl Filed {
1225 pub fn url(&self) -> Option<&str> {
1226 match self {
1227 Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => Some(url),
1228 Filed::AlreadyClosed(_, _) => None,
1231 }
1232 }
1233
1234 pub fn number(&self) -> Option<i64> {
1236 match self {
1237 Filed::Opened(n, _) | Filed::AddedTo(n, _) | Filed::Covered(n, _) => Some(*n),
1238 Filed::AlreadyClosed(_, _) => None,
1239 }
1240 }
1241
1242 pub fn note(&self) -> String {
1244 match self {
1245 Filed::Opened(n, _) => format!("#{n}"),
1246 Filed::AddedTo(n, _) => format!("added to #{n}"),
1247 Filed::Covered(n, _) => format!("#{n} already says this"),
1248 Filed::AlreadyClosed(n, _) => format!("#{n} covers it and is closed"),
1249 }
1250 }
1251
1252 pub fn describe(&self, title: &str) -> String {
1253 let title = style::clip(title.trim(), 80);
1254 match self {
1255 Filed::Opened(n, _) => format!("filed #{n}: {title}"),
1256 Filed::AddedTo(n, _) => format!("added to #{n}: {title}"),
1257 Filed::Covered(n, _) => format!("#{n} already says this: {title}"),
1258 Filed::AlreadyClosed(n, _) => format!("#{n} covers it and is closed: {title}"),
1259 }
1260 }
1261}
1262
1263pub fn file_as_issue(repo: &Repo, title: &str, body: &str) -> Result<Filed> {
1275 let title = repo.clean_title(title)?;
1276 if title.trim().is_empty() {
1277 return Err(spar_err!("nothing left of the title after cleaning it"));
1278 }
1279 if let Some(existing) = repo.find_similar_issue(&title, body) {
1280 let known = format!("{} {}", existing.title, existing.body);
1281 if !existing.open {
1282 return Ok(Filed::AlreadyClosed(existing.number, existing.url));
1283 }
1284 if crate::textsim::adds_information(body, &known) {
1285 repo.comment_issue(existing.number, body)?;
1286 return Ok(Filed::AddedTo(existing.number, existing.url));
1287 }
1288 return Ok(Filed::Covered(existing.number, existing.url));
1289 }
1290 let url = repo.create_issue(&title, body)?;
1291 let number = filed_issue_number(&url)
1292 .ok_or_else(|| spar_err!("filed an issue but could not read its number from {url}"))?;
1293 Ok(Filed::Opened(number, url))
1294}
1295
1296fn file_out_of_scope(
1297 repo: &Repo,
1298 findings: &[Finding],
1299 subject: i64,
1300 state: &mut IssueRun,
1301 cfg: &Config,
1302) {
1303 for finding in findings.iter().filter(|f| !f.in_scope) {
1304 let body = issue_report(finding);
1305 let recorded = file_followup(repo, &finding.title, &body, subject, cfg, state);
1306 if let Some(url) = recorded.url() {
1307 state.filed.push(url.to_string());
1308 }
1309 }
1310}
1311
1312pub fn issue_report(finding: &Finding) -> String {
1319 let sections = finding.report_sections();
1320 if sections.is_empty() {
1321 return finding.detail.clone();
1322 }
1323 let mut out: Vec<String> = sections
1324 .iter()
1325 .map(|(heading, text)| format!("## {heading}\n\n{text}"))
1326 .collect();
1327 if !finding.detail.trim().is_empty()
1330 && !sections
1331 .iter()
1332 .any(|(_, text)| crate::textsim::same_point(text, &finding.detail))
1333 {
1334 out.insert(0, finding.detail.trim().to_string());
1335 }
1336 out.join("\n\n")
1337}
1338
1339fn file_nonblocking(
1346 repo: &Repo,
1347 findings: &[Finding],
1348 subject: i64,
1349 state: &mut IssueRun,
1350 cfg: &Config,
1351) {
1352 for finding in findings {
1353 let keep = match finding.severity {
1354 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking,
1355 Severity::Nit => cfg.loop_cfg.file_nits,
1356 Severity::Blocking => false,
1357 };
1358 if !keep || !finding.in_scope {
1359 continue;
1360 }
1361 let recorded = file_followup(repo, &finding.title, &finding.detail, subject, cfg, state);
1362 if let Some(url) = recorded.url() {
1363 state.filed.push(url.to_string());
1364 }
1365 }
1366}
1367
1368fn bullets(lines: &[String]) -> String {
1378 lines
1379 .iter()
1380 .map(|l| format!("- {l}"))
1381 .collect::<Vec<_>>()
1382 .join("\n")
1383}
1384
1385fn located(finding: &Finding, style: &Style) -> String {
1386 let title = style::title(&finding.title, style);
1387 match finding.where_at() {
1388 "general" => title,
1389 file => format!("{title} ({file})"),
1390 }
1391}
1392
1393pub enum Ending<'a> {
1395 Approved,
1397 OutOfRounds,
1400 Deadlocked(&'a [Finding]),
1403}
1404
1405pub fn post_outcome(
1417 repo: &Repo,
1418 pr_number: i64,
1419 state: &IssueRun,
1420 ledger: &Ledger,
1421 ending: Ending<'_>,
1422) {
1423 if repo.style.pr_comments != PrComments::Outcome {
1424 return;
1425 }
1426 let Some(text) = outcome_comment(state, ledger, &ending, &repo.style) else {
1427 return;
1428 };
1429 if let Err(e) = repo.comment_pr(pr_number, &text) {
1430 logdim!("could not post the outcome comment: {e}");
1431 }
1432}
1433
1434fn settled_as(finding: &Finding, state: &IssueRun, ledger: &Ledger) -> Option<(Settled, String)> {
1437 if let Some(d) = state
1438 .disputes
1439 .iter()
1440 .find(|d| same_point(&d.title, &finding.title))
1441 {
1442 if !d.reasoning.trim().is_empty() {
1443 return Some((Settled::Refuted, d.reasoning.clone()));
1444 }
1445 }
1446 ledger
1447 .get(&finding_key(&finding.title, &finding.file))
1448 .filter(|entry| !entry.reasoning.trim().is_empty())
1449 .map(|entry| (entry.outcome, entry.reasoning.clone()))
1450}
1451
1452pub fn filed_issue_number(filed: &str) -> Option<i64> {
1457 filed
1458 .rsplit('/')
1459 .next()
1460 .and_then(|tail| tail.parse::<i64>().ok())
1461 .filter(|n| *n > 0)
1462}
1463
1464fn as_reference(url: &str) -> String {
1465 match url.rsplit('/').next().and_then(|n| n.parse::<u64>().ok()) {
1466 Some(number) => format!("#{number}"),
1467 None => url.to_string(),
1468 }
1469}
1470
1471pub fn outcome_comment(
1472 state: &IssueRun,
1473 ledger: &Ledger,
1474 ending: &Ending<'_>,
1475 style: &Style,
1476) -> Option<String> {
1477 let mut out: Vec<String> = Vec::new();
1478 let mut already: Vec<String> = Vec::new();
1481
1482 match ending {
1483 Ending::Approved => {
1484 if state.disputes.is_empty() && state.filed.is_empty() {
1485 return None;
1488 }
1489 out.push("Reviewed, nothing blocking a merge.".into());
1490 }
1491 Ending::OutOfRounds => out.push(
1492 "Not signed off: the last round of fixes was pushed but has not been reviewed.".into(),
1493 ),
1494 Ending::Deadlocked(points) => {
1495 let lines: Vec<String> = points
1501 .iter()
1502 .map(|f| {
1503 let where_at = match f.where_at() {
1504 "general" => String::new(),
1505 file => format!(" ({file})"),
1506 };
1507 let title = style::title(&f.title, style);
1508 already.push(title.clone());
1509 match settled_as(f, state, ledger) {
1510 Some((Settled::Refuted, reason)) => format!(
1511 "{title}{where_at}. Refuted as: {}",
1512 style::summary(&reason, style)
1513 ),
1514 Some((Settled::Filed, reason)) => format!(
1515 "{title}{where_at}. Filed as out of scope: {}",
1516 style::summary(&reason, style)
1517 ),
1518 Some((Settled::Dropped, reason)) => format!(
1521 "{title}{where_at}. Out of scope here, and not filed: {}",
1522 style::summary(&reason, style)
1523 ),
1524 None => format!("{title}{where_at}"),
1525 }
1526 })
1527 .collect();
1528 out.push("Needs your decision. The reviewers could not settle this:".into());
1529 out.push(bullets(&lines));
1530 }
1531 }
1532
1533 let disputes: Vec<&crate::model::Dispute> = state
1534 .disputes
1535 .iter()
1536 .filter(|d| !already.iter().any(|t| same_point(t, &d.title)))
1537 .collect();
1538 if !disputes.is_empty() {
1539 let lines: Vec<String> = disputes
1542 .iter()
1543 .map(|d| {
1544 format!(
1545 "{}. {}",
1546 style::title(&d.title, style),
1547 style::sentence(&d.reasoning, style)
1548 )
1549 })
1550 .collect();
1551 out.push(format!("Raised and refuted:\n{}", bullets(&lines)));
1552 }
1553
1554 if !state.filed.is_empty() {
1555 let refs: Vec<String> = state.filed.iter().map(|u| as_reference(u)).collect();
1556 out.push(format!("Filed separately: {}", refs.join(", ")));
1557 }
1558
1559 Some(out.join("\n\n"))
1560}
1561
1562fn implement_prompt(number: i64, title: &str, url: &str, body: &str) -> String {
1570 IMPLEMENT_PROMPT
1571 .replace("{number}", &number.to_string())
1572 .replace("{title}", title)
1573 .replace("{url}", url)
1574 .replace("{body}", body)
1575}
1576
1577pub fn pr_body(issue: i64, work: &Implementation, style: &Style) -> String {
1587 let mut parts = vec![format!("Closes #{issue}")];
1588
1589 for lead in [&work.summary, &work.problem] {
1590 let text = style::sentence(lead, style);
1591 if !text.is_empty() {
1592 parts.push(text);
1593 }
1594 }
1595 parts.extend(section("What changed", &work.changes, style));
1596 parts.extend(section("How to test", &work.testing, style));
1597
1598 let notes = style::sentence(work.notes.as_deref().unwrap_or_default(), style);
1599 if !notes.is_empty() {
1600 parts.push(format!("## Notes\n\n{notes}"));
1601 }
1602
1603 style::body(&parts.join("\n\n"), style)
1604}
1605
1606fn section(heading: &str, lines: &[String], style: &Style) -> Option<String> {
1613 let items: Vec<String> = lines
1614 .iter()
1615 .map(|line| style::summary(line, style))
1616 .filter(|line| !line.is_empty())
1617 .collect();
1618 if items.is_empty() {
1619 return None;
1620 }
1621 Some(format!("## {heading}\n\n{}", bullets(&items)))
1622}
1623
1624pub fn from_commits(repo: &Repo, work_dir: &Path, base: &str) -> Implementation {
1632 Implementation {
1633 changes: repo.commit_subjects(work_dir, "HEAD", base),
1634 notes: Some(
1635 "The implement call failed after these commits were made, so this body is assembled \
1636 from their messages rather than written by their author. Read the diff."
1637 .to_string(),
1638 ),
1639 ..Implementation::default()
1640 }
1641}
1642
1643fn no_pr_note(work: &Implementation, style: &Style) -> String {
1650 let reason = style::sentence(&work.reason, style);
1651 if !reason.is_empty() {
1652 return reason;
1653 }
1654 if work.not_worth_doing {
1655 "Left alone after reading the code, with no reason given.".to_string()
1656 } else {
1657 "Nothing was committed, so there is nothing to review.".to_string()
1658 }
1659}
1660
1661pub fn review_comment(holder: &str, round: u32, review: &Review, style: &Style) -> String {
1665 let by = |severity: Severity| -> Vec<&Finding> {
1666 review
1667 .findings
1668 .iter()
1669 .filter(|f| f.severity == severity && f.in_scope)
1670 .collect()
1671 };
1672 let blocking = by(Severity::Blocking);
1673 let non_blocking = by(Severity::NonBlocking);
1674 let nits = by(Severity::Nit);
1675 let out_of_scope: Vec<&Finding> = review.findings.iter().filter(|f| !f.in_scope).collect();
1676
1677 let mut counts = Vec::new();
1678 if !blocking.is_empty() {
1679 counts.push(format!("{} blocking", blocking.len()));
1680 }
1681 if !non_blocking.is_empty() {
1682 counts.push(format!("{} non-blocking", non_blocking.len()));
1683 }
1684 if !nits.is_empty() {
1685 counts.push(format!("{} nit", nits.len()));
1686 }
1687 if !out_of_scope.is_empty() {
1688 counts.push(format!("{} out of scope", out_of_scope.len()));
1689 }
1690 let headline = if counts.is_empty() {
1691 "no findings".to_string()
1692 } else {
1693 counts.join(", ")
1694 };
1695
1696 let _ = (holder, round, headline);
1697 let mut out = Vec::new();
1698 let summary = style::summary(&review.summary, style);
1699 if !summary.is_empty() {
1700 out.push(summary);
1701 }
1702
1703 if !blocking.is_empty() {
1704 let lines: Vec<String> = blocking
1705 .iter()
1706 .map(|f| {
1707 let detail = style::detail(&f.detail, style);
1708 if detail.is_empty() {
1709 located(f, style)
1710 } else {
1711 format!("{}. {detail}", located(f, style))
1712 }
1713 })
1714 .collect();
1715 out.push(format!("blocking\n{}", bullets(&lines)));
1716 }
1717
1718 for (label, group) in [
1721 ("non-blocking", &non_blocking),
1722 ("nits", &nits),
1723 ("out of scope", &out_of_scope),
1724 ] {
1725 if group.is_empty() {
1726 continue;
1727 }
1728 let lines: Vec<String> = group.iter().map(|f| located(f, style)).collect();
1729 out.push(format!("{label}\n{}", bullets(&lines)));
1730 }
1731
1732 out.join("\n\n")
1733}
1734
1735pub fn disposition_comment(
1739 author: &str,
1740 response: &ResponseDoc,
1741 fixed: &[String],
1742 refuted: &[String],
1743 filed: &[String],
1744 style: &Style,
1745) -> Option<String> {
1746 if fixed.is_empty() && refuted.is_empty() && filed.is_empty() {
1747 return None;
1748 }
1749 let mut counts = Vec::new();
1750 if !fixed.is_empty() {
1751 counts.push(format!("{} fixed", fixed.len()));
1752 }
1753 if !refuted.is_empty() {
1754 counts.push(format!("{} refuted", refuted.len()));
1755 }
1756 if !filed.is_empty() {
1757 counts.push(format!("{} filed", filed.len()));
1758 }
1759
1760 let _ = (author, counts);
1761 let mut out = Vec::new();
1762 let summary = style::summary(&response.summary, style);
1763 if !summary.is_empty() {
1764 out.push(summary);
1765 }
1766 if !refuted.is_empty() {
1767 out.push(format!("refuted\n{}", bullets(refuted)));
1768 }
1769 if !fixed.is_empty() {
1770 out.push(format!("fixed\n{}", bullets(fixed)));
1771 }
1772 if !filed.is_empty() {
1773 out.push(format!("filed\n{}", bullets(filed)));
1774 }
1775 Some(out.join("\n\n"))
1776}
1777
1778pub fn skip_comment(item: &SkippedItem, style: &Style) -> String {
1786 let reasons = item
1787 .reasons
1788 .values()
1789 .map(|reason| style::sentence(reason, style));
1790 let lines = crate::textsim::dedupe_by(reasons, crate::textsim::same_reason);
1794 bullets(&lines)
1795}
1796
1797pub(crate) fn findings_for_prompt(findings: &[Finding]) -> String {
1800 if findings.is_empty() {
1801 return "(none)".to_string();
1802 }
1803 findings
1804 .iter()
1805 .map(|f| {
1806 let scope = if f.in_scope { "" } else { " [out of scope]" };
1807 format!(
1808 "- [{}]{scope} {} ({})\n {}",
1809 f.severity,
1810 f.title,
1811 f.where_at(),
1812 f.detail
1813 )
1814 })
1815 .collect::<Vec<_>>()
1816 .join("\n")
1817}
1818
1819#[cfg(test)]
1820mod tests {
1821 use super::*;
1822 use crate::model::Verdict;
1823
1824 fn style() -> Style {
1825 Style::default()
1826 }
1827
1828 fn finding(severity: &str, title: &str, detail: &str, file: &str, in_scope: bool) -> Finding {
1829 Finding {
1830 severity: Severity::parse_lenient(severity).unwrap(),
1831 title: title.into(),
1832 detail: detail.into(),
1833 file: file.into(),
1834 in_scope,
1835 ..Default::default()
1836 }
1837 }
1838
1839 fn review(summary: &str, findings: Vec<Finding>) -> Review {
1840 Review {
1841 verdict: Verdict::Approve,
1842 next_action: NextAction::Merge,
1843 summary: summary.into(),
1844 findings,
1845 }
1846 }
1847
1848 fn cfg_with(worktrees: bool, keep: bool) -> Config {
1851 let text = "[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n";
1852 let mut cfg = crate::config::parse(text).unwrap();
1853 cfg.loop_cfg.worktrees = worktrees;
1854 cfg.loop_cfg.keep_worktrees = keep;
1855 cfg
1856 }
1857
1858 #[test]
1859 fn a_worktree_is_released_on_every_finished_outcome() {
1860 let cfg = cfg_with(true, false);
1861 for status in [Status::Approved, Status::Merged, Status::Abandoned] {
1862 assert!(should_release(&cfg, status), "{status}");
1863 }
1864 }
1865
1866 #[test]
1869 fn a_worktree_is_kept_only_where_a_human_has_to_look() {
1870 let cfg = cfg_with(true, false);
1871 assert!(!should_release(&cfg, Status::Escalated));
1872 assert!(!should_release(&cfg, Status::Error));
1873 }
1874
1875 #[test]
1876 fn the_keep_flag_overrides_everything() {
1877 assert!(!should_release(&cfg_with(true, true), Status::Approved));
1878 }
1879
1880 #[test]
1881 fn nothing_is_released_when_worktrees_are_off() {
1882 assert!(!should_release(&cfg_with(false, false), Status::Approved));
1883 }
1884
1885 #[test]
1890 fn fixing_your_own_findings_hands_the_pr_over() {
1891 let cfg = cfg_with(true, false);
1892 assert_eq!("a", next_reviewer(&cfg, "b", Some("b")));
1893 assert_eq!("b", next_reviewer(&cfg, "a", Some("a")));
1894 }
1895
1896 #[test]
1900 fn handing_back_keeps_the_reviewer_for_the_next_round() {
1901 let cfg = cfg_with(true, false);
1902 assert_eq!("b", next_reviewer(&cfg, "b", Some("a")));
1903 assert_eq!("a", next_reviewer(&cfg, "a", Some("b")));
1904 }
1905
1906 #[test]
1909 fn nobody_reviews_their_own_edit() {
1910 let cfg = cfg_with(true, false);
1911 let round_1 = cfg.other(&cfg.first_implementor);
1912 assert_eq!("b", round_1);
1913 for editor in ["a", "b"] {
1914 assert_ne!(editor, next_reviewer(&cfg, &round_1, Some(editor)));
1915 }
1916 }
1917
1918 #[test]
1923 fn a_fix_that_committed_nothing_leaves_the_pr_where_it_is() {
1924 let cfg = cfg_with(true, false);
1925 assert_eq!("b", next_reviewer(&cfg, "b", None));
1926 assert_eq!("a", next_reviewer(&cfg, "a", None));
1927 }
1928
1929 #[test]
1933 fn a_reviewer_that_wrote_the_head_gives_the_pr_up() {
1934 let cfg = cfg_with(true, false);
1935 assert_eq!("a", next_reviewer(&cfg, "b", Some("b")));
1936 }
1937
1938 #[test]
1942 fn a_review_that_wrote_cannot_approve_what_is_left() {
1943 assert!(!approval_stands(&[], true));
1944 }
1945
1946 #[test]
1947 fn a_clean_review_of_an_untouched_branch_approves() {
1948 assert!(approval_stands(&[], false));
1949 }
1950
1951 #[test]
1952 fn a_blocking_finding_never_approves() {
1953 let blocking = vec![finding("blocking", "Broken", "detail", "src/x.rs", true)];
1954 assert!(!approval_stands(&blocking, false));
1955 }
1956
1957 #[test]
1959 fn only_a_moved_head_counts_as_a_commit() {
1960 let before = Snapshot {
1961 head: "abc".into(),
1962 dirty: false,
1963 };
1964 assert!(!Snapshot {
1965 head: "abc".into(),
1966 dirty: true,
1967 }
1968 .landed_over(&before));
1969 assert!(Snapshot {
1970 head: "def".into(),
1971 dirty: false,
1972 }
1973 .landed_over(&before));
1974 assert!(!Snapshot {
1976 head: String::new(),
1977 dirty: false,
1978 }
1979 .landed_over(&before));
1980 }
1981
1982 #[test]
1986 fn a_fresh_run_starts_at_one() {
1987 assert_eq!((1, 3), round_window(1, 3));
1988 assert_eq!((1, 5), round_window(1, 5));
1989 }
1990
1991 #[test]
1995 fn a_resumed_run_gets_a_full_fresh_budget() {
1996 assert_eq!((6, 10), round_window(6, 5));
1997 assert_eq!((11, 13), round_window(11, 3));
1998 }
1999
2000 #[test]
2001 fn a_budget_of_one_is_a_single_round() {
2002 assert_eq!((6, 6), round_window(6, 1));
2003 }
2004
2005 #[test]
2006 fn round_numbers_keep_counting_across_sessions() {
2007 let mut start = 1;
2009 let mut seen = Vec::new();
2010 for _ in 0..3 {
2011 let (first, last) = round_window(start, 3);
2012 seen.push((first, last));
2013 start = last + 1;
2014 }
2015 assert_eq!(vec![(1, 3), (4, 6), (7, 9)], seen);
2016 }
2017
2018 fn ledger_with(title: &str, file: &str) -> Ledger {
2021 let mut ledger = Ledger::new();
2022 ledger.insert(
2023 finding_key(title, file),
2024 LedgerEntry {
2025 title: title.into(),
2026 file: file.into(),
2027 reasoning: "no".into(),
2028 round: 1,
2029 reraised: 0,
2030 outcome: Settled::Refuted,
2031 },
2032 );
2033 ledger
2034 }
2035
2036 #[test]
2037 fn a_point_refuted_and_re_raised_twice_escalates() {
2038 let mut ledger = ledger_with("nit about naming", "a.rs");
2039 let mut state = IssueRun::new(1, "t");
2040 let blocking = vec![finding("blocking", "nit about naming", "d", "a.rs", true)];
2041 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
2042 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
2043 }
2044
2045 #[test]
2046 fn an_untracked_finding_does_not_escalate() {
2047 let mut state = IssueRun::new(1, "t");
2048 let blocking = vec![finding("blocking", "brand new", "d", "a.rs", true)];
2049 assert!(!check_relitigation(
2050 &mut Ledger::new(),
2051 &blocking,
2052 &mut state
2053 ));
2054 }
2055
2056 #[test]
2060 fn a_refutation_lands_on_the_key_the_next_round_will_look_up() {
2061 let blocking = vec![finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
2062 let recorded = finding_key(&blocking[0].title, &blocking[0].file);
2063
2064 let matched = matching_finding(&blocking, "unbounded loop!").expect("should match");
2065 assert_eq!(recorded, finding_key("unbounded loop!", &matched.file));
2066 }
2067
2068 #[test]
2073 fn the_ledger_key_uses_the_reviewers_wording_not_the_authors() {
2074 let findings = vec![finding(
2075 "blocking",
2076 "Panic on multi-byte input",
2077 "d",
2078 "src/style.rs",
2079 true,
2080 )];
2081 let reworded = "Panic on multibyte input";
2082
2083 let source = matching_finding(&findings, reworded).expect("still matches");
2084 assert_ne!(
2085 finding_key(reworded, &source.file),
2086 finding_key(&source.title, &source.file),
2087 "the two spellings must genuinely hash apart, or this test proves nothing"
2088 );
2089
2090 let recorded = finding_key(&source.title, &source.file);
2092 let looked_up = finding_key(&findings[0].title, &findings[0].file);
2093 assert_eq!(recorded, looked_up);
2094 }
2095
2096 #[test]
2097 fn a_disposition_matches_its_finding_despite_wording_noise() {
2098 let findings = vec![finding(
2099 "blocking",
2100 "Unbounded loop!",
2101 "d",
2102 "src/x.rs",
2103 true,
2104 )];
2105 assert!(matching_finding(&findings, "unbounded loop").is_some());
2106 assert!(matching_finding(&findings, "something else").is_none());
2107 }
2108
2109 #[test]
2110 fn the_settled_block_is_empty_when_nothing_is_settled() {
2111 assert_eq!("", settled_block(&Ledger::new()));
2112 }
2113
2114 #[test]
2115 fn the_settled_block_names_each_refutation() {
2116 let block = settled_block(&ledger_with("a point", "x.rs"));
2117 assert!(block.contains("a point"));
2118 assert!(block.contains("settled"));
2119 }
2120
2121 #[test]
2125 fn a_filed_point_is_settled_too() {
2126 let mut ledger = ledger_with("out of scope", "x.rs");
2127 for entry in ledger.values_mut() {
2128 entry.outcome = Settled::Filed;
2129 entry.reasoning = "Tracked in #9.".into();
2130 }
2131 let block = settled_block(&ledger);
2132 assert!(block.contains("out of scope"));
2133 assert!(block.contains("#9"));
2134 }
2135
2136 #[test]
2139 fn answering_a_point_again_keeps_its_re_raise_count() {
2140 let mut ledger = ledger_with("a point", "x.rs");
2141 let entry = ledger.values().next().unwrap().clone();
2142 let key = finding_key("a point", "x.rs");
2143 let mut state = IssueRun::new(1, "t");
2144 let blocking = vec![finding("blocking", "a point", "d", "x.rs", true)];
2145
2146 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
2147 settle(&mut ledger, key, entry);
2148 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
2149 }
2150
2151 #[test]
2154 fn a_clean_review_is_just_the_verdict() {
2157 let text = review_comment("codex", 1, &review("Looks correct.", vec![]), &style());
2158 assert_eq!("Looks correct.", text);
2159 }
2160
2161 #[test]
2162 fn a_review_leads_with_the_counts() {
2163 let text = review_comment(
2164 "codex",
2165 2,
2166 &review(
2167 "One real problem.",
2168 vec![
2169 finding(
2170 "blocking",
2171 "Loop never terminates",
2172 "Confirmed by running it.",
2173 "src/a.rs",
2174 true,
2175 ),
2176 finding("non-blocking", "Name is vague", "d", "src/b.rs", true),
2177 finding("nit", "Log wording", "d", "", true),
2178 ],
2179 ),
2180 &style(),
2181 );
2182 assert!(text.starts_with("One real problem."), "{text}");
2183 assert!(!text.contains("codex"), "no agent name: {text}");
2184 assert!(!text.contains("round 2"), "no round number: {text}");
2185 }
2186
2187 #[test]
2190 fn only_blocking_findings_carry_their_detail() {
2191 let text = review_comment(
2192 "codex",
2193 1,
2194 &review(
2195 "s",
2196 vec![
2197 finding("blocking", "Loop", "BLOCKING DETAIL", "a.rs", true),
2198 finding("non-blocking", "Name", "NONBLOCKING DETAIL", "b.rs", true),
2199 ],
2200 ),
2201 &style(),
2202 );
2203 assert!(text.contains("BLOCKING DETAIL"), "{text}");
2204 assert!(!text.contains("NONBLOCKING DETAIL"), "{text}");
2205 }
2206
2207 #[test]
2208 fn a_thorough_explanation_reaches_the_author_intact() {
2211 let detail = "Reproduced by running the 429 test with max_attempts unset. ".repeat(8);
2212 let text = review_comment(
2213 "codex",
2214 1,
2215 &review(
2216 "One problem.",
2217 vec![finding("blocking", "T", &detail, "a.rs", true)],
2218 ),
2219 &style(),
2220 );
2221 assert!(
2222 text.contains(detail.trim()),
2223 "the explanation was cut:\n{text}"
2224 );
2225 }
2226
2227 #[test]
2229 fn a_runaway_model_is_still_bounded() {
2230 let long = "filler words. ".repeat(20_000);
2231 let text = review_comment(
2232 "codex",
2233 1,
2234 &review(&long, vec![finding("blocking", "T", &long, "a.rs", true)]),
2235 &style(),
2236 );
2237 assert!(
2238 text.len() < 30_000,
2239 "review comment was {} chars",
2240 text.len()
2241 );
2242 }
2243
2244 #[test]
2245 fn a_general_finding_has_no_empty_parenthesis() {
2246 let text = review_comment(
2247 "codex",
2248 1,
2249 &review("s", vec![finding("blocking", "Something", "d", "", true)]),
2250 &style(),
2251 );
2252 assert!(!text.contains("()"), "{text}");
2253 assert!(!text.contains("(general)"), "{text}");
2254 }
2255
2256 #[test]
2257 fn out_of_scope_findings_are_counted_separately() {
2258 let text = review_comment(
2259 "codex",
2260 1,
2261 &review(
2262 "s",
2263 vec![finding("blocking", "Old bug", "d", "a.rs", false)],
2264 ),
2265 &style(),
2266 );
2267 assert!(text.contains("out of scope"), "{text}");
2268 assert!(text.contains("Old bug"), "{text}");
2269 }
2270
2271 #[test]
2272 fn a_disposition_comment_leads_with_counts_and_keeps_refutations() {
2273 let response = ResponseDoc {
2274 summary: "Two of three were right.".into(),
2275 dispositions: vec![],
2276 };
2277 let text = disposition_comment(
2278 "claude",
2279 &response,
2280 &["Fixed thing".to_string()],
2281 &["Wrong thing. Because the caller already checks.".to_string()],
2282 &[],
2283 &style(),
2284 )
2285 .unwrap();
2286 assert!(text.starts_with("Two of three were right."), "{text}");
2287 assert!(!text.contains("claude"), "no agent name: {text}");
2288 assert!(
2289 text.contains("Because the caller already checks."),
2290 "{text}"
2291 );
2292 }
2293
2294 #[test]
2295 fn an_empty_disposition_comment_is_not_posted() {
2296 let response = ResponseDoc {
2297 summary: "s".into(),
2298 dispositions: vec![],
2299 };
2300 assert!(disposition_comment("claude", &response, &[], &[], &[], &style()).is_none());
2301 }
2302
2303 #[test]
2308 fn the_implementor_is_given_the_link_and_the_body() {
2309 let prompt = implement_prompt(
2310 42,
2311 "Retry a 429",
2312 "https://github.com/o/r/issues/42",
2313 "A rate limited response was treated as fatal.",
2314 );
2315 assert!(
2316 prompt.contains("https://github.com/o/r/issues/42"),
2317 "{prompt}"
2318 );
2319 assert!(
2320 prompt.contains("A rate limited response was treated as fatal."),
2321 "{prompt}"
2322 );
2323 assert!(prompt.contains("#42"), "{prompt}");
2324 assert!(prompt.contains("Retry a 429"), "{prompt}");
2325 assert!(!prompt.contains('{'), "{prompt}");
2327 }
2328
2329 #[test]
2332 fn the_prompt_says_the_discussion_is_not_included() {
2333 let prompt = implement_prompt(1, "t", "u", "b");
2334 let lower = prompt
2336 .split_whitespace()
2337 .collect::<Vec<_>>()
2338 .join(" ")
2339 .to_lowercase();
2340 assert!(
2341 lower.contains("discussion since is not included"),
2342 "{prompt}"
2343 );
2344 assert!(lower.contains("cannot reach the network"), "{prompt}");
2345 }
2346
2347 fn worked() -> Implementation {
2349 Implementation {
2350 summary: "Retry a 429 instead of failing the run.".into(),
2351 problem: "A rate limited response was treated as fatal, so one throttled call ended \
2352 a run that had hours of work left in it."
2353 .into(),
2354 changes: vec![
2355 "`send` retries a 429 with the delay the header asks for".into(),
2356 "the retry budget is bounded, so a permanent 429 still ends".into(),
2357 ],
2358 testing: vec![
2359 "`cargo test retries_a_429`".into(),
2360 "point it at a throttled endpoint and watch it finish".into(),
2361 ],
2362 ..Implementation::default()
2363 }
2364 }
2365
2366 #[test]
2367 fn a_pr_body_is_what_it_closes_and_what_changed() {
2370 let body = pr_body(42, &worked(), &style());
2371 assert_eq!(
2372 "Closes #42\n\n\
2373 Retry a 429 instead of failing the run.\n\n\
2374 A rate limited response was treated as fatal, so one throttled call \
2375 ended a run that had hours of work left in it.\n\n\
2376 ## What changed\n\n\
2377 - `send` retries a 429 with the delay the header asks for\n\
2378 - the retry budget is bounded, so a permanent 429 still ends\n\n\
2379 ## How to test\n\n\
2380 - `cargo test retries_a_429`\n\
2381 - point it at a throttled endpoint and watch it finish",
2382 body
2383 );
2384 }
2385
2386 #[test]
2389 fn a_body_with_nothing_to_list_carries_no_empty_headings() {
2390 let work = Implementation {
2391 summary: "Retry a 429 instead of failing the run.".into(),
2392 ..Implementation::default()
2393 };
2394 assert_eq!(
2395 "Closes #42\n\nRetry a 429 instead of failing the run.",
2396 pr_body(42, &work, &style())
2397 );
2398 }
2399
2400 #[test]
2401 fn a_pr_body_survives_an_implementor_that_said_nothing() {
2402 assert_eq!(
2403 "Closes #7",
2404 pr_body(7, &Implementation::default(), &style())
2405 );
2406 }
2407
2408 #[test]
2411 fn blank_list_entries_do_not_earn_a_heading() {
2412 let work = Implementation {
2413 summary: "Did a thing.".into(),
2414 changes: vec![String::new(), " ".into()],
2415 ..Implementation::default()
2416 };
2417 let body = pr_body(42, &work, &style());
2418 assert!(!body.contains("What changed"), "{body}");
2419 }
2420
2421 #[test]
2422 fn notes_appear_only_when_there_is_something_to_note() {
2423 let mut work = worked();
2424 assert!(!pr_body(42, &work, &style()).contains("## Notes"));
2425 work.notes = Some("The retry is not applied to streaming calls.".into());
2426 let body = pr_body(42, &work, &style());
2427 assert!(body.contains("## Notes"), "{body}");
2428 assert!(body.contains("streaming calls"), "{body}");
2429 }
2430
2431 #[test]
2434 fn declining_posts_the_reason_and_not_the_summary() {
2435 let work = Implementation {
2436 not_worth_doing: true,
2437 reason: "Already fixed in 1.2, and the report predates it.".into(),
2438 summary: "Nothing to do.".into(),
2439 ..Implementation::default()
2440 };
2441 assert_eq!(
2442 "Already fixed in 1.2, and the report predates it.",
2443 no_pr_note(&work, &style())
2444 );
2445 }
2446
2447 #[test]
2448 fn reporting_work_and_committing_none_says_that_rather_than_the_summary() {
2449 let work = Implementation {
2450 summary: "Retry a 429 instead of failing the run.".into(),
2451 ..Implementation::default()
2452 };
2453 let note = no_pr_note(&work, &style());
2454 assert_eq!(
2455 "Nothing was committed, so there is nothing to review.",
2456 note
2457 );
2458 }
2459
2460 #[test]
2461 fn declining_without_a_reason_still_says_something() {
2462 let work = Implementation {
2463 not_worth_doing: true,
2464 ..Implementation::default()
2465 };
2466 assert!(no_pr_note(&work, &style()).contains("no reason given"));
2467 }
2468
2469 #[test]
2470 fn a_skip_comment_is_only_the_reasoning() {
2471 let item = SkippedItem {
2472 issue: 3,
2473 title: "t".into(),
2474 tracker: false,
2475 reasons: [
2476 ("claude".to_string(), "Already fixed in 1.2.".to_string()),
2477 ("codex".to_string(), "Duplicate of #2.".to_string()),
2478 ]
2479 .into_iter()
2480 .collect(),
2481 };
2482 let text = skip_comment(&item, &style());
2483 assert!(text.contains("Already fixed in 1.2."), "{text}");
2484 assert!(text.contains("Duplicate of #2."), "{text}");
2485 assert!(
2486 !text.contains("claude") && !text.contains("codex"),
2487 "{text}"
2488 );
2489 assert!(!text.to_lowercase().contains("not scheduled"), "{text}");
2490 assert!(text.lines().count() <= 3, "{text}");
2491 }
2492
2493 #[test]
2494 fn findings_for_a_model_keep_full_detail() {
2495 let long = "x".repeat(2000);
2496 let text = findings_for_prompt(&[finding("blocking", "T", &long, "a.rs", true)]);
2497 assert!(
2498 text.contains(&long),
2499 "a model needs the whole finding, only humans need brevity"
2500 );
2501 }
2502
2503 #[test]
2504 fn findings_for_a_model_are_never_empty() {
2505 assert_eq!("(none)", findings_for_prompt(&[]));
2506 }
2507}
2508
2509#[cfg(test)]
2510mod outcome_tests {
2511 use super::*;
2512 use crate::model::{Dispute, Severity};
2513
2514 fn style() -> Style {
2515 Style::default()
2516 }
2517
2518 fn state_with(disputes: Vec<(&str, &str)>, filed: Vec<&str>) -> IssueRun {
2519 let mut s = IssueRun::new(482, "t");
2520 s.disputes = disputes
2521 .into_iter()
2522 .map(|(title, reasoning)| Dispute {
2523 title: title.into(),
2524 reasoning: reasoning.into(),
2525 })
2526 .collect();
2527 s.filed = filed.into_iter().map(String::from).collect();
2528 s
2529 }
2530
2531 fn finding(title: &str, file: &str) -> Finding {
2532 Finding {
2533 severity: Severity::Blocking,
2534 title: title.into(),
2535 detail: "d".into(),
2536 file: file.into(),
2537 in_scope: true,
2538 ..Default::default()
2539 }
2540 }
2541
2542 #[test]
2545 fn a_clean_approval_says_nothing() {
2546 let state = state_with(vec![], vec![]);
2547 assert!(outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).is_none());
2548 }
2549
2550 #[test]
2551 fn an_approval_that_filed_follow_ups_links_them() {
2552 let state = state_with(
2553 vec![],
2554 vec![
2555 "https://github.com/you/thing/issues/485",
2556 "https://github.com/you/thing/issues/486",
2557 ],
2558 );
2559 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
2560 assert!(text.contains("Filed separately: #485, #486"), "{text}");
2561 }
2562
2563 #[test]
2567 fn running_out_of_rounds_says_what_that_means_for_the_reader() {
2568 let state = state_with(vec![], vec![]);
2569 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
2570 assert!(text.contains("has not been reviewed"), "{text}");
2571 assert!(
2572 !text.to_lowercase().contains("round 3"),
2573 "no round numbers: {text}"
2574 );
2575 assert!(!text.to_lowercase().contains("convergence"), "{text}");
2576 }
2577
2578 #[test]
2579 fn a_deadlock_names_the_point_they_could_not_settle() {
2580 let state = state_with(vec![], vec![]);
2581 let points = [finding("Retry loop never terminates", "src/net.rs:88")];
2582 let text = outcome_comment(
2583 &state,
2584 &Ledger::new(),
2585 &Ending::Deadlocked(&points),
2586 &style(),
2587 )
2588 .unwrap();
2589 assert!(
2590 text.contains("Retry loop never terminates (src/net.rs:88)"),
2591 "{text}"
2592 );
2593 assert!(text.contains("could not settle"), "{text}");
2594 }
2595
2596 #[test]
2598 fn refutations_survive_because_nothing_else_carries_them() {
2599 let state = state_with(
2600 vec![(
2601 "Error is swallowed",
2602 "the caller already validates the file",
2603 )],
2604 vec![],
2605 );
2606 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
2607 assert!(text.contains("Raised and refuted:"), "{text}");
2608 assert!(
2609 text.contains("The caller already validates the file"),
2610 "{text}"
2611 );
2612 }
2613
2614 #[test]
2615 fn no_agent_names_counts_or_round_numbers_reach_the_thread() {
2616 let state = state_with(
2617 vec![("A point", "a reason")],
2618 vec!["https://github.com/you/thing/issues/485"],
2619 );
2620 for ending in [Ending::Approved, Ending::OutOfRounds] {
2621 let text = outcome_comment(&state, &Ledger::new(), &ending, &style()).unwrap();
2622 let lower = text.to_lowercase();
2623 for banned in ["claude", "codex", "blocking,", "nit,", " fixed."] {
2624 assert!(
2625 !lower.contains(banned),
2626 "{banned:?} leaked into the thread:\n{text}"
2627 );
2628 }
2629 for n in 1..9 {
2631 assert!(
2632 !lower.contains(&format!("round {n}")),
2633 "a round number leaked into the thread:\n{text}"
2634 );
2635 }
2636 }
2637 }
2638
2639 #[test]
2640 fn a_refutation_is_allowed_to_make_its_case() {
2643 let reasoning = "The caller validates against the schema first. \
2644 The discarded error is therefore unreachable in practice. ";
2645 let state = state_with(
2646 vec![("A point", &reasoning.repeat(6))],
2647 vec!["https://github.com/you/thing/issues/485"],
2648 );
2649 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
2650 assert!(
2651 !text.contains("..."),
2652 "nothing was cut mid thought:\n{text}"
2653 );
2654 assert!(text.len() < 4000, "{} chars", text.len());
2655 }
2656
2657 #[test]
2658 fn a_url_that_is_not_an_issue_link_is_left_alone() {
2659 assert_eq!(
2660 "#485",
2661 as_reference("https://github.com/you/thing/issues/485")
2662 );
2663 assert_eq!("note: something", as_reference("note: something"));
2664 }
2665}
2666
2667#[cfg(test)]
2668mod filed_reference_tests {
2669 use super::*;
2670
2671 #[test]
2672 fn an_issue_url_yields_its_number() {
2673 assert_eq!(
2674 Some(485),
2675 filed_issue_number("https://github.com/you/thing/issues/485")
2676 );
2677 }
2678
2679 #[test]
2682 fn a_local_note_yields_nothing() {
2683 assert_eq!(None, filed_issue_number("note: Retry is unbounded"));
2684 assert_eq!(None, filed_issue_number(""));
2685 assert_eq!(
2686 None,
2687 filed_issue_number("https://github.com/you/thing/issues/")
2688 );
2689 }
2690}
2691
2692#[cfg(test)]
2693mod followup_restraint_tests {
2694 use super::*;
2695 use crate::model::Severity;
2696
2697 fn cfg_with(followups: Followups, non_blocking: bool, nits: bool, cap: usize) -> Config {
2698 let mut cfg =
2699 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
2700 .unwrap();
2701 cfg.loop_cfg.followups = followups;
2702 cfg.loop_cfg.file_non_blocking = non_blocking;
2703 cfg.loop_cfg.file_nits = nits;
2704 cfg.loop_cfg.max_followups = cap;
2705 cfg
2706 }
2707
2708 fn finding(severity: Severity, title: &str, in_scope: bool) -> Finding {
2709 Finding {
2710 severity,
2711 title: title.into(),
2712 detail: "d".into(),
2713 file: "a.rs".into(),
2714 in_scope,
2715 ..Default::default()
2716 }
2717 }
2718
2719 #[test]
2723 fn a_non_blocking_finding_is_not_a_tracker_item_by_default() {
2724 let cfg = cfg_with(Followups::Issues, false, false, 5);
2725 assert!(!cfg.loop_cfg.file_non_blocking);
2726 assert!(!cfg.loop_cfg.file_nits);
2727 }
2728
2729 #[test]
2730 fn follow_ups_stay_off_the_tracker_by_default() {
2731 let cfg =
2732 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
2733 .unwrap();
2734 assert_eq!(
2735 Followups::Local,
2736 cfg.loop_cfg.followups,
2737 "the tracker is somebody's queue; the default must not write to it"
2738 );
2739 assert_eq!(5, cfg.loop_cfg.max_followups);
2740 }
2741
2742 #[test]
2744 fn only_out_of_scope_defects_qualify_at_the_defaults() {
2745 let cfg = cfg_with(Followups::Issues, false, false, 5);
2746 let qualifies = |f: &Finding| match f.severity {
2747 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking && f.in_scope,
2748 Severity::Nit => cfg.loop_cfg.file_nits && f.in_scope,
2749 Severity::Blocking => false,
2750 } || !f.in_scope;
2751
2752 assert!(qualifies(&finding(
2753 Severity::Blocking,
2754 "pre-existing",
2755 false
2756 )));
2757 assert!(!qualifies(&finding(
2758 Severity::NonBlocking,
2759 "improvement",
2760 true
2761 )));
2762 assert!(!qualifies(&finding(Severity::Nit, "taste", true)));
2763 assert!(!qualifies(&finding(
2764 Severity::Blocking,
2765 "fix it here",
2766 true
2767 )));
2768 }
2769
2770 #[test]
2771 fn opening_it_up_lets_non_blocking_findings_through_again() {
2772 let cfg = cfg_with(Followups::Issues, true, false, 5);
2773 assert!(cfg.loop_cfg.file_non_blocking);
2774 }
2775
2776 #[test]
2778 fn the_cap_is_a_real_backstop() {
2779 let cfg = cfg_with(Followups::Issues, false, false, 3);
2780 let mut state = IssueRun::new(1, "t");
2781 state.filed = (0..3).map(|n| format!("url{n}")).collect();
2782 assert!(state.filed.len() >= cfg.loop_cfg.max_followups);
2783 }
2784
2785 #[test]
2789 fn the_cap_bounds_what_one_run_can_spawn() {
2790 let cfg = cfg_with(Followups::Issues, false, false, 5);
2791 assert!(
2792 cfg.loop_cfg.max_followups <= 5,
2793 "a run that can file ten follow-ups is a branching process"
2794 );
2795 }
2796}
2797
2798#[cfg(test)]
2802mod followup_outcome_tests {
2803 use super::*;
2804
2805 const URL: &str = "https://github.com/you/thing/issues/485";
2806
2807 fn entry(recorded: Followup) -> Option<(Settled, String)> {
2808 filed_entry(&recorded, "It predates this branch.")
2809 }
2810
2811 #[test]
2815 fn a_failed_followup_settles_nothing() {
2816 assert_eq!(None, entry(Followup::Failed));
2817 }
2818
2819 #[test]
2820 fn a_recorded_followup_is_filed_and_says_where() {
2821 let (outcome, reasoning) = entry(Followup::Recorded(URL.into())).unwrap();
2822 assert_eq!(Settled::Filed, outcome);
2823 assert!(
2824 reasoning.contains("It predates this branch."),
2825 "{reasoning}"
2826 );
2827 assert!(reasoning.contains("#485"), "{reasoning}");
2828 }
2829
2830 #[test]
2833 fn a_closed_issue_covering_the_point_settles_it_without_offering_work() {
2834 let recorded = Followup::from(Filed::AlreadyClosed(9, URL.into()));
2835 assert_eq!(Followup::Covered(URL.into()), recorded);
2836 assert_eq!(
2837 None,
2838 recorded.url(),
2839 "a closed issue is not work to pick up"
2840 );
2841
2842 let (outcome, reasoning) = entry(recorded).unwrap();
2843 assert_eq!(Settled::Filed, outcome);
2844 assert!(reasoning.contains("#485"), "{reasoning}");
2845 }
2846
2847 #[test]
2850 fn an_open_issue_that_already_covers_the_point_is_still_a_reference() {
2851 for filed in [
2852 Filed::Opened(9, URL.into()),
2853 Filed::AddedTo(9, URL.into()),
2854 Filed::Covered(9, URL.into()),
2855 ] {
2856 assert_eq!(Some(URL), Followup::from(filed).url());
2857 }
2858 }
2859
2860 #[test]
2864 fn a_dropped_followup_is_settled_but_never_reported_as_filed() {
2865 let (outcome, reasoning) = entry(Followup::Dropped("follow-ups are off")).unwrap();
2866 assert_eq!(Settled::Dropped, outcome);
2867 assert!(reasoning.contains("follow-ups are off"), "{reasoning}");
2868 assert!(reasoning.contains("Not filed"), "{reasoning}");
2869 }
2870
2871 fn ledger_of(outcome: Settled, reasoning: &str) -> Ledger {
2872 let mut ledger = Ledger::new();
2873 ledger.insert(
2874 finding_key("A pre-existing leak", "src/x.rs"),
2875 LedgerEntry {
2876 title: "A pre-existing leak".into(),
2877 file: "src/x.rs".into(),
2878 reasoning: reasoning.into(),
2879 round: 1,
2880 reraised: 0,
2881 outcome,
2882 },
2883 );
2884 ledger
2885 }
2886
2887 #[test]
2891 fn the_settled_block_tells_a_filed_point_from_a_dropped_one() {
2892 let filed = settled_block(&ledger_of(Settled::Filed, "Tracked in #9."));
2893 assert!(filed.contains("out of scope here, and filed"), "{filed}");
2894
2895 let dropped = settled_block(&ledger_of(Settled::Dropped, "Not filed anywhere: off."));
2896 assert!(
2897 dropped.contains("out of scope here, and not filed"),
2898 "{dropped}"
2899 );
2900 assert!(dropped.contains("A pre-existing leak"), "{dropped}");
2901 }
2902
2903 #[test]
2906 fn a_deadlocked_point_that_was_never_filed_does_not_claim_to_be() {
2907 let points = [Finding {
2908 severity: Severity::Blocking,
2909 title: "A pre-existing leak".into(),
2910 detail: "d".into(),
2911 file: "src/x.rs".into(),
2912 in_scope: false,
2913 ..Default::default()
2914 }];
2915 let text = outcome_comment(
2916 &IssueRun::new(1, "t"),
2917 &ledger_of(Settled::Dropped, "Not filed anywhere: follow-ups are off."),
2918 &Ending::Deadlocked(&points),
2919 &Style::default(),
2920 )
2921 .unwrap();
2922 assert!(text.contains("not filed"), "{text}");
2923 assert!(!text.contains("Filed as out of scope"), "{text}");
2924 }
2925}
2926
2927#[cfg(test)]
2928mod issue_report_tests {
2929 use super::*;
2930 use crate::model::Severity;
2931
2932 fn reported() -> Finding {
2935 Finding {
2936 severity: Severity::Blocking,
2937 title: "sendPaymentAsync bypasses drain mode and spending limits".into(),
2938 detail: "The async path skips every admission check payInvoice applies.".into(),
2939 file: "src/node.ts:412".into(),
2940 in_scope: false,
2941 problem: Some(
2942 "`BeignetNode.sendPaymentAsync()` submits a payment directly to the Lightning \
2943 engine without applying the safeguards used by `payInvoice()`.\n\nThe async path \
2944 does not:\n\n- call `_checkDraining()`\n- call `_checkSpendLimit()`"
2945 .into(),
2946 ),
2947 reproduction: Some(
2948 "1. Create a `BeignetNode` with `dailySpendLimitSats: 1`.\n2. Enable drain mode.\n\
2949 3. Submit a 1,000 sat invoice.\n\nActual result:\n\n- The engine is called.\n\
2950 - `spentSats` remains 0."
2951 .into(),
2952 ),
2953 impact: Some(
2954 "An authorized client can submit async payments up to the available outbound \
2955 liquidity despite the configured limits."
2956 .into(),
2957 ),
2958 expected: Some(
2959 "- Reject new payments while draining.\n- Enforce the per-payment limit before \
2960 submission.\n- Cover both paths with regression tests.\n\nThis predates the \
2961 current branch."
2962 .into(),
2963 ),
2964 }
2965 }
2966
2967 #[test]
2968 fn a_reported_finding_becomes_a_bug_report() {
2969 let body = issue_report(&reported());
2970 for heading in [
2971 "## Problem",
2972 "## Reproduction",
2973 "## Impact",
2974 "## Expected behavior",
2975 ] {
2976 assert!(body.contains(heading), "missing {heading}:\n{body}");
2977 }
2978 let at = |h: &str| body.find(h).unwrap();
2980 assert!(at("## Problem") < at("## Reproduction"));
2981 assert!(at("## Reproduction") < at("## Impact"));
2982 assert!(at("## Impact") < at("## Expected behavior"));
2983 }
2984
2985 #[test]
2986 fn the_substance_survives_the_outbound_gates() {
2987 let repo_style = Style::default();
2988 let body = crate::style::issue_body(&issue_report(&reported()), &repo_style);
2989 for kept in [
2990 "_checkDraining()",
2991 "Actual result:",
2992 "outbound liquidity",
2993 "regression tests",
2994 "predates the current branch",
2995 ] {
2996 assert!(body.contains(kept), "the gate ate {kept:?}:\n{body}");
2997 }
2998 assert!(!body.contains("..."), "something was cut:\n{body}");
2999 }
3000
3001 #[test]
3004 fn an_ordinary_finding_is_still_just_its_detail() {
3005 let plain = Finding {
3006 severity: Severity::NonBlocking,
3007 title: "Name is vague".into(),
3008 detail: "The variable could say what it holds.".into(),
3009 file: "a.rs".into(),
3010 in_scope: true,
3011 ..Default::default()
3012 };
3013 assert_eq!(
3014 "The variable could say what it holds.",
3015 issue_report(&plain)
3016 );
3017 }
3018
3019 #[test]
3022 fn only_the_sections_that_were_written_appear() {
3023 let partial = Finding {
3024 problem: Some("The guard is inverted.".into()),
3025 expected: Some("It should reject rather than accept.".into()),
3026 ..reported()
3027 };
3028 let partial = Finding {
3029 reproduction: None,
3030 impact: None,
3031 ..partial
3032 };
3033 let body = issue_report(&partial);
3034 assert!(body.contains("## Problem") && body.contains("## Expected behavior"));
3035 assert!(!body.contains("## Reproduction"), "{body}");
3036 assert!(!body.contains("## Impact"), "{body}");
3037 }
3038
3039 #[test]
3042 fn the_summary_line_is_not_printed_twice() {
3043 let echoed = Finding {
3044 detail: "The guard is inverted so it rejects valid input.".into(),
3045 problem: Some("The guard is inverted so it rejects valid input.".into()),
3046 reproduction: None,
3047 impact: None,
3048 expected: None,
3049 ..reported()
3050 };
3051 let body = issue_report(&echoed);
3052 assert_eq!(1, body.matches("The guard is inverted").count(), "{body}");
3053 }
3054}