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, "setup", "A command spar runs in every worktree it creates, before any agent works there or the check runs, as an argv list or a string split on whitespace: `npm ci`, `uv sync`, `bundle install`. Unset runs nothing. A worktree holds only what git tracks, so without this a project's dependencies are there only if an agent happens to install them. A failure stops that issue before any agent call, with the output, and the next run rebuilds the worktree. The shared checkout is never touched."),
2155 (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."),
2156 (true, "keep_worktrees", "Keep worktrees after a run, for inspection."),
2157 (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."),
2158 (true, "parallel_triage", "Ask both agents to triage at once. They only read during triage, so there is nothing to serialise."),
2159 (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."),
2160 (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."),
2161 (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."),
2162 (true, "file_nits", "File nits as follow-ups too. Off, because a filed nit is somebody else's notification."),
2163 (true, "base_branch", "Only a fallback. Whatever origin/HEAD points at wins when it resolves."),
2164 (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."),
2165 (true, "state_store", "Where resume state is kept. local uses .spar/state and keeps it off the pull request."),
2166 (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."),
2167 (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."),
2168 (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."),
2169 (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."),
2170 (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."),
2171 (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."),
2172 (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."),
2173 (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."),
2174];
2175
2176const STYLE_OPTIONS: &[Setting] = &[
2177 (false, "ban_em_dash", "Strip em-dashes and en-dashes from everything spar posts, then refuse to post text that still has one."),
2178 (false, "ban_ai_attribution", "Strip mentions of the tooling, and Co-Authored-By trailers, from everything spar posts."),
2179 (false, "terse", "Hold model prose to a length budget. false removes the valves entirely."),
2180 (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."),
2181 (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."),
2182 (true, "max_title_chars", "A finding, issue, or pull request title. Never ellipsised: a title ending in three dots reads as broken."),
2183 (true, "max_summary_chars", "A one line verdict, or a refutation's argument."),
2184 (true, "max_detail_chars", "A blocking finding's explanation, as it appears in the pull request thread."),
2185 (true, "max_body_chars", "A pull request body."),
2186 (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."),
2187];
2188
2189const LISTEN_OPTIONS: &[Setting] = &[
2190 (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."),
2191 (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."),
2192 (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."),
2193 (true, "max_wait", "Most seconds an item may keep settling before it is acted on anyway."),
2194 (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."),
2195 (true, "on_review_requested", "What a pull request somebody asked you to review gets: review or ignore."),
2196 (true, "on_assigned", "What a pull request assigned to you gets: review or ignore."),
2197 (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."),
2198 (false, "verdicts", "Whether a review `spar listen` starts submits a formal verdict, approve or request changes, rather than a comment. A recheck always does."),
2199];
2200
2201fn settings_block(first_implementor: &str) -> String {
2206 let defaults: std::collections::BTreeMap<String, String> = config::known_options()
2207 .into_iter()
2208 .map(|option| (option.key, option.default))
2209 .collect();
2210 let value = |key: &str| match key {
2213 "first_implementor" => format!("\"{first_implementor}\""),
2214 other => defaults.get(other).cloned().unwrap_or_default(),
2215 };
2216
2217 let mut out = String::from("[loop]\n");
2218 out.push_str(&option_lines(LOOP_OPTIONS, &value));
2219 out.push_str(concat!(
2220 "\n[loop.effort_schedule]\n",
2221 "# One key per kind of call, so a value means what its name says. Values\n",
2222 "# are whatever each agent's own CLI accepts, listed above, so these are\n",
2223 "# examples rather than defaults. Left out, a call falls back to\n",
2224 "# round_1 or rest, and then to the effort the agent's own block asked\n",
2225 "# for. Where the two CLIs do not share a vocabulary, put the schedule\n",
2226 "# under [agents.NAME.effort_schedule] instead, with the same keys.\n",
2227 "# code = \"high\" # drafting an issue from a project prompt\n",
2228 "# triage = \"low\" # both agents, over the whole queue, per wave\n",
2229 "# implement = \"high\" # writing the first draft\n",
2230 "# review_1 = \"high\" # the deep first review\n",
2231 "# review_rest = \"low\" # later rounds, which see a small delta\n",
2232 "# respond = \"high\" # the author answering a review\n",
2233 "# close = \"low\" # the closing merge safety pass\n",
2234 "# screen = \"low\" # the follow-up screen, one call for the queue\n",
2235 "# checkin = \"high\" # judging comments other people left\n",
2236 "# split = \"high\" # proposing and checking a split\n",
2237 "# brainstorm = \"high\" # every brainstorm round\n",
2238 "# recheck = \"low\" # a second look after commits or replies\n",
2239 "# round_1 = \"high\" # read by any call above with no key set\n",
2240 "# rest = \"low\" # the same, for later rounds and the close\n\n",
2241 ));
2242 out.push_str("[style]\n");
2243 out.push_str(&option_lines(STYLE_OPTIONS, &value));
2244 out.push_str("\n[listen]\n");
2245 out.push_str(&option_lines(LISTEN_OPTIONS, &value));
2246 out
2247}
2248
2249fn option_lines(options: &[Setting], value: &dyn Fn(&str) -> String) -> String {
2253 let mut out = String::new();
2254 for (commented, key, note) in options {
2255 if !out.is_empty() {
2256 out.push('\n');
2257 }
2258 out.push_str(&wrap_comment(note));
2259 let lead = if *commented { "# " } else { "" };
2260 out.push_str(&format!("{lead}{key} = {}\n", value(key)));
2261 }
2262 out
2263}
2264
2265type Probe = Box<dyn Fn() -> Result<String>>;
2268
2269fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
2276 let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
2277
2278 if let Some(extra) = &spec.options_note {
2290 if !spec.models.is_empty() || !spec.efforts.is_empty() {
2291 out.push('\n');
2292 out.push_str(&wrap_comment(extra));
2293 }
2294 }
2295 for (key, choices) in [("model", &spec.models), ("effort", &spec.efforts)] {
2296 let Some(suggested) = choices.first() else {
2297 continue;
2298 };
2299 let mut note = format!("Omit {key} to use the CLI's own default.");
2300 if choices.len() > 1 {
2301 note.push_str(&format!(" One of: {}.", choices.join(" | ")));
2302 }
2303 out.push('\n');
2304 out.push_str(&wrap_comment(¬e));
2305 out.push_str(&format!("# {key} = \"{suggested}\"\n"));
2306 }
2307
2308 out.push('\n');
2312 out.push_str(&wrap_comment(
2313 "Seconds one call may take before spar gives up. A timeout costs the whole call and is \
2314 never retried, so err long.",
2315 ));
2316 out.push_str(&format!("# timeout = {}\n", spec.timeout));
2317
2318 let backup = if name == "cursor" { "gemini" } else { "cursor" };
2321 out.push('\n');
2322 out.push_str(&wrap_comment(
2323 "A stand in for when this CLI refuses, stalls, or runs out of quota. It answers in place \
2324 of this agent, never alongside it.",
2325 ));
2326 out.push_str(&format!(
2327 "# [agents.{name}.fallback]\n# preset = \"{backup}\"\n"
2328 ));
2329
2330 out.push('\n');
2335 out.push_str(&wrap_comment(
2336 "command, output, search_paths and the rest are in spar.example.toml, for pairing a CLI \
2337 that has no preset.",
2338 ));
2339 out.push('\n');
2340 out
2341}
2342
2343fn note_for(key: &str) -> &'static str {
2349 LOOP_OPTIONS
2350 .iter()
2351 .chain(STYLE_OPTIONS)
2352 .chain(LISTEN_OPTIONS)
2353 .find(|(_, name, _)| *name == key)
2354 .map(|(_, _, note)| *note)
2355 .unwrap_or("")
2356}
2357
2358fn wrap_comment(text: &str) -> String {
2360 const WIDTH: usize = 76;
2361 let mut out = String::new();
2362 let mut line = String::from("#");
2363 for word in text.split_whitespace() {
2364 if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
2365 out.push_str(&line);
2366 out.push('\n');
2367 line = String::from("#");
2368 }
2369 line.push(' ');
2370 line.push_str(word);
2371 }
2372 if line.len() > 1 {
2373 out.push_str(&line);
2374 out.push('\n');
2375 }
2376 out
2377}
2378
2379fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
2380 let mut ok = true;
2381
2382 let probes: Vec<(&str, Probe)> = vec![
2383 (
2384 "git",
2385 Box::new(|| {
2386 proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
2387 .map(|s| first_line(&s))
2388 }),
2389 ),
2390 (
2391 "gh",
2392 Box::new(|| {
2393 proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
2394 .map(|s| first_line(&s))
2395 }),
2396 ),
2397 (
2398 "gh auth",
2399 Box::new(|| {
2400 let out = proc::exec(
2401 &["gh".into(), "auth".into(), "status".into()],
2402 &ExecOpts::new().check(false).timeout_secs(60),
2403 )?;
2404 let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
2405 if out.ok() {
2406 Ok(first_line(&text))
2407 } else {
2408 Err(spar_err!("not authenticated. Run `gh auth login`."))
2409 }
2410 }),
2411 ),
2412 ];
2413
2414 for (label, probe) in probes {
2415 match probe() {
2416 Ok(detail) => println!(" ok {label:12} {detail}"),
2417 Err(e) => {
2418 println!(" FAIL {label:12} {}", e.first_line());
2419 ok = false;
2420 }
2421 }
2422 }
2423
2424 let found = config::find_config(config_path)?;
2425 let Some(path) = found else {
2426 println!("\n no spar.toml found. Run `spar init` to generate one.");
2427 println!(
2428 " presets available: {}",
2429 config::available_presets().join(", ")
2430 );
2431 return Ok(if ok { 0 } else { 1 });
2432 };
2433
2434 println!("\n config: {}", path.display());
2435 let cfg = match config::load(Some(&path)) {
2436 Ok(cfg) => cfg,
2437 Err(e) => {
2438 println!(" FAIL config {e}");
2439 return Ok(1);
2440 }
2441 };
2442
2443 let mut resolved = Vec::new();
2446 for spec in &cfg.agents {
2447 let agent = Agent::new(spec.clone());
2448 match agent.resolve_bin() {
2449 Ok(bin) => {
2450 println!(
2451 " ok {:12} {} ({})",
2452 spec.name,
2453 bin.display(),
2454 spec.describe()
2455 );
2456 report_fallback(&agent);
2457 resolved.push(agent);
2458 }
2459 Err(e) => {
2460 println!(" FAIL {:12} {}", spec.name, e.first_line());
2461 ok = false;
2462 }
2463 }
2464 }
2465
2466 if resolved.len() == cfg.agents.len() {
2467 if let Some(warning) = agent::correlation_warning(&resolved) {
2468 println!("\n WARNING {warning}");
2469 }
2470 }
2471
2472 for line in unknown_effort_values(&cfg) {
2473 println!("\n WARNING {line}");
2474 }
2475
2476 println!(
2477 "\n settings: first={} max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
2478 cfg.first_implementor_policy(),
2479 cfg.loop_cfg.max_rounds,
2480 cfg.loop_cfg.auto_merge,
2481 cfg.loop_cfg.worktrees,
2482 cfg.loop_cfg.followups,
2483 cfg.style.terse
2484 );
2485 if let Ok(text) = std::fs::read_to_string(&path) {
2489 let unset = config::unmentioned_options(&text);
2490 if !unset.is_empty() {
2491 println!(
2492 "\n {} setting(s) this config does not mention, all at their defaults:",
2493 unset.len()
2494 );
2495 for option in &unset {
2496 println!(
2497 " [{}] {} = {}",
2498 option.section, option.key, option.default
2499 );
2500 }
2501 println!(
2502 " `spar init --update {}` appends them as comments.",
2503 path.display()
2504 );
2505 }
2506 }
2507
2508 println!(
2509 "{}",
2510 if ok {
2511 "\nready"
2512 } else {
2513 "\nmissing prerequisites"
2514 }
2515 );
2516 Ok(if ok { 0 } else { 1 })
2517}
2518
2519fn unknown_effort_values(cfg: &config::Config) -> Vec<String> {
2528 let mut out = Vec::new();
2529 for spec in &cfg.agents {
2530 if spec.efforts.is_empty() {
2531 continue;
2532 }
2533 let mut said: Vec<String> = Vec::new();
2534 for call in config::Call::every() {
2535 let Some(value) = cfg.effort_for(spec, call) else {
2536 continue;
2537 };
2538 let known = spec
2539 .efforts
2540 .iter()
2541 .any(|hint| hint.eq_ignore_ascii_case(value.trim()));
2542 if known || said.contains(&value) {
2543 continue;
2544 }
2545 said.push(value.clone());
2546 out.push(format!(
2547 "{} is asked for effort \"{value}\" on the {} call, which is not one it is known \
2548 to accept ({}). Put this agent's values under \
2549 [agents.{}.effort_schedule].",
2550 spec.name,
2551 call.key(),
2552 spec.efforts.join(" | "),
2553 spec.name
2554 ));
2555 }
2556 }
2557 out
2558}
2559
2560fn first_line(text: &str) -> String {
2561 text.trim().lines().next().unwrap_or("").trim().to_string()
2562}
2563
2564fn report(results: &[IssueRun], cfg: &Config, repo: &Repo) -> i32 {
2569 report_with(results, cfg, repo, &[], &[])
2570}
2571
2572fn report_with(
2579 results: &[IssueRun],
2580 cfg: &Config,
2581 repo: &Repo,
2582 stopped: &[String],
2583 parked: &[String],
2584) -> i32 {
2585 let spent = spend::taken();
2588 if let Err(e) = repo.record_spend(&spent) {
2589 logdim!("could not record what this run spent: {e}");
2590 }
2591 println!("\n{}", "=".repeat(60));
2592 for r in results {
2593 print_outcome(r, &spent);
2594 }
2595 println!("{}", "=".repeat(60));
2596
2597 if cfg.alternate_first && results.len() > 1 {
2598 println!(
2599 "\nfirst implementor alternated, starting with {}. Who wrote each first draft is in \
2600 .spar/state/implementors.json.",
2601 cfg.first_implementor
2602 );
2603 }
2604 if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
2605 println!("\nApproved PRs are waiting on you to merge.");
2606 }
2607 let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
2608 if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
2609 println!(
2610 "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
2611 Set followups = \"issues\" to file them."
2612 );
2613 }
2614 if let Some(summary) = spend::summary(&spent) {
2615 println!("\n{summary}");
2616 }
2617 for lines in [parked, stopped] {
2618 if lines.is_empty() {
2619 continue;
2620 }
2621 println!();
2622 for line in lines {
2623 println!("{line}");
2624 }
2625 }
2626 let issue_exit = i32::from(!results.iter().all(IssueRun::succeeded) || !stopped.is_empty());
2627 issue_exit.max(report_writes(repo))
2628}
2629
2630pub(crate) fn print_outcome(r: &IssueRun, spent: &[spend::Spent]) {
2632 println!(
2633 "#{:<5} {:<10} rounds={} {}",
2634 r.issue,
2635 r.status.to_string(),
2636 r.rounds,
2637 r.pr.as_deref().unwrap_or("")
2638 );
2639 for note in &r.notes {
2640 println!(" {}", first_line(note));
2641 }
2642 for url in &r.filed {
2643 println!(" filed {url}");
2644 }
2645 for dispute in &r.disputes {
2646 println!(
2647 " disputed: {}",
2648 report_item(&dispute.title, &dispute.file)
2649 );
2650 }
2651 for finding in &r.noted {
2652 println!(
2653 " noted, not blocking: {}",
2654 report_item(&finding.title, &finding.file)
2655 );
2656 }
2657 if let Some(cost) = spend::for_subject(spent, r.issue) {
2658 println!(" {cost}");
2659 }
2660}
2661
2662pub(crate) fn report_writes(repo: &Repo) -> i32 {
2667 let writes = repo.write_summary();
2668 if let Some(line) = write_summary_line(writes) {
2669 println!("\n{line}");
2670 }
2671 write_exit_code(writes)
2672}
2673
2674fn write_summary_line(writes: WriteSummary) -> Option<String> {
2675 (writes.attempted > 0).then(|| {
2676 format!(
2677 "writes: {} attempted, {} succeeded, {} failed",
2678 writes.attempted,
2679 writes.succeeded(),
2680 writes.failed
2681 )
2682 })
2683}
2684
2685fn write_exit_code(writes: WriteSummary) -> i32 {
2686 i32::from(writes.failed > 0)
2687}
2688
2689fn report_item(title: &str, file: &str) -> String {
2690 match file.trim() {
2691 "" => title.to_string(),
2692 location => format!("{title} ({location})"),
2693 }
2694}
2695
2696#[cfg(test)]
2697mod tests {
2698 use super::*;
2699 use clap::CommandFactory;
2700
2701 fn planned(issue: i64, depends_on: Vec<i64>) -> PlanItem {
2702 PlanItem {
2703 issue,
2704 title: format!("issue {issue}"),
2705 complexity: crate::model::Complexity::S,
2706 risk: crate::model::Risk::Low,
2707 depends_on,
2708 reason: "worth doing".into(),
2709 }
2710 }
2711
2712 #[test]
2721 fn a_dependent_waits_for_a_dependency_that_has_not_landed() {
2722 let plan = Plan {
2723 order: vec![planned(9, vec![]), planned(10, vec![9])],
2724 ..Plan::default()
2725 };
2726 let mut landed = BTreeSet::new();
2727
2728 let why = held_back(&plan.order[1], &plan, &landed).expect("held");
2729 assert!(why.contains("#9"), "{why}");
2730 assert!(why.contains("has not merged"), "{why}");
2731 assert!(held_back(&plan.order[0], &plan, &landed).is_none());
2732
2733 landed.insert(9);
2735 assert!(held_back(&plan.order[1], &plan, &landed).is_none());
2736 }
2737
2738 #[test]
2741 fn a_dependent_on_a_declined_or_contested_issue_is_held_with_the_reason() {
2742 let declined = Plan {
2743 order: vec![planned(10, vec![9])],
2744 skipped: vec![crate::model::SkippedItem {
2745 issue: 9,
2746 title: "issue 9".into(),
2747 reasons: Default::default(),
2748 tracker: false,
2749 }],
2750 ..Plan::default()
2751 };
2752 let why = held_back(&declined.order[0], &declined, &BTreeSet::new()).expect("held");
2753 assert!(why.contains("declined by both reviewers"), "{why}");
2754
2755 let contested = Plan {
2756 order: vec![planned(10, vec![9])],
2757 contested: vec![crate::model::ContestedItem {
2758 issue: 9,
2759 title: "issue 9".into(),
2760 positions: Default::default(),
2761 reasons: Default::default(),
2762 note: None,
2763 }],
2764 ..Plan::default()
2765 };
2766 let why = held_back(&contested.order[0], &contested, &BTreeSet::new()).expect("held");
2767 assert!(why.contains("disagree about #9"), "{why}");
2768 }
2769
2770 #[test]
2774 fn a_dependency_outside_the_run_does_not_hold_anything() {
2775 let plan = Plan {
2776 order: vec![planned(10, vec![900])],
2777 ..Plan::default()
2778 };
2779 assert!(held_back(&plan.order[0], &plan, &BTreeSet::new()).is_none());
2780 }
2781
2782 #[test]
2788 fn doctor_says_when_a_scheduled_effort_is_not_one_the_agent_accepts() {
2789 let text = "\
2790[agents.claude]
2791preset = \"claude\"
2792
2793[agents.codex]
2794preset = \"codex\"
2795
2796[loop.effort_schedule]
2797review_1 = \"ultra\"
2798";
2799 let cfg = config::parse(text).expect("parses");
2800 let said = unknown_effort_values(&cfg);
2801 assert_eq!(1, said.len(), "{said:?}");
2802 assert!(said[0].starts_with("claude"), "{}", said[0]);
2803 assert!(said[0].contains("ultra"), "{}", said[0]);
2804 assert!(said[0].contains("review_1"), "{}", said[0]);
2805 assert!(
2806 said[0].contains("[agents.claude.effort_schedule]"),
2807 "it has to say where to put the right value: {}",
2808 said[0]
2809 );
2810
2811 let quiet = text.replace(
2813 "[loop.effort_schedule]\nreview_1 = \"ultra\"\n",
2814 "[agents.claude.effort_schedule]\nreview_1 = \"xhigh\"\n\n\
2815 [agents.codex.effort_schedule]\nreview_1 = \"ultra\"\n",
2816 );
2817 assert!(unknown_effort_values(&config::parse(&quiet).expect("parses")).is_empty());
2818 }
2819
2820 #[test]
2821 fn a_contested_issue_reads_as_both_positions_with_their_reasons() {
2822 let mut positions_map = std::collections::BTreeMap::new();
2823 positions_map.insert("claude".to_string(), "do".to_string());
2824 positions_map.insert("codex".to_string(), "skip".to_string());
2825 let mut reasons = std::collections::BTreeMap::new();
2826 reasons.insert(
2827 "claude".to_string(),
2828 "the retry path is still wrong".to_string(),
2829 );
2830 reasons.insert(
2831 "codex".to_string(),
2832 "fixed in the rewrite last month".to_string(),
2833 );
2834
2835 let line = positions(&positions_map, &reasons);
2836 assert!(line.contains("claude says do"), "{line}");
2837 assert!(line.contains("codex says skip"), "{line}");
2838 assert!(line.contains("the retry path is still wrong"), "{line}");
2839 assert!(line.contains("fixed in the rewrite"), "{line}");
2840
2841 let bare = positions(&positions_map, &Default::default());
2843 assert!(bare.contains("claude says do"), "{bare}");
2844 }
2845
2846 #[test]
2847 fn report_items_include_their_location() {
2848 assert_eq!(
2849 "Same title (src/a.rs:10)",
2850 report_item("Same title", "src/a.rs:10")
2851 );
2852 assert_eq!("General point", report_item("General point", ""));
2853 }
2854
2855 #[test]
2856 fn every_attempted_write_failing_is_a_failed_run() {
2857 let writes = WriteSummary {
2858 attempted: 3,
2859 failed: 3,
2860 };
2861
2862 assert_eq!(1, write_exit_code(writes));
2863 assert_eq!(
2864 Some("writes: 3 attempted, 0 succeeded, 3 failed".to_string()),
2865 write_summary_line(writes)
2866 );
2867 }
2868
2869 #[test]
2870 fn a_partial_write_failure_is_reported_after_the_run() {
2871 let writes = WriteSummary {
2872 attempted: 3,
2873 failed: 1,
2874 };
2875
2876 assert_eq!(1, write_exit_code(writes));
2877 assert_eq!(2, writes.succeeded());
2878 }
2879
2880 #[test]
2881 fn the_parser_is_internally_consistent() {
2882 Cli::command().debug_assert();
2883 }
2884
2885 #[test]
2886 fn quiet_is_accepted_before_or_after_the_subcommand() {
2887 for argv in [
2888 vec!["spar", "--quiet", "run", "42"],
2889 vec!["spar", "run", "42", "--quiet"],
2890 vec!["spar", "resume", "--quiet"],
2891 vec!["spar", "init", "-q"],
2892 ] {
2893 assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
2894 }
2895 assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
2896 }
2897
2898 #[test]
2899 fn several_issue_numbers_are_accepted() {
2900 let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
2901 match cli.command {
2902 Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
2903 other => panic!("{other:?}"),
2904 }
2905 }
2906
2907 #[test]
2908 fn issue_numbers_and_flags_can_be_interleaved() {
2909 let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
2910 match cli.command {
2911 Command::Run {
2912 issues, loop_flags, ..
2913 } => {
2914 assert_eq!(vec![42, 51], issues);
2915 assert!(loop_flags.auto_merge);
2916 }
2917 other => panic!("{other:?}"),
2918 }
2919 }
2920
2921 #[test]
2922 fn every_command_that_reads_a_config_accepts_one() {
2923 for argv in [
2924 vec!["spar", "run", "42"],
2925 vec!["spar", "triage"],
2926 vec!["spar", "resume"],
2927 vec!["spar", "followup"],
2928 vec!["spar", "checkin"],
2929 vec!["spar", "split"],
2930 vec!["spar", "brainstorm"],
2931 vec!["spar", "listen"],
2932 vec!["spar", "clean"],
2933 vec!["spar", "doctor"],
2934 ] {
2935 let mut full = argv.clone();
2936 full.extend(["--config", "other.toml"]);
2937 let cli = Cli::parse_from(&full);
2938 let config = match cli.command {
2939 Command::Run { common, .. }
2940 | Command::Triage { common, .. }
2941 | Command::Resume { common, .. }
2942 | Command::Followup { common, .. }
2943 | Command::Split { common, .. }
2944 | Command::Brainstorm { common, .. }
2945 | Command::Listen { common, .. }
2946 | Command::Checkin { common, .. } => common.config,
2947 Command::Clean { config, .. } | Command::Doctor { config } => config,
2948 other => panic!("{other:?}"),
2949 };
2950 assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
2951 }
2952 }
2953
2954 #[test]
2955 fn auto_merge_is_off_unless_asked_for() {
2956 let cli = Cli::parse_from(["spar", "run"]);
2957 match cli.command {
2958 Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
2959 other => panic!("{other:?}"),
2960 }
2961 }
2962
2963 #[test]
2966 fn every_command_that_reads_a_config_takes_instructions() {
2967 for argv in [
2970 vec!["spar", "run", "7", "--instructions", "Do not wait for CI."],
2971 vec![
2972 "spar",
2973 "triage",
2974 "7",
2975 "--instructions",
2976 "Do not wait for CI.",
2977 ],
2978 vec![
2979 "spar",
2980 "resume",
2981 "7",
2982 "--instructions",
2983 "Do not wait for CI.",
2984 ],
2985 vec![
2986 "spar",
2987 "review",
2988 "7",
2989 "--instructions",
2990 "Do not wait for CI.",
2991 ],
2992 vec!["spar", "followup", "--instructions", "Do not wait for CI."],
2993 vec![
2994 "spar",
2995 "checkin",
2996 "7",
2997 "--instructions",
2998 "Do not wait for CI.",
2999 ],
3000 vec![
3001 "spar",
3002 "split",
3003 "7",
3004 "--instructions",
3005 "Do not wait for CI.",
3006 ],
3007 vec![
3008 "spar",
3009 "brainstorm",
3010 "a",
3011 "subject",
3012 "--instructions",
3013 "Do not wait for CI.",
3014 ],
3015 vec![
3016 "spar",
3017 "listen",
3018 "7",
3019 "--instructions",
3020 "Do not wait for CI.",
3021 ],
3022 ] {
3023 let parsed = Cli::parse_from(&argv);
3024 let common = match parsed.command {
3025 Command::Run { common, .. }
3026 | Command::Triage { common, .. }
3027 | Command::Resume { common, .. }
3028 | Command::Review { common, .. }
3029 | Command::Followup { common, .. }
3030 | Command::Split { common, .. }
3031 | Command::Brainstorm { common, .. }
3032 | Command::Listen { common, .. }
3033 | Command::Checkin { common, .. } => common,
3034 other => panic!("{other:?}"),
3035 };
3036 assert_eq!(
3037 Some("Do not wait for CI."),
3038 common.instructions.as_deref(),
3039 "{argv:?}"
3040 );
3041 }
3042 }
3043
3044 #[test]
3045 fn the_two_close_skipped_flags_are_mutually_exclusive() {
3046 assert!(
3047 Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
3048 );
3049 }
3050
3051 #[test]
3054 fn close_skipped_is_offered_only_where_it_means_something() {
3055 assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
3056 assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
3057 assert!(Cli::try_parse_from(["spar", "followup", "--close-skipped"]).is_ok());
3059 assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
3060 assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
3061 assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
3062 }
3063
3064 #[test]
3067 fn brainstorm_takes_words_or_nothing() {
3068 match Cli::parse_from(["spar", "brainstorm", "novel", "uses", "of", "script"]).command {
3069 Command::Brainstorm { topic, ideas, .. } => {
3070 assert_eq!(vec!["novel", "uses", "of", "script"], topic);
3071 assert_eq!(5, ideas);
3072 }
3073 other => panic!("{other:?}"),
3074 }
3075 match Cli::parse_from(["spar", "brainstorm", "--ideas", "3", "--max-rounds", "2"]).command {
3076 Command::Brainstorm {
3077 topic,
3078 ideas,
3079 max_rounds,
3080 ..
3081 } => {
3082 assert!(topic.is_empty());
3083 assert_eq!(3, ideas);
3084 assert_eq!(Some(2), max_rounds);
3085 }
3086 other => panic!("{other:?}"),
3087 }
3088 }
3089
3090 #[test]
3094 fn filing_from_a_saved_session_needs_file_issues_and_writes_no_session() {
3095 assert!(Cli::try_parse_from(["spar", "brainstorm", "--from", "s.md"]).is_err());
3096 assert!(
3097 Cli::try_parse_from(["spar", "brainstorm", "--from", "s.md", "--file-issues"]).is_ok()
3098 );
3099 assert!(Cli::try_parse_from([
3100 "spar",
3101 "brainstorm",
3102 "--from",
3103 "s.md",
3104 "--file-issues",
3105 "--out",
3106 "t.md"
3107 ])
3108 .is_err());
3109 assert!(
3110 Cli::try_parse_from(["spar", "brainstorm", "x", "--out", "t.md", "--file-issues"])
3111 .is_ok()
3112 );
3113 }
3114
3115 #[test]
3118 fn followup_takes_no_numbers() {
3119 assert!(Cli::try_parse_from(["spar", "followup"]).is_ok());
3120 assert!(Cli::try_parse_from(["spar", "followup", "42"]).is_err());
3121 }
3122
3123 #[test]
3126 fn the_two_stopping_points_are_mutually_exclusive() {
3127 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only"]).is_ok());
3128 assert!(Cli::try_parse_from(["spar", "followup", "--file-only"]).is_ok());
3129 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only", "--file-only"]).is_err());
3130 }
3131
3132 #[test]
3133 fn the_close_skipped_pair_resolves_to_a_tristate() {
3134 let read = |argv: &[&str]| match Cli::parse_from(argv).command {
3135 Command::Run { triage_flags, .. } => {
3136 match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
3137 (true, _) => Some(true),
3138 (_, true) => Some(false),
3139 _ => None,
3140 }
3141 }
3142 other => panic!("{other:?}"),
3143 };
3144 assert_eq!(None, read(&["spar", "run"]));
3145 assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
3146 assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
3147 }
3148
3149 #[test]
3150 fn the_default_limit_is_twenty() {
3151 let cli = Cli::parse_from(["spar", "run"]);
3152 match cli.command {
3153 Command::Run { common, .. } => assert_eq!(20, common.limit),
3154 other => panic!("{other:?}"),
3155 }
3156 }
3157
3158 #[test]
3159 fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
3160 assert!(matches!(
3161 Cli::parse_from(["spar", "scrub-filter"]).command,
3162 Command::ScrubFilter
3163 ));
3164 let help = Cli::command().render_long_help().to_string();
3165 assert!(
3166 !help.contains("scrub-filter"),
3167 "it is plumbing, not a command"
3168 );
3169 }
3170
3171 #[test]
3172 fn review_takes_pr_numbers_and_a_dry_run() {
3173 let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
3174 match cli.command {
3175 Command::Review { items, dry_run, .. } => {
3176 assert_eq!(vec![101, 102], items);
3177 assert!(dry_run);
3178 }
3179 other => panic!("{other:?}"),
3180 }
3181 }
3182
3183 #[test]
3184 fn review_posts_unless_told_not_to() {
3185 match Cli::parse_from(["spar", "review", "101"]).command {
3186 Command::Review { dry_run, .. } => assert!(!dry_run),
3187 other => panic!("{other:?}"),
3188 }
3189 }
3190
3191 #[test]
3192 fn review_with_no_numbers_is_allowed() {
3193 match Cli::parse_from(["spar", "review"]).command {
3194 Command::Review { items, .. } => assert!(items.is_empty()),
3195 other => panic!("{other:?}"),
3196 }
3197 }
3198
3199 #[test]
3200 fn review_takes_its_own_round_budget() {
3201 match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
3202 Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
3203 other => panic!("{other:?}"),
3204 }
3205 }
3206
3207 #[test]
3208 fn checkin_takes_pr_numbers_and_a_dry_run() {
3209 match Cli::parse_from(["spar", "checkin", "108", "112", "--dry-run"]).command {
3210 Command::Checkin { items, dry_run, .. } => {
3211 assert_eq!(vec![108, 112], items);
3212 assert!(dry_run);
3213 }
3214 other => panic!("{other:?}"),
3215 }
3216 match Cli::parse_from(["spar", "checkin"]).command {
3217 Command::Checkin { items, dry_run, .. } => {
3218 assert!(items.is_empty());
3219 assert!(!dry_run);
3220 }
3221 other => panic!("{other:?}"),
3222 }
3223 }
3224
3225 #[test]
3231 fn checkin_offers_no_flag_that_would_weaken_the_pair() {
3232 assert!(Cli::try_parse_from(["spar", "checkin", "--auto-merge"]).is_err());
3233 assert!(Cli::try_parse_from(["spar", "checkin", "--max-rounds", "1"]).is_err());
3234 assert!(Cli::try_parse_from(["spar", "checkin", "--absorb", "1"]).is_err());
3235 assert!(Cli::try_parse_from(["spar", "checkin", "--close-skipped"]).is_err());
3236 assert!(Cli::try_parse_from(["spar", "checkin", "--reply-only"]).is_ok());
3238 assert!(Cli::try_parse_from(["spar", "checkin", "--any-author"]).is_ok());
3239 assert!(Cli::try_parse_from(["spar", "checkin", "--again"]).is_ok());
3240 assert!(Cli::try_parse_from(["spar", "checkin", "--keep-worktrees"]).is_ok());
3241 }
3242
3243 #[test]
3244 fn review_takes_verdict_and_recheck_flags_and_neither_by_default() {
3245 match Cli::parse_from(["spar", "review", "108", "--verdict", "--recheck"]).command {
3246 Command::Review {
3247 items,
3248 verdict,
3249 recheck,
3250 ..
3251 } => {
3252 assert_eq!(vec![108], items);
3253 assert!(verdict);
3254 assert!(recheck);
3255 }
3256 other => panic!("{other:?}"),
3257 }
3258 match Cli::parse_from(["spar", "review", "108"]).command {
3259 Command::Review {
3260 verdict, recheck, ..
3261 } => {
3262 assert!(!verdict);
3263 assert!(!recheck);
3264 }
3265 other => panic!("{other:?}"),
3266 }
3267 assert!(Cli::try_parse_from(["spar", "checkin", "108", "--verdict"]).is_err());
3268 }
3269
3270 #[test]
3271 fn listen_takes_numbers_once_dry_run_and_since() {
3272 match Cli::parse_from([
3273 "spar",
3274 "listen",
3275 "108",
3276 "42",
3277 "--once",
3278 "--dry-run",
3279 "--since",
3280 "2h",
3281 ])
3282 .command
3283 {
3284 Command::Listen {
3285 items,
3286 once,
3287 dry_run,
3288 since,
3289 ..
3290 } => {
3291 assert_eq!(vec![108, 42], items);
3292 assert!(once);
3293 assert!(dry_run);
3294 assert_eq!(Some("2h".to_string()), since);
3295 }
3296 other => panic!("{other:?}"),
3297 }
3298 match Cli::parse_from(["spar", "listen"]).command {
3299 Command::Listen {
3300 items,
3301 once,
3302 dry_run,
3303 since,
3304 common,
3305 } => {
3306 assert!(items.is_empty());
3307 assert!(!once);
3308 assert!(!dry_run);
3309 assert_eq!(None, since);
3310 assert_eq!(20, common.limit);
3311 }
3312 other => panic!("{other:?}"),
3313 }
3314 }
3315
3316 #[test]
3317 fn resume_takes_a_next_override() {
3318 let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
3319 match cli.command {
3320 Command::Resume {
3321 prs, next_actor, ..
3322 } => {
3323 assert_eq!(vec![108], prs);
3324 assert_eq!(Some("codex".to_string()), next_actor);
3325 }
3326 other => panic!("{other:?}"),
3327 }
3328 }
3329}
3330
3331#[cfg(test)]
3332mod absorb_tests {
3333 use super::*;
3334
3335 #[test]
3336 fn absorb_is_off_unless_asked_for() {
3337 match Cli::parse_from(["spar", "run"]).command {
3338 Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
3339 other => panic!("{other:?}"),
3340 }
3341 }
3342
3343 #[test]
3344 fn absorb_takes_a_wave_count() {
3345 match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
3346 Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
3347 other => panic!("{other:?}"),
3348 }
3349 }
3350
3351 #[test]
3352 fn absorb_is_only_offered_where_issues_are_worked() {
3353 assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
3354 assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
3355 assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
3356 assert!(Cli::try_parse_from(["spar", "split", "--absorb", "1"]).is_err());
3359 }
3360}
3361
3362#[cfg(test)]
3363mod split_tests {
3364 use super::*;
3365
3366 #[test]
3367 fn split_takes_numbers_of_either_kind() {
3368 match Cli::parse_from(["spar", "split", "10", "12"]).command {
3369 Command::Split { items, .. } => assert_eq!(vec![10, 12], items),
3370 other => panic!("{other:?}"),
3371 }
3372 }
3373
3374 #[test]
3377 fn split_with_no_numbers_is_allowed() {
3378 match Cli::parse_from(["spar", "split"]).command {
3379 Command::Split {
3380 items,
3381 dry_run,
3382 again,
3383 ..
3384 } => {
3385 assert!(items.is_empty());
3386 assert!(!dry_run);
3387 assert!(!again);
3388 }
3389 other => panic!("{other:?}"),
3390 }
3391 }
3392
3393 #[test]
3397 fn split_can_be_told_to_write_nothing() {
3398 match Cli::parse_from(["spar", "split", "10", "--dry-run"]).command {
3399 Command::Split { dry_run, .. } => assert!(dry_run),
3400 other => panic!("{other:?}"),
3401 }
3402 }
3403
3404 #[test]
3407 fn split_can_be_told_to_split_something_it_already_split() {
3408 match Cli::parse_from(["spar", "split", "10", "--again"]).command {
3409 Command::Split { again, .. } => assert!(again),
3410 other => panic!("{other:?}"),
3411 }
3412 }
3413
3414 #[test]
3417 fn split_takes_the_flags_that_govern_picking() {
3418 match Cli::parse_from([
3419 "spar",
3420 "split",
3421 "--limit",
3422 "5",
3423 "--min-number",
3424 "480",
3425 "--config",
3426 "other.toml",
3427 ])
3428 .command
3429 {
3430 Command::Split { common, .. } => {
3431 assert_eq!(5, common.limit);
3432 assert_eq!(Some(480), common.min_number);
3433 assert_eq!(Some(PathBuf::from("other.toml")), common.config);
3434 }
3435 other => panic!("{other:?}"),
3436 }
3437 }
3438
3439 #[test]
3442 fn split_offers_no_flag_that_would_make_it_a_second_run() {
3443 for flag in [
3444 vec!["--auto-merge"],
3445 vec!["--max-rounds", "2"],
3446 vec!["--close-skipped"],
3447 vec!["--no-close-skipped"],
3448 vec!["--keep-worktrees"],
3449 ] {
3450 let mut argv = vec!["spar", "split", "10"];
3451 argv.extend(flag.iter().copied());
3452 assert!(Cli::try_parse_from(&argv).is_err(), "{argv:?}");
3453 }
3454 }
3455}
3456
3457#[cfg(test)]
3458mod min_number_tests {
3459 use super::*;
3460
3461 fn read(argv: &[&str]) -> Option<i64> {
3462 match Cli::parse_from(argv).command {
3463 Command::Run { common, .. }
3464 | Command::Triage { common, .. }
3465 | Command::Resume { common, .. }
3466 | Command::Review { common, .. }
3467 | Command::Followup { common, .. }
3468 | Command::Split { common, .. }
3469 | Command::Listen { common, .. }
3470 | Command::Checkin { common, .. } => common.min_number,
3471 other => panic!("{other:?}"),
3472 }
3473 }
3474
3475 #[test]
3476 fn there_is_no_floor_unless_one_is_asked_for() {
3477 assert_eq!(None, read(&["spar", "run"]));
3478 }
3479
3480 #[test]
3481 fn every_command_that_picks_for_itself_accepts_a_floor() {
3482 for cmd in [
3483 "run", "triage", "resume", "review", "checkin", "split", "listen",
3484 ] {
3485 assert_eq!(
3486 Some(480),
3487 read(&["spar", cmd, "--min-number", "480"]),
3488 "{cmd}"
3489 );
3490 }
3491 }
3492}
3493
3494#[cfg(test)]
3495mod run_targets_tests {
3496 use super::*;
3497
3498 fn covered(pairs: &[(i64, i64)]) -> BTreeMap<i64, i64> {
3499 pairs.iter().copied().collect()
3500 }
3501
3502 #[test]
3507 fn an_issue_its_named_pull_request_closes_is_not_worked_again() {
3508 let kept = issues_not_on_named_prs(&[772, 783, 784], &covered(&[(772, 786)]));
3509
3510 assert_eq!(vec![783, 784], kept);
3511 }
3512
3513 #[test]
3514 fn an_issue_no_named_pull_request_closes_is_still_worked() {
3515 let kept = issues_not_on_named_prs(&[783, 784, 771], &covered(&[(772, 786)]));
3516
3517 assert_eq!(vec![783, 784, 771], kept);
3518 }
3519
3520 #[test]
3523 fn an_unreadable_pull_request_drops_no_issue() {
3524 let kept = issues_not_on_named_prs(&[772, 783], &covered(&[]));
3525
3526 assert_eq!(vec![772, 783], kept);
3527 }
3528
3529 #[test]
3531 fn every_issue_one_pull_request_closes_is_taken_by_it() {
3532 let kept = issues_not_on_named_prs(&[771, 772, 783], &covered(&[(771, 786), (772, 786)]));
3533
3534 assert_eq!(vec![783], kept);
3535 }
3536}
3537
3538#[cfg(test)]
3539mod code_tests {
3540 use super::*;
3541
3542 #[test]
3543 fn code_requires_a_prompt_and_exclusive_preview_modes() {
3544 assert!(Cli::try_parse_from(["spar", "code"]).is_err());
3545 assert!(
3546 Cli::try_parse_from(["spar", "code", "Add export", "--dry-run", "--file-only"])
3547 .is_err()
3548 );
3549 assert!(Cli::try_parse_from(["spar", "code", "Add export", "--dry-run"]).is_ok());
3550 assert!(Cli::try_parse_from(["spar", "code", "Add export", "--file-only"]).is_ok());
3551 }
3552
3553 #[test]
3554 fn code_takes_words_and_the_normal_run_options() {
3555 let cli = Cli::parse_from([
3556 "spar",
3557 "code",
3558 "Add",
3559 "CSV",
3560 "export",
3561 "--repo",
3562 "/tmp/project",
3563 "--config",
3564 "project.toml",
3565 "--first",
3566 "b",
3567 "--base",
3568 "develop",
3569 "--instructions",
3570 "Keep the format stable",
3571 "--max-rounds",
3572 "5",
3573 "--auto-merge",
3574 "--absorb",
3575 "1",
3576 "--keep-worktrees",
3577 "--no-worktrees",
3578 "--no-close-skipped",
3579 "--retriage",
3580 "--plan-out",
3581 "code-plan.json",
3582 ]);
3583 match cli.command {
3584 Command::Code {
3585 prompt,
3586 common,
3587 loop_flags,
3588 triage_flags,
3589 plan_out,
3590 no_worktrees,
3591 dry_run,
3592 file_only,
3593 } => {
3594 assert_eq!(prompt, ["Add", "CSV", "export"]);
3595 assert_eq!(common.repo, PathBuf::from("/tmp/project"));
3596 assert_eq!(common.config, Some(PathBuf::from("project.toml")));
3597 assert_eq!(common.first.as_deref(), Some("b"));
3598 assert_eq!(common.base.as_deref(), Some("develop"));
3599 assert_eq!(
3600 common.instructions.as_deref(),
3601 Some("Keep the format stable")
3602 );
3603 assert_eq!(loop_flags.max_rounds, Some(5));
3604 assert_eq!(loop_flags.absorb, Some(1));
3605 assert!(loop_flags.auto_merge && loop_flags.keep_worktrees);
3606 assert!(triage_flags.no_close_skipped && triage_flags.retriage);
3607 assert_eq!(plan_out, PathBuf::from("code-plan.json"));
3608 assert!(no_worktrees);
3609 assert!(!dry_run && !file_only);
3610 }
3611 other => panic!("{other:?}"),
3612 }
3613 }
3614}
3615
3616#[cfg(test)]
3617mod settings_block_tests {
3618 use super::*;
3619
3620 fn written(line: &str) -> String {
3623 let after = line.split_once('=').expect("an assignment").1;
3624 let mut quoted = false;
3625 for (i, c) in after.char_indices() {
3626 match c {
3627 '"' => quoted = !quoted,
3628 '#' if !quoted => return after[..i].trim().to_string(),
3629 _ => {}
3630 }
3631 }
3632 after.trim().to_string()
3633 }
3634
3635 fn line_for(text: &str, key: &str) -> String {
3636 text.lines()
3637 .find(|l| {
3638 let bare = l.trim_start().trim_start_matches('#').trim_start();
3639 bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
3640 })
3641 .unwrap_or_else(|| panic!("{key} is not offered at all:\n{text}"))
3642 .to_string()
3643 }
3644
3645 #[test]
3652 fn every_value_it_offers_is_the_default_it_actually_has() {
3653 let text = settings_block("claude");
3654 for option in config::known_options() {
3655 if option.section == "loop.effort_schedule" {
3659 continue;
3660 }
3661 let line = line_for(&text, &option.key);
3662 assert_eq!(
3663 option.default,
3664 written(&line),
3665 "the generated config offers `{}`, but the default is {}",
3666 line.trim(),
3667 option.default
3668 );
3669 }
3670 }
3671
3672 #[test]
3676 fn it_offers_every_option_the_parser_knows_about() {
3677 let text = settings_block("claude");
3678 let missing: Vec<String> = config::unmentioned_options(&text)
3679 .into_iter()
3680 .map(|o| format!("[{}] {}", o.section, o.key))
3681 .collect();
3682 assert!(missing.is_empty(), "not offered: {}", missing.join(", "));
3683 }
3684
3685 #[test]
3689 fn every_option_it_offers_can_be_uncommented_and_still_load() {
3690 let mut text = String::from(
3691 "[agents.claude]\ncommand = [\"claude\"]\n\n\
3692 [agents.codex]\ncommand = [\"codex\"]\n\n",
3693 );
3694 for line in settings_block("claude").lines() {
3695 text.push_str(uncomment(line).unwrap_or(line));
3696 text.push('\n');
3697 }
3698 let cfg = config::parse(&text).expect("a config of its own suggestions");
3699 assert_eq!("claude", cfg.first_implementor);
3700 }
3701
3702 fn uncomment(line: &str) -> Option<&str> {
3705 let bare = line.trim_start().strip_prefix('#')?.trim_start();
3706 let key = bare.split_once('=')?.0.trim();
3709 let named = !key.is_empty()
3710 && key
3711 .chars()
3712 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
3713 named.then_some(bare)
3714 }
3715
3716 #[test]
3717 fn the_agent_that_goes_first_is_the_one_that_was_chosen() {
3718 assert!(settings_block("codex").contains("first_implementor = \"codex\""));
3719 }
3720
3721 #[test]
3726 fn a_note_sits_above_the_option_it_describes() {
3727 let text = settings_block("claude");
3728 assert!(
3729 text.lines().all(|l| l.chars().count() <= 80),
3730 "a line runs off the edge:\n{text}"
3731 );
3732 assert!(
3733 text.lines().all(|l| !l.starts_with(' ')),
3734 "a line is indented, so the columns are back:\n{text}"
3735 );
3736
3737 let lines: Vec<&str> = text.lines().collect();
3739 let at = lines
3740 .iter()
3741 .position(|l| l.starts_with("max_rounds"))
3742 .expect("max_rounds");
3743 assert!(lines[at - 1].starts_with('#'), "{:?}", lines[at - 1]);
3744 assert!(
3745 lines[at - 1].contains("lifetime cap"),
3746 "the note above it is the end of its own note: {:?}",
3747 lines[at - 1]
3748 );
3749 }
3750
3751 #[test]
3753 fn every_note_starts_as_a_sentence() {
3754 for (_, key, note) in LOOP_OPTIONS
3755 .iter()
3756 .chain(STYLE_OPTIONS)
3757 .chain(LISTEN_OPTIONS)
3758 {
3759 let first = note.chars().next().expect("a note");
3760 assert!(
3761 first.is_uppercase(),
3762 "{key} reads as a margin scribble rather than a sentence: {note}"
3763 );
3764 }
3765 }
3766}
3767
3768#[cfg(test)]
3769mod agent_block_tests {
3770 use super::*;
3771
3772 fn spec(models: &[&str], efforts: &[&str]) -> config::AgentSpec {
3773 let mut spec: config::AgentSpec =
3774 toml::Value::Table(toml::from_str("command = [\"x\"]").expect("a minimal preset"))
3775 .try_into()
3776 .expect("builds");
3777 spec.models = models.iter().map(|s| s.to_string()).collect();
3778 spec.efforts = efforts.iter().map(|s| s.to_string()).collect();
3779 spec
3780 }
3781
3782 #[test]
3786 fn an_option_with_no_hints_is_left_out_rather_than_guessed_at() {
3787 let block = agent_block("cursor", &spec(&["composer-2.5", "auto"], &[]));
3788 assert!(!block.contains("..."), "{block}");
3789 assert!(!block.contains("effort"), "{block}");
3790 assert!(block.contains("# model = \"composer-2.5\""), "{block}");
3791 }
3792
3793 #[test]
3796 fn only_the_options_that_follow_are_introduced() {
3797 let model_only = agent_block("cursor", &spec(&["auto"], &[]));
3798 assert!(model_only.contains("Omit model to use"), "{model_only}");
3799 assert!(!model_only.contains("Omit effort"), "{model_only}");
3800
3801 let both = agent_block("claude", &spec(&["fable"], &["high"]));
3802 assert!(both.contains("Omit model to use"), "{both}");
3803 assert!(both.contains("Omit effort to use"), "{both}");
3804 }
3805
3806 #[test]
3809 fn a_preset_with_no_hints_still_writes_a_usable_block() {
3810 let block = agent_block("gemini", &spec(&[], &[]));
3811 assert!(!block.contains("..."), "{block}");
3812 assert!(!block.contains("Omit"), "{block}");
3813 assert!(
3814 block.starts_with("[agents.gemini]\npreset = \"gemini\"\n"),
3815 "{block}"
3816 );
3817 assert!(block.contains("[agents.gemini.fallback]"), "{block}");
3819 assert!(block.contains("# timeout = "), "{block}");
3820 }
3821
3822 #[test]
3825 fn the_timeout_offered_is_the_one_the_agent_would_use() {
3826 let mut spec = spec(&["a"], &[]);
3827 spec.timeout = 7200;
3828 assert!(
3829 agent_block("custom", &spec).contains("# timeout = 7200"),
3830 "the generator kept its own copy"
3831 );
3832 }
3833
3834 #[test]
3836 fn alternatives_are_listed_only_when_there_are_any() {
3837 assert!(agent_block("a", &spec(&["one", "two"], &[])).contains("One of: one | two."));
3838 let single = agent_block("b", &spec(&["only"], &[]));
3839 assert!(!single.contains("One of:"), "{single}");
3840 }
3841
3842 #[test]
3846 fn the_presets_note_is_said_once() {
3847 let mut spec = spec(&["m1", "m2"], &["e1", "e2"]);
3848 spec.options_note = Some("Check the current sets with: mytool --help".into());
3849 let block = agent_block("mytool", &spec);
3850 assert_eq!(
3851 1,
3852 block.matches("Check the current sets").count(),
3853 "{block}"
3854 );
3855 }
3856}