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