fix(mcp): derive checks requirement from live branch protection (Closes #751)
`gitea_assess_pr_sync_status` could permanently block a merge-ready PR whose head commit had no status contexts. Gitea's combined commit-status endpoint reports `state: pending` both when a check is executing and when the status-context collection is empty. The wrapper took that `state` verbatim, and `checks_required` defaulted to True with no production path ever setting it, so "no CI configured" was indistinguishable from "CI is running" and waiting could never resolve it. Root cause spans the whole dataflow, not one call site: * the wrapper read only the combined `state` and never counted contexts; its `checks_status="none"` fallback was unreachable for any truthy state * `pr_sync_status.assess_pr_sync_status` defaulted `checks_required=True` * the MCP wrapper exposed no derived `checks_required` and never passed one * the branch-protection reader fetched the payload carrying `enable_status_check` / `status_check_contexts` and discarded both Changes: * `pr_sync_status.py`: add `classify_commit_checks` plus explicit CHECKS_* classifications (success / failure / pending / none / not_required / missing_required / unknown). The combined state is recorded for observability but is never evidence that CI is executing. Aggregation is fail-closed and newest-wins per context. * `pr_sync_status.py`: rewrite the merge_now checks gate to consume the derived `checks_required`, distinguish the new classifications with precise blocker reasons, and fail closed on unrecognized vocabulary. The previous gate let any value outside its fixed vocabulary fall through to merge_now. * `gitea_mcp_server.py`: add `_branch_protection_policy` deriving both the current-base rule and the status-check requirement from one live read; `_branch_protection_requires_current_base` is retained as a thin accessor with unchanged semantics. Add `_commit_checks_snapshot` which returns the context collection alongside the combined state. * `gitea_mcp_server.py`: wire the derived `checks_required` into the production assessment path and report `checks_evidence`. `checks_required` is always derived from live evidence and is deliberately not a caller-supplied input, so no session can declare checks optional without proof. Live classification also outranks a caller-supplied `checks_status`, which can no longer mask a real required-check failure. An unreadable protection policy or status collection stays fail-closed. Approval, current-head, current-base, mergeability, conflict, role, lease and merge-authorization gates are unchanged. Tests: `tests/test_issue_751_checks_assessor.py` (40 tests). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
+213
-12
@@ -41,6 +41,186 @@ _DENIED_UPDATE_ROLES = frozenset({"reviewer", "merger", "reconciler", "mixed", "
|
||||
UPDATE_STYLE_MERGE = "merge"
|
||||
_FORBIDDEN_UPDATE_STYLES = frozenset({"rebase", "rebase-merge", "squash", "force"})
|
||||
|
||||
# ── Commit check classifications (#751) ──────────────────────────────────
|
||||
# Gitea's *combined* commit status reports ``state: pending`` both when a real
|
||||
# check is executing and when the status-context collection is empty. Reading
|
||||
# ``state`` alone therefore cannot distinguish "CI is running" from "no CI
|
||||
# exists", which permanently blocks a merge-ready PR that no check will ever
|
||||
# report on. These classifications are derived from the actual context
|
||||
# collection plus the live branch-protection policy.
|
||||
CHECKS_SUCCESS = "success"
|
||||
CHECKS_FAILURE = "failure"
|
||||
CHECKS_PENDING = "pending"
|
||||
CHECKS_NONE = "none" # configured/produced nothing
|
||||
CHECKS_NOT_REQUIRED = "not_required" # protection does not require checks
|
||||
CHECKS_MISSING_REQUIRED = "missing_required" # required contexts have no result
|
||||
CHECKS_UNKNOWN = "unknown" # indeterminable — fail closed
|
||||
|
||||
# Values that permit merge_now when checks are required.
|
||||
_CHECKS_OK = frozenset({"success", "passed", "ok", "skipped", "not_required"})
|
||||
|
||||
# Raw per-context state vocabularies reported by Gitea.
|
||||
_CTX_SUCCESS = frozenset({"success", "passed", "ok"})
|
||||
_CTX_FAILURE = frozenset({"failure", "failed", "error", "cancelled", "canceled"})
|
||||
_CTX_PENDING = frozenset({"pending", "running", "queued", "expected"})
|
||||
_CTX_SKIPPED = frozenset({"skipped", "neutral"})
|
||||
|
||||
|
||||
def _normalize_context_rows(statuses: Any) -> list[dict[str, str]]:
|
||||
"""Reduce a raw status collection to newest-wins ``{context, state}`` rows.
|
||||
|
||||
Gitea returns the status collection newest-first, so the first row seen for
|
||||
a context wins. Rows without a usable state are discarded rather than being
|
||||
silently treated as passing.
|
||||
"""
|
||||
rows: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
if not isinstance(statuses, list):
|
||||
return rows
|
||||
for raw in statuses:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
context = (raw.get("context") or raw.get("name") or "").strip()
|
||||
state = (raw.get("status") or raw.get("state") or "").strip().lower()
|
||||
if not state:
|
||||
continue
|
||||
key = context or f"__unnamed__{len(rows)}"
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
rows.append({"context": context, "state": state})
|
||||
return rows
|
||||
|
||||
|
||||
def _aggregate_context_states(rows: list[dict[str, str]]) -> str:
|
||||
"""Fail-closed aggregate: failure > pending > unknown-state > success."""
|
||||
states = {row["state"] for row in rows}
|
||||
if states & _CTX_FAILURE:
|
||||
return CHECKS_FAILURE
|
||||
if states & _CTX_PENDING:
|
||||
return CHECKS_PENDING
|
||||
unresolved = states - _CTX_SUCCESS - _CTX_SKIPPED
|
||||
if unresolved:
|
||||
# An unrecognized context state must never read as success.
|
||||
return CHECKS_UNKNOWN
|
||||
return CHECKS_SUCCESS
|
||||
|
||||
|
||||
def classify_commit_checks(
|
||||
*,
|
||||
combined_state: str | None = None,
|
||||
statuses: Any = None,
|
||||
checks_enabled: bool | None = None,
|
||||
required_contexts: Any = None,
|
||||
policy_determinable: bool = True,
|
||||
status_determinable: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Classify head checks from live evidence (#751).
|
||||
|
||||
``combined_state`` is deliberately **not** authoritative: it is recorded for
|
||||
observability but never used to infer that CI is executing. The context
|
||||
collection and the live protection policy decide.
|
||||
|
||||
Returns ``checks_status`` (one of the ``CHECKS_*`` values), the derived
|
||||
``checks_required`` flag, and structured ``reasons``.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
rows = _normalize_context_rows(statuses)
|
||||
required = [
|
||||
str(ctx).strip()
|
||||
for ctx in (required_contexts or [])
|
||||
if str(ctx or "").strip()
|
||||
]
|
||||
observed_combined = (combined_state or "").strip().lower() or None
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"checks_status": CHECKS_UNKNOWN,
|
||||
"checks_required": True,
|
||||
"combined_state": observed_combined,
|
||||
"context_count": len(rows),
|
||||
"observed_contexts": [row["context"] for row in rows],
|
||||
"required_contexts": required,
|
||||
"missing_required_contexts": [],
|
||||
"policy_determinable": bool(policy_determinable),
|
||||
"status_determinable": bool(status_determinable),
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
# Policy unreadable → never assume checks are optional.
|
||||
if not policy_determinable:
|
||||
reasons.append(
|
||||
"branch-protection check policy could not be read; cannot prove "
|
||||
"whether status checks are required (fail closed)"
|
||||
)
|
||||
return result
|
||||
|
||||
if checks_enabled is False:
|
||||
result["checks_required"] = False
|
||||
result["checks_status"] = CHECKS_NOT_REQUIRED
|
||||
reasons.append(
|
||||
"live branch protection does not require status checks for the base "
|
||||
"branch; head status contexts do not gate merge"
|
||||
)
|
||||
return result
|
||||
|
||||
if checks_enabled is None:
|
||||
reasons.append(
|
||||
"branch-protection status-check requirement is indeterminate "
|
||||
"(fail closed)"
|
||||
)
|
||||
return result
|
||||
|
||||
# Checks are required from here on.
|
||||
if not status_determinable:
|
||||
reasons.append(
|
||||
"head commit status collection could not be read while branch "
|
||||
"protection requires status checks (fail closed)"
|
||||
)
|
||||
return result
|
||||
|
||||
if required:
|
||||
by_context = {row["context"]: row["state"] for row in rows if row["context"]}
|
||||
missing = [ctx for ctx in required if ctx not in by_context]
|
||||
if missing:
|
||||
result["missing_required_contexts"] = missing
|
||||
result["checks_status"] = CHECKS_MISSING_REQUIRED
|
||||
reasons.append(
|
||||
"branch protection requires status context(s) "
|
||||
f"{', '.join(missing)} but no matching status result exists at "
|
||||
"the head commit (fail closed)"
|
||||
)
|
||||
return result
|
||||
matched = [
|
||||
{"context": ctx, "state": by_context[ctx]} for ctx in required
|
||||
]
|
||||
result["checks_status"] = _aggregate_context_states(matched)
|
||||
reasons.append(
|
||||
f"evaluated {len(matched)} required status context(s) from live "
|
||||
"branch protection; unrelated contexts were ignored"
|
||||
)
|
||||
return result
|
||||
|
||||
# Status checks enabled with no specific required contexts configured.
|
||||
if not rows:
|
||||
result["checks_status"] = CHECKS_NONE
|
||||
reasons.append(
|
||||
"branch protection enables status checks but no status context was "
|
||||
"produced for the head commit"
|
||||
)
|
||||
if observed_combined in _CTX_PENDING:
|
||||
reasons.append(
|
||||
f"combined commit state '{observed_combined}' does not indicate "
|
||||
"executing CI because the status-context collection is empty"
|
||||
)
|
||||
return result
|
||||
|
||||
result["checks_status"] = _aggregate_context_states(rows)
|
||||
reasons.append(
|
||||
f"aggregated {len(rows)} reported status context(s); branch protection "
|
||||
"configures no explicit required-context list"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_sha(value: str | None) -> str | None:
|
||||
text = (value or "").strip().lower()
|
||||
@@ -120,6 +300,7 @@ def assess_pr_sync_status(
|
||||
"branch_protection_requires_current_base": requires_current,
|
||||
"approval_at_current_head": approval_ok if approval_at_current_head is not None else None,
|
||||
"checks_status": checks,
|
||||
"checks_required": bool(checks_required),
|
||||
"active_locks_and_leases": {
|
||||
"author_lock": bool(active_author_lock) if active_author_lock is not None else None,
|
||||
"reviewer_lease": bool(active_reviewer_lease) if active_reviewer_lease is not None else None,
|
||||
@@ -258,21 +439,41 @@ def assess_pr_sync_status(
|
||||
result["recommended_next_action"] = ACTION_BLOCKED
|
||||
return result
|
||||
|
||||
# ── Checks gate for merge_now ────────────────────────────────────────
|
||||
if checks_required and checks not in ("success", "passed", "ok", "none", "skipped", "not_required"):
|
||||
if checks in ("pending", "running", "queued"):
|
||||
# ── Checks gate for merge_now (#751) ─────────────────────────────────
|
||||
# ``checks_required`` is derived from the live branch-protection policy by
|
||||
# the production caller. When protection does not require status checks,
|
||||
# head contexts cannot gate the merge and this whole gate is skipped.
|
||||
if not checks_required:
|
||||
reasons.append(
|
||||
"live branch protection does not require status checks; head check "
|
||||
f"state ({checks}) does not gate merge"
|
||||
)
|
||||
elif checks not in _CHECKS_OK:
|
||||
if checks in _CTX_PENDING:
|
||||
reasons.append(f"required checks are not finished (status={checks})")
|
||||
result["recommended_next_action"] = ACTION_BLOCKED
|
||||
return result
|
||||
if checks in ("failure", "failed", "error", "cancelled"):
|
||||
elif checks in _CTX_FAILURE:
|
||||
reasons.append(f"required checks failed (status={checks})")
|
||||
result["recommended_next_action"] = ACTION_BLOCKED
|
||||
return result
|
||||
# unknown — fail closed when checks_required
|
||||
if checks == "unknown":
|
||||
elif checks == CHECKS_MISSING_REQUIRED:
|
||||
reasons.append(
|
||||
"branch protection configures required status context(s) but no "
|
||||
"matching status result exists at the current head (fail closed)"
|
||||
)
|
||||
elif checks == CHECKS_NONE:
|
||||
reasons.append(
|
||||
"branch protection requires status checks but no status context "
|
||||
"was produced for the current head (fail closed); an empty "
|
||||
"status collection is not executing CI"
|
||||
)
|
||||
elif checks == CHECKS_UNKNOWN:
|
||||
reasons.append("checks status unknown (fail closed)")
|
||||
result["recommended_next_action"] = ACTION_BLOCKED
|
||||
return result
|
||||
else:
|
||||
# Unrecognized vocabulary must never fall through to merge_now.
|
||||
reasons.append(
|
||||
f"unrecognized checks status '{checks}' cannot prove required "
|
||||
"checks passed (fail closed)"
|
||||
)
|
||||
result["recommended_next_action"] = ACTION_BLOCKED
|
||||
return result
|
||||
|
||||
# ── Ready to merge without update ────────────────────────────────────
|
||||
# Includes: current with approval; outdated when update is NOT required.
|
||||
|
||||
Reference in New Issue
Block a user