1use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6
7use serde::Deserialize;
8use serde_json::Value;
9
10use crate::config::{Config, Drafts, Followups, StateStore};
11use crate::error::Result;
12use crate::model::{Issue, IssueRef, ItemKind, PersistedState, PrRef, PrView};
13use crate::proc::{self, ExecOpts};
14use crate::style::{self, Style};
15use crate::textsim;
16use crate::{bail, logdim, spar_err};
17
18pub const FETCH_CEILING: usize = 500;
22
23pub const STATE_MARKER: &str = "<!-- spar:state";
26
27const WORKTREE_DIR: &str = ".spar-worktrees";
28const STATE_DIR: &str = ".spar";
29
30#[derive(Debug)]
31pub struct Repo {
32 root: PathBuf,
33 pub style: Style,
34 pub branch_prefix: String,
35 pub state_store: StateStore,
36 pub followups: Followups,
37 pub drafts: Drafts,
38}
39
40impl Repo {
41 pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
42 let root =
43 std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
44 let inside = proc::run_str(
47 &["git", "rev-parse", "--is-inside-work-tree"],
48 &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
49 )
50 .unwrap_or_default();
51 if inside.trim() != "true" {
52 bail!("not a git repository: {}", root.display());
53 }
54 let repo = Self {
55 root,
56 style: cfg.style.clone(),
57 branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
58 state_store: cfg.loop_cfg.state_store,
59 followups: cfg.loop_cfg.followups,
60 drafts: cfg.loop_cfg.drafts,
61 };
62 repo.self_exclude();
63 Ok(repo)
64 }
65
66 fn self_exclude(&self) {
74 let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
75 let git_dir = git_dir.trim();
76 if git_dir.is_empty() {
77 return;
78 }
79 let path = Path::new(git_dir).join("info").join("exclude");
80 let existing = std::fs::read_to_string(&path).unwrap_or_default();
81
82 let wanted = [format!("/{WORKTREE_DIR}/"), format!("/{STATE_DIR}/")];
83 let missing: Vec<&String> = wanted
84 .iter()
85 .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
86 .collect();
87 if missing.is_empty() {
88 return;
89 }
90
91 use std::io::Write;
92 if let Some(parent) = path.parent() {
93 let _ = std::fs::create_dir_all(parent);
94 }
95 let mut block = String::new();
96 if !existing.is_empty() && !existing.ends_with('\n') {
97 block.push('\n');
98 }
99 block.push_str("\n# added by spar: its worktrees and run state\n");
100 for line in missing {
101 block.push_str(line);
102 block.push('\n');
103 }
104 if let Ok(mut file) = std::fs::OpenOptions::new()
105 .create(true)
106 .append(true)
107 .open(&path)
108 {
109 let _ = file.write_all(block.as_bytes());
110 }
111 }
112
113 pub fn root(&self) -> &Path {
114 &self.root
115 }
116
117 pub fn clean(&self, text: &str) -> Result<String> {
123 let out = style::scrub(text, &self.style);
124 let bad = style::violations(&out, &self.style);
125 if !bad.is_empty() {
126 bail!(
127 "style gate could not clean text ({}): {}",
128 bad.join(", "),
129 style::clip(&out, 300)
130 );
131 }
132 Ok(out)
133 }
134
135 pub fn clean_body(&self, text: &str) -> Result<String> {
137 self.clean(&style::body(text, &self.style))
138 }
139
140 pub fn clean_issue_body(&self, text: &str) -> Result<String> {
142 self.clean(&style::issue_body(text, &self.style))
143 }
144
145 pub fn clean_title(&self, text: &str) -> Result<String> {
156 Ok(style::title(&self.clean(text)?, &self.style))
157 }
158
159 fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
162 ExecOpts::new()
163 .cwd(cwd.unwrap_or(&self.root))
164 .check(check)
165 .timeout_secs(600)
166 }
167
168 pub fn git(&self, args: &[&str]) -> Result<String> {
169 self.git_at(None, args)
170 }
171
172 pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
173 let mut argv = vec!["git".to_string()];
174 argv.extend(args.iter().map(|s| s.to_string()));
175 proc::run(&argv, &self.git_opts(cwd, true))
176 }
177
178 pub fn git_try(&self, args: &[&str]) -> String {
180 self.git_try_at(None, args)
181 }
182
183 pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
184 let mut argv = vec!["git".to_string()];
185 argv.extend(args.iter().map(|s| s.to_string()));
186 proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
187 }
188
189 pub fn default_branch(&self, configured: &str) -> String {
192 let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
193 match refname.trim().rsplit('/').next() {
194 Some(name) if !name.is_empty() => name.to_string(),
195 _ => configured.to_string(),
196 }
197 }
198
199 pub fn branch_for_issue(&self, issue: i64) -> String {
207 format!("{}issue-{issue}", self.branch_prefix)
208 }
209
210 pub fn branch_for_pr(&self, number: i64) -> String {
211 format!("{}pr-{number}", self.branch_prefix)
212 }
213
214 fn ledger_path(&self) -> PathBuf {
215 self.root.join(STATE_DIR).join("branches.json")
216 }
217
218 pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
219 std::fs::read_to_string(self.ledger_path())
220 .ok()
221 .and_then(|text| serde_json::from_str(&text).ok())
222 .unwrap_or_default()
223 }
224
225 pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
226 let mut data = self.known_branches();
227 data.insert(
228 branch.to_string(),
229 BranchRecord {
230 kind: kind.to_string(),
231 number,
232 },
233 );
234 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
235 logdim!("could not record branch {branch}: {e}");
236 }
237 }
238
239 pub fn forget_branch(&self, branch: &str) {
240 let mut data = self.known_branches();
241 if data.remove(branch).is_none() {
242 return;
243 }
244 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
245 logdim!("could not update the branch record: {e}");
246 }
247 }
248
249 fn worktree_path(&self, name: &str) -> PathBuf {
252 self.root.join(WORKTREE_DIR).join(name)
253 }
254
255 pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
257 let branch = self.branch_for_issue(issue);
258 let path = self.worktree_path(&format!("issue-{issue}"));
259
260 self.git_try(&["fetch", "origin", base]);
261
262 self.git_try(&["fetch", "origin", &branch]);
271 let remote_branch = format!("origin/{branch}");
272 if self.rev_exists(&self.root, &remote_branch) {
273 let range = format!("origin/{base}..{remote_branch}");
274 let ahead: u32 = self
275 .git_try(&["rev-list", "--count", &range])
276 .trim()
277 .parse()
278 .unwrap_or(0);
279 if ahead > 0 {
280 bail!(
281 "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
282 open pull request accounts for them. Rebuilding it would force push over \
283 that work.\nOpen a pull request for the branch and run `spar resume <pr>` to \
284 continue it, or delete it with `git push origin --delete {branch}` if it is \
285 stale."
286 );
287 }
288 }
289
290 self.worktree_remove(issue);
291 self.git_try(&["branch", "-D", &branch]);
292
293 if let Some(parent) = path.parent() {
294 std::fs::create_dir_all(parent)
295 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
296 }
297
298 let path_str = path.display().to_string();
299 let remote_start = format!("origin/{base}");
300 let created = self
301 .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
302 .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
303
304 created.map_err(|e| {
307 spar_err!(
308 "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
309 and does `origin` exist?",
310 e.last_line()
311 )
312 })?;
313 self.record_branch(&branch, "issue", issue);
314 Ok((path, branch))
315 }
316
317 pub fn worktree_remove(&self, issue: i64) {
318 self.remove_worktree_at(&self.worktree_path(&format!("issue-{issue}")));
319 }
320
321 fn remove_worktree_at(&self, path: &Path) {
322 let path_str = path.display().to_string();
323 self.git_try(&["worktree", "remove", "--force", &path_str]);
324 if path.is_dir() {
325 let _ = std::fs::remove_dir_all(path);
326 }
327 self.git_try(&["worktree", "prune"]);
328 }
329
330 pub fn worktree_for_pr(&self, pr: &PrView) -> Result<(PathBuf, String)> {
332 let head = pr.head_ref_name.clone();
333 if head.trim().is_empty() {
334 bail!("PR #{} has no head branch to check out", pr.number);
335 }
336 let path = self.worktree_path(&format!("pr-{}", pr.number));
337 let local = self.branch_for_pr(pr.number);
338
339 self.git(&["fetch", "origin", &head]).map_err(|e| {
340 spar_err!(
341 "could not fetch the branch behind PR #{}: {}",
342 pr.number,
343 e.last_line()
344 )
345 })?;
346 self.remove_worktree_at(&path);
347 self.git_try(&["branch", "-D", &local]);
348
349 let path_str = path.display().to_string();
350 let start = format!("origin/{head}");
351 self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
352 self.record_branch(&local, "pr", pr.number);
353 Ok((path, head))
354 }
355
356 pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
366 let path = self.worktree_path(&format!("review-{number}"));
367 let local_ref = review_ref(number);
368 let refspec = format!("+refs/pull/{number}/head:{local_ref}");
369
370 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
371 spar_err!(
372 "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
373 every pull request, so this usually means the number is wrong or `origin` does \
374 not point at the repository the PR is on.",
375 e.last_line()
376 )
377 })?;
378
379 if let Some(parent) = path.parent() {
380 std::fs::create_dir_all(parent)
381 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
382 }
383 self.remove_worktree_at(&path);
384 let path_str = path.display().to_string();
385 self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
386 Ok(path)
387 }
388
389 pub fn release_review_worktree(&self, number: i64) {
390 self.remove_worktree_at(&self.worktree_path(&format!("review-{number}")));
391 self.git_try(&["update-ref", "-d", &review_ref(number)]);
392 }
393
394 pub fn release_pr_worktree(&self, number: i64) {
395 let path = self.worktree_path(&format!("pr-{number}"));
396 self.remove_worktree_at(&path);
397 let local = self.branch_for_pr(number);
398 self.git_try(&["branch", "-D", &local]);
399 self.forget_branch(&local);
400 }
401
402 pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
413 let remote = format!("origin/{base}");
414 if self.rev_exists(cwd, &remote) {
415 return remote;
416 }
417 if self.rev_exists(cwd, base) {
418 logdim!("origin/{base} does not resolve, comparing against local {base}");
419 return base.to_string();
420 }
421 logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
422 remote
423 }
424
425 fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
426 let spec = format!("{refname}^{{commit}}");
427 !self
428 .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
429 .trim()
430 .is_empty()
431 }
432
433 pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
434 let range = format!("{}..HEAD", self.base_ref(cwd, base));
435 !self
436 .git_try_at(Some(cwd), &["log", &range, "--oneline"])
437 .trim()
438 .is_empty()
439 }
440
441 pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
442 let range = format!("{}...HEAD", self.base_ref(cwd, base));
443 let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
444 full.trim().to_string()
445 }
446
447 pub fn rewrite_commits_if_needed(&self, cwd: &Path, base: &str) -> Result<()> {
452 let range = format!("{}..HEAD", self.base_ref(cwd, base));
453 let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
454
455 let offenders = raw
456 .split('\x1e')
457 .filter_map(|entry| entry.split_once('\0'))
458 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
459 .count();
460 if offenders == 0 {
461 return Ok(());
462 }
463 logdim!("{offenders} commit message(s) violated style rules, rewriting");
464
465 let exe = self_binary()?;
466 let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
467
468 let argv: Vec<String> = [
469 "git",
470 "filter-branch",
471 "-f",
472 "--msg-filter",
473 &filter,
474 &range,
475 ]
476 .iter()
477 .map(|s| s.to_string())
478 .collect();
479 let opts = ExecOpts::new()
480 .cwd(cwd)
481 .check(false)
482 .timeout_secs(600)
483 .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
484 .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
485 .env(
486 "SPAR_BAN_AI_ATTRIBUTION",
487 bool_env(self.style.ban_ai_attribution),
488 );
489 let _ = proc::run(&argv, &opts);
490
491 let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
492 if !style::violations(&after, &self.style).is_empty() {
493 bail!(
494 "commit messages still violate style rules after a rewrite. Fix them by hand in \
495 {} and rerun.",
496 cwd.display()
497 );
498 }
499 Ok(())
500 }
501
502 pub fn push(&self, cwd: &Path, branch: &str) -> Result<()> {
508 let refspec = format!("HEAD:{branch}");
509 self.git_at(
510 Some(cwd),
511 &["push", "--force-with-lease", "origin", &refspec],
512 )
513 .map(|_| ())
514 .map_err(|e| {
515 spar_err!(
516 "could not push to origin/{branch}. {}\nCheck push access and whether the \
517 branch moved under you.",
518 e.last_line()
519 )
520 })
521 }
522
523 pub fn gh(&self, args: &[&str]) -> Result<String> {
526 self.gh_at(None, args)
527 }
528
529 pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
530 let mut argv = vec!["gh".to_string()];
531 argv.extend(args.iter().map(|s| s.to_string()));
532 proc::run(
533 &argv,
534 &ExecOpts::new()
535 .cwd(cwd.unwrap_or(&self.root))
536 .timeout_secs(300),
537 )
538 }
539
540 pub fn gh_try(&self, args: &[&str]) -> String {
541 let mut argv = vec!["gh".to_string()];
542 argv.extend(args.iter().map(|s| s.to_string()));
543 proc::run(
544 &argv,
545 &ExecOpts::new()
546 .cwd(&self.root)
547 .check(false)
548 .timeout_secs(300),
549 )
550 .unwrap_or_default()
551 }
552
553 pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
554 let mut issues = Vec::new();
555 for number in numbers {
556 let text = self
557 .gh(&[
558 "issue",
559 "view",
560 &number.to_string(),
561 "--json",
562 "number,title,body,labels,state,url",
563 ])
564 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
565 let issue: Issue = serde_json::from_str(&text)
566 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
567 if issue.is_closed() {
568 crate::log!("issue #{number} is closed, skipping");
569 continue;
570 }
571 issues.push(issue);
572 }
573 if issues.is_empty() {
574 bail!("no open issues to work on");
575 }
576 Ok(issues)
577 }
578
579 fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
585 #[derive(Deserialize)]
586 struct Row {
587 number: i64,
588 }
589 let text = self.gh(&[
590 kind,
591 "list",
592 "--state",
593 "open",
594 "--limit",
595 &FETCH_CEILING.to_string(),
596 "--json",
597 "number",
598 ])?;
599 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
600 let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
601 numbers.sort_unstable();
602
603 let noun = if kind == "issue" { "issues" } else { "PRs" };
604 let found = numbers.len();
605 if min_number > 0 {
606 numbers.retain(|n| *n >= min_number);
607 let skipped = found - numbers.len();
608 if skipped > 0 {
609 crate::log!("{skipped} open {noun} below #{min_number} skipped");
610 }
611 }
612 if found >= FETCH_CEILING {
613 crate::log!(
614 "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
615 considered."
616 );
617 }
618 if numbers.len() > limit {
619 crate::log!(
620 "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
621 explicitly.",
622 numbers.len()
623 );
624 numbers.truncate(limit);
625 }
626 Ok(numbers)
627 }
628
629 pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
631 self.open_numbers("issue", limit, min_number)
632 }
633
634 pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
635 self.open_numbers("pr", limit, min_number)
636 }
637
638 pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
639 let text = self.gh_try(&[
640 "pr",
641 "list",
642 "--head",
643 branch,
644 "--state",
645 "open",
646 "--json",
647 "number,url,title",
648 ]);
649 serde_json::from_str::<Vec<PrRef>>(text.trim())
650 .ok()
651 .and_then(|mut v| {
652 if v.is_empty() {
653 None
654 } else {
655 Some(v.remove(0))
656 }
657 })
658 }
659
660 pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
666 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
667 let text = self
668 .gh(&[
669 "api",
670 &path,
671 "--jq",
672 "if .pull_request then \"pr\" else \"issue\" end",
673 ])
674 .map_err(|e| {
675 spar_err!(
676 "no issue or pull request #{number} in this repository. {}",
677 e.last_line()
678 )
679 })?;
680 match text.trim() {
681 "pr" => Ok(ItemKind::Pr),
682 "issue" => Ok(ItemKind::Issue),
683 other => Err(spar_err!(
684 "could not tell whether #{number} is an issue or a pull request (got {other:?})"
685 )),
686 }
687 }
688
689 pub fn open_pr_for_issue(&self, issue: i64) -> Option<PrRef> {
695 if let Some(pr) = self.pr_for_branch(&self.branch_for_issue(issue)) {
696 return Some(pr);
697 }
698 let text = self.gh_try(&[
699 "pr",
700 "list",
701 "--state",
702 "open",
703 "--limit",
704 &FETCH_CEILING.to_string(),
705 "--json",
706 "number,url,title,closingIssuesReferences",
707 ]);
708 find_linked_pr(&text, issue)
709 }
710
711 pub fn pr_view(&self, number: i64) -> Result<PrView> {
712 let text = self.gh(&[
713 "pr",
714 "view",
715 &number.to_string(),
716 "--json",
717 "number,url,title,headRefName,baseRefName,state,closingIssuesReferences,isCrossRepository",
718 ])?;
719 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
720 }
721
722 pub fn pr_state(&self, number: i64) -> String {
723 let text = self.gh_try(&["pr", "view", &number.to_string(), "--json", "state"]);
724 serde_json::from_str::<Value>(text.trim())
725 .ok()
726 .and_then(|v| v.get("state").and_then(Value::as_str).map(str::to_string))
727 .unwrap_or_default()
728 }
729
730 pub fn create_pr(
731 &self,
732 cwd: &Path,
733 branch: &str,
734 base: &str,
735 title: &str,
736 body: &str,
737 ) -> Result<PrRef> {
738 let title = self.clean_title(title)?;
739 let body = self.clean(body)?;
740 let mut argv = vec![
741 "pr", "create", "--base", base, "--head", branch, "--title", &title, "--body", &body,
742 ];
743 if self.drafts != Drafts::Never {
744 argv.push("--draft");
745 }
746 self.gh_at(Some(cwd), &argv)
747 .map_err(|e| spar_err!("could not open a PR for {branch}. {}", e.last_line()))?;
748 self.pr_for_branch(branch).ok_or_else(|| {
749 spar_err!("PR creation reported success but none was found for {branch}")
750 })
751 }
752
753 pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
754 let body = self.clean(body)?;
755 self.gh(&["pr", "comment", &number.to_string(), "--body", &body])
756 .map(|_| ())
757 }
758
759 pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
760 let body = self.clean(body)?;
761 self.gh(&["issue", "comment", &number.to_string(), "--body", &body])
762 .map(|_| ())
763 }
764
765 pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
770 self.comment_issue(number, body)?;
771 let n = number.to_string();
772 if self
773 .gh(&["issue", "close", &n, "--reason", "not planned"])
774 .is_ok()
775 {
776 return Ok(());
777 }
778 self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
780 spar_err!(
781 "commented on #{number} but could not close it: {}",
782 e.last_line()
783 )
784 })
785 }
786
787 pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
788 let title = self.clean_title(title)?;
789 let body = self.clean_issue_body(body)?;
790 Ok(self
791 .gh(&["issue", "create", "--title", &title, "--body", &body])?
792 .trim()
793 .to_string())
794 }
795}
796
797#[derive(Debug, Clone)]
799pub struct ExistingIssue {
800 pub number: i64,
801 pub url: String,
802 pub title: String,
803 pub body: String,
804 pub open: bool,
805}
806
807impl Repo {
808 pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
816 #[derive(Deserialize)]
817 #[serde(rename_all = "camelCase")]
818 struct Row {
819 number: i64,
820 #[serde(default)]
821 title: String,
822 #[serde(default)]
823 url: String,
824 #[serde(default)]
825 body: String,
826 #[serde(default)]
827 state: String,
828 }
829 if title.trim().is_empty() {
830 return None;
831 }
832 let query: String = title
835 .chars()
836 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
837 .take(120)
838 .collect();
839 let text = self.gh_try(&[
840 "issue",
841 "list",
842 "--state",
843 "all",
844 "--limit",
845 "100",
846 "--search",
847 query.trim(),
848 "--json",
849 "number,title,url,body,state",
850 ]);
851 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
852 let wanted = format!("{title} {body}");
853
854 rows.into_iter()
855 .find(|row| {
856 let theirs = format!("{} {}", row.title, row.body);
857 row.title.trim().eq_ignore_ascii_case(title.trim())
858 || textsim::same_subject(&wanted, &theirs)
859 })
860 .map(|row| ExistingIssue {
861 number: row.number,
862 url: row.url,
863 title: row.title,
864 open: row.state.eq_ignore_ascii_case("open"),
865 body: row.body,
866 })
867 }
868
869 pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
871 #[derive(Deserialize)]
872 struct Row {
873 title: String,
874 url: String,
875 }
876 let needle = title.trim().to_lowercase();
877 if needle.is_empty() {
878 return None;
879 }
880 let query: String = title
882 .chars()
883 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
884 .take(120)
885 .collect();
886 let text = self.gh_try(&[
887 "issue",
888 "list",
889 "--state",
890 "all",
891 "--limit",
892 "100",
893 "--search",
894 query.trim(),
895 "--json",
896 "number,title,url",
897 ]);
898 serde_json::from_str::<Vec<Row>>(text.trim())
899 .ok()?
900 .into_iter()
901 .find(|row| row.title.trim().to_lowercase() == needle)
902 .map(|row| row.url)
903 }
904
905 pub fn mark_ready(&self, number: i64) -> bool {
913 match self.gh(&["pr", "ready", &number.to_string()]) {
914 Ok(_) => true,
915 Err(e) => {
916 logdim!(
917 "PR #{number} is approved but could not be taken out of draft: {}",
918 e.last_line()
919 );
920 false
921 }
922 }
923 }
924
925 pub fn merge_pr(&self, number: i64) -> Result<()> {
929 let n = number.to_string();
930 match self.gh(&["pr", "merge", &n, "--squash", "--delete-branch"]) {
931 Ok(_) => Ok(()),
932 Err(e) => {
933 if self.pr_state(number) == "MERGED" {
934 logdim!(
935 "PR #{number} merged; branch cleanup did not finish: {}",
936 e.last_line()
937 );
938 Ok(())
939 } else {
940 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
941 }
942 }
943 }
944 }
945
946 pub fn append_local_followup(&self, title: &str, body: &str) -> Option<String> {
954 let path = self.root.join(STATE_DIR).join("followups.md");
955 let heading = format!("## {}", title.trim());
956 if let Ok(existing) = std::fs::read_to_string(&path) {
957 if existing.contains(&heading) {
958 logdim!("follow-up already noted: {title}");
959 return None;
960 }
961 }
962 if let Some(parent) = path.parent() {
963 let _ = std::fs::create_dir_all(parent);
964 }
965 use std::io::Write;
966 let entry = format!("{heading}\n\n{}\n\n", body.trim());
969 match std::fs::OpenOptions::new()
970 .create(true)
971 .append(true)
972 .open(&path)
973 {
974 Ok(mut file) => {
975 let _ = file.write_all(entry.as_bytes());
976 Some(format!("note: {}", title.trim()))
977 }
978 Err(e) => {
979 logdim!("could not write {}: {e}", path.display());
980 None
981 }
982 }
983 }
984
985 pub fn pending_comment_path(&self, number: i64) -> PathBuf {
994 self.root
995 .join(STATE_DIR)
996 .join("reviews")
997 .join(format!("pr-{number}.md"))
998 }
999
1000 pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
1006 let path = self.pending_comment_path(number);
1007 if let Some(parent) = path.parent() {
1008 std::fs::create_dir_all(parent)
1009 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1010 }
1011 std::fs::write(&path, text)
1012 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
1013 Ok(path)
1014 }
1015
1016 pub fn read_pending_comment(&self, number: i64) -> Option<String> {
1017 std::fs::read_to_string(self.pending_comment_path(number)).ok()
1018 }
1019
1020 pub fn state_path(&self, number: i64) -> PathBuf {
1021 self.root
1022 .join(STATE_DIR)
1023 .join("state")
1024 .join(format!("pr-{number}.json"))
1025 }
1026
1027 fn read_local_state(&self, number: i64) -> Option<PersistedState> {
1028 let path = self.state_path(number);
1029 let text = std::fs::read_to_string(&path).ok()?;
1030 match serde_json::from_str(&text) {
1031 Ok(state) => Some(state),
1032 Err(_) => {
1033 logdim!("could not read {}, starting fresh", path.display());
1034 None
1035 }
1036 }
1037 }
1038
1039 pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
1040 if let Some(local) = self.read_local_state(pr.number) {
1041 return Some(local);
1042 }
1043 if self.state_store.writes_pr() {
1044 return self.read_pr_state(pr.number);
1045 }
1046 None
1047 }
1048
1049 fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
1050 for body in self.state_comment_bodies(number).into_iter().rev() {
1051 if let Some(state) = parse_state_comment(&body) {
1052 return Some(state);
1053 }
1054 }
1055 None
1056 }
1057
1058 pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
1059 if self.state_store.writes_local() {
1060 write_json_atomic(&self.state_path(number), state)?;
1061 }
1062 if self.state_store.writes_pr() {
1063 self.write_pr_state(number, state)?;
1064 }
1065 Ok(())
1066 }
1067
1068 fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
1069 let body = format!(
1073 "{STATE_MARKER}\n{}\n-->",
1074 serde_json::to_string_pretty(state)?
1075 );
1076 if let Some(id) = self.state_comment_id(number) {
1077 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
1078 let field = format!("body={body}");
1079 self.gh_try(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"]);
1080 return Ok(());
1081 }
1082 self.gh(&["pr", "comment", &number.to_string(), "--body", &body])
1083 .map(|_| ())
1084 }
1085
1086 fn comments_json(&self, number: i64) -> Vec<Value> {
1087 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
1088 parse_comment_pages(&self.gh_try(&["api", "--paginate", &path]))
1089 }
1090
1091 fn state_comments(&self, number: i64) -> Vec<(i64, String)> {
1092 self.comments_json(number)
1093 .into_iter()
1094 .filter_map(|c| {
1095 let body = c.get("body").and_then(Value::as_str)?.to_string();
1096 if !body.contains("spar:state") {
1097 return None;
1098 }
1099 let id = c.get("id").and_then(Value::as_i64)?;
1100 Some((id, body))
1101 })
1102 .collect()
1103 }
1104
1105 fn state_comment_bodies(&self, number: i64) -> Vec<String> {
1106 self.state_comments(number)
1107 .into_iter()
1108 .map(|(_, b)| b)
1109 .collect()
1110 }
1111
1112 fn state_comment_id(&self, number: i64) -> Option<i64> {
1113 self.state_comments(number).last().map(|(id, _)| *id)
1114 }
1115
1116 pub fn clear_state(&self, number: i64) {
1118 let path = self.state_path(number);
1119 let _ = std::fs::remove_file(&path);
1120 let _ = std::fs::remove_file(path.with_extension("json.tmp"));
1121 }
1122
1123 pub fn prune_state(&self) -> Vec<String> {
1127 let base = self.root.join(STATE_DIR).join("state");
1128 let Ok(entries) = std::fs::read_dir(&base) else {
1129 return Vec::new();
1130 };
1131 let mut names: Vec<String> = entries
1132 .flatten()
1133 .filter_map(|e| e.file_name().to_str().map(str::to_string))
1134 .filter(|n| n.starts_with("pr-") && n.ends_with(".json"))
1135 .collect();
1136 names.sort();
1137
1138 let mut removed = Vec::new();
1139 for name in names {
1140 let Ok(number) = name[3..name.len() - 5].parse::<i64>() else {
1141 continue;
1142 };
1143 if is_finished(&self.pr_state(number)) {
1144 let _ = std::fs::remove_file(base.join(&name));
1145 removed.push(format!("state {name}"));
1146 }
1147 }
1148 removed
1149 }
1150
1151 pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
1155 #[derive(Deserialize)]
1156 struct Row {
1157 number: i64,
1158 }
1159 let numbers = numbers.unwrap_or_else(|| {
1160 let text = self.gh_try(&[
1161 "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
1162 ]);
1163 serde_json::from_str::<Vec<Row>>(text.trim())
1164 .unwrap_or_default()
1165 .into_iter()
1166 .map(|r| r.number)
1167 .collect()
1168 });
1169
1170 let mut removed = Vec::new();
1171 for number in numbers {
1172 if !is_finished(&self.pr_state(number)) {
1173 continue;
1174 }
1175 for (id, _) in self.state_comments(number) {
1176 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
1177 self.gh_try(&["api", "-X", "DELETE", &path, "--silent"]);
1178 removed.push(format!("state comment on PR #{number}"));
1179 }
1180 }
1181 removed
1182 }
1183
1184 pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
1191 let base = self.root.join(WORKTREE_DIR);
1192 let mut removed = Vec::new();
1193
1194 if let Ok(entries) = std::fs::read_dir(&base) {
1195 let mut names: Vec<String> = entries
1196 .flatten()
1197 .filter(|e| e.path().is_dir())
1198 .filter_map(|e| e.file_name().to_str().map(str::to_string))
1199 .collect();
1200 names.sort();
1201
1202 for name in names {
1203 if let Some(rest) = name.strip_prefix("review-") {
1206 let number: i64 = rest.parse().unwrap_or(-1);
1207 if !(force_all || is_finished(&self.pr_state(number))) {
1208 continue;
1209 }
1210 self.release_review_worktree(number);
1211 removed.push(name);
1212 continue;
1213 }
1214 let branch = format!("{}{name}", self.branch_prefix);
1215 if !(force_all || self.worktree_is_done(&branch)) {
1216 continue;
1217 }
1218 self.remove_worktree_at(&base.join(&name));
1219 self.git_try(&["branch", "-D", &branch]);
1220 self.forget_branch(&branch);
1221 removed.push(name);
1222 }
1223 }
1224 if !removed.is_empty() {
1225 self.git_try(&["worktree", "prune"]);
1226 }
1227 removed.extend(self.prune_branches(force_all));
1228 removed
1229 }
1230
1231 pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
1238 let branches: Vec<String> = self.known_branches().keys().cloned().collect();
1239 if branches.is_empty() {
1240 return Vec::new();
1241 }
1242
1243 let checked_out: Vec<String> = self
1244 .git_try(&["worktree", "list", "--porcelain"])
1245 .lines()
1246 .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
1247 .collect();
1248
1249 let existing: Vec<String> = self
1252 .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
1253 .lines()
1254 .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
1255 .collect();
1256
1257 let mut removed = Vec::new();
1258 for branch in branches {
1259 if !existing.contains(&branch) {
1260 self.forget_branch(&branch); continue;
1262 }
1263 if checked_out.contains(&branch) {
1264 continue;
1265 }
1266 if !(force_all || self.worktree_is_done(&branch)) {
1267 continue;
1268 }
1269 match self.git(&["branch", "-D", &branch]) {
1270 Ok(_) => {
1271 self.forget_branch(&branch);
1272 removed.push(format!("branch {branch}"));
1273 }
1274 Err(e) => {
1275 logdim!("could not delete {branch}: {}", e.last_line());
1278 }
1279 }
1280 }
1281 removed
1282 }
1283
1284 fn worktree_is_done(&self, branch: &str) -> bool {
1286 #[derive(Deserialize)]
1287 struct Row {
1288 state: String,
1289 }
1290 let entry = branch
1291 .strip_prefix(self.branch_prefix.as_str())
1292 .unwrap_or(branch);
1293 if let Some(rest) = entry.strip_prefix("pr-") {
1294 return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
1295 }
1296 if entry.starts_with("issue-") {
1297 let text = self.gh_try(&[
1298 "pr", "list", "--head", branch, "--state", "all", "--json", "state",
1299 ]);
1300 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
1301 return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
1302 }
1303 false
1304 }
1305}
1306
1307#[derive(Debug, Clone, serde::Serialize, Deserialize)]
1312pub struct BranchRecord {
1313 pub kind: String,
1314 pub number: i64,
1315}
1316
1317pub fn review_ref(number: i64) -> String {
1320 format!("refs/spar/pr-{number}")
1321}
1322
1323pub fn is_finished(state: &str) -> bool {
1324 matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
1325}
1326
1327pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
1330 if let Some(parent) = path.parent() {
1331 std::fs::create_dir_all(parent)
1332 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1333 }
1334 let tmp = path.with_extension(format!(
1335 "{}.tmp",
1336 path.extension().and_then(|e| e.to_str()).unwrap_or("json")
1337 ));
1338 std::fs::write(&tmp, serde_json::to_vec_pretty(value)?)
1339 .map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
1340 std::fs::rename(&tmp, path)
1341 .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
1342 Ok(())
1343}
1344
1345pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
1352 #[derive(Deserialize)]
1353 #[serde(rename_all = "camelCase")]
1354 struct Row {
1355 number: i64,
1356 #[serde(default)]
1357 url: String,
1358 #[serde(default)]
1359 title: String,
1360 #[serde(default)]
1361 closing_issues_references: Vec<IssueRef>,
1362 }
1363
1364 serde_json::from_str::<Vec<Row>>(json.trim())
1365 .ok()?
1366 .into_iter()
1367 .find(|row| {
1368 row.closing_issues_references
1369 .iter()
1370 .any(|linked| linked.number == issue)
1371 })
1372 .map(|row| PrRef {
1373 number: row.number,
1374 url: row.url,
1375 title: row.title,
1376 })
1377}
1378
1379pub fn parse_comment_pages(text: &str) -> Vec<Value> {
1386 let mut out = Vec::new();
1387 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
1388 match value {
1389 Ok(Value::Array(items)) => out.extend(items),
1390 Ok(other) => out.push(other),
1391 Err(_) => break,
1392 }
1393 }
1394 out
1395}
1396
1397pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
1400 let marker = body.find(STATE_MARKER)?;
1401 let start = body[marker..].find('{')? + marker;
1402 let end = body.rfind('}')?;
1403 if end <= start {
1404 return None;
1405 }
1406 match serde_json::from_str(&body[start..=end]) {
1407 Ok(state) => Some(state),
1408 Err(_) => {
1409 logdim!("found a spar state comment but could not parse it");
1410 None
1411 }
1412 }
1413}
1414
1415pub fn self_binary() -> Result<PathBuf> {
1421 if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
1422 let path = PathBuf::from(path);
1423 if proc::is_executable(&path) {
1424 return Ok(path);
1425 }
1426 bail!(
1427 "SPAR_SELF_BIN is set to {}, which is not executable",
1428 path.display()
1429 );
1430 }
1431 std::env::current_exe()
1432 .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
1433}
1434
1435fn bool_env(value: bool) -> &'static str {
1436 if value {
1437 "1"
1438 } else {
1439 "0"
1440 }
1441}
1442
1443pub fn sh_quote(text: &str) -> String {
1446 format!("'{}'", text.replace('\'', r"'\''"))
1447}
1448
1449pub fn style_from_env() -> Style {
1452 let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
1453 Style {
1454 ban_em_dash: flag("SPAR_BAN_EM_DASH"),
1455 ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
1456 ..Style::permissive()
1457 }
1458}
1459
1460#[cfg(test)]
1461mod tests {
1462 use super::*;
1463 use crate::config::StateStore;
1464 use crate::model::{Ledger, Status};
1465
1466 fn repo_for_titles() -> Repo {
1467 Repo {
1468 root: PathBuf::from("/nonexistent"),
1469 style: Style::default(),
1470 branch_prefix: String::new(),
1471 state_store: StateStore::Local,
1472 followups: crate::config::Followups::Issues,
1473 drafts: Drafts::Never,
1474 }
1475 }
1476
1477 #[test]
1481 fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
1482 let repo = repo_for_titles();
1483 for raw in [
1484 "Retry loop spins \u{2014} Retry-After parses to zero",
1485 "plain title",
1486 " spread over\nlines ",
1487 "\u{1F916} Generated with something",
1488 &format!("a \u{2014} {}", "very long title ".repeat(20)),
1489 &"x".repeat(300),
1490 &format!("{} \u{2014} end", "y".repeat(88)),
1491 &{
1496 let tail = "a\u{2014}b c\u{2014}d";
1497 let pad = Style::default().max_title_chars - tail.chars().count();
1498 format!("{}{tail}", "w".repeat(pad))
1499 },
1500 ] {
1501 let once = repo.clean_title(raw).unwrap();
1502 let twice = repo.clean_title(&once).unwrap();
1503 assert_eq!(once, twice, "not idempotent for {raw:?}");
1504 assert!(
1505 once.chars().count() <= repo.style.max_title_chars,
1506 "over budget: {once:?}"
1507 );
1508 assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
1509 }
1510 }
1511
1512 #[test]
1513 fn a_title_with_an_em_dash_survives_as_readable_text() {
1514 let repo = repo_for_titles();
1515 assert_eq!(
1516 "Retry loop spins, Retry-After parses to zero",
1517 repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
1518 .unwrap()
1519 );
1520 }
1521
1522 #[test]
1523 fn sh_quote_survives_a_quote() {
1524 assert_eq!(r"'a'\''b'", sh_quote("a'b"));
1525 }
1526
1527 #[test]
1528 fn sh_quote_wraps_a_space() {
1529 assert_eq!(
1530 "'/Applications/My App/spar'",
1531 sh_quote("/Applications/My App/spar")
1532 );
1533 }
1534
1535 #[test]
1536 fn finished_states_are_recognised_case_insensitively() {
1537 assert!(is_finished("MERGED"));
1538 assert!(is_finished("closed"));
1539 assert!(!is_finished("OPEN"));
1540 assert!(!is_finished(""));
1541 }
1542
1543 fn state() -> PersistedState {
1544 PersistedState {
1545 version: 1,
1546 round: 4,
1547 next_actor: "codex".into(),
1548 status: Status::Pending,
1549 ledger: Ledger::new(),
1550 filed: vec![],
1551 }
1552 }
1553
1554 #[test]
1555 fn a_state_comment_round_trips() {
1556 let body = format!(
1557 "{STATE_MARKER}\n{}\n-->",
1558 serde_json::to_string(&state()).unwrap()
1559 );
1560 let back = parse_state_comment(&body).unwrap();
1561 assert_eq!(4, back.round);
1562 assert_eq!("codex", back.next_actor);
1563 }
1564
1565 #[test]
1567 fn the_state_block_is_an_html_comment() {
1568 let body = format!(
1569 "{STATE_MARKER}\n{}\n-->",
1570 serde_json::to_string(&state()).unwrap()
1571 );
1572 assert!(body.starts_with("<!--"));
1573 assert!(body.trim_end().ends_with("-->"));
1574 assert!(!body[..body.find('{').unwrap()].contains("-->"));
1575 }
1576
1577 #[test]
1578 fn an_unrelated_json_block_is_not_state() {
1579 assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
1580 }
1581
1582 #[test]
1583 fn a_malformed_state_comment_is_none_not_a_panic() {
1584 assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
1585 }
1586
1587 #[test]
1588 fn atomic_write_leaves_no_temp_file() {
1589 let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
1590 let _ = std::fs::remove_dir_all(&dir);
1591 let path = dir.join("state").join("pr-7.json");
1592 write_json_atomic(&path, &state()).unwrap();
1593 let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
1594 .unwrap()
1595 .flatten()
1596 .filter_map(|e| e.file_name().to_str().map(str::to_string))
1597 .collect();
1598 assert_eq!(vec!["pr-7.json".to_string()], files);
1599 let _ = std::fs::remove_dir_all(&dir);
1600 }
1601
1602 #[test]
1603 fn atomic_write_overwrites_rather_than_accumulating() {
1604 let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
1605 let _ = std::fs::remove_dir_all(&dir);
1606 let path = dir.join("pr-7.json");
1607 for round in 1..4 {
1608 let mut s = state();
1609 s.round = round;
1610 write_json_atomic(&path, &s).unwrap();
1611 }
1612 let back: PersistedState =
1613 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1614 assert_eq!(3, back.round);
1615 let _ = std::fs::remove_dir_all(&dir);
1616 }
1617
1618 #[test]
1619 fn style_from_env_defaults_to_enforcing() {
1620 std::env::remove_var("SPAR_BAN_EM_DASH");
1621 std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
1622 let style = style_from_env();
1623 assert!(style.ban_em_dash && style.ban_ai_attribution);
1624 assert!(
1625 !style.terse,
1626 "the commit filter must not truncate a commit message"
1627 );
1628 }
1629}
1630
1631#[cfg(test)]
1632mod comment_page_tests {
1633 use super::*;
1634
1635 #[test]
1636 fn a_single_merged_array_is_read() {
1637 let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
1638 assert_eq!(2, pages.len());
1639 assert_eq!(Some(2), pages[1]["id"].as_i64());
1640 }
1641
1642 #[test]
1643 fn concatenated_pages_from_an_older_gh_are_read_too() {
1644 let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
1645 assert_eq!(2, pages.len());
1646 }
1647
1648 #[test]
1652 fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
1653 let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
1654 let pages = parse_comment_pages(text);
1655 assert_eq!(2, pages.len(), "{pages:?}");
1656 assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
1657 }
1658
1659 #[test]
1660 fn empty_output_is_no_comments_not_a_panic() {
1661 assert!(parse_comment_pages("").is_empty());
1662 assert!(parse_comment_pages(" ").is_empty());
1663 assert!(parse_comment_pages("[]").is_empty());
1664 }
1665
1666 #[test]
1667 fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
1668 assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
1669 }
1670
1671 #[test]
1672 fn state_is_found_in_the_last_matching_comment() {
1673 let payload = |round: u32| {
1674 format!(
1675 "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
1676 )
1677 };
1678 let text = serde_json::to_string(&serde_json::json!([
1679 {"id": 1, "body": payload(1)},
1680 {"id": 2, "body": "looks good to me"},
1681 {"id": 3, "body": payload(5)},
1682 ]))
1683 .unwrap();
1684 let pages = parse_comment_pages(&text);
1685 let last = pages
1686 .iter()
1687 .rev()
1688 .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
1689 .unwrap();
1690 assert_eq!(5, last.round);
1691 }
1692}
1693
1694#[cfg(test)]
1695mod linked_pr_tests {
1696 use super::*;
1697
1698 const REAL_PAYLOAD: &str = r#"[
1703 {"number":14252,"title":"fix: reject leading-dash branch names",
1704 "url":"https://github.com/cli/cli/pull/14252",
1705 "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
1706 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
1707 "url":"https://github.com/cli/cli/issues/14238"}]},
1708 {"number":14217,"title":"another change",
1709 "url":"https://github.com/cli/cli/pull/14217",
1710 "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
1711 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
1712 "url":"https://github.com/cli/cli/issues/9761"}]},
1713 {"number":14200,"title":"unlinked work",
1714 "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
1715 ]"#;
1716
1717 #[test]
1718 fn a_linked_pr_is_found_whatever_its_branch_is_called() {
1719 let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
1720 assert_eq!(14252, pr.number);
1721 assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
1722 }
1723
1724 #[test]
1725 fn the_right_pr_is_picked_out_of_several() {
1726 assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
1727 }
1728
1729 #[test]
1730 fn an_issue_nobody_is_working_on_finds_nothing() {
1731 assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
1732 }
1733
1734 #[test]
1735 fn an_unlinked_pr_is_never_matched() {
1736 for issue in [14200, 0, 1] {
1738 if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
1739 assert_ne!(14200, pr.number, "matched a PR that closes nothing");
1740 }
1741 }
1742 }
1743
1744 #[test]
1745 fn empty_or_broken_output_is_none_rather_than_a_panic() {
1746 assert!(find_linked_pr("", 1).is_none());
1747 assert!(find_linked_pr("[]", 1).is_none());
1748 assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
1749 assert!(find_linked_pr("[{\"number\":", 1).is_none());
1750 }
1751
1752 #[test]
1754 fn pr_view_reads_the_cross_repository_flag() {
1755 let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
1756 "baseRefName":"main","state":"OPEN",
1757 "closingIssuesReferences":[],"isCrossRepository":true}"#;
1758 let pr: PrView = serde_json::from_str(json).unwrap();
1759 assert!(pr.is_cross_repository);
1760 assert!(pr.is_open());
1761
1762 let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
1763 assert!(
1764 !serde_json::from_str::<PrView>(&same_repo)
1765 .unwrap()
1766 .is_cross_repository
1767 );
1768 }
1769}
1770
1771#[cfg(test)]
1772mod min_number_tests {
1773 fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
1779 let mut numbers: Vec<i64> = open.to_vec();
1780 numbers.sort_unstable();
1781 if min_number > 0 {
1782 numbers.retain(|n| *n >= min_number);
1783 }
1784 numbers.truncate(limit);
1785 numbers
1786 }
1787
1788 #[test]
1789 fn the_floor_is_applied_before_the_cap_not_after() {
1790 let open = [12, 13, 14, 480, 481, 482];
1791 assert_eq!(vec![480, 481], pick(&open, 2, 480));
1792 assert!(!pick(&open, 2, 480).is_empty());
1795 }
1796
1797 #[test]
1798 fn no_floor_keeps_the_old_behaviour() {
1799 assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
1800 }
1801
1802 #[test]
1803 fn the_floor_is_inclusive() {
1804 assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
1805 }
1806
1807 #[test]
1808 fn a_floor_above_everything_open_yields_nothing() {
1809 assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
1810 }
1811}