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