diff --git a/ddev/changelog.d/24316.fixed b/ddev/changelog.d/24316.fixed new file mode 100644 index 0000000000000..f209305eb8b69 --- /dev/null +++ b/ddev/changelog.d/24316.fixed @@ -0,0 +1 @@ +Retype async GitHub client response-model enum fields (PR state, workflow-job and check-run status/conclusion) as StrEnums. diff --git a/ddev/src/ddev/utils/github_async/AGENTS.md b/ddev/src/ddev/utils/github_async/AGENTS.md index 0991764bcfafe..3d839f2fc69d0 100644 --- a/ddev/src/ddev/utils/github_async/AGENTS.md +++ b/ddev/src/ddev/utils/github_async/AGENTS.md @@ -25,12 +25,28 @@ is real and must be checked per field: a `StrEnum` must list all six, not a guessed subset. ```python -from enum import StrEnum +from enum import StrEnum, auto class PullRequestState(StrEnum): - OPEN = "open" - CLOSED = "closed" + OPEN = auto() + CLOSED = auto() +``` + +Prefer `auto()` for the values: on a `StrEnum` it generates each value as the +**lowercased member name**, with underscores preserved (`IN_PROGRESS` becomes +`"in_progress"`). Use it only when every declared enum value is exactly +`member_name.lower()`. When the API's value differs — a different case, hyphens, +or any form that isn't the lowercased name — spell the value out explicitly +instead of forcing `auto()`. For example `pull-request.author_association` +declares UPPERCASE values, so `auto()` would wrongly lowercase them and each must +be written out: + +```python +class AuthorAssociation(StrEnum): + OWNER = "OWNER" + COLLABORATOR = "COLLABORATOR" + # ... ``` A plain `str` field for a declared enum hides the contract and lets invalid diff --git a/ddev/src/ddev/utils/github_async/client.py b/ddev/src/ddev/utils/github_async/client.py index 9575f726af295..f29a6df4fb0aa 100644 --- a/ddev/src/ddev/utils/github_async/client.py +++ b/ddev/src/ddev/utils/github_async/client.py @@ -20,6 +20,7 @@ from .models import ( ArtifactsList, CheckRun, + CheckRunConclusion, IssueComment, Label, PullRequest, @@ -548,7 +549,7 @@ async def update_check_run( repo: str, check_run_id: int, status: Literal["queued", "in_progress", "completed"] | None = None, - conclusion: str | None = None, + conclusion: CheckRunConclusion | None = None, details_url: str | None = None, output: dict[str, Any] | None = None, timeout: float | None = None, diff --git a/ddev/src/ddev/utils/github_async/models/__init__.py b/ddev/src/ddev/utils/github_async/models/__init__.py index 719050c723ecd..d7e2faa387137 100644 --- a/ddev/src/ddev/utils/github_async/models/__init__.py +++ b/ddev/src/ddev/utils/github_async/models/__init__.py @@ -26,18 +26,24 @@ # break the lazy-loading guarantee. The `X as X` aliases mark these as # explicit re-exports for linters. from .check_run import CheckRun as CheckRun + from .check_run import CheckRunConclusion as CheckRunConclusion + from .check_run import CheckRunStatus as CheckRunStatus from .comment import IssueComment as IssueComment from .comment import PullRequestReviewComment as PullRequestReviewComment from .label import Label as Label from .pull_request import PullRequest as PullRequest from .pull_request import PullRequestRef as PullRequestRef + from .pull_request import PullRequestState as PullRequestState from .user import GitHubUser as GitHubUser from .workflow import Artifact as Artifact from .workflow import ArtifactsList as ArtifactsList from .workflow import JobStep as JobStep + from .workflow import JobStepStatus as JobStepStatus from .workflow import WorkflowDispatchResult as WorkflowDispatchResult from .workflow import WorkflowJob as WorkflowJob + from .workflow import WorkflowJobConclusion as WorkflowJobConclusion from .workflow import WorkflowJobsList as WorkflowJobsList + from .workflow import WorkflowJobStatus as WorkflowJobStatus from .workflow import WorkflowRun as WorkflowRun # Map of exported attribute name -> submodule (relative to this package) that @@ -46,15 +52,21 @@ 'Artifact': 'workflow', 'ArtifactsList': 'workflow', 'CheckRun': 'check_run', + 'CheckRunConclusion': 'check_run', + 'CheckRunStatus': 'check_run', 'GitHubUser': 'user', 'IssueComment': 'comment', 'JobStep': 'workflow', + 'JobStepStatus': 'workflow', 'Label': 'label', 'PullRequest': 'pull_request', 'PullRequestRef': 'pull_request', 'PullRequestReviewComment': 'comment', + 'PullRequestState': 'pull_request', 'WorkflowDispatchResult': 'workflow', 'WorkflowJob': 'workflow', + 'WorkflowJobConclusion': 'workflow', + 'WorkflowJobStatus': 'workflow', 'WorkflowJobsList': 'workflow', 'WorkflowRun': 'workflow', } diff --git a/ddev/src/ddev/utils/github_async/models/check_run.py b/ddev/src/ddev/utils/github_async/models/check_run.py index 597040c73bb0a..364c5b8894241 100644 --- a/ddev/src/ddev/utils/github_async/models/check_run.py +++ b/ddev/src/ddev/utils/github_async/models/check_run.py @@ -5,17 +5,63 @@ from __future__ import annotations +from enum import StrEnum, auto + from pydantic import BaseModel, ConfigDict +class CheckRunStatus(StrEnum): + """The status of a check run. + + The `check-run` schema declares `status` as + `enum: [queued, in_progress, completed, waiting, requested, pending]`. + Reference: + https://docs.github.com/en/rest/checks/runs#get-a-check-run + """ + + QUEUED = auto() + IN_PROGRESS = auto() + COMPLETED = auto() + WAITING = auto() + REQUESTED = auto() + PENDING = auto() + + +class CheckRunConclusion(StrEnum): + """The conclusion of a check run. + + The `check-run` response schema declares `conclusion` as a nullable + `enum: [success, failure, neutral, cancelled, skipped, timed_out, action_required]`. + `stale` is added on top of those: it is a valid conclusion that only GitHub can + set (present in the update-a-check-run request enum and the docs, though the + response schema omits it), so a real response can carry it and the model must + accept it. + Reference: + https://docs.github.com/en/rest/checks/runs#update-a-check-run + """ + + SUCCESS = auto() + FAILURE = auto() + NEUTRAL = auto() + CANCELLED = auto() + SKIPPED = auto() + TIMED_OUT = auto() + ACTION_REQUIRED = auto() + STALE = auto() + + class CheckRun(BaseModel): - """A GitHub Checks API check run.""" + """A GitHub Checks API check run. + + Field reference: + https://docs.github.com/en/rest/checks/runs#get-a-check-run + """ model_config = ConfigDict(extra="ignore") id: int name: str - status: str - conclusion: str | None + status: CheckRunStatus + conclusion: CheckRunConclusion | None html_url: str | None head_sha: str diff --git a/ddev/src/ddev/utils/github_async/models/pull_request.py b/ddev/src/ddev/utils/github_async/models/pull_request.py index 6e358b5fd5cf2..fef96d2fc5f94 100644 --- a/ddev/src/ddev/utils/github_async/models/pull_request.py +++ b/ddev/src/ddev/utils/github_async/models/pull_request.py @@ -5,12 +5,26 @@ from __future__ import annotations +from enum import StrEnum, auto + from pydantic import BaseModel, ConfigDict, Field from .label import Label from .user import GitHubUser +class PullRequestState(StrEnum): + """The state of a pull request. + + The `pull-request` schema declares `state` as `enum: [open, closed]`. + Reference: + https://docs.github.com/en/rest/pulls/pulls#get-a-pull-request + """ + + OPEN = auto() + CLOSED = auto() + + class PullRequestRef(BaseModel): """A head or base branch reference on a pull request. @@ -50,7 +64,7 @@ class PullRequest(BaseModel): patch_url: str | None = None # State - state: str | None = None # 'open' or 'closed' + state: PullRequestState | None = None draft: bool = False merged: bool | None = None locked: bool = False diff --git a/ddev/src/ddev/utils/github_async/models/workflow.py b/ddev/src/ddev/utils/github_async/models/workflow.py index 49af424bf6dbf..986e802c857a2 100644 --- a/ddev/src/ddev/utils/github_async/models/workflow.py +++ b/ddev/src/ddev/utils/github_async/models/workflow.py @@ -5,11 +5,71 @@ from __future__ import annotations +from enum import StrEnum, auto + from pydantic import BaseModel, ConfigDict, Field +class WorkflowJobStatus(StrEnum): + """The status of a workflow job. + + The `job` schema declares `status` as + `enum: [queued, in_progress, completed, waiting, requested, pending]`. + Reference: + https://docs.github.com/en/rest/actions/workflow-jobs#get-a-job-for-a-workflow-run + """ + + QUEUED = auto() + IN_PROGRESS = auto() + COMPLETED = auto() + WAITING = auto() + REQUESTED = auto() + PENDING = auto() + + +class WorkflowJobConclusion(StrEnum): + """The conclusion of a workflow job. + + The `job` schema declares `conclusion` as a nullable + `enum: [success, failure, neutral, cancelled, skipped, timed_out, action_required]`. + Reference: + https://docs.github.com/en/rest/actions/workflow-jobs#get-a-job-for-a-workflow-run + """ + + SUCCESS = auto() + FAILURE = auto() + NEUTRAL = auto() + CANCELLED = auto() + SKIPPED = auto() + TIMED_OUT = auto() + ACTION_REQUIRED = auto() + + +class JobStepStatus(StrEnum): + """The status of a single step within a workflow job. + + The `job` schema's `steps` items declare `status` as + `enum: [queued, in_progress, completed]` (a narrower set than the job's own + status). Their `conclusion` is a nullable string with no `enum`, so it stays + a plain `str`. + Reference: + https://docs.github.com/en/rest/actions/workflow-jobs#get-a-job-for-a-workflow-run + """ + + QUEUED = auto() + IN_PROGRESS = auto() + COMPLETED = auto() + + class WorkflowRun(BaseModel): - """A GitHub Actions workflow run.""" + """A GitHub Actions workflow run. + + The `workflow-run` schema declares `status` and `conclusion` as plain + nullable strings with no `enum`, so they are intentionally kept as free-form + strings rather than modeled as a StrEnum. + Reference: + https://docs.github.com/en/rest/actions/workflow-runs#get-a-workflow-run + """ model_config = ConfigDict(extra="ignore") @@ -21,6 +81,11 @@ class WorkflowRun(BaseModel): created_at: str | None = None updated_at: str | None = None + @property + def is_completed(self) -> bool: + """Whether the run has finished (``status == "completed"``).""" + return self.status == "completed" + class WorkflowDispatchResult(BaseModel): """Run metadata returned by `POST /actions/workflows/{id}/dispatches` when `return_run_details=True`.""" @@ -55,26 +120,34 @@ class ArtifactsList(BaseModel): class JobStep(BaseModel): - """A single step within a GitHub Actions job.""" + """A single step within a GitHub Actions job. + + Field reference: + https://docs.github.com/en/rest/actions/workflow-jobs#get-a-job-for-a-workflow-run + """ model_config = ConfigDict(extra="ignore") name: str - status: str + status: JobStepStatus conclusion: str | None = None number: int | None = None class WorkflowJob(BaseModel): - """A single job within a GitHub Actions workflow run.""" + """A single job within a GitHub Actions workflow run. + + Field reference: + https://docs.github.com/en/rest/actions/workflow-jobs#get-a-job-for-a-workflow-run + """ model_config = ConfigDict(extra="ignore") id: int run_id: int name: str - status: str - conclusion: str | None = None + status: WorkflowJobStatus + conclusion: WorkflowJobConclusion | None = None html_url: str | None = None steps: list[JobStep] = Field(default_factory=list) diff --git a/ddev/tests/utils/test_github_async.py b/ddev/tests/utils/test_github_async.py index d8a3cc1c8dde8..8748b28a6103a 100644 --- a/ddev/tests/utils/test_github_async.py +++ b/ddev/tests/utils/test_github_async.py @@ -10,6 +10,7 @@ import httpx import pytest +from pydantic import ValidationError from ddev.utils.github_async import ( GITHUB_API_VERSION, @@ -20,15 +21,23 @@ ) from ddev.utils.github_async.models import ( ArtifactsList, + CheckRun, + CheckRunConclusion, + CheckRunStatus, GitHubUser, IssueComment, + JobStep, + JobStepStatus, Label, PullRequest, PullRequestRef, PullRequestReviewComment, + PullRequestState, WorkflowDispatchResult, WorkflowJob, + WorkflowJobConclusion, WorkflowJobsList, + WorkflowJobStatus, WorkflowRun, ) @@ -39,73 +48,117 @@ TOKEN = "ghp_test_token" -def _json_response(data: Any, status_code: int = 200, headers: dict[str, str] | None = None) -> httpx.Response: +def json_response(data: Any, status_code: int = 200, headers: dict[str, str] | None = None) -> httpx.Response: all_headers = {"content-type": "application/json"} if headers: all_headers.update(headers) return httpx.Response(status_code, json=data, headers=all_headers) -def _make_client(handler: httpx.MockTransport | None = None) -> AsyncGitHubClient: +def make_client(handler: httpx.MockTransport | None = None) -> AsyncGitHubClient: transport = handler or httpx.MockTransport(lambda r: httpx.Response(200)) return AsyncGitHubClient(token=TOKEN, transport=transport) -def _artifact(idx: int) -> dict[str, Any]: +def artifact(idx: int, expired: bool = False, **extra: Any) -> dict[str, Any]: return { "id": idx, "name": f"artifact-{idx}", "size_in_bytes": 100 * idx, "url": f"https://api.github.com/artifact/{idx}", "archive_download_url": f"https://api.github.com/artifact/{idx}/zip", - "expired": False, + "expired": expired, + **extra, } -def _workflow_run_payload() -> dict[str, Any]: +def workflow_run_payload( + id: int = 42, + name: str = "CI", + status: str = "completed", + conclusion: str | None = "success", + html_url: str = "https://github.com/owner/repo/actions/runs/42", + created_at: str = "2024-01-01T00:00:00Z", + updated_at: str = "2024-01-01T01:00:00Z", + **extra: Any, +) -> dict[str, Any]: return { - "id": 42, - "name": "CI", - "status": "completed", - "conclusion": "success", - "html_url": "https://github.com/owner/repo/actions/runs/42", - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-01T01:00:00Z", + "id": id, + "name": name, + "status": status, + "conclusion": conclusion, + "html_url": html_url, + "created_at": created_at, + "updated_at": updated_at, + **extra, } -def _workflow_job(idx: int, *, status: str = "completed", conclusion: str | None = "success") -> dict[str, Any]: +def workflow_job( + idx: int = 1, + run_id: int = 42, + name: str | None = None, + status: str = "completed", + conclusion: str | None = "success", + html_url: str | None = None, + steps: list[dict[str, Any]] | None = None, + **extra: Any, +) -> dict[str, Any]: return { "id": idx, - "run_id": 42, - "name": f"job-{idx}", + "run_id": run_id, + "name": name if name is not None else f"job-{idx}", "status": status, "conclusion": conclusion, - "html_url": f"https://github.com/owner/repo/actions/runs/42/job/{idx}", + "html_url": html_url if html_url is not None else f"https://github.com/owner/repo/actions/runs/42/job/{idx}", + "steps": steps + if steps is not None + else [{"name": "Run tests", "status": "completed", "conclusion": "success", "number": 1}], + **extra, } -def _issue_comment_payload() -> dict[str, Any]: +def issue_comment_payload( + id: int = 1, + body: str = "Hello world", + user: dict[str, Any] | None = None, + created_at: str = "2024-01-01T00:00:00Z", + updated_at: str = "2024-01-01T00:00:00Z", + html_url: str = "https://github.com/owner/repo/issues/1#issuecomment-1", + **extra: Any, +) -> dict[str, Any]: return { - "id": 1, - "body": "Hello world", - "user": {"login": "octocat"}, - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-01T00:00:00Z", - "html_url": "https://github.com/owner/repo/issues/1#issuecomment-1", + "id": id, + "body": body, + "user": user if user is not None else {"login": "octocat"}, + "created_at": created_at, + "updated_at": updated_at, + "html_url": html_url, + **extra, } -def _pr_review_comment_payload() -> dict[str, Any]: +def pr_review_comment_payload( + id: int = 10, + body: str = "Nice change", + path: str = "src/foo.py", + commit_id: str = "abc123", + html_url: str = "https://github.com/owner/repo/pull/1#discussion_r10", + created_at: str = "2024-01-01T00:00:00Z", + updated_at: str = "2024-01-01T00:00:00Z", + user: dict[str, Any] | None = None, + **extra: Any, +) -> dict[str, Any]: return { - "id": 10, - "body": "Nice change", - "path": "src/foo.py", - "commit_id": "abc123", - "html_url": "https://github.com/owner/repo/pull/1#discussion_r10", - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-01T00:00:00Z", - "user": {"login": "reviewer"}, + "id": id, + "body": body, + "path": path, + "commit_id": commit_id, + "html_url": html_url, + "created_at": created_at, + "updated_at": updated_at, + "user": user if user is not None else {"login": "reviewer"}, + **extra, } @@ -113,7 +166,7 @@ def _pr_review_comment_payload() -> dict[str, Any]: # PaginationData # --------------------------------------------------------------------------- -_BASE = "https://api.github.com" +BASE = "https://api.github.com" @pytest.mark.parametrize( @@ -121,22 +174,22 @@ def _pr_review_comment_payload() -> dict[str, Any]: [ (None, PaginationData()), ("", PaginationData()), - (f'<{_BASE}/page2>; rel="next"', PaginationData(next=f"{_BASE}/page2")), - (f'<{_BASE}/page10>; rel="last"', PaginationData(last=f"{_BASE}/page10")), + (f'<{BASE}/page2>; rel="next"', PaginationData(next=f"{BASE}/page2")), + (f'<{BASE}/page10>; rel="last"', PaginationData(last=f"{BASE}/page10")), ( - f'<{_BASE}/page2>; rel="next", <{_BASE}/page5>; rel="last"', - PaginationData(next=f"{_BASE}/page2", last=f"{_BASE}/page5"), + f'<{BASE}/page2>; rel="next", <{BASE}/page5>; rel="last"', + PaginationData(next=f"{BASE}/page2", last=f"{BASE}/page5"), ), ( - f'<{_BASE}/page1>; rel="first", <{_BASE}/page1>; rel="prev",' - f' <{_BASE}/page3>; rel="next", <{_BASE}/page5>; rel="last"', - PaginationData(first=f"{_BASE}/page1", prev=f"{_BASE}/page1", next=f"{_BASE}/page3", last=f"{_BASE}/page5"), + f'<{BASE}/page1>; rel="first", <{BASE}/page1>; rel="prev",' + f' <{BASE}/page3>; rel="next", <{BASE}/page5>; rel="last"', + PaginationData(first=f"{BASE}/page1", prev=f"{BASE}/page1", next=f"{BASE}/page3", last=f"{BASE}/page5"), ), ( - f'<{_BASE}/page1>; rel="prev", <{_BASE}/page5>; rel="last"', - PaginationData(prev=f"{_BASE}/page1", last=f"{_BASE}/page5"), + f'<{BASE}/page1>; rel="prev", <{BASE}/page5>; rel="last"', + PaginationData(prev=f"{BASE}/page1", last=f"{BASE}/page5"), ), - (f'<{_BASE}/page1>; rel="first"', PaginationData(first=f"{_BASE}/page1")), + (f'<{BASE}/page1>; rel="first"', PaginationData(first=f"{BASE}/page1")), ], ids=["none", "blank", "next_only", "last_only", "next_and_last", "all_links", "prev_and_last", "first_only"], ) @@ -168,9 +221,9 @@ async def test_client_request_headers() -> None: def handler(request: httpx.Request) -> httpx.Response: nonlocal captured captured = request.headers - return _json_response(_workflow_run_payload()) + return json_response(workflow_run_payload()) - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) await client.get_workflow_run("o", "r", 42) assert captured is not None @@ -209,7 +262,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert body["ref"] == "main" assert body["return_run_details"] is True assert "inputs" not in body - return _json_response( + return json_response( { "workflow_run_id": 999, "run_url": "https://api.github.com/repos/owner/repo/actions/runs/999", @@ -218,7 +271,7 @@ def handler(request: httpx.Request) -> httpx.Response: headers={"x-ratelimit-remaining": "59"}, ) - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) result = await client.create_workflow_dispatch("owner", "repo", "my-workflow.yml", "main", return_run_details=True) assert isinstance(result, GitHubResponse) assert isinstance(result.data, WorkflowDispatchResult) @@ -231,7 +284,7 @@ def handler(request: httpx.Request) -> httpx.Response: body = json.loads(request.content) assert body["inputs"] == {"env": "prod"} assert body["return_run_details"] is True - return _json_response( + return json_response( { "workflow_run_id": 1, "run_url": "https://api.github.com/repos/o/r/actions/runs/1", @@ -239,7 +292,7 @@ def handler(request: httpx.Request) -> httpx.Response: } ) - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) result = await client.create_workflow_dispatch( "o", "r", 123, "main", inputs={"env": "prod"}, return_run_details=True ) @@ -247,7 +300,7 @@ def handler(request: httpx.Request) -> httpx.Response: async def test_create_workflow_dispatch_http_error_raises() -> None: - client = _make_client(httpx.MockTransport(lambda r: httpx.Response(422))) + client = make_client(httpx.MockTransport(lambda r: httpx.Response(422))) with pytest.raises(httpx.HTTPStatusError) as exc_info: await client.create_workflow_dispatch("o", "r", "wf.yml", "main") assert exc_info.value.response.status_code == 422 @@ -263,9 +316,9 @@ async def test_create_workflow_dispatch_return_run_details_parses_response() -> def handler(request: httpx.Request) -> httpx.Response: body = json.loads(request.content) assert body["return_run_details"] is True - return _json_response(payload) + return json_response(payload) - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) result = await client.create_workflow_dispatch("o", "r", "wf.yml", "main", return_run_details=True) assert result.data.workflow_run_id == 987 assert result.data.html_url == "https://github.com/o/r/actions/runs/987" @@ -277,7 +330,7 @@ def handler(request: httpx.Request) -> httpx.Response: assert "return_run_details" not in body return httpx.Response(204) - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) await client.create_workflow_dispatch("o", "r", "wf.yml", "main") @@ -286,30 +339,36 @@ def handler(request: httpx.Request) -> httpx.Response: # --------------------------------------------------------------------------- -async def test_get_workflow_run_success() -> None: +@pytest.mark.parametrize( + ("status", "is_completed"), + [("completed", True), ("in_progress", False), ("queued", False)], + ids=["completed", "in_progress", "queued"], +) +async def test_get_workflow_run_success(status: str, is_completed: bool) -> None: def handler(request: httpx.Request) -> httpx.Response: assert request.method == "GET" assert "/actions/runs/42" in request.url.path - return _json_response(_workflow_run_payload()) + return json_response(workflow_run_payload(status=status)) - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) result = await client.get_workflow_run("owner", "repo", 42) assert isinstance(result.data, WorkflowRun) assert result.data.id == 42 - assert result.data.status == "completed" + assert result.data.status == status + assert result.data.is_completed is is_completed async def test_get_workflow_run_headers_forwarded() -> None: def handler(_: httpx.Request) -> httpx.Response: - return _json_response(_workflow_run_payload(), headers={"x-ratelimit-remaining": "50"}) + return json_response(workflow_run_payload(), headers={"x-ratelimit-remaining": "50"}) - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) result = await client.get_workflow_run("o", "r", 42) assert result.headers.get("x-ratelimit-remaining") == "50" async def test_get_workflow_run_http_error_raises() -> None: - client = _make_client(httpx.MockTransport(lambda r: httpx.Response(404))) + client = make_client(httpx.MockTransport(lambda r: httpx.Response(404))) with pytest.raises(httpx.HTTPStatusError) as exc_info: await client.get_workflow_run("o", "r", 99) assert exc_info.value.response.status_code == 404 @@ -321,12 +380,12 @@ async def test_get_workflow_run_http_error_raises() -> None: async def test_list_workflow_run_artifacts_single_page() -> None: - artifacts = [_artifact(1), _artifact(2)] + artifacts = [artifact(1), artifact(2)] def handler(_: httpx.Request) -> httpx.Response: - return _json_response({"total_count": 2, "artifacts": artifacts}) + return json_response({"total_count": 2, "artifacts": artifacts}) - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) pages = [] async for page in client.list_workflow_run_artifacts("owner", "repo", 1): pages.append(page) @@ -338,8 +397,8 @@ def handler(_: httpx.Request) -> httpx.Response: async def test_list_workflow_run_artifacts_two_pages() -> None: - page1_artifacts = [_artifact(1)] - page2_artifacts = [_artifact(2)] + page1_artifacts = [artifact(1)] + page2_artifacts = [artifact(2)] call_count = 0 def handler(request: httpx.Request) -> httpx.Response: @@ -347,13 +406,13 @@ def handler(request: httpx.Request) -> httpx.Response: call_count += 1 if call_count == 1: link = f'<{request.url.scheme}://{request.url.host}/page2>; rel="next"' - return _json_response( + return json_response( {"total_count": 2, "artifacts": page1_artifacts}, headers={"link": link}, ) - return _json_response({"total_count": 2, "artifacts": page2_artifacts}) + return json_response({"total_count": 2, "artifacts": page2_artifacts}) - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) pages = [] async for page in client.list_workflow_run_artifacts("owner", "repo", 1): pages.append(page) @@ -365,9 +424,9 @@ def handler(request: httpx.Request) -> httpx.Response: async def test_list_workflow_run_artifacts_pagination_stops_when_no_next() -> None: def handler(_: httpx.Request) -> httpx.Response: - return _json_response({"total_count": 1, "artifacts": [_artifact(1)]}) + return json_response({"total_count": 1, "artifacts": [artifact(1)]}) - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) count = 0 async for _ in client.list_workflow_run_artifacts("owner", "repo", 1): count += 1 @@ -375,7 +434,7 @@ def handler(_: httpx.Request) -> httpx.Response: async def test_list_workflow_run_artifacts_http_error_raises() -> None: - client = _make_client(httpx.MockTransport(lambda r: httpx.Response(403))) + client = make_client(httpx.MockTransport(lambda r: httpx.Response(403))) with pytest.raises(httpx.HTTPStatusError) as exc_info: async for _ in client.list_workflow_run_artifacts("o", "r", 1): pass @@ -385,9 +444,9 @@ async def test_list_workflow_run_artifacts_http_error_raises() -> None: async def test_list_workflow_run_artifacts_per_page_forwarded() -> None: def handler(request: httpx.Request) -> httpx.Response: assert request.url.params["per_page"] == "100" - return _json_response({"total_count": 0, "artifacts": []}) + return json_response({"total_count": 0, "artifacts": []}) - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) async for _ in client.list_workflow_run_artifacts("owner", "repo", 1, per_page=100): pass @@ -398,14 +457,14 @@ def handler(request: httpx.Request) -> httpx.Response: async def test_list_workflow_jobs_single_page() -> None: - jobs = [_workflow_job(1), _workflow_job(2, status="in_progress", conclusion=None)] + jobs = [workflow_job(1), workflow_job(2, status="in_progress", conclusion=None)] def handler(request: httpx.Request) -> httpx.Response: assert request.method == "GET" assert "/actions/runs/42/jobs" in request.url.path - return _json_response({"total_count": 2, "jobs": jobs}) + return json_response({"total_count": 2, "jobs": jobs}) - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) pages = [] async for page in client.list_workflow_jobs("owner", "repo", 42): pages.append(page) @@ -413,8 +472,39 @@ def handler(request: httpx.Request) -> httpx.Response: assert len(pages) == 1 assert isinstance(pages[0].data, WorkflowJobsList) assert pages[0].data.total_count == 2 - assert isinstance(pages[0].data.jobs[0], WorkflowJob) - assert pages[0].data.jobs[1].html_url == "https://github.com/owner/repo/actions/runs/42/job/2" + + first, second = pages[0].data.jobs + assert isinstance(first, WorkflowJob) + assert first.status is WorkflowJobStatus.COMPLETED + assert first.conclusion is WorkflowJobConclusion.SUCCESS + assert second.status is WorkflowJobStatus.IN_PROGRESS + assert second.conclusion is None + assert second.html_url == "https://github.com/owner/repo/actions/runs/42/job/2" + + # A step's status is its own StrEnum; its conclusion has no declared enum (stays str). + step = first.steps[0] + assert isinstance(step, JobStep) + assert step.status is JobStepStatus.COMPLETED + + +@pytest.mark.parametrize("status", list(WorkflowJobStatus), ids=[s.value for s in WorkflowJobStatus]) +async def test_list_workflow_jobs_parses_every_status(status: WorkflowJobStatus) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return json_response({"total_count": 1, "jobs": [workflow_job(1, status=status.value, conclusion=None)]}) + + client = make_client(httpx.MockTransport(handler)) + pages = [page async for page in client.list_workflow_jobs("owner", "repo", 42)] + assert pages[0].data.jobs[0].status is status + + +async def test_list_workflow_jobs_unexpected_status_raises() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return json_response({"total_count": 1, "jobs": [workflow_job(1, status="bogus")]}) + + client = make_client(httpx.MockTransport(handler)) + with pytest.raises(ValidationError): + async for _ in client.list_workflow_jobs("owner", "repo", 42): + pass async def test_list_workflow_jobs_two_pages() -> None: @@ -425,10 +515,10 @@ def handler(request: httpx.Request) -> httpx.Response: call_count += 1 if call_count == 1: link = f'<{request.url.scheme}://{request.url.host}/page2>; rel="next"' - return _json_response({"total_count": 2, "jobs": [_workflow_job(1)]}, headers={"link": link}) - return _json_response({"total_count": 2, "jobs": [_workflow_job(2)]}) + return json_response({"total_count": 2, "jobs": [workflow_job(1)]}, headers={"link": link}) + return json_response({"total_count": 2, "jobs": [workflow_job(2)]}) - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) pages = [] async for page in client.list_workflow_jobs("owner", "repo", 42): pages.append(page) @@ -441,9 +531,9 @@ def handler(request: httpx.Request) -> httpx.Response: async def test_list_workflow_jobs_per_page_forwarded() -> None: def handler(request: httpx.Request) -> httpx.Response: assert request.url.params["per_page"] == "100" - return _json_response({"total_count": 0, "jobs": []}) + return json_response({"total_count": 0, "jobs": []}) - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) async for _ in client.list_workflow_jobs("owner", "repo", 42, per_page=100): pass @@ -459,9 +549,9 @@ def handler(request: httpx.Request) -> httpx.Response: assert "/issues/7/comments" in request.url.path body = json.loads(request.content) assert body["body"] == "LGTM" - return _json_response({**_issue_comment_payload(), "body": "LGTM"}, status_code=201) + return json_response(issue_comment_payload(body="LGTM"), status_code=201) - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) result = await client.create_issue_comment("owner", "repo", 7, "LGTM") assert isinstance(result.data, IssueComment) assert result.data.body == "LGTM" @@ -470,7 +560,7 @@ def handler(request: httpx.Request) -> httpx.Response: async def test_create_issue_comment_http_error_raises() -> None: - client = _make_client(httpx.MockTransport(lambda r: httpx.Response(404))) + client = make_client(httpx.MockTransport(lambda r: httpx.Response(404))) with pytest.raises(httpx.HTTPStatusError) as exc_info: await client.create_issue_comment("o", "r", 1, "hi") assert exc_info.value.response.status_code == 404 @@ -489,9 +579,9 @@ def handler(request: httpx.Request) -> httpx.Response: assert body["path"] == "src/foo.py" assert body["position"] == 5 assert "line" not in body - return _json_response(_pr_review_comment_payload(), status_code=201) + return json_response(pr_review_comment_payload(), status_code=201) - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) result = await client.create_pr_review_comment( "owner", "repo", 3, "Nice change", "abc123", "src/foo.py", position=5 ) @@ -507,15 +597,15 @@ def handler(request: httpx.Request) -> httpx.Response: assert body["line"] == 10 assert body["side"] == "RIGHT" assert "position" not in body - return _json_response(_pr_review_comment_payload(), status_code=201) + return json_response(pr_review_comment_payload(), status_code=201) - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) result = await client.create_pr_review_comment("o", "r", 1, "comment", "abc123", "file.py", line=10, side="RIGHT") assert isinstance(result.data, PullRequestReviewComment) async def test_create_pr_review_comment_http_error_raises() -> None: - client = _make_client(httpx.MockTransport(lambda r: httpx.Response(422))) + client = make_client(httpx.MockTransport(lambda r: httpx.Response(422))) with pytest.raises(httpx.HTTPStatusError) as exc_info: await client.create_pr_review_comment("o", "r", 1, "body", "sha", "path") assert exc_info.value.response.status_code == 422 @@ -526,58 +616,74 @@ async def test_create_pr_review_comment_http_error_raises() -> None: # --------------------------------------------------------------------------- -def _pull_request_payload(number: int = 1) -> dict[str, Any]: +def pull_request_payload(number: int = 1, html_url: str | None = None, **extra: Any) -> dict[str, Any]: return { "number": number, - "html_url": f"https://github.com/owner/repo/pull/{number}", + "html_url": html_url if html_url is not None else f"https://github.com/owner/repo/pull/{number}", + **extra, } -def _full_pull_request_payload(number: int = 42) -> dict[str, Any]: +def full_pull_request_payload( + number: int = 42, + state: str = "open", + draft: bool = True, + merged: bool = False, + locked: bool = False, + title: str = "Fix bug", + body: str = "Backport", + node_id: str = "PR_kwDOABCD123", + merge_commit_sha: str | None = None, + created_at: str = "2026-05-01T00:00:00Z", + updated_at: str = "2026-05-02T00:00:00Z", + closed_at: str | None = None, + merged_at: str | None = None, + user: dict[str, Any] | None = None, + assignees: list[dict[str, Any]] | None = None, + requested_reviewers: list[dict[str, Any]] | None = None, + labels: list[dict[str, Any]] | None = None, + head: dict[str, Any] | None = None, + base: dict[str, Any] | None = None, + **extra: Any, +) -> dict[str, Any]: """A richer PR payload exercising sub-models (user, labels, head/base).""" return { "id": 9000 + number, "number": number, - "node_id": "PR_kwDOABCD123", + "node_id": node_id, "url": f"https://api.github.com/repos/owner/repo/pulls/{number}", "html_url": f"https://github.com/owner/repo/pull/{number}", "diff_url": f"https://github.com/owner/repo/pull/{number}.diff", "patch_url": f"https://github.com/owner/repo/pull/{number}.patch", - "state": "open", - "draft": True, - "merged": False, - "locked": False, - "merge_commit_sha": None, - "title": "Fix bug", - "body": "Backport", - "user": { - "id": 1, - "login": "octocat", - "html_url": "https://github.com/octocat", - "type": "User", - }, - "assignees": [], - "requested_reviewers": [ - {"id": 2, "login": "reviewer", "type": "User"}, - ], - "labels": [ + "state": state, + "draft": draft, + "merged": merged, + "locked": locked, + "merge_commit_sha": merge_commit_sha, + "title": title, + "body": body, + "user": user + if user is not None + else {"id": 1, "login": "octocat", "html_url": "https://github.com/octocat", "type": "User"}, + "assignees": assignees if assignees is not None else [], + "requested_reviewers": requested_reviewers + if requested_reviewers is not None + else [{"id": 2, "login": "reviewer", "type": "User"}], + "labels": labels + if labels is not None + else [ {"id": 100, "name": "qa/skip-qa", "color": "5319e7"}, {"id": 101, "name": "backport/7.62.x", "color": "5319e7"}, ], - "created_at": "2026-05-01T00:00:00Z", - "updated_at": "2026-05-02T00:00:00Z", - "closed_at": None, - "merged_at": None, - "head": { - "ref": "alice/fix", - "sha": "1234567890abcdef00", - "label": "alice:alice/fix", - }, - "base": { - "ref": "master", - "sha": "cafebabe00", - "label": "owner:master", - }, + "created_at": created_at, + "updated_at": updated_at, + "closed_at": closed_at, + "merged_at": merged_at, + "head": head + if head is not None + else {"ref": "alice/fix", "sha": "1234567890abcdef00", "label": "alice:alice/fix"}, + "base": base if base is not None else {"ref": "master", "sha": "cafebabe00", "label": "owner:master"}, + **extra, } @@ -593,9 +699,9 @@ def handler(request: httpx.Request) -> httpx.Response: "body": "Fix description", "draft": False, } - return _json_response(_pull_request_payload(number=42), status_code=201) + return json_response(pull_request_payload(number=42), status_code=201) - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) result = await client.create_pull_request("owner", "repo", "Fix bug", "alice/fix", "master", "Fix description") assert isinstance(result.data, PullRequest) assert result.data.number == 42 @@ -606,15 +712,15 @@ async def test_create_pull_request_draft_true_forwarded() -> None: def handler(request: httpx.Request) -> httpx.Response: body = json.loads(request.content) assert body["draft"] is True - return _json_response(_pull_request_payload(number=7), status_code=201) + return json_response(pull_request_payload(number=7), status_code=201) - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) result = await client.create_pull_request("o", "r", "T", "h", "b", draft=True) assert result.data.number == 7 async def test_create_pull_request_http_error_raises() -> None: - client = _make_client(httpx.MockTransport(lambda r: httpx.Response(422))) + client = make_client(httpx.MockTransport(lambda r: httpx.Response(422))) with pytest.raises(httpx.HTTPStatusError) as exc_info: await client.create_pull_request("o", "r", "T", "h", "b") assert exc_info.value.response.status_code == 422 @@ -624,15 +730,15 @@ async def test_create_pull_request_parses_full_response() -> None: """Exercises sub-models (GitHubUser, Label, PullRequestRef) end-to-end.""" def handler(request: httpx.Request) -> httpx.Response: - return _json_response(_full_pull_request_payload(number=42), status_code=201) + return json_response(full_pull_request_payload(number=42), status_code=201) - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) result = await client.create_pull_request("owner", "repo", "Fix bug", "alice/fix", "master") pr = result.data assert pr.id == 9042 assert pr.number == 42 - assert pr.state == "open" + assert pr.state is PullRequestState.OPEN assert pr.draft is True assert pr.title == "Fix bug" @@ -656,17 +762,60 @@ async def test_create_pull_request_ignores_extra_fields() -> None: """Unknown top-level fields in the response must not break parsing.""" def handler(request: httpx.Request) -> httpx.Response: - payload = _full_pull_request_payload() - payload["mergeable_state"] = "clean" - payload["additions"] = 42 - payload["unknown_future_field"] = {"nested": True} - return _json_response(payload, status_code=201) + payload = full_pull_request_payload( + mergeable_state="clean", additions=42, unknown_future_field={"nested": True} + ) + return json_response(payload, status_code=201) - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) result = await client.create_pull_request("o", "r", "T", "h", "b") assert result.data.number == 42 +# --------------------------------------------------------------------------- +# get_pull_request +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("state", list(PullRequestState), ids=[s.value for s in PullRequestState]) +async def test_get_pull_request_success(state: PullRequestState) -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "GET" + assert "/repos/owner/repo/pulls/5" in request.url.path + return json_response(full_pull_request_payload(number=5, state=state.value)) + + client = make_client(httpx.MockTransport(handler)) + result = await client.get_pull_request("owner", "repo", 5) + assert isinstance(result.data, PullRequest) + assert result.data.number == 5 + assert result.data.state is state + + +async def test_get_pull_request_headers_forwarded() -> None: + def handler(_: httpx.Request) -> httpx.Response: + return json_response(full_pull_request_payload(number=5), headers={"x-ratelimit-remaining": "50"}) + + client = make_client(httpx.MockTransport(handler)) + result = await client.get_pull_request("o", "r", 5) + assert result.headers.get("x-ratelimit-remaining") == "50" + + +async def test_get_pull_request_http_error_raises() -> None: + client = make_client(httpx.MockTransport(lambda r: httpx.Response(404))) + with pytest.raises(httpx.HTTPStatusError) as exc_info: + await client.get_pull_request("o", "r", 5) + assert exc_info.value.response.status_code == 404 + + +async def test_get_pull_request_unexpected_state_raises() -> None: + def handler(_: httpx.Request) -> httpx.Response: + return json_response(full_pull_request_payload(number=5, state="merged")) + + client = make_client(httpx.MockTransport(handler)) + with pytest.raises(ValidationError): + await client.get_pull_request("o", "r", 5) + + # --------------------------------------------------------------------------- # add_labels_to_issue # --------------------------------------------------------------------------- @@ -678,16 +827,16 @@ def handler(request: httpx.Request) -> httpx.Response: assert "/repos/owner/repo/issues/3/labels" in request.url.path body = json.loads(request.content) assert body == {"labels": ["qa/skip-qa", "backport/7.62.x"]} - return _json_response([{"id": 1, "name": "qa/skip-qa"}, {"id": 2, "name": "backport/7.62.x"}], status_code=200) + return json_response([{"id": 1, "name": "qa/skip-qa"}, {"id": 2, "name": "backport/7.62.x"}], status_code=200) - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) result = await client.add_labels_to_issue("owner", "repo", 3, ["qa/skip-qa", "backport/7.62.x"]) assert [label.name for label in result.data] == ["qa/skip-qa", "backport/7.62.x"] assert all(isinstance(label, Label) for label in result.data) async def test_add_labels_to_issue_http_error_raises() -> None: - client = _make_client(httpx.MockTransport(lambda r: httpx.Response(404))) + client = make_client(httpx.MockTransport(lambda r: httpx.Response(404))) with pytest.raises(httpx.HTTPStatusError) as exc_info: await client.add_labels_to_issue("o", "r", 1, ["bug"]) assert exc_info.value.response.status_code == 404 @@ -747,7 +896,7 @@ async def test_per_request_timeout_forwarded() -> None: """Ensure per-request timeout reaches the transport without raising.""" def handler(request: httpx.Request) -> httpx.Response: - return _json_response(_workflow_run_payload()) + return json_response(workflow_run_payload()) client = AsyncGitHubClient(token=TOKEN, default_timeout=5.0, transport=httpx.MockTransport(handler)) result = await client.get_workflow_run("o", "r", 42, timeout=2.0) @@ -759,17 +908,24 @@ def handler(request: httpx.Request) -> httpx.Response: # --------------------------------------------------------------------------- -def _check_run_payload(**overrides: Any) -> dict[str, Any]: - payload = { - "id": 1, - "name": "ck", - "status": "in_progress", - "head_sha": "abc", - "conclusion": None, - "html_url": "https://github.com/o/r/check-runs/1", +def check_run_payload( + id: int = 1, + name: str = "ck", + status: str = "in_progress", + head_sha: str = "abc", + conclusion: str | None = None, + html_url: str = "https://github.com/o/r/check-runs/1", + **extra: Any, +) -> dict[str, Any]: + return { + "id": id, + "name": name, + "status": status, + "head_sha": head_sha, + "conclusion": conclusion, + "html_url": html_url, + **extra, } - payload.update(overrides) - return payload @pytest.mark.asyncio @@ -780,11 +936,13 @@ def handler(request: httpx.Request) -> httpx.Response: assert request.method == "POST" assert "/repos/o/r/check-runs" in request.url.path captured.update(json.loads(request.content)) - return _json_response(_check_run_payload(name=captured["name"], head_sha=captured["head_sha"])) + return json_response(check_run_payload(name=captured["name"], head_sha=captured["head_sha"])) - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) result = await client.create_check_run("o", "r", name="ck", head_sha="abc", status="in_progress") + assert isinstance(result.data, CheckRun) assert result.data.id == 1 + assert result.data.status is CheckRunStatus.IN_PROGRESS assert captured == {"name": "ck", "head_sha": "abc", "status": "in_progress"} @@ -794,9 +952,9 @@ async def test_create_check_run_with_optional_fields() -> None: def handler(request: httpx.Request) -> httpx.Response: captured.update(json.loads(request.content)) - return _json_response(_check_run_payload()) + return json_response(check_run_payload()) - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) await client.create_check_run( "o", "r", @@ -811,18 +969,42 @@ def handler(request: httpx.Request) -> httpx.Response: @pytest.mark.asyncio -async def test_update_check_run_success() -> None: - captured: dict[str, Any] = {} +async def test_create_check_run_http_error_raises() -> None: + client = make_client(httpx.MockTransport(lambda r: httpx.Response(422))) + with pytest.raises(httpx.HTTPStatusError) as exc_info: + await client.create_check_run("o", "r", name="ck", head_sha="abc", status="in_progress") + assert exc_info.value.response.status_code == 422 + +# One case per non-completed status (conclusion is null until completed), plus every +# conclusion (incl. the GitHub-only ``stale``) paired with ``completed``. +CHECK_RUN_RESULT_CASES = [ + *[ + pytest.param(status, None, id=status.value) + for status in CheckRunStatus + if status is not CheckRunStatus.COMPLETED + ], + *[ + pytest.param(CheckRunStatus.COMPLETED, conclusion, id=f"completed-{conclusion.value}") + for conclusion in CheckRunConclusion + ], +] + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("status", "conclusion"), CHECK_RUN_RESULT_CASES) +async def test_update_check_run_success(status: CheckRunStatus, conclusion: CheckRunConclusion | None) -> None: def handler(request: httpx.Request) -> httpx.Response: assert request.method == "PATCH" assert "/repos/o/r/check-runs/77" in request.url.path - captured.update(json.loads(request.content)) - return _json_response(_check_run_payload(id=77, status="completed", conclusion="success")) + return json_response( + check_run_payload(id=77, status=status.value, conclusion=conclusion.value if conclusion else None) + ) - client = _make_client(httpx.MockTransport(handler)) - await client.update_check_run("o", "r", 77, status="completed", conclusion="success") - assert captured == {"status": "completed", "conclusion": "success"} + client = make_client(httpx.MockTransport(handler)) + result = await client.update_check_run("o", "r", 77, status="in_progress") + assert result.data.status is status + assert result.data.conclusion is conclusion @pytest.mark.asyncio @@ -831,9 +1013,9 @@ async def test_update_check_run_omits_unset_fields() -> None: def handler(request: httpx.Request) -> httpx.Response: captured.update(json.loads(request.content)) - return _json_response(_check_run_payload(id=77)) + return json_response(check_run_payload(id=77)) - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) await client.update_check_run("o", "r", 77, conclusion="failure") assert captured == {"conclusion": "failure"} @@ -845,20 +1027,38 @@ async def test_update_check_run_requires_conclusion_when_completed() -> None: def handler(request: httpx.Request) -> httpx.Response: nonlocal requested requested = True - return _json_response(_check_run_payload()) + return json_response(check_run_payload()) - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) with pytest.raises(ValueError, match="conclusion is required"): await client.update_check_run("o", "r", 77, status="completed") assert requested is False +@pytest.mark.asyncio +async def test_update_check_run_http_error_raises() -> None: + client = make_client(httpx.MockTransport(lambda r: httpx.Response(404))) + with pytest.raises(httpx.HTTPStatusError) as exc_info: + await client.update_check_run("o", "r", 77, status="in_progress") + assert exc_info.value.response.status_code == 404 + + +@pytest.mark.asyncio +async def test_update_check_run_unexpected_status_raises() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return json_response(check_run_payload(id=77, status="bogus")) + + client = make_client(httpx.MockTransport(handler)) + with pytest.raises(ValidationError): + await client.update_check_run("o", "r", 77, status="in_progress") + + # --------------------------------------------------------------------------- # download_artifact # --------------------------------------------------------------------------- -def _make_zip(members: dict[str, bytes]) -> bytes: +def make_zip(members: dict[str, bytes]) -> bytes: buf = io.BytesIO() with zipfile.ZipFile(buf, "w") as zf: for name, content in members.items(): @@ -876,7 +1076,7 @@ def github_handler(request: httpx.Request) -> httpx.Response: def signed_handler(request: httpx.Request) -> httpx.Response: captured_signed_headers.update({k.lower(): v for k, v in request.headers.items()}) - return httpx.Response(200, content=_make_zip({"hello.txt": b"hi"})) + return httpx.Response(200, content=make_zip({"hello.txt": b"hi"})) real_async_client = httpx.AsyncClient @@ -899,7 +1099,7 @@ async def test_download_artifact_non_302_raises(tmp_path) -> None: def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, content=b"not a redirect") - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) with pytest.raises(httpx.HTTPError, match="Expected 302"): await client.download_artifact("/repos/o/r/actions/artifacts/1/zip", tmp_path / "out") @@ -931,7 +1131,7 @@ async def test_download_artifact_missing_location_header_raises(tmp_path) -> Non def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(302) - client = _make_client(httpx.MockTransport(handler)) + client = make_client(httpx.MockTransport(handler)) with pytest.raises(httpx.HTTPError, match="Missing Location"): await client.download_artifact("/repos/o/r/actions/artifacts/1/zip", tmp_path / "out") @@ -949,7 +1149,7 @@ def github_handler(request: httpx.Request) -> httpx.Response: return httpx.Response(302, headers={"location": "https://signed.example/zip"}) def signed_handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, content=_make_zip({malicious_member: b"pwn"})) + return httpx.Response(200, content=make_zip({malicious_member: b"pwn"})) real_async_client = httpx.AsyncClient @@ -969,7 +1169,7 @@ def fake_async_client(*args: Any, **kwargs: Any) -> httpx.AsyncClient: assert list(dest.rglob("*")) == [] -def _patch_signed_download(monkeypatch, handler: Any) -> None: +def patch_signed_download(monkeypatch, handler: Any) -> None: """Route the anonymous signed-URL download through a mock transport.""" real_async_client = httpx.AsyncClient @@ -991,7 +1191,7 @@ def github_handler(request: httpx.Request) -> httpx.Response: def signed_handler(request: httpx.Request) -> httpx.Response: return httpx.Response(503, content=b"unavailable") - _patch_signed_download(monkeypatch, signed_handler) + patch_signed_download(monkeypatch, signed_handler) client = AsyncGitHubClient(token=TOKEN, transport=httpx.MockTransport(github_handler)) with pytest.raises(httpx.HTTPStatusError): await client.download_artifact("/repos/o/r/actions/artifacts/1/zip", tmp_path / "out") diff --git a/kube_apiserver_metrics/metadata.csv b/kube_apiserver_metrics/metadata.csv index 379d491bdda75..f24f853c5a381 100644 --- a/kube_apiserver_metrics/metadata.csv +++ b/kube_apiserver_metrics/metadata.csv @@ -1,5 +1,5 @@ metric_name,metric_type,interval,unit_name,per_unit_name,description,orientation,integration,short_name,curated_metric,sample_tags -kube_apiserver.APIServiceRegistrationController_depth,gauge,,,,The current depth of workqueue: APIServiceRegistrationController,0,kubernetes_api_server_metrics,service registration controller depth,, +kube_apiserver.APIServiceRegistrationController_depth,gauge,,,,The current depth of workqueue: APIServiceRegistrationController (deprecated in Kubernetes 1.26),0,kubernetes_api_server_metrics,service registration controller depth,, kube_apiserver.admission_controller_admission_duration_seconds.count,count,,,,The admission controller latency histogram in seconds identified by name and broken out for each operation and API resource and type (validate or admit) count,0,kubernetes_api_server_metrics,duration of admission controller admission,, kube_apiserver.admission_controller_admission_duration_seconds.sum,gauge,,second,,The admission controller latency histogram in seconds identified by name and broken out for each operation and API resource and type (validate or admit),0,kubernetes_api_server_metrics,duration of admission controller admission,, kube_apiserver.admission_step_admission_latencies_seconds.count,count,,,,The admission sub-step latency histogram broken out for each operation and API resource and step type (validate or admit) count,0,kubernetes_api_server_metrics,admission step admission latencies,, @@ -14,8 +14,8 @@ kube_apiserver.apiserver_admission_webhook_fail_open_count,gauge,,,,"Admission w kube_apiserver.apiserver_admission_webhook_fail_open_count.count,count,,,,"Admission webhook fail open count, identified by name and broken out for each admission type (validating or mutating).",0,kubernetes_api_server_metrics,webhook fail open count,, kube_apiserver.apiserver_admission_webhook_request_total,gauge,,,,"Admission webhook request total, identified by name and broken out for each admission type (alpha; Kubernetes 1.23+)",0,kubernetes_api_server_metrics,webhook request total,, kube_apiserver.apiserver_admission_webhook_request_total.count,count,,,,"Admission webhook request total, identified by name and broken out for each admission type (alpha; Kubernetes 1.23+)",0,kubernetes_api_server_metrics,webhook request total,, -kube_apiserver.apiserver_dropped_requests_total,gauge,,request,,The accumulated number of requests dropped with 'Try again later' response,1,kubernetes_api_server_metrics,accumulated number of dropped requests,, -kube_apiserver.apiserver_dropped_requests_total.count,count,,request,,The monotonic count of requests dropped with 'Try again later' response,0,kubernetes_api_server_metrics,count of dropped requests,, +kube_apiserver.apiserver_dropped_requests_total,gauge,,request,,The accumulated number of requests dropped with 'Try again later' response(deprecated in Kubernetes 1.29),1,kubernetes_api_server_metrics,accumulated number of dropped requests,, +kube_apiserver.apiserver_dropped_requests_total.count,count,,request,,The monotonic count of requests dropped with 'Try again later' response (deprecated in Kubernetes 1.29 replaced by kube_apiserver.flowcontrol_rejected_requests_total.count),0,kubernetes_api_server_metrics,count of dropped requests,, kube_apiserver.apiserver_request_count,gauge,,request,,The accumulated number of apiserver requests broken out for each verb API resource client and HTTP response contentType and code (deprecated in Kubernetes 1.15),1,kubernetes_api_server_metrics,accumulated number of requests,, kube_apiserver.apiserver_request_count.count,count,,request,,The monotonic count of apiserver requests broken out for each verb API resource client and HTTP response contentType and code (deprecated in Kubernetes 1.15),0,kubernetes_api_server_metrics,count of requests,, kube_apiserver.apiserver_request_terminations_total.count,count,,request,,The number of requests the apiserver terminated in self-defense (Kubernetes 1.17+),0,kubernetes_api_server_metrics,count of requests dropped in self-defense,, @@ -31,7 +31,7 @@ kube_apiserver.authentication_duration_seconds.sum,gauge,,second,,The authentica kube_apiserver.current_inflight_requests,gauge,,,,The maximal number of currently used inflight request limit of this apiserver per request kind in last second.,0,kubernetes_api_server_metrics,current inflight requests,, kube_apiserver.envelope_encryption_dek_cache_fill_percent,gauge,,,,Percent of the cache slots currently occupied by cached DEKs.,0,kubernetes_api_server_metrics,envelope encryption dek cache fillpercent,, kube_apiserver.etcd.db.total_size,gauge,,byte,,The total size of the etcd database file physically allocated in bytes (alpha; Kubernetes 1.19+),0,kubernetes_api_server_metrics,etcd db total size,, -kube_apiserver.etcd_object_counts,gauge,,object,,The number of stored objects at the time of last check split by kind (alpha; deprecated in Kubernetes 1.22),0,kubernetes_api_server_metrics,etcd object counts,, +kube_apiserver.etcd_object_counts,gauge,,object,,The number of stored objects at the time of last check split by kind (alpha; deprecated in Kubernetes 1.22 replaced by apiserver_storage_objects),0,kubernetes_api_server_metrics,etcd object counts,, kube_apiserver.etcd_request_duration_seconds.count,count,,,,Etcd request latencies count for each operation and object type (alpha),0,kubernetes_api_server_metrics,etcd latency,, kube_apiserver.etcd_request_duration_seconds.sum,gauge,,second,,Etcd request latencies for each operation and object type (alpha),0,kubernetes_api_server_metrics,etcd latency,, kube_apiserver.etcd_request_errors_total,count,,request,,Etcd failed request counts for each operation and object type,0,kubernetes_api_server_metrics,etcd request errors total,, @@ -42,7 +42,7 @@ kube_apiserver.flowcontrol_current_inqueue_requests,count,,,,Number of requests kube_apiserver.flowcontrol_dispatched_requests_total,count,,,,Number of requests executed by API Priority and Fairness subsystem,0,kubernetes_api_server_metrics, flowcontrol dispatched requests total,, kube_apiserver.flowcontrol_nominal_limit_seats,gauge,,,,Nominal limit on the number of execution seats available to requests in the API Priority and Fairness subsystem,0,kubernetes_api_server_metrics,flowcontrol nominal limit seats,, kube_apiserver.flowcontrol_rejected_requests_total.count,count,,,,Number of requests rejected by API Priority and Fairness subsystem,0,kubernetes_api_server_metrics, flowcontrol rejected requests total,, -kube_apiserver.flowcontrol_request_concurrency_limit,gauge,,,,Shared concurrency limit in the API Priority and Fairness subsystem,0,kubernetes_api_server_metrics, flowcontrol request concurrency limit,, +kube_apiserver.flowcontrol_request_concurrency_limit,gauge,,,,Shared concurrency limit in the API Priority and Fairness subsystem (deprecated in Kubernetes 1.26 replaced by kube_apiserver.nominal_limit_seats),0,kubernetes_api_server_metrics, flowcontrol request concurrency limit,, kube_apiserver.flowcontrol_request_wait_duration_seconds.count,count,,,,The request wait duration histogram count in the API Priority and Fairness subsystem,0,kubernetes_api_server_metrics,flowcontrol request wait duration,, kube_apiserver.flowcontrol_request_wait_duration_seconds.sum,gauge,,second,,The request wait duration histogram sum in the API Priority and Fairness subsystem,0,kubernetes_api_server_metrics,flowcontrol request wait duration,, kube_apiserver.go_goroutines,gauge,,,,The number of goroutines that currently exist,0,kubernetes_api_server_metrics,go routines,, @@ -61,7 +61,7 @@ kube_apiserver.process_virtual_memory_bytes,gauge,,byte,,The virtual memory size kube_apiserver.registered_watchers,gauge,,object,,The number of currently registered watchers for a given resource,0,kubernetes_api_server_metrics,number of watchers,, kube_apiserver.request_duration_seconds.count,count,,,,"The response latency distribution in seconds for each verb, dry run value, group, version, resource, subresource, scope, and component count",0,kubernetes_api_server_metrics,duration of request,, kube_apiserver.request_duration_seconds.sum,gauge,,second,,"The response latency distribution in seconds for each verb, dry run value, group, version, resource, subresource, scope, and component",0,kubernetes_api_server_metrics,duration of request,, -kube_apiserver.request_latencies.count,count,,,,"The response latency distribution in microseconds for each verb, resource, and subresource count",0,kubernetes_api_server_metrics,response latency,, +kube_apiserver.request_latencies.count,count,,,,"The response latency distribution in microseconds for each verb, resource, and subresource count (deprecated in Kubernetes 1.14, replaced by apiserver_request_duration_seconds)",0,kubernetes_api_server_metrics,response latency,, kube_apiserver.request_latencies.sum,gauge,,microsecond,,"The response latency distribution in microseconds for each verb, resource and subresource",0,kubernetes_api_server_metrics,response latency,, kube_apiserver.requested_deprecated_apis,gauge,,request,,"Gauge of deprecated APIs that have been requested, broken out by API group, version, resource, subresource, and removed_release",0,kubernetes_api_server_metrics,deprecated API requests,, kube_apiserver.rest_client_request_latency_seconds.count,count,,,,The request latency in seconds broken down by verb and URL count,0,kubernetes_api_server_metrics,rest client request latency,, @@ -69,7 +69,7 @@ kube_apiserver.rest_client_request_latency_seconds.sum,gauge,,second,,The reques kube_apiserver.rest_client_requests_total,gauge,,request,,The accumulated number of HTTP requests partitioned by status code method and host,1,kubernetes_api_server_metrics,accumulated rest client requests,, kube_apiserver.rest_client_requests_total.count,count,,request,,The monotonic count of HTTP requests partitioned by status code method and host,0,kubernetes_api_server_metrics,total count of rest client requests,, kube_apiserver.slis.kubernetes_healthcheck,gauge,,,,Result of a single kubernetes apiserver healthcheck (alpha; requires k8s v1.26+),0,kubernetes_api_server_metrics,slis.kubernetes_healthcheck,, -kube_apiserver.slis.kubernetes_healthcheck_total,count,,,,The monotonic count of all kubernetes apiserver healthchecks (alpha; requires k8s v1.26+),0,kubernetes_api_server_metrics,slis.kubernetes_healthcheck_total,, +kube_apiserver.slis.kubernetes_healthchecks_total,count,,,,The monotonic count of all kubernetes apiserver healthchecks (alpha; requires k8s v1.26+),0,kubernetes_api_server_metrics,slis.kubernetes_healthcheck_total,, kube_apiserver.storage_list_evaluated_objects_total,gauge,,object,,The number of objects tested in the course of serving a LIST request from storage (alpha; Kubernetes 1.23+),0,kubernetes_api_server_metrics,storage list evaluated objects total,, kube_apiserver.storage_list_fetched_objects_total,gauge,,object,,The number of objects read from storage in the course of serving a LIST request (alpha; Kubernetes 1.23+),0,kubernetes_api_server_metrics,storage list fetched objects total,, kube_apiserver.storage_list_returned_objects_total,gauge,,object,,The number of objects returned for a LIST request from storage (alpha; Kubernetes 1.23+),0,kubernetes_api_server_metrics,storage list returned objects total,,