From 89292862767cdee0998bb0ab8a28d2dd2e0ed299 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Tue, 7 Jul 2026 04:25:02 -0400 Subject: [PATCH] feat: add pagination metadata to gitea_list_prs (#340) Return wrapped {prs, pagination} from gitea_list_prs with has_more, inventory_complete, and page traversal via api_fetch_page. Route gitea_review_pr inventory through gitea_list_prs so trust gates can prove pagination finality. Add assess_list_prs_pagination_proof verifier. Closes #340 --- gitea_auth.py | 37 ++++++ gitea_mcp_server.py | 158 ++++++++++++++++++++----- review_proofs.py | 102 +++++++++++++++- tests/test_capability_stop_terminal.py | 10 +- tests/test_list_prs_pagination.py | 153 ++++++++++++++++++++++++ tests/test_mcp_server.py | 45 ++++--- tests/test_pr_queue_inventory.py | 122 ++++++++++--------- 7 files changed, 520 insertions(+), 107 deletions(-) create mode 100644 tests/test_list_prs_pagination.py diff --git a/gitea_auth.py b/gitea_auth.py index 52a34c6..5e30213 100644 --- a/gitea_auth.py +++ b/gitea_auth.py @@ -420,6 +420,43 @@ def api_get_all(url, auth_header, *, limit=None, page_size=50, max_pages=100, return results +def api_fetch_page(url, auth_header, *, page=1, limit=50, **kwargs): + """Fetch one page from a Gitea list endpoint with explicit pagination metadata. + + Returns ``(items, pagination)`` where *pagination* includes ``has_more``, + ``next_page``, and ``is_final_page`` derived from the returned page length. + """ + page = max(1, int(page)) + limit = max(1, min(50, int(limit))) + page_url = _add_query(url, page=page, limit=limit) + data = api_request("GET", page_url, auth_header, **kwargs) + if data is None: + pagination = { + "page": page, + "per_page": limit, + "returned_count": 0, + "has_more": False, + "next_page": None, + "is_final_page": True, + } + return [], pagination + if not isinstance(data, list): + raise RuntimeError( + f"expected a list page from Gitea, got {type(data).__name__}" + ) + returned = len(data) + has_more = returned >= limit + pagination = { + "page": page, + "per_page": limit, + "returned_count": returned, + "has_more": has_more, + "next_page": page + 1 if has_more else None, + "is_final_page": not has_more, + } + return data, pagination + + def gitea_url(host, path): """Build a full URL for *host* and *path*, using http for loopback and https for others.""" if not path.startswith("/"): diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 7eea703..88bc9ac 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -461,6 +461,7 @@ from gitea_auth import ( # noqa: E402 get_auth_header, api_request, api_get_all, + api_fetch_page, repo_api_url, get_profile, gitea_url, @@ -1118,6 +1119,56 @@ def gitea_create_pr( return _with_optional_url({"number": data["number"]}, data.get("html_url")) +def _format_list_pr_entry(pr: dict) -> dict: + head_obj = pr.get("head") or {} + base_obj = pr.get("base") or {} + entry = { + "number": pr["number"], + "title": pr["title"], + "state": pr["state"], + "head": head_obj.get("ref") if isinstance(head_obj, dict) else head_obj, + "base": base_obj.get("ref") if isinstance(base_obj, dict) else base_obj, + "mergeable": pr.get("mergeable"), + "updated_at": pr.get("updated_at"), + "head_sha": head_obj.get("sha") if isinstance(head_obj, dict) else None, + "author": (pr.get("user") or {}).get("login"), + "labels": [ + label["name"] + for label in pr.get("labels", []) + if isinstance(label, dict) and label.get("name") + ], + "body": pr.get("body") or "", + } + return _with_optional_url(entry, pr.get("html_url")) + + +def _list_prs_pagination_envelope( + prs: list[dict], + *, + page: int, + per_page: int, + pages_fetched: int, + is_final_page: bool, + has_more: bool, + next_page: int | None, + inventory_complete: bool, +) -> dict: + return { + "prs": prs, + "pagination": { + "page": page, + "per_page": per_page, + "returned_count": len(prs), + "has_more": has_more, + "next_page": next_page, + "is_final_page": is_final_page, + "pages_fetched": pages_fetched, + "inventory_complete": inventory_complete, + "total_count": len(prs) if inventory_complete else None, + }, + } + + @mcp.tool() def gitea_list_prs( state: str = "open", @@ -1125,7 +1176,9 @@ def gitea_list_prs( host: str | None = None, org: str | None = None, repo: str | None = None, -) -> list[dict]: + page: int | None = None, + per_page: int = 50, +) -> dict: """List pull requests on a Gitea repository. Args: @@ -1134,11 +1187,16 @@ def gitea_list_prs( host: Override the Gitea host. org: Override the owner/organization. repo: Override the repository name. + page: Optional 1-based page for explicit single-page fetch. When + omitted, all pages are traversed and ``inventory_complete`` is set. + per_page: Page size (1–50; Gitea caps at 50). Returns: - List of dicts with 'number', 'title', 'state', 'head', 'base', - 'mergeable', 'updated_at' ('url' only with the reveal opt-in). - The additional 'updated_at' aids stale/conflicting queue detection. + Dict with ``prs`` (list of PR summaries) and ``pagination`` metadata + (``has_more``, ``next_page``, ``is_final_page``, ``inventory_complete``, + ``pages_fetched``, ``total_count`` when complete). Each PR entry carries + ``number``, ``title``, ``state``, ``head``, ``base``, ``mergeable``, + ``updated_at`` ('url' only with the reveal opt-in). """ allowed, block_reasons = capability_stop_terminal.check_reviewer_queue_tool( "list_prs" @@ -1148,20 +1206,48 @@ def gitea_list_prs( h, o, r = _resolve(remote, host, org, repo) auth = _auth(h) url = f"{repo_api_url(h, o, r)}/pulls?state={state}" - prs = api_get_all(url, auth) - results = [] - for pr in prs: - entry = { - "number": pr["number"], - "title": pr["title"], - "state": pr["state"], - "head": pr["head"]["ref"], - "base": pr["base"]["ref"], - "mergeable": pr.get("mergeable"), - "updated_at": pr.get("updated_at"), - } - results.append(_with_optional_url(entry, pr.get("html_url"))) - return results + + if page is not None: + raw_page, pagination = api_fetch_page( + url, auth, page=page, limit=per_page + ) + prs = [_format_list_pr_entry(pr) for pr in raw_page] + return _list_prs_pagination_envelope( + prs, + page=pagination["page"], + per_page=pagination["per_page"], + pages_fetched=1, + is_final_page=pagination["is_final_page"], + has_more=pagination["has_more"], + next_page=pagination["next_page"], + inventory_complete=False, + ) + + all_raw: list[dict] = [] + current_page = 1 + pages_fetched = 0 + last_meta: dict = {} + while pages_fetched < 100: + raw_page, last_meta = api_fetch_page( + url, auth, page=current_page, limit=per_page + ) + pages_fetched += 1 + all_raw.extend(raw_page) + if last_meta["is_final_page"]: + break + current_page += 1 + + prs = [_format_list_pr_entry(pr) for pr in all_raw] + return _list_prs_pagination_envelope( + prs, + page=1, + per_page=per_page, + pages_fetched=pages_fetched, + is_final_page=bool(last_meta.get("is_final_page")), + has_more=False, + next_page=None, + inventory_complete=bool(last_meta.get("is_final_page")), + ) @mcp.tool() @@ -2928,19 +3014,25 @@ def gitea_review_pr( if can_read and auth: inventory_attempted = True try: - url = f"{repo_api_url(h, o, r)}/pulls?state=open" - prs = api_get_all(url, auth) - prs_found_count = len(prs) - for pr in prs: - labels = [l["name"] for l in pr.get("labels", [])] + prs = gitea_list_prs( + state="open", + remote=remote, + host=h, + org=o, + repo=r, + ) + prs_list = prs.get("prs") or [] + prs_found_count = len(prs_list) + for pr in prs_list: + labels = list(pr.get("labels") or []) body_text = pr.get("body") or "" linked = [] # Simple extraction of linked issues from body for match in re.findall(r'#(\d+)', body_text): linked.append(f"#{match}") - pr_head_sha = (pr.get("head") or {}).get("sha") - pr_author = (pr.get("user") or {}).get("login") + pr_head_sha = pr.get("head_sha") + pr_author = pr.get("author") # Determine review block status req_non_author = False @@ -2951,8 +3043,8 @@ def gitea_review_pr( "number": pr["number"], "title": pr["title"], "author": pr_author, - "base": pr["base"]["ref"], - "head": pr["head"]["ref"], + "base": pr["base"], + "head": pr["head"], "mergeable": pr.get("mergeable"), "linked_issues": list(set(linked)), "labels": labels, @@ -2993,6 +3085,16 @@ def gitea_review_pr( if inventory_msg: report_lines.append(inventory_msg) else: + pagination = ( + prs.get("pagination") if isinstance(prs, dict) else {} + ) or {} + has_finality_metadata = bool( + pagination.get("inventory_complete") + or ( + pagination.get("is_final_page") + and not pagination.get("has_more") + ) + ) inventory_trust_gate = review_proofs.pr_inventory_trust_gate( prs if inventory_attempted else None, remote=remote, @@ -3001,7 +3103,7 @@ def gitea_review_pr( state="open", authenticated_profile=profile, local_remote_url=_local_git_remote_url(remote), - has_finality_metadata=True, + has_finality_metadata=has_finality_metadata, ) report_lines.extend( review_proofs.format_pr_inventory_trust_gate_report( diff --git a/review_proofs.py b/review_proofs.py index be71582..7630fbe 100644 --- a/review_proofs.py +++ b/review_proofs.py @@ -968,6 +968,90 @@ def assess_mutation_ledger_report( } +def assess_list_prs_pagination_proof( + list_prs_response: dict | list | None, + *, + inventory_complete_claimed: bool = False, +) -> dict: + """#340: verify ``gitea_list_prs`` pagination metadata proves inventory scope. + + Accepts the wrapped ``{"prs": [...], "pagination": {...}}`` response from + ``gitea_list_prs``. Legacy bare lists fail closed when completeness is + claimed. Single-page fetches may not claim full inventory unless + ``inventory_complete`` or ``is_final_page`` is true. + """ + reasons: list[str] = [] + if list_prs_response is None: + reasons.append("gitea_list_prs response missing; fail closed") + return {"proven": False, "block": True, "reasons": reasons, "pagination": None} + + if isinstance(list_prs_response, list): + pagination = None + if inventory_complete_claimed: + reasons.append( + "bare PR list lacks pagination metadata; cannot prove inventory " + "completeness" + ) + return { + "proven": not reasons, + "block": bool(reasons), + "reasons": reasons, + "pagination": pagination, + } + + if not isinstance(list_prs_response, dict): + reasons.append("gitea_list_prs response is not a dict or list; fail closed") + return {"proven": False, "block": True, "reasons": reasons, "pagination": None} + + prs = list_prs_response.get("prs") + pagination = list_prs_response.get("pagination") + if not isinstance(prs, list): + reasons.append("gitea_list_prs response missing 'prs' list") + if not isinstance(pagination, dict): + reasons.append("gitea_list_prs response missing 'pagination' metadata") + return { + "proven": False, + "block": True, + "reasons": reasons, + "pagination": pagination if isinstance(pagination, dict) else None, + } + + required_keys = ("page", "per_page", "returned_count", "has_more", "is_final_page") + for key in required_keys: + if key not in pagination: + reasons.append(f"pagination metadata missing '{key}'") + + if inventory_complete_claimed: + complete = pagination.get("inventory_complete") is True + final_page = pagination.get("is_final_page") is True and pagination.get("has_more") is False + if not (complete or final_page): + reasons.append( + "inventory completeness claimed but pagination metadata does not " + "prove final page (inventory_complete or is_final_page with " + "has_more=false required)" + ) + + proven = not reasons + return { + "proven": proven, + "block": not proven, + "reasons": reasons, + "pagination": pagination, + } + + +def _prs_from_list_response(list_prs_response: list | dict | None) -> list | None: + """Normalize ``gitea_list_prs`` output to a PR list.""" + if list_prs_response is None: + return None + if isinstance(list_prs_response, list): + return list_prs_response + if isinstance(list_prs_response, dict): + prs = list_prs_response.get("prs") + return prs if isinstance(prs, list) else None + return None + + def assess_role_boundary(proof=None, *, task_role=None, namespaces_used=None, justification=None): """Assess reviewer/author role separation for blind queue workflows. @@ -1810,14 +1894,19 @@ def pr_inventory_trust_gate( "queue_target_lock": lock_status, } - if list_prs_response is None or not isinstance(list_prs_response, list): + prs_list = _prs_from_list_response(list_prs_response) + pagination_meta = ( + list_prs_response.get("pagination") + if isinstance(list_prs_response, dict) else {} + ) or {} + if prs_list is None: return { "status": "inventory_error", - "reasons": ["PR list response is invalid (not a list or None)"], + "reasons": ["PR list response is invalid (missing prs list or None)"], "corroborated": False, } - if len(list_prs_response) > 0: + if len(prs_list) > 0: return { "status": "trusted_nonempty", "reasons": [], @@ -1848,6 +1937,13 @@ def pr_inventory_trust_gate( corroborated = False if has_finality_metadata: corroborated = True + elif pagination_meta.get("inventory_complete") is True: + corroborated = True + elif ( + pagination_meta.get("is_final_page") is True + and pagination_meta.get("has_more") is False + ): + corroborated = True elif corroboration_open_pr_counter == 0: corroborated = True else: diff --git a/tests/test_capability_stop_terminal.py b/tests/test_capability_stop_terminal.py index 5588f33..4100630 100644 --- a/tests/test_capability_stop_terminal.py +++ b/tests/test_capability_stop_terminal.py @@ -99,12 +99,16 @@ class TestCapabilityStopTerminal(unittest.TestCase): self.assertIn("Cannot perform reviewer task", str(ctx.exception)) self.assertIn("review_pr", str(ctx.exception)) - @patch("mcp_server.api_get_all", return_value=[]) + @patch("mcp_server.api_fetch_page") @patch("mcp_server.api_request", return_value={"login": "jcwalker3"}) @patch("mcp_server.get_auth_header", return_value="token author-pass") def test_list_prs_allowed_after_author_task_clears_stale_denial( - self, _auth, _api, _get_all, + self, _auth, _api, mock_fetch, ): + mock_fetch.return_value = ([], { + "page": 1, "per_page": 50, "returned_count": 0, + "has_more": False, "next_page": None, "is_final_page": True, + }) with patch.dict(os.environ, self._env()): denied = mcp_server.gitea_resolve_task_capability( task="review_pr", remote="prgs" @@ -120,7 +124,7 @@ class TestCapabilityStopTerminal(unittest.TestCase): self.assertFalse(capability_stop_terminal.is_active()) prs = mcp_server.gitea_list_prs(remote="prgs") - self.assertEqual(prs, []) + self.assertEqual(prs["prs"], []) @patch("mcp_server.api_request", return_value={"login": "jcwalker3"}) @patch("mcp_server.get_auth_header", return_value="token author-pass") diff --git a/tests/test_list_prs_pagination.py b/tests/test_list_prs_pagination.py new file mode 100644 index 0000000..2f8b78a --- /dev/null +++ b/tests/test_list_prs_pagination.py @@ -0,0 +1,153 @@ +"""Tests for gitea_list_prs pagination metadata (#340).""" +import os +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) + +import gitea_auth # noqa: E402 +from mcp_server import gitea_list_prs # noqa: E402 +from review_proofs import assess_list_prs_pagination_proof # noqa: E402 + +FAKE_AUTH = "Basic dGVzdDp0ZXN0" +URL = "https://gitea.example.com/api/v1/repos/o/r/pulls?state=open" + +SAMPLE_PR = { + "number": 1, + "title": "PR 1", + "state": "open", + "head": {"ref": "branch1"}, + "base": {"ref": "master"}, + "mergeable": True, + "updated_at": "2026-07-05T12:00:00Z", +} + + +class TestApiFetchPage(unittest.TestCase): + @patch("gitea_auth.api_request") + def test_final_page_metadata(self, mock_req): + mock_req.return_value = [SAMPLE_PR] + items, meta = gitea_auth.api_fetch_page(URL, FAKE_AUTH, page=1, limit=50) + self.assertEqual(len(items), 1) + self.assertFalse(meta["has_more"]) + self.assertTrue(meta["is_final_page"]) + self.assertIsNone(meta["next_page"]) + + @patch("gitea_auth.api_request") + def test_full_page_has_more(self, mock_req): + mock_req.return_value = [{"id": i} for i in range(2)] + items, meta = gitea_auth.api_fetch_page(URL, FAKE_AUTH, page=1, limit=2) + self.assertEqual(len(items), 2) + self.assertTrue(meta["has_more"]) + self.assertFalse(meta["is_final_page"]) + self.assertEqual(meta["next_page"], 2) + + +class TestGiteaListPrsPagination(unittest.TestCase): + @patch("mcp_server.api_fetch_page") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_explicit_page_returns_has_more(self, _auth, mock_fetch): + mock_fetch.return_value = ( + [SAMPLE_PR], + { + "page": 1, + "per_page": 50, + "returned_count": 1, + "has_more": False, + "next_page": None, + "is_final_page": True, + }, + ) + with patch.dict(os.environ, {}, clear=True): + result = gitea_list_prs(page=1, per_page=50) + self.assertEqual(len(result["prs"]), 1) + self.assertFalse(result["pagination"]["inventory_complete"]) + self.assertTrue(result["pagination"]["is_final_page"]) + + @patch("mcp_server.api_fetch_page") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_multi_page_fetch_all_marks_inventory_complete(self, _auth, mock_fetch): + mock_fetch.side_effect = [ + ( + [dict(SAMPLE_PR, number=i) for i in (1, 2)], + { + "page": 1, + "per_page": 2, + "returned_count": 2, + "has_more": True, + "next_page": 2, + "is_final_page": False, + }, + ), + ( + [dict(SAMPLE_PR, number=3)], + { + "page": 2, + "per_page": 2, + "returned_count": 1, + "has_more": False, + "next_page": None, + "is_final_page": True, + }, + ), + ] + with patch.dict(os.environ, {}, clear=True): + result = gitea_list_prs(per_page=2) + self.assertEqual([p["number"] for p in result["prs"]], [1, 2, 3]) + self.assertTrue(result["pagination"]["inventory_complete"]) + self.assertEqual(result["pagination"]["pages_fetched"], 2) + self.assertEqual(result["pagination"]["total_count"], 3) + + +class TestListPrsPaginationProof(unittest.TestCase): + def test_wrapped_final_page_passes_completeness_claim(self): + response = { + "prs": [], + "pagination": { + "page": 1, + "per_page": 50, + "returned_count": 0, + "has_more": False, + "next_page": None, + "is_final_page": True, + "inventory_complete": True, + "pages_fetched": 1, + "total_count": 0, + }, + } + result = assess_list_prs_pagination_proof( + response, inventory_complete_claimed=True + ) + self.assertTrue(result["proven"]) + + def test_bare_list_blocks_completeness_claim(self): + result = assess_list_prs_pagination_proof( + [{"number": 1}], inventory_complete_claimed=True + ) + self.assertFalse(result["proven"]) + self.assertIn("pagination metadata", " ".join(result["reasons"])) + + def test_single_page_without_complete_blocks_claim(self): + response = { + "prs": [{"number": 1}], + "pagination": { + "page": 1, + "per_page": 50, + "returned_count": 1, + "has_more": True, + "next_page": 2, + "is_final_page": False, + "inventory_complete": False, + "pages_fetched": 1, + "total_count": None, + }, + } + result = assess_list_prs_pagination_proof( + response, inventory_complete_claimed=True + ) + self.assertFalse(result["proven"]) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 0b01a54..18ebb80 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -399,40 +399,49 @@ class TestMirrorRefs(unittest.TestCase): # --------------------------------------------------------------------------- class TestListPRs(unittest.TestCase): - @patch("mcp_server.api_get_all") + @patch("mcp_server.api_fetch_page") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) - def test_list_prs(self, _auth, mock_api): - mock_api.return_value = [ - { + def test_list_prs(self, _auth, mock_fetch): + mock_fetch.return_value = ( + [{ "number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1"}, "base": {"ref": "main"}, "html_url": "http://url1", "mergeable": True, "updated_at": "2026-07-05T12:00:00Z" - } - ] + }], + { + "page": 1, "per_page": 50, "returned_count": 1, + "has_more": False, "next_page": None, "is_final_page": True, + }, + ) with patch.dict(os.environ, {}, clear=True): result = gitea_list_prs() - self.assertEqual(len(result), 1) - self.assertEqual(result[0]["number"], 1) - self.assertEqual(result[0]["head"], "branch1") - self.assertNotIn("url", result[0]) + self.assertEqual(len(result["prs"]), 1) + self.assertEqual(result["prs"][0]["number"], 1) + self.assertEqual(result["prs"][0]["head"], "branch1") + self.assertNotIn("url", result["prs"][0]) + self.assertTrue(result["pagination"]["inventory_complete"]) # Staleness fields for queue reconciliation (#166) - self.assertEqual(result[0]["updated_at"], "2026-07-05T12:00:00Z") + self.assertEqual(result["prs"][0]["updated_at"], "2026-07-05T12:00:00Z") - @patch("mcp_server.api_get_all") + @patch("mcp_server.api_fetch_page") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) - def test_list_prs_reveal_opt_in_includes_url(self, _auth, mock_api): - mock_api.return_value = [ - { + def test_list_prs_reveal_opt_in_includes_url(self, _auth, mock_fetch): + mock_fetch.return_value = ( + [{ "number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1"}, "base": {"ref": "main"}, "html_url": "http://url1", "mergeable": True, "updated_at": "2026-07-05T12:00:00Z" - } - ] + }], + { + "page": 1, "per_page": 50, "returned_count": 1, + "has_more": False, "next_page": None, "is_final_page": True, + }, + ) with patch.dict(os.environ, {"GITEA_MCP_REVEAL_ENDPOINTS": "1"}, clear=True): result = gitea_list_prs() - self.assertEqual(result[0]["url"], "http://url1") + self.assertEqual(result["prs"][0]["url"], "http://url1") # --------------------------------------------------------------------------- diff --git a/tests/test_pr_queue_inventory.py b/tests/test_pr_queue_inventory.py index 82998a4..aeb8bc4 100644 --- a/tests/test_pr_queue_inventory.py +++ b/tests/test_pr_queue_inventory.py @@ -16,38 +16,50 @@ import gitea_config FAKE_AUTH = "Basic dGVzdDp0ZXN0" + +def _final_page_fetch(items): + return (items, { + "page": 1, + "per_page": 50, + "returned_count": len(items), + "has_more": False, + "next_page": None, + "is_final_page": True, + }) + + class TestPRQueueInventory(unittest.TestCase): - @patch("mcp_server.api_get_all") + @patch("mcp_server.api_fetch_page") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") - def test_author_profile_can_list_open_prs(self, mock_get_profile, _auth, mock_get_all): + def test_author_profile_can_list_open_prs(self, mock_get_profile, _auth, mock_fetch): mock_get_profile.return_value = { "profile_name": "gitea-author", "allowed_operations": ["read", "gitea.pr.comment"], "forbidden_operations": [], "base_url": None, } - mock_get_all.return_value = [ + mock_fetch.return_value = _final_page_fetch([ {"number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1", "sha": "abc1"}, "base": {"ref": "master"}, "mergeable": True} - ] + ]) result = gitea_list_prs(state="open", remote="prgs") - self.assertEqual(len(result), 1) - self.assertEqual(result[0]["number"], 1) + self.assertEqual(len(result["prs"]), 1) + self.assertEqual(result["prs"][0]["number"], 1) - @patch("mcp_server.api_get_all") + @patch("mcp_server.api_fetch_page") @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") @patch("gitea_config.is_runtime_switching_enabled", return_value=True) - def test_author_profile_can_produce_pending_reviewer_queue_report(self, mock_switch, mock_get_profile, _auth, mock_api, mock_get_all): + def test_author_profile_can_produce_pending_reviewer_queue_report(self, mock_switch, mock_get_profile, _auth, mock_api, mock_fetch): mock_get_profile.return_value = { "profile_name": "gitea-author", "allowed_operations": ["read"], "forbidden_operations": [], "base_url": None, } - mock_get_all.return_value = [ + mock_fetch.return_value = _final_page_fetch([ { "number": 1, "title": "PR 1", @@ -72,7 +84,7 @@ class TestPRQueueInventory(unittest.TestCase): "body": "", "updated_at": "2026-07-05T11:00:00Z" } - ] + ]) mock_api.return_value = {"login": "jcwalker3"} # whoami result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs") @@ -86,18 +98,18 @@ class TestPRQueueInventory(unittest.TestCase): # updated_at surfaced for reconciliation (#166) self.assertIn("Updated: 2026-07-05T10:00:00Z", result["message"]) - @patch("mcp_server.api_get_all") + @patch("mcp_server.api_fetch_page") @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") - def test_author_profile_never_calls_mutation_tools_during_inventory(self, mock_get_profile, _auth, mock_api, mock_get_all): + def test_author_profile_never_calls_mutation_tools_during_inventory(self, mock_get_profile, _auth, mock_api, mock_fetch): mock_get_profile.return_value = { "profile_name": "gitea-author", "allowed_operations": ["read"], "forbidden_operations": [], "base_url": None, } - mock_get_all.return_value = [] + mock_fetch.return_value = _final_page_fetch([]) mock_api.return_value = {"login": "jcwalker3"} gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs") @@ -105,20 +117,20 @@ class TestPRQueueInventory(unittest.TestCase): method = call.args[0] self.assertEqual(method, "GET") - @patch("mcp_server.api_get_all") + @patch("mcp_server.api_fetch_page") @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") - def test_reviewer_profile_can_proceed_from_inventory_to_review_gates(self, mock_get_profile, _auth, mock_api, mock_get_all): + def test_reviewer_profile_can_proceed_from_inventory_to_review_gates(self, mock_get_profile, _auth, mock_api, mock_fetch): mock_get_profile.return_value = { "profile_name": "gitea-reviewer", "allowed_operations": ["read", "approve", "review"], "forbidden_operations": [], "base_url": None, } - mock_get_all.return_value = [ + mock_fetch.return_value = _final_page_fetch([ {"number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1", "sha": "abc1"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "other_user"}} - ] + ]) # mock_api: inventory whoami, eligibility whoami, eligibility PR, # POST review (#244: state + visible-verdict GET reviews). mock_api.side_effect = [ @@ -152,19 +164,19 @@ class TestPRQueueInventory(unittest.TestCase): self.assertIn("Repository:", result["message"]) self.assertIn("Successfully submitted review", result["message"]) - @patch("mcp_server.api_get_all") + @patch("mcp_server.api_fetch_page") @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") @patch("gitea_config.is_runtime_switching_enabled", return_value=False) - def test_static_runtime_rejects_or_hides_profile_activation(self, mock_switch, mock_get_profile, _auth, mock_api, mock_get_all): + def test_static_runtime_rejects_or_hides_profile_activation(self, mock_switch, mock_get_profile, _auth, mock_api, mock_fetch): mock_get_profile.return_value = { "profile_name": "gitea-author", "allowed_operations": ["read"], "forbidden_operations": [], "base_url": None, } - mock_get_all.return_value = [] + mock_fetch.return_value = _final_page_fetch([]) mock_api.return_value = {"login": "jcwalker3"} result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs") @@ -200,11 +212,11 @@ class TestPRQueueInventory(unittest.TestCase): self.assertFalse(result["eligible"]) self.assertEqual(mock_api.call_count, 0) - @patch("mcp_server.api_get_all") + @patch("mcp_server.api_fetch_page") @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") - def test_inventory_failure_output_is_redacted_no_raw_exception_leak(self, mock_get_profile, _auth, mock_api, mock_get_all): + def test_inventory_failure_output_is_redacted_no_raw_exception_leak(self, mock_get_profile, _auth, mock_api, mock_fetch): """Inventory failure path must use redaction and not leak raw exception text.""" mock_get_profile.return_value = { "profile_name": "gitea-author", @@ -213,7 +225,7 @@ class TestPRQueueInventory(unittest.TestCase): "base_url": None, } # Exception contains something that should be redacted or not leaked raw - mock_get_all.side_effect = Exception("connection error token supersecrettoken123 at https://internal.example/repo") + mock_fetch.side_effect = Exception("connection error token supersecrettoken123 at https://internal.example/repo") mock_api.return_value = {"login": "jcwalker3"} result = gitea_review_pr(pr_number=42, event="APPROVE", remote="prgs") @@ -228,11 +240,11 @@ class TestPRQueueInventory(unittest.TestCase): # uses project's redaction pattern (token prefix -> [REDACTED]) # even if url leaks, the exception is redacted per pattern - @patch("mcp_server.api_get_all") + @patch("mcp_server.api_fetch_page") @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") - def test_inventory_failure_output_redacts_real_service_urls(self, mock_get_profile, _auth, mock_api, mock_get_all): + def test_inventory_failure_output_redacts_real_service_urls(self, mock_get_profile, _auth, mock_api, mock_fetch): """Failure paths must fully redact real service hostnames and URLs from inventory output.""" mock_get_profile.return_value = { "profile_name": "gitea-author", @@ -240,7 +252,7 @@ class TestPRQueueInventory(unittest.TestCase): "forbidden_operations": [], "base_url": None, } - mock_get_all.side_effect = Exception("failed to connect to https://gitea.prgs.cc/api/v1/repos/x") + mock_fetch.side_effect = Exception("failed to connect to https://gitea.prgs.cc/api/v1/repos/x") mock_api.return_value = {"login": "jcwalker3"} result = gitea_review_pr(pr_number=42, event="APPROVE", remote="prgs") @@ -250,11 +262,11 @@ class TestPRQueueInventory(unittest.TestCase): self.assertIn("[REDACTED_URL]", msg) self.assertNotIn("gitea.prgs.cc", msg) - @patch("mcp_server.api_get_all") + @patch("mcp_server.api_fetch_page") @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") - def test_author_profiles_can_perform_read_only_pr_inventory(self, mock_get_profile, _auth, mock_api, mock_get_all): + def test_author_profiles_can_perform_read_only_pr_inventory(self, mock_get_profile, _auth, mock_api, mock_fetch): """Author profiles with read can perform read-only PR inventory (report produced, no mutations).""" mock_get_profile.return_value = { "profile_name": "gitea-author", @@ -262,9 +274,9 @@ class TestPRQueueInventory(unittest.TestCase): "forbidden_operations": [], "base_url": None, } - mock_get_all.return_value = [ + mock_fetch.return_value = _final_page_fetch([ {"number": 7, "title": "Some PR", "state": "open", "head": {"ref": "f", "sha": "def"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "someone"}, "labels": [], "body": ""} - ] + ]) mock_api.return_value = {"login": "jcwalker3"} result = gitea_review_pr(pr_number=7, event="APPROVE", remote="prgs") @@ -278,12 +290,12 @@ class TestPRQueueInventory(unittest.TestCase): self.assertEqual(call.args[0], "GET") @patch("mcp_server._local_git_remote_url") - @patch("mcp_server.api_get_all") + @patch("mcp_server.api_fetch_page") @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") def test_empty_inventory_runs_trust_gate_and_blocks_without_trusted_empty( - self, mock_get_profile, _auth, mock_api, mock_get_all, mock_local_url + self, mock_get_profile, _auth, mock_api, mock_fetch, mock_local_url ): mock_get_profile.return_value = { "profile_name": "gitea-author", @@ -291,7 +303,7 @@ class TestPRQueueInventory(unittest.TestCase): "forbidden_operations": [], "base_url": None, } - mock_get_all.return_value = [] + mock_fetch.return_value = _final_page_fetch([]) mock_api.return_value = {"login": "jcwalker3"} mock_local_url.return_value = None @@ -309,12 +321,12 @@ class TestPRQueueInventory(unittest.TestCase): self.assertNotIn("Open PRs found: 0 (trusted_empty)", msg) @patch("mcp_server._local_git_remote_url") - @patch("mcp_server.api_get_all") + @patch("mcp_server.api_fetch_page") @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") def test_empty_inventory_trusted_empty_when_gate_passes( - self, mock_get_profile, _auth, mock_api, mock_get_all, mock_local_url + self, mock_get_profile, _auth, mock_api, mock_fetch, mock_local_url ): mock_get_profile.return_value = { "profile_name": "gitea-author", @@ -322,7 +334,7 @@ class TestPRQueueInventory(unittest.TestCase): "forbidden_operations": [], "base_url": None, } - mock_get_all.return_value = [] + mock_fetch.return_value = _final_page_fetch([]) mock_api.return_value = {"login": "jcwalker3"} mock_local_url.return_value = ( "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git" @@ -341,11 +353,11 @@ class TestPRQueueInventory(unittest.TestCase): self.assertIn("Open PRs found: 0 (trusted_empty)", msg) @patch("mcp_server._local_git_remote_url") - @patch("mcp_server.api_get_all") + @patch("mcp_server.api_fetch_page") @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") - def test_author_profiles_cannot_approve_request_changes_merge_or_bypass_gates(self, mock_get_profile, _auth, mock_api, mock_get_all, mock_local_url): + def test_author_profiles_cannot_approve_request_changes_merge_or_bypass_gates(self, mock_get_profile, _auth, mock_api, mock_fetch, mock_local_url): """Author profiles still cannot approve, request_changes, merge, or bypass gates even with inventory.""" mock_local_url.return_value = ( "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git" @@ -357,7 +369,7 @@ class TestPRQueueInventory(unittest.TestCase): "forbidden_operations": [], "base_url": None, } - mock_get_all.return_value = [] + mock_fetch.return_value = _final_page_fetch([]) mock_api.return_value = {"login": "jcwalker3"} result = gitea_review_pr(pr_number=1, event=event, remote="prgs") @@ -377,10 +389,10 @@ class TestPRQueueInventory(unittest.TestCase): # Tests use direct list/view calls + inventory path to ensure fields and # live data are available for detection without trusting prior handoffs. - @patch("mcp_server.api_get_all") + @patch("mcp_server.api_fetch_page") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") - def test_r5_s1_stale_prior_handoff_merged_but_live_pr_open(self, mock_get_profile, _auth, mock_get_all): + def test_r5_s1_stale_prior_handoff_merged_but_live_pr_open(self, mock_get_profile, _auth, mock_fetch): """Scenario 1: stale prior handoff says merged but live PR list says open.""" mock_get_profile.return_value = { "profile_name": "gitea-author", @@ -388,17 +400,17 @@ class TestPRQueueInventory(unittest.TestCase): "forbidden_operations": [], "base_url": None, } - mock_get_all.return_value = [{ + mock_fetch.return_value = _final_page_fetch([{ "number": 99, "title": "Stale merge claim", "state": "open", "head": {"ref": "f99", "sha": "sha99"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "other"}, "labels": [], "body": "Fixes #200", "updated_at": "2026-07-05T22:00:00Z" - }] + }]) prs = gitea_list_prs(state="open", remote="prgs") - self.assertEqual(len(prs), 1) - self.assertEqual(prs[0]["state"], "open") - self.assertIn("updated_at", prs[0]) # enables staleness check vs prior "merged" + self.assertEqual(len(prs["prs"]), 1) + self.assertEqual(prs["prs"][0]["state"], "open") + self.assertIn("updated_at", prs["prs"][0]) # enables staleness check vs prior "merged" @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @@ -424,11 +436,11 @@ class TestPRQueueInventory(unittest.TestCase): self.assertIsNotNone(pr.get("closed_at")) # live closed would contradict prior "open" handoff - @patch("mcp_server.api_get_all") + @patch("mcp_server.api_fetch_page") @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") - def test_r5_s3_list_prs_and_view_pr_disagree(self, mock_get_profile, _auth, mock_api, mock_get_all): + def test_r5_s3_list_prs_and_view_pr_disagree(self, mock_get_profile, _auth, mock_api, mock_fetch): """Scenario 3: gitea_list_prs and gitea_view_pr disagree on state/details.""" mock_get_profile.return_value = { "profile_name": "gitea-author", @@ -437,12 +449,12 @@ class TestPRQueueInventory(unittest.TestCase): "base_url": None, } # list says open - mock_get_all.return_value = [{ + mock_fetch.return_value = _final_page_fetch([{ "number": 77, "title": "Disagree", "state": "open", "head": {"ref": "f77", "sha": "s77"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "x"}, "labels": [], "body": "", "updated_at": "2026-07-05T23:00:00Z" - }] + }]) # view says closed (simulating inconsistency) mock_api.return_value = { "number": 77, "title": "Disagree", "state": "closed", @@ -453,7 +465,7 @@ class TestPRQueueInventory(unittest.TestCase): } listed = gitea_list_prs(state="open", remote="prgs") viewed = gitea_view_pr(pr_number=77, remote="prgs") - self.assertEqual(listed[0]["state"], "open") + self.assertEqual(listed["prs"][0]["state"], "open") self.assertEqual(viewed["state"], "closed") # caller must reconcile; mismatch is hard blocker per rules @@ -483,11 +495,11 @@ class TestPRQueueInventory(unittest.TestCase): self.assertIsNone(pr.get("merge_commit_sha")) # post-merge view must surface missing commit for detection - @patch("mcp_server.api_get_all") + @patch("mcp_server.api_fetch_page") @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) @patch("mcp_server.get_profile") - def test_r5_s5_linked_issue_remains_open_after_claimed_merge(self, mock_get_profile, _auth, mock_api, mock_get_all): + def test_r5_s5_linked_issue_remains_open_after_claimed_merge(self, mock_get_profile, _auth, mock_api, mock_fetch): """Scenario 5: linked issue remains open after claimed merge.""" mock_get_profile.return_value = { "profile_name": "gitea-author", @@ -496,12 +508,12 @@ class TestPRQueueInventory(unittest.TestCase): "base_url": None, } # PR claims merge, links #300 - mock_get_all.return_value = [{ + mock_fetch.return_value = _final_page_fetch([{ "number": 300, "title": "PR for #300", "state": "closed", "head": {"ref": "f300", "sha": "s3"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "u"}, "labels": [], "body": "Fixes #300", "updated_at": "2026-07-05T20:20:00Z" - }] + }]) # But linked issue (via list_issues or view) still open - here we use view_pr body to confirm link # For this test we assert the PR data shows link, caller must check issue state live mock_api.return_value = {