feat: add first-class stacked PR support to author workflow (#484)

Normal author work is unchanged: gitea_lock_issue still requires the worktree
to be base-equivalent to master/main/dev, and gitea_create_pr still targets a
normal base branch. A *stacked* PR (based on another unmerged PR's branch) is a
new explicit, proof-backed path — it never bypasses the issue lock.

- stacked_pr_support.py: pure policy. Approve a non-master base only when it is
  declared AND owned by a live OPEN PR whose number matches; reject arbitrary,
  mismatched, or stale (merged/closed) branches; require the PR body to document
  the stack (base branch, stacked-on PR, merge ordering).
- issue_lock_worktree.py: read_worktree_git_state / _find_matching_base_ref gain
  an opt-in extra_bases arg so an approved stacked base can anchor
  base-equivalence in addition to master/main/dev (empty by default = unchanged).
- gitea_mcp_server.py:
  - gitea_lock_issue gains stacked_base_branch + stacked_base_pr. When declared,
    it verifies the open PR owns the branch, anchors base-equivalence to it, and
    records approved_stacked_base = {branch, pr_number, verified_open} on the lock.
  - gitea_create_pr validates a non-master base against the lock's approved
    stacked base, re-checks the dependency PR is still open, and enforces the
    stacked-PR body fields. Master-based path untouched.
- Docs: llm-workflow-runbooks.md and work-issue.md document when stacked PRs are
  allowed and the required PR-body wording.

Tests: test_stacked_pr_support.py (18 policy cases), stacked base-equivalence in
test_issue_lock_worktree.py, approved_stacked_base persistence round-trip in
test_issue_lock_store.py. Existing lock/create_pr regressions still pass.

The #482 -> #479/#478 case motivates this: create_pr previously could not open a
stacked PR because the lock required a master-equivalent worktree.

Closes #484.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-08 02:22:54 -04:00
co-authored by Claude Opus 4.8
parent 7af73e1539
commit 0cbd1fc801
8 changed files with 603 additions and 6 deletions
+24
View File
@@ -157,6 +157,30 @@ class TestIssueLockStore(unittest.TestCase):
)
self.assertFalse(ils.has_active_issue_lock("feat/issue-999-other"))
def test_approved_stacked_base_survives_round_trip(self):
# #484: the approved stacked base recorded on the lock must persist so
# gitea_create_pr can validate the non-master base at PR time.
record = _lock_record(
issue_number=482,
branch_name="feat/issue-482-skip-stale-request-changes-pr",
approved_stacked_base={
"branch": "feat/issue-478-mcp-menu-shell",
"pr_number": 479,
"verified_open": True,
},
)
path = ils.lock_file_path(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
issue_number=482,
)
ils.save_lock_file(path, record)
stored = ils.read_lock_file(path)
self.assertEqual(stored["approved_stacked_base"]["branch"], "feat/issue-478-mcp-menu-shell")
self.assertEqual(stored["approved_stacked_base"]["pr_number"], 479)
self.assertTrue(stored["approved_stacked_base"]["verified_open"])
def test_atomic_write_preserves_unrelated_lock(self):
path_a = ils.lock_file_path(
remote="prgs",
+53
View File
@@ -72,6 +72,59 @@ class TestIssueLockWorktreeAssessment(unittest.TestCase):
self.assertTrue(result["proven"])
class TestStackedBaseEquivalence(unittest.TestCase):
"""extra_bases (an approved stacked base) can anchor base-equivalence (#484)."""
def _git(self, *args):
import subprocess
subprocess.run(
["git", "-C", self.repo, *args],
check=True,
capture_output=True,
text=True,
)
def setUp(self):
import subprocess
import tempfile
self.tmp = tempfile.TemporaryDirectory()
self.repo = self.tmp.name
subprocess.run(["git", "init", "-q", self.repo], check=True, capture_output=True)
self._git("config", "user.email", "t@t")
self._git("config", "user.name", "t")
self._git("commit", "--allow-empty", "-q", "-m", "base")
# Rename the default branch away from master/main/dev so no *base* branch
# exists at HEAD — otherwise HEAD would be base-equivalent for free.
self._git("branch", "-m", "trunk")
# Create a non-master "dependency" branch at the same commit, then a
# feature branch off it — mirrors a stacked worktree.
self._git("branch", "feat/issue-100-dep")
self._git("checkout", "-q", "-b", "feat/issue-101-stacked")
def tearDown(self):
self.tmp.cleanup()
def test_stacked_base_not_equivalent_without_extra_bases(self):
state = issue_lock_worktree.read_worktree_git_state(self.repo)
# HEAD does not match master/main/dev, so base-equivalence is False.
self.assertFalse(state["base_equivalent"])
def test_stacked_base_equivalent_with_extra_bases(self):
state = issue_lock_worktree.read_worktree_git_state(
self.repo, extra_bases=("feat/issue-100-dep",)
)
self.assertTrue(state["base_equivalent"])
self.assertEqual(state["base_branch"], "feat/issue-100-dep")
def test_unrelated_extra_base_does_not_anchor(self):
state = issue_lock_worktree.read_worktree_git_state(
self.repo, extra_bases=("feat/does-not-exist",)
)
self.assertFalse(state["base_equivalent"])
class TestIssueLockWorktreeResolution(unittest.TestCase):
def test_explicit_path_wins(self):
resolved = issue_lock_worktree.resolve_author_worktree_path(
+185
View File
@@ -0,0 +1,185 @@
"""Unit tests for stacked-PR base policy (#484)."""
import unittest
import stacked_pr_support as sps
def _pr(number, branch, state="open"):
return {"number": number, "state": state, "head": {"ref": branch}}
OPEN_PRS = [
_pr(479, "feat/issue-478-mcp-menu-shell"),
_pr(481, "feat/issue-477-lock-adoption-proof"),
]
# Motivating case (#482 stacked on #479 / #478).
STACKED_BODY = (
"Closes #482.\n\n"
"Stacked on PR #479 / issue #478.\n"
"Base branch: feat/issue-478-mcp-menu-shell\n"
"Head branch: feat/issue-482-skip-stale-request-changes-pr\n"
"Do not merge before PR #479 lands."
)
class TestIsBaseBranch(unittest.TestCase):
def test_master_main_dev_are_base(self):
for b in ("master", "main", "dev"):
self.assertTrue(sps.is_base_branch(b))
def test_feature_branch_is_not_base(self):
self.assertFalse(sps.is_base_branch("feat/issue-478-mcp-menu-shell"))
class TestStackedBaseDeclaration(unittest.TestCase):
def test_no_declaration_is_normal_path(self):
out = sps.assess_stacked_base_declaration(
stacked_base_branch=None, stacked_base_pr=None, open_prs=OPEN_PRS
)
self.assertFalse(out["block"])
self.assertFalse(out["declared"])
self.assertIsNone(out["approved"])
def test_valid_open_pr_base_is_approved(self):
out = sps.assess_stacked_base_declaration(
stacked_base_branch="feat/issue-478-mcp-menu-shell",
stacked_base_pr=479,
open_prs=OPEN_PRS,
)
self.assertFalse(out["block"])
self.assertEqual(out["approved"]["branch"], "feat/issue-478-mcp-menu-shell")
self.assertEqual(out["approved"]["pr_number"], 479)
self.assertTrue(out["approved"]["verified_open"])
def test_missing_pr_number_blocks(self):
out = sps.assess_stacked_base_declaration(
stacked_base_branch="feat/issue-478-mcp-menu-shell",
stacked_base_pr=None,
open_prs=OPEN_PRS,
)
self.assertTrue(out["block"])
self.assertIn("without stacked_base_pr", out["reasons"][0])
def test_arbitrary_branch_with_no_open_pr_blocks(self):
out = sps.assess_stacked_base_declaration(
stacked_base_branch="feat/random-unrelated-branch",
stacked_base_pr=999,
open_prs=OPEN_PRS,
)
self.assertTrue(out["block"])
self.assertIn("does not correspond to any OPEN pull request", out["reasons"][0])
def test_stale_merged_base_blocks(self):
merged = [_pr(479, "feat/issue-478-mcp-menu-shell", state="closed")]
out = sps.assess_stacked_base_declaration(
stacked_base_branch="feat/issue-478-mcp-menu-shell",
stacked_base_pr=479,
open_prs=merged,
)
self.assertTrue(out["block"])
self.assertIsNone(out["approved"])
def test_pr_number_mismatch_blocks(self):
out = sps.assess_stacked_base_declaration(
stacked_base_branch="feat/issue-478-mcp-menu-shell",
stacked_base_pr=481, # wrong PR for this branch
open_prs=OPEN_PRS,
)
self.assertTrue(out["block"])
self.assertIn("does not match the open", out["reasons"][0])
def test_declaring_a_base_branch_blocks(self):
out = sps.assess_stacked_base_declaration(
stacked_base_branch="master", stacked_base_pr=1, open_prs=OPEN_PRS
)
self.assertTrue(out["block"])
self.assertIn("already a normal base branch", out["reasons"][0])
class TestStackedPrBody(unittest.TestCase):
def test_complete_body_has_no_missing_fields(self):
missing = sps.assess_stacked_pr_body(
STACKED_BODY, base_branch="feat/issue-478-mcp-menu-shell", pr_number=479
)
self.assertEqual(missing, [])
def test_missing_all_fields(self):
missing = sps.assess_stacked_pr_body(
"just some text", base_branch="feat/issue-478-mcp-menu-shell", pr_number=479
)
self.assertEqual(len(missing), 3)
def test_missing_merge_ordering_only(self):
body = "Base branch feat/issue-478-mcp-menu-shell for PR #479"
missing = sps.assess_stacked_pr_body(
body, base_branch="feat/issue-478-mcp-menu-shell", pr_number=479
)
self.assertEqual(len(missing), 1)
self.assertIn("merge-ordering", missing[0])
class TestCreatePrBase(unittest.TestCase):
APPROVED = {"branch": "feat/issue-478-mcp-menu-shell", "pr_number": 479, "verified_open": True}
def test_master_base_passes_without_stacked_metadata(self):
out = sps.assess_create_pr_base(
base="master", approved_stacked_base=None, body="Closes #1", open_prs=[]
)
self.assertFalse(out["block"])
self.assertFalse(out["stacked"])
def test_non_base_without_approval_blocks(self):
out = sps.assess_create_pr_base(
base="feat/issue-478-mcp-menu-shell",
approved_stacked_base=None,
body=STACKED_BODY,
open_prs=OPEN_PRS,
)
self.assertTrue(out["block"])
self.assertIn("no approved stacked base", out["reasons"][0])
def test_non_base_mismatched_approval_blocks(self):
out = sps.assess_create_pr_base(
base="feat/some-other-branch",
approved_stacked_base=self.APPROVED,
body=STACKED_BODY,
open_prs=OPEN_PRS,
)
self.assertTrue(out["block"])
self.assertIn("does not match the issue lock's approved stacked base", out["reasons"][0])
def test_approved_base_with_good_body_passes(self):
out = sps.assess_create_pr_base(
base="feat/issue-478-mcp-menu-shell",
approved_stacked_base=self.APPROVED,
body=STACKED_BODY,
open_prs=OPEN_PRS,
)
self.assertFalse(out["block"])
self.assertTrue(out["stacked"])
self.assertEqual(out["stacked_base_pr"], 479)
def test_approved_base_now_stale_blocks(self):
out = sps.assess_create_pr_base(
base="feat/issue-478-mcp-menu-shell",
approved_stacked_base=self.APPROVED,
body=STACKED_BODY,
open_prs=[_pr(479, "feat/issue-478-mcp-menu-shell", state="merged")],
)
self.assertTrue(out["block"])
self.assertIn("no longer corresponds to an OPEN", out["reasons"][0])
def test_approved_base_with_incomplete_body_blocks(self):
out = sps.assess_create_pr_base(
base="feat/issue-478-mcp-menu-shell",
approved_stacked_base=self.APPROVED,
body="Closes #482 only",
open_prs=OPEN_PRS,
)
self.assertTrue(out["block"])
self.assertIn("must document the stack", out["reasons"][0])
if __name__ == "__main__":
unittest.main()