1use std::path::{Path, PathBuf};
19
20use crate::agent::{self, Agent};
21use crate::config::{Config, Followups, PrComments};
22use crate::error::{Result, SparError};
23use crate::jsonx::finding_key;
24use crate::model::{
25 Action, Dispute, Finding, Implementation, Issue, IssueRun, Ledger, LedgerEntry, NextAction,
26 PersistedState, PlanItem, PrView, ResponseDoc, Review, Severity, SkippedItem, Status,
27 STATE_VERSION,
28};
29use crate::repo::Repo;
30use crate::style::{self, Style};
31use crate::{log, logdim, logwarn, schema, spar_err};
32
33const IMPLEMENT_PROMPT: &str = "\
38Implement GitHub issue #{number} in this repository.
39
40Title: {title}
41
42{body}
43
44Do the work, then commit it on the current branch. Make focused commits with
45clear messages. Do not push, do not open a PR, and do not merge; the harness
46handles that.
47
48Then report it. Your answer becomes the pull request description, and the
49reviewer reads that cold, with nothing but the diff and a link to the issue:
50say what you found wrong, what the change does about it, and how they confirm
51it for themselves. Say what you actually ran, not what could be run.
52
53If after reading the code you conclude this issue should not be implemented,
54make no commits and set not_worth_doing, with the reason.";
55
56const REVIEW_PROMPT: &str = "\
57Review the changes on this branch against `{base}`. They implement issue
58#{number}: {title}
59
60Review thoroughly: correctness, edge cases, error handling, security, and
61whether the change actually resolves the issue. Read surrounding code, do not
62only read the diff.
63
64Label every finding by severity, and be honest about which is which:
65- blocking: the PR should not merge as is. Real defects only.
66- non-blocking: a genuine improvement that need not gate this PR.
67- nit: style or taste.
68
69Confirm anything you label blocking before you label it. Run the code,
70reproduce the failure, or point at the exact line that breaks, and say in the
71detail what you did to confirm it. When you need to run something to check a
72claim, write a scratch file and run that, rather than passing a long program on
73the command line: it is easier to read back, easier to rerun, and less likely to
74be refused by a sandbox or a safety filter part way through your work. An unverified blocking finding is worse than
75one you never raised: it stalls a good PR and teaches the author to stop
76believing you. If you suspect a problem but could not confirm it, say so and
77label it non-blocking.
78
79Set in_scope=false for a real defect that exists, that this PR did not cause, and
80that is worth somebody stopping to fix. Each one becomes a tracked item a
81maintainer has to read and triage, so the bar is a defect and not an observation.
82A thorough reviewer can always find something adjacent to what it is reading;
83that is not a reason to file it. If you are not sure it is worth a maintainer's
84time, leave in_scope true and say your piece in the finding.
85
86Reviewing one issue should not manufacture ten more. If you find yourself with
87several out of scope findings, keep the ones that would bite somebody and drop
88the rest.
89
90Then choose next_action:
91- merge: no blocking findings, the PR is good.
92- fix_myself: there are blocking findings and you will fix them directly.
93- hand_back: there are blocking findings the author should address.
94{settled}";
95
96const FIX_PROMPT: &str = "\
97You reviewed this branch and chose to fix the blocking findings yourself.
98Implement those fixes now and commit them.
99
100Your findings:
101{findings}
102
103Commit your changes. Do not push, do not merge.";
104
105const RESPOND_PROMPT: &str = "\
106Here is a review of your PR for issue #{number}.
107
108{findings}
109
110For each point, choose exactly one disposition:
111- fixed: the point is valid and in scope. Fix it and commit.
112- refuted: the point is wrong, or not worth acting on. Explain why. Refuting is
113 a legitimate outcome; do not accept a review comment you believe is incorrect
114 just to get the PR approved.
115- filed_issue: the point is valid but unrelated to this PR. Supply
116 new_issue_title and new_issue_body; the harness files it and skips duplicates.
117
118Copy each finding's title and file across exactly as given, so your answer can
119be matched back to the review.
120
121Commit any fixes. Do not push, do not merge.";
122
123fn should_release(cfg: &Config, status: Status) -> bool {
128 if !cfg.loop_cfg.worktrees || cfg.loop_cfg.keep_worktrees {
129 return false;
130 }
131 !matches!(status, Status::Escalated | Status::Error)
132}
133
134pub fn run_issue(
139 agents: &[Agent],
140 cfg: &Config,
141 repo: &Repo,
142 item: &PlanItem,
143 issue: &Issue,
144 ledger: &mut Ledger,
145) -> IssueRun {
146 if let Some(existing) = repo.open_pr_for_issue(item.issue) {
154 log!(
155 "#{}: {} is already open, continuing it instead of implementing again",
156 item.issue,
157 existing.url
158 );
159 return resume_pr(agents, cfg, repo, existing.number, None);
160 }
161
162 let mut state = IssueRun::new(item.issue, item.title.clone());
163 let base = cfg.base_branch().to_string();
164
165 let prepared = if cfg.loop_cfg.worktrees {
166 repo.worktree_add(item.issue, &base)
167 } else {
168 let branch = repo.branch_for_issue(item.issue);
169 let start = format!("origin/{base}");
170 repo.git(&["checkout", "-B", &branch, &start])
171 .map(|_| (repo.root().to_path_buf(), branch))
172 };
173
174 let (work_dir, branch) = match prepared {
175 Ok(pair) => pair,
176 Err(e) => {
177 state.status = Status::Error;
178 state.notes.push(e.to_string());
179 log!("#{} failed: {e}", item.issue);
180 return state;
181 }
182 };
183
184 let outcome = implement_and_review(
185 agents, cfg, repo, item, issue, ledger, &mut state, &work_dir, &branch,
186 );
187 if let Err(e) = outcome {
188 state.status = Status::Error;
189 state.notes.push(e.to_string());
190 log!("#{} failed: {e}", item.issue);
191 }
192
193 if should_release(cfg, state.status) {
194 repo.worktree_remove(item.issue);
195 }
196 state
197}
198
199#[allow(clippy::too_many_arguments)]
200fn implement_and_review(
201 agents: &[Agent],
202 cfg: &Config,
203 repo: &Repo,
204 item: &PlanItem,
205 issue: &Issue,
206 ledger: &mut Ledger,
207 state: &mut IssueRun,
208 work_dir: &Path,
209 branch: &str,
210) -> Result<()> {
211 let number = item.issue;
212 let holder = cfg.first_implementor.clone();
213 let implementor = agent::find(agents, &holder)?;
214 let base = cfg.base_branch().to_string();
215
216 log!("#{number}: {holder} implementing");
217 let body: String = issue.body_text().trim().chars().take(6000).collect();
218 let prompt = IMPLEMENT_PROMPT
219 .replace("{number}", &number.to_string())
220 .replace("{title}", &item.title)
221 .replace("{body}", &body);
222 let mut work: Implementation = implementor.ask_json(
223 &prompt,
224 &schema::implementation(),
225 work_dir,
226 cfg.effort_for_round(&implementor.spec, 1).as_deref(),
227 )?;
228
229 if work.not_worth_doing || !repo.has_changes(work_dir, &base) {
230 state.status = Status::Abandoned;
231 let reason = no_pr_note(&work, &repo.style);
232 state.notes.push(reason.clone());
233 if let Err(e) = repo.comment_issue(number, &reason) {
234 logdim!("could not comment on #{number}: {e}");
235 }
236 return Ok(());
237 }
238
239 if work.summary.trim().is_empty() {
243 work.summary = item.title.clone();
244 }
245
246 repo.rewrite_commits_if_needed(work_dir, &base)?;
247 repo.push(work_dir, branch)?;
248
249 let pr = match repo.pr_for_branch(branch) {
250 Some(existing) => existing,
251 None => {
252 let body = pr_body(number, &work, &repo.style);
253 repo.create_pr(
254 work_dir,
255 branch,
256 &base,
257 &format!("{} (#{number})", item.title),
258 &body,
259 )?
260 }
261 };
262 state.pr = Some(pr.url.clone());
263 log!("#{number}: PR {}", pr.url);
264
265 let ctx = LoopCtx {
266 work_dir: work_dir.to_path_buf(),
267 branch: branch.to_string(),
268 pr_number: pr.number,
269 label: format!("#{number}"),
270 subject: number,
271 title: item.title.clone(),
272 start_round: 1,
273 holder: cfg.other(&holder),
274 release: Release::Issue(number),
275 };
276 review_loop(agents, cfg, repo, &ctx, state, ledger)
277}
278
279pub fn resume_pr(
290 agents: &[Agent],
291 cfg: &Config,
292 repo: &Repo,
293 pr_number: i64,
294 holder_override: Option<&str>,
295) -> IssueRun {
296 let failed = |e: SparError| {
297 log!("PR #{pr_number} failed: {e}");
298 let mut state = IssueRun::new(pr_number, format!("PR #{pr_number}"));
299 state.status = Status::Error;
300 state.notes.push(e.to_string());
301 state
302 };
303
304 let pr = match repo.pr_view(pr_number) {
305 Ok(pr) => pr,
306 Err(e) => return failed(e),
307 };
308
309 if pr.is_cross_repository {
314 log!("PR #{pr_number} comes from a fork, reviewing it without changing it");
315 return crate::review_only::review_pr(agents, cfg, repo, pr_number, false);
316 }
317
318 match resume_inner(agents, cfg, repo, pr, holder_override) {
319 Ok(state) => state,
320 Err(e) => failed(e),
321 }
322}
323
324fn resume_inner(
325 agents: &[Agent],
326 cfg: &Config,
327 repo: &Repo,
328 pr: PrView,
329 holder_override: Option<&str>,
330) -> Result<IssueRun> {
331 let pr_number = pr.number;
332 if !pr.is_open() {
333 return Err(spar_err!("PR #{pr_number} is {}", pr.state.to_lowercase()));
334 }
335
336 let subject = pr
337 .closing_issues_references
338 .first()
339 .map(|r| r.number)
340 .unwrap_or(pr_number);
341
342 let saved = repo.read_state(&pr);
343 let mut ledger: Ledger = saved.as_ref().map(|s| s.ledger.clone()).unwrap_or_default();
344 let start_round = saved.as_ref().map(|s| s.round + 1).unwrap_or(1);
345
346 let default_holder = cfg.other(&cfg.first_implementor);
347 let mut holder = holder_override
348 .map(str::to_string)
349 .or_else(|| saved.as_ref().map(|s| s.next_actor.clone()))
350 .unwrap_or_else(|| default_holder.clone());
351 if !cfg.has_agent(&holder) {
352 log!("state named unknown agent '{holder}', using {default_holder}");
353 holder = default_holder;
354 }
355
356 match &saved {
357 Some(_) => log!(
358 "PR #{pr_number}: resuming at round {start_round}, {} settled point(s), next up {holder}",
359 ledger.len()
360 ),
361 None => log!("PR #{pr_number}: no prior spar state, starting fresh with {holder}"),
362 }
363
364 let mut state = IssueRun::new(subject, pr.title.clone());
365 state.pr = Some(pr.url.clone());
366 if let Some(s) = &saved {
367 state.filed = s.filed.clone();
368 }
369
370 let (work_dir, branch) = repo.worktree_for_pr(&pr)?;
371 let ctx = LoopCtx {
372 work_dir,
373 branch,
374 pr_number,
375 label: format!("PR #{pr_number}"),
376 subject,
377 title: pr.title.clone(),
378 start_round,
379 holder,
380 release: Release::Pr(pr_number),
381 };
382
383 let outcome = review_loop(agents, cfg, repo, &ctx, &mut state, &mut ledger);
384 if let Err(e) = outcome {
385 state.status = Status::Error;
386 state.notes.push(e.to_string());
387 log!("PR #{pr_number} failed: {e}");
388 }
389 if should_release(cfg, state.status) {
390 repo.release_pr_worktree(pr_number);
391 }
392 Ok(state)
393}
394
395#[derive(Debug, Clone, Copy)]
400enum Release {
401 Issue(i64),
402 Pr(i64),
403}
404
405struct LoopCtx {
406 work_dir: PathBuf,
407 branch: String,
408 pr_number: i64,
409 label: String,
410 subject: i64,
411 title: String,
412 start_round: u32,
413 holder: String,
414 release: Release,
415}
416
417impl LoopCtx {
418 fn release(&self, repo: &Repo) {
419 match self.release {
420 Release::Issue(n) => repo.worktree_remove(n),
421 Release::Pr(n) => repo.release_pr_worktree(n),
422 }
423 }
424}
425
426fn review_loop(
427 agents: &[Agent],
428 cfg: &Config,
429 repo: &Repo,
430 ctx: &LoopCtx,
431 state: &mut IssueRun,
432 ledger: &mut Ledger,
433) -> Result<()> {
434 let base = cfg.base_branch().to_string();
435 let mut holder = ctx.holder.clone();
436
437 let (first, last_allowed) = round_window(ctx.start_round, cfg.loop_cfg.max_rounds);
443 let mut last_round = first.saturating_sub(1);
444
445 for round in first..=last_allowed {
446 last_round = round;
447 state.rounds = round;
448 let reviewer = agent::find(agents, &holder)?;
449 let effort = cfg.effort_for_round(&reviewer.spec, round);
450 log!(
451 "{}: round {round}, {holder} reviewing ({})",
452 ctx.label,
453 effort.as_deref().unwrap_or("default effort")
454 );
455
456 let prompt = REVIEW_PROMPT
457 .replace("{base}", &base)
458 .replace("{number}", &ctx.subject.to_string())
459 .replace("{title}", &ctx.title)
460 .replace("{settled}", &settled_block(ledger));
461 let review: Review = reviewer.review(
462 &base,
463 &prompt,
464 &schema::review(),
465 &ctx.work_dir,
466 effort.as_deref(),
467 )?;
468
469 let blocking: Vec<Finding> = review
470 .findings
471 .iter()
472 .filter(|f| f.blocks())
473 .cloned()
474 .collect();
475
476 if repo.style.pr_comments == PrComments::Rounds {
477 if let Err(e) = repo.comment_pr(
478 ctx.pr_number,
479 &review_comment(&holder, round, &review, &repo.style),
480 ) {
481 logdim!("could not post the review comment: {e}");
482 }
483 }
484
485 file_out_of_scope(repo, &review.findings, ctx.subject, state, cfg);
489 file_nonblocking(repo, &review.findings, ctx.subject, state, cfg);
490
491 if check_relitigation(ledger, &blocking, state) {
492 state.status = Status::Escalated;
493 post_outcome(
494 repo,
495 ctx.pr_number,
496 state,
497 ledger,
498 Ending::Deadlocked(&blocking),
499 );
500 persist(
501 repo,
502 ctx.pr_number,
503 state,
504 ledger,
505 round,
506 &cfg.other(&holder),
507 );
508 return Ok(());
509 }
510
511 if blocking.is_empty() {
512 state.status = Status::Approved;
513 post_outcome(repo, ctx.pr_number, state, ledger, Ending::Approved);
514 persist(
515 repo,
516 ctx.pr_number,
517 state,
518 ledger,
519 round,
520 &cfg.other(&holder),
521 );
522 if cfg.loop_cfg.auto_merge {
523 ctx.release(repo);
528 repo.merge_pr(ctx.pr_number)?;
529 state.status = Status::Merged;
530 repo.clear_state(ctx.pr_number); log!("{}: merged", ctx.label);
532 } else {
533 log!("{}: approved, awaiting human merge", ctx.label);
534 }
535 return Ok(());
536 }
537
538 if review.next_action == NextAction::FixMyself {
539 log!("{}: {holder} fixing its own findings", ctx.label);
540 let prompt = FIX_PROMPT.replace("{findings}", &findings_for_prompt(&blocking));
541 reviewer.ask(&prompt, &ctx.work_dir, effort.as_deref())?;
542 } else {
543 let author_name = cfg.other(&holder);
544 let author = agent::find(agents, &author_name)?;
545 log!(
546 "{}: handing {} finding(s) to {author_name}",
547 ctx.label,
548 blocking.len()
549 );
550 let prompt = RESPOND_PROMPT
551 .replace("{number}", &ctx.subject.to_string())
552 .replace("{findings}", &findings_for_prompt(&blocking));
553 let response: ResponseDoc = author.ask_json(
554 &prompt,
555 &schema::response(),
556 &ctx.work_dir,
557 cfg.effort_for_round(&author.spec, round).as_deref(),
558 )?;
559 apply_dispositions(
560 repo,
561 cfg,
562 &response,
563 &blocking,
564 ledger,
565 state,
566 round,
567 ctx.subject,
568 ctx.pr_number,
569 &author_name,
570 );
571 }
572
573 repo.rewrite_commits_if_needed(&ctx.work_dir, &base)?;
574 repo.push(&ctx.work_dir, &ctx.branch)?;
575 holder = cfg.other(&holder);
576 persist(repo, ctx.pr_number, state, ledger, round, &holder);
577 }
578
579 state.status = Status::Escalated;
580 state
581 .notes
582 .push(exhausted_note(ctx.start_round, last_round));
583 post_outcome(repo, ctx.pr_number, state, ledger, Ending::OutOfRounds);
584 persist(repo, ctx.pr_number, state, ledger, last_round, &holder);
585 Ok(())
586}
587
588fn round_window(start_round: u32, budget: u32) -> (u32, u32) {
594 (start_round, start_round + budget.saturating_sub(1))
595}
596
597fn spent(start_round: u32, last_round: u32) -> (u32, u32) {
601 (last_round.saturating_sub(start_round) + 1, last_round)
602}
603
604fn exhausted_note(start_round: u32, last_round: u32) -> String {
605 let (this_run, total) = spent(start_round, last_round);
606 if this_run == total {
607 format!("no convergence after {this_run} rounds")
608 } else {
609 format!("no convergence after {this_run} more rounds ({total} in total)")
610 }
611}
612
613fn persist(
614 repo: &Repo,
615 pr_number: i64,
616 state: &IssueRun,
617 ledger: &Ledger,
618 round: u32,
619 next_actor: &str,
620) {
621 let payload = PersistedState {
622 version: STATE_VERSION,
623 round,
624 next_actor: next_actor.to_string(),
625 status: state.status,
626 ledger: ledger.clone(),
627 filed: state.filed.clone(),
628 };
629 if let Err(e) = repo.write_state(pr_number, &payload) {
630 logdim!("could not persist state for PR #{pr_number}: {e}");
631 }
632}
633
634fn settled_block(ledger: &Ledger) -> String {
639 if ledger.is_empty() {
640 return String::new();
641 }
642 let lines: Vec<String> = ledger
643 .values()
644 .map(|e| format!("- {}: refuted because {}", e.title, e.reasoning))
645 .collect();
646 format!(
647 "\nThe following points were already raised and refuted. Treat them as settled. Do not \
648 raise them again unless you have new evidence:\n{}",
649 lines.join("\n")
650 )
651}
652
653fn check_relitigation(ledger: &mut Ledger, blocking: &[Finding], state: &mut IssueRun) -> bool {
656 let mut escalate = false;
657 for finding in blocking {
658 let key = finding_key(&finding.title, &finding.file);
659 if let Some(entry) = ledger.get_mut(&key) {
660 entry.reraised += 1;
661 if entry.reraised >= 2 {
662 state.notes.push(format!(
663 "'{}' was refuted and re-raised twice; escalating.",
664 finding.title
665 ));
666 escalate = true;
667 }
668 }
669 }
670 escalate
671}
672
673fn normalise(text: &str) -> String {
674 text.to_lowercase()
675 .chars()
676 .filter(|c| c.is_ascii_alphanumeric() || c.is_whitespace())
677 .collect::<String>()
678 .split_whitespace()
679 .collect::<Vec<_>>()
680 .join(" ")
681}
682
683pub(crate) fn same_point(a: &str, b: &str) -> bool {
688 normalise(a) == normalise(b)
689}
690
691fn matching_finding<'a>(findings: &'a [Finding], title: &str) -> Option<&'a Finding> {
692 let wanted = normalise(title);
693 findings.iter().find(|f| normalise(&f.title) == wanted)
694}
695
696#[allow(clippy::too_many_arguments)]
697fn apply_dispositions(
698 repo: &Repo,
699 cfg: &Config,
700 response: &ResponseDoc,
701 blocking: &[Finding],
702 ledger: &mut Ledger,
703 state: &mut IssueRun,
704 round: u32,
705 subject: i64,
706 pr_number: i64,
707 author: &str,
708) {
709 let mut fixed = Vec::new();
710 let mut refuted = Vec::new();
711 let mut filed = Vec::new();
712
713 for d in &response.dispositions {
714 let source = matching_finding(blocking, &d.title);
715 let file = source
716 .map(|f| f.file.clone())
717 .filter(|f| !f.trim().is_empty())
718 .unwrap_or_else(|| d.file.clone());
719 let canonical = source.map(|f| f.title.as_str()).unwrap_or(d.title.as_str());
726 let title = style::title(canonical, &repo.style);
727
728 match d.action {
729 Action::Refuted => {
730 let reasoning = style::summary(&d.reasoning, &repo.style);
731 ledger.insert(
732 finding_key(canonical, &file),
733 LedgerEntry {
734 title: title.clone(),
735 file: file.clone(),
736 reasoning: reasoning.clone(),
737 round,
738 reraised: 0,
739 },
740 );
741 state.disputes.push(Dispute {
742 title: title.clone(),
743 reasoning: reasoning.clone(),
744 });
745 refuted.push(format!("{title}. {reasoning}"));
746 }
747 Action::FiledIssue => {
748 let new_title = d
749 .new_issue_title
750 .clone()
751 .filter(|t| !t.trim().is_empty())
752 .unwrap_or_else(|| d.title.clone());
753 let new_body = d
754 .new_issue_body
755 .clone()
756 .filter(|b| !b.trim().is_empty())
757 .unwrap_or_else(|| d.reasoning.clone());
758 let recorded = file_followup(repo, &new_title, &new_body, subject, cfg, state);
759 if let Some(url) = recorded {
760 state.filed.push(url.clone());
761 filed.push(url);
762 }
763 }
764 Action::Fixed => fixed.push(title),
765 }
766 }
767
768 if repo.style.pr_comments == PrComments::Rounds {
769 let comment = disposition_comment(author, response, &fixed, &refuted, &filed, &repo.style);
770 if let Some(text) = comment {
771 if let Err(e) = repo.comment_pr(pr_number, &text) {
772 logdim!("could not post the disposition comment: {e}");
773 }
774 }
775 }
776}
777
778fn file_followup(
789 repo: &Repo,
790 title: &str,
791 body: &str,
792 source: i64,
793 cfg: &Config,
794 state: &IssueRun,
795) -> Option<String> {
796 if repo.followups == Followups::None {
797 return None;
798 }
799 if state.filed.len() >= cfg.loop_cfg.max_followups {
802 logwarn!(
803 "already recorded {} follow-ups, not recording '{}'. Raise max_followups if you want \
804 them all.",
805 state.filed.len(),
806 style::title(title, &repo.style)
807 );
808 return None;
809 }
810 let title = match repo.clean_title(title) {
814 Ok(title) => title,
815 Err(e) => {
816 logdim!("could not clean a follow-up title: {e}");
817 return None;
818 }
819 };
820 if title.trim().is_empty() {
821 return None;
822 }
823 let body = format!(
826 "{}\n\nFound while working on #{source}.",
827 style::issue_body(body, &repo.style)
828 );
829
830 if repo.followups == Followups::Local {
831 return repo.append_local_followup(&title, &body);
832 }
833
834 if let Some(existing) = repo.find_similar_issue(&title, &body) {
838 let known = format!("{} {}", existing.title, existing.body);
839 if !existing.open {
840 logdim!(
841 "#{} already covers '{title}' and is closed, leaving it alone",
842 existing.number
843 );
844 return None;
845 }
846 if crate::textsim::adds_information(&body, &known) {
847 match repo.comment_issue(existing.number, &body) {
848 Ok(()) => log!("added to #{}: {title}", existing.number),
849 Err(e) => logdim!("could not add to #{}: {e}", existing.number),
850 }
851 } else {
852 logdim!("#{} already says this, nothing added", existing.number);
853 }
854 return Some(existing.url);
855 }
856
857 match repo.create_issue(&title, &body) {
858 Ok(url) => Some(url),
859 Err(e) => {
860 logdim!("could not file a follow-up for '{title}': {e}");
861 None
862 }
863 }
864}
865
866fn file_out_of_scope(
867 repo: &Repo,
868 findings: &[Finding],
869 subject: i64,
870 state: &mut IssueRun,
871 cfg: &Config,
872) {
873 for finding in findings.iter().filter(|f| !f.in_scope) {
874 let body = issue_report(finding);
875 if let Some(url) = file_followup(repo, &finding.title, &body, subject, cfg, state) {
876 state.filed.push(url);
877 }
878 }
879}
880
881pub fn issue_report(finding: &Finding) -> String {
888 let sections = finding.report_sections();
889 if sections.is_empty() {
890 return finding.detail.clone();
891 }
892 let mut out: Vec<String> = sections
893 .iter()
894 .map(|(heading, text)| format!("## {heading}\n\n{text}"))
895 .collect();
896 if !finding.detail.trim().is_empty()
899 && !sections
900 .iter()
901 .any(|(_, text)| crate::textsim::same_point(text, &finding.detail))
902 {
903 out.insert(0, finding.detail.trim().to_string());
904 }
905 out.join("\n\n")
906}
907
908fn file_nonblocking(
915 repo: &Repo,
916 findings: &[Finding],
917 subject: i64,
918 state: &mut IssueRun,
919 cfg: &Config,
920) {
921 for finding in findings {
922 let keep = match finding.severity {
923 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking,
924 Severity::Nit => cfg.loop_cfg.file_nits,
925 Severity::Blocking => false,
926 };
927 if !keep || !finding.in_scope {
928 continue;
929 }
930 if let Some(url) = file_followup(repo, &finding.title, &finding.detail, subject, cfg, state)
931 {
932 state.filed.push(url);
933 }
934 }
935}
936
937fn bullets(lines: &[String]) -> String {
947 lines
948 .iter()
949 .map(|l| format!("- {l}"))
950 .collect::<Vec<_>>()
951 .join("\n")
952}
953
954fn located(finding: &Finding, style: &Style) -> String {
955 let title = style::title(&finding.title, style);
956 match finding.where_at() {
957 "general" => title,
958 file => format!("{title} ({file})"),
959 }
960}
961
962pub enum Ending<'a> {
964 Approved,
966 OutOfRounds,
969 Deadlocked(&'a [Finding]),
972}
973
974pub fn post_outcome(
986 repo: &Repo,
987 pr_number: i64,
988 state: &IssueRun,
989 ledger: &Ledger,
990 ending: Ending<'_>,
991) {
992 if repo.style.pr_comments != PrComments::Outcome {
993 return;
994 }
995 let Some(text) = outcome_comment(state, ledger, &ending, &repo.style) else {
996 return;
997 };
998 if let Err(e) = repo.comment_pr(pr_number, &text) {
999 logdim!("could not post the outcome comment: {e}");
1000 }
1001}
1002
1003fn refutation_of(finding: &Finding, state: &IssueRun, ledger: &Ledger) -> Option<String> {
1006 if let Some(d) = state
1007 .disputes
1008 .iter()
1009 .find(|d| same_point(&d.title, &finding.title))
1010 {
1011 if !d.reasoning.trim().is_empty() {
1012 return Some(d.reasoning.clone());
1013 }
1014 }
1015 ledger
1016 .get(&finding_key(&finding.title, &finding.file))
1017 .map(|entry| entry.reasoning.clone())
1018 .filter(|r| !r.trim().is_empty())
1019}
1020
1021pub fn filed_issue_number(filed: &str) -> Option<i64> {
1026 filed
1027 .rsplit('/')
1028 .next()
1029 .and_then(|tail| tail.parse::<i64>().ok())
1030 .filter(|n| *n > 0)
1031}
1032
1033fn as_reference(url: &str) -> String {
1034 match url.rsplit('/').next().and_then(|n| n.parse::<u64>().ok()) {
1035 Some(number) => format!("#{number}"),
1036 None => url.to_string(),
1037 }
1038}
1039
1040pub fn outcome_comment(
1041 state: &IssueRun,
1042 ledger: &Ledger,
1043 ending: &Ending<'_>,
1044 style: &Style,
1045) -> Option<String> {
1046 let mut out: Vec<String> = Vec::new();
1047 let mut already: Vec<String> = Vec::new();
1050
1051 match ending {
1052 Ending::Approved => {
1053 if state.disputes.is_empty() && state.filed.is_empty() {
1054 return None;
1057 }
1058 out.push("Reviewed, nothing blocking a merge.".into());
1059 }
1060 Ending::OutOfRounds => out.push(
1061 "Not signed off: the last round of fixes was pushed but has not been reviewed.".into(),
1062 ),
1063 Ending::Deadlocked(points) => {
1064 let lines: Vec<String> = points
1070 .iter()
1071 .map(|f| {
1072 let where_at = match f.where_at() {
1073 "general" => String::new(),
1074 file => format!(" ({file})"),
1075 };
1076 let title = style::title(&f.title, style);
1077 already.push(title.clone());
1078 match refutation_of(f, state, ledger) {
1079 Some(reason) => format!(
1080 "{title}{where_at}. Refuted as: {}",
1081 style::summary(&reason, style)
1082 ),
1083 None => format!("{title}{where_at}"),
1084 }
1085 })
1086 .collect();
1087 out.push("Needs your decision. The reviewers could not settle this:".into());
1088 out.push(bullets(&lines));
1089 }
1090 }
1091
1092 let disputes: Vec<&crate::model::Dispute> = state
1093 .disputes
1094 .iter()
1095 .filter(|d| !already.iter().any(|t| same_point(t, &d.title)))
1096 .collect();
1097 if !disputes.is_empty() {
1098 let lines: Vec<String> = disputes
1101 .iter()
1102 .map(|d| {
1103 format!(
1104 "{}. {}",
1105 style::title(&d.title, style),
1106 style::sentence(&d.reasoning, style)
1107 )
1108 })
1109 .collect();
1110 out.push(format!("Raised and refuted:\n{}", bullets(&lines)));
1111 }
1112
1113 if !state.filed.is_empty() {
1114 let refs: Vec<String> = state.filed.iter().map(|u| as_reference(u)).collect();
1115 out.push(format!("Filed separately: {}", refs.join(", ")));
1116 }
1117
1118 Some(out.join("\n\n"))
1119}
1120
1121pub fn pr_body(issue: i64, work: &Implementation, style: &Style) -> String {
1131 let mut parts = vec![format!("Closes #{issue}")];
1132
1133 for lead in [&work.summary, &work.problem] {
1134 let text = style::sentence(lead, style);
1135 if !text.is_empty() {
1136 parts.push(text);
1137 }
1138 }
1139 parts.extend(section("What changed", &work.changes, style));
1140 parts.extend(section("How to test", &work.testing, style));
1141
1142 let notes = style::sentence(work.notes.as_deref().unwrap_or_default(), style);
1143 if !notes.is_empty() {
1144 parts.push(format!("## Notes\n\n{notes}"));
1145 }
1146
1147 style::body(&parts.join("\n\n"), style)
1148}
1149
1150fn section(heading: &str, lines: &[String], style: &Style) -> Option<String> {
1157 let items: Vec<String> = lines
1158 .iter()
1159 .map(|line| style::summary(line, style))
1160 .filter(|line| !line.is_empty())
1161 .collect();
1162 if items.is_empty() {
1163 return None;
1164 }
1165 Some(format!("## {heading}\n\n{}", bullets(&items)))
1166}
1167
1168fn no_pr_note(work: &Implementation, style: &Style) -> String {
1175 let reason = style::sentence(&work.reason, style);
1176 if !reason.is_empty() {
1177 return reason;
1178 }
1179 if work.not_worth_doing {
1180 "Left alone after reading the code, with no reason given.".to_string()
1181 } else {
1182 "Nothing was committed, so there is nothing to review.".to_string()
1183 }
1184}
1185
1186pub fn review_comment(holder: &str, round: u32, review: &Review, style: &Style) -> String {
1190 let by = |severity: Severity| -> Vec<&Finding> {
1191 review
1192 .findings
1193 .iter()
1194 .filter(|f| f.severity == severity && f.in_scope)
1195 .collect()
1196 };
1197 let blocking = by(Severity::Blocking);
1198 let non_blocking = by(Severity::NonBlocking);
1199 let nits = by(Severity::Nit);
1200 let out_of_scope: Vec<&Finding> = review.findings.iter().filter(|f| !f.in_scope).collect();
1201
1202 let mut counts = Vec::new();
1203 if !blocking.is_empty() {
1204 counts.push(format!("{} blocking", blocking.len()));
1205 }
1206 if !non_blocking.is_empty() {
1207 counts.push(format!("{} non-blocking", non_blocking.len()));
1208 }
1209 if !nits.is_empty() {
1210 counts.push(format!("{} nit", nits.len()));
1211 }
1212 if !out_of_scope.is_empty() {
1213 counts.push(format!("{} out of scope", out_of_scope.len()));
1214 }
1215 let headline = if counts.is_empty() {
1216 "no findings".to_string()
1217 } else {
1218 counts.join(", ")
1219 };
1220
1221 let _ = (holder, round, headline);
1222 let mut out = Vec::new();
1223 let summary = style::summary(&review.summary, style);
1224 if !summary.is_empty() {
1225 out.push(summary);
1226 }
1227
1228 if !blocking.is_empty() {
1229 let lines: Vec<String> = blocking
1230 .iter()
1231 .map(|f| {
1232 let detail = style::detail(&f.detail, style);
1233 if detail.is_empty() {
1234 located(f, style)
1235 } else {
1236 format!("{}. {detail}", located(f, style))
1237 }
1238 })
1239 .collect();
1240 out.push(format!("blocking\n{}", bullets(&lines)));
1241 }
1242
1243 for (label, group) in [
1246 ("non-blocking", &non_blocking),
1247 ("nits", &nits),
1248 ("out of scope", &out_of_scope),
1249 ] {
1250 if group.is_empty() {
1251 continue;
1252 }
1253 let lines: Vec<String> = group.iter().map(|f| located(f, style)).collect();
1254 out.push(format!("{label}\n{}", bullets(&lines)));
1255 }
1256
1257 out.join("\n\n")
1258}
1259
1260pub fn disposition_comment(
1264 author: &str,
1265 response: &ResponseDoc,
1266 fixed: &[String],
1267 refuted: &[String],
1268 filed: &[String],
1269 style: &Style,
1270) -> Option<String> {
1271 if fixed.is_empty() && refuted.is_empty() && filed.is_empty() {
1272 return None;
1273 }
1274 let mut counts = Vec::new();
1275 if !fixed.is_empty() {
1276 counts.push(format!("{} fixed", fixed.len()));
1277 }
1278 if !refuted.is_empty() {
1279 counts.push(format!("{} refuted", refuted.len()));
1280 }
1281 if !filed.is_empty() {
1282 counts.push(format!("{} filed", filed.len()));
1283 }
1284
1285 let _ = (author, counts);
1286 let mut out = Vec::new();
1287 let summary = style::summary(&response.summary, style);
1288 if !summary.is_empty() {
1289 out.push(summary);
1290 }
1291 if !refuted.is_empty() {
1292 out.push(format!("refuted\n{}", bullets(refuted)));
1293 }
1294 if !fixed.is_empty() {
1295 out.push(format!("fixed\n{}", bullets(fixed)));
1296 }
1297 if !filed.is_empty() {
1298 out.push(format!("filed\n{}", bullets(filed)));
1299 }
1300 Some(out.join("\n\n"))
1301}
1302
1303pub fn skip_comment(item: &SkippedItem, style: &Style) -> String {
1311 let reasons = item
1312 .reasons
1313 .values()
1314 .map(|reason| style::sentence(reason, style));
1315 let lines = crate::textsim::dedupe_by(reasons, crate::textsim::same_reason);
1319 bullets(&lines)
1320}
1321
1322pub(crate) fn findings_for_prompt(findings: &[Finding]) -> String {
1325 if findings.is_empty() {
1326 return "(none)".to_string();
1327 }
1328 findings
1329 .iter()
1330 .map(|f| {
1331 let scope = if f.in_scope { "" } else { " [out of scope]" };
1332 format!(
1333 "- [{}]{scope} {} ({})\n {}",
1334 f.severity,
1335 f.title,
1336 f.where_at(),
1337 f.detail
1338 )
1339 })
1340 .collect::<Vec<_>>()
1341 .join("\n")
1342}
1343
1344#[cfg(test)]
1345mod tests {
1346 use super::*;
1347 use crate::model::Verdict;
1348
1349 fn style() -> Style {
1350 Style::default()
1351 }
1352
1353 fn finding(severity: &str, title: &str, detail: &str, file: &str, in_scope: bool) -> Finding {
1354 Finding {
1355 severity: Severity::parse_lenient(severity).unwrap(),
1356 title: title.into(),
1357 detail: detail.into(),
1358 file: file.into(),
1359 in_scope,
1360 ..Default::default()
1361 }
1362 }
1363
1364 fn review(summary: &str, findings: Vec<Finding>) -> Review {
1365 Review {
1366 verdict: Verdict::Approve,
1367 next_action: NextAction::Merge,
1368 summary: summary.into(),
1369 findings,
1370 }
1371 }
1372
1373 fn cfg_with(worktrees: bool, keep: bool) -> Config {
1376 let text = "[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n";
1377 let mut cfg = crate::config::parse(text).unwrap();
1378 cfg.loop_cfg.worktrees = worktrees;
1379 cfg.loop_cfg.keep_worktrees = keep;
1380 cfg
1381 }
1382
1383 #[test]
1384 fn a_worktree_is_released_on_every_finished_outcome() {
1385 let cfg = cfg_with(true, false);
1386 for status in [Status::Approved, Status::Merged, Status::Abandoned] {
1387 assert!(should_release(&cfg, status), "{status}");
1388 }
1389 }
1390
1391 #[test]
1394 fn a_worktree_is_kept_only_where_a_human_has_to_look() {
1395 let cfg = cfg_with(true, false);
1396 assert!(!should_release(&cfg, Status::Escalated));
1397 assert!(!should_release(&cfg, Status::Error));
1398 }
1399
1400 #[test]
1401 fn the_keep_flag_overrides_everything() {
1402 assert!(!should_release(&cfg_with(true, true), Status::Approved));
1403 }
1404
1405 #[test]
1406 fn nothing_is_released_when_worktrees_are_off() {
1407 assert!(!should_release(&cfg_with(false, false), Status::Approved));
1408 }
1409
1410 #[test]
1414 fn a_fresh_run_starts_at_one() {
1415 assert_eq!((1, 3), round_window(1, 3));
1416 assert_eq!((1, 5), round_window(1, 5));
1417 }
1418
1419 #[test]
1423 fn a_resumed_run_gets_a_full_fresh_budget() {
1424 assert_eq!((6, 10), round_window(6, 5));
1425 assert_eq!((11, 13), round_window(11, 3));
1426 }
1427
1428 #[test]
1429 fn a_budget_of_one_is_a_single_round() {
1430 assert_eq!((6, 6), round_window(6, 1));
1431 }
1432
1433 #[test]
1434 fn round_numbers_keep_counting_across_sessions() {
1435 let mut start = 1;
1437 let mut seen = Vec::new();
1438 for _ in 0..3 {
1439 let (first, last) = round_window(start, 3);
1440 seen.push((first, last));
1441 start = last + 1;
1442 }
1443 assert_eq!(vec![(1, 3), (4, 6), (7, 9)], seen);
1444 }
1445
1446 fn ledger_with(title: &str, file: &str) -> Ledger {
1449 let mut ledger = Ledger::new();
1450 ledger.insert(
1451 finding_key(title, file),
1452 LedgerEntry {
1453 title: title.into(),
1454 file: file.into(),
1455 reasoning: "no".into(),
1456 round: 1,
1457 reraised: 0,
1458 },
1459 );
1460 ledger
1461 }
1462
1463 #[test]
1464 fn a_point_refuted_and_re_raised_twice_escalates() {
1465 let mut ledger = ledger_with("nit about naming", "a.rs");
1466 let mut state = IssueRun::new(1, "t");
1467 let blocking = vec![finding("blocking", "nit about naming", "d", "a.rs", true)];
1468 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
1469 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
1470 }
1471
1472 #[test]
1473 fn an_untracked_finding_does_not_escalate() {
1474 let mut state = IssueRun::new(1, "t");
1475 let blocking = vec![finding("blocking", "brand new", "d", "a.rs", true)];
1476 assert!(!check_relitigation(
1477 &mut Ledger::new(),
1478 &blocking,
1479 &mut state
1480 ));
1481 }
1482
1483 #[test]
1487 fn a_refutation_lands_on_the_key_the_next_round_will_look_up() {
1488 let blocking = vec![finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
1489 let recorded = finding_key(&blocking[0].title, &blocking[0].file);
1490
1491 let matched = matching_finding(&blocking, "unbounded loop!").expect("should match");
1492 assert_eq!(recorded, finding_key("unbounded loop!", &matched.file));
1493 }
1494
1495 #[test]
1500 fn the_ledger_key_uses_the_reviewers_wording_not_the_authors() {
1501 let findings = vec![finding(
1502 "blocking",
1503 "Panic on multi-byte input",
1504 "d",
1505 "src/style.rs",
1506 true,
1507 )];
1508 let reworded = "Panic on multibyte input";
1509
1510 let source = matching_finding(&findings, reworded).expect("still matches");
1511 assert_ne!(
1512 finding_key(reworded, &source.file),
1513 finding_key(&source.title, &source.file),
1514 "the two spellings must genuinely hash apart, or this test proves nothing"
1515 );
1516
1517 let recorded = finding_key(&source.title, &source.file);
1519 let looked_up = finding_key(&findings[0].title, &findings[0].file);
1520 assert_eq!(recorded, looked_up);
1521 }
1522
1523 #[test]
1524 fn a_disposition_matches_its_finding_despite_wording_noise() {
1525 let findings = vec![finding(
1526 "blocking",
1527 "Unbounded loop!",
1528 "d",
1529 "src/x.rs",
1530 true,
1531 )];
1532 assert!(matching_finding(&findings, "unbounded loop").is_some());
1533 assert!(matching_finding(&findings, "something else").is_none());
1534 }
1535
1536 #[test]
1537 fn the_settled_block_is_empty_when_nothing_is_settled() {
1538 assert_eq!("", settled_block(&Ledger::new()));
1539 }
1540
1541 #[test]
1542 fn the_settled_block_names_each_refutation() {
1543 let block = settled_block(&ledger_with("a point", "x.rs"));
1544 assert!(block.contains("a point"));
1545 assert!(block.contains("settled"));
1546 }
1547
1548 #[test]
1551 fn a_clean_review_is_just_the_verdict() {
1554 let text = review_comment("codex", 1, &review("Looks correct.", vec![]), &style());
1555 assert_eq!("Looks correct.", text);
1556 }
1557
1558 #[test]
1559 fn a_review_leads_with_the_counts() {
1560 let text = review_comment(
1561 "codex",
1562 2,
1563 &review(
1564 "One real problem.",
1565 vec![
1566 finding(
1567 "blocking",
1568 "Loop never terminates",
1569 "Confirmed by running it.",
1570 "src/a.rs",
1571 true,
1572 ),
1573 finding("non-blocking", "Name is vague", "d", "src/b.rs", true),
1574 finding("nit", "Log wording", "d", "", true),
1575 ],
1576 ),
1577 &style(),
1578 );
1579 assert!(text.starts_with("One real problem."), "{text}");
1580 assert!(!text.contains("codex"), "no agent name: {text}");
1581 assert!(!text.contains("round 2"), "no round number: {text}");
1582 }
1583
1584 #[test]
1587 fn only_blocking_findings_carry_their_detail() {
1588 let text = review_comment(
1589 "codex",
1590 1,
1591 &review(
1592 "s",
1593 vec![
1594 finding("blocking", "Loop", "BLOCKING DETAIL", "a.rs", true),
1595 finding("non-blocking", "Name", "NONBLOCKING DETAIL", "b.rs", true),
1596 ],
1597 ),
1598 &style(),
1599 );
1600 assert!(text.contains("BLOCKING DETAIL"), "{text}");
1601 assert!(!text.contains("NONBLOCKING DETAIL"), "{text}");
1602 }
1603
1604 #[test]
1605 fn a_thorough_explanation_reaches_the_author_intact() {
1608 let detail = "Reproduced by running the 429 test with max_attempts unset. ".repeat(8);
1609 let text = review_comment(
1610 "codex",
1611 1,
1612 &review(
1613 "One problem.",
1614 vec![finding("blocking", "T", &detail, "a.rs", true)],
1615 ),
1616 &style(),
1617 );
1618 assert!(
1619 text.contains(detail.trim()),
1620 "the explanation was cut:\n{text}"
1621 );
1622 }
1623
1624 #[test]
1626 fn a_runaway_model_is_still_bounded() {
1627 let long = "filler words. ".repeat(20_000);
1628 let text = review_comment(
1629 "codex",
1630 1,
1631 &review(&long, vec![finding("blocking", "T", &long, "a.rs", true)]),
1632 &style(),
1633 );
1634 assert!(
1635 text.len() < 30_000,
1636 "review comment was {} chars",
1637 text.len()
1638 );
1639 }
1640
1641 #[test]
1642 fn a_general_finding_has_no_empty_parenthesis() {
1643 let text = review_comment(
1644 "codex",
1645 1,
1646 &review("s", vec![finding("blocking", "Something", "d", "", true)]),
1647 &style(),
1648 );
1649 assert!(!text.contains("()"), "{text}");
1650 assert!(!text.contains("(general)"), "{text}");
1651 }
1652
1653 #[test]
1654 fn out_of_scope_findings_are_counted_separately() {
1655 let text = review_comment(
1656 "codex",
1657 1,
1658 &review(
1659 "s",
1660 vec![finding("blocking", "Old bug", "d", "a.rs", false)],
1661 ),
1662 &style(),
1663 );
1664 assert!(text.contains("out of scope"), "{text}");
1665 assert!(text.contains("Old bug"), "{text}");
1666 }
1667
1668 #[test]
1669 fn a_disposition_comment_leads_with_counts_and_keeps_refutations() {
1670 let response = ResponseDoc {
1671 summary: "Two of three were right.".into(),
1672 dispositions: vec![],
1673 };
1674 let text = disposition_comment(
1675 "claude",
1676 &response,
1677 &["Fixed thing".to_string()],
1678 &["Wrong thing. Because the caller already checks.".to_string()],
1679 &[],
1680 &style(),
1681 )
1682 .unwrap();
1683 assert!(text.starts_with("Two of three were right."), "{text}");
1684 assert!(!text.contains("claude"), "no agent name: {text}");
1685 assert!(
1686 text.contains("Because the caller already checks."),
1687 "{text}"
1688 );
1689 }
1690
1691 #[test]
1692 fn an_empty_disposition_comment_is_not_posted() {
1693 let response = ResponseDoc {
1694 summary: "s".into(),
1695 dispositions: vec![],
1696 };
1697 assert!(disposition_comment("claude", &response, &[], &[], &[], &style()).is_none());
1698 }
1699
1700 fn worked() -> Implementation {
1702 Implementation {
1703 summary: "Retry a 429 instead of failing the run.".into(),
1704 problem: "A rate limited response was treated as fatal, so one throttled call ended \
1705 a run that had hours of work left in it."
1706 .into(),
1707 changes: vec![
1708 "`send` retries a 429 with the delay the header asks for".into(),
1709 "the retry budget is bounded, so a permanent 429 still ends".into(),
1710 ],
1711 testing: vec![
1712 "`cargo test retries_a_429`".into(),
1713 "point it at a throttled endpoint and watch it finish".into(),
1714 ],
1715 ..Implementation::default()
1716 }
1717 }
1718
1719 #[test]
1720 fn a_pr_body_is_what_it_closes_and_what_changed() {
1723 let body = pr_body(42, &worked(), &style());
1724 assert_eq!(
1725 "Closes #42\n\n\
1726 Retry a 429 instead of failing the run.\n\n\
1727 A rate limited response was treated as fatal, so one throttled call \
1728 ended a run that had hours of work left in it.\n\n\
1729 ## What changed\n\n\
1730 - `send` retries a 429 with the delay the header asks for\n\
1731 - the retry budget is bounded, so a permanent 429 still ends\n\n\
1732 ## How to test\n\n\
1733 - `cargo test retries_a_429`\n\
1734 - point it at a throttled endpoint and watch it finish",
1735 body
1736 );
1737 }
1738
1739 #[test]
1742 fn a_body_with_nothing_to_list_carries_no_empty_headings() {
1743 let work = Implementation {
1744 summary: "Retry a 429 instead of failing the run.".into(),
1745 ..Implementation::default()
1746 };
1747 assert_eq!(
1748 "Closes #42\n\nRetry a 429 instead of failing the run.",
1749 pr_body(42, &work, &style())
1750 );
1751 }
1752
1753 #[test]
1754 fn a_pr_body_survives_an_implementor_that_said_nothing() {
1755 assert_eq!(
1756 "Closes #7",
1757 pr_body(7, &Implementation::default(), &style())
1758 );
1759 }
1760
1761 #[test]
1764 fn blank_list_entries_do_not_earn_a_heading() {
1765 let work = Implementation {
1766 summary: "Did a thing.".into(),
1767 changes: vec![String::new(), " ".into()],
1768 ..Implementation::default()
1769 };
1770 let body = pr_body(42, &work, &style());
1771 assert!(!body.contains("What changed"), "{body}");
1772 }
1773
1774 #[test]
1775 fn notes_appear_only_when_there_is_something_to_note() {
1776 let mut work = worked();
1777 assert!(!pr_body(42, &work, &style()).contains("## Notes"));
1778 work.notes = Some("The retry is not applied to streaming calls.".into());
1779 let body = pr_body(42, &work, &style());
1780 assert!(body.contains("## Notes"), "{body}");
1781 assert!(body.contains("streaming calls"), "{body}");
1782 }
1783
1784 #[test]
1787 fn declining_posts_the_reason_and_not_the_summary() {
1788 let work = Implementation {
1789 not_worth_doing: true,
1790 reason: "Already fixed in 1.2, and the report predates it.".into(),
1791 summary: "Nothing to do.".into(),
1792 ..Implementation::default()
1793 };
1794 assert_eq!(
1795 "Already fixed in 1.2, and the report predates it.",
1796 no_pr_note(&work, &style())
1797 );
1798 }
1799
1800 #[test]
1801 fn reporting_work_and_committing_none_says_that_rather_than_the_summary() {
1802 let work = Implementation {
1803 summary: "Retry a 429 instead of failing the run.".into(),
1804 ..Implementation::default()
1805 };
1806 let note = no_pr_note(&work, &style());
1807 assert_eq!(
1808 "Nothing was committed, so there is nothing to review.",
1809 note
1810 );
1811 }
1812
1813 #[test]
1814 fn declining_without_a_reason_still_says_something() {
1815 let work = Implementation {
1816 not_worth_doing: true,
1817 ..Implementation::default()
1818 };
1819 assert!(no_pr_note(&work, &style()).contains("no reason given"));
1820 }
1821
1822 #[test]
1823 fn a_skip_comment_is_only_the_reasoning() {
1824 let item = SkippedItem {
1825 issue: 3,
1826 title: "t".into(),
1827 reasons: [
1828 ("claude".to_string(), "Already fixed in 1.2.".to_string()),
1829 ("codex".to_string(), "Duplicate of #2.".to_string()),
1830 ]
1831 .into_iter()
1832 .collect(),
1833 };
1834 let text = skip_comment(&item, &style());
1835 assert!(text.contains("Already fixed in 1.2."), "{text}");
1836 assert!(text.contains("Duplicate of #2."), "{text}");
1837 assert!(
1838 !text.contains("claude") && !text.contains("codex"),
1839 "{text}"
1840 );
1841 assert!(!text.to_lowercase().contains("not scheduled"), "{text}");
1842 assert!(text.lines().count() <= 3, "{text}");
1843 }
1844
1845 #[test]
1846 fn findings_for_a_model_keep_full_detail() {
1847 let long = "x".repeat(2000);
1848 let text = findings_for_prompt(&[finding("blocking", "T", &long, "a.rs", true)]);
1849 assert!(
1850 text.contains(&long),
1851 "a model needs the whole finding, only humans need brevity"
1852 );
1853 }
1854
1855 #[test]
1856 fn findings_for_a_model_are_never_empty() {
1857 assert_eq!("(none)", findings_for_prompt(&[]));
1858 }
1859}
1860
1861#[cfg(test)]
1862mod outcome_tests {
1863 use super::*;
1864 use crate::model::{Dispute, Severity};
1865
1866 fn style() -> Style {
1867 Style::default()
1868 }
1869
1870 fn state_with(disputes: Vec<(&str, &str)>, filed: Vec<&str>) -> IssueRun {
1871 let mut s = IssueRun::new(482, "t");
1872 s.disputes = disputes
1873 .into_iter()
1874 .map(|(title, reasoning)| Dispute {
1875 title: title.into(),
1876 reasoning: reasoning.into(),
1877 })
1878 .collect();
1879 s.filed = filed.into_iter().map(String::from).collect();
1880 s
1881 }
1882
1883 fn finding(title: &str, file: &str) -> Finding {
1884 Finding {
1885 severity: Severity::Blocking,
1886 title: title.into(),
1887 detail: "d".into(),
1888 file: file.into(),
1889 in_scope: true,
1890 ..Default::default()
1891 }
1892 }
1893
1894 #[test]
1897 fn a_clean_approval_says_nothing() {
1898 let state = state_with(vec![], vec![]);
1899 assert!(outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).is_none());
1900 }
1901
1902 #[test]
1903 fn an_approval_that_filed_follow_ups_links_them() {
1904 let state = state_with(
1905 vec![],
1906 vec![
1907 "https://github.com/you/thing/issues/485",
1908 "https://github.com/you/thing/issues/486",
1909 ],
1910 );
1911 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
1912 assert!(text.contains("Filed separately: #485, #486"), "{text}");
1913 }
1914
1915 #[test]
1919 fn running_out_of_rounds_says_what_that_means_for_the_reader() {
1920 let state = state_with(vec![], vec![]);
1921 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
1922 assert!(text.contains("has not been reviewed"), "{text}");
1923 assert!(
1924 !text.to_lowercase().contains("round 3"),
1925 "no round numbers: {text}"
1926 );
1927 assert!(!text.to_lowercase().contains("convergence"), "{text}");
1928 }
1929
1930 #[test]
1931 fn a_deadlock_names_the_point_they_could_not_settle() {
1932 let state = state_with(vec![], vec![]);
1933 let points = [finding("Retry loop never terminates", "src/net.rs:88")];
1934 let text = outcome_comment(
1935 &state,
1936 &Ledger::new(),
1937 &Ending::Deadlocked(&points),
1938 &style(),
1939 )
1940 .unwrap();
1941 assert!(
1942 text.contains("Retry loop never terminates (src/net.rs:88)"),
1943 "{text}"
1944 );
1945 assert!(text.contains("could not settle"), "{text}");
1946 }
1947
1948 #[test]
1950 fn refutations_survive_because_nothing_else_carries_them() {
1951 let state = state_with(
1952 vec![(
1953 "Error is swallowed",
1954 "the caller already validates the file",
1955 )],
1956 vec![],
1957 );
1958 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
1959 assert!(text.contains("Raised and refuted:"), "{text}");
1960 assert!(
1961 text.contains("The caller already validates the file"),
1962 "{text}"
1963 );
1964 }
1965
1966 #[test]
1967 fn no_agent_names_counts_or_round_numbers_reach_the_thread() {
1968 let state = state_with(
1969 vec![("A point", "a reason")],
1970 vec!["https://github.com/you/thing/issues/485"],
1971 );
1972 for ending in [Ending::Approved, Ending::OutOfRounds] {
1973 let text = outcome_comment(&state, &Ledger::new(), &ending, &style()).unwrap();
1974 let lower = text.to_lowercase();
1975 for banned in ["claude", "codex", "blocking,", "nit,", " fixed."] {
1976 assert!(
1977 !lower.contains(banned),
1978 "{banned:?} leaked into the thread:\n{text}"
1979 );
1980 }
1981 for n in 1..9 {
1983 assert!(
1984 !lower.contains(&format!("round {n}")),
1985 "a round number leaked into the thread:\n{text}"
1986 );
1987 }
1988 }
1989 }
1990
1991 #[test]
1992 fn a_refutation_is_allowed_to_make_its_case() {
1995 let reasoning = "The caller validates against the schema first. \
1996 The discarded error is therefore unreachable in practice. ";
1997 let state = state_with(
1998 vec![("A point", &reasoning.repeat(6))],
1999 vec!["https://github.com/you/thing/issues/485"],
2000 );
2001 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
2002 assert!(
2003 !text.contains("..."),
2004 "nothing was cut mid thought:\n{text}"
2005 );
2006 assert!(text.len() < 4000, "{} chars", text.len());
2007 }
2008
2009 #[test]
2010 fn a_url_that_is_not_an_issue_link_is_left_alone() {
2011 assert_eq!(
2012 "#485",
2013 as_reference("https://github.com/you/thing/issues/485")
2014 );
2015 assert_eq!("note: something", as_reference("note: something"));
2016 }
2017}
2018
2019#[cfg(test)]
2020mod filed_reference_tests {
2021 use super::*;
2022
2023 #[test]
2024 fn an_issue_url_yields_its_number() {
2025 assert_eq!(
2026 Some(485),
2027 filed_issue_number("https://github.com/you/thing/issues/485")
2028 );
2029 }
2030
2031 #[test]
2034 fn a_local_note_yields_nothing() {
2035 assert_eq!(None, filed_issue_number("note: Retry is unbounded"));
2036 assert_eq!(None, filed_issue_number(""));
2037 assert_eq!(
2038 None,
2039 filed_issue_number("https://github.com/you/thing/issues/")
2040 );
2041 }
2042}
2043
2044#[cfg(test)]
2045mod followup_restraint_tests {
2046 use super::*;
2047 use crate::model::Severity;
2048
2049 fn cfg_with(followups: Followups, non_blocking: bool, nits: bool, cap: usize) -> Config {
2050 let mut cfg =
2051 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
2052 .unwrap();
2053 cfg.loop_cfg.followups = followups;
2054 cfg.loop_cfg.file_non_blocking = non_blocking;
2055 cfg.loop_cfg.file_nits = nits;
2056 cfg.loop_cfg.max_followups = cap;
2057 cfg
2058 }
2059
2060 fn finding(severity: Severity, title: &str, in_scope: bool) -> Finding {
2061 Finding {
2062 severity,
2063 title: title.into(),
2064 detail: "d".into(),
2065 file: "a.rs".into(),
2066 in_scope,
2067 ..Default::default()
2068 }
2069 }
2070
2071 #[test]
2075 fn a_non_blocking_finding_is_not_a_tracker_item_by_default() {
2076 let cfg = cfg_with(Followups::Issues, false, false, 5);
2077 assert!(!cfg.loop_cfg.file_non_blocking);
2078 assert!(!cfg.loop_cfg.file_nits);
2079 }
2080
2081 #[test]
2082 fn follow_ups_stay_off_the_tracker_by_default() {
2083 let cfg =
2084 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
2085 .unwrap();
2086 assert_eq!(
2087 Followups::Local,
2088 cfg.loop_cfg.followups,
2089 "the tracker is somebody's queue; the default must not write to it"
2090 );
2091 assert_eq!(5, cfg.loop_cfg.max_followups);
2092 }
2093
2094 #[test]
2096 fn only_out_of_scope_defects_qualify_at_the_defaults() {
2097 let cfg = cfg_with(Followups::Issues, false, false, 5);
2098 let qualifies = |f: &Finding| match f.severity {
2099 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking && f.in_scope,
2100 Severity::Nit => cfg.loop_cfg.file_nits && f.in_scope,
2101 Severity::Blocking => false,
2102 } || !f.in_scope;
2103
2104 assert!(qualifies(&finding(
2105 Severity::Blocking,
2106 "pre-existing",
2107 false
2108 )));
2109 assert!(!qualifies(&finding(
2110 Severity::NonBlocking,
2111 "improvement",
2112 true
2113 )));
2114 assert!(!qualifies(&finding(Severity::Nit, "taste", true)));
2115 assert!(!qualifies(&finding(
2116 Severity::Blocking,
2117 "fix it here",
2118 true
2119 )));
2120 }
2121
2122 #[test]
2123 fn opening_it_up_lets_non_blocking_findings_through_again() {
2124 let cfg = cfg_with(Followups::Issues, true, false, 5);
2125 assert!(cfg.loop_cfg.file_non_blocking);
2126 }
2127
2128 #[test]
2130 fn the_cap_is_a_real_backstop() {
2131 let cfg = cfg_with(Followups::Issues, false, false, 3);
2132 let mut state = IssueRun::new(1, "t");
2133 state.filed = (0..3).map(|n| format!("url{n}")).collect();
2134 assert!(state.filed.len() >= cfg.loop_cfg.max_followups);
2135 }
2136
2137 #[test]
2141 fn the_cap_bounds_what_one_run_can_spawn() {
2142 let cfg = cfg_with(Followups::Issues, false, false, 5);
2143 assert!(
2144 cfg.loop_cfg.max_followups <= 5,
2145 "a run that can file ten follow-ups is a branching process"
2146 );
2147 }
2148}
2149
2150#[cfg(test)]
2151mod issue_report_tests {
2152 use super::*;
2153 use crate::model::Severity;
2154
2155 fn reported() -> Finding {
2158 Finding {
2159 severity: Severity::Blocking,
2160 title: "sendPaymentAsync bypasses drain mode and spending limits".into(),
2161 detail: "The async path skips every admission check payInvoice applies.".into(),
2162 file: "src/node.ts:412".into(),
2163 in_scope: false,
2164 problem: Some(
2165 "`BeignetNode.sendPaymentAsync()` submits a payment directly to the Lightning \
2166 engine without applying the safeguards used by `payInvoice()`.\n\nThe async path \
2167 does not:\n\n- call `_checkDraining()`\n- call `_checkSpendLimit()`"
2168 .into(),
2169 ),
2170 reproduction: Some(
2171 "1. Create a `BeignetNode` with `dailySpendLimitSats: 1`.\n2. Enable drain mode.\n\
2172 3. Submit a 1,000 sat invoice.\n\nActual result:\n\n- The engine is called.\n\
2173 - `spentSats` remains 0."
2174 .into(),
2175 ),
2176 impact: Some(
2177 "An authorized client can submit async payments up to the available outbound \
2178 liquidity despite the configured limits."
2179 .into(),
2180 ),
2181 expected: Some(
2182 "- Reject new payments while draining.\n- Enforce the per-payment limit before \
2183 submission.\n- Cover both paths with regression tests.\n\nThis predates the \
2184 current branch."
2185 .into(),
2186 ),
2187 }
2188 }
2189
2190 #[test]
2191 fn a_reported_finding_becomes_a_bug_report() {
2192 let body = issue_report(&reported());
2193 for heading in [
2194 "## Problem",
2195 "## Reproduction",
2196 "## Impact",
2197 "## Expected behavior",
2198 ] {
2199 assert!(body.contains(heading), "missing {heading}:\n{body}");
2200 }
2201 let at = |h: &str| body.find(h).unwrap();
2203 assert!(at("## Problem") < at("## Reproduction"));
2204 assert!(at("## Reproduction") < at("## Impact"));
2205 assert!(at("## Impact") < at("## Expected behavior"));
2206 }
2207
2208 #[test]
2209 fn the_substance_survives_the_outbound_gates() {
2210 let repo_style = Style::default();
2211 let body = crate::style::issue_body(&issue_report(&reported()), &repo_style);
2212 for kept in [
2213 "_checkDraining()",
2214 "Actual result:",
2215 "outbound liquidity",
2216 "regression tests",
2217 "predates the current branch",
2218 ] {
2219 assert!(body.contains(kept), "the gate ate {kept:?}:\n{body}");
2220 }
2221 assert!(!body.contains("..."), "something was cut:\n{body}");
2222 }
2223
2224 #[test]
2227 fn an_ordinary_finding_is_still_just_its_detail() {
2228 let plain = Finding {
2229 severity: Severity::NonBlocking,
2230 title: "Name is vague".into(),
2231 detail: "The variable could say what it holds.".into(),
2232 file: "a.rs".into(),
2233 in_scope: true,
2234 ..Default::default()
2235 };
2236 assert_eq!(
2237 "The variable could say what it holds.",
2238 issue_report(&plain)
2239 );
2240 }
2241
2242 #[test]
2245 fn only_the_sections_that_were_written_appear() {
2246 let partial = Finding {
2247 problem: Some("The guard is inverted.".into()),
2248 expected: Some("It should reject rather than accept.".into()),
2249 ..reported()
2250 };
2251 let partial = Finding {
2252 reproduction: None,
2253 impact: None,
2254 ..partial
2255 };
2256 let body = issue_report(&partial);
2257 assert!(body.contains("## Problem") && body.contains("## Expected behavior"));
2258 assert!(!body.contains("## Reproduction"), "{body}");
2259 assert!(!body.contains("## Impact"), "{body}");
2260 }
2261
2262 #[test]
2265 fn the_summary_line_is_not_printed_twice() {
2266 let echoed = Finding {
2267 detail: "The guard is inverted so it rejects valid input.".into(),
2268 problem: Some("The guard is inverted so it rejects valid input.".into()),
2269 reproduction: None,
2270 impact: None,
2271 expected: None,
2272 ..reported()
2273 };
2274 let body = issue_report(&echoed);
2275 assert_eq!(1, body.matches("The guard is inverted").count(), "{body}");
2276 }
2277}