fix(mcp): allow create_issue from clean control checkout (#749)
Provide a narrow phase-scoped bootstrap so gitea_create_issue can run from a clean canonical control checkout when no issue number—and therefore no issue-backed worktree—can exist yet. Dirty roots, non-base branches, base races, foreign workspaces, and all post-creation author mutations remain fail-closed under the ordinary branches-only guard. Closes #749.
This commit is contained in:
+58
-8
@@ -817,7 +817,11 @@ def _enforce_canonical_repository_root(
|
||||
)
|
||||
|
||||
|
||||
def _enforce_branches_only_author_mutation(worktree_path: str | None = None) -> None:
|
||||
def _enforce_branches_only_author_mutation(
|
||||
worktree_path: str | None = None,
|
||||
*,
|
||||
task: str | None = None,
|
||||
) -> None:
|
||||
"""#274: author file/branch mutations must run from a branches/ worktree.
|
||||
|
||||
Reviewer, merger, and reconciler roles are exempt: reconciler ``close_pr``
|
||||
@@ -831,6 +835,12 @@ def _enforce_branches_only_author_mutation(worktree_path: str | None = None) ->
|
||||
genuine reconciler as an author and defeat this exemption. Keying off the
|
||||
real profile role as well preserves the exemption without weakening author
|
||||
blocking — an actual author profile classifies as ``author`` in both.
|
||||
|
||||
#749: ``create_issue`` alone may proceed from a **clean** canonical control
|
||||
checkout (pure remote mutation; no issue number exists yet for an
|
||||
issue-backed worktree). Dirty roots, non-base branches, base races, and
|
||||
every other author mutation keep the ordinary branches-only fail-closed
|
||||
behaviour.
|
||||
"""
|
||||
if (
|
||||
_effective_workspace_role() in nwb.NON_AUTHOR_ROLES
|
||||
@@ -845,10 +855,35 @@ def _enforce_branches_only_author_mutation(worktree_path: str | None = None) ->
|
||||
project_root=ctx["canonical_repo_root"],
|
||||
current_branch=git_state.get("current_branch"),
|
||||
)
|
||||
if assessment["block"]:
|
||||
raise RuntimeError(
|
||||
author_mutation_worktree.format_author_mutation_worktree_error(assessment)
|
||||
if not assessment["block"]:
|
||||
return
|
||||
|
||||
# #749 create_issue bootstrap: narrow phase exemption only.
|
||||
import create_issue_bootstrap as _cib
|
||||
|
||||
remote_master_sha = None
|
||||
try:
|
||||
remote_master_sha = root_checkout_guard.resolve_remote_master_sha(
|
||||
ctx["canonical_repo_root"]
|
||||
)
|
||||
except Exception:
|
||||
remote_master_sha = None
|
||||
bootstrap = _cib.assess_create_issue_bootstrap(
|
||||
workspace_path=workspace,
|
||||
canonical_repo_root=ctx["canonical_repo_root"],
|
||||
current_branch=git_state.get("current_branch"),
|
||||
head_sha=git_state.get("head_sha"),
|
||||
porcelain_status=git_state.get("porcelain_status") or "",
|
||||
remote_master_sha=remote_master_sha,
|
||||
task=task,
|
||||
)
|
||||
if bootstrap.get("allowed"):
|
||||
return
|
||||
if bootstrap.get("block") and not bootstrap.get("not_applicable"):
|
||||
raise RuntimeError(_cib.format_create_issue_bootstrap_error(bootstrap))
|
||||
raise RuntimeError(
|
||||
author_mutation_worktree.format_author_mutation_worktree_error(assessment)
|
||||
)
|
||||
|
||||
|
||||
def _anti_stomp_in_test_mode() -> bool:
|
||||
@@ -1213,7 +1248,7 @@ def verify_preflight_purity(
|
||||
# Historical path: root + branches after purity-order when dirty paths live.
|
||||
_enforce_canonical_repository_root(worktree_path, remote=remote)
|
||||
_enforce_root_checkout_guard(worktree_path)
|
||||
_enforce_branches_only_author_mutation(worktree_path)
|
||||
_enforce_branches_only_author_mutation(worktree_path, task=task)
|
||||
_enforce_issue_scope_guard(
|
||||
worktree_path,
|
||||
task=task,
|
||||
@@ -1247,7 +1282,7 @@ def verify_preflight_purity(
|
||||
if production_active:
|
||||
_enforce_canonical_repository_root(worktree_path, remote=remote)
|
||||
_enforce_root_checkout_guard(worktree_path)
|
||||
_enforce_branches_only_author_mutation(worktree_path)
|
||||
_enforce_branches_only_author_mutation(worktree_path, task=task)
|
||||
_enforce_issue_scope_guard(
|
||||
worktree_path,
|
||||
task=task,
|
||||
@@ -1366,10 +1401,15 @@ def _enforce_issue_scope_guard(
|
||||
"mark_issue",
|
||||
}
|
||||
)
|
||||
# #749: create_issue is pre-ownership (no issue number / lock can exist yet).
|
||||
import create_issue_bootstrap as _cib
|
||||
|
||||
is_create_issue = _cib.is_create_issue_task(task)
|
||||
require_lock = bool(require_author_lock) or (
|
||||
authorish
|
||||
and workflow_scope_guard.production_guards_forced()
|
||||
and role == "author"
|
||||
and not is_create_issue
|
||||
)
|
||||
assessment = workflow_scope_guard.assess_production_mutation_guards(
|
||||
workspace_path=workspace,
|
||||
@@ -1381,6 +1421,7 @@ def _enforce_issue_scope_guard(
|
||||
role_kind=role,
|
||||
require_author_lock=require_lock,
|
||||
in_test_mode=_preflight_in_test_mode(),
|
||||
mutation_task=task,
|
||||
)
|
||||
workflow_scope_guard.raise_if_blocked(assessment)
|
||||
|
||||
@@ -1394,7 +1435,11 @@ def _production_guard_block_from_exc(exc: BaseException, **extra) -> dict | None
|
||||
kind = workflow_scope_guard.BLOCKER_PRODUCTION_GUARD
|
||||
if "root_diagnostic_edit" in text or "tracked source or test edits" in text:
|
||||
kind = workflow_scope_guard.BLOCKER_ROOT_DIAGNOSTIC_EDIT
|
||||
elif "Branches-only mutation guard" in text or "stable control checkout" in text:
|
||||
elif (
|
||||
"Branches-only mutation guard" in text
|
||||
or "stable control checkout" in text
|
||||
or "Create-issue bootstrap guard (#749)" in text
|
||||
):
|
||||
kind = workflow_scope_guard.BLOCKER_MISSING_WORKTREE
|
||||
elif "out-of-scope" in text or "locked to issue" in text:
|
||||
kind = workflow_scope_guard.BLOCKER_OUT_OF_SCOPE_ISSUE
|
||||
@@ -1405,10 +1450,15 @@ def _production_guard_block_from_exc(exc: BaseException, **extra) -> dict | None
|
||||
reasons=[text],
|
||||
**extra,
|
||||
)
|
||||
if "Branches-only mutation guard" in text:
|
||||
if "Branches-only mutation guard" in text or "Create-issue bootstrap guard (#749)" in text:
|
||||
# Prefer bootstrap next-action text when present (#749 AC4).
|
||||
next_action = None
|
||||
if "exact_next_action:" in text:
|
||||
next_action = text.split("exact_next_action:", 1)[1].strip()
|
||||
return workflow_scope_guard.block_response(
|
||||
blocker_kind=workflow_scope_guard.BLOCKER_MISSING_WORKTREE,
|
||||
reasons=[text],
|
||||
exact_next_action=next_action,
|
||||
**extra,
|
||||
)
|
||||
if "Root checkout guard" in text:
|
||||
|
||||
Reference in New Issue
Block a user