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