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 {
345 items: Vec<i64>,
348 #[command(flatten)]
349 common: Common,
350 #[arg(long)]
352 once: bool,
353 #[arg(long)]
356 dry_run: bool,
357 #[arg(long, value_name = "DURATION")]
360 since: Option<String>,
361 },
362
363 Post {
368 #[arg(required = true)]
370 prs: Vec<i64>,
371 #[arg(long, default_value = ".")]
372 repo: PathBuf,
373 #[arg(long)]
374 config: Option<PathBuf>,
375 #[arg(long, value_name = "PATH")]
377 file: Option<PathBuf>,
378 #[arg(long)]
380 dry_run: bool,
381 },
382
383 Init {
388 #[arg(long, default_value = "spar.toml")]
389 out: PathBuf,
390 #[arg(long)]
392 force: bool,
393 #[arg(long, conflicts_with = "force")]
396 update: bool,
397 },
398
399 Clean {
401 #[arg(long, default_value = ".")]
402 repo: PathBuf,
403 #[arg(long)]
404 config: Option<PathBuf>,
405 #[arg(long)]
408 all: bool,
409 #[arg(long)]
411 pr_state: bool,
412 },
413
414 Doctor {
416 #[arg(long)]
417 config: Option<PathBuf>,
418 },
419
420 #[command(hide = true)]
424 ScrubFilter,
425}
426
427#[derive(Args, Debug, Clone)]
428pub struct Common {
429 #[arg(long, default_value = ".")]
431 pub repo: PathBuf,
432 #[arg(long)]
434 pub config: Option<PathBuf>,
435 #[arg(long)]
437 pub base: Option<String>,
438 #[arg(long)]
440 pub first: Option<String>,
441 #[arg(long, default_value_t = 20)]
444 pub limit: usize,
445 #[arg(long, value_name = "N")]
448 pub min_number: Option<i64>,
449 #[arg(long, value_name = "TEXT")]
452 pub instructions: Option<String>,
453}
454
455#[derive(Args, Debug, Clone)]
456pub struct LoopFlags {
457 #[arg(long)]
460 pub max_rounds: Option<u32>,
461 #[arg(long)]
463 pub auto_merge: bool,
464 #[arg(long)]
466 pub keep_worktrees: bool,
467 #[arg(long, value_name = "N")]
470 pub absorb: Option<u32>,
471}
472
473#[derive(Args, Debug, Clone)]
476pub struct TriageFlags {
477 #[arg(long, conflicts_with = "no_close_skipped")]
479 pub close_skipped: bool,
480 #[arg(long)]
482 pub no_close_skipped: bool,
483 #[arg(long)]
489 pub retriage: bool,
490}
491
492pub fn main() -> i32 {
497 let cli = Cli::parse();
498 logging::init_color();
499 logging::set_quiet(cli.quiet);
500
501 match dispatch(cli) {
502 Ok(code) => code,
503 Err(e) => {
504 logging::error(e.to_string());
505 2
506 }
507 }
508}
509
510fn dispatch(cli: Cli) -> Result<i32> {
511 match cli.command {
512 Command::ScrubFilter => cmd_scrub_filter(),
513 Command::Doctor { config } => cmd_doctor(config.as_deref()),
514 Command::Review {
515 items,
516 common,
517 dry_run,
518 max_rounds,
519 verdict,
520 recheck,
521 } => {
522 let overrides = Overrides {
523 max_rounds,
524 ..Overrides::default()
525 };
526 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
527 let numbers = if items.is_empty() {
528 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
529 if found.is_empty() {
530 log!("no open PRs");
531 return Ok(0);
532 }
533 log!("no PRs given, reviewing {} open", found.len());
534 found
535 } else {
536 items
537 };
538 let sorted = classify(&repo, &numbers)?;
539 let mut targets = sorted.prs;
540 for number in sorted.issues {
541 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
542 Ok(Some(pr)) => {
543 log!("#{number} is an issue; reviewing its open PR {}", pr.url);
544 targets.push(pr.number);
545 }
546 Ok(None) => {
547 logwarn!("#{number} is an issue with no open pull request to review")
548 }
549 Err(e) => logwarn!("#{number}: {e}"),
550 }
551 }
552 let mut results = Vec::new();
553 for number in targets {
554 results.push(if recheck {
555 let mode = review_only::RecheckMode {
556 dry_run,
557 requested: false,
558 verdict_on_fallback: verdict,
559 };
560 review_only::recheck_pr(&agents, &cfg, &repo, number, mode)
561 } else {
562 let opts = review_only::ReviewOpts { dry_run, verdict };
563 review_only::review_pr(&agents, &cfg, &repo, number, opts)
564 });
565 }
566 if results.is_empty() {
567 return Ok(0);
568 }
569 Ok(report(&results, &cfg, &repo))
570 }
571
572 Command::Listen {
573 items,
574 common,
575 once,
576 dry_run,
577 since,
578 } => {
579 let since = since.as_deref().map(listen::parse_duration).transpose()?;
581 let (cfg, repo, agents) = prepare(&common, None)?;
582 listen::run(
583 &agents,
584 &cfg,
585 &repo,
586 listen::Opts {
587 items,
588 once,
589 dry_run,
590 since,
591 },
592 )
593 }
594
595 Command::Brainstorm {
596 topic,
597 common,
598 max_rounds,
599 ideas,
600 out,
601 file_issues,
602 from,
603 } => cmd_brainstorm(
604 topic,
605 &common,
606 max_rounds,
607 ideas,
608 out,
609 file_issues,
610 from.as_deref(),
611 ),
612
613 Command::Checkin {
614 items,
615 common,
616 dry_run,
617 reply_only,
618 any_author,
619 again,
620 keep_worktrees,
621 } => {
622 let overrides = Overrides {
623 keep_worktrees: keep_worktrees.then_some(true),
624 ..Overrides::default()
625 };
626 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
627 let mode = checkin::Mode {
628 dry_run,
629 reply_only,
630 trust: if any_author {
631 crate::config::Trust::Anyone
632 } else {
633 cfg.loop_cfg.checkin_trust
634 },
635 again,
636 resolve: cfg.loop_cfg.checkin_resolve,
637 posts: checkin::posts(&cfg),
638 };
639 let numbers = if items.is_empty() {
640 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
641 if found.is_empty() {
642 log!("no open PRs");
643 return Ok(0);
644 }
645 log!(
646 "no PRs given, checking in on {} open: {}",
647 found.len(),
648 found
649 .iter()
650 .map(|n| format!("#{n}"))
651 .collect::<Vec<_>>()
652 .join(", ")
653 );
654 found
655 } else {
656 items
657 };
658 let sorted = classify(&repo, &numbers)?;
659 let mut results = Vec::new();
660 for number in sorted.prs {
661 results.push(checkin::checkin_pr(&agents, &cfg, &repo, number, &mode));
662 }
663 for number in sorted.issues {
667 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
668 Ok(Some(pr)) => {
669 log!(
670 "#{number} is an issue; checking in on its open PR {}",
671 pr.url
672 );
673 results.push(checkin::checkin_pr(&agents, &cfg, &repo, pr.number, &mode));
674 }
675 Ok(None) => {
676 results.push(checkin::checkin_issue(&agents, &cfg, &repo, number, &mode))
677 }
678 Err(e) => results.push(checkin::failed(number, format!("#{number}"), e)),
683 }
684 }
685 if results.is_empty() {
686 return Ok(0);
687 }
688 Ok(report(&results, &cfg, &repo))
689 }
690
691 Command::Answer {
692 items,
693 common,
694 dry_run,
695 again,
696 } => cmd_answer(&common, items, dry_run, again),
697
698 Command::Split {
699 items,
700 common,
701 dry_run,
702 again,
703 } => {
704 let (cfg, repo, agents) = prepare(&common, None)?;
705 let mode = split::Mode { dry_run, again };
706 let picked = if items.is_empty() {
707 pick_for_split(&agents, &cfg, &repo, common.limit, &mode)?
708 } else {
709 let sorted = classify(&repo, &items)?;
710 let mut out: Vec<(i64, ItemKind)> = sorted
711 .issues
712 .iter()
713 .map(|n| (*n, ItemKind::Issue))
714 .collect();
715 out.extend(sorted.prs.iter().map(|n| (*n, ItemKind::Pr)));
716 out
717 };
718
719 let mut results = Vec::new();
720 let mut seen: BTreeSet<i64> = BTreeSet::new();
721 for (number, kind) in picked {
722 let target = match kind {
726 ItemKind::Pr => Some(number),
727 ItemKind::Issue => {
728 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
729 Ok(found) => found.map(|pr| {
730 log!("#{number} is an issue; splitting its open PR {}", pr.url);
731 pr.number
732 }),
733 Err(e) => {
734 logwarn!("#{number}: {e}");
735 continue;
736 }
737 }
738 }
739 };
740 if !seen.insert(target.unwrap_or(number)) {
745 logdim!("#{number} was already covered by this run, skipping it");
746 continue;
747 }
748 results.push(match target {
749 Some(pr) => split::split_pr(&agents, &cfg, &repo, pr, &mode),
750 None => split::split_issue(&agents, &cfg, &repo, number, &mode),
751 });
752 }
753 if results.is_empty() {
754 return Ok(0);
755 }
756 Ok(report(&results, &cfg, &repo))
757 }
758
759 Command::Post {
760 prs,
761 repo: repo_path,
762 config,
763 file,
764 dry_run,
765 } => cmd_post(
766 &prs,
767 &repo_path,
768 config.as_deref(),
769 file.as_deref(),
770 dry_run,
771 ),
772
773 Command::Init { out, force, update } => {
774 if update {
775 cmd_init_update(&out)
776 } else {
777 cmd_init(&out, force)
778 }
779 }
780 Command::Clean {
781 repo,
782 config,
783 all,
784 pr_state,
785 } => cmd_clean(&repo, config.as_deref(), all, pr_state),
786 Command::Triage {
787 issues,
788 common,
789 plan_out,
790 } => {
791 let (cfg, repo, agents) = prepare(&common, None)?;
792 let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
793 if numbers.is_empty() {
794 return Ok(0);
795 }
796 let sorted = classify(&repo, &numbers)?;
797 for number in &sorted.prs {
798 log!("#{number} is a pull request, nothing to triage");
799 }
800 if sorted.issues.is_empty() {
801 log!("no issues to triage");
802 return Ok(0);
803 }
804 let issues = repo.fetch_issues(&sorted.issues)?;
805 let plan = make_plan(&agents, &cfg, &repo, &issues, &plan_out)?;
809 if cfg.loop_cfg.decompose_trackers {
813 for item in plan.skipped.iter().filter(|i| i.tracker) {
814 tracker::preview(&cfg, &repo, item.issue);
815 }
816 }
817 Ok(0)
818 }
819 Command::Run {
820 issues,
821 common,
822 loop_flags,
823 triage_flags,
824 plan_out,
825 no_worktrees,
826 } => {
827 let overrides = Overrides::for_working(&loop_flags, &triage_flags, no_worktrees);
828 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
829 let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
830 if numbers.is_empty() {
831 return Ok(0);
832 }
833 run_targets(
834 &agents,
835 &cfg,
836 &repo,
837 &numbers,
838 &plan_out,
839 triage_flags.retriage,
840 )
841 }
842 Command::Code {
843 prompt,
844 common,
845 loop_flags,
846 triage_flags,
847 dry_run,
848 file_only,
849 plan_out,
850 no_worktrees,
851 } => {
852 let prompt = prompt.join(" ");
853 code::validate_prompt(&prompt)?;
854 let overrides = Overrides::for_working(&loop_flags, &triage_flags, no_worktrees);
855 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
856 let draft = code::draft(&agents, &cfg, &repo, &prompt)?;
857 if dry_run {
858 println!("# {}\n\n{}", draft.title, draft.body);
859 return Ok(0);
860 }
861 let url = repo.create_issue(&draft.title, &draft.body)?;
862 println!("issue: {url}");
865 let number = review::filed_issue_number(&url)
866 .ok_or_else(|| spar_err!("could not read the issue number from {url}"))?;
867 println!("continue this issue later with: spar run {number}");
868 if file_only {
869 return Ok(report_writes(&repo));
870 }
871 run_targets(
872 &agents,
873 &cfg,
874 &repo,
875 &[number],
876 &plan_out,
877 triage_flags.retriage,
878 )
879 }
880 Command::Followup {
881 common,
882 loop_flags,
883 triage_flags,
884 file,
885 screen_only,
886 file_only,
887 plan_out,
888 no_worktrees,
889 } => {
890 let overrides = Overrides::for_working(&loop_flags, &triage_flags, no_worktrees);
891 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
892 let path = file.unwrap_or_else(|| repo.followups_path());
893 let mode = match (screen_only, file_only) {
894 (true, _) => followups::Mode::ScreenOnly,
895 (_, true) => followups::Mode::FileOnly,
896 _ => followups::Mode::Work,
897 };
898 let outcome = followups::run(&agents, &cfg, &repo, &path, common.limit, mode)?;
899
900 let wave = followups::wave(&outcome);
901 if mode != followups::Mode::Work || wave.is_empty() {
902 return Ok(report_writes(&repo));
903 }
904 let mut results = Vec::new();
905 let mut stopped = Vec::new();
906 let mut parked = Vec::new();
907 let mut progress = Progress::new(0);
908 work_issues(
909 &agents,
910 &cfg,
911 &repo,
912 wave,
913 &plan_out,
914 &mut results,
915 &mut stopped,
916 &mut parked,
917 &mut progress,
918 triage_flags.retriage,
919 )?;
920 if results.is_empty() {
921 log!("nothing scheduled");
924 for line in parked.iter().chain(stopped.iter()) {
925 println!("{line}");
926 }
927 return Ok(report_writes(&repo).max(i32::from(!stopped.is_empty())));
928 }
929 Ok(report_with(&results, &cfg, &repo, &stopped, &parked))
930 }
931
932 Command::Resume {
933 prs,
934 common,
935 loop_flags,
936 next_actor,
937 } => {
938 let (cfg, repo, agents) = prepare(&common, Some(Overrides::from(&loop_flags)))?;
939 if let Some(name) = &next_actor {
940 if !cfg.has_agent(name) {
941 bail!("--next must be one of: {}", cfg.agent_names().join(", "));
942 }
943 }
944 let numbers = if prs.is_empty() {
945 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
946 if found.is_empty() {
947 log!("no open PRs");
948 return Ok(0);
949 }
950 log!(
951 "no PRs given, taking {} open: {}",
952 found.len(),
953 found
954 .iter()
955 .map(|n| format!("#{n}"))
956 .collect::<Vec<_>>()
957 .join(", ")
958 );
959 found
960 } else {
961 prs
962 };
963 let sorted = classify(&repo, &numbers)?;
964 let mut results = Vec::new();
965 for number in sorted.prs {
966 results.push(review::resume_pr(
967 &agents,
968 &cfg,
969 &repo,
970 number,
971 next_actor.as_deref(),
972 ));
973 }
974 for number in sorted.issues {
977 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
978 Ok(Some(pr)) => {
979 log!("#{number} is an issue; continuing its open PR {}", pr.url);
980 results.push(review::resume_pr(
981 &agents,
982 &cfg,
983 &repo,
984 pr.number,
985 next_actor.as_deref(),
986 ));
987 }
988 Ok(None) => logwarn!(
989 "#{number} is an issue with no open pull request. Use `spar run {number}` \
990 to implement it."
991 ),
992 Err(e) => logwarn!("#{number}: {e}"),
993 }
994 }
995 if results.is_empty() {
996 return Ok(0);
997 }
998 Ok(report(&results, &cfg, &repo))
999 }
1000 }
1001}
1002
1003#[derive(Debug, Default, Clone)]
1008struct Overrides {
1009 max_rounds: Option<u32>,
1010 auto_merge: Option<bool>,
1011 keep_worktrees: Option<bool>,
1012 worktrees: Option<bool>,
1013 close_skipped: Option<bool>,
1014 absorb: Option<u32>,
1015}
1016
1017impl From<&LoopFlags> for Overrides {
1018 fn from(flags: &LoopFlags) -> Self {
1019 Self {
1020 max_rounds: flags.max_rounds,
1021 auto_merge: flags.auto_merge.then_some(true),
1022 keep_worktrees: flags.keep_worktrees.then_some(true),
1023 worktrees: None,
1024 close_skipped: None,
1025 absorb: flags.absorb,
1026 }
1027 }
1028}
1029
1030fn run_targets(
1032 agents: &[Agent],
1033 cfg: &Config,
1034 repo: &Repo,
1035 numbers: &[i64],
1036 plan_out: &Path,
1037 retriage: bool,
1038) -> Result<i32> {
1039 let sorted = classify(repo, numbers)?;
1040 let mut results = Vec::new();
1041 let mut stopped = Vec::new();
1042 let mut parked = Vec::new();
1043 let mut progress = Progress::new(sorted.prs.len());
1044
1045 let mut issues = sorted.issues.clone();
1051 if !sorted.prs.is_empty() {
1052 log!(
1053 "continuing {} pull request(s) before starting new work: {}",
1054 sorted.prs.len(),
1055 self::numbers(&sorted.prs)
1056 );
1057 let covered = issues_covered_by(repo, &sorted.prs);
1058 for (issue, pr) in covered.iter().filter(|(i, _)| issues.contains(i)) {
1059 log!(
1060 "#{issue} is what PR #{pr} closes, and both were given, so it is \
1061 continued there rather than implemented again"
1062 );
1063 }
1064 issues = issues_not_on_named_prs(&issues, &covered);
1065 for number in &sorted.prs {
1066 let run = review::resume_pr(agents, cfg, repo, *number, None);
1067 progress.finished(&run);
1068 results.push(run);
1069 }
1070 }
1071
1072 if let Err(e) = work_issues(
1073 agents,
1074 cfg,
1075 repo,
1076 issues,
1077 plan_out,
1078 &mut results,
1079 &mut stopped,
1080 &mut parked,
1081 &mut progress,
1082 retriage,
1083 ) {
1084 if results.is_empty() {
1087 return Err(e);
1088 }
1089 stopped.push(format!("the issue queue stopped: {e}"));
1090 }
1091
1092 if results.is_empty() {
1093 log!("nothing scheduled");
1096 for line in parked.iter().chain(stopped.iter()) {
1097 println!("{line}");
1098 }
1099 return Ok(report_writes(repo).max(i32::from(!stopped.is_empty())));
1100 }
1101 Ok(report_with(&results, cfg, repo, &stopped, &parked))
1102}
1103
1104pub(crate) fn run_issue(agents: &[Agent], cfg: &Config, repo: &Repo, number: i64) -> IssueRun {
1111 let mut results = Vec::new();
1112 let mut stopped = Vec::new();
1113 let mut parked = Vec::new();
1114 let mut progress = Progress::new(0);
1115 let worked = work_issues(
1116 agents,
1117 cfg,
1118 repo,
1119 vec![number],
1120 Path::new("plan.json"),
1121 &mut results,
1122 &mut stopped,
1123 &mut parked,
1124 &mut progress,
1125 false,
1126 );
1127 let mut run = match results.iter().position(|r| r.issue == number) {
1131 Some(i) => results.remove(i),
1132 None if !results.is_empty() => results.remove(0),
1133 None => {
1134 let mut nothing = IssueRun::new(number, format!("#{number}"));
1135 nothing.status = if worked.is_err() || !stopped.is_empty() {
1138 Status::Error
1139 } else {
1140 Status::Pending
1141 };
1142 nothing
1143 }
1144 };
1145 let mut notes: Vec<String> = parked.into_iter().chain(stopped).collect();
1146 if let Err(e) = worked {
1147 notes.push(format!("the issue queue stopped: {e}"));
1148 }
1149 for other in &results {
1150 notes.push(format!(
1151 "also worked #{}: {}{}",
1152 other.issue,
1153 other.status,
1154 other
1155 .pr
1156 .as_deref()
1157 .map(|url| format!(" {url}"))
1158 .unwrap_or_default()
1159 ));
1160 }
1161 run.notes.extend(notes);
1162 run
1163}
1164
1165impl Overrides {
1166 fn for_working(loop_flags: &LoopFlags, triage: &TriageFlags, no_worktrees: bool) -> Self {
1169 let mut over = Overrides::from(loop_flags);
1170 over.worktrees = if no_worktrees { Some(false) } else { None };
1171 over.close_skipped = match (triage.close_skipped, triage.no_close_skipped) {
1172 (true, _) => Some(true),
1173 (_, true) => Some(false),
1174 _ => None,
1175 };
1176 over
1177 }
1178}
1179
1180#[allow(clippy::too_many_arguments)]
1190fn work_issues(
1191 agents: &[Agent],
1192 cfg: &Config,
1193 repo: &Repo,
1194 first_wave: Vec<i64>,
1195 plan_out: &Path,
1196 results: &mut Vec<IssueRun>,
1197 stopped: &mut Vec<String>,
1198 parked: &mut Vec<String>,
1199 progress: &mut Progress,
1200 retriage: bool,
1201) -> Result<()> {
1202 let waiting = repo.read_contested();
1206 if retriage && !waiting.is_empty() {
1207 repo.forget_contested(&waiting.keys().copied().collect::<Vec<_>>());
1208 }
1209 let mut handled: BTreeSet<i64> = BTreeSet::new();
1210 let mut landed: BTreeSet<i64> = BTreeSet::new();
1214 let mut leftover: BTreeSet<i64> = BTreeSet::new();
1215 let mut worked = 0usize;
1219 let mut queue: VecDeque<Wave> = VecDeque::from([Wave::first(first_wave)]);
1220 let mut plans_written = 0usize;
1221
1222 while let Some(mut wave) = queue.pop_front() {
1229 wave.numbers.retain(|n| !handled.contains(n));
1230 if !retriage {
1231 let mut still_waiting: Vec<i64> = Vec::new();
1236 wave.numbers.retain(|n| {
1237 let Some(known) = waiting.get(n) else {
1238 return true;
1239 };
1240 still_waiting.push(*n);
1241 parked.push(format!(
1242 "#{n} contested and parked for you: {}",
1243 positions_of(known)
1244 ));
1245 false
1246 });
1247 if !still_waiting.is_empty() {
1248 log!(
1249 "{} contested on an earlier run and waiting on you. Pass --retriage to ask \
1250 again.",
1251 numbers(&still_waiting)
1252 );
1253 }
1254 }
1255 if wave.numbers.is_empty() {
1256 continue;
1257 }
1258 match wave.parent {
1259 Some(tracker) => log!(
1260 "working {} item(s) from the checklist in #{tracker}: {}",
1261 wave.numbers.len(),
1262 numbers(&wave.numbers)
1263 ),
1264 None if wave.absorbed > 0 => log!(
1265 "absorbing {} newly filed issue(s): {}",
1266 wave.numbers.len(),
1267 numbers(&wave.numbers)
1268 ),
1269 None => {}
1270 }
1271 handled.extend(wave.numbers.iter().copied());
1272
1273 let fetched = match repo.fetch_issues(&wave.numbers) {
1274 Ok(fetched) => fetched,
1275 Err(e) => {
1276 logdim!("could not read the next wave: {e}");
1277 stopped.push(format!(
1278 "could not read {}, so it was not worked: {e}",
1279 numbers(&wave.numbers)
1280 ));
1281 continue;
1282 }
1283 };
1284 let plan_path = if plans_written == 0 {
1287 plan_out.to_path_buf()
1288 } else {
1289 plan_out.with_extension(format!("wave{plans_written}.json"))
1290 };
1291 plans_written += 1;
1292 let plan = match make_plan(agents, cfg, repo, &fetched, &plan_path) {
1297 Ok(plan) => plan,
1298 Err(e) => {
1299 logwarn!("triage failed for {}: {e}", numbers(&wave.numbers));
1300 stopped.push(format!(
1301 "triage failed for {}, so it was not worked: {}",
1302 numbers(&wave.numbers),
1303 first_line(&e.to_string())
1304 ));
1305 for later in &queue {
1306 stopped.push(format!(
1307 "{} was not reached after that failure",
1308 numbers(&later.numbers)
1309 ));
1310 }
1311 queue.clear();
1312 break;
1313 }
1314 };
1315 act_on_plan(cfg, repo, &plan);
1316 repo.remember_contested(&plan.contested);
1317 for item in &plan.contested {
1318 parked.push(format!(
1319 "#{} contested and parked for you: {}",
1320 item.issue,
1321 positions(&item.positions, &item.reasons)
1322 ));
1323 }
1324 for item in &plan.skipped {
1325 let what = if item.tracker {
1326 "left open as a tracker"
1327 } else if cfg.loop_cfg.close_skipped {
1328 "commented on and closed"
1329 } else {
1330 "commented on and left open"
1331 };
1332 parked.push(format!(
1333 "#{} skipped by both reviewers, {what}: {}",
1334 item.issue,
1335 item.reasons
1336 .values()
1337 .next()
1338 .map(|r| first_line(r))
1339 .unwrap_or_default()
1340 ));
1341 }
1342
1343 if cfg.loop_cfg.decompose_trackers && wave.parent.is_none() {
1347 for item in plan.skipped.iter().filter(|i| i.tracker) {
1348 let children = tracker::decompose(cfg, repo, item.issue);
1349 if !children.is_empty() {
1350 queue.push_back(wave.child(item.issue, children));
1351 }
1352 }
1353 }
1354
1355 progress.add(
1356 plan.order
1357 .iter()
1358 .filter(|item| fetched.iter().any(|i| i.number == item.issue))
1359 .count(),
1360 );
1361 let before = results.len();
1362 for item in &plan.order {
1363 let Some(issue) = fetched.iter().find(|i| i.number == item.issue) else {
1364 continue;
1365 };
1366 if let Some(why) = held_back(item, &plan, &landed) {
1367 log!("#{}: {why}", item.issue);
1368 let mut held = IssueRun::new(item.issue, item.title.clone());
1369 held.status = Status::Pending;
1370 held.notes.push(why);
1371 progress.finished(&held);
1372 results.push(held);
1373 continue;
1374 }
1375 let run = review::run_nth_issue(agents, cfg, repo, item, issue, worked);
1376 worked += 1;
1377 if run.status == Status::Merged {
1378 landed.insert(item.issue);
1379 }
1380 progress.finished(&run);
1381 results.push(run);
1382 }
1383
1384 let filed: Vec<i64> = results[before..]
1386 .iter()
1387 .flat_map(|r| r.filed.iter())
1388 .filter_map(|url| review::filed_issue_number(url))
1389 .filter(|n| !handled.contains(n))
1390 .collect::<BTreeSet<_>>()
1391 .into_iter()
1392 .collect();
1393 if filed.is_empty() {
1394 continue;
1395 }
1396 match wave.absorbed < cfg.loop_cfg.absorb_new_issues {
1397 true => queue.push_back(wave.absorb(filed)),
1398 false => leftover.extend(filed),
1399 }
1400 }
1401 if !leftover.is_empty() && cfg.loop_cfg.absorb_new_issues > 0 {
1402 log!(
1403 "{} issue(s) filed in the last wave were left for a later run: {}",
1404 leftover.len(),
1405 numbers(&leftover.iter().copied().collect::<Vec<_>>())
1406 );
1407 }
1408 Ok(())
1409}
1410
1411fn positions(
1413 positions: &std::collections::BTreeMap<String, String>,
1414 reasons: &std::collections::BTreeMap<String, String>,
1415) -> String {
1416 if positions.is_empty() {
1417 return "no positions recorded".to_string();
1418 }
1419 positions
1420 .iter()
1421 .map(|(name, side)| match reasons.get(name) {
1422 Some(why) if !why.trim().is_empty() => {
1423 format!("{name} says {side} ({})", first_line(why))
1424 }
1425 _ => format!("{name} says {side}"),
1426 })
1427 .collect::<Vec<_>>()
1428 .join("; ")
1429}
1430
1431fn positions_of(known: &crate::model::Contested) -> String {
1432 positions(&known.positions, &known.reasons)
1433}
1434
1435fn held_back(item: &PlanItem, plan: &Plan, landed: &BTreeSet<i64>) -> Option<String> {
1447 for dep in &item.depends_on {
1448 if landed.contains(dep) {
1449 continue;
1450 }
1451 if plan.order.iter().any(|other| other.issue == *dep) {
1452 return Some(format!(
1453 "held back: #{dep} is in this run and has not merged, so its work is not on the \
1454 base this would be built from. Merge #{dep} and run #{} again.",
1455 item.issue
1456 ));
1457 }
1458 if plan.skipped.iter().any(|s| s.issue == *dep) {
1459 return Some(format!(
1460 "held back: #{dep}, which it depends on, was declined by both reviewers"
1461 ));
1462 }
1463 if plan.contested.iter().any(|c| c.issue == *dep) {
1464 return Some(format!(
1465 "held back: the reviewers disagree about #{dep}, which it depends on"
1466 ));
1467 }
1468 }
1469 None
1470}
1471
1472struct Wave {
1474 numbers: Vec<i64>,
1475 absorbed: u32,
1480 parent: Option<i64>,
1482}
1483
1484impl Wave {
1485 fn first(numbers: Vec<i64>) -> Self {
1486 Self {
1487 numbers,
1488 absorbed: 0,
1489 parent: None,
1490 }
1491 }
1492
1493 fn child(&self, tracker: i64, numbers: Vec<i64>) -> Self {
1494 Self {
1495 numbers,
1496 absorbed: self.absorbed,
1497 parent: Some(tracker),
1498 }
1499 }
1500
1501 fn absorb(&self, numbers: Vec<i64>) -> Self {
1502 Self {
1503 numbers,
1504 absorbed: self.absorbed + 1,
1505 parent: None,
1506 }
1507 }
1508}
1509
1510fn numbers(items: &[i64]) -> String {
1511 items
1512 .iter()
1513 .map(|n| format!("#{n}"))
1514 .collect::<Vec<_>>()
1515 .join(", ")
1516}
1517
1518fn prepare(common: &Common, overrides: Option<Overrides>) -> Result<(Config, Repo, Vec<Agent>)> {
1519 let mut cfg = config::load(common.config.as_deref())?;
1520
1521 if let Some(first) = &common.first {
1522 if !cfg.has_agent(first) {
1523 bail!("--first must be one of: {}", cfg.agent_names().join(", "));
1524 }
1525 cfg.first_implementor = first.clone();
1526 }
1527 if let Some(base) = &common.base {
1528 cfg.loop_cfg.base_branch = base.clone();
1529 }
1530 if let Some(min) = common.min_number {
1531 cfg.loop_cfg.min_number = min;
1532 }
1533 if let Some(extra) = common.instructions.as_deref().map(str::trim) {
1537 if !extra.is_empty() {
1538 let standing = cfg.loop_cfg.instructions.trim();
1539 cfg.loop_cfg.instructions = if standing.is_empty() {
1540 extra.to_string()
1541 } else {
1542 format!("{standing}\n{extra}")
1543 };
1544 }
1545 }
1546 if let Some(over) = overrides {
1547 if let Some(v) = over.max_rounds {
1548 if v == 0 {
1549 bail!("--max-rounds must be at least 1");
1550 }
1551 cfg.loop_cfg.max_rounds = v;
1552 }
1553 if let Some(v) = over.auto_merge {
1554 cfg.loop_cfg.auto_merge = v;
1555 }
1556 if let Some(v) = over.keep_worktrees {
1557 cfg.loop_cfg.keep_worktrees = v;
1558 }
1559 if let Some(v) = over.worktrees {
1560 cfg.loop_cfg.worktrees = v;
1561 }
1562 if let Some(v) = over.close_skipped {
1563 cfg.loop_cfg.close_skipped = v;
1564 }
1565 if let Some(v) = over.absorb {
1566 cfg.loop_cfg.absorb_new_issues = v;
1567 }
1568 }
1569
1570 let repo = Repo::open(&common.repo, &cfg)?;
1571 if common.base.is_none() {
1572 cfg.loop_cfg.base_branch = repo.default_branch(cfg.base_branch());
1573 }
1574
1575 let agents = agent::build(&cfg)?;
1576 if let Some(warning) = agent::correlation_warning(&agents) {
1577 logging::warn(warning);
1578 }
1579
1580 for stale in repo.prune_worktrees(false) {
1582 let what = if stale.starts_with("branch ") {
1583 stale
1584 } else {
1585 format!("worktree {stale}")
1586 };
1587 logdim!("cleaned up finished {what}");
1588 }
1589
1590 log!("repo {} base {}", repo.root().display(), cfg.base_branch());
1591 log!(
1592 "agents: {}, first {}",
1593 agents
1594 .iter()
1595 .map(|a| format!("{}={}", a.name(), a.spec.describe()))
1596 .collect::<Vec<_>>()
1597 .join(", "),
1598 cfg.first_implementor_policy()
1599 );
1600 Ok((cfg, repo, agents))
1601}
1602
1603fn pick_issues(repo: &Repo, given: Vec<i64>, limit: usize, min_number: i64) -> Result<Vec<i64>> {
1604 if !given.is_empty() {
1605 if min_number > 0 {
1607 let below: Vec<String> = given
1608 .iter()
1609 .filter(|n| **n < min_number)
1610 .map(|n| format!("#{n}"))
1611 .collect();
1612 if !below.is_empty() {
1613 logdim!(
1614 "{} below the #{min_number} floor, taking them because you named them",
1615 below.join(", ")
1616 );
1617 }
1618 }
1619 return Ok(given);
1620 }
1621 let found = repo.list_open_issues(limit, min_number)?;
1622 if found.is_empty() {
1623 log!("no open issues");
1624 return Ok(found);
1625 }
1626 log!(
1627 "no issues given, taking {} open: {}",
1628 found.len(),
1629 found
1630 .iter()
1631 .map(|n| format!("#{n}"))
1632 .collect::<Vec<_>>()
1633 .join(", ")
1634 );
1635 Ok(found)
1636}
1637
1638fn pick_for_split(
1651 agents: &[Agent],
1652 cfg: &Config,
1653 repo: &Repo,
1654 limit: usize,
1655 mode: &split::Mode,
1656) -> Result<Vec<(i64, ItemKind)>> {
1657 let issues = repo.list_open_issues(limit, cfg.loop_cfg.min_number)?;
1658 let prs = repo.list_open_prs(limit, cfg.loop_cfg.min_number)?;
1659 log!(
1660 "no numbers given, considering {} open issue(s) and {} open pull request(s)",
1661 issues.len(),
1662 prs.len()
1663 );
1664 if issues.is_empty() && prs.is_empty() {
1665 return Ok(Vec::new());
1666 }
1667
1668 let issue_rows = repo.open_issue_rows();
1669 let pr_rows = repo.open_pr_rows();
1670 let mut candidates: Vec<split::Candidate> = Vec::new();
1671 let mut already = 0usize;
1672
1673 for number in &issues {
1674 let Some(row) = issue_rows.iter().find(|i| i.number == *number) else {
1675 logdim!("could not read #{number}, leaving it alone");
1676 continue;
1677 };
1678 if split::already_split(row.body_text()) && !mode.again {
1681 already += 1;
1682 continue;
1683 }
1684 candidates.push(split::Candidate::from_issue(row));
1685 }
1686 for number in &prs {
1687 match pr_rows.iter().find(|p| p.number == *number) {
1688 Some(row) => candidates.push(split::Candidate::from_pr(row)),
1689 None => logdim!("could not read PR #{number}, leaving it alone"),
1690 }
1691 }
1692 if already > 0 {
1693 log!("{already} issue(s) already split, skipped. --again reopens them.");
1694 }
1695 if candidates.is_empty() {
1696 return Ok(Vec::new());
1697 }
1698
1699 let agent = agent::find(agents, &cfg.first_implementor)?;
1700 log!(
1701 "screening {} item(s) with {}",
1702 candidates.len(),
1703 agent.name()
1704 );
1705 let verdicts = split::screen(agent, cfg, repo, &candidates)?;
1706
1707 let mut picked = Vec::new();
1708 for candidate in &candidates {
1709 match verdicts.iter().find(|v| v.item == candidate.number) {
1710 Some(v) if v.split => {
1711 log!(" split #{}: {}", candidate.number, v.reason.trim());
1712 picked.push((candidate.number, candidate.kind));
1713 }
1714 Some(v) => logdim!(" leave #{} whole: {}", candidate.number, v.reason.trim()),
1717 None => logdim!(" no verdict for #{}, leaving it whole", candidate.number),
1718 }
1719 }
1720 if picked.is_empty() {
1721 log!("nothing worth splitting");
1722 }
1723 Ok(picked)
1724}
1725
1726#[derive(Debug, Default)]
1732struct Sorted {
1733 issues: Vec<i64>,
1734 prs: Vec<i64>,
1735}
1736
1737fn classify(repo: &Repo, numbers: &[i64]) -> Result<Sorted> {
1738 let mut sorted = Sorted::default();
1739 for number in numbers {
1740 match repo.item_kind(*number)? {
1741 ItemKind::Issue => sorted.issues.push(*number),
1742 ItemKind::Pr => sorted.prs.push(*number),
1743 }
1744 }
1745 if !sorted.issues.is_empty() && !sorted.prs.is_empty() {
1746 log!(
1747 "{} issue(s) and {} pull request(s) given",
1748 sorted.issues.len(),
1749 sorted.prs.len()
1750 );
1751 }
1752 Ok(sorted)
1753}
1754
1755fn issues_covered_by(repo: &Repo, prs: &[i64]) -> BTreeMap<i64, i64> {
1768 let mut covered = BTreeMap::new();
1769 for number in prs {
1770 match repo.pr_view(*number) {
1771 Ok(view) => {
1772 for issue in &view.closing_issues_references {
1773 covered.insert(issue.number, *number);
1774 }
1775 }
1776 Err(e) => logwarn!(
1777 "#{number}: could not read which issue(s) it closes, so none are skipped: {}",
1778 e.last_line()
1779 ),
1780 }
1781 }
1782 covered
1783}
1784
1785fn issues_not_on_named_prs(issues: &[i64], covered: &BTreeMap<i64, i64>) -> Vec<i64> {
1788 issues
1789 .iter()
1790 .copied()
1791 .filter(|issue| !covered.contains_key(issue))
1792 .collect()
1793}
1794
1795fn make_plan(
1796 agents: &[Agent],
1797 cfg: &Config,
1798 repo: &Repo,
1799 issues: &[Issue],
1800 plan_out: &Path,
1801) -> Result<Plan> {
1802 let plan = triage::triage(agents, cfg, repo, issues)?;
1803
1804 std::fs::write(plan_out, serde_json::to_vec_pretty(&plan)?)
1805 .map_err(|e| spar_err!("could not write {}: {e}", plan_out.display()))?;
1806 log!("plan written to {}", plan_out.display());
1807
1808 for item in &plan.order {
1809 let after = if item.depends_on.is_empty() {
1812 String::new()
1813 } else {
1814 format!(
1815 " after {}",
1816 item.depends_on
1817 .iter()
1818 .map(|n| format!("#{n}"))
1819 .collect::<Vec<_>>()
1820 .join(", ")
1821 )
1822 };
1823 log!(
1824 " do #{} [{}/{}]{after} {}",
1825 item.issue,
1826 item.complexity,
1827 item.risk,
1828 item.title
1829 );
1830 }
1831 for item in &plan.skipped {
1832 if item.tracker {
1833 log!(
1834 " hold #{} (both reviewers: tracks work filed elsewhere)",
1835 item.issue
1836 );
1837 } else {
1838 log!(" skip #{} (both reviewers: not worth doing)", item.issue);
1839 }
1840 }
1841 for item in &plan.contested {
1842 log!(" ?? #{} contested, parked for you to decide", item.issue);
1843 }
1844 Ok(plan)
1845}
1846
1847fn act_on_plan(cfg: &Config, repo: &Repo, plan: &Plan) {
1857 for item in &plan.skipped {
1858 let body = review::skip_comment(item, &repo.style);
1859 let close = cfg.loop_cfg.close_skipped && !item.tracker;
1860 let outcome = if close {
1861 repo.close_issue(item.issue, &body)
1862 } else {
1863 repo.comment_issue(item.issue, &body)
1864 };
1865 match outcome {
1866 Ok(()) if close => log!(" closed #{}", item.issue),
1867 Ok(()) if item.tracker => {
1868 log!(
1869 " left #{} open, it tracks work filed elsewhere",
1870 item.issue
1871 )
1872 }
1873 Ok(()) => {}
1874 Err(e) => logdim!("could not update #{}: {e}", item.issue),
1875 }
1876 }
1877}
1878
1879fn cmd_scrub_filter() -> Result<i32> {
1884 let mut input = String::new();
1885 std::io::stdin()
1886 .read_to_string(&mut input)
1887 .map_err(|e| spar_err!("could not read a commit message from stdin: {e}"))?;
1888 let out = style::scrub(&input, &crate::repo::style_from_env());
1889 let mut stdout = std::io::stdout();
1890 stdout
1891 .write_all(out.as_bytes())
1892 .and_then(|_| stdout.write_all(b"\n"))
1893 .map_err(|e| spar_err!("could not write the scrubbed message: {e}"))?;
1894 Ok(0)
1895}
1896
1897fn cmd_clean(
1898 repo_path: &Path,
1899 config_path: Option<&Path>,
1900 all: bool,
1901 pr_state: bool,
1902) -> Result<i32> {
1903 let cfg = config::load(config_path)?;
1904 let repo = Repo::open(repo_path, &cfg)?;
1905 let mut removed = repo.prune_worktrees(all);
1906 removed.extend(repo.prune_state());
1907 if pr_state {
1908 removed.extend(repo.prune_pr_state(None));
1909 }
1910 if removed.is_empty() {
1911 if repo.write_summary().failed > 0 {
1912 println!("nothing removed");
1913 } else {
1914 println!("nothing to clean");
1915 }
1916 } else {
1917 for item in removed {
1918 println!("removed {item}");
1919 }
1920 }
1921 Ok(report_writes(&repo))
1922}
1923
1924fn cmd_post(
1926 prs: &[i64],
1927 repo_path: &Path,
1928 config_path: Option<&Path>,
1929 file: Option<&Path>,
1930 dry_run: bool,
1931) -> Result<i32> {
1932 let cfg = config::load(config_path)?;
1933 let repo = Repo::open(repo_path, &cfg)?;
1934
1935 if file.is_some() && prs.len() > 1 {
1936 bail!("--file posts one review, so give it one pull request number");
1937 }
1938
1939 let mut failed = false;
1940 for number in prs {
1941 let text = match file {
1942 Some(path) => std::fs::read_to_string(path)
1943 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?,
1944 None => match repo.read_pending_comment(*number) {
1945 Some(text) => text,
1946 None => {
1947 logging::error(format!(
1948 "no saved review or answer for #{number}. `spar review {number} \
1949 --dry-run` or `spar answer {number} --dry-run` produces one, or pass \
1950 --file."
1951 ));
1952 failed = true;
1953 continue;
1954 }
1955 },
1956 };
1957 if text.trim().is_empty() {
1958 logging::error(format!("the saved review for PR #{number} is empty"));
1959 failed = true;
1960 continue;
1961 }
1962 if dry_run {
1963 println!("\n{}\n", text.trim());
1964 log!("would post the above to PR #{number}");
1965 continue;
1966 }
1967 if !answer::answered_keys(&text).is_empty() {
1970 match repo.comment_issue(*number, &text) {
1971 Ok(()) => {
1972 log!("posted to #{number}");
1973 answer::note_posted(&repo, *number, &text);
1974 }
1975 Err(e) => {
1976 logging::error(format!("could not post to #{number}: {e}"));
1977 failed = true;
1978 }
1979 }
1980 continue;
1981 }
1982 match repo.comment_pr(*number, &text) {
1985 Ok(url) => {
1986 log!(
1987 "posted to PR #{number}{}",
1988 crate::review_only::link(url.as_deref())
1989 );
1990 if let Some(mut record) = repo.read_review_record(*number) {
1993 if !record.posted {
1994 record.posted = true;
1995 if let Err(e) = repo.write_review_record(*number, &record) {
1996 logdim!("could not mark the review record posted: {e}");
1997 }
1998 }
1999 }
2000 }
2001 Err(e) => {
2002 logging::error(format!("could not post to PR #{number}: {e}"));
2003 failed = true;
2004 }
2005 }
2006 }
2007 Ok(i32::from(failed).max(report_writes(&repo)))
2008}
2009
2010fn cmd_answer(common: &Common, items: Vec<i64>, dry_run: bool, again: bool) -> Result<i32> {
2012 let (cfg, repo, agents) = prepare(common, None)?;
2013 let mode = answer::Mode {
2014 dry_run,
2015 draft: false,
2016 again,
2017 auto: false,
2018 posts: checkin::posts(&cfg),
2019 };
2020 let numbers = if items.is_empty() {
2021 let viewer = repo.viewer_login()?.to_string();
2022 let floor = cfg.loop_cfg.min_number;
2023 let found: Vec<i64> = repo
2024 .open_issues_mentioning(&viewer, usize::MAX)?
2025 .into_iter()
2026 .filter(|n| *n >= floor)
2027 .take(common.limit)
2028 .collect();
2029 if found.is_empty() {
2030 log!("no open issues mention @{viewer}");
2031 return Ok(0);
2032 }
2033 log!(
2034 "no issues given, answering {} that mention @{viewer}: {}",
2035 found.len(),
2036 found
2037 .iter()
2038 .map(|n| format!("#{n}"))
2039 .collect::<Vec<_>>()
2040 .join(", ")
2041 );
2042 found
2043 } else {
2044 items
2045 };
2046 let results: Vec<IssueRun> = numbers
2047 .iter()
2048 .map(|number| answer::answer_issue(&agents, &cfg, &repo, *number, &mode))
2049 .collect();
2050 Ok(report(&results, &cfg, &repo))
2051}
2052
2053#[allow(clippy::too_many_arguments)]
2059fn cmd_brainstorm(
2060 topic: Vec<String>,
2061 common: &Common,
2062 max_rounds: Option<u32>,
2063 ideas: usize,
2064 out: Option<PathBuf>,
2065 file_issues: bool,
2066 from: Option<&Path>,
2067) -> Result<i32> {
2068 if ideas == 0 {
2069 bail!("--ideas must be at least 1");
2070 }
2071 if let Some(path) = from {
2072 let cfg = config::load(common.config.as_deref())?;
2073 let repo = Repo::open(&common.repo, &cfg)?;
2074 let text = std::fs::read_to_string(path)
2075 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
2076 let entries = brainstorm::parse_session(&text);
2077 if entries.is_empty() {
2078 bail!(
2079 "no ideas in {}: an idea is a `## ` heading with its sections under it",
2080 path.display()
2081 );
2082 }
2083 log!(
2084 "filing {} idea{} from {}",
2085 entries.len(),
2086 if entries.len() == 1 { "" } else { "s" },
2087 path.display()
2088 );
2089 brainstorm::file_ideas(&repo, &entries);
2090 return Ok(report_writes(&repo));
2091 }
2092
2093 let overrides = Overrides {
2094 max_rounds,
2095 ..Overrides::default()
2096 };
2097 let (cfg, repo, agents) = prepare(common, Some(overrides))?;
2098 let joined = topic.join(" ").trim().to_string();
2099 let session = brainstorm::Session {
2100 topic: (!joined.is_empty()).then_some(joined),
2101 budget: cfg.loop_cfg.max_rounds,
2102 wanted: ideas,
2103 };
2104 let now = clock::now_secs();
2105 let outcome = brainstorm::run(&agents, &cfg, &repo, &session)?;
2106 let path = out.unwrap_or_else(|| brainstorm::default_path(&repo, &session, now));
2107 let text = brainstorm::render_session(&outcome, &clock::date_line(now), &repo.style);
2108 crate::repo::write_text_atomic(&path, &text)?;
2109 brainstorm::print_summary(&outcome, &path);
2110 if file_issues {
2111 println!();
2112 brainstorm::file_ideas(&repo, &brainstorm::issue_entries(&outcome, &repo.style));
2113 }
2114
2115 let spent = spend::taken();
2116 if let Err(e) = repo.record_spend(&spent) {
2117 logdim!("could not record what this run spent: {e}");
2118 }
2119 if let Some(summary) = spend::summary(&spent) {
2120 println!("\n{summary}");
2121 }
2122 Ok(report_writes(&repo))
2123}
2124
2125fn cmd_init_update(out: &Path) -> Result<i32> {
2131 let text = std::fs::read_to_string(out)
2132 .map_err(|e| spar_err!("could not read {}: {e}", out.display()))?;
2133 config::parse(&text).map_err(|e| spar_err!("{} does not parse: {e}", out.display()))?;
2136
2137 let unset = config::unmentioned_options(&text);
2138 if unset.is_empty() {
2139 println!("{} already mentions every setting.", out.display());
2140 return Ok(0);
2141 }
2142
2143 let mut block = String::new();
2144 if !text.ends_with('\n') {
2145 block.push('\n');
2146 }
2147 block.push_str("\n# Added by `spar init --update`: settings this file did not mention,\n");
2148 block.push_str("# shown at their defaults. Uncomment one to change it.\n");
2149 let mut section = "";
2150 for option in &unset {
2151 if option.section != section {
2152 section = option.section;
2153 block.push_str(&format!("\n# [{section}]\n"));
2154 }
2155 block.push('\n');
2160 block.push_str(&wrap_comment(note_for(&option.key)));
2161 block.push_str(&format!("# {} = {}\n", option.key, option.default));
2162 }
2163
2164 use std::io::Write;
2165 std::fs::OpenOptions::new()
2166 .append(true)
2167 .open(out)
2168 .and_then(|mut f| f.write_all(block.as_bytes()))
2169 .map_err(|e| spar_err!("could not append to {}: {e}", out.display()))?;
2170
2171 println!(
2172 "added {} setting(s) to {} as comments",
2173 unset.len(),
2174 out.display()
2175 );
2176 Ok(0)
2177}
2178
2179fn cmd_init(out: &Path, force: bool) -> Result<i32> {
2180 if out.exists() && !force {
2181 logging::error(format!(
2182 "{} already exists. `--update` appends any settings it does not mention, \
2183 `--force` overwrites it.",
2184 out.display()
2185 ));
2186 return Ok(1);
2187 }
2188
2189 let presets = config::available_presets();
2190 if presets.is_empty() {
2191 bail!("no presets available, which should be impossible in a released build");
2192 }
2193
2194 let mut found: Vec<(String, PathBuf, config::AgentSpec)> = Vec::new();
2195 for name in &presets {
2196 let raw = config::load_preset(name)?;
2197 let mut spec: config::AgentSpec = match raw
2201 .as_table()
2202 .cloned()
2203 .ok_or_else(|| spar_err!("not a table"))
2204 .and_then(|t| {
2205 toml::Value::Table(t)
2206 .try_into()
2207 .map_err(|e| spar_err!("{e}"))
2208 }) {
2209 Ok(spec) => spec,
2210 Err(e) => {
2211 println!(" BROKEN {name:10} {}", e.first_line());
2212 continue;
2213 }
2214 };
2215 spec.name = name.clone();
2216 match Agent::new(spec.clone()).resolve_bin() {
2217 Ok(path) => {
2218 println!(" found {name:10} {}", path.display());
2219 found.push((name.clone(), path.to_path_buf(), spec));
2220 }
2221 Err(_) => println!(" missing {name}"),
2222 }
2223 }
2224
2225 if found.len() < 2 {
2226 logging::error(format!(
2227 "need two agent CLIs, found {}. Install another, or write {} by hand using the \
2228 presets as a reference.",
2229 found.len(),
2230 out.display()
2231 ));
2232 return Ok(1);
2233 }
2234
2235 let chosen: Vec<&(String, PathBuf, config::AgentSpec)> = found.iter().take(2).collect();
2237 if found.len() > 2 {
2238 log!(
2239 "{} agents available, picking {} and {}. Edit {} to change.",
2240 found.len(),
2241 chosen[0].0,
2242 chosen[1].0,
2243 out.display()
2244 );
2245 }
2246
2247 let mut text = String::from(
2248 "# Generated by `spar init`. Each agent inherits a command template from a\n\
2249 # built in preset; anything set here overrides it.\n\
2250 #\n\
2251 # Commented lines are the other options, each with a working value.\n\
2252 # Uncomment one to change it.\n\n",
2253 );
2254 for (name, _, spec) in &chosen {
2255 text.push_str(&agent_block(name, spec));
2256 }
2257 text.push_str(&settings_block(&chosen[0].0));
2258
2259 std::fs::write(out, text).map_err(|e| spar_err!("could not write {}: {e}", out.display()))?;
2260 println!("\nwrote {}", out.display());
2261 println!("Next: `spar doctor` to check it, then `spar run` in a GitHub checkout.");
2262 Ok(0)
2263}
2264
2265fn report_fallback(agent: &Agent) {
2271 let Some(backup) = agent.fallback() else {
2272 return;
2273 };
2274 match backup.resolve_bin() {
2275 Ok(bin) => println!(
2276 " fallback {} ({})",
2277 bin.display(),
2278 backup.spec.describe()
2279 ),
2280 Err(_) => println!(
2281 " fallback {} not found, so it will not stand in. Set {} to its path.",
2282 backup.program(),
2283 backup.env_key()
2284 ),
2285 }
2286}
2287
2288type Setting = (bool, &'static str, &'static str);
2299
2300const LOOP_OPTIONS: &[Setting] = &[
2301 (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."),
2302 (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."),
2303 (false, "first_implementor", "Which agent takes the first pass. The other one reviews it."),
2304 (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."),
2305 (false, "close_skipped", "Close an issue both reviewers declined, after posting the shared reasoning. A tracking issue is left open whatever this says."),
2306 (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."),
2307 (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."),
2308 (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."),
2309 (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."),
2310 (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."),
2311 (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."),
2312 (true, "keep_worktrees", "Keep worktrees after a run, for inspection."),
2313 (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."),
2314 (true, "parallel_triage", "Ask both agents to triage at once. They only read during triage, so there is nothing to serialise."),
2315 (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."),
2316 (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."),
2317 (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."),
2318 (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."),
2319 (true, "file_nits", "File nits as follow-ups too. Off, because a filed nit is somebody else's notification."),
2320 (true, "base_branch", "Only a fallback. Whatever origin/HEAD points at wins when it resolves."),
2321 (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."),
2322 (true, "state_store", "Where resume state is kept. local uses .spar/state and keeps it off the pull request."),
2323 (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."),
2324 (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."),
2325 (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."),
2326 (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."),
2327 (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."),
2328 (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."),
2329 (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."),
2330 (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."),
2331];
2332
2333const STYLE_OPTIONS: &[Setting] = &[
2334 (false, "ban_em_dash", "Strip em-dashes and en-dashes from everything spar posts, then refuse to post text that still has one."),
2335 (false, "ban_ai_attribution", "Strip mentions of the tooling, and Co-Authored-By trailers, from everything spar posts."),
2336 (false, "terse", "Hold model prose to a length budget. false removes the valves entirely."),
2337 (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."),
2338 (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."),
2339 (true, "max_title_chars", "A finding, issue, or pull request title. Never ellipsised: a title ending in three dots reads as broken."),
2340 (true, "max_summary_chars", "A one line verdict, or a refutation's argument."),
2341 (true, "max_detail_chars", "A blocking finding's explanation, as it appears in the pull request thread."),
2342 (true, "max_body_chars", "A pull request body."),
2343 (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."),
2344];
2345
2346const LISTEN_OPTIONS: &[Setting] = &[
2347 (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."),
2348 (true, "sweep", "Seconds between sweeps of the open pull requests and of the issues assigned to you. The sweep sees what a notification can miss: a review asked for again, an assignment, including one you made yourself, a close, or a push, which raises no notification for a reviewer."),
2349 (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."),
2350 (true, "max_wait", "Most seconds an item may keep settling before it is acted on anyway."),
2351 (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."),
2352 (true, "on_review_requested", "What a pull request somebody asked you to review gets: review or ignore."),
2353 (true, "on_assigned", "What a pull request assigned to you gets: review or ignore."),
2354 (true, "on_assigned_issue", "What an issue assigned to you gets, whoever assigned it: run does what `spar run` does, triage and then the implement and review loop, or continues the pull request already open for it; ignore does nothing. An issue with somebody else's pull request open is left alone, and each assignment is acted on once."),
2355 (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."),
2356 (false, "verdicts", "Whether a review `spar listen` starts submits a formal verdict, approve or request changes, rather than a comment. A recheck always does."),
2357 (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."),
2358 (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."),
2359];
2360
2361fn settings_block(first_implementor: &str) -> String {
2366 let defaults: std::collections::BTreeMap<String, String> = config::known_options()
2367 .into_iter()
2368 .map(|option| (option.key, option.default))
2369 .collect();
2370 let value = |key: &str| match key {
2373 "first_implementor" => format!("\"{first_implementor}\""),
2374 other => defaults.get(other).cloned().unwrap_or_default(),
2375 };
2376
2377 let mut out = String::from("[loop]\n");
2378 out.push_str(&option_lines(LOOP_OPTIONS, &value));
2379 out.push_str(concat!(
2380 "\n[loop.effort_schedule]\n",
2381 "# One key per kind of call, so a value means what its name says. Values\n",
2382 "# are whatever each agent's own CLI accepts, listed above, so these are\n",
2383 "# examples rather than defaults. Left out, a call falls back to\n",
2384 "# round_1 or rest, and then to the effort the agent's own block asked\n",
2385 "# for. Where the two CLIs do not share a vocabulary, put the schedule\n",
2386 "# under [agents.NAME.effort_schedule] instead, with the same keys.\n",
2387 "# code = \"high\" # drafting an issue from a project prompt\n",
2388 "# triage = \"low\" # both agents, over the whole queue, per wave\n",
2389 "# implement = \"high\" # writing the first draft\n",
2390 "# review_1 = \"high\" # the deep first review\n",
2391 "# review_rest = \"low\" # later rounds, which see a small delta\n",
2392 "# respond = \"high\" # the author answering a review\n",
2393 "# close = \"low\" # the closing merge safety pass\n",
2394 "# screen = \"low\" # the follow-up screen, one call for the queue\n",
2395 "# checkin = \"high\" # judging comments other people left\n",
2396 "# split = \"high\" # proposing and checking a split\n",
2397 "# brainstorm = \"high\" # every brainstorm round\n",
2398 "# recheck = \"low\" # a second look after commits or replies\n",
2399 "# answer = \"high\" # drafting and checking answers to a mention\n",
2400 "# round_1 = \"high\" # read by any call above with no key set\n",
2401 "# rest = \"low\" # the same, for later rounds and the close\n\n",
2402 ));
2403 out.push_str("[style]\n");
2404 out.push_str(&option_lines(STYLE_OPTIONS, &value));
2405 out.push_str("\n[listen]\n");
2406 out.push_str(&option_lines(LISTEN_OPTIONS, &value));
2407 out
2408}
2409
2410fn option_lines(options: &[Setting], value: &dyn Fn(&str) -> String) -> String {
2414 let mut out = String::new();
2415 for (commented, key, note) in options {
2416 if !out.is_empty() {
2417 out.push('\n');
2418 }
2419 out.push_str(&wrap_comment(note));
2420 let lead = if *commented { "# " } else { "" };
2421 out.push_str(&format!("{lead}{key} = {}\n", value(key)));
2422 }
2423 out
2424}
2425
2426type Probe = Box<dyn Fn() -> Result<String>>;
2429
2430fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
2437 let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
2438
2439 if let Some(extra) = &spec.options_note {
2451 if !spec.models.is_empty() || !spec.efforts.is_empty() {
2452 out.push('\n');
2453 out.push_str(&wrap_comment(extra));
2454 }
2455 }
2456 for (key, choices) in [("model", &spec.models), ("effort", &spec.efforts)] {
2457 let Some(suggested) = choices.first() else {
2458 continue;
2459 };
2460 let mut note = format!("Omit {key} to use the CLI's own default.");
2461 if choices.len() > 1 {
2462 note.push_str(&format!(" One of: {}.", choices.join(" | ")));
2463 }
2464 out.push('\n');
2465 out.push_str(&wrap_comment(¬e));
2466 out.push_str(&format!("# {key} = \"{suggested}\"\n"));
2467 }
2468
2469 out.push('\n');
2473 out.push_str(&wrap_comment(
2474 "Seconds one call may take before spar gives up. A timeout costs the whole call and is \
2475 never retried, so err long.",
2476 ));
2477 out.push_str(&format!("# timeout = {}\n", spec.timeout));
2478
2479 let backup = if name == "cursor" { "gemini" } else { "cursor" };
2482 out.push('\n');
2483 out.push_str(&wrap_comment(
2484 "A stand in for when this CLI refuses, stalls, or runs out of quota. It answers in place \
2485 of this agent, never alongside it.",
2486 ));
2487 out.push_str(&format!(
2488 "# [agents.{name}.fallback]\n# preset = \"{backup}\"\n"
2489 ));
2490
2491 out.push('\n');
2496 out.push_str(&wrap_comment(
2497 "command, output, search_paths and the rest are in spar.example.toml, for pairing a CLI \
2498 that has no preset.",
2499 ));
2500 out.push('\n');
2501 out
2502}
2503
2504fn note_for(key: &str) -> &'static str {
2510 LOOP_OPTIONS
2511 .iter()
2512 .chain(STYLE_OPTIONS)
2513 .chain(LISTEN_OPTIONS)
2514 .find(|(_, name, _)| *name == key)
2515 .map(|(_, _, note)| *note)
2516 .unwrap_or("")
2517}
2518
2519fn wrap_comment(text: &str) -> String {
2521 const WIDTH: usize = 76;
2522 let mut out = String::new();
2523 let mut line = String::from("#");
2524 for word in text.split_whitespace() {
2525 if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
2526 out.push_str(&line);
2527 out.push('\n');
2528 line = String::from("#");
2529 }
2530 line.push(' ');
2531 line.push_str(word);
2532 }
2533 if line.len() > 1 {
2534 out.push_str(&line);
2535 out.push('\n');
2536 }
2537 out
2538}
2539
2540fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
2541 let mut ok = true;
2542
2543 let probes: Vec<(&str, Probe)> = vec![
2544 (
2545 "git",
2546 Box::new(|| {
2547 proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
2548 .map(|s| first_line(&s))
2549 }),
2550 ),
2551 (
2552 "gh",
2553 Box::new(|| {
2554 proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
2555 .map(|s| first_line(&s))
2556 }),
2557 ),
2558 (
2559 "gh auth",
2560 Box::new(|| {
2561 let out = proc::exec(
2562 &["gh".into(), "auth".into(), "status".into()],
2563 &ExecOpts::new().check(false).timeout_secs(60),
2564 )?;
2565 let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
2566 if out.ok() {
2567 Ok(first_line(&text))
2568 } else {
2569 Err(spar_err!("not authenticated. Run `gh auth login`."))
2570 }
2571 }),
2572 ),
2573 ];
2574
2575 for (label, probe) in probes {
2576 match probe() {
2577 Ok(detail) => println!(" ok {label:12} {detail}"),
2578 Err(e) => {
2579 println!(" FAIL {label:12} {}", e.first_line());
2580 ok = false;
2581 }
2582 }
2583 }
2584
2585 let found = config::find_config(config_path)?;
2586 let Some(path) = found else {
2587 println!("\n no spar.toml found. Run `spar init` to generate one.");
2588 println!(
2589 " presets available: {}",
2590 config::available_presets().join(", ")
2591 );
2592 return Ok(if ok { 0 } else { 1 });
2593 };
2594
2595 println!("\n config: {}", path.display());
2596 let cfg = match config::load(Some(&path)) {
2597 Ok(cfg) => cfg,
2598 Err(e) => {
2599 println!(" FAIL config {e}");
2600 return Ok(1);
2601 }
2602 };
2603
2604 let mut resolved = Vec::new();
2607 for spec in &cfg.agents {
2608 let agent = Agent::new(spec.clone());
2609 match agent.resolve_bin() {
2610 Ok(bin) => {
2611 println!(
2612 " ok {:12} {} ({})",
2613 spec.name,
2614 bin.display(),
2615 spec.describe()
2616 );
2617 report_fallback(&agent);
2618 resolved.push(agent);
2619 }
2620 Err(e) => {
2621 println!(" FAIL {:12} {}", spec.name, e.first_line());
2622 ok = false;
2623 }
2624 }
2625 }
2626
2627 if resolved.len() == cfg.agents.len() {
2628 if let Some(warning) = agent::correlation_warning(&resolved) {
2629 println!("\n WARNING {warning}");
2630 }
2631 }
2632
2633 for line in unknown_effort_values(&cfg) {
2634 println!("\n WARNING {line}");
2635 }
2636
2637 println!(
2638 "\n settings: first={} max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
2639 cfg.first_implementor_policy(),
2640 cfg.loop_cfg.max_rounds,
2641 cfg.loop_cfg.auto_merge,
2642 cfg.loop_cfg.worktrees,
2643 cfg.loop_cfg.followups,
2644 cfg.style.terse
2645 );
2646 if let Ok(text) = std::fs::read_to_string(&path) {
2650 let unset = config::unmentioned_options(&text);
2651 if !unset.is_empty() {
2652 println!(
2653 "\n {} setting(s) this config does not mention, all at their defaults:",
2654 unset.len()
2655 );
2656 for option in &unset {
2657 println!(
2658 " [{}] {} = {}",
2659 option.section, option.key, option.default
2660 );
2661 }
2662 println!(
2663 " `spar init --update {}` appends them as comments.",
2664 path.display()
2665 );
2666 }
2667 }
2668
2669 println!(
2670 "{}",
2671 if ok {
2672 "\nready"
2673 } else {
2674 "\nmissing prerequisites"
2675 }
2676 );
2677 Ok(if ok { 0 } else { 1 })
2678}
2679
2680fn unknown_effort_values(cfg: &config::Config) -> Vec<String> {
2689 let mut out = Vec::new();
2690 for spec in &cfg.agents {
2691 if spec.efforts.is_empty() {
2692 continue;
2693 }
2694 let mut said: Vec<String> = Vec::new();
2695 for call in config::Call::every() {
2696 let Some(value) = cfg.effort_for(spec, call) else {
2697 continue;
2698 };
2699 let known = spec
2700 .efforts
2701 .iter()
2702 .any(|hint| hint.eq_ignore_ascii_case(value.trim()));
2703 if known || said.contains(&value) {
2704 continue;
2705 }
2706 said.push(value.clone());
2707 out.push(format!(
2708 "{} is asked for effort \"{value}\" on the {} call, which is not one it is known \
2709 to accept ({}). Put this agent's values under \
2710 [agents.{}.effort_schedule].",
2711 spec.name,
2712 call.key(),
2713 spec.efforts.join(" | "),
2714 spec.name
2715 ));
2716 }
2717 }
2718 out
2719}
2720
2721fn first_line(text: &str) -> String {
2722 text.trim().lines().next().unwrap_or("").trim().to_string()
2723}
2724
2725fn report(results: &[IssueRun], cfg: &Config, repo: &Repo) -> i32 {
2730 report_with(results, cfg, repo, &[], &[])
2731}
2732
2733fn report_with(
2740 results: &[IssueRun],
2741 cfg: &Config,
2742 repo: &Repo,
2743 stopped: &[String],
2744 parked: &[String],
2745) -> i32 {
2746 let spent = spend::taken();
2749 if let Err(e) = repo.record_spend(&spent) {
2750 logdim!("could not record what this run spent: {e}");
2751 }
2752 println!("\n{}", "=".repeat(60));
2753 for r in results {
2754 print_outcome(r, &spent);
2755 }
2756 println!("{}", "=".repeat(60));
2757
2758 if cfg.alternate_first && results.len() > 1 {
2759 println!(
2760 "\nfirst implementor alternated, starting with {}. Who wrote each first draft is in \
2761 .spar/state/implementors.json.",
2762 cfg.first_implementor
2763 );
2764 }
2765 if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
2766 println!("\nApproved PRs are waiting on you to merge.");
2767 }
2768 let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
2769 if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
2770 println!(
2771 "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
2772 Set followups = \"issues\" to file them."
2773 );
2774 }
2775 if let Some(summary) = spend::summary(&spent) {
2776 println!("\n{summary}");
2777 }
2778 for lines in [parked, stopped] {
2779 if lines.is_empty() {
2780 continue;
2781 }
2782 println!();
2783 for line in lines {
2784 println!("{line}");
2785 }
2786 }
2787 let issue_exit = i32::from(!results.iter().all(IssueRun::succeeded) || !stopped.is_empty());
2788 issue_exit.max(report_writes(repo))
2789}
2790
2791pub(crate) fn print_outcome(r: &IssueRun, spent: &[spend::Spent]) {
2793 println!(
2794 "#{:<5} {:<10} rounds={} {}",
2795 r.issue,
2796 r.status.to_string(),
2797 r.rounds,
2798 r.pr.as_deref().unwrap_or("")
2799 );
2800 for note in &r.notes {
2801 println!(" {}", first_line(note));
2802 }
2803 if let Some(url) = &r.posted {
2804 println!(" posted {url}");
2805 }
2806 for url in &r.filed {
2807 println!(" filed {url}");
2808 }
2809 for dispute in &r.disputes {
2810 println!(
2811 " disputed: {}",
2812 report_item(&dispute.title, &dispute.file)
2813 );
2814 }
2815 for finding in &r.noted {
2816 println!(
2817 " noted, not blocking: {}",
2818 report_item(&finding.title, &finding.file)
2819 );
2820 }
2821 if let Some(cost) = spend::for_subject(spent, r.issue) {
2822 println!(" {cost}");
2823 }
2824}
2825
2826pub(crate) fn report_writes(repo: &Repo) -> i32 {
2831 let writes = repo.write_summary();
2832 if let Some(line) = write_summary_line(writes) {
2833 println!("\n{line}");
2834 }
2835 write_exit_code(writes)
2836}
2837
2838fn write_summary_line(writes: WriteSummary) -> Option<String> {
2839 (writes.attempted > 0).then(|| {
2840 format!(
2841 "writes: {} attempted, {} succeeded, {} failed",
2842 writes.attempted,
2843 writes.succeeded(),
2844 writes.failed
2845 )
2846 })
2847}
2848
2849fn write_exit_code(writes: WriteSummary) -> i32 {
2850 i32::from(writes.failed > 0)
2851}
2852
2853fn report_item(title: &str, file: &str) -> String {
2854 match file.trim() {
2855 "" => title.to_string(),
2856 location => format!("{title} ({location})"),
2857 }
2858}
2859
2860#[cfg(test)]
2861mod tests {
2862 use super::*;
2863 use clap::CommandFactory;
2864
2865 fn planned(issue: i64, depends_on: Vec<i64>) -> PlanItem {
2866 PlanItem {
2867 issue,
2868 title: format!("issue {issue}"),
2869 complexity: crate::model::Complexity::S,
2870 risk: crate::model::Risk::Low,
2871 depends_on,
2872 reason: "worth doing".into(),
2873 }
2874 }
2875
2876 #[test]
2885 fn a_dependent_waits_for_a_dependency_that_has_not_landed() {
2886 let plan = Plan {
2887 order: vec![planned(9, vec![]), planned(10, vec![9])],
2888 ..Plan::default()
2889 };
2890 let mut landed = BTreeSet::new();
2891
2892 let why = held_back(&plan.order[1], &plan, &landed).expect("held");
2893 assert!(why.contains("#9"), "{why}");
2894 assert!(why.contains("has not merged"), "{why}");
2895 assert!(held_back(&plan.order[0], &plan, &landed).is_none());
2896
2897 landed.insert(9);
2899 assert!(held_back(&plan.order[1], &plan, &landed).is_none());
2900 }
2901
2902 #[test]
2905 fn a_dependent_on_a_declined_or_contested_issue_is_held_with_the_reason() {
2906 let declined = Plan {
2907 order: vec![planned(10, vec![9])],
2908 skipped: vec![crate::model::SkippedItem {
2909 issue: 9,
2910 title: "issue 9".into(),
2911 reasons: Default::default(),
2912 tracker: false,
2913 }],
2914 ..Plan::default()
2915 };
2916 let why = held_back(&declined.order[0], &declined, &BTreeSet::new()).expect("held");
2917 assert!(why.contains("declined by both reviewers"), "{why}");
2918
2919 let contested = Plan {
2920 order: vec![planned(10, vec![9])],
2921 contested: vec![crate::model::ContestedItem {
2922 issue: 9,
2923 title: "issue 9".into(),
2924 positions: Default::default(),
2925 reasons: Default::default(),
2926 note: None,
2927 }],
2928 ..Plan::default()
2929 };
2930 let why = held_back(&contested.order[0], &contested, &BTreeSet::new()).expect("held");
2931 assert!(why.contains("disagree about #9"), "{why}");
2932 }
2933
2934 #[test]
2938 fn a_dependency_outside_the_run_does_not_hold_anything() {
2939 let plan = Plan {
2940 order: vec![planned(10, vec![900])],
2941 ..Plan::default()
2942 };
2943 assert!(held_back(&plan.order[0], &plan, &BTreeSet::new()).is_none());
2944 }
2945
2946 #[test]
2952 fn doctor_says_when_a_scheduled_effort_is_not_one_the_agent_accepts() {
2953 let text = "\
2954[agents.claude]
2955preset = \"claude\"
2956
2957[agents.codex]
2958preset = \"codex\"
2959
2960[loop.effort_schedule]
2961review_1 = \"ultra\"
2962";
2963 let cfg = config::parse(text).expect("parses");
2964 let said = unknown_effort_values(&cfg);
2965 assert_eq!(1, said.len(), "{said:?}");
2966 assert!(said[0].starts_with("claude"), "{}", said[0]);
2967 assert!(said[0].contains("ultra"), "{}", said[0]);
2968 assert!(said[0].contains("review_1"), "{}", said[0]);
2969 assert!(
2970 said[0].contains("[agents.claude.effort_schedule]"),
2971 "it has to say where to put the right value: {}",
2972 said[0]
2973 );
2974
2975 let quiet = text.replace(
2977 "[loop.effort_schedule]\nreview_1 = \"ultra\"\n",
2978 "[agents.claude.effort_schedule]\nreview_1 = \"xhigh\"\n\n\
2979 [agents.codex.effort_schedule]\nreview_1 = \"ultra\"\n",
2980 );
2981 assert!(unknown_effort_values(&config::parse(&quiet).expect("parses")).is_empty());
2982 }
2983
2984 #[test]
2985 fn a_contested_issue_reads_as_both_positions_with_their_reasons() {
2986 let mut positions_map = std::collections::BTreeMap::new();
2987 positions_map.insert("claude".to_string(), "do".to_string());
2988 positions_map.insert("codex".to_string(), "skip".to_string());
2989 let mut reasons = std::collections::BTreeMap::new();
2990 reasons.insert(
2991 "claude".to_string(),
2992 "the retry path is still wrong".to_string(),
2993 );
2994 reasons.insert(
2995 "codex".to_string(),
2996 "fixed in the rewrite last month".to_string(),
2997 );
2998
2999 let line = positions(&positions_map, &reasons);
3000 assert!(line.contains("claude says do"), "{line}");
3001 assert!(line.contains("codex says skip"), "{line}");
3002 assert!(line.contains("the retry path is still wrong"), "{line}");
3003 assert!(line.contains("fixed in the rewrite"), "{line}");
3004
3005 let bare = positions(&positions_map, &Default::default());
3007 assert!(bare.contains("claude says do"), "{bare}");
3008 }
3009
3010 #[test]
3011 fn report_items_include_their_location() {
3012 assert_eq!(
3013 "Same title (src/a.rs:10)",
3014 report_item("Same title", "src/a.rs:10")
3015 );
3016 assert_eq!("General point", report_item("General point", ""));
3017 }
3018
3019 #[test]
3020 fn every_attempted_write_failing_is_a_failed_run() {
3021 let writes = WriteSummary {
3022 attempted: 3,
3023 failed: 3,
3024 };
3025
3026 assert_eq!(1, write_exit_code(writes));
3027 assert_eq!(
3028 Some("writes: 3 attempted, 0 succeeded, 3 failed".to_string()),
3029 write_summary_line(writes)
3030 );
3031 }
3032
3033 #[test]
3034 fn a_partial_write_failure_is_reported_after_the_run() {
3035 let writes = WriteSummary {
3036 attempted: 3,
3037 failed: 1,
3038 };
3039
3040 assert_eq!(1, write_exit_code(writes));
3041 assert_eq!(2, writes.succeeded());
3042 }
3043
3044 #[test]
3045 fn the_parser_is_internally_consistent() {
3046 Cli::command().debug_assert();
3047 }
3048
3049 #[test]
3050 fn quiet_is_accepted_before_or_after_the_subcommand() {
3051 for argv in [
3052 vec!["spar", "--quiet", "run", "42"],
3053 vec!["spar", "run", "42", "--quiet"],
3054 vec!["spar", "resume", "--quiet"],
3055 vec!["spar", "init", "-q"],
3056 ] {
3057 assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
3058 }
3059 assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
3060 }
3061
3062 #[test]
3063 fn several_issue_numbers_are_accepted() {
3064 let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
3065 match cli.command {
3066 Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
3067 other => panic!("{other:?}"),
3068 }
3069 }
3070
3071 #[test]
3072 fn issue_numbers_and_flags_can_be_interleaved() {
3073 let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
3074 match cli.command {
3075 Command::Run {
3076 issues, loop_flags, ..
3077 } => {
3078 assert_eq!(vec![42, 51], issues);
3079 assert!(loop_flags.auto_merge);
3080 }
3081 other => panic!("{other:?}"),
3082 }
3083 }
3084
3085 #[test]
3086 fn every_command_that_reads_a_config_accepts_one() {
3087 for argv in [
3088 vec!["spar", "run", "42"],
3089 vec!["spar", "triage"],
3090 vec!["spar", "resume"],
3091 vec!["spar", "followup"],
3092 vec!["spar", "checkin"],
3093 vec!["spar", "split"],
3094 vec!["spar", "brainstorm"],
3095 vec!["spar", "listen"],
3096 vec!["spar", "clean"],
3097 vec!["spar", "doctor"],
3098 ] {
3099 let mut full = argv.clone();
3100 full.extend(["--config", "other.toml"]);
3101 let cli = Cli::parse_from(&full);
3102 let config = match cli.command {
3103 Command::Run { common, .. }
3104 | Command::Triage { common, .. }
3105 | Command::Resume { common, .. }
3106 | Command::Followup { common, .. }
3107 | Command::Split { common, .. }
3108 | Command::Brainstorm { common, .. }
3109 | Command::Listen { common, .. }
3110 | Command::Checkin { common, .. } => common.config,
3111 Command::Clean { config, .. } | Command::Doctor { config } => config,
3112 other => panic!("{other:?}"),
3113 };
3114 assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
3115 }
3116 }
3117
3118 #[test]
3119 fn auto_merge_is_off_unless_asked_for() {
3120 let cli = Cli::parse_from(["spar", "run"]);
3121 match cli.command {
3122 Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
3123 other => panic!("{other:?}"),
3124 }
3125 }
3126
3127 #[test]
3130 fn every_command_that_reads_a_config_takes_instructions() {
3131 for argv in [
3134 vec!["spar", "run", "7", "--instructions", "Do not wait for CI."],
3135 vec![
3136 "spar",
3137 "triage",
3138 "7",
3139 "--instructions",
3140 "Do not wait for CI.",
3141 ],
3142 vec![
3143 "spar",
3144 "resume",
3145 "7",
3146 "--instructions",
3147 "Do not wait for CI.",
3148 ],
3149 vec![
3150 "spar",
3151 "review",
3152 "7",
3153 "--instructions",
3154 "Do not wait for CI.",
3155 ],
3156 vec!["spar", "followup", "--instructions", "Do not wait for CI."],
3157 vec![
3158 "spar",
3159 "checkin",
3160 "7",
3161 "--instructions",
3162 "Do not wait for CI.",
3163 ],
3164 vec![
3165 "spar",
3166 "split",
3167 "7",
3168 "--instructions",
3169 "Do not wait for CI.",
3170 ],
3171 vec![
3172 "spar",
3173 "brainstorm",
3174 "a",
3175 "subject",
3176 "--instructions",
3177 "Do not wait for CI.",
3178 ],
3179 vec![
3180 "spar",
3181 "listen",
3182 "7",
3183 "--instructions",
3184 "Do not wait for CI.",
3185 ],
3186 ] {
3187 let parsed = Cli::parse_from(&argv);
3188 let common = match parsed.command {
3189 Command::Run { common, .. }
3190 | Command::Triage { common, .. }
3191 | Command::Resume { common, .. }
3192 | Command::Review { common, .. }
3193 | Command::Followup { common, .. }
3194 | Command::Split { common, .. }
3195 | Command::Brainstorm { common, .. }
3196 | Command::Listen { common, .. }
3197 | Command::Checkin { common, .. } => common,
3198 other => panic!("{other:?}"),
3199 };
3200 assert_eq!(
3201 Some("Do not wait for CI."),
3202 common.instructions.as_deref(),
3203 "{argv:?}"
3204 );
3205 }
3206 }
3207
3208 #[test]
3209 fn the_two_close_skipped_flags_are_mutually_exclusive() {
3210 assert!(
3211 Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
3212 );
3213 }
3214
3215 #[test]
3218 fn close_skipped_is_offered_only_where_it_means_something() {
3219 assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
3220 assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
3221 assert!(Cli::try_parse_from(["spar", "followup", "--close-skipped"]).is_ok());
3223 assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
3224 assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
3225 assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
3226 }
3227
3228 #[test]
3231 fn brainstorm_takes_words_or_nothing() {
3232 match Cli::parse_from(["spar", "brainstorm", "novel", "uses", "of", "script"]).command {
3233 Command::Brainstorm { topic, ideas, .. } => {
3234 assert_eq!(vec!["novel", "uses", "of", "script"], topic);
3235 assert_eq!(5, ideas);
3236 }
3237 other => panic!("{other:?}"),
3238 }
3239 match Cli::parse_from(["spar", "brainstorm", "--ideas", "3", "--max-rounds", "2"]).command {
3240 Command::Brainstorm {
3241 topic,
3242 ideas,
3243 max_rounds,
3244 ..
3245 } => {
3246 assert!(topic.is_empty());
3247 assert_eq!(3, ideas);
3248 assert_eq!(Some(2), max_rounds);
3249 }
3250 other => panic!("{other:?}"),
3251 }
3252 }
3253
3254 #[test]
3258 fn filing_from_a_saved_session_needs_file_issues_and_writes_no_session() {
3259 assert!(Cli::try_parse_from(["spar", "brainstorm", "--from", "s.md"]).is_err());
3260 assert!(
3261 Cli::try_parse_from(["spar", "brainstorm", "--from", "s.md", "--file-issues"]).is_ok()
3262 );
3263 assert!(Cli::try_parse_from([
3264 "spar",
3265 "brainstorm",
3266 "--from",
3267 "s.md",
3268 "--file-issues",
3269 "--out",
3270 "t.md"
3271 ])
3272 .is_err());
3273 assert!(
3274 Cli::try_parse_from(["spar", "brainstorm", "x", "--out", "t.md", "--file-issues"])
3275 .is_ok()
3276 );
3277 }
3278
3279 #[test]
3282 fn followup_takes_no_numbers() {
3283 assert!(Cli::try_parse_from(["spar", "followup"]).is_ok());
3284 assert!(Cli::try_parse_from(["spar", "followup", "42"]).is_err());
3285 }
3286
3287 #[test]
3290 fn the_two_stopping_points_are_mutually_exclusive() {
3291 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only"]).is_ok());
3292 assert!(Cli::try_parse_from(["spar", "followup", "--file-only"]).is_ok());
3293 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only", "--file-only"]).is_err());
3294 }
3295
3296 #[test]
3297 fn the_close_skipped_pair_resolves_to_a_tristate() {
3298 let read = |argv: &[&str]| match Cli::parse_from(argv).command {
3299 Command::Run { triage_flags, .. } => {
3300 match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
3301 (true, _) => Some(true),
3302 (_, true) => Some(false),
3303 _ => None,
3304 }
3305 }
3306 other => panic!("{other:?}"),
3307 };
3308 assert_eq!(None, read(&["spar", "run"]));
3309 assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
3310 assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
3311 }
3312
3313 #[test]
3314 fn the_default_limit_is_twenty() {
3315 let cli = Cli::parse_from(["spar", "run"]);
3316 match cli.command {
3317 Command::Run { common, .. } => assert_eq!(20, common.limit),
3318 other => panic!("{other:?}"),
3319 }
3320 }
3321
3322 #[test]
3323 fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
3324 assert!(matches!(
3325 Cli::parse_from(["spar", "scrub-filter"]).command,
3326 Command::ScrubFilter
3327 ));
3328 let help = Cli::command().render_long_help().to_string();
3329 assert!(
3330 !help.contains("scrub-filter"),
3331 "it is plumbing, not a command"
3332 );
3333 }
3334
3335 #[test]
3336 fn review_takes_pr_numbers_and_a_dry_run() {
3337 let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
3338 match cli.command {
3339 Command::Review { items, dry_run, .. } => {
3340 assert_eq!(vec![101, 102], items);
3341 assert!(dry_run);
3342 }
3343 other => panic!("{other:?}"),
3344 }
3345 }
3346
3347 #[test]
3348 fn review_posts_unless_told_not_to() {
3349 match Cli::parse_from(["spar", "review", "101"]).command {
3350 Command::Review { dry_run, .. } => assert!(!dry_run),
3351 other => panic!("{other:?}"),
3352 }
3353 }
3354
3355 #[test]
3356 fn review_with_no_numbers_is_allowed() {
3357 match Cli::parse_from(["spar", "review"]).command {
3358 Command::Review { items, .. } => assert!(items.is_empty()),
3359 other => panic!("{other:?}"),
3360 }
3361 }
3362
3363 #[test]
3364 fn review_takes_its_own_round_budget() {
3365 match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
3366 Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
3367 other => panic!("{other:?}"),
3368 }
3369 }
3370
3371 #[test]
3372 fn checkin_takes_pr_numbers_and_a_dry_run() {
3373 match Cli::parse_from(["spar", "checkin", "108", "112", "--dry-run"]).command {
3374 Command::Checkin { items, dry_run, .. } => {
3375 assert_eq!(vec![108, 112], items);
3376 assert!(dry_run);
3377 }
3378 other => panic!("{other:?}"),
3379 }
3380 match Cli::parse_from(["spar", "checkin"]).command {
3381 Command::Checkin { items, dry_run, .. } => {
3382 assert!(items.is_empty());
3383 assert!(!dry_run);
3384 }
3385 other => panic!("{other:?}"),
3386 }
3387 }
3388
3389 #[test]
3395 fn checkin_offers_no_flag_that_would_weaken_the_pair() {
3396 assert!(Cli::try_parse_from(["spar", "checkin", "--auto-merge"]).is_err());
3397 assert!(Cli::try_parse_from(["spar", "checkin", "--max-rounds", "1"]).is_err());
3398 assert!(Cli::try_parse_from(["spar", "checkin", "--absorb", "1"]).is_err());
3399 assert!(Cli::try_parse_from(["spar", "checkin", "--close-skipped"]).is_err());
3400 assert!(Cli::try_parse_from(["spar", "checkin", "--reply-only"]).is_ok());
3402 assert!(Cli::try_parse_from(["spar", "checkin", "--any-author"]).is_ok());
3403 assert!(Cli::try_parse_from(["spar", "checkin", "--again"]).is_ok());
3404 assert!(Cli::try_parse_from(["spar", "checkin", "--keep-worktrees"]).is_ok());
3405 }
3406
3407 #[test]
3408 fn review_takes_verdict_and_recheck_flags_and_neither_by_default() {
3409 match Cli::parse_from(["spar", "review", "108", "--verdict", "--recheck"]).command {
3410 Command::Review {
3411 items,
3412 verdict,
3413 recheck,
3414 ..
3415 } => {
3416 assert_eq!(vec![108], items);
3417 assert!(verdict);
3418 assert!(recheck);
3419 }
3420 other => panic!("{other:?}"),
3421 }
3422 match Cli::parse_from(["spar", "review", "108"]).command {
3423 Command::Review {
3424 verdict, recheck, ..
3425 } => {
3426 assert!(!verdict);
3427 assert!(!recheck);
3428 }
3429 other => panic!("{other:?}"),
3430 }
3431 assert!(Cli::try_parse_from(["spar", "checkin", "108", "--verdict"]).is_err());
3432 }
3433
3434 #[test]
3435 fn listen_takes_numbers_once_dry_run_and_since() {
3436 match Cli::parse_from([
3437 "spar",
3438 "listen",
3439 "108",
3440 "42",
3441 "--once",
3442 "--dry-run",
3443 "--since",
3444 "2h",
3445 ])
3446 .command
3447 {
3448 Command::Listen {
3449 items,
3450 once,
3451 dry_run,
3452 since,
3453 ..
3454 } => {
3455 assert_eq!(vec![108, 42], items);
3456 assert!(once);
3457 assert!(dry_run);
3458 assert_eq!(Some("2h".to_string()), since);
3459 }
3460 other => panic!("{other:?}"),
3461 }
3462 match Cli::parse_from(["spar", "listen"]).command {
3463 Command::Listen {
3464 items,
3465 once,
3466 dry_run,
3467 since,
3468 common,
3469 } => {
3470 assert!(items.is_empty());
3471 assert!(!once);
3472 assert!(!dry_run);
3473 assert_eq!(None, since);
3474 assert_eq!(20, common.limit);
3475 }
3476 other => panic!("{other:?}"),
3477 }
3478 }
3479
3480 #[test]
3481 fn resume_takes_a_next_override() {
3482 let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
3483 match cli.command {
3484 Command::Resume {
3485 prs, next_actor, ..
3486 } => {
3487 assert_eq!(vec![108], prs);
3488 assert_eq!(Some("codex".to_string()), next_actor);
3489 }
3490 other => panic!("{other:?}"),
3491 }
3492 }
3493}
3494
3495#[cfg(test)]
3496mod absorb_tests {
3497 use super::*;
3498
3499 #[test]
3500 fn absorb_is_off_unless_asked_for() {
3501 match Cli::parse_from(["spar", "run"]).command {
3502 Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
3503 other => panic!("{other:?}"),
3504 }
3505 }
3506
3507 #[test]
3508 fn absorb_takes_a_wave_count() {
3509 match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
3510 Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
3511 other => panic!("{other:?}"),
3512 }
3513 }
3514
3515 #[test]
3516 fn absorb_is_only_offered_where_issues_are_worked() {
3517 assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
3518 assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
3519 assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
3520 assert!(Cli::try_parse_from(["spar", "split", "--absorb", "1"]).is_err());
3523 }
3524}
3525
3526#[cfg(test)]
3527mod split_tests {
3528 use super::*;
3529
3530 #[test]
3531 fn split_takes_numbers_of_either_kind() {
3532 match Cli::parse_from(["spar", "split", "10", "12"]).command {
3533 Command::Split { items, .. } => assert_eq!(vec![10, 12], items),
3534 other => panic!("{other:?}"),
3535 }
3536 }
3537
3538 #[test]
3541 fn split_with_no_numbers_is_allowed() {
3542 match Cli::parse_from(["spar", "split"]).command {
3543 Command::Split {
3544 items,
3545 dry_run,
3546 again,
3547 ..
3548 } => {
3549 assert!(items.is_empty());
3550 assert!(!dry_run);
3551 assert!(!again);
3552 }
3553 other => panic!("{other:?}"),
3554 }
3555 }
3556
3557 #[test]
3561 fn split_can_be_told_to_write_nothing() {
3562 match Cli::parse_from(["spar", "split", "10", "--dry-run"]).command {
3563 Command::Split { dry_run, .. } => assert!(dry_run),
3564 other => panic!("{other:?}"),
3565 }
3566 }
3567
3568 #[test]
3571 fn split_can_be_told_to_split_something_it_already_split() {
3572 match Cli::parse_from(["spar", "split", "10", "--again"]).command {
3573 Command::Split { again, .. } => assert!(again),
3574 other => panic!("{other:?}"),
3575 }
3576 }
3577
3578 #[test]
3581 fn split_takes_the_flags_that_govern_picking() {
3582 match Cli::parse_from([
3583 "spar",
3584 "split",
3585 "--limit",
3586 "5",
3587 "--min-number",
3588 "480",
3589 "--config",
3590 "other.toml",
3591 ])
3592 .command
3593 {
3594 Command::Split { common, .. } => {
3595 assert_eq!(5, common.limit);
3596 assert_eq!(Some(480), common.min_number);
3597 assert_eq!(Some(PathBuf::from("other.toml")), common.config);
3598 }
3599 other => panic!("{other:?}"),
3600 }
3601 }
3602
3603 #[test]
3606 fn split_offers_no_flag_that_would_make_it_a_second_run() {
3607 for flag in [
3608 vec!["--auto-merge"],
3609 vec!["--max-rounds", "2"],
3610 vec!["--close-skipped"],
3611 vec!["--no-close-skipped"],
3612 vec!["--keep-worktrees"],
3613 ] {
3614 let mut argv = vec!["spar", "split", "10"];
3615 argv.extend(flag.iter().copied());
3616 assert!(Cli::try_parse_from(&argv).is_err(), "{argv:?}");
3617 }
3618 }
3619}
3620
3621#[cfg(test)]
3622mod min_number_tests {
3623 use super::*;
3624
3625 fn read(argv: &[&str]) -> Option<i64> {
3626 match Cli::parse_from(argv).command {
3627 Command::Run { common, .. }
3628 | Command::Triage { common, .. }
3629 | Command::Resume { common, .. }
3630 | Command::Review { common, .. }
3631 | Command::Followup { common, .. }
3632 | Command::Split { common, .. }
3633 | Command::Listen { common, .. }
3634 | Command::Checkin { common, .. } => common.min_number,
3635 other => panic!("{other:?}"),
3636 }
3637 }
3638
3639 #[test]
3640 fn there_is_no_floor_unless_one_is_asked_for() {
3641 assert_eq!(None, read(&["spar", "run"]));
3642 }
3643
3644 #[test]
3645 fn every_command_that_picks_for_itself_accepts_a_floor() {
3646 for cmd in [
3647 "run", "triage", "resume", "review", "checkin", "split", "listen",
3648 ] {
3649 assert_eq!(
3650 Some(480),
3651 read(&["spar", cmd, "--min-number", "480"]),
3652 "{cmd}"
3653 );
3654 }
3655 }
3656}
3657
3658#[cfg(test)]
3659mod run_targets_tests {
3660 use super::*;
3661
3662 fn covered(pairs: &[(i64, i64)]) -> BTreeMap<i64, i64> {
3663 pairs.iter().copied().collect()
3664 }
3665
3666 #[test]
3671 fn an_issue_its_named_pull_request_closes_is_not_worked_again() {
3672 let kept = issues_not_on_named_prs(&[772, 783, 784], &covered(&[(772, 786)]));
3673
3674 assert_eq!(vec![783, 784], kept);
3675 }
3676
3677 #[test]
3678 fn an_issue_no_named_pull_request_closes_is_still_worked() {
3679 let kept = issues_not_on_named_prs(&[783, 784, 771], &covered(&[(772, 786)]));
3680
3681 assert_eq!(vec![783, 784, 771], kept);
3682 }
3683
3684 #[test]
3687 fn an_unreadable_pull_request_drops_no_issue() {
3688 let kept = issues_not_on_named_prs(&[772, 783], &covered(&[]));
3689
3690 assert_eq!(vec![772, 783], kept);
3691 }
3692
3693 #[test]
3695 fn every_issue_one_pull_request_closes_is_taken_by_it() {
3696 let kept = issues_not_on_named_prs(&[771, 772, 783], &covered(&[(771, 786), (772, 786)]));
3697
3698 assert_eq!(vec![783], kept);
3699 }
3700}
3701
3702#[cfg(test)]
3703mod code_tests {
3704 use super::*;
3705
3706 #[test]
3707 fn code_requires_a_prompt_and_exclusive_preview_modes() {
3708 assert!(Cli::try_parse_from(["spar", "code"]).is_err());
3709 assert!(
3710 Cli::try_parse_from(["spar", "code", "Add export", "--dry-run", "--file-only"])
3711 .is_err()
3712 );
3713 assert!(Cli::try_parse_from(["spar", "code", "Add export", "--dry-run"]).is_ok());
3714 assert!(Cli::try_parse_from(["spar", "code", "Add export", "--file-only"]).is_ok());
3715 }
3716
3717 #[test]
3718 fn code_takes_words_and_the_normal_run_options() {
3719 let cli = Cli::parse_from([
3720 "spar",
3721 "code",
3722 "Add",
3723 "CSV",
3724 "export",
3725 "--repo",
3726 "/tmp/project",
3727 "--config",
3728 "project.toml",
3729 "--first",
3730 "b",
3731 "--base",
3732 "develop",
3733 "--instructions",
3734 "Keep the format stable",
3735 "--max-rounds",
3736 "5",
3737 "--auto-merge",
3738 "--absorb",
3739 "1",
3740 "--keep-worktrees",
3741 "--no-worktrees",
3742 "--no-close-skipped",
3743 "--retriage",
3744 "--plan-out",
3745 "code-plan.json",
3746 ]);
3747 match cli.command {
3748 Command::Code {
3749 prompt,
3750 common,
3751 loop_flags,
3752 triage_flags,
3753 plan_out,
3754 no_worktrees,
3755 dry_run,
3756 file_only,
3757 } => {
3758 assert_eq!(prompt, ["Add", "CSV", "export"]);
3759 assert_eq!(common.repo, PathBuf::from("/tmp/project"));
3760 assert_eq!(common.config, Some(PathBuf::from("project.toml")));
3761 assert_eq!(common.first.as_deref(), Some("b"));
3762 assert_eq!(common.base.as_deref(), Some("develop"));
3763 assert_eq!(
3764 common.instructions.as_deref(),
3765 Some("Keep the format stable")
3766 );
3767 assert_eq!(loop_flags.max_rounds, Some(5));
3768 assert_eq!(loop_flags.absorb, Some(1));
3769 assert!(loop_flags.auto_merge && loop_flags.keep_worktrees);
3770 assert!(triage_flags.no_close_skipped && triage_flags.retriage);
3771 assert_eq!(plan_out, PathBuf::from("code-plan.json"));
3772 assert!(no_worktrees);
3773 assert!(!dry_run && !file_only);
3774 }
3775 other => panic!("{other:?}"),
3776 }
3777 }
3778}
3779
3780#[cfg(test)]
3781mod settings_block_tests {
3782 use super::*;
3783
3784 fn written(line: &str) -> String {
3787 let after = line.split_once('=').expect("an assignment").1;
3788 let mut quoted = false;
3789 for (i, c) in after.char_indices() {
3790 match c {
3791 '"' => quoted = !quoted,
3792 '#' if !quoted => return after[..i].trim().to_string(),
3793 _ => {}
3794 }
3795 }
3796 after.trim().to_string()
3797 }
3798
3799 fn line_for(text: &str, key: &str) -> String {
3800 text.lines()
3801 .find(|l| {
3802 let bare = l.trim_start().trim_start_matches('#').trim_start();
3803 bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
3804 })
3805 .unwrap_or_else(|| panic!("{key} is not offered at all:\n{text}"))
3806 .to_string()
3807 }
3808
3809 #[test]
3816 fn every_value_it_offers_is_the_default_it_actually_has() {
3817 let text = settings_block("claude");
3818 for option in config::known_options() {
3819 if option.section == "loop.effort_schedule" {
3823 continue;
3824 }
3825 let line = line_for(&text, &option.key);
3826 assert_eq!(
3827 option.default,
3828 written(&line),
3829 "the generated config offers `{}`, but the default is {}",
3830 line.trim(),
3831 option.default
3832 );
3833 }
3834 }
3835
3836 #[test]
3840 fn it_offers_every_option_the_parser_knows_about() {
3841 let text = settings_block("claude");
3842 let missing: Vec<String> = config::unmentioned_options(&text)
3843 .into_iter()
3844 .map(|o| format!("[{}] {}", o.section, o.key))
3845 .collect();
3846 assert!(missing.is_empty(), "not offered: {}", missing.join(", "));
3847 }
3848
3849 #[test]
3853 fn every_option_it_offers_can_be_uncommented_and_still_load() {
3854 let mut text = String::from(
3855 "[agents.claude]\ncommand = [\"claude\"]\n\n\
3856 [agents.codex]\ncommand = [\"codex\"]\n\n",
3857 );
3858 for line in settings_block("claude").lines() {
3859 text.push_str(uncomment(line).unwrap_or(line));
3860 text.push('\n');
3861 }
3862 let cfg = config::parse(&text).expect("a config of its own suggestions");
3863 assert_eq!("claude", cfg.first_implementor);
3864 }
3865
3866 fn uncomment(line: &str) -> Option<&str> {
3869 let bare = line.trim_start().strip_prefix('#')?.trim_start();
3870 let key = bare.split_once('=')?.0.trim();
3873 let named = !key.is_empty()
3874 && key
3875 .chars()
3876 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
3877 named.then_some(bare)
3878 }
3879
3880 #[test]
3881 fn the_agent_that_goes_first_is_the_one_that_was_chosen() {
3882 assert!(settings_block("codex").contains("first_implementor = \"codex\""));
3883 }
3884
3885 #[test]
3890 fn a_note_sits_above_the_option_it_describes() {
3891 let text = settings_block("claude");
3892 assert!(
3893 text.lines().all(|l| l.chars().count() <= 80),
3894 "a line runs off the edge:\n{text}"
3895 );
3896 assert!(
3897 text.lines().all(|l| !l.starts_with(' ')),
3898 "a line is indented, so the columns are back:\n{text}"
3899 );
3900
3901 let lines: Vec<&str> = text.lines().collect();
3903 let at = lines
3904 .iter()
3905 .position(|l| l.starts_with("max_rounds"))
3906 .expect("max_rounds");
3907 assert!(lines[at - 1].starts_with('#'), "{:?}", lines[at - 1]);
3908 assert!(
3909 lines[at - 1].contains("lifetime cap"),
3910 "the note above it is the end of its own note: {:?}",
3911 lines[at - 1]
3912 );
3913 }
3914
3915 #[test]
3917 fn every_note_starts_as_a_sentence() {
3918 for (_, key, note) in LOOP_OPTIONS
3919 .iter()
3920 .chain(STYLE_OPTIONS)
3921 .chain(LISTEN_OPTIONS)
3922 {
3923 let first = note.chars().next().expect("a note");
3924 assert!(
3925 first.is_uppercase(),
3926 "{key} reads as a margin scribble rather than a sentence: {note}"
3927 );
3928 }
3929 }
3930}
3931
3932#[cfg(test)]
3933mod agent_block_tests {
3934 use super::*;
3935
3936 fn spec(models: &[&str], efforts: &[&str]) -> config::AgentSpec {
3937 let mut spec: config::AgentSpec =
3938 toml::Value::Table(toml::from_str("command = [\"x\"]").expect("a minimal preset"))
3939 .try_into()
3940 .expect("builds");
3941 spec.models = models.iter().map(|s| s.to_string()).collect();
3942 spec.efforts = efforts.iter().map(|s| s.to_string()).collect();
3943 spec
3944 }
3945
3946 #[test]
3950 fn an_option_with_no_hints_is_left_out_rather_than_guessed_at() {
3951 let block = agent_block("cursor", &spec(&["composer-2.5", "auto"], &[]));
3952 assert!(!block.contains("..."), "{block}");
3953 assert!(!block.contains("effort"), "{block}");
3954 assert!(block.contains("# model = \"composer-2.5\""), "{block}");
3955 }
3956
3957 #[test]
3960 fn only_the_options_that_follow_are_introduced() {
3961 let model_only = agent_block("cursor", &spec(&["auto"], &[]));
3962 assert!(model_only.contains("Omit model to use"), "{model_only}");
3963 assert!(!model_only.contains("Omit effort"), "{model_only}");
3964
3965 let both = agent_block("claude", &spec(&["fable"], &["high"]));
3966 assert!(both.contains("Omit model to use"), "{both}");
3967 assert!(both.contains("Omit effort to use"), "{both}");
3968 }
3969
3970 #[test]
3973 fn a_preset_with_no_hints_still_writes_a_usable_block() {
3974 let block = agent_block("gemini", &spec(&[], &[]));
3975 assert!(!block.contains("..."), "{block}");
3976 assert!(!block.contains("Omit"), "{block}");
3977 assert!(
3978 block.starts_with("[agents.gemini]\npreset = \"gemini\"\n"),
3979 "{block}"
3980 );
3981 assert!(block.contains("[agents.gemini.fallback]"), "{block}");
3983 assert!(block.contains("# timeout = "), "{block}");
3984 }
3985
3986 #[test]
3989 fn the_timeout_offered_is_the_one_the_agent_would_use() {
3990 let mut spec = spec(&["a"], &[]);
3991 spec.timeout = 7200;
3992 assert!(
3993 agent_block("custom", &spec).contains("# timeout = 7200"),
3994 "the generator kept its own copy"
3995 );
3996 }
3997
3998 #[test]
4000 fn alternatives_are_listed_only_when_there_are_any() {
4001 assert!(agent_block("a", &spec(&["one", "two"], &[])).contains("One of: one | two."));
4002 let single = agent_block("b", &spec(&["only"], &[]));
4003 assert!(!single.contains("One of:"), "{single}");
4004 }
4005
4006 #[test]
4010 fn the_presets_note_is_said_once() {
4011 let mut spec = spec(&["m1", "m2"], &["e1", "e2"]);
4012 spec.options_note = Some("Check the current sets with: mytool --help".into());
4013 let block = agent_block("mytool", &spec);
4014 assert_eq!(
4015 1,
4016 block.matches("Check the current sets").count(),
4017 "{block}"
4018 );
4019 }
4020}