fix(mcp): forward explicit org/repo through author mutation preflight (Closes #735)
Session-bound author mutations (create_issue, lock_issue, create_pr, etc.) called verify_preflight_purity without org/repo, so anti-stomp filled the remote-wide REMOTES default (prgs → Timesheet) and false-positived wrong_repo against a Gitea-Tools checkout even when callers passed explicit coordinates. Mirror _resolve: prefer workspace-derived identity for omitted coordinates on session-bound tasks; keep delete_branch's explicit-target contract (#733). Also forward org/repo at author mutation call sites. Add cross-repo regression coverage. Do not change REMOTES["prgs"]["repo"].
This commit is contained in:
+124
-26
@@ -767,6 +767,15 @@ def _anti_stomp_in_test_mode() -> bool:
|
||||
return not bool(os.environ.get("GITEA_TEST_FORCE_ANTI_STOMP"))
|
||||
|
||||
|
||||
# Mutation tasks that carry an explicit cross-repository *target* contract and
|
||||
# must NOT auto-resolve the workspace repository for omitted coordinates. Only
|
||||
# ``delete_branch`` (#733) qualifies: a destructive raw-branch deletion names
|
||||
# its org/repo explicitly and fails closed otherwise. Every other mutation is
|
||||
# session-bound to the single workspace-aligned repository and resolves it from
|
||||
# the trusted git remote (mirroring :func:`_resolve`).
|
||||
_EXPLICIT_TARGET_MUTATION_TASKS = frozenset({"delete_branch"})
|
||||
|
||||
|
||||
def _run_anti_stomp_preflight(
|
||||
task: str | None,
|
||||
*,
|
||||
@@ -832,21 +841,56 @@ def _run_anti_stomp_preflight(
|
||||
local_remote_url = None
|
||||
org_explicit = org is not None
|
||||
repo_explicit = repo is not None
|
||||
filled_org = False
|
||||
filled_repo = False
|
||||
if remote:
|
||||
try:
|
||||
local_remote_url = _local_git_remote_url(remote)
|
||||
except Exception:
|
||||
local_remote_url = None
|
||||
if resolved_org is None or resolved_repo is None:
|
||||
try:
|
||||
rem = _effective_remote(remote)
|
||||
if rem in REMOTES:
|
||||
if resolved_org is None:
|
||||
resolved_org = REMOTES[rem]["org"]
|
||||
if resolved_repo is None:
|
||||
resolved_repo = REMOTES[rem]["repo"]
|
||||
except Exception:
|
||||
pass
|
||||
# Session-bound mutations act on the single workspace-aligned
|
||||
# repository, so prefer the trusted git-remote-derived identity for
|
||||
# omitted coordinates — exactly as :func:`_resolve` does — instead
|
||||
# of the remote-wide ``REMOTES`` default *target*. A bare ``prgs``
|
||||
# default resolves to ``Timesheet`` and false-positived the #530
|
||||
# repo guard against a ``Gitea-Tools`` checkout, blocking native
|
||||
# issue/PR/review/merge/lock/cleanup mutations at preflight while
|
||||
# the actual resolve stage (``_resolve``) would have targeted the
|
||||
# workspace repo correctly. Workspace-filled sides are intentional
|
||||
# alignment and are marked explicit so the guard accepts them.
|
||||
# ``delete_branch`` keeps its explicit cross-repo target contract
|
||||
# (#733) and is excluded, so it still fails closed on omitted
|
||||
# coordinates. ``REMOTES`` stays a best-effort last resort only when
|
||||
# no workspace identity can be derived (preserves the dadeschools
|
||||
# default-target behavior and the existing unit suite).
|
||||
workspace_parts = None
|
||||
if task not in _EXPLICIT_TARGET_MUTATION_TASKS:
|
||||
try:
|
||||
workspace_parts = (
|
||||
remote_repo_guard.parse_org_repo_from_remote_url(
|
||||
local_remote_url
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
workspace_parts = None
|
||||
if workspace_parts:
|
||||
if resolved_org is None:
|
||||
resolved_org = workspace_parts[0]
|
||||
filled_org = True
|
||||
if resolved_repo is None:
|
||||
resolved_repo = workspace_parts[1]
|
||||
filled_repo = True
|
||||
if resolved_org is None or resolved_repo is None:
|
||||
try:
|
||||
rem = _effective_remote(remote)
|
||||
if rem in REMOTES:
|
||||
if resolved_org is None:
|
||||
resolved_org = REMOTES[rem]["org"]
|
||||
if resolved_repo is None:
|
||||
resolved_repo = REMOTES[rem]["repo"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
parity = _current_master_parity()
|
||||
startup_head = parity.get("startup_head")
|
||||
@@ -900,8 +944,10 @@ def _run_anti_stomp_preflight(
|
||||
resolved_org=resolved_org,
|
||||
resolved_repo=resolved_repo,
|
||||
local_remote_url=local_remote_url,
|
||||
org_explicit=org_explicit,
|
||||
repo_explicit=repo_explicit,
|
||||
# Workspace-filled sides are intentional alignment for #530 (same
|
||||
# contract as :func:`_resolve`), so treat them as explicit.
|
||||
org_explicit=org_explicit or filled_org,
|
||||
repo_explicit=repo_explicit or filled_repo,
|
||||
check_repo=check_repo,
|
||||
profile_name=profile_name,
|
||||
profile_role=profile_role,
|
||||
@@ -2696,8 +2742,13 @@ def gitea_create_issue(
|
||||
return blocked
|
||||
try:
|
||||
with _mutation_stage("preflight_purity"):
|
||||
# #735: forward explicit org/repo into shared anti-stomp preflight.
|
||||
verify_preflight_purity(
|
||||
remote, worktree_path=worktree_path, task="create_issue"
|
||||
remote,
|
||||
worktree_path=worktree_path,
|
||||
task="create_issue",
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
except Exception as exc:
|
||||
typed = _production_guard_block_from_exc(exc, number=None)
|
||||
@@ -2919,7 +2970,14 @@ def gitea_lock_issue(
|
||||
)
|
||||
else:
|
||||
git_state = issue_lock_worktree.read_worktree_git_state(resolved_worktree)
|
||||
verify_preflight_purity(remote, worktree_path=resolved_worktree, task="lock_issue")
|
||||
# #735: forward explicit org/repo into shared anti-stomp preflight.
|
||||
verify_preflight_purity(
|
||||
remote,
|
||||
worktree_path=resolved_worktree,
|
||||
task="lock_issue",
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
lock_assessment = issue_lock_worktree.assess_issue_lock_worktree(
|
||||
worktree_path=resolved_worktree,
|
||||
current_branch=git_state.get("current_branch"),
|
||||
@@ -3150,7 +3208,14 @@ def gitea_create_pr(
|
||||
if blocked:
|
||||
return blocked
|
||||
with _mutation_stage("preflight_purity"):
|
||||
verify_preflight_purity(remote, worktree_path=worktree_path, task="create_pr")
|
||||
# #735: forward explicit org/repo into shared anti-stomp preflight.
|
||||
verify_preflight_purity(
|
||||
remote,
|
||||
worktree_path=worktree_path,
|
||||
task="create_pr",
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
|
||||
# ── Issue Lock Validation (Issue #194 / #196 / #443) ──
|
||||
@@ -7446,9 +7511,9 @@ def gitea_edit_pr(
|
||||
# Closing uses close_pr; non-closing title/body/base edits use edit_pr so
|
||||
# the shared #604 anti-stomp inventory stays consistent with wiring.
|
||||
if closing:
|
||||
verify_preflight_purity(remote, task="close_pr")
|
||||
verify_preflight_purity(remote, task="close_pr", org=org, repo=repo)
|
||||
else:
|
||||
verify_preflight_purity(remote, task="edit_pr")
|
||||
verify_preflight_purity(remote, task="edit_pr", org=org, repo=repo)
|
||||
|
||||
# PR closure is a first-class capability, distinct from retitling or
|
||||
# rebasing edits (#216). Gate BEFORE auth/API setup so a blocked close
|
||||
@@ -7699,7 +7764,8 @@ def gitea_commit_files(
|
||||
branch="",
|
||||
)
|
||||
|
||||
verify_preflight_purity(remote, task="commit_files")
|
||||
# #735: forward explicit org/repo into shared anti-stomp preflight.
|
||||
verify_preflight_purity(remote, task="commit_files", org=org, repo=repo)
|
||||
processed_files, source_proofs = _prepare_commit_payload_files(files)
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
@@ -8814,6 +8880,8 @@ def gitea_cleanup_merged_pr_branch(
|
||||
remote,
|
||||
worktree_path=worktree_path,
|
||||
task="cleanup_merged_pr_branch",
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
@@ -9541,7 +9609,9 @@ def gitea_reconcile_merged_cleanups(
|
||||
report["executed"] = False
|
||||
return {"success": True, "performed": False, **report}
|
||||
|
||||
verify_preflight_purity(remote, task="reconcile_merged_cleanups")
|
||||
verify_preflight_purity(
|
||||
remote, task="reconcile_merged_cleanups", org=org, repo=repo
|
||||
)
|
||||
actions: list[dict] = []
|
||||
for entry in report.get("entries") or []:
|
||||
head_branch = entry.get("head_branch") or ""
|
||||
@@ -10070,7 +10140,9 @@ def gitea_reconcile_already_landed_pr(
|
||||
)
|
||||
return result
|
||||
|
||||
verify_preflight_purity(remote, task="reconcile_already_landed_pr")
|
||||
verify_preflight_purity(
|
||||
remote, task="reconcile_already_landed_pr", org=org, repo=repo
|
||||
)
|
||||
|
||||
if post_comment and comment_body.strip():
|
||||
comment_block = _profile_operation_gate("gitea.pr.comment")
|
||||
@@ -10341,7 +10413,9 @@ def gitea_reconcile_superseded_by_merged_pr(
|
||||
result["safe_next_action"] = "rerun with explicit mutation flags"
|
||||
return result
|
||||
|
||||
verify_preflight_purity(remote, task="reconcile_close_superseded_pr")
|
||||
verify_preflight_purity(
|
||||
remote, task="reconcile_close_superseded_pr", org=org, repo=repo
|
||||
)
|
||||
|
||||
if post_comment:
|
||||
comment_url = f"{base}/issues/{target_pr_number}/comments"
|
||||
@@ -10444,7 +10518,7 @@ def gitea_close_issue(
|
||||
)
|
||||
if blocked:
|
||||
return blocked
|
||||
verify_preflight_purity(remote, task="close_issue")
|
||||
verify_preflight_purity(remote, task="close_issue", org=org, repo=repo)
|
||||
|
||||
# #529: block closure that buries an unproven non-zero suite exit as an
|
||||
# "expected pre-existing failure". Skipped when no closure report is given.
|
||||
@@ -12411,10 +12485,13 @@ def gitea_create_issue_comment(
|
||||
# allowed while an author holds a different implementation lock.
|
||||
# Scope ownership for source edits is enforced via branch/worktree
|
||||
# binding + root diagnostic checks (#683).
|
||||
# #735: forward explicit org/repo into shared anti-stomp preflight.
|
||||
verify_preflight_purity(
|
||||
remote,
|
||||
worktree_path=worktree_path,
|
||||
task="comment_issue",
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
except Exception as exc:
|
||||
typed = _production_guard_block_from_exc(
|
||||
@@ -14550,7 +14627,14 @@ def gitea_mark_issue(
|
||||
)
|
||||
if blocked:
|
||||
return blocked
|
||||
verify_preflight_purity(remote, worktree_path=worktree_path, task="mark_issue")
|
||||
# #735: forward explicit org/repo into shared anti-stomp preflight.
|
||||
verify_preflight_purity(
|
||||
remote,
|
||||
worktree_path=worktree_path,
|
||||
task="mark_issue",
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
base = repo_api_url(h, o, r)
|
||||
@@ -14640,7 +14724,7 @@ def gitea_post_heartbeat(
|
||||
)
|
||||
if blocked:
|
||||
return blocked
|
||||
verify_preflight_purity(remote, task="post_heartbeat")
|
||||
verify_preflight_purity(remote, task="post_heartbeat", org=org, repo=repo)
|
||||
active_profile = profile or get_profile().get("profile_name")
|
||||
body = issue_claim_heartbeat.format_heartbeat_body(
|
||||
kind="progress",
|
||||
@@ -15719,7 +15803,9 @@ def gitea_cleanup_stale_claims(
|
||||
)
|
||||
if blocked:
|
||||
return blocked
|
||||
verify_preflight_purity(remote, task="cleanup_stale_claims")
|
||||
verify_preflight_purity(
|
||||
remote, task="cleanup_stale_claims", org=org, repo=repo
|
||||
)
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
@@ -15839,7 +15925,13 @@ def gitea_create_label(
|
||||
)
|
||||
if blocked:
|
||||
return blocked
|
||||
verify_preflight_purity(remote, worktree_path=worktree_path, task="create_label")
|
||||
verify_preflight_purity(
|
||||
remote,
|
||||
worktree_path=worktree_path,
|
||||
task="create_label",
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
base = repo_api_url(h, o, r)
|
||||
@@ -15893,7 +15985,13 @@ def gitea_set_issue_labels(
|
||||
)
|
||||
if blocked:
|
||||
return blocked
|
||||
verify_preflight_purity(remote, worktree_path=worktree_path, task="set_issue_labels")
|
||||
verify_preflight_purity(
|
||||
remote,
|
||||
worktree_path=worktree_path,
|
||||
task="set_issue_labels",
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
base = repo_api_url(h, o, r)
|
||||
|
||||
Reference in New Issue
Block a user