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 {
287 items: Vec<i64>,
290 #[command(flatten)]
291 common: Common,
292 #[arg(long)]
294 once: bool,
295 #[arg(long)]
298 dry_run: bool,
299 #[arg(long, value_name = "DURATION")]
302 since: Option<String>,
303 },
304
305 Post {
310 #[arg(required = true)]
312 prs: Vec<i64>,
313 #[arg(long, default_value = ".")]
314 repo: PathBuf,
315 #[arg(long)]
316 config: Option<PathBuf>,
317 #[arg(long, value_name = "PATH")]
319 file: Option<PathBuf>,
320 #[arg(long)]
322 dry_run: bool,
323 },
324
325 Init {
330 #[arg(long, default_value = "spar.toml")]
331 out: PathBuf,
332 #[arg(long)]
334 force: bool,
335 #[arg(long, conflicts_with = "force")]
338 update: bool,
339 },
340
341 Clean {
343 #[arg(long, default_value = ".")]
344 repo: PathBuf,
345 #[arg(long)]
346 config: Option<PathBuf>,
347 #[arg(long)]
350 all: bool,
351 #[arg(long)]
353 pr_state: bool,
354 },
355
356 Doctor {
358 #[arg(long)]
359 config: Option<PathBuf>,
360 },
361
362 #[command(hide = true)]
366 ScrubFilter,
367}
368
369#[derive(Args, Debug, Clone)]
370pub struct Common {
371 #[arg(long, default_value = ".")]
373 pub repo: PathBuf,
374 #[arg(long)]
376 pub config: Option<PathBuf>,
377 #[arg(long)]
379 pub base: Option<String>,
380 #[arg(long)]
382 pub first: Option<String>,
383 #[arg(long, default_value_t = 20)]
386 pub limit: usize,
387 #[arg(long, value_name = "N")]
390 pub min_number: Option<i64>,
391 #[arg(long, value_name = "TEXT")]
394 pub instructions: Option<String>,
395}
396
397#[derive(Args, Debug, Clone)]
398pub struct LoopFlags {
399 #[arg(long)]
402 pub max_rounds: Option<u32>,
403 #[arg(long)]
405 pub auto_merge: bool,
406 #[arg(long)]
408 pub keep_worktrees: bool,
409 #[arg(long, value_name = "N")]
412 pub absorb: Option<u32>,
413}
414
415#[derive(Args, Debug, Clone)]
418pub struct TriageFlags {
419 #[arg(long, conflicts_with = "no_close_skipped")]
421 pub close_skipped: bool,
422 #[arg(long)]
424 pub no_close_skipped: bool,
425 #[arg(long)]
431 pub retriage: bool,
432}
433
434pub fn main() -> i32 {
439 let cli = Cli::parse();
440 logging::init_color();
441 logging::set_quiet(cli.quiet);
442
443 match dispatch(cli) {
444 Ok(code) => code,
445 Err(e) => {
446 logging::error(e.to_string());
447 2
448 }
449 }
450}
451
452fn dispatch(cli: Cli) -> Result<i32> {
453 match cli.command {
454 Command::ScrubFilter => cmd_scrub_filter(),
455 Command::Doctor { config } => cmd_doctor(config.as_deref()),
456 Command::Review {
457 items,
458 common,
459 dry_run,
460 max_rounds,
461 verdict,
462 recheck,
463 } => {
464 let overrides = Overrides {
465 max_rounds,
466 ..Overrides::default()
467 };
468 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
469 let numbers = if items.is_empty() {
470 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
471 if found.is_empty() {
472 log!("no open PRs");
473 return Ok(0);
474 }
475 log!("no PRs given, reviewing {} open", found.len());
476 found
477 } else {
478 items
479 };
480 let sorted = classify(&repo, &numbers)?;
481 let mut targets = sorted.prs;
482 for number in sorted.issues {
483 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
484 Ok(Some(pr)) => {
485 log!("#{number} is an issue; reviewing its open PR {}", pr.url);
486 targets.push(pr.number);
487 }
488 Ok(None) => {
489 logwarn!("#{number} is an issue with no open pull request to review")
490 }
491 Err(e) => logwarn!("#{number}: {e}"),
492 }
493 }
494 let mut results = Vec::new();
495 for number in targets {
496 results.push(if recheck {
497 let mode = review_only::RecheckMode {
498 dry_run,
499 requested: false,
500 verdict_on_fallback: verdict,
501 };
502 review_only::recheck_pr(&agents, &cfg, &repo, number, mode)
503 } else {
504 let opts = review_only::ReviewOpts { dry_run, verdict };
505 review_only::review_pr(&agents, &cfg, &repo, number, opts)
506 });
507 }
508 if results.is_empty() {
509 return Ok(0);
510 }
511 Ok(report(&results, &cfg, &repo))
512 }
513
514 Command::Listen {
515 items,
516 common,
517 once,
518 dry_run,
519 since,
520 } => {
521 let since = since.as_deref().map(listen::parse_duration).transpose()?;
523 let (cfg, repo, agents) = prepare(&common, None)?;
524 listen::run(
525 &agents,
526 &cfg,
527 &repo,
528 listen::Opts {
529 items,
530 once,
531 dry_run,
532 since,
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 GitHub checkout.");
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, "fork_when_denied", "Publish a new branch from your fork when origin refuses the push, and open the pull request from there. GitHub returns the existing fork when there is one. Off fails the push instead, after the work is done."),
2074 (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."),
2075 (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."),
2076 (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."),
2077 (true, "checkin_trust", "Whose comments `spar checkin` will act on, and whose comments under an issue reach the prompts that implement, triage, and review it. write is anybody GitHub says can write to this repository, which is the default because acting on a comment means pushing a commit to somebody's branch. The person who filed an issue and the authenticated GitHub user are always read under it, regardless of write access. anyone answers everyone, and still only changes code when both agents agree."),
2078 (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."),
2079 (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."),
2080];
2081
2082const STYLE_OPTIONS: &[Setting] = &[
2083 (false, "ban_em_dash", "Strip em-dashes and en-dashes from everything spar posts, then refuse to post text that still has one."),
2084 (false, "ban_ai_attribution", "Strip mentions of the tooling, and Co-Authored-By trailers, from everything spar posts."),
2085 (false, "terse", "Hold model prose to a length budget. false removes the valves entirely."),
2086 (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."),
2087 (true, "max_title_chars", "A finding, issue, or pull request title. Never ellipsised: a title ending in three dots reads as broken."),
2088 (true, "max_summary_chars", "A one line verdict, or a refutation's argument."),
2089 (true, "max_detail_chars", "A blocking finding's explanation, as it appears in the pull request thread."),
2090 (true, "max_body_chars", "A pull request body."),
2091 (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."),
2092];
2093
2094const LISTEN_OPTIONS: &[Setting] = &[
2095 (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."),
2096 (true, "sweep", "Seconds between sweeps of the open pull requests. The sweep sees what a notification can miss: a review asked for again, an assignment, a close, or a push, which raises no notification for a reviewer."),
2097 (true, "settle", "Seconds an item has to stay quiet before spar acts on it, so a review request and three comments in the same minute cost one recheck rather than four."),
2098 (true, "max_wait", "Most seconds an item may keep settling before it is acted on anyway."),
2099 (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."),
2100 (true, "on_review_requested", "What a pull request somebody asked you to review gets: review or ignore."),
2101 (true, "on_assigned", "What a pull request assigned to you gets: review or ignore."),
2102 (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."),
2103 (false, "verdicts", "Whether a review `spar listen` starts submits a formal verdict, approve or request changes, rather than a comment. A recheck always does."),
2104];
2105
2106fn settings_block(first_implementor: &str) -> String {
2111 let defaults: std::collections::BTreeMap<String, String> = config::known_options()
2112 .into_iter()
2113 .map(|option| (option.key, option.default))
2114 .collect();
2115 let value = |key: &str| match key {
2118 "first_implementor" => format!("\"{first_implementor}\""),
2119 other => defaults.get(other).cloned().unwrap_or_default(),
2120 };
2121
2122 let mut out = String::from("[loop]\n");
2123 out.push_str(&option_lines(LOOP_OPTIONS, &value));
2124 out.push_str(concat!(
2125 "\n[loop.effort_schedule]\n",
2126 "# One key per kind of call, so a value means what its name says. Values\n",
2127 "# are whatever each agent's own CLI accepts, listed above, so these are\n",
2128 "# examples rather than defaults. Left out, a call falls back to\n",
2129 "# round_1 or rest, and then to the effort the agent's own block asked\n",
2130 "# for. Where the two CLIs do not share a vocabulary, put the schedule\n",
2131 "# under [agents.NAME.effort_schedule] instead, with the same keys.\n",
2132 "# triage = \"low\" # both agents, over the whole queue, per wave\n",
2133 "# implement = \"high\" # writing the first draft\n",
2134 "# review_1 = \"high\" # the deep first review\n",
2135 "# review_rest = \"low\" # later rounds, which see a small delta\n",
2136 "# respond = \"high\" # the author answering a review\n",
2137 "# close = \"low\" # the closing merge safety pass\n",
2138 "# screen = \"low\" # the follow-up screen, one call for the queue\n",
2139 "# checkin = \"high\" # judging comments other people left\n",
2140 "# split = \"high\" # proposing and checking a split\n",
2141 "# brainstorm = \"high\" # every brainstorm round\n",
2142 "# recheck = \"low\" # a second look after commits or replies\n",
2143 "# round_1 = \"high\" # read by any call above with no key set\n",
2144 "# rest = \"low\" # the same, for later rounds and the close\n\n",
2145 ));
2146 out.push_str("[style]\n");
2147 out.push_str(&option_lines(STYLE_OPTIONS, &value));
2148 out.push_str("\n[listen]\n");
2149 out.push_str(&option_lines(LISTEN_OPTIONS, &value));
2150 out
2151}
2152
2153fn option_lines(options: &[Setting], value: &dyn Fn(&str) -> String) -> String {
2157 let mut out = String::new();
2158 for (commented, key, note) in options {
2159 if !out.is_empty() {
2160 out.push('\n');
2161 }
2162 out.push_str(&wrap_comment(note));
2163 let lead = if *commented { "# " } else { "" };
2164 out.push_str(&format!("{lead}{key} = {}\n", value(key)));
2165 }
2166 out
2167}
2168
2169type Probe = Box<dyn Fn() -> Result<String>>;
2172
2173fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
2180 let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
2181
2182 if let Some(extra) = &spec.options_note {
2194 if !spec.models.is_empty() || !spec.efforts.is_empty() {
2195 out.push('\n');
2196 out.push_str(&wrap_comment(extra));
2197 }
2198 }
2199 for (key, choices) in [("model", &spec.models), ("effort", &spec.efforts)] {
2200 let Some(suggested) = choices.first() else {
2201 continue;
2202 };
2203 let mut note = format!("Omit {key} to use the CLI's own default.");
2204 if choices.len() > 1 {
2205 note.push_str(&format!(" One of: {}.", choices.join(" | ")));
2206 }
2207 out.push('\n');
2208 out.push_str(&wrap_comment(¬e));
2209 out.push_str(&format!("# {key} = \"{suggested}\"\n"));
2210 }
2211
2212 out.push('\n');
2216 out.push_str(&wrap_comment(
2217 "Seconds one call may take before spar gives up. A timeout costs the whole call and is \
2218 never retried, so err long.",
2219 ));
2220 out.push_str(&format!("# timeout = {}\n", spec.timeout));
2221
2222 let backup = if name == "cursor" { "gemini" } else { "cursor" };
2225 out.push('\n');
2226 out.push_str(&wrap_comment(
2227 "A stand in for when this CLI refuses, stalls, or runs out of quota. It answers in place \
2228 of this agent, never alongside it.",
2229 ));
2230 out.push_str(&format!(
2231 "# [agents.{name}.fallback]\n# preset = \"{backup}\"\n"
2232 ));
2233
2234 out.push('\n');
2239 out.push_str(&wrap_comment(
2240 "command, output, search_paths and the rest are in spar.example.toml, for pairing a CLI \
2241 that has no preset.",
2242 ));
2243 out.push('\n');
2244 out
2245}
2246
2247fn note_for(key: &str) -> &'static str {
2253 LOOP_OPTIONS
2254 .iter()
2255 .chain(STYLE_OPTIONS)
2256 .chain(LISTEN_OPTIONS)
2257 .find(|(_, name, _)| *name == key)
2258 .map(|(_, _, note)| *note)
2259 .unwrap_or("")
2260}
2261
2262fn wrap_comment(text: &str) -> String {
2264 const WIDTH: usize = 76;
2265 let mut out = String::new();
2266 let mut line = String::from("#");
2267 for word in text.split_whitespace() {
2268 if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
2269 out.push_str(&line);
2270 out.push('\n');
2271 line = String::from("#");
2272 }
2273 line.push(' ');
2274 line.push_str(word);
2275 }
2276 if line.len() > 1 {
2277 out.push_str(&line);
2278 out.push('\n');
2279 }
2280 out
2281}
2282
2283fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
2284 let mut ok = true;
2285
2286 let probes: Vec<(&str, Probe)> = vec![
2287 (
2288 "git",
2289 Box::new(|| {
2290 proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
2291 .map(|s| first_line(&s))
2292 }),
2293 ),
2294 (
2295 "gh",
2296 Box::new(|| {
2297 proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
2298 .map(|s| first_line(&s))
2299 }),
2300 ),
2301 (
2302 "gh auth",
2303 Box::new(|| {
2304 let out = proc::exec(
2305 &["gh".into(), "auth".into(), "status".into()],
2306 &ExecOpts::new().check(false).timeout_secs(60),
2307 )?;
2308 let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
2309 if out.ok() {
2310 Ok(first_line(&text))
2311 } else {
2312 Err(spar_err!("not authenticated. Run `gh auth login`."))
2313 }
2314 }),
2315 ),
2316 ];
2317
2318 for (label, probe) in probes {
2319 match probe() {
2320 Ok(detail) => println!(" ok {label:12} {detail}"),
2321 Err(e) => {
2322 println!(" FAIL {label:12} {}", e.first_line());
2323 ok = false;
2324 }
2325 }
2326 }
2327
2328 let found = config::find_config(config_path)?;
2329 let Some(path) = found else {
2330 println!("\n no spar.toml found. Run `spar init` to generate one.");
2331 println!(
2332 " presets available: {}",
2333 config::available_presets().join(", ")
2334 );
2335 return Ok(if ok { 0 } else { 1 });
2336 };
2337
2338 println!("\n config: {}", path.display());
2339 let cfg = match config::load(Some(&path)) {
2340 Ok(cfg) => cfg,
2341 Err(e) => {
2342 println!(" FAIL config {e}");
2343 return Ok(1);
2344 }
2345 };
2346
2347 let mut resolved = Vec::new();
2350 for spec in &cfg.agents {
2351 let agent = Agent::new(spec.clone());
2352 match agent.resolve_bin() {
2353 Ok(bin) => {
2354 println!(
2355 " ok {:12} {} ({})",
2356 spec.name,
2357 bin.display(),
2358 spec.describe()
2359 );
2360 report_fallback(&agent);
2361 resolved.push(agent);
2362 }
2363 Err(e) => {
2364 println!(" FAIL {:12} {}", spec.name, e.first_line());
2365 ok = false;
2366 }
2367 }
2368 }
2369
2370 if resolved.len() == cfg.agents.len() {
2371 if let Some(warning) = agent::correlation_warning(&resolved) {
2372 println!("\n WARNING {warning}");
2373 }
2374 }
2375
2376 for line in unknown_effort_values(&cfg) {
2377 println!("\n WARNING {line}");
2378 }
2379
2380 println!(
2381 "\n settings: first={} max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
2382 cfg.first_implementor_policy(),
2383 cfg.loop_cfg.max_rounds,
2384 cfg.loop_cfg.auto_merge,
2385 cfg.loop_cfg.worktrees,
2386 cfg.loop_cfg.followups,
2387 cfg.style.terse
2388 );
2389 if let Ok(text) = std::fs::read_to_string(&path) {
2393 let unset = config::unmentioned_options(&text);
2394 if !unset.is_empty() {
2395 println!(
2396 "\n {} setting(s) this config does not mention, all at their defaults:",
2397 unset.len()
2398 );
2399 for option in &unset {
2400 println!(
2401 " [{}] {} = {}",
2402 option.section, option.key, option.default
2403 );
2404 }
2405 println!(
2406 " `spar init --update {}` appends them as comments.",
2407 path.display()
2408 );
2409 }
2410 }
2411
2412 println!(
2413 "{}",
2414 if ok {
2415 "\nready"
2416 } else {
2417 "\nmissing prerequisites"
2418 }
2419 );
2420 Ok(if ok { 0 } else { 1 })
2421}
2422
2423fn unknown_effort_values(cfg: &config::Config) -> Vec<String> {
2432 let mut out = Vec::new();
2433 for spec in &cfg.agents {
2434 if spec.efforts.is_empty() {
2435 continue;
2436 }
2437 let mut said: Vec<String> = Vec::new();
2438 for call in config::Call::every() {
2439 let Some(value) = cfg.effort_for(spec, call) else {
2440 continue;
2441 };
2442 let known = spec
2443 .efforts
2444 .iter()
2445 .any(|hint| hint.eq_ignore_ascii_case(value.trim()));
2446 if known || said.contains(&value) {
2447 continue;
2448 }
2449 said.push(value.clone());
2450 out.push(format!(
2451 "{} is asked for effort \"{value}\" on the {} call, which is not one it is known \
2452 to accept ({}). Put this agent's values under \
2453 [agents.{}.effort_schedule].",
2454 spec.name,
2455 call.key(),
2456 spec.efforts.join(" | "),
2457 spec.name
2458 ));
2459 }
2460 }
2461 out
2462}
2463
2464fn first_line(text: &str) -> String {
2465 text.trim().lines().next().unwrap_or("").trim().to_string()
2466}
2467
2468fn report(results: &[IssueRun], cfg: &Config, repo: &Repo) -> i32 {
2473 report_with(results, cfg, repo, &[], &[])
2474}
2475
2476fn report_with(
2483 results: &[IssueRun],
2484 cfg: &Config,
2485 repo: &Repo,
2486 stopped: &[String],
2487 parked: &[String],
2488) -> i32 {
2489 let spent = spend::taken();
2492 if let Err(e) = repo.record_spend(&spent) {
2493 logdim!("could not record what this run spent: {e}");
2494 }
2495 println!("\n{}", "=".repeat(60));
2496 for r in results {
2497 print_outcome(r, &spent);
2498 }
2499 println!("{}", "=".repeat(60));
2500
2501 if cfg.alternate_first && results.len() > 1 {
2502 println!(
2503 "\nfirst implementor alternated, starting with {}. Who wrote each first draft is in \
2504 .spar/state/implementors.json.",
2505 cfg.first_implementor
2506 );
2507 }
2508 if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
2509 println!("\nApproved PRs are waiting on you to merge.");
2510 }
2511 let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
2512 if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
2513 println!(
2514 "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
2515 Set followups = \"issues\" to file them."
2516 );
2517 }
2518 if let Some(summary) = spend::summary(&spent) {
2519 println!("\n{summary}");
2520 }
2521 for lines in [parked, stopped] {
2522 if lines.is_empty() {
2523 continue;
2524 }
2525 println!();
2526 for line in lines {
2527 println!("{line}");
2528 }
2529 }
2530 let issue_exit = i32::from(!results.iter().all(IssueRun::succeeded) || !stopped.is_empty());
2531 issue_exit.max(report_writes(repo))
2532}
2533
2534pub(crate) fn print_outcome(r: &IssueRun, spent: &[spend::Spent]) {
2536 println!(
2537 "#{:<5} {:<10} rounds={} {}",
2538 r.issue,
2539 r.status.to_string(),
2540 r.rounds,
2541 r.pr.as_deref().unwrap_or("")
2542 );
2543 for note in &r.notes {
2544 println!(" {}", first_line(note));
2545 }
2546 for url in &r.filed {
2547 println!(" filed {url}");
2548 }
2549 for dispute in &r.disputes {
2550 println!(
2551 " disputed: {}",
2552 report_item(&dispute.title, &dispute.file)
2553 );
2554 }
2555 for finding in &r.noted {
2556 println!(
2557 " noted, not blocking: {}",
2558 report_item(&finding.title, &finding.file)
2559 );
2560 }
2561 if let Some(cost) = spend::for_subject(spent, r.issue) {
2562 println!(" {cost}");
2563 }
2564}
2565
2566pub(crate) fn report_writes(repo: &Repo) -> i32 {
2571 let writes = repo.write_summary();
2572 if let Some(line) = write_summary_line(writes) {
2573 println!("\n{line}");
2574 }
2575 write_exit_code(writes)
2576}
2577
2578fn write_summary_line(writes: WriteSummary) -> Option<String> {
2579 (writes.attempted > 0).then(|| {
2580 format!(
2581 "writes: {} attempted, {} succeeded, {} failed",
2582 writes.attempted,
2583 writes.succeeded(),
2584 writes.failed
2585 )
2586 })
2587}
2588
2589fn write_exit_code(writes: WriteSummary) -> i32 {
2590 i32::from(writes.failed > 0)
2591}
2592
2593fn report_item(title: &str, file: &str) -> String {
2594 match file.trim() {
2595 "" => title.to_string(),
2596 location => format!("{title} ({location})"),
2597 }
2598}
2599
2600#[cfg(test)]
2601mod tests {
2602 use super::*;
2603 use clap::CommandFactory;
2604
2605 fn planned(issue: i64, depends_on: Vec<i64>) -> PlanItem {
2606 PlanItem {
2607 issue,
2608 title: format!("issue {issue}"),
2609 complexity: crate::model::Complexity::S,
2610 risk: crate::model::Risk::Low,
2611 depends_on,
2612 reason: "worth doing".into(),
2613 }
2614 }
2615
2616 #[test]
2625 fn a_dependent_waits_for_a_dependency_that_has_not_landed() {
2626 let plan = Plan {
2627 order: vec![planned(9, vec![]), planned(10, vec![9])],
2628 ..Plan::default()
2629 };
2630 let mut landed = BTreeSet::new();
2631
2632 let why = held_back(&plan.order[1], &plan, &landed).expect("held");
2633 assert!(why.contains("#9"), "{why}");
2634 assert!(why.contains("has not merged"), "{why}");
2635 assert!(held_back(&plan.order[0], &plan, &landed).is_none());
2636
2637 landed.insert(9);
2639 assert!(held_back(&plan.order[1], &plan, &landed).is_none());
2640 }
2641
2642 #[test]
2645 fn a_dependent_on_a_declined_or_contested_issue_is_held_with_the_reason() {
2646 let declined = Plan {
2647 order: vec![planned(10, vec![9])],
2648 skipped: vec![crate::model::SkippedItem {
2649 issue: 9,
2650 title: "issue 9".into(),
2651 reasons: Default::default(),
2652 tracker: false,
2653 }],
2654 ..Plan::default()
2655 };
2656 let why = held_back(&declined.order[0], &declined, &BTreeSet::new()).expect("held");
2657 assert!(why.contains("declined by both reviewers"), "{why}");
2658
2659 let contested = Plan {
2660 order: vec![planned(10, vec![9])],
2661 contested: vec![crate::model::ContestedItem {
2662 issue: 9,
2663 title: "issue 9".into(),
2664 positions: Default::default(),
2665 reasons: Default::default(),
2666 note: None,
2667 }],
2668 ..Plan::default()
2669 };
2670 let why = held_back(&contested.order[0], &contested, &BTreeSet::new()).expect("held");
2671 assert!(why.contains("disagree about #9"), "{why}");
2672 }
2673
2674 #[test]
2678 fn a_dependency_outside_the_run_does_not_hold_anything() {
2679 let plan = Plan {
2680 order: vec![planned(10, vec![900])],
2681 ..Plan::default()
2682 };
2683 assert!(held_back(&plan.order[0], &plan, &BTreeSet::new()).is_none());
2684 }
2685
2686 #[test]
2692 fn doctor_says_when_a_scheduled_effort_is_not_one_the_agent_accepts() {
2693 let text = "\
2694[agents.claude]
2695preset = \"claude\"
2696
2697[agents.codex]
2698preset = \"codex\"
2699
2700[loop.effort_schedule]
2701review_1 = \"ultra\"
2702";
2703 let cfg = config::parse(text).expect("parses");
2704 let said = unknown_effort_values(&cfg);
2705 assert_eq!(1, said.len(), "{said:?}");
2706 assert!(said[0].starts_with("claude"), "{}", said[0]);
2707 assert!(said[0].contains("ultra"), "{}", said[0]);
2708 assert!(said[0].contains("review_1"), "{}", said[0]);
2709 assert!(
2710 said[0].contains("[agents.claude.effort_schedule]"),
2711 "it has to say where to put the right value: {}",
2712 said[0]
2713 );
2714
2715 let quiet = text.replace(
2717 "[loop.effort_schedule]\nreview_1 = \"ultra\"\n",
2718 "[agents.claude.effort_schedule]\nreview_1 = \"xhigh\"\n\n\
2719 [agents.codex.effort_schedule]\nreview_1 = \"ultra\"\n",
2720 );
2721 assert!(unknown_effort_values(&config::parse(&quiet).expect("parses")).is_empty());
2722 }
2723
2724 #[test]
2725 fn a_contested_issue_reads_as_both_positions_with_their_reasons() {
2726 let mut positions_map = std::collections::BTreeMap::new();
2727 positions_map.insert("claude".to_string(), "do".to_string());
2728 positions_map.insert("codex".to_string(), "skip".to_string());
2729 let mut reasons = std::collections::BTreeMap::new();
2730 reasons.insert(
2731 "claude".to_string(),
2732 "the retry path is still wrong".to_string(),
2733 );
2734 reasons.insert(
2735 "codex".to_string(),
2736 "fixed in the rewrite last month".to_string(),
2737 );
2738
2739 let line = positions(&positions_map, &reasons);
2740 assert!(line.contains("claude says do"), "{line}");
2741 assert!(line.contains("codex says skip"), "{line}");
2742 assert!(line.contains("the retry path is still wrong"), "{line}");
2743 assert!(line.contains("fixed in the rewrite"), "{line}");
2744
2745 let bare = positions(&positions_map, &Default::default());
2747 assert!(bare.contains("claude says do"), "{bare}");
2748 }
2749
2750 #[test]
2751 fn report_items_include_their_location() {
2752 assert_eq!(
2753 "Same title (src/a.rs:10)",
2754 report_item("Same title", "src/a.rs:10")
2755 );
2756 assert_eq!("General point", report_item("General point", ""));
2757 }
2758
2759 #[test]
2760 fn every_attempted_write_failing_is_a_failed_run() {
2761 let writes = WriteSummary {
2762 attempted: 3,
2763 failed: 3,
2764 };
2765
2766 assert_eq!(1, write_exit_code(writes));
2767 assert_eq!(
2768 Some("writes: 3 attempted, 0 succeeded, 3 failed".to_string()),
2769 write_summary_line(writes)
2770 );
2771 }
2772
2773 #[test]
2774 fn a_partial_write_failure_is_reported_after_the_run() {
2775 let writes = WriteSummary {
2776 attempted: 3,
2777 failed: 1,
2778 };
2779
2780 assert_eq!(1, write_exit_code(writes));
2781 assert_eq!(2, writes.succeeded());
2782 }
2783
2784 #[test]
2785 fn the_parser_is_internally_consistent() {
2786 Cli::command().debug_assert();
2787 }
2788
2789 #[test]
2790 fn quiet_is_accepted_before_or_after_the_subcommand() {
2791 for argv in [
2792 vec!["spar", "--quiet", "run", "42"],
2793 vec!["spar", "run", "42", "--quiet"],
2794 vec!["spar", "resume", "--quiet"],
2795 vec!["spar", "init", "-q"],
2796 ] {
2797 assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
2798 }
2799 assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
2800 }
2801
2802 #[test]
2803 fn several_issue_numbers_are_accepted() {
2804 let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
2805 match cli.command {
2806 Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
2807 other => panic!("{other:?}"),
2808 }
2809 }
2810
2811 #[test]
2812 fn issue_numbers_and_flags_can_be_interleaved() {
2813 let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
2814 match cli.command {
2815 Command::Run {
2816 issues, loop_flags, ..
2817 } => {
2818 assert_eq!(vec![42, 51], issues);
2819 assert!(loop_flags.auto_merge);
2820 }
2821 other => panic!("{other:?}"),
2822 }
2823 }
2824
2825 #[test]
2826 fn every_command_that_reads_a_config_accepts_one() {
2827 for argv in [
2828 vec!["spar", "run", "42"],
2829 vec!["spar", "triage"],
2830 vec!["spar", "resume"],
2831 vec!["spar", "followup"],
2832 vec!["spar", "checkin"],
2833 vec!["spar", "split"],
2834 vec!["spar", "brainstorm"],
2835 vec!["spar", "listen"],
2836 vec!["spar", "clean"],
2837 vec!["spar", "doctor"],
2838 ] {
2839 let mut full = argv.clone();
2840 full.extend(["--config", "other.toml"]);
2841 let cli = Cli::parse_from(&full);
2842 let config = match cli.command {
2843 Command::Run { common, .. }
2844 | Command::Triage { common, .. }
2845 | Command::Resume { common, .. }
2846 | Command::Followup { common, .. }
2847 | Command::Split { common, .. }
2848 | Command::Brainstorm { common, .. }
2849 | Command::Listen { common, .. }
2850 | Command::Checkin { common, .. } => common.config,
2851 Command::Clean { config, .. } | Command::Doctor { config } => config,
2852 other => panic!("{other:?}"),
2853 };
2854 assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
2855 }
2856 }
2857
2858 #[test]
2859 fn auto_merge_is_off_unless_asked_for() {
2860 let cli = Cli::parse_from(["spar", "run"]);
2861 match cli.command {
2862 Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
2863 other => panic!("{other:?}"),
2864 }
2865 }
2866
2867 #[test]
2870 fn every_command_that_reads_a_config_takes_instructions() {
2871 for argv in [
2874 vec!["spar", "run", "7", "--instructions", "Do not wait for CI."],
2875 vec![
2876 "spar",
2877 "triage",
2878 "7",
2879 "--instructions",
2880 "Do not wait for CI.",
2881 ],
2882 vec![
2883 "spar",
2884 "resume",
2885 "7",
2886 "--instructions",
2887 "Do not wait for CI.",
2888 ],
2889 vec![
2890 "spar",
2891 "review",
2892 "7",
2893 "--instructions",
2894 "Do not wait for CI.",
2895 ],
2896 vec!["spar", "followup", "--instructions", "Do not wait for CI."],
2897 vec![
2898 "spar",
2899 "checkin",
2900 "7",
2901 "--instructions",
2902 "Do not wait for CI.",
2903 ],
2904 vec![
2905 "spar",
2906 "split",
2907 "7",
2908 "--instructions",
2909 "Do not wait for CI.",
2910 ],
2911 vec![
2912 "spar",
2913 "brainstorm",
2914 "a",
2915 "subject",
2916 "--instructions",
2917 "Do not wait for CI.",
2918 ],
2919 vec![
2920 "spar",
2921 "listen",
2922 "7",
2923 "--instructions",
2924 "Do not wait for CI.",
2925 ],
2926 ] {
2927 let parsed = Cli::parse_from(&argv);
2928 let common = match parsed.command {
2929 Command::Run { common, .. }
2930 | Command::Triage { common, .. }
2931 | Command::Resume { common, .. }
2932 | Command::Review { common, .. }
2933 | Command::Followup { common, .. }
2934 | Command::Split { common, .. }
2935 | Command::Brainstorm { common, .. }
2936 | Command::Listen { common, .. }
2937 | Command::Checkin { common, .. } => common,
2938 other => panic!("{other:?}"),
2939 };
2940 assert_eq!(
2941 Some("Do not wait for CI."),
2942 common.instructions.as_deref(),
2943 "{argv:?}"
2944 );
2945 }
2946 }
2947
2948 #[test]
2949 fn the_two_close_skipped_flags_are_mutually_exclusive() {
2950 assert!(
2951 Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
2952 );
2953 }
2954
2955 #[test]
2958 fn close_skipped_is_offered_only_where_it_means_something() {
2959 assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
2960 assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
2961 assert!(Cli::try_parse_from(["spar", "followup", "--close-skipped"]).is_ok());
2963 assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
2964 assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
2965 assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
2966 }
2967
2968 #[test]
2971 fn brainstorm_takes_words_or_nothing() {
2972 match Cli::parse_from(["spar", "brainstorm", "novel", "uses", "of", "script"]).command {
2973 Command::Brainstorm { topic, ideas, .. } => {
2974 assert_eq!(vec!["novel", "uses", "of", "script"], topic);
2975 assert_eq!(5, ideas);
2976 }
2977 other => panic!("{other:?}"),
2978 }
2979 match Cli::parse_from(["spar", "brainstorm", "--ideas", "3", "--max-rounds", "2"]).command {
2980 Command::Brainstorm {
2981 topic,
2982 ideas,
2983 max_rounds,
2984 ..
2985 } => {
2986 assert!(topic.is_empty());
2987 assert_eq!(3, ideas);
2988 assert_eq!(Some(2), max_rounds);
2989 }
2990 other => panic!("{other:?}"),
2991 }
2992 }
2993
2994 #[test]
2998 fn filing_from_a_saved_session_needs_file_issues_and_writes_no_session() {
2999 assert!(Cli::try_parse_from(["spar", "brainstorm", "--from", "s.md"]).is_err());
3000 assert!(
3001 Cli::try_parse_from(["spar", "brainstorm", "--from", "s.md", "--file-issues"]).is_ok()
3002 );
3003 assert!(Cli::try_parse_from([
3004 "spar",
3005 "brainstorm",
3006 "--from",
3007 "s.md",
3008 "--file-issues",
3009 "--out",
3010 "t.md"
3011 ])
3012 .is_err());
3013 assert!(
3014 Cli::try_parse_from(["spar", "brainstorm", "x", "--out", "t.md", "--file-issues"])
3015 .is_ok()
3016 );
3017 }
3018
3019 #[test]
3022 fn followup_takes_no_numbers() {
3023 assert!(Cli::try_parse_from(["spar", "followup"]).is_ok());
3024 assert!(Cli::try_parse_from(["spar", "followup", "42"]).is_err());
3025 }
3026
3027 #[test]
3030 fn the_two_stopping_points_are_mutually_exclusive() {
3031 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only"]).is_ok());
3032 assert!(Cli::try_parse_from(["spar", "followup", "--file-only"]).is_ok());
3033 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only", "--file-only"]).is_err());
3034 }
3035
3036 #[test]
3037 fn the_close_skipped_pair_resolves_to_a_tristate() {
3038 let read = |argv: &[&str]| match Cli::parse_from(argv).command {
3039 Command::Run { triage_flags, .. } => {
3040 match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
3041 (true, _) => Some(true),
3042 (_, true) => Some(false),
3043 _ => None,
3044 }
3045 }
3046 other => panic!("{other:?}"),
3047 };
3048 assert_eq!(None, read(&["spar", "run"]));
3049 assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
3050 assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
3051 }
3052
3053 #[test]
3054 fn the_default_limit_is_twenty() {
3055 let cli = Cli::parse_from(["spar", "run"]);
3056 match cli.command {
3057 Command::Run { common, .. } => assert_eq!(20, common.limit),
3058 other => panic!("{other:?}"),
3059 }
3060 }
3061
3062 #[test]
3063 fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
3064 assert!(matches!(
3065 Cli::parse_from(["spar", "scrub-filter"]).command,
3066 Command::ScrubFilter
3067 ));
3068 let help = Cli::command().render_long_help().to_string();
3069 assert!(
3070 !help.contains("scrub-filter"),
3071 "it is plumbing, not a command"
3072 );
3073 }
3074
3075 #[test]
3076 fn review_takes_pr_numbers_and_a_dry_run() {
3077 let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
3078 match cli.command {
3079 Command::Review { items, dry_run, .. } => {
3080 assert_eq!(vec![101, 102], items);
3081 assert!(dry_run);
3082 }
3083 other => panic!("{other:?}"),
3084 }
3085 }
3086
3087 #[test]
3088 fn review_posts_unless_told_not_to() {
3089 match Cli::parse_from(["spar", "review", "101"]).command {
3090 Command::Review { dry_run, .. } => assert!(!dry_run),
3091 other => panic!("{other:?}"),
3092 }
3093 }
3094
3095 #[test]
3096 fn review_with_no_numbers_is_allowed() {
3097 match Cli::parse_from(["spar", "review"]).command {
3098 Command::Review { items, .. } => assert!(items.is_empty()),
3099 other => panic!("{other:?}"),
3100 }
3101 }
3102
3103 #[test]
3104 fn review_takes_its_own_round_budget() {
3105 match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
3106 Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
3107 other => panic!("{other:?}"),
3108 }
3109 }
3110
3111 #[test]
3112 fn checkin_takes_pr_numbers_and_a_dry_run() {
3113 match Cli::parse_from(["spar", "checkin", "108", "112", "--dry-run"]).command {
3114 Command::Checkin { items, dry_run, .. } => {
3115 assert_eq!(vec![108, 112], items);
3116 assert!(dry_run);
3117 }
3118 other => panic!("{other:?}"),
3119 }
3120 match Cli::parse_from(["spar", "checkin"]).command {
3121 Command::Checkin { items, dry_run, .. } => {
3122 assert!(items.is_empty());
3123 assert!(!dry_run);
3124 }
3125 other => panic!("{other:?}"),
3126 }
3127 }
3128
3129 #[test]
3135 fn checkin_offers_no_flag_that_would_weaken_the_pair() {
3136 assert!(Cli::try_parse_from(["spar", "checkin", "--auto-merge"]).is_err());
3137 assert!(Cli::try_parse_from(["spar", "checkin", "--max-rounds", "1"]).is_err());
3138 assert!(Cli::try_parse_from(["spar", "checkin", "--absorb", "1"]).is_err());
3139 assert!(Cli::try_parse_from(["spar", "checkin", "--close-skipped"]).is_err());
3140 assert!(Cli::try_parse_from(["spar", "checkin", "--reply-only"]).is_ok());
3142 assert!(Cli::try_parse_from(["spar", "checkin", "--any-author"]).is_ok());
3143 assert!(Cli::try_parse_from(["spar", "checkin", "--again"]).is_ok());
3144 assert!(Cli::try_parse_from(["spar", "checkin", "--keep-worktrees"]).is_ok());
3145 }
3146
3147 #[test]
3148 fn review_takes_verdict_and_recheck_flags_and_neither_by_default() {
3149 match Cli::parse_from(["spar", "review", "108", "--verdict", "--recheck"]).command {
3150 Command::Review {
3151 items,
3152 verdict,
3153 recheck,
3154 ..
3155 } => {
3156 assert_eq!(vec![108], items);
3157 assert!(verdict);
3158 assert!(recheck);
3159 }
3160 other => panic!("{other:?}"),
3161 }
3162 match Cli::parse_from(["spar", "review", "108"]).command {
3163 Command::Review {
3164 verdict, recheck, ..
3165 } => {
3166 assert!(!verdict);
3167 assert!(!recheck);
3168 }
3169 other => panic!("{other:?}"),
3170 }
3171 assert!(Cli::try_parse_from(["spar", "checkin", "108", "--verdict"]).is_err());
3172 }
3173
3174 #[test]
3175 fn listen_takes_numbers_once_dry_run_and_since() {
3176 match Cli::parse_from([
3177 "spar",
3178 "listen",
3179 "108",
3180 "42",
3181 "--once",
3182 "--dry-run",
3183 "--since",
3184 "2h",
3185 ])
3186 .command
3187 {
3188 Command::Listen {
3189 items,
3190 once,
3191 dry_run,
3192 since,
3193 ..
3194 } => {
3195 assert_eq!(vec![108, 42], items);
3196 assert!(once);
3197 assert!(dry_run);
3198 assert_eq!(Some("2h".to_string()), since);
3199 }
3200 other => panic!("{other:?}"),
3201 }
3202 match Cli::parse_from(["spar", "listen"]).command {
3203 Command::Listen {
3204 items,
3205 once,
3206 dry_run,
3207 since,
3208 common,
3209 } => {
3210 assert!(items.is_empty());
3211 assert!(!once);
3212 assert!(!dry_run);
3213 assert_eq!(None, since);
3214 assert_eq!(20, common.limit);
3215 }
3216 other => panic!("{other:?}"),
3217 }
3218 }
3219
3220 #[test]
3221 fn resume_takes_a_next_override() {
3222 let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
3223 match cli.command {
3224 Command::Resume {
3225 prs, next_actor, ..
3226 } => {
3227 assert_eq!(vec![108], prs);
3228 assert_eq!(Some("codex".to_string()), next_actor);
3229 }
3230 other => panic!("{other:?}"),
3231 }
3232 }
3233}
3234
3235#[cfg(test)]
3236mod absorb_tests {
3237 use super::*;
3238
3239 #[test]
3240 fn absorb_is_off_unless_asked_for() {
3241 match Cli::parse_from(["spar", "run"]).command {
3242 Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
3243 other => panic!("{other:?}"),
3244 }
3245 }
3246
3247 #[test]
3248 fn absorb_takes_a_wave_count() {
3249 match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
3250 Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
3251 other => panic!("{other:?}"),
3252 }
3253 }
3254
3255 #[test]
3256 fn absorb_is_only_offered_where_issues_are_worked() {
3257 assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
3258 assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
3259 assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
3260 assert!(Cli::try_parse_from(["spar", "split", "--absorb", "1"]).is_err());
3263 }
3264}
3265
3266#[cfg(test)]
3267mod split_tests {
3268 use super::*;
3269
3270 #[test]
3271 fn split_takes_numbers_of_either_kind() {
3272 match Cli::parse_from(["spar", "split", "10", "12"]).command {
3273 Command::Split { items, .. } => assert_eq!(vec![10, 12], items),
3274 other => panic!("{other:?}"),
3275 }
3276 }
3277
3278 #[test]
3281 fn split_with_no_numbers_is_allowed() {
3282 match Cli::parse_from(["spar", "split"]).command {
3283 Command::Split {
3284 items,
3285 dry_run,
3286 again,
3287 ..
3288 } => {
3289 assert!(items.is_empty());
3290 assert!(!dry_run);
3291 assert!(!again);
3292 }
3293 other => panic!("{other:?}"),
3294 }
3295 }
3296
3297 #[test]
3301 fn split_can_be_told_to_write_nothing() {
3302 match Cli::parse_from(["spar", "split", "10", "--dry-run"]).command {
3303 Command::Split { dry_run, .. } => assert!(dry_run),
3304 other => panic!("{other:?}"),
3305 }
3306 }
3307
3308 #[test]
3311 fn split_can_be_told_to_split_something_it_already_split() {
3312 match Cli::parse_from(["spar", "split", "10", "--again"]).command {
3313 Command::Split { again, .. } => assert!(again),
3314 other => panic!("{other:?}"),
3315 }
3316 }
3317
3318 #[test]
3321 fn split_takes_the_flags_that_govern_picking() {
3322 match Cli::parse_from([
3323 "spar",
3324 "split",
3325 "--limit",
3326 "5",
3327 "--min-number",
3328 "480",
3329 "--config",
3330 "other.toml",
3331 ])
3332 .command
3333 {
3334 Command::Split { common, .. } => {
3335 assert_eq!(5, common.limit);
3336 assert_eq!(Some(480), common.min_number);
3337 assert_eq!(Some(PathBuf::from("other.toml")), common.config);
3338 }
3339 other => panic!("{other:?}"),
3340 }
3341 }
3342
3343 #[test]
3346 fn split_offers_no_flag_that_would_make_it_a_second_run() {
3347 for flag in [
3348 vec!["--auto-merge"],
3349 vec!["--max-rounds", "2"],
3350 vec!["--close-skipped"],
3351 vec!["--no-close-skipped"],
3352 vec!["--keep-worktrees"],
3353 ] {
3354 let mut argv = vec!["spar", "split", "10"];
3355 argv.extend(flag.iter().copied());
3356 assert!(Cli::try_parse_from(&argv).is_err(), "{argv:?}");
3357 }
3358 }
3359}
3360
3361#[cfg(test)]
3362mod min_number_tests {
3363 use super::*;
3364
3365 fn read(argv: &[&str]) -> Option<i64> {
3366 match Cli::parse_from(argv).command {
3367 Command::Run { common, .. }
3368 | Command::Triage { common, .. }
3369 | Command::Resume { common, .. }
3370 | Command::Review { common, .. }
3371 | Command::Followup { common, .. }
3372 | Command::Split { common, .. }
3373 | Command::Listen { common, .. }
3374 | Command::Checkin { common, .. } => common.min_number,
3375 other => panic!("{other:?}"),
3376 }
3377 }
3378
3379 #[test]
3380 fn there_is_no_floor_unless_one_is_asked_for() {
3381 assert_eq!(None, read(&["spar", "run"]));
3382 }
3383
3384 #[test]
3385 fn every_command_that_picks_for_itself_accepts_a_floor() {
3386 for cmd in [
3387 "run", "triage", "resume", "review", "checkin", "split", "listen",
3388 ] {
3389 assert_eq!(
3390 Some(480),
3391 read(&["spar", cmd, "--min-number", "480"]),
3392 "{cmd}"
3393 );
3394 }
3395 }
3396}
3397
3398#[cfg(test)]
3399mod run_targets_tests {
3400 use super::*;
3401
3402 fn covered(pairs: &[(i64, i64)]) -> BTreeMap<i64, i64> {
3403 pairs.iter().copied().collect()
3404 }
3405
3406 #[test]
3411 fn an_issue_its_named_pull_request_closes_is_not_worked_again() {
3412 let kept = issues_not_on_named_prs(&[772, 783, 784], &covered(&[(772, 786)]));
3413
3414 assert_eq!(vec![783, 784], kept);
3415 }
3416
3417 #[test]
3418 fn an_issue_no_named_pull_request_closes_is_still_worked() {
3419 let kept = issues_not_on_named_prs(&[783, 784, 771], &covered(&[(772, 786)]));
3420
3421 assert_eq!(vec![783, 784, 771], kept);
3422 }
3423
3424 #[test]
3427 fn an_unreadable_pull_request_drops_no_issue() {
3428 let kept = issues_not_on_named_prs(&[772, 783], &covered(&[]));
3429
3430 assert_eq!(vec![772, 783], kept);
3431 }
3432
3433 #[test]
3435 fn every_issue_one_pull_request_closes_is_taken_by_it() {
3436 let kept = issues_not_on_named_prs(&[771, 772, 783], &covered(&[(771, 786), (772, 786)]));
3437
3438 assert_eq!(vec![783], kept);
3439 }
3440}
3441
3442#[cfg(test)]
3443mod settings_block_tests {
3444 use super::*;
3445
3446 fn written(line: &str) -> String {
3449 let after = line.split_once('=').expect("an assignment").1;
3450 let mut quoted = false;
3451 for (i, c) in after.char_indices() {
3452 match c {
3453 '"' => quoted = !quoted,
3454 '#' if !quoted => return after[..i].trim().to_string(),
3455 _ => {}
3456 }
3457 }
3458 after.trim().to_string()
3459 }
3460
3461 fn line_for(text: &str, key: &str) -> String {
3462 text.lines()
3463 .find(|l| {
3464 let bare = l.trim_start().trim_start_matches('#').trim_start();
3465 bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
3466 })
3467 .unwrap_or_else(|| panic!("{key} is not offered at all:\n{text}"))
3468 .to_string()
3469 }
3470
3471 #[test]
3478 fn every_value_it_offers_is_the_default_it_actually_has() {
3479 let text = settings_block("claude");
3480 for option in config::known_options() {
3481 if option.section == "loop.effort_schedule" {
3485 continue;
3486 }
3487 let line = line_for(&text, &option.key);
3488 assert_eq!(
3489 option.default,
3490 written(&line),
3491 "the generated config offers `{}`, but the default is {}",
3492 line.trim(),
3493 option.default
3494 );
3495 }
3496 }
3497
3498 #[test]
3502 fn it_offers_every_option_the_parser_knows_about() {
3503 let text = settings_block("claude");
3504 let missing: Vec<String> = config::unmentioned_options(&text)
3505 .into_iter()
3506 .map(|o| format!("[{}] {}", o.section, o.key))
3507 .collect();
3508 assert!(missing.is_empty(), "not offered: {}", missing.join(", "));
3509 }
3510
3511 #[test]
3515 fn every_option_it_offers_can_be_uncommented_and_still_load() {
3516 let mut text = String::from(
3517 "[agents.claude]\ncommand = [\"claude\"]\n\n\
3518 [agents.codex]\ncommand = [\"codex\"]\n\n",
3519 );
3520 for line in settings_block("claude").lines() {
3521 text.push_str(uncomment(line).unwrap_or(line));
3522 text.push('\n');
3523 }
3524 let cfg = config::parse(&text).expect("a config of its own suggestions");
3525 assert_eq!("claude", cfg.first_implementor);
3526 }
3527
3528 fn uncomment(line: &str) -> Option<&str> {
3531 let bare = line.trim_start().strip_prefix('#')?.trim_start();
3532 let key = bare.split_once('=')?.0.trim();
3535 let named = !key.is_empty()
3536 && key
3537 .chars()
3538 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
3539 named.then_some(bare)
3540 }
3541
3542 #[test]
3543 fn the_agent_that_goes_first_is_the_one_that_was_chosen() {
3544 assert!(settings_block("codex").contains("first_implementor = \"codex\""));
3545 }
3546
3547 #[test]
3552 fn a_note_sits_above_the_option_it_describes() {
3553 let text = settings_block("claude");
3554 assert!(
3555 text.lines().all(|l| l.chars().count() <= 80),
3556 "a line runs off the edge:\n{text}"
3557 );
3558 assert!(
3559 text.lines().all(|l| !l.starts_with(' ')),
3560 "a line is indented, so the columns are back:\n{text}"
3561 );
3562
3563 let lines: Vec<&str> = text.lines().collect();
3565 let at = lines
3566 .iter()
3567 .position(|l| l.starts_with("max_rounds"))
3568 .expect("max_rounds");
3569 assert!(lines[at - 1].starts_with('#'), "{:?}", lines[at - 1]);
3570 assert!(
3571 lines[at - 1].contains("lifetime cap"),
3572 "the note above it is the end of its own note: {:?}",
3573 lines[at - 1]
3574 );
3575 }
3576
3577 #[test]
3579 fn every_note_starts_as_a_sentence() {
3580 for (_, key, note) in LOOP_OPTIONS
3581 .iter()
3582 .chain(STYLE_OPTIONS)
3583 .chain(LISTEN_OPTIONS)
3584 {
3585 let first = note.chars().next().expect("a note");
3586 assert!(
3587 first.is_uppercase(),
3588 "{key} reads as a margin scribble rather than a sentence: {note}"
3589 );
3590 }
3591 }
3592}
3593
3594#[cfg(test)]
3595mod agent_block_tests {
3596 use super::*;
3597
3598 fn spec(models: &[&str], efforts: &[&str]) -> config::AgentSpec {
3599 let mut spec: config::AgentSpec =
3600 toml::Value::Table(toml::from_str("command = [\"x\"]").expect("a minimal preset"))
3601 .try_into()
3602 .expect("builds");
3603 spec.models = models.iter().map(|s| s.to_string()).collect();
3604 spec.efforts = efforts.iter().map(|s| s.to_string()).collect();
3605 spec
3606 }
3607
3608 #[test]
3612 fn an_option_with_no_hints_is_left_out_rather_than_guessed_at() {
3613 let block = agent_block("cursor", &spec(&["composer-2.5", "auto"], &[]));
3614 assert!(!block.contains("..."), "{block}");
3615 assert!(!block.contains("effort"), "{block}");
3616 assert!(block.contains("# model = \"composer-2.5\""), "{block}");
3617 }
3618
3619 #[test]
3622 fn only_the_options_that_follow_are_introduced() {
3623 let model_only = agent_block("cursor", &spec(&["auto"], &[]));
3624 assert!(model_only.contains("Omit model to use"), "{model_only}");
3625 assert!(!model_only.contains("Omit effort"), "{model_only}");
3626
3627 let both = agent_block("claude", &spec(&["fable"], &["high"]));
3628 assert!(both.contains("Omit model to use"), "{both}");
3629 assert!(both.contains("Omit effort to use"), "{both}");
3630 }
3631
3632 #[test]
3635 fn a_preset_with_no_hints_still_writes_a_usable_block() {
3636 let block = agent_block("gemini", &spec(&[], &[]));
3637 assert!(!block.contains("..."), "{block}");
3638 assert!(!block.contains("Omit"), "{block}");
3639 assert!(
3640 block.starts_with("[agents.gemini]\npreset = \"gemini\"\n"),
3641 "{block}"
3642 );
3643 assert!(block.contains("[agents.gemini.fallback]"), "{block}");
3645 assert!(block.contains("# timeout = "), "{block}");
3646 }
3647
3648 #[test]
3651 fn the_timeout_offered_is_the_one_the_agent_would_use() {
3652 let mut spec = spec(&["a"], &[]);
3653 spec.timeout = 7200;
3654 assert!(
3655 agent_block("custom", &spec).contains("# timeout = 7200"),
3656 "the generator kept its own copy"
3657 );
3658 }
3659
3660 #[test]
3662 fn alternatives_are_listed_only_when_there_are_any() {
3663 assert!(agent_block("a", &spec(&["one", "two"], &[])).contains("One of: one | two."));
3664 let single = agent_block("b", &spec(&["only"], &[]));
3665 assert!(!single.contains("One of:"), "{single}");
3666 }
3667
3668 #[test]
3672 fn the_presets_note_is_said_once() {
3673 let mut spec = spec(&["m1", "m2"], &["e1", "e2"]);
3674 spec.options_note = Some("Check the current sets with: mytool --help".into());
3675 let block = agent_block("mytool", &spec);
3676 assert_eq!(
3677 1,
3678 block.matches("Check the current sets").count(),
3679 "{block}"
3680 );
3681 }
3682}