1use std::path::{Path, PathBuf};
19
20use crate::agent::{self, Agent};
21use crate::config::{Config, Drafts, 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.drafts == Drafts::UntilApproved && repo.mark_ready(ctx.pr_number) {
538 log!("{}: out of draft", ctx.label);
539 }
540 if cfg.loop_cfg.auto_merge {
541 ctx.release(repo);
546 repo.merge_pr(ctx.pr_number)?;
547 state.status = Status::Merged;
548 repo.clear_state(ctx.pr_number); log!("{}: merged", ctx.label);
550 } else {
551 log!("{}: approved, awaiting human merge", ctx.label);
552 }
553 return Ok(());
554 }
555
556 if review.next_action == NextAction::FixMyself {
557 log!("{}: {holder} fixing its own findings", ctx.label);
558 let prompt = FIX_PROMPT.replace("{findings}", &findings_for_prompt(&blocking));
559 reviewer.ask(&prompt, &ctx.work_dir, effort.as_deref())?;
560 } else {
561 let author_name = cfg.other(&holder);
562 let author = agent::find(agents, &author_name)?;
563 log!(
564 "{}: handing {} finding(s) to {author_name}",
565 ctx.label,
566 blocking.len()
567 );
568 let prompt = RESPOND_PROMPT
569 .replace("{number}", &ctx.subject.to_string())
570 .replace("{findings}", &findings_for_prompt(&blocking));
571 let response: ResponseDoc = author.ask_json(
572 &prompt,
573 &schema::response(),
574 &ctx.work_dir,
575 cfg.effort_for_round(&author.spec, round).as_deref(),
576 )?;
577 apply_dispositions(
578 repo,
579 cfg,
580 &response,
581 &blocking,
582 ledger,
583 state,
584 round,
585 ctx.subject,
586 ctx.pr_number,
587 &author_name,
588 );
589 }
590
591 repo.rewrite_commits_if_needed(&ctx.work_dir, &base)?;
592 repo.push(&ctx.work_dir, &ctx.branch)?;
593 holder = cfg.other(&holder);
594 persist(repo, ctx.pr_number, state, ledger, round, &holder);
595 }
596
597 state.status = Status::Escalated;
598 state
599 .notes
600 .push(exhausted_note(ctx.start_round, last_round));
601 post_outcome(repo, ctx.pr_number, state, ledger, Ending::OutOfRounds);
602 persist(repo, ctx.pr_number, state, ledger, last_round, &holder);
603 Ok(())
604}
605
606fn round_window(start_round: u32, budget: u32) -> (u32, u32) {
612 (start_round, start_round + budget.saturating_sub(1))
613}
614
615fn spent(start_round: u32, last_round: u32) -> (u32, u32) {
619 (last_round.saturating_sub(start_round) + 1, last_round)
620}
621
622fn exhausted_note(start_round: u32, last_round: u32) -> String {
623 let (this_run, total) = spent(start_round, last_round);
624 if this_run == total {
625 format!("no convergence after {this_run} rounds")
626 } else {
627 format!("no convergence after {this_run} more rounds ({total} in total)")
628 }
629}
630
631fn persist(
632 repo: &Repo,
633 pr_number: i64,
634 state: &IssueRun,
635 ledger: &Ledger,
636 round: u32,
637 next_actor: &str,
638) {
639 let payload = PersistedState {
640 version: STATE_VERSION,
641 round,
642 next_actor: next_actor.to_string(),
643 status: state.status,
644 ledger: ledger.clone(),
645 filed: state.filed.clone(),
646 };
647 if let Err(e) = repo.write_state(pr_number, &payload) {
648 logdim!("could not persist state for PR #{pr_number}: {e}");
649 }
650}
651
652fn settled_block(ledger: &Ledger) -> String {
657 if ledger.is_empty() {
658 return String::new();
659 }
660 let lines: Vec<String> = ledger
661 .values()
662 .map(|e| format!("- {}: refuted because {}", e.title, e.reasoning))
663 .collect();
664 format!(
665 "\nThe following points were already raised and refuted. Treat them as settled. Do not \
666 raise them again unless you have new evidence:\n{}",
667 lines.join("\n")
668 )
669}
670
671fn check_relitigation(ledger: &mut Ledger, blocking: &[Finding], state: &mut IssueRun) -> bool {
674 let mut escalate = false;
675 for finding in blocking {
676 let key = finding_key(&finding.title, &finding.file);
677 if let Some(entry) = ledger.get_mut(&key) {
678 entry.reraised += 1;
679 if entry.reraised >= 2 {
680 state.notes.push(format!(
681 "'{}' was refuted and re-raised twice; escalating.",
682 finding.title
683 ));
684 escalate = true;
685 }
686 }
687 }
688 escalate
689}
690
691fn normalise(text: &str) -> String {
692 text.to_lowercase()
693 .chars()
694 .filter(|c| c.is_ascii_alphanumeric() || c.is_whitespace())
695 .collect::<String>()
696 .split_whitespace()
697 .collect::<Vec<_>>()
698 .join(" ")
699}
700
701pub(crate) fn same_point(a: &str, b: &str) -> bool {
706 normalise(a) == normalise(b)
707}
708
709fn matching_finding<'a>(findings: &'a [Finding], title: &str) -> Option<&'a Finding> {
710 let wanted = normalise(title);
711 findings.iter().find(|f| normalise(&f.title) == wanted)
712}
713
714#[allow(clippy::too_many_arguments)]
715fn apply_dispositions(
716 repo: &Repo,
717 cfg: &Config,
718 response: &ResponseDoc,
719 blocking: &[Finding],
720 ledger: &mut Ledger,
721 state: &mut IssueRun,
722 round: u32,
723 subject: i64,
724 pr_number: i64,
725 author: &str,
726) {
727 let mut fixed = Vec::new();
728 let mut refuted = Vec::new();
729 let mut filed = Vec::new();
730
731 for d in &response.dispositions {
732 let source = matching_finding(blocking, &d.title);
733 let file = source
734 .map(|f| f.file.clone())
735 .filter(|f| !f.trim().is_empty())
736 .unwrap_or_else(|| d.file.clone());
737 let canonical = source.map(|f| f.title.as_str()).unwrap_or(d.title.as_str());
744 let title = style::title(canonical, &repo.style);
745
746 match d.action {
747 Action::Refuted => {
748 let reasoning = style::summary(&d.reasoning, &repo.style);
749 ledger.insert(
750 finding_key(canonical, &file),
751 LedgerEntry {
752 title: title.clone(),
753 file: file.clone(),
754 reasoning: reasoning.clone(),
755 round,
756 reraised: 0,
757 },
758 );
759 state.disputes.push(Dispute {
760 title: title.clone(),
761 reasoning: reasoning.clone(),
762 });
763 refuted.push(format!("{title}. {reasoning}"));
764 }
765 Action::FiledIssue => {
766 let new_title = d
767 .new_issue_title
768 .clone()
769 .filter(|t| !t.trim().is_empty())
770 .unwrap_or_else(|| d.title.clone());
771 let new_body = d
772 .new_issue_body
773 .clone()
774 .filter(|b| !b.trim().is_empty())
775 .unwrap_or_else(|| d.reasoning.clone());
776 let recorded = file_followup(repo, &new_title, &new_body, subject, cfg, state);
777 if let Some(url) = recorded {
778 state.filed.push(url.clone());
779 filed.push(url);
780 }
781 }
782 Action::Fixed => fixed.push(title),
783 }
784 }
785
786 if repo.style.pr_comments == PrComments::Rounds {
787 let comment = disposition_comment(author, response, &fixed, &refuted, &filed, &repo.style);
788 if let Some(text) = comment {
789 if let Err(e) = repo.comment_pr(pr_number, &text) {
790 logdim!("could not post the disposition comment: {e}");
791 }
792 }
793 }
794}
795
796fn file_followup(
807 repo: &Repo,
808 title: &str,
809 body: &str,
810 source: i64,
811 cfg: &Config,
812 state: &IssueRun,
813) -> Option<String> {
814 if repo.followups == Followups::None {
815 return None;
816 }
817 if state.filed.len() >= cfg.loop_cfg.max_followups {
820 logwarn!(
821 "already recorded {} follow-ups, not recording '{}'. Raise max_followups if you want \
822 them all.",
823 state.filed.len(),
824 style::title(title, &repo.style)
825 );
826 return None;
827 }
828 let title = match repo.clean_title(title) {
832 Ok(title) => title,
833 Err(e) => {
834 logdim!("could not clean a follow-up title: {e}");
835 return None;
836 }
837 };
838 if title.trim().is_empty() {
839 return None;
840 }
841 let body = format!(
844 "{}\n\nFound while working on #{source}.",
845 style::issue_body(body, &repo.style)
846 );
847
848 if repo.followups == Followups::Local {
849 return repo.append_local_followup(&title, &body);
850 }
851
852 if let Some(existing) = repo.find_similar_issue(&title, &body) {
856 let known = format!("{} {}", existing.title, existing.body);
857 if !existing.open {
858 logdim!(
859 "#{} already covers '{title}' and is closed, leaving it alone",
860 existing.number
861 );
862 return None;
863 }
864 if crate::textsim::adds_information(&body, &known) {
865 match repo.comment_issue(existing.number, &body) {
866 Ok(()) => log!("added to #{}: {title}", existing.number),
867 Err(e) => logdim!("could not add to #{}: {e}", existing.number),
868 }
869 } else {
870 logdim!("#{} already says this, nothing added", existing.number);
871 }
872 return Some(existing.url);
873 }
874
875 match repo.create_issue(&title, &body) {
876 Ok(url) => Some(url),
877 Err(e) => {
878 logdim!("could not file a follow-up for '{title}': {e}");
879 None
880 }
881 }
882}
883
884fn file_out_of_scope(
885 repo: &Repo,
886 findings: &[Finding],
887 subject: i64,
888 state: &mut IssueRun,
889 cfg: &Config,
890) {
891 for finding in findings.iter().filter(|f| !f.in_scope) {
892 let body = issue_report(finding);
893 if let Some(url) = file_followup(repo, &finding.title, &body, subject, cfg, state) {
894 state.filed.push(url);
895 }
896 }
897}
898
899pub fn issue_report(finding: &Finding) -> String {
906 let sections = finding.report_sections();
907 if sections.is_empty() {
908 return finding.detail.clone();
909 }
910 let mut out: Vec<String> = sections
911 .iter()
912 .map(|(heading, text)| format!("## {heading}\n\n{text}"))
913 .collect();
914 if !finding.detail.trim().is_empty()
917 && !sections
918 .iter()
919 .any(|(_, text)| crate::textsim::same_point(text, &finding.detail))
920 {
921 out.insert(0, finding.detail.trim().to_string());
922 }
923 out.join("\n\n")
924}
925
926fn file_nonblocking(
933 repo: &Repo,
934 findings: &[Finding],
935 subject: i64,
936 state: &mut IssueRun,
937 cfg: &Config,
938) {
939 for finding in findings {
940 let keep = match finding.severity {
941 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking,
942 Severity::Nit => cfg.loop_cfg.file_nits,
943 Severity::Blocking => false,
944 };
945 if !keep || !finding.in_scope {
946 continue;
947 }
948 if let Some(url) = file_followup(repo, &finding.title, &finding.detail, subject, cfg, state)
949 {
950 state.filed.push(url);
951 }
952 }
953}
954
955fn bullets(lines: &[String]) -> String {
965 lines
966 .iter()
967 .map(|l| format!("- {l}"))
968 .collect::<Vec<_>>()
969 .join("\n")
970}
971
972fn located(finding: &Finding, style: &Style) -> String {
973 let title = style::title(&finding.title, style);
974 match finding.where_at() {
975 "general" => title,
976 file => format!("{title} ({file})"),
977 }
978}
979
980pub enum Ending<'a> {
982 Approved,
984 OutOfRounds,
987 Deadlocked(&'a [Finding]),
990}
991
992pub fn post_outcome(
1004 repo: &Repo,
1005 pr_number: i64,
1006 state: &IssueRun,
1007 ledger: &Ledger,
1008 ending: Ending<'_>,
1009) {
1010 if repo.style.pr_comments != PrComments::Outcome {
1011 return;
1012 }
1013 let Some(text) = outcome_comment(state, ledger, &ending, &repo.style) else {
1014 return;
1015 };
1016 if let Err(e) = repo.comment_pr(pr_number, &text) {
1017 logdim!("could not post the outcome comment: {e}");
1018 }
1019}
1020
1021fn refutation_of(finding: &Finding, state: &IssueRun, ledger: &Ledger) -> Option<String> {
1024 if let Some(d) = state
1025 .disputes
1026 .iter()
1027 .find(|d| same_point(&d.title, &finding.title))
1028 {
1029 if !d.reasoning.trim().is_empty() {
1030 return Some(d.reasoning.clone());
1031 }
1032 }
1033 ledger
1034 .get(&finding_key(&finding.title, &finding.file))
1035 .map(|entry| entry.reasoning.clone())
1036 .filter(|r| !r.trim().is_empty())
1037}
1038
1039pub fn filed_issue_number(filed: &str) -> Option<i64> {
1044 filed
1045 .rsplit('/')
1046 .next()
1047 .and_then(|tail| tail.parse::<i64>().ok())
1048 .filter(|n| *n > 0)
1049}
1050
1051fn as_reference(url: &str) -> String {
1052 match url.rsplit('/').next().and_then(|n| n.parse::<u64>().ok()) {
1053 Some(number) => format!("#{number}"),
1054 None => url.to_string(),
1055 }
1056}
1057
1058pub fn outcome_comment(
1059 state: &IssueRun,
1060 ledger: &Ledger,
1061 ending: &Ending<'_>,
1062 style: &Style,
1063) -> Option<String> {
1064 let mut out: Vec<String> = Vec::new();
1065 let mut already: Vec<String> = Vec::new();
1068
1069 match ending {
1070 Ending::Approved => {
1071 if state.disputes.is_empty() && state.filed.is_empty() {
1072 return None;
1075 }
1076 out.push("Reviewed, nothing blocking a merge.".into());
1077 }
1078 Ending::OutOfRounds => out.push(
1079 "Not signed off: the last round of fixes was pushed but has not been reviewed.".into(),
1080 ),
1081 Ending::Deadlocked(points) => {
1082 let lines: Vec<String> = points
1088 .iter()
1089 .map(|f| {
1090 let where_at = match f.where_at() {
1091 "general" => String::new(),
1092 file => format!(" ({file})"),
1093 };
1094 let title = style::title(&f.title, style);
1095 already.push(title.clone());
1096 match refutation_of(f, state, ledger) {
1097 Some(reason) => format!(
1098 "{title}{where_at}. Refuted as: {}",
1099 style::summary(&reason, style)
1100 ),
1101 None => format!("{title}{where_at}"),
1102 }
1103 })
1104 .collect();
1105 out.push("Needs your decision. The reviewers could not settle this:".into());
1106 out.push(bullets(&lines));
1107 }
1108 }
1109
1110 let disputes: Vec<&crate::model::Dispute> = state
1111 .disputes
1112 .iter()
1113 .filter(|d| !already.iter().any(|t| same_point(t, &d.title)))
1114 .collect();
1115 if !disputes.is_empty() {
1116 let lines: Vec<String> = disputes
1119 .iter()
1120 .map(|d| {
1121 format!(
1122 "{}. {}",
1123 style::title(&d.title, style),
1124 style::sentence(&d.reasoning, style)
1125 )
1126 })
1127 .collect();
1128 out.push(format!("Raised and refuted:\n{}", bullets(&lines)));
1129 }
1130
1131 if !state.filed.is_empty() {
1132 let refs: Vec<String> = state.filed.iter().map(|u| as_reference(u)).collect();
1133 out.push(format!("Filed separately: {}", refs.join(", ")));
1134 }
1135
1136 Some(out.join("\n\n"))
1137}
1138
1139fn implement_prompt(number: i64, title: &str, url: &str, body: &str) -> String {
1147 IMPLEMENT_PROMPT
1148 .replace("{number}", &number.to_string())
1149 .replace("{title}", title)
1150 .replace("{url}", url)
1151 .replace("{body}", body)
1152}
1153
1154pub fn pr_body(issue: i64, work: &Implementation, style: &Style) -> String {
1164 let mut parts = vec![format!("Closes #{issue}")];
1165
1166 for lead in [&work.summary, &work.problem] {
1167 let text = style::sentence(lead, style);
1168 if !text.is_empty() {
1169 parts.push(text);
1170 }
1171 }
1172 parts.extend(section("What changed", &work.changes, style));
1173 parts.extend(section("How to test", &work.testing, style));
1174
1175 let notes = style::sentence(work.notes.as_deref().unwrap_or_default(), style);
1176 if !notes.is_empty() {
1177 parts.push(format!("## Notes\n\n{notes}"));
1178 }
1179
1180 style::body(&parts.join("\n\n"), style)
1181}
1182
1183fn section(heading: &str, lines: &[String], style: &Style) -> Option<String> {
1190 let items: Vec<String> = lines
1191 .iter()
1192 .map(|line| style::summary(line, style))
1193 .filter(|line| !line.is_empty())
1194 .collect();
1195 if items.is_empty() {
1196 return None;
1197 }
1198 Some(format!("## {heading}\n\n{}", bullets(&items)))
1199}
1200
1201fn no_pr_note(work: &Implementation, style: &Style) -> String {
1208 let reason = style::sentence(&work.reason, style);
1209 if !reason.is_empty() {
1210 return reason;
1211 }
1212 if work.not_worth_doing {
1213 "Left alone after reading the code, with no reason given.".to_string()
1214 } else {
1215 "Nothing was committed, so there is nothing to review.".to_string()
1216 }
1217}
1218
1219pub fn review_comment(holder: &str, round: u32, review: &Review, style: &Style) -> String {
1223 let by = |severity: Severity| -> Vec<&Finding> {
1224 review
1225 .findings
1226 .iter()
1227 .filter(|f| f.severity == severity && f.in_scope)
1228 .collect()
1229 };
1230 let blocking = by(Severity::Blocking);
1231 let non_blocking = by(Severity::NonBlocking);
1232 let nits = by(Severity::Nit);
1233 let out_of_scope: Vec<&Finding> = review.findings.iter().filter(|f| !f.in_scope).collect();
1234
1235 let mut counts = Vec::new();
1236 if !blocking.is_empty() {
1237 counts.push(format!("{} blocking", blocking.len()));
1238 }
1239 if !non_blocking.is_empty() {
1240 counts.push(format!("{} non-blocking", non_blocking.len()));
1241 }
1242 if !nits.is_empty() {
1243 counts.push(format!("{} nit", nits.len()));
1244 }
1245 if !out_of_scope.is_empty() {
1246 counts.push(format!("{} out of scope", out_of_scope.len()));
1247 }
1248 let headline = if counts.is_empty() {
1249 "no findings".to_string()
1250 } else {
1251 counts.join(", ")
1252 };
1253
1254 let _ = (holder, round, headline);
1255 let mut out = Vec::new();
1256 let summary = style::summary(&review.summary, style);
1257 if !summary.is_empty() {
1258 out.push(summary);
1259 }
1260
1261 if !blocking.is_empty() {
1262 let lines: Vec<String> = blocking
1263 .iter()
1264 .map(|f| {
1265 let detail = style::detail(&f.detail, style);
1266 if detail.is_empty() {
1267 located(f, style)
1268 } else {
1269 format!("{}. {detail}", located(f, style))
1270 }
1271 })
1272 .collect();
1273 out.push(format!("blocking\n{}", bullets(&lines)));
1274 }
1275
1276 for (label, group) in [
1279 ("non-blocking", &non_blocking),
1280 ("nits", &nits),
1281 ("out of scope", &out_of_scope),
1282 ] {
1283 if group.is_empty() {
1284 continue;
1285 }
1286 let lines: Vec<String> = group.iter().map(|f| located(f, style)).collect();
1287 out.push(format!("{label}\n{}", bullets(&lines)));
1288 }
1289
1290 out.join("\n\n")
1291}
1292
1293pub fn disposition_comment(
1297 author: &str,
1298 response: &ResponseDoc,
1299 fixed: &[String],
1300 refuted: &[String],
1301 filed: &[String],
1302 style: &Style,
1303) -> Option<String> {
1304 if fixed.is_empty() && refuted.is_empty() && filed.is_empty() {
1305 return None;
1306 }
1307 let mut counts = Vec::new();
1308 if !fixed.is_empty() {
1309 counts.push(format!("{} fixed", fixed.len()));
1310 }
1311 if !refuted.is_empty() {
1312 counts.push(format!("{} refuted", refuted.len()));
1313 }
1314 if !filed.is_empty() {
1315 counts.push(format!("{} filed", filed.len()));
1316 }
1317
1318 let _ = (author, counts);
1319 let mut out = Vec::new();
1320 let summary = style::summary(&response.summary, style);
1321 if !summary.is_empty() {
1322 out.push(summary);
1323 }
1324 if !refuted.is_empty() {
1325 out.push(format!("refuted\n{}", bullets(refuted)));
1326 }
1327 if !fixed.is_empty() {
1328 out.push(format!("fixed\n{}", bullets(fixed)));
1329 }
1330 if !filed.is_empty() {
1331 out.push(format!("filed\n{}", bullets(filed)));
1332 }
1333 Some(out.join("\n\n"))
1334}
1335
1336pub fn skip_comment(item: &SkippedItem, style: &Style) -> String {
1344 let reasons = item
1345 .reasons
1346 .values()
1347 .map(|reason| style::sentence(reason, style));
1348 let lines = crate::textsim::dedupe_by(reasons, crate::textsim::same_reason);
1352 bullets(&lines)
1353}
1354
1355pub(crate) fn findings_for_prompt(findings: &[Finding]) -> String {
1358 if findings.is_empty() {
1359 return "(none)".to_string();
1360 }
1361 findings
1362 .iter()
1363 .map(|f| {
1364 let scope = if f.in_scope { "" } else { " [out of scope]" };
1365 format!(
1366 "- [{}]{scope} {} ({})\n {}",
1367 f.severity,
1368 f.title,
1369 f.where_at(),
1370 f.detail
1371 )
1372 })
1373 .collect::<Vec<_>>()
1374 .join("\n")
1375}
1376
1377#[cfg(test)]
1378mod tests {
1379 use super::*;
1380 use crate::model::Verdict;
1381
1382 fn style() -> Style {
1383 Style::default()
1384 }
1385
1386 fn finding(severity: &str, title: &str, detail: &str, file: &str, in_scope: bool) -> Finding {
1387 Finding {
1388 severity: Severity::parse_lenient(severity).unwrap(),
1389 title: title.into(),
1390 detail: detail.into(),
1391 file: file.into(),
1392 in_scope,
1393 ..Default::default()
1394 }
1395 }
1396
1397 fn review(summary: &str, findings: Vec<Finding>) -> Review {
1398 Review {
1399 verdict: Verdict::Approve,
1400 next_action: NextAction::Merge,
1401 summary: summary.into(),
1402 findings,
1403 }
1404 }
1405
1406 fn cfg_with(worktrees: bool, keep: bool) -> Config {
1409 let text = "[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n";
1410 let mut cfg = crate::config::parse(text).unwrap();
1411 cfg.loop_cfg.worktrees = worktrees;
1412 cfg.loop_cfg.keep_worktrees = keep;
1413 cfg
1414 }
1415
1416 #[test]
1417 fn a_worktree_is_released_on_every_finished_outcome() {
1418 let cfg = cfg_with(true, false);
1419 for status in [Status::Approved, Status::Merged, Status::Abandoned] {
1420 assert!(should_release(&cfg, status), "{status}");
1421 }
1422 }
1423
1424 #[test]
1427 fn a_worktree_is_kept_only_where_a_human_has_to_look() {
1428 let cfg = cfg_with(true, false);
1429 assert!(!should_release(&cfg, Status::Escalated));
1430 assert!(!should_release(&cfg, Status::Error));
1431 }
1432
1433 #[test]
1434 fn the_keep_flag_overrides_everything() {
1435 assert!(!should_release(&cfg_with(true, true), Status::Approved));
1436 }
1437
1438 #[test]
1439 fn nothing_is_released_when_worktrees_are_off() {
1440 assert!(!should_release(&cfg_with(false, false), Status::Approved));
1441 }
1442
1443 #[test]
1447 fn a_fresh_run_starts_at_one() {
1448 assert_eq!((1, 3), round_window(1, 3));
1449 assert_eq!((1, 5), round_window(1, 5));
1450 }
1451
1452 #[test]
1456 fn a_resumed_run_gets_a_full_fresh_budget() {
1457 assert_eq!((6, 10), round_window(6, 5));
1458 assert_eq!((11, 13), round_window(11, 3));
1459 }
1460
1461 #[test]
1462 fn a_budget_of_one_is_a_single_round() {
1463 assert_eq!((6, 6), round_window(6, 1));
1464 }
1465
1466 #[test]
1467 fn round_numbers_keep_counting_across_sessions() {
1468 let mut start = 1;
1470 let mut seen = Vec::new();
1471 for _ in 0..3 {
1472 let (first, last) = round_window(start, 3);
1473 seen.push((first, last));
1474 start = last + 1;
1475 }
1476 assert_eq!(vec![(1, 3), (4, 6), (7, 9)], seen);
1477 }
1478
1479 fn ledger_with(title: &str, file: &str) -> Ledger {
1482 let mut ledger = Ledger::new();
1483 ledger.insert(
1484 finding_key(title, file),
1485 LedgerEntry {
1486 title: title.into(),
1487 file: file.into(),
1488 reasoning: "no".into(),
1489 round: 1,
1490 reraised: 0,
1491 },
1492 );
1493 ledger
1494 }
1495
1496 #[test]
1497 fn a_point_refuted_and_re_raised_twice_escalates() {
1498 let mut ledger = ledger_with("nit about naming", "a.rs");
1499 let mut state = IssueRun::new(1, "t");
1500 let blocking = vec![finding("blocking", "nit about naming", "d", "a.rs", true)];
1501 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
1502 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
1503 }
1504
1505 #[test]
1506 fn an_untracked_finding_does_not_escalate() {
1507 let mut state = IssueRun::new(1, "t");
1508 let blocking = vec![finding("blocking", "brand new", "d", "a.rs", true)];
1509 assert!(!check_relitigation(
1510 &mut Ledger::new(),
1511 &blocking,
1512 &mut state
1513 ));
1514 }
1515
1516 #[test]
1520 fn a_refutation_lands_on_the_key_the_next_round_will_look_up() {
1521 let blocking = vec![finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
1522 let recorded = finding_key(&blocking[0].title, &blocking[0].file);
1523
1524 let matched = matching_finding(&blocking, "unbounded loop!").expect("should match");
1525 assert_eq!(recorded, finding_key("unbounded loop!", &matched.file));
1526 }
1527
1528 #[test]
1533 fn the_ledger_key_uses_the_reviewers_wording_not_the_authors() {
1534 let findings = vec![finding(
1535 "blocking",
1536 "Panic on multi-byte input",
1537 "d",
1538 "src/style.rs",
1539 true,
1540 )];
1541 let reworded = "Panic on multibyte input";
1542
1543 let source = matching_finding(&findings, reworded).expect("still matches");
1544 assert_ne!(
1545 finding_key(reworded, &source.file),
1546 finding_key(&source.title, &source.file),
1547 "the two spellings must genuinely hash apart, or this test proves nothing"
1548 );
1549
1550 let recorded = finding_key(&source.title, &source.file);
1552 let looked_up = finding_key(&findings[0].title, &findings[0].file);
1553 assert_eq!(recorded, looked_up);
1554 }
1555
1556 #[test]
1557 fn a_disposition_matches_its_finding_despite_wording_noise() {
1558 let findings = vec![finding(
1559 "blocking",
1560 "Unbounded loop!",
1561 "d",
1562 "src/x.rs",
1563 true,
1564 )];
1565 assert!(matching_finding(&findings, "unbounded loop").is_some());
1566 assert!(matching_finding(&findings, "something else").is_none());
1567 }
1568
1569 #[test]
1570 fn the_settled_block_is_empty_when_nothing_is_settled() {
1571 assert_eq!("", settled_block(&Ledger::new()));
1572 }
1573
1574 #[test]
1575 fn the_settled_block_names_each_refutation() {
1576 let block = settled_block(&ledger_with("a point", "x.rs"));
1577 assert!(block.contains("a point"));
1578 assert!(block.contains("settled"));
1579 }
1580
1581 #[test]
1584 fn a_clean_review_is_just_the_verdict() {
1587 let text = review_comment("codex", 1, &review("Looks correct.", vec![]), &style());
1588 assert_eq!("Looks correct.", text);
1589 }
1590
1591 #[test]
1592 fn a_review_leads_with_the_counts() {
1593 let text = review_comment(
1594 "codex",
1595 2,
1596 &review(
1597 "One real problem.",
1598 vec![
1599 finding(
1600 "blocking",
1601 "Loop never terminates",
1602 "Confirmed by running it.",
1603 "src/a.rs",
1604 true,
1605 ),
1606 finding("non-blocking", "Name is vague", "d", "src/b.rs", true),
1607 finding("nit", "Log wording", "d", "", true),
1608 ],
1609 ),
1610 &style(),
1611 );
1612 assert!(text.starts_with("One real problem."), "{text}");
1613 assert!(!text.contains("codex"), "no agent name: {text}");
1614 assert!(!text.contains("round 2"), "no round number: {text}");
1615 }
1616
1617 #[test]
1620 fn only_blocking_findings_carry_their_detail() {
1621 let text = review_comment(
1622 "codex",
1623 1,
1624 &review(
1625 "s",
1626 vec![
1627 finding("blocking", "Loop", "BLOCKING DETAIL", "a.rs", true),
1628 finding("non-blocking", "Name", "NONBLOCKING DETAIL", "b.rs", true),
1629 ],
1630 ),
1631 &style(),
1632 );
1633 assert!(text.contains("BLOCKING DETAIL"), "{text}");
1634 assert!(!text.contains("NONBLOCKING DETAIL"), "{text}");
1635 }
1636
1637 #[test]
1638 fn a_thorough_explanation_reaches_the_author_intact() {
1641 let detail = "Reproduced by running the 429 test with max_attempts unset. ".repeat(8);
1642 let text = review_comment(
1643 "codex",
1644 1,
1645 &review(
1646 "One problem.",
1647 vec![finding("blocking", "T", &detail, "a.rs", true)],
1648 ),
1649 &style(),
1650 );
1651 assert!(
1652 text.contains(detail.trim()),
1653 "the explanation was cut:\n{text}"
1654 );
1655 }
1656
1657 #[test]
1659 fn a_runaway_model_is_still_bounded() {
1660 let long = "filler words. ".repeat(20_000);
1661 let text = review_comment(
1662 "codex",
1663 1,
1664 &review(&long, vec![finding("blocking", "T", &long, "a.rs", true)]),
1665 &style(),
1666 );
1667 assert!(
1668 text.len() < 30_000,
1669 "review comment was {} chars",
1670 text.len()
1671 );
1672 }
1673
1674 #[test]
1675 fn a_general_finding_has_no_empty_parenthesis() {
1676 let text = review_comment(
1677 "codex",
1678 1,
1679 &review("s", vec![finding("blocking", "Something", "d", "", true)]),
1680 &style(),
1681 );
1682 assert!(!text.contains("()"), "{text}");
1683 assert!(!text.contains("(general)"), "{text}");
1684 }
1685
1686 #[test]
1687 fn out_of_scope_findings_are_counted_separately() {
1688 let text = review_comment(
1689 "codex",
1690 1,
1691 &review(
1692 "s",
1693 vec![finding("blocking", "Old bug", "d", "a.rs", false)],
1694 ),
1695 &style(),
1696 );
1697 assert!(text.contains("out of scope"), "{text}");
1698 assert!(text.contains("Old bug"), "{text}");
1699 }
1700
1701 #[test]
1702 fn a_disposition_comment_leads_with_counts_and_keeps_refutations() {
1703 let response = ResponseDoc {
1704 summary: "Two of three were right.".into(),
1705 dispositions: vec![],
1706 };
1707 let text = disposition_comment(
1708 "claude",
1709 &response,
1710 &["Fixed thing".to_string()],
1711 &["Wrong thing. Because the caller already checks.".to_string()],
1712 &[],
1713 &style(),
1714 )
1715 .unwrap();
1716 assert!(text.starts_with("Two of three were right."), "{text}");
1717 assert!(!text.contains("claude"), "no agent name: {text}");
1718 assert!(
1719 text.contains("Because the caller already checks."),
1720 "{text}"
1721 );
1722 }
1723
1724 #[test]
1725 fn an_empty_disposition_comment_is_not_posted() {
1726 let response = ResponseDoc {
1727 summary: "s".into(),
1728 dispositions: vec![],
1729 };
1730 assert!(disposition_comment("claude", &response, &[], &[], &[], &style()).is_none());
1731 }
1732
1733 #[test]
1738 fn the_implementor_is_given_the_link_and_the_body() {
1739 let prompt = implement_prompt(
1740 42,
1741 "Retry a 429",
1742 "https://github.com/o/r/issues/42",
1743 "A rate limited response was treated as fatal.",
1744 );
1745 assert!(
1746 prompt.contains("https://github.com/o/r/issues/42"),
1747 "{prompt}"
1748 );
1749 assert!(
1750 prompt.contains("A rate limited response was treated as fatal."),
1751 "{prompt}"
1752 );
1753 assert!(prompt.contains("#42"), "{prompt}");
1754 assert!(prompt.contains("Retry a 429"), "{prompt}");
1755 assert!(!prompt.contains('{'), "{prompt}");
1757 }
1758
1759 #[test]
1762 fn the_prompt_says_the_discussion_is_not_included() {
1763 let prompt = implement_prompt(1, "t", "u", "b");
1764 let lower = prompt
1766 .split_whitespace()
1767 .collect::<Vec<_>>()
1768 .join(" ")
1769 .to_lowercase();
1770 assert!(
1771 lower.contains("discussion since is not included"),
1772 "{prompt}"
1773 );
1774 assert!(lower.contains("cannot reach the network"), "{prompt}");
1775 }
1776
1777 fn worked() -> Implementation {
1779 Implementation {
1780 summary: "Retry a 429 instead of failing the run.".into(),
1781 problem: "A rate limited response was treated as fatal, so one throttled call ended \
1782 a run that had hours of work left in it."
1783 .into(),
1784 changes: vec![
1785 "`send` retries a 429 with the delay the header asks for".into(),
1786 "the retry budget is bounded, so a permanent 429 still ends".into(),
1787 ],
1788 testing: vec![
1789 "`cargo test retries_a_429`".into(),
1790 "point it at a throttled endpoint and watch it finish".into(),
1791 ],
1792 ..Implementation::default()
1793 }
1794 }
1795
1796 #[test]
1797 fn a_pr_body_is_what_it_closes_and_what_changed() {
1800 let body = pr_body(42, &worked(), &style());
1801 assert_eq!(
1802 "Closes #42\n\n\
1803 Retry a 429 instead of failing the run.\n\n\
1804 A rate limited response was treated as fatal, so one throttled call \
1805 ended a run that had hours of work left in it.\n\n\
1806 ## What changed\n\n\
1807 - `send` retries a 429 with the delay the header asks for\n\
1808 - the retry budget is bounded, so a permanent 429 still ends\n\n\
1809 ## How to test\n\n\
1810 - `cargo test retries_a_429`\n\
1811 - point it at a throttled endpoint and watch it finish",
1812 body
1813 );
1814 }
1815
1816 #[test]
1819 fn a_body_with_nothing_to_list_carries_no_empty_headings() {
1820 let work = Implementation {
1821 summary: "Retry a 429 instead of failing the run.".into(),
1822 ..Implementation::default()
1823 };
1824 assert_eq!(
1825 "Closes #42\n\nRetry a 429 instead of failing the run.",
1826 pr_body(42, &work, &style())
1827 );
1828 }
1829
1830 #[test]
1831 fn a_pr_body_survives_an_implementor_that_said_nothing() {
1832 assert_eq!(
1833 "Closes #7",
1834 pr_body(7, &Implementation::default(), &style())
1835 );
1836 }
1837
1838 #[test]
1841 fn blank_list_entries_do_not_earn_a_heading() {
1842 let work = Implementation {
1843 summary: "Did a thing.".into(),
1844 changes: vec![String::new(), " ".into()],
1845 ..Implementation::default()
1846 };
1847 let body = pr_body(42, &work, &style());
1848 assert!(!body.contains("What changed"), "{body}");
1849 }
1850
1851 #[test]
1852 fn notes_appear_only_when_there_is_something_to_note() {
1853 let mut work = worked();
1854 assert!(!pr_body(42, &work, &style()).contains("## Notes"));
1855 work.notes = Some("The retry is not applied to streaming calls.".into());
1856 let body = pr_body(42, &work, &style());
1857 assert!(body.contains("## Notes"), "{body}");
1858 assert!(body.contains("streaming calls"), "{body}");
1859 }
1860
1861 #[test]
1864 fn declining_posts_the_reason_and_not_the_summary() {
1865 let work = Implementation {
1866 not_worth_doing: true,
1867 reason: "Already fixed in 1.2, and the report predates it.".into(),
1868 summary: "Nothing to do.".into(),
1869 ..Implementation::default()
1870 };
1871 assert_eq!(
1872 "Already fixed in 1.2, and the report predates it.",
1873 no_pr_note(&work, &style())
1874 );
1875 }
1876
1877 #[test]
1878 fn reporting_work_and_committing_none_says_that_rather_than_the_summary() {
1879 let work = Implementation {
1880 summary: "Retry a 429 instead of failing the run.".into(),
1881 ..Implementation::default()
1882 };
1883 let note = no_pr_note(&work, &style());
1884 assert_eq!(
1885 "Nothing was committed, so there is nothing to review.",
1886 note
1887 );
1888 }
1889
1890 #[test]
1891 fn declining_without_a_reason_still_says_something() {
1892 let work = Implementation {
1893 not_worth_doing: true,
1894 ..Implementation::default()
1895 };
1896 assert!(no_pr_note(&work, &style()).contains("no reason given"));
1897 }
1898
1899 #[test]
1900 fn a_skip_comment_is_only_the_reasoning() {
1901 let item = SkippedItem {
1902 issue: 3,
1903 title: "t".into(),
1904 tracker: false,
1905 reasons: [
1906 ("claude".to_string(), "Already fixed in 1.2.".to_string()),
1907 ("codex".to_string(), "Duplicate of #2.".to_string()),
1908 ]
1909 .into_iter()
1910 .collect(),
1911 };
1912 let text = skip_comment(&item, &style());
1913 assert!(text.contains("Already fixed in 1.2."), "{text}");
1914 assert!(text.contains("Duplicate of #2."), "{text}");
1915 assert!(
1916 !text.contains("claude") && !text.contains("codex"),
1917 "{text}"
1918 );
1919 assert!(!text.to_lowercase().contains("not scheduled"), "{text}");
1920 assert!(text.lines().count() <= 3, "{text}");
1921 }
1922
1923 #[test]
1924 fn findings_for_a_model_keep_full_detail() {
1925 let long = "x".repeat(2000);
1926 let text = findings_for_prompt(&[finding("blocking", "T", &long, "a.rs", true)]);
1927 assert!(
1928 text.contains(&long),
1929 "a model needs the whole finding, only humans need brevity"
1930 );
1931 }
1932
1933 #[test]
1934 fn findings_for_a_model_are_never_empty() {
1935 assert_eq!("(none)", findings_for_prompt(&[]));
1936 }
1937}
1938
1939#[cfg(test)]
1940mod outcome_tests {
1941 use super::*;
1942 use crate::model::{Dispute, Severity};
1943
1944 fn style() -> Style {
1945 Style::default()
1946 }
1947
1948 fn state_with(disputes: Vec<(&str, &str)>, filed: Vec<&str>) -> IssueRun {
1949 let mut s = IssueRun::new(482, "t");
1950 s.disputes = disputes
1951 .into_iter()
1952 .map(|(title, reasoning)| Dispute {
1953 title: title.into(),
1954 reasoning: reasoning.into(),
1955 })
1956 .collect();
1957 s.filed = filed.into_iter().map(String::from).collect();
1958 s
1959 }
1960
1961 fn finding(title: &str, file: &str) -> Finding {
1962 Finding {
1963 severity: Severity::Blocking,
1964 title: title.into(),
1965 detail: "d".into(),
1966 file: file.into(),
1967 in_scope: true,
1968 ..Default::default()
1969 }
1970 }
1971
1972 #[test]
1975 fn a_clean_approval_says_nothing() {
1976 let state = state_with(vec![], vec![]);
1977 assert!(outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).is_none());
1978 }
1979
1980 #[test]
1981 fn an_approval_that_filed_follow_ups_links_them() {
1982 let state = state_with(
1983 vec![],
1984 vec![
1985 "https://github.com/you/thing/issues/485",
1986 "https://github.com/you/thing/issues/486",
1987 ],
1988 );
1989 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
1990 assert!(text.contains("Filed separately: #485, #486"), "{text}");
1991 }
1992
1993 #[test]
1997 fn running_out_of_rounds_says_what_that_means_for_the_reader() {
1998 let state = state_with(vec![], vec![]);
1999 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
2000 assert!(text.contains("has not been reviewed"), "{text}");
2001 assert!(
2002 !text.to_lowercase().contains("round 3"),
2003 "no round numbers: {text}"
2004 );
2005 assert!(!text.to_lowercase().contains("convergence"), "{text}");
2006 }
2007
2008 #[test]
2009 fn a_deadlock_names_the_point_they_could_not_settle() {
2010 let state = state_with(vec![], vec![]);
2011 let points = [finding("Retry loop never terminates", "src/net.rs:88")];
2012 let text = outcome_comment(
2013 &state,
2014 &Ledger::new(),
2015 &Ending::Deadlocked(&points),
2016 &style(),
2017 )
2018 .unwrap();
2019 assert!(
2020 text.contains("Retry loop never terminates (src/net.rs:88)"),
2021 "{text}"
2022 );
2023 assert!(text.contains("could not settle"), "{text}");
2024 }
2025
2026 #[test]
2028 fn refutations_survive_because_nothing_else_carries_them() {
2029 let state = state_with(
2030 vec![(
2031 "Error is swallowed",
2032 "the caller already validates the file",
2033 )],
2034 vec![],
2035 );
2036 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
2037 assert!(text.contains("Raised and refuted:"), "{text}");
2038 assert!(
2039 text.contains("The caller already validates the file"),
2040 "{text}"
2041 );
2042 }
2043
2044 #[test]
2045 fn no_agent_names_counts_or_round_numbers_reach_the_thread() {
2046 let state = state_with(
2047 vec![("A point", "a reason")],
2048 vec!["https://github.com/you/thing/issues/485"],
2049 );
2050 for ending in [Ending::Approved, Ending::OutOfRounds] {
2051 let text = outcome_comment(&state, &Ledger::new(), &ending, &style()).unwrap();
2052 let lower = text.to_lowercase();
2053 for banned in ["claude", "codex", "blocking,", "nit,", " fixed."] {
2054 assert!(
2055 !lower.contains(banned),
2056 "{banned:?} leaked into the thread:\n{text}"
2057 );
2058 }
2059 for n in 1..9 {
2061 assert!(
2062 !lower.contains(&format!("round {n}")),
2063 "a round number leaked into the thread:\n{text}"
2064 );
2065 }
2066 }
2067 }
2068
2069 #[test]
2070 fn a_refutation_is_allowed_to_make_its_case() {
2073 let reasoning = "The caller validates against the schema first. \
2074 The discarded error is therefore unreachable in practice. ";
2075 let state = state_with(
2076 vec![("A point", &reasoning.repeat(6))],
2077 vec!["https://github.com/you/thing/issues/485"],
2078 );
2079 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
2080 assert!(
2081 !text.contains("..."),
2082 "nothing was cut mid thought:\n{text}"
2083 );
2084 assert!(text.len() < 4000, "{} chars", text.len());
2085 }
2086
2087 #[test]
2088 fn a_url_that_is_not_an_issue_link_is_left_alone() {
2089 assert_eq!(
2090 "#485",
2091 as_reference("https://github.com/you/thing/issues/485")
2092 );
2093 assert_eq!("note: something", as_reference("note: something"));
2094 }
2095}
2096
2097#[cfg(test)]
2098mod filed_reference_tests {
2099 use super::*;
2100
2101 #[test]
2102 fn an_issue_url_yields_its_number() {
2103 assert_eq!(
2104 Some(485),
2105 filed_issue_number("https://github.com/you/thing/issues/485")
2106 );
2107 }
2108
2109 #[test]
2112 fn a_local_note_yields_nothing() {
2113 assert_eq!(None, filed_issue_number("note: Retry is unbounded"));
2114 assert_eq!(None, filed_issue_number(""));
2115 assert_eq!(
2116 None,
2117 filed_issue_number("https://github.com/you/thing/issues/")
2118 );
2119 }
2120}
2121
2122#[cfg(test)]
2123mod followup_restraint_tests {
2124 use super::*;
2125 use crate::model::Severity;
2126
2127 fn cfg_with(followups: Followups, non_blocking: bool, nits: bool, cap: usize) -> Config {
2128 let mut cfg =
2129 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
2130 .unwrap();
2131 cfg.loop_cfg.followups = followups;
2132 cfg.loop_cfg.file_non_blocking = non_blocking;
2133 cfg.loop_cfg.file_nits = nits;
2134 cfg.loop_cfg.max_followups = cap;
2135 cfg
2136 }
2137
2138 fn finding(severity: Severity, title: &str, in_scope: bool) -> Finding {
2139 Finding {
2140 severity,
2141 title: title.into(),
2142 detail: "d".into(),
2143 file: "a.rs".into(),
2144 in_scope,
2145 ..Default::default()
2146 }
2147 }
2148
2149 #[test]
2153 fn a_non_blocking_finding_is_not_a_tracker_item_by_default() {
2154 let cfg = cfg_with(Followups::Issues, false, false, 5);
2155 assert!(!cfg.loop_cfg.file_non_blocking);
2156 assert!(!cfg.loop_cfg.file_nits);
2157 }
2158
2159 #[test]
2160 fn follow_ups_stay_off_the_tracker_by_default() {
2161 let cfg =
2162 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
2163 .unwrap();
2164 assert_eq!(
2165 Followups::Local,
2166 cfg.loop_cfg.followups,
2167 "the tracker is somebody's queue; the default must not write to it"
2168 );
2169 assert_eq!(5, cfg.loop_cfg.max_followups);
2170 }
2171
2172 #[test]
2174 fn only_out_of_scope_defects_qualify_at_the_defaults() {
2175 let cfg = cfg_with(Followups::Issues, false, false, 5);
2176 let qualifies = |f: &Finding| match f.severity {
2177 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking && f.in_scope,
2178 Severity::Nit => cfg.loop_cfg.file_nits && f.in_scope,
2179 Severity::Blocking => false,
2180 } || !f.in_scope;
2181
2182 assert!(qualifies(&finding(
2183 Severity::Blocking,
2184 "pre-existing",
2185 false
2186 )));
2187 assert!(!qualifies(&finding(
2188 Severity::NonBlocking,
2189 "improvement",
2190 true
2191 )));
2192 assert!(!qualifies(&finding(Severity::Nit, "taste", true)));
2193 assert!(!qualifies(&finding(
2194 Severity::Blocking,
2195 "fix it here",
2196 true
2197 )));
2198 }
2199
2200 #[test]
2201 fn opening_it_up_lets_non_blocking_findings_through_again() {
2202 let cfg = cfg_with(Followups::Issues, true, false, 5);
2203 assert!(cfg.loop_cfg.file_non_blocking);
2204 }
2205
2206 #[test]
2208 fn the_cap_is_a_real_backstop() {
2209 let cfg = cfg_with(Followups::Issues, false, false, 3);
2210 let mut state = IssueRun::new(1, "t");
2211 state.filed = (0..3).map(|n| format!("url{n}")).collect();
2212 assert!(state.filed.len() >= cfg.loop_cfg.max_followups);
2213 }
2214
2215 #[test]
2219 fn the_cap_bounds_what_one_run_can_spawn() {
2220 let cfg = cfg_with(Followups::Issues, false, false, 5);
2221 assert!(
2222 cfg.loop_cfg.max_followups <= 5,
2223 "a run that can file ten follow-ups is a branching process"
2224 );
2225 }
2226}
2227
2228#[cfg(test)]
2229mod issue_report_tests {
2230 use super::*;
2231 use crate::model::Severity;
2232
2233 fn reported() -> Finding {
2236 Finding {
2237 severity: Severity::Blocking,
2238 title: "sendPaymentAsync bypasses drain mode and spending limits".into(),
2239 detail: "The async path skips every admission check payInvoice applies.".into(),
2240 file: "src/node.ts:412".into(),
2241 in_scope: false,
2242 problem: Some(
2243 "`BeignetNode.sendPaymentAsync()` submits a payment directly to the Lightning \
2244 engine without applying the safeguards used by `payInvoice()`.\n\nThe async path \
2245 does not:\n\n- call `_checkDraining()`\n- call `_checkSpendLimit()`"
2246 .into(),
2247 ),
2248 reproduction: Some(
2249 "1. Create a `BeignetNode` with `dailySpendLimitSats: 1`.\n2. Enable drain mode.\n\
2250 3. Submit a 1,000 sat invoice.\n\nActual result:\n\n- The engine is called.\n\
2251 - `spentSats` remains 0."
2252 .into(),
2253 ),
2254 impact: Some(
2255 "An authorized client can submit async payments up to the available outbound \
2256 liquidity despite the configured limits."
2257 .into(),
2258 ),
2259 expected: Some(
2260 "- Reject new payments while draining.\n- Enforce the per-payment limit before \
2261 submission.\n- Cover both paths with regression tests.\n\nThis predates the \
2262 current branch."
2263 .into(),
2264 ),
2265 }
2266 }
2267
2268 #[test]
2269 fn a_reported_finding_becomes_a_bug_report() {
2270 let body = issue_report(&reported());
2271 for heading in [
2272 "## Problem",
2273 "## Reproduction",
2274 "## Impact",
2275 "## Expected behavior",
2276 ] {
2277 assert!(body.contains(heading), "missing {heading}:\n{body}");
2278 }
2279 let at = |h: &str| body.find(h).unwrap();
2281 assert!(at("## Problem") < at("## Reproduction"));
2282 assert!(at("## Reproduction") < at("## Impact"));
2283 assert!(at("## Impact") < at("## Expected behavior"));
2284 }
2285
2286 #[test]
2287 fn the_substance_survives_the_outbound_gates() {
2288 let repo_style = Style::default();
2289 let body = crate::style::issue_body(&issue_report(&reported()), &repo_style);
2290 for kept in [
2291 "_checkDraining()",
2292 "Actual result:",
2293 "outbound liquidity",
2294 "regression tests",
2295 "predates the current branch",
2296 ] {
2297 assert!(body.contains(kept), "the gate ate {kept:?}:\n{body}");
2298 }
2299 assert!(!body.contains("..."), "something was cut:\n{body}");
2300 }
2301
2302 #[test]
2305 fn an_ordinary_finding_is_still_just_its_detail() {
2306 let plain = Finding {
2307 severity: Severity::NonBlocking,
2308 title: "Name is vague".into(),
2309 detail: "The variable could say what it holds.".into(),
2310 file: "a.rs".into(),
2311 in_scope: true,
2312 ..Default::default()
2313 };
2314 assert_eq!(
2315 "The variable could say what it holds.",
2316 issue_report(&plain)
2317 );
2318 }
2319
2320 #[test]
2323 fn only_the_sections_that_were_written_appear() {
2324 let partial = Finding {
2325 problem: Some("The guard is inverted.".into()),
2326 expected: Some("It should reject rather than accept.".into()),
2327 ..reported()
2328 };
2329 let partial = Finding {
2330 reproduction: None,
2331 impact: None,
2332 ..partial
2333 };
2334 let body = issue_report(&partial);
2335 assert!(body.contains("## Problem") && body.contains("## Expected behavior"));
2336 assert!(!body.contains("## Reproduction"), "{body}");
2337 assert!(!body.contains("## Impact"), "{body}");
2338 }
2339
2340 #[test]
2343 fn the_summary_line_is_not_printed_twice() {
2344 let echoed = Finding {
2345 detail: "The guard is inverted so it rejects valid input.".into(),
2346 problem: Some("The guard is inverted so it rejects valid input.".into()),
2347 reproduction: None,
2348 impact: None,
2349 expected: None,
2350 ..reported()
2351 };
2352 let body = issue_report(&echoed);
2353 assert_eq!(1, body.matches("The guard is inverted").count(), "{body}");
2354 }
2355}