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