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