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