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:
+74
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user