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:
+563
-24
@@ -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):
|
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":
|
if task != "review_pr":
|
||||||
return
|
return
|
||||||
|
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:
|
if not force:
|
||||||
lock = _load_review_decision_lock()
|
|
||||||
if lock is not None:
|
|
||||||
env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
|
env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
|
||||||
stored_lock = (lock.get("session_profile_lock") or "").strip()
|
stored_lock = (lock_get_session_profile_lock(existing)).strip()
|
||||||
same_remote = lock.get("remote") == remote
|
same_remote = existing.get("remote") == remote
|
||||||
same_profile = (not env_lock or not stored_lock or env_lock == stored_lock)
|
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
|
return
|
||||||
review_workflow_load.clear_review_workflow_load()
|
review_workflow_load.clear_review_workflow_load()
|
||||||
profile = get_profile()
|
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]:
|
def _review_workflow_load_gate_reasons() -> list[str]:
|
||||||
"""Fail closed when canonical review workflow was not loaded (#389)."""
|
"""Fail closed when canonical review workflow was not loaded (#389)."""
|
||||||
return review_workflow_load.review_workflow_load_blockers(PROJECT_ROOT)
|
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": []}
|
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()
|
@mcp.tool()
|
||||||
def gitea_cleanup_stale_review_decision_lock(
|
def gitea_cleanup_stale_review_decision_lock(
|
||||||
apply: bool = False,
|
apply: bool = False,
|
||||||
@@ -4669,12 +4973,227 @@ def gitea_cleanup_stale_review_decision_lock(
|
|||||||
"POST", comment_url, auth, {"body": body}
|
"POST", comment_url, auth, {"body": body}
|
||||||
)
|
)
|
||||||
report["audit_comment_id"] = (posted or {}).get("id")
|
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
|
except Exception as exc: # noqa: BLE001
|
||||||
report["audit_comment_error"] = _redact(str(exc))
|
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
|
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()
|
@mcp.tool()
|
||||||
def gitea_dry_run_pr_review(
|
def gitea_dry_run_pr_review(
|
||||||
pr_number: int,
|
pr_number: int,
|
||||||
@@ -5772,29 +6291,49 @@ def gitea_merge_pr(
|
|||||||
reviewer_pr_lease.get_session_lease()
|
reviewer_pr_lease.get_session_lease()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# Same-profile auto-expire (#594): if *this* profile's decision lock ends
|
# #709 AC1/AC3/AC4: reconcile decision locks after irreversible merge.
|
||||||
# with an approve of the PR just merged, clear it so durable state cannot
|
# Clears the same-profile lock when it holds the approve, and also scans
|
||||||
# block later unrelated reviews. Cross-profile locks (e.g. reviewer vs
|
# other durable profile locks (e.g. prgs-reviewer) so merger-local empty
|
||||||
# merger) still require gitea_cleanup_stale_review_decision_lock.
|
# init cannot leave the reviewer terminal ledger behind. Failures write a
|
||||||
|
# recovery-required record (applied=false) — merge is never undone.
|
||||||
try:
|
try:
|
||||||
lock_after = _load_review_decision_lock()
|
reconcile = _reconcile_decision_locks_after_merge(
|
||||||
last_term = stale_review_decision_lock.last_terminal_mutation(lock_after)
|
pr_number=pr_number,
|
||||||
if (
|
head_sha=expected_head_sha,
|
||||||
last_term
|
merge_commit_sha=(
|
||||||
and last_term.get("action") == "approve"
|
(merged or {}).get("merge_commit_sha")
|
||||||
and last_term.get("pr_number") == pr_number
|
if isinstance(merged, dict)
|
||||||
):
|
else None
|
||||||
_save_review_decision_lock(None)
|
),
|
||||||
result["review_decision_lock_cleared_after_merge"] = True
|
remote=remote,
|
||||||
reasons.append(
|
host=h,
|
||||||
f"cleared same-profile review decision lock after merge of "
|
org=o,
|
||||||
f"approved PR #{pr_number} (#594)"
|
repo=r,
|
||||||
|
auth=auth,
|
||||||
)
|
)
|
||||||
else:
|
result["review_decision_lock_reconcile"] = reconcile
|
||||||
result["review_decision_lock_cleared_after_merge"] = False
|
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
|
except Exception as clear_exc: # noqa: BLE001 — never fail the merge
|
||||||
result["review_decision_lock_cleared_after_merge"] = False
|
result["review_decision_lock_cleared_after_merge"] = False
|
||||||
result["review_decision_lock_clear_error"] = _redact(str(clear_exc))
|
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}'")
|
reasons.append(f"all gates passed; merged PR #{pr_number} via '{do}'")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
+143
-1
@@ -36,7 +36,21 @@ KIND_REVIEW_DRAFT = "review_draft"
|
|||||||
# other session proofs; a contaminated session fails closed on gated mutations
|
# other session proofs; a contaminated session fails closed on gated mutations
|
||||||
# until a reconciler audits and clears it.
|
# until a reconciler audits and clears it.
|
||||||
KIND_STABLE_BRANCH_CONTAMINATION = "stable_branch_contamination"
|
KIND_STABLE_BRANCH_CONTAMINATION = "stable_branch_contamination"
|
||||||
|
# #709: archive prior terminal decision ledgers instead of silent overwrite.
|
||||||
|
KIND_DECISION_LOCK_ARCHIVE = "review_decision_lock_archive"
|
||||||
|
# #709: post-merge cleanup/audit reconciliation-required durable record.
|
||||||
|
KIND_POST_MERGE_DECISION_RECOVERY = "post_merge_decision_recovery"
|
||||||
|
# #709: truthful record when historical terminal evidence is irrecoverably gone.
|
||||||
|
KIND_IRRECOVERABLE_DECISION_PROVENANCE = "irrecoverable_decision_provenance"
|
||||||
|
|
||||||
|
# Kinds that must survive the default session-state TTL (forensic / recovery).
|
||||||
|
RECOVERY_CRITICAL_KINDS = frozenset(
|
||||||
|
{
|
||||||
|
KIND_DECISION_LOCK_ARCHIVE,
|
||||||
|
KIND_POST_MERGE_DECISION_RECOVERY,
|
||||||
|
KIND_IRRECOVERABLE_DECISION_PROVENANCE,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
|
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
|
||||||
@@ -270,7 +284,11 @@ def identity_match_reasons(
|
|||||||
reasons.append("session state missing recorded_at timestamp (fail closed)")
|
reasons.append("session state missing recorded_at timestamp (fail closed)")
|
||||||
else:
|
else:
|
||||||
age = _now_utc() - recorded_at
|
age = _now_utc() - recorded_at
|
||||||
if age > timedelta(hours=ttl_hours()):
|
kind = (record.get("kind") or "").strip()
|
||||||
|
ttl_exempt = kind in RECOVERY_CRITICAL_KINDS or bool(
|
||||||
|
record.get("recovery_critical")
|
||||||
|
)
|
||||||
|
if age > timedelta(hours=ttl_hours()) and not ttl_exempt:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
f"session state expired after {ttl_hours():g}h (fail closed)"
|
f"session state expired after {ttl_hours():g}h (fail closed)"
|
||||||
)
|
)
|
||||||
@@ -447,3 +465,127 @@ def clear_state(
|
|||||||
profile_identity=profile_identity,
|
profile_identity=profile_identity,
|
||||||
state_dir=state_dir,
|
state_dir=state_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def list_decision_lock_profile_identities(
|
||||||
|
state_dir: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return profile identities that have a durable review_decision_lock file (#709).
|
||||||
|
|
||||||
|
Filename form: ``review_decision_lock-<profile>.json`` (see ``state_key``).
|
||||||
|
Does not validate TTL or identity — callers must load via ``load_state``.
|
||||||
|
"""
|
||||||
|
root = (state_dir or default_state_dir()).strip()
|
||||||
|
if not root or not os.path.isdir(root):
|
||||||
|
return []
|
||||||
|
prefix = f"{_sanitize_segment(KIND_DECISION_LOCK)}-"
|
||||||
|
suffix = ".json"
|
||||||
|
found: list[str] = []
|
||||||
|
try:
|
||||||
|
names = os.listdir(root)
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
for name in names:
|
||||||
|
if not name.startswith(prefix) or not name.endswith(suffix):
|
||||||
|
continue
|
||||||
|
if name.endswith(".lock"):
|
||||||
|
continue
|
||||||
|
mid = name[len(prefix) : -len(suffix)]
|
||||||
|
if mid:
|
||||||
|
found.append(mid)
|
||||||
|
return sorted(set(found))
|
||||||
|
|
||||||
|
|
||||||
|
def load_state_for_profile(
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
profile_identity: str,
|
||||||
|
remote: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
repo: str | None = None,
|
||||||
|
state_dir: str | None = None,
|
||||||
|
skip_identity_match: bool = False,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Load durable state for an explicit profile identity (#709 cross-profile).
|
||||||
|
|
||||||
|
When *skip_identity_match* is True, still requires the file's recorded
|
||||||
|
profile_identity to equal the requested profile (anti-stomp), but does not
|
||||||
|
require the *active* session identity to match — needed so a merger can
|
||||||
|
inspect a reviewer lock after merge.
|
||||||
|
"""
|
||||||
|
profile = current_profile_identity(profile_identity=profile_identity)
|
||||||
|
root = _ensure_state_dir(state_dir)
|
||||||
|
path = state_file_path(
|
||||||
|
kind=kind,
|
||||||
|
remote=remote,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
profile_identity=profile,
|
||||||
|
state_dir=root,
|
||||||
|
)
|
||||||
|
lock_path = f"{path}.lock"
|
||||||
|
with _exclusive_file_lock(lock_path):
|
||||||
|
envelope = _read_json(path)
|
||||||
|
if not envelope:
|
||||||
|
return None
|
||||||
|
payload = envelope.get("payload")
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
merged = dict(payload)
|
||||||
|
for key in (
|
||||||
|
"kind",
|
||||||
|
"remote",
|
||||||
|
"org",
|
||||||
|
"repo",
|
||||||
|
"profile_identity",
|
||||||
|
"session_profile_lock",
|
||||||
|
"recorded_at",
|
||||||
|
"updated_at",
|
||||||
|
"writer_pid",
|
||||||
|
):
|
||||||
|
if key in envelope and key not in merged:
|
||||||
|
merged[key] = envelope[key]
|
||||||
|
stored = (merged.get("profile_identity") or "").strip()
|
||||||
|
if stored and stored != profile:
|
||||||
|
return None
|
||||||
|
if skip_identity_match:
|
||||||
|
# Still enforce TTL / future-dated so dead records do not authorize cleanup.
|
||||||
|
reasons = identity_match_reasons(
|
||||||
|
merged,
|
||||||
|
remote=remote or merged.get("remote"),
|
||||||
|
org=org or merged.get("org"),
|
||||||
|
repo=repo or merged.get("repo"),
|
||||||
|
profile_identity=stored or profile,
|
||||||
|
)
|
||||||
|
# Drop active-session-only mismatches; keep expiry / spoof reasons.
|
||||||
|
filtered = [
|
||||||
|
r
|
||||||
|
for r in reasons
|
||||||
|
if "profile identity mismatch" not in r
|
||||||
|
or (stored and stored != profile)
|
||||||
|
]
|
||||||
|
# Re-run only expiry/future/missing checks via identity when profile matches
|
||||||
|
expiry_reasons = [
|
||||||
|
r
|
||||||
|
for r in identity_match_reasons(
|
||||||
|
merged,
|
||||||
|
remote=None,
|
||||||
|
org=None,
|
||||||
|
repo=None,
|
||||||
|
profile_identity=stored or profile,
|
||||||
|
)
|
||||||
|
if any(x in r for x in ("expired", "future", "missing recorded_at"))
|
||||||
|
]
|
||||||
|
if expiry_reasons:
|
||||||
|
return None
|
||||||
|
return merged
|
||||||
|
reasons = identity_match_reasons(
|
||||||
|
merged,
|
||||||
|
remote=remote,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
profile_identity=profile,
|
||||||
|
)
|
||||||
|
if reasons:
|
||||||
|
return None
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|||||||
@@ -438,3 +438,216 @@ def format_cleanup_audit_comment(audit: dict[str, Any]) -> str:
|
|||||||
"This path only clears a lock when the referenced PR is merged/closed.",
|
"This path only clears a lock when the referenced PR is merged/closed.",
|
||||||
]
|
]
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# #709 cross-profile cleanup, overwrite protection, recovery provenance
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def has_unresolved_terminal_evidence(lock: dict | None) -> bool:
|
||||||
|
"""True when *lock* still carries a terminal live mutation ledger."""
|
||||||
|
return last_terminal_mutation(lock) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def assess_init_overwrite(
|
||||||
|
existing_lock: dict | None,
|
||||||
|
*,
|
||||||
|
force: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Whether init_review_decision_lock may replace an existing durable lock (#709 AC2).
|
||||||
|
|
||||||
|
Unresolved terminal evidence must never be silently replaced by an empty
|
||||||
|
initialized lock. *force* does not authorize destruction of terminal
|
||||||
|
ledgers — only explicit cleanup / archive transitions may remove them.
|
||||||
|
"""
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"overwrite_allowed": True,
|
||||||
|
"has_existing": existing_lock is not None,
|
||||||
|
"has_unresolved_terminal": False,
|
||||||
|
"reasons": [],
|
||||||
|
"last_terminal_pr": None,
|
||||||
|
"last_terminal_action": None,
|
||||||
|
"existing_profile_identity": None,
|
||||||
|
}
|
||||||
|
if existing_lock is None:
|
||||||
|
result["reasons"].append("no existing lock; empty init allowed")
|
||||||
|
return result
|
||||||
|
result["existing_profile_identity"] = (
|
||||||
|
existing_lock.get("profile_identity")
|
||||||
|
or existing_lock.get("session_profile_lock")
|
||||||
|
or existing_lock.get("session_profile")
|
||||||
|
)
|
||||||
|
last = last_terminal_mutation(existing_lock)
|
||||||
|
if last is None:
|
||||||
|
result["reasons"].append(
|
||||||
|
"existing lock has no terminal mutations; re-init allowed"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
result["has_unresolved_terminal"] = True
|
||||||
|
result["overwrite_allowed"] = False
|
||||||
|
result["last_terminal_pr"] = last.get("pr_number")
|
||||||
|
result["last_terminal_action"] = last.get("action")
|
||||||
|
result["reasons"].append(
|
||||||
|
"refuse empty re-init: unresolved terminal decision-lock evidence "
|
||||||
|
f"present ({last.get('action')} on PR #{last.get('pr_number')}); "
|
||||||
|
"use gitea_cleanup_stale_review_decision_lock when moot, or archive "
|
||||||
|
f"via sanctioned recovery — force={force!r} does not authorize overwrite "
|
||||||
|
"(#709 AC2)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def lock_targets_merged_pr_approval(
|
||||||
|
lock: dict | None,
|
||||||
|
*,
|
||||||
|
pr_number: int,
|
||||||
|
expected_head_sha: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""True when *lock*'s last terminal mutation is approve of *pr_number*."""
|
||||||
|
last = last_terminal_mutation(lock)
|
||||||
|
if last is None:
|
||||||
|
return False
|
||||||
|
if last.get("action") != "approve":
|
||||||
|
return False
|
||||||
|
if last.get("pr_number") != pr_number:
|
||||||
|
return False
|
||||||
|
if expected_head_sha:
|
||||||
|
locked = mutation_head_sha(last, lock)
|
||||||
|
if locked and not heads_equal(locked, expected_head_sha):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def build_post_merge_recovery_record(
|
||||||
|
*,
|
||||||
|
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,
|
||||||
|
actor_username: str | None,
|
||||||
|
profile_name: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Durable recovery-required payload after irreversible merge (#709 AC3)."""
|
||||||
|
return {
|
||||||
|
"event": "post_merge_decision_lock_recovery_required",
|
||||||
|
"status": "recovery_required",
|
||||||
|
"issue_ref": "#709",
|
||||||
|
"recovery_critical": True,
|
||||||
|
"applied": False, # never claim historical cleanup succeeded
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"head_sha": normalize_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_username,
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"required_recovery_action": (
|
||||||
|
"retry cross-profile decision-lock cleanup and audit publication "
|
||||||
|
"for the merged PR; if terminal evidence is gone, use "
|
||||||
|
"gitea_record_irrecoverable_decision_lock_provenance"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_irrecoverable_provenance_record(
|
||||||
|
*,
|
||||||
|
pr_number: int,
|
||||||
|
head_sha: str | None,
|
||||||
|
remote: str | None,
|
||||||
|
org: str | None,
|
||||||
|
repo: str | None,
|
||||||
|
actor_username: str | None,
|
||||||
|
profile_name: str | None,
|
||||||
|
reason: str,
|
||||||
|
incident_ref: str | None,
|
||||||
|
operator_authorized: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Truthful absence-of-proof record (#709 AC5). Never sets applied=True."""
|
||||||
|
return {
|
||||||
|
"event": "irrecoverable_decision_lock_provenance",
|
||||||
|
"status": "provenance_irrecoverable",
|
||||||
|
"operator_recovery_required": True,
|
||||||
|
"issue_ref": "#709",
|
||||||
|
"recovery_critical": True,
|
||||||
|
"applied": False,
|
||||||
|
"historical_cleanup_proven": False,
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"head_sha": normalize_head_sha(head_sha),
|
||||||
|
"remote": remote,
|
||||||
|
"org": org,
|
||||||
|
"repo": repo,
|
||||||
|
"actor_username": actor_username,
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"reason": reason,
|
||||||
|
"incident_ref": incident_ref,
|
||||||
|
"operator_authorized": bool(operator_authorized),
|
||||||
|
"merger_may_accept": bool(operator_authorized),
|
||||||
|
"acceptance_rule": (
|
||||||
|
"Merger may accept this record only when operator_authorized=true, "
|
||||||
|
"repository/PR/head match the live target, the record is durable "
|
||||||
|
"and read back, and no conflicting terminal lock remains for a "
|
||||||
|
"different PR/head. This does not prove historical cleanup."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_irrecoverable_audit_comment(record: dict[str, Any]) -> str:
|
||||||
|
"""Markdown body for irrecoverable provenance audit (no applied=true claim)."""
|
||||||
|
lines = [
|
||||||
|
"## Irrecoverable decision-lock provenance (#709)",
|
||||||
|
"",
|
||||||
|
"Status: **PROVENANCE_IRRECOVERABLE** (not applied cleanup)",
|
||||||
|
"",
|
||||||
|
f"- actor: `{record.get('actor_username')}`",
|
||||||
|
f"- profile: `{record.get('profile_name')}`",
|
||||||
|
f"- timestamp: `{record.get('timestamp')}`",
|
||||||
|
f"- PR: `#{record.get('pr_number')}`",
|
||||||
|
f"- head_sha: `{record.get('head_sha')}`",
|
||||||
|
f"- incident_ref: `{record.get('incident_ref')}`",
|
||||||
|
f"- operator_authorized: `{record.get('operator_authorized')}`",
|
||||||
|
f"- historical_cleanup_proven: `{record.get('historical_cleanup_proven')}`",
|
||||||
|
f"- applied: `{record.get('applied')}` (must remain false)",
|
||||||
|
"",
|
||||||
|
f"Reason: {record.get('reason')}",
|
||||||
|
"",
|
||||||
|
"This record documents **absence of proof**, not successful cleanup.",
|
||||||
|
"It must not be reused for a different PR or head (#709 AC6).",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def format_post_merge_recovery_comment(record: dict[str, Any]) -> str:
|
||||||
|
"""Markdown body for post-merge recovery-required audit."""
|
||||||
|
lines = [
|
||||||
|
"## Post-merge decision-lock recovery required (#709)",
|
||||||
|
"",
|
||||||
|
"Status: **RECOVERY_REQUIRED** (merge is irreversible; cleanup/audit incomplete)",
|
||||||
|
"",
|
||||||
|
f"- actor: `{record.get('actor_username')}`",
|
||||||
|
f"- profile: `{record.get('profile_name')}`",
|
||||||
|
f"- timestamp: `{record.get('timestamp')}`",
|
||||||
|
f"- PR: `#{record.get('pr_number')}`",
|
||||||
|
f"- head_sha: `{record.get('head_sha')}`",
|
||||||
|
f"- merge_commit_sha: `{record.get('merge_commit_sha')}`",
|
||||||
|
f"- target_profile_identity: `{record.get('target_profile_identity')}`",
|
||||||
|
f"- failed_step: `{record.get('failed_step')}`",
|
||||||
|
f"- error: `{record.get('error')}`",
|
||||||
|
"",
|
||||||
|
f"Required action: {record.get('required_recovery_action')}",
|
||||||
|
"",
|
||||||
|
"applied=false — do not treat this as successful cleanup evidence.",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,15 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.pr.review",
|
"permission": "gitea.pr.review",
|
||||||
"role": "reviewer",
|
"role": "reviewer",
|
||||||
},
|
},
|
||||||
|
# #709: truthful absence-of-proof recovery record (not applied cleanup).
|
||||||
|
"record_irrecoverable_decision_lock_provenance": {
|
||||||
|
"permission": "gitea.issue.comment",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
|
"gitea_record_irrecoverable_decision_lock_provenance": {
|
||||||
|
"permission": "gitea.issue.comment",
|
||||||
|
"role": "reconciler",
|
||||||
|
},
|
||||||
"delete_branch": {
|
"delete_branch": {
|
||||||
"permission": "gitea.branch.delete",
|
"permission": "gitea.branch.delete",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
|
|||||||
@@ -0,0 +1,523 @@
|
|||||||
|
"""#709: cross-profile decision-lock cleanup, overwrite protection, recovery.
|
||||||
|
|
||||||
|
Covers AC1–AC8 regression scenarios without fabricating historical PR #696
|
||||||
|
provenance or special-casing live PR numbers in production code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import mcp_session_state as ss
|
||||||
|
import stale_review_decision_lock as srdl
|
||||||
|
|
||||||
|
|
||||||
|
def _lock(
|
||||||
|
mutations=None,
|
||||||
|
*,
|
||||||
|
profile="prgs-reviewer",
|
||||||
|
remote="prgs",
|
||||||
|
head=None,
|
||||||
|
):
|
||||||
|
muts = []
|
||||||
|
for m in mutations or []:
|
||||||
|
row = dict(m)
|
||||||
|
if head and "head_sha" not in row:
|
||||||
|
row["head_sha"] = head
|
||||||
|
muts.append(row)
|
||||||
|
return {
|
||||||
|
"task": "review_pr",
|
||||||
|
"remote": remote,
|
||||||
|
"org": "Scaled-Tech-Consulting",
|
||||||
|
"repo": "Gitea-Tools",
|
||||||
|
"session_pid": os.getpid(),
|
||||||
|
"session_profile": profile,
|
||||||
|
"session_profile_lock": profile,
|
||||||
|
"profile_identity": profile,
|
||||||
|
"final_review_decision_ready": False,
|
||||||
|
"ready_pr_number": None,
|
||||||
|
"ready_action": None,
|
||||||
|
"ready_expected_head_sha": None,
|
||||||
|
"live_mutations": muts,
|
||||||
|
"correction_authorized": False,
|
||||||
|
"correction_reason": None,
|
||||||
|
"kind": ss.KIND_DECISION_LOCK,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
APPROVE = {"pr_number": 100, "action": "approve", "review_id": 9}
|
||||||
|
APPROVE_OTHER = {"pr_number": 200, "action": "approve", "review_id": 10}
|
||||||
|
HEAD_A = "a" * 40
|
||||||
|
HEAD_B = "b" * 40
|
||||||
|
|
||||||
|
|
||||||
|
class TestAC2InitOverwrite(unittest.TestCase):
|
||||||
|
def test_empty_lock_allows_reinit(self):
|
||||||
|
a = srdl.assess_init_overwrite(_lock([]), force=True)
|
||||||
|
self.assertTrue(a["overwrite_allowed"])
|
||||||
|
|
||||||
|
def test_terminal_lock_blocks_force_reinit(self):
|
||||||
|
a = srdl.assess_init_overwrite(_lock([APPROVE]), force=True)
|
||||||
|
self.assertFalse(a["overwrite_allowed"])
|
||||||
|
self.assertTrue(a["has_unresolved_terminal"])
|
||||||
|
self.assertEqual(a["last_terminal_pr"], 100)
|
||||||
|
|
||||||
|
def test_none_lock_allows_init(self):
|
||||||
|
a = srdl.assess_init_overwrite(None)
|
||||||
|
self.assertTrue(a["overwrite_allowed"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestAC1TargetApproval(unittest.TestCase):
|
||||||
|
def test_targets_matching_approve(self):
|
||||||
|
self.assertTrue(
|
||||||
|
srdl.lock_targets_merged_pr_approval(
|
||||||
|
_lock([APPROVE], head=HEAD_A),
|
||||||
|
pr_number=100,
|
||||||
|
expected_head_sha=HEAD_A,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_other_pr(self):
|
||||||
|
self.assertFalse(
|
||||||
|
srdl.lock_targets_merged_pr_approval(
|
||||||
|
_lock([APPROVE_OTHER]), pr_number=100
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_head_mismatch(self):
|
||||||
|
self.assertFalse(
|
||||||
|
srdl.lock_targets_merged_pr_approval(
|
||||||
|
_lock([APPROVE], head=HEAD_A),
|
||||||
|
pr_number=100,
|
||||||
|
expected_head_sha=HEAD_B,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAC5IrrecoverableRecord(unittest.TestCase):
|
||||||
|
def test_never_sets_applied_true(self):
|
||||||
|
rec = srdl.build_irrecoverable_provenance_record(
|
||||||
|
pr_number=42,
|
||||||
|
head_sha=HEAD_A,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
actor_username="sysadmin",
|
||||||
|
profile_name="prgs-reconciler",
|
||||||
|
reason="evidence destroyed",
|
||||||
|
incident_ref="#700 comment 1",
|
||||||
|
operator_authorized=True,
|
||||||
|
)
|
||||||
|
self.assertFalse(rec["applied"])
|
||||||
|
self.assertFalse(rec["historical_cleanup_proven"])
|
||||||
|
self.assertEqual(rec["status"], "provenance_irrecoverable")
|
||||||
|
self.assertTrue(rec["merger_may_accept"])
|
||||||
|
body = srdl.format_irrecoverable_audit_comment(rec)
|
||||||
|
self.assertIn("applied: `False`", body)
|
||||||
|
self.assertIn("must remain false", body)
|
||||||
|
|
||||||
|
def test_unauthorized_not_merger_acceptable(self):
|
||||||
|
rec = srdl.build_irrecoverable_provenance_record(
|
||||||
|
pr_number=42,
|
||||||
|
head_sha=HEAD_A,
|
||||||
|
remote="prgs",
|
||||||
|
org=None,
|
||||||
|
repo=None,
|
||||||
|
actor_username="x",
|
||||||
|
profile_name="y",
|
||||||
|
reason="r",
|
||||||
|
incident_ref=None,
|
||||||
|
operator_authorized=False,
|
||||||
|
)
|
||||||
|
self.assertFalse(rec["merger_may_accept"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestAC3PostMergeRecoveryRecord(unittest.TestCase):
|
||||||
|
def test_recovery_record_is_not_applied_cleanup(self):
|
||||||
|
rec = srdl.build_post_merge_recovery_record(
|
||||||
|
pr_number=10,
|
||||||
|
head_sha=HEAD_A,
|
||||||
|
merge_commit_sha="m" * 40,
|
||||||
|
target_profile_identity="prgs-reviewer",
|
||||||
|
failed_step="audit_comment_publish",
|
||||||
|
error="timeout",
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
actor_username="sysadmin",
|
||||||
|
profile_name="prgs-merger",
|
||||||
|
)
|
||||||
|
self.assertEqual(rec["status"], "recovery_required")
|
||||||
|
self.assertFalse(rec["applied"])
|
||||||
|
self.assertTrue(rec["recovery_critical"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionStateCrossProfile(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.state_dir = self._tmp.name
|
||||||
|
os.chmod(self.state_dir, 0o700)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def test_list_and_load_foreign_profile_lock(self):
|
||||||
|
ss.save_state(
|
||||||
|
kind=ss.KIND_DECISION_LOCK,
|
||||||
|
payload=_lock([APPROVE], profile="prgs-reviewer"),
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
profile_identity="prgs-reviewer",
|
||||||
|
state_dir=self.state_dir,
|
||||||
|
)
|
||||||
|
# Merger-local empty lock
|
||||||
|
ss.save_state(
|
||||||
|
kind=ss.KIND_DECISION_LOCK,
|
||||||
|
payload=_lock([], profile="prgs-merger"),
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
profile_identity="prgs-merger",
|
||||||
|
state_dir=self.state_dir,
|
||||||
|
)
|
||||||
|
ids = ss.list_decision_lock_profile_identities(state_dir=self.state_dir)
|
||||||
|
self.assertIn("prgs-reviewer", ids)
|
||||||
|
self.assertIn("prgs-merger", ids)
|
||||||
|
|
||||||
|
foreign = ss.load_state_for_profile(
|
||||||
|
kind=ss.KIND_DECISION_LOCK,
|
||||||
|
profile_identity="prgs-reviewer",
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
state_dir=self.state_dir,
|
||||||
|
skip_identity_match=True,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(foreign)
|
||||||
|
self.assertTrue(
|
||||||
|
srdl.lock_targets_merged_pr_approval(foreign, pr_number=100)
|
||||||
|
)
|
||||||
|
empty = ss.load_state_for_profile(
|
||||||
|
kind=ss.KIND_DECISION_LOCK,
|
||||||
|
profile_identity="prgs-merger",
|
||||||
|
state_dir=self.state_dir,
|
||||||
|
skip_identity_match=True,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(empty)
|
||||||
|
self.assertFalse(
|
||||||
|
srdl.lock_targets_merged_pr_approval(empty, pr_number=100)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_clear_reviewer_not_merger_empty(self):
|
||||||
|
ss.save_state(
|
||||||
|
kind=ss.KIND_DECISION_LOCK,
|
||||||
|
payload=_lock([APPROVE], profile="prgs-reviewer"),
|
||||||
|
profile_identity="prgs-reviewer",
|
||||||
|
state_dir=self.state_dir,
|
||||||
|
)
|
||||||
|
ss.save_state(
|
||||||
|
kind=ss.KIND_DECISION_LOCK,
|
||||||
|
payload=_lock([], profile="prgs-merger"),
|
||||||
|
profile_identity="prgs-merger",
|
||||||
|
state_dir=self.state_dir,
|
||||||
|
)
|
||||||
|
ss.clear_state(
|
||||||
|
kind=ss.KIND_DECISION_LOCK,
|
||||||
|
profile_identity="prgs-reviewer",
|
||||||
|
state_dir=self.state_dir,
|
||||||
|
)
|
||||||
|
self.assertIsNone(
|
||||||
|
ss.load_state_for_profile(
|
||||||
|
kind=ss.KIND_DECISION_LOCK,
|
||||||
|
profile_identity="prgs-reviewer",
|
||||||
|
state_dir=self.state_dir,
|
||||||
|
skip_identity_match=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Merger empty lock remains
|
||||||
|
self.assertIsNotNone(
|
||||||
|
ss.load_state_for_profile(
|
||||||
|
kind=ss.KIND_DECISION_LOCK,
|
||||||
|
profile_identity="prgs-merger",
|
||||||
|
state_dir=self.state_dir,
|
||||||
|
skip_identity_match=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_recovery_critical_kinds_ttl_exempt(self):
|
||||||
|
rec = srdl.build_irrecoverable_provenance_record(
|
||||||
|
pr_number=1,
|
||||||
|
head_sha=HEAD_A,
|
||||||
|
remote="prgs",
|
||||||
|
org=None,
|
||||||
|
repo=None,
|
||||||
|
actor_username="a",
|
||||||
|
profile_name="p",
|
||||||
|
reason="gone",
|
||||||
|
incident_ref=None,
|
||||||
|
operator_authorized=True,
|
||||||
|
)
|
||||||
|
rec["kind"] = ss.KIND_IRRECOVERABLE_DECISION_PROVENANCE
|
||||||
|
# Force old recorded_at
|
||||||
|
rec["recorded_at"] = "2000-01-01T00:00:00Z"
|
||||||
|
rec["updated_at"] = rec["recorded_at"]
|
||||||
|
rec["profile_identity"] = "prgs-reconciler"
|
||||||
|
rec["session_profile_lock"] = "prgs-reconciler"
|
||||||
|
reasons = ss.identity_match_reasons(
|
||||||
|
rec, profile_identity="prgs-reconciler"
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
any("expired" in r for r in reasons),
|
||||||
|
msg=reasons,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestInitReviewDecisionLockIntegration(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.env = patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"GITEA_MCP_SESSION_STATE_DIR": self._tmp.name,
|
||||||
|
"GITEA_SESSION_PROFILE_LOCK": "prgs-reviewer",
|
||||||
|
"GITEA_PROFILE_NAME": "prgs-reviewer",
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
)
|
||||||
|
self.env.start()
|
||||||
|
import mcp_server
|
||||||
|
|
||||||
|
self.mcp = mcp_server
|
||||||
|
self.mcp._REVIEW_DECISION_LOCK = None
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.mcp._REVIEW_DECISION_LOCK = None
|
||||||
|
self.env.stop()
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def test_init_does_not_wipe_terminal_ledger(self):
|
||||||
|
self.mcp._save_review_decision_lock(_lock([APPROVE], profile="prgs-reviewer"))
|
||||||
|
# force=True would previously wipe
|
||||||
|
self.mcp.init_review_decision_lock("prgs", "review_pr", force=True)
|
||||||
|
loaded = self.mcp._load_review_decision_lock()
|
||||||
|
self.assertIsNotNone(loaded)
|
||||||
|
last = srdl.last_terminal_mutation(loaded)
|
||||||
|
self.assertIsNotNone(last)
|
||||||
|
self.assertEqual(last.get("pr_number"), 100)
|
||||||
|
|
||||||
|
def test_init_creates_empty_when_no_terminal(self):
|
||||||
|
self.mcp._save_review_decision_lock(None)
|
||||||
|
self.mcp.init_review_decision_lock("prgs", "review_pr", force=True)
|
||||||
|
loaded = self.mcp._load_review_decision_lock()
|
||||||
|
self.assertIsNotNone(loaded)
|
||||||
|
self.assertEqual(loaded.get("live_mutations"), [])
|
||||||
|
|
||||||
|
|
||||||
|
class TestIrrecoverableTool(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.env = patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"GITEA_MCP_SESSION_STATE_DIR": self._tmp.name,
|
||||||
|
"GITEA_SESSION_PROFILE_LOCK": "prgs-reconciler",
|
||||||
|
"GITEA_PROFILE_NAME": "prgs-reconciler",
|
||||||
|
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment,gitea.pr.comment",
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
)
|
||||||
|
self.env.start()
|
||||||
|
import mcp_server
|
||||||
|
|
||||||
|
self.mcp = mcp_server
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.env.stop()
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def test_requires_confirmation_and_operator(self):
|
||||||
|
with patch.object(
|
||||||
|
self.mcp,
|
||||||
|
"get_profile",
|
||||||
|
return_value={
|
||||||
|
"profile_name": "prgs-reconciler",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
},
|
||||||
|
):
|
||||||
|
r = self.mcp.gitea_record_irrecoverable_decision_lock_provenance(
|
||||||
|
pr_number=50,
|
||||||
|
reason="lost",
|
||||||
|
confirmation="",
|
||||||
|
operator_authorized=False,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
post_audit_comment=False,
|
||||||
|
)
|
||||||
|
self.assertFalse(r["success"])
|
||||||
|
self.assertFalse(r["applied"])
|
||||||
|
|
||||||
|
def test_records_without_applied_true(self):
|
||||||
|
with patch.object(
|
||||||
|
self.mcp,
|
||||||
|
"get_profile",
|
||||||
|
return_value={
|
||||||
|
"profile_name": "prgs-reconciler",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
},
|
||||||
|
), patch.object(
|
||||||
|
self.mcp, "_authenticated_username", return_value="sysadmin"
|
||||||
|
), patch.object(
|
||||||
|
self.mcp, "_profile_operation_gate", return_value=None
|
||||||
|
), patch.object(
|
||||||
|
self.mcp, "_resolve", return_value=("h", "Scaled-Tech-Consulting", "Gitea-Tools")
|
||||||
|
):
|
||||||
|
r = self.mcp.gitea_record_irrecoverable_decision_lock_provenance(
|
||||||
|
pr_number=50,
|
||||||
|
reason="terminal evidence overwritten",
|
||||||
|
confirmation="IRRECOVERABLE DECISION PROVENANCE PR 50",
|
||||||
|
operator_authorized=True,
|
||||||
|
expected_head_sha=HEAD_A,
|
||||||
|
incident_ref="issue-700-comment-11489",
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
post_audit_comment=False,
|
||||||
|
)
|
||||||
|
self.assertTrue(r["success"])
|
||||||
|
self.assertFalse(r["applied"])
|
||||||
|
self.assertFalse(r["historical_cleanup_proven"])
|
||||||
|
self.assertEqual(r["record"]["status"], "provenance_irrecoverable")
|
||||||
|
|
||||||
|
# Idempotent replay
|
||||||
|
with patch.object(
|
||||||
|
self.mcp,
|
||||||
|
"get_profile",
|
||||||
|
return_value={
|
||||||
|
"profile_name": "prgs-reconciler",
|
||||||
|
"allowed_operations": [
|
||||||
|
"gitea.read",
|
||||||
|
"gitea.issue.comment",
|
||||||
|
"gitea.pr.comment",
|
||||||
|
],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
},
|
||||||
|
), patch.object(
|
||||||
|
self.mcp, "_authenticated_username", return_value="sysadmin"
|
||||||
|
), patch.object(
|
||||||
|
self.mcp, "_profile_operation_gate", return_value=None
|
||||||
|
), patch.object(
|
||||||
|
self.mcp, "_resolve", return_value=("h", "Scaled-Tech-Consulting", "Gitea-Tools")
|
||||||
|
):
|
||||||
|
r2 = self.mcp.gitea_record_irrecoverable_decision_lock_provenance(
|
||||||
|
pr_number=50,
|
||||||
|
reason="terminal evidence overwritten",
|
||||||
|
confirmation="IRRECOVERABLE DECISION PROVENANCE PR 50",
|
||||||
|
operator_authorized=True,
|
||||||
|
expected_head_sha=HEAD_A,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
post_audit_comment=False,
|
||||||
|
)
|
||||||
|
self.assertTrue(r2["success"])
|
||||||
|
self.assertFalse(r2["performed"]) # idempotent hit
|
||||||
|
|
||||||
|
def test_wrong_confirmation_cannot_unblock_other_pr(self):
|
||||||
|
with patch.object(
|
||||||
|
self.mcp,
|
||||||
|
"get_profile",
|
||||||
|
return_value={
|
||||||
|
"profile_name": "prgs-reconciler",
|
||||||
|
"allowed_operations": ["gitea.read", "gitea.issue.comment"],
|
||||||
|
"forbidden_operations": [],
|
||||||
|
},
|
||||||
|
), patch.object(
|
||||||
|
self.mcp, "_authenticated_username", return_value="sysadmin"
|
||||||
|
), patch.object(
|
||||||
|
self.mcp, "_profile_operation_gate", return_value=None
|
||||||
|
), patch.object(
|
||||||
|
self.mcp, "_resolve", return_value=("h", "o", "r")
|
||||||
|
):
|
||||||
|
r = self.mcp.gitea_record_irrecoverable_decision_lock_provenance(
|
||||||
|
pr_number=50,
|
||||||
|
reason="x",
|
||||||
|
confirmation="IRRECOVERABLE DECISION PROVENANCE PR 51",
|
||||||
|
operator_authorized=True,
|
||||||
|
remote="prgs",
|
||||||
|
post_audit_comment=False,
|
||||||
|
)
|
||||||
|
self.assertFalse(r["success"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestClearProfileHelper(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.env = patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"GITEA_MCP_SESSION_STATE_DIR": self._tmp.name,
|
||||||
|
"GITEA_SESSION_PROFILE_LOCK": "prgs-merger",
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
)
|
||||||
|
self.env.start()
|
||||||
|
import mcp_server
|
||||||
|
|
||||||
|
self.mcp = mcp_server
|
||||||
|
self.mcp._REVIEW_DECISION_LOCK = None
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.mcp._REVIEW_DECISION_LOCK = None
|
||||||
|
self.env.stop()
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def test_clear_only_matching_reviewer_approve(self):
|
||||||
|
ss.save_state(
|
||||||
|
kind=ss.KIND_DECISION_LOCK,
|
||||||
|
payload=_lock([APPROVE], profile="prgs-reviewer", head=HEAD_A),
|
||||||
|
profile_identity="prgs-reviewer",
|
||||||
|
state_dir=self._tmp.name,
|
||||||
|
)
|
||||||
|
ss.save_state(
|
||||||
|
kind=ss.KIND_DECISION_LOCK,
|
||||||
|
payload=_lock([], profile="prgs-merger"),
|
||||||
|
profile_identity="prgs-merger",
|
||||||
|
state_dir=self._tmp.name,
|
||||||
|
)
|
||||||
|
out = self.mcp._clear_decision_lock_for_profile(
|
||||||
|
profile_identity="prgs-reviewer",
|
||||||
|
pr_number=100,
|
||||||
|
expected_head_sha=HEAD_A,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertTrue(out["cleared"])
|
||||||
|
skip = self.mcp._clear_decision_lock_for_profile(
|
||||||
|
profile_identity="prgs-merger",
|
||||||
|
pr_number=100,
|
||||||
|
expected_head_sha=HEAD_A,
|
||||||
|
remote="prgs",
|
||||||
|
org="Scaled-Tech-Consulting",
|
||||||
|
repo="Gitea-Tools",
|
||||||
|
)
|
||||||
|
self.assertFalse(skip["cleared"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user