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
This commit is contained in:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user