`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]>
850 lines
33 KiB
Python
850 lines
33 KiB
Python
"""PR synchronization and conflict-remediation lifecycle (#PR-SYNC).
|
|
|
|
Pure assessment helpers for:
|
|
|
|
* ``gitea_assess_pr_sync_status`` — recommend the next sanctioned action for an
|
|
open PR when the base branch advances, approvals go stale, or conflicts appear.
|
|
* ``gitea_update_pr_branch_by_merge`` preflight — author-only, fail-closed pin
|
|
of both PR head and live base head; never rebase/force-push.
|
|
|
|
All recommendation logic is hermetic (no network) so unit tests cover every
|
|
acceptance path without Gitea credentials.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Any
|
|
|
|
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
|
|
|
|
# Recommended next actions (controller / skill vocabulary).
|
|
ACTION_MERGE_NOW = "merge_now"
|
|
ACTION_UPDATE_BRANCH_BY_MERGE = "update_branch_by_merge"
|
|
ACTION_AUTHOR_CONFLICT_REMEDIATION = "author_conflict_remediation"
|
|
ACTION_FRESH_REVIEW_REQUIRED = "fresh_review_required"
|
|
ACTION_BLOCKED = "blocked"
|
|
|
|
_VALID_ACTIONS = frozenset({
|
|
ACTION_MERGE_NOW,
|
|
ACTION_UPDATE_BRANCH_BY_MERGE,
|
|
ACTION_AUTHOR_CONFLICT_REMEDIATION,
|
|
ACTION_FRESH_REVIEW_REQUIRED,
|
|
ACTION_BLOCKED,
|
|
})
|
|
|
|
# Author-only mutation; reviewer/merger must never update author branches.
|
|
_AUTHOR_UPDATE_ROLES = frozenset({"author"})
|
|
_DENIED_UPDATE_ROLES = frozenset({"reviewer", "merger", "reconciler", "mixed", "limited"})
|
|
|
|
# Allowed Gitea update style — merge only (never rebase).
|
|
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()
|
|
if not text:
|
|
return None
|
|
return text if _FULL_SHA.match(text) else None
|
|
|
|
|
|
def _normalize_role(role: str | None) -> str:
|
|
return (role or "").strip().lower() or "unknown"
|
|
|
|
|
|
def assess_pr_sync_status(
|
|
*,
|
|
host: str | None = None,
|
|
org: str | None = None,
|
|
repo: str | None = None,
|
|
pr_number: int,
|
|
pr_state: str | None = None,
|
|
source_branch: str | None = None,
|
|
pr_head_sha: str | None = None,
|
|
base_head_sha: str | None = None,
|
|
commits_behind: int | None = None,
|
|
mergeable: bool | None = None,
|
|
has_conflicts: bool | None = None,
|
|
branch_protection_requires_current_base: bool | None = None,
|
|
approval_at_current_head: bool | None = None,
|
|
checks_status: str | None = None,
|
|
active_author_lock: bool | None = None,
|
|
active_reviewer_lease: bool | None = None,
|
|
active_merger_lease: bool | None = None,
|
|
prepared_verdict_head_sha: str | None = None,
|
|
checks_required: bool = True,
|
|
) -> dict[str, Any]:
|
|
"""Recommend the next sanctioned PR lifecycle action.
|
|
|
|
Decision order (fail closed):
|
|
|
|
1. Incomplete identity / open state / head SHAs → ``blocked``
|
|
2. Conflicts (or mergeable false with conflict signal) →
|
|
``author_conflict_remediation``
|
|
3. Head changed after approval / prepared verdict for former head →
|
|
``fresh_review_required`` (unless conflicts already routed author work)
|
|
4. Behind live base + branch protection requires current base +
|
|
auto-mergeable → ``update_branch_by_merge``
|
|
5. Approval at current head + mergeable + checks ok (+ update not
|
|
required) → ``merge_now``
|
|
6. Otherwise ``blocked`` with structured reasons
|
|
"""
|
|
reasons: list[str] = []
|
|
pr_head = _normalize_sha(pr_head_sha)
|
|
base_head = _normalize_sha(base_head_sha)
|
|
prepared_head = _normalize_sha(prepared_verdict_head_sha)
|
|
state = (pr_state or "").strip().lower()
|
|
checks = (checks_status or "unknown").strip().lower()
|
|
behind = commits_behind if isinstance(commits_behind, int) and commits_behind >= 0 else None
|
|
requires_current = bool(branch_protection_requires_current_base)
|
|
approval_ok = bool(approval_at_current_head)
|
|
|
|
# Derive conflict when caller only supplies mergeable=False.
|
|
if has_conflicts is None and mergeable is False:
|
|
has_conflicts = True
|
|
conflicts = bool(has_conflicts) if has_conflicts is not None else None
|
|
|
|
result: dict[str, Any] = {
|
|
"host": (host or "").strip() or None,
|
|
"org": (org or "").strip() or None,
|
|
"repo": (repo or "").strip() or None,
|
|
"pr_number": pr_number,
|
|
"pr_state": state or None,
|
|
"source_branch": (source_branch or "").strip() or None,
|
|
"pr_head_sha": pr_head,
|
|
"base_head_sha": base_head,
|
|
"commits_behind": behind,
|
|
"mergeable": mergeable,
|
|
"has_conflicts": conflicts,
|
|
"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,
|
|
"merger_lease": bool(active_merger_lease) if active_merger_lease is not None else None,
|
|
},
|
|
"prepared_verdict_head_sha": prepared_head,
|
|
"recommended_next_action": ACTION_BLOCKED,
|
|
"approval_valid_for_merge": False,
|
|
"stale_approval": False,
|
|
"stale_prepared_verdict": False,
|
|
"reasons": reasons,
|
|
"success": True,
|
|
"performed": False,
|
|
}
|
|
|
|
if not isinstance(pr_number, int) or pr_number <= 0:
|
|
reasons.append("pr_number must be a positive integer (fail closed)")
|
|
return result
|
|
|
|
if state and state not in ("open",):
|
|
reasons.append(f"PR is not open (state={state}); cannot synchronize or merge")
|
|
result["recommended_next_action"] = ACTION_BLOCKED
|
|
return result
|
|
if not state:
|
|
reasons.append("PR state missing (fail closed)")
|
|
return result
|
|
|
|
if pr_head is None:
|
|
reasons.append(
|
|
"exact PR head SHA missing or not a full 40-char hex SHA (fail closed)"
|
|
)
|
|
if base_head is None:
|
|
reasons.append(
|
|
"exact live base head SHA missing or not a full 40-char hex SHA (fail closed)"
|
|
)
|
|
if behind is None:
|
|
reasons.append("commits_behind missing or invalid (fail closed)")
|
|
if mergeable is None:
|
|
reasons.append("mergeable signal missing (fail closed)")
|
|
if approval_at_current_head is None:
|
|
reasons.append("approval_at_current_head missing (fail closed)")
|
|
if branch_protection_requires_current_base is None:
|
|
reasons.append(
|
|
"branch_protection_requires_current_base missing (fail closed)"
|
|
)
|
|
|
|
if reasons:
|
|
result["recommended_next_action"] = ACTION_BLOCKED
|
|
return result
|
|
|
|
# Stale prepared verdict / approval across heads.
|
|
stale_prepared = bool(prepared_head and prepared_head != pr_head)
|
|
result["stale_prepared_verdict"] = stale_prepared
|
|
stale_approval = not approval_ok
|
|
result["stale_approval"] = stale_approval
|
|
result["approval_valid_for_merge"] = bool(approval_ok and not stale_prepared)
|
|
|
|
# ── Conflicts: author remediation in dedicated worktree ──────────────
|
|
if conflicts is True or mergeable is False:
|
|
if conflicts is True or mergeable is False:
|
|
# Distinguish pure non-mergeable without explicit conflict flag
|
|
# still routes author remediation (Gitea cannot auto-update).
|
|
reasons.append(
|
|
f"PR #{pr_number} has conflicts or is not mergeable at head "
|
|
f"{pr_head}; route author conflict remediation in the existing "
|
|
"issue/PR worktree under branches/ (never force-push or rebase)"
|
|
)
|
|
if stale_approval:
|
|
reasons.append(
|
|
"any approval at a former head is invalid; after remediation "
|
|
"require a fresh independent review at the new exact head"
|
|
)
|
|
result["recommended_next_action"] = ACTION_AUTHOR_CONFLICT_REMEDIATION
|
|
result["approval_valid_for_merge"] = False
|
|
return result
|
|
|
|
# ── Stale approval / prepared verdict at former head ─────────────────
|
|
if not approval_ok or stale_prepared:
|
|
if behind and behind > 0 and requires_current and mergeable is True:
|
|
# Must update first; update will also invalidate approval.
|
|
reasons.append(
|
|
f"PR is {behind} commit(s) behind live base and branch protection "
|
|
"requires current base; automatic merge-from-base is available"
|
|
)
|
|
reasons.append(
|
|
"approval is not valid at the current head (or prepared verdict "
|
|
"is head-scoped to a former SHA); after update require fresh review"
|
|
)
|
|
result["recommended_next_action"] = ACTION_UPDATE_BRANCH_BY_MERGE
|
|
result["approval_valid_for_merge"] = False
|
|
return result
|
|
reasons.append(
|
|
"approval is not valid at the exact current PR head "
|
|
f"({pr_head}); never reuse a former-head approval for merge"
|
|
)
|
|
if stale_prepared:
|
|
reasons.append(
|
|
f"prepared verdict pinned to former head {prepared_head} cannot "
|
|
f"authorize review/merge at current head {pr_head}"
|
|
)
|
|
if active_reviewer_lease:
|
|
reasons.append(
|
|
"active reviewer lease must be released or superseded for the "
|
|
"new head before a fresh independent review"
|
|
)
|
|
if active_merger_lease:
|
|
reasons.append(
|
|
"active merger lease cannot cross heads; release or re-acquire "
|
|
"at the new exact head"
|
|
)
|
|
result["recommended_next_action"] = ACTION_FRESH_REVIEW_REQUIRED
|
|
result["approval_valid_for_merge"] = False
|
|
return result
|
|
|
|
# ── Outdated + protection requires current base, auto-updatable ──────
|
|
if behind and behind > 0 and requires_current:
|
|
if mergeable is True and conflicts is not True:
|
|
reasons.append(
|
|
f"PR is {behind} commit(s) behind live base {base_head}; "
|
|
"branch protection requires the PR branch to contain current base; "
|
|
"Gitea can merge base into the PR branch without conflicts"
|
|
)
|
|
reasons.append(
|
|
"route author-only update_branch_by_merge; never rebase or force-push; "
|
|
"new head invalidates prior approval and requires fresh independent review"
|
|
)
|
|
result["recommended_next_action"] = ACTION_UPDATE_BRANCH_BY_MERGE
|
|
# Approval today is at old head that will change — not mergeable yet
|
|
# for final merge until re-reviewed, but assess marks update path.
|
|
result["approval_valid_for_merge"] = False
|
|
result["stale_approval"] = True # will become stale after update
|
|
return result
|
|
reasons.append(
|
|
"update required by branch protection but automatic merge is not available"
|
|
)
|
|
result["recommended_next_action"] = ACTION_BLOCKED
|
|
return result
|
|
|
|
# ── 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})")
|
|
elif checks in _CTX_FAILURE:
|
|
reasons.append(f"required checks failed (status={checks})")
|
|
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)")
|
|
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.
|
|
if approval_ok and mergeable is True and conflicts is not True:
|
|
if behind and behind > 0 and not requires_current:
|
|
reasons.append(
|
|
f"PR is {behind} commit(s) behind live base but branch protection "
|
|
"does not require the latest base; preserve the approved head"
|
|
)
|
|
else:
|
|
reasons.append(
|
|
"PR has a valid approval at the exact current head, is conflict-free "
|
|
"and mergeable; do not update the branch unnecessarily"
|
|
)
|
|
reasons.append("route directly to the sanctioned merger workflow (merge_now)")
|
|
result["recommended_next_action"] = ACTION_MERGE_NOW
|
|
result["approval_valid_for_merge"] = True
|
|
return result
|
|
|
|
reasons.append("no sanctioned next action matched the observed PR state (fail closed)")
|
|
result["recommended_next_action"] = ACTION_BLOCKED
|
|
return result
|
|
|
|
|
|
def assess_update_pr_branch_preflight(
|
|
*,
|
|
role_kind: str | None,
|
|
expected_pr_head_sha: str | None,
|
|
live_pr_head_sha: str | None,
|
|
expected_base_head_sha: str | None,
|
|
live_base_head_sha: str | None,
|
|
has_conflicts: bool | None = None,
|
|
mergeable: bool | None = None,
|
|
style: str | None = UPDATE_STYLE_MERGE,
|
|
has_author_lock: bool | None = None,
|
|
worktree_path: str | None = None,
|
|
worktree_owns_source_branch: bool | None = None,
|
|
control_checkout_clean: bool | None = None,
|
|
master_parity_ok: bool | None = None,
|
|
force_push: bool = False,
|
|
rebase: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Author-only preflight for update-by-merge; fail closed on any race.
|
|
|
|
When conflicts exist, returns a structured author-remediation handoff and
|
|
sets ``mutation_allowed=False`` so no partial remote update is performed.
|
|
"""
|
|
reasons: list[str] = []
|
|
role = _normalize_role(role_kind)
|
|
exp_pr = _normalize_sha(expected_pr_head_sha)
|
|
live_pr = _normalize_sha(live_pr_head_sha)
|
|
exp_base = _normalize_sha(expected_base_head_sha)
|
|
live_base = _normalize_sha(live_base_head_sha)
|
|
style_norm = (style or "").strip().lower() or UPDATE_STYLE_MERGE
|
|
|
|
conflicts = has_conflicts
|
|
if conflicts is None and mergeable is False:
|
|
conflicts = True
|
|
|
|
result: dict[str, Any] = {
|
|
"mutation_allowed": False,
|
|
"performed": False,
|
|
"style": style_norm,
|
|
"role_kind": role,
|
|
"expected_pr_head_sha": exp_pr,
|
|
"live_pr_head_sha": live_pr,
|
|
"expected_base_head_sha": exp_base,
|
|
"live_base_head_sha": live_base,
|
|
"head_race": False,
|
|
"base_race": False,
|
|
"has_conflicts": conflicts,
|
|
"author_remediation_handoff": False,
|
|
"approval_invalidated_for_former_head": False,
|
|
"force_push": bool(force_push),
|
|
"rebase": bool(rebase),
|
|
"reasons": reasons,
|
|
"success": True,
|
|
}
|
|
|
|
# Role gate — author only.
|
|
if role not in _AUTHOR_UPDATE_ROLES:
|
|
reasons.append(
|
|
f"role '{role}' cannot update an author PR branch "
|
|
"(author-only; reviewer/merger denial)"
|
|
)
|
|
return result
|
|
|
|
if force_push:
|
|
reasons.append("force-push is forbidden for PR branch update (fail closed)")
|
|
return result
|
|
if rebase or style_norm in _FORBIDDEN_UPDATE_STYLES:
|
|
reasons.append(
|
|
f"update style '{style_norm}' forbidden; only style=merge is allowed "
|
|
"(never rebase)"
|
|
)
|
|
return result
|
|
if style_norm != UPDATE_STYLE_MERGE:
|
|
reasons.append(
|
|
f"unknown update style '{style_norm}'; expected '{UPDATE_STYLE_MERGE}'"
|
|
)
|
|
return result
|
|
|
|
if not exp_pr or not live_pr:
|
|
reasons.append(
|
|
"expected and live PR head SHAs must be full 40-char hex (fail closed)"
|
|
)
|
|
if not exp_base or not live_base:
|
|
reasons.append(
|
|
"expected and live base head SHAs must be full 40-char hex (fail closed)"
|
|
)
|
|
if reasons:
|
|
return result
|
|
|
|
if exp_pr != live_pr:
|
|
result["head_race"] = True
|
|
reasons.append(
|
|
f"PR head race: expected {exp_pr} but live is {live_pr} "
|
|
"(fail closed; no partial mutation)"
|
|
)
|
|
return result
|
|
if exp_base != live_base:
|
|
result["base_race"] = True
|
|
reasons.append(
|
|
f"base head race: expected {exp_base} but live is {live_base} "
|
|
"(fail closed; no partial mutation)"
|
|
)
|
|
return result
|
|
|
|
if has_author_lock is not True:
|
|
reasons.append(
|
|
"existing issue/PR lock required before update_branch_by_merge (fail closed)"
|
|
)
|
|
wt = (worktree_path or "").strip()
|
|
if not wt:
|
|
reasons.append("existing PR worktree path required under branches/ (fail closed)")
|
|
else:
|
|
norm = wt.replace("\\", "/")
|
|
if "/branches/" not in norm and not norm.startswith("branches/"):
|
|
reasons.append(
|
|
f"worktree_path '{wt}' must be under branches/ (fail closed)"
|
|
)
|
|
if worktree_owns_source_branch is False:
|
|
reasons.append(
|
|
"worktree does not own the PR source branch (fail closed)"
|
|
)
|
|
if control_checkout_clean is False:
|
|
reasons.append("control checkout is not clean (fail closed)")
|
|
if master_parity_ok is False:
|
|
reasons.append("runtime/master parity failed (fail closed)")
|
|
|
|
if conflicts is True:
|
|
result["author_remediation_handoff"] = True
|
|
reasons.append(
|
|
"conflicts present: return structured author-remediation handoff "
|
|
"without creating a partial remote update"
|
|
)
|
|
reasons.append(
|
|
"resolve conflicts explicitly in the existing dedicated worktree, "
|
|
"run focused and regression tests, commit/push via sanctioned author "
|
|
"workflow, then route the new exact head to independent review"
|
|
)
|
|
return result
|
|
|
|
if mergeable is False and conflicts is not False:
|
|
result["author_remediation_handoff"] = True
|
|
reasons.append(
|
|
"PR is not mergeable; refuse automatic update and hand off to "
|
|
"author conflict remediation (fail closed)"
|
|
)
|
|
return result
|
|
|
|
if reasons:
|
|
return result
|
|
|
|
result["mutation_allowed"] = True
|
|
reasons.append(
|
|
"preflight passed: author may merge live base into the PR branch via "
|
|
"native MCP update (style=merge only)"
|
|
)
|
|
return result
|
|
|
|
|
|
def assess_post_update_head_transition(
|
|
*,
|
|
former_pr_head_sha: str | None,
|
|
new_pr_head_sha: str | None,
|
|
former_approval_head_sha: str | None = None,
|
|
former_reviewer_lease_head_sha: str | None = None,
|
|
former_merger_lease_head_sha: str | None = None,
|
|
prepared_verdict_head_sha: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""After a successful update-by-merge, invalidate cross-head artifacts.
|
|
|
|
The new head never inherits approval, reviewer/merger leases, or prepared
|
|
verdicts from the former head.
|
|
"""
|
|
former = _normalize_sha(former_pr_head_sha)
|
|
new = _normalize_sha(new_pr_head_sha)
|
|
reasons: list[str] = []
|
|
result: dict[str, Any] = {
|
|
"former_pr_head_sha": former,
|
|
"new_pr_head_sha": new,
|
|
"head_changed": bool(former and new and former != new),
|
|
"approval_invalidated": False,
|
|
"reviewer_lease_superseded": False,
|
|
"merger_lease_superseded": False,
|
|
"prepared_verdict_invalidated": False,
|
|
"recommended_next_action": ACTION_BLOCKED,
|
|
"reasons": reasons,
|
|
"success": True,
|
|
}
|
|
|
|
if not former or not new:
|
|
reasons.append("former and new PR head SHAs required (fail closed)")
|
|
return result
|
|
if former == new:
|
|
reasons.append(
|
|
"PR head unchanged after update; unexpected for merge-from-base"
|
|
)
|
|
result["recommended_next_action"] = ACTION_BLOCKED
|
|
return result
|
|
|
|
result["head_changed"] = True
|
|
# Approval at former head is always void at new head.
|
|
former_approval = _normalize_sha(former_approval_head_sha) or former
|
|
if former_approval != new:
|
|
result["approval_invalidated"] = True
|
|
reasons.append(
|
|
f"approval for former head {former_approval} is invalid at new head "
|
|
f"{new}; never preserve approval across a head change"
|
|
)
|
|
|
|
rev_lease = _normalize_sha(former_reviewer_lease_head_sha)
|
|
if rev_lease and rev_lease != new:
|
|
result["reviewer_lease_superseded"] = True
|
|
reasons.append(
|
|
f"reviewer lease for head {rev_lease} cannot cross to {new}; "
|
|
"release or supersede before fresh review"
|
|
)
|
|
elif rev_lease is None:
|
|
# Unknown lease head still must not authorize at new head.
|
|
result["reviewer_lease_superseded"] = True
|
|
reasons.append(
|
|
"any reviewer lease scoped to the former head is superseded at the new head"
|
|
)
|
|
|
|
mer_lease = _normalize_sha(former_merger_lease_head_sha)
|
|
if mer_lease and mer_lease != new:
|
|
result["merger_lease_superseded"] = True
|
|
reasons.append(
|
|
f"merger lease for head {mer_lease} cannot cross to {new}"
|
|
)
|
|
else:
|
|
result["merger_lease_superseded"] = True
|
|
reasons.append(
|
|
"any merger lease scoped to the former head is superseded at the new head"
|
|
)
|
|
|
|
prepared = _normalize_sha(prepared_verdict_head_sha)
|
|
if prepared and prepared != new:
|
|
result["prepared_verdict_invalidated"] = True
|
|
reasons.append(
|
|
f"prepared verdict for head {prepared} cannot authorize the new head {new}"
|
|
)
|
|
elif prepared is None:
|
|
result["prepared_verdict_invalidated"] = True
|
|
reasons.append(
|
|
"prepared verdicts associated with the former head are invalidated"
|
|
)
|
|
|
|
result["recommended_next_action"] = ACTION_FRESH_REVIEW_REQUIRED
|
|
reasons.append(
|
|
"route the new exact head to independent review "
|
|
f"({ACTION_FRESH_REVIEW_REQUIRED})"
|
|
)
|
|
return result
|
|
|
|
|
|
def assess_sequential_queue_step(
|
|
*,
|
|
just_merged: bool,
|
|
master_refreshed: bool,
|
|
next_pr_number: int | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Controller sequential processing: reassess only after merge + master refresh."""
|
|
reasons: list[str] = []
|
|
if just_merged and not master_refreshed:
|
|
reasons.append(
|
|
"master must be refreshed after each merge before reassessing the next PR "
|
|
"(do not update every PR simultaneously)"
|
|
)
|
|
return {
|
|
"reassess_allowed": False,
|
|
"process_next": False,
|
|
"next_pr_number": next_pr_number,
|
|
"reasons": reasons,
|
|
"success": True,
|
|
}
|
|
if not just_merged:
|
|
reasons.append("no merge completed; sequential step does not advance")
|
|
return {
|
|
"reassess_allowed": False,
|
|
"process_next": False,
|
|
"next_pr_number": next_pr_number,
|
|
"reasons": reasons,
|
|
"success": True,
|
|
}
|
|
if next_pr_number:
|
|
reasons.append(
|
|
"merge completed and live master refreshed; reassess the next PR "
|
|
f"#{next_pr_number}"
|
|
)
|
|
else:
|
|
reasons.append(
|
|
"merge completed and live master refreshed; reassess the next PR"
|
|
)
|
|
return {
|
|
"reassess_allowed": True,
|
|
"process_next": True,
|
|
"next_pr_number": next_pr_number,
|
|
"post_merge_cleanup_handoff": True,
|
|
"reasons": reasons,
|
|
"success": True,
|
|
}
|
|
|
|
|
|
def merge_approval_usable_at_head(
|
|
*,
|
|
current_head_sha: str | None,
|
|
approved_head_sha: str | None,
|
|
) -> dict[str, Any]:
|
|
"""Stale approval cannot authorize merge (head-scoped only)."""
|
|
current = _normalize_sha(current_head_sha)
|
|
approved = _normalize_sha(approved_head_sha)
|
|
ok = bool(current and approved and current == approved)
|
|
reasons: list[str] = []
|
|
if not ok:
|
|
reasons.append(
|
|
f"stale approval: approved head '{approved or '(none)'}' does not "
|
|
f"match current head '{current or '(none)'}'; cannot authorize merge"
|
|
)
|
|
return {
|
|
"approval_usable": ok,
|
|
"current_head_sha": current,
|
|
"approved_head_sha": approved,
|
|
"reasons": reasons,
|
|
}
|
|
|
|
|
|
def lease_usable_at_head(
|
|
*,
|
|
lease_kind: str,
|
|
lease_head_sha: str | None,
|
|
current_head_sha: str | None,
|
|
) -> dict[str, Any]:
|
|
"""Reviewer/merger leases cannot cross heads."""
|
|
current = _normalize_sha(current_head_sha)
|
|
lease_head = _normalize_sha(lease_head_sha)
|
|
kind = (lease_kind or "").strip().lower() or "unknown"
|
|
ok = bool(current and lease_head and current == lease_head)
|
|
reasons: list[str] = []
|
|
if not ok:
|
|
reasons.append(
|
|
f"stale {kind} lease: lease head '{lease_head or '(none)'}' cannot "
|
|
f"authorize work at current head '{current or '(none)'}'"
|
|
)
|
|
return {
|
|
"lease_usable": ok,
|
|
"lease_kind": kind,
|
|
"lease_head_sha": lease_head,
|
|
"current_head_sha": current,
|
|
"reasons": reasons,
|
|
}
|