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::brainstorm;
11use crate::checkin;
12use crate::clock;
13use crate::config::{self, Config};
14use crate::error::Result;
15use crate::followups;
16use crate::listen;
17use crate::model::{Issue, IssueRun, ItemKind, Plan, PlanItem, Status};
18use crate::proc::{self, ExecOpts};
19use crate::progress::Progress;
20use crate::repo::{Repo, WriteSummary};
21use crate::review;
22use crate::review_only;
23use crate::spend;
24use crate::split;
25use crate::style;
26use crate::tracker;
27use crate::triage;
28use crate::{bail, log, logdim, logging, logwarn, spar_err};
29
30pub const VERSION: &str = env!("CARGO_PKG_VERSION");
31
32#[derive(Parser, Debug)]
33#[command(
34 name = "spar",
35 version = VERSION,
36 about = "Two coding agents alternate implementing and reviewing GitHub issues.",
37 long_about = "Two coding agents alternate implementing and reviewing GitHub issues until a \
38 pull request converges. Neither agent reviews its own most recent edit.\n\n\
39 Arguments are issue numbers for `triage`, and pull request numbers \
40 for `resume`, `review`, and `checkin`. `run` and `split` take either: `run` \
41 continues any pull request given before it triages the issues, and drops an \
42 issue whose pull request was given too. Omit them and \
43 spar takes everything open, up to --limit. `split` applies that limit once to \
44 issues and once to pull requests. `followup` takes none: it works the queue in \
45 .spar/followups.md, and an entry there has no number to name. `brainstorm` \
46 takes words rather than numbers: the subject to brainstorm, or nothing to \
47 brainstorm the repository itself. `listen` takes issue or pull request \
48 numbers to watch, or nothing to watch the whole repository.",
49 max_term_width = 96
50)]
51pub struct Cli {
52 #[arg(short, long, global = true)]
54 pub quiet: bool,
55
56 #[command(subcommand)]
57 pub command: Command,
58}
59
60#[derive(Subcommand, Debug)]
61pub enum Command {
62 Run {
64 issues: Vec<i64>,
66 #[command(flatten)]
67 common: Common,
68 #[command(flatten)]
69 loop_flags: LoopFlags,
70 #[command(flatten)]
71 triage_flags: TriageFlags,
72 #[arg(long, default_value = "plan.json")]
74 plan_out: PathBuf,
75 #[arg(long)]
77 no_worktrees: bool,
78 },
79
80 Followup {
92 #[command(flatten)]
93 common: Common,
94 #[command(flatten)]
95 loop_flags: LoopFlags,
96 #[command(flatten)]
97 triage_flags: TriageFlags,
98 #[arg(long, value_name = "PATH")]
100 file: Option<PathBuf>,
101 #[arg(long)]
103 screen_only: bool,
104 #[arg(long, conflicts_with = "screen_only")]
106 file_only: bool,
107 #[arg(long, default_value = "plan.json")]
109 plan_out: PathBuf,
110 #[arg(long)]
112 no_worktrees: bool,
113 },
114
115 Triage {
117 issues: Vec<i64>,
119 #[command(flatten)]
120 common: Common,
121 #[arg(long, default_value = "plan.json")]
122 plan_out: PathBuf,
123 },
124
125 Resume {
127 prs: Vec<i64>,
129 #[command(flatten)]
130 common: Common,
131 #[command(flatten)]
132 loop_flags: LoopFlags,
133 #[arg(long = "next", value_name = "AGENT")]
135 next_actor: Option<String>,
136 },
137
138 Checkin {
146 items: Vec<i64>,
149 #[command(flatten)]
150 common: Common,
151 #[arg(long)]
153 dry_run: bool,
154 #[arg(long)]
156 reply_only: bool,
157 #[arg(long)]
160 any_author: bool,
161 #[arg(long)]
163 again: bool,
164 #[arg(long)]
166 keep_worktrees: bool,
167 },
168
169 Review {
174 items: Vec<i64>,
177 #[command(flatten)]
178 common: Common,
179 #[arg(long)]
181 dry_run: bool,
182 #[arg(long)]
185 max_rounds: Option<u32>,
186 #[arg(long)]
190 verdict: bool,
191 #[arg(long)]
196 recheck: bool,
197 },
198
199 Brainstorm {
211 topic: Vec<String>,
214 #[command(flatten)]
215 common: Common,
216 #[arg(long)]
220 max_rounds: Option<u32>,
221 #[arg(long, default_value_t = 5, value_name = "N")]
223 ideas: usize,
224 #[arg(long, value_name = "PATH")]
226 out: Option<PathBuf>,
227 #[arg(long)]
229 file_issues: bool,
230 #[arg(
233 long,
234 value_name = "PATH",
235 requires = "file_issues",
236 conflicts_with = "out"
237 )]
238 from: Option<PathBuf>,
239 },
240
241 Split {
258 items: Vec<i64>,
261 #[command(flatten)]
262 common: Common,
263 #[arg(long)]
265 dry_run: bool,
266 #[arg(long)]
269 again: bool,
270 },
271
272 Listen {
287 items: Vec<i64>,
290 #[command(flatten)]
291 common: Common,
292 #[arg(long)]
294 once: bool,
295 #[arg(long)]
298 dry_run: bool,
299 #[arg(long, value_name = "DURATION")]
302 since: Option<String>,
303 },
304
305 Post {
310 #[arg(required = true)]
312 prs: Vec<i64>,
313 #[arg(long, default_value = ".")]
314 repo: PathBuf,
315 #[arg(long)]
316 config: Option<PathBuf>,
317 #[arg(long, value_name = "PATH")]
319 file: Option<PathBuf>,
320 #[arg(long)]
322 dry_run: bool,
323 },
324
325 Init {
330 #[arg(long, default_value = "spar.toml")]
331 out: PathBuf,
332 #[arg(long)]
334 force: bool,
335 #[arg(long, conflicts_with = "force")]
338 update: bool,
339 },
340
341 Clean {
343 #[arg(long, default_value = ".")]
344 repo: PathBuf,
345 #[arg(long)]
346 config: Option<PathBuf>,
347 #[arg(long)]
350 all: bool,
351 #[arg(long)]
353 pr_state: bool,
354 },
355
356 Doctor {
358 #[arg(long)]
359 config: Option<PathBuf>,
360 },
361
362 #[command(hide = true)]
366 ScrubFilter,
367}
368
369#[derive(Args, Debug, Clone)]
370pub struct Common {
371 #[arg(long, default_value = ".")]
373 pub repo: PathBuf,
374 #[arg(long)]
376 pub config: Option<PathBuf>,
377 #[arg(long)]
379 pub base: Option<String>,
380 #[arg(long)]
382 pub first: Option<String>,
383 #[arg(long, default_value_t = 20)]
386 pub limit: usize,
387 #[arg(long, value_name = "N")]
390 pub min_number: Option<i64>,
391 #[arg(long, value_name = "TEXT")]
394 pub instructions: Option<String>,
395}
396
397#[derive(Args, Debug, Clone)]
398pub struct LoopFlags {
399 #[arg(long)]
402 pub max_rounds: Option<u32>,
403 #[arg(long)]
405 pub auto_merge: bool,
406 #[arg(long)]
408 pub keep_worktrees: bool,
409 #[arg(long, value_name = "N")]
412 pub absorb: Option<u32>,
413}
414
415#[derive(Args, Debug, Clone)]
418pub struct TriageFlags {
419 #[arg(long, conflicts_with = "no_close_skipped")]
421 pub close_skipped: bool,
422 #[arg(long)]
424 pub no_close_skipped: bool,
425 #[arg(long)]
431 pub retriage: bool,
432}
433
434pub fn main() -> i32 {
439 let cli = Cli::parse();
440 logging::init_color();
441 logging::set_quiet(cli.quiet);
442
443 match dispatch(cli) {
444 Ok(code) => code,
445 Err(e) => {
446 logging::error(e.to_string());
447 2
448 }
449 }
450}
451
452fn dispatch(cli: Cli) -> Result<i32> {
453 match cli.command {
454 Command::ScrubFilter => cmd_scrub_filter(),
455 Command::Doctor { config } => cmd_doctor(config.as_deref()),
456 Command::Review {
457 items,
458 common,
459 dry_run,
460 max_rounds,
461 verdict,
462 recheck,
463 } => {
464 let overrides = Overrides {
465 max_rounds,
466 ..Overrides::default()
467 };
468 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
469 let numbers = if items.is_empty() {
470 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
471 if found.is_empty() {
472 log!("no open PRs");
473 return Ok(0);
474 }
475 log!("no PRs given, reviewing {} open", found.len());
476 found
477 } else {
478 items
479 };
480 let sorted = classify(&repo, &numbers)?;
481 let mut targets = sorted.prs;
482 for number in sorted.issues {
483 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
484 Ok(Some(pr)) => {
485 log!("#{number} is an issue; reviewing its open PR {}", pr.url);
486 targets.push(pr.number);
487 }
488 Ok(None) => {
489 logwarn!("#{number} is an issue with no open pull request to review")
490 }
491 Err(e) => logwarn!("#{number}: {e}"),
492 }
493 }
494 let mut results = Vec::new();
495 for number in targets {
496 results.push(if recheck {
497 let mode = review_only::RecheckMode {
498 dry_run,
499 requested: false,
500 verdict_on_fallback: verdict,
501 };
502 review_only::recheck_pr(&agents, &cfg, &repo, number, mode)
503 } else {
504 let opts = review_only::ReviewOpts { dry_run, verdict };
505 review_only::review_pr(&agents, &cfg, &repo, number, opts)
506 });
507 }
508 if results.is_empty() {
509 return Ok(0);
510 }
511 Ok(report(&results, &cfg, &repo))
512 }
513
514 Command::Listen {
515 items,
516 common,
517 once,
518 dry_run,
519 since,
520 } => {
521 let since = since.as_deref().map(listen::parse_duration).transpose()?;
523 let (cfg, repo, agents) = prepare(&common, None)?;
524 listen::run(
525 &agents,
526 &cfg,
527 &repo,
528 listen::Opts {
529 items,
530 once,
531 dry_run,
532 since,
533 },
534 )
535 }
536
537 Command::Brainstorm {
538 topic,
539 common,
540 max_rounds,
541 ideas,
542 out,
543 file_issues,
544 from,
545 } => cmd_brainstorm(
546 topic,
547 &common,
548 max_rounds,
549 ideas,
550 out,
551 file_issues,
552 from.as_deref(),
553 ),
554
555 Command::Checkin {
556 items,
557 common,
558 dry_run,
559 reply_only,
560 any_author,
561 again,
562 keep_worktrees,
563 } => {
564 let overrides = Overrides {
565 keep_worktrees: keep_worktrees.then_some(true),
566 ..Overrides::default()
567 };
568 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
569 let mode = checkin::Mode {
570 dry_run,
571 reply_only,
572 trust: if any_author {
573 crate::config::Trust::Anyone
574 } else {
575 cfg.loop_cfg.checkin_trust
576 },
577 again,
578 resolve: cfg.loop_cfg.checkin_resolve,
579 posts: checkin::posts(&cfg),
580 };
581 let numbers = if items.is_empty() {
582 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
583 if found.is_empty() {
584 log!("no open PRs");
585 return Ok(0);
586 }
587 log!(
588 "no PRs given, checking in on {} open: {}",
589 found.len(),
590 found
591 .iter()
592 .map(|n| format!("#{n}"))
593 .collect::<Vec<_>>()
594 .join(", ")
595 );
596 found
597 } else {
598 items
599 };
600 let sorted = classify(&repo, &numbers)?;
601 let mut results = Vec::new();
602 for number in sorted.prs {
603 results.push(checkin::checkin_pr(&agents, &cfg, &repo, number, &mode));
604 }
605 for number in sorted.issues {
609 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
610 Ok(Some(pr)) => {
611 log!(
612 "#{number} is an issue; checking in on its open PR {}",
613 pr.url
614 );
615 results.push(checkin::checkin_pr(&agents, &cfg, &repo, pr.number, &mode));
616 }
617 Ok(None) => {
618 results.push(checkin::checkin_issue(&agents, &cfg, &repo, number, &mode))
619 }
620 Err(e) => results.push(checkin::failed(number, format!("#{number}"), e)),
625 }
626 }
627 if results.is_empty() {
628 return Ok(0);
629 }
630 Ok(report(&results, &cfg, &repo))
631 }
632
633 Command::Split {
634 items,
635 common,
636 dry_run,
637 again,
638 } => {
639 let (cfg, repo, agents) = prepare(&common, None)?;
640 let mode = split::Mode { dry_run, again };
641 let picked = if items.is_empty() {
642 pick_for_split(&agents, &cfg, &repo, common.limit, &mode)?
643 } else {
644 let sorted = classify(&repo, &items)?;
645 let mut out: Vec<(i64, ItemKind)> = sorted
646 .issues
647 .iter()
648 .map(|n| (*n, ItemKind::Issue))
649 .collect();
650 out.extend(sorted.prs.iter().map(|n| (*n, ItemKind::Pr)));
651 out
652 };
653
654 let mut results = Vec::new();
655 let mut seen: BTreeSet<i64> = BTreeSet::new();
656 for (number, kind) in picked {
657 let target = match kind {
661 ItemKind::Pr => Some(number),
662 ItemKind::Issue => {
663 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
664 Ok(found) => found.map(|pr| {
665 log!("#{number} is an issue; splitting its open PR {}", pr.url);
666 pr.number
667 }),
668 Err(e) => {
669 logwarn!("#{number}: {e}");
670 continue;
671 }
672 }
673 }
674 };
675 if !seen.insert(target.unwrap_or(number)) {
680 logdim!("#{number} was already covered by this run, skipping it");
681 continue;
682 }
683 results.push(match target {
684 Some(pr) => split::split_pr(&agents, &cfg, &repo, pr, &mode),
685 None => split::split_issue(&agents, &cfg, &repo, number, &mode),
686 });
687 }
688 if results.is_empty() {
689 return Ok(0);
690 }
691 Ok(report(&results, &cfg, &repo))
692 }
693
694 Command::Post {
695 prs,
696 repo: repo_path,
697 config,
698 file,
699 dry_run,
700 } => cmd_post(
701 &prs,
702 &repo_path,
703 config.as_deref(),
704 file.as_deref(),
705 dry_run,
706 ),
707
708 Command::Init { out, force, update } => {
709 if update {
710 cmd_init_update(&out)
711 } else {
712 cmd_init(&out, force)
713 }
714 }
715 Command::Clean {
716 repo,
717 config,
718 all,
719 pr_state,
720 } => cmd_clean(&repo, config.as_deref(), all, pr_state),
721 Command::Triage {
722 issues,
723 common,
724 plan_out,
725 } => {
726 let (cfg, repo, agents) = prepare(&common, None)?;
727 let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
728 if numbers.is_empty() {
729 return Ok(0);
730 }
731 let sorted = classify(&repo, &numbers)?;
732 for number in &sorted.prs {
733 log!("#{number} is a pull request, nothing to triage");
734 }
735 if sorted.issues.is_empty() {
736 log!("no issues to triage");
737 return Ok(0);
738 }
739 let issues = repo.fetch_issues(&sorted.issues)?;
740 let plan = make_plan(&agents, &cfg, &repo, &issues, &plan_out)?;
744 if cfg.loop_cfg.decompose_trackers {
748 for item in plan.skipped.iter().filter(|i| i.tracker) {
749 tracker::preview(&cfg, &repo, item.issue);
750 }
751 }
752 Ok(0)
753 }
754 Command::Run {
755 issues,
756 common,
757 loop_flags,
758 triage_flags,
759 plan_out,
760 no_worktrees,
761 } => {
762 let overrides = Overrides::for_working(&loop_flags, &triage_flags, no_worktrees);
763 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
764 let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
765 if numbers.is_empty() {
766 return Ok(0);
767 }
768 let sorted = classify(&repo, &numbers)?;
769 let mut results = Vec::new();
770 let mut stopped = Vec::new();
771 let mut parked = Vec::new();
772 let mut progress = Progress::new(sorted.prs.len());
773
774 let mut issues = sorted.issues.clone();
780 if !sorted.prs.is_empty() {
781 log!(
782 "continuing {} pull request(s) before starting new work: {}",
783 sorted.prs.len(),
784 self::numbers(&sorted.prs)
785 );
786 let covered = issues_covered_by(&repo, &sorted.prs);
787 for (issue, pr) in covered.iter().filter(|(i, _)| issues.contains(i)) {
788 log!(
789 "#{issue} is what PR #{pr} closes, and both were given, so it is \
790 continued there rather than implemented again"
791 );
792 }
793 issues = issues_not_on_named_prs(&issues, &covered);
794 for number in &sorted.prs {
795 let run = review::resume_pr(&agents, &cfg, &repo, *number, None);
796 progress.finished(&run);
797 results.push(run);
798 }
799 }
800
801 if let Err(e) = work_issues(
802 &agents,
803 &cfg,
804 &repo,
805 issues,
806 &plan_out,
807 &mut results,
808 &mut stopped,
809 &mut parked,
810 &mut progress,
811 triage_flags.retriage,
812 ) {
813 if results.is_empty() {
816 return Err(e);
817 }
818 stopped.push(format!("the issue queue stopped: {e}"));
819 }
820
821 if results.is_empty() {
822 log!("nothing scheduled");
825 for line in parked.iter().chain(stopped.iter()) {
826 println!("{line}");
827 }
828 return Ok(report_writes(&repo).max(i32::from(!stopped.is_empty())));
829 }
830 Ok(report_with(&results, &cfg, &repo, &stopped, &parked))
831 }
832 Command::Followup {
833 common,
834 loop_flags,
835 triage_flags,
836 file,
837 screen_only,
838 file_only,
839 plan_out,
840 no_worktrees,
841 } => {
842 let overrides = Overrides::for_working(&loop_flags, &triage_flags, no_worktrees);
843 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
844 let path = file.unwrap_or_else(|| repo.followups_path());
845 let mode = match (screen_only, file_only) {
846 (true, _) => followups::Mode::ScreenOnly,
847 (_, true) => followups::Mode::FileOnly,
848 _ => followups::Mode::Work,
849 };
850 let outcome = followups::run(&agents, &cfg, &repo, &path, common.limit, mode)?;
851
852 let wave = followups::wave(&outcome);
853 if mode != followups::Mode::Work || wave.is_empty() {
854 return Ok(report_writes(&repo));
855 }
856 let mut results = Vec::new();
857 let mut stopped = Vec::new();
858 let mut parked = Vec::new();
859 let mut progress = Progress::new(0);
860 work_issues(
861 &agents,
862 &cfg,
863 &repo,
864 wave,
865 &plan_out,
866 &mut results,
867 &mut stopped,
868 &mut parked,
869 &mut progress,
870 triage_flags.retriage,
871 )?;
872 if results.is_empty() {
873 log!("nothing scheduled");
876 for line in parked.iter().chain(stopped.iter()) {
877 println!("{line}");
878 }
879 return Ok(report_writes(&repo).max(i32::from(!stopped.is_empty())));
880 }
881 Ok(report_with(&results, &cfg, &repo, &stopped, &parked))
882 }
883
884 Command::Resume {
885 prs,
886 common,
887 loop_flags,
888 next_actor,
889 } => {
890 let (cfg, repo, agents) = prepare(&common, Some(Overrides::from(&loop_flags)))?;
891 if let Some(name) = &next_actor {
892 if !cfg.has_agent(name) {
893 bail!("--next must be one of: {}", cfg.agent_names().join(", "));
894 }
895 }
896 let numbers = if prs.is_empty() {
897 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
898 if found.is_empty() {
899 log!("no open PRs");
900 return Ok(0);
901 }
902 log!(
903 "no PRs given, taking {} open: {}",
904 found.len(),
905 found
906 .iter()
907 .map(|n| format!("#{n}"))
908 .collect::<Vec<_>>()
909 .join(", ")
910 );
911 found
912 } else {
913 prs
914 };
915 let sorted = classify(&repo, &numbers)?;
916 let mut results = Vec::new();
917 for number in sorted.prs {
918 results.push(review::resume_pr(
919 &agents,
920 &cfg,
921 &repo,
922 number,
923 next_actor.as_deref(),
924 ));
925 }
926 for number in sorted.issues {
929 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
930 Ok(Some(pr)) => {
931 log!("#{number} is an issue; continuing its open PR {}", pr.url);
932 results.push(review::resume_pr(
933 &agents,
934 &cfg,
935 &repo,
936 pr.number,
937 next_actor.as_deref(),
938 ));
939 }
940 Ok(None) => logwarn!(
941 "#{number} is an issue with no open pull request. Use `spar run {number}` \
942 to implement it."
943 ),
944 Err(e) => logwarn!("#{number}: {e}"),
945 }
946 }
947 if results.is_empty() {
948 return Ok(0);
949 }
950 Ok(report(&results, &cfg, &repo))
951 }
952 }
953}
954
955#[derive(Debug, Default, Clone)]
960struct Overrides {
961 max_rounds: Option<u32>,
962 auto_merge: Option<bool>,
963 keep_worktrees: Option<bool>,
964 worktrees: Option<bool>,
965 close_skipped: Option<bool>,
966 absorb: Option<u32>,
967}
968
969impl From<&LoopFlags> for Overrides {
970 fn from(flags: &LoopFlags) -> Self {
971 Self {
972 max_rounds: flags.max_rounds,
973 auto_merge: flags.auto_merge.then_some(true),
974 keep_worktrees: flags.keep_worktrees.then_some(true),
975 worktrees: None,
976 close_skipped: None,
977 absorb: flags.absorb,
978 }
979 }
980}
981
982impl Overrides {
983 fn for_working(loop_flags: &LoopFlags, triage: &TriageFlags, no_worktrees: bool) -> Self {
986 let mut over = Overrides::from(loop_flags);
987 over.worktrees = if no_worktrees { Some(false) } else { None };
988 over.close_skipped = match (triage.close_skipped, triage.no_close_skipped) {
989 (true, _) => Some(true),
990 (_, true) => Some(false),
991 _ => None,
992 };
993 over
994 }
995}
996
997#[allow(clippy::too_many_arguments)]
1007fn work_issues(
1008 agents: &[Agent],
1009 cfg: &Config,
1010 repo: &Repo,
1011 first_wave: Vec<i64>,
1012 plan_out: &Path,
1013 results: &mut Vec<IssueRun>,
1014 stopped: &mut Vec<String>,
1015 parked: &mut Vec<String>,
1016 progress: &mut Progress,
1017 retriage: bool,
1018) -> Result<()> {
1019 let waiting = repo.read_contested();
1023 if retriage && !waiting.is_empty() {
1024 repo.forget_contested(&waiting.keys().copied().collect::<Vec<_>>());
1025 }
1026 let mut handled: BTreeSet<i64> = BTreeSet::new();
1027 let mut landed: BTreeSet<i64> = BTreeSet::new();
1031 let mut leftover: BTreeSet<i64> = BTreeSet::new();
1032 let mut worked = 0usize;
1036 let mut queue: VecDeque<Wave> = VecDeque::from([Wave::first(first_wave)]);
1037 let mut plans_written = 0usize;
1038
1039 while let Some(mut wave) = queue.pop_front() {
1046 wave.numbers.retain(|n| !handled.contains(n));
1047 if !retriage {
1048 let mut still_waiting: Vec<i64> = Vec::new();
1053 wave.numbers.retain(|n| {
1054 let Some(known) = waiting.get(n) else {
1055 return true;
1056 };
1057 still_waiting.push(*n);
1058 parked.push(format!(
1059 "#{n} contested and parked for you: {}",
1060 positions_of(known)
1061 ));
1062 false
1063 });
1064 if !still_waiting.is_empty() {
1065 log!(
1066 "{} contested on an earlier run and waiting on you. Pass --retriage to ask \
1067 again.",
1068 numbers(&still_waiting)
1069 );
1070 }
1071 }
1072 if wave.numbers.is_empty() {
1073 continue;
1074 }
1075 match wave.parent {
1076 Some(tracker) => log!(
1077 "working {} item(s) from the checklist in #{tracker}: {}",
1078 wave.numbers.len(),
1079 numbers(&wave.numbers)
1080 ),
1081 None if wave.absorbed > 0 => log!(
1082 "absorbing {} newly filed issue(s): {}",
1083 wave.numbers.len(),
1084 numbers(&wave.numbers)
1085 ),
1086 None => {}
1087 }
1088 handled.extend(wave.numbers.iter().copied());
1089
1090 let fetched = match repo.fetch_issues(&wave.numbers) {
1091 Ok(fetched) => fetched,
1092 Err(e) => {
1093 logdim!("could not read the next wave: {e}");
1094 continue;
1095 }
1096 };
1097 let plan_path = if plans_written == 0 {
1100 plan_out.to_path_buf()
1101 } else {
1102 plan_out.with_extension(format!("wave{plans_written}.json"))
1103 };
1104 plans_written += 1;
1105 let plan = match make_plan(agents, cfg, repo, &fetched, &plan_path) {
1110 Ok(plan) => plan,
1111 Err(e) => {
1112 logwarn!("triage failed for {}: {e}", numbers(&wave.numbers));
1113 stopped.push(format!(
1114 "triage failed for {}, so it was not worked: {}",
1115 numbers(&wave.numbers),
1116 first_line(&e.to_string())
1117 ));
1118 for later in &queue {
1119 stopped.push(format!(
1120 "{} was not reached after that failure",
1121 numbers(&later.numbers)
1122 ));
1123 }
1124 queue.clear();
1125 break;
1126 }
1127 };
1128 act_on_plan(cfg, repo, &plan);
1129 repo.remember_contested(&plan.contested);
1130 for item in &plan.contested {
1131 parked.push(format!(
1132 "#{} contested and parked for you: {}",
1133 item.issue,
1134 positions(&item.positions, &item.reasons)
1135 ));
1136 }
1137 for item in &plan.skipped {
1138 let what = if item.tracker {
1139 "left open as a tracker"
1140 } else if cfg.loop_cfg.close_skipped {
1141 "commented on and closed"
1142 } else {
1143 "commented on and left open"
1144 };
1145 parked.push(format!(
1146 "#{} skipped by both reviewers, {what}: {}",
1147 item.issue,
1148 item.reasons
1149 .values()
1150 .next()
1151 .map(|r| first_line(r))
1152 .unwrap_or_default()
1153 ));
1154 }
1155
1156 if cfg.loop_cfg.decompose_trackers && wave.parent.is_none() {
1160 for item in plan.skipped.iter().filter(|i| i.tracker) {
1161 let children = tracker::decompose(cfg, repo, item.issue);
1162 if !children.is_empty() {
1163 queue.push_back(wave.child(item.issue, children));
1164 }
1165 }
1166 }
1167
1168 progress.add(
1169 plan.order
1170 .iter()
1171 .filter(|item| fetched.iter().any(|i| i.number == item.issue))
1172 .count(),
1173 );
1174 let before = results.len();
1175 for item in &plan.order {
1176 let Some(issue) = fetched.iter().find(|i| i.number == item.issue) else {
1177 continue;
1178 };
1179 if let Some(why) = held_back(item, &plan, &landed) {
1180 log!("#{}: {why}", item.issue);
1181 let mut held = IssueRun::new(item.issue, item.title.clone());
1182 held.status = Status::Pending;
1183 held.notes.push(why);
1184 progress.finished(&held);
1185 results.push(held);
1186 continue;
1187 }
1188 let run = review::run_nth_issue(agents, cfg, repo, item, issue, worked);
1189 worked += 1;
1190 if run.status == Status::Merged {
1191 landed.insert(item.issue);
1192 }
1193 progress.finished(&run);
1194 results.push(run);
1195 }
1196
1197 let filed: Vec<i64> = results[before..]
1199 .iter()
1200 .flat_map(|r| r.filed.iter())
1201 .filter_map(|url| review::filed_issue_number(url))
1202 .filter(|n| !handled.contains(n))
1203 .collect::<BTreeSet<_>>()
1204 .into_iter()
1205 .collect();
1206 if filed.is_empty() {
1207 continue;
1208 }
1209 match wave.absorbed < cfg.loop_cfg.absorb_new_issues {
1210 true => queue.push_back(wave.absorb(filed)),
1211 false => leftover.extend(filed),
1212 }
1213 }
1214 if !leftover.is_empty() && cfg.loop_cfg.absorb_new_issues > 0 {
1215 log!(
1216 "{} issue(s) filed in the last wave were left for a later run: {}",
1217 leftover.len(),
1218 numbers(&leftover.iter().copied().collect::<Vec<_>>())
1219 );
1220 }
1221 Ok(())
1222}
1223
1224fn positions(
1226 positions: &std::collections::BTreeMap<String, String>,
1227 reasons: &std::collections::BTreeMap<String, String>,
1228) -> String {
1229 if positions.is_empty() {
1230 return "no positions recorded".to_string();
1231 }
1232 positions
1233 .iter()
1234 .map(|(name, side)| match reasons.get(name) {
1235 Some(why) if !why.trim().is_empty() => {
1236 format!("{name} says {side} ({})", first_line(why))
1237 }
1238 _ => format!("{name} says {side}"),
1239 })
1240 .collect::<Vec<_>>()
1241 .join("; ")
1242}
1243
1244fn positions_of(known: &crate::model::Contested) -> String {
1245 positions(&known.positions, &known.reasons)
1246}
1247
1248fn held_back(item: &PlanItem, plan: &Plan, landed: &BTreeSet<i64>) -> Option<String> {
1260 for dep in &item.depends_on {
1261 if landed.contains(dep) {
1262 continue;
1263 }
1264 if plan.order.iter().any(|other| other.issue == *dep) {
1265 return Some(format!(
1266 "held back: #{dep} is in this run and has not merged, so its work is not on the \
1267 base this would be built from. Merge #{dep} and run #{} again.",
1268 item.issue
1269 ));
1270 }
1271 if plan.skipped.iter().any(|s| s.issue == *dep) {
1272 return Some(format!(
1273 "held back: #{dep}, which it depends on, was declined by both reviewers"
1274 ));
1275 }
1276 if plan.contested.iter().any(|c| c.issue == *dep) {
1277 return Some(format!(
1278 "held back: the reviewers disagree about #{dep}, which it depends on"
1279 ));
1280 }
1281 }
1282 None
1283}
1284
1285struct Wave {
1287 numbers: Vec<i64>,
1288 absorbed: u32,
1293 parent: Option<i64>,
1295}
1296
1297impl Wave {
1298 fn first(numbers: Vec<i64>) -> Self {
1299 Self {
1300 numbers,
1301 absorbed: 0,
1302 parent: None,
1303 }
1304 }
1305
1306 fn child(&self, tracker: i64, numbers: Vec<i64>) -> Self {
1307 Self {
1308 numbers,
1309 absorbed: self.absorbed,
1310 parent: Some(tracker),
1311 }
1312 }
1313
1314 fn absorb(&self, numbers: Vec<i64>) -> Self {
1315 Self {
1316 numbers,
1317 absorbed: self.absorbed + 1,
1318 parent: None,
1319 }
1320 }
1321}
1322
1323fn numbers(items: &[i64]) -> String {
1324 items
1325 .iter()
1326 .map(|n| format!("#{n}"))
1327 .collect::<Vec<_>>()
1328 .join(", ")
1329}
1330
1331fn prepare(common: &Common, overrides: Option<Overrides>) -> Result<(Config, Repo, Vec<Agent>)> {
1332 let mut cfg = config::load(common.config.as_deref())?;
1333
1334 if let Some(first) = &common.first {
1335 if !cfg.has_agent(first) {
1336 bail!("--first must be one of: {}", cfg.agent_names().join(", "));
1337 }
1338 cfg.first_implementor = first.clone();
1339 }
1340 if let Some(base) = &common.base {
1341 cfg.loop_cfg.base_branch = base.clone();
1342 }
1343 if let Some(min) = common.min_number {
1344 cfg.loop_cfg.min_number = min;
1345 }
1346 if let Some(extra) = common.instructions.as_deref().map(str::trim) {
1350 if !extra.is_empty() {
1351 let standing = cfg.loop_cfg.instructions.trim();
1352 cfg.loop_cfg.instructions = if standing.is_empty() {
1353 extra.to_string()
1354 } else {
1355 format!("{standing}\n{extra}")
1356 };
1357 }
1358 }
1359 if let Some(over) = overrides {
1360 if let Some(v) = over.max_rounds {
1361 if v == 0 {
1362 bail!("--max-rounds must be at least 1");
1363 }
1364 cfg.loop_cfg.max_rounds = v;
1365 }
1366 if let Some(v) = over.auto_merge {
1367 cfg.loop_cfg.auto_merge = v;
1368 }
1369 if let Some(v) = over.keep_worktrees {
1370 cfg.loop_cfg.keep_worktrees = v;
1371 }
1372 if let Some(v) = over.worktrees {
1373 cfg.loop_cfg.worktrees = v;
1374 }
1375 if let Some(v) = over.close_skipped {
1376 cfg.loop_cfg.close_skipped = v;
1377 }
1378 if let Some(v) = over.absorb {
1379 cfg.loop_cfg.absorb_new_issues = v;
1380 }
1381 }
1382
1383 let repo = Repo::open(&common.repo, &cfg)?;
1384 if common.base.is_none() {
1385 cfg.loop_cfg.base_branch = repo.default_branch(cfg.base_branch());
1386 }
1387
1388 let agents = agent::build(&cfg)?;
1389 if let Some(warning) = agent::correlation_warning(&agents) {
1390 logging::warn(warning);
1391 }
1392
1393 for stale in repo.prune_worktrees(false) {
1395 let what = if stale.starts_with("branch ") {
1396 stale
1397 } else {
1398 format!("worktree {stale}")
1399 };
1400 logdim!("cleaned up finished {what}");
1401 }
1402
1403 log!("repo {} base {}", repo.root().display(), cfg.base_branch());
1404 log!(
1405 "agents: {}, first {}",
1406 agents
1407 .iter()
1408 .map(|a| format!("{}={}", a.name(), a.spec.describe()))
1409 .collect::<Vec<_>>()
1410 .join(", "),
1411 cfg.first_implementor_policy()
1412 );
1413 Ok((cfg, repo, agents))
1414}
1415
1416fn pick_issues(repo: &Repo, given: Vec<i64>, limit: usize, min_number: i64) -> Result<Vec<i64>> {
1417 if !given.is_empty() {
1418 if min_number > 0 {
1420 let below: Vec<String> = given
1421 .iter()
1422 .filter(|n| **n < min_number)
1423 .map(|n| format!("#{n}"))
1424 .collect();
1425 if !below.is_empty() {
1426 logdim!(
1427 "{} below the #{min_number} floor, taking them because you named them",
1428 below.join(", ")
1429 );
1430 }
1431 }
1432 return Ok(given);
1433 }
1434 let found = repo.list_open_issues(limit, min_number)?;
1435 if found.is_empty() {
1436 log!("no open issues");
1437 return Ok(found);
1438 }
1439 log!(
1440 "no issues given, taking {} open: {}",
1441 found.len(),
1442 found
1443 .iter()
1444 .map(|n| format!("#{n}"))
1445 .collect::<Vec<_>>()
1446 .join(", ")
1447 );
1448 Ok(found)
1449}
1450
1451fn pick_for_split(
1464 agents: &[Agent],
1465 cfg: &Config,
1466 repo: &Repo,
1467 limit: usize,
1468 mode: &split::Mode,
1469) -> Result<Vec<(i64, ItemKind)>> {
1470 let issues = repo.list_open_issues(limit, cfg.loop_cfg.min_number)?;
1471 let prs = repo.list_open_prs(limit, cfg.loop_cfg.min_number)?;
1472 log!(
1473 "no numbers given, considering {} open issue(s) and {} open pull request(s)",
1474 issues.len(),
1475 prs.len()
1476 );
1477 if issues.is_empty() && prs.is_empty() {
1478 return Ok(Vec::new());
1479 }
1480
1481 let issue_rows = repo.open_issue_rows();
1482 let pr_rows = repo.open_pr_rows();
1483 let mut candidates: Vec<split::Candidate> = Vec::new();
1484 let mut already = 0usize;
1485
1486 for number in &issues {
1487 let Some(row) = issue_rows.iter().find(|i| i.number == *number) else {
1488 logdim!("could not read #{number}, leaving it alone");
1489 continue;
1490 };
1491 if split::already_split(row.body_text()) && !mode.again {
1494 already += 1;
1495 continue;
1496 }
1497 candidates.push(split::Candidate::from_issue(row));
1498 }
1499 for number in &prs {
1500 match pr_rows.iter().find(|p| p.number == *number) {
1501 Some(row) => candidates.push(split::Candidate::from_pr(row)),
1502 None => logdim!("could not read PR #{number}, leaving it alone"),
1503 }
1504 }
1505 if already > 0 {
1506 log!("{already} issue(s) already split, skipped. --again reopens them.");
1507 }
1508 if candidates.is_empty() {
1509 return Ok(Vec::new());
1510 }
1511
1512 let agent = agent::find(agents, &cfg.first_implementor)?;
1513 log!(
1514 "screening {} item(s) with {}",
1515 candidates.len(),
1516 agent.name()
1517 );
1518 let verdicts = split::screen(agent, cfg, repo, &candidates)?;
1519
1520 let mut picked = Vec::new();
1521 for candidate in &candidates {
1522 match verdicts.iter().find(|v| v.item == candidate.number) {
1523 Some(v) if v.split => {
1524 log!(" split #{}: {}", candidate.number, v.reason.trim());
1525 picked.push((candidate.number, candidate.kind));
1526 }
1527 Some(v) => logdim!(" leave #{} whole: {}", candidate.number, v.reason.trim()),
1530 None => logdim!(" no verdict for #{}, leaving it whole", candidate.number),
1531 }
1532 }
1533 if picked.is_empty() {
1534 log!("nothing worth splitting");
1535 }
1536 Ok(picked)
1537}
1538
1539#[derive(Debug, Default)]
1545struct Sorted {
1546 issues: Vec<i64>,
1547 prs: Vec<i64>,
1548}
1549
1550fn classify(repo: &Repo, numbers: &[i64]) -> Result<Sorted> {
1551 let mut sorted = Sorted::default();
1552 for number in numbers {
1553 match repo.item_kind(*number)? {
1554 ItemKind::Issue => sorted.issues.push(*number),
1555 ItemKind::Pr => sorted.prs.push(*number),
1556 }
1557 }
1558 if !sorted.issues.is_empty() && !sorted.prs.is_empty() {
1559 log!(
1560 "{} issue(s) and {} pull request(s) given",
1561 sorted.issues.len(),
1562 sorted.prs.len()
1563 );
1564 }
1565 Ok(sorted)
1566}
1567
1568fn issues_covered_by(repo: &Repo, prs: &[i64]) -> BTreeMap<i64, i64> {
1581 let mut covered = BTreeMap::new();
1582 for number in prs {
1583 match repo.pr_view(*number) {
1584 Ok(view) => {
1585 for issue in &view.closing_issues_references {
1586 covered.insert(issue.number, *number);
1587 }
1588 }
1589 Err(e) => logwarn!(
1590 "#{number}: could not read which issue(s) it closes, so none are skipped: {}",
1591 e.last_line()
1592 ),
1593 }
1594 }
1595 covered
1596}
1597
1598fn issues_not_on_named_prs(issues: &[i64], covered: &BTreeMap<i64, i64>) -> Vec<i64> {
1601 issues
1602 .iter()
1603 .copied()
1604 .filter(|issue| !covered.contains_key(issue))
1605 .collect()
1606}
1607
1608fn make_plan(
1609 agents: &[Agent],
1610 cfg: &Config,
1611 repo: &Repo,
1612 issues: &[Issue],
1613 plan_out: &Path,
1614) -> Result<Plan> {
1615 let plan = triage::triage(agents, cfg, repo, issues)?;
1616
1617 std::fs::write(plan_out, serde_json::to_vec_pretty(&plan)?)
1618 .map_err(|e| spar_err!("could not write {}: {e}", plan_out.display()))?;
1619 log!("plan written to {}", plan_out.display());
1620
1621 for item in &plan.order {
1622 let after = if item.depends_on.is_empty() {
1625 String::new()
1626 } else {
1627 format!(
1628 " after {}",
1629 item.depends_on
1630 .iter()
1631 .map(|n| format!("#{n}"))
1632 .collect::<Vec<_>>()
1633 .join(", ")
1634 )
1635 };
1636 log!(
1637 " do #{} [{}/{}]{after} {}",
1638 item.issue,
1639 item.complexity,
1640 item.risk,
1641 item.title
1642 );
1643 }
1644 for item in &plan.skipped {
1645 if item.tracker {
1646 log!(
1647 " hold #{} (both reviewers: tracks work filed elsewhere)",
1648 item.issue
1649 );
1650 } else {
1651 log!(" skip #{} (both reviewers: not worth doing)", item.issue);
1652 }
1653 }
1654 for item in &plan.contested {
1655 log!(" ?? #{} contested, parked for you to decide", item.issue);
1656 }
1657 Ok(plan)
1658}
1659
1660fn act_on_plan(cfg: &Config, repo: &Repo, plan: &Plan) {
1670 for item in &plan.skipped {
1671 let body = review::skip_comment(item, &repo.style);
1672 let close = cfg.loop_cfg.close_skipped && !item.tracker;
1673 let outcome = if close {
1674 repo.close_issue(item.issue, &body)
1675 } else {
1676 repo.comment_issue(item.issue, &body)
1677 };
1678 match outcome {
1679 Ok(()) if close => log!(" closed #{}", item.issue),
1680 Ok(()) if item.tracker => {
1681 log!(
1682 " left #{} open, it tracks work filed elsewhere",
1683 item.issue
1684 )
1685 }
1686 Ok(()) => {}
1687 Err(e) => logdim!("could not update #{}: {e}", item.issue),
1688 }
1689 }
1690}
1691
1692fn cmd_scrub_filter() -> Result<i32> {
1697 let mut input = String::new();
1698 std::io::stdin()
1699 .read_to_string(&mut input)
1700 .map_err(|e| spar_err!("could not read a commit message from stdin: {e}"))?;
1701 let out = style::scrub(&input, &crate::repo::style_from_env());
1702 let mut stdout = std::io::stdout();
1703 stdout
1704 .write_all(out.as_bytes())
1705 .and_then(|_| stdout.write_all(b"\n"))
1706 .map_err(|e| spar_err!("could not write the scrubbed message: {e}"))?;
1707 Ok(0)
1708}
1709
1710fn cmd_clean(
1711 repo_path: &Path,
1712 config_path: Option<&Path>,
1713 all: bool,
1714 pr_state: bool,
1715) -> Result<i32> {
1716 let cfg = config::load(config_path)?;
1717 let repo = Repo::open(repo_path, &cfg)?;
1718 let mut removed = repo.prune_worktrees(all);
1719 removed.extend(repo.prune_state());
1720 if pr_state {
1721 removed.extend(repo.prune_pr_state(None));
1722 }
1723 if removed.is_empty() {
1724 if repo.write_summary().failed > 0 {
1725 println!("nothing removed");
1726 } else {
1727 println!("nothing to clean");
1728 }
1729 } else {
1730 for item in removed {
1731 println!("removed {item}");
1732 }
1733 }
1734 Ok(report_writes(&repo))
1735}
1736
1737fn cmd_post(
1739 prs: &[i64],
1740 repo_path: &Path,
1741 config_path: Option<&Path>,
1742 file: Option<&Path>,
1743 dry_run: bool,
1744) -> Result<i32> {
1745 let cfg = config::load(config_path)?;
1746 let repo = Repo::open(repo_path, &cfg)?;
1747
1748 if file.is_some() && prs.len() > 1 {
1749 bail!("--file posts one review, so give it one pull request number");
1750 }
1751
1752 let mut failed = false;
1753 for number in prs {
1754 let text = match file {
1755 Some(path) => std::fs::read_to_string(path)
1756 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?,
1757 None => match repo.read_pending_comment(*number) {
1758 Some(text) => text,
1759 None => {
1760 logging::error(format!(
1761 "no saved review for PR #{number}. `spar review {number} --dry-run` \
1762 produces one, or pass --file."
1763 ));
1764 failed = true;
1765 continue;
1766 }
1767 },
1768 };
1769 if text.trim().is_empty() {
1770 logging::error(format!("the saved review for PR #{number} is empty"));
1771 failed = true;
1772 continue;
1773 }
1774 if dry_run {
1775 println!("\n{}\n", text.trim());
1776 log!("would post the above to PR #{number}");
1777 continue;
1778 }
1779 match repo.comment_pr(*number, &text) {
1782 Ok(()) => {
1783 log!("posted to PR #{number}");
1784 if let Some(mut record) = repo.read_review_record(*number) {
1787 if !record.posted {
1788 record.posted = true;
1789 if let Err(e) = repo.write_review_record(*number, &record) {
1790 logdim!("could not mark the review record posted: {e}");
1791 }
1792 }
1793 }
1794 }
1795 Err(e) => {
1796 logging::error(format!("could not post to PR #{number}: {e}"));
1797 failed = true;
1798 }
1799 }
1800 }
1801 Ok(i32::from(failed).max(report_writes(&repo)))
1802}
1803
1804#[allow(clippy::too_many_arguments)]
1810fn cmd_brainstorm(
1811 topic: Vec<String>,
1812 common: &Common,
1813 max_rounds: Option<u32>,
1814 ideas: usize,
1815 out: Option<PathBuf>,
1816 file_issues: bool,
1817 from: Option<&Path>,
1818) -> Result<i32> {
1819 if ideas == 0 {
1820 bail!("--ideas must be at least 1");
1821 }
1822 if let Some(path) = from {
1823 let cfg = config::load(common.config.as_deref())?;
1824 let repo = Repo::open(&common.repo, &cfg)?;
1825 let text = std::fs::read_to_string(path)
1826 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
1827 let entries = brainstorm::parse_session(&text);
1828 if entries.is_empty() {
1829 bail!(
1830 "no ideas in {}: an idea is a `## ` heading with its sections under it",
1831 path.display()
1832 );
1833 }
1834 log!(
1835 "filing {} idea{} from {}",
1836 entries.len(),
1837 if entries.len() == 1 { "" } else { "s" },
1838 path.display()
1839 );
1840 brainstorm::file_ideas(&repo, &entries);
1841 return Ok(report_writes(&repo));
1842 }
1843
1844 let overrides = Overrides {
1845 max_rounds,
1846 ..Overrides::default()
1847 };
1848 let (cfg, repo, agents) = prepare(common, Some(overrides))?;
1849 let joined = topic.join(" ").trim().to_string();
1850 let session = brainstorm::Session {
1851 topic: (!joined.is_empty()).then_some(joined),
1852 budget: cfg.loop_cfg.max_rounds,
1853 wanted: ideas,
1854 };
1855 let now = clock::now_secs();
1856 let outcome = brainstorm::run(&agents, &cfg, &repo, &session)?;
1857 let path = out.unwrap_or_else(|| brainstorm::default_path(&repo, &session, now));
1858 let text = brainstorm::render_session(&outcome, &clock::date_line(now), &repo.style);
1859 crate::repo::write_text_atomic(&path, &text)?;
1860 brainstorm::print_summary(&outcome, &path);
1861 if file_issues {
1862 println!();
1863 brainstorm::file_ideas(&repo, &brainstorm::issue_entries(&outcome, &repo.style));
1864 }
1865
1866 let spent = spend::taken();
1867 if let Err(e) = repo.record_spend(&spent) {
1868 logdim!("could not record what this run spent: {e}");
1869 }
1870 if let Some(summary) = spend::summary(&spent) {
1871 println!("\n{summary}");
1872 }
1873 Ok(report_writes(&repo))
1874}
1875
1876fn cmd_init_update(out: &Path) -> Result<i32> {
1882 let text = std::fs::read_to_string(out)
1883 .map_err(|e| spar_err!("could not read {}: {e}", out.display()))?;
1884 config::parse(&text).map_err(|e| spar_err!("{} does not parse: {e}", out.display()))?;
1887
1888 let unset = config::unmentioned_options(&text);
1889 if unset.is_empty() {
1890 println!("{} already mentions every setting.", out.display());
1891 return Ok(0);
1892 }
1893
1894 let mut block = String::new();
1895 if !text.ends_with('\n') {
1896 block.push('\n');
1897 }
1898 block.push_str("\n# Added by `spar init --update`: settings this file did not mention,\n");
1899 block.push_str("# shown at their defaults. Uncomment one to change it.\n");
1900 let mut section = "";
1901 for option in &unset {
1902 if option.section != section {
1903 section = option.section;
1904 block.push_str(&format!("\n# [{section}]\n"));
1905 }
1906 block.push('\n');
1911 block.push_str(&wrap_comment(note_for(&option.key)));
1912 block.push_str(&format!("# {} = {}\n", option.key, option.default));
1913 }
1914
1915 use std::io::Write;
1916 std::fs::OpenOptions::new()
1917 .append(true)
1918 .open(out)
1919 .and_then(|mut f| f.write_all(block.as_bytes()))
1920 .map_err(|e| spar_err!("could not append to {}: {e}", out.display()))?;
1921
1922 println!(
1923 "added {} setting(s) to {} as comments",
1924 unset.len(),
1925 out.display()
1926 );
1927 Ok(0)
1928}
1929
1930fn cmd_init(out: &Path, force: bool) -> Result<i32> {
1931 if out.exists() && !force {
1932 logging::error(format!(
1933 "{} already exists. `--update` appends any settings it does not mention, \
1934 `--force` overwrites it.",
1935 out.display()
1936 ));
1937 return Ok(1);
1938 }
1939
1940 let presets = config::available_presets();
1941 if presets.is_empty() {
1942 bail!("no presets available, which should be impossible in a released build");
1943 }
1944
1945 let mut found: Vec<(String, PathBuf, config::AgentSpec)> = Vec::new();
1946 for name in &presets {
1947 let raw = config::load_preset(name)?;
1948 let mut spec: config::AgentSpec = match raw
1952 .as_table()
1953 .cloned()
1954 .ok_or_else(|| spar_err!("not a table"))
1955 .and_then(|t| {
1956 toml::Value::Table(t)
1957 .try_into()
1958 .map_err(|e| spar_err!("{e}"))
1959 }) {
1960 Ok(spec) => spec,
1961 Err(e) => {
1962 println!(" BROKEN {name:10} {}", e.first_line());
1963 continue;
1964 }
1965 };
1966 spec.name = name.clone();
1967 match Agent::new(spec.clone()).resolve_bin() {
1968 Ok(path) => {
1969 println!(" found {name:10} {}", path.display());
1970 found.push((name.clone(), path.to_path_buf(), spec));
1971 }
1972 Err(_) => println!(" missing {name}"),
1973 }
1974 }
1975
1976 if found.len() < 2 {
1977 logging::error(format!(
1978 "need two agent CLIs, found {}. Install another, or write {} by hand using the \
1979 presets as a reference.",
1980 found.len(),
1981 out.display()
1982 ));
1983 return Ok(1);
1984 }
1985
1986 let chosen: Vec<&(String, PathBuf, config::AgentSpec)> = found.iter().take(2).collect();
1988 if found.len() > 2 {
1989 log!(
1990 "{} agents available, picking {} and {}. Edit {} to change.",
1991 found.len(),
1992 chosen[0].0,
1993 chosen[1].0,
1994 out.display()
1995 );
1996 }
1997
1998 let mut text = String::from(
1999 "# Generated by `spar init`. Each agent inherits a command template from a\n\
2000 # built in preset; anything set here overrides it.\n\
2001 #\n\
2002 # Commented lines are the other options, each with a working value.\n\
2003 # Uncomment one to change it.\n\n",
2004 );
2005 for (name, _, spec) in &chosen {
2006 text.push_str(&agent_block(name, spec));
2007 }
2008 text.push_str(&settings_block(&chosen[0].0));
2009
2010 std::fs::write(out, text).map_err(|e| spar_err!("could not write {}: {e}", out.display()))?;
2011 println!("\nwrote {}", out.display());
2012 println!("Next: `spar doctor` to check it, then `spar run` in a GitHub checkout.");
2013 Ok(0)
2014}
2015
2016fn report_fallback(agent: &Agent) {
2022 let Some(backup) = agent.fallback() else {
2023 return;
2024 };
2025 match backup.resolve_bin() {
2026 Ok(bin) => println!(
2027 " fallback {} ({})",
2028 bin.display(),
2029 backup.spec.describe()
2030 ),
2031 Err(_) => println!(
2032 " fallback {} not found, so it will not stand in. Set {} to its path.",
2033 backup.program(),
2034 backup.env_key()
2035 ),
2036 }
2037}
2038
2039type Setting = (bool, &'static str, &'static str);
2050
2051const LOOP_OPTIONS: &[Setting] = &[
2052 (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."),
2053 (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."),
2054 (false, "first_implementor", "Which agent takes the first pass. The other one reviews it."),
2055 (false, "worktrees", "Isolate each issue in its own git worktree. Set false to work in the main checkout."),
2056 (false, "close_skipped", "Close an issue both reviewers declined, after posting the shared reasoning. A tracking issue is left open whatever this says."),
2057 (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."),
2058 (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."),
2059 (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."),
2060 (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."),
2061 (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."),
2062 (true, "keep_worktrees", "Keep worktrees after a run, for inspection."),
2063 (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."),
2064 (true, "parallel_triage", "Ask both agents to triage at once. They only read during triage, so there is nothing to serialise."),
2065 (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."),
2066 (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."),
2067 (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."),
2068 (true, "file_nits", "File nits as follow-ups too. Off, because a filed nit is somebody else's notification."),
2069 (true, "base_branch", "Only a fallback. Whatever origin/HEAD points at wins when it resolves."),
2070 (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."),
2071 (true, "state_store", "Where resume state is kept. local uses .spar/state and keeps it off the pull request."),
2072 (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."),
2073 (true, "fork_when_denied", "Publish a new branch from your fork when origin refuses the push, and open the pull request from there. GitHub returns the existing fork when there is one. Off fails the push instead, after the work is done."),
2074 (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."),
2075 (true, "max_issue_chars", "Most of one issue, its body and the comments on it, that reaches a prompt. Sized so nothing a person wrote is cut. Past it the oldest comments go first, and a cut is said out loud when it happens."),
2076 (true, "max_triage_chars", "Most every issue together, bodies and comments, 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."),
2077 (true, "checkin_trust", "Whose comments `spar checkin` will act on, and whose comments under an issue reach the prompts that implement, triage, and review it. 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. The person who filed an issue and the authenticated GitHub user are always read under it, regardless of write access. anyone answers everyone, and still only changes code when both agents agree."),
2078 (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."),
2079 (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."),
2080];
2081
2082const STYLE_OPTIONS: &[Setting] = &[
2083 (false, "ban_em_dash", "Strip em-dashes and en-dashes from everything spar posts, then refuse to post text that still has one."),
2084 (false, "ban_ai_attribution", "Strip mentions of the tooling, and Co-Authored-By trailers, from everything spar posts."),
2085 (false, "terse", "Hold model prose to a length budget. false removes the valves entirely."),
2086 (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."),
2087 (true, "inline_comments", "Put each finding of `spar review` on the line it is about as well as in the summary, when the pull request's diff shows that line. The summary is unchanged either way. false keeps every finding in the summary alone."),
2088 (true, "max_title_chars", "A finding, issue, or pull request title. Never ellipsised: a title ending in three dots reads as broken."),
2089 (true, "max_summary_chars", "A one line verdict, or a refutation's argument."),
2090 (true, "max_detail_chars", "A blocking finding's explanation, as it appears in the pull request thread."),
2091 (true, "max_body_chars", "A pull request body."),
2092 (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."),
2093];
2094
2095const LISTEN_OPTIONS: &[Setting] = &[
2096 (true, "interval", "Seconds between polls of GitHub's notifications for this repository. GitHub says how often it will answer and the larger of the two is used. A poll that finds nothing costs nothing against the rate limit, and waiting never costs an agent call."),
2097 (true, "sweep", "Seconds between sweeps of the open pull requests. The sweep sees what a notification can miss: a review asked for again, an assignment, a close, or a push, which raises no notification for a reviewer."),
2098 (true, "settle", "Seconds an item has to stay quiet before spar acts on it, so a review request and three comments in the same minute cost one recheck rather than four."),
2099 (true, "max_wait", "Most seconds an item may keep settling before it is acted on anyway."),
2100 (true, "max_rechecks", "Rechecks one pull request gets from `spar listen` before a person has to run `spar review` on it again. A cap against two bots arguing, not a target. 0 turns automatic rechecks off."),
2101 (true, "on_review_requested", "What a pull request somebody asked you to review gets: review or ignore."),
2102 (true, "on_assigned", "What a pull request assigned to you gets: review or ignore."),
2103 (false, "on_own_pr", "What activity on a pull request you authored gets, since the same account cannot approve it: checkin may push a change both agents agree on, reply answers in words only, ignore does nothing."),
2104 (false, "verdicts", "Whether a review `spar listen` starts submits a formal verdict, approve or request changes, rather than a comment. A recheck always does."),
2105];
2106
2107fn settings_block(first_implementor: &str) -> String {
2112 let defaults: std::collections::BTreeMap<String, String> = config::known_options()
2113 .into_iter()
2114 .map(|option| (option.key, option.default))
2115 .collect();
2116 let value = |key: &str| match key {
2119 "first_implementor" => format!("\"{first_implementor}\""),
2120 other => defaults.get(other).cloned().unwrap_or_default(),
2121 };
2122
2123 let mut out = String::from("[loop]\n");
2124 out.push_str(&option_lines(LOOP_OPTIONS, &value));
2125 out.push_str(concat!(
2126 "\n[loop.effort_schedule]\n",
2127 "# One key per kind of call, so a value means what its name says. Values\n",
2128 "# are whatever each agent's own CLI accepts, listed above, so these are\n",
2129 "# examples rather than defaults. Left out, a call falls back to\n",
2130 "# round_1 or rest, and then to the effort the agent's own block asked\n",
2131 "# for. Where the two CLIs do not share a vocabulary, put the schedule\n",
2132 "# under [agents.NAME.effort_schedule] instead, with the same keys.\n",
2133 "# triage = \"low\" # both agents, over the whole queue, per wave\n",
2134 "# implement = \"high\" # writing the first draft\n",
2135 "# review_1 = \"high\" # the deep first review\n",
2136 "# review_rest = \"low\" # later rounds, which see a small delta\n",
2137 "# respond = \"high\" # the author answering a review\n",
2138 "# close = \"low\" # the closing merge safety pass\n",
2139 "# screen = \"low\" # the follow-up screen, one call for the queue\n",
2140 "# checkin = \"high\" # judging comments other people left\n",
2141 "# split = \"high\" # proposing and checking a split\n",
2142 "# brainstorm = \"high\" # every brainstorm round\n",
2143 "# recheck = \"low\" # a second look after commits or replies\n",
2144 "# round_1 = \"high\" # read by any call above with no key set\n",
2145 "# rest = \"low\" # the same, for later rounds and the close\n\n",
2146 ));
2147 out.push_str("[style]\n");
2148 out.push_str(&option_lines(STYLE_OPTIONS, &value));
2149 out.push_str("\n[listen]\n");
2150 out.push_str(&option_lines(LISTEN_OPTIONS, &value));
2151 out
2152}
2153
2154fn option_lines(options: &[Setting], value: &dyn Fn(&str) -> String) -> String {
2158 let mut out = String::new();
2159 for (commented, key, note) in options {
2160 if !out.is_empty() {
2161 out.push('\n');
2162 }
2163 out.push_str(&wrap_comment(note));
2164 let lead = if *commented { "# " } else { "" };
2165 out.push_str(&format!("{lead}{key} = {}\n", value(key)));
2166 }
2167 out
2168}
2169
2170type Probe = Box<dyn Fn() -> Result<String>>;
2173
2174fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
2181 let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
2182
2183 if let Some(extra) = &spec.options_note {
2195 if !spec.models.is_empty() || !spec.efforts.is_empty() {
2196 out.push('\n');
2197 out.push_str(&wrap_comment(extra));
2198 }
2199 }
2200 for (key, choices) in [("model", &spec.models), ("effort", &spec.efforts)] {
2201 let Some(suggested) = choices.first() else {
2202 continue;
2203 };
2204 let mut note = format!("Omit {key} to use the CLI's own default.");
2205 if choices.len() > 1 {
2206 note.push_str(&format!(" One of: {}.", choices.join(" | ")));
2207 }
2208 out.push('\n');
2209 out.push_str(&wrap_comment(¬e));
2210 out.push_str(&format!("# {key} = \"{suggested}\"\n"));
2211 }
2212
2213 out.push('\n');
2217 out.push_str(&wrap_comment(
2218 "Seconds one call may take before spar gives up. A timeout costs the whole call and is \
2219 never retried, so err long.",
2220 ));
2221 out.push_str(&format!("# timeout = {}\n", spec.timeout));
2222
2223 let backup = if name == "cursor" { "gemini" } else { "cursor" };
2226 out.push('\n');
2227 out.push_str(&wrap_comment(
2228 "A stand in for when this CLI refuses, stalls, or runs out of quota. It answers in place \
2229 of this agent, never alongside it.",
2230 ));
2231 out.push_str(&format!(
2232 "# [agents.{name}.fallback]\n# preset = \"{backup}\"\n"
2233 ));
2234
2235 out.push('\n');
2240 out.push_str(&wrap_comment(
2241 "command, output, search_paths and the rest are in spar.example.toml, for pairing a CLI \
2242 that has no preset.",
2243 ));
2244 out.push('\n');
2245 out
2246}
2247
2248fn note_for(key: &str) -> &'static str {
2254 LOOP_OPTIONS
2255 .iter()
2256 .chain(STYLE_OPTIONS)
2257 .chain(LISTEN_OPTIONS)
2258 .find(|(_, name, _)| *name == key)
2259 .map(|(_, _, note)| *note)
2260 .unwrap_or("")
2261}
2262
2263fn wrap_comment(text: &str) -> String {
2265 const WIDTH: usize = 76;
2266 let mut out = String::new();
2267 let mut line = String::from("#");
2268 for word in text.split_whitespace() {
2269 if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
2270 out.push_str(&line);
2271 out.push('\n');
2272 line = String::from("#");
2273 }
2274 line.push(' ');
2275 line.push_str(word);
2276 }
2277 if line.len() > 1 {
2278 out.push_str(&line);
2279 out.push('\n');
2280 }
2281 out
2282}
2283
2284fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
2285 let mut ok = true;
2286
2287 let probes: Vec<(&str, Probe)> = vec![
2288 (
2289 "git",
2290 Box::new(|| {
2291 proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
2292 .map(|s| first_line(&s))
2293 }),
2294 ),
2295 (
2296 "gh",
2297 Box::new(|| {
2298 proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
2299 .map(|s| first_line(&s))
2300 }),
2301 ),
2302 (
2303 "gh auth",
2304 Box::new(|| {
2305 let out = proc::exec(
2306 &["gh".into(), "auth".into(), "status".into()],
2307 &ExecOpts::new().check(false).timeout_secs(60),
2308 )?;
2309 let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
2310 if out.ok() {
2311 Ok(first_line(&text))
2312 } else {
2313 Err(spar_err!("not authenticated. Run `gh auth login`."))
2314 }
2315 }),
2316 ),
2317 ];
2318
2319 for (label, probe) in probes {
2320 match probe() {
2321 Ok(detail) => println!(" ok {label:12} {detail}"),
2322 Err(e) => {
2323 println!(" FAIL {label:12} {}", e.first_line());
2324 ok = false;
2325 }
2326 }
2327 }
2328
2329 let found = config::find_config(config_path)?;
2330 let Some(path) = found else {
2331 println!("\n no spar.toml found. Run `spar init` to generate one.");
2332 println!(
2333 " presets available: {}",
2334 config::available_presets().join(", ")
2335 );
2336 return Ok(if ok { 0 } else { 1 });
2337 };
2338
2339 println!("\n config: {}", path.display());
2340 let cfg = match config::load(Some(&path)) {
2341 Ok(cfg) => cfg,
2342 Err(e) => {
2343 println!(" FAIL config {e}");
2344 return Ok(1);
2345 }
2346 };
2347
2348 let mut resolved = Vec::new();
2351 for spec in &cfg.agents {
2352 let agent = Agent::new(spec.clone());
2353 match agent.resolve_bin() {
2354 Ok(bin) => {
2355 println!(
2356 " ok {:12} {} ({})",
2357 spec.name,
2358 bin.display(),
2359 spec.describe()
2360 );
2361 report_fallback(&agent);
2362 resolved.push(agent);
2363 }
2364 Err(e) => {
2365 println!(" FAIL {:12} {}", spec.name, e.first_line());
2366 ok = false;
2367 }
2368 }
2369 }
2370
2371 if resolved.len() == cfg.agents.len() {
2372 if let Some(warning) = agent::correlation_warning(&resolved) {
2373 println!("\n WARNING {warning}");
2374 }
2375 }
2376
2377 for line in unknown_effort_values(&cfg) {
2378 println!("\n WARNING {line}");
2379 }
2380
2381 println!(
2382 "\n settings: first={} max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
2383 cfg.first_implementor_policy(),
2384 cfg.loop_cfg.max_rounds,
2385 cfg.loop_cfg.auto_merge,
2386 cfg.loop_cfg.worktrees,
2387 cfg.loop_cfg.followups,
2388 cfg.style.terse
2389 );
2390 if let Ok(text) = std::fs::read_to_string(&path) {
2394 let unset = config::unmentioned_options(&text);
2395 if !unset.is_empty() {
2396 println!(
2397 "\n {} setting(s) this config does not mention, all at their defaults:",
2398 unset.len()
2399 );
2400 for option in &unset {
2401 println!(
2402 " [{}] {} = {}",
2403 option.section, option.key, option.default
2404 );
2405 }
2406 println!(
2407 " `spar init --update {}` appends them as comments.",
2408 path.display()
2409 );
2410 }
2411 }
2412
2413 println!(
2414 "{}",
2415 if ok {
2416 "\nready"
2417 } else {
2418 "\nmissing prerequisites"
2419 }
2420 );
2421 Ok(if ok { 0 } else { 1 })
2422}
2423
2424fn unknown_effort_values(cfg: &config::Config) -> Vec<String> {
2433 let mut out = Vec::new();
2434 for spec in &cfg.agents {
2435 if spec.efforts.is_empty() {
2436 continue;
2437 }
2438 let mut said: Vec<String> = Vec::new();
2439 for call in config::Call::every() {
2440 let Some(value) = cfg.effort_for(spec, call) else {
2441 continue;
2442 };
2443 let known = spec
2444 .efforts
2445 .iter()
2446 .any(|hint| hint.eq_ignore_ascii_case(value.trim()));
2447 if known || said.contains(&value) {
2448 continue;
2449 }
2450 said.push(value.clone());
2451 out.push(format!(
2452 "{} is asked for effort \"{value}\" on the {} call, which is not one it is known \
2453 to accept ({}). Put this agent's values under \
2454 [agents.{}.effort_schedule].",
2455 spec.name,
2456 call.key(),
2457 spec.efforts.join(" | "),
2458 spec.name
2459 ));
2460 }
2461 }
2462 out
2463}
2464
2465fn first_line(text: &str) -> String {
2466 text.trim().lines().next().unwrap_or("").trim().to_string()
2467}
2468
2469fn report(results: &[IssueRun], cfg: &Config, repo: &Repo) -> i32 {
2474 report_with(results, cfg, repo, &[], &[])
2475}
2476
2477fn report_with(
2484 results: &[IssueRun],
2485 cfg: &Config,
2486 repo: &Repo,
2487 stopped: &[String],
2488 parked: &[String],
2489) -> i32 {
2490 let spent = spend::taken();
2493 if let Err(e) = repo.record_spend(&spent) {
2494 logdim!("could not record what this run spent: {e}");
2495 }
2496 println!("\n{}", "=".repeat(60));
2497 for r in results {
2498 print_outcome(r, &spent);
2499 }
2500 println!("{}", "=".repeat(60));
2501
2502 if cfg.alternate_first && results.len() > 1 {
2503 println!(
2504 "\nfirst implementor alternated, starting with {}. Who wrote each first draft is in \
2505 .spar/state/implementors.json.",
2506 cfg.first_implementor
2507 );
2508 }
2509 if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
2510 println!("\nApproved PRs are waiting on you to merge.");
2511 }
2512 let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
2513 if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
2514 println!(
2515 "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
2516 Set followups = \"issues\" to file them."
2517 );
2518 }
2519 if let Some(summary) = spend::summary(&spent) {
2520 println!("\n{summary}");
2521 }
2522 for lines in [parked, stopped] {
2523 if lines.is_empty() {
2524 continue;
2525 }
2526 println!();
2527 for line in lines {
2528 println!("{line}");
2529 }
2530 }
2531 let issue_exit = i32::from(!results.iter().all(IssueRun::succeeded) || !stopped.is_empty());
2532 issue_exit.max(report_writes(repo))
2533}
2534
2535pub(crate) fn print_outcome(r: &IssueRun, spent: &[spend::Spent]) {
2537 println!(
2538 "#{:<5} {:<10} rounds={} {}",
2539 r.issue,
2540 r.status.to_string(),
2541 r.rounds,
2542 r.pr.as_deref().unwrap_or("")
2543 );
2544 for note in &r.notes {
2545 println!(" {}", first_line(note));
2546 }
2547 for url in &r.filed {
2548 println!(" filed {url}");
2549 }
2550 for dispute in &r.disputes {
2551 println!(
2552 " disputed: {}",
2553 report_item(&dispute.title, &dispute.file)
2554 );
2555 }
2556 for finding in &r.noted {
2557 println!(
2558 " noted, not blocking: {}",
2559 report_item(&finding.title, &finding.file)
2560 );
2561 }
2562 if let Some(cost) = spend::for_subject(spent, r.issue) {
2563 println!(" {cost}");
2564 }
2565}
2566
2567pub(crate) fn report_writes(repo: &Repo) -> i32 {
2572 let writes = repo.write_summary();
2573 if let Some(line) = write_summary_line(writes) {
2574 println!("\n{line}");
2575 }
2576 write_exit_code(writes)
2577}
2578
2579fn write_summary_line(writes: WriteSummary) -> Option<String> {
2580 (writes.attempted > 0).then(|| {
2581 format!(
2582 "writes: {} attempted, {} succeeded, {} failed",
2583 writes.attempted,
2584 writes.succeeded(),
2585 writes.failed
2586 )
2587 })
2588}
2589
2590fn write_exit_code(writes: WriteSummary) -> i32 {
2591 i32::from(writes.failed > 0)
2592}
2593
2594fn report_item(title: &str, file: &str) -> String {
2595 match file.trim() {
2596 "" => title.to_string(),
2597 location => format!("{title} ({location})"),
2598 }
2599}
2600
2601#[cfg(test)]
2602mod tests {
2603 use super::*;
2604 use clap::CommandFactory;
2605
2606 fn planned(issue: i64, depends_on: Vec<i64>) -> PlanItem {
2607 PlanItem {
2608 issue,
2609 title: format!("issue {issue}"),
2610 complexity: crate::model::Complexity::S,
2611 risk: crate::model::Risk::Low,
2612 depends_on,
2613 reason: "worth doing".into(),
2614 }
2615 }
2616
2617 #[test]
2626 fn a_dependent_waits_for_a_dependency_that_has_not_landed() {
2627 let plan = Plan {
2628 order: vec![planned(9, vec![]), planned(10, vec![9])],
2629 ..Plan::default()
2630 };
2631 let mut landed = BTreeSet::new();
2632
2633 let why = held_back(&plan.order[1], &plan, &landed).expect("held");
2634 assert!(why.contains("#9"), "{why}");
2635 assert!(why.contains("has not merged"), "{why}");
2636 assert!(held_back(&plan.order[0], &plan, &landed).is_none());
2637
2638 landed.insert(9);
2640 assert!(held_back(&plan.order[1], &plan, &landed).is_none());
2641 }
2642
2643 #[test]
2646 fn a_dependent_on_a_declined_or_contested_issue_is_held_with_the_reason() {
2647 let declined = Plan {
2648 order: vec![planned(10, vec![9])],
2649 skipped: vec![crate::model::SkippedItem {
2650 issue: 9,
2651 title: "issue 9".into(),
2652 reasons: Default::default(),
2653 tracker: false,
2654 }],
2655 ..Plan::default()
2656 };
2657 let why = held_back(&declined.order[0], &declined, &BTreeSet::new()).expect("held");
2658 assert!(why.contains("declined by both reviewers"), "{why}");
2659
2660 let contested = Plan {
2661 order: vec![planned(10, vec![9])],
2662 contested: vec![crate::model::ContestedItem {
2663 issue: 9,
2664 title: "issue 9".into(),
2665 positions: Default::default(),
2666 reasons: Default::default(),
2667 note: None,
2668 }],
2669 ..Plan::default()
2670 };
2671 let why = held_back(&contested.order[0], &contested, &BTreeSet::new()).expect("held");
2672 assert!(why.contains("disagree about #9"), "{why}");
2673 }
2674
2675 #[test]
2679 fn a_dependency_outside_the_run_does_not_hold_anything() {
2680 let plan = Plan {
2681 order: vec![planned(10, vec![900])],
2682 ..Plan::default()
2683 };
2684 assert!(held_back(&plan.order[0], &plan, &BTreeSet::new()).is_none());
2685 }
2686
2687 #[test]
2693 fn doctor_says_when_a_scheduled_effort_is_not_one_the_agent_accepts() {
2694 let text = "\
2695[agents.claude]
2696preset = \"claude\"
2697
2698[agents.codex]
2699preset = \"codex\"
2700
2701[loop.effort_schedule]
2702review_1 = \"ultra\"
2703";
2704 let cfg = config::parse(text).expect("parses");
2705 let said = unknown_effort_values(&cfg);
2706 assert_eq!(1, said.len(), "{said:?}");
2707 assert!(said[0].starts_with("claude"), "{}", said[0]);
2708 assert!(said[0].contains("ultra"), "{}", said[0]);
2709 assert!(said[0].contains("review_1"), "{}", said[0]);
2710 assert!(
2711 said[0].contains("[agents.claude.effort_schedule]"),
2712 "it has to say where to put the right value: {}",
2713 said[0]
2714 );
2715
2716 let quiet = text.replace(
2718 "[loop.effort_schedule]\nreview_1 = \"ultra\"\n",
2719 "[agents.claude.effort_schedule]\nreview_1 = \"xhigh\"\n\n\
2720 [agents.codex.effort_schedule]\nreview_1 = \"ultra\"\n",
2721 );
2722 assert!(unknown_effort_values(&config::parse(&quiet).expect("parses")).is_empty());
2723 }
2724
2725 #[test]
2726 fn a_contested_issue_reads_as_both_positions_with_their_reasons() {
2727 let mut positions_map = std::collections::BTreeMap::new();
2728 positions_map.insert("claude".to_string(), "do".to_string());
2729 positions_map.insert("codex".to_string(), "skip".to_string());
2730 let mut reasons = std::collections::BTreeMap::new();
2731 reasons.insert(
2732 "claude".to_string(),
2733 "the retry path is still wrong".to_string(),
2734 );
2735 reasons.insert(
2736 "codex".to_string(),
2737 "fixed in the rewrite last month".to_string(),
2738 );
2739
2740 let line = positions(&positions_map, &reasons);
2741 assert!(line.contains("claude says do"), "{line}");
2742 assert!(line.contains("codex says skip"), "{line}");
2743 assert!(line.contains("the retry path is still wrong"), "{line}");
2744 assert!(line.contains("fixed in the rewrite"), "{line}");
2745
2746 let bare = positions(&positions_map, &Default::default());
2748 assert!(bare.contains("claude says do"), "{bare}");
2749 }
2750
2751 #[test]
2752 fn report_items_include_their_location() {
2753 assert_eq!(
2754 "Same title (src/a.rs:10)",
2755 report_item("Same title", "src/a.rs:10")
2756 );
2757 assert_eq!("General point", report_item("General point", ""));
2758 }
2759
2760 #[test]
2761 fn every_attempted_write_failing_is_a_failed_run() {
2762 let writes = WriteSummary {
2763 attempted: 3,
2764 failed: 3,
2765 };
2766
2767 assert_eq!(1, write_exit_code(writes));
2768 assert_eq!(
2769 Some("writes: 3 attempted, 0 succeeded, 3 failed".to_string()),
2770 write_summary_line(writes)
2771 );
2772 }
2773
2774 #[test]
2775 fn a_partial_write_failure_is_reported_after_the_run() {
2776 let writes = WriteSummary {
2777 attempted: 3,
2778 failed: 1,
2779 };
2780
2781 assert_eq!(1, write_exit_code(writes));
2782 assert_eq!(2, writes.succeeded());
2783 }
2784
2785 #[test]
2786 fn the_parser_is_internally_consistent() {
2787 Cli::command().debug_assert();
2788 }
2789
2790 #[test]
2791 fn quiet_is_accepted_before_or_after_the_subcommand() {
2792 for argv in [
2793 vec!["spar", "--quiet", "run", "42"],
2794 vec!["spar", "run", "42", "--quiet"],
2795 vec!["spar", "resume", "--quiet"],
2796 vec!["spar", "init", "-q"],
2797 ] {
2798 assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
2799 }
2800 assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
2801 }
2802
2803 #[test]
2804 fn several_issue_numbers_are_accepted() {
2805 let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
2806 match cli.command {
2807 Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
2808 other => panic!("{other:?}"),
2809 }
2810 }
2811
2812 #[test]
2813 fn issue_numbers_and_flags_can_be_interleaved() {
2814 let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
2815 match cli.command {
2816 Command::Run {
2817 issues, loop_flags, ..
2818 } => {
2819 assert_eq!(vec![42, 51], issues);
2820 assert!(loop_flags.auto_merge);
2821 }
2822 other => panic!("{other:?}"),
2823 }
2824 }
2825
2826 #[test]
2827 fn every_command_that_reads_a_config_accepts_one() {
2828 for argv in [
2829 vec!["spar", "run", "42"],
2830 vec!["spar", "triage"],
2831 vec!["spar", "resume"],
2832 vec!["spar", "followup"],
2833 vec!["spar", "checkin"],
2834 vec!["spar", "split"],
2835 vec!["spar", "brainstorm"],
2836 vec!["spar", "listen"],
2837 vec!["spar", "clean"],
2838 vec!["spar", "doctor"],
2839 ] {
2840 let mut full = argv.clone();
2841 full.extend(["--config", "other.toml"]);
2842 let cli = Cli::parse_from(&full);
2843 let config = match cli.command {
2844 Command::Run { common, .. }
2845 | Command::Triage { common, .. }
2846 | Command::Resume { common, .. }
2847 | Command::Followup { common, .. }
2848 | Command::Split { common, .. }
2849 | Command::Brainstorm { common, .. }
2850 | Command::Listen { common, .. }
2851 | Command::Checkin { common, .. } => common.config,
2852 Command::Clean { config, .. } | Command::Doctor { config } => config,
2853 other => panic!("{other:?}"),
2854 };
2855 assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
2856 }
2857 }
2858
2859 #[test]
2860 fn auto_merge_is_off_unless_asked_for() {
2861 let cli = Cli::parse_from(["spar", "run"]);
2862 match cli.command {
2863 Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
2864 other => panic!("{other:?}"),
2865 }
2866 }
2867
2868 #[test]
2871 fn every_command_that_reads_a_config_takes_instructions() {
2872 for argv in [
2875 vec!["spar", "run", "7", "--instructions", "Do not wait for CI."],
2876 vec![
2877 "spar",
2878 "triage",
2879 "7",
2880 "--instructions",
2881 "Do not wait for CI.",
2882 ],
2883 vec![
2884 "spar",
2885 "resume",
2886 "7",
2887 "--instructions",
2888 "Do not wait for CI.",
2889 ],
2890 vec![
2891 "spar",
2892 "review",
2893 "7",
2894 "--instructions",
2895 "Do not wait for CI.",
2896 ],
2897 vec!["spar", "followup", "--instructions", "Do not wait for CI."],
2898 vec![
2899 "spar",
2900 "checkin",
2901 "7",
2902 "--instructions",
2903 "Do not wait for CI.",
2904 ],
2905 vec![
2906 "spar",
2907 "split",
2908 "7",
2909 "--instructions",
2910 "Do not wait for CI.",
2911 ],
2912 vec![
2913 "spar",
2914 "brainstorm",
2915 "a",
2916 "subject",
2917 "--instructions",
2918 "Do not wait for CI.",
2919 ],
2920 vec![
2921 "spar",
2922 "listen",
2923 "7",
2924 "--instructions",
2925 "Do not wait for CI.",
2926 ],
2927 ] {
2928 let parsed = Cli::parse_from(&argv);
2929 let common = match parsed.command {
2930 Command::Run { common, .. }
2931 | Command::Triage { common, .. }
2932 | Command::Resume { common, .. }
2933 | Command::Review { common, .. }
2934 | Command::Followup { common, .. }
2935 | Command::Split { common, .. }
2936 | Command::Brainstorm { common, .. }
2937 | Command::Listen { common, .. }
2938 | Command::Checkin { common, .. } => common,
2939 other => panic!("{other:?}"),
2940 };
2941 assert_eq!(
2942 Some("Do not wait for CI."),
2943 common.instructions.as_deref(),
2944 "{argv:?}"
2945 );
2946 }
2947 }
2948
2949 #[test]
2950 fn the_two_close_skipped_flags_are_mutually_exclusive() {
2951 assert!(
2952 Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
2953 );
2954 }
2955
2956 #[test]
2959 fn close_skipped_is_offered_only_where_it_means_something() {
2960 assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
2961 assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
2962 assert!(Cli::try_parse_from(["spar", "followup", "--close-skipped"]).is_ok());
2964 assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
2965 assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
2966 assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
2967 }
2968
2969 #[test]
2972 fn brainstorm_takes_words_or_nothing() {
2973 match Cli::parse_from(["spar", "brainstorm", "novel", "uses", "of", "script"]).command {
2974 Command::Brainstorm { topic, ideas, .. } => {
2975 assert_eq!(vec!["novel", "uses", "of", "script"], topic);
2976 assert_eq!(5, ideas);
2977 }
2978 other => panic!("{other:?}"),
2979 }
2980 match Cli::parse_from(["spar", "brainstorm", "--ideas", "3", "--max-rounds", "2"]).command {
2981 Command::Brainstorm {
2982 topic,
2983 ideas,
2984 max_rounds,
2985 ..
2986 } => {
2987 assert!(topic.is_empty());
2988 assert_eq!(3, ideas);
2989 assert_eq!(Some(2), max_rounds);
2990 }
2991 other => panic!("{other:?}"),
2992 }
2993 }
2994
2995 #[test]
2999 fn filing_from_a_saved_session_needs_file_issues_and_writes_no_session() {
3000 assert!(Cli::try_parse_from(["spar", "brainstorm", "--from", "s.md"]).is_err());
3001 assert!(
3002 Cli::try_parse_from(["spar", "brainstorm", "--from", "s.md", "--file-issues"]).is_ok()
3003 );
3004 assert!(Cli::try_parse_from([
3005 "spar",
3006 "brainstorm",
3007 "--from",
3008 "s.md",
3009 "--file-issues",
3010 "--out",
3011 "t.md"
3012 ])
3013 .is_err());
3014 assert!(
3015 Cli::try_parse_from(["spar", "brainstorm", "x", "--out", "t.md", "--file-issues"])
3016 .is_ok()
3017 );
3018 }
3019
3020 #[test]
3023 fn followup_takes_no_numbers() {
3024 assert!(Cli::try_parse_from(["spar", "followup"]).is_ok());
3025 assert!(Cli::try_parse_from(["spar", "followup", "42"]).is_err());
3026 }
3027
3028 #[test]
3031 fn the_two_stopping_points_are_mutually_exclusive() {
3032 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only"]).is_ok());
3033 assert!(Cli::try_parse_from(["spar", "followup", "--file-only"]).is_ok());
3034 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only", "--file-only"]).is_err());
3035 }
3036
3037 #[test]
3038 fn the_close_skipped_pair_resolves_to_a_tristate() {
3039 let read = |argv: &[&str]| match Cli::parse_from(argv).command {
3040 Command::Run { triage_flags, .. } => {
3041 match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
3042 (true, _) => Some(true),
3043 (_, true) => Some(false),
3044 _ => None,
3045 }
3046 }
3047 other => panic!("{other:?}"),
3048 };
3049 assert_eq!(None, read(&["spar", "run"]));
3050 assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
3051 assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
3052 }
3053
3054 #[test]
3055 fn the_default_limit_is_twenty() {
3056 let cli = Cli::parse_from(["spar", "run"]);
3057 match cli.command {
3058 Command::Run { common, .. } => assert_eq!(20, common.limit),
3059 other => panic!("{other:?}"),
3060 }
3061 }
3062
3063 #[test]
3064 fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
3065 assert!(matches!(
3066 Cli::parse_from(["spar", "scrub-filter"]).command,
3067 Command::ScrubFilter
3068 ));
3069 let help = Cli::command().render_long_help().to_string();
3070 assert!(
3071 !help.contains("scrub-filter"),
3072 "it is plumbing, not a command"
3073 );
3074 }
3075
3076 #[test]
3077 fn review_takes_pr_numbers_and_a_dry_run() {
3078 let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
3079 match cli.command {
3080 Command::Review { items, dry_run, .. } => {
3081 assert_eq!(vec![101, 102], items);
3082 assert!(dry_run);
3083 }
3084 other => panic!("{other:?}"),
3085 }
3086 }
3087
3088 #[test]
3089 fn review_posts_unless_told_not_to() {
3090 match Cli::parse_from(["spar", "review", "101"]).command {
3091 Command::Review { dry_run, .. } => assert!(!dry_run),
3092 other => panic!("{other:?}"),
3093 }
3094 }
3095
3096 #[test]
3097 fn review_with_no_numbers_is_allowed() {
3098 match Cli::parse_from(["spar", "review"]).command {
3099 Command::Review { items, .. } => assert!(items.is_empty()),
3100 other => panic!("{other:?}"),
3101 }
3102 }
3103
3104 #[test]
3105 fn review_takes_its_own_round_budget() {
3106 match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
3107 Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
3108 other => panic!("{other:?}"),
3109 }
3110 }
3111
3112 #[test]
3113 fn checkin_takes_pr_numbers_and_a_dry_run() {
3114 match Cli::parse_from(["spar", "checkin", "108", "112", "--dry-run"]).command {
3115 Command::Checkin { items, dry_run, .. } => {
3116 assert_eq!(vec![108, 112], items);
3117 assert!(dry_run);
3118 }
3119 other => panic!("{other:?}"),
3120 }
3121 match Cli::parse_from(["spar", "checkin"]).command {
3122 Command::Checkin { items, dry_run, .. } => {
3123 assert!(items.is_empty());
3124 assert!(!dry_run);
3125 }
3126 other => panic!("{other:?}"),
3127 }
3128 }
3129
3130 #[test]
3136 fn checkin_offers_no_flag_that_would_weaken_the_pair() {
3137 assert!(Cli::try_parse_from(["spar", "checkin", "--auto-merge"]).is_err());
3138 assert!(Cli::try_parse_from(["spar", "checkin", "--max-rounds", "1"]).is_err());
3139 assert!(Cli::try_parse_from(["spar", "checkin", "--absorb", "1"]).is_err());
3140 assert!(Cli::try_parse_from(["spar", "checkin", "--close-skipped"]).is_err());
3141 assert!(Cli::try_parse_from(["spar", "checkin", "--reply-only"]).is_ok());
3143 assert!(Cli::try_parse_from(["spar", "checkin", "--any-author"]).is_ok());
3144 assert!(Cli::try_parse_from(["spar", "checkin", "--again"]).is_ok());
3145 assert!(Cli::try_parse_from(["spar", "checkin", "--keep-worktrees"]).is_ok());
3146 }
3147
3148 #[test]
3149 fn review_takes_verdict_and_recheck_flags_and_neither_by_default() {
3150 match Cli::parse_from(["spar", "review", "108", "--verdict", "--recheck"]).command {
3151 Command::Review {
3152 items,
3153 verdict,
3154 recheck,
3155 ..
3156 } => {
3157 assert_eq!(vec![108], items);
3158 assert!(verdict);
3159 assert!(recheck);
3160 }
3161 other => panic!("{other:?}"),
3162 }
3163 match Cli::parse_from(["spar", "review", "108"]).command {
3164 Command::Review {
3165 verdict, recheck, ..
3166 } => {
3167 assert!(!verdict);
3168 assert!(!recheck);
3169 }
3170 other => panic!("{other:?}"),
3171 }
3172 assert!(Cli::try_parse_from(["spar", "checkin", "108", "--verdict"]).is_err());
3173 }
3174
3175 #[test]
3176 fn listen_takes_numbers_once_dry_run_and_since() {
3177 match Cli::parse_from([
3178 "spar",
3179 "listen",
3180 "108",
3181 "42",
3182 "--once",
3183 "--dry-run",
3184 "--since",
3185 "2h",
3186 ])
3187 .command
3188 {
3189 Command::Listen {
3190 items,
3191 once,
3192 dry_run,
3193 since,
3194 ..
3195 } => {
3196 assert_eq!(vec![108, 42], items);
3197 assert!(once);
3198 assert!(dry_run);
3199 assert_eq!(Some("2h".to_string()), since);
3200 }
3201 other => panic!("{other:?}"),
3202 }
3203 match Cli::parse_from(["spar", "listen"]).command {
3204 Command::Listen {
3205 items,
3206 once,
3207 dry_run,
3208 since,
3209 common,
3210 } => {
3211 assert!(items.is_empty());
3212 assert!(!once);
3213 assert!(!dry_run);
3214 assert_eq!(None, since);
3215 assert_eq!(20, common.limit);
3216 }
3217 other => panic!("{other:?}"),
3218 }
3219 }
3220
3221 #[test]
3222 fn resume_takes_a_next_override() {
3223 let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
3224 match cli.command {
3225 Command::Resume {
3226 prs, next_actor, ..
3227 } => {
3228 assert_eq!(vec![108], prs);
3229 assert_eq!(Some("codex".to_string()), next_actor);
3230 }
3231 other => panic!("{other:?}"),
3232 }
3233 }
3234}
3235
3236#[cfg(test)]
3237mod absorb_tests {
3238 use super::*;
3239
3240 #[test]
3241 fn absorb_is_off_unless_asked_for() {
3242 match Cli::parse_from(["spar", "run"]).command {
3243 Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
3244 other => panic!("{other:?}"),
3245 }
3246 }
3247
3248 #[test]
3249 fn absorb_takes_a_wave_count() {
3250 match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
3251 Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
3252 other => panic!("{other:?}"),
3253 }
3254 }
3255
3256 #[test]
3257 fn absorb_is_only_offered_where_issues_are_worked() {
3258 assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
3259 assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
3260 assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
3261 assert!(Cli::try_parse_from(["spar", "split", "--absorb", "1"]).is_err());
3264 }
3265}
3266
3267#[cfg(test)]
3268mod split_tests {
3269 use super::*;
3270
3271 #[test]
3272 fn split_takes_numbers_of_either_kind() {
3273 match Cli::parse_from(["spar", "split", "10", "12"]).command {
3274 Command::Split { items, .. } => assert_eq!(vec![10, 12], items),
3275 other => panic!("{other:?}"),
3276 }
3277 }
3278
3279 #[test]
3282 fn split_with_no_numbers_is_allowed() {
3283 match Cli::parse_from(["spar", "split"]).command {
3284 Command::Split {
3285 items,
3286 dry_run,
3287 again,
3288 ..
3289 } => {
3290 assert!(items.is_empty());
3291 assert!(!dry_run);
3292 assert!(!again);
3293 }
3294 other => panic!("{other:?}"),
3295 }
3296 }
3297
3298 #[test]
3302 fn split_can_be_told_to_write_nothing() {
3303 match Cli::parse_from(["spar", "split", "10", "--dry-run"]).command {
3304 Command::Split { dry_run, .. } => assert!(dry_run),
3305 other => panic!("{other:?}"),
3306 }
3307 }
3308
3309 #[test]
3312 fn split_can_be_told_to_split_something_it_already_split() {
3313 match Cli::parse_from(["spar", "split", "10", "--again"]).command {
3314 Command::Split { again, .. } => assert!(again),
3315 other => panic!("{other:?}"),
3316 }
3317 }
3318
3319 #[test]
3322 fn split_takes_the_flags_that_govern_picking() {
3323 match Cli::parse_from([
3324 "spar",
3325 "split",
3326 "--limit",
3327 "5",
3328 "--min-number",
3329 "480",
3330 "--config",
3331 "other.toml",
3332 ])
3333 .command
3334 {
3335 Command::Split { common, .. } => {
3336 assert_eq!(5, common.limit);
3337 assert_eq!(Some(480), common.min_number);
3338 assert_eq!(Some(PathBuf::from("other.toml")), common.config);
3339 }
3340 other => panic!("{other:?}"),
3341 }
3342 }
3343
3344 #[test]
3347 fn split_offers_no_flag_that_would_make_it_a_second_run() {
3348 for flag in [
3349 vec!["--auto-merge"],
3350 vec!["--max-rounds", "2"],
3351 vec!["--close-skipped"],
3352 vec!["--no-close-skipped"],
3353 vec!["--keep-worktrees"],
3354 ] {
3355 let mut argv = vec!["spar", "split", "10"];
3356 argv.extend(flag.iter().copied());
3357 assert!(Cli::try_parse_from(&argv).is_err(), "{argv:?}");
3358 }
3359 }
3360}
3361
3362#[cfg(test)]
3363mod min_number_tests {
3364 use super::*;
3365
3366 fn read(argv: &[&str]) -> Option<i64> {
3367 match Cli::parse_from(argv).command {
3368 Command::Run { common, .. }
3369 | Command::Triage { common, .. }
3370 | Command::Resume { common, .. }
3371 | Command::Review { common, .. }
3372 | Command::Followup { common, .. }
3373 | Command::Split { common, .. }
3374 | Command::Listen { common, .. }
3375 | Command::Checkin { common, .. } => common.min_number,
3376 other => panic!("{other:?}"),
3377 }
3378 }
3379
3380 #[test]
3381 fn there_is_no_floor_unless_one_is_asked_for() {
3382 assert_eq!(None, read(&["spar", "run"]));
3383 }
3384
3385 #[test]
3386 fn every_command_that_picks_for_itself_accepts_a_floor() {
3387 for cmd in [
3388 "run", "triage", "resume", "review", "checkin", "split", "listen",
3389 ] {
3390 assert_eq!(
3391 Some(480),
3392 read(&["spar", cmd, "--min-number", "480"]),
3393 "{cmd}"
3394 );
3395 }
3396 }
3397}
3398
3399#[cfg(test)]
3400mod run_targets_tests {
3401 use super::*;
3402
3403 fn covered(pairs: &[(i64, i64)]) -> BTreeMap<i64, i64> {
3404 pairs.iter().copied().collect()
3405 }
3406
3407 #[test]
3412 fn an_issue_its_named_pull_request_closes_is_not_worked_again() {
3413 let kept = issues_not_on_named_prs(&[772, 783, 784], &covered(&[(772, 786)]));
3414
3415 assert_eq!(vec![783, 784], kept);
3416 }
3417
3418 #[test]
3419 fn an_issue_no_named_pull_request_closes_is_still_worked() {
3420 let kept = issues_not_on_named_prs(&[783, 784, 771], &covered(&[(772, 786)]));
3421
3422 assert_eq!(vec![783, 784, 771], kept);
3423 }
3424
3425 #[test]
3428 fn an_unreadable_pull_request_drops_no_issue() {
3429 let kept = issues_not_on_named_prs(&[772, 783], &covered(&[]));
3430
3431 assert_eq!(vec![772, 783], kept);
3432 }
3433
3434 #[test]
3436 fn every_issue_one_pull_request_closes_is_taken_by_it() {
3437 let kept = issues_not_on_named_prs(&[771, 772, 783], &covered(&[(771, 786), (772, 786)]));
3438
3439 assert_eq!(vec![783], kept);
3440 }
3441}
3442
3443#[cfg(test)]
3444mod settings_block_tests {
3445 use super::*;
3446
3447 fn written(line: &str) -> String {
3450 let after = line.split_once('=').expect("an assignment").1;
3451 let mut quoted = false;
3452 for (i, c) in after.char_indices() {
3453 match c {
3454 '"' => quoted = !quoted,
3455 '#' if !quoted => return after[..i].trim().to_string(),
3456 _ => {}
3457 }
3458 }
3459 after.trim().to_string()
3460 }
3461
3462 fn line_for(text: &str, key: &str) -> String {
3463 text.lines()
3464 .find(|l| {
3465 let bare = l.trim_start().trim_start_matches('#').trim_start();
3466 bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
3467 })
3468 .unwrap_or_else(|| panic!("{key} is not offered at all:\n{text}"))
3469 .to_string()
3470 }
3471
3472 #[test]
3479 fn every_value_it_offers_is_the_default_it_actually_has() {
3480 let text = settings_block("claude");
3481 for option in config::known_options() {
3482 if option.section == "loop.effort_schedule" {
3486 continue;
3487 }
3488 let line = line_for(&text, &option.key);
3489 assert_eq!(
3490 option.default,
3491 written(&line),
3492 "the generated config offers `{}`, but the default is {}",
3493 line.trim(),
3494 option.default
3495 );
3496 }
3497 }
3498
3499 #[test]
3503 fn it_offers_every_option_the_parser_knows_about() {
3504 let text = settings_block("claude");
3505 let missing: Vec<String> = config::unmentioned_options(&text)
3506 .into_iter()
3507 .map(|o| format!("[{}] {}", o.section, o.key))
3508 .collect();
3509 assert!(missing.is_empty(), "not offered: {}", missing.join(", "));
3510 }
3511
3512 #[test]
3516 fn every_option_it_offers_can_be_uncommented_and_still_load() {
3517 let mut text = String::from(
3518 "[agents.claude]\ncommand = [\"claude\"]\n\n\
3519 [agents.codex]\ncommand = [\"codex\"]\n\n",
3520 );
3521 for line in settings_block("claude").lines() {
3522 text.push_str(uncomment(line).unwrap_or(line));
3523 text.push('\n');
3524 }
3525 let cfg = config::parse(&text).expect("a config of its own suggestions");
3526 assert_eq!("claude", cfg.first_implementor);
3527 }
3528
3529 fn uncomment(line: &str) -> Option<&str> {
3532 let bare = line.trim_start().strip_prefix('#')?.trim_start();
3533 let key = bare.split_once('=')?.0.trim();
3536 let named = !key.is_empty()
3537 && key
3538 .chars()
3539 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
3540 named.then_some(bare)
3541 }
3542
3543 #[test]
3544 fn the_agent_that_goes_first_is_the_one_that_was_chosen() {
3545 assert!(settings_block("codex").contains("first_implementor = \"codex\""));
3546 }
3547
3548 #[test]
3553 fn a_note_sits_above_the_option_it_describes() {
3554 let text = settings_block("claude");
3555 assert!(
3556 text.lines().all(|l| l.chars().count() <= 80),
3557 "a line runs off the edge:\n{text}"
3558 );
3559 assert!(
3560 text.lines().all(|l| !l.starts_with(' ')),
3561 "a line is indented, so the columns are back:\n{text}"
3562 );
3563
3564 let lines: Vec<&str> = text.lines().collect();
3566 let at = lines
3567 .iter()
3568 .position(|l| l.starts_with("max_rounds"))
3569 .expect("max_rounds");
3570 assert!(lines[at - 1].starts_with('#'), "{:?}", lines[at - 1]);
3571 assert!(
3572 lines[at - 1].contains("lifetime cap"),
3573 "the note above it is the end of its own note: {:?}",
3574 lines[at - 1]
3575 );
3576 }
3577
3578 #[test]
3580 fn every_note_starts_as_a_sentence() {
3581 for (_, key, note) in LOOP_OPTIONS
3582 .iter()
3583 .chain(STYLE_OPTIONS)
3584 .chain(LISTEN_OPTIONS)
3585 {
3586 let first = note.chars().next().expect("a note");
3587 assert!(
3588 first.is_uppercase(),
3589 "{key} reads as a margin scribble rather than a sentence: {note}"
3590 );
3591 }
3592 }
3593}
3594
3595#[cfg(test)]
3596mod agent_block_tests {
3597 use super::*;
3598
3599 fn spec(models: &[&str], efforts: &[&str]) -> config::AgentSpec {
3600 let mut spec: config::AgentSpec =
3601 toml::Value::Table(toml::from_str("command = [\"x\"]").expect("a minimal preset"))
3602 .try_into()
3603 .expect("builds");
3604 spec.models = models.iter().map(|s| s.to_string()).collect();
3605 spec.efforts = efforts.iter().map(|s| s.to_string()).collect();
3606 spec
3607 }
3608
3609 #[test]
3613 fn an_option_with_no_hints_is_left_out_rather_than_guessed_at() {
3614 let block = agent_block("cursor", &spec(&["composer-2.5", "auto"], &[]));
3615 assert!(!block.contains("..."), "{block}");
3616 assert!(!block.contains("effort"), "{block}");
3617 assert!(block.contains("# model = \"composer-2.5\""), "{block}");
3618 }
3619
3620 #[test]
3623 fn only_the_options_that_follow_are_introduced() {
3624 let model_only = agent_block("cursor", &spec(&["auto"], &[]));
3625 assert!(model_only.contains("Omit model to use"), "{model_only}");
3626 assert!(!model_only.contains("Omit effort"), "{model_only}");
3627
3628 let both = agent_block("claude", &spec(&["fable"], &["high"]));
3629 assert!(both.contains("Omit model to use"), "{both}");
3630 assert!(both.contains("Omit effort to use"), "{both}");
3631 }
3632
3633 #[test]
3636 fn a_preset_with_no_hints_still_writes_a_usable_block() {
3637 let block = agent_block("gemini", &spec(&[], &[]));
3638 assert!(!block.contains("..."), "{block}");
3639 assert!(!block.contains("Omit"), "{block}");
3640 assert!(
3641 block.starts_with("[agents.gemini]\npreset = \"gemini\"\n"),
3642 "{block}"
3643 );
3644 assert!(block.contains("[agents.gemini.fallback]"), "{block}");
3646 assert!(block.contains("# timeout = "), "{block}");
3647 }
3648
3649 #[test]
3652 fn the_timeout_offered_is_the_one_the_agent_would_use() {
3653 let mut spec = spec(&["a"], &[]);
3654 spec.timeout = 7200;
3655 assert!(
3656 agent_block("custom", &spec).contains("# timeout = 7200"),
3657 "the generator kept its own copy"
3658 );
3659 }
3660
3661 #[test]
3663 fn alternatives_are_listed_only_when_there_are_any() {
3664 assert!(agent_block("a", &spec(&["one", "two"], &[])).contains("One of: one | two."));
3665 let single = agent_block("b", &spec(&["only"], &[]));
3666 assert!(!single.contains("One of:"), "{single}");
3667 }
3668
3669 #[test]
3673 fn the_presets_note_is_said_once() {
3674 let mut spec = spec(&["m1", "m2"], &["e1", "e2"]);
3675 spec.options_note = Some("Check the current sets with: mytool --help".into());
3676 let block = agent_block("mytool", &spec);
3677 assert_eq!(
3678 1,
3679 block.matches("Check the current sets").count(),
3680 "{block}"
3681 );
3682 }
3683}