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    /// Post a review a dry run produced, without running the agents again.
106    ///
107    /// `spar review <pr> --dry-run` saves what it produced. Read it, edit the
108    /// file if you like, then post exactly that.
109    Post {
110        /// Pull request numbers whose saved review should be posted.
111        #[arg(required = true)]
112        prs: Vec<i64>,
113        #[arg(long, default_value = ".")]
114        repo: PathBuf,
115        #[arg(long)]
116        config: Option<PathBuf>,
117        /// Post this file instead of the saved review.
118        #[arg(long, value_name = "PATH")]
119        file: Option<PathBuf>,
120        /// Print what would be posted and stop.
121        #[arg(long)]
122        dry_run: bool,
123    },
124
125    /// Detect installed agent CLIs and write a spar.toml.
126    ///
127    /// On an existing config, `--update` appends any settings it does not
128    /// mention, which is how to pick up options added by a newer release.
129    Init {
130        #[arg(long, default_value = "spar.toml")]
131        out: PathBuf,
132        /// Overwrite an existing config.
133        #[arg(long)]
134        force: bool,
135        /// Append settings the existing config does not mention, as comments.
136        /// Nothing already in the file is changed.
137        #[arg(long, conflicts_with = "force")]
138        update: bool,
139    },
140
141    /// Remove worktrees, branches, and state whose PR is merged or closed.
142    Clean {
143        #[arg(long, default_value = ".")]
144        repo: PathBuf,
145        #[arg(long)]
146        config: Option<PathBuf>,
147        /// Remove every worktree and branch spar created, even for open PRs.
148        #[arg(long)]
149        all: bool,
150        /// Also delete state comments left on finished PRs.
151        #[arg(long)]
152        pr_state: bool,
153    },
154
155    /// Check prerequisites and resolve each configured agent.
156    Doctor {
157        #[arg(long)]
158        config: Option<PathBuf>,
159    },
160
161    /// Read a commit message on stdin and write the scrubbed version to stdout.
162    ///
163    /// Used by `git filter-branch`, not by people.
164    #[command(hide = true)]
165    ScrubFilter,
166}
167
168#[derive(Args, Debug, Clone)]
169pub struct Common {
170    /// Path to the git repository.
171    #[arg(long, default_value = ".")]
172    pub repo: PathBuf,
173    /// Path to spar.toml.
174    #[arg(long)]
175    pub config: Option<PathBuf>,
176    /// Base branch. Defaults to whatever origin/HEAD points at.
177    #[arg(long)]
178    pub base: Option<String>,
179    /// Which agent implements first. A key from the [agents] table.
180    #[arg(long)]
181    pub first: Option<String>,
182    /// Cap on how many open items to take when none are named.
183    #[arg(long, default_value_t = 20)]
184    pub limit: usize,
185    /// Ignore issues and pull requests numbered below this when picking for
186    /// itself. A number you name explicitly is always honoured.
187    #[arg(long, value_name = "N")]
188    pub min_number: Option<i64>,
189    /// Extra instructions for both agents, for this run only. Added to any
190    /// already in the config rather than replacing them.
191    #[arg(long, value_name = "TEXT")]
192    pub instructions: Option<String>,
193}
194
195#[derive(Args, Debug, Clone)]
196pub struct LoopFlags {
197    /// Review rounds this run may spend before escalating. Resuming grants a
198    /// fresh budget; it is not a lifetime cap on the pull request.
199    #[arg(long)]
200    pub max_rounds: Option<u32>,
201    /// Merge when no blocking findings remain. Off by default, deliberately.
202    #[arg(long)]
203    pub auto_merge: bool,
204    /// Leave worktrees in place after a run, for inspection.
205    #[arg(long)]
206    pub keep_worktrees: bool,
207    /// Waves of newly filed follow-ups to fold back into this run instead of
208    /// leaving them for the next one. Each wave is triaged like any issue.
209    #[arg(long, value_name = "N")]
210    pub absorb: Option<u32>,
211}
212
213/// Only `run` triages, so only `run` can decline an issue. Offering these on
214/// `resume` would accept a flag that does nothing.
215#[derive(Args, Debug, Clone)]
216pub struct TriageFlags {
217    /// Close an issue both agents declined, after posting the reasoning.
218    #[arg(long, conflicts_with = "no_close_skipped")]
219    pub close_skipped: bool,
220    /// Comment on a declined issue but leave it open.
221    #[arg(long)]
222    pub no_close_skipped: bool,
223}
224
225// ---------------------------------------------------------------------------
226// Entry
227// ---------------------------------------------------------------------------
228
229pub fn main() -> i32 {
230    let cli = Cli::parse();
231    logging::init_color();
232    logging::set_quiet(cli.quiet);
233
234    match dispatch(cli) {
235        Ok(code) => code,
236        Err(e) => {
237            logging::error(e.to_string());
238            2
239        }
240    }
241}
242
243fn dispatch(cli: Cli) -> Result<i32> {
244    match cli.command {
245        Command::ScrubFilter => cmd_scrub_filter(),
246        Command::Doctor { config } => cmd_doctor(config.as_deref()),
247        Command::Review {
248            items,
249            common,
250            dry_run,
251            max_rounds,
252        } => {
253            let overrides = Overrides {
254                max_rounds,
255                ..Overrides::default()
256            };
257            let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
258            let numbers = if items.is_empty() {
259                let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
260                if found.is_empty() {
261                    log!("no open PRs");
262                    return Ok(0);
263                }
264                log!("no PRs given, reviewing {} open", found.len());
265                found
266            } else {
267                items
268            };
269            let sorted = classify(&repo, &numbers)?;
270            let mut targets = sorted.prs;
271            for number in sorted.issues {
272                match repo.open_pr_for_issue(number) {
273                    Some(pr) => {
274                        log!("#{number} is an issue; reviewing its open PR {}", pr.url);
275                        targets.push(pr.number);
276                    }
277                    None => logwarn!("#{number} is an issue with no open pull request to review"),
278                }
279            }
280            let mut results = Vec::new();
281            for number in targets {
282                results.push(review_only::review_pr(
283                    &agents, &cfg, &repo, number, dry_run,
284                ));
285            }
286            if results.is_empty() {
287                return Ok(0);
288            }
289            Ok(report(&results, &cfg))
290        }
291
292        Command::Post {
293            prs,
294            repo: repo_path,
295            config,
296            file,
297            dry_run,
298        } => cmd_post(
299            &prs,
300            &repo_path,
301            config.as_deref(),
302            file.as_deref(),
303            dry_run,
304        ),
305
306        Command::Init { out, force, update } => {
307            if update {
308                cmd_init_update(&out)
309            } else {
310                cmd_init(&out, force)
311            }
312        }
313        Command::Clean {
314            repo,
315            config,
316            all,
317            pr_state,
318        } => cmd_clean(&repo, config.as_deref(), all, pr_state),
319        Command::Triage {
320            issues,
321            common,
322            plan_out,
323        } => {
324            let (cfg, repo, agents) = prepare(&common, None)?;
325            let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
326            if numbers.is_empty() {
327                return Ok(0);
328            }
329            let sorted = classify(&repo, &numbers)?;
330            for number in &sorted.prs {
331                log!("#{number} is a pull request, nothing to triage");
332            }
333            if sorted.issues.is_empty() {
334                log!("no issues to triage");
335                return Ok(0);
336            }
337            let issues = repo.fetch_issues(&sorted.issues)?;
338            // Deliberately no act_on_plan here. `triage` is the command you
339            // reach for to look before leaping, and a preview that comments on
340            // and closes issues is a trap.
341            make_plan(&agents, &cfg, &repo, &issues, &plan_out)?;
342            Ok(0)
343        }
344        Command::Run {
345            issues,
346            common,
347            loop_flags,
348            triage_flags,
349            plan_out,
350            no_worktrees,
351        } => {
352            let mut overrides = Overrides::from(&loop_flags);
353            overrides.worktrees = if no_worktrees { Some(false) } else { None };
354            overrides.close_skipped =
355                match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
356                    (true, _) => Some(true),
357                    (_, true) => Some(false),
358                    _ => None,
359                };
360            let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
361            let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
362            if numbers.is_empty() {
363                return Ok(0);
364            }
365            let sorted = classify(&repo, &numbers)?;
366            let mut results = Vec::new();
367            let mut ledger = Ledger::new();
368            let mut handled: BTreeSet<i64> = BTreeSet::new();
369            let mut wave = sorted.issues.clone();
370
371            // Wave 0 is what was asked for. Each further wave is the follow-ups
372            // the previous one filed, folded back in rather than left for the
373            // next run. Every wave is triaged like anything else, so both
374            // agents still have to agree each one is worth doing.
375            for round in 0..=cfg.loop_cfg.absorb_new_issues {
376                wave.retain(|n| !handled.contains(n));
377                if wave.is_empty() {
378                    break;
379                }
380                if round > 0 {
381                    log!(
382                        "absorbing {} newly filed issue(s): {}",
383                        wave.len(),
384                        wave.iter()
385                            .map(|n| format!("#{n}"))
386                            .collect::<Vec<_>>()
387                            .join(", ")
388                    );
389                }
390                handled.extend(wave.iter().copied());
391
392                let fetched = match repo.fetch_issues(&wave) {
393                    Ok(fetched) => fetched,
394                    Err(e) => {
395                        logdim!("could not read the next wave: {e}");
396                        break;
397                    }
398                };
399                let plan_path = if round == 0 {
400                    plan_out.clone()
401                } else {
402                    plan_out.with_extension(format!("wave{round}.json"))
403                };
404                let plan = make_plan(&agents, &cfg, &repo, &fetched, &plan_path)?;
405                act_on_plan(&cfg, &repo, &plan);
406
407                let before = results.len();
408                for item in &plan.order {
409                    let Some(issue) = fetched.iter().find(|i| i.number == item.issue) else {
410                        continue;
411                    };
412                    results.push(review::run_issue(
413                        &agents,
414                        &cfg,
415                        &repo,
416                        item,
417                        issue,
418                        &mut ledger,
419                    ));
420                }
421
422                // Whatever this wave filed becomes the next one.
423                wave = results[before..]
424                    .iter()
425                    .flat_map(|r| r.filed.iter())
426                    .filter_map(|url| review::filed_issue_number(url))
427                    .collect::<BTreeSet<_>>()
428                    .into_iter()
429                    .collect();
430            }
431            if !wave.is_empty() && cfg.loop_cfg.absorb_new_issues > 0 {
432                log!(
433                    "{} issue(s) filed in the last wave were left for a later run: {}",
434                    wave.len(),
435                    wave.iter()
436                        .map(|n| format!("#{n}"))
437                        .collect::<Vec<_>>()
438                        .join(", ")
439                );
440            }
441
442            for number in sorted.prs {
443                results.push(review::resume_pr(&agents, &cfg, &repo, number, None));
444            }
445
446            if results.is_empty() {
447                log!("nothing scheduled");
448                return Ok(0);
449            }
450            Ok(report(&results, &cfg))
451        }
452        Command::Resume {
453            prs,
454            common,
455            loop_flags,
456            next_actor,
457        } => {
458            let (cfg, repo, agents) = prepare(&common, Some(Overrides::from(&loop_flags)))?;
459            if let Some(name) = &next_actor {
460                if !cfg.has_agent(name) {
461                    bail!("--next must be one of: {}", cfg.agent_names().join(", "));
462                }
463            }
464            let numbers = if prs.is_empty() {
465                let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
466                if found.is_empty() {
467                    log!("no open PRs");
468                    return Ok(0);
469                }
470                log!(
471                    "no PRs given, taking {} open: {}",
472                    found.len(),
473                    found
474                        .iter()
475                        .map(|n| format!("#{n}"))
476                        .collect::<Vec<_>>()
477                        .join(", ")
478                );
479                found
480            } else {
481                prs
482            };
483            let sorted = classify(&repo, &numbers)?;
484            let mut results = Vec::new();
485            for number in sorted.prs {
486                results.push(review::resume_pr(
487                    &agents,
488                    &cfg,
489                    &repo,
490                    number,
491                    next_actor.as_deref(),
492                ));
493            }
494            // An issue number handed to `resume` is not a mistake worth
495            // refusing over. If work is already open for it, continue that.
496            for number in sorted.issues {
497                match repo.open_pr_for_issue(number) {
498                    Some(pr) => {
499                        log!("#{number} is an issue; continuing its open PR {}", pr.url);
500                        results.push(review::resume_pr(
501                            &agents,
502                            &cfg,
503                            &repo,
504                            pr.number,
505                            next_actor.as_deref(),
506                        ));
507                    }
508                    None => logwarn!(
509                        "#{number} is an issue with no open pull request. Use `spar run {number}` \
510                         to implement it."
511                    ),
512                }
513            }
514            if results.is_empty() {
515                return Ok(0);
516            }
517            Ok(report(&results, &cfg))
518        }
519    }
520}
521
522// ---------------------------------------------------------------------------
523// Shared setup
524// ---------------------------------------------------------------------------
525
526#[derive(Debug, Default, Clone)]
527struct Overrides {
528    max_rounds: Option<u32>,
529    auto_merge: Option<bool>,
530    keep_worktrees: Option<bool>,
531    worktrees: Option<bool>,
532    close_skipped: Option<bool>,
533    absorb: Option<u32>,
534}
535
536impl From<&LoopFlags> for Overrides {
537    fn from(flags: &LoopFlags) -> Self {
538        Self {
539            max_rounds: flags.max_rounds,
540            auto_merge: flags.auto_merge.then_some(true),
541            keep_worktrees: flags.keep_worktrees.then_some(true),
542            worktrees: None,
543            close_skipped: None,
544            absorb: flags.absorb,
545        }
546    }
547}
548
549fn prepare(common: &Common, overrides: Option<Overrides>) -> Result<(Config, Repo, Vec<Agent>)> {
550    let mut cfg = config::load(common.config.as_deref())?;
551
552    if let Some(first) = &common.first {
553        if !cfg.has_agent(first) {
554            bail!("--first must be one of: {}", cfg.agent_names().join(", "));
555        }
556        cfg.first_implementor = first.clone();
557    }
558    if let Some(base) = &common.base {
559        cfg.loop_cfg.base_branch = base.clone();
560    }
561    if let Some(min) = common.min_number {
562        cfg.loop_cfg.min_number = min;
563    }
564    // Added to the config's, not in place of them. One is what this repository
565    // always wants and the other is what today wants, and a flag that silenced
566    // the standing set would be a trap: you would notice it the run after.
567    if let Some(extra) = common.instructions.as_deref().map(str::trim) {
568        if !extra.is_empty() {
569            let standing = cfg.loop_cfg.instructions.trim();
570            cfg.loop_cfg.instructions = if standing.is_empty() {
571                extra.to_string()
572            } else {
573                format!("{standing}\n{extra}")
574            };
575        }
576    }
577    if let Some(over) = overrides {
578        if let Some(v) = over.max_rounds {
579            if v == 0 {
580                bail!("--max-rounds must be at least 1");
581            }
582            cfg.loop_cfg.max_rounds = v;
583        }
584        if let Some(v) = over.auto_merge {
585            cfg.loop_cfg.auto_merge = v;
586        }
587        if let Some(v) = over.keep_worktrees {
588            cfg.loop_cfg.keep_worktrees = v;
589        }
590        if let Some(v) = over.worktrees {
591            cfg.loop_cfg.worktrees = v;
592        }
593        if let Some(v) = over.close_skipped {
594            cfg.loop_cfg.close_skipped = v;
595        }
596        if let Some(v) = over.absorb {
597            cfg.loop_cfg.absorb_new_issues = v;
598        }
599    }
600
601    let repo = Repo::open(&common.repo, &cfg)?;
602    if common.base.is_none() {
603        cfg.loop_cfg.base_branch = repo.default_branch(cfg.base_branch());
604    }
605
606    let agents = agent::build(&cfg)?;
607    if let Some(warning) = agent::correlation_warning(&agents) {
608        logging::warn(warning);
609    }
610
611    // Sweep finished worktrees before starting, so they cannot accumulate.
612    for stale in repo.prune_worktrees(false) {
613        let what = if stale.starts_with("branch ") {
614            stale
615        } else {
616            format!("worktree {stale}")
617        };
618        logdim!("cleaned up finished {what}");
619    }
620
621    log!("repo {} base {}", repo.root().display(), cfg.base_branch());
622    log!(
623        "agents: {}",
624        agents
625            .iter()
626            .map(|a| format!("{}={}", a.name(), a.spec.describe()))
627            .collect::<Vec<_>>()
628            .join(", ")
629    );
630    Ok((cfg, repo, agents))
631}
632
633fn pick_issues(repo: &Repo, given: Vec<i64>, limit: usize, min_number: i64) -> Result<Vec<i64>> {
634    if !given.is_empty() {
635        // Naming a number is the point, so a floor never overrides it.
636        if min_number > 0 {
637            let below: Vec<String> = given
638                .iter()
639                .filter(|n| **n < min_number)
640                .map(|n| format!("#{n}"))
641                .collect();
642            if !below.is_empty() {
643                logdim!(
644                    "{} below the #{min_number} floor, taking them because you named them",
645                    below.join(", ")
646                );
647            }
648        }
649        return Ok(given);
650    }
651    let found = repo.list_open_issues(limit, min_number)?;
652    if found.is_empty() {
653        log!("no open issues");
654        return Ok(found);
655    }
656    log!(
657        "no issues given, taking {} open: {}",
658        found.len(),
659        found
660            .iter()
661            .map(|n| format!("#{n}"))
662            .collect::<Vec<_>>()
663            .join(", ")
664    );
665    Ok(found)
666}
667
668/// Numbers split by what they actually name.
669///
670/// Issues and pull requests share one number sequence per repository, so a
671/// person should not have to remember which command takes which. Both `run` and
672/// `resume` sort the numbers themselves and route each one.
673#[derive(Debug, Default)]
674struct Sorted {
675    issues: Vec<i64>,
676    prs: Vec<i64>,
677}
678
679fn classify(repo: &Repo, numbers: &[i64]) -> Result<Sorted> {
680    let mut sorted = Sorted::default();
681    for number in numbers {
682        match repo.item_kind(*number)? {
683            ItemKind::Issue => sorted.issues.push(*number),
684            ItemKind::Pr => sorted.prs.push(*number),
685        }
686    }
687    if !sorted.issues.is_empty() && !sorted.prs.is_empty() {
688        log!(
689            "{} issue(s) and {} pull request(s) given",
690            sorted.issues.len(),
691            sorted.prs.len()
692        );
693    }
694    Ok(sorted)
695}
696
697fn make_plan(
698    agents: &[Agent],
699    cfg: &Config,
700    repo: &Repo,
701    issues: &[Issue],
702    plan_out: &Path,
703) -> Result<Plan> {
704    let plan = triage::triage(agents, cfg, repo, issues)?;
705
706    std::fs::write(plan_out, serde_json::to_vec_pretty(&plan)?)
707        .map_err(|e| spar_err!("could not write {}: {e}", plan_out.display()))?;
708    log!("plan written to {}", plan_out.display());
709
710    for item in &plan.order {
711        log!(
712            "  do   #{} [{}/{}] {}",
713            item.issue,
714            item.complexity,
715            item.risk,
716            item.title
717        );
718    }
719    for item in &plan.skipped {
720        if item.tracker {
721            log!(
722                "  hold #{} (both reviewers: tracks work filed elsewhere)",
723                item.issue
724            );
725        } else {
726            log!("  skip #{} (both reviewers: not worth doing)", item.issue);
727        }
728    }
729    for item in &plan.contested {
730        log!("  ??   #{} contested, parked for you to decide", item.issue);
731    }
732    Ok(plan)
733}
734
735/// Post the shared reasoning on every issue both agents declined, and close it
736/// when the config says so. Contested issues are never touched.
737///
738/// A tracker is never closed, whatever `close_skipped` says. Declining to open
739/// a pull request for an umbrella is right, and closing it does not follow from
740/// that: its parts are still open, and the shared context and the alternatives
741/// somebody recorded against are the reason the issue exists. spar closed a
742/// real one as "not planned" while all three of its subtasks were open, which
743/// is what this exists to stop.
744fn act_on_plan(cfg: &Config, repo: &Repo, plan: &Plan) {
745    for item in &plan.skipped {
746        let body = review::skip_comment(item, &repo.style);
747        let close = cfg.loop_cfg.close_skipped && !item.tracker;
748        let outcome = if close {
749            repo.close_issue(item.issue, &body)
750        } else {
751            repo.comment_issue(item.issue, &body)
752        };
753        match outcome {
754            Ok(()) if close => log!("  closed #{}", item.issue),
755            Ok(()) if item.tracker => {
756                log!(
757                    "  left #{} open, it tracks work filed elsewhere",
758                    item.issue
759                )
760            }
761            Ok(()) => {}
762            Err(e) => logdim!("could not update #{}: {e}", item.issue),
763        }
764    }
765}
766
767// ---------------------------------------------------------------------------
768// Subcommands
769// ---------------------------------------------------------------------------
770
771fn cmd_scrub_filter() -> Result<i32> {
772    let mut input = String::new();
773    std::io::stdin()
774        .read_to_string(&mut input)
775        .map_err(|e| spar_err!("could not read a commit message from stdin: {e}"))?;
776    let out = style::scrub(&input, &crate::repo::style_from_env());
777    let mut stdout = std::io::stdout();
778    stdout
779        .write_all(out.as_bytes())
780        .and_then(|_| stdout.write_all(b"\n"))
781        .map_err(|e| spar_err!("could not write the scrubbed message: {e}"))?;
782    Ok(0)
783}
784
785fn cmd_clean(
786    repo_path: &Path,
787    config_path: Option<&Path>,
788    all: bool,
789    pr_state: bool,
790) -> Result<i32> {
791    let cfg = config::load(config_path)?;
792    let repo = Repo::open(repo_path, &cfg)?;
793    let mut removed = repo.prune_worktrees(all);
794    removed.extend(repo.prune_state());
795    if pr_state {
796        removed.extend(repo.prune_pr_state(None));
797    }
798    if removed.is_empty() {
799        println!("nothing to clean");
800    } else {
801        for item in removed {
802            println!("removed {item}");
803        }
804    }
805    Ok(0)
806}
807
808/// Post a review that was produced earlier and not sent.
809fn cmd_post(
810    prs: &[i64],
811    repo_path: &Path,
812    config_path: Option<&Path>,
813    file: Option<&Path>,
814    dry_run: bool,
815) -> Result<i32> {
816    let cfg = config::load(config_path)?;
817    let repo = Repo::open(repo_path, &cfg)?;
818
819    if file.is_some() && prs.len() > 1 {
820        bail!("--file posts one review, so give it one pull request number");
821    }
822
823    let mut failed = false;
824    for number in prs {
825        let text = match file {
826            Some(path) => std::fs::read_to_string(path)
827                .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?,
828            None => match repo.read_pending_comment(*number) {
829                Some(text) => text,
830                None => {
831                    logging::error(format!(
832                        "no saved review for PR #{number}. `spar review {number} --dry-run` \
833                         produces one, or pass --file."
834                    ));
835                    failed = true;
836                    continue;
837                }
838            },
839        };
840        if text.trim().is_empty() {
841            logging::error(format!("the saved review for PR #{number} is empty"));
842            failed = true;
843            continue;
844        }
845        if dry_run {
846            println!("\n{}\n", text.trim());
847            log!("would post the above to PR #{number}");
848            continue;
849        }
850        // Through the style gate like anything else spar sends, so an edit that
851        // reintroduces a banned dash is caught rather than published.
852        match repo.comment_pr(*number, &text) {
853            Ok(()) => log!("posted to PR #{number}"),
854            Err(e) => {
855                logging::error(format!("could not post to PR #{number}: {e}"));
856                failed = true;
857            }
858        }
859    }
860    Ok(if failed { 1 } else { 0 })
861}
862
863/// Append the settings a config does not mention, commented out.
864///
865/// Append only by design. Rewriting somebody's config to insert options would
866/// take their comments and their ordering with it, and `--force` already exists
867/// for anyone who wants the generated file back.
868fn cmd_init_update(out: &Path) -> Result<i32> {
869    let text = std::fs::read_to_string(out)
870        .map_err(|e| spar_err!("could not read {}: {e}", out.display()))?;
871    // Refuse to append to something that does not parse, rather than making a
872    // broken config longer.
873    config::parse(&text).map_err(|e| spar_err!("{} does not parse: {e}", out.display()))?;
874
875    let unset = config::unmentioned_options(&text);
876    if unset.is_empty() {
877        println!("{} already mentions every setting.", out.display());
878        return Ok(0);
879    }
880
881    let mut block = String::new();
882    if !text.ends_with('\n') {
883        block.push('\n');
884    }
885    block.push_str("\n# Added by `spar init --update`: settings this file did not mention,\n");
886    block.push_str("# shown at their defaults. Uncomment one to change it.\n");
887    let mut section = "";
888    for option in &unset {
889        if option.section != section {
890            section = option.section;
891            block.push_str(&format!("\n# [{section}]\n"));
892        }
893        // With the same note `spar init` writes. A config that gained a setting
894        // this way used to gain a bare line and nothing saying what it was for,
895        // which is the half of the setting that matters when you are reading it
896        // for the first time.
897        block.push('\n');
898        block.push_str(&wrap_comment(note_for(&option.key)));
899        block.push_str(&format!("# {} = {}\n", option.key, option.default));
900    }
901
902    use std::io::Write;
903    std::fs::OpenOptions::new()
904        .append(true)
905        .open(out)
906        .and_then(|mut f| f.write_all(block.as_bytes()))
907        .map_err(|e| spar_err!("could not append to {}: {e}", out.display()))?;
908
909    println!(
910        "added {} setting(s) to {} as comments",
911        unset.len(),
912        out.display()
913    );
914    Ok(0)
915}
916
917fn cmd_init(out: &Path, force: bool) -> Result<i32> {
918    if out.exists() && !force {
919        logging::error(format!(
920            "{} already exists. `--update` appends any settings it does not mention, \
921             `--force` overwrites it.",
922            out.display()
923        ));
924        return Ok(1);
925    }
926
927    let presets = config::available_presets();
928    if presets.is_empty() {
929        bail!("no presets available, which should be impossible in a released build");
930    }
931
932    let mut found: Vec<(String, PathBuf, config::AgentSpec)> = Vec::new();
933    for name in &presets {
934        let raw = config::load_preset(name)?;
935        // A preset that will not build is a broken preset, not an uninstalled
936        // CLI. Skipping it silently reported it as "missing" and sent people
937        // looking for an install problem that was not there.
938        let mut spec: config::AgentSpec = match raw
939            .as_table()
940            .cloned()
941            .ok_or_else(|| spar_err!("not a table"))
942            .and_then(|t| {
943                toml::Value::Table(t)
944                    .try_into()
945                    .map_err(|e| spar_err!("{e}"))
946            }) {
947            Ok(spec) => spec,
948            Err(e) => {
949                println!("  BROKEN   {name:10} {}", e.first_line());
950                continue;
951            }
952        };
953        spec.name = name.clone();
954        match Agent::new(spec.clone()).resolve_bin() {
955            Ok(path) => {
956                println!("  found    {name:10} {}", path.display());
957                found.push((name.clone(), path.to_path_buf(), spec));
958            }
959            Err(_) => println!("  missing  {name}"),
960        }
961    }
962
963    if found.len() < 2 {
964        logging::error(format!(
965            "need two agent CLIs, found {}. Install another, or write {} by hand using the \
966             presets as a reference.",
967            found.len(),
968            out.display()
969        ));
970        return Ok(1);
971    }
972
973    // Prefer a pair that cannot share blind spots, if one is available.
974    let chosen: Vec<&(String, PathBuf, config::AgentSpec)> = found.iter().take(2).collect();
975    if found.len() > 2 {
976        log!(
977            "{} agents available, picking {} and {}. Edit {} to change.",
978            found.len(),
979            chosen[0].0,
980            chosen[1].0,
981            out.display()
982        );
983    }
984
985    let mut text = String::from(
986        "# Generated by `spar init`. Each agent inherits a command template from a\n\
987         # built in preset; anything set here overrides it.\n\
988         #\n\
989         # Commented lines are the other options, each with a working value.\n\
990         # Uncomment one to change it.\n\n",
991    );
992    for (name, _, spec) in &chosen {
993        text.push_str(&agent_block(name, spec));
994    }
995    text.push_str(&settings_block(&chosen[0].0));
996
997    std::fs::write(out, text).map_err(|e| spar_err!("could not write {}: {e}", out.display()))?;
998    println!("\nwrote {}", out.display());
999    println!("Next: `spar doctor` to check it, then `spar run` in a repo you have push access to.");
1000    Ok(0)
1001}
1002
1003/// An agent's stand in, under the agent it stands in for.
1004///
1005/// Never counted against `doctor`'s exit code, deliberately. A fallback that is
1006/// not installed does not stop a run either, and a check that disagrees with
1007/// the runtime teaches people to ignore it.
1008fn report_fallback(agent: &Agent) {
1009    let Some(backup) = agent.fallback() else {
1010        return;
1011    };
1012    match backup.resolve_bin() {
1013        Ok(bin) => println!(
1014            "        fallback    {}  ({})",
1015            bin.display(),
1016            backup.spec.describe()
1017        ),
1018        Err(_) => println!(
1019            "        fallback    {} not found, so it will not stand in. Set {} to its path.",
1020            backup.program(),
1021            backup.env_key()
1022        ),
1023    }
1024}
1025
1026/// One settable option: whether the generated config leaves it commented out,
1027/// its key, and the note beside it.
1028///
1029/// The value is deliberately absent. Every value comes from the defaults
1030/// themselves, because a value typed in here is a second copy of a number that
1031/// lives somewhere else, and the second copy is the one that goes stale. This
1032/// one did: the generated config offered a title budget of 90, a summary of
1033/// 200, a detail of 320, a body of 900 and an issue body of 4000, long after
1034/// those became 140, 2000, 6000, 8000 and 20000. Uncommenting a line to see
1035/// what it did cut every comment spar posts to a fifth of its length.
1036type Setting = (bool, &'static str, &'static str);
1037
1038const LOOP_OPTIONS: &[Setting] = &[
1039    (false, "max_rounds", "Review rounds one invocation may spend before escalating. Resuming grants a fresh budget, so this is not a lifetime cap on a pull request."),
1040    (false, "auto_merge", "Merge when no blocking findings remain. Off on purpose: two models agreeing is not the same as being right, and neither carries the consequences."),
1041    (false, "first_implementor", "Which agent takes the first pass. The other one reviews it."),
1042    (false, "worktrees", "Isolate each issue in its own git worktree. Set false to work in the main checkout."),
1043    (false, "close_skipped", "Close an issue both reviewers declined, after posting the shared reasoning. A tracking issue is left open whatever this says."),
1044    (false, "followups", "Where a follow-up goes. issues files them, local writes .spar/followups.md and leaves the tracker alone, none drops them."),
1045    (true, "file_non_blocking", "File a non-blocking finding as a follow-up. Off, because not gating a merge is not the same as deserving somebody's triage queue."),
1046    (true, "max_followups", "Most follow-ups one run may record before it stops and says what it dropped. A backstop, not a target."),
1047    (true, "keep_worktrees", "Keep worktrees after a run, for inspection."),
1048    (true, "min_number", "Ignore issues and pull requests numbered below this when spar picks for itself. 0 is no floor, and a number you name explicitly is always honoured."),
1049    (true, "parallel_triage", "Ask both agents to triage at once. They only read during triage, so there is nothing to serialise."),
1050    (true, "absorb_new_issues", "Waves of newly filed follow-ups to fold back into this run rather than leaving them for the next one. Multiplies what a run costs."),
1051    (true, "file_nits", "File nits as follow-ups too. Off, because a filed nit is somebody else's notification."),
1052    (true, "base_branch", "Only a fallback. Whatever origin/HEAD points at wins when it resolves."),
1053    (true, "branch_prefix", "Namespace the branches spar creates, for example \"spar/\". Without it they are issue-N and pr-N."),
1054    (true, "state_store", "Where resume state is kept. local uses .spar/state and keeps it off the pull request."),
1055    (true, "drafts", "Whether a pull request starts as a draft. until_approved opens one and marks it ready when the review converges, which is what the draft was saying while two agents were still arguing about it. always opens one and leaves it, and cannot be combined with auto_merge."),
1056    (true, "instructions", "Extra instructions handed to both agents with every request, for what this repository always wants that spar has no setting for. --instructions adds to this for one run."),
1057    (true, "max_issue_chars", "Most of one issue body that reaches a prompt. Sized so nothing a person wrote is cut, and a cut is said out loud when it happens."),
1058    (true, "max_triage_chars", "Most every issue body together may add to one triage prompt. Past it, whole issues wait for the next run rather than all of them losing their tails."),
1059];
1060
1061const STYLE_OPTIONS: &[Setting] = &[
1062    (false, "ban_em_dash", "Strip em-dashes and en-dashes from everything spar posts, then refuse to post text that still has one."),
1063    (false, "ban_ai_attribution", "Strip mentions of the tooling, and Co-Authored-By trailers, from everything spar posts."),
1064    (false, "terse", "Hold model prose to a length budget. false removes the valves entirely."),
1065    (true, "pr_comments", "How much of its own working spar narrates into a pull request thread. outcome is one comment at the end, rounds is an audit trail, none never comments at all."),
1066    (true, "max_title_chars", "A finding, issue, or pull request title. Never ellipsised: a title ending in three dots reads as broken."),
1067    (true, "max_summary_chars", "A one line verdict, or a refutation's argument."),
1068    (true, "max_detail_chars", "A blocking finding's explanation, as it appears in the pull request thread."),
1069    (true, "max_body_chars", "A pull request body."),
1070    (true, "max_issue_body_chars", "A filed issue's body. Far larger on purpose: an issue is picked up cold months later. Fenced code blocks are never truncated and never count against it."),
1071];
1072
1073/// The `[loop]` and `[style]` blocks of a generated config.
1074///
1075/// Safety valves, not editors: the length budgets here are sized so real
1076/// content is never touched, which is why they read as large numbers.
1077fn settings_block(first_implementor: &str) -> String {
1078    let defaults: std::collections::BTreeMap<String, String> = config::known_options()
1079        .into_iter()
1080        .map(|option| (option.key, option.default))
1081        .collect();
1082    // first_implementor has no default: it is whichever agent was written
1083    // first, and until there is a config there is no answer to give.
1084    let value = |key: &str| match key {
1085        "first_implementor" => format!("\"{first_implementor}\""),
1086        other => defaults.get(other).cloned().unwrap_or_default(),
1087    };
1088
1089    let mut out = String::from("[loop]\n");
1090    out.push_str(&option_lines(LOOP_OPTIONS, &value));
1091    out.push_str(concat!(
1092        "\n[loop.effort_schedule]\n",
1093        "# Values are whatever each agent's own CLI accepts, listed above, so\n",
1094        "# these are examples rather than defaults. Left out, each agent uses\n",
1095        "# the effort its own block asked for.\n",
1096        "# round_1 = \"high\"   # the deep first review\n",
1097        "# rest    = \"low\"    # later rounds only see a small delta\n\n",
1098    ));
1099    out.push_str("[style]\n");
1100    out.push_str(&option_lines(STYLE_OPTIONS, &value));
1101    out
1102}
1103
1104/// Option lines with their notes lined up in a column, a long note wrapping
1105/// onto continuation lines that stay in the column rather than running off the
1106/// edge or restarting at the margin.
1107fn option_lines(options: &[Setting], value: &dyn Fn(&str) -> String) -> String {
1108    let mut out = String::new();
1109    for (commented, key, note) in options {
1110        if !out.is_empty() {
1111            out.push('\n');
1112        }
1113        out.push_str(&wrap_comment(note));
1114        let lead = if *commented { "# " } else { "" };
1115        out.push_str(&format!("{lead}{key} = {}\n", value(key)));
1116    }
1117    out
1118}
1119
1120/// One prerequisite check: a label and something that either reports a version
1121/// or explains what is missing.
1122type Probe = Box<dyn Fn() -> Result<String>>;
1123
1124/// One agent's block, with the options commented out beside a working value.
1125///
1126/// The values come from the preset rather than from here, so a CLI that adds a
1127/// model is a file edit. They are hints only: nothing validates against them,
1128/// because a stale list that refused a model which actually works would be
1129/// worse than no hint at all.
1130fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
1131    let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
1132
1133    // Only what the preset has hints for. An option with none used to be
1134    // written as `# effort = "..."`, and a placeholder is not a working value:
1135    // the line fails the moment somebody takes the file at its word and
1136    // uncomments it. Cursor has no effort setting at all, so for that agent the
1137    // line should not exist rather than exist and be wrong.
1138    //
1139    // The first entry of each list is the one written as the suggested value,
1140    // which is why the presets put the sensible default there rather than in
1141    // whatever order a CLI's help happens to print.
1142    // Once, above both. The preset's note is about the pair, and repeating it
1143    // under each put a sentence about models underneath the effort line.
1144    if let Some(extra) = &spec.options_note {
1145        if !spec.models.is_empty() || !spec.efforts.is_empty() {
1146            out.push('\n');
1147            out.push_str(&wrap_comment(extra));
1148        }
1149    }
1150    for (key, choices) in [("model", &spec.models), ("effort", &spec.efforts)] {
1151        let Some(suggested) = choices.first() else {
1152            continue;
1153        };
1154        let mut note = format!("Omit {key} to use the CLI's own default.");
1155        if choices.len() > 1 {
1156            note.push_str(&format!(" One of: {}.", choices.join(" | ")));
1157        }
1158        out.push('\n');
1159        out.push_str(&wrap_comment(&note));
1160        out.push_str(&format!("# {key} = \"{suggested}\"\n"));
1161    }
1162
1163    // The value from the spec, not a number typed here, for the reason the
1164    // [loop] block learned: a second copy of a default is the one that goes
1165    // stale.
1166    out.push('\n');
1167    out.push_str(&wrap_comment(
1168        "Seconds one call may take before spar gives up. A timeout costs the whole call and is \
1169         never retried, so err long.",
1170    ));
1171    out.push_str(&format!("# timeout = {}\n", spec.timeout));
1172
1173    // Anything but this agent's own preset: a CLI that has just refused is not
1174    // a stand in for itself.
1175    let backup = if name == "cursor" { "gemini" } else { "cursor" };
1176    out.push('\n');
1177    out.push_str(&wrap_comment(
1178        "A stand in for when this CLI refuses, stalls, or runs out of quota. It answers in place \
1179         of this agent, never alongside it.",
1180    ));
1181    out.push_str(&format!(
1182        "# [agents.{name}.fallback]\n# preset = \"{backup}\"\n"
1183    ));
1184
1185    // The rest of what an agent block takes defines a CLI rather than tunes
1186    // one, so it is pointed at rather than offered: a generated file that
1187    // invites somebody to edit `command` or `output` on a working preset is
1188    // offering them a way to break it.
1189    out.push('\n');
1190    out.push_str(&wrap_comment(
1191        "command, output, search_paths and the rest are in spar.example.toml, for pairing a CLI \
1192         that has no preset.",
1193    ));
1194    out.push('\n');
1195    out
1196}
1197
1198/// What `spar init` says about an option, for `--update` to say too.
1199///
1200/// Empty for one with nothing written about it, and for the effort schedule,
1201/// whose two keys are examples rather than settings and are described by the
1202/// stanza they sit in rather than one at a time.
1203fn note_for(key: &str) -> &'static str {
1204    LOOP_OPTIONS
1205        .iter()
1206        .chain(STYLE_OPTIONS)
1207        .find(|(_, name, _)| *name == key)
1208        .map(|(_, _, note)| *note)
1209        .unwrap_or("")
1210}
1211
1212/// Wrap a note across comment lines so a long one does not run off the edge.
1213fn wrap_comment(text: &str) -> String {
1214    const WIDTH: usize = 76;
1215    let mut out = String::new();
1216    let mut line = String::from("#");
1217    for word in text.split_whitespace() {
1218        if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
1219            out.push_str(&line);
1220            out.push('\n');
1221            line = String::from("#");
1222        }
1223        line.push(' ');
1224        line.push_str(word);
1225    }
1226    if line.len() > 1 {
1227        out.push_str(&line);
1228        out.push('\n');
1229    }
1230    out
1231}
1232
1233fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
1234    let mut ok = true;
1235
1236    let probes: Vec<(&str, Probe)> = vec![
1237        (
1238            "git",
1239            Box::new(|| {
1240                proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
1241                    .map(|s| first_line(&s))
1242            }),
1243        ),
1244        (
1245            "gh",
1246            Box::new(|| {
1247                proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
1248                    .map(|s| first_line(&s))
1249            }),
1250        ),
1251        (
1252            "gh auth",
1253            Box::new(|| {
1254                let out = proc::exec(
1255                    &["gh".into(), "auth".into(), "status".into()],
1256                    &ExecOpts::new().check(false).timeout_secs(60),
1257                )?;
1258                let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
1259                if out.ok() {
1260                    Ok(first_line(&text))
1261                } else {
1262                    Err(spar_err!("not authenticated. Run `gh auth login`."))
1263                }
1264            }),
1265        ),
1266    ];
1267
1268    for (label, probe) in probes {
1269        match probe() {
1270            Ok(detail) => println!("  ok    {label:12} {detail}"),
1271            Err(e) => {
1272                println!("  FAIL  {label:12} {}", e.first_line());
1273                ok = false;
1274            }
1275        }
1276    }
1277
1278    let found = config::find_config(config_path)?;
1279    let Some(path) = found else {
1280        println!("\n  no spar.toml found. Run `spar init` to generate one.");
1281        println!(
1282            "  presets available: {}",
1283            config::available_presets().join(", ")
1284        );
1285        return Ok(if ok { 0 } else { 1 });
1286    };
1287
1288    println!("\n  config: {}", path.display());
1289    let cfg = match config::load(Some(&path)) {
1290        Ok(cfg) => cfg,
1291        Err(e) => {
1292            println!("  FAIL  config       {e}");
1293            return Ok(1);
1294        }
1295    };
1296
1297    // Kept apart from `ok`: a missing gh says nothing about whether the two
1298    // agents are the same CLI, and must not silence the warning below.
1299    let mut resolved = Vec::new();
1300    for spec in &cfg.agents {
1301        let agent = Agent::new(spec.clone());
1302        match agent.resolve_bin() {
1303            Ok(bin) => {
1304                println!(
1305                    "  ok    {:12} {}  ({})",
1306                    spec.name,
1307                    bin.display(),
1308                    spec.describe()
1309                );
1310                report_fallback(&agent);
1311                resolved.push(agent);
1312            }
1313            Err(e) => {
1314                println!("  FAIL  {:12} {}", spec.name, e.first_line());
1315                ok = false;
1316            }
1317        }
1318    }
1319
1320    if resolved.len() == cfg.agents.len() {
1321        if let Some(warning) = agent::correlation_warning(&resolved) {
1322            println!("\n  WARNING  {warning}");
1323        }
1324    }
1325
1326    println!(
1327        "\n  settings: max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
1328        cfg.loop_cfg.max_rounds,
1329        cfg.loop_cfg.auto_merge,
1330        cfg.loop_cfg.worktrees,
1331        cfg.loop_cfg.followups,
1332        cfg.style.terse
1333    );
1334    // What somebody upgrading wants to know. `spar init` refuses to touch an
1335    // existing config, so without this there is no way to learn that a release
1336    // added a setting short of reading the source.
1337    if let Ok(text) = std::fs::read_to_string(&path) {
1338        let unset = config::unmentioned_options(&text);
1339        if !unset.is_empty() {
1340            println!(
1341                "\n  {} setting(s) this config does not mention, all at their defaults:",
1342                unset.len()
1343            );
1344            for option in &unset {
1345                println!(
1346                    "      [{}] {} = {}",
1347                    option.section, option.key, option.default
1348                );
1349            }
1350            println!(
1351                "  `spar init --update {}` appends them as comments.",
1352                path.display()
1353            );
1354        }
1355    }
1356
1357    println!(
1358        "{}",
1359        if ok {
1360            "\nready"
1361        } else {
1362            "\nmissing prerequisites"
1363        }
1364    );
1365    Ok(if ok { 0 } else { 1 })
1366}
1367
1368fn first_line(text: &str) -> String {
1369    text.trim().lines().next().unwrap_or("").trim().to_string()
1370}
1371
1372// ---------------------------------------------------------------------------
1373// Reporting
1374// ---------------------------------------------------------------------------
1375
1376fn report(results: &[IssueRun], cfg: &Config) -> i32 {
1377    println!("\n{}", "=".repeat(60));
1378    for r in results {
1379        println!(
1380            "#{:<5} {:<10} rounds={} {}",
1381            r.issue,
1382            r.status.to_string(),
1383            r.rounds,
1384            r.pr.as_deref().unwrap_or("")
1385        );
1386        for note in &r.notes {
1387            println!("       {}", first_line(note));
1388        }
1389        for url in &r.filed {
1390            println!("       filed {url}");
1391        }
1392        for dispute in &r.disputes {
1393            println!("       disputed: {}", dispute.title);
1394        }
1395    }
1396    println!("{}", "=".repeat(60));
1397
1398    if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
1399        println!("\nApproved PRs are waiting on you to merge.");
1400    }
1401    let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
1402    if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
1403        println!(
1404            "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
1405             Set followups = \"issues\" to file them."
1406        );
1407    }
1408    if results.iter().all(IssueRun::succeeded) {
1409        0
1410    } else {
1411        1
1412    }
1413}
1414
1415#[cfg(test)]
1416mod tests {
1417    use super::*;
1418    use clap::CommandFactory;
1419
1420    #[test]
1421    fn the_parser_is_internally_consistent() {
1422        Cli::command().debug_assert();
1423    }
1424
1425    #[test]
1426    fn quiet_is_accepted_before_or_after_the_subcommand() {
1427        for argv in [
1428            vec!["spar", "--quiet", "run", "42"],
1429            vec!["spar", "run", "42", "--quiet"],
1430            vec!["spar", "resume", "--quiet"],
1431            vec!["spar", "init", "-q"],
1432        ] {
1433            assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
1434        }
1435        assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
1436    }
1437
1438    #[test]
1439    fn several_issue_numbers_are_accepted() {
1440        let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
1441        match cli.command {
1442            Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
1443            other => panic!("{other:?}"),
1444        }
1445    }
1446
1447    #[test]
1448    fn issue_numbers_and_flags_can_be_interleaved() {
1449        let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
1450        match cli.command {
1451            Command::Run {
1452                issues, loop_flags, ..
1453            } => {
1454                assert_eq!(vec![42, 51], issues);
1455                assert!(loop_flags.auto_merge);
1456            }
1457            other => panic!("{other:?}"),
1458        }
1459    }
1460
1461    #[test]
1462    fn every_command_that_reads_a_config_accepts_one() {
1463        for argv in [
1464            vec!["spar", "run", "42"],
1465            vec!["spar", "triage"],
1466            vec!["spar", "resume"],
1467            vec!["spar", "clean"],
1468            vec!["spar", "doctor"],
1469        ] {
1470            let mut full = argv.clone();
1471            full.extend(["--config", "other.toml"]);
1472            let cli = Cli::parse_from(&full);
1473            let config = match cli.command {
1474                Command::Run { common, .. }
1475                | Command::Triage { common, .. }
1476                | Command::Resume { common, .. } => common.config,
1477                Command::Clean { config, .. } | Command::Doctor { config } => config,
1478                other => panic!("{other:?}"),
1479            };
1480            assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
1481        }
1482    }
1483
1484    #[test]
1485    fn auto_merge_is_off_unless_asked_for() {
1486        let cli = Cli::parse_from(["spar", "run"]);
1487        match cli.command {
1488            Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
1489            other => panic!("{other:?}"),
1490        }
1491    }
1492
1493    /// The flag reaches resume as well as run, which is what makes "stop, edit
1494    /// the config, carry on with something extra to say" a thing you can do.
1495    #[test]
1496    fn every_command_that_reads_a_config_takes_instructions() {
1497        for cmd in ["run", "triage", "resume", "review"] {
1498            let argv = vec!["spar", cmd, "7", "--instructions", "Do not wait for CI."];
1499            let parsed = Cli::parse_from(&argv);
1500            let common = match parsed.command {
1501                Command::Run { common, .. }
1502                | Command::Triage { common, .. }
1503                | Command::Resume { common, .. }
1504                | Command::Review { common, .. } => common,
1505                other => panic!("{other:?}"),
1506            };
1507            assert_eq!(
1508                Some("Do not wait for CI."),
1509                common.instructions.as_deref(),
1510                "{cmd}"
1511            );
1512        }
1513    }
1514
1515    #[test]
1516    fn the_two_close_skipped_flags_are_mutually_exclusive() {
1517        assert!(
1518            Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
1519        );
1520    }
1521
1522    /// Only `run` triages, so only `run` can decline an issue. Accepting the
1523    /// flag on `resume` would silently do nothing.
1524    #[test]
1525    fn close_skipped_is_offered_only_where_it_means_something() {
1526        assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
1527        assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
1528        assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
1529        assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
1530        assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
1531    }
1532
1533    #[test]
1534    fn the_close_skipped_pair_resolves_to_a_tristate() {
1535        let read = |argv: &[&str]| match Cli::parse_from(argv).command {
1536            Command::Run { triage_flags, .. } => {
1537                match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
1538                    (true, _) => Some(true),
1539                    (_, true) => Some(false),
1540                    _ => None,
1541                }
1542            }
1543            other => panic!("{other:?}"),
1544        };
1545        assert_eq!(None, read(&["spar", "run"]));
1546        assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
1547        assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
1548    }
1549
1550    #[test]
1551    fn the_default_limit_is_twenty() {
1552        let cli = Cli::parse_from(["spar", "run"]);
1553        match cli.command {
1554            Command::Run { common, .. } => assert_eq!(20, common.limit),
1555            other => panic!("{other:?}"),
1556        }
1557    }
1558
1559    #[test]
1560    fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
1561        assert!(matches!(
1562            Cli::parse_from(["spar", "scrub-filter"]).command,
1563            Command::ScrubFilter
1564        ));
1565        let help = Cli::command().render_long_help().to_string();
1566        assert!(
1567            !help.contains("scrub-filter"),
1568            "it is plumbing, not a command"
1569        );
1570    }
1571
1572    #[test]
1573    fn review_takes_pr_numbers_and_a_dry_run() {
1574        let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
1575        match cli.command {
1576            Command::Review { items, dry_run, .. } => {
1577                assert_eq!(vec![101, 102], items);
1578                assert!(dry_run);
1579            }
1580            other => panic!("{other:?}"),
1581        }
1582    }
1583
1584    #[test]
1585    fn review_posts_unless_told_not_to() {
1586        match Cli::parse_from(["spar", "review", "101"]).command {
1587            Command::Review { dry_run, .. } => assert!(!dry_run),
1588            other => panic!("{other:?}"),
1589        }
1590    }
1591
1592    #[test]
1593    fn review_with_no_numbers_is_allowed() {
1594        match Cli::parse_from(["spar", "review"]).command {
1595            Command::Review { items, .. } => assert!(items.is_empty()),
1596            other => panic!("{other:?}"),
1597        }
1598    }
1599
1600    #[test]
1601    fn review_takes_its_own_round_budget() {
1602        match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
1603            Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
1604            other => panic!("{other:?}"),
1605        }
1606    }
1607
1608    #[test]
1609    fn resume_takes_a_next_override() {
1610        let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
1611        match cli.command {
1612            Command::Resume {
1613                prs, next_actor, ..
1614            } => {
1615                assert_eq!(vec![108], prs);
1616                assert_eq!(Some("codex".to_string()), next_actor);
1617            }
1618            other => panic!("{other:?}"),
1619        }
1620    }
1621}
1622
1623#[cfg(test)]
1624mod absorb_tests {
1625    use super::*;
1626
1627    #[test]
1628    fn absorb_is_off_unless_asked_for() {
1629        match Cli::parse_from(["spar", "run"]).command {
1630            Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
1631            other => panic!("{other:?}"),
1632        }
1633    }
1634
1635    #[test]
1636    fn absorb_takes_a_wave_count() {
1637        match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
1638            Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
1639            other => panic!("{other:?}"),
1640        }
1641    }
1642
1643    #[test]
1644    fn absorb_is_only_offered_where_issues_are_worked() {
1645        assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
1646        assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
1647        assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
1648    }
1649}
1650
1651#[cfg(test)]
1652mod min_number_tests {
1653    use super::*;
1654
1655    fn read(argv: &[&str]) -> Option<i64> {
1656        match Cli::parse_from(argv).command {
1657            Command::Run { common, .. }
1658            | Command::Triage { common, .. }
1659            | Command::Resume { common, .. }
1660            | Command::Review { common, .. } => common.min_number,
1661            other => panic!("{other:?}"),
1662        }
1663    }
1664
1665    #[test]
1666    fn there_is_no_floor_unless_one_is_asked_for() {
1667        assert_eq!(None, read(&["spar", "run"]));
1668    }
1669
1670    #[test]
1671    fn every_command_that_picks_for_itself_accepts_a_floor() {
1672        for cmd in ["run", "triage", "resume", "review"] {
1673            assert_eq!(
1674                Some(480),
1675                read(&["spar", cmd, "--min-number", "480"]),
1676                "{cmd}"
1677            );
1678        }
1679    }
1680}
1681
1682#[cfg(test)]
1683mod settings_block_tests {
1684    use super::*;
1685
1686    /// The value a config line offers, with its trailing note removed. Quote
1687    /// aware, since a note is free to contain a `#` and several do.
1688    fn written(line: &str) -> String {
1689        let after = line.split_once('=').expect("an assignment").1;
1690        let mut quoted = false;
1691        for (i, c) in after.char_indices() {
1692            match c {
1693                '"' => quoted = !quoted,
1694                '#' if !quoted => return after[..i].trim().to_string(),
1695                _ => {}
1696            }
1697        }
1698        after.trim().to_string()
1699    }
1700
1701    fn line_for(text: &str, key: &str) -> String {
1702        text.lines()
1703            .find(|l| {
1704                let bare = l.trim_start().trim_start_matches('#').trim_start();
1705                bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
1706            })
1707            .unwrap_or_else(|| panic!("{key} is not offered at all:\n{text}"))
1708            .to_string()
1709    }
1710
1711    /// The guard that was missing. Every number in the generated config used to
1712    /// be typed in beside its comment, which is a second copy of a default that
1713    /// lives in the code, and the copies stopped agreeing: it offered a title
1714    /// budget of 90 against a real 140, a body of 900 against 8000, and three
1715    /// more like it. Uncommenting one to see what it did cut every comment spar
1716    /// posts to a fifth of its length.
1717    #[test]
1718    fn every_value_it_offers_is_the_default_it_actually_has() {
1719        let text = settings_block("claude");
1720        for option in config::known_options() {
1721            // The effort words are per CLI, so the schedule's are examples of
1722            // what one accepts rather than defaults. There is no default
1723            // effort: an agent that names none uses its own CLI's.
1724            if option.section == "loop.effort_schedule" {
1725                continue;
1726            }
1727            let line = line_for(&text, &option.key);
1728            assert_eq!(
1729                option.default,
1730                written(&line),
1731                "the generated config offers `{}`, but the default is {}",
1732                line.trim(),
1733                option.default
1734            );
1735        }
1736    }
1737
1738    /// `doctor` reports what a config does not mention, so a generated one
1739    /// should send nobody to that list on the day it was written. pr_comments
1740    /// was missing from it for exactly that long.
1741    #[test]
1742    fn it_offers_every_option_the_parser_knows_about() {
1743        let text = settings_block("claude");
1744        let missing: Vec<String> = config::unmentioned_options(&text)
1745            .into_iter()
1746            .map(|o| format!("[{}] {}", o.section, o.key))
1747            .collect();
1748        assert!(missing.is_empty(), "not offered: {}", missing.join(", "));
1749    }
1750
1751    /// The strongest of these: every line the file suggests has to be a line
1752    /// that works. A commented option is an invitation to uncomment it, and one
1753    /// that then fails to load is worse than never having offered it.
1754    #[test]
1755    fn every_option_it_offers_can_be_uncommented_and_still_load() {
1756        let mut text = String::from(
1757            "[agents.claude]\ncommand = [\"claude\"]\n\n\
1758             [agents.codex]\ncommand = [\"codex\"]\n\n",
1759        );
1760        for line in settings_block("claude").lines() {
1761            text.push_str(uncomment(line).unwrap_or(line));
1762            text.push('\n');
1763        }
1764        let cfg = config::parse(&text).expect("a config of its own suggestions");
1765        assert_eq!("claude", cfg.first_implementor);
1766    }
1767
1768    /// A commented assignment with its `#` removed, or None for a line of
1769    /// prose, which stays a comment.
1770    fn uncomment(line: &str) -> Option<&str> {
1771        let bare = line.trim_start().strip_prefix('#')?.trim_start();
1772        // An assignment, not a wrapped note that happens to contain an `=`:
1773        // the key has to be one bare word.
1774        let key = bare.split_once('=')?.0.trim();
1775        let named = !key.is_empty()
1776            && key
1777                .chars()
1778                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
1779        named.then_some(bare)
1780    }
1781
1782    #[test]
1783    fn the_agent_that_goes_first_is_the_one_that_was_chosen() {
1784        assert!(settings_block("codex").contains("first_implementor = \"codex\""));
1785    }
1786
1787    /// Every line starts at the margin, and an option's note sits above the
1788    /// option rather than beside it. Beside meant three different columns in
1789    /// one file, and a note that wrapped left the reader tracking indentation
1790    /// to work out which setting it belonged to.
1791    #[test]
1792    fn a_note_sits_above_the_option_it_describes() {
1793        let text = settings_block("claude");
1794        assert!(
1795            text.lines().all(|l| l.chars().count() <= 80),
1796            "a line runs off the edge:\n{text}"
1797        );
1798        assert!(
1799            text.lines().all(|l| !l.starts_with(' ')),
1800            "a line is indented, so the columns are back:\n{text}"
1801        );
1802
1803        // The line before an option carries its note, not another option.
1804        let lines: Vec<&str> = text.lines().collect();
1805        let at = lines
1806            .iter()
1807            .position(|l| l.starts_with("max_rounds"))
1808            .expect("max_rounds");
1809        assert!(lines[at - 1].starts_with('#'), "{:?}", lines[at - 1]);
1810        assert!(
1811            lines[at - 1].contains("lifetime cap"),
1812            "the note above it is the end of its own note: {:?}",
1813            lines[at - 1]
1814        );
1815    }
1816
1817    /// A note reads as a sentence now that it leads rather than trails.
1818    #[test]
1819    fn every_note_starts_as_a_sentence() {
1820        for (_, key, note) in LOOP_OPTIONS.iter().chain(STYLE_OPTIONS) {
1821            let first = note.chars().next().expect("a note");
1822            assert!(
1823                first.is_uppercase(),
1824                "{key} reads as a margin scribble rather than a sentence: {note}"
1825            );
1826        }
1827    }
1828}
1829
1830#[cfg(test)]
1831mod agent_block_tests {
1832    use super::*;
1833
1834    fn spec(models: &[&str], efforts: &[&str]) -> config::AgentSpec {
1835        let mut spec: config::AgentSpec =
1836            toml::Value::Table(toml::from_str("command = [\"x\"]").expect("a minimal preset"))
1837                .try_into()
1838                .expect("builds");
1839        spec.models = models.iter().map(|s| s.to_string()).collect();
1840        spec.efforts = efforts.iter().map(|s| s.to_string()).collect();
1841        spec
1842    }
1843
1844    /// A placeholder is not a working value. `# effort = "..."` was written for
1845    /// every preset that lists no efforts, and taking the file at its word by
1846    /// uncommenting it passes `...` to the CLI as a real setting.
1847    #[test]
1848    fn an_option_with_no_hints_is_left_out_rather_than_guessed_at() {
1849        let block = agent_block("cursor", &spec(&["composer-2.5", "auto"], &[]));
1850        assert!(!block.contains("..."), "{block}");
1851        assert!(!block.contains("effort"), "{block}");
1852        assert!(block.contains("# model = \"composer-2.5\""), "{block}");
1853    }
1854
1855    /// Each option is introduced by its own line, so a block with no effort
1856    /// setting never mentions one.
1857    #[test]
1858    fn only_the_options_that_follow_are_introduced() {
1859        let model_only = agent_block("cursor", &spec(&["auto"], &[]));
1860        assert!(model_only.contains("Omit model to use"), "{model_only}");
1861        assert!(!model_only.contains("Omit effort"), "{model_only}");
1862
1863        let both = agent_block("claude", &spec(&["fable"], &["high"]));
1864        assert!(both.contains("Omit model to use"), "{both}");
1865        assert!(both.contains("Omit effort to use"), "{both}");
1866    }
1867
1868    /// A preset with no hints at all still has to produce a loadable block,
1869    /// which is every preset that has never listed any: gemini and aider.
1870    #[test]
1871    fn a_preset_with_no_hints_still_writes_a_usable_block() {
1872        let block = agent_block("gemini", &spec(&[], &[]));
1873        assert!(!block.contains("..."), "{block}");
1874        assert!(!block.contains("Omit"), "{block}");
1875        assert!(
1876            block.starts_with("[agents.gemini]\npreset = \"gemini\"\n"),
1877            "{block}"
1878        );
1879        // What the block does still carry is unaffected.
1880        assert!(block.contains("[agents.gemini.fallback]"), "{block}");
1881        assert!(block.contains("# timeout = "), "{block}");
1882    }
1883
1884    /// The timeout comes from the spec rather than a number typed into the
1885    /// generator, for the reason the [loop] block learned the hard way.
1886    #[test]
1887    fn the_timeout_offered_is_the_one_the_agent_would_use() {
1888        let mut spec = spec(&["a"], &[]);
1889        spec.timeout = 7200;
1890        assert!(
1891            agent_block("custom", &spec).contains("# timeout = 7200"),
1892            "the generator kept its own copy"
1893        );
1894    }
1895
1896    /// Alternatives are named, and a single choice is not dressed up as one.
1897    #[test]
1898    fn alternatives_are_listed_only_when_there_are_any() {
1899        assert!(agent_block("a", &spec(&["one", "two"], &[])).contains("One of: one | two."));
1900        let single = agent_block("b", &spec(&["only"], &[]));
1901        assert!(!single.contains("One of:"), "{single}");
1902    }
1903
1904    /// The preset's own note is about the pair, so it appears once above them
1905    /// rather than under each, which put a sentence about models beneath the
1906    /// effort line.
1907    #[test]
1908    fn the_presets_note_is_said_once() {
1909        let mut spec = spec(&["m1", "m2"], &["e1", "e2"]);
1910        spec.options_note = Some("Check the current sets with: mytool --help".into());
1911        let block = agent_block("mytool", &spec);
1912        assert_eq!(
1913            1,
1914            block.matches("Check the current sets").count(),
1915            "{block}"
1916        );
1917    }
1918}