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::{bail, logdim, spar_err};
16
17pub const FETCH_CEILING: usize = 500;
21
22pub const STATE_MARKER: &str = "<!-- spar:state";
25
26const WORKTREE_DIR: &str = ".spar-worktrees";
27const STATE_DIR: &str = ".spar";
28
29#[derive(Debug)]
30pub struct Repo {
31 root: PathBuf,
32 pub style: Style,
33 pub branch_prefix: String,
34 pub state_store: StateStore,
35 pub followups: Followups,
36}
37
38impl Repo {
39 pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
40 let root =
41 std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
42 let inside = proc::run_str(
45 &["git", "rev-parse", "--is-inside-work-tree"],
46 &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
47 )
48 .unwrap_or_default();
49 if inside.trim() != "true" {
50 bail!("not a git repository: {}", root.display());
51 }
52 let repo = Self {
53 root,
54 style: cfg.style.clone(),
55 branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
56 state_store: cfg.loop_cfg.state_store,
57 followups: cfg.loop_cfg.followups,
58 };
59 repo.self_exclude();
60 Ok(repo)
61 }
62
63 fn self_exclude(&self) {
71 let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
72 let git_dir = git_dir.trim();
73 if git_dir.is_empty() {
74 return;
75 }
76 let path = Path::new(git_dir).join("info").join("exclude");
77 let existing = std::fs::read_to_string(&path).unwrap_or_default();
78
79 let wanted = [format!("/{WORKTREE_DIR}/"), format!("/{STATE_DIR}/")];
80 let missing: Vec<&String> = wanted
81 .iter()
82 .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
83 .collect();
84 if missing.is_empty() {
85 return;
86 }
87
88 use std::io::Write;
89 if let Some(parent) = path.parent() {
90 let _ = std::fs::create_dir_all(parent);
91 }
92 let mut block = String::new();
93 if !existing.is_empty() && !existing.ends_with('\n') {
94 block.push('\n');
95 }
96 block.push_str("\n# added by spar: its worktrees and run state\n");
97 for line in missing {
98 block.push_str(line);
99 block.push('\n');
100 }
101 if let Ok(mut file) = std::fs::OpenOptions::new()
102 .create(true)
103 .append(true)
104 .open(&path)
105 {
106 let _ = file.write_all(block.as_bytes());
107 }
108 }
109
110 pub fn root(&self) -> &Path {
111 &self.root
112 }
113
114 pub fn clean(&self, text: &str) -> Result<String> {
120 let out = style::scrub(text, &self.style);
121 let bad = style::violations(&out, &self.style);
122 if !bad.is_empty() {
123 bail!(
124 "style gate could not clean text ({}): {}",
125 bad.join(", "),
126 style::clip(&out, 300)
127 );
128 }
129 Ok(out)
130 }
131
132 pub fn clean_body(&self, text: &str) -> Result<String> {
134 self.clean(&style::body(text, &self.style))
135 }
136
137 pub fn clean_title(&self, text: &str) -> Result<String> {
148 Ok(style::title(&self.clean(text)?, &self.style))
149 }
150
151 fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
154 ExecOpts::new()
155 .cwd(cwd.unwrap_or(&self.root))
156 .check(check)
157 .timeout_secs(600)
158 }
159
160 pub fn git(&self, args: &[&str]) -> Result<String> {
161 self.git_at(None, args)
162 }
163
164 pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
165 let mut argv = vec!["git".to_string()];
166 argv.extend(args.iter().map(|s| s.to_string()));
167 proc::run(&argv, &self.git_opts(cwd, true))
168 }
169
170 pub fn git_try(&self, args: &[&str]) -> String {
172 self.git_try_at(None, args)
173 }
174
175 pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
176 let mut argv = vec!["git".to_string()];
177 argv.extend(args.iter().map(|s| s.to_string()));
178 proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
179 }
180
181 pub fn default_branch(&self, configured: &str) -> String {
184 let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
185 match refname.trim().rsplit('/').next() {
186 Some(name) if !name.is_empty() => name.to_string(),
187 _ => configured.to_string(),
188 }
189 }
190
191 pub fn branch_for_issue(&self, issue: i64) -> String {
199 format!("{}issue-{issue}", self.branch_prefix)
200 }
201
202 pub fn branch_for_pr(&self, number: i64) -> String {
203 format!("{}pr-{number}", self.branch_prefix)
204 }
205
206 fn ledger_path(&self) -> PathBuf {
207 self.root.join(STATE_DIR).join("branches.json")
208 }
209
210 pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
211 std::fs::read_to_string(self.ledger_path())
212 .ok()
213 .and_then(|text| serde_json::from_str(&text).ok())
214 .unwrap_or_default()
215 }
216
217 pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
218 let mut data = self.known_branches();
219 data.insert(
220 branch.to_string(),
221 BranchRecord {
222 kind: kind.to_string(),
223 number,
224 },
225 );
226 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
227 logdim!("could not record branch {branch}: {e}");
228 }
229 }
230
231 pub fn forget_branch(&self, branch: &str) {
232 let mut data = self.known_branches();
233 if data.remove(branch).is_none() {
234 return;
235 }
236 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
237 logdim!("could not update the branch record: {e}");
238 }
239 }
240
241 fn worktree_path(&self, name: &str) -> PathBuf {
244 self.root.join(WORKTREE_DIR).join(name)
245 }
246
247 pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
249 let branch = self.branch_for_issue(issue);
250 let path = self.worktree_path(&format!("issue-{issue}"));
251
252 self.git_try(&["fetch", "origin", base]);
253
254 self.git_try(&["fetch", "origin", &branch]);
263 let remote_branch = format!("origin/{branch}");
264 if self.rev_exists(&self.root, &remote_branch) {
265 let range = format!("origin/{base}..{remote_branch}");
266 let ahead: u32 = self
267 .git_try(&["rev-list", "--count", &range])
268 .trim()
269 .parse()
270 .unwrap_or(0);
271 if ahead > 0 {
272 bail!(
273 "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
274 open pull request accounts for them. Rebuilding it would force push over \
275 that work.\nOpen a pull request for the branch and run `spar resume <pr>` to \
276 continue it, or delete it with `git push origin --delete {branch}` if it is \
277 stale."
278 );
279 }
280 }
281
282 self.worktree_remove(issue);
283 self.git_try(&["branch", "-D", &branch]);
284
285 if let Some(parent) = path.parent() {
286 std::fs::create_dir_all(parent)
287 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
288 }
289
290 let path_str = path.display().to_string();
291 let remote_start = format!("origin/{base}");
292 let created = self
293 .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
294 .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
295
296 created.map_err(|e| {
299 spar_err!(
300 "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
301 and does `origin` exist?",
302 e.last_line()
303 )
304 })?;
305 self.record_branch(&branch, "issue", issue);
306 Ok((path, branch))
307 }
308
309 pub fn worktree_remove(&self, issue: i64) {
310 self.remove_worktree_at(&self.worktree_path(&format!("issue-{issue}")));
311 }
312
313 fn remove_worktree_at(&self, path: &Path) {
314 let path_str = path.display().to_string();
315 self.git_try(&["worktree", "remove", "--force", &path_str]);
316 if path.is_dir() {
317 let _ = std::fs::remove_dir_all(path);
318 }
319 self.git_try(&["worktree", "prune"]);
320 }
321
322 pub fn worktree_for_pr(&self, pr: &PrView) -> Result<(PathBuf, String)> {
324 let head = pr.head_ref_name.clone();
325 if head.trim().is_empty() {
326 bail!("PR #{} has no head branch to check out", pr.number);
327 }
328 let path = self.worktree_path(&format!("pr-{}", pr.number));
329 let local = self.branch_for_pr(pr.number);
330
331 self.git(&["fetch", "origin", &head]).map_err(|e| {
332 spar_err!(
333 "could not fetch the branch behind PR #{}: {}",
334 pr.number,
335 e.last_line()
336 )
337 })?;
338 self.remove_worktree_at(&path);
339 self.git_try(&["branch", "-D", &local]);
340
341 let path_str = path.display().to_string();
342 let start = format!("origin/{head}");
343 self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
344 self.record_branch(&local, "pr", pr.number);
345 Ok((path, head))
346 }
347
348 pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
358 let path = self.worktree_path(&format!("review-{number}"));
359 let local_ref = review_ref(number);
360 let refspec = format!("+refs/pull/{number}/head:{local_ref}");
361
362 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
363 spar_err!(
364 "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
365 every pull request, so this usually means the number is wrong or `origin` does \
366 not point at the repository the PR is on.",
367 e.last_line()
368 )
369 })?;
370
371 if let Some(parent) = path.parent() {
372 std::fs::create_dir_all(parent)
373 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
374 }
375 self.remove_worktree_at(&path);
376 let path_str = path.display().to_string();
377 self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
378 Ok(path)
379 }
380
381 pub fn release_review_worktree(&self, number: i64) {
382 self.remove_worktree_at(&self.worktree_path(&format!("review-{number}")));
383 self.git_try(&["update-ref", "-d", &review_ref(number)]);
384 }
385
386 pub fn release_pr_worktree(&self, number: i64) {
387 let path = self.worktree_path(&format!("pr-{number}"));
388 self.remove_worktree_at(&path);
389 let local = self.branch_for_pr(number);
390 self.git_try(&["branch", "-D", &local]);
391 self.forget_branch(&local);
392 }
393
394 pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
405 let remote = format!("origin/{base}");
406 if self.rev_exists(cwd, &remote) {
407 return remote;
408 }
409 if self.rev_exists(cwd, base) {
410 logdim!("origin/{base} does not resolve, comparing against local {base}");
411 return base.to_string();
412 }
413 logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
414 remote
415 }
416
417 fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
418 let spec = format!("{refname}^{{commit}}");
419 !self
420 .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
421 .trim()
422 .is_empty()
423 }
424
425 pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
426 let range = format!("{}..HEAD", self.base_ref(cwd, base));
427 !self
428 .git_try_at(Some(cwd), &["log", &range, "--oneline"])
429 .trim()
430 .is_empty()
431 }
432
433 pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
434 let range = format!("{}...HEAD", self.base_ref(cwd, base));
435 let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
436 full.trim().to_string()
437 }
438
439 pub fn rewrite_commits_if_needed(&self, cwd: &Path, base: &str) -> Result<()> {
444 let range = format!("{}..HEAD", self.base_ref(cwd, base));
445 let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
446
447 let offenders = raw
448 .split('\x1e')
449 .filter_map(|entry| entry.split_once('\0'))
450 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
451 .count();
452 if offenders == 0 {
453 return Ok(());
454 }
455 logdim!("{offenders} commit message(s) violated style rules, rewriting");
456
457 let exe = self_binary()?;
458 let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
459
460 let argv: Vec<String> = [
461 "git",
462 "filter-branch",
463 "-f",
464 "--msg-filter",
465 &filter,
466 &range,
467 ]
468 .iter()
469 .map(|s| s.to_string())
470 .collect();
471 let opts = ExecOpts::new()
472 .cwd(cwd)
473 .check(false)
474 .timeout_secs(600)
475 .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
476 .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
477 .env(
478 "SPAR_BAN_AI_ATTRIBUTION",
479 bool_env(self.style.ban_ai_attribution),
480 );
481 let _ = proc::run(&argv, &opts);
482
483 let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
484 if !style::violations(&after, &self.style).is_empty() {
485 bail!(
486 "commit messages still violate style rules after a rewrite. Fix them by hand in \
487 {} and rerun.",
488 cwd.display()
489 );
490 }
491 Ok(())
492 }
493
494 pub fn push(&self, cwd: &Path, branch: &str) -> Result<()> {
500 let refspec = format!("HEAD:{branch}");
501 self.git_at(
502 Some(cwd),
503 &["push", "--force-with-lease", "origin", &refspec],
504 )
505 .map(|_| ())
506 .map_err(|e| {
507 spar_err!(
508 "could not push to origin/{branch}. {}\nCheck push access and whether the \
509 branch moved under you.",
510 e.last_line()
511 )
512 })
513 }
514
515 pub fn gh(&self, args: &[&str]) -> Result<String> {
518 self.gh_at(None, args)
519 }
520
521 pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
522 let mut argv = vec!["gh".to_string()];
523 argv.extend(args.iter().map(|s| s.to_string()));
524 proc::run(
525 &argv,
526 &ExecOpts::new()
527 .cwd(cwd.unwrap_or(&self.root))
528 .timeout_secs(300),
529 )
530 }
531
532 pub fn gh_try(&self, args: &[&str]) -> String {
533 let mut argv = vec!["gh".to_string()];
534 argv.extend(args.iter().map(|s| s.to_string()));
535 proc::run(
536 &argv,
537 &ExecOpts::new()
538 .cwd(&self.root)
539 .check(false)
540 .timeout_secs(300),
541 )
542 .unwrap_or_default()
543 }
544
545 pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
546 let mut issues = Vec::new();
547 for number in numbers {
548 let text = self
549 .gh(&[
550 "issue",
551 "view",
552 &number.to_string(),
553 "--json",
554 "number,title,body,labels,state,url",
555 ])
556 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
557 let issue: Issue = serde_json::from_str(&text)
558 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
559 if issue.is_closed() {
560 crate::log!("issue #{number} is closed, skipping");
561 continue;
562 }
563 issues.push(issue);
564 }
565 if issues.is_empty() {
566 bail!("no open issues to work on");
567 }
568 Ok(issues)
569 }
570
571 fn open_numbers(&self, kind: &str, limit: usize) -> Result<Vec<i64>> {
572 #[derive(Deserialize)]
573 struct Row {
574 number: i64,
575 }
576 let text = self.gh(&[
577 kind,
578 "list",
579 "--state",
580 "open",
581 "--limit",
582 &FETCH_CEILING.to_string(),
583 "--json",
584 "number",
585 ])?;
586 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
587 let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
588 numbers.sort_unstable();
589
590 let noun = if kind == "issue" { "issues" } else { "PRs" };
591 if numbers.len() >= FETCH_CEILING {
592 crate::log!(
593 "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
594 considered."
595 );
596 }
597 if numbers.len() > limit {
598 crate::log!(
599 "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
600 explicitly.",
601 numbers.len()
602 );
603 numbers.truncate(limit);
604 }
605 Ok(numbers)
606 }
607
608 pub fn list_open_issues(&self, limit: usize) -> Result<Vec<i64>> {
610 self.open_numbers("issue", limit)
611 }
612
613 pub fn list_open_prs(&self, limit: usize) -> Result<Vec<i64>> {
614 self.open_numbers("pr", limit)
615 }
616
617 pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
618 let text = self.gh_try(&[
619 "pr",
620 "list",
621 "--head",
622 branch,
623 "--state",
624 "open",
625 "--json",
626 "number,url,title",
627 ]);
628 serde_json::from_str::<Vec<PrRef>>(text.trim())
629 .ok()
630 .and_then(|mut v| {
631 if v.is_empty() {
632 None
633 } else {
634 Some(v.remove(0))
635 }
636 })
637 }
638
639 pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
645 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
646 let text = self
647 .gh(&[
648 "api",
649 &path,
650 "--jq",
651 "if .pull_request then \"pr\" else \"issue\" end",
652 ])
653 .map_err(|e| {
654 spar_err!(
655 "no issue or pull request #{number} in this repository. {}",
656 e.last_line()
657 )
658 })?;
659 match text.trim() {
660 "pr" => Ok(ItemKind::Pr),
661 "issue" => Ok(ItemKind::Issue),
662 other => Err(spar_err!(
663 "could not tell whether #{number} is an issue or a pull request (got {other:?})"
664 )),
665 }
666 }
667
668 pub fn open_pr_for_issue(&self, issue: i64) -> Option<PrRef> {
674 if let Some(pr) = self.pr_for_branch(&self.branch_for_issue(issue)) {
675 return Some(pr);
676 }
677 let text = self.gh_try(&[
678 "pr",
679 "list",
680 "--state",
681 "open",
682 "--limit",
683 &FETCH_CEILING.to_string(),
684 "--json",
685 "number,url,title,closingIssuesReferences",
686 ]);
687 find_linked_pr(&text, issue)
688 }
689
690 pub fn pr_view(&self, number: i64) -> Result<PrView> {
691 let text = self.gh(&[
692 "pr",
693 "view",
694 &number.to_string(),
695 "--json",
696 "number,url,title,headRefName,baseRefName,state,closingIssuesReferences,isCrossRepository",
697 ])?;
698 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
699 }
700
701 pub fn pr_state(&self, number: i64) -> String {
702 let text = self.gh_try(&["pr", "view", &number.to_string(), "--json", "state"]);
703 serde_json::from_str::<Value>(text.trim())
704 .ok()
705 .and_then(|v| v.get("state").and_then(Value::as_str).map(str::to_string))
706 .unwrap_or_default()
707 }
708
709 pub fn create_pr(
710 &self,
711 cwd: &Path,
712 branch: &str,
713 base: &str,
714 title: &str,
715 body: &str,
716 ) -> Result<PrRef> {
717 let title = self.clean_title(title)?;
718 let body = self.clean(body)?;
719 self.gh_at(
720 Some(cwd),
721 &[
722 "pr", "create", "--base", base, "--head", branch, "--title", &title, "--body",
723 &body,
724 ],
725 )
726 .map_err(|e| spar_err!("could not open a PR for {branch}. {}", e.last_line()))?;
727 self.pr_for_branch(branch).ok_or_else(|| {
728 spar_err!("PR creation reported success but none was found for {branch}")
729 })
730 }
731
732 pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
733 let body = self.clean(body)?;
734 self.gh(&["pr", "comment", &number.to_string(), "--body", &body])
735 .map(|_| ())
736 }
737
738 pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
739 let body = self.clean(body)?;
740 self.gh(&["issue", "comment", &number.to_string(), "--body", &body])
741 .map(|_| ())
742 }
743
744 pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
749 self.comment_issue(number, body)?;
750 let n = number.to_string();
751 if self
752 .gh(&["issue", "close", &n, "--reason", "not planned"])
753 .is_ok()
754 {
755 return Ok(());
756 }
757 self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
759 spar_err!(
760 "commented on #{number} but could not close it: {}",
761 e.last_line()
762 )
763 })
764 }
765
766 pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
767 let title = self.clean_title(title)?;
768 let body = self.clean_body(body)?;
769 Ok(self
770 .gh(&["issue", "create", "--title", &title, "--body", &body])?
771 .trim()
772 .to_string())
773 }
774
775 pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
777 #[derive(Deserialize)]
778 struct Row {
779 title: String,
780 url: String,
781 }
782 let needle = title.trim().to_lowercase();
783 if needle.is_empty() {
784 return None;
785 }
786 let query: String = title
788 .chars()
789 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
790 .take(120)
791 .collect();
792 let text = self.gh_try(&[
793 "issue",
794 "list",
795 "--state",
796 "all",
797 "--limit",
798 "100",
799 "--search",
800 query.trim(),
801 "--json",
802 "number,title,url",
803 ]);
804 serde_json::from_str::<Vec<Row>>(text.trim())
805 .ok()?
806 .into_iter()
807 .find(|row| row.title.trim().to_lowercase() == needle)
808 .map(|row| row.url)
809 }
810
811 pub fn merge_pr(&self, number: i64) -> Result<()> {
817 let n = number.to_string();
818 match self.gh(&["pr", "merge", &n, "--squash", "--delete-branch"]) {
819 Ok(_) => Ok(()),
820 Err(e) => {
821 if self.pr_state(number) == "MERGED" {
822 logdim!(
823 "PR #{number} merged; branch cleanup did not finish: {}",
824 e.last_line()
825 );
826 Ok(())
827 } else {
828 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
829 }
830 }
831 }
832 }
833
834 pub fn append_local_followup(&self, title: &str, body: &str, source: i64) -> Option<String> {
841 let path = self.root.join(STATE_DIR).join("followups.md");
842 let heading = format!("## {}", title.trim());
843 if let Ok(existing) = std::fs::read_to_string(&path) {
844 if existing.contains(&heading) {
845 logdim!("follow-up already noted: {title}");
846 return None;
847 }
848 }
849 if let Some(parent) = path.parent() {
850 let _ = std::fs::create_dir_all(parent);
851 }
852 use std::io::Write;
853 let entry = format!("{heading}\n\nFrom #{source}.\n\n{}\n\n", body.trim());
854 match std::fs::OpenOptions::new()
855 .create(true)
856 .append(true)
857 .open(&path)
858 {
859 Ok(mut file) => {
860 let _ = file.write_all(entry.as_bytes());
861 Some(format!("note: {}", title.trim()))
862 }
863 Err(e) => {
864 logdim!("could not write {}: {e}", path.display());
865 None
866 }
867 }
868 }
869
870 pub fn state_path(&self, number: i64) -> PathBuf {
878 self.root
879 .join(STATE_DIR)
880 .join("state")
881 .join(format!("pr-{number}.json"))
882 }
883
884 fn read_local_state(&self, number: i64) -> Option<PersistedState> {
885 let path = self.state_path(number);
886 let text = std::fs::read_to_string(&path).ok()?;
887 match serde_json::from_str(&text) {
888 Ok(state) => Some(state),
889 Err(_) => {
890 logdim!("could not read {}, starting fresh", path.display());
891 None
892 }
893 }
894 }
895
896 pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
897 if let Some(local) = self.read_local_state(pr.number) {
898 return Some(local);
899 }
900 if self.state_store.writes_pr() {
901 return self.read_pr_state(pr.number);
902 }
903 None
904 }
905
906 fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
907 for body in self.state_comment_bodies(number).into_iter().rev() {
908 if let Some(state) = parse_state_comment(&body) {
909 return Some(state);
910 }
911 }
912 None
913 }
914
915 pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
916 if self.state_store.writes_local() {
917 write_json_atomic(&self.state_path(number), state)?;
918 }
919 if self.state_store.writes_pr() {
920 self.write_pr_state(number, state)?;
921 }
922 Ok(())
923 }
924
925 fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
926 let body = format!(
930 "{STATE_MARKER}\n{}\n-->",
931 serde_json::to_string_pretty(state)?
932 );
933 if let Some(id) = self.state_comment_id(number) {
934 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
935 let field = format!("body={body}");
936 self.gh_try(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"]);
937 return Ok(());
938 }
939 self.gh(&["pr", "comment", &number.to_string(), "--body", &body])
940 .map(|_| ())
941 }
942
943 fn comments_json(&self, number: i64) -> Vec<Value> {
944 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
945 parse_comment_pages(&self.gh_try(&["api", "--paginate", &path]))
946 }
947
948 fn state_comments(&self, number: i64) -> Vec<(i64, String)> {
949 self.comments_json(number)
950 .into_iter()
951 .filter_map(|c| {
952 let body = c.get("body").and_then(Value::as_str)?.to_string();
953 if !body.contains("spar:state") {
954 return None;
955 }
956 let id = c.get("id").and_then(Value::as_i64)?;
957 Some((id, body))
958 })
959 .collect()
960 }
961
962 fn state_comment_bodies(&self, number: i64) -> Vec<String> {
963 self.state_comments(number)
964 .into_iter()
965 .map(|(_, b)| b)
966 .collect()
967 }
968
969 fn state_comment_id(&self, number: i64) -> Option<i64> {
970 self.state_comments(number).last().map(|(id, _)| *id)
971 }
972
973 pub fn clear_state(&self, number: i64) {
975 let path = self.state_path(number);
976 let _ = std::fs::remove_file(&path);
977 let _ = std::fs::remove_file(path.with_extension("json.tmp"));
978 }
979
980 pub fn prune_state(&self) -> Vec<String> {
984 let base = self.root.join(STATE_DIR).join("state");
985 let Ok(entries) = std::fs::read_dir(&base) else {
986 return Vec::new();
987 };
988 let mut names: Vec<String> = entries
989 .flatten()
990 .filter_map(|e| e.file_name().to_str().map(str::to_string))
991 .filter(|n| n.starts_with("pr-") && n.ends_with(".json"))
992 .collect();
993 names.sort();
994
995 let mut removed = Vec::new();
996 for name in names {
997 let Ok(number) = name[3..name.len() - 5].parse::<i64>() else {
998 continue;
999 };
1000 if is_finished(&self.pr_state(number)) {
1001 let _ = std::fs::remove_file(base.join(&name));
1002 removed.push(format!("state {name}"));
1003 }
1004 }
1005 removed
1006 }
1007
1008 pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
1012 #[derive(Deserialize)]
1013 struct Row {
1014 number: i64,
1015 }
1016 let numbers = numbers.unwrap_or_else(|| {
1017 let text = self.gh_try(&[
1018 "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
1019 ]);
1020 serde_json::from_str::<Vec<Row>>(text.trim())
1021 .unwrap_or_default()
1022 .into_iter()
1023 .map(|r| r.number)
1024 .collect()
1025 });
1026
1027 let mut removed = Vec::new();
1028 for number in numbers {
1029 if !is_finished(&self.pr_state(number)) {
1030 continue;
1031 }
1032 for (id, _) in self.state_comments(number) {
1033 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
1034 self.gh_try(&["api", "-X", "DELETE", &path, "--silent"]);
1035 removed.push(format!("state comment on PR #{number}"));
1036 }
1037 }
1038 removed
1039 }
1040
1041 pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
1048 let base = self.root.join(WORKTREE_DIR);
1049 let mut removed = Vec::new();
1050
1051 if let Ok(entries) = std::fs::read_dir(&base) {
1052 let mut names: Vec<String> = entries
1053 .flatten()
1054 .filter(|e| e.path().is_dir())
1055 .filter_map(|e| e.file_name().to_str().map(str::to_string))
1056 .collect();
1057 names.sort();
1058
1059 for name in names {
1060 if let Some(rest) = name.strip_prefix("review-") {
1063 let number: i64 = rest.parse().unwrap_or(-1);
1064 if !(force_all || is_finished(&self.pr_state(number))) {
1065 continue;
1066 }
1067 self.release_review_worktree(number);
1068 removed.push(name);
1069 continue;
1070 }
1071 let branch = format!("{}{name}", self.branch_prefix);
1072 if !(force_all || self.worktree_is_done(&branch)) {
1073 continue;
1074 }
1075 self.remove_worktree_at(&base.join(&name));
1076 self.git_try(&["branch", "-D", &branch]);
1077 self.forget_branch(&branch);
1078 removed.push(name);
1079 }
1080 }
1081 if !removed.is_empty() {
1082 self.git_try(&["worktree", "prune"]);
1083 }
1084 removed.extend(self.prune_branches(force_all));
1085 removed
1086 }
1087
1088 pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
1095 let branches: Vec<String> = self.known_branches().keys().cloned().collect();
1096 if branches.is_empty() {
1097 return Vec::new();
1098 }
1099
1100 let checked_out: Vec<String> = self
1101 .git_try(&["worktree", "list", "--porcelain"])
1102 .lines()
1103 .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
1104 .collect();
1105
1106 let existing: Vec<String> = self
1109 .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
1110 .lines()
1111 .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
1112 .collect();
1113
1114 let mut removed = Vec::new();
1115 for branch in branches {
1116 if !existing.contains(&branch) {
1117 self.forget_branch(&branch); continue;
1119 }
1120 if checked_out.contains(&branch) {
1121 continue;
1122 }
1123 if !(force_all || self.worktree_is_done(&branch)) {
1124 continue;
1125 }
1126 match self.git(&["branch", "-D", &branch]) {
1127 Ok(_) => {
1128 self.forget_branch(&branch);
1129 removed.push(format!("branch {branch}"));
1130 }
1131 Err(e) => {
1132 logdim!("could not delete {branch}: {}", e.last_line());
1135 }
1136 }
1137 }
1138 removed
1139 }
1140
1141 fn worktree_is_done(&self, branch: &str) -> bool {
1143 #[derive(Deserialize)]
1144 struct Row {
1145 state: String,
1146 }
1147 let entry = branch
1148 .strip_prefix(self.branch_prefix.as_str())
1149 .unwrap_or(branch);
1150 if let Some(rest) = entry.strip_prefix("pr-") {
1151 return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
1152 }
1153 if entry.starts_with("issue-") {
1154 let text = self.gh_try(&[
1155 "pr", "list", "--head", branch, "--state", "all", "--json", "state",
1156 ]);
1157 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
1158 return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
1159 }
1160 false
1161 }
1162}
1163
1164#[derive(Debug, Clone, serde::Serialize, Deserialize)]
1169pub struct BranchRecord {
1170 pub kind: String,
1171 pub number: i64,
1172}
1173
1174pub fn review_ref(number: i64) -> String {
1177 format!("refs/spar/pr-{number}")
1178}
1179
1180pub fn is_finished(state: &str) -> bool {
1181 matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
1182}
1183
1184pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
1187 if let Some(parent) = path.parent() {
1188 std::fs::create_dir_all(parent)
1189 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1190 }
1191 let tmp = path.with_extension(format!(
1192 "{}.tmp",
1193 path.extension().and_then(|e| e.to_str()).unwrap_or("json")
1194 ));
1195 std::fs::write(&tmp, serde_json::to_vec_pretty(value)?)
1196 .map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
1197 std::fs::rename(&tmp, path)
1198 .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
1199 Ok(())
1200}
1201
1202pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
1209 #[derive(Deserialize)]
1210 #[serde(rename_all = "camelCase")]
1211 struct Row {
1212 number: i64,
1213 #[serde(default)]
1214 url: String,
1215 #[serde(default)]
1216 title: String,
1217 #[serde(default)]
1218 closing_issues_references: Vec<IssueRef>,
1219 }
1220
1221 serde_json::from_str::<Vec<Row>>(json.trim())
1222 .ok()?
1223 .into_iter()
1224 .find(|row| {
1225 row.closing_issues_references
1226 .iter()
1227 .any(|linked| linked.number == issue)
1228 })
1229 .map(|row| PrRef {
1230 number: row.number,
1231 url: row.url,
1232 title: row.title,
1233 })
1234}
1235
1236pub fn parse_comment_pages(text: &str) -> Vec<Value> {
1243 let mut out = Vec::new();
1244 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
1245 match value {
1246 Ok(Value::Array(items)) => out.extend(items),
1247 Ok(other) => out.push(other),
1248 Err(_) => break,
1249 }
1250 }
1251 out
1252}
1253
1254pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
1257 let marker = body.find(STATE_MARKER)?;
1258 let start = body[marker..].find('{')? + marker;
1259 let end = body.rfind('}')?;
1260 if end <= start {
1261 return None;
1262 }
1263 match serde_json::from_str(&body[start..=end]) {
1264 Ok(state) => Some(state),
1265 Err(_) => {
1266 logdim!("found a spar state comment but could not parse it");
1267 None
1268 }
1269 }
1270}
1271
1272pub fn self_binary() -> Result<PathBuf> {
1278 if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
1279 let path = PathBuf::from(path);
1280 if proc::is_executable(&path) {
1281 return Ok(path);
1282 }
1283 bail!(
1284 "SPAR_SELF_BIN is set to {}, which is not executable",
1285 path.display()
1286 );
1287 }
1288 std::env::current_exe()
1289 .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
1290}
1291
1292fn bool_env(value: bool) -> &'static str {
1293 if value {
1294 "1"
1295 } else {
1296 "0"
1297 }
1298}
1299
1300pub fn sh_quote(text: &str) -> String {
1303 format!("'{}'", text.replace('\'', r"'\''"))
1304}
1305
1306pub fn style_from_env() -> Style {
1309 let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
1310 Style {
1311 ban_em_dash: flag("SPAR_BAN_EM_DASH"),
1312 ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
1313 ..Style::permissive()
1314 }
1315}
1316
1317#[cfg(test)]
1318mod tests {
1319 use super::*;
1320 use crate::config::StateStore;
1321 use crate::model::{Ledger, Status};
1322
1323 fn repo_for_titles() -> Repo {
1324 Repo {
1325 root: PathBuf::from("/nonexistent"),
1326 style: Style::default(),
1327 branch_prefix: String::new(),
1328 state_store: StateStore::Local,
1329 followups: crate::config::Followups::Issues,
1330 }
1331 }
1332
1333 #[test]
1337 fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
1338 let repo = repo_for_titles();
1339 for raw in [
1340 "Retry loop spins \u{2014} Retry-After parses to zero",
1341 "plain title",
1342 " spread over\nlines ",
1343 "\u{1F916} Generated with something",
1344 &format!("a \u{2014} {}", "very long title ".repeat(20)),
1345 &"x".repeat(300),
1346 &format!("{} \u{2014} end", "y".repeat(88)),
1347 &{
1352 let tail = "a\u{2014}b c\u{2014}d";
1353 let pad = Style::default().max_title_chars - tail.chars().count();
1354 format!("{}{tail}", "w".repeat(pad))
1355 },
1356 ] {
1357 let once = repo.clean_title(raw).unwrap();
1358 let twice = repo.clean_title(&once).unwrap();
1359 assert_eq!(once, twice, "not idempotent for {raw:?}");
1360 assert!(
1361 once.chars().count() <= repo.style.max_title_chars,
1362 "over budget: {once:?}"
1363 );
1364 assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
1365 }
1366 }
1367
1368 #[test]
1369 fn a_title_with_an_em_dash_survives_as_readable_text() {
1370 let repo = repo_for_titles();
1371 assert_eq!(
1372 "Retry loop spins, Retry-After parses to zero",
1373 repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
1374 .unwrap()
1375 );
1376 }
1377
1378 #[test]
1379 fn sh_quote_survives_a_quote() {
1380 assert_eq!(r"'a'\''b'", sh_quote("a'b"));
1381 }
1382
1383 #[test]
1384 fn sh_quote_wraps_a_space() {
1385 assert_eq!(
1386 "'/Applications/My App/spar'",
1387 sh_quote("/Applications/My App/spar")
1388 );
1389 }
1390
1391 #[test]
1392 fn finished_states_are_recognised_case_insensitively() {
1393 assert!(is_finished("MERGED"));
1394 assert!(is_finished("closed"));
1395 assert!(!is_finished("OPEN"));
1396 assert!(!is_finished(""));
1397 }
1398
1399 fn state() -> PersistedState {
1400 PersistedState {
1401 version: 1,
1402 round: 4,
1403 next_actor: "codex".into(),
1404 status: Status::Pending,
1405 ledger: Ledger::new(),
1406 filed: vec![],
1407 }
1408 }
1409
1410 #[test]
1411 fn a_state_comment_round_trips() {
1412 let body = format!(
1413 "{STATE_MARKER}\n{}\n-->",
1414 serde_json::to_string(&state()).unwrap()
1415 );
1416 let back = parse_state_comment(&body).unwrap();
1417 assert_eq!(4, back.round);
1418 assert_eq!("codex", back.next_actor);
1419 }
1420
1421 #[test]
1423 fn the_state_block_is_an_html_comment() {
1424 let body = format!(
1425 "{STATE_MARKER}\n{}\n-->",
1426 serde_json::to_string(&state()).unwrap()
1427 );
1428 assert!(body.starts_with("<!--"));
1429 assert!(body.trim_end().ends_with("-->"));
1430 assert!(!body[..body.find('{').unwrap()].contains("-->"));
1431 }
1432
1433 #[test]
1434 fn an_unrelated_json_block_is_not_state() {
1435 assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
1436 }
1437
1438 #[test]
1439 fn a_malformed_state_comment_is_none_not_a_panic() {
1440 assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
1441 }
1442
1443 #[test]
1444 fn atomic_write_leaves_no_temp_file() {
1445 let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
1446 let _ = std::fs::remove_dir_all(&dir);
1447 let path = dir.join("state").join("pr-7.json");
1448 write_json_atomic(&path, &state()).unwrap();
1449 let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
1450 .unwrap()
1451 .flatten()
1452 .filter_map(|e| e.file_name().to_str().map(str::to_string))
1453 .collect();
1454 assert_eq!(vec!["pr-7.json".to_string()], files);
1455 let _ = std::fs::remove_dir_all(&dir);
1456 }
1457
1458 #[test]
1459 fn atomic_write_overwrites_rather_than_accumulating() {
1460 let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
1461 let _ = std::fs::remove_dir_all(&dir);
1462 let path = dir.join("pr-7.json");
1463 for round in 1..4 {
1464 let mut s = state();
1465 s.round = round;
1466 write_json_atomic(&path, &s).unwrap();
1467 }
1468 let back: PersistedState =
1469 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1470 assert_eq!(3, back.round);
1471 let _ = std::fs::remove_dir_all(&dir);
1472 }
1473
1474 #[test]
1475 fn style_from_env_defaults_to_enforcing() {
1476 std::env::remove_var("SPAR_BAN_EM_DASH");
1477 std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
1478 let style = style_from_env();
1479 assert!(style.ban_em_dash && style.ban_ai_attribution);
1480 assert!(
1481 !style.terse,
1482 "the commit filter must not truncate a commit message"
1483 );
1484 }
1485}
1486
1487#[cfg(test)]
1488mod comment_page_tests {
1489 use super::*;
1490
1491 #[test]
1492 fn a_single_merged_array_is_read() {
1493 let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
1494 assert_eq!(2, pages.len());
1495 assert_eq!(Some(2), pages[1]["id"].as_i64());
1496 }
1497
1498 #[test]
1499 fn concatenated_pages_from_an_older_gh_are_read_too() {
1500 let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
1501 assert_eq!(2, pages.len());
1502 }
1503
1504 #[test]
1508 fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
1509 let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
1510 let pages = parse_comment_pages(text);
1511 assert_eq!(2, pages.len(), "{pages:?}");
1512 assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
1513 }
1514
1515 #[test]
1516 fn empty_output_is_no_comments_not_a_panic() {
1517 assert!(parse_comment_pages("").is_empty());
1518 assert!(parse_comment_pages(" ").is_empty());
1519 assert!(parse_comment_pages("[]").is_empty());
1520 }
1521
1522 #[test]
1523 fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
1524 assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
1525 }
1526
1527 #[test]
1528 fn state_is_found_in_the_last_matching_comment() {
1529 let payload = |round: u32| {
1530 format!(
1531 "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
1532 )
1533 };
1534 let text = serde_json::to_string(&serde_json::json!([
1535 {"id": 1, "body": payload(1)},
1536 {"id": 2, "body": "looks good to me"},
1537 {"id": 3, "body": payload(5)},
1538 ]))
1539 .unwrap();
1540 let pages = parse_comment_pages(&text);
1541 let last = pages
1542 .iter()
1543 .rev()
1544 .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
1545 .unwrap();
1546 assert_eq!(5, last.round);
1547 }
1548}
1549
1550#[cfg(test)]
1551mod linked_pr_tests {
1552 use super::*;
1553
1554 const REAL_PAYLOAD: &str = r#"[
1559 {"number":14252,"title":"fix: reject leading-dash branch names",
1560 "url":"https://github.com/cli/cli/pull/14252",
1561 "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
1562 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
1563 "url":"https://github.com/cli/cli/issues/14238"}]},
1564 {"number":14217,"title":"another change",
1565 "url":"https://github.com/cli/cli/pull/14217",
1566 "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
1567 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
1568 "url":"https://github.com/cli/cli/issues/9761"}]},
1569 {"number":14200,"title":"unlinked work",
1570 "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
1571 ]"#;
1572
1573 #[test]
1574 fn a_linked_pr_is_found_whatever_its_branch_is_called() {
1575 let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
1576 assert_eq!(14252, pr.number);
1577 assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
1578 }
1579
1580 #[test]
1581 fn the_right_pr_is_picked_out_of_several() {
1582 assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
1583 }
1584
1585 #[test]
1586 fn an_issue_nobody_is_working_on_finds_nothing() {
1587 assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
1588 }
1589
1590 #[test]
1591 fn an_unlinked_pr_is_never_matched() {
1592 for issue in [14200, 0, 1] {
1594 if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
1595 assert_ne!(14200, pr.number, "matched a PR that closes nothing");
1596 }
1597 }
1598 }
1599
1600 #[test]
1601 fn empty_or_broken_output_is_none_rather_than_a_panic() {
1602 assert!(find_linked_pr("", 1).is_none());
1603 assert!(find_linked_pr("[]", 1).is_none());
1604 assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
1605 assert!(find_linked_pr("[{\"number\":", 1).is_none());
1606 }
1607
1608 #[test]
1610 fn pr_view_reads_the_cross_repository_flag() {
1611 let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
1612 "baseRefName":"main","state":"OPEN",
1613 "closingIssuesReferences":[],"isCrossRepository":true}"#;
1614 let pr: PrView = serde_json::from_str(json).unwrap();
1615 assert!(pr.is_cross_repository);
1616 assert!(pr.is_open());
1617
1618 let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
1619 assert!(
1620 !serde_json::from_str::<PrView>(&same_repo)
1621 .unwrap()
1622 .is_cross_repository
1623 );
1624 }
1625}