1use std::io::{Read, Write};
4use std::path::{Path, PathBuf};
5
6use clap::{Args, Parser, Subcommand};
7
8use crate::agent::{self, Agent};
9use crate::config::{self, Config};
10use crate::error::Result;
11use crate::model::{Issue, IssueRun, ItemKind, Ledger, Plan, Status};
12use crate::proc::{self, ExecOpts};
13use crate::repo::Repo;
14use crate::review;
15use crate::review_only;
16use crate::style;
17use crate::triage;
18use crate::{bail, log, logdim, logging, logwarn, spar_err};
19
20pub const VERSION: &str = env!("CARGO_PKG_VERSION");
21
22#[derive(Parser, Debug)]
23#[command(
24 name = "spar",
25 version = VERSION,
26 about = "Two coding agents alternate implementing and reviewing GitHub issues.",
27 long_about = "Two coding agents alternate implementing and reviewing GitHub issues until a \
28 pull request converges. Neither agent reviews its own most recent edit.\n\n\
29 Arguments are issue numbers for `run` and `triage`, and pull request numbers \
30 for `resume`. Omit them and spar takes everything open, up to --limit.",
31 max_term_width = 96
32)]
33pub struct Cli {
34 #[arg(short, long, global = true)]
36 pub quiet: bool,
37
38 #[command(subcommand)]
39 pub command: Command,
40}
41
42#[derive(Subcommand, Debug)]
43pub enum Command {
44 Run {
46 issues: Vec<i64>,
48 #[command(flatten)]
49 common: Common,
50 #[command(flatten)]
51 loop_flags: LoopFlags,
52 #[command(flatten)]
53 triage_flags: TriageFlags,
54 #[arg(long, default_value = "plan.json")]
56 plan_out: PathBuf,
57 #[arg(long)]
59 no_worktrees: bool,
60 },
61
62 Triage {
64 issues: Vec<i64>,
66 #[command(flatten)]
67 common: Common,
68 #[arg(long, default_value = "plan.json")]
69 plan_out: PathBuf,
70 },
71
72 Resume {
74 prs: Vec<i64>,
76 #[command(flatten)]
77 common: Common,
78 #[command(flatten)]
79 loop_flags: LoopFlags,
80 #[arg(long = "next", value_name = "AGENT")]
82 next_actor: Option<String>,
83 },
84
85 Review {
90 items: Vec<i64>,
93 #[command(flatten)]
94 common: Common,
95 #[arg(long)]
97 dry_run: bool,
98 #[arg(long)]
101 max_rounds: Option<u32>,
102 },
103
104 Init {
106 #[arg(long, default_value = "spar.toml")]
107 out: PathBuf,
108 #[arg(long)]
110 force: bool,
111 },
112
113 Clean {
115 #[arg(long, default_value = ".")]
116 repo: PathBuf,
117 #[arg(long)]
118 config: Option<PathBuf>,
119 #[arg(long)]
121 all: bool,
122 #[arg(long)]
124 pr_state: bool,
125 },
126
127 Doctor {
129 #[arg(long)]
130 config: Option<PathBuf>,
131 },
132
133 #[command(hide = true)]
137 ScrubFilter,
138}
139
140#[derive(Args, Debug, Clone)]
141pub struct Common {
142 #[arg(long, default_value = ".")]
144 pub repo: PathBuf,
145 #[arg(long)]
147 pub config: Option<PathBuf>,
148 #[arg(long)]
150 pub base: Option<String>,
151 #[arg(long)]
153 pub first: Option<String>,
154 #[arg(long, default_value_t = 20)]
156 pub limit: usize,
157}
158
159#[derive(Args, Debug, Clone)]
160pub struct LoopFlags {
161 #[arg(long)]
164 pub max_rounds: Option<u32>,
165 #[arg(long)]
167 pub auto_merge: bool,
168 #[arg(long)]
170 pub keep_worktrees: bool,
171}
172
173#[derive(Args, Debug, Clone)]
176pub struct TriageFlags {
177 #[arg(long, conflicts_with = "no_close_skipped")]
179 pub close_skipped: bool,
180 #[arg(long)]
182 pub no_close_skipped: bool,
183}
184
185pub fn main() -> i32 {
190 let cli = Cli::parse();
191 logging::init_color();
192 logging::set_quiet(cli.quiet);
193
194 match dispatch(cli) {
195 Ok(code) => code,
196 Err(e) => {
197 logging::error(e.to_string());
198 2
199 }
200 }
201}
202
203fn dispatch(cli: Cli) -> Result<i32> {
204 match cli.command {
205 Command::ScrubFilter => cmd_scrub_filter(),
206 Command::Doctor { config } => cmd_doctor(config.as_deref()),
207 Command::Review {
208 items,
209 common,
210 dry_run,
211 max_rounds,
212 } => {
213 let overrides = Overrides {
214 max_rounds,
215 ..Overrides::default()
216 };
217 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
218 let numbers = if items.is_empty() {
219 let found = repo.list_open_prs(common.limit)?;
220 if found.is_empty() {
221 log!("no open PRs");
222 return Ok(0);
223 }
224 log!("no PRs given, reviewing {} open", found.len());
225 found
226 } else {
227 items
228 };
229 let sorted = classify(&repo, &numbers)?;
230 let mut targets = sorted.prs;
231 for number in sorted.issues {
232 match repo.open_pr_for_issue(number) {
233 Some(pr) => {
234 log!("#{number} is an issue; reviewing its open PR {}", pr.url);
235 targets.push(pr.number);
236 }
237 None => logwarn!("#{number} is an issue with no open pull request to review"),
238 }
239 }
240 let mut results = Vec::new();
241 for number in targets {
242 results.push(review_only::review_pr(
243 &agents, &cfg, &repo, number, dry_run,
244 ));
245 }
246 if results.is_empty() {
247 return Ok(0);
248 }
249 Ok(report(&results, &cfg))
250 }
251
252 Command::Init { out, force } => cmd_init(&out, force),
253 Command::Clean {
254 repo,
255 config,
256 all,
257 pr_state,
258 } => cmd_clean(&repo, config.as_deref(), all, pr_state),
259 Command::Triage {
260 issues,
261 common,
262 plan_out,
263 } => {
264 let (cfg, repo, agents) = prepare(&common, None)?;
265 let numbers = pick_issues(&repo, issues, common.limit)?;
266 if numbers.is_empty() {
267 return Ok(0);
268 }
269 let sorted = classify(&repo, &numbers)?;
270 for number in &sorted.prs {
271 log!("#{number} is a pull request, nothing to triage");
272 }
273 if sorted.issues.is_empty() {
274 log!("no issues to triage");
275 return Ok(0);
276 }
277 let issues = repo.fetch_issues(&sorted.issues)?;
278 make_plan(&agents, &cfg, &repo, &issues, &plan_out)?;
282 Ok(0)
283 }
284 Command::Run {
285 issues,
286 common,
287 loop_flags,
288 triage_flags,
289 plan_out,
290 no_worktrees,
291 } => {
292 let mut overrides = Overrides::from(&loop_flags);
293 overrides.worktrees = if no_worktrees { Some(false) } else { None };
294 overrides.close_skipped =
295 match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
296 (true, _) => Some(true),
297 (_, true) => Some(false),
298 _ => None,
299 };
300 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
301 let numbers = pick_issues(&repo, issues, common.limit)?;
302 if numbers.is_empty() {
303 return Ok(0);
304 }
305 let sorted = classify(&repo, &numbers)?;
306 let mut results = Vec::new();
307
308 if !sorted.issues.is_empty() {
309 let fetched = repo.fetch_issues(&sorted.issues)?;
310 let plan = make_plan(&agents, &cfg, &repo, &fetched, &plan_out)?;
311 act_on_plan(&cfg, &repo, &plan);
312 let mut ledger = Ledger::new();
313 for item in &plan.order {
314 let Some(issue) = fetched.iter().find(|i| i.number == item.issue) else {
315 continue;
316 };
317 results.push(review::run_issue(
318 &agents,
319 &cfg,
320 &repo,
321 item,
322 issue,
323 &mut ledger,
324 ));
325 }
326 }
327
328 for number in sorted.prs {
329 results.push(review::resume_pr(&agents, &cfg, &repo, number, None));
330 }
331
332 if results.is_empty() {
333 log!("nothing scheduled");
334 return Ok(0);
335 }
336 Ok(report(&results, &cfg))
337 }
338 Command::Resume {
339 prs,
340 common,
341 loop_flags,
342 next_actor,
343 } => {
344 let (cfg, repo, agents) = prepare(&common, Some(Overrides::from(&loop_flags)))?;
345 if let Some(name) = &next_actor {
346 if !cfg.has_agent(name) {
347 bail!("--next must be one of: {}", cfg.agent_names().join(", "));
348 }
349 }
350 let numbers = if prs.is_empty() {
351 let found = repo.list_open_prs(common.limit)?;
352 if found.is_empty() {
353 log!("no open PRs");
354 return Ok(0);
355 }
356 log!(
357 "no PRs given, taking {} open: {}",
358 found.len(),
359 found
360 .iter()
361 .map(|n| format!("#{n}"))
362 .collect::<Vec<_>>()
363 .join(", ")
364 );
365 found
366 } else {
367 prs
368 };
369 let sorted = classify(&repo, &numbers)?;
370 let mut results = Vec::new();
371 for number in sorted.prs {
372 results.push(review::resume_pr(
373 &agents,
374 &cfg,
375 &repo,
376 number,
377 next_actor.as_deref(),
378 ));
379 }
380 for number in sorted.issues {
383 match repo.open_pr_for_issue(number) {
384 Some(pr) => {
385 log!("#{number} is an issue; continuing its open PR {}", pr.url);
386 results.push(review::resume_pr(
387 &agents,
388 &cfg,
389 &repo,
390 pr.number,
391 next_actor.as_deref(),
392 ));
393 }
394 None => logwarn!(
395 "#{number} is an issue with no open pull request. Use `spar run {number}` \
396 to implement it."
397 ),
398 }
399 }
400 if results.is_empty() {
401 return Ok(0);
402 }
403 Ok(report(&results, &cfg))
404 }
405 }
406}
407
408#[derive(Debug, Default, Clone)]
413struct Overrides {
414 max_rounds: Option<u32>,
415 auto_merge: Option<bool>,
416 keep_worktrees: Option<bool>,
417 worktrees: Option<bool>,
418 close_skipped: Option<bool>,
419}
420
421impl From<&LoopFlags> for Overrides {
422 fn from(flags: &LoopFlags) -> Self {
423 Self {
424 max_rounds: flags.max_rounds,
425 auto_merge: flags.auto_merge.then_some(true),
426 keep_worktrees: flags.keep_worktrees.then_some(true),
427 worktrees: None,
428 close_skipped: None,
429 }
430 }
431}
432
433fn prepare(common: &Common, overrides: Option<Overrides>) -> Result<(Config, Repo, Vec<Agent>)> {
434 let mut cfg = config::load(common.config.as_deref())?;
435
436 if let Some(first) = &common.first {
437 if !cfg.has_agent(first) {
438 bail!("--first must be one of: {}", cfg.agent_names().join(", "));
439 }
440 cfg.first_implementor = first.clone();
441 }
442 if let Some(base) = &common.base {
443 cfg.loop_cfg.base_branch = base.clone();
444 }
445 if let Some(over) = overrides {
446 if let Some(v) = over.max_rounds {
447 if v == 0 {
448 bail!("--max-rounds must be at least 1");
449 }
450 cfg.loop_cfg.max_rounds = v;
451 }
452 if let Some(v) = over.auto_merge {
453 cfg.loop_cfg.auto_merge = v;
454 }
455 if let Some(v) = over.keep_worktrees {
456 cfg.loop_cfg.keep_worktrees = v;
457 }
458 if let Some(v) = over.worktrees {
459 cfg.loop_cfg.worktrees = v;
460 }
461 if let Some(v) = over.close_skipped {
462 cfg.loop_cfg.close_skipped = v;
463 }
464 }
465
466 let repo = Repo::open(&common.repo, &cfg)?;
467 if common.base.is_none() {
468 cfg.loop_cfg.base_branch = repo.default_branch(cfg.base_branch());
469 }
470
471 let agents = agent::build(&cfg)?;
472 if let Some(warning) = agent::correlation_warning(&agents) {
473 logging::warn(warning);
474 }
475
476 for stale in repo.prune_worktrees(false) {
478 let what = if stale.starts_with("branch ") {
479 stale
480 } else {
481 format!("worktree {stale}")
482 };
483 logdim!("cleaned up finished {what}");
484 }
485
486 log!("repo {} base {}", repo.root().display(), cfg.base_branch());
487 log!(
488 "agents: {}",
489 agents
490 .iter()
491 .map(|a| format!("{}={}", a.name(), a.spec.describe()))
492 .collect::<Vec<_>>()
493 .join(", ")
494 );
495 Ok((cfg, repo, agents))
496}
497
498fn pick_issues(repo: &Repo, given: Vec<i64>, limit: usize) -> Result<Vec<i64>> {
499 if !given.is_empty() {
500 return Ok(given);
501 }
502 let found = repo.list_open_issues(limit)?;
503 if found.is_empty() {
504 log!("no open issues");
505 return Ok(found);
506 }
507 log!(
508 "no issues given, taking {} open: {}",
509 found.len(),
510 found
511 .iter()
512 .map(|n| format!("#{n}"))
513 .collect::<Vec<_>>()
514 .join(", ")
515 );
516 Ok(found)
517}
518
519#[derive(Debug, Default)]
525struct Sorted {
526 issues: Vec<i64>,
527 prs: Vec<i64>,
528}
529
530fn classify(repo: &Repo, numbers: &[i64]) -> Result<Sorted> {
531 let mut sorted = Sorted::default();
532 for number in numbers {
533 match repo.item_kind(*number)? {
534 ItemKind::Issue => sorted.issues.push(*number),
535 ItemKind::Pr => sorted.prs.push(*number),
536 }
537 }
538 if !sorted.issues.is_empty() && !sorted.prs.is_empty() {
539 log!(
540 "{} issue(s) and {} pull request(s) given",
541 sorted.issues.len(),
542 sorted.prs.len()
543 );
544 }
545 Ok(sorted)
546}
547
548fn make_plan(
549 agents: &[Agent],
550 cfg: &Config,
551 repo: &Repo,
552 issues: &[Issue],
553 plan_out: &Path,
554) -> Result<Plan> {
555 let plan = triage::triage(agents, cfg, repo, issues)?;
556
557 std::fs::write(plan_out, serde_json::to_vec_pretty(&plan)?)
558 .map_err(|e| spar_err!("could not write {}: {e}", plan_out.display()))?;
559 log!("plan written to {}", plan_out.display());
560
561 for item in &plan.order {
562 log!(
563 " do #{} [{}/{}] {}",
564 item.issue,
565 item.complexity,
566 item.risk,
567 item.title
568 );
569 }
570 for item in &plan.skipped {
571 log!(" skip #{} (both reviewers: not worth doing)", item.issue);
572 }
573 for item in &plan.contested {
574 log!(" ?? #{} contested, parked for you to decide", item.issue);
575 }
576 Ok(plan)
577}
578
579fn act_on_plan(cfg: &Config, repo: &Repo, plan: &Plan) {
582 for item in &plan.skipped {
583 let body = review::skip_comment(item, &repo.style);
584 let outcome = if cfg.loop_cfg.close_skipped {
585 repo.close_issue(item.issue, &body)
586 } else {
587 repo.comment_issue(item.issue, &body)
588 };
589 match outcome {
590 Ok(()) if cfg.loop_cfg.close_skipped => log!(" closed #{}", item.issue),
591 Ok(()) => {}
592 Err(e) => logdim!("could not update #{}: {e}", item.issue),
593 }
594 }
595}
596
597fn cmd_scrub_filter() -> Result<i32> {
602 let mut input = String::new();
603 std::io::stdin()
604 .read_to_string(&mut input)
605 .map_err(|e| spar_err!("could not read a commit message from stdin: {e}"))?;
606 let out = style::scrub(&input, &crate::repo::style_from_env());
607 let mut stdout = std::io::stdout();
608 stdout
609 .write_all(out.as_bytes())
610 .and_then(|_| stdout.write_all(b"\n"))
611 .map_err(|e| spar_err!("could not write the scrubbed message: {e}"))?;
612 Ok(0)
613}
614
615fn cmd_clean(
616 repo_path: &Path,
617 config_path: Option<&Path>,
618 all: bool,
619 pr_state: bool,
620) -> Result<i32> {
621 let cfg = config::load(config_path)?;
622 let repo = Repo::open(repo_path, &cfg)?;
623 let mut removed = repo.prune_worktrees(all);
624 removed.extend(repo.prune_state());
625 if pr_state {
626 removed.extend(repo.prune_pr_state(None));
627 }
628 if removed.is_empty() {
629 println!("nothing to clean");
630 } else {
631 for item in removed {
632 println!("removed {item}");
633 }
634 }
635 Ok(0)
636}
637
638fn cmd_init(out: &Path, force: bool) -> Result<i32> {
639 if out.exists() && !force {
640 logging::error(format!(
641 "{} already exists, pass --force to overwrite",
642 out.display()
643 ));
644 return Ok(1);
645 }
646
647 let presets = config::available_presets();
648 if presets.is_empty() {
649 bail!("no presets available, which should be impossible in a released build");
650 }
651
652 let mut found: Vec<(String, PathBuf, config::AgentSpec)> = Vec::new();
653 for name in &presets {
654 let raw = config::load_preset(name)?;
655 let mut spec: config::AgentSpec = match raw
659 .as_table()
660 .cloned()
661 .ok_or_else(|| spar_err!("not a table"))
662 .and_then(|t| {
663 toml::Value::Table(t)
664 .try_into()
665 .map_err(|e| spar_err!("{e}"))
666 }) {
667 Ok(spec) => spec,
668 Err(e) => {
669 println!(" BROKEN {name:10} {}", e.first_line());
670 continue;
671 }
672 };
673 spec.name = name.clone();
674 match Agent::new(spec.clone()).resolve_bin() {
675 Ok(path) => {
676 println!(" found {name:10} {}", path.display());
677 found.push((name.clone(), path.to_path_buf(), spec));
678 }
679 Err(_) => println!(" missing {name}"),
680 }
681 }
682
683 if found.len() < 2 {
684 logging::error(format!(
685 "need two agent CLIs, found {}. Install another, or write {} by hand using the \
686 presets as a reference.",
687 found.len(),
688 out.display()
689 ));
690 return Ok(1);
691 }
692
693 let chosen: Vec<&(String, PathBuf, config::AgentSpec)> = found.iter().take(2).collect();
695 if found.len() > 2 {
696 log!(
697 "{} agents available, picking {} and {}. Edit {} to change.",
698 found.len(),
699 chosen[0].0,
700 chosen[1].0,
701 out.display()
702 );
703 }
704
705 let mut text = String::from(
706 "# Generated by `spar init`. Each agent inherits a command template from a\n\
707 # built in preset; anything set here overrides it.\n\
708 #\n\
709 # Commented lines are the other options, each with a working value.\n\
710 # Uncomment one to change it.\n\n",
711 );
712 for (name, _, spec) in &chosen {
713 text.push_str(&agent_block(name, spec));
714 }
715 text.push_str(&format!(
716 "[loop]\n\
717 max_rounds = 3 # review rounds ONE invocation may spend.\n\
718 # Resuming grants a fresh budget, so this\n\
719 # is not a lifetime cap on a PR.\n\
720 auto_merge = false # off on purpose: two models agreeing is\n\
721 # not the same as being right\n\
722 first_implementor = \"{}\"\n\
723 worktrees = true # false works in the main checkout\n\
724 close_skipped = true # close an issue both reviewers declined\n\
725 followups = \"issues\" # issues | local | none\n\
726 # keep_worktrees = false # true leaves them behind to inspect\n\
727 # parallel_triage = true # false asks the agents one at a time\n\
728 # file_nits = false # true files nits as issues too\n\
729 # base_branch = \"main\" # only a fallback; origin/HEAD wins\n\
730 # branch_prefix = \"\" # e.g. \"spar/\" to namespace branches\n\
731 # state_store = \"local\" # local | pr | both\n\n\
732 [loop.effort_schedule]\n\
733 # Values are whatever each agent's own CLI accepts, listed above.\n\
734 # round_1 = \"high\" # the deep first review\n\
735 # rest = \"low\" # later rounds only see a small delta\n\n\
736 [style]\n\
737 ban_em_dash = true\n\
738 ban_ai_attribution = true\n\
739 terse = true # hold model prose to a length budget\n\
740 # max_title_chars = 90 # a finding, issue, or PR title\n\
741 # max_summary_chars = 200 # a one line verdict or refutation\n\
742 # max_detail_chars = 320 # a blocking finding, in the PR thread\n\
743 # max_body_chars = 900 # a filed issue's body\n",
744 chosen[0].0
745 ));
746
747 std::fs::write(out, text).map_err(|e| spar_err!("could not write {}: {e}", out.display()))?;
748 println!("\nwrote {}", out.display());
749 println!("Next: `spar doctor` to check it, then `spar run` in a repo you have push access to.");
750 Ok(0)
751}
752
753type Probe = Box<dyn Fn() -> Result<String>>;
756
757fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
764 let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
765 out.push_str("# Omit model or effort to use the CLI's own default.\n");
766
767 fn suggested(choices: &[String]) -> &str {
771 choices.first().map(String::as_str).unwrap_or("...")
772 }
773 let assignments = [
774 format!("# model = \"{}\"", suggested(&spec.models)),
775 format!("# effort = \"{}\"", suggested(&spec.efforts)),
776 ];
777 let column = assignments
779 .iter()
780 .map(|a| a.chars().count())
781 .max()
782 .unwrap_or(0)
783 + 3;
784 for (assignment, choices) in assignments.iter().zip([&spec.models, &spec.efforts]) {
785 out.push_str(assignment);
786 if choices.len() > 1 {
787 let pad = column.saturating_sub(assignment.chars().count());
788 out.push_str(&" ".repeat(pad));
789 out.push_str(&format!("# {}", choices.join(" | ")));
790 }
791 out.push('\n');
792 }
793
794 if let Some(note) = &spec.options_note {
795 out.push_str(&wrap_comment(note));
796 }
797 out.push('\n');
798 out
799}
800
801fn wrap_comment(text: &str) -> String {
803 const WIDTH: usize = 76;
804 let mut out = String::new();
805 let mut line = String::from("#");
806 for word in text.split_whitespace() {
807 if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
808 out.push_str(&line);
809 out.push('\n');
810 line = String::from("#");
811 }
812 line.push(' ');
813 line.push_str(word);
814 }
815 if line.len() > 1 {
816 out.push_str(&line);
817 out.push('\n');
818 }
819 out
820}
821
822fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
823 let mut ok = true;
824
825 let probes: Vec<(&str, Probe)> = vec![
826 (
827 "git",
828 Box::new(|| {
829 proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
830 .map(|s| first_line(&s))
831 }),
832 ),
833 (
834 "gh",
835 Box::new(|| {
836 proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
837 .map(|s| first_line(&s))
838 }),
839 ),
840 (
841 "gh auth",
842 Box::new(|| {
843 let out = proc::exec(
844 &["gh".into(), "auth".into(), "status".into()],
845 &ExecOpts::new().check(false).timeout_secs(60),
846 )?;
847 let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
848 if out.ok() {
849 Ok(first_line(&text))
850 } else {
851 Err(spar_err!("not authenticated. Run `gh auth login`."))
852 }
853 }),
854 ),
855 ];
856
857 for (label, probe) in probes {
858 match probe() {
859 Ok(detail) => println!(" ok {label:12} {detail}"),
860 Err(e) => {
861 println!(" FAIL {label:12} {}", e.first_line());
862 ok = false;
863 }
864 }
865 }
866
867 let found = config::find_config(config_path)?;
868 let Some(path) = found else {
869 println!("\n no spar.toml found. Run `spar init` to generate one.");
870 println!(
871 " presets available: {}",
872 config::available_presets().join(", ")
873 );
874 return Ok(if ok { 0 } else { 1 });
875 };
876
877 println!("\n config: {}", path.display());
878 let cfg = match config::load(Some(&path)) {
879 Ok(cfg) => cfg,
880 Err(e) => {
881 println!(" FAIL config {e}");
882 return Ok(1);
883 }
884 };
885
886 let mut resolved = Vec::new();
889 for spec in &cfg.agents {
890 let agent = Agent::new(spec.clone());
891 match agent.resolve_bin() {
892 Ok(bin) => {
893 println!(
894 " ok {:12} {} ({})",
895 spec.name,
896 bin.display(),
897 spec.describe()
898 );
899 resolved.push(agent);
900 }
901 Err(e) => {
902 println!(" FAIL {:12} {}", spec.name, e.first_line());
903 ok = false;
904 }
905 }
906 }
907
908 if resolved.len() == cfg.agents.len() {
909 if let Some(warning) = agent::correlation_warning(&resolved) {
910 println!("\n WARNING {warning}");
911 }
912 }
913
914 println!(
915 "\n settings: max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
916 cfg.loop_cfg.max_rounds,
917 cfg.loop_cfg.auto_merge,
918 cfg.loop_cfg.worktrees,
919 cfg.loop_cfg.followups,
920 cfg.style.terse
921 );
922 println!(
923 "{}",
924 if ok {
925 "\nready"
926 } else {
927 "\nmissing prerequisites"
928 }
929 );
930 Ok(if ok { 0 } else { 1 })
931}
932
933fn first_line(text: &str) -> String {
934 text.trim().lines().next().unwrap_or("").trim().to_string()
935}
936
937fn report(results: &[IssueRun], cfg: &Config) -> i32 {
942 println!("\n{}", "=".repeat(60));
943 for r in results {
944 println!(
945 "#{:<5} {:<10} rounds={} {}",
946 r.issue,
947 r.status.to_string(),
948 r.rounds,
949 r.pr.as_deref().unwrap_or("")
950 );
951 for note in &r.notes {
952 println!(" {}", first_line(note));
953 }
954 for url in &r.filed {
955 println!(" filed {url}");
956 }
957 for dispute in &r.disputes {
958 println!(" disputed: {}", dispute.title);
959 }
960 }
961 println!("{}", "=".repeat(60));
962
963 if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
964 println!("\nApproved PRs are waiting on you to merge.");
965 }
966 if results.iter().all(IssueRun::succeeded) {
967 0
968 } else {
969 1
970 }
971}
972
973#[cfg(test)]
974mod tests {
975 use super::*;
976 use clap::CommandFactory;
977
978 #[test]
979 fn the_parser_is_internally_consistent() {
980 Cli::command().debug_assert();
981 }
982
983 #[test]
984 fn quiet_is_accepted_before_or_after_the_subcommand() {
985 for argv in [
986 vec!["spar", "--quiet", "run", "42"],
987 vec!["spar", "run", "42", "--quiet"],
988 vec!["spar", "resume", "--quiet"],
989 vec!["spar", "init", "-q"],
990 ] {
991 assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
992 }
993 assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
994 }
995
996 #[test]
997 fn several_issue_numbers_are_accepted() {
998 let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
999 match cli.command {
1000 Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
1001 other => panic!("{other:?}"),
1002 }
1003 }
1004
1005 #[test]
1006 fn issue_numbers_and_flags_can_be_interleaved() {
1007 let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
1008 match cli.command {
1009 Command::Run {
1010 issues, loop_flags, ..
1011 } => {
1012 assert_eq!(vec![42, 51], issues);
1013 assert!(loop_flags.auto_merge);
1014 }
1015 other => panic!("{other:?}"),
1016 }
1017 }
1018
1019 #[test]
1020 fn every_command_that_reads_a_config_accepts_one() {
1021 for argv in [
1022 vec!["spar", "run", "42"],
1023 vec!["spar", "triage"],
1024 vec!["spar", "resume"],
1025 vec!["spar", "clean"],
1026 vec!["spar", "doctor"],
1027 ] {
1028 let mut full = argv.clone();
1029 full.extend(["--config", "other.toml"]);
1030 let cli = Cli::parse_from(&full);
1031 let config = match cli.command {
1032 Command::Run { common, .. }
1033 | Command::Triage { common, .. }
1034 | Command::Resume { common, .. } => common.config,
1035 Command::Clean { config, .. } | Command::Doctor { config } => config,
1036 other => panic!("{other:?}"),
1037 };
1038 assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
1039 }
1040 }
1041
1042 #[test]
1043 fn auto_merge_is_off_unless_asked_for() {
1044 let cli = Cli::parse_from(["spar", "run"]);
1045 match cli.command {
1046 Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
1047 other => panic!("{other:?}"),
1048 }
1049 }
1050
1051 #[test]
1052 fn the_two_close_skipped_flags_are_mutually_exclusive() {
1053 assert!(
1054 Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
1055 );
1056 }
1057
1058 #[test]
1061 fn close_skipped_is_offered_only_where_it_means_something() {
1062 assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
1063 assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
1064 assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
1065 assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
1066 assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
1067 }
1068
1069 #[test]
1070 fn the_close_skipped_pair_resolves_to_a_tristate() {
1071 let read = |argv: &[&str]| match Cli::parse_from(argv).command {
1072 Command::Run { triage_flags, .. } => {
1073 match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
1074 (true, _) => Some(true),
1075 (_, true) => Some(false),
1076 _ => None,
1077 }
1078 }
1079 other => panic!("{other:?}"),
1080 };
1081 assert_eq!(None, read(&["spar", "run"]));
1082 assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
1083 assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
1084 }
1085
1086 #[test]
1087 fn the_default_limit_is_twenty() {
1088 let cli = Cli::parse_from(["spar", "run"]);
1089 match cli.command {
1090 Command::Run { common, .. } => assert_eq!(20, common.limit),
1091 other => panic!("{other:?}"),
1092 }
1093 }
1094
1095 #[test]
1096 fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
1097 assert!(matches!(
1098 Cli::parse_from(["spar", "scrub-filter"]).command,
1099 Command::ScrubFilter
1100 ));
1101 let help = Cli::command().render_long_help().to_string();
1102 assert!(
1103 !help.contains("scrub-filter"),
1104 "it is plumbing, not a command"
1105 );
1106 }
1107
1108 #[test]
1109 fn review_takes_pr_numbers_and_a_dry_run() {
1110 let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
1111 match cli.command {
1112 Command::Review { items, dry_run, .. } => {
1113 assert_eq!(vec![101, 102], items);
1114 assert!(dry_run);
1115 }
1116 other => panic!("{other:?}"),
1117 }
1118 }
1119
1120 #[test]
1121 fn review_posts_unless_told_not_to() {
1122 match Cli::parse_from(["spar", "review", "101"]).command {
1123 Command::Review { dry_run, .. } => assert!(!dry_run),
1124 other => panic!("{other:?}"),
1125 }
1126 }
1127
1128 #[test]
1129 fn review_with_no_numbers_is_allowed() {
1130 match Cli::parse_from(["spar", "review"]).command {
1131 Command::Review { items, .. } => assert!(items.is_empty()),
1132 other => panic!("{other:?}"),
1133 }
1134 }
1135
1136 #[test]
1137 fn review_takes_its_own_round_budget() {
1138 match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
1139 Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
1140 other => panic!("{other:?}"),
1141 }
1142 }
1143
1144 #[test]
1145 fn resume_takes_a_next_override() {
1146 let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
1147 match cli.command {
1148 Command::Resume {
1149 prs, next_actor, ..
1150 } => {
1151 assert_eq!(vec![108], prs);
1152 assert_eq!(Some("codex".to_string()), next_actor);
1153 }
1154 other => panic!("{other:?}"),
1155 }
1156 }
1157}