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 if let Some(url) = file_followup(repo, &finding.title, &finding.detail, subject, cfg, state)
865 {
866 state.filed.push(url);
867 }
868 }
869}
870
871fn file_nonblocking(
878 repo: &Repo,
879 findings: &[Finding],
880 subject: i64,
881 state: &mut IssueRun,
882 cfg: &Config,
883) {
884 for finding in findings {
885 let keep = match finding.severity {
886 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking,
887 Severity::Nit => cfg.loop_cfg.file_nits,
888 Severity::Blocking => false,
889 };
890 if !keep || !finding.in_scope {
891 continue;
892 }
893 if let Some(url) = file_followup(repo, &finding.title, &finding.detail, subject, cfg, state)
894 {
895 state.filed.push(url);
896 }
897 }
898}
899
900fn bullets(lines: &[String]) -> String {
910 lines
911 .iter()
912 .map(|l| format!("- {l}"))
913 .collect::<Vec<_>>()
914 .join("\n")
915}
916
917fn located(finding: &Finding, style: &Style) -> String {
918 let title = style::title(&finding.title, style);
919 match finding.where_at() {
920 "general" => title,
921 file => format!("{title} ({file})"),
922 }
923}
924
925pub enum Ending<'a> {
927 Approved,
929 OutOfRounds,
932 Deadlocked(&'a [Finding]),
935}
936
937pub fn post_outcome(
949 repo: &Repo,
950 pr_number: i64,
951 state: &IssueRun,
952 ledger: &Ledger,
953 ending: Ending<'_>,
954) {
955 if repo.style.pr_comments != PrComments::Outcome {
956 return;
957 }
958 let Some(text) = outcome_comment(state, ledger, &ending, &repo.style) else {
959 return;
960 };
961 if let Err(e) = repo.comment_pr(pr_number, &text) {
962 logdim!("could not post the outcome comment: {e}");
963 }
964}
965
966fn refutation_of(finding: &Finding, state: &IssueRun, ledger: &Ledger) -> Option<String> {
969 if let Some(d) = state
970 .disputes
971 .iter()
972 .find(|d| same_point(&d.title, &finding.title))
973 {
974 if !d.reasoning.trim().is_empty() {
975 return Some(d.reasoning.clone());
976 }
977 }
978 ledger
979 .get(&finding_key(&finding.title, &finding.file))
980 .map(|entry| entry.reasoning.clone())
981 .filter(|r| !r.trim().is_empty())
982}
983
984pub fn filed_issue_number(filed: &str) -> Option<i64> {
989 filed
990 .rsplit('/')
991 .next()
992 .and_then(|tail| tail.parse::<i64>().ok())
993 .filter(|n| *n > 0)
994}
995
996fn as_reference(url: &str) -> String {
997 match url.rsplit('/').next().and_then(|n| n.parse::<u64>().ok()) {
998 Some(number) => format!("#{number}"),
999 None => url.to_string(),
1000 }
1001}
1002
1003pub fn outcome_comment(
1004 state: &IssueRun,
1005 ledger: &Ledger,
1006 ending: &Ending<'_>,
1007 style: &Style,
1008) -> Option<String> {
1009 let mut out: Vec<String> = Vec::new();
1010 let mut already: Vec<String> = Vec::new();
1013
1014 match ending {
1015 Ending::Approved => {
1016 if state.disputes.is_empty() && state.filed.is_empty() {
1017 return None;
1020 }
1021 out.push("Reviewed, nothing blocking a merge.".into());
1022 }
1023 Ending::OutOfRounds => out.push(
1024 "Not signed off: the last round of fixes was pushed but has not been reviewed.".into(),
1025 ),
1026 Ending::Deadlocked(points) => {
1027 let lines: Vec<String> = points
1033 .iter()
1034 .map(|f| {
1035 let where_at = match f.where_at() {
1036 "general" => String::new(),
1037 file => format!(" ({file})"),
1038 };
1039 let title = style::title(&f.title, style);
1040 already.push(title.clone());
1041 match refutation_of(f, state, ledger) {
1042 Some(reason) => format!(
1043 "{title}{where_at}. Refuted as: {}",
1044 style::summary(&reason, style)
1045 ),
1046 None => format!("{title}{where_at}"),
1047 }
1048 })
1049 .collect();
1050 out.push("Needs your decision. The reviewers could not settle this:".into());
1051 out.push(bullets(&lines));
1052 }
1053 }
1054
1055 let disputes: Vec<&crate::model::Dispute> = state
1056 .disputes
1057 .iter()
1058 .filter(|d| !already.iter().any(|t| same_point(t, &d.title)))
1059 .collect();
1060 if !disputes.is_empty() {
1061 let lines: Vec<String> = disputes
1064 .iter()
1065 .map(|d| {
1066 format!(
1067 "{}. {}",
1068 style::title(&d.title, style),
1069 style::sentence(&d.reasoning, style)
1070 )
1071 })
1072 .collect();
1073 out.push(format!("Raised and refuted:\n{}", bullets(&lines)));
1074 }
1075
1076 if !state.filed.is_empty() {
1077 let refs: Vec<String> = state.filed.iter().map(|u| as_reference(u)).collect();
1078 out.push(format!("Filed separately: {}", refs.join(", ")));
1079 }
1080
1081 Some(out.join("\n\n"))
1082}
1083
1084pub fn pr_body(issue: i64, summary: &str, style: &Style) -> String {
1087 let mut parts = vec![format!("Closes #{issue}")];
1088 let summary = style::summary(summary, style);
1089 if !summary.is_empty() {
1090 parts.push(summary);
1091 }
1092 parts.join("\n\n")
1093}
1094
1095pub fn extract_summary(text: &str) -> Option<String> {
1097 text.lines()
1098 .rev()
1099 .find_map(|line| {
1100 let trimmed = line.trim().trim_start_matches(['*', '#', '-', ' ']);
1101 trimmed
1102 .strip_prefix("SUMMARY:")
1103 .or_else(|| trimmed.strip_prefix("Summary:"))
1104 })
1105 .map(|s| {
1106 s.trim()
1107 .trim_start_matches(['*', '_', ':', ' '])
1108 .trim()
1109 .to_string()
1110 })
1111 .filter(|s| !s.is_empty())
1112}
1113
1114pub fn review_comment(holder: &str, round: u32, review: &Review, style: &Style) -> String {
1118 let by = |severity: Severity| -> Vec<&Finding> {
1119 review
1120 .findings
1121 .iter()
1122 .filter(|f| f.severity == severity && f.in_scope)
1123 .collect()
1124 };
1125 let blocking = by(Severity::Blocking);
1126 let non_blocking = by(Severity::NonBlocking);
1127 let nits = by(Severity::Nit);
1128 let out_of_scope: Vec<&Finding> = review.findings.iter().filter(|f| !f.in_scope).collect();
1129
1130 let mut counts = Vec::new();
1131 if !blocking.is_empty() {
1132 counts.push(format!("{} blocking", blocking.len()));
1133 }
1134 if !non_blocking.is_empty() {
1135 counts.push(format!("{} non-blocking", non_blocking.len()));
1136 }
1137 if !nits.is_empty() {
1138 counts.push(format!("{} nit", nits.len()));
1139 }
1140 if !out_of_scope.is_empty() {
1141 counts.push(format!("{} out of scope", out_of_scope.len()));
1142 }
1143 let headline = if counts.is_empty() {
1144 "no findings".to_string()
1145 } else {
1146 counts.join(", ")
1147 };
1148
1149 let _ = (holder, round, headline);
1150 let mut out = Vec::new();
1151 let summary = style::summary(&review.summary, style);
1152 if !summary.is_empty() {
1153 out.push(summary);
1154 }
1155
1156 if !blocking.is_empty() {
1157 let lines: Vec<String> = blocking
1158 .iter()
1159 .map(|f| {
1160 let detail = style::detail(&f.detail, style);
1161 if detail.is_empty() {
1162 located(f, style)
1163 } else {
1164 format!("{}. {detail}", located(f, style))
1165 }
1166 })
1167 .collect();
1168 out.push(format!("blocking\n{}", bullets(&lines)));
1169 }
1170
1171 for (label, group) in [
1174 ("non-blocking", &non_blocking),
1175 ("nits", &nits),
1176 ("out of scope", &out_of_scope),
1177 ] {
1178 if group.is_empty() {
1179 continue;
1180 }
1181 let lines: Vec<String> = group.iter().map(|f| located(f, style)).collect();
1182 out.push(format!("{label}\n{}", bullets(&lines)));
1183 }
1184
1185 out.join("\n\n")
1186}
1187
1188pub fn disposition_comment(
1192 author: &str,
1193 response: &ResponseDoc,
1194 fixed: &[String],
1195 refuted: &[String],
1196 filed: &[String],
1197 style: &Style,
1198) -> Option<String> {
1199 if fixed.is_empty() && refuted.is_empty() && filed.is_empty() {
1200 return None;
1201 }
1202 let mut counts = Vec::new();
1203 if !fixed.is_empty() {
1204 counts.push(format!("{} fixed", fixed.len()));
1205 }
1206 if !refuted.is_empty() {
1207 counts.push(format!("{} refuted", refuted.len()));
1208 }
1209 if !filed.is_empty() {
1210 counts.push(format!("{} filed", filed.len()));
1211 }
1212
1213 let _ = (author, counts);
1214 let mut out = Vec::new();
1215 let summary = style::summary(&response.summary, style);
1216 if !summary.is_empty() {
1217 out.push(summary);
1218 }
1219 if !refuted.is_empty() {
1220 out.push(format!("refuted\n{}", bullets(refuted)));
1221 }
1222 if !fixed.is_empty() {
1223 out.push(format!("fixed\n{}", bullets(fixed)));
1224 }
1225 if !filed.is_empty() {
1226 out.push(format!("filed\n{}", bullets(filed)));
1227 }
1228 Some(out.join("\n\n"))
1229}
1230
1231pub fn skip_comment(item: &SkippedItem, style: &Style) -> String {
1239 let reasons = item
1240 .reasons
1241 .values()
1242 .map(|reason| style::sentence(reason, style));
1243 let lines = crate::textsim::dedupe_by(reasons, crate::textsim::same_reason);
1247 bullets(&lines)
1248}
1249
1250pub(crate) fn findings_for_prompt(findings: &[Finding]) -> String {
1253 if findings.is_empty() {
1254 return "(none)".to_string();
1255 }
1256 findings
1257 .iter()
1258 .map(|f| {
1259 let scope = if f.in_scope { "" } else { " [out of scope]" };
1260 format!(
1261 "- [{}]{scope} {} ({})\n {}",
1262 f.severity,
1263 f.title,
1264 f.where_at(),
1265 f.detail
1266 )
1267 })
1268 .collect::<Vec<_>>()
1269 .join("\n")
1270}
1271
1272#[cfg(test)]
1273mod tests {
1274 use super::*;
1275 use crate::model::Verdict;
1276
1277 fn style() -> Style {
1278 Style::default()
1279 }
1280
1281 fn finding(severity: &str, title: &str, detail: &str, file: &str, in_scope: bool) -> Finding {
1282 Finding {
1283 severity: Severity::parse_lenient(severity).unwrap(),
1284 title: title.into(),
1285 detail: detail.into(),
1286 file: file.into(),
1287 in_scope,
1288 }
1289 }
1290
1291 fn review(summary: &str, findings: Vec<Finding>) -> Review {
1292 Review {
1293 verdict: Verdict::Approve,
1294 next_action: NextAction::Merge,
1295 summary: summary.into(),
1296 findings,
1297 }
1298 }
1299
1300 fn cfg_with(worktrees: bool, keep: bool) -> Config {
1303 let text = "[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n";
1304 let mut cfg = crate::config::parse(text).unwrap();
1305 cfg.loop_cfg.worktrees = worktrees;
1306 cfg.loop_cfg.keep_worktrees = keep;
1307 cfg
1308 }
1309
1310 #[test]
1311 fn a_worktree_is_released_on_every_finished_outcome() {
1312 let cfg = cfg_with(true, false);
1313 for status in [Status::Approved, Status::Merged, Status::Abandoned] {
1314 assert!(should_release(&cfg, status), "{status}");
1315 }
1316 }
1317
1318 #[test]
1321 fn a_worktree_is_kept_only_where_a_human_has_to_look() {
1322 let cfg = cfg_with(true, false);
1323 assert!(!should_release(&cfg, Status::Escalated));
1324 assert!(!should_release(&cfg, Status::Error));
1325 }
1326
1327 #[test]
1328 fn the_keep_flag_overrides_everything() {
1329 assert!(!should_release(&cfg_with(true, true), Status::Approved));
1330 }
1331
1332 #[test]
1333 fn nothing_is_released_when_worktrees_are_off() {
1334 assert!(!should_release(&cfg_with(false, false), Status::Approved));
1335 }
1336
1337 #[test]
1341 fn a_fresh_run_starts_at_one() {
1342 assert_eq!((1, 3), round_window(1, 3));
1343 assert_eq!((1, 5), round_window(1, 5));
1344 }
1345
1346 #[test]
1350 fn a_resumed_run_gets_a_full_fresh_budget() {
1351 assert_eq!((6, 10), round_window(6, 5));
1352 assert_eq!((11, 13), round_window(11, 3));
1353 }
1354
1355 #[test]
1356 fn a_budget_of_one_is_a_single_round() {
1357 assert_eq!((6, 6), round_window(6, 1));
1358 }
1359
1360 #[test]
1361 fn round_numbers_keep_counting_across_sessions() {
1362 let mut start = 1;
1364 let mut seen = Vec::new();
1365 for _ in 0..3 {
1366 let (first, last) = round_window(start, 3);
1367 seen.push((first, last));
1368 start = last + 1;
1369 }
1370 assert_eq!(vec![(1, 3), (4, 6), (7, 9)], seen);
1371 }
1372
1373 fn ledger_with(title: &str, file: &str) -> Ledger {
1376 let mut ledger = Ledger::new();
1377 ledger.insert(
1378 finding_key(title, file),
1379 LedgerEntry {
1380 title: title.into(),
1381 file: file.into(),
1382 reasoning: "no".into(),
1383 round: 1,
1384 reraised: 0,
1385 },
1386 );
1387 ledger
1388 }
1389
1390 #[test]
1391 fn a_point_refuted_and_re_raised_twice_escalates() {
1392 let mut ledger = ledger_with("nit about naming", "a.rs");
1393 let mut state = IssueRun::new(1, "t");
1394 let blocking = vec![finding("blocking", "nit about naming", "d", "a.rs", true)];
1395 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
1396 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
1397 }
1398
1399 #[test]
1400 fn an_untracked_finding_does_not_escalate() {
1401 let mut state = IssueRun::new(1, "t");
1402 let blocking = vec![finding("blocking", "brand new", "d", "a.rs", true)];
1403 assert!(!check_relitigation(
1404 &mut Ledger::new(),
1405 &blocking,
1406 &mut state
1407 ));
1408 }
1409
1410 #[test]
1414 fn a_refutation_lands_on_the_key_the_next_round_will_look_up() {
1415 let blocking = vec![finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
1416 let recorded = finding_key(&blocking[0].title, &blocking[0].file);
1417
1418 let matched = matching_finding(&blocking, "unbounded loop!").expect("should match");
1419 assert_eq!(recorded, finding_key("unbounded loop!", &matched.file));
1420 }
1421
1422 #[test]
1427 fn the_ledger_key_uses_the_reviewers_wording_not_the_authors() {
1428 let findings = vec![finding(
1429 "blocking",
1430 "Panic on multi-byte input",
1431 "d",
1432 "src/style.rs",
1433 true,
1434 )];
1435 let reworded = "Panic on multibyte input";
1436
1437 let source = matching_finding(&findings, reworded).expect("still matches");
1438 assert_ne!(
1439 finding_key(reworded, &source.file),
1440 finding_key(&source.title, &source.file),
1441 "the two spellings must genuinely hash apart, or this test proves nothing"
1442 );
1443
1444 let recorded = finding_key(&source.title, &source.file);
1446 let looked_up = finding_key(&findings[0].title, &findings[0].file);
1447 assert_eq!(recorded, looked_up);
1448 }
1449
1450 #[test]
1451 fn a_disposition_matches_its_finding_despite_wording_noise() {
1452 let findings = vec![finding(
1453 "blocking",
1454 "Unbounded loop!",
1455 "d",
1456 "src/x.rs",
1457 true,
1458 )];
1459 assert!(matching_finding(&findings, "unbounded loop").is_some());
1460 assert!(matching_finding(&findings, "something else").is_none());
1461 }
1462
1463 #[test]
1464 fn the_settled_block_is_empty_when_nothing_is_settled() {
1465 assert_eq!("", settled_block(&Ledger::new()));
1466 }
1467
1468 #[test]
1469 fn the_settled_block_names_each_refutation() {
1470 let block = settled_block(&ledger_with("a point", "x.rs"));
1471 assert!(block.contains("a point"));
1472 assert!(block.contains("settled"));
1473 }
1474
1475 #[test]
1478 fn a_clean_review_is_just_the_verdict() {
1481 let text = review_comment("codex", 1, &review("Looks correct.", vec![]), &style());
1482 assert_eq!("Looks correct.", text);
1483 }
1484
1485 #[test]
1486 fn a_review_leads_with_the_counts() {
1487 let text = review_comment(
1488 "codex",
1489 2,
1490 &review(
1491 "One real problem.",
1492 vec![
1493 finding(
1494 "blocking",
1495 "Loop never terminates",
1496 "Confirmed by running it.",
1497 "src/a.rs",
1498 true,
1499 ),
1500 finding("non-blocking", "Name is vague", "d", "src/b.rs", true),
1501 finding("nit", "Log wording", "d", "", true),
1502 ],
1503 ),
1504 &style(),
1505 );
1506 assert!(text.starts_with("One real problem."), "{text}");
1507 assert!(!text.contains("codex"), "no agent name: {text}");
1508 assert!(!text.contains("round 2"), "no round number: {text}");
1509 }
1510
1511 #[test]
1514 fn only_blocking_findings_carry_their_detail() {
1515 let text = review_comment(
1516 "codex",
1517 1,
1518 &review(
1519 "s",
1520 vec![
1521 finding("blocking", "Loop", "BLOCKING DETAIL", "a.rs", true),
1522 finding("non-blocking", "Name", "NONBLOCKING DETAIL", "b.rs", true),
1523 ],
1524 ),
1525 &style(),
1526 );
1527 assert!(text.contains("BLOCKING DETAIL"), "{text}");
1528 assert!(!text.contains("NONBLOCKING DETAIL"), "{text}");
1529 }
1530
1531 #[test]
1532 fn a_thorough_explanation_reaches_the_author_intact() {
1535 let detail = "Reproduced by running the 429 test with max_attempts unset. ".repeat(8);
1536 let text = review_comment(
1537 "codex",
1538 1,
1539 &review(
1540 "One problem.",
1541 vec![finding("blocking", "T", &detail, "a.rs", true)],
1542 ),
1543 &style(),
1544 );
1545 assert!(
1546 text.contains(detail.trim()),
1547 "the explanation was cut:\n{text}"
1548 );
1549 }
1550
1551 #[test]
1553 fn a_runaway_model_is_still_bounded() {
1554 let long = "filler words. ".repeat(20_000);
1555 let text = review_comment(
1556 "codex",
1557 1,
1558 &review(&long, vec![finding("blocking", "T", &long, "a.rs", true)]),
1559 &style(),
1560 );
1561 assert!(text.len() < 8000, "review comment was {} chars", text.len());
1562 }
1563
1564 #[test]
1565 fn a_general_finding_has_no_empty_parenthesis() {
1566 let text = review_comment(
1567 "codex",
1568 1,
1569 &review("s", vec![finding("blocking", "Something", "d", "", true)]),
1570 &style(),
1571 );
1572 assert!(!text.contains("()"), "{text}");
1573 assert!(!text.contains("(general)"), "{text}");
1574 }
1575
1576 #[test]
1577 fn out_of_scope_findings_are_counted_separately() {
1578 let text = review_comment(
1579 "codex",
1580 1,
1581 &review(
1582 "s",
1583 vec![finding("blocking", "Old bug", "d", "a.rs", false)],
1584 ),
1585 &style(),
1586 );
1587 assert!(text.contains("out of scope"), "{text}");
1588 assert!(text.contains("Old bug"), "{text}");
1589 }
1590
1591 #[test]
1592 fn a_disposition_comment_leads_with_counts_and_keeps_refutations() {
1593 let response = ResponseDoc {
1594 summary: "Two of three were right.".into(),
1595 dispositions: vec![],
1596 };
1597 let text = disposition_comment(
1598 "claude",
1599 &response,
1600 &["Fixed thing".to_string()],
1601 &["Wrong thing. Because the caller already checks.".to_string()],
1602 &[],
1603 &style(),
1604 )
1605 .unwrap();
1606 assert!(text.starts_with("Two of three were right."), "{text}");
1607 assert!(!text.contains("claude"), "no agent name: {text}");
1608 assert!(
1609 text.contains("Because the caller already checks."),
1610 "{text}"
1611 );
1612 }
1613
1614 #[test]
1615 fn an_empty_disposition_comment_is_not_posted() {
1616 let response = ResponseDoc {
1617 summary: "s".into(),
1618 dispositions: vec![],
1619 };
1620 assert!(disposition_comment("claude", &response, &[], &[], &[], &style()).is_none());
1621 }
1622
1623 #[test]
1624 fn a_pr_body_is_what_it_closes_and_what_changed() {
1627 let body = pr_body(42, "Retry on a 429 instead of failing.", &style());
1628 assert_eq!("Closes #42\n\nRetry on a 429 instead of failing.", body);
1629 }
1630
1631 #[test]
1632 fn a_pr_body_survives_a_missing_summary_and_diffstat() {
1633 assert_eq!("Closes #7", pr_body(7, "", &style()));
1634 }
1635
1636 #[test]
1637 fn the_summary_line_is_lifted_out_of_the_final_message() {
1638 let out = "I did some work.\n\nSUMMARY: Retry on a 429 instead of failing.\n";
1639 assert_eq!(
1640 Some("Retry on a 429 instead of failing.".to_string()),
1641 extract_summary(out)
1642 );
1643 }
1644
1645 #[test]
1646 fn a_decorated_summary_line_still_parses() {
1647 assert_eq!(
1648 Some("Did a thing.".to_string()),
1649 extract_summary("**SUMMARY:** Did a thing.")
1650 );
1651 }
1652
1653 #[test]
1654 fn a_missing_summary_line_is_none() {
1655 assert_eq!(None, extract_summary("no marker here"));
1656 }
1657
1658 #[test]
1659 fn the_last_summary_line_wins() {
1660 let out = "SUMMARY: first draft\nmore work\nSUMMARY: final answer";
1661 assert_eq!(Some("final answer".to_string()), extract_summary(out));
1662 }
1663
1664 #[test]
1665 fn a_skip_comment_is_only_the_reasoning() {
1666 let item = SkippedItem {
1667 issue: 3,
1668 title: "t".into(),
1669 reasons: [
1670 ("claude".to_string(), "Already fixed in 1.2.".to_string()),
1671 ("codex".to_string(), "Duplicate of #2.".to_string()),
1672 ]
1673 .into_iter()
1674 .collect(),
1675 };
1676 let text = skip_comment(&item, &style());
1677 assert!(text.contains("Already fixed in 1.2."), "{text}");
1678 assert!(text.contains("Duplicate of #2."), "{text}");
1679 assert!(
1680 !text.contains("claude") && !text.contains("codex"),
1681 "{text}"
1682 );
1683 assert!(!text.to_lowercase().contains("not scheduled"), "{text}");
1684 assert!(text.lines().count() <= 3, "{text}");
1685 }
1686
1687 #[test]
1688 fn findings_for_a_model_keep_full_detail() {
1689 let long = "x".repeat(2000);
1690 let text = findings_for_prompt(&[finding("blocking", "T", &long, "a.rs", true)]);
1691 assert!(
1692 text.contains(&long),
1693 "a model needs the whole finding, only humans need brevity"
1694 );
1695 }
1696
1697 #[test]
1698 fn findings_for_a_model_are_never_empty() {
1699 assert_eq!("(none)", findings_for_prompt(&[]));
1700 }
1701}
1702
1703#[cfg(test)]
1704mod outcome_tests {
1705 use super::*;
1706 use crate::model::{Dispute, Severity};
1707
1708 fn style() -> Style {
1709 Style::default()
1710 }
1711
1712 fn state_with(disputes: Vec<(&str, &str)>, filed: Vec<&str>) -> IssueRun {
1713 let mut s = IssueRun::new(482, "t");
1714 s.disputes = disputes
1715 .into_iter()
1716 .map(|(title, reasoning)| Dispute {
1717 title: title.into(),
1718 reasoning: reasoning.into(),
1719 })
1720 .collect();
1721 s.filed = filed.into_iter().map(String::from).collect();
1722 s
1723 }
1724
1725 fn finding(title: &str, file: &str) -> Finding {
1726 Finding {
1727 severity: Severity::Blocking,
1728 title: title.into(),
1729 detail: "d".into(),
1730 file: file.into(),
1731 in_scope: true,
1732 }
1733 }
1734
1735 #[test]
1738 fn a_clean_approval_says_nothing() {
1739 let state = state_with(vec![], vec![]);
1740 assert!(outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).is_none());
1741 }
1742
1743 #[test]
1744 fn an_approval_that_filed_follow_ups_links_them() {
1745 let state = state_with(
1746 vec![],
1747 vec![
1748 "https://github.com/you/thing/issues/485",
1749 "https://github.com/you/thing/issues/486",
1750 ],
1751 );
1752 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
1753 assert!(text.contains("Filed separately: #485, #486"), "{text}");
1754 }
1755
1756 #[test]
1760 fn running_out_of_rounds_says_what_that_means_for_the_reader() {
1761 let state = state_with(vec![], vec![]);
1762 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
1763 assert!(text.contains("has not been reviewed"), "{text}");
1764 assert!(
1765 !text.to_lowercase().contains("round 3"),
1766 "no round numbers: {text}"
1767 );
1768 assert!(!text.to_lowercase().contains("convergence"), "{text}");
1769 }
1770
1771 #[test]
1772 fn a_deadlock_names_the_point_they_could_not_settle() {
1773 let state = state_with(vec![], vec![]);
1774 let points = [finding("Retry loop never terminates", "src/net.rs:88")];
1775 let text = outcome_comment(
1776 &state,
1777 &Ledger::new(),
1778 &Ending::Deadlocked(&points),
1779 &style(),
1780 )
1781 .unwrap();
1782 assert!(
1783 text.contains("Retry loop never terminates (src/net.rs:88)"),
1784 "{text}"
1785 );
1786 assert!(text.contains("could not settle"), "{text}");
1787 }
1788
1789 #[test]
1791 fn refutations_survive_because_nothing_else_carries_them() {
1792 let state = state_with(
1793 vec![(
1794 "Error is swallowed",
1795 "the caller already validates the file",
1796 )],
1797 vec![],
1798 );
1799 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
1800 assert!(text.contains("Raised and refuted:"), "{text}");
1801 assert!(
1802 text.contains("The caller already validates the file"),
1803 "{text}"
1804 );
1805 }
1806
1807 #[test]
1808 fn no_agent_names_counts_or_round_numbers_reach_the_thread() {
1809 let state = state_with(
1810 vec![("A point", "a reason")],
1811 vec!["https://github.com/you/thing/issues/485"],
1812 );
1813 for ending in [Ending::Approved, Ending::OutOfRounds] {
1814 let text = outcome_comment(&state, &Ledger::new(), &ending, &style()).unwrap();
1815 let lower = text.to_lowercase();
1816 for banned in ["claude", "codex", "blocking,", "nit,", " fixed."] {
1817 assert!(
1818 !lower.contains(banned),
1819 "{banned:?} leaked into the thread:\n{text}"
1820 );
1821 }
1822 for n in 1..9 {
1824 assert!(
1825 !lower.contains(&format!("round {n}")),
1826 "a round number leaked into the thread:\n{text}"
1827 );
1828 }
1829 }
1830 }
1831
1832 #[test]
1833 fn a_refutation_is_allowed_to_make_its_case() {
1836 let reasoning = "The caller validates against the schema first. \
1837 The discarded error is therefore unreachable in practice. ";
1838 let state = state_with(
1839 vec![("A point", &reasoning.repeat(6))],
1840 vec!["https://github.com/you/thing/issues/485"],
1841 );
1842 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
1843 assert!(
1844 !text.contains("..."),
1845 "nothing was cut mid thought:\n{text}"
1846 );
1847 assert!(text.len() < 4000, "{} chars", text.len());
1848 }
1849
1850 #[test]
1851 fn a_url_that_is_not_an_issue_link_is_left_alone() {
1852 assert_eq!(
1853 "#485",
1854 as_reference("https://github.com/you/thing/issues/485")
1855 );
1856 assert_eq!("note: something", as_reference("note: something"));
1857 }
1858}
1859
1860#[cfg(test)]
1861mod filed_reference_tests {
1862 use super::*;
1863
1864 #[test]
1865 fn an_issue_url_yields_its_number() {
1866 assert_eq!(
1867 Some(485),
1868 filed_issue_number("https://github.com/you/thing/issues/485")
1869 );
1870 }
1871
1872 #[test]
1875 fn a_local_note_yields_nothing() {
1876 assert_eq!(None, filed_issue_number("note: Retry is unbounded"));
1877 assert_eq!(None, filed_issue_number(""));
1878 assert_eq!(
1879 None,
1880 filed_issue_number("https://github.com/you/thing/issues/")
1881 );
1882 }
1883}
1884
1885#[cfg(test)]
1886mod followup_restraint_tests {
1887 use super::*;
1888 use crate::model::Severity;
1889
1890 fn cfg_with(followups: Followups, non_blocking: bool, nits: bool, cap: usize) -> Config {
1891 let mut cfg =
1892 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
1893 .unwrap();
1894 cfg.loop_cfg.followups = followups;
1895 cfg.loop_cfg.file_non_blocking = non_blocking;
1896 cfg.loop_cfg.file_nits = nits;
1897 cfg.loop_cfg.max_followups = cap;
1898 cfg
1899 }
1900
1901 fn finding(severity: Severity, title: &str, in_scope: bool) -> Finding {
1902 Finding {
1903 severity,
1904 title: title.into(),
1905 detail: "d".into(),
1906 file: "a.rs".into(),
1907 in_scope,
1908 }
1909 }
1910
1911 #[test]
1915 fn a_non_blocking_finding_is_not_a_tracker_item_by_default() {
1916 let cfg = cfg_with(Followups::Issues, false, false, 5);
1917 assert!(!cfg.loop_cfg.file_non_blocking);
1918 assert!(!cfg.loop_cfg.file_nits);
1919 }
1920
1921 #[test]
1922 fn follow_ups_stay_off_the_tracker_by_default() {
1923 let cfg =
1924 crate::config::parse("[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n")
1925 .unwrap();
1926 assert_eq!(
1927 Followups::Local,
1928 cfg.loop_cfg.followups,
1929 "the tracker is somebody's queue; the default must not write to it"
1930 );
1931 assert_eq!(5, cfg.loop_cfg.max_followups);
1932 }
1933
1934 #[test]
1936 fn only_out_of_scope_defects_qualify_at_the_defaults() {
1937 let cfg = cfg_with(Followups::Issues, false, false, 5);
1938 let qualifies = |f: &Finding| match f.severity {
1939 Severity::NonBlocking => cfg.loop_cfg.file_non_blocking && f.in_scope,
1940 Severity::Nit => cfg.loop_cfg.file_nits && f.in_scope,
1941 Severity::Blocking => false,
1942 } || !f.in_scope;
1943
1944 assert!(qualifies(&finding(
1945 Severity::Blocking,
1946 "pre-existing",
1947 false
1948 )));
1949 assert!(!qualifies(&finding(
1950 Severity::NonBlocking,
1951 "improvement",
1952 true
1953 )));
1954 assert!(!qualifies(&finding(Severity::Nit, "taste", true)));
1955 assert!(!qualifies(&finding(
1956 Severity::Blocking,
1957 "fix it here",
1958 true
1959 )));
1960 }
1961
1962 #[test]
1963 fn opening_it_up_lets_non_blocking_findings_through_again() {
1964 let cfg = cfg_with(Followups::Issues, true, false, 5);
1965 assert!(cfg.loop_cfg.file_non_blocking);
1966 }
1967
1968 #[test]
1970 fn the_cap_is_a_real_backstop() {
1971 let cfg = cfg_with(Followups::Issues, false, false, 3);
1972 let mut state = IssueRun::new(1, "t");
1973 state.filed = (0..3).map(|n| format!("url{n}")).collect();
1974 assert!(state.filed.len() >= cfg.loop_cfg.max_followups);
1975 }
1976
1977 #[test]
1981 fn the_cap_bounds_what_one_run_can_spawn() {
1982 let cfg = cfg_with(Followups::Issues, false, false, 5);
1983 assert!(
1984 cfg.loop_cfg.max_followups <= 5,
1985 "a run that can file ten follow-ups is a branching process"
1986 );
1987 }
1988}