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