1use 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::{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
19pub const FETCH_CEILING: usize = 500;
23
24pub const STATE_MARKER: &str = "<!-- spar:state";
27
28pub 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 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 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 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 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 pub fn clean_body(&self, text: &str) -> Result<String> {
154 self.clean(&style::body(text, &self.style))
155 }
156
157 pub fn clean_issue_body(&self, text: &str) -> Result<String> {
159 self.clean(&style::issue_body(text, &self.style))
160 }
161
162 pub fn clean_title(&self, text: &str) -> Result<String> {
173 Ok(style::title(&self.clean(text)?, &self.style))
174 }
175
176 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 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 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 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 fn worktree_path(&self, name: &str) -> PathBuf {
269 self.root.join(WORKTREE_DIR).join(name)
270 }
271
272 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 self.git_try(&["fetch", "origin", &branch]);
288 let remote_branch = format!("origin/{branch}");
289 if self.rev_exists(&self.root, &remote_branch) {
290 let range = format!("origin/{base}..{remote_branch}");
291 let ahead: u32 = self
292 .git_try(&["rev-list", "--count", &range])
293 .trim()
294 .parse()
295 .unwrap_or(0);
296 if ahead > 0 {
297 bail!(
298 "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
299 open pull request accounts for them. Rebuilding it would force push over \
300 that work.\nOpen a pull request for the branch and run `spar resume <pr>` to \
301 continue it, or delete it with `git push origin --delete {branch}` if it is \
302 stale."
303 );
304 }
305 }
306
307 self.worktree_remove(issue);
308 self.git_try(&["branch", "-D", &branch]);
309
310 if let Some(parent) = path.parent() {
311 std::fs::create_dir_all(parent)
312 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
313 }
314
315 let path_str = path.display().to_string();
316 let remote_start = format!("origin/{base}");
317 let created = self
318 .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
319 .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
320
321 created.map_err(|e| {
324 spar_err!(
325 "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
326 and does `origin` exist?",
327 e.last_line()
328 )
329 })?;
330 self.record_branch(&branch, "issue", issue);
331 Ok((path, branch))
332 }
333
334 pub fn worktree_remove(&self, issue: i64) {
335 self.remove_worktree_at(&self.worktree_path(&format!("issue-{issue}")));
336 }
337
338 fn remove_worktree_at(&self, path: &Path) {
339 let path_str = path.display().to_string();
340 self.git_try(&["worktree", "remove", "--force", &path_str]);
341 if path.is_dir() {
342 let _ = std::fs::remove_dir_all(path);
343 }
344 self.git_try(&["worktree", "prune"]);
345 }
346
347 pub fn worktree_for_pr(&self, pr: &PrView) -> Result<(PathBuf, String)> {
349 let head = pr.head_ref_name.clone();
350 if head.trim().is_empty() {
351 bail!("PR #{} has no head branch to check out", pr.number);
352 }
353 let path = self.worktree_path(&format!("pr-{}", pr.number));
354 let local = self.branch_for_pr(pr.number);
355
356 self.git(&["fetch", "origin", &head]).map_err(|e| {
357 spar_err!(
358 "could not fetch the branch behind PR #{}: {}",
359 pr.number,
360 e.last_line()
361 )
362 })?;
363 self.remove_worktree_at(&path);
364 self.git_try(&["branch", "-D", &local]);
365
366 let path_str = path.display().to_string();
367 let start = format!("origin/{head}");
368 self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
369 self.record_branch(&local, "pr", pr.number);
370 Ok((path, head))
371 }
372
373 pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
383 let path = self.worktree_path(&format!("review-{number}"));
384 let local_ref = review_ref(number);
385 let refspec = format!("+refs/pull/{number}/head:{local_ref}");
386
387 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
388 spar_err!(
389 "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
390 every pull request, so this usually means the number is wrong or `origin` does \
391 not point at the repository the PR is on.",
392 e.last_line()
393 )
394 })?;
395
396 if let Some(parent) = path.parent() {
397 std::fs::create_dir_all(parent)
398 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
399 }
400 self.remove_worktree_at(&path);
401 let path_str = path.display().to_string();
402 self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
403 Ok(path)
404 }
405
406 pub fn release_review_worktree(&self, number: i64) {
407 self.remove_worktree_at(&self.worktree_path(&format!("review-{number}")));
408 self.git_try(&["update-ref", "-d", &review_ref(number)]);
409 }
410
411 pub fn release_pr_worktree(&self, number: i64) {
412 let path = self.worktree_path(&format!("pr-{number}"));
413 self.remove_worktree_at(&path);
414 let local = self.branch_for_pr(number);
415 self.git_try(&["branch", "-D", &local]);
416 self.forget_branch(&local);
417 }
418
419 pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
430 let remote = format!("origin/{base}");
431 if self.rev_exists(cwd, &remote) {
432 return remote;
433 }
434 if self.rev_exists(cwd, base) {
435 logdim!("origin/{base} does not resolve, comparing against local {base}");
436 return base.to_string();
437 }
438 logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
439 remote
440 }
441
442 fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
443 let spec = format!("{refname}^{{commit}}");
444 !self
445 .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
446 .trim()
447 .is_empty()
448 }
449
450 pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
451 let range = format!("{}..HEAD", self.base_ref(cwd, base));
452 !self
453 .git_try_at(Some(cwd), &["log", &range, "--oneline"])
454 .trim()
455 .is_empty()
456 }
457
458 pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
459 let range = format!("{}...HEAD", self.base_ref(cwd, base));
460 let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
461 full.trim().to_string()
462 }
463
464 pub fn rewrite_commits_if_needed(&self, cwd: &Path, base: &str) -> Result<()> {
469 let range = format!("{}..HEAD", self.base_ref(cwd, base));
470 let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
471
472 let offenders = raw
473 .split('\x1e')
474 .filter_map(|entry| entry.split_once('\0'))
475 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
476 .count();
477 if offenders == 0 {
478 return Ok(());
479 }
480 logdim!("{offenders} commit message(s) violated style rules, rewriting");
481
482 let exe = self_binary()?;
483 let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
484
485 let argv: Vec<String> = [
486 "git",
487 "filter-branch",
488 "-f",
489 "--msg-filter",
490 &filter,
491 &range,
492 ]
493 .iter()
494 .map(|s| s.to_string())
495 .collect();
496 let opts = ExecOpts::new()
497 .cwd(cwd)
498 .check(false)
499 .timeout_secs(600)
500 .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
501 .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
502 .env(
503 "SPAR_BAN_AI_ATTRIBUTION",
504 bool_env(self.style.ban_ai_attribution),
505 );
506 let _ = proc::run(&argv, &opts);
507
508 let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
509 if !style::violations(&after, &self.style).is_empty() {
510 bail!(
511 "commit messages still violate style rules after a rewrite. Fix them by hand in \
512 {} and rerun.",
513 cwd.display()
514 );
515 }
516 Ok(())
517 }
518
519 pub fn push(&self, cwd: &Path, branch: &str) -> Result<()> {
525 let refspec = format!("HEAD:{branch}");
526 self.git_at(
527 Some(cwd),
528 &["push", "--force-with-lease", "origin", &refspec],
529 )
530 .map(|_| ())
531 .map_err(|e| {
532 spar_err!(
533 "could not push to origin/{branch}. {}\nCheck push access and whether the \
534 branch moved under you.",
535 e.last_line()
536 )
537 })
538 }
539
540 pub fn gh(&self, args: &[&str]) -> Result<String> {
543 self.gh_at(None, args)
544 }
545
546 pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
547 let mut argv = vec!["gh".to_string()];
548 argv.extend(args.iter().map(|s| s.to_string()));
549 proc::run(
550 &argv,
551 &ExecOpts::new()
552 .cwd(cwd.unwrap_or(&self.root))
553 .timeout_secs(300),
554 )
555 }
556
557 pub fn gh_try(&self, args: &[&str]) -> String {
558 let mut argv = vec!["gh".to_string()];
559 argv.extend(args.iter().map(|s| s.to_string()));
560 proc::run(
561 &argv,
562 &ExecOpts::new()
563 .cwd(&self.root)
564 .check(false)
565 .timeout_secs(300),
566 )
567 .unwrap_or_default()
568 }
569
570 pub fn viewer_login(&self) -> Result<&str> {
581 if let Some(login) = self.viewer.get() {
582 return Ok(login);
583 }
584 let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
585 let login = if !rest.trim().is_empty() {
586 rest.trim().to_string()
587 } else {
588 self.gh(&[
591 "api",
592 "graphql",
593 "-f",
594 "query={ viewer { login } }",
595 "--jq",
596 ".data.viewer.login",
597 ])
598 .map_err(|e| {
599 spar_err!(
600 "could not find out who `gh` is authenticated as, so spar cannot tell its \
601 own comments from anybody else's. {}\nRun `gh auth status`.",
602 e.last_line()
603 )
604 })?
605 .trim()
606 .to_string()
607 };
608 if login.is_empty() {
609 bail!("`gh` reported an empty login. Run `gh auth status`.");
610 }
611 Ok(self.viewer.get_or_init(|| login))
612 }
613
614 pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
615 let mut issues = Vec::new();
616 for number in numbers {
617 let text = self
618 .gh(&[
619 "issue",
620 "view",
621 &number.to_string(),
622 "--json",
623 "number,title,body,labels,state,url",
624 ])
625 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
626 let issue: Issue = serde_json::from_str(&text)
627 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
628 if issue.is_closed() {
629 crate::log!("issue #{number} is closed, skipping");
630 continue;
631 }
632 issues.push(issue);
633 }
634 if issues.is_empty() {
635 bail!("no open issues to work on");
636 }
637 Ok(issues)
638 }
639
640 fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
646 #[derive(Deserialize)]
647 struct Row {
648 number: i64,
649 }
650 let text = self.gh(&[
651 kind,
652 "list",
653 "--state",
654 "open",
655 "--limit",
656 &FETCH_CEILING.to_string(),
657 "--json",
658 "number",
659 ])?;
660 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
661 let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
662 numbers.sort_unstable();
663
664 let noun = if kind == "issue" { "issues" } else { "PRs" };
665 let found = numbers.len();
666 if min_number > 0 {
667 numbers.retain(|n| *n >= min_number);
668 let skipped = found - numbers.len();
669 if skipped > 0 {
670 crate::log!("{skipped} open {noun} below #{min_number} skipped");
671 }
672 }
673 if found >= FETCH_CEILING {
674 crate::log!(
675 "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
676 considered."
677 );
678 }
679 if numbers.len() > limit {
680 crate::log!(
681 "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
682 explicitly.",
683 numbers.len()
684 );
685 numbers.truncate(limit);
686 }
687 Ok(numbers)
688 }
689
690 pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
692 self.open_numbers("issue", limit, min_number)
693 }
694
695 pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
696 self.open_numbers("pr", limit, min_number)
697 }
698
699 pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
700 let text = self.gh_try(&[
701 "pr",
702 "list",
703 "--head",
704 branch,
705 "--state",
706 "open",
707 "--json",
708 "number,url,title",
709 ]);
710 serde_json::from_str::<Vec<PrRef>>(text.trim())
711 .ok()
712 .and_then(|mut v| {
713 if v.is_empty() {
714 None
715 } else {
716 Some(v.remove(0))
717 }
718 })
719 }
720
721 pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
727 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
728 let text = self
729 .gh(&[
730 "api",
731 &path,
732 "--jq",
733 "if .pull_request then \"pr\" else \"issue\" end",
734 ])
735 .map_err(|e| {
736 spar_err!(
737 "no issue or pull request #{number} in this repository. {}",
738 e.last_line()
739 )
740 })?;
741 match text.trim() {
742 "pr" => Ok(ItemKind::Pr),
743 "issue" => Ok(ItemKind::Issue),
744 other => Err(spar_err!(
745 "could not tell whether #{number} is an issue or a pull request (got {other:?})"
746 )),
747 }
748 }
749
750 pub fn open_pr_for_issue(&self, issue: i64) -> Option<PrRef> {
756 if let Some(pr) = self.pr_for_branch(&self.branch_for_issue(issue)) {
757 return Some(pr);
758 }
759 let text = self.gh_try(&[
760 "pr",
761 "list",
762 "--state",
763 "open",
764 "--limit",
765 &FETCH_CEILING.to_string(),
766 "--json",
767 "number,url,title,closingIssuesReferences",
768 ]);
769 find_linked_pr(&text, issue)
770 }
771
772 pub fn pr_view(&self, number: i64) -> Result<PrView> {
773 let text = self.gh(&[
774 "pr",
775 "view",
776 &number.to_string(),
777 "--json",
778 "number,url,title,headRefName,baseRefName,state,closingIssuesReferences,isCrossRepository",
779 ])?;
780 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
781 }
782
783 pub fn pr_state(&self, number: i64) -> String {
784 let text = self.gh_try(&["pr", "view", &number.to_string(), "--json", "state"]);
785 serde_json::from_str::<Value>(text.trim())
786 .ok()
787 .and_then(|v| v.get("state").and_then(Value::as_str).map(str::to_string))
788 .unwrap_or_default()
789 }
790
791 pub fn create_pr(
792 &self,
793 cwd: &Path,
794 branch: &str,
795 base: &str,
796 title: &str,
797 body: &str,
798 ) -> Result<PrRef> {
799 let title = self.clean_title(title)?;
800 let body = self.clean(body)?;
801 let mut argv = vec![
802 "pr", "create", "--base", base, "--head", branch, "--title", &title, "--body", &body,
803 ];
804 if self.drafts != Drafts::Never {
805 argv.push("--draft");
806 }
807 self.gh_at(Some(cwd), &argv)
808 .map_err(|e| spar_err!("could not open a PR for {branch}. {}", e.last_line()))?;
809 self.pr_for_branch(branch).ok_or_else(|| {
810 spar_err!("PR creation reported success but none was found for {branch}")
811 })
812 }
813
814 pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
815 let body = self.clean(body)?;
816 self.gh(&["pr", "comment", &number.to_string(), "--body", &body])
817 .map(|_| ())
818 }
819
820 pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
821 let body = self.clean(body)?;
822 self.gh(&["issue", "comment", &number.to_string(), "--body", &body])
823 .map(|_| ())
824 }
825
826 pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
831 self.comment_issue(number, body)?;
832 let n = number.to_string();
833 if self
834 .gh(&["issue", "close", &n, "--reason", "not planned"])
835 .is_ok()
836 {
837 return Ok(());
838 }
839 self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
841 spar_err!(
842 "commented on #{number} but could not close it: {}",
843 e.last_line()
844 )
845 })
846 }
847
848 pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
849 let title = self.clean_title(title)?;
850 let body = self.clean_issue_body(body)?;
851 Ok(self
852 .gh(&["issue", "create", "--title", &title, "--body", &body])?
853 .trim()
854 .to_string())
855 }
856}
857
858#[derive(Debug, Clone)]
860pub struct ExistingIssue {
861 pub number: i64,
862 pub url: String,
863 pub title: String,
864 pub body: String,
865 pub open: bool,
866}
867
868impl Repo {
869 pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
877 #[derive(Deserialize)]
878 #[serde(rename_all = "camelCase")]
879 struct Row {
880 number: i64,
881 #[serde(default)]
882 title: String,
883 #[serde(default)]
884 url: String,
885 #[serde(default)]
886 body: String,
887 #[serde(default)]
888 state: String,
889 }
890 if title.trim().is_empty() {
891 return None;
892 }
893 let query: String = title
896 .chars()
897 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
898 .take(120)
899 .collect();
900 let text = self.gh_try(&[
901 "issue",
902 "list",
903 "--state",
904 "all",
905 "--limit",
906 "100",
907 "--search",
908 query.trim(),
909 "--json",
910 "number,title,url,body,state",
911 ]);
912 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
913 let wanted = format!("{title} {body}");
914
915 rows.into_iter()
916 .find(|row| {
917 let theirs = format!("{} {}", row.title, row.body);
918 row.title.trim().eq_ignore_ascii_case(title.trim())
919 || textsim::same_subject(&wanted, &theirs)
920 })
921 .map(|row| ExistingIssue {
922 number: row.number,
923 url: row.url,
924 title: row.title,
925 open: row.state.eq_ignore_ascii_case("open"),
926 body: row.body,
927 })
928 }
929
930 pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
932 #[derive(Deserialize)]
933 struct Row {
934 title: String,
935 url: String,
936 }
937 let needle = title.trim().to_lowercase();
938 if needle.is_empty() {
939 return None;
940 }
941 let query: String = title
943 .chars()
944 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
945 .take(120)
946 .collect();
947 let text = self.gh_try(&[
948 "issue",
949 "list",
950 "--state",
951 "all",
952 "--limit",
953 "100",
954 "--search",
955 query.trim(),
956 "--json",
957 "number,title,url",
958 ]);
959 serde_json::from_str::<Vec<Row>>(text.trim())
960 .ok()?
961 .into_iter()
962 .find(|row| row.title.trim().to_lowercase() == needle)
963 .map(|row| row.url)
964 }
965
966 pub fn mark_ready(&self, number: i64) -> bool {
974 match self.gh(&["pr", "ready", &number.to_string()]) {
975 Ok(_) => true,
976 Err(e) => {
977 logdim!(
978 "PR #{number} is approved but could not be taken out of draft: {}",
979 e.last_line()
980 );
981 false
982 }
983 }
984 }
985
986 pub fn merge_pr(&self, number: i64) -> Result<()> {
990 let n = number.to_string();
991 match self.gh(&["pr", "merge", &n, "--squash", "--delete-branch"]) {
992 Ok(_) => Ok(()),
993 Err(e) => {
994 if self.pr_state(number) == "MERGED" {
995 logdim!(
996 "PR #{number} merged; branch cleanup did not finish: {}",
997 e.last_line()
998 );
999 Ok(())
1000 } else {
1001 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
1002 }
1003 }
1004 }
1005 }
1006
1007 pub fn followups_path(&self) -> PathBuf {
1012 self.root.join(STATE_DIR).join("followups.md")
1013 }
1014
1015 pub fn worked_followups_path(&self) -> PathBuf {
1023 self.root.join(STATE_DIR).join("followups.done.md")
1024 }
1025
1026 pub fn checkin_state_path(&self, number: i64) -> PathBuf {
1028 self.root
1029 .join(STATE_DIR)
1030 .join("state")
1031 .join(format!("checkin-{number}.json"))
1032 }
1033
1034 pub fn append_local_followup(&self, title: &str, body: &str) -> Option<String> {
1045 let path = self.followups_path();
1046 let heading = format!("## {}", title.trim());
1047 for seen in [&path, &self.worked_followups_path()] {
1048 if let Ok(existing) = std::fs::read_to_string(seen) {
1049 if existing.contains(&heading) {
1050 logdim!("follow-up already noted: {title}");
1051 return None;
1052 }
1053 }
1054 }
1055 if let Some(parent) = path.parent() {
1056 let _ = std::fs::create_dir_all(parent);
1057 }
1058 use std::io::Write;
1059 let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
1066 match std::fs::OpenOptions::new()
1067 .create(true)
1068 .append(true)
1069 .open(&path)
1070 {
1071 Ok(mut file) => {
1072 let _ = file.write_all(entry.as_bytes());
1073 Some(format!("note: {}", title.trim()))
1074 }
1075 Err(e) => {
1076 logdim!("could not write {}: {e}", path.display());
1077 None
1078 }
1079 }
1080 }
1081
1082 pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
1087 let path = self.worked_followups_path();
1088 if let Some(parent) = path.parent() {
1089 let _ = std::fs::create_dir_all(parent);
1090 }
1091 use std::io::Write;
1092 let entry = format!(
1093 "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
1094 title.trim(),
1095 body.trim()
1096 );
1097 if let Ok(mut file) = std::fs::OpenOptions::new()
1098 .create(true)
1099 .append(true)
1100 .open(&path)
1101 {
1102 let _ = file.write_all(entry.as_bytes());
1103 }
1104 }
1105
1106 pub fn pending_comment_path(&self, number: i64) -> PathBuf {
1115 self.root
1116 .join(STATE_DIR)
1117 .join("reviews")
1118 .join(format!("pr-{number}.md"))
1119 }
1120
1121 pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
1127 let path = self.pending_comment_path(number);
1128 if let Some(parent) = path.parent() {
1129 std::fs::create_dir_all(parent)
1130 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1131 }
1132 std::fs::write(&path, text)
1133 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
1134 Ok(path)
1135 }
1136
1137 pub fn read_pending_comment(&self, number: i64) -> Option<String> {
1138 std::fs::read_to_string(self.pending_comment_path(number)).ok()
1139 }
1140
1141 pub fn state_path(&self, number: i64) -> PathBuf {
1142 self.root
1143 .join(STATE_DIR)
1144 .join("state")
1145 .join(format!("pr-{number}.json"))
1146 }
1147
1148 fn read_local_state(&self, number: i64) -> Option<PersistedState> {
1149 let path = self.state_path(number);
1150 let text = std::fs::read_to_string(&path).ok()?;
1151 match serde_json::from_str(&text) {
1152 Ok(state) => Some(state),
1153 Err(_) => {
1154 logdim!("could not read {}, starting fresh", path.display());
1155 None
1156 }
1157 }
1158 }
1159
1160 pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
1161 if let Some(local) = self.read_local_state(pr.number) {
1162 return Some(local);
1163 }
1164 if self.state_store.writes_pr() {
1165 return self.read_pr_state(pr.number);
1166 }
1167 None
1168 }
1169
1170 fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
1171 for body in self.state_comment_bodies(number).into_iter().rev() {
1172 if let Some(state) = parse_state_comment(&body) {
1173 return Some(state);
1174 }
1175 }
1176 None
1177 }
1178
1179 pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
1180 if self.state_store.writes_local() {
1181 write_json_atomic(&self.state_path(number), state)?;
1182 }
1183 if self.state_store.writes_pr() {
1184 self.write_pr_state(number, state)?;
1185 }
1186 Ok(())
1187 }
1188
1189 fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
1190 let body = format!(
1194 "{STATE_MARKER}\n{}\n-->",
1195 serde_json::to_string_pretty(state)?
1196 );
1197 if let Some(id) = self.state_comment_id(number) {
1198 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
1199 let field = format!("body={body}");
1200 self.gh_try(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"]);
1201 return Ok(());
1202 }
1203 self.gh(&["pr", "comment", &number.to_string(), "--body", &body])
1204 .map(|_| ())
1205 }
1206
1207 pub fn issue_comments(&self, number: i64) -> Vec<Value> {
1210 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
1211 parse_comment_pages(&self.gh_try(&["api", "--paginate", &path]))
1212 }
1213
1214 fn state_comments(&self, number: i64) -> Vec<(i64, String)> {
1215 self.issue_comments(number)
1216 .into_iter()
1217 .filter_map(|c| {
1218 let body = c.get("body").and_then(Value::as_str)?.to_string();
1219 if !body.contains("spar:state") {
1220 return None;
1221 }
1222 let id = c.get("id").and_then(Value::as_i64)?;
1223 Some((id, body))
1224 })
1225 .collect()
1226 }
1227
1228 fn state_comment_bodies(&self, number: i64) -> Vec<String> {
1229 self.state_comments(number)
1230 .into_iter()
1231 .map(|(_, b)| b)
1232 .collect()
1233 }
1234
1235 fn state_comment_id(&self, number: i64) -> Option<i64> {
1236 self.state_comments(number).last().map(|(id, _)| *id)
1237 }
1238
1239 pub fn clear_state(&self, number: i64) {
1241 let path = self.state_path(number);
1242 let _ = std::fs::remove_file(&path);
1243 let _ = std::fs::remove_file(path.with_extension("json.tmp"));
1244 }
1245
1246 pub fn prune_state(&self) -> Vec<String> {
1250 let base = self.root.join(STATE_DIR).join("state");
1251 let Ok(entries) = std::fs::read_dir(&base) else {
1252 return Vec::new();
1253 };
1254 let mut names: Vec<String> = entries
1255 .flatten()
1256 .filter_map(|e| e.file_name().to_str().map(str::to_string))
1257 .filter(|n| n.starts_with("pr-") && n.ends_with(".json"))
1258 .collect();
1259 names.sort();
1260
1261 let mut removed = Vec::new();
1262 for name in names {
1263 let Ok(number) = name[3..name.len() - 5].parse::<i64>() else {
1264 continue;
1265 };
1266 if is_finished(&self.pr_state(number)) {
1267 let _ = std::fs::remove_file(base.join(&name));
1268 removed.push(format!("state {name}"));
1269 }
1270 }
1271 removed
1272 }
1273
1274 pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
1278 #[derive(Deserialize)]
1279 struct Row {
1280 number: i64,
1281 }
1282 let numbers = numbers.unwrap_or_else(|| {
1283 let text = self.gh_try(&[
1284 "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
1285 ]);
1286 serde_json::from_str::<Vec<Row>>(text.trim())
1287 .unwrap_or_default()
1288 .into_iter()
1289 .map(|r| r.number)
1290 .collect()
1291 });
1292
1293 let mut removed = Vec::new();
1294 for number in numbers {
1295 if !is_finished(&self.pr_state(number)) {
1296 continue;
1297 }
1298 for (id, _) in self.state_comments(number) {
1299 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
1300 self.gh_try(&["api", "-X", "DELETE", &path, "--silent"]);
1301 removed.push(format!("state comment on PR #{number}"));
1302 }
1303 }
1304 removed
1305 }
1306
1307 pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
1314 let base = self.root.join(WORKTREE_DIR);
1315 let mut removed = Vec::new();
1316
1317 if let Ok(entries) = std::fs::read_dir(&base) {
1318 let mut names: Vec<String> = entries
1319 .flatten()
1320 .filter(|e| e.path().is_dir())
1321 .filter_map(|e| e.file_name().to_str().map(str::to_string))
1322 .collect();
1323 names.sort();
1324
1325 for name in names {
1326 if let Some(rest) = name.strip_prefix("review-") {
1329 let number: i64 = rest.parse().unwrap_or(-1);
1330 if !(force_all || is_finished(&self.pr_state(number))) {
1331 continue;
1332 }
1333 self.release_review_worktree(number);
1334 removed.push(name);
1335 continue;
1336 }
1337 let branch = format!("{}{name}", self.branch_prefix);
1338 if !(force_all || self.worktree_is_done(&branch)) {
1339 continue;
1340 }
1341 self.remove_worktree_at(&base.join(&name));
1342 self.git_try(&["branch", "-D", &branch]);
1343 self.forget_branch(&branch);
1344 removed.push(name);
1345 }
1346 }
1347 if !removed.is_empty() {
1348 self.git_try(&["worktree", "prune"]);
1349 }
1350 removed.extend(self.prune_branches(force_all));
1351 removed
1352 }
1353
1354 pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
1361 let branches: Vec<String> = self.known_branches().keys().cloned().collect();
1362 if branches.is_empty() {
1363 return Vec::new();
1364 }
1365
1366 let checked_out: Vec<String> = self
1367 .git_try(&["worktree", "list", "--porcelain"])
1368 .lines()
1369 .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
1370 .collect();
1371
1372 let existing: Vec<String> = self
1375 .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
1376 .lines()
1377 .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
1378 .collect();
1379
1380 let mut removed = Vec::new();
1381 for branch in branches {
1382 if !existing.contains(&branch) {
1383 self.forget_branch(&branch); continue;
1385 }
1386 if checked_out.contains(&branch) {
1387 continue;
1388 }
1389 if !(force_all || self.worktree_is_done(&branch)) {
1390 continue;
1391 }
1392 match self.git(&["branch", "-D", &branch]) {
1393 Ok(_) => {
1394 self.forget_branch(&branch);
1395 removed.push(format!("branch {branch}"));
1396 }
1397 Err(e) => {
1398 logdim!("could not delete {branch}: {}", e.last_line());
1401 }
1402 }
1403 }
1404 removed
1405 }
1406
1407 fn worktree_is_done(&self, branch: &str) -> bool {
1409 #[derive(Deserialize)]
1410 struct Row {
1411 state: String,
1412 }
1413 let entry = branch
1414 .strip_prefix(self.branch_prefix.as_str())
1415 .unwrap_or(branch);
1416 if let Some(rest) = entry.strip_prefix("pr-") {
1417 return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
1418 }
1419 if entry.starts_with("issue-") {
1420 let text = self.gh_try(&[
1421 "pr", "list", "--head", branch, "--state", "all", "--json", "state",
1422 ]);
1423 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
1424 return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
1425 }
1426 false
1427 }
1428}
1429
1430#[derive(Debug, Clone, serde::Serialize, Deserialize)]
1435pub struct BranchRecord {
1436 pub kind: String,
1437 pub number: i64,
1438}
1439
1440pub fn review_ref(number: i64) -> String {
1443 format!("refs/spar/pr-{number}")
1444}
1445
1446pub fn is_finished(state: &str) -> bool {
1447 matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
1448}
1449
1450pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
1457 if let Some(parent) = path.parent() {
1458 std::fs::create_dir_all(parent)
1459 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1460 }
1461 let tmp = path.with_extension(format!(
1464 "{}.tmp",
1465 path.extension().and_then(|e| e.to_str()).unwrap_or("json")
1466 ));
1467 std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
1468 std::fs::rename(&tmp, path)
1469 .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
1470 Ok(())
1471}
1472
1473pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
1476 write_text_atomic(path, &serde_json::to_string_pretty(value)?)
1477}
1478
1479pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
1486 #[derive(Deserialize)]
1487 #[serde(rename_all = "camelCase")]
1488 struct Row {
1489 number: i64,
1490 #[serde(default)]
1491 url: String,
1492 #[serde(default)]
1493 title: String,
1494 #[serde(default)]
1495 closing_issues_references: Vec<IssueRef>,
1496 }
1497
1498 serde_json::from_str::<Vec<Row>>(json.trim())
1499 .ok()?
1500 .into_iter()
1501 .find(|row| {
1502 row.closing_issues_references
1503 .iter()
1504 .any(|linked| linked.number == issue)
1505 })
1506 .map(|row| PrRef {
1507 number: row.number,
1508 url: row.url,
1509 title: row.title,
1510 })
1511}
1512
1513pub fn parse_comment_pages(text: &str) -> Vec<Value> {
1520 let mut out = Vec::new();
1521 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
1522 match value {
1523 Ok(Value::Array(items)) => out.extend(items),
1524 Ok(other) => out.push(other),
1525 Err(_) => break,
1526 }
1527 }
1528 out
1529}
1530
1531pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
1534 let marker = body.find(STATE_MARKER)?;
1535 let start = body[marker..].find('{')? + marker;
1536 let end = body.rfind('}')?;
1537 if end <= start {
1538 return None;
1539 }
1540 match serde_json::from_str(&body[start..=end]) {
1541 Ok(state) => Some(state),
1542 Err(_) => {
1543 logdim!("found a spar state comment but could not parse it");
1544 None
1545 }
1546 }
1547}
1548
1549pub fn self_binary() -> Result<PathBuf> {
1555 if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
1556 let path = PathBuf::from(path);
1557 if proc::is_executable(&path) {
1558 return Ok(path);
1559 }
1560 bail!(
1561 "SPAR_SELF_BIN is set to {}, which is not executable",
1562 path.display()
1563 );
1564 }
1565 std::env::current_exe()
1566 .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
1567}
1568
1569fn bool_env(value: bool) -> &'static str {
1570 if value {
1571 "1"
1572 } else {
1573 "0"
1574 }
1575}
1576
1577pub fn sh_quote(text: &str) -> String {
1580 format!("'{}'", text.replace('\'', r"'\''"))
1581}
1582
1583pub fn style_from_env() -> Style {
1586 let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
1587 Style {
1588 ban_em_dash: flag("SPAR_BAN_EM_DASH"),
1589 ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
1590 ..Style::permissive()
1591 }
1592}
1593
1594#[cfg(test)]
1595mod tests {
1596 use super::*;
1597 use crate::config::StateStore;
1598 use crate::model::{Ledger, Status};
1599
1600 fn repo_for_titles() -> Repo {
1601 Repo {
1602 root: PathBuf::from("/nonexistent"),
1603 style: Style::default(),
1604 branch_prefix: String::new(),
1605 state_store: StateStore::Local,
1606 followups: crate::config::Followups::Issues,
1607 drafts: Drafts::Never,
1608 viewer: OnceLock::new(),
1609 }
1610 }
1611
1612 #[test]
1616 fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
1617 let repo = repo_for_titles();
1618 for raw in [
1619 "Retry loop spins \u{2014} Retry-After parses to zero",
1620 "plain title",
1621 " spread over\nlines ",
1622 "\u{1F916} Generated with something",
1623 &format!("a \u{2014} {}", "very long title ".repeat(20)),
1624 &"x".repeat(300),
1625 &format!("{} \u{2014} end", "y".repeat(88)),
1626 &{
1631 let tail = "a\u{2014}b c\u{2014}d";
1632 let pad = Style::default().max_title_chars - tail.chars().count();
1633 format!("{}{tail}", "w".repeat(pad))
1634 },
1635 ] {
1636 let once = repo.clean_title(raw).unwrap();
1637 let twice = repo.clean_title(&once).unwrap();
1638 assert_eq!(once, twice, "not idempotent for {raw:?}");
1639 assert!(
1640 once.chars().count() <= repo.style.max_title_chars,
1641 "over budget: {once:?}"
1642 );
1643 assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
1644 }
1645 }
1646
1647 #[test]
1648 fn a_title_with_an_em_dash_survives_as_readable_text() {
1649 let repo = repo_for_titles();
1650 assert_eq!(
1651 "Retry loop spins, Retry-After parses to zero",
1652 repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
1653 .unwrap()
1654 );
1655 }
1656
1657 #[test]
1658 fn sh_quote_survives_a_quote() {
1659 assert_eq!(r"'a'\''b'", sh_quote("a'b"));
1660 }
1661
1662 #[test]
1663 fn sh_quote_wraps_a_space() {
1664 assert_eq!(
1665 "'/Applications/My App/spar'",
1666 sh_quote("/Applications/My App/spar")
1667 );
1668 }
1669
1670 #[test]
1671 fn finished_states_are_recognised_case_insensitively() {
1672 assert!(is_finished("MERGED"));
1673 assert!(is_finished("closed"));
1674 assert!(!is_finished("OPEN"));
1675 assert!(!is_finished(""));
1676 }
1677
1678 fn state() -> PersistedState {
1679 PersistedState {
1680 version: 1,
1681 round: 4,
1682 next_actor: "codex".into(),
1683 status: Status::Pending,
1684 ledger: Ledger::new(),
1685 filed: vec![],
1686 }
1687 }
1688
1689 #[test]
1690 fn a_state_comment_round_trips() {
1691 let body = format!(
1692 "{STATE_MARKER}\n{}\n-->",
1693 serde_json::to_string(&state()).unwrap()
1694 );
1695 let back = parse_state_comment(&body).unwrap();
1696 assert_eq!(4, back.round);
1697 assert_eq!("codex", back.next_actor);
1698 }
1699
1700 #[test]
1702 fn the_state_block_is_an_html_comment() {
1703 let body = format!(
1704 "{STATE_MARKER}\n{}\n-->",
1705 serde_json::to_string(&state()).unwrap()
1706 );
1707 assert!(body.starts_with("<!--"));
1708 assert!(body.trim_end().ends_with("-->"));
1709 assert!(!body[..body.find('{').unwrap()].contains("-->"));
1710 }
1711
1712 #[test]
1713 fn an_unrelated_json_block_is_not_state() {
1714 assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
1715 }
1716
1717 #[test]
1718 fn a_malformed_state_comment_is_none_not_a_panic() {
1719 assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
1720 }
1721
1722 #[test]
1723 fn atomic_write_leaves_no_temp_file() {
1724 let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
1725 let _ = std::fs::remove_dir_all(&dir);
1726 let path = dir.join("state").join("pr-7.json");
1727 write_json_atomic(&path, &state()).unwrap();
1728 let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
1729 .unwrap()
1730 .flatten()
1731 .filter_map(|e| e.file_name().to_str().map(str::to_string))
1732 .collect();
1733 assert_eq!(vec!["pr-7.json".to_string()], files);
1734 let _ = std::fs::remove_dir_all(&dir);
1735 }
1736
1737 #[test]
1738 fn atomic_write_overwrites_rather_than_accumulating() {
1739 let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
1740 let _ = std::fs::remove_dir_all(&dir);
1741 let path = dir.join("pr-7.json");
1742 for round in 1..4 {
1743 let mut s = state();
1744 s.round = round;
1745 write_json_atomic(&path, &s).unwrap();
1746 }
1747 let back: PersistedState =
1748 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1749 assert_eq!(3, back.round);
1750 let _ = std::fs::remove_dir_all(&dir);
1751 }
1752
1753 #[test]
1754 fn style_from_env_defaults_to_enforcing() {
1755 std::env::remove_var("SPAR_BAN_EM_DASH");
1756 std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
1757 let style = style_from_env();
1758 assert!(style.ban_em_dash && style.ban_ai_attribution);
1759 assert!(
1760 !style.terse,
1761 "the commit filter must not truncate a commit message"
1762 );
1763 }
1764}
1765
1766#[cfg(test)]
1767mod comment_page_tests {
1768 use super::*;
1769
1770 #[test]
1771 fn a_single_merged_array_is_read() {
1772 let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
1773 assert_eq!(2, pages.len());
1774 assert_eq!(Some(2), pages[1]["id"].as_i64());
1775 }
1776
1777 #[test]
1778 fn concatenated_pages_from_an_older_gh_are_read_too() {
1779 let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
1780 assert_eq!(2, pages.len());
1781 }
1782
1783 #[test]
1787 fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
1788 let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
1789 let pages = parse_comment_pages(text);
1790 assert_eq!(2, pages.len(), "{pages:?}");
1791 assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
1792 }
1793
1794 #[test]
1795 fn empty_output_is_no_comments_not_a_panic() {
1796 assert!(parse_comment_pages("").is_empty());
1797 assert!(parse_comment_pages(" ").is_empty());
1798 assert!(parse_comment_pages("[]").is_empty());
1799 }
1800
1801 #[test]
1802 fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
1803 assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
1804 }
1805
1806 #[test]
1807 fn state_is_found_in_the_last_matching_comment() {
1808 let payload = |round: u32| {
1809 format!(
1810 "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
1811 )
1812 };
1813 let text = serde_json::to_string(&serde_json::json!([
1814 {"id": 1, "body": payload(1)},
1815 {"id": 2, "body": "looks good to me"},
1816 {"id": 3, "body": payload(5)},
1817 ]))
1818 .unwrap();
1819 let pages = parse_comment_pages(&text);
1820 let last = pages
1821 .iter()
1822 .rev()
1823 .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
1824 .unwrap();
1825 assert_eq!(5, last.round);
1826 }
1827}
1828
1829#[cfg(test)]
1830mod linked_pr_tests {
1831 use super::*;
1832
1833 const REAL_PAYLOAD: &str = r#"[
1838 {"number":14252,"title":"fix: reject leading-dash branch names",
1839 "url":"https://github.com/cli/cli/pull/14252",
1840 "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
1841 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
1842 "url":"https://github.com/cli/cli/issues/14238"}]},
1843 {"number":14217,"title":"another change",
1844 "url":"https://github.com/cli/cli/pull/14217",
1845 "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
1846 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
1847 "url":"https://github.com/cli/cli/issues/9761"}]},
1848 {"number":14200,"title":"unlinked work",
1849 "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
1850 ]"#;
1851
1852 #[test]
1853 fn a_linked_pr_is_found_whatever_its_branch_is_called() {
1854 let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
1855 assert_eq!(14252, pr.number);
1856 assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
1857 }
1858
1859 #[test]
1860 fn the_right_pr_is_picked_out_of_several() {
1861 assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
1862 }
1863
1864 #[test]
1865 fn an_issue_nobody_is_working_on_finds_nothing() {
1866 assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
1867 }
1868
1869 #[test]
1870 fn an_unlinked_pr_is_never_matched() {
1871 for issue in [14200, 0, 1] {
1873 if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
1874 assert_ne!(14200, pr.number, "matched a PR that closes nothing");
1875 }
1876 }
1877 }
1878
1879 #[test]
1880 fn empty_or_broken_output_is_none_rather_than_a_panic() {
1881 assert!(find_linked_pr("", 1).is_none());
1882 assert!(find_linked_pr("[]", 1).is_none());
1883 assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
1884 assert!(find_linked_pr("[{\"number\":", 1).is_none());
1885 }
1886
1887 #[test]
1889 fn pr_view_reads_the_cross_repository_flag() {
1890 let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
1891 "baseRefName":"main","state":"OPEN",
1892 "closingIssuesReferences":[],"isCrossRepository":true}"#;
1893 let pr: PrView = serde_json::from_str(json).unwrap();
1894 assert!(pr.is_cross_repository);
1895 assert!(pr.is_open());
1896
1897 let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
1898 assert!(
1899 !serde_json::from_str::<PrView>(&same_repo)
1900 .unwrap()
1901 .is_cross_repository
1902 );
1903 }
1904}
1905
1906#[cfg(test)]
1907mod min_number_tests {
1908 fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
1914 let mut numbers: Vec<i64> = open.to_vec();
1915 numbers.sort_unstable();
1916 if min_number > 0 {
1917 numbers.retain(|n| *n >= min_number);
1918 }
1919 numbers.truncate(limit);
1920 numbers
1921 }
1922
1923 #[test]
1924 fn the_floor_is_applied_before_the_cap_not_after() {
1925 let open = [12, 13, 14, 480, 481, 482];
1926 assert_eq!(vec![480, 481], pick(&open, 2, 480));
1927 assert!(!pick(&open, 2, 480).is_empty());
1930 }
1931
1932 #[test]
1933 fn no_floor_keeps_the_old_behaviour() {
1934 assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
1935 }
1936
1937 #[test]
1938 fn the_floor_is_inclusive() {
1939 assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
1940 }
1941
1942 #[test]
1943 fn a_floor_above_everything_open_yields_nothing() {
1944 assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
1945 }
1946}