Integrated repository, worktree, git stacker, and project manager
0

Configure Feed

Select the types of activity you want to include in your feed.

stacker: add PR template support and adopt gitstack body format

On PR creation, load `.github/PULL_REQUEST_TEMPLATE.md` from the worktree
and inject the first commit's body into the template's first `## ` section
(mirrors universe gitstack `inject_body_into_template`). Update path stays
body-less so user edits to the template-filled body survive re-push
(universe PR #1859413 regression guard).

Reformat the managed block to ReviewStack's gitstack layout: emit
`## 🥞 Stacked PR` header + `---------` separator inside the existing
`<!-- stacker:begin/end -->` markers. The HTML markers remain our
authoritative parsing boundary; the inner header + separator are what
ReviewStack's gitstackParser keys off so PRs are recognized as stacked.

Jordan Isaacs (May 1, 2026, 6:15 AM UTC) 07d47586 ab2ea3fe

+451 -28
+14 -2
src/project_manager/stacker/pr/create_update.py
··· 24 24 target_repo_slug, 25 25 ) 26 26 from .stack_block import _StackRender, body_file, render_stack_block 27 + from .template import inject_body_into_template, load_pr_template 27 28 28 29 if TYPE_CHECKING: 29 30 from project_manager.stacker.ctx import StackerCtx ··· 55 56 target_repo = target_repo_slug(config) 56 57 remote_branch = remote_branch_name(ctx, tracked) 57 58 head_repo = head_repo_for_branch(ctx, tracked) 58 - title, first_body = first_commit_text(ctx, tracked) 59 + title, first_body, worktree_path = first_commit_text(ctx, tracked) 59 60 base = pr_base_for_current_branch(ctx, tracked, config, current_repo) 60 61 existing = find_open_pr(ctx, tracked, config, current_repo) 61 62 head = head_ref_for_branch(config, current_repo, remote_branch) ··· 76 77 pr_url = existing.url 77 78 else: 78 79 fmt.record(ctx, logs, f"Creating PR for {label}") 79 - body = compose_body_with_block(first_body, "") 80 + # Template-injection happens at create time only; on re-push the 81 + # update branch above keeps `EditPRRequest.body` unset, so the 82 + # template-filled body the user has since edited in the GitHub 83 + # UI survives. Mirrors universe gitstack `inject_body_into_template` 84 + # at universe/ci/gitstack/src/commands/push.rs:935-958. 85 + template = load_pr_template(worktree_path) 86 + body_seed = ( 87 + inject_body_into_template(first_body, template) 88 + if template is not None 89 + else first_body 90 + ) 91 + body = compose_body_with_block(body_seed, "") 80 92 with body_file(body) as file_path: 81 93 pr_url = ctx.pr_backend.create_pr( 82 94 gh.CreatePRRequest(
+15 -3
src/project_manager/stacker/pr/find.py
··· 139 139 140 140 141 141 def strip_managed_block(body: str) -> str: 142 + """Remove the `<!-- stacker:begin/end -->`-wrapped managed block. 143 + 144 + HTML-comment markers are our authoritative boundary — they're 145 + invisible in rendered markdown and cannot collide with user content 146 + the way a `## 🥞` heading or `---------` rule could. 147 + """ 142 148 return re.sub( 143 149 r"\n?<!-- stacker:begin -->.*?<!-- stacker:end -->\n?", 144 150 "\n", ··· 148 154 149 155 150 156 def compose_body_with_block(body: str, block: str) -> str: 157 + """Combine the managed block (already marker-wrapped) with user body. 158 + 159 + Block leads, user content follows: matches the gitstack-format 160 + layout ReviewStack's parser expects (header at the top, `---------` 161 + separator before the commit message body). 162 + """ 151 163 cleaned = strip_managed_block(body).strip() 152 - if block: 153 - return f"{cleaned}\n\n{block}" if cleaned else block 154 - return cleaned 164 + if not block: 165 + return cleaned 166 + return f"{block}\n\n{cleaned}" if cleaned else block 155 167 156 168 157 169 def pr_base_body(pr: gh.PullRequest) -> str:
+5 -2
src/project_manager/stacker/pr/resolve.py
··· 1 1 from __future__ import annotations 2 2 3 + from pathlib import Path 3 4 from typing import TYPE_CHECKING 4 5 5 6 from project_manager.stacker import gh, git, locate, selectors ··· 61 62 return remote_branch 62 63 63 64 64 - def first_commit_text(ctx: StackerCtx, tracked: TrackedBranch) -> tuple[str, str]: 65 + def first_commit_text( 66 + ctx: StackerCtx, tracked: TrackedBranch 67 + ) -> tuple[str, str, Path]: 65 68 path = require_checked_out(ctx, tracked.repo_name, tracked.branch) 66 69 title, body = git.first_commit_title_and_body( 67 70 path, f"{tracked.managed_base_commit}..HEAD" 68 71 ) 69 72 if not title: 70 73 title = tracked.branch 71 - return title, body 74 + return title, body, path 72 75 73 76 74 77 def pr_base_for_current_branch(
+29 -16
src/project_manager/stacker/pr/stack_block.py
··· 15 15 from project_manager.stacker.ctx import StackerCtx 16 16 17 17 18 + # Format constants must match ReviewStack's gitstack parser at 19 + # universe/js/packages/internal-tools/src/devportal/experimental/reviewstack/ 20 + # vendor/osscopy/stackFormat/parsers/gitstackParser.ts:23-50: header is 21 + # matched as a substring on a line; footer is the first line whose strip() 22 + # starts with at least nine dashes. 23 + STACK_HEADER = "## 🥞 Stacked PR" 24 + STACK_SEPARATOR = "---------" 25 + 26 + 18 27 @dataclass(frozen=True) 19 28 class _StackRender: 20 29 """Shared context threaded through stack-block rendering recursion.""" ··· 35 44 render_ctx: _StackRender, 36 45 current_node: TrackedBranch, 37 46 ) -> str: 38 - """Render the stack block embedded in a PR body. 47 + """Render the gitstack-format leading block of a PR body. 48 + 49 + Layout matches ReviewStack's gitstack parser: a `## 🥞 Stacked PR` 50 + header, an indented branch tree, then a `---------` separator that 51 + `compose_body_with_block` uses to splice in the user content. Each 52 + branch renders as `[branch](pr) [[Files changed](files)]` with 53 + `[MERGED]`/`[CLOSED]` appended for landed/abandoned PRs; the current 54 + branch's name is bolded inside the link. 39 55 40 - One-line preamble pointing reviewers at the current branch's files 41 - view, followed by an indented tree. Each branch renders as 42 - `[branch](pr) [[Files changed](files)]` with `[MERGED]` appended 43 - for landed PRs; the current branch's name is bolded inside the link. 56 + Files-changed links are still gated to `repo-pr` mode — in pr-pr 57 + mode GitHub's own base-chain already gives reviewers the per-branch 58 + diff. 44 59 """ 45 - lines = ["<!-- stacker:begin -->", "## Stacker", ""] 46 - # Preamble + Files-changed links only make sense when every PR bases 47 - # on trunk (repo-pr). In pr-pr mode GitHub's own base-chain already 48 - # gives reviewers the per-branch diff. 49 - if render_ctx.config.mode == "repo-pr": 50 - preamble = _files_url(current_node, render_ctx) 51 - if preamble is not None: 52 - lines.append( 53 - f"Use this [link]({preamble}) to review incremental changes." 54 - ) 55 - lines.append("") 60 + # Wrap the gitstack-format content in HTML-comment markers. The markers 61 + # are our authoritative managed-block boundary — `strip_managed_block` 62 + # parses them — because they're invisible in rendered markdown and 63 + # cannot collide with user content the way a `## 🥞` heading or 64 + # `---------` rule could. The inner header + separator are what 65 + # ReviewStack's gitstack parser keys off. 66 + lines = ["<!-- stacker:begin -->", STACK_HEADER, ""] 56 67 component_keys = {node.branch for node in render_ctx.component} 57 68 roots = [ 58 69 node for node in render_ctx.component if node.parent_branch not in component_keys ··· 61 72 if index: 62 73 lines.append("") 63 74 lines.extend(_render_stack_lines(render_ctx, root, current_node, prefix="")) 75 + lines.append("") 76 + lines.append(STACK_SEPARATOR) 64 77 lines.append("<!-- stacker:end -->") 65 78 return "\n".join(lines) 66 79
+77
src/project_manager/stacker/pr/template.py
··· 1 + """GitHub PR template discovery and commit-body injection. 2 + 3 + Mirrors universe gitstack's template handling 4 + (`universe/ci/gitstack/src/commands/push.rs:935-958`): on PR creation, load 5 + the repo's `.github/PULL_REQUEST_TEMPLATE.md` from the worktree and inject 6 + the first commit's body into the template's first `## ` section, so a 7 + `## Summary` heading auto-fills with the commit body. 8 + 9 + Future work (deliberately out of scope for v1): root-level 10 + `PULL_REQUEST_TEMPLATE.md`, `docs/PULL_REQUEST_TEMPLATE.md`, and the 11 + `.github/PULL_REQUEST_TEMPLATE/` multi-template directory. 12 + """ 13 + 14 + from __future__ import annotations 15 + 16 + from pathlib import Path 17 + 18 + TEMPLATE_PATHS: tuple[str, ...] = ( 19 + ".github/PULL_REQUEST_TEMPLATE.md", 20 + ".github/pull_request_template.md", 21 + ) 22 + 23 + 24 + def load_pr_template(worktree_path: Path) -> str | None: 25 + """Return PR template text from `worktree_path`, or None if absent. 26 + 27 + Searches `TEMPLATE_PATHS` in order; first hit wins. UnicodeDecodeError 28 + or OSError → None (treat as absent). HTML comments are preserved verbatim 29 + so the template author's instructional `<!-- delete if N/A -->` notes 30 + survive into the rendered PR body. 31 + """ 32 + for relative in TEMPLATE_PATHS: 33 + candidate = worktree_path / relative 34 + try: 35 + return candidate.read_text(encoding="utf-8") 36 + except (FileNotFoundError, IsADirectoryError): 37 + continue 38 + except (UnicodeDecodeError, OSError): 39 + return None 40 + return None 41 + 42 + 43 + def inject_body_into_template(commit_body: str, template: str) -> str: 44 + """Insert `commit_body` into the first `## ` section of `template`. 45 + 46 + - empty `commit_body` → template returned unchanged 47 + - no `## ` header in template → `commit_body` + blank line + template 48 + - else → `commit_body` inserted between the first `## ` line and the 49 + next `## ` line (or end of template); pre-existing prose in the 50 + first section is preserved AFTER the injected body, matching 51 + gitstack's behavior so default scaffolding stays visible for the 52 + user to delete. 53 + """ 54 + if not commit_body: 55 + return template 56 + lines = template.split("\n") 57 + first_header = next( 58 + (i for i, line in enumerate(lines) if line.startswith("## ")), 59 + None, 60 + ) 61 + if first_header is None: 62 + return f"{commit_body}\n\n{template}" 63 + next_header = next( 64 + ( 65 + i 66 + for i in range(first_header + 1, len(lines)) 67 + if lines[i].startswith("## ") 68 + ), 69 + len(lines), 70 + ) 71 + out = ( 72 + lines[: first_header + 1] 73 + + ["", commit_body, ""] 74 + + lines[first_header + 1 : next_header] 75 + + lines[next_header:] 76 + ) 77 + return "\n".join(out)
+139
tests/stacker/test_pr_body_rendering.py
··· 25 25 from project_manager.stacker.ops import worktree as ops_worktree 26 26 from project_manager.stacker.service import StackerService 27 27 28 + from .conftest import commit_file 28 29 from .fakes import RecordingPRBackend 29 30 30 31 ··· 552 553 service.push(SelectorTarget(repo_name=repo_name, branch="feature-b"), PushOptions(draft=True)) 553 554 for request, _body in backend.created: 554 555 assert request.head_repo == "acme/widgets" 556 + 557 + 558 + def _seed_pr_template(repo_path: Path, content: str) -> None: 559 + """Commit `.github/PULL_REQUEST_TEMPLATE.md` to main so slot worktrees inherit it. 560 + 561 + `_build_branch` does `git checkout -b <branch> <main_head>` in each 562 + slot, which updates the slot's working tree to match `main` — so the 563 + template lands in every slot before pushes call `load_pr_template`. 564 + """ 565 + commit_file(repo_path, ".github/PULL_REQUEST_TEMPLATE.md", content, "add PR template") 566 + 567 + 568 + def _user_section(body: str) -> str: 569 + """Slice everything after the managed block end marker.""" 570 + marker = "<!-- stacker:end -->" 571 + idx = body.index(marker) 572 + return body[idx + len(marker) :].lstrip("\n") 573 + 574 + 575 + def _final_body_for(backend: RecordingPRBackend, branch: str) -> str: 576 + """Last body the backend wrote for `branch` (after refresh appends the block).""" 577 + pr_number = next( 578 + pr.number for (_repo, head), pr in backend.prs_by_head.items() if head == branch 579 + ) 580 + for req, body in reversed(backend.edited): 581 + if req.number == pr_number and body is not None: 582 + return body 583 + raise AssertionError(f"no body-bearing edit_pr for {branch}") 584 + 585 + 586 + def test_create_pr_uses_template_when_present( 587 + service: StackerService, 588 + backend: RecordingPRBackend, 589 + stacker_repo: tuple[str, Path], 590 + three_slots: list[slot_mod.Slot], 591 + monkeypatch: pytest.MonkeyPatch, 592 + ) -> None: 593 + repo_name, repo_path = stacker_repo 594 + _seed_pr_template(repo_path, "## Summary\n\n## Test plan\n") 595 + _two_branch_stack(service, stacker_repo, three_slots, monkeypatch) 596 + _set_config(service, repo_name, "repo-pr") 597 + 598 + service.push(SelectorTarget(repo_name=repo_name, branch="feature-b"), PushOptions(draft=True)) 599 + 600 + for branch, expected_extra in (("feature-a", "Extra body for A."), ("feature-b", "Extra body for B.")): 601 + body = _final_body_for(backend, branch) 602 + user = _user_section(body) 603 + assert "## Summary" in user 604 + assert "## Test plan" in user 605 + assert expected_extra in user 606 + # Commit body sits between the two template headers. 607 + assert user.index("## Summary") < user.index(expected_extra) < user.index("## Test plan") 608 + 609 + 610 + def test_create_pr_no_template_unchanged_user_section( 611 + service: StackerService, 612 + backend: RecordingPRBackend, 613 + stacker_repo: tuple[str, Path], 614 + three_slots: list[slot_mod.Slot], 615 + monkeypatch: pytest.MonkeyPatch, 616 + ) -> None: 617 + """Backwards-compat guard: no template → user content is just the commit body.""" 618 + repo_name, _repo_path = stacker_repo 619 + _two_branch_stack(service, stacker_repo, three_slots, monkeypatch) 620 + _set_config(service, repo_name, "repo-pr") 621 + 622 + service.push(SelectorTarget(repo_name=repo_name, branch="feature-b"), PushOptions(draft=True)) 623 + 624 + body_a = _final_body_for(backend, "feature-a") 625 + assert _user_section(body_a) == "Extra body for A." 626 + 627 + 628 + def test_create_pr_template_without_headers_prepends_commit_body( 629 + service: StackerService, 630 + backend: RecordingPRBackend, 631 + stacker_repo: tuple[str, Path], 632 + three_slots: list[slot_mod.Slot], 633 + monkeypatch: pytest.MonkeyPatch, 634 + ) -> None: 635 + repo_name, repo_path = stacker_repo 636 + _seed_pr_template(repo_path, "Boilerplate prose.\n") 637 + _two_branch_stack(service, stacker_repo, three_slots, monkeypatch) 638 + _set_config(service, repo_name, "repo-pr") 639 + 640 + service.push(SelectorTarget(repo_name=repo_name, branch="feature-b"), PushOptions(draft=True)) 641 + 642 + body_a = _final_body_for(backend, "feature-a") 643 + user = _user_section(body_a) 644 + assert user.startswith("Extra body for A.") 645 + assert "Boilerplate prose." in user 646 + 647 + 648 + def test_second_push_preserves_user_edits_to_template_filled_body( 649 + service: StackerService, 650 + backend: RecordingPRBackend, 651 + stacker_repo: tuple[str, Path], 652 + three_slots: list[slot_mod.Slot], 653 + monkeypatch: pytest.MonkeyPatch, 654 + ) -> None: 655 + """Universe PR #1859413 regression — extended for templates. 656 + 657 + With template support seeding the initial body, the user editing it 658 + in the GitHub UI must still survive re-push. Guard: re-push must 659 + emit exactly one body-bearing edit_pr per branch (the stack-block 660 + refresh), and the user's edits below the managed block survive. 661 + """ 662 + repo_name, repo_path = stacker_repo 663 + _seed_pr_template(repo_path, "## Summary\n\n## Test plan\n") 664 + _two_branch_stack(service, stacker_repo, three_slots, monkeypatch) 665 + _set_config(service, repo_name, "repo-pr") 666 + target = SelectorTarget(repo_name=repo_name, branch="feature-b") 667 + 668 + service.push(target, PushOptions(draft=True)) 669 + 670 + # User edits the body in the GitHub UI to fill in template sections. 671 + user_body = ( 672 + "## Summary\n\nFilled in by the human, must not be clobbered.\n\n" 673 + "## Test plan\n\nTested in dev.\n" 674 + ) 675 + key = (backend.default_repo.name_with_owner, "feature-b") 676 + backend.prs_by_head[key] = replace(backend.prs_by_head[key], body=user_body) 677 + edits_before_second_push = len(backend.edited) 678 + 679 + service.push(target, PushOptions(draft=True)) 680 + 681 + second_push_edits = backend.edited[edits_before_second_push:] 682 + update_with_body = [ 683 + req for req, body in second_push_edits if body is not None and req.number == 2 684 + ] 685 + assert len(update_with_body) == 1, ( 686 + "create_or_update_current_pr must not re-write body for an existing PR" 687 + ) 688 + final_b = next( 689 + body for req, body in reversed(backend.edited) if req.number == 2 and body is not None 690 + ) 691 + assert "Filled in by the human, must not be clobbered." in final_b 692 + assert "<!-- stacker:begin -->" in final_b 693 + assert "<!-- stacker:end -->" in final_b
+79
tests/stacker/test_pr_template.py
··· 1 + from __future__ import annotations 2 + 3 + from pathlib import Path 4 + 5 + from project_manager.stacker.pr.template import ( 6 + inject_body_into_template, 7 + load_pr_template, 8 + ) 9 + 10 + 11 + def test_load_pr_template_uppercase_path(tmp_path: Path) -> None: 12 + template = "## Summary\n\n## Test plan\n" 13 + (tmp_path / ".github").mkdir() 14 + (tmp_path / ".github" / "PULL_REQUEST_TEMPLATE.md").write_text(template) 15 + assert load_pr_template(tmp_path) == template 16 + 17 + 18 + def test_load_pr_template_lowercase_fallback(tmp_path: Path) -> None: 19 + template = "Plain template body\n" 20 + (tmp_path / ".github").mkdir() 21 + (tmp_path / ".github" / "pull_request_template.md").write_text(template) 22 + assert load_pr_template(tmp_path) == template 23 + 24 + 25 + def test_load_pr_template_uppercase_takes_precedence(tmp_path: Path) -> None: 26 + (tmp_path / ".github").mkdir() 27 + (tmp_path / ".github" / "PULL_REQUEST_TEMPLATE.md").write_text("UPPER") 28 + (tmp_path / ".github" / "pull_request_template.md").write_text("lower") 29 + assert load_pr_template(tmp_path) == "UPPER" 30 + 31 + 32 + def test_load_pr_template_absent_returns_none(tmp_path: Path) -> None: 33 + assert load_pr_template(tmp_path) is None 34 + 35 + 36 + def test_load_pr_template_unreadable_returns_none(tmp_path: Path) -> None: 37 + (tmp_path / ".github").mkdir() 38 + # Non-UTF-8 bytes — invalid as template; treat as absent. 39 + (tmp_path / ".github" / "PULL_REQUEST_TEMPLATE.md").write_bytes(b"\xff\xfe\x00\x00") 40 + assert load_pr_template(tmp_path) is None 41 + 42 + 43 + def test_inject_body_into_template_first_section_filled() -> None: 44 + template = "## Summary\n\n## Test plan\n" 45 + body = "Adds frobber" 46 + out = inject_body_into_template(body, template) 47 + assert "## Summary" in out 48 + assert "## Test plan" in out 49 + summary_index = out.index("## Summary") 50 + body_index = out.index(body) 51 + test_plan_index = out.index("## Test plan") 52 + assert summary_index < body_index < test_plan_index 53 + 54 + 55 + def test_inject_body_into_template_no_headers_prepends() -> None: 56 + template = "Boilerplate prose\n" 57 + body = "Adds frobber" 58 + out = inject_body_into_template(body, template) 59 + assert out == f"{body}\n\n{template}" 60 + 61 + 62 + def test_inject_body_into_template_empty_body_returns_template_verbatim() -> None: 63 + template = "## Summary\n\n## Test plan\n" 64 + assert inject_body_into_template("", template) == template 65 + 66 + 67 + def test_inject_body_into_template_preserves_html_comments() -> None: 68 + template = "## Summary\n<!-- delete this section if N/A -->\n\n## Test plan\n" 69 + out = inject_body_into_template("X", template) 70 + assert "<!-- delete this section if N/A -->" in out 71 + 72 + 73 + def test_inject_body_into_template_preserves_first_section_default_prose() -> None: 74 + template = "## Summary\n\nDelete me.\n\n## Other\n" 75 + out = inject_body_into_template("X", template) 76 + x_index = out.index("X") 77 + delete_me_index = out.index("Delete me.") 78 + other_index = out.index("## Other") 79 + assert x_index < delete_me_index < other_index
+3 -5
tests/stacker/test_stack_block.py
··· 113 113 "[[Files changed](https://github.com/acme/widgets/pull/X/files/abc..def?node=feat-b)]" 114 114 in block 115 115 ) 116 - # Preamble pulls the current branch's files URL to the top. 117 - assert ( 118 - "Use this [link](https://github.com/acme/widgets/pull/X/files/abc..def?node=feat-b)" 119 - " to review" in block 120 - ) 116 + # Intro preamble is intentionally dropped from the gitstack-format block; 117 + # per-branch [[Files changed]] links already give reviewers per-branch diffs. 118 + assert "review incremental changes" not in block
+90
tests/stacker/test_stack_block_format.py
··· 1 + """Compose/strip contract for the managed block. 2 + 3 + Pure-string tests over `pr/find.py` boundary helpers; no fixtures needed. 4 + 5 + The managed block is wrapped in `<!-- stacker:begin -->` / `<!-- stacker:end -->` 6 + HTML-comment markers (our authoritative parsing boundary), with the 7 + gitstack-format header + separator emitted *inside* the markers so 8 + ReviewStack's gitstack parser recognizes the PR as stacked. 9 + """ 10 + 11 + from __future__ import annotations 12 + 13 + from project_manager.stacker.pr.find import ( 14 + compose_body_with_block, 15 + strip_managed_block, 16 + ) 17 + from project_manager.stacker.pr.stack_block import STACK_HEADER, STACK_SEPARATOR 18 + 19 + 20 + def _block(branches: str = "- [feat](https://github.com/o/r/pull/1)") -> str: 21 + return ( 22 + "<!-- stacker:begin -->\n" 23 + f"{STACK_HEADER}\n" 24 + "\n" 25 + f"{branches}\n" 26 + "\n" 27 + f"{STACK_SEPARATOR}\n" 28 + "<!-- stacker:end -->" 29 + ) 30 + 31 + 32 + def test_strip_managed_block_removes_markered_section() -> None: 33 + body = f"{_block()}\n\nUser content here\n\n## Summary" 34 + assert strip_managed_block(body) == "User content here\n\n## Summary" 35 + 36 + 37 + def test_strip_managed_block_no_markers_returns_body_stripped() -> None: 38 + body = " no managed block in here " 39 + assert strip_managed_block(body) == "no managed block in here" 40 + 41 + 42 + def test_strip_managed_block_ignores_lone_gitstack_header_in_user_text() -> None: 43 + # User wrote `## 🥞 Stacked PR` and `---------` in their own template 44 + # but did not wrap them in our HTML markers — strip must leave them. 45 + body = f"## 🥞 Stacked PR (mentioned by user)\n---------\nUser body" 46 + assert strip_managed_block(body) == body.strip() 47 + 48 + 49 + def test_compose_body_with_block_empty_block_returns_user_body() -> None: 50 + assert compose_body_with_block("Hello", "") == "Hello" 51 + 52 + 53 + def test_compose_body_with_block_emits_block_then_user_content() -> None: 54 + block = _block() 55 + out = compose_body_with_block("User text", block) 56 + assert out == f"{block}\n\nUser text" 57 + 58 + 59 + def test_compose_body_with_block_strips_existing_managed_block() -> None: 60 + # Body already has a stale managed block — compose must replace, not duplicate. 61 + stale_body = f"{_block()}\n\nUser text" 62 + fresh_block = _block("- [feat](https://github.com/o/r/pull/2)") 63 + out = compose_body_with_block(stale_body, fresh_block) 64 + # Exactly one begin marker, one end marker. 65 + assert out.count("<!-- stacker:begin -->") == 1 66 + assert out.count("<!-- stacker:end -->") == 1 67 + # Fresh block content survived; stale did not. 68 + assert "/pull/2" in out 69 + assert "/pull/1" not in out 70 + # User content survived. 71 + assert "User text" in out 72 + 73 + 74 + def test_composed_body_satisfies_gitstack_parser_signature() -> None: 75 + """ReviewStack's gitstack parser keys off the inner header + separator.""" 76 + block = _block() 77 + out = compose_body_with_block("## Summary\n\nThe details", block) 78 + # gitstackParser.ts:179 — header substring check. 79 + assert STACK_HEADER in out 80 + # gitstackParser.ts:184 — separator presence check. 81 + assert any(line.strip().startswith(STACK_SEPARATOR) for line in out.split("\n")) 82 + # The separator must precede the user content (parser slices commit 83 + # message from after the separator). 84 + sep_line = next( 85 + i for i, line in enumerate(out.split("\n")) if line.strip().startswith(STACK_SEPARATOR) 86 + ) 87 + summary_line = next( 88 + i for i, line in enumerate(out.split("\n")) if line.strip() == "## Summary" 89 + ) 90 + assert summary_line > sep_line