fix: resolve conflicts for PR #493

Merge prgs/master; keep review workflow load gate (#389) with explicit
merge_pr preflight task binding.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-08 04:09:31 -04:00
co-authored by Claude Opus 4.8
20 changed files with 1689 additions and 40 deletions
+193 -35
View File
@@ -165,6 +165,7 @@ _preflight_capability_called = False
_preflight_whoami_violation = False
_preflight_capability_violation = False
_preflight_resolved_role = None
_preflight_resolved_task: str | None = None
_process_start_porcelain: str | None = None
_preflight_whoami_baseline_porcelain: str | None = None
_preflight_capability_baseline_porcelain: str | None = None
@@ -379,11 +380,30 @@ def assess_preflight_status(worktree_path: str | None = None) -> dict:
}
def record_preflight_check(type_name: str, resolved_role: str | None = None):
def _clear_preflight_capability_state() -> None:
"""Drop resolved capability proof (consumed by a mutation or fresh resolve)."""
global _preflight_capability_called, _preflight_capability_violation
global _preflight_resolved_task
global _preflight_capability_baseline_porcelain, _preflight_capability_violation_files
global _preflight_reviewer_violation_files
_preflight_capability_called = False
_preflight_capability_violation = False
_preflight_capability_violation_files = []
_preflight_capability_baseline_porcelain = None
_preflight_resolved_task = None
_preflight_reviewer_violation_files = []
def record_preflight_check(
type_name: str,
resolved_role: str | None = None,
resolved_task: str | None = None,
):
"""Record a pre-flight check (whoami or capability) with session-scoped deltas."""
global _preflight_whoami_called, _preflight_capability_called
global _preflight_whoami_violation, _preflight_capability_violation
global _preflight_resolved_role
global _preflight_resolved_role, _preflight_resolved_task
global _preflight_whoami_baseline_porcelain, _preflight_capability_baseline_porcelain
global _preflight_whoami_violation_files, _preflight_capability_violation_files
global _preflight_reviewer_violation_files
@@ -391,13 +411,24 @@ def record_preflight_check(type_name: str, resolved_role: str | None = None):
current = _get_workspace_porcelain()
if type_name == "whoami":
# Fresh whoami restarts the capability step and re-evaluates violations
# instead of replaying a sticky record (#252).
_preflight_capability_called = False
_preflight_capability_violation = False
_preflight_capability_violation_files = []
_preflight_capability_baseline_porcelain = None
_preflight_reviewer_violation_files = []
# Re-evaluate whoami violations instead of replaying sticky state (#252).
# Interleaved read-only whoami must not clear a valid capability proof (#469).
saved_capability = None
preserve_capability = (
_preflight_capability_called
and _preflight_whoami_called
and not _preflight_whoami_violation
)
if preserve_capability:
saved_capability = (
_preflight_capability_violation,
list(_preflight_capability_violation_files),
_preflight_capability_baseline_porcelain,
_preflight_resolved_role,
_preflight_resolved_task,
)
else:
_clear_preflight_capability_state()
process_start = _ensure_process_start_porcelain()
whoami_delta = _new_tracked_changes_since(process_start, current)
@@ -405,6 +436,20 @@ def record_preflight_check(type_name: str, resolved_role: str | None = None):
_preflight_whoami_violation_files = whoami_delta
_preflight_whoami_baseline_porcelain = current
_preflight_whoami_called = True
if (
preserve_capability
and saved_capability is not None
and not whoami_delta
):
(
_preflight_capability_violation,
_preflight_capability_violation_files,
_preflight_capability_baseline_porcelain,
_preflight_resolved_role,
_preflight_resolved_task,
) = saved_capability
_preflight_capability_called = True
elif type_name == "capability":
baseline = _preflight_whoami_baseline_porcelain or ""
capability_delta = _new_tracked_changes_since(baseline, current)
@@ -414,11 +459,18 @@ def record_preflight_check(type_name: str, resolved_role: str | None = None):
_preflight_capability_called = True
if resolved_role:
_preflight_resolved_role = resolved_role
if resolved_task:
_preflight_resolved_task = resolved_task
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"]
@@ -434,7 +486,11 @@ def _enforce_branches_only_author_mutation(worktree_path: str | None = None) ->
)
def verify_preflight_purity(remote: str | None = None, worktree_path: str | None = None):
def verify_preflight_purity(
remote: str | None = None,
worktree_path: str | None = None,
task: str | None = None,
):
"""Verify that identity and capability were verified prior to session edits."""
global _preflight_reviewer_violation_files
@@ -453,6 +509,16 @@ def verify_preflight_purity(remote: str | None = None, worktree_path: str | None
raise RuntimeError(
"Pre-flight order violation: Task capability (gitea_resolve_task_capability) has not been resolved (fail closed)"
)
if (
task is not None
and _preflight_resolved_task is not None
and task != _preflight_resolved_task
):
raise RuntimeError(
"Pre-flight task mismatch: "
f"resolved '{_preflight_resolved_task}' but mutation requires "
f"'{task}' (fail closed)"
)
ctx = _resolve_author_mutation_context(worktree_path)
workspace = ctx["workspace_path"]
@@ -508,6 +574,7 @@ def verify_preflight_purity(remote: str | None = None, worktree_path: str | None
)
_enforce_branches_only_author_mutation(worktree_path)
_clear_preflight_capability_state()
from mcp.server.fastmcp import FastMCP # noqa: E402
@@ -536,6 +603,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
@@ -1223,7 +1291,7 @@ def gitea_create_issue(
)
if blocked:
return blocked
verify_preflight_purity(remote, worktree_path=worktree_path)
verify_preflight_purity(remote, worktree_path=worktree_path, task="create_issue")
base = repo_api_url(h, o, r)
open_issues = api_get_all(f"{base}/issues?state=open&type=issues", auth)
closed_issues = api_get_all(
@@ -1263,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,
@@ -1271,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.
@@ -1283,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}"
@@ -1310,8 +1399,32 @@ def gitea_lock_issue(
if active_lease_block:
raise RuntimeError(active_lease_block)
git_state = issue_lock_worktree.read_worktree_git_state(resolved_worktree)
verify_preflight_purity(remote, worktree_path=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,
current_branch=git_state.get("current_branch"),
@@ -1383,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
@@ -1416,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,
@@ -1430,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): "
@@ -1518,7 +1648,7 @@ def gitea_create_pr(
)
if blocked:
return blocked
verify_preflight_purity(remote, worktree_path=worktree_path)
verify_preflight_purity(remote, worktree_path=worktree_path, task="create_pr")
h, o, r = _resolve(remote, host, org, repo)
# ── Issue Lock Validation (Issue #194 / #196 / #443) ──
@@ -1572,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,
@@ -2525,7 +2673,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])
@@ -2570,7 +2723,7 @@ def _evaluate_pr_review_submission(
final_review_decision_ready: bool = False,
) -> dict:
"""Shared gate chain for live submit and dry-run review tools."""
verify_preflight_purity(remote)
verify_preflight_purity(remote, task="review_pr")
action = (action or "").strip().lower()
workflow_blockers = _review_workflow_load_gate_reasons() if live else []
result = {
@@ -3089,12 +3242,12 @@ def gitea_edit_pr(
if not payload:
raise ValueError("At least one field to edit (title, body, state, base) must be provided.")
verify_preflight_purity(remote)
closing = payload.get("state") == "closed"
verify_preflight_purity(remote, task="close_pr" if closing else None)
# PR closure is a first-class capability, distinct from retitling or
# rebasing edits (#216). Gate BEFORE auth/API setup so a blocked close
# never touches the network.
closing = payload.get("state") == "closed"
if closing:
gate_reasons = _profile_operation_gate("gitea.pr.close")
if gate_reasons:
@@ -3339,7 +3492,7 @@ def gitea_commit_files(
branch="",
)
verify_preflight_purity(remote)
verify_preflight_purity(remote, task="commit_files")
processed_files, source_proofs = _prepare_commit_payload_files(files)
h, o, r = _resolve(remote, host, org, repo)
@@ -3440,7 +3593,7 @@ def gitea_merge_pr(
reasons/gates passed or blocked, and merge result / merge commit if
available. Never secrets.
"""
verify_preflight_purity(remote)
verify_preflight_purity(remote, task="merge_pr")
workflow_blockers = _review_workflow_load_gate_reasons()
do = (do or "").strip().lower()
result = {
@@ -3974,7 +4127,7 @@ def gitea_delete_branch(
"permission_report": _permission_block_report("gitea.branch.delete"),
}
verify_preflight_purity(remote)
verify_preflight_purity(remote, task="delete_branch")
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
import urllib.parse
@@ -4083,7 +4236,7 @@ def gitea_reconcile_merged_cleanups(
report["executed"] = False
return {"success": True, "performed": False, **report}
verify_preflight_purity(remote)
verify_preflight_purity(remote, task="reconcile_merged_cleanups")
actions: list[dict] = []
for entry in report.get("entries") or []:
head_branch = entry.get("head_branch") or ""
@@ -4397,7 +4550,7 @@ def gitea_reconcile_already_landed_pr(
)
return result
verify_preflight_purity(remote)
verify_preflight_purity(remote, task="reconcile_already_landed_pr")
if post_comment and comment_body.strip():
comment_block = _profile_operation_gate("gitea.pr.comment")
@@ -4500,7 +4653,7 @@ def gitea_close_issue(
task_capability_map.required_permission("close_issue"))
if blocked:
return blocked
verify_preflight_purity(remote)
verify_preflight_purity(remote, task="close_issue")
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/issues/{issue_number}"
@@ -4928,7 +5081,7 @@ def gitea_acquire_reviewer_pr_lease(
"permission_report": _permission_block_report("gitea.pr.comment"),
}
verify_preflight_purity(remote)
verify_preflight_purity(remote, task="review_pr")
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
profile = get_profile()
@@ -5029,7 +5182,7 @@ def gitea_heartbeat_reviewer_pr_lease(
],
}
verify_preflight_purity(remote)
verify_preflight_purity(remote, task="review_pr")
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
body = reviewer_pr_lease.format_lease_body(
@@ -5146,7 +5299,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]:
@@ -5200,7 +5358,7 @@ def gitea_create_issue_comment(
(permission blocks also carry a structured 'permission_report',
#142).
"""
verify_preflight_purity(remote)
verify_preflight_purity(remote, task="comment_issue")
gate_reasons = _profile_operation_gate("gitea.issue.comment")
reasons = list(gate_reasons)
if not (body or "").strip():
@@ -6774,7 +6932,7 @@ def gitea_mark_issue(
task_capability_map.required_permission("mark_issue"))
if blocked:
return blocked
verify_preflight_purity(remote, worktree_path=worktree_path)
verify_preflight_purity(remote, worktree_path=worktree_path, task="mark_issue")
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
@@ -6852,7 +7010,7 @@ def gitea_post_heartbeat(
task_capability_map.required_permission("post_heartbeat"))
if blocked:
return blocked
verify_preflight_purity(remote)
verify_preflight_purity(remote, task="post_heartbeat")
active_profile = profile or get_profile().get("profile_name")
body = issue_claim_heartbeat.format_heartbeat_body(
kind="progress",
@@ -7095,7 +7253,7 @@ def gitea_cleanup_stale_claims(
task_capability_map.required_permission("cleanup_stale_claims"))
if blocked:
return blocked
verify_preflight_purity(remote)
verify_preflight_purity(remote, task="cleanup_stale_claims")
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
@@ -7249,7 +7407,7 @@ def gitea_set_issue_labels(
task_capability_map.required_permission("set_issue_labels"))
if blocked:
return blocked
verify_preflight_purity(remote)
verify_preflight_purity(remote, task="set_issue_labels")
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
@@ -7486,7 +7644,7 @@ def gitea_resolve_task_capability(
"exact_safe_next_action": next_safe_action,
}
record_preflight_check("capability", required_role)
record_preflight_check("capability", required_role, resolved_task=task)
# Try automatic dispatch switching
_ensure_matching_profile(required_permission, required_role, remote, host)