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