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::answer;
11use crate::brainstorm;
12use crate::checkin;
13use crate::clock;
14use crate::code;
15use crate::config::{self, Config};
16use crate::error::Result;
17use crate::followups;
18use crate::listen;
19use crate::model::{Issue, IssueRun, ItemKind, Plan, PlanItem, Status};
20use crate::proc::{self, ExecOpts};
21use crate::progress::Progress;
22use crate::repo::{Repo, WriteSummary};
23use crate::review;
24use crate::review_only;
25use crate::spend;
26use crate::split;
27use crate::style;
28use crate::tracker;
29use crate::triage;
30use crate::{bail, log, logdim, logging, logwarn, spar_err};
31
32pub const VERSION: &str = env!("CARGO_PKG_VERSION");
33
34#[derive(Parser, Debug)]
35#[command(
36 name = "spar",
37 version = VERSION,
38 about = "Two coding agents alternate implementing and reviewing GitHub issues.",
39 long_about = "Two coding agents alternate implementing and reviewing GitHub issues until a \
40 pull request converges. Neither agent reviews its own most recent edit.\n\n\
41 Arguments are issue numbers for `triage`, and pull request numbers \
42 for `resume`, `review`, and `checkin`. `run` and `split` take either: `run` \
43 continues any pull request given before it triages the issues, and drops an \
44 issue whose pull request was given too. Omit them and \
45 spar takes everything open, up to --limit. `split` applies that limit once to \
46 issues and once to pull requests. `followup` takes none: it works the queue in \
47 .spar/followups.md, and an entry there has no number to name. `brainstorm` \
48 takes words rather than numbers: the subject to brainstorm, or nothing to \
49 brainstorm the repository itself. `listen` takes issue or pull request \
50 numbers to watch, or nothing to watch the whole repository. `code` takes a \
51 project prompt, files it as a detailed issue, and runs that issue.",
52 max_term_width = 96
53)]
54pub struct Cli {
55 #[arg(short, long, global = true)]
57 pub quiet: bool,
58
59 #[command(subcommand)]
60 pub command: Command,
61}
62
63#[derive(Subcommand, Debug)]
64pub enum Command {
65 Run {
67 issues: Vec<i64>,
69 #[command(flatten)]
70 common: Common,
71 #[command(flatten)]
72 loop_flags: LoopFlags,
73 #[command(flatten)]
74 triage_flags: TriageFlags,
75 #[arg(long, default_value = "plan.json")]
77 plan_out: PathBuf,
78 #[arg(long)]
80 no_worktrees: bool,
81 },
82
83 Code {
90 #[arg(required = true, value_name = "PROMPT")]
92 prompt: Vec<String>,
93 #[command(flatten)]
94 common: Common,
95 #[command(flatten)]
96 loop_flags: LoopFlags,
97 #[command(flatten)]
98 triage_flags: TriageFlags,
99 #[arg(long)]
101 dry_run: bool,
102 #[arg(long, conflicts_with = "dry_run")]
104 file_only: bool,
105 #[arg(long, default_value = "plan.json")]
107 plan_out: PathBuf,
108 #[arg(long)]
110 no_worktrees: bool,
111 },
112
113 Followup {
125 #[command(flatten)]
126 common: Common,
127 #[command(flatten)]
128 loop_flags: LoopFlags,
129 #[command(flatten)]
130 triage_flags: TriageFlags,
131 #[arg(long, value_name = "PATH")]
133 file: Option<PathBuf>,
134 #[arg(long)]
136 screen_only: bool,
137 #[arg(long, conflicts_with = "screen_only")]
139 file_only: bool,
140 #[arg(long, default_value = "plan.json")]
142 plan_out: PathBuf,
143 #[arg(long)]
145 no_worktrees: bool,
146 },
147
148 Triage {
150 issues: Vec<i64>,
152 #[command(flatten)]
153 common: Common,
154 #[arg(long, default_value = "plan.json")]
155 plan_out: PathBuf,
156 },
157
158 Resume {
160 prs: Vec<i64>,
162 #[command(flatten)]
163 common: Common,
164 #[command(flatten)]
165 loop_flags: LoopFlags,
166 #[arg(long = "next", value_name = "AGENT")]
168 next_actor: Option<String>,
169 },
170
171 Checkin {
179 items: Vec<i64>,
182 #[command(flatten)]
183 common: Common,
184 #[arg(long)]
186 dry_run: bool,
187 #[arg(long)]
189 reply_only: bool,
190 #[arg(long)]
193 any_author: bool,
194 #[arg(long)]
196 again: bool,
197 #[arg(long)]
199 keep_worktrees: bool,
200 },
201
202 Answer {
212 items: Vec<i64>,
215 #[command(flatten)]
216 common: Common,
217 #[arg(long)]
219 dry_run: bool,
220 #[arg(long)]
222 again: bool,
223 },
224
225 Review {
230 items: Vec<i64>,
233 #[command(flatten)]
234 common: Common,
235 #[arg(long)]
237 dry_run: bool,
238 #[arg(long)]
241 max_rounds: Option<u32>,
242 #[arg(long)]
246 verdict: bool,
247 #[arg(long)]
252 recheck: bool,
253 },
254
255 Brainstorm {
267 topic: Vec<String>,
270 #[command(flatten)]
271 common: Common,
272 #[arg(long)]
276 max_rounds: Option<u32>,
277 #[arg(long, default_value_t = 5, value_name = "N")]
279 ideas: usize,
280 #[arg(long, value_name = "PATH")]
282 out: Option<PathBuf>,
283 #[arg(long)]
285 file_issues: bool,
286 #[arg(
289 long,
290 value_name = "PATH",
291 requires = "file_issues",
292 conflicts_with = "out"
293 )]
294 from: Option<PathBuf>,
295 },
296
297 Split {
314 items: Vec<i64>,
317 #[command(flatten)]
318 common: Common,
319 #[arg(long)]
321 dry_run: bool,
322 #[arg(long)]
325 again: bool,
326 },
327
328 Listen {
344 items: Vec<i64>,
347 #[command(flatten)]
348 common: Common,
349 #[arg(long)]
351 once: bool,
352 #[arg(long)]
355 dry_run: bool,
356 #[arg(long, value_name = "DURATION")]
359 since: Option<String>,
360 },
361
362 Post {
367 #[arg(required = true)]
369 prs: Vec<i64>,
370 #[arg(long, default_value = ".")]
371 repo: PathBuf,
372 #[arg(long)]
373 config: Option<PathBuf>,
374 #[arg(long, value_name = "PATH")]
376 file: Option<PathBuf>,
377 #[arg(long)]
379 dry_run: bool,
380 },
381
382 Init {
387 #[arg(long, default_value = "spar.toml")]
388 out: PathBuf,
389 #[arg(long)]
391 force: bool,
392 #[arg(long, conflicts_with = "force")]
395 update: bool,
396 },
397
398 Clean {
400 #[arg(long, default_value = ".")]
401 repo: PathBuf,
402 #[arg(long)]
403 config: Option<PathBuf>,
404 #[arg(long)]
407 all: bool,
408 #[arg(long)]
410 pr_state: bool,
411 },
412
413 Doctor {
415 #[arg(long)]
416 config: Option<PathBuf>,
417 },
418
419 #[command(hide = true)]
423 ScrubFilter,
424}
425
426#[derive(Args, Debug, Clone)]
427pub struct Common {
428 #[arg(long, default_value = ".")]
430 pub repo: PathBuf,
431 #[arg(long)]
433 pub config: Option<PathBuf>,
434 #[arg(long)]
436 pub base: Option<String>,
437 #[arg(long)]
439 pub first: Option<String>,
440 #[arg(long, default_value_t = 20)]
443 pub limit: usize,
444 #[arg(long, value_name = "N")]
447 pub min_number: Option<i64>,
448 #[arg(long, value_name = "TEXT")]
451 pub instructions: Option<String>,
452}
453
454#[derive(Args, Debug, Clone)]
455pub struct LoopFlags {
456 #[arg(long)]
459 pub max_rounds: Option<u32>,
460 #[arg(long)]
462 pub auto_merge: bool,
463 #[arg(long)]
465 pub keep_worktrees: bool,
466 #[arg(long, value_name = "N")]
469 pub absorb: Option<u32>,
470}
471
472#[derive(Args, Debug, Clone)]
475pub struct TriageFlags {
476 #[arg(long, conflicts_with = "no_close_skipped")]
478 pub close_skipped: bool,
479 #[arg(long)]
481 pub no_close_skipped: bool,
482 #[arg(long)]
488 pub retriage: bool,
489}
490
491pub fn main() -> i32 {
496 let cli = Cli::parse();
497 logging::init_color();
498 logging::set_quiet(cli.quiet);
499
500 match dispatch(cli) {
501 Ok(code) => code,
502 Err(e) => {
503 logging::error(e.to_string());
504 2
505 }
506 }
507}
508
509fn dispatch(cli: Cli) -> Result<i32> {
510 match cli.command {
511 Command::ScrubFilter => cmd_scrub_filter(),
512 Command::Doctor { config } => cmd_doctor(config.as_deref()),
513 Command::Review {
514 items,
515 common,
516 dry_run,
517 max_rounds,
518 verdict,
519 recheck,
520 } => {
521 let overrides = Overrides {
522 max_rounds,
523 ..Overrides::default()
524 };
525 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
526 let numbers = if items.is_empty() {
527 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
528 if found.is_empty() {
529 log!("no open PRs");
530 return Ok(0);
531 }
532 log!("no PRs given, reviewing {} open", found.len());
533 found
534 } else {
535 items
536 };
537 let sorted = classify(&repo, &numbers)?;
538 let mut targets = sorted.prs;
539 for number in sorted.issues {
540 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
541 Ok(Some(pr)) => {
542 log!("#{number} is an issue; reviewing its open PR {}", pr.url);
543 targets.push(pr.number);
544 }
545 Ok(None) => {
546 logwarn!("#{number} is an issue with no open pull request to review")
547 }
548 Err(e) => logwarn!("#{number}: {e}"),
549 }
550 }
551 let mut results = Vec::new();
552 for number in targets {
553 results.push(if recheck {
554 let mode = review_only::RecheckMode {
555 dry_run,
556 requested: false,
557 verdict_on_fallback: verdict,
558 };
559 review_only::recheck_pr(&agents, &cfg, &repo, number, mode)
560 } else {
561 let opts = review_only::ReviewOpts { dry_run, verdict };
562 review_only::review_pr(&agents, &cfg, &repo, number, opts)
563 });
564 }
565 if results.is_empty() {
566 return Ok(0);
567 }
568 Ok(report(&results, &cfg, &repo))
569 }
570
571 Command::Listen {
572 items,
573 common,
574 once,
575 dry_run,
576 since,
577 } => {
578 let since = since.as_deref().map(listen::parse_duration).transpose()?;
580 let (cfg, repo, agents) = prepare(&common, None)?;
581 listen::run(
582 &agents,
583 &cfg,
584 &repo,
585 listen::Opts {
586 items,
587 once,
588 dry_run,
589 since,
590 },
591 )
592 }
593
594 Command::Brainstorm {
595 topic,
596 common,
597 max_rounds,
598 ideas,
599 out,
600 file_issues,
601 from,
602 } => cmd_brainstorm(
603 topic,
604 &common,
605 max_rounds,
606 ideas,
607 out,
608 file_issues,
609 from.as_deref(),
610 ),
611
612 Command::Checkin {
613 items,
614 common,
615 dry_run,
616 reply_only,
617 any_author,
618 again,
619 keep_worktrees,
620 } => {
621 let overrides = Overrides {
622 keep_worktrees: keep_worktrees.then_some(true),
623 ..Overrides::default()
624 };
625 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
626 let mode = checkin::Mode {
627 dry_run,
628 reply_only,
629 trust: if any_author {
630 crate::config::Trust::Anyone
631 } else {
632 cfg.loop_cfg.checkin_trust
633 },
634 again,
635 resolve: cfg.loop_cfg.checkin_resolve,
636 posts: checkin::posts(&cfg),
637 };
638 let numbers = if items.is_empty() {
639 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
640 if found.is_empty() {
641 log!("no open PRs");
642 return Ok(0);
643 }
644 log!(
645 "no PRs given, checking in on {} open: {}",
646 found.len(),
647 found
648 .iter()
649 .map(|n| format!("#{n}"))
650 .collect::<Vec<_>>()
651 .join(", ")
652 );
653 found
654 } else {
655 items
656 };
657 let sorted = classify(&repo, &numbers)?;
658 let mut results = Vec::new();
659 for number in sorted.prs {
660 results.push(checkin::checkin_pr(&agents, &cfg, &repo, number, &mode));
661 }
662 for number in sorted.issues {
666 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
667 Ok(Some(pr)) => {
668 log!(
669 "#{number} is an issue; checking in on its open PR {}",
670 pr.url
671 );
672 results.push(checkin::checkin_pr(&agents, &cfg, &repo, pr.number, &mode));
673 }
674 Ok(None) => {
675 results.push(checkin::checkin_issue(&agents, &cfg, &repo, number, &mode))
676 }
677 Err(e) => results.push(checkin::failed(number, format!("#{number}"), e)),
682 }
683 }
684 if results.is_empty() {
685 return Ok(0);
686 }
687 Ok(report(&results, &cfg, &repo))
688 }
689
690 Command::Answer {
691 items,
692 common,
693 dry_run,
694 again,
695 } => cmd_answer(&common, items, dry_run, again),
696
697 Command::Split {
698 items,
699 common,
700 dry_run,
701 again,
702 } => {
703 let (cfg, repo, agents) = prepare(&common, None)?;
704 let mode = split::Mode { dry_run, again };
705 let picked = if items.is_empty() {
706 pick_for_split(&agents, &cfg, &repo, common.limit, &mode)?
707 } else {
708 let sorted = classify(&repo, &items)?;
709 let mut out: Vec<(i64, ItemKind)> = sorted
710 .issues
711 .iter()
712 .map(|n| (*n, ItemKind::Issue))
713 .collect();
714 out.extend(sorted.prs.iter().map(|n| (*n, ItemKind::Pr)));
715 out
716 };
717
718 let mut results = Vec::new();
719 let mut seen: BTreeSet<i64> = BTreeSet::new();
720 for (number, kind) in picked {
721 let target = match kind {
725 ItemKind::Pr => Some(number),
726 ItemKind::Issue => {
727 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
728 Ok(found) => found.map(|pr| {
729 log!("#{number} is an issue; splitting its open PR {}", pr.url);
730 pr.number
731 }),
732 Err(e) => {
733 logwarn!("#{number}: {e}");
734 continue;
735 }
736 }
737 }
738 };
739 if !seen.insert(target.unwrap_or(number)) {
744 logdim!("#{number} was already covered by this run, skipping it");
745 continue;
746 }
747 results.push(match target {
748 Some(pr) => split::split_pr(&agents, &cfg, &repo, pr, &mode),
749 None => split::split_issue(&agents, &cfg, &repo, number, &mode),
750 });
751 }
752 if results.is_empty() {
753 return Ok(0);
754 }
755 Ok(report(&results, &cfg, &repo))
756 }
757
758 Command::Post {
759 prs,
760 repo: repo_path,
761 config,
762 file,
763 dry_run,
764 } => cmd_post(
765 &prs,
766 &repo_path,
767 config.as_deref(),
768 file.as_deref(),
769 dry_run,
770 ),
771
772 Command::Init { out, force, update } => {
773 if update {
774 cmd_init_update(&out)
775 } else {
776 cmd_init(&out, force)
777 }
778 }
779 Command::Clean {
780 repo,
781 config,
782 all,
783 pr_state,
784 } => cmd_clean(&repo, config.as_deref(), all, pr_state),
785 Command::Triage {
786 issues,
787 common,
788 plan_out,
789 } => {
790 let (cfg, repo, agents) = prepare(&common, None)?;
791 let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
792 if numbers.is_empty() {
793 return Ok(0);
794 }
795 let sorted = classify(&repo, &numbers)?;
796 for number in &sorted.prs {
797 log!("#{number} is a pull request, nothing to triage");
798 }
799 if sorted.issues.is_empty() {
800 log!("no issues to triage");
801 return Ok(0);
802 }
803 let issues = repo.fetch_issues(&sorted.issues)?;
804 let plan = make_plan(&agents, &cfg, &repo, &issues, &plan_out)?;
808 if cfg.loop_cfg.decompose_trackers {
812 for item in plan.skipped.iter().filter(|i| i.tracker) {
813 tracker::preview(&cfg, &repo, item.issue);
814 }
815 }
816 Ok(0)
817 }
818 Command::Run {
819 issues,
820 common,
821 loop_flags,
822 triage_flags,
823 plan_out,
824 no_worktrees,
825 } => {
826 let overrides = Overrides::for_working(&loop_flags, &triage_flags, no_worktrees);
827 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
828 let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
829 if numbers.is_empty() {
830 return Ok(0);
831 }
832 run_targets(
833 &agents,
834 &cfg,
835 &repo,
836 &numbers,
837 &plan_out,
838 triage_flags.retriage,
839 )
840 }
841 Command::Code {
842 prompt,
843 common,
844 loop_flags,
845 triage_flags,
846 dry_run,
847 file_only,
848 plan_out,
849 no_worktrees,
850 } => {
851 let prompt = prompt.join(" ");
852 code::validate_prompt(&prompt)?;
853 let overrides = Overrides::for_working(&loop_flags, &triage_flags, no_worktrees);
854 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
855 let draft = code::draft(&agents, &cfg, &repo, &prompt)?;
856 if dry_run {
857 println!("# {}\n\n{}", draft.title, draft.body);
858 return Ok(0);
859 }
860 let url = repo.create_issue(&draft.title, &draft.body)?;
861 println!("issue: {url}");
864 let number = review::filed_issue_number(&url)
865 .ok_or_else(|| spar_err!("could not read the issue number from {url}"))?;
866 println!("continue this issue later with: spar run {number}");
867 if file_only {
868 return Ok(report_writes(&repo));
869 }
870 run_targets(
871 &agents,
872 &cfg,
873 &repo,
874 &[number],
875 &plan_out,
876 triage_flags.retriage,
877 )
878 }
879 Command::Followup {
880 common,
881 loop_flags,
882 triage_flags,
883 file,
884 screen_only,
885 file_only,
886 plan_out,
887 no_worktrees,
888 } => {
889 let overrides = Overrides::for_working(&loop_flags, &triage_flags, no_worktrees);
890 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
891 let path = file.unwrap_or_else(|| repo.followups_path());
892 let mode = match (screen_only, file_only) {
893 (true, _) => followups::Mode::ScreenOnly,
894 (_, true) => followups::Mode::FileOnly,
895 _ => followups::Mode::Work,
896 };
897 let outcome = followups::run(&agents, &cfg, &repo, &path, common.limit, mode)?;
898
899 let wave = followups::wave(&outcome);
900 if mode != followups::Mode::Work || wave.is_empty() {
901 return Ok(report_writes(&repo));
902 }
903 let mut results = Vec::new();
904 let mut stopped = Vec::new();
905 let mut parked = Vec::new();
906 let mut progress = Progress::new(0);
907 work_issues(
908 &agents,
909 &cfg,
910 &repo,
911 wave,
912 &plan_out,
913 &mut results,
914 &mut stopped,
915 &mut parked,
916 &mut progress,
917 triage_flags.retriage,
918 )?;
919 if results.is_empty() {
920 log!("nothing scheduled");
923 for line in parked.iter().chain(stopped.iter()) {
924 println!("{line}");
925 }
926 return Ok(report_writes(&repo).max(i32::from(!stopped.is_empty())));
927 }
928 Ok(report_with(&results, &cfg, &repo, &stopped, &parked))
929 }
930
931 Command::Resume {
932 prs,
933 common,
934 loop_flags,
935 next_actor,
936 } => {
937 let (cfg, repo, agents) = prepare(&common, Some(Overrides::from(&loop_flags)))?;
938 if let Some(name) = &next_actor {
939 if !cfg.has_agent(name) {
940 bail!("--next must be one of: {}", cfg.agent_names().join(", "));
941 }
942 }
943 let numbers = if prs.is_empty() {
944 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
945 if found.is_empty() {
946 log!("no open PRs");
947 return Ok(0);
948 }
949 log!(
950 "no PRs given, taking {} open: {}",
951 found.len(),
952 found
953 .iter()
954 .map(|n| format!("#{n}"))
955 .collect::<Vec<_>>()
956 .join(", ")
957 );
958 found
959 } else {
960 prs
961 };
962 let sorted = classify(&repo, &numbers)?;
963 let mut results = Vec::new();
964 for number in sorted.prs {
965 results.push(review::resume_pr(
966 &agents,
967 &cfg,
968 &repo,
969 number,
970 next_actor.as_deref(),
971 ));
972 }
973 for number in sorted.issues {
976 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
977 Ok(Some(pr)) => {
978 log!("#{number} is an issue; continuing its open PR {}", pr.url);
979 results.push(review::resume_pr(
980 &agents,
981 &cfg,
982 &repo,
983 pr.number,
984 next_actor.as_deref(),
985 ));
986 }
987 Ok(None) => logwarn!(
988 "#{number} is an issue with no open pull request. Use `spar run {number}` \
989 to implement it."
990 ),
991 Err(e) => logwarn!("#{number}: {e}"),
992 }
993 }
994 if results.is_empty() {
995 return Ok(0);
996 }
997 Ok(report(&results, &cfg, &repo))
998 }
999 }
1000}
1001
1002#[derive(Debug, Default, Clone)]
1007struct Overrides {
1008 max_rounds: Option<u32>,
1009 auto_merge: Option<bool>,
1010 keep_worktrees: Option<bool>,
1011 worktrees: Option<bool>,
1012 close_skipped: Option<bool>,
1013 absorb: Option<u32>,
1014}
1015
1016impl From<&LoopFlags> for Overrides {
1017 fn from(flags: &LoopFlags) -> Self {
1018 Self {
1019 max_rounds: flags.max_rounds,
1020 auto_merge: flags.auto_merge.then_some(true),
1021 keep_worktrees: flags.keep_worktrees.then_some(true),
1022 worktrees: None,
1023 close_skipped: None,
1024 absorb: flags.absorb,
1025 }
1026 }
1027}
1028
1029fn run_targets(
1031 agents: &[Agent],
1032 cfg: &Config,
1033 repo: &Repo,
1034 numbers: &[i64],
1035 plan_out: &Path,
1036 retriage: bool,
1037) -> Result<i32> {
1038 let sorted = classify(repo, numbers)?;
1039 let mut results = Vec::new();
1040 let mut stopped = Vec::new();
1041 let mut parked = Vec::new();
1042 let mut progress = Progress::new(sorted.prs.len());
1043
1044 let mut issues = sorted.issues.clone();
1050 if !sorted.prs.is_empty() {
1051 log!(
1052 "continuing {} pull request(s) before starting new work: {}",
1053 sorted.prs.len(),
1054 self::numbers(&sorted.prs)
1055 );
1056 let covered = issues_covered_by(repo, &sorted.prs);
1057 for (issue, pr) in covered.iter().filter(|(i, _)| issues.contains(i)) {
1058 log!(
1059 "#{issue} is what PR #{pr} closes, and both were given, so it is \
1060 continued there rather than implemented again"
1061 );
1062 }
1063 issues = issues_not_on_named_prs(&issues, &covered);
1064 for number in &sorted.prs {
1065 let run = review::resume_pr(agents, cfg, repo, *number, None);
1066 progress.finished(&run);
1067 results.push(run);
1068 }
1069 }
1070
1071 if let Err(e) = work_issues(
1072 agents,
1073 cfg,
1074 repo,
1075 issues,
1076 plan_out,
1077 &mut results,
1078 &mut stopped,
1079 &mut parked,
1080 &mut progress,
1081 retriage,
1082 ) {
1083 if results.is_empty() {
1086 return Err(e);
1087 }
1088 stopped.push(format!("the issue queue stopped: {e}"));
1089 }
1090
1091 if results.is_empty() {
1092 log!("nothing scheduled");
1095 for line in parked.iter().chain(stopped.iter()) {
1096 println!("{line}");
1097 }
1098 return Ok(report_writes(repo).max(i32::from(!stopped.is_empty())));
1099 }
1100 Ok(report_with(&results, cfg, repo, &stopped, &parked))
1101}
1102
1103impl Overrides {
1104 fn for_working(loop_flags: &LoopFlags, triage: &TriageFlags, no_worktrees: bool) -> Self {
1107 let mut over = Overrides::from(loop_flags);
1108 over.worktrees = if no_worktrees { Some(false) } else { None };
1109 over.close_skipped = match (triage.close_skipped, triage.no_close_skipped) {
1110 (true, _) => Some(true),
1111 (_, true) => Some(false),
1112 _ => None,
1113 };
1114 over
1115 }
1116}
1117
1118#[allow(clippy::too_many_arguments)]
1128fn work_issues(
1129 agents: &[Agent],
1130 cfg: &Config,
1131 repo: &Repo,
1132 first_wave: Vec<i64>,
1133 plan_out: &Path,
1134 results: &mut Vec<IssueRun>,
1135 stopped: &mut Vec<String>,
1136 parked: &mut Vec<String>,
1137 progress: &mut Progress,
1138 retriage: bool,
1139) -> Result<()> {
1140 let waiting = repo.read_contested();
1144 if retriage && !waiting.is_empty() {
1145 repo.forget_contested(&waiting.keys().copied().collect::<Vec<_>>());
1146 }
1147 let mut handled: BTreeSet<i64> = BTreeSet::new();
1148 let mut landed: BTreeSet<i64> = BTreeSet::new();
1152 let mut leftover: BTreeSet<i64> = BTreeSet::new();
1153 let mut worked = 0usize;
1157 let mut queue: VecDeque<Wave> = VecDeque::from([Wave::first(first_wave)]);
1158 let mut plans_written = 0usize;
1159
1160 while let Some(mut wave) = queue.pop_front() {
1167 wave.numbers.retain(|n| !handled.contains(n));
1168 if !retriage {
1169 let mut still_waiting: Vec<i64> = Vec::new();
1174 wave.numbers.retain(|n| {
1175 let Some(known) = waiting.get(n) else {
1176 return true;
1177 };
1178 still_waiting.push(*n);
1179 parked.push(format!(
1180 "#{n} contested and parked for you: {}",
1181 positions_of(known)
1182 ));
1183 false
1184 });
1185 if !still_waiting.is_empty() {
1186 log!(
1187 "{} contested on an earlier run and waiting on you. Pass --retriage to ask \
1188 again.",
1189 numbers(&still_waiting)
1190 );
1191 }
1192 }
1193 if wave.numbers.is_empty() {
1194 continue;
1195 }
1196 match wave.parent {
1197 Some(tracker) => log!(
1198 "working {} item(s) from the checklist in #{tracker}: {}",
1199 wave.numbers.len(),
1200 numbers(&wave.numbers)
1201 ),
1202 None if wave.absorbed > 0 => log!(
1203 "absorbing {} newly filed issue(s): {}",
1204 wave.numbers.len(),
1205 numbers(&wave.numbers)
1206 ),
1207 None => {}
1208 }
1209 handled.extend(wave.numbers.iter().copied());
1210
1211 let fetched = match repo.fetch_issues(&wave.numbers) {
1212 Ok(fetched) => fetched,
1213 Err(e) => {
1214 logdim!("could not read the next wave: {e}");
1215 stopped.push(format!(
1216 "could not read {}, so it was not worked: {e}",
1217 numbers(&wave.numbers)
1218 ));
1219 continue;
1220 }
1221 };
1222 let plan_path = if plans_written == 0 {
1225 plan_out.to_path_buf()
1226 } else {
1227 plan_out.with_extension(format!("wave{plans_written}.json"))
1228 };
1229 plans_written += 1;
1230 let plan = match make_plan(agents, cfg, repo, &fetched, &plan_path) {
1235 Ok(plan) => plan,
1236 Err(e) => {
1237 logwarn!("triage failed for {}: {e}", numbers(&wave.numbers));
1238 stopped.push(format!(
1239 "triage failed for {}, so it was not worked: {}",
1240 numbers(&wave.numbers),
1241 first_line(&e.to_string())
1242 ));
1243 for later in &queue {
1244 stopped.push(format!(
1245 "{} was not reached after that failure",
1246 numbers(&later.numbers)
1247 ));
1248 }
1249 queue.clear();
1250 break;
1251 }
1252 };
1253 act_on_plan(cfg, repo, &plan);
1254 repo.remember_contested(&plan.contested);
1255 for item in &plan.contested {
1256 parked.push(format!(
1257 "#{} contested and parked for you: {}",
1258 item.issue,
1259 positions(&item.positions, &item.reasons)
1260 ));
1261 }
1262 for item in &plan.skipped {
1263 let what = if item.tracker {
1264 "left open as a tracker"
1265 } else if cfg.loop_cfg.close_skipped {
1266 "commented on and closed"
1267 } else {
1268 "commented on and left open"
1269 };
1270 parked.push(format!(
1271 "#{} skipped by both reviewers, {what}: {}",
1272 item.issue,
1273 item.reasons
1274 .values()
1275 .next()
1276 .map(|r| first_line(r))
1277 .unwrap_or_default()
1278 ));
1279 }
1280
1281 if cfg.loop_cfg.decompose_trackers && wave.parent.is_none() {
1285 for item in plan.skipped.iter().filter(|i| i.tracker) {
1286 let children = tracker::decompose(cfg, repo, item.issue);
1287 if !children.is_empty() {
1288 queue.push_back(wave.child(item.issue, children));
1289 }
1290 }
1291 }
1292
1293 progress.add(
1294 plan.order
1295 .iter()
1296 .filter(|item| fetched.iter().any(|i| i.number == item.issue))
1297 .count(),
1298 );
1299 let before = results.len();
1300 for item in &plan.order {
1301 let Some(issue) = fetched.iter().find(|i| i.number == item.issue) else {
1302 continue;
1303 };
1304 if let Some(why) = held_back(item, &plan, &landed) {
1305 log!("#{}: {why}", item.issue);
1306 let mut held = IssueRun::new(item.issue, item.title.clone());
1307 held.status = Status::Pending;
1308 held.notes.push(why);
1309 progress.finished(&held);
1310 results.push(held);
1311 continue;
1312 }
1313 let run = review::run_nth_issue(agents, cfg, repo, item, issue, worked);
1314 worked += 1;
1315 if run.status == Status::Merged {
1316 landed.insert(item.issue);
1317 }
1318 progress.finished(&run);
1319 results.push(run);
1320 }
1321
1322 let filed: Vec<i64> = results[before..]
1324 .iter()
1325 .flat_map(|r| r.filed.iter())
1326 .filter_map(|url| review::filed_issue_number(url))
1327 .filter(|n| !handled.contains(n))
1328 .collect::<BTreeSet<_>>()
1329 .into_iter()
1330 .collect();
1331 if filed.is_empty() {
1332 continue;
1333 }
1334 match wave.absorbed < cfg.loop_cfg.absorb_new_issues {
1335 true => queue.push_back(wave.absorb(filed)),
1336 false => leftover.extend(filed),
1337 }
1338 }
1339 if !leftover.is_empty() && cfg.loop_cfg.absorb_new_issues > 0 {
1340 log!(
1341 "{} issue(s) filed in the last wave were left for a later run: {}",
1342 leftover.len(),
1343 numbers(&leftover.iter().copied().collect::<Vec<_>>())
1344 );
1345 }
1346 Ok(())
1347}
1348
1349fn positions(
1351 positions: &std::collections::BTreeMap<String, String>,
1352 reasons: &std::collections::BTreeMap<String, String>,
1353) -> String {
1354 if positions.is_empty() {
1355 return "no positions recorded".to_string();
1356 }
1357 positions
1358 .iter()
1359 .map(|(name, side)| match reasons.get(name) {
1360 Some(why) if !why.trim().is_empty() => {
1361 format!("{name} says {side} ({})", first_line(why))
1362 }
1363 _ => format!("{name} says {side}"),
1364 })
1365 .collect::<Vec<_>>()
1366 .join("; ")
1367}
1368
1369fn positions_of(known: &crate::model::Contested) -> String {
1370 positions(&known.positions, &known.reasons)
1371}
1372
1373fn held_back(item: &PlanItem, plan: &Plan, landed: &BTreeSet<i64>) -> Option<String> {
1385 for dep in &item.depends_on {
1386 if landed.contains(dep) {
1387 continue;
1388 }
1389 if plan.order.iter().any(|other| other.issue == *dep) {
1390 return Some(format!(
1391 "held back: #{dep} is in this run and has not merged, so its work is not on the \
1392 base this would be built from. Merge #{dep} and run #{} again.",
1393 item.issue
1394 ));
1395 }
1396 if plan.skipped.iter().any(|s| s.issue == *dep) {
1397 return Some(format!(
1398 "held back: #{dep}, which it depends on, was declined by both reviewers"
1399 ));
1400 }
1401 if plan.contested.iter().any(|c| c.issue == *dep) {
1402 return Some(format!(
1403 "held back: the reviewers disagree about #{dep}, which it depends on"
1404 ));
1405 }
1406 }
1407 None
1408}
1409
1410struct Wave {
1412 numbers: Vec<i64>,
1413 absorbed: u32,
1418 parent: Option<i64>,
1420}
1421
1422impl Wave {
1423 fn first(numbers: Vec<i64>) -> Self {
1424 Self {
1425 numbers,
1426 absorbed: 0,
1427 parent: None,
1428 }
1429 }
1430
1431 fn child(&self, tracker: i64, numbers: Vec<i64>) -> Self {
1432 Self {
1433 numbers,
1434 absorbed: self.absorbed,
1435 parent: Some(tracker),
1436 }
1437 }
1438
1439 fn absorb(&self, numbers: Vec<i64>) -> Self {
1440 Self {
1441 numbers,
1442 absorbed: self.absorbed + 1,
1443 parent: None,
1444 }
1445 }
1446}
1447
1448fn numbers(items: &[i64]) -> String {
1449 items
1450 .iter()
1451 .map(|n| format!("#{n}"))
1452 .collect::<Vec<_>>()
1453 .join(", ")
1454}
1455
1456fn prepare(common: &Common, overrides: Option<Overrides>) -> Result<(Config, Repo, Vec<Agent>)> {
1457 let mut cfg = config::load(common.config.as_deref())?;
1458
1459 if let Some(first) = &common.first {
1460 if !cfg.has_agent(first) {
1461 bail!("--first must be one of: {}", cfg.agent_names().join(", "));
1462 }
1463 cfg.first_implementor = first.clone();
1464 }
1465 if let Some(base) = &common.base {
1466 cfg.loop_cfg.base_branch = base.clone();
1467 }
1468 if let Some(min) = common.min_number {
1469 cfg.loop_cfg.min_number = min;
1470 }
1471 if let Some(extra) = common.instructions.as_deref().map(str::trim) {
1475 if !extra.is_empty() {
1476 let standing = cfg.loop_cfg.instructions.trim();
1477 cfg.loop_cfg.instructions = if standing.is_empty() {
1478 extra.to_string()
1479 } else {
1480 format!("{standing}\n{extra}")
1481 };
1482 }
1483 }
1484 if let Some(over) = overrides {
1485 if let Some(v) = over.max_rounds {
1486 if v == 0 {
1487 bail!("--max-rounds must be at least 1");
1488 }
1489 cfg.loop_cfg.max_rounds = v;
1490 }
1491 if let Some(v) = over.auto_merge {
1492 cfg.loop_cfg.auto_merge = v;
1493 }
1494 if let Some(v) = over.keep_worktrees {
1495 cfg.loop_cfg.keep_worktrees = v;
1496 }
1497 if let Some(v) = over.worktrees {
1498 cfg.loop_cfg.worktrees = v;
1499 }
1500 if let Some(v) = over.close_skipped {
1501 cfg.loop_cfg.close_skipped = v;
1502 }
1503 if let Some(v) = over.absorb {
1504 cfg.loop_cfg.absorb_new_issues = v;
1505 }
1506 }
1507
1508 let repo = Repo::open(&common.repo, &cfg)?;
1509 if common.base.is_none() {
1510 cfg.loop_cfg.base_branch = repo.default_branch(cfg.base_branch());
1511 }
1512
1513 let agents = agent::build(&cfg)?;
1514 if let Some(warning) = agent::correlation_warning(&agents) {
1515 logging::warn(warning);
1516 }
1517
1518 for stale in repo.prune_worktrees(false) {
1520 let what = if stale.starts_with("branch ") {
1521 stale
1522 } else {
1523 format!("worktree {stale}")
1524 };
1525 logdim!("cleaned up finished {what}");
1526 }
1527
1528 log!("repo {} base {}", repo.root().display(), cfg.base_branch());
1529 log!(
1530 "agents: {}, first {}",
1531 agents
1532 .iter()
1533 .map(|a| format!("{}={}", a.name(), a.spec.describe()))
1534 .collect::<Vec<_>>()
1535 .join(", "),
1536 cfg.first_implementor_policy()
1537 );
1538 Ok((cfg, repo, agents))
1539}
1540
1541fn pick_issues(repo: &Repo, given: Vec<i64>, limit: usize, min_number: i64) -> Result<Vec<i64>> {
1542 if !given.is_empty() {
1543 if min_number > 0 {
1545 let below: Vec<String> = given
1546 .iter()
1547 .filter(|n| **n < min_number)
1548 .map(|n| format!("#{n}"))
1549 .collect();
1550 if !below.is_empty() {
1551 logdim!(
1552 "{} below the #{min_number} floor, taking them because you named them",
1553 below.join(", ")
1554 );
1555 }
1556 }
1557 return Ok(given);
1558 }
1559 let found = repo.list_open_issues(limit, min_number)?;
1560 if found.is_empty() {
1561 log!("no open issues");
1562 return Ok(found);
1563 }
1564 log!(
1565 "no issues given, taking {} open: {}",
1566 found.len(),
1567 found
1568 .iter()
1569 .map(|n| format!("#{n}"))
1570 .collect::<Vec<_>>()
1571 .join(", ")
1572 );
1573 Ok(found)
1574}
1575
1576fn pick_for_split(
1589 agents: &[Agent],
1590 cfg: &Config,
1591 repo: &Repo,
1592 limit: usize,
1593 mode: &split::Mode,
1594) -> Result<Vec<(i64, ItemKind)>> {
1595 let issues = repo.list_open_issues(limit, cfg.loop_cfg.min_number)?;
1596 let prs = repo.list_open_prs(limit, cfg.loop_cfg.min_number)?;
1597 log!(
1598 "no numbers given, considering {} open issue(s) and {} open pull request(s)",
1599 issues.len(),
1600 prs.len()
1601 );
1602 if issues.is_empty() && prs.is_empty() {
1603 return Ok(Vec::new());
1604 }
1605
1606 let issue_rows = repo.open_issue_rows();
1607 let pr_rows = repo.open_pr_rows();
1608 let mut candidates: Vec<split::Candidate> = Vec::new();
1609 let mut already = 0usize;
1610
1611 for number in &issues {
1612 let Some(row) = issue_rows.iter().find(|i| i.number == *number) else {
1613 logdim!("could not read #{number}, leaving it alone");
1614 continue;
1615 };
1616 if split::already_split(row.body_text()) && !mode.again {
1619 already += 1;
1620 continue;
1621 }
1622 candidates.push(split::Candidate::from_issue(row));
1623 }
1624 for number in &prs {
1625 match pr_rows.iter().find(|p| p.number == *number) {
1626 Some(row) => candidates.push(split::Candidate::from_pr(row)),
1627 None => logdim!("could not read PR #{number}, leaving it alone"),
1628 }
1629 }
1630 if already > 0 {
1631 log!("{already} issue(s) already split, skipped. --again reopens them.");
1632 }
1633 if candidates.is_empty() {
1634 return Ok(Vec::new());
1635 }
1636
1637 let agent = agent::find(agents, &cfg.first_implementor)?;
1638 log!(
1639 "screening {} item(s) with {}",
1640 candidates.len(),
1641 agent.name()
1642 );
1643 let verdicts = split::screen(agent, cfg, repo, &candidates)?;
1644
1645 let mut picked = Vec::new();
1646 for candidate in &candidates {
1647 match verdicts.iter().find(|v| v.item == candidate.number) {
1648 Some(v) if v.split => {
1649 log!(" split #{}: {}", candidate.number, v.reason.trim());
1650 picked.push((candidate.number, candidate.kind));
1651 }
1652 Some(v) => logdim!(" leave #{} whole: {}", candidate.number, v.reason.trim()),
1655 None => logdim!(" no verdict for #{}, leaving it whole", candidate.number),
1656 }
1657 }
1658 if picked.is_empty() {
1659 log!("nothing worth splitting");
1660 }
1661 Ok(picked)
1662}
1663
1664#[derive(Debug, Default)]
1670struct Sorted {
1671 issues: Vec<i64>,
1672 prs: Vec<i64>,
1673}
1674
1675fn classify(repo: &Repo, numbers: &[i64]) -> Result<Sorted> {
1676 let mut sorted = Sorted::default();
1677 for number in numbers {
1678 match repo.item_kind(*number)? {
1679 ItemKind::Issue => sorted.issues.push(*number),
1680 ItemKind::Pr => sorted.prs.push(*number),
1681 }
1682 }
1683 if !sorted.issues.is_empty() && !sorted.prs.is_empty() {
1684 log!(
1685 "{} issue(s) and {} pull request(s) given",
1686 sorted.issues.len(),
1687 sorted.prs.len()
1688 );
1689 }
1690 Ok(sorted)
1691}
1692
1693fn issues_covered_by(repo: &Repo, prs: &[i64]) -> BTreeMap<i64, i64> {
1706 let mut covered = BTreeMap::new();
1707 for number in prs {
1708 match repo.pr_view(*number) {
1709 Ok(view) => {
1710 for issue in &view.closing_issues_references {
1711 covered.insert(issue.number, *number);
1712 }
1713 }
1714 Err(e) => logwarn!(
1715 "#{number}: could not read which issue(s) it closes, so none are skipped: {}",
1716 e.last_line()
1717 ),
1718 }
1719 }
1720 covered
1721}
1722
1723fn issues_not_on_named_prs(issues: &[i64], covered: &BTreeMap<i64, i64>) -> Vec<i64> {
1726 issues
1727 .iter()
1728 .copied()
1729 .filter(|issue| !covered.contains_key(issue))
1730 .collect()
1731}
1732
1733fn make_plan(
1734 agents: &[Agent],
1735 cfg: &Config,
1736 repo: &Repo,
1737 issues: &[Issue],
1738 plan_out: &Path,
1739) -> Result<Plan> {
1740 let plan = triage::triage(agents, cfg, repo, issues)?;
1741
1742 std::fs::write(plan_out, serde_json::to_vec_pretty(&plan)?)
1743 .map_err(|e| spar_err!("could not write {}: {e}", plan_out.display()))?;
1744 log!("plan written to {}", plan_out.display());
1745
1746 for item in &plan.order {
1747 let after = if item.depends_on.is_empty() {
1750 String::new()
1751 } else {
1752 format!(
1753 " after {}",
1754 item.depends_on
1755 .iter()
1756 .map(|n| format!("#{n}"))
1757 .collect::<Vec<_>>()
1758 .join(", ")
1759 )
1760 };
1761 log!(
1762 " do #{} [{}/{}]{after} {}",
1763 item.issue,
1764 item.complexity,
1765 item.risk,
1766 item.title
1767 );
1768 }
1769 for item in &plan.skipped {
1770 if item.tracker {
1771 log!(
1772 " hold #{} (both reviewers: tracks work filed elsewhere)",
1773 item.issue
1774 );
1775 } else {
1776 log!(" skip #{} (both reviewers: not worth doing)", item.issue);
1777 }
1778 }
1779 for item in &plan.contested {
1780 log!(" ?? #{} contested, parked for you to decide", item.issue);
1781 }
1782 Ok(plan)
1783}
1784
1785fn act_on_plan(cfg: &Config, repo: &Repo, plan: &Plan) {
1795 for item in &plan.skipped {
1796 let body = review::skip_comment(item, &repo.style);
1797 let close = cfg.loop_cfg.close_skipped && !item.tracker;
1798 let outcome = if close {
1799 repo.close_issue(item.issue, &body)
1800 } else {
1801 repo.comment_issue(item.issue, &body)
1802 };
1803 match outcome {
1804 Ok(()) if close => log!(" closed #{}", item.issue),
1805 Ok(()) if item.tracker => {
1806 log!(
1807 " left #{} open, it tracks work filed elsewhere",
1808 item.issue
1809 )
1810 }
1811 Ok(()) => {}
1812 Err(e) => logdim!("could not update #{}: {e}", item.issue),
1813 }
1814 }
1815}
1816
1817fn cmd_scrub_filter() -> Result<i32> {
1822 let mut input = String::new();
1823 std::io::stdin()
1824 .read_to_string(&mut input)
1825 .map_err(|e| spar_err!("could not read a commit message from stdin: {e}"))?;
1826 let out = style::scrub(&input, &crate::repo::style_from_env());
1827 let mut stdout = std::io::stdout();
1828 stdout
1829 .write_all(out.as_bytes())
1830 .and_then(|_| stdout.write_all(b"\n"))
1831 .map_err(|e| spar_err!("could not write the scrubbed message: {e}"))?;
1832 Ok(0)
1833}
1834
1835fn cmd_clean(
1836 repo_path: &Path,
1837 config_path: Option<&Path>,
1838 all: bool,
1839 pr_state: bool,
1840) -> Result<i32> {
1841 let cfg = config::load(config_path)?;
1842 let repo = Repo::open(repo_path, &cfg)?;
1843 let mut removed = repo.prune_worktrees(all);
1844 removed.extend(repo.prune_state());
1845 if pr_state {
1846 removed.extend(repo.prune_pr_state(None));
1847 }
1848 if removed.is_empty() {
1849 if repo.write_summary().failed > 0 {
1850 println!("nothing removed");
1851 } else {
1852 println!("nothing to clean");
1853 }
1854 } else {
1855 for item in removed {
1856 println!("removed {item}");
1857 }
1858 }
1859 Ok(report_writes(&repo))
1860}
1861
1862fn cmd_post(
1864 prs: &[i64],
1865 repo_path: &Path,
1866 config_path: Option<&Path>,
1867 file: Option<&Path>,
1868 dry_run: bool,
1869) -> Result<i32> {
1870 let cfg = config::load(config_path)?;
1871 let repo = Repo::open(repo_path, &cfg)?;
1872
1873 if file.is_some() && prs.len() > 1 {
1874 bail!("--file posts one review, so give it one pull request number");
1875 }
1876
1877 let mut failed = false;
1878 for number in prs {
1879 let text = match file {
1880 Some(path) => std::fs::read_to_string(path)
1881 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?,
1882 None => match repo.read_pending_comment(*number) {
1883 Some(text) => text,
1884 None => {
1885 logging::error(format!(
1886 "no saved review or answer for #{number}. `spar review {number} \
1887 --dry-run` or `spar answer {number} --dry-run` produces one, or pass \
1888 --file."
1889 ));
1890 failed = true;
1891 continue;
1892 }
1893 },
1894 };
1895 if text.trim().is_empty() {
1896 logging::error(format!("the saved review for PR #{number} is empty"));
1897 failed = true;
1898 continue;
1899 }
1900 if dry_run {
1901 println!("\n{}\n", text.trim());
1902 log!("would post the above to PR #{number}");
1903 continue;
1904 }
1905 if !answer::answered_keys(&text).is_empty() {
1908 match repo.comment_issue(*number, &text) {
1909 Ok(()) => {
1910 log!("posted to #{number}");
1911 answer::note_posted(&repo, *number, &text);
1912 }
1913 Err(e) => {
1914 logging::error(format!("could not post to #{number}: {e}"));
1915 failed = true;
1916 }
1917 }
1918 continue;
1919 }
1920 match repo.comment_pr(*number, &text) {
1923 Ok(()) => {
1924 log!("posted to PR #{number}");
1925 if let Some(mut record) = repo.read_review_record(*number) {
1928 if !record.posted {
1929 record.posted = true;
1930 if let Err(e) = repo.write_review_record(*number, &record) {
1931 logdim!("could not mark the review record posted: {e}");
1932 }
1933 }
1934 }
1935 }
1936 Err(e) => {
1937 logging::error(format!("could not post to PR #{number}: {e}"));
1938 failed = true;
1939 }
1940 }
1941 }
1942 Ok(i32::from(failed).max(report_writes(&repo)))
1943}
1944
1945fn cmd_answer(common: &Common, items: Vec<i64>, dry_run: bool, again: bool) -> Result<i32> {
1947 let (cfg, repo, agents) = prepare(common, None)?;
1948 let mode = answer::Mode {
1949 dry_run,
1950 draft: false,
1951 again,
1952 auto: false,
1953 posts: checkin::posts(&cfg),
1954 };
1955 let numbers = if items.is_empty() {
1956 let viewer = repo.viewer_login()?.to_string();
1957 let floor = cfg.loop_cfg.min_number;
1958 let found: Vec<i64> = repo
1959 .open_issues_mentioning(&viewer, usize::MAX)?
1960 .into_iter()
1961 .filter(|n| *n >= floor)
1962 .take(common.limit)
1963 .collect();
1964 if found.is_empty() {
1965 log!("no open issues mention @{viewer}");
1966 return Ok(0);
1967 }
1968 log!(
1969 "no issues given, answering {} that mention @{viewer}: {}",
1970 found.len(),
1971 found
1972 .iter()
1973 .map(|n| format!("#{n}"))
1974 .collect::<Vec<_>>()
1975 .join(", ")
1976 );
1977 found
1978 } else {
1979 items
1980 };
1981 let results: Vec<IssueRun> = numbers
1982 .iter()
1983 .map(|number| answer::answer_issue(&agents, &cfg, &repo, *number, &mode))
1984 .collect();
1985 Ok(report(&results, &cfg, &repo))
1986}
1987
1988#[allow(clippy::too_many_arguments)]
1994fn cmd_brainstorm(
1995 topic: Vec<String>,
1996 common: &Common,
1997 max_rounds: Option<u32>,
1998 ideas: usize,
1999 out: Option<PathBuf>,
2000 file_issues: bool,
2001 from: Option<&Path>,
2002) -> Result<i32> {
2003 if ideas == 0 {
2004 bail!("--ideas must be at least 1");
2005 }
2006 if let Some(path) = from {
2007 let cfg = config::load(common.config.as_deref())?;
2008 let repo = Repo::open(&common.repo, &cfg)?;
2009 let text = std::fs::read_to_string(path)
2010 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
2011 let entries = brainstorm::parse_session(&text);
2012 if entries.is_empty() {
2013 bail!(
2014 "no ideas in {}: an idea is a `## ` heading with its sections under it",
2015 path.display()
2016 );
2017 }
2018 log!(
2019 "filing {} idea{} from {}",
2020 entries.len(),
2021 if entries.len() == 1 { "" } else { "s" },
2022 path.display()
2023 );
2024 brainstorm::file_ideas(&repo, &entries);
2025 return Ok(report_writes(&repo));
2026 }
2027
2028 let overrides = Overrides {
2029 max_rounds,
2030 ..Overrides::default()
2031 };
2032 let (cfg, repo, agents) = prepare(common, Some(overrides))?;
2033 let joined = topic.join(" ").trim().to_string();
2034 let session = brainstorm::Session {
2035 topic: (!joined.is_empty()).then_some(joined),
2036 budget: cfg.loop_cfg.max_rounds,
2037 wanted: ideas,
2038 };
2039 let now = clock::now_secs();
2040 let outcome = brainstorm::run(&agents, &cfg, &repo, &session)?;
2041 let path = out.unwrap_or_else(|| brainstorm::default_path(&repo, &session, now));
2042 let text = brainstorm::render_session(&outcome, &clock::date_line(now), &repo.style);
2043 crate::repo::write_text_atomic(&path, &text)?;
2044 brainstorm::print_summary(&outcome, &path);
2045 if file_issues {
2046 println!();
2047 brainstorm::file_ideas(&repo, &brainstorm::issue_entries(&outcome, &repo.style));
2048 }
2049
2050 let spent = spend::taken();
2051 if let Err(e) = repo.record_spend(&spent) {
2052 logdim!("could not record what this run spent: {e}");
2053 }
2054 if let Some(summary) = spend::summary(&spent) {
2055 println!("\n{summary}");
2056 }
2057 Ok(report_writes(&repo))
2058}
2059
2060fn cmd_init_update(out: &Path) -> Result<i32> {
2066 let text = std::fs::read_to_string(out)
2067 .map_err(|e| spar_err!("could not read {}: {e}", out.display()))?;
2068 config::parse(&text).map_err(|e| spar_err!("{} does not parse: {e}", out.display()))?;
2071
2072 let unset = config::unmentioned_options(&text);
2073 if unset.is_empty() {
2074 println!("{} already mentions every setting.", out.display());
2075 return Ok(0);
2076 }
2077
2078 let mut block = String::new();
2079 if !text.ends_with('\n') {
2080 block.push('\n');
2081 }
2082 block.push_str("\n# Added by `spar init --update`: settings this file did not mention,\n");
2083 block.push_str("# shown at their defaults. Uncomment one to change it.\n");
2084 let mut section = "";
2085 for option in &unset {
2086 if option.section != section {
2087 section = option.section;
2088 block.push_str(&format!("\n# [{section}]\n"));
2089 }
2090 block.push('\n');
2095 block.push_str(&wrap_comment(note_for(&option.key)));
2096 block.push_str(&format!("# {} = {}\n", option.key, option.default));
2097 }
2098
2099 use std::io::Write;
2100 std::fs::OpenOptions::new()
2101 .append(true)
2102 .open(out)
2103 .and_then(|mut f| f.write_all(block.as_bytes()))
2104 .map_err(|e| spar_err!("could not append to {}: {e}", out.display()))?;
2105
2106 println!(
2107 "added {} setting(s) to {} as comments",
2108 unset.len(),
2109 out.display()
2110 );
2111 Ok(0)
2112}
2113
2114fn cmd_init(out: &Path, force: bool) -> Result<i32> {
2115 if out.exists() && !force {
2116 logging::error(format!(
2117 "{} already exists. `--update` appends any settings it does not mention, \
2118 `--force` overwrites it.",
2119 out.display()
2120 ));
2121 return Ok(1);
2122 }
2123
2124 let presets = config::available_presets();
2125 if presets.is_empty() {
2126 bail!("no presets available, which should be impossible in a released build");
2127 }
2128
2129 let mut found: Vec<(String, PathBuf, config::AgentSpec)> = Vec::new();
2130 for name in &presets {
2131 let raw = config::load_preset(name)?;
2132 let mut spec: config::AgentSpec = match raw
2136 .as_table()
2137 .cloned()
2138 .ok_or_else(|| spar_err!("not a table"))
2139 .and_then(|t| {
2140 toml::Value::Table(t)
2141 .try_into()
2142 .map_err(|e| spar_err!("{e}"))
2143 }) {
2144 Ok(spec) => spec,
2145 Err(e) => {
2146 println!(" BROKEN {name:10} {}", e.first_line());
2147 continue;
2148 }
2149 };
2150 spec.name = name.clone();
2151 match Agent::new(spec.clone()).resolve_bin() {
2152 Ok(path) => {
2153 println!(" found {name:10} {}", path.display());
2154 found.push((name.clone(), path.to_path_buf(), spec));
2155 }
2156 Err(_) => println!(" missing {name}"),
2157 }
2158 }
2159
2160 if found.len() < 2 {
2161 logging::error(format!(
2162 "need two agent CLIs, found {}. Install another, or write {} by hand using the \
2163 presets as a reference.",
2164 found.len(),
2165 out.display()
2166 ));
2167 return Ok(1);
2168 }
2169
2170 let chosen: Vec<&(String, PathBuf, config::AgentSpec)> = found.iter().take(2).collect();
2172 if found.len() > 2 {
2173 log!(
2174 "{} agents available, picking {} and {}. Edit {} to change.",
2175 found.len(),
2176 chosen[0].0,
2177 chosen[1].0,
2178 out.display()
2179 );
2180 }
2181
2182 let mut text = String::from(
2183 "# Generated by `spar init`. Each agent inherits a command template from a\n\
2184 # built in preset; anything set here overrides it.\n\
2185 #\n\
2186 # Commented lines are the other options, each with a working value.\n\
2187 # Uncomment one to change it.\n\n",
2188 );
2189 for (name, _, spec) in &chosen {
2190 text.push_str(&agent_block(name, spec));
2191 }
2192 text.push_str(&settings_block(&chosen[0].0));
2193
2194 std::fs::write(out, text).map_err(|e| spar_err!("could not write {}: {e}", out.display()))?;
2195 println!("\nwrote {}", out.display());
2196 println!("Next: `spar doctor` to check it, then `spar run` in a GitHub checkout.");
2197 Ok(0)
2198}
2199
2200fn report_fallback(agent: &Agent) {
2206 let Some(backup) = agent.fallback() else {
2207 return;
2208 };
2209 match backup.resolve_bin() {
2210 Ok(bin) => println!(
2211 " fallback {} ({})",
2212 bin.display(),
2213 backup.spec.describe()
2214 ),
2215 Err(_) => println!(
2216 " fallback {} not found, so it will not stand in. Set {} to its path.",
2217 backup.program(),
2218 backup.env_key()
2219 ),
2220 }
2221}
2222
2223type Setting = (bool, &'static str, &'static str);
2234
2235const LOOP_OPTIONS: &[Setting] = &[
2236 (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."),
2237 (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."),
2238 (false, "first_implementor", "Which agent takes the first pass. The other one reviews it."),
2239 (false, "worktrees", "Isolate each issue in its own git worktree. Set false to work in the main checkout."),
2240 (false, "close_skipped", "Close an issue both reviewers declined, after posting the shared reasoning. A tracking issue is left open whatever this says."),
2241 (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."),
2242 (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."),
2243 (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."),
2244 (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."),
2245 (true, "setup", "A command spar runs in every worktree it creates, before any agent works there or the check runs, as an argv list or a string split on whitespace: `npm ci`, `uv sync`, `bundle install`. Unset runs nothing. A worktree holds only what git tracks, so without this a project's dependencies are there only if an agent happens to install them. A failure stops that issue before any agent call, with the output, and the next run rebuilds the worktree. The shared checkout is never touched."),
2246 (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."),
2247 (true, "keep_worktrees", "Keep worktrees after a run, for inspection."),
2248 (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."),
2249 (true, "parallel_triage", "Ask both agents to triage at once. They only read during triage, so there is nothing to serialise."),
2250 (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."),
2251 (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."),
2252 (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."),
2253 (true, "file_nits", "File nits as follow-ups too. Off, because a filed nit is somebody else's notification."),
2254 (true, "base_branch", "Only a fallback. Whatever origin/HEAD points at wins when it resolves."),
2255 (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."),
2256 (true, "state_store", "Where resume state is kept. local uses .spar/state and keeps it off the pull request."),
2257 (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."),
2258 (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."),
2259 (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."),
2260 (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."),
2261 (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."),
2262 (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."),
2263 (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."),
2264 (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."),
2265];
2266
2267const STYLE_OPTIONS: &[Setting] = &[
2268 (false, "ban_em_dash", "Strip em-dashes and en-dashes from everything spar posts, then refuse to post text that still has one."),
2269 (false, "ban_ai_attribution", "Strip mentions of the tooling, and Co-Authored-By trailers, from everything spar posts."),
2270 (false, "terse", "Hold model prose to a length budget. false removes the valves entirely."),
2271 (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."),
2272 (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."),
2273 (true, "max_title_chars", "A finding, issue, or pull request title. Never ellipsised: a title ending in three dots reads as broken."),
2274 (true, "max_summary_chars", "A one line verdict, or a refutation's argument."),
2275 (true, "max_detail_chars", "A blocking finding's explanation, as it appears in the pull request thread."),
2276 (true, "max_body_chars", "A pull request body."),
2277 (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."),
2278];
2279
2280const LISTEN_OPTIONS: &[Setting] = &[
2281 (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."),
2282 (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."),
2283 (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."),
2284 (true, "max_wait", "Most seconds an item may keep settling before it is acted on anyway."),
2285 (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."),
2286 (true, "on_review_requested", "What a pull request somebody asked you to review gets: review or ignore."),
2287 (true, "on_assigned", "What a pull request assigned to you gets: review or ignore."),
2288 (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."),
2289 (false, "verdicts", "Whether a review `spar listen` starts submits a formal verdict, approve or request changes, rather than a comment. A recheck always does."),
2290 (false, "on_mention", "What a mention of you on an issue gets, when the issue has no pull request spar reviewed: answer runs `spar answer`, which posts what both agents agree on and can check and leaves anything that asks you to decide for you; draft saves that for `spar post` instead; ignore does nothing."),
2291 (true, "max_answers", "Times `spar listen` answers on one issue before a person has to run `spar answer` on it again. A cap against two bots talking to each other, not a target. 0 turns automatic answers off."),
2292];
2293
2294fn settings_block(first_implementor: &str) -> String {
2299 let defaults: std::collections::BTreeMap<String, String> = config::known_options()
2300 .into_iter()
2301 .map(|option| (option.key, option.default))
2302 .collect();
2303 let value = |key: &str| match key {
2306 "first_implementor" => format!("\"{first_implementor}\""),
2307 other => defaults.get(other).cloned().unwrap_or_default(),
2308 };
2309
2310 let mut out = String::from("[loop]\n");
2311 out.push_str(&option_lines(LOOP_OPTIONS, &value));
2312 out.push_str(concat!(
2313 "\n[loop.effort_schedule]\n",
2314 "# One key per kind of call, so a value means what its name says. Values\n",
2315 "# are whatever each agent's own CLI accepts, listed above, so these are\n",
2316 "# examples rather than defaults. Left out, a call falls back to\n",
2317 "# round_1 or rest, and then to the effort the agent's own block asked\n",
2318 "# for. Where the two CLIs do not share a vocabulary, put the schedule\n",
2319 "# under [agents.NAME.effort_schedule] instead, with the same keys.\n",
2320 "# code = \"high\" # drafting an issue from a project prompt\n",
2321 "# triage = \"low\" # both agents, over the whole queue, per wave\n",
2322 "# implement = \"high\" # writing the first draft\n",
2323 "# review_1 = \"high\" # the deep first review\n",
2324 "# review_rest = \"low\" # later rounds, which see a small delta\n",
2325 "# respond = \"high\" # the author answering a review\n",
2326 "# close = \"low\" # the closing merge safety pass\n",
2327 "# screen = \"low\" # the follow-up screen, one call for the queue\n",
2328 "# checkin = \"high\" # judging comments other people left\n",
2329 "# split = \"high\" # proposing and checking a split\n",
2330 "# brainstorm = \"high\" # every brainstorm round\n",
2331 "# recheck = \"low\" # a second look after commits or replies\n",
2332 "# answer = \"high\" # drafting and checking answers to a mention\n",
2333 "# round_1 = \"high\" # read by any call above with no key set\n",
2334 "# rest = \"low\" # the same, for later rounds and the close\n\n",
2335 ));
2336 out.push_str("[style]\n");
2337 out.push_str(&option_lines(STYLE_OPTIONS, &value));
2338 out.push_str("\n[listen]\n");
2339 out.push_str(&option_lines(LISTEN_OPTIONS, &value));
2340 out
2341}
2342
2343fn option_lines(options: &[Setting], value: &dyn Fn(&str) -> String) -> String {
2347 let mut out = String::new();
2348 for (commented, key, note) in options {
2349 if !out.is_empty() {
2350 out.push('\n');
2351 }
2352 out.push_str(&wrap_comment(note));
2353 let lead = if *commented { "# " } else { "" };
2354 out.push_str(&format!("{lead}{key} = {}\n", value(key)));
2355 }
2356 out
2357}
2358
2359type Probe = Box<dyn Fn() -> Result<String>>;
2362
2363fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
2370 let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
2371
2372 if let Some(extra) = &spec.options_note {
2384 if !spec.models.is_empty() || !spec.efforts.is_empty() {
2385 out.push('\n');
2386 out.push_str(&wrap_comment(extra));
2387 }
2388 }
2389 for (key, choices) in [("model", &spec.models), ("effort", &spec.efforts)] {
2390 let Some(suggested) = choices.first() else {
2391 continue;
2392 };
2393 let mut note = format!("Omit {key} to use the CLI's own default.");
2394 if choices.len() > 1 {
2395 note.push_str(&format!(" One of: {}.", choices.join(" | ")));
2396 }
2397 out.push('\n');
2398 out.push_str(&wrap_comment(¬e));
2399 out.push_str(&format!("# {key} = \"{suggested}\"\n"));
2400 }
2401
2402 out.push('\n');
2406 out.push_str(&wrap_comment(
2407 "Seconds one call may take before spar gives up. A timeout costs the whole call and is \
2408 never retried, so err long.",
2409 ));
2410 out.push_str(&format!("# timeout = {}\n", spec.timeout));
2411
2412 let backup = if name == "cursor" { "gemini" } else { "cursor" };
2415 out.push('\n');
2416 out.push_str(&wrap_comment(
2417 "A stand in for when this CLI refuses, stalls, or runs out of quota. It answers in place \
2418 of this agent, never alongside it.",
2419 ));
2420 out.push_str(&format!(
2421 "# [agents.{name}.fallback]\n# preset = \"{backup}\"\n"
2422 ));
2423
2424 out.push('\n');
2429 out.push_str(&wrap_comment(
2430 "command, output, search_paths and the rest are in spar.example.toml, for pairing a CLI \
2431 that has no preset.",
2432 ));
2433 out.push('\n');
2434 out
2435}
2436
2437fn note_for(key: &str) -> &'static str {
2443 LOOP_OPTIONS
2444 .iter()
2445 .chain(STYLE_OPTIONS)
2446 .chain(LISTEN_OPTIONS)
2447 .find(|(_, name, _)| *name == key)
2448 .map(|(_, _, note)| *note)
2449 .unwrap_or("")
2450}
2451
2452fn wrap_comment(text: &str) -> String {
2454 const WIDTH: usize = 76;
2455 let mut out = String::new();
2456 let mut line = String::from("#");
2457 for word in text.split_whitespace() {
2458 if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
2459 out.push_str(&line);
2460 out.push('\n');
2461 line = String::from("#");
2462 }
2463 line.push(' ');
2464 line.push_str(word);
2465 }
2466 if line.len() > 1 {
2467 out.push_str(&line);
2468 out.push('\n');
2469 }
2470 out
2471}
2472
2473fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
2474 let mut ok = true;
2475
2476 let probes: Vec<(&str, Probe)> = vec![
2477 (
2478 "git",
2479 Box::new(|| {
2480 proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
2481 .map(|s| first_line(&s))
2482 }),
2483 ),
2484 (
2485 "gh",
2486 Box::new(|| {
2487 proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
2488 .map(|s| first_line(&s))
2489 }),
2490 ),
2491 (
2492 "gh auth",
2493 Box::new(|| {
2494 let out = proc::exec(
2495 &["gh".into(), "auth".into(), "status".into()],
2496 &ExecOpts::new().check(false).timeout_secs(60),
2497 )?;
2498 let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
2499 if out.ok() {
2500 Ok(first_line(&text))
2501 } else {
2502 Err(spar_err!("not authenticated. Run `gh auth login`."))
2503 }
2504 }),
2505 ),
2506 ];
2507
2508 for (label, probe) in probes {
2509 match probe() {
2510 Ok(detail) => println!(" ok {label:12} {detail}"),
2511 Err(e) => {
2512 println!(" FAIL {label:12} {}", e.first_line());
2513 ok = false;
2514 }
2515 }
2516 }
2517
2518 let found = config::find_config(config_path)?;
2519 let Some(path) = found else {
2520 println!("\n no spar.toml found. Run `spar init` to generate one.");
2521 println!(
2522 " presets available: {}",
2523 config::available_presets().join(", ")
2524 );
2525 return Ok(if ok { 0 } else { 1 });
2526 };
2527
2528 println!("\n config: {}", path.display());
2529 let cfg = match config::load(Some(&path)) {
2530 Ok(cfg) => cfg,
2531 Err(e) => {
2532 println!(" FAIL config {e}");
2533 return Ok(1);
2534 }
2535 };
2536
2537 let mut resolved = Vec::new();
2540 for spec in &cfg.agents {
2541 let agent = Agent::new(spec.clone());
2542 match agent.resolve_bin() {
2543 Ok(bin) => {
2544 println!(
2545 " ok {:12} {} ({})",
2546 spec.name,
2547 bin.display(),
2548 spec.describe()
2549 );
2550 report_fallback(&agent);
2551 resolved.push(agent);
2552 }
2553 Err(e) => {
2554 println!(" FAIL {:12} {}", spec.name, e.first_line());
2555 ok = false;
2556 }
2557 }
2558 }
2559
2560 if resolved.len() == cfg.agents.len() {
2561 if let Some(warning) = agent::correlation_warning(&resolved) {
2562 println!("\n WARNING {warning}");
2563 }
2564 }
2565
2566 for line in unknown_effort_values(&cfg) {
2567 println!("\n WARNING {line}");
2568 }
2569
2570 println!(
2571 "\n settings: first={} max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
2572 cfg.first_implementor_policy(),
2573 cfg.loop_cfg.max_rounds,
2574 cfg.loop_cfg.auto_merge,
2575 cfg.loop_cfg.worktrees,
2576 cfg.loop_cfg.followups,
2577 cfg.style.terse
2578 );
2579 if let Ok(text) = std::fs::read_to_string(&path) {
2583 let unset = config::unmentioned_options(&text);
2584 if !unset.is_empty() {
2585 println!(
2586 "\n {} setting(s) this config does not mention, all at their defaults:",
2587 unset.len()
2588 );
2589 for option in &unset {
2590 println!(
2591 " [{}] {} = {}",
2592 option.section, option.key, option.default
2593 );
2594 }
2595 println!(
2596 " `spar init --update {}` appends them as comments.",
2597 path.display()
2598 );
2599 }
2600 }
2601
2602 println!(
2603 "{}",
2604 if ok {
2605 "\nready"
2606 } else {
2607 "\nmissing prerequisites"
2608 }
2609 );
2610 Ok(if ok { 0 } else { 1 })
2611}
2612
2613fn unknown_effort_values(cfg: &config::Config) -> Vec<String> {
2622 let mut out = Vec::new();
2623 for spec in &cfg.agents {
2624 if spec.efforts.is_empty() {
2625 continue;
2626 }
2627 let mut said: Vec<String> = Vec::new();
2628 for call in config::Call::every() {
2629 let Some(value) = cfg.effort_for(spec, call) else {
2630 continue;
2631 };
2632 let known = spec
2633 .efforts
2634 .iter()
2635 .any(|hint| hint.eq_ignore_ascii_case(value.trim()));
2636 if known || said.contains(&value) {
2637 continue;
2638 }
2639 said.push(value.clone());
2640 out.push(format!(
2641 "{} is asked for effort \"{value}\" on the {} call, which is not one it is known \
2642 to accept ({}). Put this agent's values under \
2643 [agents.{}.effort_schedule].",
2644 spec.name,
2645 call.key(),
2646 spec.efforts.join(" | "),
2647 spec.name
2648 ));
2649 }
2650 }
2651 out
2652}
2653
2654fn first_line(text: &str) -> String {
2655 text.trim().lines().next().unwrap_or("").trim().to_string()
2656}
2657
2658fn report(results: &[IssueRun], cfg: &Config, repo: &Repo) -> i32 {
2663 report_with(results, cfg, repo, &[], &[])
2664}
2665
2666fn report_with(
2673 results: &[IssueRun],
2674 cfg: &Config,
2675 repo: &Repo,
2676 stopped: &[String],
2677 parked: &[String],
2678) -> i32 {
2679 let spent = spend::taken();
2682 if let Err(e) = repo.record_spend(&spent) {
2683 logdim!("could not record what this run spent: {e}");
2684 }
2685 println!("\n{}", "=".repeat(60));
2686 for r in results {
2687 print_outcome(r, &spent);
2688 }
2689 println!("{}", "=".repeat(60));
2690
2691 if cfg.alternate_first && results.len() > 1 {
2692 println!(
2693 "\nfirst implementor alternated, starting with {}. Who wrote each first draft is in \
2694 .spar/state/implementors.json.",
2695 cfg.first_implementor
2696 );
2697 }
2698 if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
2699 println!("\nApproved PRs are waiting on you to merge.");
2700 }
2701 let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
2702 if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
2703 println!(
2704 "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
2705 Set followups = \"issues\" to file them."
2706 );
2707 }
2708 if let Some(summary) = spend::summary(&spent) {
2709 println!("\n{summary}");
2710 }
2711 for lines in [parked, stopped] {
2712 if lines.is_empty() {
2713 continue;
2714 }
2715 println!();
2716 for line in lines {
2717 println!("{line}");
2718 }
2719 }
2720 let issue_exit = i32::from(!results.iter().all(IssueRun::succeeded) || !stopped.is_empty());
2721 issue_exit.max(report_writes(repo))
2722}
2723
2724pub(crate) fn print_outcome(r: &IssueRun, spent: &[spend::Spent]) {
2726 println!(
2727 "#{:<5} {:<10} rounds={} {}",
2728 r.issue,
2729 r.status.to_string(),
2730 r.rounds,
2731 r.pr.as_deref().unwrap_or("")
2732 );
2733 for note in &r.notes {
2734 println!(" {}", first_line(note));
2735 }
2736 for url in &r.filed {
2737 println!(" filed {url}");
2738 }
2739 for dispute in &r.disputes {
2740 println!(
2741 " disputed: {}",
2742 report_item(&dispute.title, &dispute.file)
2743 );
2744 }
2745 for finding in &r.noted {
2746 println!(
2747 " noted, not blocking: {}",
2748 report_item(&finding.title, &finding.file)
2749 );
2750 }
2751 if let Some(cost) = spend::for_subject(spent, r.issue) {
2752 println!(" {cost}");
2753 }
2754}
2755
2756pub(crate) fn report_writes(repo: &Repo) -> i32 {
2761 let writes = repo.write_summary();
2762 if let Some(line) = write_summary_line(writes) {
2763 println!("\n{line}");
2764 }
2765 write_exit_code(writes)
2766}
2767
2768fn write_summary_line(writes: WriteSummary) -> Option<String> {
2769 (writes.attempted > 0).then(|| {
2770 format!(
2771 "writes: {} attempted, {} succeeded, {} failed",
2772 writes.attempted,
2773 writes.succeeded(),
2774 writes.failed
2775 )
2776 })
2777}
2778
2779fn write_exit_code(writes: WriteSummary) -> i32 {
2780 i32::from(writes.failed > 0)
2781}
2782
2783fn report_item(title: &str, file: &str) -> String {
2784 match file.trim() {
2785 "" => title.to_string(),
2786 location => format!("{title} ({location})"),
2787 }
2788}
2789
2790#[cfg(test)]
2791mod tests {
2792 use super::*;
2793 use clap::CommandFactory;
2794
2795 fn planned(issue: i64, depends_on: Vec<i64>) -> PlanItem {
2796 PlanItem {
2797 issue,
2798 title: format!("issue {issue}"),
2799 complexity: crate::model::Complexity::S,
2800 risk: crate::model::Risk::Low,
2801 depends_on,
2802 reason: "worth doing".into(),
2803 }
2804 }
2805
2806 #[test]
2815 fn a_dependent_waits_for_a_dependency_that_has_not_landed() {
2816 let plan = Plan {
2817 order: vec![planned(9, vec![]), planned(10, vec![9])],
2818 ..Plan::default()
2819 };
2820 let mut landed = BTreeSet::new();
2821
2822 let why = held_back(&plan.order[1], &plan, &landed).expect("held");
2823 assert!(why.contains("#9"), "{why}");
2824 assert!(why.contains("has not merged"), "{why}");
2825 assert!(held_back(&plan.order[0], &plan, &landed).is_none());
2826
2827 landed.insert(9);
2829 assert!(held_back(&plan.order[1], &plan, &landed).is_none());
2830 }
2831
2832 #[test]
2835 fn a_dependent_on_a_declined_or_contested_issue_is_held_with_the_reason() {
2836 let declined = Plan {
2837 order: vec![planned(10, vec![9])],
2838 skipped: vec![crate::model::SkippedItem {
2839 issue: 9,
2840 title: "issue 9".into(),
2841 reasons: Default::default(),
2842 tracker: false,
2843 }],
2844 ..Plan::default()
2845 };
2846 let why = held_back(&declined.order[0], &declined, &BTreeSet::new()).expect("held");
2847 assert!(why.contains("declined by both reviewers"), "{why}");
2848
2849 let contested = Plan {
2850 order: vec![planned(10, vec![9])],
2851 contested: vec![crate::model::ContestedItem {
2852 issue: 9,
2853 title: "issue 9".into(),
2854 positions: Default::default(),
2855 reasons: Default::default(),
2856 note: None,
2857 }],
2858 ..Plan::default()
2859 };
2860 let why = held_back(&contested.order[0], &contested, &BTreeSet::new()).expect("held");
2861 assert!(why.contains("disagree about #9"), "{why}");
2862 }
2863
2864 #[test]
2868 fn a_dependency_outside_the_run_does_not_hold_anything() {
2869 let plan = Plan {
2870 order: vec![planned(10, vec![900])],
2871 ..Plan::default()
2872 };
2873 assert!(held_back(&plan.order[0], &plan, &BTreeSet::new()).is_none());
2874 }
2875
2876 #[test]
2882 fn doctor_says_when_a_scheduled_effort_is_not_one_the_agent_accepts() {
2883 let text = "\
2884[agents.claude]
2885preset = \"claude\"
2886
2887[agents.codex]
2888preset = \"codex\"
2889
2890[loop.effort_schedule]
2891review_1 = \"ultra\"
2892";
2893 let cfg = config::parse(text).expect("parses");
2894 let said = unknown_effort_values(&cfg);
2895 assert_eq!(1, said.len(), "{said:?}");
2896 assert!(said[0].starts_with("claude"), "{}", said[0]);
2897 assert!(said[0].contains("ultra"), "{}", said[0]);
2898 assert!(said[0].contains("review_1"), "{}", said[0]);
2899 assert!(
2900 said[0].contains("[agents.claude.effort_schedule]"),
2901 "it has to say where to put the right value: {}",
2902 said[0]
2903 );
2904
2905 let quiet = text.replace(
2907 "[loop.effort_schedule]\nreview_1 = \"ultra\"\n",
2908 "[agents.claude.effort_schedule]\nreview_1 = \"xhigh\"\n\n\
2909 [agents.codex.effort_schedule]\nreview_1 = \"ultra\"\n",
2910 );
2911 assert!(unknown_effort_values(&config::parse(&quiet).expect("parses")).is_empty());
2912 }
2913
2914 #[test]
2915 fn a_contested_issue_reads_as_both_positions_with_their_reasons() {
2916 let mut positions_map = std::collections::BTreeMap::new();
2917 positions_map.insert("claude".to_string(), "do".to_string());
2918 positions_map.insert("codex".to_string(), "skip".to_string());
2919 let mut reasons = std::collections::BTreeMap::new();
2920 reasons.insert(
2921 "claude".to_string(),
2922 "the retry path is still wrong".to_string(),
2923 );
2924 reasons.insert(
2925 "codex".to_string(),
2926 "fixed in the rewrite last month".to_string(),
2927 );
2928
2929 let line = positions(&positions_map, &reasons);
2930 assert!(line.contains("claude says do"), "{line}");
2931 assert!(line.contains("codex says skip"), "{line}");
2932 assert!(line.contains("the retry path is still wrong"), "{line}");
2933 assert!(line.contains("fixed in the rewrite"), "{line}");
2934
2935 let bare = positions(&positions_map, &Default::default());
2937 assert!(bare.contains("claude says do"), "{bare}");
2938 }
2939
2940 #[test]
2941 fn report_items_include_their_location() {
2942 assert_eq!(
2943 "Same title (src/a.rs:10)",
2944 report_item("Same title", "src/a.rs:10")
2945 );
2946 assert_eq!("General point", report_item("General point", ""));
2947 }
2948
2949 #[test]
2950 fn every_attempted_write_failing_is_a_failed_run() {
2951 let writes = WriteSummary {
2952 attempted: 3,
2953 failed: 3,
2954 };
2955
2956 assert_eq!(1, write_exit_code(writes));
2957 assert_eq!(
2958 Some("writes: 3 attempted, 0 succeeded, 3 failed".to_string()),
2959 write_summary_line(writes)
2960 );
2961 }
2962
2963 #[test]
2964 fn a_partial_write_failure_is_reported_after_the_run() {
2965 let writes = WriteSummary {
2966 attempted: 3,
2967 failed: 1,
2968 };
2969
2970 assert_eq!(1, write_exit_code(writes));
2971 assert_eq!(2, writes.succeeded());
2972 }
2973
2974 #[test]
2975 fn the_parser_is_internally_consistent() {
2976 Cli::command().debug_assert();
2977 }
2978
2979 #[test]
2980 fn quiet_is_accepted_before_or_after_the_subcommand() {
2981 for argv in [
2982 vec!["spar", "--quiet", "run", "42"],
2983 vec!["spar", "run", "42", "--quiet"],
2984 vec!["spar", "resume", "--quiet"],
2985 vec!["spar", "init", "-q"],
2986 ] {
2987 assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
2988 }
2989 assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
2990 }
2991
2992 #[test]
2993 fn several_issue_numbers_are_accepted() {
2994 let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
2995 match cli.command {
2996 Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
2997 other => panic!("{other:?}"),
2998 }
2999 }
3000
3001 #[test]
3002 fn issue_numbers_and_flags_can_be_interleaved() {
3003 let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
3004 match cli.command {
3005 Command::Run {
3006 issues, loop_flags, ..
3007 } => {
3008 assert_eq!(vec![42, 51], issues);
3009 assert!(loop_flags.auto_merge);
3010 }
3011 other => panic!("{other:?}"),
3012 }
3013 }
3014
3015 #[test]
3016 fn every_command_that_reads_a_config_accepts_one() {
3017 for argv in [
3018 vec!["spar", "run", "42"],
3019 vec!["spar", "triage"],
3020 vec!["spar", "resume"],
3021 vec!["spar", "followup"],
3022 vec!["spar", "checkin"],
3023 vec!["spar", "split"],
3024 vec!["spar", "brainstorm"],
3025 vec!["spar", "listen"],
3026 vec!["spar", "clean"],
3027 vec!["spar", "doctor"],
3028 ] {
3029 let mut full = argv.clone();
3030 full.extend(["--config", "other.toml"]);
3031 let cli = Cli::parse_from(&full);
3032 let config = match cli.command {
3033 Command::Run { common, .. }
3034 | Command::Triage { common, .. }
3035 | Command::Resume { common, .. }
3036 | Command::Followup { common, .. }
3037 | Command::Split { common, .. }
3038 | Command::Brainstorm { common, .. }
3039 | Command::Listen { common, .. }
3040 | Command::Checkin { common, .. } => common.config,
3041 Command::Clean { config, .. } | Command::Doctor { config } => config,
3042 other => panic!("{other:?}"),
3043 };
3044 assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
3045 }
3046 }
3047
3048 #[test]
3049 fn auto_merge_is_off_unless_asked_for() {
3050 let cli = Cli::parse_from(["spar", "run"]);
3051 match cli.command {
3052 Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
3053 other => panic!("{other:?}"),
3054 }
3055 }
3056
3057 #[test]
3060 fn every_command_that_reads_a_config_takes_instructions() {
3061 for argv in [
3064 vec!["spar", "run", "7", "--instructions", "Do not wait for CI."],
3065 vec![
3066 "spar",
3067 "triage",
3068 "7",
3069 "--instructions",
3070 "Do not wait for CI.",
3071 ],
3072 vec![
3073 "spar",
3074 "resume",
3075 "7",
3076 "--instructions",
3077 "Do not wait for CI.",
3078 ],
3079 vec![
3080 "spar",
3081 "review",
3082 "7",
3083 "--instructions",
3084 "Do not wait for CI.",
3085 ],
3086 vec!["spar", "followup", "--instructions", "Do not wait for CI."],
3087 vec![
3088 "spar",
3089 "checkin",
3090 "7",
3091 "--instructions",
3092 "Do not wait for CI.",
3093 ],
3094 vec![
3095 "spar",
3096 "split",
3097 "7",
3098 "--instructions",
3099 "Do not wait for CI.",
3100 ],
3101 vec![
3102 "spar",
3103 "brainstorm",
3104 "a",
3105 "subject",
3106 "--instructions",
3107 "Do not wait for CI.",
3108 ],
3109 vec![
3110 "spar",
3111 "listen",
3112 "7",
3113 "--instructions",
3114 "Do not wait for CI.",
3115 ],
3116 ] {
3117 let parsed = Cli::parse_from(&argv);
3118 let common = match parsed.command {
3119 Command::Run { common, .. }
3120 | Command::Triage { common, .. }
3121 | Command::Resume { common, .. }
3122 | Command::Review { common, .. }
3123 | Command::Followup { common, .. }
3124 | Command::Split { common, .. }
3125 | Command::Brainstorm { common, .. }
3126 | Command::Listen { common, .. }
3127 | Command::Checkin { common, .. } => common,
3128 other => panic!("{other:?}"),
3129 };
3130 assert_eq!(
3131 Some("Do not wait for CI."),
3132 common.instructions.as_deref(),
3133 "{argv:?}"
3134 );
3135 }
3136 }
3137
3138 #[test]
3139 fn the_two_close_skipped_flags_are_mutually_exclusive() {
3140 assert!(
3141 Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
3142 );
3143 }
3144
3145 #[test]
3148 fn close_skipped_is_offered_only_where_it_means_something() {
3149 assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
3150 assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
3151 assert!(Cli::try_parse_from(["spar", "followup", "--close-skipped"]).is_ok());
3153 assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
3154 assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
3155 assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
3156 }
3157
3158 #[test]
3161 fn brainstorm_takes_words_or_nothing() {
3162 match Cli::parse_from(["spar", "brainstorm", "novel", "uses", "of", "script"]).command {
3163 Command::Brainstorm { topic, ideas, .. } => {
3164 assert_eq!(vec!["novel", "uses", "of", "script"], topic);
3165 assert_eq!(5, ideas);
3166 }
3167 other => panic!("{other:?}"),
3168 }
3169 match Cli::parse_from(["spar", "brainstorm", "--ideas", "3", "--max-rounds", "2"]).command {
3170 Command::Brainstorm {
3171 topic,
3172 ideas,
3173 max_rounds,
3174 ..
3175 } => {
3176 assert!(topic.is_empty());
3177 assert_eq!(3, ideas);
3178 assert_eq!(Some(2), max_rounds);
3179 }
3180 other => panic!("{other:?}"),
3181 }
3182 }
3183
3184 #[test]
3188 fn filing_from_a_saved_session_needs_file_issues_and_writes_no_session() {
3189 assert!(Cli::try_parse_from(["spar", "brainstorm", "--from", "s.md"]).is_err());
3190 assert!(
3191 Cli::try_parse_from(["spar", "brainstorm", "--from", "s.md", "--file-issues"]).is_ok()
3192 );
3193 assert!(Cli::try_parse_from([
3194 "spar",
3195 "brainstorm",
3196 "--from",
3197 "s.md",
3198 "--file-issues",
3199 "--out",
3200 "t.md"
3201 ])
3202 .is_err());
3203 assert!(
3204 Cli::try_parse_from(["spar", "brainstorm", "x", "--out", "t.md", "--file-issues"])
3205 .is_ok()
3206 );
3207 }
3208
3209 #[test]
3212 fn followup_takes_no_numbers() {
3213 assert!(Cli::try_parse_from(["spar", "followup"]).is_ok());
3214 assert!(Cli::try_parse_from(["spar", "followup", "42"]).is_err());
3215 }
3216
3217 #[test]
3220 fn the_two_stopping_points_are_mutually_exclusive() {
3221 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only"]).is_ok());
3222 assert!(Cli::try_parse_from(["spar", "followup", "--file-only"]).is_ok());
3223 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only", "--file-only"]).is_err());
3224 }
3225
3226 #[test]
3227 fn the_close_skipped_pair_resolves_to_a_tristate() {
3228 let read = |argv: &[&str]| match Cli::parse_from(argv).command {
3229 Command::Run { triage_flags, .. } => {
3230 match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
3231 (true, _) => Some(true),
3232 (_, true) => Some(false),
3233 _ => None,
3234 }
3235 }
3236 other => panic!("{other:?}"),
3237 };
3238 assert_eq!(None, read(&["spar", "run"]));
3239 assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
3240 assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
3241 }
3242
3243 #[test]
3244 fn the_default_limit_is_twenty() {
3245 let cli = Cli::parse_from(["spar", "run"]);
3246 match cli.command {
3247 Command::Run { common, .. } => assert_eq!(20, common.limit),
3248 other => panic!("{other:?}"),
3249 }
3250 }
3251
3252 #[test]
3253 fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
3254 assert!(matches!(
3255 Cli::parse_from(["spar", "scrub-filter"]).command,
3256 Command::ScrubFilter
3257 ));
3258 let help = Cli::command().render_long_help().to_string();
3259 assert!(
3260 !help.contains("scrub-filter"),
3261 "it is plumbing, not a command"
3262 );
3263 }
3264
3265 #[test]
3266 fn review_takes_pr_numbers_and_a_dry_run() {
3267 let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
3268 match cli.command {
3269 Command::Review { items, dry_run, .. } => {
3270 assert_eq!(vec![101, 102], items);
3271 assert!(dry_run);
3272 }
3273 other => panic!("{other:?}"),
3274 }
3275 }
3276
3277 #[test]
3278 fn review_posts_unless_told_not_to() {
3279 match Cli::parse_from(["spar", "review", "101"]).command {
3280 Command::Review { dry_run, .. } => assert!(!dry_run),
3281 other => panic!("{other:?}"),
3282 }
3283 }
3284
3285 #[test]
3286 fn review_with_no_numbers_is_allowed() {
3287 match Cli::parse_from(["spar", "review"]).command {
3288 Command::Review { items, .. } => assert!(items.is_empty()),
3289 other => panic!("{other:?}"),
3290 }
3291 }
3292
3293 #[test]
3294 fn review_takes_its_own_round_budget() {
3295 match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
3296 Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
3297 other => panic!("{other:?}"),
3298 }
3299 }
3300
3301 #[test]
3302 fn checkin_takes_pr_numbers_and_a_dry_run() {
3303 match Cli::parse_from(["spar", "checkin", "108", "112", "--dry-run"]).command {
3304 Command::Checkin { items, dry_run, .. } => {
3305 assert_eq!(vec![108, 112], items);
3306 assert!(dry_run);
3307 }
3308 other => panic!("{other:?}"),
3309 }
3310 match Cli::parse_from(["spar", "checkin"]).command {
3311 Command::Checkin { items, dry_run, .. } => {
3312 assert!(items.is_empty());
3313 assert!(!dry_run);
3314 }
3315 other => panic!("{other:?}"),
3316 }
3317 }
3318
3319 #[test]
3325 fn checkin_offers_no_flag_that_would_weaken_the_pair() {
3326 assert!(Cli::try_parse_from(["spar", "checkin", "--auto-merge"]).is_err());
3327 assert!(Cli::try_parse_from(["spar", "checkin", "--max-rounds", "1"]).is_err());
3328 assert!(Cli::try_parse_from(["spar", "checkin", "--absorb", "1"]).is_err());
3329 assert!(Cli::try_parse_from(["spar", "checkin", "--close-skipped"]).is_err());
3330 assert!(Cli::try_parse_from(["spar", "checkin", "--reply-only"]).is_ok());
3332 assert!(Cli::try_parse_from(["spar", "checkin", "--any-author"]).is_ok());
3333 assert!(Cli::try_parse_from(["spar", "checkin", "--again"]).is_ok());
3334 assert!(Cli::try_parse_from(["spar", "checkin", "--keep-worktrees"]).is_ok());
3335 }
3336
3337 #[test]
3338 fn review_takes_verdict_and_recheck_flags_and_neither_by_default() {
3339 match Cli::parse_from(["spar", "review", "108", "--verdict", "--recheck"]).command {
3340 Command::Review {
3341 items,
3342 verdict,
3343 recheck,
3344 ..
3345 } => {
3346 assert_eq!(vec![108], items);
3347 assert!(verdict);
3348 assert!(recheck);
3349 }
3350 other => panic!("{other:?}"),
3351 }
3352 match Cli::parse_from(["spar", "review", "108"]).command {
3353 Command::Review {
3354 verdict, recheck, ..
3355 } => {
3356 assert!(!verdict);
3357 assert!(!recheck);
3358 }
3359 other => panic!("{other:?}"),
3360 }
3361 assert!(Cli::try_parse_from(["spar", "checkin", "108", "--verdict"]).is_err());
3362 }
3363
3364 #[test]
3365 fn listen_takes_numbers_once_dry_run_and_since() {
3366 match Cli::parse_from([
3367 "spar",
3368 "listen",
3369 "108",
3370 "42",
3371 "--once",
3372 "--dry-run",
3373 "--since",
3374 "2h",
3375 ])
3376 .command
3377 {
3378 Command::Listen {
3379 items,
3380 once,
3381 dry_run,
3382 since,
3383 ..
3384 } => {
3385 assert_eq!(vec![108, 42], items);
3386 assert!(once);
3387 assert!(dry_run);
3388 assert_eq!(Some("2h".to_string()), since);
3389 }
3390 other => panic!("{other:?}"),
3391 }
3392 match Cli::parse_from(["spar", "listen"]).command {
3393 Command::Listen {
3394 items,
3395 once,
3396 dry_run,
3397 since,
3398 common,
3399 } => {
3400 assert!(items.is_empty());
3401 assert!(!once);
3402 assert!(!dry_run);
3403 assert_eq!(None, since);
3404 assert_eq!(20, common.limit);
3405 }
3406 other => panic!("{other:?}"),
3407 }
3408 }
3409
3410 #[test]
3411 fn resume_takes_a_next_override() {
3412 let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
3413 match cli.command {
3414 Command::Resume {
3415 prs, next_actor, ..
3416 } => {
3417 assert_eq!(vec![108], prs);
3418 assert_eq!(Some("codex".to_string()), next_actor);
3419 }
3420 other => panic!("{other:?}"),
3421 }
3422 }
3423}
3424
3425#[cfg(test)]
3426mod absorb_tests {
3427 use super::*;
3428
3429 #[test]
3430 fn absorb_is_off_unless_asked_for() {
3431 match Cli::parse_from(["spar", "run"]).command {
3432 Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
3433 other => panic!("{other:?}"),
3434 }
3435 }
3436
3437 #[test]
3438 fn absorb_takes_a_wave_count() {
3439 match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
3440 Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
3441 other => panic!("{other:?}"),
3442 }
3443 }
3444
3445 #[test]
3446 fn absorb_is_only_offered_where_issues_are_worked() {
3447 assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
3448 assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
3449 assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
3450 assert!(Cli::try_parse_from(["spar", "split", "--absorb", "1"]).is_err());
3453 }
3454}
3455
3456#[cfg(test)]
3457mod split_tests {
3458 use super::*;
3459
3460 #[test]
3461 fn split_takes_numbers_of_either_kind() {
3462 match Cli::parse_from(["spar", "split", "10", "12"]).command {
3463 Command::Split { items, .. } => assert_eq!(vec![10, 12], items),
3464 other => panic!("{other:?}"),
3465 }
3466 }
3467
3468 #[test]
3471 fn split_with_no_numbers_is_allowed() {
3472 match Cli::parse_from(["spar", "split"]).command {
3473 Command::Split {
3474 items,
3475 dry_run,
3476 again,
3477 ..
3478 } => {
3479 assert!(items.is_empty());
3480 assert!(!dry_run);
3481 assert!(!again);
3482 }
3483 other => panic!("{other:?}"),
3484 }
3485 }
3486
3487 #[test]
3491 fn split_can_be_told_to_write_nothing() {
3492 match Cli::parse_from(["spar", "split", "10", "--dry-run"]).command {
3493 Command::Split { dry_run, .. } => assert!(dry_run),
3494 other => panic!("{other:?}"),
3495 }
3496 }
3497
3498 #[test]
3501 fn split_can_be_told_to_split_something_it_already_split() {
3502 match Cli::parse_from(["spar", "split", "10", "--again"]).command {
3503 Command::Split { again, .. } => assert!(again),
3504 other => panic!("{other:?}"),
3505 }
3506 }
3507
3508 #[test]
3511 fn split_takes_the_flags_that_govern_picking() {
3512 match Cli::parse_from([
3513 "spar",
3514 "split",
3515 "--limit",
3516 "5",
3517 "--min-number",
3518 "480",
3519 "--config",
3520 "other.toml",
3521 ])
3522 .command
3523 {
3524 Command::Split { common, .. } => {
3525 assert_eq!(5, common.limit);
3526 assert_eq!(Some(480), common.min_number);
3527 assert_eq!(Some(PathBuf::from("other.toml")), common.config);
3528 }
3529 other => panic!("{other:?}"),
3530 }
3531 }
3532
3533 #[test]
3536 fn split_offers_no_flag_that_would_make_it_a_second_run() {
3537 for flag in [
3538 vec!["--auto-merge"],
3539 vec!["--max-rounds", "2"],
3540 vec!["--close-skipped"],
3541 vec!["--no-close-skipped"],
3542 vec!["--keep-worktrees"],
3543 ] {
3544 let mut argv = vec!["spar", "split", "10"];
3545 argv.extend(flag.iter().copied());
3546 assert!(Cli::try_parse_from(&argv).is_err(), "{argv:?}");
3547 }
3548 }
3549}
3550
3551#[cfg(test)]
3552mod min_number_tests {
3553 use super::*;
3554
3555 fn read(argv: &[&str]) -> Option<i64> {
3556 match Cli::parse_from(argv).command {
3557 Command::Run { common, .. }
3558 | Command::Triage { common, .. }
3559 | Command::Resume { common, .. }
3560 | Command::Review { common, .. }
3561 | Command::Followup { common, .. }
3562 | Command::Split { common, .. }
3563 | Command::Listen { common, .. }
3564 | Command::Checkin { common, .. } => common.min_number,
3565 other => panic!("{other:?}"),
3566 }
3567 }
3568
3569 #[test]
3570 fn there_is_no_floor_unless_one_is_asked_for() {
3571 assert_eq!(None, read(&["spar", "run"]));
3572 }
3573
3574 #[test]
3575 fn every_command_that_picks_for_itself_accepts_a_floor() {
3576 for cmd in [
3577 "run", "triage", "resume", "review", "checkin", "split", "listen",
3578 ] {
3579 assert_eq!(
3580 Some(480),
3581 read(&["spar", cmd, "--min-number", "480"]),
3582 "{cmd}"
3583 );
3584 }
3585 }
3586}
3587
3588#[cfg(test)]
3589mod run_targets_tests {
3590 use super::*;
3591
3592 fn covered(pairs: &[(i64, i64)]) -> BTreeMap<i64, i64> {
3593 pairs.iter().copied().collect()
3594 }
3595
3596 #[test]
3601 fn an_issue_its_named_pull_request_closes_is_not_worked_again() {
3602 let kept = issues_not_on_named_prs(&[772, 783, 784], &covered(&[(772, 786)]));
3603
3604 assert_eq!(vec![783, 784], kept);
3605 }
3606
3607 #[test]
3608 fn an_issue_no_named_pull_request_closes_is_still_worked() {
3609 let kept = issues_not_on_named_prs(&[783, 784, 771], &covered(&[(772, 786)]));
3610
3611 assert_eq!(vec![783, 784, 771], kept);
3612 }
3613
3614 #[test]
3617 fn an_unreadable_pull_request_drops_no_issue() {
3618 let kept = issues_not_on_named_prs(&[772, 783], &covered(&[]));
3619
3620 assert_eq!(vec![772, 783], kept);
3621 }
3622
3623 #[test]
3625 fn every_issue_one_pull_request_closes_is_taken_by_it() {
3626 let kept = issues_not_on_named_prs(&[771, 772, 783], &covered(&[(771, 786), (772, 786)]));
3627
3628 assert_eq!(vec![783], kept);
3629 }
3630}
3631
3632#[cfg(test)]
3633mod code_tests {
3634 use super::*;
3635
3636 #[test]
3637 fn code_requires_a_prompt_and_exclusive_preview_modes() {
3638 assert!(Cli::try_parse_from(["spar", "code"]).is_err());
3639 assert!(
3640 Cli::try_parse_from(["spar", "code", "Add export", "--dry-run", "--file-only"])
3641 .is_err()
3642 );
3643 assert!(Cli::try_parse_from(["spar", "code", "Add export", "--dry-run"]).is_ok());
3644 assert!(Cli::try_parse_from(["spar", "code", "Add export", "--file-only"]).is_ok());
3645 }
3646
3647 #[test]
3648 fn code_takes_words_and_the_normal_run_options() {
3649 let cli = Cli::parse_from([
3650 "spar",
3651 "code",
3652 "Add",
3653 "CSV",
3654 "export",
3655 "--repo",
3656 "/tmp/project",
3657 "--config",
3658 "project.toml",
3659 "--first",
3660 "b",
3661 "--base",
3662 "develop",
3663 "--instructions",
3664 "Keep the format stable",
3665 "--max-rounds",
3666 "5",
3667 "--auto-merge",
3668 "--absorb",
3669 "1",
3670 "--keep-worktrees",
3671 "--no-worktrees",
3672 "--no-close-skipped",
3673 "--retriage",
3674 "--plan-out",
3675 "code-plan.json",
3676 ]);
3677 match cli.command {
3678 Command::Code {
3679 prompt,
3680 common,
3681 loop_flags,
3682 triage_flags,
3683 plan_out,
3684 no_worktrees,
3685 dry_run,
3686 file_only,
3687 } => {
3688 assert_eq!(prompt, ["Add", "CSV", "export"]);
3689 assert_eq!(common.repo, PathBuf::from("/tmp/project"));
3690 assert_eq!(common.config, Some(PathBuf::from("project.toml")));
3691 assert_eq!(common.first.as_deref(), Some("b"));
3692 assert_eq!(common.base.as_deref(), Some("develop"));
3693 assert_eq!(
3694 common.instructions.as_deref(),
3695 Some("Keep the format stable")
3696 );
3697 assert_eq!(loop_flags.max_rounds, Some(5));
3698 assert_eq!(loop_flags.absorb, Some(1));
3699 assert!(loop_flags.auto_merge && loop_flags.keep_worktrees);
3700 assert!(triage_flags.no_close_skipped && triage_flags.retriage);
3701 assert_eq!(plan_out, PathBuf::from("code-plan.json"));
3702 assert!(no_worktrees);
3703 assert!(!dry_run && !file_only);
3704 }
3705 other => panic!("{other:?}"),
3706 }
3707 }
3708}
3709
3710#[cfg(test)]
3711mod settings_block_tests {
3712 use super::*;
3713
3714 fn written(line: &str) -> String {
3717 let after = line.split_once('=').expect("an assignment").1;
3718 let mut quoted = false;
3719 for (i, c) in after.char_indices() {
3720 match c {
3721 '"' => quoted = !quoted,
3722 '#' if !quoted => return after[..i].trim().to_string(),
3723 _ => {}
3724 }
3725 }
3726 after.trim().to_string()
3727 }
3728
3729 fn line_for(text: &str, key: &str) -> String {
3730 text.lines()
3731 .find(|l| {
3732 let bare = l.trim_start().trim_start_matches('#').trim_start();
3733 bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
3734 })
3735 .unwrap_or_else(|| panic!("{key} is not offered at all:\n{text}"))
3736 .to_string()
3737 }
3738
3739 #[test]
3746 fn every_value_it_offers_is_the_default_it_actually_has() {
3747 let text = settings_block("claude");
3748 for option in config::known_options() {
3749 if option.section == "loop.effort_schedule" {
3753 continue;
3754 }
3755 let line = line_for(&text, &option.key);
3756 assert_eq!(
3757 option.default,
3758 written(&line),
3759 "the generated config offers `{}`, but the default is {}",
3760 line.trim(),
3761 option.default
3762 );
3763 }
3764 }
3765
3766 #[test]
3770 fn it_offers_every_option_the_parser_knows_about() {
3771 let text = settings_block("claude");
3772 let missing: Vec<String> = config::unmentioned_options(&text)
3773 .into_iter()
3774 .map(|o| format!("[{}] {}", o.section, o.key))
3775 .collect();
3776 assert!(missing.is_empty(), "not offered: {}", missing.join(", "));
3777 }
3778
3779 #[test]
3783 fn every_option_it_offers_can_be_uncommented_and_still_load() {
3784 let mut text = String::from(
3785 "[agents.claude]\ncommand = [\"claude\"]\n\n\
3786 [agents.codex]\ncommand = [\"codex\"]\n\n",
3787 );
3788 for line in settings_block("claude").lines() {
3789 text.push_str(uncomment(line).unwrap_or(line));
3790 text.push('\n');
3791 }
3792 let cfg = config::parse(&text).expect("a config of its own suggestions");
3793 assert_eq!("claude", cfg.first_implementor);
3794 }
3795
3796 fn uncomment(line: &str) -> Option<&str> {
3799 let bare = line.trim_start().strip_prefix('#')?.trim_start();
3800 let key = bare.split_once('=')?.0.trim();
3803 let named = !key.is_empty()
3804 && key
3805 .chars()
3806 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
3807 named.then_some(bare)
3808 }
3809
3810 #[test]
3811 fn the_agent_that_goes_first_is_the_one_that_was_chosen() {
3812 assert!(settings_block("codex").contains("first_implementor = \"codex\""));
3813 }
3814
3815 #[test]
3820 fn a_note_sits_above_the_option_it_describes() {
3821 let text = settings_block("claude");
3822 assert!(
3823 text.lines().all(|l| l.chars().count() <= 80),
3824 "a line runs off the edge:\n{text}"
3825 );
3826 assert!(
3827 text.lines().all(|l| !l.starts_with(' ')),
3828 "a line is indented, so the columns are back:\n{text}"
3829 );
3830
3831 let lines: Vec<&str> = text.lines().collect();
3833 let at = lines
3834 .iter()
3835 .position(|l| l.starts_with("max_rounds"))
3836 .expect("max_rounds");
3837 assert!(lines[at - 1].starts_with('#'), "{:?}", lines[at - 1]);
3838 assert!(
3839 lines[at - 1].contains("lifetime cap"),
3840 "the note above it is the end of its own note: {:?}",
3841 lines[at - 1]
3842 );
3843 }
3844
3845 #[test]
3847 fn every_note_starts_as_a_sentence() {
3848 for (_, key, note) in LOOP_OPTIONS
3849 .iter()
3850 .chain(STYLE_OPTIONS)
3851 .chain(LISTEN_OPTIONS)
3852 {
3853 let first = note.chars().next().expect("a note");
3854 assert!(
3855 first.is_uppercase(),
3856 "{key} reads as a margin scribble rather than a sentence: {note}"
3857 );
3858 }
3859 }
3860}
3861
3862#[cfg(test)]
3863mod agent_block_tests {
3864 use super::*;
3865
3866 fn spec(models: &[&str], efforts: &[&str]) -> config::AgentSpec {
3867 let mut spec: config::AgentSpec =
3868 toml::Value::Table(toml::from_str("command = [\"x\"]").expect("a minimal preset"))
3869 .try_into()
3870 .expect("builds");
3871 spec.models = models.iter().map(|s| s.to_string()).collect();
3872 spec.efforts = efforts.iter().map(|s| s.to_string()).collect();
3873 spec
3874 }
3875
3876 #[test]
3880 fn an_option_with_no_hints_is_left_out_rather_than_guessed_at() {
3881 let block = agent_block("cursor", &spec(&["composer-2.5", "auto"], &[]));
3882 assert!(!block.contains("..."), "{block}");
3883 assert!(!block.contains("effort"), "{block}");
3884 assert!(block.contains("# model = \"composer-2.5\""), "{block}");
3885 }
3886
3887 #[test]
3890 fn only_the_options_that_follow_are_introduced() {
3891 let model_only = agent_block("cursor", &spec(&["auto"], &[]));
3892 assert!(model_only.contains("Omit model to use"), "{model_only}");
3893 assert!(!model_only.contains("Omit effort"), "{model_only}");
3894
3895 let both = agent_block("claude", &spec(&["fable"], &["high"]));
3896 assert!(both.contains("Omit model to use"), "{both}");
3897 assert!(both.contains("Omit effort to use"), "{both}");
3898 }
3899
3900 #[test]
3903 fn a_preset_with_no_hints_still_writes_a_usable_block() {
3904 let block = agent_block("gemini", &spec(&[], &[]));
3905 assert!(!block.contains("..."), "{block}");
3906 assert!(!block.contains("Omit"), "{block}");
3907 assert!(
3908 block.starts_with("[agents.gemini]\npreset = \"gemini\"\n"),
3909 "{block}"
3910 );
3911 assert!(block.contains("[agents.gemini.fallback]"), "{block}");
3913 assert!(block.contains("# timeout = "), "{block}");
3914 }
3915
3916 #[test]
3919 fn the_timeout_offered_is_the_one_the_agent_would_use() {
3920 let mut spec = spec(&["a"], &[]);
3921 spec.timeout = 7200;
3922 assert!(
3923 agent_block("custom", &spec).contains("# timeout = 7200"),
3924 "the generator kept its own copy"
3925 );
3926 }
3927
3928 #[test]
3930 fn alternatives_are_listed_only_when_there_are_any() {
3931 assert!(agent_block("a", &spec(&["one", "two"], &[])).contains("One of: one | two."));
3932 let single = agent_block("b", &spec(&["only"], &[]));
3933 assert!(!single.contains("One of:"), "{single}");
3934 }
3935
3936 #[test]
3940 fn the_presets_note_is_said_once() {
3941 let mut spec = spec(&["m1", "m2"], &["e1", "e2"]);
3942 spec.options_note = Some("Check the current sets with: mytool --help".into());
3943 let block = agent_block("mytool", &spec);
3944 assert_eq!(
3945 1,
3946 block.matches("Check the current sets").count(),
3947 "{block}"
3948 );
3949 }
3950}