feat: add reconciler supersession close gate
This commit is contained in:
@@ -811,6 +811,32 @@ author work. Do not switch from `prgs-reconciler` to `prgs-author` only to run
|
||||
- **Reports:** label cleanup actions as reconciler cleanup (not author mutation).
|
||||
- **Prompt:** `As prgs-reconciler, dry-run then execute gitea_reconcile_merged_cleanups for recently merged PRs without switching to prgs-author.`
|
||||
|
||||
### Superseded PR / satisfied issue reconciliation (#525)
|
||||
|
||||
Closing duplicate or superseded PRs after a canonical PR has merged is
|
||||
**reconciler** work. Do not switch to `prgs-author` only to close the duplicate
|
||||
PR, close the satisfied issue, or file a narrow follow-up discovered during
|
||||
reconciliation.
|
||||
|
||||
- **Profile:** `prgs-reconciler`.
|
||||
- **Tasks:** `reconcile_close_superseded_pr`,
|
||||
`reconcile_close_satisfied_issue`, and
|
||||
`reconcile_create_followup_issue`.
|
||||
- **Tool:** `gitea_reconcile_superseded_by_merged_pr`.
|
||||
- **Required proof:**
|
||||
1. Live target PR state is open, but it is not independently required and no
|
||||
mergeable work remains.
|
||||
2. Live superseding PR state is closed and merged.
|
||||
3. Superseding PR head is an ancestor of freshly fetched `master`.
|
||||
4. Merge commit SHA is recorded.
|
||||
5. Canonical close comment cites the superseding PR and merge commit.
|
||||
6. Linked issue closure is attempted only when the issue is open and
|
||||
explicitly satisfied by the superseding PR.
|
||||
- **Fail closed:** missing ancestry proof, missing canonical comment,
|
||||
mergeable target PR, same target/superseding PR, or missing
|
||||
`gitea.pr.close` / `gitea.issue.close` capability.
|
||||
- **Prompt:** `As prgs-reconciler, reconcile PR #N as superseded by merged PR #M; post the canonical close comment, close the superseded PR, and close issue #K only if the merged PR fully satisfies it.`
|
||||
|
||||
### Stop on blocker
|
||||
|
||||
- **Any profile.** If a required gate cannot be satisfied — identity
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4842,6 +4842,22 @@ RECONCILER_CLOSE_REPORT_FIELDS = (
|
||||
"no review/merge confirmation",
|
||||
)
|
||||
|
||||
RECONCILER_SUPERSESSION_REPORT_FIELDS = (
|
||||
"identity/profile",
|
||||
"supersession close capability proof",
|
||||
"target PR live state",
|
||||
"target PR independent-work proof",
|
||||
"superseding PR merged state",
|
||||
"superseding merge commit SHA",
|
||||
"target branch SHA",
|
||||
"superseding ancestry proof",
|
||||
"canonical close comment",
|
||||
"linked issue status",
|
||||
"PR close result",
|
||||
"issue close result",
|
||||
"no review/merge confirmation",
|
||||
)
|
||||
|
||||
|
||||
def assess_reconciler_close_gate(
|
||||
*,
|
||||
@@ -4988,6 +5004,157 @@ def assess_reconciler_close_gate(
|
||||
}
|
||||
|
||||
|
||||
def assess_reconciler_supersession_close_gate(
|
||||
*,
|
||||
target_pr_number: int | None,
|
||||
target_pr_state: str | None,
|
||||
target_pr_mergeable: bool | None,
|
||||
target_independently_required: bool | None,
|
||||
superseding_pr_number: int | None,
|
||||
superseding_pr_state: str | None,
|
||||
superseding_pr_merged: bool,
|
||||
superseding_merge_commit_sha: str | None,
|
||||
superseding_head_sha: str | None,
|
||||
target_branch: str | None,
|
||||
target_branch_sha: str | None,
|
||||
superseding_head_is_ancestor_of_target: bool | None,
|
||||
canonical_comment_valid: bool,
|
||||
canonical_comment_mentions_superseding_pr: bool,
|
||||
canonical_comment_mentions_merge_commit: bool,
|
||||
close_pr_capability: bool,
|
||||
close_issue_capability: bool = False,
|
||||
target_issue_state: str | None = None,
|
||||
issue_satisfied_by_superseding: bool = False,
|
||||
) -> dict:
|
||||
"""Gate reconciler closure of PRs/issues superseded by a merged PR.
|
||||
|
||||
This is stricter than already-landed cleanup: the target PR may be closed
|
||||
only after a named superseding PR is live-verified merged, its head is on a
|
||||
freshly fetched target branch, the target PR is proven not independently
|
||||
required, and a canonical close comment cites the superseding PR and merge
|
||||
commit.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
if not isinstance(target_pr_number, int) or target_pr_number <= 0:
|
||||
reasons.append("target PR number missing or invalid (#525)")
|
||||
if not isinstance(superseding_pr_number, int) or superseding_pr_number <= 0:
|
||||
reasons.append("superseding PR number missing or invalid (#525)")
|
||||
if target_pr_number and superseding_pr_number and (
|
||||
target_pr_number == superseding_pr_number
|
||||
):
|
||||
reasons.append("target PR and superseding PR must differ (#525)")
|
||||
if (target_pr_state or "").strip().lower() != "open":
|
||||
reasons.append("target PR must be live-verified open before close (#525)")
|
||||
if target_pr_mergeable is True:
|
||||
reasons.append(
|
||||
"target PR is still mergeable; prove it is superseded before close (#525)"
|
||||
)
|
||||
if target_independently_required is not False:
|
||||
reasons.append(
|
||||
"target PR independent-work proof missing; close requires explicit "
|
||||
"proof that no required work remains (#525)"
|
||||
)
|
||||
if (superseding_pr_state or "").strip().lower() != "closed":
|
||||
reasons.append("superseding PR must be live-verified closed (#525)")
|
||||
if superseding_pr_merged is not True:
|
||||
reasons.append("superseding PR must be live-verified merged (#525)")
|
||||
if not _FULL_SHA.match((superseding_merge_commit_sha or "").strip()):
|
||||
reasons.append("superseding merge commit SHA missing or invalid (#525)")
|
||||
if not _FULL_SHA.match((superseding_head_sha or "").strip()):
|
||||
reasons.append("superseding head SHA missing or invalid (#525)")
|
||||
if not (target_branch or "").strip():
|
||||
reasons.append("target branch missing (#525)")
|
||||
if not _FULL_SHA.match((target_branch_sha or "").strip()):
|
||||
reasons.append("target branch SHA missing or invalid (#525)")
|
||||
if superseding_head_is_ancestor_of_target is None:
|
||||
reasons.append("superseding ancestry proof not checked (#525)")
|
||||
if not canonical_comment_valid:
|
||||
reasons.append("canonical close comment failed validation (#525)")
|
||||
if not canonical_comment_mentions_superseding_pr:
|
||||
reasons.append("canonical comment must cite superseding PR (#525)")
|
||||
if not canonical_comment_mentions_merge_commit:
|
||||
reasons.append("canonical comment must cite merge commit SHA (#525)")
|
||||
|
||||
never_allowed = {
|
||||
"review_allowed": False,
|
||||
"approve_allowed": False,
|
||||
"request_changes_allowed": False,
|
||||
"merge_allowed": False,
|
||||
}
|
||||
|
||||
if reasons:
|
||||
return {
|
||||
"outcome": "GATE_NOT_PROVEN",
|
||||
"pr_close_allowed": False,
|
||||
"issue_close_allowed": False,
|
||||
"reasons": reasons,
|
||||
"required_report_fields": RECONCILER_SUPERSESSION_REPORT_FIELDS,
|
||||
"safe_next_action": (
|
||||
"prove superseding PR state, target branch ancestry, target "
|
||||
"supersession, and canonical comment before closing"
|
||||
),
|
||||
**never_allowed,
|
||||
}
|
||||
|
||||
if superseding_head_is_ancestor_of_target is False:
|
||||
return {
|
||||
"outcome": "SUPERSEDING_PR_NOT_ON_TARGET",
|
||||
"pr_close_allowed": False,
|
||||
"issue_close_allowed": False,
|
||||
"reasons": [
|
||||
f"superseding PR #{superseding_pr_number} head is not an "
|
||||
f"ancestor of {target_branch}; supersession close denied (#525)"
|
||||
],
|
||||
"required_report_fields": RECONCILER_SUPERSESSION_REPORT_FIELDS,
|
||||
"safe_next_action": "re-fetch target branch and re-check ancestry",
|
||||
**never_allowed,
|
||||
}
|
||||
|
||||
if close_pr_capability is not True:
|
||||
return {
|
||||
"outcome": "RECOVERY_HANDOFF_REQUIRED",
|
||||
"pr_close_allowed": False,
|
||||
"issue_close_allowed": False,
|
||||
"reasons": [
|
||||
"exact gitea.pr.close capability not proven; produce a "
|
||||
"recovery handoff instead of closing (#525)"
|
||||
],
|
||||
"required_report_fields": RECONCILER_SUPERSESSION_REPORT_FIELDS,
|
||||
"safe_next_action": (
|
||||
"launch a reconciler profile with gitea.pr.close and replay "
|
||||
"the live-state proof"
|
||||
),
|
||||
**never_allowed,
|
||||
}
|
||||
|
||||
issue_close_allowed = (
|
||||
(target_issue_state or "").strip().lower() == "open"
|
||||
and issue_satisfied_by_superseding is True
|
||||
and close_issue_capability is True
|
||||
)
|
||||
issue_reasons = []
|
||||
if (target_issue_state or "").strip().lower() == "closed":
|
||||
issue_reasons.append("linked issue already closed; no issue close attempted (#525)")
|
||||
elif target_issue_state and not issue_close_allowed:
|
||||
issue_reasons.append(
|
||||
"issue close requires an open issue satisfied by the superseding "
|
||||
"merged PR and exact gitea.issue.close capability (#525)"
|
||||
)
|
||||
|
||||
return {
|
||||
"outcome": "SUPERSESSION_CLOSE_ALLOWED",
|
||||
"pr_close_allowed": True,
|
||||
"issue_close_allowed": issue_close_allowed,
|
||||
"reasons": issue_reasons,
|
||||
"required_report_fields": RECONCILER_SUPERSESSION_REPORT_FIELDS,
|
||||
"safe_next_action": (
|
||||
"post the canonical comment, close the superseded PR, and close "
|
||||
"the linked issue only when issue_close_allowed is true"
|
||||
),
|
||||
**never_allowed,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Identity disclosure (#305)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -114,6 +114,9 @@ TASK_REQUIRED_ROLE = {
|
||||
# #309: reconciler tasks close already-landed PRs/issues only.
|
||||
"reconcile_close_landed_pr": "reconciler",
|
||||
"reconcile_close_landed_issue": "reconciler",
|
||||
"reconcile_close_superseded_pr": "reconciler",
|
||||
"reconcile_close_satisfied_issue": "reconciler",
|
||||
"reconcile_create_followup_issue": "reconciler",
|
||||
}
|
||||
|
||||
WRONG_ROLE_REVIEWER_MSG = (
|
||||
|
||||
@@ -150,6 +150,18 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.issue.close",
|
||||
"role": "reconciler",
|
||||
},
|
||||
"reconcile_close_superseded_pr": {
|
||||
"permission": "gitea.pr.close",
|
||||
"role": "reconciler",
|
||||
},
|
||||
"reconcile_close_satisfied_issue": {
|
||||
"permission": "gitea.issue.close",
|
||||
"role": "reconciler",
|
||||
},
|
||||
"reconcile_create_followup_issue": {
|
||||
"permission": "gitea.issue.create",
|
||||
"role": "reconciler",
|
||||
},
|
||||
"post_heartbeat": {
|
||||
"permission": "gitea.issue.comment",
|
||||
"role": "author",
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Issue #525: reconciler supersession close path."""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import mcp_server # noqa: E402
|
||||
import role_session_router # noqa: E402
|
||||
import task_capability_map # noqa: E402
|
||||
from review_proofs import assess_reconciler_supersession_close_gate # noqa: E402
|
||||
|
||||
|
||||
HEAD = "0fdc8f582026b72a229d59a172c0a63ac4aaeaf9"
|
||||
MERGE = "1111111111111111111111111111111111111111"
|
||||
TARGET = "2222222222222222222222222222222222222222"
|
||||
|
||||
|
||||
def _target_pr(mergeable=False):
|
||||
return {
|
||||
"number": 490,
|
||||
"state": "open",
|
||||
"mergeable": mergeable,
|
||||
"head": {"sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
|
||||
}
|
||||
|
||||
|
||||
def _superseding_pr():
|
||||
return {
|
||||
"number": 502,
|
||||
"state": "closed",
|
||||
"merged": True,
|
||||
"merge_commit_sha": MERGE,
|
||||
"head": {"sha": HEAD},
|
||||
}
|
||||
|
||||
|
||||
class TestReconcilerSupersessionCapabilityMap(unittest.TestCase):
|
||||
def test_supersession_tasks_route_to_reconciler(self):
|
||||
self.assertEqual(
|
||||
task_capability_map.required_permission(
|
||||
"reconcile_close_superseded_pr"),
|
||||
"gitea.pr.close",
|
||||
)
|
||||
self.assertEqual(
|
||||
task_capability_map.required_role("reconcile_close_superseded_pr"),
|
||||
"reconciler",
|
||||
)
|
||||
self.assertEqual(
|
||||
task_capability_map.required_permission(
|
||||
"reconcile_close_satisfied_issue"),
|
||||
"gitea.issue.close",
|
||||
)
|
||||
self.assertEqual(
|
||||
task_capability_map.required_role("reconcile_create_followup_issue"),
|
||||
"reconciler",
|
||||
)
|
||||
|
||||
def test_author_session_cannot_route_supersession_close(self):
|
||||
result = role_session_router.route_task_session(
|
||||
"reconcile_close_superseded_pr",
|
||||
active_profile="prgs-author",
|
||||
active_role_kind="author",
|
||||
allowed_in_current_session=False,
|
||||
)
|
||||
self.assertEqual(result["route_result"], "wrong_role_stop")
|
||||
self.assertFalse(result["downstream_allowed"])
|
||||
|
||||
|
||||
class TestReconcilerSupersessionCloseGate(unittest.TestCase):
|
||||
def _gate(self, **overrides):
|
||||
kwargs = {
|
||||
"target_pr_number": 490,
|
||||
"target_pr_state": "open",
|
||||
"target_pr_mergeable": False,
|
||||
"target_independently_required": False,
|
||||
"superseding_pr_number": 502,
|
||||
"superseding_pr_state": "closed",
|
||||
"superseding_pr_merged": True,
|
||||
"superseding_merge_commit_sha": MERGE,
|
||||
"superseding_head_sha": HEAD,
|
||||
"target_branch": "master",
|
||||
"target_branch_sha": TARGET,
|
||||
"superseding_head_is_ancestor_of_target": True,
|
||||
"canonical_comment_valid": True,
|
||||
"canonical_comment_mentions_superseding_pr": True,
|
||||
"canonical_comment_mentions_merge_commit": True,
|
||||
"close_pr_capability": True,
|
||||
"close_issue_capability": True,
|
||||
"target_issue_state": "open",
|
||||
"issue_satisfied_by_superseding": True,
|
||||
}
|
||||
kwargs.update(overrides)
|
||||
return assess_reconciler_supersession_close_gate(**kwargs)
|
||||
|
||||
def test_supersession_close_allowed_with_full_proof(self):
|
||||
result = self._gate()
|
||||
self.assertEqual(result["outcome"], "SUPERSESSION_CLOSE_ALLOWED")
|
||||
self.assertTrue(result["pr_close_allowed"])
|
||||
self.assertTrue(result["issue_close_allowed"])
|
||||
self.assertFalse(result["review_allowed"])
|
||||
self.assertFalse(result["merge_allowed"])
|
||||
|
||||
def test_mergeable_target_still_blocks(self):
|
||||
result = self._gate(target_pr_mergeable=True)
|
||||
self.assertEqual(result["outcome"], "GATE_NOT_PROVEN")
|
||||
self.assertFalse(result["pr_close_allowed"])
|
||||
self.assertTrue(any("mergeable" in r for r in result["reasons"]))
|
||||
|
||||
def test_requires_canonical_comment_with_merge_commit(self):
|
||||
result = self._gate(canonical_comment_mentions_merge_commit=False)
|
||||
self.assertEqual(result["outcome"], "GATE_NOT_PROVEN")
|
||||
self.assertFalse(result["pr_close_allowed"])
|
||||
self.assertTrue(any("merge commit" in r for r in result["reasons"]))
|
||||
|
||||
def test_superseding_head_must_be_on_target_branch(self):
|
||||
result = self._gate(superseding_head_is_ancestor_of_target=False)
|
||||
self.assertEqual(result["outcome"], "SUPERSEDING_PR_NOT_ON_TARGET")
|
||||
self.assertFalse(result["pr_close_allowed"])
|
||||
|
||||
def test_close_capability_required(self):
|
||||
result = self._gate(close_pr_capability=False)
|
||||
self.assertEqual(result["outcome"], "RECOVERY_HANDOFF_REQUIRED")
|
||||
self.assertFalse(result["pr_close_allowed"])
|
||||
|
||||
|
||||
class TestReconcilerSupersessionMcpTool(unittest.TestCase):
|
||||
def _comment(self):
|
||||
return f"""## Canonical PR State
|
||||
STATE: superseded
|
||||
NEXT_ACTION: Close superseded PR #490 and issue #470; PR #502 is canonical.
|
||||
NEXT_ACTOR: prgs-reconciler
|
||||
SUMMARY: PR #490 is superseded by merged PR #502 at merge commit {MERGE}.
|
||||
CANONICAL_ITEM: PR #502
|
||||
SUPERSEDED_ITEM: PR #490
|
||||
CLOSE_OR_KEEP_OPEN: close superseded PR #490 and satisfied issue #470
|
||||
"""
|
||||
|
||||
@patch("mcp_server.verify_preflight_purity")
|
||||
@patch("mcp_server._canonical_comment_gate")
|
||||
@patch("already_landed_reconcile.is_head_ancestor_of_ref", return_value=True)
|
||||
@patch("already_landed_reconcile.fetch_target_branch")
|
||||
@patch("mcp_server._profile_operation_gate", return_value=[])
|
||||
@patch("mcp_server._auth", return_value="token test")
|
||||
@patch("mcp_server.api_request")
|
||||
def test_tool_posts_comment_and_closes_superseded_pr_issue(
|
||||
self,
|
||||
api,
|
||||
_auth,
|
||||
_profile_gate,
|
||||
fetch_target,
|
||||
_ancestor,
|
||||
canonical_gate,
|
||||
verify_preflight,
|
||||
):
|
||||
fetch_target.return_value = {
|
||||
"success": True,
|
||||
"target_branch": "master",
|
||||
"target_ref": "prgs/master",
|
||||
"target_branch_sha": TARGET,
|
||||
}
|
||||
canonical_gate.return_value = {
|
||||
"blocked": False,
|
||||
"canonical_comment_validation": {"valid": True},
|
||||
}
|
||||
api.side_effect = [
|
||||
_target_pr(),
|
||||
_superseding_pr(),
|
||||
{"number": 470, "state": "open"},
|
||||
{"id": 123},
|
||||
{"state": "closed"},
|
||||
{"state": "closed"},
|
||||
]
|
||||
|
||||
result = mcp_server.gitea_reconcile_superseded_by_merged_pr(
|
||||
target_pr_number=490,
|
||||
superseding_pr_number=502,
|
||||
target_issue_number=470,
|
||||
target_independently_required=False,
|
||||
issue_satisfied_by_superseding=True,
|
||||
close_pr=True,
|
||||
close_issue=True,
|
||||
post_comment=True,
|
||||
comment_body=self._comment(),
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
)
|
||||
|
||||
self.assertTrue(result["success"])
|
||||
self.assertTrue(result["performed"])
|
||||
self.assertTrue(result["pr_comment_posted"])
|
||||
self.assertTrue(result["pr_closed"])
|
||||
self.assertTrue(result["issue_closed"])
|
||||
verify_preflight.assert_called_once_with(
|
||||
"prgs", task="reconcile_close_superseded_pr")
|
||||
|
||||
@patch("mcp_server.verify_preflight_purity")
|
||||
@patch("mcp_server._canonical_comment_gate")
|
||||
@patch("already_landed_reconcile.is_head_ancestor_of_ref", return_value=True)
|
||||
@patch("already_landed_reconcile.fetch_target_branch")
|
||||
@patch("mcp_server._profile_operation_gate", return_value=[])
|
||||
@patch("mcp_server._auth", return_value="token test")
|
||||
@patch("mcp_server.api_request")
|
||||
def test_tool_does_not_mutate_when_target_still_mergeable(
|
||||
self,
|
||||
api,
|
||||
_auth,
|
||||
_profile_gate,
|
||||
fetch_target,
|
||||
_ancestor,
|
||||
canonical_gate,
|
||||
verify_preflight,
|
||||
):
|
||||
fetch_target.return_value = {
|
||||
"success": True,
|
||||
"target_branch": "master",
|
||||
"target_ref": "prgs/master",
|
||||
"target_branch_sha": TARGET,
|
||||
}
|
||||
canonical_gate.return_value = {
|
||||
"blocked": False,
|
||||
"canonical_comment_validation": {"valid": True},
|
||||
}
|
||||
api.side_effect = [_target_pr(mergeable=True), _superseding_pr()]
|
||||
|
||||
result = mcp_server.gitea_reconcile_superseded_by_merged_pr(
|
||||
target_pr_number=490,
|
||||
superseding_pr_number=502,
|
||||
target_independently_required=False,
|
||||
close_pr=True,
|
||||
post_comment=True,
|
||||
comment_body=self._comment(),
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
)
|
||||
|
||||
self.assertFalse(result["success"])
|
||||
self.assertFalse(result["performed"])
|
||||
self.assertFalse(result["pr_closed"])
|
||||
self.assertFalse(result["pr_comment_posted"])
|
||||
verify_preflight.assert_not_called()
|
||||
self.assertEqual(api.call_count, 2)
|
||||
Reference in New Issue
Block a user