fix(workflow): cross-profile decision-lock cleanup and irrecoverable provenance (#709)
Prevent merger-local empty decision locks from standing in for reviewer terminal cleanup, refuse silent re-init overwrite of unresolved terminal evidence, record post-merge recovery-required state when audit fails, and add a truthful irrecoverable-provenance path that never claims applied=true. Closes #709
This commit is contained in:
+565
-26
@@ -3357,17 +3357,28 @@ def _review_decision_session_reasons(lock: dict | None) -> list[str]:
|
||||
|
||||
|
||||
def init_review_decision_lock(remote: str | None, task: str | None, force: bool = True):
|
||||
"""Seed read-only-until-ready state for reviewer PR review tasks."""
|
||||
"""Seed read-only-until-ready state for reviewer PR review tasks.
|
||||
|
||||
#709 AC2: never overwrite unresolved terminal decision-lock evidence with
|
||||
an empty initialized lock — even when *force* is True. Terminal ledgers
|
||||
are cleared only via moot cleanup or sanctioned recovery/archive paths.
|
||||
"""
|
||||
if task != "review_pr":
|
||||
return
|
||||
if not force:
|
||||
lock = _load_review_decision_lock()
|
||||
if lock is not None:
|
||||
existing = _load_review_decision_lock()
|
||||
if existing is not None:
|
||||
overwrite = stale_review_decision_lock.assess_init_overwrite(
|
||||
existing, force=force
|
||||
)
|
||||
if not overwrite.get("overwrite_allowed"):
|
||||
# Preserve terminal evidence; keep existing durable lock.
|
||||
return
|
||||
if not force:
|
||||
env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
|
||||
stored_lock = (lock.get("session_profile_lock") or "").strip()
|
||||
same_remote = lock.get("remote") == remote
|
||||
stored_lock = (lock_get_session_profile_lock(existing)).strip()
|
||||
same_remote = existing.get("remote") == remote
|
||||
same_profile = (not env_lock or not stored_lock or env_lock == stored_lock)
|
||||
if same_remote and same_profile and not _review_decision_session_reasons(lock):
|
||||
if same_remote and same_profile and not _review_decision_session_reasons(existing):
|
||||
return
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
profile = get_profile()
|
||||
@@ -3400,6 +3411,17 @@ def init_review_decision_lock(remote: str | None, task: str | None, force: bool
|
||||
})
|
||||
|
||||
|
||||
def lock_get_session_profile_lock(lock: dict | None) -> str:
|
||||
if not isinstance(lock, dict):
|
||||
return ""
|
||||
return (
|
||||
lock.get("session_profile_lock")
|
||||
or lock.get("profile_identity")
|
||||
or lock.get("session_profile")
|
||||
or ""
|
||||
)
|
||||
|
||||
|
||||
def _review_workflow_load_gate_reasons() -> list[str]:
|
||||
"""Fail closed when canonical review workflow was not loaded (#389)."""
|
||||
return review_workflow_load.review_workflow_load_blockers(PROJECT_ROOT)
|
||||
@@ -4470,6 +4492,288 @@ def gitea_authorize_review_correction(
|
||||
return {"authorized": True, "correction_reason": reason, "reasons": []}
|
||||
|
||||
|
||||
|
||||
def _record_post_merge_decision_recovery(
|
||||
*,
|
||||
pr_number: int,
|
||||
head_sha: str | None,
|
||||
merge_commit_sha: str | None,
|
||||
target_profile_identity: str | None,
|
||||
failed_step: str,
|
||||
error: str | None,
|
||||
remote: str | None,
|
||||
org: str | None,
|
||||
repo: str | None,
|
||||
) -> dict:
|
||||
"""Persist durable post-merge recovery-required state (#709 AC3)."""
|
||||
try:
|
||||
actor = None
|
||||
try:
|
||||
if remote in REMOTES:
|
||||
h, _, _ = _resolve(remote, None, org, repo)
|
||||
actor = _authenticated_username(h)
|
||||
except Exception:
|
||||
actor = None
|
||||
profile = get_profile()
|
||||
profile_name = (profile.get("profile_name") or "").strip() or None
|
||||
payload = stale_review_decision_lock.build_post_merge_recovery_record(
|
||||
pr_number=pr_number,
|
||||
head_sha=head_sha,
|
||||
merge_commit_sha=merge_commit_sha,
|
||||
target_profile_identity=target_profile_identity,
|
||||
failed_step=failed_step,
|
||||
error=error,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
actor_username=actor,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
# Key by merger/active profile so the merging session owns the recovery row.
|
||||
binding = _decision_lock_binding()
|
||||
saved = mcp_session_state.save_state(
|
||||
kind=mcp_session_state.KIND_POST_MERGE_DECISION_RECOVERY,
|
||||
payload=payload,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=binding.get("profile_identity"),
|
||||
)
|
||||
return dict(saved or payload)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"status": "recovery_record_failed", "error": _redact(str(exc))}
|
||||
|
||||
|
||||
def _clear_decision_lock_for_profile(
|
||||
*,
|
||||
profile_identity: str,
|
||||
pr_number: int,
|
||||
expected_head_sha: str | None,
|
||||
remote: str | None,
|
||||
org: str | None,
|
||||
repo: str | None,
|
||||
) -> dict:
|
||||
"""Clear one profile's durable decision lock when it targets *pr_number* approve."""
|
||||
lock = mcp_session_state.load_state_for_profile(
|
||||
kind=mcp_session_state.KIND_DECISION_LOCK,
|
||||
profile_identity=profile_identity,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
skip_identity_match=True,
|
||||
)
|
||||
if lock is None:
|
||||
return {
|
||||
"profile_identity": profile_identity,
|
||||
"cleared": False,
|
||||
"reason": "no durable lock for profile",
|
||||
}
|
||||
if not stale_review_decision_lock.lock_targets_merged_pr_approval(
|
||||
lock, pr_number=pr_number, expected_head_sha=expected_head_sha
|
||||
):
|
||||
# Also allow any terminal for this PR once merged (request_changes history).
|
||||
last = stale_review_decision_lock.last_terminal_mutation(lock)
|
||||
if not last or last.get("pr_number") != pr_number:
|
||||
return {
|
||||
"profile_identity": profile_identity,
|
||||
"cleared": False,
|
||||
"reason": "lock terminal does not target this PR approval",
|
||||
}
|
||||
# Archive then clear.
|
||||
try:
|
||||
mcp_session_state.save_state(
|
||||
kind=mcp_session_state.KIND_DECISION_LOCK_ARCHIVE,
|
||||
payload={
|
||||
**dict(lock),
|
||||
"archived_reason": "post_merge_cross_profile_cleanup",
|
||||
"archived_for_pr": pr_number,
|
||||
"recovery_critical": True,
|
||||
"kind": mcp_session_state.KIND_DECISION_LOCK_ARCHIVE,
|
||||
},
|
||||
remote=remote or lock.get("remote"),
|
||||
org=org or lock.get("org") or lock.get("ready_org"),
|
||||
repo=repo or lock.get("repo") or lock.get("ready_repo"),
|
||||
profile_identity=f"{profile_identity}-archive-pr{pr_number}",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
mcp_session_state.clear_state(
|
||||
kind=mcp_session_state.KIND_DECISION_LOCK,
|
||||
profile_identity=profile_identity,
|
||||
remote=remote or lock.get("remote"),
|
||||
org=org or lock.get("org") or lock.get("ready_org"),
|
||||
repo=repo or lock.get("repo") or lock.get("ready_repo"),
|
||||
)
|
||||
# If this is the in-memory active profile lock, clear memory too.
|
||||
active = _decision_lock_binding().get("profile_identity")
|
||||
if active and active == profile_identity:
|
||||
global _REVIEW_DECISION_LOCK
|
||||
_REVIEW_DECISION_LOCK = None
|
||||
return {
|
||||
"profile_identity": profile_identity,
|
||||
"cleared": True,
|
||||
"reason": f"cleared terminal lock for merged PR #{pr_number}",
|
||||
"prior_summary": stale_review_decision_lock.lock_summary(lock),
|
||||
}
|
||||
|
||||
|
||||
def _reconcile_decision_locks_after_merge(
|
||||
*,
|
||||
pr_number: int,
|
||||
head_sha: str | None,
|
||||
merge_commit_sha: str | None,
|
||||
remote: str,
|
||||
host: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
auth,
|
||||
) -> dict:
|
||||
"""Cross-profile decision-lock reconcile after a successful merge (#709)."""
|
||||
report: dict = {
|
||||
"cleared_any": False,
|
||||
"profiles_scanned": [],
|
||||
"cleared_profiles": [],
|
||||
"audit_comment_ids": [],
|
||||
"recovery_required": False,
|
||||
"reason_lines": [],
|
||||
"applied": False, # overall "historical applied cleanup" never claimed blindly
|
||||
}
|
||||
try:
|
||||
actor = _authenticated_username(host)
|
||||
except Exception:
|
||||
actor = None
|
||||
profile = get_profile()
|
||||
profile_name = (profile.get("profile_name") or "").strip() or None
|
||||
|
||||
identities = list(mcp_session_state.list_decision_lock_profile_identities())
|
||||
active = _decision_lock_binding().get("profile_identity")
|
||||
if active and active not in identities:
|
||||
identities.append(active)
|
||||
# Always consider common role profiles so empty local merger lock cannot
|
||||
# hide a reviewer terminal ledger (#709 AC1).
|
||||
for candidate in ("prgs-reviewer", "prgs-merger", "prgs-author", "prgs-reconciler"):
|
||||
if candidate not in identities:
|
||||
identities.append(candidate)
|
||||
report["profiles_scanned"] = list(identities)
|
||||
|
||||
for identity in identities:
|
||||
try:
|
||||
outcome = _clear_decision_lock_for_profile(
|
||||
profile_identity=identity,
|
||||
pr_number=pr_number,
|
||||
expected_head_sha=head_sha,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
report["recovery_required"] = True
|
||||
report["reason_lines"].append(
|
||||
f"failed clearing decision lock for {identity}: {_redact(str(exc))}"
|
||||
)
|
||||
_record_post_merge_decision_recovery(
|
||||
pr_number=pr_number,
|
||||
head_sha=head_sha,
|
||||
merge_commit_sha=merge_commit_sha,
|
||||
target_profile_identity=identity,
|
||||
failed_step="clear_profile_lock",
|
||||
error=_redact(str(exc)),
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
continue
|
||||
if outcome.get("cleared"):
|
||||
report["cleared_any"] = True
|
||||
report["cleared_profiles"].append(identity)
|
||||
report["reason_lines"].append(
|
||||
f"cleared decision lock profile={identity} after merge of "
|
||||
f"PR #{pr_number} (#709)"
|
||||
)
|
||||
# Audit publication (AC4): post and require comment id for full reconcile.
|
||||
audit = stale_review_decision_lock.build_cleanup_audit_record(
|
||||
assessment={
|
||||
"last_terminal_pr": pr_number,
|
||||
"last_terminal_action": "approve",
|
||||
"pr_state": "closed",
|
||||
"pr_merged": True,
|
||||
"pr_merged_or_closed": True,
|
||||
"merge_commit_sha": merge_commit_sha,
|
||||
"cleanup_allowed": True,
|
||||
"is_moot": True,
|
||||
"lock_summary": outcome.get("prior_summary"),
|
||||
"reasons": [outcome.get("reason") or "cleared"],
|
||||
},
|
||||
actor_username=actor,
|
||||
profile_name=profile_name,
|
||||
applied=True,
|
||||
)
|
||||
audit["cleanup_target_profile"] = identity
|
||||
audit["issue_ref"] = "#709"
|
||||
comment_block = _profile_operation_gate("gitea.pr.comment")
|
||||
if comment_block:
|
||||
report["recovery_required"] = True
|
||||
report["reason_lines"].append(
|
||||
f"audit comment blocked for {identity}: {comment_block}"
|
||||
)
|
||||
_record_post_merge_decision_recovery(
|
||||
pr_number=pr_number,
|
||||
head_sha=head_sha,
|
||||
merge_commit_sha=merge_commit_sha,
|
||||
target_profile_identity=identity,
|
||||
failed_step="audit_comment_permission",
|
||||
error=str(comment_block),
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
continue
|
||||
try:
|
||||
body = stale_review_decision_lock.format_cleanup_audit_comment(audit)
|
||||
comment_url = f"{repo_api_url(host, org, repo)}/issues/{pr_number}/comments"
|
||||
with _audited(
|
||||
"comment_pr",
|
||||
host=host,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
pr_number=pr_number,
|
||||
request_metadata={"source": "post_merge_decision_lock_reconcile"},
|
||||
):
|
||||
posted = api_request("POST", comment_url, auth, {"body": body})
|
||||
cid = (posted or {}).get("id")
|
||||
if not cid:
|
||||
raise RuntimeError("audit comment missing id on readback")
|
||||
report["audit_comment_ids"].append(cid)
|
||||
report["reason_lines"].append(
|
||||
f"audit comment published id={cid} for profile={identity}"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
report["recovery_required"] = True
|
||||
report["reason_lines"].append(
|
||||
f"audit comment failed for {identity}: {_redact(str(exc))}"
|
||||
)
|
||||
_record_post_merge_decision_recovery(
|
||||
pr_number=pr_number,
|
||||
head_sha=head_sha,
|
||||
merge_commit_sha=merge_commit_sha,
|
||||
target_profile_identity=identity,
|
||||
failed_step="audit_comment_publish",
|
||||
error=_redact(str(exc)),
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
|
||||
if report["cleared_any"] and not report["recovery_required"]:
|
||||
report["applied"] = True # this-session successful cleanup only
|
||||
elif not report["cleared_any"]:
|
||||
report["reason_lines"].append(
|
||||
f"no cross-profile decision lock targeted PR #{pr_number} for cleanup"
|
||||
)
|
||||
return report
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_cleanup_stale_review_decision_lock(
|
||||
apply: bool = False,
|
||||
@@ -4669,12 +4973,227 @@ def gitea_cleanup_stale_review_decision_lock(
|
||||
"POST", comment_url, auth, {"body": body}
|
||||
)
|
||||
report["audit_comment_id"] = (posted or {}).get("id")
|
||||
if not report["audit_comment_id"]:
|
||||
report["reconciled"] = False
|
||||
report["recovery_required"] = True
|
||||
report["reasons"] = list(report.get("reasons") or []) + [
|
||||
"cleanup applied but audit comment id missing on readback "
|
||||
"(#709 AC4 fail-closed for full reconcile)"
|
||||
]
|
||||
_record_post_merge_decision_recovery(
|
||||
pr_number=int(assessment["last_terminal_pr"]),
|
||||
head_sha=assessment.get("locked_head_sha"),
|
||||
merge_commit_sha=assessment.get("merge_commit_sha"),
|
||||
target_profile_identity=active_identity,
|
||||
failed_step="audit_comment_missing_id",
|
||||
error="comment response missing id",
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
)
|
||||
else:
|
||||
report["reconciled"] = True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
report["audit_comment_error"] = _redact(str(exc))
|
||||
report["reconciled"] = False
|
||||
report["recovery_required"] = True
|
||||
report["reasons"] = list(report.get("reasons") or []) + [
|
||||
"cleanup applied but audit comment failed; recovery-required "
|
||||
"recorded (#709 AC4)"
|
||||
]
|
||||
try:
|
||||
_record_post_merge_decision_recovery(
|
||||
pr_number=int(assessment["last_terminal_pr"]),
|
||||
head_sha=assessment.get("locked_head_sha"),
|
||||
merge_commit_sha=assessment.get("merge_commit_sha"),
|
||||
target_profile_identity=active_identity,
|
||||
failed_step="audit_comment_publish",
|
||||
error=_redact(str(exc)),
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if post_audit_comment is False:
|
||||
report["reconciled"] = False
|
||||
report["reasons"] = list(report.get("reasons") or []) + [
|
||||
"cleanup applied without audit comment (post_audit_comment=false); "
|
||||
"not fully reconciled (#709 AC4)"
|
||||
]
|
||||
|
||||
return report
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_record_irrecoverable_decision_lock_provenance(
|
||||
pr_number: int,
|
||||
reason: str,
|
||||
confirmation: str = "",
|
||||
operator_authorized: bool = False,
|
||||
expected_head_sha: str | None = None,
|
||||
incident_ref: str | None = None,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
post_audit_comment: bool = True,
|
||||
) -> dict:
|
||||
"""Record truthful absence of decision-lock cleanup proof (#709 AC5).
|
||||
|
||||
Never emits applied=true or claims historical cleanup was proven.
|
||||
Requires operator_authorized=True and confirmation exactly equal to
|
||||
``IRRECOVERABLE DECISION PROVENANCE PR <pr_number>``.
|
||||
"""
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
expected_confirm = f"IRRECOVERABLE DECISION PROVENANCE PR {int(pr_number)}"
|
||||
report = {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"applied": False,
|
||||
"historical_cleanup_proven": False,
|
||||
"status": "provenance_irrecoverable",
|
||||
"pr_number": pr_number,
|
||||
"expected_head_sha": expected_head_sha,
|
||||
"reasons": [],
|
||||
"record": None,
|
||||
"audit_comment_id": None,
|
||||
}
|
||||
read_block = _profile_operation_gate("gitea.read")
|
||||
if read_block:
|
||||
report["reasons"] = read_block
|
||||
report["permission_report"] = _permission_block_report("gitea.read")
|
||||
return report
|
||||
if not operator_authorized:
|
||||
report["reasons"].append(
|
||||
"operator_authorized must be true for irrecoverable provenance "
|
||||
"recording (fail closed, #709 AC5)"
|
||||
)
|
||||
return report
|
||||
if (confirmation or "").strip() != expected_confirm:
|
||||
report["reasons"].append(
|
||||
f"confirmation must equal exactly {expected_confirm!r} (fail closed)"
|
||||
)
|
||||
return report
|
||||
if not (reason or "").strip():
|
||||
report["reasons"].append("reason is required (fail closed)")
|
||||
return report
|
||||
try:
|
||||
actor = _authenticated_username(h)
|
||||
except Exception:
|
||||
actor = None
|
||||
if not actor:
|
||||
report["reasons"].append(
|
||||
"authenticated identity could not be verified (fail closed)"
|
||||
)
|
||||
return report
|
||||
profile = get_profile()
|
||||
profile_name = (profile.get("profile_name") or "").strip() or None
|
||||
# Idempotent: if matching record already exists for pr+head, return it.
|
||||
binding = _decision_lock_binding()
|
||||
existing = mcp_session_state.load_state(
|
||||
kind=mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE,
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
profile_identity=binding.get("profile_identity"),
|
||||
)
|
||||
if (
|
||||
isinstance(existing, dict)
|
||||
and existing.get("pr_number") == pr_number
|
||||
and (
|
||||
not expected_head_sha
|
||||
or stale_review_decision_lock.heads_equal(
|
||||
existing.get("head_sha"), expected_head_sha
|
||||
)
|
||||
)
|
||||
and existing.get("status") == "provenance_irrecoverable"
|
||||
):
|
||||
report["success"] = True
|
||||
report["performed"] = False
|
||||
report["record"] = existing
|
||||
report["reasons"].append("idempotent: matching irrecoverable record already present")
|
||||
return report
|
||||
|
||||
record = stale_review_decision_lock.build_irrecoverable_provenance_record(
|
||||
pr_number=pr_number,
|
||||
head_sha=expected_head_sha,
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
actor_username=actor,
|
||||
profile_name=profile_name,
|
||||
reason=reason.strip(),
|
||||
incident_ref=incident_ref,
|
||||
operator_authorized=True,
|
||||
)
|
||||
# Stamp kind for TTL exemption
|
||||
record["kind"] = mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE
|
||||
saved = mcp_session_state.save_state(
|
||||
kind=mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE,
|
||||
payload=record,
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
profile_identity=binding.get("profile_identity"),
|
||||
)
|
||||
report["record"] = dict(saved or record)
|
||||
report["performed"] = True
|
||||
report["success"] = True
|
||||
report["reasons"].append(
|
||||
"recorded provenance_irrecoverable (applied=false; historical cleanup not proven)"
|
||||
)
|
||||
|
||||
if post_audit_comment:
|
||||
comment_block = _profile_operation_gate("gitea.pr.comment") or _profile_operation_gate(
|
||||
"gitea.issue.comment"
|
||||
)
|
||||
# Prefer issue comment capability for discussion thread.
|
||||
issue_block = _profile_operation_gate("gitea.issue.comment")
|
||||
if issue_block:
|
||||
report["reasons"].append(f"audit comment skipped: {issue_block}")
|
||||
else:
|
||||
try:
|
||||
body = stale_review_decision_lock.format_irrecoverable_audit_comment(
|
||||
report["record"]
|
||||
)
|
||||
comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
|
||||
with _audited(
|
||||
"comment_issue",
|
||||
host=h,
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
issue_number=pr_number,
|
||||
request_metadata={
|
||||
"source": "record_irrecoverable_decision_lock_provenance"
|
||||
},
|
||||
):
|
||||
posted = api_request(
|
||||
"POST",
|
||||
comment_url,
|
||||
_auth(h),
|
||||
{"body": body},
|
||||
)
|
||||
report["audit_comment_id"] = (posted or {}).get("id")
|
||||
if report["audit_comment_id"]:
|
||||
# Re-save with comment id for readback completeness.
|
||||
report["record"]["audit_comment_id"] = report["audit_comment_id"]
|
||||
mcp_session_state.save_state(
|
||||
kind=mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE,
|
||||
payload=report["record"],
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
profile_identity=binding.get("profile_identity"),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
report["reasons"].append(
|
||||
f"audit comment failed: {_redact(str(exc))} (record still durable)"
|
||||
)
|
||||
return report
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_dry_run_pr_review(
|
||||
pr_number: int,
|
||||
@@ -5772,29 +6291,49 @@ def gitea_merge_pr(
|
||||
reviewer_pr_lease.get_session_lease()
|
||||
)
|
||||
)
|
||||
# Same-profile auto-expire (#594): if *this* profile's decision lock ends
|
||||
# with an approve of the PR just merged, clear it so durable state cannot
|
||||
# block later unrelated reviews. Cross-profile locks (e.g. reviewer vs
|
||||
# merger) still require gitea_cleanup_stale_review_decision_lock.
|
||||
# #709 AC1/AC3/AC4: reconcile decision locks after irreversible merge.
|
||||
# Clears the same-profile lock when it holds the approve, and also scans
|
||||
# other durable profile locks (e.g. prgs-reviewer) so merger-local empty
|
||||
# init cannot leave the reviewer terminal ledger behind. Failures write a
|
||||
# recovery-required record (applied=false) — merge is never undone.
|
||||
try:
|
||||
lock_after = _load_review_decision_lock()
|
||||
last_term = stale_review_decision_lock.last_terminal_mutation(lock_after)
|
||||
if (
|
||||
last_term
|
||||
and last_term.get("action") == "approve"
|
||||
and last_term.get("pr_number") == pr_number
|
||||
):
|
||||
_save_review_decision_lock(None)
|
||||
result["review_decision_lock_cleared_after_merge"] = True
|
||||
reasons.append(
|
||||
f"cleared same-profile review decision lock after merge of "
|
||||
f"approved PR #{pr_number} (#594)"
|
||||
)
|
||||
else:
|
||||
result["review_decision_lock_cleared_after_merge"] = False
|
||||
reconcile = _reconcile_decision_locks_after_merge(
|
||||
pr_number=pr_number,
|
||||
head_sha=expected_head_sha,
|
||||
merge_commit_sha=(
|
||||
(merged or {}).get("merge_commit_sha")
|
||||
if isinstance(merged, dict)
|
||||
else None
|
||||
),
|
||||
remote=remote,
|
||||
host=h,
|
||||
org=o,
|
||||
repo=r,
|
||||
auth=auth,
|
||||
)
|
||||
result["review_decision_lock_reconcile"] = reconcile
|
||||
result["review_decision_lock_cleared_after_merge"] = bool(
|
||||
reconcile.get("cleared_any")
|
||||
)
|
||||
for line in reconcile.get("reason_lines") or []:
|
||||
reasons.append(line)
|
||||
except Exception as clear_exc: # noqa: BLE001 — never fail the merge
|
||||
result["review_decision_lock_cleared_after_merge"] = False
|
||||
result["review_decision_lock_clear_error"] = _redact(str(clear_exc))
|
||||
try:
|
||||
_record_post_merge_decision_recovery(
|
||||
pr_number=pr_number,
|
||||
head_sha=expected_head_sha,
|
||||
merge_commit_sha=None,
|
||||
target_profile_identity=None,
|
||||
failed_step="reconcile_exception",
|
||||
error=_redact(str(clear_exc)),
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
reasons.append(f"all gates passed; merged PR #{pr_number} via '{do}'")
|
||||
return result
|
||||
|
||||
|
||||
Reference in New Issue
Block a user