diff --git a/README.md b/README.md index dbb1716..159f835 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,28 @@ coroutines. The [SDK guide](https://proofline.sh/docs/sdks/) and Licensed under the Apache License, Version 2.0. The generated models under `proofline/_generated` carry the MIT license of the generator templates. +## Pull request history + +`list_pull_request_reviews` returns one page of the runs recorded for a specific +pull request. `iter_pull_request_reviews` follows the continuation cursor over +that history for you, while keeping the same page and item bounds as +`iter_reviews`. Unlike `iter_reviews`, which lists the latest run of each pull +request, it walks every run of one pull request. + +```python +for review in client.iter_pull_request_reviews( + organization_id, "owner/name", 42, page_size=50, + filters={"status": "COMPLETED"}, +): + print(review.id, review.status) +``` + +Use `await` for each item with `AsyncProoflineClient`. Filters, the initial +cursor and server ordering are preserved across pages. `page_size` accepts +1–100, `max_pages` 1–1,000 and `max_items` 1–100,000. A missing, repeated or +non-advancing continuation cursor raises `ProoflineError`; reaching either bound +raises it with the `pagination-limit` type. + ## Contributing This repository distributes the Proofline Python SDK. The public OpenAPI contract generates the client and models in `proofline/_generated/`. Open an issue for changes to the contract or generated code. Pull requests are welcome for the hand-written client in `proofline/client.py`, transport and helpers outside `_generated/`, and tests in `tests/`. diff --git a/proofline/client.py b/proofline/client.py index de6b525..70461d1 100644 --- a/proofline/client.py +++ b/proofline/client.py @@ -162,6 +162,20 @@ def _page_limits(page_size: int, max_pages: int, max_items: int) -> None: ) +def _iteration_params( + filters: ReviewFilters | None, page_size: int +) -> tuple[ReviewFilters, set[str]]: + params: ReviewFilters = {} + if filters: + params.update(filters) + params["limit"] = page_size + seen: set[str] = set() + cursor = params.get("cursor") + if cursor: + seen.add(cursor) + return params, seen + + def _next_cursor(page: ReviewPage, seen: set[str]) -> str | None: if not isinstance(page.truncated, bool): raise _malformed() @@ -469,14 +483,7 @@ def iter_reviews( filters: ReviewFilters | None = None, ) -> Iterator[ReviewSummary]: _page_limits(page_size, max_pages, max_items) - params: ReviewFilters = {} - if filters: - params.update(filters) - params["limit"] = page_size - seen: set[str] = set() - cursor = params.get("cursor") - if cursor: - seen.add(cursor) + params, seen = _iteration_params(filters, page_size) count = 0 for _ in range(max_pages): page = self.list_reviews(organization_id, **params) @@ -491,6 +498,43 @@ def iter_reviews( params["cursor"] = cursor raise _pagination_limit() + def iter_pull_request_reviews( + self, + organization_id: UUID, + repository: str, + pull_request_number: int, + *, + page_size: int = 100, + max_pages: int = 100, + max_items: int = 10_000, + filters: ReviewFilters | None = None, + ) -> Iterator[ReviewSummary]: + """Iterate every recorded run of one pull request. + + Unlike :meth:`iter_reviews`, which lists the latest run of each pull + request, this walks the full history at the pull-request endpoint. Pages + are fetched lazily in server order. A missing, repeated or non-advancing + continuation cursor raises ``ProoflineError``; reaching ``max_pages`` or + ``max_items`` raises the same type with ``pagination-limit``. + """ + _page_limits(page_size, max_pages, max_items) + params, seen = _iteration_params(filters, page_size) + count = 0 + for _ in range(max_pages): + page = self.list_pull_request_reviews( + organization_id, repository, pull_request_number, filters=params + ) + for item in page.items: + if count == max_items: + raise _pagination_limit() + yield item + count += 1 + cursor = _next_cursor(page, seen) + if cursor is None: + return + params["cursor"] = cursor + raise _pagination_limit() + def wait_for_review( self, organization_id: UUID, review_id: UUID, *, timeout: float = 300 ) -> ReviewOutcome: @@ -828,14 +872,7 @@ async def iter_reviews( filters: ReviewFilters | None = None, ) -> AsyncIterator[ReviewSummary]: _page_limits(page_size, max_pages, max_items) - params: ReviewFilters = {} - if filters: - params.update(filters) - params["limit"] = page_size - seen: set[str] = set() - cursor = params.get("cursor") - if cursor: - seen.add(cursor) + params, seen = _iteration_params(filters, page_size) count = 0 for _ in range(max_pages): page = await self.list_reviews(organization_id, **params) @@ -850,6 +887,43 @@ async def iter_reviews( params["cursor"] = cursor raise _pagination_limit() + async def iter_pull_request_reviews( + self, + organization_id: UUID, + repository: str, + pull_request_number: int, + *, + page_size: int = 100, + max_pages: int = 100, + max_items: int = 10_000, + filters: ReviewFilters | None = None, + ) -> AsyncIterator[ReviewSummary]: + """Iterate every recorded run of one pull request. + + Unlike :meth:`iter_reviews`, which lists the latest run of each pull + request, this walks the full history at the pull-request endpoint. Pages + are fetched lazily in server order. A missing, repeated or non-advancing + continuation cursor raises ``ProoflineError``; reaching ``max_pages`` or + ``max_items`` raises the same type with ``pagination-limit``. + """ + _page_limits(page_size, max_pages, max_items) + params, seen = _iteration_params(filters, page_size) + count = 0 + for _ in range(max_pages): + page = await self.list_pull_request_reviews( + organization_id, repository, pull_request_number, filters=params + ) + for item in page.items: + if count == max_items: + raise _pagination_limit() + yield item + count += 1 + cursor = _next_cursor(page, seen) + if cursor is None: + return + params["cursor"] = cursor + raise _pagination_limit() + async def wait_for_review( self, organization_id: UUID, review_id: UUID, *, timeout: float = 300 ) -> ReviewOutcome: diff --git a/scripts/contracts.py b/scripts/contracts.py index e681909..8b4d936 100644 --- a/scripts/contracts.py +++ b/scripts/contracts.py @@ -29,7 +29,13 @@ def main() -> None: "The generated JSON operations must match the twenty-one public JSON token routes." ) tree = ast.parse((ROOT / "proofline/client.py").read_text()) - helpers = {"close", "aclose", "iter_reviews", "wait_for_review"} + helpers = { + "close", + "aclose", + "iter_reviews", + "iter_pull_request_reviews", + "wait_for_review", + } clients = { node.name: { member.name diff --git a/tests/test_http.py b/tests/test_http.py index eba316e..249e5fc 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -95,6 +95,13 @@ async def collect(client: Client, **kwargs: Any) -> list[Any]: return list(iterator) +async def collect_pull_request(client: Client, **kwargs: Any) -> list[Any]: + iterator = client.iter_pull_request_reviews(ORG, "repo/name", 42, **kwargs) + if hasattr(iterator, "__aiter__"): + return [item async for item in iterator] + return list(iterator) + + class Clock: def __init__(self, monkeypatch: pytest.MonkeyPatch) -> None: self.now = 100.0 @@ -1333,6 +1340,85 @@ def respond(request: Request, index: int) -> Reply: assert raised.value.type == "urn:proofline:sdk:pagination-limit" +@MODES +async def test_pull_request_review_iteration_preserves_path_filters_and_bounds(mode: str) -> None: + def respond(request: Request, index: int) -> Reply: + split = urlsplit(request.path) + assert split.path == f"/api/v1/organizations/{ORG}/reviews/pr/repo%2Fname/42" + query = parse_qs(split.query) + assert query["status"] == ["COMPLETED"] + assert query["limit"] == ["2"] + if index == 0: + assert query["cursor"] == ["initial"] + elif index == 1: + assert query["cursor"] == ["opaque+/cursor"] + else: + assert query["cursor"] == ["final"] + return Reply( + body=page( + cursor="opaque+/cursor" if index == 0 else ("final" if index == 1 else None), + truncated=index < 2, + ) + ) + + with Server(respond) as server: + async with session(mode, server.url) as client: + items = await collect_pull_request( + client, + page_size=2, + filters={"status": "COMPLETED", "cursor": "initial"}, + ) + assert [item.id for item in items] == [REVIEW, REVIEW, REVIEW] + assert len(server.requests) == 3 + + +@MODES +async def test_pull_request_review_iteration_refuses_cycles_and_bounds(mode: str) -> None: + for cursor in ["repeated", None]: + with Server( + lambda request, index: Reply(body=page(cursor=cursor, truncated=True)) + ) as server: + async with session(mode, server.url) as client: + with pytest.raises(ProoflineError) as raised: + await collect_pull_request(client) + assert raised.value.type == "urn:proofline:sdk:invalid-pagination" + assert len(server.requests) <= 2 + with Server( + lambda request, index: Reply(body=page(cursor=str(index), truncated=True)) + ) as server: + async with session(mode, server.url) as client: + with pytest.raises(ProoflineError) as raised: + await collect_pull_request(client, max_pages=2) + assert raised.value.type == "urn:proofline:sdk:pagination-limit" + assert len(server.requests) == 2 + two_items = page() + two_items["items"].append(dict(two_items["items"][0])) + with Server(lambda request, index: Reply(body=two_items)) as server: + async with session(mode, server.url) as client: + with pytest.raises(ProoflineError) as raised: + await collect_pull_request(client, max_items=1) + assert raised.value.type == "urn:proofline:sdk:pagination-limit" + with Server(lambda request, index: Reply(body=page())) as server: + async with session(mode, server.url) as client: + with pytest.raises(ValueError): + await collect_pull_request(client, page_size=0) + assert not server.requests + + +@MODES +async def test_pull_request_review_iteration_fetches_pages_lazily(mode: str) -> None: + with Server(lambda request, index: Reply(body=page(cursor="next", truncated=True))) as server: + async with session(mode, server.url) as client: + iterator = client.iter_pull_request_reviews(ORG, "repo/name", 42) + if hasattr(iterator, "__aiter__"): + assert (await anext(iterator)).id == REVIEW + await iterator.aclose() + else: + assert next(iterator).id == REVIEW + iterator.close() + assert len(server.requests) == 1 + + def test_token_environment_and_https_origin_rejection(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("PROOFLINE_TOKEN", TOKEN) with ProoflineClient() as client: