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(()) => {
1986 log!("posted to PR #{number}");
1987 if let Some(mut record) = repo.read_review_record(*number) {
1990 if !record.posted {
1991 record.posted = true;
1992 if let Err(e) = repo.write_review_record(*number, &record) {
1993 logdim!("could not mark the review record posted: {e}");
1994 }
1995 }
1996 }
1997 }
1998 Err(e) => {
1999 logging::error(format!("could not post to PR #{number}: {e}"));
2000 failed = true;
2001 }
2002 }
2003 }
2004 Ok(i32::from(failed).max(report_writes(&repo)))
2005}
2006
2007fn cmd_answer(common: &Common, items: Vec<i64>, dry_run: bool, again: bool) -> Result<i32> {
2009 let (cfg, repo, agents) = prepare(common, None)?;
2010 let mode = answer::Mode {
2011 dry_run,
2012 draft: false,
2013 again,
2014 auto: false,
2015 posts: checkin::posts(&cfg),
2016 };
2017 let numbers = if items.is_empty() {
2018 let viewer = repo.viewer_login()?.to_string();
2019 let floor = cfg.loop_cfg.min_number;
2020 let found: Vec<i64> = repo
2021 .open_issues_mentioning(&viewer, usize::MAX)?
2022 .into_iter()
2023 .filter(|n| *n >= floor)
2024 .take(common.limit)
2025 .collect();
2026 if found.is_empty() {
2027 log!("no open issues mention @{viewer}");
2028 return Ok(0);
2029 }
2030 log!(
2031 "no issues given, answering {} that mention @{viewer}: {}",
2032 found.len(),
2033 found
2034 .iter()
2035 .map(|n| format!("#{n}"))
2036 .collect::<Vec<_>>()
2037 .join(", ")
2038 );
2039 found
2040 } else {
2041 items
2042 };
2043 let results: Vec<IssueRun> = numbers
2044 .iter()
2045 .map(|number| answer::answer_issue(&agents, &cfg, &repo, *number, &mode))
2046 .collect();
2047 Ok(report(&results, &cfg, &repo))
2048}
2049
2050#[allow(clippy::too_many_arguments)]
2056fn cmd_brainstorm(
2057 topic: Vec<String>,
2058 common: &Common,
2059 max_rounds: Option<u32>,
2060 ideas: usize,
2061 out: Option<PathBuf>,
2062 file_issues: bool,
2063 from: Option<&Path>,
2064) -> Result<i32> {
2065 if ideas == 0 {
2066 bail!("--ideas must be at least 1");
2067 }
2068 if let Some(path) = from {
2069 let cfg = config::load(common.config.as_deref())?;
2070 let repo = Repo::open(&common.repo, &cfg)?;
2071 let text = std::fs::read_to_string(path)
2072 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
2073 let entries = brainstorm::parse_session(&text);
2074 if entries.is_empty() {
2075 bail!(
2076 "no ideas in {}: an idea is a `## ` heading with its sections under it",
2077 path.display()
2078 );
2079 }
2080 log!(
2081 "filing {} idea{} from {}",
2082 entries.len(),
2083 if entries.len() == 1 { "" } else { "s" },
2084 path.display()
2085 );
2086 brainstorm::file_ideas(&repo, &entries);
2087 return Ok(report_writes(&repo));
2088 }
2089
2090 let overrides = Overrides {
2091 max_rounds,
2092 ..Overrides::default()
2093 };
2094 let (cfg, repo, agents) = prepare(common, Some(overrides))?;
2095 let joined = topic.join(" ").trim().to_string();
2096 let session = brainstorm::Session {
2097 topic: (!joined.is_empty()).then_some(joined),
2098 budget: cfg.loop_cfg.max_rounds,
2099 wanted: ideas,
2100 };
2101 let now = clock::now_secs();
2102 let outcome = brainstorm::run(&agents, &cfg, &repo, &session)?;
2103 let path = out.unwrap_or_else(|| brainstorm::default_path(&repo, &session, now));
2104 let text = brainstorm::render_session(&outcome, &clock::date_line(now), &repo.style);
2105 crate::repo::write_text_atomic(&path, &text)?;
2106 brainstorm::print_summary(&outcome, &path);
2107 if file_issues {
2108 println!();
2109 brainstorm::file_ideas(&repo, &brainstorm::issue_entries(&outcome, &repo.style));
2110 }
2111
2112 let spent = spend::taken();
2113 if let Err(e) = repo.record_spend(&spent) {
2114 logdim!("could not record what this run spent: {e}");
2115 }
2116 if let Some(summary) = spend::summary(&spent) {
2117 println!("\n{summary}");
2118 }
2119 Ok(report_writes(&repo))
2120}
2121
2122fn cmd_init_update(out: &Path) -> Result<i32> {
2128 let text = std::fs::read_to_string(out)
2129 .map_err(|e| spar_err!("could not read {}: {e}", out.display()))?;
2130 config::parse(&text).map_err(|e| spar_err!("{} does not parse: {e}", out.display()))?;
2133
2134 let unset = config::unmentioned_options(&text);
2135 if unset.is_empty() {
2136 println!("{} already mentions every setting.", out.display());
2137 return Ok(0);
2138 }
2139
2140 let mut block = String::new();
2141 if !text.ends_with('\n') {
2142 block.push('\n');
2143 }
2144 block.push_str("\n# Added by `spar init --update`: settings this file did not mention,\n");
2145 block.push_str("# shown at their defaults. Uncomment one to change it.\n");
2146 let mut section = "";
2147 for option in &unset {
2148 if option.section != section {
2149 section = option.section;
2150 block.push_str(&format!("\n# [{section}]\n"));
2151 }
2152 block.push('\n');
2157 block.push_str(&wrap_comment(note_for(&option.key)));
2158 block.push_str(&format!("# {} = {}\n", option.key, option.default));
2159 }
2160
2161 use std::io::Write;
2162 std::fs::OpenOptions::new()
2163 .append(true)
2164 .open(out)
2165 .and_then(|mut f| f.write_all(block.as_bytes()))
2166 .map_err(|e| spar_err!("could not append to {}: {e}", out.display()))?;
2167
2168 println!(
2169 "added {} setting(s) to {} as comments",
2170 unset.len(),
2171 out.display()
2172 );
2173 Ok(0)
2174}
2175
2176fn cmd_init(out: &Path, force: bool) -> Result<i32> {
2177 if out.exists() && !force {
2178 logging::error(format!(
2179 "{} already exists. `--update` appends any settings it does not mention, \
2180 `--force` overwrites it.",
2181 out.display()
2182 ));
2183 return Ok(1);
2184 }
2185
2186 let presets = config::available_presets();
2187 if presets.is_empty() {
2188 bail!("no presets available, which should be impossible in a released build");
2189 }
2190
2191 let mut found: Vec<(String, PathBuf, config::AgentSpec)> = Vec::new();
2192 for name in &presets {
2193 let raw = config::load_preset(name)?;
2194 let mut spec: config::AgentSpec = match raw
2198 .as_table()
2199 .cloned()
2200 .ok_or_else(|| spar_err!("not a table"))
2201 .and_then(|t| {
2202 toml::Value::Table(t)
2203 .try_into()
2204 .map_err(|e| spar_err!("{e}"))
2205 }) {
2206 Ok(spec) => spec,
2207 Err(e) => {
2208 println!(" BROKEN {name:10} {}", e.first_line());
2209 continue;
2210 }
2211 };
2212 spec.name = name.clone();
2213 match Agent::new(spec.clone()).resolve_bin() {
2214 Ok(path) => {
2215 println!(" found {name:10} {}", path.display());
2216 found.push((name.clone(), path.to_path_buf(), spec));
2217 }
2218 Err(_) => println!(" missing {name}"),
2219 }
2220 }
2221
2222 if found.len() < 2 {
2223 logging::error(format!(
2224 "need two agent CLIs, found {}. Install another, or write {} by hand using the \
2225 presets as a reference.",
2226 found.len(),
2227 out.display()
2228 ));
2229 return Ok(1);
2230 }
2231
2232 let chosen: Vec<&(String, PathBuf, config::AgentSpec)> = found.iter().take(2).collect();
2234 if found.len() > 2 {
2235 log!(
2236 "{} agents available, picking {} and {}. Edit {} to change.",
2237 found.len(),
2238 chosen[0].0,
2239 chosen[1].0,
2240 out.display()
2241 );
2242 }
2243
2244 let mut text = String::from(
2245 "# Generated by `spar init`. Each agent inherits a command template from a\n\
2246 # built in preset; anything set here overrides it.\n\
2247 #\n\
2248 # Commented lines are the other options, each with a working value.\n\
2249 # Uncomment one to change it.\n\n",
2250 );
2251 for (name, _, spec) in &chosen {
2252 text.push_str(&agent_block(name, spec));
2253 }
2254 text.push_str(&settings_block(&chosen[0].0));
2255
2256 std::fs::write(out, text).map_err(|e| spar_err!("could not write {}: {e}", out.display()))?;
2257 println!("\nwrote {}", out.display());
2258 println!("Next: `spar doctor` to check it, then `spar run` in a GitHub checkout.");
2259 Ok(0)
2260}
2261
2262fn report_fallback(agent: &Agent) {
2268 let Some(backup) = agent.fallback() else {
2269 return;
2270 };
2271 match backup.resolve_bin() {
2272 Ok(bin) => println!(
2273 " fallback {} ({})",
2274 bin.display(),
2275 backup.spec.describe()
2276 ),
2277 Err(_) => println!(
2278 " fallback {} not found, so it will not stand in. Set {} to its path.",
2279 backup.program(),
2280 backup.env_key()
2281 ),
2282 }
2283}
2284
2285type Setting = (bool, &'static str, &'static str);
2296
2297const LOOP_OPTIONS: &[Setting] = &[
2298 (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."),
2299 (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."),
2300 (false, "first_implementor", "Which agent takes the first pass. The other one reviews it."),
2301 (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."),
2302 (false, "close_skipped", "Close an issue both reviewers declined, after posting the shared reasoning. A tracking issue is left open whatever this says."),
2303 (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."),
2304 (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."),
2305 (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."),
2306 (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."),
2307 (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."),
2308 (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."),
2309 (true, "keep_worktrees", "Keep worktrees after a run, for inspection."),
2310 (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."),
2311 (true, "parallel_triage", "Ask both agents to triage at once. They only read during triage, so there is nothing to serialise."),
2312 (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."),
2313 (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."),
2314 (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."),
2315 (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."),
2316 (true, "file_nits", "File nits as follow-ups too. Off, because a filed nit is somebody else's notification."),
2317 (true, "base_branch", "Only a fallback. Whatever origin/HEAD points at wins when it resolves."),
2318 (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."),
2319 (true, "state_store", "Where resume state is kept. local uses .spar/state and keeps it off the pull request."),
2320 (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."),
2321 (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."),
2322 (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."),
2323 (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."),
2324 (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."),
2325 (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."),
2326 (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."),
2327 (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."),
2328];
2329
2330const STYLE_OPTIONS: &[Setting] = &[
2331 (false, "ban_em_dash", "Strip em-dashes and en-dashes from everything spar posts, then refuse to post text that still has one."),
2332 (false, "ban_ai_attribution", "Strip mentions of the tooling, and Co-Authored-By trailers, from everything spar posts."),
2333 (false, "terse", "Hold model prose to a length budget. false removes the valves entirely."),
2334 (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."),
2335 (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."),
2336 (true, "max_title_chars", "A finding, issue, or pull request title. Never ellipsised: a title ending in three dots reads as broken."),
2337 (true, "max_summary_chars", "A one line verdict, or a refutation's argument."),
2338 (true, "max_detail_chars", "A blocking finding's explanation, as it appears in the pull request thread."),
2339 (true, "max_body_chars", "A pull request body."),
2340 (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."),
2341];
2342
2343const LISTEN_OPTIONS: &[Setting] = &[
2344 (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."),
2345 (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."),
2346 (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."),
2347 (true, "max_wait", "Most seconds an item may keep settling before it is acted on anyway."),
2348 (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."),
2349 (true, "on_review_requested", "What a pull request somebody asked you to review gets: review or ignore."),
2350 (true, "on_assigned", "What a pull request assigned to you gets: review or ignore."),
2351 (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."),
2352 (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."),
2353 (false, "verdicts", "Whether a review `spar listen` starts submits a formal verdict, approve or request changes, rather than a comment. A recheck always does."),
2354 (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."),
2355 (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."),
2356];
2357
2358fn settings_block(first_implementor: &str) -> String {
2363 let defaults: std::collections::BTreeMap<String, String> = config::known_options()
2364 .into_iter()
2365 .map(|option| (option.key, option.default))
2366 .collect();
2367 let value = |key: &str| match key {
2370 "first_implementor" => format!("\"{first_implementor}\""),
2371 other => defaults.get(other).cloned().unwrap_or_default(),
2372 };
2373
2374 let mut out = String::from("[loop]\n");
2375 out.push_str(&option_lines(LOOP_OPTIONS, &value));
2376 out.push_str(concat!(
2377 "\n[loop.effort_schedule]\n",
2378 "# One key per kind of call, so a value means what its name says. Values\n",
2379 "# are whatever each agent's own CLI accepts, listed above, so these are\n",
2380 "# examples rather than defaults. Left out, a call falls back to\n",
2381 "# round_1 or rest, and then to the effort the agent's own block asked\n",
2382 "# for. Where the two CLIs do not share a vocabulary, put the schedule\n",
2383 "# under [agents.NAME.effort_schedule] instead, with the same keys.\n",
2384 "# code = \"high\" # drafting an issue from a project prompt\n",
2385 "# triage = \"low\" # both agents, over the whole queue, per wave\n",
2386 "# implement = \"high\" # writing the first draft\n",
2387 "# review_1 = \"high\" # the deep first review\n",
2388 "# review_rest = \"low\" # later rounds, which see a small delta\n",
2389 "# respond = \"high\" # the author answering a review\n",
2390 "# close = \"low\" # the closing merge safety pass\n",
2391 "# screen = \"low\" # the follow-up screen, one call for the queue\n",
2392 "# checkin = \"high\" # judging comments other people left\n",
2393 "# split = \"high\" # proposing and checking a split\n",
2394 "# brainstorm = \"high\" # every brainstorm round\n",
2395 "# recheck = \"low\" # a second look after commits or replies\n",
2396 "# answer = \"high\" # drafting and checking answers to a mention\n",
2397 "# round_1 = \"high\" # read by any call above with no key set\n",
2398 "# rest = \"low\" # the same, for later rounds and the close\n\n",
2399 ));
2400 out.push_str("[style]\n");
2401 out.push_str(&option_lines(STYLE_OPTIONS, &value));
2402 out.push_str("\n[listen]\n");
2403 out.push_str(&option_lines(LISTEN_OPTIONS, &value));
2404 out
2405}
2406
2407fn option_lines(options: &[Setting], value: &dyn Fn(&str) -> String) -> String {
2411 let mut out = String::new();
2412 for (commented, key, note) in options {
2413 if !out.is_empty() {
2414 out.push('\n');
2415 }
2416 out.push_str(&wrap_comment(note));
2417 let lead = if *commented { "# " } else { "" };
2418 out.push_str(&format!("{lead}{key} = {}\n", value(key)));
2419 }
2420 out
2421}
2422
2423type Probe = Box<dyn Fn() -> Result<String>>;
2426
2427fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
2434 let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
2435
2436 if let Some(extra) = &spec.options_note {
2448 if !spec.models.is_empty() || !spec.efforts.is_empty() {
2449 out.push('\n');
2450 out.push_str(&wrap_comment(extra));
2451 }
2452 }
2453 for (key, choices) in [("model", &spec.models), ("effort", &spec.efforts)] {
2454 let Some(suggested) = choices.first() else {
2455 continue;
2456 };
2457 let mut note = format!("Omit {key} to use the CLI's own default.");
2458 if choices.len() > 1 {
2459 note.push_str(&format!(" One of: {}.", choices.join(" | ")));
2460 }
2461 out.push('\n');
2462 out.push_str(&wrap_comment(¬e));
2463 out.push_str(&format!("# {key} = \"{suggested}\"\n"));
2464 }
2465
2466 out.push('\n');
2470 out.push_str(&wrap_comment(
2471 "Seconds one call may take before spar gives up. A timeout costs the whole call and is \
2472 never retried, so err long.",
2473 ));
2474 out.push_str(&format!("# timeout = {}\n", spec.timeout));
2475
2476 let backup = if name == "cursor" { "gemini" } else { "cursor" };
2479 out.push('\n');
2480 out.push_str(&wrap_comment(
2481 "A stand in for when this CLI refuses, stalls, or runs out of quota. It answers in place \
2482 of this agent, never alongside it.",
2483 ));
2484 out.push_str(&format!(
2485 "# [agents.{name}.fallback]\n# preset = \"{backup}\"\n"
2486 ));
2487
2488 out.push('\n');
2493 out.push_str(&wrap_comment(
2494 "command, output, search_paths and the rest are in spar.example.toml, for pairing a CLI \
2495 that has no preset.",
2496 ));
2497 out.push('\n');
2498 out
2499}
2500
2501fn note_for(key: &str) -> &'static str {
2507 LOOP_OPTIONS
2508 .iter()
2509 .chain(STYLE_OPTIONS)
2510 .chain(LISTEN_OPTIONS)
2511 .find(|(_, name, _)| *name == key)
2512 .map(|(_, _, note)| *note)
2513 .unwrap_or("")
2514}
2515
2516fn wrap_comment(text: &str) -> String {
2518 const WIDTH: usize = 76;
2519 let mut out = String::new();
2520 let mut line = String::from("#");
2521 for word in text.split_whitespace() {
2522 if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
2523 out.push_str(&line);
2524 out.push('\n');
2525 line = String::from("#");
2526 }
2527 line.push(' ');
2528 line.push_str(word);
2529 }
2530 if line.len() > 1 {
2531 out.push_str(&line);
2532 out.push('\n');
2533 }
2534 out
2535}
2536
2537fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
2538 let mut ok = true;
2539
2540 let probes: Vec<(&str, Probe)> = vec![
2541 (
2542 "git",
2543 Box::new(|| {
2544 proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
2545 .map(|s| first_line(&s))
2546 }),
2547 ),
2548 (
2549 "gh",
2550 Box::new(|| {
2551 proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
2552 .map(|s| first_line(&s))
2553 }),
2554 ),
2555 (
2556 "gh auth",
2557 Box::new(|| {
2558 let out = proc::exec(
2559 &["gh".into(), "auth".into(), "status".into()],
2560 &ExecOpts::new().check(false).timeout_secs(60),
2561 )?;
2562 let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
2563 if out.ok() {
2564 Ok(first_line(&text))
2565 } else {
2566 Err(spar_err!("not authenticated. Run `gh auth login`."))
2567 }
2568 }),
2569 ),
2570 ];
2571
2572 for (label, probe) in probes {
2573 match probe() {
2574 Ok(detail) => println!(" ok {label:12} {detail}"),
2575 Err(e) => {
2576 println!(" FAIL {label:12} {}", e.first_line());
2577 ok = false;
2578 }
2579 }
2580 }
2581
2582 let found = config::find_config(config_path)?;
2583 let Some(path) = found else {
2584 println!("\n no spar.toml found. Run `spar init` to generate one.");
2585 println!(
2586 " presets available: {}",
2587 config::available_presets().join(", ")
2588 );
2589 return Ok(if ok { 0 } else { 1 });
2590 };
2591
2592 println!("\n config: {}", path.display());
2593 let cfg = match config::load(Some(&path)) {
2594 Ok(cfg) => cfg,
2595 Err(e) => {
2596 println!(" FAIL config {e}");
2597 return Ok(1);
2598 }
2599 };
2600
2601 let mut resolved = Vec::new();
2604 for spec in &cfg.agents {
2605 let agent = Agent::new(spec.clone());
2606 match agent.resolve_bin() {
2607 Ok(bin) => {
2608 println!(
2609 " ok {:12} {} ({})",
2610 spec.name,
2611 bin.display(),
2612 spec.describe()
2613 );
2614 report_fallback(&agent);
2615 resolved.push(agent);
2616 }
2617 Err(e) => {
2618 println!(" FAIL {:12} {}", spec.name, e.first_line());
2619 ok = false;
2620 }
2621 }
2622 }
2623
2624 if resolved.len() == cfg.agents.len() {
2625 if let Some(warning) = agent::correlation_warning(&resolved) {
2626 println!("\n WARNING {warning}");
2627 }
2628 }
2629
2630 for line in unknown_effort_values(&cfg) {
2631 println!("\n WARNING {line}");
2632 }
2633
2634 println!(
2635 "\n settings: first={} max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
2636 cfg.first_implementor_policy(),
2637 cfg.loop_cfg.max_rounds,
2638 cfg.loop_cfg.auto_merge,
2639 cfg.loop_cfg.worktrees,
2640 cfg.loop_cfg.followups,
2641 cfg.style.terse
2642 );
2643 if let Ok(text) = std::fs::read_to_string(&path) {
2647 let unset = config::unmentioned_options(&text);
2648 if !unset.is_empty() {
2649 println!(
2650 "\n {} setting(s) this config does not mention, all at their defaults:",
2651 unset.len()
2652 );
2653 for option in &unset {
2654 println!(
2655 " [{}] {} = {}",
2656 option.section, option.key, option.default
2657 );
2658 }
2659 println!(
2660 " `spar init --update {}` appends them as comments.",
2661 path.display()
2662 );
2663 }
2664 }
2665
2666 println!(
2667 "{}",
2668 if ok {
2669 "\nready"
2670 } else {
2671 "\nmissing prerequisites"
2672 }
2673 );
2674 Ok(if ok { 0 } else { 1 })
2675}
2676
2677fn unknown_effort_values(cfg: &config::Config) -> Vec<String> {
2686 let mut out = Vec::new();
2687 for spec in &cfg.agents {
2688 if spec.efforts.is_empty() {
2689 continue;
2690 }
2691 let mut said: Vec<String> = Vec::new();
2692 for call in config::Call::every() {
2693 let Some(value) = cfg.effort_for(spec, call) else {
2694 continue;
2695 };
2696 let known = spec
2697 .efforts
2698 .iter()
2699 .any(|hint| hint.eq_ignore_ascii_case(value.trim()));
2700 if known || said.contains(&value) {
2701 continue;
2702 }
2703 said.push(value.clone());
2704 out.push(format!(
2705 "{} is asked for effort \"{value}\" on the {} call, which is not one it is known \
2706 to accept ({}). Put this agent's values under \
2707 [agents.{}.effort_schedule].",
2708 spec.name,
2709 call.key(),
2710 spec.efforts.join(" | "),
2711 spec.name
2712 ));
2713 }
2714 }
2715 out
2716}
2717
2718fn first_line(text: &str) -> String {
2719 text.trim().lines().next().unwrap_or("").trim().to_string()
2720}
2721
2722fn report(results: &[IssueRun], cfg: &Config, repo: &Repo) -> i32 {
2727 report_with(results, cfg, repo, &[], &[])
2728}
2729
2730fn report_with(
2737 results: &[IssueRun],
2738 cfg: &Config,
2739 repo: &Repo,
2740 stopped: &[String],
2741 parked: &[String],
2742) -> i32 {
2743 let spent = spend::taken();
2746 if let Err(e) = repo.record_spend(&spent) {
2747 logdim!("could not record what this run spent: {e}");
2748 }
2749 println!("\n{}", "=".repeat(60));
2750 for r in results {
2751 print_outcome(r, &spent);
2752 }
2753 println!("{}", "=".repeat(60));
2754
2755 if cfg.alternate_first && results.len() > 1 {
2756 println!(
2757 "\nfirst implementor alternated, starting with {}. Who wrote each first draft is in \
2758 .spar/state/implementors.json.",
2759 cfg.first_implementor
2760 );
2761 }
2762 if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
2763 println!("\nApproved PRs are waiting on you to merge.");
2764 }
2765 let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
2766 if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
2767 println!(
2768 "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
2769 Set followups = \"issues\" to file them."
2770 );
2771 }
2772 if let Some(summary) = spend::summary(&spent) {
2773 println!("\n{summary}");
2774 }
2775 for lines in [parked, stopped] {
2776 if lines.is_empty() {
2777 continue;
2778 }
2779 println!();
2780 for line in lines {
2781 println!("{line}");
2782 }
2783 }
2784 let issue_exit = i32::from(!results.iter().all(IssueRun::succeeded) || !stopped.is_empty());
2785 issue_exit.max(report_writes(repo))
2786}
2787
2788pub(crate) fn print_outcome(r: &IssueRun, spent: &[spend::Spent]) {
2790 println!(
2791 "#{:<5} {:<10} rounds={} {}",
2792 r.issue,
2793 r.status.to_string(),
2794 r.rounds,
2795 r.pr.as_deref().unwrap_or("")
2796 );
2797 for note in &r.notes {
2798 println!(" {}", first_line(note));
2799 }
2800 for url in &r.filed {
2801 println!(" filed {url}");
2802 }
2803 for dispute in &r.disputes {
2804 println!(
2805 " disputed: {}",
2806 report_item(&dispute.title, &dispute.file)
2807 );
2808 }
2809 for finding in &r.noted {
2810 println!(
2811 " noted, not blocking: {}",
2812 report_item(&finding.title, &finding.file)
2813 );
2814 }
2815 if let Some(cost) = spend::for_subject(spent, r.issue) {
2816 println!(" {cost}");
2817 }
2818}
2819
2820pub(crate) fn report_writes(repo: &Repo) -> i32 {
2825 let writes = repo.write_summary();
2826 if let Some(line) = write_summary_line(writes) {
2827 println!("\n{line}");
2828 }
2829 write_exit_code(writes)
2830}
2831
2832fn write_summary_line(writes: WriteSummary) -> Option<String> {
2833 (writes.attempted > 0).then(|| {
2834 format!(
2835 "writes: {} attempted, {} succeeded, {} failed",
2836 writes.attempted,
2837 writes.succeeded(),
2838 writes.failed
2839 )
2840 })
2841}
2842
2843fn write_exit_code(writes: WriteSummary) -> i32 {
2844 i32::from(writes.failed > 0)
2845}
2846
2847fn report_item(title: &str, file: &str) -> String {
2848 match file.trim() {
2849 "" => title.to_string(),
2850 location => format!("{title} ({location})"),
2851 }
2852}
2853
2854#[cfg(test)]
2855mod tests {
2856 use super::*;
2857 use clap::CommandFactory;
2858
2859 fn planned(issue: i64, depends_on: Vec<i64>) -> PlanItem {
2860 PlanItem {
2861 issue,
2862 title: format!("issue {issue}"),
2863 complexity: crate::model::Complexity::S,
2864 risk: crate::model::Risk::Low,
2865 depends_on,
2866 reason: "worth doing".into(),
2867 }
2868 }
2869
2870 #[test]
2879 fn a_dependent_waits_for_a_dependency_that_has_not_landed() {
2880 let plan = Plan {
2881 order: vec![planned(9, vec![]), planned(10, vec![9])],
2882 ..Plan::default()
2883 };
2884 let mut landed = BTreeSet::new();
2885
2886 let why = held_back(&plan.order[1], &plan, &landed).expect("held");
2887 assert!(why.contains("#9"), "{why}");
2888 assert!(why.contains("has not merged"), "{why}");
2889 assert!(held_back(&plan.order[0], &plan, &landed).is_none());
2890
2891 landed.insert(9);
2893 assert!(held_back(&plan.order[1], &plan, &landed).is_none());
2894 }
2895
2896 #[test]
2899 fn a_dependent_on_a_declined_or_contested_issue_is_held_with_the_reason() {
2900 let declined = Plan {
2901 order: vec![planned(10, vec![9])],
2902 skipped: vec![crate::model::SkippedItem {
2903 issue: 9,
2904 title: "issue 9".into(),
2905 reasons: Default::default(),
2906 tracker: false,
2907 }],
2908 ..Plan::default()
2909 };
2910 let why = held_back(&declined.order[0], &declined, &BTreeSet::new()).expect("held");
2911 assert!(why.contains("declined by both reviewers"), "{why}");
2912
2913 let contested = Plan {
2914 order: vec![planned(10, vec![9])],
2915 contested: vec![crate::model::ContestedItem {
2916 issue: 9,
2917 title: "issue 9".into(),
2918 positions: Default::default(),
2919 reasons: Default::default(),
2920 note: None,
2921 }],
2922 ..Plan::default()
2923 };
2924 let why = held_back(&contested.order[0], &contested, &BTreeSet::new()).expect("held");
2925 assert!(why.contains("disagree about #9"), "{why}");
2926 }
2927
2928 #[test]
2932 fn a_dependency_outside_the_run_does_not_hold_anything() {
2933 let plan = Plan {
2934 order: vec![planned(10, vec![900])],
2935 ..Plan::default()
2936 };
2937 assert!(held_back(&plan.order[0], &plan, &BTreeSet::new()).is_none());
2938 }
2939
2940 #[test]
2946 fn doctor_says_when_a_scheduled_effort_is_not_one_the_agent_accepts() {
2947 let text = "\
2948[agents.claude]
2949preset = \"claude\"
2950
2951[agents.codex]
2952preset = \"codex\"
2953
2954[loop.effort_schedule]
2955review_1 = \"ultra\"
2956";
2957 let cfg = config::parse(text).expect("parses");
2958 let said = unknown_effort_values(&cfg);
2959 assert_eq!(1, said.len(), "{said:?}");
2960 assert!(said[0].starts_with("claude"), "{}", said[0]);
2961 assert!(said[0].contains("ultra"), "{}", said[0]);
2962 assert!(said[0].contains("review_1"), "{}", said[0]);
2963 assert!(
2964 said[0].contains("[agents.claude.effort_schedule]"),
2965 "it has to say where to put the right value: {}",
2966 said[0]
2967 );
2968
2969 let quiet = text.replace(
2971 "[loop.effort_schedule]\nreview_1 = \"ultra\"\n",
2972 "[agents.claude.effort_schedule]\nreview_1 = \"xhigh\"\n\n\
2973 [agents.codex.effort_schedule]\nreview_1 = \"ultra\"\n",
2974 );
2975 assert!(unknown_effort_values(&config::parse(&quiet).expect("parses")).is_empty());
2976 }
2977
2978 #[test]
2979 fn a_contested_issue_reads_as_both_positions_with_their_reasons() {
2980 let mut positions_map = std::collections::BTreeMap::new();
2981 positions_map.insert("claude".to_string(), "do".to_string());
2982 positions_map.insert("codex".to_string(), "skip".to_string());
2983 let mut reasons = std::collections::BTreeMap::new();
2984 reasons.insert(
2985 "claude".to_string(),
2986 "the retry path is still wrong".to_string(),
2987 );
2988 reasons.insert(
2989 "codex".to_string(),
2990 "fixed in the rewrite last month".to_string(),
2991 );
2992
2993 let line = positions(&positions_map, &reasons);
2994 assert!(line.contains("claude says do"), "{line}");
2995 assert!(line.contains("codex says skip"), "{line}");
2996 assert!(line.contains("the retry path is still wrong"), "{line}");
2997 assert!(line.contains("fixed in the rewrite"), "{line}");
2998
2999 let bare = positions(&positions_map, &Default::default());
3001 assert!(bare.contains("claude says do"), "{bare}");
3002 }
3003
3004 #[test]
3005 fn report_items_include_their_location() {
3006 assert_eq!(
3007 "Same title (src/a.rs:10)",
3008 report_item("Same title", "src/a.rs:10")
3009 );
3010 assert_eq!("General point", report_item("General point", ""));
3011 }
3012
3013 #[test]
3014 fn every_attempted_write_failing_is_a_failed_run() {
3015 let writes = WriteSummary {
3016 attempted: 3,
3017 failed: 3,
3018 };
3019
3020 assert_eq!(1, write_exit_code(writes));
3021 assert_eq!(
3022 Some("writes: 3 attempted, 0 succeeded, 3 failed".to_string()),
3023 write_summary_line(writes)
3024 );
3025 }
3026
3027 #[test]
3028 fn a_partial_write_failure_is_reported_after_the_run() {
3029 let writes = WriteSummary {
3030 attempted: 3,
3031 failed: 1,
3032 };
3033
3034 assert_eq!(1, write_exit_code(writes));
3035 assert_eq!(2, writes.succeeded());
3036 }
3037
3038 #[test]
3039 fn the_parser_is_internally_consistent() {
3040 Cli::command().debug_assert();
3041 }
3042
3043 #[test]
3044 fn quiet_is_accepted_before_or_after_the_subcommand() {
3045 for argv in [
3046 vec!["spar", "--quiet", "run", "42"],
3047 vec!["spar", "run", "42", "--quiet"],
3048 vec!["spar", "resume", "--quiet"],
3049 vec!["spar", "init", "-q"],
3050 ] {
3051 assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
3052 }
3053 assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
3054 }
3055
3056 #[test]
3057 fn several_issue_numbers_are_accepted() {
3058 let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
3059 match cli.command {
3060 Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
3061 other => panic!("{other:?}"),
3062 }
3063 }
3064
3065 #[test]
3066 fn issue_numbers_and_flags_can_be_interleaved() {
3067 let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
3068 match cli.command {
3069 Command::Run {
3070 issues, loop_flags, ..
3071 } => {
3072 assert_eq!(vec![42, 51], issues);
3073 assert!(loop_flags.auto_merge);
3074 }
3075 other => panic!("{other:?}"),
3076 }
3077 }
3078
3079 #[test]
3080 fn every_command_that_reads_a_config_accepts_one() {
3081 for argv in [
3082 vec!["spar", "run", "42"],
3083 vec!["spar", "triage"],
3084 vec!["spar", "resume"],
3085 vec!["spar", "followup"],
3086 vec!["spar", "checkin"],
3087 vec!["spar", "split"],
3088 vec!["spar", "brainstorm"],
3089 vec!["spar", "listen"],
3090 vec!["spar", "clean"],
3091 vec!["spar", "doctor"],
3092 ] {
3093 let mut full = argv.clone();
3094 full.extend(["--config", "other.toml"]);
3095 let cli = Cli::parse_from(&full);
3096 let config = match cli.command {
3097 Command::Run { common, .. }
3098 | Command::Triage { common, .. }
3099 | Command::Resume { common, .. }
3100 | Command::Followup { common, .. }
3101 | Command::Split { common, .. }
3102 | Command::Brainstorm { common, .. }
3103 | Command::Listen { common, .. }
3104 | Command::Checkin { common, .. } => common.config,
3105 Command::Clean { config, .. } | Command::Doctor { config } => config,
3106 other => panic!("{other:?}"),
3107 };
3108 assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
3109 }
3110 }
3111
3112 #[test]
3113 fn auto_merge_is_off_unless_asked_for() {
3114 let cli = Cli::parse_from(["spar", "run"]);
3115 match cli.command {
3116 Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
3117 other => panic!("{other:?}"),
3118 }
3119 }
3120
3121 #[test]
3124 fn every_command_that_reads_a_config_takes_instructions() {
3125 for argv in [
3128 vec!["spar", "run", "7", "--instructions", "Do not wait for CI."],
3129 vec![
3130 "spar",
3131 "triage",
3132 "7",
3133 "--instructions",
3134 "Do not wait for CI.",
3135 ],
3136 vec![
3137 "spar",
3138 "resume",
3139 "7",
3140 "--instructions",
3141 "Do not wait for CI.",
3142 ],
3143 vec![
3144 "spar",
3145 "review",
3146 "7",
3147 "--instructions",
3148 "Do not wait for CI.",
3149 ],
3150 vec!["spar", "followup", "--instructions", "Do not wait for CI."],
3151 vec![
3152 "spar",
3153 "checkin",
3154 "7",
3155 "--instructions",
3156 "Do not wait for CI.",
3157 ],
3158 vec![
3159 "spar",
3160 "split",
3161 "7",
3162 "--instructions",
3163 "Do not wait for CI.",
3164 ],
3165 vec![
3166 "spar",
3167 "brainstorm",
3168 "a",
3169 "subject",
3170 "--instructions",
3171 "Do not wait for CI.",
3172 ],
3173 vec![
3174 "spar",
3175 "listen",
3176 "7",
3177 "--instructions",
3178 "Do not wait for CI.",
3179 ],
3180 ] {
3181 let parsed = Cli::parse_from(&argv);
3182 let common = match parsed.command {
3183 Command::Run { common, .. }
3184 | Command::Triage { common, .. }
3185 | Command::Resume { common, .. }
3186 | Command::Review { common, .. }
3187 | Command::Followup { common, .. }
3188 | Command::Split { common, .. }
3189 | Command::Brainstorm { common, .. }
3190 | Command::Listen { common, .. }
3191 | Command::Checkin { common, .. } => common,
3192 other => panic!("{other:?}"),
3193 };
3194 assert_eq!(
3195 Some("Do not wait for CI."),
3196 common.instructions.as_deref(),
3197 "{argv:?}"
3198 );
3199 }
3200 }
3201
3202 #[test]
3203 fn the_two_close_skipped_flags_are_mutually_exclusive() {
3204 assert!(
3205 Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
3206 );
3207 }
3208
3209 #[test]
3212 fn close_skipped_is_offered_only_where_it_means_something() {
3213 assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
3214 assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
3215 assert!(Cli::try_parse_from(["spar", "followup", "--close-skipped"]).is_ok());
3217 assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
3218 assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
3219 assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
3220 }
3221
3222 #[test]
3225 fn brainstorm_takes_words_or_nothing() {
3226 match Cli::parse_from(["spar", "brainstorm", "novel", "uses", "of", "script"]).command {
3227 Command::Brainstorm { topic, ideas, .. } => {
3228 assert_eq!(vec!["novel", "uses", "of", "script"], topic);
3229 assert_eq!(5, ideas);
3230 }
3231 other => panic!("{other:?}"),
3232 }
3233 match Cli::parse_from(["spar", "brainstorm", "--ideas", "3", "--max-rounds", "2"]).command {
3234 Command::Brainstorm {
3235 topic,
3236 ideas,
3237 max_rounds,
3238 ..
3239 } => {
3240 assert!(topic.is_empty());
3241 assert_eq!(3, ideas);
3242 assert_eq!(Some(2), max_rounds);
3243 }
3244 other => panic!("{other:?}"),
3245 }
3246 }
3247
3248 #[test]
3252 fn filing_from_a_saved_session_needs_file_issues_and_writes_no_session() {
3253 assert!(Cli::try_parse_from(["spar", "brainstorm", "--from", "s.md"]).is_err());
3254 assert!(
3255 Cli::try_parse_from(["spar", "brainstorm", "--from", "s.md", "--file-issues"]).is_ok()
3256 );
3257 assert!(Cli::try_parse_from([
3258 "spar",
3259 "brainstorm",
3260 "--from",
3261 "s.md",
3262 "--file-issues",
3263 "--out",
3264 "t.md"
3265 ])
3266 .is_err());
3267 assert!(
3268 Cli::try_parse_from(["spar", "brainstorm", "x", "--out", "t.md", "--file-issues"])
3269 .is_ok()
3270 );
3271 }
3272
3273 #[test]
3276 fn followup_takes_no_numbers() {
3277 assert!(Cli::try_parse_from(["spar", "followup"]).is_ok());
3278 assert!(Cli::try_parse_from(["spar", "followup", "42"]).is_err());
3279 }
3280
3281 #[test]
3284 fn the_two_stopping_points_are_mutually_exclusive() {
3285 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only"]).is_ok());
3286 assert!(Cli::try_parse_from(["spar", "followup", "--file-only"]).is_ok());
3287 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only", "--file-only"]).is_err());
3288 }
3289
3290 #[test]
3291 fn the_close_skipped_pair_resolves_to_a_tristate() {
3292 let read = |argv: &[&str]| match Cli::parse_from(argv).command {
3293 Command::Run { triage_flags, .. } => {
3294 match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
3295 (true, _) => Some(true),
3296 (_, true) => Some(false),
3297 _ => None,
3298 }
3299 }
3300 other => panic!("{other:?}"),
3301 };
3302 assert_eq!(None, read(&["spar", "run"]));
3303 assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
3304 assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
3305 }
3306
3307 #[test]
3308 fn the_default_limit_is_twenty() {
3309 let cli = Cli::parse_from(["spar", "run"]);
3310 match cli.command {
3311 Command::Run { common, .. } => assert_eq!(20, common.limit),
3312 other => panic!("{other:?}"),
3313 }
3314 }
3315
3316 #[test]
3317 fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
3318 assert!(matches!(
3319 Cli::parse_from(["spar", "scrub-filter"]).command,
3320 Command::ScrubFilter
3321 ));
3322 let help = Cli::command().render_long_help().to_string();
3323 assert!(
3324 !help.contains("scrub-filter"),
3325 "it is plumbing, not a command"
3326 );
3327 }
3328
3329 #[test]
3330 fn review_takes_pr_numbers_and_a_dry_run() {
3331 let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
3332 match cli.command {
3333 Command::Review { items, dry_run, .. } => {
3334 assert_eq!(vec![101, 102], items);
3335 assert!(dry_run);
3336 }
3337 other => panic!("{other:?}"),
3338 }
3339 }
3340
3341 #[test]
3342 fn review_posts_unless_told_not_to() {
3343 match Cli::parse_from(["spar", "review", "101"]).command {
3344 Command::Review { dry_run, .. } => assert!(!dry_run),
3345 other => panic!("{other:?}"),
3346 }
3347 }
3348
3349 #[test]
3350 fn review_with_no_numbers_is_allowed() {
3351 match Cli::parse_from(["spar", "review"]).command {
3352 Command::Review { items, .. } => assert!(items.is_empty()),
3353 other => panic!("{other:?}"),
3354 }
3355 }
3356
3357 #[test]
3358 fn review_takes_its_own_round_budget() {
3359 match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
3360 Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
3361 other => panic!("{other:?}"),
3362 }
3363 }
3364
3365 #[test]
3366 fn checkin_takes_pr_numbers_and_a_dry_run() {
3367 match Cli::parse_from(["spar", "checkin", "108", "112", "--dry-run"]).command {
3368 Command::Checkin { items, dry_run, .. } => {
3369 assert_eq!(vec![108, 112], items);
3370 assert!(dry_run);
3371 }
3372 other => panic!("{other:?}"),
3373 }
3374 match Cli::parse_from(["spar", "checkin"]).command {
3375 Command::Checkin { items, dry_run, .. } => {
3376 assert!(items.is_empty());
3377 assert!(!dry_run);
3378 }
3379 other => panic!("{other:?}"),
3380 }
3381 }
3382
3383 #[test]
3389 fn checkin_offers_no_flag_that_would_weaken_the_pair() {
3390 assert!(Cli::try_parse_from(["spar", "checkin", "--auto-merge"]).is_err());
3391 assert!(Cli::try_parse_from(["spar", "checkin", "--max-rounds", "1"]).is_err());
3392 assert!(Cli::try_parse_from(["spar", "checkin", "--absorb", "1"]).is_err());
3393 assert!(Cli::try_parse_from(["spar", "checkin", "--close-skipped"]).is_err());
3394 assert!(Cli::try_parse_from(["spar", "checkin", "--reply-only"]).is_ok());
3396 assert!(Cli::try_parse_from(["spar", "checkin", "--any-author"]).is_ok());
3397 assert!(Cli::try_parse_from(["spar", "checkin", "--again"]).is_ok());
3398 assert!(Cli::try_parse_from(["spar", "checkin", "--keep-worktrees"]).is_ok());
3399 }
3400
3401 #[test]
3402 fn review_takes_verdict_and_recheck_flags_and_neither_by_default() {
3403 match Cli::parse_from(["spar", "review", "108", "--verdict", "--recheck"]).command {
3404 Command::Review {
3405 items,
3406 verdict,
3407 recheck,
3408 ..
3409 } => {
3410 assert_eq!(vec![108], items);
3411 assert!(verdict);
3412 assert!(recheck);
3413 }
3414 other => panic!("{other:?}"),
3415 }
3416 match Cli::parse_from(["spar", "review", "108"]).command {
3417 Command::Review {
3418 verdict, recheck, ..
3419 } => {
3420 assert!(!verdict);
3421 assert!(!recheck);
3422 }
3423 other => panic!("{other:?}"),
3424 }
3425 assert!(Cli::try_parse_from(["spar", "checkin", "108", "--verdict"]).is_err());
3426 }
3427
3428 #[test]
3429 fn listen_takes_numbers_once_dry_run_and_since() {
3430 match Cli::parse_from([
3431 "spar",
3432 "listen",
3433 "108",
3434 "42",
3435 "--once",
3436 "--dry-run",
3437 "--since",
3438 "2h",
3439 ])
3440 .command
3441 {
3442 Command::Listen {
3443 items,
3444 once,
3445 dry_run,
3446 since,
3447 ..
3448 } => {
3449 assert_eq!(vec![108, 42], items);
3450 assert!(once);
3451 assert!(dry_run);
3452 assert_eq!(Some("2h".to_string()), since);
3453 }
3454 other => panic!("{other:?}"),
3455 }
3456 match Cli::parse_from(["spar", "listen"]).command {
3457 Command::Listen {
3458 items,
3459 once,
3460 dry_run,
3461 since,
3462 common,
3463 } => {
3464 assert!(items.is_empty());
3465 assert!(!once);
3466 assert!(!dry_run);
3467 assert_eq!(None, since);
3468 assert_eq!(20, common.limit);
3469 }
3470 other => panic!("{other:?}"),
3471 }
3472 }
3473
3474 #[test]
3475 fn resume_takes_a_next_override() {
3476 let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
3477 match cli.command {
3478 Command::Resume {
3479 prs, next_actor, ..
3480 } => {
3481 assert_eq!(vec![108], prs);
3482 assert_eq!(Some("codex".to_string()), next_actor);
3483 }
3484 other => panic!("{other:?}"),
3485 }
3486 }
3487}
3488
3489#[cfg(test)]
3490mod absorb_tests {
3491 use super::*;
3492
3493 #[test]
3494 fn absorb_is_off_unless_asked_for() {
3495 match Cli::parse_from(["spar", "run"]).command {
3496 Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
3497 other => panic!("{other:?}"),
3498 }
3499 }
3500
3501 #[test]
3502 fn absorb_takes_a_wave_count() {
3503 match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
3504 Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
3505 other => panic!("{other:?}"),
3506 }
3507 }
3508
3509 #[test]
3510 fn absorb_is_only_offered_where_issues_are_worked() {
3511 assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
3512 assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
3513 assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
3514 assert!(Cli::try_parse_from(["spar", "split", "--absorb", "1"]).is_err());
3517 }
3518}
3519
3520#[cfg(test)]
3521mod split_tests {
3522 use super::*;
3523
3524 #[test]
3525 fn split_takes_numbers_of_either_kind() {
3526 match Cli::parse_from(["spar", "split", "10", "12"]).command {
3527 Command::Split { items, .. } => assert_eq!(vec![10, 12], items),
3528 other => panic!("{other:?}"),
3529 }
3530 }
3531
3532 #[test]
3535 fn split_with_no_numbers_is_allowed() {
3536 match Cli::parse_from(["spar", "split"]).command {
3537 Command::Split {
3538 items,
3539 dry_run,
3540 again,
3541 ..
3542 } => {
3543 assert!(items.is_empty());
3544 assert!(!dry_run);
3545 assert!(!again);
3546 }
3547 other => panic!("{other:?}"),
3548 }
3549 }
3550
3551 #[test]
3555 fn split_can_be_told_to_write_nothing() {
3556 match Cli::parse_from(["spar", "split", "10", "--dry-run"]).command {
3557 Command::Split { dry_run, .. } => assert!(dry_run),
3558 other => panic!("{other:?}"),
3559 }
3560 }
3561
3562 #[test]
3565 fn split_can_be_told_to_split_something_it_already_split() {
3566 match Cli::parse_from(["spar", "split", "10", "--again"]).command {
3567 Command::Split { again, .. } => assert!(again),
3568 other => panic!("{other:?}"),
3569 }
3570 }
3571
3572 #[test]
3575 fn split_takes_the_flags_that_govern_picking() {
3576 match Cli::parse_from([
3577 "spar",
3578 "split",
3579 "--limit",
3580 "5",
3581 "--min-number",
3582 "480",
3583 "--config",
3584 "other.toml",
3585 ])
3586 .command
3587 {
3588 Command::Split { common, .. } => {
3589 assert_eq!(5, common.limit);
3590 assert_eq!(Some(480), common.min_number);
3591 assert_eq!(Some(PathBuf::from("other.toml")), common.config);
3592 }
3593 other => panic!("{other:?}"),
3594 }
3595 }
3596
3597 #[test]
3600 fn split_offers_no_flag_that_would_make_it_a_second_run() {
3601 for flag in [
3602 vec!["--auto-merge"],
3603 vec!["--max-rounds", "2"],
3604 vec!["--close-skipped"],
3605 vec!["--no-close-skipped"],
3606 vec!["--keep-worktrees"],
3607 ] {
3608 let mut argv = vec!["spar", "split", "10"];
3609 argv.extend(flag.iter().copied());
3610 assert!(Cli::try_parse_from(&argv).is_err(), "{argv:?}");
3611 }
3612 }
3613}
3614
3615#[cfg(test)]
3616mod min_number_tests {
3617 use super::*;
3618
3619 fn read(argv: &[&str]) -> Option<i64> {
3620 match Cli::parse_from(argv).command {
3621 Command::Run { common, .. }
3622 | Command::Triage { common, .. }
3623 | Command::Resume { common, .. }
3624 | Command::Review { common, .. }
3625 | Command::Followup { common, .. }
3626 | Command::Split { common, .. }
3627 | Command::Listen { common, .. }
3628 | Command::Checkin { common, .. } => common.min_number,
3629 other => panic!("{other:?}"),
3630 }
3631 }
3632
3633 #[test]
3634 fn there_is_no_floor_unless_one_is_asked_for() {
3635 assert_eq!(None, read(&["spar", "run"]));
3636 }
3637
3638 #[test]
3639 fn every_command_that_picks_for_itself_accepts_a_floor() {
3640 for cmd in [
3641 "run", "triage", "resume", "review", "checkin", "split", "listen",
3642 ] {
3643 assert_eq!(
3644 Some(480),
3645 read(&["spar", cmd, "--min-number", "480"]),
3646 "{cmd}"
3647 );
3648 }
3649 }
3650}
3651
3652#[cfg(test)]
3653mod run_targets_tests {
3654 use super::*;
3655
3656 fn covered(pairs: &[(i64, i64)]) -> BTreeMap<i64, i64> {
3657 pairs.iter().copied().collect()
3658 }
3659
3660 #[test]
3665 fn an_issue_its_named_pull_request_closes_is_not_worked_again() {
3666 let kept = issues_not_on_named_prs(&[772, 783, 784], &covered(&[(772, 786)]));
3667
3668 assert_eq!(vec![783, 784], kept);
3669 }
3670
3671 #[test]
3672 fn an_issue_no_named_pull_request_closes_is_still_worked() {
3673 let kept = issues_not_on_named_prs(&[783, 784, 771], &covered(&[(772, 786)]));
3674
3675 assert_eq!(vec![783, 784, 771], kept);
3676 }
3677
3678 #[test]
3681 fn an_unreadable_pull_request_drops_no_issue() {
3682 let kept = issues_not_on_named_prs(&[772, 783], &covered(&[]));
3683
3684 assert_eq!(vec![772, 783], kept);
3685 }
3686
3687 #[test]
3689 fn every_issue_one_pull_request_closes_is_taken_by_it() {
3690 let kept = issues_not_on_named_prs(&[771, 772, 783], &covered(&[(771, 786), (772, 786)]));
3691
3692 assert_eq!(vec![783], kept);
3693 }
3694}
3695
3696#[cfg(test)]
3697mod code_tests {
3698 use super::*;
3699
3700 #[test]
3701 fn code_requires_a_prompt_and_exclusive_preview_modes() {
3702 assert!(Cli::try_parse_from(["spar", "code"]).is_err());
3703 assert!(
3704 Cli::try_parse_from(["spar", "code", "Add export", "--dry-run", "--file-only"])
3705 .is_err()
3706 );
3707 assert!(Cli::try_parse_from(["spar", "code", "Add export", "--dry-run"]).is_ok());
3708 assert!(Cli::try_parse_from(["spar", "code", "Add export", "--file-only"]).is_ok());
3709 }
3710
3711 #[test]
3712 fn code_takes_words_and_the_normal_run_options() {
3713 let cli = Cli::parse_from([
3714 "spar",
3715 "code",
3716 "Add",
3717 "CSV",
3718 "export",
3719 "--repo",
3720 "/tmp/project",
3721 "--config",
3722 "project.toml",
3723 "--first",
3724 "b",
3725 "--base",
3726 "develop",
3727 "--instructions",
3728 "Keep the format stable",
3729 "--max-rounds",
3730 "5",
3731 "--auto-merge",
3732 "--absorb",
3733 "1",
3734 "--keep-worktrees",
3735 "--no-worktrees",
3736 "--no-close-skipped",
3737 "--retriage",
3738 "--plan-out",
3739 "code-plan.json",
3740 ]);
3741 match cli.command {
3742 Command::Code {
3743 prompt,
3744 common,
3745 loop_flags,
3746 triage_flags,
3747 plan_out,
3748 no_worktrees,
3749 dry_run,
3750 file_only,
3751 } => {
3752 assert_eq!(prompt, ["Add", "CSV", "export"]);
3753 assert_eq!(common.repo, PathBuf::from("/tmp/project"));
3754 assert_eq!(common.config, Some(PathBuf::from("project.toml")));
3755 assert_eq!(common.first.as_deref(), Some("b"));
3756 assert_eq!(common.base.as_deref(), Some("develop"));
3757 assert_eq!(
3758 common.instructions.as_deref(),
3759 Some("Keep the format stable")
3760 );
3761 assert_eq!(loop_flags.max_rounds, Some(5));
3762 assert_eq!(loop_flags.absorb, Some(1));
3763 assert!(loop_flags.auto_merge && loop_flags.keep_worktrees);
3764 assert!(triage_flags.no_close_skipped && triage_flags.retriage);
3765 assert_eq!(plan_out, PathBuf::from("code-plan.json"));
3766 assert!(no_worktrees);
3767 assert!(!dry_run && !file_only);
3768 }
3769 other => panic!("{other:?}"),
3770 }
3771 }
3772}
3773
3774#[cfg(test)]
3775mod settings_block_tests {
3776 use super::*;
3777
3778 fn written(line: &str) -> String {
3781 let after = line.split_once('=').expect("an assignment").1;
3782 let mut quoted = false;
3783 for (i, c) in after.char_indices() {
3784 match c {
3785 '"' => quoted = !quoted,
3786 '#' if !quoted => return after[..i].trim().to_string(),
3787 _ => {}
3788 }
3789 }
3790 after.trim().to_string()
3791 }
3792
3793 fn line_for(text: &str, key: &str) -> String {
3794 text.lines()
3795 .find(|l| {
3796 let bare = l.trim_start().trim_start_matches('#').trim_start();
3797 bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
3798 })
3799 .unwrap_or_else(|| panic!("{key} is not offered at all:\n{text}"))
3800 .to_string()
3801 }
3802
3803 #[test]
3810 fn every_value_it_offers_is_the_default_it_actually_has() {
3811 let text = settings_block("claude");
3812 for option in config::known_options() {
3813 if option.section == "loop.effort_schedule" {
3817 continue;
3818 }
3819 let line = line_for(&text, &option.key);
3820 assert_eq!(
3821 option.default,
3822 written(&line),
3823 "the generated config offers `{}`, but the default is {}",
3824 line.trim(),
3825 option.default
3826 );
3827 }
3828 }
3829
3830 #[test]
3834 fn it_offers_every_option_the_parser_knows_about() {
3835 let text = settings_block("claude");
3836 let missing: Vec<String> = config::unmentioned_options(&text)
3837 .into_iter()
3838 .map(|o| format!("[{}] {}", o.section, o.key))
3839 .collect();
3840 assert!(missing.is_empty(), "not offered: {}", missing.join(", "));
3841 }
3842
3843 #[test]
3847 fn every_option_it_offers_can_be_uncommented_and_still_load() {
3848 let mut text = String::from(
3849 "[agents.claude]\ncommand = [\"claude\"]\n\n\
3850 [agents.codex]\ncommand = [\"codex\"]\n\n",
3851 );
3852 for line in settings_block("claude").lines() {
3853 text.push_str(uncomment(line).unwrap_or(line));
3854 text.push('\n');
3855 }
3856 let cfg = config::parse(&text).expect("a config of its own suggestions");
3857 assert_eq!("claude", cfg.first_implementor);
3858 }
3859
3860 fn uncomment(line: &str) -> Option<&str> {
3863 let bare = line.trim_start().strip_prefix('#')?.trim_start();
3864 let key = bare.split_once('=')?.0.trim();
3867 let named = !key.is_empty()
3868 && key
3869 .chars()
3870 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
3871 named.then_some(bare)
3872 }
3873
3874 #[test]
3875 fn the_agent_that_goes_first_is_the_one_that_was_chosen() {
3876 assert!(settings_block("codex").contains("first_implementor = \"codex\""));
3877 }
3878
3879 #[test]
3884 fn a_note_sits_above_the_option_it_describes() {
3885 let text = settings_block("claude");
3886 assert!(
3887 text.lines().all(|l| l.chars().count() <= 80),
3888 "a line runs off the edge:\n{text}"
3889 );
3890 assert!(
3891 text.lines().all(|l| !l.starts_with(' ')),
3892 "a line is indented, so the columns are back:\n{text}"
3893 );
3894
3895 let lines: Vec<&str> = text.lines().collect();
3897 let at = lines
3898 .iter()
3899 .position(|l| l.starts_with("max_rounds"))
3900 .expect("max_rounds");
3901 assert!(lines[at - 1].starts_with('#'), "{:?}", lines[at - 1]);
3902 assert!(
3903 lines[at - 1].contains("lifetime cap"),
3904 "the note above it is the end of its own note: {:?}",
3905 lines[at - 1]
3906 );
3907 }
3908
3909 #[test]
3911 fn every_note_starts_as_a_sentence() {
3912 for (_, key, note) in LOOP_OPTIONS
3913 .iter()
3914 .chain(STYLE_OPTIONS)
3915 .chain(LISTEN_OPTIONS)
3916 {
3917 let first = note.chars().next().expect("a note");
3918 assert!(
3919 first.is_uppercase(),
3920 "{key} reads as a margin scribble rather than a sentence: {note}"
3921 );
3922 }
3923 }
3924}
3925
3926#[cfg(test)]
3927mod agent_block_tests {
3928 use super::*;
3929
3930 fn spec(models: &[&str], efforts: &[&str]) -> config::AgentSpec {
3931 let mut spec: config::AgentSpec =
3932 toml::Value::Table(toml::from_str("command = [\"x\"]").expect("a minimal preset"))
3933 .try_into()
3934 .expect("builds");
3935 spec.models = models.iter().map(|s| s.to_string()).collect();
3936 spec.efforts = efforts.iter().map(|s| s.to_string()).collect();
3937 spec
3938 }
3939
3940 #[test]
3944 fn an_option_with_no_hints_is_left_out_rather_than_guessed_at() {
3945 let block = agent_block("cursor", &spec(&["composer-2.5", "auto"], &[]));
3946 assert!(!block.contains("..."), "{block}");
3947 assert!(!block.contains("effort"), "{block}");
3948 assert!(block.contains("# model = \"composer-2.5\""), "{block}");
3949 }
3950
3951 #[test]
3954 fn only_the_options_that_follow_are_introduced() {
3955 let model_only = agent_block("cursor", &spec(&["auto"], &[]));
3956 assert!(model_only.contains("Omit model to use"), "{model_only}");
3957 assert!(!model_only.contains("Omit effort"), "{model_only}");
3958
3959 let both = agent_block("claude", &spec(&["fable"], &["high"]));
3960 assert!(both.contains("Omit model to use"), "{both}");
3961 assert!(both.contains("Omit effort to use"), "{both}");
3962 }
3963
3964 #[test]
3967 fn a_preset_with_no_hints_still_writes_a_usable_block() {
3968 let block = agent_block("gemini", &spec(&[], &[]));
3969 assert!(!block.contains("..."), "{block}");
3970 assert!(!block.contains("Omit"), "{block}");
3971 assert!(
3972 block.starts_with("[agents.gemini]\npreset = \"gemini\"\n"),
3973 "{block}"
3974 );
3975 assert!(block.contains("[agents.gemini.fallback]"), "{block}");
3977 assert!(block.contains("# timeout = "), "{block}");
3978 }
3979
3980 #[test]
3983 fn the_timeout_offered_is_the_one_the_agent_would_use() {
3984 let mut spec = spec(&["a"], &[]);
3985 spec.timeout = 7200;
3986 assert!(
3987 agent_block("custom", &spec).contains("# timeout = 7200"),
3988 "the generator kept its own copy"
3989 );
3990 }
3991
3992 #[test]
3994 fn alternatives_are_listed_only_when_there_are_any() {
3995 assert!(agent_block("a", &spec(&["one", "two"], &[])).contains("One of: one | two."));
3996 let single = agent_block("b", &spec(&["only"], &[]));
3997 assert!(!single.contains("One of:"), "{single}");
3998 }
3999
4000 #[test]
4004 fn the_presets_note_is_said_once() {
4005 let mut spec = spec(&["m1", "m2"], &["e1", "e2"]);
4006 spec.options_note = Some("Check the current sets with: mytool --help".into());
4007 let block = agent_block("mytool", &spec);
4008 assert_eq!(
4009 1,
4010 block.matches("Check the current sets").count(),
4011 "{block}"
4012 );
4013 }
4014}