feat: add reconciler supersession close gate
This commit is contained in:
@@ -5725,6 +5725,259 @@ def gitea_reconcile_already_landed_pr(
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_reconcile_superseded_by_merged_pr(
|
||||
target_pr_number: int,
|
||||
superseding_pr_number: int,
|
||||
target_issue_number: int | None = None,
|
||||
target_independently_required: bool | None = None,
|
||||
issue_satisfied_by_superseding: bool = False,
|
||||
close_pr: bool = False,
|
||||
close_issue: bool = False,
|
||||
post_comment: bool = False,
|
||||
comment_body: str = "",
|
||||
target_branch: str = "master",
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
) -> dict:
|
||||
"""Reconcile a PR/issue superseded by a different merged PR (#525).
|
||||
|
||||
The tool always performs live-state assessment. Mutations require a
|
||||
reconciler-capable profile, a merged superseding PR whose head is on the
|
||||
freshly fetched target branch, explicit proof that the target PR has no
|
||||
independent remaining work, and a canonical close comment that cites the
|
||||
superseding PR and merge commit.
|
||||
"""
|
||||
read_block = _profile_operation_gate("gitea.read")
|
||||
if read_block:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"reasons": read_block,
|
||||
"permission_report": _permission_block_report("gitea.read"),
|
||||
}
|
||||
|
||||
close_pr_capability = True
|
||||
close_issue_capability = True
|
||||
if close_pr:
|
||||
close_block = _profile_operation_gate("gitea.pr.close")
|
||||
close_pr_capability = not bool(close_block)
|
||||
if close_block:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"target_pr_number": target_pr_number,
|
||||
"required_permission": "gitea.pr.close",
|
||||
"reasons": close_block,
|
||||
"permission_report": _permission_block_report("gitea.pr.close"),
|
||||
"safe_next_action": (
|
||||
"Launch the prgs-reconciler MCP namespace or configure a "
|
||||
"profile with gitea.pr.close for supersession cleanup."
|
||||
),
|
||||
}
|
||||
if close_issue:
|
||||
issue_block = _profile_operation_gate("gitea.issue.close")
|
||||
close_issue_capability = not bool(issue_block)
|
||||
if issue_block:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"target_issue_number": target_issue_number,
|
||||
"required_permission": "gitea.issue.close",
|
||||
"reasons": issue_block,
|
||||
"permission_report": _permission_block_report("gitea.issue.close"),
|
||||
}
|
||||
if post_comment:
|
||||
comment_block = _profile_operation_gate("gitea.pr.comment")
|
||||
if comment_block:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"target_pr_number": target_pr_number,
|
||||
"required_permission": "gitea.pr.comment",
|
||||
"reasons": comment_block,
|
||||
"permission_report": _permission_block_report("gitea.pr.comment"),
|
||||
}
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
base = repo_api_url(h, o, r)
|
||||
target_pr = api_request("GET", f"{base}/pulls/{target_pr_number}", auth)
|
||||
superseding_pr = api_request(
|
||||
"GET", f"{base}/pulls/{superseding_pr_number}", auth
|
||||
)
|
||||
|
||||
target_fetch = already_landed_reconcile.fetch_target_branch(
|
||||
PROJECT_ROOT, remote, target_branch
|
||||
)
|
||||
superseding_head_sha = (
|
||||
(superseding_pr.get("head") or {}).get("sha")
|
||||
if isinstance(superseding_pr.get("head"), dict)
|
||||
else None
|
||||
)
|
||||
ancestor = None
|
||||
if target_fetch.get("success"):
|
||||
ancestor = already_landed_reconcile.is_head_ancestor_of_ref(
|
||||
PROJECT_ROOT,
|
||||
superseding_head_sha,
|
||||
target_fetch.get("target_ref") or f"{remote}/{target_branch}",
|
||||
)
|
||||
|
||||
issue_state = None
|
||||
if target_issue_number:
|
||||
issue = api_request("GET", f"{base}/issues/{target_issue_number}", auth)
|
||||
issue_state = issue.get("state")
|
||||
|
||||
stripped_comment = comment_body.strip()
|
||||
canonical_gate = (
|
||||
_canonical_comment_gate(stripped_comment)
|
||||
if stripped_comment
|
||||
else {"blocked": True, "canonical_comment_validation": None}
|
||||
)
|
||||
merge_commit_sha = (superseding_pr.get("merge_commit_sha") or "").strip()
|
||||
comment_mentions_superseding = (
|
||||
f"#{superseding_pr_number}" in stripped_comment
|
||||
or f"PR {superseding_pr_number}" in stripped_comment
|
||||
)
|
||||
comment_mentions_merge = bool(
|
||||
merge_commit_sha and merge_commit_sha in stripped_comment
|
||||
)
|
||||
|
||||
gate = review_proofs.assess_reconciler_supersession_close_gate(
|
||||
target_pr_number=target_pr_number,
|
||||
target_pr_state=target_pr.get("state"),
|
||||
target_pr_mergeable=target_pr.get("mergeable"),
|
||||
target_independently_required=target_independently_required,
|
||||
superseding_pr_number=superseding_pr_number,
|
||||
superseding_pr_state=superseding_pr.get("state"),
|
||||
superseding_pr_merged=bool(superseding_pr.get("merged")),
|
||||
superseding_merge_commit_sha=merge_commit_sha,
|
||||
superseding_head_sha=superseding_head_sha,
|
||||
target_branch=target_branch,
|
||||
target_branch_sha=target_fetch.get("target_branch_sha"),
|
||||
superseding_head_is_ancestor_of_target=ancestor,
|
||||
canonical_comment_valid=not canonical_gate["blocked"],
|
||||
canonical_comment_mentions_superseding_pr=comment_mentions_superseding,
|
||||
canonical_comment_mentions_merge_commit=comment_mentions_merge,
|
||||
close_pr_capability=close_pr_capability,
|
||||
close_issue_capability=close_issue_capability,
|
||||
target_issue_state=issue_state,
|
||||
issue_satisfied_by_superseding=issue_satisfied_by_superseding,
|
||||
)
|
||||
|
||||
result: dict = {
|
||||
"success": gate["pr_close_allowed"],
|
||||
"performed": False,
|
||||
"target_pr_number": target_pr_number,
|
||||
"superseding_pr_number": superseding_pr_number,
|
||||
"target_issue_number": target_issue_number,
|
||||
"target_pr_state": target_pr.get("state"),
|
||||
"target_pr_mergeable": target_pr.get("mergeable"),
|
||||
"target_independently_required": target_independently_required,
|
||||
"superseding_pr_state": superseding_pr.get("state"),
|
||||
"superseding_pr_merged": bool(superseding_pr.get("merged")),
|
||||
"superseding_merge_commit_sha": merge_commit_sha,
|
||||
"superseding_head_sha": superseding_head_sha,
|
||||
"target_branch": target_branch,
|
||||
"target_branch_sha": target_fetch.get("target_branch_sha"),
|
||||
"superseding_head_is_ancestor_of_target": ancestor,
|
||||
"linked_issue_live_status": issue_state,
|
||||
"gate": gate,
|
||||
"reasons": list(gate.get("reasons") or []),
|
||||
"canonical_comment_validation": canonical_gate.get(
|
||||
"canonical_comment_validation"
|
||||
),
|
||||
"pr_comment_posted": False,
|
||||
"pr_closed": False,
|
||||
"issue_closed": False,
|
||||
}
|
||||
|
||||
wants_mutation = close_pr or close_issue or post_comment
|
||||
if not gate["pr_close_allowed"]:
|
||||
result["safe_next_action"] = gate.get("safe_next_action")
|
||||
return result
|
||||
if close_issue and not gate["issue_close_allowed"]:
|
||||
result["success"] = False
|
||||
result["safe_next_action"] = (
|
||||
"do not mutate; linked issue close was requested but the "
|
||||
"supersession gate did not allow issue closure"
|
||||
)
|
||||
return result
|
||||
if not wants_mutation:
|
||||
result["success"] = True
|
||||
result["safe_next_action"] = "rerun with explicit mutation flags"
|
||||
return result
|
||||
|
||||
verify_preflight_purity(remote, task="reconcile_close_superseded_pr")
|
||||
|
||||
if post_comment:
|
||||
comment_url = f"{base}/issues/{target_pr_number}/comments"
|
||||
with _audited(
|
||||
"comment_pr",
|
||||
host=h,
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
pr_number=target_pr_number,
|
||||
request_metadata={"source": "reconcile_superseded_by_merged_pr"},
|
||||
):
|
||||
api_request("POST", comment_url, auth, {"body": stripped_comment})
|
||||
result["pr_comment_posted"] = True
|
||||
result["performed"] = True
|
||||
|
||||
if close_pr:
|
||||
with _audited(
|
||||
"close_pr",
|
||||
host=h,
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
pr_number=target_pr_number,
|
||||
request_metadata={
|
||||
"source": "reconcile_superseded_by_merged_pr",
|
||||
"superseding_pr_number": superseding_pr_number,
|
||||
"superseding_merge_commit_sha": merge_commit_sha,
|
||||
"target_branch_sha": target_fetch.get("target_branch_sha"),
|
||||
},
|
||||
):
|
||||
closed = api_request(
|
||||
"PATCH",
|
||||
f"{base}/pulls/{target_pr_number}",
|
||||
auth,
|
||||
{"state": "closed"},
|
||||
)
|
||||
result["target_pr_state"] = closed.get("state", "closed")
|
||||
result["pr_closed"] = True
|
||||
result["performed"] = True
|
||||
|
||||
if close_issue and target_issue_number and gate["issue_close_allowed"]:
|
||||
with _audited(
|
||||
"close_issue",
|
||||
host=h,
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
issue_number=target_issue_number,
|
||||
request_metadata={
|
||||
"source": "reconcile_superseded_by_merged_pr",
|
||||
"superseding_pr_number": superseding_pr_number,
|
||||
"superseding_merge_commit_sha": merge_commit_sha,
|
||||
},
|
||||
):
|
||||
api_request(
|
||||
"PATCH",
|
||||
f"{base}/issues/{target_issue_number}",
|
||||
auth,
|
||||
{"state": "closed"},
|
||||
)
|
||||
result["issue_closed"] = True
|
||||
result["performed"] = True
|
||||
|
||||
return result
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_close_issue(
|
||||
issue_number: int,
|
||||
|
||||
Reference in New Issue
Block a user