"""#627: paginated repository-label resolution for gitea_set_issue_labels. Reproduces the post-merge #601 reconciliation failure where later-page labels (e.g. type:feature, workflow-hardening) were falsely rejected as nonexistent because inventory used a single-page GET labels?limit=100. """ from __future__ import annotations import os import sys import unittest from unittest.mock import patch, call sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) import mcp_server import gitea_auth FAKE_AUTH = "token test-token" PAGE_SIZE = 50 # Gitea effective max; see gitea_auth.api_get_all def _lb(name: str, lid: int) -> dict: return {"id": lid, "name": name, "color": "000000"} def _pages(labels: list[dict], page_size: int = PAGE_SIZE) -> list[list[dict]]: if not labels: return [[]] pages = [] for i in range(0, len(labels), page_size): pages.append(labels[i : i + page_size]) return pages class TestRepoLabelIdMapPagination(unittest.TestCase): """_repo_label_id_map must use api_get_all (all pages).""" @patch("mcp_server.api_get_all") def test_fewer_than_one_page(self, mock_all): labels = [_lb(f"l{i}", i) for i in range(3)] mock_all.return_value = labels m = mcp_server._repo_label_id_map("https://gitea.example/api/v1/repos/o/r", FAKE_AUTH) self.assertEqual(m, {"l0": 0, "l1": 1, "l2": 2}) mock_all.assert_called_once() self.assertIn("/labels", mock_all.call_args[0][0]) self.assertNotIn("limit=100", mock_all.call_args[0][0]) @patch("mcp_server.api_get_all") def test_exactly_one_full_page(self, mock_all): labels = [_lb(f"l{i:03d}", i) for i in range(PAGE_SIZE)] mock_all.return_value = labels m = mcp_server._repo_label_id_map("https://gitea.example/api/v1/repos/o/r", FAKE_AUTH) self.assertEqual(len(m), PAGE_SIZE) self.assertEqual(m["l000"], 0) self.assertEqual(m[f"l{PAGE_SIZE - 1:03d}"], PAGE_SIZE - 1) @patch("mcp_server.api_get_all") def test_more_than_one_page_includes_later_labels(self, mock_all): # Page 1: 50 early labels; page 2: type:feature + workflow-hardening (#601 style) early = [_lb(f"early-{i:02d}", i) for i in range(PAGE_SIZE)] late = [ _lb("type:feature", 1001), _lb("workflow-hardening", 1002), ] mock_all.return_value = early + late m = mcp_server._repo_label_id_map("https://gitea.example/api/v1/repos/o/r", FAKE_AUTH) self.assertEqual(len(m), PAGE_SIZE + 2) self.assertEqual(m["type:feature"], 1001) self.assertEqual(m["workflow-hardening"], 1002) @patch("mcp_server.api_get_all") def test_duplicate_names_keep_first_seen_id(self, mock_all): mock_all.return_value = [ _lb("type:feature", 103), _lb("type:feature", 130), # duplicate id later _lb("workflow-hardening", 105), _lb("workflow-hardening", 128), ] m = mcp_server._repo_label_id_map("https://gitea.example/api/v1/repos/o/r", FAKE_AUTH) self.assertEqual(m["type:feature"], 103) self.assertEqual(m["workflow-hardening"], 105) @patch("mcp_server.api_get_all", return_value={"not": "a list"}) def test_non_list_inventory_fails_closed(self, _all): with self.assertRaises(RuntimeError) as ctx: mcp_server._repo_label_id_map("https://gitea.example/api/v1/repos/o/r", FAKE_AUTH) self.assertIn("expected a list", str(ctx.exception).lower()) @patch("mcp_server.api_get_all", side_effect=RuntimeError("page 2 failed: connection reset")) def test_later_page_failure_propagates(self, _all): with self.assertRaises(RuntimeError) as ctx: mcp_server._repo_label_id_map("https://gitea.example/api/v1/repos/o/r", FAKE_AUTH) self.assertIn("page 2 failed", str(ctx.exception)) class TestSetIssueLabelsPagination(unittest.TestCase): """gitea_set_issue_labels full-set replacement with multi-page inventory.""" def setUp(self): self._remotes = patch.dict( mcp_server.REMOTES, {"prgs": {"host": "gitea.example.com", "org": "Scaled-Tech-Consulting", "repo": "Gitea-Tools"}}, ) self._remotes.start() # Allow mutation path without full preflight stack when possible self._preflight = patch( "mcp_server.verify_preflight_purity", return_value=None ) self._preflight.start() self._perm = patch( "mcp_server._profile_permission_block", return_value=None ) self._perm.start() self._auth = patch( "mcp_server._auth", return_value=FAKE_AUTH ) self._auth.start() self._audited = patch("mcp_server._audited") mock_aud = self._audited.start() mock_aud.return_value.__enter__ = lambda s: None mock_aud.return_value.__exit__ = lambda s, *a: None def tearDown(self): self._remotes.stop() self._preflight.stop() self._perm.stop() self._auth.stop() self._audited.stop() def _inventory_early_and_late(self) -> list[dict]: early = [_lb(f"alpha-{i:02d}", i + 1) for i in range(PAGE_SIZE)] late = [ _lb("type:feature", 9001), _lb("workflow-hardening", 9002), _lb("status:ready", 9003), _lb("anti-stomp", 9004), _lb("leases", 9005), _lb("recovery", 9006), ] return early + late @patch("mcp_server.api_request") @patch("mcp_server.api_get_all") def test_requested_labels_split_across_pages(self, mock_all, mock_req): inv = self._inventory_early_and_late() mock_all.return_value = inv requested = ["alpha-00", "type:feature", "workflow-hardening"] mock_req.return_value = [_lb(n, inv_i["id"]) for n in requested for inv_i in inv if inv_i["name"] == n] res = mcp_server.gitea_set_issue_labels( issue_number=601, labels=requested, remote="prgs", ) self.assertEqual({lb["name"] for lb in res}, set(requested)) # PUT must use ids from both pages put = mock_req.call_args self.assertEqual(put[0][0], "PUT") payload_ids = put[0][3]["labels"] self.assertEqual(payload_ids, [1, 9001, 9002]) @patch("mcp_server.api_request") @patch("mcp_server.api_get_all") def test_missing_requested_label_rejected_before_put(self, mock_all, mock_req): mock_all.return_value = [_lb("bug", 1), _lb("status:ready", 2)] with self.assertRaises(RuntimeError) as ctx: mcp_server.gitea_set_issue_labels( issue_number=9, labels=["bug", "does-not-exist"], remote="prgs", ) self.assertIn("do not exist", str(ctx.exception)) self.assertIn("does-not-exist", str(ctx.exception)) mock_req.assert_not_called() @patch("mcp_server.api_request") @patch("mcp_server.api_get_all") def test_complete_set_preserves_later_page_labels(self, mock_all, mock_req): inv = self._inventory_early_and_late() mock_all.return_value = inv # Full-set replacement removing only status:pr-open style stale label: # keep later-page feature labels (the #601 reconciliation shape). requested = [ "anti-stomp", "leases", "recovery", "type:feature", "workflow-hardening", ] mock_req.return_value = [_lb(n, next(x["id"] for x in inv if x["name"] == n)) for n in requested] res = mcp_server.gitea_set_issue_labels( issue_number=601, labels=requested, remote="prgs" ) self.assertEqual([lb["name"] for lb in res], requested) payload_ids = mock_req.call_args[0][3]["labels"] self.assertEqual(payload_ids, [9004, 9005, 9006, 9001, 9002]) @patch("mcp_server.api_request") @patch("mcp_server.api_get_all") def test_issue_601_regression_later_page_names_accepted(self, mock_all, mock_req): """Regression: #601 reconciler rejected type:feature + workflow-hardening. Single-page inventory would omit them; paginated inventory must accept. """ early = [_lb(f"page1-{i:02d}", i + 1) for i in range(PAGE_SIZE)] # Exactly the labels from the #601 residual set (minus status:pr-open) late = [ _lb("type:feature", 103), _lb("workflow-hardening", 105), _lb("anti-stomp", 106), _lb("leases", 109), _lb("recovery", 110), ] mock_all.return_value = early + late requested = [ "anti-stomp", "leases", "recovery", "type:feature", "workflow-hardening", ] mock_req.return_value = [ _lb(n, next(x["id"] for x in late if x["name"] == n)) for n in requested ] res = mcp_server.gitea_set_issue_labels( issue_number=601, labels=requested, remote="prgs" ) names = {lb["name"] for lb in res} self.assertEqual(names, set(requested)) self.assertNotIn("status:pr-open", names) @patch("mcp_server.api_request") @patch("mcp_server.api_get_all") def test_duplicate_label_ids_resolve_deterministically(self, mock_all, mock_req): mock_all.return_value = [ _lb("type:feature", 103), _lb("type:feature", 130), _lb("workflow-hardening", 105), _lb("workflow-hardening", 128), ] requested = ["type:feature", "workflow-hardening"] mock_req.return_value = [_lb("type:feature", 103), _lb("workflow-hardening", 105)] mcp_server.gitea_set_issue_labels( issue_number=1, labels=requested, remote="prgs" ) self.assertEqual(mock_req.call_args[0][3]["labels"], [103, 105]) @patch("mcp_server.api_request") @patch("mcp_server.api_get_all") def test_post_mutation_mismatch_fails_closed(self, mock_all, mock_req): mock_all.return_value = [_lb("bug", 1), _lb("status:ready", 2)] # Server returns incomplete set → verification must fail mock_req.return_value = [_lb("bug", 1)] with self.assertRaises(RuntimeError) as ctx: mcp_server.gitea_set_issue_labels( issue_number=9, labels=["bug", "status:ready"], remote="prgs", ) self.assertIn("Post-mutation label verification failed", str(ctx.exception)) self.assertIn("status:ready", str(ctx.exception)) @patch("mcp_server.api_request") @patch("mcp_server.api_get_all", side_effect=RuntimeError("Gitea 502 on page 2")) def test_pagination_failure_fails_closed_before_put(self, _all, mock_req): with self.assertRaises(RuntimeError) as ctx: mcp_server.gitea_set_issue_labels( issue_number=9, labels=["bug"], remote="prgs" ) self.assertIn("page 2", str(ctx.exception)) mock_req.assert_not_called() @patch("mcp_server.api_request") @patch("mcp_server.api_get_all") def test_empty_label_set_clears_all(self, mock_all, mock_req): mock_all.return_value = [_lb("bug", 1)] mock_req.return_value = [] res = mcp_server.gitea_set_issue_labels( issue_number=9, labels=[], remote="prgs" ) self.assertEqual(res, []) self.assertEqual(mock_req.call_args[0][3]["labels"], []) @patch("mcp_server.api_request") @patch("mcp_server.api_get_all") def test_does_not_call_single_page_limit_100(self, mock_all, mock_req): mock_all.return_value = [_lb("bug", 1)] mock_req.return_value = [_lb("bug", 1)] mcp_server.gitea_set_issue_labels( issue_number=9, labels=["bug"], remote="prgs" ) for c in mock_req.call_args_list: url = c[0][1] if len(c[0]) > 1 else "" self.assertNotIn("labels?limit=100", str(url)) mock_all.assert_called() class TestApiGetAllPageCapDocumentsGiteaLimit(unittest.TestCase): """Sanity: api_get_all clamps page_size to 50 (root cause of limit=100 trap).""" @patch("gitea_auth.api_request") def test_page_size_clamped_to_fifty(self, mock_req): mock_req.return_value = [] gitea_auth.api_get_all("https://gitea.example/api/v1/repos/o/r/labels", FAKE_AUTH, page_size=100) # First (only) call must use limit=50, not 100 url = mock_req.call_args[0][1] self.assertIn("limit=50", url) self.assertNotIn("limit=100", url) if __name__ == "__main__": unittest.main()