Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ddev/changelog.d/23685.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Restructure `ddev.utils.github_async` into a package with lazy model imports, add `create_pull_request` and `add_labels_to_issue` endpoints, and add a `FakeAsyncGitHubClient` test helper with a `mock_response` API.
3 changes: 3 additions & 0 deletions ddev/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ include = ["src"]
python-version = "3.13"
scripts = ["ddev"]

[tool.pytest.ini_options]
asyncio_mode = "auto"

# Keep Black configuration to generate models through validate
# Switch to Ruff after it provides a Python API
[tool.black]
Expand Down
63 changes: 63 additions & 0 deletions ddev/src/ddev/utils/github_async/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# (C) Datadog, Inc. 2026-present
# All rights reserved
# Licensed under a 3-clause BSD style license (see LICENSE)
"""Async GitHub REST API client.

Typical usage::

from ddev.utils.github_async import async_github_client
from ddev.utils.github_async.models import PullRequest

async with async_github_client(token=my_token) as client:
response = await client.create_pull_request(...)

Both this package's top-level symbols and the ``models`` subpackage use PEP 562
``__getattr__`` to load submodules on demand. Importing a model from
``ddev.utils.github_async.models`` does not load the HTTP client (so ``httpx``
stays out of ``sys.modules`` for callers that only need the types).
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
# Re-exports for type checkers / IDE autocomplete only; do not execute at runtime.
# The `X as X` aliases mark these as explicit re-exports for linters.
from .client import DEFAULT_BASE_URL as DEFAULT_BASE_URL
from .client import GITHUB_API_VERSION as GITHUB_API_VERSION
from .client import AsyncGitHubClient as AsyncGitHubClient
from .client import GitHubResponse as GitHubResponse
from .client import PaginationData as PaginationData
from .client import async_github_client as async_github_client

# Map of exported name -> submodule (relative to this package) that defines it.
MODULE_BY_NAME: dict[str, str] = {
'AsyncGitHubClient': 'client',
'async_github_client': 'client',
'GITHUB_API_VERSION': 'client',
'DEFAULT_BASE_URL': 'client',
'GitHubResponse': 'client',
'PaginationData': 'client',
}


def __getattr__(name: str) -> Any:
try:
module_name = MODULE_BY_NAME[name]
except KeyError:
raise AttributeError(f'module {__name__!r} has no attribute {name!r}') from None

import importlib

module = importlib.import_module(f'.{module_name}', __name__)
value = getattr(module, name)
globals()[name] = value
return value


def __dir__() -> list[str]:
return sorted(set(globals()) | MODULE_BY_NAME.keys())


__all__ = sorted(MODULE_BY_NAME)
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
"""Async GitHub API client for triggering and monitoring GitHub Actions workflows"""
# (C) Datadog, Inc. 2026-present
# All rights reserved
# Licensed under a 3-clause BSD style license (see LICENSE)
"""Async HTTP client for the GitHub REST API."""

from __future__ import annotations

import re
from collections.abc import AsyncIterator
Expand All @@ -9,14 +14,23 @@
import httpx
from pydantic import BaseModel, ConfigDict, Field

from .models import (
ArtifactsList,
IssueComment,
Label,
PullRequest,
PullRequestReviewComment,
WorkflowRun,
)

GITHUB_API_VERSION = "2022-11-28"
DEFAULT_BASE_URL = "https://api.github.com"

_LINK_RE = re.compile(r'<([^>]+)>;\s*rel="([^"]+)"')


# ---------------------------------------------------------------------------
# Pagination
# Pagination + response wrappers
# ---------------------------------------------------------------------------


Expand Down Expand Up @@ -45,11 +59,6 @@ def from_header(cls, header: str | None) -> Self:
)


# ---------------------------------------------------------------------------
# Response and domain models
# ---------------------------------------------------------------------------


class GitHubResponse[T](BaseModel):
"""Generic wrapper for a GitHub API response."""

Expand All @@ -59,75 +68,6 @@ class GitHubResponse[T](BaseModel):
headers: dict[str, str] = Field(default_factory=dict)


class WorkflowRun(BaseModel):
"""A GitHub Actions workflow run."""

model_config = ConfigDict(extra="ignore")

id: int
name: str | None = None
status: str
conclusion: str | None = None
html_url: str | None = None
created_at: str | None = None
updated_at: str | None = None


class Artifact(BaseModel):
"""A GitHub Actions artifact."""

model_config = ConfigDict(extra="ignore")

id: int
name: str
size_in_bytes: int | None = None
url: str | None = None
archive_download_url: str | None = None
expired: bool


class ArtifactsList(BaseModel):
"""A list of artifacts with a total count."""

model_config = ConfigDict(extra="ignore")

total_count: int
artifacts: list[Artifact]


class IssueComment(BaseModel):
"""A GitHub issue (or PR) comment."""

model_config = ConfigDict(extra="ignore")

id: int
body: str
user: dict[str, Any] | None = None
created_at: str | None = None
updated_at: str | None = None
html_url: str | None = None


class PullRequestReviewComment(BaseModel):
"""An inline review comment on a pull request diff."""

model_config = ConfigDict(extra="ignore")

id: int
body: str
path: str
commit_id: str
html_url: str | None = None
created_at: str | None = None
updated_at: str | None = None
user: dict[str, Any] | None = None


# ---------------------------------------------------------------------------
# Client
# ---------------------------------------------------------------------------


class AsyncGitHubClient:
"""
Async HTTP client for the GitHub REST API.
Expand Down Expand Up @@ -335,6 +275,78 @@ async def create_issue_comment(
)
return self._parse_response(response, IssueComment)

async def create_pull_request(
self,
owner: str,
repo: str,
title: str,
head: str,
base: str,
body: str = "",
draft: bool = False,
timeout: float | None = None,
) -> GitHubResponse[PullRequest]:
"""
Calls the GitHub API to create a pull request.

GitHub API Documentation:
https://docs.github.com/en/rest/pulls/pulls#create-a-pull-request

Args:
owner: Repository owner (user or organisation).
repo: Repository name.
title: Pull request title.
head: Name of the branch containing the changes.
base: Name of the branch to merge into.
body: Pull request body.
draft: Whether to open the pull request as a draft.
timeout: Optional timeout for this specific request. Defaults to the client's default_timeout.

Returns:
GitHubResponse[PullRequest]: The validated pull request data and headers.
"""
response = await self._request(
"POST",
f"/repos/{owner}/{repo}/pulls",
timeout=timeout,
json={"title": title, "head": head, "base": base, "body": body, "draft": draft},
)
return self._parse_response(response, PullRequest)

async def add_labels_to_issue(
self,
owner: str,
repo: str,
issue_number: int,
labels: list[str],
timeout: float | None = None,
) -> GitHubResponse[list[Label]]:
"""
Calls the GitHub API to add one or more labels to an issue or pull request.

GitHub API Documentation:
https://docs.github.com/en/rest/issues/labels#add-labels-to-an-issue

Args:
owner: Repository owner (user or organisation).
repo: Repository name.
issue_number: Issue or pull request number.
labels: Labels to add. Existing labels on the issue are preserved.
timeout: Optional timeout for this specific request. Defaults to the client's default_timeout.

Returns:
GitHubResponse[list[Label]]: The full label list resulting from the operation (preserves
any pre-existing labels alongside the newly added ones).
"""
response = await self._request(
"POST",
f"/repos/{owner}/{repo}/issues/{issue_number}/labels",
timeout=timeout,
json={"labels": labels},
)
labels_out = [Label.model_validate(item) for item in response.json()]
return GitHubResponse[list[Label]].model_validate({"data": labels_out, "headers": dict(response.headers)})

async def create_pr_review_comment(
self,
owner: str,
Expand All @@ -358,18 +370,22 @@ async def create_pr_review_comment(
owner: Repository owner (user or organisation).
repo: Repository name.
pull_number: Pull request number.
body: Markdown body text of the review comment.
body: Markdown body text of the comment.
commit_id: SHA of the commit to comment on.
path: Relative path of the file to comment on.
position: Line index in the diff (deprecated but still supported by the API).
line: Line number in the file to comment on (used with `side`).
side: Side of the diff to comment on — ``"LEFT"`` or ``"RIGHT"``.
path: Path of the file to comment on.
position: Line index in the diff (mutually exclusive with line/side).
line: Line number in the file (newer style, paired with side).
side: 'LEFT' or 'RIGHT' (newer style, paired with line).
timeout: Optional timeout for this specific request. Defaults to the client's default_timeout.

Returns:
GitHubResponse[PullRequestReviewComment]: The validated review comment data and headers.
GitHubResponse[PullRequestReviewComment]: The validated comment data and headers.
"""
payload: dict[str, Any] = {"body": body, "commit_id": commit_id, "path": path}
payload: dict[str, Any] = {
"body": body,
"commit_id": commit_id,
"path": path,
}
if position is not None:
payload["position"] = position
if line is not None:
Expand All @@ -386,7 +402,7 @@ async def create_pr_review_comment(


# ---------------------------------------------------------------------------
# Context manager helper
# Async context manager
# ---------------------------------------------------------------------------


Expand Down
72 changes: 72 additions & 0 deletions ddev/src/ddev/utils/github_async/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# (C) Datadog, Inc. 2026-present
# All rights reserved
# Licensed under a 3-clause BSD style license (see LICENSE)
"""Lazy re-exports for the async GitHub client's domain models.

Each model class lives in its own submodule (e.g. ``pull_request.py``). The
re-exports below let callers write::

from ddev.utils.github_async.models import PullRequest

without having to know which submodule the class lives in, and without
eagerly importing every submodule when only one is used.

Mechanism: PEP 562's module-level ``__getattr__`` hook. The first time a
name is accessed, the matching submodule is imported on demand and the
resolved attribute is cached on the package so subsequent accesses are free.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
# Re-exports for type checkers / IDE autocomplete. These imports do not
# execute at runtime (they live behind `TYPE_CHECKING`), so they do not
# break the lazy-loading guarantee. The `X as X` aliases mark these as
# explicit re-exports for linters.
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 .user import GitHubUser as GitHubUser
from .workflow import Artifact as Artifact
from .workflow import ArtifactsList as ArtifactsList
from .workflow import WorkflowRun as WorkflowRun

# Map of exported attribute name -> submodule (relative to this package) that
# defines it. Submodules are imported on demand by `__getattr__`.
MODULE_BY_NAME: dict[str, str] = {
'Artifact': 'workflow',
'ArtifactsList': 'workflow',
'GitHubUser': 'user',
'IssueComment': 'comment',
'Label': 'label',
'PullRequest': 'pull_request',
'PullRequestRef': 'pull_request',
'PullRequestReviewComment': 'comment',
'WorkflowRun': 'workflow',
}


def __getattr__(name: str) -> Any:
try:
module_name = MODULE_BY_NAME[name]
except KeyError:
raise AttributeError(f'module {__name__!r} has no attribute {name!r}') from None

import importlib

module = importlib.import_module(f'.{module_name}', __name__)
value = getattr(module, name)
# Cache so subsequent `from .models import Foo` is a plain dict lookup.
globals()[name] = value
return value


def __dir__() -> list[str]:
return sorted(set(globals()) | MODULE_BY_NAME.keys())


__all__ = sorted(MODULE_BY_NAME)
Loading
Loading