From 0cbd1fc801e7490a8faa9b21b89cf51d269c9830 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Wed, 8 Jul 2026 02:22:54 -0400 Subject: [PATCH] feat: add first-class stacked PR support to author workflow (#484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/llm-workflow-runbooks.md | 22 ++ gitea_mcp_server.py | 75 ++++++- issue_lock_worktree.py | 31 ++- .../workflows/work-issue.md | 8 + stacked_pr_support.py | 211 ++++++++++++++++++ tests/test_issue_lock_store.py | 24 ++ tests/test_issue_lock_worktree.py | 53 +++++ tests/test_stacked_pr_support.py | 185 +++++++++++++++ 8 files changed, 603 insertions(+), 6 deletions(-) create mode 100644 stacked_pr_support.py create mode 100644 tests/test_stacked_pr_support.py diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md index 634e22c..e0ff616 100644 --- a/docs/llm-workflow-runbooks.md +++ b/docs/llm-workflow-runbooks.md @@ -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 = ` only when it matches the recorded approval, the +dependency PR is **still open**, and the PR body documents the stack: + +- `Stacked on PR # / issue #` +- `Base branch: ` +- `Head branch: ` +- `Do not merge before PR #` (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 diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 9ea9050..b5681e9 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -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, diff --git a/issue_lock_worktree.py b/issue_lock_worktree.py index 5d9dc4a..3c23134 100644 --- a/issue_lock_worktree.py +++ b/issue_lock_worktree.py @@ -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], diff --git a/skills/llm-project-workflow/workflows/work-issue.md b/skills/llm-project-workflow/workflows/work-issue.md index ceef51c..6766523 100644 --- a/skills/llm-project-workflow/workflows/work-issue.md +++ b/skills/llm-project-workflow/workflows/work-issue.md @@ -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=, stacked_base_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=)`. The body must state: `Stacked on PR # / issue #`, `Base branch: `, `Head branch: `, `Do not merge before PR #`, 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. diff --git a/stacked_pr_support.py b/stacked_pr_support.py new file mode 100644 index 0000000..e3cab47 --- /dev/null +++ b/stacked_pr_support.py @@ -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 #')") + 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} diff --git a/tests/test_issue_lock_store.py b/tests/test_issue_lock_store.py index d87fc73..3928b19 100644 --- a/tests/test_issue_lock_store.py +++ b/tests/test_issue_lock_store.py @@ -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", diff --git a/tests/test_issue_lock_worktree.py b/tests/test_issue_lock_worktree.py index 02347f4..67baa78 100644 --- a/tests/test_issue_lock_worktree.py +++ b/tests/test_issue_lock_worktree.py @@ -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( diff --git a/tests/test_stacked_pr_support.py b/tests/test_stacked_pr_support.py new file mode 100644 index 0000000..e592fc1 --- /dev/null +++ b/tests/test_stacked_pr_support.py @@ -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()