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

Configure Feed

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

stacker: reset title/body when adopting a recycled PR

create_or_update_current_pr treated every find_open_pr hit as a PR pm
already owns, refreshing only the managed stack block and preserving the
existing title/body. But find_open_pr matches by head ref, and stack/*
branches get recycled for new work -- a reused branch can land on a
still-open PR from an unrelated prior feature, whose title/description
belong to the wrong work. The result was a pushed PR titled and described
for the old feature (a stale "[AUTH-5322] ..." title/body surviving onto
an unrelated branch).

Snapshot whether pm already had a pr_state row for the branch before
find_open_pr runs. With a cache row, keep the preserve-edits behavior.
Without one (a fresh head-ref-search adoption), claim the PR the way a
create would: set the title to this branch's bottom-commit subject and
reset the body to its first commit, then let refresh_component_pr_bodies
splice in the managed stack block.

Adds test_push_resets_title_body_when_adopting_recycled_pr.

Jordan Isaacs (Jun 30, 2026, 2:19 AM UTC) a908ac66 0a07a95d

+141 -5
+36 -4
src/project_manager/stacker/pr/create_update.py
··· 58 58 head_repo = head_repo_for_branch(ctx, tracked) 59 59 title, first_body, worktree_path = first_commit_text(ctx, tracked) 60 60 base = pr_base_for_current_branch(ctx, tracked, config, current_repo) 61 + # Snapshot before find_open_pr (which caches search hits): a cached row 62 + # here means pm already associated this PR with this branch on a prior 63 + # run; its absence means find_open_pr is about to *adopt* a PR it found 64 + # purely by head-ref search. 65 + had_cached_pr = ctx.db.get_pr_state(tracked.repo_name, tracked.branch) is not None 61 66 existing = find_open_pr(ctx, tracked, config, current_repo) 62 67 head = head_ref_for_branch(config, current_repo, remote_branch) 63 68 label = selectors.selector_for(tracked.repo_name, tracked.branch) 64 - if existing: 65 - # Don't ship a body — refresh_component_pr_bodies() runs next and 66 - # rewrites only the managed stacker block, preserving any edits 67 - # the user has made to the surrounding PR description. 69 + if existing and had_cached_pr: 70 + # PR pm already owns. Don't ship a body — refresh_component_pr_bodies() 71 + # runs next and rewrites only the managed stacker block, preserving any 72 + # edits the user has made to the surrounding PR description. 68 73 # 69 74 # Title gets the same treatment: only sync when the live title 70 75 # still matches the bottom-commit subject pm computes. A ··· 84 89 base=base, 85 90 ) 86 91 ) 92 + pr_url = existing.url 93 + elif existing: 94 + # Freshly-adopted PR: find_open_pr matched it by head ref alone, with 95 + # no prior pr_state row. The head ref is recyclable — a `stack/*` 96 + # branch reused for new work can land on a still-open PR from an 97 + # unrelated prior feature (different title, different description). 98 + # Without a cache row we have no evidence the PR's title/body belong 99 + # to *this* branch, so the preserve-manual-edits assumption is unsafe. 100 + # Claim it for the current branch the same way a create would: set the 101 + # title to this branch's bottom-commit subject and reset the body to 102 + # this branch's first commit. refresh_component_pr_bodies() then splices 103 + # in the managed stack block on top. 104 + fmt.record(ctx, logs, f"Adopting PR #{existing.number} for {label}") 105 + template = load_pr_template(worktree_path) 106 + body_seed = ( 107 + inject_body_into_template(first_body, template) if template is not None else first_body 108 + ) 109 + with body_file(compose_body_with_block(body_seed, "")) as file_path: 110 + ctx.pr_backend.edit_pr( 111 + gh.EditPRRequest( 112 + repo=target_repo, 113 + number=existing.number, 114 + title=title, 115 + body_file=file_path, 116 + base=base, 117 + ) 118 + ) 87 119 pr_url = existing.url 88 120 else: 89 121 fmt.record(ctx, logs, f"Creating PR for {label}")
+18 -1
tests/stacker/fakes.py
··· 8 8 from __future__ import annotations 9 9 10 10 from collections.abc import Sequence 11 - from dataclasses import dataclass, field 11 + from dataclasses import dataclass, field, replace 12 12 from pathlib import Path 13 13 14 14 from project_manager.stacker import gh ··· 89 89 def edit_pr(self, request: gh.EditPRRequest) -> None: 90 90 body = _read_body(request.body_file) if request.body_file else None 91 91 self.edited.append((request, body)) 92 + # Reflect the edit into the live PR so a follow-up view_pr() reads the 93 + # value we just wrote — exactly as `gh pr edit` then a REST GET would. 94 + # Each EditPRRequest field is optional; only the ones actually sent are 95 + # applied, so unset fields keep their prior value. 96 + updates: dict[str, object] = {} 97 + if request.title is not None: 98 + updates["title"] = request.title 99 + if body is not None: 100 + updates["body"] = body 101 + if request.base is not None: 102 + updates["base_ref_name"] = request.base 103 + if not updates: 104 + return 105 + for key, pr in list(self.prs_by_head.items()): 106 + if pr.number == request.number: 107 + self.prs_by_head[key] = replace(pr, **updates) # type: ignore[arg-type] 108 + break 92 109 93 110 def view_pr(self, url: str) -> gh.PullRequest | None: 94 111 for pr in self.prs_by_head.values():
+87
tests/stacker/test_push.py
··· 423 423 push_service.push(target, PushOptions(scope=ScopeSpec(only=True))) 424 424 title_syncs = [req.title for req, _body in backend.edited if req.title is not None] 425 425 assert title_syncs == ["a: first commit"] 426 + 427 + 428 + def _seed_recycled_remote_pr( 429 + backend: RecordingPRBackend, 430 + branch: str, 431 + *, 432 + title: str, 433 + body: str, 434 + number: int = 77, 435 + ) -> gh.PullRequest: 436 + """Register an OPEN PR on GitHub for `branch`'s head ref WITHOUT caching it. 437 + 438 + Mirrors the production "recycled fork branch" footgun: the fork head 439 + ref `branch` was reused for new work, but an unrelated prior feature's 440 + PR is still open on that exact head ref. pm has no `pr_state` row yet, 441 + so it discovers this PR via head-ref search — i.e. it *adopts* a PR it 442 + never created. Leaving the cache empty is the whole point: this is the 443 + fresh-adoption path, distinct from a PR pm already owns. 444 + """ 445 + pr = gh.PullRequest( 446 + number=number, 447 + url=f"https://github.com/acme/widgets/pull/{number}", 448 + title=title, 449 + body=body, 450 + head_ref_name=branch, 451 + base_ref_name="main", 452 + state="OPEN", 453 + is_draft=False, 454 + ) 455 + backend.prs_by_head[("acme/widgets", branch)] = pr 456 + return pr 457 + 458 + 459 + def test_push_resets_title_body_when_adopting_recycled_pr( 460 + push_service: StackerService, 461 + backend: RecordingPRBackend, 462 + tracked_stack: TrackedStack, 463 + ) -> None: 464 + """Adopting a recycled PR must claim it for the current branch's work. 465 + 466 + Reproduces the production corruption: a fork head ref was reused for a 467 + new feature while an UNRELATED prior feature's PR was still open on 468 + that head ref. pm discovered that PR by head-ref search (no `pr_state` 469 + cached), then only refreshed the managed stack block — leaving the 470 + other feature's title and description intact. The pushed PR ended up 471 + titled for the wrong feature, with the wrong body. 472 + 473 + On first adoption pm must set the title to this branch's bottom-commit 474 + subject and reset the body's user content to this branch's first 475 + commit, exactly as it would for a PR it just created. (A PR pm already 476 + owns — one with a cached `pr_state` row — keeps the conservative 477 + preserve-manual-edits behavior; that contract is covered separately.) 478 + """ 479 + stale_title = "[AUTH-5322] Add login Dicer slicelet target (#2141218)" 480 + stale_body = ( 481 + "## What did you change\n\nUnrelated login/dbns dicer config work.\n\n" 482 + "Co-authored-by: Christopher Gravel <chrisgravel-db@example.com>\n" 483 + ) 484 + _seed_recycled_remote_pr( 485 + backend, 486 + "a", 487 + title=stale_title, 488 + body=stale_body, 489 + ) 490 + # Critical: no pr_state row — this is a fresh head-ref-search adoption. 491 + assert push_service.db.get_pr_state(tracked_stack.repo_name, "a") is None 492 + 493 + target = SelectorTarget(repo_name=tracked_stack.repo_name, branch="a") 494 + push_service.push(target, PushOptions(scope=ScopeSpec(only=True))) 495 + 496 + # The title must be reset to this branch's bottom-commit subject, not 497 + # left as the recycled feature's title. 498 + title_syncs = [req.title for req, _body in backend.edited if req.title is not None] 499 + assert title_syncs, "adopting a recycled PR must set the title" 500 + assert title_syncs[-1] == "a: first commit", ( 501 + f"recycled PR title should be reset to the current branch's subject, " 502 + f"got title_syncs={title_syncs}" 503 + ) 504 + 505 + # The body's user content must no longer carry the other feature's 506 + # stale description (its AUTH-5322 prose / co-author trailer). 507 + final_body = backend.body_sent_for("a") 508 + assert final_body is not None 509 + assert "AUTH-5322" not in final_body, ( 510 + f"recycled PR body still carries the unrelated feature's description:\n{final_body}" 511 + ) 512 + assert "Christopher Gravel" not in final_body