Skip to main content

spar/
repo.rs

1//! git and gh. Every outbound string passes through the style and concision
2//! gates before it reaches GitHub.
3
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6
7use serde::Deserialize;
8use serde_json::Value;
9
10use crate::config::{Config, Followups, StateStore};
11use crate::error::Result;
12use crate::model::{Issue, IssueRef, ItemKind, PersistedState, PrRef, PrView};
13use crate::proc::{self, ExecOpts};
14use crate::style::{self, Style};
15use crate::textsim;
16use crate::{bail, logdim, spar_err};
17
18/// gh returns newest first, so its `--limit` cannot be used to take the lowest
19/// numbered items: it would slice the newest N and then sorting that slice
20/// silently drops the older ones. Fetch a generous page, sort, then truncate.
21pub const FETCH_CEILING: usize = 500;
22
23/// An unclosed HTML comment on purpose. The payload is written after it and
24/// terminated with `-->`, so GitHub renders the whole block as nothing.
25pub const STATE_MARKER: &str = "<!-- spar:state";
26
27const WORKTREE_DIR: &str = ".spar-worktrees";
28const STATE_DIR: &str = ".spar";
29
30#[derive(Debug)]
31pub struct Repo {
32    root: PathBuf,
33    pub style: Style,
34    pub branch_prefix: String,
35    pub state_store: StateStore,
36    pub followups: Followups,
37}
38
39impl Repo {
40    pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
41        let root =
42            std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
43        // A linked worktree has a `.git` file rather than a directory, and a
44        // bare-ish layout can have neither, so ask git instead of guessing.
45        let inside = proc::run_str(
46            &["git", "rev-parse", "--is-inside-work-tree"],
47            &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
48        )
49        .unwrap_or_default();
50        if inside.trim() != "true" {
51            bail!("not a git repository: {}", root.display());
52        }
53        let repo = Self {
54            root,
55            style: cfg.style.clone(),
56            branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
57            state_store: cfg.loop_cfg.state_store,
58            followups: cfg.loop_cfg.followups,
59        };
60        repo.self_exclude();
61        Ok(repo)
62    }
63
64    /// Keep spar's own scratch directories out of the target repo's
65    /// `git status`.
66    ///
67    /// Written to `.git/info/exclude`, never to a tracked `.gitignore`: this is
68    /// somebody else's repository and spar has no business committing to it.
69    /// Best effort and silent on failure, because a read-only git directory is
70    /// not a reason to abandon a run.
71    fn self_exclude(&self) {
72        let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
73        let git_dir = git_dir.trim();
74        if git_dir.is_empty() {
75            return;
76        }
77        let path = Path::new(git_dir).join("info").join("exclude");
78        let existing = std::fs::read_to_string(&path).unwrap_or_default();
79
80        let wanted = [format!("/{WORKTREE_DIR}/"), format!("/{STATE_DIR}/")];
81        let missing: Vec<&String> = wanted
82            .iter()
83            .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
84            .collect();
85        if missing.is_empty() {
86            return;
87        }
88
89        use std::io::Write;
90        if let Some(parent) = path.parent() {
91            let _ = std::fs::create_dir_all(parent);
92        }
93        let mut block = String::new();
94        if !existing.is_empty() && !existing.ends_with('\n') {
95            block.push('\n');
96        }
97        block.push_str("\n# added by spar: its worktrees and run state\n");
98        for line in missing {
99            block.push_str(line);
100            block.push('\n');
101        }
102        if let Ok(mut file) = std::fs::OpenOptions::new()
103            .create(true)
104            .append(true)
105            .open(&path)
106        {
107            let _ = file.write_all(block.as_bytes());
108        }
109    }
110
111    pub fn root(&self) -> &Path {
112        &self.root
113    }
114
115    // -- gates ------------------------------------------------------------
116
117    /// Scrub, then verify. A leak here reaches GitHub, so it is a hard error
118    /// rather than a warning: silent partial compliance is how a style rule
119    /// erodes over a long run.
120    pub fn clean(&self, text: &str) -> Result<String> {
121        let out = style::scrub(text, &self.style);
122        let bad = style::violations(&out, &self.style);
123        if !bad.is_empty() {
124            bail!(
125                "style gate could not clean text ({}): {}",
126                bad.join(", "),
127                style::clip(&out, 300)
128            );
129        }
130        Ok(out)
131    }
132
133    /// Clean, and hold to a length budget. For anything a model wrote.
134    pub fn clean_body(&self, text: &str) -> Result<String> {
135        self.clean(&style::body(text, &self.style))
136    }
137
138    /// The same, with an issue's far larger budget and its exemption for code.
139    pub fn clean_issue_body(&self, text: &str) -> Result<String> {
140        self.clean(&style::issue_body(text, &self.style))
141    }
142
143    /// The single transform every outbound title goes through.
144    ///
145    /// Scrub first, clip second, and never the other way round. Clipping first
146    /// lets the scrub lengthen the result past the budget (an em dash becomes
147    /// two characters), so a second pass would clip again and produce a
148    /// different string. That broke follow-up deduplication silently: the
149    /// lookup searched for one title while GitHub had stored another, no match
150    /// was ever found, and a fresh duplicate issue was filed every review
151    /// round. Doing it in this order makes the transform idempotent, which the
152    /// tests assert.
153    pub fn clean_title(&self, text: &str) -> Result<String> {
154        Ok(style::title(&self.clean(text)?, &self.style))
155    }
156
157    // -- git --------------------------------------------------------------
158
159    fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
160        ExecOpts::new()
161            .cwd(cwd.unwrap_or(&self.root))
162            .check(check)
163            .timeout_secs(600)
164    }
165
166    pub fn git(&self, args: &[&str]) -> Result<String> {
167        self.git_at(None, args)
168    }
169
170    pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
171        let mut argv = vec!["git".to_string()];
172        argv.extend(args.iter().map(|s| s.to_string()));
173        proc::run(&argv, &self.git_opts(cwd, true))
174    }
175
176    /// Run git, tolerating failure. Returns whatever landed on stdout.
177    pub fn git_try(&self, args: &[&str]) -> String {
178        self.git_try_at(None, args)
179    }
180
181    pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
182        let mut argv = vec!["git".to_string()];
183        argv.extend(args.iter().map(|s| s.to_string()));
184        proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
185    }
186
187    /// The base branch the remote actually points at, rather than assuming
188    /// `main`. Falls back to the configured value when there is no origin.
189    pub fn default_branch(&self, configured: &str) -> String {
190        let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
191        match refname.trim().rsplit('/').next() {
192            Some(name) if !name.is_empty() => name.to_string(),
193            _ => configured.to_string(),
194        }
195    }
196
197    // -- branch naming and ownership --------------------------------------
198    //
199    // Branch names default to `issue-N`, which is exactly what a person would
200    // name a branch by hand. Ownership therefore cannot be inferred from the
201    // name, so every branch spar creates is recorded and cleanup only ever
202    // touches what is in that record.
203
204    pub fn branch_for_issue(&self, issue: i64) -> String {
205        format!("{}issue-{issue}", self.branch_prefix)
206    }
207
208    pub fn branch_for_pr(&self, number: i64) -> String {
209        format!("{}pr-{number}", self.branch_prefix)
210    }
211
212    fn ledger_path(&self) -> PathBuf {
213        self.root.join(STATE_DIR).join("branches.json")
214    }
215
216    pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
217        std::fs::read_to_string(self.ledger_path())
218            .ok()
219            .and_then(|text| serde_json::from_str(&text).ok())
220            .unwrap_or_default()
221    }
222
223    pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
224        let mut data = self.known_branches();
225        data.insert(
226            branch.to_string(),
227            BranchRecord {
228                kind: kind.to_string(),
229                number,
230            },
231        );
232        if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
233            logdim!("could not record branch {branch}: {e}");
234        }
235    }
236
237    pub fn forget_branch(&self, branch: &str) {
238        let mut data = self.known_branches();
239        if data.remove(branch).is_none() {
240            return;
241        }
242        if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
243            logdim!("could not update the branch record: {e}");
244        }
245    }
246
247    // -- worktrees --------------------------------------------------------
248
249    fn worktree_path(&self, name: &str) -> PathBuf {
250        self.root.join(WORKTREE_DIR).join(name)
251    }
252
253    /// Isolate an issue so a failed run cannot poison the next one's base.
254    pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
255        let branch = self.branch_for_issue(issue);
256        let path = self.worktree_path(&format!("issue-{issue}"));
257
258        self.git_try(&["fetch", "origin", base]);
259
260        // Never rebuild a branch that already carries work.
261        //
262        // `run_issue` sends an issue with an open pull request to the resume
263        // path, so reaching here with a remote branch ahead of the base means
264        // commits were pushed that no open PR accounts for. Rebuilding would
265        // force push over them, and the lease is no protection: the remote
266        // tracking ref survives the local branch being deleted, so it still
267        // matches and the push succeeds.
268        self.git_try(&["fetch", "origin", &branch]);
269        let remote_branch = format!("origin/{branch}");
270        if self.rev_exists(&self.root, &remote_branch) {
271            let range = format!("origin/{base}..{remote_branch}");
272            let ahead: u32 = self
273                .git_try(&["rev-list", "--count", &range])
274                .trim()
275                .parse()
276                .unwrap_or(0);
277            if ahead > 0 {
278                bail!(
279                    "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
280                     open pull request accounts for them. Rebuilding it would force push over \
281                     that work.\nOpen a pull request for the branch and run `spar resume <pr>` to \
282                     continue it, or delete it with `git push origin --delete {branch}` if it is \
283                     stale."
284                );
285            }
286        }
287
288        self.worktree_remove(issue);
289        self.git_try(&["branch", "-D", &branch]);
290
291        if let Some(parent) = path.parent() {
292            std::fs::create_dir_all(parent)
293                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
294        }
295
296        let path_str = path.display().to_string();
297        let remote_start = format!("origin/{base}");
298        let created = self
299            .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
300            .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
301
302        // Recorded on both paths: an unrecorded branch is one cleanup will
303        // never remove, and the fallback creates a branch just the same.
304        created.map_err(|e| {
305            spar_err!(
306                "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
307                 and does `origin` exist?",
308                e.last_line()
309            )
310        })?;
311        self.record_branch(&branch, "issue", issue);
312        Ok((path, branch))
313    }
314
315    pub fn worktree_remove(&self, issue: i64) {
316        self.remove_worktree_at(&self.worktree_path(&format!("issue-{issue}")));
317    }
318
319    fn remove_worktree_at(&self, path: &Path) {
320        let path_str = path.display().to_string();
321        self.git_try(&["worktree", "remove", "--force", &path_str]);
322        if path.is_dir() {
323            let _ = std::fs::remove_dir_all(path);
324        }
325        self.git_try(&["worktree", "prune"]);
326    }
327
328    /// Check an existing PR branch out into an isolated worktree.
329    pub fn worktree_for_pr(&self, pr: &PrView) -> Result<(PathBuf, String)> {
330        let head = pr.head_ref_name.clone();
331        if head.trim().is_empty() {
332            bail!("PR #{} has no head branch to check out", pr.number);
333        }
334        let path = self.worktree_path(&format!("pr-{}", pr.number));
335        let local = self.branch_for_pr(pr.number);
336
337        self.git(&["fetch", "origin", &head]).map_err(|e| {
338            spar_err!(
339                "could not fetch the branch behind PR #{}: {}",
340                pr.number,
341                e.last_line()
342            )
343        })?;
344        self.remove_worktree_at(&path);
345        self.git_try(&["branch", "-D", &local]);
346
347        let path_str = path.display().to_string();
348        let start = format!("origin/{head}");
349        self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
350        self.record_branch(&local, "pr", pr.number);
351        Ok((path, head))
352    }
353
354    /// Check a pull request's head out read only, detached, with no branch.
355    ///
356    /// Fetches `refs/pull/N/head`, which GitHub serves for every pull request
357    /// including one from a fork whose branch is not in this repository at all.
358    /// That is what makes reviewing an outside contribution possible when
359    /// pushing to it is not.
360    ///
361    /// Detached on purpose. Review only mode has nothing to push, and a branch
362    /// would only invite something to try.
363    pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
364        let path = self.worktree_path(&format!("review-{number}"));
365        let local_ref = review_ref(number);
366        let refspec = format!("+refs/pull/{number}/head:{local_ref}");
367
368        self.git(&["fetch", "origin", &refspec]).map_err(|e| {
369            spar_err!(
370                "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
371                 every pull request, so this usually means the number is wrong or `origin` does \
372                 not point at the repository the PR is on.",
373                e.last_line()
374            )
375        })?;
376
377        if let Some(parent) = path.parent() {
378            std::fs::create_dir_all(parent)
379                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
380        }
381        self.remove_worktree_at(&path);
382        let path_str = path.display().to_string();
383        self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
384        Ok(path)
385    }
386
387    pub fn release_review_worktree(&self, number: i64) {
388        self.remove_worktree_at(&self.worktree_path(&format!("review-{number}")));
389        self.git_try(&["update-ref", "-d", &review_ref(number)]);
390    }
391
392    pub fn release_pr_worktree(&self, number: i64) {
393        let path = self.worktree_path(&format!("pr-{number}"));
394        self.remove_worktree_at(&path);
395        let local = self.branch_for_pr(number);
396        self.git_try(&["branch", "-D", &local]);
397        self.forget_branch(&local);
398    }
399
400    // -- branch state -----------------------------------------------------
401
402    /// What to diff against: the remote tracking branch when it resolves, the
403    /// local branch when it does not.
404    ///
405    /// This is not a nicety. Every "did the agent do anything" check hangs off
406    /// this ref, and `git log` against a ref that does not exist fails silently
407    /// and reads as "no commits". A checkout whose `origin/main` was never
408    /// fetched would report every implementation as abandoned and throw the
409    /// work away.
410    pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
411        let remote = format!("origin/{base}");
412        if self.rev_exists(cwd, &remote) {
413            return remote;
414        }
415        if self.rev_exists(cwd, base) {
416            logdim!("origin/{base} does not resolve, comparing against local {base}");
417            return base.to_string();
418        }
419        logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
420        remote
421    }
422
423    fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
424        let spec = format!("{refname}^{{commit}}");
425        !self
426            .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
427            .trim()
428            .is_empty()
429    }
430
431    pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
432        let range = format!("{}..HEAD", self.base_ref(cwd, base));
433        !self
434            .git_try_at(Some(cwd), &["log", &range, "--oneline"])
435            .trim()
436            .is_empty()
437    }
438
439    pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
440        let range = format!("{}...HEAD", self.base_ref(cwd, base));
441        let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
442        full.trim().to_string()
443    }
444
445    /// Scrub commit messages that slipped past the prompt.
446    ///
447    /// `git filter-branch` calls back into this same binary, so there is no
448    /// interpreter to find and no second copy of the rules to drift.
449    pub fn rewrite_commits_if_needed(&self, cwd: &Path, base: &str) -> Result<()> {
450        let range = format!("{}..HEAD", self.base_ref(cwd, base));
451        let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
452
453        let offenders = raw
454            .split('\x1e')
455            .filter_map(|entry| entry.split_once('\0'))
456            .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
457            .count();
458        if offenders == 0 {
459            return Ok(());
460        }
461        logdim!("{offenders} commit message(s) violated style rules, rewriting");
462
463        let exe = self_binary()?;
464        let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
465
466        let argv: Vec<String> = [
467            "git",
468            "filter-branch",
469            "-f",
470            "--msg-filter",
471            &filter,
472            &range,
473        ]
474        .iter()
475        .map(|s| s.to_string())
476        .collect();
477        let opts = ExecOpts::new()
478            .cwd(cwd)
479            .check(false)
480            .timeout_secs(600)
481            .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
482            .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
483            .env(
484                "SPAR_BAN_AI_ATTRIBUTION",
485                bool_env(self.style.ban_ai_attribution),
486            );
487        let _ = proc::run(&argv, &opts);
488
489        let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
490        if !style::violations(&after, &self.style).is_empty() {
491            bail!(
492                "commit messages still violate style rules after a rewrite. Fix them by hand in \
493                 {} and rerun.",
494                cwd.display()
495            );
496        }
497        Ok(())
498    }
499
500    /// Push by explicit refspec from HEAD.
501    ///
502    /// A resumed PR is checked out under a local name (`pr-N`) that does not
503    /// match its remote branch, so pushing by branch name would resolve the
504    /// wrong local ref or fail outright.
505    pub fn push(&self, cwd: &Path, branch: &str) -> Result<()> {
506        let refspec = format!("HEAD:{branch}");
507        self.git_at(
508            Some(cwd),
509            &["push", "--force-with-lease", "origin", &refspec],
510        )
511        .map(|_| ())
512        .map_err(|e| {
513            spar_err!(
514                "could not push to origin/{branch}. {}\nCheck push access and whether the \
515                     branch moved under you.",
516                e.last_line()
517            )
518        })
519    }
520
521    // -- gh ---------------------------------------------------------------
522
523    pub fn gh(&self, args: &[&str]) -> Result<String> {
524        self.gh_at(None, args)
525    }
526
527    pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
528        let mut argv = vec!["gh".to_string()];
529        argv.extend(args.iter().map(|s| s.to_string()));
530        proc::run(
531            &argv,
532            &ExecOpts::new()
533                .cwd(cwd.unwrap_or(&self.root))
534                .timeout_secs(300),
535        )
536    }
537
538    pub fn gh_try(&self, args: &[&str]) -> String {
539        let mut argv = vec!["gh".to_string()];
540        argv.extend(args.iter().map(|s| s.to_string()));
541        proc::run(
542            &argv,
543            &ExecOpts::new()
544                .cwd(&self.root)
545                .check(false)
546                .timeout_secs(300),
547        )
548        .unwrap_or_default()
549    }
550
551    pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
552        let mut issues = Vec::new();
553        for number in numbers {
554            let text = self
555                .gh(&[
556                    "issue",
557                    "view",
558                    &number.to_string(),
559                    "--json",
560                    "number,title,body,labels,state,url",
561                ])
562                .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
563            let issue: Issue = serde_json::from_str(&text)
564                .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
565            if issue.is_closed() {
566                crate::log!("issue #{number} is closed, skipping");
567                continue;
568            }
569            issues.push(issue);
570        }
571        if issues.is_empty() {
572            bail!("no open issues to work on");
573        }
574        Ok(issues)
575    }
576
577    /// Open items, lowest numbered first, from `min_number` upward.
578    ///
579    /// The floor exists because a long lived repository accumulates a tail of
580    /// old issues nobody is going to get to, and taking the lowest numbered
581    /// open items means walking straight into them.
582    fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
583        #[derive(Deserialize)]
584        struct Row {
585            number: i64,
586        }
587        let text = self.gh(&[
588            kind,
589            "list",
590            "--state",
591            "open",
592            "--limit",
593            &FETCH_CEILING.to_string(),
594            "--json",
595            "number",
596        ])?;
597        let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
598        let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
599        numbers.sort_unstable();
600
601        let noun = if kind == "issue" { "issues" } else { "PRs" };
602        let found = numbers.len();
603        if min_number > 0 {
604            numbers.retain(|n| *n >= min_number);
605            let skipped = found - numbers.len();
606            if skipped > 0 {
607                crate::log!("{skipped} open {noun} below #{min_number} skipped");
608            }
609        }
610        if found >= FETCH_CEILING {
611            crate::log!(
612                "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
613                 considered."
614            );
615        }
616        if numbers.len() > limit {
617            crate::log!(
618                "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
619                 explicitly.",
620                numbers.len()
621            );
622            numbers.truncate(limit);
623        }
624        Ok(numbers)
625    }
626
627    /// Open issues, lowest numbered first. `gh issue list` excludes PRs.
628    pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
629        self.open_numbers("issue", limit, min_number)
630    }
631
632    pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
633        self.open_numbers("pr", limit, min_number)
634    }
635
636    pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
637        let text = self.gh_try(&[
638            "pr",
639            "list",
640            "--head",
641            branch,
642            "--state",
643            "open",
644            "--json",
645            "number,url,title",
646        ]);
647        serde_json::from_str::<Vec<PrRef>>(text.trim())
648            .ok()
649            .and_then(|mut v| {
650                if v.is_empty() {
651                    None
652                } else {
653                    Some(v.remove(0))
654                }
655            })
656    }
657
658    /// Whether a number names an issue or a pull request.
659    ///
660    /// `gh issue view` happily returns a pull request when handed its number,
661    /// so it cannot be used to tell them apart. The issues API carries both and
662    /// marks a pull request with a `pull_request` key, which is definitive.
663    pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
664        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
665        let text = self
666            .gh(&[
667                "api",
668                &path,
669                "--jq",
670                "if .pull_request then \"pr\" else \"issue\" end",
671            ])
672            .map_err(|e| {
673                spar_err!(
674                    "no issue or pull request #{number} in this repository. {}",
675                    e.last_line()
676                )
677            })?;
678        match text.trim() {
679            "pr" => Ok(ItemKind::Pr),
680            "issue" => Ok(ItemKind::Issue),
681            other => Err(spar_err!(
682                "could not tell whether #{number} is an issue or a pull request (got {other:?})"
683            )),
684        }
685    }
686
687    /// An open pull request that would close this issue, whoever opened it.
688    ///
689    /// spar's own branch naming is checked first because it is exact and cheap.
690    /// Falling back to GitHub's own issue linkage is what lets spar pick up a
691    /// pull request a person started on a branch named anything at all.
692    pub fn open_pr_for_issue(&self, issue: i64) -> Option<PrRef> {
693        if let Some(pr) = self.pr_for_branch(&self.branch_for_issue(issue)) {
694            return Some(pr);
695        }
696        let text = self.gh_try(&[
697            "pr",
698            "list",
699            "--state",
700            "open",
701            "--limit",
702            &FETCH_CEILING.to_string(),
703            "--json",
704            "number,url,title,closingIssuesReferences",
705        ]);
706        find_linked_pr(&text, issue)
707    }
708
709    pub fn pr_view(&self, number: i64) -> Result<PrView> {
710        let text = self.gh(&[
711            "pr",
712            "view",
713            &number.to_string(),
714            "--json",
715            "number,url,title,headRefName,baseRefName,state,closingIssuesReferences,isCrossRepository",
716        ])?;
717        serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
718    }
719
720    pub fn pr_state(&self, number: i64) -> String {
721        let text = self.gh_try(&["pr", "view", &number.to_string(), "--json", "state"]);
722        serde_json::from_str::<Value>(text.trim())
723            .ok()
724            .and_then(|v| v.get("state").and_then(Value::as_str).map(str::to_string))
725            .unwrap_or_default()
726    }
727
728    pub fn create_pr(
729        &self,
730        cwd: &Path,
731        branch: &str,
732        base: &str,
733        title: &str,
734        body: &str,
735    ) -> Result<PrRef> {
736        let title = self.clean_title(title)?;
737        let body = self.clean(body)?;
738        self.gh_at(
739            Some(cwd),
740            &[
741                "pr", "create", "--base", base, "--head", branch, "--title", &title, "--body",
742                &body,
743            ],
744        )
745        .map_err(|e| spar_err!("could not open a PR for {branch}. {}", e.last_line()))?;
746        self.pr_for_branch(branch).ok_or_else(|| {
747            spar_err!("PR creation reported success but none was found for {branch}")
748        })
749    }
750
751    pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
752        let body = self.clean(body)?;
753        self.gh(&["pr", "comment", &number.to_string(), "--body", &body])
754            .map(|_| ())
755    }
756
757    pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
758        let body = self.clean(body)?;
759        self.gh(&["issue", "comment", &number.to_string(), "--body", &body])
760            .map(|_| ())
761    }
762
763    /// Comment, then close as not planned.
764    ///
765    /// Only ever called when both agents independently declined the issue: one
766    /// agent's opinion is not enough to close somebody's report.
767    pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
768        self.comment_issue(number, body)?;
769        let n = number.to_string();
770        if self
771            .gh(&["issue", "close", &n, "--reason", "not planned"])
772            .is_ok()
773        {
774            return Ok(());
775        }
776        // Older gh builds do not take --reason.
777        self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
778            spar_err!(
779                "commented on #{number} but could not close it: {}",
780                e.last_line()
781            )
782        })
783    }
784
785    pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
786        let title = self.clean_title(title)?;
787        let body = self.clean_issue_body(body)?;
788        Ok(self
789            .gh(&["issue", "create", "--title", &title, "--body", &body])?
790            .trim()
791            .to_string())
792    }
793}
794
795/// An issue that already covers what spar was about to file.
796#[derive(Debug, Clone)]
797pub struct ExistingIssue {
798    pub number: i64,
799    pub url: String,
800    pub title: String,
801    pub body: String,
802    pub open: bool,
803}
804
805impl Repo {
806    /// An issue that already describes this defect, however it was worded.
807    ///
808    /// Exact title matching let duplicates through: two agents, or two runs a
809    /// week apart, never word one defect identically. A real run filed two
810    /// duplicates that way, and each had to be closed by hand afterwards.
811    /// Titles alone are too thin to match on, so this compares titles and
812    /// bodies together.
813    pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
814        #[derive(Deserialize)]
815        #[serde(rename_all = "camelCase")]
816        struct Row {
817            number: i64,
818            #[serde(default)]
819            title: String,
820            #[serde(default)]
821            url: String,
822            #[serde(default)]
823            body: String,
824            #[serde(default)]
825            state: String,
826        }
827        if title.trim().is_empty() {
828            return None;
829        }
830        // Search on the title's own words: GitHub's index is the cheap way to
831        // narrow the field before comparing properly.
832        let query: String = title
833            .chars()
834            .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
835            .take(120)
836            .collect();
837        let text = self.gh_try(&[
838            "issue",
839            "list",
840            "--state",
841            "all",
842            "--limit",
843            "100",
844            "--search",
845            query.trim(),
846            "--json",
847            "number,title,url,body,state",
848        ]);
849        let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
850        let wanted = format!("{title} {body}");
851
852        rows.into_iter()
853            .find(|row| {
854                let theirs = format!("{} {}", row.title, row.body);
855                row.title.trim().eq_ignore_ascii_case(title.trim())
856                    || textsim::same_subject(&wanted, &theirs)
857            })
858            .map(|row| ExistingIssue {
859                number: row.number,
860                url: row.url,
861                title: row.title,
862                open: row.state.eq_ignore_ascii_case("open"),
863                body: row.body,
864            })
865    }
866
867    /// Avoid filing a duplicate when a follow-up already exists.
868    pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
869        #[derive(Deserialize)]
870        struct Row {
871            title: String,
872            url: String,
873        }
874        let needle = title.trim().to_lowercase();
875        if needle.is_empty() {
876            return None;
877        }
878        // Quotes and newlines would be read as search syntax rather than text.
879        let query: String = title
880            .chars()
881            .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
882            .take(120)
883            .collect();
884        let text = self.gh_try(&[
885            "issue",
886            "list",
887            "--state",
888            "all",
889            "--limit",
890            "100",
891            "--search",
892            query.trim(),
893            "--json",
894            "number,title,url",
895        ]);
896        serde_json::from_str::<Vec<Row>>(text.trim())
897            .ok()?
898            .into_iter()
899            .find(|row| row.title.trim().to_lowercase() == needle)
900            .map(|row| row.url)
901    }
902
903    /// Squash merge, tolerating cleanup failures after a successful merge.
904    ///
905    /// `gh pr merge --delete-branch` exits non-zero when it cannot delete the
906    /// local branch, which happens *after* the merge has already landed.
907    /// Treating that as a failure reports work as lost when it is not.
908    pub fn merge_pr(&self, number: i64) -> Result<()> {
909        let n = number.to_string();
910        match self.gh(&["pr", "merge", &n, "--squash", "--delete-branch"]) {
911            Ok(_) => Ok(()),
912            Err(e) => {
913                if self.pr_state(number) == "MERGED" {
914                    logdim!(
915                        "PR #{number} merged; branch cleanup did not finish: {}",
916                        e.last_line()
917                    );
918                    Ok(())
919                } else {
920                    Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
921                }
922            }
923        }
924    }
925
926    // -- follow-ups -------------------------------------------------------
927
928    /// Append a follow-up to a local note instead of the tracker.
929    ///
930    /// Deduplicated on the title, matching the issue path. Returns a display
931    /// string, or None when it was already recorded. The body arrives with its
932    /// provenance already stamped by the caller, so nothing is added here.
933    pub fn append_local_followup(&self, title: &str, body: &str) -> Option<String> {
934        let path = self.root.join(STATE_DIR).join("followups.md");
935        let heading = format!("## {}", title.trim());
936        if let Ok(existing) = std::fs::read_to_string(&path) {
937            if existing.contains(&heading) {
938                logdim!("follow-up already noted: {title}");
939                return None;
940            }
941        }
942        if let Some(parent) = path.parent() {
943            let _ = std::fs::create_dir_all(parent);
944        }
945        use std::io::Write;
946        // The caller already stamped the provenance into the body. Adding
947        // "From #N." here as well printed it twice, in two different wordings.
948        let entry = format!("{heading}\n\n{}\n\n", body.trim());
949        match std::fs::OpenOptions::new()
950            .create(true)
951            .append(true)
952            .open(&path)
953        {
954            Ok(mut file) => {
955                let _ = file.write_all(entry.as_bytes());
956                Some(format!("note: {}", title.trim()))
957            }
958            Err(e) => {
959                logdim!("could not write {}: {e}", path.display());
960                None
961            }
962        }
963    }
964
965    // -- resumable state --------------------------------------------------
966    //
967    // Custody cannot be read from GitHub authorship: every agent commits and
968    // comments as the same git identity, so `author` is always the human who
969    // ran spar. State is kept on disk by default and can additionally travel in
970    // a PR comment, which is what lets a run be resumed from another machine.
971
972    /// Where a comment spar produced but did not post is kept.
973    pub fn pending_comment_path(&self, number: i64) -> PathBuf {
974        self.root
975            .join(STATE_DIR)
976            .join("reviews")
977            .join(format!("pr-{number}.md"))
978    }
979
980    /// Keep a comment spar decided not to post.
981    ///
982    /// A dry run that prints and forgets means agreeing with what you read
983    /// costs a second full review. Saving it makes the whole point of reading
984    /// it first: look, edit if you like, then post what you already paid for.
985    pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
986        let path = self.pending_comment_path(number);
987        if let Some(parent) = path.parent() {
988            std::fs::create_dir_all(parent)
989                .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
990        }
991        std::fs::write(&path, text)
992            .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
993        Ok(path)
994    }
995
996    pub fn read_pending_comment(&self, number: i64) -> Option<String> {
997        std::fs::read_to_string(self.pending_comment_path(number)).ok()
998    }
999
1000    pub fn state_path(&self, number: i64) -> PathBuf {
1001        self.root
1002            .join(STATE_DIR)
1003            .join("state")
1004            .join(format!("pr-{number}.json"))
1005    }
1006
1007    fn read_local_state(&self, number: i64) -> Option<PersistedState> {
1008        let path = self.state_path(number);
1009        let text = std::fs::read_to_string(&path).ok()?;
1010        match serde_json::from_str(&text) {
1011            Ok(state) => Some(state),
1012            Err(_) => {
1013                logdim!("could not read {}, starting fresh", path.display());
1014                None
1015            }
1016        }
1017    }
1018
1019    pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
1020        if let Some(local) = self.read_local_state(pr.number) {
1021            return Some(local);
1022        }
1023        if self.state_store.writes_pr() {
1024            return self.read_pr_state(pr.number);
1025        }
1026        None
1027    }
1028
1029    fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
1030        for body in self.state_comment_bodies(number).into_iter().rev() {
1031            if let Some(state) = parse_state_comment(&body) {
1032                return Some(state);
1033            }
1034        }
1035        None
1036    }
1037
1038    pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
1039        if self.state_store.writes_local() {
1040            write_json_atomic(&self.state_path(number), state)?;
1041        }
1042        if self.state_store.writes_pr() {
1043            self.write_pr_state(number, state)?;
1044        }
1045        Ok(())
1046    }
1047
1048    fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
1049        // Not run through clean(): this is structured data, and scrubbing would
1050        // corrupt refutation text stored in the ledger. It sits inside an
1051        // unclosed HTML comment so GitHub renders it as nothing.
1052        let body = format!(
1053            "{STATE_MARKER}\n{}\n-->",
1054            serde_json::to_string_pretty(state)?
1055        );
1056        if let Some(id) = self.state_comment_id(number) {
1057            let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
1058            let field = format!("body={body}");
1059            self.gh_try(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"]);
1060            return Ok(());
1061        }
1062        self.gh(&["pr", "comment", &number.to_string(), "--body", &body])
1063            .map(|_| ())
1064    }
1065
1066    fn comments_json(&self, number: i64) -> Vec<Value> {
1067        let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
1068        parse_comment_pages(&self.gh_try(&["api", "--paginate", &path]))
1069    }
1070
1071    fn state_comments(&self, number: i64) -> Vec<(i64, String)> {
1072        self.comments_json(number)
1073            .into_iter()
1074            .filter_map(|c| {
1075                let body = c.get("body").and_then(Value::as_str)?.to_string();
1076                if !body.contains("spar:state") {
1077                    return None;
1078                }
1079                let id = c.get("id").and_then(Value::as_i64)?;
1080                Some((id, body))
1081            })
1082            .collect()
1083    }
1084
1085    fn state_comment_bodies(&self, number: i64) -> Vec<String> {
1086        self.state_comments(number)
1087            .into_iter()
1088            .map(|(_, b)| b)
1089            .collect()
1090    }
1091
1092    fn state_comment_id(&self, number: i64) -> Option<i64> {
1093        self.state_comments(number).last().map(|(id, _)| *id)
1094    }
1095
1096    /// Drop state once the PR is finished and there is nothing to resume.
1097    pub fn clear_state(&self, number: i64) {
1098        let path = self.state_path(number);
1099        let _ = std::fs::remove_file(&path);
1100        let _ = std::fs::remove_file(path.with_extension("json.tmp"));
1101    }
1102
1103    // -- housekeeping -----------------------------------------------------
1104
1105    /// Remove state files whose PR is merged or closed.
1106    pub fn prune_state(&self) -> Vec<String> {
1107        let base = self.root.join(STATE_DIR).join("state");
1108        let Ok(entries) = std::fs::read_dir(&base) else {
1109            return Vec::new();
1110        };
1111        let mut names: Vec<String> = entries
1112            .flatten()
1113            .filter_map(|e| e.file_name().to_str().map(str::to_string))
1114            .filter(|n| n.starts_with("pr-") && n.ends_with(".json"))
1115            .collect();
1116        names.sort();
1117
1118        let mut removed = Vec::new();
1119        for name in names {
1120            let Ok(number) = name[3..name.len() - 5].parse::<i64>() else {
1121                continue;
1122            };
1123            if is_finished(&self.pr_state(number)) {
1124                let _ = std::fs::remove_file(base.join(&name));
1125                removed.push(format!("state {name}"));
1126            }
1127        }
1128        removed
1129    }
1130
1131    /// Delete state comments from PRs that are finished.
1132    ///
1133    /// Open PRs are left alone: their state may still be live.
1134    pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
1135        #[derive(Deserialize)]
1136        struct Row {
1137            number: i64,
1138        }
1139        let numbers = numbers.unwrap_or_else(|| {
1140            let text = self.gh_try(&[
1141                "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
1142            ]);
1143            serde_json::from_str::<Vec<Row>>(text.trim())
1144                .unwrap_or_default()
1145                .into_iter()
1146                .map(|r| r.number)
1147                .collect()
1148        });
1149
1150        let mut removed = Vec::new();
1151        for number in numbers {
1152            if !is_finished(&self.pr_state(number)) {
1153                continue;
1154            }
1155            for (id, _) in self.state_comments(number) {
1156                let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
1157                self.gh_try(&["api", "-X", "DELETE", &path, "--silent"]);
1158                removed.push(format!("state comment on PR #{number}"));
1159            }
1160        }
1161        removed
1162    }
1163
1164    /// Drop worktrees whose PR is finished, then the branches they left behind.
1165    ///
1166    /// With auto_merge off, which is the default, a run ends at "approved", so
1167    /// nothing would ever clean these up on its own and they accumulate one per
1168    /// run. A stranded worktree also holds its branch checked out, which makes
1169    /// a later `gh pr merge --delete-branch` fail to clean up.
1170    pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
1171        let base = self.root.join(WORKTREE_DIR);
1172        let mut removed = Vec::new();
1173
1174        if let Ok(entries) = std::fs::read_dir(&base) {
1175            let mut names: Vec<String> = entries
1176                .flatten()
1177                .filter(|e| e.path().is_dir())
1178                .filter_map(|e| e.file_name().to_str().map(str::to_string))
1179                .collect();
1180            names.sort();
1181
1182            for name in names {
1183                // A review worktree is detached and owns no branch, so it is
1184                // tied to the pull request only by its directory name.
1185                if let Some(rest) = name.strip_prefix("review-") {
1186                    let number: i64 = rest.parse().unwrap_or(-1);
1187                    if !(force_all || is_finished(&self.pr_state(number))) {
1188                        continue;
1189                    }
1190                    self.release_review_worktree(number);
1191                    removed.push(name);
1192                    continue;
1193                }
1194                let branch = format!("{}{name}", self.branch_prefix);
1195                if !(force_all || self.worktree_is_done(&branch)) {
1196                    continue;
1197                }
1198                self.remove_worktree_at(&base.join(&name));
1199                self.git_try(&["branch", "-D", &branch]);
1200                self.forget_branch(&branch);
1201                removed.push(name);
1202            }
1203        }
1204        if !removed.is_empty() {
1205            self.git_try(&["worktree", "prune"]);
1206        }
1207        removed.extend(self.prune_branches(force_all));
1208        removed
1209    }
1210
1211    /// Delete leftover branches spar created whose worktree is already gone.
1212    ///
1213    /// Deletion is driven by the ledger of branches spar actually created, not
1214    /// by a name pattern. Names default to `issue-N`, which is exactly what a
1215    /// person would call a branch themselves, so a name alone can never
1216    /// establish ownership. This is the data loss guard.
1217    pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
1218        let branches: Vec<String> = self.known_branches().keys().cloned().collect();
1219        if branches.is_empty() {
1220            return Vec::new();
1221        }
1222
1223        let checked_out: Vec<String> = self
1224            .git_try(&["worktree", "list", "--porcelain"])
1225            .lines()
1226            .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
1227            .collect();
1228
1229        // %(refname:short) is ambiguous when a tag shares the branch name (it
1230        // yields "heads/..."), so take the full ref and strip it here.
1231        let existing: Vec<String> = self
1232            .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
1233            .lines()
1234            .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
1235            .collect();
1236
1237        let mut removed = Vec::new();
1238        for branch in branches {
1239            if !existing.contains(&branch) {
1240                self.forget_branch(&branch); // already gone, drop the record
1241                continue;
1242            }
1243            if checked_out.contains(&branch) {
1244                continue;
1245            }
1246            if !(force_all || self.worktree_is_done(&branch)) {
1247                continue;
1248            }
1249            match self.git(&["branch", "-D", &branch]) {
1250                Ok(_) => {
1251                    self.forget_branch(&branch);
1252                    removed.push(format!("branch {branch}"));
1253                }
1254                Err(e) => {
1255                    // A branch that silently survives pruning looks like a spar
1256                    // bug, so the name and git's own reason have to be said.
1257                    logdim!("could not delete {branch}: {}", e.last_line());
1258                }
1259            }
1260        }
1261        removed
1262    }
1263
1264    /// True when the PR behind this branch is merged or closed.
1265    fn worktree_is_done(&self, branch: &str) -> bool {
1266        #[derive(Deserialize)]
1267        struct Row {
1268            state: String,
1269        }
1270        let entry = branch
1271            .strip_prefix(self.branch_prefix.as_str())
1272            .unwrap_or(branch);
1273        if let Some(rest) = entry.strip_prefix("pr-") {
1274            return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
1275        }
1276        if entry.starts_with("issue-") {
1277            let text = self.gh_try(&[
1278                "pr", "list", "--head", branch, "--state", "all", "--json", "state",
1279            ]);
1280            let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
1281            return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
1282        }
1283        false
1284    }
1285}
1286
1287// ---------------------------------------------------------------------------
1288// Free helpers
1289// ---------------------------------------------------------------------------
1290
1291#[derive(Debug, Clone, serde::Serialize, Deserialize)]
1292pub struct BranchRecord {
1293    pub kind: String,
1294    pub number: i64,
1295}
1296
1297/// Where a pull request's fetched head is parked. Under `refs/spar/` rather
1298/// than `refs/heads/` so it can never be mistaken for a branch, or pushed.
1299pub fn review_ref(number: i64) -> String {
1300    format!("refs/spar/pr-{number}")
1301}
1302
1303pub fn is_finished(state: &str) -> bool {
1304    matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
1305}
1306
1307/// Write JSON through a temporary file and rename, so a kill cannot leave a
1308/// truncated state file behind.
1309pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
1310    if let Some(parent) = path.parent() {
1311        std::fs::create_dir_all(parent)
1312            .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1313    }
1314    let tmp = path.with_extension(format!(
1315        "{}.tmp",
1316        path.extension().and_then(|e| e.to_str()).unwrap_or("json")
1317    ));
1318    std::fs::write(&tmp, serde_json::to_vec_pretty(value)?)
1319        .map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
1320    std::fs::rename(&tmp, path)
1321        .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
1322    Ok(())
1323}
1324
1325/// Among the open pull requests gh listed, the first that would close `issue`.
1326///
1327/// Separated from the gh call so the real payload shape can be tested. GitHub
1328/// returns far more per linked issue than the number, and silently failing to
1329/// parse it would look exactly like "no pull request exists", which is the
1330/// answer that makes spar implement over the top of somebody's work.
1331pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
1332    #[derive(Deserialize)]
1333    #[serde(rename_all = "camelCase")]
1334    struct Row {
1335        number: i64,
1336        #[serde(default)]
1337        url: String,
1338        #[serde(default)]
1339        title: String,
1340        #[serde(default)]
1341        closing_issues_references: Vec<IssueRef>,
1342    }
1343
1344    serde_json::from_str::<Vec<Row>>(json.trim())
1345        .ok()?
1346        .into_iter()
1347        .find(|row| {
1348            row.closing_issues_references
1349                .iter()
1350                .any(|linked| linked.number == issue)
1351        })
1352        .map(|row| PrRef {
1353            number: row.number,
1354            url: row.url,
1355            title: row.title,
1356        })
1357}
1358
1359/// Flatten whatever `gh api --paginate` printed into a list of comments.
1360///
1361/// Current gh merges array pages into one array. Older builds concatenated one
1362/// document per page. A streaming parser reads either, and unlike splitting the
1363/// text on a bracket pair it cannot be fooled by a comment body that happens to
1364/// contain one, which would otherwise make a resume silently start over.
1365pub fn parse_comment_pages(text: &str) -> Vec<Value> {
1366    let mut out = Vec::new();
1367    for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
1368        match value {
1369            Ok(Value::Array(items)) => out.extend(items),
1370            Ok(other) => out.push(other),
1371            Err(_) => break,
1372        }
1373    }
1374    out
1375}
1376
1377/// Extract the payload from a state comment. The marker is followed by JSON and
1378/// terminated with `-->`.
1379pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
1380    let marker = body.find(STATE_MARKER)?;
1381    let start = body[marker..].find('{')? + marker;
1382    let end = body.rfind('}')?;
1383    if end <= start {
1384        return None;
1385    }
1386    match serde_json::from_str(&body[start..=end]) {
1387        Ok(state) => Some(state),
1388        Err(_) => {
1389            logdim!("found a spar state comment but could not parse it");
1390            None
1391        }
1392    }
1393}
1394
1395/// Where this binary lives, so `git filter-branch` can call back into it.
1396///
1397/// `SPAR_SELF_BIN` overrides the answer. That matters for the integration
1398/// tests, whose `current_exe` is the test harness rather than spar, and for
1399/// anyone who ships spar behind a wrapper script.
1400pub fn self_binary() -> Result<PathBuf> {
1401    if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
1402        let path = PathBuf::from(path);
1403        if proc::is_executable(&path) {
1404            return Ok(path);
1405        }
1406        bail!(
1407            "SPAR_SELF_BIN is set to {}, which is not executable",
1408            path.display()
1409        );
1410    }
1411    std::env::current_exe()
1412        .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
1413}
1414
1415fn bool_env(value: bool) -> &'static str {
1416    if value {
1417        "1"
1418    } else {
1419        "0"
1420    }
1421}
1422
1423/// Wrap a string for a POSIX shell. `git filter-branch` takes its filter as a
1424/// shell command, and an install path with a space in it is not exotic.
1425pub fn sh_quote(text: &str) -> String {
1426    format!("'{}'", text.replace('\'', r"'\''"))
1427}
1428
1429/// Style rules for the `scrub-filter` subcommand, which runs in a child process
1430/// spawned by git and so cannot see the parent's config.
1431pub fn style_from_env() -> Style {
1432    let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
1433    Style {
1434        ban_em_dash: flag("SPAR_BAN_EM_DASH"),
1435        ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
1436        ..Style::permissive()
1437    }
1438}
1439
1440#[cfg(test)]
1441mod tests {
1442    use super::*;
1443    use crate::config::StateStore;
1444    use crate::model::{Ledger, Status};
1445
1446    fn repo_for_titles() -> Repo {
1447        Repo {
1448            root: PathBuf::from("/nonexistent"),
1449            style: Style::default(),
1450            branch_prefix: String::new(),
1451            state_store: StateStore::Local,
1452            followups: crate::config::Followups::Issues,
1453        }
1454    }
1455
1456    /// Follow-up deduplication compares a title it computed against the title
1457    /// GitHub stored. If those two transforms can disagree, the check never
1458    /// matches and every review round files another copy of the same issue.
1459    #[test]
1460    fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
1461        let repo = repo_for_titles();
1462        for raw in [
1463            "Retry loop spins \u{2014} Retry-After parses to zero",
1464            "plain title",
1465            "  spread   over\nlines  ",
1466            "\u{1F916} Generated with something",
1467            &format!("a \u{2014} {}", "very long title ".repeat(20)),
1468            &"x".repeat(300),
1469            &format!("{} \u{2014} end", "y".repeat(88)),
1470            // Exactly the budget, with two spaceless dashes. The scrub turns
1471            // each "a\u{2014}b" into "a, b", so clip-then-scrub lands one
1472            // character over budget per dash and a second pass clips again,
1473            // producing a different string. Scrub-then-clip cannot.
1474            &{
1475                let tail = "a\u{2014}b c\u{2014}d";
1476                let pad = Style::default().max_title_chars - tail.chars().count();
1477                format!("{}{tail}", "w".repeat(pad))
1478            },
1479        ] {
1480            let once = repo.clean_title(raw).unwrap();
1481            let twice = repo.clean_title(&once).unwrap();
1482            assert_eq!(once, twice, "not idempotent for {raw:?}");
1483            assert!(
1484                once.chars().count() <= repo.style.max_title_chars,
1485                "over budget: {once:?}"
1486            );
1487            assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
1488        }
1489    }
1490
1491    #[test]
1492    fn a_title_with_an_em_dash_survives_as_readable_text() {
1493        let repo = repo_for_titles();
1494        assert_eq!(
1495            "Retry loop spins, Retry-After parses to zero",
1496            repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
1497                .unwrap()
1498        );
1499    }
1500
1501    #[test]
1502    fn sh_quote_survives_a_quote() {
1503        assert_eq!(r"'a'\''b'", sh_quote("a'b"));
1504    }
1505
1506    #[test]
1507    fn sh_quote_wraps_a_space() {
1508        assert_eq!(
1509            "'/Applications/My App/spar'",
1510            sh_quote("/Applications/My App/spar")
1511        );
1512    }
1513
1514    #[test]
1515    fn finished_states_are_recognised_case_insensitively() {
1516        assert!(is_finished("MERGED"));
1517        assert!(is_finished("closed"));
1518        assert!(!is_finished("OPEN"));
1519        assert!(!is_finished(""));
1520    }
1521
1522    fn state() -> PersistedState {
1523        PersistedState {
1524            version: 1,
1525            round: 4,
1526            next_actor: "codex".into(),
1527            status: Status::Pending,
1528            ledger: Ledger::new(),
1529            filed: vec![],
1530        }
1531    }
1532
1533    #[test]
1534    fn a_state_comment_round_trips() {
1535        let body = format!(
1536            "{STATE_MARKER}\n{}\n-->",
1537            serde_json::to_string(&state()).unwrap()
1538        );
1539        let back = parse_state_comment(&body).unwrap();
1540        assert_eq!(4, back.round);
1541        assert_eq!("codex", back.next_actor);
1542    }
1543
1544    /// It must render as nothing, so PRs are not littered with machine state.
1545    #[test]
1546    fn the_state_block_is_an_html_comment() {
1547        let body = format!(
1548            "{STATE_MARKER}\n{}\n-->",
1549            serde_json::to_string(&state()).unwrap()
1550        );
1551        assert!(body.starts_with("<!--"));
1552        assert!(body.trim_end().ends_with("-->"));
1553        assert!(!body[..body.find('{').unwrap()].contains("-->"));
1554    }
1555
1556    #[test]
1557    fn an_unrelated_json_block_is_not_state() {
1558        assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
1559    }
1560
1561    #[test]
1562    fn a_malformed_state_comment_is_none_not_a_panic() {
1563        assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
1564    }
1565
1566    #[test]
1567    fn atomic_write_leaves_no_temp_file() {
1568        let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
1569        let _ = std::fs::remove_dir_all(&dir);
1570        let path = dir.join("state").join("pr-7.json");
1571        write_json_atomic(&path, &state()).unwrap();
1572        let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
1573            .unwrap()
1574            .flatten()
1575            .filter_map(|e| e.file_name().to_str().map(str::to_string))
1576            .collect();
1577        assert_eq!(vec!["pr-7.json".to_string()], files);
1578        let _ = std::fs::remove_dir_all(&dir);
1579    }
1580
1581    #[test]
1582    fn atomic_write_overwrites_rather_than_accumulating() {
1583        let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
1584        let _ = std::fs::remove_dir_all(&dir);
1585        let path = dir.join("pr-7.json");
1586        for round in 1..4 {
1587            let mut s = state();
1588            s.round = round;
1589            write_json_atomic(&path, &s).unwrap();
1590        }
1591        let back: PersistedState =
1592            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1593        assert_eq!(3, back.round);
1594        let _ = std::fs::remove_dir_all(&dir);
1595    }
1596
1597    #[test]
1598    fn style_from_env_defaults_to_enforcing() {
1599        std::env::remove_var("SPAR_BAN_EM_DASH");
1600        std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
1601        let style = style_from_env();
1602        assert!(style.ban_em_dash && style.ban_ai_attribution);
1603        assert!(
1604            !style.terse,
1605            "the commit filter must not truncate a commit message"
1606        );
1607    }
1608}
1609
1610#[cfg(test)]
1611mod comment_page_tests {
1612    use super::*;
1613
1614    #[test]
1615    fn a_single_merged_array_is_read() {
1616        let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
1617        assert_eq!(2, pages.len());
1618        assert_eq!(Some(2), pages[1]["id"].as_i64());
1619    }
1620
1621    #[test]
1622    fn concatenated_pages_from_an_older_gh_are_read_too() {
1623        let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
1624        assert_eq!(2, pages.len());
1625    }
1626
1627    /// A comment body containing a bracket pair used to split the payload into
1628    /// two invalid halves, so no state comment was found and a resume silently
1629    /// started from round one.
1630    #[test]
1631    fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
1632        let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
1633        let pages = parse_comment_pages(text);
1634        assert_eq!(2, pages.len(), "{pages:?}");
1635        assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
1636    }
1637
1638    #[test]
1639    fn empty_output_is_no_comments_not_a_panic() {
1640        assert!(parse_comment_pages("").is_empty());
1641        assert!(parse_comment_pages("   ").is_empty());
1642        assert!(parse_comment_pages("[]").is_empty());
1643    }
1644
1645    #[test]
1646    fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
1647        assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
1648    }
1649
1650    #[test]
1651    fn state_is_found_in_the_last_matching_comment() {
1652        let payload = |round: u32| {
1653            format!(
1654                "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
1655            )
1656        };
1657        let text = serde_json::to_string(&serde_json::json!([
1658            {"id": 1, "body": payload(1)},
1659            {"id": 2, "body": "looks good to me"},
1660            {"id": 3, "body": payload(5)},
1661        ]))
1662        .unwrap();
1663        let pages = parse_comment_pages(&text);
1664        let last = pages
1665            .iter()
1666            .rev()
1667            .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
1668            .unwrap();
1669        assert_eq!(5, last.round);
1670    }
1671}
1672
1673#[cfg(test)]
1674mod linked_pr_tests {
1675    use super::*;
1676
1677    /// The exact shape `gh pr list --json closingIssuesReferences` returns.
1678    /// It carries an id and a whole repository object per linked issue, and a
1679    /// parser that chokes on those reports "no pull request", which is the one
1680    /// answer that makes spar implement over the top of somebody's work.
1681    const REAL_PAYLOAD: &str = r#"[
1682      {"number":14252,"title":"fix: reject leading-dash branch names",
1683       "url":"https://github.com/cli/cli/pull/14252",
1684       "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
1685         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
1686         "url":"https://github.com/cli/cli/issues/14238"}]},
1687      {"number":14217,"title":"another change",
1688       "url":"https://github.com/cli/cli/pull/14217",
1689       "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
1690         "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
1691         "url":"https://github.com/cli/cli/issues/9761"}]},
1692      {"number":14200,"title":"unlinked work",
1693       "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
1694    ]"#;
1695
1696    #[test]
1697    fn a_linked_pr_is_found_whatever_its_branch_is_called() {
1698        let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
1699        assert_eq!(14252, pr.number);
1700        assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
1701    }
1702
1703    #[test]
1704    fn the_right_pr_is_picked_out_of_several() {
1705        assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
1706    }
1707
1708    #[test]
1709    fn an_issue_nobody_is_working_on_finds_nothing() {
1710        assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
1711    }
1712
1713    #[test]
1714    fn an_unlinked_pr_is_never_matched() {
1715        // 14200 closes nothing, so no issue number should ever return it.
1716        for issue in [14200, 0, 1] {
1717            if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
1718                assert_ne!(14200, pr.number, "matched a PR that closes nothing");
1719            }
1720        }
1721    }
1722
1723    #[test]
1724    fn empty_or_broken_output_is_none_rather_than_a_panic() {
1725        assert!(find_linked_pr("", 1).is_none());
1726        assert!(find_linked_pr("[]", 1).is_none());
1727        assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
1728        assert!(find_linked_pr("[{\"number\":", 1).is_none());
1729    }
1730
1731    /// A fork PR cannot be pushed to, so the flag has to survive parsing.
1732    #[test]
1733    fn pr_view_reads_the_cross_repository_flag() {
1734        let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
1735                       "baseRefName":"main","state":"OPEN",
1736                       "closingIssuesReferences":[],"isCrossRepository":true}"#;
1737        let pr: PrView = serde_json::from_str(json).unwrap();
1738        assert!(pr.is_cross_repository);
1739        assert!(pr.is_open());
1740
1741        let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
1742        assert!(
1743            !serde_json::from_str::<PrView>(&same_repo)
1744                .unwrap()
1745                .is_cross_repository
1746        );
1747    }
1748}
1749
1750#[cfg(test)]
1751mod min_number_tests {
1752    /// The floor is applied before the cap, which is the order that matters.
1753    /// spar takes the *lowest* numbered open items, so a repository with a tail
1754    /// of old issues would otherwise spend its whole run in the tail: the cap
1755    /// would be filled by the oldest items and the floor would never be
1756    /// reached. Filtering first is what makes the setting do anything.
1757    fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
1758        let mut numbers: Vec<i64> = open.to_vec();
1759        numbers.sort_unstable();
1760        if min_number > 0 {
1761            numbers.retain(|n| *n >= min_number);
1762        }
1763        numbers.truncate(limit);
1764        numbers
1765    }
1766
1767    #[test]
1768    fn the_floor_is_applied_before_the_cap_not_after() {
1769        let open = [12, 13, 14, 480, 481, 482];
1770        assert_eq!(vec![480, 481], pick(&open, 2, 480));
1771        // Capping first would have returned the two oldest and then filtered
1772        // them all away, leaving nothing.
1773        assert!(!pick(&open, 2, 480).is_empty());
1774    }
1775
1776    #[test]
1777    fn no_floor_keeps_the_old_behaviour() {
1778        assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
1779    }
1780
1781    #[test]
1782    fn the_floor_is_inclusive() {
1783        assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
1784    }
1785
1786    #[test]
1787    fn a_floor_above_everything_open_yields_nothing() {
1788        assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
1789    }
1790}