feat: reconciler supersession close path for PRs/issues (Closes #525) #583

Merged
sysadmin merged 1 commits from feat/issue-525-reconciler-supersession-close into master 2026-07-09 11:58:07 -05:00
6 changed files with 706 additions and 0 deletions
+26
View File
@@ -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
+253
View File
@@ -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,
+167
View File
@@ -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)
# ---------------------------------------------------------------------------
+3
View File
@@ -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 = (
+12
View File
@@ -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",
+245
View File
@@ -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)