"""#780: ``status:pr-open`` must not survive a terminal PR transition. The leak this file locks down: ``gitea_create_pr`` applied ``status:pr-open`` and no terminal path ever removed it, so a repository audit found 40 closed issues still advertising an open PR that had long since merged or closed. Coverage mirrors the issue's acceptance criteria: merge, close-without-merge, supersession, already-landed reconciliation, controller closure, retry / idempotency, unrelated-label preservation, the only-label (empty set) case, and terminal validation of any residual label. """ from __future__ import annotations import sys import unittest from unittest.mock import patch sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) import mcp_server import terminal_pr_label_cleanup as tplc FAKE_AUTH = "token test-token" PR_OPEN = tplc.PR_OPEN_LABEL def _lb(name: str, lid: int) -> dict: return {"id": lid, "name": name, "color": "000000"} # --------------------------------------------------------------------------- # Pure rule: planning # --------------------------------------------------------------------------- class TestPlanPrOpenCleanup(unittest.TestCase): def test_removes_only_the_pr_open_label(self): plan = tplc.plan_pr_open_cleanup( ["type:bug", PR_OPEN, "workflow-hardening"], terminal_reason=tplc.MERGED, ) self.assertTrue(plan["cleanup_required"]) self.assertEqual(plan["removed"], [PR_OPEN]) self.assertEqual(plan["labels_after"], ["type:bug", "workflow-hardening"]) def test_preserves_unrelated_labels_in_original_order(self): labels = ["workflow-hardening", "type:bug", PR_OPEN, "role:author", "leases"] plan = tplc.plan_pr_open_cleanup(labels, terminal_reason=tplc.MERGED) self.assertEqual( plan["labels_after"], ["workflow-hardening", "type:bug", "role:author", "leases"], ) self.assertNotIn(PR_OPEN, plan["labels_after"]) def test_only_label_yields_empty_set(self): plan = tplc.plan_pr_open_cleanup([PR_OPEN], terminal_reason=tplc.MERGED) self.assertTrue(plan["cleanup_required"]) self.assertEqual(plan["labels_after"], []) self.assertTrue(plan["empty_label_set"]) def test_absent_label_is_an_idempotent_noop(self): plan = tplc.plan_pr_open_cleanup( ["type:bug", "status:done"], terminal_reason=tplc.RETRY_RECOVERY ) self.assertFalse(plan["cleanup_required"]) self.assertTrue(plan["idempotent_noop"]) self.assertEqual(plan["labels_after"], ["type:bug", "status:done"]) def test_accepts_gitea_label_objects(self): plan = tplc.plan_pr_open_cleanup( {"labels": [{"name": PR_OPEN}, {"name": "type:bug"}]}, terminal_reason=tplc.SUPERSEDED, ) self.assertEqual(plan["labels_after"], ["type:bug"]) def test_every_terminal_reason_is_planable(self): for reason in tplc.TERMINAL_REASONS: plan = tplc.plan_pr_open_cleanup([PR_OPEN], terminal_reason=reason) self.assertEqual(plan["terminal_reason"], reason) self.assertTrue(plan["terminal_reason_description"]) def test_unknown_terminal_reason_fails_closed(self): with self.assertRaises(ValueError): tplc.plan_pr_open_cleanup([PR_OPEN], terminal_reason="whenever") def test_reason_aliases_normalize(self): self.assertEqual(tplc.canonical_terminal_reason("merge"), tplc.MERGED) self.assertEqual( tplc.canonical_terminal_reason("already-landed"), tplc.ALREADY_LANDED ) self.assertEqual( tplc.canonical_terminal_reason("controller-closure"), tplc.CONTROLLER_CLOSURE, ) # --------------------------------------------------------------------------- # Pure rule: read-after-write verification # --------------------------------------------------------------------------- class TestVerifyPrOpenCleanup(unittest.TestCase): def test_verified_when_observed_matches_plan(self): plan = tplc.plan_pr_open_cleanup( ["type:bug", PR_OPEN], terminal_reason=tplc.MERGED ) result = tplc.verify_pr_open_cleanup(["type:bug"], plan=plan) self.assertTrue(result["verified"]) self.assertFalse(result["residual"]) self.assertEqual(result["reasons"], []) def test_residual_label_is_reported(self): plan = tplc.plan_pr_open_cleanup( ["type:bug", PR_OPEN], terminal_reason=tplc.MERGED ) result = tplc.verify_pr_open_cleanup(["type:bug", PR_OPEN], plan=plan) self.assertFalse(result["verified"]) self.assertTrue(result["residual"]) self.assertIn(PR_OPEN, result["reasons"][0]) self.assertTrue(result["safe_next_action"]) def test_dropped_unrelated_label_is_reported(self): plan = tplc.plan_pr_open_cleanup( ["type:bug", "leases", PR_OPEN], terminal_reason=tplc.MERGED ) result = tplc.verify_pr_open_cleanup(["type:bug"], plan=plan) self.assertFalse(result["verified"]) self.assertEqual(result["unexpected_removals"], ["leases"]) def test_unexpected_added_label_is_reported(self): plan = tplc.plan_pr_open_cleanup( ["type:bug", PR_OPEN], terminal_reason=tplc.MERGED ) result = tplc.verify_pr_open_cleanup(["type:bug", "surprise"], plan=plan) self.assertFalse(result["verified"]) self.assertEqual(result["unexpected_additions"], ["surprise"]) def test_empty_observed_set_verifies_for_only_label_case(self): plan = tplc.plan_pr_open_cleanup([PR_OPEN], terminal_reason=tplc.MERGED) result = tplc.verify_pr_open_cleanup([], plan=plan) self.assertTrue(result["verified"]) self.assertTrue(result["empty_label_set"]) # --------------------------------------------------------------------------- # Terminal validation # --------------------------------------------------------------------------- class TestDetectResidualPrOpen(unittest.TestCase): def test_clean_repository(self): issues = [ {"number": 1, "state": "closed", "labels": [{"name": "type:bug"}]}, {"number": 2, "state": "open", "labels": []}, ] result = tplc.detect_residual_pr_open(issues) self.assertTrue(result["clean"]) self.assertEqual(result["residual_count"], 0) self.assertEqual(result["checked_count"], 2) def test_reports_each_stale_issue(self): issues = [ {"number": 626, "state": "closed", "labels": [{"name": PR_OPEN}]}, {"number": 772, "state": "closed", "labels": [{"name": PR_OPEN}]}, {"number": 9, "state": "open", "labels": [{"name": "type:bug"}]}, ] result = tplc.detect_residual_pr_open(issues) self.assertFalse(result["clean"]) self.assertEqual(result["residual_count"], 2) self.assertEqual( [entry["number"] for entry in result["residual_issues"]], [626, 772] ) self.assertTrue(result["safe_next_action"]) def test_issue_with_a_live_open_pr_is_not_residual(self): issues = [{"number": 42, "state": "open", "labels": [{"name": PR_OPEN}]}] result = tplc.detect_residual_pr_open(issues, open_pr_issue_numbers=[42]) self.assertTrue(result["clean"]) self.assertEqual(result["exempt_open_pr_issues"], [42]) # --------------------------------------------------------------------------- # Executor: one authoritative rule, with read-after-write proof # --------------------------------------------------------------------------- class _ExecutorHarness(unittest.TestCase): """Drives mcp_server.clear_pr_open_label against a fake Gitea.""" def setUp(self): self.issue_labels: dict[int, list[str]] = {} self.repo_labels = { PR_OPEN: 4, "type:bug": 1, "workflow-hardening": 2, "leases": 3, "status:done": 5, } self.puts: list[tuple[int, list[int]]] = [] patch("mcp_server._resolve", return_value=("h", "o", "r")).start() patch("mcp_server._auth", return_value=FAKE_AUTH).start() patch( "mcp_server.repo_api_url", return_value="https://gitea.example/api/v1/repos/o/r", ).start() patch("gitea_audit.audit_enabled", return_value=False).start() patch("mcp_server.api_request", side_effect=self._api).start() # api_get_all resolves api_request inside gitea_auth, so patching the # mcp_server binding alone would let the label inventory hit the network. patch("mcp_server.api_get_all", side_effect=self._api_get_all).start() self.addCleanup(patch.stopall) def _api_get_all(self, url, auth, **_kwargs): if "/labels" in url: return [_lb(name, lid) for name, lid in self.repo_labels.items()] raise AssertionError(f"unexpected paginated GET: {url}") def _api(self, method, url, auth, payload=None): if method == "GET" and "/issues/" in url: num = int(url.rsplit("/issues/", 1)[1].split("?")[0]) return { "number": num, "labels": [ {"name": n, "id": self.repo_labels[n]} for n in self.issue_labels.get(num, []) ], } if method == "PUT" and url.endswith("/labels"): num = int(url.rsplit("/issues/", 1)[1].split("/")[0]) ids = payload["labels"] by_id = {lid: name for name, lid in self.repo_labels.items()} names = [by_id[i] for i in ids] self.puts.append((num, ids)) self.issue_labels[num] = names return [_lb(n, self.repo_labels[n]) for n in names] raise AssertionError(f"unexpected API call: {method} {url}") def _clear(self, numbers, reason=tplc.MERGED): return mcp_server.clear_pr_open_label( numbers, "prgs", None, None, None, terminal_reason=reason ) class TestClearPrOpenLabel(_ExecutorHarness): def test_removes_label_and_preserves_the_rest(self): self.issue_labels[780] = ["type:bug", PR_OPEN, "workflow-hardening"] summary = self._clear([780]) self.assertTrue(summary["clean"]) self.assertEqual(summary["removed"], [780]) self.assertEqual( self.issue_labels[780], ["type:bug", "workflow-hardening"] ) def test_only_label_results_in_empty_set(self): self.issue_labels[626] = [PR_OPEN] summary = self._clear([626]) self.assertTrue(summary["clean"]) self.assertEqual(self.issue_labels[626], []) self.assertEqual(self.puts, [(626, [])]) self.assertTrue(summary["results"][0]["empty_label_set"]) def test_read_after_write_proof_is_returned(self): self.issue_labels[780] = ["type:bug", PR_OPEN] summary = self._clear([780]) entry = summary["results"][0] self.assertTrue(entry["verified"]) self.assertEqual(entry["labels_before"], ["type:bug", PR_OPEN]) self.assertEqual(entry["labels_after"], ["type:bug"]) self.assertEqual(entry["verification"]["observed_labels"], ["type:bug"]) def test_repeated_cleanup_is_harmless(self): self.issue_labels[780] = ["type:bug", PR_OPEN] first = self._clear([780]) second = self._clear([780], reason=tplc.RETRY_RECOVERY) third = self._clear([780], reason=tplc.RETRY_RECOVERY) self.assertTrue(first["clean"] and second["clean"] and third["clean"]) self.assertEqual(second["already_absent"], [780]) self.assertEqual(third["already_absent"], [780]) # Exactly one mutation across three calls. self.assertEqual(len(self.puts), 1) self.assertEqual(self.issue_labels[780], ["type:bug"]) def test_noop_path_never_reads_the_label_inventory(self): self.issue_labels[780] = ["type:bug"] with patch("mcp_server._repo_label_id_map") as mock_map: summary = self._clear([780]) self.assertTrue(summary["clean"]) mock_map.assert_not_called() def test_duplicate_issue_numbers_are_collapsed(self): self.issue_labels[780] = ["type:bug", PR_OPEN] summary = self._clear([780, 780, "780"]) self.assertEqual(summary["checked"], [780]) self.assertEqual(len(self.puts), 1) def test_no_issue_numbers_is_a_clean_noop(self): summary = self._clear([]) self.assertTrue(summary["clean"]) self.assertEqual(summary["checked"], []) def test_failed_mutation_is_reported_not_swallowed(self): self.issue_labels[780] = ["type:bug", PR_OPEN] def boom(*_a, **_kw): raise RuntimeError("gitea exploded") with patch("mcp_server._put_issue_label_names", side_effect=boom): summary = self._clear([780]) self.assertFalse(summary["clean"]) self.assertEqual(summary["failed"], [780]) self.assertTrue(summary["safe_next_action"]) self.assertIn(PR_OPEN, self.issue_labels[780]) def test_residual_label_after_write_fails_verification(self): self.issue_labels[780] = ["type:bug", PR_OPEN] real_api = self._api # Simulate a write that reports success but leaves the label behind. def sticky(method, url, auth, payload=None): if method == "PUT" and url.endswith("/labels"): num = int(url.rsplit("/issues/", 1)[1].split("/")[0]) self.puts.append((num, payload["labels"])) return [_lb("type:bug", 1), _lb(PR_OPEN, 4)] return real_api(method, url, auth, payload) with patch("mcp_server.api_request", side_effect=sticky): summary = self._clear([780]) self.assertFalse(summary["clean"]) self.assertEqual(summary["failed"], [780]) # --------------------------------------------------------------------------- # Terminal workflow paths # --------------------------------------------------------------------------- class TestTerminalPathsUseTheSharedRule(_ExecutorHarness): """Merge, close-without-merge, supersession and already-landed.""" def test_merge_path_clears_the_label_for_linked_issues(self): self.issue_labels[780] = ["type:bug", PR_OPEN] merged_pr = { "title": "fix: terminal label cleanup", "body": "Closes #780", "head": {"ref": "fix/issue-780-terminal-pr-open-label-cleanup"}, } with patch( "mcp_server.release_in_progress_label", return_value={780: "released"} ): result = mcp_server.cleanup_in_progress_for_pr( merged_pr, "prgs", None, None, None, terminal_reason=tplc.MERGED ) cleanup = result["pr_open_label_cleanup"] self.assertTrue(cleanup["clean"]) self.assertEqual(cleanup["terminal_reason"], tplc.MERGED) self.assertEqual(self.issue_labels[780], ["type:bug"]) def test_close_without_merge_clears_the_label(self): self.issue_labels[781] = ["type:bug", PR_OPEN, "leases"] closed_pr = { "title": "chore: abandoned", "body": "Closes #781", "head": {"ref": "chore/issue-781-abandoned"}, } with patch( "mcp_server.release_in_progress_label", return_value={781: "released"} ): result = mcp_server.cleanup_in_progress_for_pr( closed_pr, "prgs", None, None, None, terminal_reason=tplc.CLOSED_WITHOUT_MERGE, ) cleanup = result["pr_open_label_cleanup"] self.assertTrue(cleanup["clean"]) self.assertEqual(cleanup["terminal_reason"], tplc.CLOSED_WITHOUT_MERGE) self.assertEqual(self.issue_labels[781], ["type:bug", "leases"]) def test_pr_without_linked_issue_reports_an_empty_cleanup(self): pr = {"title": "chore: no link", "body": "", "head": {"ref": "chore/none"}} result = mcp_server.cleanup_in_progress_for_pr( pr, "prgs", None, None, None, terminal_reason=tplc.MERGED ) self.assertEqual(result["cleanup_status"], "no linked issue found") self.assertTrue(result["pr_open_label_cleanup"]["clean"]) self.assertEqual(result["pr_open_label_cleanup"]["checked"], []) def test_supersession_reason_is_recorded(self): self.issue_labels[600] = [PR_OPEN, "type:bug"] summary = self._clear([600], reason=tplc.SUPERSEDED) self.assertTrue(summary["clean"]) self.assertEqual(summary["terminal_reason"], tplc.SUPERSEDED) self.assertEqual(self.issue_labels[600], ["type:bug"]) def test_already_landed_reconciliation_reason_is_recorded(self): self.issue_labels[601] = [PR_OPEN] summary = self._clear([601], reason=tplc.ALREADY_LANDED) self.assertTrue(summary["clean"]) self.assertEqual(summary["terminal_reason"], tplc.ALREADY_LANDED) self.assertEqual(self.issue_labels[601], []) def test_issue_780_regression_stale_label_survived_every_terminal_path(self): """Regression for the observed leak. Before the fix each terminal path finished without touching ``status:pr-open``, so the audit found closed issues still carrying it. Every path now routes through the one shared rule and leaves nothing behind — while preserving each issue's other labels. """ stale = { 626: (["type:bug", PR_OPEN], tplc.CONTROLLER_CLOSURE), 772: (["workflow-hardening", PR_OPEN], tplc.MERGED), 768: ([PR_OPEN], tplc.CLOSED_WITHOUT_MERGE), 758: (["leases", PR_OPEN, "type:bug"], tplc.SUPERSEDED), 755: (["status:done", PR_OPEN], tplc.ALREADY_LANDED), } for number, (labels, _reason) in stale.items(): self.issue_labels[number] = list(labels) for number, (_labels, reason) in stale.items(): summary = self._clear([number], reason=reason) self.assertTrue(summary["clean"], msg=f"issue #{number}") audit = tplc.detect_residual_pr_open( [ {"number": num, "state": "closed", "labels": names} for num, names in self.issue_labels.items() ] ) self.assertTrue(audit["clean"]) self.assertEqual(audit["residual_count"], 0) # Unrelated labels survived every path. self.assertEqual(self.issue_labels[626], ["type:bug"]) self.assertEqual(self.issue_labels[772], ["workflow-hardening"]) self.assertEqual(self.issue_labels[768], []) self.assertEqual(self.issue_labels[758], ["leases", "type:bug"]) self.assertEqual(self.issue_labels[755], ["status:done"]) # --------------------------------------------------------------------------- # Controller closure # --------------------------------------------------------------------------- class TestControllerClosure(unittest.TestCase): def test_close_issue_clears_label_before_closing_and_validates(self): calls: list[str] = [] def fake_clear(numbers, *_a, **kwargs): calls.append(f"clear:{kwargs['terminal_reason']}") return { "label": PR_OPEN, "clean": True, "checked": list(numbers), "removed": list(numbers), "already_absent": [], "failed": [], "results": [], "reasons": [], "safe_next_action": "", "terminal_reason": kwargs["terminal_reason"], } def fake_api(method, url, auth, payload=None): if method == "PATCH": calls.append("patch:closed") return {"state": "closed"} return {"labels": [{"name": "type:bug"}]} with patch("mcp_server.clear_pr_open_label", side_effect=fake_clear), \ patch("mcp_server.api_request", side_effect=fake_api), \ patch("mcp_server._profile_permission_block", return_value=None), \ patch("mcp_server.verify_preflight_purity", return_value=None), \ patch("mcp_server.release_in_progress_label", return_value={}), \ patch("mcp_server._resolve", return_value=("h", "o", "r")), \ patch("mcp_server._auth", return_value=FAKE_AUTH), \ patch("gitea_audit.audit_enabled", return_value=False): result = mcp_server.gitea_close_issue(issue_number=780, remote="prgs") self.assertTrue(result["success"]) # Cleanup precedes the state change: closing first would bake in the leak. self.assertEqual(calls[0], f"clear:{tplc.CONTROLLER_CLOSURE}") self.assertIn("patch:closed", calls) self.assertTrue(result["terminal_label_validation"]["clean"]) def test_close_issue_fails_closed_when_cleanup_cannot_complete(self): def fake_clear(numbers, *_a, **kwargs): return { "label": PR_OPEN, "clean": False, "checked": list(numbers), "removed": [], "already_absent": [], "failed": list(numbers), "results": [], "reasons": ["label replacement failed: boom"], "safe_next_action": "retry", "terminal_reason": kwargs["terminal_reason"], } def fail_on_patch(method, url, auth, payload=None): if method == "PATCH": raise AssertionError("issue must not be closed when cleanup failed") return {} with patch("mcp_server.clear_pr_open_label", side_effect=fake_clear), \ patch("mcp_server.api_request", side_effect=fail_on_patch), \ patch("mcp_server._profile_permission_block", return_value=None), \ patch("mcp_server.verify_preflight_purity", return_value=None), \ patch("mcp_server._resolve", return_value=("h", "o", "r")), \ patch("mcp_server._auth", return_value=FAKE_AUTH), \ patch("gitea_audit.audit_enabled", return_value=False): result = mcp_server.gitea_close_issue(issue_number=780, remote="prgs") self.assertFalse(result["success"]) self.assertTrue(result["blocked"]) self.assertFalse(result["performed"]) self.assertIn("#780", result["message"]) self.assertTrue(result["safe_next_action"]) # --------------------------------------------------------------------------- # Capability wiring # --------------------------------------------------------------------------- class TestCapabilityWiring(unittest.TestCase): def test_task_is_registered_with_label_authority(self): import task_capability_map self.assertEqual( task_capability_map.required_permission("cleanup_terminal_pr_labels"), "gitea.issue.comment", ) self.assertEqual( task_capability_map.required_role("cleanup_terminal_pr_labels"), "author", ) self.assertEqual( task_capability_map.tool_required_permission( "gitea_cleanup_terminal_pr_labels" ), "gitea.issue.comment", ) def test_recovery_tool_rejects_an_unknown_reason_without_mutating(self): with patch("mcp_server._profile_permission_block", return_value=None), \ patch("mcp_server.verify_preflight_purity", return_value=None), \ patch("mcp_server.clear_pr_open_label") as mock_clear: result = mcp_server.gitea_cleanup_terminal_pr_labels( issue_numbers=[780], terminal_reason="sometime", remote="prgs" ) self.assertFalse(result["success"]) self.assertFalse(result["clean"]) mock_clear.assert_not_called() if __name__ == "__main__": unittest.main()