diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md index 2a91259..effb9f7 100644 --- a/docs/llm-workflow-runbooks.md +++ b/docs/llm-workflow-runbooks.md @@ -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 diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index e41b202..209359e 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -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, diff --git a/review_proofs.py b/review_proofs.py index bab6748..b4b0ea2 100644 --- a/review_proofs.py +++ b/review_proofs.py @@ -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) # --------------------------------------------------------------------------- diff --git a/role_session_router.py b/role_session_router.py index 48dbdd2..7fedcd7 100644 --- a/role_session_router.py +++ b/role_session_router.py @@ -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 = ( diff --git a/task_capability_map.py b/task_capability_map.py index d32d315..14f3c70 100644 --- a/task_capability_map.py +++ b/task_capability_map.py @@ -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", diff --git a/tests/test_reconciler_supersession_close.py b/tests/test_reconciler_supersession_close.py new file mode 100644 index 0000000..0439e54 --- /dev/null +++ b/tests/test_reconciler_supersession_close.py @@ -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)