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