feat: add safe post-merge moot lease cleanup for already-merged PRs (Closes #515)
Refuse merge-oriented reviewer-lease acquisition/adoption on an already merged/closed PR, and add a read-first cleanup path for a lingering moot lease. - reviewer_pr_lease.assess_acquire_lease: new pr_merged_or_closed flag; fail closed with a post_merge_moot reason and no lease body when the PR already merged/closed. - reviewer_pr_lease.assess_post_merge_moot_lease: pure assessment. Only treats a lease as moot when the PR is merged/closed; never proposes touching an active lease on an open PR; provides a terminal phase:released (blocker: post-merge-moot) body to neutralise the moot lease append-only; idempotent once the released marker is the newest entry. - gitea_acquire_reviewer_pr_lease: fetch live PR state, pass the flag, surface post_merge_moot on refusal (no comment posted). - gitea_cleanup_post_merge_moot_lease: new read-first tool. Reports merged state, merge_commit_sha, linked-issue closure, lease-moot, cleanup performed/skipped, and no_merge_or_adoption. apply=True posts the released marker only when merged/closed with an active lease; refuses on open PRs. - tests/test_post_merge_moot_lease.py: 10 tests covering acquire refusal on merged PR (no post), safe/idempotent cleanup, and open-PR foreign-lease protection. No relaxation of #407 reviewer-lease or #16 gated-merge gates for open PRs. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -5064,6 +5064,13 @@ def gitea_acquire_reviewer_pr_lease(
|
||||
|
||||
comments = _fetch_pr_comments(
|
||||
pr_number, remote=remote, host=host, org=org, repo=repo)
|
||||
# Refuse merge-oriented lease acquisition/adoption on an already-merged or
|
||||
# closed PR: the lease is moot and adopting it for merge work is unsafe (#515).
|
||||
pr_live = api_request(
|
||||
"GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth) or {}
|
||||
pr_merged_or_closed = bool(
|
||||
pr_live.get("merged") or pr_live.get("merged_at")
|
||||
) or (str(pr_live.get("state") or "").strip().lower() == "closed")
|
||||
assessment = reviewer_pr_lease.assess_acquire_lease(
|
||||
comments,
|
||||
pr_number=pr_number,
|
||||
@@ -5076,6 +5083,7 @@ def gitea_acquire_reviewer_pr_lease(
|
||||
candidate_head=candidate_head,
|
||||
target_branch=target_branch,
|
||||
target_branch_sha=target_branch_sha,
|
||||
pr_merged_or_closed=pr_merged_or_closed,
|
||||
)
|
||||
if not assessment.get("acquire_allowed"):
|
||||
return {
|
||||
@@ -5083,6 +5091,7 @@ def gitea_acquire_reviewer_pr_lease(
|
||||
"acquired": False,
|
||||
"reasons": assessment.get("reasons") or [],
|
||||
"existing_lease": assessment.get("existing_lease"),
|
||||
"post_merge_moot": assessment.get("post_merge_moot", False),
|
||||
}
|
||||
|
||||
body = assessment["lease_body"]
|
||||
@@ -5231,6 +5240,126 @@ def gitea_assess_reviewer_pr_lease(
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_cleanup_post_merge_moot_lease(
|
||||
pr_number: int,
|
||||
apply: bool = False,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
) -> dict:
|
||||
"""Safely resolve a moot reviewer lease left on an ALREADY-MERGED/closed PR (#515).
|
||||
|
||||
Read-first and fail-safe. This tool never merges and never adopts a lease.
|
||||
It only acts when the live PR state is merged/closed, and it never steals or
|
||||
force-cleans an active *foreign* lease while the PR is still open. When
|
||||
``apply`` is true and a lease is still active on a merged/closed PR, it posts
|
||||
a terminal ``phase: released`` lease marker (``blocker: post-merge-moot``) —
|
||||
an append-only comment that neutralises the moot lease without deleting any
|
||||
other session's comment.
|
||||
|
||||
Args:
|
||||
pr_number: The PR whose lingering lease to assess/clean.
|
||||
apply: When false (default) report only (read-only). When true, post the
|
||||
terminal released marker if — and only if — cleanup is allowed.
|
||||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||||
host: Override the Gitea host.
|
||||
org: Override the owner/organization.
|
||||
repo: Override the repository name.
|
||||
|
||||
Returns:
|
||||
dict reporting PR merged/closed state, merge_commit_sha, linked-issue
|
||||
closure state, whether the lease is moot, whether cleanup was performed
|
||||
or skipped (and why), and ``no_merge_or_adoption`` True — this path never
|
||||
merges or adopts.
|
||||
"""
|
||||
read_block = _profile_operation_gate("gitea.read")
|
||||
if read_block:
|
||||
return {
|
||||
"success": False,
|
||||
"cleanup_performed": False,
|
||||
"no_merge_or_adoption": True,
|
||||
"reasons": read_block,
|
||||
"permission_report": _permission_block_report("gitea.read"),
|
||||
}
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
pr_live = api_request(
|
||||
"GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth) or {}
|
||||
comments = _fetch_pr_comments(
|
||||
pr_number, remote=remote, host=host, org=org, repo=repo)
|
||||
assessment = reviewer_pr_lease.assess_post_merge_moot_lease(
|
||||
comments,
|
||||
pr_number=pr_number,
|
||||
pr_merged=bool(pr_live.get("merged") or pr_live.get("merged_at")),
|
||||
pr_state=pr_live.get("state"),
|
||||
merge_commit_sha=pr_live.get("merge_commit_sha"),
|
||||
)
|
||||
|
||||
# Best-effort linked-issue closure state for the report.
|
||||
active = assessment.get("active_lease") or {}
|
||||
issue_no = active.get("issue_number")
|
||||
linked_issue_state = None
|
||||
if issue_no:
|
||||
try:
|
||||
issue = api_request(
|
||||
"GET", f"{repo_api_url(h, o, r)}/issues/{issue_no}", auth) or {}
|
||||
linked_issue_state = issue.get("state")
|
||||
except Exception:
|
||||
linked_issue_state = None
|
||||
|
||||
report = {
|
||||
"success": True,
|
||||
"pr_number": pr_number,
|
||||
"pr_state": assessment.get("pr_state"),
|
||||
"pr_merged_or_closed": assessment.get("pr_merged_or_closed"),
|
||||
"merge_commit_sha": assessment.get("merge_commit_sha"),
|
||||
"linked_issue_number": issue_no,
|
||||
"linked_issue_state": linked_issue_state,
|
||||
"lease_moot": assessment.get("is_moot"),
|
||||
"active_lease": assessment.get("active_lease"),
|
||||
"cleanup_allowed": assessment.get("cleanup_allowed"),
|
||||
"cleanup_performed": False,
|
||||
"no_merge_or_adoption": True,
|
||||
"mode": "apply" if apply else "read_only",
|
||||
"reasons": assessment.get("reasons") or [],
|
||||
}
|
||||
|
||||
if not apply:
|
||||
return report
|
||||
if not assessment.get("cleanup_allowed"):
|
||||
report["cleanup_skipped_reason"] = (
|
||||
assessment.get("reasons") or ["cleanup not allowed"]
|
||||
)
|
||||
return report
|
||||
|
||||
comment_block = _profile_operation_gate("gitea.pr.comment")
|
||||
if comment_block:
|
||||
report["success"] = False
|
||||
report["reasons"] = comment_block
|
||||
report["permission_report"] = _permission_block_report("gitea.pr.comment")
|
||||
return report
|
||||
|
||||
verify_preflight_purity(remote)
|
||||
body = assessment["release_body"]
|
||||
comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
|
||||
with _audited(
|
||||
"comment_pr",
|
||||
host=h,
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
pr_number=pr_number,
|
||||
request_metadata={"source": "cleanup_post_merge_moot_lease"},
|
||||
):
|
||||
posted = api_request("POST", comment_url, auth, {"body": body})
|
||||
|
||||
report["cleanup_performed"] = True
|
||||
report["released_comment_id"] = posted.get("id")
|
||||
return report
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_list_issue_comments(
|
||||
issue_number: int,
|
||||
|
||||
Reference in New Issue
Block a user