1use std::collections::BTreeSet;
4use std::io::{Read, Write};
5use std::path::{Path, PathBuf};
6
7use clap::{Args, Parser, Subcommand};
8
9use crate::agent::{self, Agent};
10use crate::config::{self, Config};
11use crate::error::Result;
12use crate::model::{Issue, IssueRun, ItemKind, Ledger, Plan, Status};
13use crate::proc::{self, ExecOpts};
14use crate::repo::Repo;
15use crate::review;
16use crate::review_only;
17use crate::style;
18use crate::triage;
19use crate::{bail, log, logdim, logging, logwarn, spar_err};
20
21pub const VERSION: &str = env!("CARGO_PKG_VERSION");
22
23#[derive(Parser, Debug)]
24#[command(
25 name = "spar",
26 version = VERSION,
27 about = "Two coding agents alternate implementing and reviewing GitHub issues.",
28 long_about = "Two coding agents alternate implementing and reviewing GitHub issues until a \
29 pull request converges. Neither agent reviews its own most recent edit.\n\n\
30 Arguments are issue numbers for `run` and `triage`, and pull request numbers \
31 for `resume`. Omit them and spar takes everything open, up to --limit.",
32 max_term_width = 96
33)]
34pub struct Cli {
35 #[arg(short, long, global = true)]
37 pub quiet: bool,
38
39 #[command(subcommand)]
40 pub command: Command,
41}
42
43#[derive(Subcommand, Debug)]
44pub enum Command {
45 Run {
47 issues: Vec<i64>,
49 #[command(flatten)]
50 common: Common,
51 #[command(flatten)]
52 loop_flags: LoopFlags,
53 #[command(flatten)]
54 triage_flags: TriageFlags,
55 #[arg(long, default_value = "plan.json")]
57 plan_out: PathBuf,
58 #[arg(long)]
60 no_worktrees: bool,
61 },
62
63 Triage {
65 issues: Vec<i64>,
67 #[command(flatten)]
68 common: Common,
69 #[arg(long, default_value = "plan.json")]
70 plan_out: PathBuf,
71 },
72
73 Resume {
75 prs: Vec<i64>,
77 #[command(flatten)]
78 common: Common,
79 #[command(flatten)]
80 loop_flags: LoopFlags,
81 #[arg(long = "next", value_name = "AGENT")]
83 next_actor: Option<String>,
84 },
85
86 Review {
91 items: Vec<i64>,
94 #[command(flatten)]
95 common: Common,
96 #[arg(long)]
98 dry_run: bool,
99 #[arg(long)]
102 max_rounds: Option<u32>,
103 },
104
105 Init {
110 #[arg(long, default_value = "spar.toml")]
111 out: PathBuf,
112 #[arg(long)]
114 force: bool,
115 #[arg(long, conflicts_with = "force")]
118 update: bool,
119 },
120
121 Clean {
123 #[arg(long, default_value = ".")]
124 repo: PathBuf,
125 #[arg(long)]
126 config: Option<PathBuf>,
127 #[arg(long)]
129 all: bool,
130 #[arg(long)]
132 pr_state: bool,
133 },
134
135 Doctor {
137 #[arg(long)]
138 config: Option<PathBuf>,
139 },
140
141 #[command(hide = true)]
145 ScrubFilter,
146}
147
148#[derive(Args, Debug, Clone)]
149pub struct Common {
150 #[arg(long, default_value = ".")]
152 pub repo: PathBuf,
153 #[arg(long)]
155 pub config: Option<PathBuf>,
156 #[arg(long)]
158 pub base: Option<String>,
159 #[arg(long)]
161 pub first: Option<String>,
162 #[arg(long, default_value_t = 20)]
164 pub limit: usize,
165 #[arg(long, value_name = "N")]
168 pub min_number: Option<i64>,
169}
170
171#[derive(Args, Debug, Clone)]
172pub struct LoopFlags {
173 #[arg(long)]
176 pub max_rounds: Option<u32>,
177 #[arg(long)]
179 pub auto_merge: bool,
180 #[arg(long)]
182 pub keep_worktrees: bool,
183 #[arg(long, value_name = "N")]
186 pub absorb: Option<u32>,
187}
188
189#[derive(Args, Debug, Clone)]
192pub struct TriageFlags {
193 #[arg(long, conflicts_with = "no_close_skipped")]
195 pub close_skipped: bool,
196 #[arg(long)]
198 pub no_close_skipped: bool,
199}
200
201pub fn main() -> i32 {
206 let cli = Cli::parse();
207 logging::init_color();
208 logging::set_quiet(cli.quiet);
209
210 match dispatch(cli) {
211 Ok(code) => code,
212 Err(e) => {
213 logging::error(e.to_string());
214 2
215 }
216 }
217}
218
219fn dispatch(cli: Cli) -> Result<i32> {
220 match cli.command {
221 Command::ScrubFilter => cmd_scrub_filter(),
222 Command::Doctor { config } => cmd_doctor(config.as_deref()),
223 Command::Review {
224 items,
225 common,
226 dry_run,
227 max_rounds,
228 } => {
229 let overrides = Overrides {
230 max_rounds,
231 ..Overrides::default()
232 };
233 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
234 let numbers = if items.is_empty() {
235 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
236 if found.is_empty() {
237 log!("no open PRs");
238 return Ok(0);
239 }
240 log!("no PRs given, reviewing {} open", found.len());
241 found
242 } else {
243 items
244 };
245 let sorted = classify(&repo, &numbers)?;
246 let mut targets = sorted.prs;
247 for number in sorted.issues {
248 match repo.open_pr_for_issue(number) {
249 Some(pr) => {
250 log!("#{number} is an issue; reviewing its open PR {}", pr.url);
251 targets.push(pr.number);
252 }
253 None => logwarn!("#{number} is an issue with no open pull request to review"),
254 }
255 }
256 let mut results = Vec::new();
257 for number in targets {
258 results.push(review_only::review_pr(
259 &agents, &cfg, &repo, number, dry_run,
260 ));
261 }
262 if results.is_empty() {
263 return Ok(0);
264 }
265 Ok(report(&results, &cfg))
266 }
267
268 Command::Init { out, force, update } => {
269 if update {
270 cmd_init_update(&out)
271 } else {
272 cmd_init(&out, force)
273 }
274 }
275 Command::Clean {
276 repo,
277 config,
278 all,
279 pr_state,
280 } => cmd_clean(&repo, config.as_deref(), all, pr_state),
281 Command::Triage {
282 issues,
283 common,
284 plan_out,
285 } => {
286 let (cfg, repo, agents) = prepare(&common, None)?;
287 let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
288 if numbers.is_empty() {
289 return Ok(0);
290 }
291 let sorted = classify(&repo, &numbers)?;
292 for number in &sorted.prs {
293 log!("#{number} is a pull request, nothing to triage");
294 }
295 if sorted.issues.is_empty() {
296 log!("no issues to triage");
297 return Ok(0);
298 }
299 let issues = repo.fetch_issues(&sorted.issues)?;
300 make_plan(&agents, &cfg, &repo, &issues, &plan_out)?;
304 Ok(0)
305 }
306 Command::Run {
307 issues,
308 common,
309 loop_flags,
310 triage_flags,
311 plan_out,
312 no_worktrees,
313 } => {
314 let mut overrides = Overrides::from(&loop_flags);
315 overrides.worktrees = if no_worktrees { Some(false) } else { None };
316 overrides.close_skipped =
317 match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
318 (true, _) => Some(true),
319 (_, true) => Some(false),
320 _ => None,
321 };
322 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
323 let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
324 if numbers.is_empty() {
325 return Ok(0);
326 }
327 let sorted = classify(&repo, &numbers)?;
328 let mut results = Vec::new();
329 let mut ledger = Ledger::new();
330 let mut handled: BTreeSet<i64> = BTreeSet::new();
331 let mut wave = sorted.issues.clone();
332
333 for round in 0..=cfg.loop_cfg.absorb_new_issues {
338 wave.retain(|n| !handled.contains(n));
339 if wave.is_empty() {
340 break;
341 }
342 if round > 0 {
343 log!(
344 "absorbing {} newly filed issue(s): {}",
345 wave.len(),
346 wave.iter()
347 .map(|n| format!("#{n}"))
348 .collect::<Vec<_>>()
349 .join(", ")
350 );
351 }
352 handled.extend(wave.iter().copied());
353
354 let fetched = match repo.fetch_issues(&wave) {
355 Ok(fetched) => fetched,
356 Err(e) => {
357 logdim!("could not read the next wave: {e}");
358 break;
359 }
360 };
361 let plan_path = if round == 0 {
362 plan_out.clone()
363 } else {
364 plan_out.with_extension(format!("wave{round}.json"))
365 };
366 let plan = make_plan(&agents, &cfg, &repo, &fetched, &plan_path)?;
367 act_on_plan(&cfg, &repo, &plan);
368
369 let before = results.len();
370 for item in &plan.order {
371 let Some(issue) = fetched.iter().find(|i| i.number == item.issue) else {
372 continue;
373 };
374 results.push(review::run_issue(
375 &agents,
376 &cfg,
377 &repo,
378 item,
379 issue,
380 &mut ledger,
381 ));
382 }
383
384 wave = results[before..]
386 .iter()
387 .flat_map(|r| r.filed.iter())
388 .filter_map(|url| review::filed_issue_number(url))
389 .collect::<BTreeSet<_>>()
390 .into_iter()
391 .collect();
392 }
393 if !wave.is_empty() && cfg.loop_cfg.absorb_new_issues > 0 {
394 log!(
395 "{} issue(s) filed in the last wave were left for a later run: {}",
396 wave.len(),
397 wave.iter()
398 .map(|n| format!("#{n}"))
399 .collect::<Vec<_>>()
400 .join(", ")
401 );
402 }
403
404 for number in sorted.prs {
405 results.push(review::resume_pr(&agents, &cfg, &repo, number, None));
406 }
407
408 if results.is_empty() {
409 log!("nothing scheduled");
410 return Ok(0);
411 }
412 Ok(report(&results, &cfg))
413 }
414 Command::Resume {
415 prs,
416 common,
417 loop_flags,
418 next_actor,
419 } => {
420 let (cfg, repo, agents) = prepare(&common, Some(Overrides::from(&loop_flags)))?;
421 if let Some(name) = &next_actor {
422 if !cfg.has_agent(name) {
423 bail!("--next must be one of: {}", cfg.agent_names().join(", "));
424 }
425 }
426 let numbers = if prs.is_empty() {
427 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
428 if found.is_empty() {
429 log!("no open PRs");
430 return Ok(0);
431 }
432 log!(
433 "no PRs given, taking {} open: {}",
434 found.len(),
435 found
436 .iter()
437 .map(|n| format!("#{n}"))
438 .collect::<Vec<_>>()
439 .join(", ")
440 );
441 found
442 } else {
443 prs
444 };
445 let sorted = classify(&repo, &numbers)?;
446 let mut results = Vec::new();
447 for number in sorted.prs {
448 results.push(review::resume_pr(
449 &agents,
450 &cfg,
451 &repo,
452 number,
453 next_actor.as_deref(),
454 ));
455 }
456 for number in sorted.issues {
459 match repo.open_pr_for_issue(number) {
460 Some(pr) => {
461 log!("#{number} is an issue; continuing its open PR {}", pr.url);
462 results.push(review::resume_pr(
463 &agents,
464 &cfg,
465 &repo,
466 pr.number,
467 next_actor.as_deref(),
468 ));
469 }
470 None => logwarn!(
471 "#{number} is an issue with no open pull request. Use `spar run {number}` \
472 to implement it."
473 ),
474 }
475 }
476 if results.is_empty() {
477 return Ok(0);
478 }
479 Ok(report(&results, &cfg))
480 }
481 }
482}
483
484#[derive(Debug, Default, Clone)]
489struct Overrides {
490 max_rounds: Option<u32>,
491 auto_merge: Option<bool>,
492 keep_worktrees: Option<bool>,
493 worktrees: Option<bool>,
494 close_skipped: Option<bool>,
495 absorb: Option<u32>,
496}
497
498impl From<&LoopFlags> for Overrides {
499 fn from(flags: &LoopFlags) -> Self {
500 Self {
501 max_rounds: flags.max_rounds,
502 auto_merge: flags.auto_merge.then_some(true),
503 keep_worktrees: flags.keep_worktrees.then_some(true),
504 worktrees: None,
505 close_skipped: None,
506 absorb: flags.absorb,
507 }
508 }
509}
510
511fn prepare(common: &Common, overrides: Option<Overrides>) -> Result<(Config, Repo, Vec<Agent>)> {
512 let mut cfg = config::load(common.config.as_deref())?;
513
514 if let Some(first) = &common.first {
515 if !cfg.has_agent(first) {
516 bail!("--first must be one of: {}", cfg.agent_names().join(", "));
517 }
518 cfg.first_implementor = first.clone();
519 }
520 if let Some(base) = &common.base {
521 cfg.loop_cfg.base_branch = base.clone();
522 }
523 if let Some(min) = common.min_number {
524 cfg.loop_cfg.min_number = min;
525 }
526 if let Some(over) = overrides {
527 if let Some(v) = over.max_rounds {
528 if v == 0 {
529 bail!("--max-rounds must be at least 1");
530 }
531 cfg.loop_cfg.max_rounds = v;
532 }
533 if let Some(v) = over.auto_merge {
534 cfg.loop_cfg.auto_merge = v;
535 }
536 if let Some(v) = over.keep_worktrees {
537 cfg.loop_cfg.keep_worktrees = v;
538 }
539 if let Some(v) = over.worktrees {
540 cfg.loop_cfg.worktrees = v;
541 }
542 if let Some(v) = over.close_skipped {
543 cfg.loop_cfg.close_skipped = v;
544 }
545 if let Some(v) = over.absorb {
546 cfg.loop_cfg.absorb_new_issues = v;
547 }
548 }
549
550 let repo = Repo::open(&common.repo, &cfg)?;
551 if common.base.is_none() {
552 cfg.loop_cfg.base_branch = repo.default_branch(cfg.base_branch());
553 }
554
555 let agents = agent::build(&cfg)?;
556 if let Some(warning) = agent::correlation_warning(&agents) {
557 logging::warn(warning);
558 }
559
560 for stale in repo.prune_worktrees(false) {
562 let what = if stale.starts_with("branch ") {
563 stale
564 } else {
565 format!("worktree {stale}")
566 };
567 logdim!("cleaned up finished {what}");
568 }
569
570 log!("repo {} base {}", repo.root().display(), cfg.base_branch());
571 log!(
572 "agents: {}",
573 agents
574 .iter()
575 .map(|a| format!("{}={}", a.name(), a.spec.describe()))
576 .collect::<Vec<_>>()
577 .join(", ")
578 );
579 Ok((cfg, repo, agents))
580}
581
582fn pick_issues(repo: &Repo, given: Vec<i64>, limit: usize, min_number: i64) -> Result<Vec<i64>> {
583 if !given.is_empty() {
584 if min_number > 0 {
586 let below: Vec<String> = given
587 .iter()
588 .filter(|n| **n < min_number)
589 .map(|n| format!("#{n}"))
590 .collect();
591 if !below.is_empty() {
592 logdim!(
593 "{} below the #{min_number} floor, taking them because you named them",
594 below.join(", ")
595 );
596 }
597 }
598 return Ok(given);
599 }
600 let found = repo.list_open_issues(limit, min_number)?;
601 if found.is_empty() {
602 log!("no open issues");
603 return Ok(found);
604 }
605 log!(
606 "no issues given, taking {} open: {}",
607 found.len(),
608 found
609 .iter()
610 .map(|n| format!("#{n}"))
611 .collect::<Vec<_>>()
612 .join(", ")
613 );
614 Ok(found)
615}
616
617#[derive(Debug, Default)]
623struct Sorted {
624 issues: Vec<i64>,
625 prs: Vec<i64>,
626}
627
628fn classify(repo: &Repo, numbers: &[i64]) -> Result<Sorted> {
629 let mut sorted = Sorted::default();
630 for number in numbers {
631 match repo.item_kind(*number)? {
632 ItemKind::Issue => sorted.issues.push(*number),
633 ItemKind::Pr => sorted.prs.push(*number),
634 }
635 }
636 if !sorted.issues.is_empty() && !sorted.prs.is_empty() {
637 log!(
638 "{} issue(s) and {} pull request(s) given",
639 sorted.issues.len(),
640 sorted.prs.len()
641 );
642 }
643 Ok(sorted)
644}
645
646fn make_plan(
647 agents: &[Agent],
648 cfg: &Config,
649 repo: &Repo,
650 issues: &[Issue],
651 plan_out: &Path,
652) -> Result<Plan> {
653 let plan = triage::triage(agents, cfg, repo, issues)?;
654
655 std::fs::write(plan_out, serde_json::to_vec_pretty(&plan)?)
656 .map_err(|e| spar_err!("could not write {}: {e}", plan_out.display()))?;
657 log!("plan written to {}", plan_out.display());
658
659 for item in &plan.order {
660 log!(
661 " do #{} [{}/{}] {}",
662 item.issue,
663 item.complexity,
664 item.risk,
665 item.title
666 );
667 }
668 for item in &plan.skipped {
669 log!(" skip #{} (both reviewers: not worth doing)", item.issue);
670 }
671 for item in &plan.contested {
672 log!(" ?? #{} contested, parked for you to decide", item.issue);
673 }
674 Ok(plan)
675}
676
677fn act_on_plan(cfg: &Config, repo: &Repo, plan: &Plan) {
680 for item in &plan.skipped {
681 let body = review::skip_comment(item, &repo.style);
682 let outcome = if cfg.loop_cfg.close_skipped {
683 repo.close_issue(item.issue, &body)
684 } else {
685 repo.comment_issue(item.issue, &body)
686 };
687 match outcome {
688 Ok(()) if cfg.loop_cfg.close_skipped => log!(" closed #{}", item.issue),
689 Ok(()) => {}
690 Err(e) => logdim!("could not update #{}: {e}", item.issue),
691 }
692 }
693}
694
695fn cmd_scrub_filter() -> Result<i32> {
700 let mut input = String::new();
701 std::io::stdin()
702 .read_to_string(&mut input)
703 .map_err(|e| spar_err!("could not read a commit message from stdin: {e}"))?;
704 let out = style::scrub(&input, &crate::repo::style_from_env());
705 let mut stdout = std::io::stdout();
706 stdout
707 .write_all(out.as_bytes())
708 .and_then(|_| stdout.write_all(b"\n"))
709 .map_err(|e| spar_err!("could not write the scrubbed message: {e}"))?;
710 Ok(0)
711}
712
713fn cmd_clean(
714 repo_path: &Path,
715 config_path: Option<&Path>,
716 all: bool,
717 pr_state: bool,
718) -> Result<i32> {
719 let cfg = config::load(config_path)?;
720 let repo = Repo::open(repo_path, &cfg)?;
721 let mut removed = repo.prune_worktrees(all);
722 removed.extend(repo.prune_state());
723 if pr_state {
724 removed.extend(repo.prune_pr_state(None));
725 }
726 if removed.is_empty() {
727 println!("nothing to clean");
728 } else {
729 for item in removed {
730 println!("removed {item}");
731 }
732 }
733 Ok(0)
734}
735
736fn cmd_init_update(out: &Path) -> Result<i32> {
742 let text = std::fs::read_to_string(out)
743 .map_err(|e| spar_err!("could not read {}: {e}", out.display()))?;
744 config::parse(&text).map_err(|e| spar_err!("{} does not parse: {e}", out.display()))?;
747
748 let unset = config::unmentioned_options(&text);
749 if unset.is_empty() {
750 println!("{} already mentions every setting.", out.display());
751 return Ok(0);
752 }
753
754 let mut block = String::new();
755 if !text.ends_with('\n') {
756 block.push('\n');
757 }
758 block.push_str("\n# Added by `spar init --update`: settings this file did not mention,\n");
759 block.push_str("# shown at their defaults. Uncomment one to change it.\n");
760 let mut section = "";
761 for option in &unset {
762 if option.section != section {
763 section = option.section;
764 block.push_str(&format!("# [{section}]\n"));
765 }
766 block.push_str(&format!("# {} = {}\n", option.key, option.default));
767 }
768
769 use std::io::Write;
770 std::fs::OpenOptions::new()
771 .append(true)
772 .open(out)
773 .and_then(|mut f| f.write_all(block.as_bytes()))
774 .map_err(|e| spar_err!("could not append to {}: {e}", out.display()))?;
775
776 println!(
777 "added {} setting(s) to {} as comments",
778 unset.len(),
779 out.display()
780 );
781 Ok(0)
782}
783
784fn cmd_init(out: &Path, force: bool) -> Result<i32> {
785 if out.exists() && !force {
786 logging::error(format!(
787 "{} already exists. `--update` appends any settings it does not mention, \
788 `--force` overwrites it.",
789 out.display()
790 ));
791 return Ok(1);
792 }
793
794 let presets = config::available_presets();
795 if presets.is_empty() {
796 bail!("no presets available, which should be impossible in a released build");
797 }
798
799 let mut found: Vec<(String, PathBuf, config::AgentSpec)> = Vec::new();
800 for name in &presets {
801 let raw = config::load_preset(name)?;
802 let mut spec: config::AgentSpec = match raw
806 .as_table()
807 .cloned()
808 .ok_or_else(|| spar_err!("not a table"))
809 .and_then(|t| {
810 toml::Value::Table(t)
811 .try_into()
812 .map_err(|e| spar_err!("{e}"))
813 }) {
814 Ok(spec) => spec,
815 Err(e) => {
816 println!(" BROKEN {name:10} {}", e.first_line());
817 continue;
818 }
819 };
820 spec.name = name.clone();
821 match Agent::new(spec.clone()).resolve_bin() {
822 Ok(path) => {
823 println!(" found {name:10} {}", path.display());
824 found.push((name.clone(), path.to_path_buf(), spec));
825 }
826 Err(_) => println!(" missing {name}"),
827 }
828 }
829
830 if found.len() < 2 {
831 logging::error(format!(
832 "need two agent CLIs, found {}. Install another, or write {} by hand using the \
833 presets as a reference.",
834 found.len(),
835 out.display()
836 ));
837 return Ok(1);
838 }
839
840 let chosen: Vec<&(String, PathBuf, config::AgentSpec)> = found.iter().take(2).collect();
842 if found.len() > 2 {
843 log!(
844 "{} agents available, picking {} and {}. Edit {} to change.",
845 found.len(),
846 chosen[0].0,
847 chosen[1].0,
848 out.display()
849 );
850 }
851
852 let mut text = String::from(
853 "# Generated by `spar init`. Each agent inherits a command template from a\n\
854 # built in preset; anything set here overrides it.\n\
855 #\n\
856 # Commented lines are the other options, each with a working value.\n\
857 # Uncomment one to change it.\n\n",
858 );
859 for (name, _, spec) in &chosen {
860 text.push_str(&agent_block(name, spec));
861 }
862 text.push_str(&format!(
863 "[loop]\n\
864 max_rounds = 3 # review rounds ONE invocation may spend.\n\
865 # Resuming grants a fresh budget, so this\n\
866 # is not a lifetime cap on a PR.\n\
867 auto_merge = false # off on purpose: two models agreeing is\n\
868 # not the same as being right\n\
869 first_implementor = \"{}\"\n\
870 worktrees = true # false works in the main checkout\n\
871 close_skipped = true # close an issue both reviewers declined\n\
872 followups = \"local\" # issues | local | none. local writes\n\
873 # .spar/followups.md, not the tracker\n\
874 # file_non_blocking = false # a suggestion is not a tracker item\n\
875 # max_followups = 5 # backstop on what one run can spawn\n\
876 # keep_worktrees = false # true leaves them behind to inspect\n\
877 # min_number = 0 # ignore anything numbered below this when\n\
878 # picking for itself. 0 is no floor.\n\
879 # parallel_triage = true # false asks the agents one at a time\n\
880 # absorb_new_issues = 0 # waves of newly filed follow-ups to fold\n\
881 # back into this run. Costs more.\n\
882 # file_nits = false # true files nits as issues too\n\
883 # base_branch = \"main\" # only a fallback; origin/HEAD wins\n\
884 # branch_prefix = \"\" # e.g. \"spar/\" to namespace branches\n\
885 # state_store = \"local\" # local | pr | both\n\n\
886 [loop.effort_schedule]\n\
887 # Values are whatever each agent's own CLI accepts, listed above.\n\
888 # round_1 = \"high\" # the deep first review\n\
889 # rest = \"low\" # later rounds only see a small delta\n\n\
890 [style]\n\
891 ban_em_dash = true\n\
892 ban_ai_attribution = true\n\
893 terse = true # hold model prose to a length budget\n\
894 # max_title_chars = 90 # a finding, issue, or PR title\n\
895 # max_summary_chars = 200 # a one line verdict or refutation\n\
896 # max_detail_chars = 320 # a blocking finding, in the PR thread\n\
897 # max_body_chars = 900 # a PR body\n\
898 # max_issue_body_chars = 4000 # a filed issue's body. Code blocks in\n\
899 # it are never truncated.\n",
900 chosen[0].0
901 ));
902
903 std::fs::write(out, text).map_err(|e| spar_err!("could not write {}: {e}", out.display()))?;
904 println!("\nwrote {}", out.display());
905 println!("Next: `spar doctor` to check it, then `spar run` in a repo you have push access to.");
906 Ok(0)
907}
908
909type Probe = Box<dyn Fn() -> Result<String>>;
912
913fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
920 let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
921 out.push_str("# Omit model or effort to use the CLI's own default.\n");
922
923 fn suggested(choices: &[String]) -> &str {
927 choices.first().map(String::as_str).unwrap_or("...")
928 }
929 let assignments = [
930 format!("# model = \"{}\"", suggested(&spec.models)),
931 format!("# effort = \"{}\"", suggested(&spec.efforts)),
932 ];
933 let column = assignments
935 .iter()
936 .map(|a| a.chars().count())
937 .max()
938 .unwrap_or(0)
939 + 3;
940 for (assignment, choices) in assignments.iter().zip([&spec.models, &spec.efforts]) {
941 out.push_str(assignment);
942 if choices.len() > 1 {
943 let pad = column.saturating_sub(assignment.chars().count());
944 out.push_str(&" ".repeat(pad));
945 out.push_str(&format!("# {}", choices.join(" | ")));
946 }
947 out.push('\n');
948 }
949
950 if let Some(note) = &spec.options_note {
951 out.push_str(&wrap_comment(note));
952 }
953 out.push('\n');
954 out
955}
956
957fn wrap_comment(text: &str) -> String {
959 const WIDTH: usize = 76;
960 let mut out = String::new();
961 let mut line = String::from("#");
962 for word in text.split_whitespace() {
963 if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
964 out.push_str(&line);
965 out.push('\n');
966 line = String::from("#");
967 }
968 line.push(' ');
969 line.push_str(word);
970 }
971 if line.len() > 1 {
972 out.push_str(&line);
973 out.push('\n');
974 }
975 out
976}
977
978fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
979 let mut ok = true;
980
981 let probes: Vec<(&str, Probe)> = vec![
982 (
983 "git",
984 Box::new(|| {
985 proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
986 .map(|s| first_line(&s))
987 }),
988 ),
989 (
990 "gh",
991 Box::new(|| {
992 proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
993 .map(|s| first_line(&s))
994 }),
995 ),
996 (
997 "gh auth",
998 Box::new(|| {
999 let out = proc::exec(
1000 &["gh".into(), "auth".into(), "status".into()],
1001 &ExecOpts::new().check(false).timeout_secs(60),
1002 )?;
1003 let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
1004 if out.ok() {
1005 Ok(first_line(&text))
1006 } else {
1007 Err(spar_err!("not authenticated. Run `gh auth login`."))
1008 }
1009 }),
1010 ),
1011 ];
1012
1013 for (label, probe) in probes {
1014 match probe() {
1015 Ok(detail) => println!(" ok {label:12} {detail}"),
1016 Err(e) => {
1017 println!(" FAIL {label:12} {}", e.first_line());
1018 ok = false;
1019 }
1020 }
1021 }
1022
1023 let found = config::find_config(config_path)?;
1024 let Some(path) = found else {
1025 println!("\n no spar.toml found. Run `spar init` to generate one.");
1026 println!(
1027 " presets available: {}",
1028 config::available_presets().join(", ")
1029 );
1030 return Ok(if ok { 0 } else { 1 });
1031 };
1032
1033 println!("\n config: {}", path.display());
1034 let cfg = match config::load(Some(&path)) {
1035 Ok(cfg) => cfg,
1036 Err(e) => {
1037 println!(" FAIL config {e}");
1038 return Ok(1);
1039 }
1040 };
1041
1042 let mut resolved = Vec::new();
1045 for spec in &cfg.agents {
1046 let agent = Agent::new(spec.clone());
1047 match agent.resolve_bin() {
1048 Ok(bin) => {
1049 println!(
1050 " ok {:12} {} ({})",
1051 spec.name,
1052 bin.display(),
1053 spec.describe()
1054 );
1055 resolved.push(agent);
1056 }
1057 Err(e) => {
1058 println!(" FAIL {:12} {}", spec.name, e.first_line());
1059 ok = false;
1060 }
1061 }
1062 }
1063
1064 if resolved.len() == cfg.agents.len() {
1065 if let Some(warning) = agent::correlation_warning(&resolved) {
1066 println!("\n WARNING {warning}");
1067 }
1068 }
1069
1070 println!(
1071 "\n settings: max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
1072 cfg.loop_cfg.max_rounds,
1073 cfg.loop_cfg.auto_merge,
1074 cfg.loop_cfg.worktrees,
1075 cfg.loop_cfg.followups,
1076 cfg.style.terse
1077 );
1078 if let Ok(text) = std::fs::read_to_string(&path) {
1082 let unset = config::unmentioned_options(&text);
1083 if !unset.is_empty() {
1084 println!(
1085 "\n {} setting(s) this config does not mention, all at their defaults:",
1086 unset.len()
1087 );
1088 for option in &unset {
1089 println!(
1090 " [{}] {} = {}",
1091 option.section, option.key, option.default
1092 );
1093 }
1094 println!(
1095 " `spar init --update {}` appends them as comments.",
1096 path.display()
1097 );
1098 }
1099 }
1100
1101 println!(
1102 "{}",
1103 if ok {
1104 "\nready"
1105 } else {
1106 "\nmissing prerequisites"
1107 }
1108 );
1109 Ok(if ok { 0 } else { 1 })
1110}
1111
1112fn first_line(text: &str) -> String {
1113 text.trim().lines().next().unwrap_or("").trim().to_string()
1114}
1115
1116fn report(results: &[IssueRun], cfg: &Config) -> i32 {
1121 println!("\n{}", "=".repeat(60));
1122 for r in results {
1123 println!(
1124 "#{:<5} {:<10} rounds={} {}",
1125 r.issue,
1126 r.status.to_string(),
1127 r.rounds,
1128 r.pr.as_deref().unwrap_or("")
1129 );
1130 for note in &r.notes {
1131 println!(" {}", first_line(note));
1132 }
1133 for url in &r.filed {
1134 println!(" filed {url}");
1135 }
1136 for dispute in &r.disputes {
1137 println!(" disputed: {}", dispute.title);
1138 }
1139 }
1140 println!("{}", "=".repeat(60));
1141
1142 if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
1143 println!("\nApproved PRs are waiting on you to merge.");
1144 }
1145 let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
1146 if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
1147 println!(
1148 "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
1149 Set followups = \"issues\" to file them."
1150 );
1151 }
1152 if results.iter().all(IssueRun::succeeded) {
1153 0
1154 } else {
1155 1
1156 }
1157}
1158
1159#[cfg(test)]
1160mod tests {
1161 use super::*;
1162 use clap::CommandFactory;
1163
1164 #[test]
1165 fn the_parser_is_internally_consistent() {
1166 Cli::command().debug_assert();
1167 }
1168
1169 #[test]
1170 fn quiet_is_accepted_before_or_after_the_subcommand() {
1171 for argv in [
1172 vec!["spar", "--quiet", "run", "42"],
1173 vec!["spar", "run", "42", "--quiet"],
1174 vec!["spar", "resume", "--quiet"],
1175 vec!["spar", "init", "-q"],
1176 ] {
1177 assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
1178 }
1179 assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
1180 }
1181
1182 #[test]
1183 fn several_issue_numbers_are_accepted() {
1184 let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
1185 match cli.command {
1186 Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
1187 other => panic!("{other:?}"),
1188 }
1189 }
1190
1191 #[test]
1192 fn issue_numbers_and_flags_can_be_interleaved() {
1193 let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
1194 match cli.command {
1195 Command::Run {
1196 issues, loop_flags, ..
1197 } => {
1198 assert_eq!(vec![42, 51], issues);
1199 assert!(loop_flags.auto_merge);
1200 }
1201 other => panic!("{other:?}"),
1202 }
1203 }
1204
1205 #[test]
1206 fn every_command_that_reads_a_config_accepts_one() {
1207 for argv in [
1208 vec!["spar", "run", "42"],
1209 vec!["spar", "triage"],
1210 vec!["spar", "resume"],
1211 vec!["spar", "clean"],
1212 vec!["spar", "doctor"],
1213 ] {
1214 let mut full = argv.clone();
1215 full.extend(["--config", "other.toml"]);
1216 let cli = Cli::parse_from(&full);
1217 let config = match cli.command {
1218 Command::Run { common, .. }
1219 | Command::Triage { common, .. }
1220 | Command::Resume { common, .. } => common.config,
1221 Command::Clean { config, .. } | Command::Doctor { config } => config,
1222 other => panic!("{other:?}"),
1223 };
1224 assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
1225 }
1226 }
1227
1228 #[test]
1229 fn auto_merge_is_off_unless_asked_for() {
1230 let cli = Cli::parse_from(["spar", "run"]);
1231 match cli.command {
1232 Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
1233 other => panic!("{other:?}"),
1234 }
1235 }
1236
1237 #[test]
1238 fn the_two_close_skipped_flags_are_mutually_exclusive() {
1239 assert!(
1240 Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
1241 );
1242 }
1243
1244 #[test]
1247 fn close_skipped_is_offered_only_where_it_means_something() {
1248 assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
1249 assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
1250 assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
1251 assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
1252 assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
1253 }
1254
1255 #[test]
1256 fn the_close_skipped_pair_resolves_to_a_tristate() {
1257 let read = |argv: &[&str]| match Cli::parse_from(argv).command {
1258 Command::Run { triage_flags, .. } => {
1259 match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
1260 (true, _) => Some(true),
1261 (_, true) => Some(false),
1262 _ => None,
1263 }
1264 }
1265 other => panic!("{other:?}"),
1266 };
1267 assert_eq!(None, read(&["spar", "run"]));
1268 assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
1269 assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
1270 }
1271
1272 #[test]
1273 fn the_default_limit_is_twenty() {
1274 let cli = Cli::parse_from(["spar", "run"]);
1275 match cli.command {
1276 Command::Run { common, .. } => assert_eq!(20, common.limit),
1277 other => panic!("{other:?}"),
1278 }
1279 }
1280
1281 #[test]
1282 fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
1283 assert!(matches!(
1284 Cli::parse_from(["spar", "scrub-filter"]).command,
1285 Command::ScrubFilter
1286 ));
1287 let help = Cli::command().render_long_help().to_string();
1288 assert!(
1289 !help.contains("scrub-filter"),
1290 "it is plumbing, not a command"
1291 );
1292 }
1293
1294 #[test]
1295 fn review_takes_pr_numbers_and_a_dry_run() {
1296 let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
1297 match cli.command {
1298 Command::Review { items, dry_run, .. } => {
1299 assert_eq!(vec![101, 102], items);
1300 assert!(dry_run);
1301 }
1302 other => panic!("{other:?}"),
1303 }
1304 }
1305
1306 #[test]
1307 fn review_posts_unless_told_not_to() {
1308 match Cli::parse_from(["spar", "review", "101"]).command {
1309 Command::Review { dry_run, .. } => assert!(!dry_run),
1310 other => panic!("{other:?}"),
1311 }
1312 }
1313
1314 #[test]
1315 fn review_with_no_numbers_is_allowed() {
1316 match Cli::parse_from(["spar", "review"]).command {
1317 Command::Review { items, .. } => assert!(items.is_empty()),
1318 other => panic!("{other:?}"),
1319 }
1320 }
1321
1322 #[test]
1323 fn review_takes_its_own_round_budget() {
1324 match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
1325 Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
1326 other => panic!("{other:?}"),
1327 }
1328 }
1329
1330 #[test]
1331 fn resume_takes_a_next_override() {
1332 let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
1333 match cli.command {
1334 Command::Resume {
1335 prs, next_actor, ..
1336 } => {
1337 assert_eq!(vec![108], prs);
1338 assert_eq!(Some("codex".to_string()), next_actor);
1339 }
1340 other => panic!("{other:?}"),
1341 }
1342 }
1343}
1344
1345#[cfg(test)]
1346mod absorb_tests {
1347 use super::*;
1348
1349 #[test]
1350 fn absorb_is_off_unless_asked_for() {
1351 match Cli::parse_from(["spar", "run"]).command {
1352 Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
1353 other => panic!("{other:?}"),
1354 }
1355 }
1356
1357 #[test]
1358 fn absorb_takes_a_wave_count() {
1359 match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
1360 Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
1361 other => panic!("{other:?}"),
1362 }
1363 }
1364
1365 #[test]
1366 fn absorb_is_only_offered_where_issues_are_worked() {
1367 assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
1368 assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
1369 assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
1370 }
1371}
1372
1373#[cfg(test)]
1374mod min_number_tests {
1375 use super::*;
1376
1377 fn read(argv: &[&str]) -> Option<i64> {
1378 match Cli::parse_from(argv).command {
1379 Command::Run { common, .. }
1380 | Command::Triage { common, .. }
1381 | Command::Resume { common, .. }
1382 | Command::Review { common, .. } => common.min_number,
1383 other => panic!("{other:?}"),
1384 }
1385 }
1386
1387 #[test]
1388 fn there_is_no_floor_unless_one_is_asked_for() {
1389 assert_eq!(None, read(&["spar", "run"]));
1390 }
1391
1392 #[test]
1393 fn every_command_that_picks_for_itself_accepts_a_floor() {
1394 for cmd in ["run", "triage", "resume", "review"] {
1395 assert_eq!(
1396 Some(480),
1397 read(&["spar", cmd, "--min-number", "480"]),
1398 "{cmd}"
1399 );
1400 }
1401 }
1402}