Files
Gitea-Tools/create_issue_bootstrap.py
T
sysadminandClaude Opus 4.8 adc61255b2 fix(mcp): honor the create-issue bootstrap in anti-stomp preflight (Closes #757)
The sanctioned create_issue bootstrap from #749/#750 was unreachable in
production. Two guards assessed the same workspace for the same task and
reached opposite conclusions: the #274 branches-only guard consulted the
bootstrap and permitted a clean canonical control checkout, then the #604
anti-stomp preflight -- which never consulted it -- rejected that same
checkout as wrong_worktree.

The defect was wiring, not policy: the bootstrap decision was computed in
one guard and discarded, while the other re-derived a conflicting answer
from a lower-level assessor with no notion of the bootstrap phase.

Fix: one computation site, one interpretation site.

* create_issue_bootstrap.bootstrap_permits_control_checkout() is the single
  predicate both guards use to interpret an assessment. It is fail-closed by
  construction: missing, malformed, refused, incomplete, or contradictory
  evidence returns False and leaves the ordinary block in force. It also
  verifies the assessment describes the exact workspace and canonical root
  being guarded, so a stale or foreign assessment cannot be reused.
* _create_issue_bootstrap_assessment() computes the assessment once per
  preflight from inspected repository state. verify_preflight_purity threads
  that single result into both guards.
* The #604 assessor accepts the assessment and waives ONLY the wrong-worktree
  verdict. Root checkout, repo, role, stale runtime, lease, head-SHA,
  workflow-hash, and contamination checks are evaluated independently and
  still apply.

Evidence is server-derived only and travels an internal path: no MCP tool
signature gains a bootstrap argument, and no caller-controlled boolean can
manufacture eligibility. Behavior is unchanged for callers that supply no
evidence, and for every non-create_issue author mutation.

No issue or PR number is special-cased in production behavior.

Tests: new tests/test_issue_757_bootstrap_guard_agreement.py (38 tests, 26
subtests) covering the shared predicate, the narrow waiver, guard agreement
across the full workspace-state matrix, non-forgeable eligibility, and an
end-to-end native gitea_create_issue run with the #604 gate LIVE. All 38
fail against unfixed sources; the e2e reproduces the production error text
verbatim ("Anti-stomp preflight (#604) blocked mutation [wrong_worktree]").

tests/test_reconciler_close_workspace_guard.py: one case asserted that
create_issue stays blocked on the control checkout, which only held because
the bootstrap-blind #604 guard was overriding #750 -- it encoded the defect.
Re-pointed to lock_issue, which is issue-backed and legitimately still
requires a branches/ worktree. Its teardown now restores the module-level
preflight task/role so test order cannot leak resolved state.

Full suite: 3624 passed, 2 failed, 6 skipped (426 subtests).
Baseline at bde5c5fb on a clean detached worktree: 3586 passed, 2 failed,
6 skipped (400 subtests). The same 2 failures reproduce identically on
pristine master and are unrelated to this change
(test_issue_702_review_findings_f1_f6 F1 worktree recovery;
test_reconciler_supersession_close org/repo forwarding).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-19 02:26:58 -04:00

298 lines
10 KiB
Python

"""Sanctioned pre-issue bootstrap for ``create_issue`` (#749).
``gitea_create_issue`` is a pure remote mutation: it creates a tracking issue
and writes nothing to the local working tree. The issue-first gate forbids
creating ``branches/issue-<N>-*`` before the issue number exists, while the
#274 branches-only guard previously demanded that worktree first — a deadlock.
This module defines a **narrow, phase-scoped** exemption:
* Only tasks in :data:`CREATE_ISSUE_TASKS` may use it.
* Only the **canonical control checkout** may be used (never an arbitrary
directory, unrelated worktree, or foreign clone).
* The control checkout must be clean, on an accepted base branch, and
base-equivalent to live master when a remote tip is known.
* Every post-creation author mutation keeps the ordinary ``branches/`` rule.
The exemption cannot widen: unknown tasks, dirty roots, drifted HEADs, non-base
branches, and non-control workspaces fall through to the existing fail-closed
guards.
"""
from __future__ import annotations
import os
from typing import Any
from author_mutation_worktree import BASE_BRANCHES, is_path_under_branches
from reviewer_worktree import parse_dirty_tracked_files
CREATE_ISSUE_TASKS = frozenset({"create_issue", "gitea_create_issue"})
# Satisfiable before an issue number exists — never names issue-<N>.
EXACT_NEXT_ACTION_BOOTSTRAP = (
"Restore the canonical control checkout to a clean accepted base branch "
"(master/main/dev) that matches live master, with no tracked local edits "
"and no detached HEAD. Re-resolve the exact create_issue task, then re-run "
"gitea_create_issue from that clean control checkout. Do not create "
"branches/issue-<N>-* worktrees, dummy directories, or borrow unrelated "
"worktrees before the issue exists."
)
EXACT_NEXT_ACTION_POST_CREATE = (
"After the issue exists: create a registered worktree under "
"branches/issue-<N>-* from clean master, claim/lock the issue, set "
"GITEA_AUTHOR_WORKTREE / worktree_path to that path, then continue author "
"mutations from the issue-backed worktree only."
)
def is_create_issue_task(task: str | None) -> bool:
"""True when *task* is the create_issue mutation (or tool alias)."""
return (task or "").strip() in CREATE_ISSUE_TASKS
def assess_create_issue_bootstrap(
*,
workspace_path: str,
canonical_repo_root: str,
current_branch: str | None = None,
head_sha: str | None = None,
porcelain_status: str = "",
remote_master_sha: str | None = None,
task: str | None = None,
) -> dict[str, Any]:
"""Assess whether create_issue may proceed from the control checkout.
Returns a structured assessment:
* ``not_applicable`` — not a create_issue task, or workspace is already a
``branches/`` worktree (use ordinary guards).
* ``allowed`` — create_issue bootstrap may proceed from this control root.
* ``block`` — create_issue was attempted from control checkout but gates
failed (dirty, wrong branch, base race, etc.).
"""
reasons: list[str] = []
root = os.path.realpath(canonical_repo_root or "")
workspace = os.path.realpath(workspace_path or root or ".")
branch = (current_branch or "").strip()
dirty = parse_dirty_tracked_files(porcelain_status or "")
under_branches = is_path_under_branches(workspace, root) if root else False
if not is_create_issue_task(task):
return _result(
not_applicable=True,
allowed=False,
block=False,
reasons=["task is not create_issue"],
workspace=workspace,
root=root,
branch=branch,
dirty=dirty,
under_branches=under_branches,
)
# Registered branches/ worktrees keep the normal path (no bootstrap).
if under_branches:
return _result(
not_applicable=True,
allowed=False,
block=False,
reasons=["workspace is under branches/; ordinary #274 path applies"],
workspace=workspace,
root=root,
branch=branch,
dirty=dirty,
under_branches=True,
)
# Only the exact canonical control checkout is eligible.
if not root or workspace != root:
reasons.append(
"create_issue bootstrap requires the canonical control checkout; "
f"workspace '{workspace}' is not the repository root '{root or '(unknown)'}'"
)
return _result(
not_applicable=False,
allowed=False,
block=True,
reasons=reasons,
workspace=workspace,
root=root,
branch=branch,
dirty=dirty,
under_branches=False,
exact_next_action=EXACT_NEXT_ACTION_BOOTSTRAP,
)
if dirty:
reasons.append(
"create_issue bootstrap blocked: control checkout has tracked local "
f"edits (dirty files: {', '.join(dirty)})"
)
if not branch:
reasons.append(
"create_issue bootstrap blocked: control checkout is detached HEAD; "
"expected an accepted base branch (master/main/dev)"
)
elif branch not in BASE_BRANCHES:
reasons.append(
f"create_issue bootstrap blocked: control checkout branch '{branch}' "
f"is not an accepted base branch ({'/'.join(sorted(BASE_BRANCHES))})"
)
remote_tip = (remote_master_sha or "").strip() or None
local_tip = (head_sha or "").strip() or None
if remote_tip and local_tip and remote_tip != local_tip:
reasons.append(
"create_issue bootstrap blocked: control checkout HEAD does not match "
f"live master (HEAD {local_tip[:12]}, live master {remote_tip[:12]})"
)
if reasons:
return _result(
not_applicable=False,
allowed=False,
block=True,
reasons=reasons,
workspace=workspace,
root=root,
branch=branch or None,
dirty=dirty,
under_branches=False,
exact_next_action=EXACT_NEXT_ACTION_BOOTSTRAP,
)
return _result(
not_applicable=False,
allowed=True,
block=False,
reasons=[],
workspace=workspace,
root=root,
branch=branch or None,
dirty=dirty,
under_branches=False,
exact_next_action=EXACT_NEXT_ACTION_POST_CREATE,
bootstrap_path="clean_canonical_control_checkout",
)
def bootstrap_permits_control_checkout(
assessment: Any,
*,
task: str | None,
workspace_path: str | None,
canonical_repo_root: str | None,
) -> bool:
"""Single interpretation of a bootstrap assessment (#757).
Both author-mutation guards — the #274 branches-only enforcer and the #604
anti-stomp preflight — route their "may this workspace mutate" decision
through this predicate, so the two can never reach opposite conclusions
about identical evidence.
Fail-closed by construction. Every proof obligation must be present and
affirmative in *assessment*, and the assessment must describe the very
workspace and canonical root being guarded. A missing, malformed, refused,
incomplete, or contradictory assessment returns ``False``, which leaves the
caller's ordinary block in force.
``assessment`` is server-derived only: it is produced by
:func:`assess_create_issue_bootstrap` from inspected repository state. It is
never accepted from an MCP tool argument, so no caller can assert
eligibility it has not proven.
"""
if not isinstance(assessment, dict):
return False
if not is_create_issue_task(task):
return False
# Positive proof: the assessment must affirmatively allow, with no
# competing refusal or not-applicable disposition recorded alongside it.
if assessment.get("allowed") is not True:
return False
if assessment.get("proven") is not True:
return False
if assessment.get("block") is not False:
return False
if assessment.get("not_applicable") is not False:
return False
if assessment.get("reasons"):
return False
# Scope proof: only the create_issue bootstrap, only via the clean
# canonical control checkout path.
if assessment.get("task_scope") != "create_issue_only":
return False
if assessment.get("bootstrap_path") != "clean_canonical_control_checkout":
return False
# State proof: clean, and not a branches/ worktree (those keep #274).
if assessment.get("dirty_files"):
return False
if assessment.get("under_branches") is not False:
return False
# Binding proof: the assessment must describe *this* workspace and root,
# and that workspace must be exactly the canonical control checkout.
root = os.path.realpath(canonical_repo_root or "")
workspace = os.path.realpath(workspace_path or root or ".")
if not root or workspace != root:
return False
if assessment.get("canonical_repo_root") != root:
return False
if assessment.get("workspace_path") != workspace:
return False
return True
def format_create_issue_bootstrap_error(assessment: dict[str, Any]) -> str:
"""RuntimeError / typed-block message for a failed bootstrap assessment."""
reasons = "; ".join(
assessment.get("reasons") or ["create_issue bootstrap failed"]
)
next_action = (
assessment.get("exact_next_action") or EXACT_NEXT_ACTION_BOOTSTRAP
)
root = assessment.get("canonical_repo_root") or "(unknown)"
workspace = assessment.get("workspace_path") or "(unknown)"
return (
f"Create-issue bootstrap guard (#749): {reasons}. "
f"canonical repository root: {root}; workspace: {workspace}. "
f"exact_next_action: {next_action}"
)
def _result(
*,
not_applicable: bool,
allowed: bool,
block: bool,
reasons: list[str],
workspace: str,
root: str,
branch: str | None,
dirty: list[str],
under_branches: bool,
exact_next_action: str | None = None,
bootstrap_path: str | None = None,
) -> dict[str, Any]:
return {
"not_applicable": not_applicable,
"allowed": allowed,
"block": block,
"proven": allowed and not block,
"reasons": list(reasons),
"workspace_path": workspace,
"canonical_repo_root": root,
"current_branch": branch,
"dirty_files": list(dirty),
"under_branches": under_branches,
"exact_next_action": exact_next_action,
"bootstrap_path": bootstrap_path,
"task_scope": "create_issue_only",
}