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
+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,