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::config::{self, Config};
14use crate::error::Result;
15use crate::followups;
16use crate::listen;
17use crate::model::{Issue, IssueRun, ItemKind, Plan, PlanItem, Status};
18use crate::proc::{self, ExecOpts};
19use crate::progress::Progress;
20use crate::repo::{Repo, WriteSummary};
21use crate::review;
22use crate::review_only;
23use crate::spend;
24use crate::split;
25use crate::style;
26use crate::tracker;
27use crate::triage;
28use crate::{bail, log, logdim, logging, logwarn, spar_err};
29
30pub const VERSION: &str = env!("CARGO_PKG_VERSION");
31
32#[derive(Parser, Debug)]
33#[command(
34 name = "spar",
35 version = VERSION,
36 about = "Two coding agents alternate implementing and reviewing GitHub issues.",
37 long_about = "Two coding agents alternate implementing and reviewing GitHub issues until a \
38 pull request converges. Neither agent reviews its own most recent edit.\n\n\
39 Arguments are issue numbers for `triage`, and pull request numbers \
40 for `resume`, `review`, and `checkin`. `run` and `split` take either: `run` \
41 continues any pull request given before it triages the issues, and drops an \
42 issue whose pull request was given too. Omit them and \
43 spar takes everything open, up to --limit. `split` applies that limit once to \
44 issues and once to pull requests. `followup` takes none: it works the queue in \
45 .spar/followups.md, and an entry there has no number to name. `brainstorm` \
46 takes words rather than numbers: the subject to brainstorm, or nothing to \
47 brainstorm the repository itself. `listen` takes issue or pull request \
48 numbers to watch, or nothing to watch the whole repository.",
49 max_term_width = 96
50)]
51pub struct Cli {
52 #[arg(short, long, global = true)]
54 pub quiet: bool,
55
56 #[command(subcommand)]
57 pub command: Command,
58}
59
60#[derive(Subcommand, Debug)]
61pub enum Command {
62 Run {
64 issues: Vec<i64>,
66 #[command(flatten)]
67 common: Common,
68 #[command(flatten)]
69 loop_flags: LoopFlags,
70 #[command(flatten)]
71 triage_flags: TriageFlags,
72 #[arg(long, default_value = "plan.json")]
74 plan_out: PathBuf,
75 #[arg(long)]
77 no_worktrees: bool,
78 },
79
80 Followup {
92 #[command(flatten)]
93 common: Common,
94 #[command(flatten)]
95 loop_flags: LoopFlags,
96 #[command(flatten)]
97 triage_flags: TriageFlags,
98 #[arg(long, value_name = "PATH")]
100 file: Option<PathBuf>,
101 #[arg(long)]
103 screen_only: bool,
104 #[arg(long, conflicts_with = "screen_only")]
106 file_only: bool,
107 #[arg(long, default_value = "plan.json")]
109 plan_out: PathBuf,
110 #[arg(long)]
112 no_worktrees: bool,
113 },
114
115 Triage {
117 issues: Vec<i64>,
119 #[command(flatten)]
120 common: Common,
121 #[arg(long, default_value = "plan.json")]
122 plan_out: PathBuf,
123 },
124
125 Resume {
127 prs: Vec<i64>,
129 #[command(flatten)]
130 common: Common,
131 #[command(flatten)]
132 loop_flags: LoopFlags,
133 #[arg(long = "next", value_name = "AGENT")]
135 next_actor: Option<String>,
136 },
137
138 Checkin {
146 items: Vec<i64>,
149 #[command(flatten)]
150 common: Common,
151 #[arg(long)]
153 dry_run: bool,
154 #[arg(long)]
156 reply_only: bool,
157 #[arg(long)]
160 any_author: bool,
161 #[arg(long)]
163 again: bool,
164 #[arg(long)]
166 keep_worktrees: bool,
167 },
168
169 Review {
174 items: Vec<i64>,
177 #[command(flatten)]
178 common: Common,
179 #[arg(long)]
181 dry_run: bool,
182 #[arg(long)]
185 max_rounds: Option<u32>,
186 #[arg(long)]
190 verdict: bool,
191 #[arg(long)]
196 recheck: bool,
197 },
198
199 Brainstorm {
211 topic: Vec<String>,
214 #[command(flatten)]
215 common: Common,
216 #[arg(long)]
220 max_rounds: Option<u32>,
221 #[arg(long, default_value_t = 5, value_name = "N")]
223 ideas: usize,
224 #[arg(long, value_name = "PATH")]
226 out: Option<PathBuf>,
227 #[arg(long)]
229 file_issues: bool,
230 #[arg(
233 long,
234 value_name = "PATH",
235 requires = "file_issues",
236 conflicts_with = "out"
237 )]
238 from: Option<PathBuf>,
239 },
240
241 Split {
258 items: Vec<i64>,
261 #[command(flatten)]
262 common: Common,
263 #[arg(long)]
265 dry_run: bool,
266 #[arg(long)]
269 again: bool,
270 },
271
272 Listen {
286 items: Vec<i64>,
289 #[command(flatten)]
290 common: Common,
291 #[arg(long)]
293 once: bool,
294 #[arg(long)]
297 dry_run: bool,
298 #[arg(long, value_name = "DURATION")]
301 since: Option<String>,
302 },
303
304 Post {
309 #[arg(required = true)]
311 prs: Vec<i64>,
312 #[arg(long, default_value = ".")]
313 repo: PathBuf,
314 #[arg(long)]
315 config: Option<PathBuf>,
316 #[arg(long, value_name = "PATH")]
318 file: Option<PathBuf>,
319 #[arg(long)]
321 dry_run: bool,
322 },
323
324 Init {
329 #[arg(long, default_value = "spar.toml")]
330 out: PathBuf,
331 #[arg(long)]
333 force: bool,
334 #[arg(long, conflicts_with = "force")]
337 update: bool,
338 },
339
340 Clean {
342 #[arg(long, default_value = ".")]
343 repo: PathBuf,
344 #[arg(long)]
345 config: Option<PathBuf>,
346 #[arg(long)]
349 all: bool,
350 #[arg(long)]
352 pr_state: bool,
353 },
354
355 Doctor {
357 #[arg(long)]
358 config: Option<PathBuf>,
359 },
360
361 #[command(hide = true)]
365 ScrubFilter,
366}
367
368#[derive(Args, Debug, Clone)]
369pub struct Common {
370 #[arg(long, default_value = ".")]
372 pub repo: PathBuf,
373 #[arg(long)]
375 pub config: Option<PathBuf>,
376 #[arg(long)]
378 pub base: Option<String>,
379 #[arg(long)]
381 pub first: Option<String>,
382 #[arg(long, default_value_t = 20)]
385 pub limit: usize,
386 #[arg(long, value_name = "N")]
389 pub min_number: Option<i64>,
390 #[arg(long, value_name = "TEXT")]
393 pub instructions: Option<String>,
394}
395
396#[derive(Args, Debug, Clone)]
397pub struct LoopFlags {
398 #[arg(long)]
401 pub max_rounds: Option<u32>,
402 #[arg(long)]
404 pub auto_merge: bool,
405 #[arg(long)]
407 pub keep_worktrees: bool,
408 #[arg(long, value_name = "N")]
411 pub absorb: Option<u32>,
412}
413
414#[derive(Args, Debug, Clone)]
417pub struct TriageFlags {
418 #[arg(long, conflicts_with = "no_close_skipped")]
420 pub close_skipped: bool,
421 #[arg(long)]
423 pub no_close_skipped: bool,
424 #[arg(long)]
430 pub retriage: bool,
431}
432
433pub fn main() -> i32 {
438 let cli = Cli::parse();
439 logging::init_color();
440 logging::set_quiet(cli.quiet);
441
442 match dispatch(cli) {
443 Ok(code) => code,
444 Err(e) => {
445 logging::error(e.to_string());
446 2
447 }
448 }
449}
450
451fn dispatch(cli: Cli) -> Result<i32> {
452 match cli.command {
453 Command::ScrubFilter => cmd_scrub_filter(),
454 Command::Doctor { config } => cmd_doctor(config.as_deref()),
455 Command::Review {
456 items,
457 common,
458 dry_run,
459 max_rounds,
460 verdict,
461 recheck,
462 } => {
463 let overrides = Overrides {
464 max_rounds,
465 ..Overrides::default()
466 };
467 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
468 let numbers = if items.is_empty() {
469 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
470 if found.is_empty() {
471 log!("no open PRs");
472 return Ok(0);
473 }
474 log!("no PRs given, reviewing {} open", found.len());
475 found
476 } else {
477 items
478 };
479 let sorted = classify(&repo, &numbers)?;
480 let mut targets = sorted.prs;
481 for number in sorted.issues {
482 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
483 Ok(Some(pr)) => {
484 log!("#{number} is an issue; reviewing its open PR {}", pr.url);
485 targets.push(pr.number);
486 }
487 Ok(None) => {
488 logwarn!("#{number} is an issue with no open pull request to review")
489 }
490 Err(e) => logwarn!("#{number}: {e}"),
491 }
492 }
493 let mut results = Vec::new();
494 for number in targets {
495 results.push(if recheck {
496 let mode = review_only::RecheckMode {
497 dry_run,
498 requested: false,
499 verdict_on_fallback: verdict,
500 };
501 review_only::recheck_pr(&agents, &cfg, &repo, number, mode)
502 } else {
503 let opts = review_only::ReviewOpts { dry_run, verdict };
504 review_only::review_pr(&agents, &cfg, &repo, number, opts)
505 });
506 }
507 if results.is_empty() {
508 return Ok(0);
509 }
510 Ok(report(&results, &cfg, &repo))
511 }
512
513 Command::Listen {
514 items,
515 common,
516 once,
517 dry_run,
518 since,
519 } => {
520 let since = since.as_deref().map(listen::parse_duration).transpose()?;
522 let (cfg, repo, agents) = prepare(&common, None)?;
523 listen::run(
524 &agents,
525 &cfg,
526 &repo,
527 listen::Opts {
528 items,
529 once,
530 dry_run,
531 since,
532 limit: common.limit,
533 },
534 )
535 }
536
537 Command::Brainstorm {
538 topic,
539 common,
540 max_rounds,
541 ideas,
542 out,
543 file_issues,
544 from,
545 } => cmd_brainstorm(
546 topic,
547 &common,
548 max_rounds,
549 ideas,
550 out,
551 file_issues,
552 from.as_deref(),
553 ),
554
555 Command::Checkin {
556 items,
557 common,
558 dry_run,
559 reply_only,
560 any_author,
561 again,
562 keep_worktrees,
563 } => {
564 let overrides = Overrides {
565 keep_worktrees: keep_worktrees.then_some(true),
566 ..Overrides::default()
567 };
568 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
569 let mode = checkin::Mode {
570 dry_run,
571 reply_only,
572 trust: if any_author {
573 crate::config::Trust::Anyone
574 } else {
575 cfg.loop_cfg.checkin_trust
576 },
577 again,
578 resolve: cfg.loop_cfg.checkin_resolve,
579 posts: checkin::posts(&cfg),
580 };
581 let numbers = if items.is_empty() {
582 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
583 if found.is_empty() {
584 log!("no open PRs");
585 return Ok(0);
586 }
587 log!(
588 "no PRs given, checking in on {} open: {}",
589 found.len(),
590 found
591 .iter()
592 .map(|n| format!("#{n}"))
593 .collect::<Vec<_>>()
594 .join(", ")
595 );
596 found
597 } else {
598 items
599 };
600 let sorted = classify(&repo, &numbers)?;
601 let mut results = Vec::new();
602 for number in sorted.prs {
603 results.push(checkin::checkin_pr(&agents, &cfg, &repo, number, &mode));
604 }
605 for number in sorted.issues {
609 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
610 Ok(Some(pr)) => {
611 log!(
612 "#{number} is an issue; checking in on its open PR {}",
613 pr.url
614 );
615 results.push(checkin::checkin_pr(&agents, &cfg, &repo, pr.number, &mode));
616 }
617 Ok(None) => {
618 results.push(checkin::checkin_issue(&agents, &cfg, &repo, number, &mode))
619 }
620 Err(e) => results.push(checkin::failed(number, format!("#{number}"), e)),
625 }
626 }
627 if results.is_empty() {
628 return Ok(0);
629 }
630 Ok(report(&results, &cfg, &repo))
631 }
632
633 Command::Split {
634 items,
635 common,
636 dry_run,
637 again,
638 } => {
639 let (cfg, repo, agents) = prepare(&common, None)?;
640 let mode = split::Mode { dry_run, again };
641 let picked = if items.is_empty() {
642 pick_for_split(&agents, &cfg, &repo, common.limit, &mode)?
643 } else {
644 let sorted = classify(&repo, &items)?;
645 let mut out: Vec<(i64, ItemKind)> = sorted
646 .issues
647 .iter()
648 .map(|n| (*n, ItemKind::Issue))
649 .collect();
650 out.extend(sorted.prs.iter().map(|n| (*n, ItemKind::Pr)));
651 out
652 };
653
654 let mut results = Vec::new();
655 let mut seen: BTreeSet<i64> = BTreeSet::new();
656 for (number, kind) in picked {
657 let target = match kind {
661 ItemKind::Pr => Some(number),
662 ItemKind::Issue => {
663 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
664 Ok(found) => found.map(|pr| {
665 log!("#{number} is an issue; splitting its open PR {}", pr.url);
666 pr.number
667 }),
668 Err(e) => {
669 logwarn!("#{number}: {e}");
670 continue;
671 }
672 }
673 }
674 };
675 if !seen.insert(target.unwrap_or(number)) {
680 logdim!("#{number} was already covered by this run, skipping it");
681 continue;
682 }
683 results.push(match target {
684 Some(pr) => split::split_pr(&agents, &cfg, &repo, pr, &mode),
685 None => split::split_issue(&agents, &cfg, &repo, number, &mode),
686 });
687 }
688 if results.is_empty() {
689 return Ok(0);
690 }
691 Ok(report(&results, &cfg, &repo))
692 }
693
694 Command::Post {
695 prs,
696 repo: repo_path,
697 config,
698 file,
699 dry_run,
700 } => cmd_post(
701 &prs,
702 &repo_path,
703 config.as_deref(),
704 file.as_deref(),
705 dry_run,
706 ),
707
708 Command::Init { out, force, update } => {
709 if update {
710 cmd_init_update(&out)
711 } else {
712 cmd_init(&out, force)
713 }
714 }
715 Command::Clean {
716 repo,
717 config,
718 all,
719 pr_state,
720 } => cmd_clean(&repo, config.as_deref(), all, pr_state),
721 Command::Triage {
722 issues,
723 common,
724 plan_out,
725 } => {
726 let (cfg, repo, agents) = prepare(&common, None)?;
727 let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
728 if numbers.is_empty() {
729 return Ok(0);
730 }
731 let sorted = classify(&repo, &numbers)?;
732 for number in &sorted.prs {
733 log!("#{number} is a pull request, nothing to triage");
734 }
735 if sorted.issues.is_empty() {
736 log!("no issues to triage");
737 return Ok(0);
738 }
739 let issues = repo.fetch_issues(&sorted.issues)?;
740 let plan = make_plan(&agents, &cfg, &repo, &issues, &plan_out)?;
744 if cfg.loop_cfg.decompose_trackers {
748 for item in plan.skipped.iter().filter(|i| i.tracker) {
749 tracker::preview(&cfg, &repo, item.issue);
750 }
751 }
752 Ok(0)
753 }
754 Command::Run {
755 issues,
756 common,
757 loop_flags,
758 triage_flags,
759 plan_out,
760 no_worktrees,
761 } => {
762 let overrides = Overrides::for_working(&loop_flags, &triage_flags, no_worktrees);
763 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
764 let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
765 if numbers.is_empty() {
766 return Ok(0);
767 }
768 let sorted = classify(&repo, &numbers)?;
769 let mut results = Vec::new();
770 let mut stopped = Vec::new();
771 let mut parked = Vec::new();
772 let mut progress = Progress::new(sorted.prs.len());
773
774 let mut issues = sorted.issues.clone();
780 if !sorted.prs.is_empty() {
781 log!(
782 "continuing {} pull request(s) before starting new work: {}",
783 sorted.prs.len(),
784 self::numbers(&sorted.prs)
785 );
786 let covered = issues_covered_by(&repo, &sorted.prs);
787 for (issue, pr) in covered.iter().filter(|(i, _)| issues.contains(i)) {
788 log!(
789 "#{issue} is what PR #{pr} closes, and both were given, so it is \
790 continued there rather than implemented again"
791 );
792 }
793 issues = issues_not_on_named_prs(&issues, &covered);
794 for number in &sorted.prs {
795 let run = review::resume_pr(&agents, &cfg, &repo, *number, None);
796 progress.finished(&run);
797 results.push(run);
798 }
799 }
800
801 if let Err(e) = work_issues(
802 &agents,
803 &cfg,
804 &repo,
805 issues,
806 &plan_out,
807 &mut results,
808 &mut stopped,
809 &mut parked,
810 &mut progress,
811 triage_flags.retriage,
812 ) {
813 if results.is_empty() {
816 return Err(e);
817 }
818 stopped.push(format!("the issue queue stopped: {e}"));
819 }
820
821 if results.is_empty() {
822 log!("nothing scheduled");
825 for line in parked.iter().chain(stopped.iter()) {
826 println!("{line}");
827 }
828 return Ok(report_writes(&repo).max(i32::from(!stopped.is_empty())));
829 }
830 Ok(report_with(&results, &cfg, &repo, &stopped, &parked))
831 }
832 Command::Followup {
833 common,
834 loop_flags,
835 triage_flags,
836 file,
837 screen_only,
838 file_only,
839 plan_out,
840 no_worktrees,
841 } => {
842 let overrides = Overrides::for_working(&loop_flags, &triage_flags, no_worktrees);
843 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
844 let path = file.unwrap_or_else(|| repo.followups_path());
845 let mode = match (screen_only, file_only) {
846 (true, _) => followups::Mode::ScreenOnly,
847 (_, true) => followups::Mode::FileOnly,
848 _ => followups::Mode::Work,
849 };
850 let outcome = followups::run(&agents, &cfg, &repo, &path, common.limit, mode)?;
851
852 let wave = followups::wave(&outcome);
853 if mode != followups::Mode::Work || wave.is_empty() {
854 return Ok(report_writes(&repo));
855 }
856 let mut results = Vec::new();
857 let mut stopped = Vec::new();
858 let mut parked = Vec::new();
859 let mut progress = Progress::new(0);
860 work_issues(
861 &agents,
862 &cfg,
863 &repo,
864 wave,
865 &plan_out,
866 &mut results,
867 &mut stopped,
868 &mut parked,
869 &mut progress,
870 triage_flags.retriage,
871 )?;
872 if results.is_empty() {
873 log!("nothing scheduled");
876 for line in parked.iter().chain(stopped.iter()) {
877 println!("{line}");
878 }
879 return Ok(report_writes(&repo).max(i32::from(!stopped.is_empty())));
880 }
881 Ok(report_with(&results, &cfg, &repo, &stopped, &parked))
882 }
883
884 Command::Resume {
885 prs,
886 common,
887 loop_flags,
888 next_actor,
889 } => {
890 let (cfg, repo, agents) = prepare(&common, Some(Overrides::from(&loop_flags)))?;
891 if let Some(name) = &next_actor {
892 if !cfg.has_agent(name) {
893 bail!("--next must be one of: {}", cfg.agent_names().join(", "));
894 }
895 }
896 let numbers = if prs.is_empty() {
897 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
898 if found.is_empty() {
899 log!("no open PRs");
900 return Ok(0);
901 }
902 log!(
903 "no PRs given, taking {} open: {}",
904 found.len(),
905 found
906 .iter()
907 .map(|n| format!("#{n}"))
908 .collect::<Vec<_>>()
909 .join(", ")
910 );
911 found
912 } else {
913 prs
914 };
915 let sorted = classify(&repo, &numbers)?;
916 let mut results = Vec::new();
917 for number in sorted.prs {
918 results.push(review::resume_pr(
919 &agents,
920 &cfg,
921 &repo,
922 number,
923 next_actor.as_deref(),
924 ));
925 }
926 for number in sorted.issues {
929 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
930 Ok(Some(pr)) => {
931 log!("#{number} is an issue; continuing its open PR {}", pr.url);
932 results.push(review::resume_pr(
933 &agents,
934 &cfg,
935 &repo,
936 pr.number,
937 next_actor.as_deref(),
938 ));
939 }
940 Ok(None) => logwarn!(
941 "#{number} is an issue with no open pull request. Use `spar run {number}` \
942 to implement it."
943 ),
944 Err(e) => logwarn!("#{number}: {e}"),
945 }
946 }
947 if results.is_empty() {
948 return Ok(0);
949 }
950 Ok(report(&results, &cfg, &repo))
951 }
952 }
953}
954
955#[derive(Debug, Default, Clone)]
960struct Overrides {
961 max_rounds: Option<u32>,
962 auto_merge: Option<bool>,
963 keep_worktrees: Option<bool>,
964 worktrees: Option<bool>,
965 close_skipped: Option<bool>,
966 absorb: Option<u32>,
967}
968
969impl From<&LoopFlags> for Overrides {
970 fn from(flags: &LoopFlags) -> Self {
971 Self {
972 max_rounds: flags.max_rounds,
973 auto_merge: flags.auto_merge.then_some(true),
974 keep_worktrees: flags.keep_worktrees.then_some(true),
975 worktrees: None,
976 close_skipped: None,
977 absorb: flags.absorb,
978 }
979 }
980}
981
982impl Overrides {
983 fn for_working(loop_flags: &LoopFlags, triage: &TriageFlags, no_worktrees: bool) -> Self {
986 let mut over = Overrides::from(loop_flags);
987 over.worktrees = if no_worktrees { Some(false) } else { None };
988 over.close_skipped = match (triage.close_skipped, triage.no_close_skipped) {
989 (true, _) => Some(true),
990 (_, true) => Some(false),
991 _ => None,
992 };
993 over
994 }
995}
996
997#[allow(clippy::too_many_arguments)]
1007fn work_issues(
1008 agents: &[Agent],
1009 cfg: &Config,
1010 repo: &Repo,
1011 first_wave: Vec<i64>,
1012 plan_out: &Path,
1013 results: &mut Vec<IssueRun>,
1014 stopped: &mut Vec<String>,
1015 parked: &mut Vec<String>,
1016 progress: &mut Progress,
1017 retriage: bool,
1018) -> Result<()> {
1019 let waiting = repo.read_contested();
1023 if retriage && !waiting.is_empty() {
1024 repo.forget_contested(&waiting.keys().copied().collect::<Vec<_>>());
1025 }
1026 let mut handled: BTreeSet<i64> = BTreeSet::new();
1027 let mut landed: BTreeSet<i64> = BTreeSet::new();
1031 let mut leftover: BTreeSet<i64> = BTreeSet::new();
1032 let mut worked = 0usize;
1036 let mut queue: VecDeque<Wave> = VecDeque::from([Wave::first(first_wave)]);
1037 let mut plans_written = 0usize;
1038
1039 while let Some(mut wave) = queue.pop_front() {
1046 wave.numbers.retain(|n| !handled.contains(n));
1047 if !retriage {
1048 let mut still_waiting: Vec<i64> = Vec::new();
1053 wave.numbers.retain(|n| {
1054 let Some(known) = waiting.get(n) else {
1055 return true;
1056 };
1057 still_waiting.push(*n);
1058 parked.push(format!(
1059 "#{n} contested and parked for you: {}",
1060 positions_of(known)
1061 ));
1062 false
1063 });
1064 if !still_waiting.is_empty() {
1065 log!(
1066 "{} contested on an earlier run and waiting on you. Pass --retriage to ask \
1067 again.",
1068 numbers(&still_waiting)
1069 );
1070 }
1071 }
1072 if wave.numbers.is_empty() {
1073 continue;
1074 }
1075 match wave.parent {
1076 Some(tracker) => log!(
1077 "working {} item(s) from the checklist in #{tracker}: {}",
1078 wave.numbers.len(),
1079 numbers(&wave.numbers)
1080 ),
1081 None if wave.absorbed > 0 => log!(
1082 "absorbing {} newly filed issue(s): {}",
1083 wave.numbers.len(),
1084 numbers(&wave.numbers)
1085 ),
1086 None => {}
1087 }
1088 handled.extend(wave.numbers.iter().copied());
1089
1090 let fetched = match repo.fetch_issues(&wave.numbers) {
1091 Ok(fetched) => fetched,
1092 Err(e) => {
1093 logdim!("could not read the next wave: {e}");
1094 continue;
1095 }
1096 };
1097 let plan_path = if plans_written == 0 {
1100 plan_out.to_path_buf()
1101 } else {
1102 plan_out.with_extension(format!("wave{plans_written}.json"))
1103 };
1104 plans_written += 1;
1105 let plan = match make_plan(agents, cfg, repo, &fetched, &plan_path) {
1110 Ok(plan) => plan,
1111 Err(e) => {
1112 logwarn!("triage failed for {}: {e}", numbers(&wave.numbers));
1113 stopped.push(format!(
1114 "triage failed for {}, so it was not worked: {}",
1115 numbers(&wave.numbers),
1116 first_line(&e.to_string())
1117 ));
1118 for later in &queue {
1119 stopped.push(format!(
1120 "{} was not reached after that failure",
1121 numbers(&later.numbers)
1122 ));
1123 }
1124 queue.clear();
1125 break;
1126 }
1127 };
1128 act_on_plan(cfg, repo, &plan);
1129 repo.remember_contested(&plan.contested);
1130 for item in &plan.contested {
1131 parked.push(format!(
1132 "#{} contested and parked for you: {}",
1133 item.issue,
1134 positions(&item.positions, &item.reasons)
1135 ));
1136 }
1137 for item in &plan.skipped {
1138 let what = if item.tracker {
1139 "left open as a tracker"
1140 } else if cfg.loop_cfg.close_skipped {
1141 "commented on and closed"
1142 } else {
1143 "commented on and left open"
1144 };
1145 parked.push(format!(
1146 "#{} skipped by both reviewers, {what}: {}",
1147 item.issue,
1148 item.reasons
1149 .values()
1150 .next()
1151 .map(|r| first_line(r))
1152 .unwrap_or_default()
1153 ));
1154 }
1155
1156 if cfg.loop_cfg.decompose_trackers && wave.parent.is_none() {
1160 for item in plan.skipped.iter().filter(|i| i.tracker) {
1161 let children = tracker::decompose(cfg, repo, item.issue);
1162 if !children.is_empty() {
1163 queue.push_back(wave.child(item.issue, children));
1164 }
1165 }
1166 }
1167
1168 progress.add(
1169 plan.order
1170 .iter()
1171 .filter(|item| fetched.iter().any(|i| i.number == item.issue))
1172 .count(),
1173 );
1174 let before = results.len();
1175 for item in &plan.order {
1176 let Some(issue) = fetched.iter().find(|i| i.number == item.issue) else {
1177 continue;
1178 };
1179 if let Some(why) = held_back(item, &plan, &landed) {
1180 log!("#{}: {why}", item.issue);
1181 let mut held = IssueRun::new(item.issue, item.title.clone());
1182 held.status = Status::Pending;
1183 held.notes.push(why);
1184 progress.finished(&held);
1185 results.push(held);
1186 continue;
1187 }
1188 let run = review::run_nth_issue(agents, cfg, repo, item, issue, worked);
1189 worked += 1;
1190 if run.status == Status::Merged {
1191 landed.insert(item.issue);
1192 }
1193 progress.finished(&run);
1194 results.push(run);
1195 }
1196
1197 let filed: Vec<i64> = results[before..]
1199 .iter()
1200 .flat_map(|r| r.filed.iter())
1201 .filter_map(|url| review::filed_issue_number(url))
1202 .filter(|n| !handled.contains(n))
1203 .collect::<BTreeSet<_>>()
1204 .into_iter()
1205 .collect();
1206 if filed.is_empty() {
1207 continue;
1208 }
1209 match wave.absorbed < cfg.loop_cfg.absorb_new_issues {
1210 true => queue.push_back(wave.absorb(filed)),
1211 false => leftover.extend(filed),
1212 }
1213 }
1214 if !leftover.is_empty() && cfg.loop_cfg.absorb_new_issues > 0 {
1215 log!(
1216 "{} issue(s) filed in the last wave were left for a later run: {}",
1217 leftover.len(),
1218 numbers(&leftover.iter().copied().collect::<Vec<_>>())
1219 );
1220 }
1221 Ok(())
1222}
1223
1224fn positions(
1226 positions: &std::collections::BTreeMap<String, String>,
1227 reasons: &std::collections::BTreeMap<String, String>,
1228) -> String {
1229 if positions.is_empty() {
1230 return "no positions recorded".to_string();
1231 }
1232 positions
1233 .iter()
1234 .map(|(name, side)| match reasons.get(name) {
1235 Some(why) if !why.trim().is_empty() => {
1236 format!("{name} says {side} ({})", first_line(why))
1237 }
1238 _ => format!("{name} says {side}"),
1239 })
1240 .collect::<Vec<_>>()
1241 .join("; ")
1242}
1243
1244fn positions_of(known: &crate::model::Contested) -> String {
1245 positions(&known.positions, &known.reasons)
1246}
1247
1248fn held_back(item: &PlanItem, plan: &Plan, landed: &BTreeSet<i64>) -> Option<String> {
1260 for dep in &item.depends_on {
1261 if landed.contains(dep) {
1262 continue;
1263 }
1264 if plan.order.iter().any(|other| other.issue == *dep) {
1265 return Some(format!(
1266 "held back: #{dep} is in this run and has not merged, so its work is not on the \
1267 base this would be built from. Merge #{dep} and run #{} again.",
1268 item.issue
1269 ));
1270 }
1271 if plan.skipped.iter().any(|s| s.issue == *dep) {
1272 return Some(format!(
1273 "held back: #{dep}, which it depends on, was declined by both reviewers"
1274 ));
1275 }
1276 if plan.contested.iter().any(|c| c.issue == *dep) {
1277 return Some(format!(
1278 "held back: the reviewers disagree about #{dep}, which it depends on"
1279 ));
1280 }
1281 }
1282 None
1283}
1284
1285struct Wave {
1287 numbers: Vec<i64>,
1288 absorbed: u32,
1293 parent: Option<i64>,
1295}
1296
1297impl Wave {
1298 fn first(numbers: Vec<i64>) -> Self {
1299 Self {
1300 numbers,
1301 absorbed: 0,
1302 parent: None,
1303 }
1304 }
1305
1306 fn child(&self, tracker: i64, numbers: Vec<i64>) -> Self {
1307 Self {
1308 numbers,
1309 absorbed: self.absorbed,
1310 parent: Some(tracker),
1311 }
1312 }
1313
1314 fn absorb(&self, numbers: Vec<i64>) -> Self {
1315 Self {
1316 numbers,
1317 absorbed: self.absorbed + 1,
1318 parent: None,
1319 }
1320 }
1321}
1322
1323fn numbers(items: &[i64]) -> String {
1324 items
1325 .iter()
1326 .map(|n| format!("#{n}"))
1327 .collect::<Vec<_>>()
1328 .join(", ")
1329}
1330
1331fn prepare(common: &Common, overrides: Option<Overrides>) -> Result<(Config, Repo, Vec<Agent>)> {
1332 let mut cfg = config::load(common.config.as_deref())?;
1333
1334 if let Some(first) = &common.first {
1335 if !cfg.has_agent(first) {
1336 bail!("--first must be one of: {}", cfg.agent_names().join(", "));
1337 }
1338 cfg.first_implementor = first.clone();
1339 }
1340 if let Some(base) = &common.base {
1341 cfg.loop_cfg.base_branch = base.clone();
1342 }
1343 if let Some(min) = common.min_number {
1344 cfg.loop_cfg.min_number = min;
1345 }
1346 if let Some(extra) = common.instructions.as_deref().map(str::trim) {
1350 if !extra.is_empty() {
1351 let standing = cfg.loop_cfg.instructions.trim();
1352 cfg.loop_cfg.instructions = if standing.is_empty() {
1353 extra.to_string()
1354 } else {
1355 format!("{standing}\n{extra}")
1356 };
1357 }
1358 }
1359 if let Some(over) = overrides {
1360 if let Some(v) = over.max_rounds {
1361 if v == 0 {
1362 bail!("--max-rounds must be at least 1");
1363 }
1364 cfg.loop_cfg.max_rounds = v;
1365 }
1366 if let Some(v) = over.auto_merge {
1367 cfg.loop_cfg.auto_merge = v;
1368 }
1369 if let Some(v) = over.keep_worktrees {
1370 cfg.loop_cfg.keep_worktrees = v;
1371 }
1372 if let Some(v) = over.worktrees {
1373 cfg.loop_cfg.worktrees = v;
1374 }
1375 if let Some(v) = over.close_skipped {
1376 cfg.loop_cfg.close_skipped = v;
1377 }
1378 if let Some(v) = over.absorb {
1379 cfg.loop_cfg.absorb_new_issues = v;
1380 }
1381 }
1382
1383 let repo = Repo::open(&common.repo, &cfg)?;
1384 if common.base.is_none() {
1385 cfg.loop_cfg.base_branch = repo.default_branch(cfg.base_branch());
1386 }
1387
1388 let agents = agent::build(&cfg)?;
1389 if let Some(warning) = agent::correlation_warning(&agents) {
1390 logging::warn(warning);
1391 }
1392
1393 for stale in repo.prune_worktrees(false) {
1395 let what = if stale.starts_with("branch ") {
1396 stale
1397 } else {
1398 format!("worktree {stale}")
1399 };
1400 logdim!("cleaned up finished {what}");
1401 }
1402
1403 log!("repo {} base {}", repo.root().display(), cfg.base_branch());
1404 log!(
1405 "agents: {}, first {}",
1406 agents
1407 .iter()
1408 .map(|a| format!("{}={}", a.name(), a.spec.describe()))
1409 .collect::<Vec<_>>()
1410 .join(", "),
1411 cfg.first_implementor_policy()
1412 );
1413 Ok((cfg, repo, agents))
1414}
1415
1416fn pick_issues(repo: &Repo, given: Vec<i64>, limit: usize, min_number: i64) -> Result<Vec<i64>> {
1417 if !given.is_empty() {
1418 if min_number > 0 {
1420 let below: Vec<String> = given
1421 .iter()
1422 .filter(|n| **n < min_number)
1423 .map(|n| format!("#{n}"))
1424 .collect();
1425 if !below.is_empty() {
1426 logdim!(
1427 "{} below the #{min_number} floor, taking them because you named them",
1428 below.join(", ")
1429 );
1430 }
1431 }
1432 return Ok(given);
1433 }
1434 let found = repo.list_open_issues(limit, min_number)?;
1435 if found.is_empty() {
1436 log!("no open issues");
1437 return Ok(found);
1438 }
1439 log!(
1440 "no issues given, taking {} open: {}",
1441 found.len(),
1442 found
1443 .iter()
1444 .map(|n| format!("#{n}"))
1445 .collect::<Vec<_>>()
1446 .join(", ")
1447 );
1448 Ok(found)
1449}
1450
1451fn pick_for_split(
1464 agents: &[Agent],
1465 cfg: &Config,
1466 repo: &Repo,
1467 limit: usize,
1468 mode: &split::Mode,
1469) -> Result<Vec<(i64, ItemKind)>> {
1470 let issues = repo.list_open_issues(limit, cfg.loop_cfg.min_number)?;
1471 let prs = repo.list_open_prs(limit, cfg.loop_cfg.min_number)?;
1472 log!(
1473 "no numbers given, considering {} open issue(s) and {} open pull request(s)",
1474 issues.len(),
1475 prs.len()
1476 );
1477 if issues.is_empty() && prs.is_empty() {
1478 return Ok(Vec::new());
1479 }
1480
1481 let issue_rows = repo.open_issue_rows();
1482 let pr_rows = repo.open_pr_rows();
1483 let mut candidates: Vec<split::Candidate> = Vec::new();
1484 let mut already = 0usize;
1485
1486 for number in &issues {
1487 let Some(row) = issue_rows.iter().find(|i| i.number == *number) else {
1488 logdim!("could not read #{number}, leaving it alone");
1489 continue;
1490 };
1491 if split::already_split(row.body_text()) && !mode.again {
1494 already += 1;
1495 continue;
1496 }
1497 candidates.push(split::Candidate::from_issue(row));
1498 }
1499 for number in &prs {
1500 match pr_rows.iter().find(|p| p.number == *number) {
1501 Some(row) => candidates.push(split::Candidate::from_pr(row)),
1502 None => logdim!("could not read PR #{number}, leaving it alone"),
1503 }
1504 }
1505 if already > 0 {
1506 log!("{already} issue(s) already split, skipped. --again reopens them.");
1507 }
1508 if candidates.is_empty() {
1509 return Ok(Vec::new());
1510 }
1511
1512 let agent = agent::find(agents, &cfg.first_implementor)?;
1513 log!(
1514 "screening {} item(s) with {}",
1515 candidates.len(),
1516 agent.name()
1517 );
1518 let verdicts = split::screen(agent, cfg, repo, &candidates)?;
1519
1520 let mut picked = Vec::new();
1521 for candidate in &candidates {
1522 match verdicts.iter().find(|v| v.item == candidate.number) {
1523 Some(v) if v.split => {
1524 log!(" split #{}: {}", candidate.number, v.reason.trim());
1525 picked.push((candidate.number, candidate.kind));
1526 }
1527 Some(v) => logdim!(" leave #{} whole: {}", candidate.number, v.reason.trim()),
1530 None => logdim!(" no verdict for #{}, leaving it whole", candidate.number),
1531 }
1532 }
1533 if picked.is_empty() {
1534 log!("nothing worth splitting");
1535 }
1536 Ok(picked)
1537}
1538
1539#[derive(Debug, Default)]
1545struct Sorted {
1546 issues: Vec<i64>,
1547 prs: Vec<i64>,
1548}
1549
1550fn classify(repo: &Repo, numbers: &[i64]) -> Result<Sorted> {
1551 let mut sorted = Sorted::default();
1552 for number in numbers {
1553 match repo.item_kind(*number)? {
1554 ItemKind::Issue => sorted.issues.push(*number),
1555 ItemKind::Pr => sorted.prs.push(*number),
1556 }
1557 }
1558 if !sorted.issues.is_empty() && !sorted.prs.is_empty() {
1559 log!(
1560 "{} issue(s) and {} pull request(s) given",
1561 sorted.issues.len(),
1562 sorted.prs.len()
1563 );
1564 }
1565 Ok(sorted)
1566}
1567
1568fn issues_covered_by(repo: &Repo, prs: &[i64]) -> BTreeMap<i64, i64> {
1581 let mut covered = BTreeMap::new();
1582 for number in prs {
1583 match repo.pr_view(*number) {
1584 Ok(view) => {
1585 for issue in &view.closing_issues_references {
1586 covered.insert(issue.number, *number);
1587 }
1588 }
1589 Err(e) => logwarn!(
1590 "#{number}: could not read which issue(s) it closes, so none are skipped: {}",
1591 e.last_line()
1592 ),
1593 }
1594 }
1595 covered
1596}
1597
1598fn issues_not_on_named_prs(issues: &[i64], covered: &BTreeMap<i64, i64>) -> Vec<i64> {
1601 issues
1602 .iter()
1603 .copied()
1604 .filter(|issue| !covered.contains_key(issue))
1605 .collect()
1606}
1607
1608fn make_plan(
1609 agents: &[Agent],
1610 cfg: &Config,
1611 repo: &Repo,
1612 issues: &[Issue],
1613 plan_out: &Path,
1614) -> Result<Plan> {
1615 let plan = triage::triage(agents, cfg, repo, issues)?;
1616
1617 std::fs::write(plan_out, serde_json::to_vec_pretty(&plan)?)
1618 .map_err(|e| spar_err!("could not write {}: {e}", plan_out.display()))?;
1619 log!("plan written to {}", plan_out.display());
1620
1621 for item in &plan.order {
1622 let after = if item.depends_on.is_empty() {
1625 String::new()
1626 } else {
1627 format!(
1628 " after {}",
1629 item.depends_on
1630 .iter()
1631 .map(|n| format!("#{n}"))
1632 .collect::<Vec<_>>()
1633 .join(", ")
1634 )
1635 };
1636 log!(
1637 " do #{} [{}/{}]{after} {}",
1638 item.issue,
1639 item.complexity,
1640 item.risk,
1641 item.title
1642 );
1643 }
1644 for item in &plan.skipped {
1645 if item.tracker {
1646 log!(
1647 " hold #{} (both reviewers: tracks work filed elsewhere)",
1648 item.issue
1649 );
1650 } else {
1651 log!(" skip #{} (both reviewers: not worth doing)", item.issue);
1652 }
1653 }
1654 for item in &plan.contested {
1655 log!(" ?? #{} contested, parked for you to decide", item.issue);
1656 }
1657 Ok(plan)
1658}
1659
1660fn act_on_plan(cfg: &Config, repo: &Repo, plan: &Plan) {
1670 for item in &plan.skipped {
1671 let body = review::skip_comment(item, &repo.style);
1672 let close = cfg.loop_cfg.close_skipped && !item.tracker;
1673 let outcome = if close {
1674 repo.close_issue(item.issue, &body)
1675 } else {
1676 repo.comment_issue(item.issue, &body)
1677 };
1678 match outcome {
1679 Ok(()) if close => log!(" closed #{}", item.issue),
1680 Ok(()) if item.tracker => {
1681 log!(
1682 " left #{} open, it tracks work filed elsewhere",
1683 item.issue
1684 )
1685 }
1686 Ok(()) => {}
1687 Err(e) => logdim!("could not update #{}: {e}", item.issue),
1688 }
1689 }
1690}
1691
1692fn cmd_scrub_filter() -> Result<i32> {
1697 let mut input = String::new();
1698 std::io::stdin()
1699 .read_to_string(&mut input)
1700 .map_err(|e| spar_err!("could not read a commit message from stdin: {e}"))?;
1701 let out = style::scrub(&input, &crate::repo::style_from_env());
1702 let mut stdout = std::io::stdout();
1703 stdout
1704 .write_all(out.as_bytes())
1705 .and_then(|_| stdout.write_all(b"\n"))
1706 .map_err(|e| spar_err!("could not write the scrubbed message: {e}"))?;
1707 Ok(0)
1708}
1709
1710fn cmd_clean(
1711 repo_path: &Path,
1712 config_path: Option<&Path>,
1713 all: bool,
1714 pr_state: bool,
1715) -> Result<i32> {
1716 let cfg = config::load(config_path)?;
1717 let repo = Repo::open(repo_path, &cfg)?;
1718 let mut removed = repo.prune_worktrees(all);
1719 removed.extend(repo.prune_state());
1720 if pr_state {
1721 removed.extend(repo.prune_pr_state(None));
1722 }
1723 if removed.is_empty() {
1724 if repo.write_summary().failed > 0 {
1725 println!("nothing removed");
1726 } else {
1727 println!("nothing to clean");
1728 }
1729 } else {
1730 for item in removed {
1731 println!("removed {item}");
1732 }
1733 }
1734 Ok(report_writes(&repo))
1735}
1736
1737fn cmd_post(
1739 prs: &[i64],
1740 repo_path: &Path,
1741 config_path: Option<&Path>,
1742 file: Option<&Path>,
1743 dry_run: bool,
1744) -> Result<i32> {
1745 let cfg = config::load(config_path)?;
1746 let repo = Repo::open(repo_path, &cfg)?;
1747
1748 if file.is_some() && prs.len() > 1 {
1749 bail!("--file posts one review, so give it one pull request number");
1750 }
1751
1752 let mut failed = false;
1753 for number in prs {
1754 let text = match file {
1755 Some(path) => std::fs::read_to_string(path)
1756 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?,
1757 None => match repo.read_pending_comment(*number) {
1758 Some(text) => text,
1759 None => {
1760 logging::error(format!(
1761 "no saved review for PR #{number}. `spar review {number} --dry-run` \
1762 produces one, or pass --file."
1763 ));
1764 failed = true;
1765 continue;
1766 }
1767 },
1768 };
1769 if text.trim().is_empty() {
1770 logging::error(format!("the saved review for PR #{number} is empty"));
1771 failed = true;
1772 continue;
1773 }
1774 if dry_run {
1775 println!("\n{}\n", text.trim());
1776 log!("would post the above to PR #{number}");
1777 continue;
1778 }
1779 match repo.comment_pr(*number, &text) {
1782 Ok(()) => {
1783 log!("posted to PR #{number}");
1784 if let Some(mut record) = repo.read_review_record(*number) {
1787 if !record.posted {
1788 record.posted = true;
1789 if let Err(e) = repo.write_review_record(*number, &record) {
1790 logdim!("could not mark the review record posted: {e}");
1791 }
1792 }
1793 }
1794 }
1795 Err(e) => {
1796 logging::error(format!("could not post to PR #{number}: {e}"));
1797 failed = true;
1798 }
1799 }
1800 }
1801 Ok(i32::from(failed).max(report_writes(&repo)))
1802}
1803
1804#[allow(clippy::too_many_arguments)]
1810fn cmd_brainstorm(
1811 topic: Vec<String>,
1812 common: &Common,
1813 max_rounds: Option<u32>,
1814 ideas: usize,
1815 out: Option<PathBuf>,
1816 file_issues: bool,
1817 from: Option<&Path>,
1818) -> Result<i32> {
1819 if ideas == 0 {
1820 bail!("--ideas must be at least 1");
1821 }
1822 if let Some(path) = from {
1823 let cfg = config::load(common.config.as_deref())?;
1824 let repo = Repo::open(&common.repo, &cfg)?;
1825 let text = std::fs::read_to_string(path)
1826 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
1827 let entries = brainstorm::parse_session(&text);
1828 if entries.is_empty() {
1829 bail!(
1830 "no ideas in {}: an idea is a `## ` heading with its sections under it",
1831 path.display()
1832 );
1833 }
1834 log!(
1835 "filing {} idea{} from {}",
1836 entries.len(),
1837 if entries.len() == 1 { "" } else { "s" },
1838 path.display()
1839 );
1840 brainstorm::file_ideas(&repo, &entries);
1841 return Ok(report_writes(&repo));
1842 }
1843
1844 let overrides = Overrides {
1845 max_rounds,
1846 ..Overrides::default()
1847 };
1848 let (cfg, repo, agents) = prepare(common, Some(overrides))?;
1849 let joined = topic.join(" ").trim().to_string();
1850 let session = brainstorm::Session {
1851 topic: (!joined.is_empty()).then_some(joined),
1852 budget: cfg.loop_cfg.max_rounds,
1853 wanted: ideas,
1854 };
1855 let now = clock::now_secs();
1856 let outcome = brainstorm::run(&agents, &cfg, &repo, &session)?;
1857 let path = out.unwrap_or_else(|| brainstorm::default_path(&repo, &session, now));
1858 let text = brainstorm::render_session(&outcome, &clock::date_line(now), &repo.style);
1859 crate::repo::write_text_atomic(&path, &text)?;
1860 brainstorm::print_summary(&outcome, &path);
1861 if file_issues {
1862 println!();
1863 brainstorm::file_ideas(&repo, &brainstorm::issue_entries(&outcome, &repo.style));
1864 }
1865
1866 let spent = spend::taken();
1867 if let Err(e) = repo.record_spend(&spent) {
1868 logdim!("could not record what this run spent: {e}");
1869 }
1870 if let Some(summary) = spend::summary(&spent) {
1871 println!("\n{summary}");
1872 }
1873 Ok(report_writes(&repo))
1874}
1875
1876fn cmd_init_update(out: &Path) -> Result<i32> {
1882 let text = std::fs::read_to_string(out)
1883 .map_err(|e| spar_err!("could not read {}: {e}", out.display()))?;
1884 config::parse(&text).map_err(|e| spar_err!("{} does not parse: {e}", out.display()))?;
1887
1888 let unset = config::unmentioned_options(&text);
1889 if unset.is_empty() {
1890 println!("{} already mentions every setting.", out.display());
1891 return Ok(0);
1892 }
1893
1894 let mut block = String::new();
1895 if !text.ends_with('\n') {
1896 block.push('\n');
1897 }
1898 block.push_str("\n# Added by `spar init --update`: settings this file did not mention,\n");
1899 block.push_str("# shown at their defaults. Uncomment one to change it.\n");
1900 let mut section = "";
1901 for option in &unset {
1902 if option.section != section {
1903 section = option.section;
1904 block.push_str(&format!("\n# [{section}]\n"));
1905 }
1906 block.push('\n');
1911 block.push_str(&wrap_comment(note_for(&option.key)));
1912 block.push_str(&format!("# {} = {}\n", option.key, option.default));
1913 }
1914
1915 use std::io::Write;
1916 std::fs::OpenOptions::new()
1917 .append(true)
1918 .open(out)
1919 .and_then(|mut f| f.write_all(block.as_bytes()))
1920 .map_err(|e| spar_err!("could not append to {}: {e}", out.display()))?;
1921
1922 println!(
1923 "added {} setting(s) to {} as comments",
1924 unset.len(),
1925 out.display()
1926 );
1927 Ok(0)
1928}
1929
1930fn cmd_init(out: &Path, force: bool) -> Result<i32> {
1931 if out.exists() && !force {
1932 logging::error(format!(
1933 "{} already exists. `--update` appends any settings it does not mention, \
1934 `--force` overwrites it.",
1935 out.display()
1936 ));
1937 return Ok(1);
1938 }
1939
1940 let presets = config::available_presets();
1941 if presets.is_empty() {
1942 bail!("no presets available, which should be impossible in a released build");
1943 }
1944
1945 let mut found: Vec<(String, PathBuf, config::AgentSpec)> = Vec::new();
1946 for name in &presets {
1947 let raw = config::load_preset(name)?;
1948 let mut spec: config::AgentSpec = match raw
1952 .as_table()
1953 .cloned()
1954 .ok_or_else(|| spar_err!("not a table"))
1955 .and_then(|t| {
1956 toml::Value::Table(t)
1957 .try_into()
1958 .map_err(|e| spar_err!("{e}"))
1959 }) {
1960 Ok(spec) => spec,
1961 Err(e) => {
1962 println!(" BROKEN {name:10} {}", e.first_line());
1963 continue;
1964 }
1965 };
1966 spec.name = name.clone();
1967 match Agent::new(spec.clone()).resolve_bin() {
1968 Ok(path) => {
1969 println!(" found {name:10} {}", path.display());
1970 found.push((name.clone(), path.to_path_buf(), spec));
1971 }
1972 Err(_) => println!(" missing {name}"),
1973 }
1974 }
1975
1976 if found.len() < 2 {
1977 logging::error(format!(
1978 "need two agent CLIs, found {}. Install another, or write {} by hand using the \
1979 presets as a reference.",
1980 found.len(),
1981 out.display()
1982 ));
1983 return Ok(1);
1984 }
1985
1986 let chosen: Vec<&(String, PathBuf, config::AgentSpec)> = found.iter().take(2).collect();
1988 if found.len() > 2 {
1989 log!(
1990 "{} agents available, picking {} and {}. Edit {} to change.",
1991 found.len(),
1992 chosen[0].0,
1993 chosen[1].0,
1994 out.display()
1995 );
1996 }
1997
1998 let mut text = String::from(
1999 "# Generated by `spar init`. Each agent inherits a command template from a\n\
2000 # built in preset; anything set here overrides it.\n\
2001 #\n\
2002 # Commented lines are the other options, each with a working value.\n\
2003 # Uncomment one to change it.\n\n",
2004 );
2005 for (name, _, spec) in &chosen {
2006 text.push_str(&agent_block(name, spec));
2007 }
2008 text.push_str(&settings_block(&chosen[0].0));
2009
2010 std::fs::write(out, text).map_err(|e| spar_err!("could not write {}: {e}", out.display()))?;
2011 println!("\nwrote {}", out.display());
2012 println!("Next: `spar doctor` to check it, then `spar run` in a repo you have push access to.");
2013 Ok(0)
2014}
2015
2016fn report_fallback(agent: &Agent) {
2022 let Some(backup) = agent.fallback() else {
2023 return;
2024 };
2025 match backup.resolve_bin() {
2026 Ok(bin) => println!(
2027 " fallback {} ({})",
2028 bin.display(),
2029 backup.spec.describe()
2030 ),
2031 Err(_) => println!(
2032 " fallback {} not found, so it will not stand in. Set {} to its path.",
2033 backup.program(),
2034 backup.env_key()
2035 ),
2036 }
2037}
2038
2039type Setting = (bool, &'static str, &'static str);
2050
2051const LOOP_OPTIONS: &[Setting] = &[
2052 (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."),
2053 (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."),
2054 (false, "first_implementor", "Which agent takes the first pass. The other one reviews it."),
2055 (false, "worktrees", "Isolate each issue in its own git worktree. Set false to work in the main checkout."),
2056 (false, "close_skipped", "Close an issue both reviewers declined, after posting the shared reasoning. A tracking issue is left open whatever this says."),
2057 (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."),
2058 (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."),
2059 (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."),
2060 (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."),
2061 (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."),
2062 (true, "keep_worktrees", "Keep worktrees after a run, for inspection."),
2063 (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."),
2064 (true, "parallel_triage", "Ask both agents to triage at once. They only read during triage, so there is nothing to serialise."),
2065 (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."),
2066 (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."),
2067 (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."),
2068 (true, "file_nits", "File nits as follow-ups too. Off, because a filed nit is somebody else's notification."),
2069 (true, "base_branch", "Only a fallback. Whatever origin/HEAD points at wins when it resolves."),
2070 (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."),
2071 (true, "state_store", "Where resume state is kept. local uses .spar/state and keeps it off the pull request."),
2072 (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."),
2073 (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."),
2074 (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."),
2075 (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."),
2076 (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 is always read under it. anyone answers everyone, and still only changes code when both agents agree."),
2077 (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."),
2078 (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."),
2079];
2080
2081const STYLE_OPTIONS: &[Setting] = &[
2082 (false, "ban_em_dash", "Strip em-dashes and en-dashes from everything spar posts, then refuse to post text that still has one."),
2083 (false, "ban_ai_attribution", "Strip mentions of the tooling, and Co-Authored-By trailers, from everything spar posts."),
2084 (false, "terse", "Hold model prose to a length budget. false removes the valves entirely."),
2085 (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."),
2086 (true, "max_title_chars", "A finding, issue, or pull request title. Never ellipsised: a title ending in three dots reads as broken."),
2087 (true, "max_summary_chars", "A one line verdict, or a refutation's argument."),
2088 (true, "max_detail_chars", "A blocking finding's explanation, as it appears in the pull request thread."),
2089 (true, "max_body_chars", "A pull request body."),
2090 (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."),
2091];
2092
2093const LISTEN_OPTIONS: &[Setting] = &[
2094 (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."),
2095 (true, "sweep", "Seconds between sweeps of the open pull requests. A push raises no notification for a reviewer, so the sweep is what notices a reviewed pull request moving."),
2096 (true, "settle", "Seconds an item has to stay quiet before spar acts on it, so a push and three replies in the same minute cost one recheck rather than four."),
2097 (true, "max_wait", "Most seconds an item may keep settling before it is acted on anyway."),
2098 (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."),
2099 (true, "on_review_requested", "What a pull request somebody asked you to review gets: review or ignore."),
2100 (true, "on_assigned", "What a pull request assigned to you gets: review or ignore."),
2101 (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."),
2102 (false, "verdicts", "Whether a review `spar listen` starts submits a formal verdict, approve or request changes, rather than a comment. A recheck always does."),
2103];
2104
2105fn settings_block(first_implementor: &str) -> String {
2110 let defaults: std::collections::BTreeMap<String, String> = config::known_options()
2111 .into_iter()
2112 .map(|option| (option.key, option.default))
2113 .collect();
2114 let value = |key: &str| match key {
2117 "first_implementor" => format!("\"{first_implementor}\""),
2118 other => defaults.get(other).cloned().unwrap_or_default(),
2119 };
2120
2121 let mut out = String::from("[loop]\n");
2122 out.push_str(&option_lines(LOOP_OPTIONS, &value));
2123 out.push_str(concat!(
2124 "\n[loop.effort_schedule]\n",
2125 "# One key per kind of call, so a value means what its name says. Values\n",
2126 "# are whatever each agent's own CLI accepts, listed above, so these are\n",
2127 "# examples rather than defaults. Left out, a call falls back to\n",
2128 "# round_1 or rest, and then to the effort the agent's own block asked\n",
2129 "# for. Where the two CLIs do not share a vocabulary, put the schedule\n",
2130 "# under [agents.NAME.effort_schedule] instead, with the same keys.\n",
2131 "# triage = \"low\" # both agents, over the whole queue, per wave\n",
2132 "# implement = \"high\" # writing the first draft\n",
2133 "# review_1 = \"high\" # the deep first review\n",
2134 "# review_rest = \"low\" # later rounds, which see a small delta\n",
2135 "# respond = \"high\" # the author answering a review\n",
2136 "# close = \"low\" # the closing merge safety pass\n",
2137 "# screen = \"low\" # the follow-up screen, one call for the queue\n",
2138 "# checkin = \"high\" # judging comments other people left\n",
2139 "# split = \"high\" # proposing and checking a split\n",
2140 "# brainstorm = \"high\" # every brainstorm round\n",
2141 "# recheck = \"low\" # a second look after commits or replies\n",
2142 "# round_1 = \"high\" # read by any call above with no key set\n",
2143 "# rest = \"low\" # the same, for later rounds and the close\n\n",
2144 ));
2145 out.push_str("[style]\n");
2146 out.push_str(&option_lines(STYLE_OPTIONS, &value));
2147 out.push_str("\n[listen]\n");
2148 out.push_str(&option_lines(LISTEN_OPTIONS, &value));
2149 out
2150}
2151
2152fn option_lines(options: &[Setting], value: &dyn Fn(&str) -> String) -> String {
2156 let mut out = String::new();
2157 for (commented, key, note) in options {
2158 if !out.is_empty() {
2159 out.push('\n');
2160 }
2161 out.push_str(&wrap_comment(note));
2162 let lead = if *commented { "# " } else { "" };
2163 out.push_str(&format!("{lead}{key} = {}\n", value(key)));
2164 }
2165 out
2166}
2167
2168type Probe = Box<dyn Fn() -> Result<String>>;
2171
2172fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
2179 let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
2180
2181 if let Some(extra) = &spec.options_note {
2193 if !spec.models.is_empty() || !spec.efforts.is_empty() {
2194 out.push('\n');
2195 out.push_str(&wrap_comment(extra));
2196 }
2197 }
2198 for (key, choices) in [("model", &spec.models), ("effort", &spec.efforts)] {
2199 let Some(suggested) = choices.first() else {
2200 continue;
2201 };
2202 let mut note = format!("Omit {key} to use the CLI's own default.");
2203 if choices.len() > 1 {
2204 note.push_str(&format!(" One of: {}.", choices.join(" | ")));
2205 }
2206 out.push('\n');
2207 out.push_str(&wrap_comment(¬e));
2208 out.push_str(&format!("# {key} = \"{suggested}\"\n"));
2209 }
2210
2211 out.push('\n');
2215 out.push_str(&wrap_comment(
2216 "Seconds one call may take before spar gives up. A timeout costs the whole call and is \
2217 never retried, so err long.",
2218 ));
2219 out.push_str(&format!("# timeout = {}\n", spec.timeout));
2220
2221 let backup = if name == "cursor" { "gemini" } else { "cursor" };
2224 out.push('\n');
2225 out.push_str(&wrap_comment(
2226 "A stand in for when this CLI refuses, stalls, or runs out of quota. It answers in place \
2227 of this agent, never alongside it.",
2228 ));
2229 out.push_str(&format!(
2230 "# [agents.{name}.fallback]\n# preset = \"{backup}\"\n"
2231 ));
2232
2233 out.push('\n');
2238 out.push_str(&wrap_comment(
2239 "command, output, search_paths and the rest are in spar.example.toml, for pairing a CLI \
2240 that has no preset.",
2241 ));
2242 out.push('\n');
2243 out
2244}
2245
2246fn note_for(key: &str) -> &'static str {
2252 LOOP_OPTIONS
2253 .iter()
2254 .chain(STYLE_OPTIONS)
2255 .chain(LISTEN_OPTIONS)
2256 .find(|(_, name, _)| *name == key)
2257 .map(|(_, _, note)| *note)
2258 .unwrap_or("")
2259}
2260
2261fn wrap_comment(text: &str) -> String {
2263 const WIDTH: usize = 76;
2264 let mut out = String::new();
2265 let mut line = String::from("#");
2266 for word in text.split_whitespace() {
2267 if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
2268 out.push_str(&line);
2269 out.push('\n');
2270 line = String::from("#");
2271 }
2272 line.push(' ');
2273 line.push_str(word);
2274 }
2275 if line.len() > 1 {
2276 out.push_str(&line);
2277 out.push('\n');
2278 }
2279 out
2280}
2281
2282fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
2283 let mut ok = true;
2284
2285 let probes: Vec<(&str, Probe)> = vec![
2286 (
2287 "git",
2288 Box::new(|| {
2289 proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
2290 .map(|s| first_line(&s))
2291 }),
2292 ),
2293 (
2294 "gh",
2295 Box::new(|| {
2296 proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
2297 .map(|s| first_line(&s))
2298 }),
2299 ),
2300 (
2301 "gh auth",
2302 Box::new(|| {
2303 let out = proc::exec(
2304 &["gh".into(), "auth".into(), "status".into()],
2305 &ExecOpts::new().check(false).timeout_secs(60),
2306 )?;
2307 let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
2308 if out.ok() {
2309 Ok(first_line(&text))
2310 } else {
2311 Err(spar_err!("not authenticated. Run `gh auth login`."))
2312 }
2313 }),
2314 ),
2315 ];
2316
2317 for (label, probe) in probes {
2318 match probe() {
2319 Ok(detail) => println!(" ok {label:12} {detail}"),
2320 Err(e) => {
2321 println!(" FAIL {label:12} {}", e.first_line());
2322 ok = false;
2323 }
2324 }
2325 }
2326
2327 let found = config::find_config(config_path)?;
2328 let Some(path) = found else {
2329 println!("\n no spar.toml found. Run `spar init` to generate one.");
2330 println!(
2331 " presets available: {}",
2332 config::available_presets().join(", ")
2333 );
2334 return Ok(if ok { 0 } else { 1 });
2335 };
2336
2337 println!("\n config: {}", path.display());
2338 let cfg = match config::load(Some(&path)) {
2339 Ok(cfg) => cfg,
2340 Err(e) => {
2341 println!(" FAIL config {e}");
2342 return Ok(1);
2343 }
2344 };
2345
2346 let mut resolved = Vec::new();
2349 for spec in &cfg.agents {
2350 let agent = Agent::new(spec.clone());
2351 match agent.resolve_bin() {
2352 Ok(bin) => {
2353 println!(
2354 " ok {:12} {} ({})",
2355 spec.name,
2356 bin.display(),
2357 spec.describe()
2358 );
2359 report_fallback(&agent);
2360 resolved.push(agent);
2361 }
2362 Err(e) => {
2363 println!(" FAIL {:12} {}", spec.name, e.first_line());
2364 ok = false;
2365 }
2366 }
2367 }
2368
2369 if resolved.len() == cfg.agents.len() {
2370 if let Some(warning) = agent::correlation_warning(&resolved) {
2371 println!("\n WARNING {warning}");
2372 }
2373 }
2374
2375 for line in unknown_effort_values(&cfg) {
2376 println!("\n WARNING {line}");
2377 }
2378
2379 println!(
2380 "\n settings: first={} max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
2381 cfg.first_implementor_policy(),
2382 cfg.loop_cfg.max_rounds,
2383 cfg.loop_cfg.auto_merge,
2384 cfg.loop_cfg.worktrees,
2385 cfg.loop_cfg.followups,
2386 cfg.style.terse
2387 );
2388 if let Ok(text) = std::fs::read_to_string(&path) {
2392 let unset = config::unmentioned_options(&text);
2393 if !unset.is_empty() {
2394 println!(
2395 "\n {} setting(s) this config does not mention, all at their defaults:",
2396 unset.len()
2397 );
2398 for option in &unset {
2399 println!(
2400 " [{}] {} = {}",
2401 option.section, option.key, option.default
2402 );
2403 }
2404 println!(
2405 " `spar init --update {}` appends them as comments.",
2406 path.display()
2407 );
2408 }
2409 }
2410
2411 println!(
2412 "{}",
2413 if ok {
2414 "\nready"
2415 } else {
2416 "\nmissing prerequisites"
2417 }
2418 );
2419 Ok(if ok { 0 } else { 1 })
2420}
2421
2422fn unknown_effort_values(cfg: &config::Config) -> Vec<String> {
2431 let mut out = Vec::new();
2432 for spec in &cfg.agents {
2433 if spec.efforts.is_empty() {
2434 continue;
2435 }
2436 let mut said: Vec<String> = Vec::new();
2437 for call in config::Call::every() {
2438 let Some(value) = cfg.effort_for(spec, call) else {
2439 continue;
2440 };
2441 let known = spec
2442 .efforts
2443 .iter()
2444 .any(|hint| hint.eq_ignore_ascii_case(value.trim()));
2445 if known || said.contains(&value) {
2446 continue;
2447 }
2448 said.push(value.clone());
2449 out.push(format!(
2450 "{} is asked for effort \"{value}\" on the {} call, which is not one it is known \
2451 to accept ({}). Put this agent's values under \
2452 [agents.{}.effort_schedule].",
2453 spec.name,
2454 call.key(),
2455 spec.efforts.join(" | "),
2456 spec.name
2457 ));
2458 }
2459 }
2460 out
2461}
2462
2463fn first_line(text: &str) -> String {
2464 text.trim().lines().next().unwrap_or("").trim().to_string()
2465}
2466
2467fn report(results: &[IssueRun], cfg: &Config, repo: &Repo) -> i32 {
2472 report_with(results, cfg, repo, &[], &[])
2473}
2474
2475fn report_with(
2482 results: &[IssueRun],
2483 cfg: &Config,
2484 repo: &Repo,
2485 stopped: &[String],
2486 parked: &[String],
2487) -> i32 {
2488 let spent = spend::taken();
2491 if let Err(e) = repo.record_spend(&spent) {
2492 logdim!("could not record what this run spent: {e}");
2493 }
2494 println!("\n{}", "=".repeat(60));
2495 for r in results {
2496 print_outcome(r, &spent);
2497 }
2498 println!("{}", "=".repeat(60));
2499
2500 if cfg.alternate_first && results.len() > 1 {
2501 println!(
2502 "\nfirst implementor alternated, starting with {}. Who wrote each first draft is in \
2503 .spar/state/implementors.json.",
2504 cfg.first_implementor
2505 );
2506 }
2507 if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
2508 println!("\nApproved PRs are waiting on you to merge.");
2509 }
2510 let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
2511 if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
2512 println!(
2513 "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
2514 Set followups = \"issues\" to file them."
2515 );
2516 }
2517 if let Some(summary) = spend::summary(&spent) {
2518 println!("\n{summary}");
2519 }
2520 for lines in [parked, stopped] {
2521 if lines.is_empty() {
2522 continue;
2523 }
2524 println!();
2525 for line in lines {
2526 println!("{line}");
2527 }
2528 }
2529 let issue_exit = i32::from(!results.iter().all(IssueRun::succeeded) || !stopped.is_empty());
2530 issue_exit.max(report_writes(repo))
2531}
2532
2533pub(crate) fn print_outcome(r: &IssueRun, spent: &[spend::Spent]) {
2535 println!(
2536 "#{:<5} {:<10} rounds={} {}",
2537 r.issue,
2538 r.status.to_string(),
2539 r.rounds,
2540 r.pr.as_deref().unwrap_or("")
2541 );
2542 for note in &r.notes {
2543 println!(" {}", first_line(note));
2544 }
2545 for url in &r.filed {
2546 println!(" filed {url}");
2547 }
2548 for dispute in &r.disputes {
2549 println!(
2550 " disputed: {}",
2551 report_item(&dispute.title, &dispute.file)
2552 );
2553 }
2554 for finding in &r.noted {
2555 println!(
2556 " noted, not blocking: {}",
2557 report_item(&finding.title, &finding.file)
2558 );
2559 }
2560 if let Some(cost) = spend::for_subject(spent, r.issue) {
2561 println!(" {cost}");
2562 }
2563}
2564
2565pub(crate) fn report_writes(repo: &Repo) -> i32 {
2570 let writes = repo.write_summary();
2571 if let Some(line) = write_summary_line(writes) {
2572 println!("\n{line}");
2573 }
2574 write_exit_code(writes)
2575}
2576
2577fn write_summary_line(writes: WriteSummary) -> Option<String> {
2578 (writes.attempted > 0).then(|| {
2579 format!(
2580 "writes: {} attempted, {} succeeded, {} failed",
2581 writes.attempted,
2582 writes.succeeded(),
2583 writes.failed
2584 )
2585 })
2586}
2587
2588fn write_exit_code(writes: WriteSummary) -> i32 {
2589 i32::from(writes.failed > 0)
2590}
2591
2592fn report_item(title: &str, file: &str) -> String {
2593 match file.trim() {
2594 "" => title.to_string(),
2595 location => format!("{title} ({location})"),
2596 }
2597}
2598
2599#[cfg(test)]
2600mod tests {
2601 use super::*;
2602 use clap::CommandFactory;
2603
2604 fn planned(issue: i64, depends_on: Vec<i64>) -> PlanItem {
2605 PlanItem {
2606 issue,
2607 title: format!("issue {issue}"),
2608 complexity: crate::model::Complexity::S,
2609 risk: crate::model::Risk::Low,
2610 depends_on,
2611 reason: "worth doing".into(),
2612 }
2613 }
2614
2615 #[test]
2624 fn a_dependent_waits_for_a_dependency_that_has_not_landed() {
2625 let plan = Plan {
2626 order: vec![planned(9, vec![]), planned(10, vec![9])],
2627 ..Plan::default()
2628 };
2629 let mut landed = BTreeSet::new();
2630
2631 let why = held_back(&plan.order[1], &plan, &landed).expect("held");
2632 assert!(why.contains("#9"), "{why}");
2633 assert!(why.contains("has not merged"), "{why}");
2634 assert!(held_back(&plan.order[0], &plan, &landed).is_none());
2635
2636 landed.insert(9);
2638 assert!(held_back(&plan.order[1], &plan, &landed).is_none());
2639 }
2640
2641 #[test]
2644 fn a_dependent_on_a_declined_or_contested_issue_is_held_with_the_reason() {
2645 let declined = Plan {
2646 order: vec![planned(10, vec![9])],
2647 skipped: vec![crate::model::SkippedItem {
2648 issue: 9,
2649 title: "issue 9".into(),
2650 reasons: Default::default(),
2651 tracker: false,
2652 }],
2653 ..Plan::default()
2654 };
2655 let why = held_back(&declined.order[0], &declined, &BTreeSet::new()).expect("held");
2656 assert!(why.contains("declined by both reviewers"), "{why}");
2657
2658 let contested = Plan {
2659 order: vec![planned(10, vec![9])],
2660 contested: vec![crate::model::ContestedItem {
2661 issue: 9,
2662 title: "issue 9".into(),
2663 positions: Default::default(),
2664 reasons: Default::default(),
2665 note: None,
2666 }],
2667 ..Plan::default()
2668 };
2669 let why = held_back(&contested.order[0], &contested, &BTreeSet::new()).expect("held");
2670 assert!(why.contains("disagree about #9"), "{why}");
2671 }
2672
2673 #[test]
2677 fn a_dependency_outside_the_run_does_not_hold_anything() {
2678 let plan = Plan {
2679 order: vec![planned(10, vec![900])],
2680 ..Plan::default()
2681 };
2682 assert!(held_back(&plan.order[0], &plan, &BTreeSet::new()).is_none());
2683 }
2684
2685 #[test]
2691 fn doctor_says_when_a_scheduled_effort_is_not_one_the_agent_accepts() {
2692 let text = "\
2693[agents.claude]
2694preset = \"claude\"
2695
2696[agents.codex]
2697preset = \"codex\"
2698
2699[loop.effort_schedule]
2700review_1 = \"ultra\"
2701";
2702 let cfg = config::parse(text).expect("parses");
2703 let said = unknown_effort_values(&cfg);
2704 assert_eq!(1, said.len(), "{said:?}");
2705 assert!(said[0].starts_with("claude"), "{}", said[0]);
2706 assert!(said[0].contains("ultra"), "{}", said[0]);
2707 assert!(said[0].contains("review_1"), "{}", said[0]);
2708 assert!(
2709 said[0].contains("[agents.claude.effort_schedule]"),
2710 "it has to say where to put the right value: {}",
2711 said[0]
2712 );
2713
2714 let quiet = text.replace(
2716 "[loop.effort_schedule]\nreview_1 = \"ultra\"\n",
2717 "[agents.claude.effort_schedule]\nreview_1 = \"xhigh\"\n\n\
2718 [agents.codex.effort_schedule]\nreview_1 = \"ultra\"\n",
2719 );
2720 assert!(unknown_effort_values(&config::parse(&quiet).expect("parses")).is_empty());
2721 }
2722
2723 #[test]
2724 fn a_contested_issue_reads_as_both_positions_with_their_reasons() {
2725 let mut positions_map = std::collections::BTreeMap::new();
2726 positions_map.insert("claude".to_string(), "do".to_string());
2727 positions_map.insert("codex".to_string(), "skip".to_string());
2728 let mut reasons = std::collections::BTreeMap::new();
2729 reasons.insert(
2730 "claude".to_string(),
2731 "the retry path is still wrong".to_string(),
2732 );
2733 reasons.insert(
2734 "codex".to_string(),
2735 "fixed in the rewrite last month".to_string(),
2736 );
2737
2738 let line = positions(&positions_map, &reasons);
2739 assert!(line.contains("claude says do"), "{line}");
2740 assert!(line.contains("codex says skip"), "{line}");
2741 assert!(line.contains("the retry path is still wrong"), "{line}");
2742 assert!(line.contains("fixed in the rewrite"), "{line}");
2743
2744 let bare = positions(&positions_map, &Default::default());
2746 assert!(bare.contains("claude says do"), "{bare}");
2747 }
2748
2749 #[test]
2750 fn report_items_include_their_location() {
2751 assert_eq!(
2752 "Same title (src/a.rs:10)",
2753 report_item("Same title", "src/a.rs:10")
2754 );
2755 assert_eq!("General point", report_item("General point", ""));
2756 }
2757
2758 #[test]
2759 fn every_attempted_write_failing_is_a_failed_run() {
2760 let writes = WriteSummary {
2761 attempted: 3,
2762 failed: 3,
2763 };
2764
2765 assert_eq!(1, write_exit_code(writes));
2766 assert_eq!(
2767 Some("writes: 3 attempted, 0 succeeded, 3 failed".to_string()),
2768 write_summary_line(writes)
2769 );
2770 }
2771
2772 #[test]
2773 fn a_partial_write_failure_is_reported_after_the_run() {
2774 let writes = WriteSummary {
2775 attempted: 3,
2776 failed: 1,
2777 };
2778
2779 assert_eq!(1, write_exit_code(writes));
2780 assert_eq!(2, writes.succeeded());
2781 }
2782
2783 #[test]
2784 fn the_parser_is_internally_consistent() {
2785 Cli::command().debug_assert();
2786 }
2787
2788 #[test]
2789 fn quiet_is_accepted_before_or_after_the_subcommand() {
2790 for argv in [
2791 vec!["spar", "--quiet", "run", "42"],
2792 vec!["spar", "run", "42", "--quiet"],
2793 vec!["spar", "resume", "--quiet"],
2794 vec!["spar", "init", "-q"],
2795 ] {
2796 assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
2797 }
2798 assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
2799 }
2800
2801 #[test]
2802 fn several_issue_numbers_are_accepted() {
2803 let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
2804 match cli.command {
2805 Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
2806 other => panic!("{other:?}"),
2807 }
2808 }
2809
2810 #[test]
2811 fn issue_numbers_and_flags_can_be_interleaved() {
2812 let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
2813 match cli.command {
2814 Command::Run {
2815 issues, loop_flags, ..
2816 } => {
2817 assert_eq!(vec![42, 51], issues);
2818 assert!(loop_flags.auto_merge);
2819 }
2820 other => panic!("{other:?}"),
2821 }
2822 }
2823
2824 #[test]
2825 fn every_command_that_reads_a_config_accepts_one() {
2826 for argv in [
2827 vec!["spar", "run", "42"],
2828 vec!["spar", "triage"],
2829 vec!["spar", "resume"],
2830 vec!["spar", "followup"],
2831 vec!["spar", "checkin"],
2832 vec!["spar", "split"],
2833 vec!["spar", "brainstorm"],
2834 vec!["spar", "listen"],
2835 vec!["spar", "clean"],
2836 vec!["spar", "doctor"],
2837 ] {
2838 let mut full = argv.clone();
2839 full.extend(["--config", "other.toml"]);
2840 let cli = Cli::parse_from(&full);
2841 let config = match cli.command {
2842 Command::Run { common, .. }
2843 | Command::Triage { common, .. }
2844 | Command::Resume { common, .. }
2845 | Command::Followup { common, .. }
2846 | Command::Split { common, .. }
2847 | Command::Brainstorm { common, .. }
2848 | Command::Listen { common, .. }
2849 | Command::Checkin { common, .. } => common.config,
2850 Command::Clean { config, .. } | Command::Doctor { config } => config,
2851 other => panic!("{other:?}"),
2852 };
2853 assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
2854 }
2855 }
2856
2857 #[test]
2858 fn auto_merge_is_off_unless_asked_for() {
2859 let cli = Cli::parse_from(["spar", "run"]);
2860 match cli.command {
2861 Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
2862 other => panic!("{other:?}"),
2863 }
2864 }
2865
2866 #[test]
2869 fn every_command_that_reads_a_config_takes_instructions() {
2870 for argv in [
2873 vec!["spar", "run", "7", "--instructions", "Do not wait for CI."],
2874 vec![
2875 "spar",
2876 "triage",
2877 "7",
2878 "--instructions",
2879 "Do not wait for CI.",
2880 ],
2881 vec![
2882 "spar",
2883 "resume",
2884 "7",
2885 "--instructions",
2886 "Do not wait for CI.",
2887 ],
2888 vec![
2889 "spar",
2890 "review",
2891 "7",
2892 "--instructions",
2893 "Do not wait for CI.",
2894 ],
2895 vec!["spar", "followup", "--instructions", "Do not wait for CI."],
2896 vec![
2897 "spar",
2898 "checkin",
2899 "7",
2900 "--instructions",
2901 "Do not wait for CI.",
2902 ],
2903 vec![
2904 "spar",
2905 "split",
2906 "7",
2907 "--instructions",
2908 "Do not wait for CI.",
2909 ],
2910 vec![
2911 "spar",
2912 "brainstorm",
2913 "a",
2914 "subject",
2915 "--instructions",
2916 "Do not wait for CI.",
2917 ],
2918 vec![
2919 "spar",
2920 "listen",
2921 "7",
2922 "--instructions",
2923 "Do not wait for CI.",
2924 ],
2925 ] {
2926 let parsed = Cli::parse_from(&argv);
2927 let common = match parsed.command {
2928 Command::Run { common, .. }
2929 | Command::Triage { common, .. }
2930 | Command::Resume { common, .. }
2931 | Command::Review { common, .. }
2932 | Command::Followup { common, .. }
2933 | Command::Split { common, .. }
2934 | Command::Brainstorm { common, .. }
2935 | Command::Listen { common, .. }
2936 | Command::Checkin { common, .. } => common,
2937 other => panic!("{other:?}"),
2938 };
2939 assert_eq!(
2940 Some("Do not wait for CI."),
2941 common.instructions.as_deref(),
2942 "{argv:?}"
2943 );
2944 }
2945 }
2946
2947 #[test]
2948 fn the_two_close_skipped_flags_are_mutually_exclusive() {
2949 assert!(
2950 Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
2951 );
2952 }
2953
2954 #[test]
2957 fn close_skipped_is_offered_only_where_it_means_something() {
2958 assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
2959 assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
2960 assert!(Cli::try_parse_from(["spar", "followup", "--close-skipped"]).is_ok());
2962 assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
2963 assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
2964 assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
2965 }
2966
2967 #[test]
2970 fn brainstorm_takes_words_or_nothing() {
2971 match Cli::parse_from(["spar", "brainstorm", "novel", "uses", "of", "script"]).command {
2972 Command::Brainstorm { topic, ideas, .. } => {
2973 assert_eq!(vec!["novel", "uses", "of", "script"], topic);
2974 assert_eq!(5, ideas);
2975 }
2976 other => panic!("{other:?}"),
2977 }
2978 match Cli::parse_from(["spar", "brainstorm", "--ideas", "3", "--max-rounds", "2"]).command {
2979 Command::Brainstorm {
2980 topic,
2981 ideas,
2982 max_rounds,
2983 ..
2984 } => {
2985 assert!(topic.is_empty());
2986 assert_eq!(3, ideas);
2987 assert_eq!(Some(2), max_rounds);
2988 }
2989 other => panic!("{other:?}"),
2990 }
2991 }
2992
2993 #[test]
2997 fn filing_from_a_saved_session_needs_file_issues_and_writes_no_session() {
2998 assert!(Cli::try_parse_from(["spar", "brainstorm", "--from", "s.md"]).is_err());
2999 assert!(
3000 Cli::try_parse_from(["spar", "brainstorm", "--from", "s.md", "--file-issues"]).is_ok()
3001 );
3002 assert!(Cli::try_parse_from([
3003 "spar",
3004 "brainstorm",
3005 "--from",
3006 "s.md",
3007 "--file-issues",
3008 "--out",
3009 "t.md"
3010 ])
3011 .is_err());
3012 assert!(
3013 Cli::try_parse_from(["spar", "brainstorm", "x", "--out", "t.md", "--file-issues"])
3014 .is_ok()
3015 );
3016 }
3017
3018 #[test]
3021 fn followup_takes_no_numbers() {
3022 assert!(Cli::try_parse_from(["spar", "followup"]).is_ok());
3023 assert!(Cli::try_parse_from(["spar", "followup", "42"]).is_err());
3024 }
3025
3026 #[test]
3029 fn the_two_stopping_points_are_mutually_exclusive() {
3030 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only"]).is_ok());
3031 assert!(Cli::try_parse_from(["spar", "followup", "--file-only"]).is_ok());
3032 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only", "--file-only"]).is_err());
3033 }
3034
3035 #[test]
3036 fn the_close_skipped_pair_resolves_to_a_tristate() {
3037 let read = |argv: &[&str]| match Cli::parse_from(argv).command {
3038 Command::Run { triage_flags, .. } => {
3039 match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
3040 (true, _) => Some(true),
3041 (_, true) => Some(false),
3042 _ => None,
3043 }
3044 }
3045 other => panic!("{other:?}"),
3046 };
3047 assert_eq!(None, read(&["spar", "run"]));
3048 assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
3049 assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
3050 }
3051
3052 #[test]
3053 fn the_default_limit_is_twenty() {
3054 let cli = Cli::parse_from(["spar", "run"]);
3055 match cli.command {
3056 Command::Run { common, .. } => assert_eq!(20, common.limit),
3057 other => panic!("{other:?}"),
3058 }
3059 }
3060
3061 #[test]
3062 fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
3063 assert!(matches!(
3064 Cli::parse_from(["spar", "scrub-filter"]).command,
3065 Command::ScrubFilter
3066 ));
3067 let help = Cli::command().render_long_help().to_string();
3068 assert!(
3069 !help.contains("scrub-filter"),
3070 "it is plumbing, not a command"
3071 );
3072 }
3073
3074 #[test]
3075 fn review_takes_pr_numbers_and_a_dry_run() {
3076 let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
3077 match cli.command {
3078 Command::Review { items, dry_run, .. } => {
3079 assert_eq!(vec![101, 102], items);
3080 assert!(dry_run);
3081 }
3082 other => panic!("{other:?}"),
3083 }
3084 }
3085
3086 #[test]
3087 fn review_posts_unless_told_not_to() {
3088 match Cli::parse_from(["spar", "review", "101"]).command {
3089 Command::Review { dry_run, .. } => assert!(!dry_run),
3090 other => panic!("{other:?}"),
3091 }
3092 }
3093
3094 #[test]
3095 fn review_with_no_numbers_is_allowed() {
3096 match Cli::parse_from(["spar", "review"]).command {
3097 Command::Review { items, .. } => assert!(items.is_empty()),
3098 other => panic!("{other:?}"),
3099 }
3100 }
3101
3102 #[test]
3103 fn review_takes_its_own_round_budget() {
3104 match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
3105 Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
3106 other => panic!("{other:?}"),
3107 }
3108 }
3109
3110 #[test]
3111 fn checkin_takes_pr_numbers_and_a_dry_run() {
3112 match Cli::parse_from(["spar", "checkin", "108", "112", "--dry-run"]).command {
3113 Command::Checkin { items, dry_run, .. } => {
3114 assert_eq!(vec![108, 112], items);
3115 assert!(dry_run);
3116 }
3117 other => panic!("{other:?}"),
3118 }
3119 match Cli::parse_from(["spar", "checkin"]).command {
3120 Command::Checkin { items, dry_run, .. } => {
3121 assert!(items.is_empty());
3122 assert!(!dry_run);
3123 }
3124 other => panic!("{other:?}"),
3125 }
3126 }
3127
3128 #[test]
3134 fn checkin_offers_no_flag_that_would_weaken_the_pair() {
3135 assert!(Cli::try_parse_from(["spar", "checkin", "--auto-merge"]).is_err());
3136 assert!(Cli::try_parse_from(["spar", "checkin", "--max-rounds", "1"]).is_err());
3137 assert!(Cli::try_parse_from(["spar", "checkin", "--absorb", "1"]).is_err());
3138 assert!(Cli::try_parse_from(["spar", "checkin", "--close-skipped"]).is_err());
3139 assert!(Cli::try_parse_from(["spar", "checkin", "--reply-only"]).is_ok());
3141 assert!(Cli::try_parse_from(["spar", "checkin", "--any-author"]).is_ok());
3142 assert!(Cli::try_parse_from(["spar", "checkin", "--again"]).is_ok());
3143 assert!(Cli::try_parse_from(["spar", "checkin", "--keep-worktrees"]).is_ok());
3144 }
3145
3146 #[test]
3147 fn review_takes_verdict_and_recheck_flags_and_neither_by_default() {
3148 match Cli::parse_from(["spar", "review", "108", "--verdict", "--recheck"]).command {
3149 Command::Review {
3150 items,
3151 verdict,
3152 recheck,
3153 ..
3154 } => {
3155 assert_eq!(vec![108], items);
3156 assert!(verdict);
3157 assert!(recheck);
3158 }
3159 other => panic!("{other:?}"),
3160 }
3161 match Cli::parse_from(["spar", "review", "108"]).command {
3162 Command::Review {
3163 verdict, recheck, ..
3164 } => {
3165 assert!(!verdict);
3166 assert!(!recheck);
3167 }
3168 other => panic!("{other:?}"),
3169 }
3170 assert!(Cli::try_parse_from(["spar", "checkin", "108", "--verdict"]).is_err());
3171 }
3172
3173 #[test]
3174 fn listen_takes_numbers_once_dry_run_and_since() {
3175 match Cli::parse_from([
3176 "spar",
3177 "listen",
3178 "108",
3179 "42",
3180 "--once",
3181 "--dry-run",
3182 "--since",
3183 "2h",
3184 ])
3185 .command
3186 {
3187 Command::Listen {
3188 items,
3189 once,
3190 dry_run,
3191 since,
3192 ..
3193 } => {
3194 assert_eq!(vec![108, 42], items);
3195 assert!(once);
3196 assert!(dry_run);
3197 assert_eq!(Some("2h".to_string()), since);
3198 }
3199 other => panic!("{other:?}"),
3200 }
3201 match Cli::parse_from(["spar", "listen"]).command {
3202 Command::Listen {
3203 items,
3204 once,
3205 dry_run,
3206 since,
3207 common,
3208 } => {
3209 assert!(items.is_empty());
3210 assert!(!once);
3211 assert!(!dry_run);
3212 assert_eq!(None, since);
3213 assert_eq!(20, common.limit);
3214 }
3215 other => panic!("{other:?}"),
3216 }
3217 }
3218
3219 #[test]
3220 fn resume_takes_a_next_override() {
3221 let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
3222 match cli.command {
3223 Command::Resume {
3224 prs, next_actor, ..
3225 } => {
3226 assert_eq!(vec![108], prs);
3227 assert_eq!(Some("codex".to_string()), next_actor);
3228 }
3229 other => panic!("{other:?}"),
3230 }
3231 }
3232}
3233
3234#[cfg(test)]
3235mod absorb_tests {
3236 use super::*;
3237
3238 #[test]
3239 fn absorb_is_off_unless_asked_for() {
3240 match Cli::parse_from(["spar", "run"]).command {
3241 Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
3242 other => panic!("{other:?}"),
3243 }
3244 }
3245
3246 #[test]
3247 fn absorb_takes_a_wave_count() {
3248 match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
3249 Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
3250 other => panic!("{other:?}"),
3251 }
3252 }
3253
3254 #[test]
3255 fn absorb_is_only_offered_where_issues_are_worked() {
3256 assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
3257 assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
3258 assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
3259 assert!(Cli::try_parse_from(["spar", "split", "--absorb", "1"]).is_err());
3262 }
3263}
3264
3265#[cfg(test)]
3266mod split_tests {
3267 use super::*;
3268
3269 #[test]
3270 fn split_takes_numbers_of_either_kind() {
3271 match Cli::parse_from(["spar", "split", "10", "12"]).command {
3272 Command::Split { items, .. } => assert_eq!(vec![10, 12], items),
3273 other => panic!("{other:?}"),
3274 }
3275 }
3276
3277 #[test]
3280 fn split_with_no_numbers_is_allowed() {
3281 match Cli::parse_from(["spar", "split"]).command {
3282 Command::Split {
3283 items,
3284 dry_run,
3285 again,
3286 ..
3287 } => {
3288 assert!(items.is_empty());
3289 assert!(!dry_run);
3290 assert!(!again);
3291 }
3292 other => panic!("{other:?}"),
3293 }
3294 }
3295
3296 #[test]
3300 fn split_can_be_told_to_write_nothing() {
3301 match Cli::parse_from(["spar", "split", "10", "--dry-run"]).command {
3302 Command::Split { dry_run, .. } => assert!(dry_run),
3303 other => panic!("{other:?}"),
3304 }
3305 }
3306
3307 #[test]
3310 fn split_can_be_told_to_split_something_it_already_split() {
3311 match Cli::parse_from(["spar", "split", "10", "--again"]).command {
3312 Command::Split { again, .. } => assert!(again),
3313 other => panic!("{other:?}"),
3314 }
3315 }
3316
3317 #[test]
3320 fn split_takes_the_flags_that_govern_picking() {
3321 match Cli::parse_from([
3322 "spar",
3323 "split",
3324 "--limit",
3325 "5",
3326 "--min-number",
3327 "480",
3328 "--config",
3329 "other.toml",
3330 ])
3331 .command
3332 {
3333 Command::Split { common, .. } => {
3334 assert_eq!(5, common.limit);
3335 assert_eq!(Some(480), common.min_number);
3336 assert_eq!(Some(PathBuf::from("other.toml")), common.config);
3337 }
3338 other => panic!("{other:?}"),
3339 }
3340 }
3341
3342 #[test]
3345 fn split_offers_no_flag_that_would_make_it_a_second_run() {
3346 for flag in [
3347 vec!["--auto-merge"],
3348 vec!["--max-rounds", "2"],
3349 vec!["--close-skipped"],
3350 vec!["--no-close-skipped"],
3351 vec!["--keep-worktrees"],
3352 ] {
3353 let mut argv = vec!["spar", "split", "10"];
3354 argv.extend(flag.iter().copied());
3355 assert!(Cli::try_parse_from(&argv).is_err(), "{argv:?}");
3356 }
3357 }
3358}
3359
3360#[cfg(test)]
3361mod min_number_tests {
3362 use super::*;
3363
3364 fn read(argv: &[&str]) -> Option<i64> {
3365 match Cli::parse_from(argv).command {
3366 Command::Run { common, .. }
3367 | Command::Triage { common, .. }
3368 | Command::Resume { common, .. }
3369 | Command::Review { common, .. }
3370 | Command::Followup { common, .. }
3371 | Command::Split { common, .. }
3372 | Command::Listen { common, .. }
3373 | Command::Checkin { common, .. } => common.min_number,
3374 other => panic!("{other:?}"),
3375 }
3376 }
3377
3378 #[test]
3379 fn there_is_no_floor_unless_one_is_asked_for() {
3380 assert_eq!(None, read(&["spar", "run"]));
3381 }
3382
3383 #[test]
3384 fn every_command_that_picks_for_itself_accepts_a_floor() {
3385 for cmd in [
3386 "run", "triage", "resume", "review", "checkin", "split", "listen",
3387 ] {
3388 assert_eq!(
3389 Some(480),
3390 read(&["spar", cmd, "--min-number", "480"]),
3391 "{cmd}"
3392 );
3393 }
3394 }
3395}
3396
3397#[cfg(test)]
3398mod run_targets_tests {
3399 use super::*;
3400
3401 fn covered(pairs: &[(i64, i64)]) -> BTreeMap<i64, i64> {
3402 pairs.iter().copied().collect()
3403 }
3404
3405 #[test]
3410 fn an_issue_its_named_pull_request_closes_is_not_worked_again() {
3411 let kept = issues_not_on_named_prs(&[772, 783, 784], &covered(&[(772, 786)]));
3412
3413 assert_eq!(vec![783, 784], kept);
3414 }
3415
3416 #[test]
3417 fn an_issue_no_named_pull_request_closes_is_still_worked() {
3418 let kept = issues_not_on_named_prs(&[783, 784, 771], &covered(&[(772, 786)]));
3419
3420 assert_eq!(vec![783, 784, 771], kept);
3421 }
3422
3423 #[test]
3426 fn an_unreadable_pull_request_drops_no_issue() {
3427 let kept = issues_not_on_named_prs(&[772, 783], &covered(&[]));
3428
3429 assert_eq!(vec![772, 783], kept);
3430 }
3431
3432 #[test]
3434 fn every_issue_one_pull_request_closes_is_taken_by_it() {
3435 let kept = issues_not_on_named_prs(&[771, 772, 783], &covered(&[(771, 786), (772, 786)]));
3436
3437 assert_eq!(vec![783], kept);
3438 }
3439}
3440
3441#[cfg(test)]
3442mod settings_block_tests {
3443 use super::*;
3444
3445 fn written(line: &str) -> String {
3448 let after = line.split_once('=').expect("an assignment").1;
3449 let mut quoted = false;
3450 for (i, c) in after.char_indices() {
3451 match c {
3452 '"' => quoted = !quoted,
3453 '#' if !quoted => return after[..i].trim().to_string(),
3454 _ => {}
3455 }
3456 }
3457 after.trim().to_string()
3458 }
3459
3460 fn line_for(text: &str, key: &str) -> String {
3461 text.lines()
3462 .find(|l| {
3463 let bare = l.trim_start().trim_start_matches('#').trim_start();
3464 bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
3465 })
3466 .unwrap_or_else(|| panic!("{key} is not offered at all:\n{text}"))
3467 .to_string()
3468 }
3469
3470 #[test]
3477 fn every_value_it_offers_is_the_default_it_actually_has() {
3478 let text = settings_block("claude");
3479 for option in config::known_options() {
3480 if option.section == "loop.effort_schedule" {
3484 continue;
3485 }
3486 let line = line_for(&text, &option.key);
3487 assert_eq!(
3488 option.default,
3489 written(&line),
3490 "the generated config offers `{}`, but the default is {}",
3491 line.trim(),
3492 option.default
3493 );
3494 }
3495 }
3496
3497 #[test]
3501 fn it_offers_every_option_the_parser_knows_about() {
3502 let text = settings_block("claude");
3503 let missing: Vec<String> = config::unmentioned_options(&text)
3504 .into_iter()
3505 .map(|o| format!("[{}] {}", o.section, o.key))
3506 .collect();
3507 assert!(missing.is_empty(), "not offered: {}", missing.join(", "));
3508 }
3509
3510 #[test]
3514 fn every_option_it_offers_can_be_uncommented_and_still_load() {
3515 let mut text = String::from(
3516 "[agents.claude]\ncommand = [\"claude\"]\n\n\
3517 [agents.codex]\ncommand = [\"codex\"]\n\n",
3518 );
3519 for line in settings_block("claude").lines() {
3520 text.push_str(uncomment(line).unwrap_or(line));
3521 text.push('\n');
3522 }
3523 let cfg = config::parse(&text).expect("a config of its own suggestions");
3524 assert_eq!("claude", cfg.first_implementor);
3525 }
3526
3527 fn uncomment(line: &str) -> Option<&str> {
3530 let bare = line.trim_start().strip_prefix('#')?.trim_start();
3531 let key = bare.split_once('=')?.0.trim();
3534 let named = !key.is_empty()
3535 && key
3536 .chars()
3537 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
3538 named.then_some(bare)
3539 }
3540
3541 #[test]
3542 fn the_agent_that_goes_first_is_the_one_that_was_chosen() {
3543 assert!(settings_block("codex").contains("first_implementor = \"codex\""));
3544 }
3545
3546 #[test]
3551 fn a_note_sits_above_the_option_it_describes() {
3552 let text = settings_block("claude");
3553 assert!(
3554 text.lines().all(|l| l.chars().count() <= 80),
3555 "a line runs off the edge:\n{text}"
3556 );
3557 assert!(
3558 text.lines().all(|l| !l.starts_with(' ')),
3559 "a line is indented, so the columns are back:\n{text}"
3560 );
3561
3562 let lines: Vec<&str> = text.lines().collect();
3564 let at = lines
3565 .iter()
3566 .position(|l| l.starts_with("max_rounds"))
3567 .expect("max_rounds");
3568 assert!(lines[at - 1].starts_with('#'), "{:?}", lines[at - 1]);
3569 assert!(
3570 lines[at - 1].contains("lifetime cap"),
3571 "the note above it is the end of its own note: {:?}",
3572 lines[at - 1]
3573 );
3574 }
3575
3576 #[test]
3578 fn every_note_starts_as_a_sentence() {
3579 for (_, key, note) in LOOP_OPTIONS
3580 .iter()
3581 .chain(STYLE_OPTIONS)
3582 .chain(LISTEN_OPTIONS)
3583 {
3584 let first = note.chars().next().expect("a note");
3585 assert!(
3586 first.is_uppercase(),
3587 "{key} reads as a margin scribble rather than a sentence: {note}"
3588 );
3589 }
3590 }
3591}
3592
3593#[cfg(test)]
3594mod agent_block_tests {
3595 use super::*;
3596
3597 fn spec(models: &[&str], efforts: &[&str]) -> config::AgentSpec {
3598 let mut spec: config::AgentSpec =
3599 toml::Value::Table(toml::from_str("command = [\"x\"]").expect("a minimal preset"))
3600 .try_into()
3601 .expect("builds");
3602 spec.models = models.iter().map(|s| s.to_string()).collect();
3603 spec.efforts = efforts.iter().map(|s| s.to_string()).collect();
3604 spec
3605 }
3606
3607 #[test]
3611 fn an_option_with_no_hints_is_left_out_rather_than_guessed_at() {
3612 let block = agent_block("cursor", &spec(&["composer-2.5", "auto"], &[]));
3613 assert!(!block.contains("..."), "{block}");
3614 assert!(!block.contains("effort"), "{block}");
3615 assert!(block.contains("# model = \"composer-2.5\""), "{block}");
3616 }
3617
3618 #[test]
3621 fn only_the_options_that_follow_are_introduced() {
3622 let model_only = agent_block("cursor", &spec(&["auto"], &[]));
3623 assert!(model_only.contains("Omit model to use"), "{model_only}");
3624 assert!(!model_only.contains("Omit effort"), "{model_only}");
3625
3626 let both = agent_block("claude", &spec(&["fable"], &["high"]));
3627 assert!(both.contains("Omit model to use"), "{both}");
3628 assert!(both.contains("Omit effort to use"), "{both}");
3629 }
3630
3631 #[test]
3634 fn a_preset_with_no_hints_still_writes_a_usable_block() {
3635 let block = agent_block("gemini", &spec(&[], &[]));
3636 assert!(!block.contains("..."), "{block}");
3637 assert!(!block.contains("Omit"), "{block}");
3638 assert!(
3639 block.starts_with("[agents.gemini]\npreset = \"gemini\"\n"),
3640 "{block}"
3641 );
3642 assert!(block.contains("[agents.gemini.fallback]"), "{block}");
3644 assert!(block.contains("# timeout = "), "{block}");
3645 }
3646
3647 #[test]
3650 fn the_timeout_offered_is_the_one_the_agent_would_use() {
3651 let mut spec = spec(&["a"], &[]);
3652 spec.timeout = 7200;
3653 assert!(
3654 agent_block("custom", &spec).contains("# timeout = 7200"),
3655 "the generator kept its own copy"
3656 );
3657 }
3658
3659 #[test]
3661 fn alternatives_are_listed_only_when_there_are_any() {
3662 assert!(agent_block("a", &spec(&["one", "two"], &[])).contains("One of: one | two."));
3663 let single = agent_block("b", &spec(&["only"], &[]));
3664 assert!(!single.contains("One of:"), "{single}");
3665 }
3666
3667 #[test]
3671 fn the_presets_note_is_said_once() {
3672 let mut spec = spec(&["m1", "m2"], &["e1", "e2"]);
3673 spec.options_note = Some("Check the current sets with: mytool --help".into());
3674 let block = agent_block("mytool", &spec);
3675 assert_eq!(
3676 1,
3677 block.matches("Check the current sets").count(),
3678 "{block}"
3679 );
3680 }
3681}