1use std::collections::{BTreeMap, BTreeSet, VecDeque};
4use std::io::{Read, Write};
5use std::path::{Path, PathBuf};
6
7use clap::{Args, Parser, Subcommand};
8
9use crate::agent::{self, Agent};
10use crate::checkin;
11use crate::config::{self, Config};
12use crate::error::Result;
13use crate::followups;
14use crate::model::{Issue, IssueRun, ItemKind, Plan, PlanItem, Status};
15use crate::proc::{self, ExecOpts};
16use crate::repo::{Repo, WriteSummary};
17use crate::review;
18use crate::review_only;
19use crate::spend;
20use crate::split;
21use crate::style;
22use crate::tracker;
23use crate::triage;
24use crate::{bail, log, logdim, logging, logwarn, spar_err};
25
26pub const VERSION: &str = env!("CARGO_PKG_VERSION");
27
28#[derive(Parser, Debug)]
29#[command(
30 name = "spar",
31 version = VERSION,
32 about = "Two coding agents alternate implementing and reviewing GitHub issues.",
33 long_about = "Two coding agents alternate implementing and reviewing GitHub issues until a \
34 pull request converges. Neither agent reviews its own most recent edit.\n\n\
35 Arguments are issue numbers for `triage`, and pull request numbers \
36 for `resume`, `review`, and `checkin`. `run` and `split` take either: `run` \
37 continues any pull request given before it triages the issues, and drops an \
38 issue whose pull request was given too. Omit them and \
39 spar takes everything open, up to --limit. `split` applies that limit once to \
40 issues and once to pull requests. `followup` takes none: it works the queue in \
41 .spar/followups.md, and an entry there has no number to name.",
42 max_term_width = 96
43)]
44pub struct Cli {
45 #[arg(short, long, global = true)]
47 pub quiet: bool,
48
49 #[command(subcommand)]
50 pub command: Command,
51}
52
53#[derive(Subcommand, Debug)]
54pub enum Command {
55 Run {
57 issues: Vec<i64>,
59 #[command(flatten)]
60 common: Common,
61 #[command(flatten)]
62 loop_flags: LoopFlags,
63 #[command(flatten)]
64 triage_flags: TriageFlags,
65 #[arg(long, default_value = "plan.json")]
67 plan_out: PathBuf,
68 #[arg(long)]
70 no_worktrees: bool,
71 },
72
73 Followup {
85 #[command(flatten)]
86 common: Common,
87 #[command(flatten)]
88 loop_flags: LoopFlags,
89 #[command(flatten)]
90 triage_flags: TriageFlags,
91 #[arg(long, value_name = "PATH")]
93 file: Option<PathBuf>,
94 #[arg(long)]
96 screen_only: bool,
97 #[arg(long, conflicts_with = "screen_only")]
99 file_only: bool,
100 #[arg(long, default_value = "plan.json")]
102 plan_out: PathBuf,
103 #[arg(long)]
105 no_worktrees: bool,
106 },
107
108 Triage {
110 issues: Vec<i64>,
112 #[command(flatten)]
113 common: Common,
114 #[arg(long, default_value = "plan.json")]
115 plan_out: PathBuf,
116 },
117
118 Resume {
120 prs: Vec<i64>,
122 #[command(flatten)]
123 common: Common,
124 #[command(flatten)]
125 loop_flags: LoopFlags,
126 #[arg(long = "next", value_name = "AGENT")]
128 next_actor: Option<String>,
129 },
130
131 Checkin {
139 items: Vec<i64>,
142 #[command(flatten)]
143 common: Common,
144 #[arg(long)]
146 dry_run: bool,
147 #[arg(long)]
149 reply_only: bool,
150 #[arg(long)]
153 any_author: bool,
154 #[arg(long)]
156 again: bool,
157 #[arg(long)]
159 keep_worktrees: bool,
160 },
161
162 Review {
167 items: Vec<i64>,
170 #[command(flatten)]
171 common: Common,
172 #[arg(long)]
174 dry_run: bool,
175 #[arg(long)]
178 max_rounds: Option<u32>,
179 },
180
181 Split {
198 items: Vec<i64>,
201 #[command(flatten)]
202 common: Common,
203 #[arg(long)]
205 dry_run: bool,
206 #[arg(long)]
209 again: bool,
210 },
211
212 Post {
217 #[arg(required = true)]
219 prs: Vec<i64>,
220 #[arg(long, default_value = ".")]
221 repo: PathBuf,
222 #[arg(long)]
223 config: Option<PathBuf>,
224 #[arg(long, value_name = "PATH")]
226 file: Option<PathBuf>,
227 #[arg(long)]
229 dry_run: bool,
230 },
231
232 Init {
237 #[arg(long, default_value = "spar.toml")]
238 out: PathBuf,
239 #[arg(long)]
241 force: bool,
242 #[arg(long, conflicts_with = "force")]
245 update: bool,
246 },
247
248 Clean {
250 #[arg(long, default_value = ".")]
251 repo: PathBuf,
252 #[arg(long)]
253 config: Option<PathBuf>,
254 #[arg(long)]
257 all: bool,
258 #[arg(long)]
260 pr_state: bool,
261 },
262
263 Doctor {
265 #[arg(long)]
266 config: Option<PathBuf>,
267 },
268
269 #[command(hide = true)]
273 ScrubFilter,
274}
275
276#[derive(Args, Debug, Clone)]
277pub struct Common {
278 #[arg(long, default_value = ".")]
280 pub repo: PathBuf,
281 #[arg(long)]
283 pub config: Option<PathBuf>,
284 #[arg(long)]
286 pub base: Option<String>,
287 #[arg(long)]
289 pub first: Option<String>,
290 #[arg(long, default_value_t = 20)]
293 pub limit: usize,
294 #[arg(long, value_name = "N")]
297 pub min_number: Option<i64>,
298 #[arg(long, value_name = "TEXT")]
301 pub instructions: Option<String>,
302}
303
304#[derive(Args, Debug, Clone)]
305pub struct LoopFlags {
306 #[arg(long)]
309 pub max_rounds: Option<u32>,
310 #[arg(long)]
312 pub auto_merge: bool,
313 #[arg(long)]
315 pub keep_worktrees: bool,
316 #[arg(long, value_name = "N")]
319 pub absorb: Option<u32>,
320}
321
322#[derive(Args, Debug, Clone)]
325pub struct TriageFlags {
326 #[arg(long, conflicts_with = "no_close_skipped")]
328 pub close_skipped: bool,
329 #[arg(long)]
331 pub no_close_skipped: bool,
332 #[arg(long)]
338 pub retriage: bool,
339}
340
341pub fn main() -> i32 {
346 let cli = Cli::parse();
347 logging::init_color();
348 logging::set_quiet(cli.quiet);
349
350 match dispatch(cli) {
351 Ok(code) => code,
352 Err(e) => {
353 logging::error(e.to_string());
354 2
355 }
356 }
357}
358
359fn dispatch(cli: Cli) -> Result<i32> {
360 match cli.command {
361 Command::ScrubFilter => cmd_scrub_filter(),
362 Command::Doctor { config } => cmd_doctor(config.as_deref()),
363 Command::Review {
364 items,
365 common,
366 dry_run,
367 max_rounds,
368 } => {
369 let overrides = Overrides {
370 max_rounds,
371 ..Overrides::default()
372 };
373 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
374 let numbers = if items.is_empty() {
375 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
376 if found.is_empty() {
377 log!("no open PRs");
378 return Ok(0);
379 }
380 log!("no PRs given, reviewing {} open", found.len());
381 found
382 } else {
383 items
384 };
385 let sorted = classify(&repo, &numbers)?;
386 let mut targets = sorted.prs;
387 for number in sorted.issues {
388 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
389 Ok(Some(pr)) => {
390 log!("#{number} is an issue; reviewing its open PR {}", pr.url);
391 targets.push(pr.number);
392 }
393 Ok(None) => {
394 logwarn!("#{number} is an issue with no open pull request to review")
395 }
396 Err(e) => logwarn!("#{number}: {e}"),
397 }
398 }
399 let mut results = Vec::new();
400 for number in targets {
401 results.push(review_only::review_pr(
402 &agents, &cfg, &repo, number, dry_run,
403 ));
404 }
405 if results.is_empty() {
406 return Ok(0);
407 }
408 Ok(report(&results, &cfg, &repo))
409 }
410
411 Command::Checkin {
412 items,
413 common,
414 dry_run,
415 reply_only,
416 any_author,
417 again,
418 keep_worktrees,
419 } => {
420 let overrides = Overrides {
421 keep_worktrees: keep_worktrees.then_some(true),
422 ..Overrides::default()
423 };
424 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
425 let mode = checkin::Mode {
426 dry_run,
427 reply_only,
428 trust: if any_author {
429 crate::config::Trust::Anyone
430 } else {
431 cfg.loop_cfg.checkin_trust
432 },
433 again,
434 resolve: cfg.loop_cfg.checkin_resolve,
435 posts: checkin::posts(&cfg),
436 };
437 let numbers = if items.is_empty() {
438 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
439 if found.is_empty() {
440 log!("no open PRs");
441 return Ok(0);
442 }
443 log!(
444 "no PRs given, checking in on {} open: {}",
445 found.len(),
446 found
447 .iter()
448 .map(|n| format!("#{n}"))
449 .collect::<Vec<_>>()
450 .join(", ")
451 );
452 found
453 } else {
454 items
455 };
456 let sorted = classify(&repo, &numbers)?;
457 let mut results = Vec::new();
458 for number in sorted.prs {
459 results.push(checkin::checkin_pr(&agents, &cfg, &repo, number, &mode));
460 }
461 for number in sorted.issues {
465 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
466 Ok(Some(pr)) => {
467 log!(
468 "#{number} is an issue; checking in on its open PR {}",
469 pr.url
470 );
471 results.push(checkin::checkin_pr(&agents, &cfg, &repo, pr.number, &mode));
472 }
473 Ok(None) => {
474 results.push(checkin::checkin_issue(&agents, &cfg, &repo, number, &mode))
475 }
476 Err(e) => results.push(checkin::failed(number, format!("#{number}"), e)),
481 }
482 }
483 if results.is_empty() {
484 return Ok(0);
485 }
486 Ok(report(&results, &cfg, &repo))
487 }
488
489 Command::Split {
490 items,
491 common,
492 dry_run,
493 again,
494 } => {
495 let (cfg, repo, agents) = prepare(&common, None)?;
496 let mode = split::Mode { dry_run, again };
497 let picked = if items.is_empty() {
498 pick_for_split(&agents, &cfg, &repo, common.limit, &mode)?
499 } else {
500 let sorted = classify(&repo, &items)?;
501 let mut out: Vec<(i64, ItemKind)> = sorted
502 .issues
503 .iter()
504 .map(|n| (*n, ItemKind::Issue))
505 .collect();
506 out.extend(sorted.prs.iter().map(|n| (*n, ItemKind::Pr)));
507 out
508 };
509
510 let mut results = Vec::new();
511 let mut seen: BTreeSet<i64> = BTreeSet::new();
512 for (number, kind) in picked {
513 let target = match kind {
517 ItemKind::Pr => Some(number),
518 ItemKind::Issue => {
519 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
520 Ok(found) => found.map(|pr| {
521 log!("#{number} is an issue; splitting its open PR {}", pr.url);
522 pr.number
523 }),
524 Err(e) => {
525 logwarn!("#{number}: {e}");
526 continue;
527 }
528 }
529 }
530 };
531 if !seen.insert(target.unwrap_or(number)) {
536 logdim!("#{number} was already covered by this run, skipping it");
537 continue;
538 }
539 results.push(match target {
540 Some(pr) => split::split_pr(&agents, &cfg, &repo, pr, &mode),
541 None => split::split_issue(&agents, &cfg, &repo, number, &mode),
542 });
543 }
544 if results.is_empty() {
545 return Ok(0);
546 }
547 Ok(report(&results, &cfg, &repo))
548 }
549
550 Command::Post {
551 prs,
552 repo: repo_path,
553 config,
554 file,
555 dry_run,
556 } => cmd_post(
557 &prs,
558 &repo_path,
559 config.as_deref(),
560 file.as_deref(),
561 dry_run,
562 ),
563
564 Command::Init { out, force, update } => {
565 if update {
566 cmd_init_update(&out)
567 } else {
568 cmd_init(&out, force)
569 }
570 }
571 Command::Clean {
572 repo,
573 config,
574 all,
575 pr_state,
576 } => cmd_clean(&repo, config.as_deref(), all, pr_state),
577 Command::Triage {
578 issues,
579 common,
580 plan_out,
581 } => {
582 let (cfg, repo, agents) = prepare(&common, None)?;
583 let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
584 if numbers.is_empty() {
585 return Ok(0);
586 }
587 let sorted = classify(&repo, &numbers)?;
588 for number in &sorted.prs {
589 log!("#{number} is a pull request, nothing to triage");
590 }
591 if sorted.issues.is_empty() {
592 log!("no issues to triage");
593 return Ok(0);
594 }
595 let issues = repo.fetch_issues(&sorted.issues)?;
596 let plan = make_plan(&agents, &cfg, &repo, &issues, &plan_out)?;
600 if cfg.loop_cfg.decompose_trackers {
604 for item in plan.skipped.iter().filter(|i| i.tracker) {
605 tracker::preview(&cfg, &repo, item.issue);
606 }
607 }
608 Ok(0)
609 }
610 Command::Run {
611 issues,
612 common,
613 loop_flags,
614 triage_flags,
615 plan_out,
616 no_worktrees,
617 } => {
618 let overrides = Overrides::for_working(&loop_flags, &triage_flags, no_worktrees);
619 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
620 let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
621 if numbers.is_empty() {
622 return Ok(0);
623 }
624 let sorted = classify(&repo, &numbers)?;
625 let mut results = Vec::new();
626 let mut stopped = Vec::new();
627 let mut parked = Vec::new();
628
629 let mut issues = sorted.issues.clone();
635 if !sorted.prs.is_empty() {
636 log!(
637 "continuing {} pull request(s) before starting new work: {}",
638 sorted.prs.len(),
639 self::numbers(&sorted.prs)
640 );
641 let covered = issues_covered_by(&repo, &sorted.prs);
642 for (issue, pr) in covered.iter().filter(|(i, _)| issues.contains(i)) {
643 log!(
644 "#{issue} is what PR #{pr} closes, and both were given, so it is \
645 continued there rather than implemented again"
646 );
647 }
648 issues = issues_not_on_named_prs(&issues, &covered);
649 for number in &sorted.prs {
650 results.push(review::resume_pr(&agents, &cfg, &repo, *number, None));
651 }
652 }
653
654 if let Err(e) = work_issues(
655 &agents,
656 &cfg,
657 &repo,
658 issues,
659 &plan_out,
660 &mut results,
661 &mut stopped,
662 &mut parked,
663 triage_flags.retriage,
664 ) {
665 if results.is_empty() {
668 return Err(e);
669 }
670 stopped.push(format!("the issue queue stopped: {e}"));
671 }
672
673 if results.is_empty() {
674 log!("nothing scheduled");
677 for line in parked.iter().chain(stopped.iter()) {
678 println!("{line}");
679 }
680 return Ok(report_writes(&repo).max(i32::from(!stopped.is_empty())));
681 }
682 Ok(report_with(&results, &cfg, &repo, &stopped, &parked))
683 }
684 Command::Followup {
685 common,
686 loop_flags,
687 triage_flags,
688 file,
689 screen_only,
690 file_only,
691 plan_out,
692 no_worktrees,
693 } => {
694 let overrides = Overrides::for_working(&loop_flags, &triage_flags, no_worktrees);
695 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
696 let path = file.unwrap_or_else(|| repo.followups_path());
697 let mode = match (screen_only, file_only) {
698 (true, _) => followups::Mode::ScreenOnly,
699 (_, true) => followups::Mode::FileOnly,
700 _ => followups::Mode::Work,
701 };
702 let outcome = followups::run(&agents, &cfg, &repo, &path, common.limit, mode)?;
703
704 let wave = followups::wave(&outcome);
705 if mode != followups::Mode::Work || wave.is_empty() {
706 return Ok(report_writes(&repo));
707 }
708 let mut results = Vec::new();
709 let mut stopped = Vec::new();
710 let mut parked = Vec::new();
711 work_issues(
712 &agents,
713 &cfg,
714 &repo,
715 wave,
716 &plan_out,
717 &mut results,
718 &mut stopped,
719 &mut parked,
720 triage_flags.retriage,
721 )?;
722 if results.is_empty() {
723 log!("nothing scheduled");
726 for line in parked.iter().chain(stopped.iter()) {
727 println!("{line}");
728 }
729 return Ok(report_writes(&repo).max(i32::from(!stopped.is_empty())));
730 }
731 Ok(report_with(&results, &cfg, &repo, &stopped, &parked))
732 }
733
734 Command::Resume {
735 prs,
736 common,
737 loop_flags,
738 next_actor,
739 } => {
740 let (cfg, repo, agents) = prepare(&common, Some(Overrides::from(&loop_flags)))?;
741 if let Some(name) = &next_actor {
742 if !cfg.has_agent(name) {
743 bail!("--next must be one of: {}", cfg.agent_names().join(", "));
744 }
745 }
746 let numbers = if prs.is_empty() {
747 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
748 if found.is_empty() {
749 log!("no open PRs");
750 return Ok(0);
751 }
752 log!(
753 "no PRs given, taking {} open: {}",
754 found.len(),
755 found
756 .iter()
757 .map(|n| format!("#{n}"))
758 .collect::<Vec<_>>()
759 .join(", ")
760 );
761 found
762 } else {
763 prs
764 };
765 let sorted = classify(&repo, &numbers)?;
766 let mut results = Vec::new();
767 for number in sorted.prs {
768 results.push(review::resume_pr(
769 &agents,
770 &cfg,
771 &repo,
772 number,
773 next_actor.as_deref(),
774 ));
775 }
776 for number in sorted.issues {
779 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
780 Ok(Some(pr)) => {
781 log!("#{number} is an issue; continuing its open PR {}", pr.url);
782 results.push(review::resume_pr(
783 &agents,
784 &cfg,
785 &repo,
786 pr.number,
787 next_actor.as_deref(),
788 ));
789 }
790 Ok(None) => logwarn!(
791 "#{number} is an issue with no open pull request. Use `spar run {number}` \
792 to implement it."
793 ),
794 Err(e) => logwarn!("#{number}: {e}"),
795 }
796 }
797 if results.is_empty() {
798 return Ok(0);
799 }
800 Ok(report(&results, &cfg, &repo))
801 }
802 }
803}
804
805#[derive(Debug, Default, Clone)]
810struct Overrides {
811 max_rounds: Option<u32>,
812 auto_merge: Option<bool>,
813 keep_worktrees: Option<bool>,
814 worktrees: Option<bool>,
815 close_skipped: Option<bool>,
816 absorb: Option<u32>,
817}
818
819impl From<&LoopFlags> for Overrides {
820 fn from(flags: &LoopFlags) -> Self {
821 Self {
822 max_rounds: flags.max_rounds,
823 auto_merge: flags.auto_merge.then_some(true),
824 keep_worktrees: flags.keep_worktrees.then_some(true),
825 worktrees: None,
826 close_skipped: None,
827 absorb: flags.absorb,
828 }
829 }
830}
831
832impl Overrides {
833 fn for_working(loop_flags: &LoopFlags, triage: &TriageFlags, no_worktrees: bool) -> Self {
836 let mut over = Overrides::from(loop_flags);
837 over.worktrees = if no_worktrees { Some(false) } else { None };
838 over.close_skipped = match (triage.close_skipped, triage.no_close_skipped) {
839 (true, _) => Some(true),
840 (_, true) => Some(false),
841 _ => None,
842 };
843 over
844 }
845}
846
847#[allow(clippy::too_many_arguments)]
857fn work_issues(
858 agents: &[Agent],
859 cfg: &Config,
860 repo: &Repo,
861 first_wave: Vec<i64>,
862 plan_out: &Path,
863 results: &mut Vec<IssueRun>,
864 stopped: &mut Vec<String>,
865 parked: &mut Vec<String>,
866 retriage: bool,
867) -> Result<()> {
868 let waiting = repo.read_contested();
872 if retriage && !waiting.is_empty() {
873 repo.forget_contested(&waiting.keys().copied().collect::<Vec<_>>());
874 }
875 let mut handled: BTreeSet<i64> = BTreeSet::new();
876 let mut landed: BTreeSet<i64> = BTreeSet::new();
880 let mut leftover: BTreeSet<i64> = BTreeSet::new();
881 let mut worked = 0usize;
885 let mut queue: VecDeque<Wave> = VecDeque::from([Wave::first(first_wave)]);
886 let mut plans_written = 0usize;
887
888 while let Some(mut wave) = queue.pop_front() {
895 wave.numbers.retain(|n| !handled.contains(n));
896 if !retriage {
897 wave.numbers.retain(|n| {
898 let Some(known) = waiting.get(n) else {
899 return true;
900 };
901 log!(
902 "#{n} was contested on an earlier run and is waiting on you: {}. Pass \
903 --retriage to ask again.",
904 positions_of(known)
905 );
906 parked.push(format!(
907 "#{n} contested and parked for you: {}",
908 positions_of(known)
909 ));
910 false
911 });
912 }
913 if wave.numbers.is_empty() {
914 continue;
915 }
916 match wave.parent {
917 Some(tracker) => log!(
918 "working {} item(s) from the checklist in #{tracker}: {}",
919 wave.numbers.len(),
920 numbers(&wave.numbers)
921 ),
922 None if wave.absorbed > 0 => log!(
923 "absorbing {} newly filed issue(s): {}",
924 wave.numbers.len(),
925 numbers(&wave.numbers)
926 ),
927 None => {}
928 }
929 handled.extend(wave.numbers.iter().copied());
930
931 let fetched = match repo.fetch_issues(&wave.numbers) {
932 Ok(fetched) => fetched,
933 Err(e) => {
934 logdim!("could not read the next wave: {e}");
935 continue;
936 }
937 };
938 let plan_path = if plans_written == 0 {
941 plan_out.to_path_buf()
942 } else {
943 plan_out.with_extension(format!("wave{plans_written}.json"))
944 };
945 plans_written += 1;
946 let plan = match make_plan(agents, cfg, repo, &fetched, &plan_path) {
951 Ok(plan) => plan,
952 Err(e) => {
953 logwarn!("triage failed for {}: {e}", numbers(&wave.numbers));
954 stopped.push(format!(
955 "triage failed for {}, so it was not worked: {}",
956 numbers(&wave.numbers),
957 first_line(&e.to_string())
958 ));
959 for later in &queue {
960 stopped.push(format!(
961 "{} was not reached after that failure",
962 numbers(&later.numbers)
963 ));
964 }
965 queue.clear();
966 break;
967 }
968 };
969 act_on_plan(cfg, repo, &plan);
970 repo.remember_contested(&plan.contested);
971 for item in &plan.contested {
972 parked.push(format!(
973 "#{} contested and parked for you: {}",
974 item.issue,
975 positions(&item.positions, &item.reasons)
976 ));
977 }
978 for item in &plan.skipped {
979 let what = if item.tracker {
980 "left open as a tracker"
981 } else if cfg.loop_cfg.close_skipped {
982 "commented on and closed"
983 } else {
984 "commented on and left open"
985 };
986 parked.push(format!(
987 "#{} skipped by both reviewers, {what}: {}",
988 item.issue,
989 item.reasons
990 .values()
991 .next()
992 .map(|r| first_line(r))
993 .unwrap_or_default()
994 ));
995 }
996
997 if cfg.loop_cfg.decompose_trackers && wave.parent.is_none() {
1001 for item in plan.skipped.iter().filter(|i| i.tracker) {
1002 let children = tracker::decompose(cfg, repo, item.issue);
1003 if !children.is_empty() {
1004 queue.push_back(wave.child(item.issue, children));
1005 }
1006 }
1007 }
1008
1009 let before = results.len();
1010 for item in &plan.order {
1011 let Some(issue) = fetched.iter().find(|i| i.number == item.issue) else {
1012 continue;
1013 };
1014 if let Some(why) = held_back(item, &plan, &landed) {
1015 log!("#{}: {why}", item.issue);
1016 let mut held = IssueRun::new(item.issue, item.title.clone());
1017 held.status = Status::Pending;
1018 held.notes.push(why);
1019 results.push(held);
1020 continue;
1021 }
1022 let run = review::run_nth_issue(agents, cfg, repo, item, issue, worked);
1023 worked += 1;
1024 if run.status == Status::Merged {
1025 landed.insert(item.issue);
1026 }
1027 results.push(run);
1028 }
1029
1030 let filed: Vec<i64> = results[before..]
1032 .iter()
1033 .flat_map(|r| r.filed.iter())
1034 .filter_map(|url| review::filed_issue_number(url))
1035 .filter(|n| !handled.contains(n))
1036 .collect::<BTreeSet<_>>()
1037 .into_iter()
1038 .collect();
1039 if filed.is_empty() {
1040 continue;
1041 }
1042 match wave.absorbed < cfg.loop_cfg.absorb_new_issues {
1043 true => queue.push_back(wave.absorb(filed)),
1044 false => leftover.extend(filed),
1045 }
1046 }
1047 if !leftover.is_empty() && cfg.loop_cfg.absorb_new_issues > 0 {
1048 log!(
1049 "{} issue(s) filed in the last wave were left for a later run: {}",
1050 leftover.len(),
1051 numbers(&leftover.iter().copied().collect::<Vec<_>>())
1052 );
1053 }
1054 Ok(())
1055}
1056
1057fn positions(
1059 positions: &std::collections::BTreeMap<String, String>,
1060 reasons: &std::collections::BTreeMap<String, String>,
1061) -> String {
1062 if positions.is_empty() {
1063 return "no positions recorded".to_string();
1064 }
1065 positions
1066 .iter()
1067 .map(|(name, side)| match reasons.get(name) {
1068 Some(why) if !why.trim().is_empty() => {
1069 format!("{name} says {side} ({})", first_line(why))
1070 }
1071 _ => format!("{name} says {side}"),
1072 })
1073 .collect::<Vec<_>>()
1074 .join("; ")
1075}
1076
1077fn positions_of(known: &crate::model::Contested) -> String {
1078 positions(&known.positions, &known.reasons)
1079}
1080
1081fn held_back(item: &PlanItem, plan: &Plan, landed: &BTreeSet<i64>) -> Option<String> {
1093 for dep in &item.depends_on {
1094 if landed.contains(dep) {
1095 continue;
1096 }
1097 if plan.order.iter().any(|other| other.issue == *dep) {
1098 return Some(format!(
1099 "held back: #{dep} is in this run and has not merged, so its work is not on the \
1100 base this would be built from. Merge #{dep} and run #{} again.",
1101 item.issue
1102 ));
1103 }
1104 if plan.skipped.iter().any(|s| s.issue == *dep) {
1105 return Some(format!(
1106 "held back: #{dep}, which it depends on, was declined by both reviewers"
1107 ));
1108 }
1109 if plan.contested.iter().any(|c| c.issue == *dep) {
1110 return Some(format!(
1111 "held back: the reviewers disagree about #{dep}, which it depends on"
1112 ));
1113 }
1114 }
1115 None
1116}
1117
1118struct Wave {
1120 numbers: Vec<i64>,
1121 absorbed: u32,
1126 parent: Option<i64>,
1128}
1129
1130impl Wave {
1131 fn first(numbers: Vec<i64>) -> Self {
1132 Self {
1133 numbers,
1134 absorbed: 0,
1135 parent: None,
1136 }
1137 }
1138
1139 fn child(&self, tracker: i64, numbers: Vec<i64>) -> Self {
1140 Self {
1141 numbers,
1142 absorbed: self.absorbed,
1143 parent: Some(tracker),
1144 }
1145 }
1146
1147 fn absorb(&self, numbers: Vec<i64>) -> Self {
1148 Self {
1149 numbers,
1150 absorbed: self.absorbed + 1,
1151 parent: None,
1152 }
1153 }
1154}
1155
1156fn numbers(items: &[i64]) -> String {
1157 items
1158 .iter()
1159 .map(|n| format!("#{n}"))
1160 .collect::<Vec<_>>()
1161 .join(", ")
1162}
1163
1164fn prepare(common: &Common, overrides: Option<Overrides>) -> Result<(Config, Repo, Vec<Agent>)> {
1165 let mut cfg = config::load(common.config.as_deref())?;
1166
1167 if let Some(first) = &common.first {
1168 if !cfg.has_agent(first) {
1169 bail!("--first must be one of: {}", cfg.agent_names().join(", "));
1170 }
1171 cfg.first_implementor = first.clone();
1172 }
1173 if let Some(base) = &common.base {
1174 cfg.loop_cfg.base_branch = base.clone();
1175 }
1176 if let Some(min) = common.min_number {
1177 cfg.loop_cfg.min_number = min;
1178 }
1179 if let Some(extra) = common.instructions.as_deref().map(str::trim) {
1183 if !extra.is_empty() {
1184 let standing = cfg.loop_cfg.instructions.trim();
1185 cfg.loop_cfg.instructions = if standing.is_empty() {
1186 extra.to_string()
1187 } else {
1188 format!("{standing}\n{extra}")
1189 };
1190 }
1191 }
1192 if let Some(over) = overrides {
1193 if let Some(v) = over.max_rounds {
1194 if v == 0 {
1195 bail!("--max-rounds must be at least 1");
1196 }
1197 cfg.loop_cfg.max_rounds = v;
1198 }
1199 if let Some(v) = over.auto_merge {
1200 cfg.loop_cfg.auto_merge = v;
1201 }
1202 if let Some(v) = over.keep_worktrees {
1203 cfg.loop_cfg.keep_worktrees = v;
1204 }
1205 if let Some(v) = over.worktrees {
1206 cfg.loop_cfg.worktrees = v;
1207 }
1208 if let Some(v) = over.close_skipped {
1209 cfg.loop_cfg.close_skipped = v;
1210 }
1211 if let Some(v) = over.absorb {
1212 cfg.loop_cfg.absorb_new_issues = v;
1213 }
1214 }
1215
1216 let repo = Repo::open(&common.repo, &cfg)?;
1217 if common.base.is_none() {
1218 cfg.loop_cfg.base_branch = repo.default_branch(cfg.base_branch());
1219 }
1220
1221 let agents = agent::build(&cfg)?;
1222 if let Some(warning) = agent::correlation_warning(&agents) {
1223 logging::warn(warning);
1224 }
1225
1226 for stale in repo.prune_worktrees(false) {
1228 let what = if stale.starts_with("branch ") {
1229 stale
1230 } else {
1231 format!("worktree {stale}")
1232 };
1233 logdim!("cleaned up finished {what}");
1234 }
1235
1236 log!("repo {} base {}", repo.root().display(), cfg.base_branch());
1237 log!(
1238 "agents: {}, first {}",
1239 agents
1240 .iter()
1241 .map(|a| format!("{}={}", a.name(), a.spec.describe()))
1242 .collect::<Vec<_>>()
1243 .join(", "),
1244 cfg.first_implementor_policy()
1245 );
1246 Ok((cfg, repo, agents))
1247}
1248
1249fn pick_issues(repo: &Repo, given: Vec<i64>, limit: usize, min_number: i64) -> Result<Vec<i64>> {
1250 if !given.is_empty() {
1251 if min_number > 0 {
1253 let below: Vec<String> = given
1254 .iter()
1255 .filter(|n| **n < min_number)
1256 .map(|n| format!("#{n}"))
1257 .collect();
1258 if !below.is_empty() {
1259 logdim!(
1260 "{} below the #{min_number} floor, taking them because you named them",
1261 below.join(", ")
1262 );
1263 }
1264 }
1265 return Ok(given);
1266 }
1267 let found = repo.list_open_issues(limit, min_number)?;
1268 if found.is_empty() {
1269 log!("no open issues");
1270 return Ok(found);
1271 }
1272 log!(
1273 "no issues given, taking {} open: {}",
1274 found.len(),
1275 found
1276 .iter()
1277 .map(|n| format!("#{n}"))
1278 .collect::<Vec<_>>()
1279 .join(", ")
1280 );
1281 Ok(found)
1282}
1283
1284fn pick_for_split(
1297 agents: &[Agent],
1298 cfg: &Config,
1299 repo: &Repo,
1300 limit: usize,
1301 mode: &split::Mode,
1302) -> Result<Vec<(i64, ItemKind)>> {
1303 let issues = repo.list_open_issues(limit, cfg.loop_cfg.min_number)?;
1304 let prs = repo.list_open_prs(limit, cfg.loop_cfg.min_number)?;
1305 log!(
1306 "no numbers given, considering {} open issue(s) and {} open pull request(s)",
1307 issues.len(),
1308 prs.len()
1309 );
1310 if issues.is_empty() && prs.is_empty() {
1311 return Ok(Vec::new());
1312 }
1313
1314 let issue_rows = repo.open_issue_rows();
1315 let pr_rows = repo.open_pr_rows();
1316 let mut candidates: Vec<split::Candidate> = Vec::new();
1317 let mut already = 0usize;
1318
1319 for number in &issues {
1320 let Some(row) = issue_rows.iter().find(|i| i.number == *number) else {
1321 logdim!("could not read #{number}, leaving it alone");
1322 continue;
1323 };
1324 if split::already_split(row.body_text()) && !mode.again {
1327 already += 1;
1328 continue;
1329 }
1330 candidates.push(split::Candidate::from_issue(row));
1331 }
1332 for number in &prs {
1333 match pr_rows.iter().find(|p| p.number == *number) {
1334 Some(row) => candidates.push(split::Candidate::from_pr(row)),
1335 None => logdim!("could not read PR #{number}, leaving it alone"),
1336 }
1337 }
1338 if already > 0 {
1339 log!("{already} issue(s) already split, skipped. --again reopens them.");
1340 }
1341 if candidates.is_empty() {
1342 return Ok(Vec::new());
1343 }
1344
1345 let agent = agent::find(agents, &cfg.first_implementor)?;
1346 log!(
1347 "screening {} item(s) with {}",
1348 candidates.len(),
1349 agent.name()
1350 );
1351 let verdicts = split::screen(agent, cfg, repo, &candidates)?;
1352
1353 let mut picked = Vec::new();
1354 for candidate in &candidates {
1355 match verdicts.iter().find(|v| v.item == candidate.number) {
1356 Some(v) if v.split => {
1357 log!(" split #{}: {}", candidate.number, v.reason.trim());
1358 picked.push((candidate.number, candidate.kind));
1359 }
1360 Some(v) => logdim!(" leave #{} whole: {}", candidate.number, v.reason.trim()),
1363 None => logdim!(" no verdict for #{}, leaving it whole", candidate.number),
1364 }
1365 }
1366 if picked.is_empty() {
1367 log!("nothing worth splitting");
1368 }
1369 Ok(picked)
1370}
1371
1372#[derive(Debug, Default)]
1378struct Sorted {
1379 issues: Vec<i64>,
1380 prs: Vec<i64>,
1381}
1382
1383fn classify(repo: &Repo, numbers: &[i64]) -> Result<Sorted> {
1384 let mut sorted = Sorted::default();
1385 for number in numbers {
1386 match repo.item_kind(*number)? {
1387 ItemKind::Issue => sorted.issues.push(*number),
1388 ItemKind::Pr => sorted.prs.push(*number),
1389 }
1390 }
1391 if !sorted.issues.is_empty() && !sorted.prs.is_empty() {
1392 log!(
1393 "{} issue(s) and {} pull request(s) given",
1394 sorted.issues.len(),
1395 sorted.prs.len()
1396 );
1397 }
1398 Ok(sorted)
1399}
1400
1401fn issues_covered_by(repo: &Repo, prs: &[i64]) -> BTreeMap<i64, i64> {
1414 let mut covered = BTreeMap::new();
1415 for number in prs {
1416 match repo.pr_view(*number) {
1417 Ok(view) => {
1418 for issue in &view.closing_issues_references {
1419 covered.insert(issue.number, *number);
1420 }
1421 }
1422 Err(e) => logwarn!(
1423 "#{number}: could not read which issue(s) it closes, so none are skipped: {}",
1424 e.last_line()
1425 ),
1426 }
1427 }
1428 covered
1429}
1430
1431fn issues_not_on_named_prs(issues: &[i64], covered: &BTreeMap<i64, i64>) -> Vec<i64> {
1434 issues
1435 .iter()
1436 .copied()
1437 .filter(|issue| !covered.contains_key(issue))
1438 .collect()
1439}
1440
1441fn make_plan(
1442 agents: &[Agent],
1443 cfg: &Config,
1444 repo: &Repo,
1445 issues: &[Issue],
1446 plan_out: &Path,
1447) -> Result<Plan> {
1448 let plan = triage::triage(agents, cfg, repo, issues)?;
1449
1450 std::fs::write(plan_out, serde_json::to_vec_pretty(&plan)?)
1451 .map_err(|e| spar_err!("could not write {}: {e}", plan_out.display()))?;
1452 log!("plan written to {}", plan_out.display());
1453
1454 for item in &plan.order {
1455 let after = if item.depends_on.is_empty() {
1458 String::new()
1459 } else {
1460 format!(
1461 " after {}",
1462 item.depends_on
1463 .iter()
1464 .map(|n| format!("#{n}"))
1465 .collect::<Vec<_>>()
1466 .join(", ")
1467 )
1468 };
1469 log!(
1470 " do #{} [{}/{}]{after} {}",
1471 item.issue,
1472 item.complexity,
1473 item.risk,
1474 item.title
1475 );
1476 }
1477 for item in &plan.skipped {
1478 if item.tracker {
1479 log!(
1480 " hold #{} (both reviewers: tracks work filed elsewhere)",
1481 item.issue
1482 );
1483 } else {
1484 log!(" skip #{} (both reviewers: not worth doing)", item.issue);
1485 }
1486 }
1487 for item in &plan.contested {
1488 log!(" ?? #{} contested, parked for you to decide", item.issue);
1489 }
1490 Ok(plan)
1491}
1492
1493fn act_on_plan(cfg: &Config, repo: &Repo, plan: &Plan) {
1503 for item in &plan.skipped {
1504 let body = review::skip_comment(item, &repo.style);
1505 let close = cfg.loop_cfg.close_skipped && !item.tracker;
1506 let outcome = if close {
1507 repo.close_issue(item.issue, &body)
1508 } else {
1509 repo.comment_issue(item.issue, &body)
1510 };
1511 match outcome {
1512 Ok(()) if close => log!(" closed #{}", item.issue),
1513 Ok(()) if item.tracker => {
1514 log!(
1515 " left #{} open, it tracks work filed elsewhere",
1516 item.issue
1517 )
1518 }
1519 Ok(()) => {}
1520 Err(e) => logdim!("could not update #{}: {e}", item.issue),
1521 }
1522 }
1523}
1524
1525fn cmd_scrub_filter() -> Result<i32> {
1530 let mut input = String::new();
1531 std::io::stdin()
1532 .read_to_string(&mut input)
1533 .map_err(|e| spar_err!("could not read a commit message from stdin: {e}"))?;
1534 let out = style::scrub(&input, &crate::repo::style_from_env());
1535 let mut stdout = std::io::stdout();
1536 stdout
1537 .write_all(out.as_bytes())
1538 .and_then(|_| stdout.write_all(b"\n"))
1539 .map_err(|e| spar_err!("could not write the scrubbed message: {e}"))?;
1540 Ok(0)
1541}
1542
1543fn cmd_clean(
1544 repo_path: &Path,
1545 config_path: Option<&Path>,
1546 all: bool,
1547 pr_state: bool,
1548) -> Result<i32> {
1549 let cfg = config::load(config_path)?;
1550 let repo = Repo::open(repo_path, &cfg)?;
1551 let mut removed = repo.prune_worktrees(all);
1552 removed.extend(repo.prune_state());
1553 if pr_state {
1554 removed.extend(repo.prune_pr_state(None));
1555 }
1556 if removed.is_empty() {
1557 if repo.write_summary().failed > 0 {
1558 println!("nothing removed");
1559 } else {
1560 println!("nothing to clean");
1561 }
1562 } else {
1563 for item in removed {
1564 println!("removed {item}");
1565 }
1566 }
1567 Ok(report_writes(&repo))
1568}
1569
1570fn cmd_post(
1572 prs: &[i64],
1573 repo_path: &Path,
1574 config_path: Option<&Path>,
1575 file: Option<&Path>,
1576 dry_run: bool,
1577) -> Result<i32> {
1578 let cfg = config::load(config_path)?;
1579 let repo = Repo::open(repo_path, &cfg)?;
1580
1581 if file.is_some() && prs.len() > 1 {
1582 bail!("--file posts one review, so give it one pull request number");
1583 }
1584
1585 let mut failed = false;
1586 for number in prs {
1587 let text = match file {
1588 Some(path) => std::fs::read_to_string(path)
1589 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?,
1590 None => match repo.read_pending_comment(*number) {
1591 Some(text) => text,
1592 None => {
1593 logging::error(format!(
1594 "no saved review for PR #{number}. `spar review {number} --dry-run` \
1595 produces one, or pass --file."
1596 ));
1597 failed = true;
1598 continue;
1599 }
1600 },
1601 };
1602 if text.trim().is_empty() {
1603 logging::error(format!("the saved review for PR #{number} is empty"));
1604 failed = true;
1605 continue;
1606 }
1607 if dry_run {
1608 println!("\n{}\n", text.trim());
1609 log!("would post the above to PR #{number}");
1610 continue;
1611 }
1612 match repo.comment_pr(*number, &text) {
1615 Ok(()) => log!("posted to PR #{number}"),
1616 Err(e) => {
1617 logging::error(format!("could not post to PR #{number}: {e}"));
1618 failed = true;
1619 }
1620 }
1621 }
1622 Ok(i32::from(failed).max(report_writes(&repo)))
1623}
1624
1625fn cmd_init_update(out: &Path) -> Result<i32> {
1631 let text = std::fs::read_to_string(out)
1632 .map_err(|e| spar_err!("could not read {}: {e}", out.display()))?;
1633 config::parse(&text).map_err(|e| spar_err!("{} does not parse: {e}", out.display()))?;
1636
1637 let unset = config::unmentioned_options(&text);
1638 if unset.is_empty() {
1639 println!("{} already mentions every setting.", out.display());
1640 return Ok(0);
1641 }
1642
1643 let mut block = String::new();
1644 if !text.ends_with('\n') {
1645 block.push('\n');
1646 }
1647 block.push_str("\n# Added by `spar init --update`: settings this file did not mention,\n");
1648 block.push_str("# shown at their defaults. Uncomment one to change it.\n");
1649 let mut section = "";
1650 for option in &unset {
1651 if option.section != section {
1652 section = option.section;
1653 block.push_str(&format!("\n# [{section}]\n"));
1654 }
1655 block.push('\n');
1660 block.push_str(&wrap_comment(note_for(&option.key)));
1661 block.push_str(&format!("# {} = {}\n", option.key, option.default));
1662 }
1663
1664 use std::io::Write;
1665 std::fs::OpenOptions::new()
1666 .append(true)
1667 .open(out)
1668 .and_then(|mut f| f.write_all(block.as_bytes()))
1669 .map_err(|e| spar_err!("could not append to {}: {e}", out.display()))?;
1670
1671 println!(
1672 "added {} setting(s) to {} as comments",
1673 unset.len(),
1674 out.display()
1675 );
1676 Ok(0)
1677}
1678
1679fn cmd_init(out: &Path, force: bool) -> Result<i32> {
1680 if out.exists() && !force {
1681 logging::error(format!(
1682 "{} already exists. `--update` appends any settings it does not mention, \
1683 `--force` overwrites it.",
1684 out.display()
1685 ));
1686 return Ok(1);
1687 }
1688
1689 let presets = config::available_presets();
1690 if presets.is_empty() {
1691 bail!("no presets available, which should be impossible in a released build");
1692 }
1693
1694 let mut found: Vec<(String, PathBuf, config::AgentSpec)> = Vec::new();
1695 for name in &presets {
1696 let raw = config::load_preset(name)?;
1697 let mut spec: config::AgentSpec = match raw
1701 .as_table()
1702 .cloned()
1703 .ok_or_else(|| spar_err!("not a table"))
1704 .and_then(|t| {
1705 toml::Value::Table(t)
1706 .try_into()
1707 .map_err(|e| spar_err!("{e}"))
1708 }) {
1709 Ok(spec) => spec,
1710 Err(e) => {
1711 println!(" BROKEN {name:10} {}", e.first_line());
1712 continue;
1713 }
1714 };
1715 spec.name = name.clone();
1716 match Agent::new(spec.clone()).resolve_bin() {
1717 Ok(path) => {
1718 println!(" found {name:10} {}", path.display());
1719 found.push((name.clone(), path.to_path_buf(), spec));
1720 }
1721 Err(_) => println!(" missing {name}"),
1722 }
1723 }
1724
1725 if found.len() < 2 {
1726 logging::error(format!(
1727 "need two agent CLIs, found {}. Install another, or write {} by hand using the \
1728 presets as a reference.",
1729 found.len(),
1730 out.display()
1731 ));
1732 return Ok(1);
1733 }
1734
1735 let chosen: Vec<&(String, PathBuf, config::AgentSpec)> = found.iter().take(2).collect();
1737 if found.len() > 2 {
1738 log!(
1739 "{} agents available, picking {} and {}. Edit {} to change.",
1740 found.len(),
1741 chosen[0].0,
1742 chosen[1].0,
1743 out.display()
1744 );
1745 }
1746
1747 let mut text = String::from(
1748 "# Generated by `spar init`. Each agent inherits a command template from a\n\
1749 # built in preset; anything set here overrides it.\n\
1750 #\n\
1751 # Commented lines are the other options, each with a working value.\n\
1752 # Uncomment one to change it.\n\n",
1753 );
1754 for (name, _, spec) in &chosen {
1755 text.push_str(&agent_block(name, spec));
1756 }
1757 text.push_str(&settings_block(&chosen[0].0));
1758
1759 std::fs::write(out, text).map_err(|e| spar_err!("could not write {}: {e}", out.display()))?;
1760 println!("\nwrote {}", out.display());
1761 println!("Next: `spar doctor` to check it, then `spar run` in a repo you have push access to.");
1762 Ok(0)
1763}
1764
1765fn report_fallback(agent: &Agent) {
1771 let Some(backup) = agent.fallback() else {
1772 return;
1773 };
1774 match backup.resolve_bin() {
1775 Ok(bin) => println!(
1776 " fallback {} ({})",
1777 bin.display(),
1778 backup.spec.describe()
1779 ),
1780 Err(_) => println!(
1781 " fallback {} not found, so it will not stand in. Set {} to its path.",
1782 backup.program(),
1783 backup.env_key()
1784 ),
1785 }
1786}
1787
1788type Setting = (bool, &'static str, &'static str);
1799
1800const LOOP_OPTIONS: &[Setting] = &[
1801 (false, "max_rounds", "In the custody loop, review rounds one invocation may spend asking for changes. A full-budget run may then use one extra closing pass. In `spar review`, this selects up to three phases: independent review, cross-adjudication, and rebuttal. Resuming a custody run grants a fresh budget, so this is not a lifetime cap."),
1802 (false, "auto_merge", "Merge when no blocking findings remain. Off on purpose: two models agreeing is not the same as being right, and neither carries the consequences."),
1803 (false, "first_implementor", "Which agent takes the first pass. The other one reviews it."),
1804 (false, "worktrees", "Isolate each issue in its own git worktree. Set false to work in the main checkout."),
1805 (false, "close_skipped", "Close an issue both reviewers declined, after posting the shared reasoning. A tracking issue is left open whatever this says."),
1806 (false, "followups", "Where a follow-up goes. issues files them, local writes .spar/followups.md and leaves the tracker alone, none drops them. `spar followup` works that file."),
1807 (true, "file_non_blocking", "File a non-blocking finding as a follow-up. Off, because not gating a merge is not the same as deserving somebody's triage queue."),
1808 (true, "max_followups", "Most follow-ups one run may record before it stops and says what it dropped. A backstop, not a target. `spar followup` is bounded by --limit instead."),
1809 (true, "max_split_parts", "Most parts `spar split` will make out of one issue or pull request. A backstop against a queue nobody asked for, not a target, and what it holds back is said out loud. A part is never itself split, so if one is still too big, `spar split <part>` is one command away."),
1810 (true, "check", "A command spar runs in the worktree after an editing call committed and before the branch is pushed, as an argv list or a string split on whitespace. Unset runs nothing. \"I ran the tests\" is the one claim in a report that nothing else checks, and a broken push costs the reviewer's round on a tree that does not build. On failure the output goes back to the same agent once; a second failure ends the round with the worktree kept and nothing pushed."),
1811 (true, "keep_worktrees", "Keep worktrees after a run, for inspection."),
1812 (true, "min_number", "Ignore issues and pull requests numbered below this when spar picks for itself. 0 is no floor, and a number you name explicitly is always honoured."),
1813 (true, "parallel_triage", "Ask both agents to triage at once. They only read during triage, so there is nothing to serialise."),
1814 (true, "absorb_new_issues", "Waves of newly filed follow-ups to fold back into this run rather than leaving them for the next one. Multiplies what a run costs."),
1815 (true, "decompose_trackers", "Turn the checklist in a tracking issue into issues, link each item to the one covering it, tick an item off when its issue closes, and work the children in this run. Only ever acts on markdown task list items, so it is opt in per issue as well. `spar triage` prints what it would do without writing any of it."),
1816 (true, "max_tracker_children", "Most unchecked items from one tracker's checklist that one run will act on. A cap, not a target, and what it left is named out loud."),
1817 (true, "file_nits", "File nits as follow-ups too. Off, because a filed nit is somebody else's notification."),
1818 (true, "base_branch", "Only a fallback. Whatever origin/HEAD points at wins when it resolves."),
1819 (true, "branch_prefix", "Namespace the branches spar creates, for example \"spar/\". Without it they are issue-N, pr-N, and split-N-I with a suffix on repeated split names."),
1820 (true, "state_store", "Where resume state is kept. local uses .spar/state and keeps it off the pull request."),
1821 (true, "drafts", "Whether a pull request starts as a draft. until_approved opens one and marks it ready when the review converges, which is what the draft was saying while two agents were still arguing about it. always opens one and leaves it, and cannot be combined with auto_merge."),
1822 (true, "instructions", "Extra instructions handed to both agents with every request, for what this repository always wants that spar has no setting for. --instructions adds to this for one run."),
1823 (true, "max_issue_chars", "Most of one issue body that reaches a prompt. Sized so nothing a person wrote is cut, and a cut is said out loud when it happens."),
1824 (true, "max_triage_chars", "Most every issue body together may add to one triage prompt, or every recorded follow-up in one screening prompt. Past it, whole items wait for the next run rather than all of them losing their tails."),
1825 (true, "checkin_trust", "Whose comments `spar checkin` will act on. write is anybody GitHub says can write to this repository, which is the default because acting on a comment means pushing a commit to somebody's branch. anyone answers everyone, and still only changes code when both agents agree."),
1826 (true, "checkin_resolve", "Mark a review thread resolved when spar made the change it asked for. A thread spar disagreed with is left open whatever this says."),
1827 (true, "max_checkin_comments", "Most unanswered comments spar will answer on one pull request in a run. A backstop against a long argument being read back to somebody, not a target."),
1828];
1829
1830const STYLE_OPTIONS: &[Setting] = &[
1831 (false, "ban_em_dash", "Strip em-dashes and en-dashes from everything spar posts, then refuse to post text that still has one."),
1832 (false, "ban_ai_attribution", "Strip mentions of the tooling, and Co-Authored-By trailers, from everything spar posts."),
1833 (false, "terse", "Hold model prose to a length budget. false removes the valves entirely."),
1834 (true, "pr_comments", "How much of its own working spar narrates into a pull request thread. outcome is one comment at the end, rounds is an audit trail, none never comments at all."),
1835 (true, "max_title_chars", "A finding, issue, or pull request title. Never ellipsised: a title ending in three dots reads as broken."),
1836 (true, "max_summary_chars", "A one line verdict, or a refutation's argument."),
1837 (true, "max_detail_chars", "A blocking finding's explanation, as it appears in the pull request thread."),
1838 (true, "max_body_chars", "A pull request body."),
1839 (true, "max_issue_body_chars", "A filed issue's body. Far larger on purpose: an issue is picked up cold months later. Fenced code blocks are never truncated and never count against it."),
1840];
1841
1842fn settings_block(first_implementor: &str) -> String {
1847 let defaults: std::collections::BTreeMap<String, String> = config::known_options()
1848 .into_iter()
1849 .map(|option| (option.key, option.default))
1850 .collect();
1851 let value = |key: &str| match key {
1854 "first_implementor" => format!("\"{first_implementor}\""),
1855 other => defaults.get(other).cloned().unwrap_or_default(),
1856 };
1857
1858 let mut out = String::from("[loop]\n");
1859 out.push_str(&option_lines(LOOP_OPTIONS, &value));
1860 out.push_str(concat!(
1861 "\n[loop.effort_schedule]\n",
1862 "# One key per kind of call, so a value means what its name says. Values\n",
1863 "# are whatever each agent's own CLI accepts, listed above, so these are\n",
1864 "# examples rather than defaults. Left out, a call falls back to\n",
1865 "# round_1 or rest, and then to the effort the agent's own block asked\n",
1866 "# for. Where the two CLIs do not share a vocabulary, put the schedule\n",
1867 "# under [agents.NAME.effort_schedule] instead, with the same keys.\n",
1868 "# triage = \"low\" # both agents, over the whole queue, per wave\n",
1869 "# implement = \"high\" # writing the first draft\n",
1870 "# review_1 = \"high\" # the deep first review\n",
1871 "# review_rest = \"low\" # later rounds, which see a small delta\n",
1872 "# respond = \"high\" # the author answering a review\n",
1873 "# close = \"low\" # the closing merge safety pass\n",
1874 "# screen = \"low\" # the follow-up screen, one call for the queue\n",
1875 "# checkin = \"high\" # judging comments other people left\n",
1876 "# split = \"high\" # proposing and checking a split\n",
1877 "# round_1 = \"high\" # read by any call above with no key set\n",
1878 "# rest = \"low\" # the same, for later rounds and the close\n\n",
1879 ));
1880 out.push_str("[style]\n");
1881 out.push_str(&option_lines(STYLE_OPTIONS, &value));
1882 out
1883}
1884
1885fn option_lines(options: &[Setting], value: &dyn Fn(&str) -> String) -> String {
1889 let mut out = String::new();
1890 for (commented, key, note) in options {
1891 if !out.is_empty() {
1892 out.push('\n');
1893 }
1894 out.push_str(&wrap_comment(note));
1895 let lead = if *commented { "# " } else { "" };
1896 out.push_str(&format!("{lead}{key} = {}\n", value(key)));
1897 }
1898 out
1899}
1900
1901type Probe = Box<dyn Fn() -> Result<String>>;
1904
1905fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
1912 let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
1913
1914 if let Some(extra) = &spec.options_note {
1926 if !spec.models.is_empty() || !spec.efforts.is_empty() {
1927 out.push('\n');
1928 out.push_str(&wrap_comment(extra));
1929 }
1930 }
1931 for (key, choices) in [("model", &spec.models), ("effort", &spec.efforts)] {
1932 let Some(suggested) = choices.first() else {
1933 continue;
1934 };
1935 let mut note = format!("Omit {key} to use the CLI's own default.");
1936 if choices.len() > 1 {
1937 note.push_str(&format!(" One of: {}.", choices.join(" | ")));
1938 }
1939 out.push('\n');
1940 out.push_str(&wrap_comment(¬e));
1941 out.push_str(&format!("# {key} = \"{suggested}\"\n"));
1942 }
1943
1944 out.push('\n');
1948 out.push_str(&wrap_comment(
1949 "Seconds one call may take before spar gives up. A timeout costs the whole call and is \
1950 never retried, so err long.",
1951 ));
1952 out.push_str(&format!("# timeout = {}\n", spec.timeout));
1953
1954 let backup = if name == "cursor" { "gemini" } else { "cursor" };
1957 out.push('\n');
1958 out.push_str(&wrap_comment(
1959 "A stand in for when this CLI refuses, stalls, or runs out of quota. It answers in place \
1960 of this agent, never alongside it.",
1961 ));
1962 out.push_str(&format!(
1963 "# [agents.{name}.fallback]\n# preset = \"{backup}\"\n"
1964 ));
1965
1966 out.push('\n');
1971 out.push_str(&wrap_comment(
1972 "command, output, search_paths and the rest are in spar.example.toml, for pairing a CLI \
1973 that has no preset.",
1974 ));
1975 out.push('\n');
1976 out
1977}
1978
1979fn note_for(key: &str) -> &'static str {
1985 LOOP_OPTIONS
1986 .iter()
1987 .chain(STYLE_OPTIONS)
1988 .find(|(_, name, _)| *name == key)
1989 .map(|(_, _, note)| *note)
1990 .unwrap_or("")
1991}
1992
1993fn wrap_comment(text: &str) -> String {
1995 const WIDTH: usize = 76;
1996 let mut out = String::new();
1997 let mut line = String::from("#");
1998 for word in text.split_whitespace() {
1999 if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
2000 out.push_str(&line);
2001 out.push('\n');
2002 line = String::from("#");
2003 }
2004 line.push(' ');
2005 line.push_str(word);
2006 }
2007 if line.len() > 1 {
2008 out.push_str(&line);
2009 out.push('\n');
2010 }
2011 out
2012}
2013
2014fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
2015 let mut ok = true;
2016
2017 let probes: Vec<(&str, Probe)> = vec![
2018 (
2019 "git",
2020 Box::new(|| {
2021 proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
2022 .map(|s| first_line(&s))
2023 }),
2024 ),
2025 (
2026 "gh",
2027 Box::new(|| {
2028 proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
2029 .map(|s| first_line(&s))
2030 }),
2031 ),
2032 (
2033 "gh auth",
2034 Box::new(|| {
2035 let out = proc::exec(
2036 &["gh".into(), "auth".into(), "status".into()],
2037 &ExecOpts::new().check(false).timeout_secs(60),
2038 )?;
2039 let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
2040 if out.ok() {
2041 Ok(first_line(&text))
2042 } else {
2043 Err(spar_err!("not authenticated. Run `gh auth login`."))
2044 }
2045 }),
2046 ),
2047 ];
2048
2049 for (label, probe) in probes {
2050 match probe() {
2051 Ok(detail) => println!(" ok {label:12} {detail}"),
2052 Err(e) => {
2053 println!(" FAIL {label:12} {}", e.first_line());
2054 ok = false;
2055 }
2056 }
2057 }
2058
2059 let found = config::find_config(config_path)?;
2060 let Some(path) = found else {
2061 println!("\n no spar.toml found. Run `spar init` to generate one.");
2062 println!(
2063 " presets available: {}",
2064 config::available_presets().join(", ")
2065 );
2066 return Ok(if ok { 0 } else { 1 });
2067 };
2068
2069 println!("\n config: {}", path.display());
2070 let cfg = match config::load(Some(&path)) {
2071 Ok(cfg) => cfg,
2072 Err(e) => {
2073 println!(" FAIL config {e}");
2074 return Ok(1);
2075 }
2076 };
2077
2078 let mut resolved = Vec::new();
2081 for spec in &cfg.agents {
2082 let agent = Agent::new(spec.clone());
2083 match agent.resolve_bin() {
2084 Ok(bin) => {
2085 println!(
2086 " ok {:12} {} ({})",
2087 spec.name,
2088 bin.display(),
2089 spec.describe()
2090 );
2091 report_fallback(&agent);
2092 resolved.push(agent);
2093 }
2094 Err(e) => {
2095 println!(" FAIL {:12} {}", spec.name, e.first_line());
2096 ok = false;
2097 }
2098 }
2099 }
2100
2101 if resolved.len() == cfg.agents.len() {
2102 if let Some(warning) = agent::correlation_warning(&resolved) {
2103 println!("\n WARNING {warning}");
2104 }
2105 }
2106
2107 for line in unknown_effort_values(&cfg) {
2108 println!("\n WARNING {line}");
2109 }
2110
2111 println!(
2112 "\n settings: first={} max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
2113 cfg.first_implementor_policy(),
2114 cfg.loop_cfg.max_rounds,
2115 cfg.loop_cfg.auto_merge,
2116 cfg.loop_cfg.worktrees,
2117 cfg.loop_cfg.followups,
2118 cfg.style.terse
2119 );
2120 if let Ok(text) = std::fs::read_to_string(&path) {
2124 let unset = config::unmentioned_options(&text);
2125 if !unset.is_empty() {
2126 println!(
2127 "\n {} setting(s) this config does not mention, all at their defaults:",
2128 unset.len()
2129 );
2130 for option in &unset {
2131 println!(
2132 " [{}] {} = {}",
2133 option.section, option.key, option.default
2134 );
2135 }
2136 println!(
2137 " `spar init --update {}` appends them as comments.",
2138 path.display()
2139 );
2140 }
2141 }
2142
2143 println!(
2144 "{}",
2145 if ok {
2146 "\nready"
2147 } else {
2148 "\nmissing prerequisites"
2149 }
2150 );
2151 Ok(if ok { 0 } else { 1 })
2152}
2153
2154fn unknown_effort_values(cfg: &config::Config) -> Vec<String> {
2163 let mut out = Vec::new();
2164 for spec in &cfg.agents {
2165 if spec.efforts.is_empty() {
2166 continue;
2167 }
2168 let mut said: Vec<String> = Vec::new();
2169 for call in config::Call::every() {
2170 let Some(value) = cfg.effort_for(spec, call) else {
2171 continue;
2172 };
2173 let known = spec
2174 .efforts
2175 .iter()
2176 .any(|hint| hint.eq_ignore_ascii_case(value.trim()));
2177 if known || said.contains(&value) {
2178 continue;
2179 }
2180 said.push(value.clone());
2181 out.push(format!(
2182 "{} is asked for effort \"{value}\" on the {} call, which is not one it is known \
2183 to accept ({}). Put this agent's values under \
2184 [agents.{}.effort_schedule].",
2185 spec.name,
2186 call.key(),
2187 spec.efforts.join(" | "),
2188 spec.name
2189 ));
2190 }
2191 }
2192 out
2193}
2194
2195fn first_line(text: &str) -> String {
2196 text.trim().lines().next().unwrap_or("").trim().to_string()
2197}
2198
2199fn report(results: &[IssueRun], cfg: &Config, repo: &Repo) -> i32 {
2204 report_with(results, cfg, repo, &[], &[])
2205}
2206
2207fn report_with(
2214 results: &[IssueRun],
2215 cfg: &Config,
2216 repo: &Repo,
2217 stopped: &[String],
2218 parked: &[String],
2219) -> i32 {
2220 let spent = spend::taken();
2223 if let Err(e) = repo.record_spend(&spent) {
2224 logdim!("could not record what this run spent: {e}");
2225 }
2226 println!("\n{}", "=".repeat(60));
2227 for r in results {
2228 println!(
2229 "#{:<5} {:<10} rounds={} {}",
2230 r.issue,
2231 r.status.to_string(),
2232 r.rounds,
2233 r.pr.as_deref().unwrap_or("")
2234 );
2235 for note in &r.notes {
2236 println!(" {}", first_line(note));
2237 }
2238 for url in &r.filed {
2239 println!(" filed {url}");
2240 }
2241 for dispute in &r.disputes {
2242 println!(
2243 " disputed: {}",
2244 report_item(&dispute.title, &dispute.file)
2245 );
2246 }
2247 for finding in &r.noted {
2248 println!(
2249 " noted, not blocking: {}",
2250 report_item(&finding.title, &finding.file)
2251 );
2252 }
2253 if let Some(cost) = spend::for_subject(&spent, r.issue) {
2254 println!(" {cost}");
2255 }
2256 }
2257 println!("{}", "=".repeat(60));
2258
2259 if cfg.alternate_first && results.len() > 1 {
2260 println!(
2261 "\nfirst implementor alternated, starting with {}. Who wrote each first draft is in \
2262 .spar/state/implementors.json.",
2263 cfg.first_implementor
2264 );
2265 }
2266 if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
2267 println!("\nApproved PRs are waiting on you to merge.");
2268 }
2269 let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
2270 if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
2271 println!(
2272 "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
2273 Set followups = \"issues\" to file them."
2274 );
2275 }
2276 if let Some(summary) = spend::summary(&spent) {
2277 println!("\n{summary}");
2278 }
2279 for lines in [parked, stopped] {
2280 if lines.is_empty() {
2281 continue;
2282 }
2283 println!();
2284 for line in lines {
2285 println!("{line}");
2286 }
2287 }
2288 let issue_exit = i32::from(!results.iter().all(IssueRun::succeeded) || !stopped.is_empty());
2289 issue_exit.max(report_writes(repo))
2290}
2291
2292fn report_writes(repo: &Repo) -> i32 {
2297 let writes = repo.write_summary();
2298 if let Some(line) = write_summary_line(writes) {
2299 println!("\n{line}");
2300 }
2301 write_exit_code(writes)
2302}
2303
2304fn write_summary_line(writes: WriteSummary) -> Option<String> {
2305 (writes.attempted > 0).then(|| {
2306 format!(
2307 "writes: {} attempted, {} succeeded, {} failed",
2308 writes.attempted,
2309 writes.succeeded(),
2310 writes.failed
2311 )
2312 })
2313}
2314
2315fn write_exit_code(writes: WriteSummary) -> i32 {
2316 i32::from(writes.failed > 0)
2317}
2318
2319fn report_item(title: &str, file: &str) -> String {
2320 match file.trim() {
2321 "" => title.to_string(),
2322 location => format!("{title} ({location})"),
2323 }
2324}
2325
2326#[cfg(test)]
2327mod tests {
2328 use super::*;
2329 use clap::CommandFactory;
2330
2331 fn planned(issue: i64, depends_on: Vec<i64>) -> PlanItem {
2332 PlanItem {
2333 issue,
2334 title: format!("issue {issue}"),
2335 complexity: crate::model::Complexity::S,
2336 risk: crate::model::Risk::Low,
2337 depends_on,
2338 reason: "worth doing".into(),
2339 }
2340 }
2341
2342 #[test]
2351 fn a_dependent_waits_for_a_dependency_that_has_not_landed() {
2352 let plan = Plan {
2353 order: vec![planned(9, vec![]), planned(10, vec![9])],
2354 ..Plan::default()
2355 };
2356 let mut landed = BTreeSet::new();
2357
2358 let why = held_back(&plan.order[1], &plan, &landed).expect("held");
2359 assert!(why.contains("#9"), "{why}");
2360 assert!(why.contains("has not merged"), "{why}");
2361 assert!(held_back(&plan.order[0], &plan, &landed).is_none());
2362
2363 landed.insert(9);
2365 assert!(held_back(&plan.order[1], &plan, &landed).is_none());
2366 }
2367
2368 #[test]
2371 fn a_dependent_on_a_declined_or_contested_issue_is_held_with_the_reason() {
2372 let declined = Plan {
2373 order: vec![planned(10, vec![9])],
2374 skipped: vec![crate::model::SkippedItem {
2375 issue: 9,
2376 title: "issue 9".into(),
2377 reasons: Default::default(),
2378 tracker: false,
2379 }],
2380 ..Plan::default()
2381 };
2382 let why = held_back(&declined.order[0], &declined, &BTreeSet::new()).expect("held");
2383 assert!(why.contains("declined by both reviewers"), "{why}");
2384
2385 let contested = Plan {
2386 order: vec![planned(10, vec![9])],
2387 contested: vec![crate::model::ContestedItem {
2388 issue: 9,
2389 title: "issue 9".into(),
2390 positions: Default::default(),
2391 reasons: Default::default(),
2392 note: None,
2393 }],
2394 ..Plan::default()
2395 };
2396 let why = held_back(&contested.order[0], &contested, &BTreeSet::new()).expect("held");
2397 assert!(why.contains("disagree about #9"), "{why}");
2398 }
2399
2400 #[test]
2404 fn a_dependency_outside_the_run_does_not_hold_anything() {
2405 let plan = Plan {
2406 order: vec![planned(10, vec![900])],
2407 ..Plan::default()
2408 };
2409 assert!(held_back(&plan.order[0], &plan, &BTreeSet::new()).is_none());
2410 }
2411
2412 #[test]
2418 fn doctor_says_when_a_scheduled_effort_is_not_one_the_agent_accepts() {
2419 let text = "\
2420[agents.claude]
2421preset = \"claude\"
2422
2423[agents.codex]
2424preset = \"codex\"
2425
2426[loop.effort_schedule]
2427review_1 = \"ultra\"
2428";
2429 let cfg = config::parse(text).expect("parses");
2430 let said = unknown_effort_values(&cfg);
2431 assert_eq!(1, said.len(), "{said:?}");
2432 assert!(said[0].starts_with("claude"), "{}", said[0]);
2433 assert!(said[0].contains("ultra"), "{}", said[0]);
2434 assert!(said[0].contains("review_1"), "{}", said[0]);
2435 assert!(
2436 said[0].contains("[agents.claude.effort_schedule]"),
2437 "it has to say where to put the right value: {}",
2438 said[0]
2439 );
2440
2441 let quiet = text.replace(
2443 "[loop.effort_schedule]\nreview_1 = \"ultra\"\n",
2444 "[agents.claude.effort_schedule]\nreview_1 = \"xhigh\"\n\n\
2445 [agents.codex.effort_schedule]\nreview_1 = \"ultra\"\n",
2446 );
2447 assert!(unknown_effort_values(&config::parse(&quiet).expect("parses")).is_empty());
2448 }
2449
2450 #[test]
2451 fn a_contested_issue_reads_as_both_positions_with_their_reasons() {
2452 let mut positions_map = std::collections::BTreeMap::new();
2453 positions_map.insert("claude".to_string(), "do".to_string());
2454 positions_map.insert("codex".to_string(), "skip".to_string());
2455 let mut reasons = std::collections::BTreeMap::new();
2456 reasons.insert(
2457 "claude".to_string(),
2458 "the retry path is still wrong".to_string(),
2459 );
2460 reasons.insert(
2461 "codex".to_string(),
2462 "fixed in the rewrite last month".to_string(),
2463 );
2464
2465 let line = positions(&positions_map, &reasons);
2466 assert!(line.contains("claude says do"), "{line}");
2467 assert!(line.contains("codex says skip"), "{line}");
2468 assert!(line.contains("the retry path is still wrong"), "{line}");
2469 assert!(line.contains("fixed in the rewrite"), "{line}");
2470
2471 let bare = positions(&positions_map, &Default::default());
2473 assert!(bare.contains("claude says do"), "{bare}");
2474 }
2475
2476 #[test]
2477 fn report_items_include_their_location() {
2478 assert_eq!(
2479 "Same title (src/a.rs:10)",
2480 report_item("Same title", "src/a.rs:10")
2481 );
2482 assert_eq!("General point", report_item("General point", ""));
2483 }
2484
2485 #[test]
2486 fn every_attempted_write_failing_is_a_failed_run() {
2487 let writes = WriteSummary {
2488 attempted: 3,
2489 failed: 3,
2490 };
2491
2492 assert_eq!(1, write_exit_code(writes));
2493 assert_eq!(
2494 Some("writes: 3 attempted, 0 succeeded, 3 failed".to_string()),
2495 write_summary_line(writes)
2496 );
2497 }
2498
2499 #[test]
2500 fn a_partial_write_failure_is_reported_after_the_run() {
2501 let writes = WriteSummary {
2502 attempted: 3,
2503 failed: 1,
2504 };
2505
2506 assert_eq!(1, write_exit_code(writes));
2507 assert_eq!(2, writes.succeeded());
2508 }
2509
2510 #[test]
2511 fn the_parser_is_internally_consistent() {
2512 Cli::command().debug_assert();
2513 }
2514
2515 #[test]
2516 fn quiet_is_accepted_before_or_after_the_subcommand() {
2517 for argv in [
2518 vec!["spar", "--quiet", "run", "42"],
2519 vec!["spar", "run", "42", "--quiet"],
2520 vec!["spar", "resume", "--quiet"],
2521 vec!["spar", "init", "-q"],
2522 ] {
2523 assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
2524 }
2525 assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
2526 }
2527
2528 #[test]
2529 fn several_issue_numbers_are_accepted() {
2530 let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
2531 match cli.command {
2532 Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
2533 other => panic!("{other:?}"),
2534 }
2535 }
2536
2537 #[test]
2538 fn issue_numbers_and_flags_can_be_interleaved() {
2539 let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
2540 match cli.command {
2541 Command::Run {
2542 issues, loop_flags, ..
2543 } => {
2544 assert_eq!(vec![42, 51], issues);
2545 assert!(loop_flags.auto_merge);
2546 }
2547 other => panic!("{other:?}"),
2548 }
2549 }
2550
2551 #[test]
2552 fn every_command_that_reads_a_config_accepts_one() {
2553 for argv in [
2554 vec!["spar", "run", "42"],
2555 vec!["spar", "triage"],
2556 vec!["spar", "resume"],
2557 vec!["spar", "followup"],
2558 vec!["spar", "checkin"],
2559 vec!["spar", "split"],
2560 vec!["spar", "clean"],
2561 vec!["spar", "doctor"],
2562 ] {
2563 let mut full = argv.clone();
2564 full.extend(["--config", "other.toml"]);
2565 let cli = Cli::parse_from(&full);
2566 let config = match cli.command {
2567 Command::Run { common, .. }
2568 | Command::Triage { common, .. }
2569 | Command::Resume { common, .. }
2570 | Command::Followup { common, .. }
2571 | Command::Split { common, .. }
2572 | Command::Checkin { common, .. } => common.config,
2573 Command::Clean { config, .. } | Command::Doctor { config } => config,
2574 other => panic!("{other:?}"),
2575 };
2576 assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
2577 }
2578 }
2579
2580 #[test]
2581 fn auto_merge_is_off_unless_asked_for() {
2582 let cli = Cli::parse_from(["spar", "run"]);
2583 match cli.command {
2584 Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
2585 other => panic!("{other:?}"),
2586 }
2587 }
2588
2589 #[test]
2592 fn every_command_that_reads_a_config_takes_instructions() {
2593 for argv in [
2596 vec!["spar", "run", "7", "--instructions", "Do not wait for CI."],
2597 vec![
2598 "spar",
2599 "triage",
2600 "7",
2601 "--instructions",
2602 "Do not wait for CI.",
2603 ],
2604 vec![
2605 "spar",
2606 "resume",
2607 "7",
2608 "--instructions",
2609 "Do not wait for CI.",
2610 ],
2611 vec![
2612 "spar",
2613 "review",
2614 "7",
2615 "--instructions",
2616 "Do not wait for CI.",
2617 ],
2618 vec!["spar", "followup", "--instructions", "Do not wait for CI."],
2619 vec![
2620 "spar",
2621 "checkin",
2622 "7",
2623 "--instructions",
2624 "Do not wait for CI.",
2625 ],
2626 vec![
2627 "spar",
2628 "split",
2629 "7",
2630 "--instructions",
2631 "Do not wait for CI.",
2632 ],
2633 ] {
2634 let parsed = Cli::parse_from(&argv);
2635 let common = match parsed.command {
2636 Command::Run { common, .. }
2637 | Command::Triage { common, .. }
2638 | Command::Resume { common, .. }
2639 | Command::Review { common, .. }
2640 | Command::Followup { common, .. }
2641 | Command::Split { common, .. }
2642 | Command::Checkin { common, .. } => common,
2643 other => panic!("{other:?}"),
2644 };
2645 assert_eq!(
2646 Some("Do not wait for CI."),
2647 common.instructions.as_deref(),
2648 "{argv:?}"
2649 );
2650 }
2651 }
2652
2653 #[test]
2654 fn the_two_close_skipped_flags_are_mutually_exclusive() {
2655 assert!(
2656 Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
2657 );
2658 }
2659
2660 #[test]
2663 fn close_skipped_is_offered_only_where_it_means_something() {
2664 assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
2665 assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
2666 assert!(Cli::try_parse_from(["spar", "followup", "--close-skipped"]).is_ok());
2668 assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
2669 assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
2670 assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
2671 }
2672
2673 #[test]
2676 fn followup_takes_no_numbers() {
2677 assert!(Cli::try_parse_from(["spar", "followup"]).is_ok());
2678 assert!(Cli::try_parse_from(["spar", "followup", "42"]).is_err());
2679 }
2680
2681 #[test]
2684 fn the_two_stopping_points_are_mutually_exclusive() {
2685 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only"]).is_ok());
2686 assert!(Cli::try_parse_from(["spar", "followup", "--file-only"]).is_ok());
2687 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only", "--file-only"]).is_err());
2688 }
2689
2690 #[test]
2691 fn the_close_skipped_pair_resolves_to_a_tristate() {
2692 let read = |argv: &[&str]| match Cli::parse_from(argv).command {
2693 Command::Run { triage_flags, .. } => {
2694 match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
2695 (true, _) => Some(true),
2696 (_, true) => Some(false),
2697 _ => None,
2698 }
2699 }
2700 other => panic!("{other:?}"),
2701 };
2702 assert_eq!(None, read(&["spar", "run"]));
2703 assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
2704 assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
2705 }
2706
2707 #[test]
2708 fn the_default_limit_is_twenty() {
2709 let cli = Cli::parse_from(["spar", "run"]);
2710 match cli.command {
2711 Command::Run { common, .. } => assert_eq!(20, common.limit),
2712 other => panic!("{other:?}"),
2713 }
2714 }
2715
2716 #[test]
2717 fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
2718 assert!(matches!(
2719 Cli::parse_from(["spar", "scrub-filter"]).command,
2720 Command::ScrubFilter
2721 ));
2722 let help = Cli::command().render_long_help().to_string();
2723 assert!(
2724 !help.contains("scrub-filter"),
2725 "it is plumbing, not a command"
2726 );
2727 }
2728
2729 #[test]
2730 fn review_takes_pr_numbers_and_a_dry_run() {
2731 let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
2732 match cli.command {
2733 Command::Review { items, dry_run, .. } => {
2734 assert_eq!(vec![101, 102], items);
2735 assert!(dry_run);
2736 }
2737 other => panic!("{other:?}"),
2738 }
2739 }
2740
2741 #[test]
2742 fn review_posts_unless_told_not_to() {
2743 match Cli::parse_from(["spar", "review", "101"]).command {
2744 Command::Review { dry_run, .. } => assert!(!dry_run),
2745 other => panic!("{other:?}"),
2746 }
2747 }
2748
2749 #[test]
2750 fn review_with_no_numbers_is_allowed() {
2751 match Cli::parse_from(["spar", "review"]).command {
2752 Command::Review { items, .. } => assert!(items.is_empty()),
2753 other => panic!("{other:?}"),
2754 }
2755 }
2756
2757 #[test]
2758 fn review_takes_its_own_round_budget() {
2759 match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
2760 Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
2761 other => panic!("{other:?}"),
2762 }
2763 }
2764
2765 #[test]
2766 fn checkin_takes_pr_numbers_and_a_dry_run() {
2767 match Cli::parse_from(["spar", "checkin", "108", "112", "--dry-run"]).command {
2768 Command::Checkin { items, dry_run, .. } => {
2769 assert_eq!(vec![108, 112], items);
2770 assert!(dry_run);
2771 }
2772 other => panic!("{other:?}"),
2773 }
2774 match Cli::parse_from(["spar", "checkin"]).command {
2775 Command::Checkin { items, dry_run, .. } => {
2776 assert!(items.is_empty());
2777 assert!(!dry_run);
2778 }
2779 other => panic!("{other:?}"),
2780 }
2781 }
2782
2783 #[test]
2789 fn checkin_offers_no_flag_that_would_weaken_the_pair() {
2790 assert!(Cli::try_parse_from(["spar", "checkin", "--auto-merge"]).is_err());
2791 assert!(Cli::try_parse_from(["spar", "checkin", "--max-rounds", "1"]).is_err());
2792 assert!(Cli::try_parse_from(["spar", "checkin", "--absorb", "1"]).is_err());
2793 assert!(Cli::try_parse_from(["spar", "checkin", "--close-skipped"]).is_err());
2794 assert!(Cli::try_parse_from(["spar", "checkin", "--reply-only"]).is_ok());
2796 assert!(Cli::try_parse_from(["spar", "checkin", "--any-author"]).is_ok());
2797 assert!(Cli::try_parse_from(["spar", "checkin", "--again"]).is_ok());
2798 assert!(Cli::try_parse_from(["spar", "checkin", "--keep-worktrees"]).is_ok());
2799 }
2800
2801 #[test]
2802 fn resume_takes_a_next_override() {
2803 let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
2804 match cli.command {
2805 Command::Resume {
2806 prs, next_actor, ..
2807 } => {
2808 assert_eq!(vec![108], prs);
2809 assert_eq!(Some("codex".to_string()), next_actor);
2810 }
2811 other => panic!("{other:?}"),
2812 }
2813 }
2814}
2815
2816#[cfg(test)]
2817mod absorb_tests {
2818 use super::*;
2819
2820 #[test]
2821 fn absorb_is_off_unless_asked_for() {
2822 match Cli::parse_from(["spar", "run"]).command {
2823 Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
2824 other => panic!("{other:?}"),
2825 }
2826 }
2827
2828 #[test]
2829 fn absorb_takes_a_wave_count() {
2830 match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
2831 Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
2832 other => panic!("{other:?}"),
2833 }
2834 }
2835
2836 #[test]
2837 fn absorb_is_only_offered_where_issues_are_worked() {
2838 assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
2839 assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
2840 assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
2841 assert!(Cli::try_parse_from(["spar", "split", "--absorb", "1"]).is_err());
2844 }
2845}
2846
2847#[cfg(test)]
2848mod split_tests {
2849 use super::*;
2850
2851 #[test]
2852 fn split_takes_numbers_of_either_kind() {
2853 match Cli::parse_from(["spar", "split", "10", "12"]).command {
2854 Command::Split { items, .. } => assert_eq!(vec![10, 12], items),
2855 other => panic!("{other:?}"),
2856 }
2857 }
2858
2859 #[test]
2862 fn split_with_no_numbers_is_allowed() {
2863 match Cli::parse_from(["spar", "split"]).command {
2864 Command::Split {
2865 items,
2866 dry_run,
2867 again,
2868 ..
2869 } => {
2870 assert!(items.is_empty());
2871 assert!(!dry_run);
2872 assert!(!again);
2873 }
2874 other => panic!("{other:?}"),
2875 }
2876 }
2877
2878 #[test]
2882 fn split_can_be_told_to_write_nothing() {
2883 match Cli::parse_from(["spar", "split", "10", "--dry-run"]).command {
2884 Command::Split { dry_run, .. } => assert!(dry_run),
2885 other => panic!("{other:?}"),
2886 }
2887 }
2888
2889 #[test]
2892 fn split_can_be_told_to_split_something_it_already_split() {
2893 match Cli::parse_from(["spar", "split", "10", "--again"]).command {
2894 Command::Split { again, .. } => assert!(again),
2895 other => panic!("{other:?}"),
2896 }
2897 }
2898
2899 #[test]
2902 fn split_takes_the_flags_that_govern_picking() {
2903 match Cli::parse_from([
2904 "spar",
2905 "split",
2906 "--limit",
2907 "5",
2908 "--min-number",
2909 "480",
2910 "--config",
2911 "other.toml",
2912 ])
2913 .command
2914 {
2915 Command::Split { common, .. } => {
2916 assert_eq!(5, common.limit);
2917 assert_eq!(Some(480), common.min_number);
2918 assert_eq!(Some(PathBuf::from("other.toml")), common.config);
2919 }
2920 other => panic!("{other:?}"),
2921 }
2922 }
2923
2924 #[test]
2927 fn split_offers_no_flag_that_would_make_it_a_second_run() {
2928 for flag in [
2929 vec!["--auto-merge"],
2930 vec!["--max-rounds", "2"],
2931 vec!["--close-skipped"],
2932 vec!["--no-close-skipped"],
2933 vec!["--keep-worktrees"],
2934 ] {
2935 let mut argv = vec!["spar", "split", "10"];
2936 argv.extend(flag.iter().copied());
2937 assert!(Cli::try_parse_from(&argv).is_err(), "{argv:?}");
2938 }
2939 }
2940}
2941
2942#[cfg(test)]
2943mod min_number_tests {
2944 use super::*;
2945
2946 fn read(argv: &[&str]) -> Option<i64> {
2947 match Cli::parse_from(argv).command {
2948 Command::Run { common, .. }
2949 | Command::Triage { common, .. }
2950 | Command::Resume { common, .. }
2951 | Command::Review { common, .. }
2952 | Command::Followup { common, .. }
2953 | Command::Split { common, .. }
2954 | Command::Checkin { common, .. } => common.min_number,
2955 other => panic!("{other:?}"),
2956 }
2957 }
2958
2959 #[test]
2960 fn there_is_no_floor_unless_one_is_asked_for() {
2961 assert_eq!(None, read(&["spar", "run"]));
2962 }
2963
2964 #[test]
2965 fn every_command_that_picks_for_itself_accepts_a_floor() {
2966 for cmd in ["run", "triage", "resume", "review", "checkin", "split"] {
2967 assert_eq!(
2968 Some(480),
2969 read(&["spar", cmd, "--min-number", "480"]),
2970 "{cmd}"
2971 );
2972 }
2973 }
2974}
2975
2976#[cfg(test)]
2977mod run_targets_tests {
2978 use super::*;
2979
2980 fn covered(pairs: &[(i64, i64)]) -> BTreeMap<i64, i64> {
2981 pairs.iter().copied().collect()
2982 }
2983
2984 #[test]
2989 fn an_issue_its_named_pull_request_closes_is_not_worked_again() {
2990 let kept = issues_not_on_named_prs(&[772, 783, 784], &covered(&[(772, 786)]));
2991
2992 assert_eq!(vec![783, 784], kept);
2993 }
2994
2995 #[test]
2996 fn an_issue_no_named_pull_request_closes_is_still_worked() {
2997 let kept = issues_not_on_named_prs(&[783, 784, 771], &covered(&[(772, 786)]));
2998
2999 assert_eq!(vec![783, 784, 771], kept);
3000 }
3001
3002 #[test]
3005 fn an_unreadable_pull_request_drops_no_issue() {
3006 let kept = issues_not_on_named_prs(&[772, 783], &covered(&[]));
3007
3008 assert_eq!(vec![772, 783], kept);
3009 }
3010
3011 #[test]
3013 fn every_issue_one_pull_request_closes_is_taken_by_it() {
3014 let kept = issues_not_on_named_prs(&[771, 772, 783], &covered(&[(771, 786), (772, 786)]));
3015
3016 assert_eq!(vec![783], kept);
3017 }
3018}
3019
3020#[cfg(test)]
3021mod settings_block_tests {
3022 use super::*;
3023
3024 fn written(line: &str) -> String {
3027 let after = line.split_once('=').expect("an assignment").1;
3028 let mut quoted = false;
3029 for (i, c) in after.char_indices() {
3030 match c {
3031 '"' => quoted = !quoted,
3032 '#' if !quoted => return after[..i].trim().to_string(),
3033 _ => {}
3034 }
3035 }
3036 after.trim().to_string()
3037 }
3038
3039 fn line_for(text: &str, key: &str) -> String {
3040 text.lines()
3041 .find(|l| {
3042 let bare = l.trim_start().trim_start_matches('#').trim_start();
3043 bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
3044 })
3045 .unwrap_or_else(|| panic!("{key} is not offered at all:\n{text}"))
3046 .to_string()
3047 }
3048
3049 #[test]
3056 fn every_value_it_offers_is_the_default_it_actually_has() {
3057 let text = settings_block("claude");
3058 for option in config::known_options() {
3059 if option.section == "loop.effort_schedule" {
3063 continue;
3064 }
3065 let line = line_for(&text, &option.key);
3066 assert_eq!(
3067 option.default,
3068 written(&line),
3069 "the generated config offers `{}`, but the default is {}",
3070 line.trim(),
3071 option.default
3072 );
3073 }
3074 }
3075
3076 #[test]
3080 fn it_offers_every_option_the_parser_knows_about() {
3081 let text = settings_block("claude");
3082 let missing: Vec<String> = config::unmentioned_options(&text)
3083 .into_iter()
3084 .map(|o| format!("[{}] {}", o.section, o.key))
3085 .collect();
3086 assert!(missing.is_empty(), "not offered: {}", missing.join(", "));
3087 }
3088
3089 #[test]
3093 fn every_option_it_offers_can_be_uncommented_and_still_load() {
3094 let mut text = String::from(
3095 "[agents.claude]\ncommand = [\"claude\"]\n\n\
3096 [agents.codex]\ncommand = [\"codex\"]\n\n",
3097 );
3098 for line in settings_block("claude").lines() {
3099 text.push_str(uncomment(line).unwrap_or(line));
3100 text.push('\n');
3101 }
3102 let cfg = config::parse(&text).expect("a config of its own suggestions");
3103 assert_eq!("claude", cfg.first_implementor);
3104 }
3105
3106 fn uncomment(line: &str) -> Option<&str> {
3109 let bare = line.trim_start().strip_prefix('#')?.trim_start();
3110 let key = bare.split_once('=')?.0.trim();
3113 let named = !key.is_empty()
3114 && key
3115 .chars()
3116 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
3117 named.then_some(bare)
3118 }
3119
3120 #[test]
3121 fn the_agent_that_goes_first_is_the_one_that_was_chosen() {
3122 assert!(settings_block("codex").contains("first_implementor = \"codex\""));
3123 }
3124
3125 #[test]
3130 fn a_note_sits_above_the_option_it_describes() {
3131 let text = settings_block("claude");
3132 assert!(
3133 text.lines().all(|l| l.chars().count() <= 80),
3134 "a line runs off the edge:\n{text}"
3135 );
3136 assert!(
3137 text.lines().all(|l| !l.starts_with(' ')),
3138 "a line is indented, so the columns are back:\n{text}"
3139 );
3140
3141 let lines: Vec<&str> = text.lines().collect();
3143 let at = lines
3144 .iter()
3145 .position(|l| l.starts_with("max_rounds"))
3146 .expect("max_rounds");
3147 assert!(lines[at - 1].starts_with('#'), "{:?}", lines[at - 1]);
3148 assert!(
3149 lines[at - 1].contains("lifetime cap"),
3150 "the note above it is the end of its own note: {:?}",
3151 lines[at - 1]
3152 );
3153 }
3154
3155 #[test]
3157 fn every_note_starts_as_a_sentence() {
3158 for (_, key, note) in LOOP_OPTIONS.iter().chain(STYLE_OPTIONS) {
3159 let first = note.chars().next().expect("a note");
3160 assert!(
3161 first.is_uppercase(),
3162 "{key} reads as a margin scribble rather than a sentence: {note}"
3163 );
3164 }
3165 }
3166}
3167
3168#[cfg(test)]
3169mod agent_block_tests {
3170 use super::*;
3171
3172 fn spec(models: &[&str], efforts: &[&str]) -> config::AgentSpec {
3173 let mut spec: config::AgentSpec =
3174 toml::Value::Table(toml::from_str("command = [\"x\"]").expect("a minimal preset"))
3175 .try_into()
3176 .expect("builds");
3177 spec.models = models.iter().map(|s| s.to_string()).collect();
3178 spec.efforts = efforts.iter().map(|s| s.to_string()).collect();
3179 spec
3180 }
3181
3182 #[test]
3186 fn an_option_with_no_hints_is_left_out_rather_than_guessed_at() {
3187 let block = agent_block("cursor", &spec(&["composer-2.5", "auto"], &[]));
3188 assert!(!block.contains("..."), "{block}");
3189 assert!(!block.contains("effort"), "{block}");
3190 assert!(block.contains("# model = \"composer-2.5\""), "{block}");
3191 }
3192
3193 #[test]
3196 fn only_the_options_that_follow_are_introduced() {
3197 let model_only = agent_block("cursor", &spec(&["auto"], &[]));
3198 assert!(model_only.contains("Omit model to use"), "{model_only}");
3199 assert!(!model_only.contains("Omit effort"), "{model_only}");
3200
3201 let both = agent_block("claude", &spec(&["fable"], &["high"]));
3202 assert!(both.contains("Omit model to use"), "{both}");
3203 assert!(both.contains("Omit effort to use"), "{both}");
3204 }
3205
3206 #[test]
3209 fn a_preset_with_no_hints_still_writes_a_usable_block() {
3210 let block = agent_block("gemini", &spec(&[], &[]));
3211 assert!(!block.contains("..."), "{block}");
3212 assert!(!block.contains("Omit"), "{block}");
3213 assert!(
3214 block.starts_with("[agents.gemini]\npreset = \"gemini\"\n"),
3215 "{block}"
3216 );
3217 assert!(block.contains("[agents.gemini.fallback]"), "{block}");
3219 assert!(block.contains("# timeout = "), "{block}");
3220 }
3221
3222 #[test]
3225 fn the_timeout_offered_is_the_one_the_agent_would_use() {
3226 let mut spec = spec(&["a"], &[]);
3227 spec.timeout = 7200;
3228 assert!(
3229 agent_block("custom", &spec).contains("# timeout = 7200"),
3230 "the generator kept its own copy"
3231 );
3232 }
3233
3234 #[test]
3236 fn alternatives_are_listed_only_when_there_are_any() {
3237 assert!(agent_block("a", &spec(&["one", "two"], &[])).contains("One of: one | two."));
3238 let single = agent_block("b", &spec(&["only"], &[]));
3239 assert!(!single.contains("One of:"), "{single}");
3240 }
3241
3242 #[test]
3246 fn the_presets_note_is_said_once() {
3247 let mut spec = spec(&["m1", "m2"], &["e1", "e2"]);
3248 spec.options_note = Some("Check the current sets with: mytool --help".into());
3249 let block = agent_block("mytool", &spec);
3250 assert_eq!(
3251 1,
3252 block.matches("Check the current sets").count(),
3253 "{block}"
3254 );
3255 }
3256}