Skip to main content

spar/
cli.rs

1//! The command line.
2
3use 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    /// Suppress progress logging. Warnings, errors, and the final summary still print.
36    #[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    /// Triage the issues, then work them in dependency order.
46    Run {
47        /// Issue numbers. Omit to take every open issue, up to --limit.
48        issues: Vec<i64>,
49        #[command(flatten)]
50        common: Common,
51        #[command(flatten)]
52        loop_flags: LoopFlags,
53        #[command(flatten)]
54        triage_flags: TriageFlags,
55        /// Where to write the triage plan.
56        #[arg(long, default_value = "plan.json")]
57        plan_out: PathBuf,
58        /// Work in the main checkout instead of an isolated worktree per issue.
59        #[arg(long)]
60        no_worktrees: bool,
61    },
62
63    /// Triage only. Writes the plan and touches nothing else.
64    Triage {
65        /// Issue numbers. Omit to take every open issue, up to --limit.
66        issues: Vec<i64>,
67        #[command(flatten)]
68        common: Common,
69        #[arg(long, default_value = "plan.json")]
70        plan_out: PathBuf,
71    },
72
73    /// Continue the review loop on existing PRs, including ones spar did not create.
74    Resume {
75        /// Pull request numbers. Omit to take every open PR, up to --limit.
76        prs: Vec<i64>,
77        #[command(flatten)]
78        common: Common,
79        #[command(flatten)]
80        loop_flags: LoopFlags,
81        /// Which agent reviews next, overriding the PR's saved state.
82        #[arg(long = "next", value_name = "AGENT")]
83        next_actor: Option<String>,
84    },
85
86    /// Review pull requests without changing them, including from a fork.
87    ///
88    /// Both agents review independently, then rule on each other's findings,
89    /// then answer the objections. Nothing is committed, pushed, or merged.
90    Review {
91        /// Pull request numbers. An issue number resolves to its open PR.
92        /// Omit to take every open PR, up to --limit.
93        items: Vec<i64>,
94        #[command(flatten)]
95        common: Common,
96        /// Print the review instead of posting it.
97        #[arg(long)]
98        dry_run: bool,
99        /// Adjudication passes. 1 is two independent reviews with no
100        /// cross-checking, 2 adds it, 3 adds a rebuttal on what they dispute.
101        #[arg(long)]
102        max_rounds: Option<u32>,
103    },
104
105    /// Detect installed agent CLIs and write a spar.toml.
106    Init {
107        #[arg(long, default_value = "spar.toml")]
108        out: PathBuf,
109        /// Overwrite an existing config.
110        #[arg(long)]
111        force: bool,
112    },
113
114    /// Remove worktrees, branches, and state whose PR is merged or closed.
115    Clean {
116        #[arg(long, default_value = ".")]
117        repo: PathBuf,
118        #[arg(long)]
119        config: Option<PathBuf>,
120        /// Remove every worktree and branch spar created, even for open PRs.
121        #[arg(long)]
122        all: bool,
123        /// Also delete state comments left on finished PRs.
124        #[arg(long)]
125        pr_state: bool,
126    },
127
128    /// Check prerequisites and resolve each configured agent.
129    Doctor {
130        #[arg(long)]
131        config: Option<PathBuf>,
132    },
133
134    /// Read a commit message on stdin and write the scrubbed version to stdout.
135    ///
136    /// Used by `git filter-branch`, not by people.
137    #[command(hide = true)]
138    ScrubFilter,
139}
140
141#[derive(Args, Debug, Clone)]
142pub struct Common {
143    /// Path to the git repository.
144    #[arg(long, default_value = ".")]
145    pub repo: PathBuf,
146    /// Path to spar.toml.
147    #[arg(long)]
148    pub config: Option<PathBuf>,
149    /// Base branch. Defaults to whatever origin/HEAD points at.
150    #[arg(long)]
151    pub base: Option<String>,
152    /// Which agent implements first. A key from the [agents] table.
153    #[arg(long)]
154    pub first: Option<String>,
155    /// Cap on how many open items to take when none are named.
156    #[arg(long, default_value_t = 20)]
157    pub limit: usize,
158}
159
160#[derive(Args, Debug, Clone)]
161pub struct LoopFlags {
162    /// Review rounds this run may spend before escalating. Resuming grants a
163    /// fresh budget; it is not a lifetime cap on the pull request.
164    #[arg(long)]
165    pub max_rounds: Option<u32>,
166    /// Merge when no blocking findings remain. Off by default, deliberately.
167    #[arg(long)]
168    pub auto_merge: bool,
169    /// Leave worktrees in place after a run, for inspection.
170    #[arg(long)]
171    pub keep_worktrees: bool,
172    /// Waves of newly filed follow-ups to fold back into this run instead of
173    /// leaving them for the next one. Each wave is triaged like any issue.
174    #[arg(long, value_name = "N")]
175    pub absorb: Option<u32>,
176}
177
178/// Only `run` triages, so only `run` can decline an issue. Offering these on
179/// `resume` would accept a flag that does nothing.
180#[derive(Args, Debug, Clone)]
181pub struct TriageFlags {
182    /// Close an issue both agents declined, after posting the reasoning.
183    #[arg(long, conflicts_with = "no_close_skipped")]
184    pub close_skipped: bool,
185    /// Comment on a declined issue but leave it open.
186    #[arg(long)]
187    pub no_close_skipped: bool,
188}
189
190// ---------------------------------------------------------------------------
191// Entry
192// ---------------------------------------------------------------------------
193
194pub 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            // Deliberately no act_on_plan here. `triage` is the command you
284            // reach for to look before leaping, and a preview that comments on
285            // and closes issues is a trap.
286            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            // Wave 0 is what was asked for. Each further wave is the follow-ups
317            // the previous one filed, folded back in rather than left for the
318            // next run. Every wave is triaged like anything else, so both
319            // agents still have to agree each one is worth doing.
320            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                // Whatever this wave filed becomes the next one.
368                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            // An issue number handed to `resume` is not a mistake worth
440            // refusing over. If work is already open for it, continue that.
441            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// ---------------------------------------------------------------------------
468// Shared setup
469// ---------------------------------------------------------------------------
470
471#[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    // Sweep finished worktrees before starting, so they cannot accumulate.
541    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/// Numbers split by what they actually name.
584///
585/// Issues and pull requests share one number sequence per repository, so a
586/// person should not have to remember which command takes which. Both `run` and
587/// `resume` sort the numbers themselves and route each one.
588#[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
643/// Post the shared reasoning on every issue both agents declined, and close it
644/// when the config says so. Contested issues are never touched.
645fn 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
661// ---------------------------------------------------------------------------
662// Subcommands
663// ---------------------------------------------------------------------------
664
665fn 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        // A preset that will not build is a broken preset, not an uninstalled
720        // CLI. Skipping it silently reported it as "missing" and sent people
721        // looking for an install problem that was not there.
722        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    // Prefer a pair that cannot share blind spots, if one is available.
758    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
821/// One prerequisite check: a label and something that either reports a version
822/// or explains what is missing.
823type Probe = Box<dyn Fn() -> Result<String>>;
824
825/// One agent's block, with the options commented out beside a working value.
826///
827/// The values come from the preset rather than from here, so a CLI that adds a
828/// model is a file edit. They are hints only: nothing validates against them,
829/// because a stale list that refused a model which actually works would be
830/// worse than no hint at all.
831fn 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    // The first entry of each list is the one written as the suggested value,
836    // which is why the presets put the sensible default there rather than in
837    // whatever order a CLI's help happens to print.
838    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    // Line the comments up, so the file reads as a column rather than a jumble.
846    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
869/// Wrap a note across comment lines so a long one does not run off the edge.
870fn 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    // Kept apart from `ok`: a missing gh says nothing about whether the two
955    // agents are the same CLI, and must not silence the warning below.
956    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
1005// ---------------------------------------------------------------------------
1006// Reporting
1007// ---------------------------------------------------------------------------
1008
1009fn 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    /// Only `run` triages, so only `run` can decline an issue. Accepting the
1127    /// flag on `resume` would silently do nothing.
1128    #[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}