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 = \"issues\" # issues | local | none\n\
811 # keep_worktrees = false # true leaves them behind to inspect\n\
812 # min_number = 0 # ignore anything numbered below this when\n\
813 # picking for itself. 0 is no floor.\n\
814 # parallel_triage = true # false asks the agents one at a time\n\
815 # absorb_new_issues = 0 # waves of newly filed follow-ups to fold\n\
816 # back into this run. Costs more.\n\
817 # file_nits = false # true files nits as issues too\n\
818 # base_branch = \"main\" # only a fallback; origin/HEAD wins\n\
819 # branch_prefix = \"\" # e.g. \"spar/\" to namespace branches\n\
820 # state_store = \"local\" # local | pr | both\n\n\
821 [loop.effort_schedule]\n\
822 # Values are whatever each agent's own CLI accepts, listed above.\n\
823 # round_1 = \"high\" # the deep first review\n\
824 # rest = \"low\" # later rounds only see a small delta\n\n\
825 [style]\n\
826 ban_em_dash = true\n\
827 ban_ai_attribution = true\n\
828 terse = true # hold model prose to a length budget\n\
829 # max_title_chars = 90 # a finding, issue, or PR title\n\
830 # max_summary_chars = 200 # a one line verdict or refutation\n\
831 # max_detail_chars = 320 # a blocking finding, in the PR thread\n\
832 # max_body_chars = 900 # a PR body\n\
833 # max_issue_body_chars = 4000 # a filed issue's body. Code blocks in\n\
834 # it are never truncated.\n",
835 chosen[0].0
836 ));
837
838 std::fs::write(out, text).map_err(|e| spar_err!("could not write {}: {e}", out.display()))?;
839 println!("\nwrote {}", out.display());
840 println!("Next: `spar doctor` to check it, then `spar run` in a repo you have push access to.");
841 Ok(0)
842}
843
844type Probe = Box<dyn Fn() -> Result<String>>;
847
848fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
855 let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
856 out.push_str("# Omit model or effort to use the CLI's own default.\n");
857
858 fn suggested(choices: &[String]) -> &str {
862 choices.first().map(String::as_str).unwrap_or("...")
863 }
864 let assignments = [
865 format!("# model = \"{}\"", suggested(&spec.models)),
866 format!("# effort = \"{}\"", suggested(&spec.efforts)),
867 ];
868 let column = assignments
870 .iter()
871 .map(|a| a.chars().count())
872 .max()
873 .unwrap_or(0)
874 + 3;
875 for (assignment, choices) in assignments.iter().zip([&spec.models, &spec.efforts]) {
876 out.push_str(assignment);
877 if choices.len() > 1 {
878 let pad = column.saturating_sub(assignment.chars().count());
879 out.push_str(&" ".repeat(pad));
880 out.push_str(&format!("# {}", choices.join(" | ")));
881 }
882 out.push('\n');
883 }
884
885 if let Some(note) = &spec.options_note {
886 out.push_str(&wrap_comment(note));
887 }
888 out.push('\n');
889 out
890}
891
892fn wrap_comment(text: &str) -> String {
894 const WIDTH: usize = 76;
895 let mut out = String::new();
896 let mut line = String::from("#");
897 for word in text.split_whitespace() {
898 if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
899 out.push_str(&line);
900 out.push('\n');
901 line = String::from("#");
902 }
903 line.push(' ');
904 line.push_str(word);
905 }
906 if line.len() > 1 {
907 out.push_str(&line);
908 out.push('\n');
909 }
910 out
911}
912
913fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
914 let mut ok = true;
915
916 let probes: Vec<(&str, Probe)> = vec![
917 (
918 "git",
919 Box::new(|| {
920 proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
921 .map(|s| first_line(&s))
922 }),
923 ),
924 (
925 "gh",
926 Box::new(|| {
927 proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
928 .map(|s| first_line(&s))
929 }),
930 ),
931 (
932 "gh auth",
933 Box::new(|| {
934 let out = proc::exec(
935 &["gh".into(), "auth".into(), "status".into()],
936 &ExecOpts::new().check(false).timeout_secs(60),
937 )?;
938 let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
939 if out.ok() {
940 Ok(first_line(&text))
941 } else {
942 Err(spar_err!("not authenticated. Run `gh auth login`."))
943 }
944 }),
945 ),
946 ];
947
948 for (label, probe) in probes {
949 match probe() {
950 Ok(detail) => println!(" ok {label:12} {detail}"),
951 Err(e) => {
952 println!(" FAIL {label:12} {}", e.first_line());
953 ok = false;
954 }
955 }
956 }
957
958 let found = config::find_config(config_path)?;
959 let Some(path) = found else {
960 println!("\n no spar.toml found. Run `spar init` to generate one.");
961 println!(
962 " presets available: {}",
963 config::available_presets().join(", ")
964 );
965 return Ok(if ok { 0 } else { 1 });
966 };
967
968 println!("\n config: {}", path.display());
969 let cfg = match config::load(Some(&path)) {
970 Ok(cfg) => cfg,
971 Err(e) => {
972 println!(" FAIL config {e}");
973 return Ok(1);
974 }
975 };
976
977 let mut resolved = Vec::new();
980 for spec in &cfg.agents {
981 let agent = Agent::new(spec.clone());
982 match agent.resolve_bin() {
983 Ok(bin) => {
984 println!(
985 " ok {:12} {} ({})",
986 spec.name,
987 bin.display(),
988 spec.describe()
989 );
990 resolved.push(agent);
991 }
992 Err(e) => {
993 println!(" FAIL {:12} {}", spec.name, e.first_line());
994 ok = false;
995 }
996 }
997 }
998
999 if resolved.len() == cfg.agents.len() {
1000 if let Some(warning) = agent::correlation_warning(&resolved) {
1001 println!("\n WARNING {warning}");
1002 }
1003 }
1004
1005 println!(
1006 "\n settings: max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
1007 cfg.loop_cfg.max_rounds,
1008 cfg.loop_cfg.auto_merge,
1009 cfg.loop_cfg.worktrees,
1010 cfg.loop_cfg.followups,
1011 cfg.style.terse
1012 );
1013 println!(
1014 "{}",
1015 if ok {
1016 "\nready"
1017 } else {
1018 "\nmissing prerequisites"
1019 }
1020 );
1021 Ok(if ok { 0 } else { 1 })
1022}
1023
1024fn first_line(text: &str) -> String {
1025 text.trim().lines().next().unwrap_or("").trim().to_string()
1026}
1027
1028fn report(results: &[IssueRun], cfg: &Config) -> i32 {
1033 println!("\n{}", "=".repeat(60));
1034 for r in results {
1035 println!(
1036 "#{:<5} {:<10} rounds={} {}",
1037 r.issue,
1038 r.status.to_string(),
1039 r.rounds,
1040 r.pr.as_deref().unwrap_or("")
1041 );
1042 for note in &r.notes {
1043 println!(" {}", first_line(note));
1044 }
1045 for url in &r.filed {
1046 println!(" filed {url}");
1047 }
1048 for dispute in &r.disputes {
1049 println!(" disputed: {}", dispute.title);
1050 }
1051 }
1052 println!("{}", "=".repeat(60));
1053
1054 if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
1055 println!("\nApproved PRs are waiting on you to merge.");
1056 }
1057 if results.iter().all(IssueRun::succeeded) {
1058 0
1059 } else {
1060 1
1061 }
1062}
1063
1064#[cfg(test)]
1065mod tests {
1066 use super::*;
1067 use clap::CommandFactory;
1068
1069 #[test]
1070 fn the_parser_is_internally_consistent() {
1071 Cli::command().debug_assert();
1072 }
1073
1074 #[test]
1075 fn quiet_is_accepted_before_or_after_the_subcommand() {
1076 for argv in [
1077 vec!["spar", "--quiet", "run", "42"],
1078 vec!["spar", "run", "42", "--quiet"],
1079 vec!["spar", "resume", "--quiet"],
1080 vec!["spar", "init", "-q"],
1081 ] {
1082 assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
1083 }
1084 assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
1085 }
1086
1087 #[test]
1088 fn several_issue_numbers_are_accepted() {
1089 let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
1090 match cli.command {
1091 Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
1092 other => panic!("{other:?}"),
1093 }
1094 }
1095
1096 #[test]
1097 fn issue_numbers_and_flags_can_be_interleaved() {
1098 let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
1099 match cli.command {
1100 Command::Run {
1101 issues, loop_flags, ..
1102 } => {
1103 assert_eq!(vec![42, 51], issues);
1104 assert!(loop_flags.auto_merge);
1105 }
1106 other => panic!("{other:?}"),
1107 }
1108 }
1109
1110 #[test]
1111 fn every_command_that_reads_a_config_accepts_one() {
1112 for argv in [
1113 vec!["spar", "run", "42"],
1114 vec!["spar", "triage"],
1115 vec!["spar", "resume"],
1116 vec!["spar", "clean"],
1117 vec!["spar", "doctor"],
1118 ] {
1119 let mut full = argv.clone();
1120 full.extend(["--config", "other.toml"]);
1121 let cli = Cli::parse_from(&full);
1122 let config = match cli.command {
1123 Command::Run { common, .. }
1124 | Command::Triage { common, .. }
1125 | Command::Resume { common, .. } => common.config,
1126 Command::Clean { config, .. } | Command::Doctor { config } => config,
1127 other => panic!("{other:?}"),
1128 };
1129 assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
1130 }
1131 }
1132
1133 #[test]
1134 fn auto_merge_is_off_unless_asked_for() {
1135 let cli = Cli::parse_from(["spar", "run"]);
1136 match cli.command {
1137 Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
1138 other => panic!("{other:?}"),
1139 }
1140 }
1141
1142 #[test]
1143 fn the_two_close_skipped_flags_are_mutually_exclusive() {
1144 assert!(
1145 Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
1146 );
1147 }
1148
1149 #[test]
1152 fn close_skipped_is_offered_only_where_it_means_something() {
1153 assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
1154 assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
1155 assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
1156 assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
1157 assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
1158 }
1159
1160 #[test]
1161 fn the_close_skipped_pair_resolves_to_a_tristate() {
1162 let read = |argv: &[&str]| match Cli::parse_from(argv).command {
1163 Command::Run { triage_flags, .. } => {
1164 match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
1165 (true, _) => Some(true),
1166 (_, true) => Some(false),
1167 _ => None,
1168 }
1169 }
1170 other => panic!("{other:?}"),
1171 };
1172 assert_eq!(None, read(&["spar", "run"]));
1173 assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
1174 assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
1175 }
1176
1177 #[test]
1178 fn the_default_limit_is_twenty() {
1179 let cli = Cli::parse_from(["spar", "run"]);
1180 match cli.command {
1181 Command::Run { common, .. } => assert_eq!(20, common.limit),
1182 other => panic!("{other:?}"),
1183 }
1184 }
1185
1186 #[test]
1187 fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
1188 assert!(matches!(
1189 Cli::parse_from(["spar", "scrub-filter"]).command,
1190 Command::ScrubFilter
1191 ));
1192 let help = Cli::command().render_long_help().to_string();
1193 assert!(
1194 !help.contains("scrub-filter"),
1195 "it is plumbing, not a command"
1196 );
1197 }
1198
1199 #[test]
1200 fn review_takes_pr_numbers_and_a_dry_run() {
1201 let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
1202 match cli.command {
1203 Command::Review { items, dry_run, .. } => {
1204 assert_eq!(vec![101, 102], items);
1205 assert!(dry_run);
1206 }
1207 other => panic!("{other:?}"),
1208 }
1209 }
1210
1211 #[test]
1212 fn review_posts_unless_told_not_to() {
1213 match Cli::parse_from(["spar", "review", "101"]).command {
1214 Command::Review { dry_run, .. } => assert!(!dry_run),
1215 other => panic!("{other:?}"),
1216 }
1217 }
1218
1219 #[test]
1220 fn review_with_no_numbers_is_allowed() {
1221 match Cli::parse_from(["spar", "review"]).command {
1222 Command::Review { items, .. } => assert!(items.is_empty()),
1223 other => panic!("{other:?}"),
1224 }
1225 }
1226
1227 #[test]
1228 fn review_takes_its_own_round_budget() {
1229 match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
1230 Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
1231 other => panic!("{other:?}"),
1232 }
1233 }
1234
1235 #[test]
1236 fn resume_takes_a_next_override() {
1237 let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
1238 match cli.command {
1239 Command::Resume {
1240 prs, next_actor, ..
1241 } => {
1242 assert_eq!(vec![108], prs);
1243 assert_eq!(Some("codex".to_string()), next_actor);
1244 }
1245 other => panic!("{other:?}"),
1246 }
1247 }
1248}
1249
1250#[cfg(test)]
1251mod absorb_tests {
1252 use super::*;
1253
1254 #[test]
1255 fn absorb_is_off_unless_asked_for() {
1256 match Cli::parse_from(["spar", "run"]).command {
1257 Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
1258 other => panic!("{other:?}"),
1259 }
1260 }
1261
1262 #[test]
1263 fn absorb_takes_a_wave_count() {
1264 match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
1265 Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
1266 other => panic!("{other:?}"),
1267 }
1268 }
1269
1270 #[test]
1271 fn absorb_is_only_offered_where_issues_are_worked() {
1272 assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
1273 assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
1274 assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
1275 }
1276}
1277
1278#[cfg(test)]
1279mod min_number_tests {
1280 use super::*;
1281
1282 fn read(argv: &[&str]) -> Option<i64> {
1283 match Cli::parse_from(argv).command {
1284 Command::Run { common, .. }
1285 | Command::Triage { common, .. }
1286 | Command::Resume { common, .. }
1287 | Command::Review { common, .. } => common.min_number,
1288 other => panic!("{other:?}"),
1289 }
1290 }
1291
1292 #[test]
1293 fn there_is_no_floor_unless_one_is_asked_for() {
1294 assert_eq!(None, read(&["spar", "run"]));
1295 }
1296
1297 #[test]
1298 fn every_command_that_picks_for_itself_accepts_a_floor() {
1299 for cmd in ["run", "triage", "resume", "review"] {
1300 assert_eq!(
1301 Some(480),
1302 read(&["spar", cmd, "--min-number", "480"]),
1303 "{cmd}"
1304 );
1305 }
1306 }
1307}