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
+22
View File
@@ -286,6 +286,28 @@ heartbeat timestamp. An active same-issue/same-operation lease blocks duplicate
work. An expired lease still blocks takeover until a recovery review records why
the prior work is abandoned, completed, or unsafe to continue.
**Stacked PRs (#484).** By default the lock worktree must be base-equivalent to
`master`/`main`/`dev` — ordinary work is unchanged. A *stacked* PR (deliberately
based on another unmerged PR's branch) is an explicit, opt-in path: pass
`stacked_base_branch` **and** `stacked_base_pr` to `gitea_lock_issue`. The lock
fails closed unless that branch is owned by a live **open** PR whose number
matches `stacked_base_pr`, so arbitrary or stale branches cannot be used as
bases. When approved, the lock payload records
`approved_stacked_base = {branch, pr_number, verified_open}` and the worktree may
be base-equivalent to that branch instead of master. `gitea_create_pr` then
allows `base = <that branch>` only when it matches the recorded approval, the
dependency PR is **still open**, and the PR body documents the stack:
- `Stacked on PR #<X> / issue #<Y>`
- `Base branch: <feature-branch>`
- `Head branch: <this-issue-branch>`
- `Do not merge before PR #<X>` (merge ordering)
- retarget/rebase to `master` after the dependency lands, if required
Stacked support never bypasses the issue lock — the base is recorded *on* the
lock and re-verified at PR time. A merged/closed dependency base fails closed;
retarget onto `master` or re-lock against a live base.
**Do not manually seed `/tmp/gitea_issue_lock.json` or any lock file as a normal
recovery path.** That global slot is deprecated and can clobber unrelated live
leases (#438). After an MCP restart, call `gitea_lock_issue` again — own-branch
+74 -1
View File
@@ -535,6 +535,7 @@ import issue_lock_worktree # noqa: E402
import issue_lock_provenance # noqa: E402
import issue_lock_store # noqa: E402
import issue_lock_adoption # noqa: E402
import stacked_pr_support # noqa: E402
import merge_approval_gate # noqa: E402
import already_landed_reconcile # noqa: E402
import author_mutation_worktree # noqa: E402
@@ -1262,6 +1263,16 @@ def gitea_create_issue(
@mcp.tool()
def _list_open_pulls(h: str, o: str, r: str, auth: str) -> list[dict]:
"""Fetch all OPEN pull requests for a repo (used for stacked-base proof, #484)."""
try:
return api_get_all(f"{repo_api_url(h, o, r)}/pulls?state=open", auth) or []
except Exception as exc: # fail closed: no proof of an open dependency PR
raise RuntimeError(
f"Could not list open pull requests to verify stacked base: {exc}"
)
def gitea_lock_issue(
issue_number: int,
branch_name: str,
@@ -1270,6 +1281,8 @@ def gitea_lock_issue(
org: str | None = None,
repo: str | None = None,
worktree_path: str | None = None,
stacked_base_branch: str | None = None,
stacked_base_pr: int | None = None,
) -> dict:
"""Lock exactly one Gitea issue and its branch name to ensure durable tracking.
@@ -1282,6 +1295,15 @@ def gitea_lock_issue(
repo: Override Repo.
worktree_path: Author scratch-clone path to validate (defaults to
GITEA_AUTHOR_WORKTREE or the MCP server project root).
stacked_base_branch: Opt-in. Declare a non-master base branch for a
*stacked* PR (a PR based on another unmerged PR's branch). Normal work
leaves this ``None`` and stays master-equivalent. When set, the
worktree may be base-equivalent to this branch instead of
master/main/dev, and the approved base is recorded on the lock (#484).
stacked_base_pr: Required when ``stacked_base_branch`` is set. The number
of the OPEN pull request that owns the stacked base branch. The lock
fails closed unless this open PR exists and owns that branch, so
arbitrary or stale branches cannot be used as stacked bases.
"""
# 1. Enforce branch name includes issue number
expected_pattern = f"issue-{issue_number}"
@@ -1309,7 +1331,31 @@ def gitea_lock_issue(
if active_lease_block:
raise RuntimeError(active_lease_block)
git_state = issue_lock_worktree.read_worktree_git_state(resolved_worktree)
# ── Stacked-PR base declaration (opt-in, #484) ──
# Normal work leaves stacked_base_branch None → master-equivalent path.
# A declared stacked base must be proven to own an OPEN PR before it can
# anchor base-equivalence; this never bypasses the lock.
stacked_extra_bases: tuple[str, ...] = ()
stacked_approved: dict | None = None
if stacked_base_branch:
stacked_assessment = stacked_pr_support.assess_stacked_base_declaration(
stacked_base_branch=stacked_base_branch,
stacked_base_pr=stacked_base_pr,
open_prs=_list_open_pulls(h, o, r, _auth(h)),
)
if stacked_assessment["block"]:
raise RuntimeError(
"; ".join(stacked_assessment["reasons"]) + " (fail closed)"
)
stacked_approved = stacked_assessment["approved"]
stacked_extra_bases = (stacked_approved["branch"],)
if stacked_extra_bases:
git_state = issue_lock_worktree.read_worktree_git_state(
resolved_worktree, extra_bases=stacked_extra_bases
)
else:
git_state = issue_lock_worktree.read_worktree_git_state(resolved_worktree)
verify_preflight_purity(remote, worktree_path=resolved_worktree)
lock_assessment = issue_lock_worktree.assess_issue_lock_worktree(
worktree_path=resolved_worktree,
@@ -1382,6 +1428,8 @@ def gitea_lock_issue(
claimant=work_lease.get("claimant"),
),
}
if stacked_approved:
data["approved_stacked_base"] = stacked_approved
lock_file_path = _save_issue_lock(data)
lock_record = issue_lock_store.read_lock_file(lock_file_path) or data
@@ -1415,6 +1463,13 @@ def gitea_lock_issue(
"lock_freshness": freshness,
"lock_proof": lock_proof,
}
if stacked_approved:
result["approved_stacked_base"] = stacked_approved
result["message"] = (
f"Successfully locked issue #{issue_number} to branch '{branch_name}' "
f"as a STACKED PR on base '{stacked_approved['branch']}' "
f"(open PR #{stacked_approved['pr_number']}); fail-closed check complete."
)
if adoption["adopt"]:
result["adoption"] = issue_lock_adoption.build_adoption_proof(
issue_number=issue_number,
@@ -1571,6 +1626,24 @@ def gitea_create_pr(
f"PR title or body must contain 'Closes #{locked_issue}' or 'Fixes #{locked_issue}' exactly to ensure durable tracking (fail closed)"
)
# ── Stacked-PR base validation (#484) ──
# Normal base branches (master/main/dev) pass unchanged. A non-base target is
# allowed only when it matches the lock's approved stacked base, that base still
# has an open PR, and the body documents the stack. This never bypasses the lock.
base_open_prs = (
[]
if stacked_pr_support.is_base_branch(base)
else _list_open_pulls(h, o, r, _auth(h))
)
base_check = stacked_pr_support.assess_create_pr_base(
base=base,
approved_stacked_base=lock_data.get("approved_stacked_base"),
body=body,
open_prs=base_open_prs,
)
if base_check["block"]:
raise ValueError("; ".join(base_check["reasons"]) + " (fail closed)")
duplicate_block = _enforce_locked_issue_duplicate_recheck(
remote,
issue_work_duplicate_gate.PHASE_CREATE_PR,
+26 -5
View File
@@ -30,8 +30,16 @@ def resolve_author_worktree_path(
return os.path.realpath(os.path.abspath(path))
def read_worktree_git_state(worktree_path: str) -> dict:
"""Read branch name and porcelain status from a git worktree."""
def read_worktree_git_state(
worktree_path: str,
extra_bases: tuple[str, ...] | list[str] = (),
) -> dict:
"""Read branch name and porcelain status from a git worktree.
``extra_bases`` names additional branches (e.g. an approved stacked base)
that may anchor base-equivalence in addition to master/main/dev. When empty
(the default), only the normal base branches are considered.
"""
path = (worktree_path or "").strip()
if not path:
return {"current_branch": None, "porcelain_status": ""}
@@ -63,7 +71,7 @@ def read_worktree_git_state(worktree_path: str) -> dict:
check=False,
)
head_sha = (head_res.stdout or "").strip() if head_res.returncode == 0 else None
base_branch, base_sha = _find_matching_base_ref(path, head_sha)
base_branch, base_sha = _find_matching_base_ref(path, head_sha, extra_bases)
return {
"current_branch": current_branch,
"porcelain_status": status_res.stdout or "",
@@ -203,13 +211,26 @@ def _assessment(
}
def _find_matching_base_ref(path: str, head_sha: str | None) -> tuple[str | None, str | None]:
"""Return the stable branch ref whose commit matches HEAD, if any."""
def _find_matching_base_ref(
path: str,
head_sha: str | None,
extra_bases: tuple[str, ...] | list[str] = (),
) -> tuple[str | None, str | None]:
"""Return the stable branch ref whose commit matches HEAD, if any.
Normal base branches (master/main/dev) are always considered. ``extra_bases``
adds explicitly-approved stacked bases; each is checked as a local ref and via
the ``prgs``/``origin`` remotes.
"""
if not head_sha:
return None, None
candidates: list[str] = []
for branch in sorted(BASE_BRANCHES):
candidates.extend((f"origin/{branch}", branch))
for branch in extra_bases:
name = (branch or "").strip()
if name:
candidates.extend((f"prgs/{name}", f"origin/{name}", name))
for ref in candidates:
res = subprocess.run(
["git", "-C", path, "rev-parse", "--verify", ref],
@@ -144,6 +144,14 @@ If the main checkout is dirty before selection, stop and produce a recovery hand
If the main checkout becomes dirty during the run, stop and produce a recovery handoff unless the change is explicitly allowed by the canonical workflow.
### Stacked PRs (explicit exception, #484)
Normal author work stays base-equivalent to `master`/`main`/`dev`. A **stacked PR** — deliberately based on another unmerged PR's branch — is the only sanctioned non-master base, and only when the operator/controller explicitly chooses it:
- Branch the `branches/` worktree from the dependency's branch, then lock with `gitea_lock_issue(..., stacked_base_branch=<dep-branch>, stacked_base_pr=<open-PR#>)`. The lock fails closed unless that open PR owns the branch; arbitrary or stale branches are rejected.
- Open the PR with `gitea_create_pr(base=<dep-branch>)`. The body must state: `Stacked on PR #<X> / issue #<Y>`, `Base branch: <dep-branch>`, `Head branch: <this-branch>`, `Do not merge before PR #<X>`, and note retarget/rebase to `master` after the dependency lands if required.
- This does not relax the main-checkout rule or bypass the issue lock — work still happens under `branches/`, and the approved base is recorded on the lock.
## 5. No raw MCP repair during normal issue work
Do not run `pkill`, kill MCP processes, edit MCP config, restart servers, or perform control-checkout repair during normal issue work.
+211
View File
@@ -0,0 +1,211 @@
"""Stacked-PR support for author issue locks and PR creation (#484).
Normal author work locks a worktree that is base-equivalent to ``master``/
``main``/``dev`` and opens a PR against one of those base branches. A *stacked*
PR is deliberately based on another unmerged PR's branch, so its worktree is not
master-equivalent and its PR base is not a normal base branch.
This module holds the pure decision logic that lets:
* ``gitea_lock_issue`` approve a non-master base **only** when it is explicitly
declared and proven to correspond to an open pull request, and
* ``gitea_create_pr`` accept that approved base while still rejecting arbitrary,
mismatched, or stale (merged/closed) branches.
The normal master-based path is unchanged: when no stacked base is declared, and
when the PR base is a normal base branch, these helpers are inert. Nothing here
bypasses the issue lock — a stacked base is recorded *on* the lock and re-checked
at PR time.
"""
from __future__ import annotations
BASE_BRANCHES = frozenset({"master", "main", "dev"})
# Phrases that satisfy the required merge-ordering statement in a stacked PR body.
MERGE_ORDER_PHRASES = ("do not merge before", "do not merge until")
def is_base_branch(base: str | None, base_branches: frozenset[str] | None = None) -> bool:
"""True when ``base`` is a normal base branch (master/main/dev)."""
bases = base_branches or BASE_BRANCHES
return (base or "").strip() in bases
def _pr_head_ref(pr: dict) -> str:
head = pr.get("head") or {}
if isinstance(head, dict):
return (head.get("ref") or "").strip()
return (str(head) if head else "").strip()
def find_open_pr_for_branch(open_prs: list[dict] | None, branch: str | None) -> dict | None:
"""Return the first OPEN PR whose head ref equals ``branch`` (else ``None``)."""
branch = (branch or "").strip()
if not branch:
return None
for pr in open_prs or []:
if (pr.get("state") or "").strip().lower() != "open":
continue
if _pr_head_ref(pr) == branch:
return pr
return None
def assess_stacked_base_declaration(
*,
stacked_base_branch: str | None,
stacked_base_pr: int | None,
open_prs: list[dict] | None,
) -> dict:
"""Validate an explicit stacked-base declaration at lock time.
Returns a dict with ``block`` (fail closed), ``reasons``, ``declared``
(whether a stacked base was requested), and ``approved`` (the metadata to
persist on the lock when valid, else ``None``).
"""
branch = (stacked_base_branch or "").strip()
if not branch:
# No stacked base requested — normal master-based lock path.
return {"block": False, "reasons": [], "approved": None, "declared": False}
if branch in BASE_BRANCHES:
return {
"block": True,
"declared": True,
"approved": None,
"reasons": [
f"stacked base '{branch}' is already a normal base branch; do not "
"declare a base branch as a stacked base"
],
}
if stacked_base_pr is None:
return {
"block": True,
"declared": True,
"approved": None,
"reasons": [
"stacked base branch declared without stacked_base_pr; a stacked PR "
"must cite the open PR that owns the base branch"
],
}
pr = find_open_pr_for_branch(open_prs, branch)
if pr is None:
return {
"block": True,
"declared": True,
"approved": None,
"reasons": [
f"stacked base branch '{branch}' does not correspond to any OPEN pull "
"request; arbitrary or stale branches are not allowed as stacked bases"
],
}
if int(pr.get("number")) != int(stacked_base_pr):
return {
"block": True,
"declared": True,
"approved": None,
"reasons": [
f"declared stacked_base_pr #{stacked_base_pr} does not match the open "
f"PR #{pr.get('number')} that owns base branch '{branch}'"
],
}
return {
"block": False,
"declared": True,
"reasons": [],
"approved": {
"branch": branch,
"pr_number": int(pr.get("number")),
"verified_open": True,
},
}
def assess_stacked_pr_body(
body: str | None, *, base_branch: str | None, pr_number: int | None
) -> list[str]:
"""Return the list of missing stacked-PR documentation fields (empty = ok)."""
text = body or ""
low = text.lower()
missing: list[str] = []
if base_branch and base_branch not in text:
missing.append(f"base branch '{base_branch}'")
if pr_number is not None and f"#{pr_number}" not in text:
missing.append(f"stacked-on PR reference '#{pr_number}'")
if not any(phrase in low for phrase in MERGE_ORDER_PHRASES):
missing.append("merge-ordering statement (e.g. 'Do not merge before PR #<n>')")
return missing
def assess_create_pr_base(
*,
base: str | None,
approved_stacked_base: dict | None,
body: str | None,
open_prs: list[dict] | None,
base_branches: frozenset[str] | None = None,
) -> dict:
"""Validate the PR base at create time.
Normal base branches pass through unchanged (``stacked`` False). A non-base
branch is allowed only when it matches the lock's approved stacked base, that
base still has an open PR, and the body documents the stack.
"""
bases = base_branches or BASE_BRANCHES
base = (base or "").strip()
if base in bases:
return {"block": False, "reasons": [], "stacked": False}
approved = approved_stacked_base or {}
approved_branch = (approved.get("branch") or "").strip()
if not approved_branch:
return {
"block": True,
"stacked": True,
"reasons": [
f"PR base '{base}' is not one of {'/'.join(sorted(bases))} and the "
"issue lock has no approved stacked base; re-lock with an explicit, "
"proof-backed stacked base to open a stacked PR"
],
}
if base != approved_branch:
return {
"block": True,
"stacked": True,
"reasons": [
f"PR base '{base}' does not match the issue lock's approved stacked "
f"base '{approved_branch}'"
],
}
pr = find_open_pr_for_branch(open_prs, base)
if pr is None:
return {
"block": True,
"stacked": True,
"reasons": [
f"approved stacked base '{base}' no longer corresponds to an OPEN pull "
"request (dependency merged, closed, or stale); retarget/rebase onto "
"master or re-lock against a live base"
],
}
pr_number = approved.get("pr_number") or pr.get("number")
missing = assess_stacked_pr_body(body, base_branch=base, pr_number=pr_number)
if missing:
return {
"block": True,
"stacked": True,
"reasons": [
"stacked PR body must document the stack; missing: "
+ ", ".join(missing)
],
}
return {"block": False, "reasons": [], "stacked": True, "stacked_base_pr": pr_number}
+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()