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]>
212 lines
7.3 KiB
Python
212 lines
7.3 KiB
Python
"""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}
|