"""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--*`` 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-. 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--* 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--* 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 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", }