1use std::collections::{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::checkin;
11use crate::config::{self, Config};
12use crate::error::Result;
13use crate::followups;
14use crate::model::{Issue, IssueRun, ItemKind, Plan, PlanItem, Status};
15use crate::proc::{self, ExecOpts};
16use crate::repo::{Repo, WriteSummary};
17use crate::review;
18use crate::review_only;
19use crate::spend;
20use crate::split;
21use crate::style;
22use crate::tracker;
23use crate::triage;
24use crate::{bail, log, logdim, logging, logwarn, spar_err};
25
26pub const VERSION: &str = env!("CARGO_PKG_VERSION");
27
28#[derive(Parser, Debug)]
29#[command(
30 name = "spar",
31 version = VERSION,
32 about = "Two coding agents alternate implementing and reviewing GitHub issues.",
33 long_about = "Two coding agents alternate implementing and reviewing GitHub issues until a \
34 pull request converges. Neither agent reviews its own most recent edit.\n\n\
35 Arguments are issue numbers for `run` and `triage`, and pull request numbers \
36 for `resume`, `review`, and `checkin`. `split` takes either. Omit them and \
37 spar takes everything open, up to --limit. `split` applies that limit once to \
38 issues and once to pull requests. `followup` takes none: it works the queue in \
39 .spar/followups.md, and an entry there has no number to name.",
40 max_term_width = 96
41)]
42pub struct Cli {
43 #[arg(short, long, global = true)]
45 pub quiet: bool,
46
47 #[command(subcommand)]
48 pub command: Command,
49}
50
51#[derive(Subcommand, Debug)]
52pub enum Command {
53 Run {
55 issues: Vec<i64>,
57 #[command(flatten)]
58 common: Common,
59 #[command(flatten)]
60 loop_flags: LoopFlags,
61 #[command(flatten)]
62 triage_flags: TriageFlags,
63 #[arg(long, default_value = "plan.json")]
65 plan_out: PathBuf,
66 #[arg(long)]
68 no_worktrees: bool,
69 },
70
71 Followup {
83 #[command(flatten)]
84 common: Common,
85 #[command(flatten)]
86 loop_flags: LoopFlags,
87 #[command(flatten)]
88 triage_flags: TriageFlags,
89 #[arg(long, value_name = "PATH")]
91 file: Option<PathBuf>,
92 #[arg(long)]
94 screen_only: bool,
95 #[arg(long, conflicts_with = "screen_only")]
97 file_only: bool,
98 #[arg(long, default_value = "plan.json")]
100 plan_out: PathBuf,
101 #[arg(long)]
103 no_worktrees: bool,
104 },
105
106 Triage {
108 issues: Vec<i64>,
110 #[command(flatten)]
111 common: Common,
112 #[arg(long, default_value = "plan.json")]
113 plan_out: PathBuf,
114 },
115
116 Resume {
118 prs: Vec<i64>,
120 #[command(flatten)]
121 common: Common,
122 #[command(flatten)]
123 loop_flags: LoopFlags,
124 #[arg(long = "next", value_name = "AGENT")]
126 next_actor: Option<String>,
127 },
128
129 Checkin {
137 items: Vec<i64>,
140 #[command(flatten)]
141 common: Common,
142 #[arg(long)]
144 dry_run: bool,
145 #[arg(long)]
147 reply_only: bool,
148 #[arg(long)]
151 any_author: bool,
152 #[arg(long)]
154 again: bool,
155 #[arg(long)]
157 keep_worktrees: bool,
158 },
159
160 Review {
165 items: Vec<i64>,
168 #[command(flatten)]
169 common: Common,
170 #[arg(long)]
172 dry_run: bool,
173 #[arg(long)]
176 max_rounds: Option<u32>,
177 },
178
179 Split {
196 items: Vec<i64>,
199 #[command(flatten)]
200 common: Common,
201 #[arg(long)]
203 dry_run: bool,
204 #[arg(long)]
207 again: bool,
208 },
209
210 Post {
215 #[arg(required = true)]
217 prs: Vec<i64>,
218 #[arg(long, default_value = ".")]
219 repo: PathBuf,
220 #[arg(long)]
221 config: Option<PathBuf>,
222 #[arg(long, value_name = "PATH")]
224 file: Option<PathBuf>,
225 #[arg(long)]
227 dry_run: bool,
228 },
229
230 Init {
235 #[arg(long, default_value = "spar.toml")]
236 out: PathBuf,
237 #[arg(long)]
239 force: bool,
240 #[arg(long, conflicts_with = "force")]
243 update: bool,
244 },
245
246 Clean {
248 #[arg(long, default_value = ".")]
249 repo: PathBuf,
250 #[arg(long)]
251 config: Option<PathBuf>,
252 #[arg(long)]
255 all: bool,
256 #[arg(long)]
258 pr_state: bool,
259 },
260
261 Doctor {
263 #[arg(long)]
264 config: Option<PathBuf>,
265 },
266
267 #[command(hide = true)]
271 ScrubFilter,
272}
273
274#[derive(Args, Debug, Clone)]
275pub struct Common {
276 #[arg(long, default_value = ".")]
278 pub repo: PathBuf,
279 #[arg(long)]
281 pub config: Option<PathBuf>,
282 #[arg(long)]
284 pub base: Option<String>,
285 #[arg(long)]
287 pub first: Option<String>,
288 #[arg(long, default_value_t = 20)]
291 pub limit: usize,
292 #[arg(long, value_name = "N")]
295 pub min_number: Option<i64>,
296 #[arg(long, value_name = "TEXT")]
299 pub instructions: Option<String>,
300}
301
302#[derive(Args, Debug, Clone)]
303pub struct LoopFlags {
304 #[arg(long)]
307 pub max_rounds: Option<u32>,
308 #[arg(long)]
310 pub auto_merge: bool,
311 #[arg(long)]
313 pub keep_worktrees: bool,
314 #[arg(long, value_name = "N")]
317 pub absorb: Option<u32>,
318}
319
320#[derive(Args, Debug, Clone)]
323pub struct TriageFlags {
324 #[arg(long, conflicts_with = "no_close_skipped")]
326 pub close_skipped: bool,
327 #[arg(long)]
329 pub no_close_skipped: bool,
330 #[arg(long)]
336 pub retriage: bool,
337}
338
339pub fn main() -> i32 {
344 let cli = Cli::parse();
345 logging::init_color();
346 logging::set_quiet(cli.quiet);
347
348 match dispatch(cli) {
349 Ok(code) => code,
350 Err(e) => {
351 logging::error(e.to_string());
352 2
353 }
354 }
355}
356
357fn dispatch(cli: Cli) -> Result<i32> {
358 match cli.command {
359 Command::ScrubFilter => cmd_scrub_filter(),
360 Command::Doctor { config } => cmd_doctor(config.as_deref()),
361 Command::Review {
362 items,
363 common,
364 dry_run,
365 max_rounds,
366 } => {
367 let overrides = Overrides {
368 max_rounds,
369 ..Overrides::default()
370 };
371 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
372 let numbers = if items.is_empty() {
373 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
374 if found.is_empty() {
375 log!("no open PRs");
376 return Ok(0);
377 }
378 log!("no PRs given, reviewing {} open", found.len());
379 found
380 } else {
381 items
382 };
383 let sorted = classify(&repo, &numbers)?;
384 let mut targets = sorted.prs;
385 for number in sorted.issues {
386 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
387 Ok(Some(pr)) => {
388 log!("#{number} is an issue; reviewing its open PR {}", pr.url);
389 targets.push(pr.number);
390 }
391 Ok(None) => {
392 logwarn!("#{number} is an issue with no open pull request to review")
393 }
394 Err(e) => logwarn!("#{number}: {e}"),
395 }
396 }
397 let mut results = Vec::new();
398 for number in targets {
399 results.push(review_only::review_pr(
400 &agents, &cfg, &repo, number, dry_run,
401 ));
402 }
403 if results.is_empty() {
404 return Ok(0);
405 }
406 Ok(report(&results, &cfg, &repo))
407 }
408
409 Command::Checkin {
410 items,
411 common,
412 dry_run,
413 reply_only,
414 any_author,
415 again,
416 keep_worktrees,
417 } => {
418 let overrides = Overrides {
419 keep_worktrees: keep_worktrees.then_some(true),
420 ..Overrides::default()
421 };
422 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
423 let mode = checkin::Mode {
424 dry_run,
425 reply_only,
426 trust: if any_author {
427 crate::config::Trust::Anyone
428 } else {
429 cfg.loop_cfg.checkin_trust
430 },
431 again,
432 resolve: cfg.loop_cfg.checkin_resolve,
433 posts: checkin::posts(&cfg),
434 };
435 let numbers = if items.is_empty() {
436 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
437 if found.is_empty() {
438 log!("no open PRs");
439 return Ok(0);
440 }
441 log!(
442 "no PRs given, checking in on {} open: {}",
443 found.len(),
444 found
445 .iter()
446 .map(|n| format!("#{n}"))
447 .collect::<Vec<_>>()
448 .join(", ")
449 );
450 found
451 } else {
452 items
453 };
454 let sorted = classify(&repo, &numbers)?;
455 let mut results = Vec::new();
456 for number in sorted.prs {
457 results.push(checkin::checkin_pr(&agents, &cfg, &repo, number, &mode));
458 }
459 for number in sorted.issues {
463 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
464 Ok(Some(pr)) => {
465 log!(
466 "#{number} is an issue; checking in on its open PR {}",
467 pr.url
468 );
469 results.push(checkin::checkin_pr(&agents, &cfg, &repo, pr.number, &mode));
470 }
471 Ok(None) => {
472 results.push(checkin::checkin_issue(&agents, &cfg, &repo, number, &mode))
473 }
474 Err(e) => results.push(checkin::failed(number, format!("#{number}"), e)),
479 }
480 }
481 if results.is_empty() {
482 return Ok(0);
483 }
484 Ok(report(&results, &cfg, &repo))
485 }
486
487 Command::Split {
488 items,
489 common,
490 dry_run,
491 again,
492 } => {
493 let (cfg, repo, agents) = prepare(&common, None)?;
494 let mode = split::Mode { dry_run, again };
495 let picked = if items.is_empty() {
496 pick_for_split(&agents, &cfg, &repo, common.limit, &mode)?
497 } else {
498 let sorted = classify(&repo, &items)?;
499 let mut out: Vec<(i64, ItemKind)> = sorted
500 .issues
501 .iter()
502 .map(|n| (*n, ItemKind::Issue))
503 .collect();
504 out.extend(sorted.prs.iter().map(|n| (*n, ItemKind::Pr)));
505 out
506 };
507
508 let mut results = Vec::new();
509 let mut seen: BTreeSet<i64> = BTreeSet::new();
510 for (number, kind) in picked {
511 let target = match kind {
515 ItemKind::Pr => Some(number),
516 ItemKind::Issue => {
517 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
518 Ok(found) => found.map(|pr| {
519 log!("#{number} is an issue; splitting its open PR {}", pr.url);
520 pr.number
521 }),
522 Err(e) => {
523 logwarn!("#{number}: {e}");
524 continue;
525 }
526 }
527 }
528 };
529 if !seen.insert(target.unwrap_or(number)) {
534 logdim!("#{number} was already covered by this run, skipping it");
535 continue;
536 }
537 results.push(match target {
538 Some(pr) => split::split_pr(&agents, &cfg, &repo, pr, &mode),
539 None => split::split_issue(&agents, &cfg, &repo, number, &mode),
540 });
541 }
542 if results.is_empty() {
543 return Ok(0);
544 }
545 Ok(report(&results, &cfg, &repo))
546 }
547
548 Command::Post {
549 prs,
550 repo: repo_path,
551 config,
552 file,
553 dry_run,
554 } => cmd_post(
555 &prs,
556 &repo_path,
557 config.as_deref(),
558 file.as_deref(),
559 dry_run,
560 ),
561
562 Command::Init { out, force, update } => {
563 if update {
564 cmd_init_update(&out)
565 } else {
566 cmd_init(&out, force)
567 }
568 }
569 Command::Clean {
570 repo,
571 config,
572 all,
573 pr_state,
574 } => cmd_clean(&repo, config.as_deref(), all, pr_state),
575 Command::Triage {
576 issues,
577 common,
578 plan_out,
579 } => {
580 let (cfg, repo, agents) = prepare(&common, None)?;
581 let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
582 if numbers.is_empty() {
583 return Ok(0);
584 }
585 let sorted = classify(&repo, &numbers)?;
586 for number in &sorted.prs {
587 log!("#{number} is a pull request, nothing to triage");
588 }
589 if sorted.issues.is_empty() {
590 log!("no issues to triage");
591 return Ok(0);
592 }
593 let issues = repo.fetch_issues(&sorted.issues)?;
594 let plan = make_plan(&agents, &cfg, &repo, &issues, &plan_out)?;
598 if cfg.loop_cfg.decompose_trackers {
602 for item in plan.skipped.iter().filter(|i| i.tracker) {
603 tracker::preview(&cfg, &repo, item.issue);
604 }
605 }
606 Ok(0)
607 }
608 Command::Run {
609 issues,
610 common,
611 loop_flags,
612 triage_flags,
613 plan_out,
614 no_worktrees,
615 } => {
616 let overrides = Overrides::for_working(&loop_flags, &triage_flags, no_worktrees);
617 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
618 let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
619 if numbers.is_empty() {
620 return Ok(0);
621 }
622 let sorted = classify(&repo, &numbers)?;
623 let mut results = Vec::new();
624 let mut stopped = Vec::new();
625 let mut parked = Vec::new();
626 work_issues(
627 &agents,
628 &cfg,
629 &repo,
630 sorted.issues.clone(),
631 &plan_out,
632 &mut results,
633 &mut stopped,
634 &mut parked,
635 triage_flags.retriage,
636 )?;
637
638 for number in sorted.prs {
641 results.push(review::resume_pr(&agents, &cfg, &repo, number, None));
642 }
643
644 if results.is_empty() {
645 log!("nothing scheduled");
648 for line in parked.iter().chain(stopped.iter()) {
649 println!("{line}");
650 }
651 return Ok(report_writes(&repo).max(i32::from(!stopped.is_empty())));
652 }
653 Ok(report_with(&results, &cfg, &repo, &stopped, &parked))
654 }
655 Command::Followup {
656 common,
657 loop_flags,
658 triage_flags,
659 file,
660 screen_only,
661 file_only,
662 plan_out,
663 no_worktrees,
664 } => {
665 let overrides = Overrides::for_working(&loop_flags, &triage_flags, no_worktrees);
666 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
667 let path = file.unwrap_or_else(|| repo.followups_path());
668 let mode = match (screen_only, file_only) {
669 (true, _) => followups::Mode::ScreenOnly,
670 (_, true) => followups::Mode::FileOnly,
671 _ => followups::Mode::Work,
672 };
673 let outcome = followups::run(&agents, &cfg, &repo, &path, common.limit, mode)?;
674
675 let wave = followups::wave(&outcome);
676 if mode != followups::Mode::Work || wave.is_empty() {
677 return Ok(report_writes(&repo));
678 }
679 let mut results = Vec::new();
680 let mut stopped = Vec::new();
681 let mut parked = Vec::new();
682 work_issues(
683 &agents,
684 &cfg,
685 &repo,
686 wave,
687 &plan_out,
688 &mut results,
689 &mut stopped,
690 &mut parked,
691 triage_flags.retriage,
692 )?;
693 if results.is_empty() {
694 log!("nothing scheduled");
697 for line in parked.iter().chain(stopped.iter()) {
698 println!("{line}");
699 }
700 return Ok(report_writes(&repo).max(i32::from(!stopped.is_empty())));
701 }
702 Ok(report_with(&results, &cfg, &repo, &stopped, &parked))
703 }
704
705 Command::Resume {
706 prs,
707 common,
708 loop_flags,
709 next_actor,
710 } => {
711 let (cfg, repo, agents) = prepare(&common, Some(Overrides::from(&loop_flags)))?;
712 if let Some(name) = &next_actor {
713 if !cfg.has_agent(name) {
714 bail!("--next must be one of: {}", cfg.agent_names().join(", "));
715 }
716 }
717 let numbers = if prs.is_empty() {
718 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
719 if found.is_empty() {
720 log!("no open PRs");
721 return Ok(0);
722 }
723 log!(
724 "no PRs given, taking {} open: {}",
725 found.len(),
726 found
727 .iter()
728 .map(|n| format!("#{n}"))
729 .collect::<Vec<_>>()
730 .join(", ")
731 );
732 found
733 } else {
734 prs
735 };
736 let sorted = classify(&repo, &numbers)?;
737 let mut results = Vec::new();
738 for number in sorted.prs {
739 results.push(review::resume_pr(
740 &agents,
741 &cfg,
742 &repo,
743 number,
744 next_actor.as_deref(),
745 ));
746 }
747 for number in sorted.issues {
750 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
751 Ok(Some(pr)) => {
752 log!("#{number} is an issue; continuing its open PR {}", pr.url);
753 results.push(review::resume_pr(
754 &agents,
755 &cfg,
756 &repo,
757 pr.number,
758 next_actor.as_deref(),
759 ));
760 }
761 Ok(None) => logwarn!(
762 "#{number} is an issue with no open pull request. Use `spar run {number}` \
763 to implement it."
764 ),
765 Err(e) => logwarn!("#{number}: {e}"),
766 }
767 }
768 if results.is_empty() {
769 return Ok(0);
770 }
771 Ok(report(&results, &cfg, &repo))
772 }
773 }
774}
775
776#[derive(Debug, Default, Clone)]
781struct Overrides {
782 max_rounds: Option<u32>,
783 auto_merge: Option<bool>,
784 keep_worktrees: Option<bool>,
785 worktrees: Option<bool>,
786 close_skipped: Option<bool>,
787 absorb: Option<u32>,
788}
789
790impl From<&LoopFlags> for Overrides {
791 fn from(flags: &LoopFlags) -> Self {
792 Self {
793 max_rounds: flags.max_rounds,
794 auto_merge: flags.auto_merge.then_some(true),
795 keep_worktrees: flags.keep_worktrees.then_some(true),
796 worktrees: None,
797 close_skipped: None,
798 absorb: flags.absorb,
799 }
800 }
801}
802
803impl Overrides {
804 fn for_working(loop_flags: &LoopFlags, triage: &TriageFlags, no_worktrees: bool) -> Self {
807 let mut over = Overrides::from(loop_flags);
808 over.worktrees = if no_worktrees { Some(false) } else { None };
809 over.close_skipped = match (triage.close_skipped, triage.no_close_skipped) {
810 (true, _) => Some(true),
811 (_, true) => Some(false),
812 _ => None,
813 };
814 over
815 }
816}
817
818#[allow(clippy::too_many_arguments)]
828fn work_issues(
829 agents: &[Agent],
830 cfg: &Config,
831 repo: &Repo,
832 first_wave: Vec<i64>,
833 plan_out: &Path,
834 results: &mut Vec<IssueRun>,
835 stopped: &mut Vec<String>,
836 parked: &mut Vec<String>,
837 retriage: bool,
838) -> Result<()> {
839 let waiting = repo.read_contested();
843 if retriage && !waiting.is_empty() {
844 repo.forget_contested(&waiting.keys().copied().collect::<Vec<_>>());
845 }
846 let mut handled: BTreeSet<i64> = BTreeSet::new();
847 let mut landed: BTreeSet<i64> = BTreeSet::new();
851 let mut leftover: BTreeSet<i64> = BTreeSet::new();
852 let mut worked = 0usize;
856 let mut queue: VecDeque<Wave> = VecDeque::from([Wave::first(first_wave)]);
857 let mut plans_written = 0usize;
858
859 while let Some(mut wave) = queue.pop_front() {
866 wave.numbers.retain(|n| !handled.contains(n));
867 if !retriage {
868 wave.numbers.retain(|n| {
869 let Some(known) = waiting.get(n) else {
870 return true;
871 };
872 log!(
873 "#{n} was contested on an earlier run and is waiting on you: {}. Pass \
874 --retriage to ask again.",
875 positions_of(known)
876 );
877 parked.push(format!(
878 "#{n} contested and parked for you: {}",
879 positions_of(known)
880 ));
881 false
882 });
883 }
884 if wave.numbers.is_empty() {
885 continue;
886 }
887 match wave.parent {
888 Some(tracker) => log!(
889 "working {} item(s) from the checklist in #{tracker}: {}",
890 wave.numbers.len(),
891 numbers(&wave.numbers)
892 ),
893 None if wave.absorbed > 0 => log!(
894 "absorbing {} newly filed issue(s): {}",
895 wave.numbers.len(),
896 numbers(&wave.numbers)
897 ),
898 None => {}
899 }
900 handled.extend(wave.numbers.iter().copied());
901
902 let fetched = match repo.fetch_issues(&wave.numbers) {
903 Ok(fetched) => fetched,
904 Err(e) => {
905 logdim!("could not read the next wave: {e}");
906 continue;
907 }
908 };
909 let plan_path = if plans_written == 0 {
912 plan_out.to_path_buf()
913 } else {
914 plan_out.with_extension(format!("wave{plans_written}.json"))
915 };
916 plans_written += 1;
917 let plan = match make_plan(agents, cfg, repo, &fetched, &plan_path) {
922 Ok(plan) => plan,
923 Err(e) => {
924 logwarn!("triage failed for {}: {e}", numbers(&wave.numbers));
925 stopped.push(format!(
926 "triage failed for {}, so it was not worked: {}",
927 numbers(&wave.numbers),
928 first_line(&e.to_string())
929 ));
930 for later in &queue {
931 stopped.push(format!(
932 "{} was not reached after that failure",
933 numbers(&later.numbers)
934 ));
935 }
936 queue.clear();
937 break;
938 }
939 };
940 act_on_plan(cfg, repo, &plan);
941 repo.remember_contested(&plan.contested);
942 for item in &plan.contested {
943 parked.push(format!(
944 "#{} contested and parked for you: {}",
945 item.issue,
946 positions(&item.positions, &item.reasons)
947 ));
948 }
949 for item in &plan.skipped {
950 let what = if item.tracker {
951 "left open as a tracker"
952 } else if cfg.loop_cfg.close_skipped {
953 "commented on and closed"
954 } else {
955 "commented on and left open"
956 };
957 parked.push(format!(
958 "#{} skipped by both reviewers, {what}: {}",
959 item.issue,
960 item.reasons
961 .values()
962 .next()
963 .map(|r| first_line(r))
964 .unwrap_or_default()
965 ));
966 }
967
968 if cfg.loop_cfg.decompose_trackers && wave.parent.is_none() {
972 for item in plan.skipped.iter().filter(|i| i.tracker) {
973 let children = tracker::decompose(cfg, repo, item.issue);
974 if !children.is_empty() {
975 queue.push_back(wave.child(item.issue, children));
976 }
977 }
978 }
979
980 let before = results.len();
981 for item in &plan.order {
982 let Some(issue) = fetched.iter().find(|i| i.number == item.issue) else {
983 continue;
984 };
985 if let Some(why) = held_back(item, &plan, &landed) {
986 log!("#{}: {why}", item.issue);
987 let mut held = IssueRun::new(item.issue, item.title.clone());
988 held.status = Status::Pending;
989 held.notes.push(why);
990 results.push(held);
991 continue;
992 }
993 let run = review::run_nth_issue(agents, cfg, repo, item, issue, worked);
994 worked += 1;
995 if run.status == Status::Merged {
996 landed.insert(item.issue);
997 }
998 results.push(run);
999 }
1000
1001 let filed: Vec<i64> = results[before..]
1003 .iter()
1004 .flat_map(|r| r.filed.iter())
1005 .filter_map(|url| review::filed_issue_number(url))
1006 .filter(|n| !handled.contains(n))
1007 .collect::<BTreeSet<_>>()
1008 .into_iter()
1009 .collect();
1010 if filed.is_empty() {
1011 continue;
1012 }
1013 match wave.absorbed < cfg.loop_cfg.absorb_new_issues {
1014 true => queue.push_back(wave.absorb(filed)),
1015 false => leftover.extend(filed),
1016 }
1017 }
1018 if !leftover.is_empty() && cfg.loop_cfg.absorb_new_issues > 0 {
1019 log!(
1020 "{} issue(s) filed in the last wave were left for a later run: {}",
1021 leftover.len(),
1022 numbers(&leftover.iter().copied().collect::<Vec<_>>())
1023 );
1024 }
1025 Ok(())
1026}
1027
1028fn positions(
1030 positions: &std::collections::BTreeMap<String, String>,
1031 reasons: &std::collections::BTreeMap<String, String>,
1032) -> String {
1033 if positions.is_empty() {
1034 return "no positions recorded".to_string();
1035 }
1036 positions
1037 .iter()
1038 .map(|(name, side)| match reasons.get(name) {
1039 Some(why) if !why.trim().is_empty() => {
1040 format!("{name} says {side} ({})", first_line(why))
1041 }
1042 _ => format!("{name} says {side}"),
1043 })
1044 .collect::<Vec<_>>()
1045 .join("; ")
1046}
1047
1048fn positions_of(known: &crate::model::Contested) -> String {
1049 positions(&known.positions, &known.reasons)
1050}
1051
1052fn held_back(item: &PlanItem, plan: &Plan, landed: &BTreeSet<i64>) -> Option<String> {
1064 for dep in &item.depends_on {
1065 if landed.contains(dep) {
1066 continue;
1067 }
1068 if plan.order.iter().any(|other| other.issue == *dep) {
1069 return Some(format!(
1070 "held back: #{dep} is in this run and has not merged, so its work is not on the \
1071 base this would be built from. Merge #{dep} and run #{} again.",
1072 item.issue
1073 ));
1074 }
1075 if plan.skipped.iter().any(|s| s.issue == *dep) {
1076 return Some(format!(
1077 "held back: #{dep}, which it depends on, was declined by both reviewers"
1078 ));
1079 }
1080 if plan.contested.iter().any(|c| c.issue == *dep) {
1081 return Some(format!(
1082 "held back: the reviewers disagree about #{dep}, which it depends on"
1083 ));
1084 }
1085 }
1086 None
1087}
1088
1089struct Wave {
1091 numbers: Vec<i64>,
1092 absorbed: u32,
1097 parent: Option<i64>,
1099}
1100
1101impl Wave {
1102 fn first(numbers: Vec<i64>) -> Self {
1103 Self {
1104 numbers,
1105 absorbed: 0,
1106 parent: None,
1107 }
1108 }
1109
1110 fn child(&self, tracker: i64, numbers: Vec<i64>) -> Self {
1111 Self {
1112 numbers,
1113 absorbed: self.absorbed,
1114 parent: Some(tracker),
1115 }
1116 }
1117
1118 fn absorb(&self, numbers: Vec<i64>) -> Self {
1119 Self {
1120 numbers,
1121 absorbed: self.absorbed + 1,
1122 parent: None,
1123 }
1124 }
1125}
1126
1127fn numbers(items: &[i64]) -> String {
1128 items
1129 .iter()
1130 .map(|n| format!("#{n}"))
1131 .collect::<Vec<_>>()
1132 .join(", ")
1133}
1134
1135fn prepare(common: &Common, overrides: Option<Overrides>) -> Result<(Config, Repo, Vec<Agent>)> {
1136 let mut cfg = config::load(common.config.as_deref())?;
1137
1138 if let Some(first) = &common.first {
1139 if !cfg.has_agent(first) {
1140 bail!("--first must be one of: {}", cfg.agent_names().join(", "));
1141 }
1142 cfg.first_implementor = first.clone();
1143 }
1144 if let Some(base) = &common.base {
1145 cfg.loop_cfg.base_branch = base.clone();
1146 }
1147 if let Some(min) = common.min_number {
1148 cfg.loop_cfg.min_number = min;
1149 }
1150 if let Some(extra) = common.instructions.as_deref().map(str::trim) {
1154 if !extra.is_empty() {
1155 let standing = cfg.loop_cfg.instructions.trim();
1156 cfg.loop_cfg.instructions = if standing.is_empty() {
1157 extra.to_string()
1158 } else {
1159 format!("{standing}\n{extra}")
1160 };
1161 }
1162 }
1163 if let Some(over) = overrides {
1164 if let Some(v) = over.max_rounds {
1165 if v == 0 {
1166 bail!("--max-rounds must be at least 1");
1167 }
1168 cfg.loop_cfg.max_rounds = v;
1169 }
1170 if let Some(v) = over.auto_merge {
1171 cfg.loop_cfg.auto_merge = v;
1172 }
1173 if let Some(v) = over.keep_worktrees {
1174 cfg.loop_cfg.keep_worktrees = v;
1175 }
1176 if let Some(v) = over.worktrees {
1177 cfg.loop_cfg.worktrees = v;
1178 }
1179 if let Some(v) = over.close_skipped {
1180 cfg.loop_cfg.close_skipped = v;
1181 }
1182 if let Some(v) = over.absorb {
1183 cfg.loop_cfg.absorb_new_issues = v;
1184 }
1185 }
1186
1187 let repo = Repo::open(&common.repo, &cfg)?;
1188 if common.base.is_none() {
1189 cfg.loop_cfg.base_branch = repo.default_branch(cfg.base_branch());
1190 }
1191
1192 let agents = agent::build(&cfg)?;
1193 if let Some(warning) = agent::correlation_warning(&agents) {
1194 logging::warn(warning);
1195 }
1196
1197 for stale in repo.prune_worktrees(false) {
1199 let what = if stale.starts_with("branch ") {
1200 stale
1201 } else {
1202 format!("worktree {stale}")
1203 };
1204 logdim!("cleaned up finished {what}");
1205 }
1206
1207 log!("repo {} base {}", repo.root().display(), cfg.base_branch());
1208 log!(
1209 "agents: {}, first {}",
1210 agents
1211 .iter()
1212 .map(|a| format!("{}={}", a.name(), a.spec.describe()))
1213 .collect::<Vec<_>>()
1214 .join(", "),
1215 cfg.first_implementor_policy()
1216 );
1217 Ok((cfg, repo, agents))
1218}
1219
1220fn pick_issues(repo: &Repo, given: Vec<i64>, limit: usize, min_number: i64) -> Result<Vec<i64>> {
1221 if !given.is_empty() {
1222 if min_number > 0 {
1224 let below: Vec<String> = given
1225 .iter()
1226 .filter(|n| **n < min_number)
1227 .map(|n| format!("#{n}"))
1228 .collect();
1229 if !below.is_empty() {
1230 logdim!(
1231 "{} below the #{min_number} floor, taking them because you named them",
1232 below.join(", ")
1233 );
1234 }
1235 }
1236 return Ok(given);
1237 }
1238 let found = repo.list_open_issues(limit, min_number)?;
1239 if found.is_empty() {
1240 log!("no open issues");
1241 return Ok(found);
1242 }
1243 log!(
1244 "no issues given, taking {} open: {}",
1245 found.len(),
1246 found
1247 .iter()
1248 .map(|n| format!("#{n}"))
1249 .collect::<Vec<_>>()
1250 .join(", ")
1251 );
1252 Ok(found)
1253}
1254
1255fn pick_for_split(
1268 agents: &[Agent],
1269 cfg: &Config,
1270 repo: &Repo,
1271 limit: usize,
1272 mode: &split::Mode,
1273) -> Result<Vec<(i64, ItemKind)>> {
1274 let issues = repo.list_open_issues(limit, cfg.loop_cfg.min_number)?;
1275 let prs = repo.list_open_prs(limit, cfg.loop_cfg.min_number)?;
1276 log!(
1277 "no numbers given, considering {} open issue(s) and {} open pull request(s)",
1278 issues.len(),
1279 prs.len()
1280 );
1281 if issues.is_empty() && prs.is_empty() {
1282 return Ok(Vec::new());
1283 }
1284
1285 let issue_rows = repo.open_issue_rows();
1286 let pr_rows = repo.open_pr_rows();
1287 let mut candidates: Vec<split::Candidate> = Vec::new();
1288 let mut already = 0usize;
1289
1290 for number in &issues {
1291 let Some(row) = issue_rows.iter().find(|i| i.number == *number) else {
1292 logdim!("could not read #{number}, leaving it alone");
1293 continue;
1294 };
1295 if split::already_split(row.body_text()) && !mode.again {
1298 already += 1;
1299 continue;
1300 }
1301 candidates.push(split::Candidate::from_issue(row));
1302 }
1303 for number in &prs {
1304 match pr_rows.iter().find(|p| p.number == *number) {
1305 Some(row) => candidates.push(split::Candidate::from_pr(row)),
1306 None => logdim!("could not read PR #{number}, leaving it alone"),
1307 }
1308 }
1309 if already > 0 {
1310 log!("{already} issue(s) already split, skipped. --again reopens them.");
1311 }
1312 if candidates.is_empty() {
1313 return Ok(Vec::new());
1314 }
1315
1316 let agent = agent::find(agents, &cfg.first_implementor)?;
1317 log!(
1318 "screening {} item(s) with {}",
1319 candidates.len(),
1320 agent.name()
1321 );
1322 let verdicts = split::screen(agent, cfg, repo, &candidates)?;
1323
1324 let mut picked = Vec::new();
1325 for candidate in &candidates {
1326 match verdicts.iter().find(|v| v.item == candidate.number) {
1327 Some(v) if v.split => {
1328 log!(" split #{}: {}", candidate.number, v.reason.trim());
1329 picked.push((candidate.number, candidate.kind));
1330 }
1331 Some(v) => logdim!(" leave #{} whole: {}", candidate.number, v.reason.trim()),
1334 None => logdim!(" no verdict for #{}, leaving it whole", candidate.number),
1335 }
1336 }
1337 if picked.is_empty() {
1338 log!("nothing worth splitting");
1339 }
1340 Ok(picked)
1341}
1342
1343#[derive(Debug, Default)]
1349struct Sorted {
1350 issues: Vec<i64>,
1351 prs: Vec<i64>,
1352}
1353
1354fn classify(repo: &Repo, numbers: &[i64]) -> Result<Sorted> {
1355 let mut sorted = Sorted::default();
1356 for number in numbers {
1357 match repo.item_kind(*number)? {
1358 ItemKind::Issue => sorted.issues.push(*number),
1359 ItemKind::Pr => sorted.prs.push(*number),
1360 }
1361 }
1362 if !sorted.issues.is_empty() && !sorted.prs.is_empty() {
1363 log!(
1364 "{} issue(s) and {} pull request(s) given",
1365 sorted.issues.len(),
1366 sorted.prs.len()
1367 );
1368 }
1369 Ok(sorted)
1370}
1371
1372fn make_plan(
1373 agents: &[Agent],
1374 cfg: &Config,
1375 repo: &Repo,
1376 issues: &[Issue],
1377 plan_out: &Path,
1378) -> Result<Plan> {
1379 let plan = triage::triage(agents, cfg, repo, issues)?;
1380
1381 std::fs::write(plan_out, serde_json::to_vec_pretty(&plan)?)
1382 .map_err(|e| spar_err!("could not write {}: {e}", plan_out.display()))?;
1383 log!("plan written to {}", plan_out.display());
1384
1385 for item in &plan.order {
1386 let after = if item.depends_on.is_empty() {
1389 String::new()
1390 } else {
1391 format!(
1392 " after {}",
1393 item.depends_on
1394 .iter()
1395 .map(|n| format!("#{n}"))
1396 .collect::<Vec<_>>()
1397 .join(", ")
1398 )
1399 };
1400 log!(
1401 " do #{} [{}/{}]{after} {}",
1402 item.issue,
1403 item.complexity,
1404 item.risk,
1405 item.title
1406 );
1407 }
1408 for item in &plan.skipped {
1409 if item.tracker {
1410 log!(
1411 " hold #{} (both reviewers: tracks work filed elsewhere)",
1412 item.issue
1413 );
1414 } else {
1415 log!(" skip #{} (both reviewers: not worth doing)", item.issue);
1416 }
1417 }
1418 for item in &plan.contested {
1419 log!(" ?? #{} contested, parked for you to decide", item.issue);
1420 }
1421 Ok(plan)
1422}
1423
1424fn act_on_plan(cfg: &Config, repo: &Repo, plan: &Plan) {
1434 for item in &plan.skipped {
1435 let body = review::skip_comment(item, &repo.style);
1436 let close = cfg.loop_cfg.close_skipped && !item.tracker;
1437 let outcome = if close {
1438 repo.close_issue(item.issue, &body)
1439 } else {
1440 repo.comment_issue(item.issue, &body)
1441 };
1442 match outcome {
1443 Ok(()) if close => log!(" closed #{}", item.issue),
1444 Ok(()) if item.tracker => {
1445 log!(
1446 " left #{} open, it tracks work filed elsewhere",
1447 item.issue
1448 )
1449 }
1450 Ok(()) => {}
1451 Err(e) => logdim!("could not update #{}: {e}", item.issue),
1452 }
1453 }
1454}
1455
1456fn cmd_scrub_filter() -> Result<i32> {
1461 let mut input = String::new();
1462 std::io::stdin()
1463 .read_to_string(&mut input)
1464 .map_err(|e| spar_err!("could not read a commit message from stdin: {e}"))?;
1465 let out = style::scrub(&input, &crate::repo::style_from_env());
1466 let mut stdout = std::io::stdout();
1467 stdout
1468 .write_all(out.as_bytes())
1469 .and_then(|_| stdout.write_all(b"\n"))
1470 .map_err(|e| spar_err!("could not write the scrubbed message: {e}"))?;
1471 Ok(0)
1472}
1473
1474fn cmd_clean(
1475 repo_path: &Path,
1476 config_path: Option<&Path>,
1477 all: bool,
1478 pr_state: bool,
1479) -> Result<i32> {
1480 let cfg = config::load(config_path)?;
1481 let repo = Repo::open(repo_path, &cfg)?;
1482 let mut removed = repo.prune_worktrees(all);
1483 removed.extend(repo.prune_state());
1484 if pr_state {
1485 removed.extend(repo.prune_pr_state(None));
1486 }
1487 if removed.is_empty() {
1488 if repo.write_summary().failed > 0 {
1489 println!("nothing removed");
1490 } else {
1491 println!("nothing to clean");
1492 }
1493 } else {
1494 for item in removed {
1495 println!("removed {item}");
1496 }
1497 }
1498 Ok(report_writes(&repo))
1499}
1500
1501fn cmd_post(
1503 prs: &[i64],
1504 repo_path: &Path,
1505 config_path: Option<&Path>,
1506 file: Option<&Path>,
1507 dry_run: bool,
1508) -> Result<i32> {
1509 let cfg = config::load(config_path)?;
1510 let repo = Repo::open(repo_path, &cfg)?;
1511
1512 if file.is_some() && prs.len() > 1 {
1513 bail!("--file posts one review, so give it one pull request number");
1514 }
1515
1516 let mut failed = false;
1517 for number in prs {
1518 let text = match file {
1519 Some(path) => std::fs::read_to_string(path)
1520 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?,
1521 None => match repo.read_pending_comment(*number) {
1522 Some(text) => text,
1523 None => {
1524 logging::error(format!(
1525 "no saved review for PR #{number}. `spar review {number} --dry-run` \
1526 produces one, or pass --file."
1527 ));
1528 failed = true;
1529 continue;
1530 }
1531 },
1532 };
1533 if text.trim().is_empty() {
1534 logging::error(format!("the saved review for PR #{number} is empty"));
1535 failed = true;
1536 continue;
1537 }
1538 if dry_run {
1539 println!("\n{}\n", text.trim());
1540 log!("would post the above to PR #{number}");
1541 continue;
1542 }
1543 match repo.comment_pr(*number, &text) {
1546 Ok(()) => log!("posted to PR #{number}"),
1547 Err(e) => {
1548 logging::error(format!("could not post to PR #{number}: {e}"));
1549 failed = true;
1550 }
1551 }
1552 }
1553 Ok(i32::from(failed).max(report_writes(&repo)))
1554}
1555
1556fn cmd_init_update(out: &Path) -> Result<i32> {
1562 let text = std::fs::read_to_string(out)
1563 .map_err(|e| spar_err!("could not read {}: {e}", out.display()))?;
1564 config::parse(&text).map_err(|e| spar_err!("{} does not parse: {e}", out.display()))?;
1567
1568 let unset = config::unmentioned_options(&text);
1569 if unset.is_empty() {
1570 println!("{} already mentions every setting.", out.display());
1571 return Ok(0);
1572 }
1573
1574 let mut block = String::new();
1575 if !text.ends_with('\n') {
1576 block.push('\n');
1577 }
1578 block.push_str("\n# Added by `spar init --update`: settings this file did not mention,\n");
1579 block.push_str("# shown at their defaults. Uncomment one to change it.\n");
1580 let mut section = "";
1581 for option in &unset {
1582 if option.section != section {
1583 section = option.section;
1584 block.push_str(&format!("\n# [{section}]\n"));
1585 }
1586 block.push('\n');
1591 block.push_str(&wrap_comment(note_for(&option.key)));
1592 block.push_str(&format!("# {} = {}\n", option.key, option.default));
1593 }
1594
1595 use std::io::Write;
1596 std::fs::OpenOptions::new()
1597 .append(true)
1598 .open(out)
1599 .and_then(|mut f| f.write_all(block.as_bytes()))
1600 .map_err(|e| spar_err!("could not append to {}: {e}", out.display()))?;
1601
1602 println!(
1603 "added {} setting(s) to {} as comments",
1604 unset.len(),
1605 out.display()
1606 );
1607 Ok(0)
1608}
1609
1610fn cmd_init(out: &Path, force: bool) -> Result<i32> {
1611 if out.exists() && !force {
1612 logging::error(format!(
1613 "{} already exists. `--update` appends any settings it does not mention, \
1614 `--force` overwrites it.",
1615 out.display()
1616 ));
1617 return Ok(1);
1618 }
1619
1620 let presets = config::available_presets();
1621 if presets.is_empty() {
1622 bail!("no presets available, which should be impossible in a released build");
1623 }
1624
1625 let mut found: Vec<(String, PathBuf, config::AgentSpec)> = Vec::new();
1626 for name in &presets {
1627 let raw = config::load_preset(name)?;
1628 let mut spec: config::AgentSpec = match raw
1632 .as_table()
1633 .cloned()
1634 .ok_or_else(|| spar_err!("not a table"))
1635 .and_then(|t| {
1636 toml::Value::Table(t)
1637 .try_into()
1638 .map_err(|e| spar_err!("{e}"))
1639 }) {
1640 Ok(spec) => spec,
1641 Err(e) => {
1642 println!(" BROKEN {name:10} {}", e.first_line());
1643 continue;
1644 }
1645 };
1646 spec.name = name.clone();
1647 match Agent::new(spec.clone()).resolve_bin() {
1648 Ok(path) => {
1649 println!(" found {name:10} {}", path.display());
1650 found.push((name.clone(), path.to_path_buf(), spec));
1651 }
1652 Err(_) => println!(" missing {name}"),
1653 }
1654 }
1655
1656 if found.len() < 2 {
1657 logging::error(format!(
1658 "need two agent CLIs, found {}. Install another, or write {} by hand using the \
1659 presets as a reference.",
1660 found.len(),
1661 out.display()
1662 ));
1663 return Ok(1);
1664 }
1665
1666 let chosen: Vec<&(String, PathBuf, config::AgentSpec)> = found.iter().take(2).collect();
1668 if found.len() > 2 {
1669 log!(
1670 "{} agents available, picking {} and {}. Edit {} to change.",
1671 found.len(),
1672 chosen[0].0,
1673 chosen[1].0,
1674 out.display()
1675 );
1676 }
1677
1678 let mut text = String::from(
1679 "# Generated by `spar init`. Each agent inherits a command template from a\n\
1680 # built in preset; anything set here overrides it.\n\
1681 #\n\
1682 # Commented lines are the other options, each with a working value.\n\
1683 # Uncomment one to change it.\n\n",
1684 );
1685 for (name, _, spec) in &chosen {
1686 text.push_str(&agent_block(name, spec));
1687 }
1688 text.push_str(&settings_block(&chosen[0].0));
1689
1690 std::fs::write(out, text).map_err(|e| spar_err!("could not write {}: {e}", out.display()))?;
1691 println!("\nwrote {}", out.display());
1692 println!("Next: `spar doctor` to check it, then `spar run` in a repo you have push access to.");
1693 Ok(0)
1694}
1695
1696fn report_fallback(agent: &Agent) {
1702 let Some(backup) = agent.fallback() else {
1703 return;
1704 };
1705 match backup.resolve_bin() {
1706 Ok(bin) => println!(
1707 " fallback {} ({})",
1708 bin.display(),
1709 backup.spec.describe()
1710 ),
1711 Err(_) => println!(
1712 " fallback {} not found, so it will not stand in. Set {} to its path.",
1713 backup.program(),
1714 backup.env_key()
1715 ),
1716 }
1717}
1718
1719type Setting = (bool, &'static str, &'static str);
1730
1731const LOOP_OPTIONS: &[Setting] = &[
1732 (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."),
1733 (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."),
1734 (false, "first_implementor", "Which agent takes the first pass. The other one reviews it."),
1735 (false, "worktrees", "Isolate each issue in its own git worktree. Set false to work in the main checkout."),
1736 (false, "close_skipped", "Close an issue both reviewers declined, after posting the shared reasoning. A tracking issue is left open whatever this says."),
1737 (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."),
1738 (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."),
1739 (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."),
1740 (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."),
1741 (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."),
1742 (true, "keep_worktrees", "Keep worktrees after a run, for inspection."),
1743 (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."),
1744 (true, "parallel_triage", "Ask both agents to triage at once. They only read during triage, so there is nothing to serialise."),
1745 (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."),
1746 (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."),
1747 (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."),
1748 (true, "file_nits", "File nits as follow-ups too. Off, because a filed nit is somebody else's notification."),
1749 (true, "base_branch", "Only a fallback. Whatever origin/HEAD points at wins when it resolves."),
1750 (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."),
1751 (true, "state_store", "Where resume state is kept. local uses .spar/state and keeps it off the pull request."),
1752 (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."),
1753 (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."),
1754 (true, "max_issue_chars", "Most of one issue body that reaches a prompt. Sized so nothing a person wrote is cut, and a cut is said out loud when it happens."),
1755 (true, "max_triage_chars", "Most every issue body together 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."),
1756 (true, "checkin_trust", "Whose comments `spar checkin` will act on. 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. anyone answers everyone, and still only changes code when both agents agree."),
1757 (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."),
1758 (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."),
1759];
1760
1761const STYLE_OPTIONS: &[Setting] = &[
1762 (false, "ban_em_dash", "Strip em-dashes and en-dashes from everything spar posts, then refuse to post text that still has one."),
1763 (false, "ban_ai_attribution", "Strip mentions of the tooling, and Co-Authored-By trailers, from everything spar posts."),
1764 (false, "terse", "Hold model prose to a length budget. false removes the valves entirely."),
1765 (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."),
1766 (true, "max_title_chars", "A finding, issue, or pull request title. Never ellipsised: a title ending in three dots reads as broken."),
1767 (true, "max_summary_chars", "A one line verdict, or a refutation's argument."),
1768 (true, "max_detail_chars", "A blocking finding's explanation, as it appears in the pull request thread."),
1769 (true, "max_body_chars", "A pull request body."),
1770 (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."),
1771];
1772
1773fn settings_block(first_implementor: &str) -> String {
1778 let defaults: std::collections::BTreeMap<String, String> = config::known_options()
1779 .into_iter()
1780 .map(|option| (option.key, option.default))
1781 .collect();
1782 let value = |key: &str| match key {
1785 "first_implementor" => format!("\"{first_implementor}\""),
1786 other => defaults.get(other).cloned().unwrap_or_default(),
1787 };
1788
1789 let mut out = String::from("[loop]\n");
1790 out.push_str(&option_lines(LOOP_OPTIONS, &value));
1791 out.push_str(concat!(
1792 "\n[loop.effort_schedule]\n",
1793 "# One key per kind of call, so a value means what its name says. Values\n",
1794 "# are whatever each agent's own CLI accepts, listed above, so these are\n",
1795 "# examples rather than defaults. Left out, a call falls back to\n",
1796 "# round_1 or rest, and then to the effort the agent's own block asked\n",
1797 "# for. Where the two CLIs do not share a vocabulary, put the schedule\n",
1798 "# under [agents.NAME.effort_schedule] instead, with the same keys.\n",
1799 "# triage = \"low\" # both agents, over the whole queue, per wave\n",
1800 "# implement = \"high\" # writing the first draft\n",
1801 "# review_1 = \"high\" # the deep first review\n",
1802 "# review_rest = \"low\" # later rounds, which see a small delta\n",
1803 "# respond = \"high\" # the author answering a review\n",
1804 "# close = \"low\" # the closing merge safety pass\n",
1805 "# screen = \"low\" # the follow-up screen, one call for the queue\n",
1806 "# checkin = \"high\" # judging comments other people left\n",
1807 "# split = \"high\" # proposing and checking a split\n",
1808 "# round_1 = \"high\" # read by any call above with no key set\n",
1809 "# rest = \"low\" # the same, for later rounds and the close\n\n",
1810 ));
1811 out.push_str("[style]\n");
1812 out.push_str(&option_lines(STYLE_OPTIONS, &value));
1813 out
1814}
1815
1816fn option_lines(options: &[Setting], value: &dyn Fn(&str) -> String) -> String {
1820 let mut out = String::new();
1821 for (commented, key, note) in options {
1822 if !out.is_empty() {
1823 out.push('\n');
1824 }
1825 out.push_str(&wrap_comment(note));
1826 let lead = if *commented { "# " } else { "" };
1827 out.push_str(&format!("{lead}{key} = {}\n", value(key)));
1828 }
1829 out
1830}
1831
1832type Probe = Box<dyn Fn() -> Result<String>>;
1835
1836fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
1843 let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
1844
1845 if let Some(extra) = &spec.options_note {
1857 if !spec.models.is_empty() || !spec.efforts.is_empty() {
1858 out.push('\n');
1859 out.push_str(&wrap_comment(extra));
1860 }
1861 }
1862 for (key, choices) in [("model", &spec.models), ("effort", &spec.efforts)] {
1863 let Some(suggested) = choices.first() else {
1864 continue;
1865 };
1866 let mut note = format!("Omit {key} to use the CLI's own default.");
1867 if choices.len() > 1 {
1868 note.push_str(&format!(" One of: {}.", choices.join(" | ")));
1869 }
1870 out.push('\n');
1871 out.push_str(&wrap_comment(¬e));
1872 out.push_str(&format!("# {key} = \"{suggested}\"\n"));
1873 }
1874
1875 out.push('\n');
1879 out.push_str(&wrap_comment(
1880 "Seconds one call may take before spar gives up. A timeout costs the whole call and is \
1881 never retried, so err long.",
1882 ));
1883 out.push_str(&format!("# timeout = {}\n", spec.timeout));
1884
1885 let backup = if name == "cursor" { "gemini" } else { "cursor" };
1888 out.push('\n');
1889 out.push_str(&wrap_comment(
1890 "A stand in for when this CLI refuses, stalls, or runs out of quota. It answers in place \
1891 of this agent, never alongside it.",
1892 ));
1893 out.push_str(&format!(
1894 "# [agents.{name}.fallback]\n# preset = \"{backup}\"\n"
1895 ));
1896
1897 out.push('\n');
1902 out.push_str(&wrap_comment(
1903 "command, output, search_paths and the rest are in spar.example.toml, for pairing a CLI \
1904 that has no preset.",
1905 ));
1906 out.push('\n');
1907 out
1908}
1909
1910fn note_for(key: &str) -> &'static str {
1916 LOOP_OPTIONS
1917 .iter()
1918 .chain(STYLE_OPTIONS)
1919 .find(|(_, name, _)| *name == key)
1920 .map(|(_, _, note)| *note)
1921 .unwrap_or("")
1922}
1923
1924fn wrap_comment(text: &str) -> String {
1926 const WIDTH: usize = 76;
1927 let mut out = String::new();
1928 let mut line = String::from("#");
1929 for word in text.split_whitespace() {
1930 if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
1931 out.push_str(&line);
1932 out.push('\n');
1933 line = String::from("#");
1934 }
1935 line.push(' ');
1936 line.push_str(word);
1937 }
1938 if line.len() > 1 {
1939 out.push_str(&line);
1940 out.push('\n');
1941 }
1942 out
1943}
1944
1945fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
1946 let mut ok = true;
1947
1948 let probes: Vec<(&str, Probe)> = vec![
1949 (
1950 "git",
1951 Box::new(|| {
1952 proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
1953 .map(|s| first_line(&s))
1954 }),
1955 ),
1956 (
1957 "gh",
1958 Box::new(|| {
1959 proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
1960 .map(|s| first_line(&s))
1961 }),
1962 ),
1963 (
1964 "gh auth",
1965 Box::new(|| {
1966 let out = proc::exec(
1967 &["gh".into(), "auth".into(), "status".into()],
1968 &ExecOpts::new().check(false).timeout_secs(60),
1969 )?;
1970 let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
1971 if out.ok() {
1972 Ok(first_line(&text))
1973 } else {
1974 Err(spar_err!("not authenticated. Run `gh auth login`."))
1975 }
1976 }),
1977 ),
1978 ];
1979
1980 for (label, probe) in probes {
1981 match probe() {
1982 Ok(detail) => println!(" ok {label:12} {detail}"),
1983 Err(e) => {
1984 println!(" FAIL {label:12} {}", e.first_line());
1985 ok = false;
1986 }
1987 }
1988 }
1989
1990 let found = config::find_config(config_path)?;
1991 let Some(path) = found else {
1992 println!("\n no spar.toml found. Run `spar init` to generate one.");
1993 println!(
1994 " presets available: {}",
1995 config::available_presets().join(", ")
1996 );
1997 return Ok(if ok { 0 } else { 1 });
1998 };
1999
2000 println!("\n config: {}", path.display());
2001 let cfg = match config::load(Some(&path)) {
2002 Ok(cfg) => cfg,
2003 Err(e) => {
2004 println!(" FAIL config {e}");
2005 return Ok(1);
2006 }
2007 };
2008
2009 let mut resolved = Vec::new();
2012 for spec in &cfg.agents {
2013 let agent = Agent::new(spec.clone());
2014 match agent.resolve_bin() {
2015 Ok(bin) => {
2016 println!(
2017 " ok {:12} {} ({})",
2018 spec.name,
2019 bin.display(),
2020 spec.describe()
2021 );
2022 report_fallback(&agent);
2023 resolved.push(agent);
2024 }
2025 Err(e) => {
2026 println!(" FAIL {:12} {}", spec.name, e.first_line());
2027 ok = false;
2028 }
2029 }
2030 }
2031
2032 if resolved.len() == cfg.agents.len() {
2033 if let Some(warning) = agent::correlation_warning(&resolved) {
2034 println!("\n WARNING {warning}");
2035 }
2036 }
2037
2038 for line in unknown_effort_values(&cfg) {
2039 println!("\n WARNING {line}");
2040 }
2041
2042 println!(
2043 "\n settings: first={} max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
2044 cfg.first_implementor_policy(),
2045 cfg.loop_cfg.max_rounds,
2046 cfg.loop_cfg.auto_merge,
2047 cfg.loop_cfg.worktrees,
2048 cfg.loop_cfg.followups,
2049 cfg.style.terse
2050 );
2051 if let Ok(text) = std::fs::read_to_string(&path) {
2055 let unset = config::unmentioned_options(&text);
2056 if !unset.is_empty() {
2057 println!(
2058 "\n {} setting(s) this config does not mention, all at their defaults:",
2059 unset.len()
2060 );
2061 for option in &unset {
2062 println!(
2063 " [{}] {} = {}",
2064 option.section, option.key, option.default
2065 );
2066 }
2067 println!(
2068 " `spar init --update {}` appends them as comments.",
2069 path.display()
2070 );
2071 }
2072 }
2073
2074 println!(
2075 "{}",
2076 if ok {
2077 "\nready"
2078 } else {
2079 "\nmissing prerequisites"
2080 }
2081 );
2082 Ok(if ok { 0 } else { 1 })
2083}
2084
2085fn unknown_effort_values(cfg: &config::Config) -> Vec<String> {
2094 let mut out = Vec::new();
2095 for spec in &cfg.agents {
2096 if spec.efforts.is_empty() {
2097 continue;
2098 }
2099 let mut said: Vec<String> = Vec::new();
2100 for call in config::Call::every() {
2101 let Some(value) = cfg.effort_for(spec, call) else {
2102 continue;
2103 };
2104 let known = spec
2105 .efforts
2106 .iter()
2107 .any(|hint| hint.eq_ignore_ascii_case(value.trim()));
2108 if known || said.contains(&value) {
2109 continue;
2110 }
2111 said.push(value.clone());
2112 out.push(format!(
2113 "{} is asked for effort \"{value}\" on the {} call, which is not one it is known \
2114 to accept ({}). Put this agent's values under \
2115 [agents.{}.effort_schedule].",
2116 spec.name,
2117 call.key(),
2118 spec.efforts.join(" | "),
2119 spec.name
2120 ));
2121 }
2122 }
2123 out
2124}
2125
2126fn first_line(text: &str) -> String {
2127 text.trim().lines().next().unwrap_or("").trim().to_string()
2128}
2129
2130fn report(results: &[IssueRun], cfg: &Config, repo: &Repo) -> i32 {
2135 report_with(results, cfg, repo, &[], &[])
2136}
2137
2138fn report_with(
2145 results: &[IssueRun],
2146 cfg: &Config,
2147 repo: &Repo,
2148 stopped: &[String],
2149 parked: &[String],
2150) -> i32 {
2151 let spent = spend::taken();
2154 if let Err(e) = repo.record_spend(&spent) {
2155 logdim!("could not record what this run spent: {e}");
2156 }
2157 println!("\n{}", "=".repeat(60));
2158 for r in results {
2159 println!(
2160 "#{:<5} {:<10} rounds={} {}",
2161 r.issue,
2162 r.status.to_string(),
2163 r.rounds,
2164 r.pr.as_deref().unwrap_or("")
2165 );
2166 for note in &r.notes {
2167 println!(" {}", first_line(note));
2168 }
2169 for url in &r.filed {
2170 println!(" filed {url}");
2171 }
2172 for dispute in &r.disputes {
2173 println!(
2174 " disputed: {}",
2175 report_item(&dispute.title, &dispute.file)
2176 );
2177 }
2178 for finding in &r.noted {
2179 println!(
2180 " noted, not blocking: {}",
2181 report_item(&finding.title, &finding.file)
2182 );
2183 }
2184 if let Some(cost) = spend::for_subject(&spent, r.issue) {
2185 println!(" {cost}");
2186 }
2187 }
2188 println!("{}", "=".repeat(60));
2189
2190 if cfg.alternate_first && results.len() > 1 {
2191 println!(
2192 "\nfirst implementor alternated, starting with {}. Who wrote each first draft is in \
2193 .spar/state/implementors.json.",
2194 cfg.first_implementor
2195 );
2196 }
2197 if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
2198 println!("\nApproved PRs are waiting on you to merge.");
2199 }
2200 let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
2201 if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
2202 println!(
2203 "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
2204 Set followups = \"issues\" to file them."
2205 );
2206 }
2207 if let Some(summary) = spend::summary(&spent) {
2208 println!("\n{summary}");
2209 }
2210 for lines in [parked, stopped] {
2211 if lines.is_empty() {
2212 continue;
2213 }
2214 println!();
2215 for line in lines {
2216 println!("{line}");
2217 }
2218 }
2219 let issue_exit = i32::from(!results.iter().all(IssueRun::succeeded) || !stopped.is_empty());
2220 issue_exit.max(report_writes(repo))
2221}
2222
2223fn report_writes(repo: &Repo) -> i32 {
2228 let writes = repo.write_summary();
2229 if let Some(line) = write_summary_line(writes) {
2230 println!("\n{line}");
2231 }
2232 write_exit_code(writes)
2233}
2234
2235fn write_summary_line(writes: WriteSummary) -> Option<String> {
2236 (writes.attempted > 0).then(|| {
2237 format!(
2238 "writes: {} attempted, {} succeeded, {} failed",
2239 writes.attempted,
2240 writes.succeeded(),
2241 writes.failed
2242 )
2243 })
2244}
2245
2246fn write_exit_code(writes: WriteSummary) -> i32 {
2247 i32::from(writes.failed > 0)
2248}
2249
2250fn report_item(title: &str, file: &str) -> String {
2251 match file.trim() {
2252 "" => title.to_string(),
2253 location => format!("{title} ({location})"),
2254 }
2255}
2256
2257#[cfg(test)]
2258mod tests {
2259 use super::*;
2260 use clap::CommandFactory;
2261
2262 fn planned(issue: i64, depends_on: Vec<i64>) -> PlanItem {
2263 PlanItem {
2264 issue,
2265 title: format!("issue {issue}"),
2266 complexity: crate::model::Complexity::S,
2267 risk: crate::model::Risk::Low,
2268 depends_on,
2269 reason: "worth doing".into(),
2270 }
2271 }
2272
2273 #[test]
2282 fn a_dependent_waits_for_a_dependency_that_has_not_landed() {
2283 let plan = Plan {
2284 order: vec![planned(9, vec![]), planned(10, vec![9])],
2285 ..Plan::default()
2286 };
2287 let mut landed = BTreeSet::new();
2288
2289 let why = held_back(&plan.order[1], &plan, &landed).expect("held");
2290 assert!(why.contains("#9"), "{why}");
2291 assert!(why.contains("has not merged"), "{why}");
2292 assert!(held_back(&plan.order[0], &plan, &landed).is_none());
2293
2294 landed.insert(9);
2296 assert!(held_back(&plan.order[1], &plan, &landed).is_none());
2297 }
2298
2299 #[test]
2302 fn a_dependent_on_a_declined_or_contested_issue_is_held_with_the_reason() {
2303 let declined = Plan {
2304 order: vec![planned(10, vec![9])],
2305 skipped: vec![crate::model::SkippedItem {
2306 issue: 9,
2307 title: "issue 9".into(),
2308 reasons: Default::default(),
2309 tracker: false,
2310 }],
2311 ..Plan::default()
2312 };
2313 let why = held_back(&declined.order[0], &declined, &BTreeSet::new()).expect("held");
2314 assert!(why.contains("declined by both reviewers"), "{why}");
2315
2316 let contested = Plan {
2317 order: vec![planned(10, vec![9])],
2318 contested: vec![crate::model::ContestedItem {
2319 issue: 9,
2320 title: "issue 9".into(),
2321 positions: Default::default(),
2322 reasons: Default::default(),
2323 note: None,
2324 }],
2325 ..Plan::default()
2326 };
2327 let why = held_back(&contested.order[0], &contested, &BTreeSet::new()).expect("held");
2328 assert!(why.contains("disagree about #9"), "{why}");
2329 }
2330
2331 #[test]
2335 fn a_dependency_outside_the_run_does_not_hold_anything() {
2336 let plan = Plan {
2337 order: vec![planned(10, vec![900])],
2338 ..Plan::default()
2339 };
2340 assert!(held_back(&plan.order[0], &plan, &BTreeSet::new()).is_none());
2341 }
2342
2343 #[test]
2349 fn doctor_says_when_a_scheduled_effort_is_not_one_the_agent_accepts() {
2350 let text = "\
2351[agents.claude]
2352preset = \"claude\"
2353
2354[agents.codex]
2355preset = \"codex\"
2356
2357[loop.effort_schedule]
2358review_1 = \"ultra\"
2359";
2360 let cfg = config::parse(text).expect("parses");
2361 let said = unknown_effort_values(&cfg);
2362 assert_eq!(1, said.len(), "{said:?}");
2363 assert!(said[0].starts_with("claude"), "{}", said[0]);
2364 assert!(said[0].contains("ultra"), "{}", said[0]);
2365 assert!(said[0].contains("review_1"), "{}", said[0]);
2366 assert!(
2367 said[0].contains("[agents.claude.effort_schedule]"),
2368 "it has to say where to put the right value: {}",
2369 said[0]
2370 );
2371
2372 let quiet = text.replace(
2374 "[loop.effort_schedule]\nreview_1 = \"ultra\"\n",
2375 "[agents.claude.effort_schedule]\nreview_1 = \"xhigh\"\n\n\
2376 [agents.codex.effort_schedule]\nreview_1 = \"ultra\"\n",
2377 );
2378 assert!(unknown_effort_values(&config::parse(&quiet).expect("parses")).is_empty());
2379 }
2380
2381 #[test]
2382 fn a_contested_issue_reads_as_both_positions_with_their_reasons() {
2383 let mut positions_map = std::collections::BTreeMap::new();
2384 positions_map.insert("claude".to_string(), "do".to_string());
2385 positions_map.insert("codex".to_string(), "skip".to_string());
2386 let mut reasons = std::collections::BTreeMap::new();
2387 reasons.insert(
2388 "claude".to_string(),
2389 "the retry path is still wrong".to_string(),
2390 );
2391 reasons.insert(
2392 "codex".to_string(),
2393 "fixed in the rewrite last month".to_string(),
2394 );
2395
2396 let line = positions(&positions_map, &reasons);
2397 assert!(line.contains("claude says do"), "{line}");
2398 assert!(line.contains("codex says skip"), "{line}");
2399 assert!(line.contains("the retry path is still wrong"), "{line}");
2400 assert!(line.contains("fixed in the rewrite"), "{line}");
2401
2402 let bare = positions(&positions_map, &Default::default());
2404 assert!(bare.contains("claude says do"), "{bare}");
2405 }
2406
2407 #[test]
2408 fn report_items_include_their_location() {
2409 assert_eq!(
2410 "Same title (src/a.rs:10)",
2411 report_item("Same title", "src/a.rs:10")
2412 );
2413 assert_eq!("General point", report_item("General point", ""));
2414 }
2415
2416 #[test]
2417 fn every_attempted_write_failing_is_a_failed_run() {
2418 let writes = WriteSummary {
2419 attempted: 3,
2420 failed: 3,
2421 };
2422
2423 assert_eq!(1, write_exit_code(writes));
2424 assert_eq!(
2425 Some("writes: 3 attempted, 0 succeeded, 3 failed".to_string()),
2426 write_summary_line(writes)
2427 );
2428 }
2429
2430 #[test]
2431 fn a_partial_write_failure_is_reported_after_the_run() {
2432 let writes = WriteSummary {
2433 attempted: 3,
2434 failed: 1,
2435 };
2436
2437 assert_eq!(1, write_exit_code(writes));
2438 assert_eq!(2, writes.succeeded());
2439 }
2440
2441 #[test]
2442 fn the_parser_is_internally_consistent() {
2443 Cli::command().debug_assert();
2444 }
2445
2446 #[test]
2447 fn quiet_is_accepted_before_or_after_the_subcommand() {
2448 for argv in [
2449 vec!["spar", "--quiet", "run", "42"],
2450 vec!["spar", "run", "42", "--quiet"],
2451 vec!["spar", "resume", "--quiet"],
2452 vec!["spar", "init", "-q"],
2453 ] {
2454 assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
2455 }
2456 assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
2457 }
2458
2459 #[test]
2460 fn several_issue_numbers_are_accepted() {
2461 let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
2462 match cli.command {
2463 Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
2464 other => panic!("{other:?}"),
2465 }
2466 }
2467
2468 #[test]
2469 fn issue_numbers_and_flags_can_be_interleaved() {
2470 let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
2471 match cli.command {
2472 Command::Run {
2473 issues, loop_flags, ..
2474 } => {
2475 assert_eq!(vec![42, 51], issues);
2476 assert!(loop_flags.auto_merge);
2477 }
2478 other => panic!("{other:?}"),
2479 }
2480 }
2481
2482 #[test]
2483 fn every_command_that_reads_a_config_accepts_one() {
2484 for argv in [
2485 vec!["spar", "run", "42"],
2486 vec!["spar", "triage"],
2487 vec!["spar", "resume"],
2488 vec!["spar", "followup"],
2489 vec!["spar", "checkin"],
2490 vec!["spar", "split"],
2491 vec!["spar", "clean"],
2492 vec!["spar", "doctor"],
2493 ] {
2494 let mut full = argv.clone();
2495 full.extend(["--config", "other.toml"]);
2496 let cli = Cli::parse_from(&full);
2497 let config = match cli.command {
2498 Command::Run { common, .. }
2499 | Command::Triage { common, .. }
2500 | Command::Resume { common, .. }
2501 | Command::Followup { common, .. }
2502 | Command::Split { common, .. }
2503 | Command::Checkin { common, .. } => common.config,
2504 Command::Clean { config, .. } | Command::Doctor { config } => config,
2505 other => panic!("{other:?}"),
2506 };
2507 assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
2508 }
2509 }
2510
2511 #[test]
2512 fn auto_merge_is_off_unless_asked_for() {
2513 let cli = Cli::parse_from(["spar", "run"]);
2514 match cli.command {
2515 Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
2516 other => panic!("{other:?}"),
2517 }
2518 }
2519
2520 #[test]
2523 fn every_command_that_reads_a_config_takes_instructions() {
2524 for argv in [
2527 vec!["spar", "run", "7", "--instructions", "Do not wait for CI."],
2528 vec![
2529 "spar",
2530 "triage",
2531 "7",
2532 "--instructions",
2533 "Do not wait for CI.",
2534 ],
2535 vec![
2536 "spar",
2537 "resume",
2538 "7",
2539 "--instructions",
2540 "Do not wait for CI.",
2541 ],
2542 vec![
2543 "spar",
2544 "review",
2545 "7",
2546 "--instructions",
2547 "Do not wait for CI.",
2548 ],
2549 vec!["spar", "followup", "--instructions", "Do not wait for CI."],
2550 vec![
2551 "spar",
2552 "checkin",
2553 "7",
2554 "--instructions",
2555 "Do not wait for CI.",
2556 ],
2557 vec![
2558 "spar",
2559 "split",
2560 "7",
2561 "--instructions",
2562 "Do not wait for CI.",
2563 ],
2564 ] {
2565 let parsed = Cli::parse_from(&argv);
2566 let common = match parsed.command {
2567 Command::Run { common, .. }
2568 | Command::Triage { common, .. }
2569 | Command::Resume { common, .. }
2570 | Command::Review { common, .. }
2571 | Command::Followup { common, .. }
2572 | Command::Split { common, .. }
2573 | Command::Checkin { common, .. } => common,
2574 other => panic!("{other:?}"),
2575 };
2576 assert_eq!(
2577 Some("Do not wait for CI."),
2578 common.instructions.as_deref(),
2579 "{argv:?}"
2580 );
2581 }
2582 }
2583
2584 #[test]
2585 fn the_two_close_skipped_flags_are_mutually_exclusive() {
2586 assert!(
2587 Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
2588 );
2589 }
2590
2591 #[test]
2594 fn close_skipped_is_offered_only_where_it_means_something() {
2595 assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
2596 assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
2597 assert!(Cli::try_parse_from(["spar", "followup", "--close-skipped"]).is_ok());
2599 assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
2600 assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
2601 assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
2602 }
2603
2604 #[test]
2607 fn followup_takes_no_numbers() {
2608 assert!(Cli::try_parse_from(["spar", "followup"]).is_ok());
2609 assert!(Cli::try_parse_from(["spar", "followup", "42"]).is_err());
2610 }
2611
2612 #[test]
2615 fn the_two_stopping_points_are_mutually_exclusive() {
2616 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only"]).is_ok());
2617 assert!(Cli::try_parse_from(["spar", "followup", "--file-only"]).is_ok());
2618 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only", "--file-only"]).is_err());
2619 }
2620
2621 #[test]
2622 fn the_close_skipped_pair_resolves_to_a_tristate() {
2623 let read = |argv: &[&str]| match Cli::parse_from(argv).command {
2624 Command::Run { triage_flags, .. } => {
2625 match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
2626 (true, _) => Some(true),
2627 (_, true) => Some(false),
2628 _ => None,
2629 }
2630 }
2631 other => panic!("{other:?}"),
2632 };
2633 assert_eq!(None, read(&["spar", "run"]));
2634 assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
2635 assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
2636 }
2637
2638 #[test]
2639 fn the_default_limit_is_twenty() {
2640 let cli = Cli::parse_from(["spar", "run"]);
2641 match cli.command {
2642 Command::Run { common, .. } => assert_eq!(20, common.limit),
2643 other => panic!("{other:?}"),
2644 }
2645 }
2646
2647 #[test]
2648 fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
2649 assert!(matches!(
2650 Cli::parse_from(["spar", "scrub-filter"]).command,
2651 Command::ScrubFilter
2652 ));
2653 let help = Cli::command().render_long_help().to_string();
2654 assert!(
2655 !help.contains("scrub-filter"),
2656 "it is plumbing, not a command"
2657 );
2658 }
2659
2660 #[test]
2661 fn review_takes_pr_numbers_and_a_dry_run() {
2662 let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
2663 match cli.command {
2664 Command::Review { items, dry_run, .. } => {
2665 assert_eq!(vec![101, 102], items);
2666 assert!(dry_run);
2667 }
2668 other => panic!("{other:?}"),
2669 }
2670 }
2671
2672 #[test]
2673 fn review_posts_unless_told_not_to() {
2674 match Cli::parse_from(["spar", "review", "101"]).command {
2675 Command::Review { dry_run, .. } => assert!(!dry_run),
2676 other => panic!("{other:?}"),
2677 }
2678 }
2679
2680 #[test]
2681 fn review_with_no_numbers_is_allowed() {
2682 match Cli::parse_from(["spar", "review"]).command {
2683 Command::Review { items, .. } => assert!(items.is_empty()),
2684 other => panic!("{other:?}"),
2685 }
2686 }
2687
2688 #[test]
2689 fn review_takes_its_own_round_budget() {
2690 match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
2691 Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
2692 other => panic!("{other:?}"),
2693 }
2694 }
2695
2696 #[test]
2697 fn checkin_takes_pr_numbers_and_a_dry_run() {
2698 match Cli::parse_from(["spar", "checkin", "108", "112", "--dry-run"]).command {
2699 Command::Checkin { items, dry_run, .. } => {
2700 assert_eq!(vec![108, 112], items);
2701 assert!(dry_run);
2702 }
2703 other => panic!("{other:?}"),
2704 }
2705 match Cli::parse_from(["spar", "checkin"]).command {
2706 Command::Checkin { items, dry_run, .. } => {
2707 assert!(items.is_empty());
2708 assert!(!dry_run);
2709 }
2710 other => panic!("{other:?}"),
2711 }
2712 }
2713
2714 #[test]
2720 fn checkin_offers_no_flag_that_would_weaken_the_pair() {
2721 assert!(Cli::try_parse_from(["spar", "checkin", "--auto-merge"]).is_err());
2722 assert!(Cli::try_parse_from(["spar", "checkin", "--max-rounds", "1"]).is_err());
2723 assert!(Cli::try_parse_from(["spar", "checkin", "--absorb", "1"]).is_err());
2724 assert!(Cli::try_parse_from(["spar", "checkin", "--close-skipped"]).is_err());
2725 assert!(Cli::try_parse_from(["spar", "checkin", "--reply-only"]).is_ok());
2727 assert!(Cli::try_parse_from(["spar", "checkin", "--any-author"]).is_ok());
2728 assert!(Cli::try_parse_from(["spar", "checkin", "--again"]).is_ok());
2729 assert!(Cli::try_parse_from(["spar", "checkin", "--keep-worktrees"]).is_ok());
2730 }
2731
2732 #[test]
2733 fn resume_takes_a_next_override() {
2734 let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
2735 match cli.command {
2736 Command::Resume {
2737 prs, next_actor, ..
2738 } => {
2739 assert_eq!(vec![108], prs);
2740 assert_eq!(Some("codex".to_string()), next_actor);
2741 }
2742 other => panic!("{other:?}"),
2743 }
2744 }
2745}
2746
2747#[cfg(test)]
2748mod absorb_tests {
2749 use super::*;
2750
2751 #[test]
2752 fn absorb_is_off_unless_asked_for() {
2753 match Cli::parse_from(["spar", "run"]).command {
2754 Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
2755 other => panic!("{other:?}"),
2756 }
2757 }
2758
2759 #[test]
2760 fn absorb_takes_a_wave_count() {
2761 match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
2762 Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
2763 other => panic!("{other:?}"),
2764 }
2765 }
2766
2767 #[test]
2768 fn absorb_is_only_offered_where_issues_are_worked() {
2769 assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
2770 assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
2771 assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
2772 assert!(Cli::try_parse_from(["spar", "split", "--absorb", "1"]).is_err());
2775 }
2776}
2777
2778#[cfg(test)]
2779mod split_tests {
2780 use super::*;
2781
2782 #[test]
2783 fn split_takes_numbers_of_either_kind() {
2784 match Cli::parse_from(["spar", "split", "10", "12"]).command {
2785 Command::Split { items, .. } => assert_eq!(vec![10, 12], items),
2786 other => panic!("{other:?}"),
2787 }
2788 }
2789
2790 #[test]
2793 fn split_with_no_numbers_is_allowed() {
2794 match Cli::parse_from(["spar", "split"]).command {
2795 Command::Split {
2796 items,
2797 dry_run,
2798 again,
2799 ..
2800 } => {
2801 assert!(items.is_empty());
2802 assert!(!dry_run);
2803 assert!(!again);
2804 }
2805 other => panic!("{other:?}"),
2806 }
2807 }
2808
2809 #[test]
2813 fn split_can_be_told_to_write_nothing() {
2814 match Cli::parse_from(["spar", "split", "10", "--dry-run"]).command {
2815 Command::Split { dry_run, .. } => assert!(dry_run),
2816 other => panic!("{other:?}"),
2817 }
2818 }
2819
2820 #[test]
2823 fn split_can_be_told_to_split_something_it_already_split() {
2824 match Cli::parse_from(["spar", "split", "10", "--again"]).command {
2825 Command::Split { again, .. } => assert!(again),
2826 other => panic!("{other:?}"),
2827 }
2828 }
2829
2830 #[test]
2833 fn split_takes_the_flags_that_govern_picking() {
2834 match Cli::parse_from([
2835 "spar",
2836 "split",
2837 "--limit",
2838 "5",
2839 "--min-number",
2840 "480",
2841 "--config",
2842 "other.toml",
2843 ])
2844 .command
2845 {
2846 Command::Split { common, .. } => {
2847 assert_eq!(5, common.limit);
2848 assert_eq!(Some(480), common.min_number);
2849 assert_eq!(Some(PathBuf::from("other.toml")), common.config);
2850 }
2851 other => panic!("{other:?}"),
2852 }
2853 }
2854
2855 #[test]
2858 fn split_offers_no_flag_that_would_make_it_a_second_run() {
2859 for flag in [
2860 vec!["--auto-merge"],
2861 vec!["--max-rounds", "2"],
2862 vec!["--close-skipped"],
2863 vec!["--no-close-skipped"],
2864 vec!["--keep-worktrees"],
2865 ] {
2866 let mut argv = vec!["spar", "split", "10"];
2867 argv.extend(flag.iter().copied());
2868 assert!(Cli::try_parse_from(&argv).is_err(), "{argv:?}");
2869 }
2870 }
2871}
2872
2873#[cfg(test)]
2874mod min_number_tests {
2875 use super::*;
2876
2877 fn read(argv: &[&str]) -> Option<i64> {
2878 match Cli::parse_from(argv).command {
2879 Command::Run { common, .. }
2880 | Command::Triage { common, .. }
2881 | Command::Resume { common, .. }
2882 | Command::Review { common, .. }
2883 | Command::Followup { common, .. }
2884 | Command::Split { common, .. }
2885 | Command::Checkin { common, .. } => common.min_number,
2886 other => panic!("{other:?}"),
2887 }
2888 }
2889
2890 #[test]
2891 fn there_is_no_floor_unless_one_is_asked_for() {
2892 assert_eq!(None, read(&["spar", "run"]));
2893 }
2894
2895 #[test]
2896 fn every_command_that_picks_for_itself_accepts_a_floor() {
2897 for cmd in ["run", "triage", "resume", "review", "checkin", "split"] {
2898 assert_eq!(
2899 Some(480),
2900 read(&["spar", cmd, "--min-number", "480"]),
2901 "{cmd}"
2902 );
2903 }
2904 }
2905}
2906
2907#[cfg(test)]
2908mod settings_block_tests {
2909 use super::*;
2910
2911 fn written(line: &str) -> String {
2914 let after = line.split_once('=').expect("an assignment").1;
2915 let mut quoted = false;
2916 for (i, c) in after.char_indices() {
2917 match c {
2918 '"' => quoted = !quoted,
2919 '#' if !quoted => return after[..i].trim().to_string(),
2920 _ => {}
2921 }
2922 }
2923 after.trim().to_string()
2924 }
2925
2926 fn line_for(text: &str, key: &str) -> String {
2927 text.lines()
2928 .find(|l| {
2929 let bare = l.trim_start().trim_start_matches('#').trim_start();
2930 bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
2931 })
2932 .unwrap_or_else(|| panic!("{key} is not offered at all:\n{text}"))
2933 .to_string()
2934 }
2935
2936 #[test]
2943 fn every_value_it_offers_is_the_default_it_actually_has() {
2944 let text = settings_block("claude");
2945 for option in config::known_options() {
2946 if option.section == "loop.effort_schedule" {
2950 continue;
2951 }
2952 let line = line_for(&text, &option.key);
2953 assert_eq!(
2954 option.default,
2955 written(&line),
2956 "the generated config offers `{}`, but the default is {}",
2957 line.trim(),
2958 option.default
2959 );
2960 }
2961 }
2962
2963 #[test]
2967 fn it_offers_every_option_the_parser_knows_about() {
2968 let text = settings_block("claude");
2969 let missing: Vec<String> = config::unmentioned_options(&text)
2970 .into_iter()
2971 .map(|o| format!("[{}] {}", o.section, o.key))
2972 .collect();
2973 assert!(missing.is_empty(), "not offered: {}", missing.join(", "));
2974 }
2975
2976 #[test]
2980 fn every_option_it_offers_can_be_uncommented_and_still_load() {
2981 let mut text = String::from(
2982 "[agents.claude]\ncommand = [\"claude\"]\n\n\
2983 [agents.codex]\ncommand = [\"codex\"]\n\n",
2984 );
2985 for line in settings_block("claude").lines() {
2986 text.push_str(uncomment(line).unwrap_or(line));
2987 text.push('\n');
2988 }
2989 let cfg = config::parse(&text).expect("a config of its own suggestions");
2990 assert_eq!("claude", cfg.first_implementor);
2991 }
2992
2993 fn uncomment(line: &str) -> Option<&str> {
2996 let bare = line.trim_start().strip_prefix('#')?.trim_start();
2997 let key = bare.split_once('=')?.0.trim();
3000 let named = !key.is_empty()
3001 && key
3002 .chars()
3003 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
3004 named.then_some(bare)
3005 }
3006
3007 #[test]
3008 fn the_agent_that_goes_first_is_the_one_that_was_chosen() {
3009 assert!(settings_block("codex").contains("first_implementor = \"codex\""));
3010 }
3011
3012 #[test]
3017 fn a_note_sits_above_the_option_it_describes() {
3018 let text = settings_block("claude");
3019 assert!(
3020 text.lines().all(|l| l.chars().count() <= 80),
3021 "a line runs off the edge:\n{text}"
3022 );
3023 assert!(
3024 text.lines().all(|l| !l.starts_with(' ')),
3025 "a line is indented, so the columns are back:\n{text}"
3026 );
3027
3028 let lines: Vec<&str> = text.lines().collect();
3030 let at = lines
3031 .iter()
3032 .position(|l| l.starts_with("max_rounds"))
3033 .expect("max_rounds");
3034 assert!(lines[at - 1].starts_with('#'), "{:?}", lines[at - 1]);
3035 assert!(
3036 lines[at - 1].contains("lifetime cap"),
3037 "the note above it is the end of its own note: {:?}",
3038 lines[at - 1]
3039 );
3040 }
3041
3042 #[test]
3044 fn every_note_starts_as_a_sentence() {
3045 for (_, key, note) in LOOP_OPTIONS.iter().chain(STYLE_OPTIONS) {
3046 let first = note.chars().next().expect("a note");
3047 assert!(
3048 first.is_uppercase(),
3049 "{key} reads as a margin scribble rather than a sentence: {note}"
3050 );
3051 }
3052 }
3053}
3054
3055#[cfg(test)]
3056mod agent_block_tests {
3057 use super::*;
3058
3059 fn spec(models: &[&str], efforts: &[&str]) -> config::AgentSpec {
3060 let mut spec: config::AgentSpec =
3061 toml::Value::Table(toml::from_str("command = [\"x\"]").expect("a minimal preset"))
3062 .try_into()
3063 .expect("builds");
3064 spec.models = models.iter().map(|s| s.to_string()).collect();
3065 spec.efforts = efforts.iter().map(|s| s.to_string()).collect();
3066 spec
3067 }
3068
3069 #[test]
3073 fn an_option_with_no_hints_is_left_out_rather_than_guessed_at() {
3074 let block = agent_block("cursor", &spec(&["composer-2.5", "auto"], &[]));
3075 assert!(!block.contains("..."), "{block}");
3076 assert!(!block.contains("effort"), "{block}");
3077 assert!(block.contains("# model = \"composer-2.5\""), "{block}");
3078 }
3079
3080 #[test]
3083 fn only_the_options_that_follow_are_introduced() {
3084 let model_only = agent_block("cursor", &spec(&["auto"], &[]));
3085 assert!(model_only.contains("Omit model to use"), "{model_only}");
3086 assert!(!model_only.contains("Omit effort"), "{model_only}");
3087
3088 let both = agent_block("claude", &spec(&["fable"], &["high"]));
3089 assert!(both.contains("Omit model to use"), "{both}");
3090 assert!(both.contains("Omit effort to use"), "{both}");
3091 }
3092
3093 #[test]
3096 fn a_preset_with_no_hints_still_writes_a_usable_block() {
3097 let block = agent_block("gemini", &spec(&[], &[]));
3098 assert!(!block.contains("..."), "{block}");
3099 assert!(!block.contains("Omit"), "{block}");
3100 assert!(
3101 block.starts_with("[agents.gemini]\npreset = \"gemini\"\n"),
3102 "{block}"
3103 );
3104 assert!(block.contains("[agents.gemini.fallback]"), "{block}");
3106 assert!(block.contains("# timeout = "), "{block}");
3107 }
3108
3109 #[test]
3112 fn the_timeout_offered_is_the_one_the_agent_would_use() {
3113 let mut spec = spec(&["a"], &[]);
3114 spec.timeout = 7200;
3115 assert!(
3116 agent_block("custom", &spec).contains("# timeout = 7200"),
3117 "the generator kept its own copy"
3118 );
3119 }
3120
3121 #[test]
3123 fn alternatives_are_listed_only_when_there_are_any() {
3124 assert!(agent_block("a", &spec(&["one", "two"], &[])).contains("One of: one | two."));
3125 let single = agent_block("b", &spec(&["only"], &[]));
3126 assert!(!single.contains("One of:"), "{single}");
3127 }
3128
3129 #[test]
3133 fn the_presets_note_is_said_once() {
3134 let mut spec = spec(&["m1", "m2"], &["e1", "e2"]);
3135 spec.options_note = Some("Check the current sets with: mytool --help".into());
3136 let block = agent_block("mytool", &spec);
3137 assert_eq!(
3138 1,
3139 block.matches("Check the current sets").count(),
3140 "{block}"
3141 );
3142 }
3143}