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