"""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 #')") 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}