fix: resolve conflicts for PR #490
Merge prgs/master into feat/issue-470-preflight-contract, keeping stacked-PR lock base support (#484) and explicit task binding on lock_issue preflight (#470). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
+101
-5
@@ -464,8 +464,13 @@ def record_preflight_check(
|
||||
|
||||
|
||||
def _enforce_branches_only_author_mutation(worktree_path: str | None = None) -> None:
|
||||
"""#274: author mutations must run from a branches/ session worktree."""
|
||||
if _preflight_resolved_role == "reviewer":
|
||||
"""#274: author file/branch mutations must run from a branches/ worktree.
|
||||
|
||||
Reviewer and reconciler roles are exempt: reconciler ``close_pr`` is a
|
||||
Gitea metadata mutation and must not require ``GITEA_AUTHOR_WORKTREE``
|
||||
(#468).
|
||||
"""
|
||||
if _preflight_resolved_role in ("reviewer", "reconciler"):
|
||||
return
|
||||
ctx = _resolve_author_mutation_context(worktree_path)
|
||||
workspace = ctx["workspace_path"]
|
||||
@@ -597,6 +602,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
|
||||
@@ -1325,6 +1331,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,
|
||||
@@ -1333,6 +1349,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.
|
||||
|
||||
@@ -1345,6 +1363,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}"
|
||||
@@ -1372,7 +1399,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, task="lock_issue")
|
||||
lock_assessment = issue_lock_worktree.assess_issue_lock_worktree(
|
||||
worktree_path=resolved_worktree,
|
||||
@@ -1445,6 +1496,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
|
||||
@@ -1478,6 +1531,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,
|
||||
@@ -1492,6 +1552,14 @@ def gitea_lock_issue(
|
||||
f"Adopted existing branch '{branch_name}' and locked issue "
|
||||
f"#{issue_number} for recovery (fail-closed check complete)."
|
||||
)
|
||||
else:
|
||||
# #477 AC2: normal (no-adoption) lock responses carry explicit,
|
||||
# adoption-free proof metadata so they stay clear and cannot be
|
||||
# misread as claiming a branch was adopted.
|
||||
result["adoption_check"] = issue_lock_adoption.build_non_adoption_lock_proof(
|
||||
issue_number=issue_number,
|
||||
branch_name=branch_name,
|
||||
)
|
||||
if agent_artifacts:
|
||||
result["warnings"] = [
|
||||
"Agent temp artifacts at repo root (delete before implementation): "
|
||||
@@ -1634,6 +1702,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,
|
||||
@@ -2578,7 +2664,12 @@ def _list_pr_lease_comments(
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
api = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
|
||||
comments = api_request("GET", api, auth) or []
|
||||
comments = api_request("GET", api, auth)
|
||||
# Fail safe to no lease comments when the API returns a non-list payload
|
||||
# (e.g. an error object such as an HTTP 401 body): lease state can only be
|
||||
# proven from real comment entries, never inferred from an error shape (#485).
|
||||
if not isinstance(comments, list):
|
||||
return []
|
||||
return list(comments[:limit])
|
||||
|
||||
|
||||
@@ -5182,7 +5273,12 @@ def gitea_list_issue_comments(
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
api = f"{repo_api_url(h, o, r)}/issues/{issue_number}/comments"
|
||||
comments = api_request("GET", api, auth) or []
|
||||
comments = api_request("GET", api, auth)
|
||||
# Fail safe to no comments when the API returns a non-list payload (e.g. an
|
||||
# error object such as an HTTP 401 body) so listing never crashes on a
|
||||
# malformed response (#485).
|
||||
if not isinstance(comments, list):
|
||||
comments = []
|
||||
reveal = _reveal_endpoints()
|
||||
out = []
|
||||
for c in comments[:limit]:
|
||||
|
||||
Reference in New Issue
Block a user