Merge pull request 'feat: add first-class stacked PR support to author workflow (Closes #484)' (#489) from feat/issue-484-stacked-pr-support into master
This commit was merged in pull request #489.
This commit is contained in:
@@ -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
|
work. An expired lease still blocks takeover until a recovery review records why
|
||||||
the prior work is abandoned, completed, or unsafe to continue.
|
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
|
**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
|
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
|
leases (#438). After an MCP restart, call `gitea_lock_issue` again — own-branch
|
||||||
|
|||||||
+74
-1
@@ -535,6 +535,7 @@ import issue_lock_worktree # noqa: E402
|
|||||||
import issue_lock_provenance # noqa: E402
|
import issue_lock_provenance # noqa: E402
|
||||||
import issue_lock_store # noqa: E402
|
import issue_lock_store # noqa: E402
|
||||||
import issue_lock_adoption # noqa: E402
|
import issue_lock_adoption # noqa: E402
|
||||||
|
import stacked_pr_support # noqa: E402
|
||||||
import merge_approval_gate # noqa: E402
|
import merge_approval_gate # noqa: E402
|
||||||
import already_landed_reconcile # noqa: E402
|
import already_landed_reconcile # noqa: E402
|
||||||
import author_mutation_worktree # noqa: E402
|
import author_mutation_worktree # noqa: E402
|
||||||
@@ -1262,6 +1263,16 @@ def gitea_create_issue(
|
|||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@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(
|
def gitea_lock_issue(
|
||||||
issue_number: int,
|
issue_number: int,
|
||||||
branch_name: str,
|
branch_name: str,
|
||||||
@@ -1270,6 +1281,8 @@ def gitea_lock_issue(
|
|||||||
org: str | None = None,
|
org: str | None = None,
|
||||||
repo: str | None = None,
|
repo: str | None = None,
|
||||||
worktree_path: str | None = None,
|
worktree_path: str | None = None,
|
||||||
|
stacked_base_branch: str | None = None,
|
||||||
|
stacked_base_pr: int | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Lock exactly one Gitea issue and its branch name to ensure durable tracking.
|
"""Lock exactly one Gitea issue and its branch name to ensure durable tracking.
|
||||||
|
|
||||||
@@ -1282,6 +1295,15 @@ def gitea_lock_issue(
|
|||||||
repo: Override Repo.
|
repo: Override Repo.
|
||||||
worktree_path: Author scratch-clone path to validate (defaults to
|
worktree_path: Author scratch-clone path to validate (defaults to
|
||||||
GITEA_AUTHOR_WORKTREE or the MCP server project root).
|
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
|
# 1. Enforce branch name includes issue number
|
||||||
expected_pattern = f"issue-{issue_number}"
|
expected_pattern = f"issue-{issue_number}"
|
||||||
@@ -1309,7 +1331,31 @@ def gitea_lock_issue(
|
|||||||
if active_lease_block:
|
if active_lease_block:
|
||||||
raise RuntimeError(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)
|
verify_preflight_purity(remote, worktree_path=resolved_worktree)
|
||||||
lock_assessment = issue_lock_worktree.assess_issue_lock_worktree(
|
lock_assessment = issue_lock_worktree.assess_issue_lock_worktree(
|
||||||
worktree_path=resolved_worktree,
|
worktree_path=resolved_worktree,
|
||||||
@@ -1382,6 +1428,8 @@ def gitea_lock_issue(
|
|||||||
claimant=work_lease.get("claimant"),
|
claimant=work_lease.get("claimant"),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
if stacked_approved:
|
||||||
|
data["approved_stacked_base"] = stacked_approved
|
||||||
|
|
||||||
lock_file_path = _save_issue_lock(data)
|
lock_file_path = _save_issue_lock(data)
|
||||||
lock_record = issue_lock_store.read_lock_file(lock_file_path) or 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_freshness": freshness,
|
||||||
"lock_proof": lock_proof,
|
"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"]:
|
if adoption["adopt"]:
|
||||||
result["adoption"] = issue_lock_adoption.build_adoption_proof(
|
result["adoption"] = issue_lock_adoption.build_adoption_proof(
|
||||||
issue_number=issue_number,
|
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)"
|
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(
|
duplicate_block = _enforce_locked_issue_duplicate_recheck(
|
||||||
remote,
|
remote,
|
||||||
issue_work_duplicate_gate.PHASE_CREATE_PR,
|
issue_work_duplicate_gate.PHASE_CREATE_PR,
|
||||||
|
|||||||
+26
-5
@@ -30,8 +30,16 @@ def resolve_author_worktree_path(
|
|||||||
return os.path.realpath(os.path.abspath(path))
|
return os.path.realpath(os.path.abspath(path))
|
||||||
|
|
||||||
|
|
||||||
def read_worktree_git_state(worktree_path: str) -> dict:
|
def read_worktree_git_state(
|
||||||
"""Read branch name and porcelain status from a git worktree."""
|
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()
|
path = (worktree_path or "").strip()
|
||||||
if not path:
|
if not path:
|
||||||
return {"current_branch": None, "porcelain_status": ""}
|
return {"current_branch": None, "porcelain_status": ""}
|
||||||
@@ -63,7 +71,7 @@ def read_worktree_git_state(worktree_path: str) -> dict:
|
|||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
head_sha = (head_res.stdout or "").strip() if head_res.returncode == 0 else None
|
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 {
|
return {
|
||||||
"current_branch": current_branch,
|
"current_branch": current_branch,
|
||||||
"porcelain_status": status_res.stdout or "",
|
"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]:
|
def _find_matching_base_ref(
|
||||||
"""Return the stable branch ref whose commit matches HEAD, if any."""
|
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:
|
if not head_sha:
|
||||||
return None, None
|
return None, None
|
||||||
candidates: list[str] = []
|
candidates: list[str] = []
|
||||||
for branch in sorted(BASE_BRANCHES):
|
for branch in sorted(BASE_BRANCHES):
|
||||||
candidates.extend((f"origin/{branch}", branch))
|
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:
|
for ref in candidates:
|
||||||
res = subprocess.run(
|
res = subprocess.run(
|
||||||
["git", "-C", path, "rev-parse", "--verify", ref],
|
["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.
|
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
|
## 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.
|
Do not run `pkill`, kill MCP processes, edit MCP config, restart servers, or perform control-checkout repair during normal issue work.
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -157,6 +157,30 @@ class TestIssueLockStore(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertFalse(ils.has_active_issue_lock("feat/issue-999-other"))
|
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):
|
def test_atomic_write_preserves_unrelated_lock(self):
|
||||||
path_a = ils.lock_file_path(
|
path_a = ils.lock_file_path(
|
||||||
remote="prgs",
|
remote="prgs",
|
||||||
|
|||||||
@@ -72,6 +72,59 @@ class TestIssueLockWorktreeAssessment(unittest.TestCase):
|
|||||||
self.assertTrue(result["proven"])
|
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):
|
class TestIssueLockWorktreeResolution(unittest.TestCase):
|
||||||
def test_explicit_path_wins(self):
|
def test_explicit_path_wins(self):
|
||||||
resolved = issue_lock_worktree.resolve_author_worktree_path(
|
resolved = issue_lock_worktree.resolve_author_worktree_path(
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user