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_verbose_model_is_clipped_not_forwarded() {
1502 let long_summary = "This is a very thorough summary. ".repeat(40);
1503 let long_detail = "Here is an extremely long explanation. ".repeat(40);
1504 let text = review_comment(
1505 "codex",
1506 1,
1507 &review(
1508 &long_summary,
1509 vec![finding("blocking", "T", &long_detail, "a.rs", true)],
1510 ),
1511 &style(),
1512 );
1513 assert!(
1514 text.len() < 900,
1515 "review comment was {} chars:\n{text}",
1516 text.len()
1517 );
1518 }
1519
1520 #[test]
1521 fn a_general_finding_has_no_empty_parenthesis() {
1522 let text = review_comment(
1523 "codex",
1524 1,
1525 &review("s", vec![finding("blocking", "Something", "d", "", true)]),
1526 &style(),
1527 );
1528 assert!(!text.contains("()"), "{text}");
1529 assert!(!text.contains("(general)"), "{text}");
1530 }
1531
1532 #[test]
1533 fn out_of_scope_findings_are_counted_separately() {
1534 let text = review_comment(
1535 "codex",
1536 1,
1537 &review(
1538 "s",
1539 vec![finding("blocking", "Old bug", "d", "a.rs", false)],
1540 ),
1541 &style(),
1542 );
1543 assert!(text.contains("out of scope"), "{text}");
1544 assert!(text.contains("Old bug"), "{text}");
1545 }
1546
1547 #[test]
1548 fn a_disposition_comment_leads_with_counts_and_keeps_refutations() {
1549 let response = ResponseDoc {
1550 summary: "Two of three were right.".into(),
1551 dispositions: vec![],
1552 };
1553 let text = disposition_comment(
1554 "claude",
1555 &response,
1556 &["Fixed thing".to_string()],
1557 &["Wrong thing. Because the caller already checks.".to_string()],
1558 &[],
1559 &style(),
1560 )
1561 .unwrap();
1562 assert!(text.starts_with("Two of three were right."), "{text}");
1563 assert!(!text.contains("claude"), "no agent name: {text}");
1564 assert!(
1565 text.contains("Because the caller already checks."),
1566 "{text}"
1567 );
1568 }
1569
1570 #[test]
1571 fn an_empty_disposition_comment_is_not_posted() {
1572 let response = ResponseDoc {
1573 summary: "s".into(),
1574 dispositions: vec![],
1575 };
1576 assert!(disposition_comment("claude", &response, &[], &[], &[], &style()).is_none());
1577 }
1578
1579 #[test]
1580 fn a_pr_body_is_what_it_closes_and_what_changed() {
1583 let body = pr_body(42, "Retry on a 429 instead of failing.", &style());
1584 assert_eq!("Closes #42\n\nRetry on a 429 instead of failing.", body);
1585 }
1586
1587 #[test]
1588 fn a_pr_body_survives_a_missing_summary_and_diffstat() {
1589 assert_eq!("Closes #7", pr_body(7, "", &style()));
1590 }
1591
1592 #[test]
1593 fn the_summary_line_is_lifted_out_of_the_final_message() {
1594 let out = "I did some work.\n\nSUMMARY: Retry on a 429 instead of failing.\n";
1595 assert_eq!(
1596 Some("Retry on a 429 instead of failing.".to_string()),
1597 extract_summary(out)
1598 );
1599 }
1600
1601 #[test]
1602 fn a_decorated_summary_line_still_parses() {
1603 assert_eq!(
1604 Some("Did a thing.".to_string()),
1605 extract_summary("**SUMMARY:** Did a thing.")
1606 );
1607 }
1608
1609 #[test]
1610 fn a_missing_summary_line_is_none() {
1611 assert_eq!(None, extract_summary("no marker here"));
1612 }
1613
1614 #[test]
1615 fn the_last_summary_line_wins() {
1616 let out = "SUMMARY: first draft\nmore work\nSUMMARY: final answer";
1617 assert_eq!(Some("final answer".to_string()), extract_summary(out));
1618 }
1619
1620 #[test]
1621 fn a_skip_comment_is_only_the_reasoning() {
1622 let item = SkippedItem {
1623 issue: 3,
1624 title: "t".into(),
1625 reasons: [
1626 ("claude".to_string(), "Already fixed in 1.2.".to_string()),
1627 ("codex".to_string(), "Duplicate of #2.".to_string()),
1628 ]
1629 .into_iter()
1630 .collect(),
1631 };
1632 let text = skip_comment(&item, &style());
1633 assert!(text.contains("Already fixed in 1.2."), "{text}");
1634 assert!(text.contains("Duplicate of #2."), "{text}");
1635 assert!(
1636 !text.contains("claude") && !text.contains("codex"),
1637 "{text}"
1638 );
1639 assert!(!text.to_lowercase().contains("not scheduled"), "{text}");
1640 assert!(text.lines().count() <= 3, "{text}");
1641 }
1642
1643 #[test]
1644 fn findings_for_a_model_keep_full_detail() {
1645 let long = "x".repeat(2000);
1646 let text = findings_for_prompt(&[finding("blocking", "T", &long, "a.rs", true)]);
1647 assert!(
1648 text.contains(&long),
1649 "a model needs the whole finding, only humans need brevity"
1650 );
1651 }
1652
1653 #[test]
1654 fn findings_for_a_model_are_never_empty() {
1655 assert_eq!("(none)", findings_for_prompt(&[]));
1656 }
1657}
1658
1659#[cfg(test)]
1660mod outcome_tests {
1661 use super::*;
1662 use crate::model::{Dispute, Severity};
1663
1664 fn style() -> Style {
1665 Style::default()
1666 }
1667
1668 fn state_with(disputes: Vec<(&str, &str)>, filed: Vec<&str>) -> IssueRun {
1669 let mut s = IssueRun::new(482, "t");
1670 s.disputes = disputes
1671 .into_iter()
1672 .map(|(title, reasoning)| Dispute {
1673 title: title.into(),
1674 reasoning: reasoning.into(),
1675 })
1676 .collect();
1677 s.filed = filed.into_iter().map(String::from).collect();
1678 s
1679 }
1680
1681 fn finding(title: &str, file: &str) -> Finding {
1682 Finding {
1683 severity: Severity::Blocking,
1684 title: title.into(),
1685 detail: "d".into(),
1686 file: file.into(),
1687 in_scope: true,
1688 }
1689 }
1690
1691 #[test]
1694 fn a_clean_approval_says_nothing() {
1695 let state = state_with(vec![], vec![]);
1696 assert!(outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).is_none());
1697 }
1698
1699 #[test]
1700 fn an_approval_that_filed_follow_ups_links_them() {
1701 let state = state_with(
1702 vec![],
1703 vec![
1704 "https://github.com/you/thing/issues/485",
1705 "https://github.com/you/thing/issues/486",
1706 ],
1707 );
1708 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
1709 assert!(text.contains("Filed separately: #485, #486"), "{text}");
1710 }
1711
1712 #[test]
1716 fn running_out_of_rounds_says_what_that_means_for_the_reader() {
1717 let state = state_with(vec![], vec![]);
1718 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
1719 assert!(text.contains("has not been reviewed"), "{text}");
1720 assert!(
1721 !text.to_lowercase().contains("round 3"),
1722 "no round numbers: {text}"
1723 );
1724 assert!(!text.to_lowercase().contains("convergence"), "{text}");
1725 }
1726
1727 #[test]
1728 fn a_deadlock_names_the_point_they_could_not_settle() {
1729 let state = state_with(vec![], vec![]);
1730 let points = [finding("Retry loop never terminates", "src/net.rs:88")];
1731 let text = outcome_comment(
1732 &state,
1733 &Ledger::new(),
1734 &Ending::Deadlocked(&points),
1735 &style(),
1736 )
1737 .unwrap();
1738 assert!(
1739 text.contains("Retry loop never terminates (src/net.rs:88)"),
1740 "{text}"
1741 );
1742 assert!(text.contains("could not settle"), "{text}");
1743 }
1744
1745 #[test]
1747 fn refutations_survive_because_nothing_else_carries_them() {
1748 let state = state_with(
1749 vec![(
1750 "Error is swallowed",
1751 "the caller already validates the file",
1752 )],
1753 vec![],
1754 );
1755 let text = outcome_comment(&state, &Ledger::new(), &Ending::Approved, &style()).unwrap();
1756 assert!(text.contains("Raised and refuted:"), "{text}");
1757 assert!(
1758 text.contains("The caller already validates the file"),
1759 "{text}"
1760 );
1761 }
1762
1763 #[test]
1764 fn no_agent_names_counts_or_round_numbers_reach_the_thread() {
1765 let state = state_with(
1766 vec![("A point", "a reason")],
1767 vec!["https://github.com/you/thing/issues/485"],
1768 );
1769 for ending in [Ending::Approved, Ending::OutOfRounds] {
1770 let text = outcome_comment(&state, &Ledger::new(), &ending, &style()).unwrap();
1771 let lower = text.to_lowercase();
1772 for banned in ["claude", "codex", "blocking,", "nit,", " fixed."] {
1773 assert!(
1774 !lower.contains(banned),
1775 "{banned:?} leaked into the thread:\n{text}"
1776 );
1777 }
1778 for n in 1..9 {
1780 assert!(
1781 !lower.contains(&format!("round {n}")),
1782 "a round number leaked into the thread:\n{text}"
1783 );
1784 }
1785 }
1786 }
1787
1788 #[test]
1789 fn the_whole_comment_stays_short() {
1790 let state = state_with(
1791 vec![("A point", &"long reasoning ".repeat(40))],
1792 vec!["https://github.com/you/thing/issues/485"],
1793 );
1794 let text = outcome_comment(&state, &Ledger::new(), &Ending::OutOfRounds, &style()).unwrap();
1795 assert!(text.len() < 600, "{} chars:\n{text}", text.len());
1796 }
1797
1798 #[test]
1799 fn a_url_that_is_not_an_issue_link_is_left_alone() {
1800 assert_eq!(
1801 "#485",
1802 as_reference("https://github.com/you/thing/issues/485")
1803 );
1804 assert_eq!("note: something", as_reference("note: something"));
1805 }
1806}
1807
1808#[cfg(test)]
1809mod filed_reference_tests {
1810 use super::*;
1811
1812 #[test]
1813 fn an_issue_url_yields_its_number() {
1814 assert_eq!(
1815 Some(485),
1816 filed_issue_number("https://github.com/you/thing/issues/485")
1817 );
1818 }
1819
1820 #[test]
1823 fn a_local_note_yields_nothing() {
1824 assert_eq!(None, filed_issue_number("note: Retry is unbounded"));
1825 assert_eq!(None, filed_issue_number(""));
1826 assert_eq!(
1827 None,
1828 filed_issue_number("https://github.com/you/thing/issues/")
1829 );
1830 }
1831}