The namespace health check (05fdcee) classified a false-ready namespace but
only returned an advisory blocks_merge_workflow flag; nothing in the merge
gate consumed it. Wire it in so a broken live namespace hard-blocks merge.
- review_merge_state_machine.assess_workflow_blockers: add live_namespace_broken
blocker (registered-in-FastMCP but not callable-through-namespace).
- assess_state_advancement: forward **blocker_kwargs so can_approve/can_merge/
workflow_status honor the full blocker set (also fixes latent drop of
mcp_reconnect_failed/stale_capability_state through those paths).
- gitea_assess_review_merge_state_machine tool: accept live_namespace_broken and
thread it through all state-machine calls.
- Tests: prove can_merge/workflow_status/tool block on live_namespace_broken even
with every review state + pre-merge gate satisfied; bridge classify verdict.
- Docs: enforcement section wiring blocks_merge_workflow -> live_namespace_broken.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
349 lines
11 KiB
Python
349 lines
11 KiB
Python
"""Enforced PR review/merge workflow state machine (#290).
|
|
|
|
Review and merge must advance through explicit states. Any failed upstream
|
|
gate forbids downstream approve/merge mutations until the full workflow is
|
|
restarted after blockers clear.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Any
|
|
|
|
REVIEW_MERGE_STATES: tuple[str, ...] = (
|
|
"PRECHECK",
|
|
"INVENTORY",
|
|
"SELECT_PR",
|
|
"PIN_HEAD_SHA",
|
|
"CREATE_BRANCHES_WORKTREE",
|
|
"VALIDATE",
|
|
"REVIEW_DECISION",
|
|
"APPROVE_OR_REQUEST_CHANGES",
|
|
"PRE_MERGE_RECHECK",
|
|
"MERGE",
|
|
"POST_MERGE_REPORT",
|
|
)
|
|
|
|
TERMINAL_BLOCKED_HEADING = (
|
|
"PR review/merge workflow blocked. Restart the full workflow after blockers clear."
|
|
)
|
|
|
|
_FORBIDDEN_RECOVERY_REPLAY_RE = re.compile(
|
|
r"\b(?:approve(?:\s+pr)?\s*#?\d+|merge(?:\s+pr)?\s*#?\d+|gitea_merge_pr|"
|
|
r"gitea_submit_pr_review|submit\s+approve|run\s+merge)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
_RESTART_WORKFLOW_RE = re.compile(
|
|
r"restart(?:\s+the)?\s+full\s+workflow|rerun\s+the\s+full\s+workflow",
|
|
re.IGNORECASE,
|
|
)
|
|
_READY_TO_MERGE_RE = re.compile(
|
|
r"\bready\s+to\s+merge\b",
|
|
re.IGNORECASE,
|
|
)
|
|
_REVIEWED_VALIDATED_RE = re.compile(
|
|
r"\b(?:reviewed|validated|approval\s+submitted)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
_PRE_MERGE_REQUIRED_GATES = (
|
|
"whoami_verified",
|
|
"profile_runtime_verified",
|
|
"merge_capability_verified",
|
|
"pr_refetched",
|
|
"reviewed_head_sha_unchanged",
|
|
"pr_mergeable",
|
|
"checks_passed",
|
|
"reviewer_not_author",
|
|
"worktree_clean",
|
|
)
|
|
|
|
|
|
def _clean(value: str | None) -> str:
|
|
return (value or "").strip()
|
|
|
|
|
|
def state_index(state: str) -> int:
|
|
name = _clean(state).upper()
|
|
try:
|
|
return REVIEW_MERGE_STATES.index(name)
|
|
except ValueError as exc:
|
|
raise ValueError(f"unknown review/merge state '{state}' (fail closed)") from exc
|
|
|
|
|
|
def downstream_states(from_state: str) -> list[str]:
|
|
idx = state_index(from_state)
|
|
return list(REVIEW_MERGE_STATES[idx + 1 :])
|
|
|
|
|
|
def _completed_through(state_completion: dict[str, bool], state: str) -> bool:
|
|
return bool(state_completion.get(state))
|
|
|
|
|
|
def _first_incomplete_state(state_completion: dict[str, bool]) -> str | None:
|
|
for state in REVIEW_MERGE_STATES:
|
|
if not _completed_through(state_completion, state):
|
|
return state
|
|
return None
|
|
|
|
|
|
def assess_workflow_blockers(
|
|
*,
|
|
infra_stop: bool = False,
|
|
capability_blocked: bool = False,
|
|
mcp_reconnect_failed: bool = False,
|
|
stale_capability_state: bool = False,
|
|
live_namespace_broken: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Return hard blockers that forbid all PR queue work (#290 AC3).
|
|
|
|
``live_namespace_broken`` fails the merge/review path closed when the live
|
|
MCP namespace call path is unusable (e.g. ``client is closing: EOF``) even
|
|
though the tool is registered in FastMCP (#543 AC5). Feed it the
|
|
``blocks_merge_workflow`` verdict from
|
|
``mcp_namespace_health.classify_namespace_probe``.
|
|
"""
|
|
reasons: list[str] = []
|
|
if infra_stop:
|
|
reasons.append("infra_stop is active; PR selection/review/merge is forbidden")
|
|
if capability_blocked:
|
|
reasons.append("gitea_resolve_task_capability returned blocked/stop_required")
|
|
if mcp_reconnect_failed:
|
|
reasons.append("MCP reconnect failed; stale session state cannot be reused")
|
|
if stale_capability_state:
|
|
reasons.append("stale MCP capability state detected after reconnect failure")
|
|
if live_namespace_broken:
|
|
reasons.append(
|
|
"live MCP namespace call path is broken (registered in FastMCP but "
|
|
"not callable through the namespace); repair the namespace before "
|
|
"review/merge"
|
|
)
|
|
return {
|
|
"block": bool(reasons),
|
|
"reasons": reasons,
|
|
"forbidden_states": list(REVIEW_MERGE_STATES) if reasons else [],
|
|
"safe_next_action": (
|
|
TERMINAL_BLOCKED_HEADING if reasons else "proceed with PRECHECK"
|
|
),
|
|
}
|
|
|
|
|
|
def assess_state_advancement(
|
|
state_completion: dict[str, bool] | None,
|
|
*,
|
|
target_state: str,
|
|
**blocker_kwargs,
|
|
) -> dict[str, Any]:
|
|
"""Fail closed when *target_state* is requested before upstream gates pass.
|
|
|
|
Forwards every blocker flag (``infra_stop``, ``capability_blocked``,
|
|
``mcp_reconnect_failed``, ``stale_capability_state``,
|
|
``live_namespace_broken``) to :func:`assess_workflow_blockers` so
|
|
``can_approve``/``can_merge`` honor the full blocker set, not just
|
|
infra_stop/capability_blocked (#543 AC5).
|
|
"""
|
|
completion = dict(state_completion or {})
|
|
target = _clean(target_state).upper()
|
|
blockers = assess_workflow_blockers(**blocker_kwargs)
|
|
reasons = list(blockers["reasons"])
|
|
|
|
try:
|
|
target_idx = state_index(target)
|
|
except ValueError as exc:
|
|
return {
|
|
"target_state": target,
|
|
"allowed": False,
|
|
"block": True,
|
|
"reasons": [str(exc)],
|
|
"next_allowed_state": None,
|
|
"safe_next_action": TERMINAL_BLOCKED_HEADING,
|
|
}
|
|
|
|
if blockers["block"]:
|
|
return {
|
|
"target_state": target,
|
|
"allowed": False,
|
|
"block": True,
|
|
"reasons": reasons,
|
|
"next_allowed_state": None,
|
|
"safe_next_action": blockers["safe_next_action"],
|
|
}
|
|
|
|
next_allowed = _first_incomplete_state(completion)
|
|
if next_allowed is None:
|
|
allowed = target_idx == len(REVIEW_MERGE_STATES) - 1
|
|
if not allowed:
|
|
reasons.append("workflow already completed through POST_MERGE_REPORT")
|
|
else:
|
|
allowed = state_index(next_allowed) >= target_idx
|
|
if not allowed:
|
|
reasons.append(
|
|
f"state '{target}' is forbidden until '{next_allowed}' completes"
|
|
)
|
|
|
|
return {
|
|
"target_state": target,
|
|
"allowed": allowed and not reasons,
|
|
"block": bool(reasons),
|
|
"reasons": reasons,
|
|
"next_allowed_state": next_allowed,
|
|
"completed_states": [s for s in REVIEW_MERGE_STATES if completion.get(s)],
|
|
"safe_next_action": (
|
|
f"complete state '{next_allowed}' before advancing"
|
|
if next_allowed and not allowed
|
|
else (
|
|
TERMINAL_BLOCKED_HEADING
|
|
if reasons
|
|
else f"advance to {target}"
|
|
)
|
|
),
|
|
}
|
|
|
|
|
|
def can_approve(state_completion: dict[str, bool] | None, **blocker_kwargs) -> dict[str, Any]:
|
|
"""Approval requires all states through REVIEW_DECISION (#290 AC)."""
|
|
required = REVIEW_MERGE_STATES[: REVIEW_MERGE_STATES.index("REVIEW_DECISION") + 1]
|
|
completion = dict(state_completion or {})
|
|
advance = assess_state_advancement(
|
|
completion,
|
|
target_state="APPROVE_OR_REQUEST_CHANGES",
|
|
**blocker_kwargs,
|
|
)
|
|
missing = [s for s in required if not completion.get(s)]
|
|
reasons = list(advance["reasons"])
|
|
if missing:
|
|
reasons.append(
|
|
"approval blocked: incomplete states: " + ", ".join(missing)
|
|
)
|
|
block = bool(reasons) or advance["block"]
|
|
return {
|
|
"allowed": not block,
|
|
"block": block,
|
|
"reasons": reasons,
|
|
"missing_states": missing,
|
|
"safe_next_action": advance["safe_next_action"],
|
|
}
|
|
|
|
|
|
def can_merge(
|
|
state_completion: dict[str, bool] | None,
|
|
*,
|
|
pre_merge_gates: dict[str, bool] | None = None,
|
|
**blocker_kwargs,
|
|
) -> dict[str, Any]:
|
|
"""Merge requires approve path plus fresh PRE_MERGE_RECHECK gates (#290 AC6)."""
|
|
completion = dict(state_completion or {})
|
|
approve = can_approve(completion, **blocker_kwargs)
|
|
reasons = list(approve["reasons"])
|
|
|
|
if not completion.get("APPROVE_OR_REQUEST_CHANGES"):
|
|
reasons.append("merge blocked: APPROVE_OR_REQUEST_CHANGES not completed")
|
|
|
|
gate_map = dict(pre_merge_gates or {})
|
|
missing_gates = [g for g in _PRE_MERGE_REQUIRED_GATES if not gate_map.get(g)]
|
|
if missing_gates:
|
|
reasons.append(
|
|
"merge blocked: pre-merge gates incomplete: " + ", ".join(missing_gates)
|
|
)
|
|
|
|
advance = assess_state_advancement(
|
|
completion,
|
|
target_state="MERGE",
|
|
**blocker_kwargs,
|
|
)
|
|
reasons.extend(advance["reasons"])
|
|
|
|
block = bool(reasons)
|
|
return {
|
|
"allowed": not block,
|
|
"block": block,
|
|
"reasons": reasons,
|
|
"missing_pre_merge_gates": missing_gates,
|
|
"safe_next_action": (
|
|
"complete PRE_MERGE_RECHECK with fresh whoami/capability/PR re-fetch "
|
|
"before merge"
|
|
if block
|
|
else "merge allowed"
|
|
),
|
|
}
|
|
|
|
|
|
def assess_blocked_recovery_handoff(report_text: str) -> dict[str, Any]:
|
|
"""Blocked handoffs must not replay approve/merge commands (#290 AC4)."""
|
|
text = report_text or ""
|
|
reasons: list[str] = []
|
|
if _FORBIDDEN_RECOVERY_REPLAY_RE.search(text):
|
|
reasons.append(
|
|
"blocked recovery handoff contains direct approve/merge replay command"
|
|
)
|
|
if reasons and not _RESTART_WORKFLOW_RE.search(text):
|
|
reasons.append(
|
|
"blocked recovery handoff must direct operator to restart full workflow"
|
|
)
|
|
return {
|
|
"block": bool(reasons),
|
|
"allowed": not reasons,
|
|
"reasons": reasons,
|
|
"safe_next_action": (
|
|
"remove approve/merge replay commands and say to restart full workflow "
|
|
"after blockers clear"
|
|
if reasons
|
|
else "recovery handoff wording acceptable"
|
|
),
|
|
}
|
|
|
|
|
|
def assess_final_report_state_claims(
|
|
report_text: str,
|
|
*,
|
|
state_completion: dict[str, bool] | None = None,
|
|
approve_completed: bool = False,
|
|
merge_completed: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Reports must not claim reviewed/ready-to-merge without gate proof (#290 AC10)."""
|
|
text = report_text or ""
|
|
completion = dict(state_completion or {})
|
|
reasons: list[str] = []
|
|
|
|
if _READY_TO_MERGE_RE.search(text) and not (
|
|
merge_completed or completion.get("PRE_MERGE_RECHECK")
|
|
):
|
|
reasons.append(
|
|
"report claims ready-to-merge without PRE_MERGE_RECHECK completion"
|
|
)
|
|
|
|
if _REVIEWED_VALIDATED_RE.search(text):
|
|
if not (approve_completed or completion.get("VALIDATE")):
|
|
reasons.append(
|
|
"report claims reviewed/validated without VALIDATE/APPROVE proof"
|
|
)
|
|
|
|
return {
|
|
"block": bool(reasons),
|
|
"allowed": not reasons,
|
|
"reasons": reasons,
|
|
"safe_next_action": (
|
|
"remove stale reviewed/ready-to-merge claims unless gates passed"
|
|
if reasons
|
|
else "final report state claims consistent with workflow"
|
|
),
|
|
}
|
|
|
|
|
|
def workflow_status(
|
|
state_completion: dict[str, bool] | None,
|
|
**blocker_kwargs,
|
|
) -> dict[str, Any]:
|
|
"""Summarize current state-machine position for MCP/runtime reporting."""
|
|
completion = dict(state_completion or {})
|
|
blockers = assess_workflow_blockers(**blocker_kwargs)
|
|
next_state = _first_incomplete_state(completion)
|
|
return {
|
|
"states": list(REVIEW_MERGE_STATES),
|
|
"completed_states": [s for s in REVIEW_MERGE_STATES if completion.get(s)],
|
|
"next_required_state": next_state,
|
|
"workflow_complete": next_state is None,
|
|
"approve_allowed": can_approve(completion, **blocker_kwargs)["allowed"],
|
|
"merge_allowed": can_merge(completion, **blocker_kwargs)["allowed"],
|
|
"blockers": blockers,
|
|
} |