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