Files
Gitea-Tools/issue_lock_worktree.py
sysadminandClaude Opus 4.8 0cbd1fc801 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]>
2026-07-08 02:22:54 -04:00

245 lines
7.8 KiB
Python

"""Issue-lock worktree validation (#249).
Author issue locks must validate the caller's own scratch clone (or declared
worktree path), not the shared MCP server working directory. A clean scratch at
``master``/``main`` must remain lockable while an unrelated session leaves the
shared dev worktree dirty or on a feature branch.
"""
from __future__ import annotations
import os
import subprocess
from reviewer_worktree import parse_dirty_tracked_files
AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
BASE_BRANCHES = frozenset({"master", "main", "dev"})
def resolve_author_worktree_path(
explicit: str | None,
project_root: str,
) -> str:
"""Resolve the author worktree path for lock/PR gates."""
path = (explicit or "").strip()
if not path:
path = (os.environ.get(AUTHOR_WORKTREE_ENV) or "").strip()
if not path:
path = project_root
return os.path.realpath(os.path.abspath(path))
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": ""}
branch_res = subprocess.run(
["git", "-C", path, "branch", "--show-current"],
capture_output=True,
text=True,
check=False,
)
current_branch = (branch_res.stdout or "").strip() or None
status_res = subprocess.run(
["git", "-C", path, "status", "--porcelain"],
capture_output=True,
text=True,
check=False,
)
root_res = subprocess.run(
["git", "-C", path, "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=False,
)
head_res = subprocess.run(
["git", "-C", path, "rev-parse", "HEAD"],
capture_output=True,
text=True,
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, extra_bases)
return {
"current_branch": current_branch,
"porcelain_status": status_res.stdout or "",
"inspected_git_root": (root_res.stdout or "").strip() if root_res.returncode == 0 else None,
"head_sha": head_sha,
"base_branch": base_branch,
"base_sha": base_sha,
"base_equivalent": bool(head_sha and base_sha and head_sha == base_sha),
}
def assess_issue_lock_worktree(
*,
worktree_path: str,
current_branch: str | None,
porcelain_status: str,
base_equivalent: bool | None = None,
inspected_git_root: str | None = None,
base_branch: str | None = None,
base_branches: frozenset[str] | None = None,
) -> dict:
"""Fail closed when lock preconditions are not met on the declared worktree."""
bases = base_branches or BASE_BRANCHES
reasons: list[str] = []
path = (worktree_path or "").strip()
if not path:
reasons.append("worktree path not declared for issue lock; fail closed")
return _assessment(False, reasons, path, None, [])
branch = (current_branch or "").strip()
dirty_files = parse_dirty_tracked_files(porcelain_status)
if dirty_files:
reasons.append(
"tracked file edits exist before issue lock; "
f"lock must precede implementation work in '{path}' "
f"(dirty files: {', '.join(dirty_files)})"
)
if base_equivalent is False:
reasons.append(
"issue lock worktree must be base-equivalent to one of "
f"{_base_list(bases)} before implementation work; inspected "
f"branch '{branch or '(detached)'}' at '{path}'"
)
elif base_equivalent is None:
if not branch:
reasons.append(
"current branch unknown (detached HEAD?); issue lock base-equivalence "
f"to {_base_list(bases)} could not be proven"
)
elif branch not in bases:
reasons.append(
"issue lock worktree base-equivalence could not be proven; "
f"branch '{branch}' is not {_base_list(bases)}"
)
proven = not reasons
return _assessment(
proven,
reasons,
path,
branch or None,
dirty_files,
inspected_git_root=inspected_git_root,
base_branch=base_branch,
base_equivalent=base_equivalent,
)
def format_issue_lock_worktree_error(assessment: dict) -> str:
"""Format a single fail-closed error for ``gitea_lock_issue``."""
reasons = list(assessment.get("reasons") or [])
if not reasons:
reasons = ["issue lock worktree validation failed"]
return "; ".join(reasons) + " (fail closed)"
def verify_pr_worktree_matches_lock(
locked_worktree_path: str | None,
declared_worktree_path: str | None,
project_root: str,
) -> dict:
"""PR creation must use the same worktree the lock was validated against."""
locked = (locked_worktree_path or "").strip()
if not locked:
return {"proven": True, "block": False, "reasons": []}
declared = resolve_author_worktree_path(declared_worktree_path, project_root)
locked_real = os.path.realpath(locked)
declared_real = os.path.realpath(declared)
if locked_real != declared_real:
return {
"proven": False,
"block": True,
"reasons": [
f"PR worktree '{declared_real}' does not match locked worktree "
f"'{locked_real}' (fail closed)"
],
"locked_worktree_path": locked_real,
"declared_worktree_path": declared_real,
}
return {
"proven": True,
"block": False,
"reasons": [],
"locked_worktree_path": locked_real,
"declared_worktree_path": declared_real,
}
def _base_list(bases: frozenset[str]) -> str:
return "/".join(sorted(bases))
def _assessment(
proven: bool,
reasons: list[str],
worktree_path: str,
current_branch: str | None,
dirty_files: list[str],
*,
inspected_git_root: str | None = None,
base_branch: str | None = None,
base_equivalent: bool | None = None,
) -> dict:
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"worktree_path": worktree_path or None,
"inspected_git_root": inspected_git_root,
"current_branch": current_branch,
"dirty_files": dirty_files,
"base_branch": base_branch,
"base_equivalent": base_equivalent,
}
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],
capture_output=True,
text=True,
check=False,
)
sha = (res.stdout or "").strip()
if res.returncode == 0 and sha == head_sha:
return ref, sha
return None, None