Prevent foreign open-PR terminals on the durable review-decision lock from blocking fresh formal reviews. Scope correction authorization to the named prior review's PR/head, add read-only diagnosis with exact next_action and durable handoff payloads, require thread-visible correction audits, and cover the PR #688 → #692 recovery sequence in regression tests.
885 lines
34 KiB
Python
885 lines
34 KiB
Python
"""Stale / moot #332 review-decision lock detection and cleanup policy (#594/#620/#693).
|
|
|
|
#332 correctly hard-stops a reviewer session after a terminal live review
|
|
mutation. After #559 those locks are durable on disk, so they can outlive the
|
|
work they protect: when the referenced PR is already merged or closed, no
|
|
same-PR merge sequence remains, yet the durable ledger still blocks unrelated
|
|
new terminal reviews.
|
|
|
|
#620 scopes the terminal boundary by **reviewed head SHA**: a prior
|
|
REQUEST_CHANGES (or APPROVE) on head A must not block a fresh formal decision
|
|
on head B of the **same open PR**. Same-head duplicates remain fail-closed.
|
|
Open-PR lock *cleanup* remains forbidden unless the PR is truly merged/closed
|
|
(#594); new-head re-review is allowed without deleting the durable lock.
|
|
|
|
#693 scopes the terminal boundary by **PR number**: a terminal on open PR A
|
|
must not block a fresh formal decision on open PR B on the same durable
|
|
profile lock (cross-PR isolation). Correction authorization is scoped to the
|
|
named prior review's PR/head and cannot unlock a different PR.
|
|
|
|
This module is pure policy (no Gitea I/O). The MCP tool
|
|
``gitea_cleanup_stale_review_decision_lock`` fetches live PR state, calls these
|
|
helpers, and only then clears durable state when cleanup is allowed.
|
|
|
|
Manual deletion of ``~/.cache/gitea-tools/session-state/*`` is never the
|
|
canonical path.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
# Keep in sync with gitea_mcp_server._TERMINAL_REVIEW_ACTIONS.
|
|
TERMINAL_REVIEW_ACTIONS = frozenset({"approve", "request_changes"})
|
|
|
|
|
|
def normalize_head_sha(value: Any) -> str | None:
|
|
"""Normalize a git SHA for equality comparison; None if empty/invalid."""
|
|
if value is None:
|
|
return None
|
|
text = str(value).strip().lower()
|
|
if not text:
|
|
return None
|
|
return text
|
|
|
|
|
|
def heads_equal(a: Any, b: Any) -> bool:
|
|
na = normalize_head_sha(a)
|
|
nb = normalize_head_sha(b)
|
|
if not na or not nb:
|
|
return False
|
|
return na == nb
|
|
|
|
|
|
def mutation_head_sha(mutation: dict | None, lock: dict | None = None) -> str | None:
|
|
"""Head SHA that bounds a live mutation (#620).
|
|
|
|
Prefers fields recorded on the mutation itself. For *legacy* mutations
|
|
that predate head-scoping, falls back to the lock's
|
|
``ready_expected_head_sha`` only when that ready binding is for the same
|
|
PR number (the frozen durable PR #619-style ledger).
|
|
"""
|
|
if not isinstance(mutation, dict):
|
|
return None
|
|
for key in ("head_sha", "expected_head_sha", "reviewed_head_sha"):
|
|
head = normalize_head_sha(mutation.get(key))
|
|
if head:
|
|
return head
|
|
if not isinstance(lock, dict):
|
|
return None
|
|
if lock.get("ready_pr_number") != mutation.get("pr_number"):
|
|
return None
|
|
return normalize_head_sha(lock.get("ready_expected_head_sha"))
|
|
|
|
|
|
def last_terminal_mutation(lock: dict | None) -> dict | None:
|
|
"""Return the last terminal live mutation on *lock*, or None."""
|
|
if not lock:
|
|
return None
|
|
terminals = [
|
|
m
|
|
for m in (lock.get("live_mutations") or [])
|
|
if isinstance(m, dict) and m.get("action") in TERMINAL_REVIEW_ACTIONS
|
|
]
|
|
return terminals[-1] if terminals else None
|
|
|
|
|
|
def correction_applies(
|
|
lock: dict | None,
|
|
*,
|
|
pr_number: int | None = None,
|
|
expected_head_sha: str | None = None,
|
|
) -> bool:
|
|
"""True when operator correction is active and scoped to the target (#693).
|
|
|
|
Global ``correction_authorized`` alone is not enough: correction must name
|
|
the same PR (and head when recorded) as the decision being re-opened.
|
|
Missing scope fields on legacy locks fail closed (do not apply).
|
|
"""
|
|
if not lock or not lock.get("correction_authorized"):
|
|
return False
|
|
corr_pr = lock.get("correction_pr_number")
|
|
if corr_pr is None:
|
|
# Legacy unscoped correction — refuse as generic unlock (#693).
|
|
return False
|
|
if pr_number is not None and int(corr_pr) != int(pr_number):
|
|
return False
|
|
corr_head = normalize_head_sha(lock.get("correction_head_sha"))
|
|
target = normalize_head_sha(expected_head_sha)
|
|
if corr_head and target and not heads_equal(corr_head, target):
|
|
return False
|
|
return True
|
|
|
|
|
|
def prior_live_mutations_block_boundary(
|
|
lock: dict | None,
|
|
*,
|
|
pr_number: int,
|
|
expected_head_sha: str | None,
|
|
) -> bool:
|
|
"""True when prior live mutations block a new decision for PR+head (#620/#693).
|
|
|
|
Historical terminals on a **different head of the same PR** do not block.
|
|
Terminals on a **different PR** do not block (#693 cross-PR isolation).
|
|
Same PR + same head (or same PR without a comparable head SHA) blocks
|
|
unless a scoped correction applies.
|
|
|
|
Head resolution uses ``mutation_head_sha(m, lock)`` so pre-#620 ledgers
|
|
that only stored ``ready_expected_head_sha`` still compare correctly
|
|
(PR #619-style).
|
|
"""
|
|
if not lock:
|
|
return False
|
|
if correction_applies(
|
|
lock, pr_number=pr_number, expected_head_sha=expected_head_sha
|
|
):
|
|
return False
|
|
prior = list(lock.get("live_mutations") or [])
|
|
if not prior:
|
|
return False
|
|
target = normalize_head_sha(expected_head_sha)
|
|
for m in prior:
|
|
if not isinstance(m, dict):
|
|
return True
|
|
m_pr = m.get("pr_number")
|
|
if m_pr is None:
|
|
return True # fail closed — unscoped mutation
|
|
if int(m_pr) != int(pr_number):
|
|
# #693: foreign-PR history on the shared durable lock does not block.
|
|
continue
|
|
m_head = mutation_head_sha(m, lock)
|
|
if (
|
|
target
|
|
and m_head
|
|
and not heads_equal(m_head, target)
|
|
):
|
|
# Same open PR, different reviewed head — historical boundary only.
|
|
continue
|
|
return True
|
|
return False
|
|
|
|
|
|
def terminal_boundary_allows_fresh_decision(
|
|
lock: dict | None,
|
|
*,
|
|
pr_number: int,
|
|
expected_head_sha: str | None,
|
|
operation: str,
|
|
) -> bool:
|
|
"""Whether #332 hard-stop should yield for a new PR+head boundary (#620/#693).
|
|
|
|
For ``mark_ready`` / ``review`` / ``resume``:
|
|
|
|
* **same PR, different head** — allowed (#620)
|
|
* **different PR than last terminal** — allowed (#693 cross-PR isolation)
|
|
* **same PR, same head** — not allowed (unless scoped correction)
|
|
|
|
Merge is never reopened by head change or cross-PR isolation (approved
|
|
head merge path is separate).
|
|
"""
|
|
if operation not in ("mark_ready", "review", "resume"):
|
|
return False
|
|
if not lock:
|
|
return False
|
|
if correction_applies(
|
|
lock, pr_number=pr_number, expected_head_sha=expected_head_sha
|
|
):
|
|
return True
|
|
last = last_terminal_mutation(lock)
|
|
if last is None:
|
|
return True
|
|
last_pr = last.get("pr_number")
|
|
if last_pr is None:
|
|
return False
|
|
if int(last_pr) != int(pr_number):
|
|
# #693: last terminal belongs to another PR — do not hard-stop this PR.
|
|
return True
|
|
locked_head = mutation_head_sha(last, lock)
|
|
target = normalize_head_sha(expected_head_sha)
|
|
if not locked_head or not target:
|
|
return False
|
|
return not heads_equal(locked_head, target)
|
|
|
|
|
|
def backfill_terminal_heads_from_ready(lock: dict) -> dict:
|
|
"""Stamp head_sha onto legacy terminal mutations before overwriting ready_*.
|
|
|
|
Called when marking a fresh decision on a new head so historical rows keep
|
|
their original boundary after ``ready_expected_head_sha`` advances (#620).
|
|
"""
|
|
if not isinstance(lock, dict):
|
|
return lock
|
|
ready_pr = lock.get("ready_pr_number")
|
|
ready_head = normalize_head_sha(lock.get("ready_expected_head_sha"))
|
|
if ready_pr is None or not ready_head:
|
|
return lock
|
|
mutations = list(lock.get("live_mutations") or [])
|
|
changed = False
|
|
for m in mutations:
|
|
if not isinstance(m, dict):
|
|
continue
|
|
if m.get("action") not in TERMINAL_REVIEW_ACTIONS:
|
|
continue
|
|
if m.get("pr_number") != ready_pr:
|
|
continue
|
|
if mutation_head_sha(m):
|
|
continue
|
|
m["head_sha"] = ready_head
|
|
changed = True
|
|
if changed:
|
|
lock["live_mutations"] = mutations
|
|
return lock
|
|
|
|
|
|
def classify_pr_live_state(pr_live: dict | None) -> dict[str, Any]:
|
|
"""Normalize a Gitea PR payload into merge/closed flags."""
|
|
pr = pr_live or {}
|
|
merged = bool(pr.get("merged") or pr.get("merged_at"))
|
|
state = (pr.get("state") or "").strip().lower() or None
|
|
closed = state == "closed"
|
|
head = pr.get("head") if isinstance(pr.get("head"), dict) else {}
|
|
head_sha = normalize_head_sha(
|
|
pr.get("head_sha") or pr.get("head_commit_sha") or head.get("sha")
|
|
)
|
|
return {
|
|
"pr_state": state,
|
|
"pr_merged": merged,
|
|
"pr_merged_or_closed": merged or closed,
|
|
"merge_commit_sha": pr.get("merge_commit_sha")
|
|
or pr.get("merged_commit_sha"),
|
|
"current_pr_head_sha": head_sha,
|
|
}
|
|
|
|
|
|
def lock_summary(lock: dict | None) -> dict[str, Any] | None:
|
|
"""LLM-safe summary of a decision lock (no secrets)."""
|
|
if lock is None:
|
|
return None
|
|
last = last_terminal_mutation(lock)
|
|
mutations = list(lock.get("live_mutations") or [])
|
|
last_head = mutation_head_sha(last, lock) if last else None
|
|
return {
|
|
"task": lock.get("task"),
|
|
"remote": lock.get("remote"),
|
|
"org": lock.get("org") or lock.get("ready_org"),
|
|
"repo": lock.get("repo") or lock.get("ready_repo"),
|
|
"profile_identity": lock.get("profile_identity")
|
|
or lock.get("session_profile_lock")
|
|
or lock.get("session_profile"),
|
|
"session_profile": lock.get("session_profile"),
|
|
"ready_pr_number": lock.get("ready_pr_number"),
|
|
"ready_action": lock.get("ready_action"),
|
|
"ready_expected_head_sha": normalize_head_sha(
|
|
lock.get("ready_expected_head_sha")
|
|
),
|
|
"live_mutations_count": len(mutations),
|
|
"last_terminal": (
|
|
{
|
|
"pr_number": last.get("pr_number"),
|
|
"action": last.get("action"),
|
|
"review_id": last.get("review_id"),
|
|
"head_sha": last_head,
|
|
}
|
|
if last
|
|
else None
|
|
),
|
|
"locked_head_sha": last_head,
|
|
"correction_authorized": bool(lock.get("correction_authorized")),
|
|
"updated_at": lock.get("updated_at") or lock.get("recorded_at"),
|
|
}
|
|
|
|
|
|
def assess_stale_review_decision_lock(
|
|
lock: dict | None,
|
|
*,
|
|
pr_live: dict | None = None,
|
|
pr_lookup_error: str | None = None,
|
|
active_profile_identity: str | None = None,
|
|
current_pr_head_sha: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Assess whether a durable review-decision lock is stale/moot (#594/#620).
|
|
|
|
*pr_live* must be the live Gitea payload for the **last terminal
|
|
mutation's PR**. When no lock / no terminal mutation exists, cleanup is
|
|
not applicable. When the PR is still open (or lookup fails), cleanup is
|
|
forbidden (#594). Open PR + different live head reports
|
|
``stale_by_head`` / ``fresh_review_on_current_head_allowed`` (#620)
|
|
without enabling cleanup.
|
|
"""
|
|
summary = lock_summary(lock)
|
|
result: dict[str, Any] = {
|
|
"has_lock": lock is not None,
|
|
"lock_summary": summary,
|
|
"last_terminal_pr": None,
|
|
"last_terminal_action": None,
|
|
"locked_head_sha": None,
|
|
"current_pr_head_sha": normalize_head_sha(current_pr_head_sha),
|
|
"stale_by_head": False,
|
|
"fresh_review_on_current_head_allowed": False,
|
|
"is_moot": False,
|
|
"cleanup_allowed": False,
|
|
"profile_match": True,
|
|
"pr_state": None,
|
|
"pr_merged": False,
|
|
"pr_merged_or_closed": False,
|
|
"merge_commit_sha": None,
|
|
"reasons": [],
|
|
"recovery_tool": "gitea_cleanup_stale_review_decision_lock",
|
|
}
|
|
|
|
if lock is None:
|
|
result["reasons"].append("no review decision lock present")
|
|
return result
|
|
|
|
# Profile identity gate (caller may re-check with env; pure assess still
|
|
# reports mismatch when active identity is supplied).
|
|
stored = (
|
|
(lock.get("profile_identity") or lock.get("session_profile_lock") or "")
|
|
.strip()
|
|
)
|
|
active = (active_profile_identity or "").strip()
|
|
if active and stored and active != stored:
|
|
result["profile_match"] = False
|
|
result["reasons"].append(
|
|
"review decision lock profile identity does not match active "
|
|
f"profile '{active}' (fail closed, #594)"
|
|
)
|
|
return result
|
|
|
|
last = last_terminal_mutation(lock)
|
|
if last is None:
|
|
result["reasons"].append(
|
|
"review decision lock has no terminal live mutations; nothing to clear"
|
|
)
|
|
return result
|
|
|
|
pr_number = last.get("pr_number")
|
|
action = last.get("action")
|
|
locked_head = mutation_head_sha(last, lock)
|
|
result["last_terminal_pr"] = pr_number
|
|
result["last_terminal_action"] = action
|
|
result["locked_head_sha"] = locked_head
|
|
|
|
if pr_number is None:
|
|
result["reasons"].append(
|
|
"last terminal mutation missing pr_number; refuse cleanup (fail closed)"
|
|
)
|
|
return result
|
|
|
|
if pr_lookup_error:
|
|
result["reasons"].append(
|
|
f"could not load live state for PR #{pr_number}: {pr_lookup_error} "
|
|
"(fail closed, #594)"
|
|
)
|
|
return result
|
|
|
|
if pr_live is None:
|
|
result["reasons"].append(
|
|
f"live PR state required for terminal PR #{pr_number} before cleanup "
|
|
"(fail closed, #594)"
|
|
)
|
|
return result
|
|
|
|
live = classify_pr_live_state(pr_live)
|
|
current_head = normalize_head_sha(
|
|
current_pr_head_sha or live.get("current_pr_head_sha")
|
|
)
|
|
result.update(
|
|
{
|
|
"pr_state": live["pr_state"],
|
|
"pr_merged": live["pr_merged"],
|
|
"pr_merged_or_closed": live["pr_merged_or_closed"],
|
|
"merge_commit_sha": live["merge_commit_sha"],
|
|
"current_pr_head_sha": current_head,
|
|
}
|
|
)
|
|
|
|
if not live["pr_merged_or_closed"]:
|
|
stale_by_head = bool(
|
|
locked_head and current_head and not heads_equal(locked_head, current_head)
|
|
)
|
|
result["stale_by_head"] = stale_by_head
|
|
# Fresh formal decision is allowed on the new head without cleanup (#620).
|
|
result["fresh_review_on_current_head_allowed"] = stale_by_head
|
|
if stale_by_head:
|
|
result["reasons"].append(
|
|
f"terminal {action} on PR #{pr_number} is bound to head "
|
|
f"{locked_head[:12]}…; live head is {current_head[:12]}… — "
|
|
"fresh formal review on current head is allowed without "
|
|
"cleanup (fail closed for cleanup, #620); #332 same-head "
|
|
"duplicate protection remains"
|
|
)
|
|
else:
|
|
result["reasons"].append(
|
|
f"terminal review mutation on open PR #{pr_number} is still active "
|
|
f"({action}); #332 hard-stop remains — cleanup forbidden (#594)"
|
|
)
|
|
return result
|
|
|
|
# Merged or closed: lock is moot. Same-PR merge continuation is impossible.
|
|
result["is_moot"] = True
|
|
result["cleanup_allowed"] = True
|
|
result["stale_by_head"] = False
|
|
result["fresh_review_on_current_head_allowed"] = False
|
|
result["reasons"].append(
|
|
f"terminal mutation ({action} on PR #{pr_number}) is moot: PR is "
|
|
f"{'merged' if live['pr_merged'] else 'closed'}; canonical cleanup allowed (#594)"
|
|
)
|
|
return result
|
|
|
|
|
|
def build_cleanup_audit_record(
|
|
*,
|
|
assessment: dict[str, Any],
|
|
actor_username: str | None,
|
|
profile_name: str | None,
|
|
applied: bool,
|
|
) -> dict[str, Any]:
|
|
"""Structured durable audit payload for a cleanup attempt."""
|
|
last = (assessment.get("lock_summary") or {}).get("last_terminal") or {}
|
|
return {
|
|
"event": "stale_review_decision_lock_cleanup",
|
|
"issue_ref": "#594",
|
|
"applied": bool(applied),
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"actor_username": actor_username,
|
|
"profile_name": profile_name,
|
|
"last_terminal_pr": assessment.get("last_terminal_pr") or last.get("pr_number"),
|
|
"last_terminal_action": assessment.get("last_terminal_action")
|
|
or last.get("action"),
|
|
"pr_state": assessment.get("pr_state"),
|
|
"pr_merged": assessment.get("pr_merged"),
|
|
"pr_merged_or_closed": assessment.get("pr_merged_or_closed"),
|
|
"merge_commit_sha": assessment.get("merge_commit_sha"),
|
|
"prior_live_mutations_count": (
|
|
(assessment.get("lock_summary") or {}).get("live_mutations_count")
|
|
),
|
|
"prior_profile_identity": (
|
|
(assessment.get("lock_summary") or {}).get("profile_identity")
|
|
),
|
|
"cleanup_allowed": assessment.get("cleanup_allowed"),
|
|
"is_moot": assessment.get("is_moot"),
|
|
"reasons": list(assessment.get("reasons") or []),
|
|
}
|
|
|
|
|
|
def format_cleanup_audit_comment(audit: dict[str, Any]) -> str:
|
|
"""Markdown body for a durable Gitea audit comment after cleanup."""
|
|
status = "APPLIED" if audit.get("applied") else "ASSESSED (not applied)"
|
|
lines = [
|
|
"## Stale #332 review-decision lock cleanup (#594)",
|
|
"",
|
|
f"Status: **{status}**",
|
|
"",
|
|
f"- actor: `{audit.get('actor_username')}`",
|
|
f"- profile: `{audit.get('profile_name')}`",
|
|
f"- timestamp: `{audit.get('timestamp')}`",
|
|
f"- last terminal: `{audit.get('last_terminal_action')}` on "
|
|
f"PR #{audit.get('last_terminal_pr')}",
|
|
f"- PR state: `{audit.get('pr_state')}` "
|
|
f"(merged={audit.get('pr_merged')})",
|
|
f"- merge_commit_sha: `{audit.get('merge_commit_sha')}`",
|
|
f"- prior live_mutations_count: "
|
|
f"`{audit.get('prior_live_mutations_count')}`",
|
|
f"- prior profile_identity: `{audit.get('prior_profile_identity')}`",
|
|
"",
|
|
"Manual deletion of session-state files is **not** the workflow.",
|
|
"This path only clears a lock when the referenced PR is merged/closed.",
|
|
]
|
|
return "\n".join(lines)
|
|
|
|
|
|
# --- #693 classification / recovery / correction audit -----------------------
|
|
|
|
# Deterministic classification labels for diagnostics and recovery routing.
|
|
CLASS_NO_LOCK = "no_lock"
|
|
CLASS_READY_FOR_TARGET = "ready_for_target"
|
|
CLASS_IN_PROGRESS_SAME_HEAD = "in_progress_same_head"
|
|
CLASS_TERMINAL_ACTIVE_SAME_HEAD = "terminal_active_same_head"
|
|
CLASS_STALE_SUPERSEDED_HEAD = "stale_superseded_head"
|
|
CLASS_FOREIGN_PR_TERMINAL = "foreign_pr_terminal"
|
|
CLASS_MOOT_MERGED_CLOSED = "moot_merged_closed"
|
|
CLASS_CORRECTION_ACTIVE = "correction_active"
|
|
CLASS_SESSION_OR_PROFILE_MISMATCH = "session_or_profile_mismatch"
|
|
CLASS_AMBIGUOUS = "ambiguous"
|
|
|
|
|
|
def classify_review_decision_lock(
|
|
lock: dict | None,
|
|
*,
|
|
target_pr_number: int | None = None,
|
|
target_head_sha: str | None = None,
|
|
pr_live: dict | None = None,
|
|
pr_lookup_error: str | None = None,
|
|
active_profile_identity: str | None = None,
|
|
session_blockers: list[str] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Deterministic review-decision-lock classification (#693).
|
|
|
|
Returns classification, exact next_action, owner/evidence fields, and
|
|
whether mark_final / cleanup / correction are appropriate.
|
|
"""
|
|
summary = lock_summary(lock)
|
|
target_head = normalize_head_sha(target_head_sha)
|
|
result: dict[str, Any] = {
|
|
"classification": CLASS_NO_LOCK,
|
|
"has_lock": lock is not None,
|
|
"lock_summary": summary,
|
|
"target_pr_number": target_pr_number,
|
|
"target_head_sha": target_head,
|
|
"last_terminal_pr": None,
|
|
"last_terminal_action": None,
|
|
"locked_head_sha": None,
|
|
"owner_profile_identity": (summary or {}).get("profile_identity"),
|
|
"session_profile": (summary or {}).get("session_profile"),
|
|
"updated_at": (summary or {}).get("updated_at"),
|
|
"correction_authorized": bool((lock or {}).get("correction_authorized")),
|
|
"correction_pr_number": (lock or {}).get("correction_pr_number"),
|
|
"correction_head_sha": normalize_head_sha(
|
|
(lock or {}).get("correction_head_sha")
|
|
),
|
|
"mark_final_allowed": True,
|
|
"cleanup_allowed": False,
|
|
"correction_eligible": False,
|
|
"next_action": "gitea_resolve_task_capability(task='review_pr') then mark_final",
|
|
"reasons": [],
|
|
"evidence": {},
|
|
}
|
|
|
|
if lock is None:
|
|
result["reasons"].append("no review decision lock present")
|
|
return result
|
|
|
|
session_blockers = list(session_blockers or [])
|
|
if session_blockers:
|
|
result["classification"] = CLASS_SESSION_OR_PROFILE_MISMATCH
|
|
result["mark_final_allowed"] = False
|
|
result["next_action"] = (
|
|
"reconnect matching prgs-reviewer session or wait for lock TTL; "
|
|
"do not use gitea_authorize_review_correction as unlock; "
|
|
"do not delete session-state files"
|
|
)
|
|
result["reasons"].extend(session_blockers)
|
|
return result
|
|
|
|
stored = (
|
|
(lock.get("profile_identity") or lock.get("session_profile_lock") or "")
|
|
.strip()
|
|
)
|
|
active = (active_profile_identity or "").strip()
|
|
if active and stored and active != stored:
|
|
result["classification"] = CLASS_SESSION_OR_PROFILE_MISMATCH
|
|
result["mark_final_allowed"] = False
|
|
result["next_action"] = (
|
|
f"switch to profile '{stored}' or wait for moot cleanup; "
|
|
"do not use correction authorization as a generic unlock"
|
|
)
|
|
result["reasons"].append(
|
|
f"lock profile '{stored}' does not match active '{active}'"
|
|
)
|
|
return result
|
|
|
|
last = last_terminal_mutation(lock)
|
|
if last is None:
|
|
ready_pr = lock.get("ready_pr_number")
|
|
ready_head = normalize_head_sha(lock.get("ready_expected_head_sha"))
|
|
if (
|
|
target_pr_number is not None
|
|
and ready_pr == target_pr_number
|
|
and ready_head
|
|
and target_head
|
|
and heads_equal(ready_head, target_head)
|
|
and lock.get("final_review_decision_ready")
|
|
):
|
|
result["classification"] = CLASS_IN_PROGRESS_SAME_HEAD
|
|
result["mark_final_allowed"] = True # idempotent re-mark
|
|
result["next_action"] = (
|
|
"gitea_submit_pr_review with final_review_decision_ready=true "
|
|
"for the ready PR/head"
|
|
)
|
|
result["reasons"].append(
|
|
"final decision already marked ready for this PR/head (idempotent)"
|
|
)
|
|
return result
|
|
result["classification"] = CLASS_READY_FOR_TARGET
|
|
result["next_action"] = "gitea_mark_final_review_decision then submit"
|
|
result["reasons"].append("no terminal live mutations; mark_final allowed")
|
|
return result
|
|
|
|
last_pr = last.get("pr_number")
|
|
last_action = last.get("action")
|
|
locked_head = mutation_head_sha(last, lock)
|
|
result["last_terminal_pr"] = last_pr
|
|
result["last_terminal_action"] = last_action
|
|
result["locked_head_sha"] = locked_head
|
|
result["evidence"] = {
|
|
"last_review_id": last.get("review_id"),
|
|
"live_mutations_count": len(lock.get("live_mutations") or []),
|
|
}
|
|
|
|
if correction_applies(
|
|
lock, pr_number=target_pr_number, expected_head_sha=target_head
|
|
):
|
|
result["classification"] = CLASS_CORRECTION_ACTIVE
|
|
result["mark_final_allowed"] = True
|
|
result["next_action"] = (
|
|
"gitea_mark_final_review_decision for the correction-scoped PR/head, "
|
|
"then submit once"
|
|
)
|
|
result["reasons"].append(
|
|
f"scoped correction active for PR #{lock.get('correction_pr_number')}"
|
|
)
|
|
return result
|
|
|
|
# Live state of last terminal PR when provided.
|
|
live = None
|
|
if pr_lookup_error:
|
|
result["classification"] = CLASS_AMBIGUOUS
|
|
result["mark_final_allowed"] = False
|
|
result["next_action"] = (
|
|
"retry diagnosis after live PR state is available; "
|
|
"fail closed under ambiguous evidence"
|
|
)
|
|
result["reasons"].append(f"live PR lookup failed: {pr_lookup_error}")
|
|
return result
|
|
if pr_live is not None:
|
|
live = classify_pr_live_state(pr_live)
|
|
result["evidence"]["last_terminal_pr_state"] = live.get("pr_state")
|
|
result["evidence"]["last_terminal_pr_merged"] = live.get("pr_merged")
|
|
if live.get("pr_merged_or_closed"):
|
|
result["classification"] = CLASS_MOOT_MERGED_CLOSED
|
|
result["cleanup_allowed"] = True
|
|
result["mark_final_allowed"] = target_pr_number is not None and (
|
|
int(last_pr) != int(target_pr_number)
|
|
if last_pr is not None and target_pr_number is not None
|
|
else True
|
|
)
|
|
result["next_action"] = (
|
|
"gitea_cleanup_stale_review_decision_lock(apply=true, "
|
|
f"expected_terminal_pr={last_pr}) then mark_final on the target PR"
|
|
)
|
|
result["reasons"].append(
|
|
f"terminal on PR #{last_pr} is moot (merged/closed); cleanup allowed"
|
|
)
|
|
return result
|
|
|
|
if target_pr_number is None:
|
|
result["classification"] = CLASS_AMBIGUOUS
|
|
result["mark_final_allowed"] = False
|
|
result["next_action"] = "re-run diagnosis with target_pr_number"
|
|
result["reasons"].append("target_pr_number required for open-PR classification")
|
|
return result
|
|
|
|
if last_pr is not None and int(last_pr) != int(target_pr_number):
|
|
# Cross-PR isolation: foreign terminal is history, not a block.
|
|
result["classification"] = CLASS_FOREIGN_PR_TERMINAL
|
|
result["mark_final_allowed"] = True
|
|
result["correction_eligible"] = False # must not use correction to "unlock"
|
|
result["next_action"] = (
|
|
f"gitea_mark_final_review_decision(pr_number={target_pr_number}, ...) "
|
|
f"— foreign terminal on PR #{last_pr} does not block (#693); "
|
|
"do not call gitea_authorize_review_correction for cross-PR unlock"
|
|
)
|
|
result["reasons"].append(
|
|
f"last terminal is {last_action} on foreign open/closed PR #{last_pr}; "
|
|
f"target PR #{target_pr_number} is isolated (#693)"
|
|
)
|
|
return result
|
|
|
|
# Same PR as target.
|
|
if (
|
|
locked_head
|
|
and target_head
|
|
and not heads_equal(locked_head, target_head)
|
|
):
|
|
result["classification"] = CLASS_STALE_SUPERSEDED_HEAD
|
|
result["mark_final_allowed"] = True
|
|
result["next_action"] = (
|
|
f"gitea_mark_final_review_decision with expected_head_sha="
|
|
f"{target_head} (#620 same-PR new head)"
|
|
)
|
|
result["reasons"].append(
|
|
f"terminal {last_action} on head {locked_head[:12]}…; target head "
|
|
f"{target_head[:12]}… — fresh formal review allowed without cleanup"
|
|
)
|
|
return result
|
|
|
|
# Same PR + same head (or missing head comparison) — active terminal.
|
|
result["classification"] = CLASS_TERMINAL_ACTIVE_SAME_HEAD
|
|
result["mark_final_allowed"] = False
|
|
result["correction_eligible"] = True
|
|
result["next_action"] = (
|
|
"if the prior verdict was mistaken: gitea_authorize_review_correction "
|
|
f"with prior_review_id={last.get('review_id')}, "
|
|
f"prior_review_state={last_action}, target_pr_number={target_pr_number}, "
|
|
"operator_authorized=true, then re-mark once; "
|
|
"if the prior verdict is correct: stop and hand off (do not duplicate)"
|
|
)
|
|
result["reasons"].append(
|
|
f"terminal {last_action} already recorded for PR #{target_pr_number} "
|
|
f"at this head; same-head duplicate blocked (#332/#620)"
|
|
)
|
|
return result
|
|
|
|
|
|
def build_precise_mark_final_failure(
|
|
classification: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
"""One precise recovery instruction + durable issue handoff payload (#693 AC4/8)."""
|
|
cls = classification.get("classification")
|
|
next_action = classification.get("next_action") or (
|
|
"stop and create a durable Gitea issue with lock evidence"
|
|
)
|
|
reasons = list(classification.get("reasons") or [])
|
|
reasons.append(f"exact_next_action: {next_action}")
|
|
handoff = {
|
|
"required": cls
|
|
in (
|
|
CLASS_AMBIGUOUS,
|
|
CLASS_SESSION_OR_PROFILE_MISMATCH,
|
|
CLASS_TERMINAL_ACTIVE_SAME_HEAD,
|
|
)
|
|
and not classification.get("mark_final_allowed"),
|
|
"title_hint": (
|
|
"Review-decision-lock recovery failed during formal review"
|
|
),
|
|
"classification": cls,
|
|
"exact_next_action": next_action,
|
|
"owner_profile_identity": classification.get("owner_profile_identity"),
|
|
"last_terminal_pr": classification.get("last_terminal_pr"),
|
|
"last_terminal_action": classification.get("last_terminal_action"),
|
|
"locked_head_sha": classification.get("locked_head_sha"),
|
|
"target_pr_number": classification.get("target_pr_number"),
|
|
"target_head_sha": classification.get("target_head_sha"),
|
|
"evidence": classification.get("evidence") or {},
|
|
"instruction": (
|
|
"If recovery cannot complete with the exact_next_action above, "
|
|
"create or update a durable Gitea issue with this payload and stop; "
|
|
"do not delete session-state files; do not misuse correction as unlock."
|
|
),
|
|
}
|
|
return {
|
|
"reasons": reasons,
|
|
"classification": cls,
|
|
"exact_next_action": next_action,
|
|
"durable_issue_handoff": handoff,
|
|
}
|
|
|
|
|
|
def build_correction_audit_record(
|
|
*,
|
|
prior_review_id: int | None,
|
|
prior_review_state: str | None,
|
|
target_pr_number: int | None,
|
|
target_head_sha: str | None,
|
|
reason: str | None,
|
|
actor_username: str | None,
|
|
profile_name: str | None,
|
|
authorized: bool,
|
|
) -> dict[str, Any]:
|
|
"""Structured durable audit for review-correction authorization (#693)."""
|
|
return {
|
|
"event": "review_correction_authorization",
|
|
"issue_ref": "#693",
|
|
"authorized": bool(authorized),
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"actor_username": actor_username,
|
|
"profile_name": profile_name,
|
|
"prior_review_id": prior_review_id,
|
|
"prior_review_state": prior_review_state,
|
|
"target_pr_number": target_pr_number,
|
|
"target_head_sha": normalize_head_sha(target_head_sha),
|
|
"reason": reason,
|
|
"scope": "same_pr_head_only",
|
|
}
|
|
|
|
|
|
def format_correction_audit_comment(audit: dict[str, Any]) -> str:
|
|
"""Markdown body for a thread-visible correction authorization audit."""
|
|
status = "AUTHORIZED" if audit.get("authorized") else "DENIED"
|
|
lines = [
|
|
"## Review correction authorization audit (#693)",
|
|
"",
|
|
f"Status: **{status}**",
|
|
"",
|
|
f"- actor: `{audit.get('actor_username')}`",
|
|
f"- profile: `{audit.get('profile_name')}`",
|
|
f"- timestamp: `{audit.get('timestamp')}`",
|
|
f"- prior_review_id: `{audit.get('prior_review_id')}`",
|
|
f"- prior_review_state: `{audit.get('prior_review_state')}`",
|
|
f"- target_pr: `#{audit.get('target_pr_number')}`",
|
|
f"- target_head_sha: `{audit.get('target_head_sha')}`",
|
|
f"- reason: {audit.get('reason')}",
|
|
f"- scope: `{audit.get('scope')}` (cannot unlock a different PR)",
|
|
"",
|
|
"This is **not** a generic decision-lock unlock. Cross-PR recovery "
|
|
"uses isolation (#693) or moot cleanup (#594), never correction.",
|
|
]
|
|
return "\n".join(lines)
|
|
|
|
|
|
def assess_open_pr_decision_lock_recovery(
|
|
lock: dict | None,
|
|
*,
|
|
target_pr_number: int,
|
|
target_head_sha: str | None,
|
|
last_terminal_pr_live: dict | None = None,
|
|
pr_lookup_error: str | None = None,
|
|
active_profile_identity: str | None = None,
|
|
session_blockers: list[str] | None = None,
|
|
controller_recovery_authorized: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Assess guarded recovery for decision locks affecting an open target PR (#693).
|
|
|
|
Recovery here means *workflow recovery routing*, not necessarily lock wipe.
|
|
Foreign-PR terminals are recoverable by isolation (mark_final allowed).
|
|
Moot terminals use #594 cleanup. Same-head active terminals refuse wipe.
|
|
"""
|
|
classification = classify_review_decision_lock(
|
|
lock,
|
|
target_pr_number=target_pr_number,
|
|
target_head_sha=target_head_sha,
|
|
pr_live=last_terminal_pr_live,
|
|
pr_lookup_error=pr_lookup_error,
|
|
active_profile_identity=active_profile_identity,
|
|
session_blockers=session_blockers,
|
|
)
|
|
cls = classification["classification"]
|
|
recovery_allowed = False
|
|
recovery_mode = None
|
|
if cls == CLASS_FOREIGN_PR_TERMINAL:
|
|
recovery_allowed = True
|
|
recovery_mode = "cross_pr_isolation"
|
|
elif cls == CLASS_STALE_SUPERSEDED_HEAD:
|
|
recovery_allowed = True
|
|
recovery_mode = "same_pr_new_head"
|
|
elif cls == CLASS_MOOT_MERGED_CLOSED:
|
|
recovery_allowed = True
|
|
recovery_mode = "moot_cleanup"
|
|
elif cls == CLASS_READY_FOR_TARGET:
|
|
recovery_allowed = True
|
|
recovery_mode = "none_required"
|
|
elif cls == CLASS_CORRECTION_ACTIVE:
|
|
recovery_allowed = True
|
|
recovery_mode = "scoped_correction"
|
|
elif cls == CLASS_TERMINAL_ACTIVE_SAME_HEAD:
|
|
recovery_allowed = False
|
|
recovery_mode = "correction_only_if_mistake"
|
|
else:
|
|
recovery_allowed = False
|
|
recovery_mode = "fail_closed"
|
|
|
|
if recovery_mode == "moot_cleanup" and not controller_recovery_authorized:
|
|
# Cleanup still needs apply + capability; assess reports path.
|
|
pass
|
|
|
|
return {
|
|
**classification,
|
|
"recovery_allowed": recovery_allowed,
|
|
"recovery_mode": recovery_mode,
|
|
"controller_recovery_authorized": bool(controller_recovery_authorized),
|
|
"manual_file_delete_forbidden": True,
|
|
"correction_as_generic_unlock_forbidden": True,
|
|
}
|