1use std::path::{Path, PathBuf};
19
20use crate::agent::{self, Agent};
21use crate::config::{Config, Followups, PrComments};
22use crate::error::{Result, SparError};
23use crate::jsonx::finding_key;
24use crate::model::{
25 Action, Dispute, Finding, Issue, IssueRun, Ledger, LedgerEntry, NextAction, PersistedState,
26 PlanItem, PrView, ResponseDoc, Review, Severity, SkippedItem, Status, STATE_VERSION,
27};
28use crate::repo::Repo;
29use crate::style::{self, Style};
30use crate::{log, logdim, logwarn, schema, spar_err};
31
32const IMPLEMENT_PROMPT: &str = "\
37Implement GitHub issue #{number} in this repository.
38
39Title: {title}
40
41{body}
42
43Do the work, then commit it on the current branch. Make focused commits with
44clear messages. Do not push, do not open a PR, and do not merge; the harness
45handles that.
46
47End your final message with a line of exactly this form:
48SUMMARY: <one sentence under 120 characters saying what changed>
49That line becomes the PR description, so write it for the reviewer who has to
50read it, and say what changed rather than that you changed something.
51
52If after reading the code you conclude this issue should not be implemented,
53make no commits and explain why in your final message, beginning with
54NOT_WORTH_DOING.";
55
56const REVIEW_PROMPT: &str = "\
57Review the changes on this branch against `{base}`. They implement issue
58#{number}: {title}
59
60Review thoroughly: correctness, edge cases, error handling, security, and
61whether the change actually resolves the issue. Read surrounding code, do not
62only read the diff.
63
64Label every finding by severity, and be honest about which is which:
65- blocking: the PR should not merge as is. Real defects only.
66- non-blocking: a genuine improvement that need not gate this PR.
67- nit: style or taste.
68
69Confirm anything you label blocking before you label it. Run the code,
70reproduce the failure, or point at the exact line that breaks, and say in the
71detail what you did to confirm it. An unverified blocking finding is worse than
72one you never raised: it stalls a good PR and teaches the author to stop
73believing you. If you suspect a problem but could not confirm it, say so and
74label it non-blocking.
75
76Set in_scope=false for a real defect that exists, that this PR did not cause, and
77that is worth somebody stopping to fix. Each one becomes a tracked item a
78maintainer has to read and triage, so the bar is a defect and not an observation.
79A thorough reviewer can always find something adjacent to what it is reading;
80that is not a reason to file it. If you are not sure it is worth a maintainer's
81time, leave in_scope true and say your piece in the finding.
82
83Reviewing one issue should not manufacture ten more. If you find yourself with
84several out of scope findings, keep the ones that would bite somebody and drop
85the rest.
86
87Then choose next_action:
88- merge: no blocking findings, the PR is good.
89- fix_myself: there are blocking findings and you will fix them directly.
90- hand_back: there are blocking findings the author should address.
91{settled}";
92
93const FIX_PROMPT: &str = "\
94You reviewed this branch and chose to fix the blocking findings yourself.
95Implement those fixes now and commit them.
96
97Your findings:
98{findings}
99
100Commit your changes. Do not push, do not merge.";
101
102const RESPOND_PROMPT: &str = "\
103Here is a review of your PR for issue #{number}.
104
105{findings}
106
107For each point, choose exactly one disposition:
108- fixed: the point is valid and in scope. Fix it and commit.
109- refuted: the point is wrong, or not worth acting on. Explain why. Refuting is
110 a legitimate outcome; do not accept a review comment you believe is incorrect
111 just to get the PR approved.
112- filed_issue: the point is valid but unrelated to this PR. Supply
113 new_issue_title and new_issue_body; the harness files it and skips duplicates.
114
115Copy each finding's title and file across exactly as given, so your answer can
116be matched back to the review.
117
118Commit any fixes. Do not push, do not merge.";
119
120fn should_release(cfg: &Config, status: Status) -> bool {
125 if !cfg.loop_cfg.worktrees || cfg.loop_cfg.keep_worktrees {
126 return false;
127 }
128 !matches!(status, Status::Escalated | Status::Error)
129}
130
131pub fn run_issue(
136 agents: &[Agent],
137 cfg: &Config,
138 repo: &Repo,
139 item: &PlanItem,
140 issue: &Issue,
141 ledger: &mut Ledger,
142) -> IssueRun {
143 if let Some(existing) = repo.open_pr_for_issue(item.issue) {
151 log!(
152 "#{}: {} is already open, continuing it instead of implementing again",
153 item.issue,
154 existing.url
155 );
156 return resume_pr(agents, cfg, repo, existing.number, None);
157 }
158
159 let mut state = IssueRun::new(item.issue, item.title.clone());
160 let base = cfg.base_branch().to_string();
161
162 let prepared = if cfg.loop_cfg.worktrees {
163 repo.worktree_add(item.issue, &base)
164 } else {
165 let branch = repo.branch_for_issue(item.issue);
166 let start = format!("origin/{base}");
167 repo.git(&["checkout", "-B", &branch, &start])
168 .map(|_| (repo.root().to_path_buf(), branch))
169 };
170
171 let (work_dir, branch) = match prepared {
172 Ok(pair) => pair,
173 Err(e) => {
174 state.status = Status::Error;
175 state.notes.push(e.to_string());
176 log!("#{} failed: {e}", item.issue);
177 return state;
178 }
179 };
180
181 let outcome = implement_and_review(
182 agents, cfg, repo, item, issue, ledger, &mut state, &work_dir, &branch,
183 );
184 if let Err(e) = outcome {
185 state.status = Status::Error;
186 state.notes.push(e.to_string());
187 log!("#{} failed: {e}", item.issue);
188 }
189
190 if should_release(cfg, state.status) {
191 repo.worktree_remove(item.issue);
192 }
193 state
194}
195
196#[allow(clippy::too_many_arguments)]
197fn implement_and_review(
198 agents: &[Agent],
199 cfg: &Config,
200 repo: &Repo,
201 item: &PlanItem,
202 issue: &Issue,
203 ledger: &mut Ledger,
204 state: &mut IssueRun,
205 work_dir: &Path,
206 branch: &str,
207) -> Result<()> {
208 let number = item.issue;
209 let holder = cfg.first_implementor.clone();
210 let implementor = agent::find(agents, &holder)?;
211 let base = cfg.base_branch().to_string();
212
213 log!("#{number}: {holder} implementing");
214 let body: String = issue.body_text().trim().chars().take(6000).collect();
215 let prompt = IMPLEMENT_PROMPT
216 .replace("{number}", &number.to_string())
217 .replace("{title}", &item.title)
218 .replace("{body}", &body);
219 let out = implementor.ask(
220 &prompt,
221 work_dir,
222 cfg.effort_for_round(&implementor.spec, 1).as_deref(),
223 )?;
224
225 if out.to_uppercase().contains("NOT_WORTH_DOING") || !repo.has_changes(work_dir, &base) {
226 state.status = Status::Abandoned;
227 let reason = style::body(&out, &repo.style);
228 state.notes.push(reason.clone());
229 if let Err(e) = repo.comment_issue(number, &reason) {
230 logdim!("could not comment on #{number}: {e}");
231 }
232 return Ok(());
233 }
234
235 repo.rewrite_commits_if_needed(work_dir, &base)?;
236 repo.push(work_dir, branch)?;
237
238 let pr = match repo.pr_for_branch(branch) {
239 Some(existing) => existing,
240 None => {
241 let summary = extract_summary(&out).unwrap_or_else(|| item.title.clone());
242 let body = pr_body(number, &summary, &repo.style);
243 repo.create_pr(
244 work_dir,
245 branch,
246 &base,
247 &format!("{} (#{number})", item.title),
248 &body,
249 )?
250 }
251 };
252 state.pr = Some(pr.url.clone());
253 log!("#{number}: PR {}", pr.url);
254
255 let ctx = LoopCtx {
256 work_dir: work_dir.to_path_buf(),
257 branch: branch.to_string(),
258 pr_number: pr.number,
259 label: format!("#{number}"),
260 subject: number,
261 title: item.title.clone(),
262 start_round: 1,
263 holder: cfg.other(&holder),
264 release: Release::Issue(number),
265 };
266 review_loop(agents, cfg, repo, &ctx, state, ledger)
267}
268
269pub fn resume_pr(
280 agents: &[Agent],
281 cfg: &Config,
282 repo: &Repo,
283 pr_number: i64,
284 holder_override: Option<&str>,
285) -> IssueRun {
286 let failed = |e: SparError| {
287 log!("PR #{pr_number} failed: {e}");
288 let mut state = IssueRun::new(pr_number, format!("PR #{pr_number}"));
289 state.status = Status::Error;
290 state.notes.push(e.to_string());
291 state
292 };
293
294 let pr = match repo.pr_view(pr_number) {
295 Ok(pr) => pr,
296 Err(e) => return failed(e),
297 };
298
299 if pr.is_cross_repository {
304 log!("PR #{pr_number} comes from a fork, reviewing it without changing it");
305 return crate::review_only::review_pr(agents, cfg, repo, pr_number, false);
306 }
307
308 match resume_inner(agents, cfg, repo, pr, holder_override) {
309 Ok(state) => state,
310 Err(e) => failed(e),
311 }
312}
313
314fn resume_inner(
315 agents: &[Agent],
316 cfg: &Config,
317 repo: &Repo,
318 pr: PrView,
319 holder_override: Option<&str>,
320) -> Result<IssueRun> {
321 let pr_number = pr.number;
322 if !pr.is_open() {
323 return Err(spar_err!("PR #{pr_number} is {}", pr.state.to_lowercase()));
324 }
325
326 let subject = pr
327 .closing_issues_references
328 .first()
329 .map(|r| r.number)
330 .unwrap_or(pr_number);
331
332 let saved = repo.read_state(&pr);
333 let mut ledger: Ledger = saved.as_ref().map(|s| s.ledger.clone()).unwrap_or_default();
334 let start_round = saved.as_ref().map(|s| s.round + 1).unwrap_or(1);
335
336 let default_holder = cfg.other(&cfg.first_implementor);
337 let mut holder = holder_override
338 .map(str::to_string)
339 .or_else(|| saved.as_ref().map(|s| s.next_actor.clone()))
340 .unwrap_or_else(|| default_holder.clone());
341 if !cfg.has_agent(&holder) {
342 log!("state named unknown agent '{holder}', using {default_holder}");
343 holder = default_holder;
344 }
345
346 match &saved {
347 Some(_) => log!(
348 "PR #{pr_number}: resuming at round {start_round}, {} settled point(s), next up {holder}",
349 ledger.len()
350 ),
351 None => log!("PR #{pr_number}: no prior spar state, starting fresh with {holder}"),
352 }
353
354 let mut state = IssueRun::new(subject, pr.title.clone());
355 state.pr = Some(pr.url.clone());
356 if let Some(s) = &saved {
357 state.filed = s.filed.clone();
358 }
359
360 let (work_dir, branch) = repo.worktree_for_pr(&pr)?;
361 let ctx = LoopCtx {
362 work_dir,
363 branch,
364 pr_number,
365 label: format!("PR #{pr_number}"),
366 subject,
367 title: pr.title.clone(),
368 start_round,
369 holder,
370 release: Release::Pr(pr_number),
371 };
372
373 let outcome = review_loop(agents, cfg, repo, &ctx, &mut state, &mut ledger);
374 if let Err(e) = outcome {
375 state.status = Status::Error;
376 state.notes.push(e.to_string());
377 log!("PR #{pr_number} failed: {e}");
378 }
379 if should_release(cfg, state.status) {
380 repo.release_pr_worktree(pr_number);
381 }
382 Ok(state)
383}
384
385#[derive(Debug, Clone, Copy)]
390enum Release {
391 Issue(i64),
392 Pr(i64),
393}
394
395struct LoopCtx {
396 work_dir: PathBuf,
397 branch: String,
398 pr_number: i64,
399 label: String,
400 subject: i64,
401 title: String,
402 start_round: u32,
403 holder: String,
404 release: Release,
405}
406
407impl LoopCtx {
408 fn release(&self, repo: &Repo) {
409 match self.release {
410 Release::Issue(n) => repo.worktree_remove(n),
411 Release::Pr(n) => repo.release_pr_worktree(n),
412 }
413 }
414}
415
416fn review_loop(
417 agents: &[Agent],
418 cfg: &Config,
419 repo: &Repo,
420 ctx: &LoopCtx,
421 state: &mut IssueRun,
422 ledger: &mut Ledger,
423) -> Result<()> {
424 let base = cfg.base_branch().to_string();
425 let mut holder = ctx.holder.clone();
426
427 let (first, last_allowed) = round_window(ctx.start_round, cfg.loop_cfg.max_rounds);
433 let mut last_round = first.saturating_sub(1);
434
435 for round in first..=last_allowed {
436 last_round = round;
437 state.rounds = round;
438 let reviewer = agent::find(agents, &holder)?;
439 let effort = cfg.effort_for_round(&reviewer.spec, round);
440 log!(
441 "{}: round {round}, {holder} reviewing ({})",
442 ctx.label,
443 effort.as_deref().unwrap_or("default effort")
444 );
445
446 let prompt = REVIEW_PROMPT
447 .replace("{base}", &base)
448 .replace("{number}", &ctx.subject.to_string())
449 .replace("{title}", &ctx.title)
450 .replace("{settled}", &settled_block(ledger));
451 let review: Review = reviewer.review(
452 &base,
453 &prompt,
454 &schema::review(),
455 &ctx.work_dir,
456 effort.as_deref(),
457 )?;
458
459 let blocking: Vec<Finding> = review
460 .findings
461 .iter()
462 .filter(|f| f.blocks())
463 .cloned()
464 .collect();
465
466 if repo.style.pr_comments == PrComments::Rounds {
467 if let Err(e) = repo.comment_pr(
468 ctx.pr_number,
469 &review_comment(&holder, round, &review, &repo.style),
470 ) {
471 logdim!("could not post the review comment: {e}");
472 }
473 }
474
475 file_out_of_scope(repo, &review.findings, ctx.subject, state, cfg);
479 file_nonblocking(repo, &review.findings, ctx.subject, state, cfg);
480
481 if check_relitigation(ledger, &blocking, state) {
482 state.status = Status::Escalated;
483 post_outcome(
484 repo,
485 ctx.pr_number,
486 state,
487 ledger,
488 Ending::Deadlocked(&blocking),
489 );
490 persist(
491 repo,
492 ctx.pr_number,
493 state,
494 ledger,
495 round,
496 &cfg.other(&holder),
497 );
498 return Ok(());
499 }
500
501 if blocking.is_empty() {
502 state.status = Status::Approved;
503 post_outcome(repo, ctx.pr_number, state, ledger, Ending::Approved);
504 persist(
505 repo,
506 ctx.pr_number,
507 state,
508 ledger,
509 round,
510 &cfg.other(&holder),
511 );
512 if cfg.loop_cfg.auto_merge {
513 ctx.release(repo);
518 repo.merge_pr(ctx.pr_number)?;
519 state.status = Status::Merged;
520 repo.clear_state(ctx.pr_number); log!("{}: merged", ctx.label);
522 } else {
523 log!("{}: approved, awaiting human merge", ctx.label);
524 }
525 return Ok(());
526 }
527
528 if review.next_action == NextAction::FixMyself {
529 log!("{}: {holder} fixing its own findings", ctx.label);
530 let prompt = FIX_PROMPT.replace("{findings}", &findings_for_prompt(&blocking));
531 reviewer.ask(&prompt, &ctx.work_dir, effort.as_deref())?;
532 } else {
533 let author_name = cfg.other(&holder);
534 let author = agent::find(agents, &author_name)?;
535 log!(
536 "{}: handing {} finding(s) to {author_name}",
537 ctx.label,
538 blocking.len()
539 );
540 let prompt = RESPOND_PROMPT
541 .replace("{number}", &ctx.subject.to_string())
542 .replace("{findings}", &findings_for_prompt(&blocking));
543 let response: ResponseDoc = author.ask_json(
544 &prompt,
545 &schema::response(),
546 &ctx.work_dir,
547 cfg.effort_for_round(&author.spec, round).as_deref(),
548 )?;
549 apply_dispositions(
550 repo,
551 cfg,
552 &response,
553 &blocking,
554 ledger,
555 state,
556 round,
557 ctx.subject,
558 ctx.pr_number,
559 &author_name,
560 );
561 }
562
563 repo.rewrite_commits_if_needed(&ctx.work_dir, &base)?;
564 repo.push(&ctx.work_dir, &ctx.branch)?;
565 holder = cfg.other(&holder);
566 persist(repo, ctx.pr_number, state, ledger, round, &holder);
567 }
568
569 state.status = Status::Escalated;
570 state
571 .notes
572 .push(exhausted_note(ctx.start_round, last_round));
573 post_outcome(repo, ctx.pr_number, state, ledger, Ending::OutOfRounds);
574 persist(repo, ctx.pr_number, state, ledger, last_round, &holder);
575 Ok(())
576}
577
578fn round_window(start_round: u32, budget: u32) -> (u32, u32) {
584 (start_round, start_round + budget.saturating_sub(1))
585}
586
587fn spent(start_round: u32, last_round: u32) -> (u32, u32) {
591 (last_round.saturating_sub(start_round) + 1, last_round)
592}
593
594fn exhausted_note(start_round: u32, last_round: u32) -> String {
595 let (this_run, total) = spent(start_round, last_round);
596 if this_run == total {
597 format!("no convergence after {this_run} rounds")
598 } else {
599 format!("no convergence after {this_run} more rounds ({total} in total)")
600 }
601}
602
603fn persist(
604 repo: &Repo,
605 pr_number: i64,
606 state: &IssueRun,
607 ledger: &Ledger,
608 round: u32,
609 next_actor: &str,
610) {
611 let payload = PersistedState {
612 version: STATE_VERSION,
613 round,
614 next_actor: next_actor.to_string(),
615 status: state.status,
616 ledger: ledger.clone(),
617 filed: state.filed.clone(),
618 };
619 if let Err(e) = repo.write_state(pr_number, &payload) {
620 logdim!("could not persist state for PR #{pr_number}: {e}");
621 }
622}
623
624fn settled_block(ledger: &Ledger) -> String {
629 if ledger.is_empty() {
630 return String::new();
631 }
632 let lines: Vec<String> = ledger
633 .values()
634 .map(|e| format!("- {}: refuted because {}", e.title, e.reasoning))
635 .collect();
636 format!(
637 "\nThe following points were already raised and refuted. Treat them as settled. Do not \
638 raise them again unless you have new evidence:\n{}",
639 lines.join("\n")
640 )
641}
642
643fn check_relitigation(ledger: &mut Ledger, blocking: &[Finding], state: &mut IssueRun) -> bool {
646 let mut escalate = false;
647 for finding in blocking {
648 let key = finding_key(&finding.title, &finding.file);
649 if let Some(entry) = ledger.get_mut(&key) {
650 entry.reraised += 1;
651 if entry.reraised >= 2 {
652 state.notes.push(format!(
653 "'{}' was refuted and re-raised twice; escalating.",
654 finding.title
655 ));
656 escalate = true;
657 }
658 }
659 }
660 escalate
661}
662
663fn normalise(text: &str) -> String {
664 text.to_lowercase()
665 .chars()
666 .filter(|c| c.is_ascii_alphanumeric() || c.is_whitespace())
667 .collect::<String>()
668 .split_whitespace()
669 .collect::<Vec<_>>()
670 .join(" ")
671}
672
673pub(crate) fn same_point(a: &str, b: &str) -> bool {
678 normalise(a) == normalise(b)
679}
680
681fn matching_finding<'a>(findings: &'a [Finding], title: &str) -> Option<&'a Finding> {
682 let wanted = normalise(title);
683 findings.iter().find(|f| normalise(&f.title) == wanted)
684}
685
686#[allow(clippy::too_many_arguments)]
687fn apply_dispositions(
688 repo: &Repo,
689 cfg: &Config,
690 response: &ResponseDoc,
691 blocking: &[Finding],
692 ledger: &mut Ledger,
693 state: &mut IssueRun,
694 round: u32,
695 subject: i64,
696 pr_number: i64,
697 author: &str,
698) {
699 let mut fixed = Vec::new();
700 let mut refuted = Vec::new();
701 let mut filed = Vec::new();
702
703 for d in &response.dispositions {
704 let source = matching_finding(blocking, &d.title);
705 let file = source
706 .map(|f| f.file.clone())
707 .filter(|f| !f.trim().is_empty())
708 .unwrap_or_else(|| d.file.clone());
709 let canonical = source.map(|f| f.title.as_str()).unwrap_or(d.title.as_str());
716 let title = style::title(canonical, &repo.style);
717
718 match d.action {
719 Action::Refuted => {
720 let reasoning = style::summary(&d.reasoning, &repo.style);
721 ledger.insert(
722 finding_key(canonical, &file),
723 LedgerEntry {
724 title: title.clone(),
725 file: file.clone(),
726 reasoning: reasoning.clone(),
727 round,
728 reraised: 0,
729 },
730 );
731 state.disputes.push(Dispute {
732 title: title.clone(),
733 reasoning: reasoning.clone(),
734 });
735 refuted.push(format!("{title}. {reasoning}"));
736 }
737 Action::FiledIssue => {
738 let new_title = d
739 .new_issue_title
740 .clone()
741 .filter(|t| !t.trim().is_empty())
742 .unwrap_or_else(|| d.title.clone());
743 let new_body = d
744 .new_issue_body
745 .clone()
746 .filter(|b| !b.trim().is_empty())
747 .unwrap_or_else(|| d.reasoning.clone());
748 let recorded = file_followup(repo, &new_title, &new_body, subject, cfg, state);
749 if let Some(url) = recorded {
750 state.filed.push(url.clone());
751 filed.push(url);
752 }
753 }
754 Action::Fixed => fixed.push(title),
755 }
756 }
757
758 if repo.style.pr_comments == PrComments::Rounds {
759 let comment = disposition_comment(author, response, &fixed, &refuted, &filed, &repo.style);
760 if let Some(text) = comment {
761 if let Err(e) = repo.comment_pr(pr_number, &text) {
762 logdim!("could not post the disposition comment: {e}");
763 }
764 }
765 }
766}
767
768fn file_followup(
779 repo: &Repo,
780 title: &str,
781 body: &str,
782 source: i64,
783 cfg: &Config,
784 state: &IssueRun,
785) -> Option<String> {
786 if repo.followups == Followups::None {
787 return None;
788 }
789 if state.filed.len() >= cfg.loop_cfg.max_followups {
792 logwarn!(
793 "already recorded {} follow-ups, not recording '{}'. Raise max_followups if you want \
794 them all.",
795 state.filed.len(),
796 style::title(title, &repo.style)
797 );
798 return None;
799 }
800 let title = match repo.clean_title(title) {
804 Ok(title) => title,
805 Err(e) => {
806 logdim!("could not clean a follow-up title: {e}");
807 return None;
808 }
809 };
810 if title.trim().is_empty() {
811 return None;
812 }
813 let body = format!(
816 "{}\n\nFound while working on #{source}.",
817 style::issue_body(body, &repo.style)
818 );
819
820 if repo.followups == Followups::Local {
821 return repo.append_local_followup(&title, &body);
822 }
823
824 if let Some(existing) = repo.find_similar_issue(&title, &body) {
828 let known = format!("{} {}", existing.title, existing.body);
829 if !existing.open {
830 logdim!(
831 "#{} already covers '{title}' and is closed, leaving it alone",
832 existing.number
833 );
834 return None;
835 }
836 if crate::textsim::adds_information(&body, &known) {
837 match repo.comment_issue(existing.number, &body) {
838 Ok(()) => log!("added to #{}: {title}", existing.number),
839 Err(e) => logdim!("could not add to #{}: {e}", existing.number),
840 }
841 } else {
842 logdim!("#{} already says this, nothing added", existing.number);
843 }
844 return Some(existing.url);
845 }
846
847 match repo.create_issue(&title, &body) {
848 Ok(url) => Some(url),
849 Err(e) => {
850 logdim!("could not file a follow-up for '{title}': {e}");
851 None
852 }
853 }
854}
855
856fn file_out_of_scope(
857 repo: &Repo,
858 findings: &[Finding],
859 subject: i64,
860 state: &mut IssueRun,
861 cfg: &Config,
862) {
863 for finding in findings.iter().filter(|f| !f.in_scope) {
864 let body = issue_report(finding);
865 if let Some(url) = file_followup(repo, &finding.title, &body, subject, cfg, state) {
866 state.filed.push(url);
867 }
868 }
869}
870
871pub fn issue_report(finding: &Finding) -> String {
878 let sections = finding.report_sections();
879 if sections.is_empty() {
880 return finding.detail.clone();
881 }
882 let mut out: Vec<String> = sections
883 .iter()
884 .map(|(heading, text)| format!("## {heading}\n\n{text}"))
885 .collect();
886 if !finding.detail.trim().is_empty()
889 && !sections
890 .iter()
891 .any(|(_, text)| crate::textsim::same_point(text, &finding.detail))
892 {
893 out.insert(0, finding.detail.trim().to_string());
894 }
895 out.join("\n\n")
896}
897
898fn file_nonblocking(
905 repo: &Repo,
906 findings: &[Finding],
907 subject: i64,
908 state: &mut IssueRun,
909 cfg: &Config,
910) {
911 for finding in findings {
912 let keep = match finding.severity {
913 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking,
914 Severity::Nit => cfg.loop_cfg.file_nits,
915 Severity::Blocking => false,
916 };
917 if !keep || !finding.in_scope {
918 continue;
919 }
920 if let Some(url) = file_followup(repo, &finding.title, &finding.detail, subject, cfg, state)
921 {
922 state.filed.push(url);
923 }
924 }
925}
926
927fn bullets(lines: &[String]) -> String {
937 lines
938 .iter()
939 .map(|l| format!("- {l}"))
940 .collect::<Vec<_>>()
941 .join("\n")
942}
943
944fn located(finding: &Finding, style: &Style) -> String {
945 let title = style::title(&finding.title, style);
946 match finding.where_at() {
947 "general" => title,
948 file => format!("{title} ({file})"),
949 }
950}
951
952pub enum Ending<'a> {
954 Approved,
956 OutOfRounds,
959 Deadlocked(&'a [Finding]),
962}
963
964pub fn post_outcome(
976 repo: &Repo,
977 pr_number: i64,
978 state: &IssueRun,
979 ledger: &Ledger,
980 ending: Ending<'_>,
981) {
982 if repo.style.pr_comments != PrComments::Outcome {
983 return;
984 }
985 let Some(text) = outcome_comment(state, ledger, &ending, &repo.style) else {
986 return;
987 };
988 if let Err(e) = repo.comment_pr(pr_number, &text) {
989 logdim!("could not post the outcome comment: {e}");
990 }
991}
992
993fn refutation_of(finding: &Finding, state: &IssueRun, ledger: &Ledger) -> Option<String> {
996 if let Some(d) = state
997 .disputes
998 .iter()
999 .find(|d| same_point(&d.title, &finding.title))
1000 {
1001 if !d.reasoning.trim().is_empty() {
1002 return Some(d.reasoning.clone());
1003 }
1004 }
1005 ledger
1006 .get(&finding_key(&finding.title, &finding.file))
1007 .map(|entry| entry.reasoning.clone())
1008 .filter(|r| !r.trim().is_empty())
1009}
1010
1011pub fn filed_issue_number(filed: &str) -> Option<i64> {
1016 filed
1017 .rsplit('/')
1018 .next()
1019 .and_then(|tail| tail.parse::<i64>().ok())
1020 .filter(|n| *n > 0)
1021}
1022
1023fn as_reference(url: &str) -> String {
1024 match url.rsplit('/').next().and_then(|n| n.parse::<u64>().ok()) {
1025 Some(number) => format!("#{number}"),
1026 None => url.to_string(),
1027 }
1028}
1029
1030pub fn outcome_comment(
1031 state: &IssueRun,
1032 ledger: &Ledger,
1033 ending: &Ending<'_>,
1034 style: &Style,
1035) -> Option<String> {
1036 let mut out: Vec<String> = Vec::new();
1037 let mut already: Vec<String> = Vec::new();
1040
1041 match ending {
1042 Ending::Approved => {
1043 if state.disputes.is_empty() && state.filed.is_empty() {
1044 return None;
1047 }
1048 out.push("Reviewed, nothing blocking a merge.".into());
1049 }
1050 Ending::OutOfRounds => out.push(
1051 "Not signed off: the last round of fixes was pushed but has not been reviewed.".into(),
1052 ),
1053 Ending::Deadlocked(points) => {
1054 let lines: Vec<String> = points
1060 .iter()
1061 .map(|f| {
1062 let where_at = match f.where_at() {
1063 "general" => String::new(),
1064 file => format!(" ({file})"),
1065 };
1066 let title = style::title(&f.title, style);
1067 already.push(title.clone());
1068 match refutation_of(f, state, ledger) {
1069 Some(reason) => format!(
1070 "{title}{where_at}. Refuted as: {}",
1071 style::summary(&reason, style)
1072 ),
1073 None => format!("{title}{where_at}"),
1074 }
1075 })
1076 .collect();
1077 out.push("Needs your decision. The reviewers could not settle this:".into());
1078 out.push(bullets(&lines));
1079 }
1080 }
1081
1082 let disputes: Vec<&crate::model::Dispute> = state
1083 .disputes
1084 .iter()
1085 .filter(|d| !already.iter().any(|t| same_point(t, &d.title)))
1086 .collect();
1087 if !disputes.is_empty() {
1088 let lines: Vec<String> = disputes
1091 .iter()
1092 .map(|d| {
1093 format!(
1094 "{}. {}",
1095 style::title(&d.title, style),
1096 style::sentence(&d.reasoning, style)
1097 )
1098 })
1099 .collect();
1100 out.push(format!("Raised and refuted:\n{}", bullets(&lines)));
1101 }
1102
1103 if !state.filed.is_empty() {
1104 let refs: Vec<String> = state.filed.iter().map(|u| as_reference(u)).collect();
1105 out.push(format!("Filed separately: {}", refs.join(", ")));
1106 }
1107
1108 Some(out.join("\n\n"))
1109}
1110
1111pub fn pr_body(issue: i64, summary: &str, style: &Style) -> String {
1114 let mut parts = vec![format!("Closes #{issue}")];
1115 let summary = style::summary(summary, style);
1116 if !summary.is_empty() {
1117 parts.push(summary);
1118 }
1119 parts.join("\n\n")
1120}
1121
1122pub fn extract_summary(text: &str) -> Option<String> {
1124 text.lines()
1125 .rev()
1126 .find_map(|line| {
1127 let trimmed = line.trim().trim_start_matches(['*', '#', '-', ' ']);
1128 trimmed
1129 .strip_prefix("SUMMARY:")
1130 .or_else(|| trimmed.strip_prefix("Summary:"))
1131 })
1132 .map(|s| {
1133 s.trim()
1134 .trim_start_matches(['*', '_', ':', ' '])
1135 .trim()
1136 .to_string()
1137 })
1138 .filter(|s| !s.is_empty())
1139}
1140
1141pub fn review_comment(holder: &str, round: u32, review: &Review, style: &Style) -> String {
1145 let by = |severity: Severity| -> Vec<&Finding> {
1146 review
1147 .findings
1148 .iter()
1149 .filter(|f| f.severity == severity && f.in_scope)
1150 .collect()
1151 };
1152 let blocking = by(Severity::Blocking);
1153 let non_blocking = by(Severity::NonBlocking);
1154 let nits = by(Severity::Nit);
1155 let out_of_scope: Vec<&Finding> = review.findings.iter().filter(|f| !f.in_scope).collect();
1156
1157 let mut counts = Vec::new();
1158 if !blocking.is_empty() {
1159 counts.push(format!("{} blocking", blocking.len()));
1160 }
1161 if !non_blocking.is_empty() {
1162 counts.push(format!("{} non-blocking", non_blocking.len()));
1163 }
1164 if !nits.is_empty() {
1165 counts.push(format!("{} nit", nits.len()));
1166 }
1167 if !out_of_scope.is_empty() {
1168 counts.push(format!("{} out of scope", out_of_scope.len()));
1169 }
1170 let headline = if counts.is_empty() {
1171 "no findings".to_string()
1172 } else {
1173 counts.join(", ")
1174 };
1175
1176 let _ = (holder, round, headline);
1177 let mut out = Vec::new();
1178 let summary = style::summary(&review.summary, style);
1179 if !summary.is_empty() {
1180 out.push(summary);
1181 }
1182
1183 if !blocking.is_empty() {
1184 let lines: Vec<String> = blocking
1185 .iter()
1186 .map(|f| {
1187 let detail = style::detail(&f.detail, style);
1188 if detail.is_empty() {
1189 located(f, style)
1190 } else {
1191 format!("{}. {detail}", located(f, style))
1192 }
1193 })
1194 .collect();
1195 out.push(format!("blocking\n{}", bullets(&lines)));
1196 }
1197
1198 for (label, group) in [
1201 ("non-blocking", &non_blocking),
1202 ("nits", &nits),
1203 ("out of scope", &out_of_scope),
1204 ] {
1205 if group.is_empty() {
1206 continue;
1207 }
1208 let lines: Vec<String> = group.iter().map(|f| located(f, style)).collect();
1209 out.push(format!("{label}\n{}", bullets(&lines)));
1210 }
1211
1212 out.join("\n\n")
1213}
1214
1215pub fn disposition_comment(
1219 author: &str,
1220 response: &ResponseDoc,
1221 fixed: &[String],
1222 refuted: &[String],
1223 filed: &[String],
1224 style: &Style,
1225) -> Option<String> {
1226 if fixed.is_empty() && refuted.is_empty() && filed.is_empty() {
1227 return None;
1228 }
1229 let mut counts = Vec::new();
1230 if !fixed.is_empty() {
1231 counts.push(format!("{} fixed", fixed.len()));
1232 }
1233 if !refuted.is_empty() {
1234 counts.push(format!("{} refuted", refuted.len()));
1235 }
1236 if !filed.is_empty() {
1237 counts.push(format!("{} filed", filed.len()));
1238 }
1239
1240 let _ = (author, counts);
1241 let mut out = Vec::new();
1242 let summary = style::summary(&response.summary, style);
1243 if !summary.is_empty() {
1244 out.push(summary);
1245 }
1246 if !refuted.is_empty() {
1247 out.push(format!("refuted\n{}", bullets(refuted)));
1248 }
1249 if !fixed.is_empty() {
1250 out.push(format!("fixed\n{}", bullets(fixed)));
1251 }
1252 if !filed.is_empty() {
1253 out.push(format!("filed\n{}", bullets(filed)));
1254 }
1255 Some(out.join("\n\n"))
1256}
1257
1258pub fn skip_comment(item: &SkippedItem, style: &Style) -> String {
1266 let reasons = item
1267 .reasons
1268 .values()
1269 .map(|reason| style::sentence(reason, style));
1270 let lines = crate::textsim::dedupe_by(reasons, crate::textsim::same_reason);
1274 bullets(&lines)
1275}
1276
1277pub(crate) fn findings_for_prompt(findings: &[Finding]) -> String {
1280 if findings.is_empty() {
1281 return "(none)".to_string();
1282 }
1283 findings
1284 .iter()
1285 .map(|f| {
1286 let scope = if f.in_scope { "" } else { " [out of scope]" };
1287 format!(
1288 "- [{}]{scope} {} ({})\n {}",
1289 f.severity,
1290 f.title,
1291 f.where_at(),
1292 f.detail
1293 )
1294 })
1295 .collect::<Vec<_>>()
1296 .join("\n")
1297}
1298
1299#[cfg(test)]
1300mod tests {
1301 use super::*;
1302 use crate::model::Verdict;
1303
1304 fn style() -> Style {
1305 Style::default()
1306 }
1307
1308 fn finding(severity: &str, title: &str, detail: &str, file: &str, in_scope: bool) -> Finding {
1309 Finding {
1310 severity: Severity::parse_lenient(severity).unwrap(),
1311 title: title.into(),
1312 detail: detail.into(),
1313 file: file.into(),
1314 in_scope,
1315 ..Default::default()
1316 }
1317 }
1318
1319 fn review(summary: &str, findings: Vec<Finding>) -> Review {
1320 Review {
1321 verdict: Verdict::Approve,
1322 next_action: NextAction::Merge,
1323 summary: summary.into(),
1324 findings,
1325 }
1326 }
1327
1328 fn cfg_with(worktrees: bool, keep: bool) -> Config {
1331 let text = "[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n";
1332 let mut cfg = crate::config::parse(text).unwrap();
1333 cfg.loop_cfg.worktrees = worktrees;
1334 cfg.loop_cfg.keep_worktrees = keep;
1335 cfg
1336 }
1337
1338 #[test]
1339 fn a_worktree_is_released_on_every_finished_outcome() {
1340 let cfg = cfg_with(true, false);
1341 for status in [Status::Approved, Status::Merged, Status::Abandoned] {
1342 assert!(should_release(&cfg, status), "{status}");
1343 }
1344 }
1345
1346 #[test]
1349 fn a_worktree_is_kept_only_where_a_human_has_to_look() {
1350 let cfg = cfg_with(true, false);
1351 assert!(!should_release(&cfg, Status::Escalated));
1352 assert!(!should_release(&cfg, Status::Error));
1353 }
1354
1355 #[test]
1356 fn the_keep_flag_overrides_everything() {
1357 assert!(!should_release(&cfg_with(true, true), Status::Approved));
1358 }
1359
1360 #[test]
1361 fn nothing_is_released_when_worktrees_are_off() {
1362 assert!(!should_release(&cfg_with(false, false), Status::Approved));
1363 }
1364
1365 #[test]
1369 fn a_fresh_run_starts_at_one() {
1370 assert_eq!((1, 3), round_window(1, 3));
1371 assert_eq!((1, 5), round_window(1, 5));
1372 }
1373
1374 #[test]
1378 fn a_resumed_run_gets_a_full_fresh_budget() {
1379 assert_eq!((6, 10), round_window(6, 5));
1380 assert_eq!((11, 13), round_window(11, 3));
1381 }
1382
1383 #[test]
1384 fn a_budget_of_one_is_a_single_round() {
1385 assert_eq!((6, 6), round_window(6, 1));
1386 }
1387
1388 #[test]
1389 fn round_numbers_keep_counting_across_sessions() {
1390 let mut start = 1;
1392 let mut seen = Vec::new();
1393 for _ in 0..3 {
1394 let (first, last) = round_window(start, 3);
1395 seen.push((first, last));
1396 start = last + 1;
1397 }
1398 assert_eq!(vec![(1, 3), (4, 6), (7, 9)], seen);
1399 }
1400
1401 fn ledger_with(title: &str, file: &str) -> Ledger {
1404 let mut ledger = Ledger::new();
1405 ledger.insert(
1406 finding_key(title, file),
1407 LedgerEntry {
1408 title: title.into(),
1409 file: file.into(),
1410 reasoning: "no".into(),
1411 round: 1,
1412 reraised: 0,
1413 },
1414 );
1415 ledger
1416 }
1417
1418 #[test]
1419 fn a_point_refuted_and_re_raised_twice_escalates() {
1420 let mut ledger = ledger_with("nit about naming", "a.rs");
1421 let mut state = IssueRun::new(1, "t");
1422 let blocking = vec![finding("blocking", "nit about naming", "d", "a.rs", true)];
1423 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
1424 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
1425 }
1426
1427 #[test]
1428 fn an_untracked_finding_does_not_escalate() {
1429 let mut state = IssueRun::new(1, "t");
1430 let blocking = vec![finding("blocking", "brand new", "d", "a.rs", true)];
1431 assert!(!check_relitigation(
1432 &mut Ledger::new(),
1433 &blocking,
1434 &mut state
1435 ));
1436 }
1437
1438 #[test]
1442 fn a_refutation_lands_on_the_key_the_next_round_will_look_up() {
1443 let blocking = vec![finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
1444 let recorded = finding_key(&blocking[0].title, &blocking[0].file);
1445
1446 let matched = matching_finding(&blocking, "unbounded loop!").expect("should match");
1447 assert_eq!(recorded, finding_key("unbounded loop!", &matched.file));
1448 }
1449
1450 #[test]
1455 fn the_ledger_key_uses_the_reviewers_wording_not_the_authors() {
1456 let findings = vec![finding(
1457 "blocking",
1458 "Panic on multi-byte input",
1459 "d",
1460 "src/style.rs",
1461 true,
1462 )];
1463 let reworded = "Panic on multibyte input";
1464
1465 let source = matching_finding(&findings, reworded).expect("still matches");
1466 assert_ne!(
1467 finding_key(reworded, &source.file),
1468 finding_key(&source.title, &source.file),
1469 "the two spellings must genuinely hash apart, or this test proves nothing"
1470 );
1471
1472 let recorded = finding_key(&source.title, &source.file);
1474 let looked_up = finding_key(&findings[0].title, &findings[0].file);
1475 assert_eq!(recorded, looked_up);
1476 }
1477
1478 #[test]
1479 fn a_disposition_matches_its_finding_despite_wording_noise() {
1480 let findings = vec![finding(
1481 "blocking",
1482 "Unbounded loop!",
1483 "d",
1484 "src/x.rs",
1485 true,
1486 )];
1487 assert!(matching_finding(&findings, "unbounded loop").is_some());
1488 assert!(matching_finding(&findings, "something else").is_none());
1489 }
1490
1491 #[test]
1492 fn the_settled_block_is_empty_when_nothing_is_settled() {
1493 assert_eq!("", settled_block(&Ledger::new()));
1494 }
1495
1496 #[test]
1497 fn the_settled_block_names_each_refutation() {
1498 let block = settled_block(&ledger_with("a point", "x.rs"));
1499 assert!(block.contains("a point"));
1500 assert!(block.contains("settled"));
1501 }
1502
1503 #[test]
1506 fn a_clean_review_is_just_the_verdict() {
1509 let text = review_comment("codex", 1, &review("Looks correct.", vec![]), &style());
1510 assert_eq!("Looks correct.", text);
1511 }
1512
1513 #[test]
1514 fn a_review_leads_with_the_counts() {
1515 let text = review_comment(
1516 "codex",
1517 2,
1518 &review(
1519 "One real problem.",
1520 vec![
1521 finding(
1522 "blocking",
1523 "Loop never terminates",
1524 "Confirmed by running it.",
1525 "src/a.rs",
1526 true,
1527 ),
1528 finding("non-blocking", "Name is vague", "d", "src/b.rs", true),
1529 finding("nit", "Log wording", "d", "", true),
1530 ],
1531 ),
1532 &style(),
1533 );
1534 assert!(text.starts_with("One real problem."), "{text}");
1535 assert!(!text.contains("codex"), "no agent name: {text}");
1536 assert!(!text.contains("round 2"), "no round number: {text}");
1537 }
1538
1539 #[test]
1542 fn only_blocking_findings_carry_their_detail() {
1543 let text = review_comment(
1544 "codex",
1545 1,
1546 &review(
1547 "s",
1548 vec![
1549 finding("blocking", "Loop", "BLOCKING DETAIL", "a.rs", true),
1550 finding("non-blocking", "Name", "NONBLOCKING DETAIL", "b.rs", true),
1551 ],
1552 ),
1553 &style(),
1554 );
1555 assert!(text.contains("BLOCKING DETAIL"), "{text}");
1556 assert!(!text.contains("NONBLOCKING DETAIL"), "{text}");
1557 }
1558
1559 #[test]
1560 fn a_thorough_explanation_reaches_the_author_intact() {
1563 let detail = "Reproduced by running the 429 test with max_attempts unset. ".repeat(8);
1564 let text = review_comment(
1565 "codex",
1566 1,
1567 &review(
1568 "One problem.",
1569 vec![finding("blocking", "T", &detail, "a.rs", true)],
1570 ),
1571 &style(),
1572 );
1573 assert!(
1574 text.contains(detail.trim()),
1575 "the explanation was cut:\n{text}"
1576 );
1577 }
1578
1579 #[test]
1581 fn a_runaway_model_is_still_bounded() {
1582 let long = "filler words. ".repeat(20_000);
1583 let text = review_comment(
1584 "codex",
1585 1,
1586 &review(&long, vec![finding("blocking", "T", &long, "a.rs", true)]),
1587 &style(),
1588 );
1589 assert!(
1590 text.len() < 30_000,
1591 "review comment was {} chars",
1592 text.len()
1593 );
1594 }
1595
1596 #[test]
1597 fn a_general_finding_has_no_empty_parenthesis() {
1598 let text = review_comment(
1599 "codex",
1600 1,
1601 &review("s", vec![finding("blocking", "Something", "d", "", true)]),
1602 &style(),
1603 );
1604 assert!(!text.contains("()"), "{text}");
1605 assert!(!text.contains("(general)"), "{text}");
1606 }
1607
1608 #[test]
1609 fn out_of_scope_findings_are_counted_separately() {
1610 let text = review_comment(
1611 "codex",
1612 1,
1613 &review(
1614 "s",
1615 vec![finding("blocking", "Old bug", "d", "a.rs", false)],
1616 ),
1617 &style(),
1618 );
1619 assert!(text.contains("out of scope"), "{text}");
1620 assert!(text.contains("Old bug"), "{text}");
1621 }
1622
1623 #[test]
1624 fn a_disposition_comment_leads_with_counts_and_keeps_refutations() {
1625 let response = ResponseDoc {
1626 summary: "Two of three were right.".into(),
1627 dispositions: vec![],
1628 };
1629 let text = disposition_comment(
1630 "claude",
1631 &response,
1632 &["Fixed thing".to_string()],
1633 &["Wrong thing. Because the caller already checks.".to_string()],
1634 &[],
1635 &style(),
1636 )
1637 .unwrap();
1638 assert!(text.starts_with("Two of three were right."), "{text}");
1639 assert!(!text.contains("claude"), "no agent name: {text}");
1640 assert!(
1641 text.contains("Because the caller already checks."),
1642 "{text}"
1643 );
1644 }
1645
1646 #[test]
1647 fn an_empty_disposition_comment_is_not_posted() {
1648 let response = ResponseDoc {
1649 summary: "s".into(),
1650 dispositions: vec![],
1651 };
1652 assert!(disposition_comment("claude", &response, &[], &[], &[], &style()).is_none());
1653 }
1654
1655 #[test]
1656 fn a_pr_body_is_what_it_closes_and_what_changed() {
1659 let body = pr_body(42, "Retry on a 429 instead of failing.", &style());
1660 assert_eq!("Closes #42\n\nRetry on a 429 instead of failing.", body);
1661 }
1662
1663 #[test]
1664 fn a_pr_body_survives_a_missing_summary_and_diffstat() {
1665 assert_eq!("Closes #7", pr_body(7, "", &style()));
1666 }
1667
1668 #[test]
1669 fn the_summary_line_is_lifted_out_of_the_final_message() {
1670 let out = "I did some work.\n\nSUMMARY: Retry on a 429 instead of failing.\n";
1671 assert_eq!(
1672 Some("Retry on a 429 instead of failing.".to_string()),
1673 extract_summary(out)
1674 );
1675 }
1676
1677 #[test]
1678 fn a_decorated_summary_line_still_parses() {
1679 assert_eq!(
1680 Some("Did a thing.".to_string()),
1681 extract_summary("**SUMMARY:** Did a thing.")
1682 );
1683 }
1684
1685 #[test]
1686 fn a_missing_summary_line_is_none() {
1687 assert_eq!(None, extract_summary("no marker here"));
1688 }
1689
1690 #[test]
1691 fn the_last_summary_line_wins() {
1692 let out = "SUMMARY: first draft\nmore work\nSUMMARY: final answer";
1693 assert_eq!(Some("final answer".to_string()), extract_summary(out));
1694 }
1695
1696 #[test]
1697 fn a_skip_comment_is_only_the_reasoning() {
1698 let item = SkippedItem {
1699 issue: 3,
1700 title: "t".into(),
1701 reasons: [
1702 ("claude".to_string(), "Already fixed in 1.2.".to_string()),
1703 ("codex".to_string(), "Duplicate of #2.".to_string()),
1704 ]
1705 .into_iter()
1706 .collect(),
1707 };
1708 let text = skip_comment(&item, &style());
1709 assert!(text.contains("Already fixed in 1.2."), "{text}");
1710 assert!(text.contains("Duplicate of #2."), "{text}");
1711 assert!(
1712 !text.contains("claude") && !text.contains("codex"),
1713 "{text}"
1714 );
1715 assert!(!text.to_lowercase().contains("not scheduled"), "{text}");
1716 assert!(text.lines().count() <= 3, "{text}");
1717 }
1718
1719 #[test]
1720 fn findings_for_a_model_keep_full_detail() {
1721 let long = "x".repeat(2000);
1722 let text = findings_for_prompt(&[finding("blocking", "T", &long, "a.rs", true)]);
1723 assert!(
1724 text.contains(&long),
1725 "a model needs the whole finding, only humans need brevity"
1726 );
1727 }
1728
1729 #[test]
1730 fn findings_for_a_model_are_never_empty() {
1731 assert_eq!("(none)", findings_for_prompt(&[]));
1732 }
1733}
1734
1735#[cfg(test)]
1736mod outcome_tests {
1737 use super::*;
1738 use crate::model::{Dispute, Severity};
1739
1740 fn style() -> Style {
1741 Style::default()
1742 }
1743
1744 fn state_with(disputes: Vec<(&str, &str)>, filed: Vec<&str>) -> IssueRun {
1745 let mut s = IssueRun::new(482, "t");
1746 s.disputes = disputes
1747 .into_iter()
1748 .map(|(title, reasoning)| Dispute {
1749 title: title.into(),
1750 reasoning: reasoning.into(),
1751 })
1752 .collect();
1753 s.filed = filed.into_iter().map(String::from).collect();
1754 s
1755 }
1756
1757 fn finding(title: &str, file: &str) -> Finding {
1758 Finding {
1759 severity: Severity::Blocking,
1760 title: title.into(),
1761 detail: "d".into(),
1762 file: file.into(),
1763 in_scope: true,
1764 ..Default::default()
1765 }
1766 }
1767
1768 #[test]
1771 fn a_clean_approval_says_nothing() {
1772 let state = state_with(vec![], vec![]);
1773 assert!(outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).is_none());
1774 }
1775
1776 #[test]
1777 fn an_approval_that_filed_follow_ups_links_them() {
1778 let state = state_with(
1779 vec![],
1780 vec![
1781 "https://github.com/you/thing/issues/485",
1782 "https://github.com/you/thing/issues/486",
1783 ],
1784 );
1785 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
1786 assert!(text.contains("Filed separately: #485, #486"), "{text}");
1787 }
1788
1789 #[test]
1793 fn running_out_of_rounds_says_what_that_means_for_the_reader() {
1794 let state = state_with(vec![], vec![]);
1795 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
1796 assert!(text.contains("has not been reviewed"), "{text}");
1797 assert!(
1798 !text.to_lowercase().contains("round 3"),
1799 "no round numbers: {text}"
1800 );
1801 assert!(!text.to_lowercase().contains("convergence"), "{text}");
1802 }
1803
1804 #[test]
1805 fn a_deadlock_names_the_point_they_could_not_settle() {
1806 let state = state_with(vec![], vec![]);
1807 let points = [finding("Retry loop never terminates", "src/net.rs:88")];
1808 let text = outcome_comment(
1809 &state,
1810 &Ledger::new(),
1811 &Ending::Deadlocked(&points),
1812 &style(),
1813 )
1814 .unwrap();
1815 assert!(
1816 text.contains("Retry loop never terminates (src/net.rs:88)"),
1817 "{text}"
1818 );
1819 assert!(text.contains("could not settle"), "{text}");
1820 }
1821
1822 #[test]
1824 fn refutations_survive_because_nothing_else_carries_them() {
1825 let state = state_with(
1826 vec![(
1827 "Error is swallowed",
1828 "the caller already validates the file",
1829 )],
1830 vec![],
1831 );
1832 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
1833 assert!(text.contains("Raised and refuted:"), "{text}");
1834 assert!(
1835 text.contains("The caller already validates the file"),
1836 "{text}"
1837 );
1838 }
1839
1840 #[test]
1841 fn no_agent_names_counts_or_round_numbers_reach_the_thread() {
1842 let state = state_with(
1843 vec![("A point", "a reason")],
1844 vec!["https://github.com/you/thing/issues/485"],
1845 );
1846 for ending in [Ending::Approved, Ending::OutOfRounds] {
1847 let text = outcome_comment(&state, &Ledger::new(), &ending, &style()).unwrap();
1848 let lower = text.to_lowercase();
1849 for banned in ["claude", "codex", "blocking,", "nit,", " fixed."] {
1850 assert!(
1851 !lower.contains(banned),
1852 "{banned:?} leaked into the thread:\n{text}"
1853 );
1854 }
1855 for n in 1..9 {
1857 assert!(
1858 !lower.contains(&format!("round {n}")),
1859 "a round number leaked into the thread:\n{text}"
1860 );
1861 }
1862 }
1863 }
1864
1865 #[test]
1866 fn a_refutation_is_allowed_to_make_its_case() {
1869 let reasoning = "The caller validates against the schema first. \
1870 The discarded error is therefore unreachable in practice. ";
1871 let state = state_with(
1872 vec![("A point", &reasoning.repeat(6))],
1873 vec!["https://github.com/you/thing/issues/485"],
1874 );
1875 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
1876 assert!(
1877 !text.contains("..."),
1878 "nothing was cut mid thought:\n{text}"
1879 );
1880 assert!(text.len() < 4000, "{} chars", text.len());
1881 }
1882
1883 #[test]
1884 fn a_url_that_is_not_an_issue_link_is_left_alone() {
1885 assert_eq!(
1886 "#485",
1887 as_reference("https://github.com/you/thing/issues/485")
1888 );
1889 assert_eq!("note: something", as_reference("note: something"));
1890 }
1891}
1892
1893#[cfg(test)]
1894mod filed_reference_tests {
1895 use super::*;
1896
1897 #[test]
1898 fn an_issue_url_yields_its_number() {
1899 assert_eq!(
1900 Some(485),
1901 filed_issue_number("https://github.com/you/thing/issues/485")
1902 );
1903 }
1904
1905 #[test]
1908 fn a_local_note_yields_nothing() {
1909 assert_eq!(None, filed_issue_number("note: Retry is unbounded"));
1910 assert_eq!(None, filed_issue_number(""));
1911 assert_eq!(
1912 None,
1913 filed_issue_number("https://github.com/you/thing/issues/")
1914 );
1915 }
1916}
1917
1918#[cfg(test)]
1919mod followup_restraint_tests {
1920 use super::*;
1921 use crate::model::Severity;
1922
1923 fn cfg_with(followups: Followups, non_blocking: bool, nits: bool, cap: usize) -> Config {
1924 let mut cfg =
1925 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
1926 .unwrap();
1927 cfg.loop_cfg.followups = followups;
1928 cfg.loop_cfg.file_non_blocking = non_blocking;
1929 cfg.loop_cfg.file_nits = nits;
1930 cfg.loop_cfg.max_followups = cap;
1931 cfg
1932 }
1933
1934 fn finding(severity: Severity, title: &str, in_scope: bool) -> Finding {
1935 Finding {
1936 severity,
1937 title: title.into(),
1938 detail: "d".into(),
1939 file: "a.rs".into(),
1940 in_scope,
1941 ..Default::default()
1942 }
1943 }
1944
1945 #[test]
1949 fn a_non_blocking_finding_is_not_a_tracker_item_by_default() {
1950 let cfg = cfg_with(Followups::Issues, false, false, 5);
1951 assert!(!cfg.loop_cfg.file_non_blocking);
1952 assert!(!cfg.loop_cfg.file_nits);
1953 }
1954
1955 #[test]
1956 fn follow_ups_stay_off_the_tracker_by_default() {
1957 let cfg =
1958 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
1959 .unwrap();
1960 assert_eq!(
1961 Followups::Local,
1962 cfg.loop_cfg.followups,
1963 "the tracker is somebody's queue; the default must not write to it"
1964 );
1965 assert_eq!(5, cfg.loop_cfg.max_followups);
1966 }
1967
1968 #[test]
1970 fn only_out_of_scope_defects_qualify_at_the_defaults() {
1971 let cfg = cfg_with(Followups::Issues, false, false, 5);
1972 let qualifies = |f: &Finding| match f.severity {
1973 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking && f.in_scope,
1974 Severity::Nit => cfg.loop_cfg.file_nits && f.in_scope,
1975 Severity::Blocking => false,
1976 } || !f.in_scope;
1977
1978 assert!(qualifies(&finding(
1979 Severity::Blocking,
1980 "pre-existing",
1981 false
1982 )));
1983 assert!(!qualifies(&finding(
1984 Severity::NonBlocking,
1985 "improvement",
1986 true
1987 )));
1988 assert!(!qualifies(&finding(Severity::Nit, "taste", true)));
1989 assert!(!qualifies(&finding(
1990 Severity::Blocking,
1991 "fix it here",
1992 true
1993 )));
1994 }
1995
1996 #[test]
1997 fn opening_it_up_lets_non_blocking_findings_through_again() {
1998 let cfg = cfg_with(Followups::Issues, true, false, 5);
1999 assert!(cfg.loop_cfg.file_non_blocking);
2000 }
2001
2002 #[test]
2004 fn the_cap_is_a_real_backstop() {
2005 let cfg = cfg_with(Followups::Issues, false, false, 3);
2006 let mut state = IssueRun::new(1, "t");
2007 state.filed = (0..3).map(|n| format!("url{n}")).collect();
2008 assert!(state.filed.len() >= cfg.loop_cfg.max_followups);
2009 }
2010
2011 #[test]
2015 fn the_cap_bounds_what_one_run_can_spawn() {
2016 let cfg = cfg_with(Followups::Issues, false, false, 5);
2017 assert!(
2018 cfg.loop_cfg.max_followups <= 5,
2019 "a run that can file ten follow-ups is a branching process"
2020 );
2021 }
2022}
2023
2024#[cfg(test)]
2025mod issue_report_tests {
2026 use super::*;
2027 use crate::model::Severity;
2028
2029 fn reported() -> Finding {
2032 Finding {
2033 severity: Severity::Blocking,
2034 title: "sendPaymentAsync bypasses drain mode and spending limits".into(),
2035 detail: "The async path skips every admission check payInvoice applies.".into(),
2036 file: "src/node.ts:412".into(),
2037 in_scope: false,
2038 problem: Some(
2039 "`BeignetNode.sendPaymentAsync()` submits a payment directly to the Lightning \
2040 engine without applying the safeguards used by `payInvoice()`.\n\nThe async path \
2041 does not:\n\n- call `_checkDraining()`\n- call `_checkSpendLimit()`"
2042 .into(),
2043 ),
2044 reproduction: Some(
2045 "1. Create a `BeignetNode` with `dailySpendLimitSats: 1`.\n2. Enable drain mode.\n\
2046 3. Submit a 1,000 sat invoice.\n\nActual result:\n\n- The engine is called.\n\
2047 - `spentSats` remains 0."
2048 .into(),
2049 ),
2050 impact: Some(
2051 "An authorized client can submit async payments up to the available outbound \
2052 liquidity despite the configured limits."
2053 .into(),
2054 ),
2055 expected: Some(
2056 "- Reject new payments while draining.\n- Enforce the per-payment limit before \
2057 submission.\n- Cover both paths with regression tests.\n\nThis predates the \
2058 current branch."
2059 .into(),
2060 ),
2061 }
2062 }
2063
2064 #[test]
2065 fn a_reported_finding_becomes_a_bug_report() {
2066 let body = issue_report(&reported());
2067 for heading in [
2068 "## Problem",
2069 "## Reproduction",
2070 "## Impact",
2071 "## Expected behavior",
2072 ] {
2073 assert!(body.contains(heading), "missing {heading}:\n{body}");
2074 }
2075 let at = |h: &str| body.find(h).unwrap();
2077 assert!(at("## Problem") < at("## Reproduction"));
2078 assert!(at("## Reproduction") < at("## Impact"));
2079 assert!(at("## Impact") < at("## Expected behavior"));
2080 }
2081
2082 #[test]
2083 fn the_substance_survives_the_outbound_gates() {
2084 let repo_style = Style::default();
2085 let body = crate::style::issue_body(&issue_report(&reported()), &repo_style);
2086 for kept in [
2087 "_checkDraining()",
2088 "Actual result:",
2089 "outbound liquidity",
2090 "regression tests",
2091 "predates the current branch",
2092 ] {
2093 assert!(body.contains(kept), "the gate ate {kept:?}:\n{body}");
2094 }
2095 assert!(!body.contains("..."), "something was cut:\n{body}");
2096 }
2097
2098 #[test]
2101 fn an_ordinary_finding_is_still_just_its_detail() {
2102 let plain = Finding {
2103 severity: Severity::NonBlocking,
2104 title: "Name is vague".into(),
2105 detail: "The variable could say what it holds.".into(),
2106 file: "a.rs".into(),
2107 in_scope: true,
2108 ..Default::default()
2109 };
2110 assert_eq!(
2111 "The variable could say what it holds.",
2112 issue_report(&plain)
2113 );
2114 }
2115
2116 #[test]
2119 fn only_the_sections_that_were_written_appear() {
2120 let partial = Finding {
2121 problem: Some("The guard is inverted.".into()),
2122 expected: Some("It should reject rather than accept.".into()),
2123 ..reported()
2124 };
2125 let partial = Finding {
2126 reproduction: None,
2127 impact: None,
2128 ..partial
2129 };
2130 let body = issue_report(&partial);
2131 assert!(body.contains("## Problem") && body.contains("## Expected behavior"));
2132 assert!(!body.contains("## Reproduction"), "{body}");
2133 assert!(!body.contains("## Impact"), "{body}");
2134 }
2135
2136 #[test]
2139 fn the_summary_line_is_not_printed_twice() {
2140 let echoed = Finding {
2141 detail: "The guard is inverted so it rejects valid input.".into(),
2142 problem: Some("The guard is inverted so it rejects valid input.".into()),
2143 reproduction: None,
2144 impact: None,
2145 expected: None,
2146 ..reported()
2147 };
2148 let body = issue_report(&echoed);
2149 assert_eq!(1, body.matches("The guard is inverted").count(), "{body}");
2150 }
2151}