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, and run each read-only call, such as triage or drafting an issue, in a throwaway checkout of HEAD so another agent working in the main checkout cannot fail it. 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, "tick_criteria", "Tick an issue's acceptance criteria, its unchecked task list items, after spar merges a pull request the signing review ruled met them. Each item is ruled on by the agent that did not write the head, never ticked on the author's word, and a box is never unchecked. Off, or with auto_merge off, the rulings go in the outcome comment instead."),
2253 (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."),
2254 (true, "file_nits", "File nits as follow-ups too. Off, because a filed nit is somebody else's notification."),
2255 (true, "base_branch", "Only a fallback. Whatever origin/HEAD points at wins when it resolves."),
2256 (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."),
2257 (true, "state_store", "Where resume state is kept. local uses .spar/state and keeps it off the pull request."),
2258 (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."),
2259 (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."),
2260 (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."),
2261 (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."),
2262 (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."),
2263 (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."),
2264 (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."),
2265 (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."),
2266];
2267
2268const STYLE_OPTIONS: &[Setting] = &[
2269 (false, "ban_em_dash", "Strip em-dashes and en-dashes from everything spar posts, then refuse to post text that still has one."),
2270 (false, "ban_ai_attribution", "Strip mentions of the tooling, and Co-Authored-By trailers, from everything spar posts."),
2271 (false, "terse", "Hold model prose to a length budget. false removes the valves entirely."),
2272 (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."),
2273 (true, "inline_comments", "Put each finding of `spar review` on the line it is about, when the pull request's diff shows that line. The summary still names it, and its line comment carries the detail. false keeps every finding in the summary alone, in full."),
2274 (true, "max_title_chars", "A finding, issue, or pull request title. Never ellipsised: a title ending in three dots reads as broken."),
2275 (true, "max_summary_chars", "A one line verdict, or a refutation's argument."),
2276 (true, "max_detail_chars", "A blocking finding's explanation, as it appears in the pull request thread."),
2277 (true, "max_body_chars", "A pull request body."),
2278 (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."),
2279];
2280
2281const LISTEN_OPTIONS: &[Setting] = &[
2282 (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."),
2283 (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."),
2284 (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."),
2285 (true, "max_wait", "Most seconds an item may keep settling before it is acted on anyway."),
2286 (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."),
2287 (true, "on_review_requested", "What a pull request somebody asked you to review gets: review or ignore."),
2288 (true, "on_assigned", "What a pull request assigned to you gets: review or ignore."),
2289 (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."),
2290 (false, "verdicts", "Whether a review `spar listen` starts submits a formal verdict, approve or request changes, rather than a comment. A recheck always does."),
2291 (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."),
2292 (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."),
2293];
2294
2295fn settings_block(first_implementor: &str) -> String {
2300 let defaults: std::collections::BTreeMap<String, String> = config::known_options()
2301 .into_iter()
2302 .map(|option| (option.key, option.default))
2303 .collect();
2304 let value = |key: &str| match key {
2307 "first_implementor" => format!("\"{first_implementor}\""),
2308 other => defaults.get(other).cloned().unwrap_or_default(),
2309 };
2310
2311 let mut out = String::from("[loop]\n");
2312 out.push_str(&option_lines(LOOP_OPTIONS, &value));
2313 out.push_str(concat!(
2314 "\n[loop.effort_schedule]\n",
2315 "# One key per kind of call, so a value means what its name says. Values\n",
2316 "# are whatever each agent's own CLI accepts, listed above, so these are\n",
2317 "# examples rather than defaults. Left out, a call falls back to\n",
2318 "# round_1 or rest, and then to the effort the agent's own block asked\n",
2319 "# for. Where the two CLIs do not share a vocabulary, put the schedule\n",
2320 "# under [agents.NAME.effort_schedule] instead, with the same keys.\n",
2321 "# code = \"high\" # drafting an issue from a project prompt\n",
2322 "# triage = \"low\" # both agents, over the whole queue, per wave\n",
2323 "# implement = \"high\" # writing the first draft\n",
2324 "# review_1 = \"high\" # the deep first review\n",
2325 "# review_rest = \"low\" # later rounds, which see a small delta\n",
2326 "# respond = \"high\" # the author answering a review\n",
2327 "# close = \"low\" # the closing merge safety pass\n",
2328 "# screen = \"low\" # the follow-up screen, one call for the queue\n",
2329 "# checkin = \"high\" # judging comments other people left\n",
2330 "# split = \"high\" # proposing and checking a split\n",
2331 "# brainstorm = \"high\" # every brainstorm round\n",
2332 "# recheck = \"low\" # a second look after commits or replies\n",
2333 "# answer = \"high\" # drafting and checking answers to a mention\n",
2334 "# round_1 = \"high\" # read by any call above with no key set\n",
2335 "# rest = \"low\" # the same, for later rounds and the close\n\n",
2336 ));
2337 out.push_str("[style]\n");
2338 out.push_str(&option_lines(STYLE_OPTIONS, &value));
2339 out.push_str("\n[listen]\n");
2340 out.push_str(&option_lines(LISTEN_OPTIONS, &value));
2341 out
2342}
2343
2344fn option_lines(options: &[Setting], value: &dyn Fn(&str) -> String) -> String {
2348 let mut out = String::new();
2349 for (commented, key, note) in options {
2350 if !out.is_empty() {
2351 out.push('\n');
2352 }
2353 out.push_str(&wrap_comment(note));
2354 let lead = if *commented { "# " } else { "" };
2355 out.push_str(&format!("{lead}{key} = {}\n", value(key)));
2356 }
2357 out
2358}
2359
2360type Probe = Box<dyn Fn() -> Result<String>>;
2363
2364fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
2371 let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
2372
2373 if let Some(extra) = &spec.options_note {
2385 if !spec.models.is_empty() || !spec.efforts.is_empty() {
2386 out.push('\n');
2387 out.push_str(&wrap_comment(extra));
2388 }
2389 }
2390 for (key, choices) in [("model", &spec.models), ("effort", &spec.efforts)] {
2391 let Some(suggested) = choices.first() else {
2392 continue;
2393 };
2394 let mut note = format!("Omit {key} to use the CLI's own default.");
2395 if choices.len() > 1 {
2396 note.push_str(&format!(" One of: {}.", choices.join(" | ")));
2397 }
2398 out.push('\n');
2399 out.push_str(&wrap_comment(¬e));
2400 out.push_str(&format!("# {key} = \"{suggested}\"\n"));
2401 }
2402
2403 out.push('\n');
2407 out.push_str(&wrap_comment(
2408 "Seconds one call may take before spar gives up. A timeout costs the whole call and is \
2409 never retried, so err long.",
2410 ));
2411 out.push_str(&format!("# timeout = {}\n", spec.timeout));
2412
2413 let backup = if name == "cursor" { "gemini" } else { "cursor" };
2416 out.push('\n');
2417 out.push_str(&wrap_comment(
2418 "A stand in for when this CLI refuses, stalls, or runs out of quota. It answers in place \
2419 of this agent, never alongside it.",
2420 ));
2421 out.push_str(&format!(
2422 "# [agents.{name}.fallback]\n# preset = \"{backup}\"\n"
2423 ));
2424
2425 out.push('\n');
2430 out.push_str(&wrap_comment(
2431 "command, output, search_paths and the rest are in spar.example.toml, for pairing a CLI \
2432 that has no preset.",
2433 ));
2434 out.push('\n');
2435 out
2436}
2437
2438fn note_for(key: &str) -> &'static str {
2444 LOOP_OPTIONS
2445 .iter()
2446 .chain(STYLE_OPTIONS)
2447 .chain(LISTEN_OPTIONS)
2448 .find(|(_, name, _)| *name == key)
2449 .map(|(_, _, note)| *note)
2450 .unwrap_or("")
2451}
2452
2453fn wrap_comment(text: &str) -> String {
2455 const WIDTH: usize = 76;
2456 let mut out = String::new();
2457 let mut line = String::from("#");
2458 for word in text.split_whitespace() {
2459 if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
2460 out.push_str(&line);
2461 out.push('\n');
2462 line = String::from("#");
2463 }
2464 line.push(' ');
2465 line.push_str(word);
2466 }
2467 if line.len() > 1 {
2468 out.push_str(&line);
2469 out.push('\n');
2470 }
2471 out
2472}
2473
2474fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
2475 let mut ok = true;
2476
2477 let probes: Vec<(&str, Probe)> = vec![
2478 (
2479 "git",
2480 Box::new(|| {
2481 proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
2482 .map(|s| first_line(&s))
2483 }),
2484 ),
2485 (
2486 "gh",
2487 Box::new(|| {
2488 proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
2489 .map(|s| first_line(&s))
2490 }),
2491 ),
2492 (
2493 "gh auth",
2494 Box::new(|| {
2495 let out = proc::exec(
2496 &["gh".into(), "auth".into(), "status".into()],
2497 &ExecOpts::new().check(false).timeout_secs(60),
2498 )?;
2499 let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
2500 if out.ok() {
2501 Ok(first_line(&text))
2502 } else {
2503 Err(spar_err!("not authenticated. Run `gh auth login`."))
2504 }
2505 }),
2506 ),
2507 ];
2508
2509 for (label, probe) in probes {
2510 match probe() {
2511 Ok(detail) => println!(" ok {label:12} {detail}"),
2512 Err(e) => {
2513 println!(" FAIL {label:12} {}", e.first_line());
2514 ok = false;
2515 }
2516 }
2517 }
2518
2519 let found = config::find_config(config_path)?;
2520 let Some(path) = found else {
2521 println!("\n no spar.toml found. Run `spar init` to generate one.");
2522 println!(
2523 " presets available: {}",
2524 config::available_presets().join(", ")
2525 );
2526 return Ok(if ok { 0 } else { 1 });
2527 };
2528
2529 println!("\n config: {}", path.display());
2530 let cfg = match config::load(Some(&path)) {
2531 Ok(cfg) => cfg,
2532 Err(e) => {
2533 println!(" FAIL config {e}");
2534 return Ok(1);
2535 }
2536 };
2537
2538 let mut resolved = Vec::new();
2541 for spec in &cfg.agents {
2542 let agent = Agent::new(spec.clone());
2543 match agent.resolve_bin() {
2544 Ok(bin) => {
2545 println!(
2546 " ok {:12} {} ({})",
2547 spec.name,
2548 bin.display(),
2549 spec.describe()
2550 );
2551 report_fallback(&agent);
2552 resolved.push(agent);
2553 }
2554 Err(e) => {
2555 println!(" FAIL {:12} {}", spec.name, e.first_line());
2556 ok = false;
2557 }
2558 }
2559 }
2560
2561 if resolved.len() == cfg.agents.len() {
2562 if let Some(warning) = agent::correlation_warning(&resolved) {
2563 println!("\n WARNING {warning}");
2564 }
2565 }
2566
2567 for line in unknown_effort_values(&cfg) {
2568 println!("\n WARNING {line}");
2569 }
2570
2571 println!(
2572 "\n settings: first={} max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
2573 cfg.first_implementor_policy(),
2574 cfg.loop_cfg.max_rounds,
2575 cfg.loop_cfg.auto_merge,
2576 cfg.loop_cfg.worktrees,
2577 cfg.loop_cfg.followups,
2578 cfg.style.terse
2579 );
2580 if let Ok(text) = std::fs::read_to_string(&path) {
2584 let unset = config::unmentioned_options(&text);
2585 if !unset.is_empty() {
2586 println!(
2587 "\n {} setting(s) this config does not mention, all at their defaults:",
2588 unset.len()
2589 );
2590 for option in &unset {
2591 println!(
2592 " [{}] {} = {}",
2593 option.section, option.key, option.default
2594 );
2595 }
2596 println!(
2597 " `spar init --update {}` appends them as comments.",
2598 path.display()
2599 );
2600 }
2601 }
2602
2603 println!(
2604 "{}",
2605 if ok {
2606 "\nready"
2607 } else {
2608 "\nmissing prerequisites"
2609 }
2610 );
2611 Ok(if ok { 0 } else { 1 })
2612}
2613
2614fn unknown_effort_values(cfg: &config::Config) -> Vec<String> {
2623 let mut out = Vec::new();
2624 for spec in &cfg.agents {
2625 if spec.efforts.is_empty() {
2626 continue;
2627 }
2628 let mut said: Vec<String> = Vec::new();
2629 for call in config::Call::every() {
2630 let Some(value) = cfg.effort_for(spec, call) else {
2631 continue;
2632 };
2633 let known = spec
2634 .efforts
2635 .iter()
2636 .any(|hint| hint.eq_ignore_ascii_case(value.trim()));
2637 if known || said.contains(&value) {
2638 continue;
2639 }
2640 said.push(value.clone());
2641 out.push(format!(
2642 "{} is asked for effort \"{value}\" on the {} call, which is not one it is known \
2643 to accept ({}). Put this agent's values under \
2644 [agents.{}.effort_schedule].",
2645 spec.name,
2646 call.key(),
2647 spec.efforts.join(" | "),
2648 spec.name
2649 ));
2650 }
2651 }
2652 out
2653}
2654
2655fn first_line(text: &str) -> String {
2656 text.trim().lines().next().unwrap_or("").trim().to_string()
2657}
2658
2659fn report(results: &[IssueRun], cfg: &Config, repo: &Repo) -> i32 {
2664 report_with(results, cfg, repo, &[], &[])
2665}
2666
2667fn report_with(
2674 results: &[IssueRun],
2675 cfg: &Config,
2676 repo: &Repo,
2677 stopped: &[String],
2678 parked: &[String],
2679) -> i32 {
2680 let spent = spend::taken();
2683 if let Err(e) = repo.record_spend(&spent) {
2684 logdim!("could not record what this run spent: {e}");
2685 }
2686 println!("\n{}", "=".repeat(60));
2687 for r in results {
2688 print_outcome(r, &spent);
2689 }
2690 println!("{}", "=".repeat(60));
2691
2692 if cfg.alternate_first && results.len() > 1 {
2693 println!(
2694 "\nfirst implementor alternated, starting with {}. Who wrote each first draft is in \
2695 .spar/state/implementors.json.",
2696 cfg.first_implementor
2697 );
2698 }
2699 if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
2700 println!("\nApproved PRs are waiting on you to merge.");
2701 }
2702 let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
2703 if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
2704 println!(
2705 "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
2706 Set followups = \"issues\" to file them."
2707 );
2708 }
2709 if let Some(summary) = spend::summary(&spent) {
2710 println!("\n{summary}");
2711 }
2712 for lines in [parked, stopped] {
2713 if lines.is_empty() {
2714 continue;
2715 }
2716 println!();
2717 for line in lines {
2718 println!("{line}");
2719 }
2720 }
2721 let issue_exit = i32::from(!results.iter().all(IssueRun::succeeded) || !stopped.is_empty());
2722 issue_exit.max(report_writes(repo))
2723}
2724
2725pub(crate) fn print_outcome(r: &IssueRun, spent: &[spend::Spent]) {
2727 println!(
2728 "#{:<5} {:<10} rounds={} {}",
2729 r.issue,
2730 r.status.to_string(),
2731 r.rounds,
2732 r.pr.as_deref().unwrap_or("")
2733 );
2734 for note in &r.notes {
2735 println!(" {}", first_line(note));
2736 }
2737 for url in &r.filed {
2738 println!(" filed {url}");
2739 }
2740 for dispute in &r.disputes {
2741 println!(
2742 " disputed: {}",
2743 report_item(&dispute.title, &dispute.file)
2744 );
2745 }
2746 for finding in &r.noted {
2747 println!(
2748 " noted, not blocking: {}",
2749 report_item(&finding.title, &finding.file)
2750 );
2751 }
2752 if let Some(cost) = spend::for_subject(spent, r.issue) {
2753 println!(" {cost}");
2754 }
2755}
2756
2757pub(crate) fn report_writes(repo: &Repo) -> i32 {
2762 let writes = repo.write_summary();
2763 if let Some(line) = write_summary_line(writes) {
2764 println!("\n{line}");
2765 }
2766 write_exit_code(writes)
2767}
2768
2769fn write_summary_line(writes: WriteSummary) -> Option<String> {
2770 (writes.attempted > 0).then(|| {
2771 format!(
2772 "writes: {} attempted, {} succeeded, {} failed",
2773 writes.attempted,
2774 writes.succeeded(),
2775 writes.failed
2776 )
2777 })
2778}
2779
2780fn write_exit_code(writes: WriteSummary) -> i32 {
2781 i32::from(writes.failed > 0)
2782}
2783
2784fn report_item(title: &str, file: &str) -> String {
2785 match file.trim() {
2786 "" => title.to_string(),
2787 location => format!("{title} ({location})"),
2788 }
2789}
2790
2791#[cfg(test)]
2792mod tests {
2793 use super::*;
2794 use clap::CommandFactory;
2795
2796 fn planned(issue: i64, depends_on: Vec<i64>) -> PlanItem {
2797 PlanItem {
2798 issue,
2799 title: format!("issue {issue}"),
2800 complexity: crate::model::Complexity::S,
2801 risk: crate::model::Risk::Low,
2802 depends_on,
2803 reason: "worth doing".into(),
2804 }
2805 }
2806
2807 #[test]
2816 fn a_dependent_waits_for_a_dependency_that_has_not_landed() {
2817 let plan = Plan {
2818 order: vec![planned(9, vec![]), planned(10, vec![9])],
2819 ..Plan::default()
2820 };
2821 let mut landed = BTreeSet::new();
2822
2823 let why = held_back(&plan.order[1], &plan, &landed).expect("held");
2824 assert!(why.contains("#9"), "{why}");
2825 assert!(why.contains("has not merged"), "{why}");
2826 assert!(held_back(&plan.order[0], &plan, &landed).is_none());
2827
2828 landed.insert(9);
2830 assert!(held_back(&plan.order[1], &plan, &landed).is_none());
2831 }
2832
2833 #[test]
2836 fn a_dependent_on_a_declined_or_contested_issue_is_held_with_the_reason() {
2837 let declined = Plan {
2838 order: vec![planned(10, vec![9])],
2839 skipped: vec![crate::model::SkippedItem {
2840 issue: 9,
2841 title: "issue 9".into(),
2842 reasons: Default::default(),
2843 tracker: false,
2844 }],
2845 ..Plan::default()
2846 };
2847 let why = held_back(&declined.order[0], &declined, &BTreeSet::new()).expect("held");
2848 assert!(why.contains("declined by both reviewers"), "{why}");
2849
2850 let contested = Plan {
2851 order: vec![planned(10, vec![9])],
2852 contested: vec![crate::model::ContestedItem {
2853 issue: 9,
2854 title: "issue 9".into(),
2855 positions: Default::default(),
2856 reasons: Default::default(),
2857 note: None,
2858 }],
2859 ..Plan::default()
2860 };
2861 let why = held_back(&contested.order[0], &contested, &BTreeSet::new()).expect("held");
2862 assert!(why.contains("disagree about #9"), "{why}");
2863 }
2864
2865 #[test]
2869 fn a_dependency_outside_the_run_does_not_hold_anything() {
2870 let plan = Plan {
2871 order: vec![planned(10, vec![900])],
2872 ..Plan::default()
2873 };
2874 assert!(held_back(&plan.order[0], &plan, &BTreeSet::new()).is_none());
2875 }
2876
2877 #[test]
2883 fn doctor_says_when_a_scheduled_effort_is_not_one_the_agent_accepts() {
2884 let text = "\
2885[agents.claude]
2886preset = \"claude\"
2887
2888[agents.codex]
2889preset = \"codex\"
2890
2891[loop.effort_schedule]
2892review_1 = \"ultra\"
2893";
2894 let cfg = config::parse(text).expect("parses");
2895 let said = unknown_effort_values(&cfg);
2896 assert_eq!(1, said.len(), "{said:?}");
2897 assert!(said[0].starts_with("claude"), "{}", said[0]);
2898 assert!(said[0].contains("ultra"), "{}", said[0]);
2899 assert!(said[0].contains("review_1"), "{}", said[0]);
2900 assert!(
2901 said[0].contains("[agents.claude.effort_schedule]"),
2902 "it has to say where to put the right value: {}",
2903 said[0]
2904 );
2905
2906 let quiet = text.replace(
2908 "[loop.effort_schedule]\nreview_1 = \"ultra\"\n",
2909 "[agents.claude.effort_schedule]\nreview_1 = \"xhigh\"\n\n\
2910 [agents.codex.effort_schedule]\nreview_1 = \"ultra\"\n",
2911 );
2912 assert!(unknown_effort_values(&config::parse(&quiet).expect("parses")).is_empty());
2913 }
2914
2915 #[test]
2916 fn a_contested_issue_reads_as_both_positions_with_their_reasons() {
2917 let mut positions_map = std::collections::BTreeMap::new();
2918 positions_map.insert("claude".to_string(), "do".to_string());
2919 positions_map.insert("codex".to_string(), "skip".to_string());
2920 let mut reasons = std::collections::BTreeMap::new();
2921 reasons.insert(
2922 "claude".to_string(),
2923 "the retry path is still wrong".to_string(),
2924 );
2925 reasons.insert(
2926 "codex".to_string(),
2927 "fixed in the rewrite last month".to_string(),
2928 );
2929
2930 let line = positions(&positions_map, &reasons);
2931 assert!(line.contains("claude says do"), "{line}");
2932 assert!(line.contains("codex says skip"), "{line}");
2933 assert!(line.contains("the retry path is still wrong"), "{line}");
2934 assert!(line.contains("fixed in the rewrite"), "{line}");
2935
2936 let bare = positions(&positions_map, &Default::default());
2938 assert!(bare.contains("claude says do"), "{bare}");
2939 }
2940
2941 #[test]
2942 fn report_items_include_their_location() {
2943 assert_eq!(
2944 "Same title (src/a.rs:10)",
2945 report_item("Same title", "src/a.rs:10")
2946 );
2947 assert_eq!("General point", report_item("General point", ""));
2948 }
2949
2950 #[test]
2951 fn every_attempted_write_failing_is_a_failed_run() {
2952 let writes = WriteSummary {
2953 attempted: 3,
2954 failed: 3,
2955 };
2956
2957 assert_eq!(1, write_exit_code(writes));
2958 assert_eq!(
2959 Some("writes: 3 attempted, 0 succeeded, 3 failed".to_string()),
2960 write_summary_line(writes)
2961 );
2962 }
2963
2964 #[test]
2965 fn a_partial_write_failure_is_reported_after_the_run() {
2966 let writes = WriteSummary {
2967 attempted: 3,
2968 failed: 1,
2969 };
2970
2971 assert_eq!(1, write_exit_code(writes));
2972 assert_eq!(2, writes.succeeded());
2973 }
2974
2975 #[test]
2976 fn the_parser_is_internally_consistent() {
2977 Cli::command().debug_assert();
2978 }
2979
2980 #[test]
2981 fn quiet_is_accepted_before_or_after_the_subcommand() {
2982 for argv in [
2983 vec!["spar", "--quiet", "run", "42"],
2984 vec!["spar", "run", "42", "--quiet"],
2985 vec!["spar", "resume", "--quiet"],
2986 vec!["spar", "init", "-q"],
2987 ] {
2988 assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
2989 }
2990 assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
2991 }
2992
2993 #[test]
2994 fn several_issue_numbers_are_accepted() {
2995 let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
2996 match cli.command {
2997 Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
2998 other => panic!("{other:?}"),
2999 }
3000 }
3001
3002 #[test]
3003 fn issue_numbers_and_flags_can_be_interleaved() {
3004 let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
3005 match cli.command {
3006 Command::Run {
3007 issues, loop_flags, ..
3008 } => {
3009 assert_eq!(vec![42, 51], issues);
3010 assert!(loop_flags.auto_merge);
3011 }
3012 other => panic!("{other:?}"),
3013 }
3014 }
3015
3016 #[test]
3017 fn every_command_that_reads_a_config_accepts_one() {
3018 for argv in [
3019 vec!["spar", "run", "42"],
3020 vec!["spar", "triage"],
3021 vec!["spar", "resume"],
3022 vec!["spar", "followup"],
3023 vec!["spar", "checkin"],
3024 vec!["spar", "split"],
3025 vec!["spar", "brainstorm"],
3026 vec!["spar", "listen"],
3027 vec!["spar", "clean"],
3028 vec!["spar", "doctor"],
3029 ] {
3030 let mut full = argv.clone();
3031 full.extend(["--config", "other.toml"]);
3032 let cli = Cli::parse_from(&full);
3033 let config = match cli.command {
3034 Command::Run { common, .. }
3035 | Command::Triage { common, .. }
3036 | Command::Resume { common, .. }
3037 | Command::Followup { common, .. }
3038 | Command::Split { common, .. }
3039 | Command::Brainstorm { common, .. }
3040 | Command::Listen { common, .. }
3041 | Command::Checkin { common, .. } => common.config,
3042 Command::Clean { config, .. } | Command::Doctor { config } => config,
3043 other => panic!("{other:?}"),
3044 };
3045 assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
3046 }
3047 }
3048
3049 #[test]
3050 fn auto_merge_is_off_unless_asked_for() {
3051 let cli = Cli::parse_from(["spar", "run"]);
3052 match cli.command {
3053 Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
3054 other => panic!("{other:?}"),
3055 }
3056 }
3057
3058 #[test]
3061 fn every_command_that_reads_a_config_takes_instructions() {
3062 for argv in [
3065 vec!["spar", "run", "7", "--instructions", "Do not wait for CI."],
3066 vec![
3067 "spar",
3068 "triage",
3069 "7",
3070 "--instructions",
3071 "Do not wait for CI.",
3072 ],
3073 vec![
3074 "spar",
3075 "resume",
3076 "7",
3077 "--instructions",
3078 "Do not wait for CI.",
3079 ],
3080 vec![
3081 "spar",
3082 "review",
3083 "7",
3084 "--instructions",
3085 "Do not wait for CI.",
3086 ],
3087 vec!["spar", "followup", "--instructions", "Do not wait for CI."],
3088 vec![
3089 "spar",
3090 "checkin",
3091 "7",
3092 "--instructions",
3093 "Do not wait for CI.",
3094 ],
3095 vec![
3096 "spar",
3097 "split",
3098 "7",
3099 "--instructions",
3100 "Do not wait for CI.",
3101 ],
3102 vec![
3103 "spar",
3104 "brainstorm",
3105 "a",
3106 "subject",
3107 "--instructions",
3108 "Do not wait for CI.",
3109 ],
3110 vec![
3111 "spar",
3112 "listen",
3113 "7",
3114 "--instructions",
3115 "Do not wait for CI.",
3116 ],
3117 ] {
3118 let parsed = Cli::parse_from(&argv);
3119 let common = match parsed.command {
3120 Command::Run { common, .. }
3121 | Command::Triage { common, .. }
3122 | Command::Resume { common, .. }
3123 | Command::Review { common, .. }
3124 | Command::Followup { common, .. }
3125 | Command::Split { common, .. }
3126 | Command::Brainstorm { common, .. }
3127 | Command::Listen { common, .. }
3128 | Command::Checkin { common, .. } => common,
3129 other => panic!("{other:?}"),
3130 };
3131 assert_eq!(
3132 Some("Do not wait for CI."),
3133 common.instructions.as_deref(),
3134 "{argv:?}"
3135 );
3136 }
3137 }
3138
3139 #[test]
3140 fn the_two_close_skipped_flags_are_mutually_exclusive() {
3141 assert!(
3142 Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
3143 );
3144 }
3145
3146 #[test]
3149 fn close_skipped_is_offered_only_where_it_means_something() {
3150 assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
3151 assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
3152 assert!(Cli::try_parse_from(["spar", "followup", "--close-skipped"]).is_ok());
3154 assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
3155 assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
3156 assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
3157 }
3158
3159 #[test]
3162 fn brainstorm_takes_words_or_nothing() {
3163 match Cli::parse_from(["spar", "brainstorm", "novel", "uses", "of", "script"]).command {
3164 Command::Brainstorm { topic, ideas, .. } => {
3165 assert_eq!(vec!["novel", "uses", "of", "script"], topic);
3166 assert_eq!(5, ideas);
3167 }
3168 other => panic!("{other:?}"),
3169 }
3170 match Cli::parse_from(["spar", "brainstorm", "--ideas", "3", "--max-rounds", "2"]).command {
3171 Command::Brainstorm {
3172 topic,
3173 ideas,
3174 max_rounds,
3175 ..
3176 } => {
3177 assert!(topic.is_empty());
3178 assert_eq!(3, ideas);
3179 assert_eq!(Some(2), max_rounds);
3180 }
3181 other => panic!("{other:?}"),
3182 }
3183 }
3184
3185 #[test]
3189 fn filing_from_a_saved_session_needs_file_issues_and_writes_no_session() {
3190 assert!(Cli::try_parse_from(["spar", "brainstorm", "--from", "s.md"]).is_err());
3191 assert!(
3192 Cli::try_parse_from(["spar", "brainstorm", "--from", "s.md", "--file-issues"]).is_ok()
3193 );
3194 assert!(Cli::try_parse_from([
3195 "spar",
3196 "brainstorm",
3197 "--from",
3198 "s.md",
3199 "--file-issues",
3200 "--out",
3201 "t.md"
3202 ])
3203 .is_err());
3204 assert!(
3205 Cli::try_parse_from(["spar", "brainstorm", "x", "--out", "t.md", "--file-issues"])
3206 .is_ok()
3207 );
3208 }
3209
3210 #[test]
3213 fn followup_takes_no_numbers() {
3214 assert!(Cli::try_parse_from(["spar", "followup"]).is_ok());
3215 assert!(Cli::try_parse_from(["spar", "followup", "42"]).is_err());
3216 }
3217
3218 #[test]
3221 fn the_two_stopping_points_are_mutually_exclusive() {
3222 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only"]).is_ok());
3223 assert!(Cli::try_parse_from(["spar", "followup", "--file-only"]).is_ok());
3224 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only", "--file-only"]).is_err());
3225 }
3226
3227 #[test]
3228 fn the_close_skipped_pair_resolves_to_a_tristate() {
3229 let read = |argv: &[&str]| match Cli::parse_from(argv).command {
3230 Command::Run { triage_flags, .. } => {
3231 match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
3232 (true, _) => Some(true),
3233 (_, true) => Some(false),
3234 _ => None,
3235 }
3236 }
3237 other => panic!("{other:?}"),
3238 };
3239 assert_eq!(None, read(&["spar", "run"]));
3240 assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
3241 assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
3242 }
3243
3244 #[test]
3245 fn the_default_limit_is_twenty() {
3246 let cli = Cli::parse_from(["spar", "run"]);
3247 match cli.command {
3248 Command::Run { common, .. } => assert_eq!(20, common.limit),
3249 other => panic!("{other:?}"),
3250 }
3251 }
3252
3253 #[test]
3254 fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
3255 assert!(matches!(
3256 Cli::parse_from(["spar", "scrub-filter"]).command,
3257 Command::ScrubFilter
3258 ));
3259 let help = Cli::command().render_long_help().to_string();
3260 assert!(
3261 !help.contains("scrub-filter"),
3262 "it is plumbing, not a command"
3263 );
3264 }
3265
3266 #[test]
3267 fn review_takes_pr_numbers_and_a_dry_run() {
3268 let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
3269 match cli.command {
3270 Command::Review { items, dry_run, .. } => {
3271 assert_eq!(vec![101, 102], items);
3272 assert!(dry_run);
3273 }
3274 other => panic!("{other:?}"),
3275 }
3276 }
3277
3278 #[test]
3279 fn review_posts_unless_told_not_to() {
3280 match Cli::parse_from(["spar", "review", "101"]).command {
3281 Command::Review { dry_run, .. } => assert!(!dry_run),
3282 other => panic!("{other:?}"),
3283 }
3284 }
3285
3286 #[test]
3287 fn review_with_no_numbers_is_allowed() {
3288 match Cli::parse_from(["spar", "review"]).command {
3289 Command::Review { items, .. } => assert!(items.is_empty()),
3290 other => panic!("{other:?}"),
3291 }
3292 }
3293
3294 #[test]
3295 fn review_takes_its_own_round_budget() {
3296 match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
3297 Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
3298 other => panic!("{other:?}"),
3299 }
3300 }
3301
3302 #[test]
3303 fn checkin_takes_pr_numbers_and_a_dry_run() {
3304 match Cli::parse_from(["spar", "checkin", "108", "112", "--dry-run"]).command {
3305 Command::Checkin { items, dry_run, .. } => {
3306 assert_eq!(vec![108, 112], items);
3307 assert!(dry_run);
3308 }
3309 other => panic!("{other:?}"),
3310 }
3311 match Cli::parse_from(["spar", "checkin"]).command {
3312 Command::Checkin { items, dry_run, .. } => {
3313 assert!(items.is_empty());
3314 assert!(!dry_run);
3315 }
3316 other => panic!("{other:?}"),
3317 }
3318 }
3319
3320 #[test]
3326 fn checkin_offers_no_flag_that_would_weaken_the_pair() {
3327 assert!(Cli::try_parse_from(["spar", "checkin", "--auto-merge"]).is_err());
3328 assert!(Cli::try_parse_from(["spar", "checkin", "--max-rounds", "1"]).is_err());
3329 assert!(Cli::try_parse_from(["spar", "checkin", "--absorb", "1"]).is_err());
3330 assert!(Cli::try_parse_from(["spar", "checkin", "--close-skipped"]).is_err());
3331 assert!(Cli::try_parse_from(["spar", "checkin", "--reply-only"]).is_ok());
3333 assert!(Cli::try_parse_from(["spar", "checkin", "--any-author"]).is_ok());
3334 assert!(Cli::try_parse_from(["spar", "checkin", "--again"]).is_ok());
3335 assert!(Cli::try_parse_from(["spar", "checkin", "--keep-worktrees"]).is_ok());
3336 }
3337
3338 #[test]
3339 fn review_takes_verdict_and_recheck_flags_and_neither_by_default() {
3340 match Cli::parse_from(["spar", "review", "108", "--verdict", "--recheck"]).command {
3341 Command::Review {
3342 items,
3343 verdict,
3344 recheck,
3345 ..
3346 } => {
3347 assert_eq!(vec![108], items);
3348 assert!(verdict);
3349 assert!(recheck);
3350 }
3351 other => panic!("{other:?}"),
3352 }
3353 match Cli::parse_from(["spar", "review", "108"]).command {
3354 Command::Review {
3355 verdict, recheck, ..
3356 } => {
3357 assert!(!verdict);
3358 assert!(!recheck);
3359 }
3360 other => panic!("{other:?}"),
3361 }
3362 assert!(Cli::try_parse_from(["spar", "checkin", "108", "--verdict"]).is_err());
3363 }
3364
3365 #[test]
3366 fn listen_takes_numbers_once_dry_run_and_since() {
3367 match Cli::parse_from([
3368 "spar",
3369 "listen",
3370 "108",
3371 "42",
3372 "--once",
3373 "--dry-run",
3374 "--since",
3375 "2h",
3376 ])
3377 .command
3378 {
3379 Command::Listen {
3380 items,
3381 once,
3382 dry_run,
3383 since,
3384 ..
3385 } => {
3386 assert_eq!(vec![108, 42], items);
3387 assert!(once);
3388 assert!(dry_run);
3389 assert_eq!(Some("2h".to_string()), since);
3390 }
3391 other => panic!("{other:?}"),
3392 }
3393 match Cli::parse_from(["spar", "listen"]).command {
3394 Command::Listen {
3395 items,
3396 once,
3397 dry_run,
3398 since,
3399 common,
3400 } => {
3401 assert!(items.is_empty());
3402 assert!(!once);
3403 assert!(!dry_run);
3404 assert_eq!(None, since);
3405 assert_eq!(20, common.limit);
3406 }
3407 other => panic!("{other:?}"),
3408 }
3409 }
3410
3411 #[test]
3412 fn resume_takes_a_next_override() {
3413 let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
3414 match cli.command {
3415 Command::Resume {
3416 prs, next_actor, ..
3417 } => {
3418 assert_eq!(vec![108], prs);
3419 assert_eq!(Some("codex".to_string()), next_actor);
3420 }
3421 other => panic!("{other:?}"),
3422 }
3423 }
3424}
3425
3426#[cfg(test)]
3427mod absorb_tests {
3428 use super::*;
3429
3430 #[test]
3431 fn absorb_is_off_unless_asked_for() {
3432 match Cli::parse_from(["spar", "run"]).command {
3433 Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
3434 other => panic!("{other:?}"),
3435 }
3436 }
3437
3438 #[test]
3439 fn absorb_takes_a_wave_count() {
3440 match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
3441 Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
3442 other => panic!("{other:?}"),
3443 }
3444 }
3445
3446 #[test]
3447 fn absorb_is_only_offered_where_issues_are_worked() {
3448 assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
3449 assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
3450 assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
3451 assert!(Cli::try_parse_from(["spar", "split", "--absorb", "1"]).is_err());
3454 }
3455}
3456
3457#[cfg(test)]
3458mod split_tests {
3459 use super::*;
3460
3461 #[test]
3462 fn split_takes_numbers_of_either_kind() {
3463 match Cli::parse_from(["spar", "split", "10", "12"]).command {
3464 Command::Split { items, .. } => assert_eq!(vec![10, 12], items),
3465 other => panic!("{other:?}"),
3466 }
3467 }
3468
3469 #[test]
3472 fn split_with_no_numbers_is_allowed() {
3473 match Cli::parse_from(["spar", "split"]).command {
3474 Command::Split {
3475 items,
3476 dry_run,
3477 again,
3478 ..
3479 } => {
3480 assert!(items.is_empty());
3481 assert!(!dry_run);
3482 assert!(!again);
3483 }
3484 other => panic!("{other:?}"),
3485 }
3486 }
3487
3488 #[test]
3492 fn split_can_be_told_to_write_nothing() {
3493 match Cli::parse_from(["spar", "split", "10", "--dry-run"]).command {
3494 Command::Split { dry_run, .. } => assert!(dry_run),
3495 other => panic!("{other:?}"),
3496 }
3497 }
3498
3499 #[test]
3502 fn split_can_be_told_to_split_something_it_already_split() {
3503 match Cli::parse_from(["spar", "split", "10", "--again"]).command {
3504 Command::Split { again, .. } => assert!(again),
3505 other => panic!("{other:?}"),
3506 }
3507 }
3508
3509 #[test]
3512 fn split_takes_the_flags_that_govern_picking() {
3513 match Cli::parse_from([
3514 "spar",
3515 "split",
3516 "--limit",
3517 "5",
3518 "--min-number",
3519 "480",
3520 "--config",
3521 "other.toml",
3522 ])
3523 .command
3524 {
3525 Command::Split { common, .. } => {
3526 assert_eq!(5, common.limit);
3527 assert_eq!(Some(480), common.min_number);
3528 assert_eq!(Some(PathBuf::from("other.toml")), common.config);
3529 }
3530 other => panic!("{other:?}"),
3531 }
3532 }
3533
3534 #[test]
3537 fn split_offers_no_flag_that_would_make_it_a_second_run() {
3538 for flag in [
3539 vec!["--auto-merge"],
3540 vec!["--max-rounds", "2"],
3541 vec!["--close-skipped"],
3542 vec!["--no-close-skipped"],
3543 vec!["--keep-worktrees"],
3544 ] {
3545 let mut argv = vec!["spar", "split", "10"];
3546 argv.extend(flag.iter().copied());
3547 assert!(Cli::try_parse_from(&argv).is_err(), "{argv:?}");
3548 }
3549 }
3550}
3551
3552#[cfg(test)]
3553mod min_number_tests {
3554 use super::*;
3555
3556 fn read(argv: &[&str]) -> Option<i64> {
3557 match Cli::parse_from(argv).command {
3558 Command::Run { common, .. }
3559 | Command::Triage { common, .. }
3560 | Command::Resume { common, .. }
3561 | Command::Review { common, .. }
3562 | Command::Followup { common, .. }
3563 | Command::Split { common, .. }
3564 | Command::Listen { common, .. }
3565 | Command::Checkin { common, .. } => common.min_number,
3566 other => panic!("{other:?}"),
3567 }
3568 }
3569
3570 #[test]
3571 fn there_is_no_floor_unless_one_is_asked_for() {
3572 assert_eq!(None, read(&["spar", "run"]));
3573 }
3574
3575 #[test]
3576 fn every_command_that_picks_for_itself_accepts_a_floor() {
3577 for cmd in [
3578 "run", "triage", "resume", "review", "checkin", "split", "listen",
3579 ] {
3580 assert_eq!(
3581 Some(480),
3582 read(&["spar", cmd, "--min-number", "480"]),
3583 "{cmd}"
3584 );
3585 }
3586 }
3587}
3588
3589#[cfg(test)]
3590mod run_targets_tests {
3591 use super::*;
3592
3593 fn covered(pairs: &[(i64, i64)]) -> BTreeMap<i64, i64> {
3594 pairs.iter().copied().collect()
3595 }
3596
3597 #[test]
3602 fn an_issue_its_named_pull_request_closes_is_not_worked_again() {
3603 let kept = issues_not_on_named_prs(&[772, 783, 784], &covered(&[(772, 786)]));
3604
3605 assert_eq!(vec![783, 784], kept);
3606 }
3607
3608 #[test]
3609 fn an_issue_no_named_pull_request_closes_is_still_worked() {
3610 let kept = issues_not_on_named_prs(&[783, 784, 771], &covered(&[(772, 786)]));
3611
3612 assert_eq!(vec![783, 784, 771], kept);
3613 }
3614
3615 #[test]
3618 fn an_unreadable_pull_request_drops_no_issue() {
3619 let kept = issues_not_on_named_prs(&[772, 783], &covered(&[]));
3620
3621 assert_eq!(vec![772, 783], kept);
3622 }
3623
3624 #[test]
3626 fn every_issue_one_pull_request_closes_is_taken_by_it() {
3627 let kept = issues_not_on_named_prs(&[771, 772, 783], &covered(&[(771, 786), (772, 786)]));
3628
3629 assert_eq!(vec![783], kept);
3630 }
3631}
3632
3633#[cfg(test)]
3634mod code_tests {
3635 use super::*;
3636
3637 #[test]
3638 fn code_requires_a_prompt_and_exclusive_preview_modes() {
3639 assert!(Cli::try_parse_from(["spar", "code"]).is_err());
3640 assert!(
3641 Cli::try_parse_from(["spar", "code", "Add export", "--dry-run", "--file-only"])
3642 .is_err()
3643 );
3644 assert!(Cli::try_parse_from(["spar", "code", "Add export", "--dry-run"]).is_ok());
3645 assert!(Cli::try_parse_from(["spar", "code", "Add export", "--file-only"]).is_ok());
3646 }
3647
3648 #[test]
3649 fn code_takes_words_and_the_normal_run_options() {
3650 let cli = Cli::parse_from([
3651 "spar",
3652 "code",
3653 "Add",
3654 "CSV",
3655 "export",
3656 "--repo",
3657 "/tmp/project",
3658 "--config",
3659 "project.toml",
3660 "--first",
3661 "b",
3662 "--base",
3663 "develop",
3664 "--instructions",
3665 "Keep the format stable",
3666 "--max-rounds",
3667 "5",
3668 "--auto-merge",
3669 "--absorb",
3670 "1",
3671 "--keep-worktrees",
3672 "--no-worktrees",
3673 "--no-close-skipped",
3674 "--retriage",
3675 "--plan-out",
3676 "code-plan.json",
3677 ]);
3678 match cli.command {
3679 Command::Code {
3680 prompt,
3681 common,
3682 loop_flags,
3683 triage_flags,
3684 plan_out,
3685 no_worktrees,
3686 dry_run,
3687 file_only,
3688 } => {
3689 assert_eq!(prompt, ["Add", "CSV", "export"]);
3690 assert_eq!(common.repo, PathBuf::from("/tmp/project"));
3691 assert_eq!(common.config, Some(PathBuf::from("project.toml")));
3692 assert_eq!(common.first.as_deref(), Some("b"));
3693 assert_eq!(common.base.as_deref(), Some("develop"));
3694 assert_eq!(
3695 common.instructions.as_deref(),
3696 Some("Keep the format stable")
3697 );
3698 assert_eq!(loop_flags.max_rounds, Some(5));
3699 assert_eq!(loop_flags.absorb, Some(1));
3700 assert!(loop_flags.auto_merge && loop_flags.keep_worktrees);
3701 assert!(triage_flags.no_close_skipped && triage_flags.retriage);
3702 assert_eq!(plan_out, PathBuf::from("code-plan.json"));
3703 assert!(no_worktrees);
3704 assert!(!dry_run && !file_only);
3705 }
3706 other => panic!("{other:?}"),
3707 }
3708 }
3709}
3710
3711#[cfg(test)]
3712mod settings_block_tests {
3713 use super::*;
3714
3715 fn written(line: &str) -> String {
3718 let after = line.split_once('=').expect("an assignment").1;
3719 let mut quoted = false;
3720 for (i, c) in after.char_indices() {
3721 match c {
3722 '"' => quoted = !quoted,
3723 '#' if !quoted => return after[..i].trim().to_string(),
3724 _ => {}
3725 }
3726 }
3727 after.trim().to_string()
3728 }
3729
3730 fn line_for(text: &str, key: &str) -> String {
3731 text.lines()
3732 .find(|l| {
3733 let bare = l.trim_start().trim_start_matches('#').trim_start();
3734 bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
3735 })
3736 .unwrap_or_else(|| panic!("{key} is not offered at all:\n{text}"))
3737 .to_string()
3738 }
3739
3740 #[test]
3747 fn every_value_it_offers_is_the_default_it_actually_has() {
3748 let text = settings_block("claude");
3749 for option in config::known_options() {
3750 if option.section == "loop.effort_schedule" {
3754 continue;
3755 }
3756 let line = line_for(&text, &option.key);
3757 assert_eq!(
3758 option.default,
3759 written(&line),
3760 "the generated config offers `{}`, but the default is {}",
3761 line.trim(),
3762 option.default
3763 );
3764 }
3765 }
3766
3767 #[test]
3771 fn it_offers_every_option_the_parser_knows_about() {
3772 let text = settings_block("claude");
3773 let missing: Vec<String> = config::unmentioned_options(&text)
3774 .into_iter()
3775 .map(|o| format!("[{}] {}", o.section, o.key))
3776 .collect();
3777 assert!(missing.is_empty(), "not offered: {}", missing.join(", "));
3778 }
3779
3780 #[test]
3784 fn every_option_it_offers_can_be_uncommented_and_still_load() {
3785 let mut text = String::from(
3786 "[agents.claude]\ncommand = [\"claude\"]\n\n\
3787 [agents.codex]\ncommand = [\"codex\"]\n\n",
3788 );
3789 for line in settings_block("claude").lines() {
3790 text.push_str(uncomment(line).unwrap_or(line));
3791 text.push('\n');
3792 }
3793 let cfg = config::parse(&text).expect("a config of its own suggestions");
3794 assert_eq!("claude", cfg.first_implementor);
3795 }
3796
3797 fn uncomment(line: &str) -> Option<&str> {
3800 let bare = line.trim_start().strip_prefix('#')?.trim_start();
3801 let key = bare.split_once('=')?.0.trim();
3804 let named = !key.is_empty()
3805 && key
3806 .chars()
3807 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
3808 named.then_some(bare)
3809 }
3810
3811 #[test]
3812 fn the_agent_that_goes_first_is_the_one_that_was_chosen() {
3813 assert!(settings_block("codex").contains("first_implementor = \"codex\""));
3814 }
3815
3816 #[test]
3821 fn a_note_sits_above_the_option_it_describes() {
3822 let text = settings_block("claude");
3823 assert!(
3824 text.lines().all(|l| l.chars().count() <= 80),
3825 "a line runs off the edge:\n{text}"
3826 );
3827 assert!(
3828 text.lines().all(|l| !l.starts_with(' ')),
3829 "a line is indented, so the columns are back:\n{text}"
3830 );
3831
3832 let lines: Vec<&str> = text.lines().collect();
3834 let at = lines
3835 .iter()
3836 .position(|l| l.starts_with("max_rounds"))
3837 .expect("max_rounds");
3838 assert!(lines[at - 1].starts_with('#'), "{:?}", lines[at - 1]);
3839 assert!(
3840 lines[at - 1].contains("lifetime cap"),
3841 "the note above it is the end of its own note: {:?}",
3842 lines[at - 1]
3843 );
3844 }
3845
3846 #[test]
3848 fn every_note_starts_as_a_sentence() {
3849 for (_, key, note) in LOOP_OPTIONS
3850 .iter()
3851 .chain(STYLE_OPTIONS)
3852 .chain(LISTEN_OPTIONS)
3853 {
3854 let first = note.chars().next().expect("a note");
3855 assert!(
3856 first.is_uppercase(),
3857 "{key} reads as a margin scribble rather than a sentence: {note}"
3858 );
3859 }
3860 }
3861}
3862
3863#[cfg(test)]
3864mod agent_block_tests {
3865 use super::*;
3866
3867 fn spec(models: &[&str], efforts: &[&str]) -> config::AgentSpec {
3868 let mut spec: config::AgentSpec =
3869 toml::Value::Table(toml::from_str("command = [\"x\"]").expect("a minimal preset"))
3870 .try_into()
3871 .expect("builds");
3872 spec.models = models.iter().map(|s| s.to_string()).collect();
3873 spec.efforts = efforts.iter().map(|s| s.to_string()).collect();
3874 spec
3875 }
3876
3877 #[test]
3881 fn an_option_with_no_hints_is_left_out_rather_than_guessed_at() {
3882 let block = agent_block("cursor", &spec(&["composer-2.5", "auto"], &[]));
3883 assert!(!block.contains("..."), "{block}");
3884 assert!(!block.contains("effort"), "{block}");
3885 assert!(block.contains("# model = \"composer-2.5\""), "{block}");
3886 }
3887
3888 #[test]
3891 fn only_the_options_that_follow_are_introduced() {
3892 let model_only = agent_block("cursor", &spec(&["auto"], &[]));
3893 assert!(model_only.contains("Omit model to use"), "{model_only}");
3894 assert!(!model_only.contains("Omit effort"), "{model_only}");
3895
3896 let both = agent_block("claude", &spec(&["fable"], &["high"]));
3897 assert!(both.contains("Omit model to use"), "{both}");
3898 assert!(both.contains("Omit effort to use"), "{both}");
3899 }
3900
3901 #[test]
3904 fn a_preset_with_no_hints_still_writes_a_usable_block() {
3905 let block = agent_block("gemini", &spec(&[], &[]));
3906 assert!(!block.contains("..."), "{block}");
3907 assert!(!block.contains("Omit"), "{block}");
3908 assert!(
3909 block.starts_with("[agents.gemini]\npreset = \"gemini\"\n"),
3910 "{block}"
3911 );
3912 assert!(block.contains("[agents.gemini.fallback]"), "{block}");
3914 assert!(block.contains("# timeout = "), "{block}");
3915 }
3916
3917 #[test]
3920 fn the_timeout_offered_is_the_one_the_agent_would_use() {
3921 let mut spec = spec(&["a"], &[]);
3922 spec.timeout = 7200;
3923 assert!(
3924 agent_block("custom", &spec).contains("# timeout = 7200"),
3925 "the generator kept its own copy"
3926 );
3927 }
3928
3929 #[test]
3931 fn alternatives_are_listed_only_when_there_are_any() {
3932 assert!(agent_block("a", &spec(&["one", "two"], &[])).contains("One of: one | two."));
3933 let single = agent_block("b", &spec(&["only"], &[]));
3934 assert!(!single.contains("One of:"), "{single}");
3935 }
3936
3937 #[test]
3941 fn the_presets_note_is_said_once() {
3942 let mut spec = spec(&["m1", "m2"], &["e1", "e2"]);
3943 spec.options_note = Some("Check the current sets with: mytool --help".into());
3944 let block = agent_block("mytool", &spec);
3945 assert_eq!(
3946 1,
3947 block.matches("Check the current sets").count(),
3948 "{block}"
3949 );
3950 }
3951}