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, 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 problem that exists but is not caused by this PR.
77Those become follow-up issues rather than review comments.
78
79Then choose next_action:
80- merge: no blocking findings, the PR is good.
81- fix_myself: there are blocking findings and you will fix them directly.
82- hand_back: there are blocking findings the author should address.
83{settled}";
84
85const FIX_PROMPT: &str = "\
86You reviewed this branch and chose to fix the blocking findings yourself.
87Implement those fixes now and commit them.
88
89Your findings:
90{findings}
91
92Commit your changes. Do not push, do not merge.";
93
94const RESPOND_PROMPT: &str = "\
95Here is a review of your PR for issue #{number}.
96
97{findings}
98
99For each point, choose exactly one disposition:
100- fixed: the point is valid and in scope. Fix it and commit.
101- refuted: the point is wrong, or not worth acting on. Explain why. Refuting is
102 a legitimate outcome; do not accept a review comment you believe is incorrect
103 just to get the PR approved.
104- filed_issue: the point is valid but unrelated to this PR. Supply
105 new_issue_title and new_issue_body; the harness files it and skips duplicates.
106
107Copy each finding's title and file across exactly as given, so your answer can
108be matched back to the review.
109
110Commit any fixes. Do not push, do not merge.";
111
112fn should_release(cfg: &Config, status: Status) -> bool {
117 if !cfg.loop_cfg.worktrees || cfg.loop_cfg.keep_worktrees {
118 return false;
119 }
120 !matches!(status, Status::Escalated | Status::Error)
121}
122
123pub fn run_issue(
128 agents: &[Agent],
129 cfg: &Config,
130 repo: &Repo,
131 item: &PlanItem,
132 issue: &Issue,
133 ledger: &mut Ledger,
134) -> IssueRun {
135 if let Some(existing) = repo.open_pr_for_issue(item.issue) {
143 log!(
144 "#{}: {} is already open, continuing it instead of implementing again",
145 item.issue,
146 existing.url
147 );
148 return resume_pr(agents, cfg, repo, existing.number, None);
149 }
150
151 let mut state = IssueRun::new(item.issue, item.title.clone());
152 let base = cfg.base_branch().to_string();
153
154 let prepared = if cfg.loop_cfg.worktrees {
155 repo.worktree_add(item.issue, &base)
156 } else {
157 let branch = repo.branch_for_issue(item.issue);
158 let start = format!("origin/{base}");
159 repo.git(&["checkout", "-B", &branch, &start])
160 .map(|_| (repo.root().to_path_buf(), branch))
161 };
162
163 let (work_dir, branch) = match prepared {
164 Ok(pair) => pair,
165 Err(e) => {
166 state.status = Status::Error;
167 state.notes.push(e.to_string());
168 log!("#{} failed: {e}", item.issue);
169 return state;
170 }
171 };
172
173 let outcome = implement_and_review(
174 agents, cfg, repo, item, issue, ledger, &mut state, &work_dir, &branch,
175 );
176 if let Err(e) = outcome {
177 state.status = Status::Error;
178 state.notes.push(e.to_string());
179 log!("#{} failed: {e}", item.issue);
180 }
181
182 if should_release(cfg, state.status) {
183 repo.worktree_remove(item.issue);
184 }
185 state
186}
187
188#[allow(clippy::too_many_arguments)]
189fn implement_and_review(
190 agents: &[Agent],
191 cfg: &Config,
192 repo: &Repo,
193 item: &PlanItem,
194 issue: &Issue,
195 ledger: &mut Ledger,
196 state: &mut IssueRun,
197 work_dir: &Path,
198 branch: &str,
199) -> Result<()> {
200 let number = item.issue;
201 let holder = cfg.first_implementor.clone();
202 let implementor = agent::find(agents, &holder)?;
203 let base = cfg.base_branch().to_string();
204
205 log!("#{number}: {holder} implementing");
206 let body: String = issue.body_text().trim().chars().take(6000).collect();
207 let prompt = IMPLEMENT_PROMPT
208 .replace("{number}", &number.to_string())
209 .replace("{title}", &item.title)
210 .replace("{body}", &body);
211 let out = implementor.ask(
212 &prompt,
213 work_dir,
214 cfg.effort_for_round(&implementor.spec, 1).as_deref(),
215 )?;
216
217 if out.to_uppercase().contains("NOT_WORTH_DOING") || !repo.has_changes(work_dir, &base) {
218 state.status = Status::Abandoned;
219 let reason = style::body(&out, &repo.style);
220 state.notes.push(reason.clone());
221 if let Err(e) = repo.comment_issue(number, &reason) {
222 logdim!("could not comment on #{number}: {e}");
223 }
224 return Ok(());
225 }
226
227 repo.rewrite_commits_if_needed(work_dir, &base)?;
228 repo.push(work_dir, branch)?;
229
230 let pr = match repo.pr_for_branch(branch) {
231 Some(existing) => existing,
232 None => {
233 let summary = extract_summary(&out).unwrap_or_else(|| item.title.clone());
234 let body = pr_body(number, &summary, &repo.style);
235 repo.create_pr(
236 work_dir,
237 branch,
238 &base,
239 &format!("{} (#{number})", item.title),
240 &body,
241 )?
242 }
243 };
244 state.pr = Some(pr.url.clone());
245 log!("#{number}: PR {}", pr.url);
246
247 let ctx = LoopCtx {
248 work_dir: work_dir.to_path_buf(),
249 branch: branch.to_string(),
250 pr_number: pr.number,
251 label: format!("#{number}"),
252 subject: number,
253 title: item.title.clone(),
254 start_round: 1,
255 holder: cfg.other(&holder),
256 release: Release::Issue(number),
257 };
258 review_loop(agents, cfg, repo, &ctx, state, ledger)
259}
260
261pub fn resume_pr(
272 agents: &[Agent],
273 cfg: &Config,
274 repo: &Repo,
275 pr_number: i64,
276 holder_override: Option<&str>,
277) -> IssueRun {
278 let failed = |e: SparError| {
279 log!("PR #{pr_number} failed: {e}");
280 let mut state = IssueRun::new(pr_number, format!("PR #{pr_number}"));
281 state.status = Status::Error;
282 state.notes.push(e.to_string());
283 state
284 };
285
286 let pr = match repo.pr_view(pr_number) {
287 Ok(pr) => pr,
288 Err(e) => return failed(e),
289 };
290
291 if pr.is_cross_repository {
296 log!("PR #{pr_number} comes from a fork, reviewing it without changing it");
297 return crate::review_only::review_pr(agents, cfg, repo, pr_number, false);
298 }
299
300 match resume_inner(agents, cfg, repo, pr, holder_override) {
301 Ok(state) => state,
302 Err(e) => failed(e),
303 }
304}
305
306fn resume_inner(
307 agents: &[Agent],
308 cfg: &Config,
309 repo: &Repo,
310 pr: PrView,
311 holder_override: Option<&str>,
312) -> Result<IssueRun> {
313 let pr_number = pr.number;
314 if !pr.is_open() {
315 return Err(spar_err!("PR #{pr_number} is {}", pr.state.to_lowercase()));
316 }
317
318 let subject = pr
319 .closing_issues_references
320 .first()
321 .map(|r| r.number)
322 .unwrap_or(pr_number);
323
324 let saved = repo.read_state(&pr);
325 let mut ledger: Ledger = saved.as_ref().map(|s| s.ledger.clone()).unwrap_or_default();
326 let start_round = saved.as_ref().map(|s| s.round + 1).unwrap_or(1);
327
328 let default_holder = cfg.other(&cfg.first_implementor);
329 let mut holder = holder_override
330 .map(str::to_string)
331 .or_else(|| saved.as_ref().map(|s| s.next_actor.clone()))
332 .unwrap_or_else(|| default_holder.clone());
333 if !cfg.has_agent(&holder) {
334 log!("state named unknown agent '{holder}', using {default_holder}");
335 holder = default_holder;
336 }
337
338 match &saved {
339 Some(_) => log!(
340 "PR #{pr_number}: resuming at round {start_round}, {} settled point(s), next up {holder}",
341 ledger.len()
342 ),
343 None => log!("PR #{pr_number}: no prior spar state, starting fresh with {holder}"),
344 }
345
346 let mut state = IssueRun::new(subject, pr.title.clone());
347 state.pr = Some(pr.url.clone());
348 if let Some(s) = &saved {
349 state.filed = s.filed.clone();
350 }
351
352 let (work_dir, branch) = repo.worktree_for_pr(&pr)?;
353 let ctx = LoopCtx {
354 work_dir,
355 branch,
356 pr_number,
357 label: format!("PR #{pr_number}"),
358 subject,
359 title: pr.title.clone(),
360 start_round,
361 holder,
362 release: Release::Pr(pr_number),
363 };
364
365 let outcome = review_loop(agents, cfg, repo, &ctx, &mut state, &mut ledger);
366 if let Err(e) = outcome {
367 state.status = Status::Error;
368 state.notes.push(e.to_string());
369 log!("PR #{pr_number} failed: {e}");
370 }
371 if should_release(cfg, state.status) {
372 repo.release_pr_worktree(pr_number);
373 }
374 Ok(state)
375}
376
377#[derive(Debug, Clone, Copy)]
382enum Release {
383 Issue(i64),
384 Pr(i64),
385}
386
387struct LoopCtx {
388 work_dir: PathBuf,
389 branch: String,
390 pr_number: i64,
391 label: String,
392 subject: i64,
393 title: String,
394 start_round: u32,
395 holder: String,
396 release: Release,
397}
398
399impl LoopCtx {
400 fn release(&self, repo: &Repo) {
401 match self.release {
402 Release::Issue(n) => repo.worktree_remove(n),
403 Release::Pr(n) => repo.release_pr_worktree(n),
404 }
405 }
406}
407
408fn review_loop(
409 agents: &[Agent],
410 cfg: &Config,
411 repo: &Repo,
412 ctx: &LoopCtx,
413 state: &mut IssueRun,
414 ledger: &mut Ledger,
415) -> Result<()> {
416 let base = cfg.base_branch().to_string();
417 let mut holder = ctx.holder.clone();
418
419 let (first, last_allowed) = round_window(ctx.start_round, cfg.loop_cfg.max_rounds);
425 let mut last_round = first.saturating_sub(1);
426
427 for round in first..=last_allowed {
428 last_round = round;
429 state.rounds = round;
430 let reviewer = agent::find(agents, &holder)?;
431 let effort = cfg.effort_for_round(&reviewer.spec, round);
432 log!(
433 "{}: round {round}, {holder} reviewing ({})",
434 ctx.label,
435 effort.as_deref().unwrap_or("default effort")
436 );
437
438 let prompt = REVIEW_PROMPT
439 .replace("{base}", &base)
440 .replace("{number}", &ctx.subject.to_string())
441 .replace("{title}", &ctx.title)
442 .replace("{settled}", &settled_block(ledger));
443 let review: Review = reviewer.review(
444 &base,
445 &prompt,
446 &schema::review(),
447 &ctx.work_dir,
448 effort.as_deref(),
449 )?;
450
451 let blocking: Vec<Finding> = review
452 .findings
453 .iter()
454 .filter(|f| f.blocks())
455 .cloned()
456 .collect();
457
458 if repo.style.pr_comments == PrComments::Rounds {
459 if let Err(e) = repo.comment_pr(
460 ctx.pr_number,
461 &review_comment(&holder, round, &review, &repo.style),
462 ) {
463 logdim!("could not post the review comment: {e}");
464 }
465 }
466
467 file_out_of_scope(repo, &review.findings, ctx.subject, state);
471 file_nonblocking(
472 repo,
473 &review.findings,
474 ctx.subject,
475 state,
476 cfg.loop_cfg.file_nits,
477 );
478
479 if check_relitigation(ledger, &blocking, state) {
480 state.status = Status::Escalated;
481 post_outcome(
482 repo,
483 ctx.pr_number,
484 state,
485 ledger,
486 Ending::Deadlocked(&blocking),
487 );
488 persist(
489 repo,
490 ctx.pr_number,
491 state,
492 ledger,
493 round,
494 &cfg.other(&holder),
495 );
496 return Ok(());
497 }
498
499 if blocking.is_empty() {
500 state.status = Status::Approved;
501 post_outcome(repo, ctx.pr_number, state, ledger, Ending::Approved);
502 persist(
503 repo,
504 ctx.pr_number,
505 state,
506 ledger,
507 round,
508 &cfg.other(&holder),
509 );
510 if cfg.loop_cfg.auto_merge {
511 ctx.release(repo);
516 repo.merge_pr(ctx.pr_number)?;
517 state.status = Status::Merged;
518 repo.clear_state(ctx.pr_number); log!("{}: merged", ctx.label);
520 } else {
521 log!("{}: approved, awaiting human merge", ctx.label);
522 }
523 return Ok(());
524 }
525
526 if review.next_action == NextAction::FixMyself {
527 log!("{}: {holder} fixing its own findings", ctx.label);
528 let prompt = FIX_PROMPT.replace("{findings}", &findings_for_prompt(&blocking));
529 reviewer.ask(&prompt, &ctx.work_dir, effort.as_deref())?;
530 } else {
531 let author_name = cfg.other(&holder);
532 let author = agent::find(agents, &author_name)?;
533 log!(
534 "{}: handing {} finding(s) to {author_name}",
535 ctx.label,
536 blocking.len()
537 );
538 let prompt = RESPOND_PROMPT
539 .replace("{number}", &ctx.subject.to_string())
540 .replace("{findings}", &findings_for_prompt(&blocking));
541 let response: ResponseDoc = author.ask_json(
542 &prompt,
543 &schema::response(),
544 &ctx.work_dir,
545 cfg.effort_for_round(&author.spec, round).as_deref(),
546 )?;
547 apply_dispositions(
548 repo,
549 &response,
550 &blocking,
551 ledger,
552 state,
553 round,
554 ctx.subject,
555 ctx.pr_number,
556 &author_name,
557 );
558 }
559
560 repo.rewrite_commits_if_needed(&ctx.work_dir, &base)?;
561 repo.push(&ctx.work_dir, &ctx.branch)?;
562 holder = cfg.other(&holder);
563 persist(repo, ctx.pr_number, state, ledger, round, &holder);
564 }
565
566 state.status = Status::Escalated;
567 state
568 .notes
569 .push(exhausted_note(ctx.start_round, last_round));
570 post_outcome(repo, ctx.pr_number, state, ledger, Ending::OutOfRounds);
571 persist(repo, ctx.pr_number, state, ledger, last_round, &holder);
572 Ok(())
573}
574
575fn round_window(start_round: u32, budget: u32) -> (u32, u32) {
581 (start_round, start_round + budget.saturating_sub(1))
582}
583
584fn spent(start_round: u32, last_round: u32) -> (u32, u32) {
588 (last_round.saturating_sub(start_round) + 1, last_round)
589}
590
591fn exhausted_note(start_round: u32, last_round: u32) -> String {
592 let (this_run, total) = spent(start_round, last_round);
593 if this_run == total {
594 format!("no convergence after {this_run} rounds")
595 } else {
596 format!("no convergence after {this_run} more rounds ({total} in total)")
597 }
598}
599
600fn persist(
601 repo: &Repo,
602 pr_number: i64,
603 state: &IssueRun,
604 ledger: &Ledger,
605 round: u32,
606 next_actor: &str,
607) {
608 let payload = PersistedState {
609 version: STATE_VERSION,
610 round,
611 next_actor: next_actor.to_string(),
612 status: state.status,
613 ledger: ledger.clone(),
614 filed: state.filed.clone(),
615 };
616 if let Err(e) = repo.write_state(pr_number, &payload) {
617 logdim!("could not persist state for PR #{pr_number}: {e}");
618 }
619}
620
621fn settled_block(ledger: &Ledger) -> String {
626 if ledger.is_empty() {
627 return String::new();
628 }
629 let lines: Vec<String> = ledger
630 .values()
631 .map(|e| format!("- {}: refuted because {}", e.title, e.reasoning))
632 .collect();
633 format!(
634 "\nThe following points were already raised and refuted. Treat them as settled. Do not \
635 raise them again unless you have new evidence:\n{}",
636 lines.join("\n")
637 )
638}
639
640fn check_relitigation(ledger: &mut Ledger, blocking: &[Finding], state: &mut IssueRun) -> bool {
643 let mut escalate = false;
644 for finding in blocking {
645 let key = finding_key(&finding.title, &finding.file);
646 if let Some(entry) = ledger.get_mut(&key) {
647 entry.reraised += 1;
648 if entry.reraised >= 2 {
649 state.notes.push(format!(
650 "'{}' was refuted and re-raised twice; escalating.",
651 finding.title
652 ));
653 escalate = true;
654 }
655 }
656 }
657 escalate
658}
659
660fn normalise(text: &str) -> String {
661 text.to_lowercase()
662 .chars()
663 .filter(|c| c.is_ascii_alphanumeric() || c.is_whitespace())
664 .collect::<String>()
665 .split_whitespace()
666 .collect::<Vec<_>>()
667 .join(" ")
668}
669
670pub(crate) fn same_point(a: &str, b: &str) -> bool {
675 normalise(a) == normalise(b)
676}
677
678fn matching_finding<'a>(findings: &'a [Finding], title: &str) -> Option<&'a Finding> {
679 let wanted = normalise(title);
680 findings.iter().find(|f| normalise(&f.title) == wanted)
681}
682
683#[allow(clippy::too_many_arguments)]
684fn apply_dispositions(
685 repo: &Repo,
686 response: &ResponseDoc,
687 blocking: &[Finding],
688 ledger: &mut Ledger,
689 state: &mut IssueRun,
690 round: u32,
691 subject: i64,
692 pr_number: i64,
693 author: &str,
694) {
695 let mut fixed = Vec::new();
696 let mut refuted = Vec::new();
697 let mut filed = Vec::new();
698
699 for d in &response.dispositions {
700 let source = matching_finding(blocking, &d.title);
701 let file = source
702 .map(|f| f.file.clone())
703 .filter(|f| !f.trim().is_empty())
704 .unwrap_or_else(|| d.file.clone());
705 let canonical = source.map(|f| f.title.as_str()).unwrap_or(d.title.as_str());
712 let title = style::title(canonical, &repo.style);
713
714 match d.action {
715 Action::Refuted => {
716 let reasoning = style::summary(&d.reasoning, &repo.style);
717 ledger.insert(
718 finding_key(canonical, &file),
719 LedgerEntry {
720 title: title.clone(),
721 file: file.clone(),
722 reasoning: reasoning.clone(),
723 round,
724 reraised: 0,
725 },
726 );
727 state.disputes.push(Dispute {
728 title: title.clone(),
729 reasoning: reasoning.clone(),
730 });
731 refuted.push(format!("{title}. {reasoning}"));
732 }
733 Action::FiledIssue => {
734 let new_title = d
735 .new_issue_title
736 .clone()
737 .filter(|t| !t.trim().is_empty())
738 .unwrap_or_else(|| d.title.clone());
739 let new_body = d
740 .new_issue_body
741 .clone()
742 .filter(|b| !b.trim().is_empty())
743 .unwrap_or_else(|| d.reasoning.clone());
744 if let Some(url) = file_followup(repo, &new_title, &new_body, subject) {
745 state.filed.push(url.clone());
746 filed.push(url);
747 }
748 }
749 Action::Fixed => fixed.push(title),
750 }
751 }
752
753 if repo.style.pr_comments == PrComments::Rounds {
754 let comment = disposition_comment(author, response, &fixed, &refuted, &filed, &repo.style);
755 if let Some(text) = comment {
756 if let Err(e) = repo.comment_pr(pr_number, &text) {
757 logdim!("could not post the disposition comment: {e}");
758 }
759 }
760 }
761}
762
763fn file_followup(repo: &Repo, title: &str, body: &str, source: i64) -> Option<String> {
774 if repo.followups == Followups::None {
775 return None;
776 }
777 let title = match repo.clean_title(title) {
781 Ok(title) => title,
782 Err(e) => {
783 logdim!("could not clean a follow-up title: {e}");
784 return None;
785 }
786 };
787 if title.trim().is_empty() {
788 return None;
789 }
790 let body = format!(
793 "{}\n\nFound while working on #{source}.",
794 style::issue_body(body, &repo.style)
795 );
796
797 if repo.followups == Followups::Local {
798 return repo.append_local_followup(&title, &body);
799 }
800
801 if let Some(existing) = repo.find_similar_issue(&title, &body) {
805 let known = format!("{} {}", existing.title, existing.body);
806 if !existing.open {
807 logdim!(
808 "#{} already covers '{title}' and is closed, leaving it alone",
809 existing.number
810 );
811 return None;
812 }
813 if crate::textsim::adds_information(&body, &known) {
814 match repo.comment_issue(existing.number, &body) {
815 Ok(()) => log!("added to #{}: {title}", existing.number),
816 Err(e) => logdim!("could not add to #{}: {e}", existing.number),
817 }
818 } else {
819 logdim!("#{} already says this, nothing added", existing.number);
820 }
821 return Some(existing.url);
822 }
823
824 match repo.create_issue(&title, &body) {
825 Ok(url) => Some(url),
826 Err(e) => {
827 logdim!("could not file a follow-up for '{title}': {e}");
828 None
829 }
830 }
831}
832
833fn file_out_of_scope(repo: &Repo, findings: &[Finding], subject: i64, state: &mut IssueRun) {
834 for finding in findings.iter().filter(|f| !f.in_scope) {
835 if let Some(url) = file_followup(repo, &finding.title, &finding.detail, subject) {
836 state.filed.push(url);
837 }
838 }
839}
840
841fn file_nonblocking(
848 repo: &Repo,
849 findings: &[Finding],
850 subject: i64,
851 state: &mut IssueRun,
852 file_nits: bool,
853) {
854 for finding in findings {
855 let keep = match finding.severity {
856 Severity::NonBlocking => true,
857 Severity::Nit => file_nits,
858 Severity::Blocking => false,
859 };
860 if !keep || !finding.in_scope {
861 continue;
862 }
863 if let Some(url) = file_followup(repo, &finding.title, &finding.detail, subject) {
864 state.filed.push(url);
865 }
866 }
867}
868
869fn bullets(lines: &[String]) -> String {
879 lines
880 .iter()
881 .map(|l| format!("- {l}"))
882 .collect::<Vec<_>>()
883 .join("\n")
884}
885
886fn located(finding: &Finding, style: &Style) -> String {
887 let title = style::title(&finding.title, style);
888 match finding.where_at() {
889 "general" => title,
890 file => format!("{title} ({file})"),
891 }
892}
893
894pub enum Ending<'a> {
896 Approved,
898 OutOfRounds,
901 Deadlocked(&'a [Finding]),
904}
905
906pub fn post_outcome(
918 repo: &Repo,
919 pr_number: i64,
920 state: &IssueRun,
921 ledger: &Ledger,
922 ending: Ending<'_>,
923) {
924 if repo.style.pr_comments != PrComments::Outcome {
925 return;
926 }
927 let Some(text) = outcome_comment(state, ledger, &ending, &repo.style) else {
928 return;
929 };
930 if let Err(e) = repo.comment_pr(pr_number, &text) {
931 logdim!("could not post the outcome comment: {e}");
932 }
933}
934
935fn refutation_of(finding: &Finding, state: &IssueRun, ledger: &Ledger) -> Option<String> {
938 if let Some(d) = state
939 .disputes
940 .iter()
941 .find(|d| same_point(&d.title, &finding.title))
942 {
943 if !d.reasoning.trim().is_empty() {
944 return Some(d.reasoning.clone());
945 }
946 }
947 ledger
948 .get(&finding_key(&finding.title, &finding.file))
949 .map(|entry| entry.reasoning.clone())
950 .filter(|r| !r.trim().is_empty())
951}
952
953pub fn filed_issue_number(filed: &str) -> Option<i64> {
958 filed
959 .rsplit('/')
960 .next()
961 .and_then(|tail| tail.parse::<i64>().ok())
962 .filter(|n| *n > 0)
963}
964
965fn as_reference(url: &str) -> String {
966 match url.rsplit('/').next().and_then(|n| n.parse::<u64>().ok()) {
967 Some(number) => format!("#{number}"),
968 None => url.to_string(),
969 }
970}
971
972pub fn outcome_comment(
973 state: &IssueRun,
974 ledger: &Ledger,
975 ending: &Ending<'_>,
976 style: &Style,
977) -> Option<String> {
978 let mut out: Vec<String> = Vec::new();
979 let mut already: Vec<String> = Vec::new();
982
983 match ending {
984 Ending::Approved => {
985 if state.disputes.is_empty() && state.filed.is_empty() {
986 return None;
989 }
990 out.push("Reviewed, nothing blocking a merge.".into());
991 }
992 Ending::OutOfRounds => out.push(
993 "Not signed off: the last round of fixes was pushed but has not been reviewed.".into(),
994 ),
995 Ending::Deadlocked(points) => {
996 let lines: Vec<String> = points
1002 .iter()
1003 .map(|f| {
1004 let where_at = match f.where_at() {
1005 "general" => String::new(),
1006 file => format!(" ({file})"),
1007 };
1008 let title = style::title(&f.title, style);
1009 already.push(title.clone());
1010 match refutation_of(f, state, ledger) {
1011 Some(reason) => format!(
1012 "{title}{where_at}. Refuted as: {}",
1013 style::summary(&reason, style)
1014 ),
1015 None => format!("{title}{where_at}"),
1016 }
1017 })
1018 .collect();
1019 out.push("Needs your decision. The reviewers could not settle this:".into());
1020 out.push(bullets(&lines));
1021 }
1022 }
1023
1024 let disputes: Vec<&crate::model::Dispute> = state
1025 .disputes
1026 .iter()
1027 .filter(|d| !already.iter().any(|t| same_point(t, &d.title)))
1028 .collect();
1029 if !disputes.is_empty() {
1030 let lines: Vec<String> = disputes
1033 .iter()
1034 .map(|d| {
1035 format!(
1036 "{}. {}",
1037 style::title(&d.title, style),
1038 style::sentence(&d.reasoning, style)
1039 )
1040 })
1041 .collect();
1042 out.push(format!("Raised and refuted:\n{}", bullets(&lines)));
1043 }
1044
1045 if !state.filed.is_empty() {
1046 let refs: Vec<String> = state.filed.iter().map(|u| as_reference(u)).collect();
1047 out.push(format!("Filed separately: {}", refs.join(", ")));
1048 }
1049
1050 Some(out.join("\n\n"))
1051}
1052
1053pub fn pr_body(issue: i64, summary: &str, style: &Style) -> String {
1056 let mut parts = vec![format!("Closes #{issue}")];
1057 let summary = style::summary(summary, style);
1058 if !summary.is_empty() {
1059 parts.push(summary);
1060 }
1061 parts.join("\n\n")
1062}
1063
1064pub fn extract_summary(text: &str) -> Option<String> {
1066 text.lines()
1067 .rev()
1068 .find_map(|line| {
1069 let trimmed = line.trim().trim_start_matches(['*', '#', '-', ' ']);
1070 trimmed
1071 .strip_prefix("SUMMARY:")
1072 .or_else(|| trimmed.strip_prefix("Summary:"))
1073 })
1074 .map(|s| {
1075 s.trim()
1076 .trim_start_matches(['*', '_', ':', ' '])
1077 .trim()
1078 .to_string()
1079 })
1080 .filter(|s| !s.is_empty())
1081}
1082
1083pub fn review_comment(holder: &str, round: u32, review: &Review, style: &Style) -> String {
1087 let by = |severity: Severity| -> Vec<&Finding> {
1088 review
1089 .findings
1090 .iter()
1091 .filter(|f| f.severity == severity && f.in_scope)
1092 .collect()
1093 };
1094 let blocking = by(Severity::Blocking);
1095 let non_blocking = by(Severity::NonBlocking);
1096 let nits = by(Severity::Nit);
1097 let out_of_scope: Vec<&Finding> = review.findings.iter().filter(|f| !f.in_scope).collect();
1098
1099 let mut counts = Vec::new();
1100 if !blocking.is_empty() {
1101 counts.push(format!("{} blocking", blocking.len()));
1102 }
1103 if !non_blocking.is_empty() {
1104 counts.push(format!("{} non-blocking", non_blocking.len()));
1105 }
1106 if !nits.is_empty() {
1107 counts.push(format!("{} nit", nits.len()));
1108 }
1109 if !out_of_scope.is_empty() {
1110 counts.push(format!("{} out of scope", out_of_scope.len()));
1111 }
1112 let headline = if counts.is_empty() {
1113 "no findings".to_string()
1114 } else {
1115 counts.join(", ")
1116 };
1117
1118 let _ = (holder, round, headline);
1119 let mut out = Vec::new();
1120 let summary = style::summary(&review.summary, style);
1121 if !summary.is_empty() {
1122 out.push(summary);
1123 }
1124
1125 if !blocking.is_empty() {
1126 let lines: Vec<String> = blocking
1127 .iter()
1128 .map(|f| {
1129 let detail = style::detail(&f.detail, style);
1130 if detail.is_empty() {
1131 located(f, style)
1132 } else {
1133 format!("{}. {detail}", located(f, style))
1134 }
1135 })
1136 .collect();
1137 out.push(format!("blocking\n{}", bullets(&lines)));
1138 }
1139
1140 for (label, group) in [
1143 ("non-blocking", &non_blocking),
1144 ("nits", &nits),
1145 ("out of scope", &out_of_scope),
1146 ] {
1147 if group.is_empty() {
1148 continue;
1149 }
1150 let lines: Vec<String> = group.iter().map(|f| located(f, style)).collect();
1151 out.push(format!("{label}\n{}", bullets(&lines)));
1152 }
1153
1154 out.join("\n\n")
1155}
1156
1157pub fn disposition_comment(
1161 author: &str,
1162 response: &ResponseDoc,
1163 fixed: &[String],
1164 refuted: &[String],
1165 filed: &[String],
1166 style: &Style,
1167) -> Option<String> {
1168 if fixed.is_empty() && refuted.is_empty() && filed.is_empty() {
1169 return None;
1170 }
1171 let mut counts = Vec::new();
1172 if !fixed.is_empty() {
1173 counts.push(format!("{} fixed", fixed.len()));
1174 }
1175 if !refuted.is_empty() {
1176 counts.push(format!("{} refuted", refuted.len()));
1177 }
1178 if !filed.is_empty() {
1179 counts.push(format!("{} filed", filed.len()));
1180 }
1181
1182 let _ = (author, counts);
1183 let mut out = Vec::new();
1184 let summary = style::summary(&response.summary, style);
1185 if !summary.is_empty() {
1186 out.push(summary);
1187 }
1188 if !refuted.is_empty() {
1189 out.push(format!("refuted\n{}", bullets(refuted)));
1190 }
1191 if !fixed.is_empty() {
1192 out.push(format!("fixed\n{}", bullets(fixed)));
1193 }
1194 if !filed.is_empty() {
1195 out.push(format!("filed\n{}", bullets(filed)));
1196 }
1197 Some(out.join("\n\n"))
1198}
1199
1200pub fn skip_comment(item: &SkippedItem, style: &Style) -> String {
1208 let reasons = item
1209 .reasons
1210 .values()
1211 .map(|reason| style::sentence(reason, style));
1212 let lines = crate::textsim::dedupe_by(reasons, crate::textsim::same_reason);
1216 bullets(&lines)
1217}
1218
1219pub(crate) fn findings_for_prompt(findings: &[Finding]) -> String {
1222 if findings.is_empty() {
1223 return "(none)".to_string();
1224 }
1225 findings
1226 .iter()
1227 .map(|f| {
1228 let scope = if f.in_scope { "" } else { " [out of scope]" };
1229 format!(
1230 "- [{}]{scope} {} ({})\n {}",
1231 f.severity,
1232 f.title,
1233 f.where_at(),
1234 f.detail
1235 )
1236 })
1237 .collect::<Vec<_>>()
1238 .join("\n")
1239}
1240
1241#[cfg(test)]
1242mod tests {
1243 use super::*;
1244 use crate::model::Verdict;
1245
1246 fn style() -> Style {
1247 Style::default()
1248 }
1249
1250 fn finding(severity: &str, title: &str, detail: &str, file: &str, in_scope: bool) -> Finding {
1251 Finding {
1252 severity: Severity::parse_lenient(severity).unwrap(),
1253 title: title.into(),
1254 detail: detail.into(),
1255 file: file.into(),
1256 in_scope,
1257 }
1258 }
1259
1260 fn review(summary: &str, findings: Vec<Finding>) -> Review {
1261 Review {
1262 verdict: Verdict::Approve,
1263 next_action: NextAction::Merge,
1264 summary: summary.into(),
1265 findings,
1266 }
1267 }
1268
1269 fn cfg_with(worktrees: bool, keep: bool) -> Config {
1272 let text = "[agents.a]\ncommand = [\"x\"]\n[agents.b]\ncommand = [\"y\"]\n";
1273 let mut cfg = crate::config::parse(text).unwrap();
1274 cfg.loop_cfg.worktrees = worktrees;
1275 cfg.loop_cfg.keep_worktrees = keep;
1276 cfg
1277 }
1278
1279 #[test]
1280 fn a_worktree_is_released_on_every_finished_outcome() {
1281 let cfg = cfg_with(true, false);
1282 for status in [Status::Approved, Status::Merged, Status::Abandoned] {
1283 assert!(should_release(&cfg, status), "{status}");
1284 }
1285 }
1286
1287 #[test]
1290 fn a_worktree_is_kept_only_where_a_human_has_to_look() {
1291 let cfg = cfg_with(true, false);
1292 assert!(!should_release(&cfg, Status::Escalated));
1293 assert!(!should_release(&cfg, Status::Error));
1294 }
1295
1296 #[test]
1297 fn the_keep_flag_overrides_everything() {
1298 assert!(!should_release(&cfg_with(true, true), Status::Approved));
1299 }
1300
1301 #[test]
1302 fn nothing_is_released_when_worktrees_are_off() {
1303 assert!(!should_release(&cfg_with(false, false), Status::Approved));
1304 }
1305
1306 #[test]
1310 fn a_fresh_run_starts_at_one() {
1311 assert_eq!((1, 3), round_window(1, 3));
1312 assert_eq!((1, 5), round_window(1, 5));
1313 }
1314
1315 #[test]
1319 fn a_resumed_run_gets_a_full_fresh_budget() {
1320 assert_eq!((6, 10), round_window(6, 5));
1321 assert_eq!((11, 13), round_window(11, 3));
1322 }
1323
1324 #[test]
1325 fn a_budget_of_one_is_a_single_round() {
1326 assert_eq!((6, 6), round_window(6, 1));
1327 }
1328
1329 #[test]
1330 fn round_numbers_keep_counting_across_sessions() {
1331 let mut start = 1;
1333 let mut seen = Vec::new();
1334 for _ in 0..3 {
1335 let (first, last) = round_window(start, 3);
1336 seen.push((first, last));
1337 start = last + 1;
1338 }
1339 assert_eq!(vec![(1, 3), (4, 6), (7, 9)], seen);
1340 }
1341
1342 fn ledger_with(title: &str, file: &str) -> Ledger {
1345 let mut ledger = Ledger::new();
1346 ledger.insert(
1347 finding_key(title, file),
1348 LedgerEntry {
1349 title: title.into(),
1350 file: file.into(),
1351 reasoning: "no".into(),
1352 round: 1,
1353 reraised: 0,
1354 },
1355 );
1356 ledger
1357 }
1358
1359 #[test]
1360 fn a_point_refuted_and_re_raised_twice_escalates() {
1361 let mut ledger = ledger_with("nit about naming", "a.rs");
1362 let mut state = IssueRun::new(1, "t");
1363 let blocking = vec![finding("blocking", "nit about naming", "d", "a.rs", true)];
1364 assert!(!check_relitigation(&mut ledger, &blocking, &mut state));
1365 assert!(check_relitigation(&mut ledger, &blocking, &mut state));
1366 }
1367
1368 #[test]
1369 fn an_untracked_finding_does_not_escalate() {
1370 let mut state = IssueRun::new(1, "t");
1371 let blocking = vec![finding("blocking", "brand new", "d", "a.rs", true)];
1372 assert!(!check_relitigation(
1373 &mut Ledger::new(),
1374 &blocking,
1375 &mut state
1376 ));
1377 }
1378
1379 #[test]
1383 fn a_refutation_lands_on_the_key_the_next_round_will_look_up() {
1384 let blocking = vec![finding("blocking", "Unbounded loop", "d", "src/x.rs", true)];
1385 let recorded = finding_key(&blocking[0].title, &blocking[0].file);
1386
1387 let matched = matching_finding(&blocking, "unbounded loop!").expect("should match");
1388 assert_eq!(recorded, finding_key("unbounded loop!", &matched.file));
1389 }
1390
1391 #[test]
1396 fn the_ledger_key_uses_the_reviewers_wording_not_the_authors() {
1397 let findings = vec![finding(
1398 "blocking",
1399 "Panic on multi-byte input",
1400 "d",
1401 "src/style.rs",
1402 true,
1403 )];
1404 let reworded = "Panic on multibyte input";
1405
1406 let source = matching_finding(&findings, reworded).expect("still matches");
1407 assert_ne!(
1408 finding_key(reworded, &source.file),
1409 finding_key(&source.title, &source.file),
1410 "the two spellings must genuinely hash apart, or this test proves nothing"
1411 );
1412
1413 let recorded = finding_key(&source.title, &source.file);
1415 let looked_up = finding_key(&findings[0].title, &findings[0].file);
1416 assert_eq!(recorded, looked_up);
1417 }
1418
1419 #[test]
1420 fn a_disposition_matches_its_finding_despite_wording_noise() {
1421 let findings = vec![finding(
1422 "blocking",
1423 "Unbounded loop!",
1424 "d",
1425 "src/x.rs",
1426 true,
1427 )];
1428 assert!(matching_finding(&findings, "unbounded loop").is_some());
1429 assert!(matching_finding(&findings, "something else").is_none());
1430 }
1431
1432 #[test]
1433 fn the_settled_block_is_empty_when_nothing_is_settled() {
1434 assert_eq!("", settled_block(&Ledger::new()));
1435 }
1436
1437 #[test]
1438 fn the_settled_block_names_each_refutation() {
1439 let block = settled_block(&ledger_with("a point", "x.rs"));
1440 assert!(block.contains("a point"));
1441 assert!(block.contains("settled"));
1442 }
1443
1444 #[test]
1447 fn a_clean_review_is_just_the_verdict() {
1450 let text = review_comment("codex", 1, &review("Looks correct.", vec![]), &style());
1451 assert_eq!("Looks correct.", text);
1452 }
1453
1454 #[test]
1455 fn a_review_leads_with_the_counts() {
1456 let text = review_comment(
1457 "codex",
1458 2,
1459 &review(
1460 "One real problem.",
1461 vec![
1462 finding(
1463 "blocking",
1464 "Loop never terminates",
1465 "Confirmed by running it.",
1466 "src/a.rs",
1467 true,
1468 ),
1469 finding("non-blocking", "Name is vague", "d", "src/b.rs", true),
1470 finding("nit", "Log wording", "d", "", true),
1471 ],
1472 ),
1473 &style(),
1474 );
1475 assert!(text.starts_with("One real problem."), "{text}");
1476 assert!(!text.contains("codex"), "no agent name: {text}");
1477 assert!(!text.contains("round 2"), "no round number: {text}");
1478 }
1479
1480 #[test]
1483 fn only_blocking_findings_carry_their_detail() {
1484 let text = review_comment(
1485 "codex",
1486 1,
1487 &review(
1488 "s",
1489 vec![
1490 finding("blocking", "Loop", "BLOCKING DETAIL", "a.rs", true),
1491 finding("non-blocking", "Name", "NONBLOCKING DETAIL", "b.rs", true),
1492 ],
1493 ),
1494 &style(),
1495 );
1496 assert!(text.contains("BLOCKING DETAIL"), "{text}");
1497 assert!(!text.contains("NONBLOCKING DETAIL"), "{text}");
1498 }
1499
1500 #[test]
1501 fn a_thorough_explanation_reaches_the_author_intact() {
1504 let detail = "Reproduced by running the 429 test with max_attempts unset. ".repeat(8);
1505 let text = review_comment(
1506 "codex",
1507 1,
1508 &review(
1509 "One problem.",
1510 vec![finding("blocking", "T", &detail, "a.rs", true)],
1511 ),
1512 &style(),
1513 );
1514 assert!(
1515 text.contains(detail.trim()),
1516 "the explanation was cut:\n{text}"
1517 );
1518 }
1519
1520 #[test]
1522 fn a_runaway_model_is_still_bounded() {
1523 let long = "filler words. ".repeat(20_000);
1524 let text = review_comment(
1525 "codex",
1526 1,
1527 &review(&long, vec![finding("blocking", "T", &long, "a.rs", true)]),
1528 &style(),
1529 );
1530 assert!(text.len() < 8000, "review comment was {} chars", text.len());
1531 }
1532
1533 #[test]
1534 fn a_general_finding_has_no_empty_parenthesis() {
1535 let text = review_comment(
1536 "codex",
1537 1,
1538 &review("s", vec![finding("blocking", "Something", "d", "", true)]),
1539 &style(),
1540 );
1541 assert!(!text.contains("()"), "{text}");
1542 assert!(!text.contains("(general)"), "{text}");
1543 }
1544
1545 #[test]
1546 fn out_of_scope_findings_are_counted_separately() {
1547 let text = review_comment(
1548 "codex",
1549 1,
1550 &review(
1551 "s",
1552 vec![finding("blocking", "Old bug", "d", "a.rs", false)],
1553 ),
1554 &style(),
1555 );
1556 assert!(text.contains("out of scope"), "{text}");
1557 assert!(text.contains("Old bug"), "{text}");
1558 }
1559
1560 #[test]
1561 fn a_disposition_comment_leads_with_counts_and_keeps_refutations() {
1562 let response = ResponseDoc {
1563 summary: "Two of three were right.".into(),
1564 dispositions: vec![],
1565 };
1566 let text = disposition_comment(
1567 "claude",
1568 &response,
1569 &["Fixed thing".to_string()],
1570 &["Wrong thing. Because the caller already checks.".to_string()],
1571 &[],
1572 &style(),
1573 )
1574 .unwrap();
1575 assert!(text.starts_with("Two of three were right."), "{text}");
1576 assert!(!text.contains("claude"), "no agent name: {text}");
1577 assert!(
1578 text.contains("Because the caller already checks."),
1579 "{text}"
1580 );
1581 }
1582
1583 #[test]
1584 fn an_empty_disposition_comment_is_not_posted() {
1585 let response = ResponseDoc {
1586 summary: "s".into(),
1587 dispositions: vec![],
1588 };
1589 assert!(disposition_comment("claude", &response, &[], &[], &[], &style()).is_none());
1590 }
1591
1592 #[test]
1593 fn a_pr_body_is_what_it_closes_and_what_changed() {
1596 let body = pr_body(42, "Retry on a 429 instead of failing.", &style());
1597 assert_eq!("Closes #42\n\nRetry on a 429 instead of failing.", body);
1598 }
1599
1600 #[test]
1601 fn a_pr_body_survives_a_missing_summary_and_diffstat() {
1602 assert_eq!("Closes #7", pr_body(7, "", &style()));
1603 }
1604
1605 #[test]
1606 fn the_summary_line_is_lifted_out_of_the_final_message() {
1607 let out = "I did some work.\n\nSUMMARY: Retry on a 429 instead of failing.\n";
1608 assert_eq!(
1609 Some("Retry on a 429 instead of failing.".to_string()),
1610 extract_summary(out)
1611 );
1612 }
1613
1614 #[test]
1615 fn a_decorated_summary_line_still_parses() {
1616 assert_eq!(
1617 Some("Did a thing.".to_string()),
1618 extract_summary("**SUMMARY:** Did a thing.")
1619 );
1620 }
1621
1622 #[test]
1623 fn a_missing_summary_line_is_none() {
1624 assert_eq!(None, extract_summary("no marker here"));
1625 }
1626
1627 #[test]
1628 fn the_last_summary_line_wins() {
1629 let out = "SUMMARY: first draft\nmore work\nSUMMARY: final answer";
1630 assert_eq!(Some("final answer".to_string()), extract_summary(out));
1631 }
1632
1633 #[test]
1634 fn a_skip_comment_is_only_the_reasoning() {
1635 let item = SkippedItem {
1636 issue: 3,
1637 title: "t".into(),
1638 reasons: [
1639 ("claude".to_string(), "Already fixed in 1.2.".to_string()),
1640 ("codex".to_string(), "Duplicate of #2.".to_string()),
1641 ]
1642 .into_iter()
1643 .collect(),
1644 };
1645 let text = skip_comment(&item, &style());
1646 assert!(text.contains("Already fixed in 1.2."), "{text}");
1647 assert!(text.contains("Duplicate of #2."), "{text}");
1648 assert!(
1649 !text.contains("claude") && !text.contains("codex"),
1650 "{text}"
1651 );
1652 assert!(!text.to_lowercase().contains("not scheduled"), "{text}");
1653 assert!(text.lines().count() <= 3, "{text}");
1654 }
1655
1656 #[test]
1657 fn findings_for_a_model_keep_full_detail() {
1658 let long = "x".repeat(2000);
1659 let text = findings_for_prompt(&[finding("blocking", "T", &long, "a.rs", true)]);
1660 assert!(
1661 text.contains(&long),
1662 "a model needs the whole finding, only humans need brevity"
1663 );
1664 }
1665
1666 #[test]
1667 fn findings_for_a_model_are_never_empty() {
1668 assert_eq!("(none)", findings_for_prompt(&[]));
1669 }
1670}
1671
1672#[cfg(test)]
1673mod outcome_tests {
1674 use super::*;
1675 use crate::model::{Dispute, Severity};
1676
1677 fn style() -> Style {
1678 Style::default()
1679 }
1680
1681 fn state_with(disputes: Vec<(&str, &str)>, filed: Vec<&str>) -> IssueRun {
1682 let mut s = IssueRun::new(482, "t");
1683 s.disputes = disputes
1684 .into_iter()
1685 .map(|(title, reasoning)| Dispute {
1686 title: title.into(),
1687 reasoning: reasoning.into(),
1688 })
1689 .collect();
1690 s.filed = filed.into_iter().map(String::from).collect();
1691 s
1692 }
1693
1694 fn finding(title: &str, file: &str) -> Finding {
1695 Finding {
1696 severity: Severity::Blocking,
1697 title: title.into(),
1698 detail: "d".into(),
1699 file: file.into(),
1700 in_scope: true,
1701 }
1702 }
1703
1704 #[test]
1707 fn a_clean_approval_says_nothing() {
1708 let state = state_with(vec![], vec![]);
1709 assert!(outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).is_none());
1710 }
1711
1712 #[test]
1713 fn an_approval_that_filed_follow_ups_links_them() {
1714 let state = state_with(
1715 vec![],
1716 vec![
1717 "https://github.com/you/thing/issues/485",
1718 "https://github.com/you/thing/issues/486",
1719 ],
1720 );
1721 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
1722 assert!(text.contains("Filed separately: #485, #486"), "{text}");
1723 }
1724
1725 #[test]
1729 fn running_out_of_rounds_says_what_that_means_for_the_reader() {
1730 let state = state_with(vec![], vec![]);
1731 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
1732 assert!(text.contains("has not been reviewed"), "{text}");
1733 assert!(
1734 !text.to_lowercase().contains("round 3"),
1735 "no round numbers: {text}"
1736 );
1737 assert!(!text.to_lowercase().contains("convergence"), "{text}");
1738 }
1739
1740 #[test]
1741 fn a_deadlock_names_the_point_they_could_not_settle() {
1742 let state = state_with(vec![], vec![]);
1743 let points = [finding("Retry loop never terminates", "src/net.rs:88")];
1744 let text = outcome_comment(
1745 &state,
1746 &Ledger::new(),
1747 &Ending::Deadlocked(&points),
1748 &style(),
1749 )
1750 .unwrap();
1751 assert!(
1752 text.contains("Retry loop never terminates (src/net.rs:88)"),
1753 "{text}"
1754 );
1755 assert!(text.contains("could not settle"), "{text}");
1756 }
1757
1758 #[test]
1760 fn refutations_survive_because_nothing_else_carries_them() {
1761 let state = state_with(
1762 vec![(
1763 "Error is swallowed",
1764 "the caller already validates the file",
1765 )],
1766 vec![],
1767 );
1768 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
1769 assert!(text.contains("Raised and refuted:"), "{text}");
1770 assert!(
1771 text.contains("The caller already validates the file"),
1772 "{text}"
1773 );
1774 }
1775
1776 #[test]
1777 fn no_agent_names_counts_or_round_numbers_reach_the_thread() {
1778 let state = state_with(
1779 vec![("A point", "a reason")],
1780 vec!["https://github.com/you/thing/issues/485"],
1781 );
1782 for ending in [Ending::Approved, Ending::OutOfRounds] {
1783 let text = outcome_comment(&state, &Ledger::new(), &ending, &style()).unwrap();
1784 let lower = text.to_lowercase();
1785 for banned in ["claude", "codex", "blocking,", "nit,", " fixed."] {
1786 assert!(
1787 !lower.contains(banned),
1788 "{banned:?} leaked into the thread:\n{text}"
1789 );
1790 }
1791 for n in 1..9 {
1793 assert!(
1794 !lower.contains(&format!("round {n}")),
1795 "a round number leaked into the thread:\n{text}"
1796 );
1797 }
1798 }
1799 }
1800
1801 #[test]
1802 fn a_refutation_is_allowed_to_make_its_case() {
1805 let reasoning = "The caller validates against the schema first. \
1806 The discarded error is therefore unreachable in practice. ";
1807 let state = state_with(
1808 vec![("A point", &reasoning.repeat(6))],
1809 vec!["https://github.com/you/thing/issues/485"],
1810 );
1811 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
1812 assert!(
1813 !text.contains("..."),
1814 "nothing was cut mid thought:\n{text}"
1815 );
1816 assert!(text.len() < 4000, "{} chars", text.len());
1817 }
1818
1819 #[test]
1820 fn a_url_that_is_not_an_issue_link_is_left_alone() {
1821 assert_eq!(
1822 "#485",
1823 as_reference("https://github.com/you/thing/issues/485")
1824 );
1825 assert_eq!("note: something", as_reference("note: something"));
1826 }
1827}
1828
1829#[cfg(test)]
1830mod filed_reference_tests {
1831 use super::*;
1832
1833 #[test]
1834 fn an_issue_url_yields_its_number() {
1835 assert_eq!(
1836 Some(485),
1837 filed_issue_number("https://github.com/you/thing/issues/485")
1838 );
1839 }
1840
1841 #[test]
1844 fn a_local_note_yields_nothing() {
1845 assert_eq!(None, filed_issue_number("note: Retry is unbounded"));
1846 assert_eq!(None, filed_issue_number(""));
1847 assert_eq!(
1848 None,
1849 filed_issue_number("https://github.com/you/thing/issues/")
1850 );
1851 }
1852}