Merge pull request 'Add pagination metadata to gitea_list_prs' (#342) from feat/issue-340-list-prs-pagination-metadata into master
This commit was merged in pull request #342.
This commit is contained in:
@@ -420,6 +420,43 @@ def api_get_all(url, auth_header, *, limit=None, page_size=50, max_pages=100,
|
|||||||
return results
|
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):
|
def gitea_url(host, path):
|
||||||
"""Build a full URL for *host* and *path*, using http for loopback and https for others."""
|
"""Build a full URL for *host* and *path*, using http for loopback and https for others."""
|
||||||
if not path.startswith("/"):
|
if not path.startswith("/"):
|
||||||
|
|||||||
+130
-28
@@ -461,6 +461,7 @@ from gitea_auth import ( # noqa: E402
|
|||||||
get_auth_header,
|
get_auth_header,
|
||||||
api_request,
|
api_request,
|
||||||
api_get_all,
|
api_get_all,
|
||||||
|
api_fetch_page,
|
||||||
repo_api_url,
|
repo_api_url,
|
||||||
get_profile,
|
get_profile,
|
||||||
gitea_url,
|
gitea_url,
|
||||||
@@ -1119,6 +1120,56 @@ def gitea_create_pr(
|
|||||||
return _with_optional_url({"number": data["number"]}, data.get("html_url"))
|
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()
|
@mcp.tool()
|
||||||
def gitea_list_prs(
|
def gitea_list_prs(
|
||||||
state: str = "open",
|
state: str = "open",
|
||||||
@@ -1126,7 +1177,9 @@ def gitea_list_prs(
|
|||||||
host: str | None = None,
|
host: str | None = None,
|
||||||
org: str | None = None,
|
org: str | None = None,
|
||||||
repo: 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.
|
"""List pull requests on a Gitea repository.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -1135,11 +1188,16 @@ def gitea_list_prs(
|
|||||||
host: Override the Gitea host.
|
host: Override the Gitea host.
|
||||||
org: Override the owner/organization.
|
org: Override the owner/organization.
|
||||||
repo: Override the repository name.
|
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:
|
Returns:
|
||||||
List of dicts with 'number', 'title', 'state', 'head', 'base',
|
Dict with ``prs`` (list of PR summaries) and ``pagination`` metadata
|
||||||
'mergeable', 'updated_at' ('url' only with the reveal opt-in).
|
(``has_more``, ``next_page``, ``is_final_page``, ``inventory_complete``,
|
||||||
The additional 'updated_at' aids stale/conflicting queue detection.
|
``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(
|
allowed, block_reasons = capability_stop_terminal.check_reviewer_queue_tool(
|
||||||
"list_prs"
|
"list_prs"
|
||||||
@@ -1149,20 +1207,48 @@ def gitea_list_prs(
|
|||||||
h, o, r = _resolve(remote, host, org, repo)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
url = f"{repo_api_url(h, o, r)}/pulls?state={state}"
|
url = f"{repo_api_url(h, o, r)}/pulls?state={state}"
|
||||||
prs = api_get_all(url, auth)
|
|
||||||
results = []
|
if page is not None:
|
||||||
for pr in prs:
|
raw_page, pagination = api_fetch_page(
|
||||||
entry = {
|
url, auth, page=page, limit=per_page
|
||||||
"number": pr["number"],
|
)
|
||||||
"title": pr["title"],
|
prs = [_format_list_pr_entry(pr) for pr in raw_page]
|
||||||
"state": pr["state"],
|
return _list_prs_pagination_envelope(
|
||||||
"head": pr["head"]["ref"],
|
prs,
|
||||||
"base": pr["base"]["ref"],
|
page=pagination["page"],
|
||||||
"mergeable": pr.get("mergeable"),
|
per_page=pagination["per_page"],
|
||||||
"updated_at": pr.get("updated_at"),
|
pages_fetched=1,
|
||||||
}
|
is_final_page=pagination["is_final_page"],
|
||||||
results.append(_with_optional_url(entry, pr.get("html_url")))
|
has_more=pagination["has_more"],
|
||||||
return results
|
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()
|
@mcp.tool()
|
||||||
@@ -2929,19 +3015,25 @@ def gitea_review_pr(
|
|||||||
if can_read and auth:
|
if can_read and auth:
|
||||||
inventory_attempted = True
|
inventory_attempted = True
|
||||||
try:
|
try:
|
||||||
url = f"{repo_api_url(h, o, r)}/pulls?state=open"
|
prs = gitea_list_prs(
|
||||||
prs = api_get_all(url, auth)
|
state="open",
|
||||||
prs_found_count = len(prs)
|
remote=remote,
|
||||||
for pr in prs:
|
host=h,
|
||||||
labels = [l["name"] for l in pr.get("labels", [])]
|
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 ""
|
body_text = pr.get("body") or ""
|
||||||
linked = []
|
linked = []
|
||||||
# Simple extraction of linked issues from body
|
# Simple extraction of linked issues from body
|
||||||
for match in re.findall(r'#(\d+)', body_text):
|
for match in re.findall(r'#(\d+)', body_text):
|
||||||
linked.append(f"#{match}")
|
linked.append(f"#{match}")
|
||||||
|
|
||||||
pr_head_sha = (pr.get("head") or {}).get("sha")
|
pr_head_sha = pr.get("head_sha")
|
||||||
pr_author = (pr.get("user") or {}).get("login")
|
pr_author = pr.get("author")
|
||||||
|
|
||||||
# Determine review block status
|
# Determine review block status
|
||||||
req_non_author = False
|
req_non_author = False
|
||||||
@@ -2952,8 +3044,8 @@ def gitea_review_pr(
|
|||||||
"number": pr["number"],
|
"number": pr["number"],
|
||||||
"title": pr["title"],
|
"title": pr["title"],
|
||||||
"author": pr_author,
|
"author": pr_author,
|
||||||
"base": pr["base"]["ref"],
|
"base": pr["base"],
|
||||||
"head": pr["head"]["ref"],
|
"head": pr["head"],
|
||||||
"mergeable": pr.get("mergeable"),
|
"mergeable": pr.get("mergeable"),
|
||||||
"linked_issues": list(set(linked)),
|
"linked_issues": list(set(linked)),
|
||||||
"labels": labels,
|
"labels": labels,
|
||||||
@@ -2994,6 +3086,16 @@ def gitea_review_pr(
|
|||||||
if inventory_msg:
|
if inventory_msg:
|
||||||
report_lines.append(inventory_msg)
|
report_lines.append(inventory_msg)
|
||||||
else:
|
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(
|
inventory_trust_gate = review_proofs.pr_inventory_trust_gate(
|
||||||
prs if inventory_attempted else None,
|
prs if inventory_attempted else None,
|
||||||
remote=remote,
|
remote=remote,
|
||||||
@@ -3002,7 +3104,7 @@ def gitea_review_pr(
|
|||||||
state="open",
|
state="open",
|
||||||
authenticated_profile=profile,
|
authenticated_profile=profile,
|
||||||
local_remote_url=_local_git_remote_url(remote),
|
local_remote_url=_local_git_remote_url(remote),
|
||||||
has_finality_metadata=True,
|
has_finality_metadata=has_finality_metadata,
|
||||||
)
|
)
|
||||||
report_lines.extend(
|
report_lines.extend(
|
||||||
review_proofs.format_pr_inventory_trust_gate_report(
|
review_proofs.format_pr_inventory_trust_gate_report(
|
||||||
|
|||||||
+99
-3
@@ -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,
|
def assess_role_boundary(proof=None, *, task_role=None, namespaces_used=None,
|
||||||
justification=None):
|
justification=None):
|
||||||
"""Assess reviewer/author role separation for blind queue workflows.
|
"""Assess reviewer/author role separation for blind queue workflows.
|
||||||
@@ -1862,14 +1946,19 @@ def pr_inventory_trust_gate(
|
|||||||
"queue_target_lock": lock_status,
|
"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 {
|
return {
|
||||||
"status": "inventory_error",
|
"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,
|
"corroborated": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(list_prs_response) > 0:
|
if len(prs_list) > 0:
|
||||||
return {
|
return {
|
||||||
"status": "trusted_nonempty",
|
"status": "trusted_nonempty",
|
||||||
"reasons": [],
|
"reasons": [],
|
||||||
@@ -1900,6 +1989,13 @@ def pr_inventory_trust_gate(
|
|||||||
corroborated = False
|
corroborated = False
|
||||||
if has_finality_metadata:
|
if has_finality_metadata:
|
||||||
corroborated = True
|
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:
|
elif corroboration_open_pr_counter == 0:
|
||||||
corroborated = True
|
corroborated = True
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -99,12 +99,16 @@ class TestCapabilityStopTerminal(unittest.TestCase):
|
|||||||
self.assertIn("Cannot perform reviewer task", str(ctx.exception))
|
self.assertIn("Cannot perform reviewer task", str(ctx.exception))
|
||||||
self.assertIn("review_pr", 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.api_request", return_value={"login": "jcwalker3"})
|
||||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||||
def test_list_prs_allowed_after_author_task_clears_stale_denial(
|
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()):
|
with patch.dict(os.environ, self._env()):
|
||||||
denied = mcp_server.gitea_resolve_task_capability(
|
denied = mcp_server.gitea_resolve_task_capability(
|
||||||
task="review_pr", remote="prgs"
|
task="review_pr", remote="prgs"
|
||||||
@@ -120,7 +124,7 @@ class TestCapabilityStopTerminal(unittest.TestCase):
|
|||||||
self.assertFalse(capability_stop_terminal.is_active())
|
self.assertFalse(capability_stop_terminal.is_active())
|
||||||
|
|
||||||
prs = mcp_server.gitea_list_prs(remote="prgs")
|
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.api_request", return_value={"login": "jcwalker3"})
|
||||||
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
@patch("mcp_server.get_auth_header", return_value="token author-pass")
|
||||||
|
|||||||
@@ -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()
|
||||||
+27
-18
@@ -400,40 +400,49 @@ class TestMirrorRefs(unittest.TestCase):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
class TestListPRs(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)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_list_prs(self, _auth, mock_api):
|
def test_list_prs(self, _auth, mock_fetch):
|
||||||
mock_api.return_value = [
|
mock_fetch.return_value = (
|
||||||
{
|
[{
|
||||||
"number": 1, "title": "PR 1", "state": "open",
|
"number": 1, "title": "PR 1", "state": "open",
|
||||||
"head": {"ref": "branch1"}, "base": {"ref": "main"},
|
"head": {"ref": "branch1"}, "base": {"ref": "main"},
|
||||||
"html_url": "http://url1", "mergeable": True,
|
"html_url": "http://url1", "mergeable": True,
|
||||||
"updated_at": "2026-07-05T12:00:00Z"
|
"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):
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
result = gitea_list_prs()
|
result = gitea_list_prs()
|
||||||
self.assertEqual(len(result), 1)
|
self.assertEqual(len(result["prs"]), 1)
|
||||||
self.assertEqual(result[0]["number"], 1)
|
self.assertEqual(result["prs"][0]["number"], 1)
|
||||||
self.assertEqual(result[0]["head"], "branch1")
|
self.assertEqual(result["prs"][0]["head"], "branch1")
|
||||||
self.assertNotIn("url", result[0])
|
self.assertNotIn("url", result["prs"][0])
|
||||||
|
self.assertTrue(result["pagination"]["inventory_complete"])
|
||||||
# Staleness fields for queue reconciliation (#166)
|
# 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)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
def test_list_prs_reveal_opt_in_includes_url(self, _auth, mock_api):
|
def test_list_prs_reveal_opt_in_includes_url(self, _auth, mock_fetch):
|
||||||
mock_api.return_value = [
|
mock_fetch.return_value = (
|
||||||
{
|
[{
|
||||||
"number": 1, "title": "PR 1", "state": "open",
|
"number": 1, "title": "PR 1", "state": "open",
|
||||||
"head": {"ref": "branch1"}, "base": {"ref": "main"},
|
"head": {"ref": "branch1"}, "base": {"ref": "main"},
|
||||||
"html_url": "http://url1", "mergeable": True,
|
"html_url": "http://url1", "mergeable": True,
|
||||||
"updated_at": "2026-07-05T12:00:00Z"
|
"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):
|
with patch.dict(os.environ, {"GITEA_MCP_REVEAL_ENDPOINTS": "1"}, clear=True):
|
||||||
result = gitea_list_prs()
|
result = gitea_list_prs()
|
||||||
self.assertEqual(result[0]["url"], "http://url1")
|
self.assertEqual(result["prs"][0]["url"], "http://url1")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -16,38 +16,50 @@ import gitea_config
|
|||||||
|
|
||||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
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):
|
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_auth_header", return_value=FAKE_AUTH)
|
||||||
@patch("mcp_server.get_profile")
|
@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 = {
|
mock_get_profile.return_value = {
|
||||||
"profile_name": "gitea-author",
|
"profile_name": "gitea-author",
|
||||||
"allowed_operations": ["read", "gitea.pr.comment"],
|
"allowed_operations": ["read", "gitea.pr.comment"],
|
||||||
"forbidden_operations": [],
|
"forbidden_operations": [],
|
||||||
"base_url": None,
|
"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}
|
{"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")
|
result = gitea_list_prs(state="open", remote="prgs")
|
||||||
self.assertEqual(len(result), 1)
|
self.assertEqual(len(result["prs"]), 1)
|
||||||
self.assertEqual(result[0]["number"], 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.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
@patch("mcp_server.get_profile")
|
@patch("mcp_server.get_profile")
|
||||||
@patch("gitea_config.is_runtime_switching_enabled", return_value=True)
|
@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 = {
|
mock_get_profile.return_value = {
|
||||||
"profile_name": "gitea-author",
|
"profile_name": "gitea-author",
|
||||||
"allowed_operations": ["read"],
|
"allowed_operations": ["read"],
|
||||||
"forbidden_operations": [],
|
"forbidden_operations": [],
|
||||||
"base_url": None,
|
"base_url": None,
|
||||||
}
|
}
|
||||||
mock_get_all.return_value = [
|
mock_fetch.return_value = _final_page_fetch([
|
||||||
{
|
{
|
||||||
"number": 1,
|
"number": 1,
|
||||||
"title": "PR 1",
|
"title": "PR 1",
|
||||||
@@ -72,7 +84,7 @@ class TestPRQueueInventory(unittest.TestCase):
|
|||||||
"body": "",
|
"body": "",
|
||||||
"updated_at": "2026-07-05T11:00:00Z"
|
"updated_at": "2026-07-05T11:00:00Z"
|
||||||
}
|
}
|
||||||
]
|
])
|
||||||
mock_api.return_value = {"login": "jcwalker3"} # whoami
|
mock_api.return_value = {"login": "jcwalker3"} # whoami
|
||||||
|
|
||||||
result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs")
|
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)
|
# updated_at surfaced for reconciliation (#166)
|
||||||
self.assertIn("Updated: 2026-07-05T10:00:00Z", result["message"])
|
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.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
@patch("mcp_server.get_profile")
|
@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 = {
|
mock_get_profile.return_value = {
|
||||||
"profile_name": "gitea-author",
|
"profile_name": "gitea-author",
|
||||||
"allowed_operations": ["read"],
|
"allowed_operations": ["read"],
|
||||||
"forbidden_operations": [],
|
"forbidden_operations": [],
|
||||||
"base_url": None,
|
"base_url": None,
|
||||||
}
|
}
|
||||||
mock_get_all.return_value = []
|
mock_fetch.return_value = _final_page_fetch([])
|
||||||
mock_api.return_value = {"login": "jcwalker3"}
|
mock_api.return_value = {"login": "jcwalker3"}
|
||||||
|
|
||||||
gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs")
|
gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs")
|
||||||
@@ -105,20 +117,20 @@ class TestPRQueueInventory(unittest.TestCase):
|
|||||||
method = call.args[0]
|
method = call.args[0]
|
||||||
self.assertEqual(method, "GET")
|
self.assertEqual(method, "GET")
|
||||||
|
|
||||||
@patch("mcp_server.api_get_all")
|
@patch("mcp_server.api_fetch_page")
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
@patch("mcp_server.get_profile")
|
@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 = {
|
mock_get_profile.return_value = {
|
||||||
"profile_name": "gitea-reviewer",
|
"profile_name": "gitea-reviewer",
|
||||||
"allowed_operations": ["read", "approve", "review"],
|
"allowed_operations": ["read", "approve", "review"],
|
||||||
"forbidden_operations": [],
|
"forbidden_operations": [],
|
||||||
"base_url": None,
|
"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"}}
|
{"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,
|
# mock_api: inventory whoami, eligibility whoami, eligibility PR,
|
||||||
# POST review (#244: state + visible-verdict GET reviews).
|
# POST review (#244: state + visible-verdict GET reviews).
|
||||||
mock_api.side_effect = [
|
mock_api.side_effect = [
|
||||||
@@ -152,19 +164,19 @@ class TestPRQueueInventory(unittest.TestCase):
|
|||||||
self.assertIn("Repository:", result["message"])
|
self.assertIn("Repository:", result["message"])
|
||||||
self.assertIn("Successfully submitted review", 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.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
@patch("mcp_server.get_profile")
|
@patch("mcp_server.get_profile")
|
||||||
@patch("gitea_config.is_runtime_switching_enabled", return_value=False)
|
@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 = {
|
mock_get_profile.return_value = {
|
||||||
"profile_name": "gitea-author",
|
"profile_name": "gitea-author",
|
||||||
"allowed_operations": ["read"],
|
"allowed_operations": ["read"],
|
||||||
"forbidden_operations": [],
|
"forbidden_operations": [],
|
||||||
"base_url": None,
|
"base_url": None,
|
||||||
}
|
}
|
||||||
mock_get_all.return_value = []
|
mock_fetch.return_value = _final_page_fetch([])
|
||||||
mock_api.return_value = {"login": "jcwalker3"}
|
mock_api.return_value = {"login": "jcwalker3"}
|
||||||
|
|
||||||
result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs")
|
result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs")
|
||||||
@@ -200,11 +212,11 @@ class TestPRQueueInventory(unittest.TestCase):
|
|||||||
self.assertFalse(result["eligible"])
|
self.assertFalse(result["eligible"])
|
||||||
self.assertEqual(mock_api.call_count, 0)
|
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.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
@patch("mcp_server.get_profile")
|
@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."""
|
"""Inventory failure path must use redaction and not leak raw exception text."""
|
||||||
mock_get_profile.return_value = {
|
mock_get_profile.return_value = {
|
||||||
"profile_name": "gitea-author",
|
"profile_name": "gitea-author",
|
||||||
@@ -213,7 +225,7 @@ class TestPRQueueInventory(unittest.TestCase):
|
|||||||
"base_url": None,
|
"base_url": None,
|
||||||
}
|
}
|
||||||
# Exception contains something that should be redacted or not leaked raw
|
# 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"}
|
mock_api.return_value = {"login": "jcwalker3"}
|
||||||
|
|
||||||
result = gitea_review_pr(pr_number=42, event="APPROVE", remote="prgs")
|
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])
|
# uses project's redaction pattern (token prefix -> [REDACTED])
|
||||||
# even if url leaks, the exception is redacted per pattern
|
# 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.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
@patch("mcp_server.get_profile")
|
@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."""
|
"""Failure paths must fully redact real service hostnames and URLs from inventory output."""
|
||||||
mock_get_profile.return_value = {
|
mock_get_profile.return_value = {
|
||||||
"profile_name": "gitea-author",
|
"profile_name": "gitea-author",
|
||||||
@@ -240,7 +252,7 @@ class TestPRQueueInventory(unittest.TestCase):
|
|||||||
"forbidden_operations": [],
|
"forbidden_operations": [],
|
||||||
"base_url": None,
|
"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"}
|
mock_api.return_value = {"login": "jcwalker3"}
|
||||||
|
|
||||||
result = gitea_review_pr(pr_number=42, event="APPROVE", remote="prgs")
|
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.assertIn("[REDACTED_URL]", msg)
|
||||||
self.assertNotIn("gitea.prgs.cc", 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.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
@patch("mcp_server.get_profile")
|
@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)."""
|
"""Author profiles with read can perform read-only PR inventory (report produced, no mutations)."""
|
||||||
mock_get_profile.return_value = {
|
mock_get_profile.return_value = {
|
||||||
"profile_name": "gitea-author",
|
"profile_name": "gitea-author",
|
||||||
@@ -262,9 +274,9 @@ class TestPRQueueInventory(unittest.TestCase):
|
|||||||
"forbidden_operations": [],
|
"forbidden_operations": [],
|
||||||
"base_url": None,
|
"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": ""}
|
{"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"}
|
mock_api.return_value = {"login": "jcwalker3"}
|
||||||
|
|
||||||
result = gitea_review_pr(pr_number=7, event="APPROVE", remote="prgs")
|
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")
|
self.assertEqual(call.args[0], "GET")
|
||||||
|
|
||||||
@patch("mcp_server._local_git_remote_url")
|
@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.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
@patch("mcp_server.get_profile")
|
@patch("mcp_server.get_profile")
|
||||||
def test_empty_inventory_runs_trust_gate_and_blocks_without_trusted_empty(
|
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 = {
|
mock_get_profile.return_value = {
|
||||||
"profile_name": "gitea-author",
|
"profile_name": "gitea-author",
|
||||||
@@ -291,7 +303,7 @@ class TestPRQueueInventory(unittest.TestCase):
|
|||||||
"forbidden_operations": [],
|
"forbidden_operations": [],
|
||||||
"base_url": None,
|
"base_url": None,
|
||||||
}
|
}
|
||||||
mock_get_all.return_value = []
|
mock_fetch.return_value = _final_page_fetch([])
|
||||||
mock_api.return_value = {"login": "jcwalker3"}
|
mock_api.return_value = {"login": "jcwalker3"}
|
||||||
mock_local_url.return_value = None
|
mock_local_url.return_value = None
|
||||||
|
|
||||||
@@ -309,12 +321,12 @@ class TestPRQueueInventory(unittest.TestCase):
|
|||||||
self.assertNotIn("Open PRs found: 0 (trusted_empty)", msg)
|
self.assertNotIn("Open PRs found: 0 (trusted_empty)", msg)
|
||||||
|
|
||||||
@patch("mcp_server._local_git_remote_url")
|
@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.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
@patch("mcp_server.get_profile")
|
@patch("mcp_server.get_profile")
|
||||||
def test_empty_inventory_trusted_empty_when_gate_passes(
|
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 = {
|
mock_get_profile.return_value = {
|
||||||
"profile_name": "gitea-author",
|
"profile_name": "gitea-author",
|
||||||
@@ -322,7 +334,7 @@ class TestPRQueueInventory(unittest.TestCase):
|
|||||||
"forbidden_operations": [],
|
"forbidden_operations": [],
|
||||||
"base_url": None,
|
"base_url": None,
|
||||||
}
|
}
|
||||||
mock_get_all.return_value = []
|
mock_fetch.return_value = _final_page_fetch([])
|
||||||
mock_api.return_value = {"login": "jcwalker3"}
|
mock_api.return_value = {"login": "jcwalker3"}
|
||||||
mock_local_url.return_value = (
|
mock_local_url.return_value = (
|
||||||
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
"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)
|
self.assertIn("Open PRs found: 0 (trusted_empty)", msg)
|
||||||
|
|
||||||
@patch("mcp_server._local_git_remote_url")
|
@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.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
@patch("mcp_server.get_profile")
|
@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."""
|
"""Author profiles still cannot approve, request_changes, merge, or bypass gates even with inventory."""
|
||||||
mock_local_url.return_value = (
|
mock_local_url.return_value = (
|
||||||
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
||||||
@@ -357,7 +369,7 @@ class TestPRQueueInventory(unittest.TestCase):
|
|||||||
"forbidden_operations": [],
|
"forbidden_operations": [],
|
||||||
"base_url": None,
|
"base_url": None,
|
||||||
}
|
}
|
||||||
mock_get_all.return_value = []
|
mock_fetch.return_value = _final_page_fetch([])
|
||||||
mock_api.return_value = {"login": "jcwalker3"}
|
mock_api.return_value = {"login": "jcwalker3"}
|
||||||
|
|
||||||
result = gitea_review_pr(pr_number=1, event=event, remote="prgs")
|
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
|
# Tests use direct list/view calls + inventory path to ensure fields and
|
||||||
# live data are available for detection without trusting prior handoffs.
|
# 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_auth_header", return_value=FAKE_AUTH)
|
||||||
@patch("mcp_server.get_profile")
|
@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."""
|
"""Scenario 1: stale prior handoff says merged but live PR list says open."""
|
||||||
mock_get_profile.return_value = {
|
mock_get_profile.return_value = {
|
||||||
"profile_name": "gitea-author",
|
"profile_name": "gitea-author",
|
||||||
@@ -388,17 +400,17 @@ class TestPRQueueInventory(unittest.TestCase):
|
|||||||
"forbidden_operations": [],
|
"forbidden_operations": [],
|
||||||
"base_url": None,
|
"base_url": None,
|
||||||
}
|
}
|
||||||
mock_get_all.return_value = [{
|
mock_fetch.return_value = _final_page_fetch([{
|
||||||
"number": 99, "title": "Stale merge claim", "state": "open",
|
"number": 99, "title": "Stale merge claim", "state": "open",
|
||||||
"head": {"ref": "f99", "sha": "sha99"}, "base": {"ref": "master"},
|
"head": {"ref": "f99", "sha": "sha99"}, "base": {"ref": "master"},
|
||||||
"mergeable": True, "user": {"login": "other"},
|
"mergeable": True, "user": {"login": "other"},
|
||||||
"labels": [], "body": "Fixes #200",
|
"labels": [], "body": "Fixes #200",
|
||||||
"updated_at": "2026-07-05T22:00:00Z"
|
"updated_at": "2026-07-05T22:00:00Z"
|
||||||
}]
|
}])
|
||||||
prs = gitea_list_prs(state="open", remote="prgs")
|
prs = gitea_list_prs(state="open", remote="prgs")
|
||||||
self.assertEqual(len(prs), 1)
|
self.assertEqual(len(prs["prs"]), 1)
|
||||||
self.assertEqual(prs[0]["state"], "open")
|
self.assertEqual(prs["prs"][0]["state"], "open")
|
||||||
self.assertIn("updated_at", prs[0]) # enables staleness check vs prior "merged"
|
self.assertIn("updated_at", prs["prs"][0]) # enables staleness check vs prior "merged"
|
||||||
|
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
@@ -424,11 +436,11 @@ class TestPRQueueInventory(unittest.TestCase):
|
|||||||
self.assertIsNotNone(pr.get("closed_at"))
|
self.assertIsNotNone(pr.get("closed_at"))
|
||||||
# live closed would contradict prior "open" handoff
|
# 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.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
@patch("mcp_server.get_profile")
|
@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."""
|
"""Scenario 3: gitea_list_prs and gitea_view_pr disagree on state/details."""
|
||||||
mock_get_profile.return_value = {
|
mock_get_profile.return_value = {
|
||||||
"profile_name": "gitea-author",
|
"profile_name": "gitea-author",
|
||||||
@@ -437,12 +449,12 @@ class TestPRQueueInventory(unittest.TestCase):
|
|||||||
"base_url": None,
|
"base_url": None,
|
||||||
}
|
}
|
||||||
# list says open
|
# list says open
|
||||||
mock_get_all.return_value = [{
|
mock_fetch.return_value = _final_page_fetch([{
|
||||||
"number": 77, "title": "Disagree", "state": "open",
|
"number": 77, "title": "Disagree", "state": "open",
|
||||||
"head": {"ref": "f77", "sha": "s77"}, "base": {"ref": "master"},
|
"head": {"ref": "f77", "sha": "s77"}, "base": {"ref": "master"},
|
||||||
"mergeable": True, "user": {"login": "x"},
|
"mergeable": True, "user": {"login": "x"},
|
||||||
"labels": [], "body": "", "updated_at": "2026-07-05T23:00:00Z"
|
"labels": [], "body": "", "updated_at": "2026-07-05T23:00:00Z"
|
||||||
}]
|
}])
|
||||||
# view says closed (simulating inconsistency)
|
# view says closed (simulating inconsistency)
|
||||||
mock_api.return_value = {
|
mock_api.return_value = {
|
||||||
"number": 77, "title": "Disagree", "state": "closed",
|
"number": 77, "title": "Disagree", "state": "closed",
|
||||||
@@ -453,7 +465,7 @@ class TestPRQueueInventory(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
listed = gitea_list_prs(state="open", remote="prgs")
|
listed = gitea_list_prs(state="open", remote="prgs")
|
||||||
viewed = gitea_view_pr(pr_number=77, 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")
|
self.assertEqual(viewed["state"], "closed")
|
||||||
# caller must reconcile; mismatch is hard blocker per rules
|
# caller must reconcile; mismatch is hard blocker per rules
|
||||||
|
|
||||||
@@ -483,11 +495,11 @@ class TestPRQueueInventory(unittest.TestCase):
|
|||||||
self.assertIsNone(pr.get("merge_commit_sha"))
|
self.assertIsNone(pr.get("merge_commit_sha"))
|
||||||
# post-merge view must surface missing commit for detection
|
# 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.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
@patch("mcp_server.get_profile")
|
@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."""
|
"""Scenario 5: linked issue remains open after claimed merge."""
|
||||||
mock_get_profile.return_value = {
|
mock_get_profile.return_value = {
|
||||||
"profile_name": "gitea-author",
|
"profile_name": "gitea-author",
|
||||||
@@ -496,12 +508,12 @@ class TestPRQueueInventory(unittest.TestCase):
|
|||||||
"base_url": None,
|
"base_url": None,
|
||||||
}
|
}
|
||||||
# PR claims merge, links #300
|
# 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",
|
"number": 300, "title": "PR for #300", "state": "closed",
|
||||||
"head": {"ref": "f300", "sha": "s3"}, "base": {"ref": "master"},
|
"head": {"ref": "f300", "sha": "s3"}, "base": {"ref": "master"},
|
||||||
"mergeable": True, "user": {"login": "u"},
|
"mergeable": True, "user": {"login": "u"},
|
||||||
"labels": [], "body": "Fixes #300", "updated_at": "2026-07-05T20:20:00Z"
|
"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
|
# 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
|
# For this test we assert the PR data shows link, caller must check issue state live
|
||||||
mock_api.return_value = {
|
mock_api.return_value = {
|
||||||
|
|||||||
Reference in New Issue
Block a user