diff --git a/hookdeck/api.py b/hookdeck/api.py index 968cf2d..93a4ef0 100644 --- a/hookdeck/api.py +++ b/hookdeck/api.py @@ -229,6 +229,18 @@ async def queue_depth( async def list_issues(self, **params: Any) -> Any: return await self.request("GET", "/issues", params=params) + async def count_issues(self, **params: Any) -> int: + """How many issues match, in total rather than on a page. + + The list endpoints paginate and their ``count`` is the page's own size, + so counting from a list means counting to whatever limit was asked for. + Issues have a dedicated count endpoint; events do not. + """ + result = await self.request("GET", "/issues/count", params=params) + if isinstance(result, dict): + return int(result.get("count") or 0) + return 0 + def run_sync(coro: Any) -> Any: """Run *coro* from synchronous CLI code.""" diff --git a/hookdeck/tools.py b/hookdeck/tools.py index 3ab417f..d6e9618 100644 --- a/hookdeck/tools.py +++ b/hookdeck/tools.py @@ -22,6 +22,11 @@ TOOLSET = "hookdeck" +#: How many failed events `hookdeck_queue_status` counts before giving up and +#: reporting a floor. High enough that a real inbox is counted exactly, low +#: enough that a badly broken one does not stall the tool. +FAILED_SCAN_LIMIT = 100 + def _run(coro: Any) -> Any: """Run *coro* from a synchronous tool handler. @@ -140,13 +145,18 @@ def hookdeck_queue_status(_args: dict) -> str: async def _go() -> str: async with HookdeckAPI() as api: depth = await api.queue_depth() - failed = await api.list_events(status="FAILED", limit=1) - issues = await api.list_issues(status="OPENED", limit=1) + failed = await api.list_events(status="FAILED", limit=FAILED_SCAN_LIMIT) + open_issues = await api.count_issues(status="OPENED") + seen = len(_models(failed)) return json.dumps( { "queue_depth": depth, - "failed_events_page_count": len(_models(failed)), - "open_issues_page_count": len(_models(issues)), + "failed_events": seen, + # Events have no count endpoint, so this is what one page + # holds. Saying when it is capped stops the model reporting a + # ceiling as though it were a total. + "failed_events_is_at_least": seen >= FAILED_SCAN_LIMIT, + "open_issues": open_issues, } ) diff --git a/tests/test_tools.py b/tests/test_tools.py index a484312..4f5f1a9 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -58,6 +58,9 @@ async def list_events(self, **kw): async def list_issues(self, **kw): return await self._record("list_issues", **kw) + async def count_issues(self, **kw): + return await self._record("count_issues", **kw) or 0 + async def get_event_raw_body(self, event_id): return await self._record("get_event_raw_body", event_id) @@ -466,3 +469,49 @@ def test_every_required_parameter_is_a_declared_parameter(): params = schema["function"]["parameters"] missing = set(params["required"]) - set(params["properties"]) assert not missing, f"{name} requires undeclared {missing}" + + +# ---------------------------------------------------------------------- +# Queue status reports numbers, not page sizes +# ---------------------------------------------------------------------- + + +def test_queue_status_reports_the_real_number_of_open_issues(api): + # Counted from the dedicated endpoint. Counting from a listing would count + # to whatever limit was asked for — a project with four open issues used + # to be reported as one, and the model then guessed at what "1" meant. + api.responses["count_issues"] = 4 + api.responses["list_events"] = {"models": []} + status = json.loads(call("hookdeck_queue_status")) + + assert status["open_issues"] == 4 + assert calls_named(api, "count_issues") == [ + ("count_issues", (), {"status": "OPENED"}) + ] + # The old page-size fields are gone, not merely renamed alongside. + assert "open_issues_page_count" not in status + assert "failed_events_page_count" not in status + + +def test_queue_status_counts_failed_events_exactly_when_it_can(api): + api.responses["list_events"] = {"models": [{"id": f"evt_{i}"} for i in range(7)]} + status = json.loads(call("hookdeck_queue_status")) + assert status["failed_events"] == 7 + assert status["failed_events_is_at_least"] is False + + +def test_a_full_page_of_failures_is_flagged_as_a_floor(api): + # Events have no count endpoint, so a full page means "at least this + # many". Reporting it bare would let the model state a ceiling as a total. + api.responses["list_events"] = { + "models": [{"id": f"evt_{i}"} for i in range(tools.FAILED_SCAN_LIMIT)] + } + status = json.loads(call("hookdeck_queue_status")) + assert status["failed_events"] == tools.FAILED_SCAN_LIMIT + assert status["failed_events_is_at_least"] is True + + +def test_queue_status_asks_for_more_than_one_failure(api): + api.responses["list_events"] = {"models": []} + call("hookdeck_queue_status") + assert calls_named(api, "list_events")[0][2]["limit"] == tools.FAILED_SCAN_LIMIT