From df2fdcd2906894dd0293370782b93ccb313ea71a Mon Sep 17 00:00:00 2001 From: jayhack Date: Sun, 16 Feb 2025 00:35:22 -0800 Subject: [PATCH] . --- src/codegen/extensions/events/app.py | 3 + src/codegen/extensions/events/github.py | 123 ++++++++++++++++++ src/codegen/extensions/events/github_types.py | 62 +++++++++ src/codegen/extensions/github/__init__.py | 0 .../extensions/github/types/__init__.py | 0 src/codegen/extensions/github/types/author.py | 7 + src/codegen/extensions/github/types/base.py | 68 ++++++++++ src/codegen/extensions/github/types/commit.py | 17 +++ .../extensions/github/types/enterprise.py | 14 ++ .../github/types/events/pull_request.py | 31 +++++ .../extensions/github/types/events/push.py | 27 ++++ .../extensions/github/types/installation.py | 6 + src/codegen/extensions/github/types/label.py | 11 ++ .../extensions/github/types/organization.py | 16 +++ .../extensions/github/types/pull_request.py | 71 ++++++++++ src/codegen/extensions/github/types/push.py | 29 +++++ src/codegen/extensions/github/types/pusher.py | 6 + 17 files changed, 491 insertions(+) create mode 100644 src/codegen/extensions/events/github.py create mode 100644 src/codegen/extensions/events/github_types.py create mode 100644 src/codegen/extensions/github/__init__.py create mode 100644 src/codegen/extensions/github/types/__init__.py create mode 100644 src/codegen/extensions/github/types/author.py create mode 100644 src/codegen/extensions/github/types/base.py create mode 100644 src/codegen/extensions/github/types/commit.py create mode 100644 src/codegen/extensions/github/types/enterprise.py create mode 100644 src/codegen/extensions/github/types/events/pull_request.py create mode 100644 src/codegen/extensions/github/types/events/push.py create mode 100644 src/codegen/extensions/github/types/installation.py create mode 100644 src/codegen/extensions/github/types/label.py create mode 100644 src/codegen/extensions/github/types/organization.py create mode 100644 src/codegen/extensions/github/types/pull_request.py create mode 100644 src/codegen/extensions/github/types/push.py create mode 100644 src/codegen/extensions/github/types/pusher.py diff --git a/src/codegen/extensions/events/app.py b/src/codegen/extensions/events/app.py index 3e004e366..ca889d727 100644 --- a/src/codegen/extensions/events/app.py +++ b/src/codegen/extensions/events/app.py @@ -2,6 +2,7 @@ import modal # deptry: ignore +from codegen.extensions.events.github import GitHub from codegen.extensions.events.linear import Linear from codegen.extensions.events.slack import Slack @@ -11,6 +12,7 @@ class CodegenApp(modal.App): linear: Linear slack: Slack + github: GitHub def __init__(self, name: str, modal_api_key: str, image: modal.Image): self._modal_api_key = modal_api_key @@ -22,3 +24,4 @@ def __init__(self, name: str, modal_api_key: str, image: modal.Image): # Expose attributes that provide event decorators for different providers. self.linear = Linear(self) self.slack = Slack(self) + self.github = GitHub(self) diff --git a/src/codegen/extensions/events/github.py b/src/codegen/extensions/events/github.py new file mode 100644 index 000000000..0c899ebb7 --- /dev/null +++ b/src/codegen/extensions/events/github.py @@ -0,0 +1,123 @@ +import logging +from typing import Any, Callable, TypeVar + +from fastapi import Request +from pydantic import BaseModel + +from codegen.extensions.events.interface import EventHandlerManagerProtocol +from codegen.extensions.github.types.base import GitHubInstallation, GitHubWebhookPayload + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + + +# Type variable for event types +T = TypeVar("T", bound=BaseModel) + + +class GitHub(EventHandlerManagerProtocol): + def __init__(self, app): + self.app = app + self.registered_handlers = {} + + # TODO - add in client info + # @property + # def client(self) -> Github: + # if not self._client: + # self._client = Github(os.environ["GITHUB_TOKEN"]) + # return self._client + + def unsubscribe_all_handlers(self): + logger.info("[HANDLERS] Clearing all handlers") + self.registered_handlers.clear() + + def event(self, event_name: str): + """Decorator for registering a GitHub event handler. + + Example: + @app.github.event('push') + def handle_push(event: PushEvent): # Can be typed with Pydantic model + logger.info(f"Received push to {event.ref}") + + @app.github.event('pull_request:opened') + def handle_pr(event: dict): # Or just use dict for raw event + logger.info(f"Received PR") + """ + logger.info(f"[EVENT] Registering handler for {event_name}") + + def register_handler(func: Callable[[T], Any]): + # Get the type annotation from the first parameter + event_type = func.__annotations__.get("event") + func_name = func.__qualname__ + logger.info(f"[EVENT] Registering function {func_name} for {event_name}") + + def new_func(raw_event: dict): + # Only validate if a Pydantic model was specified + if event_type and issubclass(event_type, BaseModel): + try: + parsed_event = event_type.model_validate(raw_event) + return func(parsed_event) + except Exception as e: + logger.exception(f"Error parsing event: {e}") + raise + else: + # Pass through raw dict if no type validation needed + return func(raw_event) + + self.registered_handlers[event_name] = new_func + return new_func + + return register_handler + + def handle(self, event: dict, request: Request): + """Handle both webhook events and installation callbacks.""" + logger.info("[HANDLER] Handling GitHub event") + + # Check if this is an installation event + if "installation_id" in event and "code" in event: + installation = GitHubInstallation.model_validate(event) + logger.info("=====[GITHUB APP INSTALLATION]=====") + logger.info(f"Code: {installation.code}") + logger.info(f"Installation ID: {installation.installation_id}") + logger.info(f"Setup Action: {installation.setup_action}") + return { + "message": "GitHub app installation details received", + "details": { + "code": installation.code, + "installation_id": installation.installation_id, + "setup_action": installation.setup_action, + }, + } + + # Extract headers for webhook events + headers = { + "x-github-event": request.headers.get("x-github-event"), + "x-github-delivery": request.headers.get("x-github-delivery"), + "x-github-hook-id": request.headers.get("x-github-hook-id"), + "x-github-hook-installation-target-id": request.headers.get("x-github-hook-installation-target-id"), + "x-github-hook-installation-target-type": request.headers.get("x-github-hook-installation-target-type"), + } + print(headers) + + # Handle webhook events + try: + webhook = GitHubWebhookPayload.model_validate({"headers": headers, "event": event}) + + # Get base event type and action + event_type = webhook.headers.event_type + action = webhook.event.action + + # Combine event type and action if both exist + full_event_type = f"{event_type}:{action}" if action else event_type + + if full_event_type not in self.registered_handlers: + logger.info(f"[HANDLER] No handler found for event type: {full_event_type}") + return {"message": "Event type not handled"} + + else: + logger.info(f"[HANDLER] Handling event: {full_event_type}") + handler = self.registered_handlers[full_event_type] + return handler(event) # TODO - pass through typed values + except Exception as e: + logger.exception(f"Error handling webhook: {e}") + raise diff --git a/src/codegen/extensions/events/github_types.py b/src/codegen/extensions/events/github_types.py new file mode 100644 index 000000000..fd3f62536 --- /dev/null +++ b/src/codegen/extensions/events/github_types.py @@ -0,0 +1,62 @@ +from datetime import datetime +from typing import Optional + + +class GitHubRepository: + id: int + node_id: str + name: str + full_name: str + private: bool + + +class GitHubAccount: + login: str + id: int + node_id: str + avatar_url: str + type: str + site_admin: bool + # Other URL fields omitted for brevity + user_view_type: str + + +class GitHubInstallation: + id: int + client_id: str + account: GitHubAccount + repository_selection: str + access_tokens_url: str + repositories_url: str + html_url: str + app_id: int + app_slug: str + target_id: int + target_type: str + permissions: dict[str, str] # e.g. {'actions': 'write', 'checks': 'read', ...} + events: list[str] + created_at: datetime + updated_at: datetime + single_file_name: Optional[str] + has_multiple_single_files: bool + single_file_paths: list[str] + suspended_by: Optional[str] + suspended_at: Optional[datetime] + + +class GitHubUser: + login: str + id: int + node_id: str + avatar_url: str + type: str + site_admin: bool + # Other URL fields omitted for brevity + + +class GitHubInstallationEvent: + action: str + installation: GitHubInstallation + repositories: list[GitHubRepository] + requester: Optional[dict] + sender: GitHubUser diff --git a/src/codegen/extensions/github/__init__.py b/src/codegen/extensions/github/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/codegen/extensions/github/types/__init__.py b/src/codegen/extensions/github/types/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/codegen/extensions/github/types/author.py b/src/codegen/extensions/github/types/author.py new file mode 100644 index 000000000..2ecdd2e8a --- /dev/null +++ b/src/codegen/extensions/github/types/author.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + + +class GitHubAuthor(BaseModel): + name: str + email: str + username: str diff --git a/src/codegen/extensions/github/types/base.py b/src/codegen/extensions/github/types/base.py new file mode 100644 index 000000000..8c6bef223 --- /dev/null +++ b/src/codegen/extensions/github/types/base.py @@ -0,0 +1,68 @@ +from pydantic import BaseModel, Field + + +class GitHubUser(BaseModel): + login: str + id: int + node_id: str + type: str + + +class GitHubRepository(BaseModel): + id: int + node_id: str + name: str + full_name: str + private: bool + owner: GitHubUser + + +class GitHubIssue(BaseModel): + id: int + node_id: str + number: int + title: str + body: str | None + user: GitHubUser + state: str + comments: int + + +class GitHubPullRequest(BaseModel): + id: int + node_id: str + number: int + title: str + body: str | None + user: GitHubUser + state: str + head: dict + base: dict + merged: bool | None = None + + +class GitHubEvent(BaseModel): + action: str | None = None + issue: GitHubIssue | None = None + pull_request: GitHubPullRequest | None = None + repository: GitHubRepository + sender: GitHubUser + + +class GitHubWebhookHeaders(BaseModel): + event_type: str = Field(..., alias="x-github-event") + delivery_id: str = Field(..., alias="x-github-delivery") + hook_id: str = Field(..., alias="x-github-hook-id") + installation_target_id: str = Field(..., alias="x-github-hook-installation-target-id") + installation_target_type: str = Field(..., alias="x-github-hook-installation-target-type") + + +class GitHubWebhookPayload(BaseModel): + headers: GitHubWebhookHeaders + event: GitHubEvent + + +class GitHubInstallation(BaseModel): + code: str + installation_id: str + setup_action: str = "install" diff --git a/src/codegen/extensions/github/types/commit.py b/src/codegen/extensions/github/types/commit.py new file mode 100644 index 000000000..9fa27052f --- /dev/null +++ b/src/codegen/extensions/github/types/commit.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel + +from .author import GitHubAuthor + + +class GitHubCommit(BaseModel): + id: str + tree_id: str + distinct: bool + message: str + timestamp: str + url: str + author: GitHubAuthor + committer: GitHubAuthor + added: list[str] + removed: list[str] + modified: list[str] diff --git a/src/codegen/extensions/github/types/enterprise.py b/src/codegen/extensions/github/types/enterprise.py new file mode 100644 index 000000000..ed4861ad9 --- /dev/null +++ b/src/codegen/extensions/github/types/enterprise.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel + + +class GitHubEnterprise(BaseModel): + id: int + slug: str + name: str + node_id: str + avatar_url: str + description: str + website_url: str + html_url: str + created_at: str + updated_at: str diff --git a/src/codegen/extensions/github/types/events/pull_request.py b/src/codegen/extensions/github/types/events/pull_request.py new file mode 100644 index 000000000..13cae72c7 --- /dev/null +++ b/src/codegen/extensions/github/types/events/pull_request.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel + +from ..base import GitHubRepository, GitHubUser +from ..enterprise import GitHubEnterprise +from ..installation import GitHubInstallation +from ..label import GitHubLabel +from ..organization import GitHubOrganization +from ..pull_request import PullRequest + + +class PullRequestLabeledEvent(BaseModel): + action: str # Will be "labeled" + number: int + pull_request: PullRequest + label: GitHubLabel + repository: GitHubRepository + organization: GitHubOrganization + enterprise: GitHubEnterprise + sender: GitHubUser + installation: GitHubInstallation + + +class PullRequestOpenedEvent(BaseModel): + action: str = "opened" # Always "opened" for this event + number: int + pull_request: PullRequest + repository: GitHubRepository + organization: GitHubOrganization + enterprise: GitHubEnterprise + sender: GitHubUser + installation: GitHubInstallation diff --git a/src/codegen/extensions/github/types/events/push.py b/src/codegen/extensions/github/types/events/push.py new file mode 100644 index 000000000..e5202a978 --- /dev/null +++ b/src/codegen/extensions/github/types/events/push.py @@ -0,0 +1,27 @@ +from pydantic import BaseModel + +from ..base import GitHubRepository, GitHubUser +from ..commit import GitHubCommit +from ..enterprise import GitHubEnterprise +from ..installation import GitHubInstallation +from ..organization import GitHubOrganization +from ..pusher import GitHubPusher + + +class PushEvent(BaseModel): + ref: str + before: str + after: str + repository: GitHubRepository + pusher: GitHubPusher + organization: GitHubOrganization + enterprise: GitHubEnterprise + sender: GitHubUser + installation: GitHubInstallation + created: bool + deleted: bool + forced: bool + base_ref: str | None = None + compare: str + commits: list[GitHubCommit] + head_commit: GitHubCommit diff --git a/src/codegen/extensions/github/types/installation.py b/src/codegen/extensions/github/types/installation.py new file mode 100644 index 000000000..5b8e2b9cf --- /dev/null +++ b/src/codegen/extensions/github/types/installation.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class GitHubInstallation(BaseModel): + id: int + node_id: str diff --git a/src/codegen/extensions/github/types/label.py b/src/codegen/extensions/github/types/label.py new file mode 100644 index 000000000..1d91f32f9 --- /dev/null +++ b/src/codegen/extensions/github/types/label.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + + +class GitHubLabel(BaseModel): + id: int + node_id: str + url: str + name: str + color: str + default: bool + description: str | None diff --git a/src/codegen/extensions/github/types/organization.py b/src/codegen/extensions/github/types/organization.py new file mode 100644 index 000000000..56b64e950 --- /dev/null +++ b/src/codegen/extensions/github/types/organization.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel + + +class GitHubOrganization(BaseModel): + login: str + id: int + node_id: str + url: str + repos_url: str + events_url: str + hooks_url: str + issues_url: str + members_url: str + public_members_url: str + avatar_url: str + description: str diff --git a/src/codegen/extensions/github/types/pull_request.py b/src/codegen/extensions/github/types/pull_request.py new file mode 100644 index 000000000..3ad03dca1 --- /dev/null +++ b/src/codegen/extensions/github/types/pull_request.py @@ -0,0 +1,71 @@ +from typing import Optional + +from pydantic import BaseModel + +from .base import GitHubRepository, GitHubUser +from .label import GitHubLabel + + +class PullRequestRef(BaseModel): + label: str + ref: str + sha: str + user: GitHubUser + repo: GitHubRepository + + +class PullRequestLinks(BaseModel): + self: dict + html: dict + issue: dict + comments: dict + review_comments: dict + review_comment: dict + commits: dict + statuses: dict + + +class PullRequest(BaseModel): + url: str + id: int + node_id: str + html_url: str + diff_url: str + patch_url: str + issue_url: str + number: int + state: str + locked: bool + title: str + user: GitHubUser + body: Optional[str] + created_at: str + updated_at: str + closed_at: Optional[str] + merged_at: Optional[str] + merge_commit_sha: str + assignee: Optional[GitHubUser] + assignees: list[GitHubUser] + requested_reviewers: list[GitHubUser] + requested_teams: list[dict] + labels: list[GitHubLabel] + milestone: Optional[dict] + draft: bool + head: PullRequestRef + base: PullRequestRef + _links: PullRequestLinks + author_association: str + auto_merge: Optional[dict] + active_lock_reason: Optional[str] + merged: bool + mergeable: Optional[bool] + rebaseable: Optional[bool] + mergeable_state: str + merged_by: Optional[GitHubUser] + comments: int + review_comments: int + maintainer_can_modify: bool + commits: int + additions: int + deletions: int + changed_files: int diff --git a/src/codegen/extensions/github/types/push.py b/src/codegen/extensions/github/types/push.py new file mode 100644 index 000000000..10f44f5e7 --- /dev/null +++ b/src/codegen/extensions/github/types/push.py @@ -0,0 +1,29 @@ +from typing import Optional + +from pydantic import BaseModel + +from .base import GitHubRepository, GitHubUser +from .commit import GitHubCommit +from .enterprise import GitHubEnterprise +from .installation import GitHubInstallation +from .organization import GitHubOrganization +from .pusher import GitHubPusher + + +class PushEvent(BaseModel): + ref: str + before: str + after: str + repository: GitHubRepository + pusher: GitHubPusher + organization: GitHubOrganization + enterprise: GitHubEnterprise + sender: GitHubUser + installation: GitHubInstallation + created: bool + deleted: bool + forced: bool + base_ref: Optional[str] + compare: str + commits: list[GitHubCommit] + head_commit: GitHubCommit diff --git a/src/codegen/extensions/github/types/pusher.py b/src/codegen/extensions/github/types/pusher.py new file mode 100644 index 000000000..2d52056d4 --- /dev/null +++ b/src/codegen/extensions/github/types/pusher.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class GitHubPusher(BaseModel): + name: str + email: str