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 match file_as_issue(repo, &title, &body) {
853 Ok(filed) => filed.url().map(str::to_string),
854 Err(e) => {
855 logdim!("could not file a follow-up for '{title}': {e}");
856 None
857 }
858 }
859}
860
861#[derive(Debug, Clone)]
863pub enum Filed {
864 Opened(i64, String),
866 AddedTo(i64, String),
868 Covered(i64, String),
870 AlreadyClosed(i64, String),
872}
873
874impl Filed {
875 pub fn url(&self) -> Option<&str> {
876 match self {
877 Filed::Opened(_, url) | Filed::AddedTo(_, url) | Filed::Covered(_, url) => Some(url),
878 Filed::AlreadyClosed(_, _) => None,
881 }
882 }
883
884 pub fn number(&self) -> Option<i64> {
886 match self {
887 Filed::Opened(n, _) | Filed::AddedTo(n, _) | Filed::Covered(n, _) => Some(*n),
888 Filed::AlreadyClosed(_, _) => None,
889 }
890 }
891
892 pub fn note(&self) -> String {
894 match self {
895 Filed::Opened(n, _) => format!("#{n}"),
896 Filed::AddedTo(n, _) => format!("added to #{n}"),
897 Filed::Covered(n, _) => format!("#{n} already says this"),
898 Filed::AlreadyClosed(n, _) => format!("#{n} covers it and is closed"),
899 }
900 }
901
902 pub fn describe(&self, title: &str) -> String {
903 let title = style::clip(title.trim(), 80);
904 match self {
905 Filed::Opened(n, _) => format!("filed #{n}: {title}"),
906 Filed::AddedTo(n, _) => format!("added to #{n}: {title}"),
907 Filed::Covered(n, _) => format!("#{n} already says this: {title}"),
908 Filed::AlreadyClosed(n, _) => format!("#{n} covers it and is closed: {title}"),
909 }
910 }
911}
912
913pub fn file_as_issue(repo: &Repo, title: &str, body: &str) -> Result<Filed> {
925 let title = repo.clean_title(title)?;
926 if title.trim().is_empty() {
927 return Err(spar_err!("nothing left of the title after cleaning it"));
928 }
929 if let Some(existing) = repo.find_similar_issue(&title, body) {
930 let known = format!("{} {}", existing.title, existing.body);
931 if !existing.open {
932 return Ok(Filed::AlreadyClosed(existing.number, existing.url));
933 }
934 if crate::textsim::adds_information(body, &known) {
935 repo.comment_issue(existing.number, body)?;
936 return Ok(Filed::AddedTo(existing.number, existing.url));
937 }
938 return Ok(Filed::Covered(existing.number, existing.url));
939 }
940 let url = repo.create_issue(&title, body)?;
941 let number = filed_issue_number(&url)
942 .ok_or_else(|| spar_err!("filed an issue but could not read its number from {url}"))?;
943 Ok(Filed::Opened(number, url))
944}
945
946fn file_out_of_scope(
947 repo: &Repo,
948 findings: &[Finding],
949 subject: i64,
950 state: &mut IssueRun,
951 cfg: &Config,
952) {
953 for finding in findings.iter().filter(|f| !f.in_scope) {
954 let body = issue_report(finding);
955 if let Some(url) = file_followup(repo, &finding.title, &body, subject, cfg, state) {
956 state.filed.push(url);
957 }
958 }
959}
960
961pub fn issue_report(finding: &Finding) -> String {
968 let sections = finding.report_sections();
969 if sections.is_empty() {
970 return finding.detail.clone();
971 }
972 let mut out: Vec<String> = sections
973 .iter()
974 .map(|(heading, text)| format!("## {heading}\n\n{text}"))
975 .collect();
976 if !finding.detail.trim().is_empty()
979 && !sections
980 .iter()
981 .any(|(_, text)| crate::textsim::same_point(text, &finding.detail))
982 {
983 out.insert(0, finding.detail.trim().to_string());
984 }
985 out.join("\n\n")
986}
987
988fn file_nonblocking(
995 repo: &Repo,
996 findings: &[Finding],
997 subject: i64,
998 state: &mut IssueRun,
999 cfg: &Config,
1000) {
1001 for finding in findings {
1002 let keep = match finding.severity {
1003 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking,
1004 Severity::Nit => cfg.loop_cfg.file_nits,
1005 Severity::Blocking => false,
1006 };
1007 if !keep || !finding.in_scope {
1008 continue;
1009 }
1010 if let Some(url) = file_followup(repo, &finding.title, &finding.detail, subject, cfg, state)
1011 {
1012 state.filed.push(url);
1013 }
1014 }
1015}
1016
1017fn bullets(lines: &[String]) -> String {
1027 lines
1028 .iter()
1029 .map(|l| format!("- {l}"))
1030 .collect::<Vec<_>>()
1031 .join("\n")
1032}
1033
1034fn located(finding: &Finding, style: &Style) -> String {
1035 let title = style::title(&finding.title, style);
1036 match finding.where_at() {
1037 "general" => title,
1038 file => format!("{title} ({file})"),
1039 }
1040}
1041
1042pub enum Ending<'a> {
1044 Approved,
1046 OutOfRounds,
1049 Deadlocked(&'a [Finding]),
1052}
1053
1054pub fn post_outcome(
1066 repo: &Repo,
1067 pr_number: i64,
1068 state: &IssueRun,
1069 ledger: &Ledger,
1070 ending: Ending<'_>,
1071) {
1072 if repo.style.pr_comments != PrComments::Outcome {
1073 return;
1074 }
1075 let Some(text) = outcome_comment(state, ledger, &ending, &repo.style) else {
1076 return;
1077 };
1078 if let Err(e) = repo.comment_pr(pr_number, &text) {
1079 logdim!("could not post the outcome comment: {e}");
1080 }
1081}
1082
1083fn refutation_of(finding: &Finding, state: &IssueRun, ledger: &Ledger) -> Option<String> {
1086 if let Some(d) = state
1087 .disputes
1088 .iter()
1089 .find(|d| same_point(&d.title, &finding.title))
1090 {
1091 if !d.reasoning.trim().is_empty() {
1092 return Some(d.reasoning.clone());
1093 }
1094 }
1095 ledger
1096 .get(&finding_key(&finding.title, &finding.file))
1097 .map(|entry| entry.reasoning.clone())
1098 .filter(|r| !r.trim().is_empty())
1099}
1100
1101pub fn filed_issue_number(filed: &str) -> Option<i64> {
1106 filed
1107 .rsplit('/')
1108 .next()
1109 .and_then(|tail| tail.parse::<i64>().ok())
1110 .filter(|n| *n > 0)
1111}
1112
1113fn as_reference(url: &str) -> String {
1114 match url.rsplit('/').next().and_then(|n| n.parse::<u64>().ok()) {
1115 Some(number) => format!("#{number}"),
1116 None => url.to_string(),
1117 }
1118}
1119
1120pub fn outcome_comment(
1121 state: &IssueRun,
1122 ledger: &Ledger,
1123 ending: &Ending<'_>,
1124 style: &Style,
1125) -> Option<String> {
1126 let mut out: Vec<String> = Vec::new();
1127 let mut already: Vec<String> = Vec::new();
1130
1131 match ending {
1132 Ending::Approved => {
1133 if state.disputes.is_empty() && state.filed.is_empty() {
1134 return None;
1137 }
1138 out.push("Reviewed, nothing blocking a merge.".into());
1139 }
1140 Ending::OutOfRounds => out.push(
1141 "Not signed off: the last round of fixes was pushed but has not been reviewed.".into(),
1142 ),
1143 Ending::Deadlocked(points) => {
1144 let lines: Vec<String> = points
1150 .iter()
1151 .map(|f| {
1152 let where_at = match f.where_at() {
1153 "general" => String::new(),
1154 file => format!(" ({file})"),
1155 };
1156 let title = style::title(&f.title, style);
1157 already.push(title.clone());
1158 match refutation_of(f, state, ledger) {
1159 Some(reason) => format!(
1160 "{title}{where_at}. Refuted as: {}",
1161 style::summary(&reason, style)
1162 ),
1163 None => format!("{title}{where_at}"),
1164 }
1165 })
1166 .collect();
1167 out.push("Needs your decision. The reviewers could not settle this:".into());
1168 out.push(bullets(&lines));
1169 }
1170 }
1171
1172 let disputes: Vec<&crate::model::Dispute> = state
1173 .disputes
1174 .iter()
1175 .filter(|d| !already.iter().any(|t| same_point(t, &d.title)))
1176 .collect();
1177 if !disputes.is_empty() {
1178 let lines: Vec<String> = disputes
1181 .iter()
1182 .map(|d| {
1183 format!(
1184 "{}. {}",
1185 style::title(&d.title, style),
1186 style::sentence(&d.reasoning, style)
1187 )
1188 })
1189 .collect();
1190 out.push(format!("Raised and refuted:\n{}", bullets(&lines)));
1191 }
1192
1193 if !state.filed.is_empty() {
1194 let refs: Vec<String> = state.filed.iter().map(|u| as_reference(u)).collect();
1195 out.push(format!("Filed separately: {}", refs.join(", ")));
1196 }
1197
1198 Some(out.join("\n\n"))
1199}
1200
1201fn implement_prompt(number: i64, title: &str, url: &str, body: &str) -> String {
1209 IMPLEMENT_PROMPT
1210 .replace("{number}", &number.to_string())
1211 .replace("{title}", title)
1212 .replace("{url}", url)
1213 .replace("{body}", body)
1214}
1215
1216pub fn pr_body(issue: i64, work: &Implementation, style: &Style) -> String {
1226 let mut parts = vec![format!("Closes #{issue}")];
1227
1228 for lead in [&work.summary, &work.problem] {
1229 let text = style::sentence(lead, style);
1230 if !text.is_empty() {
1231 parts.push(text);
1232 }
1233 }
1234 parts.extend(section("What changed", &work.changes, style));
1235 parts.extend(section("How to test", &work.testing, style));
1236
1237 let notes = style::sentence(work.notes.as_deref().unwrap_or_default(), style);
1238 if !notes.is_empty() {
1239 parts.push(format!("## Notes\n\n{notes}"));
1240 }
1241
1242 style::body(&parts.join("\n\n"), style)
1243}
1244
1245fn section(heading: &str, lines: &[String], style: &Style) -> Option<String> {
1252 let items: Vec<String> = lines
1253 .iter()
1254 .map(|line| style::summary(line, style))
1255 .filter(|line| !line.is_empty())
1256 .collect();
1257 if items.is_empty() {
1258 return None;
1259 }
1260 Some(format!("## {heading}\n\n{}", bullets(&items)))
1261}
1262
1263fn no_pr_note(work: &Implementation, style: &Style) -> String {
1270 let reason = style::sentence(&work.reason, style);
1271 if !reason.is_empty() {
1272 return reason;
1273 }
1274 if work.not_worth_doing {
1275 "Left alone after reading the code, with no reason given.".to_string()
1276 } else {
1277 "Nothing was committed, so there is nothing to review.".to_string()
1278 }
1279}
1280
1281pub fn review_comment(holder: &str, round: u32, review: &Review, style: &Style) -> String {
1285 let by = |severity: Severity| -> Vec<&Finding> {
1286 review
1287 .findings
1288 .iter()
1289 .filter(|f| f.severity == severity && f.in_scope)
1290 .collect()
1291 };
1292 let blocking = by(Severity::Blocking);
1293 let non_blocking = by(Severity::NonBlocking);
1294 let nits = by(Severity::Nit);
1295 let out_of_scope: Vec<&Finding> = review.findings.iter().filter(|f| !f.in_scope).collect();
1296
1297 let mut counts = Vec::new();
1298 if !blocking.is_empty() {
1299 counts.push(format!("{} blocking", blocking.len()));
1300 }
1301 if !non_blocking.is_empty() {
1302 counts.push(format!("{} non-blocking", non_blocking.len()));
1303 }
1304 if !nits.is_empty() {
1305 counts.push(format!("{} nit", nits.len()));
1306 }
1307 if !out_of_scope.is_empty() {
1308 counts.push(format!("{} out of scope", out_of_scope.len()));
1309 }
1310 let headline = if counts.is_empty() {
1311 "no findings".to_string()
1312 } else {
1313 counts.join(", ")
1314 };
1315
1316 let _ = (holder, round, headline);
1317 let mut out = Vec::new();
1318 let summary = style::summary(&review.summary, style);
1319 if !summary.is_empty() {
1320 out.push(summary);
1321 }
1322
1323 if !blocking.is_empty() {
1324 let lines: Vec<String> = blocking
1325 .iter()
1326 .map(|f| {
1327 let detail = style::detail(&f.detail, style);
1328 if detail.is_empty() {
1329 located(f, style)
1330 } else {
1331 format!("{}. {detail}", located(f, style))
1332 }
1333 })
1334 .collect();
1335 out.push(format!("blocking\n{}", bullets(&lines)));
1336 }
1337
1338 for (label, group) in [
1341 ("non-blocking", &non_blocking),
1342 ("nits", &nits),
1343 ("out of scope", &out_of_scope),
1344 ] {
1345 if group.is_empty() {
1346 continue;
1347 }
1348 let lines: Vec<String> = group.iter().map(|f| located(f, style)).collect();
1349 out.push(format!("{label}\n{}", bullets(&lines)));
1350 }
1351
1352 out.join("\n\n")
1353}
1354
1355pub fn disposition_comment(
1359 author: &str,
1360 response: &ResponseDoc,
1361 fixed: &[String],
1362 refuted: &[String],
1363 filed: &[String],
1364 style: &Style,
1365) -> Option<String> {
1366 if fixed.is_empty() && refuted.is_empty() && filed.is_empty() {
1367 return None;
1368 }
1369 let mut counts = Vec::new();
1370 if !fixed.is_empty() {
1371 counts.push(format!("{} fixed", fixed.len()));
1372 }
1373 if !refuted.is_empty() {
1374 counts.push(format!("{} refuted", refuted.len()));
1375 }
1376 if !filed.is_empty() {
1377 counts.push(format!("{} filed", filed.len()));
1378 }
1379
1380 let _ = (author, counts);
1381 let mut out = Vec::new();
1382 let summary = style::summary(&response.summary, style);
1383 if !summary.is_empty() {
1384 out.push(summary);
1385 }
1386 if !refuted.is_empty() {
1387 out.push(format!("refuted\n{}", bullets(refuted)));
1388 }
1389 if !fixed.is_empty() {
1390 out.push(format!("fixed\n{}", bullets(fixed)));
1391 }
1392 if !filed.is_empty() {
1393 out.push(format!("filed\n{}", bullets(filed)));
1394 }
1395 Some(out.join("\n\n"))
1396}
1397
1398pub fn skip_comment(item: &SkippedItem, style: &Style) -> String {
1406 let reasons = item
1407 .reasons
1408 .values()
1409 .map(|reason| style::sentence(reason, style));
1410 let lines = crate::textsim::dedupe_by(reasons, crate::textsim::same_reason);
1414 bullets(&lines)
1415}
1416
1417pub(crate) fn findings_for_prompt(findings: &[Finding]) -> String {
1420 if findings.is_empty() {
1421 return "(none)".to_string();
1422 }
1423 findings
1424 .iter()
1425 .map(|f| {
1426 let scope = if f.in_scope { "" } else { " [out of scope]" };
1427 format!(
1428 "- [{}]{scope} {} ({})\n {}",
1429 f.severity,
1430 f.title,
1431 f.where_at(),
1432 f.detail
1433 )
1434 })
1435 .collect::<Vec<_>>()
1436 .join("\n")
1437}
1438
1439#[cfg(test)]
1440mod tests {
1441 use super::*;
1442 use crate::model::Verdict;
1443
1444 fn style() -> Style {
1445 Style::default()
1446 }
1447
1448 fn finding(severity: &str, title: &str, detail: &str, file: &str, in_scope: bool) -> Finding {
1449 Finding {
1450 severity: Severity::parse_lenient(severity).unwrap(),
1451 title: title.into(),
1452 detail: detail.into(),
1453 file: file.into(),
1454 in_scope,
1455 ..Default::default()
1456 }
1457 }
1458
1459 fn review(summary: &str, findings: Vec<Finding>) -> Review {
1460 Review {
1461 verdict: Verdict::Approve,
1462 next_action: NextAction::Merge,
1463 summary: summary.into(),
1464 findings,
1465 }
1466 }
1467
1468 fn cfg_with(worktrees: bool, keep: bool) -> Config {
1471 let text = "[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n";
1472 let mut cfg = crate::config::parse(text).unwrap();
1473 cfg.loop_cfg.worktrees = worktrees;
1474 cfg.loop_cfg.keep_worktrees = keep;
1475 cfg
1476 }
1477
1478 #[test]
1479 fn a_worktree_is_released_on_every_finished_outcome() {
1480 let cfg = cfg_with(true, false);
1481 for status in [Status::Approved, Status::Merged, Status::Abandoned] {
1482 assert!(should_release(&cfg, status), "{status}");
1483 }
1484 }
1485
1486 #[test]
1489 fn a_worktree_is_kept_only_where_a_human_has_to_look() {
1490 let cfg = cfg_with(true, false);
1491 assert!(!should_release(&cfg, Status::Escalated));
1492 assert!(!should_release(&cfg, Status::Error));
1493 }
1494
1495 #[test]
1496 fn the_keep_flag_overrides_everything() {
1497 assert!(!should_release(&cfg_with(true, true), Status::Approved));
1498 }
1499
1500 #[test]
1501 fn nothing_is_released_when_worktrees_are_off() {
1502 assert!(!should_release(&cfg_with(false, false), Status::Approved));
1503 }
1504
1505 #[test]
1509 fn a_fresh_run_starts_at_one() {
1510 assert_eq!((1, 3), round_window(1, 3));
1511 assert_eq!((1, 5), round_window(1, 5));
1512 }
1513
1514 #[test]
1518 fn a_resumed_run_gets_a_full_fresh_budget() {
1519 assert_eq!((6, 10), round_window(6, 5));
1520 assert_eq!((11, 13), round_window(11, 3));
1521 }
1522
1523 #[test]
1524 fn a_budget_of_one_is_a_single_round() {
1525 assert_eq!((6, 6), round_window(6, 1));
1526 }
1527
1528 #[test]
1529 fn round_numbers_keep_counting_across_sessions() {
1530 let mut start = 1;
1532 let mut seen = Vec::new();
1533 for _ in 0..3 {
1534 let (first, last) = round_window(start, 3);
1535 seen.push((first, last));
1536 start = last + 1;
1537 }
1538 assert_eq!(vec![(1, 3), (4, 6), (7, 9)], seen);
1539 }
1540
1541 fn ledger_with(title: &str, file: &str) -> Ledger {
1544 let mut ledger = Ledger::new();
1545 ledger.insert(
1546 finding_key(title, file),
1547 LedgerEntry {
1548 title: title.into(),
1549 file: file.into(),
1550 reasoning: "no".into(),
1551 round: 1,
1552 reraised: 0,
1553 },
1554 );
1555 ledger
1556 }
1557
1558 #[test]
1559 fn a_point_refuted_and_re_raised_twice_escalates() {
1560 let mut ledger = ledger_with("nit about naming", "a.rs");
1561 let mut state = IssueRun::new(1, "t");
1562 let blocking = vec![finding("blocking", "nit about naming", "d", "a.rs", true)];
1563 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
1564 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
1565 }
1566
1567 #[test]
1568 fn an_untracked_finding_does_not_escalate() {
1569 let mut state = IssueRun::new(1, "t");
1570 let blocking = vec![finding("blocking", "brand new", "d", "a.rs", true)];
1571 assert!(!check_relitigation(
1572 &mut Ledger::new(),
1573 &blocking,
1574 &mut state
1575 ));
1576 }
1577
1578 #[test]
1582 fn a_refutation_lands_on_the_key_the_next_round_will_look_up() {
1583 let blocking = vec![finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
1584 let recorded = finding_key(&blocking[0].title, &blocking[0].file);
1585
1586 let matched = matching_finding(&blocking, "unbounded loop!").expect("should match");
1587 assert_eq!(recorded, finding_key("unbounded loop!", &matched.file));
1588 }
1589
1590 #[test]
1595 fn the_ledger_key_uses_the_reviewers_wording_not_the_authors() {
1596 let findings = vec![finding(
1597 "blocking",
1598 "Panic on multi-byte input",
1599 "d",
1600 "src/style.rs",
1601 true,
1602 )];
1603 let reworded = "Panic on multibyte input";
1604
1605 let source = matching_finding(&findings, reworded).expect("still matches");
1606 assert_ne!(
1607 finding_key(reworded, &source.file),
1608 finding_key(&source.title, &source.file),
1609 "the two spellings must genuinely hash apart, or this test proves nothing"
1610 );
1611
1612 let recorded = finding_key(&source.title, &source.file);
1614 let looked_up = finding_key(&findings[0].title, &findings[0].file);
1615 assert_eq!(recorded, looked_up);
1616 }
1617
1618 #[test]
1619 fn a_disposition_matches_its_finding_despite_wording_noise() {
1620 let findings = vec![finding(
1621 "blocking",
1622 "Unbounded loop!",
1623 "d",
1624 "src/x.rs",
1625 true,
1626 )];
1627 assert!(matching_finding(&findings, "unbounded loop").is_some());
1628 assert!(matching_finding(&findings, "something else").is_none());
1629 }
1630
1631 #[test]
1632 fn the_settled_block_is_empty_when_nothing_is_settled() {
1633 assert_eq!("", settled_block(&Ledger::new()));
1634 }
1635
1636 #[test]
1637 fn the_settled_block_names_each_refutation() {
1638 let block = settled_block(&ledger_with("a point", "x.rs"));
1639 assert!(block.contains("a point"));
1640 assert!(block.contains("settled"));
1641 }
1642
1643 #[test]
1646 fn a_clean_review_is_just_the_verdict() {
1649 let text = review_comment("codex", 1, &review("Looks correct.", vec![]), &style());
1650 assert_eq!("Looks correct.", text);
1651 }
1652
1653 #[test]
1654 fn a_review_leads_with_the_counts() {
1655 let text = review_comment(
1656 "codex",
1657 2,
1658 &review(
1659 "One real problem.",
1660 vec![
1661 finding(
1662 "blocking",
1663 "Loop never terminates",
1664 "Confirmed by running it.",
1665 "src/a.rs",
1666 true,
1667 ),
1668 finding("non-blocking", "Name is vague", "d", "src/b.rs", true),
1669 finding("nit", "Log wording", "d", "", true),
1670 ],
1671 ),
1672 &style(),
1673 );
1674 assert!(text.starts_with("One real problem."), "{text}");
1675 assert!(!text.contains("codex"), "no agent name: {text}");
1676 assert!(!text.contains("round 2"), "no round number: {text}");
1677 }
1678
1679 #[test]
1682 fn only_blocking_findings_carry_their_detail() {
1683 let text = review_comment(
1684 "codex",
1685 1,
1686 &review(
1687 "s",
1688 vec![
1689 finding("blocking", "Loop", "BLOCKING DETAIL", "a.rs", true),
1690 finding("non-blocking", "Name", "NONBLOCKING DETAIL", "b.rs", true),
1691 ],
1692 ),
1693 &style(),
1694 );
1695 assert!(text.contains("BLOCKING DETAIL"), "{text}");
1696 assert!(!text.contains("NONBLOCKING DETAIL"), "{text}");
1697 }
1698
1699 #[test]
1700 fn a_thorough_explanation_reaches_the_author_intact() {
1703 let detail = "Reproduced by running the 429 test with max_attempts unset. ".repeat(8);
1704 let text = review_comment(
1705 "codex",
1706 1,
1707 &review(
1708 "One problem.",
1709 vec![finding("blocking", "T", &detail, "a.rs", true)],
1710 ),
1711 &style(),
1712 );
1713 assert!(
1714 text.contains(detail.trim()),
1715 "the explanation was cut:\n{text}"
1716 );
1717 }
1718
1719 #[test]
1721 fn a_runaway_model_is_still_bounded() {
1722 let long = "filler words. ".repeat(20_000);
1723 let text = review_comment(
1724 "codex",
1725 1,
1726 &review(&long, vec![finding("blocking", "T", &long, "a.rs", true)]),
1727 &style(),
1728 );
1729 assert!(
1730 text.len() < 30_000,
1731 "review comment was {} chars",
1732 text.len()
1733 );
1734 }
1735
1736 #[test]
1737 fn a_general_finding_has_no_empty_parenthesis() {
1738 let text = review_comment(
1739 "codex",
1740 1,
1741 &review("s", vec![finding("blocking", "Something", "d", "", true)]),
1742 &style(),
1743 );
1744 assert!(!text.contains("()"), "{text}");
1745 assert!(!text.contains("(general)"), "{text}");
1746 }
1747
1748 #[test]
1749 fn out_of_scope_findings_are_counted_separately() {
1750 let text = review_comment(
1751 "codex",
1752 1,
1753 &review(
1754 "s",
1755 vec![finding("blocking", "Old bug", "d", "a.rs", false)],
1756 ),
1757 &style(),
1758 );
1759 assert!(text.contains("out of scope"), "{text}");
1760 assert!(text.contains("Old bug"), "{text}");
1761 }
1762
1763 #[test]
1764 fn a_disposition_comment_leads_with_counts_and_keeps_refutations() {
1765 let response = ResponseDoc {
1766 summary: "Two of three were right.".into(),
1767 dispositions: vec![],
1768 };
1769 let text = disposition_comment(
1770 "claude",
1771 &response,
1772 &["Fixed thing".to_string()],
1773 &["Wrong thing. Because the caller already checks.".to_string()],
1774 &[],
1775 &style(),
1776 )
1777 .unwrap();
1778 assert!(text.starts_with("Two of three were right."), "{text}");
1779 assert!(!text.contains("claude"), "no agent name: {text}");
1780 assert!(
1781 text.contains("Because the caller already checks."),
1782 "{text}"
1783 );
1784 }
1785
1786 #[test]
1787 fn an_empty_disposition_comment_is_not_posted() {
1788 let response = ResponseDoc {
1789 summary: "s".into(),
1790 dispositions: vec![],
1791 };
1792 assert!(disposition_comment("claude", &response, &[], &[], &[], &style()).is_none());
1793 }
1794
1795 #[test]
1800 fn the_implementor_is_given_the_link_and_the_body() {
1801 let prompt = implement_prompt(
1802 42,
1803 "Retry a 429",
1804 "https://github.com/o/r/issues/42",
1805 "A rate limited response was treated as fatal.",
1806 );
1807 assert!(
1808 prompt.contains("https://github.com/o/r/issues/42"),
1809 "{prompt}"
1810 );
1811 assert!(
1812 prompt.contains("A rate limited response was treated as fatal."),
1813 "{prompt}"
1814 );
1815 assert!(prompt.contains("#42"), "{prompt}");
1816 assert!(prompt.contains("Retry a 429"), "{prompt}");
1817 assert!(!prompt.contains('{'), "{prompt}");
1819 }
1820
1821 #[test]
1824 fn the_prompt_says_the_discussion_is_not_included() {
1825 let prompt = implement_prompt(1, "t", "u", "b");
1826 let lower = prompt
1828 .split_whitespace()
1829 .collect::<Vec<_>>()
1830 .join(" ")
1831 .to_lowercase();
1832 assert!(
1833 lower.contains("discussion since is not included"),
1834 "{prompt}"
1835 );
1836 assert!(lower.contains("cannot reach the network"), "{prompt}");
1837 }
1838
1839 fn worked() -> Implementation {
1841 Implementation {
1842 summary: "Retry a 429 instead of failing the run.".into(),
1843 problem: "A rate limited response was treated as fatal, so one throttled call ended \
1844 a run that had hours of work left in it."
1845 .into(),
1846 changes: vec![
1847 "`send` retries a 429 with the delay the header asks for".into(),
1848 "the retry budget is bounded, so a permanent 429 still ends".into(),
1849 ],
1850 testing: vec![
1851 "`cargo test retries_a_429`".into(),
1852 "point it at a throttled endpoint and watch it finish".into(),
1853 ],
1854 ..Implementation::default()
1855 }
1856 }
1857
1858 #[test]
1859 fn a_pr_body_is_what_it_closes_and_what_changed() {
1862 let body = pr_body(42, &worked(), &style());
1863 assert_eq!(
1864 "Closes #42\n\n\
1865 Retry a 429 instead of failing the run.\n\n\
1866 A rate limited response was treated as fatal, so one throttled call \
1867 ended a run that had hours of work left in it.\n\n\
1868 ## What changed\n\n\
1869 - `send` retries a 429 with the delay the header asks for\n\
1870 - the retry budget is bounded, so a permanent 429 still ends\n\n\
1871 ## How to test\n\n\
1872 - `cargo test retries_a_429`\n\
1873 - point it at a throttled endpoint and watch it finish",
1874 body
1875 );
1876 }
1877
1878 #[test]
1881 fn a_body_with_nothing_to_list_carries_no_empty_headings() {
1882 let work = Implementation {
1883 summary: "Retry a 429 instead of failing the run.".into(),
1884 ..Implementation::default()
1885 };
1886 assert_eq!(
1887 "Closes #42\n\nRetry a 429 instead of failing the run.",
1888 pr_body(42, &work, &style())
1889 );
1890 }
1891
1892 #[test]
1893 fn a_pr_body_survives_an_implementor_that_said_nothing() {
1894 assert_eq!(
1895 "Closes #7",
1896 pr_body(7, &Implementation::default(), &style())
1897 );
1898 }
1899
1900 #[test]
1903 fn blank_list_entries_do_not_earn_a_heading() {
1904 let work = Implementation {
1905 summary: "Did a thing.".into(),
1906 changes: vec![String::new(), " ".into()],
1907 ..Implementation::default()
1908 };
1909 let body = pr_body(42, &work, &style());
1910 assert!(!body.contains("What changed"), "{body}");
1911 }
1912
1913 #[test]
1914 fn notes_appear_only_when_there_is_something_to_note() {
1915 let mut work = worked();
1916 assert!(!pr_body(42, &work, &style()).contains("## Notes"));
1917 work.notes = Some("The retry is not applied to streaming calls.".into());
1918 let body = pr_body(42, &work, &style());
1919 assert!(body.contains("## Notes"), "{body}");
1920 assert!(body.contains("streaming calls"), "{body}");
1921 }
1922
1923 #[test]
1926 fn declining_posts_the_reason_and_not_the_summary() {
1927 let work = Implementation {
1928 not_worth_doing: true,
1929 reason: "Already fixed in 1.2, and the report predates it.".into(),
1930 summary: "Nothing to do.".into(),
1931 ..Implementation::default()
1932 };
1933 assert_eq!(
1934 "Already fixed in 1.2, and the report predates it.",
1935 no_pr_note(&work, &style())
1936 );
1937 }
1938
1939 #[test]
1940 fn reporting_work_and_committing_none_says_that_rather_than_the_summary() {
1941 let work = Implementation {
1942 summary: "Retry a 429 instead of failing the run.".into(),
1943 ..Implementation::default()
1944 };
1945 let note = no_pr_note(&work, &style());
1946 assert_eq!(
1947 "Nothing was committed, so there is nothing to review.",
1948 note
1949 );
1950 }
1951
1952 #[test]
1953 fn declining_without_a_reason_still_says_something() {
1954 let work = Implementation {
1955 not_worth_doing: true,
1956 ..Implementation::default()
1957 };
1958 assert!(no_pr_note(&work, &style()).contains("no reason given"));
1959 }
1960
1961 #[test]
1962 fn a_skip_comment_is_only_the_reasoning() {
1963 let item = SkippedItem {
1964 issue: 3,
1965 title: "t".into(),
1966 tracker: false,
1967 reasons: [
1968 ("claude".to_string(), "Already fixed in 1.2.".to_string()),
1969 ("codex".to_string(), "Duplicate of #2.".to_string()),
1970 ]
1971 .into_iter()
1972 .collect(),
1973 };
1974 let text = skip_comment(&item, &style());
1975 assert!(text.contains("Already fixed in 1.2."), "{text}");
1976 assert!(text.contains("Duplicate of #2."), "{text}");
1977 assert!(
1978 !text.contains("claude") && !text.contains("codex"),
1979 "{text}"
1980 );
1981 assert!(!text.to_lowercase().contains("not scheduled"), "{text}");
1982 assert!(text.lines().count() <= 3, "{text}");
1983 }
1984
1985 #[test]
1986 fn findings_for_a_model_keep_full_detail() {
1987 let long = "x".repeat(2000);
1988 let text = findings_for_prompt(&[finding("blocking", "T", &long, "a.rs", true)]);
1989 assert!(
1990 text.contains(&long),
1991 "a model needs the whole finding, only humans need brevity"
1992 );
1993 }
1994
1995 #[test]
1996 fn findings_for_a_model_are_never_empty() {
1997 assert_eq!("(none)", findings_for_prompt(&[]));
1998 }
1999}
2000
2001#[cfg(test)]
2002mod outcome_tests {
2003 use super::*;
2004 use crate::model::{Dispute, Severity};
2005
2006 fn style() -> Style {
2007 Style::default()
2008 }
2009
2010 fn state_with(disputes: Vec<(&str, &str)>, filed: Vec<&str>) -> IssueRun {
2011 let mut s = IssueRun::new(482, "t");
2012 s.disputes = disputes
2013 .into_iter()
2014 .map(|(title, reasoning)| Dispute {
2015 title: title.into(),
2016 reasoning: reasoning.into(),
2017 })
2018 .collect();
2019 s.filed = filed.into_iter().map(String::from).collect();
2020 s
2021 }
2022
2023 fn finding(title: &str, file: &str) -> Finding {
2024 Finding {
2025 severity: Severity::Blocking,
2026 title: title.into(),
2027 detail: "d".into(),
2028 file: file.into(),
2029 in_scope: true,
2030 ..Default::default()
2031 }
2032 }
2033
2034 #[test]
2037 fn a_clean_approval_says_nothing() {
2038 let state = state_with(vec![], vec![]);
2039 assert!(outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).is_none());
2040 }
2041
2042 #[test]
2043 fn an_approval_that_filed_follow_ups_links_them() {
2044 let state = state_with(
2045 vec![],
2046 vec![
2047 "https://github.com/you/thing/issues/485",
2048 "https://github.com/you/thing/issues/486",
2049 ],
2050 );
2051 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
2052 assert!(text.contains("Filed separately: #485, #486"), "{text}");
2053 }
2054
2055 #[test]
2059 fn running_out_of_rounds_says_what_that_means_for_the_reader() {
2060 let state = state_with(vec![], vec![]);
2061 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
2062 assert!(text.contains("has not been reviewed"), "{text}");
2063 assert!(
2064 !text.to_lowercase().contains("round 3"),
2065 "no round numbers: {text}"
2066 );
2067 assert!(!text.to_lowercase().contains("convergence"), "{text}");
2068 }
2069
2070 #[test]
2071 fn a_deadlock_names_the_point_they_could_not_settle() {
2072 let state = state_with(vec![], vec![]);
2073 let points = [finding("Retry loop never terminates", "src/net.rs:88")];
2074 let text = outcome_comment(
2075 &state,
2076 &Ledger::new(),
2077 &Ending::Deadlocked(&points),
2078 &style(),
2079 )
2080 .unwrap();
2081 assert!(
2082 text.contains("Retry loop never terminates (src/net.rs:88)"),
2083 "{text}"
2084 );
2085 assert!(text.contains("could not settle"), "{text}");
2086 }
2087
2088 #[test]
2090 fn refutations_survive_because_nothing_else_carries_them() {
2091 let state = state_with(
2092 vec![(
2093 "Error is swallowed",
2094 "the caller already validates the file",
2095 )],
2096 vec![],
2097 );
2098 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
2099 assert!(text.contains("Raised and refuted:"), "{text}");
2100 assert!(
2101 text.contains("The caller already validates the file"),
2102 "{text}"
2103 );
2104 }
2105
2106 #[test]
2107 fn no_agent_names_counts_or_round_numbers_reach_the_thread() {
2108 let state = state_with(
2109 vec![("A point", "a reason")],
2110 vec!["https://github.com/you/thing/issues/485"],
2111 );
2112 for ending in [Ending::Approved, Ending::OutOfRounds] {
2113 let text = outcome_comment(&state, &Ledger::new(), &ending, &style()).unwrap();
2114 let lower = text.to_lowercase();
2115 for banned in ["claude", "codex", "blocking,", "nit,", " fixed."] {
2116 assert!(
2117 !lower.contains(banned),
2118 "{banned:?} leaked into the thread:\n{text}"
2119 );
2120 }
2121 for n in 1..9 {
2123 assert!(
2124 !lower.contains(&format!("round {n}")),
2125 "a round number leaked into the thread:\n{text}"
2126 );
2127 }
2128 }
2129 }
2130
2131 #[test]
2132 fn a_refutation_is_allowed_to_make_its_case() {
2135 let reasoning = "The caller validates against the schema first. \
2136 The discarded error is therefore unreachable in practice. ";
2137 let state = state_with(
2138 vec![("A point", &reasoning.repeat(6))],
2139 vec!["https://github.com/you/thing/issues/485"],
2140 );
2141 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
2142 assert!(
2143 !text.contains("..."),
2144 "nothing was cut mid thought:\n{text}"
2145 );
2146 assert!(text.len() < 4000, "{} chars", text.len());
2147 }
2148
2149 #[test]
2150 fn a_url_that_is_not_an_issue_link_is_left_alone() {
2151 assert_eq!(
2152 "#485",
2153 as_reference("https://github.com/you/thing/issues/485")
2154 );
2155 assert_eq!("note: something", as_reference("note: something"));
2156 }
2157}
2158
2159#[cfg(test)]
2160mod filed_reference_tests {
2161 use super::*;
2162
2163 #[test]
2164 fn an_issue_url_yields_its_number() {
2165 assert_eq!(
2166 Some(485),
2167 filed_issue_number("https://github.com/you/thing/issues/485")
2168 );
2169 }
2170
2171 #[test]
2174 fn a_local_note_yields_nothing() {
2175 assert_eq!(None, filed_issue_number("note: Retry is unbounded"));
2176 assert_eq!(None, filed_issue_number(""));
2177 assert_eq!(
2178 None,
2179 filed_issue_number("https://github.com/you/thing/issues/")
2180 );
2181 }
2182}
2183
2184#[cfg(test)]
2185mod followup_restraint_tests {
2186 use super::*;
2187 use crate::model::Severity;
2188
2189 fn cfg_with(followups: Followups, non_blocking: bool, nits: bool, cap: usize) -> Config {
2190 let mut cfg =
2191 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
2192 .unwrap();
2193 cfg.loop_cfg.followups = followups;
2194 cfg.loop_cfg.file_non_blocking = non_blocking;
2195 cfg.loop_cfg.file_nits = nits;
2196 cfg.loop_cfg.max_followups = cap;
2197 cfg
2198 }
2199
2200 fn finding(severity: Severity, title: &str, in_scope: bool) -> Finding {
2201 Finding {
2202 severity,
2203 title: title.into(),
2204 detail: "d".into(),
2205 file: "a.rs".into(),
2206 in_scope,
2207 ..Default::default()
2208 }
2209 }
2210
2211 #[test]
2215 fn a_non_blocking_finding_is_not_a_tracker_item_by_default() {
2216 let cfg = cfg_with(Followups::Issues, false, false, 5);
2217 assert!(!cfg.loop_cfg.file_non_blocking);
2218 assert!(!cfg.loop_cfg.file_nits);
2219 }
2220
2221 #[test]
2222 fn follow_ups_stay_off_the_tracker_by_default() {
2223 let cfg =
2224 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
2225 .unwrap();
2226 assert_eq!(
2227 Followups::Local,
2228 cfg.loop_cfg.followups,
2229 "the tracker is somebody's queue; the default must not write to it"
2230 );
2231 assert_eq!(5, cfg.loop_cfg.max_followups);
2232 }
2233
2234 #[test]
2236 fn only_out_of_scope_defects_qualify_at_the_defaults() {
2237 let cfg = cfg_with(Followups::Issues, false, false, 5);
2238 let qualifies = |f: &Finding| match f.severity {
2239 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking && f.in_scope,
2240 Severity::Nit => cfg.loop_cfg.file_nits && f.in_scope,
2241 Severity::Blocking => false,
2242 } || !f.in_scope;
2243
2244 assert!(qualifies(&finding(
2245 Severity::Blocking,
2246 "pre-existing",
2247 false
2248 )));
2249 assert!(!qualifies(&finding(
2250 Severity::NonBlocking,
2251 "improvement",
2252 true
2253 )));
2254 assert!(!qualifies(&finding(Severity::Nit, "taste", true)));
2255 assert!(!qualifies(&finding(
2256 Severity::Blocking,
2257 "fix it here",
2258 true
2259 )));
2260 }
2261
2262 #[test]
2263 fn opening_it_up_lets_non_blocking_findings_through_again() {
2264 let cfg = cfg_with(Followups::Issues, true, false, 5);
2265 assert!(cfg.loop_cfg.file_non_blocking);
2266 }
2267
2268 #[test]
2270 fn the_cap_is_a_real_backstop() {
2271 let cfg = cfg_with(Followups::Issues, false, false, 3);
2272 let mut state = IssueRun::new(1, "t");
2273 state.filed = (0..3).map(|n| format!("url{n}")).collect();
2274 assert!(state.filed.len() >= cfg.loop_cfg.max_followups);
2275 }
2276
2277 #[test]
2281 fn the_cap_bounds_what_one_run_can_spawn() {
2282 let cfg = cfg_with(Followups::Issues, false, false, 5);
2283 assert!(
2284 cfg.loop_cfg.max_followups <= 5,
2285 "a run that can file ten follow-ups is a branching process"
2286 );
2287 }
2288}
2289
2290#[cfg(test)]
2291mod issue_report_tests {
2292 use super::*;
2293 use crate::model::Severity;
2294
2295 fn reported() -> Finding {
2298 Finding {
2299 severity: Severity::Blocking,
2300 title: "sendPaymentAsync bypasses drain mode and spending limits".into(),
2301 detail: "The async path skips every admission check payInvoice applies.".into(),
2302 file: "src/node.ts:412".into(),
2303 in_scope: false,
2304 problem: Some(
2305 "`BeignetNode.sendPaymentAsync()` submits a payment directly to the Lightning \
2306 engine without applying the safeguards used by `payInvoice()`.\n\nThe async path \
2307 does not:\n\n- call `_checkDraining()`\n- call `_checkSpendLimit()`"
2308 .into(),
2309 ),
2310 reproduction: Some(
2311 "1. Create a `BeignetNode` with `dailySpendLimitSats: 1`.\n2. Enable drain mode.\n\
2312 3. Submit a 1,000 sat invoice.\n\nActual result:\n\n- The engine is called.\n\
2313 - `spentSats` remains 0."
2314 .into(),
2315 ),
2316 impact: Some(
2317 "An authorized client can submit async payments up to the available outbound \
2318 liquidity despite the configured limits."
2319 .into(),
2320 ),
2321 expected: Some(
2322 "- Reject new payments while draining.\n- Enforce the per-payment limit before \
2323 submission.\n- Cover both paths with regression tests.\n\nThis predates the \
2324 current branch."
2325 .into(),
2326 ),
2327 }
2328 }
2329
2330 #[test]
2331 fn a_reported_finding_becomes_a_bug_report() {
2332 let body = issue_report(&reported());
2333 for heading in [
2334 "## Problem",
2335 "## Reproduction",
2336 "## Impact",
2337 "## Expected behavior",
2338 ] {
2339 assert!(body.contains(heading), "missing {heading}:\n{body}");
2340 }
2341 let at = |h: &str| body.find(h).unwrap();
2343 assert!(at("## Problem") < at("## Reproduction"));
2344 assert!(at("## Reproduction") < at("## Impact"));
2345 assert!(at("## Impact") < at("## Expected behavior"));
2346 }
2347
2348 #[test]
2349 fn the_substance_survives_the_outbound_gates() {
2350 let repo_style = Style::default();
2351 let body = crate::style::issue_body(&issue_report(&reported()), &repo_style);
2352 for kept in [
2353 "_checkDraining()",
2354 "Actual result:",
2355 "outbound liquidity",
2356 "regression tests",
2357 "predates the current branch",
2358 ] {
2359 assert!(body.contains(kept), "the gate ate {kept:?}:\n{body}");
2360 }
2361 assert!(!body.contains("..."), "something was cut:\n{body}");
2362 }
2363
2364 #[test]
2367 fn an_ordinary_finding_is_still_just_its_detail() {
2368 let plain = Finding {
2369 severity: Severity::NonBlocking,
2370 title: "Name is vague".into(),
2371 detail: "The variable could say what it holds.".into(),
2372 file: "a.rs".into(),
2373 in_scope: true,
2374 ..Default::default()
2375 };
2376 assert_eq!(
2377 "The variable could say what it holds.",
2378 issue_report(&plain)
2379 );
2380 }
2381
2382 #[test]
2385 fn only_the_sections_that_were_written_appear() {
2386 let partial = Finding {
2387 problem: Some("The guard is inverted.".into()),
2388 expected: Some("It should reject rather than accept.".into()),
2389 ..reported()
2390 };
2391 let partial = Finding {
2392 reproduction: None,
2393 impact: None,
2394 ..partial
2395 };
2396 let body = issue_report(&partial);
2397 assert!(body.contains("## Problem") && body.contains("## Expected behavior"));
2398 assert!(!body.contains("## Reproduction"), "{body}");
2399 assert!(!body.contains("## Impact"), "{body}");
2400 }
2401
2402 #[test]
2405 fn the_summary_line_is_not_printed_twice() {
2406 let echoed = Finding {
2407 detail: "The guard is inverted so it rejects valid input.".into(),
2408 problem: Some("The guard is inverted so it rejects valid input.".into()),
2409 reproduction: None,
2410 impact: None,
2411 expected: None,
2412 ..reported()
2413 };
2414 let body = issue_report(&echoed);
2415 assert_eq!(1, body.matches("The guard is inverted").count(), "{body}");
2416 }
2417}